Agent analyzability

Summary. AI coding agents have a hard context budget. This decision makes analyzability a CI-enforced property of the repo: understanding any one file must require that file plus the small public interfaces of its direct dependencies — never the surrounding codebase. We keep the TypeScript import graph an acyclic, layered, low-fan-out DAG of deep modules, and we keep docs topic-scoped with summary-first headers. Violations fail the build.

For runtime/CLI contracts and pipelines, see Architecture. For contributor workflow, see Contributing.

Decision

Treat agent analyzability as a formal, enforceable invariant of this codebase — the same property as human maintainability, measured strictly.

Invariant. Understanding any single production file under src/ requires loading only:

  1. that file, and
  2. the public entry points (interfaces) of its direct dependencies,

and must not require paging in sibling implementations, unrelated feature slices, or the whole repository.

Why

Agents do not fail first from “not being smart enough”; they fail when the import graph forces them to load more than their context window can hold. Tangled imports, cycles, deep reaches into other packages, and oversized files make the context cost of a local change grow with repo size. An unconstrained tree degrades toward “load everything or guess.”

Constraining structure keeps the context cost of a task bounded and independent of repository growth. Side effects we want anyway: no cycles, isolated slices, smaller modules, docs that stay navigable from headers.

Non-goals

Code structure

Layers (DAG)

Imports may point only downward. Lower layers never import higher ones.

Layer Paths May import from
4 CLI src/cli/**, src/cli.ts 3, 2, 1, 0
3 Runtime src/runtime/** 2 (only the transpile public entry src/transpiler.ts), 1, 0 — not CLI
2 Compile src/transpile/**, src/transpiler.ts 1, 0 — not runtime
1 Parse / format src/parse/**, src/parser.ts, src/format/** 0 only
0 Shared leaf src/types.ts, src/errors.ts, src/diagnostics.ts, src/version.ts, src/env-reserved.ts, src/inline-script-name.ts other layer-0 files only

Already pinned today: compile-time must not import runtime (src/transpile/no-runtime-imports.test.ts). The layer table generalizes that rule, and npm run arch:check now enforces the whole table (see Enforcement (CI)).

Allowlisted exception. Runtime may depend on the public module-graph API from compile (loadModuleGraph / readModuleGraph / writeModuleGraph / ModuleGraph, …) because the runner reuses the same graph. That dependency must go through transpile’s single public entry src/transpiler.ts (which re-exports the module-graph API), never through src/transpile/module-graph.ts or any other internal.

Deep modules (public entry = contract)

Each package is a deep module: narrow public surface, large private capability (Ousterhout-style).

Package Public entry (contract) Private
Shared (0) the listed src/*.ts leaf files themselves n/a
Parse src/parser.ts (sole external entry; re-exports the intentional public API — no export * barrel) src/parse/**
Format src/format/index.ts (sole external entry; re-exports the intentional formatter API, no export * barrel) other src/format/**
Transpile src/transpiler.ts (sole external entry; re-exports the compile/validate surface plus the full module-graph API, no export * barrel) module-graph.ts, validate-*.ts, emit internals, etc.
Runtime src/runtime/index.ts (launch, docker, runner, buildRuntimeGraph, shared types/helpers intended for CLI); src/runtime/testing.ts is a second entry for named test seams that cross-package *.test.ts files reach (kept off the production entry) src/runtime/kernel/** and other internals
CLI src/cli/index.ts plus per-slice entries under src/cli/<slice>/ as needed slice-private files

Rule. Code outside a package imports only that package’s public entry. Code inside a package may import siblings freely, subject to no-cycles, fan-out, and file-size caps.

Deep ≠ fat files. Depth is interface/implementation asymmetry. Implementations stay split into short private files (prefer ≤ ~400 lines; see factory code_philosophy and ESLint max-lines below).

Facades are curated. Public entries export a small, intentional API. export * from './everything' is forbidden — it recreates shallow modules and blows fan-out.

CLI slice isolation

Treat these as vertical slices: commands, run, serve, mcp, exec, telemetry.

commands is the composition root. It wires the other slices together (each jaiph subcommand launches its feature), so commands may import any slice’s private tree — that is orchestration, not peer coupling.

Peer slices must not import each other. run, serve, mcp, exec, and telemetry must not import each other’s private trees. Peer coupling (e.g. servemcp) is the real analyzability problem: it makes one feature un-understandable without paging in another. Cross-slice reuse among peers goes through src/cli/shared (or layer 3/0 public entries). Same idea as “no cross-feature imports” in a feature-slice layout, with the composition root exempted.

Enforced by no-cross-cli-slice-imports in .dependency-cruiser.cjs: its from set is the peer slices only (commands excluded), so a commands → slice import passes while a peer → peer private import fails. The former feature-composition edges (serve mounting mcp/exec, exec reusing run/telemetry) are gone, not baselined: the shared MCP-protocol engine (shared/mcp-server, shared/mcp-tools) and the workflow-call executor (shared/workflow-call) now live under src/cli/shared, so serve, the jaiph mcp subcommand, and shared/generation all reach them downward and no peer slice imports another peer’s private tree. The baseline carries zero no-cross-cli-slice-imports entries.

Fan-out and file size

Cap Default Enforcement
Runtime imports per file ≤ 8 ESLint import/max-dependencies (ignoreTypeImports: true)
Lines per file ≤ 400 ESLint max-lines (skipBlankLines, skipComments)

Turn a cap off for a file only with a per-file override in eslint.config.mjs (or an inline disable) and a one-line justification. Prefer split over raise.

Cycles

No circular dependencies anywhere under src/. Cycles destroy the “direct deps’ interfaces suffice” story: each side needs the other’s body.

Documentation structure

Docs obey the same budget discipline:

  1. One topic per file (aligned with Diátaxis page types already in use).
  2. Size cap — prefer pages agents can load whole; split when a page outgrows a single topic. Enforced: integration/docs-structure.test.ts fails any non-allowlisted docs/*.md whose body exceeds 500 lines (front matter excluded); an oversized single-topic page goes on the test’s DOC_SIZE_ALLOWLIST with a justification rather than merging topics.
  3. Summary first — every page opens with a short summary so an agent can skip the body from the header alone. Enforced: the same test requires the first body line after the H1 to be a prose lead paragraph (this page labels its lead **Summary.**), not a subheading, list, or table.
  4. Entry-point manifest — nav in docs/_layouts/docs.html plus this page and Architecture as the structural maps; do not bury contracts only in chat history or QUEUE.md.

Enforcement (CI)

These are guardrails, not conventions. Violations fail CI.

Mechanism What it enforces
dependency-cruiser (npm run arch:check) no cycles; the layer DAG (including runtimecli); deep imports past the parse public entry (no-deep-imports-into-parse), the transpile public entry (no-deep-imports-into-transpile), the runtime public entry (no-deep-imports-into-runtime), and the format public entry (no-deep-imports-into-format); cross-CLI-slice private imports (no-cross-cli-slice-imports). Every layer now sits behind a public-entry gate, and the committed known-violations baseline (.dependency-cruiser-known-violations.json) is empty — no cycles, upward imports, deep imports, or cross-slice edges remain tracked
ESLint (npm run lint) import/max-dependencies and max-lines on src/**/*.ts. Most former violators were split into sibling modules and now pass under the global caps with no override; the four largest remaining files keep a per-file override in eslint.config.mjs, each with a fresh justification
Existing grep/shape tests e.g. transpile ↛ runtime, trivia isolation, file-size caps on specific hot files
Docs structure tests Diátaxis front matter, nav bijection, link resolution, summary-first lead, and a 500-line body cap (integration/docs-structure.test.ts)

Baseline policy. If the tree already violates a new rule, do not weaken the rule. Commit a dependency-cruiser known-violations baseline (and an explicit ESLint grandfather list) so new violations fail while old ones are tracked. Follow-up work removes baseline entries; it does not relax severity.

Landed today. .dependency-cruiser.cjs and npm run arch:check now enforce no-circular and the layer DAG, meaning each layer’s rule against upward imports, including the exception that lets runtime reuse compile only through the single public entry src/transpiler.ts. Orphan modules are reported as a warning. Pre-existing violations are grandfathered in .dependency-cruiser-known-violations.json and passed to the check with --ignore-known, so a new cycle or upward import fails the build; that baseline is now empty — every originally-grandfathered edge was fixed rather than kept, so no import-graph violations remain tracked. eslint.config.mjs and npm run lint now enforce the two caps below on src/**/*.ts: import/max-dependencies at 8 (type imports ignored) and max-lines at 400 (blank and comment lines skipped). Test files are out of scope, because they legitimately import many modules and run long. Most files that once exceeded a cap were split into sibling modules in the same directory and now pass under the global caps with no override. The four largest remaining files keep a per-file override in eslint.config.mjs that turns off only the rule they break, each with a justification naming why splitting it is larger follow-up work, and the global cap is never raised, so any new violation still fails. Deep imports past the parse public entry (src/parser.ts) are now enforced by the no-deep-imports-into-parse rule, and every production call site routes through the entry (the former validate-string.tsparse/core.ts baseline is gone: the interpolation validator moved into parse, see below). Deep imports past the transpile public entry are now enforced by the no-deep-imports-into-transpile rule: code outside src/transpile/ imports only the single public entry src/transpiler.ts (the compile/validate surface plus the full module-graph API: buildScripts*, loadModuleGraph/readModuleGraph/writeModuleGraph, collectDiagnostics, walkjhFiles, ModuleGraph types, …) — src/transpile/module-graph.ts is no longer a second door, so runtime reaches the graph API through src/transpiler.ts too and layer3-runtime-only-transpile-public-graph now forbids every runtimesrc/transpile/** edge; the CLI collectDiagnostics/walkjhFiles call sites were retargeted to the entry, and the former parse→transpile leak is gone: validateJaiphStringContent/extractInlineCaptures (which need parseCallRef) moved down into src/parse/validate-string-content.ts, so parse/metadata.ts uses a parse sibling and transpile/validate-string.ts re-exports them through src/parser.ts — no production file under src/parse/ imports src/transpile/. The runtime slice now has a public entry too: src/runtime/index.ts re-exports the curated CLI-facing surface (graph construction, launch/runner, the Docker sandbox, emit/redact/portability helpers, embedded assets, and run-tree param display) and no-deep-imports-into-runtime fails any outside import that reaches a src/runtime/** internal. The runtime→CLI leak is gone: buildStepDisplayParamPairs moved out of src/cli/commands/format-params.ts into src/runtime/kernel/format-params.ts (re-exported through the public entry), so no production runtime file imports src/cli/** and there are zero baselined runtimecli edges. Every production CLI call site that reached a runtime internal (docker, emit, portability, redact, runner, launch, embedded-assets) was retargeted to src/runtime/index.ts; the former src/config.tsruntime/kernel/runtime-arg-parser leak is gone too — the pure interpolate helper moved down into src/config.ts (which runtime-arg-parser now imports downward and re-exports), so config.ts imports nothing from src/runtime/. The former cross-package test-seam imports (_dockerExec, _dockerSpawn, _inplacePrompt, CHAIN_GENESIS, chainHmac, RuntimeEventEmitter) were retargeted to a second named public entry src/runtime/testing.ts (allowlisted beside index.ts in the no-deep-imports-into-runtime rule), so those seams stay off the production index.ts while no test reaches a raw src/runtime/** path; there are now zero baselined no-deep-imports-into-runtime edges. Two upward test edges were also cleared by moving the test to its correct layer rather than baselining: the parser-error snapshot test that needs loadModuleGraph moved src/parse/src/transpile/, and the compile→runtime graph-reuse test that needs buildRuntimeGraph moved src/transpile/src/runtime/. The format slice now has a public entry too: src/format/index.ts re-exports the formatter API (emitModule and the EmitOptions type) and no-deep-imports-into-format fails any outside import that reaches a src/format/** internal such as emit.ts. The one outside call site (src/cli/commands/format.ts) was retargeted to the entry, and format keeps importing only parse and types, so no format source imports src/cli, src/runtime, or src/transpile. CLI slice isolation is now enforced too: no-cross-cli-slice-imports fails any import from a peer slice (run, serve, mcp, exec, telemetry) into another slice’s private tree, using a $1 path-group backreference so same-slice imports and imports of src/cli/shared/** (or lower-layer public entries) stay allowed. commands is the composition root and is deliberately absent from the rule’s from set: it wires the other slices together (each subcommand launches its feature), so commands → slice imports are allowed rather than baselined. The one back-edge that was a shared display helper (run/display.tscommands/format-params.ts) was fixed earlier by moving format-params.ts into src/cli/shared/. There are now zero baselined no-cross-cli-slice-imports edges. The former 17 peer feature-composition edges (serve mounting mcp tools and exec over HTTP, exec reusing run lifecycle and telemetry) were not domain contracts of their home slices but shared CLI infrastructure misfiled inside peer slices — shared/generation.ts already reached up into exec/call, mcp/tools, and three run/* modules, an inverted dependency the baseline hid. The fix moves that infrastructure down into src/cli/shared: the MCP-protocol engine mcp/server.tsshared/mcp-server.ts and mcp/tools.tsshared/mcp-tools.ts (used by both the jaiph mcp stdio subcommand and jaiph serve over HTTP), and the workflow-call executor exec/call.tsshared/workflow-call.ts (callWorkflow, WorkflowCallResult/WorkflowCallContext, used by commands/mcp, commands/serve, serve/handler, and shared/generation). Because shared is not in the rule’s from set, shared/workflow-call.ts may import run/* and telemetry/otlp downward without a peer violation, so the run/telemetry primitives stay put. The mcp and exec slice directories no longer exist — their concerns became shared infrastructure — while the peer-slice regex still names them so a reintroduced private tree is still guarded. Zero baselined slice edges originate from commands. With parse, transpile, runtime, format, and the CLI slices all gated, no deep-import work remains queued: every layer sits behind a public-entry rule and the dependency-cruiser baseline is empty.

Scripts. The import-graph gate and the ESLint caps gate are both live and wired to their committed configs:

"arch:check": "depcruise src --config .dependency-cruiser.cjs --ignore-known",
"lint": "eslint src --max-warnings 0",

Both arch:check and lint are required CI steps on the Compiler and unit tests job. There is intentionally no committed arch:graph gate: rendering the graph (depcruise … --output-type dot | dot -T svg > docs/dependency-graph.svg) needs Graphviz’s dot, which is not a declared dependency, so it stays an optional dev-only command you run ad hoc rather than a promised CI step.

How agents should navigate

  1. Read Architecture for pipelines and runtime/CLI contracts.
  2. Read this page for import-graph and module-boundary rules.
  3. Open the public entry of the package you need; treat it as the contract.
  4. Open private implementations only at the point of change.
  5. Assume CI structure checks are trustworthy: if arch:check / lint pass, the loaded interface set is sufficient for local reasoning.

Consequences

Status

Accepted (2026-08-02). The import-graph rollout has landed in full. The dependency-cruiser gate (npm run arch:check) for no cycles and the layer DAG has landed and runs in CI. The ESLint fan-out and file-size caps (npm run lint) have also landed and run in CI; most former violators were split into sibling modules and now pass the global caps, and only the four largest files keep a per-file override in eslint.config.mjs, each with a fresh justification. The no-deep-imports-into-parse, no-deep-imports-into-transpile, no-deep-imports-into-runtime, and no-deep-imports-into-format rules now guard the parse, transpile, runtime, and format public entries, and the runtimecli inversion is fixed (no production runtime file imports src/cli/**). CLI slice isolation (no-cross-cli-slice-imports) now bars the five peer CLI slices from importing each other’s private trees while exempting commands as the composition root, and the former peer feature-composition edges are eliminated (the shared MCP-protocol engine and workflow-call executor moved into src/cli/shared), so zero such edges remain in the baseline. No deep-import work remains queued: every layer sits behind a public-entry gate and the dependency-cruiser known-violations baseline is empty. The only grandfathered items left are the four oversized / high-fan-out files carrying per-file ESLint overrides in eslint.config.mjs, whose splits are tracked follow-up work.