Files
codegraph/__tests__/call-value-targets.test.ts
T
Colby MchenryandClaude Opus 5.5 5d81d292fa fix(js,ts): a call or new never lands on an interface or a type alias (#2801)
A call or `new` that a strategy resolved by name or through an import could
target a TypeScript interface or type alias, which nothing can call or make
an instance of. memfs's fs-core `result.ts` declares `interface Ok` beside
`function Ok` (and `Err` beside `Err`), and `Ok(...)` through an import
reached the interface. A `new` of a global that a project only declares a
type for (`new Response()`, `new Worker()`, `new Event()`) was linked by
name to that type, and jest's `write()` parameter call to a `type write`.

Root cause: no rule on what a call may target. Whichever strategy found the
name picked among same-named nodes by order, and nothing stopped it on a
type.

Fix (gateTargetKind): for the JS family, a `calls` or `instantiates` result
whose target is an `interface` or a `type_alias` moves to the value the
target's file declares under the same name (a function, class, component,
constant or variable, first by position): `function Ok` beside
`interface Ok`, a class merged with an interface. With no such value the
result is dropped, and the reference stays unresolved.

Validation:
- __tests__/call-value-targets.test.ts (3 tests): all 3 fail on main and
  pass on the kernel. Each of 5 rules removed in turn fails at least one.
- Sync equals a fresh index (CLI) when the value beside a type is added or
  removed, and when a project type for a global or a parameter appears.
- A/B on 14 TS/JS repos and 2 controls: memfs's 11 Ok/Err calls move
  from the interfaces to the functions; 165 `new` of globals dropped (vite
  27, nuxt 57, webdriverio 26, insomnia 47, jest 7, memfs 1) and jest's
  `write()` call on `type write`. Every dropped `new` names a global
  (Worker, Event, Response, Request, Headers, ReadableStream, Node's Module,
  mocha's Context/Suite) or a local variable. vben, outline, social-app,
  t3-turbo, nitro, vue3-antdv-admin, pathe, c12, flask and gin are
  byte-identical.
- 91 related test files pass (2,178 tests).
- Cost: fresh index within noise (median of 3, interleaved): vite 9.07 s
  to 8.74 s, nuxt 11.71 s to 10.93 s, insomnia 15.26 s to 15.11 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:49:07 +00:00

99 lines
4.8 KiB
TypeScript

/**
* A call or `new` written with a name that a TypeScript file declares both as
* a type and as a value (`interface Ok` beside `function Ok`, a class merged
* with an interface) reached the type, and a `new` of a value that only a
* project type shares its name with (`interface Response` for the global
* `Response`) was linked to that type. Nothing calls an interface or a type
* alias, or makes an instance of one: the call reaches the value its file
* declares under the name, or links nothing.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let roots: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
async function index(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-call-value-'));
roots.push(root);
for (const [rel, content] of Object.entries(files)) {
const file = path.join(root, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
return graph;
}
/** `kind name(kind)@file` of every edge of `kinds` out of `file`'s nodes. */
function edgesFrom(graph: CodeGraph, file: string, kinds: string[]): string[] {
const out: string[] = [];
for (const node of graph.getNodesInFile(file)) {
for (const e of graph.getOutgoingEdges(node.id)) {
if (!kinds.includes(e.kind)) continue;
const target = graph.getNode(e.target)!;
out.push(`${e.kind} ${target.name}(${target.kind})@${target.filePath}`);
}
}
return out.sort();
}
describe('a call or `new` never lands on an interface or a type alias', () => {
/** memfs's fs-core/src/result.ts: each result type beside the function that makes one. */
const result =
'export interface Ok<T> {\n ok: true;\n value: T;\n}\n\nexport interface Err<E> {\n ok: false;\n err: E;\n}\n\n' +
'export type Result<T, E> = Ok<T> | Err<E>;\n\n' +
'export function Ok<T>(value: T): Ok<T> {\n return { ok: true, value };\n}\n\n' +
'export function Err<E>(err: E): Err<E> {\n return { ok: false, err };\n}\n';
it('reaches the value declared beside the type, directly and through a barrel', async () => {
const graph = await index({
'package.json': JSON.stringify({ name: 'app' }),
'lib/result.ts': result,
'lib/index.ts': "export * from './result';\n",
'direct.ts':
"import { Ok, Err } from './lib/result';\n\nexport const direct = (fail: boolean) => (fail ? Err('no') : Ok(1));\nexport const typed = (r: Ok<number>) => r;\n",
'barrel.ts': "import { Ok } from './lib';\n\nexport const barrel = () => Ok(2);\n",
});
expect(edgesFrom(graph, 'direct.ts', ['calls'])).toEqual(['calls Err(function)@lib/result.ts', 'calls Ok(function)@lib/result.ts']);
expect(edgesFrom(graph, 'barrel.ts', ['calls'])).toEqual(['calls Ok(function)@lib/result.ts']);
// A type position still names the type.
expect(edgesFrom(graph, 'direct.ts', ['references'])).toContain('references Ok(interface)@lib/result.ts');
}, 120_000);
it('links nothing for a value only a project type answers', async () => {
// insomnia's Postman `interface Response`, vite's `interface Worker`, jest-circus's `type Event`: globals.
const graph = await index({
'package.json': JSON.stringify({ name: 'app' }),
'types/postman.ts': 'export interface Response {\n code: number;\n}\n\nexport type Event = { name: string };\n',
'types/worker.d.ts': 'export interface Worker {\n postMessage(data: unknown): void;\n}\n',
'fetch.ts': "export function respond(): unknown {\n return [new Response('ok'), new Event('ready'), new Worker('w.js')];\n}\n",
// jest's DefaultReporter: a `write` parameter beside a `type write` declared for it.
'types/stream.ts': 'export type write = (chunk: string) => boolean;\n',
'flush.ts': "export function flush(write: (chunk: string) => boolean): void {\n write('done');\n}\n",
});
expect(edgesFrom(graph, 'fetch.ts', ['calls', 'instantiates'])).toEqual([]);
expect(edgesFrom(graph, 'flush.ts', ['calls'])).toEqual([]);
}, 120_000);
it('reaches the class an interface merges with', async () => {
const graph = await index({
'package.json': JSON.stringify({ name: 'app' }),
'store.ts': 'export interface Store {\n size: number;\n}\n\nexport class Store {\n size = 0;\n}\n',
'use.ts': "import { Store } from './store';\n\nexport const make = () => new Store();\n",
});
expect(edgesFrom(graph, 'use.ts', ['instantiates'])).toEqual(['instantiates Store(class)@store.ts']);
}, 120_000);
});