/** * 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().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 `). 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 * * `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 { return new User(); } getTyped(): User { return new User(); } } `, }, header: `import { Service, User } from './models';\n`, wrap: (entry, body) => `export async function ${entry}(svc: Service, repos: User[]): Promise {\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().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': ` `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 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()->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 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.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 GetUserAsync() { return new User(); } public User GetTyped() { 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().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(&self) -> User { User { address: Address } } } `, }, header: `mod models;\nuse models::{Address, Service, User};\n`, wrap: (entry, body) => `fn ${entry}(svc: &Service, repos: &Vec) {\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::().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, } impl RsBoxHolder { fn decoratedFieldType(&self) { self.repo.save(); } } `, note: 'Box — 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 getTyped(): User = User() } `, }, header: '', wrap: (entry, body) => `suspend fun ${entry}(svc: Service, repos: List) {\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().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 getUserAsync() async => User(); } `, }, header: `import 'models.dart';\n`, wrap: (entry, body) => `Future ${entry}(Service svc, List 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 ?? '<>'; 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 ?? '<>'; callDropsByShape.set(shape, (callDropsByShape.get(shape) ?? 0) + 1); const origin = drop.receiverOrigin ?? '<>'; 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)); }