Skip to content

Architecture

listenrightmeow edited this page Jul 17, 2026 · 3 revisions

Architecture

How the toolchain is put together: the pipeline, the packages, the runtime, the adapter contracts, and the rules that keep all of it deterministic.


1. The pipeline

.feat  +  .contract.json  +  feat.config.json
   │
   ├─ 1. parse    text → BuiltSpec IR
   │              statement scanner (bracket-balanced logical lines, string-aware),
   │              matcher grammar, IR construction, the four closed-space validations,
   │              12 coded errors with line + hint
   │
   ├─ 2. resolve  (CLI-mediated, before derive)
   │              contract registries loaded, $ref chains resolved, golden fixtures and
   │              seed fixtures read — all I/O happens HERE so the next stages stay pure
   │
   ├─ 3. derive   IR + config → TestTopology
   │              scenario → test case; outline rows expand (placeholders substituted in
   │              payloads, value blocks, rejection IDs, matching-arguments); ordering
   │              resolved from consistency or override; query predictions synthesize
   │              implicit zero-assertions; anchors assigned
   │
   ├─ 4. emit     TestTopology + resolved schemas/goldens → a complete test file
   │              deterministic header (source ref, language + emitter versions, inputs
   │              sha256 — no timestamps), inlined schemas/goldens/seeds, cases as data,
   │              location-independent config resolution
   │
   ├─ 5. verify   regenerate in memory, byte-compare against committed files
   │
   └─ 6. run      vitest subprocess over the generated suites; adapters do the capturing;
                  JUnit XML out

Stages 1, 3, and 4 are pure functions — no filesystem, no clock, no randomness. That is not a style preference; it is what makes the determinism guarantees testable and feat verify possible.

2. Packages and the dependency law

Package Responsibility Depends on
@mmmnt/feat-types The shared contracts — types only, zero runtime code. Mirrors the JSON Schemas in schemas/, which are the language-neutral source of truth (any future implementation binds to the schemas, not to TypeScript).
@mmmnt/feat-core The parser (stage 1). types, runtime
@mmmnt/feat-derive Stage 3. Pure. types
@mmmnt/feat-emit-ts Stage 4. Pure. Owns no runtime code — it generates imports of the runtime, never behavior of its own. types, derive
@mmmnt/feat-runtime Everything a generated test needs at execution time: config loading, adapter loading, the capture harness, the prediction matcher. Deliberately light — it rides in every consumer's test process. types
@mmmnt/feat-runner A thin vitest run subprocess orchestrator: file selection, reporter wiring, exit-code passthrough. Nothing more — generated tests own their lifecycle. types
@mmmnt/feature The feat CLI. Orchestrates and delegates; owns no pipeline logic. all of the above
@mmmnt/feat-adapter-* Adapters (handler, http, fs, …). Leaves. types

The law: types ← runtime ← { core, generated tests }; derive and emit are pure; adapters are leaves and never import pipeline packages; the CLI sits on top. The graph is a DAG — no cycles, enforced by construction.

3. Anatomy of a generated test file

// AUTO-GENERATED by feat emit — do not edit manually
// Source: specs/create-user.feat (SPEC-USR-001)
// Language: feat 1.0 · Emitter: @mmmnt/feat-emit-ts@x.y.z
// Inputs-hash: sha256:…

import { describe, it, beforeAll, afterAll } from "vitest";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createHarness, type Harness, type HarnessCase, type InlineData } from "@mmmnt/feat-runtime";

const CONFIG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../feat.config.json");
const INLINE: InlineData = { schemas: { /* resolved, inlined */ }, goldens: { /* inlined */ } };
const CASES: HarnessCase[] = [ /* every scenario/row as data, with anchors */ ];

describe("SPEC-USR-001: CreateUser", () => {
  let h: Harness;
  beforeAll(async () => { h = await createHarness({ configPath: CONFIG_PATH }); });
  afterAll(async () => { await h.teardown(); });
  for (const c of CASES) it(c.name, async () => { await h.runCase(c, INLINE); });
});

Properties worth noting:

  • Self-contained — schemas, goldens, and seed data are inlined; the only import besides the test framework is the runtime. No compile-time machinery exists at run time.
  • Location-independent — the config resolves relative to the file itself; the process runs from the project root (the canonical cwd for paths inside spec payloads).
  • Cases are data. The file contains no per-scenario logic to review — reviewers read the spec; the file exists so CI can hold the implementation to it.

4. The runtime harness

createHarness executes once per test file (beforeAll) and:

  1. Loads and validates feat.config.json (Ajv against the published config schema).
  2. Resolves and instantiates every configured adapter by dynamic import of the module specifier — npm package or local path — anchored at the project root (the config's directory). A module that can't load or lacks createAdapter is a configuration error surfaced before any test runs.

runCase then executes the choreography per test: reset → preconditions (execute via the response adapter, seed via the service adapter — before capture) → capture window → stimulus → one shared eventual-consistency wait → capture sweep → prediction diff. Failures throw with the full violation list, each line anchored to its spec coordinates.

Parallelism model: adapter instances are per test file, so the runner's cross-file parallelism is safe by construction; within a file, tests run sequentially against that file's adapters; per-test resets give each test a fresh namespace. There are no global singletons.

5. Adapter contracts

An adapter is a plain module exporting one function:

export function createAdapter(config: Record<string, unknown>): Adapter

config is the adapter's own block from feat.config.json (plus projectRoot). Three kinds:

Response adapters — invoke the system under test

interface FeatResponseAdapter {
  setup(invokeConfig): Promise<void>;
  teardown(): Promise<void>;
  invoke(command, payload, actor?): Promise<{ status: number | string; body: object | null }>;
}

invoke resolves the command through response.commands (its route shape is adapter-specific), applies the actor's auth material from the registry (anonymous = none), and captures the response. First-party: handler (direct function call — routes are { module, export }), http (routes are { method, path } with {param} substitution against invoke.baseUrl).

Service adapters — capture observable effects

interface FeatServiceAdapter {
  setup(): Promise<void>;            // provision (containers, connections)
  teardown(): Promise<void>;
  reset(): Promise<void>;            // per-test isolation (fresh namespace)
  startCapture(): Promise<void>;
  stopCapture(): Promise<CapturedRecord[]>;   // { type, key?, payload, timestamp }
  read(query): Promise<unknown>;     // resulting state, for `contains`
  seed?(records): Promise<void>;     // optional: direct state injection for `seed`
}

The capture window is the contract: everything the service observes between startCapture and stopCapture comes back as records; the matcher diffs them against the prediction. A spec that Two optional methods extend the contract:

  • deliver(event, payload) — accept an event as a scenario stimulus (projection/policy/saga triggers, ADR-0011). Delivered stimuli are excluded from capture; only the effects the system produces in response are captured. Routing a deliver at an adapter without this method is a configuration error.
  • read(query) — return current state as a record array; contains assertions diff predicted records against it with subset semantics (extra state is not a violation — exclusivity claims belong to has).

seeds a service whose adapter lacks seed() is a configuration error. First-party service adapters: fs (directory-tree capture), memory (in-memory event bus for deliver-triggered specs), stripe (the Stripe Events API as a capture window). First-party response adapters: handler (in-process functions), http (routed requests), playwright (a command is a real browser journey). fs (filesystem writes/deletes within a scoped directory).

Schema adapters — compile-time only

Resolve schema references and normalize to JSON Schema. They never appear at run time: emit inlines everything. First-party: @mmmnt/feat-schema-json.

6. The runner and reporting

feat run spawns vitest run <generated files> from the project root — the runner adds file selection, --coverage passthrough, JUnit reporter wiring (report.junitOutput), and exit-code passthrough, and deliberately nothing else. Because generated files are standard Vitest suites, everything in the Vitest ecosystem (IDE integrations, watch mode, coverage providers, custom reporters) works on them unchanged. feat report reads the JUnit output back: per-suite summaries, exit 1 on any failure, --junit prints the XML path for CI ingestion.

7. Determinism, enforced

Invariant Statement Enforced by
Parse determinism same spec + config → identical IR property test: every corpus exemplar parsed repeatedly, byte-compared
Derivation determinism same IR + config → identical topology derive is pure
Emission determinism same inputs → byte-identical file no timestamps; inputs hash; feat verify in CI
Prediction completeness every configured service in every prediction parse-time error
Closed reference spaces no undeclared identifiers parse-time errors with hints
Config immutability config loaded once per run harness

8. The corpus as conformance suite

corpus/ holds nine exemplars spanning the whole language — commands, CRUD, seeds (inline + fixture), ordering overrides, error predictions, a projection (deliver + contains), a query, an outline/actors/clock exemplar, and a true multi-deliver saga. Each pairs with its expected IR (.ir.json), validated against schemas/builtspec.schema.json. The parser's own spec asserts every pairing as a golden fixture, which makes the corpus the language's executable conformance suite: any alternative parser — including a future LSP-oriented grammar — must round-trip the same nine files to the same nine documents, byte for byte.