@intentius/chant 0.33.1 → 0.34.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 (50) hide show
  1. package/dist/cli/commands/onboard.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  4. package/dist/cli/handlers/search.d.ts +49 -1
  5. package/dist/cli/handlers/search.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/registry.d.ts +26 -0
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/graph-ir.d.ts +14 -0
  10. package/dist/graph-ir.d.ts.map +1 -1
  11. package/dist/graph-refs.d.ts +19 -0
  12. package/dist/graph-refs.d.ts.map +1 -1
  13. package/dist/lexicon.d.ts +141 -0
  14. package/dist/lexicon.d.ts.map +1 -1
  15. package/dist/lifecycle/deep-observe.d.ts +4 -0
  16. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  17. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  18. package/dist/lifecycle/observe.d.ts +55 -1
  19. package/dist/lifecycle/observe.d.ts.map +1 -1
  20. package/dist/lifecycle/replay.d.ts +47 -0
  21. package/dist/lifecycle/replay.d.ts.map +1 -0
  22. package/dist/lifecycle/snapshot.d.ts +6 -0
  23. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  24. package/dist/lifecycle/types.d.ts +46 -0
  25. package/dist/lifecycle/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/cli/commands/onboard.ts +10 -25
  28. package/src/cli/handlers/graph.test.ts +74 -0
  29. package/src/cli/handlers/graph.ts +77 -36
  30. package/src/cli/handlers/lifecycle.test.ts +86 -0
  31. package/src/cli/handlers/lifecycle.ts +43 -10
  32. package/src/cli/handlers/search.test.ts +200 -4
  33. package/src/cli/handlers/search.ts +383 -29
  34. package/src/cli/main.ts +9 -0
  35. package/src/cli/registry.ts +27 -0
  36. package/src/codegen/lexicon-wiring.test.ts +53 -0
  37. package/src/codegen/release-wiring.test.ts +92 -0
  38. package/src/graph-ir-live.test.ts +83 -0
  39. package/src/graph-ir.ts +51 -1
  40. package/src/graph-refs.test.ts +59 -0
  41. package/src/graph-refs.ts +39 -8
  42. package/src/lexicon.ts +145 -0
  43. package/src/lifecycle/deep-observe.ts +5 -0
  44. package/src/lifecycle/live-diff.test.ts +38 -0
  45. package/src/lifecycle/live-diff.ts +45 -2
  46. package/src/lifecycle/observe.ts +186 -4
  47. package/src/lifecycle/replay.ts +141 -0
  48. package/src/lifecycle/snapshot.test.ts +179 -0
  49. package/src/lifecycle/snapshot.ts +88 -3
  50. package/src/lifecycle/types.ts +47 -0
@@ -133,11 +133,31 @@ function compareMetadata(
133
133
  return changes;
134
134
  }
135
135
 
136
+ /**
137
+ * Value equality that does not care what order a provider listed the keys in.
138
+ *
139
+ * This compared with `JSON.stringify`, which is key-order sensitive. That held
140
+ * while observed attributes were flat strings, and broke the moment they carried
141
+ * nested objects (#1279): a provider returning `{AvailabilityZone, Tenancy}` on
142
+ * one read and `{Tenancy, AvailabilityZone}` on the next made an unchanged
143
+ * instance drift on every single run. Order is not a fact about the resource,
144
+ * and reporting it as drift is exactly the noise this module exists to remove.
145
+ *
146
+ * Arrays stay order-sensitive — for a list, order is part of the value.
147
+ */
136
148
  function shallowEqual(a: unknown, b: unknown): boolean {
137
149
  if (a === b) return true;
138
150
  if (a == null || b == null) return false;
139
151
  if (typeof a !== "object" || typeof b !== "object") return false;
140
- return JSON.stringify(a) === JSON.stringify(b);
152
+ return canonical(a) === canonical(b);
153
+ }
154
+
155
+ /** JSON with object keys sorted at every depth, so equal values stringify equally. */
156
+ function canonical(value: unknown): string {
157
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
158
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
159
+ const entries = Object.entries(value as Record<string, unknown>).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
160
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
141
161
  }
142
162
 
143
163
  /** The delta between two saved snapshots (#822): a two-way observed diff. */
@@ -227,8 +247,28 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
227
247
  // same as `unowned`/`foreign` — composing with #1168's tri-state precedent:
228
248
  // an incomplete read never earns the more confident classification.
229
249
  const runtimeChildNames = new Set<string>();
250
+ const dependencyNames = new Set<string>();
230
251
  for (const name of observedNowNames) {
231
252
  if (declared.has(name)) continue;
253
+ // A referenced dependency (#1273) is observed only because something
254
+ // declared points at it — an account's default VPC route table, a shared
255
+ // subnet. Offering it as a delete/adopt candidate is wrong: it is not
256
+ // yours, and it changes on its own, so counting it as drift is noise.
257
+ // Same treatment as a runtime child, for the same reason, arrived at from
258
+ // the other direction — a child is something declared created, a
259
+ // dependency is something declared relies on.
260
+ if ((observedNow[name]?.referencedBy?.length ?? 0) > 0) {
261
+ dependencyNames.add(name);
262
+ continue;
263
+ }
264
+ // Ambient (#1278): observed because it exists, not because anything points
265
+ // at it. Reported so a caller can ask about it — "which of these are
266
+ // unused" is the whole point — but never counted as drift, since chant
267
+ // neither created it nor tracks its changes.
268
+ if (observedNow[name]?.ambient) {
269
+ dependencyNames.add(name);
270
+ continue;
271
+ }
232
272
  const chain = observedNow[name]?.ownerChain;
233
273
  if (chain?.root === "declared") {
234
274
  runtimeChildNames.add(name);
@@ -256,7 +296,10 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
256
296
  // happened to record the same name (e.g. a StatefulSet's stable pod
257
297
  // identity) must not turn its ordinary churn into `driftedSinceSnapshot`.
258
298
  for (const name of observedNowNames) {
259
- if (runtimeChildNames.has(name)) continue;
299
+ // Referenced dependencies (#1273) are excluded for the same reason runtime
300
+ // children are: they are observed to complete the picture, not to be
301
+ // governed, and their ordinary churn is somebody else's.
302
+ if (runtimeChildNames.has(name) || dependencyNames.has(name)) continue;
260
303
  const now = observedNow[name];
261
304
  const then = observedThenMap[name];
262
305
  if (!then) {
@@ -14,7 +14,8 @@ import type { BuildResult } from "../build";
14
14
  import { build as buildProject } from "../build";
15
15
  import { resolve as resolvePath } from "node:path";
16
16
  import type { SerializerResult } from "../serializer";
17
- import type { LiveObservation } from "../graph-ir";
17
+ import type { LiveObservation, IREdge } from "../graph-ir";
18
+ import type { ResourceMetadata } from "../lexicon";
18
19
  import {
19
20
  mergeObservations,
20
21
  normalizeObservation,
@@ -73,10 +74,18 @@ export async function observeResources(
73
74
  environment: string,
74
75
  plugins: ObservationLexicon[],
75
76
  buildResult: BuildResult,
76
- opts?: { owned?: boolean; stacks?: Array<string | { name: string; region?: string; src?: string }> },
77
+ opts?: {
78
+ owned?: boolean;
79
+ stacks?: Array<string | { name: string; region?: string; src?: string }>;
80
+ /** Also report resources of a managed kind that nothing declares or
81
+ * references (#1278). Opt-in: it asks the provider what exists rather than
82
+ * resolving out from what is declared. */
83
+ ambient?: boolean;
84
+ },
77
85
  ): Promise<ObserveResult> {
78
86
  const owned = opts?.owned ?? true;
79
87
  const stacks = (opts?.stacks ?? []).map((st) => (typeof st === "string" ? { name: st } : st));
88
+ const includeAmbient = opts?.ambient ?? false;
80
89
  // A stack's `src` (multi-stack, #1162) is built SCOPED to recover that stack's
81
90
  // BARE entity names — the names it actually deploys. Matching deployed bare
82
91
  // LogicalResourceIds against the whole-project build's DISAMBIGUATED names
@@ -177,7 +186,38 @@ export async function observeResources(
177
186
  }),
178
187
  );
179
188
  }
180
- pushObservation(observations, warnings, plugin.name, observed, environment, entityNames.length);
189
+ // What the estate depends on but does not declare (#1273). Read after the
190
+ // managed resources, because the declared observation is the closure's
191
+ // roots — there is nothing to reference out from until it exists.
192
+ const dependencies = await collectDependencies(plugin, {
193
+ environment,
194
+ entities,
195
+ observed: observed.resources,
196
+ stacks,
197
+ });
198
+ for (const message of dependencies.warnings) warnings.push(message);
199
+ // Resources of a managed kind that nothing declares or references (#1278).
200
+ // Bounded by what this lexicon's declared entities actually are, so a
201
+ // project managing security groups is not made to enumerate the account.
202
+ const ambient = includeAmbient
203
+ ? await collectAmbient(plugin, {
204
+ environment,
205
+ kinds: [...new Set([...entities.values()].map((e) => e.entityType))],
206
+ observed: observed.resources,
207
+ stacks,
208
+ warnings,
209
+ })
210
+ : {};
211
+ for (const [id, meta] of Object.entries(ambient)) dependencies.resources[id] ??= meta;
212
+ pushObservation(
213
+ observations,
214
+ warnings,
215
+ plugin.name,
216
+ observed,
217
+ environment,
218
+ entityNames.length,
219
+ dependencies,
220
+ );
181
221
  } catch (err) {
182
222
  // A thrown read is the whole-lexicon failure: every declared entity is
183
223
  // NOT-OBSERVED, not absent (#1089). Emitting nothing here is what made a
@@ -201,6 +241,141 @@ export async function observeResources(
201
241
  return { observations, warnings, errors };
202
242
  }
203
243
 
244
+ /**
245
+ * The subset of an observation belonging to one stack.
246
+ *
247
+ * A scoped stack's ids are qualified `${stack}::${id}` (#1162), so the prefix is
248
+ * the whole test. An unqualified observation — single-stack, or a bare-string
249
+ * stack sharing one id space — has no way to be split and is returned whole,
250
+ * which is what it already was.
251
+ */
252
+ function scopeToStack(
253
+ resources: Record<string, ResourceMetadata>,
254
+ stack: string | undefined,
255
+ ): Record<string, ResourceMetadata> {
256
+ if (!stack) return resources;
257
+ const prefix = `${stack}::`;
258
+ const scoped = Object.fromEntries(
259
+ Object.entries(resources)
260
+ .filter(([id]) => id.startsWith(prefix))
261
+ .map(([id, meta]) => [id, meta] as const),
262
+ );
263
+ // No qualified ids at all means this observation was never stack-scoped.
264
+ return Object.keys(scoped).length > 0 ? scoped : resources;
265
+ }
266
+
267
+ /**
268
+ * Ask a lexicon what exists of the kinds it manages, beyond what is declared
269
+ * (#1278). Once per stack for the region, merged by physical id — the same
270
+ * ambient resource seen from two stacks is one resource.
271
+ */
272
+ export async function collectAmbient(
273
+ plugin: ObservationLexicon,
274
+ opts: {
275
+ environment: string;
276
+ kinds: string[];
277
+ observed: Record<string, ResourceMetadata>;
278
+ stacks: Array<{ name: string; region?: string; src?: string }>;
279
+ warnings: string[];
280
+ },
281
+ ): Promise<Record<string, ResourceMetadata>> {
282
+ if (!plugin.observeAmbient || opts.kinds.length === 0) return {};
283
+ const found: Record<string, ResourceMetadata> = {};
284
+ const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }];
285
+ for (const ref of refs) {
286
+ try {
287
+ const part = await plugin.observeAmbient({
288
+ environment: opts.environment,
289
+ kinds: opts.kinds,
290
+ observed: opts.observed,
291
+ ...(ref.name ? { stack: ref.name } : {}),
292
+ ...(ref.region ? { region: ref.region } : {}),
293
+ });
294
+ for (const [id, meta] of Object.entries(part)) found[id] ??= meta;
295
+ } catch (err) {
296
+ opts.warnings.push(
297
+ `${plugin.name}: ambient resources not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${err instanceof Error ? err.message : String(err)}`,
298
+ );
299
+ }
300
+ }
301
+ return found;
302
+ }
303
+
304
+ /** Dependencies collected across a lexicon's stacks, plus anything to report. */
305
+ export interface CollectedDependencies {
306
+ resources: Record<string, ResourceMetadata>;
307
+ edges: IREdge[];
308
+ warnings: string[];
309
+ }
310
+
311
+ const NO_DEPENDENCIES: CollectedDependencies = { resources: {}, edges: [], warnings: [] };
312
+
313
+ /**
314
+ * Ask a lexicon what its declared estate references but does not manage (#1273).
315
+ *
316
+ * Called once per stack, because the closure roots and the region differ per
317
+ * stack, and merged by key. Dependencies are keyed by physical id and are
318
+ * deliberately NOT stack-qualified: the account's default VPC route table is the
319
+ * same resource whichever stack routes through it, and qualifying it would
320
+ * produce one node per referrer and an edge to each.
321
+ *
322
+ * Best-effort. A lexicon that does not implement the hook, or one whose read
323
+ * fails, contributes nothing — the managed observation is already complete and
324
+ * useful on its own, and failing it because an ambient dependency could not be
325
+ * read would trade a whole answer for a partial one.
326
+ */
327
+ export async function collectDependencies(
328
+ plugin: ObservationLexicon,
329
+ opts: {
330
+ environment: string;
331
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
332
+ observed: Record<string, ResourceMetadata>;
333
+ stacks: Array<{ name: string; region?: string; src?: string }>;
334
+ },
335
+ ): Promise<CollectedDependencies> {
336
+ if (!plugin.observeDependencies) return NO_DEPENDENCIES;
337
+
338
+ const resources: Record<string, ResourceMetadata> = {};
339
+ const edges: IREdge[] = [];
340
+ const warnings: string[] = [];
341
+ const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }];
342
+
343
+ for (const ref of refs) {
344
+ // Only this stack's resources are the closure's roots. Handing a lexicon
345
+ // the whole estate makes it resolve out from resources that live somewhere
346
+ // else — for AWS that means `describe-instances` in one region with another
347
+ // region's instance ids, which fails outright with InvalidInstanceID and
348
+ // takes the whole read down with it.
349
+ const roots = scopeToStack(opts.observed, ref.name);
350
+ if (Object.keys(roots).length === 0) continue;
351
+ try {
352
+ const found = await plugin.observeDependencies({
353
+ environment: opts.environment,
354
+ entities: opts.entities,
355
+ observed: roots,
356
+ ...(ref.name ? { stack: ref.name } : {}),
357
+ ...(ref.region ? { region: ref.region } : {}),
358
+ });
359
+ for (const [id, meta] of Object.entries(found.resources)) {
360
+ // Merge referrers rather than overwrite: two stacks routing through the
361
+ // same table is one node reached twice, and the reason it is here is
362
+ // both of them.
363
+ const existing = resources[id];
364
+ resources[id] = existing
365
+ ? { ...existing, referencedBy: [...new Set([...(existing.referencedBy ?? []), ...(meta.referencedBy ?? [])])] }
366
+ : meta;
367
+ }
368
+ edges.push(...(found.edges ?? []));
369
+ } catch (err) {
370
+ const message = err instanceof Error ? err.message : String(err);
371
+ warnings.push(
372
+ `${plugin.name}: dependencies not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${message}`,
373
+ );
374
+ }
375
+ }
376
+ return { resources, edges, warnings };
377
+ }
378
+
204
379
  /** Record one lexicon's observation, warning once per unobserved entity. */
205
380
  function pushObservation(
206
381
  observations: LiveObservation[],
@@ -209,6 +384,7 @@ function pushObservation(
209
384
  observed: NormalizedObservation,
210
385
  environment: string,
211
386
  declaredCount: number,
387
+ dependencies: CollectedDependencies = NO_DEPENDENCIES,
212
388
  ): void {
213
389
  const hasResources = Object.keys(observed.resources).length > 0;
214
390
  const unobservedNames = Object.keys(observed.unobserved);
@@ -225,9 +401,15 @@ function pushObservation(
225
401
  if (notice) warnings.push(notice);
226
402
  return;
227
403
  }
404
+ // Dependencies ride alongside the managed resources so they become nodes, and
405
+ // carry `referencedBy` so every consumer can still tell the two apart.
406
+ const hasDependencies = Object.keys(dependencies.resources).length > 0;
228
407
  observations.push({
229
408
  lexicon,
230
- resources: observed.resources,
409
+ resources: hasDependencies
410
+ ? { ...observed.resources, ...dependencies.resources }
411
+ : observed.resources,
231
412
  ...(unobservedNames.length > 0 ? { unobserved: observed.unobserved } : {}),
413
+ ...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}),
232
414
  });
233
415
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Replay a recorded snapshot as if it had just been observed (#1266, #1279).
3
+ *
4
+ * Shared by `chant search --at` and `chant graph --at`. A snapshot already holds
5
+ * what an observation is — resources with their physical ids and attributes,
6
+ * and since #1266 the relationships between them — so turning it back into
7
+ * `LiveObservation[]` lets a replay rejoin the live path at `buildLiveGraphIr`.
8
+ * Every fold, overlay and edge reconstruction below that point is shared, and
9
+ * nothing downstream needs to know which source it got.
10
+ *
11
+ * That sharing is the reason this is a module rather than a helper inside one
12
+ * command: `graph` had no `--at` at all, so anyone wanting the raw IR of a
13
+ * recorded estate had to reach for the live endpoint, and a snapshot could
14
+ * answer most questions but never all of them.
15
+ */
16
+
17
+ import { readEnvironmentSnapshots } from "./git";
18
+ import type { LiveObservation } from "../graph-ir";
19
+ import type { LifecycleSnapshot } from "./types";
20
+
21
+ /**
22
+ * Rebuild observations from a recorded snapshot (#1266).
23
+ *
24
+ * A snapshot already holds what an observation is: resources with their
25
+ * physical ids and attributes, and — since #1266 — the relationships between
26
+ * them. Turning it back into `LiveObservation[]` means the replay rejoins the
27
+ * live path at `buildLiveGraphIr`, and every fold, overlay and query below that
28
+ * is shared. Nothing downstream needs to know which source it got.
29
+ *
30
+ * `latest` is the only ref for now. A specific commit is the natural extension
31
+ * and the storage already supports it (`readSnapshotAt`), but "answer from what
32
+ * is recorded" is the question worth settling first.
33
+ */
34
+ /**
35
+ * Whether this environment has a recording, without reading one.
36
+ *
37
+ * Used to turn "the estate could not be read" into "the estate could not be
38
+ * read, and you already have a recording of it". Cheap and best-effort: a
39
+ * failure here means the caller says the plain version of the message, never
40
+ * that the command fails.
41
+ */
42
+ export async function hasSnapshot(environment: string): Promise<boolean> {
43
+ try {
44
+ return (await readEnvironmentSnapshots(environment)).size > 0;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ export async function replaySnapshots(
51
+ environment: string,
52
+ ref: string,
53
+ scopedStacks: Set<string>,
54
+ ): Promise<{ observations: LiveObservation[]; commit: string; timestamp: string } | { error: string; hint?: string }> {
55
+ if (ref !== "latest" && ref !== "true") {
56
+ return {
57
+ error: `chant search --at only accepts "latest" for now, got "${ref}"`,
58
+ hint: "a specific snapshot commit is not wired up yet",
59
+ };
60
+ }
61
+ const stored = await readEnvironmentSnapshots(environment);
62
+ if (stored.size === 0) {
63
+ return {
64
+ error: `No snapshots found for environment "${environment}"`,
65
+ hint: `Record one first: chant lifecycle snapshot ${environment}`,
66
+ };
67
+ }
68
+ const observations: LiveObservation[] = [];
69
+ let commit = "";
70
+ let timestamp = "";
71
+ // Ambient and dependency resources are keyed by physical id and are
72
+ // account-level: the default security group three stacks each recorded is one
73
+ // group, not three. Managed resources are stack-qualified below and cannot
74
+ // collide, so only the unqualified ones need this.
75
+ const seenUnqualified = new Set<string>();
76
+ // A stack's snapshot could only exclude what THAT stack manages, so a stack
77
+ // declaring no security groups reported the neighbouring stack's as ambient.
78
+ // The union is only knowable here, with every snapshot in hand.
79
+ const managedPhysicalIds = new Set<string>();
80
+ for (const content of stored.values()) {
81
+ const snap = JSON.parse(content) as LifecycleSnapshot;
82
+ for (const meta of Object.values(snap.resources ?? {})) {
83
+ if (!meta.ambient && !meta.referencedBy?.length && meta.physicalId) {
84
+ managedPhysicalIds.add(meta.physicalId);
85
+ }
86
+ }
87
+ }
88
+ for (const [key, content] of stored) {
89
+ const snapshot = JSON.parse(content) as LifecycleSnapshot;
90
+ // The storage key is `<stack>__<lexicon>` for a multi-stack project; the
91
+ // snapshot carries its own lexicon, which is the one to trust.
92
+ const lexicon = snapshot.lexicon ?? key;
93
+ // A scoped stack's ids are qualified `${stack}::${id}` on the live path
94
+ // (#1162), because the same bare LogicalResourceId exists in every region's
95
+ // stack. A snapshot stores them bare, so a replay has to re-apply the same
96
+ // rule — otherwise `server` from us-west-1 and `server` from us-west-2
97
+ // collide, and none of them join the declared canvas, which qualifies.
98
+ const stack = snapshot.stack;
99
+ const qualify = stack !== undefined && scopedStacks.has(stack);
100
+ // Dependencies (#1273) are keyed by physical id and are account-level: the
101
+ // default VPC's route table is one resource however many stacks route
102
+ // through it. Qualifying those would split it per stack and break the
103
+ // edges into it.
104
+ const managed = (id: string, meta: { referencedBy?: string[]; ambient?: boolean }): string =>
105
+ qualify && !meta.ambient && !(meta.referencedBy && meta.referencedBy.length > 0)
106
+ ? `${stack}::${id}`
107
+ : id;
108
+ const resources: Record<string, (typeof snapshot.resources)[string]> = {};
109
+ for (const [id, meta] of Object.entries(snapshot.resources ?? {})) {
110
+ const key = managed(id, meta);
111
+ if (key === id) {
112
+ // Ambient means "nothing manages this". Another stack managing it makes
113
+ // that false, and reporting it twice would inflate any count over it.
114
+ if (meta.ambient && meta.physicalId && managedPhysicalIds.has(meta.physicalId)) continue;
115
+ // Unqualified: account-level, so first sighting wins and the rest are
116
+ // the same resource seen again from another stack's snapshot.
117
+ if (seenUnqualified.has(id)) continue;
118
+ seenUnqualified.add(id);
119
+ }
120
+ resources[key] = meta;
121
+ }
122
+ const known = new Set(Object.keys(snapshot.resources ?? {}));
123
+ const requalify = (id: string): string => {
124
+ const meta = (snapshot.resources ?? {})[id];
125
+ return known.has(id) && meta ? managed(id, meta) : id;
126
+ };
127
+ const edges = (snapshot.edges ?? []).map((e) => ({ ...e, from: requalify(e.from), to: requalify(e.to) }));
128
+ observations.push({
129
+ lexicon,
130
+ resources,
131
+ ...(edges.length > 0 ? { edges } : {}),
132
+ });
133
+ commit ||= snapshot.commit ?? "";
134
+ // Report the OLDEST timestamp across stacks: a caller asking how stale this
135
+ // answer is wants the weakest link, not the freshest one.
136
+ if (!timestamp || (snapshot.timestamp && snapshot.timestamp < timestamp)) {
137
+ timestamp = snapshot.timestamp ?? timestamp;
138
+ }
139
+ }
140
+ return { observations, commit, timestamp };
141
+ }
@@ -1,6 +1,8 @@
1
1
  import { describe, test, expect, vi, beforeEach } from "vitest";
2
2
  import { createMockPlugin, staticDescribeResources, staticObservation, staticListArtifacts } from "@intentius/chant-test-utils";
3
3
  import type { BuildResult } from "../build";
4
+ import type { DeepResourceObservation } from "../deep-observation";
5
+ import type { UnobservedEntity } from "../observation";
4
6
 
5
7
  const writeSnapshotMock = vi.fn();
6
8
  const getHeadCommitMock = vi.fn();
@@ -83,6 +85,126 @@ describe("takeSnapshot", () => {
83
85
  expect(writeSnapshotMock.mock.calls[0][1]).toBe("loom-backend__aws");
84
86
  });
85
87
 
88
+ test("region option: the stack's own region reaches describeResources (#1261)", async () => {
89
+ let observedRegion: string | undefined = "unset";
90
+ const plugin = createMockPlugin({
91
+ name: "aws",
92
+ describeResources: async (options: { region?: string }) => {
93
+ observedRegion = options.region;
94
+ return { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
95
+ },
96
+ });
97
+ await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), {
98
+ stack: "loom-us-west-2",
99
+ region: "us-west-2",
100
+ });
101
+ // Without this the reader falls back to the ambient region, and every stack
102
+ // outside it snapshots as "no valid resources or artifacts returned".
103
+ expect(observedRegion).toBe("us-west-2");
104
+ });
105
+
106
+ test("no region declared: describeResources keeps its ambient-region default", async () => {
107
+ let observedRegion: string | undefined = "unset";
108
+ const plugin = createMockPlugin({
109
+ name: "aws",
110
+ describeResources: async (options: { region?: string }) => {
111
+ observedRegion = options.region;
112
+ return { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
113
+ },
114
+ });
115
+ await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }));
116
+ expect(observedRegion).toBeUndefined();
117
+ });
118
+
119
+ // #1267 — a snapshot records identity by default; --deep also records each
120
+ // resource's property tree, which is what a fold over topology needs.
121
+ describe("deep snapshots (#1267)", () => {
122
+ const identity = { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
123
+
124
+ function deepPlugin(
125
+ resources: Record<string, DeepResourceObservation>,
126
+ unobserved: Record<string, UnobservedEntity> = {},
127
+ ) {
128
+ return createMockPlugin({
129
+ name: "aws",
130
+ describeResources: staticDescribeResources(identity),
131
+ observeResourcesDeep: async () => ({ deepObservation: "v1" as const, resources, unobserved }),
132
+ });
133
+ }
134
+
135
+ test("without --deep: identity only, and depth is not written", async () => {
136
+ const plugin = deepPlugin({
137
+ bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: { versioning: "Enabled" } },
138
+ });
139
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }));
140
+ // Absent, not "identity" — every snapshot written before #1267 is thin,
141
+ // and a reader must treat a missing field as thin rather than unknown.
142
+ expect(result.snapshots[0].depth).toBeUndefined();
143
+ expect(result.snapshots[0].properties).toBeUndefined();
144
+ });
145
+
146
+ test("with --deep: records the property trees alongside identity", async () => {
147
+ const plugin = deepPlugin({
148
+ bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: { versioning: "Enabled" } },
149
+ });
150
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
151
+ expect(result.snapshots[0].depth).toBe("deep");
152
+ expect(result.snapshots[0].properties?.bucket.properties).toEqual({ versioning: "Enabled" });
153
+ // Identity is still there — deep adds, it does not replace.
154
+ expect(result.snapshots[0].resources.bucket).toMatchObject({ type: "AWS::S3::Bucket" });
155
+ });
156
+
157
+ test("--deep against a lexicon with no deep reader: identity snapshot plus a warning", async () => {
158
+ const plugin = createMockPlugin({ name: "aws", describeResources: staticDescribeResources(identity) });
159
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
160
+ // Still a usable snapshot, but it must not claim a depth it does not have.
161
+ expect(result.snapshots).toHaveLength(1);
162
+ expect(result.snapshots[0].depth).toBeUndefined();
163
+ expect(result.warnings.join("\n")).toContain("no deep reader");
164
+ });
165
+
166
+ test("--deep returning nothing: downgrades to identity rather than discarding a good snapshot", async () => {
167
+ const plugin = deepPlugin({});
168
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
169
+ expect(result.snapshots).toHaveLength(1);
170
+ expect(result.snapshots[0].depth).toBeUndefined();
171
+ expect(result.warnings.join("\n")).toContain("deep read returned no properties");
172
+ });
173
+
174
+ test("--deep passes the stack's region to the deep reader (#1261 family)", async () => {
175
+ let seen: string | undefined = "unset";
176
+ const plugin = createMockPlugin({
177
+ name: "aws",
178
+ describeResources: staticDescribeResources(identity),
179
+ observeResourcesDeep: async (options: { region?: string }) => {
180
+ seen = options.region;
181
+ return {
182
+ deepObservation: "v1" as const,
183
+ resources: { bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: {} } },
184
+ unobserved: {},
185
+ };
186
+ },
187
+ });
188
+ await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), {
189
+ stack: "app-us-west-2",
190
+ region: "us-west-2",
191
+ deep: true,
192
+ });
193
+ // Without this the deep read targets the ambient region and comes back
194
+ // empty, which downgrades a multi-region snapshot to identity silently.
195
+ expect(seen).toBe("us-west-2");
196
+ });
197
+
198
+ test("--deep reports entities the deep reader could not read", async () => {
199
+ const plugin = deepPlugin(
200
+ { bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: {} } },
201
+ { queue: { reason: "read-failed", detail: "boom" } },
202
+ );
203
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
204
+ expect(result.warnings.join("\n")).toContain("not observed deeply");
205
+ });
206
+ });
207
+
86
208
  test("plugin without describeResources is skipped", async () => {
87
209
  const plugin = createMockPlugin({ name: "aws" });
88
210
  const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["x"] }));
@@ -231,3 +353,60 @@ describe("takeSnapshot", () => {
231
353
  expect(result.snapshots).toEqual([]);
232
354
  });
233
355
  });
356
+
357
+ // #1266 — a snapshot that records only what it manages cannot answer a fold
358
+ // question when it is replayed: the account's default VPC routing is not in it,
359
+ // so `internetFacing` is unanswerable and `search --at` would be quietly weaker
360
+ // than `search --live`.
361
+ describe("dependencies and edges in a snapshot (#1266)", () => {
362
+ const identity = { webServer: { type: "AWS::EC2::Instance", status: "OK", physicalId: "i-1" } };
363
+
364
+ test("records the dependencies the estate references, and the edges to them", async () => {
365
+ const plugin = createMockPlugin({
366
+ name: "aws",
367
+ describeResources: staticDescribeResources(identity),
368
+ observeDependencies: async () => ({
369
+ resources: {
370
+ "rtb-default": {
371
+ type: "AWS::EC2::RouteTable",
372
+ status: "OBSERVED",
373
+ physicalId: "rtb-default",
374
+ referencedBy: ["webServer"],
375
+ },
376
+ },
377
+ edges: [{ from: "webServer", to: "rtb-default", kind: "ref" as const, viaAttr: "RouteTableId" }],
378
+ }),
379
+ });
380
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["webServer"] }));
381
+ // Both, in one record: what exists and how it connects.
382
+ expect(result.snapshots[0].resources["rtb-default"]).toMatchObject({ referencedBy: ["webServer"] });
383
+ expect(result.snapshots[0].edges).toEqual([
384
+ { from: "webServer", to: "rtb-default", kind: "ref", viaAttr: "RouteTableId" },
385
+ ]);
386
+ });
387
+
388
+ test("a lexicon with no dependency reader snapshots exactly as before", async () => {
389
+ const plugin = createMockPlugin({ name: "aws", describeResources: staticDescribeResources(identity) });
390
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["webServer"] }));
391
+ expect(Object.keys(result.snapshots[0].resources)).toEqual(["webServer"]);
392
+ // Absent, not empty — "no relationships recorded", not "none existed".
393
+ expect(result.snapshots[0].edges).toBeUndefined();
394
+ });
395
+
396
+ test("a dependency read that fails warns and keeps the managed snapshot", async () => {
397
+ const plugin = createMockPlugin({
398
+ name: "aws",
399
+ describeResources: staticDescribeResources(identity),
400
+ observeDependencies: async () => {
401
+ throw new Error("route tables unreadable");
402
+ },
403
+ });
404
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["webServer"] }));
405
+ // The managed observation is complete and useful on its own; losing it
406
+ // because an ambient dependency could not be read trades a whole answer
407
+ // for none.
408
+ expect(result.snapshots).toHaveLength(1);
409
+ expect(result.snapshots[0].resources.webServer).toBeDefined();
410
+ expect(result.warnings.join("\n")).toContain("dependencies not read");
411
+ });
412
+ });