Step 259: cross-file symbol resolution and import graph
getInScopeSymbols gains crossFile parameter to include exported symbols from all open buffers. Import graph tracks file dependencies, auto-maintained on openFile/closeFile. Text-based fallback for parsers without Import AST nodes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1521,4 +1521,15 @@ target_link_libraries(step258_test PRIVATE
|
||||
tree_sitter_java tree_sitter_rust tree_sitter_go
|
||||
tree_sitter_org)
|
||||
|
||||
# Step 259: Cross-file symbol resolution
|
||||
add_executable(step259_test tests/step259_test.cpp)
|
||||
target_include_directories(step259_test PRIVATE src)
|
||||
target_link_libraries(step259_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)
|
||||
|
||||
@@ -53,7 +53,8 @@ struct AgentPermissionPolicy {
|
||||
method == "batchQuery" ||
|
||||
method == "listBuffers" ||
|
||||
method == "setActiveBuffer" ||
|
||||
method == "indexWorkspace") {
|
||||
method == "indexWorkspace" ||
|
||||
method == "getImportGraph") {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -396,6 +396,7 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
return headlessRpcError(id, -32602,
|
||||
"Missing nodeId parameter");
|
||||
bool detailed = params.value("detailed", false);
|
||||
bool crossFile = params.value("crossFile", false);
|
||||
ContextAPI ctx;
|
||||
ctx.setRoot(state.activeAST());
|
||||
auto symbols = ctx.getInScopeSymbols(nodeId);
|
||||
@@ -412,9 +413,31 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
{"nodeId", s.nodeId}});
|
||||
}
|
||||
}
|
||||
// Cross-file: add exported symbols from other open buffers
|
||||
if (crossFile) {
|
||||
std::string activePath = state.activeBuffer
|
||||
? state.activeBuffer->path : "";
|
||||
for (const auto& [path, buf] : state.bufferStates) {
|
||||
if (path == activePath) continue;
|
||||
Module* otherAST = buf->sync.getAST();
|
||||
if (!otherAST) continue;
|
||||
auto exports = collectExportedSymbols(otherAST, path);
|
||||
for (const auto& ex : exports) {
|
||||
json entry = {{"name", ex.name}, {"kind", ex.kind},
|
||||
{"nodeId", ex.nodeId},
|
||||
{"file", ex.filePath}};
|
||||
if (detailed) {
|
||||
ASTNode* node = findNodeById(otherAST, ex.nodeId);
|
||||
if (node) entry["node"] = toJson(node);
|
||||
}
|
||||
arr.push_back(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
json result = {{"symbols", arr},
|
||||
{"count", (int)arr.size()},
|
||||
{"mode", detailed ? "detailed" : "symbols"}};
|
||||
if (crossFile) result["crossFile"] = true;
|
||||
return headlessRpcResult(id, result);
|
||||
}
|
||||
|
||||
@@ -961,6 +984,13 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
auto* buf = state.openBuffer(path, content, language);
|
||||
if (!buf)
|
||||
return headlessRpcError(id, -32041, "Failed to open buffer");
|
||||
// Update import graph from the new buffer's AST + source
|
||||
Module* bufAST = buf->sync.getAST();
|
||||
if (bufAST)
|
||||
state.project.importGraph.updateFromAST(path, bufAST);
|
||||
// Fallback: scan source text for imports not captured by parser
|
||||
if (!content.empty())
|
||||
state.project.importGraph.updateFromSource(path, content);
|
||||
return headlessRpcResult(id, {
|
||||
{"path", path}, {"language", language},
|
||||
{"bufferCount", (int)state.bufferStates.size()}
|
||||
@@ -979,6 +1009,7 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
auto it = state.bufferStates.find(path);
|
||||
if (it == state.bufferStates.end())
|
||||
return headlessRpcError(id, -32002, "Buffer not found: " + path);
|
||||
state.project.importGraph.clearFile(path);
|
||||
state.closeBuffer(path);
|
||||
return headlessRpcResult(id, {
|
||||
{"closed", path},
|
||||
@@ -1040,6 +1071,40 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
});
|
||||
}
|
||||
|
||||
// --- getImportGraph ---
|
||||
if (method == "getImportGraph") {
|
||||
auto params = request.contains("params") ? request["params"]
|
||||
: json::object();
|
||||
std::string filePath = params.value("file", "");
|
||||
const auto& graph = state.project.importGraph;
|
||||
if (!filePath.empty()) {
|
||||
// Imports for a specific file
|
||||
auto imports = graph.importsOf(filePath);
|
||||
json importArr = json::array();
|
||||
for (const auto& m : imports) importArr.push_back(m);
|
||||
auto importers = graph.importedBy(
|
||||
fs::path(filePath).stem().string());
|
||||
json importerArr = json::array();
|
||||
for (const auto& f : importers) importerArr.push_back(f);
|
||||
return headlessRpcResult(id, {
|
||||
{"file", filePath},
|
||||
{"imports", importArr},
|
||||
{"importedBy", importerArr}
|
||||
});
|
||||
}
|
||||
// Full import graph
|
||||
json edges = json::object();
|
||||
for (const auto& [file, imports] : graph.edges()) {
|
||||
json importArr = json::array();
|
||||
for (const auto& m : imports) importArr.push_back(m);
|
||||
edges[file] = importArr;
|
||||
}
|
||||
return headlessRpcResult(id, {
|
||||
{"edges", edges},
|
||||
{"fileCount", (int)graph.edges().size()}
|
||||
});
|
||||
}
|
||||
|
||||
// --- batchQuery ---
|
||||
if (method == "batchQuery") {
|
||||
auto params = request.contains("params") ? request["params"]
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include "FileTree.h"
|
||||
#include "ast/ASTNode.h"
|
||||
#include "ast/Module.h"
|
||||
#include "ast/Function.h"
|
||||
#include "ast/Variable.h"
|
||||
#include "ast/Import.h"
|
||||
#include "CompactAST.h" // getNodeName
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
@@ -136,11 +143,134 @@ private:
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Step 259: ImportGraph — tracks which files import which
|
||||
// -----------------------------------------------------------------------
|
||||
class ImportGraph {
|
||||
public:
|
||||
// Record that `fromFile` imports `moduleName`
|
||||
void addEdge(const std::string& fromFile,
|
||||
const std::string& moduleName) {
|
||||
edges_[fromFile].insert(moduleName);
|
||||
}
|
||||
|
||||
// Clear edges for a file (call before re-scanning)
|
||||
void clearFile(const std::string& filePath) {
|
||||
edges_.erase(filePath);
|
||||
}
|
||||
|
||||
// Get modules imported by a file
|
||||
std::set<std::string> importsOf(const std::string& filePath) const {
|
||||
auto it = edges_.find(filePath);
|
||||
if (it != edges_.end()) return it->second;
|
||||
return {};
|
||||
}
|
||||
|
||||
// Get files that import a given module name
|
||||
std::vector<std::string> importedBy(
|
||||
const std::string& moduleName) const {
|
||||
std::vector<std::string> result;
|
||||
for (const auto& [file, imports] : edges_) {
|
||||
if (imports.count(moduleName)) result.push_back(file);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Rebuild import edges from an AST's Import nodes
|
||||
void updateFromAST(const std::string& filePath, Module* ast) {
|
||||
clearFile(filePath);
|
||||
if (!ast) return;
|
||||
for (auto* child : ast->allChildren()) {
|
||||
if (child->conceptType == "Import") {
|
||||
auto* imp = static_cast<const Import*>(child);
|
||||
if (!imp->moduleName.empty())
|
||||
addEdge(filePath, imp->moduleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild import edges from source text (fallback for parsers
|
||||
// that don't generate Import AST nodes, e.g. Python)
|
||||
void updateFromSource(const std::string& filePath,
|
||||
const std::string& source) {
|
||||
// Simple line-by-line scan for import statements
|
||||
std::istringstream iss(source);
|
||||
std::string line;
|
||||
while (std::getline(iss, line)) {
|
||||
// Python: "import foo" or "from foo import bar"
|
||||
if (line.substr(0, 7) == "import ") {
|
||||
std::string mod = line.substr(7);
|
||||
// Trim whitespace
|
||||
while (!mod.empty() && mod.back() == ' ')
|
||||
mod.pop_back();
|
||||
// Handle "import foo, bar"
|
||||
size_t comma = mod.find(',');
|
||||
if (comma != std::string::npos)
|
||||
mod = mod.substr(0, comma);
|
||||
if (!mod.empty()) addEdge(filePath, mod);
|
||||
} else if (line.substr(0, 5) == "from ") {
|
||||
size_t space = line.find(' ', 5);
|
||||
if (space != std::string::npos) {
|
||||
std::string mod = line.substr(5, space - 5);
|
||||
if (!mod.empty()) addEdge(filePath, mod);
|
||||
}
|
||||
}
|
||||
// JS/TS: "import ... from 'foo'" handled by AST
|
||||
// C++: "#include" handled by AST
|
||||
}
|
||||
}
|
||||
|
||||
const std::map<std::string, std::set<std::string>>& edges() const {
|
||||
return edges_;
|
||||
}
|
||||
|
||||
private:
|
||||
// filePath -> set of imported module names
|
||||
std::map<std::string, std::set<std::string>> edges_;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ExportedSymbol — a symbol visible across files
|
||||
// -----------------------------------------------------------------------
|
||||
struct ExportedSymbol {
|
||||
std::string name;
|
||||
std::string kind; // "function", "variable", "class"
|
||||
std::string nodeId;
|
||||
std::string filePath; // source file
|
||||
};
|
||||
|
||||
// Collect exported (module-level) symbols from an AST
|
||||
inline std::vector<ExportedSymbol> collectExportedSymbols(
|
||||
Module* ast, const std::string& filePath) {
|
||||
std::vector<ExportedSymbol> symbols;
|
||||
if (!ast) return symbols;
|
||||
for (auto* child : ast->getChildren("functions")) {
|
||||
if (child->conceptType == "Function") {
|
||||
auto* fn = static_cast<const Function*>(child);
|
||||
symbols.push_back({fn->name, "function", fn->id, filePath});
|
||||
}
|
||||
}
|
||||
for (auto* child : ast->getChildren("variables")) {
|
||||
if (child->conceptType == "Variable") {
|
||||
auto* v = static_cast<const Variable*>(child);
|
||||
symbols.push_back({v->name, "variable", v->id, filePath});
|
||||
}
|
||||
}
|
||||
for (auto* child : ast->getChildren("classes")) {
|
||||
if (child->conceptType == "Class") {
|
||||
symbols.push_back({getNodeName(child), "class",
|
||||
child->id, filePath});
|
||||
}
|
||||
}
|
||||
return symbols;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ProjectState — aggregates workspace index and open buffer tracking
|
||||
// -----------------------------------------------------------------------
|
||||
struct ProjectState {
|
||||
WorkspaceIndex index;
|
||||
ImportGraph importGraph;
|
||||
|
||||
void scanWorkspace(const std::string& workspaceRoot) {
|
||||
index.scan(workspaceRoot);
|
||||
|
||||
450
editor/tests/step259_test.cpp
Normal file
450
editor/tests/step259_test.cpp
Normal file
@@ -0,0 +1,450 @@
|
||||
// Step 259 TDD Test: Cross-File Symbol Resolution
|
||||
//
|
||||
// Tests crossFile parameter on getInScopeSymbols, import graph tracking,
|
||||
// exported symbol collection across open buffers, and getImportGraph RPC.
|
||||
#include "HeadlessEditorState.h"
|
||||
#include "CompactAST.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, imports utils
|
||||
std::string srcA =
|
||||
"import utils\n\n"
|
||||
"def helper(x):\n"
|
||||
" return x * 2\n\n"
|
||||
"def compute(a, b):\n"
|
||||
" c = helper(a)\n"
|
||||
" return c + b\n";
|
||||
|
||||
// Module B: defines transform and process
|
||||
std::string srcB =
|
||||
"def transform(data):\n"
|
||||
" return data + 1\n\n"
|
||||
"def process(items):\n"
|
||||
" result = transform(items)\n"
|
||||
" return result\n";
|
||||
|
||||
// Module C: imports both A and B
|
||||
std::string srcC =
|
||||
"import app\n"
|
||||
"import utils\n\n"
|
||||
"def main():\n"
|
||||
" x = 42\n"
|
||||
" return x\n";
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 1: getInScopeSymbols without crossFile (baseline)
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "app.py"}, {"content", srcA}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "utils.py"}, {"content", srcB}});
|
||||
rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}});
|
||||
|
||||
std::string computeId;
|
||||
for (auto* c : state.activeAST()->allChildren()) {
|
||||
if (c->conceptType == "Function" &&
|
||||
getNodeName(c) == "compute") {
|
||||
computeId = c->id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
json resp = rpc(state, "s1", "getInScopeSymbols",
|
||||
{{"nodeId", computeId}});
|
||||
int count = resp["result"].value("count", 0);
|
||||
bool noCrossFile = !resp["result"].contains("crossFile");
|
||||
// Should only have symbols from app.py
|
||||
bool noFileField = true;
|
||||
for (const auto& s : resp["result"]["symbols"]) {
|
||||
if (s.contains("file")) noFileField = false;
|
||||
}
|
||||
expect(count > 0 && noCrossFile && noFileField,
|
||||
"Baseline: " + std::to_string(count) +
|
||||
" local symbols, no crossFile flag",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 2: getInScopeSymbols with crossFile includes other buffers
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "app.py"}, {"content", srcA}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "utils.py"}, {"content", srcB}});
|
||||
rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}});
|
||||
|
||||
std::string computeId;
|
||||
for (auto* c : state.activeAST()->allChildren()) {
|
||||
if (c->conceptType == "Function" &&
|
||||
getNodeName(c) == "compute") {
|
||||
computeId = c->id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
json resp = rpc(state, "s1", "getInScopeSymbols",
|
||||
{{"nodeId", computeId}, {"crossFile", true}});
|
||||
int count = resp["result"].value("count", 0);
|
||||
bool hasCrossFile = resp["result"].value("crossFile", false);
|
||||
|
||||
// Should include symbols from utils.py (transform, process)
|
||||
std::set<std::string> names;
|
||||
for (const auto& s : resp["result"]["symbols"])
|
||||
names.insert(s.value("name", ""));
|
||||
|
||||
bool hasTransform = names.count("transform") > 0;
|
||||
bool hasProcess = names.count("process") > 0;
|
||||
expect(hasCrossFile && hasTransform && hasProcess,
|
||||
"crossFile=true includes transform, process from utils.py (" +
|
||||
std::to_string(count) + " total)",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 3: Cross-file symbols include file path
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "app.py"}, {"content", srcA}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "utils.py"}, {"content", srcB}});
|
||||
rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}});
|
||||
|
||||
std::string computeId;
|
||||
for (auto* c : state.activeAST()->allChildren()) {
|
||||
if (c->conceptType == "Function" &&
|
||||
getNodeName(c) == "compute") {
|
||||
computeId = c->id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
json resp = rpc(state, "s1", "getInScopeSymbols",
|
||||
{{"nodeId", computeId}, {"crossFile", true}});
|
||||
bool hasFileField = false;
|
||||
std::string fileVal;
|
||||
for (const auto& s : resp["result"]["symbols"]) {
|
||||
if (s.contains("file")) {
|
||||
hasFileField = true;
|
||||
fileVal = s.value("file", "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(hasFileField && fileVal == "utils.py",
|
||||
"Cross-file symbols have file='utils.py'",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 4: Import graph populated on openFile
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "app.py"}, {"content", srcA}});
|
||||
|
||||
json resp = rpc(state, "s1", "getImportGraph",
|
||||
{{"file", "app.py"}});
|
||||
bool hasImports = resp["result"].contains("imports") &&
|
||||
resp["result"]["imports"].is_array();
|
||||
int importCount = hasImports
|
||||
? (int)resp["result"]["imports"].size() : 0;
|
||||
expect(hasImports && importCount == 1,
|
||||
"app.py imports 1 module (utils)",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 5: Full import graph across multiple 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", "utils.py"}, {"content", srcB}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "main.py"}, {"content", srcC}});
|
||||
|
||||
json resp = rpc(state, "s1", "getImportGraph");
|
||||
int fileCount = resp["result"].value("fileCount", 0);
|
||||
bool hasEdges = resp["result"].contains("edges");
|
||||
// app.py imports utils; main.py imports app and utils
|
||||
// utils.py has no imports
|
||||
expect(hasEdges && fileCount == 2,
|
||||
"Import graph has 2 files with imports",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 6: importedBy query — who imports "utils"?
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "app.py"}, {"content", srcA}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "main.py"}, {"content", srcC}});
|
||||
|
||||
json resp = rpc(state, "s1", "getImportGraph",
|
||||
{{"file", "utils.py"}});
|
||||
bool hasImportedBy = resp["result"].contains("importedBy") &&
|
||||
resp["result"]["importedBy"].is_array();
|
||||
int importerCount = hasImportedBy
|
||||
? (int)resp["result"]["importedBy"].size() : 0;
|
||||
// Both app.py and main.py import "utils"
|
||||
expect(hasImportedBy && importerCount == 2,
|
||||
"utils imported by 2 files (app.py, main.py)",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 7: Cross-file detailed mode includes node JSON
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "app.py"}, {"content", srcA}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "utils.py"}, {"content", srcB}});
|
||||
rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}});
|
||||
|
||||
std::string computeId;
|
||||
for (auto* c : state.activeAST()->allChildren()) {
|
||||
if (c->conceptType == "Function" &&
|
||||
getNodeName(c) == "compute") {
|
||||
computeId = c->id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
json resp = rpc(state, "s1", "getInScopeSymbols",
|
||||
{{"nodeId", computeId},
|
||||
{"crossFile", true},
|
||||
{"detailed", true}});
|
||||
bool hasNodeOnCrossFile = false;
|
||||
for (const auto& s : resp["result"]["symbols"]) {
|
||||
if (s.contains("file") && s.contains("node"))
|
||||
hasNodeOnCrossFile = true;
|
||||
}
|
||||
expect(hasNodeOnCrossFile,
|
||||
"Cross-file detailed mode includes node JSON",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 8: Cross-file count is larger than local-only
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "app.py"}, {"content", srcA}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "utils.py"}, {"content", srcB}});
|
||||
rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}});
|
||||
|
||||
std::string computeId;
|
||||
for (auto* c : state.activeAST()->allChildren()) {
|
||||
if (c->conceptType == "Function" &&
|
||||
getNodeName(c) == "compute") {
|
||||
computeId = c->id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
json local = rpc(state, "s1", "getInScopeSymbols",
|
||||
{{"nodeId", computeId}});
|
||||
json cross = rpc(state, "s1", "getInScopeSymbols",
|
||||
{{"nodeId", computeId},
|
||||
{"crossFile", true}});
|
||||
int localCount = local["result"].value("count", 0);
|
||||
int crossCount = cross["result"].value("count", 0);
|
||||
expect(crossCount > localCount,
|
||||
"Cross-file count (" + std::to_string(crossCount) +
|
||||
") > local count (" + std::to_string(localCount) + ")",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 9: closeFile removes from import graph
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "app.py"}, {"content", srcA}});
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "main.py"}, {"content", srcC}});
|
||||
|
||||
// Before close: 2 files in import graph
|
||||
json before = rpc(state, "s1", "getImportGraph");
|
||||
int beforeCount = before["result"].value("fileCount", 0);
|
||||
|
||||
rpc(state, "s1", "closeFile", {{"path", "main.py"}});
|
||||
|
||||
// After close: only 1 file should remain
|
||||
json after = rpc(state, "s1", "getImportGraph");
|
||||
int afterCount = after["result"].value("fileCount", 0);
|
||||
expect(beforeCount == 2 && afterCount == 1,
|
||||
"closeFile removes from import graph (2→1)",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 10: Cross-file with no other buffers returns local only
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "solo.py"}, {"content", srcA}});
|
||||
|
||||
std::string computeId;
|
||||
for (auto* c : state.activeAST()->allChildren()) {
|
||||
if (c->conceptType == "Function" &&
|
||||
getNodeName(c) == "compute") {
|
||||
computeId = c->id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
json local = rpc(state, "s1", "getInScopeSymbols",
|
||||
{{"nodeId", computeId}});
|
||||
json cross = rpc(state, "s1", "getInScopeSymbols",
|
||||
{{"nodeId", computeId},
|
||||
{"crossFile", true}});
|
||||
int localCount = local["result"].value("count", 0);
|
||||
int crossCount = cross["result"].value("count", 0);
|
||||
// With only one buffer, crossFile adds nothing
|
||||
expect(localCount == crossCount,
|
||||
"Single buffer: crossFile=local (" +
|
||||
std::to_string(crossCount) + ")",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 11: collectExportedSymbols extracts module-level symbols
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("s1", AgentRole::Refactor);
|
||||
|
||||
rpc(state, "s1", "openFile",
|
||||
{{"path", "utils.py"}, {"content", srcB}});
|
||||
|
||||
auto it = state.bufferStates.find("utils.py");
|
||||
Module* ast = it->second->sync.getAST();
|
||||
auto exports = collectExportedSymbols(ast, "utils.py");
|
||||
std::set<std::string> names;
|
||||
for (const auto& ex : exports)
|
||||
names.insert(ex.name);
|
||||
bool hasTransform = names.count("transform") > 0;
|
||||
bool hasProcess = names.count("process") > 0;
|
||||
expect(hasTransform && hasProcess && exports.size() == 2,
|
||||
"collectExportedSymbols: transform, process (2 total)",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 12: Linter role can read import graph and cross-file scope
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
HeadlessEditorState state;
|
||||
state.defaultLanguage = "python";
|
||||
state.setAgentRole("admin", AgentRole::Refactor);
|
||||
state.setAgentRole("s1", AgentRole::Linter);
|
||||
|
||||
rpc(state, "admin", "openFile",
|
||||
{{"path", "app.py"}, {"content", srcA}});
|
||||
rpc(state, "admin", "openFile",
|
||||
{{"path", "utils.py"}, {"content", srcB}});
|
||||
|
||||
// Linter can read import graph
|
||||
json graphResp = rpc(state, "s1", "getImportGraph");
|
||||
bool graphOk = graphResp.contains("result");
|
||||
|
||||
// Linter can query cross-file scope
|
||||
state.setActiveBuffer("app.py");
|
||||
std::string computeId;
|
||||
for (auto* c : state.activeAST()->allChildren()) {
|
||||
if (c->conceptType == "Function" &&
|
||||
getNodeName(c) == "compute") {
|
||||
computeId = c->id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
json scopeResp = rpc(state, "s1", "getInScopeSymbols",
|
||||
{{"nodeId", computeId},
|
||||
{"crossFile", true}});
|
||||
bool scopeOk = scopeResp.contains("result");
|
||||
expect(graphOk && scopeOk,
|
||||
"Linter role can read import graph and cross-file scope",
|
||||
passed, failed);
|
||||
}
|
||||
|
||||
std::cout << "\n=== Step 259 Results: " << passed << " passed, "
|
||||
<< failed << " failed ===\n";
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
37
progress.md
37
progress.md
@@ -445,3 +445,40 @@ auto-detected from file extension.
|
||||
- indexWorkspace scans using FileTree (respects .gitignore) without opening files
|
||||
- WorkspaceIndex provides findByExtension/findByLanguage for agent queries
|
||||
- tools/list now returns 27 tools (was 22): +5 project management tools
|
||||
|
||||
### Step 259: Cross-File Symbol Resolution
|
||||
**Status:** PASS (12/12 tests)
|
||||
|
||||
`getInScopeSymbols` gains `crossFile: true` parameter to include exported
|
||||
symbols from all other open buffers. Import graph tracks which files import
|
||||
which, updated automatically on openFile/closeFile. `getImportGraph` RPC
|
||||
method exposes the graph.
|
||||
|
||||
**Files created:**
|
||||
- `editor/tests/step259_test.cpp` — 12 test cases: baseline local scope,
|
||||
crossFile includes other buffers' exports, cross-file symbols have file
|
||||
path, import graph populated on openFile, full import graph across 3 files,
|
||||
importedBy reverse query, cross-file detailed mode with node JSON,
|
||||
cross-file count > local count, closeFile removes from import graph,
|
||||
single buffer crossFile=local, collectExportedSymbols extraction,
|
||||
Linter role can read cross-file data
|
||||
|
||||
**Files modified:**
|
||||
- `editor/src/ProjectState.h` — ImportGraph (addEdge, clearFile, importsOf,
|
||||
importedBy, updateFromAST, updateFromSource), ExportedSymbol struct,
|
||||
collectExportedSymbols, ProjectState gains ImportGraph member
|
||||
- `editor/src/HeadlessAgentRPCHandler.h` — getInScopeSymbols gains
|
||||
crossFile parameter (scans all bufferStates for exports), getImportGraph
|
||||
RPC method (per-file or full graph), openFile updates import graph,
|
||||
closeFile clears import graph entry
|
||||
- `editor/src/AgentPermissionPolicy.h` — getImportGraph as read-only
|
||||
- `editor/CMakeLists.txt` — step259_test target
|
||||
|
||||
**Key design decisions:**
|
||||
- Cross-file symbols include `file` field identifying source buffer path
|
||||
- Import graph built from AST Import nodes + source text fallback
|
||||
(Python parser doesn't generate Import AST nodes, so text scan catches
|
||||
`import foo` and `from foo import bar` patterns)
|
||||
- Import graph auto-maintained: updated on openFile, cleared on closeFile
|
||||
- getImportGraph supports per-file query (imports + importedBy) or full graph
|
||||
- Cross-file detailed mode resolves node JSON from the source buffer's AST
|
||||
|
||||
Reference in New Issue
Block a user