ruby: private_class_method :name marks the INSTANCE method private, not the class method #102
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#102
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?
Found while writing exhaustive expectations against the builtin Ruby plugin for #84 Phase 3, and verified in the source. The expectations lane recorded it as an observation rather than "correcting" it, which is why it is a report and not a silent edit — an expectation encoding an opinion instead of the reference makes the parity gate grade the wrong thing.
The defect
crates/plugins/src/ruby.rs::visibility_markertakes aclass_method_form: booland has three arms. Two honour it and one does not.So for:
the search finds a symbol by
(parent, name, kind ∈ {METHOD, FUNCTION})and marks whichever it reaches.private_class_methodis supposed to privatiseself.thetaand leave the instance method alone. Both directions are wrong: the instance method is demoted tofilevisibility when it should stay exported, and the class method stays exported when it should be private..rev()means the LAST matching symbol wins, so which one is hit depends on declaration order rather than on the form the user wrote.Why it matters beyond a wrong field
Visibility is not cosmetic here — it gates candidate-pool membership. A method wrongly marked
fileis excluded from cross-file resolution, so this can silently cost real edges on any Ruby codebase using the targeted form. And a class method wrongly leftexportedis offered to pools it should not be in.Confirmed adjacent, and already documented as out of scope
visibility_marker's own doc says: "Not handled (v1):module_function,private_constant, visibility insideclass << selfbodies." That list is honest and this is not on it — the targetedprivate_class_methodarm looks handled and is not.Suggested fix
The search needs the class/instance distinction the other two arms already have. Whatever field distinguishes a class method from an instance method on
RawSymbolshould join thefindpredicate, and the case where no matching symbol exists should stay a no-op (the doc already says a name targeted before itsdefis skipped).Test shape
The fixture already exists:
tests/packages/ruby/fixtures/visibility.rb.rbxcovers section flips,public :x, inlineprivate def, theprivate def self.gotcha,private_class_methodin both forms, the targeted string form,protected, attr-in-private-section and nested-body reset — 21 symbols. It currently PINS the wrong behaviour as the reference, because that is what the builtin does.When this is fixed, that expectation must be regenerated and the change reviewed as an intentional delta —
COSI_BLESS_RUBY_EXPECT=1rewrites it, and the diff is the proof. A mutation restoring theclass_method_form-blind predicate must go red.Both directions need asserting: the class method becomes private AND the instance method of the same name stays exported. A fix that privatises both would pass a one-sided test.
Related
Found via #84 Phase 3. Not a blocker for the migration proof — the package must reproduce the builtin's behaviour whatever it is, so this affects both legs identically and the parity gate stays valid. Fixing it changes what BOTH must produce.
derived_names, so a package using #86 gap 1 can never be enabled #103The packaged Ruby half is NOT optional, and it is not silent either —
ruby_package_parityis the gate that catches itRecording the coupling, because the builtin fix is in flight and its packaged twin is not.
This issue already says the right thing and it is worth pulling out of the last paragraph:
crates/indexer/tests/ruby_package_parity.rscomparescrates/plugins/src/ruby.rsagainsttests/packages/rubyrow for row on every symbol column, and since #112 closed thequalified_namedelta there is no pinned mechanism left on the symbol table at all — the loop now assertsdiff.is_empty()and panics with both rows on anything else.visibilityis one of those columns.So the day the builtin fix lands alone, that suite goes red with
That is the correct outcome and it is worth saying out loud: a divergence between the two Ruby implementations cannot be silent in this tree. #84's anti-vacuity claim rests on the two agreeing, and the gate enforces it.
The three ways out, and only one is right
Mechanismvariant pinning the divergence. Wrong, and specifically forbidden: aMechanismis a host-side gate a package cannot close. This would be a port defect wearing a pin, which is the "widen a Mechanism predicate without re-measuring" move that file's own doc refuses.What the packaged half costs, concretely
The guest's
visibility_marker(crates/guest/ruby/src/extract.rs:710) has the identical three-arm shape and the identical blind spot: the bare arm at 712 consultsclass_method_form, the inline arm at 720 carries it intopending_def_vis, and the targeted arm does not. The builtin's in-flight fix threads asingleton_symbolsset and joinsself.singleton_symbols.contains(&i) == class_method_formonto thefindpredicate; the guest needs the same rule against its own index space.Six coordinated artifacts, and none of them is optional:
crates/guest/ruby/src/extract.rs— the predicate.crates/guest/ruby/build.sh— rebuildextractor.wasm(gen-kinds.sh,cargo build --target wasm32-unknown-unknown,--strip-name-section,--inject-globals).crates/plugin-host/tests/grammar_provenance.rs::WASM_ARTIFACTS— the artifact sha256 lives there and only there.tests/packages/ruby/fixtures/visibility.expected— regenerate withCOSI_BLESS_RUBY_EXPECT=1; the diff is the review.tests/packages/ruby/plugin.toml—[package] version0.3.0 → 0.4.0. The facts change, so the version must.tests/packages/ruby.digest— re-record. This movesextraction_identity, which re-extracts every claimed file on every project that enabled the package. That is the CORRECT consequence and not a cost to avoid.Plus the test discipline this issue already specifies: both directions asserted (the class method becomes private AND the instance method of the same name stays exported — a fix that privatises both passes a one-sided test), and a mutation restoring the
class_method_form-blind predicate must go red on the guest side too.Status
Recorded deferral, sequenced after the builtin fix, not abandoned. Porting it now would mean building against an uncommitted
ruby.rswhosesingleton_symbolsdesign is still under review, and a guest rebuilt against a design that then changes is a wastedextraction_identitymove charged to every operator who enabled the package.The sequencing that works: land the builtin fix and the guest port in one change, with
ruby_package_paritygreen at the end of it andCOSI_BLESS_RUBY_EXPECT=1run once, not twice.🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
ruby_package_parityis RED at integration HEAD: #134's tier-3 origin gate reads a manifest relation the packaged leg structurally cannot have #167The packaged half LANDED, and the fixture that was supposed to grade this graded nothing — measured, not argued
f3fceedonwip/rubypkg, based onintegrationata33210d. Both halves are now in one tree, which is option 1 of the three the previous comment listed.The port
The guest had the identical three-arm shape and the identical blind spot.
Symgains asingletonfield — never encoded, scratch forvisibility_markerexactly as the builtin'ssingleton_symbolsset is — threadedtruethrough thedef self.construction site andfalsethrough the other four, and the backward scan now requiress.singleton == class_method_form.falseis the safe default because it can only withhold a demotion, never invent one.THE FIXTURE NEVER GRADED THE DEFECT, AND THAT IS PROVEN RATHER THAN SUSPECTED
This issue said "The fixture already exists" and "a mutation restoring the
class_method_form-blind predicate must go red." The first was true and the second was not, and the reason is this issue's own §Test shape being one case short.visibility.rb.rbx'sthetasits inside an enclosing bareprivatesection, so it wasfilefor an unrelated reason, and the file declares nodef self.thetaat all — so the marker had one candidate and no way to pick wrong. That is whyCOSI_BLESS_RUBY_EXPECT=1wrote zero bytes after the builtin fix.Measured, with the guest predicate reverted and the OLD fixture in place:
The defect fully present, the grader silent. Shipping the port against that fixture would have been a green that means nothing.
The fixture that does grade it
Api::MarkersandApi::Mirrors, appended so no existing line number moves and the regenerated.expectedis a readable diff. One class per marker form; each declares an instance AND a class method of the same name; and the declaration order is INVERTED between them, so a form-blind predicate — which searches backwards in both implementations — reaches the WRONG method in both rather than being accidentally right in one. That is the trap the builtin's own unit tests hit first (.rev()reaching the right symbol by luck, mutation surviving twice).MUTATION (RUN): drop
&& s.singleton == class_method_formfrom the guest, rebuild the wasm withbuild.sh, repack, re-sign, re-install and re-check — deliberately through a locally-signed package, so that the digest pins cannot be what fails and the FIXTURE is what grades.Four rows: both directions, both forms. A fix that privatised both, or neither, fails.
The six coordinated artifacts, each verified
crates/guest/ruby/src/extract.rs+src/lib.rsZERO_SYMinitialisertests/packages/ruby/extractor.wasm8c366129…7b1c3167…grammar_provenance.rs::WASM_ARTIFACTS8c366129…7b1c3167…fixtures/visibility.rb.rbx+.expectedCOSI_BLESS_RUBY_EXPECT=1from the BUILTINtests/packages/ruby/plugin.toml[package] versiontests/packages/ruby.digest6cede75e…/abe3ee0b…/ 2,187,848 B281dd11a…/d9c1592f…/ 2,191,714 Bextraction_identitymoved, which re-extracts every claimed file on every project that enabled the package. That is the correct consequence, and it is recorded inruby.digestrather than left for an operator to discover as adigest_mismatch.Reproducibility was proven BEFORE the change, not assumed after it.
build.shwas run on the UNMODIFIED tree first and produced 24,125 bytes /8c366129…byte-for-byte — so this box builds 0.3.0 identically, which is what makes 0.4.0's digest a measurement rather than a local artefact.build.sh's container provenance block is annotated, not overwritten: that measurement was 0.3.0's, the container leg was not re-run for 0.4.0, and the file now says so.Gates
crates/daemon/tests/ruby_package_e2e.rs— 5 passed, 0 failed, includingthe_shipped_package_is_the_artifact_that_was_recorded(the digest pin) andan_operator_can_grant_derived_names_and_the_rails_file_then_indexes(which runsplugin check --derived-namesand asserts all five fixtures pass).plugin-host'sgrammar_provenance7/7 andruby_guest_wire5/5 withCOSI_GUEST_REBUILD=1, so the artifact is bound to the source that builds it.One thing this landing could NOT verify, and it is not this change
ruby_package_parity— the suite this issue's previous comment correctly names as the gate that catches a one-sided landing — is RED atintegrationHEAD for an unrelated, pre-existing reason, bisected to2f16e22's #134 tier-3 origin gate and filed as #167 (green ate724819, red ata33210d, unmodified trees both times; 17 unexplained ref deltas in two opposite directions).So the claim "the two Ruby implementations agree row for row" is currently unavailable, and this landing does not assert it. What it does assert is what
plugin checkgrades directly: the guest reproduces the builtin's expectations, fixture for fixture, including the four new rows — which is the property this issue actually asks for.🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
ruby_package_parity: theSelfQualifierarm absorbs resolution differences, and closing #167's mechanism made 11 of them visible #170Triage 2026-09-06: CLOSING. Both halves are now in one tree — the builtin fix and the packaged fix — with a fixture that actually grades the defect and six coordinated artifacts moved together.
Verified against master. Builtin half in
2f16e22; packaged half inc54a060(it was built, verified under wasmtime, then reverted in2f16e22because landing it movespackage_digest, and re-landed once the version bump,ruby.digestre-record andWASM_ARTIFACTSsha could move with it).Builtin half
singleton_symbols: HashSet<usize>—crates/plugins/src/ruby.rs:119(doc at:107-118: "ABSENCE MEANS INSTANCE, which is the safe default"), threaded at:138and:439. The targeted arm's predicate,ruby.rs:501-507:Packaged half
pub(crate) singleton: bool—crates/guest/ruby/src/extract.rs:159, documented as "Never encoded. It is scratch forvisibility_marker, exactly as the builtin'ssingleton_symbolsset is" — the same shape on both legs rather than two rules that must be kept in step. Predicate atextract.rs:762-765.The declaration-order trap is encoded deliberately
ruby.rs:1477-1533: the class method is declared first so.rev()offers the wrong one first, and the mirror test inverts it. That matters because a mutation ledger elsewhere in this tree records exactly this failure — declaration order let.rev()reach the right symbol by luck and a mutation survived twice. Both directions are asserted here.The old fixture genuinely graded nothing — confirmed
2f16e22reports thatCOSI_BLESS_RUBY_EXPECT=1wrote nothing, because the fixture'sthetaalready sat inside an enclosing bareprivatesection and wasfilefor an unrelated reason. That is the sharpest kind of finding: the fixture that was supposed to cover this defect was passing for a reason that had nothing to do with it.The replacement grades it:
tests/packages/ruby/fixtures/visibility.rb.rbx:84-98—class Markers(def self.nufirst) andclass Mirrors(def xifirst), order inverted between them — with 4 new rows atvisibility.expected:244-289.Six artifacts verified as moving together
extractor.wasm24,218 B, sha2567b1c3167020eebe93a5187c2c70478b9d59bbd3f3d569ca9554ea794693fefac(matches the recorded claim);WASM_ARTIFACTSatcrates/plugin-host/tests/grammar_provenance.rs:182;plugin.tomlversion = "0.4.0";ruby.digestre-recorded; the fixture; the.expected.Runs (exit 0)
incl.
the_shipped_package_is_the_artifact_that_was_recorded(the digest pin) andan_operator_can_grant_derived_names_and_the_rails_file_then_indexes, which runsplugin checkover all five fixtures.Residual — read this before assuming parity is proven
The gate this issue's own first comment names as the one that would catch a one-sided landing —
crates/indexer/tests/ruby_package_parity.rs— was not run as part of this verification, and it has been red on the integration branch for a pre-existing unrelated reason (#167 / #170, owned elsewhere).So the claim "the two Ruby implementations agree row for row" is currently unavailable, not established. What is proven is that
plugin checkgrades the guest against builtin-generated expectations, fixture for fixture, including the four new rows — which is what this issue actually asked for. Stating it that way rather than implying more.🤖 Triage lane, 2026-09-06, master
45cf6e4