Skip to content

Implementing Import

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/importing/.
Implement import for the lexicon I name: `src/import/parser.ts` on `parseYAML` with an allowlist of props, `src/import/generator.ts`, the plugin members that return them, and fixtures in `src/import/testdata/`.
Write `parser.test.ts`, `generator.test.ts` and `roundtrip.test.ts` as the testing checklist describes, and run `npx vitest run lexicons/<name>`. Try `chant import --lexicon <name> <fixture> -o <dir>` on each fixture.
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 chant import command reads existing infrastructure files (YAML, JSON, Dockerfiles) and emits TypeScript source using your lexicon’s types. Six optional plugin members cover import and its neighbours; a lexicon without import implements none of them.

MemberReturnsDrives
detectTemplate(data)booleanWhich lexicon owns a template file. Core parses the file, asks the project’s lexicons and then the installed ones, and takes the first true (detectTemplateLexicon in packages/core/src/cli/commands/import.ts).
templateParser(options?)TemplateParserSource format to TemplateIR. options holds the --parser-option values, checked and typed.
parserOptions()ParserOptionSpec[]Optional. The options templateParser takes (name, type, description). chant import --parser-option refuses any name not listed.
templateGenerator()TypeScriptGeneratorTemplateIR to GeneratedFile[].
embeddedImporters()EmbeddedContentImporter[]This lexicon’s content when it sits inside another lexicon’s resources, such as a collector config in a k8s ConfigMap. See Embedded content.
migrationSource(from)MigrationSource | undefinedchant migrate: another provider’s format into this lexicon’s.
agentConfigImporter()AgentConfigImporterchant import --agents: local agent configuration into this lexicon’s resources.

Two interfaces in packages/core/src/import/ are the whole contract between a lexicon and the import pipeline:

interface TemplateParser { parse(content: string, context?: ParseContext): TemplateIR; }
interface TypeScriptGenerator { generate(ir: TemplateIR): GeneratedFile[]; readonly ownsLayout?: boolean; }
interface GeneratedFile { readonly path: string; readonly content: string; }

TemplateIR follows CloudFormation’s vocabulary, and every lexicon maps onto it:

interface TemplateIR {
readonly resources: ResourceIR[];
readonly parameters: ParameterIR[];
readonly conditions?: ConditionIR[]; // named boolean expressions (#2069)
readonly outputs?: OutputIR[]; // template outputs (#2069)
readonly metadata?: Record<string, unknown>;
readonly warnings?: string[]; // what you read but cannot carry
}
interface ResourceIR {
readonly logicalId: string; // becomes the export name
readonly type: string; // "GitHub::Actions::Workflow", "service", …
readonly properties: Record<string, unknown>; // everything the source declared
readonly metadata?: Record<string, unknown>;
readonly condition?: string; // gating condition name, when declared
}

Three rules follow from that shape:

  • Capture all properties. properties is a lossless intermediate, so every property from the source file should appear there for the generator to emit. A silently dropped property breaks the roundtrip.
  • Name what you cannot carry. A section you read and deliberately drop goes in warnings, which chant import prints (parseAndWrite pushes ir.warnings straight into the result). Silence is the failure mode #2069 closed.
  • logicalId becomes an export name. Core lowercases its first character when it writes the barrel re-exports, so emit a valid JavaScript identifier.

generateOrganizedFiles in packages/core/src/cli/commands/import.ts decides the file layout unless your generator takes it over. File import and live import (chant import --from <env>) both go through it.

By default an IR of up to three resources gets one generate(ir) call and core writes whatever paths you return. Above three, core buckets resources by category (storage / compute / network / other) and calls generate() once per non-empty bucket. The category is guessed from substrings of each resource’s type. It writes generated[0].content as <category>.ts and adds an index.ts that re-exports each resource’s lowercased logicalId from its category file. Any further files a call returns are not written, and the import warns with their paths. A call that returns nothing is skipped with a warning. So under the default layout your first file must be the whole content for the resources you were handed, and resources in different buckets cannot import each other.

Set ownsLayout: true on the generator when that split does not fit. That is the case when your resources refer to each other (collector pipelines to components, or Alertmanager routes to receivers), or when one call returns several modules. Core then calls generate(ir) once with the whole IR, whatever its size. It writes exactly the files you return at the paths you give, and adds no index.ts of its own. The otel and prometheus generators return several modules per call but leave ownsLayout unset. Each of their parsers emits a single IR resource per config, so the split never runs for them.

export class MyGenerator implements TypeScriptGenerator {
readonly ownsLayout = true;
generate(ir: TemplateIR): GeneratedFile[] {
// one module per component, plus whatever barrel you want
}
}

Some sources carry another lexicon’s source inside them. A Kubernetes ConfigMap can hold a collector’s config.yaml, a Prometheus rule file or a Grafana dashboard’s JSON, and a PrometheusRule’s spec.groups are Prometheus rule groups. chant import hands such content to the lexicon that owns it, so it comes out as that lexicon’s typed declarations, and the host resource refers to them (#2962):

import { ConfigMap } from "@intentius/chant-lexicon-k8s";
import { collectorYaml } from "@intentius/chant-lexicon-otel";
import { debug } from "./otel-agent/exporters";
import { traces } from "./otel-agent/pipelines";
import { otlp } from "./otel-agent/receivers";
export const configMapOtelAgent = new ConfigMap({
metadata: { name: "otel-agent" },
data: { "config.yaml": collectorYaml([otlp, debug, traces]) },
});

A ConfigMap’s alertmanager.yml is handed to prometheus the same way.

Neither side depends on the other. The contract is in packages/core/src/import/embedded.ts:

  • The host’s parser offers content. parse(content, context) calls context.embedded.resolve(site) for each place content can sit, and puts the EmbeddedReference it gets back into its IR in place of the raw value. undefined means keep the value as written. A site gives its location for messages, a directory name, the content as text or as the document it would be in a file of its own, and select when the host’s value is one member of that document ("groups" for spec.groups). It may also name an expectedOwner, which is used only to warn when that lexicon is not installed. context is absent outside chant import, and a parser then keeps everything as written.
  • The owner declares an EmbeddedContentImporter in embeddedImporters(). matches(site) says whether it imports the content. import(site) returns the modules that declare it (paths relative to their own directory) and an EmbeddedValue: the declarations the host’s value is built from, either as a list or as a single one, and optionally a function of the owner’s package to pass them through. The otel importer returns collectorYaml([...]) over every component and pipeline. Prometheus gives the list of RuleGroups, ruleFileYaml([...]) for a rule file held as text, or alertmanagerYaml([...]) for an alertmanager.yml, and grafana gives dashboardJson(dashboard).
  • Content an owner matches can still be kept as written. keepsAsWritten(site) returns the reason, which becomes the import’s warning, and undefined means import it. Use it when importing would change the content on the next build. Grafana keeps a v2 dashboard this way: it reads v2, but builds v1 JSON, so the ConfigMap would change from v2 to v1 (#3031).
  • Core finds the owner at run time. When the parser offers anything, core asks the project’s lexicons and every installed lexicon whose /detect module’s detectTemplate recognizes an offered document, loads only those in full, and parses again with their importers. It writes each import’s modules in a directory of its own beside the host’s files. Warnings name content the owner could not carry, an import that failed, and content whose expected owner is not installed. That content is kept as written.
  • The host’s generator renders the reference with renderEmbeddedReference(ref, scope). An EmbeddedImportScope collects the imports the module needs and aliases a name the module already uses (a second collector’s otlp becomes otlp2).

chant import --from <env> delegates the same way (#2995). An export has no parser call of its own, so an exporter that maps through its import parser returns reparse(context) on its ExportedTemplate: the same objects parsed again with a ParseContext, with no second read of the live target. Core probes and resolves through it exactly as above, and writes the owners’ modules beside the generated files. An export without reparse keeps embedded content as read. See Implementing Live Export.

k8s is the only host so far (lexicons/k8s/src/import/embedded.ts). otel, prometheus and grafana are the owners, each in its src/import/embedded.ts. The round trip over real manifests is lexicons/k8s/src/import/embedded-roundtrip.test.ts, and the same through live import against a fake cluster is embedded-live.test.ts beside it.

FileResponsibility
src/import/parser.tsParse source format into IR
src/import/generator.tsIR into TypeScript source
src/import/roundtrip.test.tsEnd-to-end fixture tests

Two shapes are in use. github and gitlab implement TemplateParser / TypeScriptGenerator directly, so plugin.ts just returns new GitHubActionsParser(). docker keeps a lexicon-local DockerIR ({ kind, name, props }) that suits its multi-section source, and bridges it in src/import/adapter.ts with a DockerTemplateParser / DockerTemplateGenerator pair that maps DockerIR onto ResourceIR and back. Pick the second only when your source format does not fit ResourceIR; the examples below are docker’s, so they show the local shape.

import { parseYAML } from "@intentius/chant/yaml";

parseYAML reads YAML with js-yaml and the YAML 1.2 core schema, plus merge keys and three things chant has always done: yes and no are booleans, a date stays a string, and a local tag on a scalar or sequence (!reference [.base, script], !Ref MyApi) reads as its text. It returns {} for a top-level list (use parseYAMLDocument for the list), and skips --- and ... lines. Custom regex parsers miss edge cases and drift from the core parser’s behaviour. Input it cannot read, such as a key indented under a scalar or an alias with no anchor, throws a YAMLParseError naming the line; let it reach chant import, which reports it as a parse failure.

For multi-section formats (Docker Compose, Kubernetes), parse each section independently by reading keys from the top-level object:

export class DockerParser {
parse(content: string): ParseResult {
if (!content.trim()) return { entities: [], warnings: [] };
const entities: DockerIR[] = [];
const doc = parseYAML(content) as Record<string, unknown>;
const services = doc["services"];
if (services && typeof services === "object" && !Array.isArray(services)) {
for (const [name, raw] of Object.entries(services as Record<string, unknown>)) {
entities.push({ kind: "service", name, props: extractProps(raw, SERVICE_PROPS) });
}
}
const volumes = doc["volumes"];
if (volumes && typeof volumes === "object" && !Array.isArray(volumes)) {
for (const [name, raw] of Object.entries(volumes as Record<string, unknown>)) {
entities.push({ kind: "volume", name, props: extractProps(raw, VOLUME_PROPS) });
}
}
// ... networks, configs, secrets, etc.
return { entities, warnings };
}
}

Define an allowlist of known property names and copy only those into props:

const SERVICE_PROPS = [
"image", "ports", "environment", "volumes", "depends_on",
"restart", "healthcheck", "labels", "command", "entrypoint",
"networks", "build", "deploy", "secrets", "configs",
] as const;
function extractProps(raw: unknown, allowed: readonly string[]): Record<string, unknown> {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const obj = raw as Record<string, unknown>;
const props: Record<string, unknown> = {};
for (const key of allowed) {
if (key in obj) props[key] = obj[key];
}
return props;
}

This keeps props predictable and avoids emitting internal YAML anchors or parser artifacts.

For Dockerfiles with multiple FROM stages, split on FROM boundaries and produce a stages array:

export class DockerfileParser {
parse(name: string, content: string): DockerfileIR {
const stages: DockerfileStage[] = [];
let current: DockerfileStage | null = null;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const match = trimmed.match(/^([A-Z]+)\s+([\s\S]+)$/);
if (!match) continue;
const [, instruction, value] = match;
if (instruction === "FROM") {
const asMatch = value.match(/^(.+?)\s+[Aa][Ss]\s+(\S+)$/);
current = asMatch
? { from: asMatch[1].trim(), as: asMatch[2].trim(), instructions: [] }
: { from: value.trim(), instructions: [] };
stages.push(current);
} else if (current) {
current.instructions.push({ instruction, value: value.trim() });
}
}
return { kind: "dockerfile", name, stages };
}
}
export class DockerGenerator {
generate(entities: DockerIR[]): GenerateResult {
const imports = new Set<string>();
const lines: string[] = [];
for (const entity of entities) {
switch (entity.kind) {
case "service":
imports.add("Service");
lines.push(generateService(entity));
break;
case "volume":
imports.add("Volume");
lines.push(generateVolume(entity));
break;
// ...
}
}
const importLine = `import { ${[...imports].sort().join(", ")} } from "@intentius/chant-lexicon-docker";`;
return { source: [importLine, "", ...lines].join("\n"), warnings: [] };
}
}

Emit all props with JSON.stringify + key unquoting

Section titled “Emit all props with JSON.stringify + key unquoting”
function generateService(svc: ServiceIR): string {
const propsStr = JSON.stringify(svc.props, null, 2)
.replace(/"([a-z_][a-z0-9_]*)":/g, "$1:");
return `export const ${sanitizeName(svc.name)} = new Service(${propsStr});`;
}

The JSON.stringify + regex approach:

  • Preserves all props the parser captured
  • Emits valid TypeScript (unquotes simple identifiers as keys)
  • Handles nested objects, arrays, booleans, and numbers correctly

Export names must be valid JavaScript identifiers. Convert kebab-case and snake_case to camelCase:

function sanitizeName(name: string): string {
return name.replace(/[-_]([a-z])/g, (_, c) => c.toUpperCase());
}

Detect stages.length > 1 and switch the emitted format:

function generateDockerfile(df: DockerfileIR): string {
if (df.stages.length > 1) {
const propsStr = JSON.stringify({ stages: df.stages }, null, 2)
.replace(/"([a-z_][a-z0-9_]*)":/g, "$1:");
return `export const ${sanitizeName(df.name)} = new Dockerfile(${propsStr});`;
}
// single-stage: flat props
const stage = df.stages[0];
const props: Record<string, unknown> = {};
if (stage) props.from = stage.from;
// ... group and emit instructions
}

Put one .yaml (or .dockerfile, etc.) per scenario in src/import/testdata/:

src/import/testdata/
simple.yaml # minimal case — one service + one volume
webapp.yaml # realistic multi-service with ports/env/healthcheck
full.yaml # exercises every top-level section

Load fixtures in tests using readFileSync relative to import.meta.dirname:

const testdata = (file: string) =>
readFileSync(join(import.meta.dirname, "testdata", file), "utf8");
test("simple.yaml → Service + Volume constructors", () => {
const { entities } = new DockerParser().parse(testdata("simple.yaml"));
const { source } = new DockerGenerator().generate(entities);
expect(source).toContain("new Service(");
expect(source).toContain("new Volume(");
});
#Test case
1image extracted correctly
2ports array preserved (quoted: "80:80")
3environment map preserved
4volumes list preserved (quoted: "data:/path")
5depends_on list preserved
6restart string preserved
7healthcheck object preserved
8Top-level volumes: → VolumeIR entities
9Top-level networks: → NetworkIR entities
10Top-level configs: → ConfigIR entities
11Top-level secrets: → SecretIR entities
12Dockerfile multi-stage: two stages with correct from/as
13Dockerfile single-stage: stages[0].from correct
14Skips comments and blank lines in Dockerfiles
15Empty compose: empty entities
#Test case
1Service with image generates correct TypeScript
2Service with ports/env generates all props
3Config entity generates correct constructor
4Secret entity generates correct constructor
5Single-stage Dockerfile generates flat props
6Multi-stage Dockerfile generates stages: array
7Import line includes only needed types
8Multiple entity types → sorted combined import
9Kebab-case name → camelCase export
10Network entity generates correct constructor
#Test case
1simple.yaml → Service + Volume constructors
2webapp.yaml → ports / healthcheck / depends_on survive
3full.yaml → Config / Secret / Network constructors
4Multi-stage Dockerfile inline → stages: in output

chant migrate — another provider’s format

Section titled “chant migrate — another provider’s format”

migrationSource(from) is a separate hook for translating one lexicon’s format into another, driven by chant migrate <file> --from <lexicon> --to <lexicon>. Core handles I/O and exit codes; the target lexicon owns the translation.

interface MigrationSource {
detect(content: string): boolean; // does this look like `from`?
transform(content: string, opts: MigrateOptions): Promise<MigrationResult>;
}

Implement it on the target lexicon, returning undefined for a from you do not handle. lexicons/gitlab/src/plugin.ts implements migrationSource("github") and lexicons/forgejo/src/plugin.ts does the same; both keep the transform behind a dynamic import so the migrate code stays out of the import graph until a translation runs.

The CLI defaults --from to github and --to to gitlab. Its flags:

FlagMeaning
--from <lexicon>Source format (default github).
--to <lexicon>Target lexicon (default gitlab), loaded by name without a project context.
--emit yaml|tsEmit target YAML or typed chant TypeScript (default yaml).
--strict
--validateLint the result through the target lexicon’s rules.
--use-composites
-o, --output <file>Write instead of printing.
--report <file>Write the SARIF provenance report.

chant import --agents — local agent configuration

Section titled “chant import --agents — local agent configuration”

agentConfigImporter() re-expresses skills, MCP servers and instruction files that chant audit --agents discovered as this lexicon’s resources. Core does the harness-neutral discovery; the mapping onto concrete types is the lexicon’s call, for the same reason templateParser is.

interface AgentConfigImporter {
toTemplateIR(sites: AgentConfigSite[]): AgentImportOutcome;
}

lexicons/fountain/src/plugin.ts is the only implementation, mapping onto fountain’s Agent / Environment types. The CLI adds --scope and --runtime filters on top of the shared --lexicon / --output / --force.

LexiconFilesWorth reading for
GitHub Actionslexicons/github/src/import/{parser,generator}.tsThe direct shape: TemplateParser implemented on the class, one ResourceIR per workflow and per job, main.ts emitted as the single file
GitLab CIlexicons/gitlab/src/import/{parser,generator}.tsThe same shape over a flatter source document
Forgejolexicons/forgejo/src/migrate/A lexicon with detectTemplate and migrationSource but no parser of its own, because it runs GitHub-Actions-compatible YAML
Dockerlexicons/docker/src/import/{parser,generator,adapter}.tsThe adapter shape: a lexicon-local IR bridged onto ResourceIR
K8slexicons/k8s/src/import/parser.tsparseYAML over multi-document YAML separated by ---
AWSlexicons/aws/src/import/parser.tsA custom YAML schema layer for CloudFormation intrinsics (!Ref, !Sub)
Cedarlexicons/cedar/src/import/A format with a real upstream grammar: parsing is delegated to @cedar-policy/cedar-wasm, and the parser’s own work is turning that output into properties

chant