@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
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { PropertyConstraints } from "@intentius/chant/codegen/json-schema";
7
- import type { K8sParseResult } from "../spec/parse";
7
+ import type { K8sParseResult, CrdFieldSchema } from "../spec/parse";
8
8
  import { k8sShortName, gvkToApiVersion } from "../spec/parse";
9
9
  import type { NamingStrategy } from "./naming";
10
10
  import {
@@ -21,6 +21,12 @@ export interface K8sLexiconEntry {
21
21
  gvkKind?: string;
22
22
  deprecatedProperties?: string[];
23
23
  constraints?: Record<string, PropertyConstraints>;
24
+ /**
25
+ * Field schema of a custom resource's `spec` (chant #1372). Only CRD-derived
26
+ * kinds carry one; built-in kinds are typed by the `.d.ts`. Consumed by the
27
+ * WK8501/WK8502 post-synth checks and the MCP catalog.
28
+ */
29
+ specSchema?: CrdFieldSchema;
24
30
  }
25
31
 
26
32
  /**
@@ -56,6 +62,9 @@ export function generateLexiconJSON(
56
62
  if (propConstraints && Object.keys(propConstraints).length > 0) {
57
63
  entry.constraints = propConstraints;
58
64
  }
65
+ if (r?.specSchema) {
66
+ entry.specSchema = r.specSchema;
67
+ }
59
68
  return entry;
60
69
  },
61
70
  buildPropertyEntry: (resourceType, propertyType) => ({
@@ -65,5 +74,32 @@ export function generateLexiconJSON(
65
74
  }),
66
75
  });
67
76
 
68
- return serializeRegistry(entries);
77
+ return serializeWithCompactSchemas(entries);
78
+ }
79
+
80
+ /**
81
+ * Pretty-print the registry the way every lexicon does, but keep each
82
+ * `specSchema` on one line (chant #1372). A CRD schema nests deeply — an
83
+ * ApplicationSet or RayCluster embeds a whole PodTemplateSpec — and two-space
84
+ * indentation on that tree quadruples the file for no reader's benefit.
85
+ * Compact, the schemas add ~0.8 MB to the registry; pretty, ~3.9 MB.
86
+ */
87
+ export function serializeWithCompactSchemas(entries: Record<string, K8sLexiconEntry>): string {
88
+ const compact = new Map<string, string>();
89
+ const stripped: Record<string, unknown> = {};
90
+ for (const [name, entry] of Object.entries(entries)) {
91
+ if (!entry.specSchema) {
92
+ stripped[name] = entry;
93
+ continue;
94
+ }
95
+ const { specSchema, ...rest } = entry;
96
+ const token = `__chant_spec_schema_${compact.size}__`;
97
+ compact.set(token, JSON.stringify(specSchema));
98
+ stripped[name] = { ...rest, specSchema: token };
99
+ }
100
+ let out = serializeRegistry(stripped);
101
+ for (const [token, json] of compact) {
102
+ out = out.replace(`"${token}"`, json);
103
+ }
104
+ return out;
69
105
  }
@@ -92,4 +92,24 @@ describe("snapshot", () => {
92
92
  expect(dts).toContain("class Service");
93
93
  expect(dts).toContain("class Pod");
94
94
  });
95
+
96
+ // chant #1372 — CRD field schemas ship in the registry; built-ins do not carry one.
97
+ test.skipIf(!hasGenerated)("every CRD-derived resource entry carries a specSchema, built-ins none", () => {
98
+ const raw = readFileSync(join(generatedDir, "lexicon-k8s.json"), "utf-8");
99
+ const registry = JSON.parse(raw) as Record<string, any>;
100
+
101
+ const crd = registry.Certificate;
102
+ expect(crd.resourceType).toBe("K8s::CertManager::Certificate");
103
+ expect(crd.specSchema.type).toBe("object");
104
+ expect(crd.specSchema.fields.issuerRef.fields.name.type).toBe("string");
105
+ expect(crd.specSchema.required).toContain("secretName");
106
+
107
+ expect(registry.Deployment.specSchema).toBeUndefined();
108
+ expect(registry.Certificate_Status.specSchema).toBeUndefined();
109
+
110
+ // Each schema is written on one line so the file stays reviewable; the
111
+ // pretty-printed form is four times the size for no reader's benefit.
112
+ const line = raw.split("\n").find((l) => l.includes('"specSchema": {'));
113
+ expect(line).toMatch(/^\s+"specSchema": \{.*\}$/);
114
+ });
95
115
  });
@@ -0,0 +1,20 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { isCapabilityPlugin } from "@intentius/chant/components/capability-plugin";
4
+ import { k8sCapabilityPlugin, K8S_VERB_FAMILIES } from "./capability-plugin";
5
+
6
+ describe("k8sCapabilityPlugin", () => {
7
+ test("satisfies the CapabilityPlugin contract", () => {
8
+ expect(isCapabilityPlugin(k8sCapabilityPlugin)).toBe(true);
9
+ expect(k8sCapabilityPlugin.capabilities().map((c) => c.kind)).toContain("kubectl-apply");
10
+ expect(k8sCapabilityPlugin.families?.()).toBe(K8S_VERB_FAMILIES);
11
+ });
12
+
13
+ test("the plugin's version is the lexicon package's own, not a literal (#1505)", () => {
14
+ const { version } = JSON.parse(
15
+ readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
16
+ ) as { version: string };
17
+ expect(k8sCapabilityPlugin.version).toBe(version);
18
+ expect(k8sCapabilityPlugin.version).not.toBe("0.41.0");
19
+ });
20
+ });
@@ -25,8 +25,11 @@ export const K8S_VERB_FAMILIES = {
25
25
  export const k8sCapabilityPlugin: CapabilityPlugin = {
26
26
  name: "k8s",
27
27
  // The lexicon package's own version (#1505) — lockstep releases bump it, so
28
- // a literal here was stale one release after it was written.
29
- version: ownPackageVersion(import.meta.url),
28
+ // a literal here was stale one release after it was written. A getter, so
29
+ // the package.json read happens on first access rather than at import time.
30
+ get version(): string {
31
+ return ownPackageVersion(import.meta.url);
32
+ },
30
33
  capabilities(): Array<Capability<never, unknown>> {
31
34
  return [
32
35
  kubectlApplyCapability as Capability<never, unknown>,
@@ -15,7 +15,7 @@ import { parseYAML } from "@intentius/chant/yaml";
15
15
 
16
16
  /** Serialize one declaration the way `chant build` would, and read it back. */
17
17
  function synth(logicalName: string, resource: unknown): any {
18
- const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]]));
18
+ const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]])) as string;
19
19
  return parseYAML(yaml);
20
20
  }
21
21
 
@@ -13,7 +13,7 @@ import { k8sSerializer } from "../serializer";
13
13
  import { parseYAML } from "@intentius/chant/yaml";
14
14
 
15
15
  function synth(logicalName: string, resource: unknown): any {
16
- const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]]));
16
+ const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]])) as string;
17
17
  return parseYAML(yaml);
18
18
  }
19
19
 
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { parseCRD, parseCRDSpec } from "./parser";
2
+ import { parseCRD, parseCRDSpec, toFieldSchema } from "./parser";
3
3
 
4
4
  describe("parseCRD", () => {
5
5
  test("parses valid CRD YAML", () => {
@@ -453,3 +453,119 @@ describe("parseCRDSpec", () => {
453
453
  expect(results[0].propertyTypes.some((pt) => pt.name === "Widget_Status")).toBe(false);
454
454
  });
455
455
  });
456
+
457
+ // chant #1372 — the spec field schema that ships in the lexicon JSON.
458
+ describe("specSchema (chant #1372)", () => {
459
+ const microVmImage = `
460
+ apiVersion: apiextensions.k8s.io/v1
461
+ kind: CustomResourceDefinition
462
+ metadata:
463
+ name: microvmimages.lambda.aws.amazon.com
464
+ spec:
465
+ group: lambda.aws.amazon.com
466
+ names:
467
+ kind: MicroVMImage
468
+ plural: microvmimages
469
+ scope: Namespaced
470
+ versions:
471
+ - name: v1alpha1
472
+ served: true
473
+ storage: true
474
+ schema:
475
+ openAPIV3Schema:
476
+ type: object
477
+ properties:
478
+ spec:
479
+ type: object
480
+ required: [source]
481
+ properties:
482
+ desiredState:
483
+ type: string
484
+ enum: [Active, Inactive, Deleted]
485
+ description: Lifecycle state wanted for the image.
486
+ memorySizeMiB:
487
+ type: integer
488
+ minimum: 128
489
+ maximum: 10240
490
+ autoActivate:
491
+ type: boolean
492
+ source:
493
+ type: object
494
+ properties:
495
+ s3Bucket:
496
+ type: string
497
+ s3Key:
498
+ type: string
499
+ layers:
500
+ type: array
501
+ items:
502
+ type: object
503
+ properties:
504
+ arn:
505
+ type: string
506
+ annotations:
507
+ type: object
508
+ additionalProperties:
509
+ type: string
510
+ extra:
511
+ type: object
512
+ x-kubernetes-preserve-unknown-fields: true
513
+ port:
514
+ x-kubernetes-int-or-string: true
515
+ status:
516
+ type: object
517
+ properties:
518
+ phase:
519
+ type: string
520
+ `;
521
+
522
+ test("is the spec's field tree — names, scalar types, enums, required — with no prose or bounds", () => {
523
+ const [result] = parseCRD(microVmImage);
524
+ expect(result.specSchema).toEqual({
525
+ type: "object",
526
+ required: ["source"],
527
+ fields: {
528
+ desiredState: { type: "string", enum: ["Active", "Inactive", "Deleted"] },
529
+ memorySizeMiB: { type: "integer" },
530
+ autoActivate: { type: "boolean" },
531
+ source: { type: "object", fields: { s3Bucket: { type: "string" }, s3Key: { type: "string" } } },
532
+ layers: { type: "array", items: { type: "object", fields: { arn: { type: "string" } } } },
533
+ annotations: { type: "object", open: true },
534
+ extra: { type: "object", open: true },
535
+ port: { open: true },
536
+ },
537
+ });
538
+ // Status never rides along — it is server-owned, not something a spec validator checks.
539
+ expect(JSON.stringify(result.specSchema)).not.toContain("phase");
540
+ });
541
+
542
+ test("absent when the CRD declares no spec schema", () => {
543
+ const crd = `
544
+ apiVersion: apiextensions.k8s.io/v1
545
+ kind: CustomResourceDefinition
546
+ metadata:
547
+ name: bars.example.com
548
+ spec:
549
+ group: example.com
550
+ names:
551
+ kind: Bar
552
+ plural: bars
553
+ scope: Namespaced
554
+ versions:
555
+ - name: v1
556
+ served: true
557
+ storage: true
558
+ `;
559
+ const [result] = parseCRD(crd);
560
+ expect("specSchema" in result).toBe(false);
561
+ });
562
+
563
+ test("toFieldSchema infers object/array from structure when type is missing", () => {
564
+ expect(toFieldSchema({ properties: { a: { type: "string" } } })).toEqual({
565
+ type: "object",
566
+ fields: { a: { type: "string" } },
567
+ });
568
+ expect(toFieldSchema({ items: { type: "integer" } })).toEqual({ type: "array", items: { type: "integer" } });
569
+ expect(toFieldSchema({})).toEqual({});
570
+ });
571
+ });
package/src/crd/parser.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  * with the full codegen pipeline.
8
8
  */
9
9
 
10
- import type { K8sParseResult, ParsedProperty, ParsedPropertyType, GroupVersionKind } from "../spec/parse";
10
+ import type {
11
+ K8sParseResult,
12
+ ParsedProperty,
13
+ ParsedPropertyType,
14
+ GroupVersionKind,
15
+ CrdFieldSchema,
16
+ } from "../spec/parse";
11
17
  import type { CRDSpec } from "./types";
12
18
  import { namespaceSegmentForGroup } from "../group-namespace";
13
19
  import type { PropertyConstraints } from "@intentius/chant/codegen/json-schema";
@@ -66,6 +72,7 @@ export function parseCRDSpec(spec: CRDSpec): K8sParseResult[] {
66
72
  const properties = schema ? extractProperties(schema) : [];
67
73
  const propertyTypes = schema ? extractPropertyTypes(schema, typeName) : [];
68
74
  const status = schema ? extractStatusType(schema, typeName) : {};
75
+ const specSchema = schema?.properties?.spec ? toFieldSchema(schema.properties.spec) : undefined;
69
76
 
70
77
  const attributes = [
71
78
  { name: "name", tsType: "string" },
@@ -85,6 +92,10 @@ export function parseCRDSpec(spec: CRDSpec): K8sParseResult[] {
85
92
  propertyTypes: status.propertyType ? [...propertyTypes, status.propertyType] : propertyTypes,
86
93
  enums: [],
87
94
  gvk,
95
+ // chant #1372 — the spec's field schema, shipped in the lexicon JSON so
96
+ // the post-synth checks (WK8501/WK8502) can validate a custom resource
97
+ // the way the API server's structural schema would, before apply.
98
+ ...(specSchema ? { specSchema } : {}),
88
99
  // chant #1074 — the CRD declares its own plural and scope, so the
89
100
  // operation surface for a custom resource comes from the same document its
90
101
  // types do, exactly as the OpenAPI `paths` supply them for built-in kinds.
@@ -265,10 +276,12 @@ function extractStatusType(
265
276
  const statusSchema = schema.properties?.status;
266
277
  if (!statusSchema) return {};
267
278
 
268
- // The `.d.ts` accessor is deliberately opaque CRD typing lives in the
269
- // lexicon JSON (the same channel `spec` uses, which is also `Record<string,
270
- // unknown>` in the constructor). The rich, per-field status shape is carried
271
- // by the property type below, surfaced through LSP / validation / MCP.
279
+ // The `.d.ts` accessor is deliberately opaque, like the constructor's
280
+ // `spec: Record<string, unknown>`. The writable side is typed through the
281
+ // lexicon JSON instead (`specSchema`, chant #1372, checked by WK8501/WK8502).
282
+ // Status is server-owned and never validated; the property type below only
283
+ // registers its name so the shape is discoverable (chant #1372 reviewed the
284
+ // claim that it fed LSP / validation — it did not, and does not need to).
272
285
  const attribute = { name: "status", tsType: "Record<string, unknown>" };
273
286
 
274
287
  // Opaque status (preserve-unknown or no schema) → read-only record only.
@@ -293,6 +306,59 @@ function extractStatusType(
293
306
  return { attribute, propertyType };
294
307
  }
295
308
 
309
+ // ── Spec field schema (chant #1372) ────────────────────────────────
310
+
311
+ /**
312
+ * Reduce an `openAPIV3Schema` node to the compact {@link CrdFieldSchema} the
313
+ * lexicon JSON ships. Walks the whole tree; drops descriptions, formats,
314
+ * defaults and numeric bounds. `allOf`/`oneOf`/`anyOf` are not modelled — a
315
+ * node that relies on them with no `type` of its own comes out untyped, which
316
+ * the validators treat as "anything goes".
317
+ */
318
+ export function toFieldSchema(node: OpenAPISchema): CrdFieldSchema {
319
+ const out: CrdFieldSchema = {};
320
+
321
+ if (node["x-kubernetes-int-or-string"]) {
322
+ out.open = true;
323
+ return out;
324
+ }
325
+
326
+ const type = node.type;
327
+ if (type === "string" || type === "integer" || type === "number" || type === "boolean" || type === "object" || type === "array") {
328
+ out.type = type;
329
+ } else if (node.properties) {
330
+ out.type = "object";
331
+ } else if (node.items) {
332
+ out.type = "array";
333
+ }
334
+
335
+ if (node.enum && node.enum.length > 0) {
336
+ out.enum = node.enum.map((v) => String(v));
337
+ }
338
+
339
+ if (out.type === "object") {
340
+ if (node.properties) {
341
+ const fields: Record<string, CrdFieldSchema> = {};
342
+ for (const [name, child] of Object.entries(node.properties)) {
343
+ fields[name] = toFieldSchema(child);
344
+ }
345
+ out.fields = fields;
346
+ }
347
+ if (node.required && node.required.length > 0) {
348
+ out.required = [...node.required];
349
+ }
350
+ if (node["x-kubernetes-preserve-unknown-fields"] || node.additionalProperties || !node.properties) {
351
+ out.open = true;
352
+ }
353
+ } else if (out.type === "array") {
354
+ if (node.items) out.items = toFieldSchema(node.items);
355
+ } else if (node["x-kubernetes-preserve-unknown-fields"] && !out.type) {
356
+ out.open = true;
357
+ }
358
+
359
+ return out;
360
+ }
361
+
296
362
  /**
297
363
  * Resolve an OpenAPI schema node to a TypeScript type string.
298
364
  */
@@ -14,7 +14,7 @@ import { k8sSerializer } from "../serializer";
14
14
  import { parseYAML } from "@intentius/chant/yaml";
15
15
 
16
16
  function synth(logicalName: string, resource: unknown): any {
17
- const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]]));
17
+ const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]])) as string;
18
18
  return parseYAML(yaml);
19
19
  }
20
20
 
@@ -22,14 +22,18 @@
22
22
  * are sets addressed by a well-known identity (containers by name, ports by
23
23
  * containerPort+protocol). None of that needs a live object in hand.
24
24
  *
25
- * What does *not* live here is the managed-fields prunewhether one
26
- * specific field on one specific live object is chant-owned, foreign-owned,
27
- * or contested. That is inherently per-object (it depends on *that* object's
25
+ * What does *not* live here is the managed-fields ownership walk which
26
+ * manager owns one specific field on one specific live object. That is
27
+ * inherently per-object (it depends on *that* object's
28
28
  * `metadata.managedFields`, which the declared tree never carries and which
29
29
  * differs between two Deployments of the same type), so it cannot be
30
30
  * expressed as a fixed rule keyed only by entity type and path — the shape
31
31
  * every other hook in this file takes. `./deep-observe.ts` computes it once
32
- * per resource and layers it on top of the rules below.
32
+ * per resource and attaches it to the observation as `fieldOwners`. Since
33
+ * chant #1191 ownership no longer prunes anything: a foreign-owned field
34
+ * nobody declared is reported as `undeclared` drift, and the only static
35
+ * subtraction for foreign writes is `K8S_SYSTEM_METADATA_PRUNE_PATTERNS`
36
+ * (core's `managed-fields`), applied below.
33
37
  *
34
38
  * The *entity-type-agnostic* half of these rules — which fields every
35
39
  * Kubernetes API object carries regardless of kind, and the well-known
@@ -41,20 +45,17 @@
41
45
  * keyed by chant's own k8s entityType catalog.
42
46
  */
43
47
 
44
- import { createRequire } from "module";
48
+ import { existsSync, readFileSync } from "fs";
49
+ import { dirname, join } from "path";
50
+ import { fileURLToPath } from "url";
45
51
  import type { DeepArrayElement, DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
46
- import { K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS, k8sListMapOrderKey } from "@intentius/chant/managed-fields";
52
+ import {
53
+ K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS,
54
+ K8S_SYSTEM_METADATA_PRUNE_PATTERNS,
55
+ k8sListMapOrderKey,
56
+ } from "@intentius/chant/managed-fields";
47
57
  import { LABEL_OWNERSHIP_KEYS, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
48
58
 
49
- // This module ships as ESM (tsx strips types from src/ directly), where a bare
50
- // `require` is not defined — the same reason serializer.ts and the LSP modules
51
- // already build one. The bug only surfaced on a LIVE deep read that meets an
52
- // associative list (every k8s estate does: a Deployment's containers), so
53
- // `lifecycle diff --live` crashed with `require is not defined` on exactly the
54
- // estates the generated table exists for (#1441 regression, found on
55
- // kubemicrovm-ops).
56
- const require = createRequire(import.meta.url);
57
-
58
59
  /**
59
60
  * Kubernetes-defaulted fields, per entity type, as index-erased property
60
61
  * paths. Subtracted only where source never declared the property
@@ -184,11 +185,63 @@ type ListMapKeyTable = Record<string, string[][]>;
184
185
 
185
186
  let cachedListMapKeys: ListMapKeyTable | undefined;
186
187
 
188
+ /**
189
+ * Where the generated table lives: next to this module, resolved from
190
+ * `import.meta.url`. This module ships as ESM (tsx strips types from src/
191
+ * directly), where a bare `require` is not defined — the #1441 regression
192
+ * crashed `lifecycle diff --live` with `require is not defined` on every
193
+ * estate with a Deployment, and azure's apiVersion registry hit the same
194
+ * class of bug in #1581. A plain fs read has no module-system dependency.
195
+ */
196
+ export function listMapKeyTablePath(): string {
197
+ return join(dirname(fileURLToPath(import.meta.url)), "generated", "list-map-keys.json");
198
+ }
199
+
200
+ /**
201
+ * Load the spec-derived table once. A missing or unreadable file degrades to
202
+ * an EMPTY table, which routes every list through `k8sListMapOrderKey`'s
203
+ * hand-written conventions — the same fallback an unlisted property already
204
+ * takes. The empty table is cached by the caller too, so an absent artifact
205
+ * costs one failed read and one warning, not one per array element
206
+ * (chant #1476).
207
+ *
208
+ * Degrading is the right call here, unlike azure's apiVersion registry
209
+ * (#1581) where a silent fallback bypasses correctness-bearing pins. Without
210
+ * the table, drift detection narrows back to the six hardcoded properties:
211
+ * less coverage, never wrong answers. The warning names the regen command so
212
+ * the narrowing is not silent.
213
+ */
214
+ export function loadListMapKeyTable(path: string = listMapKeyTablePath()): ListMapKeyTable {
215
+ if (!existsSync(path)) {
216
+ warnMissingTable(path, "not found");
217
+ return {};
218
+ }
219
+ try {
220
+ return JSON.parse(readFileSync(path, "utf-8")) as ListMapKeyTable;
221
+ } catch (err) {
222
+ warnMissingTable(path, `unreadable: ${err instanceof Error ? err.message : String(err)}`);
223
+ return {};
224
+ }
225
+ }
226
+
227
+ function warnMissingTable(path: string, reason: string): void {
228
+ console.warn(
229
+ `k8s: list-map-keys table ${reason} (${path}); list identity falls back to the ` +
230
+ "hand-written conventions in @intentius/chant/managed-fields. Run `npm run generate` " +
231
+ "in lexicons/k8s (dev checkout) or reinstall @intentius/chant-lexicon-k8s.",
232
+ );
233
+ }
234
+
187
235
  function listMapKeyTable(): ListMapKeyTable {
188
- cachedListMapKeys ??= require("./generated/list-map-keys.json") as ListMapKeyTable;
236
+ cachedListMapKeys ??= loadListMapKeyTable();
189
237
  return cachedListMapKeys;
190
238
  }
191
239
 
240
+ /** Test hook: replace (or drop) the cached table so the next lookup reloads it. */
241
+ export function resetListMapKeyTableCache(table?: ListMapKeyTable): void {
242
+ cachedListMapKeys = table;
243
+ }
244
+
192
245
  /** Last dotted segment of an index-erased path: `spec.template.spec.containers` → `containers`. */
193
246
  function lastSegment(pattern: string): string {
194
247
  const at = pattern.lastIndexOf(".");
@@ -259,6 +312,15 @@ export const k8sDeepNormalizationHooks: DeepNormalizationHooks = {
259
312
 
260
313
  if (node.side !== "live" || node.counterpart !== "absent") return false;
261
314
 
315
+ // What Kubernetes' own controllers stamp on every object of a kind
316
+ // (rollout counters, selector hashes, client-side-apply bookkeeping) is
317
+ // not drift. This is the static allowlist that stands in for the
318
+ // managed-fields prune since #1191: any *other* foreign-owned field
319
+ // nobody declared — a `kubectl label` from the console — now reaches the
320
+ // diff and reports as `undeclared`, with the accepted baseline as the
321
+ // valve for the ones an estate has decided to live with.
322
+ if (K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has(node.pattern)) return true;
323
+
262
324
  // chant's own ownership marker is not drift (see the sets' docs).
263
325
  if (K8S_OWNERSHIP_LABEL_PATTERNS.has(node.pattern)) return true;
264
326
  if (node.pattern === K8S_MANAGED_BY_LABEL_PATTERN && node.value === OWNERSHIP_MANAGED_BY_VALUE) return true;