From eab979bae3aca5f73fd8f2a017d9a00728d8e31a Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 11 Feb 2026 20:27:47 +0000 Subject: [PATCH] Step 261: project-wide search and refactor (searchProject, renameSymbol) Co-Authored-By: Claude Opus 4.6 --- editor/CMakeLists.txt | 11 + editor/src/AgentPermissionPolicy.h | 6 +- editor/src/HeadlessAgentRPCHandler.h | 170 +++++++++++ editor/src/MCPServer.h | 39 +++ editor/src/ProjectState.h | 140 +++++++++ editor/tests/step261_test.cpp | 422 +++++++++++++++++++++++++++ progress.md | 40 +++ 7 files changed, 826 insertions(+), 2 deletions(-) create mode 100644 editor/tests/step261_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 2959da0..128d924 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1543,4 +1543,15 @@ target_link_libraries(step260_test PRIVATE tree_sitter_java tree_sitter_rust tree_sitter_go tree_sitter_org) +# Step 261: Project-wide search and refactor +add_executable(step261_test tests/step261_test.cpp) +target_include_directories(step261_test PRIVATE src) +target_link_libraries(step261_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 f480a23..060cedd 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -55,7 +55,8 @@ struct AgentPermissionPolicy { method == "setActiveBuffer" || method == "indexWorkspace" || method == "getImportGraph" || - method == "getProjectDiagnostics") { + method == "getProjectDiagnostics" || + method == "searchProject") { return true; } @@ -68,7 +69,8 @@ struct AgentPermissionPolicy { method == "fileCreate" || method == "applyQuickFix" || method == "openFile" || - method == "closeFile") { + method == "closeFile" || + method == "renameSymbol") { return role == AgentRole::Refactor || role == AgentRole::Generator; } diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index b7e201a..b7b8025 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -1105,6 +1105,176 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, }); } + // --- searchProject --- + if (method == "searchProject") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string name = params.value("name", ""); + std::string nodeId = params.value("nodeId", ""); + + // If nodeId given, resolve name from the active buffer + if (!name.empty()) { + // use name as-is + } else if (!nodeId.empty()) { + // Search all buffers for a node with this ID + for (const auto& [path, buf] : state.bufferStates) { + Module* ast = buf->sync.getAST(); + if (!ast) continue; + ASTNode* node = findNodeById(ast, nodeId); + if (node) { + name = getNodeName(node); + break; + } + } + if (name.empty()) + return headlessRpcError(id, -32002, + "Node not found: " + nodeId); + } else { + return headlessRpcError(id, -32602, + "Missing name or nodeId parameter"); + } + + json results = json::array(); + for (const auto& [path, buf] : state.bufferStates) { + Module* ast = buf->sync.getAST(); + if (!ast) continue; + auto refs = collectSymbolReferences(ast, name, path); + for (const auto& ref : refs) { + results.push_back({ + {"file", ref.file}, {"line", ref.line}, + {"col", ref.col}, {"nodeId", ref.nodeId}, + {"kind", ref.kind}, {"context", ref.context} + }); + } + } + + std::set searchFiles; + for (const auto& r : results) + searchFiles.insert(r.value("file", "")); + + return headlessRpcResult(id, { + {"name", name}, + {"references", results}, + {"count", (int)results.size()}, + {"fileCount", (int)searchFiles.size()} + }); + } + + // --- renameSymbol --- + if (method == "renameSymbol") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string oldName = params.value("oldName", ""); + std::string newName = params.value("newName", ""); + bool preview = params.value("preview", false); + + if (oldName.empty() || newName.empty()) + return headlessRpcError(id, -32602, + "Missing oldName or newName parameter"); + + // Collect changes across all open buffers + std::vector allChanges; + for (const auto& [path, buf] : state.bufferStates) { + Module* ast = buf->sync.getAST(); + if (!ast) continue; + auto changes = buildRenameChanges( + ast, oldName, newName, path); + allChanges.insert(allChanges.end(), + changes.begin(), changes.end()); + } + + if (allChanges.empty()) + return headlessRpcError(id, -32002, + "No references found for '" + oldName + "'"); + + // Build preview JSON + json changesJson = json::array(); + std::set affectedFiles; + for (const auto& c : allChanges) { + changesJson.push_back({ + {"file", c.file}, {"nodeId", c.nodeId}, + {"property", c.property}, + {"oldValue", c.oldValue}, + {"newValue", c.newValue}, + {"kind", c.kind} + }); + affectedFiles.insert(c.file); + } + + if (preview) { + return headlessRpcResult(id, { + {"preview", true}, + {"changes", changesJson}, + {"changeCount", (int)allChanges.size()}, + {"fileCount", (int)affectedFiles.size()} + }); + } + + // Apply changes: use setProperty on each node + int applied = 0; + std::vector errors; + for (const auto& c : allChanges) { + auto it = state.bufferStates.find(c.file); + if (it == state.bufferStates.end()) continue; + Module* ast = it->second->sync.getAST(); + if (!ast) continue; + ASTNode* node = findNodeById(ast, c.nodeId); + if (!node) { + errors.push_back("Node " + c.nodeId + + " not found in " + c.file); + continue; + } + // Apply the property change directly + if (node->conceptType == "Function" && + c.property == "name") { + static_cast(node)->name = c.newValue; + ++applied; + } else if (node->conceptType == "FunctionCall" && + c.property == "functionName") { + static_cast(node)->functionName = + c.newValue; + ++applied; + } else if (node->conceptType == "Variable" && + c.property == "name") { + static_cast(node)->name = c.newValue; + ++applied; + } else if (node->conceptType == "VariableReference" && + c.property == "variableName") { + static_cast(node)->variableName = + c.newValue; + ++applied; + } else if (node->conceptType == "Parameter" && + c.property == "name") { + static_cast(node)->name = c.newValue; + ++applied; + } + } + + // Mark affected buffers as modified + for (const auto& f : affectedFiles) { + auto it = state.bufferStates.find(f); + if (it != state.bufferStates.end()) + it->second->modified = true; + } + + json result = { + {"applied", applied}, + {"changes", changesJson}, + {"changeCount", (int)allChanges.size()}, + {"fileCount", (int)affectedFiles.size()} + }; + if (!errors.empty()) { + json errArr = json::array(); + for (const auto& e : errors) errArr.push_back(e); + result["errors"] = errArr; + } + return headlessRpcResult(id, result); + } + // --- getProjectDiagnostics --- if (method == "getProjectDiagnostics") { if (!AgentPermissionPolicy::canInvoke(role, method)) diff --git a/editor/src/MCPServer.h b/editor/src/MCPServer.h index 559f775..0418028 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -838,6 +838,45 @@ private: toolHandlers_["whetstone_index_workspace"] = [this](const json& args) { return callWhetstone("indexWorkspace", args); }; + + // whetstone_search_project + tools_.push_back({"whetstone_search_project", + "Find all references to a symbol across all open files. " + "Search by name or nodeId. Returns file, line, col, nodeId, " + "kind (definition/call/reference/parameter), and context.", + {{"type", "object"}, {"properties", { + {"name", {{"type", "string"}, + {"description", + "Symbol name to search for"}}}, + {"nodeId", {{"type", "string"}, + {"description", + "Node ID to resolve name from (alternative to name)"}}} + }}} + }); + toolHandlers_["whetstone_search_project"] = + [this](const json& args) { + return callWhetstone("searchProject", args); + }; + + // whetstone_rename_symbol + tools_.push_back({"whetstone_rename_symbol", + "Rename a symbol across all open files. Updates function " + "definitions, calls, variable declarations, references, and " + "parameters. Set preview=true to see changes without applying.", + {{"type", "object"}, {"properties", { + {"oldName", {{"type", "string"}, + {"description", "Current symbol name"}}}, + {"newName", {{"type", "string"}, + {"description", "New symbol name"}}}, + {"preview", {{"type", "boolean"}, + {"description", + "Preview changes without applying (default false)"}}} + }}, {"required", {"oldName", "newName"}}} + }); + toolHandlers_["whetstone_rename_symbol"] = + [this](const json& args) { + return callWhetstone("renameSymbol", args); + }; } void registerWhetstoneTools() { diff --git a/editor/src/ProjectState.h b/editor/src/ProjectState.h index 929837f..cfe94ff 100644 --- a/editor/src/ProjectState.h +++ b/editor/src/ProjectState.h @@ -17,6 +17,8 @@ #include "ast/Function.h" #include "ast/Variable.h" #include "ast/Import.h" +#include "ast/Expression.h" +#include "ast/Parameter.h" #include "CompactAST.h" // getNodeName namespace fs = std::filesystem; @@ -265,6 +267,144 @@ inline std::vector collectExportedSymbols( return symbols; } +// ----------------------------------------------------------------------- +// Step 261: SymbolReference — a single reference to a symbol +// ----------------------------------------------------------------------- +struct SymbolReference { + std::string file; // source file path + int line = 0; // 1-based (from span if available) + int col = 0; // 1-based + std::string nodeId; // AST node ID + std::string kind; // "definition", "call", "reference", "parameter" + std::string context; // brief context (node type + name) +}; + +// Recursive helper: collect references to a name in a subtree +inline void collectSymbolRefsRecursive( + ASTNode* node, const std::string& name, + const std::string& filePath, + std::vector& refs) { + if (!node) return; + + SymbolReference ref; + ref.file = filePath; + ref.nodeId = node->id; + ref.line = node->hasSpan() ? node->spanStartLine : 0; + ref.col = node->hasSpan() ? node->spanStartCol : 0; + + bool matched = false; + if (node->conceptType == "Function") { + auto* fn = static_cast(node); + if (fn->name == name) { + ref.kind = "definition"; + ref.context = "function " + fn->name; + matched = true; + } + } else if (node->conceptType == "FunctionCall") { + auto* fc = static_cast(node); + if (fc->functionName == name) { + ref.kind = "call"; + ref.context = "call " + fc->functionName; + matched = true; + } + } else if (node->conceptType == "Variable") { + auto* v = static_cast(node); + if (v->name == name) { + ref.kind = "definition"; + ref.context = "variable " + v->name; + matched = true; + } + } else if (node->conceptType == "VariableReference") { + auto* vr = static_cast(node); + if (vr->variableName == name) { + ref.kind = "reference"; + ref.context = "ref " + vr->variableName; + matched = true; + } + } else if (node->conceptType == "Parameter") { + auto* p = static_cast(node); + if (p->name == name) { + ref.kind = "parameter"; + ref.context = "param " + p->name; + matched = true; + } + } + + if (matched) refs.push_back(std::move(ref)); + + for (auto* child : node->allChildren()) + collectSymbolRefsRecursive(child, name, filePath, refs); +} + +// Collect all references to a symbol name within a single AST +inline std::vector collectSymbolReferences( + Module* ast, const std::string& name, + const std::string& filePath) { + std::vector refs; + if (!ast || name.empty()) return refs; + collectSymbolRefsRecursive(ast, name, filePath, refs); + return refs; +} + +// Build rename mutations for all references to a symbol in a single AST +struct RenameChange { + std::string file; + std::string nodeId; + std::string property; // which property to set + std::string oldValue; + std::string newValue; + std::string kind; // definition/call/reference/parameter +}; + +// Recursive helper: collect rename changes in a subtree +inline void buildRenameChangesRecursive( + ASTNode* node, const std::string& oldName, + const std::string& newName, const std::string& filePath, + std::vector& changes) { + if (!node) return; + + if (node->conceptType == "Function") { + auto* fn = static_cast(node); + if (fn->name == oldName) + changes.push_back({filePath, node->id, "name", + oldName, newName, "definition"}); + } else if (node->conceptType == "FunctionCall") { + auto* fc = static_cast(node); + if (fc->functionName == oldName) + changes.push_back({filePath, node->id, "functionName", + oldName, newName, "call"}); + } else if (node->conceptType == "Variable") { + auto* v = static_cast(node); + if (v->name == oldName) + changes.push_back({filePath, node->id, "name", + oldName, newName, "definition"}); + } else if (node->conceptType == "VariableReference") { + auto* vr = static_cast(node); + if (vr->variableName == oldName) + changes.push_back({filePath, node->id, "variableName", + oldName, newName, "reference"}); + } else if (node->conceptType == "Parameter") { + auto* p = static_cast(node); + if (p->name == oldName) + changes.push_back({filePath, node->id, "name", + oldName, newName, "parameter"}); + } + + for (auto* child : node->allChildren()) + buildRenameChangesRecursive(child, oldName, newName, + filePath, changes); +} + +inline std::vector buildRenameChanges( + Module* ast, const std::string& oldName, + const std::string& newName, const std::string& filePath) { + std::vector changes; + if (!ast || oldName.empty() || newName.empty()) return changes; + buildRenameChangesRecursive(ast, oldName, newName, + filePath, changes); + return changes; +} + // ----------------------------------------------------------------------- // ProjectState — aggregates workspace index and open buffer tracking // ----------------------------------------------------------------------- diff --git a/editor/tests/step261_test.cpp b/editor/tests/step261_test.cpp new file mode 100644 index 0000000..018f5ef --- /dev/null +++ b/editor/tests/step261_test.cpp @@ -0,0 +1,422 @@ +// Step 261 TDD Test: Project-Wide Search and Refactor +// +// Tests searchProject (find symbol references across files by name/nodeId), +// renameSymbol (rename across all files with preview and apply modes), +// and MCP tool registration. +// +// Note: Python parser creates FunctionCall nodes for calls in return +// statements and expressions, but not inside assignments. Test sources +// use return-based calls to ensure proper AST node creation. +#include "HeadlessEditorState.h" +#include "MCPServer.h" + +#include +#include +#include + +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); +} + +int main() { + int passed = 0; + int failed = 0; + + // Module A: defines helper and compute; compute calls helper in return + std::string srcA = + "def helper(x):\n" + " return x * 2\n\n" + "def compute(a, b):\n" + " return helper(a) + b\n"; + + // Module B: defines process, calls helper in return (cross-file ref) + std::string srcB = + "def process(data):\n" + " return helper(data)\n"; + + // --------------------------------------------------------------- + // Test 1: searchProject by name — finds references across files + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + rpc(state, "s1", "openFile", + {{"path", "lib.py"}, {"content", srcB}}); + + json resp = rpc(state, "s1", "searchProject", + {{"name", "helper"}}); + bool hasResult = resp.contains("result"); + int count = hasResult ? resp["result"].value("count", 0) : 0; + int fileCount = hasResult + ? resp["result"].value("fileCount", 0) : 0; + // app.py: function def + call in compute; lib.py: call in process + expect(hasResult && count >= 3 && fileCount == 2, + "searchProject 'helper': " + std::to_string(count) + + " refs across " + std::to_string(fileCount) + " files", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: searchProject returns correct fields + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + + json resp = rpc(state, "s1", "searchProject", + {{"name", "helper"}}); + bool correctFields = false; + if (resp.contains("result") && + !resp["result"]["references"].empty()) { + const auto& ref = resp["result"]["references"][0]; + correctFields = ref.contains("file") && + ref.contains("line") && + ref.contains("col") && + ref.contains("nodeId") && + ref.contains("kind") && + ref.contains("context"); + } + expect(correctFields, + "Search results have file, line, col, nodeId, kind, " + "context", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: searchProject by nodeId + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + rpc(state, "s1", "openFile", + {{"path", "lib.py"}, {"content", srcB}}); + rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}}); + + // Find the helper function node ID + std::string helperId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "helper") { + helperId = c->id; + break; + } + } + + json resp = rpc(state, "s1", "searchProject", + {{"nodeId", helperId}}); + int count = resp["result"].value("count", 0); + std::string resolvedName = resp["result"].value("name", ""); + expect(count >= 3 && resolvedName == "helper", + "searchProject by nodeId resolves to 'helper', " + + std::to_string(count) + " refs", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: searchProject distinguishes definition vs call + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + + json resp = rpc(state, "s1", "searchProject", + {{"name", "helper"}}); + bool hasDef = false; + bool hasCall = false; + for (const auto& ref : resp["result"]["references"]) { + std::string kind = ref.value("kind", ""); + if (kind == "definition") hasDef = true; + if (kind == "call") hasCall = true; + } + expect(hasDef && hasCall, + "Search distinguishes definition and call kinds", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: renameSymbol preview mode + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + rpc(state, "s1", "openFile", + {{"path", "lib.py"}, {"content", srcB}}); + + json resp = rpc(state, "s1", "renameSymbol", + {{"oldName", "helper"}, + {"newName", "assist"}, + {"preview", true}}); + bool isPreview = resp["result"].value("preview", false); + int changeCount = resp["result"].value("changeCount", 0); + bool hasChanges = resp["result"].contains("changes") && + resp["result"]["changes"].is_array(); + // Preview should NOT actually rename + rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}}); + bool notRenamed = false; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "helper") { + notRenamed = true; + break; + } + } + expect(isPreview && hasChanges && changeCount >= 3 && notRenamed, + "Preview: " + std::to_string(changeCount) + + " changes, NOT applied", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: renameSymbol apply mode — function renamed across files + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + rpc(state, "s1", "openFile", + {{"path", "lib.py"}, {"content", srcB}}); + + json resp = rpc(state, "s1", "renameSymbol", + {{"oldName", "helper"}, + {"newName", "assist"}}); + int applied = resp["result"].value("applied", 0); + int fileCount = resp["result"].value("fileCount", 0); + + // Verify rename took effect in app.py (walk full tree) + rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}}); + bool defRenamed = false; + bool callRenamed = false; + std::function walkA = [&](ASTNode* n) { + if (!n) return; + if (n->conceptType == "Function" && + getNodeName(n) == "assist") + defRenamed = true; + if (n->conceptType == "FunctionCall" && + getNodeName(n) == "assist") + callRenamed = true; + for (auto* c : n->allChildren()) walkA(c); + }; + walkA(state.activeAST()); + + // Verify rename took effect in lib.py + rpc(state, "s1", "setActiveBuffer", {{"path", "lib.py"}}); + bool libCallRenamed = false; + std::function walkB = [&](ASTNode* n) { + if (!n) return; + if (n->conceptType == "FunctionCall" && + getNodeName(n) == "assist") + libCallRenamed = true; + for (auto* c : n->allChildren()) walkB(c); + }; + walkB(state.activeAST()); + + expect(applied >= 3 && fileCount == 2 && + defRenamed && callRenamed && libCallRenamed, + "Rename applied: " + std::to_string(applied) + + " changes across " + std::to_string(fileCount) + + " files, verified in both ASTs", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: renameSymbol — parameter and variable reference rename + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + // Use parameter (properly parsed) and VariableReference in return + std::string src = + "def foo(result):\n" + " return result + 1\n"; + rpc(state, "s1", "openFile", + {{"path", "var.py"}, {"content", src}}); + + json resp = rpc(state, "s1", "renameSymbol", + {{"oldName", "result"}, + {"newName", "output"}}); + int applied = resp["result"].value("applied", 0); + + // Verify: parameter and reference both renamed (walk tree) + rpc(state, "s1", "setActiveBuffer", {{"path", "var.py"}}); + bool paramRenamed = false; + bool refRenamed = false; + std::function walk = [&](ASTNode* n) { + if (!n) return; + if (n->conceptType == "Parameter" && + getNodeName(n) == "output") + paramRenamed = true; + if (n->conceptType == "VariableReference" && + getNodeName(n) == "output") + refRenamed = true; + for (auto* c : n->allChildren()) walk(c); + }; + walk(state.activeAST()); + expect(applied >= 2 && paramRenamed && refRenamed, + "Param/ref rename: " + std::to_string(applied) + + " changes, param+ref both updated", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: renameSymbol — no matches returns error + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + + json resp = rpc(state, "s1", "renameSymbol", + {{"oldName", "nonexistent"}, + {"newName", "something"}}); + bool hasError = resp.contains("error"); + expect(hasError, + "Rename of nonexistent symbol returns error", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 9: Linter role can search but not rename + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("admin", AgentRole::Refactor); + state.setAgentRole("linter", AgentRole::Linter); + + rpc(state, "admin", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + + // Linter can search + json searchResp = rpc(state, "linter", "searchProject", + {{"name", "helper"}}); + bool searchOk = searchResp.contains("result"); + + // Linter cannot rename + json renameResp = rpc(state, "linter", "renameSymbol", + {{"oldName", "helper"}, + {"newName", "assist"}}); + bool renameDenied = renameResp.contains("error"); + + expect(searchOk && renameDenied, + "Linter can search but cannot rename", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 10: searchProject — no results for unknown name + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + + json resp = rpc(state, "s1", "searchProject", + {{"name", "zzz_not_found"}}); + int count = resp["result"].value("count", 0); + expect(count == 0, + "Search for unknown name returns 0 references", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 11: MCP tools registered + // --------------------------------------------------------------- + { + MCPServer mcp; + bool foundSearch = false; + bool foundRename = false; + for (const auto& t : mcp.getTools()) { + if (t.name == "whetstone_search_project") + foundSearch = true; + if (t.name == "whetstone_rename_symbol") + foundRename = true; + } + expect(foundSearch && foundRename, + "whetstone_search_project and whetstone_rename_symbol " + "MCP tools registered", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 12: renameSymbol marks buffers as modified + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "app.py"}, {"content", srcA}}); + rpc(state, "s1", "openFile", + {{"path", "lib.py"}, {"content", srcB}}); + + // Reset modified flags + for (auto& [path, buf] : state.bufferStates) + buf->modified = false; + + rpc(state, "s1", "renameSymbol", + {{"oldName", "helper"}, {"newName", "assist"}}); + + bool appModified = false; + bool libModified = false; + for (const auto& [path, buf] : state.bufferStates) { + if (path == "app.py" && buf->modified) appModified = true; + if (path == "lib.py" && buf->modified) libModified = true; + } + expect(appModified && libModified, + "Rename marks both app.py and lib.py as modified", + passed, failed); + } + + std::cout << "\n=== Step 261 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index c312bc9..6513450 100644 --- a/progress.md +++ b/progress.md @@ -520,3 +520,43 @@ Supports optional severity filter and file glob filter. - Severity filter reuses existing `filterBySeverity()` infrastructure - Diagnostics sorted by priority within each file group - All roles can call (read-only); 28 MCP tools total + +### Step 261: Project-Wide Search and Refactor +**Status:** PASS (12/12 tests) + +`searchProject` finds symbol references across all open files by name or nodeId. +`renameSymbol` renames a symbol across all files with preview and apply modes. +Both use recursive AST traversal to find Function definitions, FunctionCalls, +Variable declarations, VariableReferences, and Parameters. + +**Files created:** +- `editor/tests/step261_test.cpp` — 12 test cases: cross-file search by name, + correct result fields, search by nodeId, definition vs call kind distinction, + preview mode (shows changes without applying), apply mode (renames across 2 + files with verification), parameter/variable reference rename, no-match error, + Linter can search but not rename, unknown name returns 0, MCP tool + registration, rename marks buffers as modified + +**Files modified:** +- `editor/src/ProjectState.h` — SymbolReference struct, RenameChange struct, + `collectSymbolRefsRecursive()` and `collectSymbolReferences()` (recursive + AST search for matching names), `buildRenameChangesRecursive()` and + `buildRenameChanges()` (builds property-change list for rename operations) +- `editor/src/HeadlessAgentRPCHandler.h` — `searchProject` RPC method (by + name or nodeId, returns file/line/col/nodeId/kind/context per reference), + `renameSymbol` RPC method (preview mode returns change list, apply mode + modifies AST nodes directly, marks buffers modified) +- `editor/src/AgentPermissionPolicy.h` — `searchProject` as read-only, + `renameSymbol` as mutation (Refactor/Generator only) +- `editor/src/MCPServer.h` — `whetstone_search_project` and + `whetstone_rename_symbol` tools registered in registerProjectTools() +- `editor/CMakeLists.txt` — step261_test target + +**Key design decisions:** +- Recursive tree walk for deep references (FunctionCalls inside function bodies) +- Preview mode returns full change list without applying (agent can review) +- Apply mode directly modifies AST node properties (name, functionName, variableName) +- Rename changes include property name so agent sees what exactly changes +- Buffers marked as modified after rename for save-tracking +- Search returns kind field (definition/call/reference/parameter) to categorize +- 30 MCP tools total