ADR-0017: Load Directive Resolved as a Compiler Step
Date: 2026-04-04 Status: Accepted
Context
The jPipe grammar allows a source file to import models from another file:
load "path/to/other.jd" as namespace
load "path/to/other.jd" // namespace-less variant
The loaded file's models are made available under the given namespace alias (or
flat, without a prefix, when as is omitted). Loads are recursive: a loaded
file may itself load further files. Paths are resolved relative to the file that
declares the load, not to the calling file.
In the previous compiler architecture, load was implemented as a
MacroCommand inside jpipe-model: at execution time the engine expanded it
into the commands produced by parsing the referenced file. That design required
jpipe-model to call back into jpipe-compiler, creating a circular module
dependency that cannot exist in the current multi-module build.
Two alternative approaches were considered:
- Grammar pre-processing — differentiate
loadsyntactically (e.g., a#loadpreprocessor directive) and resolve it in a text-substitution pass before ANTLR runs. Avoids touching the domain model, but requires a separate lexer phase, makes error locations harder to track, and loses the clean grammar rule. - Compile-and-merge — compile each loaded file independently into a
Unit, then merge the resultingUnitobjects before validation. Keeps each file's compilation self-contained, but requires adding a merge API toUnit, forces a decision about whether validation runs per-unit or on the merged whole, and complicates source-location propagation across merge boundaries.
Decision
load is resolved as a dedicated Transformation step (LoadResolver) that
runs inside jpipe-compiler, between ActionListProvider and
ActionListInterpretation:
… → ActionListProvider → LoadResolver → ActionListInterpretation → …
ActionListProvider produces a LoadCommand record (defined in
jpipe-compiler, not jpipe-model) whenever it encounters a load directive.
LoadCommand implements Command so it can inhabit the List<Command> that
flows between the two steps, but its condition() always returns false and
its execute() always throws, preventing it from reaching ExecutionEngine.
LoadResolver eliminates every LoadCommand by:
- Resolving the path to an absolute, normalised
Pathrelative to the directory of the file currently being compiled (ctx.sourcePath()). - Detecting load cycles: a
Set<Path>of in-progress files is threaded through the recursion. If the resolved path is already in the set, aFATALdiagnostic is reported and the load is skipped. - Parsing the referenced file through a raw sub-chain (the same steps as
parsingChain(), but withoutLoadResolveritself) using a freshCompilationContextbound to the sub-file. - Recursively resolving any
LoadCommands found in the sub-file's command list, passing an extended copy of the visited set. - Prefixing every model-name argument of every command in the expanded list
with
namespace + ":"viaCommandPrefixer, when a namespace alias was given. Element IDs and display labels are not prefixed; they are local to their model and get qualified byJustificationModel.inline()at template expansion time. - Splicing the resulting flat list in place of the
LoadCommand.
Diagnostics produced while compiling a sub-file are forwarded to the parent
CompilationContext via a finally block, so the root caller receives a
unified error report. The visited-set is copied (not shared) on each branch so
that a file loaded from two independent paths is not incorrectly flagged as a
cycle.
When as is omitted, step 5 is skipped: the sub-file's models are imported
flat, under their declared names, into the parent unit.
Rationale
- No circular module dependency.
LoadCommandandLoadResolverlive injpipe-compiler;jpipe-modelremains unaware of file loading. The dependency direction (compiler → model, nevermodel → compiler) is preserved. - The
ExecutionEnginesees a flat, fully expandedList<Command>. It needs no knowledge ofloadat all. This is the same principle asMacroCommand— eliminating a directive before interpretation — but applied one layer earlier, at the pipeline level rather than the command level. - Command prefixing is the right granularity. Namespace isolation is a
compilation concern, not a domain-model concern. Rewriting model-name strings
in commands before they are executed means the
Unitends up with correctly namespaced models without any change to the model API. - Cycle detection is O(depth) per file. Passing an immutable snapshot of the visited set down each branch makes cycle detection both correct (a file reachable via two independent paths is not a cycle) and cheap (one set lookup per load directive).
- Grammar-pre-processing is avoided. The
loadrule remains a first-class grammar production, so ANTLR reports syntax errors at accurate source locations. - Compile-and-merge is avoided. Merging
Unitobjects would require duplicating validation logic or deferring it to the merged whole. Merging at the command-list level instead means validation always runs on the single finalUnit, with no special handling needed.
Consequences
LoadCommandmust never reachExecutionEngine. Itscondition() = falseandexecute() = throwenforce this by construction, butLoadResolvermust always appear in the pipeline betweenActionListProviderandActionListInterpretation.- Model names from a loaded file are prefixed, but element IDs are not.
Overriding an abstract support that was inherited through a load requires the
full qualified key:
namespace:templateName:elementId. This is consistent with the existing override syntax (ADR-0012) and documented in the examples. - Omitting
asimports all models from the loaded file into the current namespace without prefix. If two loaded files declare models with the same name,ExecutionEnginewill report a duplicate-model error at interpretation time, not at load time. - Cycle detection operates on normalised absolute file paths. Symlinks that resolve to the same inode via different paths will be treated as the same file and correctly flagged as a cycle.
- Sub-file diagnostics (errors, fatals) are forwarded to the root context. A
FATALin any sub-file aborts the entire pipeline via the standard fast-fail mechanism inTransformation.fire().