@intentius/chant 0.107.1 → 0.108.1

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 (55) hide show
  1. package/dist/cli/handlers/generate.d.ts +17 -0
  2. package/dist/cli/handlers/generate.d.ts.map +1 -0
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/plugins.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +12 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/codegen/json-schema-to-ts.d.ts +61 -0
  8. package/dist/codegen/json-schema-to-ts.d.ts.map +1 -0
  9. package/dist/config.d.ts +15 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +13 -0
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/project-codegen.d.ts +145 -0
  14. package/dist/project-codegen.d.ts.map +1 -0
  15. package/dist/workspace/ci-green-cli.d.ts +17 -0
  16. package/dist/workspace/ci-green-cli.d.ts.map +1 -0
  17. package/dist/workspace/ci-green-forge.d.ts +54 -0
  18. package/dist/workspace/ci-green-forge.d.ts.map +1 -0
  19. package/dist/workspace/ci-green-workflow.d.ts +107 -0
  20. package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
  21. package/dist/workspace/ci-green.d.ts +191 -0
  22. package/dist/workspace/ci-green.d.ts.map +1 -0
  23. package/dist/workspace/declaration.d.ts +31 -0
  24. package/dist/workspace/declaration.d.ts.map +1 -1
  25. package/dist/workspace/declaration.schema.json +102 -0
  26. package/dist/workspace/ls.d.ts +15 -0
  27. package/dist/workspace/ls.d.ts.map +1 -1
  28. package/dist/workspace/reason-codes.d.ts +2 -0
  29. package/dist/workspace/reason-codes.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/cli/handlers/generate.ts +90 -0
  32. package/src/cli/main.ts +53 -0
  33. package/src/cli/plugins.ts +16 -2
  34. package/src/cli/registry.ts +12 -0
  35. package/src/codegen/json-schema-to-ts.test.ts +256 -0
  36. package/src/codegen/json-schema-to-ts.ts +373 -0
  37. package/src/config.ts +16 -0
  38. package/src/lexicon.ts +20 -0
  39. package/src/project-codegen.test.ts +208 -0
  40. package/src/project-codegen.ts +296 -0
  41. package/src/workspace/ci-green-cli.ts +118 -0
  42. package/src/workspace/ci-green-forge.test.ts +66 -0
  43. package/src/workspace/ci-green-forge.ts +111 -0
  44. package/src/workspace/ci-green-workflow.test.ts +288 -0
  45. package/src/workspace/ci-green-workflow.ts +252 -0
  46. package/src/workspace/ci-green.test.ts +401 -0
  47. package/src/workspace/ci-green.ts +424 -0
  48. package/src/workspace/ci-last-green.schema.json +158 -0
  49. package/src/workspace/declaration.schema.json +102 -0
  50. package/src/workspace/declaration.ts +64 -0
  51. package/src/workspace/ls.schema.json +34 -0
  52. package/src/workspace/ls.ts +22 -0
  53. package/src/workspace/read-contract.test.ts +2 -1
  54. package/src/workspace/reason-codes.test.ts +2 -0
  55. package/src/workspace/reason-codes.ts +3 -0
@@ -0,0 +1,90 @@
1
+ import { relative, resolve, sep } from "node:path";
2
+ import { findProjectConfigPastFragments, loadChantConfig } from "../../config";
3
+ import { checkProjectCodegen, generateProjectCode } from "../../project-codegen";
4
+ import { formatError, formatSuccess, formatWarning } from "../format";
5
+ import type { CommandContext } from "../registry";
6
+
7
+ /**
8
+ * `chant generate [path] [--check] [--lexicon <name>]` — project-local code
9
+ * generation (../../project-codegen.ts). Each configured lexicon that
10
+ * implements the `projectCodegen` hook renders typed code from the sources
11
+ * the project declares (`k8s.crds`, `helm.charts`) into
12
+ * `<codegen.outDir>/<lexicon>/`.
13
+ *
14
+ * `--check` writes nothing and exits 1 when any lexicon's committed output no
15
+ * longer matches its declared sources: the same check `chant build` makes,
16
+ * for a CI step that wants to say so before building.
17
+ *
18
+ * Not `chant dev generate`, which regenerates a lexicon package's own types
19
+ * from upstream specs and is for lexicon authors.
20
+ */
21
+ export async function runGenerate(ctx: CommandContext): Promise<number> {
22
+ const { args } = ctx;
23
+ const projectRoot = findProjectConfigPastFragments(resolve(args.path)).dir;
24
+ let config: Record<string, unknown>;
25
+ try {
26
+ config = (await loadChantConfig(projectRoot)).config as unknown as Record<string, unknown>;
27
+ } catch (err) {
28
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
29
+ return 1;
30
+ }
31
+
32
+ const plugins = ctx.plugins.filter(
33
+ (p) => typeof p.projectCodegen === "function" && (!args.lexicon || p.name === args.lexicon),
34
+ );
35
+ if (args.lexicon && plugins.length === 0) {
36
+ console.error(formatError({
37
+ message: `lexicon "${args.lexicon}" is not configured for this project, or generates no project code`,
38
+ }));
39
+ return 1;
40
+ }
41
+
42
+ const show = (path: string): string => {
43
+ const rel = relative(projectRoot, path);
44
+ return rel === "" ? "." : rel.split(sep).join("/");
45
+ };
46
+
47
+ if (args.check) {
48
+ let failed = 0;
49
+ for (const plugin of plugins) {
50
+ const check = await checkProjectCodegen(plugin, projectRoot, config).catch((err: unknown) => ({
51
+ status: "error" as const,
52
+ message: err instanceof Error ? err.message : String(err),
53
+ }));
54
+ if (check.status === "current") console.error(formatSuccess(`${plugin.name}: generated code is current`));
55
+ if ("message" in check) {
56
+ failed++;
57
+ console.error(formatError({ message: `${plugin.name}: ${check.message}` }));
58
+ }
59
+ }
60
+ return failed > 0 ? 1 : 0;
61
+ }
62
+
63
+ let written = 0;
64
+ for (const plugin of plugins) {
65
+ let result;
66
+ try {
67
+ result = await generateProjectCode(plugin, projectRoot, config);
68
+ } catch (err) {
69
+ console.error(formatError({ message: `${plugin.name}: ${err instanceof Error ? err.message : String(err)}` }));
70
+ return 1;
71
+ }
72
+ if (!result || result.status === "none") continue;
73
+ if (result.status === "removed") {
74
+ console.error(formatWarning({
75
+ message: `${plugin.name}: no sources declared, removed ${result.files.length} generated file(s) from ${show(result.outDir)}`,
76
+ }));
77
+ continue;
78
+ }
79
+ written++;
80
+ for (const line of result.summary) console.log(` ${plugin.name}: ${line}`);
81
+ console.error(formatSuccess(`${plugin.name}: wrote ${result.files.length} file(s) to ${show(result.outDir)}`));
82
+ }
83
+
84
+ if (written === 0) {
85
+ console.error(
86
+ "Nothing to generate: no configured lexicon declares project sources (for example k8s.crds or helm.charts in chant.config.ts).",
87
+ );
88
+ }
89
+ return 0;
90
+ }
package/src/cli/main.ts CHANGED
@@ -473,6 +473,30 @@ export function parseArgs(args: string[]): ParsedArgs {
473
473
  // `chant workspace wip --branch <branch>` (#3172)
474
474
  result.branch = args[++i];
475
475
  if (!result.branch || result.branch.startsWith("-")) throw new Error("--branch needs a branch name: --branch <branch>");
476
+ } else if (arg === "--workflow") {
477
+ // `chant ci workflow --workflow <name>`, repeatable (#3573)
478
+ const name = args[++i];
479
+ if (!name || name.startsWith("-")) throw new Error("--workflow needs a workflow's name: --workflow <name>");
480
+ result.ciWorkflows = [...(result.ciWorkflows ?? []), name];
481
+ } else if (arg === "--chant") {
482
+ // `chant ci workflow --chant <command>` (#3573): what the tick step runs chant with.
483
+ result.ciChant = args[++i];
484
+ if (!result.ciChant || result.ciChant.startsWith("-")) throw new Error("--chant needs the command that runs chant, such as \"npx tsx packages/core/src/cli/main.ts\": --chant <command>");
485
+ } else if (arg === "--install") {
486
+ // `chant ci workflow --install <command>` (#3573): a step before the tick that installs what --chant runs.
487
+ result.ciInstall = args[++i];
488
+ if (!result.ciInstall || result.ciInstall.startsWith("-")) throw new Error("--install needs the install command, such as \"npm install\": --install <command>");
489
+ } else if (arg === "--token-secret") {
490
+ // `chant ci workflow --token-secret <NAME>` (#3573): the secret the tick's tag push authenticates with.
491
+ result.ciTokenSecret = args[++i];
492
+ if (!result.ciTokenSecret || result.ciTokenSecret.startsWith("-")) throw new Error("--token-secret needs a repository secret's name, such as CI_GREEN_TOKEN: --token-secret <NAME>");
493
+ } else if (arg === "--app-id-var") {
494
+ // `chant ci workflow --app-id-var <VAR> --app-key-secret <NAME>` (#3573): a GitHub App token pushes the tags.
495
+ result.ciAppIdVar = args[++i];
496
+ if (!result.ciAppIdVar || result.ciAppIdVar.startsWith("-")) throw new Error("--app-id-var needs the Actions variable holding the App's id: --app-id-var <VAR>");
497
+ } else if (arg === "--app-key-secret") {
498
+ result.ciAppKeySecret = args[++i];
499
+ if (!result.ciAppKeySecret || result.ciAppKeySecret.startsWith("-")) throw new Error("--app-key-secret needs the secret holding the App's private key: --app-key-secret <NAME>");
476
500
  } else if (arg === "--verdict") {
477
501
  // `chant workspace records review <id> --verdict agree|dissent|abstain` (#2670)
478
502
  result.verdict = args[++i];
@@ -796,6 +820,10 @@ Commands:
796
820
  okf emits an OKF v0.2 knowledge bundle — one markdown
797
821
  concept per entity + index.md; -o <dir> writes the
798
822
  bundle tree, otherwise JSON path→content on stdout)
823
+ generate Generate typed code from sources chant.config declares
824
+ (k8s.crds, helm.charts) into src/generated/<lexicon>/
825
+ (codegen.outDir); --check exits 1 when the committed
826
+ output no longer matches its sources, as build does
799
827
  vendor Pull pinned, checksummed patterns into your repo
800
828
  (pull [name] | check | migrate; migrate moves
801
829
  vendor.json into .chant/workspace.lock.json)
@@ -846,6 +874,24 @@ Commands:
846
874
  (--json) its target, stage (planned/emitted/bridged/
847
875
  applied) and path. Read-only.
848
876
 
877
+ CI:
878
+ ci last-green [--json] The newest first-parent commit of ci.green's branch
879
+ with a ci/green tag and no ci/revoked tag. Reads local
880
+ tags only; fetch them first.
881
+ ci tick [--dry-run] [--forge github] Tag each commit within ci.green's
882
+ window whose required phases all passed ci/green/<sha>,
883
+ and revoke a green one that now fails ci/revoked/<sha>.
884
+ Reads check runs from the forge, pushes the tags.
885
+ ci workflow [--workflow <name>]... [--output <file>] Write
886
+ [--chant <command>] [--install <command>]
887
+ [--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>]
888
+ .github/workflows/chant-ci-green.yml, which runs the
889
+ tick when a required workflow completes and every 15m.
890
+ --chant replaces the npx @intentius/chant pin the tick
891
+ runs; --install adds a step before it. --token-secret,
892
+ or a GitHub App's id and key, pushes the tags with a
893
+ token that may tag commits changing workflow files.
894
+
849
895
  Change sets:
850
896
  change-set summary <file> The grouped plan summary of a change-set
851
897
  [--format text|json|markdown] document: members taking
@@ -1727,6 +1773,10 @@ export const commandRegistry: CommandDef[] = [
1727
1773
  // Status read over a tree of carve manifests (#2038): the contract a
1728
1774
  // renderer replaces its own walk-and-guess discovery with. Read-only.
1729
1775
  { name: "carve status", handler: runCarveStatus },
1776
+ // #3573 — which commits passed CI, kept as ci/green and ci/revoked tags (ws-103). Imported on first use.
1777
+ { name: "ci last-green", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiLastGreen(ctx) },
1778
+ { name: "ci tick", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiTick(ctx) },
1779
+ { name: "ci workflow", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiWorkflow(ctx) },
1730
1780
  // #3188 — the grouped plan summary of a change-set document. Reads one file; imported on first use.
1731
1781
  { name: "change-set summary", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/change-set")).runChangeSetSummary(ctx) },
1732
1782
  { name: "init", handler: runInit, runsNoConfig: true },
@@ -1759,6 +1809,9 @@ export const commandRegistry: CommandDef[] = [
1759
1809
 
1760
1810
  { name: "graph", handler: runGraph },
1761
1811
  { name: "vendor", handler: runVendor },
1812
+ // Project-local codegen: typed code from sources chant.config declares
1813
+ // (k8s.crds, helm.charts). Imported on first use.
1814
+ { name: "generate", requiresPlugins: true, handler: async (ctx) => (await import("./handlers/generate")).runGenerate(ctx) },
1762
1815
 
1763
1816
  // Workspace reads (#2524). Imported on first use, so a level-0 command never
1764
1817
  // loads anything under workspace/ (#2525 rule 5, pinned by #2526's goldens).
@@ -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,18 @@ 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;
403
+ /** `chant ci workflow --token-secret <NAME>` (#3573): the repository secret the tick's tag push authenticates with. */
404
+ ciTokenSecret?: string;
405
+ /** `chant ci workflow --app-id-var <VAR>` (#3573): the Actions variable holding the GitHub App id whose token pushes the tags. */
406
+ ciAppIdVar?: string;
407
+ /** `chant ci workflow --app-key-secret <NAME>` (#3573): the secret holding that App's private key. */
408
+ ciAppKeySecret?: string;
397
409
  /**
398
410
  * `chant workspace records review <id> --by <principal>` (#2670): the
399
411
  * 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
+ });