@intentius/chant 0.45.0 → 0.46.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 (74) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  3. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  4. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  5. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  6. package/dist/cli/commands/lint.d.ts.map +1 -1
  7. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/handlers/search.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +7 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/codegen/lexicon-regen.d.ts +11 -0
  14. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  15. package/dist/codegen/validate.d.ts +10 -0
  16. package/dist/codegen/validate.d.ts.map +1 -1
  17. package/dist/config.d.ts +28 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/env.d.ts +12 -1
  20. package/dist/env.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +182 -2
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/index.d.ts +1 -0
  24. package/dist/lifecycle/index.d.ts.map +1 -1
  25. package/dist/lifecycle/teardown.d.ts +130 -0
  26. package/dist/lifecycle/teardown.d.ts.map +1 -0
  27. package/dist/lint/engine.d.ts +6 -2
  28. package/dist/lint/engine.d.ts.map +1 -1
  29. package/dist/lint/rule.d.ts +31 -0
  30. package/dist/lint/rule.d.ts.map +1 -1
  31. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  32. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  33. package/dist/lint/rules/index.d.ts +2 -1
  34. package/dist/lint/rules/index.d.ts.map +1 -1
  35. package/dist/op/builders.d.ts +36 -7
  36. package/dist/op/builders.d.ts.map +1 -1
  37. package/dist/op/index.d.ts +1 -1
  38. package/dist/op/index.d.ts.map +1 -1
  39. package/dist/testing.d.ts +136 -0
  40. package/dist/testing.d.ts.map +1 -0
  41. package/package.json +6 -1
  42. package/src/cli/commands/build.test.ts +131 -0
  43. package/src/cli/commands/build.ts +20 -0
  44. package/src/cli/commands/check-lexicon.test.ts +45 -1
  45. package/src/cli/commands/check-lexicon.ts +45 -0
  46. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  47. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  48. package/src/cli/commands/lint.ts +19 -6
  49. package/src/cli/handlers/graph.ts +2 -2
  50. package/src/cli/handlers/lifecycle.test.ts +231 -1
  51. package/src/cli/handlers/lifecycle.ts +219 -3
  52. package/src/cli/handlers/search.ts +5 -2
  53. package/src/cli/main.ts +12 -1
  54. package/src/cli/registry.ts +7 -0
  55. package/src/codegen/lexicon-regen.ts +19 -1
  56. package/src/codegen/validate.test.ts +33 -0
  57. package/src/codegen/validate.ts +21 -2
  58. package/src/config.test.ts +40 -0
  59. package/src/config.ts +58 -1
  60. package/src/env.test.ts +35 -1
  61. package/src/env.ts +17 -3
  62. package/src/lexicon.ts +182 -2
  63. package/src/lifecycle/index.ts +1 -0
  64. package/src/lifecycle/teardown.test.ts +537 -0
  65. package/src/lifecycle/teardown.ts +357 -0
  66. package/src/lint/engine.ts +7 -1
  67. package/src/lint/rule.ts +23 -0
  68. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  69. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  70. package/src/lint/rules/index.ts +4 -1
  71. package/src/op/builders.ts +40 -7
  72. package/src/op/index.ts +1 -1
  73. package/src/testing.test.ts +261 -0
  74. package/src/testing.ts +338 -0
@@ -0,0 +1,537 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { createMockPlugin, staticObservation } from "@intentius/chant-test-utils";
3
+ import { planTeardown, executeTeardown } from "./teardown";
4
+ import { observation } from "../observation";
5
+ import type { ResourceMetadata, TeardownEnumeration, TeardownExecution } from "../lexicon";
6
+
7
+ const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
8
+ type: "K8s::Apps::Deployment",
9
+ status: "READY",
10
+ physicalId: "deploy-1",
11
+ ...overrides,
12
+ });
13
+
14
+ describe("planTeardown — teardownOwned capability path", () => {
15
+ test("returns the lexicon's candidates, attributed", async () => {
16
+ const enumeration: TeardownEnumeration = {
17
+ candidates: [
18
+ { name: "web", type: "K8s::Apps::Deployment", physicalId: "web-1", marker: { stack: "shop", env: "dev" } },
19
+ { name: "cache", type: "K8s::Core::Service", marker: { stack: "shop", env: "dev" } },
20
+ ],
21
+ };
22
+ const teardownOwned = vi.fn(async () => enumeration);
23
+ const plan = await planTeardown({
24
+ environment: "dev",
25
+ stack: "shop",
26
+ plugins: [createMockPlugin({ name: "k8s", teardownOwned })],
27
+ });
28
+
29
+ expect(teardownOwned).toHaveBeenCalledWith({
30
+ environment: "dev",
31
+ marker: { stack: "shop", env: "dev" },
32
+ });
33
+ expect(plan.entries).toEqual([
34
+ { lexicon: "k8s", name: "cache", type: "K8s::Core::Service", marker: { stack: "shop", env: "dev" } },
35
+ { lexicon: "k8s", name: "web", type: "K8s::Apps::Deployment", physicalId: "web-1", marker: { stack: "shop", env: "dev" } },
36
+ ]);
37
+ expect(plan.holes).toEqual([]);
38
+ expect(plan.skipped).toEqual([]);
39
+ });
40
+
41
+ test("the capability wins over describeResources when both exist", async () => {
42
+ const teardownOwned = vi.fn(async (): Promise<TeardownEnumeration> => ({
43
+ candidates: [{ name: "a", type: "T", marker: { stack: "shop", env: "dev" } }],
44
+ }));
45
+ const describeResources = vi.fn(async () => ({}));
46
+ const plan = await planTeardown({
47
+ environment: "dev",
48
+ stack: "shop",
49
+ plugins: [createMockPlugin({ name: "k8s", teardownOwned, describeResources })],
50
+ });
51
+ expect(plan.entries).toHaveLength(1);
52
+ expect(describeResources).not.toHaveBeenCalled();
53
+ });
54
+
55
+ test("drops a candidate whose marker is foreign-stack or foreign-env — a buggy lexicon cannot widen the set", async () => {
56
+ const plan = await planTeardown({
57
+ environment: "dev",
58
+ stack: "shop",
59
+ plugins: [
60
+ createMockPlugin({
61
+ name: "k8s",
62
+ teardownOwned: async () => ({
63
+ candidates: [
64
+ { name: "mine", type: "T", marker: { stack: "shop", env: "dev" } },
65
+ { name: "other-stack", type: "T", marker: { stack: "blog", env: "dev" } },
66
+ { name: "other-env", type: "T", marker: { stack: "shop", env: "prod" } },
67
+ { name: "no-env", type: "T", marker: { stack: "shop" } },
68
+ ],
69
+ }),
70
+ }),
71
+ ],
72
+ });
73
+ expect(plan.entries.map((e) => e.name)).toEqual(["mine"]);
74
+ });
75
+
76
+ test("surfaces the lexicon's holes, attributed", async () => {
77
+ const plan = await planTeardown({
78
+ environment: "dev",
79
+ stack: "shop",
80
+ plugins: [
81
+ createMockPlugin({
82
+ name: "k8s",
83
+ teardownOwned: async () => ({
84
+ candidates: [],
85
+ holes: [{ name: "crds", type: "K8s::CRD", reason: "unsupported-kind", detail: "no reader" }],
86
+ }),
87
+ }),
88
+ ],
89
+ });
90
+ expect(plan.holes).toEqual([
91
+ { lexicon: "k8s", name: "crds", type: "K8s::CRD", reason: "unsupported-kind", detail: "no reader" },
92
+ ]);
93
+ });
94
+
95
+ test("a thrown enumeration becomes a whole-lexicon hole, never a clean lexicon", async () => {
96
+ const plan = await planTeardown({
97
+ environment: "dev",
98
+ stack: "shop",
99
+ plugins: [
100
+ createMockPlugin({ name: "k8s", teardownOwned: async () => { throw new Error("no cluster binding"); } }),
101
+ ],
102
+ });
103
+ expect(plan.entries).toEqual([]);
104
+ expect(plan.holes).toEqual([
105
+ { lexicon: "k8s", name: "*", reason: "read-failed", detail: "no cluster binding" },
106
+ ]);
107
+ });
108
+
109
+ test("passes deployedStack and region through", async () => {
110
+ const teardownOwned = vi.fn(async (): Promise<TeardownEnumeration> => ({ candidates: [] }));
111
+ await planTeardown({
112
+ environment: "dev",
113
+ stack: "shop",
114
+ deployedStack: "shop-network",
115
+ region: "eu-west-1",
116
+ plugins: [createMockPlugin({ name: "aws", teardownOwned })],
117
+ });
118
+ expect(teardownOwned).toHaveBeenCalledWith({
119
+ environment: "dev",
120
+ marker: { stack: "shop", env: "dev" },
121
+ stack: "shop-network",
122
+ region: "eu-west-1",
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("planTeardown — describeResources fallback path", () => {
128
+ test("selects only resources whose marker matches this stack and env", async () => {
129
+ const plan = await planTeardown({
130
+ environment: "dev",
131
+ stack: "shop",
132
+ plugins: [
133
+ createMockPlugin({
134
+ name: "k8s",
135
+ describeResources: staticObservation({
136
+ mine: meta({ marker: { stack: "shop", env: "dev" } }),
137
+ foreignStack: meta({ marker: { stack: "blog", env: "dev" } }),
138
+ foreignEnv: meta({ marker: { stack: "shop", env: "prod" } }),
139
+ noEnvOnMarker: meta({ marker: { stack: "shop" } }),
140
+ unmarked: meta(),
141
+ }),
142
+ }),
143
+ ],
144
+ });
145
+ expect(plan.entries).toEqual([
146
+ {
147
+ lexicon: "k8s",
148
+ name: "mine",
149
+ type: "K8s::Apps::Deployment",
150
+ physicalId: "deploy-1",
151
+ marker: { stack: "shop", env: "dev" },
152
+ },
153
+ ]);
154
+ });
155
+
156
+ test("asks for owned resources with no declared entity list — stateless, no build", async () => {
157
+ const describeResources = vi.fn(async () => ({}));
158
+ await planTeardown({
159
+ environment: "dev",
160
+ stack: "shop",
161
+ plugins: [createMockPlugin({ name: "k8s", describeResources })],
162
+ });
163
+ expect(describeResources).toHaveBeenCalledWith({
164
+ environment: "dev",
165
+ buildOutput: "",
166
+ entityNames: [],
167
+ entities: new Map(),
168
+ owned: true,
169
+ });
170
+ });
171
+
172
+ test("envelope unobserved entries become holes (#1089)", async () => {
173
+ const plan = await planTeardown({
174
+ environment: "dev",
175
+ stack: "shop",
176
+ plugins: [
177
+ createMockPlugin({
178
+ name: "k8s",
179
+ describeResources: async () =>
180
+ observation(
181
+ { mine: meta({ marker: { stack: "shop", env: "dev" } }) },
182
+ { hidden: { type: "K8s::CRD", reason: "unsupported-kind", detail: "no reader for kind" } },
183
+ ),
184
+ }),
185
+ ],
186
+ });
187
+ expect(plan.entries).toHaveLength(1);
188
+ expect(plan.holes).toEqual([
189
+ { lexicon: "k8s", name: "hidden", type: "K8s::CRD", reason: "unsupported-kind", detail: "no reader for kind" },
190
+ ]);
191
+ });
192
+
193
+ test("a thrown read becomes a whole-lexicon hole", async () => {
194
+ const plan = await planTeardown({
195
+ environment: "dev",
196
+ stack: "shop",
197
+ plugins: [
198
+ createMockPlugin({ name: "azure", describeResources: async () => { throw new Error("az not found"); } }),
199
+ ],
200
+ });
201
+ expect(plan.entries).toEqual([]);
202
+ expect(plan.holes).toEqual([
203
+ { lexicon: "azure", name: "*", reason: "read-failed", detail: "az not found" },
204
+ ]);
205
+ });
206
+ });
207
+
208
+ describe("planTeardown — lexicons with neither path", () => {
209
+ test("are reported as skipped, so nothing-to-delete never means nobody-looked", async () => {
210
+ const plan = await planTeardown({
211
+ environment: "dev",
212
+ stack: "shop",
213
+ plugins: [createMockPlugin({ name: "helm" })],
214
+ });
215
+ expect(plan.entries).toEqual([]);
216
+ expect(plan.skipped).toEqual(["helm"]);
217
+ });
218
+ });
219
+
220
+ describe("planTeardown — aggregation", () => {
221
+ test("merges entries and holes across lexicons, sorted by lexicon then name", async () => {
222
+ const plan = await planTeardown({
223
+ environment: "dev",
224
+ stack: "shop",
225
+ plugins: [
226
+ createMockPlugin({
227
+ name: "k8s",
228
+ teardownOwned: async () => ({
229
+ candidates: [{ name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "dev" } }],
230
+ }),
231
+ }),
232
+ createMockPlugin({
233
+ name: "gcp",
234
+ describeResources: staticObservation({
235
+ bucket: meta({ type: "GCP::Storage::Bucket", physicalId: "b-1", marker: { stack: "shop", env: "dev" } }),
236
+ }),
237
+ }),
238
+ createMockPlugin({ name: "helm" }),
239
+ ],
240
+ });
241
+ expect(plan.entries.map((e) => `${e.lexicon}/${e.name}`)).toEqual(["gcp/bucket", "k8s/web"]);
242
+ expect(plan.skipped).toEqual(["helm"]);
243
+ expect(plan.environment).toBe("dev");
244
+ expect(plan.stack).toBe("shop");
245
+ });
246
+ });
247
+
248
+ describe("executeTeardown — the execution half (#1222)", () => {
249
+ const candidateWeb = { name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "dev" } };
250
+ const candidateCache = { name: "cache", type: "K8s::Core::Service", marker: { stack: "shop", env: "dev" } };
251
+
252
+ test("hands each lexicon its own candidates and reports the outcomes, attributed", async () => {
253
+ const execute = vi.fn(async (): Promise<TeardownExecution> => ({
254
+ outcomes: [
255
+ { name: "cache", outcome: "deleted" },
256
+ { name: "web", outcome: "not-prunable", detail: "identity changed under us" },
257
+ ],
258
+ }));
259
+ const report = await executeTeardown({
260
+ environment: "dev",
261
+ stack: "shop",
262
+ plugins: [
263
+ createMockPlugin({
264
+ name: "k8s",
265
+ teardownOwned: async () => ({ candidates: [candidateWeb, candidateCache] }),
266
+ executeTeardown: execute,
267
+ }),
268
+ ],
269
+ });
270
+
271
+ expect(execute).toHaveBeenCalledTimes(1);
272
+ expect(execute).toHaveBeenCalledWith({
273
+ environment: "dev",
274
+ marker: { stack: "shop", env: "dev" },
275
+ candidates: [candidateCache, candidateWeb],
276
+ });
277
+ expect(report.outcomes).toEqual([
278
+ { lexicon: "k8s", ...candidateCache, outcome: "deleted" },
279
+ { lexicon: "k8s", ...candidateWeb, outcome: "not-prunable", detail: "identity changed under us" },
280
+ ]);
281
+ expect(report.unimplemented).toEqual([]);
282
+ });
283
+
284
+ test("reuses a handed-in plan instead of re-reading", async () => {
285
+ const teardownOwned = vi.fn();
286
+ const execute = vi.fn(async (): Promise<TeardownExecution> => ({
287
+ outcomes: [{ name: "web", outcome: "deleted" }],
288
+ }));
289
+ const plan = {
290
+ environment: "dev",
291
+ stack: "shop",
292
+ entries: [{ lexicon: "k8s", ...candidateWeb }],
293
+ holes: [],
294
+ skipped: [],
295
+ };
296
+ const report = await executeTeardown({
297
+ environment: "dev",
298
+ stack: "shop",
299
+ plan,
300
+ plugins: [createMockPlugin({ name: "k8s", teardownOwned, executeTeardown: execute })],
301
+ });
302
+ expect(teardownOwned).not.toHaveBeenCalled();
303
+ expect(report.plan).toBe(plan);
304
+ expect(report.outcomes[0].outcome).toBe("deleted");
305
+ });
306
+
307
+ test("a failure gets exactly one retry pass, and a retry success is marked", async () => {
308
+ let calls = 0;
309
+ const execute = vi.fn(async (options: { candidates: Array<{ name: string }> }): Promise<TeardownExecution> => {
310
+ calls++;
311
+ if (calls === 1) {
312
+ return {
313
+ outcomes: [
314
+ { name: "cache", outcome: "deleted" },
315
+ { name: "web", outcome: "failed", detail: "conflict" },
316
+ ],
317
+ };
318
+ }
319
+ // The retry pass gets only the failures.
320
+ expect(options.candidates.map((c) => c.name)).toEqual(["web"]);
321
+ return { outcomes: [{ name: "web", outcome: "deleted" }] };
322
+ });
323
+ const report = await executeTeardown({
324
+ environment: "dev",
325
+ stack: "shop",
326
+ plugins: [
327
+ createMockPlugin({
328
+ name: "k8s",
329
+ teardownOwned: async () => ({ candidates: [candidateWeb, candidateCache] }),
330
+ executeTeardown: execute as never,
331
+ }),
332
+ ],
333
+ });
334
+ expect(calls).toBe(2);
335
+ const web = report.outcomes.find((o) => o.name === "web")!;
336
+ expect(web.outcome).toBe("deleted");
337
+ expect(web.retried).toBe(true);
338
+ const cache = report.outcomes.find((o) => o.name === "cache")!;
339
+ expect(cache.outcome).toBe("deleted");
340
+ expect(cache.retried).toBeUndefined();
341
+ });
342
+
343
+ test("a failure that survives the retry stays failed — reported, never silent", async () => {
344
+ const execute = vi.fn(async (): Promise<TeardownExecution> => ({
345
+ outcomes: [{ name: "web", outcome: "failed", detail: "still refused" }],
346
+ }));
347
+ const report = await executeTeardown({
348
+ environment: "dev",
349
+ stack: "shop",
350
+ plugins: [
351
+ createMockPlugin({
352
+ name: "k8s",
353
+ teardownOwned: async () => ({ candidates: [candidateWeb] }),
354
+ executeTeardown: execute,
355
+ }),
356
+ ],
357
+ });
358
+ expect(execute).toHaveBeenCalledTimes(2); // one pass + one bounded retry, never a third
359
+ expect(report.outcomes).toEqual([
360
+ { lexicon: "k8s", ...candidateWeb, outcome: "failed", detail: "still refused", retried: true },
361
+ ]);
362
+ });
363
+
364
+ test("a thrown execution fails all its candidates, then retries them once", async () => {
365
+ let calls = 0;
366
+ const execute = vi.fn(async (): Promise<TeardownExecution> => {
367
+ calls++;
368
+ if (calls === 1) throw new Error("api down");
369
+ return { outcomes: [{ name: "web", outcome: "deleted" }, { name: "cache", outcome: "deleted" }] };
370
+ });
371
+ const report = await executeTeardown({
372
+ environment: "dev",
373
+ stack: "shop",
374
+ plugins: [
375
+ createMockPlugin({
376
+ name: "k8s",
377
+ teardownOwned: async () => ({ candidates: [candidateWeb, candidateCache] }),
378
+ executeTeardown: execute,
379
+ }),
380
+ ],
381
+ });
382
+ expect(report.outcomes.every((o) => o.outcome === "deleted" && o.retried)).toBe(true);
383
+ });
384
+
385
+ test("a candidate the lexicon stays silent about is failed — silence is never success", async () => {
386
+ const execute = vi.fn(async (): Promise<TeardownExecution> => ({
387
+ outcomes: [{ name: "cache", outcome: "deleted" }],
388
+ }));
389
+ const report = await executeTeardown({
390
+ environment: "dev",
391
+ stack: "shop",
392
+ plugins: [
393
+ createMockPlugin({
394
+ name: "k8s",
395
+ teardownOwned: async () => ({ candidates: [candidateWeb, candidateCache] }),
396
+ executeTeardown: execute,
397
+ }),
398
+ ],
399
+ });
400
+ const web = report.outcomes.find((o) => o.name === "web")!;
401
+ expect(web.outcome).toBe("failed");
402
+ expect(web.detail).toContain("no outcome");
403
+ });
404
+
405
+ test("an outcome for a name core never asked about is dropped", async () => {
406
+ const execute = vi.fn(async (): Promise<TeardownExecution> => ({
407
+ outcomes: [
408
+ { name: "web", outcome: "deleted" },
409
+ { name: "somebody-elses", outcome: "deleted" },
410
+ ],
411
+ }));
412
+ const report = await executeTeardown({
413
+ environment: "dev",
414
+ stack: "shop",
415
+ plugins: [
416
+ createMockPlugin({
417
+ name: "k8s",
418
+ teardownOwned: async () => ({ candidates: [candidateWeb] }),
419
+ executeTeardown: execute,
420
+ }),
421
+ ],
422
+ });
423
+ expect(report.outcomes.map((o) => o.name)).toEqual(["web"]);
424
+ });
425
+
426
+ test("a lexicon with candidates but no executeTeardown reports them skipped, loudly", async () => {
427
+ const report = await executeTeardown({
428
+ environment: "dev",
429
+ stack: "shop",
430
+ plugins: [
431
+ createMockPlugin({
432
+ name: "gcp",
433
+ teardownOwned: async () => ({
434
+ candidates: [{ name: "bucket", type: "GCP::Storage::Bucket", marker: { stack: "shop", env: "dev" } }],
435
+ }),
436
+ }),
437
+ ],
438
+ });
439
+ expect(report.unimplemented).toEqual(["gcp"]);
440
+ expect(report.outcomes).toHaveLength(1);
441
+ expect(report.outcomes[0].outcome).toBe("skipped");
442
+ expect(report.outcomes[0].detail).toContain("gcp");
443
+ });
444
+
445
+ test("per-lexicon isolation: one lexicon's failure never blocks another's deletes", async () => {
446
+ const report = await executeTeardown({
447
+ environment: "dev",
448
+ stack: "shop",
449
+ plugins: [
450
+ createMockPlugin({
451
+ name: "fly",
452
+ teardownOwned: async () => ({
453
+ candidates: [{ name: "app", type: "Fly::Machines::App", marker: { stack: "shop", env: "dev" } }],
454
+ }),
455
+ executeTeardown: async () => { throw new Error("flaps down"); },
456
+ }),
457
+ createMockPlugin({
458
+ name: "k8s",
459
+ teardownOwned: async () => ({ candidates: [candidateWeb] }),
460
+ executeTeardown: async () => ({ outcomes: [{ name: "web", outcome: "deleted" as const }] }),
461
+ }),
462
+ ],
463
+ });
464
+ expect(report.outcomes.find((o) => o.lexicon === "fly")!.outcome).toBe("failed");
465
+ expect(report.outcomes.find((o) => o.lexicon === "k8s")!.outcome).toBe("deleted");
466
+ });
467
+
468
+ test("a plan entry naming a lexicon that is not loaded is skipped, not lost", async () => {
469
+ const report = await executeTeardown({
470
+ environment: "dev",
471
+ stack: "shop",
472
+ plan: {
473
+ environment: "dev",
474
+ stack: "shop",
475
+ entries: [{ lexicon: "aws", name: "vpc", type: "AWS::EC2::VPC", marker: { stack: "shop", env: "dev" } }],
476
+ holes: [],
477
+ skipped: [],
478
+ },
479
+ plugins: [],
480
+ });
481
+ expect(report.outcomes).toHaveLength(1);
482
+ expect(report.outcomes[0].outcome).toBe("skipped");
483
+ expect(report.outcomes[0].detail).toContain("aws");
484
+ });
485
+ });
486
+
487
+ describe("deployedStacks — a multi-stack project's declared stacks reach the lexicon (#1222)", () => {
488
+ const STACKS = [{ name: "net" }, { name: "app", region: "eu-west-1" }];
489
+
490
+ test("planTeardown forwards them to teardownOwned as `stacks`", async () => {
491
+ const teardownOwned = vi.fn(async (): Promise<TeardownEnumeration> => ({ candidates: [] }));
492
+ await planTeardown({
493
+ environment: "dev",
494
+ stack: "shop",
495
+ plugins: [createMockPlugin({ name: "aws", teardownOwned })],
496
+ deployedStacks: STACKS,
497
+ });
498
+ expect(teardownOwned).toHaveBeenCalledWith({
499
+ environment: "dev",
500
+ marker: { stack: "shop", env: "dev" },
501
+ stacks: STACKS,
502
+ });
503
+ });
504
+
505
+ test("executeTeardown forwards them to the execution half too", async () => {
506
+ const candidates = [
507
+ { name: "net", type: "AWS::CloudFormation::Stack", marker: { stack: "shop", env: "dev" } },
508
+ ];
509
+ const teardownOwned = vi.fn(async (): Promise<TeardownEnumeration> => ({ candidates }));
510
+ const execute = vi.fn(async (): Promise<TeardownExecution> => ({
511
+ outcomes: [{ name: "net", outcome: "deleted" }],
512
+ }));
513
+ await executeTeardown({
514
+ environment: "dev",
515
+ stack: "shop",
516
+ plugins: [createMockPlugin({ name: "aws", teardownOwned, executeTeardown: execute })],
517
+ deployedStacks: STACKS,
518
+ });
519
+ expect(execute).toHaveBeenCalledWith(
520
+ expect.objectContaining({ candidates, stacks: STACKS }),
521
+ );
522
+ });
523
+
524
+ test("an empty list is not forwarded — the single-stack convention stays the lexicon's", async () => {
525
+ const teardownOwned = vi.fn(async (): Promise<TeardownEnumeration> => ({ candidates: [] }));
526
+ await planTeardown({
527
+ environment: "dev",
528
+ stack: "shop",
529
+ plugins: [createMockPlugin({ name: "aws", teardownOwned })],
530
+ deployedStacks: [],
531
+ });
532
+ expect(teardownOwned).toHaveBeenCalledWith({
533
+ environment: "dev",
534
+ marker: { stack: "shop", env: "dev" },
535
+ });
536
+ });
537
+ });