Lexicon Authoring Overview
A lexicon is a plugin that adds one platform to chant, such as CloudFormation or Kubernetes. It contributes the platform’s types, a serializer, and the rules that check the output. You can write one for any platform or internal tool.
When You Need This Section
Section titled “When You Need This Section”You’re in the right place if you want to:
- Add support for a new cloud provider (GCP, Azure, Kubernetes)
- Create an internal lexicon for your organization’s infrastructure patterns
- Extend chant with custom resource types and lint rules
If you’re using an existing lexicon (like AWS CloudFormation), see the User Guide instead.
The LexiconPlugin Interface
Section titled “The LexiconPlugin Interface”Every lexicon exports a LexiconPlugin object:
import type { LexiconPlugin } from "@intentius/chant";
export const myPlugin: LexiconPlugin = { name: "my-lexicon", serializer: mySerializer, // ... lifecycle methods};Required fields
Section titled “Required fields”| Field | Type | Description |
|---|---|---|
name | string | The resolution key. Core imports @intentius/chant-lexicon-<name> and users write lexicons: ["<name>"] (packages/core/src/cli/plugins.ts) |
serializer | Serializer | Serializer for build output |
Required lifecycle methods
Section titled “Required lifecycle methods”Four methods, all four required. isLexiconPlugin() in packages/core/src/lexicon.ts
accepts an object only when name, serializer and these four are present, so a
plugin missing one is rejected at load rather than at first use.
| Method | Signature | Description |
|---|---|---|
generate | (options?: { verbose?: boolean }) => Promise<void> | Generate lexicon artifacts (types, registry, runtime index) from the upstream spec |
validate | (options?: { verbose?: boolean }) => Promise<void> | Validate generated artifacts |
coverage | (options?: { verbose?: boolean; minOverall?: number }) => Promise<void> | Analyze spec coverage across resource dimensions |
package | (options?: { verbose?: boolean; force?: boolean }) => Promise<void> | Package the lexicon into a distributable bundle |
Optional members
Section titled “Optional members”Every optional member of LexiconPlugin. lexicon-doc-coverage.test.ts parses
the interface and asserts this table lists all of them, so a member added
without a row here fails the build (chant #1347). The table had previously
drifted to 16 of roughly 30, losing the whole observation family beyond the two
best-known readers.
Lint and audit
| Member | Returns | Description |
|---|---|---|
lintRules() | LintRule[] | Imperative lint rules |
declarativeRules() | RuleSpec[] | Declarative lint rules (compiled via rule()). No shipped lexicon uses this — see Write Lint Rules |
propertyClassNames() | string[] | Class names whose instances are property-kind declarables (createProperty). The core COR001, COR004 and COR009 heuristics leave them out, since they live inside the resource that holds them (#2957). Grafana implements it for its panels, rows, queries and variables |
postSynthChecks() | PostSynthCheck[] | Post-build validation checks |
activityContracts() | ActivityContract[] | Args and return schemas for the Op activities this lexicon implements, so another lexicon’s post-synth check can validate a step that calls one (#2101). Rarely needed: the ordinary route is exporting them from @intentius/chant-lexicon-<name>/op/activity-contracts, which loadActivityContracts resolves by convention, the same way loadActivities resolves the implementations. This member is for a lexicon whose contracts are not reachable at that subpath |
activities() | Record<string, ActivityFn> | The Op activity implementations, keyed by the name a step’s fn uses (#2520). Read only for a lexicon declared by module path in chant.config.ts, which has no op/activities subpath. A package-backed lexicon leaves it out |
coverageReport() | Promise<{ unaccountedKinds?: string[] }> | Offline kind accounting from committed snapshots. chant dev check-lexicon fails when a lexicon that declares it leaves any upstream kind unaccounted (chant #1330) |
auditCatalog() | Record<string, RuleMeta> | Title, tier, fix kind and authority for this lexicon’s checks, for chant audit — see Post-Synth Checks |
auditEntities(content) | Map<string, Declarable> | Promise<Map<string, Declarable>> | Parse standalone manifest content into the entity graph so entity-reading checks fire on chant audit (parse-to-graph). May be async for a lexicon whose parser inherently is (e.g. a wasm-backed HCL parser). Must tolerate malformed input |
lintPresets() | Record<string, string[]> | Preset name mapped to the post-synth check ids that preset enables, e.g. recommended/all. A project selects one via lint.presets (chant #2113). See Post-Synth Checks |
Language surface
| Member | Returns | Description |
|---|---|---|
intrinsics() | IntrinsicDef[] | Lexicon-specific intrinsic functions |
pseudoParameters() | string[] | Pseudo-parameter names |
completionProvider() | CompletionItem[] | LSP completions for resource/property names |
hoverProvider() | HoverInfo | undefined | LSP hover information for resource types |
codeActionProvider() | CodeAction[] | LSP code actions. No shipped lexicon uses this |
symbolResolvers() | SymbolResolver[] | Resolve path#symbol in this lexicon’s languages for chant workspace graph --intent, such as a table in a .sql file, for a file in a member that configures the lexicon (#3313). See Symbol resolvers |
Import and migration
| Member | Returns | Description |
|---|---|---|
detectTemplate(data) | boolean | Detect whether a parsed template belongs to this lexicon |
templateParser() | TemplateParser | Parser for importing external templates |
parserOptions() | ParserOptionSpec[] | Options templateParser takes, set with chant import --parser-option |
templateGenerator() | TypeScriptGenerator | Generator for converting IR to TypeScript |
embeddedImporters() | EmbeddedContentImporter[] | Import this lexicon’s content where another lexicon’s resource holds it: otel collector configs in a k8s ConfigMap, prometheus rule groups in a PrometheusRule and alertmanager.yml in a ConfigMap, grafana dashboards in a ConfigMap. Core finds the owner at run time, so the host needs no dependency on it (#2962). See Implementing Import |
agentConfigImporter() | AgentConfigImporter | Re-express local agent configuration (skills, MCP servers, instruction files) discovered by chant audit --agents as this lexicon’s resources — fountain implements it for Agent/Environment |
migrationSource(from) | MigrationSource | undefined | Translate another lexicon’s format into this one — gitlab and forgejo implement migrationSource("github") |
Observation — see Implementing Observation
| Member | Returns | Description |
|---|---|---|
describeResources() | DescribeResourcesResult | What this estate manages, keyed by chant entity name. The thin read |
observeResourcesDeep() | DeepObservationResult | The full live property tree per entity, for property-level drift |
deepNormalizationHooks | DeepNormalizationHooks | Which fields are server-populated or order-insensitive, applied to both the live and declared trees |
healthTypes | readonly string[] | The entity types observeHealth() reads, as data, so core picks the entities without calling it (chant #4177) |
observeHealth() | HealthReadResult | Whether each declared service of those types is up, for chant lifecycle health. A failed read is unobserved, never unhealthy |
predictBehaviour() | BehaviourResult | Cost per hour, headroom, an error-rate expectation, a resilience verdict and a right-size hint per entity, at a stated traffic level, every figure carrying its engine, version, tolerance and modeled/validated basis. The one member of this family that reports a prediction rather than a reading, and the type keeps the two apart: money exists only as a per-hour rate for an imagined hour, the options cannot carry a credential, and a missing, unreachable, unfunded or throttled engine returns a named refusal instead of an empty report. Options mirror observeResourcesDeep()’s plus traffic and edges, the latter carrying IREdge so the declared and live paths hand the engine the same graph shape (chant #2356) |
behaviourKinds | BehaviourKinds | What this lexicon’s own entity types are worth to a behaviour engine: mapped rows giving an engine kind and the declared property its size is read from, unmapped rows stating that a real type carries no rate and why, or nothingPriced saying it once for the whole substrate — a CI workflow is not an estate. Rows rather than a method, because core does the predicting and a lexicon only says what its types mean; a type this lexicon claims and has no row for is reported unknown-type, the one verdict that is a defect rather than a decision. Where rows are keyed on something other than the entity type — terraform’s every resource block arrives as Terraform::Resource — resolveType names the key (chant #2382) |
observeDependencies() | DependencyObservation | What the estate relies on but does not declare — a shared subnet, another team’s network |
classifyDisruption() | Record<string, DisruptionVerdict> | Whether applying a pending update replaces the resource or mutates it in place, from the spec this lexicon compiled. Every degradation is unknown |
ambientKinds() | string[] | Kinds this lexicon can enumerate beyond the declared estate, so a caller knows --ambient is relevant without paying for a scan |
observeAmbient() | Record<string, ResourceMetadata> | Resources of a managed kind that exist while being neither declared nor referenced — the unattached security group |
describeStackStatus() | StackStatusObservation | null | Whether one deploy unit is present and healthy, by deployed name. For multi-stack component projects |
describeIdentity() | DescribeIdentityResult | The principal chant would act as here and the scope it resolves to, from the substrate’s own self-query. Read-only, and never a credential — see chant lifecycle whoami |
teardownOwned() | TeardownEnumeration | What this lexicon would delete for one marker identity (stack + env) — enumeration only; unreadable kinds report as holes |
executeTeardown() | TeardownExecution | Delete the handed-over teardown candidates, one outcome per candidate (deleted / failed / not-prunable / retained with reason) — core retries failures once |
listArtifacts() | Record<string, ArtifactMetadata> | Runtime artifacts rather than 1:1 resources — Helm releases, Docker containers |
ownershipChannel | OwnershipChannel | Where this lexicon can stamp and read chant’s ownership marker, per read path |
referenceCatalog | ReferenceCatalog | How observed resources reference each other, so chant graph --live can rebuild edges from a bag of nodes |
enrichLiveAttrs() | Record<string, Record<string, unknown>> | Extra node attributes when describeResources metadata is too thin to carry references |
exportResources() | ExportedTemplate | Full-fidelity config read from a live API — see Implementing Live Export |
subscribeChanges() | ChangeSubscription | Subscribe to the substrate’s own change stream so chant operator can wake a tick early. onChange takes no arguments, so a notification can never become an observation; only implement it where the stream needs nothing deployed into the observed account. The verdict table in the operator guide records where that holds |
Project surface
| Member | Returns | Description |
|---|---|---|
initTemplates(template?) | InitTemplateSet | Source file templates for chant init scaffolding |
skills() | SkillDefinition[] | AI assistant skills |
docs() | Promise<void> | Generate documentation pages |
mcpTools() | McpToolContribution[] | MCP tool contributions — see LSP & MCP Providers |
mcpResources() | McpResourceContribution[] | MCP resource contributions |
composites() | CompositeEntry[] | The composites the lexicon exports, as static data: name, description, the resource kinds each bundles, and its parameters. The MCP composites tool, the chant://composites resource and search read it. npm run generate:composite-catalogs writes it to src/composites/catalog.ts |
commands() | CommandGroup | A CLI verb group mounted under chant <name> <verb> — core dispatches to it wholesale and knows nothing about what’s inside. <name> cannot be one of core’s own command words or workspace, which core reserves; a plugin that tries fails to load with an error naming the word |
changedUnits(ctx) | Promise<string[]> | The units a change touched, from the changed file paths, for a lexicon whose units are not chant source. The terraform lexicon answers with the terraform.roots whose directory, local modules or var files changed. chant components fan-out --base and the pull-request loop read it (#3183) |
emulator | EmulatorCapability or a list of them | Local emulator(s) chant emulator boots — see Declaring a Local Emulator |
generateComponentPipeline() | ComponentPipelineResult | Turn the component graph into CI YAML. CI-provider lexicons only (gitlab, github, forgejo) |
generateOpWavesPipeline() | OpWavesPipelineResult | Render an op waves spec (ordered waves of Op runs, each behind one gate) as CI YAML. CI-provider lexicons only (gitlab, github, forgejo) |
generateOpPipeline() | OpPipelineResult | Turn a set of scheduled Ops into cron-triggered CI YAML, one file per Op. CI-provider lexicons only (gitlab, github, forgejo) |
opRuntime | OpRuntimeProvider | Host Op runs: chant run <op> --on <this lexicon> starts, watches, cancels and wakes runs through it. Core ships the built-in local runtime and selects it when no --on is passed |
buildRoots() | Promise<BuildRootContribution> | Render config-declared build roots that are not typed chant source into entities, merged into the build before partitioning — k8s implements it for k8s.kustomize.roots, and sql reads the .sql files in the build’s source directory (ctx.sourceDir, listed with findSourceFiles from @intentius/chant/discovery/files) |
projectCodegen() | ProjectCodegen | Generate typed code into the project from sources its config declares, written by chant generate and checked for drift by every build. k8s implements it for k8s.crds, helm for helm.charts |
graphMeta(entities) | Record<string, unknown> | undefined | Whole-read facts for chant graph --format ir to put under meta. The otel lexicon answers { collector }, the topology of the project’s collector config, which chant workspace graph lists as collectors |
graphEdges(entities) | { references, pipelines? } | undefined | Edges the lexicon’s entities write as strings, resolved to entity keys: each { from, to, kind }, or dangling with the string as written when nothing declared matches. pipelines maps a workflow or pipeline name to its entities. The github, forgejo and gitlab lexicons report CI needs, uses:, trigger:, include: and extends this way. See chant graph |
workspaceOutputs(entities, unread?) | { outputs, gaps? } | undefined | The names this lexicon’s declarations expose as workspace outputs, so another member’s link can target them without an output(...). The sql lexicon reports each table as <schema>.<table> and each column as <schema>.<table>.<column>. chant workspace check calls it offline on the folded source, and a file that did not fold makes the list a gap, so a link to a name outside it is unresolved rather than missing. See Workspace outputs (#3931) |
configSchema | LexiconConfigSchema (a ZodObject) | The shape of this lexicon’s own chant.config.ts namespace — see Owning a config namespace |
upstreamPin | UpstreamPin | How this lexicon pins its upstream schema and where to look for a newer one, for self-upgrade tooling |
upstreamPins | (UpstreamPin & { label: string })[] | More pins, for a lexicon that pins several upstreams (sql: one Postgres server per major). Each needs a unique label |
init() | void | Promise<void> | Called once when the plugin loads. No shipped lexicon uses this |
Owning a config namespace
Section titled “Owning a config namespace”A lexicon may own the top-level chant.config.ts key named after it, such as
k8s.profiles, fountain.profiles or forgejo.runnerLabels. Declare its shape
and core validates the namespace at load:
import { z } from "zod";import type { ChantConfig } from "@intentius/chant/config";
export const myConfigSchema = z.strictObject({ endpoint: z.string().optional(), profiles: z.record(z.string(), z.strictObject({ region: z.string() })).optional(),});
export type MyConfig = z.infer<typeof myConfigSchema>;
declare module "@intentius/chant/config" { interface ChantConfig { mylexicon?: MyConfig; }}
/** Compile-time proof that the augmentation above reached `ChantConfig`. */export type MyConfigNamespace = NonNullable<ChantConfig["mylexicon"]>;import { myConfigSchema } from "./config";
export const myPlugin: LexiconPlugin = { name: "mylexicon", serializer, configSchema: myConfigSchema, // ...};lexicons/forgejo/src/config.ts is the shipped example of exactly this file.
One schema, three consumers: core validates against it, the exported type is
inferred from it, and that inferred type is what augments ChantConfig. The
runtime rule and the compile-time one cannot disagree because there is only one
of them.
This fixes two bugs (chant #1344):
- An unknown key inside a declared namespace is now an error. The project
config schema is
.passthrough(), soforgejo: { runnerLabel: ... }, a typo forrunnerLabels, was accepted and ignored, leaving the dialect on its defaults with nothing said. satisfies ChantConfigcompiles.ChantConfigis a closed interface, so a config carrying a lexicon key waserror TS2353until the lexicon augmented it. The augmentation applies only when the config file has the package in its program, which a bareimport "@intentius/chant-lexicon-mylexicon"guarantees. Forgetting it is a compile error, which is the safe way to fail.
Use z.strictObject for nested objects. validateLexiconConfig()
(packages/core/src/lexicon-config.ts) calls schema.strict() on the top level
of your namespace, and one level is all that reaches, so a typo in
profiles.prod.regoin lives deeper than the strictness core adds for you.
Declaring nothing keeps the old passthrough for your namespace, so this is
opt-in. chant dev check-lexicon warns when a lexicon reads its own namespace
without declaring one.
Registering in chant.config.ts
Section titled “Registering in chant.config.ts”Users register lexicons in their project config:
import type { ChantConfig } from "@intentius/chant";
export default { lexicons: ["my-lexicon"],} satisfies ChantConfig;The CLI resolves lexicons by looking for @intentius/chant-lexicon-{name} packages in node_modules.
Authoring Workflow
Section titled “Authoring Workflow”Each step depends on the one before it. Everything else needs steps 1 through 3.
| # | Step | What it produces |
|---|---|---|
| 1 | Scaffold | chant init lexicon <name> writes the project, including src/plugin.ts and src/serializer.ts |
| 2 | Implement Generate | src/spec/fetch.ts, src/spec/parse.ts and src/codegen/generate.ts, which fill src/generated/ |
| 3 | Create a Serializer | src/serializer.ts, turning the evaluated entities into your target format |
| 4 | Write Lint Rules | src/lint/rules/, imperative or declarative |
| 5 | Post-Synth Checks | src/lint/post-synth/, plus the auditCatalog() metadata behind chant audit |
| 6 | Testing | plugin.test.ts and serializer.test.ts, both required by chant dev check-lexicon |
| 7 | LSP & MCP Providers | src/lsp/completions.ts and src/lsp/hover.ts, registered on the plugin |
| 8 | Skills | src/skills/, the AI agent skill files |
| 9 | Package & Publish | dist/, via packagePipeline and writeBundleSpec |
| 10 | CI & Distribution | chant dev onboard wires CI, Docker smoke tests, and npm publishing |
Verify the result against the Completeness Checklist,
which is the tier list chant dev check-lexicon prints.
Example: Minimal Custom Lexicon
Section titled “Example: Minimal Custom Lexicon”import type { LexiconPlugin, Serializer, Declarable } from "@intentius/chant";
const serializer: Serializer = { name: "acme", rulePrefix: "ACM", serialize(entities: Map<string, Declarable>): string { const manifests: Array<Record<string, unknown>> = []; for (const [name, entity] of entities) { manifests.push({ apiVersion: "v1", kind: entity.entityType, metadata: { name }, }); } return JSON.stringify(manifests, null, 2); },};
export const acmePlugin: LexiconPlugin = { name: "acme", serializer,
async generate() { // TODO: Fetch upstream schemas and run generatePipeline throw new Error("Not yet implemented"); },
async validate() { // TODO: Validate generated artifacts console.error("All checks passed."); },
async coverage() { // TODO: Analyze spec coverage console.error("Coverage analysis not yet implemented."); },
async package() { // TODO: Run packagePipeline to produce a distributable bundle throw new Error("Not yet implemented"); },};Only the four lifecycle methods above are required, so this compiles without
coverageReport and without any observation or LSP member. It reads only
entity.entityType; a serializer that emits authored properties reads
entity.props, which is the subject of Create a Serializer.
Publish it as @intentius/chant-lexicon-acme. The package name must follow
that pattern. loadPlugin() in packages/core/src/cli/plugins.ts imports
@intentius/chant-lexicon-<name> for each entry in the project’s lexicons
array, so a package published under any other name will not resolve.
Background Reading
Section titled “Background Reading”These architecture pages cover the internals a lexicon author needs:
- Core Type System —
Declarable,ResourceType, and the evaluator’s type model - Lexicon Registry — how chant discovers, loads, and validates lexicon plugins
- Serializer Architecture — the serialization contract your plugin must implement