@amritk/generate-parsers 0.12.3 → 0.13.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/generators/build-schema.js +11 -1
- package/dist/generators/generate-parser-function.js +62 -125
- package/dist/generators/generate-strict-assertion.d.ts +16 -1
- package/dist/generators/generate-strict-assertion.js +58 -5
- package/dist/generators/generate-type-checks.d.ts +65 -0
- package/dist/generators/generate-type-checks.js +370 -0
- package/package.json +2 -2
|
@@ -21,7 +21,17 @@ const readHelperSource = async (helper) => {
|
|
|
21
21
|
const helpersRoot = dirname(require.resolve('@amritk/helpers/package.json'));
|
|
22
22
|
try {
|
|
23
23
|
const source = await readFile(resolvePath(helpersRoot, 'src', `${helper}.ts`), 'utf-8');
|
|
24
|
-
|
|
24
|
+
// The leading `\b` in this pattern is load-bearing beyond its regex meaning.
|
|
25
|
+
// Our build runs tsc-alias --resolveFullPaths over the compiled output, and its
|
|
26
|
+
// scanner rewrites anything that looks like `from '<path>'` — even inside a
|
|
27
|
+
// regex literal. Without the `\b`, normalize-path turned this pattern's
|
|
28
|
+
// backslashes into slashes and v0.12.3 shipped with an unparseable regex that
|
|
29
|
+
// crashed the CLI on load. The `\b` blocks that scan (in the emitted text,
|
|
30
|
+
// `from` is preceded by the word character `b`, so tsc-alias's own `\bfrom`
|
|
31
|
+
// never matches) and is also semantically correct: an import keyword always
|
|
32
|
+
// sits at a word boundary. The dist smoke test (scripts/dist-smoke.test.ts)
|
|
33
|
+
// fails the build if this ever regresses.
|
|
34
|
+
return { ext: 'ts', content: source.replace(/\bfrom '(\.\/[^'".]+)'/g, "from '$1.js'") };
|
|
25
35
|
}
|
|
26
36
|
catch {
|
|
27
37
|
const compiled = await readFile(resolvePath(helpersRoot, 'dist', `${helper}.js`), 'utf-8');
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
|
-
import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
|
|
3
2
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
4
3
|
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
5
4
|
import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor';
|
|
6
|
-
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum,
|
|
5
|
+
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasItems, hasOneOf, hasProperties, hasRef, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
7
6
|
import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
|
|
8
7
|
import { findDiscriminator } from '../helpers/find-discriminator.js';
|
|
9
8
|
import { getDefaultValue } from '../helpers/get-default-value.js';
|
|
10
9
|
import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
|
|
11
10
|
import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
|
|
11
|
+
import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, shapeValidatorName, } from './generate-type-checks.js';
|
|
12
12
|
import { generateValidationExpression, scalarItemTypeCheck } from './generate-validation-expression.js';
|
|
13
13
|
/**
|
|
14
14
|
* Generates the parser function name from a type name.
|
|
@@ -374,6 +374,30 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
|
|
|
374
374
|
return dispatch;
|
|
375
375
|
}
|
|
376
376
|
}
|
|
377
|
+
// An alias definition (a bare `$ref` with no shape of its own, e.g. a root
|
|
378
|
+
// schema that is just `$ref: '#/$defs/expr'`) delegates to the referenced
|
|
379
|
+
// parser instead of blindly casting, in both modes. Guarded against
|
|
380
|
+
// self-reference: delegating to ourselves would recurse forever.
|
|
381
|
+
if (unionCtx?.useRefImports && isSchemaObject(schema) && hasRef(schema) && !hasProperties(schema)) {
|
|
382
|
+
const refName = refToName(schema.$ref, unionCtx.suffix);
|
|
383
|
+
if (refName !== typeName) {
|
|
384
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => ${generateParserName(refName)}(input) as ${typeName};`;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Strict union enforcement for inline (or mixed) branches: membership is the
|
|
388
|
+
// disjunction of per-branch checks, and a non-member throws. Only emitted
|
|
389
|
+
// when every branch check is false-sound (see canEnforceUnion) — otherwise a
|
|
390
|
+
// conservative stub validator somewhere in the graph could reject valid
|
|
391
|
+
// input — and never when stripping unknown keys (see UnionParserContext).
|
|
392
|
+
if (strict && unionCtx && isSchemaObject(schema)) {
|
|
393
|
+
const branches = getUnionBranches(schema);
|
|
394
|
+
if (branches && !unionCtx.stripUnknown && canEnforceUnion(branches, unionCtx.rootSchema)) {
|
|
395
|
+
const check = generateUnionCheck('input', branches, unionCtx.useRefImports, unionCtx.suffix);
|
|
396
|
+
if (check !== null) {
|
|
397
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!(${check})) throw new Error(${JSON.stringify(`[${typeName}] value does not match any union branch`)});\n return input as ${typeName};\n};`;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
377
401
|
// `const`/`enum` define the value space directly (the generated type is a
|
|
378
402
|
// literal / literal-union), so a non-member must coerce to a valid member —
|
|
379
403
|
// otherwise the returned value would not be of the declared type. This runs
|
|
@@ -471,12 +495,6 @@ const toVarName = (key) => {
|
|
|
471
495
|
const safe = key.replace(/[^a-zA-Z0-9_$]/g, '_');
|
|
472
496
|
return `_${safe}`;
|
|
473
497
|
};
|
|
474
|
-
/**
|
|
475
|
-
* Returns the shape-predicate function name for a given type name.
|
|
476
|
-
* The predicate is generated alongside each parser and tells callers
|
|
477
|
-
* whether `input` is already in the shape produced by the parser's fast path.
|
|
478
|
-
*/
|
|
479
|
-
const shapeValidatorName = (typeName) => `validate${typeName}Shape`;
|
|
480
498
|
/**
|
|
481
499
|
* Matches an inline nested object property — an object schema written directly
|
|
482
500
|
* under `properties` with its own `properties`, rather than referenced via
|
|
@@ -544,119 +562,6 @@ const hasStrictKeys = (schema) => {
|
|
|
544
562
|
return false;
|
|
545
563
|
return true;
|
|
546
564
|
};
|
|
547
|
-
/**
|
|
548
|
-
* Generates a fast-path type check expression for a property.
|
|
549
|
-
* Returns null if the schema is too complex for a simple type check
|
|
550
|
-
* (e.g. has unions, enums that require more elaborate validation).
|
|
551
|
-
*
|
|
552
|
-
* When `useRefImports` is true, $ref properties (and arrays of $refs) are
|
|
553
|
-
* checked by calling the imported shape predicate, allowing parent parsers
|
|
554
|
-
* to fast-path through nested ref types without recursing into their parsers.
|
|
555
|
-
*/
|
|
556
|
-
const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
|
|
557
|
-
if (!isSchemaObject(schema))
|
|
558
|
-
return null;
|
|
559
|
-
// $ref via shape predicate (deep fast-path through nested types).
|
|
560
|
-
if (useRefImports && hasRef(schema)) {
|
|
561
|
-
const refName = refToName(schema.$ref, suffix);
|
|
562
|
-
return `${shapeValidatorName(refName)}(${varName})`;
|
|
563
|
-
}
|
|
564
|
-
// Skip fast path for complex schemas that need more elaborate validation
|
|
565
|
-
if (hasEnum(schema) ||
|
|
566
|
-
hasOneOf(schema) ||
|
|
567
|
-
hasAnyOf(schema) ||
|
|
568
|
-
hasAllOf(schema) ||
|
|
569
|
-
hasRef(schema) ||
|
|
570
|
-
'not' in schema) {
|
|
571
|
-
return null;
|
|
572
|
-
}
|
|
573
|
-
// A `const` property (the usual discriminated-union tag, e.g. `kind: "lit"`) has
|
|
574
|
-
// no `type`, but is fast-path predicable via strict equality. Handling it here is
|
|
575
|
-
// what lets a discriminated branch's shape validator be a real predicate instead
|
|
576
|
-
// of the `=> false` stub. Structural consts need deep equality, so skip those.
|
|
577
|
-
if (hasConst(schema)) {
|
|
578
|
-
const c = schema.const;
|
|
579
|
-
return c === null || typeof c !== 'object' ? `${varName} === ${JSON.stringify(c)}` : null;
|
|
580
|
-
}
|
|
581
|
-
if (!hasType(schema))
|
|
582
|
-
return null;
|
|
583
|
-
// Array of $refs: each item must satisfy the imported shape predicate.
|
|
584
|
-
if (useRefImports &&
|
|
585
|
-
schema.type === 'array' &&
|
|
586
|
-
hasItems(schema) &&
|
|
587
|
-
isSchemaObject(schema.items) &&
|
|
588
|
-
hasRef(schema.items)) {
|
|
589
|
-
const refName = refToName(schema.items.$ref, suffix);
|
|
590
|
-
return `Array.isArray(${varName}) && ${varName}.every(${shapeValidatorName(refName)})`;
|
|
591
|
-
}
|
|
592
|
-
const checks = [];
|
|
593
|
-
switch (schema.type) {
|
|
594
|
-
case 'string': {
|
|
595
|
-
checks.push(`typeof ${varName} === "string"`);
|
|
596
|
-
if (hasPattern(schema))
|
|
597
|
-
checks.push(`/${escapeRegexPattern(schema.pattern)}/.test(${varName})`);
|
|
598
|
-
if (hasMinLength(schema))
|
|
599
|
-
checks.push(`${varName}.length >= ${schema.minLength}`);
|
|
600
|
-
if (hasMaxLength(schema))
|
|
601
|
-
checks.push(`${varName}.length <= ${schema.maxLength}`);
|
|
602
|
-
break;
|
|
603
|
-
}
|
|
604
|
-
case 'number':
|
|
605
|
-
case 'integer': {
|
|
606
|
-
checks.push(`typeof ${varName} === "number"`);
|
|
607
|
-
// `integer` must reject non-integral numbers on the fast path too, otherwise
|
|
608
|
-
// `1.5` would pass through uncoerced.
|
|
609
|
-
if (schema.type === 'integer')
|
|
610
|
-
checks.push(`Number.isInteger(${varName})`);
|
|
611
|
-
if (hasMinimum(schema))
|
|
612
|
-
checks.push(`${varName} >= ${schema.minimum}`);
|
|
613
|
-
if (hasMaximum(schema))
|
|
614
|
-
checks.push(`${varName} <= ${schema.maximum}`);
|
|
615
|
-
if (hasExclusiveMinimum(schema))
|
|
616
|
-
checks.push(`${varName} > ${schema.exclusiveMinimum}`);
|
|
617
|
-
if (hasExclusiveMaximum(schema))
|
|
618
|
-
checks.push(`${varName} < ${schema.exclusiveMaximum}`);
|
|
619
|
-
if (hasMultipleOf(schema))
|
|
620
|
-
checks.push(multipleOfPassExpr(varName, schema.multipleOf));
|
|
621
|
-
break;
|
|
622
|
-
}
|
|
623
|
-
case 'boolean':
|
|
624
|
-
checks.push(`typeof ${varName} === "boolean"`);
|
|
625
|
-
break;
|
|
626
|
-
case 'array': {
|
|
627
|
-
checks.push(`Array.isArray(${varName})`);
|
|
628
|
-
if (hasMinItems(schema))
|
|
629
|
-
checks.push(`${varName}.length >= ${schema.minItems}`);
|
|
630
|
-
if (hasMaxItems(schema))
|
|
631
|
-
checks.push(`${varName}.length <= ${schema.maxItems}`);
|
|
632
|
-
if (hasUniqueItems(schema) && schema.uniqueItems === true) {
|
|
633
|
-
checks.push(`new Set(${varName}).size === ${varName}.length`);
|
|
634
|
-
}
|
|
635
|
-
// For a scalar item type, every element must already be well-typed to take
|
|
636
|
-
// the fast path; a mistyped element routes the array to the slow path where
|
|
637
|
-
// each element is coerced.
|
|
638
|
-
if (hasItems(schema) && !Array.isArray(schema.items)) {
|
|
639
|
-
const itemCheck = scalarItemTypeCheck(schema.items, '_it');
|
|
640
|
-
if (itemCheck)
|
|
641
|
-
checks.push(`${varName}.every((_it) => ${itemCheck})`);
|
|
642
|
-
}
|
|
643
|
-
break;
|
|
644
|
-
}
|
|
645
|
-
case 'object':
|
|
646
|
-
checks.push(`isObject(${varName})`);
|
|
647
|
-
break;
|
|
648
|
-
default:
|
|
649
|
-
return null;
|
|
650
|
-
}
|
|
651
|
-
if (checks.length === 0)
|
|
652
|
-
return null;
|
|
653
|
-
// checks[0] is safe: we guard with checks.length === 0 above
|
|
654
|
-
let result = checks[0];
|
|
655
|
-
for (let i = 1; i < checks.length; i++) {
|
|
656
|
-
result += ' && ' + checks[i];
|
|
657
|
-
}
|
|
658
|
-
return result;
|
|
659
|
-
};
|
|
660
565
|
/**
|
|
661
566
|
* Determines if a property needs a local variable or can be inlined.
|
|
662
567
|
* Schema-object properties are always cached: the slow-path ternary chain
|
|
@@ -677,7 +582,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
|
|
|
677
582
|
* - Fast path that returns input directly when all properties are already valid
|
|
678
583
|
* - Slow path with optimized expressions
|
|
679
584
|
*/
|
|
680
|
-
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false) => {
|
|
585
|
+
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema) => {
|
|
681
586
|
const functionName = generateParserName(typeName);
|
|
682
587
|
const exportPrefix = exported ? 'export ' : '';
|
|
683
588
|
if (!hasProperties(schema)) {
|
|
@@ -697,7 +602,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
697
602
|
const propSchema = schema.properties[key];
|
|
698
603
|
preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
|
|
699
604
|
preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown));
|
|
700
|
-
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown));
|
|
605
|
+
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema));
|
|
701
606
|
}
|
|
702
607
|
// additionalProperties: false — the per-key "is this undeclared" test inlines
|
|
703
608
|
// `!==` comparisons for a short key list and hoists a Set only for a long one
|
|
@@ -995,7 +900,12 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
995
900
|
lines.push(...varDeclLines);
|
|
996
901
|
lines.push(...warnLines);
|
|
997
902
|
// The first assertion line is the `isObject` check, already done above.
|
|
998
|
-
const assertionLines = generateObjectStrictAssertion(schema, typeName
|
|
903
|
+
const assertionLines = generateObjectStrictAssertion(schema, typeName, {
|
|
904
|
+
useRefImports,
|
|
905
|
+
suffix,
|
|
906
|
+
stripUnknown,
|
|
907
|
+
...(rootSchema !== undefined ? { rootSchema } : {}),
|
|
908
|
+
}).slice(1);
|
|
999
909
|
if (shallowGuard) {
|
|
1000
910
|
// stripUnknown: a well-typed input skips the assertions and goes straight
|
|
1001
911
|
// to the strip build (which removes extras and recurses into sub-parsers).
|
|
@@ -1436,6 +1346,7 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1436
1346
|
return generateNonObjectParser(typeName, schema, strict, {
|
|
1437
1347
|
useRefImports,
|
|
1438
1348
|
suffix,
|
|
1349
|
+
stripUnknown,
|
|
1439
1350
|
...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
|
|
1440
1351
|
});
|
|
1441
1352
|
}
|
|
@@ -1450,7 +1361,7 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1450
1361
|
// properties AND conditional keywords use all declared properties rather than
|
|
1451
1362
|
// only the if/then fragment.
|
|
1452
1363
|
if (hasProperties(schema)) {
|
|
1453
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
1364
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema);
|
|
1454
1365
|
}
|
|
1455
1366
|
// Handle conditional schemas (if/then/else) for schemas without explicit properties.
|
|
1456
1367
|
if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
|
|
@@ -1467,6 +1378,7 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1467
1378
|
return generateNonObjectParser(typeName, schema, strict, {
|
|
1468
1379
|
useRefImports,
|
|
1469
1380
|
suffix,
|
|
1381
|
+
stripUnknown,
|
|
1470
1382
|
...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
|
|
1471
1383
|
});
|
|
1472
1384
|
}
|
|
@@ -1527,6 +1439,31 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
|
|
|
1527
1439
|
const stub = `${exportPrefix}const ${fnName} = (_input: unknown): boolean => false;`;
|
|
1528
1440
|
if (!isSchemaObject(schema))
|
|
1529
1441
|
return stub;
|
|
1442
|
+
// An alias definition (a bare `$ref` with no shape of its own) delegates to
|
|
1443
|
+
// the referenced type's validator. Guarded against self-reference.
|
|
1444
|
+
if (useRefImports && hasRef(schema) && !hasProperties(schema)) {
|
|
1445
|
+
const refName = refToName(schema.$ref, suffix);
|
|
1446
|
+
if (refName !== typeName) {
|
|
1447
|
+
return `${exportPrefix}const ${fnName} = (input: unknown): boolean => ${shapeValidatorName(refName)}(input);`;
|
|
1448
|
+
}
|
|
1449
|
+
return stub;
|
|
1450
|
+
}
|
|
1451
|
+
// A pure union definition (file-level oneOf/anyOf without properties) gets a
|
|
1452
|
+
// real membership predicate when every branch is checkable. A recursive
|
|
1453
|
+
// union (e.g. `expr` whose branches $ref `expr` itself) works because the
|
|
1454
|
+
// branch checks call this very validator by name at runtime. Skipped under
|
|
1455
|
+
// stripUnknown: the branch checks carry no known-keys terms, so a `true`
|
|
1456
|
+
// could not guarantee the parser's strip build would be a no-op.
|
|
1457
|
+
if (!hasProperties(schema) && !('patternProperties' in schema) && !('if' in schema) && !stripUnknown) {
|
|
1458
|
+
const branches = getUnionBranches(schema);
|
|
1459
|
+
if (branches) {
|
|
1460
|
+
const check = generateUnionCheck('input', branches, useRefImports, suffix);
|
|
1461
|
+
if (check !== null) {
|
|
1462
|
+
return `${exportPrefix}const ${fnName} = (input: unknown): boolean => ${check};`;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return stub;
|
|
1466
|
+
}
|
|
1530
1467
|
if (!hasProperties(schema))
|
|
1531
1468
|
return stub;
|
|
1532
1469
|
// Composition / conditional schemas can match in many shapes — bail.
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Context for assertions that reach beyond the property's own schema: union
|
|
4
|
+
* membership checks may call imported `validate{X}Shape` predicates ($ref
|
|
5
|
+
* branches), which requires knowing the ref-import mode, the type-name suffix,
|
|
6
|
+
* and the root document (to prove those validators are real — see
|
|
7
|
+
* canEnforceUnion). `stripUnknown` disables union enforcement entirely, since
|
|
8
|
+
* shape validators then treat undeclared keys as a mismatch while the
|
|
9
|
+
* stripUnknown contract is to drop them.
|
|
10
|
+
*/
|
|
11
|
+
export type StrictAssertionContext = {
|
|
12
|
+
readonly useRefImports?: boolean;
|
|
13
|
+
readonly suffix?: string;
|
|
14
|
+
readonly rootSchema?: Record<string, unknown>;
|
|
15
|
+
readonly stripUnknown?: boolean;
|
|
16
|
+
};
|
|
2
17
|
/**
|
|
3
18
|
* Generates strict-mode assertions for the body of an object parser.
|
|
4
19
|
* Throws on:
|
|
@@ -10,7 +25,7 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
10
25
|
* Properties with a `$ref` are validated by the nested parser's own strict
|
|
11
26
|
* check when that parser is invoked downstream.
|
|
12
27
|
*/
|
|
13
|
-
export declare const generateObjectStrictAssertion: (schema: JSONSchema, typeName: string) => string[];
|
|
28
|
+
export declare const generateObjectStrictAssertion: (schema: JSONSchema, typeName: string, context?: StrictAssertionContext) => string[];
|
|
14
29
|
/**
|
|
15
30
|
* Generates a single strict-mode assertion line for a non-object scalar parser.
|
|
16
31
|
* Returns null when the schema has no type information to assert on.
|
|
@@ -2,7 +2,10 @@ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
|
2
2
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
3
3
|
import { multipleOfFailExpr } from '@amritk/helpers/multiple-of-check';
|
|
4
4
|
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
5
|
-
import { hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
5
|
+
import { hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
6
|
+
import { generateEnumCheck } from './generate-enum-check.js';
|
|
7
|
+
import { canEnforceUnion, generateUnionCheck, getUnionBranches } from './generate-type-checks.js';
|
|
8
|
+
import { scalarItemTypeCheck } from './generate-validation-expression.js';
|
|
6
9
|
/**
|
|
7
10
|
* Returns the inline condition that is true when `accessor` is the wrong type
|
|
8
11
|
* for the given JSON Schema primitive type.
|
|
@@ -94,15 +97,41 @@ const generateConstraintChecks = (acc, propSchema, typeName, key) => {
|
|
|
94
97
|
// a plain `new Set` would only catch primitive duplicates, not equal objects.
|
|
95
98
|
lines.push(` if (Array.isArray(${acc}) && new Set(${acc}.map((_u) => JSON.stringify(_u))).size !== ${acc}.length) ${throwError(`${field} must NOT have duplicate items`)};`);
|
|
96
99
|
}
|
|
100
|
+
// Item types: the fast path proves them via `.every`, but this slow path
|
|
101
|
+
// used to check only length/uniqueness, letting e.g. a number slip into a
|
|
102
|
+
// declared `string[]`. Enforce scalar and enum item schemas here; richer
|
|
103
|
+
// item schemas ($refs, objects) are validated by their own parsers.
|
|
104
|
+
const itemCheck = generateItemCheck(propSchema);
|
|
105
|
+
if (itemCheck) {
|
|
106
|
+
lines.push(` if (Array.isArray(${acc}) && !${acc}.every((_it) => ${itemCheck.check})) ${throwError(`${field} ${itemCheck.message}`)};`);
|
|
107
|
+
}
|
|
97
108
|
}
|
|
98
109
|
return lines;
|
|
99
110
|
};
|
|
111
|
+
/**
|
|
112
|
+
* Boolean per-item check (bound to `_it`) for an array schema's `items`, with
|
|
113
|
+
* the error-message fragment describing what was expected. Only scalar types
|
|
114
|
+
* and enums are checked — returns null for anything richer.
|
|
115
|
+
*/
|
|
116
|
+
const generateItemCheck = (schema) => {
|
|
117
|
+
if (!isSchemaObject(schema) || !hasItems(schema) || Array.isArray(schema.items))
|
|
118
|
+
return null;
|
|
119
|
+
const items = schema.items;
|
|
120
|
+
if (isSchemaObject(items) && hasEnum(items) && items.enum.length > 0) {
|
|
121
|
+
const label = items.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
122
|
+
return { check: generateEnumCheck('_it', items.enum), message: `items must be one of: ${label}` };
|
|
123
|
+
}
|
|
124
|
+
const scalarCheck = scalarItemTypeCheck(items, '_it');
|
|
125
|
+
if (scalarCheck === null || !isSchemaObject(items) || !hasType(items))
|
|
126
|
+
return null;
|
|
127
|
+
return { check: scalarCheck, message: `items expected ${typeLabel(items.type)}` };
|
|
128
|
+
};
|
|
100
129
|
/**
|
|
101
130
|
* Generates strict-mode lines for a single property of an object schema.
|
|
102
131
|
* Properties with a `$ref` are skipped here — the nested parser handles its own
|
|
103
132
|
* strict check when called from the parent's slow path.
|
|
104
133
|
*/
|
|
105
|
-
const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
|
|
134
|
+
const generatePropertyAssertion = (key, propSchema, isRequired, typeName, context = {}) => {
|
|
106
135
|
const acc = safeAccessor('input', key);
|
|
107
136
|
const field = `[${typeName}] field '${key}'`;
|
|
108
137
|
const lines = [];
|
|
@@ -113,6 +142,21 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
|
|
|
113
142
|
return lines;
|
|
114
143
|
if (hasRef(propSchema))
|
|
115
144
|
return lines;
|
|
145
|
+
// Union properties: enforce membership when every branch check is
|
|
146
|
+
// false-sound (canEnforceUnion), so a value matching no variant throws
|
|
147
|
+
// instead of passing through untyped. Left unenforced (pass-through, the
|
|
148
|
+
// historical behavior) when any branch is too complex to check safely.
|
|
149
|
+
const unionBranches = getUnionBranches(propSchema);
|
|
150
|
+
if (unionBranches) {
|
|
151
|
+
if (!context.stripUnknown && canEnforceUnion(unionBranches, context.rootSchema)) {
|
|
152
|
+
const check = generateUnionCheck(acc, unionBranches, context.useRefImports ?? false, context.suffix ?? '');
|
|
153
|
+
if (check !== null) {
|
|
154
|
+
const failure = throwError(`${field} does not match any allowed variant`);
|
|
155
|
+
lines.push(isRequired ? ` if (!(${check})) ${failure};` : ` if (${acc} !== undefined && !(${check})) ${failure};`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
116
160
|
const instanceOf = getMjstInstanceOf(propSchema);
|
|
117
161
|
if (instanceOf) {
|
|
118
162
|
if (isRequired) {
|
|
@@ -171,14 +215,14 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
|
|
|
171
215
|
* Properties with a `$ref` are validated by the nested parser's own strict
|
|
172
216
|
* check when that parser is invoked downstream.
|
|
173
217
|
*/
|
|
174
|
-
export const generateObjectStrictAssertion = (schema, typeName) => {
|
|
218
|
+
export const generateObjectStrictAssertion = (schema, typeName, context = {}) => {
|
|
175
219
|
const lines = [];
|
|
176
220
|
lines.push(` if (!isObject(input)) ${throwError(`[${typeName}] expected object, got `, 'input === null ? "null" : typeof input')};`);
|
|
177
221
|
if (!hasProperties(schema) || !isSchemaObject(schema))
|
|
178
222
|
return lines;
|
|
179
223
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
180
224
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
181
|
-
lines.push(...generatePropertyAssertion(key, propSchema, required.has(key), typeName));
|
|
225
|
+
lines.push(...generatePropertyAssertion(key, propSchema, required.has(key), typeName, context));
|
|
182
226
|
}
|
|
183
227
|
return lines;
|
|
184
228
|
};
|
|
@@ -202,5 +246,14 @@ export const generateScalarStrictAssertion = (schema, typeName) => {
|
|
|
202
246
|
const wrongType = wrongTypeCondition('input', t);
|
|
203
247
|
if (!wrongType)
|
|
204
248
|
return null;
|
|
205
|
-
|
|
249
|
+
const lines = [` if (${wrongType}) ${throwError(`[${typeName}] expected ${typeLabel(t)}, got `, got)};`];
|
|
250
|
+
// Root-level arrays enforce scalar/enum item types too — the same gap the
|
|
251
|
+
// property path closes in generateConstraintChecks.
|
|
252
|
+
if (t === 'array') {
|
|
253
|
+
const itemCheck = generateItemCheck(schema);
|
|
254
|
+
if (itemCheck) {
|
|
255
|
+
lines.push(` if (!(input as readonly unknown[]).every((_it) => ${itemCheck.check})) ${throwError(`[${typeName}] ${itemCheck.message}`)};`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return lines.join('\n');
|
|
206
259
|
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Boolean type-check expression builders shared by the shape validators, the
|
|
4
|
+
* parsers' fast paths, and the strict-mode assertions. They live in their own
|
|
5
|
+
* module (rather than generate-parser-function) so generate-strict-assertion
|
|
6
|
+
* can use them without an import cycle.
|
|
7
|
+
*
|
|
8
|
+
* Every expression built here is *true-sound*: when it evaluates to true the
|
|
9
|
+
* value definitely matches the schema fragment. That is all a fast path needs.
|
|
10
|
+
* The reverse direction — false definitely means invalid — only holds when
|
|
11
|
+
* {@link canEnforceUnion} approves the schema, because a check may call an
|
|
12
|
+
* imported `validate{X}Shape` that was generated as a conservative `=> false`
|
|
13
|
+
* stub. Strict-mode code that *throws* on a false check must gate on it.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Returns the shape-predicate function name for a given type name.
|
|
17
|
+
* The predicate is generated alongside each parser and tells callers
|
|
18
|
+
* whether `input` is already in the shape produced by the parser's fast path.
|
|
19
|
+
*/
|
|
20
|
+
export declare const shapeValidatorName: (typeName: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* Extracts the branch list of a `oneOf`/`anyOf` union, or `null` when the
|
|
23
|
+
* schema is not a union (or mixes in other composition keywords we cannot
|
|
24
|
+
* turn into a membership check).
|
|
25
|
+
*/
|
|
26
|
+
export declare const getUnionBranches: (schema: JSONSchema) => readonly JSONSchema[] | null;
|
|
27
|
+
/**
|
|
28
|
+
* Generates a fast-path type check expression for a property.
|
|
29
|
+
* Returns null if the schema is too complex for a simple type check.
|
|
30
|
+
*
|
|
31
|
+
* When `useRefImports` is true, $ref properties (and arrays of $refs) are
|
|
32
|
+
* checked by calling the imported shape predicate, allowing parent parsers
|
|
33
|
+
* to fast-path through nested ref types without recursing into their parsers.
|
|
34
|
+
*/
|
|
35
|
+
export declare const generatePropertyTypeCheck: (varName: string, schema: JSONSchema, useRefImports: boolean, suffix: string) => string | null;
|
|
36
|
+
/**
|
|
37
|
+
* Generates a boolean check that `varName` matches an inline object schema —
|
|
38
|
+
* `isObject` plus a per-property check for every declared property (and a
|
|
39
|
+
* presence check for `required` keys without a declared schema). Used for
|
|
40
|
+
* union branches written inline, where no named shape validator exists to
|
|
41
|
+
* call. Returns null when any property is too complex to check.
|
|
42
|
+
*/
|
|
43
|
+
export declare const generateInlineObjectCheck: (varName: string, schema: JSONSchema, useRefImports: boolean, suffix: string) => string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Generates a union membership check: true when `varName` matches at least one
|
|
46
|
+
* branch of a `oneOf`/`anyOf`. Inline object branches get a per-property check;
|
|
47
|
+
* everything else goes through {@link generatePropertyTypeCheck} ($refs call
|
|
48
|
+
* the imported shape validator, consts/enums/scalars check directly). Returns
|
|
49
|
+
* null when any branch cannot be checked.
|
|
50
|
+
*/
|
|
51
|
+
export declare const generateUnionCheck: (varName: string, branches: readonly JSONSchema[], useRefImports: boolean, suffix: string) => string | null;
|
|
52
|
+
/**
|
|
53
|
+
* True when the union membership check emitted by {@link generateUnionCheck}
|
|
54
|
+
* is also *false-sound* — a false result definitely means the value is invalid
|
|
55
|
+
* — so strict mode may throw on it.
|
|
56
|
+
*
|
|
57
|
+
* The risk is `$ref`s: a branch (or a property inside one) whose check calls
|
|
58
|
+
* an imported `validate{X}Shape` is only trustworthy when that validator was
|
|
59
|
+
* generated as a real predicate. This walks every reachable ref (mirroring
|
|
60
|
+
* generateShapeValidator's stub conditions) and rejects the union when any of
|
|
61
|
+
* them would have produced the conservative `=> false` stub, or is itself
|
|
62
|
+
* built on one. Cycles (e.g. a recursive `expr` union) are assumed sound —
|
|
63
|
+
* the recursion bottoms out on the data, not the schema.
|
|
64
|
+
*/
|
|
65
|
+
export declare const canEnforceUnion: (branches: readonly JSONSchema[], rootSchema: Record<string, unknown> | undefined) => boolean;
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
|
+
import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
|
|
3
|
+
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
4
|
+
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
5
|
+
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
6
|
+
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
7
|
+
import { generateEnumCheck } from './generate-enum-check.js';
|
|
8
|
+
import { scalarItemTypeCheck } from './generate-validation-expression.js';
|
|
9
|
+
/**
|
|
10
|
+
* Boolean type-check expression builders shared by the shape validators, the
|
|
11
|
+
* parsers' fast paths, and the strict-mode assertions. They live in their own
|
|
12
|
+
* module (rather than generate-parser-function) so generate-strict-assertion
|
|
13
|
+
* can use them without an import cycle.
|
|
14
|
+
*
|
|
15
|
+
* Every expression built here is *true-sound*: when it evaluates to true the
|
|
16
|
+
* value definitely matches the schema fragment. That is all a fast path needs.
|
|
17
|
+
* The reverse direction — false definitely means invalid — only holds when
|
|
18
|
+
* {@link canEnforceUnion} approves the schema, because a check may call an
|
|
19
|
+
* imported `validate{X}Shape` that was generated as a conservative `=> false`
|
|
20
|
+
* stub. Strict-mode code that *throws* on a false check must gate on it.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Returns the shape-predicate function name for a given type name.
|
|
24
|
+
* The predicate is generated alongside each parser and tells callers
|
|
25
|
+
* whether `input` is already in the shape produced by the parser's fast path.
|
|
26
|
+
*/
|
|
27
|
+
export const shapeValidatorName = (typeName) => `validate${typeName}Shape`;
|
|
28
|
+
/**
|
|
29
|
+
* Extracts the branch list of a `oneOf`/`anyOf` union, or `null` when the
|
|
30
|
+
* schema is not a union (or mixes in other composition keywords we cannot
|
|
31
|
+
* turn into a membership check).
|
|
32
|
+
*/
|
|
33
|
+
export const getUnionBranches = (schema) => {
|
|
34
|
+
if (!isSchemaObject(schema))
|
|
35
|
+
return null;
|
|
36
|
+
if (hasAllOf(schema) || 'not' in schema)
|
|
37
|
+
return null;
|
|
38
|
+
if (hasOneOf(schema) && schema.oneOf.length > 0)
|
|
39
|
+
return schema.oneOf;
|
|
40
|
+
if (hasAnyOf(schema) && schema.anyOf.length > 0)
|
|
41
|
+
return schema.anyOf;
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Generates a fast-path type check expression for a property.
|
|
46
|
+
* Returns null if the schema is too complex for a simple type check.
|
|
47
|
+
*
|
|
48
|
+
* When `useRefImports` is true, $ref properties (and arrays of $refs) are
|
|
49
|
+
* checked by calling the imported shape predicate, allowing parent parsers
|
|
50
|
+
* to fast-path through nested ref types without recursing into their parsers.
|
|
51
|
+
*/
|
|
52
|
+
export const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
|
|
53
|
+
if (!isSchemaObject(schema))
|
|
54
|
+
return null;
|
|
55
|
+
// $ref via shape predicate (deep fast-path through nested types).
|
|
56
|
+
if (useRefImports && hasRef(schema)) {
|
|
57
|
+
const refName = refToName(schema.$ref, suffix);
|
|
58
|
+
return `${shapeValidatorName(refName)}(${varName})`;
|
|
59
|
+
}
|
|
60
|
+
if (hasRef(schema))
|
|
61
|
+
return null;
|
|
62
|
+
// A fixed member set is directly predicable regardless of `type` — this is
|
|
63
|
+
// what lets a nested object carrying an enum property (e.g. `axiom.kind`)
|
|
64
|
+
// keep a real shape validator instead of the `=> false` stub.
|
|
65
|
+
if (hasEnum(schema)) {
|
|
66
|
+
return schema.enum.length > 0 ? generateEnumCheck(varName, schema.enum) : null;
|
|
67
|
+
}
|
|
68
|
+
// A union property is predicable when every branch is: membership is the
|
|
69
|
+
// disjunction of the branch checks.
|
|
70
|
+
const branches = getUnionBranches(schema);
|
|
71
|
+
if (branches) {
|
|
72
|
+
return generateUnionCheck(varName, branches, useRefImports, suffix);
|
|
73
|
+
}
|
|
74
|
+
// Remaining composition (allOf, not, or a union mixed with them) cannot be
|
|
75
|
+
// expressed as a simple boolean check.
|
|
76
|
+
if (hasOneOf(schema) || hasAnyOf(schema) || hasAllOf(schema) || 'not' in schema) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
// A `const` property (the usual discriminated-union tag, e.g. `kind: "lit"`) has
|
|
80
|
+
// no `type`, but is fast-path predicable via strict equality. Handling it here is
|
|
81
|
+
// what lets a discriminated branch's shape validator be a real predicate instead
|
|
82
|
+
// of the `=> false` stub. Structural consts need deep equality, so skip those.
|
|
83
|
+
if (hasConst(schema)) {
|
|
84
|
+
const c = schema.const;
|
|
85
|
+
return c === null || typeof c !== 'object' ? `${varName} === ${JSON.stringify(c)}` : null;
|
|
86
|
+
}
|
|
87
|
+
if (!hasType(schema))
|
|
88
|
+
return null;
|
|
89
|
+
// Array of $refs: each item must satisfy the imported shape predicate.
|
|
90
|
+
if (useRefImports &&
|
|
91
|
+
schema.type === 'array' &&
|
|
92
|
+
hasItems(schema) &&
|
|
93
|
+
isSchemaObject(schema.items) &&
|
|
94
|
+
hasRef(schema.items)) {
|
|
95
|
+
const refName = refToName(schema.items.$ref, suffix);
|
|
96
|
+
return `Array.isArray(${varName}) && ${varName}.every(${shapeValidatorName(refName)})`;
|
|
97
|
+
}
|
|
98
|
+
const checks = [];
|
|
99
|
+
switch (schema.type) {
|
|
100
|
+
case 'string': {
|
|
101
|
+
checks.push(`typeof ${varName} === "string"`);
|
|
102
|
+
if (hasPattern(schema))
|
|
103
|
+
checks.push(`/${escapeRegexPattern(schema.pattern)}/.test(${varName})`);
|
|
104
|
+
if (hasMinLength(schema))
|
|
105
|
+
checks.push(`${varName}.length >= ${schema.minLength}`);
|
|
106
|
+
if (hasMaxLength(schema))
|
|
107
|
+
checks.push(`${varName}.length <= ${schema.maxLength}`);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'number':
|
|
111
|
+
case 'integer': {
|
|
112
|
+
checks.push(`typeof ${varName} === "number"`);
|
|
113
|
+
// `integer` must reject non-integral numbers on the fast path too, otherwise
|
|
114
|
+
// `1.5` would pass through uncoerced.
|
|
115
|
+
if (schema.type === 'integer')
|
|
116
|
+
checks.push(`Number.isInteger(${varName})`);
|
|
117
|
+
if (hasMinimum(schema))
|
|
118
|
+
checks.push(`${varName} >= ${schema.minimum}`);
|
|
119
|
+
if (hasMaximum(schema))
|
|
120
|
+
checks.push(`${varName} <= ${schema.maximum}`);
|
|
121
|
+
if (hasExclusiveMinimum(schema))
|
|
122
|
+
checks.push(`${varName} > ${schema.exclusiveMinimum}`);
|
|
123
|
+
if (hasExclusiveMaximum(schema))
|
|
124
|
+
checks.push(`${varName} < ${schema.exclusiveMaximum}`);
|
|
125
|
+
if (hasMultipleOf(schema))
|
|
126
|
+
checks.push(multipleOfPassExpr(varName, schema.multipleOf));
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'boolean':
|
|
130
|
+
checks.push(`typeof ${varName} === "boolean"`);
|
|
131
|
+
break;
|
|
132
|
+
case 'array': {
|
|
133
|
+
checks.push(`Array.isArray(${varName})`);
|
|
134
|
+
if (hasMinItems(schema))
|
|
135
|
+
checks.push(`${varName}.length >= ${schema.minItems}`);
|
|
136
|
+
if (hasMaxItems(schema))
|
|
137
|
+
checks.push(`${varName}.length <= ${schema.maxItems}`);
|
|
138
|
+
if (hasUniqueItems(schema) && schema.uniqueItems === true) {
|
|
139
|
+
checks.push(`new Set(${varName}).size === ${varName}.length`);
|
|
140
|
+
}
|
|
141
|
+
// For a scalar or enum item type, every element must already be well-typed
|
|
142
|
+
// to take the fast path; a mismatched element routes the array to the slow
|
|
143
|
+
// path where each element is coerced (or, in strict mode, throws).
|
|
144
|
+
if (hasItems(schema) && !Array.isArray(schema.items)) {
|
|
145
|
+
const items = schema.items;
|
|
146
|
+
const itemCheck = scalarItemTypeCheck(items, '_it') ??
|
|
147
|
+
(isSchemaObject(items) && hasEnum(items) && items.enum.length > 0
|
|
148
|
+
? generateEnumCheck('_it', items.enum)
|
|
149
|
+
: null);
|
|
150
|
+
if (itemCheck)
|
|
151
|
+
checks.push(`${varName}.every((_it) => ${itemCheck})`);
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'object':
|
|
156
|
+
checks.push(`isObject(${varName})`);
|
|
157
|
+
break;
|
|
158
|
+
default:
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (checks.length === 0)
|
|
162
|
+
return null;
|
|
163
|
+
// checks[0] is safe: we guard with checks.length === 0 above
|
|
164
|
+
let result = checks[0];
|
|
165
|
+
for (let i = 1; i < checks.length; i++) {
|
|
166
|
+
result += ' && ' + checks[i];
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Generates a boolean check that `varName` matches an inline object schema —
|
|
172
|
+
* `isObject` plus a per-property check for every declared property (and a
|
|
173
|
+
* presence check for `required` keys without a declared schema). Used for
|
|
174
|
+
* union branches written inline, where no named shape validator exists to
|
|
175
|
+
* call. Returns null when any property is too complex to check.
|
|
176
|
+
*/
|
|
177
|
+
export const generateInlineObjectCheck = (varName, schema, useRefImports, suffix) => {
|
|
178
|
+
if (!isSchemaObject(schema) || !isObjectSchema(schema) || !hasProperties(schema))
|
|
179
|
+
return null;
|
|
180
|
+
if (hasAllOf(schema) || hasOneOf(schema) || hasAnyOf(schema) || 'not' in schema)
|
|
181
|
+
return null;
|
|
182
|
+
if ('patternProperties' in schema || 'if' in schema)
|
|
183
|
+
return null;
|
|
184
|
+
// additionalProperties-as-schema (records) would leave the record values
|
|
185
|
+
// unchecked, breaking the "true means valid" guarantee.
|
|
186
|
+
if (hasAdditionalProperties(schema) && typeof schema.additionalProperties !== 'boolean')
|
|
187
|
+
return null;
|
|
188
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
189
|
+
const checks = [`isObject(${varName})`];
|
|
190
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
191
|
+
const acc = safeAccessor(varName, key);
|
|
192
|
+
const check = generatePropertyTypeCheck(acc, propSchema, useRefImports, suffix);
|
|
193
|
+
if (check === null)
|
|
194
|
+
return null;
|
|
195
|
+
checks.push(required.has(key) ? check : `(${acc} === undefined || ${check})`);
|
|
196
|
+
required.delete(key);
|
|
197
|
+
}
|
|
198
|
+
// `required` keys without a declared property schema still need a presence check.
|
|
199
|
+
for (const key of required) {
|
|
200
|
+
checks.push(`${JSON.stringify(key)} in ${varName}`);
|
|
201
|
+
}
|
|
202
|
+
// additionalProperties: false — extras make the value invalid for this branch.
|
|
203
|
+
if (hasAdditionalProperties(schema) && schema.additionalProperties === false) {
|
|
204
|
+
const allowedKeys = JSON.stringify(Object.keys(schema.properties));
|
|
205
|
+
checks.push(`Object.keys(${varName}).every((_k) => ${allowedKeys}.includes(_k))`);
|
|
206
|
+
}
|
|
207
|
+
return checks.join(' && ');
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Generates a union membership check: true when `varName` matches at least one
|
|
211
|
+
* branch of a `oneOf`/`anyOf`. Inline object branches get a per-property check;
|
|
212
|
+
* everything else goes through {@link generatePropertyTypeCheck} ($refs call
|
|
213
|
+
* the imported shape validator, consts/enums/scalars check directly). Returns
|
|
214
|
+
* null when any branch cannot be checked.
|
|
215
|
+
*/
|
|
216
|
+
export const generateUnionCheck = (varName, branches, useRefImports, suffix) => {
|
|
217
|
+
if (branches.length === 0)
|
|
218
|
+
return null;
|
|
219
|
+
const parts = [];
|
|
220
|
+
for (const branch of branches) {
|
|
221
|
+
if (!isSchemaObject(branch))
|
|
222
|
+
return null;
|
|
223
|
+
const check = isObjectSchema(branch) && hasProperties(branch) && !hasRef(branch)
|
|
224
|
+
? generateInlineObjectCheck(varName, branch, useRefImports, suffix)
|
|
225
|
+
: generatePropertyTypeCheck(varName, branch, useRefImports, suffix);
|
|
226
|
+
if (check === null)
|
|
227
|
+
return null;
|
|
228
|
+
parts.push(`(${check})`);
|
|
229
|
+
}
|
|
230
|
+
// Callers conjoin this with other checks, so a multi-branch disjunction must
|
|
231
|
+
// carry its own parentheses — `a || b && c` binds the wrong way without them.
|
|
232
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(' || ')})`;
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* True when the union membership check emitted by {@link generateUnionCheck}
|
|
236
|
+
* is also *false-sound* — a false result definitely means the value is invalid
|
|
237
|
+
* — so strict mode may throw on it.
|
|
238
|
+
*
|
|
239
|
+
* The risk is `$ref`s: a branch (or a property inside one) whose check calls
|
|
240
|
+
* an imported `validate{X}Shape` is only trustworthy when that validator was
|
|
241
|
+
* generated as a real predicate. This walks every reachable ref (mirroring
|
|
242
|
+
* generateShapeValidator's stub conditions) and rejects the union when any of
|
|
243
|
+
* them would have produced the conservative `=> false` stub, or is itself
|
|
244
|
+
* built on one. Cycles (e.g. a recursive `expr` union) are assumed sound —
|
|
245
|
+
* the recursion bottoms out on the data, not the schema.
|
|
246
|
+
*/
|
|
247
|
+
export const canEnforceUnion = (branches, rootSchema) => {
|
|
248
|
+
const visiting = new Set();
|
|
249
|
+
return (branches.length > 0 &&
|
|
250
|
+
branches.every((branch) => {
|
|
251
|
+
if (!isSchemaObject(branch))
|
|
252
|
+
return false;
|
|
253
|
+
return isObjectSchema(branch) && hasProperties(branch) && !hasRef(branch)
|
|
254
|
+
? canTrustInlineObjectCheck(branch, rootSchema, visiting)
|
|
255
|
+
: canTrustPropertyCheck(branch, rootSchema, visiting);
|
|
256
|
+
}));
|
|
257
|
+
};
|
|
258
|
+
/** Mirrors {@link generatePropertyTypeCheck}: non-null AND every embedded validator call is real. */
|
|
259
|
+
const canTrustPropertyCheck = (schema, rootSchema, visiting) => {
|
|
260
|
+
if (!isSchemaObject(schema))
|
|
261
|
+
return false;
|
|
262
|
+
if (hasRef(schema)) {
|
|
263
|
+
return canTrustReferencedValidator(schema.$ref, rootSchema, visiting);
|
|
264
|
+
}
|
|
265
|
+
if (hasEnum(schema))
|
|
266
|
+
return schema.enum.length > 0;
|
|
267
|
+
const branches = getUnionBranches(schema);
|
|
268
|
+
if (branches) {
|
|
269
|
+
return branches.every((branch) => {
|
|
270
|
+
if (!isSchemaObject(branch))
|
|
271
|
+
return false;
|
|
272
|
+
return isObjectSchema(branch) && hasProperties(branch) && !hasRef(branch)
|
|
273
|
+
? canTrustInlineObjectCheck(branch, rootSchema, visiting)
|
|
274
|
+
: canTrustPropertyCheck(branch, rootSchema, visiting);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
if (hasOneOf(schema) || hasAnyOf(schema) || hasAllOf(schema) || 'not' in schema)
|
|
278
|
+
return false;
|
|
279
|
+
if (hasConst(schema))
|
|
280
|
+
return schema.const === null || typeof schema.const !== 'object';
|
|
281
|
+
if (!hasType(schema))
|
|
282
|
+
return false;
|
|
283
|
+
if (schema.type === 'array' && hasItems(schema) && isSchemaObject(schema.items) && hasRef(schema.items)) {
|
|
284
|
+
return canTrustReferencedValidator(schema.items.$ref, rootSchema, visiting);
|
|
285
|
+
}
|
|
286
|
+
switch (schema.type) {
|
|
287
|
+
case 'string':
|
|
288
|
+
case 'number':
|
|
289
|
+
case 'integer':
|
|
290
|
+
case 'boolean':
|
|
291
|
+
case 'array':
|
|
292
|
+
case 'object':
|
|
293
|
+
return true;
|
|
294
|
+
default:
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
/** Mirrors {@link generateInlineObjectCheck}: non-null AND every property check is trustworthy. */
|
|
299
|
+
const canTrustInlineObjectCheck = (schema, rootSchema, visiting) => {
|
|
300
|
+
if (!isSchemaObject(schema) || !isObjectSchema(schema) || !hasProperties(schema))
|
|
301
|
+
return false;
|
|
302
|
+
if (hasAllOf(schema) || hasOneOf(schema) || hasAnyOf(schema) || 'not' in schema)
|
|
303
|
+
return false;
|
|
304
|
+
if ('patternProperties' in schema || 'if' in schema)
|
|
305
|
+
return false;
|
|
306
|
+
if (hasAdditionalProperties(schema) && typeof schema.additionalProperties !== 'boolean')
|
|
307
|
+
return false;
|
|
308
|
+
return Object.values(schema.properties).every((propSchema) => canTrustPropertyCheck(propSchema, rootSchema, visiting));
|
|
309
|
+
};
|
|
310
|
+
/**
|
|
311
|
+
* True when the file generated for `ref`'s target carries a *real* (non-stub)
|
|
312
|
+
* shape validator whose result can be trusted in both directions. Mirrors the
|
|
313
|
+
* schema classes generateShapeValidator (and its new union/alias paths) turns
|
|
314
|
+
* into real predicates, recursing into every reachable ref.
|
|
315
|
+
*/
|
|
316
|
+
const canTrustReferencedValidator = (ref, rootSchema, visiting) => {
|
|
317
|
+
if (visiting.has(ref))
|
|
318
|
+
return true;
|
|
319
|
+
if (!rootSchema)
|
|
320
|
+
return false;
|
|
321
|
+
const resolved = resolveRef(ref, rootSchema);
|
|
322
|
+
if (!resolved || !isSchemaObject(resolved))
|
|
323
|
+
return false;
|
|
324
|
+
visiting.add(ref);
|
|
325
|
+
try {
|
|
326
|
+
// Alias definition (bare $ref): its validator delegates to the target's.
|
|
327
|
+
if (hasRef(resolved) && !hasProperties(resolved)) {
|
|
328
|
+
return canTrustReferencedValidator(resolved.$ref, rootSchema, visiting);
|
|
329
|
+
}
|
|
330
|
+
// Pure union definition: real when the membership check is enforceable.
|
|
331
|
+
if (!hasProperties(resolved)) {
|
|
332
|
+
const branches = getUnionBranches(resolved);
|
|
333
|
+
if (!branches)
|
|
334
|
+
return false;
|
|
335
|
+
if ('patternProperties' in resolved || 'if' in resolved)
|
|
336
|
+
return false;
|
|
337
|
+
return branches.every((branch) => {
|
|
338
|
+
if (!isSchemaObject(branch))
|
|
339
|
+
return false;
|
|
340
|
+
return isObjectSchema(branch) && hasProperties(branch) && !hasRef(branch)
|
|
341
|
+
? canTrustInlineObjectCheck(branch, rootSchema, visiting)
|
|
342
|
+
: canTrustPropertyCheck(branch, rootSchema, visiting);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// Object definition: real when generateShapeValidator emits a predicate —
|
|
346
|
+
// no composition/conditional keywords, and every property check resolves.
|
|
347
|
+
if (hasOneOf(resolved) ||
|
|
348
|
+
hasAnyOf(resolved) ||
|
|
349
|
+
hasAllOf(resolved) ||
|
|
350
|
+
'not' in resolved ||
|
|
351
|
+
'patternProperties' in resolved ||
|
|
352
|
+
'if' in resolved ||
|
|
353
|
+
'then' in resolved ||
|
|
354
|
+
'else' in resolved) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
return Object.values(resolved.properties).every((propSchema) => {
|
|
358
|
+
// Records of refs make generateShapeValidator bail to the stub.
|
|
359
|
+
if (isSchemaObject(propSchema) &&
|
|
360
|
+
hasAdditionalProperties(propSchema) &&
|
|
361
|
+
typeof propSchema.additionalProperties !== 'boolean') {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
return canTrustPropertyCheck(propSchema, rootSchema, visiting);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
visiting.delete(ref);
|
|
369
|
+
}
|
|
370
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-parsers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"json-schema-typed": "^8.0.1",
|
|
51
|
-
"@amritk/helpers": "0.10.
|
|
51
|
+
"@amritk/helpers": "0.10.3",
|
|
52
52
|
"@amritk/generate-markdown": "0.4.1"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|