@intentius/chant 0.38.0 → 0.39.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 (85) hide show
  1. package/dist/build.d.ts +21 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  6. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  7. package/dist/cli/handlers/search.d.ts +30 -1
  8. package/dist/cli/handlers/search.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/plugins.d.ts +20 -0
  11. package/dist/cli/plugins.d.ts.map +1 -1
  12. package/dist/codegen/registry.d.ts +23 -0
  13. package/dist/codegen/registry.d.ts.map +1 -0
  14. package/dist/components/sandbox/driver.d.ts.map +1 -1
  15. package/dist/composite.d.ts +23 -4
  16. package/dist/composite.d.ts.map +1 -1
  17. package/dist/deep-observation.d.ts +11 -0
  18. package/dist/deep-observation.d.ts.map +1 -1
  19. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  20. package/dist/graph-declared.d.ts.map +1 -1
  21. package/dist/graph-ir.d.ts +17 -3
  22. package/dist/graph-ir.d.ts.map +1 -1
  23. package/dist/graph-refs.d.ts +24 -0
  24. package/dist/graph-refs.d.ts.map +1 -1
  25. package/dist/kubectl-context.d.ts.map +1 -1
  26. package/dist/lexicon-config.d.ts +61 -0
  27. package/dist/lexicon-config.d.ts.map +1 -0
  28. package/dist/lexicon.d.ts +19 -0
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/deep-diff.d.ts +11 -0
  31. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  32. package/dist/lifecycle/digest.d.ts.map +1 -1
  33. package/dist/lifecycle/identity.d.ts +52 -0
  34. package/dist/lifecycle/identity.d.ts.map +1 -0
  35. package/dist/lifecycle/observe.d.ts +5 -0
  36. package/dist/lifecycle/observe.d.ts.map +1 -1
  37. package/dist/lifecycle/replay.d.ts.map +1 -1
  38. package/dist/lifecycle/types.d.ts +30 -0
  39. package/dist/lifecycle/types.d.ts.map +1 -1
  40. package/dist/managed-fields.d.ts +11 -0
  41. package/dist/managed-fields.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/build.ts +24 -0
  44. package/src/cli/commands/build.ts +10 -0
  45. package/src/cli/commands/check-lexicon.ts +20 -1
  46. package/src/cli/handlers/lifecycle.ts +10 -0
  47. package/src/cli/handlers/run-client.ts +3 -1
  48. package/src/cli/handlers/search-kind.test.ts +45 -0
  49. package/src/cli/handlers/search.ts +102 -4
  50. package/src/cli/main.ts +32 -10
  51. package/src/cli/param-flag-scope.test.ts +69 -0
  52. package/src/cli/plugins.test.ts +33 -1
  53. package/src/cli/plugins.ts +55 -0
  54. package/src/codegen/registry.test.ts +56 -0
  55. package/src/codegen/registry.ts +69 -0
  56. package/src/components/SPRAWL-VALIDATION.md +5 -5
  57. package/src/components/sandbox/driver.test.ts +27 -0
  58. package/src/components/sandbox/driver.ts +12 -0
  59. package/src/composite.ts +33 -4
  60. package/src/deep-observation.ts +11 -0
  61. package/src/discovery/sandbox/driver.test.ts +34 -0
  62. package/src/discovery/sandbox/driver.ts +19 -0
  63. package/src/graph-declared.test.ts +86 -0
  64. package/src/graph-declared.ts +14 -2
  65. package/src/graph-ir.ts +32 -8
  66. package/src/graph-refs.test.ts +56 -0
  67. package/src/graph-refs.ts +37 -1
  68. package/src/kubectl-context.ts +4 -1
  69. package/src/lexicon-config.test.ts +111 -0
  70. package/src/lexicon-config.ts +92 -0
  71. package/src/lexicon.ts +20 -0
  72. package/src/lifecycle/deep-diff.test.ts +48 -1
  73. package/src/lifecycle/deep-diff.ts +16 -0
  74. package/src/lifecycle/digest.test.ts +81 -0
  75. package/src/lifecycle/digest.ts +34 -3
  76. package/src/lifecycle/identity.test.ts +39 -0
  77. package/src/lifecycle/identity.ts +61 -0
  78. package/src/lifecycle/observe.test.ts +75 -1
  79. package/src/lifecycle/observe.ts +28 -2
  80. package/src/lifecycle/replay.test.ts +251 -0
  81. package/src/lifecycle/replay.ts +67 -19
  82. package/src/lifecycle/types.ts +26 -0
  83. package/src/managed-fields.test.ts +50 -0
  84. package/src/managed-fields.ts +25 -6
  85. package/src/meta/peer-deps.test.ts +111 -14
@@ -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
  /**
@@ -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
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The config namespace a lexicon owns (#1344).
3
+ *
4
+ * Three lexicons read a top-level `chant.config.ts` key named after themselves —
5
+ * `k8s.profiles.<env>.context`, `temporal.profiles`, `forgejo.runnerLabels` and
6
+ * `forgejo.actionsRoot` — all documented for users, none declared anywhere.
7
+ * `ChantConfigSchema` is `.passthrough()`, so at runtime any key is accepted and
8
+ * a typo is silently ignored: write `runnerLabel` and the Forgejo dialect just
9
+ * uses its defaults, with nothing said. The `ChantConfig` interface is closed,
10
+ * so the documented forgejo example did not even compile:
11
+ *
12
+ * error TS2353: Object literal may only specify known properties,
13
+ * and 'forgejo' does not exist in type 'ChantConfig'.
14
+ *
15
+ * Three lexicons had arrived at three different workarounds: temporal exported
16
+ * its own widened `TemporalChantConfig`, k8s's docs dropped `satisfies`, and
17
+ * forgejo's kept it and were wrong.
18
+ *
19
+ * A lexicon now declares the shape of its namespace. The declaration is both
20
+ * halves of the fix at once: core validates against it at load, and the lexicon
21
+ * derives the type it augments `ChantConfig` with from the same schema, so the
22
+ * runtime rule and the compile-time one cannot disagree.
23
+ */
24
+
25
+ import type { ZodObject, ZodRawShape } from "zod";
26
+ import type { ChantConfig } from "./config";
27
+
28
+ /**
29
+ * A lexicon's config schema. A `ZodObject` specifically, so core can apply
30
+ * `.strict()` itself rather than trusting each lexicon to remember — an unknown
31
+ * key at the top of a declared namespace is a typo, and silently ignoring it is
32
+ * the behavior this replaces.
33
+ *
34
+ * Nested objects are the lexicon's own responsibility: author them with
35
+ * `z.strictObject` so a typo in `k8s.profiles.prod.contxt` fails too. `.strict()`
36
+ * applies to one level.
37
+ */
38
+ export type LexiconConfigSchema = ZodObject<ZodRawShape>;
39
+
40
+ /** What a lexicon needs to expose for its namespace to be validated. */
41
+ export interface ConfigOwningLexicon {
42
+ name: string;
43
+ configSchema?: LexiconConfigSchema;
44
+ }
45
+
46
+ export interface LexiconConfigProblem {
47
+ /** The lexicon whose namespace failed. */
48
+ lexicon: string;
49
+ /** Dotted path to the offending value, e.g. `forgejo.runnerLabel`. */
50
+ path: string;
51
+ message: string;
52
+ }
53
+
54
+ /**
55
+ * Validate each lexicon's own namespace against the schema it declares.
56
+ *
57
+ * A lexicon that declares nothing keeps today's passthrough — silence for an
58
+ * unknown key — because tightening a namespace nobody described would fail
59
+ * configs that are working. Declaring is the opt-in.
60
+ *
61
+ * An absent namespace is not a problem: every one of them is optional.
62
+ */
63
+ export function validateLexiconConfig(
64
+ lexicons: readonly ConfigOwningLexicon[],
65
+ config: ChantConfig | undefined,
66
+ ): LexiconConfigProblem[] {
67
+ if (!config) return [];
68
+ const problems: LexiconConfigProblem[] = [];
69
+ const raw = config as unknown as Record<string, unknown>;
70
+
71
+ for (const lexicon of lexicons) {
72
+ const schema = lexicon.configSchema;
73
+ if (!schema) continue;
74
+ const value = raw[lexicon.name];
75
+ if (value === undefined) continue;
76
+
77
+ const result = schema.strict().safeParse(value);
78
+ if (result.success) continue;
79
+
80
+ for (const issue of result.error.issues) {
81
+ const path = [lexicon.name, ...issue.path.map(String)].join(".");
82
+ problems.push({ lexicon: lexicon.name, path, message: issue.message });
83
+ }
84
+ }
85
+
86
+ return problems;
87
+ }
88
+
89
+ /** One line per problem, for a CLI error. */
90
+ export function formatLexiconConfigProblems(problems: readonly LexiconConfigProblem[]): string {
91
+ return problems.map((p) => ` ${p.path}: ${p.message}`).join("\n");
92
+ }
package/src/lexicon.ts CHANGED
@@ -10,6 +10,7 @@ import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
10
10
  import type { DriverComponent } from "./components/driver";
11
11
  import type { EmulatorDeclaration } from "./op/emulator-lifecycle";
12
12
  import type { OwnershipChannel } from "./ownership";
13
+ import type { LexiconConfigSchema } from "./lexicon-config";
13
14
  import type { RuleMeta } from "./audit/catalog";
14
15
  import type { ReferenceCatalog } from "./graph-refs";
15
16
  import type { IREdge } from "./graph-ir";
@@ -440,6 +441,25 @@ export interface LexiconPlugin {
440
441
  /** Package lexicon into distributable tarball */
441
442
  package(options?: { verbose?: boolean; force?: boolean }): Promise<void>;
442
443
 
444
+ /**
445
+ * The shape of this lexicon's own `chant.config.ts` namespace — the top-level
446
+ * key named after the lexicon (#1344).
447
+ *
448
+ * k8s reads `k8s.profiles.<env>.context`, temporal `temporal.profiles`,
449
+ * forgejo `forgejo.runnerLabels` and `forgejo.actionsRoot`. All were
450
+ * documented for users and declared nowhere: the config schema is
451
+ * `.passthrough()`, so a typo was accepted and silently ignored, and the
452
+ * `ChantConfig` interface is closed, so the documented examples did not
453
+ * compile.
454
+ *
455
+ * Declaring the schema makes an unknown key inside the namespace an error
456
+ * rather than a default, and gives the lexicon a single source to derive the
457
+ * type it augments `ChantConfig` with — so the runtime rule and the
458
+ * compile-time one cannot disagree. Omit it and the namespace keeps today's
459
+ * passthrough.
460
+ */
461
+ readonly configSchema?: LexiconConfigSchema;
462
+
443
463
  /**
444
464
  * Local emulator(s) (#920), if this lexicon has any: Floci for aws, floci-az
445
465
  * for azure, floci-gcp for gcp, mudflaps and spritzer for fly. Drives
@@ -4,7 +4,7 @@ import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation"
4
4
  import type { BaselineLexicon } from "./observation-baseline";
5
5
 
6
6
  const live = (
7
- resources: Record<string, { type: string; properties: Record<string, unknown> }>,
7
+ resources: Record<string, { type: string; properties: Record<string, unknown>; fieldOwners?: Record<string, string> }>,
8
8
  unobserved: NormalizedDeepObservation["unobserved"] = {},
9
9
  ): NormalizedDeepObservation => ({ resources, unobserved });
10
10
 
@@ -155,3 +155,50 @@ describe("diffDeep with an accepted baseline", () => {
155
155
  expect(result.drifted[0].changes.map((c) => c.path)).toEqual(["Extra"]);
156
156
  });
157
157
  });
158
+
159
+ // #1189 — `kind` says a path is undeclared or changed; `owner` says who did it.
160
+ // The two are independent: `hpa-controller` owning `spec.replicas` and somebody
161
+ // running `kubectl edit` are the same kind and opposite situations.
162
+ describe("diffDeep — owning field manager (#1189)", () => {
163
+ const declared = { web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 2 } } } };
164
+
165
+ test("names the manager on a drifted path", () => {
166
+ const result = diffDeep({
167
+ declared,
168
+ live: live({
169
+ web: {
170
+ type: "K8s::Apps::Deployment",
171
+ properties: { spec: { replicas: 5 } },
172
+ fieldOwners: { "spec.replicas": "hpa-controller" },
173
+ },
174
+ }),
175
+ });
176
+ expect(result.drifted[0].changes[0]).toMatchObject({
177
+ path: "spec.replicas",
178
+ kind: "changed",
179
+ owner: "hpa-controller",
180
+ });
181
+ });
182
+
183
+ test("is absent when the substrate records no per-field ownership", () => {
184
+ // Every substrate but k8s. The field must not appear at all rather than
185
+ // appear empty — a consumer branches on its presence.
186
+ const result = diffDeep({
187
+ declared,
188
+ live: live({ web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 5 } } } }),
189
+ });
190
+ expect(result.drifted[0].changes[0]).not.toHaveProperty("owner");
191
+ });
192
+
193
+ test("is absent for a path with no live value — nobody owns a field that is not there", () => {
194
+ const result = diffDeep({
195
+ declared,
196
+ live: live({
197
+ web: { type: "K8s::Apps::Deployment", properties: {}, fieldOwners: { "spec.replicas": "someone" } },
198
+ }),
199
+ });
200
+ const change = result.drifted[0].changes.find((c) => c.path === "spec.replicas")!;
201
+ expect(change.kind).toBe("absent");
202
+ expect(change).not.toHaveProperty("owner");
203
+ });
204
+ });
@@ -59,6 +59,17 @@ export interface PropertyDrift {
59
59
  * accepted value there would lose the most useful column in the report.
60
60
  */
61
61
  baseline?: unknown;
62
+ /**
63
+ * The field manager that owns this path live, where the substrate records one
64
+ * (#1189) — Kubernetes' `managedFields`, and nowhere else today.
65
+ *
66
+ * `kind` says a path is `undeclared` or `changed`; this says who did it.
67
+ * "Owned by `kubectl-client-side-apply`" and "owned by `hpa-controller`" are
68
+ * the same `kind` and mean opposite things: one is somebody bypassing the
69
+ * pipeline, the other is a controller doing its job. Absent on a substrate
70
+ * with no per-field ownership, which is every substrate but k8s.
71
+ */
72
+ owner?: string;
62
73
  }
63
74
 
64
75
  /** Property-level drift for one declared entity. */
@@ -173,11 +184,16 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
173
184
  if (hasDeclared && hasLive && deepValueEqual(declaredValue, liveValue)) continue;
174
185
 
175
186
  const kind: PropertyDriftKind = !hasDeclared ? "undeclared" : !hasLive ? "absent" : "changed";
187
+ // Who owns the path live, where the substrate records it (#1189). Only
188
+ // meaningful for a path that exists live — an `absent` drift has no live
189
+ // field for anyone to own.
190
+ const owner = hasLive ? liveEntity.fieldOwners?.[path] : undefined;
176
191
  const drift: PropertyDrift = {
177
192
  path,
178
193
  kind,
179
194
  ...(hasDeclared ? { declared: declaredValue } : {}),
180
195
  ...(hasLive ? { live: liveValue } : {}),
196
+ ...(owner ? { owner } : {}),
181
197
  };
182
198
 
183
199
  const acceptedEntry = acceptedDeviation(baseline, name, path);
@@ -115,3 +115,84 @@ describe("diffDigests", () => {
115
115
  expect(result.removed.sort()).toEqual(["c"]);
116
116
  });
117
117
  });
118
+
119
+ /**
120
+ * chant #1442 — a digest records what interpreted the declarations, not only
121
+ * what was declared.
122
+ */
123
+ function withVersions(versions: Record<string, string> | undefined): BuildResult {
124
+ const result = makeBuildResult({ k8s: [{ name: "app", type: "K8s::Apps::Deployment", props: { replicas: 2 } }] });
125
+ return { ...result, lexiconVersions: versions } as unknown as BuildResult;
126
+ }
127
+
128
+ describe("computeBuildDigest — lexicon versions (#1442)", () => {
129
+ test("records the version of each lexicon that served the build", () => {
130
+ expect(computeBuildDigest(withVersions({ k8s: "0.38.0" })).lexiconVersions).toEqual({ k8s: "0.38.0" });
131
+ });
132
+
133
+ test("records once per lexicon, not once per resource", () => {
134
+ const many = makeBuildResult({
135
+ k8s: [
136
+ { name: "a", type: "K8s::Apps::Deployment", props: {} },
137
+ { name: "b", type: "K8s::Core::Service", props: {} },
138
+ ],
139
+ });
140
+ const digest = computeBuildDigest({ ...many, lexiconVersions: { k8s: "0.38.0" } } as unknown as BuildResult);
141
+ expect(Object.keys(digest.resources)).toHaveLength(2);
142
+ expect(digest.lexiconVersions).toEqual({ k8s: "0.38.0" });
143
+ });
144
+
145
+ test("a build with no plugins records an empty map, not absence", () => {
146
+ // Absent and empty mean different things when read back: absent is
147
+ // "recorded before #1442", empty is "recorded, nothing loaded".
148
+ expect(computeBuildDigest(withVersions(undefined)).lexiconVersions).toEqual({});
149
+ });
150
+
151
+ test("the recorded map is a copy, so later mutation cannot rewrite history", () => {
152
+ const versions = { k8s: "0.38.0" };
153
+ const digest = computeBuildDigest(withVersions(versions));
154
+ versions.k8s = "0.39.0";
155
+ expect(digest.lexiconVersions).toEqual({ k8s: "0.38.0" });
156
+ });
157
+ });
158
+
159
+ describe("diffDigests — lexicon version changes (#1442)", () => {
160
+ const resources = { app: { type: "K8s::Apps::Deployment", lexicon: "k8s", propsHash: "same" } };
161
+ const digest = (lexiconVersions?: Record<string, string>): BuildDigest =>
162
+ ({ resources, dependencies: {}, outputs: {}, deployOrder: ["k8s"], lexiconVersions }) as BuildDigest;
163
+
164
+ test("reports a bump even when every resource is unchanged", () => {
165
+ const diff = diffDigests(digest({ k8s: "0.39.0" }), digest({ k8s: "0.38.0" }));
166
+ expect(diff.changed).toEqual([]);
167
+ expect(diff.unchanged).toEqual(["app"]);
168
+ expect(diff.lexiconVersionChanges).toEqual([{ lexicon: "k8s", previous: "0.38.0", current: "0.39.0" }]);
169
+ });
170
+
171
+ test("reports nothing when versions match", () => {
172
+ expect(diffDigests(digest({ k8s: "0.38.0" }), digest({ k8s: "0.38.0" })).lexiconVersionChanges).toEqual([]);
173
+ });
174
+
175
+ test("reports a lexicon added to or dropped from the build", () => {
176
+ const added = diffDigests(digest({ k8s: "0.38.0", aws: "0.38.0" }), digest({ k8s: "0.38.0" }));
177
+ expect(added.lexiconVersionChanges).toEqual([{ lexicon: "aws", previous: undefined, current: "0.38.0" }]);
178
+
179
+ const dropped = diffDigests(digest({ k8s: "0.38.0" }), digest({ k8s: "0.38.0", aws: "0.38.0" }));
180
+ expect(dropped.lexiconVersionChanges).toEqual([{ lexicon: "aws", previous: "0.38.0", current: undefined }]);
181
+ });
182
+
183
+ test("a pre-#1442 snapshot reports no change rather than inventing one", () => {
184
+ // The older digest never recorded versions. Every lexicon would otherwise
185
+ // look newly-added on the first comparison after upgrading chant.
186
+ expect(diffDigests(digest({ k8s: "0.38.0" }), digest(undefined)).lexiconVersionChanges).toEqual([]);
187
+ expect(diffDigests(digest(undefined), digest({ k8s: "0.38.0" })).lexiconVersionChanges).toEqual([]);
188
+ });
189
+
190
+ test("with no previous digest at all, there is no version change", () => {
191
+ expect(diffDigests(digest({ k8s: "0.38.0" }), undefined).lexiconVersionChanges).toEqual([]);
192
+ });
193
+
194
+ test("changes are ordered by lexicon name, so output is stable", () => {
195
+ const diff = diffDigests(digest({ k8s: "2", aws: "2", gcp: "2" }), digest({ k8s: "1", aws: "1", gcp: "1" }));
196
+ expect(diff.lexiconVersionChanges.map((c) => c.lexicon)).toEqual(["aws", "gcp", "k8s"]);
197
+ });
198
+ });