@pyreon/lathe — API Reference
Generated from
lathe'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see lathe.
Lathe reads an OpenAPI 3.x document and emits a client for the Pyreon stack: @pyreon/validate schemas, @pyreon/http endpoint declarations, @pyreon/query hooks, deterministic mock fixtures, and @pyreon/atlas scenarios derived from the spec's own enums and examples. YAML is read as YAML 1.2 core through the yaml package, configured strictly: anchors and merge keys resolve, while duplicate keys, multi-document streams, custom tags and non-JSON values are REFUSED with a line number rather than producing a document the author did not write. What separates it from a conventional generator is target: 'multiplatform': the native compiler (PMTC) lowers only a SUBSET of TypeScript and has no module graph, so it recognises a client, a schema and a call only when they share ONE file's top level. Hand-written code drifts out of that constantly; generated code need not, so Lathe emits a self-contained module per tag — a layout no human would maintain and exactly the one the compiler wants — then runs the real compiler over its own output and checks for the POSITIVE marker (PyreonQuery<, PyreonZodSchema_), because zero warnings is not evidence of lowering.
Multiplatform
Tier: Web-only — the browser package; the native story is stated below
the code generator — build-time tooling that emits app code, not app runtime itself
See Multiplatform for the capability matrix and Multiplatform libraries for every package's tier.
Features
OpenAPI 3.x and Swagger 2.0 (up-converted in process) reader: JSON or YAML,
$refresolution within and ACROSS files (a split spec is bundled;lathe pullbundles remote parts),allOfflattening through refs (the inheritance idiom),oneOf/anyOfwithdiscriminator(validated at generation — an implicit or non-object discriminator degrades to a plain union with a note instead of a module that throws at import), nullability in every spelling (3.0nullable, 3.1type: [X, null]/anyOf: [X, {type: null}]) on every node including component models,enum/constof any JSON scalar, constraints on the type itself (so they apply to array items and alias models), path-level parameters, and{id}→:idconversion to the@pyreon/httpendpoint formStrict YAML 1.2 reading via the
yamlpackage — anchors, aliases and merge keys resolve; duplicate keys, multi-document streams, custom tags, recursive aliases and.inf/.nanare REFUSED with a line number rather than producing a subtly wrong documentLoss is REPORTED, never silent: every spec feature the client does not honour becomes a
notewith a stable greppablecode, an RFC 6901 JSON-pointer location, and a SEVERITY derived from the code (NOTE_SEVERITY) --loss(security schemes, response headers, non-default serialization, a non-scalarconst, a$refcycle through references alone, an optional body the call requires, a non-JSON media type, …) orchoice(JSON over XML, the first tag). The report leads with losses and summarises choices; the reference pages split themTyped error responses: each operation's 4xx / 5xx / range /
defaultJSON bodies become its endpoint'serrors, and every hook typeserror()asEndpointError<typeof op>—err.matched === '404'narrowserr.body, on every clientWebhooks and callbacks are emitted in
webhooks.ts— never as client operations — with a schema per payload, handler types,validateWebhook, a framework-agnosticwebhookHandler(handlers, { verify, event, bodyLimit })(Request→Response, usable as a zero API route: enforce bodyLimit (default 1 MiB) on streamed bytes → verify over the raw bytes → parse by declared media type → method → validate → dispatch, answering 401/400/413/404/405/422/204), andcallbackUrlevaluating a callback's OpenAPI runtime expressionRefuses the wrong document before writing anything: a file that is not an OpenAPI or Swagger document, Swagger 1.x, or an unsupported OpenAPI major. Every project is generated before any is written, so a refused spec leaves every output tree untouched
A strict CLI: unknown flags, commands and values are errors with a did-you-mean (exit 2),
--dry-runreports what would change, the config is found upward from the cwd with paths relative to the config FILE, errors go to stderr, colour respects TTY/NO_COLOR, and--jsonhas ONE documented shape (JsonReport) for any project count.lathe pullsupports--token/--header, conditional requests via ETag, and every project with asourceOrphaned output is pruned: each run writes
lathe-manifest.json, and the nextgenerateremoves files it no longer produces whilecheckreports them stale -- only manifest-listed paths are ever removedtarget: 'multiplatform'emits an additional self-contained module per tag — client, schemas, endpoints and calls sharing one top level — because PMTC resolves nothing across file boundaries; the web output is unchanged, so enabling it can never make the web build worseThe multiplatform claim is MEASURED:
verifyNativeruns the real@pyreon/native-compileron both targets and asserts the positive marker plus the absence of leaked web-only symbols. Adoes NOT compilewarning is treated as broken, not advisory, and an absent compiler SKIPS loudly rather than passingPer-operation native reach with a reason in spec terms: a mutation, a relative base URL, or a read with no typed JSON response (there is no declared type for a native query to decode into) is reported
web-onlyby name instead of silently degrading. A path parameter becomes a PROP of the native data component and lowersPlugin selection is expanded along the IMPORT EDGES of the emitted code, not refused:
componentspulls inqueries->client->schemasbecausecomponents.tsximports the hooks, and the report names what came along. Selecting a plugin without what its output imports previously produced files referencing modules that were never written - output that looks complete and does not resolve.componentsitself is independent of Atlas: the previews are ordinary Pyreon components over the generated hooks, so a project that wants them without a workbench gets exactly thatEvery emitter is opt-in via
plugins(types/schemas/client/queries/mocks/faker/components/atlas/docs/mcp) — schemas alone is a first-class use, andtargetis ADDITIVE on top of the selection rather than a separate output, so asking for schemas gets schemas on both targetsThe output is a LAYERED graph, not one barrel:
index.tscarries the production surface,dev.tsthe fixtures/factories/previews, andendpoints/index.ts+queries/index.tsone layer each, so a consumer can take exactly the layer it needs. An emittedpackage.jsondeclares the output side-effect-free (an ARRAY namingatlas.wrapper.tsx, which really does callinstallMocks()at module scope, rather than a blanketfalsethat would be a lie) — and together with ONE SCHEMA MODULE PER MODEL (a$refcycle shares one) and/* @__PURE__ */on every emitted call, one hook costs what it uses: measured with Vite 8 on GitHub's spec, one hook went from 94.4 KB to 2.8 KB gzipped of generated code, and the root barrel costs exactly what the per-tag import costs. Untagged operations are grouped by path (Stripe's single 612-endpointdefaultmodule became 79)Model TYPES are written out as interfaces and each schema const is cast to its schema (
export const Book = s.object({…}) as unknown as Schema<Book>) instead of inferred, which cut the TypeScript cost of the generated schemas + client + queries by 26-65% on GitHub and Stripe (instantiations, deterministic). Interface/schema agreement is enforced by lathe's own tests, both ways, for every model. The trade: a generated schema is aSchema<Book>, so object-only builders (.extend,.pick) do not type-check on itfakeremits one factory per model (createBook(overrides?)), and its rule is that a factory must produce data its OWN schema accepts:min/max/pattern/enumchoose the generator and the field-name guess only applies where the spec states nothing. Depth is threaded explicitly so a recursive model terminates.docsrenders Markdown with frontmatter — the generated HOOK name and its import site next to the HTTP contract, plus the one column a rendering of the spec cannot produce: whether the operation reaches iOS and Android, and when it does not, whySeveral specs in ONE pass via
projects: [{ name, input, output }]— each to its own path (typically another package in the workspace, which is the intended use), withtarget/pluginswritten once at the top level and overridable per project.lathe checkcovers them all and fails if any is stale; a CLI--outalongsideprojectsis REFUSED rather than applied to every oneA generated
index.tsbarrel and akeys.tsquery-key registry: one import site regardless of how operations were tagged, and invalidation keys derived from the endpoints rather than hand-written literals that drift the moment a path changes (keys.books.listBooks.allmatches every call;.of(args)matches one)The Atlas story is FULLY generated:
components.tsxemits one preview per SAFE READ — everyGETwith a JSON body, detail views with path parameters included (requested with the spec's example values, which the generated mocks answer), credential and session operations (login, logout, token, password) excluded — and renders the response by its SHAPE: a list of records as a table of the model's fields, one record as a description list, never a JSON dump; password/secret fields are not displayed.force(loading / error / empty),argsanddataare real props, so Atlas infers controls;atlas.scenarios.tskeys those names with Default, a seeded faker-built "Data" scenario, and the three forced states;atlas.wrapper.tsxinstalls the mocks so every card renders with NO server. Measured on the example withatlas verify+atlas verify-browser: 3 components, 15 scenarios, 0 failing, rendered in real Chromiumlathe initsets a project up from what it already has: it detects an orval / @hey-api/openapi-ts / kubb config (read as TEXT, never imported or executed), an openapi-typescript script, or a bareopenapi.*file; maps every option it can onto alathesection and NAMES every one it cannot with what to do instead (an orval mutator becomesconfigureApi({ use })); writespyreon.config.ts— creating it, or inserting one entry into an existing one, never replacing alathesection already there; addslathe:generate/lathe:checkscripts; prints the install command for what the generated code imports; and runs the first generate. It asks only on a terminal;--yestakes every default for CI,--dry-runwrites nothing, and it never writes into a directory holding another generator's filesGenerated JSDoc is written for the person hovering a symbol: the spec's summary AND description, one bullet per parameter with its location and meaning,
@deprecated(operation, parameter, property, schema), a copyable@examplebuilt from the spec's own examples (else the same deterministic sample the mocks return), a@seelink fromexternalDocs, and per-field docs on every model interface. One two-line header per file; no generator commentaryAutomation: a
@pyreon/lathe/viteplugin that readspyreon.config.*itself, generates ONCE at dev-server start and on every spec or config change, warns on a missing spec with a did-you-mean, and logs breaking contract changes and spec losses -- withcheckOnBuildturning a stale client into a BUILD ERROR rather than a warning; pluslathe generate --watch, which watches the config too. The watcher is on the containing DIRECTORY with a filename filter, because editors write via rename and a watch on the inode dies the first time one replaces the file; an unparseable mid-save spec prints and keeps watching rather than exitingDeterministic by construction: sorted models, sorted operations, sorted imports, no randomness in fixtures — an unchanged spec regenerates byte-identically, so a regeneration diff is reviewable
lathe checkis the CI half — regenerates in memory and fails when committed output has drifted from the spec, the same contract asgen-docs --checkThe SCHEMA LIBRARY is selectable —
validator: 'pyreon' | 'zod'. One walk with a different binding, not two renderers that can drift; both satisfy Standard Schema, so the endpoint layer accepts either without knowing. Every zod spelling was verified against the INSTALLED zod (4.4.3) rather than inferred:z.string().email()is deprecated there in favour ofz.email(), and the deprecated form is emitted deliberately because it works in zod 3 AND 4 while the newer one exists only in 4MEASURED, and the opposite of what you would assume: on the native target PMTC's zod recogniser STRICTLY DOMINATES its first-party
s.*one — a nested object and an array of objects lower under zod (via@pyreon/validation'szodSchema(...)wrapper) and are DROPPED unders.*. Sovalidator: 'zod'lowers strictly more of a real spec. Pinned by a test running the real compiler, so a PMTC change corrects the claim rather than leaving it staleA field NAMING another model — what every OpenAPI document of any size is full of — is dropped by both recognisers, and under zod that gap CLOSES: refs are inlined on the native path, and an inlined ref is a nested object. A
$refcycle falls back to naming the target (no finite nesting exists), so the compiler drops that one field with a warning and the generator stays boundedThe HTTP runtime is SELECTABLE —
client: 'pyreon' | 'fetch' | 'axios' | 'ky'. Onlyclient.tschanges: every other emitted file reads an endpoint's callable /.key/.query()shape and nothing else, so endpoints, hooks, keys and the barrel are BYTE-IDENTICAL across all four. An adapter emits a self-contained endpoint factory over that library rather than wrapping@pyreon/http, so choosing axios means genuinely not depending on itAdapter URL and key semantics are pinned to
@pyreon/httpby a DIFFERENTIAL test that uses its ownbuildUrlas the ORACLE, over the shapes these libraries disagree on: a leading-slash path under a based URL (axios and ky RESOLVE, discarding the base's path; Pyreon PREFIXES), nullish query values, array values, and a path parameter containing/. A duplicated URL builder drifts, and drift there means the same generated call issues a different request depending on one config wordFailure is normalised across adapters into one
LatheHttpErrorcarryingstatusand the parsed body —fetchresolves a 500, axios rejects with anAxiosError, ky with anHTTPError, and a generated query'serrormust not change shape when the transport is swapped. Retry policy is deliberately NOT normalised (ky retries 5xx GETs, the others do not) and is asserted rather than papered overtarget: 'multiplatform'with a non-Pyreon client is REFUSED, not silently downgraded: PMTC lowerscreateHttp+api.endpoint(...)by NAME, so emitting native modules over axios would produce exactly the silent regression to web-only that the target exists to catchMocks ride on
@pyreon/http's ownmock()middleware rather than MSW: no service worker, no extra install, identical in node and the browser. A parameterised route emits a bounded RegExp — the declared/books/:idis not a SUFFIX of the resolved/v1/books/b1, so a plain string matched nothing and every such fixture fell through to the real network. Adapter clients need no pattern at all: their seam is handed the declared path alongside the resolved oneSTREAMS: an operation whose 2xx response is
text/event-streamor NDJSON (read from OpenAPI 3.2itemSchemaor the mediaschema, or declared in thestreamsconfig) gets<op>Stream— an async iterator of events validated perconfigureApi({ validate })— anduse<Op>Stream(events/latest/status/error signals, aborted on unmount). It is an ordinary call through the generated client, so POST bodies, auth, middleware and mocks apply on everyclient; a dropped GET stream reconnects with backoff andLast-Event-ID, a non-GET one is not replayed by default. Undertarget: 'multiplatform'a stream-only GET with a typed event (or SSE text) also gets a<Op>Streamcomponent in its tag's native module, which PMTC lowers to the nativePyreonStreamruntime, so the reach report names itweb+native; a stream-only non-GET operation with a JSON body (or none) gets a TRIGGERED one instead —enabledopens it, the body is thejsonprop — and reaches native too. Mocks answer a real stream andmockOperation(id, { dropAfter: n })fails each connection afternevents, so reconnect-and-resume is testable without a serverlathe diff <before> <after>: the client-contract diff between any two specs,api-surface.jsonfiles or<git-rev>:<path>, breaking first, each change naming the generated SYMBOLS it reaches (model changes traced transitively to operations), rendered as text, a Markdown PR comment, GitHub annotations + job summary, or JSON; exit 1 on breaking under--fail-on-breaking, 2 when an input cannot be read. The same classifier backs@pyreon/mcp'sexplain_api_diffThe
mcpplugin emits every operation as a Model Context Protocol tool definition — a self-contained JSON Schema of the endpoint's own call arguments, method-derived annotations, and acallthat runs the GENERATED endpoint — as plain data with no SDK dependency; stream-only operations, non-JSON bodies and over-long names are excluded and listed with the reasonA public PLUGIN API:
definePlugin({ name, requires?, setup?, transformDocument?, emit? })over the same IR andSourceFilewriter the built-ins use, so a new output (MSW handlers, an MCP tool table) is a file in the app rather than a fork. Enforced, not hoped for: every hook failure names the plugin and the hook; the document a hook receives is FROZEN (a transform returns a modified copy, and a copy whose model references dangle is refused by name); each hook runs TWICE and must agree with itself, so a timestamp cannot makelathe checkflap; plugin files join the manifest (pruned when dropped),check,formatand the case-insensitive path-collision guardAuthor control without editing the spec:
filters(include/exclude matchers by tag — every tag, not only the first — path glob, operationId glob and method; models only the dropped operations used go with them, and a matcher that selects NOTHING is an error with a did-you-mean),patches(RFC 6902 add/replace/remove at an RFC 6901 pointer, applied before the spec is read so the fix surviveslathe pull; a target that moved FAILS the run),operations(per operation: hook name orfalse,responseValidation,pagination),naming(operation/model/file/hook functions receiving Lathe's own choice asdefault, every result validated and collision-checked) andformat(the project formatter, applied before write AND beforecheckcompares)Atlas scenarios generated from the spec — one per enum value on a response field, so a variant axis the API declares is one the workbench actually exercises, and it regenerates when the API changes instead of drifting
Complete example
A full, end-to-end usage of the package:
$ npx lathe generate ./openapi.yaml --target multiplatform
lathe / Bookshelf 1.2.0
4 models 4 operations target=multiplatform
+ src/gen/schemas/Book.ts
+ src/gen/schemas.ts
+ src/gen/client.ts
+ src/gen/endpoints/books.ts
+ src/gen/queries/books.ts
+ src/gen/books.native.tsx
5 file(s) written
native reach 3/4 operations
web-only 1 op(s): createBook
POST lowers through mutations, which PMTC does not yet recognise.
lowers books.native.tsx swift [PyreonQuery< PyreonZodSchema_]
lowers books.native.tsx kotlin [PyreonQuery< PyreonZodSchema_]Exports
| Symbol | Kind | Summary |
|---|---|---|
generate | function | The whole pipeline, pure: spec text in, file CONTENTS out. |
generateAsync | function | generate, awaiting plugin hooks that return promises — what the CLI and the Vite plugin run. |
resolveConfig | function | Fills defaults and validates one project's settings, and is where the whole option surface lives: plugins (which emitt |
definePlugin | function | Declares a third-party Lathe plugin, listed in plugins beside the built-in names. |
formatFiles | function | Applies the format config hook to generated files, preserving order, skipping Lathe's own bookkeeping (`lathe-manifest |
verifyNative | function | Runs the real native compiler over the generated .native.tsx modules on both targets and returns a per-file verdict. |
contractDiff | function | The client-contract diff lathe diff prints, as data: every change classified breaking or additive from the CLIENT' |
loadOpenApi | function | Parses OpenAPI 3.x or Swagger 2.0 (JSON or YAML text) into the spec-agnostic IR. |
resolveProjects | function | The multi-spec sibling of resolveConfig — ALWAYS returns a list, so a config with no projects array resolves to a on |
resolveTransform / worstVerdict | function | resolveTransform resolves the CONSUMING PROJECT's own @pyreon/native-compiler (dynamic import(), never bundled) — |
API
generate function
generate(specText: string, config: ResolvedConfig, options?: { sourceUrl?: string; location?: string; readDocument?: (id: string) => string }): GenerateResultThe whole pipeline, pure: spec text in, file CONTENTS out. Touches no filesystem, which is what makes the generator testable without a temp directory and lets lathe check diff before writing. Returns the IR document, generated files, every input document read, and a per-operation reach map explaining in spec terms which operations can run natively and why the others cannot. Pass location and readDocument to resolve and bundle cross-file $refs.
Example
import { generate, resolveConfig } from '@pyreon/lathe'
const config = resolveConfig({ input: './openapi.yaml', target: 'multiplatform' })
const { doc, files, reach } = generate(specText, config)
for (const [id, r] of reach) {
if (r.reach === 'web-only') console.warn(id, r.reason)
}Common mistakes
Passing a relative
baseUrl(or omittingserversfrom the spec) and expecting native output — PMTC bakes the request URL at compile time, so a relative base makes EVERY operation web-only. The reach report names this, but only if you read it.Assuming the
.native.tsxmodules replace the web output. They are ADDITIVE: the web files are byte-identical whether the target iswebormultiplatform.Editing generated files. Every file carries a DO-NOT-EDIT banner and is overwritten on the next run; change the spec or the emitter.
Expecting
s.enumin native output. Enums do not lower, so the native path narrows them to their base scalar (s.string()/s.number()) — the constraint is genuinely lost there, which is why the two layouts are emitted separately rather than shared.
generateAsync function
generateAsync(specText: string, config: ResolvedConfig, options?: LoadOptions): Promise<GenerateResult>generate, awaiting plugin hooks that return promises — what the CLI and the Vite plugin run. The pipeline is written once and driven either way, so the output is byte-identical to generate() for the same spec, config and plugins. Each hook still runs twice for the determinism check, the second call only after the first has settled, and a rejection is attributed to the plugin and hook exactly like a throw.
Example
import { definePlugin, generateAsync, resolveConfig } from '@pyreon/lathe'
const banner = definePlugin({
name: 'banner-file',
async emit({ doc }) {
const text = await Promise.resolve(doc.title)
return [{ path: 'extras/title.txt', contents: text + '\n' }]
},
})
const { files } = await generateAsync(specText, resolveConfig({ input: './openapi.yaml', plugins: ['schemas', banner] }))Common mistakes
Calling
generate()with an async plugin. It refuses the promise by the plugin's name rather than awaiting it — usegenerateAsync().Starting work in an async hook that depends on WHEN it runs (a clock, a counter, a network call). Each hook runs twice and the two results must agree, or generation fails naming the plugin.
resolveConfig function
resolveConfig(section: LatheSection | undefined): ResolvedConfigFills defaults and validates one project's settings, and is where the whole option surface lives: plugins (which emitters run — built-in names and definePlugin plugins), filters / patches / operations / naming / format (author control over the subset, the spec, per-operation hooks and validation, generated names, and formatting), client (pyreon | fetch | axios | ky), validator (pyreon | zod), target (web | multiplatform), baseUrl, strictNative and responseValidation (strict | warn | off, what the web client does with a response that does not match its schema). A plugin selection is EXPANDED to cover what its output imports rather than refused -- asking for components gets queries, client and schemas too, and the CLI report says what came along. Use resolveProjects instead when the config may declare projects: [...]; it always returns a LIST, so a single-project config is a one-element list rather than a special case.
Example
import { generate, resolveConfig } from '@pyreon/lathe'
const config = resolveConfig({
input: './openapi.yaml',
output: './src/gen',
// Nine emitters. Omit one and it does not run; schemas alone is a first-class use.
plugins: ['schemas', 'client', 'queries', 'mocks', 'faker', 'docs'],
client: 'pyreon',
validator: 'zod',
})
const { files } = generate(specText, config)Common mistakes
Expecting
plugins: ['faker']to emit ONLY factories. It expands to includeschemas, because the factories exist to produce data the schema accepts and are typed against the model types it exports.Combining
target: 'multiplatform'with a non-Pyreonclient. It is REFUSED, not downgraded: PMTC lowerscreateHttpandapi.endpoint(...)by name and cannot see through axios or ky, so native modules over one would lower to nothing -- the exact silent regression that target exists to catch.Importing
installMocks,mockRoutesor the faker factories from the generatedindex.ts. They are NOT there by design -- they live in./dev, so a page bundle has no import edge that could reach a fixture table or@faker-js/faker.Assuming
validator: 'pyreon'lowers more natively thanzodbecause it is first-party. Measured against the real compiler it is the OPPOSITE: nested objects and arrays of objects lower under zod and are DROPPED unders.*, sozodis the better native choice for any spec with nested models.Setting
pluginsand expecting the generatedpackage.jsonto change. ThesideEffectsmarker is emitted unconditionally -- it is a statement ABOUT the output rather than a plugin's output -- and it names./atlas.wrapper.tsxonly whenatlasis selected, because that file alone has a module-scope side effect.
definePlugin function
definePlugin<P extends LathePlugin>(plugin: P): P // LathePlugin = { name, requires?, setup?(ctx), transformDocument?(doc, ctx), emit?(ctx) }Declares a third-party Lathe plugin, listed in plugins beside the built-in names. setup({ config }) runs once per project and may throw to refuse a config; transformDocument(doc, { config, note }) rewrites the IR after filters, naming and operations (the argument is frozen — return a modified copy; note() reports a loss under code plugin); emit({ doc, config, reach, files, banner }) runs after every built-in and returns SourceFiles (banner added) or { path, contents, sideEffects? }. requires turns on the built-ins its files import. Failures are attributed (plugin xfailed inemit: …), each hook runs twice to prove determinism, and plugin files are listed in the manifest, compared by check, formatted by format, and refused on a path collision. Any hook may return a promise: generateAsync() (what the CLI and the Vite plugin run) awaits it, still running each hook twice sequentially for the determinism check, and generate() refuses a promise naming the plugin. On the CLI, --plugins schemas,./x.ts,some-package loads every non-built-in name as a plugin module resolved from the cwd.
Example
import { definePlugin, SourceFile } from '@pyreon/lathe'
export const pathTable = definePlugin({
name: 'path-table',
emit({ doc }) {
const f = new SourceFile('extras/paths.ts')
for (const op of doc.operations) f.line(`export const ${op.id}Path = ${JSON.stringify(op.path)}`)
return [f]
},
})
// pyreon.config.ts
export default { lathe: { input: './openapi.yaml', plugins: ['schemas', 'client', pathTable] } }Common mistakes
Mutating the document in
transformDocument. It is frozen and the write throws; return{ ...doc, operations: doc.operations.map(...) }.Emitting anything that varies between runs — a date, a random id, a
Setiterated in insertion order built from unordered input. Each hook runs twice and a disagreement is an error; sort withbyCodeUnit, neverlocaleCompare(locale-dependent across machines).Passing a plain object instead of a
definePluginresult. The config refuses it: a typo such astransform:fortransformDocument:would otherwise be a hook that silently never runs.Emitting to a path a built-in owns (
client.ts,index.ts) or Lathe's own bookkeeping (lathe-manifest.json,api-surface.json,package.json). Emit under a directory of the plugin's own.Renaming a model in a transform without rewriting every
{ kind: 'ref' }to it. The document is refused, naming the dangling references — use therenameRefswalker, ornaming.modelin the config.
formatFiles function
formatFiles(files: GeneratedFile[], format: ((code: string, path: string) => string | Promise<string>) | undefined): Promise<GeneratedFile[]>Applies the format config hook to generated files, preserving order, skipping Lathe's own bookkeeping (lathe-manifest.json, api-surface.json). The CLI and the Vite plugin call it after generateAsync() and BEFORE both writing and check's comparison, which is what keeps formatted, committed output from reading as stale. generate() itself stays synchronous and unformatted; call this when driving the pipeline programmatically.
Example
import { format as prettier } from 'prettier'
import { formatFiles, generate, resolveConfig } from '@pyreon/lathe'
const config = resolveConfig({ input: './openapi.yaml', format: (code, path) => prettier(code, { filepath: path }) })
const files = await formatFiles(generate(specText, config).files, config.format)Common mistakes
Formatting only on write.
lathe checkthen compares Lathe's raw bytes with your formatted files and reports everything stale — the formatter must run before the comparison too, which the CLI and Vite plugin do.A formatter that is not deterministic (plugin order, config read from the network). The output must regenerate byte-identically.
verifyNative function
verifyNative(files: GeneratedFile[], transform: TransformFn | undefined, compile?: NativeCompilers): VerifyReportRuns the real native compiler over the generated .native.tsx modules on both targets and returns a per-file verdict. The check is POSITIVE — it asserts the emitted Swift/Kotlin contains PyreonQuery< / PyreonZodSchema_ and contains no leaked web-only symbol — because zero warnings is not evidence: a standalone hook wrapping useQuery produces no warnings and emits Swift that cannot find the symbol. Passing undefined for transform yields ran: false with a reason, never a pass. Warnings are classified by CLASS per declaration (a verbatim reproduction is broken, a dropped field partial), identically for both targets; with compile (the project compiler's validateSwiftWithStubs / validateKotlin, as resolveNativeCompiler() returns them) each module is compiled too, and a compile error outranks every heuristic. When the compiler also exports validateSwiftFilesWithStubs / validateKotlinFiles, every module of a target is ALSO compiled together as one (report.modules), because a per-file compile cannot see two modules declaring the same type — and that is exactly how an app builds them; a failure there makes the report broken.
Example
import { generate, resolveConfig, resolveNativeCompiler, verifyNative, worstVerdict } from '@pyreon/lathe'
const { files } = generate(specText, resolveConfig({ input: 'spec', target: 'multiplatform' }))
const { transform, compile } = await resolveNativeCompiler()
const report = verifyNative(files, transform, compile)
if (!report.ran) console.warn('not verified:', report.reason)
if (worstVerdict(report) !== 'lowers') process.exitCode = 1Common mistakes
Reading
warnings.length === 0as success. That is exactly the shape this function exists to catch — PMTC reproduces an unrecognised call verbatim and says nothing, so the native build fails later with "cannot find useQuery in scope".Treating
ran: falseas a pass. A verification that could not run is not one that ran and succeeded;--strict-nativefails on it deliberately.Bundling a copy of
@pyreon/native-compilerinstead of resolving the project's. A verdict from a different compiler version than the one that will build the app is worse than no verdict.Reading only
report.filesfor the compile result. Each file can compile alone while the set does not (two modules declaring the same type);report.modulescarries the together-compile, andworstVerdictalready folds it in.
contractDiff function
contractDiff(before: ApiSurface, after: ApiSurface): ContractDiffThe client-contract diff lathe diff prints, as data: every change classified breaking or additive from the CLIENT's side (a response field turning optional breaks, a request field doing so does not), breaking first, each with the operations it affects — a model change is traced through other models to every operation that reaches it, and each operation carries its generated module and symbols when the surface came from a generation run. Read either side with readContractSide(text, name) (a spec or an api-surface.json) and render with renderContractDiff(diff, 'text' | 'markdown' | 'github' | 'json'). Pure — no filesystem.
Example
import { contractDiff, readContractSide, renderContractDiff } from '@pyreon/lathe/core'
const before = readContractSide(baseSpecText, 'main:openapi.yaml').surface
const after = readContractSide(headSpecText, 'openapi.yaml').surface
const diff = contractDiff(before, after)
if (diff.breaking > 0) console.log(renderContractDiff(diff, 'markdown'))Common mistakes
Diffing the generated TypeScript instead — formatting, ordering and doc comments move for non-contract reasons, and a real change hides inside that noise. The surface holds only what a caller can observe.
Reading
additiveas "no action needed" for an enum —member-addedon a RESPONSE model is breaking (aswitchcan now receive a member it does not handle); the classifier already applies that, so trustseverity, not the code name.Passing an
api-surface.jsonwritten by an incompatible Lathe —readContractSiderefuses a wrong-version surface by name rather than diffing a shape it does not understand.
loadOpenApi function
loadOpenApi(source: string, options?: { sourceUrl?: string; location?: string; readDocument?: (id: string) => string }): { doc: IrDocument; documents: string[] }Parses OpenAPI 3.x or Swagger 2.0 (JSON or YAML text) into the spec-agnostic IR. Every reduction the IR cannot represent is recorded in doc.notes with a stable code and a location. Deterministic: models and operations are sorted. Pass sourceUrl to resolve a relative server URL. With location and readDocument, cross-file $refs are resolved and bundled; schemas are hoisted into named models and other values are inlined. Operations carry typed error bodies, while OpenAPI 3.1 webhooks and callbacks land in doc.webhooks.
Example
import { loadOpenApi } from '@pyreon/lathe'
const { doc } = loadOpenApi(await readFile('./openapi.yaml', 'utf8'))
console.log(doc.models.length, 'models', doc.operations.length, 'operations')
for (const note of doc.notes) console.warn(note.code, note.at, note.message)Common mistakes
Calling it with only the text of a split spec. Without
locationandreadDocument, relative cross-file references cannot be resolved. Remote references are never fetched here;lathe pullbundles them.Ignoring
doc.notes. A spec with a remote$refor a non-JSON media type still produces output — with those pieces typedunknown. The note is the only signal. Filter onnoteSeverity(note) === 'loss'for the ones that change behaviour.Reading
op.bodyas a type. It is{ mediaType, encoding, type }—encoding(json/form/multipart/text/binary) decides the call argument (json:/form:/multipart:/body:), and a form body carries its per-fieldfieldEncoding.Reading pointers in Swagger 2 notes against the original file. The document is up-converted first, so a pointer under
#/components/schemasmay have originated under#/definitions. Swagger 1.x is refused.Expecting a custom YAML tag (
!Ref,!include) to be expanded. The reader refuses it with a line number instead of reading it as a plain string; resolve or bundle the spec first. Anchors, aliases and merge keys DO resolve.
resolveProjects function
resolveProjects(section: LatheSection | undefined): ResolvedConfig[]The multi-spec sibling of resolveConfig — ALWAYS returns a list, so a config with no projects array resolves to a one-element list (the top-level config alone) rather than a special case the caller has to branch on. Each declared project inherits the top-level target/plugins/client/validator and may override any of them; a project without a unique name — the key the CLI report and error messages use — throws.
Example
import { resolveProjects, generate } from '@pyreon/lathe'
const projects = resolveProjects({
target: 'multiplatform',
projects: [
{ name: 'billing', input: './billing.yaml', output: './src/gen/billing' },
{ name: 'catalog', input: './catalog.yaml', output: './src/gen/catalog' },
],
})
for (const config of projects) {
const { files } = generate(await readSpec(config), config)
}Common mistakes
Reaching for
resolveConfigwhen the section MAY declareprojects— it resolves only the top-level fields and silently ignores aprojectsarray;resolveProjectsis the one that fans it outPassing a CLI
--outalongside aprojectsconfig expecting it to apply to every project — it is REFUSED, since each project already declares its ownoutput
See also: resolveConfig · generate
resolveTransform / worstVerdict function
resolveTransform(): Promise<TransformFn | undefined> · worstVerdict(report: VerifyReport): "lowers" | "web-only" | "broken" | "skipped"resolveTransform resolves the CONSUMING PROJECT's own @pyreon/native-compiler (dynamic import(), never bundled) — the transform fn verifyNative needs — and returns undefined when the package is not installed, so a verify call never silently substitutes a different compiler version than the one that will actually build the app. worstVerdict reduces a whole VerifyReport (one verdict per generated .native.tsx file) to a single exit-worthy answer: "broken" beats "web-only" beats "lowers", and an un-run report (report.ran === false) is "skipped" — never conflated with a pass.
Example
import { resolveTransform, verifyNative, worstVerdict } from '@pyreon/lathe'
const report = verifyNative(files, await resolveTransform())
if (worstVerdict(report) !== 'lowers') process.exitCode = 1Common mistakes
Checking
report.files.lengthinstead ofworstVerdict(report)— a report with zero broken/web-only files can still be"skipped"(compiler absent), which is not the same as every file lowering
See also: verifyNative