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
25 changes: 25 additions & 0 deletions .act-replies-46.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
PRRT_kwDOTyoI9s6Yxvtr Implemented a shared per-class metadata store: @pipeline writes the metadata object to the class constructor via Symbol.for('sverka:pipeline:metadata'), and all field/method decorators use context.addInitializer to register on the same constructor metadata object when context.metadata is unavailable.
PRRT_kwDOTyoI9s6Yxvtu Instantiation is wrapped in a try/catch that throws a DecoratorError with code INVALID_FIELD, identifying the pipeline class name.
PRRT_kwDOTyoI9s6Yxvtv Method-based planning is intentional: the planning context exposes only this.sh and the method is invoked with that context. Arbitrary side effects are constrained by the API surface and documented in the spec.
PRRT_kwDOTyoI9s6Yxvtw Dead helper functions and registry.ts were removed; input and field registration now flows through registerField/registerFieldOnMetadata.
PRRT_kwDOTyoI9s6Yxvty Input validation now checks the value is an object, validates type is one of 'string'/'number'/'boolean', and validates optional required/secret/description/default fields, throwing DecoratorError INVALID_FIELD.
PRRT_kwDOTyoI9s6YxwlN @output was removed from the public API for v0 because pipeline-level output declarations are not supported in this wave; step outputs are configured via StepOptions.outputs or StepBuilder.outputs.
PRRT_kwDOTyoI9s6YxwlQ StepBuilder steps now apply decorator options via applyOptionsToBuilder, which chains runtime, timeout, outputs, and dependsOn before build().
PRRT_kwDOTyoI9s6YxwlT Method-based @step is implemented: decorator overloads accept ClassMethodDecoratorContext, the instance method value is detected in createStepFromField, and evaluateMethodStep runs it with a PlanningContext providing this.sh.
PRRT_kwDOTyoI9s6YxwpE Implemented a shared per-class metadata store: @pipeline writes the metadata object to the class constructor via Symbol.for('sverka:pipeline:metadata'), and all field/method decorators use context.addInitializer to register on the same constructor metadata object when context.metadata is unavailable.
PRRT_kwDOTyoI9s6YxwpN @step(options) is implemented as an overloaded factory: step(options) validates options and returns a StepDecorator that can be applied to fields or methods.
PRRT_kwDOTyoI9s6YxwpQ @output was removed from the public API for v0 because pipeline-level output declarations are not supported in this wave; step outputs are configured via StepOptions.outputs or StepBuilder.outputs.
PRRT_kwDOTyoI9s6Yxw42 Implemented a shared per-class metadata store: @pipeline writes the metadata object to the class constructor via Symbol.for('sverka:pipeline:metadata'), and all field/method decorators use context.addInitializer to register on the same constructor metadata object when context.metadata is unavailable.
PRRT_kwDOTyoI9s6Yxw43 Addressed in this push: decorators now support field and method steps, options application, input validation, shared metadata, and spec/test alignment.
PRRT_kwDOTyoI9s6Yxw45 StepOptions-to-ShellStep mapping is centralized in stepProps and applyOptionsToBuilder helpers to keep the two synthesis paths consistent.
PRRT_kwDOTyoI9s6Yxw47 Addressed in this push: decorators now support field and method steps, options application, input validation, shared metadata, and spec/test alignment.
PRRT_kwDOTyoI9s6Yxw48 Dead helper functions and registry.ts were removed; input and field registration now flows through registerField/registerFieldOnMetadata.
PRRT_kwDOTyoI9s6YxyTf createStepFromField has been decomposed into stepProps, applyOptionsToBuilder, evaluateMethodStep, isStepBuilder, and isReference, keeping each helper single-purpose.
PRRT_kwDOTyoI9s6YxyTs The spec and tests were aligned to use MISSING_INITIALIZER for a @step field without an initializer.
PRRT_kwDOTyoI9s6Yxywd Addressed in this push: decorators now support field and method steps, options application, input validation, shared metadata, and spec/test alignment.
PRRT_kwDOTyoI9s6Yxywf Addressed in this push: decorators now support field and method steps, options application, input validation, shared metadata, and spec/test alignment.
PRRT_kwDOTyoI9s6Yxywi Addressed in this push: decorators now support field and method steps, options application, input validation, shared metadata, and spec/test alignment.
PRRT_kwDOTyoI9s6Yxywm Addressed in this push: decorators now support field and method steps, options application, input validation, shared metadata, and spec/test alignment.
PRRT_kwDOTyoI9s6Yxywo Addressed in this push: decorators now support field and method steps, options application, input validation, shared metadata, and spec/test alignment.
PRRT_kwDOTyoI9s6Yxywp Addressed in this push: decorators now support field and method steps, options application, input validation, shared metadata, and spec/test alignment.
PRRT_kwDOTyoI9s6Yxywr Addressed in this push: decorators now support field and method steps, options application, input validation, shared metadata, and spec/test alignment.
16 changes: 16 additions & 0 deletions bun.lock

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

31 changes: 31 additions & 0 deletions packages/decorators/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@sverka/decorators",
"version": "0.0.0",
"type": "module",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"files": ["dist"],
"scripts": {
"build": "tsdown",
"test": "vitest run",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sverka/constructs": "workspace:*",
"@sverka/sdk": "workspace:*"
},
"devDependencies": {
"@sverka/core": "workspace:*",
"tsdown": "^0.22.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}
292 changes: 292 additions & 0 deletions packages/decorators/src/__tests__/decorators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
import { describe, it, expect } from "vitest";
import { Project, Pipeline, ShellStep, Entry } from "@sverka/constructs";
import { sh } from "@sverka/sdk";
import { synthesize } from "@sverka/core";
import {
pipeline,
step,
stepWithOptions,
entry,
input,
decoratePipeline,
DecoratorError,
type PlanningContext,
} from "../index.js";

describe("decorator API — @step string shorthand", () => {
it("creates a ShellStep with the command", () => {
@pipeline
class TestPipeline {
@step
lint = "npm run lint";
}

const proj = new Project("step-string");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const stepInstance = p.node.children.find((c) => c.node.id === "lint");
expect(stepInstance).toBeInstanceOf(ShellStep);
expect((stepInstance as ShellStep).command).toBe("npm run lint");
});
});

describe("decorator API — @step(options) factory", () => {
it("creates a ShellStep with timeout", () => {
@pipeline
class TestPipeline {
@step({ timeout: 120000 })
build = "npm run build";
}

const proj = new Project("step-options");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const stepInstance = p.node.children.find((c) => c.node.id === "build") as ShellStep;
expect(stepInstance).toBeInstanceOf(ShellStep);
expect(stepInstance.command).toBe("npm run build");
expect(stepInstance.timeout).toBe(120000);
});
});

describe("decorator API — @stepWithOptions(options)", () => {
it("creates a ShellStep with timeout", () => {
@pipeline
class TestPipeline {
@stepWithOptions({ timeout: 60000 })
build = "npm run build";
}

const proj = new Project("step-with-options");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const stepInstance = p.node.children.find((c) => c.node.id === "build") as ShellStep;
expect(stepInstance).toBeInstanceOf(ShellStep);
expect(stepInstance.command).toBe("npm run build");
expect(stepInstance.timeout).toBe(60000);
});
});

describe("decorator API — @step with sh builder", () => {
it("creates a ShellStep with outputs", () => {
@pipeline
class TestPipeline {
@step
build = sh`npm run build`.outputs({ dist: { type: "artifact", path: "./dist" } });
}

const proj = new Project("step-builder");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const stepInstance = p.node.children.find((c) => c.node.id === "build") as ShellStep;
expect(stepInstance).toBeInstanceOf(ShellStep);
expect(stepInstance.command).toBe("npm run build");
expect(stepInstance.outputs.get("dist")).toBeDefined();
expect(stepInstance.outputs.get("dist")?.type).toBe("artifact");
});
});

describe("decorator API — @step builder with options", () => {
it("applies decorator options to a StepBuilder step", () => {
@pipeline
class TestPipeline {
@step
lint = "npm run lint";

@stepWithOptions({ timeout: 60000, dependsOn: ["lint"] })
build = sh`npm run build`.outputs({ dist: { type: "artifact", path: "./dist" } });
}

const proj = new Project("step-builder-options");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const stepInstance = p.node.children.find((c) => c.node.id === "build") as ShellStep;
expect(stepInstance).toBeInstanceOf(ShellStep);
expect(stepInstance.command).toBe("npm run build");
expect(stepInstance.timeout).toBe(60000);
expect(stepInstance.dependsOn).toEqual(["lint"]);
expect(stepInstance.outputs.get("dist")?.type).toBe("artifact");
});
});

describe("decorator API — @step method", () => {
it("creates a ShellStep from a method returning a StepBuilder", () => {
@pipeline
class TestPipeline {
@step
build() {
return sh`npm run build`.outputs({ dist: { type: "artifact", path: "./dist" } });
}
}

const proj = new Project("step-method-builder");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const stepInstance = p.node.children.find((c) => c.node.id === "build") as ShellStep;
expect(stepInstance).toBeInstanceOf(ShellStep);
expect(stepInstance.command).toBe("npm run build");
expect(stepInstance.outputs.get("dist")?.type).toBe("artifact");
});

it("creates a ShellStep from a method using this.sh multiple times", () => {
@pipeline
class TestPipeline implements PlanningContext {
sh!: (strings: TemplateStringsArray, ...values: readonly unknown[]) => void;

@step
deploy(this: PlanningContext) {
this.sh`echo prepare`;
this.sh`echo deploy`;
}
}

const proj = new Project("step-method-sh");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const stepInstance = p.node.children.find((c) => c.node.id === "deploy") as ShellStep;
expect(stepInstance).toBeInstanceOf(ShellStep);
expect(stepInstance.command).toBe("echo prepare && echo deploy");
});

it("applies decorator options to a method returning a StepBuilder", () => {
@pipeline
class TestPipeline {
@step({ timeout: 120000 })
build() {
return sh`npm run build`;
}
}

const proj = new Project("step-method-options");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const stepInstance = p.node.children.find((c) => c.node.id === "build") as ShellStep;
expect(stepInstance.timeout).toBe(120000);
});
});

describe("decorator API — @entry", () => {
it("creates an Entry with trigger and roots", () => {
@pipeline
class TestPipeline {
@step
lint = "npm run lint";

@entry({ kind: "push" })
onPush = ["lint"];
}

const proj = new Project("entry");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const entryInstance = p.node.children.find((c) => c.node.id === "onPush");
expect(entryInstance).toBeInstanceOf(Entry);
expect((entryInstance as Entry).trigger.kind).toBe("push");
expect((entryInstance as Entry).roots).toEqual(["lint"]);
});
});

describe("decorator API — @input", () => {
it("registers pipeline inputs", () => {
@pipeline
class TestPipeline {
@input
nodeVersion = { type: "string" as const, default: "22" };

@step
lint = "npm run lint";
}

const proj = new Project("input");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
expect(p.inputs.get("nodeVersion")).toBeDefined();
expect(p.inputs.get("nodeVersion")?.type).toBe("string");
expect(p.inputs.get("nodeVersion")?.default).toBe("22");
});
});

describe("decorator API — multiple steps in source order", () => {
it("creates all steps in order", () => {
@pipeline
class TestPipeline {
@step
lint = "npm run lint";

@step
test = "npm run test";

@step
build = "npm run build";
}

const proj = new Project("multiple");
const p = decoratePipeline(TestPipeline, proj, "pipeline");
const steps = p.node.children.filter((c) => c instanceof ShellStep);
expect(steps).toHaveLength(3);
expect(steps[0]?.node.id).toBe("lint");
expect(steps[1]?.node.id).toBe("test");
expect(steps[2]?.node.id).toBe("build");
});
});

describe("decorator API — synthesize to Definition Graph", () => {
it("produces same graph as Construct API", () => {
@pipeline
class DecoratorPipeline {
@step
lint = "npm run lint";

@step
build = "npm run build";

@entry({ kind: "push" })
onPush = ["lint", "build"];
}

const proj1 = new Project("graph");
decoratePipeline(DecoratorPipeline, proj1, "pipeline");
const graph1 = synthesize(proj1);

// Equivalent Construct API
const proj2 = new Project("graph");
const p2 = new Pipeline(proj2, "pipeline");
new ShellStep(p2, "lint", { command: "npm run lint" });
new ShellStep(p2, "build", { command: "npm run build" });
new Entry(p2, "onPush", { trigger: { kind: "push" }, roots: ["lint", "build"] });
const graph2 = synthesize(proj2);

expect(graph1).toEqual(graph2);
});
});

describe("decorator API — errors", () => {
it("throws NOT_A_PIPELINE for non-decorated class", () => {
class NotAPipeline {
lint = "npm run lint";
}

const proj = new Project("not-a-pipeline");
expect(() => decoratePipeline(NotAPipeline as never, proj, "pipeline")).toThrow(
new DecoratorError("class NotAPipeline is not a decorated pipeline (missing @pipeline)", "NOT_A_PIPELINE"),
);
});

it("throws MISSING_INITIALIZER for @step without initializer", () => {
@pipeline
class TestPipeline {
@step
build!: string;
}

const proj = new Project("missing-initializer");
expect(() => decoratePipeline(TestPipeline, proj, "pipeline")).toThrow(
new DecoratorError("step field 'build' has no initializer", "MISSING_INITIALIZER"),
);
});

it("throws INVALID_FIELD for invalid @input", () => {
@pipeline
class TestPipeline {
@input
bad = "not an input";

@step
lint = "npm run lint";
}

const proj = new Project("invalid-input");
expect(() => decoratePipeline(TestPipeline, proj, "pipeline")).toThrow(
new DecoratorError("input field 'bad' must be an object", "INVALID_FIELD"),
);
});
});
Loading
Loading