@intentius/chant 0.18.26 → 0.18.28

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.
@@ -34,7 +34,7 @@ import {
34
34
  listReleaseEnvironments,
35
35
  InvalidReleaseRecordError,
36
36
  } from "../../lifecycle/release-ledger";
37
- import { reconcileStatus, liveEvidenceFromChangeSet, compareAcrossEnvironments } from "../../lifecycle/status";
37
+ import { reconcileStatus, liveEvidenceFromChangeSet, compareAcrossEnvironments, mergeLiveEvidence, type LiveComponentEvidence } from "../../lifecycle/status";
38
38
  import { buildChangeSet } from "../../lifecycle/change-set";
39
39
  import { buildLedgerEntries, componentBomSummary, type BuildLedgerEntry } from "../../lifecycle/build-ledger";
40
40
  import { findBuildManifestByArtifactDigest } from "../../lifecycle/build-ledger-store";
@@ -44,7 +44,8 @@ import { build } from "../../build";
44
44
  import { discoverComponents } from "../../components/discover";
45
45
  import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
46
46
  import type { CommandContext } from "../registry";
47
- import type { ResourceMetadata } from "../../lexicon";
47
+ import type { ResourceMetadata, LexiconPlugin } from "../../lexicon";
48
+ import type { Phase, Component } from "../../components/component";
48
49
 
49
50
  /**
50
51
  * chant components release <env> --component <name> --digest <sha256:...>
@@ -189,6 +190,58 @@ interface StatusJsonRow {
189
190
  * the epic names explicitly: "which build is in `<env>`, and is it the one
190
191
  * tested in `<compare-to>`."
191
192
  */
193
+ /** Every distinct `cfn-deploy` stack name a component's deploy phases target. A
194
+ * step may itself be a nested `Phase`, so walk recursively; a resolved component
195
+ * carries the stack as a concrete string. */
196
+ function cfnDeployStacks(deploy: Phase[]): string[] {
197
+ const stacks = new Set<string>();
198
+ const walkSteps = (steps: Phase["steps"]): void => {
199
+ for (const step of steps) {
200
+ // A step may itself be a nested Phase (it carries its own `steps`). Step is
201
+ // open-typed (capability inputs), so discriminate structurally on `steps`.
202
+ const nested = (step as { steps?: unknown }).steps;
203
+ if (Array.isArray(nested)) {
204
+ walkSteps(nested as Phase["steps"]);
205
+ continue;
206
+ }
207
+ const s = step as { kind?: string; stack?: unknown };
208
+ if (s.kind === "cfn-deploy" && typeof s.stack === "string") stacks.add(s.stack);
209
+ }
210
+ };
211
+ for (const phase of deploy) walkSteps(phase.steps);
212
+ return [...stacks];
213
+ }
214
+
215
+ /**
216
+ * Per-component stack presence for `--live`: resolve each component's own
217
+ * `cfn-deploy` stack(s) and observe them directly via a lexicon's
218
+ * `describeStackStatus`. This is the multi-stack component signal that
219
+ * `describeResources` (entity-keyed, single-stack-per-env) misses (#57): a
220
+ * component whose stack is present reconciles as live/owned, joined to the DAG
221
+ * by component name. Components with no `cfn-deploy` stack — or where the
222
+ * observer can't determine any of theirs — are omitted, so the change-set
223
+ * evidence still governs them.
224
+ */
225
+ async function observeComponentStacks(
226
+ components: Map<string, { component: Component }>,
227
+ observer: LexiconPlugin,
228
+ environment: string,
229
+ ): Promise<Map<string, LiveComponentEvidence>> {
230
+ const evidence = new Map<string, LiveComponentEvidence>();
231
+ for (const [name, { component }] of components) {
232
+ const stacks = cfnDeployStacks(component.deploy);
233
+ if (stacks.length === 0) continue;
234
+ const observed = await Promise.all(
235
+ stacks.map((stack) => observer.describeStackStatus!({ environment, stack }).catch(() => null)),
236
+ );
237
+ const determinate = observed.filter((o): o is NonNullable<typeof o> => o !== null);
238
+ if (determinate.length === 0) continue;
239
+ const present = determinate.every((o) => o.present);
240
+ evidence.set(name, { live: present, ownership: present ? "owned" : undefined });
241
+ }
242
+ return evidence;
243
+ }
244
+
192
245
  export async function runComponentsStatus(ctx: CommandContext): Promise<number> {
193
246
  const { args, plugins, serializers } = ctx;
194
247
  const requestedEnv = args.extraPositional;
@@ -261,6 +314,16 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
261
314
  merged.entries.push(...cs.entries);
262
315
  }
263
316
  liveEvidence = liveEvidenceFromChangeSet(merged, liveNameMapping);
317
+
318
+ // Multi-stack component projects (each component owns its own stack) are
319
+ // invisible to the entity-keyed, single-stack `describeResources` above —
320
+ // observe each component's own cfn-deploy stack directly and overlay it as
321
+ // the authoritative presence signal (#57).
322
+ const stackObserver = plugins.find((p) => p.describeStackStatus);
323
+ if (stackObserver) {
324
+ const stackEvidence = await observeComponentStacks(discovery.components, stackObserver, environment);
325
+ liveEvidence = mergeLiveEvidence(liveEvidence, stackEvidence);
326
+ }
264
327
  }
265
328
 
266
329
  // #609: resolve the persisted build manifest behind each recorded digest
@@ -18,6 +18,11 @@ vi.mock("../commands/lint", () => ({
18
18
  lintCommand: () => lintMock(),
19
19
  }));
20
20
 
21
+ const componentGraphMock = vi.fn();
22
+ vi.mock("../../components/cli-support", () => ({
23
+ computeComponentGraph: () => componentGraphMock(),
24
+ }));
25
+
21
26
  // Avoid running a real layout engine in tests; the format dispatch + size/engine
22
27
  // plumbing is what matters here (engines have their own unit tests).
23
28
  const layoutMock = vi.fn();
@@ -87,6 +92,7 @@ describe("runGraph", () => {
87
92
  discoverMock.mockReset();
88
93
  lintMock.mockReset();
89
94
  layoutMock.mockReset();
95
+ componentGraphMock.mockReset();
90
96
  });
91
97
 
92
98
  describe("Op graph (default)", () => {
@@ -227,6 +233,79 @@ describe("runGraph", () => {
227
233
  });
228
234
  });
229
235
 
236
+ describe("component DAG view (--components --format ir|layout)", () => {
237
+ // shared-foundation (wave 1) <- loom-db (wave 2) <- loom-backend (wave 3).
238
+ const componentGraphClean = (): void => {
239
+ componentGraphMock.mockResolvedValue({
240
+ success: true,
241
+ order: ["shared-foundation", "loom-db", "loom-backend"],
242
+ waves: [["shared-foundation"], ["loom-db"], ["loom-backend"]],
243
+ edges: [
244
+ { from: "loom-db", to: "shared-foundation" },
245
+ { from: "loom-backend", to: "loom-db" },
246
+ ],
247
+ files: {
248
+ "shared-foundation": "components/shared-foundation.component.ts",
249
+ "loom-db": "components/loom-db.component.ts",
250
+ "loom-backend": "components/loom-backend.component.ts",
251
+ },
252
+ });
253
+ };
254
+
255
+ test("--components --format ir emits component nodes, byWave groups, and dependsOn edges", async () => {
256
+ lintMock.mockResolvedValue({ success: true });
257
+ componentGraphClean();
258
+ const exit = await runGraph({ args: makeArgs({ format: "ir", components: true }), plugins: [], serializers: [] });
259
+ expect(exit).toBe(0);
260
+ const ir = JSON.parse(stdoutBuf.join("\n"));
261
+ // One node per component (not per resource); wave carried on the node.
262
+ expect(ir.nodes.map((n: { id: string }) => n.id)).toEqual(["shared-foundation", "loom-db", "loom-backend"]);
263
+ expect(ir.nodes.every((n: { kind: string; lexicon: string }) => n.kind === "Component" && n.lexicon === "chant")).toBe(true);
264
+ expect(ir.nodes.find((n: { id: string }) => n.id === "loom-backend").attrs.wave).toBe(3);
265
+ // Each component node deep-links to its source file.
266
+ expect(ir.nodes.find((n: { id: string }) => n.id === "loom-db").sourceLoc).toEqual({
267
+ file: "components/loom-db.component.ts",
268
+ });
269
+ // dependsOn edges, consumer → producer.
270
+ expect(ir.edges).toContainEqual({ from: "loom-db", to: "shared-foundation", kind: "ref" });
271
+ // Waves as groups.
272
+ expect(ir.groups.byWave).toEqual({
273
+ "wave-1": ["shared-foundation"],
274
+ "wave-2": ["loom-db"],
275
+ "wave-3": ["loom-backend"],
276
+ });
277
+ // The entity-graph discovery path is not taken for the component projection.
278
+ expect(discoverMock).not.toHaveBeenCalled();
279
+ });
280
+
281
+ test("--components --format mermaid lanes the components by wave", async () => {
282
+ lintMock.mockResolvedValue({ success: true });
283
+ componentGraphClean();
284
+ const exit = await runGraph({ args: makeArgs({ format: "mermaid", components: true }), plugins: [], serializers: [] });
285
+ expect(exit).toBe(0);
286
+ const out = stdoutBuf.join("\n");
287
+ expect(out).toContain("flowchart TD");
288
+ expect(out).toContain("wave-1");
289
+ });
290
+
291
+ test("--components view is lint-gated like the entity view", async () => {
292
+ lintMock.mockResolvedValue({ success: false });
293
+ const exit = await runGraph({ args: makeArgs({ format: "ir", components: true }), plugins: [], serializers: [] });
294
+ expect(exit).toBe(1);
295
+ expect(stdoutBuf.join("\n")).toBe("");
296
+ expect(stderrBuf.join("\n")).toMatch(/lint errors/i);
297
+ expect(componentGraphMock).not.toHaveBeenCalled();
298
+ });
299
+
300
+ test("propagates a component-graph failure (unknown dep / cycle) as a non-zero exit", async () => {
301
+ lintMock.mockResolvedValue({ success: true });
302
+ componentGraphMock.mockResolvedValue({ success: false, order: [], waves: [], edges: [], error: "cycle: a ↔ b" });
303
+ const exit = await runGraph({ args: makeArgs({ format: "ir", components: true }), plugins: [], serializers: [] });
304
+ expect(exit).toBe(1);
305
+ expect(stderrBuf.join("\n")).toContain("cycle: a ↔ b");
306
+ });
307
+ });
308
+
230
309
  describe("live graph (--live)", () => {
231
310
  // Regression: `graph` is not `requiresPlugins`, so `ctx.plugins` is empty. The
232
311
  // live path must load the project's plugins itself — otherwise it wrongly
@@ -24,7 +24,9 @@ import { computeComponentGraph } from "../../components/cli-support";
24
24
  * cross-lexicon references; `--components` (#560) renders the same
25
25
  * order/waves shape for discovered `Component` declarations, from their
26
26
  * `dependsOn`; `--format ir|mermaid` emits the lint-gated entity-graph IR (or
27
- * a Mermaid flowchart of it) for diagrams (#493/#496).
27
+ * a Mermaid flowchart of it) for diagrams (#493/#496). `--components` with a
28
+ * `--format` projects the component DAG itself into that format (nodes =
29
+ * components, wave groups, `dependsOn` edges) — the graph behold renders.
28
30
  */
29
31
  export async function runGraph(ctx: CommandContext): Promise<number> {
30
32
  const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
@@ -35,6 +37,11 @@ export async function runGraph(ctx: CommandContext): Promise<number> {
35
37
  return runGraphLive(ctx, isViewFormat ? (ctx.args.format as (typeof viewFormats)[number]) : "ir");
36
38
  }
37
39
  if (isViewFormat) {
40
+ // `--components` projects the component DAG (nodes = components, wave groups,
41
+ // dependsOn edges) into the same view formats; without it, the entity graph.
42
+ if (ctx.args.components) {
43
+ return runComponentGraphView(ctx, ctx.args.format as (typeof viewFormats)[number]);
44
+ }
38
45
  return runGraphView(ctx, ctx.args.format as (typeof viewFormats)[number]);
39
46
  }
40
47
  if (ctx.args.components) return runComponentGraph(ctx);
@@ -204,6 +211,63 @@ async function runComponentGraph(ctx: CommandContext): Promise<number> {
204
211
  return 0;
205
212
  }
206
213
 
214
+ /**
215
+ * `chant graph --components --format ir|mermaid|dot|layout` — the component DAG
216
+ * projected into the renderable IR (nodes = components, `groups.byWave` = the
217
+ * parallel-safe deploy waves, edges = `dependsOn` consumer → producer). This is
218
+ * the graph behold paints: read one way it is the deploy order, read the other
219
+ * the CI pipeline. Distinct from `runGraphView`, which emits the AWS *entity*
220
+ * graph — the component projection has one node per component, not per resource.
221
+ *
222
+ * Lint-gated like the entity view: the DAG stands for deployable source, so we
223
+ * refuse to emit it for source that does not pass lint.
224
+ */
225
+ async function runComponentGraphView(
226
+ ctx: CommandContext,
227
+ format: "ir" | "mermaid" | "dot" | "layout",
228
+ ): Promise<number> {
229
+ const projectPath = resolve(ctx.args.path === "." ? "." : ctx.args.path);
230
+
231
+ const lint = await lintCommand({ path: ctx.args.path, format: "stylish" });
232
+ if (!lint.success) {
233
+ console.error(
234
+ formatError({
235
+ message: "Refusing to emit graph: source has lint errors. Run `chant lint` and fix them first.",
236
+ }),
237
+ );
238
+ return 1;
239
+ }
240
+
241
+ const graph = await computeComponentGraph(projectPath);
242
+ if (!graph.success) {
243
+ console.error(formatError({ message: graph.error ?? "Failed to compute component graph" }));
244
+ return 1;
245
+ }
246
+
247
+ // One node per component; wave index carried on the node so a renderer that
248
+ // ignores groups can still lane by it. `dependsOn` is a plain name edge —
249
+ // `kind: "ref"` matches the entity graph's edge vocabulary the emitters read.
250
+ const waveOf = new Map<string, number>();
251
+ graph.waves.forEach((wave, i) => wave.forEach((name) => waveOf.set(name, i + 1)));
252
+
253
+ const ir: GraphIR = {
254
+ nodes: graph.order.map((name) => ({
255
+ id: name,
256
+ kind: "Component",
257
+ lexicon: "chant",
258
+ attrs: { wave: waveOf.get(name) ?? null },
259
+ // Deep-link the node to its `*.component.ts` (behold's inspect panel).
260
+ ...(graph.files?.[name] ? { sourceLoc: { file: graph.files[name] } } : {}),
261
+ })),
262
+ edges: graph.edges.map(({ from, to }) => ({ from, to, kind: "ref" as const })),
263
+ groups: {
264
+ byWave: Object.fromEntries(graph.waves.map((wave, i) => [`wave-${i + 1}`, [...wave]])),
265
+ },
266
+ };
267
+
268
+ return emitIr(ir, ctx, format);
269
+ }
270
+
207
271
  /**
208
272
  * `chant graph --format ir|mermaid|dot|layout` — build the graph IR (honouring
209
273
  * `--detail`) and emit it as JSON, a Mermaid flowchart, Graphviz DOT, or node
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { parseArgs } from "./main";
2
+ import { EventEmitter } from "node:events";
3
+ import { parseArgs, waitForStreamDrain } from "./main";
3
4
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
4
5
 
5
6
  describe("parseArgs", () => {
@@ -416,3 +417,50 @@ describe("parseArgs — run flags", () => {
416
417
  expect(result.extraPositional2).toBe("gate-dns");
417
418
  });
418
419
  });
420
+
421
+ describe("waitForStreamDrain", () => {
422
+ // Minimal writable stub — just the surface waitForStreamDrain reads.
423
+ function fakeStream(len: number): NodeJS.WriteStream & { writableLength: number } {
424
+ const s = new EventEmitter() as unknown as NodeJS.WriteStream & { writableLength: number };
425
+ s.writableLength = len;
426
+ (s as { writableEnded: boolean }).writableEnded = false;
427
+ (s as { destroyed: boolean }).destroyed = false;
428
+ return s;
429
+ }
430
+
431
+ test("resolves immediately when nothing is buffered (e.g. a TTY)", async () => {
432
+ await expect(waitForStreamDrain(fakeStream(0))).resolves.toBeUndefined();
433
+ });
434
+
435
+ test("waits through drains until the buffer is actually empty", async () => {
436
+ const s = fakeStream(1000);
437
+ let done = false;
438
+ const p = waitForStreamDrain(s).then(() => (done = true));
439
+ await Promise.resolve();
440
+ expect(done).toBe(false);
441
+ // A drain while still buffered must NOT resolve (large one-shot write, kernel
442
+ // took a slice, more remains) — it re-arms.
443
+ s.emit("drain");
444
+ await Promise.resolve();
445
+ expect(done).toBe(false);
446
+ // Fully flushed now.
447
+ s.writableLength = 0;
448
+ s.emit("drain");
449
+ await p;
450
+ expect(done).toBe(true);
451
+ });
452
+
453
+ test("resolves on error so a reader that closed early (EPIPE) can't hang exit", async () => {
454
+ const s = fakeStream(500);
455
+ const p = waitForStreamDrain(s);
456
+ s.emit("error", new Error("EPIPE"));
457
+ await expect(p).resolves.toBeUndefined();
458
+ });
459
+
460
+ test("resolves on close as well", async () => {
461
+ const s = fakeStream(500);
462
+ const p = waitForStreamDrain(s);
463
+ s.emit("close");
464
+ await expect(p).resolves.toBeUndefined();
465
+ });
466
+ });
package/src/cli/main.ts CHANGED
@@ -551,7 +551,48 @@ async function main(): Promise<void> {
551
551
  const serializers = plugins.map((p) => p.serializer);
552
552
  const ctx = { args, plugins, serializers };
553
553
 
554
- process.exit(await match.def.handler(ctx));
554
+ await flushAndExit(await match.def.handler(ctx));
555
+ }
556
+
557
+ /**
558
+ * Wait until a writable stream has flushed its buffer. `process.exit()` discards
559
+ * data still buffered for an async sink (a pipe or file), truncating large output
560
+ * at the ~64 KB pipe buffer — so `chant graph --format ir` piped into a consumer
561
+ * loses everything past 64 KB and its JSON won't parse. A TTY writes
562
+ * synchronously (`writableLength` stays 0), so this is a no-op there. Resolves on
563
+ * `error`/`close` too, so a reader that closes early (EPIPE) can't hang exit.
564
+ * Exported for testing.
565
+ */
566
+ export function waitForStreamDrain(stream: NodeJS.WriteStream): Promise<void> {
567
+ return new Promise((resolve) => {
568
+ const tick = (): void => {
569
+ if (stream.writableLength === 0 || stream.writableEnded || stream.destroyed) {
570
+ cleanup();
571
+ resolve();
572
+ return;
573
+ }
574
+ stream.once("drain", tick);
575
+ };
576
+ const stop = (): void => {
577
+ cleanup();
578
+ resolve();
579
+ };
580
+ const cleanup = (): void => {
581
+ stream.off("drain", tick);
582
+ stream.off("error", stop);
583
+ stream.off("close", stop);
584
+ };
585
+ stream.once("error", stop);
586
+ stream.once("close", stop);
587
+ tick();
588
+ });
589
+ }
590
+
591
+ /** Flush stdout+stderr, then exit — so a large piped payload isn't truncated. */
592
+ async function flushAndExit(code: number): Promise<never> {
593
+ await waitForStreamDrain(process.stdout);
594
+ await waitForStreamDrain(process.stderr);
595
+ process.exit(code);
555
596
  }
556
597
 
557
598
  // Only run main when executed directly, not when imported. Robust to symlinked
@@ -559,13 +600,13 @@ async function main(): Promise<void> {
559
600
  // whole CLI through the npm .bin shim / a symlinked checkout).
560
601
  const isMain = isEntryPoint(process.argv[1], import.meta.url);
561
602
  if (isMain) {
562
- main().catch((err) => {
603
+ main().catch(async (err) => {
563
604
  const verbose = process.argv.includes("--verbose") || process.argv.includes("-v");
564
605
  if (verbose && err instanceof Error && err.stack) {
565
606
  console.error(err.stack);
566
607
  } else {
567
608
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
568
609
  }
569
- process.exit(1);
610
+ await flushAndExit(1);
570
611
  });
571
612
  }
@@ -37,6 +37,7 @@ import {
37
37
  type DriverRunResult,
38
38
  } from "./driver";
39
39
  import { isLexiconPlugin, type LexiconPlugin, type ComponentPipelineOptions } from "../lexicon";
40
+ import { relative } from "node:path";
40
41
  import { buildCapabilityRegistry } from "./capability-plugin-loader";
41
42
  import type { CapabilityRegistry } from "./capability";
42
43
  import { applyConfigDefaults } from "./config-defaults";
@@ -129,6 +130,10 @@ export interface ComponentGraphResult {
129
130
  order: string[];
130
131
  waves: string[][];
131
132
  edges: Array<{ from: string; to: string }>;
133
+ /** Component name → its declaring `*.component.ts` file (relative to `path`),
134
+ * so a renderer can deep-link a component node to source (`chant graph
135
+ * --components --format ir` sets `sourceLoc` from this). */
136
+ files?: Record<string, string>;
132
137
  error?: string;
133
138
  }
134
139
 
@@ -145,13 +150,19 @@ export async function computeComponentGraph(path: string): Promise<ComponentGrap
145
150
  deploy: component.deploy,
146
151
  }));
147
152
 
153
+ // component name → its declaring file, relative to `path`, for node deep-links.
154
+ const files: Record<string, string> = {};
155
+ for (const [name, discovered] of result.components) {
156
+ files[name] = relative(path, discovered.filePath);
157
+ }
158
+
148
159
  try {
149
160
  const { order, waves } = resolveComponentGraph(driverComponents);
150
161
  const edges: Array<{ from: string; to: string }> = [];
151
162
  for (const c of driverComponents) {
152
163
  for (const dep of c.dependsOn ?? []) edges.push({ from: c.name, to: dep });
153
164
  }
154
- return { success: true, order, waves, edges };
165
+ return { success: true, order, waves, edges, files };
155
166
  } catch (err) {
156
167
  if (err instanceof UnknownDependencyError || err instanceof DependencyCycleError) {
157
168
  return { success: false, order: [], waves: [], edges: [], error: err.message };
package/src/graph-dot.ts CHANGED
@@ -12,12 +12,13 @@ import type { GraphIR, IRNode, IREdge } from "./graph-ir";
12
12
  export function toDot(ir: GraphIR): string {
13
13
  const lines: string[] = ["digraph chant {", " rankdir=TB;", ' node [shape=box];'];
14
14
 
15
- const byLexicon = ir.groups.byLexicon;
15
+ // Cluster by wave (component graph) when present, else by lexicon (entity graph).
16
+ const clusters = ir.groups.byWave ?? ir.groups.byLexicon;
16
17
  const grouped = new Set<string>();
17
- if (byLexicon) {
18
- for (const [lexicon, members] of Object.entries(byLexicon)) {
19
- lines.push(` subgraph ${q(`cluster_${lexicon}`)} {`);
20
- lines.push(` label=${q(lexicon)};`);
18
+ if (clusters) {
19
+ for (const [name, members] of Object.entries(clusters)) {
20
+ lines.push(` subgraph ${q(`cluster_${name}`)} {`);
21
+ lines.push(` label=${q(name)};`);
21
22
  for (const id of members) {
22
23
  const node = ir.nodes.find((n) => n.id === id);
23
24
  if (!node) continue;
package/src/graph-ir.ts CHANGED
@@ -94,6 +94,11 @@ export interface IRGroups {
94
94
  * so a boundary-box renderer recurses it. Populated by `chant graph --live`
95
95
  * from the reference resolver's containment output; absent for source IR. */
96
96
  byContainer?: Record<string, string[]>;
97
+ /** Component-graph deploy wave (`chant graph --components --format ir`):
98
+ * `wave-N → component node ids` that deploy in parallel in that wave. A
99
+ * wave-laned renderer reads this the way `byStack` drives boundary boxes.
100
+ * Present only for the component-DAG projection; absent for entity IR. */
101
+ byWave?: Record<string, string[]>;
97
102
  }
98
103
 
99
104
  /** A cross-stack export this stack publishes (a `stackOutput`/`output`): its
@@ -20,13 +20,15 @@ export function toMermaid(ir: GraphIR): string {
20
20
 
21
21
  const lines: string[] = ["flowchart TD"];
22
22
 
23
- // Cluster by lexicon when grouping is available; nodes outside any group fall
24
- // through to the top level. byLexicon is sorted, so output is deterministic.
25
- const byLexicon = ir.groups.byLexicon;
23
+ // Cluster by wave (component graph) when present, else by lexicon (entity
24
+ // graph); nodes outside any group fall through to the top level. Both are
25
+ // sorted, so output is deterministic.
26
+ const clusters = ir.groups.byWave ?? ir.groups.byLexicon;
27
+ const clusterPrefix = ir.groups.byWave ? "wave" : "lex";
26
28
  const grouped = new Set<string>();
27
- if (byLexicon) {
28
- for (const [lexicon, members] of Object.entries(byLexicon)) {
29
- lines.push(` subgraph ${safeId(`lex_${lexicon}`, ids)}[${quote(lexicon)}]`);
29
+ if (clusters) {
30
+ for (const [name, members] of Object.entries(clusters)) {
31
+ lines.push(` subgraph ${safeId(`${clusterPrefix}_${name}`, ids)}[${quote(name)}]`);
30
32
  for (const id of members) {
31
33
  const node = ir.nodes.find((n) => n.id === id);
32
34
  if (!node) continue;
package/src/lexicon.ts CHANGED
@@ -231,6 +231,25 @@ export interface ComponentPipelineResult {
231
231
  jobs: ComponentPipelineJob[];
232
232
  }
233
233
 
234
+ /**
235
+ * Live status of a single deploy unit (a CloudFormation stack, a K8s release, …)
236
+ * addressed by its deployed name — the per-component presence signal
237
+ * `chant components status --live` needs (#57). A component's deploy step carries
238
+ * the exact unit name it targets (e.g. a `cfn-deploy` step's `stack`), which is
239
+ * the identity in a multi-stack component project where `describeResources`
240
+ * (entity-keyed, single-stack-per-env) can't see the component's own stack.
241
+ */
242
+ export interface StackStatusObservation {
243
+ /** The deploy-unit name queried (the stack name). */
244
+ stack: string;
245
+ /** False when the unit does not exist yet — the pre-first-apply state. */
246
+ present: boolean;
247
+ /** Provider-native status string, e.g. CloudFormation "CREATE_COMPLETE". */
248
+ status?: string;
249
+ /** True when `status` is a terminal *success* state (deployed and healthy). */
250
+ healthy?: boolean;
251
+ }
252
+
234
253
  export interface LexiconPlugin {
235
254
  // ── Required ──────────────────────────────────────────────
236
255
  /** Human-readable name (e.g. "aws", "gcp") */
@@ -388,6 +407,22 @@ export interface LexiconPlugin {
388
407
  owned?: boolean;
389
408
  }): Promise<Record<string, ResourceMetadata>>;
390
409
 
410
+ /**
411
+ * Report the live status of one deploy unit by its deployed name. Opt-in.
412
+ *
413
+ * Complements {@link describeResources}: that observes a stack's *entities*
414
+ * keyed by chant entity name (and assumes one stack per environment), which
415
+ * can't see a multi-stack component project where each component owns its own
416
+ * stack. `chant components status --live` resolves a component's deploy-step
417
+ * target (e.g. a `cfn-deploy` step's `stack`) and calls this to learn whether
418
+ * that unit is present and healthy — a component-level presence signal.
419
+ *
420
+ * Returns `null` when the lexicon cannot determine status (e.g. the provider
421
+ * CLI failed for a reason other than "does not exist"); a genuinely absent
422
+ * unit returns `{ present: false }`.
423
+ */
424
+ describeStackStatus?(options: { environment: string; stack: string }): Promise<StackStatusObservation | null>;
425
+
391
426
  /**
392
427
  * Reference catalog for live edge reconstruction (#778). Declares how this
393
428
  * lexicon's observed resources reference each other — an identity map (which
@@ -4,6 +4,7 @@ import {
4
4
  liveEvidenceFromChangeSet,
5
5
  resolveLiveNames,
6
6
  compareAcrossEnvironments,
7
+ mergeLiveEvidence,
7
8
  type LiveComponentEvidence,
8
9
  type LiveNameMapping,
9
10
  } from "./status";
@@ -327,4 +328,35 @@ describe("status", () => {
327
328
  expect(result.same).toBe(false);
328
329
  });
329
330
  });
331
+
332
+ describe("mergeLiveEvidence (#57 — per-component stack presence overlay)", () => {
333
+ test("stack presence overrides change-set 'not live' but keeps its drift action", () => {
334
+ const base = new Map<string, LiveComponentEvidence>([
335
+ ["shared-foundation", { live: false }], // entity-keyed observe saw nothing
336
+ ["loom-backend", { live: true, action: "update", ownership: "owned" }],
337
+ ]);
338
+ const supplement = new Map<string, LiveComponentEvidence>([
339
+ ["shared-foundation", { live: true, ownership: "owned" }], // its stack IS present
340
+ ["loom-backend", { live: true, ownership: "owned" }],
341
+ ]);
342
+ const merged = mergeLiveEvidence(base, supplement);
343
+ expect(merged.get("shared-foundation")).toEqual({ live: true, ownership: "owned", action: undefined });
344
+ // loom-backend: presence confirmed, change-set drift action preserved.
345
+ expect(merged.get("loom-backend")).toEqual({ live: true, ownership: "owned", action: "update" });
346
+ });
347
+
348
+ test("a component only in the supplement is added; base-only entries pass through", () => {
349
+ const base = new Map<string, LiveComponentEvidence>([["only-base", { live: true, ownership: "foreign" }]]);
350
+ const supplement = new Map<string, LiveComponentEvidence>([["only-sup", { live: false }]]);
351
+ const merged = mergeLiveEvidence(base, supplement);
352
+ expect(merged.get("only-base")).toEqual({ live: true, ownership: "foreign" });
353
+ expect(merged.get("only-sup")).toEqual({ live: false, ownership: undefined, action: undefined });
354
+ });
355
+
356
+ test("undefined base (no --live change-set) still yields the supplement", () => {
357
+ const supplement = new Map<string, LiveComponentEvidence>([["c", { live: true, ownership: "owned" }]]);
358
+ const merged = mergeLiveEvidence(undefined, supplement);
359
+ expect(merged.get("c")).toEqual({ live: true, ownership: "owned", action: undefined });
360
+ });
361
+ });
330
362
  });
@@ -86,6 +86,33 @@ export interface LiveComponentEvidence {
86
86
  ownership?: "owned" | "foreign" | "unknown";
87
87
  }
88
88
 
89
+ /**
90
+ * Overlay per-component stack-presence evidence onto change-set evidence.
91
+ *
92
+ * The change-set axis (`liveEvidenceFromChangeSet`) is entity-keyed and, for
93
+ * AWS, single-stack-per-env — it can't see a multi-stack component project where
94
+ * each component owns its own stack (#57). `supplement` carries the direct
95
+ * per-component stack observation (from a lexicon's `describeStackStatus`), which
96
+ * is authoritative for **presence** (`live`) and **ownership**; the change-set's
97
+ * `action` is kept, since drift is still assessed from the diff. A component in
98
+ * only one map passes through unchanged.
99
+ */
100
+ export function mergeLiveEvidence(
101
+ base: Map<string, LiveComponentEvidence> | undefined,
102
+ supplement: Map<string, LiveComponentEvidence>,
103
+ ): Map<string, LiveComponentEvidence> {
104
+ const merged = new Map(base ?? []);
105
+ for (const [component, sup] of supplement) {
106
+ const b = merged.get(component);
107
+ merged.set(component, {
108
+ live: sup.live,
109
+ ownership: sup.ownership ?? b?.ownership,
110
+ action: b?.action,
111
+ });
112
+ }
113
+ return merged;
114
+ }
115
+
89
116
  /**
90
117
  * Component -> live entity/resource name(s) it owns (#598). Mirrors
91
118
  * `Component.liveNames` (../components/component.ts) without importing it —