An UNQUALIFIED destructuring use still produces no reference row at all (JS/C#), PHP+Ruby have no written exemption, and there is no cross-language fixture — the qualified case was fixed by 2f16e22 #125
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#125
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?
Dogfood finding from the #76 declarative-tier retirement, where it nearly caused a field to be read as dead.
The measurement
ClaimDecl::tier(crates/package/src/manifest.rs) reports:One of those two is
match c.tierin the same file, whereciterates&self.claims: Vec<ClaimDecl>.But the use in
crates/package/src/digest.rs:produces no ref row whatsoever. It is in neither counter — not in
ref_count, not inname_fallback_count, not returned byfind_references.That site is not incidental: it is where the field is written into the package digest, which is package identity. It is arguably the most important use of the field in the tree.
Why this is the more dangerous half of #118
#118 is a falsely earned zero — a
#[tool]method reportsref_count: 0, name_fallback_count: 0with nounmeasuredflag. Bad, but the population was at least considered.Here the reference is never extracted, so no counter can see it and no disclosure can qualify it.
name_fallback_count: 2happens to be non-zero for unrelated reasons, and that is the only thing that stopped the field being read as dead in this case. Had the twomatch c.tiersites not existed,ClaimDecl::tierwould have presented as a completely unused field — with a fully earned zero — while being load-bearing for package identity.safe_deleteinherits this directly: a field used only through destructuring looks removable.Scope to establish before fixing
Rust destructuring has several shapes and they should be measured rather than assumed:
let Struct { field, .. } = value;if let/while let/matchpatterns binding fields@-bound patternsfield) versus renaming (field: local)And the same question applies to the other six languages — JS/TS object destructuring and Python starred/tuple unpacking are the obvious analogues. Do not fix Rust alone; the standing rule here is one clause resting on a structural fact across all languages, not a per-language patch.
What closing it needs
name_span— the pattern position, since that is where a reader would edit.precision_gate— new ref rows are new resolution opportunities, and this must not manufacture phantoms. Zero phantoms is non-negotiable.Related
#118 (a falsely earned zero from
search_symbols) — same family, but that one at least measures the population. Found alongside a note that thename_fallback_count: 2disclosure is what prevented the wrong conclusion here, which is the disclosure machinery working exactly as intended.ruby_package_parityis RED at integration HEAD: #134's tier-3 origin gate reads a manifest relation the packaged leg structurally cannot have #167Triage 2026-09-06: LEFT OPEN — PARTIAL. Four languages emit, but only for qualified patterns, so this issue's headline is still true for the JS majority case.
Landed in
2f16e22. The work is good and the trade is defensible; the issue should not close on it as written.What landed
crates/plugins/src/rust.rs:317—"struct_pattern"→emit_pattern_field_reads(:1234)crates/plugins/src/typescript.rs:483—"object_pattern"→emit_pattern_property_reads(:1618)crates/plugins/src/csharp.rs:736—"property_pattern_clause"(:1643)crates/plugins/src/python.rs:261—class_patternkeyword →emit_pattern_attribute_read(:1016)The motivating case is genuinely covered:
let ClaimDecl { language, tier, .. } = claim;names its type, so Rust emits.Why PARTIAL — three reasons
1. The headline is still true for unqualified patterns.
crates/plugins/src/typescript.rs:1663andcrates/plugins/src/csharp.rs:1663are literallyif qualifier.is_none() { return; }— no ref row of any kind. The comment attypescript.rs:1656-1662says so: "untyped JavaScript destructuring is now invisible again."That is a defensible engineering decision, and the reasoning is excellent — the first version produced six phantoms, and counting would have missed them.
function BlogLayout({ children }: { children: ReactNode })in a docs.tsxboundchildrento aget children()in a zod test file in another package, four times; express'sconst { address, port } = server.address()boundvar address = urlinsideres.redirect. Every phantom was unqualified; all 11 qualified patterns bound correctly. One structural clause across four languages, and two right answers given up to stop six wrong ones shipping asresolved. That is the right call.But "a destructuring use produces no reference row at all" remains shipped behaviour for untyped JS, which is most JS. Closing on this issue's own text would overstate what was achieved.
2. No cross-language fixture. Grading is four plugin-local unit tests (
rust.rs:2222,typescript.rs:2240,csharp.rs:2136,python.rs:1252), not a fixture "in the shape of the existing seven-language projection gates", which is criterion 2.3. The PHP/Ruby exemption is not recorded in the tree. It exists only in
2f16e22's commit message — "PHP and Ruby emit nothing because their destructuring is by array/hash KEY, not member name — a language fact, not a gap." Searching#125across*.rs/*.md/*.json/*.toml: zero hits incrates/plugins/src/php.rsandcrates/plugins/src/ruby.rs, and zero hits for that reasoning anywhere.That is the shape this repo keeps filing against itself: a language exemption that lives only in a commit message. The next reader of the producer-coverage matrix sees two empty cells and no reason. Put the sentence at the two sites.
What closing needs
Item 1 is small and worth doing regardless of 2 and 3.
🤖 Triage lane, 2026-09-06, master
45cf6e4STAYING OPEN, NARROWED — the qualified case is fixed; the headline is still shipped behaviour for untyped JS
Close-out lane, master
552e3a2. Title corrected.Fixed by
2f16e22"feat: destructuring member reads and a tier-3 origin gate, both bind-inspected"crates/plugins/src/rust.rs:317emit_pattern_field_readscrates/plugins/src/typescript.rs:483emit_pattern_property_readscrates/plugins/src/csharp.rs:736crates/plugins/src/python.rs:261Four unit tests pass (
a_struct_pattern_reads_every_member_it_names,an_object_pattern_reads_every_property_its_annotation_anchors,a_property_pattern_reads_every_member_it_names,a_class_pattern_reads_every_attribute_it_names) — EXIT=0.precision_gate7/7 withphantoms=0and recall 1.000 on all seven languages — criterion 4 met. An end-to-end probe with a552e3a2binary on the issue's exact motivating shape emits and resolves the row.Three residuals, all still true byte-for-byte on master
crates/plugins/src/typescript.rs:1663andcrates/plugins/src/csharp.rs:1656areif qualifier.is_none() { return; }, and the TS unit test asserts the withholding —const { theta } = obj;and({ zeta: h } = obj)must emit noread/writeat all. The trade is well-measured (six corpus phantoms against two correct binds,typescript.rs:1640-1662) and is the right call, but an unqualified destructuring use still produces no reference row.#125mention outside the four plugins is a comment incrates/mcp-server/tests/overview_payload_budget_e2e.rs:1466.search_text("#125")returns 6 files; zero hits inphp.rsorruby.rs, andemit_pattern*exists in neither. The reason — "their destructuring is by array/hash KEY, not member name — a language fact, not a gap" — lives only in2f16e22's commit message, which is exactly the "a decision nobody wrote down is indistinguishable from a bug" state this project files issues about.Corrected scope
Retitle as above; record in the body that qualified patterns in rust/TS/C#/python were closed by
2f16e22, with the six-phantom measurement as the accepted reason for the unqualified recall loss. Remaining work: (a) the PHP/Ruby exemption written at the two sites, (b) the cross-language fixture decision — either build it or record why four plugin tests are the right grading.NEW DEFECT, and it is the more urgent finding
An extractor change that adds ref rows shipped with no re-heal migration, so no existing index sees this fix — including this repo's own.
On the live dogfood index (installed daemon
0.26.1 (8d90075), which contains2f16e22), the motivating symbol still reports the defect verbatim:A fresh index of the same shape with the
552e3a2binary emits and resolves that row, so the difference is staleness, not code:git show --stat 2f16e22adds no migration, and the series still ends atm0061_file_refs_rollup.rs. Indexing skips files by stat/hash, so a plugin upgrade never re-extracts an unchanged file — every pre-existing index keeps its pre-fix rows until each affected file happens to be edited. This repo has fixed exactly this before with a no-invalidation re-heal migration (the m0017/m0019 pattern). Concretely:safe_deleteandref_countonClaimDecl::tierstill read as dead on the daemon a user is running today, which is the failure mode this issue was filed about.A struct-destructuring use produces no reference row at all, so it is in neitherto An UNQUALIFIED destructuring use still produces no reference row at all (JS/C#), PHP+Ruby have no written exemption, and there is no cross-language fixture — the qualified case was fixed by 2f16e22ref_countnorname_fallback_countBoth close-out items done, plus the NEW DEFECT the last comment flagged — but the headline stays true for untyped JS, so this narrows again rather than closing
Resolver-recall lane, worktree off master
fc329a8. Taking the three residuals in the order the previous comment listed them.(3) The PHP/Ruby exemption is now IN THE TREE, at both sites
crates/plugins/src/php.rsandcrates/plugins/src/ruby.rs, onrebind_pattern— the function a reader lands on when asking why that cell is empty. Both carry the same argument in the same words: PHP destructuring names ARRAY KEYS and offsets ([$a, $b] = $pairnames 0 and 1;['id' => $i] = $rownames the string'id'), Ruby's names POSITIONS and HASH KEYS. Neither is a declared member of any type, so no honestreadrow exists — and a row keyed onidwould be a phantom generator against every$obj->idproperty and everyattr_reader :idin the tree. Object member access in those languages is$obj->prop/recv.attr/@ivar, whichemit_property_access,emit_member_callandemit_ivar_accessalready emit.(2) The cross-language fixture exists, and it keeps the two empty cells APART
crates/plugins/src/lib.rs,destructuring_contract_tests, in the shape of theaccess_ref_contract_testsseven-language gate next to it. One row per language,(extension, source, expectation)with the pattern always on line 2.The reason it is not just a table of "emits / does not emit": #125 shipped with two DIFFERENT empty cells and recorded neither, and they are not the same finding.
Expecthas three arms so they cannot be collapsed:Members(&[(name, qualifier)])— rust, ts, csharp, python. Exhaustive: an EXTRA row fails too, because an unanchored extra row is a phantom candidate, not a bonus.WithheldUnqualified(reason)— javascript. The language HAS member destructuring; the plugin withholds because nothing names the type. A measured TRADE (six corpus phantoms against two correct binds), and the OPEN half of this issue.NoMembersByLanguage(reason)— php, ruby. Permanent language fact.The failure message prints the recorded reason and says move the row, do not delete the arm.
Both no-row arms are graded, never asserted away: the fixture must produce
bindingrows on the pattern line first, which proves the pattern parsed and the walk reached it. Without that, "projects no member read" is satisfied by a fixture holding no pattern — the vacuous (b) cell in an (a) cell's clothes, which is exactly whatproducer_coverage_matrixexists to prevent.A second test derives the language list from
all_plugins()so a plugin claiming an extension no row covers fails. A hand-copied list is how this issue kept two silent cells in the first place.Five mutations, all RUN, all RED:
"struct_pattern"fromrust.rs'swalk.rsMembers arm,Got: []php.rs'srebind_patternpushes anaccess_refper variable.php projected member reads ... [("a", None), ("b", None)]if qualifier.is_none() { return; }fromtypescript.rs.js projected ... [("alpha", None), ("beta", None)]produced 0 binding rows ... is VACUOUS. Fix the fixture, not the assertion.rbrow from the tablea plugin claiming ["rb", "rake", "gemspec"] is covered by no destructuring rowThe last two grade the guards rather than the emitters, which is the part that would otherwise rot.
THE NEW DEFECT — fixed.
m0063, a RE-PARSEThe previous comment is right and it is the more urgent finding:
2f16e22is an EXTRACTION change that shipped with no migration, so no existing index has those rows, including this repository's own.crates/indexer/src/migrations/m0063_pattern_reads_and_member_receivers.rsfollows the m0040 pattern, not the m0017/m0019/m0062 re-heal one:mtime_ns = -1fails the stat tier (no real mtime is negative), the zeroed hash fails the content tier, so neither reconcile tier can skip a file. Scoped tokind = 'code'— text rows carry no extraction. #69's resolution change rides on it for free: a re-parse rewrites every ref and resolution runs over the result, which is strictly stronger than the heal Spec 04 requires.m0063_forces_a_reparse_because_pattern_reads_come_from_the_parsergrades it, and it is SCOPED TO m0063 on purpose.m0040's andm0041's versions of this test run the chain to the END, so any later re-parse satisfies them and they no longer grade the migration they are named after — that is a live vacuity in two existing tests, worth its own look. This one runs to 62, stamps every file FRESH, asserts the fixture really is fresh, and only then applies 63.MUTATION (RUN): replace
m0063's body with m0062's re-heal (UPDATE refs SET target_id = NULL+resolve_ref_targets_in_tx). RED:left: 0, right: 2— a re-heal touches no file, and every pre-2f16e22index keeps its missing pattern rows forever.(1) The headline is STILL TRUE for untyped JS
Unchanged, and deliberately so.
typescript.rs:1663andcsharp.rs:1656are stillif qualifier.is_none() { return; }, and the new.jsrow now asserts the withholding with the six-phantom measurement as its recorded reason. That is the right call and I am not touching it; buying it back needs receiver-type evidence, not a wider pattern arm.Worth noting from the #69 work in this same lane: receiver-type evidence is exactly what tier 1R now supplies for member access, and it produced +4 922 binds with 0 lost — but 0 of them on
js-express, because untyped JS has no binding rows to key on. So the mechanism that would close this half is already in the tree and demonstrably does not reach JavaScript. That is a fact about the language, not about the tier.Where this leaves the issue
Residuals (2) and (3) are closed and graded; the new re-heal defect is fixed and graded. Residual (1) — an unqualified destructuring use produces no reference row — is unchanged and is now the whole of the issue. Either accept it in the body with the six-phantom measurement as the reason and close, or keep it open as the JS recall item; it should not close silently on the two items above.