@intentius/chant 0.106.4 → 0.108.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 (108) hide show
  1. package/dist/cli/commands/check-lexicon.d.ts +7 -0
  2. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  3. package/dist/cli/handlers/generate.d.ts +17 -0
  4. package/dist/cli/handlers/generate.d.ts.map +1 -0
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/plugins.d.ts.map +1 -1
  8. package/dist/cli/registry.d.ts +6 -0
  9. package/dist/cli/registry.d.ts.map +1 -1
  10. package/dist/codegen/json-schema-to-ts.d.ts +61 -0
  11. package/dist/codegen/json-schema-to-ts.d.ts.map +1 -0
  12. package/dist/config.d.ts +15 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/lexicon.d.ts +13 -0
  15. package/dist/lexicon.d.ts.map +1 -1
  16. package/dist/lifecycle/gate-ledger.d.ts +29 -0
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/op/activities/apply.d.ts +40 -6
  19. package/dist/op/activities/apply.d.ts.map +1 -1
  20. package/dist/op/builders.d.ts +7 -0
  21. package/dist/op/builders.d.ts.map +1 -1
  22. package/dist/op/composites/apply-op.d.ts +21 -3
  23. package/dist/op/composites/apply-op.d.ts.map +1 -1
  24. package/dist/op/gate-approval.d.ts +10 -2
  25. package/dist/op/gate-approval.d.ts.map +1 -1
  26. package/dist/op/gate-plan-context.d.ts +101 -0
  27. package/dist/op/gate-plan-context.d.ts.map +1 -0
  28. package/dist/op/gate-point-run.d.ts +110 -0
  29. package/dist/op/gate-point-run.d.ts.map +1 -0
  30. package/dist/op/gate-point.d.ts +70 -0
  31. package/dist/op/gate-point.d.ts.map +1 -0
  32. package/dist/op/gate.d.ts +12 -1
  33. package/dist/op/gate.d.ts.map +1 -1
  34. package/dist/op/index.d.ts +6 -0
  35. package/dist/op/index.d.ts.map +1 -1
  36. package/dist/op/local-executor.d.ts +11 -3
  37. package/dist/op/local-executor.d.ts.map +1 -1
  38. package/dist/op/op-ir.d.ts +2 -0
  39. package/dist/op/op-ir.d.ts.map +1 -1
  40. package/dist/op/types.d.ts +11 -0
  41. package/dist/op/types.d.ts.map +1 -1
  42. package/dist/project-codegen.d.ts +145 -0
  43. package/dist/project-codegen.d.ts.map +1 -0
  44. package/dist/workspace/ci-green-cli.d.ts +16 -0
  45. package/dist/workspace/ci-green-cli.d.ts.map +1 -0
  46. package/dist/workspace/ci-green-forge.d.ts +54 -0
  47. package/dist/workspace/ci-green-forge.d.ts.map +1 -0
  48. package/dist/workspace/ci-green-workflow.d.ts +82 -0
  49. package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
  50. package/dist/workspace/ci-green.d.ts +185 -0
  51. package/dist/workspace/ci-green.d.ts.map +1 -0
  52. package/dist/workspace/declaration.d.ts +31 -0
  53. package/dist/workspace/declaration.d.ts.map +1 -1
  54. package/dist/workspace/declaration.schema.json +102 -0
  55. package/dist/workspace/ls.d.ts +15 -0
  56. package/dist/workspace/ls.d.ts.map +1 -1
  57. package/dist/workspace/reason-codes.d.ts +2 -0
  58. package/dist/workspace/reason-codes.d.ts.map +1 -1
  59. package/dist/workspace/status-gates.d.ts +15 -1
  60. package/dist/workspace/status-gates.d.ts.map +1 -1
  61. package/package.json +1 -1
  62. package/src/cli/commands/check-lexicon.test.ts +21 -5
  63. package/src/cli/commands/check-lexicon.ts +14 -3
  64. package/src/cli/handlers/generate.ts +90 -0
  65. package/src/cli/handlers/operator.ts +9 -0
  66. package/src/cli/main.ts +39 -0
  67. package/src/cli/plugins.ts +16 -2
  68. package/src/cli/registry.ts +6 -0
  69. package/src/codegen/json-schema-to-ts.test.ts +256 -0
  70. package/src/codegen/json-schema-to-ts.ts +373 -0
  71. package/src/config.ts +16 -0
  72. package/src/lexicon.ts +20 -0
  73. package/src/lifecycle/gate-ledger.ts +30 -0
  74. package/src/op/activities/apply.test.ts +43 -0
  75. package/src/op/activities/apply.ts +111 -6
  76. package/src/op/builders.ts +13 -1
  77. package/src/op/composites/apply-op.ts +24 -4
  78. package/src/op/gate-approval.ts +18 -2
  79. package/src/op/gate-plan-context.test.ts +186 -0
  80. package/src/op/gate-plan-context.ts +229 -0
  81. package/src/op/gate-point-run.ts +207 -0
  82. package/src/op/gate-point.e2e.test.ts +166 -0
  83. package/src/op/gate-point.test.ts +235 -0
  84. package/src/op/gate-point.ts +119 -0
  85. package/src/op/gate.ts +26 -1
  86. package/src/op/index.ts +8 -0
  87. package/src/op/local-executor.ts +96 -3
  88. package/src/op/op-ir.ts +4 -0
  89. package/src/op/types.ts +11 -0
  90. package/src/project-codegen.test.ts +208 -0
  91. package/src/project-codegen.ts +296 -0
  92. package/src/workspace/ci-green-cli.ts +101 -0
  93. package/src/workspace/ci-green-forge.test.ts +66 -0
  94. package/src/workspace/ci-green-forge.ts +111 -0
  95. package/src/workspace/ci-green-workflow.test.ts +198 -0
  96. package/src/workspace/ci-green-workflow.ts +215 -0
  97. package/src/workspace/ci-green.test.ts +375 -0
  98. package/src/workspace/ci-green.ts +408 -0
  99. package/src/workspace/ci-last-green.schema.json +158 -0
  100. package/src/workspace/declaration.schema.json +102 -0
  101. package/src/workspace/declaration.ts +64 -0
  102. package/src/workspace/ls.schema.json +34 -0
  103. package/src/workspace/ls.ts +22 -0
  104. package/src/workspace/read-contract.test.ts +2 -1
  105. package/src/workspace/reason-codes.test.ts +2 -0
  106. package/src/workspace/reason-codes.ts +3 -0
  107. package/src/workspace/status-gates.ts +13 -2
  108. package/src/workspace/status.schema.json +11 -1
@@ -9,6 +9,7 @@ import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
9
9
  import { loadChantConfigUpward } from "../config";
10
10
  import { findInfraFiles, detectLexicons } from "../index";
11
11
  import { checkConflicts, describeConflict } from "./conflict-check";
12
+ import { prepareProjectCodegen } from "../project-codegen";
12
13
 
13
14
  /**
14
15
  * chant#2591 — record the lexicons the chant.config nearest `dir` declares by
@@ -194,11 +195,24 @@ export function collectBuildRootContributors(
194
195
  config: Record<string, unknown>,
195
196
  projectRoot: string,
196
197
  ): Array<import("../lexicon").BuildRootContributor> {
197
- return (plugins ?? [])
198
+ // Project-local codegen (../project-codegen.ts) rides the same seam: every
199
+ // build path that renders build roots also has to refuse generated code
200
+ // that drifted from its declared sources, and to let the lexicon load that
201
+ // code (the k8s kinds its serializer must know) before serialization. Its
202
+ // contributor adds no entities; a drift is thrown, which the merge reports
203
+ // as a build error. Runs first, so a build-root hook sees loaded kinds.
204
+ const codegen = (plugins ?? [])
205
+ .filter((plugin) => typeof plugin.projectCodegen === "function")
206
+ .map((plugin): import("../lexicon").BuildRootContributor => async () => {
207
+ await prepareProjectCodegen(plugin, projectRoot, config);
208
+ return { entities: new Map() };
209
+ });
210
+ const roots = (plugins ?? [])
198
211
  .filter((plugin) => typeof plugin.buildRoots === "function")
199
212
  // `entities` is not bindable here — discovery has not run — so the merge
200
213
  // hands it in when it calls the closure (#1828 / SOPS provenance).
201
- .map((plugin) => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
214
+ .map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
215
+ return [...codegen, ...roots];
202
216
  }
203
217
 
204
218
  /**
@@ -394,6 +394,12 @@ export interface ParsedArgs {
394
394
  label?: string;
395
395
  /** `chant workspace wip --branch <branch>` (#3172): list one branch's snapshots. */
396
396
  branch?: string;
397
+ /** `chant ci workflow --workflow <name>`, repeatable (#3573): a workflow whose completion triggers a tick, beside those the scan finds. */
398
+ ciWorkflows?: string[];
399
+ /** `chant ci workflow --chant <command>` (#3573): the command the tick step runs chant with, in place of the npx pin. */
400
+ ciChant?: string;
401
+ /** `chant ci workflow --install <command>` (#3573): a step before the tick that installs what `--chant` runs. */
402
+ ciInstall?: string;
397
403
  /**
398
404
  * `chant workspace records review <id> --by <principal>` (#2670): the
399
405
  * reviewer, as the caller names them. Also `chant workspace records new
@@ -0,0 +1,256 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import ts from "typescript";
3
+ import { schemaToTypeScript, type SchemaToTsOptions } from "./json-schema-to-ts";
4
+
5
+ /** The declarations plus `type Root = ...`, as a module. */
6
+ function module(schema: unknown, options: SchemaToTsOptions = {}): string {
7
+ const { type, declarations } = schemaToTypeScript(schema, { rootName: "Root", ...options });
8
+ return [...declarations.map((d) => `type ${d.name} = ${d.type};`), `type Root = ${type};`].join("\n");
9
+ }
10
+
11
+ const libFiles = new Map<string, ts.SourceFile | undefined>();
12
+
13
+ /** Type-check `code` in memory; returns the diagnostics' messages. */
14
+ function typecheck(code: string): string[] {
15
+ const fileName = "/virtual/check.ts";
16
+ const options: ts.CompilerOptions = { strict: true, noEmit: true, target: ts.ScriptTarget.ES2022, lib: ["lib.es2022.d.ts"] };
17
+ const host = ts.createCompilerHost(options);
18
+ const getSourceFile = host.getSourceFile;
19
+ // The standard library parses once per file, not once per check.
20
+ host.getSourceFile = (name, lang, ...rest) => {
21
+ if (name === fileName) return ts.createSourceFile(name, code, lang);
22
+ if (!libFiles.has(name)) libFiles.set(name, getSourceFile.call(host, name, lang, ...rest));
23
+ return libFiles.get(name);
24
+ };
25
+ const program = ts.createProgram([fileName], options, host);
26
+ return ts
27
+ .getPreEmitDiagnostics(program)
28
+ .filter((d) => d.file?.fileName === fileName)
29
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
30
+ }
31
+
32
+ /** Whether `value` (TS source) is assignable to the schema's root type. */
33
+ function accepts(schema: unknown, value: string, options?: SchemaToTsOptions): string[] {
34
+ return typecheck(`${module(schema, options)}\nexport const v: Root = ${value};\n`);
35
+ }
36
+
37
+ describe("schemaToTypeScript", () => {
38
+ test("scalars, arrays, type arrays and nullable", () => {
39
+ const { type } = schemaToTypeScript({
40
+ type: "object",
41
+ properties: {
42
+ s: { type: "string" },
43
+ i: { type: "integer" },
44
+ b: { type: "boolean" },
45
+ list: { type: "array", items: { type: "string" } },
46
+ maybe: { type: ["string", "null"] },
47
+ legacy: { type: "string", nullable: true },
48
+ any: {},
49
+ },
50
+ }, { openByDefault: false });
51
+ expect(type).toBe(
52
+ [
53
+ "{",
54
+ " s?: string;",
55
+ " i?: number;",
56
+ " b?: boolean;",
57
+ " list?: string[];",
58
+ " maybe?: string | null;",
59
+ " legacy?: string | null;",
60
+ " any?: unknown;",
61
+ "}",
62
+ ].join("\n"),
63
+ );
64
+ });
65
+
66
+ test("required members are required unless allOptional", () => {
67
+ const schema = { type: "object", required: ["name"], properties: { name: { type: "string" } }, additionalProperties: false };
68
+ expect(schemaToTypeScript(schema).type).toContain(" name: string;");
69
+ expect(schemaToTypeScript(schema, { allOptional: true }).type).toContain(" name?: string;");
70
+ expect(accepts(schema, "{}")).not.toEqual([]);
71
+ expect(accepts(schema, "{}", { allOptional: true })).toEqual([]);
72
+ });
73
+
74
+ test("$ref into $defs and definitions becomes a named declaration", () => {
75
+ const schema = {
76
+ type: "object",
77
+ properties: {
78
+ image: { $ref: "#/$defs/image" },
79
+ probe: { $ref: "#/definitions/io.k8s.api.core.v1.Probe" },
80
+ },
81
+ $defs: { image: { type: "object", properties: { tag: { type: "string" } }, additionalProperties: false } },
82
+ definitions: { "io.k8s.api.core.v1.Probe": { type: "object", properties: { periodSeconds: { type: "integer" } } } },
83
+ };
84
+ const { type, declarations } = schemaToTypeScript(schema, { namePrefix: "Values" });
85
+ expect(type).toContain("image?: ValuesImage;");
86
+ expect(type).toContain("probe?: ValuesIoK8sApiCoreV1Probe;");
87
+ expect(declarations.map((d) => d.name)).toEqual(["ValuesImage", "ValuesIoK8sApiCoreV1Probe"]);
88
+ expect(accepts(schema, `{ image: { tag: "1.0" }, probe: { periodSeconds: 10 } }`)).toEqual([]);
89
+ expect(accepts(schema, `{ image: { tga: "1.0" } }`).join("\n")).toMatch(/'tga' does not exist/);
90
+ });
91
+
92
+ test("recursive references terminate, including a reference to the root", () => {
93
+ const schema = {
94
+ type: "object",
95
+ additionalProperties: false,
96
+ properties: {
97
+ children: { type: "array", items: { $ref: "#/$defs/node" } },
98
+ self: { $ref: "#" },
99
+ },
100
+ $defs: {
101
+ node: { type: "object", additionalProperties: false, properties: { next: { $ref: "#/$defs/node" }, value: { type: "number" } } },
102
+ },
103
+ };
104
+ const { declarations, type } = schemaToTypeScript(schema, { rootName: "Root" });
105
+ expect(declarations).toHaveLength(1);
106
+ expect(declarations[0].type).toContain("next?: Node;");
107
+ expect(type).toContain("self?: Root;");
108
+ expect(accepts(schema, `{ children: [{ value: 1, next: { value: 2 } }], self: {} }`)).toEqual([]);
109
+ expect(accepts(schema, `{ children: [{ value: "x" }] }`)).not.toEqual([]);
110
+ });
111
+
112
+ test("keys with slashes, dots, dashes or leading digits are quoted and never renamed", () => {
113
+ const schema = {
114
+ type: "object",
115
+ additionalProperties: false,
116
+ properties: {
117
+ "app.kubernetes.io/name": { type: "string" },
118
+ "use-forwarded-headers": { type: "boolean" },
119
+ "2fa": { type: "boolean" },
120
+ snake_case: { type: "string" },
121
+ HTTPPort: { type: "integer" },
122
+ },
123
+ };
124
+ const { type } = schemaToTypeScript(schema);
125
+ expect(type).toContain(`"app.kubernetes.io/name"?: string;`);
126
+ expect(type).toContain(`"use-forwarded-headers"?: boolean;`);
127
+ expect(type).toContain(`"2fa"?: boolean;`);
128
+ expect(type).toContain(` snake_case?: string;`);
129
+ expect(type).toContain(` HTTPPort?: number;`);
130
+ expect(accepts(schema, `{ "app.kubernetes.io/name": "x", "use-forwarded-headers": true }`)).toEqual([]);
131
+ });
132
+
133
+ test("objects nested in additionalProperties maps keep their value type", () => {
134
+ const schema = {
135
+ type: "object",
136
+ properties: {
137
+ ports: {
138
+ type: "object",
139
+ additionalProperties: {
140
+ type: "object",
141
+ additionalProperties: false,
142
+ properties: { port: { type: "integer" }, expose: { type: "object", properties: { default: { type: "boolean" } } } },
143
+ },
144
+ },
145
+ },
146
+ };
147
+ const { type } = schemaToTypeScript(schema);
148
+ expect(type).toContain("ports?: Record<string, {");
149
+ expect(accepts(schema, `{ ports: { web: { port: 8000, expose: { default: true } } } }`)).toEqual([]);
150
+ expect(accepts(schema, `{ ports: { web: { prot: 8000 } } }`).join("\n")).toMatch(/'prot' does not exist/);
151
+ expect(accepts(schema, `{ ports: { web: { port: "8000" } } }`)).not.toEqual([]);
152
+ });
153
+
154
+ test("a map that also lists members widens its signature to cover them", () => {
155
+ const schema = {
156
+ type: "object",
157
+ properties: { fixed: { type: "string" } },
158
+ additionalProperties: { type: "number" },
159
+ };
160
+ expect(schemaToTypeScript(schema).type).toContain("[key: string]: number | string | undefined;");
161
+ expect(accepts(schema, `{ fixed: "a", other: 1 }`)).toEqual([]);
162
+ });
163
+
164
+ test("additionalProperties false closes an object; the default follows openByDefault", () => {
165
+ const closed = { type: "object", additionalProperties: false, properties: { a: { type: "string" } } };
166
+ const unspecified = { type: "object", properties: { a: { type: "string" } } };
167
+ expect(accepts(closed, `{ a: "x", b: 1 }`)).not.toEqual([]);
168
+ expect(accepts(unspecified, `{ a: "x", b: 1 }`)).toEqual([]);
169
+ expect(accepts(unspecified, `{ a: "x", b: 1 }`, { openByDefault: false })).not.toEqual([]);
170
+ // An object with no properties at all stays open either way.
171
+ expect(schemaToTypeScript({ type: "object" }, { openByDefault: false }).type).toBe("Record<string, unknown>");
172
+ expect(schemaToTypeScript({ type: "object", additionalProperties: false }).type).toBe("Record<string, never>");
173
+ });
174
+
175
+ test("enum and const become literal unions", () => {
176
+ const schema = {
177
+ type: "object",
178
+ additionalProperties: false,
179
+ properties: {
180
+ policy: { type: "string", enum: ["Always", "IfNotPresent", "Never"] },
181
+ level: { enum: [1, 2, null] },
182
+ kind: { const: "Widget" },
183
+ },
184
+ };
185
+ const { type } = schemaToTypeScript(schema);
186
+ expect(type).toContain(`policy?: "Always" | "IfNotPresent" | "Never";`);
187
+ expect(type).toContain(`level?: 1 | 2 | null;`);
188
+ expect(type).toContain(`kind?: "Widget";`);
189
+ expect(accepts(schema, `{ policy: "Sometimes" }`)).not.toEqual([]);
190
+ });
191
+
192
+ test("oneOf and anyOf become unions, allOf an intersection; constraint-only branches are ignored", () => {
193
+ const schema = {
194
+ type: "object",
195
+ additionalProperties: false,
196
+ properties: {
197
+ port: { oneOf: [{ type: "integer" }, { type: "string" }] },
198
+ target: { anyOf: [{ $ref: "#/$defs/a" }, { type: "object", properties: { b: { type: "string" } }, additionalProperties: false }] },
199
+ both: { allOf: [{ type: "object", properties: { x: { type: "string" } } }, { type: "object", properties: { y: { type: "string" } } }] },
200
+ either: { type: "object", properties: { a: { type: "string" }, b: { type: "string" } }, oneOf: [{ required: ["a"] }, { required: ["b"] }] },
201
+ },
202
+ $defs: { a: { type: "object", properties: { a: { type: "string" } }, additionalProperties: false } },
203
+ };
204
+ const { type } = schemaToTypeScript(schema);
205
+ expect(type).toContain("port?: number | string;");
206
+ expect(type).toMatch(/target\?: A \| \{/);
207
+ expect(type).toMatch(/both\?: \{[\s\S]*\} & \{/);
208
+ expect(accepts(schema, `{ port: 80, target: { b: "x" }, both: { x: "1", y: "2" }, either: { a: "1" } }`)).toEqual([]);
209
+ expect(accepts(schema, `{ port: true }`)).not.toEqual([]);
210
+ });
211
+
212
+ test("an object base with object alternatives is intersected with their union", () => {
213
+ const schema = {
214
+ type: "object",
215
+ additionalProperties: false,
216
+ properties: { name: { type: "string" } },
217
+ oneOf: [
218
+ { type: "object", properties: { file: { type: "string" } } },
219
+ { type: "object", properties: { url: { type: "string" } } },
220
+ ],
221
+ };
222
+ const { type } = schemaToTypeScript(schema);
223
+ expect(type).toMatch(/^\{[\s\S]*\} & \(\{[\s\S]*\} \| \{[\s\S]*\}\)$/);
224
+ expect(typecheck(`${module(schema)}\nexport type T = Root;`)).toEqual([]);
225
+ });
226
+
227
+ test("Kubernetes int-or-string and preserve-unknown-fields", () => {
228
+ const schema = {
229
+ type: "object",
230
+ properties: {
231
+ port: { "x-kubernetes-int-or-string": true },
232
+ config: { type: "object", "x-kubernetes-preserve-unknown-fields": true },
233
+ tuned: { type: "object", "x-kubernetes-preserve-unknown-fields": true, properties: { a: { type: "string" } } },
234
+ },
235
+ };
236
+ const { type } = schemaToTypeScript(schema, { openByDefault: false });
237
+ expect(type).toContain("port?: number | string;");
238
+ expect(type).toContain("config?: Record<string, unknown>;");
239
+ expect(type).toMatch(/tuned\?: \{\n {4}a\?: string;\n {4}\[key: string\]: unknown;/);
240
+ });
241
+
242
+ test("descriptions become JSDoc, with comment terminators escaped", () => {
243
+ const { type } = schemaToTypeScript({
244
+ type: "object",
245
+ properties: { a: { type: "string", description: "Either a | b (see */ docs)" }, b: { type: "string", description: "line one\nline two" } },
246
+ });
247
+ expect(type).toContain("/** Either a | b (see *\\/ docs) */");
248
+ expect(type).toContain(" * line two");
249
+ expect(typecheck(`type T = ${type};`)).toEqual([]);
250
+ });
251
+
252
+ test("a reference to another document is unknown", () => {
253
+ expect(schemaToTypeScript({ $ref: "https://example.com/schema.json" }).type).toBe("unknown");
254
+ expect(schemaToTypeScript({ $ref: "#/$defs/missing" }).type).toBe("unknown");
255
+ });
256
+ });
@@ -0,0 +1,373 @@
1
+ /**
2
+ * JSON Schema to TypeScript type text, for code a lexicon generates into a
3
+ * project (`chant generate`): a CRD's `openAPIV3Schema`, a Helm chart's
4
+ * `values.schema.json`.
5
+ *
6
+ * Unlike ./json-schema.ts, which maps the subset of JSON Schema that resource
7
+ * specifications use onto a lexicon's named property types, this renders a
8
+ * whole schema as one type expression, nested object literals inline, plus a
9
+ * named declaration for each `$ref` target. What it handles:
10
+ *
11
+ * - `$ref` into `$defs`, `definitions` or any other JSON pointer in the same
12
+ * document, including `#` itself; each target becomes one named type, so
13
+ * recursive schemas terminate. A reference to another document is `unknown`.
14
+ * - Property names that are not identifiers (`app.kubernetes.io/name`,
15
+ * `use-forwarded-headers`) are quoted, never renamed: a Helm values key or
16
+ * a CRD field is a literal the consumer reads back.
17
+ * - `additionalProperties` as a schema becomes an index signature, so maps of
18
+ * objects keep their value type; `false` closes the object. Whether an
19
+ * object with neither is open is the caller's choice (`openByDefault`): JSON
20
+ * Schema says open, a Kubernetes structural schema says closed.
21
+ * - `enum` and `const` become literal unions; `oneOf`/`anyOf` unions;
22
+ * `allOf` intersections; `type` arrays and `nullable` unions with `null`.
23
+ * - `x-kubernetes-int-or-string` and `x-kubernetes-preserve-unknown-fields`.
24
+ */
25
+
26
+ /** Options for {@link schemaToTypeScript}. */
27
+ export interface SchemaToTsOptions {
28
+ /** Make every object member optional, whatever `required` says (a Helm values override sets any subset). */
29
+ allOptional?: boolean;
30
+ /**
31
+ * Whether an object with no `additionalProperties` accepts keys it does not
32
+ * list. Default `true`, JSON Schema's rule. Pass `false` for a Kubernetes
33
+ * structural schema, where only `x-kubernetes-preserve-unknown-fields` (or
34
+ * an object with no `properties` at all) opens one.
35
+ */
36
+ openByDefault?: boolean;
37
+ /** Prefix for the names of `$ref` target declarations. Default none. */
38
+ namePrefix?: string;
39
+ /** The name a `$ref: "#"` resolves to; the caller declares the root type under it. Default `${namePrefix}Root`. */
40
+ rootName?: string;
41
+ }
42
+
43
+ /** A named type a `$ref` target produced. */
44
+ export interface SchemaTsDeclaration {
45
+ name: string;
46
+ /** The type expression, at indentation zero. */
47
+ type: string;
48
+ description?: string;
49
+ }
50
+
51
+ /** What {@link schemaToTypeScript} returns. */
52
+ export interface SchemaToTsResult {
53
+ /** The root schema's type expression, at indentation zero. */
54
+ type: string;
55
+ /** Declarations the expression (or another declaration) references, in first-reference order. */
56
+ declarations: SchemaTsDeclaration[];
57
+ }
58
+
59
+ type Schema = Record<string, unknown>;
60
+
61
+ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
62
+
63
+ /** Quote an object key unless it is a plain identifier. Never renames. */
64
+ export function tsPropertyKey(name: string): string {
65
+ return IDENT.test(name) ? name : JSON.stringify(name);
66
+ }
67
+
68
+ /** A JSDoc block for `text` at `indent`, or nothing when there is no text. */
69
+ export function jsdocLines(text: unknown, indent: string): string[] {
70
+ if (typeof text !== "string" || text.trim() === "") return [];
71
+ const lines = text.trim().replace(/\*\//g, "*\\/").split(/\r?\n/);
72
+ if (lines.length === 1) return [`${indent}/** ${lines[0]} */`];
73
+ return [`${indent}/**`, ...lines.map((l) => (l.trim() === "" ? `${indent} *` : `${indent} * ${l}`)), `${indent} */`];
74
+ }
75
+
76
+ /** Render a JSON Schema as a TypeScript type expression plus the declarations it references. */
77
+ export function schemaToTypeScript(schema: unknown, options: SchemaToTsOptions = {}): SchemaToTsResult {
78
+ const converter = new Converter(schema, options);
79
+ const type = converter.convert(schema, "");
80
+ return { type, declarations: converter.declarations() };
81
+ }
82
+
83
+ class Converter {
84
+ private readonly names = new Map<string, string>();
85
+ private readonly decls = new Map<string, SchemaTsDeclaration>();
86
+ private readonly used = new Set<string>();
87
+ private readonly prefix: string;
88
+ private readonly rootName: string;
89
+
90
+ constructor(
91
+ private readonly root: unknown,
92
+ private readonly opts: SchemaToTsOptions,
93
+ ) {
94
+ this.prefix = opts.namePrefix ?? "";
95
+ this.rootName = opts.rootName ?? `${this.prefix}Root`;
96
+ this.used.add(this.rootName);
97
+ }
98
+
99
+ declarations(): SchemaTsDeclaration[] {
100
+ return [...this.decls.values()];
101
+ }
102
+
103
+ convert(node: unknown, indent: string): string {
104
+ if (node === true || node === undefined) return "unknown";
105
+ if (node === false) return "never";
106
+ if (!isObject(node)) return "unknown";
107
+
108
+ if (typeof node.$ref === "string") return this.ref(node.$ref);
109
+
110
+ if (node.const !== undefined) return literal(node.const) ?? "unknown";
111
+ if (Array.isArray(node.enum) && node.enum.length > 0) {
112
+ const literals = node.enum.map(literal);
113
+ if (literals.every((l): l is string => l !== undefined)) return union(literals);
114
+ }
115
+ if (node["x-kubernetes-int-or-string"] === true) return "number | string";
116
+
117
+ const parts: string[] = [];
118
+ const base = this.baseType(node, indent);
119
+ if (base) parts.push(base);
120
+
121
+ const all = shapedBranches(node.allOf);
122
+ for (const branch of all) parts.push(paren(this.convert(branch, indent)));
123
+
124
+ const alternatives = shapedBranches(node.oneOf ?? node.anyOf);
125
+ if (alternatives.length > 0) {
126
+ parts.push(paren(union(alternatives.map((b) => this.convert(b, indent)))));
127
+ }
128
+
129
+ if (parts.length === 0) return "unknown";
130
+ const type = parts.length === 1 ? unparen(parts[0]) : parts.join(" & ");
131
+ return node.nullable === true && type !== "unknown" ? union([paren(type), "null"]) : type;
132
+ }
133
+
134
+ /** The type `type` (or the presence of `properties`/`items`) implies, before combinators. */
135
+ private baseType(node: Schema, indent: string): string | undefined {
136
+ let types: string[];
137
+ if (typeof node.type === "string") types = [node.type];
138
+ else if (Array.isArray(node.type)) types = node.type.filter((t): t is string => typeof t === "string");
139
+ else if (node.properties || node.additionalProperties !== undefined || node.patternProperties) types = ["object"];
140
+ else if (node.items) types = ["array"];
141
+ else if (node["x-kubernetes-preserve-unknown-fields"] === true) types = ["object"];
142
+ else types = [];
143
+
144
+ const out = types.map((t) => {
145
+ switch (t) {
146
+ case "string":
147
+ return "string";
148
+ case "integer":
149
+ case "number":
150
+ return "number";
151
+ case "boolean":
152
+ return "boolean";
153
+ case "null":
154
+ return "null";
155
+ case "array":
156
+ return this.arrayType(node, indent);
157
+ case "object":
158
+ return this.objectType(node, indent);
159
+ default:
160
+ return "unknown";
161
+ }
162
+ });
163
+ return out.length > 0 ? union(out) : undefined;
164
+ }
165
+
166
+ private arrayType(node: Schema, indent: string): string {
167
+ const items = node.items;
168
+ if (items === undefined || Array.isArray(items)) return "unknown[]";
169
+ const item = this.convert(items, indent);
170
+ return /^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(item) ? `${item}[]` : `Array<${item}>`;
171
+ }
172
+
173
+ private objectType(node: Schema, indent: string): string {
174
+ const props = isObject(node.properties) ? node.properties : {};
175
+ const names = Object.keys(props);
176
+ const required = new Set(Array.isArray(node.required) ? node.required.filter((r) => typeof r === "string") : []);
177
+ const inner = indent + " ";
178
+
179
+ // The index signature, if the object accepts keys it does not list.
180
+ let index: string | undefined;
181
+ const additional = node.additionalProperties;
182
+ if (additional === false) {
183
+ index = undefined;
184
+ } else if (isObject(additional)) {
185
+ index = this.convert(additional, inner);
186
+ } else if (additional === true) {
187
+ index = "unknown";
188
+ } else if (isObject(node.patternProperties) && Object.keys(node.patternProperties).length > 0) {
189
+ index = union(Object.values(node.patternProperties).map((p) => this.convert(p, inner)));
190
+ } else if (
191
+ node["x-kubernetes-preserve-unknown-fields"] === true ||
192
+ names.length === 0 ||
193
+ this.opts.openByDefault !== false
194
+ ) {
195
+ index = "unknown";
196
+ }
197
+
198
+ if (names.length === 0) {
199
+ return index === undefined ? "Record<string, never>" : `Record<string, ${index}>`;
200
+ }
201
+
202
+ const lines = ["{"];
203
+ const memberTypes: string[] = [];
204
+ for (const name of names) {
205
+ const child = props[name];
206
+ const type = this.convert(child, inner);
207
+ const optional = this.opts.allOptional || !required.has(name);
208
+ memberTypes.push(type);
209
+ if (optional) memberTypes.push("undefined");
210
+ lines.push(...jsdocLines(isObject(child) ? child.description : undefined, inner));
211
+ lines.push(`${inner}${tsPropertyKey(name)}${optional ? "?" : ""}: ${type};`);
212
+ }
213
+ if (index !== undefined) {
214
+ // A listed member must be assignable to the index signature, so a typed
215
+ // map that also lists members widens its signature to cover them.
216
+ const signature = index === "unknown" ? index : union([index, ...memberTypes].map(paren));
217
+ lines.push(`${inner}[key: string]: ${signature};`);
218
+ }
219
+ lines.push(`${indent}}`);
220
+ return lines.join("\n");
221
+ }
222
+
223
+ private ref(ref: string): string {
224
+ if (ref === "#" || ref === "#/") return this.rootName;
225
+ const existing = this.names.get(ref);
226
+ if (existing) return existing;
227
+ if (!ref.startsWith("#/")) return "unknown";
228
+ const target = resolvePointer(this.root, ref.slice(2));
229
+ if (target === undefined) return "unknown";
230
+
231
+ const segments = ref.slice(2).split("/");
232
+ const name = this.uniqueName(this.prefix + pascal(decodePointerSegment(segments[segments.length - 1])));
233
+ this.names.set(ref, name);
234
+ // Reserve the slot before converting, so a self-reference resolves to the name.
235
+ const decl: SchemaTsDeclaration = { name, type: "unknown" };
236
+ if (isObject(target) && typeof target.description === "string") decl.description = target.description;
237
+ this.decls.set(name, decl);
238
+ decl.type = this.convert(target, "");
239
+ return name;
240
+ }
241
+
242
+ private uniqueName(base: string): string {
243
+ let name = IDENT.test(base) ? base : `${this.prefix}Def`;
244
+ if (!this.used.has(name)) {
245
+ this.used.add(name);
246
+ return name;
247
+ }
248
+ let i = 2;
249
+ while (this.used.has(`${name}${i}`)) i++;
250
+ name = `${name}${i}`;
251
+ this.used.add(name);
252
+ return name;
253
+ }
254
+ }
255
+
256
+ function isObject(value: unknown): value is Schema {
257
+ return typeof value === "object" && value !== null && !Array.isArray(value);
258
+ }
259
+
260
+ /** Branches of a combinator that say something about shape; constraint-only branches (`{ required: [...] }`) say nothing a type can. */
261
+ function shapedBranches(value: unknown): unknown[] {
262
+ if (!Array.isArray(value)) return [];
263
+ return value.filter(
264
+ (b) =>
265
+ b === false ||
266
+ (isObject(b) &&
267
+ ["type", "$ref", "properties", "items", "enum", "const", "additionalProperties", "allOf", "oneOf", "anyOf"].some(
268
+ (k) => k in b,
269
+ )),
270
+ );
271
+ }
272
+
273
+ function literal(value: unknown): string | undefined {
274
+ if (value === null) return "null";
275
+ if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
276
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
277
+ return undefined;
278
+ }
279
+
280
+ function union(types: string[]): string {
281
+ const unique = [...new Set(types)];
282
+ if (unique.includes("unknown")) return "unknown";
283
+ return unique.join(" | ");
284
+ }
285
+
286
+ /** Parenthesize a union or intersection so it can be an operand. */
287
+ function paren(type: string): string {
288
+ return hasTopLevelOperator(type) ? `(${type})` : type;
289
+ }
290
+
291
+ /** Strip one pair of parentheses that wraps the whole expression. */
292
+ function unparen(type: string): string {
293
+ if (!type.startsWith("(") || closingIndex(type, 0) !== type.length - 1) return type;
294
+ return type.slice(1, -1);
295
+ }
296
+
297
+ /** Whether `type` has a `|` or `&` outside any brackets or string literal. */
298
+ function hasTopLevelOperator(type: string): boolean {
299
+ let depth = 0;
300
+ for (let i = 0; i < type.length; i++) {
301
+ const c = type[i];
302
+ if (c === '"') i = stringEnd(type, i);
303
+ else if (c === "/" && type[i + 1] === "*") i = commentEnd(type, i);
304
+ else if (c === "(" || c === "{" || c === "[" || c === "<") depth++;
305
+ else if (c === ")" || c === "}" || c === "]" || c === ">") depth--;
306
+ else if (depth === 0 && (c === "|" || c === "&")) return true;
307
+ }
308
+ return false;
309
+ }
310
+
311
+ /** Index of the bracket closing the one at `open`, or -1. */
312
+ function closingIndex(type: string, open: number): number {
313
+ let depth = 0;
314
+ for (let i = open; i < type.length; i++) {
315
+ const c = type[i];
316
+ if (c === '"') i = stringEnd(type, i);
317
+ else if (c === "/" && type[i + 1] === "*") i = commentEnd(type, i);
318
+ else if (c === "(" || c === "{" || c === "[" || c === "<") depth++;
319
+ else if (c === ")" || c === "}" || c === "]" || c === ">") {
320
+ depth--;
321
+ if (depth === 0) return i;
322
+ }
323
+ }
324
+ return -1;
325
+ }
326
+
327
+ /** Index of the `/` closing the block comment that opens at `start`. */
328
+ function commentEnd(type: string, start: number): number {
329
+ const end = type.indexOf("*/", start + 2);
330
+ return end === -1 ? type.length : end + 1;
331
+ }
332
+
333
+ /** Index of the quote closing the JSON string literal that opens at `start`. */
334
+ function stringEnd(type: string, start: number): number {
335
+ for (let i = start + 1; i < type.length; i++) {
336
+ if (type[i] === "\\") i++;
337
+ else if (type[i] === '"') return i;
338
+ }
339
+ return type.length;
340
+ }
341
+
342
+ function decodePointerSegment(segment: string): string {
343
+ let s = segment;
344
+ try {
345
+ s = decodeURIComponent(s);
346
+ } catch {
347
+ // Not percent-encoded; use as is.
348
+ }
349
+ return s.replace(/~1/g, "/").replace(/~0/g, "~");
350
+ }
351
+
352
+ function resolvePointer(root: unknown, pointer: string): unknown {
353
+ let node: unknown = root;
354
+ for (const raw of pointer.split("/")) {
355
+ if (raw === "") continue;
356
+ const key = decodePointerSegment(raw);
357
+ if (Array.isArray(node)) node = node[Number(key)];
358
+ else if (isObject(node)) node = node[key];
359
+ else return undefined;
360
+ if (node === undefined) return undefined;
361
+ }
362
+ return node;
363
+ }
364
+
365
+ /** PascalCase an arbitrary definition name into an identifier fragment. */
366
+ function pascal(name: string): string {
367
+ const out = name
368
+ .split(/[^A-Za-z0-9]+/)
369
+ .filter(Boolean)
370
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
371
+ .join("");
372
+ return /^[0-9]/.test(out) ? `_${out}` : out;
373
+ }