From 698f38d8053c4c16f7507270a3933f6213b7ea07 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:55:53 +0000 Subject: [PATCH] feat(cli): add sverka compile command for github/gitlab targets - Add toPlan() to SDK to build canonical Plan IR from sverka.config.ts. - Wire compiler-github and compiler-gitlab into CLI deps. - Add compile command: sverka compile --target github|gitlab [--output ]. - Output YAML to stdout or file, with JSON format support. - Add CLI tests for github/gitlab output and --output. Relates-to: sverka design - compile single TS plan to multiple CI targets. Co-Authored-By: Petr Plenkov --- bun.lock | 2 + packages/cli/package.json | 2 + packages/cli/src/__tests__/compile.test.ts | 94 +++++++++++++++++++ packages/cli/src/commands/compile.ts | 81 ++++++++++++++++ packages/cli/src/main.ts | 38 ++++++++ packages/sdk/src/__tests__/public-api.test.ts | 2 + packages/sdk/src/index.ts | 2 +- packages/sdk/src/sverka.ts | 45 ++++++++- packages/sdk/src/types.ts | 4 + 9 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/__tests__/compile.test.ts create mode 100644 packages/cli/src/commands/compile.ts diff --git a/bun.lock b/bun.lock index f0ec277e7..1d1d7a2c1 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,8 @@ "sverka": "./dist/bin.mjs", }, "dependencies": { + "@sverka/compiler-github": "workspace:*", + "@sverka/compiler-gitlab": "workspace:*", "@sverka/sdk": "workspace:*", "yargs": "^17.7.3", }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 33e87835a..5ed0f78e5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,6 +22,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@sverka/compiler-github": "workspace:*", + "@sverka/compiler-gitlab": "workspace:*", "@sverka/sdk": "workspace:*", "yargs": "^17.7.3" }, diff --git a/packages/cli/src/__tests__/compile.test.ts b/packages/cli/src/__tests__/compile.test.ts new file mode 100644 index 000000000..2a52c614e --- /dev/null +++ b/packages/cli/src/__tests__/compile.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { main } from "../index.js"; +import { + makeTempDir, + cleanupTempDir, + CaptureWriter, + initGitRepo, + writefile, +} from "./helpers/fixtures.js"; + +const CONFIG = [ + 'import { defineWorkflow, pipeline, task, run } from "@sverka/sdk";', + "", + "export default defineWorkflow({", + ' name: "verify",', + " workflow: pipeline(", + ' task("lint", run({ command: "bun", args: ["run", "lint"] })),', + ' task("test", run({ command: "bun", args: ["run", "test"] })),', + " ),", + "});", + "", +].join("\n"); + +describe("compile command", () => { + let dir: string; + + beforeEach(async () => { + dir = await makeTempDir(); + await initGitRepo(dir); + await writefile(dir, "sverka.config.ts", CONFIG); + }); + + afterEach(async () => { + await cleanupTempDir(dir); + }); + + it("compiles to GitHub Actions YAML", async () => { + const out = new CaptureWriter(); + const code = await main( + ["compile", "--target", "github", "--root", dir], + { output: out }, + ); + expect(code).toBe(0); + expect(out.stdoutText).toContain("name: Sverka"); + expect(out.stdoutText).toContain("runs-on: ubuntu-latest"); + expect(out.stdoutText).toContain("sverka execute"); + }); + + it("compiles to GitLab CI YAML", async () => { + const out = new CaptureWriter(); + const code = await main( + ["compile", "--target", "gitlab", "--root", dir], + { output: out }, + ); + expect(code).toBe(0); + expect(out.stdoutText).toContain("stages:"); + expect(out.stdoutText).toContain("- verify"); + expect(out.stdoutText).toContain("image: oven/bun:latest"); + expect(out.stdoutText).toContain("sverka execute"); + }); + + it("writes compiled YAML to --output", async () => { + const out = new CaptureWriter(); + const outPath = join(dir, ".github", "workflows", "sverka.yml"); + const code = await main( + [ + "compile", + "--target", + "github", + "--root", + dir, + "--output", + ".github/workflows/sverka.yml", + ], + { output: out }, + ); + expect(code).toBe(0); + expect(existsSync(outPath)).toBe(true); + const written = readFileSync(outPath, "utf8"); + expect(written).toContain("name: Sverka"); + expect(out.stdoutText).toContain("Compiled github workflow to"); + }); + + it("fails with usage error for unsupported target", async () => { + const out = new CaptureWriter(); + const code = await main( + ["compile", "--target", "azure", "--root", dir], + { output: out }, + ); + expect(code).toBe(2); + }); +}); diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts new file mode 100644 index 000000000..ccaec2319 --- /dev/null +++ b/packages/cli/src/commands/compile.ts @@ -0,0 +1,81 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { createSverka } from "@sverka/sdk"; +import { compileGithubWorkflow } from "@sverka/compiler-github"; +import { compileGitlabCi } from "@sverka/compiler-gitlab"; +import type { GlobalFlags, OutputWriter } from "../types.js"; +import { CliError, ExitCode } from "../types.js"; +import { resolveUnderRoot } from "../internal/paths.js"; + +/** Args parsed for the compile command. */ +export interface CompileArgs { + target: string; + output?: string | undefined; +} + +/** Compile the canonical Plan to a target CI YAML. */ +export async function compileCommand( + args: CompileArgs, + global: GlobalFlags, + output: OutputWriter, + start: number, +): Promise { + const target = args.target; + if (target !== "github" && target !== "gitlab") { + throw new CliError( + `invalid compile target: ${target} (expected github or gitlab)`, + "INVALID_FLAG", + ExitCode.UsageError, + ); + } + + output.debug( + `compile: root=${global.root} target=${target} output=${args.output ?? "stdout"}`, + ); + + const sverka = createSverka({ + root: global.root, + ...(global.config + ? { configPath: resolveUnderRoot(global.root, global.config) } + : {}), + }); + + const plan = await sverka.toPlan(); + + const yaml = + target === "github" + ? compileGithubWorkflow(plan) + : compileGitlabCi(plan); + + if (args.output) { + const outPath = resolve(global.root, args.output); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, yaml, "utf8"); + + if (global.format === "json") { + output.writeLine( + JSON.stringify({ + command: "compile", + data: { target, path: outPath }, + durationMs: Date.now() - start, + }), + ); + } else { + output.writeLine(`Compiled ${target} workflow to ${outPath}`); + } + } else { + if (global.format === "json") { + output.writeLine( + JSON.stringify({ + command: "compile", + data: { target, yaml }, + durationMs: Date.now() - start, + }), + ); + } else { + output.write(yaml); + } + } + + return ExitCode.Success; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index aec1bd290..4b524f5a1 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -12,6 +12,7 @@ import { executeCommand } from "./commands/execute.js"; import { validateCommand } from "./commands/validate.js"; import { baselineCommand } from "./commands/baseline.js"; import { doctorCommand } from "./commands/doctor.js"; +import { compileCommand } from "./commands/compile.js"; /** Optional dependencies for main (testability seam). */ export interface MainDeps { @@ -65,6 +66,22 @@ function addExecuteCommand(y: Argv): Argv { .option("baseline", { type: "string" }); } +/** Configure the compile subcommand options. */ +function addCompileCommand(y: Argv): Argv { + return y + .option("target", { + type: "string", + choices: ["github", "gitlab"] as const, + demandOption: true, + describe: "CI target to compile to", + }) + .option("output", { + type: "string", + alias: "o", + describe: "Write YAML to a file instead of stdout", + }); +} + /** Configure the baseline subcommand tree. */ function addBaselineCommand(y: Argv): Argv { return y @@ -95,6 +112,7 @@ function buildParser(): Argv { ) .command(["execute", "run"], "Execute the workflow locally", addExecuteCommand) .command("validate", "Validate a sverka.config.ts without executing") + .command("compile", "Compile the workflow to a CI target", addCompileCommand) .command("baseline", "Manage the findings baseline", addBaselineCommand) .command("doctor", "Diagnose environment and dependencies") .demandCommand(1, "No command given") @@ -125,6 +143,8 @@ async function dispatch( return dispatchExecute(parsed, global, output, start); case "validate": return validateCommand(global, output, start); + case "compile": + return dispatchCompile(parsed, global, output, start); case "baseline": return dispatchBaseline(parsed, global, output, start); case "doctor": @@ -169,6 +189,24 @@ function dispatchPlan( ); } +function dispatchCompile( + parsed: Arguments, + global: GlobalFlags, + output: OutputWriter, + start: number, +): Promise { + return compileCommand( + { + target: String(parsed.target ?? ""), + output: + typeof parsed.output === "string" ? parsed.output : undefined, + }, + global, + output, + start, + ); +} + function dispatchExecute( parsed: Arguments, global: GlobalFlags, diff --git a/packages/sdk/src/__tests__/public-api.test.ts b/packages/sdk/src/__tests__/public-api.test.ts index ad8d222ff..e1eb4f243 100644 --- a/packages/sdk/src/__tests__/public-api.test.ts +++ b/packages/sdk/src/__tests__/public-api.test.ts @@ -7,6 +7,7 @@ import { type ExecutionResult, type WorkflowDefinition, type Sverka, + type Plan, } from "../index.js"; describe("public API — SdkError", () => { @@ -50,6 +51,7 @@ describe("public API — types (compile-time check)", () => { context: {} as never, operations: [], proposal: null, + plan: {} as unknown as Plan, }; const _exec: ExecutionResult = { findings: [], diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 55300e49d..c42132ab6 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -97,7 +97,7 @@ export type { export { findConfig, loadWorkflow } from "./config.js"; // ── Sverka instance ─────────────────────────────────────────────── -export { createSverka, plan, execute } from "./sverka.js"; +export { createSverka, plan, toPlan, execute } from "./sverka.js"; // ── task helper ─────────────────────────────────────────────────── import type { Operation } from "@sverka/core"; diff --git a/packages/sdk/src/sverka.ts b/packages/sdk/src/sverka.ts index a6a949e8c..d2bbcda04 100644 --- a/packages/sdk/src/sverka.ts +++ b/packages/sdk/src/sverka.ts @@ -41,6 +41,9 @@ export function createSverka(defaultOptions?: SverkaOptions): Sverka { async plan(options?: SverkaOptions): Promise { return doPlan(mergeOptions(defaultOptions, options)); }, + async toPlan(options?: SverkaOptions): Promise { + return buildPlan(mergeOptions(defaultOptions, options)); + }, async execute(options?: SverkaOptions): Promise { return doExecute(mergeOptions(defaultOptions, options)); }, @@ -52,6 +55,11 @@ export async function plan(options?: SverkaOptions): Promise { return doPlan(options ?? {}); } +/** Top-level toPlan convenience function. Returns the canonical Plan IR. */ +export async function toPlan(options?: SverkaOptions): Promise { + return buildPlan(options ?? {}); +} + /** Top-level execute convenience function. */ export async function execute(options?: SverkaOptions): Promise { return doExecute(options ?? {}); @@ -73,7 +81,8 @@ async function doPlan(options: SverkaOptions): Promise { if (configPath !== null) { const def = await loadWorkflow(configPath, root); const operations = await evaluateWorkflow(def); - return { context, operations, proposal: null }; + const plan = buildPlanFromOps(operations, def.name, context); + return { context, operations, proposal: null, plan }; } // Auto-discovery mode. @@ -88,7 +97,39 @@ async function doPlan(options: SverkaOptions): Promise { // Skip checks whose custom resolver fails and continue with the rest. } } - return { context, operations, proposal }; + const plan = buildPlanFromOps(operations, "sverka-plan", context); + return { context, operations, proposal, plan }; +} + +/** Build a canonical Plan IR from resolved operations. */ +async function buildPlan(options: SverkaOptions): Promise { + const result = await doPlan(options); + return result.plan; +} + +/** Convert operations into a validated Plan with host executor defaults. */ +function buildPlanFromOps( + operations: readonly OperationSpec[], + name: string, + context: ProjectContext, +): Plan { + const plan = convertToPlan(operations, { + name, + executor: "host", + context, + }); + + if (operations.length > 0) { + const validation = validatePlan(plan); + if (!validation.valid) { + throw new SdkError( + `plan validation failed: ${validation.errors.map((e) => e.message).join("; ")}`, + "EXECUTION_FAILED", + ); + } + } + + return plan; } // --------------------------------------------------------------------------- diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 137be7b69..f08db2e7b 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -2,6 +2,7 @@ import type { Workflow, Operation, OperationSpec } from "@sverka/core"; import type { CheckResolver } from "@sverka/checks"; import type { ProjectContext, PlanProposal } from "@sverka/planner"; import type { Finding } from "@sverka/findings"; +import type { Plan } from "@sverka/ir"; import type { Verdict, PolicyResult, PolicyConfig } from "@sverka/policy"; import type { OperationOutcome as RuntimeOperationOutcome } from "@sverka/runtime"; @@ -38,6 +39,7 @@ export interface SverkaOptions { /** The Sverka instance returned by createSverka. */ export interface Sverka { plan(options?: SverkaOptions): Promise; + toPlan(options?: SverkaOptions): Promise; execute(options?: SverkaOptions): Promise; } @@ -49,6 +51,8 @@ export interface PlanResult { operations: readonly OperationSpec[]; /** Planner proposal (null if a user config was loaded). */ proposal: PlanProposal | null; + /** Canonical Plan IR built from the resolved operations. */ + plan: Plan; } /** Result of execute mode. */