@intentius/chant 0.78.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/dist/audit/core.d.ts.map +1 -1
  2. package/dist/audit/discover.d.ts +38 -0
  3. package/dist/audit/discover.d.ts.map +1 -1
  4. package/dist/audit/terraform-state.d.ts +14 -0
  5. package/dist/audit/terraform-state.d.ts.map +1 -1
  6. package/dist/cli/command-group.d.ts +6 -0
  7. package/dist/cli/command-group.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +27 -2
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  11. package/dist/cli/commands/doctor.d.ts.map +1 -1
  12. package/dist/cli/commands/import-agents.d.ts.map +1 -1
  13. package/dist/cli/commands/init.d.ts +6 -0
  14. package/dist/cli/commands/init.d.ts.map +1 -1
  15. package/dist/cli/commands/lint.d.ts.map +1 -1
  16. package/dist/cli/commands/onboard.d.ts.map +1 -1
  17. package/dist/cli/commands/update.d.ts.map +1 -1
  18. package/dist/cli/conflict-check.d.ts +6 -0
  19. package/dist/cli/conflict-check.d.ts.map +1 -1
  20. package/dist/cli/handlers/dev.d.ts.map +1 -1
  21. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  22. package/dist/cli/handlers/misc.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts +17 -0
  24. package/dist/cli/handlers/operator.d.ts.map +1 -1
  25. package/dist/cli/handlers/promote.d.ts +17 -0
  26. package/dist/cli/handlers/promote.d.ts.map +1 -0
  27. package/dist/cli/handlers/run-generate.d.ts +39 -0
  28. package/dist/cli/handlers/run-generate.d.ts.map +1 -0
  29. package/dist/cli/handlers/run.d.ts.map +1 -1
  30. package/dist/cli/main.d.ts.map +1 -1
  31. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  32. package/dist/cli/plugins.d.ts +9 -0
  33. package/dist/cli/plugins.d.ts.map +1 -1
  34. package/dist/cli/registry.d.ts +18 -1
  35. package/dist/cli/registry.d.ts.map +1 -1
  36. package/dist/components/capability-plugin-loader.d.ts.map +1 -1
  37. package/dist/components/cli-support.d.ts.map +1 -1
  38. package/dist/components/discover.d.ts.map +1 -1
  39. package/dist/components/driver.d.ts.map +1 -1
  40. package/dist/components/fan-out-support.d.ts.map +1 -1
  41. package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
  42. package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
  43. package/dist/components/presets/ecs-fargate.d.ts +10 -4
  44. package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
  45. package/dist/components/promote.d.ts +211 -0
  46. package/dist/components/promote.d.ts.map +1 -0
  47. package/dist/components/unbound-gate-approval.d.ts +24 -0
  48. package/dist/components/unbound-gate-approval.d.ts.map +1 -0
  49. package/dist/config.d.ts +82 -5
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/discovery/convergence.d.ts +97 -0
  52. package/dist/discovery/convergence.d.ts.map +1 -0
  53. package/dist/discovery/files.d.ts +41 -2
  54. package/dist/discovery/files.d.ts.map +1 -1
  55. package/dist/discovery/fold-import.d.ts +15 -0
  56. package/dist/discovery/fold-import.d.ts.map +1 -1
  57. package/dist/graph-ir.d.ts +13 -0
  58. package/dist/graph-ir.d.ts.map +1 -1
  59. package/dist/lexicon-module.d.ts +65 -0
  60. package/dist/lexicon-module.d.ts.map +1 -0
  61. package/dist/lexicon.d.ts +24 -0
  62. package/dist/lexicon.d.ts.map +1 -1
  63. package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
  64. package/dist/lifecycle/gate-ledger.d.ts +22 -0
  65. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  66. package/dist/lifecycle/legacy-digest.d.ts +26 -0
  67. package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
  68. package/dist/lifecycle/release-ledger.d.ts +25 -0
  69. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  70. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
  71. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
  72. package/dist/lint/rules/op/index.d.ts +2 -1
  73. package/dist/lint/rules/op/index.d.ts.map +1 -1
  74. package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
  75. package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
  76. package/dist/op/activity-contract-registry.d.ts.map +1 -1
  77. package/dist/op/activity-registry.d.ts.map +1 -1
  78. package/dist/op/builders.d.ts +4 -0
  79. package/dist/op/builders.d.ts.map +1 -1
  80. package/dist/op/discover.d.ts.map +1 -1
  81. package/dist/op/gate-approval.d.ts +142 -0
  82. package/dist/op/gate-approval.d.ts.map +1 -0
  83. package/dist/op/gate.d.ts +54 -0
  84. package/dist/op/gate.d.ts.map +1 -1
  85. package/dist/op/generate-pipeline.d.ts.map +1 -1
  86. package/dist/op/index.d.ts +4 -1
  87. package/dist/op/index.d.ts.map +1 -1
  88. package/dist/op/local-executor.d.ts +4 -0
  89. package/dist/op/local-executor.d.ts.map +1 -1
  90. package/dist/op/op-ir.d.ts +2 -0
  91. package/dist/op/op-ir.d.ts.map +1 -1
  92. package/dist/op/runtimes/local.d.ts.map +1 -1
  93. package/dist/op/types.d.ts +7 -0
  94. package/dist/op/types.d.ts.map +1 -1
  95. package/dist/workspace/record-source.d.ts +22 -0
  96. package/dist/workspace/record-source.d.ts.map +1 -0
  97. package/dist/workspace/records-cli.d.ts +57 -0
  98. package/dist/workspace/records-cli.d.ts.map +1 -0
  99. package/dist/workspace/records.d.ts +118 -0
  100. package/dist/workspace/records.d.ts.map +1 -0
  101. package/package.json +3 -3
  102. package/src/audit/core.ts +13 -2
  103. package/src/audit/discover.ts +90 -12
  104. package/src/audit/terraform-state.test.ts +86 -1
  105. package/src/audit/terraform-state.ts +26 -0
  106. package/src/cli/command-group.ts +7 -0
  107. package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
  108. package/src/cli/commands/audit.ts +93 -19
  109. package/src/cli/commands/check-lexicon-examples.ts +2 -1
  110. package/src/cli/commands/doctor.ts +3 -0
  111. package/src/cli/commands/import-agents.ts +11 -4
  112. package/src/cli/commands/init.ts +57 -25
  113. package/src/cli/commands/lint.test.ts +58 -1
  114. package/src/cli/commands/lint.ts +48 -18
  115. package/src/cli/commands/onboard.ts +15 -0
  116. package/src/cli/commands/update.ts +5 -1
  117. package/src/cli/conflict-check.test.ts +18 -1
  118. package/src/cli/conflict-check.ts +13 -0
  119. package/src/cli/discovery-skip.test.ts +168 -0
  120. package/src/cli/handlers/build.test.ts +11 -0
  121. package/src/cli/handlers/build.ts +1 -1
  122. package/src/cli/handlers/dev.ts +3 -0
  123. package/src/cli/handlers/graph.test.ts +20 -0
  124. package/src/cli/handlers/graph.ts +4 -2
  125. package/src/cli/handlers/lifecycle.ts +2 -1
  126. package/src/cli/handlers/misc.ts +16 -0
  127. package/src/cli/handlers/operator.test.ts +85 -0
  128. package/src/cli/handlers/operator.ts +118 -2
  129. package/src/cli/handlers/promote.test.ts +261 -0
  130. package/src/cli/handlers/promote.ts +325 -0
  131. package/src/cli/handlers/run-generate.test.ts +160 -0
  132. package/src/cli/handlers/run-generate.ts +133 -0
  133. package/src/cli/handlers/run.ts +13 -1
  134. package/src/cli/main.test.ts +33 -1
  135. package/src/cli/main.ts +78 -16
  136. package/src/cli/mcp/op-tools.ts +2 -1
  137. package/src/cli/path-lexicon-messages.test.ts +152 -0
  138. package/src/cli/plugins.ts +44 -6
  139. package/src/cli/registry.ts +18 -1
  140. package/src/components/__fixtures__/alb-ecs-service.json +0 -8
  141. package/src/components/capability-plugin-loader.ts +3 -1
  142. package/src/components/cli-support.ts +3 -2
  143. package/src/components/discover.ts +26 -2
  144. package/src/components/driver.test.ts +12 -11
  145. package/src/components/driver.ts +2 -0
  146. package/src/components/fan-out-support.ts +2 -1
  147. package/src/components/pilots/README.md +1 -1
  148. package/src/components/pilots/alb-ecs.pilot.ts +7 -7
  149. package/src/components/presets/ecs-fargate.ts +10 -5
  150. package/src/components/promote.test.ts +371 -0
  151. package/src/components/promote.ts +537 -0
  152. package/src/components/unbound-gate-approval.test.ts +82 -0
  153. package/src/components/unbound-gate-approval.ts +51 -0
  154. package/src/config.test.ts +40 -0
  155. package/src/config.ts +128 -11
  156. package/src/discovery/convergence.test.ts +231 -0
  157. package/src/discovery/convergence.ts +399 -0
  158. package/src/discovery/files.test.ts +127 -1
  159. package/src/discovery/files.ts +97 -9
  160. package/src/discovery/fold-import.ts +83 -9
  161. package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
  162. package/src/graph-ir.ts +14 -0
  163. package/src/lexicon-module.test.ts +203 -0
  164. package/src/lexicon-module.ts +117 -0
  165. package/src/lexicon.ts +26 -0
  166. package/src/lifecycle/build-ledger-store.ts +11 -1
  167. package/src/lifecycle/gate-ledger.ts +22 -0
  168. package/src/lifecycle/legacy-digest.test.ts +114 -0
  169. package/src/lifecycle/legacy-digest.ts +62 -0
  170. package/src/lifecycle/release-ledger.ts +34 -0
  171. package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
  172. package/src/lint/rules/op/index.ts +4 -2
  173. package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
  174. package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
  175. package/src/op/activity-contract-registry.ts +18 -4
  176. package/src/op/activity-registry.ts +26 -0
  177. package/src/op/builders.ts +10 -1
  178. package/src/op/discover-convergence.test.ts +70 -0
  179. package/src/op/discover.ts +4 -0
  180. package/src/op/gate-approval.test.ts +258 -0
  181. package/src/op/gate-approval.ts +260 -0
  182. package/src/op/gate.ts +148 -10
  183. package/src/op/generate-pipeline.ts +2 -1
  184. package/src/op/index.ts +9 -1
  185. package/src/op/local-executor.test.ts +66 -0
  186. package/src/op/local-executor.ts +44 -1
  187. package/src/op/op-ir.ts +4 -0
  188. package/src/op/runtimes/local.ts +2 -1
  189. package/src/op/types.ts +7 -0
  190. package/src/workspace/record-source.ts +117 -0
  191. package/src/workspace/records-cli.ts +126 -0
  192. package/src/workspace/records-contract.test.ts +88 -0
  193. package/src/workspace/records.schema.json +120 -0
  194. package/src/workspace/records.test.ts +243 -0
  195. package/src/workspace/records.ts +383 -0
@@ -24,9 +24,8 @@
24
24
  * component.schema.json), a component may attach a `noRollback: "<reason>"`
25
25
  * string property directly on the step to declare the compensation gap is
26
26
  * intentional and understood, or supply its own explicit compensation via a
27
- * component-level `rollback` field (as the ALB/ECS pilot does with
28
- * `rollback-previous`) or a sibling `rollback-previous`/`snapshot-before` step
29
- * in the same phase.
27
+ * component-level `rollback` field or a sibling
28
+ * `rollback-previous`/`snapshot-before` step in the same phase.
30
29
  *
31
30
  * Triggers on: a bare `{ kind: "run-migration", ... }` step with no
32
31
  * `noRollback` reason, no component-level `rollback`, and no sibling
@@ -12,12 +12,14 @@ import type { PostSynthCheck } from "../../post-synth";
12
12
  import { ops012 } from "./ops012-activity-contract";
13
13
  import { ops013 } from "./ops013-step-output-ref";
14
14
  import { ops014 } from "./ops014-converge-rule-refusals";
15
+ import { ops015 } from "./ops015-gate-approval";
15
16
 
16
17
  export { ops012 } from "./ops012-activity-contract";
17
18
  export { ops013 } from "./ops013-step-output-ref";
18
19
  export { ops014 } from "./ops014-converge-rule-refusals";
20
+ export { ops015 } from "./ops015-gate-approval";
19
21
 
20
- /** Core's own post-synth checks over the Op model — OPS012, OPS013, OPS014. */
22
+ /** Core's own post-synth checks over the Op model — OPS012, OPS013, OPS014, OPS015. */
21
23
  export function coreOpChecks(): PostSynthCheck[] {
22
- return [ops012, ops013, ops014];
24
+ return [ops012, ops013, ops014, ops015];
23
25
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * OPS015 (#2508) — a gate's approval block, checked over the build output.
3
+ */
4
+
5
+ import { describe, test, expect } from "vitest";
6
+ import type { PostSynthContext } from "../../post-synth";
7
+ import { DECLARABLE_MARKER } from "../../../declarable";
8
+ import { gatePolicyVersion } from "../../../op/gate-approval";
9
+ import { ops015 } from "./ops015-gate-approval";
10
+
11
+ const TEXT = "permit (principal, action, resource);\n";
12
+ const POLICY = { kind: "gate-policy", lexicon: "cedar", name: "ship", version: gatePolicyVersion(TEXT), text: TEXT };
13
+
14
+ function ctxWith(phases: unknown[], onFailure?: unknown[]): PostSynthContext {
15
+ const entities = new Map<string, unknown>([["release", {
16
+ [DECLARABLE_MARKER]: true,
17
+ entityType: "Chant::Op",
18
+ lexicon: "chant",
19
+ kind: "resource",
20
+ props: { name: "release", overview: "test", phases, ...(onFailure ? { onFailure } : {}) },
21
+ attributes: {},
22
+ }]]);
23
+ return {
24
+ outputs: new Map([["chant", ""]]),
25
+ entities: entities as Map<string, never>,
26
+ buildResult: { outputs: new Map([["chant", ""]]), entities: entities as Map<string, never>, warnings: [], errors: [], sourceFileCount: 1 },
27
+ };
28
+ }
29
+
30
+ const gateStep = (approval: unknown) => ({ kind: "gate", gate: "ship", approval });
31
+
32
+ describe("OPS015", () => {
33
+ test("a well-formed approval block passes", () => {
34
+ const ctx = ctxWith([{ name: "Apply", steps: [gateStep({ quorum: { count: 2 }, policy: POLICY, mode: "enforce" })] }]);
35
+ expect(ops015.check(ctx)).toEqual([]);
36
+ });
37
+
38
+ test("a gate with no approval block is not this rule's concern", () => {
39
+ expect(ops015.check(ctxWith([{ name: "Apply", steps: [{ kind: "gate", gate: "ship" }] }]))).toEqual([]);
40
+ });
41
+
42
+ test("a policy that is not a gate policy set is refused, naming the op and gate", () => {
43
+ const ctx = ctxWith([{ name: "Apply", steps: [gateStep({ policy: "dist/ship.cedar" })] }]);
44
+ const [diag] = ops015.check(ctx);
45
+ expect(diag?.checkId).toBe("OPS015");
46
+ expect(diag?.severity).toBe("error");
47
+ expect(diag?.message).toContain('Op "release", gate "ship"');
48
+ expect(diag?.message).toContain("does not resolve to a gate policy set");
49
+ });
50
+
51
+ test("a policy whose text was edited after it was stamped is refused", () => {
52
+ const ctx = ctxWith([{ name: "Apply", steps: [gateStep({ policy: { ...POLICY, text: "forbid (principal, action, resource);\n" } })] }]);
53
+ expect(ops015.check(ctx)[0]?.message).toContain("not the digest of its text");
54
+ });
55
+
56
+ test("reports every problem, including a gate nested in an effect step and one in onFailure", () => {
57
+ const ctx = ctxWith(
58
+ [{ name: "Apply", steps: [{ kind: "effect", receipt: {}, steps: [gateStep({ quorum: { count: 0 }, mode: "enforce" })] }] }],
59
+ [{ name: "Rollback", steps: [gateStep({ mode: "sometimes" })] }],
60
+ );
61
+ const messages = ops015.check(ctx).map((d) => d.message);
62
+ expect(messages).toHaveLength(3);
63
+ expect(messages.join("\n")).toContain("quorum.count");
64
+ expect(messages.join("\n")).toContain("needs an `approval.policy`");
65
+ expect(messages.join("\n")).toContain("approval.mode");
66
+ });
67
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * OPS015: a gate's `approval` block (#2508) must be well formed. The quorum
3
+ * count is an integer of at least 1, roles are named, the mode is `log-only`
4
+ * or `enforce`, `enforce` has a policy to enforce, and `policy` resolves to a
5
+ * gate policy set a lexicon rendered, with a version that is still the digest
6
+ * of its text.
7
+ *
8
+ * `gate()` already throws on the first of these. This check runs over the
9
+ * build output, so it also covers a gate step written without the builder and
10
+ * reports every problem rather than the first.
11
+ */
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
13
+ import type { OpConfig, PhaseDefinition, StepDefinition } from "../../../op/types";
14
+ import { gateApprovalProblems } from "../../../op/gate-approval";
15
+ import { gateName } from "../../../op/gate-name";
16
+ import { isOpEntity } from "./support";
17
+
18
+ function* gateSteps(phases: PhaseDefinition[] | undefined): Generator<StepDefinition & { kind: "gate" }> {
19
+ for (const phase of phases ?? []) {
20
+ for (const step of phase.steps ?? []) {
21
+ if (step.kind === "gate") yield step;
22
+ else if (step.kind === "effect") {
23
+ for (const nested of step.steps ?? []) if (nested.kind === "gate") yield nested;
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ export const ops015: PostSynthCheck = {
30
+ id: "OPS015",
31
+ description:
32
+ "A gate's approval block is well formed: quorum count at least 1, roles named, mode log-only or enforce, enforce has a policy, and policy resolves to a gate policy set whose version is the digest of its text",
33
+
34
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
35
+ const diagnostics: PostSynthDiagnostic[] = [];
36
+ for (const [entityKey, entity] of ctx.entities) {
37
+ if (!isOpEntity(entity)) continue;
38
+ const rec = entity as unknown as Record<string, unknown>;
39
+ const props = ((entity as { props?: Record<string, unknown> }).props ?? {}) as unknown as OpConfig;
40
+ if (!Array.isArray(props.phases)) continue;
41
+
42
+ for (const step of [...gateSteps(props.phases), ...gateSteps(props.onFailure)]) {
43
+ if (step.approval === undefined) continue;
44
+ for (const problem of gateApprovalProblems(step.approval)) {
45
+ diagnostics.push({
46
+ checkId: "OPS015",
47
+ severity: "error",
48
+ message: `Op "${props.name}", gate "${gateName(step)}": ${problem}`,
49
+ entity: entityKey,
50
+ lexicon: typeof rec.lexicon === "string" ? rec.lexicon : undefined,
51
+ });
52
+ }
53
+ }
54
+ }
55
+ return diagnostics;
56
+ },
57
+ };
@@ -36,6 +36,7 @@
36
36
 
37
37
  import * as coreContracts from "./activities/activity-contracts";
38
38
  import { collectActivityContracts, type ActivityContract } from "./activity-contract";
39
+ import { importLexiconModule } from "../lexicon-module";
39
40
 
40
41
  /**
41
42
  * The shape {@link loadActivityContracts} reads off a plugin: an optional
@@ -74,16 +75,29 @@ export async function loadActivityContracts(
74
75
 
75
76
  for (const entry of lexicons) {
76
77
  const name = typeof entry === "string" ? entry : entry.name;
78
+ let contributor: LexiconActivityContractContributor | undefined = typeof entry === "string" ? undefined : entry;
77
79
  try {
78
- const spec = `@intentius/chant-lexicon-${name}/op/activity-contracts`;
79
- collectActivityContracts((await import(spec)) as Record<string, unknown>, contracts);
80
+ // chant #2520 — a lexicon declared by module path has no subpath to
81
+ // import. Its plugin's `activityContracts()` member stands in for it;
82
+ // given only the name, the plugin is read from the declared module.
83
+ const local = await importLexiconModule(name);
84
+ if (local !== undefined) {
85
+ contributor ??= Object.values(local).find(
86
+ (v): v is LexiconActivityContractContributor =>
87
+ typeof v === "object" && v !== null && (v as { name?: unknown }).name === name &&
88
+ typeof (v as { activityContracts?: unknown }).activityContracts === "function",
89
+ );
90
+ } else {
91
+ const spec = `@intentius/chant-lexicon-${name}/op/activity-contracts`;
92
+ collectActivityContracts((await import(spec)) as Record<string, unknown>, contracts);
93
+ }
80
94
  } catch {
81
95
  // Lexicon absent or declares no contracts at the conventional subpath.
82
96
  }
83
97
 
84
- if (typeof entry !== "string" && typeof entry.activityContracts === "function") {
98
+ if (contributor !== undefined && typeof contributor.activityContracts === "function") {
85
99
  try {
86
- for (const contract of entry.activityContracts()) contracts.set(contract.name, contract);
100
+ for (const contract of contributor.activityContracts()) contracts.set(contract.name, contract);
87
101
  } catch {
88
102
  // A plugin member that throws contributes nothing, the same as one that is absent.
89
103
  }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import * as baseActivities from "./activities";
14
14
  import { ACTIVITY_PROFILES, type ActivityProfile } from "./activity-profiles";
15
+ import { importLexiconModule } from "../lexicon-module";
15
16
 
16
17
  export type { ActivityProfile } from "./activity-profiles";
17
18
 
@@ -52,6 +53,16 @@ export async function loadActivities(lexicons: string[] = []): Promise<Map<strin
52
53
 
53
54
  for (const name of lexicons) {
54
55
  try {
56
+ // chant #2520 — a lexicon declared by module path has no subpath to
57
+ // import; its plugin's `activities()` member stands in for it.
58
+ const local = await importLexiconModule(name);
59
+ if (local !== undefined) {
60
+ const plugin = Object.values(local).find((v) => isActivityContributor(v) && v.name === name) as
61
+ | ActivityContributor
62
+ | undefined;
63
+ if (plugin) collectActivities(await plugin.activities(), activities);
64
+ continue;
65
+ }
55
66
  const spec = `@intentius/chant-lexicon-${name}/op/activities`;
56
67
  collectActivities((await import(spec)) as Record<string, unknown>, activities);
57
68
  } catch {
@@ -62,6 +73,21 @@ export async function loadActivities(lexicons: string[] = []): Promise<Map<strin
62
73
  return activities;
63
74
  }
64
75
 
76
+ /** A plugin-shaped export with an `activities()` member (`LexiconPlugin.activities`). Structural, so this layer does not import `../lexicon.ts`. */
77
+ interface ActivityContributor {
78
+ name: string;
79
+ activities(): Record<string, unknown> | Promise<Record<string, unknown>>;
80
+ }
81
+
82
+ function isActivityContributor(value: unknown): value is ActivityContributor {
83
+ return (
84
+ typeof value === "object" &&
85
+ value !== null &&
86
+ typeof (value as { name?: unknown }).name === "string" &&
87
+ typeof (value as { activities?: unknown }).activities === "function"
88
+ );
89
+ }
90
+
65
91
  /**
66
92
  * The built-in profile table. Kept async because it is awaited on the CLI's
67
93
  * run path alongside {@link loadActivities}, and because a hosted runtime may
@@ -2,6 +2,7 @@ import { OpResource } from "./resource";
2
2
  import type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep } from "./types";
3
3
  import { isEffectReceipt, type EffectReceiptDeclaration } from "../effect-receipt";
4
4
  import { receiptCheckInput } from "./receipt-store";
5
+ import { gateApprovalProblems } from "./gate-approval";
5
6
  import { makeOutProxy, type StepOutputRef, type WithStepRefs } from "./step-output-ref";
6
7
  import type { ChantBuildArgs } from "./activities/build";
7
8
  import type { ShellCmdArgs } from "./activities/shell";
@@ -112,17 +113,25 @@ export function activity(
112
113
  * Plan phase's own digest, `plan.out.planDigest`. Then a resolution counts
113
114
  * only for that plan, and a run whose fresh plan differs is refused by name
114
115
  * instead of applying a change nobody approved. See {@link GateStep.plan}.
116
+ *
117
+ * Pass `approval` to require a quorum of human approvers, or to evaluate a
118
+ * policy for each approval (#2508). See {@link GateStep.approval}.
115
119
  */
116
120
  export function gate(
117
121
  name: string,
118
- opts?: { timeout?: string; description?: string; plan?: GateStep["plan"] },
122
+ opts?: { timeout?: string; description?: string; plan?: GateStep["plan"]; approval?: GateStep["approval"] },
119
123
  ): GateStep {
124
+ if (opts?.approval !== undefined) {
125
+ const problems = gateApprovalProblems(opts.approval);
126
+ if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
127
+ }
120
128
  return {
121
129
  kind: "gate",
122
130
  gate: name,
123
131
  ...(opts?.timeout ? { timeout: opts.timeout } : {}),
124
132
  ...(opts?.description ? { description: opts.description } : {}),
125
133
  ...(opts?.plan !== undefined ? { plan: opts.plan } : {}),
134
+ ...(opts?.approval !== undefined ? { approval: opts.approval } : {}),
126
135
  };
127
136
  }
128
137
 
@@ -0,0 +1,70 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { discoverOps } from "./discover";
6
+ import { resetDiscoveryWarnings } from "../discovery/convergence";
7
+
8
+ /**
9
+ * chant#2527's warning release for Op discovery. The upward search for the
10
+ * Op root stays as it is; what changes next release is the downward walk,
11
+ * which stops at child projects. Today an Op in a child project is
12
+ * discovered, and it still is here, with a warning naming it.
13
+ */
14
+ let fakeGitRoot = "";
15
+ vi.mock("../runtime-adapter", async (importOriginal) => ({
16
+ ...(await importOriginal<typeof import("../runtime-adapter")>()),
17
+ getRuntime: () => ({
18
+ spawn: async (cmd: string[]) =>
19
+ cmd[0] === "git" && cmd[1] === "rev-parse"
20
+ ? { stdout: fakeGitRoot, stderr: "", exitCode: 0 }
21
+ : { stdout: "", stderr: "", exitCode: 0 },
22
+ }),
23
+ }));
24
+
25
+ const OP_FILE = (name: string): string =>
26
+ `export default { props: { name: ${JSON.stringify(name)}, overview: "t", phases: [{ name: "Run", steps: [] }] } };\n`;
27
+
28
+ describe("discoverOps — an Op in a child project (#2527 warning release)", () => {
29
+ let root: string;
30
+ let stderr: string[];
31
+
32
+ beforeEach(() => {
33
+ root = realpathSync(mkdtempSync(join(tmpdir(), "chant-2527-ops-")));
34
+ fakeGitRoot = root;
35
+ resetDiscoveryWarnings();
36
+ stderr = [];
37
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => void stderr.push(args.join(" ")));
38
+ vi.spyOn(process, "cwd").mockReturnValue(root);
39
+ writeFileSync(join(root, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
40
+ mkdirSync(join(root, "ops"), { recursive: true });
41
+ writeFileSync(join(root, "ops", "mine.op.ts"), OP_FILE("mine"));
42
+ mkdirSync(join(root, "child", "ops"), { recursive: true });
43
+ writeFileSync(join(root, "child", "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
44
+ writeFileSync(join(root, "child", "ops", "theirs.op.ts"), OP_FILE("theirs"));
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.restoreAllMocks();
49
+ rmSync(root, { recursive: true, force: true });
50
+ });
51
+
52
+ test("is still discovered, and the warning names it with the include glob", async () => {
53
+ const { ops, errors } = await discoverOps({ cwd: root });
54
+ expect(errors).toEqual([]);
55
+ expect([...ops.keys()].sort()).toEqual(["mine", "theirs"]);
56
+ expect(stderr).toEqual([
57
+ "warning: Op discovery under the current directory changes in the next release (chant#2527):\n" +
58
+ " child/ops/theirs.op.ts: child/ is a child project with its own chant.config. " +
59
+ "Discovery stops at child projects from the next release. " +
60
+ 'To keep reading it after the change, add "child" to include in chant.config.json, which the next release honours.',
61
+ ]);
62
+ });
63
+
64
+ test("with the glob in the config, nothing is printed", async () => {
65
+ writeFileSync(join(root, "chant.config.json"), JSON.stringify({ lexicons: ["aws"], include: ["child"] }));
66
+ const { ops } = await discoverOps({ cwd: root });
67
+ expect([...ops.keys()].sort()).toEqual(["mine", "theirs"]);
68
+ expect(stderr).toEqual([]);
69
+ });
70
+ });
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import type { OpConfig } from "./types";
6
+ import { warnDiscoveryChanges } from "../discovery/convergence";
6
7
 
7
8
  export interface DiscoveredOp {
8
9
  config: OpConfig;
@@ -148,6 +149,9 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
148
149
 
149
150
  const root = await findDiscoveryRoot(opts?.cwd);
150
151
  const files = await collectOpFiles(root);
152
+ // #2527's warning release: Op discovery stops at child projects below the
153
+ // root next release, and skips git-ignored files. `files` is unchanged.
154
+ await warnDiscoveryChanges({ walker: "ops", root, files });
151
155
 
152
156
  const nameToFile = new Map<string, string>();
153
157
 
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Gate approval policy (#2508): quorum, roles, and a policy decision that is
3
+ * logged or enforced. The rules are decided by `evaluateGate`, driven here
4
+ * through `memoryGateLedgerPort`; the Cedar evaluation itself is the cedar
5
+ * lexicon's and is tested there.
6
+ */
7
+ import { describe, test, expect } from "vitest";
8
+ import { evaluateGate, memoryGateLedgerPort, tallyGateApprovals } from "./gate";
9
+ import { gate } from "./builders";
10
+ import { gateApprovalProblems, gatePolicyRequest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
11
+ import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
12
+
13
+ const PLAN_A = `sha256:${"a".repeat(64)}`;
14
+ const PLAN_B = `sha256:${"b".repeat(64)}`;
15
+ const TEXT = 'permit (principal is Chant::Agent, action, resource) when { context.risk == "low" };\n';
16
+ const POLICY: GatePolicyRef = { kind: "gate-policy", lexicon: "cedar", name: "ship", version: gatePolicyVersion(TEXT), text: TEXT };
17
+
18
+ const PENDING: PendingGateRecord = {
19
+ version: 1, kind: "pending", op: "release", gate: "ship",
20
+ timestamp: "2026-09-01T00:00:00.000Z", expiresAt: "2026-09-03T00:00:00.000Z", planDigest: PLAN_A,
21
+ };
22
+ const NOW = "2026-09-01T12:00:00.000Z";
23
+
24
+ function approval(overrides: Partial<GateResolutionRecord> & { resolvedBy: string; timestamp: string }): GateResolutionRecord {
25
+ return { version: 1, op: "release", gate: "ship", planDigest: PLAN_A, approver: { kind: "human" }, ...overrides };
26
+ }
27
+
28
+ function allow(mode: "log-only" | "enforce" = "enforce"): GateResolutionRecord["policyDecision"] {
29
+ return { policy: "ship", version: POLICY.version, mode, decision: "allow", determining: ["ship-0"], errors: [] };
30
+ }
31
+
32
+ async function decide(resolutions: GateResolutionRecord[], approvalBlock: ResolvedGateApproval, planDigest = PLAN_A) {
33
+ const port = memoryGateLedgerPort({ resolutions, pending: [{ ...PENDING, approval: approvalBlock, planDigest }] });
34
+ const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest, approval: approvalBlock, now: NOW });
35
+ return { check, port };
36
+ }
37
+
38
+ describe("gate approval — quorum (#2508)", () => {
39
+ const twoMaintainers: ResolvedGateApproval = { quorum: { count: 2, roles: ["maintainer"] }, mode: "log-only" };
40
+
41
+ test("quorum not met: one approval of two leaves the gate standing and reports progress", async () => {
42
+ const { check, port } = await decide(
43
+ [approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } })],
44
+ twoMaintainers,
45
+ );
46
+ expect(check.satisfied).toBe(false);
47
+ if (check.satisfied) return;
48
+ expect(check.quorum).toEqual({ approvers: ["alex"], need: 2 });
49
+ // The standing fact still describes this gate, so nothing new is appended.
50
+ expect(check.recorded).toBe(false);
51
+ expect(port.appended).toHaveLength(0);
52
+ });
53
+
54
+ test("quorum met: two distinct maintainers pass the gate, and both are named", async () => {
55
+ const { check } = await decide(
56
+ [
57
+ approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
58
+ approval({ resolvedBy: "sam", timestamp: "2026-09-01T02:00:00.000Z", approver: { kind: "human", roles: ["maintainer", "sre"] } }),
59
+ ],
60
+ twoMaintainers,
61
+ );
62
+ expect(check.satisfied).toBe(true);
63
+ if (!check.satisfied) return;
64
+ expect(check.via).toBe("quorum");
65
+ expect(check.approvals?.map((r) => r.resolvedBy)).toEqual(["alex", "sam"]);
66
+ expect(check.resolution.resolvedBy).toBe("sam");
67
+ });
68
+
69
+ test("the same person approving twice counts once", async () => {
70
+ const { check } = await decide(
71
+ [
72
+ approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
73
+ approval({ resolvedBy: "alex", timestamp: "2026-09-01T02:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
74
+ ],
75
+ twoMaintainers,
76
+ );
77
+ expect(check.satisfied).toBe(false);
78
+ });
79
+
80
+ test("an approver without one of the quorum's roles does not count", async () => {
81
+ const { check } = await decide(
82
+ [
83
+ approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
84
+ approval({ resolvedBy: "pat", timestamp: "2026-09-01T02:00:00.000Z", approver: { kind: "human", roles: ["viewer"] } }),
85
+ ],
86
+ twoMaintainers,
87
+ );
88
+ expect(check.satisfied).toBe(false);
89
+ if (check.satisfied) return;
90
+ expect(check.quorum?.approvers).toEqual(["alex"]);
91
+ });
92
+
93
+ test("a changed plan invalidates every collected approval", async () => {
94
+ const { check, port } = await decide(
95
+ [
96
+ approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
97
+ approval({ resolvedBy: "sam", timestamp: "2026-09-01T02:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
98
+ ],
99
+ twoMaintainers,
100
+ PLAN_B,
101
+ );
102
+ expect(check.satisfied).toBe(false);
103
+ if (check.satisfied) return;
104
+ expect(check.mismatch?.approved).toBe(PLAN_A);
105
+ expect(check.mismatch?.planned).toBe(PLAN_B);
106
+ expect(check.quorum).toEqual({ approvers: [], need: 2 });
107
+ expect(port.appended).toHaveLength(0);
108
+ });
109
+
110
+ test("an approval older than the standing pending fact does not count", async () => {
111
+ const { check } = await decide(
112
+ [approval({ resolvedBy: "alex", timestamp: "2026-08-31T00:00:00.000Z" })],
113
+ { quorum: { count: 1 }, mode: "log-only" },
114
+ );
115
+ expect(check.satisfied).toBe(false);
116
+ });
117
+
118
+ test("a resolution written before #2508 reads as a human, and one from a model channel as an agent", () => {
119
+ const legacy: GateResolutionRecord = { version: 1, op: "release", gate: "ship", resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", planDigest: PLAN_A };
120
+ const fromMcp: GateResolutionRecord = { ...legacy, resolvedBy: "unattested", origin: "mcp", timestamp: "2026-09-01T02:00:00.000Z" };
121
+ const tally = tallyGateApprovals([legacy, fromMcp], "ship", PENDING.timestamp, PLAN_A, { quorum: { count: 2 }, mode: "log-only" });
122
+ expect(tally.counted.map((r) => r.resolvedBy)).toEqual(["alex"]);
123
+ });
124
+ });
125
+
126
+ describe("gate approval — policy decisions (#2508)", () => {
127
+ test("log-only: an agent's permit is recorded but does not pass the gate", async () => {
128
+ const block: ResolvedGateApproval = { policy: POLICY, mode: "log-only", context: { risk: "low" } };
129
+ const { check } = await decide(
130
+ [approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow("log-only") })],
131
+ block,
132
+ );
133
+ expect(check.satisfied).toBe(false);
134
+ if (check.satisfied) return;
135
+ expect(check.quorum).toEqual({ approvers: [], need: 1 });
136
+ });
137
+
138
+ test("log-only: a human's approval passes even when the policy recorded a deny", async () => {
139
+ const block: ResolvedGateApproval = { policy: POLICY, mode: "log-only" };
140
+ const { check } = await decide(
141
+ [approval({
142
+ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z",
143
+ policyDecision: { ...allow("log-only")!, decision: "deny", determining: [] },
144
+ })],
145
+ block,
146
+ );
147
+ expect(check.satisfied).toBe(true);
148
+ if (!check.satisfied) return;
149
+ expect(check.via).toBe("quorum");
150
+ expect(check.resolution.policyDecision?.decision).toBe("deny");
151
+ });
152
+
153
+ test("enforce: an agent's permit passes the gate on its own", async () => {
154
+ const block: ResolvedGateApproval = { quorum: { count: 2 }, policy: POLICY, mode: "enforce" };
155
+ const { check } = await decide(
156
+ [approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow() })],
157
+ block,
158
+ );
159
+ expect(check.satisfied).toBe(true);
160
+ if (!check.satisfied) return;
161
+ expect(check.via).toBe("policy");
162
+ expect(check.resolution.resolvedBy).toBe("release-bot");
163
+ });
164
+
165
+ test("enforce: a permit recorded under an older policy version does not count", async () => {
166
+ const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
167
+ const { check } = await decide(
168
+ [approval({
169
+ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" },
170
+ policyDecision: { ...allow()!, version: gatePolicyVersion("permit (principal, action, resource);\n") },
171
+ })],
172
+ block,
173
+ );
174
+ expect(check.satisfied).toBe(false);
175
+ });
176
+
177
+ test("enforce: a deny-by-default policy keeps the old behaviour, where only a human's approval counts", async () => {
178
+ const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
179
+ const deny = { ...allow()!, decision: "deny" as const, determining: ["floor"] };
180
+ const agentOnly = await decide(
181
+ [approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: deny })],
182
+ block,
183
+ );
184
+ expect(agentOnly.check.satisfied).toBe(false);
185
+
186
+ const withHuman = await decide(
187
+ [
188
+ approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: deny }),
189
+ approval({ resolvedBy: "alex", timestamp: "2026-09-01T02:00:00.000Z", policyDecision: deny }),
190
+ ],
191
+ block,
192
+ );
193
+ expect(withHuman.check.satisfied).toBe(true);
194
+ if (!withHuman.check.satisfied) return;
195
+ expect(withHuman.check.via).toBe("quorum");
196
+ });
197
+
198
+ test("a changed approval block re-records the pending fact, so approve evaluates the current context", async () => {
199
+ const port = memoryGateLedgerPort({
200
+ pending: [{ ...PENDING, approval: { policy: POLICY, mode: "log-only", context: { risk: "high" } } }],
201
+ });
202
+ const check = await evaluateGate(port, {
203
+ op: "release", gate: "ship", planDigest: PLAN_A, now: NOW,
204
+ approval: { policy: POLICY, mode: "log-only", context: { risk: "low" } },
205
+ });
206
+ expect(check.satisfied).toBe(false);
207
+ if (check.satisfied) return;
208
+ expect(check.recorded).toBe(true);
209
+ expect(port.appended[0]?.approval?.context).toEqual({ risk: "low" });
210
+ });
211
+
212
+ test("a gate with no approval block decides exactly as before", async () => {
213
+ const port = memoryGateLedgerPort({
214
+ resolutions: [approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" } })],
215
+ pending: [PENDING],
216
+ });
217
+ const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest: PLAN_A, now: NOW });
218
+ expect(check.satisfied).toBe(true);
219
+ if (!check.satisfied) return;
220
+ expect(check.via).toBeUndefined();
221
+ });
222
+ });
223
+
224
+ describe("gate approval — the request a policy is asked (#2508)", () => {
225
+ test("carries the approver, the gate and the context, with the plan digest", () => {
226
+ expect(gatePolicyRequest({
227
+ op: "release", gate: "ship", resolvedBy: "release-bot", approver: { kind: "agent", roles: ["deployer"] },
228
+ planDigest: PLAN_A, context: { risk: "low" },
229
+ })).toEqual({
230
+ principal: { kind: "agent", name: "release-bot", roles: ["deployer"] },
231
+ action: "PassGate",
232
+ resource: { op: "release", gate: "ship" },
233
+ context: { risk: "low", planDigest: PLAN_A },
234
+ });
235
+ });
236
+ });
237
+
238
+ describe("gate approval — the approval block is checked where it is written (#2508)", () => {
239
+ test("a well-formed block passes, and lands on the step", () => {
240
+ const step = gate("ship", { approval: { quorum: { count: 2, roles: ["maintainer"] }, policy: POLICY, mode: "enforce", context: { risk: "low" } } });
241
+ expect(step.approval?.quorum?.count).toBe(2);
242
+ expect(gateApprovalProblems(step.approval)).toEqual([]);
243
+ });
244
+
245
+ test.each([
246
+ [{ quorum: { count: 0 } }, "quorum.count"],
247
+ [{ quorum: { count: 2, roles: [] } }, "quorum.roles"],
248
+ [{ mode: "enforce" }, "needs an `approval.policy`"],
249
+ [{ mode: "strict" }, "approval.mode"],
250
+ [{ policy: { name: "ship" } }, "does not resolve to a gate policy set"],
251
+ [{ policy: { ...POLICY, version: "sha256:stale" } }, "not the digest of its text"],
252
+ [{ context: { risk: "low" } }, "declares none"],
253
+ [{ policy: POLICY, context: { risk: { level: 1 } } }, "approval.context.risk"],
254
+ ])("rejects %j", (block, expected) => {
255
+ expect(gateApprovalProblems(block).join("\n")).toContain(expected);
256
+ expect(() => gate("ship", { approval: block as never })).toThrow(expected);
257
+ });
258
+ });