Skip to content

Latest commit

 

History

History
134 lines (96 loc) · 9.04 KB

File metadata and controls

134 lines (96 loc) · 9.04 KB

Architecture

How @illuma/cli is built. Read ILLUMA_INTERNALS.md first — it explains why the design is shaped this way.

Strategy

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.

Pipeline

tsconfig ─▶ 1. Load ─▶ 2. Index ─▶ 3. Edges ─▶ 4. Graph ─▶ 5. Report
           (ts-morph)  tokens +    callsite    build +     human /
                       classes +   scan        passes      JSON + exit
                       providers
  1. Load — open via tsconfig.json so the checker is available.
  2. Index — keyed by declaration site: token declarations (new NodeToken/MultiNodeToken, with singleton/factory/global from opts); injectable classes (decorators + make*) → synthetic node _<CtorName> + the ctor→token map; provider sites → node universe + alias/multi edges.
  3. Edges — in each class body and factory closure, find calls to the resolved binding of nodeInject; resolve arg 1 to a node (emulating extractToken); read optional/self/skipSelf; emit a typed edge with a confidence label.
  4. Graph + passes — assemble DepGraph, then findCycles, resolvability, opaque-boundary.
  5. Report — human or --json; set the exit code.

Module layout (target)

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/.

Data model

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).

Strict cycles

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.

Resolvability

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.

Container topology

"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/injectGroupAsync build new NodeContainer({ parent }) internally (utils/inheritance.ts ~line 158). The predicate must be: no .child(), no Injector.spawnChild(), no injectAsync*, no new NodeContainer({ parent }). Miss one and the shortcut fires false i400 errors in exactly the projects using the framework's own lazy-loading idiom.

The taint rule

"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.

Suppression rules (get these wrong ⇒ false positives)

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().

Soundness tradeoff

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.

Output & exit codes

  • Human: cycles first ([i401] A -> B -> C), then i400 with injection site and container, then other diagnostics with file:line:col, then opaque boundaries.
  • --json: { summary, cycles, diagnostics }.
  • Exit: 0 nothing to report · 1 problems (--fail-on-warn also counts warnings) · 2 tool/usage error, including a tsconfig matching no files.

Testing

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.

Phase 4 — hybrid corroboration (optional)

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.