@intentius/chant 0.46.0 → 0.49.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 (183) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/lifecycle.d.ts +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  14. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  15. package/dist/cli/plugins.d.ts +1 -1
  16. package/dist/cli/plugins.d.ts.map +1 -1
  17. package/dist/cli/reporters/stylish.d.ts +15 -1
  18. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  19. package/dist/components/auto-release.d.ts +4 -0
  20. package/dist/components/auto-release.d.ts.map +1 -1
  21. package/dist/components/starter-plugin.d.ts +2 -0
  22. package/dist/components/starter-plugin.d.ts.map +1 -1
  23. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  24. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  25. package/dist/components/verbs/index.d.ts +8 -0
  26. package/dist/components/verbs/index.d.ts.map +1 -1
  27. package/dist/components/verbs/r2-sync.d.ts +76 -0
  28. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  29. package/dist/components/verbs/wrangler.d.ts +108 -0
  30. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  31. package/dist/config.d.ts +26 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/deep-observation.d.ts +14 -0
  34. package/dist/deep-observation.d.ts.map +1 -1
  35. package/dist/effect-receipt.d.ts +177 -0
  36. package/dist/effect-receipt.d.ts.map +1 -0
  37. package/dist/fold/subset.d.ts +15 -2
  38. package/dist/fold/subset.d.ts.map +1 -1
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/lexicon.d.ts +44 -3
  42. package/dist/lexicon.d.ts.map +1 -1
  43. package/dist/lifecycle/change-set.d.ts +33 -5
  44. package/dist/lifecycle/change-set.d.ts.map +1 -1
  45. package/dist/lifecycle/index.d.ts +2 -0
  46. package/dist/lifecycle/index.d.ts.map +1 -1
  47. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  48. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  49. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  50. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  51. package/dist/lifecycle/release-ledger.d.ts +20 -0
  52. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  53. package/dist/lifecycle/teardown.d.ts +6 -4
  54. package/dist/lifecycle/teardown.d.ts.map +1 -1
  55. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  56. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  57. package/dist/lint/knowledge-checks.d.ts +48 -0
  58. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  59. package/dist/lint/output-checks.d.ts +5 -0
  60. package/dist/lint/output-checks.d.ts.map +1 -0
  61. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  62. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  63. package/dist/lint/post-synth.d.ts +12 -0
  64. package/dist/lint/post-synth.d.ts.map +1 -1
  65. package/dist/lint/receipt-checks.d.ts +9 -0
  66. package/dist/lint/receipt-checks.d.ts.map +1 -0
  67. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  68. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  69. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  70. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  71. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  72. package/dist/lint/rules/index.d.ts +3 -1
  73. package/dist/lint/rules/index.d.ts.map +1 -1
  74. package/dist/okf-read.d.ts +78 -0
  75. package/dist/okf-read.d.ts.map +1 -0
  76. package/dist/op/builders.d.ts +98 -1
  77. package/dist/op/builders.d.ts.map +1 -1
  78. package/dist/op/index.d.ts +4 -2
  79. package/dist/op/index.d.ts.map +1 -1
  80. package/dist/op/local-executor.d.ts +2 -1
  81. package/dist/op/local-executor.d.ts.map +1 -1
  82. package/dist/op/receipt-store.d.ts +138 -0
  83. package/dist/op/receipt-store.d.ts.map +1 -0
  84. package/dist/op/types.d.ts +31 -1
  85. package/dist/op/types.d.ts.map +1 -1
  86. package/dist/secret-materialization.d.ts +138 -0
  87. package/dist/secret-materialization.d.ts.map +1 -0
  88. package/dist/secret-provenance.d.ts +218 -0
  89. package/dist/secret-provenance.d.ts.map +1 -0
  90. package/dist/serializer.d.ts +11 -0
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/yaml.d.ts.map +1 -1
  93. package/package.json +4 -1
  94. package/src/audit/core.test.ts +57 -0
  95. package/src/audit/core.ts +0 -0
  96. package/src/audit/detect-bundle.test.ts +1 -1
  97. package/src/audit/discover.test.ts +24 -0
  98. package/src/audit/discover.ts +11 -2
  99. package/src/audit/rules-doc.ts +11 -1
  100. package/src/build.test.ts +41 -0
  101. package/src/build.ts +34 -6
  102. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  103. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  104. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  105. package/src/cli/commands/audit.test.ts +53 -0
  106. package/src/cli/commands/audit.ts +1 -1
  107. package/src/cli/commands/build.test.ts +80 -0
  108. package/src/cli/commands/build.ts +106 -8
  109. package/src/cli/commands/lint.ts +15 -3
  110. package/src/cli/handlers/explain.test.ts +70 -1
  111. package/src/cli/handlers/graph.ts +2 -2
  112. package/src/cli/handlers/lifecycle.test.ts +115 -1
  113. package/src/cli/handlers/lifecycle.ts +84 -11
  114. package/src/cli/mcp/resource-handlers.ts +38 -1
  115. package/src/cli/mcp/server.test.ts +58 -1
  116. package/src/cli/mcp/tools/explain.ts +51 -2
  117. package/src/cli/plugins.ts +4 -2
  118. package/src/cli/reporters/stylish.test.ts +154 -0
  119. package/src/cli/reporters/stylish.ts +154 -33
  120. package/src/components/auto-release.ts +6 -0
  121. package/src/components/registry.test.ts +7 -2
  122. package/src/components/starter-plugin.ts +17 -0
  123. package/src/components/verbs/ensure-secret.test.ts +130 -0
  124. package/src/components/verbs/ensure-secret.ts +79 -0
  125. package/src/components/verbs/index.ts +8 -0
  126. package/src/components/verbs/r2-sync.test.ts +107 -0
  127. package/src/components/verbs/r2-sync.ts +124 -0
  128. package/src/components/verbs/wrangler.test.ts +170 -0
  129. package/src/components/verbs/wrangler.ts +241 -0
  130. package/src/config.test.ts +15 -0
  131. package/src/config.ts +30 -0
  132. package/src/deep-observation.test.ts +19 -0
  133. package/src/deep-observation.ts +17 -0
  134. package/src/effect-receipt-exclusion.test.ts +190 -0
  135. package/src/effect-receipt.test.ts +419 -0
  136. package/src/effect-receipt.ts +412 -0
  137. package/src/fold/subset.test.ts +26 -0
  138. package/src/fold/subset.ts +45 -19
  139. package/src/index.ts +4 -0
  140. package/src/lexicon.ts +48 -3
  141. package/src/lifecycle/change-set.ts +46 -7
  142. package/src/lifecycle/index.ts +2 -0
  143. package/src/lifecycle/observation-baseline.test.ts +46 -0
  144. package/src/lifecycle/observation-baseline.ts +33 -1
  145. package/src/lifecycle/receipt-plan.test.ts +250 -0
  146. package/src/lifecycle/receipt-plan.ts +249 -0
  147. package/src/lifecycle/release-ledger.ts +20 -0
  148. package/src/lifecycle/teardown.test.ts +31 -0
  149. package/src/lifecycle/teardown.ts +6 -4
  150. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  151. package/src/lifecycle/unobserved-gate.ts +102 -0
  152. package/src/lint/knowledge-checks.test.ts +80 -0
  153. package/src/lint/knowledge-checks.ts +74 -0
  154. package/src/lint/output-checks.test.ts +85 -0
  155. package/src/lint/output-checks.ts +99 -0
  156. package/src/lint/pipeline-change-gate.test.ts +144 -0
  157. package/src/lint/pipeline-change-gate.ts +153 -0
  158. package/src/lint/post-synth.ts +15 -0
  159. package/src/lint/receipt-checks.test.ts +101 -0
  160. package/src/lint/receipt-checks.ts +93 -0
  161. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  162. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  163. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  164. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  165. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  166. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  167. package/src/lint/rules/index.ts +7 -1
  168. package/src/okf-read.test.ts +149 -0
  169. package/src/okf-read.ts +197 -0
  170. package/src/op/builders.ts +139 -1
  171. package/src/op/effect-step.test.ts +311 -0
  172. package/src/op/index.ts +10 -3
  173. package/src/op/local-executor.ts +172 -25
  174. package/src/op/op.test.ts +25 -2
  175. package/src/op/receipt-store.ts +211 -0
  176. package/src/op/types.ts +33 -1
  177. package/src/secret-materialization.test.ts +199 -0
  178. package/src/secret-materialization.ts +235 -0
  179. package/src/secret-provenance.test.ts +388 -0
  180. package/src/secret-provenance.ts +475 -0
  181. package/src/serializer.ts +12 -0
  182. package/src/yaml.test.ts +88 -0
  183. package/src/yaml.ts +76 -6
@@ -1,4 +1,5 @@
1
1
  import type { LintDiagnostic, LintRule, Severity } from "../../lint/rule";
2
+ import type { OkfBundle } from "../../okf-read";
2
3
  import { pathToFileURL } from "node:url";
3
4
 
4
5
  /**
@@ -33,64 +34,184 @@ function color(text: string, colorCode: string): string {
33
34
  /**
34
35
  * Format diagnostics in stylish format (similar to ESLint)
35
36
  * Groups by file, shows severity, message, and rule ID
37
+ *
38
+ * @param diagnostics - Active (non-suppressed) diagnostics
39
+ * @param suppressed - Suppressed diagnostics with optional reason (optional). When
40
+ * present and non-empty, a "Suppressed" section is appended (#1866, design #1059)
41
+ * below the active diagnostics, mirroring SARIF's `suppressed` param on
42
+ * {@link formatSarif} rather than changing what "diagnostics" means here.
43
+ * @param bundle - The project's loaded OKF knowledge bundle (`../../okf-read`'s
44
+ * `loadOkfBundle`), used to resolve `okf:` citations in suppression reasons.
45
+ * Omitted (or a bundle with no matching concept) falls back to printing the
46
+ * raw reason with a warning — this never affects suppression itself, only
47
+ * how a cited reason renders.
36
48
  */
37
- export function formatStylish(diagnostics: LintDiagnostic[]): string {
38
- if (diagnostics.length === 0) {
49
+ export function formatStylish(
50
+ diagnostics: LintDiagnostic[],
51
+ suppressed?: Array<LintDiagnostic & { reason?: string }>,
52
+ bundle?: OkfBundle,
53
+ ): string {
54
+ const hasSuppressed = suppressed !== undefined && suppressed.length > 0;
55
+
56
+ if (diagnostics.length === 0 && !hasSuppressed) {
39
57
  return formatSummary(0, 0);
40
58
  }
41
59
 
42
- // Group by file
43
- const byFile = new Map<string, LintDiagnostic[]>();
44
- for (const diag of diagnostics) {
60
+ const lines: string[] = [];
61
+ let errorCount = 0;
62
+ let warningCount = 0;
63
+
64
+ if (diagnostics.length > 0) {
65
+ // Group by file
66
+ const byFile = new Map<string, LintDiagnostic[]>();
67
+ for (const diag of diagnostics) {
68
+ const existing = byFile.get(diag.file) ?? [];
69
+ existing.push(diag);
70
+ byFile.set(diag.file, existing);
71
+ }
72
+
73
+ for (const [file, fileDiags] of byFile) {
74
+ // File header with underline
75
+ lines.push("");
76
+ lines.push(color(file, colors.underline));
77
+
78
+ // Sort by line, then column
79
+ fileDiags.sort((a, b) => {
80
+ if (a.line !== b.line) return a.line - b.line;
81
+ return a.column - b.column;
82
+ });
83
+
84
+ for (const diag of fileDiags) {
85
+ // Count errors and warnings
86
+ if (diag.severity === "error") {
87
+ errorCount++;
88
+ } else if (diag.severity === "warning") {
89
+ warningCount++;
90
+ }
91
+
92
+ // Format: " line:col severity message ruleId"
93
+ const location = color(
94
+ `${String(diag.line).padStart(4)}:${String(diag.column).padEnd(3)}`,
95
+ colors.gray
96
+ );
97
+
98
+ const severityColor = diag.severity === "error" ? colors.red : colors.yellow;
99
+ const severity = color(diag.severity.padEnd(7), severityColor);
100
+
101
+ const ruleId = color(diag.ruleId, colors.gray);
102
+
103
+ lines.push(` ${location} ${severity} ${diag.message} ${ruleId}`);
104
+ }
105
+ }
106
+ }
107
+
108
+ if (hasSuppressed) {
109
+ lines.push(...formatSuppressedSection(suppressed!, bundle));
110
+ }
111
+
112
+ // Summary line
113
+ lines.push("");
114
+ const summary = formatSummary(errorCount, warningCount);
115
+ lines.push(summary);
116
+
117
+ return lines.join("\n");
118
+ }
119
+
120
+ /**
121
+ * A suppression reason's `okf:` citation, resolved against a loaded bundle.
122
+ */
123
+ interface ResolvedCitation {
124
+ /** What to print beside the rule id: the concept title when resolved, the raw reason otherwise. */
125
+ label: string;
126
+ /** The concept's bundle-relative path, printed on the line below — only set when resolved. */
127
+ path?: string;
128
+ /** True when the reason carried an `okf:` token that named no concept in `bundle`. */
129
+ unresolved: boolean;
130
+ }
131
+
132
+ /**
133
+ * Resolve a suppression reason's `okf:<path>` citation (#1866, design #1059)
134
+ * against a loaded bundle's concepts, matched on `OkfConcept.path`. Not an
135
+ * `okf:` citation at all: returns `null`, and the reason renders as plain
136
+ * text, same as before this section existed. A leading `/` on the cited path
137
+ * is accepted and stripped — `okf-read.ts`'s bundle-relative paths never
138
+ * carry one, but the design doc's worked example writes citations with one
139
+ * (mirroring an absolute-from-bundle-root reading), so both forms resolve.
140
+ */
141
+ function resolveOkfCitation(reason: string, bundle: OkfBundle | undefined): ResolvedCitation | null {
142
+ const prefix = "okf:";
143
+ if (!reason.startsWith(prefix)) return null;
144
+
145
+ const token = reason.slice(prefix.length).trim().replace(/^\/+/, "");
146
+ const concept = bundle?.concepts.find((c) => c.path === token);
147
+
148
+ if (concept) {
149
+ return { label: concept.title ?? concept.path, path: concept.path, unresolved: false };
150
+ }
151
+
152
+ return { label: reason, unresolved: true };
153
+ }
154
+
155
+ /**
156
+ * Render the "Suppressed" section: every suppressed diagnostic, grouped by
157
+ * file like the active section above, with an `okf:` reason resolved to its
158
+ * concept's title (and bundle-relative path on the line below) when
159
+ * `bundle` has a match. A citation naming no concept — including when no
160
+ * `bundle` was loaded at all — prints the raw reason plus a warning; a
161
+ * plain (non-`okf:`) reason prints as-is. Never affects which diagnostics
162
+ * are suppressed, only how the reason renders (design #1059: "a cited
163
+ * suppression suppresses exactly as an uncited one does").
164
+ */
165
+ function formatSuppressedSection(
166
+ suppressed: Array<LintDiagnostic & { reason?: string }>,
167
+ bundle: OkfBundle | undefined,
168
+ ): string[] {
169
+ const lines: string[] = [];
170
+
171
+ const byFile = new Map<string, Array<LintDiagnostic & { reason?: string }>>();
172
+ for (const diag of suppressed) {
45
173
  const existing = byFile.get(diag.file) ?? [];
46
174
  existing.push(diag);
47
175
  byFile.set(diag.file, existing);
48
176
  }
49
177
 
50
- const lines: string[] = [];
51
- let errorCount = 0;
52
- let warningCount = 0;
178
+ lines.push("");
179
+ lines.push(color("Suppressed", colors.underline));
53
180
 
54
181
  for (const [file, fileDiags] of byFile) {
55
- // File header with underline
56
182
  lines.push("");
57
183
  lines.push(color(file, colors.underline));
58
184
 
59
- // Sort by line, then column
60
185
  fileDiags.sort((a, b) => {
61
186
  if (a.line !== b.line) return a.line - b.line;
62
187
  return a.column - b.column;
63
188
  });
64
189
 
65
190
  for (const diag of fileDiags) {
66
- // Count errors and warnings
67
- if (diag.severity === "error") {
68
- errorCount++;
69
- } else if (diag.severity === "warning") {
70
- warningCount++;
71
- }
72
-
73
- // Format: " line:col severity message ruleId"
74
- const location = color(
75
- `${String(diag.line).padStart(4)}:${String(diag.column).padEnd(3)}`,
76
- colors.gray
77
- );
78
-
79
- const severityColor = diag.severity === "error" ? colors.red : colors.yellow;
80
- const severity = color(diag.severity.padEnd(7), severityColor);
81
-
191
+ const locationPlain = `${String(diag.line).padStart(4)}:${String(diag.column).padEnd(3)}`;
192
+ const location = color(locationPlain, colors.gray);
193
+ const severity = color("suppressed", colors.cyan);
82
194
  const ruleId = color(diag.ruleId, colors.gray);
83
195
 
84
- lines.push(` ${location} ${severity} ${diag.message} ${ruleId}`);
196
+ const citation = diag.reason ? resolveOkfCitation(diag.reason, bundle) : null;
197
+ const indent = " ".repeat(2 + locationPlain.length + 2 + "suppressed".length + 2 + diag.ruleId.length + 2);
198
+
199
+ if (citation) {
200
+ lines.push(` ${location} ${severity} ${ruleId} ${citation.label}`);
201
+ if (!citation.unresolved) {
202
+ lines.push(`${indent}${color(citation.path!, colors.gray)}`);
203
+ } else {
204
+ lines.push(`${indent}${color("⚠ unresolved okf citation", colors.yellow)}`);
205
+ }
206
+ } else if (diag.reason) {
207
+ lines.push(` ${location} ${severity} ${ruleId} ${diag.reason}`);
208
+ } else {
209
+ lines.push(` ${location} ${severity} ${ruleId}`);
210
+ }
85
211
  }
86
212
  }
87
213
 
88
- // Summary line
89
- lines.push("");
90
- const summary = formatSummary(errorCount, warningCount);
91
- lines.push(summary);
92
-
93
- return lines.join("\n");
214
+ return lines;
94
215
  }
95
216
 
96
217
  /**
@@ -117,6 +117,10 @@ export interface AutoReleaseRunInfo {
117
117
  digest?: string;
118
118
  /** Orchestrator run identifier — a Temporal `runId`, or a locally generated id for the local executor (mirrors `runComponentsReleaseRecord`'s `--run-id` default). */
119
119
  runId: string;
120
+ /** The bypassed capability-profile divergences, when the caller deliberately overrode a deploy-time profile assertion (chant #1244) — recorded verbatim as the release record's `profileOverride`. */
121
+ profileOverride?: string;
122
+ /** The deploy's input-side digest, when `digest` is a rendered-content identity (a pinned helm deploy, chant #1242) — recorded as the release record's `inputDigest` so ledger queries can still join on inputs across clusters whose bytes legitimately differ. */
123
+ inputDigest?: string;
120
124
  }
121
125
 
122
126
  /** Opt-out + field-override knobs for auto-release recording, threaded from CLI flags/config (#597: "opt-out-able (flag/config), documented default"). */
@@ -202,6 +206,8 @@ export async function maybeRecordAutoRelease(
202
206
  runId: run.runId,
203
207
  timestamp,
204
208
  actor,
209
+ ...(run.profileOverride ? { profileOverride: run.profileOverride } : {}),
210
+ ...(run.inputDigest ? { inputDigest: run.inputDigest } : {}),
205
211
  },
206
212
  { cwd: options.cwd },
207
213
  );
@@ -92,9 +92,14 @@ describe("createCapabilityRegistry", () => {
92
92
  }
93
93
  });
94
94
 
95
- test("family table lists exactly the agnostic starter families (cloud leaves moved to lexicons)", () => {
95
+ // Every AWS/k8s cloud leaf moved out to its lexicon but the three
96
+ // Cloudflare Workers apply verbs (#1293, epic #1296) stay in core by
97
+ // deliberate decision (see ./verbs/wrangler.ts's module doc): the epic
98
+ // cedes the Workers plane to `wrangler` and there is no cloudflare lexicon
99
+ // for them to live in instead.
100
+ test("family table lists exactly the agnostic starter families plus the Cloudflare apply leaves (#1293)", () => {
96
101
  expect(Object.keys(STARTER_VERB_FAMILIES).sort()).toEqual(
97
- ["build", "escapeHatch", "sbom", "supplyChainPolicy", "supplyChainSecurity", "waitVerify"].sort(),
102
+ ["build", "cloudflare", "escapeHatch", "sbom", "secrets", "supplyChainPolicy", "supplyChainSecurity", "waitVerify"].sort(),
98
103
  );
99
104
  });
100
105
 
@@ -41,6 +41,10 @@ import {
41
41
  waitEndpointCapability,
42
42
  healthGateCapability,
43
43
  shellCapability,
44
+ ensureSecretCapability,
45
+ wranglerDeployCapability,
46
+ wranglerVersionsPromoteCapability,
47
+ r2SyncCapability,
44
48
  } from "./verbs/index";
45
49
  import { ownPackageVersion, type CapabilityPlugin } from "./capability-plugin";
46
50
 
@@ -64,7 +68,16 @@ export const STARTER_VERB_FAMILIES = {
64
68
  // cloud-specific waits (`wait-for-stack`/`wait-steady-state`/`wait-job`) are
65
69
  // contributed by the relevant lexicon (aws) — see docs/components/cloud-boundary.
66
70
  waitVerify: ["wait-cluster-healthy", "wait-endpoint", "health-gate"],
71
+ // generated-once secret materialization (#1829, epic #1365): read-then-write,
72
+ // present means done; the k8s store adapter is #1830.
73
+ secrets: ["ensure-secret"],
67
74
  escapeHatch: ["shell"],
75
+ // The Cloudflare Workers apply leaves (#1293, epic #1296) — placed in core
76
+ // rather than a cloudflare lexicon (decided explicitly, see ./verbs/wrangler.ts's
77
+ // module doc): the epic cedes the Workers plane to `wrangler` entirely, and
78
+ // there is no cloudflare lexicon for these three verbs to live in. Revisit
79
+ // if/when the zone-plane lexicon (#1294) lands.
80
+ cloudflare: ["wrangler-deploy", "wrangler-versions-promote", "r2-sync"],
68
81
  } as const;
69
82
 
70
83
  /**
@@ -88,7 +101,11 @@ function starterCapabilities(): Array<Capability<never, unknown>> {
88
101
  waitClusterHealthyCapability,
89
102
  waitEndpointCapability,
90
103
  healthGateCapability,
104
+ ensureSecretCapability,
91
105
  shellCapability,
106
+ wranglerDeployCapability,
107
+ wranglerVersionsPromoteCapability,
108
+ r2SyncCapability,
92
109
  ] as unknown as Array<Capability<never, unknown>>;
93
110
  }
94
111
 
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `ensure-secret` capability surface tests (#1829, epic #1365 decision 3):
3
+ * the verb is the same engine as the `ensureSecret(...)` op builder, its
4
+ * rollback disposition is `needs-opt-out`, and COMP003 fires on a component
5
+ * that uses it without acknowledging the compensation gap — through the same
6
+ * registry-derived policy map `chant lint` builds, not a hard-coded list.
7
+ */
8
+
9
+ import { describe, expect, it, vi } from "vitest";
10
+ import type { DeployContext } from "../capability";
11
+ import { CapabilityNotImplementedError, type RollbackPolicy } from "../capability";
12
+ import { createCapabilityRegistry } from "../registry";
13
+ import { createEnsureSecretCapability, ensureSecretCapability } from "./ensure-secret";
14
+ import {
15
+ SecretMaterial,
16
+ SecretContractMismatchError,
17
+ consumeSecretMaterial,
18
+ type SecretMaterialGenerator,
19
+ type SecretStoreAdapter,
20
+ } from "../../secret-materialization";
21
+ import { loadComponentChecks } from "../../lint/rules/comp";
22
+
23
+ const ctx: DeployContext = { env: "dev", component: "fountain" };
24
+
25
+ function fakeStore(seed?: Record<string, Record<string, string>>) {
26
+ const secrets = new Map<string, Record<string, string>>(Object.entries(seed ?? {}));
27
+ const adapter: SecretStoreAdapter = {
28
+ exists: vi.fn(async (name: string) => secrets.has(name)),
29
+ describe: vi.fn(async (name: string) => ({ keys: Object.keys(secrets.get(name) ?? {}) })),
30
+ create: vi.fn(async (name: string, keys: readonly string[], generate: SecretMaterialGenerator) => {
31
+ const data: Record<string, string> = {};
32
+ for (const key of keys) data[key] = consumeSecretMaterial(await generate(key));
33
+ secrets.set(name, data);
34
+ }),
35
+ };
36
+ return { adapter, secrets };
37
+ }
38
+
39
+ describe("ensure-secret capability", () => {
40
+ it("creates an absent secret, then treats present-and-matching as done (create fires once across reruns)", async () => {
41
+ const { adapter } = fakeStore();
42
+ const capability = createEnsureSecretCapability({
43
+ store: adapter,
44
+ generator: (key) => SecretMaterial.mint(`v:${key}`),
45
+ });
46
+ const first = await capability.run(ctx, { name: "app-secret", keys: ["token"] });
47
+ const second = await capability.run(ctx, { name: "app-secret", keys: ["token"] });
48
+ expect(first.outcome).toBe("created");
49
+ expect(second.outcome).toBe("present");
50
+ expect(adapter.create).toHaveBeenCalledTimes(1);
51
+ });
52
+
53
+ it("propagates the loud mismatch from the shared engine", async () => {
54
+ const { adapter } = fakeStore({ "app-secret": { other: "x" } });
55
+ const capability = createEnsureSecretCapability({ store: adapter });
56
+ await expect(capability.run(ctx, { name: "app-secret", keys: ["token"] })).rejects.toThrowError(
57
+ SecretContractMismatchError,
58
+ );
59
+ });
60
+
61
+ it("output carries names only — no field of the outcome can hold material", async () => {
62
+ const { adapter } = fakeStore();
63
+ const capability = createEnsureSecretCapability({
64
+ store: adapter,
65
+ generator: (key) => SecretMaterial.mint(`v:${key}`),
66
+ });
67
+ const outcome = await capability.run(ctx, { name: "app-secret", keys: ["token"] });
68
+ expect(outcome).toEqual({ outcome: "created", name: "app-secret", keys: ["token"] });
69
+ expect(JSON.stringify(outcome)).not.toContain("v:token");
70
+ });
71
+
72
+ it("is registered in the starter set as a typed stub until a provider row (#1830) wires an adapter", async () => {
73
+ const registry = createCapabilityRegistry();
74
+ const starter = registry.resolve("ensure-secret");
75
+ expect(starter.rollbackPolicy).toBe("needs-opt-out");
76
+ await expect(starter.run(ctx as never, {} as never)).rejects.toThrowError(CapabilityNotImplementedError);
77
+ });
78
+ });
79
+
80
+ describe("COMP003 over ensure-secret", () => {
81
+ /** The same derivation chant lint's resolveRegistryContext performs. */
82
+ function registryRollbackPolicies(): Map<string, RollbackPolicy> {
83
+ const registry = createCapabilityRegistry();
84
+ const policies = new Map<string, RollbackPolicy>();
85
+ for (const kind of registry.kinds()) {
86
+ const capability = registry.resolve(kind);
87
+ policies.set(kind, capability.rollbackPolicy ?? (capability.rollback ? "native" : "none-by-design"));
88
+ }
89
+ return policies;
90
+ }
91
+
92
+ const [comp003] = loadComponentChecks().filter((c) => c.id === "COMP003");
93
+
94
+ function checkComponent(step: Record<string, unknown>) {
95
+ const checkCtx = {
96
+ rollbackPolicies: registryRollbackPolicies(),
97
+ components: new Map([
98
+ [
99
+ "fountain",
100
+ {
101
+ component: {
102
+ name: "fountain",
103
+ dependsOn: [],
104
+ deploy: [{ phase: "Secrets", steps: [step] }],
105
+ },
106
+ filePath: "fountain.component.ts",
107
+ },
108
+ ],
109
+ ]),
110
+ };
111
+ return comp003.check(checkCtx as never);
112
+ }
113
+
114
+ it("flags a bare ensure-secret step — minting has no safe undo, so the gap needs an explicit opt-out", () => {
115
+ const diagnostics = checkComponent({ kind: "ensure-secret", name: "master-key", keys: ["MASTER_SECRETS_KEY"] });
116
+ expect(diagnostics).toHaveLength(1);
117
+ expect(diagnostics[0].checkId).toBe("COMP003");
118
+ expect(diagnostics[0].message).toContain("ensure-secret");
119
+ });
120
+
121
+ it("accepts the step once the component acknowledges the gap via noRollback", () => {
122
+ const diagnostics = checkComponent({
123
+ kind: "ensure-secret",
124
+ name: "master-key",
125
+ keys: ["MASTER_SECRETS_KEY"],
126
+ noRollback: "generated-once: un-creating would destroy the only copy of the material",
127
+ });
128
+ expect(diagnostics).toHaveLength(0);
129
+ });
130
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * secrets family — `ensure-secret` (#1829, epic #1365 decision 3). The
3
+ * capability surface over the one generated-once materialization engine
4
+ * (../../secret-materialization.ts); the op step builder `ensureSecret(...)`
5
+ * (../../op/builders.ts) is the other surface over the same engine.
6
+ *
7
+ * Contract (read-then-write): present means done. The verb never mints over
8
+ * an existing value and never rotates implicitly; an existing secret that
9
+ * does not match the declared key-set/metadata stops the apply with a
10
+ * `SecretContractMismatchError` naming key names and metadata keys — never
11
+ * values. There is no safe undo for minting a secret others may already have
12
+ * consumed, and un-creating it would destroy the only copy of the material —
13
+ * so `rollbackPolicy: "needs-opt-out"`: COMP003 requires a component using
14
+ * this verb to acknowledge the compensation gap explicitly.
15
+ *
16
+ * The store adapter is per-provider (#1830 is the k8s row). Core registers a
17
+ * typed stub; a provider builds the real capability with
18
+ * {@link createEnsureSecretCapability}.
19
+ */
20
+
21
+ import type { Capability } from "../capability";
22
+ import { CapabilityNotImplementedError } from "../capability";
23
+ import {
24
+ ensureSecretMaterialization,
25
+ defaultSecretMaterialGenerator,
26
+ type EnsureSecretOutcome,
27
+ type SecretMaterialGenerator,
28
+ type SecretStoreAdapter,
29
+ } from "../../secret-materialization";
30
+
31
+ export interface EnsureSecretInput {
32
+ /** The secret's name as the store knows it. */
33
+ name: string;
34
+ /** The declared key-set. Creation mints one value per key; verification compares names only. */
35
+ keys: string[];
36
+ /** Declared metadata an existing secret must carry. Mismatches are reported by KEY. */
37
+ metadata?: Record<string, string>;
38
+ }
39
+
40
+ /** Names only — no field of the output can carry material. */
41
+ export type EnsureSecretOutput = EnsureSecretOutcome;
42
+
43
+ /**
44
+ * Build the real `ensure-secret` capability over a provider's
45
+ * {@link SecretStoreAdapter}. The generator seam defaults to 32 CSPRNG bytes
46
+ * per key; the material it mints flows straight through the adapter to the
47
+ * store and is never returned, logged, or retained (see
48
+ * ../../secret-materialization.ts).
49
+ */
50
+ export function createEnsureSecretCapability(deps: {
51
+ store: SecretStoreAdapter;
52
+ generator?: SecretMaterialGenerator;
53
+ }): Capability<EnsureSecretInput, EnsureSecretOutput> {
54
+ return {
55
+ kind: "ensure-secret",
56
+ rollbackPolicy: "needs-opt-out",
57
+ async run(_ctx, input) {
58
+ return ensureSecretMaterialization(
59
+ deps.store,
60
+ { name: input.name, keys: input.keys, metadata: input.metadata },
61
+ deps.generator ?? defaultSecretMaterialGenerator,
62
+ );
63
+ },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The starter-set registration: fully typed, correct `rollbackPolicy` for
69
+ * COMP003, but no store adapter — running it throws
70
+ * `CapabilityNotImplementedError` until a provider row (#1830, k8s) wires a
71
+ * real adapter via {@link createEnsureSecretCapability}.
72
+ */
73
+ export const ensureSecretCapability: Capability<EnsureSecretInput, EnsureSecretOutput> = {
74
+ kind: "ensure-secret",
75
+ rollbackPolicy: "needs-opt-out",
76
+ async run(): Promise<EnsureSecretOutput> {
77
+ throw new CapabilityNotImplementedError("ensure-secret");
78
+ },
79
+ };
@@ -26,6 +26,11 @@
26
26
  * `ProvenanceLink` material. `verify` (#622, ./verify.ts) is the matching
27
27
  * deploy-time gate — `cosign verify`/`verify-attestation` against a
28
28
  * configured identity policy, mirroring ../../lint/policy.ts's `policyGate`.
29
+ * `wrangler-deploy`/`wrangler-versions-promote` (#1293, epic #1296,
30
+ * ./wrangler.ts) and `r2-sync` (#1293, ./r2-sync.ts) are the Cloudflare
31
+ * Workers apply leaves — real implementations over the same injectable
32
+ * `ProcessRunner` `sign` uses, shelling to `wrangler`/`rclone` rather than an
33
+ * AWS-shaped SDK client.
29
34
  */
30
35
 
31
36
  export * from "./build-archive";
@@ -39,11 +44,14 @@ export * from "./component-bom";
39
44
  export * from "./reproducibility";
40
45
  export * from "./sign";
41
46
  export * from "./verify";
47
+ export * from "./wrangler";
48
+ export * from "./r2-sync";
42
49
  export * from "./vuln-scan";
43
50
  export * from "./vex";
44
51
  export * from "./license-policy";
45
52
  export * from "./vuln-gate";
46
53
  export * from "./wait-verify";
47
54
  export * from "./shell";
55
+ export * from "./ensure-secret";
48
56
  export * from "./cloud-executor";
49
57
  export * from "./process-runner";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Tests `r2-sync` (#1293, epic #1296, ./r2-sync.ts) against a
3
+ * `MockProcessRunner` — no live `rclone`, no network call, ever. Asserts the
4
+ * `r2://` destination shape mirrors `s3-sync`'s `s3://` shape, the
5
+ * copy-vs-sync dispatch on `delete`, the uploaded/deleted count parsing, and
6
+ * the `needs-opt-out` rollback disposition `s3-sync` also carries.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+ import {
11
+ buildR2SyncArgs,
12
+ createR2SyncCapability,
13
+ parseRcloneStats,
14
+ R2SyncInvalidDestinationError,
15
+ toRcloneDest,
16
+ } from "./r2-sync";
17
+ import { ToolNotAvailableError } from "./process-runner";
18
+ import { createMockProcessRunner } from "./__tests__/mock-process-runner";
19
+
20
+ const ctx = { env: "prod", component: "assets" };
21
+
22
+ describe("toRcloneDest", () => {
23
+ it("translates r2://bucket/prefix to the default 'r2' rclone remote", () => {
24
+ expect(toRcloneDest("r2://my-bucket/assets")).toBe("r2:my-bucket/assets");
25
+ });
26
+
27
+ it("translates a bare bucket with no prefix", () => {
28
+ expect(toRcloneDest("r2://my-bucket")).toBe("r2:my-bucket");
29
+ });
30
+
31
+ it("honours an explicit remote name override", () => {
32
+ expect(toRcloneDest("r2://my-bucket/assets", "cf-r2")).toBe("cf-r2:my-bucket/assets");
33
+ });
34
+
35
+ it("throws R2SyncInvalidDestinationError for a non-r2:// URI (e.g. an s3:// one passed by mistake)", () => {
36
+ expect(() => toRcloneDest("s3://my-bucket/assets")).toThrow(R2SyncInvalidDestinationError);
37
+ });
38
+ });
39
+
40
+ describe("buildR2SyncArgs", () => {
41
+ it("uses 'rclone copy' by default — never deletes destination-only keys, matching s3-sync's default", () => {
42
+ const cmd = buildR2SyncArgs({ from: "dist/assets", to: "r2://my-bucket/assets" });
43
+ expect(cmd).toBe(`rclone copy 'dist/assets' 'r2:my-bucket/assets' -v`);
44
+ });
45
+
46
+ it("uses 'rclone sync' when delete: true — matching s3-sync's opt-in delete", () => {
47
+ const cmd = buildR2SyncArgs({ from: "dist/assets", to: "r2://my-bucket/assets", delete: true });
48
+ expect(cmd).toBe(`rclone sync 'dist/assets' 'r2:my-bucket/assets' -v`);
49
+ });
50
+ });
51
+
52
+ describe("parseRcloneStats", () => {
53
+ it("counts new and replaced uploads together as 'uploaded'", () => {
54
+ const stdout = [
55
+ "2026/08/24 10:00:00 INFO : a.txt: Copied (new)",
56
+ "2026/08/24 10:00:00 INFO : b.txt: Copied (replaced existing)",
57
+ "2026/08/24 10:00:00 INFO : c.txt: Copied (new)",
58
+ ].join("\n");
59
+ expect(parseRcloneStats(stdout)).toEqual({ uploaded: 3, deleted: 0 });
60
+ });
61
+
62
+ it("counts deletions separately", () => {
63
+ const stdout = [
64
+ "2026/08/24 10:00:00 INFO : a.txt: Copied (new)",
65
+ "2026/08/24 10:00:00 INFO : stale.txt: Deleted",
66
+ ].join("\n");
67
+ expect(parseRcloneStats(stdout)).toEqual({ uploaded: 1, deleted: 1 });
68
+ });
69
+
70
+ it("returns zeros for a no-op sync", () => {
71
+ expect(parseRcloneStats("2026/08/24 10:00:00 INFO : There was nothing to transfer\n")).toEqual({
72
+ uploaded: 0,
73
+ deleted: 0,
74
+ });
75
+ });
76
+ });
77
+
78
+ describe("r2-sync capability", () => {
79
+ it("round-trips a directory upload and reports the object count", async () => {
80
+ const mock = createMockProcessRunner({
81
+ responses: {
82
+ "rclone copy": "2026/08/24 10:00:00 INFO : a.txt: Copied (new)\n2026/08/24 10:00:00 INFO : b.txt: Copied (new)\n",
83
+ },
84
+ });
85
+ const capability = createR2SyncCapability(mock.runner);
86
+
87
+ const output = await capability.run(ctx, { from: "dist/assets", to: "r2://my-bucket/assets" });
88
+
89
+ expect(output).toEqual({ uploaded: 2, deleted: 0 });
90
+ expect(mock.calls.some((c) => c.command === `rclone copy 'dist/assets' 'r2:my-bucket/assets' -v`)).toBe(true);
91
+ });
92
+
93
+ it("throws ToolNotAvailableError when rclone is absent, rather than silently skipping the sync", async () => {
94
+ const mock = createMockProcessRunner({ tools: { rclone: false } });
95
+ const capability = createR2SyncCapability(mock.runner);
96
+
97
+ await expect(capability.run(ctx, { from: "dist/assets", to: "r2://my-bucket/assets" })).rejects.toBeInstanceOf(
98
+ ToolNotAvailableError,
99
+ );
100
+ });
101
+
102
+ it("declares rollbackPolicy: needs-opt-out — a mutating, destructive-capable sync with no native undo, same as s3-sync", () => {
103
+ const capability = createR2SyncCapability(createMockProcessRunner().runner);
104
+ expect(capability.rollbackPolicy).toBe("needs-opt-out");
105
+ expect(capability.rollback).toBeUndefined();
106
+ });
107
+ });