@intentius/chant 0.45.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 (223) 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/check-lexicon.d.ts +14 -0
  10. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  11. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  12. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts.map +1 -1
  14. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  15. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  16. package/dist/cli/handlers/search.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  19. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  20. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  21. package/dist/cli/plugins.d.ts +1 -1
  22. package/dist/cli/plugins.d.ts.map +1 -1
  23. package/dist/cli/registry.d.ts +7 -0
  24. package/dist/cli/registry.d.ts.map +1 -1
  25. package/dist/cli/reporters/stylish.d.ts +15 -1
  26. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  27. package/dist/codegen/lexicon-regen.d.ts +11 -0
  28. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  29. package/dist/codegen/validate.d.ts +10 -0
  30. package/dist/codegen/validate.d.ts.map +1 -1
  31. package/dist/components/auto-release.d.ts +4 -0
  32. package/dist/components/auto-release.d.ts.map +1 -1
  33. package/dist/components/starter-plugin.d.ts +2 -0
  34. package/dist/components/starter-plugin.d.ts.map +1 -1
  35. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  36. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  37. package/dist/components/verbs/index.d.ts +8 -0
  38. package/dist/components/verbs/index.d.ts.map +1 -1
  39. package/dist/components/verbs/r2-sync.d.ts +76 -0
  40. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  41. package/dist/components/verbs/wrangler.d.ts +108 -0
  42. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  43. package/dist/config.d.ts +54 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/deep-observation.d.ts +14 -0
  46. package/dist/deep-observation.d.ts.map +1 -1
  47. package/dist/effect-receipt.d.ts +177 -0
  48. package/dist/effect-receipt.d.ts.map +1 -0
  49. package/dist/env.d.ts +12 -1
  50. package/dist/env.d.ts.map +1 -1
  51. package/dist/fold/subset.d.ts +15 -2
  52. package/dist/fold/subset.d.ts.map +1 -1
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/lexicon.d.ts +223 -2
  56. package/dist/lexicon.d.ts.map +1 -1
  57. package/dist/lifecycle/change-set.d.ts +33 -5
  58. package/dist/lifecycle/change-set.d.ts.map +1 -1
  59. package/dist/lifecycle/index.d.ts +3 -0
  60. package/dist/lifecycle/index.d.ts.map +1 -1
  61. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  62. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  63. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  64. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +20 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lifecycle/teardown.d.ts +132 -0
  68. package/dist/lifecycle/teardown.d.ts.map +1 -0
  69. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  70. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  71. package/dist/lint/engine.d.ts +6 -2
  72. package/dist/lint/engine.d.ts.map +1 -1
  73. package/dist/lint/knowledge-checks.d.ts +48 -0
  74. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  75. package/dist/lint/output-checks.d.ts +5 -0
  76. package/dist/lint/output-checks.d.ts.map +1 -0
  77. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  78. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  79. package/dist/lint/post-synth.d.ts +12 -0
  80. package/dist/lint/post-synth.d.ts.map +1 -1
  81. package/dist/lint/receipt-checks.d.ts +9 -0
  82. package/dist/lint/receipt-checks.d.ts.map +1 -0
  83. package/dist/lint/rule.d.ts +31 -0
  84. package/dist/lint/rule.d.ts.map +1 -1
  85. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  86. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  87. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  88. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  89. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  90. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  91. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  92. package/dist/lint/rules/index.d.ts +4 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/okf-read.d.ts +78 -0
  95. package/dist/okf-read.d.ts.map +1 -0
  96. package/dist/op/builders.d.ts +134 -8
  97. package/dist/op/builders.d.ts.map +1 -1
  98. package/dist/op/index.d.ts +4 -2
  99. package/dist/op/index.d.ts.map +1 -1
  100. package/dist/op/local-executor.d.ts +2 -1
  101. package/dist/op/local-executor.d.ts.map +1 -1
  102. package/dist/op/receipt-store.d.ts +138 -0
  103. package/dist/op/receipt-store.d.ts.map +1 -0
  104. package/dist/op/types.d.ts +31 -1
  105. package/dist/op/types.d.ts.map +1 -1
  106. package/dist/secret-materialization.d.ts +138 -0
  107. package/dist/secret-materialization.d.ts.map +1 -0
  108. package/dist/secret-provenance.d.ts +218 -0
  109. package/dist/secret-provenance.d.ts.map +1 -0
  110. package/dist/serializer.d.ts +11 -0
  111. package/dist/serializer.d.ts.map +1 -1
  112. package/dist/testing.d.ts +136 -0
  113. package/dist/testing.d.ts.map +1 -0
  114. package/dist/yaml.d.ts.map +1 -1
  115. package/package.json +9 -1
  116. package/src/audit/core.test.ts +57 -0
  117. package/src/audit/core.ts +0 -0
  118. package/src/audit/detect-bundle.test.ts +1 -1
  119. package/src/audit/discover.test.ts +24 -0
  120. package/src/audit/discover.ts +11 -2
  121. package/src/audit/rules-doc.ts +11 -1
  122. package/src/build.test.ts +41 -0
  123. package/src/build.ts +34 -6
  124. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  125. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  126. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  127. package/src/cli/commands/audit.test.ts +53 -0
  128. package/src/cli/commands/audit.ts +1 -1
  129. package/src/cli/commands/build.test.ts +211 -0
  130. package/src/cli/commands/build.ts +126 -8
  131. package/src/cli/commands/check-lexicon.test.ts +45 -1
  132. package/src/cli/commands/check-lexicon.ts +45 -0
  133. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  134. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  135. package/src/cli/commands/lint.ts +32 -7
  136. package/src/cli/handlers/explain.test.ts +70 -1
  137. package/src/cli/handlers/graph.ts +4 -4
  138. package/src/cli/handlers/lifecycle.test.ts +345 -1
  139. package/src/cli/handlers/lifecycle.ts +300 -11
  140. package/src/cli/handlers/search.ts +5 -2
  141. package/src/cli/main.ts +12 -1
  142. package/src/cli/mcp/resource-handlers.ts +38 -1
  143. package/src/cli/mcp/server.test.ts +58 -1
  144. package/src/cli/mcp/tools/explain.ts +51 -2
  145. package/src/cli/plugins.ts +4 -2
  146. package/src/cli/registry.ts +7 -0
  147. package/src/cli/reporters/stylish.test.ts +154 -0
  148. package/src/cli/reporters/stylish.ts +154 -33
  149. package/src/codegen/lexicon-regen.ts +19 -1
  150. package/src/codegen/validate.test.ts +33 -0
  151. package/src/codegen/validate.ts +21 -2
  152. package/src/components/auto-release.ts +6 -0
  153. package/src/components/registry.test.ts +7 -2
  154. package/src/components/starter-plugin.ts +17 -0
  155. package/src/components/verbs/ensure-secret.test.ts +130 -0
  156. package/src/components/verbs/ensure-secret.ts +79 -0
  157. package/src/components/verbs/index.ts +8 -0
  158. package/src/components/verbs/r2-sync.test.ts +107 -0
  159. package/src/components/verbs/r2-sync.ts +124 -0
  160. package/src/components/verbs/wrangler.test.ts +170 -0
  161. package/src/components/verbs/wrangler.ts +241 -0
  162. package/src/config.test.ts +55 -0
  163. package/src/config.ts +88 -1
  164. package/src/deep-observation.test.ts +19 -0
  165. package/src/deep-observation.ts +17 -0
  166. package/src/effect-receipt-exclusion.test.ts +190 -0
  167. package/src/effect-receipt.test.ts +419 -0
  168. package/src/effect-receipt.ts +412 -0
  169. package/src/env.test.ts +35 -1
  170. package/src/env.ts +17 -3
  171. package/src/fold/subset.test.ts +26 -0
  172. package/src/fold/subset.ts +45 -19
  173. package/src/index.ts +4 -0
  174. package/src/lexicon.ts +227 -2
  175. package/src/lifecycle/change-set.ts +46 -7
  176. package/src/lifecycle/index.ts +3 -0
  177. package/src/lifecycle/observation-baseline.test.ts +46 -0
  178. package/src/lifecycle/observation-baseline.ts +33 -1
  179. package/src/lifecycle/receipt-plan.test.ts +250 -0
  180. package/src/lifecycle/receipt-plan.ts +249 -0
  181. package/src/lifecycle/release-ledger.ts +20 -0
  182. package/src/lifecycle/teardown.test.ts +568 -0
  183. package/src/lifecycle/teardown.ts +359 -0
  184. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  185. package/src/lifecycle/unobserved-gate.ts +102 -0
  186. package/src/lint/engine.ts +7 -1
  187. package/src/lint/knowledge-checks.test.ts +80 -0
  188. package/src/lint/knowledge-checks.ts +74 -0
  189. package/src/lint/output-checks.test.ts +85 -0
  190. package/src/lint/output-checks.ts +99 -0
  191. package/src/lint/pipeline-change-gate.test.ts +144 -0
  192. package/src/lint/pipeline-change-gate.ts +153 -0
  193. package/src/lint/post-synth.ts +15 -0
  194. package/src/lint/receipt-checks.test.ts +101 -0
  195. package/src/lint/receipt-checks.ts +93 -0
  196. package/src/lint/rule.ts +23 -0
  197. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  198. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  199. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  200. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  201. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  202. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  203. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  204. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  205. package/src/lint/rules/index.ts +10 -1
  206. package/src/okf-read.test.ts +149 -0
  207. package/src/okf-read.ts +197 -0
  208. package/src/op/builders.ts +179 -8
  209. package/src/op/effect-step.test.ts +311 -0
  210. package/src/op/index.ts +10 -3
  211. package/src/op/local-executor.ts +172 -25
  212. package/src/op/op.test.ts +25 -2
  213. package/src/op/receipt-store.ts +211 -0
  214. package/src/op/types.ts +33 -1
  215. package/src/secret-materialization.test.ts +199 -0
  216. package/src/secret-materialization.ts +235 -0
  217. package/src/secret-provenance.test.ts +388 -0
  218. package/src/secret-provenance.ts +475 -0
  219. package/src/serializer.ts +12 -0
  220. package/src/testing.test.ts +261 -0
  221. package/src/testing.ts +338 -0
  222. package/src/yaml.test.ts +88 -0
  223. package/src/yaml.ts +76 -6
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Effect receipts (chant #1831, epic #1703).
3
+ *
4
+ * A RECEIPT is the declared witness that an out-of-band effect (a migration,
5
+ * a seed job, a one-shot bootstrap) has run — declared, diffed, and observed
6
+ * like any resource, but observe-only to the generic apply path: the
7
+ * `effect()` step (#1834) is the sole writer, on success, last. Anything
8
+ * else silently converts at-least-once into never (epic decision log, item
9
+ * 3). That write-exclusion is #1832's enforcement; what THIS module provides
10
+ * is the recognition marker that makes it possible: core code (lint, plan,
11
+ * apply) identifies receipts via {@link EFFECT_RECEIPT_MARKER} without any
12
+ * lexicon knowledge, while per-lexicon rows (#1835's `AWS::SSM::Parameter`)
13
+ * materialize them as real resources. Unlike a secret provenance declaration
14
+ * (./secret-provenance.ts), a receipt DOES serialize — the marker identifies,
15
+ * it does not exclude.
16
+ *
17
+ * The resolution split (epic decision log, item 5):
18
+ *
19
+ * - STATIC inputs hash at synthesis, in the serializer — a fully static
20
+ * receipt's expectation is already known when the template is written
21
+ * ({@link receiptExpectation}).
22
+ * - REFERENCE inputs (attr-refs and other intrinsics — deploy-time values)
23
+ * are recorded in placeholder form at synthesis and resolve in the plan
24
+ * engine (#1832) and again in the effect step (#1834), via
25
+ * {@link resolveReceiptExpectation}. Synthesis resolves NOTHING:
26
+ * {@link receiptExpectation} refuses a hash-flavor receipt that still
27
+ * carries references rather than hashing a placeholder.
28
+ * - Build-time `params.*` are not references here: they fold to literals
29
+ * before discovery ever runs (../params.ts), so by the time this factory
30
+ * sees them they are static.
31
+ *
32
+ * Nothing in this module reads live state. Every function is pure over the
33
+ * declaration and, for resolution, the caller-supplied resolver.
34
+ *
35
+ * Hashing is JCS-style canonical JSON (RFC 8785 shape: sorted keys, standard
36
+ * ECMAScript number/string encoding — implemented minimally here, no
37
+ * dependency) digested with sha256 ({@link canonicalJson},
38
+ * `sha256:<hex>` like the build-ledger digests in ./lifecycle/build-ledger.ts).
39
+ */
40
+
41
+ import { createHash } from "node:crypto";
42
+ import { DECLARABLE_MARKER, type Declarable } from "./declarable";
43
+ import { isIntrinsic, type Intrinsic } from "./intrinsic";
44
+
45
+ /** The closed union of receipt flavors. */
46
+ export type EffectReceiptFlavor = "existence" | "hash";
47
+
48
+ /** Every receipt flavor, for exhaustiveness checks. */
49
+ export const EFFECT_RECEIPT_FLAVORS: readonly EffectReceiptFlavor[] = ["existence", "hash"];
50
+
51
+ /** Marker symbol identifying an effect receipt. `Symbol.for` so it survives
52
+ * the entity-wire codec (./discovery/entity-wire-codec.ts) and so a lexicon
53
+ * row can stamp the SAME symbol on its materialized resource — core's lint
54
+ * (#1833), plan (#1832), and apply write-exclusion recognize receipts through
55
+ * this marker alone, lexicon-independently. */
56
+ export const EFFECT_RECEIPT_MARKER = Symbol.for("chant.effect-receipt");
57
+
58
+ /** The `entityType` the core factory stamps. Lexicon materialization rows
59
+ * (#1835) use their own entityType and carry the marker instead. */
60
+ export const EFFECT_RECEIPT_ENTITY_TYPE = "Chant::EffectReceipt";
61
+
62
+ /**
63
+ * The expected stored value of an `existence`-flavor receipt: a fixed marker
64
+ * constant, the same for every existence receipt. Present-and-equal means the
65
+ * effect has run; anything else renders an effect-will-fire row (#1832).
66
+ */
67
+ export const EXISTENCE_EXPECTATION = "chant.effect-receipt:exists";
68
+
69
+ /**
70
+ * An effect receipt declaration — a Declarable, so discovery collects it,
71
+ * `chant list` shows it, and (unlike a secret declaration) serialization
72
+ * keeps it: a lexicon row turns it into a real, observable resource.
73
+ */
74
+ export interface EffectReceiptDeclaration extends Declarable {
75
+ readonly [EFFECT_RECEIPT_MARKER]: true;
76
+ /** `"chant"` for the plain factory; a lexicon row (#1835) declares its own. */
77
+ readonly lexicon: string;
78
+ /** The core factory stamps EFFECT_RECEIPT_ENTITY_TYPE; a lexicon row uses its materialized kind. */
79
+ readonly entityType: string;
80
+ /** The receipt's own name (the export-level identity of the witness). */
81
+ readonly name: string;
82
+ /** The effect this receipt witnesses — the identity the `effect()` step
83
+ * (#1834) and the receipt path (#1835) key on. */
84
+ readonly effect: string;
85
+ /** How the receipt is compared: mere presence, or a digest of the inputs. */
86
+ readonly flavor: EffectReceiptFlavor;
87
+ /**
88
+ * The effect's inputs as recorded at synthesis: static values verbatim,
89
+ * references (intrinsics) kept in placeholder form — never resolved here.
90
+ */
91
+ readonly inputs: Readonly<Record<string, unknown>>;
92
+ }
93
+
94
+ /** Factory options for {@link EffectReceipt}. */
95
+ export interface EffectReceiptOptions {
96
+ /** The effect this receipt witnesses. Non-empty. */
97
+ readonly effect: string;
98
+ /** `existence` — presence is the witness; `hash` — a digest of the inputs
99
+ * is, so changed inputs re-propose the fire. */
100
+ readonly flavor: EffectReceiptFlavor;
101
+ /** The effect's inputs. Static values hash at synthesis; intrinsic values
102
+ * (attr-refs, deploy-time references) resolve at plan and at run. */
103
+ readonly inputs?: Record<string, unknown>;
104
+ }
105
+
106
+ /**
107
+ * Declare an effect receipt. The returned object is a locked Declarable:
108
+ * declared fields are immutable, plain-data input structures are frozen
109
+ * (intrinsics are left live — discovery still stamps logical names onto
110
+ * attr-refs), and the top-level object stays extensible for discovery's own
111
+ * symbol-keyed metadata.
112
+ */
113
+ export function EffectReceipt(name: string, options: EffectReceiptOptions): EffectReceiptDeclaration {
114
+ if (typeof name !== "string" || name.length === 0) {
115
+ throw new Error("EffectReceipt: `name` must be a non-empty string");
116
+ }
117
+ if (typeof options?.effect !== "string" || options.effect.length === 0) {
118
+ throw new Error(`EffectReceipt("${name}"): \`effect\` must be a non-empty string`);
119
+ }
120
+ if (!EFFECT_RECEIPT_FLAVORS.includes(options.flavor)) {
121
+ throw new Error(
122
+ `EffectReceipt("${name}"): unknown flavor "${String(options.flavor)}" — ` +
123
+ `expected one of ${EFFECT_RECEIPT_FLAVORS.join(", ")}`,
124
+ );
125
+ }
126
+ if (options.inputs !== undefined && (typeof options.inputs !== "object" || options.inputs === null || Array.isArray(options.inputs))) {
127
+ throw new Error(`EffectReceipt("${name}"): \`inputs\` must be a plain object when present`);
128
+ }
129
+
130
+ const inputs = deepFreezeStatic({ ...(options.inputs ?? {}) }) as Readonly<Record<string, unknown>>;
131
+
132
+ const decl: EffectReceiptDeclaration = {
133
+ [DECLARABLE_MARKER]: true,
134
+ [EFFECT_RECEIPT_MARKER]: true,
135
+ lexicon: "chant",
136
+ entityType: EFFECT_RECEIPT_ENTITY_TYPE,
137
+ name,
138
+ effect: options.effect,
139
+ flavor: options.flavor,
140
+ inputs,
141
+ };
142
+ // Declared fields immutable, object extensible — same shape secret
143
+ // declarations use (./secret-provenance.ts's lockDeclaredFields).
144
+ for (const key of Object.keys(decl)) {
145
+ Object.defineProperty(decl, key, { writable: false, configurable: false });
146
+ }
147
+ return decl;
148
+ }
149
+
150
+ /** Freeze plain objects and arrays in place, leaving intrinsics (and any
151
+ * other class instance) untouched — discovery mutates AttrRefs when it
152
+ * assigns logical names. */
153
+ function deepFreezeStatic(value: unknown, seen: Set<object> = new Set()): unknown {
154
+ if (typeof value !== "object" || value === null || isIntrinsic(value)) return value;
155
+ if (seen.has(value)) return value;
156
+ seen.add(value);
157
+ if (Array.isArray(value)) {
158
+ for (const el of value) deepFreezeStatic(el, seen);
159
+ return Object.freeze(value);
160
+ }
161
+ if (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) {
162
+ for (const el of Object.values(value)) deepFreezeStatic(el, seen);
163
+ return Object.freeze(value);
164
+ }
165
+ return value;
166
+ }
167
+
168
+ /** Type guard for an effect receipt — the recognition read core's guards use.
169
+ * True for the core declaration AND for any lexicon-materialized resource
170
+ * that carries the marker. */
171
+ export function isEffectReceipt(value: unknown): value is EffectReceiptDeclaration {
172
+ return (
173
+ typeof value === "object" &&
174
+ value !== null &&
175
+ EFFECT_RECEIPT_MARKER in value &&
176
+ (value as Record<symbol, unknown>)[EFFECT_RECEIPT_MARKER] === true
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Extract the effect receipts from a discovered entity map — the read surface
182
+ * for lint (#1833), the plan engine (#1832), and the apply write-exclusion.
183
+ * Keyed by entity name (export name), the same key `DiscoveryResult.entities`
184
+ * uses.
185
+ */
186
+ export function collectEffectReceipts(
187
+ entities: ReadonlyMap<string, Declarable>,
188
+ ): Map<string, EffectReceiptDeclaration> {
189
+ const out = new Map<string, EffectReceiptDeclaration>();
190
+ for (const [name, entity] of entities) {
191
+ if (isEffectReceipt(entity)) out.set(name, entity);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * Split an entity map into the apply-bound set and the receipts (#1832).
198
+ *
199
+ * The write-exclusion seam: receipts are observe-only to the generic apply
200
+ * path — the `effect()` step is the sole writer (epic #1703, decision 3), and
201
+ * a receipt the generic apply stamped would silently convert at-least-once
202
+ * into never. The build calls this at serializer-input assembly, the one core
203
+ * choke point every applier's input flows through (appliers consume serialized
204
+ * build outputs), so no lexicon's serialized apply document ever contains a
205
+ * receipt and no applier's desired or prune set can. Receipts still reach the
206
+ * serializer — for visibility rendering outside the apply-bound document
207
+ * (#1835) — via `SerializeContext.receipts`, never in the entity map.
208
+ */
209
+ export function splitReceiptEntities(entities: ReadonlyMap<string, Declarable>): {
210
+ applyBound: Map<string, Declarable>;
211
+ receipts: Map<string, EffectReceiptDeclaration>;
212
+ } {
213
+ const applyBound = new Map<string, Declarable>();
214
+ const receipts = new Map<string, EffectReceiptDeclaration>();
215
+ for (const [name, entity] of entities) {
216
+ if (isEffectReceipt(entity)) receipts.set(name, entity);
217
+ else applyBound.set(name, entity);
218
+ }
219
+ return { applyBound, receipts };
220
+ }
221
+
222
+ // ─────────────────────────────────────────────────────────────────────────
223
+ // Canonical hashing — JCS-style canonical JSON + sha256.
224
+ // ─────────────────────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * JCS-style canonical JSON (the RFC 8785 shape, implemented minimally):
228
+ * object keys sorted by UTF-16 code units, numbers and strings in standard
229
+ * ECMAScript `JSON.stringify` encoding, no insignificant whitespace.
230
+ * `toJSON()` is honored the way `JSON.stringify` honors it — which is what
231
+ * puts an intrinsic's PLACEHOLDER envelope (e.g. an attr-ref's
232
+ * `{"__attrRef":{...}}`) into the canonical form rather than a resolved
233
+ * value. Non-representable values (undefined outside an object property,
234
+ * functions, symbols, bigints, non-finite numbers, cycles) throw — a hash
235
+ * input silently coerced is a wrong expectation.
236
+ */
237
+ export function canonicalJson(value: unknown): string {
238
+ const out = encodeCanonical(value, "$", new Set());
239
+ if (out === undefined) {
240
+ throw new Error(`canonicalJson: value at $ is not representable in JSON`);
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /** Returns undefined only for values JSON.stringify would drop as an object
246
+ * property (undefined); throws for everything else non-representable. */
247
+ function encodeCanonical(value: unknown, path: string, seen: Set<object>): string | undefined {
248
+ // toJSON first, like JSON.stringify — once per node.
249
+ if (value !== null && (typeof value === "object" || typeof value === "function")) {
250
+ const toJSON = (value as { toJSON?: unknown }).toJSON;
251
+ if (typeof toJSON === "function") {
252
+ value = (toJSON as () => unknown).call(value);
253
+ }
254
+ }
255
+ if (value === undefined) return undefined;
256
+ if (value === null) return "null";
257
+ switch (typeof value) {
258
+ case "boolean":
259
+ return value ? "true" : "false";
260
+ case "number":
261
+ if (!Number.isFinite(value)) {
262
+ throw new Error(`canonicalJson: non-finite number at ${path}`);
263
+ }
264
+ return String(value); // ECMAScript ToString — what JCS specifies.
265
+ case "string":
266
+ return JSON.stringify(value);
267
+ case "bigint":
268
+ throw new Error(`canonicalJson: bigint at ${path} is not representable in JSON`);
269
+ case "function":
270
+ case "symbol":
271
+ throw new Error(`canonicalJson: ${typeof value} at ${path} is not representable in JSON`);
272
+ }
273
+ const obj = value as object;
274
+ if (seen.has(obj)) {
275
+ throw new Error(`canonicalJson: circular structure at ${path}`);
276
+ }
277
+ seen.add(obj);
278
+ try {
279
+ if (Array.isArray(obj)) {
280
+ const parts = obj.map((el, i) => encodeCanonical(el, `${path}[${i}]`, seen) ?? "null");
281
+ return `[${parts.join(",")}]`;
282
+ }
283
+ const keys = Object.keys(obj).sort();
284
+ const parts: string[] = [];
285
+ for (const key of keys) {
286
+ const encoded = encodeCanonical((obj as Record<string, unknown>)[key], `${path}.${key}`, seen);
287
+ if (encoded !== undefined) parts.push(`${JSON.stringify(key)}:${encoded}`);
288
+ }
289
+ return `{${parts.join(",")}}`;
290
+ } finally {
291
+ seen.delete(obj);
292
+ }
293
+ }
294
+
295
+ function sha256Digest(canonical: string): string {
296
+ return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
297
+ }
298
+
299
+ // ─────────────────────────────────────────────────────────────────────────
300
+ // The resolution split.
301
+ // ─────────────────────────────────────────────────────────────────────────
302
+
303
+ /** The paths (dot/bracket, rooted at `inputs`) of every reference (intrinsic)
304
+ * input still unresolved on the receipt. Empty means the receipt is fully
305
+ * static and {@link receiptExpectation} can stamp its digest at synthesis. */
306
+ export function referenceInputPaths(receipt: EffectReceiptDeclaration): string[] {
307
+ const paths: string[] = [];
308
+ collectReferencePaths(receipt.inputs, "inputs", paths, new Set());
309
+ return paths;
310
+ }
311
+
312
+ function collectReferencePaths(value: unknown, path: string, out: string[], seen: Set<object>): void {
313
+ if (typeof value !== "object" || value === null) return;
314
+ if (isIntrinsic(value)) {
315
+ out.push(path);
316
+ return;
317
+ }
318
+ if (seen.has(value)) return;
319
+ seen.add(value);
320
+ if (Array.isArray(value)) {
321
+ value.forEach((el, i) => collectReferencePaths(el, `${path}[${i}]`, out, seen));
322
+ return;
323
+ }
324
+ for (const [key, el] of Object.entries(value)) {
325
+ collectReferencePaths(el, `${path}.${key}`, out, seen);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * The receipt's expected stored value, computable at synthesis:
331
+ *
332
+ * - `existence` → {@link EXISTENCE_EXPECTATION}, always.
333
+ * - `hash` → `sha256:<hex>` over the canonical JSON of
334
+ * `{ effect, inputs }` — the effect name is bound into the digest so the
335
+ * same inputs under a different effect never collide.
336
+ *
337
+ * A hash-flavor receipt that still carries reference inputs THROWS instead
338
+ * of hashing placeholders: synthesis resolves nothing (epic decision log,
339
+ * item 5). The plan engine and the effect step get the digest through
340
+ * {@link resolveReceiptExpectation}.
341
+ */
342
+ export function receiptExpectation(receipt: EffectReceiptDeclaration): string {
343
+ if (receipt.flavor === "existence") return EXISTENCE_EXPECTATION;
344
+ const refs = referenceInputPaths(receipt);
345
+ if (refs.length > 0) {
346
+ throw new Error(
347
+ `receiptExpectation("${receipt.name}"): unresolved reference inputs at ${refs.join(", ")} — ` +
348
+ `references resolve at plan and at run, never at synthesis; ` +
349
+ `use resolveReceiptExpectation with a resolver`,
350
+ );
351
+ }
352
+ return sha256Digest(canonicalJson({ effect: receipt.effect, inputs: receipt.inputs }));
353
+ }
354
+
355
+ /**
356
+ * Resolves one reference input to its live value. `path` is the reference's
357
+ * location (as {@link referenceInputPaths} renders it). Must return a
358
+ * JSON-representable value — returning `undefined` or another intrinsic is
359
+ * an error, reported with the path.
360
+ */
361
+ export type ReceiptInputResolver = (ref: Intrinsic, path: string) => unknown;
362
+
363
+ /**
364
+ * The receipt's expected stored value with references resolved — the form
365
+ * the plan engine (#1832) compares against the live receipt and the effect
366
+ * step (#1834) writes on success. Deterministic over the receipt and the
367
+ * resolver's answers; resolves nothing itself and reads no live state (the
368
+ * resolver is the caller's seam to deploy-time values).
369
+ */
370
+ export function resolveReceiptExpectation(
371
+ receipt: EffectReceiptDeclaration,
372
+ resolver: ReceiptInputResolver,
373
+ ): string {
374
+ if (receipt.flavor === "existence") return EXISTENCE_EXPECTATION;
375
+ const resolved = resolveValue(receipt.inputs, "inputs", resolver, new Set());
376
+ return sha256Digest(canonicalJson({ effect: receipt.effect, inputs: resolved }));
377
+ }
378
+
379
+ function resolveValue(
380
+ value: unknown,
381
+ path: string,
382
+ resolver: ReceiptInputResolver,
383
+ seen: Set<object>,
384
+ ): unknown {
385
+ if (typeof value !== "object" || value === null) return value;
386
+ if (isIntrinsic(value)) {
387
+ const resolved = resolver(value, path);
388
+ if (resolved === undefined) {
389
+ throw new Error(`resolveReceiptExpectation: resolver returned undefined for reference at ${path}`);
390
+ }
391
+ if (isIntrinsic(resolved)) {
392
+ throw new Error(`resolveReceiptExpectation: resolver returned another reference for ${path}`);
393
+ }
394
+ return resolved;
395
+ }
396
+ if (seen.has(value)) {
397
+ throw new Error(`resolveReceiptExpectation: circular structure at ${path}`);
398
+ }
399
+ seen.add(value);
400
+ try {
401
+ if (Array.isArray(value)) {
402
+ return value.map((el, i) => resolveValue(el, `${path}[${i}]`, resolver, seen));
403
+ }
404
+ const out: Record<string, unknown> = {};
405
+ for (const [key, el] of Object.entries(value)) {
406
+ out[key] = resolveValue(el, `${path}.${key}`, resolver, seen);
407
+ }
408
+ return out;
409
+ } finally {
410
+ seen.delete(value);
411
+ }
412
+ }
package/src/env.test.ts CHANGED
@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
2
2
  import { mkdir, writeFile, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { env, unknownEnvError, ENV_VAR } from "./env";
5
+ import { env, unknownEnvError, isProdLikeEnvironment, ENV_VAR } from "./env";
6
6
  import { discover } from "./discovery/index";
7
7
 
8
8
  const withEnv = async (value: string | undefined, fn: () => void | Promise<void>): Promise<void> => {
@@ -52,6 +52,25 @@ describe("unknownEnvError", () => {
52
52
  unknownEnvError("stage", ["prod", { name: "floci", endpoint: "http://localhost:4566" }]),
53
53
  ).toMatch(/Unknown environment "stage".*prod, floci/);
54
54
  });
55
+
56
+ // #1221 — an entry containing `*` is a glob pattern, so an unbounded family
57
+ // (per-PR environments, per-suite test environments) is declarable without
58
+ // listing each name.
59
+ test("accepts an env covered by a declared glob pattern (#1221)", () => {
60
+ expect(unknownEnvError("pr-42", ["dev", "prod", "pr-*"])).toBeUndefined();
61
+ expect(unknownEnvError("test-suite-a", ["prod", { name: "test-*", endpoint: "http://localhost:4566" }])).toBeUndefined();
62
+ });
63
+
64
+ test("rejects an env no pattern covers, listing the patterns among the declared entries (#1221)", () => {
65
+ expect(unknownEnvError("stage", ["dev", "prod", "pr-*"])).toMatch(/Unknown environment "stage".*dev, prod, pr-\*/);
66
+ expect(unknownEnvError("pr", ["pr-*"])).toMatch(/Unknown environment "pr"/); // prefix alone is not covered
67
+ });
68
+
69
+ test("a literal entry still matches by equality, never as a pattern (#1221)", () => {
70
+ // `prod` contains no `*`: nothing about pattern support changes it.
71
+ expect(unknownEnvError("prod", ["prod", "pr-*"])).toBeUndefined();
72
+ expect(unknownEnvError("production", ["prod"])).toMatch(/Unknown environment "production"/);
73
+ });
55
74
  });
56
75
 
57
76
  describe("env-aware discovery (#505)", () => {
@@ -87,3 +106,18 @@ describe("env-aware discovery (#505)", () => {
87
106
  }
88
107
  });
89
108
  });
109
+
110
+
111
+ describe("isProdLikeEnvironment (#1222)", () => {
112
+ test("matches prod, production, and separator-delimited variants", () => {
113
+ for (const name of ["prod", "production", "Prod", "PRODUCTION", "prod-eu", "us-prod", "prod2", "production-east", "eu_prod"]) {
114
+ expect(isProdLikeEnvironment(name)).toBe(true);
115
+ }
116
+ });
117
+
118
+ test("does not match names that merely contain the letters", () => {
119
+ for (const name of ["dev", "staging", "preprod", "product-demo", "reproduction", "pr-123"]) {
120
+ expect(isProdLikeEnvironment(name)).toBe(false);
121
+ }
122
+ });
123
+ });
package/src/env.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * pinhole renders/diffs them to show environment drift (INTENTIUS/pinhole#3).
19
19
  */
20
20
 
21
- import { environmentNames, type EnvironmentDeclaration } from "./config";
21
+ import { environmentNames, matchesDeclaredEnvironment, type EnvironmentDeclaration } from "./config";
22
22
 
23
23
  /** The environment variable the CLI sets from `--env`. */
24
24
  export const ENV_VAR = "CHANT_ENV";
@@ -34,14 +34,28 @@ export function env(fallback?: string): string | undefined {
34
34
  * when it's valid (or when the project declares no environments, in which case
35
35
  * any name is accepted). `declared` entries may be a bare name or `{ name,
36
36
  * endpoint }` (#1166) — {@link environmentNames} reduces either to the names
37
- * this checks against.
37
+ * this checks against. An entry containing `*` is a glob pattern (#1221):
38
+ * `"pr-*"` legalizes every `pr-<n>` environment. Literal entries are checked
39
+ * first, then patterns — see {@link matchesDeclaredEnvironment}.
38
40
  */
39
41
  export function unknownEnvError(
40
42
  requested: string | undefined,
41
43
  declared: EnvironmentDeclaration[] | undefined,
42
44
  ): string | undefined {
43
45
  if (!requested || !declared || declared.length === 0) return undefined;
46
+ if (matchesDeclaredEnvironment(declared, requested)) return undefined;
44
47
  const names = environmentNames(declared) ?? [];
45
- if (names.includes(requested)) return undefined;
46
48
  return `Unknown environment "${requested}". Declared environments: ${names.join(", ")}.`;
47
49
  }
50
+
51
+ /**
52
+ * True when an environment name looks like production — `prod`, `production`,
53
+ * and separator-delimited variants (`prod-eu`, `us-prod`, `production2`).
54
+ * `chant lifecycle teardown <env> --yes` demands an extra confirmation for
55
+ * these (#1222): a typo that survives the declared-environments check should
56
+ * still not delete production on one flag. Name-shaped, deliberately — chant
57
+ * has no other signal for which environment is the one that pays the bills.
58
+ */
59
+ export function isProdLikeEnvironment(name: string): boolean {
60
+ return /(^|[-_./])prod(uction)?([-_./0-9]|$)/i.test(name);
61
+ }
@@ -258,6 +258,32 @@ describe("documented divergences — NOT unified by design (see subset.ts module
258
258
  expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
259
259
  });
260
260
 
261
+ test("composite step access: fold still rejects a call as a value; EVL001 accepts .step access (chant #1544, opt-in only)", () => {
262
+ // `Checkout({...}).step` — the single-action Composite() wrapper idiom
263
+ // every lexicon's own docs/examples embed inline inside a Job's
264
+ // `steps:` array. fold() has no way to invoke an arbitrary composite
265
+ // factory, so it still rejects this shape and falls the file back to
266
+ // the run path (documented, correct behavior — never an error). EVL001
267
+ // now opts INTO treating it as shape-valid via `allowCompositeStepAccess`
268
+ // — a caller of `findSubsetViolation` that does not pass that flag
269
+ // (fold(), or this very call below) is completely unaffected.
270
+ const source = `const bad = new Thing({ x: Checkout({}).step });`;
271
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
272
+ const consts = collectConsts(sourceFile);
273
+ const badInit = consts.get("bad") as ts.NewExpression;
274
+
275
+ expect(() => foldResource(badInit, consts, [])).toThrow(FoldError);
276
+
277
+ // findSubsetViolation with no third argument (fold()'s own answer, and
278
+ // every pre-#1544 caller) is unchanged — still a violation.
279
+ const bareCallExpr = (badInit.arguments![0] as ts.ObjectLiteralExpression).properties[0];
280
+ expect(findSubsetViolation(bareCallExpr)).toBeDefined();
281
+
282
+ // EVL001 itself opts in and stops flagging it.
283
+ const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
284
+ expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
285
+ });
286
+
261
287
  test("nested resource construction: NO LONGER a divergence (chant #1169) — both fold and EVL001 accept it", () => {
262
288
  // This was the largest divergence in the table until #1169: a nested
263
289
  // `new Type()` as a property value could only fold to a {__resource, props}