@intentius/chant 0.1.14 → 0.1.16

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +18 -2
  3. package/src/cli/commands/build.ts +9 -1
  4. package/src/cli/commands/import-live.test.ts +126 -0
  5. package/src/cli/commands/import.ts +152 -2
  6. package/src/cli/commands/migrate.ts +2 -2
  7. package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +80 -37
  8. package/src/cli/handlers/{state.ts → lifecycle.ts} +166 -40
  9. package/src/cli/handlers/misc.ts +31 -2
  10. package/src/cli/handlers/run.test.ts +98 -0
  11. package/src/cli/handlers/run.ts +123 -0
  12. package/src/cli/main.test.ts +14 -0
  13. package/src/cli/main.ts +49 -15
  14. package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
  15. package/src/cli/mcp/op-tools.ts +2 -2
  16. package/src/cli/mcp/resource-handlers.ts +1 -1
  17. package/src/cli/mcp/server.test.ts +2 -2
  18. package/src/cli/mcp/server.ts +1 -1
  19. package/src/cli/registry.ts +23 -2
  20. package/src/codegen/fetch.test.ts +103 -2
  21. package/src/codegen/fetch.ts +62 -10
  22. package/src/config.ts +41 -0
  23. package/src/detectLexicon.test.ts +2 -2
  24. package/src/index.ts +2 -2
  25. package/src/lexicon-export.test.ts +92 -0
  26. package/src/lexicon.ts +88 -9
  27. package/src/lifecycle/change-set.test.ts +151 -0
  28. package/src/lifecycle/change-set.ts +172 -0
  29. package/src/{state → lifecycle}/git.test.ts +15 -15
  30. package/src/{state → lifecycle}/git.ts +14 -14
  31. package/src/{state → lifecycle}/index.ts +2 -0
  32. package/src/{state → lifecycle}/snapshot.test.ts +5 -5
  33. package/src/{state → lifecycle}/snapshot.ts +9 -9
  34. package/src/{state → lifecycle}/types.ts +1 -1
  35. package/src/op/activity-registry.test.ts +59 -0
  36. package/src/op/activity-registry.ts +98 -0
  37. package/src/op/builders.ts +56 -20
  38. package/src/op/index.ts +6 -1
  39. package/src/op/local-executor.test.ts +263 -0
  40. package/src/op/local-executor.ts +300 -0
  41. package/src/op/local-output.test.ts +54 -0
  42. package/src/op/local-output.ts +63 -0
  43. package/src/op/op.test.ts +41 -4
  44. package/src/op/types.ts +2 -2
  45. package/src/ownership.test.ts +109 -0
  46. package/src/ownership.ts +142 -0
  47. package/src/serializer.ts +19 -1
  48. package/src/toml-parse.ts +3 -3
  49. /package/src/{state → lifecycle}/digest.test.ts +0 -0
  50. /package/src/{state → lifecycle}/digest.ts +0 -0
  51. /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
  52. /package/src/{state → lifecycle}/live-diff.ts +0 -0
@@ -20,6 +20,66 @@ export interface FetchConfig {
20
20
  cacheTtlMs?: number;
21
21
  }
22
22
 
23
+ // ── Transient-aware fetch ──────────────────────────────────────────
24
+
25
+ /**
26
+ * HTTP statuses worth retrying. These are transient: a gateway hiccup,
27
+ * a rate limit, or an overloaded upstream — not a permanent 404/403.
28
+ */
29
+ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
30
+
31
+ const DEFAULT_RETRIES = 4;
32
+ const DEFAULT_BACKOFF_MS = 1000;
33
+
34
+ function sleep(ms: number): Promise<void> {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+
38
+ /**
39
+ * Fetch a URL, retrying on transient failures (network errors and
40
+ * retryable HTTP statuses) with exponential backoff.
41
+ *
42
+ * Permanent failures (e.g. 404, 403) are not retried — they throw on the
43
+ * first response. The returned response is guaranteed `ok`.
44
+ *
45
+ * `init` is passed through to `fetch` on every attempt, so callers that
46
+ * need request headers (e.g. the GitHub API `Accept` header) or an abort
47
+ * signal get retries without duplicating the loop.
48
+ */
49
+ export async function fetchWithRetry(
50
+ url: string,
51
+ retries = DEFAULT_RETRIES,
52
+ backoffMs = DEFAULT_BACKOFF_MS,
53
+ init?: RequestInit,
54
+ ): Promise<Response> {
55
+ let lastError: Error | undefined;
56
+
57
+ for (let attempt = 0; attempt <= retries; attempt++) {
58
+ if (attempt > 0) {
59
+ const delay = backoffMs * 2 ** (attempt - 1);
60
+ debug(`retrying ${url} (attempt ${attempt}/${retries}) after ${delay}ms: ${lastError?.message}`);
61
+ await sleep(delay);
62
+ }
63
+
64
+ let response: Response;
65
+ try {
66
+ response = init === undefined ? await fetch(url) : await fetch(url, init);
67
+ } catch (e) {
68
+ // Network-level failure (DNS, connection reset, timeout). Transient.
69
+ lastError = e instanceof Error ? e : new Error(String(e));
70
+ continue;
71
+ }
72
+
73
+ if (response.ok) return response;
74
+
75
+ const err = new Error(`Download from ${url} returned ${response.status}`);
76
+ if (!RETRYABLE_STATUSES.has(response.status)) throw err;
77
+ lastError = err;
78
+ }
79
+
80
+ throw lastError ?? new Error(`Download from ${url} failed after ${retries} retries`);
81
+ }
82
+
23
83
  // ── Fetch with cache ───────────────────────────────────────────────
24
84
 
25
85
  const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -46,11 +106,7 @@ export async function fetchWithCache(config: FetchConfig, force = false): Promis
46
106
  }
47
107
  }
48
108
 
49
- const response = await fetch(config.url);
50
- if (!response.ok) {
51
- throw new Error(`Download from ${config.url} returned ${response.status}`);
52
- }
53
-
109
+ const response = await fetchWithRetry(config.url);
54
110
  const arrayBuffer = await response.arrayBuffer();
55
111
  const data = Buffer.from(arrayBuffer);
56
112
 
@@ -205,11 +261,7 @@ export async function fetchAndExtractTar(
205
261
  }
206
262
  }
207
263
 
208
- const resp = await fetch(config.url);
209
- if (!resp.ok) {
210
- throw new Error(`Tarball download from ${config.url} returned ${resp.status}`);
211
- }
212
-
264
+ const resp = await fetchWithRetry(config.url);
213
265
  const compressed = new Uint8Array(await resp.arrayBuffer());
214
266
  const { gunzipSync } = await import("fflate");
215
267
  const tarData = gunzipSync(compressed);
package/src/config.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { z } from "zod";
4
4
  import type { LintConfig } from "./lint/config";
5
+ import type { OwnershipMarker } from "./ownership";
5
6
 
6
7
  /**
7
8
  * Zod schema for ChantConfig validation.
@@ -9,7 +10,13 @@ import type { LintConfig } from "./lint/config";
9
10
  export const ChantConfigSchema = z.object({
10
11
  lexicons: z.array(z.string().min(1)).optional(),
11
12
  environments: z.array(z.string().min(1)).optional(),
13
+ sourceDir: z.string().min(1).optional(),
12
14
  lint: z.record(z.string(), z.unknown()).optional(),
15
+ ownership: z.object({
16
+ stack: z.string().min(1).optional(),
17
+ env: z.string().min(1).optional(),
18
+ enabled: z.boolean().optional(),
19
+ }).optional(),
13
20
  }).passthrough();
14
21
 
15
22
  /**
@@ -24,8 +31,32 @@ export interface ChantConfig {
24
31
  /** Environment names (e.g. ["staging", "prod"]) */
25
32
  environments?: string[];
26
33
 
34
+ /**
35
+ * Directory (relative to the project root) that holds the chant infrastructure
36
+ * source. Lifecycle commands (`snapshot`/`diff`/`plan`) build from here instead
37
+ * of the project root, so a mixed-layout project — chant `src/` alongside app
38
+ * code that has import side effects — can scope the build to just the infra.
39
+ * Defaults to "." (the project root). The `--src` flag overrides it.
40
+ */
41
+ sourceDir?: string;
42
+
27
43
  /** Lint configuration (rules, extends, overrides, plugins) */
28
44
  lint?: LintConfig;
45
+
46
+ /**
47
+ * Opt-in cloud-side ownership marking. When `stack` is set (and `enabled`
48
+ * is not false), the serializer stamps a chant ownership marker carrying
49
+ * this stack/env identity onto every supported resource. See {@link
50
+ * resolveOwnershipMarker}.
51
+ */
52
+ ownership?: {
53
+ /** Stack identity stamped onto resources (required to enable stamping). */
54
+ stack?: string;
55
+ /** Optional environment identity. */
56
+ env?: string;
57
+ /** Set false to disable stamping even when `stack` is present. */
58
+ enabled?: boolean;
59
+ };
29
60
  }
30
61
 
31
62
  /**
@@ -71,6 +102,16 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
71
102
  return { config: DEFAULT_CHANT_CONFIG };
72
103
  }
73
104
 
105
+ /**
106
+ * Resolve the ownership marker to stamp from project config, or undefined when
107
+ * ownership marking is off (no `stack`, or `enabled: false`).
108
+ */
109
+ export function resolveOwnershipMarker(config: ChantConfig): OwnershipMarker | undefined {
110
+ const o = config.ownership;
111
+ if (!o || !o.stack || o.enabled === false) return undefined;
112
+ return { stack: o.stack, env: o.env };
113
+ }
114
+
74
115
  /**
75
116
  * Validate and normalize a raw config object into ChantConfig shape.
76
117
  */
@@ -238,12 +238,12 @@ describe("detectLexicons", () => {
238
238
  const file = join(testDir, "infra.ts");
239
239
  await writeFile(
240
240
  file,
241
- `import {\n Namespace,\n Service,\n} from "@intentius/chant-lexicon-k8s";\nimport {\n FlywayProject,\n Environment,\n} from "@intentius/chant-lexicon-flyway";\n\nexport const ns = new Namespace({});`
241
+ `import {\n Namespace,\n Service,\n} from "@intentius/chant-lexicon-k8s";\nimport {\n HelmRelease,\n HelmChart,\n} from "@intentius/chant-lexicon-helm";\n\nexport const ns = new Namespace({});`
242
242
  );
243
243
 
244
244
  const result = await detectLexicons([file]);
245
245
  expect(result).toContain("k8s");
246
- expect(result).toContain("flyway");
246
+ expect(result).toContain("helm");
247
247
  expect(result).toHaveLength(2);
248
248
  });
249
249
 
package/src/index.ts CHANGED
@@ -59,8 +59,8 @@ export * from "./child-project";
59
59
  export * from "./lsp/types";
60
60
  export * from "./lsp/lexicon-providers";
61
61
  export * from "./mcp/types";
62
- export * from "./state/index";
62
+ export * from "./lifecycle/index";
63
63
  // Op builders — use explicit exports to avoid collision with the core `build` function
64
64
  export { Op, phase, activity, gate, kubectlApply, helmInstall, waitForStack,
65
- gitlabPipeline, stateSnapshot, shell, teardown, OpResource } from "./op/index";
65
+ gitlabPipeline, lifecycleSnapshot, shell, teardown, OpResource } from "./op/index";
66
66
  export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./op/index";
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type {
3
+ LexiconPlugin,
4
+ ObservationLexicon,
5
+ ExportedTemplate,
6
+ ResourceSelector,
7
+ } from "./lexicon";
8
+ import type { TypeScriptGenerator } from "./import/generator";
9
+ import type { TemplateIR } from "./import/parser";
10
+
11
+ // A throwaway generator standing in for any lexicon's real templateGenerator().
12
+ const generator: TypeScriptGenerator = {
13
+ generate(ir: TemplateIR) {
14
+ return ir.resources.map((r) => ({
15
+ path: `${r.logicalId}.ts`,
16
+ content: `export const ${r.logicalId} = ${JSON.stringify(r.properties)};`,
17
+ }));
18
+ },
19
+ };
20
+
21
+ // A minimal lexicon that implements live export — the acceptance shape.
22
+ const exportingLexicon: Pick<LexiconPlugin, "name" | "exportResources"> = {
23
+ name: "fake",
24
+ async exportResources(opts: {
25
+ environment: string;
26
+ selector?: ResourceSelector;
27
+ owned?: boolean;
28
+ }): Promise<ExportedTemplate> {
29
+ const selector = opts.selector;
30
+ const all: ExportedTemplate = {
31
+ resources: [
32
+ { logicalId: "Bucket", type: "Fake::Bucket", properties: { versioning: true } },
33
+ { logicalId: "Queue", type: "Fake::Queue", properties: { fifo: false } },
34
+ ],
35
+ parameters: [],
36
+ metadata: { environment: opts.environment, owned: opts.owned ?? false },
37
+ };
38
+ if (!selector) return all;
39
+ return {
40
+ ...all,
41
+ resources: all.resources.filter(
42
+ (r) =>
43
+ (selector.type === undefined || r.type === selector.type) &&
44
+ (selector.name === undefined || r.logicalId === selector.name),
45
+ ),
46
+ };
47
+ },
48
+ };
49
+
50
+ describe("exportResources contract (#113)", () => {
51
+ test("an ExportedTemplate feeds templateGenerator() unchanged", async () => {
52
+ const ir = await exportingLexicon.exportResources!({ environment: "prod" });
53
+ // ExportedTemplate is structurally a TemplateIR — no adapter needed.
54
+ const files = generator.generate(ir);
55
+ expect(files.map((f) => f.path)).toEqual(["Bucket.ts", "Queue.ts"]);
56
+ expect(files[0].content).toContain("versioning");
57
+ });
58
+
59
+ test("selector narrows the exported set", async () => {
60
+ const ir = await exportingLexicon.exportResources!({
61
+ environment: "prod",
62
+ selector: { name: "Queue" },
63
+ });
64
+ expect(ir.resources).toHaveLength(1);
65
+ expect(ir.resources[0].logicalId).toBe("Queue");
66
+ });
67
+
68
+ test("owned is accepted but inert (carried into metadata, no filtering yet)", async () => {
69
+ const ir = await exportingLexicon.exportResources!({ environment: "prod", owned: true });
70
+ expect(ir.metadata?.owned).toBe(true);
71
+ expect(ir.resources).toHaveLength(2);
72
+ });
73
+
74
+ test("type separation: observation view cannot reach exportResources", () => {
75
+ // A full plugin is assignable to the narrowed observation view…
76
+ const full = exportingLexicon as unknown as LexiconPlugin;
77
+ const observed: ObservationLexicon = full;
78
+ // …but exportResources is not visible on it. Compile-time guarantee:
79
+ // @ts-expect-error exportResources is omitted from ObservationLexicon
80
+ void observed.exportResources;
81
+ expect(typeof observed.name).toBe("string");
82
+ });
83
+
84
+ test("type separation: scrubbed metadata is not an ExportedTemplate", () => {
85
+ // A ResourceMetadata-shaped object lacks resources/parameters, so it can
86
+ // never be passed where an ExportedTemplate (full config) is expected.
87
+ const scrubbed = { type: "Fake::Bucket", status: "OK" };
88
+ // @ts-expect-error observation metadata is not a full-fidelity export
89
+ const asExport: ExportedTemplate = scrubbed;
90
+ expect(asExport).toBeDefined();
91
+ });
92
+ });
package/src/lexicon.ts CHANGED
@@ -2,7 +2,7 @@ import type { Serializer } from "./serializer";
2
2
  import type { LintRule } from "./lint/rule";
3
3
  import type { RuleSpec } from "./lint/declarative";
4
4
  import type { PostSynthCheck } from "./lint/post-synth";
5
- import type { TemplateParser } from "./import/parser";
5
+ import type { TemplateParser, TemplateIR } from "./import/parser";
6
6
  import type { TypeScriptGenerator } from "./import/generator";
7
7
  import type { ArtifactIntegrity } from "./lexicon-integrity";
8
8
  import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
@@ -267,6 +267,13 @@ export interface LexiconPlugin {
267
267
  buildOutput: string;
268
268
  entityNames: string[];
269
269
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
270
+ /**
271
+ * Restrict the result to chant-owned resources (those carrying the
272
+ * ownership marker, #119). Where a lexicon has no durable marker channel,
273
+ * it must log that ownership is unavailable rather than silently returning
274
+ * everything.
275
+ */
276
+ owned?: boolean;
270
277
  }): Promise<Record<string, ResourceMetadata>>;
271
278
 
272
279
  /**
@@ -274,22 +281,87 @@ export interface LexiconPlugin {
274
281
  *
275
282
  * Use this for lexicons whose chant entities describe *authoring*
276
283
  * primitives rather than 1:1 cloud resources — e.g. Helm (charts vs
277
- * releases), Docker (Compose vs running containers), Flyway (migration
278
- * scripts vs applied migrations). The contract is context-keyed: given an
279
- * environment, list all artifacts visible there. There is no `declared`
280
- * comparison axis `state diff --live` reports added/removed/changed
281
- * between snapshots, not vs. declared.
284
+ * releases), Docker (Compose vs running containers). The contract is
285
+ * context-keyed: given an environment, list all artifacts visible there.
286
+ * There is no `declared` comparison axis `state diff --live` reports
287
+ * added/removed/changed between snapshots, not vs. declared.
282
288
  *
283
289
  * `entities` is passed for cases where the lexicon needs to know what
284
- * was declared in order to enumerate (e.g. Flyway needs the declared
285
- * `Flyway::Environment` entities to know which DBs to query).
290
+ * was declared in order to scope its enumeration (e.g. a per-tenant
291
+ * runtime where the declared entities name which tenants to query).
286
292
  */
287
293
  listArtifacts?(options: {
288
294
  environment: string;
289
295
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
290
296
  }): Promise<Record<string, ArtifactMetadata>>;
297
+
298
+ // Live export (cloud → code)
299
+ /**
300
+ * Export full-fidelity import IR from a live API — the cloud→code primitive
301
+ * that lets chant regenerate TypeScript from running cloud/cluster state.
302
+ *
303
+ * Deliberately separate from {@link describeResources}: that returns scrubbed
304
+ * *output* metadata for diffing, this returns full *input* config — enough to
305
+ * regenerate a resource, and therefore possibly containing secrets. The
306
+ * scrubbing boundary stays single-purpose; never overload one method for both.
307
+ *
308
+ * The return type {@link ExportedTemplate} is branded distinct from the
309
+ * observation types so a full-fidelity export can never flow into the
310
+ * observation/`state` code paths by accident.
311
+ *
312
+ * `owned` is accepted now but inert until ownership marking exists (#119/#120).
313
+ *
314
+ * `verbatim` controls fidelity: by default an implementation strips
315
+ * server-defaulted and server-managed fields to reach the declared shape
316
+ * (the form a user would have authored). `verbatim: true` keeps them. Targets
317
+ * whose live config is already the declared shape (e.g. a CloudFormation
318
+ * original template) may ignore it.
319
+ */
320
+ exportResources?(options: {
321
+ environment: string;
322
+ selector?: ResourceSelector;
323
+ owned?: boolean;
324
+ verbatim?: boolean;
325
+ }): Promise<ExportedTemplate>;
326
+ }
327
+
328
+ /**
329
+ * The observation view of a lexicon — every capability except live export.
330
+ *
331
+ * State/observation code (snapshots, `state diff --live`) consumes lexicons
332
+ * through this type so that `exportResources` is unreachable from those paths:
333
+ * a full-fidelity {@link ExportedTemplate} (which may carry secrets) must never
334
+ * be read where scrubbed {@link ResourceMetadata} is expected. A full
335
+ * {@link LexiconPlugin} is assignable to this type; accessing `exportResources`
336
+ * on it is a compile error.
337
+ */
338
+ export type ObservationLexicon = Omit<LexiconPlugin, "exportResources">;
339
+
340
+ /**
341
+ * Narrows which live resources {@link LexiconPlugin.exportResources} and the
342
+ * `owned` filter operate on. Both fields are optional; omit to export all.
343
+ */
344
+ export interface ResourceSelector {
345
+ /** Restrict to a single chant resource type (e.g. "AWS::S3::Bucket"). */
346
+ readonly type?: string;
347
+ /** Restrict to a single resource name. */
348
+ readonly name?: string;
291
349
  }
292
350
 
351
+ /**
352
+ * Full-fidelity import IR read from a live API, branded distinct from the
353
+ * scrubbed observation metadata. It IS a {@link TemplateIR} — it feeds the
354
+ * existing `templateGenerator()` unchanged — but the phantom brand keeps it
355
+ * from being passed where observation metadata is expected, and keeps
356
+ * observation metadata from being passed where an export is expected.
357
+ *
358
+ * The brand is type-only; nothing is added at runtime.
359
+ */
360
+ export type ExportedTemplate = TemplateIR & {
361
+ /** Phantom marker — never present at runtime. */
362
+ readonly __fidelity?: "full-config";
363
+ };
364
+
293
365
  /**
294
366
  * Metadata about a deployed resource, returned by describeResources.
295
367
  */
@@ -304,6 +376,13 @@ export interface ResourceMetadata {
304
376
  lastUpdated?: string;
305
377
  /** Cloud-assigned output properties */
306
378
  attributes?: Record<string, unknown>;
379
+ /**
380
+ * Live ownership verdict from the resource's marker (#119/#120), when the
381
+ * lexicon could determine it. `owned` = carries chant's marker; `foreign` =
382
+ * no marker. Absent = the lexicon has no marker channel here. The change set
383
+ * reads this — never the snapshot — to decide whether an orphan is a delete.
384
+ */
385
+ ownership?: "owned" | "foreign";
307
386
  }
308
387
 
309
388
  /**
@@ -311,7 +390,7 @@ export interface ResourceMetadata {
311
390
  * as ResourceMetadata; the conceptual distinction is whether the lexicon's
312
391
  * chant entities have 1:1 runtime equivalents (resources) or whether the
313
392
  * runtime artifacts are created by tooling outside chant's entity model
314
- * (artifacts — e.g. Helm releases, Docker containers, Flyway migrations).
393
+ * (artifacts — e.g. Helm releases, Docker containers).
315
394
  */
316
395
  export interface ArtifactMetadata {
317
396
  /** Artifact type (e.g. Helm::Release, Docker::Container) */
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { buildChangeSet, renderChangeSet, summarize } from "./change-set";
3
+ import type { ResourceMetadata } from "../lexicon";
4
+
5
+ const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
6
+ type: "Fake::Resource",
7
+ status: "OK",
8
+ ...over,
9
+ });
10
+
11
+ describe("buildChangeSet (#118)", () => {
12
+ test("declared but not live → create", () => {
13
+ const cs = buildChangeSet("prod", {
14
+ declared: new Set(["bucket"]),
15
+ observedNow: {},
16
+ observedThen: undefined,
17
+ });
18
+ const e = cs.entries.find((x) => x.name === "bucket")!;
19
+ expect(e.action).toBe("create");
20
+ expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false });
21
+ expect(e.ownership).toBe("unknown");
22
+ });
23
+
24
+ test("declared and live with drift since snapshot → update with deltas", () => {
25
+ const cs = buildChangeSet("prod", {
26
+ declared: new Set(["queue"]),
27
+ observedNow: { queue: meta({ status: "ACTIVE" }) },
28
+ observedThen: { queue: meta({ status: "CREATING" }) },
29
+ });
30
+ const e = cs.entries.find((x) => x.name === "queue")!;
31
+ expect(e.action).toBe("update");
32
+ expect(e.deltas).toEqual([{ path: "status", oldValue: "CREATING", newValue: "ACTIVE" }]);
33
+ });
34
+
35
+ test("declared and live, unchanged → noop", () => {
36
+ const cs = buildChangeSet("prod", {
37
+ declared: new Set(["queue"]),
38
+ observedNow: { queue: meta({ status: "ACTIVE" }) },
39
+ observedThen: { queue: meta({ status: "ACTIVE" }) },
40
+ });
41
+ expect(cs.entries.find((x) => x.name === "queue")!.action).toBe("noop");
42
+ });
43
+
44
+ test("live but undeclared, no ownership data → adopt, never delete", () => {
45
+ const cs = buildChangeSet("prod", {
46
+ declared: new Set(),
47
+ observedNow: { orphan: meta() },
48
+ observedThen: undefined,
49
+ });
50
+ const e = cs.entries.find((x) => x.name === "orphan")!;
51
+ expect(e.action).toBe("adopt");
52
+ expect(e.ownership).toBe("unknown");
53
+ });
54
+
55
+ test("owned orphan → delete (#121)", () => {
56
+ const cs = buildChangeSet("prod", {
57
+ declared: new Set(),
58
+ observedNow: { orphan: meta({ ownership: "owned" }) },
59
+ observedThen: undefined,
60
+ });
61
+ const e = cs.entries.find((x) => x.name === "orphan")!;
62
+ expect(e.action).toBe("delete");
63
+ expect(e.ownership).toBe("owned");
64
+ });
65
+
66
+ test("foreign orphan → adopt, never delete (#121)", () => {
67
+ const cs = buildChangeSet("prod", {
68
+ declared: new Set(),
69
+ observedNow: { orphan: meta({ ownership: "foreign" }) },
70
+ observedThen: undefined,
71
+ });
72
+ const e = cs.entries.find((x) => x.name === "orphan")!;
73
+ expect(e.action).toBe("adopt");
74
+ expect(e.ownership).toBe("foreign");
75
+ });
76
+
77
+ test("snapshot is never load-bearing: ownership/delete ignores observedThen", () => {
78
+ // The same live orphan, once with a rich snapshot and once with none.
79
+ // The delete decision must depend only on the LIVE ownership marker, so the
80
+ // result must be identical regardless of what the snapshot says.
81
+ const withSnapshot = buildChangeSet("prod", {
82
+ declared: new Set(),
83
+ observedNow: { orphan: meta({ ownership: "owned" }) },
84
+ observedThen: { orphan: meta({ ownership: "foreign", status: "STALE" }) },
85
+ });
86
+ const withoutSnapshot = buildChangeSet("prod", {
87
+ declared: new Set(),
88
+ observedNow: { orphan: meta({ ownership: "owned" }) },
89
+ observedThen: undefined,
90
+ });
91
+ const a = withSnapshot.entries.find((x) => x.name === "orphan")!;
92
+ const b = withoutSnapshot.entries.find((x) => x.name === "orphan")!;
93
+ expect(a.action).toBe("delete");
94
+ expect(a.ownership).toBe("owned");
95
+ expect(a.action).toBe(b.action);
96
+ expect(a.ownership).toBe(b.ownership);
97
+ });
98
+
99
+ test("never proposes a delete without ownership data", () => {
100
+ const cs = buildChangeSet("prod", {
101
+ declared: new Set(["a"]),
102
+ observedNow: { b: meta(), c: meta() }, // two orphans
103
+ observedThen: { b: meta(), c: meta() },
104
+ });
105
+ expect(cs.entries.some((e) => e.action === "delete")).toBe(false);
106
+ expect(cs.entries.filter((e) => e.action === "adopt").map((e) => e.name)).toEqual(["b", "c"]);
107
+ });
108
+
109
+ test("only in snapshot (gone now, undeclared) → noop", () => {
110
+ const cs = buildChangeSet("prod", {
111
+ declared: new Set(),
112
+ observedNow: {},
113
+ observedThen: { ghost: meta() },
114
+ });
115
+ expect(cs.entries.find((x) => x.name === "ghost")!.action).toBe("noop");
116
+ });
117
+
118
+ test("entries are sorted by name", () => {
119
+ const cs = buildChangeSet("prod", {
120
+ declared: new Set(["z", "a", "m"]),
121
+ observedNow: {},
122
+ observedThen: undefined,
123
+ });
124
+ expect(cs.entries.map((e) => e.name)).toEqual(["a", "m", "z"]);
125
+ });
126
+ });
127
+
128
+ describe("summarize / renderChangeSet", () => {
129
+ const cs = buildChangeSet("prod", {
130
+ declared: new Set(["create-me", "keep-me"]),
131
+ observedNow: { "keep-me": meta(), orphan: meta() },
132
+ observedThen: { "keep-me": meta() },
133
+ });
134
+
135
+ test("summarize counts each action", () => {
136
+ const counts = summarize(cs);
137
+ expect(counts.create).toBe(1);
138
+ expect(counts.noop).toBe(1);
139
+ expect(counts.adopt).toBe(1);
140
+ expect(counts.delete).toBe(0);
141
+ });
142
+
143
+ test("render shows the env and grouped sections", () => {
144
+ const out = renderChangeSet(cs);
145
+ expect(out).toContain("Plan for prod");
146
+ expect(out).toContain("CREATE:");
147
+ expect(out).toContain("create-me");
148
+ expect(out).toContain("ADOPT:");
149
+ expect(out).toContain("orphan");
150
+ });
151
+ });