@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,440 @@
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
+ HelmValueSchema,
11
+ StringBooleanSchema,
12
+ } from "./schemas.js";
13
+ import { capitalizeFirst, sanitizePropertyName, sanitizeTypeName } from "./utils.js";
14
+
15
+ /**
16
+ * Convert JSON schema type to TypeScript type string
17
+ */
18
+ export function jsonSchemaToTypeScript(schema: JSONSchemaProperty): string {
19
+ // Handle oneOf - union of types
20
+ if (schema.oneOf) {
21
+ const types = schema.oneOf.map((s) => jsonSchemaToTypeScript(s));
22
+ return types.join(" | ");
23
+ }
24
+
25
+ // Handle anyOf - union of types
26
+ if (schema.anyOf) {
27
+ const types = schema.anyOf.map((s) => jsonSchemaToTypeScript(s));
28
+ return types.join(" | ");
29
+ }
30
+
31
+ // Handle enum
32
+ if (schema.enum) {
33
+ return schema.enum.map((v) => (StringSchema.safeParse(v).success ? `"${String(v)}"` : String(v))).join(" | ");
34
+ }
35
+
36
+ // Handle array type
37
+ if (schema.type === "array" && schema.items) {
38
+ const itemType = jsonSchemaToTypeScript(schema.items);
39
+ return `${itemType}[]`;
40
+ }
41
+
42
+ // Handle basic types
43
+ if (StringSchema.safeParse(schema.type).success) {
44
+ switch (schema.type) {
45
+ case "string":
46
+ return "string";
47
+ case "number":
48
+ case "integer":
49
+ return "number";
50
+ case "boolean":
51
+ return "boolean";
52
+ case "object":
53
+ return "object";
54
+ case "array":
55
+ return "unknown[]";
56
+ case "null":
57
+ return "null";
58
+ default:
59
+ return "unknown";
60
+ }
61
+ }
62
+
63
+ // Handle multiple types
64
+ const arrayTypeCheck = ArraySchema.safeParse(schema.type);
65
+ if (arrayTypeCheck.success) {
66
+ return arrayTypeCheck.data
67
+ .map((t: unknown) => {
68
+ if (!StringSchema.safeParse(t).success) return "unknown";
69
+ const typeStr = String(t);
70
+ switch (typeStr) {
71
+ case "string":
72
+ return "string";
73
+ case "number":
74
+ case "integer":
75
+ return "number";
76
+ case "boolean":
77
+ return "boolean";
78
+ case "object":
79
+ return "object";
80
+ case "array":
81
+ return "unknown[]";
82
+ case "null":
83
+ return "null";
84
+ default:
85
+ return "unknown";
86
+ }
87
+ })
88
+ .join(" | ");
89
+ }
90
+
91
+ return "unknown";
92
+ }
93
+
94
+ /**
95
+ * Infer TypeScript type from actual runtime value
96
+ */
97
+ export function inferTypeFromValue(value: unknown): string | null {
98
+ // Check null/undefined
99
+ if (NullSchema.safeParse(value).success || UndefinedSchema.safeParse(value).success) {
100
+ return null;
101
+ }
102
+
103
+ // Check for actual boolean
104
+ if (ActualBooleanSchema.safeParse(value).success) {
105
+ return "boolean";
106
+ }
107
+
108
+ // Check for actual number
109
+ if (ActualNumberSchema.safeParse(value).success) {
110
+ return "number";
111
+ }
112
+
113
+ // Check if it's a string that looks like a boolean
114
+ if (StringBooleanSchema.safeParse(value).success) {
115
+ return "boolean";
116
+ }
117
+
118
+ // Check if it's a string that looks like a number
119
+ const stringCheck = StringSchema.safeParse(value);
120
+ if (stringCheck.success) {
121
+ const trimmed = stringCheck.data.trim();
122
+ if (trimmed !== "" && !isNaN(Number(trimmed)) && isFinite(Number(trimmed))) {
123
+ return "number";
124
+ }
125
+ }
126
+
127
+ // Check for array
128
+ if (ArraySchema.safeParse(value).success) {
129
+ return "array";
130
+ }
131
+
132
+ // Check for object
133
+ if (HelmValueSchema.safeParse(value).success) {
134
+ return "object";
135
+ }
136
+
137
+ // Plain string
138
+ if (StringSchema.safeParse(value).success) {
139
+ return "string";
140
+ }
141
+
142
+ return "unknown";
143
+ }
144
+
145
+ /**
146
+ * Check if inferred type is compatible with schema type
147
+ */
148
+ export function typesAreCompatible(inferredType: string, schemaType: string): boolean {
149
+ // Exact match
150
+ if (inferredType === schemaType) {
151
+ return true;
152
+ }
153
+
154
+ // Check if the inferred type is part of a union in the schema
155
+ // For example: schemaType might be "number | \"default\"" and inferredType is "string"
156
+ const schemaTypes = schemaType.split("|").map((t) => t.trim().replace(/^["']|["']$/g, ""));
157
+
158
+ // If schema is a union, check if inferred type is compatible with any part
159
+ if (schemaTypes.length > 1) {
160
+ for (const st of schemaTypes) {
161
+ // Handle quoted strings in unions (like "default")
162
+ if (st.startsWith('"') && st.endsWith('"') && inferredType === "string") {
163
+ return true;
164
+ }
165
+ if (st === inferredType) {
166
+ return true;
167
+ }
168
+ // Arrays
169
+ if (st.endsWith("[]") && inferredType === "array") {
170
+ return true;
171
+ }
172
+ }
173
+ }
174
+
175
+ // Handle array types
176
+ if (schemaType.endsWith("[]") && inferredType === "array") {
177
+ return true;
178
+ }
179
+
180
+ // Handle specific string literals - if schema expects specific strings and value is a string
181
+ if (schemaType.includes('"') && inferredType === "string") {
182
+ return true;
183
+ }
184
+
185
+ // unknown is compatible with everything (schema might be less specific)
186
+ if (schemaType === "unknown" || inferredType === "unknown") {
187
+ return true;
188
+ }
189
+
190
+ return false;
191
+ }
192
+
193
+ /**
194
+ * Convert Helm values to TypeScript interface
195
+ */
196
+ export function convertToTypeScriptInterface(
197
+ values: HelmValue,
198
+ interfaceName: string,
199
+ schema?: JSONSchemaProperty | null,
200
+ yamlComments?: Map<string, string>,
201
+ keyPrefix = "",
202
+ ): TypeScriptInterface {
203
+ const properties: Record<string, TypeProperty> = {};
204
+ const schemaProps = schema?.properties;
205
+
206
+ for (const [key, value] of Object.entries(values)) {
207
+ const sanitizedKey = sanitizePropertyName(key);
208
+ const typeNameSuffix = sanitizeTypeName(key);
209
+ const propertySchema = schemaProps?.[key];
210
+ const fullKey = keyPrefix ? `${keyPrefix}.${key}` : key;
211
+ const yamlComment = yamlComments?.get(fullKey);
212
+
213
+ properties[sanitizedKey] = convertValueToProperty(
214
+ value,
215
+ `${interfaceName}${capitalizeFirst(typeNameSuffix)}`,
216
+ propertySchema,
217
+ key, // Pass the property name for better warnings
218
+ yamlComment,
219
+ yamlComments,
220
+ fullKey,
221
+ );
222
+ }
223
+
224
+ return {
225
+ name: interfaceName,
226
+ properties,
227
+ };
228
+ }
229
+
230
+ function convertValueToProperty(
231
+ value: unknown,
232
+ nestedTypeName: string,
233
+ schema?: JSONSchemaProperty,
234
+ propertyName?: string,
235
+ yamlComment?: string,
236
+ yamlComments?: Map<string, string>,
237
+ fullKey?: string,
238
+ ): TypeProperty {
239
+ // If we have a JSON schema for this property, prefer it over inference
240
+ if (schema) {
241
+ // First, infer the type from the actual value for comparison
242
+ const inferredType = inferTypeFromValue(value);
243
+ const schemaType = jsonSchemaToTypeScript(schema);
244
+
245
+ // Check if schema and inferred types are in agreement
246
+ if (inferredType && !typesAreCompatible(inferredType, schemaType)) {
247
+ const propName = propertyName ? `'${propertyName}': ` : "";
248
+ console.warn(
249
+ ` ⚠️ Type mismatch for ${propName}Schema says '${schemaType}' but value suggests '${inferredType}' (value: ${String(value).substring(0, 50)})`,
250
+ );
251
+ }
252
+
253
+ // Merge description from schema and YAML comments
254
+ let description = schema.description;
255
+ if (yamlComment) {
256
+ if (description) {
257
+ // If both exist, combine them
258
+ description = `${yamlComment}\n\n${description}`;
259
+ } else {
260
+ description = yamlComment;
261
+ }
262
+ }
263
+ const defaultValue = schema.default !== undefined ? schema.default : value;
264
+
265
+ // If schema defines it as an object with properties, recurse
266
+ const helmValueCheckForProps = HelmValueSchema.safeParse(value);
267
+ if (schema.properties && helmValueCheckForProps.success) {
268
+ const nestedInterface = convertToTypeScriptInterface(
269
+ helmValueCheckForProps.data,
270
+ nestedTypeName,
271
+ schema,
272
+ yamlComments,
273
+ fullKey,
274
+ );
275
+ return {
276
+ type: nestedTypeName,
277
+ optional: true,
278
+ nested: nestedInterface,
279
+ description,
280
+ default: defaultValue,
281
+ };
282
+ }
283
+
284
+ // Otherwise, use the schema type directly
285
+ const tsType = schemaType;
286
+
287
+ // Handle object types without explicit properties
288
+ const helmValueCheckForObject = HelmValueSchema.safeParse(value);
289
+ if (tsType === "object" && helmValueCheckForObject.success) {
290
+ const nestedInterface = convertToTypeScriptInterface(
291
+ helmValueCheckForObject.data,
292
+ nestedTypeName,
293
+ undefined,
294
+ yamlComments,
295
+ fullKey,
296
+ );
297
+ return {
298
+ type: nestedTypeName,
299
+ optional: true,
300
+ nested: nestedInterface,
301
+ description,
302
+ default: defaultValue,
303
+ };
304
+ }
305
+
306
+ return { type: tsType, optional: true, description, default: defaultValue };
307
+ }
308
+
309
+ // Fall back to runtime type inference when no schema is available
310
+ // Use Zod schemas for robust type detection
311
+ // IMPORTANT: Check for complex types (arrays, objects) BEFORE primitive types with coercion
312
+
313
+ // Check for null/undefined first
314
+ if (NullSchema.safeParse(value).success || UndefinedSchema.safeParse(value).success) {
315
+ return { type: "unknown", optional: true };
316
+ }
317
+
318
+ // Check for array (before coercion checks, as coercion can convert arrays to true)
319
+ const arrayResult = ArraySchema.safeParse(value);
320
+ if (arrayResult.success) {
321
+ const arrayValue = arrayResult.data;
322
+ if (arrayValue.length === 0) {
323
+ return { type: "unknown[]", optional: true };
324
+ }
325
+
326
+ // Sample multiple elements for better type inference
327
+ const elementTypes = new Set<string>();
328
+ const elementTypeProps: TypeProperty[] = [];
329
+ const sampleSize = Math.min(arrayValue.length, 3); // Check up to 3 elements
330
+
331
+ for (let i = 0; i < sampleSize; i++) {
332
+ const elementType = convertValueToProperty(arrayValue[i], nestedTypeName);
333
+ elementTypes.add(elementType.type);
334
+ elementTypeProps.push(elementType);
335
+ }
336
+
337
+ // If all elements have the same type, use that
338
+ if (elementTypes.size === 1) {
339
+ const elementType = Array.from(elementTypes)[0];
340
+ const elementProp = elementTypeProps[0];
341
+ if (elementType && elementProp) {
342
+ // For object array elements, we need to create a proper interface for the array element
343
+ if (elementProp.nested) {
344
+ // Create a new interface name for array elements
345
+ const arrayElementTypeName = `${nestedTypeName}Element`;
346
+ const arrayElementInterface: TypeScriptInterface = {
347
+ name: arrayElementTypeName,
348
+ properties: elementProp.nested.properties,
349
+ };
350
+
351
+ return {
352
+ type: `${arrayElementTypeName}[]`,
353
+ optional: true,
354
+ nested: arrayElementInterface,
355
+ };
356
+ } else {
357
+ return {
358
+ type: `${elementType}[]`,
359
+ optional: true,
360
+ };
361
+ }
362
+ }
363
+ }
364
+
365
+ // If mixed types, use union type for common cases
366
+ const types = Array.from(elementTypes).sort();
367
+ if (types.length <= 3 && types.every((t) => ["string", "number", "boolean"].includes(t))) {
368
+ return {
369
+ type: `(${types.join(" | ")})[]`,
370
+ optional: true,
371
+ };
372
+ }
373
+
374
+ // Otherwise fall back to unknown[]
375
+ return { type: "unknown[]", optional: true };
376
+ }
377
+
378
+ // Check for object (before primitive coercion checks)
379
+ const objectResult = HelmValueSchema.safeParse(value);
380
+ if (objectResult.success) {
381
+ const nestedInterface = convertToTypeScriptInterface(
382
+ objectResult.data,
383
+ nestedTypeName,
384
+ undefined,
385
+ yamlComments,
386
+ fullKey,
387
+ );
388
+ return {
389
+ type: nestedTypeName,
390
+ optional: true,
391
+ nested: nestedInterface,
392
+ description: yamlComment,
393
+ default: value,
394
+ };
395
+ }
396
+
397
+ // Now check for primitives - first actual runtime types, then coerced string types
398
+ // This prevents objects from being coerced to booleans
399
+
400
+ // Check for actual runtime boolean (true/false)
401
+ if (ActualBooleanSchema.safeParse(value).success) {
402
+ return { type: "boolean", optional: true, description: yamlComment, default: value };
403
+ }
404
+
405
+ // Check for actual runtime number
406
+ if (ActualNumberSchema.safeParse(value).success) {
407
+ return { type: "number", optional: true, description: yamlComment, default: value };
408
+ }
409
+
410
+ // Check if it's a string that represents a boolean ("true", "FALSE", etc.)
411
+ if (StringBooleanSchema.safeParse(value).success) {
412
+ return { type: "boolean", optional: true, description: yamlComment, default: value };
413
+ }
414
+
415
+ // Check if it's a string that represents a number ("15", "0", etc.)
416
+ // Only treat non-empty strings that parse as numbers as numbers
417
+ const stringCheckForNumber = StringSchema.safeParse(value);
418
+ if (stringCheckForNumber.success) {
419
+ const trimmed = stringCheckForNumber.data.trim();
420
+ // Don't treat empty strings or purely whitespace as numbers
421
+ if (trimmed !== "" && !isNaN(Number(trimmed)) && isFinite(Number(trimmed))) {
422
+ return { type: "number", optional: true, description: yamlComment, default: value };
423
+ }
424
+ }
425
+
426
+ // Check for plain string (strings that don't look like numbers or booleans)
427
+ const stringCheckForPlain = StringSchema.safeParse(value);
428
+ if (stringCheckForPlain.success) {
429
+ // Special case: "default" is often used as a sentinel value in Helm charts
430
+ // that can be overridden with actual typed values (numbers, booleans, etc.)
431
+ if (stringCheckForPlain.data === "default") {
432
+ return { type: "string | number | boolean", optional: true, description: yamlComment, default: value };
433
+ }
434
+ return { type: "string", optional: true, description: yamlComment, default: value };
435
+ }
436
+
437
+ // Fallback for any unrecognized type
438
+ console.warn(`Unrecognized value type for: ${String(value)}, using 'unknown'`);
439
+ return { type: "unknown", optional: true, description: yamlComment };
440
+ }
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Core type definitions for Helm chart type generation
2
+
3
+ export type ChartInfo = {
4
+ name: string;
5
+ repoUrl: string;
6
+ version: string;
7
+ chartName: string; // The actual chart name (may differ from versions.ts key)
8
+ };
9
+
10
+ export type JSONSchemaProperty = {
11
+ type?: string | string[];
12
+ enum?: unknown[];
13
+ oneOf?: JSONSchemaProperty[];
14
+ anyOf?: JSONSchemaProperty[];
15
+ items?: JSONSchemaProperty;
16
+ properties?: Record<string, JSONSchemaProperty>;
17
+ required?: string[];
18
+ description?: string;
19
+ default?: unknown;
20
+ };
21
+
22
+ export type TypeScriptInterface = {
23
+ name: string;
24
+ properties: Record<string, TypeProperty>;
25
+ /**
26
+ * If true, add an index signature to allow arbitrary additional properties
27
+ * Useful for config maps, arbitrary key-value stores, etc.
28
+ */
29
+ allowArbitraryProps?: boolean;
30
+ };
31
+
32
+ export type TypeProperty = {
33
+ type: string;
34
+ optional: boolean;
35
+ description?: string;
36
+ default?: unknown;
37
+ nested?: TypeScriptInterface;
38
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Utility functions for Helm type generation
3
+ */
4
+
5
+ /**
6
+ * Sanitize property names for TypeScript interfaces
7
+ * Handles special characters, reserved keywords, and invalid syntax
8
+ */
9
+ export function sanitizePropertyName(key: string): string {
10
+ // TypeScript reserved keywords that need quoting
11
+ const reservedKeywords = new Set([
12
+ "break",
13
+ "case",
14
+ "catch",
15
+ "class",
16
+ "const",
17
+ "continue",
18
+ "debugger",
19
+ "default",
20
+ "delete",
21
+ "do",
22
+ "else",
23
+ "enum",
24
+ "export",
25
+ "extends",
26
+ "false",
27
+ "finally",
28
+ "for",
29
+ "function",
30
+ "if",
31
+ "import",
32
+ "in",
33
+ "instanceof",
34
+ "new",
35
+ "null",
36
+ "return",
37
+ "super",
38
+ "switch",
39
+ "this",
40
+ "throw",
41
+ "true",
42
+ "try",
43
+ "typeof",
44
+ "var",
45
+ "void",
46
+ "while",
47
+ "with",
48
+ ]);
49
+
50
+ // Check if key needs quoting
51
+ const needsQuoting =
52
+ reservedKeywords.has(key) ||
53
+ /[^a-zA-Z0-9_$]/.test(key) || // Contains special characters
54
+ /^\d/.test(key); // Starts with digit
55
+
56
+ return needsQuoting ? `"${key}"` : key;
57
+ }
58
+
59
+ /**
60
+ * Sanitize type names by removing invalid characters and normalizing
61
+ */
62
+ export function sanitizeTypeName(key: string): string {
63
+ return (
64
+ key
65
+ .replace(/[^a-zA-Z0-9]/g, "") // Remove all special characters
66
+ .replace(/^\d+/, "") || // Remove leading digits
67
+ "Property"
68
+ ); // Fallback if empty
69
+ }
70
+
71
+ /**
72
+ * Capitalize the first letter of a string
73
+ */
74
+ export function capitalizeFirst(str: string): string {
75
+ return str.charAt(0).toUpperCase() + str.slice(1);
76
+ }