Skip to content

fix(types): reject bare/qualified nominal collision by owner identity#2663

Merged
slepp merged 5 commits into
mainfrom
fix/2651-nominal-equality
Jul 15, 2026
Merged

fix(types): reject bare/qualified nominal collision by owner identity#2663
slepp merged 5 commits into
mainfrom
fix/2651-nominal-equality

Conversation

@slepp

@slepp slepp commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

The type checker now rejects a bare nominal type and a qualified nominal type as distinct when they resolve to different owning modules, closing a hole where Widget (defined locally) and pkg.Widget (imported) could unify as the same type and reach LLVM codegen with two conflicting struct layouts. The new check runs entirely inside the checker's expect_type coercion boundary, reports the ordinary type-mismatch diagnostic, and rewrites no stored type names, so it introduces no new interaction with inference, substitution, or the checker→HIR/MIR handoff. Legitimate aliasing — selected imports, callee-frame bare returns, self-qualified same-module references, and known builtin bare/qualified pairs — continues to unify as before.

Verification

  • make test-pkg-import: passed — all package-import fixtures, including the new collision, self-qualified, and callee-frame-alias regressions
  • make hew-check-all: passed — 1,587 checked, 254 intentional rejects skipped, 133/133 expected vs. actual failures
  • make test-lane CRATE=hew-types: 2,524 passed, 3 skipped
  • target/debug/hew check tests/vertical-slice/accept/tls_active_attach.hew: passed
  • make ci-preflight: passed (comprehensive profile)

Out of scope

  • Package registration collision handling (fix(types): preserve package-local bare type identity #2626) is a separate resolver-level concern and is not touched here.
  • Vec admission behavior (types: converge checker and MIR Vec element admission #2647) is unrelated to nominal type identity and is left for its own fix.
  • The owner-conflict check currently gates the expect_type coercion path only; other direct unification entrypoints (method-receiver, ?-operator error types, generic inference) still fall back to the pre-existing codegen fail-closed behavior rather than this checker diagnostic. Widening coverage to those paths is a follow-on, not a correctness gap — codegen still fails closed on any uncovered collision.

Fixes #2651

slepp added 5 commits July 14, 2026 21:30
A root-local type and an imported type that share a bare final segment
(a root-local `Widget` vs an imported `widgeti8.Widget`) were conflated
by `Ty::names_match_qualified`, the context-free suffix compare used
inside `unify`. That primitive accepts any bare↔qualified pair sharing a
final segment, so the local value flowed into the import's layout and the
mismatch surfaced only downstream as an `E_CODEGEN_FRONT` LLVM-verify
fail-closed instead of a checker diagnostic (issue #2651).

Add a compare-only gate at the `expect_type` boundary,
`reject_nominal_owner_conflict`, that fires exactly when the permissive
suffix rule would ACCEPT a pairing that owner-qualified definition
identity REJECTS. It is purely read-only — it rewrites no stored name and
binds no inference variable, so the canonical spelling never leaks into a
`Subst` binding or the checker→HIR/MIR name handoff (the record-layout
registry keeps single-module references bare on purpose). It fires only
when both sides are fully concrete after substitution, so an already-bound
generic `T` inferred to a callee-frame type is seen as the type it denotes.

`canonical_nominal_name` resolves a bare name to its UNIQUE registered
owner (mirroring the `resolved_name` resolution cascade, read-only) and
`strict_nominal_identity` additionally strips a redundant current-module
self-qualifier, so genuine same-def aliases still compare equal: a callee
frame's bare `Box` vs `nestbox.Box`, a prelude `MonitorError` vs
`link_monitor.MonitorError`, a builtin `HashSet` vs `collections.HashSet`,
and a machine referenced both bare (`Lifecycle`) and through its own module
(`lifecycle.Lifecycle`). A real structural mismatch is not suffix-equal and
is left for `unify` to report, preserving its dyn-trait and `LocalPid`
coercion-recovery paths.

The permissive primitive is kept unchanged (its unit tests still hold) so
every legitimate non-local bare-name alias keeps unifying leak-free; the
owner discrimination lives entirely in the new checker-side gate, which is
the only place with the resolution tables needed to tell a same-def alias
from a cross-owner collision. This deviates from the plan's original
"narrow the primitive" mechanism, which empirically broke those legit
non-local aliases (prelude types, callee-frame bare returns, generic
inference) that cannot be leak-free canonicalized before unification.

Reuses the owner-qualified identity precedent from
`canonicalize_type_identity` (trait-signature comparison) without touching
any shared resolution helper (`published_bare_type_qualified` is read, not
modified), so the #2626 package-collision lane stays independent.
Add three fixtures pinning the issue #2651 boundary:

- local_shadow_import_divergent_reject: a root-local `Widget { v: i64 }`
  passed where the imported `widgeti8.Widget { v: i8 }` is required. This is
  the headline case that previously reached codegen as an
  `E_CODEGEN_FRONT` LLVM-verify fail-closed; the harness asserts a clean
  nominal type-mismatch diagnostic AND the absence of any codegen-front/D10
  fall-through.
- local_shadow_import_same_layout_reject: the same shadow against
  `widgeti64.Widget { v: i64 }`, an identical layout. Proves the gate
  rejects on DEFINITION IDENTITY, not on a layout heuristic that would
  wrongly accept a byte-compatible collision.
- bare_import_alias_accept: an opt-in `import hew::widgeti8::{ Widget }`
  where the bare `Widget` and the qualified `widgeti8.Widget` denote ONE
  definition; a value of either spelling satisfies a parameter typed with
  the other. Guards that the gate does not over-reject a legal alias.
Add two end-to-end regressions closing coverage gaps in the issue #2651
owner-identity gate, each verified to fail without the corresponding
carve-out:

- self_qualified_type_identity: module `selfqualtype` references its OWN
  type `Meter` both bare (`build`'s return) and through its own module
  qualifier (`read`'s `selfqualtype.Meter` parameter). `roundtrip` crosses
  the bare value into the self-qualified parameter with the current module
  set to `selfqualtype`, so the gate compares bare `Meter` against
  `selfqualtype.Meter`. This is the type-level equivalent of a generic
  machine's self-qualified transition (the stdlib `Lifecycle<T>` case),
  reached deterministically without machine plumbing. Reverting the
  current-module self-qualifier strip in `strict_nominal_identity`
  reintroduces a false `type mismatch: expected selfqualtype.Meter, found
  Meter`; the fixture prints 3.

- callee_frame_bare_alias_accept: `framealias.make()` returns its result
  spelled bare (`Widget`) in the callee frame, reached at the import site
  through the module-qualified `framealias.Widget`. Under a plain
  (non-opt-in) import the bare name is not a published bare binding, so the
  gate must qualify it through the UNIQUE registered-owner scan over
  `type_defs`/`known_types`. Neutering that scan reintroduces a false
  `type mismatch: expected framealias.Widget, found Widget`; the fixture
  prints 11.

Scope stays #2651 only — no #2626/#2400/#2647 surface is touched.
…port

The #2651 owner-identity gate rejected any suffix-equal bare/qualified
nominal pair whose two spellings could not both be resolved to a
registered owner. That over-fired on a LEGAL alias: a foreign type
spelled bare in its own module's method signature (`TlsHandler` in
`std.net.tls`) compared against a caller's alias-qualified
`tls.TlsHandler`, with no `TlsHandler` def key in the importer's frame to
canonicalize either side. Standalone `hew check` of the TLS active-attach
acceptance fixture regressed to a `type mismatch` on `LocalPid<TlsHandler>`
vs `LocalPid<tls.TlsHandler>`.

Narrow the strict side of `nominal_owner_conflict` to report a conflict
only when the pairing is a PROVABLE collision: one side is a genuine
root-local / current-module declaration and the other is a foreign-owner
qualifier (`Widget` vs `widgeti8.Widget`). An unresolved bare↔qualified
alias — neither side provably distinct — stays accepted, preserving the
permissive suffix behaviour it relied on. This is strictly narrower than
the previous predicate, so it can only remove false rejects; the
root-local divergent/same-layout reject fixtures still fail closed.
…cted failures

The new #2651 package-import accept fixtures import `hew::<pkg>`, which
resolves only under the package harness (`tests/pkg-import/run.sh`, via
`--pkg-path`). Standalone `hew check` — run by `make hew-check-all` over
the whole tracked corpus — reports `module hew::<pkg> not found`, which
the sweep classifies as an unexpected failure.

List the three flat accept fixtures and the self-qualified package source
under `requires-package-resolver`, matching the existing pattern for every
other `tests/pkg-import/` accept fixture. The reject fixtures are already
auto-excluded (basename contains `reject`); `pkgs/framealias/framealias.hew`
checks clean standalone and is intentionally not listed. The package
harness continues to prove real success for all of them.
@slepp slepp merged commit 8f2a74a into main Jul 15, 2026
12 checks passed
@slepp slepp deleted the fix/2651-nominal-equality branch July 15, 2026 08:57
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.

types: keep root-local and imported qualified nominal types distinct

1 participant