`chrome.storage.local.get(key)` and `document.body.querySelector(s)` end in a platform API, but the extractor emitted the bare method name for them. That name then exact-matched whatever project symbol shared it: in a Chrome extension every `chrome.storage.local.get/set` inside a storage wrapper bound to the wrapper's own `get`/`set`, giving self-edges that are not in the source (#1707). A member chain whose root identifier is a host object the project never declares now emits nothing — a silent miss instead of a wrong edge, the same trade the literal-receiver gate makes (#1230). `window` is deliberately not a host root: `window.MyNs.doThing()` reaches a project symbol. A chain rooted at a project value keeps the bare name, so `store.getState().act()`, `ref.value .m()` and `this.<field>.m()` are untouched. The Rust kernel mirrors the same gate. Verified on Linux: fail→pass on both kernel and wasm arms for `__tests__/ts-chained-receiver.test.ts` (2 fail / 1 pass on main → 3/3 with the fix). Lands / rebases https://github.com/colbymchenry/codegraph/pull/1710 onto current main. Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com> Co-authored-by: Aaron Queen <bompus@users.noreply.github.com>
This commit is contained in:
co-authored by
Colby McHenry
Aaron Queen
parent
90dcdbc827
commit
a7ea5ba730
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1065,6 +1065,28 @@ impl<'t> Walker<'t> {
|
|||||||
|
|
||||||
// --- extractCall (TS/JS generic tail) -------------------------------------------------
|
// --- 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>) {
|
pub(super) fn extract_call(&mut self, node: Node<'t>) {
|
||||||
if self.stack.is_empty() {
|
if self.stack.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -1092,6 +1114,16 @@ impl<'t> Walker<'t> {
|
|||||||
if is_literal_receiver(r.kind()) {
|
if is_literal_receiver(r.kind()) {
|
||||||
return;
|
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| {
|
let recv_ident = receiver.filter(|r| {
|
||||||
matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier")
|
matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier")
|
||||||
|
|||||||
@@ -388,6 +388,41 @@ const LITERAL_RECEIVER_TYPES = new Set([
|
|||||||
'dictionary', 'dict_literal', 'object', 'tuple', '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 =
|
* React hooks that bind a NAME to a handler function (`const onPress =
|
||||||
* useCallback(() => {…}, [])`). The arrow inside is extracted as a function
|
* useCallback(() => {…}, [])`). The arrow inside is extracted as a function
|
||||||
@@ -4624,6 +4659,24 @@ export class TreeSitterExtractor {
|
|||||||
// Go receivers resolve strictly via validated field-hop
|
// Go receivers resolve strictly via validated field-hop
|
||||||
// inference (see matchGoFieldChainCall) or stay unresolved.
|
// inference (see matchGoFieldChainCall) or stay unresolved.
|
||||||
calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`;
|
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 {
|
} else {
|
||||||
calleeName = methodName;
|
calleeName = methodName;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user