@intentius/chant 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/cli/build-options.d.ts +68 -0
  2. package/dist/cli/build-options.d.ts.map +1 -0
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/op-progress.d.ts +57 -0
  6. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  7. package/dist/cli/handlers/run-client.d.ts +21 -1
  8. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  9. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  10. package/dist/cli/handlers/run.d.ts +0 -21
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/handlers/search.d.ts +22 -0
  13. package/dist/cli/handlers/search.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts.map +1 -1
  15. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  16. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  17. package/dist/cli/registry.d.ts +33 -1
  18. package/dist/cli/registry.d.ts.map +1 -1
  19. package/dist/components/run-progress.d.ts +7 -5
  20. package/dist/components/run-progress.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +41 -0
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/assert-live.d.ts +77 -0
  24. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  25. package/dist/lifecycle/change-set.d.ts +40 -0
  26. package/dist/lifecycle/change-set.d.ts.map +1 -1
  27. package/dist/lifecycle/disruption.d.ts +96 -0
  28. package/dist/lifecycle/disruption.d.ts.map +1 -0
  29. package/dist/lifecycle/index.d.ts +2 -0
  30. package/dist/lifecycle/index.d.ts.map +1 -1
  31. package/dist/lifecycle/replay.d.ts +2 -0
  32. package/dist/lifecycle/replay.d.ts.map +1 -1
  33. package/dist/lint/policy.d.ts +16 -0
  34. package/dist/lint/policy.d.ts.map +1 -1
  35. package/dist/op/local-executor.d.ts +22 -2
  36. package/dist/op/local-executor.d.ts.map +1 -1
  37. package/dist/testing.d.ts +23 -2
  38. package/dist/testing.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/build-options.test.ts +101 -0
  41. package/src/cli/build-options.ts +109 -0
  42. package/src/cli/commands/build.ts +24 -53
  43. package/src/cli/handlers/lifecycle.test.ts +109 -0
  44. package/src/cli/handlers/lifecycle.ts +32 -8
  45. package/src/cli/handlers/op-progress.test.ts +202 -0
  46. package/src/cli/handlers/op-progress.ts +192 -0
  47. package/src/cli/handlers/run-client.test.ts +82 -0
  48. package/src/cli/handlers/run-client.ts +85 -2
  49. package/src/cli/handlers/run-report.test.ts +62 -0
  50. package/src/cli/handlers/run-report.ts +20 -58
  51. package/src/cli/handlers/run.test.ts +240 -0
  52. package/src/cli/handlers/run.ts +76 -18
  53. package/src/cli/handlers/search-drift.test.ts +263 -0
  54. package/src/cli/handlers/search.ts +150 -1
  55. package/src/cli/main.ts +11 -0
  56. package/src/cli/mcp/op-tools.ts +17 -6
  57. package/src/cli/mcp/resource-handlers.ts +13 -5
  58. package/src/cli/registry.ts +33 -1
  59. package/src/components/run-progress.ts +9 -5
  60. package/src/lexicon.ts +51 -0
  61. package/src/lifecycle/assert-live.test.ts +125 -0
  62. package/src/lifecycle/assert-live.ts +154 -0
  63. package/src/lifecycle/change-set.test.ts +144 -1
  64. package/src/lifecycle/change-set.ts +165 -11
  65. package/src/lifecycle/disruption.test.ts +186 -0
  66. package/src/lifecycle/disruption.ts +224 -0
  67. package/src/lifecycle/index.ts +2 -0
  68. package/src/lifecycle/replay.test.ts +25 -0
  69. package/src/lifecycle/replay.ts +11 -3
  70. package/src/lint/policy-build-parity.test.ts +232 -0
  71. package/src/lint/policy.ts +51 -6
  72. package/src/op/local-executor.ts +35 -1
  73. package/src/op/local-output.ts +1 -1
  74. package/src/testing.test.ts +89 -2
  75. package/src/testing.ts +63 -3
@@ -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
+ });
@@ -8,7 +8,11 @@ import { discover } from "../../discovery/index";
8
8
 
9
9
  import { observeResources } from "../../lifecycle/observe";
10
10
  import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
11
+ import { diffLive, type LiveDiffResult } from "../../lifecycle/live-diff";
11
12
  import type { LiveObservation } from "../../graph-ir";
13
+ import type { ResourceMetadata } from "../../lexicon";
14
+ import type { ObservationDepth } from "../../lifecycle/types";
15
+ import { formatUnobserved } from "../../observation";
12
16
  import { loadChantConfig, matchesDeclaredEnvironment } from "../../config";
13
17
  import { loadPlugins, resolveProjectLexicons } from "../plugins";
14
18
  import { formatError, formatWarning } from "../format";
@@ -56,6 +60,31 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
56
60
  }
57
61
  const show = parseShow(args);
58
62
 
63
+ // Query-scoped drift (#1268): --check-live checks a snapshot answer against
64
+ // a live read, --check-snapshot checks a live answer against the recorded
65
+ // snapshot — the direction each needs is the source it does NOT already have.
66
+ if (args.checkLive && !args.at) {
67
+ console.error(formatError({
68
+ message: "chant search --check-live needs --at <ref>",
69
+ hint: "answer from a snapshot and check the matched rows against a live read: --at latest --check-live --env <name>",
70
+ }));
71
+ return 1;
72
+ }
73
+ if (args.checkSnapshot && !args.live) {
74
+ console.error(formatError({
75
+ message: "chant search --check-snapshot needs --live",
76
+ hint: "answer live and check the matched rows against the recorded snapshot: --live --check-snapshot --env <name>",
77
+ }));
78
+ return 1;
79
+ }
80
+ if (args.failOnDrift && !args.checkLive && !args.checkSnapshot) {
81
+ console.error(formatError({
82
+ message: "chant search --fail-on-drift needs --check-live or --check-snapshot",
83
+ hint: "it fails the check those flags run, so it is meaningless without one",
84
+ }));
85
+ return 1;
86
+ }
87
+
59
88
  const projectPath = resolve(".");
60
89
  const { config } = await loadChantConfig(projectPath);
61
90
 
@@ -73,6 +102,14 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
73
102
  let ambientKinds: string[] = [];
74
103
  // Set only on a replay: whether the recording itself holds ambient resources.
75
104
  let replayAmbient: { recordedAmbient: boolean } | undefined;
105
+ // Query-scoped drift (#1268): the OTHER observation, read only when
106
+ // --check-live/--check-snapshot asked for one — the declared canvas to
107
+ // classify against, and whether the exit code should reflect what it found.
108
+ let checkObservations: LiveObservation[] | undefined;
109
+ let primaryObservations: LiveObservation[] | undefined;
110
+ let declaredForDrift: GraphIR | undefined;
111
+ let snapshotDepth: ObservationDepth | undefined;
112
+ let driftFailure = false;
76
113
  if (args.live || args.at) {
77
114
  const environment = args.env;
78
115
  if (!environment) {
@@ -126,6 +163,21 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
126
163
  ),
127
164
  };
128
165
  source = { kind: "snapshot", commit: replay.commit, timestamp: replay.timestamp };
166
+ snapshotDepth = replay.depth;
167
+ if (args.checkLive) {
168
+ // A fresh live read, additional to the snapshot the answer itself came
169
+ // from — this is what the matched rows get checked against.
170
+ const liveCheck = await observeResources(environment, observing, buildResult, {
171
+ owned: true,
172
+ stacks,
173
+ ambient: args.ambient === true,
174
+ });
175
+ for (const e of liveCheck.errors) {
176
+ liveFailures.push(e);
177
+ console.error(formatError({ message: `live read failed — ${e}` }));
178
+ }
179
+ checkObservations = liveCheck.observations;
180
+ }
129
181
  } else {
130
182
  const observed = await observeResources(environment, observing, buildResult, {
131
183
  owned: true,
@@ -142,7 +194,22 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
142
194
  observations = observed.observations;
143
195
  liveNotes = observed.notes ?? [];
144
196
  source = { kind: "live" };
197
+ if (args.checkSnapshot) {
198
+ // The reverse direction: answer live, check the matched rows against
199
+ // the most recently recorded snapshot. Missing entirely is not a
200
+ // failure of THIS command — the live answer already stands — so it is
201
+ // a note, not an error.
202
+ const scoped = new Set(stacks.filter((st) => st.src).map((st) => st.name));
203
+ const checkReplay = await replaySnapshots(environment, "latest", scoped);
204
+ if ("error" in checkReplay) {
205
+ console.error(formatWarning({ message: `--check-snapshot: ${checkReplay.error}` }));
206
+ } else {
207
+ checkObservations = checkReplay.observations;
208
+ snapshotDepth = checkReplay.depth;
209
+ }
210
+ }
145
211
  }
212
+ primaryObservations = observations;
146
213
  let live = buildLiveGraphIr(observations);
147
214
  // Containment edges, kept aside until after the overlay (see below).
148
215
  let containmentEdges: IREdge[] = [];
@@ -206,6 +273,7 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
206
273
  stacks.length > 0
207
274
  ? await buildDeclaredPerStack(stacks, projectPath)
208
275
  : buildGraphIr((await discover(resolve(args.src ?? config.sourceDir ?? "."))).entities, projectPath);
276
+ declaredForDrift = declared;
209
277
  // Carry the NOT-OBSERVED half of the tri-state (#1089) onto the rows, so a
210
278
  // declared entity nobody could read is painted `_unobserved` and a row can
211
279
  // say so instead of printing blank where a physical id would go (#1263).
@@ -279,6 +347,29 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
279
347
  // Qualifies the provenance line, so it sits with it: one line per distinct
280
348
  // note for the whole run, not one per stack, and after the rows (#1265).
281
349
  for (const n of liveNotes) console.error(formatWarning({ message: n }));
350
+ // Query-scoped drift (#1268): the matched rows, and only the matched rows,
351
+ // checked against the observation the primary answer did NOT use. Scoping
352
+ // both sides of diffLive() to the matched ids is what keeps a query-scoped-
353
+ // out resource from reading as `missing` or `unobserved` — it was never
354
+ // asked about, which is a third thing from both.
355
+ if (checkObservations && declaredForDrift && primaryObservations) {
356
+ const declaredIds = new Set(declaredForDrift.nodes.map((n) => n.id));
357
+ const matchedIds = new Set(matches.map((n) => n.id));
358
+ const nowObservations = args.checkLive ? checkObservations : primaryObservations;
359
+ const thenObservations = args.checkLive ? primaryObservations : checkObservations;
360
+ const driftCount = renderDrift(
361
+ diffLive({
362
+ declared: new Set([...matchedIds].filter((id) => declaredIds.has(id))),
363
+ observedNow: pickResources(mergeResources(nowObservations), matchedIds),
364
+ observedThen: pickResources(mergeResources(thenObservations), matchedIds),
365
+ unobserved: pickResources(collectUnobserved(nowObservations), matchedIds),
366
+ }),
367
+ matches.length,
368
+ args.checkLive ? "live" : "snapshot",
369
+ snapshotDepth,
370
+ );
371
+ if (args.failOnDrift && driftCount > 0) driftFailure = true;
372
+ }
282
373
  ambientHint(matches, ambientKinds, args.ambient === true, replayAmbient);
283
374
  showMiss(matches, show);
284
375
  regionSpread(terms, matches, show);
@@ -297,6 +388,7 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
297
388
  }));
298
389
  return 1;
299
390
  }
391
+ if (driftFailure) return 1;
300
392
  return 0;
301
393
  }
302
394
 
@@ -463,6 +555,63 @@ function provenance(matches: IRNode[], source: AnswerSource, recorded?: string,
463
555
  console.log(`— observed from snapshot${at}${taken} · bound ${bound}/${matches.length}`);
464
556
  }
465
557
 
558
+ /** Union `LiveObservation.resources` across lexicons into one name-keyed map. */
559
+ function mergeResources(observations: LiveObservation[]): Record<string, ResourceMetadata> {
560
+ const out: Record<string, ResourceMetadata> = {};
561
+ for (const o of observations) Object.assign(out, o.resources);
562
+ return out;
563
+ }
564
+
565
+ /** Restrict a name-keyed map to the matched ids — the whole of "query-scoped". */
566
+ function pickResources<T>(map: Record<string, T>, ids: Set<string>): Record<string, T> {
567
+ const out: Record<string, T> = {};
568
+ for (const id of ids) if (id in map) out[id] = map[id];
569
+ return out;
570
+ }
571
+
572
+ /**
573
+ * Render the query-scoped drift check (#1268) — one line per matched resource
574
+ * whose {@link diffLive} verdict is not `unchanged`, then a summary.
575
+ *
576
+ * Reuses `diffLive` unmodified, so every category means exactly what it means
577
+ * in `lifecycle diff --live`: `missing`/`orphan`/`disappeared`/`drifted` count
578
+ * as drift, the same set `lifecycle diff` totals; `newlyObserved` and runtime
579
+ * children are reported but do not, and `unobserved` — a read that could not
580
+ * look — stays distinct from both, the same #1089 tri-state everywhere else.
581
+ */
582
+ function renderDrift(
583
+ diff: LiveDiffResult,
584
+ checked: number,
585
+ direction: "live" | "snapshot",
586
+ depth?: ObservationDepth,
587
+ ): number {
588
+ for (const name of diff.missing) console.log(`⚠ ${name} — missing (declared, not found by this check)`);
589
+ for (const name of diff.orphan) console.log(`⚠ ${name} — orphan (observed, not declared)`);
590
+ for (const name of diff.disappeared) console.log(`⚠ ${name} — disappeared since the recorded snapshot`);
591
+ for (const drift of diff.driftedSinceSnapshot) {
592
+ for (const change of drift.changes) {
593
+ console.log(`⚠ ${drift.name} ${change.path}: ${formatValue(change.oldValue)} → ${formatValue(change.newValue)} — drifted`);
594
+ }
595
+ }
596
+ for (const name of diff.newlyObserved) console.log(` · ${name} — newly observed since the recorded snapshot`);
597
+ for (const r of diff.runtimeChildren) console.log(` · ${r.name} (${r.type}) — runtime, owned by ${r.owner}`);
598
+ for (const u of diff.unobserved) console.log(` ? ${formatUnobserved(u.name, u)}`);
599
+ const driftCount = diff.missing.length + diff.orphan.length + diff.disappeared.length + diff.driftedSinceSnapshot.length;
600
+ const label = direction === "live" ? "a live read" : "the recorded snapshot";
601
+ const depthNote = depth === "deep" ? " · snapshot recorded at deep depth, compared here at identity" : "";
602
+ console.log(
603
+ `— checked against ${label} · ${driftCount > 0 ? `${driftCount} of ${checked} matched drifted` : `no drift across ${checked} matched`}${depthNote}`,
604
+ );
605
+ return driftCount;
606
+ }
607
+
608
+ function formatValue(v: unknown): string {
609
+ if (v === undefined) return "<unset>";
610
+ if (typeof v === "string") return v.length > 60 ? v.slice(0, 57) + "..." : v;
611
+ const json = JSON.stringify(v);
612
+ return json.length > 60 ? json.slice(0, 57) + "..." : json;
613
+ }
614
+
466
615
  /**
467
616
  * Name the facts chant computed for the kinds in this result that the query did not use.
468
617
  *
@@ -815,4 +964,4 @@ function formatRow(n: IRNode, show: string[]): string {
815
964
  }
816
965
 
817
966
  /** Internals exposed for unit tests. */
818
- export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance };
967
+ export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance, renderDrift, mergeResources, pickResources };
package/src/cli/main.ts CHANGED
@@ -83,6 +83,9 @@ const BOOLEAN_FLAGS = new Set([
83
83
  "--yes",
84
84
  "--confirm-prod",
85
85
  "--once",
86
+ "--check-live",
87
+ "--check-snapshot",
88
+ "--fail-on-drift",
86
89
  ]);
87
90
 
88
91
  /**
@@ -305,6 +308,12 @@ export function parseArgs(args: string[]): ParsedArgs {
305
308
  result.at = args[++i];
306
309
  } else if (arg === "--ambient") {
307
310
  result.ambient = true;
311
+ } else if (arg === "--check-live") {
312
+ result.checkLive = true;
313
+ } else if (arg === "--check-snapshot") {
314
+ result.checkSnapshot = true;
315
+ } else if (arg === "--fail-on-drift") {
316
+ result.failOnDrift = true;
308
317
  } else if (arg === "--run-examples") {
309
318
  result.runExamples = true;
310
319
  } else if (arg === "--pinned-digest") {
@@ -615,6 +624,8 @@ Options:
615
624
  OR with a path arg: SARIF report destination (migrate)
616
625
  OR '--report gitlab-mr': emit the GitLab MR plan-widget
617
626
  JSON (lifecycle plan)
627
+ OR '--report markdown': emit a reviewer-facing markdown
628
+ render, holes and disruption included (lifecycle plan)
618
629
  --from <name> Source lexicon for migrate (default: github)
619
630
  --to <name> Target lexicon for migrate (default: gitlab)
620
631
  --emit <fmt> Migration output format: yaml (default) or ts
@@ -1,8 +1,9 @@
1
1
  import { resolve } from "node:path";
2
2
  import { discoverOps } from "../../op/discover";
3
3
  import { makeTemporalClient } from "../handlers/run";
4
- import { resolveWorkflowId } from "../handlers/run-client";
4
+ import { resolveWorkflowId, fetchNormalizedHistory } from "../handlers/run-client";
5
5
  import { generateReport } from "../handlers/run-report";
6
+ import { extractStepRecords, countActivities, queryGateState } from "../handlers/op-progress";
6
7
  import type { ToolRegistration } from "./lifecycle-tools";
7
8
 
8
9
  function workflowFnName(opName: string): string {
@@ -117,14 +118,22 @@ export function createOpStatusTool(): ToolRegistration {
117
118
  const name = params.name as string;
118
119
  const profile = params.profile as string | undefined;
119
120
 
121
+ const { ops } = await discoverOps();
122
+ const config = ops.get(name)?.config;
123
+
120
124
  const { client } = await makeTemporalClient(profile, resolve("."));
121
125
  const handle = client.workflow.getHandle(resolveWorkflowId(name));
122
126
  const desc = await handle.describe();
123
- const history = await handle.fetchHistory();
127
+ const history = await fetchNormalizedHistory(handle);
124
128
 
125
- const events = history.events ?? [];
126
- const activitiesCompleted = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
127
- const activitiesScheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
129
+ const { completed: activitiesCompleted, scheduled: activitiesScheduled } = countActivities(history);
130
+ // Per-phase progress (#1676) — the same StepRecord shape `chant run
131
+ // <name> --json` uses locally, so a consumer renders one way
132
+ // regardless of executor. Only buildable when the Op's config was
133
+ // discoverable (a *.op.ts file on disk); absent otherwise rather than
134
+ // guessed at.
135
+ const progress = config ? extractStepRecords(config, history, { final: Boolean(desc.closeTime) }) : undefined;
136
+ const gate = await queryGateState(handle);
128
137
 
129
138
  return {
130
139
  workflowId: desc.workflowId,
@@ -135,6 +144,8 @@ export function createOpStatusTool(): ToolRegistration {
135
144
  taskQueue: desc.taskQueue,
136
145
  activitiesCompleted,
137
146
  activitiesScheduled,
147
+ ...(progress ? { progress } : {}),
148
+ gate: gate ?? null,
138
149
  };
139
150
  },
140
151
  };
@@ -196,7 +207,7 @@ export function createOpReportTool(): ToolRegistration {
196
207
  const { client } = await makeTemporalClient(profile, resolve("."));
197
208
  const handle = client.workflow.getHandle(resolveWorkflowId(name));
198
209
  const desc = await handle.describe();
199
- const history = await handle.fetchHistory();
210
+ const history = await fetchNormalizedHistory(handle);
200
211
 
201
212
  return generateReport(name, config, desc, history);
202
213
  },
@@ -5,7 +5,8 @@ import { getContext } from "./resources/context";
5
5
  import { readSnapshot, readEnvironmentSnapshots } from "../../lifecycle/git";
6
6
  import { discoverOps } from "../../op/discover";
7
7
  import { makeTemporalClient } from "../handlers/run";
8
- import { resolveWorkflowId } from "../handlers/run-client";
8
+ import { resolveWorkflowId, fetchNormalizedHistory } from "../handlers/run-client";
9
+ import { extractStepRecords, countActivities, queryGateState } from "../handlers/op-progress";
9
10
  import { loadOkfBundle } from "../../okf-read";
10
11
  import { loadChantConfigUpward, resolveKnowledgeDir } from "../../config";
11
12
 
@@ -177,11 +178,16 @@ export async function handleResourcesRead(
177
178
  if (uri.startsWith("chant://ops/") && uri.endsWith("/runs/latest")) {
178
179
  const name = uri.replace("chant://ops/", "").replace("/runs/latest", "");
179
180
  try {
181
+ const { ops } = await discoverOps();
182
+ const config = ops.get(name)?.config;
183
+
180
184
  const { client } = await makeTemporalClient(undefined, resolve("."));
181
185
  const handle = client.workflow.getHandle(resolveWorkflowId(name));
182
186
  const desc = await handle.describe();
183
- const history = await handle.fetchHistory();
184
- const events = history.events ?? [];
187
+ const history = await fetchNormalizedHistory(handle);
188
+ const { completed: activitiesCompleted, scheduled: activitiesScheduled } = countActivities(history);
189
+ const progress = config ? extractStepRecords(config, history, { final: Boolean(desc.closeTime) }) : undefined;
190
+ const gate = await queryGateState(handle);
185
191
  const result = {
186
192
  workflowId: desc.workflowId,
187
193
  runId: desc.runId,
@@ -189,8 +195,10 @@ export async function handleResourcesRead(
189
195
  startTime: desc.startTime,
190
196
  closeTime: desc.closeTime ?? null,
191
197
  taskQueue: desc.taskQueue,
192
- activitiesCompleted: events.filter((e) => e.eventType === "ActivityTaskCompleted").length,
193
- activitiesScheduled: events.filter((e) => e.eventType === "ActivityTaskScheduled").length,
198
+ activitiesCompleted,
199
+ activitiesScheduled,
200
+ ...(progress ? { progress } : {}),
201
+ gate: gate ?? null,
194
202
  };
195
203
  return {
196
204
  contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }],
@@ -26,7 +26,20 @@ export interface ParsedArgs {
26
26
  temporal?: boolean;
27
27
  /** `chant run` — emit the structured OpRunResult as JSON on stdout. */
28
28
  json?: boolean;
29
- /** `chant run --components <name|all> --progress-json` — stream one NDJSON `RunProgressEvent` (../../components/run-progress.ts) per line to stdout while the run executes (local executor only), so a consumer can render live wave/component/phase/step progress instead of tailing raw logs. Purely additive: run semantics, ordering, and exit code are unchanged; omitted (undefined, not false) when the flag isn't passed. */
29
+ /**
30
+ * `chant run --components <name|all> --progress-json` (local executor) —
31
+ * stream one NDJSON `RunProgressEvent` (../../components/run-progress.ts)
32
+ * per line to stdout while the run executes, so a consumer can render live
33
+ * wave/component/phase/step progress instead of tailing raw logs.
34
+ *
35
+ * `chant run <name> --temporal --progress-json` (chant #1676) — the same
36
+ * flag on an Op's durable path streams one NDJSON `StepRecord`
37
+ * (../../op/local-executor.ts, reconstructed from workflow history by
38
+ * ../handlers/op-progress.ts) per settled step instead.
39
+ *
40
+ * Both are purely additive: run semantics, ordering, and exit code are
41
+ * unchanged; omitted (undefined, not false) when the flag isn't passed.
42
+ */
30
43
  progressJson?: boolean;
31
44
  live: boolean;
32
45
  /** `chant migrate --from <name>` (default "github") */
@@ -185,6 +198,25 @@ export interface ParsedArgs {
185
198
  * what is declared, which is a broader read and a different claim.
186
199
  */
187
200
  ambient?: boolean;
201
+ /**
202
+ * `chant search "<q>" --at <ref> --check-live --env <name>` (#1268) —
203
+ * additionally read the estate live and diff the matched rows against the
204
+ * snapshot the answer came from, reusing `diffLive` (the same engine
205
+ * `lifecycle diff --live` uses) scoped to just those rows. Requires `--at`.
206
+ */
207
+ checkLive?: boolean;
208
+ /**
209
+ * `chant search "<q>" --live --check-snapshot --env <name>` (#1268) — the
210
+ * reverse of `--check-live`: answer live, diff the matched rows against the
211
+ * most recently recorded snapshot. Requires `--live`.
212
+ */
213
+ checkSnapshot?: boolean;
214
+ /**
215
+ * `chant search "<q>" --check-live|--check-snapshot --fail-on-drift`
216
+ * (#1268) — exit non-zero when the scoped check finds drift, so it is usable
217
+ * as a CI gate. Meaningless without one of the two flags above.
218
+ */
219
+ failOnDrift?: boolean;
188
220
 
189
221
  /** `chant dev surface-diff --run-examples` — also run the example build harness */
190
222
  runExamples?: boolean;