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:
Bill
2026-02-11 20:06:46 +00:00
parent ac5b7e21c8
commit f0417bccce
6 changed files with 695 additions and 1 deletions

View File

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

View File

@@ -53,7 +53,8 @@ struct AgentPermissionPolicy {
method == "batchQuery" ||
method == "listBuffers" ||
method == "setActiveBuffer" ||
method == "indexWorkspace") {
method == "indexWorkspace" ||
method == "getImportGraph") {
return true;
}

View File

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

View File

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

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

View File

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