fix(tethys): resolve qualified refs from import-less files (rivets-044i)#74
Conversation
Code Review: fix(tethys): resolve qualified refs from import-less files (rivets-044i)Overall this is a well-scoped, carefully reasoned fix. The implementation is clean, additive-only, and backed by a rigorous audit trail. A few items worth discussing below, but none are blockers. What the PR doesAdds Strengths
Issues1. Leftover
|
There was a problem hiding this comment.
Code Review
This pull request implements a fallback resolution mechanism for qualified references in import-less Rust files, addressing a limitation where module-stripped stored names caused lookup failures. The new qualified_module_fallback method employs a longest-prefix split strategy to resolve path segments to specific files. Review feedback identifies opportunities to optimize performance by reducing redundant allocations within the resolution loop and emphasizes ensuring that single-segment paths correctly resolve to crate entry points to maintain dependency graph integrity.
| let crates = self.crates(); | ||
| for split in (1..segments.len()).rev() { | ||
| let prefix = &segments[..split]; | ||
| let tail = segments[split..].join("::"); | ||
|
|
||
| let mut file_id = None; | ||
|
|
||
| // Interpretation A: implicit-crate prepend. | ||
| if !matches!(prefix[0], "crate" | "self" | "super") { | ||
| let mut with_crate: Vec<String> = Vec::with_capacity(prefix.len() + 1); | ||
| with_crate.push("crate".to_string()); | ||
| with_crate.extend(prefix.iter().map(|s| (*s).to_string())); | ||
| if let Some(resolved) = | ||
| resolve_module_path(&with_crate, current_file, src_root, crates) | ||
| { | ||
| let relative = self.relative_path(&resolved); | ||
| file_id = self.db.get_file_id(&relative)?; | ||
| } | ||
| } | ||
|
|
||
| // Interpretation B: as-written. | ||
| if file_id.is_none() { | ||
| let as_owned: Vec<String> = prefix.iter().map(|s| (*s).to_string()).collect(); | ||
| if let Some(resolved) = | ||
| resolve_module_path(&as_owned, current_file, src_root, crates) | ||
| { | ||
| let relative = self.relative_path(&resolved); | ||
| file_id = self.db.get_file_id(&relative)?; | ||
| } | ||
| } |
There was a problem hiding this comment.
The current implementation performs redundant allocations inside the loop and may incorrectly handle submodule shadowing. In Rust module resolution, ensure single-segment paths resolve to the crate entry point file (such as lib.rs) rather than the source directory to prevent corrupting dependency graphs. To improve performance, avoid unnecessary allocations in loops by using borrowed slices instead of owned String objects, delaying conversion until necessary. While clarity is prioritized over micro-optimization, these changes address both correctness and efficiency.
let crates = self.crates();
let path_segments: Vec<&str> = std::iter::once("crate").chain(segments.iter().copied()).collect();
for split in (1..segments.len()).rev() {
let mut file_id = None;
let mut module_resolved = false;
if !matches!(segments[0], "crate" | "self" | "super") {
if let Some(resolved) = resolve_module_path(&path_segments[..split + 1], current_file, src_root, crates) {
module_resolved = true;
file_id = self.db.get_file_id(&self.relative_path(&resolved))?;
}
}
if !module_resolved {
if let Some(resolved) = resolve_module_path(&path_segments[1..split + 1], current_file, src_root, crates) {
file_id = self.db.get_file_id(&self.relative_path(&resolved))?;
}
}
}References
- In Rust module resolution, ensure single-segment paths resolve to the crate's entry point file (e.g., lib.rs) rather than the source directory to prevent corrupting dependency graphs that expect file-level edges.
- To improve performance, avoid unnecessary allocations in loops. When using Cow types (like Cow), perform lookups and comparisons on the borrowed variant (e.g., using .as_ref()) and delay conversion to the owned variant (e.g., using .into_owned()) until the value needs to be stored.
- Prioritize code clarity over micro-optimizations like avoiding small allocations in loops unless profiling demonstrates a performance issue, especially for small data sets. Consider pre-normalizing data at construction time if optimization is required.
Tighten regression fences and apply doc/log polish per the assessing- review-feedback pass on PR #74. Decision log committed at `.rivets-044i/review-decisions-round-1.md` (7 accept, 4 modify, 4 reject of 15 findings). Test changes: - I1 `qualified_external_crate_stays_unresolved`: rewrite the negative assertion. The prior filter `reference_name LIKE 'std::%' AND symbol_id IS NOT NULL` is structurally vacuous — `resolve_reference` atomically clears `reference_name` to NULL when setting `symbol_id`, so both predicates can never both hold. New filter joins through the symbols table targeting `s.name IN ('HashMap', 'new')`, which would match if `std::*` had phantom-resolved. - I4 `qualified_crate_prefix_resolves`: lay a phantom-resolution trap at `src/crate/sub_044i.rs` (literal directory name). Change ref shape from a type-position path (`let _: ...`) to a free-fn call so tree- sitter preserves the qualified `reference_name` and the ref actually exercises `qualified_module_fallback` — the prior type-position form was resolving via the unqualified workspace-wide search, bypassing the fallback entirely. With the `prefix[0]==\"crate\"` gate working, the ref binds to `src/sub_044i.rs`; with the gate broken (dropping `\"crate\"` from the matches), it phantom-resolves to the trap. The two new assertions distinguish those cases. TDD-verified. - I2 new test `longest_prefix_wins_over_shorter`: 3-segment ref where both `outer::inner` (deeper) and `outer` (shallower) resolve to files containing tail-matching symbols. The deeper path resolves to a free fn; the shallower path resolves to a method on an `impl inner` whose stored `qualified_name = inner::deep_thing_044i`. Fences the longest-first loop direction. TDD-verified by inverting `.rev()` to forward iteration — test fails. - I3 new test `prefix_resolves_but_tail_missing_stays_unresolved`: `helper.rs` exists with one fn, but the caller references a different fn. Asserts the ref remains unresolved (prefix resolved + tail miss → no phantom bind). - S3 partial: new test `self_and_super_paths_resolve_via_as_written` pins that the `matches!` gate set in `qualified_module_fallback` permits both `self::*` and `super::*` to fire the as-written interpretation. Fixture lays files where tethys's filesystem-walk semantics expect them (`super` from `src/parent/child.rs` reaches `src/cousin.rs`, not `src/parent/cousin.rs`). - S4: drop `eprintln!(\"PROBE 044i state: ...\")` probe-era diagnostics from tests 1 and 2; rephrase \"Pre-fix this will hold\" comments as steady-state regression-fence wording. - S10: replace `indexing.rs:627-630` line-anchor in module doc with the symbol-relative `indexing.rs::store_references` free-fn arm. Production code changes (`crates/tethys/src/resolve.rs`): - I5: extend `qualified_module_fallback` rustdoc with an \"Acceptable ambiguity\" section documenting the bounded same-named file + same- named tail collision class, noting it's the same shape as `fallback_symbol_search`'s same-crate prefix arm per design-v3 C5/C6. - S1: emit `trace!(ref_name, segments, ...)` on the \"all prefixes exhausted\" path before the final `Ok(None)` for forensic replay. - S5: drop redundant `// Interpretation A:` / `// Interpretation B:` inline labels — the rustdoc already enumerates them. - S9: tighten the two `// Load-bearing for correctness:` comments to preserve the WHY (synthetic-ref None propagation, defense-in-depth against future hand-rolled callers) and drop the WHAT-restatements. Rejected findings (rationale in the decision log): - S2 `relative_path` warn-spam — not observable in practice; outside- workspace resolved paths require `resolve_module_path` to return a path it can't currently construct from in-workspace prefixes. - S6 `(*s).to_string()` — clippy doesn't flag it. - S7 closure for two Vec<String> blocks — premature abstraction. - S8 hoist `Option<&Path>` — signature change without simplification. Verification: - `cargo nextest run -p tethys` → 636/636 pass (+4 new fences). - `cargo clippy --all-targets --all-features -- -D warnings` → clean. - TDD inversions: shortest-first loop fails I2; dropped-\"crate\" gate fails I4. Both inversions reverted.
Code Review — fix(tethys): resolve qualified refs from import-less files (rivets-044i)OverviewThis PR closes the qualified-path resolution gap that dn35 (PR #69) explicitly deferred: refs like Production code:
|
Code Review: fix(tethys): resolve qualified refs from import-less files (rivets-044i)OverviewThis PR adds The gilfoyle audit trail ( Implementation (
|
| Test | Shape | Adversarial element |
|---|---|---|
submodule_qualified_call_resolves |
s-submod | — |
workspace_crate_prefixed_call_resolves |
s-wscrate | — |
submodule_shadows_workspace_crate |
s-submod vs s-wscrate | Name collision: same string is both workspace-crate and local submodule |
qualified_external_crate_stays_unresolved |
s-extern | std_helper lookalike submodule to defeat partial-string bugs |
qualified_crate_prefix_resolves |
s-crate | Literal src/crate/sub_044i.rs trap directory to catch broken gate |
longest_prefix_wins_over_shorter |
iteration order | Both outer and outer::inner resolve to different files with same-named symbols |
prefix_resolves_but_tail_missing_stays_unresolved |
tail-miss | Prefix resolves; tail symbol absent in the file |
self_and_super_paths_resolve_via_as_written |
s-self / s-super | As-written arm must fire; implicit-crate gate must suppress retry |
These are proper falsifiable tests — each one would fail under the specific bug class it's designed to detect.
One observation — redundant intermediate assertion in workspace_crate_prefixed_call_resolves:
assert!(
unresolved_refs_in_test == 0 || resolved_to_target >= 1,
...
);
assert!(
resolved_to_target >= 1,
...
);The || assertion is logically subsumed by the strong assertion below it: if resolved_to_target >= 1, both pass; if resolved_to_target == 0 and unresolved_refs_in_test == 0 (ref dropped from DB entirely), the first assertion passes but the second correctly fails. So the intermediate assertion adds no new information and could be removed for clarity. Not a correctness issue.
Documentation note — self::*/super::* filesystem semantics:
The self_and_super_paths_resolve_via_as_written test doc comment correctly calls out that tethys's resolve_super_path walks the filesystem (grandparent directory), which differs from Rust's actual super:: semantics (parent module in the module tree). This is pre-existing behaviour in resolve_module_path, not introduced here. Good that it's documented.
Metrics
| Metric | Pre-fix | Post-fix |
|---|---|---|
| Wall time | 23.25s | 20.81s (-10%) |
| Phantom rate | 0.00% | 0.00% |
| 0gom violations | 0 | 0 |
| Resolved refs | 6756 | 6837 (+81) |
| Unresolved qualified | 1558 | 1493 (-65) |
All regression fences pass. The -10% wall-time improvement is consistent with resolving more refs early (shorter subsequent lookup chains).
Minor note: verification.md reports 5 tests in pass2_qualified_paths.rs, but the file as-committed contains 8. The three additional tests (I2, I3, S3 partial) were added in the round-1 review-decisions commit. The diagnostic directory is point-in-time per convention, so this is expected — just worth noting if you cross-reference the numbers.
CLAUDE.md update
The one-line pass-2 chain description is accurate and appreciably more useful than the prior entry. The symbol-relative anchor (indexing.rs::store_references) avoids the line-range rot problem (S10 applied correctly).
Summary
Approve. The implementation is correct, the test suite is adversarially comprehensive, all prior review feedback has been addressed, and the regression metrics hold. The two notes above (redundant intermediate assertion, cosmetic (*s).to_string() style) are both non-blocking.
Fix a self-inflicted regression introduced in round-1 (commit cc6dd0c). Claude code-review #2 against the round-1 commit caught that the disjunctive assertion I added to `workspace_crate_prefixed_call_resolves` assert!(unresolved_refs_in_test == 0 || resolved_to_target >= 1, ...); is structurally vacuous: whenever the subsequent `resolved_to_target >= 1` assertion would pass, the disjunction is trivially true; whenever it would fail, the disjunction can only pass if `unresolved_refs_in_test > 0` — but then the second assertion fails anyway. The disjunction cannot catch a phantom-resolution that the second assertion misses. Same anti-pattern as the I1 vacuous-filter bug fixed in round-1, just self-inflicted. Replaced with a dedicated no-phantoms query: count refs in `crate_b/tests/it.rs` that bind to a `make_widget_044i` or `Widget` symbol whose target file is NOT `crate_a/src/lib.rs`. The fixture has exactly one definition of each there, so the count must be zero; any nonzero value is a phantom resolution. Other round-2 review items (rationale in `.rivets-044i/review-decisions-round-2.md`): - Reject: `tail` allocation timing (sub-microsecond, reviewer agreed not blocking). - Reject: depth-1 `super::` test (would primarily exercise tethys's `resolve_super_path` filesystem-walk semantics, not the fallback's `matches!` gate — out of scope for this PR). - Reject: Gemini's Vec<&str> rewrite (would not compile; `resolve_module_path` takes `&[String]`). - Reject: Gemini's "single-segment paths should resolve to entry-point file" concern (pre-existing resolver behavior, not reachable as a phantom-resolution vector through `qualified_module_fallback`). Verification: - `cargo nextest run -p tethys --test pass2_qualified_paths` → 8/8 pass. - `cargo clippy --all-targets --all-features -- -D warnings` → clean. - `cargo fmt --check` → clean.
Code Review — fix(tethys): qualified refs from import-less files (rivets-044i)Overall: Approve with minor notes. The algorithm design is correct, the scope is tight, and the adversarial test suite is genuinely thorough. Notes below are mostly informational or stylistic — none block merge. Algorithm CorrectnessThe longest-prefix-first + implicit-crate-first interpretation order correctly implements Rust's scoping rule (submodule shadows extern crate). The The loop direction Test QualityThe adversarial cases are the standout feature of this PR:
One thing worth documenting (may already be noted in the design file but not visible in the diff): the Minor Code Observations
with_crate.extend(prefix.iter().map(|s| (*s).to_string()));
// and
let as_owned: Vec<String> = prefix.iter().map(|s| (*s).to_string()).collect();The explicit deref
let tail = segments[split..].join("::");
// ... later, if file_id is None, we `continue` without using tailRound-2 flagged this (R2-2). The rejection ("sub-microsecond in practice, segments.len() ≤ ~6") is correct. No action needed — just noting it's a known tradeoff. Pre-existing Behavior Note:
|
| Area | Assessment |
|---|---|
| Algorithm correctness | ✅ Sound — longest-prefix-first + implicit-crate-first is the right order |
| Test coverage | ✅ Adversarial fences cover the key failure modes |
| Scope | ✅ One production file changed, additive only |
| Performance | ✅ −10% wall time (counter-intuitive but explained: more early resolutions = less downstream work) |
| Phantom safety | ✅ Layered: per-test + workspace-level phantom-rate gate |
| Style | (*s).to_string() is non-idiomatic (non-blocking) |
| CI | ⏳ Matrix still running — merge after green |
LGTM pending CI green.
Round-3 review-feedback assessment of two new claude-code-review comments (00:38Z and 00:45Z) that reviewed the round-1 state. Two new actions; rest were carryover rejections or already-fixed-in-round-2. R3-1 (claude #3): verification.md said "5 tests" but the file as-committed in round-1 has 8 (the 3 round-1 review-decisions additions: I2, I3, S3-partial). Update the test-count and total-suite-count lines to current state with a brief note crediting the round-1 commit. R3-2 (claude #4): the `super::` filesystem-walk-vs-Rust-spec divergence is documented in the new `self_and_super_paths_resolve_via_as_written` test docstring but had no tracker entry. Per the project's tracker-discipline rule, documented-only deferrals rot in `.<issue-id>/` dirs. Filed rivets-nkjd to track the divergence and updated the test docstring to reference it. Rejected (carryover from prior rounds): - `(*s).to_string()` style: clippy doesn't flag it (round-1 S6 rationale stands; two reviewers' subjective preference doesn't override the linter). - `tail` allocation timing: round-2 R2-2 rationale stands (sub-microsecond). Already fixed: - Disjunctive assertion in `workspace_crate_prefixed_call_resolves`: both reviewers flagged it; round-2 R2-1 fixed it in commit 80167b3. They were reading the round-1 state. Decision log: `.rivets-044i/review-decisions-round-3.md`. Verification: - 8/8 pass2_qualified_paths tests pass. - `cargo clippy --all-targets --all-features -- -D warnings` clean. - `cargo fmt --check` clean.
Pass 2's qualified-name fallback (`get_symbol_by_qualified_name`) does a literal string-equality match against `symbols.qualified_name`, but stored qualified names are module-stripped (free fns: `name`; methods: `parent_name::name` — see `indexing.rs:627-630`). Any ref whose source text carries a module or workspace-crate prefix systematically misses, regardless of whether the target symbol exists in the same workspace. This is the residual gap rivets-dn35 explicitly deferred: dn35 removed the `imports.is_empty()` short-circuit so import-less files could reach fallback, but the qualified-path arm still couldn't bridge stored vs. written qualified-name formats. Fix: `resolve.rs::qualified_module_fallback`. After every prior path returns None for a qualified ref, split `ref_name` at each `::` boundary from longest prefix to shortest. For each prefix, translate to a file_id via `resolver::resolve_module_path` (trying implicit-`crate::` prepend first, then as-written), then query `search_symbol_by_qualified_name_in_file` on the tail. First successful (file_id, symbol) pair wins. Implicit-crate-first preserves Rust's submodule-shadows-extern-crate scoping rule; the `submodule_shadows_workspace_crate` test in `pass2_qualified_paths.rs` is the adversarial fence for that ordering. Slice 1 of 3 (see `.rivets-044i/plan.md`). Slice 2 adds adversarial coverage for external-crate refs and `crate::`-prefixed refs. Slice 3 verifies no regression in the rivets-3d0s phantom rate or rivets-0gom ambiguity violations on the rivets workspace. Tests: - `submodule_qualified_call_resolves` (shape s-submod) - `workspace_crate_prefixed_call_resolves` (shape s-wscrate) - `submodule_shadows_workspace_crate` (interpretation-order fence)
…ets-044i) Slice 2 of 3. Adds two adversarial tests that pin the boundaries of qualified_module_fallback (PR introduction at slice 1): - `qualified_external_crate_stays_unresolved`: ref `std::collections::HashMap` from an import-less file MUST NOT phantom-resolve. The fixture adds a `std_helper` submodule deliberately, so a partial-prefix-match bug (e.g., implicit-crate prepend stumbling into a same-named submodule) would falsely resolve the std ref. Pins the s-extern shape. - `qualified_crate_prefix_resolves`: ref `crate::sub_044i::ThingFour` from an import-less file resolves via the explicit `crate::` arm of `resolve_module_path`. Defeats a regression in the `path[0] == "crate"` short-circuit that prevents the implicit-prepend retry from producing the nonsense `crate::crate::sub::Item` path. Pins the s-crate shape.
Per .<issue-id>/ convention: probes, oracles, design, plan, and per-slice verification artifacts for the rivets-044i fix. Slice 3 verification (rivets workspace, post-fix vs baseline): - Indexing wall time: 20.81s vs 23.25s baseline (−10%, within 1.5× budget) - Phantom rate: 0.00% (claim 7 fence: PASS) - 0gom Section 3 ambiguity: 0 violations (claim 8 fence: PASS) - Resolved refs: +81 (unresolved qualified: −65) - Full workspace test suite: 1532 passed, 0 failed
Tighten regression fences and apply doc/log polish per the assessing- review-feedback pass on PR #74. Decision log committed at `.rivets-044i/review-decisions-round-1.md` (7 accept, 4 modify, 4 reject of 15 findings). Test changes: - I1 `qualified_external_crate_stays_unresolved`: rewrite the negative assertion. The prior filter `reference_name LIKE 'std::%' AND symbol_id IS NOT NULL` is structurally vacuous — `resolve_reference` atomically clears `reference_name` to NULL when setting `symbol_id`, so both predicates can never both hold. New filter joins through the symbols table targeting `s.name IN ('HashMap', 'new')`, which would match if `std::*` had phantom-resolved. - I4 `qualified_crate_prefix_resolves`: lay a phantom-resolution trap at `src/crate/sub_044i.rs` (literal directory name). Change ref shape from a type-position path (`let _: ...`) to a free-fn call so tree- sitter preserves the qualified `reference_name` and the ref actually exercises `qualified_module_fallback` — the prior type-position form was resolving via the unqualified workspace-wide search, bypassing the fallback entirely. With the `prefix[0]==\"crate\"` gate working, the ref binds to `src/sub_044i.rs`; with the gate broken (dropping `\"crate\"` from the matches), it phantom-resolves to the trap. The two new assertions distinguish those cases. TDD-verified. - I2 new test `longest_prefix_wins_over_shorter`: 3-segment ref where both `outer::inner` (deeper) and `outer` (shallower) resolve to files containing tail-matching symbols. The deeper path resolves to a free fn; the shallower path resolves to a method on an `impl inner` whose stored `qualified_name = inner::deep_thing_044i`. Fences the longest-first loop direction. TDD-verified by inverting `.rev()` to forward iteration — test fails. - I3 new test `prefix_resolves_but_tail_missing_stays_unresolved`: `helper.rs` exists with one fn, but the caller references a different fn. Asserts the ref remains unresolved (prefix resolved + tail miss → no phantom bind). - S3 partial: new test `self_and_super_paths_resolve_via_as_written` pins that the `matches!` gate set in `qualified_module_fallback` permits both `self::*` and `super::*` to fire the as-written interpretation. Fixture lays files where tethys's filesystem-walk semantics expect them (`super` from `src/parent/child.rs` reaches `src/cousin.rs`, not `src/parent/cousin.rs`). - S4: drop `eprintln!(\"PROBE 044i state: ...\")` probe-era diagnostics from tests 1 and 2; rephrase \"Pre-fix this will hold\" comments as steady-state regression-fence wording. - S10: replace `indexing.rs:627-630` line-anchor in module doc with the symbol-relative `indexing.rs::store_references` free-fn arm. Production code changes (`crates/tethys/src/resolve.rs`): - I5: extend `qualified_module_fallback` rustdoc with an \"Acceptable ambiguity\" section documenting the bounded same-named file + same- named tail collision class, noting it's the same shape as `fallback_symbol_search`'s same-crate prefix arm per design-v3 C5/C6. - S1: emit `trace!(ref_name, segments, ...)` on the \"all prefixes exhausted\" path before the final `Ok(None)` for forensic replay. - S5: drop redundant `// Interpretation A:` / `// Interpretation B:` inline labels — the rustdoc already enumerates them. - S9: tighten the two `// Load-bearing for correctness:` comments to preserve the WHY (synthetic-ref None propagation, defense-in-depth against future hand-rolled callers) and drop the WHAT-restatements. Rejected findings (rationale in the decision log): - S2 `relative_path` warn-spam — not observable in practice; outside- workspace resolved paths require `resolve_module_path` to return a path it can't currently construct from in-workspace prefixes. - S6 `(*s).to_string()` — clippy doesn't flag it. - S7 closure for two Vec<String> blocks — premature abstraction. - S8 hoist `Option<&Path>` — signature change without simplification. Verification: - `cargo nextest run -p tethys` → 636/636 pass (+4 new fences). - `cargo clippy --all-targets --all-features -- -D warnings` → clean. - TDD inversions: shortest-first loop fails I2; dropped-\"crate\" gate fails I4. Both inversions reverted.
Fix a self-inflicted regression introduced in round-1 (commit cc6dd0c). Claude code-review #2 against the round-1 commit caught that the disjunctive assertion I added to `workspace_crate_prefixed_call_resolves` assert!(unresolved_refs_in_test == 0 || resolved_to_target >= 1, ...); is structurally vacuous: whenever the subsequent `resolved_to_target >= 1` assertion would pass, the disjunction is trivially true; whenever it would fail, the disjunction can only pass if `unresolved_refs_in_test > 0` — but then the second assertion fails anyway. The disjunction cannot catch a phantom-resolution that the second assertion misses. Same anti-pattern as the I1 vacuous-filter bug fixed in round-1, just self-inflicted. Replaced with a dedicated no-phantoms query: count refs in `crate_b/tests/it.rs` that bind to a `make_widget_044i` or `Widget` symbol whose target file is NOT `crate_a/src/lib.rs`. The fixture has exactly one definition of each there, so the count must be zero; any nonzero value is a phantom resolution. Other round-2 review items (rationale in `.rivets-044i/review-decisions-round-2.md`): - Reject: `tail` allocation timing (sub-microsecond, reviewer agreed not blocking). - Reject: depth-1 `super::` test (would primarily exercise tethys's `resolve_super_path` filesystem-walk semantics, not the fallback's `matches!` gate — out of scope for this PR). - Reject: Gemini's Vec<&str> rewrite (would not compile; `resolve_module_path` takes `&[String]`). - Reject: Gemini's "single-segment paths should resolve to entry-point file" concern (pre-existing resolver behavior, not reachable as a phantom-resolution vector through `qualified_module_fallback`). Verification: - `cargo nextest run -p tethys --test pass2_qualified_paths` → 8/8 pass. - `cargo clippy --all-targets --all-features -- -D warnings` → clean. - `cargo fmt --check` → clean.
Round-3 review-feedback assessment of two new claude-code-review comments (00:38Z and 00:45Z) that reviewed the round-1 state. Two new actions; rest were carryover rejections or already-fixed-in-round-2. R3-1 (claude #3): verification.md said "5 tests" but the file as-committed in round-1 has 8 (the 3 round-1 review-decisions additions: I2, I3, S3-partial). Update the test-count and total-suite-count lines to current state with a brief note crediting the round-1 commit. R3-2 (claude #4): the `super::` filesystem-walk-vs-Rust-spec divergence is documented in the new `self_and_super_paths_resolve_via_as_written` test docstring but had no tracker entry. Per the project's tracker-discipline rule, documented-only deferrals rot in `.<issue-id>/` dirs. Filed rivets-nkjd to track the divergence and updated the test docstring to reference it. Rejected (carryover from prior rounds): - `(*s).to_string()` style: clippy doesn't flag it (round-1 S6 rationale stands; two reviewers' subjective preference doesn't override the linter). - `tail` allocation timing: round-2 R2-2 rationale stands (sub-microsecond). Already fixed: - Disjunctive assertion in `workspace_crate_prefixed_call_resolves`: both reviewers flagged it; round-2 R2-1 fixed it in commit 80167b3. They were reading the round-1 state. Decision log: `.rivets-044i/review-decisions-round-3.md`. Verification: - 8/8 pass2_qualified_paths tests pass. - `cargo clippy --all-targets --all-features -- -D warnings` clean. - `cargo fmt --check` clean.
305ca2c to
9bfae53
Compare
Code Review: fix(tethys): resolve qualified refs from import-less files (rivets-044i)OverviewThis PR closes the qualified-path resolution gap that rivets-dn35 (PR #69) explicitly deferred. Import-less files now reach The full gilfoyle workflow (probe → falsifiable design → budgeted plan → checkpointed build) is present in Production code (
|
| Test | Shape defended |
|---|---|
submodule_qualified_call_resolves |
s-submod — implicit-crate interpretation |
workspace_crate_prefixed_call_resolves |
s-wscrate — as-written workspace-crate arm |
submodule_shadows_workspace_crate |
adversarial — interpretation order must honor submodule-shadows-extern-crate |
qualified_external_crate_stays_unresolved |
s-extern — must not phantom-resolve; std_helper lookalike defeats partial-string-match bugs |
qualified_crate_prefix_resolves |
s-crate — filesystem trap src/crate/sub_044i.rs pins the prefix[0]=="crate" skip gate |
longest_prefix_wins_over_shorter |
loop order — two competing matching files, correct binding requires reverse iteration |
prefix_resolves_but_tail_missing_stays_unresolved |
graceful non-resolution when prefix resolves but tail symbol absent |
self_and_super_paths_resolve_via_as_written |
s-self / s-super — as-written arm; documents known super:: filesystem-walk divergence, now tracked as rivets-nkjd |
Strengths
- Every test includes a negative phantom-detection assertion alongside the positive resolution assertion. This is stronger than simple "did it resolve?" checks — it verifies where the ref binds. The
workspace_crate_prefixed_call_resolvesfix from R2-1 (replacing the vacuous disjunction with af_target.path != 'crate_a/src/lib.rs'phantom query) is correct and important. - Oracle precondition checks (
definition_exists == 1,other_exists == 1) prevent false negatives by confirming the fixture is indexed as expected before asserting resolution outcome. - The
longest_prefix_wins_over_shorterfixture is notably clever: it constructs a workspace where both a shorter and a longer prefix resolve, and uses distinct target files and symbol types (method vs. free fn) to prove which one won. Loop-order bugs cannot hide here. - The
qualified_crate_prefix_resolvestrap directory (src/crate/sub_044i.rs) is exactly the right adversarial fixture for theprefix[0]=="crate"gate.
One gap worth noting
The self_and_super_paths_resolve_via_as_written test documents a pre-existing super:: semantic divergence (tethys walks to grandparent directory; Rust spec says parent's parent module). The test is written to match tethys's actual behavior — correctly — and the divergence is now tracked in rivets-nkjd. When rivets-nkjd closes, this fixture will need updating. The docstring says as much, which is the right pattern. Just flagging it so reviewers are not surprised when the super_resolved assertion someday starts testing a different file path.
Performance & regressions
The PR table shows a −10% indexing wall time improvement post-fix. The explanation (resolving more refs earlier trims downstream work) is plausible and the data is backed by hyperfine output in .rivets-044i/. Phantom rate stays at 0.00%, ambiguity violations stay at 0 — both fences pass as expected.
The design acknowledges the per-ref syscall budget exceeds the 10^3 cap by ~10× with the justification that .exists() calls are OS-cached after the first access. This is a reasonable approximation for warm-cache indexing (the dominant production use case), but cold-cache first-run scenarios (CI agents, fresh clones) could see a higher relative cost. That optimization is deferred to rivets-bjdn.
Audit trail quality
The .rivets-044i/ directory is the strongest part of this PR. The oracle/probe separation, falsification table with run status, three rounds of review decisions with clear accept/reject rationale, and the commitment to filing tracker entries (R3-2 → rivets-nkjd) rather than leaving undocumented deferrals — all of this reflects the standard the CLAUDE.md diagnostic-directory convention sets, executed well.
Summary
This is a clean, well-bounded fix with excellent test coverage and a rigorous audit trail. The three minor observations above are all non-blocking; the production code is correct and the test suite is meaningfully adversarial. Approve.
Summary
helper::do_thingorcrate_a::Widget::methodfrom import-less files now resolve via a newqualified_module_fallbackin Pass 2..rivets-044i/.The bug
Stored
symbols.qualified_nameis module-stripped (indexing.rs:627-630: free fns storename; methods storeparent_name::name). The existing Pass 2 qualified-name fallback (get_symbol_by_qualified_name) does a literal string-equality match, so any ref whose source text carries a module or workspace-crate prefix systematically misses — regardless of whether the target symbol is indexed.This was the residual gap dn35 deferred: dn35 removed the
imports.is_empty()short-circuit so import-less files reached fallback, but the qualified-path arm still couldn't bridge stored vs. written qualified-name formats.The fix
resolve.rs::qualified_module_fallback(new private method, ~80 lines). After every prior path returns None for a qualified ref:ref_nameat each::boundary from longest prefix to shortest.file_idviaresolver::resolve_module_path, trying:crate::, skipped whenpath[0]is alreadycrate/self/super) — handles Rust 2018+ implicit-crate-relative paths likehelper::foo.resolve_module_path'scrate/self/super/workspace-crate arms fire.search_symbol_by_qualified_name_in_fileon the tail.(file_id, symbol)pair wins.The implicit-crate-first order preserves Rust's submodule-shadows-extern-crate scoping rule — pinned by the
submodule_shadows_workspace_cratetest.Slices
qualified_module_fallback+ 3 tests covering s-submod, s-wscrate, and the submodule-shadows-extern-crate adversarial fence. CLAUDE.md updated to reflect post-dn35 + post-044i Pass-2 chain.qualified_external_crate_stays_unresolved(pins s-extern boundary —std::*must not phantom-resolve) andqualified_crate_prefix_resolves(pins thepath[0] == "crate"short-circuit)..rivets-044i/diagnostic dir (probe, oracle, design, plan, verification). No production code change.Verification (rivets workspace)
The post-fix wall time is faster than baseline because resolving more refs earlier trims downstream work.
Falsifiable design
All 8 design claims and their falsifiers are documented in
.rivets-044i/design.md. Each claim has a regression fence — either a new test inpass2_qualified_paths.rsor an existing CI fence (file_deps_corroboration.rs,resolver_routing.rs,pass2_no_imports.rs).Test plan
cargo nextest run— 1532/1532 pass workspace-widecargo clippy --all-targets --all-features -- -D warnings— cleanfile_deps_corroboration(2 tests),resolver_routing(4 tests),pass2_no_imports(1 test)