@shepherdjerred/helm-types 1.1.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.
@@ -0,0 +1,536 @@
1
+ import type { JSONSchemaProperty, TypeScriptInterface, TypeProperty } from "./types.js";
2
+ import type { HelmValue } from "./schemas.js";
3
+ import {
4
+ StringSchema,
5
+ ActualNumberSchema,
6
+ ActualBooleanSchema,
7
+ NullSchema,
8
+ UndefinedSchema,
9
+ ArraySchema,
10
+ StringBooleanSchema,
11
+ HelmValueSchema,
12
+ } from "./schemas.js";
13
+ import { shouldAllowArbitraryProps, isK8sResourceSpec } from "./config.js";
14
+ import { sanitizePropertyName, sanitizeTypeName, capitalizeFirst } from "./utils.js";
15
+
16
+ /**
17
+ * Augment a Kubernetes resource spec interface with both requests and limits.
18
+ * If only one is present, copy its type structure to the other.
19
+ */
20
+ function augmentK8sResourceSpec(iface: TypeScriptInterface): void {
21
+ const hasRequests = "requests" in iface.properties;
22
+ const hasLimits = "limits" in iface.properties;
23
+
24
+ // If we have requests but not limits, add limits with the same structure
25
+ if (hasRequests && !hasLimits) {
26
+ const requestsProp = iface.properties["requests"];
27
+ if (requestsProp) {
28
+ // Create limits property with the same type but different name for the nested interface
29
+ const limitsTypeName = requestsProp.type.replace("Requests", "Limits");
30
+
31
+ // If there's a nested interface, create a copy for limits
32
+ if (requestsProp.nested) {
33
+ const limitsNested: TypeScriptInterface = {
34
+ name: limitsTypeName,
35
+ properties: { ...requestsProp.nested.properties },
36
+ allowArbitraryProps: requestsProp.nested.allowArbitraryProps,
37
+ };
38
+ iface.properties["limits"] = {
39
+ type: limitsTypeName,
40
+ optional: true,
41
+ nested: limitsNested,
42
+ description: "Kubernetes resource limits (memory, cpu, etc.)",
43
+ };
44
+ } else {
45
+ // No nested interface, just copy the type
46
+ iface.properties["limits"] = {
47
+ type: requestsProp.type,
48
+ optional: true,
49
+ description: "Kubernetes resource limits (memory, cpu, etc.)",
50
+ };
51
+ }
52
+ }
53
+ }
54
+
55
+ // If we have limits but not requests, add requests with the same structure
56
+ if (hasLimits && !hasRequests) {
57
+ const limitsProp = iface.properties["limits"];
58
+ if (limitsProp) {
59
+ const requestsTypeName = limitsProp.type.replace("Limits", "Requests");
60
+
61
+ if (limitsProp.nested) {
62
+ const requestsNested: TypeScriptInterface = {
63
+ name: requestsTypeName,
64
+ properties: { ...limitsProp.nested.properties },
65
+ allowArbitraryProps: limitsProp.nested.allowArbitraryProps,
66
+ };
67
+ iface.properties["requests"] = {
68
+ type: requestsTypeName,
69
+ optional: true,
70
+ nested: requestsNested,
71
+ description: "Kubernetes resource requests (memory, cpu, etc.)",
72
+ };
73
+ } else {
74
+ iface.properties["requests"] = {
75
+ type: limitsProp.type,
76
+ optional: true,
77
+ description: "Kubernetes resource requests (memory, cpu, etc.)",
78
+ };
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Convert JSON schema type to TypeScript type string
86
+ */
87
+ export function jsonSchemaToTypeScript(schema: JSONSchemaProperty): string {
88
+ // Handle oneOf - union of types
89
+ if (schema.oneOf) {
90
+ const types = schema.oneOf.map((s) => jsonSchemaToTypeScript(s));
91
+ return types.join(" | ");
92
+ }
93
+
94
+ // Handle anyOf - union of types
95
+ if (schema.anyOf) {
96
+ const types = schema.anyOf.map((s) => jsonSchemaToTypeScript(s));
97
+ return types.join(" | ");
98
+ }
99
+
100
+ // Handle enum
101
+ if (schema.enum) {
102
+ return schema.enum.map((v) => (StringSchema.safeParse(v).success ? `"${String(v)}"` : String(v))).join(" | ");
103
+ }
104
+
105
+ // Handle array type
106
+ if (schema.type === "array" && schema.items) {
107
+ const itemType = jsonSchemaToTypeScript(schema.items);
108
+ return `${itemType}[]`;
109
+ }
110
+
111
+ // Handle basic types
112
+ if (StringSchema.safeParse(schema.type).success) {
113
+ switch (schema.type) {
114
+ case "string":
115
+ return "string";
116
+ case "number":
117
+ case "integer":
118
+ return "number";
119
+ case "boolean":
120
+ return "boolean";
121
+ case "object":
122
+ return "object";
123
+ case "array":
124
+ return "unknown[]";
125
+ case "null":
126
+ return "null";
127
+ default:
128
+ return "unknown";
129
+ }
130
+ }
131
+
132
+ // Handle multiple types
133
+ const arrayTypeCheck = ArraySchema.safeParse(schema.type);
134
+ if (arrayTypeCheck.success) {
135
+ return arrayTypeCheck.data
136
+ .map((t: unknown) => {
137
+ if (!StringSchema.safeParse(t).success) return "unknown";
138
+ const typeStr = String(t);
139
+ switch (typeStr) {
140
+ case "string":
141
+ return "string";
142
+ case "number":
143
+ case "integer":
144
+ return "number";
145
+ case "boolean":
146
+ return "boolean";
147
+ case "object":
148
+ return "object";
149
+ case "array":
150
+ return "unknown[]";
151
+ case "null":
152
+ return "null";
153
+ default:
154
+ return "unknown";
155
+ }
156
+ })
157
+ .join(" | ");
158
+ }
159
+
160
+ return "unknown";
161
+ }
162
+
163
+ /**
164
+ * Infer TypeScript type from actual runtime value
165
+ */
166
+ export function inferTypeFromValue(value: unknown): string | null {
167
+ // Check null/undefined
168
+ if (NullSchema.safeParse(value).success || UndefinedSchema.safeParse(value).success) {
169
+ return null;
170
+ }
171
+
172
+ // Check for actual boolean
173
+ if (ActualBooleanSchema.safeParse(value).success) {
174
+ return "boolean";
175
+ }
176
+
177
+ // Check for actual number
178
+ if (ActualNumberSchema.safeParse(value).success) {
179
+ return "number";
180
+ }
181
+
182
+ // Check if it's a string that looks like a boolean
183
+ if (StringBooleanSchema.safeParse(value).success) {
184
+ return "boolean";
185
+ }
186
+
187
+ // Check if it's a string that looks like a number
188
+ const stringCheck = StringSchema.safeParse(value);
189
+ if (stringCheck.success) {
190
+ const trimmed = stringCheck.data.trim();
191
+ if (trimmed !== "" && !isNaN(Number(trimmed)) && isFinite(Number(trimmed))) {
192
+ return "number";
193
+ }
194
+ }
195
+
196
+ // Check for array
197
+ if (ArraySchema.safeParse(value).success) {
198
+ return "array";
199
+ }
200
+
201
+ // Check for object
202
+ if (HelmValueSchema.safeParse(value).success) {
203
+ return "object";
204
+ }
205
+
206
+ // Plain string
207
+ if (StringSchema.safeParse(value).success) {
208
+ return "string";
209
+ }
210
+
211
+ return "unknown";
212
+ }
213
+
214
+ /**
215
+ * Check if inferred type is compatible with schema type
216
+ */
217
+ export function typesAreCompatible(inferredType: string, schemaType: string): boolean {
218
+ // Exact match
219
+ if (inferredType === schemaType) {
220
+ return true;
221
+ }
222
+
223
+ // Check if the inferred type is part of a union in the schema
224
+ // For example: schemaType might be "number | \"default\"" and inferredType is "string"
225
+ const schemaTypes = schemaType.split("|").map((t) => t.trim().replace(/^["']|["']$/g, ""));
226
+
227
+ // If schema is a union, check if inferred type is compatible with any part
228
+ if (schemaTypes.length > 1) {
229
+ for (const st of schemaTypes) {
230
+ // Handle quoted strings in unions (like "default")
231
+ if (st.startsWith('"') && st.endsWith('"') && inferredType === "string") {
232
+ return true;
233
+ }
234
+ if (st === inferredType) {
235
+ return true;
236
+ }
237
+ // Arrays
238
+ if (st.endsWith("[]") && inferredType === "array") {
239
+ return true;
240
+ }
241
+ }
242
+ }
243
+
244
+ // Handle array types
245
+ if (schemaType.endsWith("[]") && inferredType === "array") {
246
+ return true;
247
+ }
248
+
249
+ // Handle specific string literals - if schema expects specific strings and value is a string
250
+ if (schemaType.includes('"') && inferredType === "string") {
251
+ return true;
252
+ }
253
+
254
+ // unknown is compatible with everything (schema might be less specific)
255
+ if (schemaType === "unknown" || inferredType === "unknown") {
256
+ return true;
257
+ }
258
+
259
+ return false;
260
+ }
261
+
262
+ /**
263
+ * Convert Helm values to TypeScript interface
264
+ */
265
+ export function convertToTypeScriptInterface(
266
+ values: HelmValue,
267
+ interfaceName: string,
268
+ schema?: JSONSchemaProperty | null,
269
+ yamlComments?: Map<string, string>,
270
+ keyPrefix = "",
271
+ chartName?: string,
272
+ ): TypeScriptInterface {
273
+ const properties: Record<string, TypeProperty> = {};
274
+ const schemaProps = schema?.properties;
275
+
276
+ for (const [key, value] of Object.entries(values)) {
277
+ const sanitizedKey = sanitizePropertyName(key);
278
+ const typeNameSuffix = sanitizeTypeName(key);
279
+ const propertySchema = schemaProps?.[key];
280
+ const fullKey = keyPrefix ? `${keyPrefix}.${key}` : key;
281
+ const yamlComment = yamlComments?.get(fullKey);
282
+
283
+ properties[sanitizedKey] = convertValueToProperty(
284
+ value,
285
+ `${interfaceName}${capitalizeFirst(typeNameSuffix)}`,
286
+ propertySchema,
287
+ key, // Pass the property name for better warnings
288
+ yamlComment,
289
+ yamlComments,
290
+ fullKey,
291
+ chartName,
292
+ );
293
+ }
294
+
295
+ // Check if this interface should allow arbitrary properties
296
+ const allowArbitraryProps = chartName
297
+ ? shouldAllowArbitraryProps(keyPrefix, chartName, keyPrefix.split(".").pop() ?? "", yamlComments?.get(keyPrefix))
298
+ : false;
299
+
300
+ return {
301
+ name: interfaceName,
302
+ properties,
303
+ allowArbitraryProps,
304
+ };
305
+ }
306
+
307
+ function convertValueToProperty(
308
+ value: unknown,
309
+ nestedTypeName: string,
310
+ schema?: JSONSchemaProperty,
311
+ propertyName?: string,
312
+ yamlComment?: string,
313
+ yamlComments?: Map<string, string>,
314
+ fullKey?: string,
315
+ chartName?: string,
316
+ ): TypeProperty {
317
+ // If we have a JSON schema for this property, prefer it over inference
318
+ if (schema) {
319
+ // First, infer the type from the actual value for comparison
320
+ const inferredType = inferTypeFromValue(value);
321
+ const schemaType = jsonSchemaToTypeScript(schema);
322
+
323
+ // Check if schema and inferred types are in agreement
324
+ if (inferredType && !typesAreCompatible(inferredType, schemaType)) {
325
+ const propName = propertyName ? `'${propertyName}': ` : "";
326
+ console.warn(
327
+ ` ⚠️ Type mismatch for ${propName}Schema says '${schemaType}' but value suggests '${inferredType}' (value: ${String(value).substring(0, 50)})`,
328
+ );
329
+ }
330
+
331
+ // Merge description from schema and YAML comments
332
+ let description = schema.description;
333
+ if (yamlComment) {
334
+ if (description) {
335
+ // If both exist, combine them
336
+ description = `${yamlComment}\n\n${description}`;
337
+ } else {
338
+ description = yamlComment;
339
+ }
340
+ }
341
+ const defaultValue = schema.default !== undefined ? schema.default : value;
342
+
343
+ // If schema defines it as an object with properties, recurse
344
+ const helmValueCheckForProps = HelmValueSchema.safeParse(value);
345
+ if (schema.properties && helmValueCheckForProps.success) {
346
+ const nestedInterface = convertToTypeScriptInterface(
347
+ helmValueCheckForProps.data,
348
+ nestedTypeName,
349
+ schema,
350
+ yamlComments,
351
+ fullKey,
352
+ chartName,
353
+ );
354
+ return {
355
+ type: nestedTypeName,
356
+ optional: true,
357
+ nested: nestedInterface,
358
+ description,
359
+ default: defaultValue,
360
+ };
361
+ }
362
+
363
+ // Otherwise, use the schema type directly
364
+ const tsType = schemaType;
365
+
366
+ // Handle object types without explicit properties
367
+ const helmValueCheckForObject = HelmValueSchema.safeParse(value);
368
+ if (tsType === "object" && helmValueCheckForObject.success) {
369
+ const nestedInterface = convertToTypeScriptInterface(
370
+ helmValueCheckForObject.data,
371
+ nestedTypeName,
372
+ undefined,
373
+ yamlComments,
374
+ fullKey,
375
+ chartName,
376
+ );
377
+ return {
378
+ type: nestedTypeName,
379
+ optional: true,
380
+ nested: nestedInterface,
381
+ description,
382
+ default: defaultValue,
383
+ };
384
+ }
385
+
386
+ return { type: tsType, optional: true, description, default: defaultValue };
387
+ }
388
+
389
+ // Fall back to runtime type inference when no schema is available
390
+ // Use Zod schemas for robust type detection
391
+ // IMPORTANT: Check for complex types (arrays, objects) BEFORE primitive types with coercion
392
+
393
+ // Check for null/undefined first
394
+ if (NullSchema.safeParse(value).success || UndefinedSchema.safeParse(value).success) {
395
+ return { type: "unknown", optional: true };
396
+ }
397
+
398
+ // Check for array (before coercion checks, as coercion can convert arrays to true)
399
+ const arrayResult = ArraySchema.safeParse(value);
400
+ if (arrayResult.success) {
401
+ const arrayValue = arrayResult.data;
402
+ if (arrayValue.length === 0) {
403
+ return { type: "unknown[]", optional: true };
404
+ }
405
+
406
+ // Sample multiple elements for better type inference
407
+ const elementTypes = new Set<string>();
408
+ const elementTypeProps: TypeProperty[] = [];
409
+ const sampleSize = Math.min(arrayValue.length, 3); // Check up to 3 elements
410
+
411
+ for (let i = 0; i < sampleSize; i++) {
412
+ const elementType = convertValueToProperty(arrayValue[i], nestedTypeName);
413
+ elementTypes.add(elementType.type);
414
+ elementTypeProps.push(elementType);
415
+ }
416
+
417
+ // If all elements have the same type, use that
418
+ if (elementTypes.size === 1) {
419
+ const elementType = Array.from(elementTypes)[0];
420
+ const elementProp = elementTypeProps[0];
421
+ if (elementType && elementProp) {
422
+ // For object array elements, we need to create a proper interface for the array element
423
+ if (elementProp.nested) {
424
+ // Create a new interface name for array elements
425
+ const arrayElementTypeName = `${nestedTypeName}Element`;
426
+
427
+ // Check if array elements should allow arbitrary properties
428
+ // Array elements inherit extensibility from their parent array
429
+ const allowArbitraryProps =
430
+ chartName && fullKey
431
+ ? shouldAllowArbitraryProps(fullKey, chartName, propertyName ?? "", yamlComment)
432
+ : false;
433
+
434
+ const arrayElementInterface: TypeScriptInterface = {
435
+ name: arrayElementTypeName,
436
+ properties: elementProp.nested.properties,
437
+ allowArbitraryProps,
438
+ };
439
+
440
+ return {
441
+ type: `${arrayElementTypeName}[]`,
442
+ optional: true,
443
+ nested: arrayElementInterface,
444
+ };
445
+ } else {
446
+ return {
447
+ type: `${elementType}[]`,
448
+ optional: true,
449
+ };
450
+ }
451
+ }
452
+ }
453
+
454
+ // If mixed types, use union type for common cases
455
+ const types = Array.from(elementTypes).sort();
456
+ if (types.length <= 3 && types.every((t) => ["string", "number", "boolean"].includes(t))) {
457
+ return {
458
+ type: `(${types.join(" | ")})[]`,
459
+ optional: true,
460
+ };
461
+ }
462
+
463
+ // Otherwise fall back to unknown[]
464
+ return { type: "unknown[]", optional: true };
465
+ }
466
+
467
+ // Check for object (before primitive coercion checks)
468
+ const objectResult = HelmValueSchema.safeParse(value);
469
+ if (objectResult.success) {
470
+ const nestedInterface = convertToTypeScriptInterface(
471
+ objectResult.data,
472
+ nestedTypeName,
473
+ undefined,
474
+ yamlComments,
475
+ fullKey,
476
+ chartName,
477
+ );
478
+
479
+ // Augment K8s resource specs with both requests and limits
480
+ if (propertyName && isK8sResourceSpec(propertyName)) {
481
+ augmentK8sResourceSpec(nestedInterface);
482
+ }
483
+
484
+ return {
485
+ type: nestedTypeName,
486
+ optional: true,
487
+ nested: nestedInterface,
488
+ description: yamlComment,
489
+ default: value,
490
+ };
491
+ }
492
+
493
+ // Now check for primitives - first actual runtime types, then coerced string types
494
+ // This prevents objects from being coerced to booleans
495
+
496
+ // Check for actual runtime boolean (true/false)
497
+ if (ActualBooleanSchema.safeParse(value).success) {
498
+ return { type: "boolean", optional: true, description: yamlComment, default: value };
499
+ }
500
+
501
+ // Check for actual runtime number
502
+ if (ActualNumberSchema.safeParse(value).success) {
503
+ return { type: "number", optional: true, description: yamlComment, default: value };
504
+ }
505
+
506
+ // Check if it's a string that represents a boolean ("true", "FALSE", etc.)
507
+ if (StringBooleanSchema.safeParse(value).success) {
508
+ return { type: "boolean", optional: true, description: yamlComment, default: value };
509
+ }
510
+
511
+ // Check if it's a string that represents a number ("15", "0", etc.)
512
+ // Only treat non-empty strings that parse as numbers as numbers
513
+ const stringCheckForNumber = StringSchema.safeParse(value);
514
+ if (stringCheckForNumber.success) {
515
+ const trimmed = stringCheckForNumber.data.trim();
516
+ // Don't treat empty strings or purely whitespace as numbers
517
+ if (trimmed !== "" && !isNaN(Number(trimmed)) && isFinite(Number(trimmed))) {
518
+ return { type: "number", optional: true, description: yamlComment, default: value };
519
+ }
520
+ }
521
+
522
+ // Check for plain string (strings that don't look like numbers or booleans)
523
+ const stringCheckForPlain = StringSchema.safeParse(value);
524
+ if (stringCheckForPlain.success) {
525
+ // Special case: "default" is often used as a sentinel value in Helm charts
526
+ // that can be overridden with actual typed values (numbers, booleans, etc.)
527
+ if (stringCheckForPlain.data === "default") {
528
+ return { type: "string | number | boolean", optional: true, description: yamlComment, default: value };
529
+ }
530
+ return { type: "string", optional: true, description: yamlComment, default: value };
531
+ }
532
+
533
+ // Fallback for any unrecognized type
534
+ console.warn(`Unrecognized value type for: ${String(value)}, using 'unknown'`);
535
+ return { type: "unknown", optional: true, description: yamlComment };
536
+ }