@intentius/chant 0.107.1 → 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.
- package/dist/cli/handlers/generate.d.ts +17 -0
- package/dist/cli/handlers/generate.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/json-schema-to-ts.d.ts +61 -0
- package/dist/codegen/json-schema-to-ts.d.ts.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/project-codegen.d.ts +145 -0
- package/dist/project-codegen.d.ts.map +1 -0
- package/dist/workspace/ci-green-cli.d.ts +16 -0
- package/dist/workspace/ci-green-cli.d.ts.map +1 -0
- package/dist/workspace/ci-green-forge.d.ts +54 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -0
- package/dist/workspace/ci-green-workflow.d.ts +82 -0
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-green.d.ts +185 -0
- package/dist/workspace/ci-green.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +31 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +102 -0
- package/dist/workspace/ls.d.ts +15 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/generate.ts +90 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/plugins.ts +16 -2
- package/src/cli/registry.ts +6 -0
- package/src/codegen/json-schema-to-ts.test.ts +256 -0
- package/src/codegen/json-schema-to-ts.ts +373 -0
- package/src/config.ts +16 -0
- package/src/lexicon.ts +20 -0
- package/src/project-codegen.test.ts +208 -0
- package/src/project-codegen.ts +296 -0
- package/src/workspace/ci-green-cli.ts +101 -0
- package/src/workspace/ci-green-forge.test.ts +66 -0
- package/src/workspace/ci-green-forge.ts +111 -0
- package/src/workspace/ci-green-workflow.test.ts +198 -0
- package/src/workspace/ci-green-workflow.ts +215 -0
- package/src/workspace/ci-green.test.ts +375 -0
- package/src/workspace/ci-green.ts +408 -0
- package/src/workspace/ci-last-green.schema.json +158 -0
- package/src/workspace/declaration.schema.json +102 -0
- package/src/workspace/declaration.ts +64 -0
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +22 -0
- package/src/workspace/read-contract.test.ts +2 -1
- package/src/workspace/reason-codes.test.ts +2 -0
- 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,19 @@ 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>");
|
|
476
489
|
} else if (arg === "--verdict") {
|
|
477
490
|
// `chant workspace records review <id> --verdict agree|dissent|abstain` (#2670)
|
|
478
491
|
result.verdict = args[++i];
|
|
@@ -796,6 +809,10 @@ Commands:
|
|
|
796
809
|
okf emits an OKF v0.2 knowledge bundle — one markdown
|
|
797
810
|
concept per entity + index.md; -o <dir> writes the
|
|
798
811
|
bundle tree, otherwise JSON path→content on stdout)
|
|
812
|
+
generate Generate typed code from sources chant.config declares
|
|
813
|
+
(k8s.crds, helm.charts) into src/generated/<lexicon>/
|
|
814
|
+
(codegen.outDir); --check exits 1 when the committed
|
|
815
|
+
output no longer matches its sources, as build does
|
|
799
816
|
vendor Pull pinned, checksummed patterns into your repo
|
|
800
817
|
(pull [name] | check | migrate; migrate moves
|
|
801
818
|
vendor.json into .chant/workspace.lock.json)
|
|
@@ -846,6 +863,21 @@ Commands:
|
|
|
846
863
|
(--json) its target, stage (planned/emitted/bridged/
|
|
847
864
|
applied) and path. Read-only.
|
|
848
865
|
|
|
866
|
+
CI:
|
|
867
|
+
ci last-green [--json] The newest first-parent commit of ci.green's branch
|
|
868
|
+
with a ci/green tag and no ci/revoked tag. Reads local
|
|
869
|
+
tags only; fetch them first.
|
|
870
|
+
ci tick [--dry-run] [--forge github] Tag each commit within ci.green's
|
|
871
|
+
window whose required phases all passed ci/green/<sha>,
|
|
872
|
+
and revoke a green one that now fails ci/revoked/<sha>.
|
|
873
|
+
Reads check runs from the forge, pushes the tags.
|
|
874
|
+
ci workflow [--workflow <name>]... [--output <file>] Write
|
|
875
|
+
[--chant <command>] [--install <command>]
|
|
876
|
+
.github/workflows/chant-ci-green.yml, which runs the
|
|
877
|
+
tick when a required workflow completes and every 15m.
|
|
878
|
+
--chant replaces the npx @intentius/chant pin the tick
|
|
879
|
+
runs; --install adds a step before it.
|
|
880
|
+
|
|
849
881
|
Change sets:
|
|
850
882
|
change-set summary <file> The grouped plan summary of a change-set
|
|
851
883
|
[--format text|json|markdown] document: members taking
|
|
@@ -1727,6 +1759,10 @@ export const commandRegistry: CommandDef[] = [
|
|
|
1727
1759
|
// Status read over a tree of carve manifests (#2038): the contract a
|
|
1728
1760
|
// renderer replaces its own walk-and-guess discovery with. Read-only.
|
|
1729
1761
|
{ name: "carve status", handler: runCarveStatus },
|
|
1762
|
+
// #3573 — which commits passed CI, kept as ci/green and ci/revoked tags (ws-103). Imported on first use.
|
|
1763
|
+
{ name: "ci last-green", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiLastGreen(ctx) },
|
|
1764
|
+
{ name: "ci tick", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiTick(ctx) },
|
|
1765
|
+
{ name: "ci workflow", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiWorkflow(ctx) },
|
|
1730
1766
|
// #3188 — the grouped plan summary of a change-set document. Reads one file; imported on first use.
|
|
1731
1767
|
{ name: "change-set summary", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/change-set")).runChangeSetSummary(ctx) },
|
|
1732
1768
|
{ name: "init", handler: runInit, runsNoConfig: true },
|
|
@@ -1759,6 +1795,9 @@ export const commandRegistry: CommandDef[] = [
|
|
|
1759
1795
|
|
|
1760
1796
|
{ name: "graph", handler: runGraph },
|
|
1761
1797
|
{ name: "vendor", handler: runVendor },
|
|
1798
|
+
// Project-local codegen: typed code from sources chant.config declares
|
|
1799
|
+
// (k8s.crds, helm.charts). Imported on first use.
|
|
1800
|
+
{ name: "generate", requiresPlugins: true, handler: async (ctx) => (await import("./handlers/generate")).runGenerate(ctx) },
|
|
1762
1801
|
|
|
1763
1802
|
// Workspace reads (#2524). Imported on first use, so a level-0 command never
|
|
1764
1803
|
// loads anything under workspace/ (#2525 rule 5, pinned by #2526's goldens).
|
package/src/cli/plugins.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
+
});
|