@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.
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +0 -16
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts +3 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/main.d.ts +10 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/graph-dot.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +5 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-mermaid.d.ts.map +1 -1
- package/dist/lexicon.d.ts +36 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +12 -0
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lint/config.d.ts +12 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/cli/commands/lint.test.ts +89 -0
- package/src/cli/commands/lint.ts +58 -18
- package/src/cli/handlers/components.ts +65 -2
- package/src/cli/handlers/graph.test.ts +79 -0
- package/src/cli/handlers/graph.ts +65 -1
- package/src/cli/main.test.ts +49 -1
- package/src/cli/main.ts +44 -3
- package/src/components/cli-support.ts +12 -1
- package/src/graph-dot.ts +6 -5
- package/src/graph-ir.ts +5 -0
- package/src/graph-mermaid.ts +8 -6
- package/src/lexicon.ts +35 -0
- package/src/lifecycle/status.test.ts +32 -0
- package/src/lifecycle/status.ts +27 -0
- package/src/lint/config.test.ts +29 -1
- package/src/lint/config.ts +23 -0
|
@@ -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
|
package/src/cli/main.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
18
|
-
for (const [
|
|
19
|
-
lines.push(` subgraph ${q(`cluster_${
|
|
20
|
-
lines.push(` label=${q(
|
|
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
|
package/src/graph-mermaid.ts
CHANGED
|
@@ -20,13 +20,15 @@ export function toMermaid(ir: GraphIR): string {
|
|
|
20
20
|
|
|
21
21
|
const lines: string[] = ["flowchart TD"];
|
|
22
22
|
|
|
23
|
-
// Cluster by
|
|
24
|
-
// through to the top level.
|
|
25
|
-
|
|
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 (
|
|
28
|
-
for (const [
|
|
29
|
-
lines.push(` subgraph ${safeId(
|
|
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
|
});
|
package/src/lifecycle/status.ts
CHANGED
|
@@ -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 —
|