@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.
Files changed (37) hide show
  1. package/dist/cli/build-params-cli.d.ts +24 -0
  2. package/dist/cli/build-params-cli.d.ts.map +1 -1
  3. package/dist/cli/handlers/components.d.ts.map +1 -1
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/components/capability-plugin.d.ts +16 -0
  6. package/dist/components/capability-plugin.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +5 -0
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/components/deploy-units.d.ts +49 -0
  10. package/dist/components/deploy-units.d.ts.map +1 -0
  11. package/dist/components/starter-plugin.d.ts +1 -1
  12. package/dist/components/starter-plugin.d.ts.map +1 -1
  13. package/dist/config.d.ts +10 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/graph-detail.d.ts +16 -0
  16. package/dist/graph-detail.d.ts.map +1 -1
  17. package/dist/kubectl-context.d.ts +22 -14
  18. package/dist/kubectl-context.d.ts.map +1 -1
  19. package/package.json +1 -1
  20. package/src/cli/build-params-cli.ts +35 -0
  21. package/src/cli/handlers/components.ts +29 -23
  22. package/src/cli/handlers/graph.test.ts +51 -1
  23. package/src/cli/handlers/graph.ts +40 -5
  24. package/src/cli/handlers/lifecycle.ts +18 -3
  25. package/src/components/capability-plugin.test.ts +21 -0
  26. package/src/components/capability-plugin.ts +33 -0
  27. package/src/components/cli-support.test.ts +17 -0
  28. package/src/components/cli-support.ts +11 -1
  29. package/src/components/deploy-units.test.ts +42 -0
  30. package/src/components/deploy-units.ts +82 -0
  31. package/src/components/starter-plugin.ts +4 -2
  32. package/src/config.test.ts +53 -0
  33. package/src/config.ts +41 -3
  34. package/src/graph-detail.test.ts +37 -1
  35. package/src/graph-detail.ts +28 -0
  36. package/src/kubectl-context.test.ts +16 -24
  37. 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
- const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
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 { CapabilityPlugin } from "./capability-plugin";
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: "1.0.0",
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
  };
@@ -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
- const { dir } = findProjectConfig(startDir);
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`).
@@ -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
+ });
@@ -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 and ambient context matches: returns the bound context", async () => {
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).toHaveBeenCalledWith(expect.stringContaining("current-context"));
45
+ expect(execMock).not.toHaveBeenCalled();
48
46
  });
49
47
 
50
- test("bound and ambient context cannot be determined: proceeds with the bound context anyway", async () => {
51
- execMock.mockRejectedValue(new Error("no kubeconfig"));
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("bound and ambient context mismatches: refuses loudly, naming env/expected/ambient", async () => {
63
- execMock.mockResolvedValue({ stdout: "staging-eks\n", stderr: "" });
64
-
65
- const err: unknown = await resolveClusterTarget(
66
- { k8s: { profiles: { prod: { context: "prod-eks" } } } },
67
- "prod",
68
- "k8s",
69
- ).catch((e: unknown) => e);
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 () => {