Implementing Import
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`.Optional: hand this page to your coding agent
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.
| Member | Returns | Drives |
|---|---|---|
detectTemplate(data) | boolean | Which 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?) | TemplateParser | Source 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() | TypeScriptGenerator | TemplateIR 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 | undefined | chant migrate: another provider’s format into this lexicon’s. |
agentConfigImporter() | AgentConfigImporter | chant import --agents: local agent configuration into this lexicon’s resources. |
The core IR
Section titled “The core IR”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.
propertiesis 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, whichchant importprints (parseAndWritepushesir.warningsstraight into the result). Silence is the failure mode #2069 closed. logicalIdbecomes an export name. Core lowercases its first character when it writes the barrel re-exports, so emit a valid JavaScript identifier.
How core drives the two halves
Section titled “How core drives the two halves”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 }}Embedded content
Section titled “Embedded content”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)callscontext.embedded.resolve(site)for each place content can sit, and puts theEmbeddedReferenceit gets back into its IR in place of the raw value.undefinedmeans keep the value as written. A site gives itslocationfor messages, adirectoryname, the content astextor as thedocumentit would be in a file of its own, andselectwhen the host’s value is one member of that document ("groups"forspec.groups). It may also name anexpectedOwner, which is used only to warn when that lexicon is not installed.contextis absent outsidechant import, and a parser then keeps everything as written. - The owner declares an
EmbeddedContentImporterinembeddedImporters().matches(site)says whether it imports the content.import(site)returns the modules that declare it (paths relative to their own directory) and anEmbeddedValue: 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 returnscollectorYaml([...])over every component and pipeline. Prometheus gives the list ofRuleGroups,ruleFileYaml([...])for a rule file held as text, oralertmanagerYaml([...])for analertmanager.yml, and grafana givesdashboardJson(dashboard). - Content an owner matches can still be kept as written.
keepsAsWritten(site)returns the reason, which becomes the import’s warning, andundefinedmeans 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
/detectmodule’sdetectTemplaterecognizes 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). AnEmbeddedImportScopecollects the imports the module needs and aliases a name the module already uses (a second collector’sotlpbecomesotlp2).
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.
The three files
Section titled “The three files”| File | Responsibility |
|---|---|
src/import/parser.ts | Parse source format into IR |
src/import/generator.ts | IR into TypeScript source |
src/import/roundtrip.test.ts | End-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.
parser.ts
Section titled “parser.ts”Use parseYAML, never regex
Section titled “Use parseYAML, never regex”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.
Iterate top-level sections
Section titled “Iterate top-level sections”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 }; }}Use an allowlist for props
Section titled “Use an allowlist for props”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.
Multi-document / multi-stage formats
Section titled “Multi-document / multi-stage formats”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 }; }}generator.ts
Section titled “generator.ts”Collect imports, emit constructors
Section titled “Collect imports, emit constructors”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
Name sanitization
Section titled “Name sanitization”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());}Multi-stage branching
Section titled “Multi-stage branching”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}Fixtures and roundtrip tests
Section titled “Fixtures and roundtrip tests”Fixture directory
Section titled “Fixture directory”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 sectionLoad fixtures in tests using readFileSync relative to import.meta.dirname:
const testdata = (file: string) => readFileSync(join(import.meta.dirname, "testdata", file), "utf8");roundtrip.test.ts pattern
Section titled “roundtrip.test.ts pattern”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(");});Testing checklist
Section titled “Testing checklist”parser.test.ts
Section titled “parser.test.ts”| # | Test case |
|---|---|
| 1 | image extracted correctly |
| 2 | ports array preserved (quoted: "80:80") |
| 3 | environment map preserved |
| 4 | volumes list preserved (quoted: "data:/path") |
| 5 | depends_on list preserved |
| 6 | restart string preserved |
| 7 | healthcheck object preserved |
| 8 | Top-level volumes: → VolumeIR entities |
| 9 | Top-level networks: → NetworkIR entities |
| 10 | Top-level configs: → ConfigIR entities |
| 11 | Top-level secrets: → SecretIR entities |
| 12 | Dockerfile multi-stage: two stages with correct from/as |
| 13 | Dockerfile single-stage: stages[0].from correct |
| 14 | Skips comments and blank lines in Dockerfiles |
| 15 | Empty compose: empty entities |
generator.test.ts
Section titled “generator.test.ts”| # | Test case |
|---|---|
| 1 | Service with image generates correct TypeScript |
| 2 | Service with ports/env generates all props |
| 3 | Config entity generates correct constructor |
| 4 | Secret entity generates correct constructor |
| 5 | Single-stage Dockerfile generates flat props |
| 6 | Multi-stage Dockerfile generates stages: array |
| 7 | Import line includes only needed types |
| 8 | Multiple entity types → sorted combined import |
| 9 | Kebab-case name → camelCase export |
| 10 | Network entity generates correct constructor |
roundtrip.test.ts
Section titled “roundtrip.test.ts”| # | Test case |
|---|---|
| 1 | simple.yaml → Service + Volume constructors |
| 2 | webapp.yaml → ports / healthcheck / depends_on survive |
| 3 | full.yaml → Config / Secret / Network constructors |
| 4 | Multi-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:
| Flag | Meaning |
|---|---|
--from <lexicon> | Source format (default github). |
--to <lexicon> | Target lexicon (default gitlab), loaded by name without a project context. |
--emit yaml|ts | Emit target YAML or typed chant TypeScript (default yaml). |
--strict | |
--validate | Lint 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.
Reference implementations
Section titled “Reference implementations”| Lexicon | Files | Worth reading for |
|---|---|---|
| GitHub Actions | lexicons/github/src/import/{parser,generator}.ts | The direct shape: TemplateParser implemented on the class, one ResourceIR per workflow and per job, main.ts emitted as the single file |
| GitLab CI | lexicons/gitlab/src/import/{parser,generator}.ts | The same shape over a flatter source document |
| Forgejo | lexicons/forgejo/src/migrate/ | A lexicon with detectTemplate and migrationSource but no parser of its own, because it runs GitHub-Actions-compatible YAML |
| Docker | lexicons/docker/src/import/{parser,generator,adapter}.ts | The adapter shape: a lexicon-local IR bridged onto ResourceIR |
| K8s | lexicons/k8s/src/import/parser.ts | parseYAML over multi-document YAML separated by --- |
| AWS | lexicons/aws/src/import/parser.ts | A custom YAML schema layer for CloudFormation intrinsics (!Ref, !Sub) |
| Cedar | lexicons/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 |