@intentius/chant 0.28.0 → 0.30.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.
Files changed (106) hide show
  1. package/dist/cli/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +14 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/config.d.ts +46 -4
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/deep-observation.d.ts +257 -0
  11. package/dist/deep-observation.d.ts.map +1 -0
  12. package/dist/discovery/fold-import.d.ts +153 -17
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  15. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  16. package/dist/env.d.ts +5 -2
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/fold/fold.d.ts +35 -3
  19. package/dist/fold/fold.d.ts.map +1 -1
  20. package/dist/fold/subset.d.ts +9 -0
  21. package/dist/fold/subset.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +73 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +27 -0
  27. package/dist/kubectl-context.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +78 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/deep-diff.d.ts +103 -0
  33. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  34. package/dist/lifecycle/deep-observe.d.ts +62 -0
  35. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  36. package/dist/lifecycle/index.d.ts +3 -0
  37. package/dist/lifecycle/index.d.ts.map +1 -1
  38. package/dist/lifecycle/live-diff.d.ts +25 -1
  39. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  41. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  42. package/dist/lifecycle/observe.d.ts +4 -2
  43. package/dist/lifecycle/observe.d.ts.map +1 -1
  44. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  45. package/dist/lifecycle/status.d.ts +26 -1
  46. package/dist/lifecycle/status.d.ts.map +1 -1
  47. package/dist/lifecycle/types.d.ts +8 -0
  48. package/dist/lifecycle/types.d.ts.map +1 -1
  49. package/dist/live-endpoint.d.ts +92 -0
  50. package/dist/live-endpoint.d.ts.map +1 -0
  51. package/dist/observation.d.ts +123 -0
  52. package/dist/observation.d.ts.map +1 -0
  53. package/dist/stack-output.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/cli/handlers/components.test.ts +63 -4
  56. package/src/cli/handlers/components.ts +78 -35
  57. package/src/cli/handlers/graph.test.ts +155 -6
  58. package/src/cli/handlers/graph.ts +124 -29
  59. package/src/cli/handlers/lifecycle.test.ts +410 -6
  60. package/src/cli/handlers/lifecycle.ts +475 -182
  61. package/src/cli/main.test.ts +6 -0
  62. package/src/cli/main.ts +12 -0
  63. package/src/cli/registry.ts +14 -0
  64. package/src/config.test.ts +75 -0
  65. package/src/config.ts +61 -3
  66. package/src/deep-observation.test.ts +234 -0
  67. package/src/deep-observation.ts +489 -0
  68. package/src/discovery/fold-composite.test.ts +594 -0
  69. package/src/discovery/fold-import.test.ts +372 -1
  70. package/src/discovery/fold-import.ts +1216 -116
  71. package/src/discovery/sandbox/config-wire.ts +3 -2
  72. package/src/env.test.ts +12 -0
  73. package/src/env.ts +12 -4
  74. package/src/fold/fold.test.ts +105 -0
  75. package/src/fold/fold.ts +100 -20
  76. package/src/fold/subset.test.ts +38 -7
  77. package/src/fold/subset.ts +9 -0
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +115 -12
  80. package/src/index.ts +2 -0
  81. package/src/kubectl-context.ts +81 -0
  82. package/src/lexicon.ts +100 -6
  83. package/src/lifecycle/change-set.test.ts +93 -1
  84. package/src/lifecycle/change-set.ts +65 -13
  85. package/src/lifecycle/deep-diff.test.ts +157 -0
  86. package/src/lifecycle/deep-diff.ts +213 -0
  87. package/src/lifecycle/deep-observe.test.ts +174 -0
  88. package/src/lifecycle/deep-observe.ts +173 -0
  89. package/src/lifecycle/index.ts +3 -0
  90. package/src/lifecycle/live-diff.test.ts +39 -0
  91. package/src/lifecycle/live-diff.ts +51 -5
  92. package/src/lifecycle/observation-baseline.test.ts +99 -0
  93. package/src/lifecycle/observation-baseline.ts +217 -0
  94. package/src/lifecycle/observe.test.ts +74 -3
  95. package/src/lifecycle/observe.ts +82 -22
  96. package/src/lifecycle/snapshot.test.ts +39 -1
  97. package/src/lifecycle/snapshot.ts +40 -20
  98. package/src/lifecycle/status.test.ts +89 -8
  99. package/src/lifecycle/status.ts +53 -3
  100. package/src/lifecycle/types.ts +8 -0
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +55 -0
  106. package/src/stack-output.ts +41 -20
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, vi, beforeEach } from "vitest";
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import type { ParsedArgs } from "../registry";
3
3
  import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
4
4
  import { AttrRef } from "../../attrref";
@@ -19,8 +19,10 @@ vi.mock("../commands/lint", () => ({
19
19
  }));
20
20
 
21
21
  const componentGraphMock = vi.fn();
22
+ const generatePipelineMock = vi.fn();
22
23
  vi.mock("../../components/cli-support", () => ({
23
24
  computeComponentGraph: () => componentGraphMock(),
25
+ generateComponentsPipeline: (...a: unknown[]) => generatePipelineMock(...a),
24
26
  }));
25
27
 
26
28
  // Avoid running a real layout engine in tests; the format dispatch + size/engine
@@ -52,9 +54,14 @@ const discoverComponentsMock = vi.fn();
52
54
  vi.mock("../../components/discover", () => ({
53
55
  discoverComponents: (...a: unknown[]) => discoverComponentsMock(...a),
54
56
  }));
55
- vi.mock("../../config", () => ({
56
- loadChantConfig: () => Promise.resolve({ config: {} }),
57
- }));
57
+ const loadChantConfigMock = vi.fn();
58
+ vi.mock("../../config", async () => {
59
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
60
+ return {
61
+ ...actual,
62
+ loadChantConfig: (...a: unknown[]) => loadChantConfigMock(...a),
63
+ };
64
+ });
58
65
  vi.mock("../../build", () => ({
59
66
  build: () => Promise.resolve({ errors: [] }),
60
67
  partitionByLexicon: () => ({}),
@@ -101,12 +108,15 @@ describe("runGraph", () => {
101
108
  lintMock.mockReset();
102
109
  layoutMock.mockReset();
103
110
  componentGraphMock.mockReset();
111
+ generatePipelineMock.mockReset();
104
112
  discoverComponentsMock.mockReset();
105
113
  // Default: no components — the single-stack --live path most tests exercise.
106
114
  discoverComponentsMock.mockResolvedValue({ components: new Map(), sourceFiles: [], errors: [] });
107
115
  observeMock.mockReset();
108
116
  loadPluginsMock.mockReset();
109
117
  resolveLexMock.mockReset();
118
+ loadChantConfigMock.mockReset();
119
+ loadChantConfigMock.mockResolvedValue({ config: {} });
110
120
  });
111
121
 
112
122
  describe("Op graph (default)", () => {
@@ -318,6 +328,89 @@ describe("runGraph", () => {
318
328
  expect(exit).toBe(1);
319
329
  expect(stderrBuf.join("\n")).toContain("cycle: a ↔ b");
320
330
  });
331
+
332
+ describe("CI/pipeline projection (--projection, #989)", () => {
333
+ test("rejects --projection without --components --format ir", async () => {
334
+ const exit = await runGraph({ args: makeArgs({ projection: "gitlab" }), plugins: [], serializers: [] });
335
+ expect(exit).toBe(1);
336
+ expect(stderrBuf.join("\n")).toMatch(/--projection needs --components --format ir/);
337
+ expect(componentGraphMock).not.toHaveBeenCalled();
338
+ });
339
+
340
+ test("rejects --projection with --components --format mermaid", async () => {
341
+ const exit = await runGraph({
342
+ args: makeArgs({ projection: "gitlab", components: true, format: "mermaid" }),
343
+ plugins: [],
344
+ serializers: [],
345
+ });
346
+ expect(exit).toBe(1);
347
+ expect(stderrBuf.join("\n")).toMatch(/--projection needs --components --format ir/);
348
+ });
349
+
350
+ test("--components --format ir --projection gitlab adds ir.pipeline, reusing generateComponentsPipeline", async () => {
351
+ lintMock.mockResolvedValue({ success: true });
352
+ componentGraphClean();
353
+ generatePipelineMock.mockResolvedValue({
354
+ success: true,
355
+ stages: ["wave-1", "wave-2", "wave-3"],
356
+ jobs: [
357
+ { jobName: "shared-foundation", component: "shared-foundation", stage: "wave-1", needs: [] },
358
+ { jobName: "loom-db", component: "loom-db", stage: "wave-2", needs: ["shared-foundation"] },
359
+ { jobName: "loom-backend", component: "loom-backend", stage: "wave-3", needs: ["loom-db"] },
360
+ ],
361
+ yaml: "stages: [...]\n",
362
+ });
363
+
364
+ const exit = await runGraph({
365
+ args: makeArgs({ format: "ir", components: true, projection: "gitlab" }),
366
+ plugins: [],
367
+ serializers: [],
368
+ });
369
+ expect(exit).toBe(0);
370
+
371
+ // Reuses the same generator `build --components --generate` calls —
372
+ // never re-derives stages/jobs/needs itself.
373
+ expect(generatePipelineMock).toHaveBeenCalledWith(expect.any(String), "gitlab", undefined, undefined);
374
+
375
+ const ir = JSON.parse(stdoutBuf.join("\n"));
376
+ // The component graph itself is untouched by the projection.
377
+ expect(ir.nodes.map((n: { id: string }) => n.id)).toEqual(["shared-foundation", "loom-db", "loom-backend"]);
378
+ expect(ir.groups.byWave["wave-2"]).toEqual(["loom-db"]);
379
+
380
+ // The CI/pipeline projection sits alongside it as first-class IR nodes/edges.
381
+ expect(ir.pipeline.provider).toBe("gitlab");
382
+ expect(ir.pipeline.stages).toEqual(["wave-1", "wave-2", "wave-3"]);
383
+ expect(ir.pipeline.nodes).toEqual([
384
+ { id: "shared-foundation", kind: "CIJob", component: "shared-foundation", stage: "wave-1" },
385
+ { id: "loom-db", kind: "CIJob", component: "loom-db", stage: "wave-2" },
386
+ { id: "loom-backend", kind: "CIJob", component: "loom-backend", stage: "wave-3" },
387
+ ]);
388
+ // `needs:` edges, consumer job → producer job (mirrors the component
389
+ // graph's consumer → producer convention).
390
+ expect(ir.pipeline.edges).toEqual([
391
+ { from: "loom-db", to: "shared-foundation", kind: "needs" },
392
+ { from: "loom-backend", to: "loom-db", kind: "needs" },
393
+ ]);
394
+ });
395
+
396
+ test("an unsupported --projection lexicon fails the whole graph command", async () => {
397
+ lintMock.mockResolvedValue({ success: true });
398
+ componentGraphClean();
399
+ generatePipelineMock.mockResolvedValue({
400
+ success: false,
401
+ error: 'Lexicon "bogus" does not support generate mode (no generateComponentPipeline).',
402
+ });
403
+
404
+ const exit = await runGraph({
405
+ args: makeArgs({ format: "ir", components: true, projection: "bogus" }),
406
+ plugins: [],
407
+ serializers: [],
408
+ });
409
+ expect(exit).toBe(1);
410
+ expect(stderrBuf.join("\n")).toContain("does not support generate mode");
411
+ expect(stdoutBuf.join("\n")).toBe("");
412
+ });
413
+ });
321
414
  });
322
415
 
323
416
  describe("live graph (--live)", () => {
@@ -332,6 +425,7 @@ describe("runGraph", () => {
332
425
  observeMock.mockResolvedValue({
333
426
  observations: [{ lexicon: "aws", resources: { "web-vpc": { type: "AWS::EC2::VPC", status: "OK" } } }],
334
427
  errors: [],
428
+ warnings: [],
335
429
  });
336
430
  const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
337
431
  expect(exit).toBe(0);
@@ -349,7 +443,7 @@ describe("runGraph", () => {
349
443
  loadPluginsMock.mockResolvedValue([
350
444
  { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
351
445
  ]);
352
- observeMock.mockResolvedValue({ observations: [], errors: [] });
446
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
353
447
  const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
354
448
  expect(exit).toBe(0);
355
449
  expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
@@ -390,6 +484,7 @@ describe("runGraph", () => {
390
484
  observeMock.mockResolvedValue({
391
485
  observations: [{ lexicon: "aws", resources: { "loom-db": { type: "AWS::RDS::DBInstance", status: "OK" } } }],
392
486
  errors: [],
487
+ warnings: [],
393
488
  });
394
489
  const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "local" }), plugins: [], serializers: [] });
395
490
  expect(exit).toBe(0);
@@ -409,7 +504,7 @@ describe("runGraph", () => {
409
504
  { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
410
505
  ]);
411
506
  discoverComponentsMock.mockResolvedValue({ errors: [{ message: "bad component" }], sourceFiles: [], components: new Map() });
412
- observeMock.mockResolvedValue({ observations: [], errors: [] });
507
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
413
508
  const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
414
509
  expect(exit).toBe(0);
415
510
  expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
@@ -418,5 +513,59 @@ describe("runGraph", () => {
418
513
  });
419
514
  expect(stderrBuf.join("\n")).toMatch(/component discovery failed/i);
420
515
  });
516
+
517
+ // #1166 — an environment can declare its own endpoint (a local emulator
518
+ // like Floci), so `--live --env floci` observes the right target even when
519
+ // the ambient shell never exported AWS_ENDPOINT_URL.
520
+ describe("declared endpoint (#1166)", () => {
521
+ const prevEndpoint = process.env.AWS_ENDPOINT_URL;
522
+
523
+ afterEach(() => {
524
+ if (prevEndpoint === undefined) delete process.env.AWS_ENDPOINT_URL;
525
+ else process.env.AWS_ENDPOINT_URL = prevEndpoint;
526
+ });
527
+
528
+ test("applies the declared endpoint to AWS_ENDPOINT_URL for the observe call, then restores it", async () => {
529
+ delete process.env.AWS_ENDPOINT_URL;
530
+ loadChantConfigMock.mockResolvedValue({
531
+ config: { environments: [{ name: "floci", endpoint: "http://localhost:4566" }] },
532
+ });
533
+ resolveLexMock.mockResolvedValue(["aws"]);
534
+ loadPluginsMock.mockResolvedValue([
535
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
536
+ ]);
537
+ let seenDuringObserve: string | undefined;
538
+ observeMock.mockImplementation(async () => {
539
+ seenDuringObserve = process.env.AWS_ENDPOINT_URL;
540
+ return { observations: [], errors: [], warnings: [] };
541
+ });
542
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "floci" }), plugins: [], serializers: [] });
543
+ expect(exit).toBe(0);
544
+ expect(seenDuringObserve).toBe("http://localhost:4566");
545
+ expect(process.env.AWS_ENDPOINT_URL).toBeUndefined(); // restored after the read
546
+ expect(stderrBuf.join("\n")).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
547
+ });
548
+
549
+ test("ambient AWS_ENDPOINT_URL still wins when already set", async () => {
550
+ process.env.AWS_ENDPOINT_URL = "http://real-endpoint.example";
551
+ loadChantConfigMock.mockResolvedValue({
552
+ config: { environments: [{ name: "floci", endpoint: "http://localhost:4566" }] },
553
+ });
554
+ resolveLexMock.mockResolvedValue(["aws"]);
555
+ loadPluginsMock.mockResolvedValue([
556
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
557
+ ]);
558
+ let seenDuringObserve: string | undefined;
559
+ observeMock.mockImplementation(async () => {
560
+ seenDuringObserve = process.env.AWS_ENDPOINT_URL;
561
+ return { observations: [], errors: [], warnings: [] };
562
+ });
563
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "floci" }), plugins: [], serializers: [] });
564
+ expect(exit).toBe(0);
565
+ expect(seenDuringObserve).toBe("http://real-endpoint.example"); // ambient wins
566
+ expect(process.env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // untouched
567
+ expect(stderrBuf.join("\n")).toMatch(/ambient AWS_ENDPOINT_URL already set/);
568
+ });
569
+ });
421
570
  });
422
571
  });
@@ -2,10 +2,11 @@ import { resolve } from "node:path";
2
2
  import { discoverOps } from "../../op/discover";
3
3
  import { discover } from "../../discovery/index";
4
4
  import { partitionByLexicon, computeStackGraph, build } from "../../build";
5
- import { buildGraphIr, buildLiveGraphIr, overlayGraphs, sourceOverlayGraphs, type GraphIR } from "../../graph-ir";
5
+ import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
6
6
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
7
7
  import { observeResources } from "../../lifecycle/observe";
8
- import { loadChantConfig } from "../../config";
8
+ import { loadChantConfig, environmentNames } from "../../config";
9
+ import { applyLiveEndpoint } from "../../live-endpoint";
9
10
  import { applyDetail, type DetailLevel } from "../../graph-detail";
10
11
  import { applyLens, parseLens } from "../../graph-lens";
11
12
  import { toMermaid } from "../../graph-mermaid";
@@ -16,7 +17,7 @@ import { loadPlugins, resolveProjectLexicons } from "../plugins";
16
17
  import { readFileSync } from "node:fs";
17
18
  import { formatError, formatWarning, formatBold } from "../format";
18
19
  import type { CommandContext } from "../registry";
19
- import { computeComponentGraph } from "../../components/cli-support";
20
+ import { computeComponentGraph, generateComponentsPipeline } from "../../components/cli-support";
20
21
  import { discoverComponents } from "../../components/discover";
21
22
  import { cfnDeployStacks } from "./components";
22
23
 
@@ -33,6 +34,18 @@ import { cfnDeployStacks } from "./components";
33
34
  export async function runGraph(ctx: CommandContext): Promise<number> {
34
35
  const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
35
36
  const isViewFormat = (viewFormats as readonly string[]).includes(ctx.args.format);
37
+ // `--projection <lexicon>` (#989) only means anything for the component
38
+ // graph's IR — it adds the CI/pipeline shape to `GraphIR.pipeline`, a field
39
+ // the other view formats' emitters (mermaid/dot/layout) don't read, and the
40
+ // plain (non-`--format`) `--components` text/`--json` modes don't build an
41
+ // IR at all. Reject every other combination up front rather than silently
42
+ // ignoring the flag.
43
+ if (ctx.args.projection && !(ctx.args.components && ctx.args.format === "ir")) {
44
+ console.error(formatError({
45
+ message: "--projection needs --components --format ir — the CI/pipeline projection extends the component-graph IR, the only mode that carries it.",
46
+ }));
47
+ return 1;
48
+ }
36
49
  // `--live` graphs the provisioned (observed) infrastructure, not the declared
37
50
  // source (epic #776). It only makes sense as a view format; default to `ir`.
38
51
  if (ctx.args.live) {
@@ -76,10 +89,11 @@ async function runGraphLive(
76
89
  // lexicon), so `ctx.plugins` is empty. The live path needs the project's
77
90
  // observation plugins — load them here, mirroring the lifecycle handlers.
78
91
  const plugins = ctx.plugins.length > 0 ? ctx.plugins : await loadPlugins(await resolveProjectLexicons(projectPath));
79
- if (config.environments && !config.environments.includes(environment)) {
92
+ const declaredEnvNames = environmentNames(config.environments);
93
+ if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
80
94
  console.error(formatError({
81
95
  message: `Unknown environment "${environment}"`,
82
- hint: `Defined environments: ${config.environments.join(", ")}`,
96
+ hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
83
97
  }));
84
98
  return 1;
85
99
  }
@@ -121,30 +135,58 @@ async function runGraphLive(
121
135
  console.error(formatWarning({ message: "component discovery failed — observing the single-stack convention instead" }));
122
136
  }
123
137
 
124
- const { observations, errors } = await observeResources(environment, observing, buildResult, {
125
- owned: true,
126
- stacks: [...stacks],
127
- });
128
- for (const e of errors) console.error(formatWarning({ message: e }));
138
+ // #1166 an environment can declare its own endpoint (a local emulator like
139
+ // Floci), so this read is self-sufficient even when the ambient shell never
140
+ // exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
141
+ // Scoped to just this describe/enrich pass — restored in `finally` so it
142
+ // never leaks into a later invocation in the same process.
143
+ const endpointResult = applyLiveEndpoint(config.environments, environment, observing.map((p) => p.name));
144
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
145
+
146
+ let ir: GraphIR;
147
+ let observations: LiveObservation[];
148
+ try {
149
+ const observeResult = await observeResources(environment, observing, buildResult, {
150
+ owned: true,
151
+ stacks: [...stacks],
152
+ });
153
+ observations = observeResult.observations;
154
+ const { errors, warnings } = observeResult;
155
+ for (const e of errors) console.error(formatWarning({ message: e }));
156
+ // Unobserved entities (#1089) arrive as warnings — a node missing from the
157
+ // live graph because nobody looked is a different fact from one that isn't
158
+ // deployed, and the diagram alone cannot say which. Capped: an estate with no
159
+ // ownership markers can produce one per declared entity, and a wall of them
160
+ // buries the graph output. The full list is `lifecycle diff --live`.
161
+ const WARN_CAP = 5;
162
+ for (const w of warnings.slice(0, WARN_CAP)) console.error(formatWarning({ message: w }));
163
+ if (warnings.length > WARN_CAP) {
164
+ console.error(formatWarning({
165
+ message: `... and ${warnings.length - WARN_CAP} more entity(ies) not observed — run \`chant lifecycle diff ${environment} --live\` for the full list`,
166
+ }));
167
+ }
129
168
 
130
- let ir: GraphIR = buildLiveGraphIr(observations);
169
+ ir = buildLiveGraphIr(observations);
131
170
 
132
- // Enrich node attrs from the fuller live config (#784) so references are
133
- // present for edge reconstruction — describeResources metadata alone is often
134
- // too thin (e.g. AWS returns stack outputs, not per-resource references).
135
- for (const p of observing) {
136
- if (!p.enrichLiveAttrs) continue;
137
- try {
138
- const enriched = await p.enrichLiveAttrs({ environment, owned: true });
139
- ir = {
140
- ...ir,
141
- nodes: ir.nodes.map((n) =>
142
- n.lexicon === p.name && enriched[n.id] ? { ...n, attrs: { ...n.attrs, ...enriched[n.id] } } : n,
143
- ),
144
- };
145
- } catch (err) {
146
- console.error(formatWarning({ message: `${p.name}: live attr enrichment failed — edges may be sparse (${err instanceof Error ? err.message : String(err)})` }));
171
+ // Enrich node attrs from the fuller live config (#784) so references are
172
+ // present for edge reconstruction — describeResources metadata alone is often
173
+ // too thin (e.g. AWS returns stack outputs, not per-resource references).
174
+ for (const p of observing) {
175
+ if (!p.enrichLiveAttrs) continue;
176
+ try {
177
+ const enriched = await p.enrichLiveAttrs({ environment, owned: true });
178
+ ir = {
179
+ ...ir,
180
+ nodes: ir.nodes.map((n) =>
181
+ n.lexicon === p.name && enriched[n.id] ? { ...n, attrs: { ...n.attrs, ...enriched[n.id] } } : n,
182
+ ),
183
+ };
184
+ } catch (err) {
185
+ console.error(formatWarning({ message: `${p.name}: live attr enrichment failed — edges may be sparse (${err instanceof Error ? err.message : String(err)})` }));
186
+ }
147
187
  }
188
+ } finally {
189
+ endpointResult.restore();
148
190
  }
149
191
 
150
192
  // Reconstruct edges + containment from live references (#778): merge the
@@ -166,10 +208,14 @@ async function runGraphLive(
166
208
  const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."));
167
209
  if (declared.errors.length === 0) {
168
210
  const declaredIr = buildGraphIr(declared.entities, projectPath);
211
+ // Declared nodes chant could not read are painted `neutral`, not
212
+ // `accent`/pending (#1089) — a wrong-cluster or unsupported-kind read
213
+ // must not draw the estate as "not deployed yet".
214
+ const overlayOpts = { unobserved: collectUnobserved(observations) };
169
215
  ir =
170
216
  args.overlayAnchor === "live"
171
- ? overlayGraphs(ir, declaredIr)
172
- : sourceOverlayGraphs(declaredIr, ir);
217
+ ? overlayGraphs(ir, declaredIr, overlayOpts)
218
+ : sourceOverlayGraphs(declaredIr, ir, overlayOpts);
173
219
  } else {
174
220
  console.error(formatWarning({ message: "overlay: source has discovery errors — showing the provisioned graph without the declared overlay" }));
175
221
  }
@@ -247,6 +293,15 @@ async function runComponentGraph(ctx: CommandContext): Promise<number> {
247
293
  * the CI pipeline. Distinct from `runGraphView`, which emits the AWS *entity*
248
294
  * graph — the component projection has one node per component, not per resource.
249
295
  *
296
+ * `--projection <lexicon>` (#989, `--format ir` only — validated in `runGraph`)
297
+ * adds the **CI/pipeline projection** alongside this component graph:
298
+ * `ir.pipeline` carries the stages/jobs/`needs` `<lexicon>`'s
299
+ * `generateComponentPipeline` synthesizes for `chant build --components
300
+ * --generate <lexicon>` (`generateComponentsPipeline`, ../../components/cli-support.ts)
301
+ * — reused wholesale, not re-derived, so a consumer (e.g. behold, epic #492/
302
+ * INTENTIUS/behold#54) gets the pipeline shape as first-class IR nodes/edges
303
+ * instead of re-deriving it from `dependsOn` or parsing generated CI YAML.
304
+ *
250
305
  * Lint-gated like the entity view: the DAG stands for deployable source, so we
251
306
  * refuse to emit it for source that does not pass lint.
252
307
  */
@@ -278,7 +333,7 @@ async function runComponentGraphView(
278
333
  const waveOf = new Map<string, number>();
279
334
  graph.waves.forEach((wave, i) => wave.forEach((name) => waveOf.set(name, i + 1)));
280
335
 
281
- const ir: GraphIR = {
336
+ let ir: GraphIR = {
282
337
  nodes: graph.order.map((name) => ({
283
338
  id: name,
284
339
  kind: "Component",
@@ -293,9 +348,49 @@ async function runComponentGraphView(
293
348
  },
294
349
  };
295
350
 
351
+ if (ctx.args.projection) {
352
+ const pipeline = await buildPipelineProjection(projectPath, ctx.args.projection, ctx.args.sandbox);
353
+ if (!pipeline.success) {
354
+ console.error(formatError({ message: pipeline.error ?? `Failed to generate ${ctx.args.projection} pipeline projection` }));
355
+ return 1;
356
+ }
357
+ ir = { ...ir, pipeline: pipeline.pipeline };
358
+ }
359
+
296
360
  return emitIr(ir, ctx, format);
297
361
  }
298
362
 
363
+ /**
364
+ * Reshape `generateComponentsPipeline`'s result (../../components/cli-support.ts
365
+ * — the exact function `chant build --components --generate <lexicon>` calls)
366
+ * into the IR's `IRPipeline` vocabulary (#989): one `IRPipelineNode` per
367
+ * generated CI job, one `IRPipelineEdge` per `needs:` dependency (consumer job
368
+ * → producer job, mirroring the component edges' consumer → producer
369
+ * direction). Every shape decision — job naming, one stage per wave,
370
+ * dependency resolution — stays owned by `lexicon`'s `generateComponentPipeline`;
371
+ * this only relabels its `{ stages, jobs }` output as IR nodes/edges, it never
372
+ * re-derives the graph.
373
+ */
374
+ async function buildPipelineProjection(
375
+ projectPath: string,
376
+ lexicon: string,
377
+ sandbox?: boolean,
378
+ ): Promise<{ success: true; pipeline: IRPipeline } | { success: false; error?: string }> {
379
+ const result = await generateComponentsPipeline(projectPath, lexicon, undefined, sandbox);
380
+ if (!result.success) return { success: false, error: result.error };
381
+
382
+ const jobs = result.jobs ?? [];
383
+ return {
384
+ success: true,
385
+ pipeline: {
386
+ provider: lexicon,
387
+ stages: result.stages ?? [],
388
+ nodes: jobs.map((j) => ({ id: j.jobName, kind: "CIJob" as const, component: j.component, stage: j.stage })),
389
+ edges: jobs.flatMap((j) => j.needs.map((dep) => ({ from: j.jobName, to: dep, kind: "needs" as const }))),
390
+ },
391
+ };
392
+ }
393
+
299
394
  /**
300
395
  * `chant graph --format ir|mermaid|dot|layout` — build the graph IR (honouring
301
396
  * `--detail`) and emit it as JSON, a Mermaid flowchart, Graphviz DOT, or node