Skip to content

fix(ae): binary-import prepass walks the full import graph (transitive imports) - #2106

Merged
paul-hammant merged 2 commits into
mainfrom
fix/binary-import-transitive-prepass
Sep 19, 2026
Merged

paul-hammant merged 2 commits into
mainfrom
fix/binary-import-transitive-prepass

Conversation

@paul-hammant

Copy link
Copy Markdown
Collaborator

The bug

prepare_binary_imports (tools/ae.c) discovered binary-package imports by scanning only the entry file's import lines. An import <binpkg> sitting in a non-entry module — a wrapper/adapter the entry pulls in transitively — was never discovered, its interface stub never synthesized, and the build failed with unresolved import '<binpkg>'. Only a binary import written directly in the entry file resolved.

This blocked the whole adapter pattern: a project that wraps an engine in validate/module.ae (import phonenumber_ae) and imports validate everywhere never triggered the prepass — datastar-aether's phone component (main → phone component → validate → phonenumber_ae).

Reported and verified from the code by the datastar-aether / libphonenumber-ae line, against fix/ae-host-triple-freebsd (0.693.0 + 50cc9871).

The fix

The prepass now walks the full source-import graph (chosen over a manifest declaration — the general fix that makes a binary package usable anywhere a source module is). For each import line it recurses into the imported source module's file (via a new ae_source_module_path, factored out of ae_source_module_exists) and synthesizes a stub for any binary-package import found at any depth. A heap-allocated visited-set of file paths dedupes and breaks cycles; the stub-emit logic is factored into ae_emit_binimport_stub, otherwise unchanged. POSIX-only, same as the rest of the binary-import path.

Verification

  • The committed pre-fix prepass fails the transitive case (import in a wrapper → unresolved import 'gizmo'); the fix resolves + runs it (cc=42), through a 3-deep chain too, and the compiler still reports genuine source-import cycles rather than the prepass hanging.
  • Leak-clean (visited-set freed; valgrind: 0 bytes definitely lost).
  • tests/integration/binary_import gains a transitive step — a wrapper (wrap.ae) imports the binary package gizmo, the entry (app_transitive.ae) imports only wrap; asserts ae run + ae build resolve it. Reverting only tools/ae.c to the entry-only prepass makes that step fail → a real regression guard.

Relation to #2099

Separate from #2099 (the binary-package feature + install-name + FreeBSD triples). This is a distinct resolution bug in the prepass, on its own branch. Both are needed end-to-end for the adapter consume path; this one is independently correct against any binary-import producer (--emit=lib), not just ae add.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YAS2Xtsem7MRFggdiEaakq

paul-hammant added a commit that referenced this pull request Sep 19, 2026
#2106's graph-walk followed only FLAT imports. In ae_scan_binary_imports the
per-import line did `if (mi == 0 || *p == '.') continue;` -- a dotted import
(import a.b.c) hit `*p == '.'` after reading the first segment and was skipped
whole, so the walk never resolved the dotted module to a file and never recursed
into it. A binary import nested inside a dotted-path module was therefore never
discovered: import foo.validate (validate holds import phonenumber_ae) ->
"unresolved import 'phonenumber_ae'". This is dsa's real shape --
import harness.components.phone.validate -- and dotted package imports are
exactly what the modules="." root export exists to support.

Read the whole module token including dots; if it is dotted, convert a.b.c ->
a/b/c and resolve it with ae_source_module_path (same path form the source
resolver uses: a/b/c.ae or a/b/c/module.ae across . / src / --lib dirs), then
recurse into that file. The dotted NAME is still never a binary-import candidate
itself (a binary import is always a bare name); only the recursion changes. A
dotted std./contrib. import that does not resolve to a local source file simply
no-ops, so nothing regresses.

Verified against the peer's A/B: A (import foo.validate, dotted) failed before,
resolves now; B (import validate, flat) unchanged; a 4-deep dotted chain
(harness.components.phone.validate -> engine) resolves; import std.string still
builds. Reverting only tools/ae.c makes the new dotted test step fail. Leak-clean.

Test: binary_import gains a dotted-transitive step -- pkg/dotwrap.ae reached as
import pkg.dotwrap, holding import gizmo; asserts ae run resolves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
paul-hammant added a commit that referenced this pull request Sep 19, 2026
#2106's graph-walk followed only FLAT imports. In ae_scan_binary_imports the
per-import line did `if (mi == 0 || *p == '.') continue;` -- a dotted import
(import a.b.c) hit `*p == '.'` after reading the first segment and was skipped
whole, so the walk never resolved the dotted module to a file and never recursed
into it. A binary import nested inside a dotted-path module was therefore never
discovered: import foo.validate (validate holds import phonenumber_ae) ->
"unresolved import 'phonenumber_ae'". This is dsa's real shape --
import harness.components.phone.validate -- and dotted package imports are
exactly what the modules="." root export exists to support.

Read the whole module token including dots; if it is dotted, convert a.b.c ->
a/b/c and resolve it with ae_source_module_path (same path form the source
resolver uses: a/b/c.ae or a/b/c/module.ae across . / src / --lib dirs), then
recurse into that file. The dotted NAME is still never a binary-import candidate
itself (a binary import is always a bare name); only the recursion changes. A
dotted std./contrib. import that does not resolve to a local source file simply
no-ops, so nothing regresses.

Verified against the peer's A/B: A (import foo.validate, dotted) failed before,
resolves now; B (import validate, flat) unchanged; a 4-deep dotted chain
(harness.components.phone.validate -> engine) resolves; import std.string still
builds. Reverting only tools/ae.c makes the new dotted test step fail. Leak-clean.

Test: binary_import gains a dotted-transitive step -- pkg/dotwrap.ae reached as
import pkg.dotwrap, holding import gizmo; asserts ae run resolves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@paul-hammant
paul-hammant force-pushed the fix/binary-import-transitive-prepass branch from fa1c298 to 782836a Compare September 19, 2026 07:24
paul-hammant and others added 2 commits September 19, 2026 08:27
…the entry

prepare_binary_imports (tools/ae.c) discovered binary-package imports by
scanning only the ENTRY file's import lines. An import of a binary package that
sat in a NON-ENTRY module -- a wrapper/adapter the entry pulls in transitively
-- was never seen, its interface stub never synthesized, and the build failed
with "unresolved import <binpkg>". Only a binary import written directly in the
entry file resolved. This blocked the whole adapter pattern: a project that
wraps an engine in validate/module.ae (import phonenumber_ae) and imports
validate everywhere never triggered the prepass (datastar-aether's phone
component: main -> phone component -> validate -> phonenumber_ae).

Fix (the general one, chosen over a manifest declaration): the prepass now walks
the source-import graph. For each import line it recurses into the imported
SOURCE module's file (resolved via the new ae_source_module_path, factored out
of ae_source_module_exists) and synthesizes a stub for any BINARY-package import
it finds at any depth. A heap-allocated visited-set of file paths dedupes and
breaks cycles; the stub-emit logic is factored into ae_emit_binimport_stub and
is otherwise unchanged. POSIX-only, same as the rest of the binary-import path.

Verified: the committed pre-fix prepass fails the transitive case (import in a
wrapper -> "unresolved import 'gizmo'"); the fix resolves + runs it (cc=42),
through a 3-deep chain too, and the compiler still reports genuine source import
cycles rather than the prepass hanging. Leak-clean (visited-set freed).

Test: tests/integration/binary_import gains a transitive step -- a wrapper
module (wrap.ae) imports the binary package gizmo, and the entry
(app_transitive.ae) imports only wrap; asserts ae run + ae build resolve it.
Reverting only tools/ae.c to the entry-only prepass makes that step fail, so it
is a real regression guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2106's graph-walk followed only FLAT imports. In ae_scan_binary_imports the
per-import line did `if (mi == 0 || *p == '.') continue;` -- a dotted import
(import a.b.c) hit `*p == '.'` after reading the first segment and was skipped
whole, so the walk never resolved the dotted module to a file and never recursed
into it. A binary import nested inside a dotted-path module was therefore never
discovered: import foo.validate (validate holds import phonenumber_ae) ->
"unresolved import 'phonenumber_ae'". This is dsa's real shape --
import harness.components.phone.validate -- and dotted package imports are
exactly what the modules="." root export exists to support.

Read the whole module token including dots; if it is dotted, convert a.b.c ->
a/b/c and resolve it with ae_source_module_path (same path form the source
resolver uses: a/b/c.ae or a/b/c/module.ae across . / src / --lib dirs), then
recurse into that file. The dotted NAME is still never a binary-import candidate
itself (a binary import is always a bare name); only the recursion changes. A
dotted std./contrib. import that does not resolve to a local source file simply
no-ops, so nothing regresses.

Verified against the peer's A/B: A (import foo.validate, dotted) failed before,
resolves now; B (import validate, flat) unchanged; a 4-deep dotted chain
(harness.components.phone.validate -> engine) resolves; import std.string still
builds. Reverting only tools/ae.c makes the new dotted test step fail. Leak-clean.

Test: binary_import gains a dotted-transitive step -- pkg/dotwrap.ae reached as
import pkg.dotwrap, holding import gizmo; asserts ae run resolves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@paul-hammant
paul-hammant force-pushed the fix/binary-import-transitive-prepass branch from 782836a to b59a9bc Compare September 19, 2026 07:29
@paul-hammant
paul-hammant merged commit 6aa1498 into main Sep 19, 2026
28 checks passed
@paul-hammant
paul-hammant deleted the fix/binary-import-transitive-prepass branch September 19, 2026 08:22
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.

1 participant