Skip to content

Implement ADR-0078: single-candidate import resolution - #2531

Merged
steveklabnik merged 10 commits into
trunkfrom
claude/decisionmodules-import-fallback-2d2gq9
Aug 18, 2026
Merged

Implement ADR-0078: single-candidate import resolution#2531
steveklabnik merged 10 commits into
trunkfrom
claude/decisionmodules-import-fallback-2d2gq9

Conversation

@steveklabnik

@steveklabnik steveklabnik commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Implements ADR-0078 (RUE-1127), all five phases. The ruling was recorded in #2522; this is the language change itself.

The change

Policy version 2. Every @import specifier owns exactly one candidate path:

  • Importer-relative only. The root-directory fallback is gone, so adding a nearer file can no longer silently retarget an unchanged import.
  • Extensionless means facade. @import("foo") names foo/_foo.rue alone; a file module is spelled @import("foo.rue"). Both forms may coexist — the ambiguity is deleted, not diagnosed.
  • std anchors to the program. Vendored {root}/std/_std.rue, then the toolchain root. Ambient environment can no longer replace a standard library the program ships.
  • Project-root identity is total. A relative import whose normalized candidate leaves the root is E0713, derived lexically with no filesystem probe.

The core is ~40 lines in discovery_candidate_groups / discovery_groups_for_occurrence; the rest is consequence.

Two rulings settled after the ADR merged

--std-path is deferred, not shipped. No present workflow needs a deliberate-override channel — every harness overrides through the environment against non-vendoring programs — and CLI surface shipped now is surface RUE-1586's distribution design must later honor or break. Adding a flag later is additive. The chain is vendored → env → E0705.

Under a manifest, the manifest decides which std is in the build. A candidate the manifest does not declare is skipped and the walk continues: declare the vendored copy and it wins, omit it and the declared toolchain facade resolves.

This narrows an earlier form of the ruling that required an undeclared-but-present vendored std to fail closed. That rule is not implementable as stated, and CI proved it: hermetic denial is lexical and takes no probe — by design, so a hermetic build never touches an undeclared path — so the compiler cannot distinguish "absent" from "present but undeclared". Treating the denial as conclusive failed every hermetic build whose program does not vendor std at all, because the vendored candidate is probed first and denied even when nothing is there. The repository's own reproducible-programs fixture is exactly that shape. Preserving the stricter rule would require statting undeclared candidates, weakening the no-probe guarantee to sharpen a case a correct manifest never reaches.

Consequently a failed observation is conclusive only when no candidate in the occurrence's chain resolved. Under policy v2 only std has more than one candidate, so this is confined to the std chain; every relative specifier still fails on its single candidate's failure. Denied and absent remain distinguishable typed observations per ADR-0063 — they are merely both non-resolutions for precedence purposes.

E0708 retired

With one candidate per group a group can never accept two sources, so the ambiguity surface is structurally unreachable. Removed end to end rather than left dead: CanonicalImportResolution::Ambiguous, AmbiguousResolution, the winner and diagnostic arms, ImportBindingFailure::Ambiguous and its AIR projection, the envelope outcome, and ErrorKind::AmbiguousModule. E0708 stays a retired number per the registry convention.

Verified against a built compiler

Every hazard the ADR cites, re-run after the change:

Behavior Before After
Nearer file added, no source edited silently retargets (7 → 42) root sibling is not a candidate
Alternate module form added working program → E0708 both forms coexist, facade resolves
sub/std/_std.rue beside importer, env unset captures the stdlib (→ 13) ignored; E0705
Vendored std, nested importer worked only via the fallback works from any depth
@import("../outside.rue") accepted, identity "../outside.rue" E0713

Plus the three manifest arms: no vendored std compiles; undeclared vendored std falls through to the declared toolchain std; declared vendored std wins.

Suites: compiler units 901, AIR 653, driver 45, rue 201, spec 2350, CLI integration 1891, and the premerge tier 194/194.

Migration

Zero real-code changes. dijkstra, calculator and tinydb were facade imports all along, so they compile untouched — the ADR's estimate of three affected files was wrong in the safe direction. The churn is ~280 fixture sites that exercised the policy deliberately.

Three fixture classes needed judgment rather than mechanical rewriting, all flagged in commit messages:

  • RUE-317's ../ reconciliation cases are superseded. They pinned that an escaping import reconciles with a CLI-listed file; total root-relative identity forbids exactly that. Replaced with an E0713 pin.
  • Root-relative intra-facade spellings were masked bugs. Fixtures where outer/_outer.rue imported "outer/inner.rue" only resolved through the fallback; they are importer-relative now.
  • Two fixtures were purpose-built on the fallback (reproducibility/semantic-order, fixtures/rue-program/hello) and said so in their comments. They now name their paths explicitly, keeping the contracts they exist to pin. A static audit over every .rue file outside crates/ confirms no fallback-dependent import remains.

Spec

Amended under the permanent-ID law — every touched rule reworded under its stable ID, never renumbered. 10.2:1 (one candidate), 10.2:2 (one base), 10.2:4 (canonical identity), 10.2:6 (program-anchored std), new 10.2:7 (total identity), 4.13:89 (escape legality), 10.1:5 (coexistence). ADR-0026 marked amended-by: [0078] where it stated the E0708 ambiguity.

New coverage

Importer-relative-only locality, negative→positive at the single candidate, vendored std serving a nested importer while an adjacent sub/std/_std.rue stays inert, escape rejection, the migration help, and the undeclared-vendored-std skip.

Closes RUE-1127.

claude added 8 commits August 18, 2026 18:36
Two post-acceptance rulings, folded into Decision 4:

The --std-path flag is deferred to RUE-1586 rather than shipped. No present
workflow needs a deliberate override channel - every harness in this
repository overrides through the environment against non-vendoring
programs - and CLI surface shipped now is surface the distribution design
must later honor or break. Adding a flag later is additive. The std chain
is therefore vendored {root}/std/_std.rue, then $RUE_STD_PATH, then E0705.

Under --source-manifest, an existing but undeclared vendored std is a
hermetic denial error, never treated as absence. The alternative (manifest
as world, falling through to the declared env std) would let dev and
manifest builds of one tree silently resolve std to different files, which
is the positive-to-different-positive divergence this ADR exists to kill,
and would collapse the denied/absent distinction ADR-0063 invalidation
depends on. Phase 2 pins this with a test; no behavior change is expected.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
Policy version 2 (ADR-0078). Relative specifiers search one base, the
importing file's directory: the root-directory fallback is gone, so
adding a nearer file can no longer retarget an unchanged import.
Extensionless specifiers name the directory facade alone; a file module
is spelled with its extension, which deletes the file/facade ambiguity
group instead of diagnosing it. std anchors to the program: the vendored
{root}/std/_std.rue precedes the captured toolchain root, so ambient
environment never replaces a standard library the program ships.

A relative import whose single normalized candidate leaves the project
root derives no filesystem request and is rejected as E0713, making
project-root-relative module identity a total function. The diagnostic
is recomputed from the parsed program and captured context by the
canonical diagnostic projection; on the demand path the binding is a
first-class Missing terminal. Trusted standard-library importers resolve
within their own root and are exempt.

Unit tests that encoded the old group shapes move to the new policy;
the ambiguity tests become deterministic-facade tests, and the
two-leaf provenance test becomes a single-leaf test that pins the
sibling file-module spelling as inert.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
The wave-hop carry test exercised its answered-but-open contract through
the root-relative fallback, which policy v2 removes. The std chain is
the remaining multi-group occurrence: the vendored candidate probed
absent leaves the occurrence owing the toolchain facade an operation,
pinning the same carry contract.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
With one candidate per group, a group can never accept two sources, so
the ambiguity surface is structurally unreachable. Delete it end to end
rather than leaving a dead path: CanonicalImportResolution::Ambiguous,
CanonicalImportGraphProblem::AmbiguousResolution, the winner and
diagnostic arms, ImportBindingFailure::Ambiguous and its AIR
ImportResolution::Ambiguous projection, the envelope's ambiguous
outcome, and ErrorKind::AmbiguousModule with its boxed payload. E0708
stays a retired number per the registry convention.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
ADR-0078 Phase 4. When an extensionless import reports E0704 and the
sibling {P}.rue exists on disk, the CLI appends a help naming the
extensioned spelling. The probe is presentation-only: it runs after
discovery has closed, feeds no observation ledger, and creates no
dependency edge, so it can never affect resolution, closure, or reuse.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
ADR-0078 Phase 1, under the permanent-ID law: every touched rule is
reworded under its stable ID, never renumbered.

10.2:1 names exactly one candidate per path: extensionless resolves to
the directory facade alone, and a file module requires its extensioned
spelling, so both forms coexist without ambiguity. 10.2:2 searches one
base, the importing file's directory - no root-file fallback. 10.2:4
states canonical identity as the normalized project-root-relative path.
10.2:6 replaces the env-first std search with the program-anchored
chain: vendored {root}/std/_std.rue, then $RUE_STD_PATH; std is a
reserved specifier, never searched importer-relative. New 10.2:7 makes
project-root identity total. 4.13:89 becomes the escape legality rule
(E0713). 10.1:5 states the coexistence rule and retires E0708.

Every extensionless file-module example across the spec gains its
extension; directory-facade examples stay extensionless. ADR-0026 is
marked amended-by 0078 where it stated the E0708 ambiguity.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
Mechanical migration: every extensionless import of a file module across
the spec, CLI, and ui fixtures gains its extension; directory-facade
imports stay extensionless. Intra-facade imports that spelled paths
root-relatively (outer/inner.rue from inside outer/) become
importer-relative (inner.rue), which the removed fallback had masked.

Policy fixtures are rewritten rather than deleted:
- the root-relative-fallback case becomes no_root_relative_fallback
  (E0704) plus an explicit ../-spelling case that stays inside the root;
- the E0708 ambiguity cases become coexistence pins: extensionless
  resolves the facade, extensioned resolves the file, deterministically;
- the RUE-317 dotdot reconciliation cases collapse into one E0713 pin -
  an escaping import is rejected even when its target exists;
- emit-deps expectations track normalized specifiers with extensions and
  the complete facade resolution where ambiguity used to fail.

New coverage for the ADR-0078 acceptance: importer-relative-only
locality (nearer file is the ONLY candidate), negative-to-positive at
the single candidate path, vendored std serving a nested importer while
an adjacent sub/std/_std.rue stays inert (10.2:6), escape rejection
citing 10.2:7/4.13:89, the migration help naming the extensioned
spelling, and the undeclared-vendored-std hermetic fail-closed denial.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
@steveklabnik
steveklabnik marked this pull request as ready for review August 18, 2026 19:54
@steveklabnik
steveklabnik enabled auto-merge August 18, 2026 19:54
claude added 2 commits August 18, 2026 20:13
A candidate that could not be read is not a resolution: the precedence
walk continues to the next candidate, and a failed observation is
reported only when no candidate in the occurrence's chain resolved.

This fixes a regression this branch introduced. Under --source-manifest,
hermetic denial is lexical and takes no probe - by design, so a hermetic
build never touches an undeclared path - so the compiler cannot tell
"absent" from "present but undeclared". With the vendored std candidate
probed first, treating its denial as conclusive failed EVERY hermetic
build whose program does not vendor std at all: the candidate is denied
even when nothing is there. The repository's own reproducible-programs
fixture is exactly that shape, and CI caught it.

The manifest is therefore the authority on which standard library is in
the build: declare the vendored copy and it wins, omit it and the
declared toolchain facade resolves. Preserving the stricter rule would
require statting undeclared candidates, weakening the no-probe guarantee
to sharpen a case a correct manifest never reaches.

Under policy v2 only std has more than one candidate, so the rule is
confined to the std chain; every relative specifier still fails on its
single candidate's failure. Denied and absent remain distinguishable
typed observations per ADR-0063 - they are merely both non-resolutions
for precedence purposes.

The semantic-order reproducibility fixture leaned on the removed
root-directory fallback to reach its shared module; it now names the
path explicitly, keeping the FileId-ordering contract it exists to pin.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
The rue_scan fixture reached its shared module through the root-directory
fallback ADR-0078 removes, and its comment documented that dependency.
Name the path explicitly; the scan contract it pins is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoHS41MNhkoMdqfeNqPrja
@steveklabnik
steveklabnik added this pull request to the merge queue Aug 18, 2026
Merged via the queue into trunk with commit f0dcdb9 Aug 18, 2026
26 checks passed
@steveklabnik
steveklabnik deleted the claude/decisionmodules-import-fallback-2d2gq9 branch August 18, 2026 20:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants