Step 261: project-wide search and refactor (searchProject, renameSymbol)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
422
editor/tests/step261_test.cpp
Normal file
422
editor/tests/step261_test.cpp
Normal 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;
|
||||
}
|
||||
40
progress.md
40
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
|
||||
|
||||
Reference in New Issue
Block a user