GitNexus/gitnexus/bench/receiver-resolution/measure.mjs
Gergő Magyar 911151e230
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(resolution): resolve Go pointer-receiver calls, and report the program boundary instead of hedging (#2766) (#2782)
2026-08-01 22:42:18 +01:00

1608 lines
56 KiB
JavaScript

/**
* Receiver-resolution measurement harness.
*
* Answers one question — "how many method calls does GitNexus lose because it
* could not establish the receiver's type, and which source shapes are they?" —
* and answers it in a way that can gate a decision.
*
* TWO ARMS, because neither one alone is trustworthy:
*
* 1. SHAPE ARM (`--shapes`, default). A fixed matrix of receiver spellings —
* every SHAPE_ID against every language — each cell classified by what the
* graph actually contains:
*
* RESOLVES edge emitted. A test written against this shape starts
* green and proves nothing. Note this states only that an
* edge EXISTS — not that it points at the right target. A
* name-keyed fallback onto a same-named member reads as
* RESOLVES here, so a shape whose receiver has no
* well-defined type is not a usable control.
* VISIBLE-GAP no edge, and a `receiver-unresolved` drop was recorded.
* Measurable by the count arm below.
* INVISIBLE-GAP no edge, and NO drop was recorded. The call is lost and
* the instrument cannot see it.
* N/A the language's grammar does not admit this spelling.
* Carries a REQUIRED reason — an omitted cell and a
* genuinely inapplicable one look identical in a diff
* otherwise, and that is how coverage silently rots.
* GRAMMAR- the language's parser could not be loaded, so nothing
* UNAVAILABLE about it was measured. Neither passes nor fails the
* gate. Dart, Kotlin and Swift are vendored OPTIONAL
* grammars: they are skipped entirely by
* GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1, and they soft-fail
* when no vendored prebuild matches the host (the set
* covers darwin/linux arm64+x64 and win32-arm64 — a
* win32-x64 or musl host has none). Without this state an
* unloaded grammar emits no edges and every one of its
* cells reads as a resolution gap indistinguishable from a
* real regression.
* NOTE: all 14 load on a glibc linux-x64 host, so this
* state has no producer in the committed baseline. It
* guards the skip-flag and unsupported-host cases.
*
* INVISIBLE-GAP is why this arm exists, and the series proved it: at the
* time this arm was built `svc?.getUser().save()`, `svc.getTyped<User>().save()`
* and `repos[0].save()` were all INVISIBLE, so fixing them moved the count
* arm by exactly zero and a gate reading only the drop count would have
* scored a working change as "no improvement". They resolve for TypeScript
* now; the current per-shape state is `baseline.json`, never this comment.
*
* COMPLETENESS IS ENFORCED: every language must declare a cell for every
* SHAPE_ID, and every N/A must carry a reason. A missing cell fails the run
* rather than being silently skipped.
*
* 2. COUNT ARM (`--corpus <repoPath>`). Runs the real pipeline over a repo and
* reports drops SPLIT BY SITE KIND. The gate number is `call` only: the
* recorder's gate tests the receiver's punctuation, not the site's kind, so
* property reads (`d.source.kind`) and writes (`x.argtypes = [...]`) land in
* the same bucket as lost method calls and would inflate it.
*
* KNOWN BLIND SPOTS — reported in every run, deliberately, because the number
* is a lower bound on a KNOWN-BIASED population and any delta measured later
* must be read against the same bias:
*
* - Case 0 is reached by a receiver-TEXT punctuation test (`.` or `(`) OR by a
* minted receiver chain, so a receiver spelled without that punctuation — a
* subscript, PHP `->`/`::` — records a drop only where its emitter mints a
* chain. Where none is minted the call still vanishes unrecorded.
* - A drop is recorded only while `compoundReceiverUnresolved` stays true: if
* the cascade types the receiver but finds no member, no drop is recorded
* even though no edge was emitted, so an absent drop is not proof of
* resolution. (Superseded claim, kept because it was quoted for several
* units: `?.` and explicit type arguments DO produce a reference site —
* what was absent was the edge and the drop, never the site.)
*
* MEASUREMENT HYGIENE — a run that skips this is void:
*
* npm run build # the parse worker runs from dist/
* rm -rf .gitnexus/parse-cache .gitnexus/parsedfile-cache
* node --import tsx bench/receiver-resolution/measure.mjs --corpus <repo>
*
* `analyze --force` clears NEITHER cache, so a stale shard will happily serve
* the previous capture set and produce a confident, wrong number.
*
* Usage:
* node --import tsx bench/receiver-resolution/measure.mjs
* node --import tsx bench/receiver-resolution/measure.mjs --corpus /path/to/repo
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.ts';
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/index.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baseline.json');
/** The `--check` corpus. Committed and multi-language, so the gate is
* deterministic and does not depend on anything outside the repo. */
const DEFAULT_CORPUS = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution');
// ---------------------------------------------------------------------------
// Shape corpus
// ---------------------------------------------------------------------------
/**
* The canonical shape axis. Every language corpus must declare a cell for every
* id here — a body to measure, or `na` with a reason. `assertMatrixComplete`
* enforces it, so adding a shape forces every language to answer for it.
*/
const SHAPE_IDS = [
'plainChain',
'plainDeepChain',
'optionalChain',
'nonNullAssert',
'awaitParen',
'explicitTypeArgs',
'indexElement',
'fieldReceiverCall',
'decoratedReceiverBase',
'decoratedFieldType',
];
/**
* Languages that carry no receiver-chain emission at all, recorded as
* language-level rows so the language axis obeys the same no-omitted-cells rule
* as the shape axis. Their capture emitters do not call
* `synthesizeReceiverChainCapture`, so there is nothing to measure — but an
* absent row and an inapplicable row must not look alike in a diff.
*/
const NA_LANGUAGES = {
vue: 'SFC templates delegate to the TypeScript/JavaScript emitters; no receiver chain is minted for the .vue file itself.',
cobol: 'No method-call receiver syntax; the emitter mints no receiver chains.',
};
/**
* Each entry is one receiver spelling. `entry` is the function that contains
* it; `member` is the method it should reach. Classification asks only two
* questions of the result — is there a CALLS edge from `entry` to `member`,
* and was a drop recorded on that line — so it never guesses from an id shape.
*
* An entry with `na` declares the shape inapplicable to this language and is
* never compiled into the fixture.
*/
const CORPORA = [
{
lang: 'typescript',
ext: '.ts',
// `fourHopChain` exists to make MAX_CHAIN_DEPTH answerable. Without a chain
// longer than the cap, raising the cap measures nothing and the question
// "what does depth N buy?" has no instrument behind it.
extraShapeIds: ['fourHopChain'],
support: {
'models.ts': `export class City {
save(): void {}
}
export class Address {
save(): void {}
getCity(): City {
return new City();
}
}
export class User {
name: string = '';
address: Address = new Address();
save(): void {}
}
export class Service {
getUser(): User {
return new User();
}
async getUserAsync(): Promise<User> {
return new User();
}
getTyped<T>(): User {
return new User();
}
}
`,
},
header: `import { Service, User } from './models';\n`,
wrap: (entry, body) =>
`export async function ${entry}(svc: Service, repos: User[]): Promise<void> {\n ${body}\n}\n`,
shapes: [
{ id: 'plainChain', member: 'save', body: 'svc.getUser().save();', note: 'control' },
{
id: 'plainDeepChain',
member: 'save',
body: 'svc.getUser().address.save();',
note: 'control',
},
{ id: 'optionalChain', member: 'save', body: 'svc?.getUser().save();', note: 'PF1' },
{ id: 'nonNullAssert', member: 'save', body: 'svc!.getUser().save();', note: 'PF2' },
{
id: 'awaitParen',
member: 'save',
body: '(await svc.getUserAsync()).save();',
note: 'PF3',
},
{ id: 'explicitTypeArgs', member: 'save', body: 'svc.getTyped<User>().save();', note: 'PF4' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save();', note: 'PF5' },
{
// FOUR STEPS in the receiver of `save`: getSvc, getUser, address,
// getCity. One more than the pre-U6 cap of 3, so the chain is DISCARDED
// WHOLE at depth 3 and types end to end at depth 4 — the whole basis
// for choosing the number. (An earlier version of this fixture had only
// three steps and therefore resolved at both depths, measuring nothing.)
id: 'fourHopChain',
member: 'save',
body: 'root.getSvc().getUser().address.getCity().save();',
raw: `class Root {
getSvc(): Service {
return new Service();
}
}
export function fourHopChain(root: Root): void {
root.getSvc().getUser().address.getCity().save();
}
`,
},
{
id: 'fieldReceiverCall',
member: 'save',
body: 'this.repo.save();',
raw: `class TsHolder {
repo: User = new User();
fieldReceiverCall(): void {
this.repo.save();
}
}
`,
note: 'control — undecorated field receiver',
},
{
id: 'decoratedReceiverBase',
na: 'TypeScript `this` carries no type decoration — there is no pointer/reference spelling on a method receiver.',
},
{
id: 'decoratedFieldType',
member: 'save',
body: 'this.repo!.save();',
raw: `class TsNullableHolder {
repo: User | null = null;
decoratedFieldType(): void {
this.repo!.save();
}
}
`,
note: 'union-with-null field type',
},
],
},
{
lang: 'php',
ext: '.php',
// `arrowCallChain` / `arrowPropertyPath` discriminate an unannotated return
// type from a property-path receiver — neither is expressible on the
// canonical axis, and both are load-bearing for the PHP diagnosis.
extraShapeIds: ['arrowCallChain', 'arrowPropertyPath'],
support: {
'models.php': `<?php
class Address {
public function save() {}
}
class User {
public Address $address;
public function save() {}
}
class Service {
// Deliberately UNANNOTATED. \`arrowCallChain\` measures this one, and the
// missing return type is the suspected cause of its gap — annotating it
// here would erase the signal instead of explaining it.
public function getUser() {
return new User();
}
// The annotated twin. \`plainChain\` measures this one, so the two cells
// together discriminate "PHP cannot chain" from "PHP cannot infer an
// unannotated return type".
public function getUserTyped(): User {
return new User();
}
}
`,
},
header: `<?php\nrequire_once 'models.php';\n`,
// TYPED parameter. An untyped `$svc` has no type binding to resolve the
// chain's base against, which would make this row report a language gap
// that is really a fixture defect. `$repos` is typed as an array for the
// subscript row for the same reason.
wrap: (entry, body) => `function ${entry}(Service $svc, array $repos) {\n ${body}\n}\n`,
shapes: [
{
id: 'arrowCallChain',
member: 'save',
body: '$svc->getUser()->save();',
note: 'PF6 — recorded, because the receiver text contains `(`',
},
{
// The discriminating control for KTD6 defect 2. This receiver
// (`$this->repo`) contains neither `.` nor `(`, so Case 0's gate never
// fires and the drop is never recorded — while the call chain above IS
// recorded. "PHP records no drops" is too coarse: it is the
// property-path receiver that is invisible, not the language.
id: 'arrowPropertyPath',
member: 'save',
body: '$this->repo->save();',
raw: `class Holder {
public User $repo;
public function arrowPropertyPath() {
$this->repo->save();
}
}
`,
note: 'PF6-control — property-path receiver, no `.` and no `(`',
},
{
id: 'plainChain',
member: 'save',
body: '$svc->getUserTyped()->save();',
note: 'discriminator for arrowCallChain — same chain, ANNOTATED return type',
},
{
id: 'plainDeepChain',
member: 'save',
body: '$svc->getUser()->address->save();',
note: 'deep arrow chain',
},
{ id: 'optionalChain', member: 'save', body: '$svc?->getUser()->save();', note: 'nullsafe' },
{
id: 'nonNullAssert',
na: 'PHP has no non-null assertion operator.',
},
{ id: 'awaitParen', na: 'PHP has no await expression in the core language.' },
{
id: 'explicitTypeArgs',
na: 'PHP has no generic type-argument syntax at a call site.',
},
{ id: 'indexElement', member: 'save', body: '$repos[0]->save();', note: 'array subscript' },
{
id: 'fieldReceiverCall',
na: 'Covered by `arrowPropertyPath` above — the same undecorated field-receiver shape.',
},
{
id: 'decoratedReceiverBase',
na: 'PHP `$this` carries no type decoration on a method receiver.',
},
{
id: 'decoratedFieldType',
member: 'save',
body: '$this->repo->save();',
raw: `class NullableHolder {
public ?User $repo;
public function decoratedFieldType() {
$this->repo->save();
}
}
`,
note: 'nullable field type',
},
],
},
{
lang: 'cpp',
ext: '.cpp',
// `pointerArrowChain` / `valueDotChain` discriminate a `->` base from a `.`
// base on the same chain — the pair is why PF7 was attributable to the base
// rather than to C++ chaining in general.
extraShapeIds: ['pointerArrowChain', 'valueDotChain'],
support: {
'models.h': `#pragma once
class Address {
public:
void save();
};
class User {
public:
Address addr;
void save();
};
class Service {
public:
User* getUser();
template <typename T>
T* getTyped();
};
`,
},
header: `#include "models.h"\n`,
wrap: (entry, body) =>
`void ${entry}(Service* svc, Service svc2, User* repos) {\n ${body}\n}\n`,
shapes: [
{
id: 'pointerArrowChain',
member: 'save',
body: 'svc->getUser()->save();',
note: 'PF7 — no fixture exists today; cpp-chain-call/ uses value `.`',
},
{
// The discriminating control for PF7. Same chain, same `->save()` tail,
// but a value `.` on the BASE receiver — and it resolves. So the defect
// is the `->` base specifically, not C++ chaining, and the existing
// `cpp-chain-call/` fixture cannot catch it because it uses this form.
id: 'valueDotChain',
member: 'save',
body: 'svc2.getUser()->save();',
note: 'PF7-control — value `.` base resolves',
},
{
id: 'plainChain',
na: 'C++ spells a chained call through `->` or `.`; both are measured above as pointerArrowChain and valueDotChain.',
},
{ id: 'plainDeepChain', member: 'save', body: 'svc->getUser()->addr.save();' },
{ id: 'optionalChain', na: 'C++ has no optional-chaining operator.' },
{ id: 'nonNullAssert', na: 'C++ has no non-null assertion operator.' },
{
id: 'awaitParen',
na: 'co_await is a coroutine feature the emitter does not model as an async container.',
},
{ id: 'explicitTypeArgs', member: 'save', body: 'svc->getTyped<User>()->save();' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save();' },
{
id: 'fieldReceiverCall',
member: 'save',
body: 'this->repo.save();',
raw: `struct CppHolder {
User repo;
void fieldReceiverCall() {
this->repo.save();
}
};
`,
note: 'control — undecorated field receiver',
},
{
id: 'decoratedReceiverBase',
na: 'C++ `this` is implicitly a pointer with no written decoration; the pointer base is measured by pointerArrowChain.',
},
{
id: 'decoratedFieldType',
member: 'save',
body: 'this->repo->save();',
raw: `struct CppPtrHolder {
User* repo;
void decoratedFieldType() {
this->repo->save();
}
};
`,
note: 'pointer field type',
},
],
},
{
// Go is the language whose root cause this plan proves: a method with a
// POINTER receiver binds its receiver to the literal string `*Host`, which
// findClassBindingInScope cannot resolve. The three field-receiver rows
// below isolate that — they vary receiver decoration and field decoration
// independently, so a gap can be attributed to one or the other rather than
// to "Go field receivers" as a whole.
lang: 'go',
ext: '.go',
support: {
'models.go': `package main
type Address struct{}
func (a *Address) Save() {}
type User struct {
Address *Address
}
func (u *User) Save() {}
type Service struct{}
func (s *Service) GetUser() *User { return &User{} }
type Tgt struct{}
func (t *Tgt) PtrM() {}
func (t Tgt) ValM() {}
`,
},
header: `package main\n`,
wrap: (entry, body) => `func ${entry}(svc *Service, repos []*User) {\n\t${body}\n}\n`,
shapes: [
{ id: 'plainChain', member: 'Save', body: 'svc.GetUser().Save()', note: 'control' },
{
id: 'plainDeepChain',
member: 'Save',
body: 'svc.GetUser().Address.Save()',
note: 'control',
},
{ id: 'optionalChain', na: 'Go has no optional-chaining operator.' },
{ id: 'nonNullAssert', na: 'Go has no non-null assertion operator.' },
{ id: 'awaitParen', na: 'Go has no await expression; concurrency is channel-based.' },
{
id: 'explicitTypeArgs',
na: 'Go methods cannot declare type parameters, so there is no call-site type-argument spelling on a method.',
},
{ id: 'indexElement', member: 'Save', body: 'repos[0].Save()', note: 'slice subscript' },
{
// VALUE receiver + VALUE field. Neither side carries decoration, so this
// is the control that isolates the two rows below.
id: 'fieldReceiverCall',
member: 'ValM',
body: 'h.f.ValM()',
raw: `type HostVV struct{ f Tgt }
func (h HostVV) fieldReceiverCall() {
h.f.ValM()
}
`,
note: 'control — value receiver, value field',
},
{
// POINTER receiver + VALUE field. The receiver binds as `*HostPV` and
// the class lookup misses every branch (no dot, so the dotted-tail
// fallback never fires). THE root cause this plan fixes.
id: 'decoratedReceiverBase',
member: 'ValM',
body: 'h.f.ValM()',
raw: `type HostPV struct{ f Tgt }
func (h *HostPV) decoratedReceiverBase() {
h.f.ValM()
}
`,
note: 'pointer receiver, value field — isolates the BASE',
},
{
// VALUE receiver + POINTER field. Go already normalizes field type
// bindings through normalizeGoTypeName, so this resolves today and
// proves the step lookup is NOT the defect.
id: 'decoratedFieldType',
member: 'PtrM',
body: 'h.f.PtrM()',
raw: `type HostVP struct{ f *Tgt }
func (h HostVP) decoratedFieldType() {
h.f.PtrM()
}
`,
note: 'value receiver, pointer field — isolates the STEP',
},
],
},
{
lang: 'javascript',
ext: '.js',
support: {
'models.js': `export class Address {
save() {}
}
export class User {
constructor() {
this.address = new Address();
}
save() {}
}
export class Service {
getUser() {
return new User();
}
async getUserAsync() {
return new User();
}
}
`,
},
header: `import { Service, User } from './models.js';\n`,
wrap: (entry, body) => `export async function ${entry}(svc, repos) {\n ${body}\n}\n`,
shapes: [
{ id: 'plainChain', member: 'save', body: 'svc.getUser().save();' },
{ id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save();' },
{ id: 'optionalChain', member: 'save', body: 'svc?.getUser().save();' },
{ id: 'nonNullAssert', na: 'JavaScript has no non-null assertion operator.' },
{ id: 'awaitParen', member: 'save', body: '(await svc.getUserAsync()).save();' },
{ id: 'explicitTypeArgs', na: 'JavaScript has no type-argument syntax.' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save();' },
{
id: 'fieldReceiverCall',
member: 'save',
body: 'this.repo.save();',
raw: `class JsHolder {
constructor() {
this.repo = new User();
}
fieldReceiverCall() {
this.repo.save();
}
}
`,
},
{
id: 'decoratedReceiverBase',
na: 'JavaScript method receivers carry no type decoration.',
},
{
id: 'decoratedFieldType',
na: 'JavaScript fields carry no declared type, so there is no decoration to strip.',
},
],
},
{
lang: 'python',
ext: '.py',
support: {
'models.py': `class Address:
def save(self) -> None:
pass
class User:
def __init__(self) -> None:
self.address: Address = Address()
def save(self) -> None:
pass
class Service:
def get_user(self) -> User:
return User()
async def get_user_async(self) -> User:
return User()
`,
},
header: `from typing import List, Optional\nfrom models import Address, Service, User\n`,
wrap: (entry, body) => `def ${entry}(svc: Service, repos: List[User]) -> None:\n ${body}\n`,
shapes: [
{ id: 'plainChain', member: 'save', body: 'svc.get_user().save()' },
{ id: 'plainDeepChain', member: 'save', body: 'svc.get_user().address.save()' },
{ id: 'optionalChain', na: 'Python has no optional-chaining operator.' },
{ id: 'nonNullAssert', na: 'Python has no non-null assertion operator.' },
{
id: 'awaitParen',
member: 'save',
body: '(await svc.get_user_async()).save()',
raw: `async def awaitParen(svc: Service) -> None:
(await svc.get_user_async()).save()
`,
note: 'annotated coroutine — the declared return type IS the awaited type',
},
{ id: 'explicitTypeArgs', na: 'Python has no call-site type-argument syntax.' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save()' },
{
id: 'fieldReceiverCall',
member: 'save',
body: 'self.repo.save()',
raw: `class PyHolder:
def __init__(self) -> None:
self.repo: User = User()
def fieldReceiverCall(self) -> None:
self.repo.save()
`,
},
{ id: 'decoratedReceiverBase', na: 'Python `self` carries no type decoration.' },
{
id: 'decoratedFieldType',
member: 'save',
body: 'self.repo.save()',
raw: `class PyOptionalHolder:
def __init__(self) -> None:
self.repo: Optional[User] = None
def decoratedFieldType(self) -> None:
self.repo.save()
`,
note: 'Optional[...] field annotation',
},
],
},
{
lang: 'java',
ext: '.java',
support: {
'Address.java': `public class Address {
public void save() {}
}
`,
'User.java': `public class User {
public Address address;
public void save() {}
}
`,
'Service.java': `import java.util.List;
public class Service {
public User getUser() { return new User(); }
public <T> User getTyped() { return new User(); }
}
`,
},
header: `import java.util.List;\n`,
// Java has no free functions, so each shape gets its own wrapper class. The
// METHOD is named for the shape, which is what classification matches on.
wrap: (entry, body) =>
`class Cap_${entry} {\n void ${entry}(Service svc, User[] repos) {\n ${body}\n }\n}\n`,
shapes: [
{ id: 'plainChain', member: 'save', body: 'svc.getUser().save();' },
{ id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save();' },
{ id: 'optionalChain', na: 'Java has no optional-chaining operator.' },
{ id: 'nonNullAssert', na: 'Java has no non-null assertion operator.' },
{ id: 'awaitParen', na: 'Java has no await expression in the core language.' },
{ id: 'explicitTypeArgs', member: 'save', body: 'svc.<User>getTyped().save();' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save();' },
{
id: 'fieldReceiverCall',
member: 'save',
body: 'this.repo.save();',
raw: `class JavaHolder {
User repo;
void fieldReceiverCall() {
this.repo.save();
}
}
`,
},
{ id: 'decoratedReceiverBase', na: 'Java `this` carries no type decoration.' },
{
id: 'decoratedFieldType',
na: "Java's only field-type decorations are containers (arrays, generics), which change the member set and are measured by indexElement rather than by a type-preserving strip.",
},
],
},
{
lang: 'csharp',
ext: '.cs',
support: {
'Models.cs': `using System.Threading.Tasks;
public class Address {
public void Save() {}
}
public class User {
public Address Address;
public void Save() {}
}
public class Service {
public User GetUser() { return new User(); }
public async Task<User> GetUserAsync() { return new User(); }
public User GetTyped<T>() { return new User(); }
}
`,
},
header: `using System.Threading.Tasks;\n`,
wrap: (entry, body) =>
`class Cap_${entry} {\n async Task ${entry}(Service svc, User[] repos) {\n ${body}\n }\n}\n`,
shapes: [
{ id: 'plainChain', member: 'Save', body: 'svc.GetUser().Save();' },
{ id: 'plainDeepChain', member: 'Save', body: 'svc.GetUser().Address.Save();' },
{ id: 'optionalChain', member: 'Save', body: 'svc?.GetUser().Save();' },
{ id: 'nonNullAssert', member: 'Save', body: 'svc!.GetUser().Save();' },
{ id: 'awaitParen', member: 'Save', body: '(await svc.GetUserAsync()).Save();' },
{ id: 'explicitTypeArgs', member: 'Save', body: 'svc.GetTyped<User>().Save();' },
{ id: 'indexElement', member: 'Save', body: 'repos[0].Save();' },
{
id: 'fieldReceiverCall',
member: 'Save',
body: 'this.repo.Save();',
raw: `class CsHolder {
User repo;
void fieldReceiverCall() {
this.repo.Save();
}
}
`,
},
{ id: 'decoratedReceiverBase', na: 'C# `this` carries no type decoration.' },
{
id: 'decoratedFieldType',
member: 'Save',
body: 'this.repo.Save();',
raw: `class CsNullableHolder {
User? repo;
void decoratedFieldType() {
this.repo.Save();
}
}
`,
note: 'nullable reference-type field',
},
],
},
{
lang: 'ruby',
ext: '.rb',
support: {
'models.rb': `class Address
def save; end
end
class User
attr_reader :address
def initialize
@address = Address.new
end
def save; end
end
class Service
def get_user
User.new
end
end
`,
},
header: `require_relative 'models'\n`,
wrap: (entry, body) => `def ${entry}(svc, repos)\n ${body}\nend\n`,
shapes: [
{ id: 'plainChain', member: 'save', body: 'svc.get_user.save' },
{ id: 'plainDeepChain', member: 'save', body: 'svc.get_user.address.save' },
{ id: 'optionalChain', member: 'save', body: 'svc&.get_user.save', note: 'safe navigation' },
{ id: 'nonNullAssert', na: 'Ruby has no non-null assertion operator.' },
{ id: 'awaitParen', na: 'Ruby has no await expression in the core language.' },
{ id: 'explicitTypeArgs', na: 'Ruby has no type-argument syntax.' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save' },
{
id: 'fieldReceiverCall',
member: 'save',
body: '@repo.save',
raw: `class RbHolder
def initialize
@repo = User.new
end
def fieldReceiverCall
@repo.save
end
end
`,
},
{ id: 'decoratedReceiverBase', na: 'Ruby method receivers carry no type decoration.' },
{
id: 'decoratedFieldType',
na: 'Ruby instance variables carry no declared type, so there is no decoration to strip.',
},
],
},
{
lang: 'rust',
ext: '.rs',
support: {
'models.rs': `pub struct Address;
impl Address {
pub fn save(&self) {}
}
pub struct User {
pub address: Address,
}
impl User {
pub fn save(&self) {}
}
pub struct Service;
impl Service {
pub fn get_user(&self) -> User {
User { address: Address }
}
pub fn get_typed<T>(&self) -> User {
User { address: Address }
}
}
`,
},
header: `mod models;\nuse models::{Address, Service, User};\n`,
wrap: (entry, body) => `fn ${entry}(svc: &Service, repos: &Vec<User>) {\n ${body}\n}\n`,
shapes: [
{ id: 'plainChain', member: 'save', body: 'svc.get_user().save();' },
{ id: 'plainDeepChain', member: 'save', body: 'svc.get_user().address.save();' },
{
id: 'optionalChain',
na: 'Rust `?` is error propagation, not optional chaining on a receiver.',
},
{ id: 'nonNullAssert', na: 'Rust has no non-null assertion operator.' },
{
id: 'awaitParen',
na: 'Rust `async fn` declares the AWAITED type; `.await` is postfix and mints no wrapper the emitter models as an async container.',
},
{ id: 'explicitTypeArgs', member: 'save', body: 'svc.get_typed::<User>().save();' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save();' },
{
id: 'fieldReceiverCall',
member: 'save',
body: 'self.repo.save();',
raw: `struct RsHolder {
repo: User,
}
impl RsHolder {
fn fieldReceiverCall(&self) {
self.repo.save();
}
}
`,
},
{
// Rust is the one language besides Go whose method receiver carries
// written decoration: `&self` / `&mut self` rather than a bare `self`.
id: 'decoratedReceiverBase',
member: 'save',
body: 'self.repo.save();',
raw: `struct RsMutHolder {
repo: User,
}
impl RsMutHolder {
fn decoratedReceiverBase(&mut self) {
self.repo.save();
}
}
`,
note: '&mut self receiver',
},
{
id: 'decoratedFieldType',
member: 'save',
body: 'self.repo.save();',
raw: `struct RsBoxHolder {
repo: Box<User>,
}
impl RsBoxHolder {
fn decoratedFieldType(&self) {
self.repo.save();
}
}
`,
note: 'Box<User> — deref-transparent smart pointer',
},
],
},
{
lang: 'c',
ext: '.c',
support: {
'models.h': `#pragma once
typedef struct Address {
void (*save)(void);
} Address;
typedef struct User {
Address* address;
void (*save)(void);
} User;
`,
},
header: `#include "models.h"\n`,
wrap: (entry, body) => `void ${entry}(User* u, User* repos) {\n ${body}\n}\n`,
// C has no method dispatch. The only receiver-shaped construct is a call
// through a struct function-pointer field, so the chain shapes that assume a
// method on a returned value are inapplicable rather than gaps.
shapes: [
{
id: 'plainChain',
na: 'C has no method call on a returned value; dispatch is through struct function-pointer fields only.',
},
{ id: 'plainDeepChain', member: 'save', body: 'u->address->save();' },
{ id: 'optionalChain', na: 'C has no optional-chaining operator.' },
{ id: 'nonNullAssert', na: 'C has no non-null assertion operator.' },
{ id: 'awaitParen', na: 'C has no await expression.' },
{ id: 'explicitTypeArgs', na: 'C has no generics.' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save();' },
{ id: 'fieldReceiverCall', member: 'save', body: 'u->save();' },
{
id: 'decoratedReceiverBase',
na: 'C has no method receiver, so there is no receiver decoration.',
},
{
id: 'decoratedFieldType',
na: 'A C struct field is a function pointer, not a typed receiver whose decoration could be stripped.',
},
],
},
{
// Kotlin, Swift and Dart are vendored OPTIONAL grammars shipping only
// darwin-arm64 prebuilds. On any other host these rows come back
// GRAMMAR-UNAVAILABLE and are skipped by the gate in both directions, so the
// cells below are the contract for a host that CAN load them rather than a
// measurement this machine produced.
lang: 'kotlin',
ext: '.kt',
support: {
'models.kt': `class Address {
fun save() {}
}
class User {
val address: Address = Address()
fun save() {}
}
class Service {
fun getUser(): User = User()
suspend fun getUserAsync(): User = User()
fun <T> getTyped(): User = User()
}
`,
},
header: '',
wrap: (entry, body) =>
`suspend fun ${entry}(svc: Service, repos: List<User>) {\n ${body}\n}\n`,
shapes: [
{ id: 'plainChain', member: 'save', body: 'svc.getUser().save()' },
{ id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save()' },
{ id: 'optionalChain', member: 'save', body: 'svc?.getUser()?.save()' },
{ id: 'nonNullAssert', member: 'save', body: 'svc!!.getUser().save()' },
{ id: 'awaitParen', member: 'save', body: '(svc.getUserAsync()).save()' },
{ id: 'explicitTypeArgs', member: 'save', body: 'svc.getTyped<User>().save()' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save()' },
{
id: 'fieldReceiverCall',
member: 'save',
body: 'this.repo.save()',
raw: `class KtHolder {
val repo: User = User()
fun fieldReceiverCall() {
this.repo.save()
}
}
`,
},
{ id: 'decoratedReceiverBase', na: 'Kotlin `this` carries no type decoration.' },
{
id: 'decoratedFieldType',
member: 'save',
body: 'this.repo?.save()',
raw: `class KtNullableHolder {
val repo: User? = null
fun decoratedFieldType() {
this.repo?.save()
}
}
`,
note: 'nullable field type',
},
],
},
{
lang: 'swift',
ext: '.swift',
support: {
'models.swift': `class Address {
func save() {}
}
class User {
var address: Address = Address()
func save() {}
}
class Service {
func getUser() -> User { return User() }
func getUserAsync() async -> User { return User() }
}
`,
},
header: '',
wrap: (entry, body) => `func ${entry}(svc: Service, repos: [User]) async {\n ${body}\n}\n`,
shapes: [
{ id: 'plainChain', member: 'save', body: 'svc.getUser().save()' },
{ id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save()' },
{ id: 'optionalChain', member: 'save', body: 'svc.getUser().address?.save()' },
{ id: 'nonNullAssert', member: 'save', body: 'svc.getUser().address!.save()' },
{ id: 'awaitParen', member: 'save', body: '(await svc.getUserAsync()).save()' },
{
id: 'explicitTypeArgs',
na: 'Swift infers generic parameters; there is no call-site type-argument spelling on a method.',
},
{ id: 'indexElement', member: 'save', body: 'repos[0].save()' },
{
id: 'fieldReceiverCall',
member: 'save',
body: 'self.repo.save()',
raw: `class SwHolder {
var repo: User = User()
func fieldReceiverCall() {
self.repo.save()
}
}
`,
},
{ id: 'decoratedReceiverBase', na: 'Swift `self` carries no type decoration.' },
{
id: 'decoratedFieldType',
member: 'save',
body: 'self.repo?.save()',
raw: `class SwOptionalHolder {
var repo: User? = nil
func decoratedFieldType() {
self.repo?.save()
}
}
`,
note: 'optional field type',
},
],
},
{
lang: 'dart',
ext: '.dart',
support: {
'models.dart': `class Address {
void save() {}
}
class User {
Address address = Address();
void save() {}
}
class Service {
User getUser() => User();
Future<User> getUserAsync() async => User();
}
`,
},
header: `import 'models.dart';\n`,
wrap: (entry, body) =>
`Future<void> ${entry}(Service svc, List<User> repos) async {\n ${body}\n}\n`,
shapes: [
{ id: 'plainChain', member: 'save', body: 'svc.getUser().save();' },
{ id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save();' },
{ id: 'optionalChain', member: 'save', body: 'svc.getUser().address?.save();' },
{ id: 'nonNullAssert', member: 'save', body: 'svc.getUser().address!.save();' },
{ id: 'awaitParen', member: 'save', body: '(await svc.getUserAsync()).save();' },
{ id: 'explicitTypeArgs', na: 'Dart infers generic parameters at a method call site.' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save();' },
{
id: 'fieldReceiverCall',
member: 'save',
body: 'this.repo.save();',
raw: `class DartHolder {
User repo = User();
void fieldReceiverCall() {
this.repo.save();
}
}
`,
},
{ id: 'decoratedReceiverBase', na: 'Dart `this` carries no type decoration.' },
{
id: 'decoratedFieldType',
member: 'save',
body: 'this.repo?.save();',
raw: `class DartNullableHolder {
User? repo;
void decoratedFieldType() {
this.repo?.save();
}
}
`,
note: 'nullable field type',
},
],
},
];
/**
* Every corpus declares a cell for every SHAPE_ID, every `na` carries a reason,
* and no id is declared twice or unknown. Throws rather than warning: a matrix
* with a hole reports as if the hole were measured, which is the exact failure
* the N/A-with-reason rule exists to prevent.
*/
function assertMatrixComplete(corpora) {
const problems = [];
for (const corpus of corpora) {
// A language may carry EXTRA cells beyond the canonical axis when they
// discriminate something the axis cannot — PHP's annotated/unannotated
// return-type pair, C++'s pointer/value base pair. They must be declared, so
// an extra stays a deliberate diagnostic rather than a typo'd canonical id.
const known = new Set([...SHAPE_IDS, ...(corpus.extraShapeIds ?? [])]);
const seen = new Set();
for (const shape of corpus.shapes) {
if (!known.has(shape.id)) {
problems.push(
`${corpus.lang}: shape id "${shape.id}" is neither canonical nor in extraShapeIds`,
);
}
if (seen.has(shape.id)) problems.push(`${corpus.lang}: duplicate shape id "${shape.id}"`);
seen.add(shape.id);
if (shape.na !== undefined && String(shape.na).trim() === '') {
problems.push(`${corpus.lang}.${shape.id}: N/A with no reason`);
}
if (shape.na === undefined && shape.body === undefined) {
problems.push(`${corpus.lang}.${shape.id}: neither a body nor an N/A reason`);
}
}
for (const id of SHAPE_IDS) {
if (!seen.has(id)) problems.push(`${corpus.lang}: missing cell for "${id}"`);
}
}
if (problems.length > 0) {
throw new Error(
`[receiver-resolution] shape matrix is incomplete:\n ${problems.join('\n ')}`,
);
}
}
function classify(corpus, result) {
const calls = [];
for (const rel of result.graph.iterRelationships()) {
if (rel.type !== 'CALLS') continue;
calls.push({
from: result.graph.getNode(rel.sourceId)?.properties.name ?? '',
to: result.graph.getNode(rel.targetId)?.properties.name ?? '',
});
}
const drops = (result.resolutionOutcomes ?? []).filter(
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
);
return corpus.shapes.map((shape) => {
if (shape.na !== undefined) {
return { shape: null, id: shape.id, note: shape.na, state: 'N/A', siteKind: null };
}
const hasEdge = calls.some((call) => call.from === shape.id && call.to === shape.member);
// A drop belongs to this shape when it names the shape's member and sits on
// the shape's own line — matched on the generated source, not on an id.
const drop = drops.find(
(candidate) => candidate.name === shape.member && candidate.shapeId === shape.id,
);
return {
shape: shape.body,
id: shape.id,
note: shape.note,
state: hasEdge ? 'RESOLVES' : drop ? 'VISIBLE-GAP' : 'INVISIBLE-GAP',
siteKind: drop?.siteKind ?? null,
};
});
}
/** Every cell for a language whose parser could not be loaded. Neither passes
* nor fails the gate — an unbuilt optional grammar is a fact about the host,
* not a resolution result. */
function grammarUnavailableRow(corpus, reason) {
return corpus.shapes.map((shape) => ({
shape: shape.body ?? null,
id: shape.id,
note: reason,
state: 'GRAMMAR-UNAVAILABLE',
siteKind: null,
}));
}
/** A parse failure caused by a missing vendored grammar, as opposed to a real
* pipeline error. The vendored loader throws with the grammar package name, and
* a language whose grammar never loaded produces a graph with no nodes at all. */
function isGrammarLoadFailure(error) {
const text = `${error?.message ?? ''}`;
return (
text.includes('tree-sitter-') &&
(text.includes('Cannot find module') ||
text.includes('No native build') ||
text.includes('not supported on this platform') ||
text.includes('prebuild'))
);
}
async function runShapeArm() {
assertMatrixComplete(CORPORA);
const results = [];
for (const [lang, reason] of Object.entries(NA_LANGUAGES)) {
results.push({
language: lang,
shapes: SHAPE_IDS.map((id) => ({
shape: null,
id,
note: reason,
state: 'N/A',
siteKind: null,
})),
});
}
for (const corpus of CORPORA) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gn-recv-${corpus.lang}-`));
try {
for (const [rel, content] of Object.entries(corpus.support)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content, 'utf8');
}
// Each shape's statement gets its own line, so a recorded drop's line
// identifies which shape produced it without matching on an id.
const lineOfShape = new Map();
let text = corpus.header;
for (const shape of corpus.shapes) {
// N/A cells are declarations about the language, not code to measure.
if (shape.na !== undefined) continue;
// A shape whose receiver needs surrounding structure (a class with a
// property, say) supplies `raw`; everything else is wrapped in a plain
// function. Either way the statement itself is `body`, and its offset
// is found by locating it in the generated block.
const block = shape.raw ?? corpus.wrap(shape.id, shape.body);
const blockStartLine = text.split('\n').length;
const offset = block.split('\n').findIndex((line) => line.includes(shape.body));
lineOfShape.set(shape.id, blockStartLine + offset);
text += block;
}
fs.writeFileSync(path.join(root, `main${corpus.ext}`), text, 'utf8');
let result;
try {
result = await runPipelineFromRepo(root, () => {});
} catch (error) {
if (!isGrammarLoadFailure(error)) throw error;
results.push({
language: corpus.lang,
shapes: grammarUnavailableRow(corpus, `parser failed to load: ${error.message}`),
});
continue;
}
// A grammar that soft-fails produces no parse and therefore no nodes.
// Reporting that as ten resolution gaps would be indistinguishable from a
// real regression, so it gets its own state.
if ([...result.graph.iterNodes()].length === 0) {
results.push({
language: corpus.lang,
shapes: grammarUnavailableRow(corpus, 'parser produced no nodes — grammar unavailable'),
});
continue;
}
// Attach the owning shape to each drop by line before classifying.
for (const outcome of result.resolutionOutcomes ?? []) {
for (const [id, line] of lineOfShape) {
if (outcome.range?.startLine === line) outcome.shapeId = id;
}
}
results.push({ language: corpus.lang, shapes: classify(corpus, result) });
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
return results;
}
// ---------------------------------------------------------------------------
// Count arm
// ---------------------------------------------------------------------------
async function runCountArm(repoPath) {
const result = await runPipelineFromRepo(repoPath, () => {});
const drops = (result.resolutionOutcomes ?? []).filter(
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
);
const byKind = new Map();
const byExtension = new Map();
const callDropsByExtension = new Map();
// Shape census over the CALL drops only, so the answer to "which kind of
// receiver are we losing?" is not diluted by property reads and writes.
const callDropsByShape = new Map();
// THE number that matters now. A drop whose receiver is rooted outside the
// indexed program (`System.out.println`, `fetch(...)`) reaches code with no
// node to point an edge at — nothing was lost. Only `in-program` and
// `unknown` drops represent a real resolver gap, and only those make an
// `impact` count a lower bound. Measured on this corpus: 76 external, 20
// in-program, 6 unknown — i.e. three quarters of the raw count was the
// program boundary rather than uncertainty about the program.
const callDropsByOrigin = new Map();
const callDropsByShapeAndExt = new Map();
for (const drop of drops) {
const kind = drop.siteKind ?? '<<unset>>';
byKind.set(kind, (byKind.get(kind) ?? 0) + 1);
const ext = path.extname(drop.filePath);
byExtension.set(ext, (byExtension.get(ext) ?? 0) + 1);
if (kind === 'call') {
callDropsByExtension.set(ext, (callDropsByExtension.get(ext) ?? 0) + 1);
const shape = drop.receiverShape ?? '<<unclassified>>';
callDropsByShape.set(shape, (callDropsByShape.get(shape) ?? 0) + 1);
const origin = drop.receiverOrigin ?? '<<unclassified>>';
callDropsByOrigin.set(origin, (callDropsByOrigin.get(origin) ?? 0) + 1);
const pair = `${ext} ${shape}`;
callDropsByShapeAndExt.set(pair, (callDropsByShapeAndExt.get(pair) ?? 0) + 1);
}
}
const sortDesc = (map) => Object.fromEntries([...map.entries()].sort((a, b) => b[1] - a[1]));
return {
repo: repoPath,
// THE gate number. Property reads and writes are excluded deliberately.
callDrops: byKind.get('call') ?? 0,
totalDropsAllKinds: drops.length,
bySiteKind: sortDesc(byKind),
callDropsByExtension: sortDesc(callDropsByExtension),
callDropsByShape: sortDesc(callDropsByShape),
callDropsByOrigin: sortDesc(callDropsByOrigin),
callDropsByShapeAndExtension: sortDesc(callDropsByShapeAndExt),
allDropsByExtension: sortDesc(byExtension),
};
}
// ---------------------------------------------------------------------------
// Perf arm — the U7 thresholds
// ---------------------------------------------------------------------------
/**
* Wall-clock, peak RSS, persisted chain bytes and cache-dir growth for one
* pipeline run over a corpus.
*
* The A/B control is produced by reverting ONLY the fold wiring
* (`compound-receiver.ts` + `receiver-bound-calls.ts`) to the pre-U10 commit and
* rebuilding, so the capture emission — and therefore the persisted bytes — is
* identical in both arms and the delta isolates the fold itself.
*/
/** Every file under `root` with one of `exts`. */
function walkFiles(root, exts) {
const out = [];
const stack = [root];
while (stack.length > 0) {
const dir = stack.pop();
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) stack.push(full);
else if (exts.some((e) => entry.name.endsWith(e))) out.push(full);
}
}
return out;
}
async function runPerfArm(repoPath, reps) {
const timings = [];
let peakRss = 0;
let chainSites = 0;
let chainBytes = 0;
let referenceSites = 0;
for (let i = 0; i < reps; i++) {
const started = process.hrtime.bigint();
const result = await runPipelineFromRepo(repoPath, () => {});
timings.push(Number(process.hrtime.bigint() - started) / 1e6);
peakRss = Math.max(peakRss, process.memoryUsage().rss);
void result;
}
// Persisted chain payload, counted from the emitter rather than from the
// pipeline result: `PipelineResult` exposes no ParsedFiles, and the emitter is
// the side that decides what gets written, so this is the authoritative count.
for (const file of walkFiles(repoPath, ['.ts', '.tsx'])) {
const src = fs.readFileSync(file, 'utf8');
for (const match of emitTsScopeCaptures(src, path.relative(repoPath, file))) {
if (match['@reference.name'] === undefined) continue;
referenceSites++;
const chain = match['@reference.receiver-chain'];
if (chain === undefined) continue;
chainSites++;
chainBytes += Buffer.byteLength(chain.text, 'utf8');
}
}
timings.sort((a, b) => a - b);
const median = timings[Math.floor(timings.length / 2)];
return {
reps,
wallClockMsMedian: +median.toFixed(1),
wallClockMsAll: timings.map((t) => +t.toFixed(1)),
peakRssBytes: peakRss,
referenceSites,
chainSites,
chainBytes,
bytesPerChainSite: chainSites === 0 ? 0 : +(chainBytes / chainSites).toFixed(1),
};
}
// ---------------------------------------------------------------------------
const KNOWN_BLIND = [
'Case 0 is reached by a receiver-TEXT punctuation test (`.` or `(`) OR by a minted receiver chain. A receiver spelled without that punctuation — a subscript, PHP `->`/`::` — therefore records a drop only where its emitter mints a chain; where none is minted the call still vanishes with the recorder blind to it.',
'A drop is recorded only while `compoundReceiverUnresolved` stays true. When the cascade TYPES the receiver but then finds no member on it, the flag is false and no drop is recorded even though no edge was emitted — so an absent drop is not evidence that a site resolved.',
'Every count is therefore a LOWER BOUND on a known-biased population. A later delta must be read against the same bias.',
];
/**
* The gated projection: shape states per language, plus the call-drop counts.
* Deliberately EXACT rather than budgeted — two consecutive runs are
* byte-identical, so a range would only hide real movement. Adding fixtures
* moves these numbers and requires a rebaseline; that treadmill is the accepted
* cost of the guard, the same trade the scope-capture bench already makes.
*/
/**
* NOTE ON SCOPE: this is the GATED projection — shape states plus call-drop
* counts. The perf arm (`--perf N`: wall-clock, RSS, bytes/site) is deliberately
* NOT part of it: those measurements need an A/B against a control build, which
* `--check` has no way to construct, so asserting them here would compare against
* numbers from a different machine and fail on noise. The perf figures recorded in
* BASELINE.md are therefore a POINT-IN-TIME MEASUREMENT, not a CI guard — do not
* read a green `--check` as evidence that performance has not regressed.
*/
function projection(output) {
return {
shapeArm: Object.fromEntries(
output.shapeArm.map((corpus) => [
corpus.language,
Object.fromEntries(corpus.shapes.map((shape) => [shape.id, shape.state])),
]),
),
countArm: {
callDrops: output.countArm.callDrops,
totalDropsAllKinds: output.countArm.totalDropsAllKinds,
bySiteKind: output.countArm.bySiteKind,
callDropsByExtension: output.countArm.callDropsByExtension,
callDropsByShape: output.countArm.callDropsByShape,
callDropsByOrigin: output.countArm.callDropsByOrigin,
},
};
}
/** Every leaf whose value differs, as `dotted.path: expected -> actual`.
*
* `GRAMMAR-UNAVAILABLE` is skipped on EITHER side. That state is a fact about
* the host, not about the code: the optional grammars are absent when a run
* sets `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or when no vendored prebuild matches
* the platform, so the same commit legitimately measures those cells on one
* runner and cannot on another. Gating on them would fail CI for the
* environment it ran in rather than for a regression — the opposite of what the
* state was added to prevent. Skipping both directions is what makes "neither
* passes nor fails the gate" true. */
function drift(expected, actual, prefix = '') {
const out = [];
const keys = new Set([...Object.keys(expected ?? {}), ...Object.keys(actual ?? {})]);
for (const key of keys) {
const want = expected?.[key];
const got = actual?.[key];
const at = prefix === '' ? key : `${prefix}.${key}`;
if (want === 'GRAMMAR-UNAVAILABLE' || got === 'GRAMMAR-UNAVAILABLE') continue;
if (want !== null && typeof want === 'object') out.push(...drift(want, got ?? {}, at));
else if (want !== got) out.push(`${at}: ${JSON.stringify(want)} -> ${JSON.stringify(got)}`);
}
return out;
}
const args = process.argv.slice(2);
const corpusIndex = args.indexOf('--corpus');
const check = args.includes('--check');
const updateBaseline = args.includes('--update-baseline');
// `--update-baseline` defaults the corpus for the same reason `--check` does:
// the two must measure the SAME thing or the gate can never be satisfied. It
// previously did not, so a bare `--update-baseline` skipped the count arm and
// then crashed dereferencing `output.countArm` in `projection` — which is the
// command the `--check` failure message tells you to run. Writing a baseline
// with no count arm would have been worse: the gate would go green while
// silently measuring nothing.
const corpusPath =
corpusIndex === -1
? check || updateBaseline
? DEFAULT_CORPUS
: undefined
: path.resolve(args[corpusIndex + 1]);
const output = { knownBlind: KNOWN_BLIND };
// `--update-baseline` needs BOTH arms, because `projection` writes both and
// `--check` compares both. Running one arm would write a baseline missing the
// other, and the next `--check` would then crash rather than fail a comparison.
if (corpusPath === undefined || check || updateBaseline || args.includes('--shapes')) {
output.shapeArm = await runShapeArm();
}
if (corpusPath !== undefined) {
output.countArm = await runCountArm(corpusPath);
}
const perfIndex = args.indexOf('--perf');
if (perfIndex !== -1) {
const reps = Number(args[perfIndex + 1] ?? '3');
output.perfArm = await runPerfArm(corpusPath ?? DEFAULT_CORPUS, Number.isFinite(reps) ? reps : 3);
}
if (updateBaseline) {
fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(projection(output), null, 2)}\n`, 'utf8');
console.error(`[receiver-resolution] wrote ${BASELINE_PATH}`);
} else if (check) {
const expected = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
const diffs = drift(expected, projection(output));
if (diffs.length > 0) {
console.error('[receiver-resolution] FAIL — drift against the committed baseline:');
for (const line of diffs) console.error(` ${line}`);
console.error(
'\nIf this is intended (a fixture was added, or a shape genuinely changed state),' +
'\nre-run with --update-baseline and explain the movement in the commit message.',
);
process.exit(1);
}
console.error('[receiver-resolution] OK — shape states and call-drop counts match baseline.');
} else {
console.log(JSON.stringify(output, null, 2));
}