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>
This commit is contained in:
Colby Mchenry
2026-10-22 13:49:07 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e8ddc4e2be
commit 5d81d292fa
3 changed files with 130 additions and 0 deletions
+1
View File
@@ -224,6 +224,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a function a file loads with a dynamic import from Node or a package, like `const { createServer } = await import('node:http')`, `const { default: chalk } = await import('chalk')` or `import('node:fs/promises').then(({ readFile }) => …)`, no longer links to a project function that only shares its name. Before, vite's call to Node's `createServer` showed up among the callers of vite's own `createServer`. The import counts only where it is in scope, so the rest of the file links as before, and a dynamic import of your own project's module still leads to that module. Thanks @Ancienttwo for the report. (#2491)
- In JavaScript and TypeScript, code outside your tests no longer links to a Jest or Vitest manual mock under `__mocks__/`. Before, when an import of a workspace package couldn't be followed, a call like `formatLabel(name)` linked by name to the mock beside it instead of the package's own function, so the real function lost its callers, and the same happened to rendered components and event handlers. Production code is now matched as if the mocks weren't there, so it reaches the real function, and adding or deleting a mock leaves it alone. Tests, other mocks, and a file that imports a mock by its path, like a `jest.setup.ts`, still reach the mock. Thanks @Y-Osawa2022 for the report and @danusha2345. (#2455)
- In JavaScript and TypeScript, an import of a package from your own repository now leads to the file its `package.json` names: the `exports` entry for the import, conditions and subpaths like `"./utils": "./src/utils.ts"` included, or else its `main`, `module`, `types` or `source`. A package another one depends on by path, like `"acme-shared": "file:../common"` or `link:`, now counts as part of your project too, for the files of the package that declares it, even with no root `workspaces`. Before, an import of a pnpm workspace member declared with `"main": "./src/index.ts"` and no `index` file at its root, or of a `file:` dependency, couldn't be followed, so its calls were matched by name alone and a same-named private function nearer the caller could take them. An entry that names build output, like `./dist/esm/index.mjs`, or an import of a path inside it, like `@jsonjoy.com/fs-node-utils/lib/types/misc`, leads to the matching source file, like `src/index.ts` or `src/types/misc.ts`, when your project has one, and to nothing otherwise. `codegraph sync` follows edits to these `package.json` fields. Re-index JavaScript and TypeScript monorepos after upgrading. Thanks @elderbernardi and @Y-Osawa2022 for the reports, and @maxmilian and @danusha2345. (#2502, #2456)
- In JavaScript and TypeScript, a call or `new` no longer lands on an interface or a type alias. When a file declares both a type and a value of the same name, like `interface Ok` and `function Ok`, or a class merged with an interface, the call reaches the value, and a global like `new Response()`, `new Worker()` or `new Event()` no longer links to a project type that only shares its name. Re-index JavaScript and TypeScript projects after upgrading.
- In JavaScript and TypeScript, a value the file imports from your own project and passes along now links only to what it imports: a constant like `isCloudHosted`, a class like a `Star` model, a whole module (`import * as Slack from '../slack'`), or a stylesheet, image or GraphQL document (`import TCO21 from './themes/TCO21.scss'`) no longer links to a function elsewhere that only shares its name. Before, when the project had exactly one function of that name, such a value linked to it: outline's settings screens showed up among the callers of the server's `isCloudHosted()` check, and its stores among those of the `<Star>` icon, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A component that wraps the function declared beside it, like `export default styled(Disclosure)`, and a page loaded lazily, like `lazy(() => import('../pages/Home'))`, still lead to that function. Re-index JavaScript and TypeScript projects after upgrading.
- In JavaScript, an import of a TypeScript module written without an extension, like `import AudioModal from './audio_modal'` in a `.jsx` file next to `audio_modal.tsx`, now links to that module, as it already did from a TypeScript file. Before, a `.js` or `.jsx` file's imports of TypeScript modules were never followed, so in a codebase moving to TypeScript they were matched by name instead: an import under another name linked nothing, and a name that repeats could link to the wrong file, so mastodon's `<IconButton>` and `<Status>` tags rendered other components of those names than the ones each file imports. A JavaScript module beside a TypeScript one of the same name still comes first. Re-index JavaScript projects after upgrading.
- `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named.
+98
View File
@@ -0,0 +1,98 @@
/**
* 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);
});
+31
View File
@@ -158,6 +158,9 @@ const GO_BUILT_INS = new Set([
/** What a Pascal typecast's name resolves to: `TFoo(x)` casts, it doesn't call (see createEdges). */
const PASCAL_CAST_TARGET_KINDS: ReadonlySet<string> = new Set(['class', 'struct', 'interface', 'enum', 'type_alias']);
/** The values a TypeScript type may share its name with in its file, which a call or `new` of the name means. */
const TYPE_COMPANION_VALUE_KINDS: ReadonlySet<string> = new Set(['function', 'class', 'component', 'constant', 'variable']);
const PASCAL_UNIT_PREFIXES = [
'System.', 'Winapi.', 'Vcl.', 'Fmx.', 'Data.', 'Datasnap.',
'Soap.', 'Xml.', 'Web.', 'REST.', 'FireDAC.', 'IBX.',
@@ -3707,6 +3710,21 @@ export class ReferenceResolver {
!ref.referenceName.includes('.') && !isGoBareName(ref, this.context)) return null;
}
// A TypeScript interface or type alias is no value: nothing calls one or
// makes an instance of it. A strategy that found one by name meant the
// value its file declares beside it (`interface Ok` and `function Ok` in a
// result module); with none there, the name is a value from elsewhere
// that the project only types (`new Response()`, `new Worker()`), and
// links nothing.
if ((ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates') && ESM_IMPORT_LANGUAGES.has(ref.language)) {
const target = this.nodeById(result.targetNodeId);
if (target && (target.kind === 'interface' || target.kind === 'type_alias')) {
const value = this.sameNamedValueOfType(target);
if (!value) return null;
result = { ...result, targetNodeId: value.id };
}
}
// An `imports` reference names something importable — never a member that
// only exists inside a type, nor its own import statement. A module path
// that names no file (`./missing`, `stdio.h`, `pkg.mod`, a C# `using` of
@@ -3948,6 +3966,19 @@ export class ReferenceResolver {
}
/** The one supertype-kind node a TypeScript value shares its name and file with. */
/**
* The value a file declares beside a type of the same name — `export
* function Ok()` beside `export interface Ok`, a class merged with an
* interface — which a call or `new` written with the name reaches. The
* first by position; null when the file declares none.
*/
private sameNamedValueOfType(type: Node): Node | null {
return this.context
.getNodesInFile(type.filePath)
.filter((n) => n.name === type.name && TYPE_COMPANION_VALUE_KINDS.has(n.kind))
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0] ?? null;
}
private sameNamedTypeOfValue(value: Node): Node | null {
if (value.kind !== 'constant' && value.kind !== 'variable') return null;
if (value.language !== 'typescript' && value.language !== 'tsx') return null;