@intentius/chant 0.32.0 → 0.33.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.
@@ -1,9 +1,25 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { observeResources } from "./observe";
3
3
  import { observation } from "../observation";
4
4
  import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
5
5
  import type { BuildResult } from "../build";
6
6
 
7
+ // The per-stack scoped build (#1162) resolves each stack's `src` through the
8
+ // real `build`. Mock it so the test controls what each src yields — a stack
9
+ // whose src is scoped reports BARE entity names, matching the deployed
10
+ // LogicalResourceIds, not the whole-project build's disambiguated names.
11
+ const scopedBuilds: Record<string, string[]> = {};
12
+ vi.mock("../build", () => ({
13
+ build: async (src: string): Promise<BuildResult> => {
14
+ const names = scopedBuilds[src] ?? [];
15
+ return {
16
+ outputs: new Map<string, string>([["aws", "{}"]]),
17
+ entities: new Map(names.map((n) => [n, { lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} }])),
18
+ errors: [],
19
+ } as unknown as BuildResult;
20
+ },
21
+ }));
22
+
7
23
  function mockBuild(): BuildResult {
8
24
  return {
9
25
  outputs: new Map<string, string>([["aws", "{}"]]),
@@ -154,7 +170,11 @@ describe("observeResources", () => {
154
170
  awsPlugin((opts) => {
155
171
  const stack = (opts as { stack?: string }).stack;
156
172
  calls.push(stack);
157
- // Different resources per stack — the multi-stack, per-component case.
173
+ // Different resources per stack — the multi-stack, per-component case
174
+ // (#57 loomster). Bare-string stacks keep BARE ids (no `src` scope), so
175
+ // the union is `db-a`+`db-b`, not stack-qualified: per-component ids are
176
+ // already unique and behold reads them bare. Qualification is a scoped
177
+ // (`src`) feature — see the per-stack src test below.
158
178
  const resources: Record<string, ResourceMetadata> =
159
179
  stack === "s1"
160
180
  ? { "db-a": { type: "AWS::RDS::DBInstance", status: "AVAILABLE" } }
@@ -169,6 +189,50 @@ describe("observeResources", () => {
169
189
  expect(Object.keys(observations[0].resources).sort()).toEqual(["db-a", "db-b"]);
170
190
  });
171
191
 
192
+ it("with per-stack src (#1162): describeResources gets each stack's SCOPED bare names, not the whole-project build's names", async () => {
193
+ const { resolve } = await import("node:path");
194
+ // The whole-project build disambiguates colliding names by module path;
195
+ // each stack's scoped src reports the bare names it actually deploys.
196
+ scopedBuilds[resolve("east/src")] = ["server", "vpc"];
197
+ scopedBuilds[resolve("west/src")] = ["server", "vpc"];
198
+
199
+ const seen: Record<string, string[]> = {};
200
+ const plugins = [
201
+ awsPlugin((opts) => {
202
+ const o = opts as { stack?: string; entityNames: string[] };
203
+ seen[o.stack ?? "?"] = o.entityNames;
204
+ // Echo one resource keyed by a bare name the deployed stack owns.
205
+ return { server: { type: "AWS::EC2::Instance", status: "AVAILABLE", physicalId: `i-${o.stack}` } };
206
+ }),
207
+ ];
208
+ // The whole-project buildResult carries DISAMBIGUATED names — proving the
209
+ // scoped path overrides them rather than falling through to these.
210
+ const wholeProject = {
211
+ outputs: new Map<string, string>([["aws", "{}"]]),
212
+ entities: new Map([
213
+ ["EastServer", { lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} }],
214
+ ["WestServer", { lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} }],
215
+ ]),
216
+ errors: [],
217
+ } as unknown as BuildResult;
218
+
219
+ const { observations } = await observeResources("floci", plugins, wholeProject, {
220
+ stacks: [
221
+ { name: "east", src: "east/src" },
222
+ { name: "west", src: "west/src" },
223
+ ],
224
+ });
225
+
226
+ // Each stack saw its own scoped bare names (matching deployed ids).
227
+ expect(seen["east"]).toEqual(["server", "vpc"]);
228
+ expect(seen["west"]).toEqual(["server", "vpc"]);
229
+ // Observed nodes are stack-qualified, so the colliding `server` id is
230
+ // distinct per stack and each carries its own physical id.
231
+ expect(Object.keys(observations[0].resources).sort()).toEqual(["east::server", "west::server"]);
232
+ expect(observations[0].resources["east::server"].physicalId).toBe("i-east");
233
+ expect(observations[0].resources["west::server"].physicalId).toBe("i-west");
234
+ });
235
+
172
236
  it("with an empty stacks array — falls back to the single unstacked call", async () => {
173
237
  const calls: Array<{ stack?: string }> = [];
174
238
  const plugins = [
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import type { ObservationLexicon } from "../lexicon";
13
13
  import type { BuildResult } from "../build";
14
+ import { build as buildProject } from "../build";
15
+ import { resolve as resolvePath } from "node:path";
14
16
  import type { SerializerResult } from "../serializer";
15
17
  import type { LiveObservation } from "../graph-ir";
16
18
  import {
@@ -28,6 +30,19 @@ export interface ObserveResult {
28
30
  errors: string[];
29
31
  }
30
32
 
33
+ /**
34
+ * Re-key a normalized observation's entities by `${stack}::${id}` (#1162) so a
35
+ * bare LogicalResourceId shared across stacks (e.g. `vpc`) stays unambiguous
36
+ * once the per-stack results are merged. The declared canvas qualifies the same
37
+ * way (`buildDeclaredPerStack`), so the overlay join lines up. Applies to both
38
+ * the OBSERVED-PRESENT and NOT-OBSERVED maps of the tri-state (#1089).
39
+ */
40
+ function qualifyObservation(obs: NormalizedObservation, stackName: string): NormalizedObservation {
41
+ const q = <T>(m: Record<string, T>): Record<string, T> =>
42
+ Object.fromEntries(Object.entries(m).map(([k, v]) => [`${stackName}::${k}`, v]));
43
+ return { resources: q(obs.resources), unobserved: q(obs.unobserved) };
44
+ }
45
+
31
46
  /**
32
47
  * Query every plugin that implements `describeResources` for its resources in
33
48
  * `environment`. `owned` (default true for the managed-only diagram, epic #776)
@@ -44,20 +59,39 @@ export interface ObserveResult {
44
59
  * absent an explicit `stack`) queries a stack that simply doesn't exist there,
45
60
  * so the single-call path always observes zero nodes. When `stacks` is
46
61
  * present and non-empty, each observing plugin's `describeResources` is
47
- * called once per stack (same `environment`/`entities`/`entityNames`, only
48
- * `stack` varies) and the returned resource maps are unioned a resource
49
- * appears under whichever stack contains its logical id. When `stacks` is
50
- * absent or empty, behavior is exactly the single call of before (no `stack`
51
- * key at all), so a single-stack project is unaffected.
62
+ * called once per stack and the returned observations are merged. A stack entry
63
+ * may be a bare name or `{ name, region?, src? }` (#1162): `src` is built
64
+ * SCOPED so the deployed BARE LogicalResourceIds match (the whole-project build
65
+ * disambiguates colliding names to `UsWest1Src…`, which the live ids never
66
+ * carry), and a scoped stack's observed ids are qualified `${stack}::${id}` so
67
+ * the same bare id in two stacks stays distinct. A bare-string stack keeps its
68
+ * bare ids and the tri-state merge (#57). When `stacks` is absent or empty, behavior is
69
+ * exactly the single call of before (no `stack` key at all), so a single-stack
70
+ * project is unaffected.
52
71
  */
53
72
  export async function observeResources(
54
73
  environment: string,
55
74
  plugins: ObservationLexicon[],
56
75
  buildResult: BuildResult,
57
- opts?: { owned?: boolean; stacks?: string[] },
76
+ opts?: { owned?: boolean; stacks?: Array<string | { name: string; region?: string; src?: string }> },
58
77
  ): Promise<ObserveResult> {
59
78
  const owned = opts?.owned ?? true;
60
- const stacks = opts?.stacks ?? [];
79
+ const stacks = (opts?.stacks ?? []).map((st) => (typeof st === "string" ? { name: st } : st));
80
+ // A stack's `src` (multi-stack, #1162) is built SCOPED to recover that stack's
81
+ // BARE entity names — the names it actually deploys. Matching deployed bare
82
+ // LogicalResourceIds against the whole-project build's DISAMBIGUATED names
83
+ // (UsWest1Src…) misses every colliding resource. Cached per src.
84
+ const serializers = plugins.map((p) => p.serializer);
85
+ const scopedBuildCache = new Map<string, BuildResult>();
86
+ const scopedBuild = async (src: string): Promise<BuildResult> => {
87
+ const key = resolvePath(src);
88
+ let r = scopedBuildCache.get(key);
89
+ if (!r) {
90
+ r = await buildProject(key, serializers);
91
+ scopedBuildCache.set(key, r);
92
+ }
93
+ return r;
94
+ };
61
95
  const observations: LiveObservation[] = [];
62
96
  const warnings: string[] = [];
63
97
  const errors: string[] = [];
@@ -91,18 +125,45 @@ export async function observeResources(
91
125
  if (stacks.length > 0) {
92
126
  const parts: NormalizedObservation[] = [];
93
127
  for (const stack of stacks) {
94
- parts.push(
95
- normalizeObservation(
96
- await plugin.describeResources({
97
- environment,
98
- buildOutput,
99
- entityNames,
100
- entities,
101
- owned,
102
- stack,
103
- }),
104
- ),
128
+ // Use this stack's scoped build (bare entity names) when it has a src,
129
+ // so describeResources matches the deployed bare LogicalResourceIds.
130
+ let stackEntityNames = entityNames;
131
+ let stackBuildOutput = buildOutput;
132
+ let stackEntities = entities;
133
+ if (stack.src) {
134
+ const sb = await scopedBuild(stack.src);
135
+ stackEntityNames = [];
136
+ stackEntities = new Map();
137
+ for (const [name, entity] of sb.entities) {
138
+ if (entity.lexicon !== plugin.name) continue;
139
+ stackEntityNames.push(name);
140
+ stackEntities.set(name, {
141
+ entityType: entity.entityType,
142
+ props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
143
+ });
144
+ }
145
+ const raw = sb.outputs.get(plugin.name);
146
+ stackBuildOutput = raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary;
147
+ }
148
+ const norm = normalizeObservation(
149
+ await plugin.describeResources({
150
+ environment,
151
+ buildOutput: stackBuildOutput,
152
+ entityNames: stackEntityNames,
153
+ entities: stackEntities,
154
+ owned,
155
+ stack: stack.name,
156
+ region: stack.region,
157
+ }),
105
158
  );
159
+ // Qualify ids by stack ONLY for a scoped (`src`) stack (#1162): that
160
+ // is the multi-region case where the SAME bare LogicalResourceId
161
+ // (e.g. `vpc`) exists in every stack, so a bare union would collide.
162
+ // A bare-string stack (#57 loomster) has unique per-component ids and
163
+ // is asked the whole-project entity set, so it keeps the bare-id
164
+ // tri-state merge (present > not-observed > absent) that behold and
165
+ // other consumers read.
166
+ parts.push(stack.src ? qualifyObservation(norm, stack.name) : norm);
106
167
  }
107
168
  observed = mergeObservations(parts);
108
169
  } else {