From f0417bcccecd96df4e58c3cc273945aee9b08631 Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 11 Feb 2026 20:06:46 +0000 Subject: [PATCH] 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 --- editor/CMakeLists.txt | 11 + editor/src/AgentPermissionPolicy.h | 3 +- editor/src/HeadlessAgentRPCHandler.h | 65 ++++ editor/src/ProjectState.h | 130 ++++++++ editor/tests/step259_test.cpp | 450 +++++++++++++++++++++++++++ progress.md | 37 +++ 6 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 editor/tests/step259_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 24ba1c9..475677d 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -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) diff --git a/editor/src/AgentPermissionPolicy.h b/editor/src/AgentPermissionPolicy.h index dca27d5..e5d2760 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -53,7 +53,8 @@ struct AgentPermissionPolicy { method == "batchQuery" || method == "listBuffers" || method == "setActiveBuffer" || - method == "indexWorkspace") { + method == "indexWorkspace" || + method == "getImportGraph") { return true; } diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index 8a4d7e3..7eb05cd 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -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"] diff --git a/editor/src/ProjectState.h b/editor/src/ProjectState.h index 5e436d5..929837f 100644 --- a/editor/src/ProjectState.h +++ b/editor/src/ProjectState.h @@ -9,8 +9,15 @@ #include #include #include +#include #include #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 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 importedBy( + const std::string& moduleName) const { + std::vector 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(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>& edges() const { + return edges_; + } + +private: + // filePath -> set of imported module names + std::map> 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 collectExportedSymbols( + Module* ast, const std::string& filePath) { + std::vector symbols; + if (!ast) return symbols; + for (auto* child : ast->getChildren("functions")) { + if (child->conceptType == "Function") { + auto* fn = static_cast(child); + symbols.push_back({fn->name, "function", fn->id, filePath}); + } + } + for (auto* child : ast->getChildren("variables")) { + if (child->conceptType == "Variable") { + auto* v = static_cast(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); diff --git a/editor/tests/step259_test.cpp b/editor/tests/step259_test.cpp new file mode 100644 index 0000000..9a22ade --- /dev/null +++ b/editor/tests/step259_test.cpp @@ -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 +#include +#include + +static void expect(bool cond, const std::string& name, + int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) + << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) + << " FAIL: " << name << "\n"; + ++failed; + } +} + +static json rpc(HeadlessEditorState& state, const std::string& session, + const std::string& method, + json params = json::object()) { + json request = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", method}, {"params", params}}; + return state.processAgentRequest(request, session); +} + +int main() { + int passed = 0; + int failed = 0; + + // Module A: defines helper and compute, 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 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 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; +} diff --git a/progress.md b/progress.md index c618f90..b7b72f7 100644 --- a/progress.md +++ b/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