GitNexus/gitnexus/bench/scope-capture/measure.mjs
Gergő Magyar e06c2dd13e
fix(impact): fail closed on id-less targets and follow ??/||/?: callable values (#3354) (#3373)
* 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 from
  8ad0d8db7, 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-base 233ca2849) 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>
2026-09-25 21:43:24 +01:00

456 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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`);
}