Skip to content

Implement Generate

The generate lifecycle method fetches the upstream spec, parses it, and produces TypeScript types, a runtime index, and a registry. Core provides generatePipeline to orchestrate this; you supply provider-specific callbacks.

import { generatePipeline, type GeneratePipelineConfig } from "@intentius/chant/codegen/generate";
const result = await generatePipeline({
fetchSchemas: async (opts) => fetchMySchemas(opts.force),
parseSchema: (name, data) => parseMySchema(data),
createNaming: (results) => new NamingStrategy(results, myConfig),
generateRegistry: (results, naming) => buildLexiconJSON(results, naming),
generateTypes: (results, naming) => buildTypesDTS(results, naming),
generateRuntimeIndex: (results, naming) => buildRuntimeIndex(results, naming),
augmentSchemas: async (schemas, opts, log) => { /* patches, overlays */ },
augmentResults: (results, opts, log) => { /* fallbacks, synthetic resources */ },
generateExtraArtifacts: (results, naming) => ({ "extra.json": buildExtra(results) }),
});

See lexicons/aws/src/codegen/generate.ts for the complete AWS generation pipeline.

The pipeline executes these steps in order:

  1. fetchSchemas(opts) -> Map<typeName, Buffer> — raw schema files keyed by type name
  2. augmentSchemas(schemas, opts, log) (optional) — patch or overlay schemas before parsing. Skipped when opts.schemaSource is provided (e.g. during testing). Returns { schemas, extraResults?, warnings? }
  3. parseSchema(typeName, data) -> T | T[] | null — parse each schema buffer. Return null to skip a file, or an array when one file yields several results (the k8s OpenAPI spec and the GitLab CI schema both do)
  4. augmentResults(results, opts, log) (optional) — add synthetic resources or fallbacks after parsing. Returns { results, warnings? }
  5. createNaming(results) -> NamingStrategy — build the naming strategy from all parsed results
  6. generateRegistry(results, naming) -> string — JSON content for the lexicon registry
  7. generateTypes(results, naming) -> string — TypeScript declaration content (.d.ts)
  8. generateRuntimeIndex(results, naming) -> string — runtime index with factory exports (index.ts)
  9. generateExtraArtifacts(results, naming) (optional) -> Record<filename, string> — further derived tables from the same pass, returned on GenerateResult.extraArtifacts so they cannot skew against the registry and the types

Your parser’s return type must extend ParsedResult:

interface ParsedResult {
propertyTypes: Array<{ name: string }>; // property type definitions
enums: Array<unknown>; // enum definitions
}

The pipeline uses .propertyTypes.length and .enums.length for stats only — the arrays are passed through untouched to your generate callbacks. Extend ParsedResult with any additional fields your callbacks need:

interface MyParsedResult extends ParsedResult {
typeName: string;
description: string;
properties: Map<string, PropertyDef>;
attributes: string[];
}

See lexicons/aws/src/spec/parse.ts for the AWS parser that produces a ParsedResult.

The NamingStrategy class implements a 5-phase collision-free naming algorithm for TypeScript class names. Supply your provider’s data tables via NamingConfig:

import { NamingStrategy, type NamingConfig, type NamingInput } from "@intentius/chant/codegen/naming";
const config: NamingConfig = {
priorityNames: { "Provider::S3::Bucket": "Bucket" },
priorityAliases: {},
priorityPropertyAliases: {},
serviceAbbreviations: {},
shortName: (t) => t.split("::").pop()!,
serviceName: (t) => t.split("::")[1],
// Once the lexicon has published a surface, feed it the names it already
// shipped so an unrelated upstream change cannot rename them (chant #1459):
// reservedNames: reservedNamesFromSnapshot(snapshot),
};
const naming = new NamingStrategy(inputs, config);

See lexicons/aws/src/codegen/naming.ts for the AWS naming configuration with real data tables.

fetchWithCache and extractFromZip handle HTTP download + caching + zip extraction:

import { fetchWithCache, extractFromZip } from "@intentius/chant/codegen/fetch";
const zipData = await fetchWithCache({ url: SCHEMA_URL, cacheFile: CACHE_PATH });
const schemas = await extractFromZip(zipData, (name) => name.endsWith(".json"));

See lexicons/aws/src/spec/fetch.ts for the AWS schema fetcher.

Reach for fetchWithCache from src/spec/fetch.ts and nowhere else. Generation is the one phase allowed to fetch: a serializer, a plugin hook or a lint rule that reached upstream would put a network call on chant build, which Network Egress guarantees is offline and a guard test fails on. fetchWithCache also caches to disk and falls back to the committed snapshot when the endpoint is unreachable, so generation itself survives a network that is not there.

generatePipeline only requires fetchSchemas to hand back a Map<string, Buffer>. It never inspects the bytes, so fetching can just as well be a local file read or a transform over something that was never a schema. Three shipped lexicons take three different routes:

LexiconWhat its fetchSchemas returnsSource
k3sFive pinned Go source files from pkg/cli/cmds/, packed into one JSON envelope keyed K3s::Config. k3s publishes no schema; the config keys are the CLI flag names, and the flags are urfave/cli struct literals in that source (chant #1599)lexicons/k3s/src/spec/fetch.ts
k3dThe v1alpha5/schema.json from a tagged source tree rather than a moving branch, so the pin is the git taglexicons/k3d/src/spec/fetch.ts
cedarA file read with a three-step resolution order: the project’s cedar.schema config key, then schema.cedarschema in the project root, then a real schema bundled in the package. No network at alllexicons/cedar/src/spec/fetch.ts

Cedar’s third step is the one to copy if your spec is local. Without a bundled fallback, generate() has nothing to read in a fresh clone. Nothing gates it, and CI never exercises the pipeline. fountain does the same thing on the other axis: it fetches its pinned OpenAPI release and falls back to a committed fountain-openapi.snapshot.json when the network is absent (lexicons/fountain/src/spec/fetch.ts).

A hand-authored spec with no upstream at all belongs in src/spec/, read by fetchSchemas. Skip upstreamPin in that case. The rest of the pipeline is unchanged, and parseSchema still owns the shape.

Use createResource and createProperty to generate Declarable-marked constructors:

import { createResource, createProperty } from "@intentius/chant/runtime";
const MyResource = createResource("Provider::Service::Type", "my-lexicon", { arn: "Arn" });
const MyProperty = createProperty("Provider::Service::Type.PropType", "my-lexicon");

After generatePipeline returns a GenerateResult, write the files using writeGeneratedArtifacts:

import { writeGeneratedArtifacts } from "@intentius/chant/codegen/generate";
writeGeneratedArtifacts({
baseDir: pkgDir, // root of your lexicon package
generatedSubdir: "src/generated", // default; can be customized
files: {
"lexicon.json": result.lexiconJSON,
"index.d.ts": result.typesDTS,
"index.ts": result.indexTS,
},
});

See lexicons/aws/src/codegen/generate.ts for the complete generate + write flow.

import { generateRuntimeIndex, type RuntimeIndexConfig } from "@intentius/chant/codegen/generate-runtime-index";
const indexTS = generateRuntimeIndex(resources, properties, {
lexiconName: "my-lexicon",
intrinsicReExports: [],
pseudoReExports: [],
});
import { buildRegistry, serializeRegistry } from "@intentius/chant/codegen/generate-registry";
const registry = buildRegistry(results, naming, {
shortName: (t) => naming.shortName(t),
buildEntry: (r, shortName) => ({ resourceType: r.typeName, kind: "resource" }),
buildPropertyEntry: (r, shortName) => ({ resourceType: r.typeName, kind: "property" }),
});
const lexiconJSON = serializeRegistry(registry);

For lexicons with JSON Schema-based specs:

import { resolvePropertyType, extractConstraints, isEnumDefinition } from "@intentius/chant/codegen/json-schema";
  • resolvePropertyType(prop, schema, resolveDefName) — resolve a schema property to a TypeScript type string. Handles $ref, oneOf/anyOf, arrays, objects, and primitives. Inline enum arrays produce sorted string-literal union types (e.g. "Allow" | "Deny"). When a $ref points to an enum definition, it calls resolveDefName to produce a named enum type (e.g. Bucket_Status); pass null to fall back to "string"
  • extractConstraints(prop) — extract validation constraints (min/max, pattern, allowed values, enum arrays)
  • isEnumDefinition(def) — detect if a schema definition is a pure string enum (has enum array, no properties)
import { PseudoParameter, createPseudoParameters } from "@intentius/chant/pseudo-parameter";
const pseudos = createPseudoParameters({
"My::Region": "The deployment region",
"My::AccountId": "The account identifier",
});

For template import (converting existing templates to chant TypeScript):

import { BaseValueParser } from "@intentius/chant/import/base-parser";
import { hasIntrinsicInValue, irUsesIntrinsic, collectDependencies } from "@intentius/chant/import/ir-utils";
  • BaseValueParser — abstract base class for parsing template values into IR nodes
  • hasIntrinsicInValue(value, name) — check whether a raw value contains a call to the named intrinsic
  • irUsesIntrinsic(ir, name) — check whether an IR template uses a specific intrinsic
  • collectDependencies(value, isDependency) — collect resource dependencies from a value. The predicate is yours: given an object, return its logical id or null

For lexicons with string interpolation intrinsics (like CloudFormation’s Fn::Sub):

import { buildInterpolatedString, defaultInterpolationSerializer } from "@intentius/chant/intrinsic-interpolation";
// parts/values come straight from a tagged template literal's arguments.
const result = buildInterpolatedString(parts, values, defaultInterpolationSerializer);

buildInterpolatedString walks the template parts and the interpolated values in lockstep, rendering each value through the serializer you pass.

Pinning the upstream schema — upstreamPin

Section titled “Pinning the upstream schema — upstreamPin”

A lexicon generated from an upstream schema should pin the version it generated from, so a regeneration is reproducible and a version bump is a reviewable diff rather than whatever upstream published that morning. aws is the cautionary case: its spec URL is unpinned, so any prepack folds in whatever CloudFormation shipped today, and its resource count moves without a commit saying so.

upstreamPin tells the self-upgrade tooling where the pin lives and where to look for a newer one:

plugin.ts
upstreamPin: {
file: "src/spec/fetch.ts",
pattern: /export const K8S_SCHEMA_VERSION\s*=\s*"([^"]+)"/,
replace: (v, line) =>
line.replace(/= "[^"]+"/, `= "${v}"`),
upstream: { owner: "kubernetes", repo: "kubernetes", kind: "releases" },
},
FieldWhat it is
fileSource file holding the pinned version constant, relative to the package root
patternRegex whose first capture group is the current version
replace(v, line)Rebuild a matched line with the new version
upstream.kindreleases for published releases, tags for every git tag
upstream.tagSuffixOnly consider tags with this suffix — gitlab uses -ee

k8s, k3s, gcp, gitlab, docker and cedar declare one. checkPinnedUpgrade (packages/core/src/codegen/pinned-upgrade.ts) uses it to report an available upgrade, apply it, regenerate, and revert cleanly if the regeneration fails — which is the reason replace exists rather than the tooling doing a blind string substitution.

upstreamPin resolves a version constant against GitHub releases or tags. Some upstreams offer neither. The CloudFormation Registry schema is a single “latest” artifact on an S3-backed host, republished constantly, with no version anywhere in the path — so aws generated against whatever AWS shipped that morning, and a docs-only branch twice picked up a resource-count move with nothing in the commit explaining it (chant #1390).

Where there is no version, pin the content: lexicons/aws/src/spec/pin.ts records a sha256 over the extracted schemas — sorted typeName -> sha256(schema) — plus the type names it covered, and generation refuses when upstream no longer matches:

The upstream CloudFormation schema has moved since the pinned one.
pinned sha256:a2d99e08… (1650 resources, accepted 2026-08-03)
upstream sha256:1357a446… (1651 resource types, +1 against the pin)
added AWS::Fake::NewThing

Two details worth copying if you need the same thing:

  • Digest the extracted content, not the archive. A repackaged zip has a new ETag and new bytes while the schemas inside are identical; a pin that fires on that is noise nobody will keep.
  • Commit the type names beside the digest. Generated artifacts are not committed, so without the list an acceptance is one opaque hash replacing another. With it, the PR that moves the pin shows exactly what AWS added.

Unlike the emulator image pins, which only report (chant #808), this one refuses. An emulator that drifts fails a test you can see; a spec that drifts rewrites committed artifacts in a branch about something else.

With generation working, the next step is to create a serializer that converts evaluated resources to your target format.