@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.
- package/dist/audit/core.d.ts +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +8 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +15 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +6 -4
- package/dist/lifecycle/teardown.d.ts.map +1 -1
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +12 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +3 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +98 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +80 -0
- package/src/cli/commands/build.ts +106 -8
- package/src/cli/commands/lint.ts +15 -3
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/lifecycle.test.ts +115 -1
- package/src/cli/handlers/lifecycle.ts +84 -11
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/components/auto-release.ts +6 -0
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/config.test.ts +15 -0
- package/src/config.ts +30 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/fold/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +48 -3
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/teardown.test.ts +31 -0
- package/src/lifecycle/teardown.ts +6 -4
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +7 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +139 -1
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +12 -0
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
package/src/lexicon.ts
CHANGED
|
@@ -439,6 +439,24 @@ export interface BuildRootContext {
|
|
|
439
439
|
projectRoot: string;
|
|
440
440
|
/** The resolved project configuration, for the lexicon's own namespace. */
|
|
441
441
|
config: Record<string, unknown>;
|
|
442
|
+
/**
|
|
443
|
+
* The discovered entities, read-only (#1828 / SOPS provenance). A
|
|
444
|
+
* contributor that reacts to what the project DECLARED — rather than to
|
|
445
|
+
* what its config listed — reads them here: the committed-encrypted
|
|
446
|
+
* `declareSecret()` hook resolves each declaration's `file` into a sidecar
|
|
447
|
+
* entity, and any future declaration-driven contributor needs the same.
|
|
448
|
+
*
|
|
449
|
+
* Read-only on purpose. The merge that follows is the only thing that adds
|
|
450
|
+
* to the entity set, and it refuses a name collision rather than
|
|
451
|
+
* overwriting; a contributor mutating the map directly would slip past
|
|
452
|
+
* that. Contributors run in order, so a contributor also sees entities
|
|
453
|
+
* earlier contributors added.
|
|
454
|
+
*
|
|
455
|
+
* Optional: a caller that has no entity set (a plugin hook invoked
|
|
456
|
+
* directly, a graph mode that never discovered) omits it, and a hook must
|
|
457
|
+
* treat an absent map as an empty one.
|
|
458
|
+
*/
|
|
459
|
+
entities?: ReadonlyMap<string, Declarable>;
|
|
442
460
|
}
|
|
443
461
|
|
|
444
462
|
/**
|
|
@@ -450,6 +468,17 @@ export interface BuildRootContribution {
|
|
|
450
468
|
warnings?: string[];
|
|
451
469
|
}
|
|
452
470
|
|
|
471
|
+
/**
|
|
472
|
+
* A plugin's `buildRoots` hook, already bound to this invocation's project
|
|
473
|
+
* root and config (`collectBuildRootContributors`, ./cli/plugins.ts). What is
|
|
474
|
+
* NOT bindable that early is the entity set — discovery has not run yet — so
|
|
475
|
+
* the merge supplies it when it calls the closure. A contributor that ignores
|
|
476
|
+
* the argument is still assignable, which is what every pre-#1828 hook does.
|
|
477
|
+
*/
|
|
478
|
+
export type BuildRootContributor = (
|
|
479
|
+
ctx: Pick<BuildRootContext, "entities">,
|
|
480
|
+
) => Promise<BuildRootContribution>;
|
|
481
|
+
|
|
453
482
|
export interface LexiconPlugin {
|
|
454
483
|
// ── Required ──────────────────────────────────────────────
|
|
455
484
|
/** Human-readable name (e.g. "aws", "gcp") */
|
|
@@ -540,6 +569,17 @@ export interface LexiconPlugin {
|
|
|
540
569
|
*/
|
|
541
570
|
auditCatalog?(): Record<string, RuleMeta>;
|
|
542
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Parse standalone template content (a file audit discovery classified for
|
|
574
|
+
* this lexicon) into the lexicon's entity graph, keyed the way `ctx.entities`
|
|
575
|
+
* is during a build. Lets entity-reading post-synth checks fire on
|
|
576
|
+
* `chant audit` of hand-written manifests — parse-to-graph rather than
|
|
577
|
+
* output-reading rule variants (#1567). Implementations must tolerate
|
|
578
|
+
* arbitrary external content: a malformed document yields no entities, never
|
|
579
|
+
* a throw. Omit for lexicons whose audit checks read `ctx.outputs`.
|
|
580
|
+
*/
|
|
581
|
+
auditEntities?(content: string): Map<string, Declarable>;
|
|
582
|
+
|
|
543
583
|
/**
|
|
544
584
|
* Machine-readable spec-coverage accounting for `check-lexicon` (#1330).
|
|
545
585
|
*
|
|
@@ -1115,10 +1155,15 @@ export interface TeardownOutcome {
|
|
|
1115
1155
|
/**
|
|
1116
1156
|
* - `deleted` — gone, including already-gone (deletion is idempotent);
|
|
1117
1157
|
* - `failed` — the delete errored (core retries these once);
|
|
1118
|
-
* - `not-prunable` — deliberately not deleted; `detail` says why
|
|
1158
|
+
* - `not-prunable` — deliberately not deleted; `detail` says why;
|
|
1159
|
+
* - `retained` — owned and no longer declared, but deliberately kept
|
|
1160
|
+
* (#1365 decision 5): a `generated-once` secret never enters the prunable
|
|
1161
|
+
* set, because deleting it would destroy the only copy of material chant
|
|
1162
|
+
* never held. Reported loudly, never deleted; deletion is an explicit act
|
|
1163
|
+
* (`kubectl delete`, or a future gated op), never a sweep's.
|
|
1119
1164
|
*/
|
|
1120
|
-
outcome: "deleted" | "failed" | "not-prunable";
|
|
1121
|
-
/** The error for `failed`, the reason for `not-prunable`. */
|
|
1165
|
+
outcome: "deleted" | "failed" | "not-prunable" | "retained";
|
|
1166
|
+
/** The error for `failed`, the reason for `not-prunable`/`retained`. */
|
|
1122
1167
|
detail?: string;
|
|
1123
1168
|
}
|
|
1124
1169
|
|
|
@@ -31,11 +31,30 @@ import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
|
31
31
|
* not drift, just the runtime doing its job. `runtimeOwner` names the
|
|
32
32
|
* declared entity it belongs to.
|
|
33
33
|
* - `noop` — declared and live with no drift, or already reconciled.
|
|
34
|
+
* - `effect` — a declared effect receipt (#1832) whose live value is absent or
|
|
35
|
+
* differs from the resolved expectation: the effect step will fire. Never a
|
|
36
|
+
* `create` or `update` — the generic apply path is observe-only to receipts,
|
|
37
|
+
* and the `effect()` step is the sole writer (epic #1703, decision 3). Read
|
|
38
|
+
* `effect` for the effect's identity and `effectReason` for why it fires.
|
|
34
39
|
* - `unobserved` — declared, and the lexicon could not look (#1089). Not a
|
|
35
40
|
* proposal at all: it is the plan admitting a hole. Never a create, never a
|
|
36
41
|
* delete. Read `unobservedReason` for which hole.
|
|
37
42
|
*/
|
|
38
|
-
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime" | "noop" | "unobserved";
|
|
43
|
+
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime" | "noop" | "effect" | "unobserved";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Why an `effect` entry proposes a fire (#1832).
|
|
47
|
+
*
|
|
48
|
+
* - `receipt-absent` — the provider confirmed the receipt absent: the effect
|
|
49
|
+
* has never recorded a run (or the run crashed before the write — the
|
|
50
|
+
* at-least-once case this classification exists to preserve).
|
|
51
|
+
* - `receipt-stale` — the receipt is live but its value differs from the
|
|
52
|
+
* resolved expectation: the effect's inputs changed since the last run.
|
|
53
|
+
* - `unresolved-input` — a reference input could not resolve at plan time, so
|
|
54
|
+
* the expectation cannot be computed. The fire is proposed rather than
|
|
55
|
+
* guessed away; the effect step resolves again at run.
|
|
56
|
+
*/
|
|
57
|
+
export type EffectFireReason = "receipt-absent" | "receipt-stale" | "unresolved-input";
|
|
39
58
|
|
|
40
59
|
/**
|
|
41
60
|
* Who answers "is this resource chant's?". `unknown` until a live ownership
|
|
@@ -100,6 +119,16 @@ export interface ChangeSetEntry {
|
|
|
100
119
|
queried?: string;
|
|
101
120
|
/** The declared entity this resource's owner chain resolves to, for `action: "runtime"` (#1077). */
|
|
102
121
|
runtimeOwner?: string;
|
|
122
|
+
/**
|
|
123
|
+
* The effect a receipt witnesses (#1832), for entries derived from an effect
|
|
124
|
+
* receipt. On `action: "effect"` it names what will fire; on a receipt's
|
|
125
|
+
* `noop`/`unobserved` rows it keeps the attribution.
|
|
126
|
+
*/
|
|
127
|
+
effect?: string;
|
|
128
|
+
/** Why the effect fires, for `action: "effect"` (#1832). */
|
|
129
|
+
effectReason?: EffectFireReason;
|
|
130
|
+
/** Human-readable backing for `effectReason` (the digests that differ, the unresolved path). */
|
|
131
|
+
effectDetail?: string;
|
|
103
132
|
}
|
|
104
133
|
|
|
105
134
|
export interface ChangeSet {
|
|
@@ -234,13 +263,14 @@ export function buildChangeSet(env: string, input: DiffLiveInput, options?: Chan
|
|
|
234
263
|
return { env, entries };
|
|
235
264
|
}
|
|
236
265
|
|
|
237
|
-
const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "runtime", "noop", "unobserved"];
|
|
266
|
+
const ACTION_ORDER: ChangeAction[] = ["create", "update", "effect", "delete", "adopt", "runtime", "noop", "unobserved"];
|
|
238
267
|
|
|
239
268
|
/** Count entries per action. */
|
|
240
269
|
export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
241
270
|
const counts: Record<ChangeAction, number> = {
|
|
242
271
|
create: 0,
|
|
243
272
|
update: 0,
|
|
273
|
+
effect: 0,
|
|
244
274
|
delete: 0,
|
|
245
275
|
adopt: 0,
|
|
246
276
|
runtime: 0,
|
|
@@ -257,10 +287,10 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
|
257
287
|
* GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
|
|
258
288
|
* UI as "N to add, M to change, K to delete". The format is generic — any tool
|
|
259
289
|
* that emits this JSON gets the widget — and the chant plan maps onto it
|
|
260
|
-
* directly. Only the mutating actions count: `adopt`, `runtime`, `noop
|
|
261
|
-
* `unobserved` are excluded, since the widget has no column for
|
|
262
|
-
* undeclared", "expected runtime child" (#1077), "no change",
|
|
263
|
-
* look" (#1089). The widget is therefore a floor, not a complete plan: read
|
|
290
|
+
* directly. Only the mutating actions count: `adopt`, `runtime`, `noop`,
|
|
291
|
+
* `effect` and `unobserved` are excluded, since the widget has no column for
|
|
292
|
+
* "live but undeclared", "expected runtime child" (#1077), "no change", "an
|
|
293
|
+
* effect will fire" (#1832), or "could not look" (#1089). The widget is therefore a floor, not a complete plan: read
|
|
264
294
|
* the full change set when entities are unobserved or classified runtime.
|
|
265
295
|
*
|
|
266
296
|
* The widget label reads "Terraform" regardless of producer; that is GitLab's
|
|
@@ -292,9 +322,18 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
292
322
|
? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
|
|
293
323
|
: action === "runtime"
|
|
294
324
|
? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
|
|
295
|
-
:
|
|
325
|
+
: action === "effect"
|
|
326
|
+
? "\nEFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt):"
|
|
327
|
+
: `\n${action.toUpperCase()}:`,
|
|
296
328
|
);
|
|
297
329
|
for (const e of group) {
|
|
330
|
+
if (e.action === "effect") {
|
|
331
|
+
lines.push(
|
|
332
|
+
` effect will fire: ${e.effect ?? e.name} — receipt ${e.name}${e.type ? ` (${e.type})` : ""}` +
|
|
333
|
+
`${e.effectDetail ? ` — ${e.effectDetail}` : ""}`,
|
|
334
|
+
);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
298
337
|
const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
|
|
299
338
|
const why = e.unobservedReason
|
|
300
339
|
? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ export * from "./deep-diff";
|
|
|
7
7
|
export * from "./deep-observe";
|
|
8
8
|
export * from "./observation-baseline";
|
|
9
9
|
export * from "./change-set";
|
|
10
|
+
export * from "./unobserved-gate";
|
|
11
|
+
export * from "./receipt-plan";
|
|
10
12
|
export * from "./affected";
|
|
11
13
|
export * from "./release-ledger";
|
|
12
14
|
export * from "./build-ledger";
|
|
@@ -97,3 +97,49 @@ describe("acceptDeviations", () => {
|
|
|
97
97
|
expect(countAccepted(null)).toBe(0);
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("acceptDeviations refuses effect receipts (#1833)", () => {
|
|
102
|
+
const now = "2026-08-24T00:00:00.000Z";
|
|
103
|
+
|
|
104
|
+
test("refuses a deviation whose type is the core receipt entityType", () => {
|
|
105
|
+
expect(() =>
|
|
106
|
+
acceptDeviations(emptyBaseline("prod"), "chant", [
|
|
107
|
+
{ entity: "seededReceipt", type: "Chant::EffectReceipt", path: "value", value: "gone" },
|
|
108
|
+
], { now }),
|
|
109
|
+
).toThrow(/effect receipt "seededReceipt"/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("refuses a deviation on an entity the caller recognized as a receipt (materialized row)", () => {
|
|
113
|
+
expect(() =>
|
|
114
|
+
acceptDeviations(emptyBaseline("prod"), "aws", [
|
|
115
|
+
{ entity: "migratedReceipt", type: "AWS::SSM::Parameter", path: "Value", value: "stale" },
|
|
116
|
+
], { now, receipts: new Set(["migratedReceipt"]) }),
|
|
117
|
+
).toThrow(/effect receipt "migratedReceipt"/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the refusal names the receipt and the effect step as sole writer, and records nothing", () => {
|
|
121
|
+
const before = emptyBaseline("prod");
|
|
122
|
+
let error: Error | undefined;
|
|
123
|
+
try {
|
|
124
|
+
acceptDeviations(before, "aws", [
|
|
125
|
+
{ entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
|
|
126
|
+
{ entity: "migratedReceipt", type: "AWS::SSM::Parameter", path: "Value", value: "stale" },
|
|
127
|
+
], { now, receipts: new Set(["migratedReceipt"]) });
|
|
128
|
+
} catch (e) {
|
|
129
|
+
error = e as Error;
|
|
130
|
+
}
|
|
131
|
+
expect(error).toBeDefined();
|
|
132
|
+
expect(error!.message).toContain('effect receipt "migratedReceipt"');
|
|
133
|
+
expect(error!.message).toContain("only writer");
|
|
134
|
+
expect(error!.message).toContain("defuse the effect");
|
|
135
|
+
// The whole acceptance aborts — the non-receipt row was not recorded either.
|
|
136
|
+
expect(before.lexicons).toEqual({});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("passes: non-receipt deviations accept as before, receipts set present", () => {
|
|
140
|
+
const b = acceptDeviations(emptyBaseline("prod"), "aws", [
|
|
141
|
+
{ entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
|
|
142
|
+
], { now, receipts: new Set(["migratedReceipt"]) });
|
|
143
|
+
expect(baselineForLexicon(b, "aws").Role.accepted).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
import { readBlobFromPath, writeBlobToPath } from "./git";
|
|
39
39
|
import { sortedJsonReplacer } from "../utils";
|
|
40
|
+
import { EFFECT_RECEIPT_ENTITY_TYPE } from "../effect-receipt";
|
|
40
41
|
|
|
41
42
|
/** The file name under `<environment>/` on the orphan branch. */
|
|
42
43
|
export const OBSERVATION_BASELINE_FILE = "observation-baseline.json";
|
|
@@ -140,19 +141,50 @@ export interface DeviationToAccept {
|
|
|
140
141
|
note?: string;
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
/** Options for {@link acceptDeviations}. */
|
|
145
|
+
export interface AcceptDeviationsOptions {
|
|
146
|
+
/** ISO timestamp to stamp instead of the wall clock (tests, replays). */
|
|
147
|
+
now?: string;
|
|
148
|
+
/**
|
|
149
|
+
* Entity names the caller recognized as effect receipts (#1833) — the
|
|
150
|
+
* marker-based read (`collectEffectReceipts`, ../effect-receipt.ts) over
|
|
151
|
+
* the build's entities, which is how a lexicon-materialized receipt row is
|
|
152
|
+
* caught even though its `entityType` is the row's own. Core-typed
|
|
153
|
+
* receipts are refused regardless, via {@link DeviationToAccept.type}.
|
|
154
|
+
*/
|
|
155
|
+
receipts?: ReadonlySet<string>;
|
|
156
|
+
}
|
|
157
|
+
|
|
143
158
|
/**
|
|
144
159
|
* Record deviations as accepted, returning a new baseline (the input is not
|
|
145
160
|
* mutated). An existing acceptance for the same entity+path is replaced — that
|
|
146
161
|
* is how re-accepting after a deliberate change works, and it keeps the file
|
|
147
162
|
* from growing a second entry for every value a path has ever held.
|
|
163
|
+
*
|
|
164
|
+
* REFUSES a deviation on an effect receipt (#1833, epic #1703), throwing
|
|
165
|
+
* before anything is recorded: a receipt is the declared witness that its
|
|
166
|
+
* effect ran, and the `effect()` step is its only writer — baselining a
|
|
167
|
+
* receipt's live value would accept "the effect never fired" as the new
|
|
168
|
+
* normal and silently defuse the effect. The whole acceptance aborts, not
|
|
169
|
+
* just the receipt's row, so a partial baseline never lands.
|
|
148
170
|
*/
|
|
149
171
|
export function acceptDeviations(
|
|
150
172
|
baseline: ObservationBaseline,
|
|
151
173
|
lexicon: string,
|
|
152
174
|
deviations: readonly DeviationToAccept[],
|
|
153
|
-
opts?:
|
|
175
|
+
opts?: AcceptDeviationsOptions,
|
|
154
176
|
): ObservationBaseline {
|
|
155
177
|
if (deviations.length === 0) return baseline;
|
|
178
|
+
for (const dev of deviations) {
|
|
179
|
+
if (dev.type === EFFECT_RECEIPT_ENTITY_TYPE || opts?.receipts?.has(dev.entity)) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`cannot accept drift on effect receipt "${dev.entity}" (path ${dev.path}): ` +
|
|
182
|
+
`the effect() step is the receipt's only writer — accepting the live value would ` +
|
|
183
|
+
`silently defuse the effect. Re-run the effect (or change its inputs) instead of ` +
|
|
184
|
+
`baselining the receipt.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
156
188
|
const now = opts?.now ?? new Date().toISOString();
|
|
157
189
|
const lexicons: Record<string, BaselineLexicon> = { ...baseline.lexicons };
|
|
158
190
|
const entities: BaselineLexicon = { ...(lexicons[lexicon] ?? {}) };
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
planReceipts,
|
|
4
|
+
mergeReceiptEntries,
|
|
5
|
+
observedValueResolver,
|
|
6
|
+
readReceiptValue,
|
|
7
|
+
type ReceiptReading,
|
|
8
|
+
} from "./receipt-plan";
|
|
9
|
+
import { buildChangeSet, renderChangeSet, summarize, type ChangeSet } from "./change-set";
|
|
10
|
+
import {
|
|
11
|
+
EffectReceipt,
|
|
12
|
+
EXISTENCE_EXPECTATION,
|
|
13
|
+
receiptExpectation,
|
|
14
|
+
resolveReceiptExpectation,
|
|
15
|
+
collectEffectReceipts,
|
|
16
|
+
type EffectReceiptDeclaration,
|
|
17
|
+
} from "../effect-receipt";
|
|
18
|
+
import { AttrRef } from "../attrref";
|
|
19
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
20
|
+
import type { ReceiptInputResolver } from "../effect-receipt";
|
|
21
|
+
|
|
22
|
+
const noRefs: ReceiptInputResolver = (_ref, path) => {
|
|
23
|
+
throw new Error(`unexpected reference at ${path}`);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function receiptsOf(...decls: Array<[string, EffectReceiptDeclaration]>): Map<string, EffectReceiptDeclaration> {
|
|
27
|
+
return new Map(decls);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("planReceipts", () => {
|
|
31
|
+
it("absent receipt proposes the fire", () => {
|
|
32
|
+
const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
|
|
33
|
+
const readings = new Map<string, ReceiptReading>([
|
|
34
|
+
["dbMigrated", { observed: true, present: false, lexicon: "aws" }],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const entries = planReceipts(receiptsOf(["dbMigrated", r]), readings, noRefs);
|
|
38
|
+
|
|
39
|
+
expect(entries).toHaveLength(1);
|
|
40
|
+
expect(entries[0].action).toBe("effect");
|
|
41
|
+
expect(entries[0].effect).toBe("db-migrate");
|
|
42
|
+
expect(entries[0].effectReason).toBe("receipt-absent");
|
|
43
|
+
expect(entries[0].evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("stale hash proposes the fire, with both values in the detail", () => {
|
|
47
|
+
const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { rows: 10 } });
|
|
48
|
+
const expected = receiptExpectation(r);
|
|
49
|
+
const readings = new Map<string, ReceiptReading>([
|
|
50
|
+
["seeded", { observed: true, present: true, value: "sha256:0000", lexicon: "aws" }],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const entries = planReceipts(receiptsOf(["seeded", r]), readings, noRefs);
|
|
54
|
+
|
|
55
|
+
expect(entries[0].action).toBe("effect");
|
|
56
|
+
expect(entries[0].effectReason).toBe("receipt-stale");
|
|
57
|
+
expect(entries[0].effectDetail).toContain("sha256:0000");
|
|
58
|
+
expect(entries[0].effectDetail).toContain(expected);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("matching receipt is a clean noop", () => {
|
|
62
|
+
const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { rows: 10 } });
|
|
63
|
+
const readings = new Map<string, ReceiptReading>([
|
|
64
|
+
["seeded", { observed: true, present: true, value: receiptExpectation(r), lexicon: "aws" }],
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const entries = planReceipts(receiptsOf(["seeded", r]), readings, noRefs);
|
|
68
|
+
|
|
69
|
+
expect(entries[0].action).toBe("noop");
|
|
70
|
+
expect(entries[0].effect).toBe("seed");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("matching existence receipt is a clean noop", () => {
|
|
74
|
+
const r = EffectReceipt("bootstrapped", { effect: "bootstrap", flavor: "existence" });
|
|
75
|
+
const readings = new Map<string, ReceiptReading>([
|
|
76
|
+
["bootstrapped", { observed: true, present: true, value: EXISTENCE_EXPECTATION }],
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
expect(planReceipts(receiptsOf(["bootstrapped", r]), readings, noRefs)[0].action).toBe("noop");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("a lexicon that could not look yields unobserved with its reason, never a clean row", () => {
|
|
83
|
+
const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
|
|
84
|
+
const readings = new Map<string, ReceiptReading>([
|
|
85
|
+
["dbMigrated", { observed: false, present: false, unobservedReason: "no-credentials", unobservedDetail: "no AWS credentials" }],
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const entries = planReceipts(receiptsOf(["dbMigrated", r]), readings, noRefs);
|
|
89
|
+
|
|
90
|
+
expect(entries[0].action).toBe("unobserved");
|
|
91
|
+
expect(entries[0].unobservedReason).toBe("no-credentials");
|
|
92
|
+
expect(entries[0].unobservedDetail).toBe("no AWS credentials");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("a receipt no lexicon observes yields unobserved, loudly, never silently clean", () => {
|
|
96
|
+
const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
|
|
97
|
+
|
|
98
|
+
const entries = planReceipts(receiptsOf(["dbMigrated", r]), new Map(), noRefs);
|
|
99
|
+
|
|
100
|
+
expect(entries[0].action).toBe("unobserved");
|
|
101
|
+
expect(entries[0].unobservedReason).toBe("unsupported-kind");
|
|
102
|
+
expect(entries[0].unobservedDetail).toContain("db-migrate");
|
|
103
|
+
expect(entries[0].unobservedDetail).toContain("unknown, not clean");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("a reference that cannot resolve at plan time proposes the fire with an unresolved-input note", () => {
|
|
107
|
+
const parent = { entityType: "Test::Db" };
|
|
108
|
+
const ref = new AttrRef(parent, "endpoint");
|
|
109
|
+
ref._setLogicalName("db");
|
|
110
|
+
const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { endpoint: ref } });
|
|
111
|
+
const readings = new Map<string, ReceiptReading>([
|
|
112
|
+
["seeded", { observed: true, present: true, value: "sha256:live" }],
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
// Resolver over observed values that do NOT include db.endpoint.
|
|
116
|
+
const entries = planReceipts(receiptsOf(["seeded", r]), readings, observedValueResolver({}));
|
|
117
|
+
|
|
118
|
+
expect(entries[0].action).toBe("effect");
|
|
119
|
+
expect(entries[0].effectReason).toBe("unresolved-input");
|
|
120
|
+
expect(entries[0].effectDetail).toContain("unresolved input");
|
|
121
|
+
expect(entries[0].effectDetail).toContain("db.endpoint");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("a reference input resolves against observed values and the comparison uses the resolved digest", () => {
|
|
125
|
+
const parent = { entityType: "Test::Db" };
|
|
126
|
+
const ref = new AttrRef(parent, "endpoint");
|
|
127
|
+
ref._setLogicalName("db");
|
|
128
|
+
const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { endpoint: ref } });
|
|
129
|
+
|
|
130
|
+
const observed: Record<string, ResourceMetadata> = {
|
|
131
|
+
db: { type: "Test::Db", status: "ok", attributes: { endpoint: "db.example.internal:5432" } },
|
|
132
|
+
};
|
|
133
|
+
const resolver = observedValueResolver(observed);
|
|
134
|
+
const expected = resolveReceiptExpectation(r, resolver);
|
|
135
|
+
const readings = new Map<string, ReceiptReading>([
|
|
136
|
+
["seeded", { observed: true, present: true, value: expected }],
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
expect(planReceipts(receiptsOf(["seeded", r]), readings, resolver)[0].action).toBe("noop");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("crash between effect and receipt write: the stale receipt re-proposes the fire on the next plan", () => {
|
|
143
|
+
// Run 1 wrote the receipt for inputs { schema: 1 }. The effect for
|
|
144
|
+
// { schema: 2 } ran, then the process died before the receipt write —
|
|
145
|
+
// the live value still carries run 1's digest.
|
|
146
|
+
const run1 = EffectReceipt("migrated", { effect: "migrate", flavor: "hash", inputs: { schema: 1 } });
|
|
147
|
+
const run2 = EffectReceipt("migrated", { effect: "migrate", flavor: "hash", inputs: { schema: 2 } });
|
|
148
|
+
const readings = new Map<string, ReceiptReading>([
|
|
149
|
+
["migrated", { observed: true, present: true, value: receiptExpectation(run1) }],
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const entries = planReceipts(receiptsOf(["migrated", run2]), readings, noRefs);
|
|
153
|
+
|
|
154
|
+
expect(entries[0].action).toBe("effect");
|
|
155
|
+
expect(entries[0].effectReason).toBe("receipt-stale");
|
|
156
|
+
|
|
157
|
+
// The first-run variant of the same crash: the effect ran, the write
|
|
158
|
+
// never happened, the receipt is absent — the fire is proposed again.
|
|
159
|
+
const absent = new Map<string, ReceiptReading>([["migrated", { observed: true, present: false }]]);
|
|
160
|
+
expect(planReceipts(receiptsOf(["migrated", run2]), absent, noRefs)[0].effectReason).toBe("receipt-absent");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("mergeReceiptEntries", () => {
|
|
165
|
+
it("replaces the generic create proposed for an absent receipt with the effect row", () => {
|
|
166
|
+
const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
|
|
167
|
+
const receipts = receiptsOf(["dbMigrated", r]);
|
|
168
|
+
|
|
169
|
+
// The generic classification knows nothing about receipts: declared and
|
|
170
|
+
// confirmed absent classifies as create.
|
|
171
|
+
const cs = buildChangeSet("prod", {
|
|
172
|
+
declared: new Set(["dbMigrated", "bucket"]),
|
|
173
|
+
observedNow: { bucket: { type: "Test::Bucket", status: "ok" } },
|
|
174
|
+
observedThen: undefined,
|
|
175
|
+
});
|
|
176
|
+
expect(cs.entries.find((e) => e.name === "dbMigrated")?.action).toBe("create");
|
|
177
|
+
|
|
178
|
+
const entries = planReceipts(receipts, new Map([["dbMigrated", { observed: true, present: false } as ReceiptReading]]), noRefs);
|
|
179
|
+
mergeReceiptEntries(cs, receipts, entries);
|
|
180
|
+
|
|
181
|
+
const row = cs.entries.filter((e) => e.name === "dbMigrated");
|
|
182
|
+
expect(row).toHaveLength(1);
|
|
183
|
+
expect(row[0].action).toBe("effect");
|
|
184
|
+
expect(summarize(cs).create).toBe(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("a live receipt is never a prune candidate: the generic delete is replaced", () => {
|
|
188
|
+
const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
|
|
189
|
+
const receipts = receiptsOf(["dbMigrated", r]);
|
|
190
|
+
|
|
191
|
+
// Worst case for the write-exclusion: the receipt is live and owned but
|
|
192
|
+
// missing from the declared axis the change set saw (exactly what
|
|
193
|
+
// withholding it from the apply-bound set produces) — the generic
|
|
194
|
+
// classification proposes delete, the prune candidacy a receipt must
|
|
195
|
+
// never have.
|
|
196
|
+
const cs = buildChangeSet("prod", {
|
|
197
|
+
declared: new Set<string>(),
|
|
198
|
+
observedNow: {
|
|
199
|
+
dbMigrated: { type: "AWS::SSM::Parameter", status: "ok", ownership: "owned" },
|
|
200
|
+
},
|
|
201
|
+
observedThen: undefined,
|
|
202
|
+
});
|
|
203
|
+
expect(cs.entries.find((e) => e.name === "dbMigrated")?.action).toBe("delete");
|
|
204
|
+
|
|
205
|
+
const entries = planReceipts(
|
|
206
|
+
receipts,
|
|
207
|
+
new Map([["dbMigrated", { observed: true, present: true, value: EXISTENCE_EXPECTATION } as ReceiptReading]]),
|
|
208
|
+
noRefs,
|
|
209
|
+
);
|
|
210
|
+
mergeReceiptEntries(cs, receipts, entries);
|
|
211
|
+
|
|
212
|
+
expect(cs.entries.filter((e) => e.action === "delete")).toHaveLength(0);
|
|
213
|
+
expect(cs.entries.filter((e) => e.action === "adopt")).toHaveLength(0);
|
|
214
|
+
expect(cs.entries.find((e) => e.name === "dbMigrated")?.action).toBe("noop");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("renderChangeSet with effect entries", () => {
|
|
219
|
+
it('renders "effect will fire: <effect>"', () => {
|
|
220
|
+
const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
|
|
221
|
+
const cs: ChangeSet = { env: "prod", entries: [] };
|
|
222
|
+
mergeReceiptEntries(
|
|
223
|
+
cs,
|
|
224
|
+
receiptsOf(["dbMigrated", r]),
|
|
225
|
+
planReceipts(receiptsOf(["dbMigrated", r]), new Map([["dbMigrated", { observed: true, present: false } as ReceiptReading]]), noRefs),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const rendered = renderChangeSet(cs);
|
|
229
|
+
expect(rendered).toContain("effect will fire: db-migrate");
|
|
230
|
+
expect(rendered).toContain("1 effect");
|
|
231
|
+
expect(rendered).toContain("the generic apply never writes a receipt");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("readReceiptValue", () => {
|
|
236
|
+
it("reads value, falling back to the provider's Value casing", () => {
|
|
237
|
+
expect(readReceiptValue({ value: "a" })).toBe("a");
|
|
238
|
+
expect(readReceiptValue({ Value: "b" })).toBe("b");
|
|
239
|
+
expect(readReceiptValue({ value: "a", Value: "b" })).toBe("a");
|
|
240
|
+
expect(readReceiptValue(undefined)).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("collectEffectReceipts over a mixed entity map", () => {
|
|
245
|
+
it("finds only marker-carrying entities", () => {
|
|
246
|
+
const r = EffectReceipt("dbMigrated", { effect: "db-migrate", flavor: "existence" });
|
|
247
|
+
const entities = new Map<string, EffectReceiptDeclaration>([["dbMigrated", r]]);
|
|
248
|
+
expect([...collectEffectReceipts(entities).keys()]).toEqual(["dbMigrated"]);
|
|
249
|
+
});
|
|
250
|
+
});
|