/** * TypeScript code generator for GitLab CI import. * * Converts a TemplateIR (from parsed .gitlab-ci.yml) into TypeScript * source code using the @intentius/chant-lexicon-gitlab constructors. */ import type { TypeScriptGenerator, GeneratedFile } from "@intentius/chant/import/generator"; import type { TemplateIR, ResourceIR } from "@intentius/chant/import/parser"; /** * Map GitLab CI entity types to their constructor names. */ const TYPE_TO_CLASS: Record = { "GitLab::CI::Job": "Job", "GitLab::CI::Default": "Default", "GitLab::CI::Workflow": "Workflow", }; /** * Composite IR types emit as `FunctionName({...})` factory calls rather * than `new ClassName({...})` constructors. The `chant migrate --use- * composites` rewriter rewrites recognised IR shapes into these sentinel * types (see `lexicons/gitlab/src/migrate/from-github/composites/`). */ const COMPOSITE_TYPE_TO_FN: Record = { "GitLab::Composite::NodePipeline": "NodePipeline", "GitLab::Composite::NodeCI": "NodeCI", }; /** * Properties that reference known property entities. */ const PROPERTY_CONSTRUCTORS: Record = { artifacts: "Artifacts", cache: "Cache", image: "Image", retry: "Retry", allow_failure: "AllowFailure", parallel: "Parallel", environment: "Environment", trigger: "Trigger", auto_cancel: "AutoCancel", inherit: "Inherit", }; /** * Generate TypeScript source code from a GitLab CI IR. */ export class GitLabGenerator implements TypeScriptGenerator { generate(ir: TemplateIR): GeneratedFile[] { const lines: string[] = []; // Collect which constructors are needed const usedConstructors = new Set(); for (const resource of ir.resources) { const cls = TYPE_TO_CLASS[resource.type]; if (cls) usedConstructors.add(cls); const fn = COMPOSITE_TYPE_TO_FN[resource.type]; if (fn) usedConstructors.add(fn); // Check properties for nested constructors this.collectNestedConstructors(resource.properties, usedConstructors); } // Import statement const imports = [...usedConstructors].sort().join(", "); lines.push(`import { ${imports} } from "@intentius/chant-lexicon-gitlab";`); lines.push(""); // Emit stages if present in metadata if (ir.metadata?.stages && Array.isArray(ir.metadata.stages)) { lines.push(`// Pipeline stages: ${(ir.metadata.stages as string[]).join(", ")}`); lines.push(""); } // Emit includes as comments if (ir.metadata?.include) { lines.push("// Imported includes (not converted):"); const includes = Array.isArray(ir.metadata.include) ? ir.metadata.include : [ir.metadata.include]; for (const inc of includes) { if (typeof inc === "string") { lines.push(`// - ${inc}`); } else if (typeof inc === "object" && inc !== null) { lines.push(`// - ${JSON.stringify(inc)}`); } } lines.push(""); } // Emit resources for (const resource of ir.resources) { const cls = TYPE_TO_CLASS[resource.type]; const fn = COMPOSITE_TYPE_TO_FN[resource.type]; if (!cls && !fn) continue; const varName = resource.logicalId; const propsStr = this.emitProps(resource.properties, 1); if (fn) { lines.push(`export const ${varName} = ${fn}(${propsStr});`); } else { lines.push(`export const ${varName} = new ${cls}(${propsStr});`); } lines.push(""); } return [{ path: "main.ts", content: lines.join("\n") }]; } private collectNestedConstructors(props: Record, used: Set): void { for (const [key, value] of Object.entries(props)) { const constructor = PROPERTY_CONSTRUCTORS[key]; if (constructor && typeof value === "object" && value !== null && !Array.isArray(value)) { used.add(constructor); } if (key === "rules" && Array.isArray(value)) { used.add("Rule"); } } } private emitProps(props: Record, depth: number): string { const indent = " ".repeat(depth); const innerIndent = " ".repeat(depth + 1); const entries: string[] = []; for (const [key, value] of Object.entries(props)) { if (value === undefined || value === null) continue; const emitted = this.emitValue(key, value, depth + 1); entries.push(`${innerIndent}${key}: ${emitted},`); } if (entries.length === 0) return "{}"; return `{\n${entries.join("\n")}\n${indent}}`; } private emitValue(key: string, value: unknown, depth: number): string { if (value === null || value === undefined) return "undefined"; // Check if this key maps to a property constructor const constructor = PROPERTY_CONSTRUCTORS[key]; if (constructor && typeof value === "object" && !Array.isArray(value)) { const propsStr = this.emitProps(value as Record, depth); return `new ${constructor}(${propsStr})`; } // Rules array — wrap each item in Rule constructor if (key === "rules" && Array.isArray(value)) { const items = value.map((item) => { if (typeof item === "object" && item !== null) { const propsStr = this.emitProps(item as Record, depth + 1); return `new Rule(${propsStr})`; } return JSON.stringify(item); }); const indent = " ".repeat(depth); const innerIndent = " ".repeat(depth + 1); return `[\n${items.map((i) => `${innerIndent}${i},`).join("\n")}\n${indent}]`; } return this.emitLiteral(value, depth); } private emitLiteral(value: unknown, depth: number): string { if (value === null || value === undefined) return "undefined"; if (typeof value === "string") return JSON.stringify(value); if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) { if (value.length === 0) return "[]"; const items = value.map((item) => this.emitLiteral(item, depth + 1)); // Short arrays on one line const oneLine = `[${items.join(", ")}]`; if (oneLine.length < 80) return oneLine; const indent = " ".repeat(depth); const innerIndent = " ".repeat(depth + 1); return `[\n${items.map((i) => `${innerIndent}${i},`).join("\n")}\n${indent}]`; } if (typeof value === "object") { const entries = Object.entries(value as Record); if (entries.length === 0) return "{}"; const indent = " ".repeat(depth); const innerIndent = " ".repeat(depth + 1); const items = entries.map(([k, v]) => `${innerIndent}${k}: ${this.emitLiteral(v, depth + 1)},`); return `{\n${items.join("\n")}\n${indent}}`; } return String(value); } }