@amritk/validation 0.2.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/AI.md +92 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/generate.d.ts +135 -0
- package/dist/generate.js +74 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/parsers/generators/assert-supported-keywords.d.ts +30 -0
- package/dist/parsers/generators/assert-supported-keywords.js +87 -0
- package/dist/parsers/generators/build-schema.d.ts +111 -0
- package/dist/parsers/generators/build-schema.js +74 -0
- package/dist/parsers/generators/generate-deep-equal-check.d.ts +24 -0
- package/dist/parsers/generators/generate-deep-equal-check.js +35 -0
- package/dist/parsers/generators/generate-discriminated-union-validation.d.ts +15 -0
- package/dist/parsers/generators/generate-discriminated-union-validation.js +28 -0
- package/dist/parsers/generators/generate-enum-check.d.ts +35 -0
- package/dist/parsers/generators/generate-enum-check.js +31 -0
- package/dist/parsers/generators/generate-files.d.ts +138 -0
- package/dist/parsers/generators/generate-files.js +89 -0
- package/dist/parsers/generators/generate-non-discriminated-union-validation.d.ts +6 -0
- package/dist/parsers/generators/generate-non-discriminated-union-validation.js +26 -0
- package/dist/parsers/generators/generate-parser-function.d.ts +120 -0
- package/dist/parsers/generators/generate-parser-function.js +1973 -0
- package/dist/parsers/generators/generate-schema-checks.d.ts +5 -0
- package/dist/parsers/generators/generate-schema-checks.js +260 -0
- package/dist/parsers/generators/generate-strict-assertion.d.ts +155 -0
- package/dist/parsers/generators/generate-strict-assertion.js +759 -0
- package/dist/parsers/generators/generate-type-checks.d.ts +148 -0
- package/dist/parsers/generators/generate-type-checks.js +454 -0
- package/dist/parsers/generators/generate-unique-items-check.d.ts +16 -0
- package/dist/parsers/generators/generate-unique-items-check.js +32 -0
- package/dist/parsers/generators/generate-validation-expression.d.ts +79 -0
- package/dist/parsers/generators/generate-validation-expression.js +416 -0
- package/dist/parsers/generators/subschema-match.d.ts +48 -0
- package/dist/parsers/generators/subschema-match.js +485 -0
- package/dist/parsers/generators/unevaluated-match.d.ts +19 -0
- package/dist/parsers/generators/unevaluated-match.js +206 -0
- package/dist/parsers/helpers/apply-schema-extensions.d.ts +35 -0
- package/dist/parsers/helpers/apply-schema-extensions.js +20 -0
- package/dist/parsers/helpers/collect-helpers.d.ts +15 -0
- package/dist/parsers/helpers/collect-helpers.js +64 -0
- package/dist/parsers/helpers/collect-imports.d.ts +104 -0
- package/dist/parsers/helpers/collect-imports.js +227 -0
- package/dist/parsers/helpers/find-discriminator.d.ts +6 -0
- package/dist/parsers/helpers/find-discriminator.js +27 -0
- package/dist/parsers/helpers/get-discriminator-value.d.ts +6 -0
- package/dist/parsers/helpers/get-discriminator-value.js +18 -0
- package/dist/parsers/index.d.ts +2 -0
- package/dist/parsers/index.js +4 -0
- package/dist/parsers/types/schema-extensions.d.ts +34 -0
- package/dist/parsers/types/schema-extensions.js +0 -0
- package/dist/rehome-parser-file.d.ts +25 -0
- package/dist/rehome-parser-file.js +74 -0
- package/dist/validators/generators/assert-generatable-refs.d.ts +19 -0
- package/dist/validators/generators/assert-generatable-refs.js +12 -0
- package/dist/validators/generators/assert-unevaluated-generatable.d.ts +30 -0
- package/dist/validators/generators/assert-unevaluated-generatable.js +71 -0
- package/dist/validators/generators/build-schema.d.ts +61 -0
- package/dist/validators/generators/build-schema.js +666 -0
- package/dist/validators/generators/collect-emitted-refs.d.ts +26 -0
- package/dist/validators/generators/collect-emitted-refs.js +112 -0
- package/dist/validators/generators/collect-validator-imports.d.ts +86 -0
- package/dist/validators/generators/collect-validator-imports.js +62 -0
- package/dist/validators/generators/emit-format-checks.d.ts +50 -0
- package/dist/validators/generators/emit-format-checks.js +248 -0
- package/dist/validators/generators/enforced-keywords.d.ts +33 -0
- package/dist/validators/generators/enforced-keywords.js +70 -0
- package/dist/validators/generators/folds-to-constant.d.ts +15 -0
- package/dist/validators/generators/folds-to-constant.js +43 -0
- package/dist/validators/generators/generate-coerce-function.d.ts +22 -0
- package/dist/validators/generators/generate-coerce-function.js +208 -0
- package/dist/validators/generators/generate-files.d.ts +92 -0
- package/dist/validators/generators/generate-files.js +86 -0
- package/dist/validators/generators/generate-repair-function.d.ts +27 -0
- package/dist/validators/generators/generate-repair-function.js +102 -0
- package/dist/validators/generators/generate-validator-function.d.ts +58 -0
- package/dist/validators/generators/generate-validator-function.js +1691 -0
- package/dist/validators/generators/tuple-shape.d.ts +31 -0
- package/dist/validators/generators/tuple-shape.js +20 -0
- package/dist/validators/generators/unevaluated-match.d.ts +29 -0
- package/dist/validators/generators/unevaluated-match.js +223 -0
- package/dist/validators/index.d.ts +2 -0
- package/dist/validators/index.js +4 -0
- package/package.json +92 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { hasConst, hasEnum, hasProperties, isObjectSchema, isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
2
|
+
const findDiscriminator = (schemas) => {
|
|
3
|
+
const propertyValues = /* @__PURE__ */ new Map();
|
|
4
|
+
for (const schema of schemas) {
|
|
5
|
+
if (isObjectSchema(schema) && hasProperties(schema)) {
|
|
6
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
7
|
+
if (!propertyValues.has(key)) {
|
|
8
|
+
propertyValues.set(key, /* @__PURE__ */ new Set());
|
|
9
|
+
}
|
|
10
|
+
if (isSchemaObject(propSchema) && hasConst(propSchema)) {
|
|
11
|
+
propertyValues.get(key)?.add(propSchema.const);
|
|
12
|
+
} else if (isSchemaObject(propSchema) && hasEnum(propSchema) && propSchema.enum.length === 1) {
|
|
13
|
+
propertyValues.get(key)?.add(propSchema.enum[0]);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const [key, values] of propertyValues.entries()) {
|
|
19
|
+
if (values.size === schemas.length) {
|
|
20
|
+
return key;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
};
|
|
25
|
+
export {
|
|
26
|
+
findDiscriminator
|
|
27
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Gets the discriminator value for a specific schema.
|
|
4
|
+
* Returns the const value or single enum value if available.
|
|
5
|
+
*/
|
|
6
|
+
export declare const getDiscriminatorValue: (schema: JSONSchema, discriminatorKey: string) => unknown | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { hasConst, hasEnum, hasProperties, isObjectSchema, isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
2
|
+
const getDiscriminatorValue = (schema, discriminatorKey) => {
|
|
3
|
+
if (isObjectSchema(schema) && hasProperties(schema)) {
|
|
4
|
+
const propSchema = schema.properties[discriminatorKey];
|
|
5
|
+
if (propSchema && isSchemaObject(propSchema)) {
|
|
6
|
+
if (hasConst(propSchema)) {
|
|
7
|
+
return propSchema.const;
|
|
8
|
+
}
|
|
9
|
+
if (hasEnum(propSchema) && propSchema.enum.length === 1) {
|
|
10
|
+
return propSchema.enum[0];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
};
|
|
16
|
+
export {
|
|
17
|
+
getDiscriminatorValue
|
|
18
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Defines custom extension properties that can be added to specific schema
|
|
4
|
+
* definitions during the build process.
|
|
5
|
+
*
|
|
6
|
+
* The outer key is the definition name (matching the $defs key in the root
|
|
7
|
+
* schema, e.g., "parameter", "operation"). The inner key is the extension
|
|
8
|
+
* property name (e.g., "x-enabled", "x-internal"). The value is a JSON
|
|
9
|
+
* Schema that validates the extension property and generates its TypeScript type.
|
|
10
|
+
*
|
|
11
|
+
* Extensions are merged into the schema properties before type and parser
|
|
12
|
+
* generation, so each extension gets full type safety and runtime validation.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const extensions: SchemaExtensions = {
|
|
17
|
+
* parameter: {
|
|
18
|
+
* 'x-enabled': { type: 'boolean' },
|
|
19
|
+
* 'x-internal': { type: 'boolean', default: false },
|
|
20
|
+
* },
|
|
21
|
+
* operation: {
|
|
22
|
+
* 'x-codegen': {
|
|
23
|
+
* type: 'object',
|
|
24
|
+
* properties: {
|
|
25
|
+
* methodName: { type: 'string' },
|
|
26
|
+
* },
|
|
27
|
+
* },
|
|
28
|
+
* },
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* const files = buildSchema(schema, 'Document', markdownDocumentation, extensions)
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export type SchemaExtensions = Record<string, Record<string, JSONSchema>>;
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Both generators emit their own `export type X = …` for a schema, and both emit
|
|
3
|
+
* the identical text for it — they call the same
|
|
4
|
+
* `@amritk/helpers/generate-type-definition`. That is what makes one output
|
|
5
|
+
* directory possible rather than the two side-by-side trees the CLI settles for:
|
|
6
|
+
* the type is declared once, on the validator side, and the parser side imports
|
|
7
|
+
* it.
|
|
8
|
+
*
|
|
9
|
+
* This is the surgery that does it. It is text on generated text, which is worth
|
|
10
|
+
* being uncomfortable about, so it is one small file with its own tests rather
|
|
11
|
+
* than a few lines buried in the composer.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Strips a generated parser file's own type declarations and points it at the
|
|
15
|
+
* validator file that now owns them.
|
|
16
|
+
*
|
|
17
|
+
* `typeNames` are the types this file declared and must now import — the root
|
|
18
|
+
* type it is named for, and nothing else; a `$ref`'s type was always imported
|
|
19
|
+
* rather than declared, and only needs its import rehomed.
|
|
20
|
+
*
|
|
21
|
+
* Returns the file unchanged when it declares no type at all (an index barrel, a
|
|
22
|
+
* helper module), so the caller can hand it every file without sorting them
|
|
23
|
+
* first.
|
|
24
|
+
*/
|
|
25
|
+
export declare const rehomeParserFile: (source: string, moduleName: string, suffix: string, ext?: 'js' | 'ts') => string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { identifierMentions } from "@amritk/helpers/identifier-mentions";
|
|
2
|
+
const declarationEnd = (source, start) => {
|
|
3
|
+
let depth = 0;
|
|
4
|
+
let quote = null;
|
|
5
|
+
for (let i = start; i < source.length; i++) {
|
|
6
|
+
const char = source[i];
|
|
7
|
+
if (quote !== null) {
|
|
8
|
+
if (char === "\\")
|
|
9
|
+
i++;
|
|
10
|
+
else if (char === quote)
|
|
11
|
+
quote = null;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (char === '"' || char === "'" || char === "`")
|
|
15
|
+
quote = char;
|
|
16
|
+
else if (char === "{" || char === "[" || char === "(")
|
|
17
|
+
depth++;
|
|
18
|
+
else if (char === "}" || char === "]" || char === ")")
|
|
19
|
+
depth--;
|
|
20
|
+
else if (char === ";" && depth === 0)
|
|
21
|
+
return i + 1;
|
|
22
|
+
}
|
|
23
|
+
return -1;
|
|
24
|
+
};
|
|
25
|
+
const RELATIVE_IMPORT = /^import\s.*\sfrom\s+'\.\.?\//;
|
|
26
|
+
const rehomeImport = (statement, suffix, ext) => {
|
|
27
|
+
const match = /^import\s+\{([^}]*)\}\s+from\s+'\.\/([^']+)\.(?:js|ts)';?$/.exec(statement.trim());
|
|
28
|
+
if (!match)
|
|
29
|
+
return [statement];
|
|
30
|
+
const [, clause = "", module = ""] = match;
|
|
31
|
+
if (module.includes("/"))
|
|
32
|
+
return [statement];
|
|
33
|
+
const names = clause.split(",").map((name) => name.trim()).filter((name) => name !== "");
|
|
34
|
+
const types = names.filter((name) => name.startsWith("type ")).map((name) => name.slice("type ".length));
|
|
35
|
+
const values = names.filter((name) => !name.startsWith("type "));
|
|
36
|
+
const lines = [];
|
|
37
|
+
if (types.length > 0)
|
|
38
|
+
lines.push(`import type { ${types.join(", ")} } from './${module}.${ext}';`);
|
|
39
|
+
if (values.length > 0)
|
|
40
|
+
lines.push(`import { ${values.join(", ")} } from './${module}${suffix}.${ext}';`);
|
|
41
|
+
return lines;
|
|
42
|
+
};
|
|
43
|
+
const rehomeParserFile = (source, moduleName, suffix, ext = "js") => {
|
|
44
|
+
const declared = [];
|
|
45
|
+
let result = source;
|
|
46
|
+
for (; ; ) {
|
|
47
|
+
const match = /^export type (\w+) = /m.exec(result);
|
|
48
|
+
if (!match || match.index === void 0)
|
|
49
|
+
break;
|
|
50
|
+
const end = declarationEnd(result, match.index);
|
|
51
|
+
if (end === -1)
|
|
52
|
+
break;
|
|
53
|
+
declared.push(match[1]);
|
|
54
|
+
const trailing = result.slice(end).match(/^\n{1,2}/)?.[0] ?? "";
|
|
55
|
+
result = result.slice(0, match.index) + result.slice(end + trailing.length);
|
|
56
|
+
}
|
|
57
|
+
if (declared.length === 0)
|
|
58
|
+
return source;
|
|
59
|
+
const rehomed = result.split("\n").flatMap((line) => RELATIVE_IMPORT.test(line) ? rehomeImport(line, suffix, ext) : [line]);
|
|
60
|
+
const body = rehomed.filter((line) => !line.startsWith("import ")).join("\n");
|
|
61
|
+
const mentions = identifierMentions(body);
|
|
62
|
+
const kept = rehomed.filter((line) => !line.startsWith("import ") || importIsRead(line, mentions));
|
|
63
|
+
return `import type { ${declared.join(", ")} } from './${moduleName}.${ext}';
|
|
64
|
+
${kept.join("\n")}`;
|
|
65
|
+
};
|
|
66
|
+
const importIsRead = (statement, mentions) => {
|
|
67
|
+
const match = /\{([^}]*)\}/.exec(statement);
|
|
68
|
+
if (!match)
|
|
69
|
+
return true;
|
|
70
|
+
return (match[1] ?? "").split(",").map((name) => name.trim().replace(/^type\s+/, "")).filter((name) => name !== "").some(mentions);
|
|
71
|
+
};
|
|
72
|
+
export {
|
|
73
|
+
rehomeParserFile
|
|
74
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throws when a schema would make the emitter call a validator that no file
|
|
3
|
+
* defines.
|
|
4
|
+
*
|
|
5
|
+
* The walker refuses an unresolvable `#`/HTTP `$ref` already, so those never get
|
|
6
|
+
* this far. A ref written against an `$id` does: the walker's queue skips it
|
|
7
|
+
* (nothing in the document is keyed by `int.json`), the import collector skips it
|
|
8
|
+
* for the same reason — and the emitter, which only ever looks at the ref
|
|
9
|
+
* *string*, happily writes `validateIntJson(input, _path)` anyway. The result is
|
|
10
|
+
* generated TypeScript that does not compile, which is a loud failure in the
|
|
11
|
+
* wrong place: it lands in the consumer's build, long after the schema that
|
|
12
|
+
* caused it is out of sight.
|
|
13
|
+
*
|
|
14
|
+
* Stopping here instead puts the failure next to its cause and names the ref, the
|
|
15
|
+
* same answer the resolvable-but-unsupported paths already give. Resolving these
|
|
16
|
+
* refs properly means tracking `$id` base URIs through the document, which is a
|
|
17
|
+
* separate feature — until it exists, refusing is the honest report.
|
|
18
|
+
*/
|
|
19
|
+
export declare const assertGeneratableRefs: (schema: unknown, typeName: string) => void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { collectEmittedRefs } from "./collect-emitted-refs.js";
|
|
2
|
+
const isGeneratableRef = (ref) => ref.startsWith("#") || ref.startsWith("http://") || ref.startsWith("https://");
|
|
3
|
+
const assertGeneratableRefs = (schema, typeName) => {
|
|
4
|
+
for (const ref of collectEmittedRefs(schema)) {
|
|
5
|
+
if (isGeneratableRef(ref))
|
|
6
|
+
continue;
|
|
7
|
+
throw new Error(`[${typeName}] unresolvable $ref "${ref}": its target is not in this document, and generation reads no files, so the validator it would call is never emitted. Inline the other document first with @amritk/resolve-refs, rewrite the ref as an in-document pointer (e.g. "#/$defs/name") or an absolute http(s) URI keyed in "$defs", or validate this schema with the runtime interpreter, which takes documents you already have via its \`schemas\` option.`);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
export {
|
|
11
|
+
assertGeneratableRefs
|
|
12
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
import { type UnevaluatedMatchFn } from './unevaluated-match.js';
|
|
3
|
+
/**
|
|
4
|
+
* The message a node gets when its coverage cannot be worked out inline. Shared
|
|
5
|
+
* with the emitter so the gate and the code path it guards say the same thing.
|
|
6
|
+
*/
|
|
7
|
+
export declare const UNPROVABLE_COVERAGE_MESSAGE: (keyword: string) => string;
|
|
8
|
+
/**
|
|
9
|
+
* Throws when a schema uses `unevaluatedProperties` / `unevaluatedItems` in a way
|
|
10
|
+
* the generated code cannot honour.
|
|
11
|
+
*
|
|
12
|
+
* Both keywords police whatever a value's *other* keywords left untouched, which
|
|
13
|
+
* the interpreter answers with annotations gathered during its walk. Generated
|
|
14
|
+
* code has no walk, so `unevaluated-match.ts` reconstructs the same answer as an
|
|
15
|
+
* expression — and where it cannot (a `$ref` that resolves to nothing or back to
|
|
16
|
+
* itself, a `$dynamicRef` whose target is only known at runtime, or a position
|
|
17
|
+
* this generator does not enforce at all), generation stops here.
|
|
18
|
+
*
|
|
19
|
+
* That is deliberately a refusal *per shape* rather than per keyword: the vast
|
|
20
|
+
* majority of `unevaluated*` schemas are expressible and are now generated. What
|
|
21
|
+
* is left is the handful the flat form genuinely cannot see through, and for
|
|
22
|
+
* those a build error is the honest report — far better than a validator that
|
|
23
|
+
* accepts documents the interpreter rejects.
|
|
24
|
+
*
|
|
25
|
+
* `$defs` / `definitions` are skipped: a referenced definition is generated as
|
|
26
|
+
* its own file and gated there, with its own fresh annotation scope (which is
|
|
27
|
+
* also what the spec says a `$ref` target gets), and an unreferenced one is never
|
|
28
|
+
* applied to anything.
|
|
29
|
+
*/
|
|
30
|
+
export declare const assertUnevaluatedGeneratable: (schema: JSONSchema, typeName: string, rootSchema: Record<string, unknown> | undefined, match: UnevaluatedMatchFn) => void;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { declaresKey, readKey } from "@amritk/helpers/read-key";
|
|
2
|
+
import { unevaluatedItemsExpr, unevaluatedPropertiesExpr } from "./unevaluated-match.js";
|
|
3
|
+
const UNPROVABLE_COVERAGE_MESSAGE = (keyword) => `unsupported "${keyword}": working out which keys the schema already evaluated means reading through an applicator this generator cannot follow inline \u2014 an unresolvable or cyclic "$ref", or a "$dynamicRef", whose target is only known at validation time. Validate this schema with \`@amritk/runtime-validators\` instead.`;
|
|
4
|
+
const SINGLE_SUBSCHEMA_KEYS = [
|
|
5
|
+
"additionalProperties",
|
|
6
|
+
"propertyNames",
|
|
7
|
+
"contains",
|
|
8
|
+
"not",
|
|
9
|
+
"if",
|
|
10
|
+
"then",
|
|
11
|
+
"else",
|
|
12
|
+
"unevaluatedProperties",
|
|
13
|
+
"unevaluatedItems"
|
|
14
|
+
];
|
|
15
|
+
const SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
|
|
16
|
+
const SUBSCHEMA_MAP_KEYS = ["properties", "patternProperties", "dependentSchemas", "dependencies"];
|
|
17
|
+
const additionalItemsIsEnforced = (node) => Array.isArray(readKey(node, "items")) && !Array.isArray(readKey(node, "prefixItems"));
|
|
18
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
const check = (node, typeName, rootSchema, match, enforced) => {
|
|
20
|
+
const record = node;
|
|
21
|
+
for (const keyword of ["unevaluatedProperties", "unevaluatedItems"]) {
|
|
22
|
+
if (!declaresKey(record, keyword) || record[keyword] === true)
|
|
23
|
+
continue;
|
|
24
|
+
if (!enforced) {
|
|
25
|
+
throw new Error(`[${typeName}] unsupported "${keyword}": it sits under "additionalItems", a subschema position this generator does not enforce, so the check would never run. Validate this schema with \`@amritk/runtime-validators\` instead.`);
|
|
26
|
+
}
|
|
27
|
+
const expression = keyword === "unevaluatedProperties" ? unevaluatedPropertiesExpr("_probe", node, rootSchema, 0, match) : unevaluatedItemsExpr("_probe", node, rootSchema, 0, match);
|
|
28
|
+
if (expression === null)
|
|
29
|
+
throw new Error(`[${typeName}] ${UNPROVABLE_COVERAGE_MESSAGE(keyword)}`);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const assertUnevaluatedGeneratable = (schema, typeName, rootSchema, match) => {
|
|
33
|
+
const visit = (node, enforced) => {
|
|
34
|
+
if (!isRecord(node))
|
|
35
|
+
return;
|
|
36
|
+
if (declaresKey(node, "unevaluatedProperties") || declaresKey(node, "unevaluatedItems")) {
|
|
37
|
+
check(node, typeName, rootSchema, match, enforced);
|
|
38
|
+
}
|
|
39
|
+
for (const key of SINGLE_SUBSCHEMA_KEYS) {
|
|
40
|
+
if (declaresKey(node, key))
|
|
41
|
+
visit(node[key], enforced);
|
|
42
|
+
}
|
|
43
|
+
const items = readKey(node, "items");
|
|
44
|
+
if (Array.isArray(items))
|
|
45
|
+
for (const entry of items)
|
|
46
|
+
visit(entry, enforced);
|
|
47
|
+
else if (items !== void 0)
|
|
48
|
+
visit(items, enforced);
|
|
49
|
+
for (const key of SUBSCHEMA_LIST_KEYS) {
|
|
50
|
+
const list = readKey(node, key);
|
|
51
|
+
if (Array.isArray(list))
|
|
52
|
+
for (const entry of list)
|
|
53
|
+
visit(entry, enforced);
|
|
54
|
+
}
|
|
55
|
+
for (const key of SUBSCHEMA_MAP_KEYS) {
|
|
56
|
+
const map = readKey(node, key);
|
|
57
|
+
if (!isRecord(map))
|
|
58
|
+
continue;
|
|
59
|
+
for (const entry of Object.values(map))
|
|
60
|
+
if (!Array.isArray(entry))
|
|
61
|
+
visit(entry, enforced);
|
|
62
|
+
}
|
|
63
|
+
if (declaresKey(node, "additionalItems"))
|
|
64
|
+
visit(node["additionalItems"], enforced && additionalItemsIsEnforced(node));
|
|
65
|
+
};
|
|
66
|
+
visit(schema, true);
|
|
67
|
+
};
|
|
68
|
+
export {
|
|
69
|
+
UNPROVABLE_COVERAGE_MESSAGE,
|
|
70
|
+
assertUnevaluatedGeneratable
|
|
71
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
|
|
2
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
3
|
+
/**
|
|
4
|
+
* Represents a generated TypeScript file with its filename and content.
|
|
5
|
+
*/
|
|
6
|
+
export type GeneratedFile = {
|
|
7
|
+
filename: string;
|
|
8
|
+
content: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The runtime contract every generated validator imports: the `ValidationResult`
|
|
12
|
+
* types plus the helpers emitted code calls as free identifiers. Exported so tests
|
|
13
|
+
* can evaluate the very source that ships instead of reimplementing it.
|
|
14
|
+
*/
|
|
15
|
+
export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation error: what went wrong, where, and which keyword said so.\n *\n * The shape matches `@amritk/runtime-validators`, so an error from a generated\n * validator and one from the runtime interpreter can be handled by the same\n * code \u2014 grouped, translated, or branched on \u2014 without knowing which produced it.\n */\nexport type ValidationError = {\n /** Human-readable description of what went wrong. */\n message: string\n /** JSON Pointer to the offending value inside the instance. */\n path: string\n /** The JSON Schema keyword that rejected the value \u2014 `type`, `required`, `minimum`, \u2026 */\n keyword: string\n /**\n * The keyword's own values, as far as they explain the failure: the bound that\n * was exceeded, the property that was missing, the allowed values that were not\n * matched. Empty for a keyword with nothing to add beyond its name.\n */\n params: Record<string, unknown>\n}\n\n/**\n * The result of a generated validator function.\n * Returns `true` when the input is valid, or an object with `valid: false`\n * and a list of errors when it is not.\n */\nexport type ValidationResult = true | { valid: false; errors: ValidationError[] }\n\n/**\n * The result of a generated coercing validator.\n *\n * Unlike `ValidationResult` there is no bare `true`: a caller that coerces wants\n * the value back, and the whole point is that it may differ from what went in.\n * The input is never modified \u2014 `value` is the input itself when nothing needed\n * coercing, and otherwise a copy that shares everything the coercion did not\n * touch. So a caller does not have to clone defensively the way an in-place\n * coercer forces them to.\n */\nexport type CoercionResult<T> = { valid: true; value: T } | { valid: false; errors: ValidationError[] }\n\n/**\n * A string this will read as a number: an optional sign, digits with an optional\n * fractional part, an optional exponent. Nothing else.\n *\n * Deliberately narrower than what `Number()` accepts, which is where Ajv gets\n * its surprises \u2014 `Number(\" \")` is `0`, `Number(\"0x10\")` is `16` and\n * `Number(\"Infinity\")` is a value JSON cannot even represent. A config that says\n * `retries: \" \"` has a mistake in it, and answering `0` is the one thing worse\n * than rejecting it. Leading zeros (`\"007\"`) and exponents (`\"1e3\"`) stay: both\n * are ordinary ways to write a number in a YAML file, and neither is ambiguous.\n */\nconst NUMERIC_STRING = /^[+-]?(?:\\d+|\\d*\\.\\d+)(?:[eE][+-]?\\d+)?$/\n\n/**\n * One scalar, coerced toward `type`, or returned untouched when that is not\n * possible.\n *\n * Returning the original on failure is what keeps the error honest: nothing is\n * substituted, so the validator that runs next rejects the value the caller\n * actually wrote, with the keyword and params that rejected it. That is the\n * difference between this and a parser's repair, which repairs toward a default\n * and leaves nothing to report.\n *\n * The table is Ajv's `coerceTypes` minus the cells where Ajv guesses. Every\n * value this coerces, Ajv coerces to the same value \u2014 `coerced-vs-ajv` pins that\n * as a property, so moving off Ajv never changes a value, it only turns some of\n * Ajv's silent repairs into errors. What is deliberately *not* coerced:\n *\n * - **Anything to or from `null`.** Ajv reads `null` as `\"\"`, `0` and\n * `false`, and reads `\"\"`, `0` and `false` back as `null`. `null` is a\n * JSON value in its own right and usually means \"not set\"; turning it into an\n * empty string, or an empty string into it, loses the distinction the document\n * drew.\n * - **Strings that are not cleanly numeric** ({@link NUMERIC_STRING}) \u2014 no\n * whitespace padding, no `0x`/`0o`/`0b`, no `Infinity`, no trailing `.`.\n */\nexport const coerceScalar = (value: unknown, type: string): unknown => {\n switch (type) {\n case 'string':\n return typeof value === 'number' || typeof value === 'boolean' ? String(value) : value\n case 'number':\n case 'integer': {\n if (typeof value === 'boolean') return value ? 1 : 0\n if (typeof value !== 'string' || !NUMERIC_STRING.test(value)) return value\n const asNumber = Number(value)\n if (!Number.isFinite(asNumber)) return value\n return type === 'integer' && asNumber % 1 !== 0 ? value : asNumber\n }\n case 'boolean':\n if (value === 'true' || value === 1) return true\n if (value === 'false' || value === 0) return false\n return value\n default:\n return value\n }\n}\n\n/**\n * One scalar at a position that offers several types \u2014 a `type` array, or a\n * union of scalar branches \u2014 coerced only when exactly one of them can take it.\n *\n * A value whose type is already one of the offered types is left alone: it is\n * what the schema asked for, and the question of coercion does not arise. That\n * one rule is where this parts company with Ajv, which walks its own coercion\n * list in order and so turns `\"1\"` into `1` under `[\"number\", \"string\"]` while\n * leaving it a string under `[\"string\", \"number\"]`. The answer should not depend\n * on the order someone wrote the union in.\n *\n * Otherwise every offered type is tried, and the coercion is taken only if it is\n * the only one that succeeds. `true` against `number | string` could be `1` or\n * `\"true\"` with equal justification, so it stays `true` and the validator says\n * what is wrong with it.\n */\nexport const coerceUnion = (value: unknown, types: readonly string[]): unknown => {\n const actual = value === null ? 'null' : typeof value\n for (const type of types) {\n // `integer` is satisfied by a number, so a non-integral number is not a\n // *type* mismatch here \u2014 the validator is what holds it to being whole.\n if (actual === type || (actual === 'number' && type === 'integer')) return value\n }\n\n let coerced: unknown = value\n let found = 0\n for (const type of types) {\n const candidate = coerceScalar(value, type)\n if (candidate === value) continue\n coerced = candidate\n found++\n }\n return found === 1 ? coerced : value\n}\n\n/**\n * How deep a structural comparison walks before it gives up and answers \"not\n * equal\".\n *\n * JSON data is acyclic, but a generated validator is a plain function applied to\n * whatever in-memory value a caller hands it \u2014 and a self-referential object\n * reaching a `const` / `enum` / `uniqueItems` check used to recurse until the\n * stack overflowed, so `validateFoo` threw a `RangeError` instead of returning\n * the `ValidationResult` its signature promises. The cap turns that into an\n * ordinary \"these are different\" without ever coming near real data;\n * `@amritk/runtime-validators` guards its own `deepEqual` at the same depth.\n */\nconst MAX_EQUAL_DEPTH = 512\n\n/**\n * Structural deep equality used by generated `const` checks. Objects compare by\n * their key sets rather than serialization, so `{ a: 1, b: 2 }` and\n * `{ b: 2, a: 1 }` are equal \u2014 unlike `JSON.stringify`, which is key-order\n * sensitive and would reject a reordered-but-equal value.\n */\nexport const valuesEqual = (a: unknown, b: unknown, depth = 0): boolean => {\n // SameValueZero: `===` settles every primitive except `NaN`, which counts as\n // equal to itself here. That is what the native `Set` in {@link allUnique} does,\n // what Ajv does, and what the interpreter's `deepEqual` does \u2014 leaving it out\n // made a `NaN` nested inside an object compare unequal to itself, so the same\n // array was \"unique\" here and \"duplicated\" everywhere else.\n if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true\n if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false\n if (depth >= MAX_EQUAL_DEPTH) return false\n const aArray = Array.isArray(a)\n const bArray = Array.isArray(b)\n if (aArray !== bArray) return false\n if (aArray) {\n const aa = a as unknown[]\n const bb = b as unknown[]\n if (aa.length !== bb.length) return false\n for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i], depth + 1)) return false\n return true\n }\n const ao = a as Record<string, unknown>\n const bo = b as Record<string, unknown>\n const keys = Object.keys(ao)\n if (keys.length !== Object.keys(bo).length) return false\n for (const key of keys) {\n if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key], depth + 1)) return false\n }\n return true\n}\n\n/**\n * A cheap, order-independent structural hash consistent with {@link valuesEqual}:\n * equal values always hash the same. It buckets candidate-equal elements in\n * {@link allUnique} so the exact comparison only ever runs inside a bucket.\n *\n * Object keys are folded commutatively (XOR) so key order does not change the\n * hash, and `NaN` / `-0` collapse the way SameValueZero does. Depth-capped like\n * {@link valuesEqual}: an over-deep value simply shares a bucket and is settled by\n * the (also capped) comparison, so the cap can cost a little time and never a\n * wrong answer. This is the same hash `@amritk/runtime-validators` uses.\n */\nconst structuralHash = (value: unknown, depth = 0): number => {\n if (value === null) return 0x1a2b3c\n const t = typeof value\n if (t === 'number') {\n const n = value as number\n return Number.isNaN(n) ? 0x7ff8 : n === 0 ? 0 : Math.trunc(n * 2654435761) | 0\n }\n if (t === 'string') {\n const s = value as string\n let h = 0x811c9dc5\n for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)\n return h | 0\n }\n if (t === 'boolean') return value ? 1 : 2\n if (t !== 'object') return 0x5eed\n if (depth >= MAX_EQUAL_DEPTH) return 0xdee9\n if (Array.isArray(value)) {\n let h = 0x12345 ^ value.length\n for (let i = 0; i < value.length; i++) h = (Math.imul(h, 31) + structuralHash(value[i], depth + 1)) | 0\n return h | 0\n }\n const obj = value as Record<string, unknown>\n const keys = Object.keys(obj)\n let h = 0xabcde ^ keys.length\n for (const k of keys) {\n let kh = 0x811c9dc5\n for (let i = 0; i < k.length; i++) kh = Math.imul(kh ^ k.charCodeAt(i), 0x01000193)\n h = (h ^ (Math.imul(kh, 0x9e3779b1) + structuralHash(obj[k], depth + 1))) | 0\n }\n return h | 0\n}\n\n/**\n * True when every element of `arr` is distinct under structural equality\n * ({@link valuesEqual}). Backs generated `uniqueItems` checks whose items may be\n * objects or arrays, where a `JSON.stringify` dedupe key would be key-order\n * sensitive and let a reordered-but-equal duplicate (`{ a: 1, b: 2 }` vs\n * `{ b: 2, a: 1 }`) slip through.\n *\n * A native `Set` dedupes the all-primitive case in one linear pass. Object and\n * array elements are bucketed by {@link structuralHash} first, so the exact\n * comparison runs only against elements that could actually be equal: an array of\n * distinct objects costs ~O(n) instead of the O(n\u00B2) an exhaustive pairwise sweep\n * charged \u2014 4 000 rows took over half a second of pure comparison before, which\n * is a lot to hand an unauthenticated caller.\n */\nexport const allUnique = (arr: readonly unknown[]): boolean => {\n const len = arr.length\n if (len < 2) return true\n let allPrimitive = true\n for (let i = 0; i < len; i++) {\n const v = arr[i]\n if (v !== null && typeof v === 'object') {\n allPrimitive = false\n break\n }\n }\n if (allPrimitive) return new Set(arr).size === len\n const buckets = new Map<number, unknown[]>()\n for (let i = 0; i < len; i++) {\n const item = arr[i]\n const hash = structuralHash(item)\n const bucket = buckets.get(hash)\n if (bucket === undefined) {\n buckets.set(hash, [item])\n continue\n }\n for (const seen of bucket) if (valuesEqual(seen, item)) return false\n bucket.push(item)\n }\n return true\n}\n\n/**\n * True when `test` holds for every element of `arr`, holes included. Backs the\n * item check inside a generated boolean guard.\n *\n * Not `Array.prototype.every`, because that *skips holes* in a sparse array\n * (`[, 'x']`), whereas the validator's index-based loop reads a hole as\n * `undefined` and rejects it \u2014 and the guard must never accept what the validator\n * rejects. The guard used to get that by materialising `Array.from(arr)` first,\n * which copied every array it looked at; an index loop reads a hole the same way\n * and copies nothing.\n */\nexport const everyItem = (arr: readonly unknown[], test: (item: unknown) => boolean): boolean => {\n for (let i = 0; i < arr.length; i++) if (!test(arr[i])) return false\n return true\n}\n\n/**\n * Escapes one JSON Pointer segment (RFC 6901): `~` \u2192 `~0`, `/` \u2192 `~1`, in that\n * order. Generated error paths are built from *runtime* keys wherever the schema\n * did not name them \u2014 a `patternProperties` match, an `additionalProperties`\n * sweep, a `propertyNames` loop \u2014 and a key containing a `/` would otherwise read\n * back as two segments, so an error on `{\"a/b\": \u2026}` pointed at `/a/b`, which is\n * the child `b` of a property `a`. Keys the schema *does* name are escaped at\n * generation time instead, and `@amritk/runtime-validators` escapes the same way,\n * so all three agree.\n *\n * The `indexOf` pre-test keeps the common key \u2014 no `/`, no `~` \u2014 off the replace\n * path entirely, which is what the interpreter does for the same reason.\n */\nexport const escapePointer = (key: string): string =>\n key.indexOf('/') !== -1 || key.indexOf('~') !== -1 ? key.replace(/~/g, '~0').replace(/\\//g, '~1') : key\n\n/**\n * The errors of the branch that was plainly the one meant, out of every branch a\n * failing `anyOf` / `oneOf` rejected. Empty when no branch stands out.\n *\n * A failing combinator on its own says almost nothing: \"must match a schema in\n * anyOf\" names no field and no reason, and on the shape this is most often used\n * for \u2014 a union where the value plainly *is* one of the variants and one field of\n * it is wrong \u2014 that is the least useful thing a validator can say. The branch\n * errors are computed anyway to answer the yes/no question, so the only question\n * is which of them are worth reporting.\n *\n * Branches that rejected the value's *kind* go first: a branch wanting a string\n * has nothing to say about an object, so a `string | { \u2026 }` union is left with\n * the one branch that was even talking about this value. When more than one\n * survives they all describe the same kind of value, and the tie is broken the\n * way a discriminated union reads from the outside \u2014 if every survivor but one\n * was rejected on the value's *identity* (a `const` or `enum` on the value or\n * one of its own properties), the remaining one is the variant the author meant.\n *\n * Nothing is reported when no branch stands out, which is as much as can be said\n * honestly: \"the branch with the fewest errors\" would answer here too, and\n * answers wrongly on `oneOf: [aReference, theActualThing]`, where \"you did not\n * write a $ref\" is one complaint and the real mistake is two.\n *\n * `path` is where the combinator was applied, so a segment below it is a direct\n * property of the value being judged. `@amritk/runtime-validators` selects the\n * same branch by the same rule, so a generated validator and the interpreter\n * explain a failing union the same way.\n */\nexport const selectBranchErrors = (\n branches: readonly (readonly ValidationError[])[],\n path: string,\n): readonly ValidationError[] => {\n const candidates: (readonly ValidationError[])[] = []\n for (const errors of branches) {\n // The value itself is `path`, so a `type` error there is a rejected kind.\n if (!errors.some((error) => error.keyword === 'type' && error.path === path)) candidates.push(errors)\n }\n if (candidates.length === 1) return candidates[0] as readonly ValidationError[]\n\n let selected: readonly ValidationError[] | null = null\n let rejectedOnIdentity = 0\n for (const errors of candidates) {\n // One segment below `path` and no deeper: a discriminator is conventionally a\n // direct field, and a `const` buried further down is far more likely to be an\n // ordinary payload constraint.\n const identity = errors.some(\n (error) =>\n (error.keyword === 'const' || error.keyword === 'enum') && error.path.indexOf('/', path.length + 1) === -1,\n )\n if (identity) {\n rejectedOnIdentity++\n continue\n }\n // Two branches survive the discriminator, so it did not discriminate.\n if (selected !== null) return []\n selected = errors\n }\n\n return selected !== null && rejectedOnIdentity === candidates.length - 1 ? selected : []\n}\n\n/**\n * The result of a generated repairing validator.\n *\n * `repairs` is not a second, parallel account of what went wrong \u2014 it *is* the\n * errors `validateX` produced, the ones a repair was found for. So a caller that\n * logs a repair logs the same path, keyword and params it would have been\n * rejected with, and the two can never drift apart, because there is only one of\n * them.\n *\n * A document that needed no repair comes back `valid: true` with an empty\n * `repairs`, which is exactly what `coerceX` would have returned. One that was\n * fully repaired is `valid: true` with a non-empty `repairs` \u2014 the caller\n * decides whether that is acceptable by looking, rather than by being told. One\n * that could not be fully repaired is `valid: false` and carries both: the\n * repairs that were applied, and the `errors` still outstanding against the\n * value handed back.\n */\nexport type RepairResult<T> =\n | { valid: true; value: T; repairs: ValidationError[] }\n | { valid: false; value: unknown; errors: ValidationError[]; repairs: ValidationError[] }\n\n/**\n * Looks up the repaired value for one position, given that position's JSON\n * Pointer split into segments. Returns a thunk rather than a value so each\n * repair gets its own object \u2014 a shared literal would alias every site that\n * repaired to it \u2014 and `null` where the schema offers nothing to repair toward.\n */\nexport type RepairLookup = (segments: readonly string[]) => (() => unknown) | null\n\n/**\n * How many validate-and-repair rounds `repairX` will run. Repairing a child can\n * expose a parent that only became checkable once the child was there, so one\n * round is not always enough; but each round is bounded further by the rule that\n * a position is repaired at most once, so this is a backstop rather than the\n * thing doing the terminating.\n */\nexport const MAX_REPAIR_PASSES = 8\n\n/**\n * Splits a JSON Pointer into its segments, undoing the escaping\n * {@link escapePointer} applied. The root pointer (`''`) is zero segments.\n */\nexport const pointerSegments = (path: string): string[] =>\n path === ''\n ? []\n : path\n .slice(1)\n .split('/')\n .map((segment) =>\n segment.indexOf('~') === -1 ? segment : segment.replace(/~1/g, '/').replace(/~0/g, '~'),\n )\n\n/**\n * Returns `root` with the position at `segments` replaced by `value`, copying\n * only the containers along the way.\n *\n * Copying rather than writing is not politeness, it is the same promise\n * `coerceX` makes: the caller's input is never modified, so a repaired document\n * can be compared against what was actually sent. Everything the path did not\n * touch is shared, so repairing one field of a large document does not clone it.\n *\n * Returns `root` unchanged when the path does not lead anywhere \u2014 a segment into\n * a missing or non-container parent. That is not a failure to handle here: the\n * parent has an error of its own, and repairing *it* is what makes this position\n * reachable on a later pass.\n */\nexport const repairAt = (root: unknown, segments: readonly string[], value: unknown): unknown => {\n if (segments.length === 0) return value\n const [head, ...rest] = segments as [string, ...string[]]\n\n if (Array.isArray(root)) {\n const index = Number(head)\n // `index === root.length` is an append, which is what padding a short array\n // to its `minItems` is made of. Anything past that would leave a hole, and a\n // hole is not a repair.\n if (!Number.isInteger(index) || index < 0 || index > root.length) return root\n const next = repairAt(root[index], rest, value)\n if (next === root[index]) return root\n const copy = [...root]\n copy[index] = next\n return copy\n }\n\n if (typeof root !== 'object' || root === null) return root\n const obj = root as Record<string, unknown>\n // A missing key is reachable only when this is the last segment: that is the\n // `required` repair, which is putting the key there. Deeper than that and the\n // parent is the thing that needs repairing first.\n if (!Object.hasOwn(obj, head) && rest.length > 0) return root\n const next = repairAt(obj[head], rest, value)\n if (next === obj[head] && Object.hasOwn(obj, head)) return root\n return { ...obj, [head]: next }\n}\n\n/**\n * The position one error is asking to have repaired, or `null` when the error is\n * not about a position a value can be put at.\n *\n * One keyword does not point at the value that is wrong: a `required` error is\n * reported against the *object* that is missing the key, and names the key in\n * `params.missingProperty`, so the position to fill is one segment deeper.\n * `minItems` is the other exception and is handled separately, because it is\n * satisfied by adding several values rather than by replacing one.\n */\nconst repairTarget = (error: ValidationError): readonly string[] | null => {\n const segments = pointerSegments(error.path)\n if (error.keyword === 'required') {\n const missing = error.params['missingProperty']\n return typeof missing === 'string' ? [...segments, missing] : null\n }\n return segments\n}\n\n/** The value at a pointer, or `undefined` when the path does not lead anywhere. */\nconst valueAt = (root: unknown, segments: readonly string[]): unknown =>\n segments.reduce<unknown>(\n (node, segment) =>\n Array.isArray(node)\n ? node[Number(segment)]\n : typeof node === 'object' && node !== null\n ? (node as Record<string, unknown>)[segment]\n : undefined,\n root,\n )\n\n/**\n * Grows a short array to the length `minItems` asks for, or `null` when it\n * cannot.\n *\n * Padding is its own operation rather than a position repair because one\n * `minItems` error is satisfied by *several* values, not one. Doing it a single\n * element per round would work, but it would spend a round per element and a\n * `minItems: 50` would run out of them; filling the shortfall at once keeps the\n * number of rounds a question about the shape of the document rather than about\n * the size of its arrays.\n *\n * Every added element is a separate call to the thunk, so no two share an object.\n */\nconst padToMinItems = (\n root: unknown,\n error: ValidationError,\n lookup: RepairLookup,\n done: Set<string>,\n): unknown => {\n const segments = pointerSegments(error.path)\n const array = valueAt(root, segments)\n const limit = error.params['limit']\n if (!Array.isArray(array) || typeof limit !== 'number' || array.length >= limit) return null\n\n // Keyed apart from a position repair at the same path: replacing the whole\n // array and padding it are different repairs, and having done one is no reason\n // to refuse the other.\n const key = `${segments.join('/')}#minItems`\n if (done.has(key)) return null\n\n const element = lookup([...segments, String(array.length)])\n if (element === null) return null\n\n const padded = [...array]\n while (padded.length < limit) padded.push(element())\n const updated = repairAt(root, segments, padded)\n if (updated === root) return null\n done.add(key)\n return updated\n}\n\n/**\n * Applies one round of repairs: every error the schema offers a value for is\n * repaired, and the rest are handed back untouched.\n *\n * `done` carries the positions already repaired on an earlier round. A position\n * that failed again after being repaired is not repaired a second time \u2014 the\n * value the schema offered did not satisfy the schema, which is a fact about the\n * schema, and trying again would only produce the same value. Refusing keeps the\n * loop finite without relying on the pass cap, and turns the position into an\n * honest error instead of a silent spin.\n */\nexport const applyRepairs = (\n value: unknown,\n errors: readonly ValidationError[],\n lookup: RepairLookup,\n done: Set<string>,\n): { value: unknown; repaired: ValidationError[] } => {\n const repaired: ValidationError[] = []\n let next = value\n\n for (const error of errors) {\n if (error.keyword === 'minItems') {\n const padded = padToMinItems(next, error, lookup, done)\n if (padded === null) continue\n next = padded\n repaired.push(error)\n continue\n }\n\n const target = repairTarget(error)\n if (target === null) continue\n const key = target.join('/')\n if (done.has(key)) continue\n const to = lookup(target)\n if (to === null) continue\n\n const updated = repairAt(next, target, to())\n // An unreachable position leaves the document exactly as it was. Not counting\n // it as repaired is what lets the parent's own error be the thing that\n // reports, rather than this silently claiming a fix that did not land.\n if (updated === next) continue\n done.add(key)\n next = updated\n repaired.push(error)\n }\n\n return { value: next, repaired }\n}\n";
|
|
16
|
+
/**
|
|
17
|
+
* Builds all TypeScript validator files from a JSON Schema by traversing all
|
|
18
|
+
* `$ref` / `$dynamicRef` references recursively (via the shared
|
|
19
|
+
* `@amritk/helpers/walk-ref-graph` walker).
|
|
20
|
+
*
|
|
21
|
+
* Each generated file exports:
|
|
22
|
+
* - A TypeScript type definition
|
|
23
|
+
* - A `validateFoo(input: unknown, _path?: string): ValidationResult` function
|
|
24
|
+
* - A `checkFoo(input: unknown, _path?: string): ValidationResult` function, when
|
|
25
|
+
* `check` is on — the same result type, stopped at the first violation
|
|
26
|
+
*
|
|
27
|
+
* A `validation-result.ts` file containing the `ValidationResult` and `ValidationError`
|
|
28
|
+
* runtime contract is always emitted. An `index.ts` re-exports everything.
|
|
29
|
+
*
|
|
30
|
+
* @param rootSchema - The root JSON Schema to build from
|
|
31
|
+
* @param rootTypeName - The name for the root type (e.g. "Document")
|
|
32
|
+
* @param typeSuffix - Suffix appended to every type name derived from a `$ref`
|
|
33
|
+
* (e.g. `'Object'` → `ContactObject`). Defaults to `''`. The root type name is
|
|
34
|
+
* used verbatim and is not affected.
|
|
35
|
+
* @param schemas - Other schema documents you have **already loaded**, keyed by
|
|
36
|
+
* the absolute URI a `$ref` names them by. Supplying them makes those URIs
|
|
37
|
+
* resolvable, so the schema can reference a document that is not itself — each
|
|
38
|
+
* one becomes a resource of the generated document, with its own `$id`,
|
|
39
|
+
* anchors and nested resources all resolvable. Nothing is fetched here:
|
|
40
|
+
* loading is yours to do (or `@amritk/resolve-refs`'), and a `$ref` to a URI
|
|
41
|
+
* nobody registered still stops generation. Only the documents actually
|
|
42
|
+
* referenced get files.
|
|
43
|
+
* @param unknownKeys - How the generated fast paths prove a closed object
|
|
44
|
+
* (`additionalProperties: false`) carries no undeclared key: `'count-keys'`
|
|
45
|
+
* (the default) compares `Object.keys(obj).length`, `'count-enumerable'`
|
|
46
|
+
* counts with `for…in`. The first is the faster form on JavaScriptCore (Bun),
|
|
47
|
+
* the second on V8 (Node) — see the README for the measurements.
|
|
48
|
+
* @returns An array of generated TypeScript files
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* const files = await buildValidatorSchema(schema, 'Document')
|
|
53
|
+
* // files → [{ filename: 'document.ts', content: '...' }, { filename: 'info.ts', ... }, ...]
|
|
54
|
+
*
|
|
55
|
+
* // Referencing a document you loaded yourself:
|
|
56
|
+
* const withRemote = await buildValidatorSchema({ $ref: 'https://example.com/user.json' }, 'Document', '', {
|
|
57
|
+
* 'https://example.com/user.json': userSchema,
|
|
58
|
+
* })
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string, schemas?: Readonly<Record<string, unknown>>, unknownKeys?: UnknownKeysStrategy, formats?: 'all' | readonly string[], coerce?: boolean, branchErrors?: boolean, repair?: boolean, importExt?: 'js' | 'ts', check?: boolean) => Promise<GeneratedFile[]>;
|