Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sverka/compiler-github": "workspace:*",
"@sverka/compiler-gitlab": "workspace:*",
"@sverka/sdk": "workspace:*",
"yargs": "^17.7.3"
},
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/__tests__/compile.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
81 changes: 81 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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({
Comment thread
ThePlenkov marked this conversation as resolved.
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);
Comment thread
ThePlenkov marked this conversation as resolved.
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") {

Check warning on line 67 in packages/cli/src/commands/compile.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'If' statement should not be the only statement in 'else' block

See more on https://sonarcloud.io/project/issues?id=sverka-dev_sverka&issues=AZ_2s_NtrIcTy-_n40Fj&open=AZ_2s_NtrIcTy-_n40Fj&pullRequest=35
output.writeLine(
JSON.stringify({
command: "compile",
data: { target, yaml },
durationMs: Date.now() - start,
}),
);
} else {
output.write(yaml);
}
}

return ExitCode.Success;
}
38 changes: 38 additions & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -169,6 +189,24 @@ function dispatchPlan(
);
}

function dispatchCompile(
parsed: Arguments,
global: GlobalFlags,
output: OutputWriter,
start: number,
): Promise<number> {
return compileCommand(
{
target: String(parsed.target ?? ""),
output:
typeof parsed.output === "string" ? parsed.output : undefined,
},
global,
output,
start,
);
}

function dispatchExecute(
parsed: Arguments,
global: GlobalFlags,
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/__tests__/public-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ExecutionResult,
type WorkflowDefinition,
type Sverka,
type Plan,
} from "../index.js";

describe("public API — SdkError", () => {
Expand Down Expand Up @@ -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: [],
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
45 changes: 43 additions & 2 deletions packages/sdk/src/sverka.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export function createSverka(defaultOptions?: SverkaOptions): Sverka {
async plan(options?: SverkaOptions): Promise<PlanResult> {
return doPlan(mergeOptions(defaultOptions, options));
},
async toPlan(options?: SverkaOptions): Promise<Plan> {
Comment thread
ThePlenkov marked this conversation as resolved.
return buildPlan(mergeOptions(defaultOptions, options));
},
async execute(options?: SverkaOptions): Promise<ExecutionResult> {
return doExecute(mergeOptions(defaultOptions, options));
},
Expand All @@ -52,6 +55,11 @@ export async function plan(options?: SverkaOptions): Promise<PlanResult> {
return doPlan(options ?? {});
}

/** Top-level toPlan convenience function. Returns the canonical Plan IR. */
export async function toPlan(options?: SverkaOptions): Promise<Plan> {
return buildPlan(options ?? {});
}

/** Top-level execute convenience function. */
export async function execute(options?: SverkaOptions): Promise<ExecutionResult> {
return doExecute(options ?? {});
Expand All @@ -73,7 +81,8 @@ async function doPlan(options: SverkaOptions): Promise<PlanResult> {
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.
Expand All @@ -88,7 +97,39 @@ async function doPlan(options: SverkaOptions): Promise<PlanResult> {
// 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 };
Comment thread
ThePlenkov marked this conversation as resolved.
}

/** Build a canonical Plan IR from resolved operations. */
async function buildPlan(options: SverkaOptions): Promise<Plan> {
const result = await doPlan(options);
return result.plan;
}

/** Convert operations into a validated Plan with host executor defaults. */
function buildPlanFromOps(
Comment thread
ThePlenkov marked this conversation as resolved.
operations: readonly OperationSpec[],
name: string,
context: ProjectContext,
): Plan {
const plan = convertToPlan(operations, {
name,
executor: "host",
context,
});
Comment thread
ThePlenkov marked this conversation as resolved.

if (operations.length > 0) {
const validation = validatePlan(plan);
if (!validation.valid) {
throw new SdkError(
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
`plan validation failed: ${validation.errors.map((e) => e.message).join("; ")}`,
"EXECUTION_FAILED",
);
}
}
Comment thread
ThePlenkov marked this conversation as resolved.

return plan;
}

// ---------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
ThePlenkov marked this conversation as resolved.
import type { Verdict, PolicyResult, PolicyConfig } from "@sverka/policy";
import type { OperationOutcome as RuntimeOperationOutcome } from "@sverka/runtime";

Expand Down Expand Up @@ -38,6 +39,7 @@ export interface SverkaOptions {
/** The Sverka instance returned by createSverka. */
export interface Sverka {
plan(options?: SverkaOptions): Promise<PlanResult>;
toPlan(options?: SverkaOptions): Promise<Plan>;
execute(options?: SverkaOptions): Promise<ExecutionResult>;
}

Expand All @@ -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. */
Expand Down
Loading