# The three phases



A SKMTC generation run is a pipeline. The interesting work happens in three
sequential phases — **Parse**, **Generate**, and **Render** — each producing an
immutable artifact that the next consumes.

This document explains what each phase does, why the boundaries are drawn where
they are, and what invariants depend on the separation. It's organized for
understanding, not lookup; for the API surface of each phase see
[the API reference](/docs/reference/api/core-overview).

## The shape of the pipeline [#the-shape-of-the-pipeline]

```
Schema input ──▶  PARSE  ──▶  GENERATE  ──▶  RENDER  ──▶  Artifacts
                  │            │             │
                  │            │             │
              parsed model  files map   serialized
              + issues      (in-memory) strings
```

Each arrow is a one-way data hand-off. By the time a phase finishes, its output
is locked in — the next phase reads it but can't change it. This is structural:
each phase has its own context class (`ParseContext`, `GenerateContext`,
`RenderContext`) that holds the in-progress state, and the hand-off to the next
phase is a method call that produces an output value.

```ts
// core/run/toArtifacts.ts (sketch)
const parseContext  = new ParseContext({ input, ... })
const parsedDocument = parseContext.parse(stackTrail)

const generateContext = new GenerateContext({ document: parsedDocument, settings, ... })
const { files }       = generateContext.toArtifacts(stackTrail)

const renderContext = new RenderContext({ files, ... })
const { artifacts } = renderContext.render(stackTrail)
```

The phases share a `StackTrail` (for location tracking in diagnostics) and a
`Logger`. Everything else flows phase-to-phase as an explicit return value.

## Why three phases? [#why-three-phases]

You could imagine fewer or more. Single-phase ("everything in one pass") is what
most simple codegen tools do. Two-phase ("parse and render") is the next step
up. Three-phase is where SKMTC settles, and the reasons are concrete:

**Parse is separate because** the parse-time error model is fundamentally
different from generate-time. Parsing tolerates partial failure (one bad schema
doesn't kill the run; it produces a `ParseIssue` and prunes downstream
consumers). Generate is permitted to assume everything in `parsedDocument` is
valid. Combining the two would force every generator to defensively handle
malformed schemas.

**Generate is separate from Render because** cross-generator coordination needs
a settled model of "what files exist and what's in them" before serialization.
Two generators may both contribute imports to the same file; the order of
contributions doesn't affect output, but only because Render runs after both are
done. If Render were interleaved with Generate, generator A's output would
already be a string by the time generator B tried to add an import to the same
file.

**Render is separate from Persist because** the in-memory artifact map is the
boundary with the host process. Inside the Worker, Render produces
`Record<path, content>`. The Worker then `postMessage`s this back to the host,
which writes to disk. The host doesn't have a notion of `File` or `Definition`;
it sees only `{ path, content }`. That clean boundary is what lets the host be
permission-unconstrained while the Worker is sandboxed.

## Phase 1: Parse [#phase-1-parse]

**Purpose:** Convert raw schema input into a typed, navigable internal model
that downstream phases can rely on.

**Input:** `SkmtcDocumentInput` — a discriminated union, either
`{ type: 'oas', value: OpenAPIV3.Document }` or
`{ type: 'gql', value: GraphQLSchema | string }`.

**Output:** `SkmtcParsedDocument` (`{ type: 'oas', value: OasDocument }` or
`{ type: 'gql', value: GqlDocument }`), plus a populated `ParseContext.issues`
array.

**Where it runs:** Inside the Worker. (The host *does* run a pre-parse step for
OAS — see [the worker runtime concept](/docs/concepts/the-worker-runtime) for why — but the
protocol-specific parse always happens worker-side.)

### Mechanism [#mechanism]

The walk is recursive descent. `core/parse/v3-{0,1}/document/toDocumentFieldsV3.ts`
destructures the OAS document and traces each top-level field (`info`, `paths`,
`components`, …) into a child parser. Each child parser does the same for its
sub-fields. The accumulated location is carried in a `StackTrail`:

```ts
operations: stackTrail.trace(
  "paths",
  (st) => toOperationsV3({ paths, stackTrail: st, context }),
);
```

Every `trace(key, fn)` pushes `key` onto the trail before calling `fn`, and pops
it after. So an error at
`paths['/users']['post'].requestBody.content['application/json'].schema.properties.email`
has a precise location string in its `ParseIssue` without any individual parser
explicitly threading path information.

### Two-tier error isolation [#two-tier-error-isolation]

Parse uses two complementary mechanisms to ensure one bad item doesn't kill the
run.

**Tier 1 — per-item isolation via `tryParseAt`.** Every per-item parser is
wrapped:

```ts
// core/parse/v3-{0,1}/schema/toSchemasV3.ts
for (const [key, schema] of entries) {
  const value = tryParseAt({
    stackTrail,
    key,
    context,
    type: "INVALID_SCHEMA",
    parent: schema,
    fn: (st) => toSchemaV3({ schema, stackTrail: st, context }),
  });
  if (value !== undefined) {
    output[key] = value; // bad entries silently omitted
  }
}
```

A throw inside `toSchemaV3` becomes a `level: 'error'` `ParseIssue`, and the key
is skipped in the output map.

**Tier 2 — cascade pruning via `removeErroredItems`.** During the walk, every
`$ref` consumer is recorded; when a component fails to parse, everything that
referenced it is pruned from the document with an `INVALID_DEPENDENCY_REF`
issue at the consumer's location. The downstream Generate phase sees a smaller
document with all surviving items guaranteed valid. The cascade is one hop
deep by design; a transitive consumer fails later, at generate time, as a
per-operation error. The mechanism in full — the two maps, the pruning walk,
and the implementation choices it rests on — is in
[error handling philosophy](/docs/explanation/error-handling-philosophy#tier-2-cross-ref-via-removeerroreditems).

### Type-inference fallbacks [#type-inference-fallbacks]

`toSchemaV3` (`core/parse/v3-{0,1}/schema/toSchemasV3.ts`) dispatches on
`schema.type`. But OAS documents in the wild often omit `type` for object-shaped
schemas. Rather than failing, SKMTC infers:

* Has `properties` → assume `type: 'object'`, log a `MISSING_OBJECT_TYPE`
  warning.
* Has `items` → assume `type: 'array'`, log a `MISSING_ARRAY_TYPE` warning.
* Has a string-shaped `enum` or recognized string `format` → assume
  `type: 'string'`, log a `MISSING_STRING_TYPE` warning.
* Otherwise → fall through to `toUnknown`, which produces an `OasUnknown`
  schema.

"Be lenient on input, strict on diagnostics" — incorrect schemas produce code
anyway, but every assumption shows up in the issue log.

### Forward-reference handling [#forward-reference-handling]

A `$ref` may point at a definition that hasn't been parsed yet. SKMTC handles
this without a two-pass scheme by giving each `OasRef` a live reference to the
in-progress document:

```ts
// core/parse/v3-{0,1}/ref/toRefV31.ts
context.registerRef(stackTrail.clone(), $ref)

return context.withStackTrail(stackTrail, () =>
  new OasRef({ refType, $ref, nullable }, context)
)
```

The `OasRef` constructor stores `context.parsedDocument` — a
`SkmtcParsedDocument` wrapping the *same
mutable* `OasDocument` instance that the rest of the parse is filling in. The
`OasRef`'s `.resolve()` looks up its target at call time. Resolution succeeds as
long as the target has been populated by the time anyone resolves — which is
always true after parse completes.

### Output guarantees [#output-guarantees]

By the time `parse()` returns:

* Every item in the output `OasDocument` (or `GqlDocument`) parsed without
  throwing.
* Every item that depended on a failed schema has been pruned, with an issue
  logged.
* `ParseContext.issues` contains the full diagnostic record.

The Generate phase can iterate `oasDocument.operations` and trust every
operation; it doesn't need defensive checks for "what if the request body schema
is malformed."

## Phase 2: Generate [#phase-2-generate]

**Purpose:** Walk the parsed document with the configured generators, producing
an in-memory map of files-to-render.

**Input:** `SkmtcParsedDocument`, `ClientSettings`, `toGeneratorConfigMap()`
(provides the registered generators).

**Output:** `{ files: Map<string, FileBase>, previews, mappings }`.

**Where it runs:** Inside the Worker.

### The outer loop [#the-outer-loop]

`GenerateContext.toArtifacts` (`core/context/GenerateContext.ts`) iterates
the configured generators. For each generator, it applies filter checks, then
dispatches by generator type:

```ts
generators.forEach(generatorConfig => {
  stackTrail.trace(generatorConfig.id, st => {
    if (this.settings?.skip?.includes(generatorConfig.id)) return
    if (/* include filter excludes this generator */) return

    switch (generatorConfig.type) {
      case 'oasOperation': this.#runOasOperationGenerator(...)
      case 'gqlOperation': this.#runGqlOperationGenerator(...)
      case 'model':        this.#runModelGenerator(...)
    }
  })
})
```

Inside each `#run*Generator`, the per-item loop iterates operations or refNames
(and each item's variants), applies item-level filters, calls the generator's
`isSupported({ operation })` capability gate, then calls
`generatorConfig.transform({ context, operation, variant })`. The transform is
where the generator produces its output — but not by returning strings (it
returns `void`). Instead, the transform calls
`context.insertOperation({ projection: MyProjection, operation })` or
`context.insertNormalizedModel(MyProjection, args)`, which delegate to Drivers.

### The Driver lifecycle [#the-driver-lifecycle]

When `transform` calls `context.insertOperation({ projection: TanstackQuery, operation })`:

1. `new OasOperationDriver(...)` runs
   (`core/dsl/operation/oas/OasOperationDriver.ts`).
2. Driver computes
   `settings = context.toOperationContentSettings({ projection, operation })`,
   which calls the Projection's static `toIdentifierName`, `toIdentifierType`,
   `toExportPath`, and `toEnrichments`.
3. Driver looks up
   `context.findDefinition({ name: settings.identifier.name, exportPath: settings.exportPath })`.
4. **Cache hit + `affirmDefinition` passes:** Driver returns the cached
   `Definition`. No work done.
5. **Cache hit + `generatorKey` mismatch:** Driver throws
   `Registered definition mismatch`. Loud failure.
6. **Cache miss:** Driver instantiates
   `new projection({ context, operation, settings })`. The Projection's
   *constructor* runs — which may call `register({ imports, ... })`,
   `insertNormalizedModel(...)`, or even `insertOperation(...)` recursively for
   further dependencies. After the constructor returns, Driver wraps the value
   in a `Definition` and registers it via
   `context.register({ definitions: [definition], destinationPath: settings.exportPath })`.
7. If the calling file differs from `settings.exportPath` (e.g., a form file is
   asking for a hook in a services file), Driver also registers an import stitch
   into the calling file via `context.register({ imports, destinationPath })`.

### Why order doesn't matter [#why-order-doesnt-matter]

This is the single most important property of the Generate phase. Two facts
combine to make it work:

1. `toIdentifierName` and `toExportPath` are *pure functions* of
   `(operation, enrichments, variant)`. Same inputs → same outputs.
2. The cache key is `(identifier.name, exportPath)`.

So whichever generator's `transform` runs first for a given
`(projection, operation)` pair triggers the construction. Later generators that
depend on the same projection (e.g., a form depending on a mutation hook) get a
cache hit. The output `#files` map is identical regardless of which order the
outer loop happens to visit generators in.

This is what underlies "generators run in any order" — it's a structural
property, not a feature you have to maintain.

### Output structure [#output-structure]

`context.#files: Map<string, FileBase>`. The concrete code file class comes
from the language package (`TsFile` in `@skmtc/lang-typescript`); each one
contains:

* `imports: Map<module, TsImport>` — populated by
  `register({ imports })`. The per-module merge is what dedupes.
* `reExports: Map<module, TsReExport>` — populated by
  `register({ reExports })`.
* `definitions: Map<declarationKey, TsDefinition>` — populated by
  `register({ definitions })`. First-write-wins.

The `JsonFile` variant is used when the path ends in `.json`; instead of
definitions, it holds a JSON value.

By the time Generate finishes, every file's contents are fully determined. The
map is what's handed to Render.

## Phase 3: Render [#phase-3-render]

**Purpose:** Serialize the files map to a `Record<path, content>` artifacts
payload.

**Input:** `Map<string, FileBase>` from Generate.

**Output:**
`{ artifacts: Record<path, string>, files: Record<path, metadata> }`.

**Where it runs:** Inside the Worker.

### Mechanism [#mechanism-1]

`RenderContext.collate` (`core/context/RenderContext.ts`) iterates the files
map and calls `file.toString()` on each:

```ts
const fileObjects: FileObject[] = fileEntries.map(([destinationPath, file]) => {
  return stackTrail.trace(destinationPath, (st) => {
    return renderFile({
      content: file.toString(),
      destinationPath,
      basePath: this.basePath,
    });
  });
});
```

Rendering a code file is language-specific: core declares the abstract
`FileBase`, and the language's file class owns `toString()`.
`TsFile.toString()` (`lang-typescript/src/TsFile.ts`) joins three
sections:

```ts
const body = [reExports, imports, definitions.map(definition => definition.toString())]
  .filter(section => Boolean(section.length))
  .map(section => section.join('\n'))
  .join('\n\n')
```

That's the entire transformation. Imports get assembled from the per-module
import map. Definitions get stringified via their own `toString()`
(which produces `export const X = VALUE;` via the `Definition` wrapper). The
sections are joined with blank lines. No formatting, no analysis, no
transformation.

### What Render does *not* do [#what-render-does-not-do]

Render does not format. `renderFile` takes the `content` produced by
`file.toString()` and returns it unmodified:

```ts
const renderFile = (
  { content, destinationPath, basePath }: RenderFileArgs,
): FileObject => {
  const path = toResolvedArtifactPath({ basePath, destinationPath });
  return {
    content: content, // ← raw, no formatting
    path,
    destinationPath,
    lines: content.split("\n").length,
    characters: content.length,
  };
};
```

A `grep` for `prettier.format` across `@skmtc/core` returns zero hits. No
formatter — Prettier, Biome, `deno fmt`, or otherwise — runs inside the
pipeline. &#x2A;*Generated output is unformatted.** Consumers run their own formatter
as a separate step (typically a pre-commit hook or build script).

This is a deliberate architectural choice, not an omission: formatting is the
consumer's concern. Generators produce syntactically valid TypeScript and trust
the consumer's toolchain to handle aesthetics.

### Output structure [#output-structure-1]

`{ artifacts, files }` where:

* `artifacts: Record<resolvedPath, content>` — the actual file contents keyed by
  their resolved disk path (with `basePath` applied).
* `files: Record<resolvedPath, { destinationPath, lines, characters }>` —
  metadata used by the manifest.

This is what the Worker `postMessage`s back to the host.

## Phase boundaries as invariants [#phase-boundaries-as-invariants]

The three-phase model encodes several invariants that other code relies on:

1. **Parse output is immutable to Generate.** Generate doesn't add or remove
   items from `oasDocument`; it only reads. If you needed to add a synthetic
   operation, you'd have to do it during parse, not generate.

2. **Generate output is fully determined before Render.** Render is pure
   serialization; if a definition isn't in `#files` by the end of Generate, it
   won't appear in output. There's no "Render-time hook" for adding content.

3. **The Worker boundary aligns with the parse-safety boundary.** OAS gets
   converted to v3 host-side (so the clone-safe JSON crosses cleanly), then
   parsed worker-side. GraphQL SDL stays a string until inside the worker. The
   asymmetry is forced by `structuredClone`'s inability to handle class
   instances with cyclic back-references.

4. **The Worker boundary is also the security boundary.** Generators run
   sandboxed (no network, no subprocess). The host handles disk I/O outside the
   sandbox. The three-phase model maps cleanly onto this: parse and generate (in
   the worker) trust nothing from the host; persist (on the host) trusts only
   the artifact paths and contents that the worker returned.

## Common questions [#common-questions]

**Can a generator run before Parse finishes?**

No. Generate operates on `SkmtcParsedDocument`, which only exists after Parse
completes. The two phases are strictly sequential.

**Can generators see each other's output during Generate?**

Yes — through the cache. When Generator A calls
`insertOperation(BProjection, op)`, the Driver instantiates `B` (if not cached)
and returns an `Inserted<...>` carrying the identifier name. A can use that name
in its own template. A cannot read B's *body* (`toString()` output), but it
doesn't need to — coordination is by name, not by content.

**Can Render call back into Generate?**

No. Render is a one-way serialization. If your generator needs to know something
about other generators' output, it must happen during Generate via the
cross-generator coordination mechanism, not in Render.

**Why is OAS converted before the worker but GraphQL isn't?**

`structuredClone` (which the Worker `postMessage` uses) can serialize plain JSON
but not class instances with cyclic references. A converted `OpenAPIV3.Document`
is plain JSON — clone-safe. A parsed `GqlDocument` has class instances with
back-references — clone-unsafe. So OAS gets converted (a JSON-preserving step)
host-side, but GraphQL parsing happens worker-side. See
[the GraphQL asymmetry](/docs/explanation/the-graphql-asymmetry).

**What happens if I throw in a generator's `transform`?**

`#runOasOperationGenerator` (`core/context/GenerateContext.ts`) catches
it, logs an error, and marks the operation as `'error'` in the manifest. The
rest of the run continues. Errors are scoped to one (generator × operation)
pair.

**What happens if I throw in a Projection's *constructor*?**

The throw propagates up through the Driver, then up through `insertOperation` in
the calling generator's `transform`. The catch in `#runOasOperationGenerator`
handles it. So a constructor failure becomes an operation-level error in the
same way a transform-level failure does.

**Can two generators write to the same file?**

Yes — this is the common case. The form generator and the Tanstack Query
generator both write definitions into different files, but they also both write
imports into each other's files (when forms reference hooks). The `Set`-based
deduplication in `register({ imports })` handles same-module collisions; the
`Map.has` gate in `register({ definitions })` enforces first-write-wins.
Same-name collisions from different generators throw via `affirmDefinition` on
the Driver path.

## Further reading [#further-reading]

* [How generators produce output](/docs/concepts/how-generators-produce-output) —
  `GenerateContext.toArtifacts`'s iteration loop and the pull-based Projection
  model.
* [Files, deduplication, and integrity](/docs/concepts/files-and-dedup) — what Drivers
  register into, and the `generatorKey` integrity check on top.
* [The Worker runtime](/docs/concepts/the-worker-runtime) — what happens at the worker
  boundary.
* [Cross-generator coordination](/docs/concepts/cross-generator-coordination) — the cache
  mechanism in depth.
* [Error handling philosophy](/docs/explanation/error-handling-philosophy) — the manifest as
  canonical run record.
* [`reference/api/parse-context.md`](/docs/reference/api/parse-context) — Parse
  API surface.
* [`reference/api/generate-context.md`](/docs/reference/api/generate-context) —
  Generate API surface.
* [`reference/api/render-context.md`](/docs/reference/api/render-context) —
  Render API surface.
