diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8ab6f79..8fd99dcfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,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. - A file built around one very long function no longer takes the whole `codegraph_explore` answer for itself — or disappears from it. Previously such a file was shown in full however big it was, which used up the room every file after it needed, and when the function was larger than the entire response the file was dropped without a word. These files now come back as a bounded window on whole lines — the signature and the top of the body, plus the call site when the call path runs through it — with the rest one follow-up `codegraph_explore` away. - `codegraph_explore` no longer lets the first file in an answer spend the room set aside for the files below it, so the rest of the answer still arrives. Previously a large file near the top could quietly use up everything left, and the files ranked under it — each already judged relevant enough to include — were dropped with no source at all; on one question only one of six made it into the answer. Every file now keeps what it was given, and a question that really is about one file still concentrates on that file. - When a `codegraph_explore` answer runs right up against its size limit, it now drops the trailing notes rather than a whole file's source. Previously the last file was cut even though trimming the notes alone would have fit, so a file that had already been read, ranked and rendered was thrown away at the last moment. Across a range of real projects this returns one more file and up to 20% more source per call. diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 292658822..cfb198c86 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', () => { @@ -1175,6 +1247,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'); + }); + it('should extract union declarations and their impl edges', () => { const code = ` pub union Reg { @@ -1382,6 +1474,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', () => { @@ -1985,6 +2108,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', () => { @@ -7671,6 +7908,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 1e14b7b7a..8e594705a 100644 --- a/__tests__/fixtures/kernel-parity/torture.rs +++ b/__tests__/fixtures/kernel-parity/torture.rs @@ -210,6 +210,11 @@ 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; + pub union Reg { pub raw: u32, pub halves: [u16; 2], 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 16447c243..6fbb0e523 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -652,6 +652,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" { @@ -659,7 +661,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, @@ -669,6 +671,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 8d71d7f18..7d14087f4 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2159,6 +2159,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 { @@ -2698,19 +2713,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) { @@ -2735,6 +2755,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 @@ -2742,11 +2763,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 @@ -2885,6 +2918,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') { @@ -2893,13 +2928,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 d1d013514..da34e0c51 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2550,8 +2550,16 @@ export class ToolHandler { const isPreciseToken = (x: string) => /[._$]|::|\//.test(x) || /[a-z][A-Z]/.test(x) || /^[A-Z]/.test(x); const preciseNamedIds = new Set(); + // 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)); @@ -2602,9 +2610,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;