@intentius/chant-lexicon-helm 0.0.16

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 (110) hide show
  1. package/README.md +22 -0
  2. package/dist/integrity.json +36 -0
  3. package/dist/manifest.json +37 -0
  4. package/dist/meta.json +208 -0
  5. package/dist/rules/chart-metadata.ts +64 -0
  6. package/dist/rules/helm-helpers.ts +64 -0
  7. package/dist/rules/no-hardcoded-image.ts +62 -0
  8. package/dist/rules/values-no-secrets.ts +82 -0
  9. package/dist/rules/whm101.ts +46 -0
  10. package/dist/rules/whm102.ts +33 -0
  11. package/dist/rules/whm103.ts +59 -0
  12. package/dist/rules/whm104.ts +35 -0
  13. package/dist/rules/whm105.ts +30 -0
  14. package/dist/rules/whm201.ts +36 -0
  15. package/dist/rules/whm202.ts +50 -0
  16. package/dist/rules/whm203.ts +39 -0
  17. package/dist/rules/whm204.ts +60 -0
  18. package/dist/rules/whm301.ts +41 -0
  19. package/dist/rules/whm302.ts +40 -0
  20. package/dist/rules/whm401.ts +57 -0
  21. package/dist/rules/whm402.ts +45 -0
  22. package/dist/rules/whm403.ts +45 -0
  23. package/dist/rules/whm404.ts +36 -0
  24. package/dist/rules/whm405.ts +53 -0
  25. package/dist/rules/whm406.ts +34 -0
  26. package/dist/rules/whm407.ts +83 -0
  27. package/dist/rules/whm501.ts +103 -0
  28. package/dist/rules/whm502.ts +94 -0
  29. package/dist/skills/chant-helm-chart-patterns.md +229 -0
  30. package/dist/skills/chant-helm-chart-security-patterns.md +192 -0
  31. package/dist/skills/chant-helm-create-chart.md +211 -0
  32. package/dist/types/index.d.ts +132 -0
  33. package/package.json +34 -0
  34. package/src/codegen/docs-cli.ts +4 -0
  35. package/src/codegen/docs.ts +483 -0
  36. package/src/codegen/generate-cli.ts +28 -0
  37. package/src/codegen/generate.ts +249 -0
  38. package/src/codegen/naming.ts +38 -0
  39. package/src/codegen/package.ts +64 -0
  40. package/src/composites/composites.test.ts +1050 -0
  41. package/src/composites/helm-batch-job.ts +209 -0
  42. package/src/composites/helm-crd-lifecycle.ts +184 -0
  43. package/src/composites/helm-cron-job.ts +177 -0
  44. package/src/composites/helm-daemon-set.ts +169 -0
  45. package/src/composites/helm-external-secret.ts +93 -0
  46. package/src/composites/helm-library.ts +51 -0
  47. package/src/composites/helm-microservice.ts +331 -0
  48. package/src/composites/helm-monitored-service.ts +252 -0
  49. package/src/composites/helm-namespace-env.ts +154 -0
  50. package/src/composites/helm-secure-ingress.ts +114 -0
  51. package/src/composites/helm-stateful-service.ts +213 -0
  52. package/src/composites/helm-web-app.ts +264 -0
  53. package/src/composites/helm-worker.ts +207 -0
  54. package/src/composites/index.ts +38 -0
  55. package/src/coverage.test.ts +21 -0
  56. package/src/coverage.ts +50 -0
  57. package/src/generated/index.d.ts +132 -0
  58. package/src/generated/index.ts +13 -0
  59. package/src/generated/lexicon-helm.json +208 -0
  60. package/src/helpers.test.ts +51 -0
  61. package/src/helpers.ts +100 -0
  62. package/src/import/generator.ts +285 -0
  63. package/src/import/import.test.ts +224 -0
  64. package/src/import/parser.ts +160 -0
  65. package/src/import/template-stripper.ts +123 -0
  66. package/src/index.ts +108 -0
  67. package/src/intrinsics.test.ts +380 -0
  68. package/src/intrinsics.ts +484 -0
  69. package/src/lint/post-synth/helm-helpers.ts +64 -0
  70. package/src/lint/post-synth/post-synth.test.ts +504 -0
  71. package/src/lint/post-synth/whm101.ts +46 -0
  72. package/src/lint/post-synth/whm102.ts +33 -0
  73. package/src/lint/post-synth/whm103.ts +59 -0
  74. package/src/lint/post-synth/whm104.ts +35 -0
  75. package/src/lint/post-synth/whm105.ts +30 -0
  76. package/src/lint/post-synth/whm201.ts +36 -0
  77. package/src/lint/post-synth/whm202.ts +50 -0
  78. package/src/lint/post-synth/whm203.ts +39 -0
  79. package/src/lint/post-synth/whm204.ts +60 -0
  80. package/src/lint/post-synth/whm301.ts +41 -0
  81. package/src/lint/post-synth/whm302.ts +40 -0
  82. package/src/lint/post-synth/whm401.ts +57 -0
  83. package/src/lint/post-synth/whm402.ts +45 -0
  84. package/src/lint/post-synth/whm403.ts +45 -0
  85. package/src/lint/post-synth/whm404.ts +36 -0
  86. package/src/lint/post-synth/whm405.ts +53 -0
  87. package/src/lint/post-synth/whm406.ts +34 -0
  88. package/src/lint/post-synth/whm407.ts +83 -0
  89. package/src/lint/post-synth/whm501.ts +103 -0
  90. package/src/lint/post-synth/whm502.ts +94 -0
  91. package/src/lint/rules/chart-metadata.ts +64 -0
  92. package/src/lint/rules/lint-rules.test.ts +97 -0
  93. package/src/lint/rules/no-hardcoded-image.ts +62 -0
  94. package/src/lint/rules/values-no-secrets.ts +82 -0
  95. package/src/lsp/completions.test.ts +72 -0
  96. package/src/lsp/completions.ts +20 -0
  97. package/src/lsp/hover.test.ts +46 -0
  98. package/src/lsp/hover.ts +46 -0
  99. package/src/package-cli.ts +28 -0
  100. package/src/plugin.test.ts +71 -0
  101. package/src/plugin.ts +206 -0
  102. package/src/resources.ts +77 -0
  103. package/src/serializer.test.ts +930 -0
  104. package/src/serializer.ts +835 -0
  105. package/src/skills/chart-patterns.md +229 -0
  106. package/src/skills/chart-security-patterns.md +192 -0
  107. package/src/skills/create-chart.md +211 -0
  108. package/src/validate-cli.ts +21 -0
  109. package/src/validate.test.ts +37 -0
  110. package/src/validate.ts +36 -0
@@ -0,0 +1,835 @@
1
+ /**
2
+ * Helm chart serializer.
3
+ *
4
+ * Converts Chant declarables into a complete Helm chart directory structure
5
+ * returned as a SerializerResult with a files map:
6
+ *
7
+ * Chart.yaml, values.yaml, values.schema.json, .helmignore,
8
+ * templates/_helpers.tpl, templates/<resource>.yaml, templates/NOTES.txt,
9
+ * templates/tests/test-connection.yaml
10
+ *
11
+ * The serializer detects `__helm_tpl` markers in walked values and emits
12
+ * raw Go template expressions instead of YAML-quoting them. It also
13
+ * detects `__helm_if` markers to wrap entire resources in conditionals.
14
+ */
15
+
16
+ import type { Declarable } from "@intentius/chant/declarable";
17
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
18
+ import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
19
+ import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
20
+ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
21
+ import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
22
+ import { HELM_TPL_KEY, HELM_IF_KEY, HELM_RANGE_KEY, HELM_WITH_KEY, type HelmConditional } from "./intrinsics";
23
+ import { generateHelpers } from "./helpers";
24
+
25
+ // ── GVK resolution for K8s resources ──────────────────────
26
+
27
+ /**
28
+ * Well-known K8s API group → apiVersion mappings.
29
+ * Used to resolve K8s entity types to apiVersion/kind for YAML emission.
30
+ */
31
+ const API_GROUP_VERSIONS: Record<string, string> = {
32
+ Core: "v1",
33
+ Apps: "apps/v1",
34
+ Batch: "batch/v1",
35
+ Networking: "networking.k8s.io/v1",
36
+ Policy: "policy/v1",
37
+ Rbac: "rbac.authorization.k8s.io/v1",
38
+ Storage: "storage.k8s.io/v1",
39
+ Autoscaling: "autoscaling/v2",
40
+ Admissionregistration: "admissionregistration.k8s.io/v1",
41
+ };
42
+
43
+ function resolveK8sGVK(entityType: string): { apiVersion: string; kind: string } | null {
44
+ const parts = entityType.split("::");
45
+ if (parts.length !== 3 || parts[0] !== "K8s") return null;
46
+ const group = parts[1];
47
+ const kind = parts[2];
48
+ const apiVersion = API_GROUP_VERSIONS[group];
49
+ if (!apiVersion) return null;
50
+ return { apiVersion, kind };
51
+ }
52
+
53
+ // ── Helm visitor ──────────────────────────────────────────
54
+
55
+ function helmVisitor(): SerializerVisitor {
56
+ return {
57
+ attrRef: (name, _attr) => name,
58
+ resourceRef: (name) => name,
59
+ propertyDeclarable: (entity, walk) => {
60
+ if (!("props" in entity) || typeof entity.props !== "object" || entity.props === null) {
61
+ return undefined;
62
+ }
63
+ const props = entity.props as Record<string, unknown>;
64
+ const result: Record<string, unknown> = {};
65
+ for (const [key, value] of Object.entries(props)) {
66
+ if (value !== undefined) {
67
+ result[key] = walk(value);
68
+ }
69
+ }
70
+ return Object.keys(result).length > 0 ? result : undefined;
71
+ },
72
+ };
73
+ }
74
+
75
+ // ── YAML emission with Helm template support ──────────────
76
+
77
+ /**
78
+ * Emit an else chain, detecting nested `__helm_if` markers to produce
79
+ * `{{- else if <cond> }}` instead of `{{- else }}\n{{- if <cond> }}`.
80
+ */
81
+ function emitElseChain(elseBody: unknown, indent: number): string {
82
+ if (typeof elseBody === "object" && elseBody !== null && HELM_IF_KEY in (elseBody as Record<string, unknown>)) {
83
+ const nested = elseBody as Record<string, unknown>;
84
+ const nestedCond = nested[HELM_IF_KEY] as string;
85
+ const nestedBody = nested.body;
86
+ const nestedElse = nested.else;
87
+ let result = `\n{{- else if ${nestedCond} }}\n${emitHelmYAML(nestedBody, indent)}`;
88
+ if (nestedElse !== undefined) {
89
+ result += emitElseChain(nestedElse, indent);
90
+ }
91
+ return result;
92
+ }
93
+ return `\n{{- else }}\n${emitHelmYAML(elseBody, indent)}`;
94
+ }
95
+
96
+ /**
97
+ * Emit a YAML value, detecting __helm_tpl markers and emitting them
98
+ * as raw Go template expressions.
99
+ */
100
+ function emitHelmYAML(value: unknown, indent: number): string {
101
+ const prefix = " ".repeat(indent);
102
+
103
+ if (value === null || value === undefined) return "null";
104
+ if (typeof value === "boolean") return value ? "true" : "false";
105
+ if (typeof value === "number") return String(value);
106
+
107
+ if (typeof value === "string") {
108
+ // Check if this is a raw template expression that was already inlined
109
+ if (value.startsWith("{{") && value.endsWith("}}")) {
110
+ return value;
111
+ }
112
+ if (
113
+ value === "" || value === "true" || value === "false" || value === "null" ||
114
+ value === "yes" || value === "no" ||
115
+ value.includes(": ") || value.includes("#") ||
116
+ value.startsWith("*") || value.startsWith("&") || value.startsWith("!") ||
117
+ value.startsWith("{") || value.startsWith("[") ||
118
+ value.startsWith("'") || value.startsWith('"') || value.startsWith("$") ||
119
+ /^\d/.test(value)
120
+ ) {
121
+ return `'${value.replace(/'/g, "''")}'`;
122
+ }
123
+ return value;
124
+ }
125
+
126
+ if (Array.isArray(value)) {
127
+ if (value.length === 0) return "[]";
128
+ const lines: string[] = [];
129
+ for (const item of value) {
130
+ if (typeof item === "object" && item !== null && !Array.isArray(item)) {
131
+ const entries = Object.entries(item as Record<string, unknown>);
132
+ if (entries.length > 0) {
133
+ const [firstKey, firstVal] = entries[0];
134
+ const firstEmitted = emitHelmYAML(firstVal, indent + 2);
135
+ if (firstEmitted.startsWith("\n")) {
136
+ lines.push(`${prefix}- ${firstKey}:${firstEmitted}`);
137
+ } else {
138
+ lines.push(`${prefix}- ${firstKey}: ${firstEmitted}`);
139
+ }
140
+ for (let i = 1; i < entries.length; i++) {
141
+ const [key, val] = entries[i];
142
+ const emitted = emitHelmYAML(val, indent + 2);
143
+ if (emitted.startsWith("\n")) {
144
+ lines.push(`${prefix} ${key}:${emitted}`);
145
+ } else {
146
+ lines.push(`${prefix} ${key}: ${emitted}`);
147
+ }
148
+ }
149
+ }
150
+ } else {
151
+ lines.push(`${prefix}- ${emitHelmYAML(item, indent + 1).trimStart()}`);
152
+ }
153
+ }
154
+ return "\n" + lines.join("\n");
155
+ }
156
+
157
+ if (typeof value === "object") {
158
+ const obj = value as Record<string, unknown>;
159
+
160
+ // Detect __helm_tpl marker → emit raw template expression
161
+ if (HELM_TPL_KEY in obj && typeof obj[HELM_TPL_KEY] === "string") {
162
+ return obj[HELM_TPL_KEY] as string;
163
+ }
164
+
165
+ // Detect __helm_if marker → emit conditional block
166
+ if (HELM_IF_KEY in obj) {
167
+ const condition = obj[HELM_IF_KEY] as string;
168
+ const body = obj.body;
169
+ const elseBody = obj.else;
170
+ let result = `{{- if ${condition} }}\n${emitHelmYAML(body, indent)}`;
171
+ if (elseBody !== undefined) {
172
+ result += emitElseChain(elseBody, indent);
173
+ }
174
+ result += "\n{{- end }}";
175
+ return result;
176
+ }
177
+
178
+ // Detect __helm_range marker → emit range loop
179
+ if (HELM_RANGE_KEY in obj) {
180
+ const list = obj[HELM_RANGE_KEY] as string;
181
+ const body = obj.body;
182
+ return `{{- range ${list} }}\n${emitHelmYAML(body, indent)}\n{{- end }}`;
183
+ }
184
+
185
+ // Detect __helm_with marker → emit with scope
186
+ if (HELM_WITH_KEY in obj) {
187
+ const scope = obj[HELM_WITH_KEY] as string;
188
+ const body = obj.body;
189
+ return `{{- with ${scope} }}\n${emitHelmYAML(body, indent)}\n{{- end }}`;
190
+ }
191
+
192
+ const entries = Object.entries(obj);
193
+ if (entries.length === 0) return "{}";
194
+ const lines: string[] = [];
195
+ for (const [key, val] of entries) {
196
+ const emitted = emitHelmYAML(val, indent + 1);
197
+ if (emitted.startsWith("\n")) {
198
+ lines.push(`${prefix}${key}:${emitted}`);
199
+ } else {
200
+ lines.push(`${prefix}${key}: ${emitted}`);
201
+ }
202
+ }
203
+ return "\n" + lines.join("\n");
204
+ }
205
+
206
+ return String(value);
207
+ }
208
+
209
+ /**
210
+ * Emit a top-level key-value pair in Helm YAML.
211
+ */
212
+ function emitKeyValue(key: string, value: unknown): string {
213
+ const yamlStr = emitHelmYAML(value, 1);
214
+ if (yamlStr.startsWith("\n")) {
215
+ return `${key}:${yamlStr}`;
216
+ }
217
+ return `${key}: ${yamlStr}`;
218
+ }
219
+
220
+ // ── Serializer ────────────────────────────────────────────
221
+
222
+ /**
223
+ * Specless K8s types whose properties live directly on the manifest.
224
+ */
225
+ const SPECLESS_TYPES = new Set([
226
+ "ConfigMap", "Secret", "Namespace", "ServiceAccount",
227
+ ]);
228
+
229
+ /**
230
+ * Convert a logical name to a kebab-case filename stem.
231
+ */
232
+ function toFileName(name: string): string {
233
+ return name
234
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
235
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
236
+ .toLowerCase();
237
+ }
238
+
239
+ /**
240
+ * Generate Chart.yaml content from Helm::Chart entity props.
241
+ */
242
+ function emitChartYaml(props: Record<string, unknown>): string {
243
+ const orderedKeys = [
244
+ "apiVersion", "name", "version", "kubeVersion", "description", "type",
245
+ "keywords", "home", "sources", "icon", "maintainers", "deprecated",
246
+ "annotations", "condition", "tags", "appVersion",
247
+ ];
248
+ const lines: string[] = [];
249
+
250
+ for (const key of orderedKeys) {
251
+ if (props[key] !== undefined) {
252
+ lines.push(emitKeyValue(key, props[key]));
253
+ }
254
+ }
255
+
256
+ for (const [key, val] of Object.entries(props)) {
257
+ if (!orderedKeys.includes(key) && val !== undefined) {
258
+ lines.push(emitKeyValue(key, val));
259
+ }
260
+ }
261
+
262
+ return lines.join("\n") + "\n";
263
+ }
264
+
265
+ /**
266
+ * Generate values.yaml content from Helm::Values entity props.
267
+ */
268
+ function emitValuesYaml(props: Record<string, unknown>): string {
269
+ if (Object.keys(props).length === 0) return "{}\n";
270
+ const lines: string[] = [];
271
+ for (const [key, val] of Object.entries(props)) {
272
+ lines.push(emitKeyValue(key, val));
273
+ }
274
+ return lines.join("\n") + "\n";
275
+ }
276
+
277
+ /**
278
+ * Description inference — static map from common Helm value key names.
279
+ */
280
+ const KEY_DESCRIPTIONS: Record<string, string> = {
281
+ replicaCount: "Number of pod replicas",
282
+ image: "Container image configuration",
283
+ repository: "Image repository",
284
+ tag: "Image tag (empty defaults to Chart.appVersion)",
285
+ pullPolicy: "Image pull policy",
286
+ port: "Service port number",
287
+ enabled: "Whether this feature is enabled",
288
+ resources: "Container resource requests and limits",
289
+ service: "Kubernetes Service configuration",
290
+ type: "Service type",
291
+ ingress: "Ingress configuration",
292
+ className: "Ingress class name",
293
+ hosts: "Ingress host rules",
294
+ tls: "Ingress TLS configuration",
295
+ autoscaling: "Horizontal pod autoscaling configuration",
296
+ minReplicas: "Minimum number of replicas",
297
+ maxReplicas: "Maximum number of replicas",
298
+ targetCPUUtilizationPercentage: "Target CPU utilization for autoscaling",
299
+ targetMemoryUtilizationPercentage: "Target memory utilization for autoscaling",
300
+ serviceAccount: "Service account configuration",
301
+ create: "Whether to create the resource",
302
+ name: "Resource name override",
303
+ annotations: "Additional annotations",
304
+ nodeSelector: "Node selector constraints",
305
+ tolerations: "Pod tolerations",
306
+ affinity: "Pod affinity rules",
307
+ podSecurityContext: "Pod-level security context",
308
+ securityContext: "Container-level security context",
309
+ livenessProbe: "Liveness probe configuration",
310
+ readinessProbe: "Readiness probe configuration",
311
+ persistence: "Persistent storage configuration",
312
+ size: "Storage size",
313
+ storageClass: "Storage class name",
314
+ config: "Application configuration",
315
+ schedule: "Cron schedule expression",
316
+ fullnameOverride: "Override the full release name",
317
+ nameOverride: "Override the chart name",
318
+ };
319
+
320
+ /**
321
+ * Enum detection — known string enums keyed by `parentKey.key` or just `key`.
322
+ */
323
+ const KEY_ENUMS: Record<string, string[]> = {
324
+ pullPolicy: ["Always", "IfNotPresent", "Never"],
325
+ "service.type": ["ClusterIP", "NodePort", "LoadBalancer", "ExternalName"],
326
+ "ingress.pathType": ["Prefix", "Exact", "ImplementationSpecific"],
327
+ restartPolicy: ["Always", "OnFailure", "Never"],
328
+ "updateStrategy.type": ["RollingUpdate", "Recreate"],
329
+ };
330
+
331
+ /**
332
+ * Numeric constraints keyed by key name.
333
+ */
334
+ const KEY_NUMERIC_CONSTRAINTS: Record<string, { minimum?: number; maximum?: number }> = {
335
+ replicaCount: { minimum: 0 },
336
+ port: { minimum: 1, maximum: 65535 },
337
+ containerPort: { minimum: 1, maximum: 65535 },
338
+ minReplicas: { minimum: 1 },
339
+ maxReplicas: { minimum: 1 },
340
+ targetCPUUtilizationPercentage: { minimum: 1, maximum: 100 },
341
+ targetMemoryUtilizationPercentage: { minimum: 1, maximum: 100 },
342
+ };
343
+
344
+ /**
345
+ * Generate values.schema.json from values defaults.
346
+ */
347
+ function generateValuesSchema(props: Record<string, unknown>): string {
348
+ function inferType(
349
+ value: unknown,
350
+ includeDefault: boolean = true,
351
+ keyName?: string,
352
+ parentKeyName?: string,
353
+ ): Record<string, unknown> {
354
+ if (value === null || value === undefined) return { type: "null" };
355
+
356
+ if (typeof value === "boolean") {
357
+ const schema: Record<string, unknown> = { type: "boolean" };
358
+ if (includeDefault) schema.default = value;
359
+ if (keyName && KEY_DESCRIPTIONS[keyName]) schema.description = KEY_DESCRIPTIONS[keyName];
360
+ return schema;
361
+ }
362
+
363
+ if (typeof value === "number") {
364
+ const schema: Record<string, unknown> = {
365
+ type: Number.isInteger(value) ? "integer" : "number",
366
+ };
367
+ if (includeDefault) schema.default = value;
368
+ if (keyName && KEY_DESCRIPTIONS[keyName]) schema.description = KEY_DESCRIPTIONS[keyName];
369
+ if (keyName && KEY_NUMERIC_CONSTRAINTS[keyName]) {
370
+ const constraints = KEY_NUMERIC_CONSTRAINTS[keyName];
371
+ if (constraints.minimum !== undefined) schema.minimum = constraints.minimum;
372
+ if (constraints.maximum !== undefined) schema.maximum = constraints.maximum;
373
+ }
374
+ return schema;
375
+ }
376
+
377
+ if (typeof value === "string") {
378
+ const schema: Record<string, unknown> = { type: "string" };
379
+ if (includeDefault && value !== "") schema.default = value;
380
+ if (keyName && KEY_DESCRIPTIONS[keyName]) schema.description = KEY_DESCRIPTIONS[keyName];
381
+
382
+ // Check enum — qualified key first, then bare key
383
+ const qualifiedKey = parentKeyName ? `${parentKeyName}.${keyName}` : undefined;
384
+ const enumValues = (qualifiedKey && KEY_ENUMS[qualifiedKey]) || (keyName && KEY_ENUMS[keyName]);
385
+ if (enumValues) schema.enum = enumValues;
386
+
387
+ return schema;
388
+ }
389
+
390
+ if (Array.isArray(value)) {
391
+ const schema: Record<string, unknown> = {
392
+ type: "array",
393
+ items: value.length > 0 ? inferType(value[0], false) : {},
394
+ };
395
+ if (includeDefault && value.length > 0) schema.default = value;
396
+ if (keyName && KEY_DESCRIPTIONS[keyName]) schema.description = KEY_DESCRIPTIONS[keyName];
397
+ return schema;
398
+ }
399
+
400
+ if (typeof value === "object") {
401
+ const obj = value as Record<string, unknown>;
402
+ const properties: Record<string, unknown> = {};
403
+ const requiredFields: string[] = [];
404
+
405
+ for (const [k, v] of Object.entries(obj)) {
406
+ properties[k] = inferType(v, true, k, keyName);
407
+ // Non-null, non-empty-string defaults suggest the field is expected
408
+ if (v !== null && v !== undefined && v !== "" && v !== false) {
409
+ requiredFields.push(k);
410
+ }
411
+ }
412
+
413
+ const schema: Record<string, unknown> = {
414
+ type: "object",
415
+ properties,
416
+ };
417
+ if (keyName && KEY_DESCRIPTIONS[keyName]) schema.description = KEY_DESCRIPTIONS[keyName];
418
+ if (requiredFields.length > 0) {
419
+ schema.required = requiredFields;
420
+ }
421
+ return schema;
422
+ }
423
+
424
+ return {};
425
+ }
426
+
427
+ const topLevel = inferType(props, false, undefined, undefined) as Record<string, unknown>;
428
+ return JSON.stringify(
429
+ { $schema: "http://json-schema.org/draft-07/schema#", ...topLevel },
430
+ null,
431
+ 2,
432
+ ) + "\n";
433
+ }
434
+
435
+ /**
436
+ * Emit a K8s resource as a Helm template YAML file.
437
+ */
438
+ function emitK8sTemplate(
439
+ name: string,
440
+ entityType: string,
441
+ props: Record<string, unknown>,
442
+ entityNames: Map<Declarable, string>,
443
+ hookAnnotations?: Record<string, string>,
444
+ ): string {
445
+ let gvk = resolveK8sGVK(entityType);
446
+
447
+ // Fallback: extract apiVersion/kind from props for CRD-based resources
448
+ if (!gvk && props.apiVersion && props.kind) {
449
+ gvk = {
450
+ apiVersion: props.apiVersion as string,
451
+ kind: props.kind as string,
452
+ };
453
+ }
454
+ if (!gvk) return "";
455
+
456
+ const walked = walkValue(props, entityNames, helmVisitor()) as Record<string, unknown>;
457
+ if (!walked) return "";
458
+
459
+ const manifest: Record<string, unknown> = {
460
+ apiVersion: gvk.apiVersion,
461
+ kind: gvk.kind,
462
+ };
463
+
464
+ // Build metadata
465
+ const metadata: Record<string, unknown> = walked.metadata as Record<string, unknown> ?? {};
466
+ if (!metadata.name) {
467
+ metadata.name = toFileName(name);
468
+ }
469
+
470
+ // Add hook annotations
471
+ if (hookAnnotations) {
472
+ const existing = (metadata.annotations ?? {}) as Record<string, unknown>;
473
+ metadata.annotations = { ...existing, ...hookAnnotations };
474
+ }
475
+
476
+ manifest.metadata = metadata;
477
+
478
+ // Build spec / specless body
479
+ if (SPECLESS_TYPES.has(gvk.kind)) {
480
+ for (const [key, value] of Object.entries(walked)) {
481
+ if (key !== "metadata") manifest[key] = value;
482
+ }
483
+ } else if (walked.spec !== undefined) {
484
+ manifest.spec = walked.spec;
485
+ for (const [key, value] of Object.entries(walked)) {
486
+ if (key !== "metadata" && key !== "spec") manifest[key] = value;
487
+ }
488
+ } else {
489
+ const spec: Record<string, unknown> = {};
490
+ for (const [key, value] of Object.entries(walked)) {
491
+ if (key !== "metadata") spec[key] = value;
492
+ }
493
+ if (Object.keys(spec).length > 0) manifest.spec = spec;
494
+ }
495
+
496
+ // Emit as YAML
497
+ const orderedKeys = ["apiVersion", "kind", "metadata", "spec"];
498
+ const lines: string[] = [];
499
+
500
+ for (const key of orderedKeys) {
501
+ if (manifest[key] !== undefined) {
502
+ lines.push(emitKeyValue(key, manifest[key]));
503
+ }
504
+ }
505
+ for (const [key, value] of Object.entries(manifest)) {
506
+ if (!orderedKeys.includes(key) && value !== undefined) {
507
+ lines.push(emitKeyValue(key, value));
508
+ }
509
+ }
510
+
511
+ return lines.join("\n") + "\n";
512
+ }
513
+
514
+ /**
515
+ * Generate .helmignore content.
516
+ */
517
+ function emitHelmignore(): string {
518
+ return `# Patterns to ignore when building packages.
519
+ .DS_Store
520
+ .git
521
+ .gitignore
522
+ .bzr
523
+ .bzrignore
524
+ .hg
525
+ .hgignore
526
+ .svn
527
+ *.swp
528
+ *.bak
529
+ *.tmp
530
+ *.orig
531
+ *~
532
+ .project
533
+ .idea
534
+ *.tmproj
535
+ .vscode
536
+ `;
537
+ }
538
+
539
+ // ── Conditional / intrinsic detection ──────────────────────
540
+
541
+ /**
542
+ * Check if a value is a HelmConditional (If wrapper).
543
+ */
544
+ function isHelmConditional(value: unknown): value is HelmConditional {
545
+ if (typeof value !== "object" || value === null) return false;
546
+ return INTRINSIC_MARKER in value && "condition" in value && "body" in value;
547
+ }
548
+
549
+ /**
550
+ * Check if a value is a Declarable with an entityType.
551
+ */
552
+ function hasEntityType(value: unknown): value is Record<string, unknown> & { entityType: string } {
553
+ return typeof value === "object" && value !== null && "entityType" in value &&
554
+ typeof (value as Record<string, unknown>).entityType === "string";
555
+ }
556
+
557
+ /**
558
+ * Emit a Chart.yaml dependencies block from HelmDependency property entities.
559
+ */
560
+ /**
561
+ * Key mapping for dependency props that differ between JS and YAML.
562
+ */
563
+ const DEP_KEY_MAP: Record<string, string> = {
564
+ importValues: "import-values",
565
+ };
566
+
567
+ function emitDependencies(deps: Record<string, unknown>[]): string {
568
+ const lines: string[] = ["dependencies:"];
569
+ for (const dep of deps) {
570
+ const orderedKeys = ["name", "version", "repository", "condition", "tags", "enabled", "importValues", "alias"];
571
+ const entries: [string, unknown][] = [];
572
+ for (const key of orderedKeys) {
573
+ if (dep[key] !== undefined) entries.push([DEP_KEY_MAP[key] ?? key, dep[key]]);
574
+ }
575
+ for (const [key, val] of Object.entries(dep)) {
576
+ if (!orderedKeys.includes(key) && val !== undefined) entries.push([key, val]);
577
+ }
578
+ if (entries.length > 0) {
579
+ const [firstKey, firstVal] = entries[0];
580
+ lines.push(` - ${firstKey}: ${emitHelmYAML(firstVal, 2).trimStart()}`);
581
+ for (let i = 1; i < entries.length; i++) {
582
+ const [key, val] = entries[i];
583
+ const emitted = emitHelmYAML(val, 2);
584
+ if (emitted.startsWith("\n")) {
585
+ lines.push(` ${key}:${emitted}`);
586
+ } else {
587
+ lines.push(` ${key}: ${emitted}`);
588
+ }
589
+ }
590
+ }
591
+ }
592
+ return lines.join("\n") + "\n";
593
+ }
594
+
595
+ /**
596
+ * Emit a Chart.yaml maintainers block from HelmMaintainer property entities.
597
+ */
598
+ function emitMaintainers(maintainers: Record<string, unknown>[]): string {
599
+ const lines: string[] = ["maintainers:"];
600
+ for (const m of maintainers) {
601
+ const orderedKeys = ["name", "email", "url"];
602
+ const entries: [string, unknown][] = [];
603
+ for (const key of orderedKeys) {
604
+ if (m[key] !== undefined) entries.push([key, m[key]]);
605
+ }
606
+ for (const [key, val] of Object.entries(m)) {
607
+ if (!orderedKeys.includes(key) && val !== undefined) entries.push([key, val]);
608
+ }
609
+ if (entries.length > 0) {
610
+ const [firstKey, firstVal] = entries[0];
611
+ lines.push(` - ${firstKey}: ${emitHelmYAML(firstVal, 2).trimStart()}`);
612
+ for (let i = 1; i < entries.length; i++) {
613
+ const [key, val] = entries[i];
614
+ const emitted = emitHelmYAML(val, 2);
615
+ if (emitted.startsWith("\n")) {
616
+ lines.push(` ${key}:${emitted}`);
617
+ } else {
618
+ lines.push(` ${key}: ${emitted}`);
619
+ }
620
+ }
621
+ }
622
+ }
623
+ return lines.join("\n") + "\n";
624
+ }
625
+
626
+ /**
627
+ * Helm chart serializer implementation.
628
+ */
629
+ export const helmSerializer: Serializer = {
630
+ name: "helm",
631
+ rulePrefix: "WHM",
632
+
633
+ serialize(entities: Map<string, Declarable>, _outputs?: LexiconOutput[]): SerializerResult {
634
+ const entityNames = new Map<Declarable, string>();
635
+ for (const [name, entity] of entities) {
636
+ entityNames.set(entity, name);
637
+ }
638
+
639
+ const files: Record<string, string> = {};
640
+
641
+ let chartName = "my-chart";
642
+ let chartProps: Record<string, unknown> = {};
643
+ let valuesProps: Record<string, unknown> = {};
644
+ let hasValues = false;
645
+ let notesContent: string | undefined;
646
+ const dependencies: Record<string, unknown>[] = [];
647
+ const maintainers: Record<string, unknown>[] = [];
648
+
649
+ // First pass: extract Helm-specific resources and collect metadata
650
+ for (const [_name, entity] of entities) {
651
+ if (!hasEntityType(entity)) continue;
652
+ const entityType = entity.entityType;
653
+
654
+ if (entityType === "Helm::Chart") {
655
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
656
+ chartProps = props ?? {};
657
+ chartName = (chartProps.name as string) ?? chartName;
658
+ } else if (entityType === "Helm::Values") {
659
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
660
+ valuesProps = props ?? {};
661
+ hasValues = true;
662
+ } else if (entityType === "Helm::Notes") {
663
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
664
+ notesContent = (props?.content as string) ?? "";
665
+ } else if (entityType === "Helm::Dependency") {
666
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
667
+ if (props) dependencies.push(props);
668
+ } else if (entityType === "Helm::Maintainer") {
669
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
670
+ if (props) maintainers.push(props);
671
+ } else if (entityType === "Helm::CRD") {
672
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
673
+ if (props?.content) {
674
+ const filename = (props.filename as string) ?? `${toFileName(_name)}.yaml`;
675
+ files[`crds/${filename}`] = props.content as string;
676
+ }
677
+ }
678
+ }
679
+
680
+ // Emit Chart.yaml
681
+ if (!chartProps.apiVersion) chartProps.apiVersion = "v2";
682
+ if (!chartProps.name) chartProps.name = chartName;
683
+ if (!chartProps.version) chartProps.version = "0.1.0";
684
+ if (!chartProps.type) chartProps.type = "application";
685
+
686
+ // Inject collected maintainers into chart props for ordered emission
687
+ if (maintainers.length > 0) {
688
+ chartProps.maintainers = maintainers;
689
+ }
690
+ let chartYaml = emitChartYaml(chartProps);
691
+ if (dependencies.length > 0) {
692
+ chartYaml += emitDependencies(dependencies);
693
+ }
694
+ files["Chart.yaml"] = chartYaml;
695
+
696
+ // Emit values.yaml
697
+ files["values.yaml"] = emitValuesYaml(valuesProps);
698
+
699
+ // Emit values.schema.json if we have values
700
+ if (hasValues && Object.keys(valuesProps).length > 0) {
701
+ files["values.schema.json"] = generateValuesSchema(valuesProps);
702
+ }
703
+
704
+ // Emit .helmignore
705
+ files[".helmignore"] = emitHelmignore();
706
+
707
+ // Emit _helpers.tpl
708
+ files["templates/_helpers.tpl"] = generateHelpers({ chartName });
709
+
710
+ // Second pass: emit K8s resources as templates
711
+ for (const [name, entity] of entities) {
712
+ const raw = entity as unknown;
713
+
714
+ // Handle resource-level If(condition, resource) — HelmConditional wrapping a Declarable
715
+ if (isHelmConditional(raw)) {
716
+ const conditional = raw;
717
+ const innerEntity = conditional.body;
718
+ if (hasEntityType(innerEntity) && (innerEntity.entityType as string).startsWith("K8s::")) {
719
+ const entityType = innerEntity.entityType as string;
720
+ const props = (innerEntity as Record<string, unknown>).props as Record<string, unknown>;
721
+ if (props) {
722
+ const fileName = toFileName(name);
723
+ const templateContent = emitK8sTemplate(name, entityType, props, entityNames);
724
+ if (templateContent) {
725
+ files[`templates/${fileName}.yaml`] =
726
+ `{{- if ${conditional.condition} }}\n${templateContent}{{- end }}\n`;
727
+ }
728
+ }
729
+ }
730
+ continue;
731
+ }
732
+
733
+ if (!hasEntityType(raw)) continue;
734
+ const entityType = (raw as Record<string, unknown>).entityType as string;
735
+
736
+ if (isPropertyDeclarable(entity)) continue;
737
+
738
+ // Skip Helm-specific resources (already handled)
739
+ if (entityType.startsWith("Helm::")) continue;
740
+
741
+ // Handle K8s resources
742
+ if (entityType.startsWith("K8s::")) {
743
+ const props = (raw as Record<string, unknown>).props as Record<string, unknown>;
744
+ if (!props) continue;
745
+
746
+ const fileName = toFileName(name);
747
+ const templateContent = emitK8sTemplate(name, entityType, props, entityNames);
748
+ if (templateContent) {
749
+ files[`templates/${fileName}.yaml`] = templateContent;
750
+ }
751
+ }
752
+ }
753
+
754
+ // Handle Helm::Test resources
755
+ for (const [name, entity] of entities) {
756
+ if (!hasEntityType(entity)) continue;
757
+ const entityType = (entity as Record<string, unknown>).entityType as string;
758
+ if (entityType === "Helm::Test") {
759
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
760
+ if (!props) continue;
761
+
762
+ const fileName = toFileName(name);
763
+
764
+ // If the test has a nested K8s Declarable as `resource`, use it
765
+ const resource = props.resource;
766
+ if (hasEntityType(resource) && (resource.entityType as string).startsWith("K8s::")) {
767
+ const resType = resource.entityType as string;
768
+ const resProps = (resource as Record<string, unknown>).props as Record<string, unknown>;
769
+ if (resProps) {
770
+ const templateContent = emitK8sTemplate(name, resType, resProps, entityNames, { "helm.sh/hook": "test" });
771
+ if (templateContent) {
772
+ files[`templates/tests/${fileName}.yaml`] = templateContent;
773
+ }
774
+ }
775
+ } else {
776
+ // Treat props directly as Pod spec
777
+ const templateContent = emitK8sTemplate(
778
+ name,
779
+ "K8s::Core::Pod",
780
+ props,
781
+ entityNames,
782
+ { "helm.sh/hook": "test" },
783
+ );
784
+ if (templateContent) {
785
+ files[`templates/tests/${fileName}.yaml`] = templateContent;
786
+ }
787
+ }
788
+ }
789
+ }
790
+
791
+ // Handle Helm::Hook property wrappers (property kind, but we iterate all)
792
+ for (const [name, entity] of entities) {
793
+ if (!hasEntityType(entity)) continue;
794
+ const entityType = (entity as Record<string, unknown>).entityType as string;
795
+ if (entityType === "Helm::Hook") {
796
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
797
+ if (!props) continue;
798
+
799
+ const hook = props.hook as string;
800
+ const weight = props.weight as number | undefined;
801
+ const deletePolicy = props.deletePolicy as string | undefined;
802
+ const resource = props.resource as Declarable | undefined;
803
+
804
+ if (resource && hasEntityType(resource)) {
805
+ const resType = resource.entityType as string;
806
+ const resProps = (resource as Record<string, unknown>).props as Record<string, unknown>;
807
+ if (resProps && resType) {
808
+ const hookAnnotations: Record<string, string> = {
809
+ "helm.sh/hook": hook,
810
+ };
811
+ if (weight !== undefined) hookAnnotations["helm.sh/hook-weight"] = String(weight);
812
+ if (deletePolicy) hookAnnotations["helm.sh/hook-delete-policy"] = deletePolicy;
813
+
814
+ const fileName = toFileName(name);
815
+ const templateContent = emitK8sTemplate(name, resType, resProps, entityNames, hookAnnotations);
816
+ if (templateContent) {
817
+ files[`templates/${fileName}.yaml`] = templateContent;
818
+ }
819
+ }
820
+ }
821
+ }
822
+ }
823
+
824
+ // Emit NOTES.txt
825
+ if (notesContent) {
826
+ files["templates/NOTES.txt"] = notesContent;
827
+ }
828
+
829
+ // Primary content is Chart.yaml (used as the build output identifier)
830
+ return {
831
+ primary: files["Chart.yaml"],
832
+ files,
833
+ };
834
+ },
835
+ };