@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
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The `signalName` -> `gate` rename (#2202) and the one-version bridge that
3
+ * keeps the old key readable through 0.59.0.
4
+ */
5
+ import { describe, it, expect } from "vitest";
6
+ import { gateName, usesDeprecatedGateKey, opUsesDeprecatedGateKey } from "./gate-name";
7
+ import type { OpConfig } from "./types";
8
+
9
+ describe("gateName", () => {
10
+ it("reads the new key", () => {
11
+ expect(gateName({ gate: "approve-prod" })).toBe("approve-prod");
12
+ });
13
+
14
+ it("falls back to the deprecated key", () => {
15
+ expect(gateName({ signalName: "approve-prod" })).toBe("approve-prod");
16
+ });
17
+
18
+ it("prefers the new key when a step somehow carries both", () => {
19
+ expect(gateName({ gate: "new", signalName: "old" })).toBe("new");
20
+ });
21
+ });
22
+
23
+ describe("usesDeprecatedGateKey", () => {
24
+ it("is true only when the new key is absent", () => {
25
+ expect(usesDeprecatedGateKey({ signalName: "old" })).toBe(true);
26
+ expect(usesDeprecatedGateKey({ gate: "new" })).toBe(false);
27
+ expect(usesDeprecatedGateKey({ gate: "new", signalName: "old" })).toBe(false);
28
+ expect(usesDeprecatedGateKey({})).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("opUsesDeprecatedGateKey", () => {
33
+ const op = (phases: OpConfig["phases"], onFailure?: OpConfig["phases"]): OpConfig =>
34
+ ({ name: "x", overview: "x", phases, ...(onFailure ? { onFailure } : {}) }) as OpConfig;
35
+
36
+ it("finds a gate on the old key in a plain phase", () => {
37
+ expect(opUsesDeprecatedGateKey(op([{ name: "Approve", steps: [{ kind: "gate", signalName: "go" }] }]))).toBe(true);
38
+ });
39
+
40
+ it("finds one nested inside an effect step", () => {
41
+ const config = op([
42
+ {
43
+ name: "Seed",
44
+ steps: [
45
+ {
46
+ kind: "effect",
47
+ receipt: { name: "seeded", effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql" } },
48
+ steps: [{ kind: "gate", signalName: "go" }],
49
+ },
50
+ ],
51
+ },
52
+ ]);
53
+ expect(opUsesDeprecatedGateKey(config)).toBe(true);
54
+ });
55
+
56
+ it("finds one in an onFailure phase", () => {
57
+ expect(
58
+ opUsesDeprecatedGateKey(op([{ name: "Apply", steps: [] }], [{ name: "Undo", steps: [{ kind: "gate", signalName: "go" }] }])),
59
+ ).toBe(true);
60
+ });
61
+
62
+ it("is false for an Op on the new key", () => {
63
+ expect(opUsesDeprecatedGateKey(op([{ name: "Approve", steps: [{ kind: "gate", gate: "go" }] }]))).toBe(false);
64
+ });
65
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The gate step's name field, and the one-version bridge for the key it used
3
+ * to carry (chant #2202).
4
+ *
5
+ * A gate is a fact on the gate ledger, not a channel: since #2119 nothing is
6
+ * running and nothing is signalled, so the field that names a gate is called
7
+ * `gate` — the same word `chant approve <op> <gate>` takes on the command
8
+ * line. It was called `signalName` through 0.58.0.
9
+ *
10
+ * `signalName` is still read, on `GateStep`, on the component `Gate` step and
11
+ * on the composites' gate options, and a build that sees only the old key
12
+ * warns once. It goes away in 0.60.0: delete this module's
13
+ * {@link DEPRECATED_GATE_KEY_WARNING}, {@link usesDeprecatedGateKey}, the
14
+ * `signalName` branch of {@link gateName}, the optional `signalName` fields
15
+ * that call it, and the schema's `oneOf` once that release is out, leaving
16
+ * `gate` as the only key.
17
+ */
18
+
19
+ import type { OpConfig, PhaseDefinition, StepDefinition } from "./types";
20
+
21
+ /** Either spelling of a gate step's name. `gate` since #2202; `signalName` through 0.59.0. */
22
+ export interface GateNamed {
23
+ gate?: string;
24
+ /** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
25
+ signalName?: string;
26
+ }
27
+
28
+ /**
29
+ * The gate's name, from whichever key carries it. One place reads the old key,
30
+ * so removing the bridge is a one-file edit rather than a sweep.
31
+ */
32
+ export function gateName(step: GateNamed): string {
33
+ return step.gate ?? step.signalName ?? "";
34
+ }
35
+
36
+ /** True when a step names its gate only with the deprecated key. */
37
+ export function usesDeprecatedGateKey(step: GateNamed): boolean {
38
+ return step.gate === undefined && typeof step.signalName === "string";
39
+ }
40
+
41
+ /** What a build prints once when it finds a gate step still using the old key. */
42
+ export const DEPRECATED_GATE_KEY_WARNING =
43
+ 'Gate steps still using "signalName": the key is now "gate" (chant #2202). ' +
44
+ '"signalName" is accepted through 0.59.0 and removed in 0.60.0.';
45
+
46
+ /**
47
+ * True when any gate step in an Op's phases (main or `onFailure`, at any
48
+ * nesting depth inside an `effect()`) names itself only with the deprecated
49
+ * key. `chant build` calls this to warn once per build.
50
+ */
51
+ export function opUsesDeprecatedGateKey(config: OpConfig): boolean {
52
+ const inStep = (step: StepDefinition): boolean => {
53
+ if (step.kind === "gate") return usesDeprecatedGateKey(step);
54
+ if (step.kind === "effect") return step.steps.some(inStep);
55
+ return false;
56
+ };
57
+ const inPhases = (phases: PhaseDefinition[] | undefined): boolean =>
58
+ (phases ?? []).some((p) => p.steps.some(inStep));
59
+ return inPhases(config.phases) || inPhases(config.onFailure);
60
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The pending-gate block a CI run leaves where people actually look (#2243).
3
+ *
4
+ * A gated run already prints the gate, the approve command and the expiry to
5
+ * stderr (`./local-output.ts`'s `renderHuman`). In CI that lands in a log
6
+ * nobody opens unless the run is red, which is exactly the run this stops
7
+ * being red. GitHub Actions, Forgejo Actions and Gitea all give a step a
8
+ * markdown scratchpad through `GITHUB_STEP_SUMMARY`, rendered at the top of
9
+ * the run page; anything else that sets the variable gets the same block.
10
+ *
11
+ * That is the whole forge coupling: one environment variable, appended to
12
+ * when it is set, and nothing at all when it is not. No API call, no token,
13
+ * no flag to turn it on.
14
+ */
15
+
16
+ import { appendFileSync } from "node:fs";
17
+ import { gateLedgerPath } from "../lifecycle/gate-ledger";
18
+ import { approveCommand } from "./gate";
19
+
20
+ /** What a gated run knows about the gate it stopped on. */
21
+ export interface GatedRunSummary {
22
+ /** The Op (or component) the gate belongs to. */
23
+ op: string;
24
+ /** The gate's name — the second argument to `chant approve`. */
25
+ gate: string;
26
+ /** The gate's authored description, when it has one. */
27
+ description?: string;
28
+ /** ISO-8601 instant the pending fact ages out at. */
29
+ expiresAt?: string;
30
+ /** The approval surface the run resolved, when it knew one. */
31
+ url?: string;
32
+ }
33
+
34
+ /**
35
+ * The markdown block itself. Pure, so a test reads what CI would render
36
+ * without touching a file: op, gate, the exact `chant approve` line, and the
37
+ * ledger path the pending fact was appended to.
38
+ */
39
+ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
40
+ const lines = [
41
+ `### Waiting on gate \`${summary.gate}\``,
42
+ "",
43
+ `Op \`${summary.op}\` reached a gate nobody has approved, recorded the pending fact, and stopped. Nothing failed.`,
44
+ "",
45
+ ];
46
+ if (summary.description) {
47
+ lines.push(summary.description, "");
48
+ }
49
+ lines.push(
50
+ "Approve it, then re-run this workflow:",
51
+ "",
52
+ "```",
53
+ `${approveCommand(summary.op, summary.gate)} --approver <you>`,
54
+ "```",
55
+ "",
56
+ `Ledger: \`${gateLedgerPath(summary.op)}\` on the \`chant/lifecycle\` branch.`,
57
+ );
58
+ if (summary.expiresAt) lines.push(`Expires: ${summary.expiresAt}`);
59
+ if (summary.url) lines.push(`Approve at: ${summary.url}`);
60
+ lines.push("");
61
+ return lines.join("\n");
62
+ }
63
+
64
+ /**
65
+ * Append {@link gatedRunSummaryMarkdown} to the file `GITHUB_STEP_SUMMARY`
66
+ * names, when the variable is set and the file can be written.
67
+ *
68
+ * Returns the path written, or `undefined` when there was nothing to write to.
69
+ * A write that fails is swallowed: a run that already decided its outcome must
70
+ * not turn into a different one because a CI scratchpad was read-only.
71
+ */
72
+ export function writeGatedRunSummary(
73
+ summary: GatedRunSummary,
74
+ env: NodeJS.ProcessEnv = process.env,
75
+ ): string | undefined {
76
+ const path = env.GITHUB_STEP_SUMMARY;
77
+ if (!path) return undefined;
78
+ try {
79
+ appendFileSync(path, gatedRunSummaryMarkdown(summary));
80
+ return path;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
package/src/op/index.ts CHANGED
@@ -52,6 +52,8 @@ export { runOpLocally, parseDuration, OpRunFailure } from "./local-executor";
52
52
  export type { StepRecord, OpRunResult, RunOpOptions } from "./local-executor";
53
53
  export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand } from "./gate";
54
54
  export type { GateLedgerPort, GateCheck, GateCheckInput } from "./gate";
55
+ export { gateName, usesDeprecatedGateKey, DEPRECATED_GATE_KEY_WARNING } from "./gate-name";
56
+ export type { GateNamed } from "./gate-name";
55
57
  export { createLocalOpRuntime } from "./runtimes/local";
56
58
  export { runStateOf } from "./runtime";
57
59
  export type {
@@ -85,9 +87,14 @@ export type {
85
87
  } from "./converge-rule";
86
88
  export {
87
89
  discoverConvergeOps, runOperatorRound, runOperatorForever, formatRoundLine,
88
- DEFAULT_OPERATOR_INTERVAL_MS,
90
+ formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS,
89
91
  } from "./operator";
90
- export type { OperatorTickEvent, OperatorRoundOptions, OperatorLoopOptions } from "./operator";
92
+ export type {
93
+ OperatorTickEvent, OperatorRoundOptions, OperatorLoopOptions,
94
+ ChangeSubscriber, OperatorSignalEvent,
95
+ } from "./operator";
96
+ export { createChangeSignalGate, DEFAULT_SIGNAL_FLOOR_MS } from "./change-signal";
97
+ export type { ChangeSignalGate, ChangeSignalGateOptions, WakeReason } from "./change-signal";
91
98
  export { classifyOpVerbClass, isGated } from "./op-verb-class";
92
99
  export type { OpVerbClass } from "./op-verb-class";
93
100
  export { takeProfileAndId } from "./builders";
@@ -284,7 +284,7 @@ describe("runOpLocally — gate as fact (#2119)", () => {
284
284
  phases: [
285
285
  { name: "P1", steps: [
286
286
  { kind: "activity", fn: "before" },
287
- { kind: "gate", signalName: "approve-prod", description: "release manager signs off", timeout: "24h" },
287
+ { kind: "gate", gate: "approve-prod", description: "release manager signs off", timeout: "24h" },
288
288
  { kind: "activity", fn: "after" },
289
289
  ] },
290
290
  { name: "P2", steps: [{ kind: "activity", fn: "later" }] },
@@ -324,6 +324,21 @@ describe("runOpLocally — gate as fact (#2119)", () => {
324
324
  ]);
325
325
  });
326
326
 
327
+ // #2202: the gate step's name key is `gate`; `signalName` is read through
328
+ // 0.59.0, so a step still spelling it that way reaches the same ledger entry.
329
+ test("a gate step still using the deprecated `signalName` key names the same gate", async () => {
330
+ const { activities } = tracked();
331
+ const legacy = op({
332
+ phases: [{ name: "P1", steps: [{ kind: "gate", signalName: "approve-prod" }] }],
333
+ });
334
+ const port = memoryGateLedgerPort();
335
+ const result = await runOpLocally(legacy, activities, PROFILES, undefined, { gates: port, now: NOW });
336
+
337
+ expect(result.status).toBe("gated");
338
+ expect(result.gate).toMatchObject({ op: "test-op", gate: "approve-prod" });
339
+ expect(result.records.map((r) => r.fn)).toEqual(["gate:approve-prod"]);
340
+ });
341
+
327
342
  test("onFailure phases do not run on a gated run", async () => {
328
343
  const { calls, activities } = tracked();
329
344
  await runOpLocally(gatedOp(), activities, PROFILES, undefined, { gates: memoryGateLedgerPort(), now: NOW });
@@ -26,6 +26,7 @@ import type { ReceiptReadResult } from "./receipt-store";
26
26
  import { isStepOutputRef } from "./step-output-ref";
27
27
  import { parseDuration } from "./duration";
28
28
  import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
29
+ import { gateName } from "./gate-name";
29
30
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
30
31
  import { appendRunRecord, buildRunRecord } from "../lifecycle/run-ledger";
31
32
  import type { OpRunRecord } from "./runtime";
@@ -342,7 +343,7 @@ function pushRecord(sink: StepRecord[], ctx: GateContext, ...recs: StepRecord[])
342
343
 
343
344
  /** The record name a gate step lands under, so a reader (and a JSON consumer) can pick it out of `records`. */
344
345
  function gateFn(step: GateStep): string {
345
- return `gate:${step.signalName}`;
346
+ return `gate:${gateName(step)}`;
346
347
  }
347
348
 
348
349
  /**
@@ -359,7 +360,7 @@ async function runGateStep(
359
360
  const start = Date.now();
360
361
  const check = await evaluateGate(gates.port, {
361
362
  op: gates.op,
362
- gate: step.signalName,
363
+ gate: gateName(step),
363
364
  ...(step.description ? { description: step.description } : {}),
364
365
  ...(step.timeout ? { timeout: step.timeout } : {}),
365
366
  ...(gates.runId ? { runId: gates.runId } : {}),
@@ -376,7 +377,7 @@ async function runGateStep(
376
377
  status: "ok",
377
378
  durationMs: Date.now() - start,
378
379
  approval: {
379
- gate: step.signalName,
380
+ gate: gateName(step),
380
381
  resolvedBy: resolution.resolvedBy,
381
382
  timestamp: resolution.timestamp,
382
383
  ...(resolution.url ? { url: resolution.url } : {}),
@@ -126,7 +126,7 @@ describe("op.json IR", () => {
126
126
  expect(build.steps[0]).toMatchObject({ kind: "activity", fn: "shellCmd", profile: "fastIdempotent" });
127
127
  expect(approve.steps[0]).toMatchObject({
128
128
  kind: "gate",
129
- signalName: "approve-deploy",
129
+ gate: "approve-deploy",
130
130
  timeout: "24h",
131
131
  description: "Release manager sign-off",
132
132
  });
@@ -141,6 +141,17 @@ describe("op.json IR", () => {
141
141
  expect(ir.onFailure[0].name).toBe("Rollback");
142
142
  });
143
143
 
144
+ // #2202: whichever key an authored gate step used, the IR writes `gate`, so
145
+ // a foreign consumer of op.json never sees the deprecated spelling.
146
+ it("normalizes a gate step's deprecated `signalName` key onto `gate`", () => {
147
+ const config = representativeOp();
148
+ const approve = config.phases.find((p) => p.name === "Approve")!;
149
+ approve.steps[0] = { kind: "gate", signalName: "approve-deploy", timeout: "24h" };
150
+ const ir = buildOpIR(config);
151
+ expect(ir.phases[1].steps[0]).toMatchObject({ kind: "gate", gate: "approve-deploy", timeout: "24h" });
152
+ expect(serializeOpIR(config)).not.toContain("signalName");
153
+ });
154
+
144
155
  it("contracts are the injected registry's: empty until a caller supplies one", () => {
145
156
  const bare = buildOpIR(representativeOp());
146
157
  expect(bare.activityContracts).toEqual({});
package/src/op/op-ir.ts CHANGED
@@ -95,6 +95,7 @@ import type {
95
95
  import type { EffectReceiptRef } from "./receipt-store";
96
96
  import type { ActivityContract } from "./activity-contract";
97
97
  import { ACTIVITY_PROFILES, type ActivityProfile } from "./activity-profiles";
98
+ import { gateName } from "./gate-name";
98
99
 
99
100
  /**
100
101
  * The op.json IR schema version. Bumped on a breaking change to this
@@ -131,7 +132,8 @@ export interface OpIRActivityStep {
131
132
 
132
133
  export interface OpIRGateStep {
133
134
  kind: "gate";
134
- signalName: string;
135
+ /** The gate's name — `GateStep.gate`, or its deprecated `signalName` spelling (#2202). */
136
+ gate: string;
135
137
  /** Resolved to its effective value — `GateStep.timeout ?? "48h"`. */
136
138
  timeout: string;
137
139
  description?: string;
@@ -227,7 +229,7 @@ function irActivityStep(step: ActivityStep, contracts: ReadonlyMap<string, Activ
227
229
  function irGateStep(step: GateStep): OpIRGateStep {
228
230
  return {
229
231
  kind: "gate",
230
- signalName: step.signalName,
232
+ gate: gateName(step),
231
233
  timeout: step.timeout ?? "48h",
232
234
  ...(step.description ? { description: step.description } : {}),
233
235
  };
@@ -375,7 +377,7 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
375
377
  if (step.kind === "gate") {
376
378
  return {
377
379
  kind: "gate",
378
- signalName: step.signalName,
380
+ gate: step.gate,
379
381
  timeout: step.timeout,
380
382
  ...(step.description ? { description: step.description } : {}),
381
383
  };
@@ -106,7 +106,7 @@ describe("isGated", () => {
106
106
  expect(
107
107
  isGated(
108
108
  cfg([
109
- { name: "Approve", steps: [{ kind: "gate", signalName: "approve-x" }] },
109
+ { name: "Approve", steps: [{ kind: "gate", gate: "approve-x" }] },
110
110
  { name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] },
111
111
  ]),
112
112
  ),
@@ -118,7 +118,7 @@ describe("isGated", () => {
118
118
  isGated(
119
119
  cfg(
120
120
  [{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] }],
121
- [{ name: "Rollback", steps: [{ kind: "gate", signalName: "approve-rollback" }] }],
121
+ [{ name: "Rollback", steps: [{ kind: "gate", gate: "approve-rollback" }] }],
122
122
  ),
123
123
  ),
124
124
  ).toBe(true);
package/src/op/op.test.ts CHANGED
@@ -131,10 +131,10 @@ describe("activity()", () => {
131
131
  // ── gate() ────────────────────────────────────────────────────────────────────
132
132
 
133
133
  describe("gate()", () => {
134
- it("returns GateStep with kind 'gate' and signalName", () => {
134
+ it("returns GateStep with kind 'gate' and the gate name", () => {
135
135
  const g = gate("dns-delegation");
136
136
  expect(g.kind).toBe("gate");
137
- expect(g.signalName).toBe("dns-delegation");
137
+ expect(g.gate).toBe("dns-delegation");
138
138
  });
139
139
 
140
140
  it("includes timeout when provided", () => {