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
7 changes: 6 additions & 1 deletion src/graph/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ function goReceiverTypeOf(node: SyntaxNode): string | null {
return type?.type === "type_identifier" ? type.text : null;
}

type RecvCtx = Pick<WalkCtx, "scope" | "enclosingClass" | "goReceiverVar" | "lang" | "bindings" | "rSuperClass">;
type RecvCtx = Pick<WalkCtx, "scope" | "enclosingClass" | "goReceiverVar" | "lang" | "bindings" | "rSuperClass" | "tsClassRefs">;

/** 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
Expand Down Expand Up @@ -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
);
}
Expand Down
53 changes: 53 additions & 0 deletions src/graph/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { name: string; specifier: string }>;
/** 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<string, string>;
goImports: ReadonlyMap<string, string>;
goLocals: ReadonlyMap<string, number>;
// R6 (Phase 2): which list we're inside while walking an `R6Class(...)` call's
Expand Down Expand Up @@ -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<string, string>();
const goImports = lang === "go" ? collectGoImports(root) : new Map<string, string>();
const rGenerics = lang === "r" ? collectRGenerics(root) : EMPTY_SET;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -902,6 +912,49 @@ function collectImportedSymbols(
return new Map();
}

function collectTsClassRefs(
root: SyntaxNode,
imports: ReadonlyMap<string, { name: string; specifier: string }>,
): Map<string, string> {
const out = new Map<string, string>();
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<string, string>, definition: SyntaxNode): ReadonlyMap<string, string> {
const declared = new Set<string>();
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<string, number> {
const out = new Map<string, number>();
const declare = (holder: SyntaxNode | null, end: number): void => {
Expand Down
84 changes: 84 additions & 0 deletions test/graph-ts-static.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<GraphV1> {
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"]);
});
Loading