Skip to content

Implement Generate

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/lexicon-authoring/generate/.
Wire the generate step of the lexicon I name with `generatePipeline` and the callbacks the page lists. Fetch only through `fetchWithCache`, and write into `src/generated/` with `writeGeneratedArtifacts`.
Declare `upstreamPin` in `src/plugin.ts` for the pinned spec version, run `npm run generate` and `npm run bundle` in the lexicon and `chant dev pinned-upgrade lexicons/<name>`, and open a pull request with the result.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

The generate lifecycle method fetches the upstream spec, parses it, and produces TypeScript types, a runtime index, and a registry. Core’s generatePipeline runs these steps; 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 still works offline.

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
labelOptional on a lone upstreamPin, required on every upstreamPins entry. The short name (clickhouse, postgres-17) you pass to pinned-upgrade to check one pin
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, sql uses -lts
upstream.tagVersionOptional (tag) => string | null. Maps a raw tag to the version the pin stores, or returns null to skip the tag. For upstreams whose tags are not versions
upstream.trackMajorOptional, default false. Only consider versions whose first number equals the pinned version’s, for a lexicon that pins one snapshot per major
alsoMovesOptional. Set when other constants move with the version and replace cannot compute them. The tooling reports the new version and this text, and edits nothing

k8s, k3s, gcp, gitlab, docker, cedar and sql declare one. checkPinnedUpgrade (packages/core/src/codegen/pinned-upgrade.ts) uses it to report an available upgrade and apply it. It then regenerates and reverts cleanly if the regeneration fails, which is why replace exists in place of a blind string substitution.

chant dev pinned-upgrade <dir> loads the plugin at <dir> and uses whatever upstreamPin it declares; there is no list of supported lexicons in core. A lexicon with no upstreamPin gets a message saying so. The sql pin sets alsoMoves, because its server image digest sits beside the version in src/spec/pin.ts and a new tag has a new digest: pinned-upgrade reports the newer -lts release and how to move both constants, and does not edit the file or regenerate.

LexiconUpgradeOp finds pinned lexicons the same way. Any lexicon that is not rolling (aws, azure, github and fly are) is checked through its plugin’s upstreamPin, so a lexicon that adds a pin needs no change in core. In pull-request mode the Op opens a version-bump PR for a pin replace can rewrite. A pin with alsoMoves stays report-only whatever the mode: the report names the newer version and the manual step, and the Op makes no branch and opens no PR.

A lexicon that pins more than one upstream keeps upstreamPin as it is and lists the rest in upstreamPins, an array of the same descriptor with a required, unique label. Every lexicon with one pin is unchanged. The sql lexicon has six. upstreamPin is ClickHouse’s (label clickhouse), and upstreamPins holds postgres-14 to postgres-18, one per Postgres major, built by postgresUpstreamPin(major) in src/spec/postgres-pin.ts. Each has alsoMoves (the image digest sits beside the version) and trackMajor, so it reports only its own major.

upstreamPin: { label: "clickhouse", /* ... */ },
upstreamPins: POSTGRES_MAJORS.map(postgresUpstreamPin),

chant dev pinned-upgrade <dir> checks upstreamPin first and then each upstreamPins entry in order, one report per pin (a JSON array with -f json), labelled sql/postgres-17. A failing pin is reported and the others still run. Pass a label to check one pin and query only its upstream; a label the plugin does not declare gets the list of labels it does.

LexiconUpgradeOp checks every pin too, and takes an optional pin label. Its summary has one section per pin, and a pin with alsoMoves is report-only. When a pin that replace can rewrite has an upgrade, the Op works on the first such pin: the branch is lexicon-upgrade/<lexicon>-<label>, the bump goes to that pin’s line, and the summary names the other pins to re-run with pin.

postgres/postgres publishes no GitHub releases, only tags, such as REL_18_6, REL_17_11, REL_18_BETA1 and REL_18_RC1, plus older spellings like REL9_6_24 and Release_2_0. With kind: "tags" alone none of them parses as a version, so nothing is ever found. tagVersion supplies the mapping, as in this sketch.

upstream: {
owner: "postgres",
repo: "postgres",
kind: "tags",
tagVersion: (tag) => {
const m = /^REL_(\d+)_(\d+)$/.exec(tag);
return m ? `${m[1]}.${m[2]}` : null;
},
trackMajor: true,
},

The tooling then behaves as follows.

  • A tag the function maps to null is skipped, so the pattern above drops the _BETA/_RC tags, REL_18_STABLE and the old spellings. Tags with rc, alpha, beta, preview, nightly, dev or canary in them are dropped before the function runs, whatever it would return.
  • Versions are compared numerically segment by segment, so 17.11 is newer than 17.9. The mapped value (18.6) is what the report shows as to and what replace(v, line) receives. The pinned constant may hold the mapped version or the raw tag (REL_18_6). Both read as the same version.
  • With tagVersion set the tool reads every page of the tag list (100 per page, at most 20 pages), because the tags API is not ordered by version and the newest minor is not on the first page. Without it, only the first 50 items are read, as before.
  • trackMajor: true keeps the answer in the pinned version’s major line. A pin on 17.9 reports 17.11 and never 18.6. This is for a lexicon that commits one snapshot per supported major: each pin is checked against its own line. Without it the highest version of any major wins.

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. 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

Copy two details 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.

Next, create a serializer that converts evaluated resources to your target format.

chant