diff --git a/CHANGELOG.md b/CHANGELOG.md index 877bd3f..a97683f 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 0000000..28412de --- /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 50ddd02..f1e57a6 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 900fc74..813e554 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 1722ad2..39f8de1 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 b2397fa..da46beb 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 577a879..db365ba 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 079b965..2d87412 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 16bdd0d..88e083e 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 9c00b9f..4c8936e 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;