@intentius/chant 0.33.0 → 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 (56) 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 +72 -0
  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-effective.d.ts.map +1 -1
  10. package/dist/graph-ir.d.ts +21 -0
  11. package/dist/graph-ir.d.ts.map +1 -1
  12. package/dist/graph-refs.d.ts +19 -0
  13. package/dist/graph-refs.d.ts.map +1 -1
  14. package/dist/lexicon.d.ts +141 -0
  15. package/dist/lexicon.d.ts.map +1 -1
  16. package/dist/lifecycle/deep-observe.d.ts +4 -0
  17. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  18. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  19. package/dist/lifecycle/observe.d.ts +55 -1
  20. package/dist/lifecycle/observe.d.ts.map +1 -1
  21. package/dist/lifecycle/replay.d.ts +47 -0
  22. package/dist/lifecycle/replay.d.ts.map +1 -0
  23. package/dist/lifecycle/snapshot.d.ts +6 -0
  24. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  25. package/dist/lifecycle/types.d.ts +46 -0
  26. package/dist/lifecycle/types.d.ts.map +1 -1
  27. package/dist/observation.d.ts +71 -0
  28. package/dist/observation.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli/commands/onboard.ts +10 -25
  31. package/src/cli/handlers/graph.test.ts +74 -0
  32. package/src/cli/handlers/graph.ts +77 -36
  33. package/src/cli/handlers/lifecycle.test.ts +86 -0
  34. package/src/cli/handlers/lifecycle.ts +43 -10
  35. package/src/cli/handlers/search.test.ts +246 -4
  36. package/src/cli/handlers/search.ts +432 -27
  37. package/src/cli/main.ts +9 -0
  38. package/src/cli/registry.ts +27 -0
  39. package/src/codegen/lexicon-wiring.test.ts +53 -0
  40. package/src/codegen/release-wiring.test.ts +174 -0
  41. package/src/graph-effective.ts +7 -1
  42. package/src/graph-ir-live.test.ts +83 -0
  43. package/src/graph-ir.ts +58 -1
  44. package/src/graph-refs.test.ts +59 -0
  45. package/src/graph-refs.ts +39 -8
  46. package/src/lexicon.ts +145 -0
  47. package/src/lifecycle/deep-observe.ts +5 -0
  48. package/src/lifecycle/live-diff.test.ts +38 -0
  49. package/src/lifecycle/live-diff.ts +45 -2
  50. package/src/lifecycle/observe.ts +186 -4
  51. package/src/lifecycle/replay.ts +141 -0
  52. package/src/lifecycle/snapshot.test.ts +179 -0
  53. package/src/lifecycle/snapshot.ts +88 -3
  54. package/src/lifecycle/types.ts +47 -0
  55. package/src/observation.test.ts +135 -0
  56. package/src/observation.ts +151 -0
@@ -1 +1 @@
1
- {"version":3,"file":"observe.d.ts","sourceRoot":"","sources":["../../src/lifecycle/observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAI5C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAUnD,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,eAAe,EAAE,CAAC;IAChC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAeD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,kBAAkB,EAAE,EAC7B,WAAW,EAAE,WAAW,EACxB,IAAI,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,GACnG,OAAO,CAAC,aAAa,CAAC,CA6HxB"}
1
+ {"version":3,"file":"observe.d.ts","sourceRoot":"","sources":["../../src/lifecycle/observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAI5C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAUnD,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,eAAe,EAAE,CAAC;IAChC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAeD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,kBAAkB,EAAE,EAC7B,WAAW,EAAE,WAAW,EACxB,IAAI,CAAC,EAAE;IACL,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzE;;8CAE0C;IAC1C,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,GACA,OAAO,CAAC,aAAa,CAAC,CA6JxB;AAyBD;;;;GAIG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE;IACJ,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC3C,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/D,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,GACA,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAqB3C;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAID;;;;;;;;;;;;;GAaG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE;IACJ,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC3C,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChE,GACA,OAAO,CAAC,qBAAqB,CAAC,CA0ChC"}
@@ -0,0 +1,47 @@
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
+ import type { LiveObservation } from "../graph-ir.js";
17
+ /**
18
+ * Rebuild observations from a recorded snapshot (#1266).
19
+ *
20
+ * A snapshot already holds what an observation is: resources with their
21
+ * physical ids and attributes, and — since #1266 — the relationships between
22
+ * them. Turning it back into `LiveObservation[]` means the replay rejoins the
23
+ * live path at `buildLiveGraphIr`, and every fold, overlay and query below that
24
+ * is shared. Nothing downstream needs to know which source it got.
25
+ *
26
+ * `latest` is the only ref for now. A specific commit is the natural extension
27
+ * and the storage already supports it (`readSnapshotAt`), but "answer from what
28
+ * is recorded" is the question worth settling first.
29
+ */
30
+ /**
31
+ * Whether this environment has a recording, without reading one.
32
+ *
33
+ * Used to turn "the estate could not be read" into "the estate could not be
34
+ * read, and you already have a recording of it". Cheap and best-effort: a
35
+ * failure here means the caller says the plain version of the message, never
36
+ * that the command fails.
37
+ */
38
+ export declare function hasSnapshot(environment: string): Promise<boolean>;
39
+ export declare function replaySnapshots(environment: string, ref: string, scopedStacks: Set<string>): Promise<{
40
+ observations: LiveObservation[];
41
+ commit: string;
42
+ timestamp: string;
43
+ } | {
44
+ error: string;
45
+ hint?: string;
46
+ }>;
47
+ //# sourceMappingURL=replay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../../src/lifecycle/replay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGnD;;;;;;;;;;;;GAYG;AACH;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMvE;AAED,wBAAsB,eAAe,CACnC,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,EACX,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,GACxB,OAAO,CAAC;IAAE,YAAY,EAAE,eAAe,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAuFpH"}
@@ -17,5 +17,11 @@ export interface TakeSnapshotResult {
17
17
  export declare function takeSnapshot(environment: string, plugins: ObservationLexicon[], buildResult: BuildResult, opts?: {
18
18
  cwd?: string;
19
19
  stack?: string;
20
+ region?: string;
21
+ deep?: boolean;
22
+ ambient?: boolean;
23
+ /** Kinds the PROJECT manages, not just this stack — a region whose stack
24
+ * declares no security group still has a default one (#1278). */
25
+ ambientKinds?: string[];
20
26
  }): Promise<TakeSnapshotResult>;
21
27
  //# sourceMappingURL=snapshot.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../../src/lifecycle/snapshot.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAsC,MAAM,YAAY,CAAC;AACzF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AA6DjD,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,kBAAkB,EAAE,EAC7B,WAAW,EAAE,WAAW,EACxB,IAAI,CAAC,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtC,OAAO,CAAC,kBAAkB,CAAC,CA0I7B"}
1
+ {"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../../src/lifecycle/snapshot.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAsC,MAAM,YAAY,CAAC;AACzF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAC;AAgEnE,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,kBAAkB,EAAE,EAC7B,WAAW,EAAE,WAAW,EACxB,IAAI,CAAC,EAAE;IACL,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;qEACiE;IACjE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB,GACA,OAAO,CAAC,kBAAkB,CAAC,CAmN7B"}
@@ -1,6 +1,22 @@
1
1
  import type { ResourceMetadata, ArtifactMetadata } from "../lexicon.js";
2
2
  import type { UnobservedEntity } from "../observation.js";
3
+ import type { DeepResourceObservation } from "../deep-observation.js";
4
+ import type { IREdge } from "../graph-ir.js";
3
5
  export type { ResourceMetadata, ArtifactMetadata } from "../lexicon.js";
6
+ /**
7
+ * How much of each resource an observation actually read (#1267).
8
+ *
9
+ * `identity` is the thin path: logical name, type, physical id, status. It is
10
+ * what a snapshot recorded before deep reads existed, and it stays the default
11
+ * because a deep read costs more provider calls and a larger record.
12
+ *
13
+ * `deep` additionally carries each resource's normalized property tree, which
14
+ * is what a fold over topology needs — a subnet's route-table association, a
15
+ * security group's rules. A consumer must branch on this rather than assume:
16
+ * asking an `identity` snapshot a property question has no answer, and
17
+ * silently returning nothing would read as "no such resources".
18
+ */
19
+ export type ObservationDepth = "identity" | "deep";
4
20
  /**
5
21
  * State snapshot for a single lexicon in an environment.
6
22
  */
@@ -26,6 +42,36 @@ export interface LifecycleSnapshot {
26
42
  unobserved?: Record<string, UnobservedEntity>;
27
43
  /** Artifact metadata keyed by server-side identifier (lexicon-specific). */
28
44
  artifacts?: Record<string, ArtifactMetadata>;
45
+ /**
46
+ * How much of each resource this snapshot read (#1267). Absent means
47
+ * `identity` — every snapshot written before deep reads existed was thin, and
48
+ * treating a missing field as unknown rather than as thin would invalidate
49
+ * them all.
50
+ */
51
+ depth?: ObservationDepth;
52
+ /**
53
+ * Normalized per-resource property trees, present only at `deep` depth and
54
+ * keyed by the same logical names as `resources`.
55
+ *
56
+ * Kept beside `resources` rather than merged into it so the thin record stays
57
+ * exactly what it always was: a reader that only wants identity does not have
58
+ * to learn a new shape, and an old snapshot and a new one parse the same way.
59
+ */
60
+ properties?: Record<string, DeepResourceObservation>;
61
+ /**
62
+ * Relationships observed between the recorded resources (#1266).
63
+ *
64
+ * `resources` says what existed; without this a snapshot cannot say how any
65
+ * of it connected, so a fold over topology has nothing to traverse when the
66
+ * snapshot is replayed. That is the difference between a snapshot answering
67
+ * "which instances exist" and answering "which are reachable from the
68
+ * internet" — and the second is the whole reason the graph is worth
69
+ * recording.
70
+ *
71
+ * Absent on every snapshot written before this, which is read as "no
72
+ * relationships recorded" rather than "no relationships existed".
73
+ */
74
+ edges?: IREdge[];
29
75
  /** Build digest at snapshot time — what was declared when this snapshot was taken */
30
76
  digest?: BuildDigest;
31
77
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lifecycle/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACrE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEvD,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAErE;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB;;gFAE4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC9C,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC7C,qFAAqF;IACrF,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC1C,sCAAsC;IACtC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACvC,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/E,iCAAiC;IACjC,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,4DAA4D;IAC5D,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,wCAAwC;IACxC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,wCAAwC;IACxC,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lifecycle/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACrE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AACnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAErE;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,MAAM,CAAC;AAEnD;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB;;gFAE4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC9C,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC7C;;;;;OAKG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;IACrD;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,qFAAqF;IACrF,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC1C,sCAAsC;IACtC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACvC,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/E,iCAAiC;IACjC,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,4DAA4D;IAC5D,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,wCAAwC;IACxC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,wCAAwC;IACxC,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB"}
@@ -120,4 +120,75 @@ export declare function mergeObservations(parts: Iterable<NormalizedObservation>
120
120
  export declare function unobservedReasonText(reason: UnobservedReason): string;
121
121
  /** `name — reason (detail)`, the shared rendering for CLI and plan output. */
122
122
  export declare function formatUnobserved(name: string, entry: UnobservedEntity): string;
123
+ /** One declared entity handed to the harness. */
124
+ export interface DeclaredEntity {
125
+ /** chant entity name — the key every outcome is filed under. */
126
+ name: string;
127
+ /** Declared entity type (e.g. `AWS::EC2::VPC`). */
128
+ type: string;
129
+ /** Declared properties, for the adapter to derive a physical address from. */
130
+ props: Record<string, unknown>;
131
+ }
132
+ /**
133
+ * The outcome of reading one entity, mapped onto the tri-state:
134
+ * - `present` — a key in `resources`.
135
+ * - `absent` — in neither map (the provider was asked and reported it missing).
136
+ * - `unobserved` — a typed NOT-OBSERVED (unsupported kind, filtered, read error).
137
+ */
138
+ export type EntityObservation = {
139
+ present: ResourceMetadata;
140
+ } | {
141
+ absent: true;
142
+ } | {
143
+ unobserved: {
144
+ reason: UnobservedReason;
145
+ detail?: string;
146
+ };
147
+ };
148
+ /** What a lexicon supplies to drive the harness. `Client` is its transport handle. */
149
+ export interface ObserverAdapter<Client> {
150
+ /**
151
+ * Reach the provider on the applier's transport. Throw for a whole-lexicon
152
+ * failure; {@link classifyBindFailure} decides what the throw means.
153
+ */
154
+ bind(): Promise<Client>;
155
+ /**
156
+ * Map a `bind()` throw to a typed whole-lexicon reason (every entity becomes
157
+ * NOT-OBSERVED with it), or `"rethrow"` for a loud refusal that must not be
158
+ * swallowed — a context/subscription mismatch, which core turns into an
159
+ * honest hole per entity at a higher layer.
160
+ */
161
+ classifyBindFailure(err: unknown): {
162
+ reason: UnobservedReason;
163
+ detail?: string;
164
+ } | "rethrow";
165
+ /** Read one declared entity. A throw here is caught and recorded `read-failed`. */
166
+ read(client: Client, entity: DeclaredEntity): Promise<EntityObservation>;
167
+ /**
168
+ * Run `fn` over `items` concurrently. Supply the transport's own bounded pool
169
+ * (the k8s client's `concurrently`, say); when omitted the harness uses
170
+ * {@link boundedConcurrently}, so "N entities is not N serial spawns" holds
171
+ * for every lexicon whether or not its transport ships a pool.
172
+ */
173
+ concurrently?<T>(items: readonly T[], fn: (item: T) => Promise<void>): Promise<void>;
174
+ }
175
+ /** Default concurrency for {@link boundedConcurrently} when a transport ships no pool. */
176
+ export declare const DEFAULT_OBSERVE_CONCURRENCY = 16;
177
+ /**
178
+ * Run `fn` over `items` with at most `limit` in flight. A rejected `fn` rejects
179
+ * the whole run (the harness wraps per-entity reads so this stays for genuinely
180
+ * unexpected faults).
181
+ */
182
+ export declare function boundedConcurrently<T>(items: readonly T[], fn: (item: T) => Promise<void>, limit?: number): Promise<void>;
183
+ /**
184
+ * The shared observer control flow (#1201). Binds via the adapter, reads every
185
+ * declared entity concurrently, and assembles the tri-state {@link ObservationResult}.
186
+ *
187
+ * A `bind()` throw becomes NOT-OBSERVED for every entity (typed by
188
+ * {@link ObserverAdapter.classifyBindFailure}) unless the adapter asks to
189
+ * rethrow. A per-entity `read()` throw the adapter did not itself map becomes
190
+ * `read-failed` for that one entity — never a silent absence, which would
191
+ * classify as a spurious `create`.
192
+ */
193
+ export declare function observeEntities<Client>(declared: readonly DeclaredEntity[], adapter: ObserverAdapter<Client>): Promise<ObservationResult>;
123
194
  //# sourceMappingURL=observation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"observation.d.ts","sourceRoot":"","sources":["../src/observation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAElD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,gBAAgB,GACxB,aAAa,GACb,gBAAgB,GAChB,YAAY,GACZ,kBAAkB,GAClB,UAAU,CAAC;AAEf,mFAAmF;AACnF,eAAO,MAAM,kBAAkB,EAAE,SAAS,gBAAgB,EAMzD,CAAC;AAEF,6DAA6D;AAC7D,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAE5E;AAED,kEAAkE;AAClE,MAAM,WAAW,gBAAgB;IAC/B,kGAAkG;IAClG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,MAAM,EAAE,gBAAgB,CAAC;IACzB,qGAAqG;IACrG,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,2GAA2G;IAC3G,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC;IAC3B,oDAAoD;IACpD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C,+GAA+G;IAC/G,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CAC/C;AAED;;;GAGG;AACH,MAAM,MAAM,uBAAuB,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,iBAAiB,CAAC;AAE3F,2EAA2E;AAC3E,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CAC9C;AAED,6EAA6E;AAC7E,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,iBAAiB,CAM9E;AAED;;;GAGG;AACH,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC3C,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAC5C,iBAAiB,CAMnB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,uBAAuB,GAAG,SAAS,GAAG,qBAAqB,CAMtG;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,EACvB,MAAM,EAAE,gBAAgB,EACxB,MAAM,CAAC,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACnE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAYlC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,QAAQ,CAAC,qBAAqB,CAAC,GAAG,qBAAqB,CAW/F;AAED,2DAA2D;AAC3D,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAarE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAG9E"}
1
+ {"version":3,"file":"observation.d.ts","sourceRoot":"","sources":["../src/observation.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -171,40 +171,25 @@ function patchCiWorkflow(root: string, name: string): { patched: boolean; reason
171
171
  }
172
172
 
173
173
  /**
174
- * Patch publish.yml: add prepack line in test job + publish step.
174
+ * Patch publish.yml: add the prepack line to the test job.
175
+ *
176
+ * No publish step is added. scripts/publish-packages.sh enumerates every
177
+ * non-private workspace package at run time, so a new lexicon is published
178
+ * the moment it exists — that is deliberate. Hand-maintained publish steps
179
+ * were how k8s-client (#1177) and fountain (#1253) each shipped a package
180
+ * the release pipeline could not publish.
175
181
  */
176
182
  function patchPublishWorkflow(root: string, name: string): { patched: boolean; reason?: string } {
177
183
  const filePath = join(root, ".github/workflows/publish.yml");
178
184
  if (!existsSync(filePath)) return { patched: false, reason: "publish.yml not found" };
179
185
 
180
186
  const content = readFileSync(filePath, "utf-8");
181
- if (content.includes(`working-directory: lexicons/${name}`)) {
182
- return { patched: false, reason: `publish step for ${name} already present` };
187
+ if (content.includes(`lexicons/${name} prepack`)) {
188
+ return { patched: false, reason: `prepack for ${name} already present` };
183
189
  }
184
190
 
185
191
  const lines = content.split("\n");
186
-
187
- // Insert prepack line in test job
188
192
  insertPrepackAfterEach(lines, name);
189
-
190
- // Add publish step after the last existing publish step
191
- let lastPublishRunIdx = -1;
192
- for (let i = 0; i < lines.length; i++) {
193
- if (lines[i].includes("npm publish --access public")) {
194
- lastPublishRunIdx = i;
195
- }
196
- }
197
-
198
- if (lastPublishRunIdx > 0) {
199
- const block = [
200
- "",
201
- ` - name: Publish @intentius/chant-lexicon-${name}`,
202
- ` working-directory: lexicons/${name}`,
203
- " run: npm publish --access public",
204
- ];
205
- lines.splice(lastPublishRunIdx + 1, 0, ...block);
206
- }
207
-
208
193
  writeFileSync(filePath, lines.join("\n"));
209
194
  return { patched: true };
210
195
  }
@@ -246,7 +231,7 @@ export function onboardCommand(options: OnboardOptions): OnboardResult {
246
231
 
247
232
  // 3. Publish workflow
248
233
  const pubResult = patchPublishWorkflow(root, options.name);
249
- if (pubResult.patched) patched.push("publish.yml (prepack + publish step)");
234
+ if (pubResult.patched) patched.push("publish.yml (prepack)");
250
235
  else skipped.push(`publish.yml: ${pubResult.reason}`);
251
236
 
252
237
  // 4. Dockerfiles
@@ -43,6 +43,13 @@ vi.mock("../plugins", () => ({
43
43
  resolveProjectLexicons: (...a: unknown[]) => resolveLexMock(...a),
44
44
  }));
45
45
  const observeMock = vi.fn();
46
+ const replayMock = vi.fn();
47
+ const hasSnapshotMock = vi.fn((..._a: unknown[]) => Promise.resolve(false));
48
+ vi.mock("../../lifecycle/replay", () => ({
49
+ replaySnapshots: (...a: unknown[]) => replayMock(...a),
50
+ hasSnapshot: (...a: unknown[]) => hasSnapshotMock(...a),
51
+ }));
52
+
46
53
  vi.mock("../../lifecycle/observe", () => ({
47
54
  observeResources: (...a: unknown[]) => observeMock(...a),
48
55
  }));
@@ -435,6 +442,73 @@ describe("runGraph", () => {
435
442
  expect(out).toContain("web-vpc");
436
443
  });
437
444
 
445
+ // #1279 — `graph` had no `--at`, so anyone wanting the raw IR of a recorded
446
+ // estate had to reach for the live endpoint. A snapshot could answer most
447
+ // questions and never all of them.
448
+ test("--at graphs the recorded snapshot without reading the estate", async () => {
449
+ resolveLexMock.mockResolvedValue(["aws"]);
450
+ loadPluginsMock.mockResolvedValue([
451
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}),
452
+ enrichLiveAttrs: () => Promise.reject(new Error("must not be called on a replay")) },
453
+ ]);
454
+ replayMock.mockResolvedValue({
455
+ observations: [{ lexicon: "aws", resources: {
456
+ web: { type: "AWS::EC2::Instance", status: "OBSERVED", physicalId: "i-1" },
457
+ } }],
458
+ commit: "abc1234def",
459
+ timestamp: "2026-07-31T00:00:00.000Z",
460
+ });
461
+ const exit = await runGraph({ args: makeArgs({ format: "ir", at: "latest", env: "prod" }), plugins: [], serializers: [] });
462
+ expect(exit).toBe(0);
463
+ expect(observeMock).not.toHaveBeenCalled();
464
+ expect(replayMock).toHaveBeenCalled();
465
+ expect(stdoutBuf.join("\n")).toContain("i-1");
466
+ });
467
+
468
+ // Denied the network, agents read the empty graph as an empty estate and
469
+ // spent their turns retrying --live. The per-entity warnings describe the
470
+ // same failure N times and never name the thing that would answer.
471
+ test("an unreadable estate names the recorded snapshot", async () => {
472
+ observeMock.mockClear();
473
+ hasSnapshotMock.mockResolvedValue(true);
474
+ resolveLexMock.mockResolvedValue(["aws"]);
475
+ loadPluginsMock.mockResolvedValue([
476
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
477
+ ]);
478
+ observeMock.mockResolvedValue({ observations: [], errors: ["could not connect"], warnings: [] });
479
+ const errs: string[] = [];
480
+ const spy = vi.spyOn(console, "error").mockImplementation((s: string) => { errs.push(s); });
481
+ await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
482
+ spy.mockRestore();
483
+ hasSnapshotMock.mockResolvedValue(false);
484
+ expect(errs.join("\n")).toContain("--at latest");
485
+ });
486
+
487
+ test("says nothing about a snapshot when there is none to name", async () => {
488
+ observeMock.mockClear();
489
+ hasSnapshotMock.mockResolvedValue(false);
490
+ resolveLexMock.mockResolvedValue(["aws"]);
491
+ loadPluginsMock.mockResolvedValue([
492
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
493
+ ]);
494
+ observeMock.mockResolvedValue({ observations: [], errors: ["could not connect"], warnings: [] });
495
+ const errs: string[] = [];
496
+ const spy = vi.spyOn(console, "error").mockImplementation((s: string) => { errs.push(s); });
497
+ await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
498
+ spy.mockRestore();
499
+ expect(errs.join("\n")).not.toContain("--at latest");
500
+ });
501
+
502
+ test("--at and --live together is refused rather than guessed at", async () => {
503
+ observeMock.mockClear();
504
+ replayMock.mockClear();
505
+ resolveLexMock.mockResolvedValue(["aws"]);
506
+ const exit = await runGraph({ args: makeArgs({ format: "ir", at: "latest", live: true, env: "prod" }), plugins: [], serializers: [] });
507
+ expect(exit).toBe(1);
508
+ expect(observeMock).not.toHaveBeenCalled();
509
+ expect(replayMock).not.toHaveBeenCalled();
510
+ });
511
+
438
512
  // Regression for #57: a single-stack project (no `*.component.ts` files —
439
513
  // discoverComponents returns an empty map) must observe exactly as before,
440
514
  // no `stacks` collected.
@@ -6,6 +6,7 @@ import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourc
6
6
  import { buildDeclaredPerStack } from "../../graph-declared";
7
7
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
8
8
  import { observeResources } from "../../lifecycle/observe";
9
+ import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
9
10
  import { loadChantConfig, environmentNames } from "../../config";
10
11
  import { applyLiveEndpoint } from "../../live-endpoint";
11
12
  import { applyDetail, type DetailLevel } from "../../graph-detail";
@@ -47,9 +48,18 @@ export async function runGraph(ctx: CommandContext): Promise<number> {
47
48
  }));
48
49
  return 1;
49
50
  }
51
+ // `--at` graphs a recorded observation instead of reading the estate (#1279).
52
+ // Two different observations with no rule for which wins, so not both.
53
+ if (ctx.args.at && ctx.args.live) {
54
+ console.error(formatError({
55
+ message: "chant graph takes --live or --at, not both",
56
+ hint: "--live reads the estate now; --at graphs a recorded snapshot",
57
+ }));
58
+ return 1;
59
+ }
50
60
  // `--live` graphs the provisioned (observed) infrastructure, not the declared
51
61
  // source (epic #776). It only makes sense as a view format; default to `ir`.
52
- if (ctx.args.live) {
62
+ if (ctx.args.live || ctx.args.at) {
53
63
  return runGraphLive(ctx, isViewFormat ? (ctx.args.format as (typeof viewFormats)[number]) : "ir");
54
64
  }
55
65
  if (isViewFormat) {
@@ -136,50 +146,81 @@ async function runGraphLive(
136
146
  }
137
147
  }
138
148
  } else {
139
- console.error(formatWarning({ message: "component discovery failed — observing the single-stack convention instead" }));
140
- }
141
- // ChantConfig.stacks (with optional per-stack region) — multi-region estates.
142
- for (const declared of config.stacks ?? []) {
143
- if (!seenStacks.has(declared.name)) { seenStacks.add(declared.name); stacks.push({ name: declared.name, region: declared.region, src: declared.src }); }
144
- }
149
+ console.error(formatWarning({ message: "component discovery failed — observing the single-stack convention instead" }));
150
+ }
151
+ // ChantConfig.stacks (with optional per-stack region) — multi-region estates.
152
+ for (const declared of config.stacks ?? []) {
153
+ if (!seenStacks.has(declared.name)) { seenStacks.add(declared.name); stacks.push({ name: declared.name, region: declared.region, src: declared.src }); }
154
+ }
145
155
 
146
- // #1166 — an environment can declare its own endpoint (a local emulator like
147
- // Floci), so this read is self-sufficient even when the ambient shell never
148
- // exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
149
- // Scoped to just this describe/enrich pass — restored in `finally` so it
150
- // never leaks into a later invocation in the same process.
151
- const endpointResult = applyLiveEndpoint(config.environments, environment, observing.map((p) => p.name));
152
- if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
156
+ // #1166 — an environment can declare its own endpoint (a local emulator like
157
+ // Floci), so this read is self-sufficient even when the ambient shell never
158
+ // exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
159
+ // Scoped to just this describe/enrich pass — restored in `finally` so it
160
+ // never leaks into a later invocation in the same process.
161
+ const endpointResult = applyLiveEndpoint(config.environments, environment, observing.map((p) => p.name));
162
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
163
+
164
+ let ir: GraphIR;
165
+ let observations: LiveObservation[];
166
+ // A replay is a graph of what was recorded, so it must not reach the provider
167
+ // at all — not for observation and not for enrichment. Anything that quietly
168
+ // called out would make `--at` a live read with an older label on it.
169
+ const replaying = !!args.at && !args.live;
170
+ try {
171
+ if (replaying) {
172
+ const scoped = new Set(stacks.filter((st) => st.src).map((st) => st.name));
173
+ const replay = await replaySnapshots(environment, String(args.at), scoped);
174
+ if ("error" in replay) {
175
+ console.error(formatError({ message: replay.error, ...(replay.hint ? { hint: replay.hint } : {}) }));
176
+ return 1;
177
+ }
178
+ observations = replay.observations;
179
+ console.error(formatWarning({
180
+ message: `graphing snapshot ${replay.commit.slice(0, 7)} taken ${replay.timestamp} — the estate was not read`,
181
+ }));
182
+ } else {
183
+ const observeResult = await observeResources(environment, observing, buildResult, {
184
+ owned: true,
185
+ stacks: [...stacks],
186
+ });
187
+ observations = observeResult.observations;
188
+ const { errors, warnings } = observeResult;
189
+ for (const e of errors) console.error(formatWarning({ message: e }));
190
+ // Unobserved entities (#1089) arrive as warnings — a node missing from the
191
+ // live graph because nobody looked is a different fact from one that isn't
192
+ // deployed, and the diagram alone cannot say which. Capped: an estate with no
193
+ // ownership markers can produce one per declared entity, and a wall of them
194
+ // buries the graph output. The full list is `lifecycle diff --live`.
195
+ const WARN_CAP = 5;
196
+ for (const w of warnings.slice(0, WARN_CAP)) console.error(formatWarning({ message: w }));
197
+ if (warnings.length > WARN_CAP) {
198
+ console.error(formatWarning({
199
+ message: `... and ${warnings.length - WARN_CAP} more entity(ies) not observed — run \`chant lifecycle diff ${environment} --live\` for the full list`,
200
+ }));
201
+ }
202
+ }
153
203
 
154
- let ir: GraphIR;
155
- let observations: LiveObservation[];
156
- try {
157
- const observeResult = await observeResources(environment, observing, buildResult, {
158
- owned: true,
159
- stacks: [...stacks],
160
- });
161
- observations = observeResult.observations;
162
- const { errors, warnings } = observeResult;
163
- for (const e of errors) console.error(formatWarning({ message: e }));
164
- // Unobserved entities (#1089) arrive as warnings — a node missing from the
165
- // live graph because nobody looked is a different fact from one that isn't
166
- // deployed, and the diagram alone cannot say which. Capped: an estate with no
167
- // ownership markers can produce one per declared entity, and a wall of them
168
- // buries the graph output. The full list is `lifecycle diff --live`.
169
- const WARN_CAP = 5;
170
- for (const w of warnings.slice(0, WARN_CAP)) console.error(formatWarning({ message: w }));
171
- if (warnings.length > WARN_CAP) {
204
+ // Both paths land here: a replay rejoins the live pipeline at exactly this
205
+ // point, which is what makes every fold and overlay below shared.
206
+ ir = buildLiveGraphIr(observations);
207
+
208
+ // The estate was asked for and nothing came back. Say that once, and say
209
+ // that a recording exists — the per-entity warnings above describe the same
210
+ // failure N times without ever naming the thing that would answer. Agents
211
+ // denied the network read the empty graph as an empty estate and spent
212
+ // their turns retrying `--live`.
213
+ if (!replaying && ir.nodes.length === 0 && (await hasSnapshot(environment))) {
172
214
  console.error(formatWarning({
173
- message: `... and ${warnings.length - WARN_CAP} more entity(ies) not observed run \`chant lifecycle diff ${environment} --live\` for the full list`,
215
+ message: `could not read the estate for "${environment}" a snapshot of it is recorded; graph that instead with --at latest`,
174
216
  }));
175
217
  }
176
218
 
177
- ir = buildLiveGraphIr(observations);
178
-
179
219
  // Enrich node attrs from the fuller live config (#784) so references are
180
220
  // present for edge reconstruction — describeResources metadata alone is often
181
221
  // too thin (e.g. AWS returns stack outputs, not per-resource references).
182
- for (const p of observing) {
222
+ // Live-only: enrichment is a provider call, so a replay must skip it.
223
+ for (const p of replaying ? [] : observing) {
183
224
  if (!p.enrichLiveAttrs) continue;
184
225
  try {
185
226
  const enriched = await p.enrichLiveAttrs({ environment, owned: true, stacks });
@@ -734,6 +734,92 @@ describe("runLifecycleSnapshot", () => {
734
734
  expect(takeSnapshotMock).toHaveBeenCalledTimes(1);
735
735
  });
736
736
 
737
+ // #1261 — each stack declares the region it deploys to. Dropping it here
738
+ // observed every stack against the ambient region, so a multi-region estate
739
+ // snapshotted only the stacks that shared it and reported the rest as
740
+ // "no valid resources or artifacts returned".
741
+ test("multi-stack: each stack's declared region reaches takeSnapshot", async () => {
742
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
743
+ takeSnapshotMock.mockResolvedValue({
744
+ snapshots: [{ lexicon: "aws", environment: "prod", resources: { bucket: meta() } }],
745
+ commit: "sha",
746
+ warnings: [],
747
+ errors: [],
748
+ });
749
+ loadChantConfigMock.mockResolvedValue({ config: { environments: ["prod"], stacks: [
750
+ { name: "app-us-east-1", src: "src/us-east-1", region: "us-east-1" },
751
+ { name: "app-us-west-2", src: "src/us-west-2", region: "us-west-2" },
752
+ ] } });
753
+ const plugins: LexiconPlugin[] = [
754
+ createMockPlugin({ name: "aws", describeResources: staticDescribeResources({ bucket: meta() }) }),
755
+ ];
756
+
757
+ const exit = await runLifecycleSnapshot({
758
+ args: makeArgs({ command: "state", path: "snapshot", extraPositional: "prod" }),
759
+ plugins,
760
+ serializers: plugins.map((p) => p.serializer),
761
+ });
762
+
763
+ expect(exit).toBe(0);
764
+ // toMatchObject, not toEqual: this test is about region reaching the
765
+ // snapshot, and pinning the whole options object makes it fail whenever an
766
+ // unrelated option is added.
767
+ const opts = takeSnapshotMock.mock.calls.map((c) => c[3]);
768
+ expect(opts[0]).toMatchObject({ stack: "app-us-east-1", region: "us-east-1" });
769
+ expect(opts[1]).toMatchObject({ stack: "app-us-west-2", region: "us-west-2" });
770
+ });
771
+
772
+ // #1267 — --deep is opt-in and reaches takeSnapshot; without it the snapshot
773
+ // stays thin, which is what every pre-#1267 snapshot was.
774
+ test("--deep reaches takeSnapshot; absent means identity", async () => {
775
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
776
+ takeSnapshotMock.mockResolvedValue({
777
+ snapshots: [{ lexicon: "aws", environment: "prod", resources: { bucket: meta() } }],
778
+ commit: "sha",
779
+ warnings: [],
780
+ errors: [],
781
+ });
782
+ const plugins: LexiconPlugin[] = [
783
+ createMockPlugin({ name: "aws", describeResources: staticDescribeResources({ bucket: meta() }) }),
784
+ ];
785
+ const ctx = (deep?: boolean) => ({
786
+ args: makeArgs({ command: "state", path: "snapshot", extraPositional: "prod", ...(deep ? { deep: true } : {}) }),
787
+ plugins,
788
+ serializers: plugins.map((p) => p.serializer),
789
+ });
790
+
791
+ await runLifecycleSnapshot(ctx(true));
792
+ expect(takeSnapshotMock.mock.calls[0][3]).toMatchObject({ deep: true });
793
+
794
+ takeSnapshotMock.mockClear();
795
+ await runLifecycleSnapshot(ctx());
796
+ expect(takeSnapshotMock.mock.calls[0][3]).toMatchObject({ deep: undefined });
797
+ });
798
+
799
+ test("stack without a declared region: region stays undefined", async () => {
800
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
801
+ takeSnapshotMock.mockResolvedValue({
802
+ snapshots: [{ lexicon: "aws", environment: "prod", resources: { bucket: meta() } }],
803
+ commit: "sha",
804
+ warnings: [],
805
+ errors: [],
806
+ });
807
+ loadChantConfigMock.mockResolvedValue({ config: { environments: ["prod"], stacks: [
808
+ { name: "app", src: "src/app" },
809
+ ] } });
810
+ const plugins: LexiconPlugin[] = [
811
+ createMockPlugin({ name: "aws", describeResources: staticDescribeResources({ bucket: meta() }) }),
812
+ ];
813
+
814
+ await runLifecycleSnapshot({
815
+ args: makeArgs({ command: "state", path: "snapshot", extraPositional: "prod" }),
816
+ plugins,
817
+ serializers: plugins.map((p) => p.serializer),
818
+ });
819
+
820
+ expect(takeSnapshotMock.mock.calls[0][3]).toMatchObject({ stack: "app", region: undefined });
821
+ });
822
+
737
823
  // #1166 — a snapshot is always a live read, so a declared environment
738
824
  // endpoint applies here too, unless the ambient shell already set it.
739
825
  describe("declared endpoint (#1166)", () => {