mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
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/main8998697f(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 todeac7710(#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>
85 lines
3.9 KiB
TypeScript
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);
|
|
});
|
|
});
|