Step 258: project model and workspace indexing (Phase 9d start)
Adds multi-buffer management via openFile/closeFile/listBuffers/setActiveBuffer RPC methods, plus indexWorkspace for scanning workspace files without opening them. Language auto-detected from extension. 27 MCP tools total (was 22). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1510,4 +1510,15 @@ target_link_libraries(step257_test PRIVATE
|
||||
tree_sitter_java tree_sitter_rust tree_sitter_go
|
||||
tree_sitter_org)
|
||||
|
||||
# Step 258: Project model and workspace indexing
|
||||
add_executable(step258_test tests/step258_test.cpp)
|
||||
target_include_directories(step258_test PRIVATE src)
|
||||
target_link_libraries(step258_test PRIVATE
|
||||
nlohmann_json::nlohmann_json
|
||||
unofficial::tree-sitter::tree-sitter
|
||||
tree_sitter_python tree_sitter_cpp tree_sitter_elisp
|
||||
tree_sitter_javascript tree_sitter_typescript
|
||||
tree_sitter_java tree_sitter_rust tree_sitter_go
|
||||
tree_sitter_org)
|
||||
|
||||
# Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies)
|
||||
|
||||
@@ -50,7 +50,10 @@ struct AgentPermissionPolicy {
|
||||
method == "getDiagnostics" ||
|
||||
method == "getDiagnosticsDelta" ||
|
||||
method == "getQuickFixes" ||
|
||||
method == "batchQuery") {
|
||||
method == "batchQuery" ||
|
||||
method == "listBuffers" ||
|
||||
method == "setActiveBuffer" ||
|
||||
method == "indexWorkspace") {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,7 +64,9 @@ struct AgentPermissionPolicy {
|
||||
method == "applyBatch" ||
|
||||
method == "fileWrite" ||
|
||||
method == "fileCreate" ||
|
||||
method == "applyQuickFix") {
|
||||
method == "applyQuickFix" ||
|
||||
method == "openFile" ||
|
||||
method == "closeFile") {
|
||||
return role == AgentRole::Refactor || role == AgentRole::Generator;
|
||||
}
|
||||
|
||||
|
||||
@@ -932,6 +932,114 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
return headlessRpcResult(id, {{"files", files}});
|
||||
}
|
||||
|
||||
// --- openFile ---
|
||||
if (method == "openFile") {
|
||||
if (!AgentPermissionPolicy::canInvoke(role, method))
|
||||
return headlessRpcError(id, -32031, "Role not permitted");
|
||||
auto params = request.contains("params") ? request["params"]
|
||||
: json::object();
|
||||
std::string path = params.value("path", "");
|
||||
if (path.empty())
|
||||
return headlessRpcError(id, -32602, "Missing path parameter");
|
||||
std::string content = params.value("content", "");
|
||||
std::string language = params.value("language", "");
|
||||
// Auto-detect language from extension if not specified
|
||||
if (language.empty())
|
||||
language = detectLanguage(path);
|
||||
if (language.empty())
|
||||
language = state.defaultLanguage;
|
||||
// Read from disk if no content provided and workspace is set
|
||||
if (content.empty() && !state.workspaceRoot.empty()) {
|
||||
auto [ok, resolved] =
|
||||
fileOpsResolvePath(state.workspaceRoot, path);
|
||||
if (ok) {
|
||||
auto [rok, fileContent, lc] = fileOpsRead(resolved);
|
||||
if (rok) content = fileContent;
|
||||
path = resolved;
|
||||
}
|
||||
}
|
||||
auto* buf = state.openBuffer(path, content, language);
|
||||
if (!buf)
|
||||
return headlessRpcError(id, -32041, "Failed to open buffer");
|
||||
return headlessRpcResult(id, {
|
||||
{"path", path}, {"language", language},
|
||||
{"bufferCount", (int)state.bufferStates.size()}
|
||||
});
|
||||
}
|
||||
|
||||
// --- closeFile ---
|
||||
if (method == "closeFile") {
|
||||
if (!AgentPermissionPolicy::canInvoke(role, method))
|
||||
return headlessRpcError(id, -32031, "Role not permitted");
|
||||
auto params = request.contains("params") ? request["params"]
|
||||
: json::object();
|
||||
std::string path = params.value("path", "");
|
||||
if (path.empty())
|
||||
return headlessRpcError(id, -32602, "Missing path parameter");
|
||||
auto it = state.bufferStates.find(path);
|
||||
if (it == state.bufferStates.end())
|
||||
return headlessRpcError(id, -32002, "Buffer not found: " + path);
|
||||
state.closeBuffer(path);
|
||||
return headlessRpcResult(id, {
|
||||
{"closed", path},
|
||||
{"bufferCount", (int)state.bufferStates.size()}
|
||||
});
|
||||
}
|
||||
|
||||
// --- listBuffers ---
|
||||
if (method == "listBuffers") {
|
||||
json buffers = json::array();
|
||||
for (const auto& [path, buf] : state.bufferStates) {
|
||||
bool isActive = (buf.get() == state.activeBuffer);
|
||||
buffers.push_back({
|
||||
{"path", buf->path},
|
||||
{"language", buf->language},
|
||||
{"modified", buf->modified},
|
||||
{"active", isActive}
|
||||
});
|
||||
}
|
||||
return headlessRpcResult(id, {
|
||||
{"buffers", buffers},
|
||||
{"count", (int)buffers.size()},
|
||||
{"activeBuffer", state.activeBuffer
|
||||
? state.activeBuffer->path : ""}
|
||||
});
|
||||
}
|
||||
|
||||
// --- setActiveBuffer ---
|
||||
if (method == "setActiveBuffer") {
|
||||
auto params = request.contains("params") ? request["params"]
|
||||
: json::object();
|
||||
std::string path = params.value("path", "");
|
||||
if (path.empty())
|
||||
return headlessRpcError(id, -32602, "Missing path parameter");
|
||||
if (!state.setActiveBuffer(path))
|
||||
return headlessRpcError(id, -32002, "Buffer not found: " + path);
|
||||
return headlessRpcResult(id, {
|
||||
{"activeBuffer", path},
|
||||
{"language", state.activeBuffer->language}
|
||||
});
|
||||
}
|
||||
|
||||
// --- indexWorkspace ---
|
||||
if (method == "indexWorkspace") {
|
||||
auto params = request.contains("params") ? request["params"]
|
||||
: json::object();
|
||||
std::string root = params.value("root", state.workspaceRoot);
|
||||
if (root.empty())
|
||||
return headlessRpcError(id, -32602,
|
||||
"No workspace root (set via --workspace or root param)");
|
||||
state.project.scanWorkspace(root);
|
||||
state.workspaceRoot = root;
|
||||
const auto& idx = state.project.index;
|
||||
return headlessRpcResult(id, {
|
||||
{"root", root},
|
||||
{"fileCount", idx.fileCount()},
|
||||
{"dirCount", idx.dirCount()},
|
||||
{"totalEntries", (int)idx.files().size()}
|
||||
});
|
||||
}
|
||||
|
||||
// --- batchQuery ---
|
||||
if (method == "batchQuery") {
|
||||
auto params = request.contains("params") ? request["params"]
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
#include "EditorModePolicy.h"
|
||||
#include "PrimitivesRegistry.h"
|
||||
#include "SemanticTags.h"
|
||||
#include "ProjectState.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
@@ -87,6 +88,7 @@ struct HeadlessEditorState {
|
||||
HeadlessBufferState* activeBuffer = nullptr;
|
||||
HeadlessAgentState agent;
|
||||
HeadlessLibraryState library;
|
||||
ProjectState project;
|
||||
std::string workspaceRoot;
|
||||
std::string defaultLanguage = "python";
|
||||
bool verbose = false;
|
||||
|
||||
@@ -749,11 +749,82 @@ private:
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Project management tools
|
||||
// ---------------------------------------------------------------
|
||||
void registerProjectTools() {
|
||||
// whetstone_open_file
|
||||
tools_.push_back({"whetstone_open_file",
|
||||
"Open a file as a buffer for AST analysis. Reads from disk "
|
||||
"if content not provided. Language auto-detected from extension.",
|
||||
{{"type", "object"}, {"properties", {
|
||||
{"path", {{"type", "string"},
|
||||
{"description", "File path (relative to workspace or absolute)"}}},
|
||||
{"content", {{"type", "string"},
|
||||
{"description", "File content (optional, reads from disk if omitted)"}}},
|
||||
{"language", {{"type", "string"},
|
||||
{"description", "Language (optional, auto-detected from extension)"}}}
|
||||
}}, {"required", {"path"}}}
|
||||
});
|
||||
toolHandlers_["whetstone_open_file"] = [this](const json& args) {
|
||||
return callWhetstone("openFile", args);
|
||||
};
|
||||
|
||||
// whetstone_close_file
|
||||
tools_.push_back({"whetstone_close_file",
|
||||
"Close an open buffer, removing it from the project.",
|
||||
{{"type", "object"}, {"properties", {
|
||||
{"path", {{"type", "string"},
|
||||
{"description", "Path of the buffer to close"}}}
|
||||
}}, {"required", {"path"}}}
|
||||
});
|
||||
toolHandlers_["whetstone_close_file"] = [this](const json& args) {
|
||||
return callWhetstone("closeFile", args);
|
||||
};
|
||||
|
||||
// whetstone_list_buffers
|
||||
tools_.push_back({"whetstone_list_buffers",
|
||||
"List all open buffers with language, modified status, and "
|
||||
"which buffer is currently active.",
|
||||
{{"type", "object"}, {"properties", json::object()}}
|
||||
});
|
||||
toolHandlers_["whetstone_list_buffers"] = [this](const json& args) {
|
||||
return callWhetstone("listBuffers", args);
|
||||
};
|
||||
|
||||
// whetstone_set_active_buffer
|
||||
tools_.push_back({"whetstone_set_active_buffer",
|
||||
"Switch the active buffer. Subsequent getAST, getDiagnostics, "
|
||||
"etc. will operate on this buffer.",
|
||||
{{"type", "object"}, {"properties", {
|
||||
{"path", {{"type", "string"},
|
||||
{"description", "Path of the buffer to activate"}}}
|
||||
}}, {"required", {"path"}}}
|
||||
});
|
||||
toolHandlers_["whetstone_set_active_buffer"] = [this](const json& args) {
|
||||
return callWhetstone("setActiveBuffer", args);
|
||||
};
|
||||
|
||||
// whetstone_index_workspace
|
||||
tools_.push_back({"whetstone_index_workspace",
|
||||
"Scan the workspace directory and index all file paths. "
|
||||
"Returns file and directory counts. Does not open files.",
|
||||
{{"type", "object"}, {"properties", {
|
||||
{"root", {{"type", "string"},
|
||||
{"description", "Workspace root (optional, uses --workspace default)"}}}
|
||||
}}}
|
||||
});
|
||||
toolHandlers_["whetstone_index_workspace"] = [this](const json& args) {
|
||||
return callWhetstone("indexWorkspace", args);
|
||||
};
|
||||
}
|
||||
|
||||
void registerWhetstoneTools() {
|
||||
registerASTTools();
|
||||
registerAnnotationTools();
|
||||
registerFileTools();
|
||||
registerDiagnosticTools();
|
||||
registerBatchTools();
|
||||
registerProjectTools();
|
||||
}
|
||||
};
|
||||
|
||||
148
editor/src/ProjectState.h
Normal file
148
editor/src/ProjectState.h
Normal file
@@ -0,0 +1,148 @@
|
||||
#pragma once
|
||||
// Step 258: Project Model and Workspace Indexing
|
||||
//
|
||||
// Tracks workspace files, detects languages, and provides a project-level
|
||||
// index of file paths (not content) for fast lookup. Works with
|
||||
// HeadlessEditorState to manage multiple open buffers.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <filesystem>
|
||||
#include "FileTree.h"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Language detection from file extension
|
||||
// -----------------------------------------------------------------------
|
||||
inline std::string detectLanguage(const std::string& filePath) {
|
||||
std::string ext = fs::path(filePath).extension().string();
|
||||
if (ext == ".py") return "python";
|
||||
if (ext == ".cpp" || ext == ".cc" ||
|
||||
ext == ".cxx" || ext == ".h" ||
|
||||
ext == ".hpp" || ext == ".hxx") return "cpp";
|
||||
if (ext == ".rs") return "rust";
|
||||
if (ext == ".go") return "go";
|
||||
if (ext == ".java") return "java";
|
||||
if (ext == ".js" || ext == ".mjs") return "javascript";
|
||||
if (ext == ".ts" || ext == ".tsx") return "typescript";
|
||||
if (ext == ".el" || ext == ".elisp") return "elisp";
|
||||
if (ext == ".org") return "org";
|
||||
return "";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WorkspaceIndex — file path index (not content) for fast lookup
|
||||
// -----------------------------------------------------------------------
|
||||
struct IndexedFile {
|
||||
std::string path; // absolute path
|
||||
std::string relativePath; // relative to workspace root
|
||||
std::string language; // detected from extension
|
||||
bool isDir = false;
|
||||
};
|
||||
|
||||
class WorkspaceIndex {
|
||||
public:
|
||||
void scan(const std::string& workspaceRoot) {
|
||||
files_.clear();
|
||||
root_ = workspaceRoot;
|
||||
if (root_.empty() || !fs::exists(root_)) return;
|
||||
|
||||
FileTree tree;
|
||||
FileNode rootNode = tree.build(root_);
|
||||
collectFiles(rootNode);
|
||||
}
|
||||
|
||||
const std::vector<IndexedFile>& files() const { return files_; }
|
||||
|
||||
// Find files matching a glob-like pattern (extension filter)
|
||||
std::vector<IndexedFile> findByExtension(
|
||||
const std::string& ext) const {
|
||||
std::vector<IndexedFile> result;
|
||||
for (const auto& f : files_) {
|
||||
if (!f.isDir &&
|
||||
fs::path(f.path).extension().string() == ext)
|
||||
result.push_back(f);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Find files by language
|
||||
std::vector<IndexedFile> findByLanguage(
|
||||
const std::string& lang) const {
|
||||
std::vector<IndexedFile> result;
|
||||
for (const auto& f : files_) {
|
||||
if (!f.isDir && f.language == lang)
|
||||
result.push_back(f);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Lookup by relative path
|
||||
const IndexedFile* findByRelativePath(
|
||||
const std::string& relPath) const {
|
||||
auto it = byRelPath_.find(relPath);
|
||||
if (it != byRelPath_.end()) return &files_[it->second];
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int fileCount() const {
|
||||
int count = 0;
|
||||
for (const auto& f : files_)
|
||||
if (!f.isDir) ++count;
|
||||
return count;
|
||||
}
|
||||
|
||||
int dirCount() const {
|
||||
int count = 0;
|
||||
for (const auto& f : files_)
|
||||
if (f.isDir) ++count;
|
||||
return count;
|
||||
}
|
||||
|
||||
const std::string& root() const { return root_; }
|
||||
|
||||
private:
|
||||
std::string root_;
|
||||
std::vector<IndexedFile> files_;
|
||||
std::map<std::string, size_t> byRelPath_;
|
||||
|
||||
void collectFiles(const FileNode& node) {
|
||||
if (node.path == root_) {
|
||||
for (const auto& child : node.children)
|
||||
collectFiles(child);
|
||||
return;
|
||||
}
|
||||
|
||||
IndexedFile entry;
|
||||
entry.path = node.path;
|
||||
entry.isDir = node.isDir;
|
||||
std::error_code ec;
|
||||
entry.relativePath = fs::relative(
|
||||
fs::path(node.path), fs::path(root_), ec).string();
|
||||
if (!node.isDir)
|
||||
entry.language = detectLanguage(node.path);
|
||||
|
||||
size_t idx = files_.size();
|
||||
files_.push_back(entry);
|
||||
byRelPath_[entry.relativePath] = idx;
|
||||
|
||||
if (node.isDir) {
|
||||
for (const auto& child : node.children)
|
||||
collectFiles(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ProjectState — aggregates workspace index and open buffer tracking
|
||||
// -----------------------------------------------------------------------
|
||||
struct ProjectState {
|
||||
WorkspaceIndex index;
|
||||
|
||||
void scanWorkspace(const std::string& workspaceRoot) {
|
||||
index.scan(workspaceRoot);
|
||||
}
|
||||
};
|
||||
383
editor/tests/step258_test.cpp
Normal file
383
editor/tests/step258_test.cpp
Normal file
@@ -0,0 +1,383 @@
|
||||
// Step 258 TDD Test: Project Model and Workspace Indexing
|
||||
//
|
||||
// Tests openFile/closeFile/listBuffers/setActiveBuffer RPC methods,
|
||||
// workspace indexing, language detection, and MCP tool registration.
|
||||
#include "HeadlessEditorState.h"
|
||||
#include "CompactAST.h"
|
||||
#include "MCPBridge.h"
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static void expect(bool cond, const std::string& name,
|
||||
int& passed, int& failed) {
|
||||
if (cond) {
|
||||
std::cout << "Test " << (passed + failed + 1)
|
||||
<< " PASS: " << name << "\n";
|
||||
++passed;
|
||||
} else {
|
||||
std::cout << "Test " << (passed + failed + 1)
|
||||
<< " FAIL: " << name << "\n";
|
||||
++failed;
|
||||
}
|
||||
}
|
||||
|
||||
static json rpc(HeadlessEditorState& state, const std::string& session,
|
||||
const std::string& method,
|
||||
json params = json::object()) {
|
||||
json request = {{"jsonrpc", "2.0"}, {"id", 1},
|
||||
{"method", method}, {"params", params}};
|
||||
return state.processAgentRequest(request, session);
|
||||
}
|
||||
|
||||
// Create a temp workspace with some files for indexing tests
|
||||
struct TempWorkspace {
|
||||
std::string root;
|
||||
TempWorkspace() {
|
||||
root = (fs::temp_directory_path() / "whetstone_test_ws_258").string();
|
||||
fs::create_directories(root);
|
||||
fs::create_directories(root + "/src");
|
||||
fs::create_directories(root + "/tests");
|
||||
writeFile("src/main.py",
|
||||
"def main():\n print('hello')\n");
|
||||
writeFile("src/utils.py",
|
||||
"def helper(x):\n return x * 2\n");
|
||||
writeFile("src/app.cpp",
|
||||
"#include <iostream>\nint main() { return 0; }\n");
|
||||
writeFile("tests/test_main.py",
|
||||
"def test_main():\n assert True\n");
|
||||
writeFile("README.md", "# Test Project\n");
|
||||
}
|
||||
~TempWorkspace() {
|
||||
std::error_code ec;
|
||||
fs::remove_all(root, ec);
|
||||
}
|
||||
void writeFile(const std::string& rel, const std::string& content) {
|
||||
std::string full = root + "/" + rel;
|
||||
fs::create_directories(fs::path(full).parent_path());
|
||||
std::ofstream f(full);
|
||||
f << content;
|
||||
}
|
||||
};
|
||||
|
||||
int main() {
|
||||
int passed = 0;
|
||||
int failed = 0;
|
||||
|
||||
std::string pySrc =
|
||||
"def greet(name):\n return 'hello ' + name\n";
|
||||
std::string pySrc2 =
|
||||
"def compute(a, b):\n return a + b\n";
|
||||
std::string cppSrc =
|
||||
"#include <iostream>\nint main() { return 0; }\n";
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 1: openFile creates a buffer and parses AST
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
json resp = rpc(state, "s1", "openFile",
|
||||
{{"path", "greet.py"},
|
||||
{"content", pySrc},
|
||||
{"language", "python"}});
|
||||
bool hasPath = resp.contains("result") &&
|
||||
resp["result"].value("path", "") == "greet.py";
|
||||
int bufCount = resp["result"].value("bufferCount", 0);
|
||||
expect(hasPath && bufCount == 1,
|
||||
"openFile creates buffer (bufferCount=1)",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 2: Open 3 files, listBuffers returns all 3
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "a.py"}, {"content", pySrc}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "b.py"}, {"content", pySrc2}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "c.cpp"}, {"content", cppSrc},
|
||||
{"language", "cpp"}});
|
||||
|
||||
json resp = rpc(state, "s1", "listBuffers");
|
||||
int count = resp["result"].value("count", 0);
|
||||
bool hasBuffers = resp["result"].contains("buffers") &&
|
||||
resp["result"]["buffers"].is_array();
|
||||
expect(count == 3 && hasBuffers,
|
||||
"listBuffers returns 3 buffers after opening 3 files",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 3: setActiveBuffer switches which buffer getAST reads
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "a.py"}, {"content", pySrc}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "b.py"}, {"content", pySrc2}});
|
||||
|
||||
// Active should be "a.py" (first opened)
|
||||
json ast1 = rpc(state, "s1", "getAST", {{"compact", true}});
|
||||
|
||||
// Switch to b.py
|
||||
json switchResp = rpc(state, "s1", "setActiveBuffer",
|
||||
{{"path", "b.py"}});
|
||||
std::string activePath =
|
||||
switchResp["result"].value("activeBuffer", "");
|
||||
|
||||
json ast2 = rpc(state, "s1", "getAST", {{"compact", true}});
|
||||
|
||||
// The two ASTs should differ (different source code)
|
||||
std::string ast1Dump = ast1["result"].dump();
|
||||
std::string ast2Dump = ast2["result"].dump();
|
||||
expect(activePath == "b.py" && ast1Dump != ast2Dump,
|
||||
"setActiveBuffer switches AST context",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 4: closeFile removes buffer from list
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "a.py"}, {"content", pySrc}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "b.py"}, {"content", pySrc2}});
|
||||
|
||||
json closeResp = rpc(state, "s1", "closeFile",
|
||||
{{"path", "a.py"}});
|
||||
int remaining = closeResp["result"].value("bufferCount", 0);
|
||||
|
||||
json listResp = rpc(state, "s1", "listBuffers");
|
||||
int count = listResp["result"].value("count", 0);
|
||||
expect(remaining == 1 && count == 1,
|
||||
"closeFile removes buffer (1 remaining)",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 5: Language auto-detection from file extension
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
json resp = rpc(state, "s1", "openFile",
|
||||
{{"path", "app.cpp"}, {"content", cppSrc}});
|
||||
std::string lang = resp["result"].value("language", "");
|
||||
expect(lang == "cpp",
|
||||
"Language auto-detected as 'cpp' for .cpp file",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 6: listBuffers shows active flag correctly
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "a.py"}, {"content", pySrc}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "b.py"}, {"content", pySrc2}});
|
||||
rpc(state, "s1", "setActiveBuffer", {{"path", "b.py"}});
|
||||
|
||||
json resp = rpc(state, "s1", "listBuffers");
|
||||
bool aActive = false, bActive = false;
|
||||
for (const auto& buf : resp["result"]["buffers"]) {
|
||||
if (buf.value("path", "") == "a.py")
|
||||
aActive = buf.value("active", false);
|
||||
if (buf.value("path", "") == "b.py")
|
||||
bActive = buf.value("active", false);
|
||||
}
|
||||
expect(!aActive && bActive,
|
||||
"listBuffers active flag: a=false, b=true",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 7: Workspace indexing scans files
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
TempWorkspace ws;
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
json resp = rpc(state, "s1", "indexWorkspace",
|
||||
{{"root", ws.root}});
|
||||
int fileCount = resp["result"].value("fileCount", 0);
|
||||
int dirCount = resp["result"].value("dirCount", 0);
|
||||
// 5 files: main.py, utils.py, app.cpp, test_main.py, README.md
|
||||
// 2 dirs: src, tests
|
||||
expect(fileCount == 5 && dirCount == 2,
|
||||
"indexWorkspace finds 5 files, 2 dirs",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 8: openFile reads from disk when no content provided
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
TempWorkspace ws;
|
||||
HeadlessEditorState state;
|
||||
state.workspaceRoot = ws.root;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
json resp = rpc(state, "s1", "openFile",
|
||||
{{"path", "src/main.py"}});
|
||||
bool success = resp.contains("result") &&
|
||||
!resp.contains("error");
|
||||
|
||||
// Verify AST was parsed from disk content
|
||||
json ast = rpc(state, "s1", "getAST", {{"compact", true}});
|
||||
bool hasNodes = ast.contains("result") &&
|
||||
(ast["result"].contains("nodes") ||
|
||||
ast["result"].contains("ast"));
|
||||
expect(success && hasNodes,
|
||||
"openFile reads from disk and parses AST",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 9: closeFile on nonexistent buffer returns error
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
json resp = rpc(state, "s1", "closeFile",
|
||||
{{"path", "nonexistent.py"}});
|
||||
bool hasError = resp.contains("error");
|
||||
expect(hasError,
|
||||
"closeFile on nonexistent buffer returns error",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 10: Linter role cannot openFile (mutation)
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Linter);
|
||||
|
||||
json resp = rpc(state, "s1", "openFile",
|
||||
{{"path", "test.py"}, {"content", pySrc}});
|
||||
bool denied = resp.contains("error");
|
||||
expect(denied,
|
||||
"Linter role denied openFile (requires Refactor)",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 11: MCP tools registered for project operations
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.openBuffer("test.py", pySrc, "python");
|
||||
state.setAgentRole("mcp-session", AgentRole::Refactor);
|
||||
|
||||
MCPBridge bridge;
|
||||
bridge.setRequestHandler([&state](const json& request) -> json {
|
||||
return state.processAgentRequest(request, "mcp-session");
|
||||
});
|
||||
|
||||
json initReq = {{"jsonrpc", "2.0"}, {"id", 1},
|
||||
{"method", "initialize"},
|
||||
{"params", {{"protocolVersion", "2024-11-05"},
|
||||
{"clientInfo", {{"name", "test"}}}}}};
|
||||
bridge.processMessage(initReq.dump());
|
||||
|
||||
json toolsReq = {{"jsonrpc", "2.0"}, {"id", 2},
|
||||
{"method", "tools/list"}};
|
||||
std::string toolsResp = bridge.processMessage(toolsReq.dump());
|
||||
json toolsJson = json::parse(toolsResp);
|
||||
|
||||
std::set<std::string> expected = {
|
||||
"whetstone_open_file", "whetstone_close_file",
|
||||
"whetstone_list_buffers", "whetstone_set_active_buffer",
|
||||
"whetstone_index_workspace"
|
||||
};
|
||||
std::set<std::string> found;
|
||||
for (const auto& t : toolsJson["result"]["tools"]) {
|
||||
std::string name = t.value("name", "");
|
||||
if (expected.count(name)) found.insert(name);
|
||||
}
|
||||
int totalTools = (int)toolsJson["result"]["tools"].size();
|
||||
expect(found.size() == 5 && totalTools == 27,
|
||||
"5 project tools registered (" +
|
||||
std::to_string(totalTools) + " total tools)",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 12: Full workflow — open, switch, query, close
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
// Open two files
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "greet.py"}, {"content", pySrc}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "math.py"}, {"content", pySrc2}});
|
||||
|
||||
// Get AST from first (active)
|
||||
json ast1 = rpc(state, "s1", "getAST", {{"compact", true}});
|
||||
int nodes1 = ast1["result"].value("nodeCount", 0);
|
||||
|
||||
// Switch and get AST from second
|
||||
rpc(state, "s1", "setActiveBuffer", {{"path", "math.py"}});
|
||||
json ast2 = rpc(state, "s1", "getAST", {{"compact", true}});
|
||||
int nodes2 = ast2["result"].value("nodeCount", 0);
|
||||
|
||||
// Both should have nodes
|
||||
bool bothHaveNodes = nodes1 > 0 && nodes2 > 0;
|
||||
|
||||
// Close first, verify active stays on second
|
||||
rpc(state, "s1", "closeFile", {{"path", "greet.py"}});
|
||||
json list = rpc(state, "s1", "listBuffers");
|
||||
int remaining = list["result"].value("count", 0);
|
||||
std::string active = list["result"].value("activeBuffer", "");
|
||||
|
||||
expect(bothHaveNodes && remaining == 1 && active == "math.py",
|
||||
"Full workflow: open→switch→query→close " +
|
||||
std::to_string(nodes1) + "/" + std::to_string(nodes2) +
|
||||
" nodes, 1 remaining",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
std::cout << "\n=== Step 258 Results: " << passed << " passed, "
|
||||
<< failed << " failed ===\n";
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
44
progress.md
44
progress.md
@@ -401,3 +401,47 @@ compact/lean/delta/budget/batch features, plus throughput benchmarking.
|
||||
- Combined optimized query path: 95% total token savings vs naive approach
|
||||
- Compact ratio stable across 10/50/200 function modules (1% spread)
|
||||
- Throughput: 0ms/cycle for parse→mutate→diagnose (sub-millisecond)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9d: Multi-File Project Support
|
||||
|
||||
### Step 258: Project Model and Workspace Indexing
|
||||
**Status:** PASS (12/12 tests)
|
||||
|
||||
HeadlessEditorState gains project awareness: `openFile`, `closeFile`,
|
||||
`listBuffers`, `setActiveBuffer` RPC methods for multi-buffer management,
|
||||
plus `indexWorkspace` for workspace-level file scanning. Language
|
||||
auto-detected from file extension.
|
||||
|
||||
**Files created:**
|
||||
- `editor/src/ProjectState.h` — WorkspaceIndex (scan, findByExtension,
|
||||
findByLanguage, findByRelativePath, fileCount/dirCount), detectLanguage,
|
||||
IndexedFile struct, ProjectState wrapper
|
||||
- `editor/tests/step258_test.cpp` — 12 test cases: openFile buffer creation,
|
||||
listBuffers with 3 files, setActiveBuffer switches AST context, closeFile
|
||||
removes buffer, language auto-detection, active flag in listBuffers,
|
||||
workspace indexing (5 files, 2 dirs), openFile reads from disk, closeFile
|
||||
error on nonexistent, Linter permission denied, MCP tool registration
|
||||
(5 new tools, 27 total), full open→switch→query→close workflow
|
||||
|
||||
**Files modified:**
|
||||
- `editor/src/HeadlessEditorState.h` — include ProjectState.h, add
|
||||
ProjectState member
|
||||
- `editor/src/HeadlessAgentRPCHandler.h` — openFile, closeFile, listBuffers,
|
||||
setActiveBuffer, indexWorkspace RPC methods
|
||||
- `editor/src/AgentPermissionPolicy.h` — listBuffers/setActiveBuffer/
|
||||
indexWorkspace read-only; openFile/closeFile require Refactor/Generator
|
||||
- `editor/src/MCPServer.h` — registerProjectTools() with 5 MCP tool
|
||||
definitions (whetstone_open_file, whetstone_close_file,
|
||||
whetstone_list_buffers, whetstone_set_active_buffer,
|
||||
whetstone_index_workspace)
|
||||
- `editor/CMakeLists.txt` — step258_test target
|
||||
|
||||
**Key design decisions:**
|
||||
- openFile auto-detects language from extension (detectLanguage)
|
||||
- openFile reads from disk if no content provided (uses fileOpsResolvePath)
|
||||
- listBuffers includes path, language, modified, and active flag per buffer
|
||||
- indexWorkspace scans using FileTree (respects .gitignore) without opening files
|
||||
- WorkspaceIndex provides findByExtension/findByLanguage for agent queries
|
||||
- tools/list now returns 27 tools (was 22): +5 project management tools
|
||||
|
||||
Reference in New Issue
Block a user