diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2d90596..2f2921a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466) - Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478) - When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474) +- Python module-level assignments now contribute the calls their right-hand side makes — `app = FastAPI()`, `ENGINE = create_engine(url)`, `handler = lambda: run()`, a registry dict or list of handlers. Previously everything a module wires up at import time was missing from the graph, so the objects it builds looked unreferenced. Re-index with `codegraph index -f` after upgrading to pick up the new edges. - Rust `const` and `static` initializers now contribute their calls: `static REGISTRY: Lazy = Lazy::new(|| build())`, `const LEN: usize = compute_len()`. Previously anything a lazily-built singleton or a computed constant called was missing from the graph entirely. Re-index with `codegraph index -f` after upgrading to pick up the new edges. - Scala `val`/`var` definitions now contribute the calls their initializer makes — `val handler = () => process(msg)`, `val client = buildClient()`, `lazy val engine = start()`. Previously everything on the right-hand side was dropped, which on a val-heavy codebase (SpinalHDL hardware descriptions, Akka wiring) is most of the wiring: a 32-file SpinalHDL project gained 812 references it had been missing. Re-index with `codegraph index -f` after upgrading to pick up the new edges. - TypeScript and JavaScript module-level declarations now name themselves as the caller of whatever their initializer runs. `const cfg = loadConfig()` recorded the *file* as loadConfig's caller, which is no use for callers or impact; it now records `cfg`. And an object literal that wasn't exported — `const handlers = { onSave: () => persist() }` — was skipped entirely, so nothing inside it reached the graph at all. Re-index with `codegraph index -f` after upgrading to pick up the new edges. diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 6abd93e92..320f99aa5 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1055,6 +1055,42 @@ class UserService: expect(classNode).toBeDefined(); expect(classNode?.name).toBe('UserService'); }); + + it('walks a module-level assignment initializer scoped to the name (#693 for Python)', () => { + // The assignment minted a node and stopped, so everything a module builds + // at import time — `app = FastAPI()`, `ENGINE = create_engine(url)` — was + // missing from the graph. A tuple target mints no symbol, so its + // right-hand side attributes to the enclosing scope instead of vanishing. + const code = ` +def target(): pass +def compute(): return 1 + +APP = compute() +handler = lambda: target() +MAPPING = {"a": compute()} +first, second = compute(), target() + +class K: + ATTR = compute() +`; + const result = extractFromSource('app.py', code); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + const owners = result.unresolvedReferences + .filter((u) => u.referenceKind === 'calls') + .map((u) => { + const n = byId.get(u.fromNodeId); + return `${u.referenceName}<-${n ? `${n.kind}:${n.name}` : '?'}`; + }) + .sort(); + expect(owners).toEqual([ + 'compute<-class:K', // a class attribute still rides the class (no node of its own) + 'compute<-file:app.py', // the tuple target mints nothing + 'compute<-variable:APP', + 'compute<-variable:MAPPING', + 'target<-file:app.py', + 'target<-variable:handler', + ]); + }); }); describe('Go Extraction', () => { diff --git a/__tests__/fixtures/kernel-parity/torture.py b/__tests__/fixtures/kernel-parity/torture.py index 900fc74f8..8ef3e5885 100644 --- a/__tests__/fixtures/kernel-parity/torture.py +++ b/__tests__/fixtures/kernel-parity/torture.py @@ -47,3 +47,9 @@ def shadowed(): handlers = {"recv": target_cb} callbacks = [target_cb, view] + +# Initializer walks attributed to the assigned name (#693). +INIT_EAGER = helper() +INIT_LAMBDA = lambda: target_cb() +INIT_MAP = {"a": helper()} +init_a, init_b = helper(), view() diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts index fe5016c13..4e156c31d 100644 --- a/__tests__/function-ref.test.ts +++ b/__tests__/function-ref.test.ts @@ -795,8 +795,10 @@ describe('Function-as-value capture (#756)', () => { // The DRF wiring: get_serializer_class → the imported serializer class, // via `return` — the issue's headline gap. The module-level registry - // dict rides the file node. + // dict rides BOTH the assigned name (the initializer walk, #693) and the + // file node (the dispatcher's own scan, which runs either way). expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerFull'))).toEqual([ + 'SERIALIZER_REGISTRY', 'get_serializer_class', 'views.py', ]); diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs index 93cb10a9d..fe4a49690 100644 --- a/codegraph-kernel/src/python.rs +++ b/codegraph-kernel/src/python.rs @@ -469,14 +469,37 @@ impl<'t> Walker<'t> { let docstring = preceding_docstring(node, self.src); let left = node.child_by_field_name("left").or_else(|| node.named_child(0)); let right = node.child_by_field_name("right").or_else(|| node.named_child(1)); - let Some(left) = left else { return }; - if !matches!(left.kind(), "identifier" | "constant") { - return; + let mut assigned: Option<(u32, String)> = None; + if let Some(left) = left { + if matches!(left.kind(), "identifier" | "constant") { + let name = self.text(left).to_string(); + let signature = right.map(|r| util::init_signature(self.text(r))); + // No isConst hook ⇒ always `variable` (UPPER_CASE constants included). + let row = self.create_node( + "variable", + &name, + node, + Extra { docstring, signature, ..Extra::default() }, + ); + if let Some(row) = row { + assigned = Some((row, name)); + } + } + } + // Walk the initializer ATTRIBUTED to the assigned name (#693): a + // module-level `app = FastAPI()` / `handler = lambda: run()` dropped + // every call on the right-hand side. A tuple target mints no symbol, so + // its RHS is walked at the enclosing scope rather than lost. + if let Some(right) = right { + match assigned { + Some((row, name)) => { + self.stack.push(Scope { row, kind: "variable", name }); + self.visit_function_body(right); + self.stack.pop(); + } + None => self.visit_function_body(right), + } } - let name = self.text(left).to_string(); - let signature = right.map(|r| util::init_signature(self.text(r))); - // No isConst hook ⇒ always `variable` (UPPER_CASE constants included). - self.create_node("variable", &name, node, Extra { docstring, signature, ..Extra::default() }); } fn extract_import(&mut self, node: Node<'t>) { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index b0bdbf997..ba5668872 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2739,6 +2739,7 @@ export class TreeSitterExtractor { // Ruby constant assignments (`MAX = 3`) have a `constant`-typed LHS, not // `identifier`; without this they were never extracted as symbols at all. + let assigned: Node | null = null; if (left && (left.type === 'identifier' || left.type === 'constant')) { const name = getNodeText(left, this.source); // Skip if name starts with lowercase and looks like a function call result @@ -2746,11 +2747,23 @@ export class TreeSitterExtractor { const initValue = right ? getNodeText(right, this.source).slice(0, 100) : undefined; const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; - this.createNode(kind, name, node, { + assigned = this.createNode(kind, name, node, { docstring, signature: initSignature, }); } + // Walk the initializer ATTRIBUTED to the assigned name (#693). A + // module-level `app = FastAPI()` / `ENGINE = create_engine(url)` / + // `handler = lambda: run()` dropped every call on the right-hand side, so + // whatever the module builds at import time linked to nothing. A tuple + // target (`a, b = f(), g()`) mints no symbol, so its RHS is walked at the + // enclosing scope rather than lost. Python only: Ruby shares this branch + // and gets its own turn. + if (this.language === 'python' && right) { + if (assigned) this.nodeStack.push(assigned.id); + this.visitFunctionBody(right, ''); + if (assigned) this.nodeStack.pop(); + } } else if (this.language === 'go') { // Go: var_declaration, short_var_declaration, const_declaration // These can have multiple identifiers on the left