diff --git a/CHANGELOG.md b/CHANGELOG.md index 877bd3fb..a97683f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- A method called on the result of another call — `d.setdefault(k, []).append(v)`, `make().run()` — no longer produces a call edge to an unrelated top-level function that merely shares the name, in Python and JavaScript/TypeScript. The receiver is kept so the inner call still resolves; the outer method stays unresolved rather than guessed. Re-index after upgrading. (#1683, #1681) - **A definition its language makes file-local no longer captures calls from other files.** A C `static` in another source file (`.c`/`.cc`/… — not a header's `static inline`, which is textually included), a Kotlin/Java/C#/Swift/Scala/Dart/PHP `private` member, a Go unexported name in another package, and a Rust non-`pub` item outside its module subtree cannot be what a name in another file means, but name matching accepted them whenever the names agreed: an Android `editor.apply()` onto an unrelated class's `private fun apply`, a JavaScript `fail(...)` onto a Go `func fail`, a Rust `.count()` onto a private `fn count` in another crate, and C USB helpers onto a `static` in a `.c` they never link. Such a target is now declined after the whole name-matching pipeline settles — the reference stays unresolved rather than falling through to a fuzzy namesake. Same-file definitions, a child Rust module reaching its ancestors' private items, and Rust `impl Trait for Type` methods stay resolvable. Re-index after upgrading. (#1730, #1731) - **A binding in a module that exports nothing is no longer a cross-file target.** On vite, every `import { defineConfig } from 'vite'` across the playground resolved onto a `const vite = await createServer(…)` sitting at module scope in `playground/ssr-html/test-stacktrace.js` — a file with an import and no export, so that binding is reachable from nowhere but itself. Name matching commits as soon as one candidate survives, and nothing asked whether an import could reach the survivor; that one binding took 157 edges. A JS/TS file holding an `import` and no export of any kind now offers its locals to no other file. Classic scripts, CommonJS (including `exports["x"] = …`), a later `export { … }`, and names contributed through `declare global` are all unaffected. Across vite this removed 320 wrong edges and added 18, each addition a reference that was previously ambiguous rather than newly invented. Re-index after upgrading. (#1719) diff --git a/__tests__/call-receiver-no-fabrication.test.ts b/__tests__/call-receiver-no-fabrication.test.ts new file mode 100644 index 00000000..28412ded --- /dev/null +++ b/__tests__/call-receiver-no-fabrication.test.ts @@ -0,0 +1,81 @@ +/** + * A member call whose receiver is itself a call never fabricates an edge + * (#1683, #1681). `d.setdefault(k, []).append(v)` used to lose its receiver at + * extraction time, degrade to the bare `append`, and exact-match any top-level + * project function of that name — a call edge from an unrelated function, + * reproduced in Python and JavaScript alike. The receiver is now kept as + * `().`, which nothing name-matches; the inner call resolves + * on its own as before. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +let dir: string; +let cg: CodeGraph; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1683-')); + fs.mkdirSync(path.join(dir, 'py')); + fs.mkdirSync(path.join(dir, 'js')); + fs.writeFileSync(path.join(dir, 'py', '__init__.py'), ''); + fs.writeFileSync( + path.join(dir, 'py', 'collect.py'), + 'def append(item):\n return item\n\ndef get(key):\n return key\n\ndef make():\n return {}\n\n' + + 'def bucket(d, k, v):\n d.setdefault(k, []).append(v)\n return d.items().get(k)\n\n' + + 'def fresh():\n return make().get("x")\n' + ); + fs.writeFileSync( + path.join(dir, 'js', 'collect.js'), + 'function append(item) { return item; }\nfunction run() { return 1; }\nfunction make() { return {}; }\n' + + 'function bucket(d, k, v) { d.setdefault(k, []).append(v); make().run(); (0, make)().run(); }\n' + + 'module.exports = { append, run, make, bucket };\n' + ); + cg = CodeGraph.initSync(dir); + await cg.indexAll(); +}); + +afterAll(() => { + cg.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +const fn = (name: string, file: string) => cg.getNodesByName(name).find((n) => n.kind === 'function' && n.filePath.endsWith(file))!; +const calleesOf = (name: string, file: string) => + cg.getCallees(fn(name, file).id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.name).sort(); +// Callers through `calls` edges only — a `module.exports = { run }` value reference is not a call. +const callersOf = (name: string, file: string) => + cg.getCallers(fn(name, file).id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.name); + +describe('call-expression receivers (#1683)', () => { + it('Python: no edge from a call-result receiver to a same-named top-level function', () => { + expect(calleesOf('bucket', 'collect.py')).toEqual([]); + expect(callersOf('append', 'collect.py')).toEqual([]); + expect(callersOf('get', 'collect.py')).toEqual([]); + // The inner call still resolves on its own; `.get` on its unknown product does not. + expect(calleesOf('fresh', 'collect.py')).toEqual(['make']); + }); + + it('JavaScript: the same shape, and the inner call keeps its edge', () => { + expect(callersOf('append', 'collect.js')).toEqual([]); + // `make().run()` — what `make` returns is unknown, so `run` is not guessed. + expect(callersOf('run', 'collect.js')).toEqual([]); + expect(calleesOf('bucket', 'collect.js')).toEqual(['make']); + }); + + it('encodes the receiver as `().` and drops a receiver with no static callee', () => { + const r = extractFromSource('src/x.js', 'function f(d) { d.setdefault("k", []).append(1); make().run(); (0, make)().run(); arr[0]().go(); }'); + const names = r.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort(); + // `(0, make)` and `arr[0]` are the inner calls' own refs, unchanged; their chains are dropped. + expect(names).toEqual(['(0, make)', 'arr[0]', 'd.setdefault', 'd.setdefault().append', 'make', 'make().run']); + const py = extractFromSource('x.py', 'def f(d):\n d.setdefault("k", []).append(1)\n d.items().get(2)\n'); + expect(py.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort()) + .toEqual(['d.items', 'd.items().get', 'd.setdefault', 'd.setdefault().append']); + }); +}); diff --git a/__tests__/fixtures/kernel-parity/torture.js b/__tests__/fixtures/kernel-parity/torture.js index 50ddd024..f1e57a6b 100644 --- a/__tests__/fixtures/kernel-parity/torture.js +++ b/__tests__/fixtures/kernel-parity/torture.js @@ -73,3 +73,12 @@ export default { }, }, }; + +// --- call-expression receivers (#1683) ---------------------------------------- +function bucketChains(d, k, v) { + d.setdefault(k, []).append(v); + make().run(); + (0, make)().run(); + arr[0]().go(); + obj.make().run().again(); +} diff --git a/__tests__/fixtures/kernel-parity/torture.py b/__tests__/fixtures/kernel-parity/torture.py index 900fc74f..813e5541 100644 --- a/__tests__/fixtures/kernel-parity/torture.py +++ b/__tests__/fixtures/kernel-parity/torture.py @@ -47,3 +47,12 @@ def shadowed(): handlers = {"recv": target_cb} callbacks = [target_cb, view] + + +# --- call receivers (#1683) --------------------------------------------------- +def bucket_chains(d, k, v): + d.setdefault(k, []).append(v) + d.items().get(k) + make().run() + (lambda: make)()().run() + obj.make().run().again() diff --git a/__tests__/object-literal-methods.test.ts b/__tests__/object-literal-methods.test.ts index 1722ad2d..39f8de1c 100644 --- a/__tests__/object-literal-methods.test.ts +++ b/__tests__/object-literal-methods.test.ts @@ -55,7 +55,10 @@ describe('object-literal method extraction', () => { // so an in-store calls edge will resolve once the pipeline runs. const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!; const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id); - expect(fetchUserRefs.map((r) => r.referenceName)).toContain('reset'); + // `get().reset()` keeps its call receiver (#1683): the ref is the chain + // `get().reset`, which the resolver binds to the store's own `reset`. + expect(fetchUserRefs.map((r) => r.referenceName)).toContain('get().reset'); + expect(fetchUserRefs.map((r) => r.referenceName)).not.toContain('reset'); // The action's body wasn't mis-attributed to the file scope (the reason we // skip the generic body-visit for the store-factory call). diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs index b2397fac..da46beb8 100644 --- a/codegraph-kernel/src/python.rs +++ b/codegraph-kernel/src/python.rs @@ -608,6 +608,13 @@ impl<'t> Walker<'t> { } else { callee_name = method_name.to_string(); } + } else if let Some(r) = receiver.filter(|r| r.kind() == "call") { + // Call receiver — `d.setdefault(k, []).append(v)` (#1683): + // `().`, or nothing when the inner callee + // is not a plain name / attribute chain. Mirrors + // TreeSitterExtractor.extractCall. + let Some(inner) = self.plain_inner_callee(r) else { return }; + callee_name = format!("{inner}().{method_name}"); } else { callee_name = method_name.to_string(); } @@ -626,6 +633,22 @@ impl<'t> Walker<'t> { } } + /// The callee of a call receiver when it is a plain identifier or attribute + /// chain (`make`, `d.setdefault`), whitespace stripped (#1683). + fn plain_inner_callee(&self, call: Node<'t>) -> Option { + let inner = call.child_by_field_name("function")?; + let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect(); + if text.is_empty() { + return None; + } + let ok = text.split('.').all(|seg| { + let mut chars = seg.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + }); + if ok { Some(text) } else { None } + } + /// extractDecoratorsFor — python decorators are PRECEDING SIBLINGS inside /// decorated_definition. Only bare-identifier decorators yield a target /// (python's `call` kind isn't `call_expression`, and `attribute` isn't in diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 577a879e..db365ba1 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1103,9 +1103,14 @@ impl<'t> Walker<'t> { } else { callee_name = method_name.to_string(); } + } else if let Some(r) = receiver.filter(|r| r.kind() == "call_expression") { + // Call receiver — `make().run()` (#1683): keep the inner + // callee as `().`, or emit nothing when it + // is not a plain name / member chain. Mirrors + // TreeSitterExtractor.extractCall. + let Some(inner) = self.plain_inner_callee(r) else { return }; + callee_name = format!("{inner}().{method_name}"); } else { - // (the call-receiver re-encode branches are other - // languages'; TS/JS keeps the bare method name) callee_name = method_name.to_string(); } } @@ -1128,6 +1133,22 @@ impl<'t> Walker<'t> { // --- extractInstantiation ----------------------------------------------------------- + /// The callee of a call-expression receiver when it is a plain identifier + /// or member chain (`make`, `d.setdefault`), whitespace stripped (#1683). + fn plain_inner_callee(&self, call: Node<'t>) -> Option { + let inner = call.child_by_field_name("function")?; + let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect(); + if text.is_empty() { + return None; + } + let ok = text.split('.').all(|seg| { + let mut chars = seg.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') + }); + if ok { Some(text) } else { None } + } + pub(super) fn extract_instantiation(&mut self, node: Node<'t>) { if self.stack.is_empty() { return; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 079b9653..2d874126 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -4573,6 +4573,32 @@ export class TreeSitterExtractor { // scope keywords: such calls previously emitted a bare method // name, which either failed to resolve or resolved ambiguously. calleeName = `${getNodeText(receiver, this.source)}.${methodName}`; + } else if ( + (this.language === 'typescript' || + this.language === 'javascript' || + this.language === 'tsx' || + this.language === 'jsx' || + this.language === 'python') && + receiver && + (receiver.type === 'call_expression' || receiver.type === 'call') + ) { + // Receiver that is itself a call — `d.setdefault(k, []).append(v)`, + // `make().run()`, `res.json().data` (#1683). The bare method name + // this used to emit exact-matched any top-level project symbol of + // that name and fabricated a call edge from an unrelated function + // (`append`, `get`, `run`…). Keep the inner callee, encoded as + // `().` like the Java/Kotlin/C++ chains: the + // marker never appears in an ordinary ref, so nothing name-matches + // it, and a chain resolver can later infer the receiver's type + // from what the inner call returns. An inner callee that is not a + // plain name or member chain (`(await x)()`, `arr[0]()`) has no + // static receiver at all — emit nothing: a silent miss, never a + // wrong edge. The inner call is visited on its own either way. + // Mirrored in the kernel (tsjs/extractors.rs, python.rs). + const innerFn = getChildByField(receiver, 'function'); + const innerCallee = innerFn ? getNodeText(innerFn, this.source).replace(/\s+/g, '') : ''; + if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(innerCallee)) return; + calleeName = `${innerCallee}().${methodName}`; } else if ( this.language === 'go' && receiver && diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 16bdd0d8..88e083e0 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -974,6 +974,19 @@ export class ReferenceResolver { if (fwEarly) return fwEarly; // Strategy 2: Try import-based resolution + // A TS/JS/Python call-receiver chain (`useStore.getState().reset`, #1683) + // names the ROOT's import, not the method's: letting resolveViaImport see + // it binds the call to the imported store constant and the method is + // never looked up. The name-matcher owns the chain shape for these + // languages — the Java/Kotlin/C++ chains keep their existing path. + if ( + ref.referenceKind === 'calls' && + CHAIN_SHAPE.test(ref.referenceName) && + (ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python') + ) { + return this.gateLanguage(matchReference(ref, this.context), ref); + } + const tImp = this.profileStages ? process.hrtime.bigint() : 0n; const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref); if (this.profileStages) this.stageAdd('viaImport', ref, !!importResult, tImp); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9c00b9fe..4c8936e8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2507,6 +2507,30 @@ function matchRustSelfFieldCall( return null; } +/** + * The one fallback a TS/JS/Python call-receiver chain keeps (#1683): a STORE + * ACCESSOR. Zustand's `get()` inside the store factory and + * `useStore.getState()` outside it hand back the store whose actions are + * indexed as functions (#1573), so a unique callable of the method's name in + * the same language family is what `get().reset()` reaches. Nothing else + * qualifies: a chain rooted in a project value still says nothing about what + * the inner call RETURNS — `db.prepare(sql).all()` would bind to any project + * function named `all` — so it resolves to nothing, exactly like a chain + * rooted in a parameter (`d.setdefault(k, []).append(v)`). + */ +function matchStoreAccessorChain(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const m = ref.referenceName.match(/^([\w$.]+)\(\)\.(\w+)$/); + if (!m || !m[1] || !m[2]) return null; + const inner = m[1]; + const method = m[2]; + if (!(inner === 'get' || inner === 'getState' || inner.endsWith('.getState'))) return null; + const callables = context + .getNodesByName(method) + .filter((n) => (n.kind === 'function' || n.kind === 'method') && sameLanguageFamily(n.language, ref.language) && n.id !== ref.fromNodeId); + if (callables.length !== 1) return null; + return { original: ref, targetNodeId: callables[0]!.id, confidence: 0.6, resolvedBy: 'exact-match' }; +} + /** * Split a camelCase or PascalCase string into words. */ @@ -2917,6 +2941,19 @@ export function matchReference( if (result) return result; } + // A call-receiver chain the extractor encoded as `().` for a + // language with no chain resolver above (TS/JS, Python — #1683) is a + // receiver whose type is unknown. Nothing below may guess for it: the + // method-call pattern rejects the parens, exact name never matches, but the + // fuzzy strategy splits on `.` and would hand `make().run` to any `run` — + // the fabricated edge the encoding exists to prevent. + if ( + ref.referenceName.includes('().') && + (ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python') + ) { + return nmTimed('storeAccessorChain', ref, () => matchStoreAccessorChain(ref, context)); + } + // 2. Method call pattern result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context)); if (result) return result;