gate: full-language migration proof — migrate the Ruby plugin to a package (#80 step 13) #84
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
h-dv/code-index#84
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Split out of #80's "Full-language migration proof" / release-gate step 13, by an explicit deferral. Full record with the measurements:
_prdoc/records/80-S43-migration-candidate.mdonepic-75-runtime-plugins. Ledger row E9.#80 requires, as the anti-vacuity gate for the whole epic:
Why this is its own issue and not a step on the epic branch
Not because the ABI is inexpressive. The measurement came out the other way: for Ruby, today's fact ABI can express every fact the builtin plugin produces — zero registry additions, zero ABI record changes, zero schema migration. Ruby's 7 symbol kinds are all in
SYMBOL_KINDS, its 6 ref kinds are all inREF_KINDS, its role bits are insideGRANTABLE_ROLES, and itsbinding-ref qualifier is a contiguous verbatim span thatreceiver_spancan carry.What blocks it is tooling plus one product decision:
_prdoc/guides/80-extractor-abi.md§5 says so itself: nocode-index-sdkcrate, no guest-side helper library, no published example package. The only guests anywhere in the repo are hand-written.watfixtures.u16kind and field ids, and the guest has zero imports — nots_language_symbol_for_name, no string table. The XAML extractor sidesteps this by keying on source text, which works for markup and cannot work for a language: nothing in afunction_item's bytes distinguishes it from acall_expression's.crates/plugin-host/src/tree.rs.wat.crates/plugin-host/tests/extractor_engine.rsstatic BUILTIN_CLAIMS;languages.enabledis, inactivation.rs's own words, "read by NOTHING in this workspace outside its own tests".crates/package/src/builtin.rs,crates/indexer/src/packages.rsSqueezed into one branch step this produces either a
.wattoy — the exact vacuity #80 names — or a harness whose two legs collapse into one.The candidate: Ruby (measured)
attr_start_line?is_extension?files.langidsRuby is the smallest plugin, has the smallest node-kind vocabulary (~32 kinds vs ~98–103 for C#/TS), one grammar, one language id — and is one of only two languages emitting neither field the fact ABI cannot carry. A migration whose first assertion must be "these two columns are pinned deltas" is a weaker gate than one where they are identical by construction.
Also:
tree-sitter-ruby's scanner includes only<string.h>and<wctype.h>, both already intests/grammars/shim/, so the existing XAML grammar recipe should apply with no new shim work.Runner-up PHP matches on those two holes but carries ~75% more node kinds, and both
refactor.rsandqualified_name_separatorare keyed onlang == "php". TypeScript is disqualified structurally:Manifest.grammaris a single non-Optionfield, so one package cannot ship the three grammars its plugin selects between.Plan
Phase 1 — feasibility, allowed to fail
tree-sitter-ruby.wasmvia the existing reproducible recipe. Two independent build dirs must produce byte-identical output; the module must parse a fixture to a node count matching the native grammar.(kind name, named) → idand field id — not just the ~43 Ruby uses — with a different grammar version as a negative control. If these disagree, every emitted fact is silently wrong, so this is the cheap early kill.wasm32-unknown-unknowncdylib satisfy zero-imports, export the required symbols, precompile, and land underMAX_EXTRACTOR_BYTES(8 MiB) as a.cwasm? Do this before anything in Phase 2.Phase 2 — the SDK (minimum a Ruby guest needs)
4. A guest cursor over the 16-byte pre-order stream. Non-vacuity: for every fixture, the cursor's traversal must produce an identical
(kind_id, field_id, start, end)sequence totree_sitter::TreeCursor— every node, not a spot check.5. A guest-side fact encoder, round-tripped against
code_index_abi::validatefor every legal record shape.Phase 3 — the package
6. Port the extraction logic, with the host-side bounds (
MAX_TRANSPARENT_STEPS,MAX_PARSE_DEPTH— there are two shipped quadratics behind those). This step does not get to declare itself done; its gate is axis B below, against expectations it may not edit.7. Generate the expectations from the builtin plugin, written by a different agent than step 6. Every fact table present (absent = ungraded), at least one
[[absent]]decoy per fixture.8. Pack, approve and activate through the shipped binaries,
xaml_package_e2e.rsas template.Phase 4 — parity, on three axes
ClaimTables directly, no indexing.crates/package/src/expect.rs, which already grades a present table exhaustively and already supports[[absent]]decoys. Three-way: builtin-live == checked-in expectation == package worker output, so the checked-in file is a builtin-drift tripwire and not a bless-to-green baseline.crates/indexer/tests/corpus/mod.rs'sproject(). ItsREF_SQLalready renders targets aspath#name@linerather than rowids, and its own doc already says comparing counts "hides real ones" — which is #80's "aggregate-count similarity is not equivalence", already implemented. Do not write a second comparator.Exactly two normalisations are permitted (shadow-path suffix;
files.lang), each asserted applied to every row. Anything else is a delta with a named mechanism in a record file, or the gate is red.Non-vacuity the harness must carry:
extraction_componentswith the package key, builtin-leg rows withbuiltin. This is the direct answer to a parity test whose two legs collapsed under an env knob — the legs are distinguished by a row written by the thing under test.name_spanby one byte; swap two entries in the generated id table; remove the package from the approval record (must fail on identity, not by comparing empty); delete the shadow-suffix normalisation.Cost: the corpus leg is corpus-gated/nightly with its own baseline file — it must not write
tests/corpus/baseline.jsonor the cost baseline, so a package-leg movement cannot launder a builtin one. Two ceilings, because SQLite work counters cannot see a slow wasm guest: the usual counters plus extraction wall clock expressed as a ratio to the builtin leg measured on the same box, never an absolute.Schema: no migration. Every column axis C reads already exists.
Out of scope (own issues)
.rboutright — a product feature; shadow extensions get this gate green without it.attr_start_linewire gap (blocks rust/python/typescript/csharp; an optional-tag addition, no migration) — scope it with whichever language migrates second.is_extension(blocks C#; a resolver change, not an ABI change).80-abi-support-policy.mdalready names it as an unmet condition of a stable ABI.Related
Blocks #80 / #75. Adjacent deferral: release-gate step 12 (cross-platform) — ledger E10, needs runners this project does not have.
Correction, same day: Ruby is still the right candidate, but it is not free
The claim in the issue body — "for Ruby, today's fact ABI can express every fact the builtin plugin produces" — was measured on the wrong axis and is false as written. Correcting it here rather than editing it away, because the way it was wrong is itself useful to whoever picks this up.
The gap analysis checked that Ruby's symbol kinds, ref kinds and role bits are all present in the ABI registries. They are. It did not check the validator's strongest rule,
name == src[name_span].What breaks
crates/plugins/src/ruby.rs's Rails association DSL.has_many :postsemits atyperef namedPost— singularised and camelised — at the span of the literal:posts. Soname != src[name_span], andvalidaterefuses it withfact.span_name_mismatch.Because validation is all-or-nothing per file, the cost is 11 refs across two whole files (
app/models/user.rb,app/models/post.rb), not the 3 offending rows.Structurally this is a derived fact — the same shape as a
bindingrow — and the ABI carves out exactly one derived kind, by name.Consequence for this issue
Phase 3 (port the extraction logic) must additionally choose one of:
binding— the preferred option, since it is one mechanism rather than a per-language exception, and it closes the class rather than this instance; orEither way it is now a known, bounded, reviewable delta rather than a surprise discovered mid-port — which is what the gap analysis was for. But note how it was actually found: by a test, not by the analysis, and the analysis had already been filed as this issue when the test found it.
Related finding, same measurement
RawRef.qualifier's own doc says the qualifier is "exactly as written in source". Measured across all seven languages, that is false in three, by three independent mechanisms:emit_implicit_member_accessattachesqualifier = "this"to a bare member name;csharp/Sample.cscontains the stringthiszero times.@ivararm attachesqualifier = "self";ruby/sample.rbcontainsselfzero times.join_useassembles nested brace-import prefixes:use std::sync::{atomic::{AtomicBool, Ordering}, Arc};yieldsstd::sync::atomic, which appears nowhere contiguously in the file.qualifier_span/receiver_spancannot represent any of these. The ABI needs a text field, or those three rules need real source positions. This is a prerequisite for migrating C# or Rust, and a smaller one for Ruby.Why this was invisible
crates/plugins/tests/abi_projection.rs's seven-language test never projected refs at all — ref coverage was Rust-only, and Rust has no derived-name ref. The qualifier test likewise walkedtests/fixtures/rust/project/srcalone, two directories away fromrust/completeness/, so it reported zero for four releases.Both are now measured per language and pinned as sets. Ledger rows D28g, D29g, D30g; record
_prdoc/records/80-S43-migration-candidate.mdcorrected in place.Phase 1 step 3 — the Rust guest spike, RUN. It does not end the issue.
This was the step listed as "the step that can end the issue", to be done before anything in Phase 2. It has been executed against the real host contract. The approach survives, and one of this issue's four blockers is already gone.
Blocker status, re-measured
crates/plugin-host/src/kinds.rs,GUEST_KIND_TABLE_DIGEST(#87 part 2)What was built and measured
A
wasm32-unknown-unknowncdylib on stable 1.98 (the project's MSRV),no_std,panic=abort,opt-level="z", LTO, stripped — implementing the contract inengine.rs:extract(i32,i32,i32,i32) -> i64,memory,src_offset,kind_table_digest.no_stdgymnastics beyond a panic handlermemoryexportedextractwith the four-i32/i64 signatureMAX_EXTRACTOR_BYTES(8 MiB).cwasmvia--precompileThe zero-import result is the one that mattered most: it is the property that makes
fork,openandconnectinexpressible, and it holds for an ordinary Rust cdylib without any special handling.The real obstacle, measured rather than inferred
Stable Rust cannot emit a wasm GLOBAL.
#[no_mangle] pub staticdoes not produce a global holding a value — wasm-ld exports the static's address:Both are addresses, and the digest comes out as i32 where the ABI says i64. That is exactly the state
guest_claiming_kind_table_as_i32exists to pin, so the host refuses a naive Rust guest withReason::GrammarKindTableMismatch. It fails closed, which is the right direction — but it fails.The obvious escape is blocked too:
Nightly would unblock it, but this project pins stable 1.98 and gates on
cargo check (MSRV 1.98), so requiring nightly of package authors is a real cost, not a detail.The way through, demonstrated rather than proposed
A post-link step that appends the two globals and repoints the exports. Written and run against the actual rustc output:
171 → 190 bytes, zero imports preserved, and the patched module still precompiles (17 784-byte
.cwasm). Nineteen bytes of section rewriting.This is routine for wasm toolchains —
wasm-bindgenandwasm-optboth post-process — and it belongs in the SDK's build step rather than in each package author's hands.What this means for the plan
Phase 1 step 3 passes. Nothing here invalidates Phases 2–4, and the size headroom means a real Ruby guest has room to be far larger than a spike.
Phase 2 (the SDK) gains one requirement not previously written down: the SDK must own a post-link step that injects
src_offsetandkind_table_digestas correctly-typed wasm globals, because the guest cannot declare them itself on stable Rust. It should also assert the result — a guest that ships an i32 digest is refused by the host, so the SDK catching it at build time is the difference between a build error and a runtime refusal.Phase 1 steps 1 and 2 (reproducible
tree-sitter-ruby.wasm, and full(kind name, named) → idagreement between the wasm grammar and the native crate) are still unrun and remain the cheap early kill — "if these disagree, every emitted fact is silently wrong".Artefacts are scratch and not committed; the spike is reproducible from this comment.
private_class_method :namemarks the INSTANCE method private, not the class method #102derived_names, so a package using #86 gap 1 can never be enabled #103derived_nameson an already-enabled package does not reindex, so the files stay at 0 symbols #104Phases 1, 2 and 3 are DONE. Phase 4 is running. One issue-body claim was wrong.
Correction: "no new shim work" was false
The body says "
tree-sitter-ruby's scanner includes only<string.h>and<wctype.h>, both already intests/grammars/shim/, so the existing XAML grammar recipe should apply with no new shim work."The headers are present; the symbols are not. The scanner calls
strchr, whichtests/grammars/shim/string.hdid not declare, and the build fails outright. One added line. Recording it because this is the second time an issue-body claim on this ticket was measured on the wrong axis — the first was the ABI-registry check that missedname == src[name_span].Phase 1 — steps 1 and 2, RUN
tests/grammars/tree-sitter-ruby.wasm, 2 125 126 bytes, sha256c54cc209e4284bef45cf7ccbd3c0e6c38a900c2ae8a977bf63362a91d0c4e3d8, built bytests/grammars/build-tree-sitter-ruby.sh. Two independent build dirs, byte-identical.crates/plugin-host/tests/ruby_kind_table.rsproves the wasm grammar and the native crate agree on every(kind name, named) → idand every field id — not the ~43 Ruby uses — with a different grammar version as the negative control.A wasm-vs-native cost A/B also landed (
grammar_ab.rs): ~1.5×, flat across three decades of file size. That is the number the whole "every language ships as a plugin" direction rests on, and it had never been measured. It ships a floor as well as a ceiling — every way of getting an A/B harness wrong makes the ratio smaller, so a ceiling alone could never catch the mistakes that file is prone to. The floor mutation goes red at 1.00×; the ceiling mutation did not.Phase 2 — the SDK
crates/guest/:no_std,forbid(unsafe_code), zero dependencies. Cursor over the 16-byte pre-order stream, fact encoder, frame codec. Non-vacuity as specified:guest_cursor_equivalence.rscompares the cursor's traversal againsttree_sitter::TreeCursorfor every node of every fixture, not a spot check.Plus the post-link global injector the spike comment said Phase 2 would need (
crates/plugin-host/src/globals.rs,--inject-globals), because stable Rust cannot emit a wasm global and the host correctly refuses an i32 digest.Phase 3 — the port
tests/packages/ruby/: 29 573-byte Rust guest extractor, the grammar, 5 fixtures. Step 7's expectations were generated from the builtin by a different agent than the port, with[[absent]]decoys, and step 6 could not edit them.Axis B is green. Three-way: builtin-live == checked-in expectation == package worker output. All five expectations grade zero mismatches.
Step 8 (pack/approve/activate through the shipped binaries) is
crates/daemon/tests/ruby_package_e2e.rs, and it was blocked by #103 — now fixed and closed: an operator can grantderived_names,plugin check --derived-namesrecords the answer on the verdict, and the Rails file indexes with the derivedtype Postref present.The measurement Phase 4 has to adjudicate
Across 147 ruby-sinatra corpus files: 1254 symbols / 17 889 refs / 220 imports identical on both legs. But 883 rows differ, by exactly two systematic mechanisms:
selfqualifiers on the package leg — the same mechanism the first correction comment already named as aRawRef.qualifierhonesty problem ("exactly as written in source" is false in three of seven languages;ruby/sample.rbcontainsselfzero times).name_spanprojection difference.This issue permits exactly two normalisations — shadow-path suffix and
files.lang— and says anything else is "a delta with a named mechanism in a record file, or the gate is red". So Phase 4's central job is to decide, per mechanism and against the actual differing rows: defect in the port, or genuine pinned delta? A blanket normalisation that swallows both is precisely the vacuity this gate exists to prevent, and the pin must stay narrow enough that the required mutations still go red.Phase 4 axes A (routing /
ClaimTable, no indexing) and C (resolved projection viacorpus/mod.rs'sproject()) are dispatched now, with the non-vacuity requirements from the body: producer identity read fromextraction_componentsrather than an env var, absolute floors from the builtin leg on both, all six pool capabilities asserted granted, and the five named mutations run.Follow-ups filed rather than scope-crept
#104 (re-grant does not reindex), #105 (aggregate cost of declining the grant), #106 (MCP
plugin_addcannot grant it), #102 (private_class_methodprivatises the instance method — a real builtin defect this port found).Phase 4 complete — all three axes. And the gate's own anti-vacuity clause caught a real defect on its first run.
The headline: the shipped package resolved zero
tests/packages/ruby0.1.0 declaredresolver = []. Every candidate relation joinstemp.pool_admit, andgrant_reservedhands all six capabilities to every reserved component — so a builtin never notices the gate exists. Measured through the shipped binaries over 156 files:resolver = []Symbols, refs and imports are identical in all three rows. A parity harness comparing extraction alone — which is what "compare canonical facts" would naturally mean — would have called this a perfect port. It took this issue's own clause, "all six pool capabilities asserted granted, so an inert package cannot compare 'no resolutions' against 'no resolutions'", to see it. That clause was written as a precaution against a hypothetical; it caught the actual shipped artifact.
Fixed in the lane:
plugin.toml→ 0.2.0 requesting the six, digest re-recorded,tests/packages/README.mdand80-package-authoring.mdupdated. Consequence worth noting:ruby_package_e2estep 2 had usedsame_file_candidateas its "wider than the request" probe — now legal — so it usesbridge_destination, with the reason written in.The three axes
crates/package/tests/ruby_claim_parity.rs, 5 tests. TwoClaimTables compared directly: no indexing, no database, no grammar. Proves the namespaces are structurally disjoint, that the package conflicts with no builtin (with anext:rbcontrol that must conflict), that every builtin-Ruby path has a package-claimed shadow, and pins the excess where the shadow map is not a bijection.crates/indexer/tests/ruby_package_parity.rsover all 156 ruby-domainruby-sinatrafiles, both legs throughindex_path_with_packages, compared withcorpus::project()— no second comparator, as required.tests/corpus/ruby-package-cost.json);tests/corpus/baseline.jsonandcost-baseline.jsonboth untouched. The SQLite trace machinery was extracted fromcorpus_cost.rsrather than copied, andcorpus_costrewired and re-verified green.The pinned-delta decision
After the two permitted normalisations: 1260/1260 symbols identical on 9 of 10 columns, 18450/18450 ref sites identical, 231/231 imports identical. Five populations differ, each pinned by a predicate on the row, never a column exclusion:
qualifier="self"unspellable on the wireNonetook the delta 876→0 and the resolution delta 99→57writer::qualified_name_separatorbuiltin-lang allowlistlang IN ('php','ruby','csharp')same-dir armresolved_by == 12, package unresolvedPOSITION_TYPE_KINDS_BY_LANGGemfile/*.gemspecSites 2–4 are one defect with three sites, and no package can close any of them: a manifest cannot declare a separator, "importlessness", or its position-type kinds. Filed as #112, with the note that
POSITION_TYPE_KINDS_BY_LANG's own doc argues the keying is correct — which is why it is a product decision, not a bug fix. Cost findings are #113.Also measured, and a trap worth recording: the shadow suffix must replace the extension, not append. Appending changes
file_stemand costs 122 resolutions viatemp.file_keys.Mutations — all six executed, output verbatim in the record
tag::MODULE)got 1122— recorded honestly: the floor fires before the comparisonname_span+1KIND_TAG[181]↔[184]got 18279file_contributionsjoin,left 0, right 156— before any projectionqualified: true→falseMutation 6 is the one that matters for this issue's central question. The blanket-normalisation failure mode is not a worry any more; it is a measured, reproducible fact about this harness.
Axis A ran 5 more. Two are findings about the lane's own tests, recorded rather than re-aimed:
["rbx"]→["rb"]first failed at a parseexpectbecausefrom_containervalidates, so the compared table now comes from unvalidated TOML; and droppingext("rake")leaves the shadow tests green because the corpus is self-derived — caught one test over, by the key-list pin.Two open items, both stated rather than buried
--test ruby_package_parity --test ruby_package_costin thecorpusjob'sCorpus suitesstep (~4s and ~78s). Routed to the lane that owns.forgejo/right now. This is the #108 defect in a second place — and worse: #108 is a floor of one where nine were needed; this grades zero and passes.Gates: fmt, clippy
-D warnings, rustdoc,corpus_ratchet(baseline md5 unmoved at534084b856c22566c48e386bc41ed67e),precision_gate7/7,ruby_package_parity,ruby_package_cost,ruby_package_e2e4/4 — all green. Four failures incrates/mcp-serverbelong to concurrent lanes and are theirs to resolve._prdoc/records/84-P4-parity-deltas.mdnow exists with the verbatim mutation output; it had been cited 10 times, including inside operator-facing failure text, while not existing at all.EMBEDDED_DISPATCH_SEMANTICS_VERSION = 0, so no file can carry two producers and #77's criterion 2 is unexercisable #119COSI_CORPUS_REQUIRE=1has a floor of one, not of nine — 8 of 9 repos can skip and the run passes green #108tests/packages/ruby/extractor.wasmis reproducible from exactly one directory on earth, and we publish it as reproducible #132EMBEDDED_DISPATCH_SEMANTICS_VERSION = 0, so no file can carry two producers and #77's criterion 2 is unexercisable #119private_class_method :namemarks the INSTANCE method private, not the class method #102ruby_package_parityis RED at integration HEAD: #134's tier-3 origin gate reads a manifest relation the packaged leg structurally cannot have #167ruby_package_parity: theSelfQualifierarm absorbs resolution differences, and closing #167's mechanism made 11 of them visible #170release_gate_e2e.rs:102-122still says musl is continue-on-error and that nothing runs the step-13 migration gate — both false since #84 #187disclosure_derivation_registry.rscalls #137 an open blind spot in its header while its own body says the gap is closed and inside the gate #186release_gate_e2e.rs:102-122still says musl is continue-on-error and that nothing runs the step-13 migration gate — both false since #84 #187Verdict, asked directly: YES — this closes. All three axes re-run in an independent lane at master
4f866e5, and BOTH open items from the phase-4 comment are closed in tree.#75 turns on this, so the question was taken as "can it close", not "does it look done". Everything below was executed here, on a clean detached worktree of
origin/master, not read off the previous comment.The three axes, re-run
crates/package/tests/ruby_claim_parity.rsClaimTables compared directly: no indexing, no database, no grammarcrates/daemon/tests/ruby_package_e2e.rs,COSI_E2E_LEG=daemonderived_namesgrant armscrates/indexer/tests/ruby_package_parity.rscorpus[ruby_package_parity]: executed=1 unavailable=0 not_applicable=0 controls=4 (require=true)executed=1, not a skip — run withCOSI_CORPUS_DIRandCOSI_CORPUS_REQUIRE=1and--nocapture, because without them the suite reportsexecuted=0and PASSES, which is its own trap.Axis C's pinned-delta line reads
SelfQualifier: 834, which reconciles with the recorded834 + 31 + 11 = 876(ruby_package_parity.rs:995). No drift since the split by consequence.Open item 1 — "Axis C is in no CI job, so it currently skips green" — CLOSED
.forgejo/workflows/ci.yml:935-936now names--test ruby_package_parity --test ruby_package_costin thecorpusjob'sCorpus suitesstep, withpackage_cost_attributionbeside them.And the fix went further than the request, which is why this is closed rather than patched: the list is no longer maintained by hand.
corpus_require_floor.rs::every_corpus_suite_runs_where_the_require_floor_appliesfails if a file usingcorpus::Coverageis named by no job that setsCOSI_CORPUS_REQUIRE=1. That is the generic form of the defect this issue's own comment named — "#108's defect at its limit: a floor of ZERO, passing green" — and the CI comment records that it caught a fifth suite (package_cost_attribution) within the hour of being written, before it shipped rather than after.Open item 2 — "the wall-clock band was blessed on a loaded box" — CLOSED
tests/corpus/ruby-package-cost.json's current_blessed.reasonrecords the re-measure and states the condition verbatim:The superseded reasons are kept rather than overwritten, so the two contended bands (load 22-35 and load 39, both of which said so out loud) are still readable beside it. That is the item this comment asked for — an isolated run compared against an isolated run.
The one thing that is red, and it is NOT this issue's
ruby_package_costis currently refused at master, by its own condition gate:_blessed.schemais 61 and the binary is 62. That is the gate working exactly as designed — "a record taken under a different schema or a different package version is refused withre-MEASURE, never re-blessed on top of a stale reading" — and it belongs to whoever bumped the schema, not to the migration proof. Deliberately not blessed here, and not re-run either: it is a wall-clock-sensitive suite and this box was running a workspace build, so a reading taken now would be worth nothing in either direction.Nothing about that red is a statement about whether the Ruby plugin migrated. Axis C — the axis that grades whether the two legs agree — is green.
What this issue set out to prove, and did
#80's anti-vacuity gate asked for "one complete existing compiled plugin" migrated into an external package path, with "aggregate-count similarity is not equivalence". What it got:
wasm32-unknown-unknownRust guest against a realtree-sitter-ruby.wasm, both reproducible, with the kind-table agreement proved on every(kind name, named) → idrather than the ~43 Ruby uses;_prdoc/records/84-P4-parity-deltas.md;resolver = [], resolving zero against the builtin's 2853, with symbols/refs/imports identical — a parity harness comparing extraction alone would have called it perfect).Three issue-body claims were measured wrong along the way and each was corrected in place rather than edited away: the ABI-registry check that missed
name == src[name_span], the "no new shim work" claim (strchrwas undeclared), and the assumption that a.watguest's zero-import property transferred. That record is worth more than a clean ticket.What is NOT closed by this, and now owns itself
Visibility::Unknown.None of those is "the Ruby plugin did not migrate". They are what the migration MEASURED, which is what the gate was for.
Closing. #80 step 13 is satisfied; #75's remaining structure is #80 and #86.
🤖 Packaged-language lane, 2026-09-06, master
4f866e5, worktree/tmp/cosi-lane-pkgGUEST_ABI_MAJORis bracketed by no manifest field, andplugin pack --check-reproducibledoes not exist #153Eligibility::from_builtinsdegradesauto-generated (plugin-excluded), which is package-independent by construction #219