@intentius/chant 0.44.1 → 0.44.3

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 (46) hide show
  1. package/dist/build.d.ts +23 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts +10 -0
  4. package/dist/cli/commands/lint.d.ts.map +1 -1
  5. package/dist/cli/handlers/graph.d.ts.map +1 -1
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +1 -1
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/governance.d.ts +1 -1
  10. package/dist/graph-detail.d.ts +31 -13
  11. package/dist/graph-detail.d.ts.map +1 -1
  12. package/dist/lifecycle/change-set.d.ts +7 -0
  13. package/dist/lifecycle/change-set.d.ts.map +1 -1
  14. package/dist/lifecycle/live-diff.d.ts +18 -0
  15. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  16. package/dist/lifecycle/observe.d.ts.map +1 -1
  17. package/dist/lint/component-checks.d.ts +2 -1
  18. package/dist/lint/component-checks.d.ts.map +1 -1
  19. package/dist/observation.d.ts +33 -3
  20. package/dist/observation.d.ts.map +1 -1
  21. package/dist/reconcile.d.ts +5 -5
  22. package/dist/reconcile.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/build.ts +51 -19
  25. package/src/cli/commands/lint.ts +14 -3
  26. package/src/cli/handlers/components.ts +1 -1
  27. package/src/cli/handlers/graph.test.ts +191 -15
  28. package/src/cli/handlers/graph.ts +131 -14
  29. package/src/cli/handlers/lifecycle.ts +8 -2
  30. package/src/codegen/publish-order.test.ts +1 -1
  31. package/src/codegen/release-wiring.test.ts +3 -4
  32. package/src/components/cli-support.test.ts +52 -0
  33. package/src/components/cli-support.ts +13 -2
  34. package/src/governance.test.ts +1 -1
  35. package/src/governance.ts +1 -1
  36. package/src/graph-detail.test.ts +117 -6
  37. package/src/graph-detail.ts +76 -18
  38. package/src/lifecycle/change-set.test.ts +26 -0
  39. package/src/lifecycle/change-set.ts +13 -0
  40. package/src/lifecycle/live-diff.test.ts +35 -0
  41. package/src/lifecycle/live-diff.ts +21 -0
  42. package/src/lifecycle/observe.ts +2 -1
  43. package/src/lint/component-checks.ts +8 -1
  44. package/src/observation.test.ts +54 -7
  45. package/src/observation.ts +53 -13
  46. package/src/reconcile.ts +7 -7
@@ -1,4 +1,5 @@
1
1
  import { resolve, join, relative } from "path";
2
+ import type { BuildParamProvenance } from "../../provenance";
2
3
  import { readFileSync, writeFileSync, readdirSync, statSync } from "fs";
3
4
  import { execFileSync } from "child_process";
4
5
  import { runLint, parseDisableComments } from "../../lint/engine";
@@ -152,6 +153,15 @@ async function loadAllPluginRules(
152
153
  export interface LintOptions {
153
154
  /** Path to lint */
154
155
  path: string;
156
+ /**
157
+ * This invocation's resolved build parameters (#1490).
158
+ *
159
+ * The COMP* checks import `*.component.ts`, and an ES module evaluates once
160
+ * per path — so the values in effect during the lint gate are the values
161
+ * every later reader observes. A caller that lints before it graphs must
162
+ * pass the same parameters to both or the later resolution has no effect.
163
+ */
164
+ buildParams?: BuildParamProvenance[];
155
165
  /** Apply auto-fixes */
156
166
  fix?: boolean;
157
167
  /** Output format */
@@ -334,6 +344,7 @@ async function resolveRegistryContext(
334
344
  async function runComponentCheckDiagnostics(
335
345
  infraPath: string,
336
346
  sandbox?: boolean,
347
+ buildParams?: BuildParamProvenance[],
337
348
  ): Promise<{ diagnostics: LintDiagnostic[]; suppressed: Array<LintDiagnostic & { reason?: string }> }> {
338
349
  // Discovery stays scoped to the lint arg; COMP* severity overrides come from
339
350
  // the project-root config, same as the AST-rule pass.
@@ -342,7 +353,7 @@ async function runComponentCheckDiagnostics(
342
353
  const allCheckIds = new Set(checks.map((c) => c.id));
343
354
 
344
355
  const registryContext = await resolveRegistryContext(infraPath);
345
- const raw = await runComponentChecks(infraPath, checks, registryContext, sandbox);
356
+ const raw = await runComponentChecks(infraPath, checks, registryContext, sandbox, buildParams);
346
357
 
347
358
  const fileDirectivesCache = new Map<string, ReturnType<typeof parseDisableComments>>();
348
359
  const fileLevelDisable = (
@@ -483,7 +494,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
483
494
  // structurally distinct check family (whole-project, post-discovery,
484
495
  // see ../../lint/component-checks.ts) but the same `chant lint` output and
485
496
  // the same error-severity gating as every COR/EVL diagnostic.
486
- const componentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox);
497
+ const componentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox, options.buildParams);
487
498
  diagnostics.push(...componentResult.diagnostics);
488
499
  suppressed.push(...componentResult.suppressed);
489
500
 
@@ -531,7 +542,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
531
542
  // `*.component.ts` file on their behalf), but a fix applied to another
532
543
  // rule could still be in the same file a component was discovered from —
533
544
  // re-run for consistency with the AST re-lint above.
534
- const postComponentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox);
545
+ const postComponentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox, options.buildParams);
535
546
  diagnostics.push(...postComponentResult.diagnostics);
536
547
  suppressed.push(...postComponentResult.suppressed);
537
548
  }
@@ -430,7 +430,7 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
430
430
  // rather than "stale" (recorded, and nothing live).
431
431
  const message = err instanceof Error ? err.message : String(err);
432
432
  console.error(formatWarning({ message: `${plugin.name}: describeResources failed — ${message} (components in this lexicon report unknown, not stale)` }));
433
- observed = { resources: {}, unobserved: unobservedAll(declared, "read-failed", message, entities) };
433
+ observed = { resources: {}, unobserved: unobservedAll(declared, "read-failed", message, entities), queried: {} };
434
434
  }
435
435
  const cs = buildChangeSet(environment, {
436
436
  declared,
@@ -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
  }
@@ -378,7 +456,11 @@ async function runGraphLive(
378
456
  */
379
457
  async function runComponentGraph(ctx: CommandContext): Promise<number> {
380
458
  const projectPath = resolve(ctx.args.path === "." ? "." : ctx.args.path);
381
- const graph = await computeComponentGraph(projectPath, ctx.args.sandbox);
459
+ // #1490 as in runComponentGraphView: the text/--json mode reads the same
460
+ // source and must not disagree with the --format modes about it.
461
+ const componentParams = await graphBuildParams(ctx, projectPath);
462
+ if (!componentParams) return 1;
463
+ const graph = await computeComponentGraph(projectPath, ctx.args.sandbox, componentParams);
382
464
 
383
465
  if (!graph.success) {
384
466
  console.error(formatError({ message: graph.error ?? "Failed to compute component graph" }));
@@ -434,7 +516,20 @@ async function runComponentGraphView(
434
516
  ): Promise<number> {
435
517
  const projectPath = resolve(ctx.args.path === "." ? "." : ctx.args.path);
436
518
 
437
- const lint = await lintCommand({ path: ctx.args.path, format: "stylish", sandbox: ctx.args.sandbox });
519
+ // #1490 resolved BEFORE the lint gate, not after. The COMP* checks import
520
+ // every `*.component.ts`, an ES module evaluates once per path, and the
521
+ // values in effect at that first import are the ones the graph below will
522
+ // read no matter what it resolves for itself. Ordering is the fix; passing
523
+ // them to `computeComponentGraph` alone did nothing.
524
+ const componentParams = await graphBuildParams(ctx, projectPath);
525
+ if (!componentParams) return 1;
526
+
527
+ const lint = await lintCommand({
528
+ path: ctx.args.path,
529
+ format: "stylish",
530
+ sandbox: ctx.args.sandbox,
531
+ buildParams: componentParams,
532
+ });
438
533
  if (!lint.success) {
439
534
  console.error(
440
535
  formatError({
@@ -444,7 +539,7 @@ async function runComponentGraphView(
444
539
  return 1;
445
540
  }
446
541
 
447
- const graph = await computeComponentGraph(projectPath, ctx.args.sandbox);
542
+ const graph = await computeComponentGraph(projectPath, ctx.args.sandbox, componentParams);
448
543
  if (!graph.success) {
449
544
  console.error(formatError({ message: graph.error ?? "Failed to compute component graph" }));
450
545
  return 1;
@@ -538,8 +633,19 @@ async function runGraphView(
538
633
  return 1;
539
634
  }
540
635
 
636
+ // Resolved before the gate for the reason in runComponentGraphView: the
637
+ // lint pass imports project source, and the first import of a module is the
638
+ // one that binds its parameter-derived values (#1490).
639
+ const buildParams = await graphBuildParams(ctx, projectPath);
640
+ if (!buildParams) return 1;
641
+
541
642
  // Gate: only emit for lint-clean source.
542
- const lint = await lintCommand({ path: ctx.args.path, format: "stylish", sandbox: ctx.args.sandbox });
643
+ const lint = await lintCommand({
644
+ path: ctx.args.path,
645
+ format: "stylish",
646
+ sandbox: ctx.args.sandbox,
647
+ buildParams,
648
+ });
543
649
  if (!lint.success) {
544
650
  console.error(
545
651
  formatError({
@@ -550,15 +656,20 @@ async function runGraphView(
550
656
  return 1;
551
657
  }
552
658
 
553
- const buildParams = await graphBuildParams(ctx, projectPath);
554
- if (!buildParams) return 1;
555
-
556
659
  const result = await discover(projectPath, { buildParams });
557
660
  if (result.errors.length > 0) {
558
661
  for (const e of result.errors) console.error(formatError({ message: e.message }));
559
662
  return 1;
560
663
  }
561
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
+
562
673
  // Build the base IR, focus with a lens (declarable-level, most precise), then
563
674
  // apply the detail tier — so e.g. blast:<resource> works before any collapse.
564
675
  let ir: GraphIR = buildGraphIr(result.entities, projectPath);
@@ -678,6 +789,12 @@ async function runStackGraph(ctx: CommandContext): Promise<number> {
678
789
  return 1;
679
790
  }
680
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
+
681
798
  const lexicons = [...partitionByLexicon(result.entities).keys()];
682
799
  const graph = computeStackGraph(result.entities, lexicons);
683
800
 
@@ -737,6 +737,7 @@ async function observeLexicon(
737
737
  return {
738
738
  resources: {},
739
739
  unobserved: unobservedAll(entityNames, "read-failed", message, opts.entities),
740
+ queried: {},
740
741
  };
741
742
  }
742
743
  }
@@ -806,7 +807,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
806
807
  });
807
808
  const observedNow = observed.resources;
808
809
  const observedThen = prevSnapshot?.resources;
809
- const diff = diffLive({ declared, observedNow, observedThen, unobserved: observed.unobserved });
810
+ const diff = diffLive({ declared, observedNow, observedThen, unobserved: observed.unobserved, queried: observed.queried });
810
811
  // Unobserved entities are deliberately NOT drift: a hole in the read is
811
812
  // not a change in the cloud. They are reported separately (#1089) so a
812
813
  // "no drift detected" line can never be built on top of a failed read.
@@ -962,7 +963,12 @@ function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiff
962
963
  }
963
964
  if (diff.missing.length > 0) {
964
965
  console.log(formatBold("\nMISSING (declared, provider reports not in cloud):"));
965
- for (const name of diff.missing) console.log(` - ${name}`);
966
+ for (const name of diff.missing) {
967
+ // The address the read actually went to (#1620) — the line between "not
968
+ // there" and "looked in the wrong place" (a defaulted namespace, say).
969
+ const queried = diff.queried?.[name];
970
+ console.log(` - ${name}${queried ? ` [queried ${queried}]` : ""}`);
971
+ }
966
972
  }
967
973
  if (diff.orphan.length > 0) {
968
974
  console.log(formatBold("\nORPHAN (in cloud, not declared):"));
@@ -48,7 +48,7 @@ describe("publish order", () => {
48
48
  // same stranding by a different route.
49
49
  const all = execFileSync(
50
50
  "bash",
51
- ["-c", 'for d in packages/*/ lexicons/*/ wardens/*/; do [ -f "$d/package.json" ] && echo "${d%/}"; done'],
51
+ ["-c", 'for d in packages/*/ lexicons/*/; do [ -f "$d/package.json" ] && echo "${d%/}"; done'],
52
52
  { cwd: REPO, encoding: "utf8" },
53
53
  )
54
54
  .split("\n")
@@ -118,10 +118,9 @@ function recipeBody(name: string): string {
118
118
  */
119
119
  describe("release wiring: peer ranges stay in lockstep (#1255)", () => {
120
120
  it("the whole-repo release rewrites every @intentius peer range", () => {
121
- // Since #788 the rewrite is generic (`with_entries` over every
122
- // `@intentius/*` peer) rather than two named keys: the wardens added
123
- // `@intentius/warden-core` as a third in-repo peer, and a hand-listed
124
- // rewrite goes stale the same way the hand-listed publish steps did.
121
+ // The rewrite is generic (`with_entries` over every `@intentius/*`
122
+ // peer) rather than named keys a hand-listed rewrite goes stale the
123
+ // same way the hand-listed publish steps did.
125
124
  const body = recipeBody("release");
126
125
  expect(body).toMatch(/\.peerDependencies\s*\|=\s*with_entries/);
127
126
  expect(body).toMatch(/startswith\("@intentius\/"\)/);
@@ -229,6 +229,58 @@ describe("computeComponentGraph", () => {
229
229
  expect(result.edges).toEqual([]);
230
230
  });
231
231
 
232
+ // #1490 — `discoverComponents` has honoured buildParams since #1108, but
233
+ // every caller stopped short of passing them, so the component graph was
234
+ // always the default-parameter graph. A component conditioned on a parameter
235
+ // survived `--param` that removed the resources it describes, and `chant
236
+ // build` and `chant graph --components` disagreed about the same source.
237
+ //
238
+ // The fixture imports the params module by absolute path rather than by the
239
+ // `@intentius/chant/params` specifier: it is written to a temp dir with no
240
+ // node_modules, so the bare specifier would resolve elsewhere (or nowhere)
241
+ // and the fixture would read a DIFFERENT params object than the one
242
+ // discovery mutates — passing the test for the wrong reason.
243
+ const paramsModule = new URL("../params.ts", import.meta.url).pathname;
244
+ const conditionalComponent = `import { params } from ${JSON.stringify(paramsModule)};
245
+ export const backup =
246
+ params.backups === "omit"
247
+ ? undefined
248
+ : { name: "backup", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "t" }] }] };`;
249
+
250
+ // A fresh directory per call: the ESM loader caches a module by path, so
251
+ // re-importing the same fixture would replay the FIRST call's parameter
252
+ // binding and the assertion would pass or fail for the wrong reason.
253
+ async function graphWith(
254
+ dir: string,
255
+ provenance?: Array<{ name: string; value: string; source: "cli" }>,
256
+ ) {
257
+ await mkdir(dir, { recursive: true });
258
+ await writeFile(join(dir, "backup.component.ts"), conditionalComponent);
259
+ return computeComponentGraph(dir, undefined, provenance);
260
+ }
261
+
262
+ test("a component conditioned on a build parameter follows the parameter", async () => {
263
+ const omitted = await graphWith(join(testDir, "omit"), [
264
+ { name: "backups", value: "omit", source: "cli" },
265
+ ]);
266
+ expect(omitted.success).toBe(true);
267
+ expect(omitted.order).toEqual([]);
268
+
269
+ const kept = await graphWith(join(testDir, "keep"), [
270
+ { name: "backups", value: "pg-dump", source: "cli" },
271
+ ]);
272
+ expect(kept.success).toBe(true);
273
+ expect(kept.order).toEqual(["backup"]);
274
+ });
275
+
276
+ // Supplying none must not inherit whatever a previous call set — the same
277
+ // leak `discoverComponents`'s unconditional setBuildParams guards against.
278
+ test("passing no parameters does not inherit the previous call's", async () => {
279
+ await graphWith(join(testDir, "first"), [{ name: "backups", value: "omit", source: "cli" }]);
280
+ const fresh = await graphWith(join(testDir, "second"));
281
+ expect(fresh.order).toEqual(["backup"]);
282
+ });
283
+
232
284
  test("orders a consumer after its producer, with a consumer → producer edge", async () => {
233
285
  await writeFile(
234
286
  join(testDir, "alb.component.ts"),