@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
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `r2-sync` — bulk object upload to a Cloudflare R2 bucket (chant #1293,
3
+ * epic #1296), the direct analogue of `s3-sync`
4
+ * (lexicons/aws/src/components/apply.ts): same input shape (`from`/`to`/
5
+ * `delete`), same mutating-no-native-rollback disposition
6
+ * (`rollbackPolicy: "needs-opt-out"`, ../../lint/rules/comp/comp003-mutating-no-rollback.ts),
7
+ * learnable as the same idea per #1293's own verification bullet. See
8
+ * ./wrangler.ts's module doc for why this lives in core rather than a
9
+ * cloudflare lexicon.
10
+ *
11
+ * R2 has no first-party bulk-sync CLI the way S3 has `aws s3 sync`, but its
12
+ * API is S3-compatible, so it takes any S3-compatible sync tool — this wraps
13
+ * `rclone` (its `sync`/`copy` distinguish "mirror, deleting extras" from
14
+ * "upload only" exactly the way `s3-sync`'s `delete` flag does) through the
15
+ * same injectable `ProcessRunner` (./process-runner.ts) `./sign.ts` and
16
+ * `./wrangler.ts` use. No live `rclone`, no network, ever, in a test run —
17
+ * every test substitutes `MockProcessRunner`.
18
+ */
19
+
20
+ import type { Capability } from "../capability";
21
+ import { defaultProcessRunner, q, requireTool, type ProcessRunner } from "./process-runner";
22
+
23
+ const RCLONE_TOOL = "rclone";
24
+
25
+ /** Matches an `r2://bucket[/prefix]` destination — mirrors `s3-sync`'s `s3://bucket/prefix` shape. */
26
+ const R2_URI_RE = /^r2:\/\/([^/]+)(?:\/(.*))?$/;
27
+
28
+ /** Thrown when `R2SyncInput.to` is not an `r2://bucket[/prefix]` URI. */
29
+ export class R2SyncInvalidDestinationError extends Error {
30
+ constructor(public readonly to: string) {
31
+ super(
32
+ `r2-sync: destination "${to}" is not an "r2://bucket[/prefix]" URI — mirror s3-sync's "s3://bucket/prefix" shape.`,
33
+ );
34
+ this.name = "R2SyncInvalidDestinationError";
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Translate an `r2://bucket/prefix` destination to rclone's `<remote>:<path>`
40
+ * form. `remote` is the name of an rclone remote already configured against
41
+ * R2's S3-compatible endpoint (`<account-id>.r2.cloudflarestorage.com`) —
42
+ * out of this capability's scope the same way `s3-sync` doesn't configure
43
+ * AWS credentials; default `"r2"` is the conventional alias. Exported for
44
+ * tests, mirroring ./sign.ts's `buildSignArgs`.
45
+ */
46
+ export function toRcloneDest(to: string, remote = "r2"): string {
47
+ const match = R2_URI_RE.exec(to);
48
+ if (!match) throw new R2SyncInvalidDestinationError(to);
49
+ const [, bucket, prefix] = match;
50
+ return `${remote}:${bucket}${prefix ? `/${prefix}` : ""}`;
51
+ }
52
+
53
+ export interface R2SyncInput {
54
+ /** Local path, or archive-relative path, to sync from. */
55
+ from: string;
56
+ /** Destination bucket URI, `"r2://bucket/prefix"` — mirrors `s3-sync`'s `S3SyncInput.to` shape. */
57
+ to: string;
58
+ /** Delete destination keys not present in the source. Default: false. */
59
+ delete?: boolean;
60
+ /** rclone remote name pointed at R2. Default: `"r2"`. */
61
+ remote?: string;
62
+ }
63
+
64
+ export interface R2SyncOutput {
65
+ /** Number of objects uploaded (new or changed). */
66
+ uploaded: number;
67
+ /** Number of objects deleted (only possible when `delete: true`). */
68
+ deleted: number;
69
+ }
70
+
71
+ /**
72
+ * Build the `rclone` invocation for `input`. `rclone sync` deletes
73
+ * destination-only files by default (matching `s3-sync`'s opt-in `delete:
74
+ * true`); `rclone copy` never deletes (matching the `s3-sync` default) — so
75
+ * the verb dispatches on `input.delete` rather than passing a `--delete`
76
+ * flag rclone doesn't have. `-v` gives the per-file action lines
77
+ * `parseRcloneStats` reads. Exported for tests, mirroring ./sign.ts's
78
+ * `buildSignArgs`.
79
+ */
80
+ export function buildR2SyncArgs(input: R2SyncInput): string {
81
+ const dest = toRcloneDest(input.to, input.remote);
82
+ const verb = input.delete ? "sync" : "copy";
83
+ return `rclone ${verb} ${q(input.from)} ${q(dest)} -v`;
84
+ }
85
+
86
+ // rclone's `-v` per-file log lines end e.g. "path/to/file: Copied (new)",
87
+ // "path/to/file: Copied (replaced existing)", "path/to/file: Deleted".
88
+ const COPIED_NEW_RE = /: Copied \(new\)/g;
89
+ const COPIED_REPLACED_RE = /: Copied \(replaced existing\)/g;
90
+ const DELETED_RE = /: Deleted$/gm;
91
+
92
+ /** Count uploaded/deleted objects out of `rclone -v`'s stdout. Exported for tests. */
93
+ export function parseRcloneStats(stdout: string): R2SyncOutput {
94
+ const uploaded = (stdout.match(COPIED_NEW_RE) ?? []).length + (stdout.match(COPIED_REPLACED_RE) ?? []).length;
95
+ const deleted = (stdout.match(DELETED_RE) ?? []).length;
96
+ return { uploaded, deleted };
97
+ }
98
+
99
+ /**
100
+ * Bulk-sync a directory of objects to an R2 bucket via `rclone`
101
+ * (endpoint-aware only through the rclone remote's own config — this
102
+ * capability never touches R2 credentials directly). Mutating, no native
103
+ * undo (an overwritten or deleted object is gone), so — like `s3-sync` —
104
+ * `rollbackPolicy: "needs-opt-out"`: COMP003 requires the component to
105
+ * acknowledge the compensation gap explicitly (a `noRollback` reason, a
106
+ * component-level `rollback` phase, or a sibling `rollback-previous`/
107
+ * `snapshot-before` step).
108
+ */
109
+ export function createR2SyncCapability(
110
+ processRunner: ProcessRunner = defaultProcessRunner(),
111
+ ): Capability<R2SyncInput, R2SyncOutput> {
112
+ return {
113
+ kind: "r2-sync",
114
+ rollbackPolicy: "needs-opt-out",
115
+ async run(_ctx, input) {
116
+ await requireTool(processRunner, RCLONE_TOOL, `sync ${input.from} to ${input.to}`);
117
+ const { stdout } = await processRunner.run(buildR2SyncArgs(input));
118
+ return parseRcloneStats(stdout);
119
+ },
120
+ };
121
+ }
122
+
123
+ /** Default `r2-sync` capability, backed by the real `ProcessRunner`. */
124
+ export const r2SyncCapability: Capability<R2SyncInput, R2SyncOutput> = createR2SyncCapability();
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tests `wrangler-deploy`/`wrangler-versions-promote` (#1293, epic #1296,
3
+ * ./wrangler.ts) against a `MockProcessRunner` — no live `wrangler`, no
4
+ * network call, ever. Asserts the constructed invocations, the version-id
5
+ * extraction, the wired output -> promote input handoff, and the
6
+ * captured-previous-version rollback (the "no hand-written compensation"
7
+ * claim from #1293's verification section).
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import {
12
+ createWranglerDeployCapability,
13
+ createWranglerVersionsPromoteCapability,
14
+ parseWranglerVersionId,
15
+ WranglerVersionIdNotFoundError,
16
+ } from "./wrangler";
17
+ import { ToolNotAvailableError } from "./process-runner";
18
+ import { createMockProcessRunner } from "./__tests__/mock-process-runner";
19
+
20
+ const ctx = { env: "prod", component: "api-worker" };
21
+ const VERSION_A = "07bcb198-9633-4172-a1f0-b09f6f1a1a11";
22
+ const VERSION_B = "aa11bb22-cc33-dd44-ee55-ff6677889900";
23
+
24
+ describe("wrangler-deploy", () => {
25
+ it("shells `wrangler deploy` with --config, extracts the Version ID from stdout", async () => {
26
+ const mock = createMockProcessRunner({
27
+ responses: { "wrangler deploy": `Uploaded api-worker (1.23 sec)\nVersion ID: ${VERSION_A}\n` },
28
+ });
29
+ const capability = createWranglerDeployCapability(mock.runner);
30
+
31
+ const output = await capability.run(ctx, { config: "api/wrangler.jsonc" });
32
+
33
+ expect(output).toEqual({ versionId: VERSION_A });
34
+ const deployCall = mock.calls.find((c) => c.command.startsWith("wrangler deploy"))!;
35
+ expect(deployCall.command).toBe(`wrangler deploy --config 'api/wrangler.jsonc'`);
36
+ });
37
+
38
+ it("passes --env through when supplied", async () => {
39
+ const mock = createMockProcessRunner({
40
+ responses: { "wrangler deploy": `Version ID: ${VERSION_A}\n` },
41
+ });
42
+ const capability = createWranglerDeployCapability(mock.runner);
43
+
44
+ await capability.run(ctx, { config: "api/wrangler.jsonc", env: "staging" });
45
+
46
+ const deployCall = mock.calls.find((c) => c.command.startsWith("wrangler deploy"))!;
47
+ expect(deployCall.command).toBe(`wrangler deploy --config 'api/wrangler.jsonc' --env 'staging'`);
48
+ });
49
+
50
+ it("throws WranglerVersionIdNotFoundError when stdout carries no Version ID", async () => {
51
+ const mock = createMockProcessRunner({ responses: { "wrangler deploy": "Uploaded, nothing else.\n" } });
52
+ const capability = createWranglerDeployCapability(mock.runner);
53
+
54
+ await expect(capability.run(ctx, { config: "api/wrangler.jsonc" })).rejects.toBeInstanceOf(
55
+ WranglerVersionIdNotFoundError,
56
+ );
57
+ });
58
+
59
+ it("throws ToolNotAvailableError when wrangler is absent, rather than silently no-op'ing", async () => {
60
+ const mock = createMockProcessRunner({ tools: { wrangler: false } });
61
+ const capability = createWranglerDeployCapability(mock.runner);
62
+
63
+ await expect(capability.run(ctx, { config: "api/wrangler.jsonc" })).rejects.toBeInstanceOf(
64
+ ToolNotAvailableError,
65
+ );
66
+ });
67
+
68
+ it("declares a rollback (native rollback disposition, no rollbackPolicy override needed)", () => {
69
+ const capability = createWranglerDeployCapability(createMockProcessRunner().runner);
70
+ expect(typeof capability.rollback).toBe("function");
71
+ expect(capability.rollbackPolicy).toBeUndefined();
72
+ });
73
+
74
+ it("rollback is a no-op on a first deploy — nothing was live before it", async () => {
75
+ const mock = createMockProcessRunner({
76
+ responses: { "versions list": "[]", "wrangler deploy": `Version ID: ${VERSION_A}\n` },
77
+ });
78
+ const capability = createWranglerDeployCapability(mock.runner);
79
+ await capability.run(ctx, { config: "api/wrangler.jsonc" });
80
+
81
+ await capability.rollback!(ctx, { config: "api/wrangler.jsonc" });
82
+
83
+ expect(mock.calls.some((c) => c.command.includes("versions deploy"))).toBe(false);
84
+ });
85
+
86
+ it("rollback promotes back to whichever version was live before this deploy — the wrangler-versions-promote back-to-prior-version claim, with no hand-written compensation", async () => {
87
+ const mock = createMockProcessRunner({
88
+ responses: {
89
+ "versions list": JSON.stringify([{ id: VERSION_A, percentage: 100 }]),
90
+ "wrangler deploy": `Version ID: ${VERSION_B}\n`,
91
+ },
92
+ });
93
+ const capability = createWranglerDeployCapability(mock.runner);
94
+ await capability.run(ctx, { config: "api/wrangler.jsonc" });
95
+
96
+ await capability.rollback!(ctx, { config: "api/wrangler.jsonc" });
97
+
98
+ const promoteCall = mock.calls.find((c) => c.command.startsWith("wrangler versions deploy"))!;
99
+ expect(promoteCall.command).toBe(
100
+ `wrangler versions deploy '${VERSION_A}@100' --config 'api/wrangler.jsonc' --yes`,
101
+ );
102
+ });
103
+ });
104
+
105
+ describe("wrangler-versions-promote", () => {
106
+ it("shells `wrangler versions deploy <id>@<pct> --yes`, defaulting percentage to 100", async () => {
107
+ const mock = createMockProcessRunner();
108
+ const capability = createWranglerVersionsPromoteCapability(mock.runner);
109
+
110
+ const output = await capability.run(ctx, { config: "api/wrangler.jsonc", versionId: VERSION_A });
111
+
112
+ expect(output).toEqual({ versionId: VERSION_A, percentage: 100 });
113
+ const promoteCall = mock.calls.find((c) => c.command.startsWith("wrangler versions deploy"))!;
114
+ expect(promoteCall.command).toBe(
115
+ `wrangler versions deploy '${VERSION_A}@100' --config 'api/wrangler.jsonc' --yes`,
116
+ );
117
+ });
118
+
119
+ it("consumes a wired wrangler-deploy output as its versionId input", async () => {
120
+ const mock = createMockProcessRunner({
121
+ responses: { "wrangler deploy": `Version ID: ${VERSION_A}\n` },
122
+ });
123
+ const deploy = createWranglerDeployCapability(mock.runner);
124
+ const promote = createWranglerVersionsPromoteCapability(mock.runner);
125
+
126
+ const { versionId } = await deploy.run(ctx, { config: "api/wrangler.jsonc" });
127
+ const promoted = await promote.run(ctx, { config: "api/wrangler.jsonc", versionId, percentage: 10 });
128
+
129
+ expect(promoted).toEqual({ versionId: VERSION_A, percentage: 10 });
130
+ const promoteCall = mock.calls.find((c) => c.command.startsWith("wrangler versions deploy"))!;
131
+ expect(promoteCall.command).toContain(`'${VERSION_A}@10'`);
132
+ });
133
+
134
+ it("declares a rollback (native): re-promotes to whichever version was live before this promote call", async () => {
135
+ const mock = createMockProcessRunner({
136
+ responses: { "versions list": JSON.stringify([{ id: VERSION_A, percentage: 100 }]) },
137
+ });
138
+ const capability = createWranglerVersionsPromoteCapability(mock.runner);
139
+ await capability.run(ctx, { config: "api/wrangler.jsonc", versionId: VERSION_B });
140
+
141
+ await capability.rollback!(ctx, { config: "api/wrangler.jsonc", versionId: VERSION_B });
142
+
143
+ const calls = mock.calls.filter((c) => c.command.startsWith("wrangler versions deploy"));
144
+ expect(calls).toHaveLength(2);
145
+ expect(calls[0]!.command).toContain(`'${VERSION_B}@100'`);
146
+ expect(calls[1]!.command).toContain(`'${VERSION_A}@100'`);
147
+ });
148
+
149
+ it("throws ToolNotAvailableError when wrangler is absent", async () => {
150
+ const mock = createMockProcessRunner({ tools: { wrangler: false } });
151
+ const capability = createWranglerVersionsPromoteCapability(mock.runner);
152
+
153
+ await expect(
154
+ capability.run(ctx, { config: "api/wrangler.jsonc", versionId: VERSION_A }),
155
+ ).rejects.toBeInstanceOf(ToolNotAvailableError);
156
+ });
157
+ });
158
+
159
+ describe("parseWranglerVersionId", () => {
160
+ it("accepts both 'Version ID:' and bare 'Version:' wording", () => {
161
+ expect(parseWranglerVersionId(`Version ID: ${VERSION_A}`)).toBe(VERSION_A);
162
+ expect(parseWranglerVersionId(`Version: ${VERSION_A}`)).toBe(VERSION_A);
163
+ });
164
+
165
+ it("throws on stdout with no UUID-shaped version token", () => {
166
+ expect(() => parseWranglerVersionId("Version: 2 (a plain counter, not a version id)")).toThrow(
167
+ WranglerVersionIdNotFoundError,
168
+ );
169
+ });
170
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * `wrangler-deploy` / `wrangler-versions-promote` — the Cloudflare Workers
3
+ * apply leaves (chant #1293, epic #1296). The epic cedes the Workers plane
4
+ * entirely to `wrangler` (first-party, schema-backed, already owns the
5
+ * config format, bindings model, local dev, and deploy) — these two verbs
6
+ * are a typed wrapper over that CLI, not a reimplementation of it.
7
+ *
8
+ * Placement: core, not a cloudflare lexicon. #1293's own analysis weighed
9
+ * three options — core (generic verb, fastest, costs a little conceptual
10
+ * tidiness since `wrangler-deploy` shells a specific vendor CLI), a
11
+ * capability-only cloudflare lexicon (the "correct" home, but `LexiconPlugin`
12
+ * tier-1 completeness — serializer, lint rules, post-synth, LSP, examples,
13
+ * docs — is heavy ceremony for three verbs and no resource types), or
14
+ * generalizing to a `cli-deploy` verb parameterised by tool (tempting, but
15
+ * premature and it re-opens the verb set the bounded-primitives thesis
16
+ * depends on staying closed). Core wins on the same precedent `sign`/
17
+ * `attest-provenance` (./sign.ts) already set: a specific vendor CLI
18
+ * (`cosign`) shelled out through the injectable `ProcessRunner`
19
+ * (./process-runner.ts), no cloud-specific SDK client needed the way AWS's
20
+ * `CloudExecutor` (./cloud-executor.ts) models CloudFormation/ECS/Lambda.
21
+ * `wrangler` fits that exact shape. Revisit if/when the zone-plane lexicon
22
+ * (#1294) lands and gives these three verbs a natural cloudflare-owned home.
23
+ *
24
+ * `wrangler-versions-promote` is also the direct evidence for #1296's
25
+ * "three new verbs cover a whole new cloud" claim: Cloudflare's native
26
+ * Worker version rollback maps onto the existing `RollbackPolicy: "native"`
27
+ * (../capability.ts) with no compensation code to hand-write — both verbs
28
+ * here declare a `rollback` (auto-derived "native" per ../capability.ts's
29
+ * `Capability.rollbackPolicy` doc) that re-promotes to whichever version was
30
+ * live before the step ran, the same best-effort captured-previous-state
31
+ * pattern lexicons/aws/src/components/apply.ts's `lambda-deploy` uses for
32
+ * its alias rollback.
33
+ *
34
+ * No real Cloudflare control-plane emulator exists yet (#1295, epic #1296),
35
+ * so — like ./sign.ts — every real path here shells out through the
36
+ * injectable `ProcessRunner` and every test substitutes `MockProcessRunner`
37
+ * (./__tests__/mock-process-runner.ts): no live `wrangler`, no network, ever,
38
+ * in a test run. CI coverage is plan-shape/invocation-shape assertions only,
39
+ * per #1293's own "Verification" section.
40
+ */
41
+
42
+ import type { Capability } from "../capability";
43
+ import { defaultProcessRunner, q, requireTool, type ProcessRunner } from "./process-runner";
44
+
45
+ const WRANGLER_TOOL = "wrangler";
46
+
47
+ /** Distinguishes one deploy target (a wrangler config + optional named environment) from another, so the best-effort previous-version tracking below never conflates two different Workers sharing a process. */
48
+ function targetKey(config: string, env?: string): string {
49
+ return env ? `${config}#${env}` : config;
50
+ }
51
+
52
+ function buildWranglerDeployArgs(input: WranglerDeployInput): string {
53
+ const args = ["wrangler", "deploy", "--config", q(input.config)];
54
+ if (input.env) args.push("--env", q(input.env));
55
+ return args.join(" ");
56
+ }
57
+
58
+ function buildVersionsListArgs(config: string, env?: string): string {
59
+ const args = ["wrangler", "versions", "list", "--config", q(config), "--json"];
60
+ if (env) args.push("--env", q(env));
61
+ return args.join(" ");
62
+ }
63
+
64
+ function buildVersionsPromoteArgs(config: string, versionId: string, percentage: number, env?: string): string {
65
+ const args = ["wrangler", "versions", "deploy", q(`${versionId}@${percentage}`), "--config", q(config), "--yes"];
66
+ if (env) args.push("--env", q(env));
67
+ return args.join(" ");
68
+ }
69
+
70
+ /** Thrown when `wrangler deploy`'s stdout carries no parseable Version ID — fail-closed rather than returning a capability output downstream steps (`wrangler-versions-promote`, wired via `"@Deploy.versionId"`) would silently receive as `undefined`. */
71
+ export class WranglerVersionIdNotFoundError extends Error {
72
+ constructor(public readonly stdout: string) {
73
+ super(`wrangler-deploy: could not find a Version ID in wrangler's output. Got:\n${stdout}`);
74
+ this.name = "WranglerVersionIdNotFoundError";
75
+ }
76
+ }
77
+
78
+ // wrangler's deploy/versions-upload output names the new version as e.g.
79
+ // "Version ID: 07bcb198-... " (gradual deployments) — accept either "Version
80
+ // ID:" or the bare "Version:" wording across wrangler versions, and require a
81
+ // UUID-shaped token so a stray "Version: 2" summary line can't be mistaken
82
+ // for it.
83
+ const VERSION_ID_RE = /Version(?: ID)?:\s*([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/;
84
+
85
+ /** Extract the deployed Worker version id from `wrangler deploy`'s stdout. Exported so tests can assert on it directly, mirroring ./sign.ts's `buildSignArgs`. */
86
+ export function parseWranglerVersionId(stdout: string): string {
87
+ const match = VERSION_ID_RE.exec(stdout);
88
+ if (!match) throw new WranglerVersionIdNotFoundError(stdout);
89
+ return match[1]!;
90
+ }
91
+
92
+ interface WranglerVersionsListEntry {
93
+ id: string;
94
+ percentage?: number;
95
+ }
96
+
97
+ /**
98
+ * Best-effort: the currently-live (100%) version id, from `wrangler versions
99
+ * list --json` — `undefined` for a first deploy (no versions yet) or if
100
+ * listing fails/parses oddly. Never throws: capturing "what to roll back to"
101
+ * must not block the deploy/promote itself, the same fail-soft stance
102
+ * `lambda-deploy`'s alias-version capture takes.
103
+ */
104
+ async function currentLiveVersionId(
105
+ runner: ProcessRunner,
106
+ config: string,
107
+ env?: string,
108
+ ): Promise<string | undefined> {
109
+ try {
110
+ const { stdout } = await runner.run(buildVersionsListArgs(config, env));
111
+ const entries = JSON.parse(stdout) as WranglerVersionsListEntry[];
112
+ return entries.find((entry) => entry.percentage === 100)?.id;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ // ── wrangler-deploy ──────────────────────────────────────────────────────────
119
+
120
+ export interface WranglerDeployInput {
121
+ /** Path to the Worker's `wrangler.jsonc`/`wrangler.toml`. */
122
+ config: string;
123
+ /** Named environment within the config (`wrangler deploy --env <env>`). */
124
+ env?: string;
125
+ }
126
+
127
+ export interface WranglerDeployOutput {
128
+ /** The version id wrangler assigned this deploy — wire into `wrangler-versions-promote` (e.g. `"@Deploy.versionId"`). */
129
+ versionId: string;
130
+ }
131
+
132
+ /**
133
+ * Deploy a Worker from a `wrangler.jsonc`/`wrangler.toml` via `wrangler
134
+ * deploy`, and return the version id it published — the analogue of
135
+ * `lambda-deploy` (../../lexicons/aws/src/components/apply.ts) for the
136
+ * Workers plane.
137
+ *
138
+ * Rollback: `wrangler-versions-promote` back to whichever version was live
139
+ * before this step ran (captured up front via `wrangler versions list`, the
140
+ * same best-effort captured-previous pattern `lambda-deploy` uses for its
141
+ * alias). A no-op on a first deploy — nothing was live to restore.
142
+ */
143
+ export function createWranglerDeployCapability(
144
+ processRunner: ProcessRunner = defaultProcessRunner(),
145
+ ): Capability<WranglerDeployInput, WranglerDeployOutput> {
146
+ const previousVersionByTarget = new Map<string, string | undefined>();
147
+
148
+ return {
149
+ kind: "wrangler-deploy",
150
+ async run(_ctx, input) {
151
+ await requireTool(processRunner, WRANGLER_TOOL, `deploy the Worker in ${input.config}`);
152
+ const target = targetKey(input.config, input.env);
153
+ if (!previousVersionByTarget.has(target)) {
154
+ previousVersionByTarget.set(target, await currentLiveVersionId(processRunner, input.config, input.env));
155
+ }
156
+
157
+ const { stdout } = await processRunner.run(buildWranglerDeployArgs(input));
158
+ return { versionId: parseWranglerVersionId(stdout) };
159
+ },
160
+ async rollback(_ctx, input) {
161
+ const target = targetKey(input.config, input.env);
162
+ const previousVersionId = previousVersionByTarget.get(target);
163
+ if (!previousVersionId) return; // first deploy — nothing was live before it.
164
+ await requireTool(processRunner, WRANGLER_TOOL, `roll back the Worker in ${input.config}`);
165
+ await processRunner.run(buildVersionsPromoteArgs(input.config, previousVersionId, 100, input.env));
166
+ },
167
+ };
168
+ }
169
+
170
+ /** Default `wrangler-deploy` capability, backed by the real `ProcessRunner`. */
171
+ export const wranglerDeployCapability: Capability<WranglerDeployInput, WranglerDeployOutput> =
172
+ createWranglerDeployCapability();
173
+
174
+ // ── wrangler-versions-promote ─────────────────────────────────────────────────
175
+
176
+ export interface WranglerVersionsPromoteInput {
177
+ /** Path to the Worker's `wrangler.jsonc`/`wrangler.toml`. */
178
+ config: string;
179
+ /** Version id to promote — typically wired from a prior `wrangler-deploy` step (`"@Deploy.versionId"`) or a prior version id when this step composes as an explicit rollback. */
180
+ versionId: string;
181
+ /** Traffic percentage to route to `versionId`. Default: 100 (full promote/rollback). Below 100 is the gradual-deployment lever. */
182
+ percentage?: number;
183
+ /** Named environment within the config (`wrangler versions deploy --env <env>`). */
184
+ env?: string;
185
+ }
186
+
187
+ export interface WranglerVersionsPromoteOutput {
188
+ /** The version id that was promoted. */
189
+ versionId: string;
190
+ /** The traffic percentage actually routed to it. */
191
+ percentage: number;
192
+ }
193
+
194
+ /**
195
+ * Promote a Worker version to (some percentage of) live traffic via
196
+ * `wrangler versions deploy <version-id>@<percentage> --yes` — both the
197
+ * gradual-deployment lever (`percentage` < 100) and, at `percentage: 100`,
198
+ * the rollback mechanism: Cloudflare's native version rollback is a promote
199
+ * to a prior version id, not a redeploy, so this same verb composes as the
200
+ * explicit compensation step a component wires up (#1293's "verification"
201
+ * example: forced failure after `wrangler-deploy` -> `wrangler-versions-promote`
202
+ * back to the prior version, no hand-written compensation needed).
203
+ *
204
+ * Also declares its own `rollback` (native, no `rollbackPolicy` override
205
+ * needed — see ../capability.ts): re-promotes to whichever version was live
206
+ * before *this* promote call, for the case where the promote step itself is
207
+ * composed directly (not just as `wrangler-deploy`'s compensation).
208
+ */
209
+ export function createWranglerVersionsPromoteCapability(
210
+ processRunner: ProcessRunner = defaultProcessRunner(),
211
+ ): Capability<WranglerVersionsPromoteInput, WranglerVersionsPromoteOutput> {
212
+ const previousVersionByTarget = new Map<string, string | undefined>();
213
+
214
+ return {
215
+ kind: "wrangler-versions-promote",
216
+ async run(_ctx, input) {
217
+ await requireTool(processRunner, WRANGLER_TOOL, `promote version ${input.versionId} in ${input.config}`);
218
+ const percentage = input.percentage ?? 100;
219
+ const target = targetKey(input.config, input.env);
220
+ if (!previousVersionByTarget.has(target)) {
221
+ previousVersionByTarget.set(target, await currentLiveVersionId(processRunner, input.config, input.env));
222
+ }
223
+
224
+ await processRunner.run(buildVersionsPromoteArgs(input.config, input.versionId, percentage, input.env));
225
+ return { versionId: input.versionId, percentage };
226
+ },
227
+ async rollback(_ctx, input) {
228
+ const target = targetKey(input.config, input.env);
229
+ const previousVersionId = previousVersionByTarget.get(target);
230
+ if (!previousVersionId) return; // nothing was live before this promote.
231
+ await requireTool(processRunner, WRANGLER_TOOL, `roll back version promotion in ${input.config}`);
232
+ await processRunner.run(buildVersionsPromoteArgs(input.config, previousVersionId, 100, input.env));
233
+ },
234
+ };
235
+ }
236
+
237
+ /** Default `wrangler-versions-promote` capability, backed by the real `ProcessRunner`. */
238
+ export const wranglerVersionsPromoteCapability: Capability<
239
+ WranglerVersionsPromoteInput,
240
+ WranglerVersionsPromoteOutput
241
+ > = createWranglerVersionsPromoteCapability();
@@ -6,6 +6,7 @@ import {
6
6
  resolveAutoReleaseDisabled,
7
7
  resolveFoldEnabled,
8
8
  resolveSbomFormat,
9
+ resolveKnowledgeDir,
9
10
  environmentName,
10
11
  environmentNames,
11
12
  environmentEndpoint,
@@ -433,3 +434,17 @@ describe("resolveSbomFormat (#606)", () => {
433
434
  expect(resolveSbomFormat({}, "cyclonedx")).toBe("cyclonedx");
434
435
  });
435
436
  });
437
+
438
+ describe("resolveKnowledgeDir (#1864, design #1059)", () => {
439
+ test("convention: knowledge/ beside the project root when config is silent", () => {
440
+ expect(resolveKnowledgeDir({}, "/proj")).toBe(join("/proj", "knowledge"));
441
+ });
442
+
443
+ test("config override honored", () => {
444
+ expect(resolveKnowledgeDir({ knowledge: { dir: "docs/knowledge" } }, "/proj")).toBe(join("/proj", "docs/knowledge"));
445
+ });
446
+
447
+ test("an empty knowledge object still falls back to the convention name", () => {
448
+ expect(resolveKnowledgeDir({ knowledge: {} }, "/proj")).toBe(join("/proj", "knowledge"));
449
+ });
450
+ });
package/src/config.ts CHANGED
@@ -181,6 +181,9 @@ export const ChantConfigSchema = z.object({
181
181
  scanner: z.enum(["grype", "trivy"]).optional(),
182
182
  vexSources: z.array(z.string()).optional(),
183
183
  }).optional(),
184
+ knowledge: z.object({
185
+ dir: z.string().min(1).optional(),
186
+ }).optional(),
184
187
  }).passthrough();
185
188
 
186
189
  /**
@@ -400,6 +403,21 @@ export interface ChantConfig {
400
403
  /** Default VEX document paths (OpenVEX/CycloneDX) applied to every gate. Read where the gate step is composed. */
401
404
  vexSources?: string[];
402
405
  };
406
+
407
+ /**
408
+ * OKF knowledge bundle location (#1864, design #1059, epic #1057) — the
409
+ * *input* side of `chant explain --format okf` (#1058): a project may
410
+ * author knowledge as an OKF v0.2 bundle (a directory of markdown files
411
+ * with YAML frontmatter) that `./okf-read.ts`'s `loadOkfBundle` reads and
412
+ * binds to discovered entities via each concept's `binds` frontmatter key.
413
+ * Convention-first: `knowledge/` beside `chant.config.ts` is used when this
414
+ * is unset. Set `dir` only when that name is already taken by something
415
+ * else in the project. See {@link resolveKnowledgeDir}.
416
+ */
417
+ knowledge?: {
418
+ /** Bundle directory, relative to the project root. Defaults to `"knowledge"`. */
419
+ dir?: string;
420
+ };
403
421
  }
404
422
 
405
423
  /**
@@ -783,6 +801,18 @@ export function resolveVulnPolicy(config: ChantConfig): Partial<VulnPolicy> {
783
801
  return out;
784
802
  }
785
803
 
804
+ /**
805
+ * Resolve the OKF knowledge bundle directory (#1864, design #1059):
806
+ * `knowledge.dir` relative to `projectPath` when configured, else the
807
+ * `knowledge/` convention beside `chant.config.ts`. Never checks existence —
808
+ * a project with no bundle yet resolves a path all the same, and
809
+ * `okf-read.ts`'s `loadOkfBundle` treats a missing directory as an empty
810
+ * bundle rather than an error.
811
+ */
812
+ export function resolveKnowledgeDir(config: ChantConfig, projectPath: string): string {
813
+ return join(projectPath, config.knowledge?.dir ?? "knowledge");
814
+ }
815
+
786
816
  /**
787
817
  * Validate and normalize a raw config object into ChantConfig shape.
788
818
  */
@@ -48,6 +48,25 @@ describe("normalizeDeepProperties", () => {
48
48
  expect(Object.keys(out.mid as Record<string, unknown>)).toEqual(["a", "z"]);
49
49
  });
50
50
 
51
+ test("the mask hook collapses a structural secret path to MASKED, on either side (#1830)", () => {
52
+ const hooks: DeepNormalizationHooks = {
53
+ mask: (n) => n.entityType === "K8s::Core::Secret" && n.pattern.startsWith("data."),
54
+ };
55
+ for (const side of ["declared", "live"] as const) {
56
+ const out = normalizeDeepProperties(
57
+ { data: { "app.conf": "c2VjcmV0LWJ5dGVz" }, type: "Opaque" },
58
+ { entityType: "K8s::Core::Secret", side, hooks },
59
+ );
60
+ expect(out).toEqual({ data: { "app.conf": MASKED }, type: "Opaque" });
61
+ }
62
+ // Another entity type sails through the same hook untouched.
63
+ const other = normalizeDeepProperties(
64
+ { data: { "app.conf": "plain" } },
65
+ { entityType: "K8s::Core::ConfigMap", side: "live", hooks },
66
+ );
67
+ expect(other).toEqual({ data: { "app.conf": "plain" } });
68
+ });
69
+
51
70
  test("leaves array order alone with no ordering hook", () => {
52
71
  const out = normalizeDeepProperties({ Tags: [{ Key: "z" }, { Key: "a" }] }, { entityType: "T", side: "live" });
53
72
  expect(out.Tags).toEqual([{ Key: "z" }, { Key: "a" }]);
@@ -266,6 +266,20 @@ export interface DeepNormalizationHooks {
266
266
  * reads as permanent drift.
267
267
  */
268
268
  unresolved?(node: DeepNode): boolean;
269
+ /**
270
+ * Return true when this value is secret material the diff must never hold —
271
+ * it is collapsed to {@link MASKED} on BOTH sides (the hook runs over the
272
+ * declared and the live tree alike), so presence and key names still
273
+ * classify while values never reach a diff row, a log line, or a snapshot.
274
+ *
275
+ * The pass masks by key name on its own ({@link isSensitiveKey}); this hook
276
+ * exists for the lexicons whose secret-bearing paths are structural rather
277
+ * than name-shaped — a Kubernetes Secret's `data` carries arbitrary key
278
+ * names (`app.conf`), and #1365 decision 6 draws the hard line: drift on a
279
+ * secret observes presence, declared key-set, and metadata — never a value
280
+ * or a value-derived hash.
281
+ */
282
+ mask?(node: DeepNode): boolean;
269
283
  }
270
284
 
271
285
  /** Everything the pass needs besides the tree itself. */
@@ -405,6 +419,9 @@ export function normalizeDeepProperties(
405
419
 
406
420
  const normalizeValue = (value: unknown, path: string, pattern: string, key: string): unknown => {
407
421
  if (isSensitiveKey(key)) return MASKED;
422
+ // The lexicon's structural mask — same collapse, path-shaped rather than
423
+ // key-named (a k8s Secret's `data.*` no matter what the key is called).
424
+ if (hooks?.mask?.(nodeOf(path, pattern, key, value))) return MASKED;
408
425
  // A value the lexicon says cannot be known without deploying — an
409
426
  // expression-string reference — collapses exactly like a class-instance
410
427
  // intrinsic does below.