From b442e04bf4c7be80d19569ce5224e8031aa75df4 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 31 Jul 2026 10:59:20 +0000 Subject: [PATCH] test(bench): canonical shape axis across all 14 languages (#2766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shape arm was three languages with an ad-hoc shape list each, so "covers all supported languages" was an assertion rather than a measurement. Replace it with a canonical 10-shape axis every language must answer for, and add two states so a hole stops masquerading as a result: - `N/A` — the grammar does not admit the spelling. A reason is REQUIRED; an omitted cell and an inapplicable one are otherwise identical in a diff, which is how coverage rots. - `GRAMMAR-UNAVAILABLE` — the parser could not load, so nothing was measured. `drift` skips it on BOTH sides, so the gate cannot fail for the environment it ran in. Dart, Kotlin and Swift are vendored OPTIONAL grammars, absent under GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 or when no vendored prebuild matches the host. All 14 load on glibc linux-x64, so this state has no producer in the committed baseline — it guards the skip-flag and unsupported-host cases. `assertMatrixComplete` throws on a missing cell, an unknown id, or a reasonless N/A. It caught four real ids on its first run; those became declared `extraShapeIds`, because PHP's annotated/unannotated pair and C++'s pointer/value pair discriminate things the axis cannot express. 164 cells: 42 RESOLVES, 22 VISIBLE-GAP, 31 INVISIBLE-GAP, 69 N/A. Count arm unmoved at 101 call drops — shape fixtures build in temp directories and never touch the committed corpus. The measurement contradicts what was assumed about the decoration bug: - Go isolates it to ONE cell. Varying receiver and field decoration independently: value/value RESOLVES, value/pointer RESOLVES, pointer/value is a VISIBLE-GAP. Only the pointer receiver fails; field bindings already go through normalizeGoTypeName, so the step lookup is sound and the defect is entirely the base. - Go is the ONLY language whose receiver decoration defeats the lookup. Rust `&mut self` resolves. - The field-type gap is real in rust (Box), typescript (User|null), csharp (User?), swift (User?) and cpp (User*) — but php, python, kotlin and dart already resolve theirs. - PHP's sigil hypothesis is dead: `$svc->getUser()->save()` (unannotated return) is an INVISIBLE-GAP while `$svc->getUserTyped()->save()` (annotated) RESOLVES. Same chain, same `->`, same base. Also surfaced, previously unknown: Swift resolves almost no chained receiver; Ruby's chains are VISIBLE-GAPs; C++ `this->` field receivers are INVISIBLE; `indexElement` is INVISIBLE-GAP in all 14; and Dart is the only language where `awaitParen` already resolves. Co-Authored-By: Claude Opus 5 (1M context) --- .../bench/receiver-resolution/BASELINE.md | 118 ++ .../bench/receiver-resolution/baseline.json | 185 ++- .../bench/receiver-resolution/measure.mjs | 1056 ++++++++++++++++- 3 files changed, 1348 insertions(+), 11 deletions(-) diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 49ac4979b..e33b844e5 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,123 @@ # Receiver-resolution baseline +## U2 — shape matrix expanded to a canonical axis + +The shape arm was three languages with an ad-hoc shape list each. It is now a +**canonical 10-shape axis** (`SHAPE_IDS`) that every language must answer for, +with two states added so a hole cannot masquerade as a measurement: + +- `N/A` — the grammar does not admit this spelling. **A reason is required.** An + omitted cell and a genuinely inapplicable cell look identical in a diff + otherwise, which is how coverage rots. +- `GRAMMAR-UNAVAILABLE` — the parser could not be loaded, so nothing was + measured. Neither passes nor fails the gate, and `drift` skips it on **both** + sides so the gate cannot fail for the environment it ran in. `tree-sitter-dart`, + `-kotlin` and `-swift` are vendored *optional* grammars: absent when a run sets + `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`, and soft-failing 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). **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 rather than a condition seen here. + +`assertMatrixComplete` throws when a language omits a cell, declares an unknown +id, or writes an `N/A` with no reason. Languages may declare `extraShapeIds` for +diagnostics the canonical axis cannot express (PHP's annotated/unannotated +return-type pair, C++'s pointer/value base pair) — an extra must be declared, so +it stays a deliberate diagnostic rather than a typo'd canonical id. + +**Vue and COBOL** are language-level `N/A` rows: their emitters never call +`synthesizeReceiverChainCapture`, so there is nothing to measure — but the +language axis now obeys the same no-omitted-cells rule as the shape axis. + +### What the first expanded run found + +Three results that redirected the plan they were built to serve: + +**Go — the root cause, isolated to one cell.** Three rows vary receiver +decoration and field decoration independently: + +| Cell | Receiver | Field | State | +|---|---|---|---| +| `fieldReceiverCall` | value | value | RESOLVES | +| `decoratedFieldType` | value | **pointer** | RESOLVES | +| `decoratedReceiverBase` | **pointer** | value | **VISIBLE-GAP** | + +Only the pointer *receiver* fails. Go already normalizes field type bindings +through `normalizeGoTypeName`, so the step lookup is sound and the defect is +entirely the base — `synthesizeGoReceiverBinding` stores `typeNode.text` raw, so +`func (h *Host)` binds `h` to the literal `*Host`, which +`findClassBindingInScope` cannot resolve. + +**PHP — the sigil hypothesis is dead.** The two rows differ only in whether the +called method declares a return type: + +| Cell | Return type | State | +|---|---|---| +| `arrowCallChain` — `$svc->getUser()->save()` | unannotated | INVISIBLE-GAP | +| `plainChain` — `$svc->getUserTyped()->save()` | **annotated** | **RESOLVES** | + +Same chain, same `->`, same base. PHP chains resolve when the return type is +declared; the `$` sigil is not involved. `decoratedFieldType` (`?User $repo`) +also resolves, so PHP nullable field types already work. + +**C++ — the base already resolves, but `this->` field receivers do not.** +`pointerArrowChain` and `valueDotChain` both RESOLVE, so a decorated C++ base is +not a gap. But `this->repo.save()` and `this->repo->save()` are both +INVISIBLE-GAP — a distinct defect, not a decoration one. + +**Rust — the decorated receiver is NOT a gap.** `&mut self` resolves, so Go is +the only language whose method receiver decoration defeats the lookup. Rust's +gap is the field: `Box` is INVISIBLE-GAP. + +### The decoration cells, across all 14 + +The rows U1 exists to fix. Everything else is a different defect. + +| Language | `decoratedReceiverBase` | `decoratedFieldType` | +|---|---|---| +| go | **VISIBLE-GAP** (`*Host`) | RESOLVES | +| rust | RESOLVES (`&mut self`) | **INVISIBLE-GAP** (`Box`) | +| typescript | N/A | **INVISIBLE-GAP** (`User \| null`) | +| csharp | N/A | **VISIBLE-GAP** (`User?`) | +| swift | N/A | **INVISIBLE-GAP** (`User?`) | +| cpp | N/A | **INVISIBLE-GAP** (`User*`) | +| python, php, kotlin, dart | N/A | RESOLVES | +| java, c, javascript, ruby | N/A | N/A | + +So U1's measured scope is **Go's receiver base**, plus the field-type gap in +**Rust, TypeScript, C#, Swift and C++** — and *not* PHP, Python, Kotlin, Dart or +Java, whose decoration handling already works or does not exist. Five of the +seven hooks the plan speculatively listed were aimed at languages that need +none; three languages that do need one were not on the list at all. + +### Other gaps this run surfaced, not in the plan + +- **Swift resolves almost nothing.** `plainChain`, `plainDeepChain`, + `optionalChain` and `nonNullAssert` are all INVISIBLE-GAP, while + `fieldReceiverCall` resolves. Chained receivers are essentially unsupported. +- **Ruby chains are VISIBLE-GAPs** (`plainChain`, `plainDeepChain`, + `optionalChain`) and `fieldReceiverCall` on `@repo` is INVISIBLE. +- **C++ `this->` field receivers** are INVISIBLE-GAP in both the value and + pointer form. +- **C# has four gaps** beyond the field one: `optionalChain`, `nonNullAssert`, + `awaitParen`, `explicitTypeArgs`. +- **Dart `await` already resolves** — the only language where `awaitParen` is + green, which makes it the reference for U4's unwrap direction. +- **`indexElement` is INVISIBLE-GAP in all 14** — uniform, and exactly what U5 + targets. + +### Coverage status + +All 14 languages measured, plus `vue` and `cobol` as language-level `N/A` rows. +164 cells: 42 RESOLVES, 22 VISIBLE-GAP, 31 INVISIBLE-GAP, 69 N/A, 0 +GRAMMAR-UNAVAILABLE. + +The **count arm is unchanged at 101 call drops** — shape fixtures are built in +temp directories and never touch the committed corpus, so expanding the shape +axis moves the shape arm only. + +--- + > **Updated after U10** (structural receiver typing wired into Case 0). Three > TypeScript shapes flipped to `RESOLVES` — `svc?.getUser().save()`, > `svc!.getUser().save()`, `svc.getTyped().save()` — and the call-drop diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 904bc658b..536b30beb 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -1,5 +1,29 @@ { "shapeArm": { + "vue": { + "plainChain": "N/A", + "plainDeepChain": "N/A", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "N/A", + "fieldReceiverCall": "N/A", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "cobol": { + "plainChain": "N/A", + "plainDeepChain": "N/A", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "N/A", + "fieldReceiverCall": "N/A", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, "typescript": { "plainChain": "RESOLVES", "plainDeepChain": "RESOLVES", @@ -7,15 +31,170 @@ "nonNullAssert": "RESOLVES", "awaitParen": "VISIBLE-GAP", "explicitTypeArgs": "RESOLVES", - "indexElement": "INVISIBLE-GAP" + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "INVISIBLE-GAP" }, "php": { "arrowCallChain": "INVISIBLE-GAP", - "arrowPropertyPath": "RESOLVES" + "arrowPropertyPath": "RESOLVES", + "plainChain": "RESOLVES", + "plainDeepChain": "INVISIBLE-GAP", + "optionalChain": "INVISIBLE-GAP", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "N/A", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "RESOLVES" }, "cpp": { "pointerArrowChain": "RESOLVES", - "valueDotChain": "RESOLVES" + "valueDotChain": "RESOLVES", + "plainChain": "N/A", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "VISIBLE-GAP", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "INVISIBLE-GAP", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "INVISIBLE-GAP" + }, + "go": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "VISIBLE-GAP", + "decoratedFieldType": "RESOLVES" + }, + "javascript": { + "plainChain": "VISIBLE-GAP", + "plainDeepChain": "VISIBLE-GAP", + "optionalChain": "VISIBLE-GAP", + "nonNullAssert": "N/A", + "awaitParen": "VISIBLE-GAP", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "python": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "VISIBLE-GAP", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "RESOLVES" + }, + "java": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "RESOLVES", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "csharp": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "INVISIBLE-GAP", + "nonNullAssert": "VISIBLE-GAP", + "awaitParen": "VISIBLE-GAP", + "explicitTypeArgs": "VISIBLE-GAP", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "VISIBLE-GAP" + }, + "ruby": { + "plainChain": "VISIBLE-GAP", + "plainDeepChain": "VISIBLE-GAP", + "optionalChain": "VISIBLE-GAP", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "INVISIBLE-GAP", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "rust": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "INVISIBLE-GAP", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "RESOLVES", + "decoratedFieldType": "INVISIBLE-GAP" + }, + "c": { + "plainChain": "N/A", + "plainDeepChain": "INVISIBLE-GAP", + "optionalChain": "N/A", + "nonNullAssert": "N/A", + "awaitParen": "N/A", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "INVISIBLE-GAP", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "N/A" + }, + "kotlin": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "RESOLVES", + "nonNullAssert": "VISIBLE-GAP", + "awaitParen": "VISIBLE-GAP", + "explicitTypeArgs": "RESOLVES", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "RESOLVES" + }, + "swift": { + "plainChain": "INVISIBLE-GAP", + "plainDeepChain": "INVISIBLE-GAP", + "optionalChain": "INVISIBLE-GAP", + "nonNullAssert": "INVISIBLE-GAP", + "awaitParen": "VISIBLE-GAP", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "INVISIBLE-GAP" + }, + "dart": { + "plainChain": "VISIBLE-GAP", + "plainDeepChain": "VISIBLE-GAP", + "optionalChain": "VISIBLE-GAP", + "nonNullAssert": "VISIBLE-GAP", + "awaitParen": "RESOLVES", + "explicitTypeArgs": "N/A", + "indexElement": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", + "decoratedReceiverBase": "N/A", + "decoratedFieldType": "RESOLVES" } }, "countArm": { diff --git a/gitnexus/bench/receiver-resolution/measure.mjs b/gitnexus/bench/receiver-resolution/measure.mjs index 7a279ea87..0bda7ad74 100644 --- a/gitnexus/bench/receiver-resolution/measure.mjs +++ b/gitnexus/bench/receiver-resolution/measure.mjs @@ -7,8 +7,9 @@ * * TWO ARMS, because neither one alone is trustworthy: * - * 1. SHAPE ARM (`--shapes`, default). A fixed corpus of receiver spellings, - * each classified by what the graph actually contains: + * 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 @@ -20,14 +21,36 @@ * 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. * - * The third state is why this arm exists. Case 0's recorder is reached only + * INVISIBLE-GAP is why this arm exists. Case 0's recorder is reached only * when the receiver text contains `.` or `(` AND the capture layer produced * a reference site at all. Measured on this corpus, `svc?.getUser().save()`, * `svc.getTyped().save()` and `repos[0].save()` are all INVISIBLE — * so fixing them moves the count arm by exactly zero. Gating solely on a * drop count would read a working fix as "no improvement". * + * 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 @@ -74,11 +97,44 @@ const DEFAULT_CORPUS = path.resolve(__dirname, '..', '..', 'test', 'fixtures', ' // 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 = [ { @@ -129,29 +185,79 @@ export class Service { }, { id: 'explicitTypeArgs', member: 'save', body: 'svc.getTyped().save();', note: 'PF4' }, { id: 'indexElement', member: 'save', body: 'repos[0].save();', note: 'PF5' }, + { + 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) {\n ${body}\n}\n`, + // 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', @@ -177,27 +283,84 @@ class Service { `, 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) {\n ${body}\n}\n`, + wrap: (entry, body) => + `void ${entry}(Service* svc, Service svc2, User* repos) {\n ${body}\n}\n`, shapes: [ { id: 'pointerArrowChain', @@ -215,10 +378,812 @@ public: 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()) { @@ -233,6 +1198,9 @@ function classify(corpus, result) { ); 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. @@ -249,8 +1217,48 @@ function classify(corpus, result) { }); } +/** 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 { @@ -263,6 +1271,8 @@ async function runShapeArm() { 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 @@ -275,7 +1285,27 @@ async function runShapeArm() { } fs.writeFileSync(path.join(root, `main${corpus.ext}`), text, 'utf8'); - const result = await runPipelineFromRepo(root, () => {}); + 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) { @@ -438,7 +1468,16 @@ function projection(output) { }; } -/** Every leaf whose value differs, as `dotted.path: expected -> actual`. */ +/** 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 ?? {})]); @@ -446,6 +1485,7 @@ function drift(expected, actual, prefix = '') { 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)}`); }