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:

  1. MUST resolve <path> relative to the importing file’s directory and read the referenced Frame source.
  2. 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.
  3. 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.
  4. 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

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. @@import names 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 @@import whose file is unreadable, or which surfaces no systems, is an error or a warning.