Skip to content

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.

MemberReturnsDrives
detectTemplate(data)booleanWhich 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()TemplateParserSource format to TemplateIR.
templateGenerator()TypeScriptGeneratorTemplateIR to GeneratedFile[].
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): 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. properties is 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, 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, 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.

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 genuinely does not fit ResourceIR; the examples below are docker’s, so they show the local shape.

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.

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 };
}
}

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.

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.yamlService + Volume constructors
2webapp.yaml → ports / healthcheck / depends_on survive
3full.yamlConfig / 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 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:

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