@intentius/chant 0.41.0 → 0.41.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/build-params-cli.d.ts +24 -0
- package/dist/cli/build-params-cli.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/components/capability-plugin.d.ts +16 -0
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +5 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts +49 -0
- package/dist/components/deploy-units.d.ts.map +1 -0
- package/dist/components/starter-plugin.d.ts +1 -1
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/graph-detail.d.ts +16 -0
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +22 -14
- package/dist/kubectl-context.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/build-params-cli.ts +35 -0
- package/src/cli/handlers/components.ts +29 -23
- package/src/cli/handlers/graph.test.ts +51 -1
- package/src/cli/handlers/graph.ts +40 -5
- package/src/cli/handlers/lifecycle.ts +18 -3
- package/src/components/capability-plugin.test.ts +21 -0
- package/src/components/capability-plugin.ts +33 -0
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +11 -1
- package/src/components/deploy-units.test.ts +42 -0
- package/src/components/deploy-units.ts +82 -0
- package/src/components/starter-plugin.ts +4 -2
- package/src/config.test.ts +53 -0
- package/src/config.ts +41 -3
- package/src/graph-detail.test.ts +37 -1
- package/src/graph-detail.ts +28 -0
- package/src/kubectl-context.test.ts +16 -24
- package/src/kubectl-context.ts +22 -19
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { commandBuildParams } from "../build-params-cli";
|
|
2
3
|
import { build } from "../../build";
|
|
3
4
|
import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
4
5
|
import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
@@ -101,6 +102,10 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
101
102
|
// Validate environment against config
|
|
102
103
|
const projectPath = resolve(".");
|
|
103
104
|
const { config } = await loadChantConfig(projectPath);
|
|
105
|
+
// This invocation's parameters, so the declared side of the comparison is the estate
|
|
106
|
+
// the caller asked for rather than the parameter defaults (#1483).
|
|
107
|
+
const declaredParams = await commandBuildParams(config.buildParams, args);
|
|
108
|
+
if (!declaredParams) return 1;
|
|
104
109
|
const declaredEnvNames = environmentNames(config.environments);
|
|
105
110
|
if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
|
|
106
111
|
console.error(formatError({
|
|
@@ -146,7 +151,7 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
146
151
|
const built: Array<{ target: (typeof targets)[number]; buildResult: Awaited<ReturnType<typeof build>> }> = [];
|
|
147
152
|
for (const target of targets) {
|
|
148
153
|
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
149
|
-
const buildResult = await build(target.root, targetSerializers);
|
|
154
|
+
const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams });
|
|
150
155
|
if (buildResult.errors.length > 0) {
|
|
151
156
|
console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
|
|
152
157
|
anyHardError = true;
|
|
@@ -326,6 +331,10 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
326
331
|
|
|
327
332
|
// Fetch previous snapshots once (all stacks share the orphan branch).
|
|
328
333
|
const { config } = await loadChantConfig(resolve("."));
|
|
334
|
+
// This invocation's parameters, so the declared side of the comparison is the estate
|
|
335
|
+
// the caller asked for rather than the parameter defaults (#1483).
|
|
336
|
+
const declaredParams = await commandBuildParams(config.buildParams, args);
|
|
337
|
+
if (!declaredParams) return 1;
|
|
329
338
|
await fetchLifecycle();
|
|
330
339
|
|
|
331
340
|
// One target per stack (single-stack projects: exactly one), each built from
|
|
@@ -355,7 +364,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
355
364
|
|
|
356
365
|
try {
|
|
357
366
|
for (const target of targets) {
|
|
358
|
-
const buildResult = await build(target.root, targetSerializers);
|
|
367
|
+
const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams });
|
|
359
368
|
if (buildResult.errors.length > 0) {
|
|
360
369
|
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
361
370
|
console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
|
|
@@ -1021,7 +1030,13 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1021
1030
|
: serializers;
|
|
1022
1031
|
|
|
1023
1032
|
const { config } = await loadChantConfig(resolve("."));
|
|
1024
|
-
|
|
1033
|
+
// This invocation's parameters, so the declared side of the comparison is the estate
|
|
1034
|
+
// the caller asked for rather than the parameter defaults (#1483).
|
|
1035
|
+
const declaredParams = await commandBuildParams(config.buildParams, args);
|
|
1036
|
+
if (!declaredParams) return 1;
|
|
1037
|
+
const buildResult = await build(resolveBuildRoot(args, config), targetSerializers, undefined, {
|
|
1038
|
+
buildParams: declaredParams,
|
|
1039
|
+
});
|
|
1025
1040
|
if (buildResult.errors.length > 0) {
|
|
1026
1041
|
console.error(formatError({ message: "Build failed — fix errors before planning" }));
|
|
1027
1042
|
return 1;
|
|
@@ -233,3 +233,24 @@ describe("MalformedCapabilityPluginError", () => {
|
|
|
233
233
|
expect(err.name).toBe("MalformedCapabilityPluginError");
|
|
234
234
|
});
|
|
235
235
|
});
|
|
236
|
+
|
|
237
|
+
// #1505 — plugin versions track the lockstep release instead of a literal that
|
|
238
|
+
// goes stale on every `just release` (aws shipped "1.0.0" from its extraction;
|
|
239
|
+
// the k8s plugin's authoring-time literal was stale one release later).
|
|
240
|
+
describe("ownPackageVersion (#1505)", () => {
|
|
241
|
+
test("resolves this module's own package version — core's package.json, exactly", async () => {
|
|
242
|
+
const { ownPackageVersion } = await import("./capability-plugin");
|
|
243
|
+
const { readFileSync } = await import("node:fs");
|
|
244
|
+
const { version } = JSON.parse(
|
|
245
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
|
|
246
|
+
) as { version: string };
|
|
247
|
+
expect(ownPackageVersion(import.meta.url)).toBe(version);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("the starter plugin reports that version, not a literal", async () => {
|
|
251
|
+
const { starterCapabilityPlugin } = await import("./starter-plugin");
|
|
252
|
+
const { ownPackageVersion } = await import("./capability-plugin");
|
|
253
|
+
expect(starterCapabilityPlugin.version).toBe(ownPackageVersion(import.meta.url));
|
|
254
|
+
expect(starterCapabilityPlugin.version).not.toBe("1.0.0");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
* migration path and the "no behavior change" guarantee.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
31
34
|
import type { Capability } from "./capability";
|
|
32
35
|
|
|
33
36
|
/**
|
|
@@ -106,3 +109,33 @@ export function isCapabilityPlugin(value: unknown): value is CapabilityPlugin {
|
|
|
106
109
|
const obj = value as Record<string, unknown>;
|
|
107
110
|
return typeof obj.capabilities === "function";
|
|
108
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The calling module's own package version, read from the nearest
|
|
115
|
+
* `package.json` above it (chant #1505).
|
|
116
|
+
*
|
|
117
|
+
* `CapabilityPlugin.version` documents itself as the plugin package's semver,
|
|
118
|
+
* but chant's packages release in lockstep, so a hardcoded literal goes stale
|
|
119
|
+
* on every `just release` — the aws plugin shipped `"1.0.0"` from the day it
|
|
120
|
+
* was extracted from the starter set (#681), and the k8s plugin's authoring-
|
|
121
|
+
* time `"0.41.0"` was stale one release later. Walking up from the module's
|
|
122
|
+
* own URL survives both the `src/` (development condition) and `dist/`
|
|
123
|
+
* layouts, and an npm install, without any build-time stamping.
|
|
124
|
+
*
|
|
125
|
+
* Returns `"0.0.0"` when no versioned `package.json` is found — a visible
|
|
126
|
+
* sentinel rather than a guess; nothing gates on the field.
|
|
127
|
+
*/
|
|
128
|
+
export function ownPackageVersion(moduleUrl: string): string {
|
|
129
|
+
let dir = dirname(fileURLToPath(moduleUrl));
|
|
130
|
+
for (;;) {
|
|
131
|
+
try {
|
|
132
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as { version?: unknown };
|
|
133
|
+
if (typeof pkg.version === "string") return pkg.version;
|
|
134
|
+
} catch {
|
|
135
|
+
// No package.json here (or unreadable) — keep walking up.
|
|
136
|
+
}
|
|
137
|
+
const parent = dirname(dir);
|
|
138
|
+
if (parent === dir) return "0.0.0";
|
|
139
|
+
dir = parent;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -275,6 +275,23 @@ describe("computeComponentGraph", () => {
|
|
|
275
275
|
expect(result.success).toBe(false);
|
|
276
276
|
expect(result.error).toMatch(/unknown component|ghost/i);
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
test("carries each component's liveNames, with the [name] identity fallback (#1491)", async () => {
|
|
280
|
+
await writeFile(
|
|
281
|
+
join(testDir, "pg.component.ts"),
|
|
282
|
+
`export const pg = { name: "postgres", liveNames: ["pgDeployment", "pgService", "pgClaim"], dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "test" }] }] };`,
|
|
283
|
+
);
|
|
284
|
+
await writeFile(
|
|
285
|
+
join(testDir, "plain.component.ts"),
|
|
286
|
+
`export const plain = { name: "plain", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "test" }] }] };`,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const result = await computeComponentGraph(testDir);
|
|
290
|
+
|
|
291
|
+
expect(result.success).toBe(true);
|
|
292
|
+
expect(result.liveNames?.postgres).toEqual(["pgDeployment", "pgService", "pgClaim"]);
|
|
293
|
+
expect(result.liveNames?.plain).toEqual(["plain"]);
|
|
294
|
+
});
|
|
278
295
|
});
|
|
279
296
|
|
|
280
297
|
// ── runComponents (#585) ─────────────────────────────────────────────────────
|
|
@@ -136,6 +136,11 @@ export interface ComponentGraphResult {
|
|
|
136
136
|
* so a renderer can deep-link a component node to source (`chant graph
|
|
137
137
|
* --components --format ir` sets `sourceLoc` from this). */
|
|
138
138
|
files?: Record<string, string>;
|
|
139
|
+
/** Component name → the live resource names it owns (#1491): the declared
|
|
140
|
+
* `liveNames`, or `[name]` when undeclared — the identity-join fallback the
|
|
141
|
+
* `Component` contract already specifies. This is what lets a consumer join
|
|
142
|
+
* the component DAG to the resource graph without guessing at kinds. */
|
|
143
|
+
liveNames?: Record<string, string[]>;
|
|
139
144
|
error?: string;
|
|
140
145
|
}
|
|
141
146
|
|
|
@@ -154,8 +159,13 @@ export async function computeComponentGraph(path: string, sandbox?: boolean): Pr
|
|
|
154
159
|
|
|
155
160
|
// component name → its declaring file, relative to `path`, for node deep-links.
|
|
156
161
|
const files: Record<string, string> = {};
|
|
162
|
+
// component name → owned live resource names (#1491), declared or the
|
|
163
|
+
// contract's identity fallback.
|
|
164
|
+
const liveNames: Record<string, string[]> = {};
|
|
157
165
|
for (const [name, discovered] of result.components) {
|
|
158
166
|
files[name] = relative(path, discovered.filePath);
|
|
167
|
+
const declared = discovered.component.liveNames;
|
|
168
|
+
liveNames[name] = declared && declared.length > 0 ? [...declared] : [name];
|
|
159
169
|
}
|
|
160
170
|
|
|
161
171
|
try {
|
|
@@ -164,7 +174,7 @@ export async function computeComponentGraph(path: string, sandbox?: boolean): Pr
|
|
|
164
174
|
for (const c of driverComponents) {
|
|
165
175
|
for (const dep of c.dependsOn ?? []) edges.push({ from: c.name, to: dep });
|
|
166
176
|
}
|
|
167
|
-
return { success: true, order, waves, edges, files };
|
|
177
|
+
return { success: true, order, waves, edges, files, liveNames };
|
|
168
178
|
} catch (err) {
|
|
169
179
|
if (err instanceof UnknownDependencyError || err instanceof DependencyCycleError) {
|
|
170
180
|
return { success: false, order: [], waves: [], edges: [], error: err.message };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { deployUnits } from "./deploy-units";
|
|
3
|
+
import type { Phase } from "./component";
|
|
4
|
+
|
|
5
|
+
const phase = (name: string, steps: Phase["steps"]): Phase => ({ phase: name, steps });
|
|
6
|
+
|
|
7
|
+
describe("deployUnits (#1495 piece 1)", () => {
|
|
8
|
+
test("resolves a cfn-deploy step to its stack, keyed to the aws observer", () => {
|
|
9
|
+
const deploy = [phase("Apply", [{ kind: "cfn-deploy", stack: "cc-canonical", template: "t.json" }])];
|
|
10
|
+
expect(deployUnits(deploy)).toEqual([{ unit: "cc-canonical", lexicon: "aws" }]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("resolves kubectl-apply and helm-upgrade units once their steps exist", () => {
|
|
14
|
+
const deploy = [
|
|
15
|
+
phase("Apply", [
|
|
16
|
+
{ kind: "kubectl-apply", stack: "kubemicrovm-ops" },
|
|
17
|
+
{ kind: "helm-upgrade", release: "operator" },
|
|
18
|
+
]),
|
|
19
|
+
];
|
|
20
|
+
expect(deployUnits(deploy)).toEqual([
|
|
21
|
+
{ unit: "kubemicrovm-ops", lexicon: "k8s" },
|
|
22
|
+
{ unit: "operator", lexicon: "helm" },
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("walks nested phases, dedupes per lexicon, and skips unitless steps", () => {
|
|
27
|
+
const deploy = [
|
|
28
|
+
phase("Outer", [
|
|
29
|
+
phase("Inner", [{ kind: "cfn-deploy", stack: "web" }]) as never,
|
|
30
|
+
{ kind: "cfn-deploy", stack: "web" },
|
|
31
|
+
{ kind: "shell", cmd: "echo", reason: "no capability yet" },
|
|
32
|
+
{ kind: "cfn-deploy" }, // no stack named — contributes nothing
|
|
33
|
+
]),
|
|
34
|
+
];
|
|
35
|
+
expect(deployUnits(deploy)).toEqual([{ unit: "web", lexicon: "aws" }]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an unlisted kind contributes no unit — the registry is the rule", () => {
|
|
39
|
+
const deploy = [phase("Apply", [{ kind: "gcloud-deploy", stack: "x" }])];
|
|
40
|
+
expect(deployUnits(deploy)).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy units — which live unit(s) a component's composition targets (#1495).
|
|
3
|
+
*
|
|
4
|
+
* `chant components status --live` (and `chant graph --live`) need to know
|
|
5
|
+
* what a component deployed in order to observe it. That answer used to be a
|
|
6
|
+
* string literal in the walk — `kind === "cfn-deploy"` — which made the whole
|
|
7
|
+
* chain CloudFormation-shaped: a component whose deploy phase is a server-side
|
|
8
|
+
* apply or a Helm upgrade contributed nothing and was skipped, not
|
|
9
|
+
* "unobserved", simply absent from the result.
|
|
10
|
+
*
|
|
11
|
+
* This module is the seam that fixes the walk (piece 1 of #1495's
|
|
12
|
+
* decomposition): a registry mapping a deploy-family step `kind` to the field
|
|
13
|
+
* naming its unit and the lexicon whose `describeStackStatus` can observe it.
|
|
14
|
+
* The registry is data, not a rule about what steps look like — a kind not
|
|
15
|
+
* listed here contributes no unit, exactly as before.
|
|
16
|
+
*/
|
|
17
|
+
import type { Phase } from "./component";
|
|
18
|
+
|
|
19
|
+
/** A deploy-family step kind that names a live unit, and who observes it. */
|
|
20
|
+
export interface DeployUnitRule {
|
|
21
|
+
/** The step `kind` (the capability's registered name). */
|
|
22
|
+
kind: string;
|
|
23
|
+
/** The step field carrying the unit's name (`stack`, `release`). */
|
|
24
|
+
field: string;
|
|
25
|
+
/** The lexicon whose `describeStackStatus` reads this kind of unit. */
|
|
26
|
+
lexicon: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The deploy-family kinds that name a unit. cfn-deploy's unit is its stack;
|
|
31
|
+
* kubectl-apply's is the ownership stack its labels carry (#1495 piece 2);
|
|
32
|
+
* helm-upgrade's is its release (#1495 piece 4). Listing a kind here is safe
|
|
33
|
+
* before its lexicon implements `describeStackStatus` — units whose lexicon
|
|
34
|
+
* has no observer are skipped by the caller, the same absent-observer path as
|
|
35
|
+
* before.
|
|
36
|
+
*/
|
|
37
|
+
export const DEPLOY_UNIT_RULES: readonly DeployUnitRule[] = [
|
|
38
|
+
{ kind: "cfn-deploy", field: "stack", lexicon: "aws" },
|
|
39
|
+
{ kind: "kubectl-apply", field: "stack", lexicon: "k8s" },
|
|
40
|
+
{ kind: "helm-upgrade", field: "release", lexicon: "helm" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** One resolved unit a component's deploy composition targets. */
|
|
44
|
+
export interface DeployUnit {
|
|
45
|
+
/** The unit's name — a stack, a release. */
|
|
46
|
+
unit: string;
|
|
47
|
+
/** The lexicon that observes this kind of unit. */
|
|
48
|
+
lexicon: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every distinct deploy unit a component's phases target, in declaration
|
|
53
|
+
* order. A step may itself be a nested `Phase`, so the walk recurses; a
|
|
54
|
+
* resolved component carries the unit as a concrete string. Pure.
|
|
55
|
+
*/
|
|
56
|
+
export function deployUnits(deploy: Phase[]): DeployUnit[] {
|
|
57
|
+
const byKind = new Map(DEPLOY_UNIT_RULES.map((r) => [r.kind, r]));
|
|
58
|
+
const seen = new Set<string>();
|
|
59
|
+
const units: DeployUnit[] = [];
|
|
60
|
+
const walkSteps = (steps: Phase["steps"]): void => {
|
|
61
|
+
for (const step of steps) {
|
|
62
|
+
// A step may itself be a nested Phase (it carries its own `steps`). Step
|
|
63
|
+
// is open-typed (capability inputs), so discriminate structurally.
|
|
64
|
+
const nested = (step as { steps?: unknown }).steps;
|
|
65
|
+
if (Array.isArray(nested)) {
|
|
66
|
+
walkSteps(nested as Phase["steps"]);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const s = step as { kind?: string } & Record<string, unknown>;
|
|
70
|
+
const rule = s.kind ? byKind.get(s.kind) : undefined;
|
|
71
|
+
if (!rule) continue;
|
|
72
|
+
const unit = s[rule.field];
|
|
73
|
+
if (typeof unit !== "string" || unit.length === 0) continue;
|
|
74
|
+
const key = `${rule.lexicon}\0${unit}`;
|
|
75
|
+
if (seen.has(key)) continue;
|
|
76
|
+
seen.add(key);
|
|
77
|
+
units.push({ unit, lexicon: rule.lexicon });
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
for (const phase of deploy) walkSteps(phase.steps);
|
|
81
|
+
return units;
|
|
82
|
+
}
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
healthGateCapability,
|
|
43
43
|
shellCapability,
|
|
44
44
|
} from "./verbs/index";
|
|
45
|
-
import type
|
|
45
|
+
import { ownPackageVersion, type CapabilityPlugin } from "./capability-plugin";
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Every `kind` in the starter verb set, grouped by family, per epic #551 and
|
|
@@ -99,7 +99,9 @@ function starterCapabilities(): Array<Capability<never, unknown>> {
|
|
|
99
99
|
*/
|
|
100
100
|
export const starterCapabilityPlugin: CapabilityPlugin = {
|
|
101
101
|
name: "starter",
|
|
102
|
-
version
|
|
102
|
+
// The core package's own version (#1505) — lockstep releases bump it, so a
|
|
103
|
+
// literal here would go stale every `just release`.
|
|
104
|
+
version: ownPackageVersion(import.meta.url),
|
|
103
105
|
capabilities: starterCapabilities,
|
|
104
106
|
families: () => STARTER_VERB_FAMILIES,
|
|
105
107
|
};
|
package/src/config.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
loadChantConfig,
|
|
4
|
+
loadChantConfigUpward,
|
|
4
5
|
DEFAULT_CHANT_CONFIG,
|
|
5
6
|
resolveAutoReleaseDisabled,
|
|
6
7
|
resolveFoldEnabled,
|
|
@@ -137,6 +138,58 @@ describe("loadChantConfig", () => {
|
|
|
137
138
|
});
|
|
138
139
|
});
|
|
139
140
|
|
|
141
|
+
// #1502 — the upward walk skips lint-scoping fragments. A `src/chant.config.json`
|
|
142
|
+
// holding only `extends`/`rules` (the examples/ convention) must not shadow the
|
|
143
|
+
// project config above it, or `chant build src` silently loses `ownership`/
|
|
144
|
+
// `buildParams` — the exact fallback #1117's walk exists to prevent.
|
|
145
|
+
describe("loadChantConfigUpward (#1502 — lint fragments do not end the walk)", () => {
|
|
146
|
+
const SRC = join(TEST_DIR, "src");
|
|
147
|
+
|
|
148
|
+
test("walks past a lint-only src/chant.config.json to the project config", async () => {
|
|
149
|
+
mkdirSync(SRC, { recursive: true });
|
|
150
|
+
writeFileSync(
|
|
151
|
+
join(SRC, "chant.config.json"),
|
|
152
|
+
JSON.stringify({ extends: ["@intentius/chant/lint/presets/strict"], rules: { COR001: "off" } }),
|
|
153
|
+
);
|
|
154
|
+
writeFileSync(
|
|
155
|
+
join(TEST_DIR, "chant.config.json"),
|
|
156
|
+
JSON.stringify({ ownership: { stack: "billing", env: "prod" } }),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const result = await loadChantConfigUpward(SRC);
|
|
160
|
+
expect(result.config.ownership).toEqual({ stack: "billing", env: "prod" });
|
|
161
|
+
expect(result.configPath).toBe(join(TEST_DIR, "chant.config.json"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a src/chant.config.json declaring any project-level key still wins in place", async () => {
|
|
165
|
+
mkdirSync(SRC, { recursive: true });
|
|
166
|
+
writeFileSync(
|
|
167
|
+
join(SRC, "chant.config.json"),
|
|
168
|
+
JSON.stringify({ ownership: { stack: "nested" }, rules: { COR001: "off" } }),
|
|
169
|
+
);
|
|
170
|
+
writeFileSync(
|
|
171
|
+
join(TEST_DIR, "chant.config.json"),
|
|
172
|
+
JSON.stringify({ ownership: { stack: "root" } }),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const result = await loadChantConfigUpward(SRC);
|
|
176
|
+
expect(result.config.ownership?.stack).toBe("nested");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a fragment-only project resolves to the default config at the boundary", async () => {
|
|
180
|
+
mkdirSync(SRC, { recursive: true });
|
|
181
|
+
writeFileSync(join(TEST_DIR, "package.json"), JSON.stringify({ name: "boundary" }));
|
|
182
|
+
writeFileSync(
|
|
183
|
+
join(SRC, "chant.config.json"),
|
|
184
|
+
JSON.stringify({ extends: ["@intentius/chant/lint/presets/strict"] }),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const result = await loadChantConfigUpward(SRC);
|
|
188
|
+
expect(result.config).toEqual(DEFAULT_CHANT_CONFIG);
|
|
189
|
+
expect(result.configPath).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
140
193
|
// #1166 — `environments` accepts either a bare name (unchanged) or
|
|
141
194
|
// `{ name, endpoint }`, so a declared environment can be self-sufficient for
|
|
142
195
|
// `--live` reads without an ambient AWS_ENDPOINT_URL export.
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import type { LintConfig } from "./lint/config";
|
|
5
5
|
import type { OwnershipMarker } from "./ownership";
|
|
@@ -384,12 +384,50 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
|
|
|
384
384
|
* same walk `chant lint`/`chant graph` already used ({@link findProjectConfig},
|
|
385
385
|
* shared with `./lint/config.ts`'s `findProjectRoot`) — one config-discovery
|
|
386
386
|
* contract for the whole CLI.
|
|
387
|
+
*
|
|
388
|
+
* chant #1502 — a lint-scoping fragment does not end the walk. The convention
|
|
389
|
+
* of a `src/chant.config.json` holding only `extends`/`rules` (cc-aws-canonical
|
|
390
|
+
* and most of examples/) sits BETWEEN the build directory and the real
|
|
391
|
+
* `chant.config.ts`, and stopping there re-introduced the exact silent
|
|
392
|
+
* fallback this walk exists to prevent: `chant build src` resolved the
|
|
393
|
+
* fragment, found no `ownership`, and built unstamped manifests that every
|
|
394
|
+
* owned-scoped live read then withheld. A fragment is skipped, not merged —
|
|
395
|
+
* lint resolution keeps its own nearest-wins walk untouched, and a JSON
|
|
396
|
+
* config declaring any project-level key still wins where it stands.
|
|
387
397
|
*/
|
|
388
398
|
export async function loadChantConfigUpward(startDir: string): Promise<ResolvedConfig> {
|
|
389
|
-
|
|
399
|
+
let { dir, configPath } = findProjectConfig(startDir);
|
|
400
|
+
while (configPath && isLintOnlyFragment(configPath)) {
|
|
401
|
+
const parent = dirname(dir);
|
|
402
|
+
if (parent === dir) break;
|
|
403
|
+
({ dir, configPath } = findProjectConfig(parent));
|
|
404
|
+
}
|
|
390
405
|
return loadChantConfig(dir);
|
|
391
406
|
}
|
|
392
407
|
|
|
408
|
+
/**
|
|
409
|
+
* The top-level keys of `./lint/config.ts`'s `LintConfigSchema` (plus the
|
|
410
|
+
* `$schema` editor convention). A `chant.config.json` whose keys all come from
|
|
411
|
+
* this set is a lint-scoping fragment, not a project config — see
|
|
412
|
+
* {@link loadChantConfigUpward}. `chant.config.ts` is never a fragment: it is
|
|
413
|
+
* project-authored code, and inspecting it would mean evaluating it.
|
|
414
|
+
*/
|
|
415
|
+
const LINT_FRAGMENT_KEYS = new Set(["$schema", "extends", "rules", "overrides", "plugins", "policies"]);
|
|
416
|
+
|
|
417
|
+
function isLintOnlyFragment(configPath: string): boolean {
|
|
418
|
+
if (!configPath.endsWith("chant.config.json")) return false;
|
|
419
|
+
try {
|
|
420
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
|
|
421
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
|
|
422
|
+
const keys = Object.keys(parsed);
|
|
423
|
+
return keys.length > 0 && keys.every((k) => LINT_FRAGMENT_KEYS.has(k));
|
|
424
|
+
} catch {
|
|
425
|
+
// Unreadable/unparseable JSON: let loadChantConfig surface the real error
|
|
426
|
+
// in place rather than silently walking past it.
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
393
431
|
/**
|
|
394
432
|
* Resolve the ownership marker to stamp from project config, or undefined when
|
|
395
433
|
* ownership marking is off (no `stack`, or `enabled: false`).
|
package/src/graph-detail.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { applyDetail, DETAIL } from "./graph-detail";
|
|
2
|
+
import { applyDetail, DETAIL, detailInertNotice } from "./graph-detail";
|
|
3
3
|
import type { GraphIR } from "./graph-ir";
|
|
4
4
|
|
|
5
5
|
// A small graph: a gcp vpc/subnet pair plus a k8s namespace and deployment that
|
|
@@ -90,3 +90,39 @@ describe("applyDetail", () => {
|
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
// #1489 — an inert --detail 3 names itself instead of silently emitting the
|
|
95
|
+
// same bytes as --detail 2 (the k8s lexicon links by name/label convention, so
|
|
96
|
+
// its graphs never have a producer attribute to annotate).
|
|
97
|
+
describe("detailInertNotice (#1489)", () => {
|
|
98
|
+
test("detail 3 that added toAttr annotations: no notice", () => {
|
|
99
|
+
const detailed = applyDetail(base, DETAIL.ATTRIBUTES);
|
|
100
|
+
expect(detailInertNotice(base, detailed)).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("edges without producer attributes: notice names the convention-linking cause", () => {
|
|
104
|
+
const labelLinked: GraphIR = {
|
|
105
|
+
nodes: [
|
|
106
|
+
{ id: "svc", kind: "Service", lexicon: "k8s", attrs: { spec: { selector: { app: "web" } } } },
|
|
107
|
+
{ id: "web", kind: "Deployment", lexicon: "k8s", attrs: { metadata: { labels: { app: "web" } } } },
|
|
108
|
+
],
|
|
109
|
+
edges: [{ from: "svc", to: "web", kind: "ref", viaAttr: "spec.selector" }],
|
|
110
|
+
groups: {},
|
|
111
|
+
};
|
|
112
|
+
const detailed = applyDetail(labelLinked, DETAIL.ATTRIBUTES);
|
|
113
|
+
const notice = detailInertNotice(labelLinked, detailed);
|
|
114
|
+
expect(notice).toContain("--detail 3");
|
|
115
|
+
expect(notice).toContain("1 edge(s)");
|
|
116
|
+
expect(notice).toContain("identical to --detail 2");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("no edges at all: notice says so", () => {
|
|
120
|
+
const edgeless: GraphIR = {
|
|
121
|
+
nodes: [{ id: "ns", kind: "Namespace", lexicon: "k8s", attrs: {} }],
|
|
122
|
+
edges: [],
|
|
123
|
+
groups: {},
|
|
124
|
+
};
|
|
125
|
+
const detailed = applyDetail(edgeless, DETAIL.ATTRIBUTES);
|
|
126
|
+
expect(detailInertNotice(edgeless, detailed)).toContain("no edges at all");
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/graph-detail.ts
CHANGED
|
@@ -158,3 +158,31 @@ function findRefAttr(attrs: Record<string, unknown>, producer: string): string |
|
|
|
158
158
|
visit(attrs);
|
|
159
159
|
return found;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* chant #1489 — the message to print when `--detail 3` changed nothing.
|
|
164
|
+
*
|
|
165
|
+
* T3's only addition over T2 is the producer attribute on `$ref`-derived
|
|
166
|
+
* edges. A graph whose resources link by name or label convention instead of
|
|
167
|
+
* attribute references (the k8s lexicon end to end) has nothing to annotate,
|
|
168
|
+
* so levels 2 and 3 come out byte-identical — which reads as a broken dial
|
|
169
|
+
* from any consumer stepping through the levels (behold#131 was filed over
|
|
170
|
+
* exactly this). Accepting a value that changes nothing without saying so is
|
|
171
|
+
* the bug; this names it.
|
|
172
|
+
*
|
|
173
|
+
* Returns the warning text, or undefined when detail 3 did add something.
|
|
174
|
+
* Callers print it through their own sink; the compare is over the two IRs
|
|
175
|
+
* the caller already holds, so this stays a pure function.
|
|
176
|
+
*/
|
|
177
|
+
export function detailInertNotice(base: GraphIR, detailed: GraphIR): string | undefined {
|
|
178
|
+
if (JSON.stringify(detailed) !== JSON.stringify(base)) return undefined;
|
|
179
|
+
const edges = base.edges.length;
|
|
180
|
+
const why =
|
|
181
|
+
edges === 0
|
|
182
|
+
? "this graph has no edges at all"
|
|
183
|
+
: `none of this graph's ${edges} edge(s) reference a producer attribute — they link by name or label convention`;
|
|
184
|
+
return (
|
|
185
|
+
`--detail 3 adds the producer attribute to reference edges, but ${why}, ` +
|
|
186
|
+
`so the output is identical to --detail 2.`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -16,7 +16,7 @@ vi.mock("node:child_process", async () => {
|
|
|
16
16
|
|
|
17
17
|
const { resolveClusterTarget, ClusterBindingMismatchError } = await import("./kubectl-context");
|
|
18
18
|
|
|
19
|
-
describe("resolveClusterTarget (chant #1100)", () => {
|
|
19
|
+
describe("resolveClusterTarget (chant #1100, #1488)", () => {
|
|
20
20
|
beforeEach(() => {
|
|
21
21
|
execMock.mockReset();
|
|
22
22
|
});
|
|
@@ -34,9 +34,7 @@ describe("resolveClusterTarget (chant #1100)", () => {
|
|
|
34
34
|
warnSpy.mockRestore();
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
test("bound
|
|
38
|
-
execMock.mockResolvedValue({ stdout: "prod-eks\n", stderr: "" });
|
|
39
|
-
|
|
37
|
+
test("bound: returns the bound context without ever probing the ambient one (#1488)", async () => {
|
|
40
38
|
const target = await resolveClusterTarget(
|
|
41
39
|
{ k8s: { profiles: { prod: { context: "prod-eks" } } } },
|
|
42
40
|
"prod",
|
|
@@ -44,11 +42,14 @@ describe("resolveClusterTarget (chant #1100)", () => {
|
|
|
44
42
|
);
|
|
45
43
|
|
|
46
44
|
expect(target).toEqual({ context: "prod-eks", source: "bound" });
|
|
47
|
-
expect(execMock).
|
|
45
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
48
46
|
});
|
|
49
47
|
|
|
50
|
-
test("bound
|
|
51
|
-
|
|
48
|
+
test("bound while a different context is ambient: the binding wins, no refusal (#1488)", async () => {
|
|
49
|
+
// Ambient says staging-eks; the declared binding must be used regardless.
|
|
50
|
+
// Before #1488 this threw ClusterBindingMismatchError, which turned a
|
|
51
|
+
// healthy estate grey the moment any other project switched the context.
|
|
52
|
+
execMock.mockResolvedValue({ stdout: "staging-eks\n", stderr: "" });
|
|
52
53
|
|
|
53
54
|
const target = await resolveClusterTarget(
|
|
54
55
|
{ k8s: { profiles: { prod: { context: "prod-eks" } } } },
|
|
@@ -59,23 +60,14 @@ describe("resolveClusterTarget (chant #1100)", () => {
|
|
|
59
60
|
expect(target).toEqual({ context: "prod-eks", source: "bound" });
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
test("
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
).
|
|
70
|
-
|
|
71
|
-
expect(err).toBeInstanceOf(ClusterBindingMismatchError);
|
|
72
|
-
const mismatch = err as InstanceType<typeof ClusterBindingMismatchError>;
|
|
73
|
-
expect(mismatch.environment).toBe("prod");
|
|
74
|
-
expect(mismatch.expectedContext).toBe("prod-eks");
|
|
75
|
-
expect(mismatch.ambientContext).toBe("staging-eks");
|
|
76
|
-
expect(mismatch.message).toContain('environment "prod"');
|
|
77
|
-
expect(mismatch.message).toContain('"prod-eks"');
|
|
78
|
-
expect(mismatch.message).toContain('"staging-eks"');
|
|
63
|
+
test("ClusterBindingMismatchError still constructs and names all three parts (catchers rely on it)", () => {
|
|
64
|
+
const err = new ClusterBindingMismatchError("prod", "prod-eks", "staging-eks");
|
|
65
|
+
expect(err.environment).toBe("prod");
|
|
66
|
+
expect(err.expectedContext).toBe("prod-eks");
|
|
67
|
+
expect(err.ambientContext).toBe("staging-eks");
|
|
68
|
+
expect(err.message).toContain('environment "prod"');
|
|
69
|
+
expect(err.message).toContain('"prod-eks"');
|
|
70
|
+
expect(err.message).toContain('"staging-eks"');
|
|
79
71
|
});
|
|
80
72
|
|
|
81
73
|
test("bound for a different environment than the one requested: treated as unbound for this environment", async () => {
|