GitNexus/gitnexus/bench/scope-emission/measure.mjs

253 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Scope-emission bench (#2699).
*
* JavaScript/TypeScript gained block scopes so that `let`/`const` in sibling
* blocks are distinct bindings. Emitted naively — one scope per
* `statement_block` — that TRIPLED the block-scope count and cost ~10% of
* analyze wall time, because every scope-chain walk in every function then
* steps through levels that bind nothing.
*
* Two emit-side filters keep the semantics and drop the waste:
* 1. a block that IS a function body duplicates the enclosing Function scope;
* 2. a block that declares no `let`/`const`/`class`/`function` binds nothing,
* so it is transparent to every lookup.
*
* This bench guards that. It counts scope captures over a synthetic corpus
* whose shape is fixed in this file, so the numbers are exact and independent
* of the machine — unlike wall-clock analyze, where a 2% effect sits well
* inside the noise of a shared runner (measured: ±10% run to run).
*
* BOTH languages are measured. The filters are implemented twice —
* `FUNCTION_BODY_OWNER_TYPES` in `typescript/captures.ts` and
* `JS_FUNCTION_BODY_OWNER_TYPES` in `javascript/captures.ts`, each with its own
* `blockDeclaresBinding` and its own `BLOCK_BINDING_CHILD_TYPES` — so a
* TypeScript-only bench would let a JavaScript-only regression ship green.
*
* Loop and catch environments also carry lexical ownership. On this corpus
* the two currently agree exactly (4 blocks per module, 2600
* scopes). That is a measured result, not a required invariant: the fixtures
* are structurally parallel and the TS-only syntax they drop carries no extra
* scopes. Each language is still gated against its OWN baseline, because the
* filters are separate code and nothing enforces that the counts stay equal.
*
* Usage:
* node bench/scope-emission/measure.mjs # print measurements
* node bench/scope-emission/measure.mjs --check # gate against baselines
*
* Build-free: imports the TypeScript sources through tsx, like the other
* benches here.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
const { emitTsScopeCaptures } =
await import('../../src/core/ingestion/languages/typescript/captures.ts');
const { emitJsScopeCaptures } =
await import('../../src/core/ingestion/languages/javascript/captures.ts');
/**
* One synthetic TypeScript module, parameterised by index so names stay
* distinct.
*
* Deliberately mixes the shapes the filters discriminate between:
* - function/method/arrow bodies → block scope must be SUPPRESSED
* - empty `if`/`else`/`while`/`try` → suppressed
* - lexical `for` and `catch` → environment scope REQUIRED
* - blocks declaring `let`/`const` → block scope REQUIRED (shadowing)
* - a block declaring only `var` → suppressed (`var` hoists past it)
*/
const tsModuleSource = (i) => `
export class Svc${i} {
private total = 0;
run(xs: number[]): number {
for (const x of xs) {
if (x > 0) {
this.total += x;
} else {
this.total -= x;
}
}
while (this.total > 100) {
this.total = this.total / 2;
}
try {
this.total = Math.round(this.total);
} catch {
this.total = 0;
}
return this.total;
}
pick(flag: boolean): number {
if (flag) {
const chosen = (n: number) => n * 2;
return chosen(1);
} else {
const chosen = (n: number) => n * 3;
return chosen(2);
}
}
hoisted(flag: boolean): number {
if (flag) { var v = 1; }
return v ?? 0;
}
}
export function free${i}(): number {
const inner = (n: number) => n + 1;
return inner(1);
}
`;
/** The same shapes with the TypeScript-only syntax removed. Kept structurally
* parallel to `tsModuleSource` on purpose: when the two languages' block
* counts diverge, the cause is the emitter, not the fixture. */
const jsModuleSource = (i) => `
export class Svc${i} {
total = 0;
run(xs) {
for (const x of xs) {
if (x > 0) {
this.total += x;
} else {
this.total -= x;
}
}
while (this.total > 100) {
this.total = this.total / 2;
}
try {
this.total = Math.round(this.total);
} catch {
this.total = 0;
}
return this.total;
}
pick(flag) {
if (flag) {
const chosen = (n) => n * 2;
return chosen(1);
} else {
const chosen = (n) => n * 3;
return chosen(2);
}
}
hoisted(flag) {
if (flag) { var v = 1; }
return v ?? 0;
}
}
export function free${i}() {
const inner = (n) => n + 1;
return inner(1);
}
`;
const CORPUS_MODULES = 200;
const REPS = 7;
const LANGUAGES = [
{ name: 'typescript', ext: 'ts', emit: emitTsScopeCaptures, moduleSource: tsModuleSource },
{ name: 'javascript', ext: 'js', emit: emitJsScopeCaptures, moduleSource: jsModuleSource },
];
const measure = ({ ext, emit, moduleSource }) => {
const corpus = Array.from({ length: CORPUS_MODULES }, (_, i) => ({
path: `bench/mod${i}.${ext}`,
source: moduleSource(i),
}));
const tally = () => {
const counts = new Map();
for (const { path, source } of corpus) {
for (const match of emit(source, path)) {
for (const key of Object.keys(match)) {
// These decorate an existing scope; they do not create scopes.
if (key === '@scope.lookup-policy' || key === '@scope.name-claims') continue;
if (key.startsWith('@scope.')) counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
}
return counts;
};
// Warm the parser + query caches so the timing reflects steady state.
tally();
let bestMs = Infinity;
let counts;
for (let r = 0; r < REPS; r++) {
const t0 = process.hrtime.bigint();
counts = tally();
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
if (ms < bestMs) bestMs = ms;
}
const scopes = Object.fromEntries([...counts.entries()].sort());
return {
modules: CORPUS_MODULES,
scopes,
total_scopes: Object.values(scopes).reduce((a, b) => a + b, 0),
emit_min_ms: Number(bestMs.toFixed(2)),
blocks_per_module: Number(((scopes['@scope.block'] ?? 0) / CORPUS_MODULES).toFixed(3)),
};
};
const result = Object.fromEntries(LANGUAGES.map((lang) => [lang.name, measure(lang)]));
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(result, null, 2));
process.exit(0);
}
const baselines = JSON.parse(readFileSync(join(HERE, 'baselines.json'), 'utf8'));
const failures = [];
for (const { name } of LANGUAGES) {
const expected = baselines[name];
const actual = result[name];
if (expected === undefined) {
failures.push(`${name}: no baseline entry — add one rather than skipping the language`);
continue;
}
// Scope counts are EXACT — a synthetic corpus and a deterministic emitter. A
// mismatch means the emitted scope set moved and must be explained, never
// re-baselined to make CI green.
for (const [key, want] of Object.entries(expected.scopes)) {
const got = actual.scopes[key] ?? 0;
if (got !== want) failures.push(`${name} ${key}: expected ${want}, got ${got}`);
}
for (const key of Object.keys(actual.scopes)) {
if (!(key in expected.scopes)) {
failures.push(`${name}: unexpected capture ${key}: ${actual.scopes[key]}`);
}
}
// Timing carries deliberate headroom for shared CI runners; it exists to
// catch an order-of-magnitude regression, not to police a few percent.
if (actual.emit_min_ms > expected.emit_ms_budget) {
failures.push(
`${name} emit_min_ms ${actual.emit_min_ms} exceeds budget ${expected.emit_ms_budget}`,
);
}
}
// A language present in baselines but not measured means the bench stopped
// covering it — the exact way a gate goes quietly green.
for (const name of Object.keys(baselines)) {
if (name.startsWith('_')) continue;
if (!(name in result)) failures.push(`${name}: baselined but not measured`);
}
console.log(JSON.stringify(result, null, 2));
if (failures.length > 0) {
console.error('[scope-emission --check] FAIL');
for (const f of failures) console.error(` - ${f}`);
process.exit(1);
}
console.log('[scope-emission --check] PASS');