mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
fix(python): walk a module-level assignment's initializer scoped to the name (#693 for Python)
The assignment branch minted the node and stopped, so every call on the
right-hand side was missing from the graph:
APP = compute() # no caller
handler = lambda: target() # no caller
MAPPING = {"a": compute()} # no caller
first, second = compute(), f() # no caller, and no symbol either
That is everything a module wires up at import time — `app = FastAPI()`,
`ENGINE = create_engine(url)`, `router = build_router()`, handler registries —
so whatever those build looked unreferenced.
Fix: walk the `right` field through visitFunctionBody with the assigned name
pushed on the scope stack. A tuple target mints no symbol, so its RHS is walked
at the enclosing scope rather than lost. Gated to Python — Ruby shares this
branch and gets its own turn.
Class attributes are untouched: they never reach this branch (the dispatcher's
class-scope gate excludes them), so their calls keep riding the class node.
Giving them symbols of their own is a separate question — there are no nodes
for them today at all.
A function-as-value in an initializer (`REGISTRY = {"org": Serializer}`) now
produces a reference from BOTH the assigned name and the file node, because the
dispatcher's own scan runs either way. That is the shape Go's #744 already
ships — verified against the Go arm on the same input — so the two languages
stay consistent; the function-ref test's expectation is updated to match.
Measured on three Python trees (libresdr, ADRC-betaflight,
bot_predlogka_big_project — 799 files): 0 lost refs, 0 re-attributed, +3057
new; node and edge counts unchanged. Kernel parity holds — 6221/6221 files
byte-identical across the sweep.
This commit is contained in:
@@ -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<Cfg> = 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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user