@intentius/chant 0.50.0 → 0.52.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 (75) hide show
  1. package/dist/cli/build-options.d.ts +68 -0
  2. package/dist/cli/build-options.d.ts.map +1 -0
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/op-progress.d.ts +57 -0
  6. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  7. package/dist/cli/handlers/run-client.d.ts +21 -1
  8. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  9. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  10. package/dist/cli/handlers/run.d.ts +0 -21
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/handlers/search.d.ts +22 -0
  13. package/dist/cli/handlers/search.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts.map +1 -1
  15. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  16. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  17. package/dist/cli/registry.d.ts +33 -1
  18. package/dist/cli/registry.d.ts.map +1 -1
  19. package/dist/components/run-progress.d.ts +7 -5
  20. package/dist/components/run-progress.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +41 -0
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/assert-live.d.ts +77 -0
  24. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  25. package/dist/lifecycle/change-set.d.ts +40 -0
  26. package/dist/lifecycle/change-set.d.ts.map +1 -1
  27. package/dist/lifecycle/disruption.d.ts +96 -0
  28. package/dist/lifecycle/disruption.d.ts.map +1 -0
  29. package/dist/lifecycle/index.d.ts +2 -0
  30. package/dist/lifecycle/index.d.ts.map +1 -1
  31. package/dist/lifecycle/replay.d.ts +2 -0
  32. package/dist/lifecycle/replay.d.ts.map +1 -1
  33. package/dist/lint/policy.d.ts +16 -0
  34. package/dist/lint/policy.d.ts.map +1 -1
  35. package/dist/op/local-executor.d.ts +22 -2
  36. package/dist/op/local-executor.d.ts.map +1 -1
  37. package/dist/testing.d.ts +23 -2
  38. package/dist/testing.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/build-options.test.ts +101 -0
  41. package/src/cli/build-options.ts +109 -0
  42. package/src/cli/commands/build.ts +24 -53
  43. package/src/cli/handlers/lifecycle.test.ts +109 -0
  44. package/src/cli/handlers/lifecycle.ts +32 -8
  45. package/src/cli/handlers/op-progress.test.ts +202 -0
  46. package/src/cli/handlers/op-progress.ts +192 -0
  47. package/src/cli/handlers/run-client.test.ts +82 -0
  48. package/src/cli/handlers/run-client.ts +85 -2
  49. package/src/cli/handlers/run-report.test.ts +62 -0
  50. package/src/cli/handlers/run-report.ts +20 -58
  51. package/src/cli/handlers/run.test.ts +240 -0
  52. package/src/cli/handlers/run.ts +76 -18
  53. package/src/cli/handlers/search-drift.test.ts +263 -0
  54. package/src/cli/handlers/search.ts +150 -1
  55. package/src/cli/main.ts +11 -0
  56. package/src/cli/mcp/op-tools.ts +17 -6
  57. package/src/cli/mcp/resource-handlers.ts +13 -5
  58. package/src/cli/registry.ts +33 -1
  59. package/src/components/run-progress.ts +9 -5
  60. package/src/lexicon.ts +51 -0
  61. package/src/lifecycle/assert-live.test.ts +125 -0
  62. package/src/lifecycle/assert-live.ts +154 -0
  63. package/src/lifecycle/change-set.test.ts +144 -1
  64. package/src/lifecycle/change-set.ts +165 -11
  65. package/src/lifecycle/disruption.test.ts +186 -0
  66. package/src/lifecycle/disruption.ts +224 -0
  67. package/src/lifecycle/index.ts +2 -0
  68. package/src/lifecycle/replay.test.ts +25 -0
  69. package/src/lifecycle/replay.ts +11 -3
  70. package/src/lint/policy-build-parity.test.ts +232 -0
  71. package/src/lint/policy.ts +51 -6
  72. package/src/op/local-executor.ts +35 -1
  73. package/src/op/local-output.ts +1 -1
  74. package/src/testing.test.ts +89 -2
  75. package/src/testing.ts +63 -3
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff";
16
16
  import { unobservedReasonText, type UnobservedReason } from "../observation";
17
+ import { renderDisruption, summarizeDisruption, type Disruption } from "./disruption";
17
18
 
18
19
  /**
19
20
  * What the projection proposes for a single resource.
@@ -129,6 +130,22 @@ export interface ChangeSetEntry {
129
130
  effectReason?: EffectFireReason;
130
131
  /** Human-readable backing for `effectReason` (the digests that differ, the unresolved path). */
131
132
  effectDetail?: string;
133
+ /**
134
+ * How much applying this change hurts (#1665) — in-place / rolling / replace
135
+ * / destroy / unknown. Set on `update` entries only: every other action
136
+ * carries its blast radius in the action itself.
137
+ *
138
+ * The verdict comes from the lexicon that owns the spec
139
+ * ({@link LexiconPlugin.classifyDisruption}), never from core, which has no
140
+ * per-provider replacement rules and must not grow any. `unknown` is the
141
+ * default and the only fallback — read it as "nobody could say", never as
142
+ * "probably in place".
143
+ */
144
+ disruption?: Disruption;
145
+ /** The attribute paths that forced `disruption` (#1665). */
146
+ disruptionBecause?: string[];
147
+ /** Human-readable backing for `disruption` — the spec knowledge behind the call, or why there is none. */
148
+ disruptionDetail?: string;
132
149
  }
133
150
 
134
151
  export interface ChangeSet {
@@ -308,24 +325,65 @@ export function gitlabMrReport(cs: ChangeSet): GitlabMrReport {
308
325
  return { create: counts.create, update: counts.update, delete: counts.delete };
309
326
  }
310
327
 
328
+ /**
329
+ * Warning a plan should print when it carries a hole — a declared entity the
330
+ * lexicon could not observe (#1089). The `--json` and `--report gitlab-mr`
331
+ * shapes have no column for `unobserved`, so both the CLI (on stderr) and the
332
+ * `markdown` report (in the body, since a reviewer never sees a job's stderr)
333
+ * read this same wording rather than drifting apart. Empty when the plan has
334
+ * no hole. Same discipline as {@link disruptionNotices} in `./disruption`.
335
+ */
336
+ export function unobservedPlanNotice(cs: ChangeSet): string[] {
337
+ const count = summarize(cs).unobserved;
338
+ return count > 0
339
+ ? [
340
+ `${count} declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.`,
341
+ ]
342
+ : [];
343
+ }
344
+
345
+ /**
346
+ * Section heading text for one action group — shared between {@link renderChangeSet}
347
+ * and {@link renderChangeSetMarkdown} so the wording never drifts between the
348
+ * terminal render and the reviewer-facing one.
349
+ */
350
+ function actionSectionLabel(action: ChangeAction, hasDisruption: boolean): string {
351
+ switch (action) {
352
+ case "unobserved":
353
+ return "UNOBSERVED (declared; chant could not read live state — no action proposed)";
354
+ case "runtime":
355
+ return "RUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate)";
356
+ case "effect":
357
+ return "EFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt)";
358
+ case "update":
359
+ return hasDisruption
360
+ ? "UPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe)"
361
+ : "UPDATE";
362
+ default:
363
+ return action.toUpperCase();
364
+ }
365
+ }
366
+
311
367
  /** Human-readable render of a change set. Pure — returns a string. */
312
368
  export function renderChangeSet(cs: ChangeSet): string {
313
369
  const counts = summarize(cs);
314
370
  const header = ACTION_ORDER.map((a) => `${counts[a]} ${a}`).join(", ");
315
371
  const lines: string[] = [`Plan for ${cs.env}: ${header}`];
316
372
 
373
+ // Disruption (#1665) rides the header too, so the one number that says how
374
+ // much this plan hurts is visible without reading every row.
375
+ const disruption = summarizeDisruption(cs);
376
+ const disruptionParts = (Object.entries(disruption) as Array<[Disruption, number]>)
377
+ .filter(([, n]) => n > 0)
378
+ .map(([level, n]) => `${n} ${level}`);
379
+ if (disruptionParts.length > 0) {
380
+ lines.push(`Disruption: ${disruptionParts.join(", ")}`);
381
+ }
382
+
317
383
  for (const action of ACTION_ORDER) {
318
384
  const group = cs.entries.filter((e) => e.action === action);
319
385
  if (group.length === 0) continue;
320
- lines.push(
321
- action === "unobserved"
322
- ? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
323
- : action === "runtime"
324
- ? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
325
- : action === "effect"
326
- ? "\nEFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt):"
327
- : `\n${action.toUpperCase()}:`,
328
- );
386
+ lines.push(`\n${actionSectionLabel(action, disruptionParts.length > 0)}:`);
329
387
  for (const e of group) {
330
388
  if (e.action === "effect") {
331
389
  lines.push(
@@ -339,9 +397,12 @@ export function renderChangeSet(cs: ChangeSet): string {
339
397
  ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
340
398
  : "";
341
399
  const owner = e.runtimeOwner ? ` — owned by ${e.runtimeOwner}` : "";
342
- lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}`);
400
+ lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}${renderDisruption(e)}`);
401
+ const forced = new Set(e.disruptionBecause ?? []);
343
402
  for (const d of e.deltas ?? []) {
344
- lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
403
+ // A path the verdict rests on is marked, so a `replace` row says which
404
+ // of five changed properties caused it.
405
+ lines.push(` ${forced.has(d.path) ? "! " : ""}${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
345
406
  }
346
407
  }
347
408
  }
@@ -349,6 +410,99 @@ export function renderChangeSet(cs: ChangeSet): string {
349
410
  return lines.join("\n");
350
411
  }
351
412
 
413
+ /**
414
+ * Entries beyond this in one action group are collapsed into a `<details>`
415
+ * block in {@link renderChangeSetMarkdown} — a group's worth of rows is fine
416
+ * to read inline, a hundred-entry plan is not (#1983).
417
+ */
418
+ const MARKDOWN_FOLD_THRESHOLD = 20;
419
+
420
+ /** One entry's markdown, mirroring the rows {@link renderChangeSet} prints. */
421
+ function renderMarkdownEntry(e: ChangeSetEntry): string[] {
422
+ if (e.action === "effect") {
423
+ return [
424
+ `- effect will fire: \`${e.effect ?? e.name}\` — receipt \`${e.name}\`${e.type ? ` (${e.type})` : ""}` +
425
+ `${e.effectDetail ? ` — ${e.effectDetail}` : ""}`,
426
+ ];
427
+ }
428
+ const lexicon = e.lexicon ? ` \`${e.lexicon}\`` : "";
429
+ const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
430
+ const why = e.unobservedReason
431
+ ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
432
+ : "";
433
+ const owner = e.runtimeOwner ? ` — owned by \`${e.runtimeOwner}\`` : "";
434
+ // Bolded rather than the plain-text render's bare " — destroy: ..." — a
435
+ // reviewer scanning a wall of "update" rows should see disruption without
436
+ // reading every one (#1665).
437
+ const disruption = e.disruption
438
+ ? ` — **${e.disruption}**${e.disruptionDetail ? `: ${e.disruptionDetail}` : ""}`
439
+ : "";
440
+ const lines = [`- \`${e.name}\`${e.type ? ` (${e.type})` : ""}${lexicon}${own}${why}${owner}${disruption}`];
441
+
442
+ if (e.deltas && e.deltas.length > 0) {
443
+ const forced = new Set(e.disruptionBecause ?? []);
444
+ lines.push(" ```");
445
+ for (const d of e.deltas) {
446
+ lines.push(` ${forced.has(d.path) ? "! " : " "}${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
447
+ }
448
+ lines.push(" ```");
449
+ }
450
+ return lines;
451
+ }
452
+
453
+ /**
454
+ * Markdown projection of a change set (#1983) — sized for a PR/MR comment
455
+ * rather than a terminal. A scannable counts header, entries grouped by
456
+ * action and attributed to their lexicon, deltas in a fenced block, and any
457
+ * group past {@link MARKDOWN_FOLD_THRESHOLD} folded into a `<details>` so a
458
+ * large plan doesn't bury the comment. Pure — no ANSI, no I/O — and
459
+ * deterministic: same entry ordering as {@link renderChangeSet}.
460
+ *
461
+ * Both a hole (#1089) and an expensive verdict (#1665) are surfaced IN the
462
+ * body, not left to stderr the way `--json` and `--report gitlab-mr` leave
463
+ * them: a reviewer reading a comment never sees the job's log, so a plan
464
+ * carrying either must not read as clean.
465
+ */
466
+ export function renderChangeSetMarkdown(cs: ChangeSet): string {
467
+ const counts = summarize(cs);
468
+ const lines: string[] = [`## Plan for \`${cs.env}\``, "", ACTION_ORDER.map((a) => `${counts[a]} ${a}`).join(", ")];
469
+
470
+ const disruption = summarizeDisruption(cs);
471
+ const disruptionParts = (Object.entries(disruption) as Array<[Disruption, number]>)
472
+ .filter(([, n]) => n > 0)
473
+ .map(([level, n]) => `${n} ${level}`);
474
+ if (disruptionParts.length > 0) {
475
+ lines.push("", `**Disruption:** ${disruptionParts.join(", ")}`);
476
+ }
477
+
478
+ for (const notice of unobservedPlanNotice(cs)) {
479
+ lines.push("", `> **${notice}**`);
480
+ }
481
+
482
+ for (const action of ACTION_ORDER) {
483
+ const group = cs.entries.filter((e) => e.action === action);
484
+ if (group.length === 0) continue;
485
+
486
+ lines.push("", `### ${actionSectionLabel(action, disruptionParts.length > 0)}`);
487
+
488
+ const body = group.flatMap((e) => renderMarkdownEntry(e));
489
+ if (group.length > MARKDOWN_FOLD_THRESHOLD) {
490
+ lines.push(
491
+ "",
492
+ `<details><summary>${group.length} entries — click to expand</summary>`,
493
+ "",
494
+ ...body,
495
+ "",
496
+ "</details>",
497
+ );
498
+ } else {
499
+ lines.push("", ...body);
500
+ }
501
+ }
502
+
503
+ return lines.join("\n").trimEnd() + "\n";
504
+ }
505
+
352
506
  function fmt(v: unknown): string {
353
507
  if (v === undefined) return "<unset>";
354
508
  if (typeof v === "string") return v.length > 60 ? v.slice(0, 57) + "..." : v;
@@ -0,0 +1,186 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ annotateDisruption,
4
+ disruptionNotices,
5
+ summarizeDisruption,
6
+ worstDisruption,
7
+ type DisruptionClassifier,
8
+ } from "./disruption";
9
+ import { renderChangeSet, type ChangeSet, type ChangeSetEntry } from "./change-set";
10
+
11
+ function entry(overrides: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
12
+ return {
13
+ name: "db",
14
+ type: "AWS::RDS::DBInstance",
15
+ lexicon: "aws",
16
+ action: "update",
17
+ evidence: { declared: true, inSnapshot: true, live: true, observed: true },
18
+ deltas: [{ path: "attributes.Engine", oldValue: "postgres", newValue: "mysql" }],
19
+ ownership: "unknown",
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function set(entries: ChangeSetEntry[]): ChangeSet {
25
+ return { env: "prod", entries };
26
+ }
27
+
28
+ describe("annotateDisruption (#1665)", () => {
29
+ test("no classifier degrades every update to unknown, never in-place", async () => {
30
+ const out = await annotateDisruption(set([entry()]), "prod", undefined);
31
+ expect(out.entries[0].disruption).toBe("unknown");
32
+ expect(out.entries[0].disruptionDetail).toContain("aws lexicon does not classify disruption");
33
+ });
34
+
35
+ test("a lexicon's verdict rides the entry", async () => {
36
+ const classify: DisruptionClassifier = () => ({
37
+ db: { disruption: "replace", because: ["attributes.Engine"], detail: "Engine is create-only" },
38
+ });
39
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
40
+ expect(out.entries[0]).toMatchObject({
41
+ disruption: "replace",
42
+ disruptionBecause: ["attributes.Engine"],
43
+ disruptionDetail: "Engine is create-only",
44
+ });
45
+ });
46
+
47
+ test("a name the classifier said nothing about is unknown", async () => {
48
+ const classify: DisruptionClassifier = () => ({});
49
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
50
+ expect(out.entries[0].disruption).toBe("unknown");
51
+ expect(out.entries[0].disruptionDetail).toContain("returned no verdict");
52
+ });
53
+
54
+ // The guard that makes `in-place` trustworthy: a lexicon cannot smuggle a
55
+ // level in that core does not recognise, and a bogus one is never treated as
56
+ // the safe end of the scale.
57
+ test("a level outside the vocabulary is rejected into unknown", async () => {
58
+ const classify = (() => ({
59
+ db: { disruption: "totally-fine", detail: "trust me" },
60
+ })) as unknown as DisruptionClassifier;
61
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
62
+ expect(out.entries[0].disruption).toBe("unknown");
63
+ expect(out.entries[0].disruptionDetail).toContain("totally-fine");
64
+ });
65
+
66
+ test("a classifier that throws leaves unknown, and the plan survives", async () => {
67
+ const classify: DisruptionClassifier = () => {
68
+ throw new Error("registry missing");
69
+ };
70
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
71
+ expect(out.entries[0].disruption).toBe("unknown");
72
+ expect(out.entries[0].disruptionDetail).toContain("registry missing");
73
+ });
74
+
75
+ test("an async classifier is awaited", async () => {
76
+ const classify: DisruptionClassifier = async () => ({
77
+ db: { disruption: "in-place", detail: "no create-only property changed" },
78
+ });
79
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
80
+ expect(out.entries[0].disruption).toBe("in-place");
81
+ });
82
+
83
+ test("only update entries are classified", async () => {
84
+ const classify: DisruptionClassifier = ({ changes }) => {
85
+ expect(changes.map((c) => c.name)).toEqual(["db"]);
86
+ return { db: { disruption: "destroy" } };
87
+ };
88
+ const out = await annotateDisruption(
89
+ set([
90
+ entry(),
91
+ entry({ name: "bucket", action: "create", deltas: undefined }),
92
+ entry({ name: "queue", action: "delete", deltas: undefined }),
93
+ ]),
94
+ "prod",
95
+ classify,
96
+ );
97
+ const byName = Object.fromEntries(out.entries.map((e) => [e.name, e]));
98
+ expect(byName.db.disruption).toBe("destroy");
99
+ expect(byName.bucket.disruption).toBeUndefined();
100
+ expect(byName.queue.disruption).toBeUndefined();
101
+ });
102
+
103
+ test("a set with no updates is returned untouched, and no classifier is called", async () => {
104
+ let called = false;
105
+ const cs = set([entry({ action: "noop", deltas: undefined })]);
106
+ const out = await annotateDisruption(cs, "prod", () => {
107
+ called = true;
108
+ return {};
109
+ });
110
+ expect(out).toBe(cs);
111
+ expect(called).toBe(false);
112
+ });
113
+
114
+ test("the input change set is not mutated", async () => {
115
+ const cs = set([entry()]);
116
+ await annotateDisruption(cs, "prod", () => ({ db: { disruption: "replace" } }));
117
+ expect(cs.entries[0].disruption).toBeUndefined();
118
+ });
119
+ });
120
+
121
+ describe("summaries and notices", () => {
122
+ const classified = set([
123
+ entry({ name: "db", disruption: "destroy" }),
124
+ entry({ name: "sg", disruption: "replace" }),
125
+ entry({ name: "tags", disruption: "in-place" }),
126
+ entry({ name: "mystery", disruption: "unknown" }),
127
+ entry({ name: "bucket", action: "noop", disruption: undefined, deltas: undefined }),
128
+ ]);
129
+
130
+ test("summarizeDisruption counts update entries only", () => {
131
+ expect(summarizeDisruption(classified)).toEqual({
132
+ "in-place": 1,
133
+ rolling: 0,
134
+ replace: 1,
135
+ destroy: 1,
136
+ unknown: 1,
137
+ });
138
+ });
139
+
140
+ test("worstDisruption ranks unknown above every confident verdict", () => {
141
+ expect(worstDisruption(classified)).toBe("unknown");
142
+ expect(worstDisruption(set([entry({ disruption: "in-place" })]))).toBe("in-place");
143
+ expect(worstDisruption(set([entry({ action: "noop", disruption: undefined })]))).toBeUndefined();
144
+ });
145
+
146
+ test("notices name the replacing and the unclassified rows", () => {
147
+ const notices = disruptionNotices(classified);
148
+ expect(notices[0]).toContain("2 update(s) replace the resource");
149
+ expect(notices[0]).toContain("1 of them by deleting it first");
150
+ expect(notices[1]).toContain("1 update(s) could not be classified");
151
+ });
152
+
153
+ test("a clean plan produces no notices", () => {
154
+ expect(disruptionNotices(set([entry({ disruption: "in-place" })]))).toEqual([]);
155
+ });
156
+ });
157
+
158
+ describe("renderChangeSet with disruption", () => {
159
+ test("the header, the row, and the forcing delta all say it", () => {
160
+ const out = renderChangeSet(
161
+ set([
162
+ entry({
163
+ disruption: "replace",
164
+ disruptionBecause: ["attributes.Engine"],
165
+ disruptionDetail: "Engine is create-only",
166
+ deltas: [
167
+ { path: "attributes.Engine", oldValue: "postgres", newValue: "mysql" },
168
+ { path: "attributes.AllocatedStorage", oldValue: 20, newValue: 40 },
169
+ ],
170
+ }),
171
+ ]),
172
+ );
173
+ expect(out).toContain("Disruption: 1 replace");
174
+ expect(out).toContain("UPDATE (disruption from the lexicon that owns the spec");
175
+ expect(out).toContain("db (AWS::RDS::DBInstance) — replace: Engine is create-only");
176
+ expect(out).toContain("! attributes.Engine:");
177
+ expect(out).toContain(" attributes.AllocatedStorage:");
178
+ expect(out).not.toContain("! attributes.AllocatedStorage");
179
+ });
180
+
181
+ test("an unclassified plan renders the plain UPDATE header", () => {
182
+ const out = renderChangeSet(set([entry({ disruption: undefined })]));
183
+ expect(out).toContain("\nUPDATE:");
184
+ expect(out).not.toContain("Disruption:");
185
+ });
186
+ });
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Per-change disruption classification (#1665).
3
+ *
4
+ * The change set says WHAT a pending change is (`create`/`update`/`delete`/…).
5
+ * It says nothing about what applying it costs. An `update` that flips a tag
6
+ * and an `update` that rebuilds a database read identically, and the second one
7
+ * is the one that wakes somebody up.
8
+ *
9
+ * The knowledge that separates them is spec knowledge. CloudFormation's
10
+ * registry schema declares `createOnlyProperties` per type; Kubernetes' SSA
11
+ * schema knows which field changes roll a workload. Core owns neither, and
12
+ * hardcoding either here would put per-provider replacement rules in the tool
13
+ * — the same mistake `postSynthChecks` exists to avoid. So core defines the
14
+ * contract and the reporting, and the lexicon that compiled the spec supplies
15
+ * the answer, via {@link LexiconPlugin.classifyDisruption}.
16
+ *
17
+ * The invariant that makes the field trustworthy is that `unknown` is the
18
+ * default and the only fallback. No classifier, a classifier that says nothing
19
+ * about an entry, a classifier that throws, a classifier that returns a level
20
+ * outside the vocabulary — all of them land on `unknown`, never on `in-place`.
21
+ * A confident "this mutates in place" is only ever a lexicon's own claim.
22
+ */
23
+ import type { AttributeChange } from "./live-diff";
24
+ import type { ChangeSet, ChangeSetEntry } from "./change-set";
25
+
26
+ /**
27
+ * How much applying one pending change hurts.
28
+ *
29
+ * - `in-place` — the provider mutates the existing resource. No new identity,
30
+ * no window where it is absent.
31
+ * - `rolling` — the resource survives, but its workload is replaced
32
+ * incrementally (a Deployment's pod template changing). Disruptive to what
33
+ * is running, not to the resource.
34
+ * - `replace` — a new resource is created and the old one removed. The
35
+ * physical id changes; anything holding the old one has to be updated.
36
+ * - `destroy` — replacement that removes the old resource FIRST. There is a
37
+ * window with nothing there, and whatever the old one held is gone.
38
+ * - `unknown` — nobody could say. The honest value, and the default: it is
39
+ * what a change gets when no lexicon classifies it, and it must never be
40
+ * read as "probably fine".
41
+ */
42
+ export type Disruption = "in-place" | "rolling" | "replace" | "destroy" | "unknown";
43
+
44
+ /** Every level, most disruptive last — also the guard core validates a lexicon's answer against. */
45
+ export const DISRUPTION_LEVELS: readonly Disruption[] = [
46
+ "in-place",
47
+ "rolling",
48
+ "replace",
49
+ "destroy",
50
+ "unknown",
51
+ ];
52
+
53
+ /** Ordering for "the worst thing in this plan", with `unknown` above every confident verdict. */
54
+ const DISRUPTION_RANK: Record<Disruption, number> = {
55
+ "in-place": 0,
56
+ rolling: 1,
57
+ replace: 2,
58
+ destroy: 3,
59
+ unknown: 4,
60
+ };
61
+
62
+ /** One pending change put to a lexicon for classification. */
63
+ export interface DisruptionQuery {
64
+ /** The change set entry's `name` — the key a verdict comes back under. */
65
+ name: string;
66
+ /** Resource type, when the observation reported one. */
67
+ type?: string;
68
+ /** The attribute-level changes the entry carries. */
69
+ deltas: AttributeChange[];
70
+ }
71
+
72
+ /** A lexicon's answer for one query. */
73
+ export interface DisruptionVerdict {
74
+ disruption: Disruption;
75
+ /** The attribute paths that forced the verdict — empty or absent when none did. */
76
+ because?: string[];
77
+ /** One line of human-readable backing, naming the spec knowledge behind the call. */
78
+ detail?: string;
79
+ }
80
+
81
+ /**
82
+ * The shape of {@link LexiconPlugin.classifyDisruption}. Keyed by query `name`;
83
+ * a name the lexicon says nothing about degrades to `unknown`, so a partial
84
+ * answer is a valid answer.
85
+ */
86
+ export type DisruptionClassifier = (options: {
87
+ environment: string;
88
+ changes: DisruptionQuery[];
89
+ }) => Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
90
+
91
+ /** The verdict every fallback path produces. */
92
+ export function unknownDisruption(detail: string): DisruptionVerdict {
93
+ return { disruption: "unknown", detail };
94
+ }
95
+
96
+ /**
97
+ * Annotate one lexicon's change set with a disruption verdict per `update`.
98
+ *
99
+ * Only `update` entries are asked about: every other action already carries its
100
+ * blast radius in the action itself. Called once per lexicon, before the plan
101
+ * merges the change sets, so `classify` is always the lexicon that produced the
102
+ * entries — the only party that can map its own observation's attribute paths
103
+ * back onto spec properties.
104
+ *
105
+ * Returns a new change set; the input is not mutated.
106
+ */
107
+ export async function annotateDisruption(
108
+ cs: ChangeSet,
109
+ environment: string,
110
+ classify?: DisruptionClassifier,
111
+ ): Promise<ChangeSet> {
112
+ const updates = cs.entries.filter((e) => e.action === "update");
113
+ if (updates.length === 0) return cs;
114
+
115
+ const who = updates[0].lexicon ? `the ${updates[0].lexicon} lexicon` : "this lexicon";
116
+
117
+ let verdicts: Record<string, DisruptionVerdict> = {};
118
+ let fallback: string | undefined;
119
+
120
+ if (!classify) {
121
+ fallback = `${who} does not classify disruption — replacement semantics are spec knowledge it has not published`;
122
+ } else {
123
+ try {
124
+ verdicts = (await classify({
125
+ environment,
126
+ changes: updates.map((e) => ({
127
+ name: e.name,
128
+ ...(e.type ? { type: e.type } : {}),
129
+ deltas: e.deltas ?? [],
130
+ })),
131
+ })) ?? {};
132
+ } catch (err) {
133
+ // A broken classifier is not evidence of anything. It must not be able to
134
+ // leave a confident verdict behind, and it must not fail the plan either.
135
+ verdicts = {};
136
+ fallback = `${who}'s disruption classifier failed: ${err instanceof Error ? err.message : String(err)}`;
137
+ }
138
+ }
139
+
140
+ const entries = cs.entries.map((e) => {
141
+ if (e.action !== "update") return e;
142
+ const verdict = resolveVerdict(verdicts[e.name], fallback, who);
143
+ const annotated: ChangeSetEntry = {
144
+ ...e,
145
+ disruption: verdict.disruption,
146
+ ...(verdict.because && verdict.because.length > 0 ? { disruptionBecause: verdict.because } : {}),
147
+ ...(verdict.detail ? { disruptionDetail: verdict.detail } : {}),
148
+ };
149
+ return annotated;
150
+ });
151
+
152
+ return { ...cs, entries };
153
+ }
154
+
155
+ function resolveVerdict(
156
+ verdict: DisruptionVerdict | undefined,
157
+ fallback: string | undefined,
158
+ who: string,
159
+ ): DisruptionVerdict {
160
+ if (fallback) return unknownDisruption(fallback);
161
+ if (!verdict) return unknownDisruption(`${who} returned no verdict for this change`);
162
+ if (!DISRUPTION_LEVELS.includes(verdict.disruption)) {
163
+ return unknownDisruption(
164
+ `${who} returned "${String(verdict.disruption)}", which is not a disruption level`,
165
+ );
166
+ }
167
+ return verdict;
168
+ }
169
+
170
+ /** Count `update` entries per level. Entries with no verdict at all are not counted. */
171
+ export function summarizeDisruption(cs: ChangeSet): Record<Disruption, number> {
172
+ const counts: Record<Disruption, number> = {
173
+ "in-place": 0,
174
+ rolling: 0,
175
+ replace: 0,
176
+ destroy: 0,
177
+ unknown: 0,
178
+ };
179
+ for (const e of cs.entries) {
180
+ if (e.action === "update" && e.disruption) counts[e.disruption]++;
181
+ }
182
+ return counts;
183
+ }
184
+
185
+ /** The most disruptive verdict in the set, or undefined when nothing was classified. */
186
+ export function worstDisruption(cs: ChangeSet): Disruption | undefined {
187
+ let worst: Disruption | undefined;
188
+ for (const e of cs.entries) {
189
+ if (e.action !== "update" || !e.disruption) continue;
190
+ if (!worst || DISRUPTION_RANK[e.disruption] > DISRUPTION_RANK[worst]) worst = e.disruption;
191
+ }
192
+ return worst;
193
+ }
194
+
195
+ /**
196
+ * Warnings a plan should print on stderr — so a `--json` or `--report gitlab-mr`
197
+ * consumer, whose shape has no column for disruption, still hears about the
198
+ * expensive rows. Same discipline as the unobserved warning (#1089).
199
+ */
200
+ export function disruptionNotices(cs: ChangeSet): string[] {
201
+ const counts = summarizeDisruption(cs);
202
+ const notices: string[] = [];
203
+ const replacing = counts.replace + counts.destroy;
204
+ if (replacing > 0) {
205
+ notices.push(
206
+ `${replacing} update(s) replace the resource rather than mutating it in place` +
207
+ (counts.destroy > 0
208
+ ? `, ${counts.destroy} of them by deleting it first — that window has nothing in it.`
209
+ : "."),
210
+ );
211
+ }
212
+ if (counts.unknown > 0) {
213
+ notices.push(
214
+ `${counts.unknown} update(s) could not be classified — no lexicon could say whether applying them replaces the resource. Unknown is not "in place".`,
215
+ );
216
+ }
217
+ return notices;
218
+ }
219
+
220
+ /** Render one entry's verdict for the human plan, or "" when there is none. */
221
+ export function renderDisruption(entry: ChangeSetEntry): string {
222
+ if (!entry.disruption) return "";
223
+ return ` — ${entry.disruption}${entry.disruptionDetail ? `: ${entry.disruptionDetail}` : ""}`;
224
+ }
@@ -7,6 +7,7 @@ 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 "./disruption";
10
11
  export * from "./unobserved-gate";
11
12
  export * from "./receipt-plan";
12
13
  export * from "./affected";
@@ -16,6 +17,7 @@ export * from "./build-ledger-store";
16
17
  export * from "./oras-referrer-lookup";
17
18
  export * from "./status";
18
19
  export * from "./teardown";
20
+ export * from "./assert-live";
19
21
  export * from "./symptoms";
20
22
  export * from "./converge-ledger";
21
23
  export * from "./scenario";
@@ -249,3 +249,28 @@ describe("a resource recorded twice is one node (#1432 follow-up)", () => {
249
249
  expect(keys(result.observations)).toEqual(["rtb-default"]);
250
250
  });
251
251
  });
252
+
253
+ describe("replaySnapshots — recorded depth (#1268)", () => {
254
+ it("reports identity when nothing recorded deeper", async () => {
255
+ stored.set("main__aws", snapshot("main", "us-east-1", { managed: { web: "i-1" } }));
256
+ const result = await replaySnapshots("prod", "latest", new Set());
257
+ if ("error" in result) throw new Error(result.error);
258
+ expect(result.depth).toBe("identity");
259
+ });
260
+
261
+ it("reports deep when any recorded lexicon read that deep", async () => {
262
+ stored.set("main__aws", JSON.stringify({
263
+ lexicon: "aws", environment: "prod", stack: "main",
264
+ commit: "abc", timestamp: "2026-08-03T00:00:00.000Z", depth: "deep",
265
+ resources: { web: { type: "AWS::EC2::Instance", status: "OBSERVED", physicalId: "i-1" } },
266
+ }));
267
+ stored.set("net__aws", JSON.stringify({
268
+ lexicon: "aws", environment: "prod", stack: "net",
269
+ commit: "abc", timestamp: "2026-08-03T00:00:00.000Z",
270
+ resources: { vpc: { type: "AWS::EC2::VPC", status: "OBSERVED", physicalId: "vpc-1" } },
271
+ }));
272
+ const result = await replaySnapshots("prod", "latest", new Set());
273
+ if ("error" in result) throw new Error(result.error);
274
+ expect(result.depth).toBe("deep");
275
+ });
276
+ });