@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.
- package/dist/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +30 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +20 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/composite.d.ts +23 -4
- package/dist/composite.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +24 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon-config.d.ts +61 -0
- package/dist/lexicon-config.d.ts.map +1 -0
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +11 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/identity.d.ts +52 -0
- package/dist/lifecycle/identity.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +5 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +30 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +11 -0
- package/dist/managed-fields.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon.ts +20 -1
- package/src/cli/handlers/lifecycle.ts +10 -0
- package/src/cli/handlers/run-client.ts +3 -1
- package/src/cli/handlers/search-kind.test.ts +45 -0
- package/src/cli/handlers/search.ts +102 -4
- package/src/cli/main.ts +32 -10
- package/src/cli/param-flag-scope.test.ts +69 -0
- package/src/cli/plugins.test.ts +33 -1
- package/src/cli/plugins.ts +55 -0
- package/src/codegen/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/sandbox/driver.test.ts +27 -0
- package/src/components/sandbox/driver.ts +12 -0
- package/src/composite.ts +33 -4
- package/src/deep-observation.ts +11 -0
- package/src/discovery/sandbox/driver.test.ts +34 -0
- package/src/discovery/sandbox/driver.ts +19 -0
- package/src/graph-declared.test.ts +86 -0
- package/src/graph-declared.ts +14 -2
- package/src/graph-ir.ts +32 -8
- package/src/graph-refs.test.ts +56 -0
- package/src/graph-refs.ts +37 -1
- package/src/kubectl-context.ts +4 -1
- package/src/lexicon-config.test.ts +111 -0
- package/src/lexicon-config.ts +92 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/deep-diff.test.ts +48 -1
- package/src/lifecycle/deep-diff.ts +16 -0
- package/src/lifecycle/digest.test.ts +81 -0
- package/src/lifecycle/digest.ts +34 -3
- package/src/lifecycle/identity.test.ts +39 -0
- package/src/lifecycle/identity.ts +61 -0
- package/src/lifecycle/observe.test.ts +75 -1
- package/src/lifecycle/observe.ts +28 -2
- package/src/lifecycle/replay.test.ts +251 -0
- package/src/lifecycle/replay.ts +67 -19
- package/src/lifecycle/types.ts +26 -0
- package/src/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
package/src/graph-declared.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
//
|
|
419
|
-
// stack
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
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
|
}
|
package/src/graph-refs.test.ts
CHANGED
|
@@ -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/kubectl-context.ts
CHANGED
|
@@ -126,7 +126,10 @@ export async function resolveClusterTarget(
|
|
|
126
126
|
lexiconName: string,
|
|
127
127
|
options: ResolveClusterTargetOptions = {},
|
|
128
128
|
): Promise<ResolvedClusterTarget> {
|
|
129
|
-
|
|
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
|
+
});
|