Two LineIndex phantoms survive on rust-analyzer because tier 1Q anchors on a file STEM — directions 1 and 2 (package_root_of/lib) measured and REFUSED, direction 3 is what remains #168
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#168
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 bounding #165's phantom risk, with an oracle independent of the resolver: each ref crate's own
Cargo.toml.The two binds
Of the 16 717 binds #165's fold newly admits on rust-analyzer, 16 444 cross a crate boundary Cargo declares, 42 are same-crate, and 229 of the remaining 231 travel a real re-export (
crates/ide-db/src/lib.rs:75,pub use base_db::{self, ..}). Two do not:Both sites are
use ide_db::{..., line_index::{LineCol, LineIndex}, ...}. The correct target islib/line-index/src/lib.rs:85, re-exported byide-db.crates/idedoes not depend oncrates/rust-analyzer— that edge runs the other way — so these are phantoms by the same argument #134 uses.The mechanism, and it is not the fold
index::package_root_ofreturns the prefix before the first conventional source directory:libis in that list. rust-analyzer keeps four real packages under a top-levellib/(lib/line-index,lib/la-arena,lib/lsp-server,lib/ra-ap-rustc_*-style vendored crates), so forlib/line-index/src/lib.rsthe loop matcheslibat index 0 and returns"".pkg_tailis empty,temp.pkg_langskips it (WHERE fp.pkg_tail != ''), and the package cannot anchor a qualifier.What is left for
ide_db::line_index::LineIndexis tier 1Q's rule (b) file-key anchor onq_last: the stemline_indexnames exactly one file in the whole tree —crates/rust-analyzer/src/line_index.rs. One candidate, so it resolves. This is #134's family verbatim (a basename stem carries no package), reached through the qualified path instead of the import path.#165 did not create either half. Before the fold,
q_first = ide_dbwas not a known root at all (ide-dbis the directory,ide_dbthe identifier), soqual_knownrefused the qualifier outright and no anchor ran. The fold correctly admits the qualifier; the stem anchor then mis-aims it. Reverting the fold would hide these two by refusing 16 715 correct binds with them.Bound
ide_db::base_dbre-export).tests/corpus/tier3-baseline.jsonbless reason and inindex::fold_pkg_sep's doc, so it is not lost.Directions (each needs a measured before/after, bind-for-bind)
libfromSRC_DIRS, or accept it only when it is not the FIRST component.lib/as a package container (lib/line-index/Cargo.toml) andlib/as a source dir (mypkg/lib/foo.rb) are different things, and only the second is what the list is for. Blast radius: every Ruby/PHP layout that useslib/as a source root — measurable onruby-sinatraandphp-guzzle.manifest_package_dirs/nearest_package_diralready knowlib/line-indexholds aCargo.toml;pkg_tailis still derived from the lexicalpackage_root_of. Deriving the tail frompkg_dirwhen there IS a manifest would fix this class without touching the lexical fallback. This is the same "manifests do not guess" argument that made #134 comparepkg_dirrather thanpkg_root.Direction 2 looks like the one clause: it is structural, it changes no lexical behaviour where no manifest exists, and it makes
pkg_tailandpkg_diragree instead of disagreeing.🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
REFUSED: directions 1 and 2 both measured, both cost recall, and NEITHER fixes the two phantoms
Measured in
/tmp/cosi-lane-precisionofforigin/master(f6a878a), against a private copy of the pinned corpus. Baseline reproduces the record exactly: rust-analyzer 143 113 resolved, and every other repo's figure matchesbaseline.json. Bind sets compared id-independently,ref path:line:col kind name -> target path#name@line by <rule>, sorted in the C locale.Direction 2 — derive
pkg_tailfrompkg_dirwhen a manifest existsThe one the issue calls "the one clause".
The two phantoms survive. They do not go away; they change arm:
(
crates/ide/src/view_syntax_tree.rs:4:27likewise. One genuine gain beside them:LineColat142:18now bindslib/line-index/src/lib.rs#LineCol@14correctly.)Direction 1 — drop
libfromSRC_DIRSwhen it is the FIRST componentThe two phantoms survive here too, and again as rule 42 —
tier1q_tiebreak.The mechanism, read at source rather than inferred from the delta
Both directions do the same structural thing: they give
lib/<pkg>a non-emptypkg_tail. An empty tail is currently an EXEMPTION from #134's origin gate (AND (fp.pkg_tail = '' OR …)). Filling it in subjects those files to the gate, and the gate then refuses every bind an import does not vouch for — which on rust-analyzer means the re-export binds.Where the 1 620 rust-analyzer losses land, by target package:
lib/text-size1 071,lib/smol_str263,crates/syntax102,lib/line-index84,lib/ungrammar54,lib/lsp-server21,lib/la-arena14.Checked against the oracle #168 itself used — each referencing crate's own
Cargo.toml.crates/base-db/Cargo.tomldeclaressyntax,la-arena,vfs,span,intern,cfg— and nottext-size, notsmol_str. Same forcrates/hir-def. Socrates/base-db/src/lib.rs:312:49 type TextSize -> lib/text-size/src/size.rs#TextSize@24travelssyntax's re-export, exactly like the 229 binds throughcrates/ide-db/src/lib.rs:75that this issue's own bound counted as legitimate. These are the good kind of bind, and both directions withdraw ~1 300 of them to fix 2 phantoms.That trade — 67 legitimate resolutions against 2 phantoms — is the one
index::manifest_package_dirs' doc already records as "Measured, then rejected." This is the same trade at 20× the scale.Direction 1 also does what the issue predicted for Ruby/PHP, and the Ruby number is the one that decides it:
lib/sinatra/base.rbgoes from root""to rootlib/sinatra, tailsinatra, and ruby-sinatra loses 115 of 2 850 binds (4.0 %). php-guzzle nets +66 but churns 520 rows in both directions, which is not a result to accept without reading every one.Where that leaves the issue
Direction 3 is the only one that touches the mechanism, and both other directions demonstrate it by relocating the phantom into
tier1q_tiebreak— which is the stem-anchored arm. The issue's own analysis said so ("the file-stem anchor is then the only candidate"); the measurement now says the package tail is not what mis-aims these two, and givinglib/line-indexa tail changes which stem-anchored arm answers, not whether one does.Not implemented here. Direction 3 costs recall wherever a stem is the only evidence, so it needs the same bind-for-bind before/after over the nine repos plus a reading of the losses, and it is a change inside tier 1Q rather than at the
pkg_tailfunnel.The residual stands as filed
2 phantoms in 16 444 admitted cross-crate binds (0.012 %), bounded by an oracle outside the resolver, recorded in
fold_pkg_sep's doc and in thetier3-baseline.jsonbless reason. Nothing in this measurement changes that bound; what it changes is which of the three directions can be expected to close it.No source change from this lane on #168. The two experiments were applied, measured and reverted;
crates/indexer/src/index.rsis md5-identical to its pre-experiment snapshot.STAYING OPEN — the defect is live on master; two of three directions are now measured and refused
Close-out lane, master
552e3a2. Title corrected to name the mechanism the measurement actually found.Why this is not a "refused, recorded" close
A refusal backed by numbers is a legitimate close when the refusal disposes of the defect. Here it does not: the two
LineIndexphantoms still bind on master, andpackage_root_of'sSRC_DIRSis unchanged —— so
lib/line-index/src/lib.rsstill yieldspkg_tail == ''and still cannot anchor a qualifier. Nothing about the reported behaviour changed.What the measurement did settle — the issue's own diagnosis was wrong
Both directions were applied, measured bind-for-bind against the nine pinned repos, and reverted (
index.rsmd5-identical to its pre-experiment snapshot). Neither fixes the two phantoms; both relocate them from rule 40tier1q_pass1to rule 42tier1q_tiebreak— which is still a stem-anchored arm. And both cost real recall:pkg_tailfrompkg_dirwhen a manifest existslibfromSRC_DIRSwhen firstThe mechanism, read at source rather than inferred from the delta: both directions give
lib/<pkg>a non-emptypkg_tail, and an empty tail is currently an exemption from #134's origin gate (AND (fp.pkg_tail = '' OR …)). Filling it in subjects those files to the gate, which then refuses every bind an import does not vouch for — i.e. the re-export binds. The rust-analyzer losses land onlib/text-size(1 071),lib/smol_str(263),crates/syntax(102),lib/line-index(84). Checked against the same oracle this issue used — each referencing crate's ownCargo.toml— those travelsyntax's re-export exactly as the 229 binds throughcrates/ide-db/src/lib.rs:75do, which this issue's own bound already counted as legitimate.That is the trade
index::manifest_package_dirs' doc already records as "Measured, then rejected" — here at 20× the scale: ~1 300 correct resolutions for 2 phantoms.Corrected scope — what remains
Direction 3 only: refuse a tier-1Q anchor whose sole evidence is a file STEM (#134 direction 1, applied to 1Q). It is the only direction that touches the mechanism, and the other two demonstrate that by relocating the phantom into the stem-anchored arm. It needs the same bind-for-bind before/after over the nine repos plus a reading of the losses, because it costs recall wherever a stem is the only evidence.
Directions 1 and 2 should not be re-attempted without new evidence; the numbers above are the record.
The residual stands as filed
2 phantoms in 16 444 admitted cross-crate binds (0.012 %), bounded by an oracle outside the resolver, recorded in
index::fold_pkg_sep's doc and in thetests/corpus/tier3-baseline.jsonbless reason.to Twopackage_root_ofcounts a top-levellib/as a source dir, solib/line-indexhas an empty package tail and a stem anchor binds the wrongLineIndexLineIndexphantoms survive on rust-analyzer because tier 1Q anchors on a file STEM — directions 1 and 2 (package_root_of/lib) measured and REFUSED, direction 3 is what remainsDIRECTION 3 MEASURED AND REFUSED — it costs 5 840 correct binds to remove the two phantoms, and its narrow form does not remove them at all
Resolver-recall lane, worktree off master
fc329a8. All three directions are now measured. Two experiments, both applied, measured bind-for-bind over the nine pinned repos, and reverted —crates/indexer/src/index.rsis md5-identical to its pre-experiment snapshot (43634ff127df8fad4cea1f9a09bb99d0).How direction 3 was made measurable
temp.file_keysis the union ofmodulesymbol names and file BASENAME STEMS, inserted by two different statements, so "the anchor's sole evidence is a stem" is decidable: addfrom_stemto that table and gate the tier-1Q arms on it. The stem tie-break (temp.qual_stem_files, rule 42) is a stem anchor by construction and has no other evidence at all.Experiment 3a — refuse the stem in tier 1Q's rule-(b)
q_lastfile-key anchorThe arm that produced the two filed binds.
Nearly a no-op on recall — and the two phantoms SURVIVE:
Rule 40 -> 42, exactly as directions 1 and 2 did. rust-analyzer's movement is 647 binds relocating 40 -> 42 with identical targets. The tie-break arm catches everything the pass-1 anchor drops, because it anchors on the same stem.
That is the third independent demonstration of one fact: the phantom does not belong to any single arm. Every direction so far has moved it between arms that share the stem.
Experiment 3b — the COMPLETE form: no stem-only anchor anywhere in tier 1Q
Rule-(b)
q_lastgated onfrom_stem = 0andqual_stem_filesemptied, so rule 42 cannot fire.The two phantoms are gone. And so is this:
Roughly 2 800 correct binds per phantom, against ~650 for direction 2 and ~575 for direction 1.
The losses read at source, and they are the ordinary case
The Cargo oracle over rust-analyzer's 1 078 losses: 896 same-crate, 166 cross-crate, 46 undeclared (
ide-assists -> hir-ty, travellinghir's re-export). These arefrom django.utils.version import get_versionanduse crate::expr_store::path::Path— the bread-and-butter qualified import.Why direction 3 is MIS-FRAMED, and this is the finding
The issue treats "the anchor's sole evidence is a file STEM" as weak evidence. In a module-per-file language it is not evidence at all — it is the language's own naming rule.
django.utils.version's last segment IS the file stem ofdjango/utils/version.py, by Python's import semantics;crate::expr_store::pathISexpr_store/path.rs, by Rust's. Refusing the stem anchor does not remove a heuristic, it removes the primary mechanism by which a qualified import resolves in two of the seven languages — which is why py-django alone loses 4 510 binds.The
LineIndexcase is not "a stem was trusted". It is a stem was trusted GLOBALLY: the keyline_indexwas matched against every file in the tree rather than against files the qualifier's own root could reach.crates/idereachingcrates/rust-analyzer/src/line_index.rsis wrong becauseide_dbdoes not name that crate — not because a stem was involved.So the direction worth measuring next is not a refusal but a scoping: require the stem-anchored file to be reachable from the qualifier's root (
q_first's package/root), instead of accepting any file in the project whose basename matches. That is the shape #134's origin gate already has for tier 3, applied to tier 1Q's anchor, and it would leavedjango.utils.versionuntouched (the stem sits under thedjangoroot the qualifier names) while refusingide_db::line_index::LineIndex -> crates/rust-analyzer/….Where the issue stands
libfromSRC_DIRSpkg_tailfrompkg_dirq_lastAll three directions as filed are now closed with numbers. The residual stands exactly as recorded: 2 phantoms in 16 444 admitted cross-crate binds (0.012 %), bounded by an oracle outside the resolver, written into
index::fold_pkg_sep's doc and thetests/corpus/tier3-baseline.jsonbless reason. What should change in this issue is the DIRECTIONS list — replace it with the root-scoped anchor above, which is the only remaining shape that has not been shown to trade thousands of correct binds for two.Note on the numbers' baseline
These experiments were run on top of this lane's #69 change (tier 1R serving the member pool), so the absolute totals differ from the earlier comment's. The deltas are all measured against that same build, and #69 touches tier 1R only — no tier-1Q arm — so the tier-1Q conclusions are unaffected. The two
LineIndexbinds are byte-identical in both baselines.No source change from this lane on #168.
Visibility::Unknownhas no wire slot, and it costs a packaged JavaScript 19% of ALL its resolutions — MEASURED #166from .models importreaches everymodels.pyin the tree, and it produced 3 of #69's 5 measured phantoms #196build_recv_originbypassed #57 for four years of commits, and nothing could have told a reader #203build_recv_originbypassed #57 for four years of commits, and nothing could have told a reader #203build_recv_originbypassed #57 for four years of commits, and nothing could have told a reader #203user.emailbinds across unrelated Django test apps via TIER1R_RECEIVER — 2 live phantoms on master, and #199's own comment says they were gone #246Fixed on
masterat6f8cddf(mergedbb214f1). And this issue undercounts the family by more than 3×.Two corrections to the measurement
"Two
LineIndexphantoms" is three.crates/rust-analyzer/src/cli/parse.rs:3:25binds the same wrong target and predates #165's fold, so this issue only ever counted the new binds.The family is 10, not 3. Seven unfiled
hir_def::layout::{TagEncoding,Variants} → crates/hir-ty/src/layout.rsbinds of the identical shape — two of them self-referential, wherelayout.rs:76:41'spub type TagEncoding = hir_def::layout::TagEncoding<…>bound the alias on its own line to itself.crates/hir-defdoes not depend onhir-ty, checked in itsCargo.toml.So the "0.012 % of 16,444" bound was computed on the wrong denominator.
The proposed fix is right in direction and insufficient as stated
index::qual_root_scope_sqlis one predicate spliced into both stem-anchored tier-1Q arms (theq_lastfile-key arm and thequal_stem_filestie-break). A stem-anchored candidate is admitted only if the qualifier's first segment names no package, or the candidate has no package root, or it sits under a root that segment names, or it sits in a package that root's own files import.Two findings this issue does not contain:
1. Containment alone withdraws 35 CORRECT binds. 27 of them are
hir::db::HirDatabasereachingcrates/hir-ty/src/db.rsacross a one-linepub useatcrates/hir/src/db.rs:7. The re-export arm (temp.root_pkg_reach, one hop from the root's own imports) is what separates those from the phantoms:crates/ide-dbimports nothing namedrust_analyzer;crates/hir-defimports nothing namedhir_ty. This issue's "root-scoped anchor" has no such arm.2. A root may not be read off
qseg_pkg's SUFFIX relation.view_hirends with the tailhir; rooting it atcrates/hirrefusedview_hir::view_hir()insidecrates/ide. Fixed withLENGTH(sp.pkg_tail) = LENGTH(sp.seg). That hazard is mentioned nowhere in the issue.Bind census, read at source, against a
de98a4fbinaryThe prior binary reproduces
baseline.jsonexactly on all 7 pinned repos and reproduces both filed phantoms at rule 40 — the harness's own control.#168 alone: six repos bind-for-bind identical. rust-analyzer −10 / +20:
LineIndex(including the unfiledcli/parse.rs) and the 7hir_def::layoutfamily.hir_expand::proc_macro::ProcMacroKind(decoyhir-def/src/nameres/proc_macro.rs, same stem), 4 ×ide_db::rename::RenameConfig(decoyide/src/rename.rs:30, including two sites inside that very file that literally writeide_db::rename::RenameConfig), 6 ×hir::term_search::term_search.Mutations
q_lastarm) → RED on the REFUSAL, targetSome("crates/scope-user/src/gadget.rs")by rule 40 — the filed rule.qual_stem_files) → RED, same target by rule 42, reproducing the relocation each of this issue's three prior experiments hit.root_pkg_reach) → RED: "the re-export arm is not optional…"view_gaugemerely ENDS WITH the tailgauge…"file_root_ancdisjunct fired nothing, because anchor rule (c) reached the file anyway via a shared package tail. Reported as a survivor, then a third test (a_stem_anchor_inside_a_nested_package_of_the_qualifiers_root_is_admitted, thedjango.utils.versionshape) was written that the same mutation takes RED.precision_gatere-run by me post-merge: 7/7,phantom_count == 0,recall = 1.000, with the denominator GROWN — rustforbid_sites6 → 7, python 7 → 8, probes 13 → 16 and 14 → 16.Three of this repo's own gates caught real defects in the work:
doc_citation_gate(a cited test that was never written),generation_build's ungated-importscensus (root_pkg_reachreadimportswithout a generation gate — a pending generation could have let a package under construction vouch for a root), andpool_capability_registry(three new temp relations with no declared stance).Closing. The absolute-module stem family this fixes is the half #203 explicitly left open; the remaining sibling is #246.
method_calldraws only fromkind='method', and python.rs mints no module symbol at all #175package_root_ofreturns EMPTY when a SRC_DIRS name is the first path component, sosrc/- andlib/-headed trees can anchor no qualifier — #175's own repro still fails because of it #247views.serveandstorage.request.COOKIES#250django/utils/{inspect,html}.py) #251django/utils/{inspect,html}.py) #251