@intentius/chant 0.38.0 → 0.41.0

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 (106) hide show
  1. package/dist/apply.d.ts +171 -0
  2. package/dist/apply.d.ts.map +1 -0
  3. package/dist/build.d.ts +21 -0
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  7. package/dist/cli/commands/doctor.d.ts.map +1 -1
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  10. package/dist/cli/handlers/search.d.ts +30 -1
  11. package/dist/cli/handlers/search.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/plugins.d.ts +20 -0
  14. package/dist/cli/plugins.d.ts.map +1 -1
  15. package/dist/codegen/naming.d.ts +48 -1
  16. package/dist/codegen/naming.d.ts.map +1 -1
  17. package/dist/codegen/registry.d.ts +23 -0
  18. package/dist/codegen/registry.d.ts.map +1 -0
  19. package/dist/codegen/validate.d.ts +31 -0
  20. package/dist/codegen/validate.d.ts.map +1 -1
  21. package/dist/components/sandbox/driver.d.ts.map +1 -1
  22. package/dist/composite.d.ts +23 -4
  23. package/dist/composite.d.ts.map +1 -1
  24. package/dist/deep-observation.d.ts +11 -0
  25. package/dist/deep-observation.d.ts.map +1 -1
  26. package/dist/discovery/index.d.ts.map +1 -1
  27. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  28. package/dist/graph-declared.d.ts.map +1 -1
  29. package/dist/graph-ir.d.ts +17 -3
  30. package/dist/graph-ir.d.ts.map +1 -1
  31. package/dist/graph-refs.d.ts +24 -0
  32. package/dist/graph-refs.d.ts.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/kubectl-context.d.ts.map +1 -1
  36. package/dist/lexicon-config.d.ts +61 -0
  37. package/dist/lexicon-config.d.ts.map +1 -0
  38. package/dist/lexicon.d.ts +19 -0
  39. package/dist/lexicon.d.ts.map +1 -1
  40. package/dist/lifecycle/deep-diff.d.ts +11 -0
  41. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  42. package/dist/lifecycle/digest.d.ts.map +1 -1
  43. package/dist/lifecycle/identity.d.ts +52 -0
  44. package/dist/lifecycle/identity.d.ts.map +1 -0
  45. package/dist/lifecycle/observe.d.ts +5 -0
  46. package/dist/lifecycle/observe.d.ts.map +1 -1
  47. package/dist/lifecycle/replay.d.ts.map +1 -1
  48. package/dist/lifecycle/types.d.ts +30 -0
  49. package/dist/lifecycle/types.d.ts.map +1 -1
  50. package/dist/managed-fields.d.ts +11 -0
  51. package/dist/managed-fields.d.ts.map +1 -1
  52. package/package.json +1 -1
  53. package/src/apply.test.ts +169 -0
  54. package/src/apply.ts +249 -0
  55. package/src/build.ts +24 -0
  56. package/src/cli/commands/build.ts +10 -0
  57. package/src/cli/commands/check-lexicon.ts +20 -1
  58. package/src/cli/commands/doctor.test.ts +45 -0
  59. package/src/cli/commands/doctor.ts +40 -0
  60. package/src/cli/handlers/lifecycle.ts +10 -0
  61. package/src/cli/handlers/run-client.ts +3 -1
  62. package/src/cli/handlers/search-kind.test.ts +45 -0
  63. package/src/cli/handlers/search.ts +102 -4
  64. package/src/cli/main.ts +32 -10
  65. package/src/cli/param-flag-scope.test.ts +69 -0
  66. package/src/cli/plugins.test.ts +33 -1
  67. package/src/cli/plugins.ts +55 -0
  68. package/src/codegen/naming.test.ts +129 -0
  69. package/src/codegen/naming.ts +72 -1
  70. package/src/codegen/registry.test.ts +56 -0
  71. package/src/codegen/registry.ts +69 -0
  72. package/src/codegen/validate.test.ts +86 -0
  73. package/src/codegen/validate.ts +74 -0
  74. package/src/components/SPRAWL-VALIDATION.md +5 -5
  75. package/src/components/sandbox/driver.test.ts +27 -0
  76. package/src/components/sandbox/driver.ts +12 -0
  77. package/src/composite.ts +33 -4
  78. package/src/deep-observation.ts +11 -0
  79. package/src/discovery/index.ts +59 -0
  80. package/src/discovery/params-cjs-warning.test.ts +75 -0
  81. package/src/discovery/sandbox/driver.test.ts +34 -0
  82. package/src/discovery/sandbox/driver.ts +19 -0
  83. package/src/graph-declared.test.ts +86 -0
  84. package/src/graph-declared.ts +14 -2
  85. package/src/graph-ir.ts +32 -8
  86. package/src/graph-refs.test.ts +56 -0
  87. package/src/graph-refs.ts +37 -1
  88. package/src/index.ts +1 -0
  89. package/src/kubectl-context.ts +4 -1
  90. package/src/lexicon-config.test.ts +111 -0
  91. package/src/lexicon-config.ts +92 -0
  92. package/src/lexicon.ts +20 -0
  93. package/src/lifecycle/deep-diff.test.ts +48 -1
  94. package/src/lifecycle/deep-diff.ts +16 -0
  95. package/src/lifecycle/digest.test.ts +81 -0
  96. package/src/lifecycle/digest.ts +34 -3
  97. package/src/lifecycle/identity.test.ts +39 -0
  98. package/src/lifecycle/identity.ts +61 -0
  99. package/src/lifecycle/observe.test.ts +75 -1
  100. package/src/lifecycle/observe.ts +28 -2
  101. package/src/lifecycle/replay.test.ts +251 -0
  102. package/src/lifecycle/replay.ts +67 -19
  103. package/src/lifecycle/types.ts +26 -0
  104. package/src/managed-fields.test.ts +50 -0
  105. package/src/managed-fields.ts +25 -6
  106. package/src/meta/peer-deps.test.ts +111 -14
@@ -11,6 +11,64 @@ import { getProvenance } from "../provenance";
11
11
  import type { BuildParamProvenance } from "../provenance";
12
12
  import { buildParamValues } from "../build-params";
13
13
  import { setBuildParams } from "../params";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { dirname, join, parse } from "node:path";
16
+
17
+ /**
18
+ * Warn when resolved build parameters cannot reach project source (#1421).
19
+ *
20
+ * chant's core is ESM. When the project is CommonJS — `"type": "commonjs"`, or
21
+ * no `type` field — tsx loads project source through the CommonJS transform, so
22
+ * the project's `require` of `params.ts` and core's `import` of it produce two
23
+ * separate module records. {@link setBuildParams} mutates one object in place;
24
+ * project source reads the other, and sees `{}`.
25
+ *
26
+ * The failure is silence. chant prints `[param] tier = "prod" (cli)` and then
27
+ * emits the graph for the default branch. `chant graph` is always affected
28
+ * because it always takes the run path; `chant build --no-fold` likewise. Plain
29
+ * `chant build` usually escapes because folding substitutes parameters
30
+ * statically — but a file that falls back to run inside a folded build is wrong
31
+ * the same way, which is why this warns regardless of `fold`.
32
+ *
33
+ * Only fires when parameters were actually resolved, so a CJS project that uses
34
+ * none is never nagged. `chant doctor`'s `package-type-module` check is the
35
+ * ambient version of the same advice.
36
+ *
37
+ * Best-effort and never throws: a project whose `package.json` cannot be found
38
+ * or parsed gets no warning rather than a failed build.
39
+ */
40
+ function warnIfParamsCannotReachProject(path: string, values: Record<string, unknown>): void {
41
+ if (Object.keys(values).length === 0) return;
42
+ try {
43
+ const pkgPath = findPackageJsonUpward(path);
44
+ if (!pkgPath) return;
45
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { type?: string };
46
+ if (pkg.type === "module") return;
47
+ const found = pkg.type ? `"type": "${pkg.type}"` : "no `type` field";
48
+ const names = Object.keys(values).sort().join(", ");
49
+ console.error(
50
+ `warning: ${pkgPath} has ${found}, but chant is ESM — build parameters (${names}) ` +
51
+ `will read as empty in project source on the run path, so declarations conditioned ` +
52
+ `on them take their default branch. Set "type": "module". (chant #1421)`,
53
+ );
54
+ } catch {
55
+ // Unreadable or unparseable package.json — say nothing rather than fail.
56
+ }
57
+ }
58
+
59
+ /** Nearest `package.json` at or above `startDir`, or undefined. */
60
+ function findPackageJsonUpward(startDir: string): string | undefined {
61
+ let dir = startDir;
62
+ const { root } = parse(dir);
63
+ for (;;) {
64
+ const candidate = join(dir, "package.json");
65
+ if (existsSync(candidate)) return candidate;
66
+ if (dir === root) return undefined;
67
+ const parent = dirname(dir);
68
+ if (parent === dir) return undefined;
69
+ dir = parent;
70
+ }
71
+ }
14
72
 
15
73
  /**
16
74
  * Per-file fold-vs-run outcome (chant #1022, epic #1019), populated only
@@ -150,6 +208,7 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
150
208
  // `--watch`) never leaks into a build that supplied none.
151
209
  const buildParamValuesMap = buildParamValues(options?.buildParams ?? []);
152
210
  setBuildParams(buildParamValuesMap);
211
+ warnIfParamsCannotReachProject(path, buildParamValuesMap);
153
212
 
154
213
  // Step 1: Scan for TypeScript files
155
214
  const files = await findInfraFiles(path);
@@ -0,0 +1,75 @@
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { discover } from "./index";
6
+
7
+ /**
8
+ * #1421 — chant's core is ESM. A CommonJS project's `require` of `params.ts`
9
+ * and core's `import` of it are two module records, so `setBuildParams`'s
10
+ * in-place mutation never reaches project source: it reads `{}` and every
11
+ * declaration conditioned on a parameter takes its default branch, silently.
12
+ *
13
+ * The fix is to stop it being silent. These assert the warning fires exactly
14
+ * when the hazard exists and stays quiet otherwise.
15
+ */
16
+ describe("build params that cannot reach a CommonJS project (#1421)", () => {
17
+ const dirs: string[] = [];
18
+ const project = (type: string | undefined): string => {
19
+ const dir = mkdtempSync(join(tmpdir(), "chant-1421-"));
20
+ dirs.push(dir);
21
+ writeFileSync(
22
+ join(dir, "package.json"),
23
+ JSON.stringify(type === undefined ? { name: "p" } : { name: "p", type }),
24
+ );
25
+ mkdirSync(join(dir, "src"));
26
+ writeFileSync(join(dir, "src", "main.ts"), "export const x = 1;\n");
27
+ return dir;
28
+ };
29
+
30
+ afterEach(() => {
31
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ const warnings = async (dir: string, params: Array<{ name: string; value: unknown }>): Promise<string[]> => {
36
+ const seen: string[] = [];
37
+ vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => void seen.push(a.join(" ")));
38
+ await discover(join(dir, "src"), {
39
+ buildParams: params as never,
40
+ });
41
+ return seen.filter((s) => s.includes("#1421"));
42
+ };
43
+
44
+ test('warns for "type": "commonjs" when parameters were resolved', async () => {
45
+ const out = await warnings(project("commonjs"), [{ name: "tier", value: "prod" }]);
46
+ expect(out).toHaveLength(1);
47
+ expect(out[0]).toMatch(/"type": "commonjs"/);
48
+ expect(out[0]).toMatch(/tier/);
49
+ expect(out[0]).toMatch(/Set "type": "module"/);
50
+ });
51
+
52
+ // The sneakier half: no `type` field at all is also CommonJS.
53
+ test("warns when package.json declares no type at all", async () => {
54
+ const out = await warnings(project(undefined), [{ name: "tier", value: "prod" }]);
55
+ expect(out).toHaveLength(1);
56
+ expect(out[0]).toMatch(/no `type` field/);
57
+ });
58
+
59
+ test('stays quiet for "type": "module"', async () => {
60
+ expect(await warnings(project("module"), [{ name: "tier", value: "prod" }])).toEqual([]);
61
+ });
62
+
63
+ // A CJS project using no parameters is not at risk, and must not be nagged.
64
+ test("stays quiet when no parameters were resolved", async () => {
65
+ expect(await warnings(project("commonjs"), [])).toEqual([]);
66
+ });
67
+
68
+ test("names every resolved parameter, sorted", async () => {
69
+ const out = await warnings(project("commonjs"), [
70
+ { name: "zone", value: "b" },
71
+ { name: "tier", value: "prod" },
72
+ ]);
73
+ expect(out[0]).toMatch(/\(tier, zone\)/);
74
+ });
75
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, test, expect, afterEach } from "vitest";
2
+ import { generateDriverSource } from "./driver";
3
+ import { setBuildParams } from "../../params";
4
+
5
+ /**
6
+ * chant #1108 — the generated child driver must re-bind the parent's resolved
7
+ * build-time parameters BEFORE importing any project file: the child process
8
+ * starts with its own empty copy of ../../params.ts's `params` object, so
9
+ * without this a run-fallback file reading `params.<name>` saw `undefined`
10
+ * under `--sandbox` while the fold path (substituting in the parent) saw the
11
+ * resolved value.
12
+ */
13
+ describe("generateDriverSource — build-time parameters (#1108)", () => {
14
+ afterEach(() => {
15
+ setBuildParams({});
16
+ });
17
+
18
+ test("embeds a snapshot of the parent's current params, bound before any project import", () => {
19
+ setBuildParams({ tier: "production-ha", replicas: 3, ha: true });
20
+ const source = generateDriverSource({ files: ["/tmp/project/a.ts"], buildRoot: "/tmp/project" });
21
+
22
+ expect(source).toContain("import { setBuildParams } from ");
23
+ const bindAt = source.indexOf(`setBuildParams(${JSON.stringify({ tier: "production-ha", replicas: 3, ha: true })});`);
24
+ const firstImportAt = source.indexOf(`await import("/tmp/project/a.ts")`);
25
+ expect(bindAt).toBeGreaterThan(-1);
26
+ expect(firstImportAt).toBeGreaterThan(-1);
27
+ expect(bindAt).toBeLessThan(firstImportAt);
28
+ });
29
+
30
+ test("with no params resolved, binds an empty snapshot (explicit, not absent)", () => {
31
+ const source = generateDriverSource({ files: ["/tmp/project/a.ts"], buildRoot: "/tmp/project" });
32
+ expect(source).toContain("setBuildParams({});");
33
+ });
34
+ });
@@ -1,4 +1,5 @@
1
1
  import { dirname, join } from "node:path";
2
+ import { params as currentBuildParams } from "../../params";
2
3
 
3
4
  /**
4
5
  * chant #1045 Phase 2 — generates the source of the "driver" module that runs
@@ -46,6 +47,16 @@ const CONFIG_WIRE_MODULE = join(HERE, "config-wire.ts");
46
47
  // chant #1131 — the policy driver's build-result decoding + diagnostics contract.
47
48
  const POLICY_WIRE_MODULE = join(HERE, "policy-wire.ts");
48
49
  const POST_SYNTH_MODULE = join(dirname(DISCOVERY_DIR), "lint", "post-synth.ts");
50
+ // chant #1108 — the shared build-time params module (../params.ts). The child
51
+ // process starts with its own, EMPTY copy of that module's `params` object;
52
+ // without re-binding, every run-fallback file imported in the child would see
53
+ // `params.* === undefined` while the fold path (which substitutes values in
54
+ // the PARENT) sees the resolved values — the exact split-brain #1064 exists
55
+ // to prevent. esbuild resolves a project file's own
56
+ // `import { params } from "@intentius/chant/params"` to this same absolute
57
+ // file, so the driver's `setBuildParams` call below mutates the one object
58
+ // every bundled import observes.
59
+ const PARAMS_MODULE = join(dirname(DISCOVERY_DIR), "params.ts");
49
60
 
50
61
  export interface GenerateDriverOptions {
51
62
  /** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
@@ -73,9 +84,17 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
73
84
  `import { encodeEntitySet } from ${lit(ENTITY_WIRE_CODEC_MODULE)};`,
74
85
  `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
75
86
  `import { getProvenance } from ${lit(PROVENANCE_MODULE)};`,
87
+ `import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
76
88
  ``,
77
89
  `const BUILD_ROOT = ${lit(buildRoot)};`,
78
90
  ``,
91
+ // chant #1108 — a snapshot of the PARENT's resolved build-time parameter
92
+ // values, embedded as a literal at generation time (the parent bound them
93
+ // via applyBuildParams/discover() before this source was generated).
94
+ // Values are declared-scalar only (BuildParamValue), so JSON round-trips
95
+ // them exactly. Bound before any project import below.
96
+ `setBuildParams(${lit({ ...currentBuildParams })});`,
97
+ ``,
79
98
  `function send(payload) {`,
80
99
  ` if (typeof process.send === "function") process.send(payload);`,
81
100
  ` else console.log(JSON.stringify(payload));`,
@@ -0,0 +1,86 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { buildDeclaredPerStack } from "./graph-declared";
6
+
7
+ /**
8
+ * Side-by-side stacks (#1433) — several stacks declared in config, each with its
9
+ * own `src`, composed into one graph.
10
+ *
11
+ * This is chant's ordinary multi-stack shape, as distinct from directory
12
+ * partitioning (subdirectories of one `src/`), and it is the one where stack
13
+ * membership is *declared* rather than inferred: the caller passes the names, and
14
+ * `buildDeclaredPerStack` already renames every node `${stack}::${id}`. The
15
+ * grouping was being discarded, so `groups.byStack` — the axis a boundary-box
16
+ * renderer reads — came back empty for exactly the projects that have real
17
+ * stacks to box.
18
+ */
19
+ let dir: string;
20
+
21
+ // The shape discovery actually recognises — a plain object carrying the
22
+ // declarable marker, as `env.test.ts`'s fixtures do.
23
+ const stackSrc = (resource: string) =>
24
+ `export const ${resource} = { entityType: "Thing", lexicon: "aws", kind: "resource", [Symbol.for("chant.declarable")]: true };\n`;
25
+
26
+ beforeAll(() => {
27
+ dir = mkdtempSync(join(tmpdir(), "chant-side-by-side-"));
28
+ for (const [stack, resource] of [
29
+ ["network", "vpc"],
30
+ ["app", "service"],
31
+ ] as const) {
32
+ mkdirSync(join(dir, stack, "src"), { recursive: true });
33
+ writeFileSync(join(dir, stack, "src", "main.ts"), stackSrc(resource));
34
+ }
35
+ });
36
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
37
+
38
+ describe("buildDeclaredPerStack groups by declared stack (#1433)", () => {
39
+ test("every node lands in the stack that declared it", async () => {
40
+ const ir = await buildDeclaredPerStack(
41
+ [
42
+ { name: "network", src: "network/src" },
43
+ { name: "app", src: "app/src" },
44
+ ],
45
+ dir,
46
+ );
47
+ const byStack = ir.groups.byStack ?? {};
48
+ expect(Object.keys(byStack).sort()).toEqual(["app", "network"]);
49
+ // Membership uses the SAME qualified ids the nodes carry — a consumer joins
50
+ // the two without re-deriving anything.
51
+ const ids = new Set(ir.nodes.map((n) => n.id));
52
+ for (const members of Object.values(byStack)) {
53
+ for (const id of members) expect(ids.has(id)).toBe(true);
54
+ }
55
+ // Every node is in exactly one stack: a boundary-box renderer that drew from
56
+ // this would otherwise leave resources outside every box.
57
+ expect(Object.values(byStack).flat().sort()).toEqual([...ids].sort());
58
+ });
59
+
60
+ test("a stack with no src contributes no group, as it contributes no nodes", async () => {
61
+ const ir = await buildDeclaredPerStack(
62
+ [{ name: "network", src: "network/src" }, { name: "unbuilt" }],
63
+ dir,
64
+ );
65
+ expect(Object.keys(ir.groups.byStack ?? {})).toEqual(["network"]);
66
+ });
67
+
68
+ test("no stacks with src means no grouping at all, not an empty box", async () => {
69
+ const ir = await buildDeclaredPerStack([{ name: "unbuilt" }], dir);
70
+ expect(ir.groups.byStack).toBeUndefined();
71
+ expect(ir.nodes).toEqual([]);
72
+ });
73
+
74
+ test("members are sorted, so the IR stays byte-stable across runs", async () => {
75
+ const stacks = [
76
+ { name: "network", src: "network/src" },
77
+ { name: "app", src: "app/src" },
78
+ ];
79
+ const a = await buildDeclaredPerStack(stacks, dir);
80
+ const b = await buildDeclaredPerStack(stacks, dir);
81
+ expect(a.groups.byStack).toEqual(b.groups.byStack);
82
+ for (const members of Object.values(a.groups.byStack ?? {})) {
83
+ expect(members).toEqual([...members].sort());
84
+ }
85
+ });
86
+ });
@@ -22,12 +22,24 @@ export async function buildDeclaredPerStack(
22
22
  ): Promise<GraphIR> {
23
23
  const nodes: IRNode[] = [];
24
24
  const edges: GraphIR["edges"] = [];
25
+ // Which nodes belong to which stack (#1433). This is not derived or guessed:
26
+ // the stack is declared in config and every node here is being renamed with
27
+ // its name on the line below. The membership was always known and thrown
28
+ // away, which left `byStack` — the axis consumers draw boundary boxes from —
29
+ // empty for the one project shape that genuinely has side-by-side stacks.
30
+ const byStack: Record<string, string[]> = {};
25
31
  for (const st of stacks) {
26
32
  if (!st.src) continue;
27
33
  const g = buildGraphIr((await discover(resolve(projectPath, st.src))).entities, projectPath);
28
34
  const q = (id: string) => `${st.name}::${id}`;
29
- for (const n of g.nodes) nodes.push({ ...n, id: q(n.id) });
35
+ for (const n of g.nodes) {
36
+ const id = q(n.id);
37
+ nodes.push({ ...n, id });
38
+ (byStack[st.name] ??= []).push(id);
39
+ }
30
40
  for (const e of g.edges) edges.push({ ...e, from: q(e.from), to: q(e.to) });
31
41
  }
32
- return { nodes, edges, groups: {} };
42
+ for (const ids of Object.values(byStack)) ids.sort();
43
+ const groups: GraphIR["groups"] = Object.keys(byStack).length ? { byStack } : {};
44
+ return { nodes, edges, groups };
33
45
  }
package/src/graph-ir.ts CHANGED
@@ -94,9 +94,23 @@ export interface IREdge {
94
94
  export interface IRGroups {
95
95
  byLexicon?: Record<string, string[]>;
96
96
  byComposite?: Record<string, string[]>;
97
- /** Deployable-stack grouping (`stackName → nodeIds`). A stack is a lexicon
98
- * partition today; #513 phase 2 regroups by nested child-project. Consumers
99
- * (e.g. pinhole's boundary boxes) read this rather than inferring stacks. */
97
+ /**
98
+ * Deployable-stack grouping (`stackName nodeIds`). Consumers (e.g. pinhole's
99
+ * boundary boxes) read this rather than inferring stacks — which is the point,
100
+ * so it should never require one.
101
+ *
102
+ * Two sources, depending on how the project is shaped:
103
+ *
104
+ * - **side-by-side stacks**, declared in config and composed by
105
+ * `buildDeclaredPerStack` — keys are the declared stack names. Nothing is
106
+ * inferred; the project stated both the names and the membership.
107
+ * - **one source tree** — keys are lexicon partitions, since each lexicon
108
+ * serialises to one deployable stack.
109
+ *
110
+ * Formerly documented as awaiting "#513 phase 2" to regroup by nested
111
+ * child-project. #513 is closed and that phase was never filed; directory
112
+ * partitioning is the exception rather than the rule (#1433).
113
+ */
100
114
  byStack?: Record<string, string[]>;
101
115
  /** Live containment (#779): a container node id → the node ids directly inside
102
116
  * it (VPC → subnets/SGs, subnet → instances/service). Nested *flatly* — a
@@ -415,11 +429,21 @@ export function buildGraphIr(
415
429
  nodes.push(node);
416
430
 
417
431
  (byLexicon[entity.lexicon] ??= []).push(name);
418
- // A stack is a lexicon partition (each lexicon serialises to one deployable
419
- // stack a CloudFormation template, a CI config). `byStack` mirrors that
420
- // today; #513 phase 2 will regroup it by nested child-project. It's a
421
- // distinct axis from `byLexicon` (which is for provenance/colouring), so it's
422
- // emitted separately even where the two currently coincide.
432
+ // Within ONE source tree a stack is a lexicon partition each lexicon
433
+ // serialises to one deployable stack (a CloudFormation template, a CI
434
+ // config) so that is what `byStack` reports here. It stays a distinct axis
435
+ // from `byLexicon` (which is for provenance/colouring), emitted separately
436
+ // even where the two coincide.
437
+ //
438
+ // A project with genuinely separate, side-by-side stacks declares them in
439
+ // config, and `buildDeclaredPerStack` (./graph-declared.ts) groups those by
440
+ // their declared names — no inference, since the project already said. That
441
+ // is the multi-stack shape chant steers toward (#1433).
442
+ //
443
+ // This previously promised that "#513 phase 2 will regroup it by nested
444
+ // child-project". #513 is closed, that phase was never filed, and directory
445
+ // partitioning is the exception rather than the rule — so the promise is
446
+ // withdrawn rather than left pointing at a closed issue.
423
447
  (byStack[entity.lexicon] ??= []).push(name);
424
448
  if (prov?.composite) (byComposite[prov.composite] ??= []).push(name);
425
449
  }
@@ -188,3 +188,59 @@ describe("traversal name vs rendering label (#1275)", () => {
188
188
  expect(edges).toEqual([]);
189
189
  });
190
190
  });
191
+
192
+ describe("containment is traversable without being drawn", () => {
193
+ // "What is inside this" is a query, not a picture. The two had been decided
194
+ // by one field: a containment rule became a real edge only if it set
195
+ // `viaAttr`, which a renderer also reads. So making a relationship queryable
196
+ // meant drawing it, and the list of which ones had been remembered was the
197
+ // list of which questions could be asked.
198
+ const catalog: ReferenceCatalog = {
199
+ identities: [
200
+ { kind: "Subnet", ids: ["SubnetId"] },
201
+ { kind: "Vpc", ids: ["VpcId"] },
202
+ ],
203
+ refs: [
204
+ { from: "Eni", path: "SubnetId", targetKind: "Subnet", relation: "containment", label: "in subnet" },
205
+ { from: "Subnet", path: "VpcId", targetKind: "Vpc", relation: "containment", label: "in VPC" },
206
+ ],
207
+ };
208
+ const nodes = [
209
+ { id: "vpc-1", kind: "Vpc", lexicon: "x", attrs: { VpcId: "vpc-1" } },
210
+ { id: "sub-1", kind: "Subnet", lexicon: "x", attrs: { SubnetId: "sub-1", VpcId: "vpc-1" } },
211
+ { id: "sub-2", kind: "Subnet", lexicon: "x", attrs: { SubnetId: "sub-2", VpcId: "vpc-1" } },
212
+ { id: "eni-1", kind: "Eni", lexicon: "x", attrs: { SubnetId: "sub-1" } },
213
+ ];
214
+
215
+ it("still draws no containment lines — the boundary stays a boundary", () => {
216
+ const { edges } = reconstructEdges(nodes, catalog);
217
+ expect(edges).toEqual([]);
218
+ });
219
+
220
+ it("still reports the boundary pairs a renderer groups by", () => {
221
+ const { containment } = reconstructEdges(nodes, catalog);
222
+ expect(containment).toContainEqual({ child: "eni-1", parent: "sub-1", label: "in subnet" });
223
+ expect(containment).toContainEqual({ child: "sub-1", parent: "vpc-1", label: "in VPC" });
224
+ });
225
+
226
+ it("offers the same pairs as edges a query can walk", () => {
227
+ const { containmentEdges } = reconstructEdges(nodes, catalog);
228
+ expect(containmentEdges).toContainEqual({ from: "eni-1", to: "sub-1", kind: "ref", viaAttr: "SubnetId" });
229
+ expect(containmentEdges).toContainEqual({ from: "sub-1", to: "vpc-1", kind: "ref", viaAttr: "VpcId" });
230
+ });
231
+
232
+ it("no rule had to opt in — neither declares viaAttr", () => {
233
+ // The point of the change. Both rules above are plain containment; the
234
+ // traversal name is derived from the attribute the containment was read
235
+ // through, so a new kind is queryable the day its rule is written.
236
+ expect(catalog.refs.every((r) => r.viaAttr === undefined)).toBe(true);
237
+ // eni-1 -> sub-1, and both subnets -> vpc-1.
238
+ const { containmentEdges } = reconstructEdges(nodes, catalog);
239
+ expect(containmentEdges).toHaveLength(3);
240
+ });
241
+
242
+ it("an empty container is reached by nothing, which is the whole question", () => {
243
+ const { containmentEdges } = reconstructEdges(nodes, catalog);
244
+ expect(containmentEdges.some((e) => e.to === "sub-2")).toBe(false);
245
+ });
246
+ });
package/src/graph-refs.ts CHANGED
@@ -86,6 +86,30 @@ export interface DanglingRef {
86
86
  export interface ReconstructedEdges {
87
87
  edges: IREdge[];
88
88
  containment: ContainmentPair[];
89
+ /**
90
+ * The same containment pairs, as edges a query can walk.
91
+ *
92
+ * Containment is a boundary when you are drawing it and a relationship when
93
+ * you are asking about it, and those two consumers had been served by one
94
+ * decision. `edges` is what a renderer draws as lines, so putting "is in this
95
+ * VPC" there would draw a line from every resource to its VPC and undo the
96
+ * boxes; that is why containment is kept out of it, and why it stays out.
97
+ *
98
+ * But `->`/`<-` is asking which nodes reach which, and being inside something
99
+ * is a way of reaching it. "Which subnets have no network interfaces in them"
100
+ * and "which VPCs have no instances in them" are the same question, and both
101
+ * are containment. With only `edges` to walk, the negation matched everything
102
+ * and reported an estate where nothing is anywhere.
103
+ *
104
+ * The escape hatch this replaces was per-rule: a containment rule could set
105
+ * `viaAttr` and become a real edge. That put the query layer's needs in a
106
+ * field the renderer also reads, and it had to be remembered per rule —
107
+ * `AWS::EC2::Instance -> Subnet` had it and `AWS::EC2::NetworkInterface ->
108
+ * Subnet` did not, which is the kind of gap hand-maintained lists always
109
+ * develop. Deriving them here means a containment rule is traversable because
110
+ * it is a containment rule, not because someone remembered.
111
+ */
112
+ containmentEdges: IREdge[];
89
113
  dangling: DanglingRef[];
90
114
  }
91
115
 
@@ -158,6 +182,8 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
158
182
 
159
183
  const edges: IREdge[] = [];
160
184
  const containment: ContainmentPair[] = [];
185
+ const containmentEdges: IREdge[] = [];
186
+ const seenContEdge = new Set<string>();
161
187
  const dangling: DanglingRef[] = [];
162
188
  const seenEdge = new Set<string>();
163
189
  const seenCont = new Set<string>();
@@ -185,6 +211,15 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
185
211
  seenCont.add(k);
186
212
  containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
187
213
  }
214
+ // Traversable by construction. The attribute the containment was read
215
+ // through is its traversal name, so `<-attr:` can still discriminate
216
+ // between two ways of being inside something.
217
+ const via = rule.viaAttr ?? rule.path;
218
+ const ke = `${node.id}|${match.id}|${via}`;
219
+ if (!seenContEdge.has(ke)) {
220
+ seenContEdge.add(ke);
221
+ containmentEdges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via });
222
+ }
188
223
  // A containment relation is a boundary hint, not an edge — unless the
189
224
  // rule declares a traversal name (#1275). A fold's first hop is
190
225
  // sometimes exactly a containment ("an instance is in a subnet"), and
@@ -202,10 +237,11 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
202
237
  }
203
238
 
204
239
  edges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`));
240
+ containmentEdges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`));
205
241
  containment.sort((a, b) => `${a.child}|${a.parent}`.localeCompare(`${b.child}|${b.parent}`));
206
242
  dangling.sort((a, b) => `${a.from}|${a.path}|${a.value}`.localeCompare(`${b.from}|${b.path}|${b.value}`));
207
243
 
208
- return { edges, containment, dangling };
244
+ return { edges, containment, containmentEdges, dangling };
209
245
  }
210
246
 
211
247
  /**
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export * from "./import/parser";
49
49
  export * from "./import/generator";
50
50
  export * from "./lexicon";
51
51
  export * from "./observation";
52
+ export * from "./apply";
52
53
  export * from "./deep-observation";
53
54
  export * from "./owner-chain";
54
55
  export * from "./lexicon-integrity";
@@ -126,7 +126,10 @@ export async function resolveClusterTarget(
126
126
  lexiconName: string,
127
127
  options: ResolveClusterTargetOptions = {},
128
128
  ): Promise<ResolvedClusterTarget> {
129
- const k8sConfig = config.k8s as K8sConfigShape | undefined;
129
+ // #1344 the k8s lexicon declares this namespace and core validates it at
130
+ // load, so the shape is checked rather than asserted. `K8sConfigShape` stays
131
+ // as core's local description for the case where the lexicon is absent.
132
+ const k8sConfig = (config as { k8s?: K8sConfigShape }).k8s;
130
133
  const bound = k8sConfig?.profiles?.[environment]?.context;
131
134
 
132
135
  if (!bound) {
@@ -0,0 +1,111 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { z } from "zod";
3
+ import { validateLexiconConfig, formatLexiconConfigProblems } from "./lexicon-config";
4
+ import type { ChantConfig } from "./config";
5
+
6
+ const forgejo = {
7
+ name: "forgejo",
8
+ configSchema: z.strictObject({
9
+ runnerLabels: z.record(z.string(), z.string()).optional(),
10
+ actionsRoot: z.string().optional(),
11
+ }),
12
+ };
13
+
14
+ const undeclared = { name: "docker" };
15
+
16
+ const cfg = (extra: Record<string, unknown>): ChantConfig =>
17
+ ({ lexicons: ["forgejo"], ...extra }) as unknown as ChantConfig;
18
+
19
+ describe("validateLexiconConfig (#1344)", () => {
20
+ test("accepts a namespace matching the declared shape", () => {
21
+ const problems = validateLexiconConfig(
22
+ [forgejo],
23
+ cfg({ forgejo: { runnerLabels: { "ubuntu-latest": "docker" }, actionsRoot: "https://x" } }),
24
+ );
25
+ expect(problems).toEqual([]);
26
+ });
27
+
28
+ test("rejects an unknown key — the typo that used to be silently ignored", () => {
29
+ const problems = validateLexiconConfig([forgejo], cfg({ forgejo: { runnerLabel: {} } }));
30
+ expect(problems).toHaveLength(1);
31
+ expect(problems[0].path).toBe("forgejo");
32
+ expect(problems[0].message).toContain("runnerLabel");
33
+ });
34
+
35
+ test("names the dotted path of a bad value, not just the namespace", () => {
36
+ const problems = validateLexiconConfig([forgejo], cfg({ forgejo: { actionsRoot: 42 } }));
37
+ expect(problems[0].path).toBe("forgejo.actionsRoot");
38
+ });
39
+
40
+ test("an absent namespace is fine — every one of them is optional", () => {
41
+ expect(validateLexiconConfig([forgejo], cfg({}))).toEqual([]);
42
+ });
43
+
44
+ test("an empty namespace is fine", () => {
45
+ expect(validateLexiconConfig([forgejo], cfg({ forgejo: {} }))).toEqual([]);
46
+ });
47
+
48
+ test("a lexicon that declares nothing keeps passthrough", () => {
49
+ // Tightening a namespace nobody described would fail configs that work.
50
+ expect(validateLexiconConfig([undeclared], cfg({ docker: { anything: true } }))).toEqual([]);
51
+ });
52
+
53
+ test("another lexicon's namespace is not this lexicon's to reject", () => {
54
+ expect(validateLexiconConfig([forgejo], cfg({ somethingElse: { a: 1 } }))).toEqual([]);
55
+ });
56
+
57
+ test("core's own keys are untouched", () => {
58
+ expect(validateLexiconConfig([forgejo], cfg({ sourceDir: "src", build: { fold: true } }))).toEqual([]);
59
+ });
60
+
61
+ test("reports every problem, not just the first", () => {
62
+ const problems = validateLexiconConfig(
63
+ [forgejo],
64
+ cfg({ forgejo: { runnerLabel: {}, actionsRoot: 42 } }),
65
+ );
66
+ expect(problems.length).toBeGreaterThanOrEqual(2);
67
+ });
68
+
69
+ test("validates each declaring lexicon independently", () => {
70
+ const temporal = {
71
+ name: "temporal",
72
+ configSchema: z.strictObject({ defaultProfile: z.string().optional() }),
73
+ };
74
+ const problems = validateLexiconConfig(
75
+ [forgejo, temporal],
76
+ cfg({ forgejo: { actionsRoot: "https://x" }, temporal: { defaultProfil: "local" } }),
77
+ );
78
+ expect(problems.map((p) => p.lexicon)).toEqual(["temporal"]);
79
+ });
80
+
81
+ test("no config at all is not a problem", () => {
82
+ expect(validateLexiconConfig([forgejo], undefined)).toEqual([]);
83
+ });
84
+
85
+ test("a nested strictObject catches a typo one level down", () => {
86
+ const k8s = {
87
+ name: "k8s",
88
+ configSchema: z.strictObject({
89
+ profiles: z.record(z.string(), z.strictObject({ context: z.string() })).optional(),
90
+ }),
91
+ };
92
+ const problems = validateLexiconConfig(
93
+ [k8s],
94
+ cfg({ k8s: { profiles: { prod: { contxt: "prod-eks" } } } }),
95
+ );
96
+ // Two, and both are right: the key it does not recognize, and the required
97
+ // one that is now missing because of the typo.
98
+ expect(problems.map((p) => p.path).sort()).toEqual(["k8s.profiles.prod", "k8s.profiles.prod.context"]);
99
+ });
100
+ });
101
+
102
+ describe("formatLexiconConfigProblems", () => {
103
+ test("one indented line per problem", () => {
104
+ const problems = validateLexiconConfig([forgejo], cfg({ forgejo: { actionsRoot: 42 } }));
105
+ expect(formatLexiconConfigProblems(problems)).toMatch(/^ {2}forgejo\.actionsRoot: /);
106
+ });
107
+
108
+ test("empty for no problems", () => {
109
+ expect(formatLexiconConfigProblems([])).toBe("");
110
+ });
111
+ });