Credit a cross-module field read through a bound name - #95
Open
mpecan wants to merge 1 commit into
Open
Conversation
`allium.field.unused` reported a field whose only reader reads it across a `use` edge through a bound name rather than through the qualified spelling. A surface writes `context shelf: alias/Shelf` and then reads the relationship as `shelf.copies`; `alias/Shelf.copies` never appears, so `collect_reverse_contributions` credited nothing and the imported module reported a field its only reader iterates. The root cause is that the reverse channel credited one spelling of a field read. `collect_qualified_field_refs` matches `alias/Entity.field` at the reference site, which is the spelling an importer uses when it names the entity — and not the one it uses when something has already bound it. A surface's `context`/`facing` binding and a rule's trigger parameter are both such bindings, and both are the ordinary way an importer reads an imported entity. Two additions, both reusing what the file already has: - `collect_bound_field_refs` walks with `walk_expr_children` and credits `bound.field` for names typed to an imported entity, with a `_from_item` sibling alongside the seven that already exist. - The binding sets come from `qualified_context_binding` and `collect_importer_command_param_types` unchanged, passed a map of every imported entity instead of `status_by_entity`. Both read that map for membership only; it carries the status-bearing entities because its other consumers are lifecycle checks, and a field read is worth crediting whatever the entity's lifecycle. `rule_trigger_bindings` then resolves a rule's `when:` positionally against that table, the same resolution `collect_witnessed_transition` already does for its own clause. Crediting stays gated on a real `use` edge and on the binding's type resolving to a declared imported entity; it is not widened to arbitrary co-supply. The new path only ever fires on a member access, so every name it yields is one the module-local collector would yield on the same expression, intersected with the imported entity's declared fields — it cannot credit anything the equivalent merged single file does not. Not covered, and deliberately: a read through a loop binding derived from a bound name (`for c in shelf.copies: c.title` credits `copies` but not `title`), and a chained read (`message.group.members`), whose object is itself a member access rather than a name. Both need field-type resolution across the boundary, which is a larger change than this one. Both reproduce on `main` with the qualified spelling too, so neither is introduced here. Six parser unit tests and six integration tests in `cross_module_lifecycle.rs`, including the merged-single-file control, the imported module analysed alone, the no-`use`-edge gate, and a precision guard that an unread field of a bound entity is still reported. Each of the four new decisions — the `facing` keyword, the rule channel, the entity-existence guard and the type refinement — was mutated and is killed by exactly one test. Workspace suite 658 passed, clippy warning set unchanged from `main`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Credit a cross-module field read through a bound name
Problem
allium.field.unusedfires on a field whose only reader reads it across auseedge through a bound name rather than through the qualified spelling. A surface
writes
context shelf: alias/Shelfand then reads the relationship asshelf.copies;alias/Shelf.copiesnever appears anywhere, socollect_reverse_contributionscredits nothing and the imported module reports afield that its only reader iterates.
Because
checkexits 1 on warnings, this fails any gate keyed on the exit status,on valid input. Found downstream in a repo that had to raise a ratcheting debt
baseline to accommodate two of these.
Root cause
The reverse channel credits one spelling of a field read.
collect_qualified_field_refsmatchesalias/Entity.fieldat the reference site.That is the spelling an importer uses when it names the entity — and not the one
it uses once something has already bound it.
Two ordinary constructs bind it. A surface's
context/facingclause, and arule's trigger parameter typed by the surface that provides that trigger. Both are
the normal way an importer reads an imported entity, and neither leaves a qualified
reference behind for the existing pass to see.
This is the
field.unusedanalogue of #65: the collector was built for thequalified form and never re-enumerated the ways a name can come to denote an
imported entity.
Fix
Two additions, both reusing machinery that is already here.
The read.
collect_bound_field_refscreditsbound.fieldfor names typed toan imported entity, walking with the existing
walk_expr_childrenrather thanduplicating the expression match. Its
_from_itemsibling joins the seven thatalready exist in this file.
The bindings. These come from
qualified_context_bindingandcollect_importer_command_param_typesunchanged — passed a map of everyimported entity in place of
status_by_entity. Both helpers read that map formembership only; it carries just the status-bearing entities because its other
consumers are lifecycle checks, and a field read is worth crediting whatever the
entity's lifecycle.
rule_trigger_bindingsthen resolves a rule'swhen:positionally against that table — the same resolution
collect_witnessed_transitionalready performs for its own clause.
No existing collector is modified.
collect_qualified_field_refsis byte-identicalto
main.Why this cannot over-credit
Crediting stays gated on a real
useimport edge and on the binding's typeresolving to a declared imported entity — never arbitrary co-supply.
Beyond that gate, the new path only ever fires on an
Expr::MemberAccess/OptionalAccess, so every name it yields is one the module-local collector wouldyield on the same expression, then intersected with the imported entity's declared
field names. It cannot credit anything the equivalent merged single file does not
already credit.
bound_read_matches_the_merged_single_file_controlasserts exactlythat, and is the test to read first.
The module-wide (rather than per-entity)
imported_field_namesset is unchangedfrom the existing qualified path, deliberately: making the new path stricter would
break the merged-file oracle in the other direction.
Not covered, and deliberately
Both need field-type resolution across the boundary, which is a larger change than
this one, and both reproduce on
mainwith the qualified spelling too — neitheris introduced here.
for c in shelf.copies: c.titlecreditscopies, nottitle.message.group.members, whose object is itself a memberaccess rather than a name.
In the downstream repo this means three false reports become one. Happy to file
these as an issue if you'd like them tracked.
Tests
Six parser unit tests and six integration tests in
cross_module_lifecycle.rs,placed with their siblings. The integration set asserts the three oracles this file
already holds everything else to:
useedge, a co-supplied file's bound read credits nothing.Plus a precision guard: crediting one field of a bound entity leaves that entity's
unread fields still reported.
Each of the four new decisions — the
facingkeyword, the rule channel, theentity-existence guard, and the type refinement — was mutated individually and is
killed by exactly one test.
Verification
cargo test --workspace: 658 passed, 0 failed.cargo clippy --workspace --all-targets: warning multiset identical tomain(diffed, not sampled).
allium check docs/project/specs/: coded diagnostics identical tomain,per code. The behaviour spec gains two rules in the reverse-aggregation section.
cargo fmt --checkdoes not pass on thisfile on
maineither, and CI runs neitherfmtnorclippy, so I have matchedthe neighbouring style rather than reformatted regions I did not otherwise touch.
Version bump left to land separately after merge, per the usual flow here.