How @illuma/cli is built. Read ILLUMA_INTERNALS.md first — it explains why the design is shaped this way.
Static-first, type-checker-backed. ts-morph gives symbol resolution, so we follow imports to a token's declaration and emulate Illuma's by-reference identity. Regex/pure-AST is wrong by construction (identity needs the checker). Load-and-bootstrap is accurate but executes user code and yields one configuration's edges — optional corroboration only (Phase 4).
The default never runs user code. That is the point: a pre-runtime tool must read source.
tsconfig ─▶ 1. Load ─▶ 2. Index ─▶ 3. Edges ─▶ 4. Graph ─▶ 5. Report
(ts-morph) tokens + callsite build + human /
classes + scan passes JSON + exit
providers
- Load — open via
tsconfig.jsonso the checker is available. - Index — keyed by declaration site: token declarations (
new NodeToken/MultiNodeToken, withsingleton/factory/globalfrom opts); injectable classes (decorators +make*) → synthetic node_<CtorName>+ the ctor→token map; provider sites → node universe + alias/multi edges. - Edges — in each class body and factory closure, find calls to the resolved binding of
nodeInject; resolve arg 1 to a node (emulatingextractToken); readoptional/self/skipSelf; emit a typed edge with a confidence label. - Graph + passes — assemble
DepGraph, thenfindCycles, resolvability, opaque-boundary. - Report — human or
--json; set the exit code.
src/
index.ts # programmatic API: analyze(), re-exports
cli.ts # `illuma` binary: shebang + version, hands off to dispatch
commands/
dispatch.ts # command table, help/version, routing (done)
lint.ts # `illuma lint` — options, help, exit code (done)
io.ts # drain-safe stdout writer
types.ts # the Command interface
types/ # data model (done)
graph.ts # NodeId, GraphNode, GraphEdge, DepGraph, Cycle
diagnostics.ts # Severity, Diagnostic
analysis.ts # AnalysisResult, AnalyzeOptions
source.ts # SourceLoc
load/project.ts # ts-morph Project from tsconfig
index/
tokens.ts # token declarations -> nodes (by decl site)
classes.ts # @NodeInjectable/make* -> nodes + ctor->token map
providers.ts # provide()/with*/{provide,…} -> node universe + edges
containers.ts # container sites -> topology forest
extract/
ast.ts # ts-morph helpers, no Illuma knowledge
illuma.ts # resolving symbols to *Illuma's* exports
provider.ts # the provider shapes `provide` accepts
scan.ts # nodeInject call-site scanning + evaluation reachability
resolve.ts # identifier/class -> NodeId (emulates extractToken)
coverage.ts # `no-coverage`: a run that indexed nothing (done)
wellformed.ts # i100/i102/i103/i200 — registration failures (done)
reachability.ts # Phase 2c: evaluated while the context is open?
graph/
cycles.ts # cycle detection (done)
missing.ts # missing-provider pass (tokens only, single container)
resolvability.ts # Phase 2b: container-aware walk + taint -> i400
boundaries.ts # opaque-boundary detection
report/{human,json}.ts
hybrid/ # Phase 4, optional
graph/ stays pure and ts-morph-free. ts-morph is confined to load/, index/, extract/.
See src/types/. confidence is load-bearing — it decides whether an edge can form a cycle and what the boundary report lists: declared (provider literal), scanned-static (resolved call site, may over-count), dynamic-unknown (never a hard edge, always a boundary).
Default reports only what Illuma throws at bootstrap(): no defer/async edges, no transparent-only cycles. --strict treats all three as real, surfacing what the runtime tolerates ("you broke this with injectDefer, but it's still a smell"). Neither is a proof; the reporter must say so.
Will a dependency find a provider, or will the container walk to the root and throw notFound? Mirror findProto step for step — see ILLUMA_INTERNALS.
"No provider" is meaningless without "where". The walk is own container → ancestor chain → singleton auto-materialization → throw, so we model container sites (new NodeContainer, .child(), spawnChild(), { parent }), which provide() targets which container, and the modifiers: self cuts the ancestor walk, skipSelf cuts both the own-container lookup and auto-materialization.
Flat-topology shortcut. With no child-container site anywhere, every injection resolves in the root and "not provided in the project" ⇒ "will throw". Cheapest available proof; implement first.
⚠️ "No child container" ≠ "no.child()call."injectAsync/injectEntryAsync/injectGroupAsyncbuildnew NodeContainer({ parent })internally (utils/inheritance.ts~line 158). The predicate must be: no.child(), noInjector.spawnChild(), noinjectAsync*, nonew NodeContainer({ parent }). Miss one and the shortcut fires falsei400errors in exactly the projects using the framework's own lazy-loading idiom.
"Nothing provides X" is sound only if nothing could provide X unseen. These block the proof:
| Construct | Why |
|---|---|
Computed/spread provide(...) |
may register an unknowable token |
Conditional/looped provide(...) |
registers 0..n depending on runtime state |
| Unmodelled child container | may be registered on a container we didn't place |
Injector.get/.produce/.spawnChild |
imperative resolution outside the declared graph |
extendContextScanner / registerGlobalMiddleware |
can inject edges or swap instances |
Taint is per-token where attributable, global where not (a spread provide(...list) taints everything). Tainted ⇒ warning naming the blocking construct and its file:line.
Severity is a function of what we could prove, not of how likely we think the bug is. That is what lets i400 be an error without becoming a false-positive magnet.
| Case | Code | Severity |
|---|---|---|
| No provider on the path; topology and provider set both provable | i400 |
error |
| No provider found, conclusion tainted | i400 |
warning + construct |
| Declared, no provider, and nobody injects it | unimplemented-token |
warning |
The third cannot fail today — nothing resolves it — so an error would fail CI on a token stubbed ahead of its implementation. --fail-on-warn is the knob for teams that want it enforced.
Never report when: the edge is optional; the node is a MultiNodeToken (resolves to []); an implementation provider exists; or the token has a default factory and is either declared via provide(TOKEN) or singleton (and the site is not skipSelf).
Both halves of that last clause are load-bearing and both were wrong in Phase 2 — singleton alone misses a bare { singleton: true } token with no factory; hasDefaultFactory alone misses an unprovided non-singleton token whose factory nothing can reach. Single source of truth: GraphNode.hasProvider, computed once in analyze().
A static tool over a runtime-defined graph cannot be both sound and complete. Default leans complete (few false positives, accept misses) because a noisy gate gets ignored; --strict leans sound. Always emit the opaque-boundary section so a clean report is never mistaken for a proof.
- Human: cycles first (
[i401] A -> B -> C), theni400with injection site and container, then other diagnostics withfile:line:col, then opaque boundaries. --json:{ summary, cycles, diagnostics }.- Exit:
0nothing to report ·1problems (--fail-on-warnalso counts warnings) ·2tool/usage error, including a tsconfig matching no files.
Fixtures under test/fixtures/<case>/, one per dependency form and per defeater, each with its own tsconfig and excluded from the build. Specs run analyze() and assert exact diagnostics. graph/* gets pure unit tests. A meta-test asserts our i401 string matches InjectionError.circularDependency, so output never drifts from the runtime.
Import the user's container module in a child process, let Illuma run its own probe and bootstrap(), read back ground-truth proto.injections. Strictly more accurate — it is Illuma's discovery mechanism — but executes user code, needs @internal hooks, and covers one configuration. Labeled "single-config ground truth": it validates the static graph, never replaces it.