@intentius/chant 0.58.0 → 0.60.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 (169) hide show
  1. package/dist/audit/core.d.ts +17 -1
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +15 -4
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/build-params.d.ts +2 -2
  6. package/dist/cli/commands/audit.d.ts.map +1 -1
  7. package/dist/cli/commands/build.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  9. package/dist/cli/commands/lint.d.ts +13 -0
  10. package/dist/cli/commands/lint.d.ts.map +1 -1
  11. package/dist/cli/handlers/lint.d.ts.map +1 -1
  12. package/dist/cli/handlers/operator.d.ts.map +1 -1
  13. package/dist/cli/handlers/run.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts +0 -16
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/plugins.d.ts +22 -0
  17. package/dist/cli/plugins.d.ts.map +1 -1
  18. package/dist/cli/registry.d.ts +14 -0
  19. package/dist/cli/registry.d.ts.map +1 -1
  20. package/dist/components/cli-support.d.ts +4 -1
  21. package/dist/components/cli-support.d.ts.map +1 -1
  22. package/dist/components/component.d.ts +19 -4
  23. package/dist/components/component.d.ts.map +1 -1
  24. package/dist/components/driver.d.ts +8 -2
  25. package/dist/components/driver.d.ts.map +1 -1
  26. package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
  27. package/dist/components/verbs/run-agent.d.ts +1 -7
  28. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  29. package/dist/config.d.ts +4 -4
  30. package/dist/detectLexicon.d.ts +13 -0
  31. package/dist/detectLexicon.d.ts.map +1 -1
  32. package/dist/lexicon.d.ts +213 -3
  33. package/dist/lexicon.d.ts.map +1 -1
  34. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  35. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  36. package/dist/lifecycle/observe.d.ts +4 -4
  37. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  38. package/dist/op/activities/index.d.ts +2 -2
  39. package/dist/op/activities/index.d.ts.map +1 -1
  40. package/dist/op/activities/reconcile.d.ts +134 -2
  41. package/dist/op/activities/reconcile.d.ts.map +1 -1
  42. package/dist/op/builders.d.ts +2 -2
  43. package/dist/op/builders.d.ts.map +1 -1
  44. package/dist/op/change-signal.d.ts +91 -0
  45. package/dist/op/change-signal.d.ts.map +1 -0
  46. package/dist/op/composites/apply-op.d.ts +7 -2
  47. package/dist/op/composites/apply-op.d.ts.map +1 -1
  48. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  49. package/dist/op/gate-name.d.ts +40 -0
  50. package/dist/op/gate-name.d.ts.map +1 -0
  51. package/dist/op/gate-summary.d.ts +55 -0
  52. package/dist/op/gate-summary.d.ts.map +1 -0
  53. package/dist/op/index.d.ts +6 -2
  54. package/dist/op/index.d.ts.map +1 -1
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/op-ir.d.ts +2 -1
  57. package/dist/op/op-ir.d.ts.map +1 -1
  58. package/dist/op/operator.d.ts +63 -0
  59. package/dist/op/operator.d.ts.map +1 -1
  60. package/dist/op/types.d.ts +18 -3
  61. package/dist/op/types.d.ts.map +1 -1
  62. package/dist/params.d.ts +1 -1
  63. package/dist/project-root.d.ts +2 -2
  64. package/dist/terraform/bridge.d.ts +26 -7
  65. package/dist/terraform/bridge.d.ts.map +1 -1
  66. package/dist/terraform/carve-provider.d.ts +28 -2
  67. package/dist/terraform/carve-provider.d.ts.map +1 -1
  68. package/dist/terraform/data-source-shape.d.ts +77 -0
  69. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  70. package/dist/terraform/graph.d.ts.map +1 -1
  71. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  72. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  73. package/dist/terraform/tier-map.d.ts +16 -4
  74. package/dist/terraform/tier-map.d.ts.map +1 -1
  75. package/dist/terraform/types.d.ts +8 -0
  76. package/dist/terraform/types.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/audit/core.ts +25 -3
  79. package/src/audit/discover.ts +122 -14
  80. package/src/build-params.ts +2 -2
  81. package/src/cli/commands/audit.test.ts +5 -3
  82. package/src/cli/commands/audit.ts +5 -1
  83. package/src/cli/commands/build.test.ts +39 -1
  84. package/src/cli/commands/build.ts +22 -8
  85. package/src/cli/commands/carve-bridge.test.ts +237 -14
  86. package/src/cli/commands/carve-bridge.ts +21 -17
  87. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  88. package/src/cli/commands/lint.test.ts +297 -1
  89. package/src/cli/commands/lint.ts +119 -11
  90. package/src/cli/handlers/graph.test.ts +4 -4
  91. package/src/cli/handlers/graph.ts +11 -11
  92. package/src/cli/handlers/lint.test.ts +107 -0
  93. package/src/cli/handlers/lint.ts +30 -0
  94. package/src/cli/handlers/operator.ts +81 -0
  95. package/src/cli/handlers/run.test.ts +132 -3
  96. package/src/cli/handlers/run.ts +87 -3
  97. package/src/cli/main.test.ts +9 -11
  98. package/src/cli/main.ts +5 -27
  99. package/src/cli/plugins.test.ts +68 -2
  100. package/src/cli/plugins.ts +39 -0
  101. package/src/cli/registry.ts +14 -0
  102. package/src/components/README.md +2 -2
  103. package/src/components/SPRAWL-VALIDATION.md +5 -5
  104. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  105. package/src/components/cli-support.test.ts +18 -7
  106. package/src/components/cli-support.ts +8 -3
  107. package/src/components/component-schema.test.ts +18 -2
  108. package/src/components/component.schema.json +17 -4
  109. package/src/components/component.test.ts +2 -2
  110. package/src/components/component.ts +25 -5
  111. package/src/components/config-defaults.test.ts +2 -2
  112. package/src/components/driver.test.ts +20 -1
  113. package/src/components/driver.ts +12 -5
  114. package/src/components/pilots/README.md +1 -1
  115. package/src/components/pilots/alb-ecs.pilot.ts +2 -2
  116. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  117. package/src/components/verbs/run-agent.test.ts +19 -0
  118. package/src/components/verbs/run-agent.ts +1 -7
  119. package/src/config.ts +4 -4
  120. package/src/detectLexicon.ts +18 -1
  121. package/src/discovery/fold-import.test.ts +2 -2
  122. package/src/discovery/fold-import.ts +3 -3
  123. package/src/fold/foldable-helpers.ts +1 -1
  124. package/src/graph-ops.test.ts +1 -1
  125. package/src/lexicon.ts +221 -3
  126. package/src/lifecycle/gate-ledger.ts +12 -1
  127. package/src/lifecycle/observe.test.ts +2 -2
  128. package/src/lifecycle/observe.ts +8 -8
  129. package/src/lifecycle/release-ledger.test.ts +2 -2
  130. package/src/lint/pipeline-change-gate.test.ts +2 -2
  131. package/src/lint/rules/comp/comp.test.ts +26 -0
  132. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  133. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  134. package/src/op/activities/index.ts +9 -2
  135. package/src/op/activities/reconcile.test.ts +320 -2
  136. package/src/op/activities/reconcile.ts +423 -2
  137. package/src/op/builders.ts +3 -3
  138. package/src/op/change-signal.test.ts +117 -0
  139. package/src/op/change-signal.ts +169 -0
  140. package/src/op/composites/apply-op.ts +14 -4
  141. package/src/op/composites/composites.test.ts +17 -4
  142. package/src/op/composites/reconcile-op.ts +7 -4
  143. package/src/op/effect-step.test.ts +3 -3
  144. package/src/op/gate-name.test.ts +65 -0
  145. package/src/op/gate-name.ts +60 -0
  146. package/src/op/gate-summary.test.ts +62 -0
  147. package/src/op/gate-summary.ts +96 -0
  148. package/src/op/index.ts +9 -2
  149. package/src/op/local-executor.test.ts +16 -1
  150. package/src/op/local-executor.ts +4 -3
  151. package/src/op/op-ir.test.ts +12 -1
  152. package/src/op/op-ir.ts +5 -3
  153. package/src/op/op-verb-class.test.ts +2 -2
  154. package/src/op/op.test.ts +2 -2
  155. package/src/op/operator.test.ts +368 -0
  156. package/src/op/operator.ts +141 -5
  157. package/src/op/runtimes/local.test.ts +1 -1
  158. package/src/op/types.ts +23 -3
  159. package/src/params.ts +1 -1
  160. package/src/project-root.ts +2 -2
  161. package/src/terraform/aws-resources.test.ts +13 -4
  162. package/src/terraform/bridge.test.ts +22 -9
  163. package/src/terraform/bridge.ts +89 -28
  164. package/src/terraform/carve-provider.ts +38 -2
  165. package/src/terraform/data-source-shape.ts +95 -0
  166. package/src/terraform/graph.ts +35 -7
  167. package/src/terraform/providers/kubernetes.ts +48 -2
  168. package/src/terraform/tier-map.ts +21 -6
  169. package/src/terraform/types.ts +8 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * chant #2251 — `chant lint` resolves this invocation's declared build-time
3
+ * parameters before it lints.
4
+ *
5
+ * The gap this covers: `runLint` built its `LintOptions` without ever
6
+ * touching `chant.config.ts`'s `buildParams`, so the OPS* checks imported
7
+ * every `*.op.ts` file with `params` (`@intentius/chant/params`) still empty.
8
+ * An Op taking a step argument from `params.<name>` therefore read
9
+ * `undefined` and OPS012 reported the activity contract violated on source
10
+ * that builds and runs — reproducible on any project with an Op that reads a
11
+ * build parameter, `--param` and the declared `env` mapping alike (the
12
+ * resolution never ran at all, so no input could reach it).
13
+ *
14
+ * Mocks `lintCommand` and `loadChantConfigUpward` and drives the exported
15
+ * `runLint` dispatcher, the same shape ./build.test.ts uses for the matching
16
+ * #1108 gap in generate mode.
17
+ */
18
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
19
+ import type { ParsedArgs } from "../registry";
20
+
21
+ const lintCommandMock = vi.fn();
22
+ const loadChantConfigUpwardMock = vi.fn();
23
+
24
+ vi.mock("../commands/lint", async () => {
25
+ const actual = await vi.importActual<typeof import("../commands/lint")>("../commands/lint");
26
+ return {
27
+ ...actual,
28
+ lintCommand: (...args: unknown[]) => lintCommandMock(...args),
29
+ printLintResult: () => {},
30
+ };
31
+ });
32
+ vi.mock("../../config", async () => {
33
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
34
+ return { ...actual, loadChantConfigUpward: (...args: unknown[]) => loadChantConfigUpwardMock(...args) };
35
+ });
36
+
37
+ const { runLint } = await import("./lint");
38
+
39
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
40
+ return {
41
+ command: "lint",
42
+ path: ".",
43
+ format: "",
44
+ fix: false,
45
+ watch: false,
46
+ verbose: false,
47
+ help: false,
48
+ live: false,
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ describe("runLint build-time parameters (#2251)", () => {
54
+ beforeEach(() => {
55
+ lintCommandMock.mockReset().mockResolvedValue({ success: true, errorCount: 0, warningCount: 0, diagnostics: [] });
56
+ loadChantConfigUpwardMock.mockReset().mockResolvedValue({ config: {} });
57
+ vi.spyOn(console, "error").mockImplementation(() => {});
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ test("a project declaring no buildParams lints with an empty provenance array", async () => {
65
+ const exit = await runLint({ args: makeArgs(), plugins: [], serializers: [] });
66
+
67
+ expect(exit).toBe(0);
68
+ expect(lintCommandMock.mock.calls[0][0].buildParams).toEqual([]);
69
+ });
70
+
71
+ test("declared buildParams resolve from their defaults and reach lintCommand", async () => {
72
+ loadChantConfigUpwardMock.mockResolvedValue({
73
+ config: { buildParams: { env: { type: "string", default: "local" } } },
74
+ });
75
+
76
+ const exit = await runLint({ args: makeArgs(), plugins: [], serializers: [] });
77
+
78
+ expect(exit).toBe(0);
79
+ expect(lintCommandMock.mock.calls[0][0].buildParams).toEqual([
80
+ { name: "env", value: "local", source: "default" },
81
+ ]);
82
+ });
83
+
84
+ test("--param overrides the declared default, the same precedence chant build applies", async () => {
85
+ loadChantConfigUpwardMock.mockResolvedValue({
86
+ config: { buildParams: { env: { type: "string", default: "local" } } },
87
+ });
88
+
89
+ const exit = await runLint({ args: makeArgs({ param: ["env=pr-42"] }), plugins: [], serializers: [] });
90
+
91
+ expect(exit).toBe(0);
92
+ expect(lintCommandMock.mock.calls[0][0].buildParams).toEqual([
93
+ { name: "env", value: "pr-42", source: "cli" },
94
+ ]);
95
+ });
96
+
97
+ test("an unresolvable parameter exits non-zero without linting", async () => {
98
+ loadChantConfigUpwardMock.mockResolvedValue({
99
+ config: { buildParams: { env: { type: "string", required: true } } },
100
+ });
101
+
102
+ const exit = await runLint({ args: makeArgs(), plugins: [], serializers: [] });
103
+
104
+ expect(exit).toBe(1);
105
+ expect(lintCommandMock).not.toHaveBeenCalled();
106
+ });
107
+ });
@@ -1,6 +1,31 @@
1
+ import { resolve } from "node:path";
1
2
  import { lintCommand, lintCommandWatch, printLintResult } from "../commands/lint";
2
3
  import { formatError, formatInfo } from "../format";
3
4
  import type { CommandContext } from "../registry";
5
+ import { commandBuildParams } from "../build-params-cli";
6
+ import { loadChantConfigUpward, type ChantConfig } from "../../config";
7
+
8
+ /**
9
+ * chant #2251 — `chant lint` resolves this invocation's declared build-time
10
+ * parameters (`chant.config.ts`'s `buildParams`) before it lints, the same
11
+ * way `chant build` and the lifecycle family do (`commandBuildParams`,
12
+ * ../build-params-cli.ts).
13
+ *
14
+ * The OPS* checks import every `*.op.ts` file to read the Op it declares
15
+ * (../commands/lint.ts's `runOpCheckDiagnostics`), and an Op that takes a
16
+ * step argument from `params.<name>` (`@intentius/chant/params`) evaluates
17
+ * that read at module load. With no parameters resolved, `params` is the
18
+ * empty object every such argument reads `undefined` out of, and OPS012
19
+ * reports the activity contract violated — `args.env: expected string,
20
+ * received undefined` — for source that builds and runs correctly. The
21
+ * config is loaded by walking up from the lint path, so linting a
22
+ * subdirectory still sees the project root's declarations.
23
+ */
24
+ async function lintBuildParams(args: { path: string; param?: string[]; paramsFile?: string }) {
25
+ const { config } = await loadChantConfigUpward(resolve(args.path)).catch(() => ({ config: {} as ChantConfig }));
26
+ if (!config.buildParams) return [];
27
+ return commandBuildParams(config.buildParams, args);
28
+ }
4
29
 
5
30
  export async function runLint(ctx: CommandContext): Promise<number> {
6
31
  const { args } = ctx;
@@ -11,12 +36,16 @@ export async function runLint(ctx: CommandContext): Promise<number> {
11
36
  return 1;
12
37
  }
13
38
 
39
+ const buildParams = await lintBuildParams(args);
40
+ if (buildParams === undefined) return 1;
41
+
14
42
  if (args.watch) {
15
43
  const cleanup = lintCommandWatch({
16
44
  path: args.path,
17
45
  fix: args.fix,
18
46
  format: lintFormat,
19
47
  sandbox: args.sandbox,
48
+ buildParams,
20
49
  });
21
50
  process.on("SIGINT", () => {
22
51
  cleanup();
@@ -31,6 +60,7 @@ export async function runLint(ctx: CommandContext): Promise<number> {
31
60
  fix: args.fix,
32
61
  format: lintFormat,
33
62
  sandbox: args.sandbox,
63
+ buildParams,
34
64
  });
35
65
 
36
66
  printLintResult(result);
@@ -7,6 +7,9 @@
7
7
  * `loadProfiles` from the project's configured lexicons).
8
8
  */
9
9
  import { loadChantConfig } from "../../config";
10
+ import { build } from "../../build";
11
+ import { isResourceDeclarable } from "../../declarable";
12
+ import { collectBuildRootContributors, collectChangeSubscribers } from "../plugins";
10
13
  import { discoverOps } from "../../op/discover";
11
14
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
12
15
  import { parseDuration } from "../../op/local-executor";
@@ -15,7 +18,10 @@ import {
15
18
  runOperatorRound,
16
19
  runOperatorForever,
17
20
  formatRoundLine,
21
+ formatSignalLine,
18
22
  DEFAULT_OPERATOR_INTERVAL_MS,
23
+ type ChangeSubscriber,
24
+ type OperatorSignalEvent,
19
25
  type OperatorTickEvent,
20
26
  } from "../../op/operator";
21
27
  import { readLease, DEFAULT_LEASE_TTL_MS } from "../../lifecycle/lease";
@@ -43,6 +49,76 @@ async function loadOperatorActivities() {
43
49
  return Promise.all([loadActivities(lexicons), loadProfiles()]);
44
50
  }
45
51
 
52
+ /**
53
+ * Bind the change-signal seams (#1981) the daemon will keep subscribed, or an
54
+ * empty list.
55
+ *
56
+ * Three gates, in cost order, so a project that gains nothing from this pays
57
+ * nothing for it:
58
+ *
59
+ * 1. No configured lexicon implements `subscribeChanges`. Return immediately,
60
+ * without loading config or building anything. This is every project today.
61
+ * 2. No `--env`. A subscription resolves the same cluster binding a read
62
+ * does, and there is no binding to resolve without an environment. Said out
63
+ * loud rather than silently skipped, because "why did it not wake" is
64
+ * otherwise unanswerable.
65
+ * 3. The build that supplies the declared entities failed. Warn and fall back
66
+ * to the timer. A subscription is an optimization; a build error here must
67
+ * not stop the operator, which has its own per-tick build inside the tick.
68
+ */
69
+ async function collectOperatorSubscribers(
70
+ ctx: CommandContext,
71
+ env: string | undefined,
72
+ ): Promise<ChangeSubscriber[]> {
73
+ if (!ctx.plugins.some((p) => typeof p.subscribeChanges === "function")) return [];
74
+
75
+ if (!env) {
76
+ console.error(formatWarning({
77
+ message: "a change signal needs an environment to resolve its binding, so this runs on the timer alone",
78
+ hint: "pass --env <env> to let a lexicon's subscribeChanges wake a tick early",
79
+ }));
80
+ return [];
81
+ }
82
+
83
+ const cwd = process.cwd();
84
+ try {
85
+ const { config } = await loadChantConfig(cwd);
86
+ const buildRoots = collectBuildRootContributors(
87
+ ctx.plugins,
88
+ config as unknown as Record<string, unknown>,
89
+ cwd,
90
+ );
91
+ const buildResult = await build(config.sourceDir ?? ".", ctx.serializers, undefined, { buildRoots });
92
+ if (buildResult.errors.length > 0) {
93
+ console.error(formatWarning({
94
+ message: "build failed while scoping the change signal, so this runs on the timer alone",
95
+ }));
96
+ return [];
97
+ }
98
+
99
+ // The declared estate, sliced per lexicon: the same slice
100
+ // `takeSnapshot` hands `describeResources`, and the bound on what any
101
+ // subscription may watch.
102
+ const entities = new Map<string, Map<string, { entityType: string; props: Record<string, unknown> }>>();
103
+ for (const [name, entity] of buildResult.entities) {
104
+ if (!isResourceDeclarable(entity)) continue;
105
+ let perLexicon = entities.get(entity.lexicon);
106
+ if (!perLexicon) entities.set(entity.lexicon, (perLexicon = new Map()));
107
+ perLexicon.set(name, {
108
+ entityType: entity.entityType,
109
+ props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
110
+ });
111
+ }
112
+
113
+ return collectChangeSubscribers(ctx.plugins, { environment: env, cwd, entities });
114
+ } catch (err) {
115
+ console.error(formatWarning({
116
+ message: `could not scope the change signal, so this runs on the timer alone (${err instanceof Error ? err.message : String(err)})`,
117
+ }));
118
+ return [];
119
+ }
120
+ }
121
+
46
122
  // ── chant operator ──────────────────────────────────────────────────────────
47
123
 
48
124
  /**
@@ -98,6 +174,9 @@ export async function runOperator(ctx: CommandContext): Promise<number> {
98
174
  console.error(formatInfo(
99
175
  `chant operator: watching ${ops.length} ConvergeOp(s) every ${intervalMs}ms (Ctrl-C to stop)`,
100
176
  ));
177
+ // A change signal (#1981) only ever shortens the sleep above. Every round
178
+ // it wakes is the round the timer would have run.
179
+ const subscribers = await collectOperatorSubscribers(ctx, ctx.args.env);
101
180
  await runOperatorForever({
102
181
  env: ctx.args.env,
103
182
  intervalMs,
@@ -106,6 +185,8 @@ export async function runOperator(ctx: CommandContext): Promise<number> {
106
185
  profiles,
107
186
  signal: controller.signal,
108
187
  onRound: printRound,
188
+ subscribers,
189
+ onSignalEvent: (event: OperatorSignalEvent) => console.error(formatInfo(formatSignalLine(event))),
109
190
  });
110
191
  return 0;
111
192
  } finally {
@@ -1,4 +1,7 @@
1
- import { describe, test, expect, vi, beforeEach } from "vitest";
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { existsSync, mkdtempSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import type { ParsedArgs } from "../registry";
3
6
 
4
7
  const discoverOpsMock = vi.fn();
@@ -208,7 +211,7 @@ describe("runOp dispatcher", () => {
208
211
  test("gate in local mode → exit 3 and the approve line (#2119)", async () => {
209
212
  gateLedger = memoryGateLedgerPort();
210
213
  discoverOpsMock.mockResolvedValue({
211
- ops: new Map([localOp("gated", [{ kind: "gate", signalName: "approve-prod" }])]),
214
+ ops: new Map([localOp("gated", [{ kind: "gate", gate: "approve-prod" }])]),
212
215
  errors: [],
213
216
  });
214
217
  // `renderHuman` writes straight to process.stderr, not through console.error.
@@ -235,7 +238,7 @@ describe("runOp dispatcher", () => {
235
238
  });
236
239
  discoverOpsMock.mockResolvedValue({
237
240
  ops: new Map([localOp("gated", [
238
- { kind: "gate", signalName: "approve-prod" },
241
+ { kind: "gate", gate: "approve-prod" },
239
242
  { kind: "activity", fn: "shellCmd", args: { cmd: "true" } },
240
243
  ])]),
241
244
  errors: [],
@@ -266,6 +269,132 @@ describe("runOp dispatcher", () => {
266
269
  });
267
270
  });
268
271
 
272
+ /**
273
+ * chant #2243 — a gated run's exit code is the one thing `--gated-exit`
274
+ * remaps, and the gate is reported where CI can see it without opening a log.
275
+ *
276
+ * The motivating shape is a push-to-main terraform apply: GitHub Actions has
277
+ * no neutral conclusion for a `run:` step, so exit 3 paints the branch red on
278
+ * every merge until someone approves. `--gated-exit 0` says a pending
279
+ * approval is not a broken build; nothing else about the run changes, and a
280
+ * failure is still a failure.
281
+ */
282
+ describe("runOp: --gated-exit (#2243)", () => {
283
+ const gateStep = { kind: "gate", gate: "approve-prod" };
284
+
285
+ function summaryFile(): string {
286
+ return join(mkdtempSync(join(tmpdir(), "chant-gated-exit-")), "summary.md");
287
+ }
288
+
289
+ beforeEach(() => {
290
+ discoverOpsMock.mockReset();
291
+ loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
292
+ loadPluginsMock.mockReset().mockResolvedValue([]);
293
+ gateLedger = memoryGateLedgerPort();
294
+ delete process.env.GITHUB_STEP_SUMMARY;
295
+ });
296
+
297
+ afterEach(() => {
298
+ delete process.env.GITHUB_STEP_SUMMARY;
299
+ vi.restoreAllMocks();
300
+ });
301
+
302
+ test("unmapped: without the flag a gated run still exits 3", async () => {
303
+ discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("gated", [gateStep])]), errors: [] });
304
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
305
+ const exit = await runOp({ args: makeArgs({ path: "gated" }), plugins: [], serializers: [] });
306
+ expect(exit).toBe(3);
307
+ });
308
+
309
+ test("mapped: --gated-exit 0 exits 0 and writes the gate to GITHUB_STEP_SUMMARY", async () => {
310
+ const file = summaryFile();
311
+ process.env.GITHUB_STEP_SUMMARY = file;
312
+ discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("gated", [gateStep])]), errors: [] });
313
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
314
+ const stderr = makeStderrSpy();
315
+
316
+ const exit = await runOp({
317
+ args: makeArgs({ path: "gated", gatedExit: 0 }),
318
+ plugins: [], serializers: [],
319
+ });
320
+
321
+ expect(exit).toBe(0);
322
+ // The run genuinely stopped: the pending fact is on the ledger, and
323
+ // nothing after the gate ran.
324
+ expect(gateLedger.appended).toHaveLength(1);
325
+ const summary = readFileSync(file, "utf8");
326
+ expect(summary).toContain("approve-prod");
327
+ expect(summary).toContain("chant approve gated approve-prod");
328
+ expect(summary).toContain("_gates/gated.jsonl");
329
+ // A green run that applied nothing says so on stderr too.
330
+ expect(stderr.join("\n")).toContain("exiting 0");
331
+ });
332
+
333
+ test("mapped: the --json payload still reports the gate, not a success", async () => {
334
+ const file = summaryFile();
335
+ process.env.GITHUB_STEP_SUMMARY = file;
336
+ discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("gated", [gateStep])]), errors: [] });
337
+ const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
338
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
339
+ makeStderrSpy();
340
+
341
+ const exit = await runOp({
342
+ args: makeArgs({ path: "gated", gatedExit: 0, json: true }),
343
+ plugins: [], serializers: [],
344
+ });
345
+
346
+ expect(exit).toBe(0);
347
+ const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => String(c[0])).join("").trim());
348
+ expect(parsed.status).toBe("gated");
349
+ expect(parsed.gate).toMatchObject({ name: "approve-prod" });
350
+ expect(parsed.approve).toBe("chant approve gated approve-prod");
351
+ // The summary is written whether or not stdout is machine-readable.
352
+ expect(readFileSync(file, "utf8")).toContain("chant approve gated approve-prod");
353
+ });
354
+
355
+ test("a run that fails for any other reason is still red under the flag", async () => {
356
+ discoverOpsMock.mockResolvedValue({
357
+ ops: new Map([localOp("broken", [{ kind: "activity", fn: "shellCmd", args: { cmd: "exit 7" } }])]),
358
+ errors: [],
359
+ });
360
+ const file = summaryFile();
361
+ process.env.GITHUB_STEP_SUMMARY = file;
362
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
363
+
364
+ const exit = await runOp({
365
+ args: makeArgs({ path: "broken", gatedExit: 0 }),
366
+ plugins: [], serializers: [],
367
+ });
368
+
369
+ expect(exit).toBe(1);
370
+ // Nothing is pending, so nothing is reported as pending.
371
+ expect(existsSync(file)).toBe(false);
372
+ });
373
+
374
+ test("a value that is not an exit status is refused before the Op runs", async () => {
375
+ discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("gated", [gateStep])]), errors: [] });
376
+ const stderr = makeStderrSpy();
377
+ const exit = await runOp({
378
+ args: makeArgs({ path: "gated", gatedExit: 300 }),
379
+ plugins: [], serializers: [],
380
+ });
381
+ expect(exit).toBe(1);
382
+ expect(stderr.join("\n")).toContain("--gated-exit expects a whole number from 0 to 255");
383
+ expect(discoverOpsMock).not.toHaveBeenCalled();
384
+ });
385
+
386
+ test("no GITHUB_STEP_SUMMARY, no file: the variable is the whole forge coupling", async () => {
387
+ discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("gated", [gateStep])]), errors: [] });
388
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
389
+ const exit = await runOp({
390
+ args: makeArgs({ path: "gated", gatedExit: 0 }),
391
+ plugins: [], serializers: [],
392
+ });
393
+ expect(exit).toBe(0);
394
+ expect(process.env.GITHUB_STEP_SUMMARY).toBeUndefined();
395
+ });
396
+ });
397
+
269
398
  /**
270
399
  * chant #2003 — `--sandbox` is a global flag and `../main.ts` arms the
271
400
  * process-wide policy latch off it for every command, so `chant run <op>
@@ -6,6 +6,7 @@ import type { OpConfig } from "../../op/types";
6
6
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
7
7
  import { runOpLocally, findPolicyGateStep, OpRunFailure, type StepRecord } from "../../op/local-executor";
8
8
  import { approveCommand } from "../../op/gate";
9
+ import { writeGatedRunSummary, type GatedRunSummary } from "../../op/gate-summary";
9
10
  import { createLocalOpRuntime } from "../../op/runtimes/local";
10
11
  import type { OpRuntimeProvider, OpRunStatus } from "../../op/runtime";
11
12
  import { renderHuman, renderJson } from "../../op/local-output";
@@ -28,6 +29,54 @@ import type { DriverComponentResult } from "../../components/driver";
28
29
  */
29
30
  export const GATED_EXIT_CODE = 3;
30
31
 
32
+ /**
33
+ * The exit code this invocation gives a gated run — {@link GATED_EXIT_CODE}
34
+ * unless `--gated-exit <code>` asked for another one (#2243).
35
+ *
36
+ * The mapping lives here rather than in a shell wrapper in every generated
37
+ * pipeline, so one rule covers every forge: GitHub Actions has no neutral
38
+ * conclusion for a `run:` step, so a push-to-main apply that stops at its gate
39
+ * paints the branch red on every merge until someone approves. `--gated-exit
40
+ * 0` is how the job that knows a pending approval is not a failure says so.
41
+ *
42
+ * Only the gated outcome is remapped. A failed run still returns 1, so the
43
+ * flag can never hide a broken apply. Returns `undefined` after printing the
44
+ * refusal when the value is not a process exit status.
45
+ */
46
+ function resolveGatedExitCode(ctx: CommandContext): number | undefined {
47
+ const raw = ctx.args.gatedExit;
48
+ if (raw === undefined) return GATED_EXIT_CODE;
49
+ if (!Number.isInteger(raw) || raw < 0 || raw > 255) {
50
+ console.error(formatError({
51
+ message: "--gated-exit expects a whole number from 0 to 255",
52
+ hint: `Got ${Number.isNaN(raw) ? "a non-numeric value" : String(raw)}. ` +
53
+ `Pass --gated-exit 0 to make a run that stopped at a gate a success for CI; ` +
54
+ `omit the flag for the default ${GATED_EXIT_CODE}.`,
55
+ }));
56
+ return undefined;
57
+ }
58
+ return raw;
59
+ }
60
+
61
+ /**
62
+ * What a gated run leaves behind for CI, beyond the stderr block the renderers
63
+ * already print (#2243): the same gate, approve command and ledger path
64
+ * appended to whatever file `GITHUB_STEP_SUMMARY` names, so the run page says
65
+ * what is pending without anyone opening the log.
66
+ *
67
+ * Also says on stderr that the exit code was remapped, when it was. A job that
68
+ * passes `--gated-exit 0` reports success, and the one line that explains why
69
+ * a zero-exit run applied nothing belongs next to the gate itself.
70
+ */
71
+ function reportGatedRun(summary: GatedRunSummary, exitCode: number): void {
72
+ writeGatedRunSummary(summary);
73
+ if (exitCode !== GATED_EXIT_CODE) {
74
+ console.error(formatInfo(
75
+ `gated: exiting ${exitCode} because --gated-exit asked for it. Nothing after the gate ran.`,
76
+ ));
77
+ }
78
+ }
79
+
31
80
  /**
32
81
  * `run list/status/log/cancel --components` reported a component's *durable*
33
82
  * run state — a run that outlives the CLI process and can be queried, signalled
@@ -613,6 +662,9 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
613
662
  return 1;
614
663
  }
615
664
 
665
+ const gatedExit = resolveGatedExitCode(ctx);
666
+ if (gatedExit === undefined) return 1;
667
+
616
668
  const projectPath = resolve(".");
617
669
  const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
618
670
  const paramsResolution = resolveCliBuildParams(config.buildParams, {
@@ -692,7 +744,17 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
692
744
  console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate)}`));
693
745
  if (gate.url) console.error(formatInfo(`approve at: ${gate.url}`));
694
746
  console.error(formatInfo(`expires : ${gate.expiresAt}`));
695
- return GATED_EXIT_CODE;
747
+ reportGatedRun(
748
+ {
749
+ op: gate.op,
750
+ gate: gate.gate,
751
+ ...(gate.description ? { description: gate.description } : {}),
752
+ expiresAt: gate.expiresAt,
753
+ ...(gate.url ? { url: gate.url } : {}),
754
+ },
755
+ gatedExit,
756
+ );
757
+ return gatedExit;
696
758
  }
697
759
 
698
760
  if (result.success && result.run) {
@@ -725,6 +787,9 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
725
787
  return 1;
726
788
  }
727
789
 
790
+ const gatedExit = resolveGatedExitCode(ctx);
791
+ if (gatedExit === undefined) return 1;
792
+
728
793
  const { ops, errors } = await discoverOps();
729
794
  for (const err of errors) console.error(formatWarning({ message: err }));
730
795
 
@@ -786,8 +851,27 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
786
851
 
787
852
  // Exit 3 for a gated run (#2119) — a distinct code so CI can tell
788
853
  // "waiting on a human" from a broken op, and retry the one but not the
789
- // other.
790
- if (status.state === "gated") return GATED_EXIT_CODE;
854
+ // other. `--gated-exit <code>` remaps that one outcome and nothing else
855
+ // (#2243).
856
+ if (status.state === "gated") {
857
+ // The local runtime carries the whole pending fact on its result; a
858
+ // runtime that reports only a state carries the gate's name alone.
859
+ const pending = status.result?.gate;
860
+ const gate = pending?.gate ?? status.gate?.name;
861
+ if (gate) {
862
+ reportGatedRun(
863
+ {
864
+ op: opName,
865
+ gate,
866
+ ...(pending?.description ? { description: pending.description } : {}),
867
+ ...(pending?.expiresAt ? { expiresAt: pending.expiresAt } : {}),
868
+ ...(pending?.url ? { url: pending.url } : {}),
869
+ },
870
+ gatedExit,
871
+ );
872
+ }
873
+ return gatedExit;
874
+ }
791
875
  return status.state === "completed" ? 0 : 1;
792
876
  } catch (err) {
793
877
  if (err instanceof OpRunFailure) {
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { EventEmitter } from "node:events";
3
- import { parseArgs, waitForStreamDrain, usesRemovedTemporalFlag, REMOVED_TEMPORAL_FLAG, REMOVED_FLAG_EXIT_CODE } from "./main";
3
+ import { parseArgs, waitForStreamDrain } from "./main";
4
4
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
5
5
 
6
6
  describe("parseArgs", () => {
@@ -590,16 +590,14 @@ describe("resolveCommand", () => {
590
590
  });
591
591
 
592
592
  describe("--temporal, removed in #2116", () => {
593
- test("the flag is caught before parseArgs, with the line that says where the runtime went", () => {
594
- expect(usesRemovedTemporalFlag(["run", "alb-deploy", "--temporal"])).toBe(true);
595
- expect(usesRemovedTemporalFlag(["run", "alb-deploy", "--temporal=true"])).toBe(true);
596
- expect(REMOVED_TEMPORAL_FLAG).toBe("--temporal was removed in #2116; use --on fountain");
597
- expect(REMOVED_FLAG_EXIT_CODE).toBe(2);
598
- });
599
-
600
- test("an invocation without it is untouched, and the parser has forgotten the flag", () => {
601
- expect(usesRemovedTemporalFlag(["run", "alb-deploy", "--on", "fountain"])).toBe(false);
602
- expect(() => parseArgs(["run", "alb-deploy", "--temporal"])).toThrow(/Unknown flag/);
593
+ // #2204 removed the bridge that caught the flag ahead of the parser and
594
+ // exited 2. Both spellings now take the same route any other unrecognised
595
+ // flag takes, and nothing in the CLI knows the word.
596
+ test("both spellings fail as an unknown flag, like any other unrecognised flag", () => {
597
+ expect(() => parseArgs(["run", "alb-deploy", "--temporal"])).toThrow(/Unknown flag: --temporal/);
598
+ expect(() => parseArgs(["run", "alb-deploy", "--temporal=true"])).toThrow(/Unknown flag: --temporal/);
599
+ // The message and the throw match what an invented flag gets.
600
+ expect(() => parseArgs(["run", "alb-deploy", "--nonesuch"])).toThrow(/Unknown flag: --nonesuch/);
603
601
  });
604
602
  });
605
603
 
package/src/cli/main.ts CHANGED
@@ -95,27 +95,6 @@ const BOOLEAN_FLAGS = new Set([
95
95
  /**
96
96
  * Parse command line arguments
97
97
  */
98
- /**
99
- * `--temporal` picked the Temporal runtime, which #2116 deleted. It is caught
100
- * ahead of {@link parseArgs} — which no longer knows the flag at all — for one
101
- * minor version, so an invocation that still carries it is told where the
102
- * runtime went instead of getting "Unknown flag: --temporal" and a pointer at
103
- * `--help`. Delete this, its test and the constants below once that version
104
- * has shipped.
105
- */
106
- export const REMOVED_TEMPORAL_FLAG = "--temporal was removed in #2116; use --on fountain";
107
-
108
- /**
109
- * Not 1: a removed flag means the command never started, so a CI job that
110
- * retries a failed run has nothing to retry here.
111
- */
112
- export const REMOVED_FLAG_EXIT_CODE = 2;
113
-
114
- /** Matches the bare flag and the joined `--temporal=…` form `splitJoinedFlags` would otherwise split. */
115
- export function usesRemovedTemporalFlag(argv: string[]): boolean {
116
- return argv.some((arg) => arg === "--temporal" || arg.startsWith("--temporal="));
117
- }
118
-
119
98
  export function parseArgs(args: string[]): ParsedArgs {
120
99
  // Local mutable copy — chant #1127's joined-`--flag=value` splitting below
121
100
  // rewrites the array in place (one token becomes two), so this must not
@@ -328,6 +307,11 @@ export function parseArgs(args: string[]): ParsedArgs {
328
307
  result.json = true;
329
308
  } else if (arg === "--progress-json") {
330
309
  result.progressJson = true;
310
+ } else if (arg === "--gated-exit") {
311
+ // `chant run <op> --gated-exit <code>` (#2243) — remap only the gated
312
+ // outcome's exit code. Parsed as a number here and range-checked in the
313
+ // handler, where the refusal can name the flag alongside the run.
314
+ result.gatedExit = Number(args[++i]);
331
315
  } else if (arg === "--durable-requests") {
332
316
  // `chant run approve ... --on <lexicon> --durable-requests` (#2126) —
333
317
  // resolve the gate on the hosting runtime's own request path rather
@@ -1006,12 +990,6 @@ export const commandRegistry: CommandDef[] = [
1006
990
  async function main(): Promise<void> {
1007
991
  const rawArgv = process.argv.slice(2);
1008
992
 
1009
- if (usesRemovedTemporalFlag(rawArgv)) {
1010
- console.error(formatError({ message: REMOVED_TEMPORAL_FLAG }));
1011
- await flushAndExit(REMOVED_FLAG_EXIT_CODE);
1012
- return;
1013
- }
1014
-
1015
993
  let args: ParsedArgs;
1016
994
  try {
1017
995
  args = parseArgs(rawArgv);