@intentius/chant 0.58.0 → 0.59.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 (146) 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/cli/commands/audit.d.ts.map +1 -1
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/lint.d.ts +13 -0
  9. package/dist/cli/commands/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/operator.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts +0 -16
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/plugins.d.ts +22 -0
  15. package/dist/cli/plugins.d.ts.map +1 -1
  16. package/dist/cli/registry.d.ts +14 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/cli-support.d.ts +4 -1
  19. package/dist/components/cli-support.d.ts.map +1 -1
  20. package/dist/components/component.d.ts +19 -4
  21. package/dist/components/component.d.ts.map +1 -1
  22. package/dist/components/driver.d.ts +8 -2
  23. package/dist/components/driver.d.ts.map +1 -1
  24. package/dist/components/verbs/run-agent.d.ts +1 -7
  25. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  26. package/dist/detectLexicon.d.ts +13 -0
  27. package/dist/detectLexicon.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +170 -3
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  31. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  32. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +2 -2
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/reconcile.d.ts +62 -2
  36. package/dist/op/activities/reconcile.d.ts.map +1 -1
  37. package/dist/op/builders.d.ts +2 -2
  38. package/dist/op/builders.d.ts.map +1 -1
  39. package/dist/op/change-signal.d.ts +91 -0
  40. package/dist/op/change-signal.d.ts.map +1 -0
  41. package/dist/op/composites/apply-op.d.ts +7 -2
  42. package/dist/op/composites/apply-op.d.ts.map +1 -1
  43. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  44. package/dist/op/gate-name.d.ts +40 -0
  45. package/dist/op/gate-name.d.ts.map +1 -0
  46. package/dist/op/gate-summary.d.ts +43 -0
  47. package/dist/op/gate-summary.d.ts.map +1 -0
  48. package/dist/op/index.d.ts +6 -2
  49. package/dist/op/index.d.ts.map +1 -1
  50. package/dist/op/local-executor.d.ts.map +1 -1
  51. package/dist/op/op-ir.d.ts +2 -1
  52. package/dist/op/op-ir.d.ts.map +1 -1
  53. package/dist/op/operator.d.ts +63 -0
  54. package/dist/op/operator.d.ts.map +1 -1
  55. package/dist/op/types.d.ts +18 -3
  56. package/dist/op/types.d.ts.map +1 -1
  57. package/dist/terraform/bridge.d.ts +26 -7
  58. package/dist/terraform/bridge.d.ts.map +1 -1
  59. package/dist/terraform/carve-provider.d.ts +28 -2
  60. package/dist/terraform/carve-provider.d.ts.map +1 -1
  61. package/dist/terraform/data-source-shape.d.ts +77 -0
  62. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  63. package/dist/terraform/graph.d.ts.map +1 -1
  64. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  65. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  66. package/dist/terraform/tier-map.d.ts +16 -4
  67. package/dist/terraform/tier-map.d.ts.map +1 -1
  68. package/dist/terraform/types.d.ts +8 -0
  69. package/dist/terraform/types.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/audit/core.ts +25 -3
  72. package/src/audit/discover.ts +122 -14
  73. package/src/cli/commands/audit.test.ts +5 -3
  74. package/src/cli/commands/audit.ts +5 -1
  75. package/src/cli/commands/build.test.ts +39 -1
  76. package/src/cli/commands/build.ts +14 -0
  77. package/src/cli/commands/carve-bridge.test.ts +237 -14
  78. package/src/cli/commands/carve-bridge.ts +21 -17
  79. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  80. package/src/cli/commands/lint.test.ts +146 -1
  81. package/src/cli/commands/lint.ts +82 -7
  82. package/src/cli/handlers/operator.ts +81 -0
  83. package/src/cli/handlers/run.test.ts +132 -3
  84. package/src/cli/handlers/run.ts +87 -3
  85. package/src/cli/main.test.ts +9 -11
  86. package/src/cli/main.ts +5 -27
  87. package/src/cli/plugins.test.ts +68 -2
  88. package/src/cli/plugins.ts +39 -0
  89. package/src/cli/registry.ts +14 -0
  90. package/src/components/README.md +2 -2
  91. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  92. package/src/components/cli-support.test.ts +18 -7
  93. package/src/components/cli-support.ts +8 -3
  94. package/src/components/component-schema.test.ts +18 -2
  95. package/src/components/component.schema.json +17 -4
  96. package/src/components/component.test.ts +2 -2
  97. package/src/components/component.ts +25 -5
  98. package/src/components/config-defaults.test.ts +2 -2
  99. package/src/components/driver.test.ts +20 -1
  100. package/src/components/driver.ts +12 -5
  101. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  102. package/src/components/verbs/run-agent.test.ts +19 -0
  103. package/src/components/verbs/run-agent.ts +1 -7
  104. package/src/detectLexicon.ts +18 -1
  105. package/src/discovery/fold-import.test.ts +1 -1
  106. package/src/fold/foldable-helpers.ts +1 -1
  107. package/src/graph-ops.test.ts +1 -1
  108. package/src/lexicon.ts +177 -3
  109. package/src/lifecycle/gate-ledger.ts +12 -1
  110. package/src/lint/pipeline-change-gate.test.ts +2 -2
  111. package/src/lint/rules/comp/comp.test.ts +26 -0
  112. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  113. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  114. package/src/op/activities/index.ts +2 -2
  115. package/src/op/activities/reconcile.test.ts +82 -2
  116. package/src/op/activities/reconcile.ts +169 -2
  117. package/src/op/builders.ts +3 -3
  118. package/src/op/change-signal.test.ts +117 -0
  119. package/src/op/change-signal.ts +169 -0
  120. package/src/op/composites/apply-op.ts +14 -4
  121. package/src/op/composites/composites.test.ts +17 -4
  122. package/src/op/composites/reconcile-op.ts +7 -4
  123. package/src/op/effect-step.test.ts +3 -3
  124. package/src/op/gate-name.test.ts +65 -0
  125. package/src/op/gate-name.ts +60 -0
  126. package/src/op/gate-summary.ts +84 -0
  127. package/src/op/index.ts +9 -2
  128. package/src/op/local-executor.test.ts +16 -1
  129. package/src/op/local-executor.ts +4 -3
  130. package/src/op/op-ir.test.ts +12 -1
  131. package/src/op/op-ir.ts +5 -3
  132. package/src/op/op-verb-class.test.ts +2 -2
  133. package/src/op/op.test.ts +2 -2
  134. package/src/op/operator.test.ts +368 -0
  135. package/src/op/operator.ts +141 -5
  136. package/src/op/runtimes/local.test.ts +1 -1
  137. package/src/op/types.ts +23 -3
  138. package/src/terraform/aws-resources.test.ts +13 -4
  139. package/src/terraform/bridge.test.ts +22 -9
  140. package/src/terraform/bridge.ts +89 -28
  141. package/src/terraform/carve-provider.ts +38 -2
  142. package/src/terraform/data-source-shape.ts +95 -0
  143. package/src/terraform/graph.ts +35 -7
  144. package/src/terraform/providers/kubernetes.ts +48 -2
  145. package/src/terraform/tier-map.ts +21 -6
  146. package/src/terraform/types.ts +8 -0
@@ -4,7 +4,7 @@ import { readFileSync, writeFileSync, readdirSync, statSync } from "fs";
4
4
  import { execFileSync } from "child_process";
5
5
  import { runLint, parseDisableComments } from "../../lint/engine";
6
6
  import type { LintRule, LintDiagnostic, LintFix } from "../../lint/rule";
7
- import type { IntrinsicDef } from "../../lexicon";
7
+ import type { IntrinsicDef, LexiconPlugin } from "../../lexicon";
8
8
  import { loadPlugins, resolveProjectLexicons } from "../plugins";
9
9
  import { formatStylish, formatJson, formatSarif } from "../reporters/stylish";
10
10
  import { loadLocalRules } from "../../lint/rule-loader";
@@ -20,10 +20,12 @@ import { rule } from "../../lint/declarative";
20
20
  import { watchDirectory, formatTimestamp, formatChangedFiles } from "../watch";
21
21
  import { formatError, formatInfo } from "../format";
22
22
  import { GENERATED_MARKER } from "../../discovery/files";
23
+ import { isNoLexiconDetected } from "../../detectLexicon";
23
24
 
24
25
  // Import config loader
25
26
  import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
26
27
  import { loadChantConfig, resolveKnowledgeDir } from "../../config";
28
+ import { findProjectConfig } from "../../project-root";
27
29
  import type { LintProjectConfig } from "../../lint/rule";
28
30
  import { loadOkfBundle, type OkfBundle } from "../../okf-read";
29
31
 
@@ -91,6 +93,40 @@ export async function loadPluginRules(
91
93
  return pluginRules;
92
94
  }
93
95
 
96
+ /**
97
+ * The diagnostic id `chant lint` reports a lexicon it could not resolve under
98
+ * (chant #2222).
99
+ *
100
+ * Deliberately outside the COR/EVL/COMP/OPS families: those are *rules*, each
101
+ * with a `check()` the engine runs per file, a documented page, a configurable
102
+ * severity and a `chant-disable` escape hatch. This is none of those. It is
103
+ * the lint run reporting that it could not assemble the rule set the project
104
+ * asked for, so it is not silenceable through `rules: { ... : "off" }` or a
105
+ * disable comment. Silencing it would put back the exact "green on a broken
106
+ * project" this id exists to prevent.
107
+ */
108
+ export const LEXICON_RESOLUTION_RULE_ID = "LEX001";
109
+
110
+ /**
111
+ * Turn a lexicon-resolution failure into the error diagnostic `chant lint`
112
+ * reports it as. The message is the underlying error's own, unwrapped, so it
113
+ * is character-for-character what `chant build` prints after `error: ` for the
114
+ * same project (`loadPluginsOrExit`, ../main.ts): one failure, one wording.
115
+ *
116
+ * Attributed to the project's `chant.config.*` where there is one, since that
117
+ * is the file that names the lexicon; to the project root otherwise.
118
+ */
119
+ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDiagnostic {
120
+ return {
121
+ file: findProjectConfig(projectRoot).configPath ?? projectRoot,
122
+ line: 1,
123
+ column: 1,
124
+ ruleId: LEXICON_RESOLUTION_RULE_ID,
125
+ severity: "error",
126
+ message: error.message,
127
+ };
128
+ }
129
+
94
130
  /**
95
131
  * Load all lint rules: core COR/EVL rules, then lexicon plugin rules.
96
132
  *
@@ -103,7 +139,7 @@ export async function loadPluginRules(
103
139
  */
104
140
  async function loadAllPluginRules(
105
141
  projectPath: string,
106
- ): Promise<{ rules: Map<string, LintRule>; intrinsics: IntrinsicDef[] }> {
142
+ ): Promise<{ rules: Map<string, LintRule>; intrinsics: IntrinsicDef[]; lexiconError?: Error }> {
107
143
  const rules = new Map<string, LintRule>();
108
144
 
109
145
  // Load core COR/EVL rules directly
@@ -111,16 +147,47 @@ async function loadAllPluginRules(
111
147
  rules.set(r.id, r);
112
148
  }
113
149
 
114
- // Resolve project lexicons (e.g. ["aws"]) from config or detection
150
+ // Resolve project lexicons (e.g. ["aws"]) from config or detection, then
151
+ // load their plugins.
152
+ //
153
+ // chant #2222: a failure in either step is handed back to `lintCommand`
154
+ // (which reports it as {@link LEXICON_RESOLUTION_RULE_ID}) rather than
155
+ // swallowed or thrown. Both steps fail on the same project state: a
156
+ // `chant.config.ts` that names a lexicon whose package is not installed
157
+ // throws out of `loadPlugins`, and one that *imports* that package throws
158
+ // out of `resolveProjectLexicons` before the names are ever read. Before
159
+ // this, the first case crashed `chant lint` with a stack trace and the
160
+ // second was caught here and discarded, so `chant lint` printed "No
161
+ // problems found" and exited 0 on a project `chant build` refuses to
162
+ // build. A CI job that lints before it builds reported green on a project
163
+ // whose lexicon was missing.
164
+ //
165
+ // The one failure that stays quiet is the one the original `catch` was
166
+ // written for and named in its comment: a project that declares no
167
+ // `lexicons` and imports none from its source files, where the detection
168
+ // fallback throws `NO_LEXICON_DETECTED_MESSAGE`. That project lints under
169
+ // the core rules alone and always has. It is told apart by the exported
170
+ // sentinel rather than by a string literal copied to this file, so the two
171
+ // cannot drift apart.
115
172
  let lexiconNames: string[] = [];
173
+ let lexiconError: Error | undefined;
116
174
  try {
117
175
  lexiconNames = await resolveProjectLexicons(projectPath);
118
- } catch {
119
- // No lexicons detected — core rules only
176
+ } catch (err) {
177
+ if (!isNoLexiconDetected(err)) {
178
+ lexiconError = err instanceof Error ? err : new Error(String(err));
179
+ }
120
180
  }
121
181
 
122
182
  // Load only project lexicon plugins (no "chant" injection)
123
- const plugins = await loadPlugins(lexiconNames);
183
+ let plugins: LexiconPlugin[] = [];
184
+ if (!lexiconError) {
185
+ try {
186
+ plugins = await loadPlugins(lexiconNames);
187
+ } catch (err) {
188
+ lexiconError = err instanceof Error ? err : new Error(String(err));
189
+ }
190
+ }
124
191
 
125
192
  // chant #1106 — the same plugins' registered intrinsics (`Ref`, `GetAtt`,
126
193
  // ...), so EVL001 can answer "does this call fold?" exactly like fold()
@@ -150,7 +217,7 @@ async function loadAllPluginRules(
150
217
  rules.set(r.id, r);
151
218
  }
152
219
 
153
- return { rules, intrinsics };
220
+ return { rules, intrinsics, ...(lexiconError ? { lexiconError } : {}) };
154
221
  }
155
222
 
156
223
  /**
@@ -695,6 +762,14 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
695
762
  suppressed.push(...postOpResult.suppressed);
696
763
  }
697
764
 
765
+ // chant #2222: a lexicon the project declared but this run could not
766
+ // resolve. Appended here, after the `--fix` re-lint block above has finished
767
+ // reassigning `diagnostics`, so it survives every path; it is a property of
768
+ // the run, not of any file a fix could touch.
769
+ if (loaded.lexiconError) {
770
+ diagnostics.push(lexiconResolutionDiagnostic(projectRoot, loaded.lexiconError));
771
+ }
772
+
698
773
  // Count errors and warnings
699
774
  let errorCount = 0;
700
775
  let warningCount = 0;
@@ -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);