@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,359 @@
1
+ /**
2
+ * Teardown planning (#1222) — the enumeration half of
3
+ * `chant lifecycle teardown <env>`.
4
+ *
5
+ * Answers one question: which live resources carry THIS project's ownership
6
+ * marker for THIS environment? Selection is marker-scoped by construction —
7
+ * managed-by present, stack equal to the project's `ownership.stack`, env
8
+ * equal to the argument — so a foreign stack's resources, another env's
9
+ * resources, and unmarked resources are out of scope by shape, not by
10
+ * filtering discipline someone has to remember.
11
+ *
12
+ * Stateless: live markers only. No build, no snapshot, no ledger — the
13
+ * ownership record lives on the cloud resource (see ../ownership.ts), and this
14
+ * module reads it back from there.
15
+ *
16
+ * Two paths per lexicon:
17
+ * - the `teardownOwned` capability, where the lexicon enumerates its own
18
+ * would-delete set (and can use a read shaped for deletion — aws's
19
+ * stack-level path, k8s's prune selector);
20
+ * - a fallback over `describeResources` + the {@link ResourceMetadata.marker}
21
+ * field (#1222 PR 1), for lexicons that have not implemented the capability
22
+ * yet. Best-effort: a lexicon whose thin read only resolves declared names
23
+ * returns nothing here, which the plan reports as a skip, not as clean.
24
+ *
25
+ * Whichever path ran, core re-checks every candidate's marker and drops
26
+ * mismatches — an implementation bug can narrow the set, never widen it.
27
+ *
28
+ * The execution half is {@link executeTeardown}: it drives each lexicon's
29
+ * `executeTeardown` capability over the planned set, then runs one bounded
30
+ * retry pass over the failures. Both halves are exported as functions —
31
+ * #1224's test-env harness calls them in-process, not only through the verb.
32
+ */
33
+
34
+ import { normalizeObservation, unobservedAll } from "../observation";
35
+ import type { ObservationLexicon, TeardownCandidate, TeardownHole, TeardownOutcome } from "../lexicon";
36
+ import type { OwnershipMarker } from "../ownership";
37
+
38
+ /** One would-delete row in a teardown plan, attributed to its lexicon. */
39
+ export interface TeardownPlanEntry extends TeardownCandidate {
40
+ lexicon: string;
41
+ }
42
+
43
+ /** One hole in a teardown plan (#1089), attributed to its lexicon. */
44
+ export interface TeardownPlanHole extends TeardownHole {
45
+ lexicon: string;
46
+ }
47
+
48
+ /** The plan `chant lifecycle teardown <env>` prints and #1224 consumes. */
49
+ export interface TeardownPlan {
50
+ environment: string;
51
+ /** The project's ownership stack — the identity everything was selected on. */
52
+ stack: string;
53
+ /** The would-delete set. Every entry's marker equals `{ stack, env: environment }`. */
54
+ entries: TeardownPlanEntry[];
55
+ /** What could not be read (#1089). A plan with holes is incomplete, not clean. */
56
+ holes: TeardownPlanHole[];
57
+ /**
58
+ * Lexicons that took part in neither path — no `teardownOwned`, no
59
+ * `describeResources`. Reported so "nothing to delete" can never quietly
60
+ * mean "nobody looked".
61
+ */
62
+ skipped: string[];
63
+ }
64
+
65
+ export interface PlanTeardownOptions {
66
+ /** The environment being torn down — the marker env to select on. */
67
+ environment: string;
68
+ /** This project's ownership stack (`ownership.stack` in chant.config). */
69
+ stack: string;
70
+ plugins: ObservationLexicon[];
71
+ /** Deployed stack name, for a multi-stack project. */
72
+ deployedStack?: string;
73
+ /** Region that stack is deployed in. */
74
+ region?: string;
75
+ /**
76
+ * Every deployed stack a multi-stack project declares (`stacks` in
77
+ * chant.config), for a lexicon whose teardown is stack-shaped (aws
78
+ * enumerates and deletes whole stacks). Forwarded to `teardownOwned` /
79
+ * `executeTeardown` as `stacks`.
80
+ */
81
+ deployedStacks?: Array<{ name: string; region?: string }>;
82
+ }
83
+
84
+ /** True when `marker` is exactly the identity this plan selects on. */
85
+ function markerMatches(marker: OwnershipMarker | undefined, stack: string, env: string): boolean {
86
+ return marker !== undefined && marker.stack === stack && marker.env === env;
87
+ }
88
+
89
+ /**
90
+ * Enumerate what `chant lifecycle teardown <env>` would delete. Read-only —
91
+ * this function never deletes and never will; execution composes on top of the
92
+ * plan it returns.
93
+ */
94
+ export async function planTeardown(opts: PlanTeardownOptions): Promise<TeardownPlan> {
95
+ const marker: OwnershipMarker = { stack: opts.stack, env: opts.environment };
96
+ const entries: TeardownPlanEntry[] = [];
97
+ const holes: TeardownPlanHole[] = [];
98
+ const skipped: string[] = [];
99
+
100
+ for (const plugin of opts.plugins) {
101
+ if (plugin.teardownOwned) {
102
+ let enumeration;
103
+ try {
104
+ enumeration = await plugin.teardownOwned({
105
+ environment: opts.environment,
106
+ marker,
107
+ ...(opts.deployedStack ? { stack: opts.deployedStack } : {}),
108
+ ...(opts.region ? { region: opts.region } : {}),
109
+ ...(opts.deployedStacks && opts.deployedStacks.length > 0 ? { stacks: opts.deployedStacks } : {}),
110
+ });
111
+ } catch (err) {
112
+ // A failed enumeration is a hole over the whole lexicon, not a clean
113
+ // lexicon (#1089): nothing was read, so nothing is known.
114
+ holes.push({
115
+ lexicon: plugin.name,
116
+ name: "*",
117
+ reason: "read-failed",
118
+ detail: err instanceof Error ? err.message : String(err),
119
+ });
120
+ continue;
121
+ }
122
+ for (const candidate of enumeration.candidates) {
123
+ // Defense in depth: the contract says every candidate carries the
124
+ // requested identity; a candidate that does not is dropped here so an
125
+ // implementation bug cannot widen the delete set.
126
+ if (!markerMatches(candidate.marker, opts.stack, opts.environment)) continue;
127
+ entries.push({ lexicon: plugin.name, ...candidate });
128
+ }
129
+ for (const hole of enumeration.holes ?? []) {
130
+ holes.push({ lexicon: plugin.name, ...hole });
131
+ }
132
+ continue;
133
+ }
134
+
135
+ if (plugin.describeResources) {
136
+ let observed;
137
+ try {
138
+ observed = normalizeObservation(
139
+ await plugin.describeResources({
140
+ environment: opts.environment,
141
+ buildOutput: "",
142
+ entityNames: [],
143
+ entities: new Map(),
144
+ owned: true,
145
+ ...(opts.deployedStack ? { stack: opts.deployedStack } : {}),
146
+ ...(opts.region ? { region: opts.region } : {}),
147
+ }),
148
+ );
149
+ } catch (err) {
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ observed = {
152
+ resources: {},
153
+ unobserved: unobservedAll(["*"], "read-failed", message),
154
+ queried: {},
155
+ notes: [],
156
+ };
157
+ }
158
+ for (const [name, meta] of Object.entries(observed.resources)) {
159
+ // Marker-scoped by construction: no marker, foreign stack, or foreign
160
+ // env means not a candidate — a resource with no readable identity is
161
+ // never promoted to a delete.
162
+ if (!markerMatches(meta.marker, opts.stack, opts.environment)) continue;
163
+ entries.push({
164
+ lexicon: plugin.name,
165
+ name,
166
+ type: meta.type,
167
+ ...(meta.physicalId ? { physicalId: meta.physicalId } : {}),
168
+ marker: meta.marker!,
169
+ });
170
+ }
171
+ for (const [name, u] of Object.entries(observed.unobserved)) {
172
+ holes.push({
173
+ lexicon: plugin.name,
174
+ name,
175
+ ...(u.type ? { type: u.type } : {}),
176
+ reason: u.reason,
177
+ ...(u.detail ? { detail: u.detail } : {}),
178
+ });
179
+ }
180
+ continue;
181
+ }
182
+
183
+ skipped.push(plugin.name);
184
+ }
185
+
186
+ entries.sort((a, b) => a.lexicon.localeCompare(b.lexicon) || a.name.localeCompare(b.name));
187
+ return { environment: opts.environment, stack: opts.stack, entries, holes, skipped };
188
+ }
189
+
190
+ /** One planned entry's fate after execution, attributed to its lexicon. */
191
+ export interface TeardownOutcomeEntry extends TeardownPlanEntry {
192
+ /**
193
+ * `skipped` is core's verdict for a candidate whose lexicon implements no
194
+ * `executeTeardown` yet; the others come from the lexicon (see
195
+ * {@link TeardownOutcome}). `retained` is the loud keep (#1365 decision 5):
196
+ * owned, no longer declared, deliberately not deleted — a `generated-once`
197
+ * secret survives every sweep and says so with a row.
198
+ */
199
+ outcome: "deleted" | "failed" | "not-prunable" | "retained" | "skipped";
200
+ /** The error for `failed`, the reason for `not-prunable`/`retained`/`skipped`. */
201
+ detail?: string;
202
+ /** True when this final outcome came from the bounded retry pass. */
203
+ retried?: boolean;
204
+ }
205
+
206
+ /** What `chant lifecycle teardown <env> --yes` prints and #1224 consumes. */
207
+ export interface TeardownReport {
208
+ environment: string;
209
+ stack: string;
210
+ /** The plan that was executed — holes and skipped lexicons included. */
211
+ plan: TeardownPlan;
212
+ /** One row per planned entry. Never fewer: silence is never success. */
213
+ outcomes: TeardownOutcomeEntry[];
214
+ /** Lexicons whose candidates were skipped for lack of an `executeTeardown`. */
215
+ unimplemented: string[];
216
+ }
217
+
218
+ export interface ExecuteTeardownOptions extends PlanTeardownOptions {
219
+ /**
220
+ * A plan already computed (the one just shown to the user). Recomputed from
221
+ * a fresh live read when omitted.
222
+ */
223
+ plan?: TeardownPlan;
224
+ }
225
+
226
+ /**
227
+ * Run one execution pass over a lexicon's candidates and return exactly one
228
+ * outcome per candidate: what the lexicon reported, `failed` for anything it
229
+ * stayed silent about, and `failed` across the board when the call threw.
230
+ * Outcomes the lexicon volunteers for names core never asked about are
231
+ * dropped — an implementation cannot widen the set by reporting on it.
232
+ */
233
+ async function executePass(
234
+ plugin: ObservationLexicon,
235
+ candidates: TeardownCandidate[],
236
+ opts: ExecuteTeardownOptions,
237
+ marker: OwnershipMarker,
238
+ ): Promise<Map<string, TeardownOutcome>> {
239
+ const byName = new Map<string, TeardownOutcome>();
240
+ let reported: TeardownOutcome[];
241
+ try {
242
+ const execution = await plugin.executeTeardown!({
243
+ environment: opts.environment,
244
+ marker,
245
+ candidates,
246
+ ...(opts.deployedStack ? { stack: opts.deployedStack } : {}),
247
+ ...(opts.region ? { region: opts.region } : {}),
248
+ ...(opts.deployedStacks && opts.deployedStacks.length > 0 ? { stacks: opts.deployedStacks } : {}),
249
+ });
250
+ reported = execution.outcomes;
251
+ } catch (err) {
252
+ const detail = err instanceof Error ? err.message : String(err);
253
+ for (const candidate of candidates) {
254
+ byName.set(candidate.name, { name: candidate.name, outcome: "failed", detail });
255
+ }
256
+ return byName;
257
+ }
258
+ const asked = new Set(candidates.map((c) => c.name));
259
+ for (const outcome of reported) {
260
+ if (!asked.has(outcome.name)) continue;
261
+ byName.set(outcome.name, outcome);
262
+ }
263
+ for (const candidate of candidates) {
264
+ if (byName.has(candidate.name)) continue;
265
+ byName.set(candidate.name, {
266
+ name: candidate.name,
267
+ outcome: "failed",
268
+ detail: "the lexicon reported no outcome for this candidate",
269
+ });
270
+ }
271
+ return byName;
272
+ }
273
+
274
+ /**
275
+ * Execute a teardown: delete every planned candidate through its lexicon's
276
+ * `executeTeardown`, then retry the failures once. Per-lexicon ordering only —
277
+ * each lexicon deletes its own set in the order its target requires (k8s
278
+ * deletes namespaces last, fly deletes apps last); there is no global
279
+ * reverse-dependency ordering in v1, the bounded retry pass covers the
280
+ * cross-lexicon cases it would.
281
+ *
282
+ * Every planned entry comes back with an outcome. A lexicon that enumerates
283
+ * but implements no execution reports its candidates as `skipped` — loudly,
284
+ * never as clean. Failures that survive the retry stay `failed` in the
285
+ * report; nothing here ever swallows one.
286
+ */
287
+ export async function executeTeardown(opts: ExecuteTeardownOptions): Promise<TeardownReport> {
288
+ const marker: OwnershipMarker = { stack: opts.stack, env: opts.environment };
289
+ const plan = opts.plan ?? (await planTeardown(opts));
290
+
291
+ const byLexicon = new Map<string, TeardownPlanEntry[]>();
292
+ for (const entry of plan.entries) {
293
+ const list = byLexicon.get(entry.lexicon) ?? [];
294
+ list.push(entry);
295
+ byLexicon.set(entry.lexicon, list);
296
+ }
297
+
298
+ const outcomes: TeardownOutcomeEntry[] = [];
299
+ const unimplemented: string[] = [];
300
+
301
+ // Plugin registration order, so a project's lexicon ordering is stable.
302
+ for (const plugin of opts.plugins) {
303
+ const entries = byLexicon.get(plugin.name);
304
+ if (!entries) continue;
305
+ byLexicon.delete(plugin.name);
306
+
307
+ if (!plugin.executeTeardown) {
308
+ unimplemented.push(plugin.name);
309
+ for (const entry of entries) {
310
+ outcomes.push({
311
+ ...entry,
312
+ outcome: "skipped",
313
+ detail: `the ${plugin.name} lexicon does not implement teardown execution yet`,
314
+ });
315
+ }
316
+ continue;
317
+ }
318
+
319
+ const candidates: TeardownCandidate[] = entries.map(({ lexicon: _lexicon, ...candidate }) => candidate);
320
+ const first = await executePass(plugin, candidates, opts, marker);
321
+
322
+ // One bounded retry pass over this lexicon's failures — transient errors
323
+ // and ordering hiccups get a second chance, nothing gets an infinite one.
324
+ const failedNames = new Set(
325
+ [...first.values()].filter((o) => o.outcome === "failed").map((o) => o.name),
326
+ );
327
+ const retried =
328
+ failedNames.size > 0
329
+ ? await executePass(plugin, candidates.filter((c) => failedNames.has(c.name)), opts, marker)
330
+ : new Map<string, TeardownOutcome>();
331
+
332
+ for (const entry of entries) {
333
+ const second = retried.get(entry.name);
334
+ const outcome = second ?? first.get(entry.name)!;
335
+ outcomes.push({
336
+ ...entry,
337
+ outcome: outcome.outcome,
338
+ ...(outcome.detail !== undefined ? { detail: outcome.detail } : {}),
339
+ ...(second !== undefined ? { retried: true } : {}),
340
+ });
341
+ }
342
+ }
343
+
344
+ // A planned entry attributed to a lexicon that is not in `plugins` at
345
+ // execution time (a plan handed in from elsewhere). Nobody can delete it,
346
+ // and silence is never success.
347
+ for (const entries of byLexicon.values()) {
348
+ for (const entry of entries) {
349
+ outcomes.push({
350
+ ...entry,
351
+ outcome: "skipped",
352
+ detail: `no loaded lexicon named "${entry.lexicon}" to execute this candidate`,
353
+ });
354
+ }
355
+ }
356
+
357
+ outcomes.sort((a, b) => a.lexicon.localeCompare(b.lexicon) || a.name.localeCompare(b.name));
358
+ return { environment: opts.environment, stack: opts.stack, plan, outcomes, unimplemented };
359
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { buildChangeSet } from "./change-set";
3
+ import { evaluateUnobservedGate } from "./unobserved-gate";
4
+ import type { ChangeSet } from "./change-set";
5
+ import type { UnobservedReason } from "../observation";
6
+
7
+ const cleanChangeSet = (env = "prod"): ChangeSet =>
8
+ buildChangeSet(env, {
9
+ declared: new Set(["bucket"]),
10
+ observedNow: { bucket: { type: "Fake::Bucket", status: "OK" } },
11
+ observedThen: undefined,
12
+ });
13
+
14
+ const unobservedChangeSet = (reason: UnobservedReason, detail?: string): ChangeSet =>
15
+ buildChangeSet("prod", {
16
+ declared: new Set(["queue"]),
17
+ observedNow: {},
18
+ observedThen: undefined,
19
+ unobserved: { queue: { reason, ...(detail ? { detail } : {}) } },
20
+ });
21
+
22
+ describe("evaluateUnobservedGate (#1568)", () => {
23
+ test("a clean change set passes under every policy", () => {
24
+ const cs = cleanChangeSet();
25
+ expect(evaluateUnobservedGate(cs)).toEqual({ pass: true, escalate: false, findings: [] });
26
+ expect(evaluateUnobservedGate(cs, "escalate")).toEqual({ pass: true, escalate: false, findings: [] });
27
+ expect(evaluateUnobservedGate(cs, { allow: [] })).toEqual({ pass: true, escalate: false, findings: [] });
28
+ });
29
+
30
+ test("default policy (no argument) refuses a plan with an unobserved entity", () => {
31
+ const cs = unobservedChangeSet("no-credentials", "aws sts get-caller-identity: access denied");
32
+ const verdict = evaluateUnobservedGate(cs);
33
+ expect(verdict.pass).toBe(false);
34
+ expect(verdict.escalate).toBe(false);
35
+ expect(verdict.findings).toEqual([
36
+ { name: "queue", reason: "no-credentials", detail: "aws sts get-caller-identity: access denied" },
37
+ ]);
38
+ expect(verdict.detail).toContain("queue");
39
+ expect(verdict.detail).toContain("no credentials");
40
+ });
41
+
42
+ test('explicit "refuse" behaves the same as the default', () => {
43
+ const cs = unobservedChangeSet("read-failed");
44
+ expect(evaluateUnobservedGate(cs, "refuse").pass).toBe(false);
45
+ });
46
+
47
+ test('"escalate" does not fail the gate but flags escalate with the findings', () => {
48
+ const cs = unobservedChangeSet("no-binding");
49
+ const verdict = evaluateUnobservedGate(cs, "escalate");
50
+ expect(verdict.pass).toBe(true);
51
+ expect(verdict.escalate).toBe(true);
52
+ expect(verdict.findings).toHaveLength(1);
53
+ expect(verdict.findings[0].reason).toBe("no-binding");
54
+ expect(verdict.detail).toBeDefined();
55
+ });
56
+
57
+ test("an allowed reason passes cleanly and is not counted as a finding", () => {
58
+ const cs = unobservedChangeSet("filtered");
59
+ const verdict = evaluateUnobservedGate(cs, { allow: ["filtered"] });
60
+ expect(verdict).toEqual({ pass: true, escalate: false, findings: [] });
61
+ });
62
+
63
+ test("a reason outside the allow list still refuses", () => {
64
+ const cs = unobservedChangeSet("no-credentials");
65
+ const verdict = evaluateUnobservedGate(cs, { allow: ["filtered"] });
66
+ expect(verdict.pass).toBe(false);
67
+ expect(verdict.findings[0].reason).toBe("no-credentials");
68
+ });
69
+
70
+ test("a mixed change set with one allowed and one unallowed reason only reports the unallowed one", () => {
71
+ const cs = buildChangeSet("prod", {
72
+ declared: new Set(["a", "b"]),
73
+ observedNow: {},
74
+ observedThen: undefined,
75
+ unobserved: {
76
+ a: { reason: "filtered" },
77
+ b: { reason: "unsupported-kind" },
78
+ },
79
+ });
80
+ const verdict = evaluateUnobservedGate(cs, { allow: ["filtered"] });
81
+ expect(verdict.pass).toBe(false);
82
+ expect(verdict.findings).toEqual([{ name: "b", reason: "unsupported-kind" }]);
83
+ });
84
+
85
+ test("never escalates or refuses on create/update/delete/adopt/noop — only `unobserved` counts", () => {
86
+ const cs = buildChangeSet("prod", {
87
+ declared: new Set(["created", "updated"]),
88
+ observedNow: {
89
+ updated: { type: "Fake::Resource", status: "DRIFTED" },
90
+ orphan: { type: "Fake::Resource", status: "OK" },
91
+ },
92
+ observedThen: { updated: { type: "Fake::Resource", status: "OK" } },
93
+ });
94
+ expect(evaluateUnobservedGate(cs)).toEqual({ pass: true, escalate: false, findings: [] });
95
+ });
96
+
97
+ test("carries type and detail through into the finding when present", () => {
98
+ const cs = buildChangeSet("prod", {
99
+ declared: new Set(["disk"]),
100
+ observedNow: {},
101
+ observedThen: undefined,
102
+ unobserved: { disk: { reason: "unsupported-kind", detail: "no describe support for this kind yet", type: "Fake::Disk" } },
103
+ });
104
+ const verdict = evaluateUnobservedGate(cs);
105
+ expect(verdict.findings).toEqual([
106
+ { name: "disk", type: "Fake::Disk", reason: "unsupported-kind", detail: "no describe support for this kind yet" },
107
+ ]);
108
+ });
109
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The unobserved gate predicate (#1568): a plan containing unobserved
3
+ * entities must not pass as clean.
4
+ *
5
+ * `buildChangeSet` (./change-set.ts) already refuses to guess when a lexicon
6
+ * could not look (#1089) — entries land as `action: "unobserved"`, carrying a
7
+ * total `unobservedReason` (`read-failed`, `no-credentials`, `no-binding`,
8
+ * `unsupported-kind`, `filtered`; see ../observation.ts). But nothing
9
+ * downstream is *required* to look: a gate that checks lint-clean,
10
+ * owned-only, and a blast-radius budget can walk right past a change set
11
+ * whose live read partially failed and call it clean, because none of those
12
+ * checks reads `unobserved` at all. A gate over an incomplete read is a gate
13
+ * over a guess.
14
+ *
15
+ * This is a reusable predicate, not a runtime — evaluate a `ChangeSet`
16
+ * against a policy and get back a verdict; the caller (a `lint.policies`
17
+ * check routed through `policyGate`, an Op step, #1487's gate-as-fact once it
18
+ * lands) decides what to do with a refusal (throw, diagnostic, a durable
19
+ * gated-run fact). Same instinct #1484 already adopts for `unknown` in the
20
+ * operating loop ("`unknown` never remediates"), surfaced here as something
21
+ * any gate can declare rather than a property of one runtime.
22
+ */
23
+ import type { ChangeSet } from "./change-set";
24
+ import { unobservedReasonText, type UnobservedReason } from "../observation";
25
+
26
+ /**
27
+ * How the gate treats the change set's unobserved set.
28
+ *
29
+ * - `"refuse"` (the default) — any unobserved entry not covered by `allow`
30
+ * fails the gate outright. Fail-closed, the right default for a policy set
31
+ * aimed at agent-proposed change sets: a hole must never launder into a
32
+ * clean verdict just because nobody configured otherwise.
33
+ * - `"escalate"` — the gate does not fail here; instead the verdict reports
34
+ * `escalate: true` so a caller can route the plan to a stricter gate class
35
+ * rather than the ordinary path (the same shape of routing decision #1569
36
+ * makes for pipeline changes).
37
+ * - `{ allow: [...] }` — the listed reasons are tolerated (e.g. `"filtered"`,
38
+ * intentional in a scoped run) and never fail or escalate the gate; any
39
+ * reason NOT in the list still refuses. `no-credentials` — a hole that must
40
+ * never launder into a clean verdict — should never appear in an `allow`
41
+ * list for a policy aimed at agent-proposed change sets.
42
+ */
43
+ export type UnobservedGatePolicy = "refuse" | "escalate" | { allow: UnobservedReason[] };
44
+
45
+ /** One unobserved hole the gate found, carried into the refusal. */
46
+ export interface UnobservedGateFinding {
47
+ name: string;
48
+ type?: string;
49
+ reason: UnobservedReason;
50
+ detail?: string;
51
+ }
52
+
53
+ export interface UnobservedGateVerdict {
54
+ /** False when the policy is `"refuse"` (default) and an unallowed unobserved entry exists. */
55
+ pass: boolean;
56
+ /** True when the policy is `"escalate"` and an unallowed unobserved entry exists — route to a stricter gate instead of failing here. */
57
+ escalate: boolean;
58
+ /** Every unobserved entry the policy did not allow. Empty when `pass` is true and `escalate` is false. */
59
+ findings: UnobservedGateFinding[];
60
+ /** Human-readable summary carrying the reasons into a refusal — the message a caller can surface as-is. Undefined when `findings` is empty. */
61
+ detail?: string;
62
+ }
63
+
64
+ /**
65
+ * Evaluate a `ChangeSet`'s unobserved set against `policy`. Pure — reads only
66
+ * `cs.entries`, no I/O. Defaults to `"refuse"`.
67
+ */
68
+ export function evaluateUnobservedGate(
69
+ cs: ChangeSet,
70
+ policy: UnobservedGatePolicy = "refuse",
71
+ ): UnobservedGateVerdict {
72
+ const allowed = typeof policy === "object" ? new Set(policy.allow) : undefined;
73
+
74
+ const findings: UnobservedGateFinding[] = [];
75
+ for (const e of cs.entries) {
76
+ if (e.action !== "unobserved" || e.unobservedReason === undefined) continue;
77
+ if (allowed?.has(e.unobservedReason)) continue;
78
+ findings.push({
79
+ name: e.name,
80
+ ...(e.type ? { type: e.type } : {}),
81
+ reason: e.unobservedReason,
82
+ ...(e.unobservedDetail ? { detail: e.unobservedDetail } : {}),
83
+ });
84
+ }
85
+
86
+ if (findings.length === 0) {
87
+ return { pass: true, escalate: false, findings: [] };
88
+ }
89
+
90
+ const detail =
91
+ `plan contains ${findings.length} unobserved ${findings.length === 1 ? "entity" : "entities"} — ` +
92
+ findings
93
+ .map((f) => `${f.name}${f.type ? ` (${f.type})` : ""}: ${unobservedReasonText(f.reason)}${f.detail ? ` (${f.detail})` : ""}`)
94
+ .join(", ");
95
+
96
+ if (policy === "escalate") {
97
+ return { pass: true, escalate: true, findings, detail };
98
+ }
99
+
100
+ // "refuse" (default) and `{ allow }` both fail closed on anything not allowed.
101
+ return { pass: false, escalate: false, findings, detail };
102
+ }
@@ -1,4 +1,4 @@
1
- import type { LintRule, LintDiagnostic, LintContext } from "./rule";
1
+ import type { LintRule, LintDiagnostic, LintContext, LintProjectConfig } from "./rule";
2
2
  import type { IntrinsicDef } from "../lexicon";
3
3
  import { parseFile } from "./parser";
4
4
  import { readFileSync } from "fs";
@@ -203,6 +203,10 @@ function isDiagnosticDisabled(
203
203
  * `IntrinsicDef[]` into the fold path since #1039/#1105. Optional and
204
204
  * defaulting to none, so a caller that hasn't resolved a project's
205
205
  * lexicons (a unit test, `bench.test.ts`) is unaffected.
206
+ * @param projectConfig - chant #1221 — the project's config slice for
207
+ * config-aware rules (COR021 reads `environments` + `ownership`), put on
208
+ * every file's `LintContext.projectConfig`. Optional; without it those
209
+ * rules stay silent.
206
210
  * @returns LintRunResult with diagnostics and suppressed items
207
211
  */
208
212
  export async function runLint(
@@ -210,6 +214,7 @@ export async function runLint(
210
214
  rules: LintRule[],
211
215
  ruleOptions?: Map<string, Record<string, unknown>>,
212
216
  intrinsics?: readonly IntrinsicDef[],
217
+ projectConfig?: LintProjectConfig,
213
218
  ): Promise<LintRunResult> {
214
219
  const allDiagnostics: LintDiagnostic[] = [];
215
220
  const allSuppressed: Array<LintDiagnostic & { reason?: string }> = [];
@@ -231,6 +236,7 @@ export async function runLint(
231
236
  filePath,
232
237
  lexicon: undefined,
233
238
  intrinsics,
239
+ projectConfig,
234
240
  };
235
241
 
236
242
  // Execute each rule
@@ -0,0 +1,80 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { coreKnowledgeChecks, STALE_KNOWLEDGE_BINDING_CHECK_ID } from "./knowledge-checks";
3
+ import { runPostSynthChecks } from "./post-synth";
4
+ import { DECLARABLE_MARKER, type Declarable } from "../declarable";
5
+ import type { OkfBundle, OkfConcept } from "../okf-read";
6
+
7
+ function entity(): Declarable {
8
+ return { [DECLARABLE_MARKER]: true, lexicon: "aws", entityType: "AWS::S3::Bucket" } as unknown as Declarable;
9
+ }
10
+
11
+ function concept(overrides: Partial<OkfConcept> = {}): OkfConcept {
12
+ return {
13
+ path: "decisions/example.md",
14
+ type: "decision",
15
+ binds: [],
16
+ frontmatter: {},
17
+ body: "",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ function run(bundle: OkfBundle, entities: Map<string, Declarable>) {
23
+ return runPostSynthChecks(coreKnowledgeChecks(bundle), {
24
+ outputs: new Map(),
25
+ entities,
26
+ warnings: [],
27
+ errors: [],
28
+ sourceFileCount: 1,
29
+ });
30
+ }
31
+
32
+ describe("COR026: stale knowledge binding (#1865)", () => {
33
+ test("check id", () => {
34
+ expect(STALE_KNOWLEDGE_BINDING_CHECK_ID).toBe("COR026");
35
+ expect(coreKnowledgeChecks({ concepts: [] }).map((c) => c.id)).toContain("COR026");
36
+ });
37
+
38
+ test("fires once per unresolved binding, naming the concept path and the unresolved name", () => {
39
+ const bundle: OkfBundle = {
40
+ concepts: [concept({ path: "decisions/ghost.md", title: "Stale", binds: ["ghostBucket"] })],
41
+ };
42
+ const diags = run(bundle, new Map());
43
+ expect(diags).toHaveLength(1);
44
+ expect(diags[0].checkId).toBe("COR026");
45
+ expect(diags[0].severity).toBe("warning");
46
+ expect(diags[0].message).toContain("decisions/ghost.md");
47
+ expect(diags[0].message).toContain("ghostBucket");
48
+ });
49
+
50
+ test("fires one diagnostic per unresolved name when a concept binds several", () => {
51
+ const bundle: OkfBundle = {
52
+ concepts: [concept({ binds: ["ghostOne", "ghostTwo"] })],
53
+ };
54
+ const diags = run(bundle, new Map());
55
+ expect(diags).toHaveLength(2);
56
+ expect(diags.map((d) => d.message).join("\n")).toContain("ghostOne");
57
+ expect(diags.map((d) => d.message).join("\n")).toContain("ghostTwo");
58
+ });
59
+
60
+ test("does not fire when the bound name resolves to a discovered entity", () => {
61
+ const bundle: OkfBundle = { concepts: [concept({ binds: ["realBucket"] })] };
62
+ const entities = new Map<string, Declarable>([["realBucket", entity()]]);
63
+ expect(run(bundle, entities)).toHaveLength(0);
64
+ });
65
+
66
+ test("does not fire for a concept with no binds at all — orphaned knowledge is legitimate", () => {
67
+ const bundle: OkfBundle = { concepts: [concept({ binds: [] })] };
68
+ expect(run(bundle, new Map())).toHaveLength(0);
69
+ });
70
+
71
+ test("does not fire for an empty bundle (no knowledge directory)", () => {
72
+ expect(run({ concepts: [] }, new Map())).toHaveLength(0);
73
+ });
74
+
75
+ test("build and synthesis are unaffected — severity is always warning, never error", () => {
76
+ const bundle: OkfBundle = { concepts: [concept({ binds: ["ghost"] })] };
77
+ const diags = run(bundle, new Map());
78
+ expect(diags.every((d) => d.severity === "warning")).toBe(true);
79
+ });
80
+ });