mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* fix(impact): fail closed on id-less targets and follow ??/||/?: callable values (#3354) #3354 reports `impact` returning a byte-identical 1037/CRITICAL/`exact` result for three unrelated targets, with only `target.name` differing. The reporter's `target` had no `id` and no `filePath`, which the traversal path always emits, and a synthetic reproduction of their monorepo (pnpm, Cloudflare worker-configuration.d.ts in five packages, Hono, a Durable Object) resolves every target correctly on main. So the identical result was not reproduced. The repro did surface two real gaps and one hardening point: - `_runImpactBFS` now throws when the target has no node id. Every caller already catches, so impact reports `impactedCount: null, risk: UNKNOWN` instead of a normal-looking blast radius that cannot be about this symbol. - Callable-value flow followed only a single designator on the RHS, so `const sweep = env.__sweep ?? runSweep; await sweep(env)` produced no flow and `scheduled` was missing as a caller of `runSweep`, while the result still claimed `epistemic: exact`. Each branch of `??`, `||`, `or`, and `?:` now flows into the binding (language-neutral: operator and field names, no language checks). Parse cache bumped 104 -> 112 (105-111 are claimed by open PR #3326). - A whitespace-only `target_uid` (strict adapters materialize omitted optional strings) is treated as omitted and falls back to the name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(mcp): treat a non-string uid as omitted instead of throwing (#3354) Review follow-up on #3373. `query.uid?.trim()` called `.trim()` on a client-supplied value, and the MCP envelope is not type-validated, so `context({uid: 42})` threw a TypeError that `context()` does not catch. Before #3373 the same input ended as a structured not_found. A non-string uid now counts as omitted, which matches how normalizeToolParams already treats a non-string `target_uid`. The impact and trace not-found messages print a trimmed uid only when it is a non-blank string, so a strict adapter sending " " sees the name it searched for instead of `' '`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ingestion): expand ??/?:/ternary branches through a provider hook (#3354) Review follow-up on #3373. The shared value-alternatives rule keys on tree-sitter field names, and several grammars spell the same construct differently, so the expansion never fired for them: - Kotlin `elvis_expression` has no fields. - Swift uses `value`/`if_nil` and `if_true`/`if_false`. - Dart uses `first`/`second`, and its conditional has no `condition` field. - Python `a if c else b` has no fields. The `'?:'` operator entry was dead, since no bundled grammar emits it. Add an optional `valueAlternatives` hook to CallableFlowCaptureOptions, consulted before the shared rule, and implement it in the Kotlin, Swift, Dart, Python and Ruby providers. Shared code still names no language. Ruby's statement-bodied `if`/`unless`/`elsif` also carries `condition`/`consequence`/`alternative`, so the shared ternary rule dug an identifier out of an arbitrary statement (`g = h; 0` flowed `h`) and produced a wrong CALLS edge. The Ruby hook now keeps a multi-statement branch as one opaque source, as before #3373. Tests: new provider fixtures for Python, Kotlin, Swift, Dart and Ruby (including a Ruby negative), and TS chain, parenthesized, callable-left and `&&` negative cases. Captures goldens gain one entry each for the new fixtures. SCHEMA_BUMP stays 112 (same unreleased PR). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ingestion): keep long ||/??/?: chains linear in callable-flow capture (#3354) Expanding value-selecting sources into per-branch flows made long chains super-linear and, past a few thousand operands, a stack overflow. A generated `w === "k0" || w === "k1" || ...` keyword table took 6.7 s at 1000 operands and 48 s at 2000, and threw RangeError at 8000. Two causes, both fixed without changing any emitted capture: - valueAlternatives recursed once per operator level and spread the partial results at every level. It is now an explicit stack that writes to one output array. The left-to-right order, the paren unwrapping, and the provider-hook contract (`[node]` means opaque) are unchanged. - Every alternative ran its visibility walks from its own leaf, which can be as deep as the chain is long, all the way to the root. Also, tree-sitter's `parent` re-descends from the root, so each step costs the node's depth. The walks now jump between "anchor" nodes, the only nodes any check can match: region ids and formal owners. The nearest anchor is memoized per node across the file, and parents come from a map recorded by the one DFS the synthesizer already does. After the fix: 0.34 s / 0.23 s / 1.6 s at 1000 / 2000 / 8000 operands. That is within about 1.2x of main without the expansion; what remains is tree-sitter query time. Capture fingerprints are byte-identical before and after the fix for all 16 scope-capture bench languages and for the Python harness. A `typescript-deep-chain` case added to the scope-capture bench guards the scaling: 1.11-1.22 now, 7.5-8.0 before. A unit test pins a 10000-operand chain: it must still yield the seed for a callable operand, the copy for a formal at the deepest leaf, and the invoke. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(kotlin): see through braced if-branches in callable alternatives (#3354) tree-sitter-kotlin wraps a braced branch as `control_structure_body > statements > <expr>`, so for any non-empty block kotlinValueAlternatives saw exactly one named child (`statements`) and pushed the wrapper itself as the branch value. operandSyntax emits nothing for a `statements` node, so `val run = if (c) { ::f } else { ::g }; run()` produced no flow edges, and the "multi-statement block stays opaque" guard could never fire. Descend one level through `statements` and require exactly one non-comment expression there. Empty blocks (`{}` has no named children), multi-statement blocks, and `if` without `else` still return the whole `if` as one opaque source. The doc comment now describes that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ruby): skip only the multi-statement branch in callable alternatives (#3354) rubyValueAlternatives returned `[node]` (opaque) as soon as one branch held more than one statement. Two things went wrong because of that: - At the top level, `run = if c then g = h; 0 else method(:f) end` dropped the single-statement `else`, so `f` got no flow edge. - In an elsif chain, the outer `if` pushed the `elsif` node as a branch. The shared loop called the hook on it again, got `[elsif]` back, and used the whole elsif subtree as one source. So in `if a then method(:run_a) elsif b then g = h; 0 else method(:run_b) end` the `run_b` edge was lost. The hook now walks the elsif chain itself and skips each multi-statement branch, while every single-statement branch still becomes an alternative. This cannot add a wrong edge: each emitted alternative is a value the conditional really evaluates to, and nothing is taken from the skipped branch. Before, that branch did not contribute a resolvable value either. A whole conditional used as a source becomes a qualified-name seed, which resolves to nothing when it has more than one identifier leaf. With a single identifier leaf, it could even seed the condition variable. When no branch is a single statement, the conditional still stays one opaque source, as before. Ruby captures golden: ruby-callable-alternatives/app.rb goes from 33 to 58 capture groups. 23 of them come from the new fixture functions (checked against the old source). The other 2 are the new branch alternatives, `statement_if -> run_sweep` and `elsif_chain -> run_b`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ingestion): flow the right operand of && / and into callable bindings (#3354) `a && b` / `a and b` yields `a` when it is falsy and `b` otherwise. A falsy value is never a callable, so the right operand is the only one that can be invoked later. The capture left `&&` unexpanded, and the compound source became a qualified seed that resolves to nothing: `const run = x && f; run()` gained no edge to `f` while impact claimed `exact`. Python `x and f or g` reached only `g`. The shared expansion now maps `&&` / `and` to the right branch only. It recurses, so `x and f or g` reaches both `f` and `g`. Where `&&` yields a boolean (Java, C#, Go, Rust, C, C++, PHP, Zig), the destination cannot be invoked, so the flow never meets a call. A before/after CALLS diff over all 83 lang-resolution fixtures that contain `&&` or `and` shows exactly one new edge, logicalAnd -> runAndRight. PHP and Ruby bind low-precedence `and` looser than `=`, so `$g = $x and $y` never reaches this rule. The Python golden digest changes only for the extended python-callable-alternatives fixture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ingestion): keep operator branches of a value-selecting source opaque (#3354) The fan-out sent every branch of `??` / `||` / `or` / `?:` to emitAssignmentFact on its own, including branches that compute a value. `Handlers.fallback === run || fb` then emitted the comparison as a seed whose qualified text sliced to receiver `Handlers`, member `run`, and resolveBoundMemberCandidates minted a CALLS edge to `Handlers.run`. The same happened for `this.state !== run ?? this.fallback`, `this.state + run || this.fallback`, and Python `self.state != run or self.fallback`. Before the fan-out, the whole compound source was one opaque seed. A branch that is a binary operator expression now contributes nothing. The check uses the field vocabulary valueBranches already reads (a `left`/`right` pair, or an `operator`/`operators`/`op` token after the expression start), not grammar type names. Member accesses that field their `.`/`->` as `operator` (Ruby `call`, C/C++ `field_expression`) also field a member name through the list memberParts uses, now shared as memberNameNode, so they stay designators. Unary `&f`/`*fp` lead with their operator and stay designators. Call results were already dropped by emitAssignmentFact, and lambdas and callable references are unchanged. CALLS-edge diff over 87 lang-resolution fixtures (505 -> 501 edges): only the four false edges above were removed, and none were added. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(ingestion): pin the LEFT operand of ?? / or / elvis per provider (#3354) The Python, Kotlin, Swift and Dart "override ?? fn" cases put an unresolvable parameter on the left, so a fan-out that kept only the last operand still passed them. Each fixture now adds a callableLeft case with a real function on the left and a parameter on the right (`run_left or fallback`, `::runLeft ?: fallback`, `runLeft ?? fallback`), and each language asserts `callableLeft → runLeft`. Mutation check: dropping the left branch from the shared `??`/`||`/`or` rule and making the four provider hooks return only their last operand fails all four new tests, while the existing right-operand tests still pass. Capture goldens were regenerated with UPDATE_GOLDEN=1: - python app.py: 35 -> 85 groups. 35 -> 75 is stale drift from8ad0d8db7, which added the comparison cases without regenerating. 75 -> 85 is the new case: 2 declarations, 2 scopes, the variable, the `run()` reference, and 4 callable-flow captures (seed -> run_left, copy <- fallback, formal, invoke). - swift App.swift: 25 -> 36 groups for the same new case, plus Swift's type-binding for the fallback parameter. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(bench): re-baseline scope-capture fingerprints for #3354 callable alternatives This PR makes a callable chosen by a value-selecting source flow every branch it can yield (??, ||, or, && right operand, ternary, statement if, elvis). It also keeps an operator branch opaque. Both change @callable-flow captures, and the PR adds *-callable-alternatives regression fixtures that these benches glob. To verify, the BASE (merge-base233ca2849) and HEAD emitters were run over the same HEAD fixture corpus plus the synthetic source. Every added or removed match is an @callable-flow.* match on one of those sources. The pre-existing corpus is byte-identical for all 16 scope-capture languages and for Python. Emitter delta, then corpus growth: - ruby: +5/-0 app.rb (+1 file, +58 groups) - swift: +6/-3 App.swift (+1 file, +36 groups) - dart: +6/-3 app.dart (+1 file, +33 groups) - kotlin: +8/-2 App.kt (+1 file, +71 groups) - typescript: +18/-14 4 files (+288 groups) - python: +11/-7 app.py (+1 file, +85 groups) The removed matches are whole-expression seeds and copies that carried a qualified name of the compound source. They are replaced by one seed or copy per operand. Synthetic scaling counts are unchanged and every scaling ratio stays under budget. Per-language notes are in baselines.json under _rebaselined_3354_callable_alternatives. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(mcp): share one blank-uid check across impact, trace and symbol lookup (#3354) The "trimmed uid, or omitted when blank/non-string" rule was inlined four times, three of them trimming twice. nonBlankUid() owns it now; behaviour is unchanged. The whitespace and empty target_uid tests collapse into one it.each. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(cfg): correct the Kotlin and Python grammar-field notes (#3354) The Kotlin CFG visitor claimed no control-flow node has fields; a parse of the vendored grammar shows if_expression fields condition/consequence/ alternative (when/for/while/do/try/elvis are fieldless). The Python harvest note listed conditional_expression's children like field names; the node is fieldless and they are positional. Both notes were misleading reviewers of the #3354 value-alternatives hooks. Comment-only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(ingestion): skip optional-grammar suites in callable-alternatives providers test (#3354) Kotlin, Swift and Dart grammars are optional installs. Guard their describe blocks with isLanguageAvailable, as swift.test.ts and dart.test.ts do, so an install without one of them skips instead of failing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(ingestion): probe the Dart parser before enabling its providers suite (#3354) isLanguageAvailable only proves the module loaded; tree-sitter-dart can still fail on setLanguage. Probe loadParser/loadLanguage and skip on failure, the same guard dart.test.ts uses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
456 lines
18 KiB
JavaScript
456 lines
18 KiB
JavaScript
/**
|
||
* Unified build-free scope-capture measurement harness for every currently
|
||
* benchmarked language (the ones with a `*-pipeline-benchmark.test.ts`):
|
||
* go, csharp, rust, php, ruby, cobol, swift — plus python lives in its own
|
||
* `bench/python-scope/` harness (richer: it also covers import resolution).
|
||
*
|
||
* For each language it:
|
||
* - times `emit<Lang>ScopeCaptures` on a synthetic DAO-style source at two
|
||
* sizes (250 / 800 top-level entities), reporting elapsed_ms + a scaling
|
||
* ratio `(t_large/t_small)/(800/250)`: ~1.0 is linear, ~3.2 is quadratic
|
||
* (the O(matches × rootChildren) shape #1848 hit in Go);
|
||
* - computes an order-independent sha256 fingerprint over the whole
|
||
* `lang-resolution/<lang>-*` fixture corpus + a fixed 20-entity synthetic
|
||
* source, as the correctness gate.
|
||
*
|
||
* Build-free: imports the `.ts` hotpaths through tsx
|
||
* (`node --import tsx bench/scope-capture/measure.mjs`). Static `.ts` imports
|
||
* work; a top-level `await import()` breaks tsx's lexer.
|
||
*
|
||
* Without args: prints one JSON object per language.
|
||
* With `--check`: asserts each language's fingerprint == its committed baseline
|
||
* (baselines.json) AND scaling_ratio < that language's recorded budget; exits
|
||
* non-zero on any drift/regression.
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import crypto from 'node:crypto';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import { emitGoScopeCaptures } from '../../src/core/ingestion/languages/go/index.ts';
|
||
import { emitCsharpScopeCaptures } from '../../src/core/ingestion/languages/csharp/index.ts';
|
||
import { emitRustScopeCaptures } from '../../src/core/ingestion/languages/rust/index.ts';
|
||
import { emitPhpScopeCaptures } from '../../src/core/ingestion/languages/php/index.ts';
|
||
import { emitRubyScopeCaptures } from '../../src/core/ingestion/languages/ruby/index.ts';
|
||
import { emitCobolScopeCaptures } from '../../src/core/ingestion/languages/cobol/index.ts';
|
||
import { emitSwiftScopeCaptures } from '../../src/core/ingestion/languages/swift/index.ts';
|
||
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/index.ts';
|
||
import { emitJsScopeCaptures } from '../../src/core/ingestion/languages/javascript/index.ts';
|
||
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/index.ts';
|
||
import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/index.ts';
|
||
import { emitCScopeCaptures } from '../../src/core/ingestion/languages/c/index.ts';
|
||
import { emitCppScopeCaptures } from '../../src/core/ingestion/languages/cpp/index.ts';
|
||
import { emitDartScopeCaptures } from '../../src/core/ingestion/languages/dart/index.ts';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution');
|
||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||
|
||
// ---- correctness fingerprint (order-independent; mirrors python harness) ----
|
||
|
||
function canonicalizeMatch(match) {
|
||
const parts = [];
|
||
for (const tag of Object.keys(match)) {
|
||
const cap = match[tag];
|
||
if (cap === undefined || cap === null || cap.range === undefined) {
|
||
parts.push(`${tag}|<no-range>`);
|
||
continue;
|
||
}
|
||
const r = cap.range;
|
||
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
|
||
}
|
||
parts.sort();
|
||
return parts.join(';');
|
||
}
|
||
|
||
function digestCaptures(matches) {
|
||
return crypto
|
||
.createHash('sha256')
|
||
.update(matches.map(canonicalizeMatch).sort().join('\n'))
|
||
.digest('hex');
|
||
}
|
||
|
||
/** All fixture files for a language, sorted by repo-relative key. */
|
||
function collectFixtures(prefix, exts) {
|
||
const out = [];
|
||
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
|
||
if (!entry.isDirectory() || !entry.name.startsWith(`${prefix}-`)) continue;
|
||
const stack = [path.join(FIXTURE_ROOT, entry.name)];
|
||
while (stack.length) {
|
||
const dir = stack.pop();
|
||
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const p = path.join(dir, c.name);
|
||
if (c.isDirectory()) stack.push(p);
|
||
else if (exts.some((e) => c.name.endsWith(e))) {
|
||
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out.sort((a, b) => a.key.localeCompare(b.key));
|
||
return out;
|
||
}
|
||
|
||
// ---- per-language config: synthetic DAO generators + fixture globs ----
|
||
|
||
const LANGS = [
|
||
{
|
||
name: 'go',
|
||
emit: emitGoScopeCaptures,
|
||
fixturePrefix: 'go',
|
||
exts: ['.go'],
|
||
file: 'bench.go',
|
||
// Heritage-bearing: each Entity embeds Base (Go inheritance = struct
|
||
// embedding) so the @reference.inherits synth (#1951) is driven at scale.
|
||
header:
|
||
'package generated\n\ntype Base struct{}\n\nfunc (b *Base) BaseMethod() string { return "base" }\n\n',
|
||
unit: (n) =>
|
||
`type Entity${n} struct {\n\tBase\n\tid int64\n\tname string\n}\n\n` +
|
||
`func (e *Entity${n}) GetID() int64 { return e.id }\n` +
|
||
`func (e *Entity${n}) SetName(v string) { e.name = v }\n\n`,
|
||
},
|
||
{
|
||
name: 'csharp',
|
||
emit: emitCsharpScopeCaptures,
|
||
fixturePrefix: 'csharp',
|
||
exts: ['.cs'],
|
||
file: 'bench.cs',
|
||
// Heritage-bearing: extends Base + implements IEntity (both forms) so the
|
||
// @reference.inherits synth (#1951) is driven at scale, not just the base loop.
|
||
header:
|
||
'namespace Generated;\n\npublic class Base { }\n\npublic interface IEntity {\n long GetId();\n}\n\n',
|
||
unit: (n) =>
|
||
`public class Entity${n} : Base, IEntity {\n` +
|
||
` public long Id;\n public string Name;\n` +
|
||
` public long GetId() { return Id; }\n` +
|
||
` public void SetName(string v) { Name = v; }\n}\n\n`,
|
||
},
|
||
{
|
||
name: 'rust',
|
||
emit: emitRustScopeCaptures,
|
||
fixturePrefix: 'rust',
|
||
exts: ['.rs'],
|
||
file: 'bench.rs',
|
||
// Heritage-bearing: `impl Shape for Entity_n` (Rust inheritance lives on
|
||
// impl_item) so the @reference.inherits trait-impl synth (#1951) is driven
|
||
// at scale. The two methods move into the trait impl to keep unit size flat.
|
||
header: 'trait Shape {\n fn area(&self) -> i64;\n fn name(&self) -> String;\n}\n\n',
|
||
unit: (n) =>
|
||
`struct Entity${n} {\n id: i64,\n name: String,\n}\n\n` +
|
||
`impl Shape for Entity${n} {\n` +
|
||
` fn area(&self) -> i64 { self.id }\n` +
|
||
` fn name(&self) -> String { self.name.clone() }\n}\n\n`,
|
||
},
|
||
{
|
||
name: 'php',
|
||
emit: emitPhpScopeCaptures,
|
||
fixturePrefix: 'php',
|
||
exts: ['.php'],
|
||
file: 'bench.php',
|
||
// Heritage-bearing: extends Base + uses a trait (both forms) so the
|
||
// @reference.inherits synth (#1951) is driven at scale.
|
||
header:
|
||
'<?php\n\nclass Base {}\n\ntrait Auditable {\n public function audit() { return true; }\n}\n\n',
|
||
unit: (n) =>
|
||
`class Entity${n} extends Base {\n` +
|
||
` use Auditable;\n` +
|
||
` public $id;\n public $name;\n` +
|
||
` function getId() { return $this->id; }\n` +
|
||
` function setName($v) { $this->name = $v; }\n}\n\n`,
|
||
},
|
||
{
|
||
name: 'ruby',
|
||
emit: emitRubyScopeCaptures,
|
||
fixturePrefix: 'ruby',
|
||
exts: ['.rb'],
|
||
file: 'bench.rb',
|
||
// Heritage-bearing: `< Base` superclass + `include Trackable` mixin (both
|
||
// forms) so the @reference.inherits synth (#1951) is driven at scale.
|
||
header:
|
||
'class Base\n def base_id\n @id\n end\nend\n\nmodule Trackable\n def track\n @tracked = true\n end\nend\n\n',
|
||
unit: (n) =>
|
||
`class Entity${n} < Base\n` +
|
||
` include Trackable\n` +
|
||
` def get_id\n @id\n end\n` +
|
||
` def set_name(v)\n @name = v\n end\nend\n\n`,
|
||
},
|
||
{
|
||
name: 'cobol',
|
||
emit: emitCobolScopeCaptures,
|
||
fixturePrefix: 'cobol',
|
||
exts: ['.cbl', '.cpy'],
|
||
file: 'bench.cbl',
|
||
header:
|
||
' IDENTIFICATION DIVISION.\n' +
|
||
' PROGRAM-ID. BENCH.\n' +
|
||
' PROCEDURE DIVISION.\n',
|
||
unit: (n) => ` PARA-${String(n).padStart(5, '0')}.\n DISPLAY "P${n}".\n`,
|
||
},
|
||
{
|
||
name: 'c',
|
||
emit: emitCScopeCaptures,
|
||
fixturePrefix: 'c',
|
||
exts: ['.c', '.h'],
|
||
file: 'bench.c',
|
||
// C has no inheritance construct — flat scale source. Added (was unbenched);
|
||
// adding it exposed + fixed the same O(n²) findNodeAtRange root-walk (#1956).
|
||
header: '#include <stdint.h>\n#include <stddef.h>\n\ntypedef int64_t id_t;\n\n',
|
||
unit: (n) =>
|
||
`typedef struct Entity${n} {\n id_t id;\n const char *name;\n} Entity${n};\n\n` +
|
||
`id_t entity_${n}_get_id(Entity${n} *e) { return e->id; }\n` +
|
||
`void entity_${n}_set_name(Entity${n} *e, const char *v) { e->name = v; }\n\n`,
|
||
},
|
||
{
|
||
name: 'cpp',
|
||
emit: emitCppScopeCaptures,
|
||
fixturePrefix: 'cpp',
|
||
exts: ['.cpp', '.cc', '.cxx', '.hpp', '.h'],
|
||
file: 'bench.cpp',
|
||
// Heritage-bearing: `: public Base, public Mixin` (single + multiple
|
||
// inheritance) drives emitCppInheritanceCaptures (#1951) at scale. Added
|
||
// (was unbenched); adding it exposed + fixed the same O(n²) root-walk (#1956).
|
||
//
|
||
// Also GENERIC-MEMBER-bearing (#2833): `Repo<Entity_n> repo;` is a member
|
||
// whose declared type is a bare `template_type`, and
|
||
// `std::vector<Entity_n> items;` is the far commoner spelling where a
|
||
// `qualified_identifier` WRAPS that template_type. Both were absent, and
|
||
// their absence is why two successive rounds of `field_declaration`
|
||
// type-binding rules landed with a byte-identical cpp fingerprint: the gate
|
||
// could not see a member field it had no instance of. With them present,
|
||
// reverting either round of rules drifts the fingerprint, which is the
|
||
// property that makes the gate worth running.
|
||
header:
|
||
'#include <string>\n#include <vector>\n\ntemplate <typename T>\nclass Repo {\n public:\n void save(T v) {}\n};\n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n',
|
||
unit: (n) =>
|
||
`class Entity${n} : public Base, public Mixin {\n public:\n long id;\n std::string name;\n` +
|
||
` Repo<Entity${n}> repo;\n` +
|
||
` std::vector<Entity${n}> items;\n` +
|
||
` long getId() const { return id; }\n` +
|
||
` void setName(std::string v) { name = v; }\n};\n\n`,
|
||
},
|
||
{
|
||
name: 'swift',
|
||
emit: emitSwiftScopeCaptures,
|
||
fixturePrefix: 'swift',
|
||
exts: ['.swift'],
|
||
file: 'bench.swift',
|
||
// Heritage-bearing: inherits Base + conforms to Serviceable (both forms) so
|
||
// the @reference.inherits synth (#1951) is driven at scale.
|
||
header:
|
||
'class Base {\n func ping() -> String { return "base" }\n}\n\nprotocol Serviceable {\n func serve() -> String\n}\n\n',
|
||
unit: (n) =>
|
||
`class Entity${n}: Base, Serviceable {\n` +
|
||
` var id: Int64 = 0\n var name: String = ""\n` +
|
||
` func getId() -> Int64 { return self.id }\n` +
|
||
` func serve() -> String { return self.name }\n}\n\n`,
|
||
},
|
||
{
|
||
name: 'dart',
|
||
emit: emitDartScopeCaptures,
|
||
fixturePrefix: 'dart',
|
||
exts: ['.dart'],
|
||
file: 'bench.dart',
|
||
// Heritage-bearing: `extends Base` (generic @reference.inherits pre-pass)
|
||
// + `implements Marker` (Dart `implements <class>` → IMPLEMENTS marker) so
|
||
// both heritage paths and the postfix-chain reference walk run at scale.
|
||
header:
|
||
'class Base {\n String ping() { return "base"; }\n}\n\nabstract class Marker {\n String mark();\n}\n\n',
|
||
unit: (n) =>
|
||
`class Entity${n} extends Base implements Marker {\n` +
|
||
` int id = 0;\n String name = '';\n` +
|
||
` int getId() { return this.id; }\n` +
|
||
` String mark() { return this.name; }\n}\n\n`,
|
||
},
|
||
{
|
||
name: 'java',
|
||
emit: emitJavaScopeCaptures,
|
||
fixturePrefix: 'java',
|
||
exts: ['.java'],
|
||
file: 'bench.java',
|
||
// Java was previously unbenched. Class and record heritage both implement
|
||
// Marker so the @reference.inherits synth (#1951, #2900) is driven at scale.
|
||
header: 'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\n',
|
||
unit: (n) =>
|
||
`class Entity${n} extends Base implements Marker {\n` +
|
||
` long id = 0L;\n String name = "";\n` +
|
||
` public long getId() { return this.id; }\n` +
|
||
` public void setName(String v) { this.name = v; }\n}\n\n` +
|
||
`record RecordEntity${n}(long id) implements Marker {}\n\n`,
|
||
},
|
||
{
|
||
name: 'java-local-types',
|
||
emit: emitJavaScopeCaptures,
|
||
fixturePrefix: 'java-local',
|
||
exts: ['.java'],
|
||
file: 'bench-local.java',
|
||
header:
|
||
'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\nclass Bench {\n void run() {\n',
|
||
// Co-scale both independent ordinal sequences under one host; construction
|
||
// and dispatch keep lexical-alias captures hot. The old per-identity
|
||
// host-candidate filter made this combined workload quadratic.
|
||
unit: (n) =>
|
||
` { class Local extends Base implements Marker { long value() { return ${n}L; } } ` +
|
||
`new Local().value(); }\n` +
|
||
` Marker marker${n} = new Marker() {};\n`,
|
||
footer: ' }\n}\n',
|
||
},
|
||
{
|
||
name: 'typescript',
|
||
emit: emitTsScopeCaptures,
|
||
fixturePrefix: 'typescript',
|
||
exts: ['.ts', '.tsx'],
|
||
file: 'bench.ts',
|
||
// Inheritance-bearing units so the @reference.inherits synth pass (#1951)
|
||
// is exercised at scale, not just the base capture loop.
|
||
header: 'class Base {}\n\n',
|
||
unit: (n) =>
|
||
`class Entity${n} extends Base {\n` +
|
||
` id: number = 0;\n name: string = '';\n` +
|
||
` getId(): number { return this.id; }\n` +
|
||
` setName(v: string): void { this.name = v; }\n}\n\n`,
|
||
},
|
||
{
|
||
// One `a || b || …` chain whose operand count is the entity count (#3354
|
||
// review): each operand is one more nesting level, so this guards the
|
||
// callable-flow value-alternative expansion against per-leaf work that
|
||
// grows with chain depth (ratio ~8 before the fix, and 8000 operands
|
||
// overflowed the stack). No fixture corpus — the fingerprint is the synthetic
|
||
// source alone; `typescript` above already covers the TS fixtures.
|
||
name: 'typescript-deep-chain',
|
||
emit: emitTsScopeCaptures,
|
||
exts: ['.ts'],
|
||
file: 'bench-chain.ts',
|
||
header: 'function handler() {}\n\nexport function isKw(w: string) {\n const f = handler',
|
||
unit: (n) => ` || w === "k${n}"`,
|
||
footer: ';\n return f;\n}\n',
|
||
},
|
||
{
|
||
name: 'javascript',
|
||
emit: emitJsScopeCaptures,
|
||
fixturePrefix: 'javascript',
|
||
exts: ['.js', '.jsx', '.mjs', '.cjs'],
|
||
file: 'bench.js',
|
||
header: 'class Base {}\n\n',
|
||
unit: (n) =>
|
||
`class Entity${n} extends Base {\n` +
|
||
` getId() { return this.id; }\n` +
|
||
` setName(v) { this.name = v; }\n}\n\n`,
|
||
},
|
||
{
|
||
name: 'kotlin',
|
||
emit: emitKotlinScopeCaptures,
|
||
fixturePrefix: 'kotlin',
|
||
exts: ['.kt', '.kts'],
|
||
file: 'bench.kt',
|
||
header: 'open class Base\n\n',
|
||
unit: (n) =>
|
||
`class Entity${n} : Base() {\n` +
|
||
` var id: Long = 0\n var name: String = ""\n` +
|
||
` fun getId(): Long { return id }\n` +
|
||
` fun setName(v: String) { name = v }\n}\n\n`,
|
||
},
|
||
];
|
||
|
||
function generate(lang, entityCount) {
|
||
let src = lang.header;
|
||
for (let i = 0; i < entityCount; i++) src += lang.unit(i);
|
||
return src + (lang.footer ?? '');
|
||
}
|
||
|
||
// ---- timing ----
|
||
|
||
function median(xs) {
|
||
const s = [...xs].sort((a, b) => a - b);
|
||
const m = Math.floor(s.length / 2);
|
||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||
}
|
||
|
||
function timeEmit(emit, src, file, reps) {
|
||
emit(src, `warmup-${file}`); // warm parser/query JIT (not counted)
|
||
const samples = [];
|
||
let count = 0;
|
||
for (let i = 0; i < reps; i++) {
|
||
const start = process.hrtime.bigint();
|
||
const out = emit(src, file);
|
||
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
|
||
count = out.length;
|
||
}
|
||
return { ms: median(samples), count };
|
||
}
|
||
|
||
const SMALL = 250;
|
||
const LARGE = 800;
|
||
const REPS = 7;
|
||
|
||
function measureLang(lang) {
|
||
// Correctness fingerprint over the fixture corpus + a fixed 20-entity source.
|
||
const perFixture = [];
|
||
let groups = 0;
|
||
const fixtures =
|
||
lang.fixturePrefix === undefined ? [] : collectFixtures(lang.fixturePrefix, lang.exts);
|
||
for (const { key, absPath } of fixtures) {
|
||
const matches = lang.emit(fs.readFileSync(absPath, 'utf8'), absPath);
|
||
groups += matches.length;
|
||
perFixture.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
|
||
}
|
||
const daoMatches = lang.emit(generate(lang, 20), `synthetic-dao-20${path.extname(lang.file)}`);
|
||
groups += daoMatches.length;
|
||
perFixture.push(`synthetic:dao-20\t${daoMatches.length}\t${digestCaptures(daoMatches)}`);
|
||
const fingerprint = crypto
|
||
.createHash('sha256')
|
||
.update(perFixture.sort().join('\n'))
|
||
.digest('hex');
|
||
|
||
// Scaling.
|
||
const small = timeEmit(lang.emit, generate(lang, SMALL), lang.file, REPS);
|
||
const large = timeEmit(lang.emit, generate(lang, LARGE), lang.file, REPS);
|
||
const scalingRatio = small.ms > 0 ? large.ms / small.ms / (LARGE / SMALL) : 0;
|
||
|
||
return {
|
||
language: lang.name,
|
||
elapsed_ms_small: Number(small.ms.toFixed(2)),
|
||
elapsed_ms_large: Number(large.ms.toFixed(2)),
|
||
scaling_ratio: Number(scalingRatio.toFixed(3)),
|
||
capture_groups_small: small.count,
|
||
capture_groups_large: large.count,
|
||
fingerprint,
|
||
capture_groups_fp: groups,
|
||
fixture_count: perFixture.length,
|
||
};
|
||
}
|
||
|
||
// ---- run ----
|
||
|
||
const CHECK = process.argv.includes('--check');
|
||
const results = LANGS.map(measureLang);
|
||
|
||
if (!CHECK) {
|
||
for (const r of results) process.stdout.write(JSON.stringify(r) + '\n');
|
||
} else {
|
||
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
|
||
const failures = [];
|
||
for (const r of results) {
|
||
const base = baselines[r.language];
|
||
if (base === undefined) {
|
||
failures.push(`${r.language}: no baseline recorded`);
|
||
continue;
|
||
}
|
||
if (r.fingerprint !== base.fingerprint) {
|
||
failures.push(
|
||
`${r.language}: capture fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`,
|
||
);
|
||
}
|
||
if (r.scaling_ratio >= base.scaling_budget) {
|
||
failures.push(
|
||
`${r.language}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` +
|
||
`(${SMALL}->${LARGE} ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`,
|
||
);
|
||
}
|
||
process.stdout.write(JSON.stringify(r) + '\n');
|
||
}
|
||
if (failures.length > 0) {
|
||
for (const f of failures) process.stderr.write(`[scope-capture --check] FAIL: ${f}\n`);
|
||
process.exit(1);
|
||
}
|
||
process.stderr.write(`[scope-capture --check] PASS (${results.length} languages)\n`);
|
||
}
|