@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
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { loadPlugin, loadPlugins, resolveLexiconVersions } from "./plugins";
3
- import { isLexiconPlugin } from "../lexicon";
2
+ import { collectChangeSubscribers, loadPlugin, loadPlugins, resolveLexiconVersions } from "./plugins";
3
+ import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
4
4
 
5
5
  describe("loadPlugin", () => {
6
6
  test("loads aws plugin with full LexiconPlugin interface", async () => {
@@ -61,3 +61,69 @@ describe("resolveLexiconVersions", () => {
61
61
  expect(resolveLexiconVersions([])).toEqual({});
62
62
  });
63
63
  });
64
+
65
+ describe("collectChangeSubscribers (#1981)", () => {
66
+ function fakePlugin(name: string, withSeam: boolean): LexiconPlugin {
67
+ const plugin = {
68
+ name,
69
+ serializer: { name, serialize: () => "" },
70
+ generate: async () => {},
71
+ validate: async () => {},
72
+ coverage: async () => {},
73
+ package: async () => {},
74
+ } as unknown as LexiconPlugin;
75
+ if (withSeam) {
76
+ plugin.subscribeChanges = async () => ({ close: async () => {} });
77
+ }
78
+ return plugin;
79
+ }
80
+
81
+ const entitiesFor = (lexicon: string) =>
82
+ new Map([[lexicon, new Map([["web", { entityType: "K8s::Apps::Deployment", props: {} }]])]]);
83
+
84
+ test("a lexicon without the seam contributes nothing", () => {
85
+ expect(
86
+ collectChangeSubscribers([fakePlugin("aws", false)], {
87
+ environment: "prod",
88
+ entities: entitiesFor("aws"),
89
+ }),
90
+ ).toEqual([]);
91
+ });
92
+
93
+ test("a lexicon with the seam but no declared entities is skipped rather than handed an empty scope", () => {
94
+ expect(
95
+ collectChangeSubscribers([fakePlugin("k8s", true)], {
96
+ environment: "prod",
97
+ entities: new Map(),
98
+ }),
99
+ ).toEqual([]);
100
+ });
101
+
102
+ test("binds the environment, cwd and this lexicon's own entity slice", async () => {
103
+ const plugin = fakePlugin("k8s", true);
104
+ let seen: Record<string, unknown> | undefined;
105
+ plugin.subscribeChanges = async (options) => {
106
+ seen = options as unknown as Record<string, unknown>;
107
+ return { close: async () => {} };
108
+ };
109
+
110
+ const entities = new Map([
111
+ ["k8s", new Map([["web", { entityType: "K8s::Apps::Deployment", props: {} }]])],
112
+ ["aws", new Map([["bucket", { entityType: "AWS::S3::Bucket", props: {} }]])],
113
+ ]);
114
+ const subscribers = collectChangeSubscribers([plugin], { environment: "prod", cwd: "/proj", entities });
115
+ expect(subscribers).toHaveLength(1);
116
+ expect(subscribers[0].lexicon).toBe("k8s");
117
+
118
+ const controller = new AbortController();
119
+ const onChange = () => {};
120
+ const onError = () => {};
121
+ await subscribers[0].subscribe({ onChange, onError, signal: controller.signal });
122
+ expect(seen!.environment).toBe("prod");
123
+ expect(seen!.cwd).toBe("/proj");
124
+ expect(seen!.onChange).toBe(onChange);
125
+ expect(seen!.signal).toBe(controller.signal);
126
+ // This lexicon's slice only: aws's bucket is not in scope for k8s.
127
+ expect([...(seen!.entities as Map<string, unknown>).keys()]).toEqual(["web"]);
128
+ });
129
+ });
@@ -114,6 +114,45 @@ export function collectBuildRootContributors(
114
114
  .map((plugin) => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
115
115
  }
116
116
 
117
+ /**
118
+ * Bind each loaded plugin's `subscribeChanges` seam (#1981) to one environment
119
+ * and its declared entities, producing the `ChangeSubscriber` list `chant
120
+ * operator`'s loop takes, in the same extract-then-thread shape
121
+ * {@link collectBuildRootContributors} uses, and for the same reason: core's
122
+ * operator must not import plugins.
123
+ *
124
+ * Plugins without the seam contribute nothing, so a project whose lexicons all
125
+ * lack it gets a loop that is exactly the timer it always was. `entities` is
126
+ * the per-lexicon slice of a build's declared entities, which is the bound on
127
+ * what a subscription may watch; a lexicon with no declared entities is
128
+ * skipped rather than handed an empty scope to widen.
129
+ */
130
+ export function collectChangeSubscribers(
131
+ plugins: readonly LexiconPlugin[] | undefined,
132
+ options: {
133
+ environment: string;
134
+ cwd?: string;
135
+ /** Declared entities per lexicon name, from a build. */
136
+ entities: Map<string, Map<string, { entityType: string; props: Record<string, unknown> }>>;
137
+ },
138
+ ): Array<import("../op/operator").ChangeSubscriber> {
139
+ return (plugins ?? [])
140
+ .filter((plugin) => typeof plugin.subscribeChanges === "function")
141
+ .filter((plugin) => (options.entities.get(plugin.name)?.size ?? 0) > 0)
142
+ .map((plugin) => ({
143
+ lexicon: plugin.name,
144
+ subscribe: (ctx) =>
145
+ plugin.subscribeChanges!({
146
+ environment: options.environment,
147
+ ...(options.cwd ? { cwd: options.cwd } : {}),
148
+ entities: options.entities.get(plugin.name),
149
+ onChange: ctx.onChange,
150
+ onError: ctx.onError,
151
+ signal: ctx.signal,
152
+ }),
153
+ }));
154
+ }
155
+
117
156
  /**
118
157
  * Load plugins for all detected lexicon names.
119
158
  * Calls `init()` on each plugin if present.
@@ -40,6 +40,20 @@ export interface ParsedArgs {
40
40
  profile?: string;
41
41
  /** `chant run` — emit the structured OpRunResult as JSON on stdout. */
42
42
  json?: boolean;
43
+ /**
44
+ * `chant run <op> --gated-exit <code>` (#2243) — the process exit code a
45
+ * run that stopped at an unapproved gate returns, instead of the default 3.
46
+ *
47
+ * Only the gated outcome is remapped. A run that fails still returns 1 and a
48
+ * run that completes still returns 0, so `--gated-exit 0` on a CI job means
49
+ * "a pending approval is not a broken build" and nothing more. The `--json`
50
+ * payload is untouched by it: the record still says `status: "gated"` and
51
+ * still carries the gate and the `chant approve` line, so a job that maps the
52
+ * code to success can still tell the two apart.
53
+ *
54
+ * Accepts 0-255 (a POSIX exit status). Anything else is refused by name.
55
+ */
56
+ gatedExit?: number;
43
57
  /**
44
58
  * `chant run --components <name|all> --progress-json` (local executor) —
45
59
  * stream one NDJSON `RunProgressEvent` (../../components/run-progress.ts)
@@ -50,7 +50,7 @@ adopting chant.
50
50
  be a nested `Phase`, which is how fan-out (e.g. a Neo4j cluster seeding one
51
51
  node then rolling through the rest) is expressed as composition rather than
52
52
  orchestrator knowledge.
53
- - **Gates** — a distinct step shape (`kind: "gate"`, `signalName`) for a human
53
+ - **Gates** — a distinct step shape (`kind: "gate"`, `gate`) for a human
54
54
  approval, decided against the gate ledger: a run that reaches one with no
55
55
  resolution stops there with status `gated` and the pending fact recorded.
56
56
  - **Wiring reference forms**, all resolved by the graph, never by orchestrator
@@ -89,7 +89,7 @@ Lambda function. `component-schema.test.ts` validates every fixture against
89
89
  `component.schema.json` with [ajv](https://ajv.js.org/) (`ajv/dist/2020` — the
90
90
  draft 2020-12 build) and exercises the schema's negative cases (missing
91
91
  required fields, malformed wiring references, an invalid archetype, a gate
92
- missing `signalName`, and so on).
92
+ naming neither `gate` nor its deprecated `signalName` spelling, and so on).
93
93
 
94
94
  ## Typed authoring form (`component.ts`, #560)
95
95
 
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "phase": "Node 1",
18
18
  "steps": [
19
- { "kind": "gate", "signalName": "approve-neo4j-node-1", "description": "Confirm the seed node is healthy before rolling to node 1", "timeout": "24h" },
19
+ { "kind": "gate", "gate": "approve-neo4j-node-1", "description": "Confirm the seed node is healthy before rolling to node 1", "timeout": "24h" },
20
20
  { "kind": "cfn-deploy", "template": "archive:neo4j-1.template.json" },
21
21
  { "kind": "code-deploy", "instance": 1, "revision": "@Seed.templateUri" },
22
22
  { "kind": "wait-cluster-healthy", "quorum": true }
@@ -428,9 +428,9 @@ describe("findComponentGate", () => {
428
428
  const component: DriverComponent = {
429
429
  name: "svc",
430
430
  dependsOn: [],
431
- deploy: [{ phase: "Approve", steps: [{ kind: "gate", signalName: "release-approval" }] }],
431
+ deploy: [{ phase: "Approve", steps: [{ kind: "gate", gate: "release-approval" }] }],
432
432
  };
433
- expect(findComponentGate(component)).toEqual({ kind: "gate", signalName: "release-approval" });
433
+ expect(findComponentGate(component)).toEqual({ gate: "release-approval" });
434
434
  });
435
435
 
436
436
  test("finds a gate nested inside a fan-out phase", () => {
@@ -443,13 +443,13 @@ describe("findComponentGate", () => {
443
443
  steps: [
444
444
  {
445
445
  phase: "instance-2",
446
- steps: [{ kind: "gate", signalName: "instance-2-approval" }],
446
+ steps: [{ kind: "gate", gate: "instance-2-approval" }],
447
447
  },
448
448
  ],
449
449
  },
450
450
  ],
451
451
  };
452
- expect(findComponentGate(component)?.signalName).toBe("instance-2-approval");
452
+ expect(findComponentGate(component)?.gate).toBe("instance-2-approval");
453
453
  });
454
454
 
455
455
  test("finds a gate in a component's rollback phases", () => {
@@ -457,9 +457,20 @@ describe("findComponentGate", () => {
457
457
  name: "svc",
458
458
  dependsOn: [],
459
459
  deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }],
460
- rollback: [{ phase: "Rollback", steps: [{ kind: "gate", signalName: "rollback-approval" }] }],
460
+ rollback: [{ phase: "Rollback", steps: [{ kind: "gate", gate: "rollback-approval" }] }],
461
+ };
462
+ expect(findComponentGate(component)?.gate).toBe("rollback-approval");
463
+ });
464
+
465
+ // #2202: the name comes back on `gate` whichever key the component spelled
466
+ // it with, so no caller has to know about the deprecated one.
467
+ test("normalizes a gate step's deprecated `signalName` key onto `gate`", () => {
468
+ const component = {
469
+ name: "svc",
470
+ dependsOn: [],
471
+ deploy: [{ phase: "Approve", steps: [{ kind: "gate", signalName: "release-approval" }] }],
461
472
  };
462
- expect(findComponentGate(component)?.signalName).toBe("rollback-approval");
473
+ expect(findComponentGate(component)).toEqual({ gate: "release-approval" });
463
474
  });
464
475
  });
465
476
 
@@ -592,7 +603,7 @@ describe("runComponents", () => {
592
603
  await writeFile(
593
604
  join(testDir, "svc.component.ts"),
594
605
  `export const svc = { name: "svc", dependsOn: [], deploy: [
595
- { phase: "Approve", steps: [{ kind: "gate", signalName: "release-approval" }] },
606
+ { phase: "Approve", steps: [{ kind: "gate", gate: "release-approval" }] },
596
607
  { phase: "Apply", steps: [{ kind: "deploy-thing" }] },
597
608
  ] };`,
598
609
  );
@@ -33,11 +33,13 @@ import {
33
33
  DependencyCycleError,
34
34
  DriverRunFailure,
35
35
  type DriverComponent,
36
+ type DriverGate,
36
37
  type DriverPhase,
37
38
  type DriverRunResult,
38
39
  } from "./driver";
39
40
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
40
41
  import type { GateLedgerPort } from "../op/gate";
42
+ import { gateName } from "../op/gate-name";
41
43
  import { isLexiconPlugin, type LexiconPlugin, type ComponentPipelineOptions } from "../lexicon";
42
44
  import type { RunProgressEvent } from "./run-progress";
43
45
  import { relative } from "node:path";
@@ -319,13 +321,16 @@ function toDriverComponent(component: { name: string; dependsOn: string[]; deplo
319
321
  * A declaration-time question, not a pre-flight refusal: since #2119 the
320
322
  * driver decides a gate against the ledger when it reaches one, so nothing
321
323
  * needs to know up front that a component has one.
324
+ *
325
+ * The name comes back on `gate` whichever key the component spelled it with,
326
+ * so a caller never has to know about the deprecated `signalName` (#2202).
322
327
  */
323
- export function findComponentGate(component: DriverComponent): { signalName: string } | undefined {
324
- const search = (phases: DriverPhase[] | undefined): { signalName: string } | undefined => {
328
+ export function findComponentGate(component: DriverComponent): { gate: string } | undefined {
329
+ const search = (phases: DriverPhase[] | undefined): { gate: string } | undefined => {
325
330
  for (const phaseDef of phases ?? []) {
326
331
  for (const entry of phaseDef.steps) {
327
332
  if ((entry as { kind?: unknown }).kind === "gate") {
328
- return entry as unknown as { signalName: string };
333
+ return { gate: gateName(entry as DriverGate) };
329
334
  }
330
335
  if (typeof (entry as DriverPhase).phase === "string" && Array.isArray((entry as DriverPhase).steps)) {
331
336
  const nested = search([entry as DriverPhase]);
@@ -192,14 +192,14 @@ describe("Component JSON Schema", () => {
192
192
  deploy: [
193
193
  {
194
194
  phase: "Approve",
195
- steps: [{ kind: "gate", signalName: "approve-gated", description: "confirm", timeout: "24h" }],
195
+ steps: [{ kind: "gate", gate: "approve-gated", description: "confirm", timeout: "24h" }],
196
196
  },
197
197
  ],
198
198
  };
199
199
  expect(validate(withGate)).toBe(true);
200
200
  });
201
201
 
202
- it("rejects a gate step missing signalName", () => {
202
+ it("rejects a gate step naming neither `gate` nor `signalName`", () => {
203
203
  const invalid = {
204
204
  name: "gated",
205
205
  dependsOn: [],
@@ -208,6 +208,22 @@ describe("Component JSON Schema", () => {
208
208
  expect(validate(invalid)).toBe(false);
209
209
  });
210
210
 
211
+ // The gate step's name key was renamed from `signalName` to `gate` in #2202.
212
+ // The schema takes either one and exactly one, through 0.59.0.
213
+ const gated = (step: Record<string, unknown>): Record<string, unknown> => ({
214
+ name: "gated",
215
+ dependsOn: [],
216
+ deploy: [{ phase: "Approve", steps: [step] }],
217
+ });
218
+
219
+ it("still accepts the deprecated `signalName` key on a gate step", () => {
220
+ expect(validate(gated({ kind: "gate", signalName: "approve-gated" }))).toBe(true);
221
+ });
222
+
223
+ it("rejects a gate step carrying both `gate` and `signalName`", () => {
224
+ expect(validate(gated({ kind: "gate", gate: "approve-gated", signalName: "approve-gated" }))).toBe(false);
225
+ });
226
+
211
227
  it("accepts a stackOutput cross-stack reference", () => {
212
228
  const withStackOutput = {
213
229
  name: "svc",
@@ -160,18 +160,31 @@
160
160
  }
161
161
  },
162
162
 
163
+ "GateName": {
164
+ "type": "string",
165
+ "minLength": 1,
166
+ "description": "A gate's name, on either of the two keys that may carry it."
167
+ },
168
+
163
169
  "Gate": {
164
170
  "type": "object",
165
171
  "description": "Ends the run pending human approval. The driver decides a gate against the gate ledger when it reaches one; a gate nobody has approved stops the run there, and `chant approve <component> <gate>` records the resolution the next run reads.",
166
- "required": ["kind", "signalName"],
172
+ "required": ["kind"],
173
+ "oneOf": [
174
+ { "required": ["gate"], "properties": { "gate": { "$ref": "#/$defs/GateName" } } },
175
+ { "required": ["signalName"], "properties": { "signalName": { "$ref": "#/$defs/GateName" } } }
176
+ ],
167
177
  "additionalProperties": false,
168
178
  "properties": {
169
179
  "kind": { "type": "string", "const": "gate" },
170
- "signalName": {
171
- "type": "string",
172
- "minLength": 1,
180
+ "gate": {
181
+ "$ref": "#/$defs/GateName",
173
182
  "description": "The gate's name — what `chant approve <component> <gate>` resolves."
174
183
  },
184
+ "signalName": {
185
+ "$ref": "#/$defs/GateName",
186
+ "description": "Deprecated spelling of `gate` (chant #2202), accepted through 0.59.0 and removed in 0.60.0. Exactly one of `gate` and `signalName` may be present."
187
+ },
175
188
  "timeout": {
176
189
  "type": "string",
177
190
  "description": "Duration string bounding the wait, e.g. \"48h\". Default: \"48h\"."
@@ -37,13 +37,13 @@ describe("phase()", () => {
37
37
 
38
38
  describe("gate()", () => {
39
39
  it("builds a minimal Gate", () => {
40
- expect(gate("approve-x")).toEqual({ kind: "gate", signalName: "approve-x" });
40
+ expect(gate("approve-x")).toEqual({ kind: "gate", gate: "approve-x" });
41
41
  });
42
42
 
43
43
  it("carries optional timeout/description", () => {
44
44
  expect(gate("approve-x", { timeout: "24h", description: "confirm" })).toEqual({
45
45
  kind: "gate",
46
- signalName: "approve-x",
46
+ gate: "approve-x",
47
47
  timeout: "24h",
48
48
  description: "confirm",
49
49
  });
@@ -77,16 +77,36 @@ export interface Step {
77
77
  [param: string]: unknown;
78
78
  }
79
79
 
80
- /** Mirrors `$defs.Gate` — a human approval decided against the gate ledger; the run stops here with status `gated` until `chant approve` has answered it. */
81
- export interface Gate {
80
+ /** Everything on a component gate step except the key that names it. */
81
+ export interface GateBase {
82
82
  kind: "gate";
83
- signalName: string;
84
83
  /** How long a recorded pending gate stays valid, as a duration string. Default: "48h". */
85
84
  timeout?: string;
86
85
  /** Human-readable description of the action required to unblock this gate. */
87
86
  description?: string;
88
87
  }
89
88
 
89
+ /**
90
+ * Mirrors `$defs.Gate` — a human approval decided against the gate ledger; the
91
+ * run stops here with status `gated` until `chant approve` has answered it.
92
+ * The name lives on `gate`; `signalName` is the key it carried through 0.58.0
93
+ * and is still accepted (#2202). Read both through `gateName()`.
94
+ */
95
+ export type Gate = GateBase &
96
+ (
97
+ | {
98
+ /** The gate's name — what `chant approve <component> <gate>` resolves. */
99
+ gate: string;
100
+ /** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
101
+ signalName?: string;
102
+ }
103
+ | {
104
+ gate?: undefined;
105
+ /** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
106
+ signalName: string;
107
+ }
108
+ );
109
+
90
110
  /**
91
111
  * Mirrors `$defs.Phase` — one named phase of a deploy composition. A step may
92
112
  * itself be a nested `Phase`, which is how a fan-out unit becomes a mini-
@@ -171,8 +191,8 @@ export function phase(name: string, steps: Array<Step | Gate | Phase>, opts?: {
171
191
  }
172
192
 
173
193
  /** Author a gate step — a human approval the driver decides against the gate ledger, stopping the run `gated` when nothing has answered it. */
174
- export function gate(signalName: string, opts?: { timeout?: string; description?: string }): Gate {
175
- return { kind: "gate", signalName, ...opts };
194
+ export function gate(name: string, opts?: { timeout?: string; description?: string }): Gate {
195
+ return { kind: "gate", gate: name, ...opts };
176
196
  }
177
197
 
178
198
  /**
@@ -257,11 +257,11 @@ describe("applyConfigDefaults", () => {
257
257
  test("leaves gate steps untouched", () => {
258
258
  const config: ChantConfig = { sbom: { format: "cyclonedx" } };
259
259
  const comp = component([
260
- { phase: "Approve", steps: [{ kind: "gate", signalName: "release-approval" }] },
260
+ { phase: "Approve", steps: [{ kind: "gate", gate: "release-approval" }] },
261
261
  ]);
262
262
 
263
263
  const result = applyConfigDefaults(comp, config);
264
- expect(result.deploy[0]!.steps[0]).toEqual({ kind: "gate", signalName: "release-approval" });
264
+ expect(result.deploy[0]!.steps[0]).toEqual({ kind: "gate", gate: "release-approval" });
265
265
  });
266
266
 
267
267
  test("applies defaults to rollback phases too", () => {
@@ -217,7 +217,7 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
217
217
  name: "neo4j-cluster",
218
218
  deploy: [
219
219
  { phase: "Prepare", steps: [{ kind: "cfn-deploy" }] },
220
- { phase: "Node 1", steps: [{ kind: "gate", signalName: "approve-node-1" }, { kind: "code-deploy" }] },
220
+ { phase: "Node 1", steps: [{ kind: "gate", gate: "approve-node-1" }, { kind: "code-deploy" }] },
221
221
  ],
222
222
  rollback: [{ phase: "Undo", steps: [{ kind: "cfn-deploy" }] }],
223
223
  });
@@ -259,6 +259,25 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
259
259
  ]);
260
260
  });
261
261
 
262
+ // #2202: `signalName` was the key that named a component gate through 0.58.0
263
+ // and is still read, so a component on the old key gates identically.
264
+ it("still reads a gate step's deprecated `signalName` key", async () => {
265
+ const { registry } = registryWithCalls();
266
+ const legacy: DriverComponent = {
267
+ name: "neo4j-cluster",
268
+ deploy: [{ phase: "Node 1", steps: [{ kind: "gate", signalName: "approve-node-1" }, { kind: "code-deploy" }] }],
269
+ };
270
+ const port = memoryGateLedgerPort();
271
+ const result = await runComponentDeploy(
272
+ legacy, { env: "dev", component: "neo4j-cluster" }, registry, {}, undefined,
273
+ { port, now: NOW },
274
+ );
275
+
276
+ expect(result.status).toBe("gated");
277
+ expect(result.gate).toMatchObject({ op: "neo4j-cluster", gate: "approve-node-1" });
278
+ expect(result.records.map((r) => r.kind)).toEqual(["gate:approve-node-1", "code-deploy"]);
279
+ });
280
+
262
281
  it("a resolution newer than the pending fact passes the gate and carries the approver", async () => {
263
282
  const { registry, calls } = registryWithCalls();
264
283
  const port = memoryGateLedgerPort({
@@ -48,6 +48,7 @@
48
48
 
49
49
  import { topoSort } from "../codegen/topo-sort";
50
50
  import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
51
+ import { gateName } from "../op/gate-name";
51
52
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
52
53
  import type { CapabilityRegistry, DeployContext } from "./capability";
53
54
  import type { RunProgressEvent, RunProgressStatus } from "./run-progress";
@@ -68,10 +69,16 @@ export interface DriverStep {
68
69
  [param: string]: unknown;
69
70
  }
70
71
 
71
- /** A gate step — a human approval, decided against the gate ledger when the run reaches it (schema `Gate`). */
72
+ /**
73
+ * A gate step — a human approval, decided against the gate ledger when the run
74
+ * reaches it (schema `Gate`). The name is on `gate`; `signalName` is the
75
+ * deprecated spelling (#2202), so read the name through `gateName()`.
76
+ */
72
77
  export interface DriverGate {
73
78
  kind: "gate";
74
- signalName: string;
79
+ gate?: string;
80
+ /** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
81
+ signalName?: string;
75
82
  timeout?: string;
76
83
  description?: string;
77
84
  }
@@ -444,12 +451,12 @@ async function runPhase(
444
451
  const start = Date.now();
445
452
  const check = await evaluateGate(gates.port, {
446
453
  op: ctx.component,
447
- gate: gate.signalName,
454
+ gate: gateName(gate),
448
455
  ...(gate.description ? { description: gate.description } : {}),
449
456
  ...(gate.timeout ? { timeout: gate.timeout } : {}),
450
457
  ...(gates.now ? { now: gates.now } : {}),
451
458
  });
452
- const base = { component: ctx.component, phase: phaseDef.phase, kind: `gate:${gate.signalName}` };
459
+ const base = { component: ctx.component, phase: phaseDef.phase, kind: `gate:${gateName(gate)}` };
453
460
  if (!check.satisfied) {
454
461
  gateRecords.push({ ...base, status: "skipped" as const, durationMs: Date.now() - start });
455
462
  for (const skipped of phaseDef.steps.filter((s): s is DriverStep | DriverPhase => !isGateStep(s))) {
@@ -462,7 +469,7 @@ async function runPhase(
462
469
  status: "ok" as const,
463
470
  durationMs: Date.now() - start,
464
471
  approval: {
465
- gate: gate.signalName,
472
+ gate: gateName(gate),
466
473
  resolvedBy: check.resolution.resolvedBy,
467
474
  timestamp: check.resolution.timestamp,
468
475
  ...(check.resolution.url ? { url: check.resolution.url } : {}),
@@ -31,7 +31,7 @@ interface Neo4jInstanceConfig {
31
31
  */
32
32
  health: { size: number } | { quorum: true };
33
33
  /** Optional human gate placed before this instance's steps (rolling instances only, never the seed). */
34
- approval?: { signalName: string; description: string; timeout: string };
34
+ approval?: { gate: string; description: string; timeout: string };
35
35
  }
36
36
 
37
37
  const SEED: Neo4jInstanceConfig = {
@@ -55,7 +55,7 @@ const FOLLOWERS: Neo4jInstanceConfig[] = [
55
55
  template: "archive:neo4j-1.template.json",
56
56
  health: { quorum: true },
57
57
  approval: {
58
- signalName: "approve-neo4j-node-1",
58
+ gate: "approve-neo4j-node-1",
59
59
  description: "Confirm the seed node is healthy before rolling to node 1",
60
60
  timeout: "24h",
61
61
  },
@@ -71,7 +71,7 @@ const FOLLOWERS: Neo4jInstanceConfig[] = [
71
71
  /** Build one instance's mini-composition phase — the unit that repeats N times for an N-node cluster. */
72
72
  function instancePhase(instance: Neo4jInstanceConfig): Phase {
73
73
  const steps = [
74
- ...(instance.approval ? [gate(instance.approval.signalName, instance.approval)] : []),
74
+ ...(instance.approval ? [gate(instance.approval.gate, instance.approval)] : []),
75
75
  { kind: "cfn-deploy", template: instance.template },
76
76
  { kind: "code-deploy", instance: instance.index, revision: "@Seed.templateUri" },
77
77
  { kind: "wait-cluster-healthy", ...instance.health },
@@ -287,6 +287,25 @@ describe("run() — failed turn: non-zero exit surfaces as status \"failed\", ne
287
287
  });
288
288
  });
289
289
 
290
+ describe("RunAgentTurn[\"status\"] is exactly the two states run() produces (#2072)", () => {
291
+ it("has no third member: a switch handling only \"completed\" and \"failed\" compiles with no default needed", () => {
292
+ // Compile-time exhaustiveness check, not a runtime assertion: if a third
293
+ // member were ever added back to the union (e.g. "interrupted"), this
294
+ // switch would stop being exhaustive and `tsc` would reject the missing
295
+ // case, since `identity`'s return type has no branch left to satisfy it.
296
+ const identity = (status: RunAgentOutput["turn"]["status"]): "completed" | "failed" => {
297
+ switch (status) {
298
+ case "completed":
299
+ return "completed";
300
+ case "failed":
301
+ return "failed";
302
+ }
303
+ };
304
+ expect(identity("completed")).toBe("completed");
305
+ expect(identity("failed")).toBe("failed");
306
+ });
307
+ });
308
+
290
309
  describe("collectArtifacts — only a \"not found\" read means \"no artifact\"; a genuine infra failure propagates (#1942 review finding 2)", () => {
291
310
  it("branch A: readFile rejecting with the sprite-fs \"not found\" shape resolves to empty artifacts.files, not a run() rejection", async () => {
292
311
  const { sprites } = makeFakeSprites(succeed); // never writes /work/output — readFile hits the fake's "not found" branch
@@ -149,12 +149,6 @@
149
149
  * snapshot folded into a `BuildArchiveManifest` entry (./build-archive.ts)
150
150
  * remains an open question, as does `artifacts.diff`, which `run()` never
151
151
  * populates (the real Sprites API has no built-in diff endpoint).
152
- * - Interrupted turns — `turn.status: "interrupted"` stays in the type but
153
- * is unreachable from this implementation: any `sprites.exec` rejection
154
- * (including one caused by an aborted signal) propagates as a genuine
155
- * `run()` failure rather than being classified as an interrupted turn.
156
- * Distinguishing "deadline hit mid-run" from "the sprite backend errored"
157
- * well enough to surface `"interrupted"` safely is left for #1944.
158
152
  *
159
153
  * **#1943 (this revision) resolved the transcript-hash basis and
160
154
  * sign/verify-gate interop, closing #1941's open "transcript hash basis"
@@ -285,7 +279,7 @@ export interface RunAgentInput {
285
279
 
286
280
  /** Mirrors fountain's `Turn` shape (`status`/`exit_code`/`started_at`/`ended_at`), even though the turn itself runs on a chant-owned sprite rather than a fountain-managed `Sandbox`. */
287
281
  export interface RunAgentTurn {
288
- status: "completed" | "failed" | "interrupted";
282
+ status: "completed" | "failed";
289
283
  exitCode: number | null;
290
284
  startedAt: string;
291
285
  endedAt: string | null;
@@ -1,5 +1,22 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ /**
4
+ * The message {@link detectLexicons} throws when a scan of the project's
5
+ * source files turns up no `@intentius/chant-lexicon-*` import at all.
6
+ *
7
+ * Named (chant #2222) because one caller has to tell this failure apart from
8
+ * every other one: `chant lint` reports a lexicon it cannot resolve as an
9
+ * error diagnostic, but "this directory declares no lexicon and imports none"
10
+ * is not that. It is a project that lints under the core rules alone, and it
11
+ * has always been allowed to. See {@link isNoLexiconDetected}.
12
+ */
13
+ export const NO_LEXICON_DETECTED_MESSAGE = "No lexicon detected in infrastructure files";
14
+
15
+ /** Whether `error` is the {@link NO_LEXICON_DETECTED_MESSAGE} failure. */
16
+ export function isNoLexiconDetected(error: unknown): boolean {
17
+ return error instanceof Error && error.message === NO_LEXICON_DETECTED_MESSAGE;
18
+ }
19
+
3
20
  /**
4
21
  * Detects which lexicons are being used by analyzing import statements
5
22
  * in the provided infrastructure files. Matches any `@intentius/chant-lexicon-*` package.
@@ -32,7 +49,7 @@ export async function detectLexicons(files: string[]): Promise<string[]> {
32
49
 
33
50
  // Validate results
34
51
  if (detectedLexicons.size === 0) {
35
- throw new Error("No lexicon detected in infrastructure files");
52
+ throw new Error(NO_LEXICON_DETECTED_MESSAGE);
36
53
  }
37
54
 
38
55
  return Array.from(detectedLexicons);
@@ -1276,7 +1276,7 @@ describe("tryFoldFile — registered authoring helpers (#1082)", () => {
1276
1276
  stack: "web",
1277
1277
  inputs: { pVpcId: { stackOutput: { stack: "shared-foundation", name: "oVpcId" } } },
1278
1278
  },
1279
- { kind: "gate", signalName: "approve", timeout: "24h" },
1279
+ { kind: "gate", gate: "approve", timeout: "24h" },
1280
1280
  ],
1281
1281
  },
1282
1282
  ],
@@ -123,7 +123,7 @@ export const FOLDABLE_AUTHORING_HELPERS: readonly FoldableHelperDef[] = [
123
123
  {
124
124
  name: "gate",
125
125
  module: "components/component.ts, op/builders.ts",
126
- note: "Returns a plain `{ kind: 'gate', signalName, ... }` object literal built from its arguments.",
126
+ note: "Returns a plain `{ kind: 'gate', gate, ... }` object literal built from its arguments.",
127
127
  },
128
128
  {
129
129
  name: "activity",