@intentius/chant 0.1.7 → 0.1.9

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.
@@ -0,0 +1,409 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import { createMockPlugin, staticDescribeResources, staticListArtifacts } from "@intentius/chant-test-utils";
3
+ import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
4
+ import type { BuildResult } from "../../build";
5
+ import type { ParsedArgs } from "../registry";
6
+
7
+ const buildMock = vi.fn();
8
+ const fetchStateMock = vi.fn();
9
+ const readSnapshotMock = vi.fn();
10
+ const readEnvironmentSnapshotsMock = vi.fn();
11
+ const listSnapshotsMock = vi.fn();
12
+ const takeSnapshotMock = vi.fn();
13
+ const loadChantConfigMock = vi.fn();
14
+
15
+ vi.mock("../../build", () => ({ build: (...args: unknown[]) => buildMock(...args) }));
16
+ vi.mock("../../state/git", () => ({
17
+ fetchState: () => fetchStateMock(),
18
+ readSnapshot: (...args: unknown[]) => readSnapshotMock(...args),
19
+ readEnvironmentSnapshots: (...args: unknown[]) => readEnvironmentSnapshotsMock(...args),
20
+ listSnapshots: (...args: unknown[]) => listSnapshotsMock(...args),
21
+ }));
22
+ vi.mock("../../state/snapshot", () => ({
23
+ takeSnapshot: (...args: unknown[]) => takeSnapshotMock(...args),
24
+ }));
25
+ vi.mock("../../config", () => ({
26
+ loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
27
+ }));
28
+
29
+ const { runStateDiff, runStateSnapshot, runStateShow, runStateLog, runStateUnknown } = await import("./state");
30
+
31
+ function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
32
+ return {
33
+ command: "state",
34
+ path: "diff",
35
+ format: "",
36
+ fix: false,
37
+ watch: false,
38
+ verbose: false,
39
+ help: false,
40
+ live: false,
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ function makeBuildResult(entitiesByLexicon: Record<string, string[]>): BuildResult {
46
+ const entities = new Map();
47
+ for (const [lexicon, names] of Object.entries(entitiesByLexicon)) {
48
+ for (const name of names) {
49
+ entities.set(name, { lexicon, entityType: `${lexicon}::Mock`, props: {} });
50
+ }
51
+ }
52
+ return {
53
+ outputs: new Map(Object.keys(entitiesByLexicon).map((l) => [l, "{}"])),
54
+ entities,
55
+ dependencies: new Map(),
56
+ errors: [],
57
+ warnings: [],
58
+ manifest: {
59
+ lexicons: Object.keys(entitiesByLexicon),
60
+ outputs: {},
61
+ deployOrder: Object.keys(entitiesByLexicon),
62
+ },
63
+ sourceFileCount: 1,
64
+ } as unknown as BuildResult;
65
+ }
66
+
67
+ const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
68
+ type: "AWS::S3::Bucket",
69
+ status: "CREATE_COMPLETE",
70
+ physicalId: "bucket-1",
71
+ ...overrides,
72
+ });
73
+
74
+ describe("runStateDiff --live", () => {
75
+ let stdoutSpy: ReturnType<typeof vi.spyOn>;
76
+ let stderrSpy: ReturnType<typeof vi.spyOn>;
77
+ let stdoutBuf: string[];
78
+ let stderrBuf: string[];
79
+
80
+ beforeEach(() => {
81
+ stdoutBuf = [];
82
+ stderrBuf = [];
83
+ stdoutSpy = vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
84
+ stderrSpy = vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
85
+ buildMock.mockReset();
86
+ fetchStateMock.mockReset();
87
+ readSnapshotMock.mockReset();
88
+ });
89
+
90
+ test("surfaces drift between previous snapshot and live state", async () => {
91
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
92
+ fetchStateMock.mockResolvedValue(undefined);
93
+ readSnapshotMock.mockResolvedValue(JSON.stringify({
94
+ lexicon: "aws",
95
+ environment: "prod",
96
+ commit: "abc",
97
+ timestamp: "2026-04-01T00:00:00Z",
98
+ resources: { bucket: meta({ status: "CREATE_COMPLETE" }) },
99
+ }));
100
+
101
+ const plugins: LexiconPlugin[] = [
102
+ createMockPlugin({
103
+ name: "aws",
104
+ describeResources: staticDescribeResources({
105
+ bucket: meta({ status: "UPDATE_COMPLETE" }),
106
+ }),
107
+ }),
108
+ ];
109
+
110
+ const ctx = {
111
+ args: makeArgs({ command: "state", path: "diff", extraPositional: "prod", live: true }),
112
+ plugins,
113
+ serializers: plugins.map((p) => p.serializer),
114
+ };
115
+
116
+ const exit = await runStateDiff(ctx);
117
+
118
+ expect(exit).toBe(0);
119
+ const output = stdoutBuf.join("\n");
120
+ expect(output).toContain("DRIFTED");
121
+ expect(output).toContain("bucket");
122
+ expect(output).toContain("status:");
123
+ expect(output).toContain("CREATE_COMPLETE");
124
+ expect(output).toContain("UPDATE_COMPLETE");
125
+ });
126
+
127
+ test("warns and skips lexicons without describeResources", async () => {
128
+ buildMock.mockResolvedValue(makeBuildResult({ k8s: ["pod"] }));
129
+ fetchStateMock.mockResolvedValue(undefined);
130
+ readSnapshotMock.mockResolvedValue(null);
131
+
132
+ const plugins: LexiconPlugin[] = [
133
+ createMockPlugin({ name: "k8s" }),
134
+ ];
135
+
136
+ const ctx = {
137
+ args: makeArgs({ command: "state", path: "diff", extraPositional: "prod", live: true }),
138
+ plugins,
139
+ serializers: plugins.map((p) => p.serializer),
140
+ };
141
+
142
+ const exit = await runStateDiff(ctx);
143
+
144
+ expect(exit).toBe(1);
145
+ const stderr = stderrBuf.join("\n");
146
+ expect(stderr).toContain("k8s");
147
+ expect(stderr).toContain("does not implement describeResources");
148
+ });
149
+
150
+ test("--live with a listArtifacts-only plugin diffs artifacts (no resources path)", async () => {
151
+ buildMock.mockResolvedValue(makeBuildResult({ helm: [] }));
152
+ fetchStateMock.mockResolvedValue(undefined);
153
+ // Previous snapshot has no artifact entry for the new release → expect ARTIFACTS ADDED
154
+ readSnapshotMock.mockResolvedValue(JSON.stringify({
155
+ lexicon: "helm",
156
+ environment: "prod",
157
+ commit: "x",
158
+ timestamp: "t",
159
+ resources: {},
160
+ artifacts: {},
161
+ }));
162
+
163
+ const plugins: LexiconPlugin[] = [
164
+ createMockPlugin({
165
+ name: "helm",
166
+ listArtifacts: staticListArtifacts({
167
+ "release/default/web": { type: "Helm::Release", physicalId: "default/web", status: "deployed" },
168
+ }),
169
+ }),
170
+ ];
171
+
172
+ const ctx = {
173
+ args: makeArgs({ command: "state", path: "diff", extraPositional: "prod", live: true }),
174
+ plugins,
175
+ serializers: plugins.map((p) => p.serializer),
176
+ };
177
+
178
+ const exit = await runStateDiff(ctx);
179
+
180
+ expect(exit).toBe(0);
181
+ const output = stdoutBuf.join("\n");
182
+ expect(output).toContain("ARTIFACTS ADDED");
183
+ expect(output).toContain("release/default/web");
184
+ });
185
+
186
+ test("legacy digest mode still works without --live", async () => {
187
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
188
+ fetchStateMock.mockResolvedValue(undefined);
189
+ readSnapshotMock.mockResolvedValue(null);
190
+
191
+ const plugins: LexiconPlugin[] = [createMockPlugin({ name: "aws" })];
192
+
193
+ const ctx = {
194
+ args: makeArgs({ command: "state", path: "diff", extraPositional: "prod", live: false }),
195
+ plugins,
196
+ serializers: plugins.map((p) => p.serializer),
197
+ };
198
+
199
+ const exit = await runStateDiff(ctx);
200
+
201
+ expect(exit).toBe(0);
202
+ const output = stdoutBuf.join("\n");
203
+ expect(output).toContain("aws");
204
+ expect(output).toContain("bucket");
205
+ expect(output).toContain("added");
206
+ });
207
+ });
208
+
209
+ describe("runStateSnapshot", () => {
210
+ let stdoutBuf: string[];
211
+ let stderrBuf: string[];
212
+
213
+ beforeEach(() => {
214
+ stdoutBuf = [];
215
+ stderrBuf = [];
216
+ vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
217
+ vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
218
+ buildMock.mockReset();
219
+ takeSnapshotMock.mockReset();
220
+ loadChantConfigMock.mockReset();
221
+ loadChantConfigMock.mockResolvedValue({ config: { environments: ["prod"] } });
222
+ });
223
+
224
+ test("missing environment arg → exit 1 with helpful message", async () => {
225
+ const ctx = {
226
+ args: makeArgs({ command: "state", path: "snapshot" }),
227
+ plugins: [],
228
+ serializers: [],
229
+ };
230
+ const exit = await runStateSnapshot(ctx);
231
+ expect(exit).toBe(1);
232
+ expect(stderrBuf.join("\n")).toContain("Environment is required");
233
+ });
234
+
235
+ test("environment not in config → exit 1", async () => {
236
+ const ctx = {
237
+ args: makeArgs({ command: "state", path: "snapshot", extraPositional: "unknown" }),
238
+ plugins: [],
239
+ serializers: [],
240
+ };
241
+ const exit = await runStateSnapshot(ctx);
242
+ expect(exit).toBe(1);
243
+ expect(stderrBuf.join("\n")).toContain('Unknown environment "unknown"');
244
+ });
245
+
246
+ test("no plugins implement describeResources → exit 1 with hint", async () => {
247
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["x"] }));
248
+ const plugins: LexiconPlugin[] = [createMockPlugin({ name: "aws" })];
249
+ const ctx = {
250
+ args: makeArgs({ command: "state", path: "snapshot", extraPositional: "prod" }),
251
+ plugins,
252
+ serializers: plugins.map((p) => p.serializer),
253
+ };
254
+ const exit = await runStateSnapshot(ctx);
255
+ expect(exit).toBe(1);
256
+ expect(stderrBuf.join("\n")).toContain("No plugins implement describeResources");
257
+ });
258
+
259
+ test("happy path: writes snapshot via takeSnapshot and reports counts", async () => {
260
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
261
+ takeSnapshotMock.mockResolvedValue({
262
+ snapshots: [{ lexicon: "aws", environment: "prod", resources: { bucket: meta() } }],
263
+ commit: "sha",
264
+ warnings: [],
265
+ errors: [],
266
+ });
267
+ const plugins: LexiconPlugin[] = [
268
+ createMockPlugin({
269
+ name: "aws",
270
+ describeResources: staticDescribeResources({ bucket: meta() }),
271
+ }),
272
+ ];
273
+ const ctx = {
274
+ args: makeArgs({ command: "state", path: "snapshot", extraPositional: "prod" }),
275
+ plugins,
276
+ serializers: plugins.map((p) => p.serializer),
277
+ };
278
+ const exit = await runStateSnapshot(ctx);
279
+ expect(exit).toBe(0);
280
+ expect(stderrBuf.join("\n")).toContain("Snapshot saved");
281
+ expect(takeSnapshotMock).toHaveBeenCalledTimes(1);
282
+ });
283
+ });
284
+
285
+ describe("runStateShow", () => {
286
+ let stdoutBuf: string[];
287
+ let stderrBuf: string[];
288
+
289
+ beforeEach(() => {
290
+ stdoutBuf = [];
291
+ stderrBuf = [];
292
+ vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
293
+ vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
294
+ fetchStateMock.mockReset();
295
+ readSnapshotMock.mockReset();
296
+ readEnvironmentSnapshotsMock.mockReset();
297
+ });
298
+
299
+ test("missing environment arg → exit 1", async () => {
300
+ const ctx = {
301
+ args: makeArgs({ command: "state", path: "show" }),
302
+ plugins: [], serializers: [],
303
+ };
304
+ expect(await runStateShow(ctx)).toBe(1);
305
+ expect(stderrBuf.join("\n")).toContain("Environment is required");
306
+ });
307
+
308
+ test("specific lexicon: prints snapshot table when found", async () => {
309
+ fetchStateMock.mockResolvedValue(undefined);
310
+ readSnapshotMock.mockResolvedValue(JSON.stringify({
311
+ lexicon: "aws", environment: "prod", commit: "x", timestamp: "t",
312
+ resources: { bucket: { type: "AWS::S3::Bucket", physicalId: "b-1", status: "OK" } },
313
+ }));
314
+ const ctx = {
315
+ args: makeArgs({ command: "state", path: "show", extraPositional: "prod", extraPositional2: "aws" }),
316
+ plugins: [], serializers: [],
317
+ };
318
+ expect(await runStateShow(ctx)).toBe(0);
319
+ const out = stdoutBuf.join("\n");
320
+ expect(out).toContain("bucket");
321
+ expect(out).toContain("AWS::S3::Bucket");
322
+ });
323
+
324
+ test("specific lexicon: returns 1 when no snapshot found", async () => {
325
+ fetchStateMock.mockResolvedValue(undefined);
326
+ readSnapshotMock.mockResolvedValue(null);
327
+ const ctx = {
328
+ args: makeArgs({ command: "state", path: "show", extraPositional: "prod", extraPositional2: "aws" }),
329
+ plugins: [], serializers: [],
330
+ };
331
+ expect(await runStateShow(ctx)).toBe(1);
332
+ expect(stderrBuf.join("\n")).toContain("No snapshot found");
333
+ });
334
+
335
+ test("no lexicon: lists all lexicons in env", async () => {
336
+ fetchStateMock.mockResolvedValue(undefined);
337
+ readEnvironmentSnapshotsMock.mockResolvedValue(new Map([
338
+ ["aws", JSON.stringify({ lexicon: "aws", environment: "prod", commit: "x", timestamp: "t", resources: {} })],
339
+ ["gcp", JSON.stringify({ lexicon: "gcp", environment: "prod", commit: "x", timestamp: "t", resources: {} })],
340
+ ]));
341
+ const ctx = {
342
+ args: makeArgs({ command: "state", path: "show", extraPositional: "prod" }),
343
+ plugins: [], serializers: [],
344
+ };
345
+ expect(await runStateShow(ctx)).toBe(0);
346
+ const out = stdoutBuf.join("\n");
347
+ expect(out).toContain("prod/aws");
348
+ expect(out).toContain("prod/gcp");
349
+ });
350
+ });
351
+
352
+ describe("runStateLog", () => {
353
+ let stdoutBuf: string[];
354
+ let stderrBuf: string[];
355
+
356
+ beforeEach(() => {
357
+ stdoutBuf = [];
358
+ stderrBuf = [];
359
+ vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
360
+ vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
361
+ fetchStateMock.mockReset();
362
+ listSnapshotsMock.mockReset();
363
+ });
364
+
365
+ test("returns 1 with message when no entries exist", async () => {
366
+ fetchStateMock.mockResolvedValue(undefined);
367
+ listSnapshotsMock.mockResolvedValue([]);
368
+ const ctx = {
369
+ args: makeArgs({ command: "state", path: "log" }),
370
+ plugins: [], serializers: [],
371
+ };
372
+ expect(await runStateLog(ctx)).toBe(1);
373
+ expect(stderrBuf.join("\n")).toContain("No state snapshots");
374
+ });
375
+
376
+ test("prints commit / date / message rows for each entry", async () => {
377
+ fetchStateMock.mockResolvedValue(undefined);
378
+ listSnapshotsMock.mockResolvedValue([
379
+ { commit: "abcdef1234567890", date: "2026-05-01T00:00:00Z", message: "Snapshot prod" },
380
+ { commit: "fedcba9876543210", date: "2026-05-02T00:00:00Z", message: "Snapshot staging" },
381
+ ]);
382
+ const ctx = {
383
+ args: makeArgs({ command: "state", path: "log" }),
384
+ plugins: [], serializers: [],
385
+ };
386
+ expect(await runStateLog(ctx)).toBe(0);
387
+ const out = stdoutBuf.join("\n");
388
+ expect(out).toContain("abcdef1");
389
+ expect(out).toContain("Snapshot prod");
390
+ expect(out).toContain("Snapshot staging");
391
+ });
392
+ });
393
+
394
+ describe("runStateUnknown", () => {
395
+ test("returns 1 with subcommand list", async () => {
396
+ const stderrBuf: string[] = [];
397
+ vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
398
+ const ctx = {
399
+ args: makeArgs({ command: "state", path: "garbage" }),
400
+ plugins: [], serializers: [],
401
+ };
402
+ expect(await runStateUnknown(ctx)).toBe(1);
403
+ const stderr = stderrBuf.join("\n");
404
+ expect(stderr).toContain("snapshot");
405
+ expect(stderr).toContain("show");
406
+ expect(stderr).toContain("diff");
407
+ expect(stderr).toContain("log");
408
+ });
409
+ });
@@ -1,12 +1,16 @@
1
1
  import { resolve } from "node:path";
2
2
  import { build } from "../../build";
3
3
  import { takeSnapshot } from "../../state/snapshot";
4
- import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchState } from "../../state/git";
4
+ import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchState, StaleStateBranchError } from "../../state/git";
5
5
  import { computeBuildDigest, diffDigests } from "../../state/digest";
6
+ import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../state/live-diff";
6
7
  import { loadChantConfig } from "../../config";
7
8
  import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
8
9
  import type { CommandContext } from "../registry";
9
10
  import type { StateSnapshot } from "../../state/types";
11
+ import type { SerializerResult } from "../../serializer";
12
+ import type { LexiconPlugin, ResourceMetadata, ArtifactMetadata } from "../../lexicon";
13
+ import type { BuildResult } from "../../build";
10
14
 
11
15
  /**
12
16
  * chant state snapshot <environment> [lexicon]
@@ -45,16 +49,28 @@ export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
45
49
  return 1;
46
50
  }
47
51
 
48
- const pluginsWithDescribe = targetPlugins.filter((p) => p.describeResources);
49
- if (pluginsWithDescribe.length === 0) {
52
+ const observingPlugins = targetPlugins.filter((p) => p.describeResources || p.listArtifacts);
53
+ if (observingPlugins.length === 0) {
50
54
  console.error(formatError({
51
- message: "No plugins implement describeResources",
55
+ message: "No plugins implement describeResources or listArtifacts",
52
56
  hint: lexiconFilter ? `Lexicon "${lexiconFilter}" does not support state snapshots` : undefined,
53
57
  }));
54
58
  return 1;
55
59
  }
56
60
 
57
- const result = await takeSnapshot(environment, pluginsWithDescribe, buildResult);
61
+ let result;
62
+ try {
63
+ result = await takeSnapshot(environment, observingPlugins, buildResult);
64
+ } catch (err) {
65
+ if (err instanceof StaleStateBranchError) {
66
+ console.error(formatError({
67
+ message: `Another snapshot completed for chant/state after this run started (env: ${environment}).`,
68
+ hint: `Pull and retry: \`git fetch origin ${"chant/state"}:${"chant/state"}\` && \`chant state snapshot ${environment}\`.`,
69
+ }));
70
+ return 1;
71
+ }
72
+ throw err;
73
+ }
58
74
 
59
75
  for (const w of result.warnings) {
60
76
  console.error(formatWarning({ message: w }));
@@ -132,7 +148,7 @@ export async function runStateDiff(ctx: CommandContext): Promise<number> {
132
148
  ? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
133
149
  : serializers;
134
150
 
135
- // Build to get current digest
151
+ // Build to get current state
136
152
  const projectPath = resolve(".");
137
153
  const buildResult = await build(projectPath, targetSerializers);
138
154
  if (buildResult.errors.length > 0) {
@@ -140,8 +156,6 @@ export async function runStateDiff(ctx: CommandContext): Promise<number> {
140
156
  return 1;
141
157
  }
142
158
 
143
- const currentDigest = computeBuildDigest(buildResult);
144
-
145
159
  // Fetch and read previous snapshot
146
160
  await fetchState();
147
161
 
@@ -149,8 +163,24 @@ export async function runStateDiff(ctx: CommandContext): Promise<number> {
149
163
  ? [lexiconFilter]
150
164
  : Array.from(buildResult.manifest.lexicons);
151
165
 
152
- for (const lexicon of lexicons) {
153
- const content = await readSnapshot(environment, lexicon);
166
+ if (args.live) {
167
+ return runStateDiffLive({ environment, lexicons, plugins, buildResult });
168
+ }
169
+
170
+ return runStateDiffDigest({ environment, lexicons, buildResult });
171
+ }
172
+
173
+ interface DigestDiffArgs {
174
+ environment: string;
175
+ lexicons: string[];
176
+ buildResult: BuildResult;
177
+ }
178
+
179
+ async function runStateDiffDigest(args: DigestDiffArgs): Promise<number> {
180
+ const currentDigest = computeBuildDigest(args.buildResult);
181
+
182
+ for (const lexicon of args.lexicons) {
183
+ const content = await readSnapshot(args.environment, lexicon);
154
184
  let previousDigest = undefined;
155
185
  if (content) {
156
186
  const snapshot: StateSnapshot = JSON.parse(content);
@@ -180,6 +210,198 @@ export async function runStateDiff(ctx: CommandContext): Promise<number> {
180
210
  return 0;
181
211
  }
182
212
 
213
+ interface LiveDiffArgs {
214
+ environment: string;
215
+ lexicons: string[];
216
+ plugins: LexiconPlugin[];
217
+ buildResult: BuildResult;
218
+ }
219
+
220
+ async function runStateDiffLive(args: LiveDiffArgs): Promise<number> {
221
+ let totalDrift = 0;
222
+ let totalLexiconsChecked = 0;
223
+
224
+ for (const lexiconName of args.lexicons) {
225
+ const plugin = args.plugins.find((p) => p.name === lexiconName);
226
+ if (!plugin) continue;
227
+
228
+ if (!plugin.describeResources && !plugin.listArtifacts) {
229
+ console.error(formatWarning({
230
+ message: `${lexiconName}: lexicon does not implement describeResources or listArtifacts — skipping (use without --live for digest diff)`,
231
+ }));
232
+ continue;
233
+ }
234
+
235
+ // Build per-lexicon entity index
236
+ const declared = new Set<string>();
237
+ const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
238
+ for (const [name, entity] of args.buildResult.entities) {
239
+ if (entity.lexicon === lexiconName) {
240
+ declared.add(name);
241
+ entities.set(name, {
242
+ entityType: entity.entityType,
243
+ props: ("props" in entity && entity.props != null
244
+ ? entity.props
245
+ : {}) as Record<string, unknown>,
246
+ });
247
+ }
248
+ }
249
+
250
+ const rawOutput = args.buildResult.outputs.get(lexiconName);
251
+ const buildOutput =
252
+ rawOutput === undefined
253
+ ? ""
254
+ : typeof rawOutput === "string"
255
+ ? rawOutput
256
+ : (rawOutput as SerializerResult).primary;
257
+
258
+ // Read previous snapshot once; both flows pull what they need.
259
+ let prevSnapshot: StateSnapshot | undefined;
260
+ const content = await readSnapshot(args.environment, lexiconName);
261
+ if (content) prevSnapshot = JSON.parse(content);
262
+
263
+ let lexiconChecked = false;
264
+
265
+ // ── Resources path (entity-keyed) ──────────────────────────────────────
266
+ if (plugin.describeResources) {
267
+ let observedNow: Record<string, ResourceMetadata>;
268
+ try {
269
+ observedNow = await plugin.describeResources({
270
+ environment: args.environment,
271
+ buildOutput,
272
+ entityNames: Array.from(declared),
273
+ entities,
274
+ });
275
+ } catch (err) {
276
+ console.error(formatError({
277
+ message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
278
+ }));
279
+ continue;
280
+ }
281
+ const observedThen = prevSnapshot?.resources;
282
+ const diff = diffLive({ declared, observedNow, observedThen });
283
+ totalDrift += diff.driftedSinceSnapshot.length + diff.missing.length + diff.orphan.length + diff.disappeared.length;
284
+ renderLiveDiff(lexiconName, args.environment, diff);
285
+ lexiconChecked = true;
286
+ }
287
+
288
+ // ── Artifacts path (context-keyed) ─────────────────────────────────────
289
+ if (plugin.listArtifacts) {
290
+ let observedNow: Record<string, ArtifactMetadata>;
291
+ try {
292
+ observedNow = await plugin.listArtifacts({ environment: args.environment, entities });
293
+ } catch (err) {
294
+ console.error(formatError({
295
+ message: `${lexiconName}: listArtifacts failed — ${err instanceof Error ? err.message : String(err)}`,
296
+ }));
297
+ continue;
298
+ }
299
+ const observedThen = prevSnapshot?.artifacts;
300
+ const adiff = diffLiveArtifacts({ observedNow, observedThen });
301
+ totalDrift += adiff.added.length + adiff.removed.length + adiff.changed.length;
302
+ renderLiveArtifactDiff(lexiconName, args.environment, adiff);
303
+ lexiconChecked = true;
304
+ }
305
+
306
+ if (lexiconChecked) totalLexiconsChecked++;
307
+ }
308
+
309
+ if (totalLexiconsChecked === 0) {
310
+ console.error(formatWarning({
311
+ message: "No lexicons implement describeResources or listArtifacts — nothing to diff in --live mode",
312
+ }));
313
+ return 1;
314
+ }
315
+
316
+ if (totalDrift === 0) {
317
+ console.error(formatSuccess(`No drift detected across ${totalLexiconsChecked} lexicon(s)`));
318
+ }
319
+
320
+ return 0;
321
+ }
322
+
323
+ function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiffResult): void {
324
+ const counts =
325
+ `${diff.missing.length} missing, ${diff.orphan.length} orphan, ` +
326
+ `${diff.disappeared.length} disappeared, ${diff.newlyObserved.length} newly observed, ` +
327
+ `${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged`;
328
+
329
+ console.log(`\n${formatBold(lexiconName)} — environment: ${environment}`);
330
+ console.log(counts);
331
+ console.log("-".repeat(80));
332
+
333
+ if (diff.missing.length > 0) {
334
+ console.log(formatBold("\nMISSING (declared, not in cloud):"));
335
+ for (const name of diff.missing) console.log(` - ${name}`);
336
+ }
337
+ if (diff.orphan.length > 0) {
338
+ console.log(formatBold("\nORPHAN (in cloud, not declared):"));
339
+ for (const name of diff.orphan) console.log(` - ${name}`);
340
+ }
341
+ if (diff.disappeared.length > 0) {
342
+ console.log(formatBold("\nDISAPPEARED (in last snapshot, gone now):"));
343
+ for (const name of diff.disappeared) console.log(` - ${name}`);
344
+ }
345
+ if (diff.newlyObserved.length > 0) {
346
+ console.log(formatBold("\nNEWLY OBSERVED (declared, observed, no prior snapshot):"));
347
+ for (const name of diff.newlyObserved) console.log(` - ${name}`);
348
+ }
349
+ if (diff.driftedSinceSnapshot.length > 0) {
350
+ console.log(formatBold("\nDRIFTED (changed since last snapshot):"));
351
+ for (const drift of diff.driftedSinceSnapshot) {
352
+ console.log(` - ${drift.name} (${drift.type})`);
353
+ for (const change of drift.changes) {
354
+ const oldStr = formatValue(change.oldValue);
355
+ const newStr = formatValue(change.newValue);
356
+ console.log(` ${change.path}: ${oldStr} → ${newStr}`);
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ function formatValue(v: unknown): string {
363
+ if (v === undefined) return "<unset>";
364
+ if (typeof v === "string") return v.length > 60 ? v.slice(0, 57) + "..." : v;
365
+ const json = JSON.stringify(v);
366
+ return json.length > 60 ? json.slice(0, 57) + "..." : json;
367
+ }
368
+
369
+ function renderLiveArtifactDiff(lexiconName: string, environment: string, diff: LiveArtifactDiffResult): void {
370
+ // Skip emission entirely when there's nothing to show — keeps the output
371
+ // clean for lexicons that only implement describeResources.
372
+ if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0 && diff.unchanged.length === 0) {
373
+ return;
374
+ }
375
+
376
+ const counts =
377
+ `${diff.added.length} added, ${diff.removed.length} removed, ` +
378
+ `${diff.changed.length} changed, ${diff.unchanged.length} unchanged`;
379
+
380
+ console.log(`\n${formatBold(lexiconName)} (artifacts) — environment: ${environment}`);
381
+ console.log(counts);
382
+ console.log("-".repeat(80));
383
+
384
+ if (diff.added.length > 0) {
385
+ console.log(formatBold("\nARTIFACTS ADDED (in cloud now, not in last snapshot):"));
386
+ for (const name of diff.added) console.log(` - ${name}`);
387
+ }
388
+ if (diff.removed.length > 0) {
389
+ console.log(formatBold("\nARTIFACTS REMOVED (in last snapshot, gone now):"));
390
+ for (const name of diff.removed) console.log(` - ${name}`);
391
+ }
392
+ if (diff.changed.length > 0) {
393
+ console.log(formatBold("\nARTIFACTS CHANGED (metadata differs since last snapshot):"));
394
+ for (const drift of diff.changed) {
395
+ console.log(` - ${drift.name} (${drift.type})`);
396
+ for (const change of drift.changes) {
397
+ const oldStr = formatValue(change.oldValue);
398
+ const newStr = formatValue(change.newValue);
399
+ console.log(` ${change.path}: ${oldStr} → ${newStr}`);
400
+ }
401
+ }
402
+ }
403
+ }
404
+
183
405
  /**
184
406
  * chant state log [environment]
185
407
  */