@intentius/chant 0.79.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/audit/core.d.ts.map +1 -1
  2. package/dist/audit/discover.d.ts +38 -0
  3. package/dist/audit/discover.d.ts.map +1 -1
  4. package/dist/audit/terraform-state.d.ts +14 -0
  5. package/dist/audit/terraform-state.d.ts.map +1 -1
  6. package/dist/cli/command-group.d.ts +6 -0
  7. package/dist/cli/command-group.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +27 -2
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  11. package/dist/cli/commands/doctor.d.ts.map +1 -1
  12. package/dist/cli/commands/import-agents.d.ts.map +1 -1
  13. package/dist/cli/commands/init.d.ts +6 -0
  14. package/dist/cli/commands/init.d.ts.map +1 -1
  15. package/dist/cli/commands/lint.d.ts.map +1 -1
  16. package/dist/cli/commands/onboard.d.ts.map +1 -1
  17. package/dist/cli/commands/update.d.ts.map +1 -1
  18. package/dist/cli/conflict-check.d.ts +6 -0
  19. package/dist/cli/conflict-check.d.ts.map +1 -1
  20. package/dist/cli/handlers/dev.d.ts.map +1 -1
  21. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  22. package/dist/cli/handlers/misc.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts.map +1 -1
  24. package/dist/cli/handlers/promote.d.ts +17 -0
  25. package/dist/cli/handlers/promote.d.ts.map +1 -0
  26. package/dist/cli/handlers/run-generate.d.ts +39 -0
  27. package/dist/cli/handlers/run-generate.d.ts.map +1 -0
  28. package/dist/cli/handlers/run.d.ts.map +1 -1
  29. package/dist/cli/main.d.ts.map +1 -1
  30. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  31. package/dist/cli/plugins.d.ts +9 -0
  32. package/dist/cli/plugins.d.ts.map +1 -1
  33. package/dist/cli/registry.d.ts +14 -1
  34. package/dist/cli/registry.d.ts.map +1 -1
  35. package/dist/components/capability-plugin-loader.d.ts.map +1 -1
  36. package/dist/components/cli-support.d.ts.map +1 -1
  37. package/dist/components/discover.d.ts.map +1 -1
  38. package/dist/components/driver.d.ts.map +1 -1
  39. package/dist/components/fan-out-support.d.ts.map +1 -1
  40. package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
  41. package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
  42. package/dist/components/presets/ecs-fargate.d.ts +10 -4
  43. package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
  44. package/dist/components/promote.d.ts +211 -0
  45. package/dist/components/promote.d.ts.map +1 -0
  46. package/dist/components/unbound-gate-approval.d.ts +24 -0
  47. package/dist/components/unbound-gate-approval.d.ts.map +1 -0
  48. package/dist/config.d.ts +82 -5
  49. package/dist/config.d.ts.map +1 -1
  50. package/dist/discovery/convergence.d.ts +97 -0
  51. package/dist/discovery/convergence.d.ts.map +1 -0
  52. package/dist/discovery/files.d.ts +41 -2
  53. package/dist/discovery/files.d.ts.map +1 -1
  54. package/dist/discovery/fold-import.d.ts +15 -0
  55. package/dist/discovery/fold-import.d.ts.map +1 -1
  56. package/dist/graph-ir.d.ts +13 -0
  57. package/dist/graph-ir.d.ts.map +1 -1
  58. package/dist/lexicon-module.d.ts +65 -0
  59. package/dist/lexicon-module.d.ts.map +1 -0
  60. package/dist/lexicon.d.ts +24 -0
  61. package/dist/lexicon.d.ts.map +1 -1
  62. package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
  63. package/dist/lifecycle/legacy-digest.d.ts +26 -0
  64. package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +25 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
  68. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
  69. package/dist/op/activity-contract-registry.d.ts.map +1 -1
  70. package/dist/op/activity-registry.d.ts.map +1 -1
  71. package/dist/op/discover.d.ts.map +1 -1
  72. package/dist/op/gate-approval.d.ts.map +1 -1
  73. package/dist/op/generate-pipeline.d.ts.map +1 -1
  74. package/dist/op/runtimes/local.d.ts.map +1 -1
  75. package/dist/workspace/record-source.d.ts +22 -0
  76. package/dist/workspace/record-source.d.ts.map +1 -0
  77. package/dist/workspace/records-cli.d.ts +57 -0
  78. package/dist/workspace/records-cli.d.ts.map +1 -0
  79. package/dist/workspace/records.d.ts +118 -0
  80. package/dist/workspace/records.d.ts.map +1 -0
  81. package/package.json +3 -3
  82. package/src/audit/core.ts +13 -2
  83. package/src/audit/discover.ts +90 -12
  84. package/src/audit/terraform-state.test.ts +86 -1
  85. package/src/audit/terraform-state.ts +26 -0
  86. package/src/cli/command-group.ts +7 -0
  87. package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
  88. package/src/cli/commands/audit.ts +93 -19
  89. package/src/cli/commands/check-lexicon-examples.ts +2 -1
  90. package/src/cli/commands/doctor.ts +3 -0
  91. package/src/cli/commands/import-agents.ts +11 -4
  92. package/src/cli/commands/init.ts +57 -25
  93. package/src/cli/commands/lint.test.ts +58 -1
  94. package/src/cli/commands/lint.ts +48 -18
  95. package/src/cli/commands/onboard.ts +15 -0
  96. package/src/cli/commands/update.ts +5 -1
  97. package/src/cli/conflict-check.test.ts +18 -1
  98. package/src/cli/conflict-check.ts +13 -0
  99. package/src/cli/discovery-skip.test.ts +168 -0
  100. package/src/cli/handlers/build.test.ts +11 -0
  101. package/src/cli/handlers/build.ts +1 -1
  102. package/src/cli/handlers/dev.ts +3 -0
  103. package/src/cli/handlers/graph.test.ts +20 -0
  104. package/src/cli/handlers/graph.ts +4 -2
  105. package/src/cli/handlers/lifecycle.ts +2 -1
  106. package/src/cli/handlers/misc.ts +16 -0
  107. package/src/cli/handlers/operator.ts +2 -1
  108. package/src/cli/handlers/promote.test.ts +261 -0
  109. package/src/cli/handlers/promote.ts +325 -0
  110. package/src/cli/handlers/run-generate.test.ts +160 -0
  111. package/src/cli/handlers/run-generate.ts +133 -0
  112. package/src/cli/handlers/run.ts +13 -1
  113. package/src/cli/main.test.ts +33 -1
  114. package/src/cli/main.ts +69 -16
  115. package/src/cli/mcp/op-tools.ts +2 -1
  116. package/src/cli/path-lexicon-messages.test.ts +152 -0
  117. package/src/cli/plugins.ts +44 -6
  118. package/src/cli/registry.ts +14 -1
  119. package/src/components/__fixtures__/alb-ecs-service.json +0 -8
  120. package/src/components/capability-plugin-loader.ts +3 -1
  121. package/src/components/cli-support.ts +3 -2
  122. package/src/components/discover.ts +26 -2
  123. package/src/components/driver.test.ts +12 -11
  124. package/src/components/driver.ts +2 -0
  125. package/src/components/fan-out-support.ts +2 -1
  126. package/src/components/pilots/README.md +1 -1
  127. package/src/components/pilots/alb-ecs.pilot.ts +7 -7
  128. package/src/components/presets/ecs-fargate.ts +10 -5
  129. package/src/components/promote.test.ts +371 -0
  130. package/src/components/promote.ts +537 -0
  131. package/src/components/unbound-gate-approval.test.ts +82 -0
  132. package/src/components/unbound-gate-approval.ts +51 -0
  133. package/src/config.test.ts +40 -0
  134. package/src/config.ts +128 -11
  135. package/src/discovery/convergence.test.ts +231 -0
  136. package/src/discovery/convergence.ts +399 -0
  137. package/src/discovery/files.test.ts +127 -1
  138. package/src/discovery/files.ts +97 -9
  139. package/src/discovery/fold-import.ts +83 -9
  140. package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
  141. package/src/graph-ir.ts +14 -0
  142. package/src/lexicon-module.test.ts +203 -0
  143. package/src/lexicon-module.ts +117 -0
  144. package/src/lexicon.ts +26 -0
  145. package/src/lifecycle/build-ledger-store.ts +11 -1
  146. package/src/lifecycle/legacy-digest.test.ts +114 -0
  147. package/src/lifecycle/legacy-digest.ts +62 -0
  148. package/src/lifecycle/release-ledger.ts +34 -0
  149. package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
  150. package/src/op/activity-contract-registry.ts +18 -4
  151. package/src/op/activity-registry.ts +26 -0
  152. package/src/op/discover-convergence.test.ts +70 -0
  153. package/src/op/discover.ts +4 -0
  154. package/src/op/gate-approval.ts +3 -1
  155. package/src/op/generate-pipeline.ts +2 -1
  156. package/src/op/runtimes/local.ts +2 -1
  157. package/src/workspace/record-source.ts +117 -0
  158. package/src/workspace/records-cli.ts +126 -0
  159. package/src/workspace/records-contract.test.ts +88 -0
  160. package/src/workspace/records.schema.json +120 -0
  161. package/src/workspace/records.test.ts +243 -0
  162. package/src/workspace/records.ts +383 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `chant run --generate <provider>` (#2533). Discovery is stubbed so the
3
+ * scheduled Ops are fixed; the github, gitlab and forgejo lexicon plugins are
4
+ * the real ones, so "identical to calling `generateOpsPipeline` directly" is
5
+ * checked against the real generators.
6
+ */
7
+
8
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
9
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import type { ParsedArgs } from "../registry";
13
+ import type { OpConfig } from "../../op/types";
14
+
15
+ const discovered = new Map<string, { config: Partial<OpConfig>; filePath: string; exportName: string }>();
16
+
17
+ vi.mock("../../op/discover", () => ({
18
+ discoverOps: async () => ({ ops: discovered, errors: [] }),
19
+ }));
20
+
21
+ const { runOp } = await import("./run");
22
+ const { generateOpsPipeline } = await import("../../op/generate-pipeline");
23
+ const { GENERATED_MARKER } = await import("../../discovery/files");
24
+ const { parseArgs } = await import("../main");
25
+
26
+ function ctx(argv: string[]) {
27
+ const args = parseArgs(argv) as ParsedArgs;
28
+ return { args, plugins: [], serializers: [] };
29
+ }
30
+
31
+ let dir: string;
32
+ let errSpy: ReturnType<typeof vi.spyOn>;
33
+ let logSpy: ReturnType<typeof vi.spyOn>;
34
+
35
+ beforeEach(() => {
36
+ dir = mkdtempSync(join(tmpdir(), "chant-run-generate-"));
37
+ discovered.clear();
38
+ discovered.set("nightly-audit", {
39
+ config: { name: "nightly-audit", phases: [], schedule: { cron: "0 6 * * *" } } as Partial<OpConfig>,
40
+ filePath: "nightly-audit.op.ts",
41
+ exportName: "default",
42
+ });
43
+ discovered.set("weekly-report", {
44
+ config: { name: "weekly-report", phases: [], schedule: { cron: "0 7 * * 1" } } as Partial<OpConfig>,
45
+ filePath: "weekly-report.op.ts",
46
+ exportName: "default",
47
+ });
48
+ discovered.set("deploy", {
49
+ config: { name: "deploy", phases: [] } as Partial<OpConfig>,
50
+ filePath: "deploy.op.ts",
51
+ exportName: "default",
52
+ });
53
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
54
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
55
+ });
56
+
57
+ afterEach(() => {
58
+ errSpy.mockRestore();
59
+ logSpy.mockRestore();
60
+ rmSync(dir, { recursive: true, force: true });
61
+ });
62
+
63
+ describe("chant run --generate <provider>", () => {
64
+ test.each(["github", "gitlab", "forgejo"])(
65
+ "%s: each file is the generator's own output under one marker line",
66
+ async (provider) => {
67
+ const out = join(dir, provider);
68
+ expect(await runOp(ctx(["run", "--generate", provider, "--output", out]))).toBe(0);
69
+
70
+ const direct = await generateOpsPipeline([{ name: "nightly-audit" }, { name: "weekly-report" }], provider);
71
+ expect(direct.success).toBe(true);
72
+ const header = `# ${GENERATED_MARKER}. Regenerate with: chant run --generate ${provider}\n`;
73
+
74
+ expect(readdirSync(out).sort()).toEqual(direct.files!.map((f) => f.name).sort());
75
+ for (const file of direct.files!) {
76
+ expect(readFileSync(join(out, file.name), "utf-8")).toBe(header + file.yaml);
77
+ }
78
+ },
79
+ );
80
+
81
+ test("github and forgejo get one file per scheduled Op; an Op with no schedule is left out", async () => {
82
+ for (const provider of ["github", "forgejo"]) {
83
+ const out = join(dir, provider);
84
+ expect(await runOp(ctx(["run", "--generate", provider, "--output", out]))).toBe(0);
85
+ expect(readdirSync(out).sort()).toEqual(["nightly-audit.yml", "weekly-report.yml"]);
86
+ }
87
+ });
88
+
89
+ test("each github workflow is named after its Op (#2580); forgejo workflows carry no name", async () => {
90
+ for (const provider of ["github", "forgejo"]) {
91
+ expect(await runOp(ctx(["run", "--generate", provider, "--output", join(dir, provider)]))).toBe(0);
92
+ }
93
+ const header = `# ${GENERATED_MARKER}. Regenerate with: chant run --generate github\n`;
94
+ for (const op of ["nightly-audit", "weekly-report"]) {
95
+ const github = readFileSync(join(dir, "github", `${op}.yml`), "utf-8");
96
+ expect(github.startsWith(`${header}name: ${op}\n\non:\n`)).toBe(true);
97
+ const forgejo = readFileSync(join(dir, "forgejo", `${op}.yml`), "utf-8");
98
+ expect(forgejo).not.toMatch(/^name:/m);
99
+ }
100
+ });
101
+
102
+ test("--spec passes its ops and options to the generator unchanged", async () => {
103
+ const specFile = join(dir, "ops.json");
104
+ const spec = {
105
+ ops: [
106
+ { name: "deploy", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
107
+ { name: "nightly-audit" },
108
+ ],
109
+ options: { opsStage: "chant" },
110
+ };
111
+ writeFileSync(specFile, JSON.stringify(spec));
112
+ const out = join(dir, "gitlab");
113
+ expect(await runOp(ctx(["run", "--generate", "gitlab", "--spec", specFile, "--output", out]))).toBe(0);
114
+
115
+ const direct = await generateOpsPipeline(spec.ops as never, "gitlab", spec.options);
116
+ expect(direct.files!.map((f) => f.name)).toEqual(["chant.gitlab-ci.yml"]);
117
+ const written = readFileSync(join(out, "chant.gitlab-ci.yml"), "utf-8");
118
+ expect(written).toBe(
119
+ `# ${GENERATED_MARKER}. Regenerate with: chant run --generate gitlab --spec ${specFile}\n` + direct.files![0].yaml,
120
+ );
121
+ expect(written).toContain('$CI_PIPELINE_SOURCE == "merge_request_event"');
122
+ });
123
+
124
+ test("--format json prints the files and jobs and writes nothing", async () => {
125
+ const out = join(dir, "none");
126
+ expect(await runOp(ctx(["run", "--generate", "github", "--output", out, "--format", "json"]))).toBe(0);
127
+ const printed = JSON.parse(String(logSpy.mock.calls[0][0]));
128
+ expect(printed.files.map((f: { path: string }) => f.path)).toEqual([
129
+ join(out, "nightly-audit.yml"),
130
+ join(out, "weekly-report.yml"),
131
+ ]);
132
+ expect(printed.files[0].content.startsWith(`# ${GENERATED_MARKER}`)).toBe(true);
133
+ expect(printed.jobs).toHaveLength(2);
134
+ expect(() => readdirSync(out)).toThrow();
135
+ });
136
+
137
+ test("with no scheduled Op and no --spec, it fails and says what to do", async () => {
138
+ discovered.delete("nightly-audit");
139
+ discovered.delete("weekly-report");
140
+ expect(await runOp(ctx(["run", "--generate", "github", "--output", dir]))).toBe(1);
141
+ expect(String(errSpy.mock.calls.flat().join("\n"))).toMatch(/No Op declares a schedule/);
142
+ });
143
+
144
+ test("a provider with no Op generator fails by name", async () => {
145
+ expect(await runOp(ctx(["run", "--generate", "aws", "--output", dir]))).toBe(1);
146
+ expect(String(errSpy.mock.calls.flat().join("\n"))).toMatch(/does not support Op generate mode/);
147
+ });
148
+
149
+ test("a --spec entry without a name is refused", async () => {
150
+ const specFile = join(dir, "bad.json");
151
+ writeFileSync(specFile, JSON.stringify([{ trigger: { kind: "push" } }]));
152
+ expect(await runOp(ctx(["run", "--generate", "github", "--spec", specFile, "--output", dir]))).toBe(1);
153
+ expect(String(errSpy.mock.calls.flat().join("\n"))).toMatch(/entry 0 has no "name"/);
154
+ });
155
+
156
+ test("--components with --generate on run is refused", async () => {
157
+ expect(await runOp(ctx(["run", "--components", "--generate", "github"]))).toBe(1);
158
+ expect(String(errSpy.mock.calls.flat().join("\n"))).toMatch(/build --components --generate/);
159
+ });
160
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `chant run --generate <provider>` (#2533): the CLI for `generateOpsPipeline`
3
+ * (../../op/generate-pipeline.ts), the Op counterpart to `chant build
4
+ * --components --generate <lexicon>`.
5
+ *
6
+ * The generator is a library function with no caller of its own. A pipeline
7
+ * committed to a repository needs a named command that regenerates it, so a
8
+ * drift check (#2524 D14) can rerun that command and compare. This handler is
9
+ * that command and adds nothing to the generator's output beyond one header
10
+ * line: every file it writes is the generator's own `yaml`, byte for byte,
11
+ * under a first line carrying {@link GENERATED_MARKER} and the command that
12
+ * produced it.
13
+ *
14
+ * Where the specs come from:
15
+ *
16
+ * - by default, every discovered Op that declares its own `schedule`, as a
17
+ * bare `{ name }` spec. `generateOpsPipeline` fills the cron in from the Op
18
+ * (`withOpSchedules`), so the result is exactly what a direct call with
19
+ * those names returns;
20
+ * - with `--spec <file>`, a JSON file holding either a `ScheduledOpSpec[]`
21
+ * or `{ ops, options }`, for Ops triggered by a pull request or a push, a
22
+ * finding mode, setup steps, or generator options. The file is passed to
23
+ * the generator unchanged.
24
+ */
25
+
26
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { dirname, join, resolve } from "node:path";
28
+ import { discoverOps } from "../../op/discover";
29
+ import { generateOpsPipeline } from "../../op/generate-pipeline";
30
+ import { GENERATED_MARKER } from "../../discovery/files";
31
+ import type { ComponentPipelineOptions, ScheduledOpSpec } from "../../lexicon";
32
+ import { formatBold, formatError, formatSuccess } from "../format";
33
+ import type { CommandContext } from "../registry";
34
+
35
+ /** Where each forge reads its pipeline files from, relative to the repository root. `--output` overrides it. */
36
+ export const DEFAULT_OP_PIPELINE_DIRS: Record<string, string> = {
37
+ github: ".github/workflows",
38
+ forgejo: ".forgejo/workflows",
39
+ gitlab: ".",
40
+ };
41
+
42
+ /** The first line of every file this command writes. */
43
+ export function opPipelineHeader(provider: string, specFile?: string): string {
44
+ const command = `chant run --generate ${provider}${specFile ? ` --spec ${specFile}` : ""}`;
45
+ return `# ${GENERATED_MARKER}. Regenerate with: ${command}\n`;
46
+ }
47
+
48
+ interface SpecFile {
49
+ ops: ScheduledOpSpec[];
50
+ options?: ComponentPipelineOptions;
51
+ }
52
+
53
+ /** Read `--spec`: a `ScheduledOpSpec[]`, or `{ ops, options }`. Throws with the file named on anything else. */
54
+ export function readOpSpecFile(path: string): SpecFile {
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
58
+ } catch (err) {
59
+ throw new Error(`--spec ${path}: ${err instanceof Error ? err.message : String(err)}`);
60
+ }
61
+ const shape = "an array of Op specs, or { \"ops\": [...], \"options\": {...} }";
62
+ const ops = Array.isArray(parsed) ? parsed : (parsed as { ops?: unknown } | null)?.ops;
63
+ if (!Array.isArray(ops)) throw new Error(`--spec ${path} must hold ${shape}`);
64
+ for (const [i, spec] of ops.entries()) {
65
+ if (typeof (spec as { name?: unknown })?.name !== "string") {
66
+ throw new Error(`--spec ${path}: entry ${i} has no "name". Each entry names the Op it generates a pipeline for.`);
67
+ }
68
+ }
69
+ const options = Array.isArray(parsed) ? undefined : (parsed as { options?: ComponentPipelineOptions }).options;
70
+ return { ops: ops as ScheduledOpSpec[], ...(options ? { options } : {}) };
71
+ }
72
+
73
+ export async function runOpGenerate(ctx: CommandContext): Promise<number> {
74
+ const { args } = ctx;
75
+ const provider = args.generate as string;
76
+ const specPath = args.opsSpec;
77
+
78
+ let ops: ScheduledOpSpec[];
79
+ let options: ComponentPipelineOptions | undefined;
80
+ if (specPath) {
81
+ try {
82
+ ({ ops, options } = readOpSpecFile(resolve(specPath)));
83
+ } catch (err) {
84
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
85
+ return 1;
86
+ }
87
+ } else {
88
+ const discovered = await discoverOps();
89
+ if (discovered.errors.length > 0) {
90
+ for (const e of discovered.errors) console.error(formatError({ message: e }));
91
+ return 1;
92
+ }
93
+ ops = [...discovered.ops.entries()]
94
+ .filter(([, op]) => op.config.schedule)
95
+ .map(([name]) => name)
96
+ .sort()
97
+ .map((name) => ({ name }));
98
+ if (ops.length === 0) {
99
+ console.error(formatError({
100
+ message: "No Op declares a schedule, so there is nothing to generate",
101
+ hint: "Give an Op a `schedule`, or pass --spec <file.json> naming the Ops and their triggers.",
102
+ }));
103
+ return 1;
104
+ }
105
+ }
106
+
107
+ const result = await generateOpsPipeline(ops, provider, options);
108
+ if (!result.success) {
109
+ console.error(formatError({ message: result.error ?? "Failed to generate Op pipelines" }));
110
+ return 1;
111
+ }
112
+
113
+ const header = opPipelineHeader(provider, specPath);
114
+ const outDir = args.output ?? DEFAULT_OP_PIPELINE_DIRS[provider] ?? ".";
115
+ const files = (result.files ?? []).map((f) => ({
116
+ name: f.name,
117
+ path: join(outDir, f.name),
118
+ content: header + f.yaml,
119
+ }));
120
+
121
+ if (args.format === "json") {
122
+ console.log(JSON.stringify({ files, jobs: result.jobs ?? [] }, null, 2));
123
+ return 0;
124
+ }
125
+
126
+ for (const file of files) {
127
+ const target = resolve(file.path);
128
+ mkdirSync(dirname(target), { recursive: true });
129
+ writeFileSync(target, file.content);
130
+ console.error(formatSuccess(`wrote ${formatBold(file.path)}`));
131
+ }
132
+ return 0;
133
+ }
@@ -1,3 +1,4 @@
1
+ import { lexiconNames } from "../../lexicon-module";
1
2
  import { resolve, dirname } from "node:path";
2
3
  import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
3
4
  import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
@@ -20,6 +21,7 @@ import { ndjsonProgressSink } from "../../components/run-progress";
20
21
  import { maybeRecordAutoRelease } from "../../components/auto-release";
21
22
  import { maybePersistBuildManifest } from "../../components/manifest-persistence";
22
23
  import type { DriverComponentResult } from "../../components/driver";
24
+ import { runOpGenerate } from "./run-generate";
23
25
 
24
26
  /**
25
27
  * The exit code a run that stopped at an unapproved gate uses (#2119).
@@ -124,7 +126,7 @@ async function resolveOpRuntime(ctx: CommandContext): Promise<OpRuntimeProvider
124
126
  // long wait to be told a name is wrong.
125
127
  let configured: string[] = [];
126
128
  try {
127
- configured = (await loadChantConfig(resolve("."))).config.lexicons ?? [];
129
+ configured = lexiconNames((await loadChantConfig(resolve("."))).config.lexicons ?? []);
128
130
  } catch {
129
131
  // No/unreadable chant.config.ts — the error below says so by listing nothing.
130
132
  }
@@ -524,6 +526,16 @@ function refusesPolicyGateUnderSandbox(ctx: CommandContext, config: OpConfig, op
524
526
  }
525
527
 
526
528
  export async function runOp(ctx: CommandContext): Promise<number> {
529
+ if (ctx.args.generate) {
530
+ if (ctx.args.components) {
531
+ console.error(formatError({
532
+ message: "`chant run --components --generate` is not a command",
533
+ hint: "Generate a component pipeline with `chant build --components --generate <lexicon>`, or Op pipelines with `chant run --generate <provider>`.",
534
+ }));
535
+ return 1;
536
+ }
537
+ return runOpGenerate(ctx);
538
+ }
527
539
  if (ctx.args.components && ctx.args.report) {
528
540
  console.error(formatError({
529
541
  message: "--report is not supported with --components",
@@ -1,6 +1,8 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { EventEmitter } from "node:events";
3
- import { parseArgs, waitForStreamDrain } from "./main";
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { commandRegistry, parseArgs, waitForStreamDrain } from "./main";
4
6
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
5
7
 
6
8
  describe("parseArgs", () => {
@@ -298,6 +300,12 @@ describe("parseArgs", () => {
298
300
 
299
301
  // ── --no-release-record (#597) ───────────────────────────────────────────
300
302
 
303
+ test("--promote-to <env> sets promoteTo, and a missing value is refused (#2575)", () => {
304
+ expect(parseArgs(["build", "--components", "--generate", "github", "--promote-to", "prod"]).promoteTo).toBe("prod");
305
+ expect(() => parseArgs(["build", "--components", "--generate", "github", "--promote-to"])).toThrow(/--promote-to needs an environment/);
306
+ expect(() => parseArgs(["build", "--promote-to", "--env", "x"])).toThrow(/--promote-to needs an environment/);
307
+ });
308
+
301
309
  test("parses --no-release-record for run --components", () => {
302
310
  const result = parseArgs(["run", "--components", "search-service", "--env", "staging", "--no-release-record"]);
303
311
  expect(result.components).toBe(true);
@@ -633,6 +641,30 @@ describe("parseArgs — run flags", () => {
633
641
  });
634
642
  });
635
643
 
644
+ describe("workspace records (#2546)", () => {
645
+ test("parses --kind, --current, --at and --json", () => {
646
+ const args = parseArgs(["workspace", "records", "--kind", "docs/k.kind.mjs", "--current", "--at", "HEAD~1", "--json"]);
647
+ expect(args).toMatchObject({ command: "workspace", path: "records", kind: "docs/k.kind.mjs", current: true, at: "HEAD~1", json: true });
648
+ expect(parseArgs(["workspace", "records", "--kind=docs/k.kind.mjs"]).kind).toBe("docs/k.kind.mjs");
649
+ expect(parseArgs(["workspace", "records"]).current).toBeUndefined();
650
+ expect(() => parseArgs(["workspace", "records", "--kind"])).toThrow(/--kind needs a kind file/);
651
+ expect(() => parseArgs(["workspace", "records", "--kind", "--json"])).toThrow(/--kind needs a kind file/);
652
+ });
653
+
654
+ test("resolves to the records command, and anything else under workspace to its fallback", () => {
655
+ expect(resolveCommand(parseArgs(["workspace", "records", "--kind", "k.mjs"]), commandRegistry)?.def.name).toBe("workspace records");
656
+ expect(resolveCommand(parseArgs(["workspace", "ls"]), commandRegistry)?.def.name).toBe("workspace");
657
+ });
658
+
659
+ // #2525 rule 5: the workspace code loads only when a workspace command runs.
660
+ // The level-0 goldens catch it at run time; this catches it in review.
661
+ test("main.ts never imports workspace code statically", () => {
662
+ const source = readFileSync(join(import.meta.dirname, "main.ts"), "utf-8");
663
+ expect(source).not.toMatch(/^import[^;]*from\s+["'][^"']*workspace/m);
664
+ expect(source).toMatch(/await import\("\.\.\/workspace\/records-cli"\)/);
665
+ });
666
+ });
667
+
636
668
  describe("waitForStreamDrain", () => {
637
669
  // Minimal writable stub — just the surface waitForStreamDrain reads.
638
670
  function fakeStream(len: number): NodeJS.WriteStream & { writableLength: number } {
package/src/cli/main.ts CHANGED
@@ -27,6 +27,7 @@ import { runCarveStatus } from "./handlers/carve-status";
27
27
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } from "./handlers/lifecycle";
28
28
  import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
29
29
  import { runComponentsFanOut } from "./handlers/fan-out";
30
+ import { runComponentsPromote, runComponentsRollback } from "./handlers/promote";
30
31
  import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
31
32
  import { runGraph } from "./handlers/graph";
32
33
  import { runExplain } from "./handlers/explain";
@@ -93,6 +94,7 @@ const BOOLEAN_FLAGS = new Set([
93
94
  "--fail-on-drift",
94
95
  "--durable-requests",
95
96
  "--skip-mcp",
97
+ "--current",
96
98
  ]);
97
99
 
98
100
  /**
@@ -205,8 +207,9 @@ export function parseArgs(args: string[]): ParsedArgs {
205
207
  if (v !== "source" && v !== "live") throw new Error(`--overlay-anchor must be 'source' or 'live', got '${v}'`);
206
208
  result.overlayAnchor = v;
207
209
  } else if (arg === "--from") {
208
- // Shared by `migrate --from <lexicon>` and `import --from <env>`; the
209
- // two commands never run together, so one field carries both.
210
+ // Shared by `migrate --from <lexicon>`, `import --from <env>` and
211
+ // `components promote --from <env>`; the commands never run together,
212
+ // so one field carries all three.
210
213
  result.migrateFrom = args[++i];
211
214
  } else if (arg === "--kustomize") {
212
215
  // `chant import --kustomize <dir>` (#1548): render the overlay, import
@@ -265,6 +268,9 @@ export function parseArgs(args: string[]): ParsedArgs {
265
268
  result.src = args[++i];
266
269
  } else if (arg === "--env") {
267
270
  result.env = args[++i];
271
+ } else if (arg === "--promote-to") {
272
+ result.promoteTo = args[++i];
273
+ if (!result.promoteTo || result.promoteTo.startsWith("-")) throw new Error("--promote-to needs an environment: --promote-to <env>");
268
274
  } else if (arg === "--tier") {
269
275
  result.tier = args[++i];
270
276
  } else if (arg === "--agents") {
@@ -277,6 +283,9 @@ export function parseArgs(args: string[]): ParsedArgs {
277
283
  result.runtime = args[++i];
278
284
  } else if (arg === "--fail-on") {
279
285
  result.failOn = args[++i];
286
+ } else if (arg === "--max-files") {
287
+ // Validated by the audit handler, like `--limit` below.
288
+ result.maxFiles = Number(args[++i]);
280
289
  } else if (arg === "--theme") {
281
290
  result.theme = args[++i];
282
291
  } else if (arg === "--stacks") {
@@ -285,6 +294,8 @@ export function parseArgs(args: string[]): ParsedArgs {
285
294
  result.components = true;
286
295
  } else if (arg === "--generate") {
287
296
  result.generate = args[++i];
297
+ } else if (arg === "--spec") {
298
+ result.opsSpec = args[++i];
288
299
  } else if (arg === "--dump-outputs") {
289
300
  result.dumpOutputs = args[++i];
290
301
  } else if (arg === "--seed-outputs") {
@@ -342,6 +353,12 @@ export function parseArgs(args: string[]): ParsedArgs {
342
353
  result.deep = true;
343
354
  } else if (arg === "--at") {
344
355
  result.at = args[++i];
356
+ } else if (arg === "--kind") {
357
+ // `chant workspace records --kind <kind file>` (#2546)
358
+ result.kind = args[++i];
359
+ if (!result.kind || result.kind.startsWith("-")) throw new Error("--kind needs a kind file: --kind <path>");
360
+ } else if (arg === "--current") {
361
+ result.current = true;
345
362
  } else if (arg === "--ambient") {
346
363
  result.ambient = true;
347
364
  } else if (arg === "--check-live") {
@@ -491,10 +508,11 @@ Commands:
491
508
  init Initialize a new chant project
492
509
  init lexicon <name> Scaffold a new lexicon plugin project
493
510
  build Build infrastructure from specification files
494
- (--components --generate gitlab: generate mode (#563) —
495
- synthesize a thin .gitlab-ci.yml that triggers each
496
- discovered component's own deploy in wave order,
497
- instead of a normal lexicon build)
511
+ (--components --generate github|gitlab|forgejo:
512
+ generate mode (#563), which synthesizes a thin CI
513
+ pipeline that triggers each discovered component's
514
+ own deploy in wave order, instead of a normal
515
+ lexicon build)
498
516
  lint Check specifications for issues
499
517
  list List discovered entities
500
518
  describe Show the effective config for one component
@@ -509,7 +527,8 @@ Commands:
509
527
  audit [path|url] Audit a repo's CI YAML for security issues
510
528
  (--format stylish|json|sarif|markdown|html, -o <file>,
511
529
  --tier merge-worthy|all, --fail-on merge-worthy|warning|error|none,
512
- --template <file> / --theme <file> for the html report)
530
+ --template <file> / --theme <file> for the html report,
531
+ --max-files <n> to walk more than 1000 files)
513
532
  --agents audits this machine's agent configuration —
514
533
  instruction files, MCP servers, skills, plugins,
515
534
  permissions — instead of a repository
@@ -556,6 +575,11 @@ Ops:
556
575
  run approve <op> <gate> Record a gate's resolution and wake the runtime
557
576
  run cancel <name> Cancel the active run (requires --force)
558
577
  run log <name> Show run history for an Op
578
+ run --generate <provider> Write one CI pipeline file per scheduled Op for
579
+ github, gitlab or forgejo (#2533), from every Op that
580
+ declares a schedule or from --spec <file.json>.
581
+ Files go to the forge's own directory unless
582
+ --output <dir>; --format json prints them instead
559
583
  run --components <name|all> Run discovered Component(s) through the interpret
560
584
  driver on the local executor (--env <env>; #585).
561
585
  On success, auto-emits a release-ledger record per
@@ -615,6 +639,13 @@ Ops:
615
639
  predicts the account. The level reaches the engine
616
640
  verbatim; without the flag nothing is asked (#2377)
617
641
 
642
+ Workspace (first-test slice, #2546):
643
+ workspace records --kind <kind file> [--current] [--at <rev>] [--json]
644
+ Read the records a record kind locates, validated
645
+ against its schema, with reason codes for invalid
646
+ ones. --current leaves out superseded records; --at
647
+ reads a commit's git objects. Needs no workspace file
648
+
618
649
  Lifecycle (alias: lc):
619
650
  lifecycle snapshot <env> Query API, save metadata to orphan branch
620
651
  --deep: also record each resource's property tree,
@@ -670,6 +701,15 @@ Component release ledger + status:
670
701
  [--digest <manifestDigest>] -o <dir> [--json]);
671
702
  copies every image/template/asset/sbom entry
672
703
  byte-for-byte plus a self-describing manifest.json
704
+ components promote Deploy the digests one environment's release
705
+ ledger records to another, without a build
706
+ (--from <env> --to <env> [--component <name>
707
+ [--digest <sha256:...>]]; --dry-run prints the
708
+ plan; the target's gates still apply)
709
+ components rollback <env> Redeploy an earlier release of an environment
710
+ from its recorded digest (--component <name>
711
+ [--digest <sha256:...>]; defaults to the release
712
+ before the current one; --dry-run prints the plan)
673
713
 
674
714
  Lexicon development:
675
715
  dev generate Generate lexicon artifacts (+ validate + coverage)
@@ -744,9 +784,14 @@ Options:
744
784
  --use-composites Rewrite to composite calls when patterns match (migrate)
745
785
  --components Target discovered Component declarations instead of
746
786
  lexicon resources (list, describe, graph, build, run)
747
- --generate <lexicon> Generate mode (build --components only): synthesize CI
748
- YAML for <lexicon> instead of running a normal build.
749
- Only "gitlab" is implemented for v1.
787
+ --generate <lexicon> Generate mode: synthesize CI YAML for <lexicon> (github,
788
+ gitlab or forgejo) instead of running. build
789
+ --components: one pipeline for the components; run:
790
+ one pipeline file per scheduled Op
791
+ --spec <file> (run --generate) JSON Op specs: an array, or
792
+ { ops, options }
793
+ --promote-to <env> (build --components --generate) Add a job that
794
+ promotes the deployed releases to <env>
750
795
  --no-release-record Skip auto-emitting a release-ledger record after a
751
796
  successful \`run --components\` deploy (default: on;
752
797
  also settable via chant.config.ts's
@@ -778,18 +823,18 @@ Options:
778
823
  project source; network egress is NOT blocked (see
779
824
  docs). Default: off (also settable via
780
825
  chant.config.ts's build.sandbox: true; #1045)
781
- --param <name=value> (build, graph, run --components, components fan-out)
782
- Bind a declared
783
- build-time parameter (chant.config.ts's buildParams)
826
+ --param <name=value> (build, graph, run --components, components fan-out,
827
+ components promote, components rollback) Bind a
828
+ declared build-time parameter (chant.config.ts's buildParams)
784
829
  to a value, for source to read as params.<name>
785
830
  (#1064) instead of process.env — repeatable.
786
831
  Distinct from the AWS lexicon's deploy-time
787
832
  Parameter(): this resolves before synthesis, so it
788
833
  can change which resources are produced at all.
789
834
  Highest precedence.
790
- --params-file <path> (build, graph, run --components, components fan-out)
791
- JSON file of
792
- { "name": value } build-time parameter values
835
+ --params-file <path> (build, graph, run --components, components fan-out,
836
+ components promote, components rollback) JSON file
837
+ of { "name": value } build-time parameter values
793
838
  (#1064). Second precedence, after --param.
794
839
 
795
840
  Examples:
@@ -800,6 +845,7 @@ Examples:
800
845
  chant build ./infra/ --fold
801
846
  chant build ./infra/ --components --generate gitlab
802
847
  chant build ./infra/ --components --generate gitlab --output .gitlab-ci.yml
848
+ chant run --generate github
803
849
  chant run --components search-service --env staging
804
850
  chant run --components all --env production
805
851
  chant components export prod --component search-service -o ./dist/search-service
@@ -994,6 +1040,10 @@ export const commandRegistry: CommandDef[] = [
994
1040
  { name: "graph", handler: runGraph },
995
1041
  { name: "vendor", handler: runVendor },
996
1042
 
1043
+ // Workspace reads (#2524). Imported on first use, so a level-0 command never
1044
+ // loads anything under workspace/ (#2525 rule 5, pinned by #2526's goldens).
1045
+ { name: "workspace records", handler: async (ctx) => (await import("../workspace/records-cli")).runWorkspaceRecords(ctx) },
1046
+
997
1047
  // State subcommands
998
1048
  { name: "lifecycle snapshot", requiresPlugins: true, handler: runLifecycleSnapshot },
999
1049
  { name: "lifecycle show", handler: runLifecycleShow },
@@ -1015,6 +1065,8 @@ export const commandRegistry: CommandDef[] = [
1015
1065
  { name: "components status", requiresPlugins: true, handler: runComponentsStatus },
1016
1066
  { name: "components release", handler: runComponentsReleaseRecord },
1017
1067
  { name: "components export", handler: runComponentsExport },
1068
+ { name: "components promote", handler: runComponentsPromote },
1069
+ { name: "components rollback", handler: runComponentsRollback },
1018
1070
 
1019
1071
  // Local emulators of configured lexicons (#920). Compound so the action word
1020
1072
  // lands in args.path (not consumed as a project dir) and projectPath is forced ".".
@@ -1034,6 +1086,7 @@ export const commandRegistry: CommandDef[] = [
1034
1086
  { name: "dev", handler: runDevUnknown },
1035
1087
  { name: "serve", handler: runServeUnknown },
1036
1088
  { name: "components", handler: runComponentsUnknown },
1089
+ { name: "workspace", handler: async (ctx) => (await import("../workspace/records-cli")).runWorkspaceUnknown(ctx) },
1037
1090
  ];
1038
1091
 
1039
1092
  /**
@@ -14,6 +14,7 @@
14
14
  * run.
15
15
  */
16
16
 
17
+ import { lexiconNames } from "../../lexicon-module";
17
18
  import { resolve } from "node:path";
18
19
  import { discoverOps } from "../../op/discover";
19
20
  import { createLocalOpRuntime } from "../../op/runtimes/local";
@@ -39,7 +40,7 @@ async function runtimeFor(name: unknown): Promise<OpRuntimeProvider> {
39
40
  }
40
41
 
41
42
  const configured = await loadChantConfig(resolve("."))
42
- .then(({ config }) => config.lexicons ?? [])
43
+ .then(({ config }) => lexiconNames(config.lexicons ?? []))
43
44
  .catch(() => [] as string[]);
44
45
  if (!configured.includes(wanted)) {
45
46
  throw new Error(