diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfc0e9e8..ff4c86abe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ 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. +- `codegraph_explore` again lists a dynamic-dispatch link when the same two symbols are also joined by an ordinary call, instead of dropping it from the summary. +- Java fields initialized with a lambda or an anonymous class — `private final Runnable r = () -> doWork();`, `new LocationListener() { … }`, `Parcelable.Creator` — now contribute call edges, and the anonymous class and its overrides become real symbols instead of being invisible. Previously everything inside a field initializer was dropped, so a method reached only from one looked like it had no callers. Re-index with `codegraph index -f` after upgrading to pick up the new edges. +- Kotlin `init { }` blocks and destructuring declarations no longer swallow their code: `init { val cfg = load() }` and `val (a, b) = makePair()` contributed no call edge at all, and now attribute to the enclosing class or file. +- A Kotlin property's accessor body now belongs to the property whichever line it is written on, instead of being dropped (same line) or handed to the enclosing class (own line). +- Kotlin properties that hold a lambda, a SAM callback or an anonymous object — `private val frameListener = CameraFrameListener { … }`, the way Android and MSDK callbacks are almost always declared — now contribute call edges. Previously everything inside such an initializer was dropped, so a function reached only through one of these callbacks looked like it had no callers at all and its blast radius came back far too small. Delegated properties (`by lazy { … }`) and plain initializers (`val x = compute()`) were affected the same way and are fixed too. Re-index with `codegraph index -f` after upgrading to pick up the new edges. - The blast-radius section of `codegraph_explore` flagged "no covering tests found" whenever no test called a symbol directly — falsely branding helpers that tests exercise through their callers as untested (about 40% of flagged symbols in a measured sample). The check now follows caller chains up to 3 hops and reports indirect coverage as "tested via callers"; when nothing is found it states exactly what was checked instead of an unconditional warning. Thanks @inth3shadows for measuring the false-positive rate. (#1475) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 784023952..320f99aa5 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -931,6 +931,42 @@ const token = getTokenMp(); ); expect(call).toBeDefined(); }); + + describe('initializer walk is scoped to the declared symbol (#693 for TS/JS)', () => { + const code = ` +const eager = load(); +const obj = { handler: () => target(), plain: target() }; +const list = [() => target()]; +export const exported = { handler: () => target() }; +`; + const callersOf = (name: string) => { + const result = extractFromSource('app.ts', code); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.unresolvedReferences + .filter((u) => u.referenceKind === 'calls' && u.referenceName === name) + .map((u) => byId.get(u.fromNodeId)) + .map((n) => (n ? `${n.kind}:${n.name}` : '?')) + .sort(); + }; + + it("a plain call initializer names the CONSTANT as caller, not the file", () => { + // The walk ran with only the file on the stack, so `load` recorded the + // file as its caller — useless for callers/impact. + expect(callersOf('load')).toEqual(['constant:eager']); + }); + + it('a non-exported object literal contributes calls (it was skipped outright)', () => { + // `exported`'s members are minted as their own function nodes, so its + // arrow's call comes from `handler`; the non-exported ones attribute to + // the declared constant. + expect(callersOf('target')).toEqual([ + 'constant:list', + 'constant:obj', + 'constant:obj', + 'function:handler', + ]); + }); + }); }); describe('File Node Extraction', () => { @@ -1019,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', () => { @@ -1174,6 +1246,26 @@ impl Counter { ); expect(implRefs).toHaveLength(0); }); + + it('walks a const/static initializer scoped to the declared symbol (#693 for Rust)', () => { + // The declaration minted a node and stopped, so a handler table, a + // lazily-built singleton or any computed const linked to nothing. + const code = ` +const LEN: usize = compute_len(); +static REGISTRY: Lazy = Lazy::new(|| build_cfg()); +`; + const result = extractFromSource('lib.rs', code); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + const owner = (name: string) => { + const u = result.unresolvedReferences.find( + (r) => r.referenceKind === 'calls' && r.referenceName === name + ); + const n = u ? byId.get(u.fromNodeId) : undefined; + return n ? `${n.kind}:${n.name}` : undefined; + }; + expect(owner('compute_len')).toBe('variable:LEN'); + expect(owner('build_cfg')).toBe('variable:REGISTRY'); + }); }); describe('Java Extraction', () => { @@ -1337,6 +1429,37 @@ public class Splitter { ); expect(sepStart, 'override inside the lambda-returned anon class should be a method node').toBeDefined(); }); + + it('walks a field initializer scoped to the field (#693 for Java)', () => { + // The dispatcher only scanned a field_declaration for function-as-value + // candidates, so a lambda or anonymous class holding the work — the + // Android listener idiom — contributed no call edge and `target` looked + // callerless. + const code = ` +package p; +class T { + private final Runnable fieldLambda = () -> target(); + private final Runnable anonClass = new Runnable() { + public void run() { target(); } + }; + private final int eager = compute(); + void directCall() { target(); } + private void target() {} + private static int compute() { return 1; } +} +`; + const result = extractFromSource('T.java', code); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + const callersOf = (name: string) => + result.unresolvedReferences + .filter((u) => u.referenceKind === 'calls' && u.referenceName === name) + .map((u) => byId.get(u.fromNodeId)?.name) + .sort(); + + // `run` is the anonymous class's override, itself extracted under the field. + expect(callersOf('target')).toEqual(['directCall', 'fieldLambda', 'run']); + expect(callersOf('compute')).toEqual(['eager']); + }); }); describe('C# Extraction', () => { @@ -1940,6 +2063,120 @@ class Bar { const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar'); expect(cls?.qualifiedName).toBe('Bar'); }); + + describe('property initializers are walked, attributed to the property (#693 for Kotlin)', () => { + // The property hook consumes the whole property_declaration subtree, so + // before this the initializer was only scanned for function-as-value + // candidates and every call inside it vanished from the graph. Android/MSDK + // callbacks are declared exactly this way (`private val l = Listener { … }`), + // so anything reached only through one looked like it had no callers at all. + const code = ` +package repro + +class Repro { + private val fieldLambda: () -> Unit = { target() } + private val samField = Runnable { target() } + private val plain = target() + private val delegated by lazy { target() } + private val anonObject = object : Runnable { override fun run() { target() } } + + fun directCall() { target() } + fun lambdaInMethod() { run { target() } } + + private fun target() {} +} + +object Holder { + val topLevelLambda: () -> Unit = { hit() } + private fun hit() {} +} +`; + const callersOf = (target: string) => { + const result = extractFromSource('Repro.kt', code); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.unresolvedReferences + .filter((u) => u.referenceKind === 'calls' && u.referenceName === target) + .map((u) => byId.get(u.fromNodeId)?.name) + .sort(); + }; + + it('a lambda / SAM / plain / delegated / object initializer calls FROM the property', () => { + // `run` is the anonymous object's override, extracted as its own node + // under `anonObject` — the same shape Go's initializer walk produces. + expect(callersOf('target')).toEqual([ + 'delegated', + 'directCall', + 'fieldLambda', + 'lambdaInMethod', + 'plain', + 'run', + 'samField', + ]); + }); + + it('a property in an `object` singleton is a caller too', () => { + expect(callersOf('hit')).toEqual(['topLevelLambda']); + }); + + it('an accessor body belongs to its property, written on either line', () => { + // `val x: T get() = …` nests the accessor UNDER the declaration; written + // on its own line the grammar makes it a following SIBLING instead. Both + // used to lose their calls (the nested one) or hand them to the enclosing + // class (the sibling); both now attribute to the property. + const src = ` +package p + +class C { + val sameLine: Int get() = compute() + val nextLine: Int + get() = compute() + var written: Int = 0 + set(v) { store(v) } + private fun compute(): Int = 1 + private fun store(v: Int) {} +} +`; + const result = extractFromSource('C.kt', src); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + const ownersOf = (name: string) => + result.unresolvedReferences + .filter((u) => u.referenceKind === 'calls' && u.referenceName === name) + .map((u) => { + const n = byId.get(u.fromNodeId); + return n ? `${n.kind}:${n.name}` : '?'; + }) + .sort(); + expect(ownersOf('compute')).toEqual(['field:nextLine', 'field:sameLine']); + expect(ownersOf('store')).toEqual(['field:written']); + }); + + it('an `init` block and a destructuring RHS no longer vanish', () => { + // Both mint no symbol of their own, so the hook consumed them and their + // code disappeared entirely; they now attribute to the enclosing scope. + const src = ` +package p + +class C { + init { val q = initCall() } + val (a, b) = makePair() +} + +val (t1, t2) = topMakePair() +`; + const result = extractFromSource('C.kt', src); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + const owner = (name: string) => { + const u = result.unresolvedReferences.find( + (r) => r.referenceKind === 'calls' && r.referenceName === name + ); + const n = u ? byId.get(u.fromNodeId) : undefined; + return n ? `${n.kind}:${n.name}` : undefined; + }; + expect(owner('initCall')).toBe('class:C'); + expect(owner('makePair')).toBe('class:C'); + expect(owner('topMakePair')).toBe('namespace:p'); + }); + }); }); describe('Dart Extraction', () => { @@ -7561,6 +7798,35 @@ def processData(): Unit = { const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); expect(calls.length).toBeGreaterThan(0); }); + + it('walks a val/var initializer scoped to the declared symbol (#693 for Scala)', () => { + // The val/var hook minted the node and returned true, so the dispatcher + // only scanned the subtree for function-as-value candidates — every call + // in an initializer was dropped, which on a `val`-heavy codebase + // (SpinalHDL, Akka wiring) is most of the wiring. + const code = ` +class C { + val fieldLambda: () => Unit = () => target() + val direct = target() + lazy val lazily = target() + private def target(): Unit = {} +} + +object O { + val topLambda = () => hit() + def hit(): Unit = {} +} +`; + const result = extractFromSource('C.scala', code); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + const callersOf = (name: string) => + result.unresolvedReferences + .filter((u) => u.referenceKind === 'calls' && u.referenceName === name) + .map((u) => byId.get(u.fromNodeId)?.name) + .sort(); + expect(callersOf('target')).toEqual(['direct', 'fieldLambda', 'lazily']); + expect(callersOf('hit')).toEqual(['topLambda']); + }); }); }); diff --git a/__tests__/fixtures/kernel-parity/Torture.java b/__tests__/fixtures/kernel-parity/Torture.java index 703dc3b3c..9ecb8d823 100644 --- a/__tests__/fixtures/kernel-parity/Torture.java +++ b/__tests__/fixtures/kernel-parity/Torture.java @@ -22,6 +22,15 @@ public class TortureService extends BaseService implements Runnable, AutoCloseab protected int count = 0; private final List names; int packagePrivate, secondDeclarator; + /** Field initializers — walked scoped to the field (#693). */ + private final Runnable fieldLambda = () -> helper(RETRY_LIMITS); + private final Runnable fieldAnonClass = new Runnable() { + @Override + public void run() { + helper(RETRY_LIMITS); + } + }; + private final Runnable fieldMethodRef = 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 50ddd0246..98b6ea4ed 100644 --- a/__tests__/fixtures/kernel-parity/torture.js +++ b/__tests__/fixtures/kernel-parity/torture.js @@ -73,3 +73,9 @@ export default { }, }, }; + +// Initializer walks attributed to the declared symbol (#693). A plain call +// leaked to the FILE node; a non-exported object literal was skipped outright. +const eagerConfig = loadConfig(); +const handlerMap = { onSave: () => persist(eagerConfig), onLoad: loadConfig() }; +const lazyList = [() => persist(eagerConfig)]; diff --git a/__tests__/fixtures/kernel-parity/torture.kt b/__tests__/fixtures/kernel-parity/torture.kt index 130611c81..c5531c55e 100644 --- a/__tests__/fixtures/kernel-parity/torture.kt +++ b/__tests__/fixtures/kernel-parity/torture.kt @@ -50,6 +50,13 @@ val topDelegated by lazy { WidgetK(1) } val (destA, destB) = makePair() val withGetter: Int get() = 42 +val initLambda: () -> Unit = { caller() } +val initSam = Runnable { caller() } +val initObject = object : Runnable { + override fun run() { + caller() + } +} class WidgetK(val size: Int, private var name: String = defaultName()) { val area: Int = size * size @@ -265,3 +272,20 @@ fun labeledLambda() { } fun whereClause(): Int where Int : Comparable = 1 + +class AccessorK { + val sameLineGetter: Int get() = compute() + var sameLinePair: Int get() = compute() + set(v) { draw(v) } +} + +class SiblingAccessorK { + var nextLine: Int = 0 + get() = compute() + set(v) { draw(v) } + val (localA, localB) = makePair() + init { + val fromInit = compute() + register(fromInit) + } +} 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__/fixtures/kernel-parity/torture.rs b/__tests__/fixtures/kernel-parity/torture.rs index 0f7e80c4e..da2a168ff 100644 --- a/__tests__/fixtures/kernel-parity/torture.rs +++ b/__tests__/fixtures/kernel-parity/torture.rs @@ -209,3 +209,8 @@ fn mount() { } routes![top_level_h]; + +// Initializer walks attributed to the declared symbol (#693). +const INIT_CONST: usize = compute_len(); +static INIT_LAZY: Lazy = Lazy::new(|| build_cfg()); +static INIT_ALIAS: fn() = free_fn; diff --git a/__tests__/fixtures/kernel-parity/torture.scala b/__tests__/fixtures/kernel-parity/torture.scala index de536e659..c1e4703ae 100644 --- a/__tests__/fixtures/kernel-parity/torture.scala +++ b/__tests__/fixtures/kernel-parity/torture.scala @@ -175,3 +175,10 @@ package object utilpkg { def pkgHelper(): Int = 1 val pkgShared = 2 } + +class InitWalk { + val initLambda: () => Unit = () => helperCall() + val initDirect = helperCall() + lazy val initLazy = process(1) + val initAnon = new Runnable { def run(): Unit = helperCall() } +} 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/__tests__/kernel-kotlin-parity.test.ts b/__tests__/kernel-kotlin-parity.test.ts index 4e4540882..649c44455 100644 --- a/__tests__/kernel-kotlin-parity.test.ts +++ b/__tests__/kernel-kotlin-parity.test.ts @@ -5,7 +5,9 @@ * compiled from the vendored fwcd 0.3.8 C sources, the arc's first * vendored-grammar-C language) produces the SAME ExtractionResult as the * wasm TreeSitterExtractor over the checked-in torture fixture (torture.kt: - * the property hook's scope classification, extension-function receiver QNs + * the property hook's scope classification and its initializer walk (a + * lambda / SAM / anonymous-object RHS attributing its calls to the property), + * extension-function receiver QNs * (`WidgetK::extend`, the qualified `com::qext` bug) + the owner-contains * fallback, expect/actual → node DECORATORS (the KMP synthesizer feed), * the bodiless-vs-bodied class header asymmetry, comment-glued diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index d2017a093..da789b7b3 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -751,6 +751,16 @@ impl<'t> Walker<'t> { if let Some(row) = row { self.extract_decorators_for(node, row); self.extract_type_annotations(node, row); + // Walk the initializer ATTRIBUTED to the declared field + // (#693, the Go fix): the dispatcher only fn-ref-scans this + // subtree, so a lambda / method reference / anonymous class + // in `private final Runnable r = () -> target();` emitted no + // call edge at all. + if let Some(value) = decl.child_by_field_name("value") { + self.stack.push(Scope { row, kind: field_kind, name: name.clone() }); + self.visit_function_body(value); + self.stack.pop(); + } } } } else { diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index ee47d8758..366705700 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -9,10 +9,10 @@ //! is source-order dependent) and extractModifiers (expect/actual platform //! modifiers → the node DECORATORS wire field, on every created node — the //! KMP synthesizer's input). Preserved on purpose: the FIELD_COUNT-0 dead -//! cluster (no signatures, ZERO type-annotation refs), hook-consumed property -//! initializers emitting nothing, the bodiless-class header re-walk asymmetry, -//! enum-entry bodies being invisible, KDoc (`multiline_comment`) never being -//! a docstring AND chain-breaking, comment-gluing into import/package extents, +//! cluster (no signatures, ZERO type-annotation refs), the bodiless-class +//! header re-walk asymmetry, enum-entry bodies being invisible, KDoc +//! (`multiline_comment`) never being a docstring AND chain-breaking, +//! comment-gluing into import/package extents, //! `@Anno(args)` emitting nothing while `@Anno` emits decorates, zero //! instantiates refs (constructors are capitalized `calls`), the qualified- //! receiver `com::qext` bug, the paren-then-lambda `trailing()` garbage @@ -87,6 +87,66 @@ fn strip_js_ws(s: &str) -> String { s.chars().filter(|c| !is_js_space(*c)).collect() } +/// A property's CODE children: the named child right after the `=` token, a +/// `property_delegate` (`by lazy { … }`), and an accessor the grammar nested +/// under the declaration (`val x: Int get() = compute()` — written on ONE line; +/// an accessor on its own line parses as a SIBLING of the property and is not +/// reachable from here). What stays unwalked is the declaration itself — +/// modifiers, the `val`/`var` keyword, the name+type, and an extension +/// receiver's type and type parameters. (Go's #693 fix walks the `value` field +/// for the same reason; this grammar exposes no fields at all, hence the `=` +/// anchor.) +fn property_initializers<'t>(node: Node<'t>) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut after_eq = false; + for i in 0..node.child_count() { + let Some(c) = node.child(i) else { continue }; + if !c.is_named() { + if c.kind() == "=" { + after_eq = true; + } + continue; + } + if after_eq { + out.push(c); + after_eq = false; + } else if matches!(c.kind(), "property_delegate" | "getter" | "setter") { + out.push(c); + } + } + out +} + +/// Accessors written on their OWN line parse as SIBLINGS of the property, not +/// as children of it (same-line ones nest — see property_initializers). Walking +/// back over any accessors between us and the declaration finds the property an +/// accessor belongs to; None when this accessor stands alone. +fn accessor_owner<'t>(node: Node<'t>) -> Option> { + let mut p = node.prev_named_sibling(); + while let Some(n) = p { + if matches!(n.kind(), "getter" | "setter") { + p = n.prev_named_sibling(); + continue; + } + return if n.kind() == "property_declaration" { Some(n) } else { None }; + } + None +} + +/// The sibling accessors that follow a property declaration, in source order. +fn following_accessors<'t>(node: Node<'t>) -> Vec> { + let mut out = Vec::new(); + let mut n = node.next_named_sibling(); + while let Some(c) = n { + if !matches!(c.kind(), "getter" | "setter") { + break; + } + out.push(c); + n = c.next_named_sibling(); + } + out +} + struct Scope { row: u32, kind: &'static str, @@ -583,25 +643,23 @@ impl<'t> Walker<'t> { // --- the visitNode hook (property branch ONLY — fun-interface recovery is // defer-shielded and not ported) ------------------------------------------------ - fn try_visit_hook(&mut self, node: Node<'t>) -> bool { - if node.kind() != "property_declaration" { - return false; - } + /// A property's node kind, or None when the declaration mints no node at + /// all: destructuring, an unreadable name, or a local (inside a function + /// body / `init` block / lambda / accessor). Kind by enclosing scope — a + /// singleton `object` / `companion object` (and a top-level property) holds + /// SHARED values (`val`→constant, `var`→variable, the Scala-object rule; a + /// `const val` is just a val); a class/interface/enum instance `val`/`var` + /// is per-instance state → `field`. + fn property_kind(&self, node: Node<'t>) -> Option<&'static str> { let var_decl = (0..node.named_child_count()) .filter_map(|i| node.named_child(i)) - .find(|c| c.kind() == "variable_declaration"); - let name_node = var_decl.and_then(|vd| { - (0..vd.named_child_count()) - .filter_map(|i| vd.named_child(i)) - .find(|c| c.kind() == "simple_identifier") - }); - let Some(name_node) = name_node else { return false }; // destructuring → decline - let name = self.text(name_node).to_string(); - if name.is_empty() { - return false; + .find(|c| c.kind() == "variable_declaration")?; + let name_node = (0..var_decl.named_child_count()) + .filter_map(|i| var_decl.named_child(i)) + .find(|c| c.kind() == "simple_identifier")?; + if self.text(name_node).is_empty() { + return None; } - - // Scope walk up the parent chain — first match wins. let mut scope: &str = "const"; let mut p = node.parent(); while let Some(pn) = p { @@ -624,24 +682,89 @@ impl<'t> Walker<'t> { p = pn.parent(); } if scope == "local" { - return true; // a local — extract nothing, subtree still scanned + return None; } - let binding = (0..node.named_child_count()) .filter_map(|i| node.named_child(i)) .find(|c| c.kind() == "binding_pattern_kind"); let is_val = binding.map(|b| self.text(b) == "val").unwrap_or(false); - let kind: &'static str = if scope == "instance" { + Some(if scope == "instance" { "field" } else if is_val { "constant" } else { "variable" + }) + } + + fn try_visit_hook(&mut self, node: Node<'t>) -> bool { + // 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 + // accessors were NOT consumed and must keep falling through. + if matches!(node.kind(), "getter" | "setter") { + return accessor_owner(node) + .and_then(|owner| self.property_kind(owner)) + .is_some(); + } + if node.kind() != "property_declaration" { + return false; + } + let var_decl = (0..node.named_child_count()) + .filter_map(|i| node.named_child(i)) + .find(|c| c.kind() == "variable_declaration"); + let name_node = var_decl.and_then(|vd| { + (0..vd.named_child_count()) + .filter_map(|i| vd.named_child(i)) + .find(|c| c.kind() == "simple_identifier") + }); + // Destructuring (`val (a, b) = makePair()`): NEITHER arm mints a symbol + // for the destructured names — declining just routes the node to + // extractField/extractVariable, which both find nothing for kotlin and + // end in the same fn-ref scan. But the RHS is CODE, and it was vanishing + // whole. Consume the node here and walk it at the ENCLOSING scope (no + // symbol of its own to attribute to). + let Some(name_node) = name_node else { + for init in property_initializers(node) { + self.visit_function_body(init); + } + return true; + }; + let name = self.text(name_node).to_string(); + if name.is_empty() { + return false; + } + let Some(kind) = self.property_kind(node) else { + // A local — no node is minted, but the initializer is still code. + // Walk it at the ENCLOSING scope: an `init { }` block's + // `val q = load()` is the CLASS calling load, and it used to + // disappear entirely (only the block's bare statements survived). + for init in property_initializers(node) { + self.visit_function_body(init); + } + return true; }; // The `type`-field signature read is dead (zero fields) → signature // undefined; NO docstring/visibility/isStatic — the modifiers merge in // create_node still decorates expect/actual properties. - self.create_node(kind, &name, node, Extra::default()); + let row = self.create_node(kind, &name, node, Extra::default()); + // Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go + // fix, ported): without this the subtree is only fn-ref-scanned, so a + // lambda / SAM / object initializer (`val cb = Runnable { target() }` — + // 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. + 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); + } + for acc in following_accessors(node) { + self.visit_function_body(acc); + } + self.stack.pop(); + } true } 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/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index b5b9da3a9..abc0efefb 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -650,6 +650,8 @@ impl<'t> Walker<'t> { /// and the initializer value is never body-walked. fn extract_variable(&mut self, node: Node<'t>) { let docstring = preceding_docstring(node, self.src); + let name_field = node.child_by_field_name("name"); + let mut declared: Option<(u32, String)> = None; for i in 0..node.named_child_count() { let Some(child) = node.named_child(i) else { continue }; if child.kind() != "identifier" { @@ -657,7 +659,7 @@ impl<'t> Walker<'t> { } let name = self.text(child).to_string(); if !name.is_empty() { - self.create_node( + let row = self.create_node( "variable", &name, child, @@ -667,6 +669,26 @@ impl<'t> Walker<'t> { ..Extra::default() }, ); + if let (Some(row), Some(nf)) = (row, name_field) { + if child.start_byte() == nf.start_byte() { + declared = Some((row, name)); + } + } + } + } + // Walk the initializer ATTRIBUTED to the declared symbol (#693): + // `const N: usize = compute()` and + // `static REGISTRY: Lazy = Lazy::new(|| build())` dropped every call + // inside the initializer, so a handler table or a lazily-built + // singleton linked to nothing. + 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(); + } + None => self.visit_function_body(value), } } } diff --git a/codegraph-kernel/src/scala.rs b/codegraph-kernel/src/scala.rs index eb7a0bd3d..01aa36bf7 100644 --- a/codegraph-kernel/src/scala.rs +++ b/codegraph-kernel/src/scala.rs @@ -652,6 +652,18 @@ impl<'t> Walker<'t> { if let (Some(row), Some(t)) = (created, type_node) { self.emit_scala_type_refs(t, row); } + // Walk the initializer ATTRIBUTED to the declared symbol + // (#693, the Go fix): the hook consumes this subtree and the + // dispatcher only fn-ref-scans it, so `val cb = () => target()` + // — and even a plain `val x = compute()` — emitted no call edge + // at all. + if let Some(row) = created { + if let Some(value) = node.child_by_field_name("value") { + self.stack.push(Scope { row, kind, name: name.clone() }); + self.visit_body(value); + self.stack.pop(); + } + } true } "enum_case_definitions" => { diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index ad0440d6d..66679943d 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -398,18 +398,26 @@ impl<'t> Walker<'t> { } } - // Walk the initializer for calls — except the object/store shapes - // whose members are extracted method-by-method below. + // Walk the initializer for calls, ATTRIBUTED to the declared symbol + // (#693) — except the object/store shapes whose members are + // extracted method-by-method below (walking those too would + // double-count each member arrow's calls). Before this the walk ran + // with only the FILE on the stack (`const cfg = load()` recorded the + // file as load's caller) and object literals were skipped outright. + let members_extracted_separately = extract_object_methods + || rtk_endpoints.is_some() + || pinia_setup.is_some() + || !store_collections.is_empty(); if let Some(v) = value { - let vk = v.kind(); - if vk != "object" - && vk != "object_expression" - && !(extract_object_methods && vk == "call_expression") - && rtk_endpoints.is_none() - && pinia_setup.is_none() - && store_collections.is_empty() - { - self.visit_function_body(v); + if !members_extracted_separately { + match var_row { + Some(row) => { + self.stack.push(Scope { row, kind, name: name.clone() }); + self.visit_function_body(v); + self.stack.pop(); + } + None => self.visit_function_body(v), + } } } diff --git a/docs/design/kotlin-kernel-port-checklist.md b/docs/design/kotlin-kernel-port-checklist.md index 1d5fd3e58..fcbdcd9c8 100644 --- a/docs/design/kotlin-kernel-port-checklist.md +++ b/docs/design/kotlin-kernel-port-checklist.md @@ -278,10 +278,18 @@ Hooks PRESENT (port each exactly): but createNode's extractModifiers merge still runs, so `expect val` / `actual val` DO get decorators. Return true → the dispatcher runs `scanFnRefSubtree(node, 0)` (capture-only, halts at nested - function/lambda types) and NEVER descends → **property initializers - emit NO calls/instantiates refs anywhere** (`val SHARED = WidgetK(0)` - → nothing; `by lazy { compute() }` → nothing, the scan halts at the - lambda_literal). Consequences pinned in `extract-torture.txt`. + function/lambda types) and never descends on its own. **The hook itself + then walks the property's RHS under the property's scope** — the named + child after the `=` token plus a `property_delegate` — via + `ctx.visitFunctionBody`, so `val SHARED = WidgetK(0)`, `val cb = + Runnable { hit() }` and `by lazy { compute() }` all emit their calls + FROM the property node (Go's #693 initializer walk, ported). The + declaration's own children — modifiers, `val`/`var`, the name+type, an + extension receiver's type and type parameters, `getter`/`setter` — are + NOT walked: a same-line `val c get() = f()` still emits nothing, a + next-line accessor still attributes to the class, and a + hook-DECLINED destructuring RHS is still invisible. + Consequences pinned in `extract-torture.txt`. 2. **`lambda_literal` after a fun-interface ERROR (:139-143)** and 3. **fun-interface misparse recovery (:145-214)** (ERROR/ function_declaration shapes; `isFunInterfaceNode` :46; Pattern 1 walks @@ -391,7 +399,7 @@ Hooks ABSENT (the walker must NOT do these): `preParse`, `resolveName`, | `anonymous_initializer` (`init { }`) | no branch | recursed → its statements' calls → **`calls` refs FROM THE CLASS node**; its `val` locals → hook 'local' → nothing (pinned: `calls "register" from=class:WidgetK`) | | `secondary_constructor` | no branch | **NO constructor node**; recursed → body calls attribute to the CLASS (`calls "log" from=class:WidgetK`); the `constructor_delegation_call`'s value_arguments still feed fn-ref capture | | `getter`/`setter` as SIBLINGS (accessor on its own line) | no branch | recursed → accessor-body calls attribute to the CLASS (or file). See §Properties for the sibling/child split | -| `object_literal` (`object : T { … }` initializer) | no branch anywhere | never a node; see §Body walker for the method-leak quirk | +| `object_literal` (`object : T { … }` initializer) | no branch anywhere | never a node itself; inside a PROPERTY initializer the hook's walk reaches its `fun`s, which leak out as FUNCTIONS under the property (see §Body walker for the same method-leak quirk) | | `file_annotation` (`@file:JvmName("x")`) | no branch | recursed; its value_arguments feed fn-ref capture (string args → nothing). No decorates ref | | INSTANTIATION_KINDS (354-361) | **no kotlin member** | extractInstantiation:4610 is **UNREACHABLE** for kotlin — constructor calls `Foo()` are call_expressions → plain `calls` refs named `Foo` (capitalized). Kotlin emits **zero `instantiates` refs**, ever | | `impl_item`:1274 / property_signature:1282 / export_statement / swift property:1121 | never | not kotlin node kinds (the swift `property_declaration` branch at 1121-1193 is gated `language === 'swift'` — kotlin property_declarations never enter it) | @@ -659,7 +667,8 @@ refs — kotlin emits NO instantiates, §dispatch table); backticked ### Static-member / value-read refs (4750-4808) — kotlin IS in STATIC_MEMBER_LANGS (345-347) Called ONLY from the body walker (5218) — top-level/class-scope reads emit -nothing (hook-consumed property initializers doubly so). +nothing — EXCEPT a property initializer, which the hook now walks through +visitFunctionBody under the property's own scope (§Properties). `navigation_expression` ∈ MEMBER_ACCESS_TYPES (326). Mechanics: - callee-of-call skip (4772-4778): parent ∈ callTypes AND parent.namedChild(0) @@ -850,12 +859,13 @@ unwrap/ungatedModes/addressOfOnly. - Capture points: visitNode:990 (top-level/class-scope call args), visitFunctionBody:5137, scanFnRefSubtree (hook-consumed property subtrees — `val x = register(::f)` captures via the inner - value_arguments; **the scan halts at `lambda_literal` (610), so refs - inside `by lazy { }`/trailing lambdas under a hook-consumed property are - NOT captured**). **NOT captured anywhere: property/local initializer - callable refs (`val m = ::caller`, `val bound = w::render`) — kotlin's - dispatch has NO property_declaration/varinit key** (unlike SWIFT_SPEC — - do not borrow it). Pinned: torture emits exactly three function_refs — + value_arguments; **the scan halts at `lambda_literal` (610)**, but the + hook's own initializer walk (§Properties) covers the same subtree with the + PROPERTY on the stack, so refs inside `by lazy { }`/trailing lambdas are + captured there — a shallow `::ref` reachable by BOTH is emitted twice, once + from the class and once from the property). **NOT captured anywhere: + local initializer callable refs — kotlin's dispatch has NO + property_declaration/varinit key** (unlike SWIFT_SPEC — do not borrow it). Pinned: torture emits exactly three function_refs — `topLevel` (definedHere), `OtherClass::handle`, `this.caller`. - Flush gate (639-728): generated-file skip; `this.`-prefixed + `::`-containing candidates always flush; bare names need definedHere @@ -1039,7 +1049,7 @@ unwrap/ungatedModes/addressOfOnly. `Unit` / nullable / lambda return / `: T` generic leak; `expect fun` (bodiless + dec) / `actual fun`; tailrec self-call in expression body; top-level `val`/`var`/`const val`/`by lazy {}` (constant/variable kinds, - NO initializer refs, NO capture inside the delegate lambda) + + initializer + delegate refs attributed TO the property) + **destructuring (`val (a,b)` → nothing, both scopes)** + next-line-getter top-level `val` (getter calls → file/namespace); class with primary ctor (props invisible, defaults not walked), class-body val/var/computed diff --git a/src/extraction/cfml-extractor.ts b/src/extraction/cfml-extractor.ts index 2f4bc4779..edb4a4955 100644 --- a/src/extraction/cfml-extractor.ts +++ b/src/extraction/cfml-extractor.ts @@ -356,6 +356,13 @@ export class CfmlExtractor { .filter((e) => e.kind === 'contains' && e.source === innerFileNodeId) .map((e) => e.target) ); + // Snippet-top-level non-callables: `var x = …` locals of the enclosing + // function that the fragment-as-module parse mints as declarations. + const localVarIds = new Set( + result.nodes + .filter((n) => topLevelIds.has(n.id) && (n.kind === 'variable' || n.kind === 'constant')) + .map((n) => n.id) + ); for (const node of result.nodes) { if (node.kind === 'file') continue; node.startLine += startLine; @@ -385,7 +392,14 @@ export class CfmlExtractor { // top-level script in a .cfm template, or any statement directly in // the snippet body) attribute to the filtered-out snippet file node by // default — redirect those (and any genuinely unset ones) to parentId. - if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId) && parentId) ref.fromNodeId = parentId; + // Same for a snippet-top-level `var x = helper()`: the inner extractor + // parses the fragment as a whole module, so it mints a variable node and + // attributes the initializer's calls to it — but this fragment is a + // FUNCTION BODY, so `x` is a local and `helper` is the enclosing + // function's callee. Snippet-top-level FUNCTIONS keep their own calls. + if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId || localVarIds.has(ref.fromNodeId)) && parentId) { + ref.fromNodeId = parentId; + } this.unresolvedReferences.push(ref); } for (const error of result.errors) { diff --git a/src/extraction/languages/kotlin.ts b/src/extraction/languages/kotlin.ts index 5e3c4e2fa..144d52318 100644 --- a/src/extraction/languages/kotlin.ts +++ b/src/extraction/languages/kotlin.ts @@ -42,6 +42,99 @@ function extractKotlinReturnType(node: SyntaxNode, source: string): string | und return undefined; } +/** + * A property's CODE children: the named child right after the `=` token, a + * `property_delegate` (`by lazy { … }`), and an accessor the grammar nested + * under the declaration (`val x: Int get() = compute()` — written on ONE line; + * an accessor on its own line parses as a SIBLING of the property and is not + * reachable from here). What stays unwalked is the declaration itself — + * modifiers, the `val`/`var` keyword, the name+type, and an extension + * receiver's type and type parameters. (Go's #693 fix walks the `value` field + * for the same reason; tree-sitter-kotlin exposes no fields at all, hence the + * `=` anchor.) + */ +function kotlinPropertyInitializers(node: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + let afterEq = false; + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (!c) continue; + if (!c.isNamed) { + if (c.type === '=') afterEq = true; + continue; + } + if (afterEq) { + out.push(c); + afterEq = false; + } else if (c.type === 'property_delegate' || c.type === 'getter' || c.type === 'setter') { + out.push(c); + } + } + return out; +} + + +/** + * A property's node kind, or null when the declaration mints no node at all: + * destructuring (`val (a, b) = …`), an unreadable name, or a local (one inside + * a function body / `init` block / lambda / accessor). Kind by enclosing scope: + * a singleton `object` / `companion object` — and a top-level property — holds + * *shared* values, so `val`→`constant` and `var`→`variable` (the Scala-object + * rule; a `const val` is just a val). A `class`/`interface`/`enum` instance + * `val`/`var` is per-instance state → `field` (never a value-ref target, like a + * Java instance `final`). + */ +function kotlinPropertyKind( + node: SyntaxNode, + source: string +): 'field' | 'constant' | 'variable' | null { + const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration'); + const nameNode = varDecl?.namedChildren.find((c) => c.type === 'simple_identifier'); + if (!nameNode || !getNodeText(nameNode, source)) return null; + + let scope: 'local' | 'const' | 'instance' = 'const'; + for (let p = node.parent; p; p = p.parent) { + const pt = p.type; + if ( + pt === 'function_body' || pt === 'function_declaration' || + pt === 'lambda_literal' || pt === 'anonymous_initializer' || + pt === 'control_structure_body' || pt === 'getter' || pt === 'setter' + ) { scope = 'local'; break; } + if (pt === 'companion_object' || pt === 'object_declaration') { scope = 'const'; break; } + if (pt === 'class_declaration') { scope = 'instance'; break; } + } + if (scope === 'local') return null; + + const binding = node.namedChildren.find((c) => c.type === 'binding_pattern_kind'); + const isVal = binding != null && getNodeText(binding, source) === 'val'; + return scope === 'instance' ? 'field' : isVal ? 'constant' : 'variable'; +} + +/** + * Accessors written on their OWN line parse as SIBLINGS of the property, not as + * children of it (same-line ones nest — see kotlinPropertyInitializers). Walking + * back over any accessors between us and the declaration finds the property an + * accessor belongs to; null when this accessor stands alone (a grammar + * accident, or an accessor on a destructured/local declaration). + */ +function kotlinAccessorOwner(node: SyntaxNode): SyntaxNode | null { + for (let p = node.previousNamedSibling; p; p = p.previousNamedSibling) { + if (p.type === 'getter' || p.type === 'setter') continue; + return p.type === 'property_declaration' ? p : null; + } + return null; +} + +/** The sibling accessors that follow a property declaration, in source order. */ +function kotlinFollowingAccessors(node: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + for (let n = node.nextNamedSibling; n; n = n.nextNamedSibling) { + if (n.type !== 'getter' && n.type !== 'setter') break; + out.push(n); + } + return out; +} + /** Check if a node matches the `fun interface` misparse pattern */ function isFunInterfaceNode(node: SyntaxNode): boolean { let hasFun = false; @@ -88,48 +181,70 @@ export const kotlinExtractor: LanguageExtractor = { // Kotlin properties (`val` / `var` / `const val`). The name nests as // property_declaration → variable_declaration → simple_identifier, which the // generic variable/field path can't read — so nothing was extracted before. - // Kind by enclosing scope: a singleton `object` / `companion object` (and a - // top-level property) holds *shared* values — `val`→`constant`, - // `var`→`variable` (the Scala-object rule; a `const val` is a `val`). A - // `class`/`interface`/`enum` instance `val`/`var` is per-instance state → - // `field` (never a value-ref target, like a Java instance `final`). A - // property inside a function body / `init` block / lambda is a local and is - // skipped entirely. + // Kind comes from kotlinPropertyKind. if (node.type === 'property_declaration') { const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration'); const nameNode = varDecl?.namedChildren.find((c) => c.type === 'simple_identifier'); - if (!nameNode) return false; // destructuring `val (a,b)` etc. — leave to default + // Destructuring (`val (a, b) = makePair()`): no symbol is minted for the + // destructured names either way — declining just routes the node to + // extractField/extractVariable, which both find nothing for Kotlin and + // end in the same fn-ref scan. But the RHS is CODE, and it was vanishing + // whole. Consume the node here and walk it at the ENCLOSING scope (there + // is no symbol of its own to attribute it to). + if (!nameNode) { + for (const init of kotlinPropertyInitializers(node)) ctx.visitFunctionBody(init, ''); + return true; + } const name = getNodeText(nameNode, ctx.source); if (!name) return false; - // Walk to the nearest enclosing definition: a function body / init / lambda - // means it's a local; `object`/`companion object` is a constant scope; a - // `class_declaration` (covers class/interface/enum) is an instance scope. - let scope: 'local' | 'const' | 'instance' = 'const'; - for (let p = node.parent; p; p = p.parent) { - const pt = p.type; - if ( - pt === 'function_body' || pt === 'function_declaration' || - pt === 'lambda_literal' || pt === 'anonymous_initializer' || - pt === 'control_structure_body' || pt === 'getter' || pt === 'setter' - ) { scope = 'local'; break; } - if (pt === 'companion_object' || pt === 'object_declaration') { scope = 'const'; break; } - if (pt === 'class_declaration') { scope = 'instance'; break; } + const kind = kotlinPropertyKind(node, ctx.source); + if (kind == null) { + // A local — no node is minted, but the initializer is still code. Walk + // it at the ENCLOSING scope: an `init { }` block's `val q = load()` is + // the CLASS calling load, and it used to disappear entirely (only the + // block's bare statements survived). + for (const init of kotlinPropertyInitializers(node)) ctx.visitFunctionBody(init, ''); + return true; } - if (scope === 'local') return true; // a local — don't extract const binding = node.namedChildren.find((c) => c.type === 'binding_pattern_kind'); const isVal = binding != null && getNodeText(binding, ctx.source) === 'val'; - const kind = scope === 'instance' ? 'field' : isVal ? 'constant' : 'variable'; - const typeNode = node.childForFieldName('type'); const sig = typeNode ? `${isVal ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}` : undefined; - ctx.createNode(kind, name, node, { signature: sig }); + const created = ctx.createNode(kind, name, node, { signature: sig }); + // Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go + // fix, ported to Kotlin): the hook consumes this subtree, so without an + // explicit walk a lambda / SAM / object initializer + // (`private val cb = Runnable { target() }` — the idiomatic Android + // callback field) contributed NO call edge at all, and everything reached + // only through such a callback looked like it had no callers. + // 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. 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) { + ctx.pushScope(created.id); + for (const init of inits) ctx.visitFunctionBody(init, created.id); + ctx.popScope(); + } return true; } + // An own-line accessor already walked by its owning property above. The + // ownership test re-derives the property's kind rather than remembering it: + // a destructured or local declaration mints no node, so its accessors were + // NOT consumed and must keep falling through to the normal recursion. + if (node.type === 'getter' || node.type === 'setter') { + const owner = kotlinAccessorOwner(node); + return owner != null && kotlinPropertyKind(owner, ctx.source) != null; + } + // Handle Kotlin `fun interface` declarations. // Tree-sitter-kotlin doesn't support `fun interface` syntax (Kotlin 1.4+). // It produces two different misparse patterns: diff --git a/src/extraction/languages/scala.ts b/src/extraction/languages/scala.ts index 7e05daf66..b0d995faa 100644 --- a/src/extraction/languages/scala.ts +++ b/src/extraction/languages/scala.ts @@ -166,6 +166,16 @@ export const scalaExtractor: LanguageExtractor = { const created = ctx.createNode(kind, name, node, { signature: sig, visibility: extractVisibility(node) }); if (created && typeNode) emitScalaTypeRefs(typeNode, created.id, ctx, ctx.source); + // Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go + // fix): the hook consumes this subtree and the dispatcher only scans it + // for function-as-value candidates, so `val cb = () => target()` — and + // even a plain `val x = compute()` — emitted no call edge at all. + const valueNode = node.childForFieldName('value'); + if (created && valueNode) { + ctx.pushScope(created.id); + ctx.visitFunctionBody(valueNode, created.id); + ctx.popScope(); + } return true; } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9e53e62da..ba5668872 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2143,6 +2143,21 @@ export class TreeSitterExtractor { // and the language-aware path in `extractTypeAnnotations` descends // into that wrapper (#381). this.extractTypeAnnotations(node, fieldNode.id); + // Walk the initializer ATTRIBUTED to the declared field (#693, the + // Go fix; same shape as the TS/JS class-field walk above). The + // dispatcher only scanned this subtree for function-as-value + // candidates, so a lambda / method reference / anonymous class in + // `private final Runnable r = () -> target();` contributed NO call + // edge at all and `target` looked callerless. Keyed on the `value` + // FIELD, which only Java's `variable_declarator` carries — C#, + // VB.NET and PHP spell their initializer differently and are + // deliberately untouched here. + const valueNode = getChildByField(decl, 'value'); + if (valueNode) { + this.nodeStack.push(fieldNode.id); + this.visitFunctionBody(valueNode, fieldNode.id); + this.nodeStack.pop(); + } } } } else { @@ -2682,19 +2697,24 @@ export class TreeSitterExtractor { storeCollections.push(objectOfFns); } - // Visit the initializer body for calls — EXCEPT object literals (their - // function-valued properties are extracted below) and the store-factory - // / createApi / store-collection call whose nested objects we extract - // method-by-method below (walking the whole call would re-visit those - // method arrows and mis-attribute their inner calls to the file scope). - if (valueNode && - valueNode.type !== 'object' && - valueNode.type !== 'object_expression' && - !(extractObjectMethods && valueNode.type === 'call_expression') && - !rtkEndpoints && - !piniaSetup && - storeCollections.length === 0) { + // Visit the initializer body for calls, ATTRIBUTED to the declared + // symbol (#693) — EXCEPT the shapes whose members are extracted + // one-by-one below (the store-factory / createApi / store-collection + // objects), where walking the whole initializer would re-visit each + // member arrow and double-count its calls. + // + // Two things were wrong here before. The walk ran with only the FILE + // on the stack, so `const cfg = load()` recorded the FILE as load's + // caller — the exact leak Go's #693 fixed. And an object literal was + // skipped outright, so `const obj = { handler: () => target() }` + // contributed nothing at all unless the const was exported (only then + // does extractObjectLiteralFunctions mint the members). + const membersExtractedSeparately = + extractObjectMethods || !!rtkEndpoints || !!piniaSetup || storeCollections.length > 0; + if (valueNode && !membersExtractedSeparately) { + if (varNode) this.nodeStack.push(varNode.id); this.visitFunctionBody(valueNode, ''); + if (varNode) this.nodeStack.pop(); } if (extractObjectMethods && objectOfFns) { @@ -2719,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 @@ -2726,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 @@ -2869,6 +2902,8 @@ export class TreeSitterExtractor { } else { // Generic fallback for other languages // Try to find identifier children + const nameField = getChildByField(node, 'name'); + let declared: Node | null = null; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (child?.type === 'identifier' || child?.type === 'variable_declarator') { @@ -2877,13 +2912,30 @@ export class TreeSitterExtractor { : extractName(child, this.source, this.extractor); if (name && name !== '') { - this.createNode(kind, name, child, { + const created = this.createNode(kind, name, child, { docstring, isExported, }); + if (created && nameField && child.startIndex === nameField.startIndex) { + declared = created; + } } } } + // Walk the initializer ATTRIBUTED to the declared symbol (#693). Rust + // only for now: `const N: usize = compute()` and + // `static REGISTRY: Lazy = Lazy::new(|| build())` dropped every call + // inside the initializer, so a handler table or a lazily-built singleton + // linked to nothing. The other languages sharing this fallback spell + // their initializer differently and get their own turn. + if (this.language === 'rust') { + const valueNode = getChildByField(node, 'value'); + if (valueNode) { + if (declared) this.nodeStack.push(declared.id); + this.visitFunctionBody(valueNode, ''); + if (declared) this.nodeStack.pop(); + } + } } } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 4a6e1a5fe..1c89305af 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2484,8 +2484,16 @@ export class ToolHandler { // fed only to the dynamic-dispatch-links scan below. const dynNamed = new Map(); const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']); + // RAW edges, not getCallers/getCallees: those return one row per NEIGHBOUR + // (the #1086 de-dup), so when a pair is joined by BOTH a static and a + // synthesized edge the static one wins and the synthesized one becomes + // invisible — which is exactly what happens once a thunk's `dispatch(x)` + // is walked statically. The question here is about the graph, not about + // callers, so ask the edges directly. const hasHeuristicEdge = (id: string): boolean => - [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic'); + [...cg.getIncomingEdges(id), ...cg.getOutgoingEdges(id)].some( + (e) => e.provenance === 'heuristic' + ); for (const t of tokens) { const hits = this.findAllSymbols(cg, t).nodes; const cands = hits.filter((n) => CALLABLE.has(n.kind)); @@ -2533,9 +2541,16 @@ export class ToolHandler { const synthSeen = new Set(); for (const n of [...named.values(), ...dynNamed.values()]) { if (synthLines.length >= 6) break; - for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) { + // RAW edges for the same reason as hasHeuristicEdge above — a static + // edge over the same pair hides the synthesized one from getCallers. + const incident = [...cg.getIncomingEdges(n.id), ...cg.getOutgoingEdges(n.id)]; + for (const edge of incident) { if (synthLines.length >= 6) break; - if (edge.provenance !== 'heuristic' || other.id === n.id) continue; + if (edge.provenance !== 'heuristic') continue; + const otherId = edge.source === n.id ? edge.target : edge.source; + if (otherId === n.id) continue; + const other = cg.getNode(otherId); + if (!other) continue; if (skipInChain && skipInChain(edge)) continue; const src = edge.source === n.id ? n : other; const tgt = edge.source === n.id ? other : n;