// 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; }