@intentius/chant 0.50.0 → 0.51.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 (63) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/cli/handlers/op-progress.d.ts +57 -0
  3. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  4. package/dist/cli/handlers/run-client.d.ts +21 -1
  5. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  6. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/handlers/search.d.ts +22 -0
  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/mcp/op-tools.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +33 -1
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/components/run-progress.d.ts +7 -5
  16. package/dist/components/run-progress.d.ts.map +1 -1
  17. package/dist/lexicon.d.ts +41 -0
  18. package/dist/lexicon.d.ts.map +1 -1
  19. package/dist/lifecycle/assert-live.d.ts +77 -0
  20. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  21. package/dist/lifecycle/change-set.d.ts +17 -0
  22. package/dist/lifecycle/change-set.d.ts.map +1 -1
  23. package/dist/lifecycle/disruption.d.ts +96 -0
  24. package/dist/lifecycle/disruption.d.ts.map +1 -0
  25. package/dist/lifecycle/index.d.ts +2 -0
  26. package/dist/lifecycle/index.d.ts.map +1 -1
  27. package/dist/lifecycle/replay.d.ts +2 -0
  28. package/dist/lifecycle/replay.d.ts.map +1 -1
  29. package/dist/op/local-executor.d.ts +7 -1
  30. package/dist/op/local-executor.d.ts.map +1 -1
  31. package/dist/testing.d.ts +23 -2
  32. package/dist/testing.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli/handlers/lifecycle.test.ts +90 -0
  35. package/src/cli/handlers/lifecycle.ts +18 -1
  36. package/src/cli/handlers/op-progress.test.ts +202 -0
  37. package/src/cli/handlers/op-progress.ts +192 -0
  38. package/src/cli/handlers/run-client.test.ts +82 -0
  39. package/src/cli/handlers/run-client.ts +85 -2
  40. package/src/cli/handlers/run-report.test.ts +62 -0
  41. package/src/cli/handlers/run-report.ts +20 -58
  42. package/src/cli/handlers/run.test.ts +144 -0
  43. package/src/cli/handlers/run.ts +40 -18
  44. package/src/cli/handlers/search-drift.test.ts +263 -0
  45. package/src/cli/handlers/search.ts +150 -1
  46. package/src/cli/main.ts +9 -0
  47. package/src/cli/mcp/op-tools.ts +17 -6
  48. package/src/cli/mcp/resource-handlers.ts +13 -5
  49. package/src/cli/registry.ts +33 -1
  50. package/src/components/run-progress.ts +9 -5
  51. package/src/lexicon.ts +51 -0
  52. package/src/lifecycle/assert-live.test.ts +125 -0
  53. package/src/lifecycle/assert-live.ts +154 -0
  54. package/src/lifecycle/change-set.ts +35 -3
  55. package/src/lifecycle/disruption.test.ts +186 -0
  56. package/src/lifecycle/disruption.ts +224 -0
  57. package/src/lifecycle/index.ts +2 -0
  58. package/src/lifecycle/replay.test.ts +25 -0
  59. package/src/lifecycle/replay.ts +11 -3
  60. package/src/op/local-executor.ts +7 -1
  61. package/src/op/local-output.ts +1 -1
  62. package/src/testing.test.ts +89 -2
  63. package/src/testing.ts +63 -3
@@ -50,6 +50,9 @@ vi.mock("./run-client", () => ({
50
50
  connectionOptions: (profile: { address: string }) => ({ address: profile.address }),
51
51
  resolveProfile: (...args: unknown[]) => resolveProfileMock(...args),
52
52
  resolveWorkflowId: (name: string) => `chant-op-${name}`,
53
+ // Test fixtures already use the short event-type form fetchNormalizedHistory
54
+ // produces (see mock-temporal-client.ts) — a passthrough keeps them valid.
55
+ fetchNormalizedHistory: (handle: { fetchHistory(): Promise<unknown> }) => handle.fetchHistory(),
53
56
  }));
54
57
  vi.mock("node:fs", async () => {
55
58
  const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
@@ -297,6 +300,54 @@ describe("runOpStatus", () => {
297
300
  expect(out).toContain("COMPLETED");
298
301
  expect(out).toContain("1/2 completed");
299
302
  });
303
+
304
+ test("a pending gate prints a Gate line via the gateState query (#1676)", async () => {
305
+ setupTemporalClient(createMockTemporalClient({
306
+ describeByWorkflowId: {
307
+ "chant-op-alb-deploy": {
308
+ workflowId: "chant-op-alb-deploy", runId: "r1",
309
+ status: { name: "RUNNING" },
310
+ startTime: new Date("2026-05-01T00:00:00Z"),
311
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
312
+ },
313
+ },
314
+ historyByWorkflowId: { "chant-op-alb-deploy": [] },
315
+ queryResultByWorkflowId: {
316
+ "chant-op-alb-deploy:gateState": {
317
+ signalName: "gate-dns-delegation",
318
+ description: "Approve DNS delegation",
319
+ since: "2026-05-01T00:05:00.000Z",
320
+ },
321
+ },
322
+ }));
323
+ const stdout = makeStdoutSpy();
324
+ const exit = await runOpStatus({ args: makeArgs({ extraPositional: "alb-deploy" }), plugins: [], serializers: [] });
325
+ expect(exit).toBe(0);
326
+ const out = stdout.join("\n");
327
+ expect(out).toContain("Gate");
328
+ expect(out).toContain("gate-dns-delegation");
329
+ expect(out).toContain("Approve DNS delegation");
330
+ expect(out).toContain("2026-05-01T00:05:00.000Z");
331
+ });
332
+
333
+ test("an Op with no pending gate (query unregistered or answers null) prints no Gate line", async () => {
334
+ setupTemporalClient(createMockTemporalClient({
335
+ describeByWorkflowId: {
336
+ "chant-op-alb-deploy": {
337
+ workflowId: "chant-op-alb-deploy", runId: "r1",
338
+ status: { name: "COMPLETED" },
339
+ startTime: new Date("2026-05-01T00:00:00Z"),
340
+ closeTime: new Date("2026-05-01T01:00:00Z"),
341
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
342
+ },
343
+ },
344
+ historyByWorkflowId: { "chant-op-alb-deploy": [] },
345
+ }));
346
+ const stdout = makeStdoutSpy();
347
+ const exit = await runOpStatus({ args: makeArgs({ extraPositional: "alb-deploy" }), plugins: [], serializers: [] });
348
+ expect(exit).toBe(0);
349
+ expect(stdout.join("\n")).not.toContain("Gate");
350
+ });
300
351
  });
301
352
 
302
353
  // ── chant run status <name> --components (#599) ─────────────────────────────
@@ -731,6 +782,99 @@ describe("runOp", () => {
731
782
  }
732
783
  });
733
784
 
785
+ // ── --progress-json (#1676) ──────────────────────────────────────────────
786
+
787
+ test("--progress-json streams one NDJSON StepRecord per settled step, plus a final skipped record", async () => {
788
+ vi.useFakeTimers();
789
+ try {
790
+ discoverOpsMock.mockResolvedValue({
791
+ ops: new Map([[
792
+ "alb-deploy",
793
+ {
794
+ config: {
795
+ name: "alb-deploy", overview: "o",
796
+ phases: [
797
+ { name: "Build", steps: [{ kind: "activity", fn: "build" }] },
798
+ { name: "Deploy", steps: [{ kind: "activity", fn: "deploy" }] },
799
+ ],
800
+ },
801
+ },
802
+ ]]),
803
+ errors: [],
804
+ });
805
+ setupTemporalClient(createMockTemporalClient({
806
+ describeByWorkflowId: {
807
+ "chant-op-alb-deploy": {
808
+ workflowId: "chant-op-alb-deploy", runId: "r1",
809
+ status: { name: "COMPLETED" }, startTime: new Date(),
810
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
811
+ },
812
+ },
813
+ historyByWorkflowId: {
814
+ "chant-op-alb-deploy": [
815
+ { eventId: "1", eventType: "ActivityTaskScheduled", activityTaskScheduledEventAttributes: { activityId: "1", activityType: { name: "build" } } },
816
+ { eventType: "ActivityTaskCompleted", activityTaskCompletedEventAttributes: { scheduledEventId: "1" } },
817
+ ],
818
+ },
819
+ }));
820
+ existsSyncMock.mockReturnValue(true);
821
+ const { proc } = makeFakeChildProcess();
822
+ spawnChildMock.mockReturnValue(proc);
823
+ generateReportMock.mockReturnValue("# Report");
824
+ writeReportMock.mockReturnValue("/tmp/report.md");
825
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
826
+ const stdoutLines: string[] = [];
827
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { stdoutLines.push(String(chunk)); return true; });
828
+
829
+ const promise = runOp({ args: makeArgs({ path: "alb-deploy", progressJson: true }), plugins: [], serializers: [] });
830
+ await vi.advanceTimersByTimeAsync(5000);
831
+ const exit = await promise;
832
+
833
+ expect(exit).toBe(0);
834
+ const records = stdoutLines.filter((l) => l.trim()).map((l) => JSON.parse(l));
835
+ expect(records).toEqual([
836
+ { phase: "Build", fn: "build", status: "ok", durationMs: 0 },
837
+ { phase: "Deploy", fn: "deploy", status: "skipped", durationMs: 0 },
838
+ ]);
839
+ stdoutSpy.mockRestore();
840
+ } finally {
841
+ vi.useRealTimers();
842
+ }
843
+ }, 10_000);
844
+
845
+ test("without --progress-json, nothing is written to stdout during the poll loop", async () => {
846
+ vi.useFakeTimers();
847
+ try {
848
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy")]), errors: [] });
849
+ setupTemporalClient(createMockTemporalClient({
850
+ describeByWorkflowId: {
851
+ "chant-op-alb-deploy": {
852
+ workflowId: "chant-op-alb-deploy", runId: "r1",
853
+ status: { name: "COMPLETED" }, startTime: new Date(),
854
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
855
+ },
856
+ },
857
+ historyByWorkflowId: { "chant-op-alb-deploy": [] },
858
+ }));
859
+ existsSyncMock.mockReturnValue(true);
860
+ const { proc } = makeFakeChildProcess();
861
+ spawnChildMock.mockReturnValue(proc);
862
+ generateReportMock.mockReturnValue("# Report");
863
+ writeReportMock.mockReturnValue("/tmp/report.md");
864
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
865
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
866
+
867
+ const promise = runOp({ args: makeArgs({ path: "alb-deploy" }), plugins: [], serializers: [] });
868
+ await vi.advanceTimersByTimeAsync(5000);
869
+ await promise;
870
+
871
+ expect(stdoutSpy).not.toHaveBeenCalled();
872
+ stdoutSpy.mockRestore();
873
+ } finally {
874
+ vi.useRealTimers();
875
+ }
876
+ });
877
+
734
878
  test("workflow ends in FAILED → exit 1, worker still killed", async () => {
735
879
  vi.useFakeTimers();
736
880
  try {
@@ -5,7 +5,7 @@ import { spawn as spawnChild, type ChildProcess } from "node:child_process";
5
5
  import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
6
6
  import { discoverOps } from "../../op/discover";
7
7
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
8
- import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure } from "../../op/local-executor";
8
+ import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure, type StepRecord } from "../../op/local-executor";
9
9
  import { renderHuman, renderJson } from "../../op/local-output";
10
10
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
11
11
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
@@ -15,11 +15,13 @@ import {
15
15
  connectionOptions,
16
16
  resolveWorkflowId,
17
17
  resolveProfile,
18
+ fetchNormalizedHistory,
18
19
  type WorkflowHandleRaw,
19
20
  type WorkflowExecutionDescription,
20
21
  type WorkflowHistoryRaw,
21
22
  } from "./run-client";
22
23
  import { generateReport, writeReport } from "./run-report";
24
+ import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
23
25
  import { runComponents, resolveComponentTargets, findComponentGate, listComponents } from "../../components/cli-support";
24
26
  import { renderDriverHuman, renderDriverJson } from "../../components/driver-output";
25
27
  import { ndjsonProgressSink } from "../../components/run-progress";
@@ -250,12 +252,12 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
250
252
  if (ctx.args.components) return runComponentStatus(ctx, name);
251
253
 
252
254
  const projectPath = resolve(".");
253
- let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw;
255
+ let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw, handle: WorkflowHandleRaw;
254
256
  try {
255
257
  ({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
256
- const handle = client.workflow.getHandle(resolveWorkflowId(name));
258
+ handle = client.workflow.getHandle(resolveWorkflowId(name));
257
259
  desc = await handle.describe();
258
- history = await handle.fetchHistory();
260
+ history = await fetchNormalizedHistory(handle);
259
261
  } catch (err) {
260
262
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
261
263
  return 1;
@@ -269,13 +271,19 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
269
271
  console.log(` Started : ${desc.startTime.toISOString()}`);
270
272
  if (desc.closeTime) console.log(` Closed : ${desc.closeTime.toISOString()}`);
271
273
 
272
- const events = history.events ?? [];
273
- const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
274
- const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
274
+ const { completed, scheduled } = countActivities(history);
275
275
  if (scheduled > 0) {
276
276
  console.log(` Activities : ${completed}/${scheduled} completed`);
277
277
  }
278
278
 
279
+ // Queryable gate state (#1676) — undefined means the query itself failed
280
+ // (an ungated Op never registers a "gateState" handler), so print nothing;
281
+ // null means the query answered "no gate pending" — also nothing to print.
282
+ const gate = await queryGateState(handle);
283
+ if (gate) {
284
+ console.log(` Gate : ${gate.signalName}${gate.description ? ` — ${gate.description}` : ""} (waiting since ${gate.since})`);
285
+ }
286
+
279
287
  return 0;
280
288
  }
281
289
 
@@ -293,7 +301,7 @@ async function runComponentStatus(ctx: CommandContext, name: string): Promise<nu
293
301
  ({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
294
302
  const handle = client.workflow.getHandle(componentWorkflowId(name));
295
303
  desc = await handle.describe();
296
- history = await handle.fetchHistory();
304
+ history = await fetchNormalizedHistory(handle);
297
305
  } catch (err) {
298
306
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
299
307
  return 1;
@@ -307,9 +315,7 @@ async function runComponentStatus(ctx: CommandContext, name: string): Promise<nu
307
315
  console.log(` Started : ${desc.startTime.toISOString()}`);
308
316
  if (desc.closeTime) console.log(` Closed : ${desc.closeTime.toISOString()}`);
309
317
 
310
- const events = history.events ?? [];
311
- const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
312
- const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
318
+ const { completed, scheduled } = countActivities(history);
313
319
  if (scheduled > 0) {
314
320
  console.log(` Activities : ${completed}/${scheduled} completed`);
315
321
  }
@@ -528,9 +534,7 @@ async function waitForTemporalServer(address: string, maxWaitMs = 30_000): Promi
528
534
  }
529
535
 
530
536
  function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
531
- const events = history.events ?? [];
532
- const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
533
- const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
537
+ const { completed, scheduled } = countActivities(history);
534
538
  process.stderr.write(
535
539
  `\r${formatInfo(`[${opName}]`)} ${completed}/${scheduled} activities completed`,
536
540
  );
@@ -954,7 +958,7 @@ async function runComponentTemporal(
954
958
  while (true) {
955
959
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
956
960
  const desc = await handle.describe();
957
- const history = await handle.fetchHistory();
961
+ const history = await fetchNormalizedHistory(handle);
958
962
  renderProgress(component.name, history);
959
963
  if (TERMINAL_STATUSES.has(desc.status.name)) {
960
964
  process.stderr.write("\n");
@@ -1160,7 +1164,7 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1160
1164
  ({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
1161
1165
  const handle = client.workflow.getHandle(resolveWorkflowId(opName));
1162
1166
  desc = await handle.describe();
1163
- history = await handle.fetchHistory();
1167
+ history = await fetchNormalizedHistory(handle);
1164
1168
  } catch (err) {
1165
1169
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
1166
1170
  return 1;
@@ -1248,7 +1252,23 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1248
1252
  return 1;
1249
1253
  }
1250
1254
 
1251
- // Poll for progress until terminal state
1255
+ // Poll for progress until terminal state.
1256
+ //
1257
+ // `--progress-json` (chant #1676): stream one NDJSON StepRecord per line to
1258
+ // stdout as each declared step settles — the durable-path counterpart to
1259
+ // `chant run <name> --json`'s local-executor record shape (op-progress.ts).
1260
+ // Purely additive: when the flag is absent `emitProgress` stays undefined
1261
+ // and every `emitProgress?.(...)` call is a no-op, so poll-loop behavior
1262
+ // is unchanged. `extractStepRecords` is stable and append-only across
1263
+ // polls (same declared order, a growing history), so a plain emitted-count
1264
+ // watermark is enough to emit only what's new each round.
1265
+ const emitProgress = ctx.args.progressJson ? ndjsonProgressSink<StepRecord>() : undefined;
1266
+ let emittedCount = 0;
1267
+ const emitNewRecords = (records: StepRecord[]) => {
1268
+ for (const record of records.slice(emittedCount)) emitProgress?.(record);
1269
+ emittedCount = records.length;
1270
+ };
1271
+
1252
1272
  let finalDesc: WorkflowExecutionDescription | undefined;
1253
1273
  let finalHistory: WorkflowHistoryRaw | undefined;
1254
1274
 
@@ -1256,12 +1276,14 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1256
1276
  while (true) {
1257
1277
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1258
1278
  const desc = await handle.describe();
1259
- const history = await handle.fetchHistory();
1279
+ const history = await fetchNormalizedHistory(handle);
1260
1280
 
1261
1281
  renderProgress(opName, history);
1282
+ if (emitProgress) emitNewRecords(extractStepRecords(config, history));
1262
1283
 
1263
1284
  if (TERMINAL_STATUSES.has(desc.status.name)) {
1264
1285
  process.stderr.write("\n");
1286
+ if (emitProgress) emitNewRecords(extractStepRecords(config, history, { final: true }));
1265
1287
  finalDesc = desc;
1266
1288
  finalHistory = history;
1267
1289
  break;
@@ -0,0 +1,263 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import type { ParsedArgs } from "../registry";
3
+ import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
4
+ import { createMockPlugin } from "../../../../test-utils/src/mock-plugin";
5
+
6
+ // #1268 — query-scoped drift: `chant search --at latest --check-live` (and its
7
+ // reverse, `--live --check-snapshot`) compares the matched rows against the
8
+ // observation the primary answer did NOT use, reusing `diffLive` — the same
9
+ // engine `lifecycle diff --live` uses — scoped to just those rows.
10
+
11
+ const discoverMock = vi.fn();
12
+ vi.mock("../../discovery/index", () => ({
13
+ discover: (...a: unknown[]) => discoverMock(...a),
14
+ }));
15
+ const loadPluginsMock = vi.fn();
16
+ const resolveLexMock = vi.fn();
17
+ vi.mock("../plugins", async () => {
18
+ const actual = await vi.importActual<typeof import("../plugins")>("../plugins");
19
+ return {
20
+ ...actual,
21
+ loadPlugins: (...a: unknown[]) => loadPluginsMock(...a),
22
+ resolveProjectLexicons: (...a: unknown[]) => resolveLexMock(...a),
23
+ };
24
+ });
25
+ const replaySnapshotsMock = vi.fn();
26
+ const hasSnapshotMock = vi.fn((..._a: unknown[]) => Promise.resolve(false));
27
+ vi.mock("../../lifecycle/replay", () => ({
28
+ replaySnapshots: (...a: unknown[]) => replaySnapshotsMock(...a),
29
+ hasSnapshot: (...a: unknown[]) => hasSnapshotMock(...a),
30
+ }));
31
+ const loadChantConfigMock = vi.fn();
32
+ vi.mock("../../config", async () => {
33
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
34
+ return { ...actual, loadChantConfig: (...a: unknown[]) => loadChantConfigMock(...a) };
35
+ });
36
+ const buildResultMock = vi.fn();
37
+ vi.mock("../../build", async () => {
38
+ const actual = await vi.importActual<typeof import("../../build")>("../../build");
39
+ return {
40
+ ...actual,
41
+ build: (...a: unknown[]) => Promise.resolve(buildResultMock(...a)),
42
+ buildProject: (...a: unknown[]) => Promise.resolve(buildResultMock(...a)),
43
+ };
44
+ });
45
+
46
+ const { runSearch } = await import("./search");
47
+
48
+ function decl<T extends object>(base: T): Declarable & T {
49
+ return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
50
+ }
51
+
52
+ const entities = new Map<string, Declarable>([
53
+ ["webServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
54
+ ["launchTemplateServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
55
+ ["dbServer", decl({ lexicon: "aws", entityType: "AWS::RDS::Instance", props: {} })],
56
+ ]);
57
+
58
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
59
+ return {
60
+ command: "search", path: "kind:Instance",
61
+ format: "", fix: false, watch: false, verbose: false, help: false, live: false, env: "dev",
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ describe("search query-scoped drift (#1268)", () => {
67
+ let out: string[];
68
+ let err: string[];
69
+
70
+ beforeEach(() => {
71
+ out = [];
72
+ err = [];
73
+ vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
74
+ vi.spyOn(console, "error").mockImplementation((s: string) => { err.push(s); });
75
+ discoverMock.mockReset();
76
+ discoverMock.mockResolvedValue({ entities, errors: [], sourceFiles: [] });
77
+ buildResultMock.mockReset();
78
+ buildResultMock.mockReturnValue({ errors: [], entities, outputs: new Map([["aws", ""]]), warnings: [] });
79
+ loadChantConfigMock.mockReset();
80
+ loadChantConfigMock.mockResolvedValue({ config: {} });
81
+ resolveLexMock.mockReset();
82
+ resolveLexMock.mockResolvedValue(["aws"]);
83
+ hasSnapshotMock.mockReset();
84
+ hasSnapshotMock.mockResolvedValue(false);
85
+ replaySnapshotsMock.mockReset();
86
+ });
87
+
88
+ test("--check-live without --at is refused", async () => {
89
+ const exit = await runSearch({ args: makeArgs({ checkLive: true }), plugins: [], serializers: [] });
90
+ expect(exit).toBe(1);
91
+ expect(err.join("\n")).toContain("--check-live needs --at");
92
+ });
93
+
94
+ test("--check-snapshot without --live is refused", async () => {
95
+ const exit = await runSearch({ args: makeArgs({ checkSnapshot: true }), plugins: [], serializers: [] });
96
+ expect(exit).toBe(1);
97
+ expect(err.join("\n")).toContain("--check-snapshot needs --live");
98
+ });
99
+
100
+ test("--fail-on-drift without a check flag is refused", async () => {
101
+ const exit = await runSearch({ args: makeArgs({ failOnDrift: true }), plugins: [], serializers: [] });
102
+ expect(exit).toBe(1);
103
+ expect(err.join("\n")).toContain("--fail-on-drift needs --check-live or --check-snapshot");
104
+ });
105
+
106
+ test("--at --check-live: drift only for matched rows, using diffLive's categories", async () => {
107
+ replaySnapshotsMock.mockResolvedValue({
108
+ observations: [{
109
+ lexicon: "aws",
110
+ resources: {
111
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK", attributes: { SubnetId: "subnet-old" } },
112
+ launchTemplateServer: { type: "AWS::EC2::Instance", physicalId: "i-2", status: "OK", attributes: {} },
113
+ },
114
+ }],
115
+ commit: "a1b2c3d4e5f6", timestamp: "2026-08-01T03:15:00.000Z", depth: "identity",
116
+ });
117
+ loadPluginsMock.mockResolvedValue([
118
+ createMockPlugin({
119
+ name: "aws",
120
+ describeResources: async () => ({
121
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK", attributes: { SubnetId: "subnet-new" } },
122
+ launchTemplateServer: { type: "AWS::EC2::Instance", physicalId: "i-2", status: "OK", attributes: {} },
123
+ dbServer: { type: "AWS::RDS::Instance", physicalId: "db-1", status: "OK" },
124
+ }),
125
+ }),
126
+ ]);
127
+ const exit = await runSearch({
128
+ args: makeArgs({ path: "kind:EC2::Instance", at: "latest", checkLive: true }),
129
+ plugins: [], serializers: [],
130
+ });
131
+ expect(exit).toBe(0);
132
+ const stdout = out.join("\n");
133
+ expect(stdout).toContain("webServer attributes.SubnetId: subnet-old → subnet-new — drifted");
134
+ expect(stdout).toContain("checked against a live read · 1 of 2 matched drifted");
135
+ // dbServer isn't matched by kind:EC2::Instance and must never enter the diff.
136
+ expect(stdout).not.toContain("dbServer");
137
+ });
138
+
139
+ test("--fail-on-drift exits non-zero when the scoped check finds drift", async () => {
140
+ replaySnapshotsMock.mockResolvedValue({
141
+ observations: [{ lexicon: "aws", resources: { webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" } } }],
142
+ commit: "a1b2c3d", timestamp: "t", depth: "identity",
143
+ });
144
+ loadPluginsMock.mockResolvedValue([
145
+ createMockPlugin({
146
+ name: "aws",
147
+ describeResources: async () => ({
148
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "UPDATING" },
149
+ }),
150
+ }),
151
+ ]);
152
+ const exit = await runSearch({
153
+ args: makeArgs({ path: "kind:EC2::Instance", at: "latest", checkLive: true, failOnDrift: true }),
154
+ plugins: [], serializers: [],
155
+ });
156
+ expect(exit).toBe(1);
157
+ });
158
+
159
+ test("no drift exits 0 even with --fail-on-drift", async () => {
160
+ replaySnapshotsMock.mockResolvedValue({
161
+ observations: [{ lexicon: "aws", resources: { webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" } } }],
162
+ commit: "a1b2c3d", timestamp: "t", depth: "identity",
163
+ });
164
+ loadPluginsMock.mockResolvedValue([
165
+ createMockPlugin({
166
+ name: "aws",
167
+ describeResources: async () => ({
168
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" },
169
+ }),
170
+ }),
171
+ ]);
172
+ const exit = await runSearch({
173
+ args: makeArgs({ path: "webServer", at: "latest", checkLive: true, failOnDrift: true }),
174
+ plugins: [], serializers: [],
175
+ });
176
+ expect(exit).toBe(0);
177
+ expect(out.join("\n")).toContain("no drift across 1 matched");
178
+ });
179
+
180
+ test("--live --check-snapshot: a resource newly observed since the snapshot", async () => {
181
+ loadPluginsMock.mockResolvedValue([
182
+ createMockPlugin({
183
+ name: "aws",
184
+ describeResources: async () => ({
185
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" },
186
+ }),
187
+ }),
188
+ ]);
189
+ replaySnapshotsMock.mockResolvedValue({
190
+ observations: [{ lexicon: "aws", resources: {} }],
191
+ commit: "a1b2c3d", timestamp: "t", depth: "identity",
192
+ });
193
+ const exit = await runSearch({
194
+ args: makeArgs({ path: "webServer", live: true, checkSnapshot: true }),
195
+ plugins: [], serializers: [],
196
+ });
197
+ expect(exit).toBe(0);
198
+ expect(out.join("\n")).toContain("webServer — newly observed since the recorded snapshot");
199
+ });
200
+
201
+ test("--check-snapshot with nothing recorded is a note, not a failure", async () => {
202
+ loadPluginsMock.mockResolvedValue([
203
+ createMockPlugin({
204
+ name: "aws",
205
+ describeResources: async () => ({
206
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" },
207
+ }),
208
+ }),
209
+ ]);
210
+ replaySnapshotsMock.mockResolvedValue({ error: `No snapshots found for environment "dev"` });
211
+ const exit = await runSearch({
212
+ args: makeArgs({ path: "kind:EC2::Instance", live: true, checkSnapshot: true }),
213
+ plugins: [], serializers: [],
214
+ });
215
+ expect(exit).toBe(0);
216
+ expect(err.join("\n")).toContain("--check-snapshot: No snapshots found");
217
+ expect(out.join("\n")).not.toContain("checked against");
218
+ });
219
+
220
+ test("a live read that fails during --check-live reports unobserved, never missing (#1089)", async () => {
221
+ replaySnapshotsMock.mockResolvedValue({
222
+ observations: [{ lexicon: "aws", resources: { webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" } } }],
223
+ commit: "a1b2c3d", timestamp: "t", depth: "identity",
224
+ });
225
+ loadPluginsMock.mockResolvedValue([
226
+ createMockPlugin({
227
+ name: "aws",
228
+ describeResources: async () => { throw new Error("ECONNREFUSED"); },
229
+ }),
230
+ ]);
231
+ const exit = await runSearch({
232
+ args: makeArgs({ path: "kind:EC2::Instance", at: "latest", checkLive: true }),
233
+ plugins: [], serializers: [],
234
+ });
235
+ // The check-live read itself failed, which is the existing --live failure
236
+ // path (#1263) — a real problem distinct from any drift verdict.
237
+ expect(exit).toBe(1);
238
+ const stdout = out.join("\n");
239
+ expect(stdout).toContain("? webServer");
240
+ expect(stdout).not.toContain("missing");
241
+ });
242
+
243
+ test("depth note: a deep-recorded snapshot compared here at identity says so", async () => {
244
+ replaySnapshotsMock.mockResolvedValue({
245
+ observations: [{ lexicon: "aws", resources: { webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" } } }],
246
+ commit: "a1b2c3d", timestamp: "t", depth: "deep",
247
+ });
248
+ loadPluginsMock.mockResolvedValue([
249
+ createMockPlugin({
250
+ name: "aws",
251
+ describeResources: async () => ({
252
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" },
253
+ }),
254
+ }),
255
+ ]);
256
+ const exit = await runSearch({
257
+ args: makeArgs({ path: "webServer", at: "latest", checkLive: true }),
258
+ plugins: [], serializers: [],
259
+ });
260
+ expect(exit).toBe(0);
261
+ expect(out.join("\n")).toContain("snapshot recorded at deep depth, compared here at identity");
262
+ });
263
+ });