@intentius/chant-lexicon-k8s 0.44.13 → 0.45.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 (56) hide show
  1. package/dist/codegen/docs.d.ts.map +1 -1
  2. package/dist/codegen/generate-lexicon.d.ts +15 -1
  3. package/dist/codegen/generate-lexicon.d.ts.map +1 -1
  4. package/dist/components/capability-plugin.d.ts.map +1 -1
  5. package/dist/crd/parser.d.ts +28 -1
  6. package/dist/crd/parser.d.ts.map +1 -1
  7. package/dist/deep-observe-hooks.d.ts +60 -4
  8. package/dist/deep-observe-hooks.d.ts.map +1 -1
  9. package/dist/deep-observe.d.ts +36 -61
  10. package/dist/deep-observe.d.ts.map +1 -1
  11. package/dist/integrity.json +6 -3
  12. package/dist/lint/audit-catalog.d.ts.map +1 -1
  13. package/dist/lint/post-synth/crd-schema-helpers.d.ts +49 -0
  14. package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -0
  15. package/dist/lint/post-synth/index.d.ts.map +1 -1
  16. package/dist/lint/post-synth/wk8501.d.ts +14 -0
  17. package/dist/lint/post-synth/wk8501.d.ts.map +1 -0
  18. package/dist/lint/post-synth/wk8502.d.ts +13 -0
  19. package/dist/lint/post-synth/wk8502.d.ts.map +1 -0
  20. package/dist/manifest.json +1 -1
  21. package/dist/meta.json +126 -63
  22. package/dist/okf/index.md +2 -0
  23. package/dist/okf/rules/WK8501.md +11 -0
  24. package/dist/okf/rules/WK8502.md +11 -0
  25. package/dist/rules/crd-schema-helpers.ts +228 -0
  26. package/dist/rules/wk8501.ts +40 -0
  27. package/dist/rules/wk8502.ts +39 -0
  28. package/dist/spec/parse.d.ts +32 -0
  29. package/dist/spec/parse.d.ts.map +1 -1
  30. package/dist/validate.d.ts.map +1 -1
  31. package/package.json +3 -3
  32. package/src/codegen/docs.ts +0 -1044
  33. package/src/codegen/generate-lexicon.ts +38 -2
  34. package/src/codegen/snapshot.test.ts +20 -0
  35. package/src/components/capability-plugin.test.ts +20 -0
  36. package/src/components/capability-plugin.ts +5 -2
  37. package/src/crd/cnpg.test.ts +1 -1
  38. package/src/crd/infisical.test.ts +1 -1
  39. package/src/crd/parser.test.ts +117 -1
  40. package/src/crd/parser.ts +71 -5
  41. package/src/crd/traefik.test.ts +1 -1
  42. package/src/deep-observe-hooks.ts +78 -16
  43. package/src/deep-observe.test.ts +145 -29
  44. package/src/deep-observe.ts +43 -85
  45. package/src/generated/lexicon-k8s.json +126 -63
  46. package/src/lint/audit-catalog.ts +2 -0
  47. package/src/lint/post-synth/crd-schema-helpers.ts +228 -0
  48. package/src/lint/post-synth/index.ts +4 -0
  49. package/src/lint/post-synth/post-synth.test.ts +156 -0
  50. package/src/lint/post-synth/wk8501.ts +40 -0
  51. package/src/lint/post-synth/wk8502.ts +39 -0
  52. package/src/list-map-key-table.test.ts +83 -0
  53. package/src/op/activities/kubectl.ts +2 -2
  54. package/src/serializer.test.ts +4 -4
  55. package/src/spec/parse.ts +33 -0
  56. package/src/validate.ts +7 -4
@@ -36,4 +36,6 @@ export const k8sAuditCatalog: Record<string, RuleMeta> = {
36
36
  WK8401: auditRule("WK8401", "merge-worthy", "guidance", "shmSize exceeds the container memory limit", "Lower shmSize or raise the memory limit so the pod can schedule.", { category: "best-practice" }),
37
37
  WK8402: auditRule("WK8402", "report-only", "guidance", "RayCluster missing spec.rayVersion", "Set spec.rayVersion so KubeRay picks the right autoscaler image.", { category: "best-practice" }),
38
38
  WK8403: auditRule("WK8403", "report-only", "guidance", "rayVersion does not match the head image tag", "Align spec.rayVersion with the Ray version in the head container image.", { category: "best-practice" }),
39
+ WK8501: auditRule("WK8501", "merge-worthy", "guidance", "Custom resource spec has a field its CRD does not declare", "Fix the field name; the API server prunes unknown fields and the controller never sees them.", { category: "correctness" }),
40
+ WK8502: auditRule("WK8502", "merge-worthy", "guidance", "Custom resource spec field has the wrong type or enum value", "Match the CRD schema: use the declared scalar type and one of the enum values.", { category: "correctness" }),
39
41
  };
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Shared helpers for the custom-resource spec checks (WK8501, WK8502) — chant #1372.
3
+ *
4
+ * A CRD's constructor takes `spec: Record<string, unknown>`, so a misspelled
5
+ * or wrong-typed field type-checks, serializes, and is accepted by the API
6
+ * server (which prunes what the structural schema does not know) — the
7
+ * controller then runs with a default nobody chose. The generated lexicon JSON
8
+ * carries each CRD's `spec` field schema (`specSchema`); these helpers walk a
9
+ * synthesized manifest against it and report what the schema would reject.
10
+ *
11
+ * Excluded from check auto-discovery by the "helper" filename filter.
12
+ */
13
+
14
+ import { createRequire } from "module";
15
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
16
+ import type { CrdFieldSchema } from "../../spec/parse";
17
+ import { getPrimaryOutput, parseK8sManifests, type K8sManifest } from "./k8s-helpers";
18
+
19
+ export type { CrdFieldSchema };
20
+
21
+ interface LexiconEntry {
22
+ kind: "resource" | "property";
23
+ apiVersion?: string;
24
+ gvkKind?: string;
25
+ specSchema?: CrdFieldSchema;
26
+ }
27
+
28
+ let cachedRegistry: Map<string, CrdFieldSchema> | null = null;
29
+
30
+ /** Registry key: `<apiVersion>/<kind>`, the pair a manifest carries verbatim. */
31
+ function registryKey(apiVersion: string, kind: string): string {
32
+ return `${apiVersion}/${kind}`;
33
+ }
34
+
35
+ /**
36
+ * Spec schemas of every CRD the lexicon ships, keyed by `apiVersion/kind`.
37
+ * Built-in kinds carry no `specSchema` and are never in the map.
38
+ */
39
+ export function getCrdSchemaRegistry(): Map<string, CrdFieldSchema> {
40
+ if (cachedRegistry) return cachedRegistry;
41
+ cachedRegistry = new Map();
42
+ try {
43
+ // Built lazily so importing this module stays edge-safe: `createRequire`
44
+ // throws where import.meta.url is undefined (bundled Workers), and there
45
+ // the registry is simply empty.
46
+ const require = createRequire(import.meta.url);
47
+ const lexicon = require("../../generated/lexicon-k8s.json") as Record<string, LexiconEntry>;
48
+ for (const entry of Object.values(lexicon)) {
49
+ if (entry.kind === "resource" && entry.apiVersion && entry.gvkKind && entry.specSchema) {
50
+ cachedRegistry.set(registryKey(entry.apiVersion, entry.gvkKind), entry.specSchema);
51
+ }
52
+ }
53
+ } catch {
54
+ // Lexicon JSON not yet generated — empty registry, checks pass.
55
+ }
56
+ return cachedRegistry;
57
+ }
58
+
59
+ /** Test seam: replace the registry (pass `null` to reload from the lexicon JSON). */
60
+ export function setCrdSchemaRegistry(registry: Map<string, CrdFieldSchema> | null): void {
61
+ cachedRegistry = registry;
62
+ }
63
+
64
+ /** The schema for a manifest's `apiVersion`/`kind`, if the lexicon ships one. */
65
+ export function specSchemaFor(manifest: K8sManifest): CrdFieldSchema | undefined {
66
+ if (typeof manifest.apiVersion !== "string" || typeof manifest.kind !== "string") return undefined;
67
+ return getCrdSchemaRegistry().get(registryKey(manifest.apiVersion, manifest.kind));
68
+ }
69
+
70
+ /** Every manifest in the build that has a shipped spec schema, with that schema. */
71
+ export function customResources(ctx: PostSynthContext): Array<{ manifest: K8sManifest; schema: CrdFieldSchema }> {
72
+ const out: Array<{ manifest: K8sManifest; schema: CrdFieldSchema }> = [];
73
+ for (const [, output] of ctx.outputs) {
74
+ for (const manifest of parseK8sManifests(getPrimaryOutput(output))) {
75
+ const schema = specSchemaFor(manifest);
76
+ if (schema) out.push({ manifest, schema });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ export interface SpecFinding {
83
+ kind: "unknown-field" | "type-mismatch";
84
+ /** Dotted path under `spec`, e.g. `spec.source.s3bucket`. Array elements use `[i]`. */
85
+ path: string;
86
+ message: string;
87
+ }
88
+
89
+ /**
90
+ * Walk `value` against `schema` and report every field the schema does not
91
+ * list (unless the enclosing object is `open`) and every scalar whose type or
92
+ * enum membership the schema rejects. Untyped nodes pass anything.
93
+ */
94
+ export function validateSpec(value: unknown, schema: CrdFieldSchema, path = "spec"): SpecFinding[] {
95
+ const findings: SpecFinding[] = [];
96
+ walk(value, schema, path, findings);
97
+ return findings;
98
+ }
99
+
100
+ function walk(value: unknown, schema: CrdFieldSchema, path: string, out: SpecFinding[]): void {
101
+ if (value === null || value === undefined) return;
102
+
103
+ // int-or-string and other deliberately untyped nodes accept anything.
104
+ if (!schema.type) return;
105
+
106
+ switch (schema.type) {
107
+ case "object": {
108
+ if (typeof value !== "object" || Array.isArray(value)) {
109
+ out.push(mismatch(path, "an object", value));
110
+ return;
111
+ }
112
+ const obj = value as Record<string, unknown>;
113
+ for (const [name, child] of Object.entries(obj)) {
114
+ const childSchema = schema.fields?.[name];
115
+ if (childSchema) {
116
+ walk(child, childSchema, `${path}.${name}`, out);
117
+ continue;
118
+ }
119
+ if (schema.open) continue;
120
+ const known = Object.keys(schema.fields ?? {});
121
+ const suggestion = suggestField(name, known);
122
+ const hint = suggestion ? ` (did you mean "${suggestion}"?)` : "";
123
+ out.push({
124
+ kind: "unknown-field",
125
+ path: `${path}.${name}`,
126
+ message: `unknown field "${path}.${name}"${hint}`,
127
+ });
128
+ }
129
+ return;
130
+ }
131
+ case "array": {
132
+ if (!Array.isArray(value)) {
133
+ out.push(mismatch(path, "an array", value));
134
+ return;
135
+ }
136
+ if (schema.items) {
137
+ value.forEach((item, i) => walk(item, schema.items!, `${path}[${i}]`, out));
138
+ }
139
+ return;
140
+ }
141
+ case "string": {
142
+ if (typeof value !== "string") {
143
+ // `x-kubernetes-int-or-string` never reaches here (untyped); a plain
144
+ // string field given a number is a real mismatch the server coerces
145
+ // or rejects depending on version, so say so.
146
+ out.push(mismatch(path, "a string", value));
147
+ return;
148
+ }
149
+ checkEnum(value, schema, path, out);
150
+ return;
151
+ }
152
+ case "integer":
153
+ case "number": {
154
+ if (typeof value !== "number" || (schema.type === "integer" && !Number.isInteger(value))) {
155
+ out.push(mismatch(path, schema.type === "integer" ? "an integer" : "a number", value));
156
+ return;
157
+ }
158
+ checkEnum(value, schema, path, out);
159
+ return;
160
+ }
161
+ case "boolean": {
162
+ if (typeof value !== "boolean") out.push(mismatch(path, "a boolean", value));
163
+ return;
164
+ }
165
+ }
166
+ }
167
+
168
+ function checkEnum(value: string | number, schema: CrdFieldSchema, path: string, out: SpecFinding[]): void {
169
+ if (!schema.enum || schema.enum.length === 0) return;
170
+ if (schema.enum.includes(String(value))) return;
171
+ out.push({
172
+ kind: "type-mismatch",
173
+ path,
174
+ message: `"${path}" must be one of ${schema.enum.map((v) => `"${v}"`).join(", ")}, got ${describe(value)}`,
175
+ });
176
+ }
177
+
178
+ function mismatch(path: string, expected: string, value: unknown): SpecFinding {
179
+ return {
180
+ kind: "type-mismatch",
181
+ path,
182
+ message: `"${path}" expects ${expected}, got ${describe(value)}`,
183
+ };
184
+ }
185
+
186
+ function describe(value: unknown): string {
187
+ if (typeof value === "string") return `string "${value}"`;
188
+ if (typeof value === "number" || typeof value === "boolean") return `${typeof value} ${String(value)}`;
189
+ if (Array.isArray(value)) return "an array";
190
+ if (value !== null && typeof value === "object") return "an object";
191
+ return String(value);
192
+ }
193
+
194
+ /** Levenshtein distance between two strings. */
195
+ export function levenshtein(a: string, b: string): number {
196
+ const m = a.length;
197
+ const n = b.length;
198
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array<number>(n + 1).fill(0));
199
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
200
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
201
+ for (let i = 1; i <= m; i++) {
202
+ for (let j = 1; j <= n; j++) {
203
+ dp[i][j] = a[i - 1] === b[j - 1]
204
+ ? dp[i - 1][j - 1]
205
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
206
+ }
207
+ }
208
+ return dp[m][n];
209
+ }
210
+
211
+ /** Closest known field within Levenshtein distance 3 (case-insensitive), if any. */
212
+ export function suggestField(unknown: string, known: string[]): string | undefined {
213
+ let best: string | undefined;
214
+ let bestDist = 4;
215
+ for (const field of known) {
216
+ const dist = levenshtein(unknown.toLowerCase(), field.toLowerCase());
217
+ if (dist < bestDist) {
218
+ bestDist = dist;
219
+ best = field;
220
+ }
221
+ }
222
+ return best;
223
+ }
224
+
225
+ /** `metadata.name` or the kind, for diagnostics. */
226
+ export function resourceLabel(manifest: K8sManifest): string {
227
+ return manifest.metadata?.name ?? manifest.kind ?? "resource";
228
+ }
@@ -31,6 +31,8 @@ import { wk8306 } from "./wk8306";
31
31
  import { wk8401 } from "./wk8401";
32
32
  import { wk8402 } from "./wk8402";
33
33
  import { wk8403 } from "./wk8403";
34
+ import { wk8501 } from "./wk8501";
35
+ import { wk8502 } from "./wk8502";
34
36
 
35
37
  export const postSynthChecks: PostSynthCheck[] = [
36
38
  argo002,
@@ -64,4 +66,6 @@ export const postSynthChecks: PostSynthCheck[] = [
64
66
  wk8401,
65
67
  wk8402,
66
68
  wk8403,
69
+ wk8501,
70
+ wk8502,
67
71
  ];
@@ -33,6 +33,9 @@ import { argo003 } from "./argo003";
33
33
  import { argo005 } from "./argo005";
34
34
  import { flux002 } from "./flux002";
35
35
  import { flux003 } from "./flux003";
36
+ import { wk8501 } from "./wk8501";
37
+ import { wk8502 } from "./wk8502";
38
+ import { getCrdSchemaRegistry, setCrdSchemaRegistry, validateSpec } from "./crd-schema-helpers";
36
39
 
37
40
  function makeCtx(yaml: string): PostSynthContext {
38
41
  return {
@@ -1791,3 +1794,156 @@ describe("FLUX003: Kustomization dependsOn names declared Kustomizations", () =>
1791
1794
  expect(flux003.check(ctx).length).toBe(0);
1792
1795
  });
1793
1796
  });
1797
+
1798
+ // ── WK8501 / WK8502: custom-resource spec against the CRD schema (chant #1372) ──
1799
+
1800
+ describe("WK8501/WK8502: custom resource spec validated against the shipped CRD schema", () => {
1801
+ const hasLexicon = getCrdSchemaRegistry().size > 0;
1802
+
1803
+ function microVm(spec: Record<string, unknown>) {
1804
+ return {
1805
+ apiVersion: "lambda.aws.amazon.com/v1alpha1",
1806
+ kind: "MicroVM",
1807
+ metadata: { name: "agent-1", namespace: "vms" },
1808
+ spec,
1809
+ };
1810
+ }
1811
+
1812
+ test("metadata", () => {
1813
+ expect(wk8501.id).toBe("WK8501");
1814
+ expect(wk8502.id).toBe("WK8502");
1815
+ });
1816
+
1817
+ test.skipIf(!hasLexicon)("the lexicon ships a spec schema for every CRD-derived kind", () => {
1818
+ const registry = getCrdSchemaRegistry();
1819
+ for (const key of ["lambda.aws.amazon.com/v1alpha1/MicroVM", "cert-manager.io/v1/Certificate", "ray.io/v1/RayCluster"]) {
1820
+ expect(registry.get(key)?.type, key).toBe("object");
1821
+ }
1822
+ // Built-in kinds are typed by the .d.ts and carry no schema.
1823
+ expect(registry.has("apps/v1/Deployment")).toBe(false);
1824
+ });
1825
+
1826
+ test.skipIf(!hasLexicon)("WK8501 flags a misspelled MicroVM field and suggests the real one", () => {
1827
+ // The kubemicrovm-ops case from #1372: `classname` type-checks, applies
1828
+ // cleanly, and the controller runs the VM with the default class.
1829
+ const ctx = manifestsCtx(microVm({ classname: "large", imageRef: "img" }));
1830
+ const diags = wk8501.check(ctx);
1831
+ expect(diags.length).toBe(1);
1832
+ expect(diags[0].severity).toBe("error");
1833
+ expect(diags[0].entity).toBe("agent-1");
1834
+ expect(diags[0].message).toContain('unknown field "spec.classname"');
1835
+ expect(diags[0].message).toContain('did you mean "className"');
1836
+ // WK8502 has nothing to say about a field the schema does not know.
1837
+ expect(wk8502.check(ctx).length).toBe(0);
1838
+ });
1839
+
1840
+ test.skipIf(!hasLexicon)("WK8502 flags a wrong-typed scalar and a value outside its enum", () => {
1841
+ const ctx = manifestsCtx(microVm({
1842
+ className: "large",
1843
+ maxIdleDurationSeconds: "300",
1844
+ desiredState: "Runing",
1845
+ autoResumeEnabled: "yes",
1846
+ }));
1847
+ const diags = wk8502.check(ctx);
1848
+ const messages = diags.map((d) => d.message);
1849
+ expect(messages).toEqual([
1850
+ expect.stringContaining('"spec.maxIdleDurationSeconds" expects an integer, got string "300"'),
1851
+ expect.stringContaining('"spec.desiredState" must be one of "Running", "Suspended", "Terminated", got string "Runing"'),
1852
+ expect.stringContaining('"spec.autoResumeEnabled" expects a boolean, got string "yes"'),
1853
+ ]);
1854
+ expect(diags.every((d) => d.checkId === "WK8502" && d.severity === "error")).toBe(true);
1855
+ expect(wk8501.check(ctx).length).toBe(0);
1856
+ });
1857
+
1858
+ test.skipIf(!hasLexicon)("a well-formed custom resource passes both checks", () => {
1859
+ const ctx = manifestsCtx(
1860
+ microVm({ className: "large", desiredState: "Running", maxIdleDurationSeconds: 300, tags: { team: "ml", any: 1 } }),
1861
+ {
1862
+ apiVersion: "cert-manager.io/v1",
1863
+ kind: "Certificate",
1864
+ metadata: { name: "web-tls" },
1865
+ spec: {
1866
+ secretName: "web-tls",
1867
+ dnsNames: ["example.com"],
1868
+ issuerRef: { name: "letsencrypt", kind: "ClusterIssuer" },
1869
+ privateKey: { algorithm: "ECDSA", size: 256 },
1870
+ },
1871
+ },
1872
+ );
1873
+ expect(wk8501.check(ctx)).toEqual([]);
1874
+ expect(wk8502.check(ctx)).toEqual([]);
1875
+ });
1876
+
1877
+ test.skipIf(!hasLexicon)("walks nested objects and array elements", () => {
1878
+ const ctx = manifestsCtx({
1879
+ apiVersion: "cert-manager.io/v1",
1880
+ kind: "Certificate",
1881
+ metadata: { name: "web-tls" },
1882
+ spec: {
1883
+ secretName: "web-tls",
1884
+ issuerRef: { nmae: "letsencrypt" },
1885
+ additionalOutputFormats: [{ type: "DER" }, { type: "PEM" }],
1886
+ privateKey: { algorithm: "DSA" },
1887
+ },
1888
+ });
1889
+ expect(wk8501.check(ctx).map((d) => d.message)).toEqual([
1890
+ expect.stringContaining('unknown field "spec.issuerRef.nmae" (did you mean "name"?)'),
1891
+ ]);
1892
+ expect(wk8502.check(ctx).map((d) => d.message)).toEqual([
1893
+ expect.stringContaining('"spec.additionalOutputFormats[1].type" must be one of "DER", "CombinedPEM"'),
1894
+ expect.stringContaining('"spec.privateKey.algorithm" must be one of "RSA", "ECDSA", "Ed25519"'),
1895
+ ]);
1896
+ });
1897
+
1898
+ test("built-in kinds and unknown apiVersion/kind pairs are never checked", () => {
1899
+ const ctx = manifestsCtx(
1900
+ { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "web" }, spec: { replicas: "2", bogus: true } },
1901
+ { apiVersion: "example.com/v1", kind: "Widget", metadata: { name: "w" }, spec: { bogus: true } },
1902
+ );
1903
+ expect(wk8501.check(ctx)).toEqual([]);
1904
+ expect(wk8502.check(ctx)).toEqual([]);
1905
+ });
1906
+
1907
+ test("open objects and int-or-string accept anything (stub registry)", () => {
1908
+ setCrdSchemaRegistry(new Map([[
1909
+ "example.com/v1/Widget",
1910
+ {
1911
+ type: "object",
1912
+ fields: {
1913
+ labels: { type: "object", open: true },
1914
+ port: { open: true },
1915
+ replicas: { type: "integer" },
1916
+ items: { type: "array", items: { type: "string" } },
1917
+ },
1918
+ },
1919
+ ]]));
1920
+ try {
1921
+ const ok = manifestsCtx({
1922
+ apiVersion: "example.com/v1", kind: "Widget", metadata: { name: "w" },
1923
+ spec: { labels: { anything: { nested: true } }, port: "http", replicas: 2, items: ["a"] },
1924
+ });
1925
+ expect(wk8501.check(ok)).toEqual([]);
1926
+ expect(wk8502.check(ok)).toEqual([]);
1927
+
1928
+ const bad = manifestsCtx({
1929
+ apiVersion: "example.com/v1", kind: "Widget", metadata: { name: "w" },
1930
+ spec: { replicas: 2.5, items: "a" },
1931
+ });
1932
+ expect(wk8502.check(bad).map((d) => d.message)).toEqual([
1933
+ expect.stringContaining('"spec.replicas" expects an integer, got number 2.5'),
1934
+ expect.stringContaining('"spec.items" expects an array, got string "a"'),
1935
+ ]);
1936
+ } finally {
1937
+ setCrdSchemaRegistry(null);
1938
+ }
1939
+ });
1940
+
1941
+ test("validateSpec reports unknown fields and type mismatches with dotted paths", () => {
1942
+ const schema = { type: "object" as const, fields: { a: { type: "object" as const, fields: { b: { type: "boolean" as const } } } } };
1943
+ expect(validateSpec({ a: { b: "x", c: 1 }, d: 2 }, schema)).toEqual([
1944
+ { kind: "type-mismatch", path: "spec.a.b", message: expect.stringContaining("expects a boolean") },
1945
+ { kind: "unknown-field", path: "spec.a.c", message: expect.stringContaining('unknown field "spec.a.c"') },
1946
+ { kind: "unknown-field", path: "spec.d", message: expect.stringContaining('unknown field "spec.d"') },
1947
+ ]);
1948
+ });
1949
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * WK8501: Custom resource spec has a field its CRD schema does not declare
3
+ *
4
+ * A generated CRD class takes `spec: Record<string, unknown>`, so a misspelled
5
+ * field (`classname` for `className`) type-checks and serializes. The API
6
+ * server accepts the object and prunes the unknown field, the controller
7
+ * never sees it, and the resource runs with a default nobody chose. The
8
+ * lexicon ships each CRD's `spec` schema (chant #1372); this check flags any
9
+ * field that schema does not list, unless the enclosing object declares
10
+ * `x-kubernetes-preserve-unknown-fields` or `additionalProperties`.
11
+ */
12
+
13
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
14
+ import { customResources, resourceLabel, validateSpec } from "./crd-schema-helpers";
15
+
16
+ export const wk8501: PostSynthCheck = {
17
+ id: "WK8501",
18
+ description: "Custom resource spec contains a field its CRD schema does not declare",
19
+
20
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
21
+ const diagnostics: PostSynthDiagnostic[] = [];
22
+
23
+ for (const { manifest, schema } of customResources(ctx)) {
24
+ if (manifest.spec === undefined) continue;
25
+ const name = resourceLabel(manifest);
26
+ for (const finding of validateSpec(manifest.spec, schema)) {
27
+ if (finding.kind !== "unknown-field") continue;
28
+ diagnostics.push({
29
+ checkId: "WK8501",
30
+ severity: "error",
31
+ message: `${manifest.kind} "${name}": ${finding.message}. The API server prunes it and the controller never sees it.`,
32
+ entity: name,
33
+ lexicon: "k8s",
34
+ });
35
+ }
36
+ }
37
+
38
+ return diagnostics;
39
+ },
40
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * WK8502: Custom resource spec field has the wrong type or an invalid enum value
3
+ *
4
+ * With `spec: Record<string, unknown>` on every generated CRD class, nothing
5
+ * at compile time stops `replicas: "2"` or `desiredState: "Runing"`. The API
6
+ * server rejects the first at apply and the controller ignores the second.
7
+ * The lexicon ships each CRD's `spec` schema (chant #1372); this check
8
+ * compares every scalar against its declared type and enum before apply.
9
+ * `x-kubernetes-int-or-string` and untyped nodes accept anything.
10
+ */
11
+
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
+ import { customResources, resourceLabel, validateSpec } from "./crd-schema-helpers";
14
+
15
+ export const wk8502: PostSynthCheck = {
16
+ id: "WK8502",
17
+ description: "Custom resource spec field has the wrong type or a value outside its enum",
18
+
19
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
20
+ const diagnostics: PostSynthDiagnostic[] = [];
21
+
22
+ for (const { manifest, schema } of customResources(ctx)) {
23
+ if (manifest.spec === undefined) continue;
24
+ const name = resourceLabel(manifest);
25
+ for (const finding of validateSpec(manifest.spec, schema)) {
26
+ if (finding.kind !== "type-mismatch") continue;
27
+ diagnostics.push({
28
+ checkId: "WK8502",
29
+ severity: "error",
30
+ message: `${manifest.kind} "${name}": ${finding.message}`,
31
+ entity: name,
32
+ lexicon: "k8s",
33
+ });
34
+ }
35
+ }
36
+
37
+ return diagnostics;
38
+ },
39
+ };
@@ -0,0 +1,83 @@
1
+ import { describe, test, expect, afterEach, vi } from "vitest";
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import {
6
+ listMapKeyTablePath,
7
+ loadListMapKeyTable,
8
+ resetListMapKeyTableCache,
9
+ schemaListMapOrderKey,
10
+ } from "./deep-observe-hooks";
11
+ import type { DeepArrayElement } from "@intentius/chant/lexicon";
12
+
13
+ /**
14
+ * chant #1476 — the generated table is data, not a hard dependency. Present,
15
+ * it drives list identity; absent, `schemaListMapOrderKey` degrades to the
16
+ * hand-written conventions with one warning, and never throws.
17
+ */
18
+
19
+ function el(pattern: string, element: unknown): DeepArrayElement {
20
+ return { entityType: "K8s::Apps::Deployment", pattern, path: pattern, element, index: 0, side: "live" };
21
+ }
22
+
23
+ describe("list-map-keys table loading (#1476)", () => {
24
+ afterEach(() => {
25
+ resetListMapKeyTableCache();
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ test("the generated table exists where the loader looks", () => {
30
+ expect(listMapKeyTablePath()).toMatch(/generated[\\/]list-map-keys\.json$/);
31
+ expect(existsSync(listMapKeyTablePath())).toBe(true);
32
+ });
33
+
34
+ test("table present: spec-declared keys identify lists the conventions miss", () => {
35
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
36
+ const table = loadListMapKeyTable();
37
+ expect(table.conditions).toBeDefined();
38
+ expect(warn).not.toHaveBeenCalled();
39
+
40
+ resetListMapKeyTableCache();
41
+ expect(schemaListMapOrderKey(el("status.conditions", { type: "Ready", status: "True" }))).toBe("Ready");
42
+ expect(warn).not.toHaveBeenCalled();
43
+ });
44
+
45
+ test("table absent: falls back to the conventions, warns once, never throws", () => {
46
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
47
+ const missing = join(tmpdir(), "chant-1476-does-not-exist", "list-map-keys.json");
48
+
49
+ const table = loadListMapKeyTable(missing);
50
+ expect(table).toEqual({});
51
+ expect(warn).toHaveBeenCalledTimes(1);
52
+ const message = String(warn.mock.calls[0][0]);
53
+ expect(message).toContain("npm run generate");
54
+ expect(message).toContain(missing);
55
+
56
+ // Seed the cache with the empty table, as the first lookup would have.
57
+ resetListMapKeyTableCache(table);
58
+
59
+ // Hand-written conventions still apply ...
60
+ expect(schemaListMapOrderKey(el("spec.template.spec.containers", { name: "app" }))).toBe("app");
61
+ expect(schemaListMapOrderKey(el("spec.template.spec.containers[].env", { name: "FOO" }))).toBe("FOO");
62
+ // ... and lists only the spec knows about fall through to undefined, not a throw.
63
+ expect(() => schemaListMapOrderKey(el("status.conditions", { type: "Ready" }))).not.toThrow();
64
+ expect(schemaListMapOrderKey(el("status.conditions", { type: "Ready" }))).toBeUndefined();
65
+
66
+ // The warning was emitted by the load, not per element.
67
+ expect(warn).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ test("table unreadable: treated like absent", () => {
71
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
72
+ const dir = mkdtempSync(join(tmpdir(), "chant-1476-"));
73
+ try {
74
+ const corrupt = join(dir, "list-map-keys.json");
75
+ writeFileSync(corrupt, "{ not json");
76
+ expect(loadListMapKeyTable(corrupt)).toEqual({});
77
+ expect(warn).toHaveBeenCalledTimes(1);
78
+ expect(String(warn.mock.calls[0][0])).toContain("unreadable");
79
+ } finally {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ });
83
+ });
@@ -35,7 +35,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
35
35
  import { join } from "node:path";
36
36
  import { loadAll } from "js-yaml";
37
37
  import { safeHeartbeat } from "@intentius/chant/op";
38
- import { loadChantConfig, resolveOwnershipMarker } from "@intentius/chant/config";
38
+ import { loadChantConfig, resolveOwnershipStack } from "@intentius/chant/config";
39
39
  import {
40
40
  hasOwnershipMarker,
41
41
  LABEL_OWNERSHIP_KEYS,
@@ -185,7 +185,7 @@ async function resolveApplyIdentity(
185
185
  if (stack === undefined) {
186
186
  try {
187
187
  const { config } = await loadChantConfig(args.cwd ?? process.cwd());
188
- stack = resolveOwnershipMarker(config)?.stack;
188
+ stack = resolveOwnershipStack(config);
189
189
  } catch (err) {
190
190
  console.warn(
191
191
  `[k8s] could not read the project config to derive a field manager ` +
@@ -348,7 +348,7 @@ describe("k8sSerializer", () => {
348
348
  }),
349
349
  );
350
350
 
351
- const result = k8sSerializer.serialize(entities);
351
+ const result = k8sSerializer.serialize(entities) as string;
352
352
  expect(result).toContain("env: prod");
353
353
  // Should not contain "dev" since explicit overrides
354
354
  const envLines = result.split("\n").filter((l: string) => l.includes("env:"));
@@ -366,7 +366,7 @@ describe("k8sSerializer", () => {
366
366
  }),
367
367
  );
368
368
 
369
- const result = k8sSerializer.serialize(entities);
369
+ const result = k8sSerializer.serialize(entities) as string;
370
370
  expect(result).toContain("kind: Deployment");
371
371
  // Only one document — property entities should not appear as separate docs
372
372
  expect(result.split("---").length).toBeLessThanOrEqual(2);
@@ -406,7 +406,7 @@ describe("k8sSerializer", () => {
406
406
  }),
407
407
  );
408
408
 
409
- const result = k8sSerializer.serialize(entities);
409
+ const result = k8sSerializer.serialize(entities) as string;
410
410
  const docs = result.split("---");
411
411
  // First document should be the Namespace
412
412
  expect(docs[0]).toContain("kind: Namespace");
@@ -449,7 +449,7 @@ describe("k8sSerializer", () => {
449
449
  }),
450
450
  );
451
451
 
452
- const result = k8sSerializer.serialize(entities);
452
+ const result = k8sSerializer.serialize(entities) as string;
453
453
  const lines = result.split("\n");
454
454
  const keyLines = lines.filter((l: string) => /^\w+:/.test(l));
455
455
  const keys = keyLines.map((l: string) => l.split(":")[0]);