Step 261: project-wide search and refactor (searchProject, renameSymbol)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bill
2026-02-11 20:27:47 +00:00
parent 8a461df47e
commit eab979bae3
7 changed files with 826 additions and 2 deletions

View File

@@ -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)

View File

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

View File

@@ -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<std::string> 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<RenameChange> 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<std::string> 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<std::string> 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<Function*>(node)->name = c.newValue;
++applied;
} else if (node->conceptType == "FunctionCall" &&
c.property == "functionName") {
static_cast<FunctionCall*>(node)->functionName =
c.newValue;
++applied;
} else if (node->conceptType == "Variable" &&
c.property == "name") {
static_cast<Variable*>(node)->name = c.newValue;
++applied;
} else if (node->conceptType == "VariableReference" &&
c.property == "variableName") {
static_cast<VariableReference*>(node)->variableName =
c.newValue;
++applied;
} else if (node->conceptType == "Parameter" &&
c.property == "name") {
static_cast<Parameter*>(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))

View File

@@ -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() {

View File

@@ -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<ExportedSymbol> 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<SymbolReference>& 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<const Function*>(node);
if (fn->name == name) {
ref.kind = "definition";
ref.context = "function " + fn->name;
matched = true;
}
} else if (node->conceptType == "FunctionCall") {
auto* fc = static_cast<const FunctionCall*>(node);
if (fc->functionName == name) {
ref.kind = "call";
ref.context = "call " + fc->functionName;
matched = true;
}
} else if (node->conceptType == "Variable") {
auto* v = static_cast<const Variable*>(node);
if (v->name == name) {
ref.kind = "definition";
ref.context = "variable " + v->name;
matched = true;
}
} else if (node->conceptType == "VariableReference") {
auto* vr = static_cast<const VariableReference*>(node);
if (vr->variableName == name) {
ref.kind = "reference";
ref.context = "ref " + vr->variableName;
matched = true;
}
} else if (node->conceptType == "Parameter") {
auto* p = static_cast<const Parameter*>(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<SymbolReference> collectSymbolReferences(
Module* ast, const std::string& name,
const std::string& filePath) {
std::vector<SymbolReference> 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<RenameChange>& changes) {
if (!node) return;
if (node->conceptType == "Function") {
auto* fn = static_cast<const Function*>(node);
if (fn->name == oldName)
changes.push_back({filePath, node->id, "name",
oldName, newName, "definition"});
} else if (node->conceptType == "FunctionCall") {
auto* fc = static_cast<const FunctionCall*>(node);
if (fc->functionName == oldName)
changes.push_back({filePath, node->id, "functionName",
oldName, newName, "call"});
} else if (node->conceptType == "Variable") {
auto* v = static_cast<const Variable*>(node);
if (v->name == oldName)
changes.push_back({filePath, node->id, "name",
oldName, newName, "definition"});
} else if (node->conceptType == "VariableReference") {
auto* vr = static_cast<const VariableReference*>(node);
if (vr->variableName == oldName)
changes.push_back({filePath, node->id, "variableName",
oldName, newName, "reference"});
} else if (node->conceptType == "Parameter") {
auto* p = static_cast<const Parameter*>(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<RenameChange> buildRenameChanges(
Module* ast, const std::string& oldName,
const std::string& newName, const std::string& filePath) {
std::vector<RenameChange> 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
// -----------------------------------------------------------------------

View File

@@ -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 <iostream>
#include <string>
#include <set>
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<void(ASTNode*)> 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<void(ASTNode*)> 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<void(ASTNode*)> 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;
}

View File

@@ -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