@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.
- package/dist/build.d.ts +23 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +10 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/governance.d.ts +1 -1
- package/dist/graph-detail.d.ts +31 -13
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +7 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +18 -0
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lint/component-checks.d.ts +2 -1
- package/dist/lint/component-checks.d.ts.map +1 -1
- package/dist/observation.d.ts +33 -3
- package/dist/observation.d.ts.map +1 -1
- package/dist/reconcile.d.ts +5 -5
- package/dist/reconcile.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +51 -19
- package/src/cli/commands/lint.ts +14 -3
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/graph.test.ts +191 -15
- package/src/cli/handlers/graph.ts +131 -14
- package/src/cli/handlers/lifecycle.ts +8 -2
- package/src/codegen/publish-order.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +3 -4
- package/src/components/cli-support.test.ts +52 -0
- package/src/components/cli-support.ts +13 -2
- package/src/governance.test.ts +1 -1
- package/src/governance.ts +1 -1
- package/src/graph-detail.test.ts +117 -6
- package/src/graph-detail.ts +76 -18
- package/src/lifecycle/change-set.test.ts +26 -0
- package/src/lifecycle/change-set.ts +13 -0
- package/src/lifecycle/live-diff.test.ts +35 -0
- package/src/lifecycle/live-diff.ts +21 -0
- package/src/lifecycle/observe.ts +2 -1
- package/src/lint/component-checks.ts +8 -1
- package/src/observation.test.ts +54 -7
- package/src/observation.ts +53 -13
- package/src/reconcile.ts +7 -7
package/src/cli/commands/lint.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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"),
|