Skip to content

Resolve an external fn by its declaration rather than its bare name - #427

Open
0xGeorgii wants to merge 1 commit into
mainfrom
423-bug-fix-extern-identity
Open

Resolve an external fn by its declaration rather than its bare name#427
0xGeorgii wants to merge 1 commit into
mainfrom
423-bug-fix-extern-identity

Conversation

@0xGeorgii

@0xGeorgii 0xGeorgii commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes #423.

An external fn's identity is the declaration a call site names, reachable through the scope that call is written in — the declaring file, and the spec block within it. Three whole-program maps keyed it on the bare name instead, so at most one declaration of a given name could exist anywhere in a program. Analysis rule A024, the specification translator and the link driver had already been migrated to declaration identity, so the compiler held both models at once and they disagreed.

The three bare-name maps — collect_top_level_extern_decls, the imports accumulator inside collect_extern_bindings, and Compiler::extern_name_to_idx — are gone. Resolution now runs through a single ExternIndex, promoted out of the specification translator into core/type-checker and keyed by (module_path, spec, name), which analysis, the specification translator and code generation all consult. core/type-checker is the unique common ancestor of core/analysis and core/wasm-codegen, and it already owned ExternOrigin, so no new dependency edge was needed.

What was wrong

The issue reported two problems. Enumerating and executing every shape of (same/different file) x (extern/local) x (bound/unbound) x (same/different module) found nine.

Defect Evidence
1 A bound external fn in any file captured every same-file bare-identifier call of that name program-wide entry(2) returned 1998 where 20 was correct, the entry file's own fn scale emitted and left dead, silent at type check, analysis, codegen, link and validation
2 An unrelated, never-called, never-bound sibling declaration broke a working bound call calling file byte-identical, A024 fired on it
3 A use … from in one file bound another file's declaration built, linked and executed at 1998
4 Two files each declaring and binding one name were rejected both for different modules and for the same module
5 The cross-file ambiguity error blamed the wrong file stamped with the first file naming the field, never the offending one
6 Signature validation became lossy once binding was re-keyed see below
7 A spec-inner declaration could be displaced by a same-named top-level import reachable through a spec-inner struct method
8 TypedContext::extern_origin / is_extern_function walked all scopes ignoring file boundaries zero production callers
9 Extern binding diagnostics were emitted in hash order four dangling imports on lines 1-4 reported 4, 1, 3, 2

Defect 1 is not arity-safe either: a two-argument call into a one-parameter import still validates, because return is stack-polymorphic.

Defect 6 did not exist before this change and was created by it. Re-keying the binding lets two declarations share a (module, field) pair, which SymbolTable::extern_origins deduplicated on. One declaration's signature would then never reach validate_extern, and the linker satisfies an import on (module, field) alone without comparing signatures — so a disagreement would have shipped as a silently mis-linked artifact rather than a rejection. The dedup key now carries the declaration, and a disagreement is rejected. This is why the binding re-key and the validation fix land together rather than in sequence.

Defect 3 is the one a narrower fix misses. It is the only defect whose correct outcome is a new rejection, so it could have stayed broken behind an otherwise-green suite.

Behaviour changes

Now compiles:

  • Two files may each declare external fn f and bind it to a different module, with each file's calls reaching its own binding.
  • Two files may each declare and bind external fn f to the same module; they share one WASM import.
  • A file's own bound extern is no longer broken by an unrelated same-named declaration elsewhere.

Now errors:

  • A use … from clause no longer reaches across files, so one naming an extern its own file does not declare is ExternImportNotDeclared.
  • A free function and an external fn sharing one bare name is the new ExternFunctionNameCollision, naming both declarations and their locations, in one file or across files.

AmbiguousExternModule is correspondingly a within-file rule. The name-keyed TypedContext::extern_origin and is_extern_function are removed and their test call sites migrated to extern_origin_by_decl.

Note that the collision rule rejects a program the compiler now resolves correctly — each file's call reaches its own function, verified by execution before the rule existed. It is a deliberate rule about the spelling, because a call site does not say whether its callee is compiled here or linked in, not a limit on resolution. The diagnostic and changelog both say so.

Ordering

The codegen re-key had to land before the binding re-key. The binding change makes two same-named bound externs reachable; with codegen still bare-name-keyed at that moment, a hard AmbiguousExternModule degrades into the miscompile.

Verification

cargo test --no-fail-fast: 6035 passed, 0 failed, 161 ignored, 51 suites, against a 5995-passing baseline captured in a worktree pinned at d869f9e. Every test of the +40 accounted for individually; three renamed tests appear as a removal plus an addition and no test was dropped or ignored.

No committed .wasm, .wat or .v golden moves. That was predicted rather than observed: all 13 extern goldens are single-file, where (module_path=[], spec=None, name) resolves on exactly the names the old bare-name lookup did.

Each fix was neutralised to prove the tests have teeth. Reverting only the call-lowering probe fails the regression at left: 1998, right: 20.

Coverage went to the gap that let this ship: the multi-file x extern cell was empty across tests/ — all 16 A024 tests, all 13 extern goldens and every linker test were single-file. New coverage spans the merged-arena, project-mode, type-checker and CLI seams, including execution through Wasmtime of the two-files-two-modules shape, asserting each file's call reaches its own import.

Documentation

Prose asserting properties the code no longer has was corrected alongside it, including two statements that were false before this work: core/wasm-linker/src/merge.rs claimed the merge keys on the export field alone, contradicted by find_export two hundred lines below it, and the A024 row in core/analysis/README.md still described extern calls as unimplemented in codegen, untrue since the linker landed.

Confidence Score: 5/5

The PR appears safe to merge; no concrete changed-code defect remains after tracing scope identity through type checking, analysis, code generation, validation, and linking.

The new declaration-keyed resolution uses consistent file and spec identities across all consumers, call lowering remains aligned with parameter escape handling, and every shared module-field declaration is independently signature-validated before linker input is deduplicated.

Important Files Changed

Filename Overview
core/type-checker/src/extern_index.rs Introduces the shared scope-aware declaration index; construction and consumer key provenance are consistent.
core/type-checker/src/type_checker.rs Re-keys extern bindings by declaration, confines ambiguity checks to each file, and adds fatal local/extern name-collision diagnostics.
core/type-checker/src/symbol_table.rs Preserves every distinct declaration during external-signature validation while retaining deterministic enumeration.
core/analysis/src/rules/extern_function_call.rs Migrates A024 to the shared ExternIndex so bound-state checks follow the call site's file and spec scope.
core/wasm-codegen/src/compiler.rs Resolves imports by declaration for both call lowering and compound-parameter escape analysis, keeping those decisions aligned.
core/wasm-codegen/src/hassert/translate.rs Uses the shared scope-aware index when resolving external calls in specification translation.
core/inference/src/wasm_link/driver.rs Validates each bound declaration's signature before deduplicating resolved modules for linking.
tests/src/codegen/wasm/multi_file_extern.rs Adds multi-file regression and execution coverage for declaration-scoped external resolution.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Call["Call site<br/>(module path, optional spec, name)"]
  Index["ExternIndex<br/>(module path, spec, name → DefId)"]
  Binding["Extern binding<br/>(DefId → module + field)"]
  Analysis["A024 bound/unbound check"]
  Spec["Specification translation"]
  Codegen["WASM import and call lowering"]
  Validate["Per-declaration signature validation"]
  Link["Static linker<br/>(module + field)"]

  Call --> Index
  Index --> Binding
  Index --> Analysis
  Index --> Spec
  Index --> Codegen
  Binding --> Codegen
  Binding --> Validate
  Codegen --> Link
  Validate --> Link
Loading

Reviews (1): Last reviewed commit: "Resolve an external fn by its declaratio..." | Re-trigger Greptile

Context used (4)

An `external fn`'s identity is the declaration a call site names, reachable
through the scope that call is written in: the declaring file, and the `spec`
block within it. Three whole-program maps keyed it on the bare name instead,
so at most one declaration of a given name could exist anywhere in a program.
Three other passes had already been migrated to declaration identity, so the
compiler held both models at once and they disagreed.

The bare-name maps were `collect_top_level_extern_decls`, the local `imports`
accumulator in `collect_extern_bindings`, and `Compiler::extern_name_to_idx`.
All three are gone. Resolution now runs through one shared `ExternIndex`,
promoted out of the specification translator into `core/type-checker` and
keyed by `(module_path, spec, name)`, which analysis rule A024, the
specification translator, and code generation all consult.

Fixed, each reproduced by execution:

- A bound `external fn` in any file captured every same-file bare-identifier
  call of that name in the whole program. An entry file defining its own
  `fn scale` and calling it reached a sibling file's linked external instead,
  returning 1998 where 20 was correct, with its own function emitted and left
  dead and no diagnostic at any stage.
- An unrelated, never-called, never-bound sibling declaration broke a working
  bound call, with the calling file byte-identical.
- A `use … from` clause in one file bound another file's declaration, and
  linked, and executed.
- Two files that each declared and bound one name were rejected, whether they
  named different modules or the same one.
- The cross-file ambiguity error was stamped with the first file naming the
  field rather than the offending one.
- A spec-inner declaration could be displaced by a same-named top-level import.
- Extern binding diagnostics were emitted in hash order.

Widening the binding made two declarations able to share a `(module, field)`
pair, which `extern_origins()` deduplicated on: one declaration's signature
would then never reach validation, and the linker satisfies an import on
`(module, field)` alone without comparing signatures, so a disagreement would
have shipped as a silently mis-linked artifact. The dedup key now carries the
declaration, and a disagreement is rejected.

Two declarations binding the same module and field share one WASM import.
A free function and an `external fn` sharing one bare name is now the hard
error `ExternFunctionNameCollision`, naming both declarations and their
locations, in one file or across files. `AmbiguousExternModule` is
correspondingly a within-file rule.

The name-keyed `TypedContext::extern_origin` and `is_extern_function` are
removed; they walked every scope ignoring file boundaries and had no
production callers.

No committed `.wasm`, `.wat` or `.v` golden moves: every extern golden is
single-file, where the scope key resolves exactly as the bare name did.
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.77011% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
core/type-checker/src/errors.rs 95.23% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

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.

Only one external fn per bare name can be bound across a multi-file program

1 participant