Implementing Import
The chant import command reads existing infrastructure files (YAML, JSON, Dockerfiles) and emits TypeScript source using your lexicon’s types. Five optional plugin members cover import and its neighbours; a lexicon with no import story implements none of them.
| Member | Returns | Drives |
|---|---|---|
detectTemplate(data) | boolean | Which lexicon owns a template file. Core parses the file, asks each loaded plugin in turn, and takes the first true (detectPlugin in packages/core/src/cli/commands/import.ts). |
templateParser() | TemplateParser | Source format to TemplateIR. |
templateGenerator() | TypeScriptGenerator | TemplateIR to GeneratedFile[]. |
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): TemplateIR; }interface TypeScriptGenerator { generate(ir: TemplateIR): GeneratedFile[]; }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. Dropping properties silently causes roundtrip failures and user surprise. - 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, not you. Up to three resources it calls generate(ir) once and writes whatever paths you return. Above three it buckets resources by category (storage / compute / network / other), calls generate() once per non-empty bucket, and takes generated[0].content for <category>.ts. So a generator must return at least one file for any IR it is handed, and its first file must be the whole content for those resources.
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 genuinely 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 handles the YAML subset found in real infrastructure files — nested maps, block sequences, inline JSON, booleans, and quoted scalars with colons (e.g. "80:80" port strings). Custom regex parsers miss edge cases and drift from the core parser’s behaviour.
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”Don’t pass the raw object through as props. Define an allowlist of known property names and extract only those:
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 orchestrates 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 actually 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 |