Files
codegraph/__tests__/js-resolution-work.test.ts
T
Colby MchenryandClaude Opus 5.5 7210d2fd4c perf(resolution): remove the JS indexing regressions since 1.6.2; the summary time covers the whole run (#2334) (#2362)
Indexing pretix took 2.2x as long since #2163 (issue #2334), and the
bundled minified d3 was what #2344 left of CPython's 1.6.2 slowdown. Two
JS/TS checks that first shipped in 1.6.2 redid whole-file work for every
reference:

- matchDestructuredCallResult (#2163) ran for every bare call in a file
  holding `const {` / `let {` / `var {`: per call it re-tested that
  pattern on the source, then stripped, blanked and brace-walked every
  line above the call and matched the binding regex over it.
- jsFunctionLocalScope (#2226) re-stripped the calling function's lines
  above each reference. In a minified script every function's text runs
  to the end of its one line, so each strip was the whole file.

CPU profiles, main -> this change: matchDestructuredCallResult 82.4 s
-> 1.7 s and jsFunctionLocalScope 12.3 s -> 5.2 s on pretix; 35.4 s ->
0.37 s and 23.3 s -> 1.0 s on CPython's Lib/profiling + Doc/_static.

Now:
- A file's `const { … } = f(…)` bindings are read once per resolution:
  the blanked code, each binding's end, callee and keys, the block it
  sits in and where that block closes, and the spans the blanker read as
  regex literals. A call looks up its own name; only a call through a
  destructured name scans the code between that binding and itself for
  shadowing, and only once the callee is found to return the key (a
  `require(…)` destructuring, for one, needs no scan).
- Blanking the text above a call on its own gives the file's blanked code
  up to the call: the stripper and blanker look past a character only for
  a comment opener's second character and for a regex literal's closing
  `/`. So the prefix's bindings are the file's that end at or before the
  call (a match never reads past its `(`, and one running past the call
  holds no `{`…`(` to start another), and its brace stack follows from
  where each block closes. For a call inside a span the file reads as a
  regex literal (a division, in minified code) the prefix is the code up
  to the `/` plus the short tail blanked on its own; where a binding
  statement could run across that `/` (inside its `{ … }` or `< … >`), and
  for out-of-range positions, the old per-call scan still runs.
- jsFunctionLocalScope strips a function's lines once and cuts at the
  reference's line end; functions that start on the same line share the
  regex answer by (file, first line, reference line, name).
- The new memos, and JS_FN_LOCAL_MEMO (never cleared before), drop with
  the other per-file memos in clearNameMatcherMemos.

The summary line printed orchestrator.indexAll's durationMs, which covers
extraction only, beside node and edge totals recomputed after resolution:
the issue's 19.9 s run printed "in 1.7s". CodeGraph.indexAll and sync now
report the whole run's wall time, which `codegraph init`, `index` and
`sync`, the MCP auto-sync log and the telemetry duration bucket read.

Verification: nodes, edges and unresolved_refs dumps keyed by
file|kind|qualified_name|start_line are byte-identical to origin/main
8998697f (with #2357's new destructuring and Vue template refs) on all
24 runs: pretix, mealie and CPython's Lib/profiling + Doc/_static, 8
each; and to deac7710 (#2358, #2360, #2361) on one more round of each. A differential harness ran old and new matchDestructuredCallResult
on every call position of mealie (10,330) and the CPython assets (9,170,
742 inside misread regex literals) and on 16,000 random token-soup
sources (1.33M positions), and old and new jsFunctionLocalScope on ~500K
(function, line, name) triples: no difference; it reports one for each
deliberate mutation of the new code that is not provably equivalent.

Medians of 4 interleaved runs on a loaded 16-thread Windows box, process
CPU / wall / peak RSS: pretix 160.2 s / 75.9 s / 2.47 GB -> 88.7 s /
43.5 s / 1.93 GB; CPython assets 75.1 s / 37.6 s / 1.23 GB -> 32.1 s /
20.4 s / 0.81 GB; mealie (no bundles, the control) 39.2 s -> 38.6 s CPU.

__tests__/js-resolution-work.test.ts counts strips: a file with a
destructured binding and 40 bare calls is stripped for the check once
(41 on main), a 40-line function once (40 on main); each count fails when
only its own half of the fix is reverted. full-pipeline.test.ts requires
indexAll's and sync's durationMs to cover a linking pass delayed 250 ms
past the orchestrator's time (on main: 218 < 468).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:52:39 +00:00

85 lines
3.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { stripCommentsForRegex } from '../src/resolution/strip-comments';
// Pass-through, so a test can count how often text is stripped.
vi.mock('../src/resolution/strip-comments', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/resolution/strip-comments')>();
return { ...actual, stripCommentsForRegex: vi.fn(actual.stripCommentsForRegex) };
});
/**
* #2334: resolving JS/TS does a bounded amount of work per file. In a file
* with destructuring, the check for a call through a destructured name
* stripped and scanned every line above each bare call; the check for a name
* the calling function binds itself stripped that function's lines above each
* reference again. A bundled library took time in the square of its size.
* Counted, never timed.
*/
describe('JS resolution work (#2334)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
vi.mocked(stripCommentsForRegex).mockClear();
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 });
tmpDir = undefined;
});
async function index(files: Record<string, string>): Promise<CodeGraph> {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-work-'));
for (const [file, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(tmpDir, file)), { recursive: true });
fs.writeFileSync(path.join(tmpDir, file), content);
}
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
return cg;
}
/** How often a non-empty start of `text` was stripped as `lang`. */
const strips = (lang: string, text: string) => vi.mocked(stripCommentsForRegex).mock.calls
.filter(([stripped, as]) => as === lang && stripped.length > 0 && text.startsWith(stripped)).length;
const callers = (graph: CodeGraph, file: string, name: string) => {
const target = graph.getNodesByName(name).find((n) => n.filePath === file && n.kind === 'function')!;
return [...new Set(graph.getIncomingEdges(target.id).filter((e) => e.kind === 'calls')
.map((e) => graph.getNode(e.source)?.name))];
};
it('reads a file with destructuring once, not once per call', async () => {
const CALLS = 40;
const app = `import { useAuth } from './auth';\n` +
`const { login } = useAuth();\n` +
Array.from({ length: CALLS }, (_, i) => `function step${i}() { return ${i}; }\n`).join('') +
`export function run() {\n` +
Array.from({ length: CALLS }, (_, i) => ` step${i}();\n`).join('') +
// A division the blanking reads as a regex literal around the call.
` return (${CALLS}) / login() / 2;\n}\n`;
const graph = await index({
'auth.js': 'export function useAuth() {\n function login() {\n return 1;\n }\n return { login };\n}\n',
'app.js': app,
});
// The call through the destructured name reaches the function the hook returns.
expect(callers(graph, 'auth.js', 'login')).toEqual(['run']);
expect(callers(graph, 'app.js', 'step7')).toEqual(['run']);
// Each bare call was checked; the file was stripped for that once, not once per call.
expect(strips('typescript', app)).toBeLessThan(CALLS / 4);
});
it("strips a function's lines once, not once per reference", async () => {
const LINES = 40;
const host = `export function host() {\n` +
Array.from({ length: LINES }, (_, i) => ` helper(${i});\n`).join('') + `}\n`;
const graph = await index({ 'lib.js': `export function helper(n) {\n return n;\n}\n${host}` });
// Each call resolved, so each asked whether `host` binds `helper` itself.
expect(callers(graph, 'lib.js', 'helper')).toEqual(['host']);
expect(strips('javascript', host)).toBeLessThan(LINES / 4);
});
});