RFC-0040: Re-introduce @@import as analysis-only cross-file resolution
- Status: Accepted — implemented in framec 4.3.0 (cross-file persist-name resolution; cross-file arg/type validation phase-in pending)
- Author: Mark Truluck mark.truluck@cogiton.com
- Created: 2026-05-27
- Builds on: RFC-0012, RFC-0015
- Amends: RFC-0024 (the analysis half only; RFC-0024’s removal of import emission stands)
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.
Summary
Re-introduce the @@import "<path>" directive, but with a strictly narrower
meaning than the directive RFC-0024 removed. The new @@import is
an analysis directive, not a code-generation directive: it tells framec where
a referenced system’s Frame source lives so framec can
read it while compiling the current file — to check that this file uses the
referenced system correctly, and to resolve the cross-file facts code generation
needs. framec emits nothing for an @@import: it produces no import line and
no target code for the imported system. Native host imports remain the user’s
responsibility, written as Oceans Model
pass-through, exactly as today.
Motivation
framec compiles one file at a time and treats every cross-file reference as an
opaque name. A composed system that holds a
child declared in another file — domain: child = @@Child() where Child lives
in child.frm — is lowered using only the literal string "Child". framec never
reads child.frm, so it knows nothing about Child beyond its name.
That opacity is silently lossy in two ways.
It cannot check that this file uses the other system correctly. A handler that constructs or transitions to a cross-file system with the wrong number of arguments, or arguments of the wrong type, compiles without complaint. framec has no signature to check against, so it passes the arguments through verbatim and hopes. When the mistake is caught at all, it is caught later — by the host compiler, or not until runtime.
It cannot resolve cross-file facts that code generation genuinely needs. The
persist contract is the sharpest example. A
persisted system names its save and load methods with
@@[save(<name>)] / @@[load(<name>)]; the
names are the author’s to choose. When a parent serializes a composed child, the
generated parent code must call the child’s save/load methods by the child’s
chosen names. For a child in the same file, framec reads those names from
the child’s declaration and emits the right call. For a child in another
file, framec has no declaration to read — so it falls back to the target
language’s default spelling. If the imported child chose a non-default name, the
parent calls a method that does not exist.
This last case deserves emphasis because it refutes the assumption that
cross-file checking is redundant with the host compiler. Consider a parent whose
save_state serializes an imported child, where the child named its save method
save_state (snake_case, mirroring another target’s idiom) while the host target
spells its default saveState (camelCase). The generated parent calls
this.child.saveState(). That code compiles cleanly and links cleanly on the
host — saveState is a perfectly well-formed method name, the import resolves,
nothing is missing. It fails only at runtime, with a “method is not a
function” error, the first time the parent is saved. The host compiler never
catches it, because there is nothing for the host compiler to catch: the error is
that framec generated a call to the wrong name, and only framec — had it read the
child’s source — was in a position to know the right one.
RFC-0024 removed @@import for good reasons, but those reasons were about
emission — having framec generate each target’s native import statement, which
requires host packaging information framec does not have, and which split the
backends into “framec emits the import” versus “the user writes it.” Removing the
emission role was correct. But @@import also had an analysis role — letting
framec see the imported source to validate and resolve against it — and that role
was removed as collateral, not because it was the problem. RFC-0024’s own
Drawbacks acknowledge the loss (“Loses early validation”; “Loss of project-wide
dependency view”), and its rejected Alternative C (“keep @@import as pure
validation”) is precisely the design this RFC revives — now with a concrete case
showing the early-validation benefit is not small.
A fix needs to let framec read a referenced system’s source to check usage and resolve cross-file facts, without reintroducing import emission and without requiring the whole program to be present to compile one file.
The contract
What @@import means
@@import "<path>" is a module-level directive naming a Frame source file. It is
an analysis directive. When framec compiles a file containing
@@import "<path>", it:
- MUST resolve
<path>relative to the importing file’s directory and read the referenced Frame source. - MUST parse the systems declared in that source and make them visible — for the duration of the current compilation only — to the same checks and resolution that apply to systems declared in the current file.
- MUST NOT emit any target code for an imported system. An imported system is present for analysis and is then discarded; generating its target code is the responsibility of its own compilation.
- MUST NOT emit any import statement, in any target. Cross-file linkage is expressed by the user in the host language’s native syntax as Oceans Model pass-through, unchanged from RFC-0024.
An imported system is therefore known for analysis but external for code
generation. Its declaration informs the checks and the cross-file facts
framec resolves, but references to it lower exactly as a cross-file reference
lowers today — as a name the host’s native import resolves — and framec produces
no class, method, or other target artifact for it. The visibility an @@import
grants and the emission it withholds are two separate things, and framec MUST
keep them separate: making an imported system visible to validation MUST NOT
cause framec to emit it, nor to change how references to it are lowered.
In short: @@import changes what framec knows, never what framec writes. A
file’s generated output is byte-for-byte identical whether or not its @@import
lines are present — except where knowing the imported system corrects a call
framec would otherwise have emitted wrongly (a wrong persist-method name being
the motivating case).
Cross-file checking
With imported systems visible, the checks framec already performs for same-file
references MUST apply to references resolved through an @@import:
construction and transition argument arity and types, and existence of the
referenced system. A file that calls an imported system incorrectly is reported
at framec-compile time, in the file that contains the mistake, rather than
surfacing later at host-compile or at runtime.
To avoid breaking existing multi-file programs that compile today only because
cross-file references go unchecked, this stricter checking SHOULD be phased
in: newly-surfaced cross-file violations SHOULD be reported as warnings in
the release that introduces analysis @@import, and MAY be promoted to
errors in a later release. (The exact mechanism — a strictness setting, or a
fixed release in which promotion happens — is left to Unresolved questions.)
The open-world boundary
@@import resolves what framec can read: a Frame source file. A composed child
MAY instead be a hand-written host class, or a system shipped in a
precompiled package with no accompanying Frame source. framec cannot read what
isn’t there. For such a reference there is simply no @@import, framec has no
declaration to resolve against, and the reference falls back to today’s behavior
— arguments pass through verbatim, and persist method names take the target’s
default spelling. Authors composing such a system across a file boundary MUST
give it persist names matching the target default, because framec cannot discover
otherwise. This is the open-world fallback; @@import is the closed-world
path. The two compose: @@import raises correctness wherever the source is
available, and changes nothing where it isn’t.
Examples
A parent composing a child defined in another file, where the child renames its
persist methods. Without the @@import, framec would generate the parent’s save
using the target’s default method name and the call would fail at runtime; with
it, framec reads the child and emits the child’s chosen name.
counter.fjs:
@@[persist(string)]
@@[save(snapshot)]
@@[load(restore)]
@@system Counter {
interface:
bump()
machine:
$Active { bump() { self.n = self.n + 1 } }
domain:
n: int = 0
}
app.fjs:
// Native host import — Oceans Model pass-through; framec emits this verbatim.
import { Counter } from "./counter.machine.js"
// Analysis import — framec reads counter.fjs to resolve `Counter`.
// It is NOT emitted; it only tells framec where the source lives.
@@import "./counter.fjs"
@@[persist(string)]
@@[save(snapshot)]
@@[load(restore)]
@@system App {
interface:
run()
machine:
$Running { run() { } }
domain:
counter: Counter = @@Counter()
}
App’s generated snapshot serializes counter by calling the child’s chosen
snapshot() (read from counter.fjs), not the target default. The import { … }
line is the user’s native import, passed through unchanged; the @@import line
produces no output.
Calling an imported system incorrectly is now reported against app.fjs:
@@import "./counter.fjs"
// ...
// Counter's interface declares bump() with no parameters;
// passing an argument is now a framec-compile-time diagnostic
// instead of a host-compile or runtime surprise.
$Running { run() { self.counter.bump(42) } }
Alternatives
Leave cross-file composition opaque; document the constraint. Keep today’s behavior and tell authors that a cross-file composed child must use the target default persist name, optionally warning when a persisted field is a cross-file reference. This was the contained option. It is rejected as the primary answer because it permanently forecloses cross-file checking of arguments and types, not just persist names — it accepts the opacity rather than closing it. The documented constraint survives only as the open-world fallback above, for references framec genuinely cannot read.
A reserved, fixed-name internal persist entry point. Have every persisted system also expose an internal, framework-named save/load method that composition always calls, so a parent never needs the child’s chosen public name. This solves the persist case without reading the child, and it is appealing in isolation. It is rejected because it is persist-specific: it does nothing for argument or type checking across files, and it adds a second, hidden naming scheme to the persist contract solely to work around framec’s inability to see the child. Reading the child solves the general problem once.
Restore @@import as it was under RFC-0022 (analysis and emission). Bring
back the full directive, including framec generating each target’s native import.
Rejected for exactly the reasons RFC-0024 gives: the host’s import vocabulary
needs packaging information framec does not have, and emission splits the backends
into haves and have-nots. This RFC deliberately keeps emission removed and revives
only analysis.
RFC-0024 Alternative C, on its original reasoning. RFC-0024 considered keeping
@@import as pure validation and rejected it, judging that “the host compiler
already catches missing imports” so the early-detection benefit was small. That
reasoning holds for missing imports and does not hold for the class of error
this RFC targets: a cross-file persist-name mismatch produces code that the host
compiler accepts and links, and that fails only at runtime. The host compiler
cannot catch it because there is nothing malformed for it to catch. This RFC
adopts Alternative C’s shape while correcting the premise that motivated its
rejection.
Whole-program compilation driven by a project manifest. Discover every Frame
source in a project from a build manifest or directory crawl and compile them
with a shared view, rather than naming dependencies in-source. This is a coherent
larger direction and is not foreclosed by this RFC; an in-source @@import and a
manifest-driven file set are complementary discovery mechanisms. @@import is
chosen here as the direct, explicit, in-source declaration of intent that also
works for a single-file invocation. A manifest layer is left to a future RFC.
Migration
Source-additive. @@import "<path>" parsed to a hard error after RFC-0024; under
this RFC it is a valid analysis directive again. Existing multi-file programs use
the host language’s native imports and continue to compile unchanged — analysis
@@import is optional and additive; a program adds it only where it wants framec
to check and resolve against an imported Frame source. The stricter cross-file
checking that an @@import enables is phased in as warnings first (see The
contract), so adding an import surfaces latent cross-file mistakes without
turning them into immediate build breaks.
References
- RFC-0024 — removed
@@import; this RFC amends it, reviving the analysis role while leaving the emission removal in force. - RFC-0022 — the original
@@import(analysis and emission). - RFC-0012, RFC-0015 — the persist contract and the
@@[save]/@@[load]naming whose cross-file resolution motivates this RFC. - Frame language reference
- Glossary —
@@import, Oceans Model, composed system, persist contract. CHANGELOG.md
Unresolved questions
- Phase-in mechanism. Whether newly-surfaced cross-file violations are gated by a strictness setting, by a fixed release in which warnings become errors, or both.
- Import path target.
@@importnames a Frame source path. Whether to also accept resolving a source from a host-import module path (so a single line can serve both roles) is deferred — it risks coupling analysis to output layout. - Transitive imports. Whether an
@@imported file’s own@@imports are followed (a dependency reads its dependencies) or only direct imports are resolved. - Diagnostic for a stale import. Whether an
@@importwhose file is unreadable, or which surfaces no systems, is an error or a warning.