@intentius/chant 0.33.1 → 0.34.1

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 (51) 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/publish-order.test.ts +133 -0
  38. package/src/codegen/release-wiring.test.ts +92 -0
  39. package/src/graph-ir-live.test.ts +83 -0
  40. package/src/graph-ir.ts +51 -1
  41. package/src/graph-refs.test.ts +59 -0
  42. package/src/graph-refs.ts +39 -8
  43. package/src/lexicon.ts +145 -0
  44. package/src/lifecycle/deep-observe.ts +5 -0
  45. package/src/lifecycle/live-diff.test.ts +38 -0
  46. package/src/lifecycle/live-diff.ts +45 -2
  47. package/src/lifecycle/observe.ts +186 -4
  48. package/src/lifecycle/replay.ts +141 -0
  49. package/src/lifecycle/snapshot.test.ts +179 -0
  50. package/src/lifecycle/snapshot.ts +88 -3
  51. package/src/lifecycle/types.ts +47 -0
package/src/lexicon.ts CHANGED
@@ -11,6 +11,7 @@ import type { DriverComponent } from "./components/driver";
11
11
  import type { EmulatorCapability } from "./op/emulator-lifecycle";
12
12
  import type { RuleMeta } from "./audit/catalog";
13
13
  import type { ReferenceCatalog } from "./graph-refs";
14
+ import type { IREdge } from "./graph-ir";
14
15
  import type { DescribeResourcesResult } from "./observation";
15
16
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
16
17
  import type { OwnerChainVerdict } from "./owner-chain";
@@ -24,6 +25,10 @@ export type { CommandGroup, CommandGroupCommand, CommandGroupContext } from "./c
24
25
  // `@intentius/chant/lexicon` entry they import the plugin contract from.
25
26
  export type { ReferenceCatalog, IdentityRule, RefRule } from "./graph-refs";
26
27
 
28
+ // An observation can report relationships (#1271/#1273), so a lexicon needs the
29
+ // edge type from the same entry it imports the plugin contract from.
30
+ export type { IREdge } from "./graph-ir";
31
+
27
32
  // The observation contract (#1089), re-exported from the same entry so a
28
33
  // lexicon's `describeResources` can report NOT-OBSERVED without a second
29
34
  // import path. Runtime helpers live in `@intentius/chant/observation`.
@@ -641,6 +646,84 @@ export interface LexiconPlugin {
641
646
  * Throwing is the whole-lexicon failure, same as the thin read: core turns it
642
647
  * into `read-failed` for every declared entity.
643
648
  */
649
+ /**
650
+ * Report the undeclared resources this estate *depends on* (#1273), as
651
+ * opposed to the ones it manages.
652
+ *
653
+ * `describeResources` is scoped to what the stack declares. Anything the
654
+ * estate references but does not declare — an account's default VPC route
655
+ * tables, a shared subnet, networking owned by another team — is invisible to
656
+ * it, so it never becomes a node, so no edge can reach it and no fold can
657
+ * traverse it. That is why derived facts about un-modelled topology have had
658
+ * to be computed inside lexicons and injected as attributes.
659
+ *
660
+ * The closure rule is depth one by reference, plus whatever chains this
661
+ * lexicon's {@link referenceCatalog} declares as meaningful — so AWS follows
662
+ * `SubnetId` → `RouteTableId` → `GatewayId` because the catalog says those
663
+ * references matter, not because they happen to be reachable. Without a rule
664
+ * a VPC transitively reaches most of an account.
665
+ *
666
+ * Every returned resource must carry {@link ResourceMetadata.referencedBy},
667
+ * naming the nodes that pulled it in. A dependency with no referrer is
668
+ * unbounded discovery, which is what the closure rule exists to prevent.
669
+ *
670
+ * Optional and additive. A lexicon that does not implement it behaves exactly
671
+ * as before, and a consumer that ignores dependencies sees what it always saw.
672
+ */
673
+ observeDependencies?(options: {
674
+ environment: string;
675
+ /** Declared entities, for a lexicon that resolves references from source. */
676
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
677
+ /** What {@link describeResources} just found — the roots of the closure. */
678
+ observed: Record<string, ResourceMetadata>;
679
+ stack?: string;
680
+ region?: string;
681
+ }): Promise<DependencyObservation>;
682
+
683
+ /**
684
+ * Report resources of a kind this estate manages that exist in the account
685
+ * without being declared or referenced (#1278).
686
+ *
687
+ * `describeResources` answers "what do I manage" and `observeDependencies`
688
+ * answers "what do I rely on". Neither can see a resource that is simply
689
+ * *there* — an unattached security group, an orphaned volume — because both
690
+ * resolve outward from the declared estate and an unused resource is reached
691
+ * by nothing.
692
+ *
693
+ * That is a real question with no other answer: "which of my security groups
694
+ * are unused" cannot be resolved from a state file at all, because a state
695
+ * file knows only what it created. A lexicon that can enumerate a kind can
696
+ * answer it.
697
+ *
698
+ * `kinds` bounds the scan to types the project actually declares, so a
699
+ * project managing security groups is not made to enumerate the account. A
700
+ * lexicon returns resources marked {@link ResourceMetadata.ambient}, and must
701
+ * exclude anything already in `observed` — those are managed, not ambient.
702
+ *
703
+ * Optional and opt-in. A lexicon that does not implement it, or a caller that
704
+ * does not ask, sees exactly what it saw before.
705
+ */
706
+ /**
707
+ * Kinds this lexicon can enumerate beyond the declared estate (#1278).
708
+ *
709
+ * Declared separately from {@link observeAmbient} so a caller can say that
710
+ * ambient resources of a kind are POSSIBLE without paying for a scan to find
711
+ * out. `chant search` uses it to point out that `--ambient` is relevant to
712
+ * the kind just queried — an agent asking which security groups are unused
713
+ * has no way to know that some are not in the answer at all.
714
+ */
715
+ ambientKinds?(): string[];
716
+
717
+ observeAmbient?(options: {
718
+ environment: string;
719
+ /** Entity types the project declares — the bound on what to enumerate. */
720
+ kinds: string[];
721
+ /** Already-observed managed resources, to exclude. */
722
+ observed: Record<string, ResourceMetadata>;
723
+ stack?: string;
724
+ region?: string;
725
+ }): Promise<Record<string, ResourceMetadata>>;
726
+
644
727
  observeResourcesDeep?(options: {
645
728
  environment: string;
646
729
  buildOutput: string;
@@ -648,6 +731,10 @@ export interface LexiconPlugin {
648
731
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
649
732
  /** Deployed stack to observe, for a multi-stack project (see `stacks` in {@link ChantConfig}). */
650
733
  stack?: string;
734
+ /** Region the stack is deployed in (#1267), mirroring the thin path's
735
+ * `describeResources` (#1261). When omitted, an implementation keeps its
736
+ * ambient-region default. */
737
+ region?: string;
651
738
  /** Restrict to chant-owned resources (#119). A lexicon with no marker channel on this path says so. */
652
739
  owned?: boolean;
653
740
  }): Promise<DeepObservationResult>;
@@ -799,6 +886,31 @@ export type ExportedTemplate = TemplateIR & {
799
886
  /**
800
887
  * Metadata about a deployed resource, returned by describeResources.
801
888
  */
889
+ /**
890
+ * What a lexicon saw outside its declared estate (#1273): the resources
891
+ * something declared references, and how they connect.
892
+ *
893
+ * Kept as its own result rather than folded into `describeResources` so every
894
+ * consumer downstream can tell "what I manage" from "what I depend on" without
895
+ * disentangling them — the same reason the change set separates `orphan` from
896
+ * `runtimeChildren`.
897
+ */
898
+ export interface DependencyObservation {
899
+ /**
900
+ * Undeclared resources, keyed by a stable id. Physical id is the natural
901
+ * choice: these have no logical name, and the key has to match between a live
902
+ * read and a snapshot replay or an overlay double-counts them.
903
+ */
904
+ resources: Record<string, ResourceMetadata>;
905
+ /**
906
+ * Relationships among the dependencies and back to the declared nodes that
907
+ * reached them. Reported rather than reconstructed, because a reference
908
+ * catalog can only resolve what it has an identity index for, and part of the
909
+ * point here is expressing a hop the catalog does not model.
910
+ */
911
+ edges?: IREdge[];
912
+ }
913
+
802
914
  export interface ResourceMetadata {
803
915
  /** Entity type (e.g. AWS::S3::Bucket, K8s::Apps::Deployment) */
804
916
  type: string;
@@ -832,6 +944,39 @@ export interface ResourceMetadata {
832
944
  * undeclared live resource stays `orphan`.
833
945
  */
834
946
  ownerChain?: OwnerChainVerdict;
947
+ /**
948
+ * The declared node ids that reference this resource (#1273), set on an
949
+ * undeclared resource observed only because something declared points at it —
950
+ * the account's default VPC route table an instance routes through, a shared
951
+ * subnet, a network someone else owns.
952
+ *
953
+ * A third kind of observed-but-undeclared resource, beside {@link ownership}
954
+ * and {@link ownerChain}. Not an orphan: nobody is going to adopt or delete
955
+ * the account's default VPC, so offering it as a delete candidate is wrong.
956
+ * Not a runtime child either — nothing declared created it. It gets a runtime
957
+ * child's drift treatment for the same reason: it is not yours, it changes on
958
+ * its own, and alerting on it is noise.
959
+ *
960
+ * Carries the referrers rather than a bare flag so the reason a resource was
961
+ * pulled in is answerable, and so a closure can be walked back to the declared
962
+ * entity that justified it.
963
+ */
964
+ referencedBy?: string[];
965
+ /**
966
+ * Observed in the account, of a kind this estate manages, but neither
967
+ * declared nor referenced by anything declared (#1278).
968
+ *
969
+ * The third and last category of observed-but-undeclared resource, after
970
+ * {@link ownerChain}'s runtime children and {@link referencedBy}'s
971
+ * dependencies. A default security group AWS creates per VPC is the type
972
+ * case: nothing declares it, nothing points at it, and it is exactly what
973
+ * "which of my security groups are unused" is asking about.
974
+ *
975
+ * Opt-in, because finding these means asking the provider what exists rather
976
+ * than resolving out from what is declared, which is a broader read and a
977
+ * different claim about what an observation is.
978
+ */
979
+ ambient?: boolean;
835
980
  }
836
981
 
837
982
  /**
@@ -39,6 +39,10 @@ export interface DeepObserveOptions {
39
39
  entities: DeclaredEntities;
40
40
  /** Deployed stack for a multi-stack project (#932). */
41
41
  stack?: string;
42
+ /** Region that stack is deployed in (#1267). Same contract as the thin path
43
+ * (#1261): a multi-region estate reads each stack in its own region, not in
44
+ * whichever one the shell is set to. */
45
+ region?: string;
42
46
  /** Component projects deploy one stack per component; read them all and merge. */
43
47
  componentStacks?: string[];
44
48
  owned?: boolean;
@@ -80,6 +84,7 @@ export async function observeDeep(
80
84
  buildOutput: opts.buildOutput,
81
85
  entityNames,
82
86
  entities: opts.entities,
87
+ ...(opts.region ? { region: opts.region } : {}),
83
88
  ...(opts.owned !== undefined ? { owned: opts.owned } : {}),
84
89
  };
85
90
  try {
@@ -336,3 +336,41 @@ describe("diffSnapshots (#822)", () => {
336
336
  expect(d.added).toEqual(["a", "b"]);
337
337
  });
338
338
  });
339
+
340
+ describe("attribute comparison is key-order insensitive (#1279)", () => {
341
+ const inst = (attributes: Record<string, unknown>): ResourceMetadata => ({
342
+ type: "AWS::EC2::Instance",
343
+ status: "OBSERVED",
344
+ physicalId: "i-1",
345
+ attributes,
346
+ });
347
+
348
+ test("does not drift when a provider reorders the keys of a nested object", () => {
349
+ // Exactly what AWS did between two reads of the same untouched instance.
350
+ const result = diffLive({
351
+ declared: new Set(["one"]),
352
+ observedNow: { one: inst({ Placement: { Tenancy: "default", AvailabilityZone: "us-east-1c" } }) },
353
+ observedThen: { one: inst({ Placement: { AvailabilityZone: "us-east-1c", Tenancy: "default" } }) },
354
+ });
355
+ expect(result.driftedSinceSnapshot).toEqual([]);
356
+ expect(result.unchanged).toEqual(["one"]);
357
+ });
358
+
359
+ test("still reports a real change to a nested value", () => {
360
+ const result = diffLive({
361
+ declared: new Set(["one"]),
362
+ observedNow: { one: inst({ Placement: { AvailabilityZone: "us-east-1d" } }) },
363
+ observedThen: { one: inst({ Placement: { AvailabilityZone: "us-east-1c" } }) },
364
+ });
365
+ expect(result.driftedSinceSnapshot.map((d) => d.changes[0].path)).toEqual(["attributes.Placement"]);
366
+ });
367
+
368
+ test("keeps array order significant — for a list, order is part of the value", () => {
369
+ const result = diffLive({
370
+ declared: new Set(["one"]),
371
+ observedNow: { one: inst({ Rules: [{ p: 80 }, { p: 22 }] }) },
372
+ observedThen: { one: inst({ Rules: [{ p: 22 }, { p: 80 }] }) },
373
+ });
374
+ expect(result.driftedSinceSnapshot).toHaveLength(1);
375
+ });
376
+ });
@@ -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
  }