@intentius/chant 0.44.2 → 0.44.4

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/build.d.ts CHANGED
@@ -203,6 +203,29 @@ export declare function collectLexiconOutputs(entities: Map<string, Declarable>)
203
203
  * @returns Array of auto-detected LexiconOutput instances
204
204
  */
205
205
  export declare function detectCrossLexiconRefs(entities: Map<string, Declarable>): LexiconOutput[];
206
+ /** What merging build-root contributions produced: non-fatal render notes and
207
+ * fatal messages (a failed contributor, a name collision). */
208
+ export interface BuildRootMergeResult {
209
+ warnings: string[];
210
+ errors: string[];
211
+ }
212
+ /**
213
+ * Run each build-root contributor (#1548 piece 3) and merge its rendered
214
+ * entities into `entities`, in place. THE one merge implementation — `build()`
215
+ * uses it for step 4b, and the graph handler's discover-based paths reuse it
216
+ * so a rendered kustomize root joins the graphed entity set under exactly the
217
+ * rules the build applies (#1626):
218
+ *
219
+ * - a contributed name colliding with an existing entity is an error, never a
220
+ * silent overwrite;
221
+ * - a contributor that throws becomes an error carrying its message (the k8s
222
+ * hook's missing-binary refusal names the binaries), not a stack trace.
223
+ *
224
+ * Errors come back as plain messages; each caller wraps them in its own error
225
+ * vocabulary (`BuildErrorClass` in `build()`, the CLI's `formatError` on the
226
+ * graph paths).
227
+ */
228
+ export declare function mergeBuildRootEntities(entities: Map<string, Declarable>, contributors: ReadonlyArray<() => Promise<BuildRootContribution>>): Promise<BuildRootMergeResult>;
206
229
  /**
207
230
  * Builds a lexicon specification by discovering entities, sorting them
208
231
  * topologically, and serializing them using the lexicon serializers.
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAsClD;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCnE"}
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAqGlD;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCnE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.44.2",
3
+ "version": "0.44.4",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/build.ts CHANGED
@@ -565,6 +565,53 @@ function generateManifest(
565
565
  };
566
566
  }
567
567
 
568
+ /** What merging build-root contributions produced: non-fatal render notes and
569
+ * fatal messages (a failed contributor, a name collision). */
570
+ export interface BuildRootMergeResult {
571
+ warnings: string[];
572
+ errors: string[];
573
+ }
574
+
575
+ /**
576
+ * Run each build-root contributor (#1548 piece 3) and merge its rendered
577
+ * entities into `entities`, in place. THE one merge implementation — `build()`
578
+ * uses it for step 4b, and the graph handler's discover-based paths reuse it
579
+ * so a rendered kustomize root joins the graphed entity set under exactly the
580
+ * rules the build applies (#1626):
581
+ *
582
+ * - a contributed name colliding with an existing entity is an error, never a
583
+ * silent overwrite;
584
+ * - a contributor that throws becomes an error carrying its message (the k8s
585
+ * hook's missing-binary refusal names the binaries), not a stack trace.
586
+ *
587
+ * Errors come back as plain messages; each caller wraps them in its own error
588
+ * vocabulary (`BuildErrorClass` in `build()`, the CLI's `formatError` on the
589
+ * graph paths).
590
+ */
591
+ export async function mergeBuildRootEntities(
592
+ entities: Map<string, Declarable>,
593
+ contributors: ReadonlyArray<() => Promise<BuildRootContribution>>,
594
+ ): Promise<BuildRootMergeResult> {
595
+ const warnings: string[] = [];
596
+ const errors: string[] = [];
597
+ for (const contribute of contributors) {
598
+ try {
599
+ const contribution = await contribute();
600
+ warnings.push(...(contribution.warnings ?? []));
601
+ for (const [name, entity] of contribution.entities) {
602
+ if (entities.has(name)) {
603
+ errors.push(`Build-root entity "${name}" collides with a discovered entity of the same name`);
604
+ continue;
605
+ }
606
+ entities.set(name, entity);
607
+ }
608
+ } catch (error) {
609
+ errors.push(error instanceof Error ? error.message : String(error));
610
+ }
611
+ }
612
+ return { warnings, errors };
613
+ }
614
+
568
615
  /**
569
616
  * Builds a lexicon specification by discovering entities, sorting them
570
617
  * topologically, and serializing them using the lexicon serializers.
@@ -680,25 +727,10 @@ async function buildFromDiscoveryResult(
680
727
  // object, and re-running the contributors there would duplicate every
681
728
  // contributed entity once per child.
682
729
  if (!parentBuildStack && options?.buildRoots) {
683
- for (const contribute of options.buildRoots) {
684
- try {
685
- const contribution = await contribute();
686
- for (const w of contribution.warnings ?? []) warnings.push(w);
687
- for (const [name, entity] of contribution.entities) {
688
- if (discoveryResult.entities.has(name)) {
689
- errors.push(
690
- new BuildErrorClass(
691
- "",
692
- `Build-root entity "${name}" collides with a discovered entity of the same name`,
693
- ),
694
- );
695
- continue;
696
- }
697
- discoveryResult.entities.set(name, entity);
698
- }
699
- } catch (error) {
700
- errors.push(new BuildErrorClass("", error instanceof Error ? error.message : String(error)));
701
- }
730
+ const merged = await mergeBuildRootEntities(discoveryResult.entities, options.buildRoots);
731
+ warnings.push(...merged.warnings);
732
+ for (const message of merged.errors) {
733
+ errors.push(new BuildErrorClass("", message));
702
734
  }
703
735
  }
704
736
 
@@ -52,10 +52,16 @@ vi.mock("../../graph-layout", () => ({
52
52
  // hit the Op-graph and source-view modes, so these mocks don't touch them).
53
53
  const loadPluginsMock = vi.fn();
54
54
  const resolveLexMock = vi.fn();
55
- vi.mock("../plugins", () => ({
56
- loadPlugins: (...a: unknown[]) => loadPluginsMock(...a),
57
- resolveProjectLexicons: (...a: unknown[]) => resolveLexMock(...a),
58
- }));
55
+ vi.mock("../plugins", async () => {
56
+ // Real `collectBuildRootContributors` (#1626): it is pure binding — the
57
+ // handler's build-root plumbing is exactly what the tests below assert.
58
+ const actual = await vi.importActual<typeof import("../plugins")>("../plugins");
59
+ return {
60
+ ...actual,
61
+ loadPlugins: (...a: unknown[]) => loadPluginsMock(...a),
62
+ resolveProjectLexicons: (...a: unknown[]) => resolveLexMock(...a),
63
+ };
64
+ });
59
65
  const observeMock = vi.fn();
60
66
  const replayMock = vi.fn();
61
67
  const hasSnapshotMock = vi.fn((..._a: unknown[]) => Promise.resolve(false));
@@ -86,17 +92,22 @@ vi.mock("../../config", async () => {
86
92
  };
87
93
  });
88
94
  const buildMock = vi.fn();
89
- vi.mock("../../build", () => ({
90
- // Forwards its arguments so a test can assert what the live path passed —
91
- // `buildParams` in particular (#1483), the same reason `discover` above
92
- // forwards for #1359.
93
- build: (...a: unknown[]) => {
94
- buildMock(...a);
95
- return Promise.resolve({ errors: [] });
96
- },
97
- partitionByLexicon: () => ({}),
98
- computeStackGraph: () => ({}),
99
- }));
95
+ vi.mock("../../build", async () => {
96
+ // Real `mergeBuildRootEntities` (#1626): the graph paths reuse build's one
97
+ // merge implementation, so the tests exercise the real merge rules
98
+ // (collision error) rather than a re-stubbed copy of them.
99
+ const actual = await vi.importActual<typeof import("../../build")>("../../build");
100
+ return {
101
+ ...actual,
102
+ // Forwards its arguments so a test can assert what the live path passed —
103
+ // `buildParams` in particular (#1483), the same reason `discover` above
104
+ // forwards for #1359.
105
+ build: (...a: unknown[]) => {
106
+ buildMock(...a);
107
+ return Promise.resolve({ errors: [] });
108
+ },
109
+ };
110
+ });
100
111
 
101
112
  const { runGraph } = await import("./graph");
102
113
 
@@ -812,4 +823,169 @@ describe("runGraph", () => {
812
823
  });
813
824
  });
814
825
  });
826
+
827
+ // #1626 — #1612's `buildRoots` seam reached `chant build` and the lifecycle
828
+ // handlers but none of the graph command's four entity-collection sites, so
829
+ // a `k8s.kustomize.roots` estate graphed only its typed source: the rendered
830
+ // Deployment/Service existed in the build output and on the cluster, and
831
+ // nowhere in any graph. The shapes here mirror the kustomize-root fixture
832
+ // (lexicons/k8s/src/testdata/kustomize-root, rendered as `overlays/prod`):
833
+ // the contributor below returns exactly what `renderKustomizeRoots` returns
834
+ // for it — rendered-manifest entities keyed `overlays/prod/<kindName>`,
835
+ // props = the verbatim document, provenance annotation stamped.
836
+ describe("build-root contributions reach the graph (#1626)", () => {
837
+ const KUSTOMIZE_ROOT_ANNOTATION = "chant.intentius.io/kustomize-root";
838
+
839
+ /** A rendered kustomize document as `renderedManifestEntity` shapes it. */
840
+ const rendered = (group: string, kind: string, name: string, spec: Record<string, unknown>): Declarable =>
841
+ ({
842
+ lexicon: "k8s",
843
+ entityType: `K8s::${group}::${kind}`,
844
+ kind: "resource",
845
+ props: {
846
+ apiVersion: group === "Core" ? "v1" : `${group.toLowerCase()}/v1`,
847
+ kind,
848
+ metadata: { name, annotations: { [KUSTOMIZE_ROOT_ANNOTATION]: "overlays/prod" } },
849
+ spec,
850
+ },
851
+ [DECLARABLE_MARKER]: true,
852
+ [Symbol.for("chant.k8s.renderedManifest")]: true,
853
+ }) as unknown as Declarable;
854
+
855
+ const fixtureContribution = () => ({
856
+ entities: new Map<string, Declarable>([
857
+ ["overlays/prod/serviceProdWeb", rendered("Core", "Service", "prod-web", { selector: { app: "web" } })],
858
+ ["overlays/prod/deploymentProdWeb", rendered("Apps", "Deployment", "prod-web", { replicas: 3 })],
859
+ ]),
860
+ });
861
+
862
+ /** A k8s-shaped plugin whose `buildRoots` hook renders the fixture. */
863
+ const k8sPluginStub = (hook = async () => fixtureContribution()) => ({
864
+ name: "k8s",
865
+ serializer: {},
866
+ describeResources: () => Promise.resolve({}),
867
+ buildRoots: hook,
868
+ });
869
+
870
+ const declaredNamespace = (): void => {
871
+ discoverMock.mockResolvedValue({
872
+ entities: new Map<string, Declarable>([["ns", decl({ lexicon: "k8s", entityType: "K8s::Core::Namespace", kind: "resource", props: { metadata: { name: "web" } } })]]),
873
+ errors: [], dependencies: new Map(), sourceFiles: [],
874
+ });
875
+ };
876
+
877
+ const staticWithFixtureRoot = (hook?: () => Promise<{ entities: Map<string, Declarable> }>): void => {
878
+ lintMock.mockResolvedValue({ success: true });
879
+ declaredNamespace();
880
+ resolveLexMock.mockResolvedValue(["k8s"]);
881
+ loadPluginsMock.mockResolvedValue([k8sPluginStub(hook)]);
882
+ };
883
+
884
+ test("static --format ir emits the rendered nodes alongside the typed ones", async () => {
885
+ staticWithFixtureRoot();
886
+ const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
887
+ expect(exit).toBe(0);
888
+ const ir = JSON.parse(stdoutBuf.join("\n"));
889
+ expect(ir.nodes.map((n: { id: string }) => n.id).sort()).toEqual([
890
+ "ns",
891
+ "overlays/prod/deploymentProdWeb",
892
+ "overlays/prod/serviceProdWeb",
893
+ ]);
894
+ const deployment = ir.nodes.find((n: { id: string }) => n.id === "overlays/prod/deploymentProdWeb");
895
+ expect(deployment.kind).toBe("K8s::Apps::Deployment");
896
+ expect(deployment.lexicon).toBe("k8s");
897
+ });
898
+
899
+ // #1624 made attrs projection monotonic; the provenance annotation is a
900
+ // nested `metadata` tree, so it belongs to the full-property tier and only
901
+ // that tier — present at --detail 3, absent from --detail 1's topology view.
902
+ test("--detail 3 carries the provenance annotation in the rendered node's attrs; --detail 1 does not", async () => {
903
+ staticWithFixtureRoot();
904
+ expect(await runGraph({ args: makeArgs({ format: "ir", detail: 3 }), plugins: [], serializers: [] })).toBe(0);
905
+ const detailed = JSON.parse(stdoutBuf.join("\n"));
906
+ const at3 = detailed.nodes.find((n: { id: string }) => n.id === "overlays/prod/deploymentProdWeb");
907
+ expect(at3.attrs.metadata.annotations[KUSTOMIZE_ROOT_ANNOTATION]).toBe("overlays/prod");
908
+
909
+ stdoutBuf.length = 0;
910
+ staticWithFixtureRoot();
911
+ expect(await runGraph({ args: makeArgs({ format: "ir", detail: 1 }), plugins: [], serializers: [] })).toBe(0);
912
+ const coarse = JSON.parse(stdoutBuf.join("\n"));
913
+ const at1 = coarse.nodes.find((n: { id: string }) => n.id === "overlays/prod/deploymentProdWeb");
914
+ expect(at1).toBeDefined(); // the node survives the tier —
915
+ expect(at1.attrs.metadata).toBeUndefined(); // its property tree does not
916
+ });
917
+
918
+ test("a contributed name colliding with a discovered entity fails the graph like a discovery error", async () => {
919
+ staticWithFixtureRoot(async () => ({
920
+ entities: new Map<string, Declarable>([["ns", rendered("Core", "Namespace", "web", {})]]),
921
+ }));
922
+ const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
923
+ expect(exit).toBe(1);
924
+ expect(stdoutBuf.join("\n")).toBe("");
925
+ expect(stderrBuf.join("\n")).toContain('"ns" collides');
926
+ });
927
+
928
+ test("--stacks joins the rendered entities into the stack partitions", async () => {
929
+ declaredNamespace();
930
+ resolveLexMock.mockResolvedValue(["k8s"]);
931
+ loadPluginsMock.mockResolvedValue([k8sPluginStub()]);
932
+ const exit = await runGraph({ args: makeArgs({ stacks: true, json: true }), plugins: [], serializers: [] });
933
+ expect(exit).toBe(0);
934
+ // partitionByLexicon/computeStackGraph are stubbed above; what matters is
935
+ // that the merge ran before them without erroring. The collision case
936
+ // proves the same merge is live on this path:
937
+ stderrBuf.length = 0;
938
+ declaredNamespace();
939
+ loadPluginsMock.mockResolvedValue([
940
+ k8sPluginStub(async () => ({ entities: new Map<string, Declarable>([["ns", rendered("Core", "Namespace", "web", {})]]) })),
941
+ ]);
942
+ expect(await runGraph({ args: makeArgs({ stacks: true, json: true }), plugins: [], serializers: [] })).toBe(1);
943
+ expect(stderrBuf.join("\n")).toContain('"ns" collides');
944
+ });
945
+
946
+ test("--live passes the contributors through to build(), like lifecycle does", async () => {
947
+ resolveLexMock.mockResolvedValue(["k8s"]);
948
+ loadPluginsMock.mockResolvedValue([k8sPluginStub()]);
949
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
950
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "kcheck" }), plugins: [], serializers: [] });
951
+ expect(exit).toBe(0);
952
+ const [, , , options] = buildMock.mock.calls[0] as [string, unknown, unknown, { buildRoots?: unknown[] }];
953
+ expect(options.buildRoots).toHaveLength(1);
954
+ });
955
+
956
+ test("--live --overlay classifies an observed rendered node as managed, not foreign", async () => {
957
+ resolveLexMock.mockResolvedValue(["k8s"]);
958
+ loadPluginsMock.mockResolvedValue([k8sPluginStub()]);
959
+ declaredNamespace();
960
+ // The cluster has the namespace and the rendered Deployment — the
961
+ // latter observed through the same entityType/props flow as any
962
+ // declared entity (its props carry metadata.name like typed ones).
963
+ observeMock.mockResolvedValue({
964
+ observations: [{
965
+ lexicon: "k8s",
966
+ resources: {
967
+ ns: { type: "K8s::Core::Namespace", status: "OBSERVED", physicalId: "uid-ns", ownership: "owned" },
968
+ "overlays/prod/deploymentProdWeb": { type: "K8s::Apps::Deployment", status: "OBSERVED", physicalId: "uid-deploy", ownership: "owned" },
969
+ },
970
+ }],
971
+ errors: [], warnings: [],
972
+ });
973
+ const exit = await runGraph({
974
+ args: makeArgs({ format: "ir", live: true, overlay: true, env: "kcheck" }),
975
+ plugins: [], serializers: [],
976
+ });
977
+ expect(exit).toBe(0);
978
+ const ir = JSON.parse(stdoutBuf.join("\n"));
979
+ const deployment = ir.nodes.find((n: { id: string }) => n.id === "overlays/prod/deploymentProdWeb");
980
+ // Declared (via the merged contribution) + observed = managed. Before
981
+ // #1626 the declared canvas lacked the rendered node, so it read as
982
+ // foreign (`warn`) despite being chant's own build output.
983
+ expect(deployment.attrs._status).toBe("good");
984
+ expect(deployment.physicalId).toBe("uid-deploy");
985
+ // The rendered Service was declared but not observed — pending, the
986
+ // same verdict a typed entity would get.
987
+ const service = ir.nodes.find((n: { id: string }) => n.id === "overlays/prod/serviceProdWeb");
988
+ expect(service.attrs._status).toBe("accent");
989
+ });
990
+ });
815
991
  });
@@ -1,7 +1,7 @@
1
- import { resolve } from "node:path";
1
+ import { dirname, resolve } from "node:path";
2
2
  import { discoverOps } from "../../op/discover";
3
3
  import { discover } from "../../discovery/index";
4
- import { partitionByLexicon, computeStackGraph, build } from "../../build";
4
+ import { partitionByLexicon, computeStackGraph, build, mergeBuildRootEntities } from "../../build";
5
5
  import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
6
6
  import { buildDeclaredPerStack } from "../../graph-declared";
7
7
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
@@ -15,7 +15,7 @@ import { toMermaid } from "../../graph-mermaid";
15
15
  import { toDot } from "../../graph-dot";
16
16
  import { getLayoutEngine, toLayoutInput, type NodeSize } from "../../graph-layout";
17
17
  import { lintCommand } from "../commands/lint";
18
- import { loadPlugins, resolveProjectLexicons } from "../plugins";
18
+ import { loadPlugins, resolveProjectLexicons, collectBuildRootContributors } from "../plugins";
19
19
  import { readFileSync } from "node:fs";
20
20
  import { formatError, formatWarning, formatBold } from "../format";
21
21
  import type { CommandContext } from "../registry";
@@ -56,6 +56,69 @@ async function graphBuildParams(
56
56
  return resolution.provenance;
57
57
  }
58
58
 
59
+ /**
60
+ * The build-root contributors this invocation's project configures (#1626) —
61
+ * the same closures `chant build` and the lifecycle handlers hand to
62
+ * `BuildOptions.buildRoots`, bound here for the graph command's
63
+ * discover-based paths, which never run `build()` and so never ran the hook:
64
+ * a `k8s.kustomize.roots` estate graphed only its typed source while its
65
+ * built (and deployed) output carried the rendered manifests too.
66
+ *
67
+ * `graph` is not `requiresPlugins` (Op/source-graph modes must work without a
68
+ * lexicon), so plugins are loaded here when `ctx.plugins` is empty, mirroring
69
+ * `runGraphLive`. Rooted at the config's own directory, like
70
+ * `cli/commands/build.ts` — a relative kustomize root resolves against where
71
+ * it was declared, not against a sourceDir-scoped graph path. A project that
72
+ * fails to load plugins degrades to no contributors with a warning rather
73
+ * than failing modes that never needed plugins at all.
74
+ */
75
+ async function graphBuildRootContributors(
76
+ ctx: CommandContext,
77
+ projectPath: string,
78
+ ): Promise<Array<() => Promise<import("../../lexicon").BuildRootContribution>>> {
79
+ const { config, configPath } = await loadChantConfigUpward(projectPath).catch(
80
+ () => ({ config: {} as ChantConfig, configPath: undefined }),
81
+ );
82
+ let plugins = ctx.plugins;
83
+ if (plugins.length === 0) {
84
+ try {
85
+ plugins = await loadPlugins(await resolveProjectLexicons(projectPath));
86
+ } catch (err) {
87
+ console.error(formatWarning({
88
+ message: `could not load the project's lexicons — build-root entities (kustomize roots) will be missing from the graph (${err instanceof Error ? err.message : String(err)})`,
89
+ }));
90
+ return [];
91
+ }
92
+ }
93
+ return collectBuildRootContributors(
94
+ plugins,
95
+ config as unknown as Record<string, unknown>,
96
+ configPath ? dirname(configPath) : projectPath,
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Merge build-root contributions into a discovered entity set — the exact
102
+ * merge `build()` performs (one implementation: `mergeBuildRootEntities`,
103
+ * ../../build.ts), surfaced in the graph command's error style. Returns false
104
+ * when a contributor failed or a name collided; the caller stops, matching
105
+ * how the discover-based paths treat discovery errors — a graph missing the
106
+ * entities the build would refuse over must not be emitted as if complete.
107
+ */
108
+ async function mergeGraphBuildRoots(
109
+ entities: Map<string, import("../../declarable").Declarable>,
110
+ contributors: Array<() => Promise<import("../../lexicon").BuildRootContribution>>,
111
+ ): Promise<boolean> {
112
+ if (contributors.length === 0) return true;
113
+ const merged = await mergeBuildRootEntities(entities, contributors);
114
+ for (const w of merged.warnings) console.error(formatWarning({ message: w }));
115
+ if (merged.errors.length > 0) {
116
+ for (const message of merged.errors) console.error(formatError({ message }));
117
+ return false;
118
+ }
119
+ return true;
120
+ }
121
+
59
122
  /**
60
123
  * `chant graph` — the Op dependency graph by default; `--stacks` renders the
61
124
  * cross-stack apply-ordering graph (edges, order, waves) chant computes from
@@ -154,11 +217,17 @@ async function runGraphLive(
154
217
  // a confidently wrong overlay rather than an empty one.
155
218
  const liveBuildParams = await graphBuildParams(ctx, projectPath);
156
219
  if (!liveBuildParams) return 1;
220
+ // #1626 — the same build-root contributors `chant build` runs (kustomize
221
+ // roots rendered into entities), so the scope handed to describeResources
222
+ // includes the rendered objects and the live read observes them like any
223
+ // declared entity. Without this the built estate carried them but the graph
224
+ // build here didn't, and the overlay called every one of them foreign.
225
+ const buildRoots = collectBuildRootContributors(plugins, config as unknown as Record<string, unknown>, projectPath);
157
226
  const buildResult = await build(
158
227
  resolve(args.src ?? config.sourceDir ?? "."),
159
228
  plugins.map((p) => p.serializer),
160
229
  undefined,
161
- { buildParams: liveBuildParams },
230
+ { buildParams: liveBuildParams, buildRoots },
162
231
  );
163
232
  if (buildResult.errors.length > 0) {
164
233
  console.error(formatError({ message: "Build failed — fix errors before graphing live state" }));
@@ -323,14 +392,23 @@ async function runGraphLive(
323
392
  const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."), {
324
393
  ...(declaredParams ? { buildParams: declaredParams } : {}),
325
394
  });
326
- if (declared.errors.length === 0) {
395
+ // #1626 — the declared canvas must include what the build declares, and
396
+ // that is discovery PLUS build-root contributions (rendered kustomize
397
+ // objects). Same contributors, same merge as the `build()` above — so
398
+ // the rendered nodes classify `good`/`accent` like typed ones instead
399
+ // of reading as foreign. A merge that fails here (it just succeeded in
400
+ // the build above, so only a non-deterministic render gets this far)
401
+ // degrades exactly like discovery errors: overlay skipped, said once.
402
+ if (declared.errors.length > 0) {
403
+ console.error(formatWarning({ message: "overlay: source has discovery errors — showing the provisioned graph without the declared overlay" }));
404
+ } else if (!(await mergeGraphBuildRoots(declared.entities, buildRoots))) {
405
+ console.error(formatWarning({ message: "overlay: a build root failed to render — showing the provisioned graph without the declared overlay" }));
406
+ } else {
327
407
  const declaredIr = buildGraphIr(declared.entities, projectPath);
328
408
  ir =
329
409
  args.overlayAnchor === "live"
330
410
  ? overlayGraphs(ir, declaredIr, overlayOpts)
331
411
  : sourceOverlayGraphs(declaredIr, ir, overlayOpts);
332
- } else {
333
- console.error(formatWarning({ message: "overlay: source has discovery errors — showing the provisioned graph without the declared overlay" }));
334
412
  }
335
413
  }
336
414
  }
@@ -584,6 +662,14 @@ async function runGraphView(
584
662
  return 1;
585
663
  }
586
664
 
665
+ // #1626 — join the build-root contributions (rendered kustomize objects) to
666
+ // the discovered set, the way `build()` does before partitioning, so the
667
+ // static graph shows the same estate the build emits. Fatal on failure,
668
+ // like discovery errors: this graph stands for what would build.
669
+ if (!(await mergeGraphBuildRoots(result.entities, await graphBuildRootContributors(ctx, projectPath)))) {
670
+ return 1;
671
+ }
672
+
587
673
  // Build the base IR, focus with a lens (declarable-level, most precise), then
588
674
  // apply the detail tier — so e.g. blast:<resource> works before any collapse.
589
675
  let ir: GraphIR = buildGraphIr(result.entities, projectPath);
@@ -703,6 +789,12 @@ async function runStackGraph(ctx: CommandContext): Promise<number> {
703
789
  return 1;
704
790
  }
705
791
 
792
+ // #1626 — same join as runGraphView: contributed entities are part of the
793
+ // built estate, so they belong in its stack partitions too.
794
+ if (!(await mergeGraphBuildRoots(result.entities, await graphBuildRootContributors(ctx, projectPath)))) {
795
+ return 1;
796
+ }
797
+
706
798
  const lexicons = [...partitionByLexicon(result.entities).keys()];
707
799
  const graph = computeStackGraph(result.entities, lexicons);
708
800