diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ac3831d..a36bbe48a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -269,6 +269,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript, a type written with a qualifier, like `React.ReactNode`, `Y.Doc`, `TChat.ChatMessage` or `EQueryType.CLICKHOUSE`, now links to the member of what the qualifier names: a type from a package, like React's, no longer links to a class or component of your own that shares its name, and a member of an enum or a namespace your project declares links to that member instead of to another enum's or a same-named component. Re-index TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written from the app's root with a leading slash, like bigbluebutton's `import Icon from '/imports/ui/components/common/icon/component'`, now links to the file it names when the app's `tsconfig.json` or `jsconfig.json` maps it, as `"paths": { "/*": ["*"] }` does. Before, such an import was taken for a package, so the module looked unused and every name it imports linked by name instead, often to the wrong one of several components of the same name, or to nothing. Re-index projects that import this way after upgrading. - In JavaScript and TypeScript, a call whose callee is not a name, like an immediately invoked function `(() => { … })()`, a curried call such as `connect(mapState)(View)`, `handlers[type]()` or `(0, fn)()`, no longer stores the callee's whole source text as the name of a reference that nothing in the project can answer. A single immediately invoked function could leave its entire body in the index that way. The calls inside such a callee, and the inner call of a curried one, are still recorded. A call through parentheses, a cast or a non-null assertion, like `(fn)()`, `(fn as F)()` or `fn!()`, now links to `fn` exactly as `fn()` does, so a parameter named `fn` no longer links to a same-named property elsewhere in the project. In TypeScript, a call with type arguments behind an operator, like `!settings.get('X')` or `void apiRequest()`, now links to what it calls, as `await load()` already did. +- In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/fixtures/kernel-parity/Torture.java b/__tests__/fixtures/kernel-parity/Torture.java index d1e71cbd2..4d8c7c7ed 100644 --- a/__tests__/fixtures/kernel-parity/Torture.java +++ b/__tests__/fixtures/kernel-parity/Torture.java @@ -31,6 +31,7 @@ public class TortureService extends BaseService implements Runnable, AutoCloseab } }; private final Runnable fieldMethodRef = TortureService::compute; + private static final List FIELD_TABLE = List.of(TortureService::compute); /** Ctor javadoc. */ public TortureService(List names) { diff --git a/__tests__/fixtures/kernel-parity/torture.js b/__tests__/fixtures/kernel-parity/torture.js index 0f904ff4f..3b47ae529 100644 --- a/__tests__/fixtures/kernel-parity/torture.js +++ b/__tests__/fixtures/kernel-parity/torture.js @@ -14,6 +14,7 @@ const arrow = (x) => legacyHelper(x, 1); class Widget extends EventEmitter { static registry = new Map(); + static hooks = { tick: legacyHelper }; #privateField = 1; label = 'w'; onTick = () => { @@ -78,6 +79,7 @@ export default { // leaked to the FILE node; a non-exported object literal was skipped outright. const eagerConfig = loadConfig(); const handlerMap = { onSave: () => persist(eagerConfig), onLoad: loadConfig() }; +const registry = { legacyHelper, run: arrow, nested: [legacyHelper] }; const lazyList = [() => persist(eagerConfig)]; // --- CommonJS export assignments (#1675) ----------------------------------- exports.getItems = async (req, res) => { res.json(await findItems()); }; diff --git a/__tests__/fixtures/kernel-parity/torture.kt b/__tests__/fixtures/kernel-parity/torture.kt index c5531c55e..3e85d3de5 100644 --- a/__tests__/fixtures/kernel-parity/torture.kt +++ b/__tests__/fixtures/kernel-parity/torture.kt @@ -52,6 +52,7 @@ val withGetter: Int get() = 42 val initLambda: () -> Unit = { caller() } val initSam = Runnable { caller() } +val handlerList = listOf(::caller) val initObject = object : Runnable { override fun run() { caller() @@ -60,6 +61,7 @@ val initObject = object : Runnable { class WidgetK(val size: Int, private var name: String = defaultName()) { val area: Int = size * size + val hooks = listOf(::caller) var label: String? = null val computed: Int get() = size * 2 diff --git a/__tests__/fixtures/kernel-parity/torture.rs b/__tests__/fixtures/kernel-parity/torture.rs index 5030219bb..0049939ae 100644 --- a/__tests__/fixtures/kernel-parity/torture.rs +++ b/__tests__/fixtures/kernel-parity/torture.rs @@ -305,6 +305,8 @@ routes![top_level_h]; const INIT_CONST: usize = compute_len(); static INIT_LAZY: Lazy = Lazy::new(|| build_cfg()); static INIT_ALIAS: fn() = free_fn; +// A table the walk above takes for its symbol, and the scan no longer for the file. +static INIT_TABLE: &[fn()] = &[handler, handler2]; pub union Reg { pub raw: u32, diff --git a/__tests__/fixtures/kernel-parity/torture.tsx b/__tests__/fixtures/kernel-parity/torture.tsx index 1ba868fcb..55346e770 100644 --- a/__tests__/fixtures/kernel-parity/torture.tsx +++ b/__tests__/fixtures/kernel-parity/torture.tsx @@ -23,6 +23,7 @@ var oldVar = 'x'; @scoped.Registry('name') export abstract class BaseService extends EventTarget implements Disposable, Serializable { static instances = 0; + static handlers = { refresh: helperFn, table: CONFIG_TABLE }; private readonly cache: Map = new Map(); public fonts: FontConfig; count = 0; @@ -189,6 +190,7 @@ export const actionsMap = { update(input: UpdateInput) { return persistExisting(input); }, + fallback: helperFn, }; // Zustand-style store through middleware wrappers. diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts index 1572c1eb9..a92b17ca9 100644 --- a/__tests__/function-ref.test.ts +++ b/__tests__/function-ref.test.ts @@ -779,12 +779,11 @@ 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 BOTH the assigned name (the initializer walk, #693) and the - // file node (the dispatcher's own scan, which runs either way). + // dict rides the assigned name (the initializer walk, #693), and not the + // file node as well: the dispatcher's scan skips what that walk took. expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerFull'))).toEqual([ 'SERIALIZER_REGISTRY', 'get_serializer_class', - 'views.py', ]); // Second branch return + a module-level call argument. expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerBrief'))).toEqual([ @@ -1581,4 +1580,72 @@ func Unknown(obj interface{}) { Submit(obj.Fetch) } cg.destroy(); } }); + + // An initializer walked under the symbol it declares (#693) takes its + // function values for that symbol. The declaration-wide scan took each one + // again for the enclosing file or class: a second reference to the same + // function from the same spot (TS/JS constants and class fields, Python, + // Go, Rust, Java fields, Kotlin properties). Each function here is + // referenced only from an initializer, so a second source shows. + it('INITIALIZER WALKS: a function in an initializer is referenced once, by what it declares', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-init-')); + fs.writeFileSync( + path.join(tmpDir, 'app.ts'), + [ + 'export function tsHome() {}', + 'export function tsAbout() {}', + 'export function tsClick() {}', + 'export function tsBoot() {}', + 'function register(fn: () => void) {}', + 'const routes = { tsHome, about: tsAbout, list: [tsHome] };', + 'const api = { load() {}, tsAbout };', + 'export class Widget {', + ' static handlers = { click: tsClick };', + '}', + 'register(tsBoot);', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'views.py'), + ['def py_home():', ' pass', '', 'HANDLERS = {"home": py_home}', 'ROUTES = [py_home]'].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'handlers.go'), + ['package main', '', 'func goHome() {}', '', 'var handlers = map[string]func(){"home": goHome}'].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'lib.rs'), + ['fn rust_home() {}', 'static HANDLERS: &[fn()] = &[rust_home];'].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Registry.java'), + [ + 'class Registry {', + ' static void javaHome() {}', + ' static final java.util.List TABLE = java.util.List.of(Registry::javaHome);', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'handlers.kt'), + ['fun ktHome() {}', 'val ktHandlers = listOf(::ktHome)', 'class KtWidget {', ' val hooks = listOf(::ktHome)', '}'].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'tsHome'))).toEqual(['routes']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'tsAbout'))).toEqual(['api', 'routes']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'tsClick'))).toEqual(['handlers']); + // A call argument at module scope is the file's, as before. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'tsBoot'))).toEqual(['app.ts']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'py_home'))).toEqual(['HANDLERS', 'ROUTES']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'goHome'))).toEqual(['handlers']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'rust_home'))).toEqual(['HANDLERS']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'javaHome'))).toEqual(['TABLE']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'ktHome'))).toEqual(['hooks', 'ktHandlers']); + } finally { + cg.destroy(); + } + }); }); diff --git a/codegraph-kernel/src/go.rs b/codegraph-kernel/src/go.rs index d5c2166b5..9b52e97ae 100644 --- a/codegraph-kernel/src/go.rs +++ b/codegraph-kernel/src/go.rs @@ -428,8 +428,10 @@ impl<'t> Walker<'t> { } else if matches!(kind, "var_declaration" | "short_var_declaration" | "const_declaration") && !self.inside_class_like() { - self.extract_variable(node); - self.scan_fn_ref_subtree(node, 0); + // The initializers extract_variable walked under their specs' + // symbols took their own candidates; the scan covers the rest. + let walked = self.extract_variable(node); + self.scan_fn_ref_subtree(node, 0, &walked); skip_children = true; } else if kind == "import_declaration" { self.extract_import(node); @@ -678,7 +680,10 @@ impl<'t> Walker<'t> { } /// extractVariable's Go branch: var/const specs + short_var_declaration. - fn extract_variable(&mut self, node: Node<'t>) { + /// Returns the initializers it walked under their specs' symbols, for the + /// declaration scan to skip. + fn extract_variable(&mut self, node: Node<'t>) -> Vec { + let mut walked = Vec::new(); let docstring = self.docstring_of(node); let is_const_decl = node.kind() == "const_declaration"; @@ -726,6 +731,7 @@ impl<'t> Walker<'t> { self.stack.push(Scope { row, kind: "variable", name }); self.visit_function_body(value_field); self.stack.pop(); + walked.push(value_field.id()); } else { self.visit_function_body(value_field); } @@ -756,6 +762,7 @@ impl<'t> Walker<'t> { } } } + walked } /// extractImport's Go branch: one import node + ref per import_spec. @@ -1241,11 +1248,15 @@ impl<'t> Walker<'t> { } } - fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) { + fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32, walked: &[usize]) { stack_guard!(); if depth > 12 { return; } + // Subtrees the body walker has already been through. + if walked.contains(&node.id()) { + return; + } if depth > 0 && matches!( node.kind(), @@ -1258,7 +1269,7 @@ impl<'t> Walker<'t> { self.maybe_capture_fn_refs(node); for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { - self.scan_fn_ref_subtree(c, depth + 1); + self.scan_fn_ref_subtree(c, depth + 1, walked); } } } diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index daaf49de0..42c30f76e 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -507,12 +507,14 @@ impl<'t> Walker<'t> { self.extract_enum(node); skip_children = true; } else if kind == "field_declaration" && self.inside_class_like() { - self.extract_field(node); - self.scan_fn_ref_subtree(node, 0); + // The initializers extract_field walked under their fields took + // their own candidates; the scan covers the rest. + let walked = self.extract_field(node); + self.scan_fn_ref_subtree(node, 0, &walked); skip_children = true; } else if kind == "local_variable_declaration" && !self.inside_class_like() { self.extract_variable(node); - self.scan_fn_ref_subtree(node, 0); + self.scan_fn_ref_subtree(node, 0, &[]); skip_children = true; } else if kind == "import_declaration" { self.extract_import(node); @@ -726,8 +728,11 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// extractField — each declarator becomes a field/constant node. - fn extract_field(&mut self, node: Node<'t>) { + /// extractField — each declarator becomes a field/constant node. Returns + /// the initializers walked under their fields, for the declaration scan to + /// skip. + fn extract_field(&mut self, node: Node<'t>) -> Vec { + let mut walked = Vec::new(); let docstring = preceding_docstring(node, self.src); let visibility = self.visibility_of(node); let is_static = Some(self.is_static(node)); @@ -786,6 +791,7 @@ impl<'t> Walker<'t> { self.stack.push(Scope { row, kind: field_kind, name: name.clone() }); self.visit_function_body(value); self.stack.pop(); + walked.push(value.id()); } } } @@ -805,6 +811,7 @@ impl<'t> Walker<'t> { ); } } + walked } /// extractVariable's generic fallback (top-level locals — rare in Java). @@ -1230,11 +1237,15 @@ impl<'t> Walker<'t> { } } - fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) { + fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32, walked: &[usize]) { stack_guard!(); if depth > 12 { return; } + // Subtrees the body walker has already been through. + if walked.contains(&node.id()) { + return; + } // (functionTypes is empty for Java; lambda_expression halts the scan) if depth > 0 && matches!(node.kind(), "lambda_literal" | "lambda_expression") { return; @@ -1242,7 +1253,7 @@ impl<'t> Walker<'t> { self.maybe_capture_fn_refs(node); for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { - self.scan_fn_ref_subtree(c, depth + 1); + self.scan_fn_ref_subtree(c, depth + 1, walked); } } } diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index a224cb726..83170723f 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -733,7 +733,10 @@ impl<'t> Walker<'t> { }) } - fn try_visit_hook(&mut self, node: Node<'t>) -> bool { + /// The Kotlin visitNode hook: `Some` when it consumed the node, holding the + /// initializers it walked as such (`walkInitializer`), which the caller's + /// fn-ref scan of the node skips. + fn try_visit_hook(&mut self, node: Node<'t>) -> Option> { // An own-line accessor already walked by its owning property below. The // ownership test re-derives the property's kind rather than remembering // it: a destructured or local declaration mints no node, so its @@ -741,10 +744,10 @@ impl<'t> Walker<'t> { if matches!(node.kind(), "getter" | "setter") { return accessor_owner(node) .and_then(|owner| self.property_kind(owner)) - .is_some(); + .map(|_| Vec::new()); } if node.kind() != "property_declaration" { - return false; + return None; } let var_decl = (0..node.named_child_count()) .filter_map(|i| node.named_child(i)) @@ -764,11 +767,11 @@ impl<'t> Walker<'t> { for init in property_initializers(node) { self.visit_function_body(init); } - return true; + return Some(Vec::new()); }; let name = self.text(name_node).to_string(); if name.is_empty() { - return false; + return None; } let Some(kind) = self.property_kind(node) else { // A local — no node is minted, but the initializer is still code. @@ -778,7 +781,7 @@ impl<'t> Walker<'t> { for init in property_initializers(node) { self.visit_function_body(init); } - return true; + return Some(Vec::new()); }; // The `type`-field signature read is dead (zero fields) → signature // undefined; NO docstring/visibility/isStatic — the modifiers merge in @@ -790,26 +793,30 @@ impl<'t> Walker<'t> { // the idiomatic Android callback field) contributed NO call edge at all. // The property also OWNS any accessor written on its own line, which the // grammar makes a following SIBLING rather than a child; those bodies - // used to attribute to the enclosing class. + // used to attribute to the enclosing class. The initializers are + // walked as such: the fn-ref scan skips them, or it would take each + // function value again for the enclosing file or class. + let mut walked = Vec::new(); if let Some(row) = row { self.stack.push(Scope { row, kind, name: name.clone() }); for init in property_initializers(node) { self.visit_function_body(init); + walked.push(init.id()); } for acc in following_accessors(node) { self.visit_function_body(acc); } self.stack.pop(); } - true + Some(walked) } // --- the dispatcher (visitNode, Kotlin-relevant branches) ----------------------- fn visit_node(&mut self, node: Node<'t>) { stack_guard!(); - if self.try_visit_hook(node) { - self.scan_fn_ref_subtree(node, 0); + if let Some(walked) = self.try_visit_hook(node) { + self.scan_fn_ref_subtree(node, 0, &walked); return; } @@ -856,7 +863,7 @@ impl<'t> Walker<'t> { // Hook-declined destructuring: extractField/extractVariable both // find no matching children for kotlin — NOTHING minted, RHS // invisible; candidates-only scan. - self.scan_fn_ref_subtree(node, 0); + self.scan_fn_ref_subtree(node, 0, &[]); skip_children = true; } else if kind == "import_header" { self.extract_import(node); @@ -1574,11 +1581,15 @@ impl<'t> Walker<'t> { }); } - fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) { + fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32, walked: &[usize]) { stack_guard!(); if depth > 12 { return; } + // Subtrees the body walker has already been through. + if walked.contains(&node.id()) { + return; + } // Halts at functionTypes (function_declaration) + the fixed list — // lambda_literal halts (no captures inside `by lazy { }` under a // hook-consumed property). @@ -1594,7 +1605,7 @@ impl<'t> Walker<'t> { self.maybe_capture_fn_refs(node); for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { - self.scan_fn_ref_subtree(c, depth + 1); + self.scan_fn_ref_subtree(c, depth + 1, walked); } } } diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs index bdec80f99..fabe63fe0 100644 --- a/codegraph-kernel/src/python.rs +++ b/codegraph-kernel/src/python.rs @@ -431,8 +431,10 @@ impl<'t> Walker<'t> { self.extract_class(node); skip_children = true; } else if kind == "assignment" && !self.inside_class_like() { - self.extract_variable(node); - self.scan_fn_ref_subtree(node, 0); + // The right-hand side extract_variable walked under the assigned + // name took its own candidates; the scan covers the rest. + let walked = self.extract_variable(node); + self.scan_fn_ref_subtree(node, 0, &walked); skip_children = true; } else if kind == "import_statement" || kind == "import_from_statement" { self.extract_import(node); @@ -564,7 +566,9 @@ impl<'t> Walker<'t> { } /// extractVariable's python branch: `left = right` at module scope. - fn extract_variable(&mut self, node: Node<'t>) { + /// Returns the right-hand side when it was walked under the assigned + /// name, for the declaration scan to skip. + fn extract_variable(&mut self, node: Node<'t>) -> Vec { 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)); @@ -589,16 +593,19 @@ impl<'t> Walker<'t> { // 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. + let mut walked = Vec::new(); 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(); + walked.push(right.id()); } None => self.visit_function_body(right), } } + walked } fn extract_import(&mut self, node: Node<'t>) { @@ -955,11 +962,15 @@ impl<'t> Walker<'t> { } } - fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) { + fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32, walked: &[usize]) { stack_guard!(); if depth > 12 { return; } + // Subtrees the body walker has already been through. + if walked.contains(&node.id()) { + return; + } // Halts at functionTypes ∪ the fixed arrow/lambda list — python's // `lambda` kind is NOT in that list (mirrored). if depth > 0 @@ -974,7 +985,7 @@ impl<'t> Walker<'t> { self.maybe_capture_fn_refs(node); for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { - self.scan_fn_ref_subtree(c, depth + 1); + self.scan_fn_ref_subtree(c, depth + 1, walked); } } } diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index 2353c69a4..f963bf0bd 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -500,8 +500,10 @@ impl<'t> Walker<'t> { // Inside a class-like scope the gate fails and the else-ladder // falls through with children VISITED — a trait const's value // expression emits calls refs from the trait node. - self.extract_variable(node); - self.scan_fn_ref_subtree(node, 0); + // The initializer extract_variable walked under the declared + // symbol took its own candidates; the scan covers the rest. + let walked = self.extract_variable(node); + self.scan_fn_ref_subtree(node, 0, &walked); skip_children = true; } else if kind == "use_declaration" { self.extract_import(node); @@ -721,9 +723,10 @@ impl<'t> Walker<'t> { /// extractVariable's generic fallback: kind is ALWAYS `variable` (no /// isConst hook), every direct `identifier` child mints a node positioned - /// at the CHILD, docstring shared, isExported present-false, no signature, - /// and the initializer value is never body-walked. - fn extract_variable(&mut self, node: Node<'t>) { + /// at the CHILD, docstring shared, isExported present-false, no signature. + /// The initializer is walked under the declared symbol (#693), and returned + /// then for the declaration scan to skip. + fn extract_variable(&mut self, node: Node<'t>) -> Vec { let docstring = preceding_docstring(node, self.src); let name_field = node.child_by_field_name("name"); // An impl's associated const is its type's (`Limits::MAX`), as its methods are. @@ -759,16 +762,19 @@ impl<'t> Walker<'t> { // `static REGISTRY: Lazy = Lazy::new(|| build())` dropped every call // inside the initializer, so a handler table or a lazily-built // singleton linked to nothing. + let mut walked = Vec::new(); if let Some(value) = node.child_by_field_name("value") { match declared { Some((row, name)) => { self.stack.push(Scope { row, kind: "variable", name }); self.visit_function_body(value); self.stack.pop(); + walked.push(value.id()); } None => self.visit_function_body(value), } } + walked } /// extractImport via the rust hook: import node named by the ROOT module + @@ -1430,11 +1436,15 @@ impl<'t> Walker<'t> { } } - fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) { + fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32, walked: &[usize]) { stack_guard!(); if depth > 12 { return; } + // Subtrees the body walker has already been through. + if walked.contains(&node.id()) { + return; + } if depth > 0 && matches!( node.kind(), @@ -1447,7 +1457,7 @@ impl<'t> Walker<'t> { self.maybe_capture_fn_refs(node); for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { - self.scan_fn_ref_subtree(c, depth + 1); + self.scan_fn_ref_subtree(c, depth + 1, walked); } } } diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index ce72780b6..abaaa24a1 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -530,9 +530,11 @@ impl<'t> Walker<'t> { } } - /// Returns the initializers it walked under a component wrapper, whose - /// fn-ref candidates that walk already took (the caller's scan of the - /// declaration skips them). Mirrors TreeSitterExtractor.extractVariable. + /// Returns the initializers it walked under the symbol they declare — a + /// constant's or a variable's (#693), a component wrapper's — whose fn-ref + /// candidates that walk already took for the symbol (the caller's scan of + /// the declaration skips them; it would take each again for the file). + /// Mirrors TreeSitterExtractor.extractVariable. pub(super) fn extract_variable(&mut self, node: Node<'t>) -> Option> { let mut walked: Option> = None; let is_const = self.is_const_decl(node); @@ -619,6 +621,7 @@ impl<'t> Walker<'t> { // the ownedObject branch of TreeSitterExtractor.extractVariable. if let (Some(row), Some(obj)) = (var_row, self.owned_object_value(value)) { self.extract_owned_object_members(obj, row, kind, &name, true, false); + walked.get_or_insert_with(HashSet::new).insert(obj.id()); continue; } @@ -678,6 +681,7 @@ impl<'t> Walker<'t> { self.stack.push(Scope { row, kind, name: name.clone() }); self.visit_function_body(v); self.stack.pop(); + walked.get_or_insert_with(HashSet::new).insert(v.id()); } None => self.visit_function_body(v), } diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index 4fd30ced0..69860a015 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -700,12 +700,16 @@ impl<'t> Walker<'t> { { if classify_ts_class_member(node) == Member::Property { let prop = self.extract_property(node); + // The initializer walked under the property took its own + // candidates; the scan covers the rest of the field. + let mut walked: Option> = None; if let (Some((row, name)), Some(value)) = (prop, node.child_by_field_name("value")) { self.stack.push(Scope { row, kind: "property", name }); self.visit_function_body(value); self.stack.pop(); + walked = Some(HashSet::from([value.id()])); } - self.scan_fn_ref_subtree(node, 0, None); + self.scan_fn_ref_subtree(node, 0, walked.as_ref()); } else { self.extract_method(node); } @@ -719,8 +723,9 @@ impl<'t> Walker<'t> { } else if self.variant.is_ts() && kind == "type_alias_declaration" { skip_children = self.extract_type_alias(node); } else if is_variable_type(kind) && !self.inside_class_like() { - // A component wrapper's initializer extract_variable walked under - // the component captured its own candidates; the scan skips it. + // An initializer extract_variable walked under the symbol it + // declares (a constant's, a component wrapper's) captured its own + // candidates; the scan skips it. let walked = self.extract_variable(node); self.scan_fn_ref_subtree(node, 0, walked.as_ref()); skip_children = true; diff --git a/src/extraction/languages/kotlin.ts b/src/extraction/languages/kotlin.ts index c41b2cd38..c84983094 100644 --- a/src/extraction/languages/kotlin.ts +++ b/src/extraction/languages/kotlin.ts @@ -293,12 +293,13 @@ export const kotlinExtractor: LanguageExtractor = { // grammar makes a following SIBLING rather than a child; those bodies used // to attribute to the enclosing class. Consumed here so the accessor // branch below can skip them without any cross-node state. - const inits = created - ? [...kotlinPropertyInitializers(node), ...kotlinFollowingAccessors(node)] - : []; - if (created && inits.length > 0) { + // The initializers are walked as such, so the dispatcher's + // function-as-value scan of the property skips them: it would take each + // function value again for the enclosing file or class. + if (created) { ctx.pushScope(created.id); - for (const init of inits) ctx.visitFunctionBody(init, created.id); + for (const init of kotlinPropertyInitializers(node)) ctx.walkInitializer(init, created.id); + for (const accessor of kotlinFollowingAccessors(node)) ctx.visitFunctionBody(accessor, created.id); ctx.popScope(); } return true; diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 6e5d40e51..b883febc1 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -56,11 +56,12 @@ export interface ExtractorContext { visitFunctionBody(body: SyntaxNode, functionId: string): void; /** * Walk a declaration's initializer as code the scope on top of the stack - * runs, the way a function body is walked. When the hook then reports the - * node handled, the function-as-value scan the dispatcher runs over it skips - * what was walked here: the walk captured those candidates already. + * runs, the way a function body is walked (`functionId` as for + * visitFunctionBody). When the hook then reports the node handled, the + * function-as-value scan the dispatcher runs over it skips what was walked + * here: the walk captured those candidates already. */ - walkInitializer(node: SyntaxNode): void; + walkInitializer(node: SyntaxNode, functionId?: string): void; /** Add an unresolved reference */ addUnresolvedReference(ref: UnresolvedReference): void; /** Push a node ID onto the scope stack (for containment/qualified name building) */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index efbebf985..ee757ab08 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1521,9 +1521,12 @@ export class TreeSitterExtractor { this.nodeStack.pop(); } // A field initializer can also register callbacks - // (`static handlers = { click: onClick }`) — scan it for - // function-as-value candidates (capture-only, halts at functions). - this.scanFnRefSubtree(node, 0); + // (`static handlers = { click: onClick }`). The walk above took those + // for the property, so the scan for function-as-value candidates + // (capture-only, halts at functions) skips the initializer it walked + // and covers the rest of the field: the class would keep a second + // reference to each function from the same spot. + this.scanFnRefSubtree(node, 0, propNode && valueNode ? new Set([valueNode.id]) : undefined); skipChildren = true; } else { this.extractMethod(node); @@ -1616,16 +1619,18 @@ export class TreeSitterExtractor { const definedType = declaredType && isClassLikeDefinition(declaredType) ? declaredType : null; if (definedType) this.visitNode(definedType); const walked = this.extractVariable(node); - // extractVariable doesn't walk every initializer shape (object literals - // are deliberately skipped; Python/Ruby don't walk at all), so scan the - // declaration subtree for function-as-value candidates — `const routes = - // { home: renderHome }`, `handlers = {"recv": target_cb}`. The scan halts - // at nested function definitions (their bodies are walked — and - // attributed — separately) and flush-time dedup absorbs any overlap with - // initializers extractVariable DOES walk. A type walked above, and a - // component wrapper's initializer extractVariable walked under the - // component (which must not hand the component it wraps to the file), - // captured their own. + // extractVariable doesn't walk every initializer (a store's members are + // extracted one by one, and only TS/JS, Python, Go and Rust walk one at + // all), so scan the declaration subtree for function-as-value candidates + // — `const routes = { home: renderHome }`, `handlers = {"recv": + // target_cb}`. The scan halts at nested function definitions (their + // bodies are walked — and attributed — separately). It skips what was + // walked already: a type walked above, and every initializer + // extractVariable walked under the symbol it declares — a constant's, a + // component wrapper's (which must not hand the component it wraps to the + // file). Those captured their own, and flush-time dedup keys on the + // source, so the file would keep a second reference to each function + // from the same spot. this.scanFnRefSubtree(node, 0, definedType ? new Set([definedType.id]) : walked); skipChildren = true; // extractVariable handles children } @@ -2016,9 +2021,9 @@ export class TreeSitterExtractor { createNode: (kind, name, node, extra) => self.createNode(kind, name, node, extra), visitNode: (node) => self.visitNode(node), visitFunctionBody: (body, functionId) => self.visitFunctionBody(body, functionId), - walkInitializer: (node) => { + walkInitializer: (node, functionId = '') => { (ctx.walked ??= new Set()).add(node.id); - self.visitFunctionBody(node, ''); + self.visitFunctionBody(node, functionId); }, addUnresolvedReference: (ref) => self.unresolvedReferences.push(ref), pushScope: (nodeId) => self.nodeStack.push(nodeId), @@ -2869,8 +2874,8 @@ export class TreeSitterExtractor { /** * Extract a class field declaration (e.g. Java field_declaration, C# field_declaration). * Extracts each declarator as a 'field' kind node inside the owning class. - * Returns the C# declarators it walked, for the function-as-value scan to - * skip. + * Returns the initializers it walked (a C# declarator, a Java `value`), for + * the function-as-value scan to skip. */ private extractField(node: SyntaxNode): Set { const walked = new Set(); @@ -2988,11 +2993,11 @@ export class TreeSitterExtractor { decl.namedChild(decl.namedChildCount - 1), varDecl ? getChildByField(varDecl, 'type') : null, ); - // Its function-as-value candidates are the field's, captured - // here once. (VB.NET captures none; Java's scan still takes - // its initializers for the class as well.) - walked.add(decl.id); } + // Its function-as-value candidates are the field's, captured here + // once (VB.NET captures none): the scan would take Java's again for + // the class. + walked.add(valueNode.id); this.visitFunctionBody(valueNode, fieldNode.id); this.nodeStack.pop(); } @@ -3584,9 +3589,11 @@ export class TreeSitterExtractor { * Extracts top-level and module-level variable declarations. * Captures the variable name and first 100 chars of initializer in signature for searchability. * - * Returns the initializers it walked under a component wrapper, whose - * function-as-value candidates that walk already took (the caller's scan of - * the declaration skips them). + * Returns the initializers it walked under the symbol they declare — a + * constant's or a variable's (#693), a component wrapper's — whose + * function-as-value candidates that walk already took for the symbol. The + * caller's scan of the declaration skips them: it would take each again for + * the file, as a second reference to the same function from the same spot. */ private extractVariable(node: SyntaxNode): Set | undefined { if (!this.extractor) return undefined; @@ -3686,6 +3693,7 @@ export class TreeSitterExtractor { const ownedObject = varNode ? this.ownedObjectValue(valueNode) : null; if (varNode && ownedObject) { this.extractOwnedObjectMembers(ownedObject, varNode.id, true, false); + (walked ??= new Set()).add(ownedObject.id); continue; } @@ -3772,6 +3780,7 @@ export class TreeSitterExtractor { if (varNode) this.nodeStack.push(varNode.id); this.visitFunctionBody(valueNode, ''); if (varNode) this.nodeStack.pop(); + if (varNode) (walked ??= new Set()).add(valueNode.id); } if (extractObjectMethods && objectOfFns) { @@ -3820,6 +3829,7 @@ export class TreeSitterExtractor { if (assigned) this.nodeStack.push(assigned.id); this.visitFunctionBody(right, ''); if (assigned) this.nodeStack.pop(); + if (assigned) (walked ??= new Set()).add(right.id); } } else if (this.language === 'go') { // Go: var_declaration, short_var_declaration, const_declaration @@ -3860,6 +3870,7 @@ export class TreeSitterExtractor { if (varNode) this.nodeStack.push(varNode.id); this.visitFunctionBody(valueField, varNode?.id ?? ''); if (varNode) this.nodeStack.pop(); + if (varNode) (walked ??= new Set()).add(valueField.id); } } @@ -4016,6 +4027,7 @@ export class TreeSitterExtractor { if (declared) this.nodeStack.push(declared.id); this.visitFunctionBody(valueNode, ''); if (declared) this.nodeStack.pop(); + if (declared) (walked ??= new Set()).add(valueNode.id); } } }