@intentius/chant-lexicon-k8s 0.38.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/codegen/generate-list-map-keys.d.ts +53 -0
  2. package/dist/codegen/generate-list-map-keys.d.ts.map +1 -0
  3. package/dist/codegen/generate.d.ts.map +1 -1
  4. package/dist/composites/adot-collector.d.ts +7 -1
  5. package/dist/composites/adot-collector.d.ts.map +1 -1
  6. package/dist/composites/agic-ingress.d.ts +3 -1
  7. package/dist/composites/agic-ingress.d.ts.map +1 -1
  8. package/dist/composites/aks-external-dns-agent.d.ts +6 -1
  9. package/dist/composites/aks-external-dns-agent.d.ts.map +1 -1
  10. package/dist/composites/alb-ingress.d.ts +3 -1
  11. package/dist/composites/alb-ingress.d.ts.map +1 -1
  12. package/dist/composites/autoscaled-service.d.ts +6 -1
  13. package/dist/composites/autoscaled-service.d.ts.map +1 -1
  14. package/dist/composites/azure-disk-storage-class.d.ts +3 -1
  15. package/dist/composites/azure-disk-storage-class.d.ts.map +1 -1
  16. package/dist/composites/azure-file-storage-class.d.ts +3 -1
  17. package/dist/composites/azure-file-storage-class.d.ts.map +1 -1
  18. package/dist/composites/azure-monitor-collector.d.ts +7 -1
  19. package/dist/composites/azure-monitor-collector.d.ts.map +1 -1
  20. package/dist/composites/batch-job.d.ts +1 -1
  21. package/dist/composites/batch-job.d.ts.map +1 -1
  22. package/dist/composites/cockroachdb-cluster.d.ts +13 -1
  23. package/dist/composites/cockroachdb-cluster.d.ts.map +1 -1
  24. package/dist/composites/config-connector-context.d.ts +3 -1
  25. package/dist/composites/config-connector-context.d.ts.map +1 -1
  26. package/dist/composites/configured-app.d.ts +1 -1
  27. package/dist/composites/configured-app.d.ts.map +1 -1
  28. package/dist/composites/cron-workload.d.ts +6 -1
  29. package/dist/composites/cron-workload.d.ts.map +1 -1
  30. package/dist/composites/ebs-storage-class.d.ts +3 -1
  31. package/dist/composites/ebs-storage-class.d.ts.map +1 -1
  32. package/dist/composites/efs-storage-class.d.ts +3 -1
  33. package/dist/composites/efs-storage-class.d.ts.map +1 -1
  34. package/dist/composites/external-dns-agent.d.ts +6 -1
  35. package/dist/composites/external-dns-agent.d.ts.map +1 -1
  36. package/dist/composites/filestore-storage-class.d.ts +3 -1
  37. package/dist/composites/filestore-storage-class.d.ts.map +1 -1
  38. package/dist/composites/fluent-bit-agent.d.ts +7 -1
  39. package/dist/composites/fluent-bit-agent.d.ts.map +1 -1
  40. package/dist/composites/gce-ingress.d.ts +3 -1
  41. package/dist/composites/gce-ingress.d.ts.map +1 -1
  42. package/dist/composites/gce-pd-storage-class.d.ts +3 -1
  43. package/dist/composites/gce-pd-storage-class.d.ts.map +1 -1
  44. package/dist/composites/gke-external-dns-agent.d.ts +6 -1
  45. package/dist/composites/gke-external-dns-agent.d.ts.map +1 -1
  46. package/dist/composites/gke-fluent-bit-agent.d.ts +7 -1
  47. package/dist/composites/gke-fluent-bit-agent.d.ts.map +1 -1
  48. package/dist/composites/gke-gateway.d.ts +4 -1
  49. package/dist/composites/gke-gateway.d.ts.map +1 -1
  50. package/dist/composites/gke-otel-collector.d.ts +7 -1
  51. package/dist/composites/gke-otel-collector.d.ts.map +1 -1
  52. package/dist/composites/irsa-service-account.d.ts +1 -1
  53. package/dist/composites/irsa-service-account.d.ts.map +1 -1
  54. package/dist/composites/metrics-server.d.ts +10 -1
  55. package/dist/composites/metrics-server.d.ts.map +1 -1
  56. package/dist/composites/monitored-service.d.ts +1 -1
  57. package/dist/composites/monitored-service.d.ts.map +1 -1
  58. package/dist/composites/namespace-env.d.ts +1 -1
  59. package/dist/composites/namespace-env.d.ts.map +1 -1
  60. package/dist/composites/network-isolated-app.d.ts +5 -1
  61. package/dist/composites/network-isolated-app.d.ts.map +1 -1
  62. package/dist/composites/node-agent.d.ts +1 -1
  63. package/dist/composites/node-agent.d.ts.map +1 -1
  64. package/dist/composites/ray-cluster.d.ts +10 -1
  65. package/dist/composites/ray-cluster.d.ts.map +1 -1
  66. package/dist/composites/ray-job.d.ts +8 -1
  67. package/dist/composites/ray-job.d.ts.map +1 -1
  68. package/dist/composites/ray-service.d.ts +10 -1
  69. package/dist/composites/ray-service.d.ts.map +1 -1
  70. package/dist/composites/secure-ingress.d.ts +1 -1
  71. package/dist/composites/secure-ingress.d.ts.map +1 -1
  72. package/dist/composites/sidecar-app.d.ts +4 -1
  73. package/dist/composites/sidecar-app.d.ts.map +1 -1
  74. package/dist/composites/stateful-app.d.ts +1 -1
  75. package/dist/composites/stateful-app.d.ts.map +1 -1
  76. package/dist/composites/web-app.d.ts +1 -1
  77. package/dist/composites/web-app.d.ts.map +1 -1
  78. package/dist/composites/worker-pool.d.ts +1 -1
  79. package/dist/composites/worker-pool.d.ts.map +1 -1
  80. package/dist/composites/workload-identity-sa.d.ts +1 -1
  81. package/dist/composites/workload-identity-sa.d.ts.map +1 -1
  82. package/dist/composites/workload-identity-service-account.d.ts +1 -1
  83. package/dist/composites/workload-identity-service-account.d.ts.map +1 -1
  84. package/dist/config-schema.d.ts +40 -0
  85. package/dist/config-schema.d.ts.map +1 -0
  86. package/dist/config.d.ts +22 -1
  87. package/dist/config.d.ts.map +1 -1
  88. package/dist/deep-observe-hooks.d.ts +15 -18
  89. package/dist/deep-observe-hooks.d.ts.map +1 -1
  90. package/dist/deep-observe.d.ts.map +1 -1
  91. package/dist/describe-resources.d.ts +1 -0
  92. package/dist/describe-resources.d.ts.map +1 -1
  93. package/dist/integrity.json +2 -2
  94. package/dist/manifest.json +1 -1
  95. package/dist/plugin.d.ts.map +1 -1
  96. package/dist/spec/parse.d.ts +17 -0
  97. package/dist/spec/parse.d.ts.map +1 -1
  98. package/package.json +3 -2
  99. package/src/codegen/generate-list-map-keys.test.ts +112 -0
  100. package/src/codegen/generate-list-map-keys.ts +99 -0
  101. package/src/codegen/generate.ts +9 -1
  102. package/src/composites/adot-collector.ts +1 -1
  103. package/src/composites/agic-ingress.ts +1 -1
  104. package/src/composites/aks-external-dns-agent.ts +1 -1
  105. package/src/composites/alb-ingress.ts +1 -1
  106. package/src/composites/autoscaled-service.ts +1 -1
  107. package/src/composites/azure-disk-storage-class.ts +1 -1
  108. package/src/composites/azure-file-storage-class.ts +1 -1
  109. package/src/composites/azure-monitor-collector.ts +1 -1
  110. package/src/composites/batch-job.ts +1 -1
  111. package/src/composites/cockroachdb-cluster.ts +1 -1
  112. package/src/composites/config-connector-context.ts +1 -1
  113. package/src/composites/configured-app.ts +1 -1
  114. package/src/composites/cron-workload.ts +1 -1
  115. package/src/composites/ebs-storage-class.ts +1 -1
  116. package/src/composites/efs-storage-class.ts +1 -1
  117. package/src/composites/external-dns-agent.ts +1 -1
  118. package/src/composites/filestore-storage-class.ts +1 -1
  119. package/src/composites/fluent-bit-agent.ts +1 -1
  120. package/src/composites/gce-ingress.ts +1 -1
  121. package/src/composites/gce-pd-storage-class.ts +1 -1
  122. package/src/composites/gke-external-dns-agent.ts +1 -1
  123. package/src/composites/gke-fluent-bit-agent.ts +1 -1
  124. package/src/composites/gke-gateway.ts +1 -1
  125. package/src/composites/gke-otel-collector.ts +1 -1
  126. package/src/composites/irsa-service-account.ts +1 -1
  127. package/src/composites/metrics-server.ts +1 -1
  128. package/src/composites/monitored-service.ts +1 -1
  129. package/src/composites/namespace-env.ts +1 -1
  130. package/src/composites/network-isolated-app.ts +1 -1
  131. package/src/composites/node-agent.ts +1 -1
  132. package/src/composites/ray-cluster.ts +1 -1
  133. package/src/composites/ray-job.ts +1 -1
  134. package/src/composites/ray-service.ts +1 -1
  135. package/src/composites/secure-ingress.ts +1 -1
  136. package/src/composites/sidecar-app.ts +1 -1
  137. package/src/composites/stateful-app.ts +1 -1
  138. package/src/composites/web-app.ts +1 -1
  139. package/src/composites/worker-pool.ts +1 -1
  140. package/src/composites/workload-identity-sa.ts +1 -1
  141. package/src/composites/workload-identity-service-account.ts +1 -1
  142. package/src/config-schema.ts +46 -0
  143. package/src/config.ts +25 -1
  144. package/src/deep-observe-hooks.ts +90 -10
  145. package/src/deep-observe.ts +7 -0
  146. package/src/describe-resources.test.ts +138 -1
  147. package/src/describe-resources.ts +98 -0
  148. package/src/generated/list-map-keys.json +166 -0
  149. package/src/list-map-order-key.test.ts +119 -0
  150. package/src/plugin.ts +2 -0
  151. package/src/spec/parse.test.ts +42 -0
  152. package/src/spec/parse.ts +51 -2
@@ -0,0 +1,166 @@
1
+ {
2
+ "addresses": [
3
+ [
4
+ "type"
5
+ ]
6
+ ],
7
+ "allocatedResourcesStatus": [
8
+ [
9
+ "name"
10
+ ]
11
+ ],
12
+ "claims": [
13
+ [
14
+ "name"
15
+ ]
16
+ ],
17
+ "conditions": [
18
+ [
19
+ "type"
20
+ ]
21
+ ],
22
+ "containers": [
23
+ [
24
+ "name"
25
+ ]
26
+ ],
27
+ "devices": [
28
+ [
29
+ "device",
30
+ "driver",
31
+ "pool",
32
+ "shareID"
33
+ ]
34
+ ],
35
+ "drivers": [
36
+ [
37
+ "name"
38
+ ]
39
+ ],
40
+ "env": [
41
+ [
42
+ "name"
43
+ ]
44
+ ],
45
+ "ephemeralContainers": [
46
+ [
47
+ "name"
48
+ ]
49
+ ],
50
+ "hostAliases": [
51
+ [
52
+ "ip"
53
+ ]
54
+ ],
55
+ "imagePullSecrets": [
56
+ [
57
+ "name"
58
+ ]
59
+ ],
60
+ "initContainers": [
61
+ [
62
+ "name"
63
+ ]
64
+ ],
65
+ "matchConditions": [
66
+ [
67
+ "name"
68
+ ]
69
+ ],
70
+ "ownerReferences": [
71
+ [
72
+ "uid"
73
+ ]
74
+ ],
75
+ "podGroupTemplates": [
76
+ [
77
+ "name"
78
+ ]
79
+ ],
80
+ "podIPs": [
81
+ [
82
+ "ip"
83
+ ]
84
+ ],
85
+ "ports": [
86
+ [
87
+ "containerPort",
88
+ "protocol"
89
+ ],
90
+ [
91
+ "port",
92
+ "protocol"
93
+ ]
94
+ ],
95
+ "reservedFor": [
96
+ [
97
+ "uid"
98
+ ]
99
+ ],
100
+ "resourceClaimStatuses": [
101
+ [
102
+ "name"
103
+ ]
104
+ ],
105
+ "resourceClaims": [
106
+ [
107
+ "name"
108
+ ]
109
+ ],
110
+ "resources": [
111
+ [
112
+ "resourceID"
113
+ ]
114
+ ],
115
+ "schedulingGates": [
116
+ [
117
+ "name"
118
+ ]
119
+ ],
120
+ "secrets": [
121
+ [
122
+ "name"
123
+ ]
124
+ ],
125
+ "storageVersions": [
126
+ [
127
+ "apiServerID"
128
+ ]
129
+ ],
130
+ "topologySpreadConstraints": [
131
+ [
132
+ "topologyKey",
133
+ "whenUnsatisfiable"
134
+ ]
135
+ ],
136
+ "variables": [
137
+ [
138
+ "name"
139
+ ]
140
+ ],
141
+ "volumeDevices": [
142
+ [
143
+ "devicePath"
144
+ ]
145
+ ],
146
+ "volumeMounts": [
147
+ [
148
+ "mountPath"
149
+ ]
150
+ ],
151
+ "volumes": [
152
+ [
153
+ "name"
154
+ ]
155
+ ],
156
+ "webhooks": [
157
+ [
158
+ "name"
159
+ ]
160
+ ],
161
+ "x-kubernetes-validations": [
162
+ [
163
+ "rule"
164
+ ]
165
+ ]
166
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { schemaListMapOrderKey } from "./deep-observe-hooks";
3
+ import { k8sListMapOrderKey } from "@intentius/chant/managed-fields";
4
+ import type { DeepArrayElement } from "@intentius/chant/lexicon";
5
+
6
+ /**
7
+ * chant #1441 — list elements are identified by the key the SPEC declares,
8
+ * with the hand-written conventions kept as fallback.
9
+ *
10
+ * The behavioural claim under test: two observations of the same object whose
11
+ * associative lists arrive in different orders must canonicalize to the same
12
+ * sequence, so ordering alone is never reported as drift.
13
+ */
14
+
15
+ function el(pattern: string, element: unknown, index = 0): DeepArrayElement {
16
+ return { entityType: "K8s::Apps::Deployment", pattern, path: pattern, element, index, side: "live" };
17
+ }
18
+
19
+ /** Sort a list the way core's normalization does, using the hook's key. */
20
+ function canonicalOrder(pattern: string, elements: unknown[]): unknown[] {
21
+ return elements
22
+ .map((element, index) => ({ element, key: schemaListMapOrderKey(el(pattern, element, index)) }))
23
+ .sort((a, b) => (a.key ?? "").localeCompare(b.key ?? ""))
24
+ .map((e) => e.element);
25
+ }
26
+
27
+ describe("conditions — the case the hardcoded table missed", () => {
28
+ const ready = { type: "Ready", status: "True" };
29
+ const progressing = { type: "Progressing", status: "True" };
30
+
31
+ test("is identified by type", () => {
32
+ expect(schemaListMapOrderKey(el("status.conditions", ready))).toBe("Ready");
33
+ });
34
+
35
+ test("was NOT identified before this change", () => {
36
+ // Guards the premise: without the spec-derived table, `conditions` fell
37
+ // through to `undefined`, which leaves the array order alone — so two
38
+ // orderings compared positionally and read as drift.
39
+ expect(k8sListMapOrderKey(el("status.conditions", ready))).toBeUndefined();
40
+ });
41
+
42
+ test("two orderings of the same conditions canonicalize identically", () => {
43
+ expect(canonicalOrder("status.conditions", [ready, progressing])).toEqual(
44
+ canonicalOrder("status.conditions", [progressing, ready]),
45
+ );
46
+ });
47
+ });
48
+
49
+ describe("ports — one name, two key shapes", () => {
50
+ test("a container port is keyed by containerPort and protocol", () => {
51
+ // Byte-identical to what the hardcoded implementation produced, because
52
+ // this string is also the element's address in drift output.
53
+ const element = { containerPort: 8080, protocol: "TCP" };
54
+ expect(schemaListMapOrderKey(el("spec.template.spec.containers.ports", element))).toBe("08080/TCP");
55
+ expect(schemaListMapOrderKey(el("spec.template.spec.containers.ports", element))).toBe(
56
+ k8sListMapOrderKey(el("spec.template.spec.containers.ports", element)),
57
+ );
58
+ });
59
+
60
+ test("a Service port is keyed by port and protocol", () => {
61
+ expect(schemaListMapOrderKey(el("spec.ports", { port: 443, protocol: "TCP" }))).toBe("00443/TCP");
62
+ });
63
+
64
+ test("numeric key fields order numerically, not lexicographically", () => {
65
+ const ordered = canonicalOrder("spec.ports", [
66
+ { port: 443, protocol: "TCP" },
67
+ { port: 80, protocol: "TCP" },
68
+ ]);
69
+ expect(ordered).toEqual([
70
+ { port: 80, protocol: "TCP" },
71
+ { port: 443, protocol: "TCP" },
72
+ ]);
73
+ });
74
+ });
75
+
76
+ describe("fallback", () => {
77
+ test("a list the spec does not annotate still gets the hand-written key", () => {
78
+ // `containers` is in both the generated table and the hardcoded one; the
79
+ // point is that removing an element's spec-declared fields does not strand
80
+ // it — it lands on the by-name convention.
81
+ const key = schemaListMapOrderKey(el("spec.template.spec.containers", { name: "app", image: "nginx" }));
82
+ expect(key).toBeDefined();
83
+ });
84
+
85
+ test("an unknown list is left unordered, exactly as before", () => {
86
+ expect(schemaListMapOrderKey(el("spec.somethingUnknown", { a: 1 }))).toBeUndefined();
87
+ });
88
+
89
+ test("a non-object element is left to the fallback rather than crashing", () => {
90
+ expect(() => schemaListMapOrderKey(el("spec.template.spec.containers", "not-an-object"))).not.toThrow();
91
+ });
92
+
93
+ test("an element missing its declared key fields falls through", () => {
94
+ // A conditions entry with no `type` cannot be identified by the spec key.
95
+ // It must not produce `type=undefined`, which would collide across
96
+ // elements and silently merge distinct entries.
97
+ const key = schemaListMapOrderKey(el("status.conditions", { status: "True" }));
98
+ expect(key ?? "").not.toContain("undefined");
99
+ });
100
+ });
101
+
102
+ describe("compatibility with the hardcoded table", () => {
103
+ // The order key is the element's address in drift output, so every property
104
+ // the hand-written table already covered must keep producing the identical
105
+ // string. A change here silently rewrites existing drift paths.
106
+ const cases: Array<[string, unknown]> = [
107
+ ["spec.template.spec.containers", { name: "app", image: "nginx" }],
108
+ ["spec.template.spec.initContainers", { name: "init" }],
109
+ ["spec.template.spec.ephemeralContainers", { name: "debug" }],
110
+ ["spec.template.spec.containers.env", { name: "PORT", value: "8080" }],
111
+ ["spec.template.spec.volumes", { name: "data" }],
112
+ ["spec.template.spec.containers.ports", { containerPort: 80, protocol: "TCP" }],
113
+ ["spec.ports", { port: 443, protocol: "TCP" }],
114
+ ];
115
+
116
+ test.each(cases)("%s produces the same key as before", (pattern, element) => {
117
+ expect(schemaListMapOrderKey(el(pattern, element))).toBe(k8sListMapOrderKey(el(pattern, element)));
118
+ });
119
+ });
package/src/plugin.ts CHANGED
@@ -27,10 +27,12 @@ import { K8sParser } from "./import/parser";
27
27
  import { K8sGenerator } from "./import/generator";
28
28
  import { k8sDeepNormalizationHooks } from "./deep-observe-hooks";
29
29
  import { LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
30
+ import { k8sConfigSchema } from "./config-schema";
30
31
 
31
32
  export const k8sPlugin: LexiconPlugin = {
32
33
  name: "k8s",
33
34
  ownershipChannel: { keys: LABEL_OWNERSHIP_KEYS, reads: ["describeResources", "observeResourcesDeep", "exportResources"] },
35
+ configSchema: k8sConfigSchema,
34
36
  auditCatalog: () => k8sAuditCatalog,
35
37
  serializer: k8sSerializer,
36
38
  // Self-upgrade: where the pinned Kubernetes schema version lives + its upstream (#685).
@@ -4,6 +4,7 @@ import {
4
4
  gvkToApiVersion,
5
5
  k8sShortName,
6
6
  k8sServiceName,
7
+ specListMapKeyPairs,
7
8
  } from "./parse";
8
9
 
9
10
  describe("gvkToTypeName", () => {
@@ -100,3 +101,44 @@ describe("k8sServiceName", () => {
100
101
  expect(typeof name).toBe("string");
101
102
  });
102
103
  });
104
+
105
+ /**
106
+ * chant #1441 — the merge semantics the API server publishes, read off every
107
+ * definition rather than only the ones chant emits a type for.
108
+ */
109
+ describe("specListMapKeyPairs", () => {
110
+ const spec = JSON.stringify({
111
+ definitions: {
112
+ "io.k8s.api.core.v1.PodSpec": {
113
+ properties: {
114
+ containers: { type: "array", "x-kubernetes-list-type": "map", "x-kubernetes-list-map-keys": ["name"] },
115
+ // atomic and set lists carry no identity key
116
+ tolerations: { type: "array", "x-kubernetes-list-type": "atomic" },
117
+ finalizers: { type: "array", "x-kubernetes-list-type": "set" },
118
+ nodeName: { type: "string" },
119
+ },
120
+ },
121
+ "io.k8s.api.core.v1.ServiceSpec": {
122
+ properties: {
123
+ ports: { type: "array", "x-kubernetes-list-type": "map", "x-kubernetes-list-map-keys": ["port", "protocol"] },
124
+ },
125
+ },
126
+ "io.k8s.api.core.v1.NoProperties": {},
127
+ },
128
+ });
129
+
130
+ test("collects only map-typed lists that name their keys", () => {
131
+ expect(specListMapKeyPairs(spec)).toEqual([
132
+ ["containers", ["name"]],
133
+ ["ports", ["port", "protocol"]],
134
+ ]);
135
+ });
136
+
137
+ test("accepts a Buffer, as the fetch path supplies", () => {
138
+ expect(specListMapKeyPairs(Buffer.from(spec, "utf-8"))).toHaveLength(2);
139
+ });
140
+
141
+ test("a spec with no definitions yields nothing rather than throwing", () => {
142
+ expect(specListMapKeyPairs(JSON.stringify({}))).toEqual([]);
143
+ });
144
+ });
package/src/spec/parse.ts CHANGED
@@ -25,6 +25,10 @@ export interface ParsedProperty {
25
25
  description?: string;
26
26
  enum?: string[];
27
27
  constraints: PropertyConstraints;
28
+ /** `x-kubernetes-list-type` — `map`, `set` or `atomic`. Absent when the spec does not say (chant #1441). */
29
+ listType?: string;
30
+ /** `x-kubernetes-list-map-keys` — the fields that jointly identify one element. Present only alongside `listType: "map"`. */
31
+ listMapKeys?: string[];
28
32
  }
29
33
 
30
34
  export interface ParsedPropertyType {
@@ -104,6 +108,16 @@ interface SwaggerDefinition {
104
108
  "x-kubernetes-group-version-kind"?: GroupVersionKind[];
105
109
  "x-kubernetes-int-or-string"?: boolean;
106
110
  "x-kubernetes-preserve-unknown-fields"?: boolean;
111
+ /**
112
+ * How the API server merges this list: `map` (associative, identified by
113
+ * {@link SwaggerDefinition["x-kubernetes-list-map-keys"]}), `set` (unordered,
114
+ * elements are their own identity), or `atomic` (replaced wholesale). chant
115
+ * #1441 — carried through so the drift engine can identify list elements
116
+ * semantically instead of positionally.
117
+ */
118
+ "x-kubernetes-list-type"?: string;
119
+ /** The property names that jointly identify one element of an associative list. */
120
+ "x-kubernetes-list-map-keys"?: string[];
107
121
  }
108
122
 
109
123
  interface SwaggerProperty extends SwaggerDefinition {
@@ -227,6 +241,33 @@ export function parseK8sSwagger(data: string | Buffer): K8sParseResult[] {
227
241
  }
228
242
 
229
243
  /** Stable key for a GVK, used to join the `paths` pass onto the `definitions` pass. */
244
+ /**
245
+ * Every `(property name, list-map keys)` pair the spec declares, across ALL
246
+ * definitions (chant #1441).
247
+ *
248
+ * Deliberately independent of {@link parseK8sSwagger}'s resource/property-type
249
+ * extraction. That extraction keeps 257 resources and 320 property types out
250
+ * of roughly a thousand definitions, so collecting merge keys from its output
251
+ * alone finds 18 of the 32 properties the spec annotates — and misses
252
+ * `ServicePort.port`, whose absence would silently demote every Service port
253
+ * list to the hand-written fallback. A definition is worth reading for its
254
+ * merge semantics whether or not chant emits a type for it.
255
+ */
256
+ export function specListMapKeyPairs(data: string | Buffer): Array<[string, string[]]> {
257
+ const spec = JSON.parse(typeof data === "string" ? data : data.toString("utf-8")) as SwaggerSpec;
258
+ const pairs: Array<[string, string[]]> = [];
259
+
260
+ for (const def of Object.values(spec.definitions ?? {})) {
261
+ for (const [name, prop] of Object.entries(def.properties ?? {})) {
262
+ if (prop["x-kubernetes-list-type"] !== "map") continue;
263
+ const keys = prop["x-kubernetes-list-map-keys"];
264
+ if (keys?.length) pairs.push([name, [...keys]]);
265
+ }
266
+ }
267
+
268
+ return pairs;
269
+ }
270
+
230
271
  export function gvkKey(gvk: GroupVersionKind): string {
231
272
  return `${gvk.group}|${gvk.version}|${gvk.kind}`;
232
273
  }
@@ -442,14 +483,22 @@ function parseProperties(
442
483
 
443
484
  for (const [name, prop] of Object.entries(properties)) {
444
485
  const tsType = resolvePropertyType(prop, definitions);
445
- result.push({
486
+ const parsed: ParsedProperty = {
446
487
  name,
447
488
  tsType,
448
489
  required: requiredSet.has(name),
449
490
  description: prop.description,
450
491
  enum: prop.enum,
451
492
  constraints: coreExtractConstraints(prop as JsonSchemaProperty),
452
- });
493
+ };
494
+ // chant #1441 — the merge semantics the API server itself uses. Kept only
495
+ // when present; a spec that says nothing leaves both fields absent rather
496
+ // than asserting a default the schema did not state.
497
+ const listType = prop["x-kubernetes-list-type"];
498
+ if (listType) parsed.listType = listType;
499
+ const listMapKeys = prop["x-kubernetes-list-map-keys"];
500
+ if (listMapKeys?.length) parsed.listMapKeys = [...listMapKeys];
501
+ result.push(parsed);
453
502
  }
454
503
 
455
504
  return result;