diff --git a/__tests__/ts-chained-receiver.test.ts b/__tests__/ts-chained-receiver.test.ts new file mode 100644 index 000000000..71049d4a5 --- /dev/null +++ b/__tests__/ts-chained-receiver.test.ts @@ -0,0 +1,91 @@ +/** + * A TS/JS member call reached through a host namespace — `chrome.storage.local + * .get(k)`, `document.body.querySelector(s)` — ends in a platform API. Emitting + * the bare method name for it let every such call exact-match whatever project + * symbol shared the name, so a storage wrapper's `get` called itself (#1707). + * Those are dropped. A chain rooted at a project value keeps the bare name: + * `window.MyNs.run()` and `this..m()` reach real targets. + */ + +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'; + +let dir: string; +let cg: CodeGraph; + +beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1707-')); + const w = (rel: string, body: string) => fs.writeFileSync(path.join(dir, rel), body); + w( + 'storage.ts', + 'declare const chrome: any;\n' + + 'export const DraftHubStorage = {\n' + + ' async get(key: string): Promise {\n' + + ' const result = await chrome.storage.local.get([key]);\n' + + ' return result[key];\n' + + ' },\n' + + '};\n' + ); + w( + 'dom.ts', + 'export function querySelector(sel: string): string { return sel; }\n' + + 'export function findRow(): unknown {\n' + + ' return document.body.querySelector("tr");\n' + + '}\n' + ); + w( + 'service.ts', + 'declare const window: any;\n' + + 'export function ping(): string { return "pong"; }\n' + + 'export function viaGlobal(): string {\n' + + ' return window.MyNs.ping();\n' + + '}\n' + + 'export class Runner {\n' + + ' constructor(private svc: { ping(): string }) {}\n' + + ' run(): string { return this.svc.ping(); }\n' + + '}\n' + ); + cg = await CodeGraph.init(dir, { index: true }); + cg.resolveReferences(); +}); + +afterAll(() => { + cg.destroy(); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway. + } +}); + +const fn = (name: string, file: string) => + cg.getNodesByKind('function').find((n) => n.name === name && n.filePath === file)!; +const method = (qn: string) => cg.getNodesByKind('method').find((n) => n.qualifiedName === qn)!; +const callTargets = (id: string) => + cg + .getOutgoingEdges(id) + .filter((e) => e.kind === 'calls') + .map((e) => e.target); + +describe('TS/JS call through a host-global chain (#1707)', () => { + it('does not make a storage wrapper call itself through chrome.storage.local.get', () => { + const get = fn('get', 'storage.ts'); + expect(get).toBeDefined(); + expect(callTargets(get.id)).not.toContain(get.id); + }); + + it('does not bind document.body.querySelector to a same-named project function', () => { + expect(callTargets(fn('findRow', 'dom.ts').id)).not.toContain( + fn('querySelector', 'dom.ts').id + ); + }); + + it('keeps a chain rooted at a project value — window.MyNs.m() and this..m()', () => { + const ping = fn('ping', 'service.ts').id; + expect(callTargets(fn('viaGlobal', 'service.ts').id)).toContain(ping); + expect(callTargets(method('Runner::run').id)).toContain(ping); + }); +}); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index f240c5e79..f357548f3 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1065,6 +1065,28 @@ impl<'t> Walker<'t> { // --- extractCall (TS/JS generic tail) ------------------------------------------------- + /// Whether a member-call receiver is a chain rooted at a host object a + /// TS/JS project never declares. `window` is absent on purpose: + /// `window.MyNs.doThing()` reaches a project symbol (#1707). + fn is_host_global_chain(&self, receiver: Node<'t>) -> bool { + const HOST_GLOBAL_ROOTS: [&str; 19] = [ + "chrome", "browser", "document", "navigator", "performance", "console", + "localStorage", "sessionStorage", "indexedDB", "crypto", "globalThis", + "process", "Math", "JSON", "Object", "Array", "Reflect", "Promise", "Intl", + ]; + let mut cur = receiver; + if !matches!(cur.kind(), "member_expression" | "subscript_expression") { + return false; + } + while matches!(cur.kind(), "member_expression" | "subscript_expression") { + match cur.child_by_field_name("object") { + Some(next) => cur = next, + None => return false, + } + } + cur.kind() == "identifier" && HOST_GLOBAL_ROOTS.contains(&self.text(cur)) + } + pub(super) fn extract_call(&mut self, node: Node<'t>) { if self.stack.is_empty() { return; @@ -1092,6 +1114,16 @@ impl<'t> Walker<'t> { if is_literal_receiver(r.kind()) { return; } + // A chain rooted at a host namespace — `chrome.storage + // .local.get(k)`, `document.body.querySelector(s)` — + // ends in a platform API, so the bare method name emitted + // here could only exact-match an unrelated project symbol + // sharing it (#1707). Emit nothing. A chain rooted at a + // project value keeps the bare name. Mirrors the TS + // extractor's extractCall (extraction/tree-sitter.ts). + if self.is_host_global_chain(r) { + return; + } } let recv_ident = receiver.filter(|r| { matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier") diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 7ef90c273..60245c19c 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -388,6 +388,41 @@ const LITERAL_RECEIVER_TYPES = new Set([ 'dictionary', 'dict_literal', 'object', 'tuple', 'set', ]); +/** + * Languages whose member calls go through the TS/JS grammars. + */ +const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); + +/** + * Host objects a TS/JS project never declares: the browser, extension, and + * runtime namespaces, plus the builtin constructors whose statics are library + * calls. A member chain ROOTED at one of these ends in a platform API, so the + * bare method name the extractor used to emit for `chrome.storage.local.get(k)` + * or `document.body.querySelector(s)` could only ever exact-match an unrelated + * project symbol that happened to share the name (#1707). `window` is absent on + * purpose: `window.MyNamespace.doThing()` reaches a project symbol. + */ +const TS_JS_HOST_GLOBAL_ROOTS = new Set([ + 'chrome', 'browser', 'document', 'navigator', 'performance', 'console', + 'localStorage', 'sessionStorage', 'indexedDB', 'crypto', 'globalThis', + 'process', 'Math', 'JSON', 'Object', 'Array', 'Reflect', 'Promise', 'Intl', +]); + +/** Receiver node types (TS/JS grammars) that continue a member chain downward. */ +const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']); + +/** + * Root identifier of a TS/JS member chain — `chrome` for `chrome.storage.local` + * — or null when the chain bottoms out in a call, a literal, or `this`. + */ +function tsJsChainRoot(node: SyntaxNode, source: string): string | null { + let cur: SyntaxNode | null = node; + while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) { + cur = getChildByField(cur, 'object'); + } + return cur && cur.type === 'identifier' ? getNodeText(cur, source) : null; +} + /** * React hooks that bind a NAME to a handler function (`const onPress = * useCallback(() => {…}, [])`). The arrow inside is extracted as a function @@ -4588,6 +4623,24 @@ export class TreeSitterExtractor { // Go receivers resolve strictly via validated field-hop // inference (see matchGoFieldChainCall) or stay unresolved. calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`; + } else if ( + TS_JS_CHAIN_LANGUAGES.has(this.language) && + receiver && + TS_JS_CHAIN_RECEIVER_TYPES.has(receiver.type) && + TS_JS_HOST_GLOBAL_ROOTS.has(tsJsChainRoot(receiver, this.source) ?? '') + ) { + // TS/JS member call reached through a host namespace — + // `chrome.storage.local.get(key)`, `document.body.querySelector(s)`. + // The bare method name this used to emit exact-matched whatever + // project symbol shared it: every `chrome.storage.local.get/set` + // in a storage wrapper bound to the wrapper's own `get`/`set`, + // a self-edge not in the source (#1707). Emit nothing: a silent + // miss, never a wrong edge. A chain rooted at a project value + // (`window.MyNs.run()`, `store.getState().act()`, `ref.value.m()`) + // keeps the bare name — those targets are real, and dropping them + // would cost far more recall than the mis-bind costs precision. + // Mirrored in the kernel's extract_call (tsjs/extractors.rs). + return; } else { calleeName = methodName; }