-
Notifications
You must be signed in to change notification settings - Fork 0
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.
.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.
| 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.
// 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.
createHarness executes once per test file (beforeAll) and:
- Loads and validates
feat.config.json(Ajv against the published config schema). - 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
createAdapteris 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.
An adapter is a plain module exporting one function:
export function createAdapter(config: Record<string, unknown>): Adapterconfig is the adapter's own block from feat.config.json (plus projectRoot). Three kinds:
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).
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 adeliverat an adapter without this method is a configuration error. -
read(query)— return current state as a record array;containsassertions diff predicted records against it with subset semantics (extra state is not a violation — exclusivity claims belong tohas).
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).
Resolve schema references and normalize to JSON Schema. They never appear at run time: emit
inlines everything. First-party: @mmmnt/feat-schema-json.
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.
| 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 |
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.