From a80039d12a36040d2bef78973d22432555c9501f Mon Sep 17 00:00:00 2001 From: Kunal Roy Choudhury Date: Mon, 9 Nov 2026 21:35:46 +0530 Subject: [PATCH] fix(ts): resolve a static call made through the class name `Cache.create()` names the class itself as the receiver, but a TS/JS receiver with no local binding gave no type, so the call was dropped and `callers Cache.create` found nothing, in the same file or imported. Read a capitalised receiver as the class when the file declares that class or imports it by name from a relative module (an alias maps to the class's own name). An external, default or namespace import, a local value such as `const Cache = {...}`, and a parameter or nested declaration of the same name are not the class and still drop, as does a class that owns no such method (`Math.max`). --- src/graph/bindings.ts | 7 ++- src/graph/extract.ts | 53 +++++++++++++++++++++++ test/graph-ts-static.test.ts | 84 ++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 test/graph-ts-static.test.ts diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index f637ff449..c4a9616f0 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -189,7 +189,7 @@ function goReceiverTypeOf(node: SyntaxNode): string | null { return type?.type === "type_identifier" ? type.text : null; } -type RecvCtx = Pick; +type RecvCtx = Pick; /** Resolves a call site's receiver text (from `calleeName`) to a bound type * name, given the enclosing walk state. `self`/`cls`/`this`/the Go receiver @@ -232,6 +232,11 @@ export function resolveRecvType( // `MyService.do_thing()` — resolved to nothing, so `graft callers` reported // only the internal `cls.` call sites (#305). (ctx.lang === "python" && /^[A-Z]/.test(receiver) ? receiver : undefined) ?? + // TypeScript/JavaScript static call `Cache.create()`: the receiver is the class itself when + // the file declares that class or imports it from a relative module (an external import or + // a local value of the same name is not a class here). A class that owns no such method + // still finds no owner-qualified match and drops, as it did before. + ((ctx.lang === "typescript" || ctx.lang === "tsx") ? ctx.tsClassRefs.get(receiver) : undefined) ?? undefined ); } diff --git a/src/graph/extract.ts b/src/graph/extract.ts index b18a56c01..ed8030e3b 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -337,6 +337,10 @@ export interface WalkCtx { enclosingClass: string | null; // nearest enclosing class (py/ts `self`/`this`) goReceiverVar: string | null; // Go receiver var, e.g. `w` in `func (w *Worker)` importedSymbols: ReadonlyMap; + /** TS/JS names that stand for a class here: a class declared in the file, or a + * named import from a relative module, mapped to the class's own name. A receiver + * outside it (an external import, a local value) is never read as a class. */ + tsClassRefs: ReadonlyMap; goImports: ReadonlyMap; goLocals: ReadonlyMap; // R6 (Phase 2): which list we're inside while walking an `R6Class(...)` call's @@ -388,6 +392,7 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac function extractTree(rel: string, source: string, lang: Language, root: SyntaxNode): ExtractResult { const bindings = collectBindings(root, lang); const importedSymbols = collectImportedSymbols(root, lang); + const tsClassRefs = lang === "typescript" || lang === "tsx" ? collectTsClassRefs(root, importedSymbols) : new Map(); const goImports = lang === "go" ? collectGoImports(root) : new Map(); const rGenerics = lang === "r" ? collectRGenerics(root) : EMPTY_SET; @@ -422,6 +427,7 @@ function extractTree(rel: string, source: string, lang: Language, root: SyntaxNo enclosingClass: null, goReceiverVar: null, importedSymbols, + tsClassRefs, goImports, goLocals: new Map(), rR6Access: null, @@ -648,6 +654,10 @@ function walk(node: SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEdge[], m desc.kind === "function" || desc.kind === "method" ? withoutShadowedImports(ctx.importedSymbols, node) : ctx.importedSymbols, + tsClassRefs: + (desc.kind === "function" || desc.kind === "method") && ctx.tsClassRefs.size > 0 + ? withoutDeclaredNames(ctx.tsClassRefs, node) + : ctx.tsClassRefs, // Reset on every new definition — this is a purely local marker for "we're // still inside THIS class-defining call's own public=/private=/active= // argument chain," not something that should leak into a nested definition @@ -902,6 +912,49 @@ function collectImportedSymbols( return new Map(); } +function collectTsClassRefs( + root: SyntaxNode, + imports: ReadonlyMap, +): Map { + const out = new Map(); + for (const [local, { name, specifier }] of imports) if (specifier.startsWith(".")) out.set(local, name); + for (const top of root.namedChildren) { + const decl = top.type === "export_statement" ? top.childForFieldName("declaration") : top; + if (decl?.type !== "class_declaration" && decl?.type !== "abstract_class_declaration") continue; + const name = decl.childForFieldName("name")?.text; + if (name) out.set(name, name); + } + return out; +} + +/** Drop the class names a function redeclares anywhere inside it (a local, a parameter, + * a nested function or class): the name then stands for that value, not the class. */ +function withoutDeclaredNames(refs: ReadonlyMap, definition: SyntaxNode): ReadonlyMap { + const declared = new Set(); + const collect = (pattern: SyntaxNode | null): void => { + if (!pattern) return; + if (pattern.type === "identifier" || pattern.type === "shorthand_property_identifier_pattern") declared.add(pattern.text); + for (const c of pattern.namedChildren) collect(c); + }; + const visit = (node: SyntaxNode): void => { + if (node.type === "variable_declarator") collect(node.childForFieldName("name")); + else if (node.type === "required_parameter" || node.type === "optional_parameter") collect(node.childForFieldName("pattern")); + else if (node.type === "formal_parameters") { + for (const c of node.namedChildren) if (c.type === "identifier") declared.add(c.text); + } + else if (node.type === "arrow_function") collect(node.childForFieldName("parameter")); + else if (node.type === "catch_clause") collect(node.childForFieldName("parameter")); + else if (!sameSyntaxNode(node, definition) && /^(function|generator_function|class|abstract_class)_declaration$/.test(node.type)) { + const name = node.childForFieldName("name")?.text; + if (name) declared.add(name); + } + for (const c of node.namedChildren) visit(c); + }; + visit(definition); + if (![...declared].some((name) => refs.has(name))) return refs; + return new Map([...refs].filter(([local]) => !declared.has(local))); +} + function collectGoLocals(definition: SyntaxNode): Map { const out = new Map(); const declare = (holder: SyntaxNode | null, end: number): void => { diff --git a/test/graph-ts-static.test.ts b/test/graph-ts-static.test.ts new file mode 100644 index 000000000..d9f5bbae0 --- /dev/null +++ b/test/graph-ts-static.test.ts @@ -0,0 +1,84 @@ +/** + * TypeScript/JavaScript static member calls in the Tier-1 code graph: `Cache.create()` + * names the class itself as the receiver, so it resolves to that class's static method — + * same file or imported — while a capitalised receiver that owns no such method + * (`Math.max`) and a local that shadows the class name resolve to nothing. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildGraph } from "../src/graph/build.js"; +import { readGraph, wiringPath } from "../src/graph/write.js"; +import type { GraphV1 } from "../src/graph/types.js"; + +async function graphOf(files: Record): Promise { + const dir = mkdtempSync(join(tmpdir(), "graft-ts-static-")); + try { + for (const [name, src] of Object.entries(files)) writeFileSync(join(dir, name), src); + await buildGraph(dir); + return readGraph(wiringPath(join(dir, "graft")))!; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const calls = (g: GraphV1, source: string) => + g.edges.filter((e) => e.relation === "calls" && e.source === source).map((e) => e.target).sort(); + +const CACHE_TS = `export class Cache { + static create(): Cache { return new Cache(); } + static reset(): void {} + get(k: string): number { return Cache.reset(), 1; } +} +export class Pool { + static create(): Pool { return new Pool(); } +} +`; + +test("TS static call: Class.method() resolves to the class's static method, in the same file and across files", async () => { + const g = await graphOf({ + "cache.ts": CACHE_TS + "export function local() { return Cache.create(); }\n", + "use.ts": `import { Cache, Pool } from "./cache"; +export function build() { return Cache.create(); } +export function both() { Pool.create(); return Cache.reset(); } +`, + "legacy.js": `class Svc { static make() {} run() { return Svc.make(); } }\nmodule.exports = Svc;\n`, + }); + assert.deepEqual(calls(g, "cache.ts#local"), ["cache.ts#Cache.create"]); + assert.deepEqual(calls(g, "cache.ts#Cache.get"), ["cache.ts#Cache.reset"]); + assert.deepEqual(calls(g, "use.ts#build"), ["cache.ts#Cache.create"]); + assert.deepEqual(calls(g, "use.ts#both"), ["cache.ts#Cache.reset", "cache.ts#Pool.create"]); + assert.deepEqual(calls(g, "legacy.js#Svc.run"), ["legacy.js#Svc.make"]); +}); + +test("TS static call: a capitalised receiver without that method, and a typed parameter named like a class, do not become the class's statics", async () => { + const g = await graphOf({ + "cache.ts": CACHE_TS, + "use.ts": `import { Cache } from "./cache"; +class Other { run() {} } +export function builtins() { return Math.max(1, 2) + Number.parseInt("3") + Cache.nope(); } +export function shadow(Pool: Other) { Pool.create(); Pool.run(); } +`, + }); + assert.deepEqual(calls(g, "use.ts#builtins"), []); + assert.deepEqual(calls(g, "use.ts#shadow"), ["use.ts#Other.run"]); +}); + +test("TS static call: only a class declared in the file or imported from a relative module is read as the class", async () => { + const g = await graphOf({ + "cache.ts": CACHE_TS, + "fns.ts": `export function create() {}\n`, + "external.ts": `import { Cache } from "lru-cache";\nexport function run() { return Cache.create(); }\n`, + "default.ts": `import Cache from "lru-cache";\nexport function run() { return Cache.create(); }\n`, + "namespace.ts": `import * as Cache from "./fns";\nexport function run() { return Cache.create(); }\n`, + "local.ts": `export function run() { const Cache = { create() {} }; return Cache.create(); }\n`, + "callback.ts": `import { Cache } from "./cache";\nexport function run(xs: any[]) { return xs.map((Cache) => Cache.create()); }\nexport function after() { return Cache.create(); }\n`, + "alias.ts": `import { Cache as Store } from "./cache";\nexport function run() { return Store.create(); }\n`, + }); + for (const file of ["external", "default", "namespace", "local"]) assert.deepEqual(calls(g, `${file}.ts#run`), [], file); + assert.deepEqual(calls(g, "callback.ts#run"), []); + assert.deepEqual(calls(g, "callback.ts#after"), ["cache.ts#Cache.create"]); + assert.deepEqual(calls(g, "alias.ts#run"), ["cache.ts#Cache.create"]); +});