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

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