@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
@@ -107,11 +107,15 @@ export type RunProgressSink = (event: RunProgressEvent) => void;
107
107
  /**
108
108
  * Build a sink that writes `JSON.stringify(event) + "\n"` to `write` (default:
109
109
  * `process.stdout.write`), one line per event, as they happen — the
110
- * `--progress-json` CLI wiring's sink (../cli/handlers/run.ts). Kept separate
111
- * from `driver-output.ts`'s end-of-run renderers: this emits *during* the
112
- * run, one line at a time; `renderDriverJson`/`renderDriverHuman` render the
113
- * completed `DriverRunResult` once, after the run finishes.
110
+ * `--progress-json` CLI wiring's sink (../cli/handlers/run.ts, for both the
111
+ * `--components` driver's `RunProgressEvent`s and the Temporal Op path's
112
+ * `StepRecord`s, chant #1676). Kept separate from `driver-output.ts`'s
113
+ * end-of-run renderers: this emits *during* the run, one line at a time;
114
+ * `renderDriverJson`/`renderDriverHuman` render the completed
115
+ * `DriverRunResult` once, after the run finishes.
114
116
  */
115
- export function ndjsonProgressSink(write: (chunk: string) => void = (s) => void process.stdout.write(s)): RunProgressSink {
117
+ export function ndjsonProgressSink<T = RunProgressEvent>(
118
+ write: (chunk: string) => void = (s) => void process.stdout.write(s),
119
+ ): (event: T) => void {
116
120
  return (event) => write(JSON.stringify(event) + "\n");
117
121
  }
package/src/lexicon.ts CHANGED
@@ -18,6 +18,7 @@ import type { ReferenceCatalog } from "./graph-refs";
18
18
  import type { IREdge } from "./graph-ir";
19
19
  import type { DescribeResourcesResult, UnobservedReason } from "./observation";
20
20
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
21
+ import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
21
22
  import type { OwnerChainVerdict } from "./owner-chain";
22
23
  import type { CommandGroup } from "./cli/command-group";
23
24
 
@@ -44,6 +45,16 @@ export type {
44
45
  UnobservedReason,
45
46
  } from "./observation";
46
47
 
48
+ // Disruption classification (#1665), re-exported from the same entry so a
49
+ // lexicon's `classifyDisruption` types itself without a second import path.
50
+ // Runtime helpers live in `@intentius/chant/lifecycle/disruption`.
51
+ export type {
52
+ Disruption,
53
+ DisruptionQuery,
54
+ DisruptionVerdict,
55
+ DisruptionClassifier,
56
+ } from "./lifecycle/disruption";
57
+
47
58
  // The deep observation contract (#1014), re-exported for the same reason: a
48
59
  // lexicon authoring `observeResourcesDeep` + its pruning/ordering hooks types
49
60
  // them from the same entry. Runtime helpers live in
@@ -878,6 +889,46 @@ export interface LexiconPlugin {
878
889
  namespace?: string;
879
890
  }): Promise<DescribeResourcesResult>;
880
891
 
892
+ /**
893
+ * Classify how much each pending `update` in a plan hurts (#1665) —
894
+ * in-place / rolling / replace / destroy, or `unknown`.
895
+ *
896
+ * `lifecycle plan` reports WHAT changes. What it costs to apply is a
897
+ * different question, and the answer is spec knowledge: CloudFormation's
898
+ * registry schema declares `createOnlyProperties` per type, Kubernetes' SSA
899
+ * schema knows which field changes roll a workload. Core owns neither, so it
900
+ * defines the vocabulary and does the reporting, and the lexicon that
901
+ * compiled the spec answers — the same division `postSynthChecks` draws for
902
+ * validation. A replacement rule hardcoded in core would be a rule the tool
903
+ * has to keep in step with a provider it does not generate from.
904
+ *
905
+ * Only the lexicon can answer, for a second reason: the `deltas` on an entry
906
+ * are paths into ITS OWN observation shape (`attributes.<key>` off
907
+ * {@link describeResources}), which core cannot map back onto spec property
908
+ * names without knowing how the reader named things.
909
+ *
910
+ * Called once per lexicon with that lexicon's `update` entries, before the
911
+ * plan merges the change sets. Expected to be a table lookup over compiled
912
+ * spec data — no live API call, no mutation. Returning a `Promise` is
913
+ * allowed, so a lexicon whose answer needs an await is not shut out.
914
+ *
915
+ * **Every degradation lands on `unknown`.** Return a partial map: a name
916
+ * absent from the result is `unknown`, which is the right answer when the
917
+ * spec does not say (a conditionally-immutable property, a type with no
918
+ * schema on record). Core also forces `unknown` when this method is absent,
919
+ * when it throws, and when it returns a level outside the vocabulary. There
920
+ * is no path by which a lexicon accidentally produces a confident
921
+ * `in-place` — that claim has to be made deliberately, and a consumer gating
922
+ * on `replace` can trust it for exactly that reason.
923
+ *
924
+ * Omit for lexicons with no replacement semantics to publish.
925
+ */
926
+ classifyDisruption?(options: {
927
+ environment: string;
928
+ /** This lexicon's `update` entries — name, type, and the attribute deltas. */
929
+ changes: DisruptionQuery[];
930
+ }): Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
931
+
881
932
  /**
882
933
  * Report the undeclared resources this estate *depends on* (#1273), as
883
934
  * opposed to the ones it manages.
@@ -0,0 +1,125 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createMockPlugin, staticObservation } from "@intentius/chant-test-utils";
3
+ import { observation } from "../observation";
4
+ import { assertLiveEntity, LiveAssertionError, UnobservedAssertionError } from "./assert-live";
5
+ import type { ResourceMetadata } from "../lexicon";
6
+
7
+ const MARKER = { stack: "shop", env: "test-suite-abc123" };
8
+
9
+ const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
10
+ type: "Mock::Queue",
11
+ status: "READY",
12
+ marker: MARKER,
13
+ ownership: "owned",
14
+ ...overrides,
15
+ });
16
+
17
+ const call = (opts: Partial<Parameters<typeof assertLiveEntity>[0]> = {}) =>
18
+ assertLiveEntity({
19
+ plugin: createMockPlugin(),
20
+ name: "taskQueue",
21
+ entityType: "Mock::Queue",
22
+ props: {},
23
+ buildOutput: "",
24
+ environment: MARKER.env,
25
+ marker: MARKER,
26
+ ...opts,
27
+ });
28
+
29
+ describe("assertLiveEntity", () => {
30
+ test("resolves the metadata for an observed, marker-matched entity", async () => {
31
+ const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta() }) });
32
+ await expect(call({ plugin })).resolves.toEqual(meta());
33
+ });
34
+
35
+ test("checks status when given, passes when it matches", async () => {
36
+ const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta({ status: "READY" }) }) });
37
+ await expect(call({ plugin, status: "READY" })).resolves.toEqual(meta());
38
+ });
39
+
40
+ test("throws naming the entity when observed absent", async () => {
41
+ const plugin = createMockPlugin({ describeResources: staticObservation({}) });
42
+ await expect(call({ plugin })).rejects.toThrow(LiveAssertionError);
43
+ await expect(call({ plugin })).rejects.toThrow(/taskQueue.*observed absent/s);
44
+ });
45
+
46
+ test("throws UnobservedAssertionError, not a plain failure, when NOT-OBSERVED", async () => {
47
+ const plugin = createMockPlugin({
48
+ describeResources: staticObservation({}, { taskQueue: { reason: "no-credentials", type: "Mock::Queue" } }),
49
+ });
50
+ const err = await call({ plugin }).then(
51
+ () => undefined,
52
+ (e: unknown) => e,
53
+ );
54
+ expect(err).toBeInstanceOf(UnobservedAssertionError);
55
+ expect(err).not.toBeInstanceOf(LiveAssertionError);
56
+ expect((err as UnobservedAssertionError).reason).toBe("no-credentials");
57
+ });
58
+
59
+ test("a thrown describeResources degrades to NOT-OBSERVED read-failed, never a silent absence", async () => {
60
+ const plugin = createMockPlugin({
61
+ describeResources: async () => {
62
+ throw new Error("ECONNREFUSED");
63
+ },
64
+ });
65
+ const err = await call({ plugin }).then(
66
+ () => undefined,
67
+ (e: unknown) => e,
68
+ );
69
+ expect(err).toBeInstanceOf(UnobservedAssertionError);
70
+ expect((err as UnobservedAssertionError).reason).toBe("read-failed");
71
+ expect((err as UnobservedAssertionError).detail).toContain("ECONNREFUSED");
72
+ });
73
+
74
+ test("a lexicon with no describeResources is NOT-OBSERVED, unsupported-kind", async () => {
75
+ const plugin = createMockPlugin();
76
+ const err = await call({ plugin }).then(
77
+ () => undefined,
78
+ (e: unknown) => e,
79
+ );
80
+ expect(err).toBeInstanceOf(UnobservedAssertionError);
81
+ expect((err as UnobservedAssertionError).reason).toBe("unsupported-kind");
82
+ });
83
+
84
+ test("throws when the observed marker names a foreign stack", async () => {
85
+ const plugin = createMockPlugin({
86
+ describeResources: staticObservation({ taskQueue: meta({ marker: { stack: "other-stack", env: MARKER.env } }) }),
87
+ });
88
+ await expect(call({ plugin })).rejects.toThrow(/not this deploy's/);
89
+ });
90
+
91
+ test("throws when the observed marker names a foreign env — a same-named leftover from another run", async () => {
92
+ const plugin = createMockPlugin({
93
+ describeResources: staticObservation({ taskQueue: meta({ marker: { stack: MARKER.stack, env: "test-other-999999" } }) }),
94
+ });
95
+ await expect(call({ plugin })).rejects.toThrow(LiveAssertionError);
96
+ });
97
+
98
+ test("throws when the entity is confirmed foreign (no marker, ownership foreign)", async () => {
99
+ const plugin = createMockPlugin({
100
+ describeResources: staticObservation({
101
+ taskQueue: meta({ marker: undefined, ownership: "foreign" }),
102
+ }),
103
+ });
104
+ await expect(call({ plugin })).rejects.toThrow(/not this deploy's/);
105
+ });
106
+
107
+ test("passes through an entity whose lexicon has no marker channel (ownership unknown, no marker) rather than always failing", async () => {
108
+ const plugin = createMockPlugin({
109
+ describeResources: staticObservation({
110
+ taskQueue: meta({ marker: undefined, ownership: "unknown" }),
111
+ }),
112
+ });
113
+ await expect(call({ plugin })).resolves.toEqual(meta({ marker: undefined, ownership: "unknown" }));
114
+ });
115
+
116
+ test("throws a status mismatch after identity is confirmed", async () => {
117
+ const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta({ status: "PENDING" }) }) });
118
+ await expect(call({ plugin, status: "READY" })).rejects.toThrow(/status "PENDING"/);
119
+ });
120
+
121
+ test("uses the observation() envelope form identically to the bare-map form", async () => {
122
+ const plugin = createMockPlugin({ describeResources: async () => observation({ taskQueue: meta() }) });
123
+ await expect(call({ plugin })).resolves.toEqual(meta());
124
+ });
125
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * assertLive (#1857) — the read half of the test harness (#1224):
3
+ * observation-backed assertions against a live deploy, for exactly one
4
+ * declared entity at a time. Same primitive teardown.ts's fallback path
5
+ * uses — `describeResources` — turned into a pass/throw instead of a
6
+ * would-delete set.
7
+ *
8
+ * The observation contract (#1089) draws a hard line between OBSERVED-ABSENT
9
+ * and NOT-OBSERVED: a declared entity the read could not cover is never the
10
+ * same as one confirmed missing. `assertLiveEntity` preserves that line by
11
+ * construction — NOT-OBSERVED throws {@link UnobservedAssertionError}, a type
12
+ * distinct from the {@link LiveAssertionError} an observed-absent, foreign,
13
+ * or status-mismatched verdict throws, so a caller can tell "could not tell"
14
+ * from "confirmed wrong" without parsing a message.
15
+ *
16
+ * Marker verification is best-effort by the same logic {@link
17
+ * ResourceMetadata.marker}'s own contract states: a lexicon with no marker
18
+ * channel on this read path (aws's thin `describeResources`, `ownership:
19
+ * "unknown"`) reports no marker at all, which is not the same claim as
20
+ * "foreign". Enforcing a match whenever the channel exists — a present
21
+ * mismatch, or `ownership: "foreign"` with no marker to show — catches the
22
+ * case the harness cares about (a same-named leftover from another env);
23
+ * an absent channel is passed through unverified rather than making every
24
+ * lexicon without one unusable.
25
+ */
26
+
27
+ import { normalizeObservation, unobservedAll, unobservedReasonText, type UnobservedReason } from "../observation";
28
+ import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
29
+ import type { OwnershipMarker } from "../ownership";
30
+
31
+ /**
32
+ * Thrown by {@link assertLiveEntity} for a confirmed failure: observed
33
+ * absent, a marker that names another stack/env, a resource confirmed
34
+ * foreign, or a status mismatch. Never thrown for NOT-OBSERVED — see {@link
35
+ * UnobservedAssertionError}.
36
+ */
37
+ export class LiveAssertionError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "LiveAssertionError";
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Thrown when the entity is NOT-OBSERVED (#1089) rather than confirmed
46
+ * present or absent. Kept as its own type, not a flag on {@link
47
+ * LiveAssertionError}: a suite (or a CI policy) that wants to fail loudly on
48
+ * "could not tell" but treat it differently from a confirmed miss can catch
49
+ * this one specifically.
50
+ */
51
+ export class UnobservedAssertionError extends Error {
52
+ constructor(
53
+ public readonly entity: string,
54
+ public readonly reason: UnobservedReason,
55
+ public readonly detail?: string,
56
+ ) {
57
+ super(
58
+ `assertLive("${entity}") is NOT-OBSERVED — ${unobservedReasonText(reason)}` +
59
+ `${detail ? `: ${detail}` : ""}. An entity chant could not read is never the same as one confirmed absent.`,
60
+ );
61
+ this.name = "UnobservedAssertionError";
62
+ }
63
+ }
64
+
65
+ export interface AssertLiveOptions {
66
+ /** Expected `ResourceMetadata.status`, where the lexicon reports one. Skipped when omitted. */
67
+ status?: string;
68
+ }
69
+
70
+ export interface AssertLiveEntityOptions extends AssertLiveOptions {
71
+ plugin: ObservationLexicon;
72
+ /** chant entity name — the key to assert on. */
73
+ name: string;
74
+ entityType: string;
75
+ props: Record<string, unknown>;
76
+ /** This lexicon's own built output for the deploy, or `""` when none was built. */
77
+ buildOutput: string;
78
+ environment: string;
79
+ /** This deploy's identity — the marker an observed resource is checked against. */
80
+ marker: OwnershipMarker;
81
+ }
82
+
83
+ /** True when `meta` names a different stack/env than `marker`, on whichever signal it carries. */
84
+ function isConfirmedForeign(meta: ResourceMetadata, marker: OwnershipMarker): boolean {
85
+ if (meta.marker) return meta.marker.stack !== marker.stack || meta.marker.env !== marker.env;
86
+ return meta.ownership === "foreign";
87
+ }
88
+
89
+ /**
90
+ * Assert one declared entity is live: observed present in `environment`, not
91
+ * a confirmed-foreign resource, and — when `status` is given — reporting
92
+ * that status. Resolves to the entity's {@link ResourceMetadata} on success.
93
+ *
94
+ * Throws {@link UnobservedAssertionError} for NOT-OBSERVED. Throws {@link
95
+ * LiveAssertionError} for observed-absent, a confirmed-foreign identity, or a
96
+ * status mismatch.
97
+ */
98
+ export async function assertLiveEntity(opts: AssertLiveEntityOptions): Promise<ResourceMetadata> {
99
+ const { plugin, name, entityType, props, buildOutput, environment, marker, status } = opts;
100
+
101
+ if (!plugin.describeResources) {
102
+ throw new UnobservedAssertionError(
103
+ name,
104
+ "unsupported-kind",
105
+ `the "${plugin.name}" lexicon implements no describeResources`,
106
+ );
107
+ }
108
+
109
+ let observed;
110
+ try {
111
+ observed = normalizeObservation(
112
+ await plugin.describeResources({
113
+ environment,
114
+ buildOutput,
115
+ entityNames: [name],
116
+ entities: new Map([[name, { entityType, props }]]),
117
+ }),
118
+ );
119
+ } catch (err) {
120
+ const detail = err instanceof Error ? err.message : String(err);
121
+ observed = {
122
+ resources: {},
123
+ unobserved: unobservedAll([name], "read-failed", detail, { [name]: entityType }),
124
+ queried: {},
125
+ notes: [],
126
+ };
127
+ }
128
+
129
+ const unobserved = observed.unobserved[name];
130
+ if (unobserved) throw new UnobservedAssertionError(name, unobserved.reason, unobserved.detail);
131
+
132
+ const meta = observed.resources[name];
133
+ if (!meta) {
134
+ throw new LiveAssertionError(
135
+ `assertLive("${name}"): observed absent — chant looked and "${environment}" reported no such resource.`,
136
+ );
137
+ }
138
+
139
+ if (isConfirmedForeign(meta, marker)) {
140
+ const found = meta.marker ? `{ stack: "${meta.marker.stack}", env: "${meta.marker.env ?? ""}" }` : "no chant marker";
141
+ throw new LiveAssertionError(
142
+ `assertLive("${name}"): observed, but it is not this deploy's — carries ${found}, not ` +
143
+ `{ stack: "${marker.stack}", env: "${marker.env ?? ""}" }. A same-named resource from another stack or env cannot satisfy this assertion.`,
144
+ );
145
+ }
146
+
147
+ if (status !== undefined && meta.status !== status) {
148
+ throw new LiveAssertionError(
149
+ `assertLive("${name}", { status: "${status}" }): observed with status "${meta.status}".`,
150
+ );
151
+ }
152
+
153
+ return meta;
154
+ }
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { buildChangeSet, renderChangeSet, summarize, gitlabMrReport } from "./change-set";
2
+ import {
3
+ buildChangeSet,
4
+ renderChangeSet,
5
+ renderChangeSetMarkdown,
6
+ summarize,
7
+ gitlabMrReport,
8
+ unobservedPlanNotice,
9
+ type ChangeSet,
10
+ type ChangeSetEntry,
11
+ } from "./change-set";
3
12
  import type { ResourceMetadata } from "../lexicon";
4
13
 
5
14
  const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
@@ -446,3 +455,137 @@ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
446
455
  expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
447
456
  });
448
457
  });
458
+
459
+ // ── Markdown report (#1983) ──────────────────────────────────────────────
460
+
461
+ function entry(overrides: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
462
+ return {
463
+ name: "db",
464
+ type: "AWS::RDS::DBInstance",
465
+ lexicon: "aws",
466
+ action: "update",
467
+ evidence: { declared: true, inSnapshot: true, live: true, observed: true },
468
+ ownership: "unknown",
469
+ ...overrides,
470
+ };
471
+ }
472
+
473
+ function set(entries: ChangeSetEntry[]): ChangeSet {
474
+ return { env: "prod", entries };
475
+ }
476
+
477
+ describe("unobservedPlanNotice (#1983)", () => {
478
+ test("empty when nothing is unobserved", () => {
479
+ expect(unobservedPlanNotice(set([entry({ action: "noop" })]))).toEqual([]);
480
+ });
481
+
482
+ test("one notice naming the count, matching the CLI's stderr wording", () => {
483
+ const cs = set([
484
+ entry({ name: "a", action: "unobserved", unobservedReason: "no-binding" }),
485
+ entry({ name: "b", action: "unobserved", unobservedReason: "read-failed" }),
486
+ entry({ name: "c", action: "noop" }),
487
+ ]);
488
+ expect(unobservedPlanNotice(cs)).toEqual([
489
+ "2 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.",
490
+ ]);
491
+ });
492
+ });
493
+
494
+ describe("renderChangeSetMarkdown (#1983)", () => {
495
+ test("empty plan: header only, no ANSI, deterministic", () => {
496
+ const out = renderChangeSetMarkdown(set([]));
497
+ expect(out).toBe(
498
+ "## Plan for `prod`\n\n0 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 0 noop, 0 unobserved\n",
499
+ );
500
+ expect(out).not.toMatch(/\x1b\[/);
501
+ });
502
+
503
+ test("counts header and grouped sections, attributed to lexicon", () => {
504
+ const cs = set([
505
+ entry({ name: "new-bucket", type: "S3::Bucket", lexicon: "aws", action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true } }),
506
+ entry({ name: "web", type: "K8s::Apps::Deployment", lexicon: "k8s", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true } }),
507
+ ]);
508
+ const out = renderChangeSetMarkdown(cs);
509
+ expect(out).toContain("## Plan for `prod`");
510
+ expect(out).toContain("1 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 1 noop, 0 unobserved");
511
+ expect(out).toContain("### CREATE");
512
+ expect(out).toContain("- `new-bucket` (S3::Bucket) `aws`");
513
+ expect(out).toContain("### NOOP");
514
+ expect(out).toContain("- `web` (K8s::Apps::Deployment) `k8s`");
515
+ });
516
+
517
+ test("deltas render in a fenced block, forced path marked", () => {
518
+ const cs = set([
519
+ entry({
520
+ deltas: [
521
+ { path: "attributes.DBInstanceIdentifier", oldValue: "app-db", newValue: "app-db-2" },
522
+ { path: "attributes.AllocatedStorage", oldValue: 20, newValue: 40 },
523
+ ],
524
+ disruption: "destroy",
525
+ disruptionBecause: ["attributes.DBInstanceIdentifier"],
526
+ disruptionDetail: "DBInstanceIdentifier is create-only",
527
+ }),
528
+ ]);
529
+ const out = renderChangeSetMarkdown(cs);
530
+ expect(out).toContain("**destroy**: DBInstanceIdentifier is create-only");
531
+ expect(out).toContain("```");
532
+ expect(out).toContain("! attributes.DBInstanceIdentifier: app-db → app-db-2");
533
+ expect(out).toContain("attributes.AllocatedStorage: 20 → 40");
534
+ });
535
+
536
+ test("disruption count rides the header, same as the human render", () => {
537
+ const cs = set([entry({ disruption: "in-place" }), entry({ name: "web", disruption: "destroy" })]);
538
+ const out = renderChangeSetMarkdown(cs);
539
+ expect(out).toContain("**Disruption:** 1 in-place, 1 destroy");
540
+ });
541
+
542
+ test("unobserved renders both the prominent notice and its own section, same wording as the human render", () => {
543
+ const cs = set([
544
+ entry({ name: "crd-widget", action: "unobserved", unobservedReason: "no-binding", unobservedDetail: "no kubectl context for prod" }),
545
+ ]);
546
+ const out = renderChangeSetMarkdown(cs);
547
+ expect(out).toContain(
548
+ "> **1 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.**",
549
+ );
550
+ expect(out).toContain("### UNOBSERVED (declared; chant could not read live state — no action proposed)");
551
+ expect(out).toContain("- `crd-widget`");
552
+ expect(out).toContain("no binding for this environment");
553
+ expect(out).toContain("no kubectl context for prod");
554
+ });
555
+
556
+ test("a clean plan (no holes) carries no unobserved notice at all", () => {
557
+ const out = renderChangeSetMarkdown(set([entry({ action: "noop" })]));
558
+ expect(out).not.toContain("could not be observed");
559
+ expect(out).not.toContain("UNOBSERVED");
560
+ });
561
+
562
+ test("effect entries render as their own line, no deltas", () => {
563
+ const cs = set([
564
+ entry({
565
+ name: "receipt-x",
566
+ action: "effect",
567
+ effect: "seed-admin",
568
+ effectReason: "receipt-absent",
569
+ effectDetail: "no receipt recorded yet",
570
+ deltas: undefined,
571
+ }),
572
+ ]);
573
+ const out = renderChangeSetMarkdown(cs);
574
+ expect(out).toContain("- effect will fire: `seed-admin` — receipt `receipt-x`");
575
+ expect(out).toContain("no receipt recorded yet");
576
+ });
577
+
578
+ test("a group past the fold threshold collapses into <details>, a small one does not", () => {
579
+ const many = Array.from({ length: 25 }, (_, i) =>
580
+ entry({ name: `bucket-${i}`, action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true }, deltas: undefined }),
581
+ );
582
+ const out = renderChangeSetMarkdown(set(many));
583
+ expect(out).toContain("<details><summary>25 entries — click to expand</summary>");
584
+ expect(out).toContain("</details>");
585
+ expect(out).toContain("- `bucket-0`");
586
+ expect(out).toContain("- `bucket-24`");
587
+
588
+ const few = renderChangeSetMarkdown(set(many.slice(0, 3)));
589
+ expect(few).not.toContain("<details>");
590
+ });
591
+ });