From ac5b7e21c8001386b8956ae82cf5cedecca811d7 Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 11 Feb 2026 19:43:53 +0000 Subject: [PATCH] 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 --- editor/CMakeLists.txt | 11 + editor/src/AgentPermissionPolicy.h | 9 +- editor/src/HeadlessAgentRPCHandler.h | 108 ++++++++ editor/src/HeadlessEditorState.h | 2 + editor/src/MCPServer.h | 71 +++++ editor/src/ProjectState.h | 148 +++++++++++ editor/tests/step258_test.cpp | 383 +++++++++++++++++++++++++++ progress.md | 44 +++ 8 files changed, 774 insertions(+), 2 deletions(-) create mode 100644 editor/src/ProjectState.h create mode 100644 editor/tests/step258_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 8e14f06..24ba1c9 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -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) diff --git a/editor/src/AgentPermissionPolicy.h b/editor/src/AgentPermissionPolicy.h index 94cef43..dca27d5 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -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; } diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index 3d5a6bd..8a4d7e3 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -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"] diff --git a/editor/src/HeadlessEditorState.h b/editor/src/HeadlessEditorState.h index 6eb1296..a6b0391 100644 --- a/editor/src/HeadlessEditorState.h +++ b/editor/src/HeadlessEditorState.h @@ -33,6 +33,7 @@ #include "EditorModePolicy.h" #include "PrimitivesRegistry.h" #include "SemanticTags.h" +#include "ProjectState.h" #include #include @@ -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; diff --git a/editor/src/MCPServer.h b/editor/src/MCPServer.h index 2620fea..c155b0d 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -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(); } }; diff --git a/editor/src/ProjectState.h b/editor/src/ProjectState.h new file mode 100644 index 0000000..5e436d5 --- /dev/null +++ b/editor/src/ProjectState.h @@ -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 +#include +#include +#include +#include +#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& files() const { return files_; } + + // Find files matching a glob-like pattern (extension filter) + std::vector findByExtension( + const std::string& ext) const { + std::vector 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 findByLanguage( + const std::string& lang) const { + std::vector 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 files_; + std::map 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); + } +}; diff --git a/editor/tests/step258_test.cpp b/editor/tests/step258_test.cpp new file mode 100644 index 0000000..2f793f9 --- /dev/null +++ b/editor/tests/step258_test.cpp @@ -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 +#include +#include +#include + +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 \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 \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 expected = { + "whetstone_open_file", "whetstone_close_file", + "whetstone_list_buffers", "whetstone_set_active_buffer", + "whetstone_index_workspace" + }; + std::set 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; +} diff --git a/progress.md b/progress.md index 883a737..c618f90 100644 --- a/progress.md +++ b/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