Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions __tests__/ts-chained-receiver.test.ts
Original file line number Diff line number Diff line change
@@ -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.<field>.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<unknown> {\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.<field>.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);
});
});
32 changes: 32 additions & 0 deletions codegraph-kernel/src/tsjs/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down
53 changes: 53 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down