@intentius/chant 0.18.30 → 0.18.31

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,4 +1,5 @@
1
1
  import type { CommandContext } from "../registry.js";
2
+ import type { Phase } from "../../components/component.js";
2
3
  /**
3
4
  * chant components release <env> --component <name> --digest <sha256:...>
4
5
  * [--git-sha <sha>] [--run-id <id>] [--actor <name>]
@@ -17,6 +18,32 @@ import type { CommandContext } from "../registry.js";
17
18
  * timestamp from the environment/CLI at record time."
18
19
  */
19
20
  export declare function runComponentsReleaseRecord(ctx: CommandContext): Promise<number>;
21
+ /**
22
+ * chant components status [env] [--live] [--json] [--compare-to <env>]
23
+ *
24
+ * Answers "what's built" and "what's deployed where," joined by digest.
25
+ * Without `--live`, reports the release ledger alone (recorded vs unrecorded
26
+ * is meaningless without live evidence, so every recorded row reports
27
+ * `unknown` reconciliation — still useful as a pure ledger read). With
28
+ * `--live`, joins against `lifecycle diff --live` + ownership (reusing
29
+ * `buildChangeSet`, the same classification `lifecycle plan` already
30
+ * trusts) to flag unrecorded deploys and drift.
31
+ *
32
+ * Omitting `<env>` reports every environment that has release records.
33
+ * `--compare-to <env>` answers the single-query cross-environment question
34
+ * the epic names explicitly: "which build is in `<env>`, and is it the one
35
+ * tested in `<compare-to>`."
36
+ */
37
+ /** Every distinct `cfn-deploy` stack name a component's deploy phases target. A
38
+ * step may itself be a nested `Phase`, so walk recursively; a resolved component
39
+ * carries the stack as a concrete string.
40
+ *
41
+ * Exported for `chant graph --live` (../handlers/graph.ts's `runGraphLive`,
42
+ * #57): the same per-component stack resolution this file uses for `chant
43
+ * components status --live` is also how the live graph learns which stacks to
44
+ * observe on a multi-stack, per-component project (`describeResources`'s
45
+ * single-stack-named-after-the-environment convention never matches one). */
46
+ export declare function cfnDeployStacks(deploy: Phase[]): string[];
20
47
  export declare function runComponentsStatus(ctx: CommandContext): Promise<number>;
21
48
  /** Fallback for `chant components` with no/unknown subcommand. */
22
49
  export declare function runComponentsUnknown(ctx: CommandContext): Promise<number>;
@@ -1 +1 @@
1
- {"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/components.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAIlD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAuErF;AAqHD,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAuN9E;AAED,kEAAkE;AAClE,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAM/E"}
1
+ {"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/components.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,OAAO,KAAK,EAAE,KAAK,EAAa,MAAM,4BAA4B,CAAC;AAEnE;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAuErF;AAyCD;;;;;;;;;;;;;;;GAeG;AACH;;;;;;;;6EAQ6E;AAC7E,wBAAgB,eAAe,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,EAAE,CAiBzD;AAwCD,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAuN9E;AAED,kEAAkE;AAClE,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAM/E"}
@@ -1 +1 @@
1
- {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAGlD;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAmBnE"}
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAKlD;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAmBnE"}
@@ -24,8 +24,21 @@ export interface ObserveResult {
24
24
  * marker channel logs and returns everything (its own contract). Plugins that
25
25
  * throw are collected into `errors` and skipped — one failing lexicon never
26
26
  * sinks the whole graph.
27
+ *
28
+ * `stacks` (#57) is for a multi-stack, per-component project (e.g. loomster)
29
+ * where there is no single stack named after the environment — AWS's
30
+ * single-stack convention (`lexicons/aws/src/plugin.ts`'s `describeResources`,
31
+ * absent an explicit `stack`) queries a stack that simply doesn't exist there,
32
+ * so the single-call path always observes zero nodes. When `stacks` is
33
+ * present and non-empty, each observing plugin's `describeResources` is
34
+ * called once per stack (same `environment`/`entities`/`entityNames`, only
35
+ * `stack` varies) and the returned resource maps are unioned — a resource
36
+ * appears under whichever stack contains its logical id. When `stacks` is
37
+ * absent or empty, behavior is exactly the single call of before (no `stack`
38
+ * key at all), so a single-stack project is unaffected.
27
39
  */
28
40
  export declare function observeResources(environment: string, plugins: ObservationLexicon[], buildResult: BuildResult, opts?: {
29
41
  owned?: boolean;
42
+ stacks?: string[];
30
43
  }): Promise<ObserveResult>;
31
44
  //# sourceMappingURL=observe.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"observe.d.ts","sourceRoot":"","sources":["../../src/lifecycle/observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAoB,MAAM,YAAY,CAAC;AACvE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,eAAe,EAAE,CAAC;IAChC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;GAOG;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,CAAA;CAAE,GACzB,OAAO,CAAC,aAAa,CAAC,CA+CxB"}
1
+ {"version":3,"file":"observe.d.ts","sourceRoot":"","sources":["../../src/lifecycle/observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAoB,MAAM,YAAY,CAAC;AACvE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,eAAe,EAAE,CAAC;IAChC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;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,MAAM,EAAE,CAAA;CAAE,GAC5C,OAAO,CAAC,aAAa,CAAC,CAgExB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.18.30",
3
+ "version": "0.18.31",
4
4
  "description": "Declarative infrastructure-as-code toolkit \u2014 TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -194,8 +194,14 @@ interface StatusJsonRow {
194
194
  */
195
195
  /** Every distinct `cfn-deploy` stack name a component's deploy phases target. A
196
196
  * step may itself be a nested `Phase`, so walk recursively; a resolved component
197
- * carries the stack as a concrete string. */
198
- function cfnDeployStacks(deploy: Phase[]): string[] {
197
+ * carries the stack as a concrete string.
198
+ *
199
+ * Exported for `chant graph --live` (../handlers/graph.ts's `runGraphLive`,
200
+ * #57): the same per-component stack resolution this file uses for `chant
201
+ * components status --live` is also how the live graph learns which stacks to
202
+ * observe on a multi-stack, per-component project (`describeResources`'s
203
+ * single-stack-named-after-the-environment convention never matches one). */
204
+ export function cfnDeployStacks(deploy: Phase[]): string[] {
199
205
  const stacks = new Set<string>();
200
206
  const walkSteps = (steps: Phase["steps"]): void => {
201
207
  for (const step of steps) {
@@ -44,6 +44,14 @@ const observeMock = vi.fn();
44
44
  vi.mock("../../lifecycle/observe", () => ({
45
45
  observeResources: (...a: unknown[]) => observeMock(...a),
46
46
  }));
47
+ // `runGraphLive` resolves each component's `cfn-deploy` stack(s) (#57) so a
48
+ // multi-stack, per-component project observes the right stacks; default to
49
+ // "no components" (empty map, no errors) so the existing single-stack --live
50
+ // test is unaffected unless a test opts into a component layout below.
51
+ const discoverComponentsMock = vi.fn();
52
+ vi.mock("../../components/discover", () => ({
53
+ discoverComponents: (...a: unknown[]) => discoverComponentsMock(...a),
54
+ }));
47
55
  vi.mock("../../config", () => ({
48
56
  loadChantConfig: () => Promise.resolve({ config: {} }),
49
57
  }));
@@ -93,6 +101,12 @@ describe("runGraph", () => {
93
101
  lintMock.mockReset();
94
102
  layoutMock.mockReset();
95
103
  componentGraphMock.mockReset();
104
+ discoverComponentsMock.mockReset();
105
+ // Default: no components — the single-stack --live path most tests exercise.
106
+ discoverComponentsMock.mockResolvedValue({ components: new Map(), sourceFiles: [], errors: [] });
107
+ observeMock.mockReset();
108
+ loadPluginsMock.mockReset();
109
+ resolveLexMock.mockReset();
96
110
  });
97
111
 
98
112
  describe("Op graph (default)", () => {
@@ -326,5 +340,83 @@ describe("runGraph", () => {
326
340
  expect(out).not.toContain("No lexicons implement describeResources");
327
341
  expect(out).toContain("web-vpc");
328
342
  });
343
+
344
+ // Regression for #57: a single-stack project (no `*.component.ts` files —
345
+ // discoverComponents returns an empty map) must observe exactly as before,
346
+ // no `stacks` collected.
347
+ test("single-stack project (no components): observeResources gets an empty stacks list", async () => {
348
+ resolveLexMock.mockResolvedValue(["aws"]);
349
+ loadPluginsMock.mockResolvedValue([
350
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
351
+ ]);
352
+ observeMock.mockResolvedValue({ observations: [], errors: [] });
353
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
354
+ expect(exit).toBe(0);
355
+ expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
356
+ owned: true,
357
+ stacks: [],
358
+ });
359
+ });
360
+
361
+ // The bug this branch fixes (#57): a multi-stack, per-component project
362
+ // (loomster/Floci) has no stack literally named after the environment, so
363
+ // the live graph must resolve each component's own `cfn-deploy` stack(s)
364
+ // and pass them through to `observeResources` for the per-stack union.
365
+ test("multi-stack component project: resolves each component's cfn-deploy stack(s) and passes them to observeResources", async () => {
366
+ resolveLexMock.mockResolvedValue(["aws"]);
367
+ loadPluginsMock.mockResolvedValue([
368
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
369
+ ]);
370
+ discoverComponentsMock.mockResolvedValue({
371
+ errors: [],
372
+ sourceFiles: [],
373
+ components: new Map([
374
+ ["loom-db", { component: { name: "loom-db", dependsOn: [], deploy: [
375
+ { phase: "deploy", steps: [{ kind: "cfn-deploy", stack: "loom-local-a-loom-db" }] },
376
+ ] }, exportName: "loomDb", filePath: "components/loom-db.component.ts" }],
377
+ // Multi-stack component: two cfn-deploy steps, nested under a sub-phase.
378
+ ["loom-backend", { component: { name: "loom-backend", dependsOn: ["loom-db"], deploy: [
379
+ { phase: "deploy", steps: [
380
+ { phase: "nested", steps: [{ kind: "cfn-deploy", stack: "loom-local-a-loom-backend" }] },
381
+ { kind: "cfn-deploy", stack: "loom-local-a-loom-backend-jobs" },
382
+ ] },
383
+ ] }, exportName: "loomBackend", filePath: "components/loom-backend.component.ts" }],
384
+ // Non-aws / no cfn-deploy component: contributes no stacks.
385
+ ["loom-frontend", { component: { name: "loom-frontend", dependsOn: [], deploy: [
386
+ { phase: "deploy", steps: [{ kind: "s3-sync" }] },
387
+ ] }, exportName: "loomFrontend", filePath: "components/loom-frontend.component.ts" }],
388
+ ]),
389
+ });
390
+ observeMock.mockResolvedValue({
391
+ observations: [{ lexicon: "aws", resources: { "loom-db": { type: "AWS::RDS::DBInstance", status: "OK" } } }],
392
+ errors: [],
393
+ });
394
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "local" }), plugins: [], serializers: [] });
395
+ expect(exit).toBe(0);
396
+ expect(observeMock).toHaveBeenCalledTimes(1);
397
+ const [, , , opts] = observeMock.mock.calls[0];
398
+ expect((opts as { stacks: string[] }).stacks.sort()).toEqual([
399
+ "loom-local-a-loom-backend",
400
+ "loom-local-a-loom-backend-jobs",
401
+ "loom-local-a-loom-db",
402
+ ]);
403
+ expect((opts as { owned: boolean }).owned).toBe(true);
404
+ });
405
+
406
+ test("component discovery errors: falls back to the single-stack path with a warning", async () => {
407
+ resolveLexMock.mockResolvedValue(["aws"]);
408
+ loadPluginsMock.mockResolvedValue([
409
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
410
+ ]);
411
+ discoverComponentsMock.mockResolvedValue({ errors: [{ message: "bad component" }], sourceFiles: [], components: new Map() });
412
+ observeMock.mockResolvedValue({ observations: [], errors: [] });
413
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
414
+ expect(exit).toBe(0);
415
+ expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
416
+ owned: true,
417
+ stacks: [],
418
+ });
419
+ expect(stderrBuf.join("\n")).toMatch(/component discovery failed/i);
420
+ });
329
421
  });
330
422
  });
@@ -17,6 +17,8 @@ import { readFileSync } from "node:fs";
17
17
  import { formatError, formatWarning, formatBold } from "../format";
18
18
  import type { CommandContext } from "../registry";
19
19
  import { computeComponentGraph } from "../../components/cli-support";
20
+ import { discoverComponents } from "../../components/discover";
21
+ import { cfnDeployStacks } from "./components";
20
22
 
21
23
  /**
22
24
  * `chant graph` — the Op dependency graph by default; `--stacks` renders the
@@ -96,7 +98,31 @@ async function runGraphLive(
96
98
  return 1;
97
99
  }
98
100
 
99
- const { observations, errors } = await observeResources(environment, observing, buildResult, { owned: true });
101
+ // Multi-stack, per-component projects (loomster/Floci, #57): AWS's
102
+ // single-stack convention (`describeResources` defaults to a stack named
103
+ // after the environment, lexicons/aws/src/plugin.ts) never matches a
104
+ // per-component layout (e.g. `loom-local-a-<component>`), so the plain
105
+ // single call always observes zero nodes. Resolve every discovered
106
+ // component's `cfn-deploy` stack(s) — the same walk `chant components
107
+ // status --live` uses (`cfnDeployStacks`, ./components.ts) — and hand them
108
+ // to `observeResources`, which queries `describeResources` once per stack
109
+ // and unions the results. A project with no components (or whose discovery
110
+ // errors) yields no stacks, so `observeResources` falls back to its
111
+ // original single-stack call — unchanged.
112
+ const componentsDiscovery = await discoverComponents(resolve(args.src ?? config.sourceDir ?? "."));
113
+ const stacks = new Set<string>();
114
+ if (componentsDiscovery.errors.length === 0) {
115
+ for (const { component } of componentsDiscovery.components.values()) {
116
+ for (const stack of cfnDeployStacks(component.deploy)) stacks.add(stack);
117
+ }
118
+ } else {
119
+ console.error(formatWarning({ message: "component discovery failed — observing the single-stack convention instead" }));
120
+ }
121
+
122
+ const { observations, errors } = await observeResources(environment, observing, buildResult, {
123
+ owned: true,
124
+ stacks: [...stacks],
125
+ });
100
126
  for (const e of errors) console.error(formatWarning({ message: e }));
101
127
 
102
128
  let ir: GraphIR = buildLiveGraphIr(observations);
@@ -62,4 +62,52 @@ describe("observeResources", () => {
62
62
  const { observations } = await observeResources("prod", [empty, noObserve], mockBuild());
63
63
  expect(observations).toEqual([]);
64
64
  });
65
+
66
+ it("with no stacks: calls describeResources exactly once with no `stack` key (unchanged single-stack path)", async () => {
67
+ const calls: Array<{ stack?: string }> = [];
68
+ const plugins = [
69
+ awsPlugin((opts) => {
70
+ calls.push(opts as { stack?: string });
71
+ return { "web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE" } };
72
+ }),
73
+ ];
74
+ const { observations } = await observeResources("prod", plugins, mockBuild());
75
+ expect(calls).toHaveLength(1);
76
+ expect(calls[0]).not.toHaveProperty("stack");
77
+ expect(Object.keys(observations[0].resources)).toEqual(["web-vpc"]);
78
+ });
79
+
80
+ it("with stacks: [s1, s2] — calls describeResources once per stack and unions the results", async () => {
81
+ const calls: Array<string | undefined> = [];
82
+ const plugins = [
83
+ awsPlugin((opts) => {
84
+ const stack = (opts as { stack?: string }).stack;
85
+ calls.push(stack);
86
+ // Different resources per stack — the multi-stack, per-component case.
87
+ const resources: Record<string, ResourceMetadata> =
88
+ stack === "s1"
89
+ ? { "db-a": { type: "AWS::RDS::DBInstance", status: "AVAILABLE" } }
90
+ : { "db-b": { type: "AWS::RDS::DBInstance", status: "AVAILABLE" } };
91
+ return resources;
92
+ }),
93
+ ];
94
+ const { observations, errors } = await observeResources("prod", plugins, mockBuild(), { stacks: ["s1", "s2"] });
95
+ expect(calls).toEqual(["s1", "s2"]);
96
+ expect(errors).toEqual([]);
97
+ expect(observations).toHaveLength(1);
98
+ expect(Object.keys(observations[0].resources).sort()).toEqual(["db-a", "db-b"]);
99
+ });
100
+
101
+ it("with an empty stacks array — falls back to the single unstacked call", async () => {
102
+ const calls: Array<{ stack?: string }> = [];
103
+ const plugins = [
104
+ awsPlugin((opts) => {
105
+ calls.push(opts as { stack?: string });
106
+ return { "web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE" } };
107
+ }),
108
+ ];
109
+ await observeResources("prod", plugins, mockBuild(), { stacks: [] });
110
+ expect(calls).toHaveLength(1);
111
+ expect(calls[0]).not.toHaveProperty("stack");
112
+ });
65
113
  });
@@ -27,14 +27,27 @@ export interface ObserveResult {
27
27
  * marker channel logs and returns everything (its own contract). Plugins that
28
28
  * throw are collected into `errors` and skipped — one failing lexicon never
29
29
  * sinks the whole graph.
30
+ *
31
+ * `stacks` (#57) is for a multi-stack, per-component project (e.g. loomster)
32
+ * where there is no single stack named after the environment — AWS's
33
+ * single-stack convention (`lexicons/aws/src/plugin.ts`'s `describeResources`,
34
+ * absent an explicit `stack`) queries a stack that simply doesn't exist there,
35
+ * so the single-call path always observes zero nodes. When `stacks` is
36
+ * present and non-empty, each observing plugin's `describeResources` is
37
+ * called once per stack (same `environment`/`entities`/`entityNames`, only
38
+ * `stack` varies) and the returned resource maps are unioned — a resource
39
+ * appears under whichever stack contains its logical id. When `stacks` is
40
+ * absent or empty, behavior is exactly the single call of before (no `stack`
41
+ * key at all), so a single-stack project is unaffected.
30
42
  */
31
43
  export async function observeResources(
32
44
  environment: string,
33
45
  plugins: ObservationLexicon[],
34
46
  buildResult: BuildResult,
35
- opts?: { owned?: boolean },
47
+ opts?: { owned?: boolean; stacks?: string[] },
36
48
  ): Promise<ObserveResult> {
37
49
  const owned = opts?.owned ?? true;
50
+ const stacks = opts?.stacks ?? [];
38
51
  const observations: LiveObservation[] = [];
39
52
  const warnings: string[] = [];
40
53
  const errors: string[] = [];
@@ -64,13 +77,29 @@ export async function observeResources(
64
77
  }
65
78
 
66
79
  try {
67
- const resources: Record<string, ResourceMetadata> = await plugin.describeResources({
68
- environment,
69
- buildOutput,
70
- entityNames,
71
- entities,
72
- owned,
73
- });
80
+ let resources: Record<string, ResourceMetadata>;
81
+ if (stacks.length > 0) {
82
+ resources = {};
83
+ for (const stack of stacks) {
84
+ const perStack = await plugin.describeResources({
85
+ environment,
86
+ buildOutput,
87
+ entityNames,
88
+ entities,
89
+ owned,
90
+ stack,
91
+ });
92
+ Object.assign(resources, perStack);
93
+ }
94
+ } else {
95
+ resources = await plugin.describeResources({
96
+ environment,
97
+ buildOutput,
98
+ entityNames,
99
+ entities,
100
+ owned,
101
+ });
102
+ }
74
103
  if (Object.keys(resources).length > 0) {
75
104
  observations.push({ lexicon: plugin.name, resources });
76
105
  }