mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
fix(mcp): re-emit advertised patterns in a portable regex grammar (#10582)
* test(mcp): check advertised schemas against a portable dialect in every profile * fix(mcp): re-emit advertised patterns in a portable regex grammar * fix(mcp): validate advertised dialect by selected profile * fix(mcp): preserve schema widening through references * fix(mcp): refuse zero-padded repeat bounds and record host-specific path patterns * fix(mcp): keep references valid when presentation widens their targets * fix(mcp): relax references into negations and conditionals presentation may remove * fix(mcp): keep astral literals and dynamic references widening in every mode * fix(mcp): omit astral literals and relax untracked references when presentation widens * fix(mcp): read reference pointers by schema position, not segment spelling * fix(mcp): protect references into collapsing boolean unions and keep referencing branches out of the collapse * fix(mcp): omit patterns whose classes name astral characters * fix(mcp): decide union collapse and pointer protection from canonical branches * docs(mcp): document non-u astral matching and pin BMP agreement across V8 modes * test(mcp): expect the advertised base64 schema to omit the unbounded pad-bit probe * fix(mcp): protect references into unevaluated keywords lost evaluation removes * fix(mcp): emit stack-safe classes with non-ASCII members and protect draft-07 reference siblings * fix(mcp): protect references into literal siblings of relaxed draft-07 references
This commit is contained in:
1 parent
5bf66f7500
commit
d0fee4e58e
13 files changed
+1575
-195
No files matched your search
@@ -212,9 +212,9 @@ directives are errors. `vitest/valid-expect` warns (`maxArgs: 2`) because valid
|
||||
collected assertions can be awaited later. `import/no-duplicates` warns with
|
||||
`preferInline: false`.
|
||||
|
||||
`no-useless-escape` keeps intentional `\-` regex escapes because advertised
|
||||
patterns must also compile in `v` mode; the catalog regression checks every
|
||||
advertised pattern.
|
||||
`no-useless-escape` keeps intentional `\-` regex escapes. Source regexes need
|
||||
no escaping for clients: the presentation re-emits every advertised pattern in
|
||||
the portable grammar (`src/contracts/portablePattern.ts`).
|
||||
|
||||
Fast lint stays syntax-based. `npm run lint:typed` runs serially with pinned
|
||||
`oxlint-tsgolint` 7.0.2005 and records native plus typed diagnostics in
|
||||
@@ -294,7 +294,17 @@ advertised dialect: the keyword allowlist, size and depth limits, and the regex
|
||||
grammar that compiles in V8, Python, RE2 and Rust. `advertisedDialect.test.ts`
|
||||
lists every input, output and catalog profile combination and compares the
|
||||
violation counts with `advertisedDialectBaseline.ts`, which may only shrink;
|
||||
the packaged catalog check applies the same rules to the default profiles. Keep canonical rejection checks separate from the
|
||||
the packaged catalog check applies the same rules to the default profiles.
|
||||
Advertised patterns, input and output, are enforced at zero:
|
||||
`portablePattern.ts` re-emits each one exactly, widens it, or omits it, and the
|
||||
presentation drops any enclosing `not` or `if` and turns `oneOf` into `anyOf`
|
||||
when a pattern widens. `portablePattern.test.ts` pins the same acceptance of
|
||||
BMP strings in V8's plain, `u` and `v` modes. Python, Go and Rust read code
|
||||
points as `u` does; Python's `$` also matches before a final newline, which
|
||||
only widens. A client compiling without `u` may still reject one astral
|
||||
character where a wildcard, shorthand or negated class must match it (for
|
||||
example `^[\S]$`); `PortablePatternProjection` documents this accepted
|
||||
limitation. Keep canonical rejection checks separate from the
|
||||
intentionally reduced compact advertisement, and probe actual client renderers
|
||||
when changing schema representations.
|
||||
|
||||
|
||||
@@ -14,16 +14,14 @@ export const verifyCompleteToolCatalog = async (
|
||||
{ requireMcpLifecycleResult, sortVerifierNames },
|
||||
{ TOOL_CONTRACTS },
|
||||
dialect,
|
||||
{ ADVERTISED_INPUT_DIALECT_BASELINE, ADVERTISED_OUTPUT_DIALECT_BASELINE },
|
||||
{ ADVERTISED_INPUT_DIALECT_BASELINE },
|
||||
{ mcpInputSchemaProfileSchema },
|
||||
{ mcpOutputSchemaProfileSchema },
|
||||
] = await Promise.all([
|
||||
import("./mcp-verifier-results.mjs"),
|
||||
import("../../dist/contracts/toolContracts.js"),
|
||||
import("../../dist/contracts/advertisedDialect.js"),
|
||||
import("../../dist/contracts/advertisedDialectBaseline.js"),
|
||||
import("../../dist/config/mcpInputSchemaProfile.js"),
|
||||
import("../../dist/config/mcpOutputSchemaProfile.js"),
|
||||
]);
|
||||
const listed = await client.listTools(undefined, options);
|
||||
const canonical = sortVerifierNames(TOOL_CONTRACTS.map(({ name }) => name));
|
||||
@@ -54,9 +52,6 @@ export const verifyCompleteToolCatalog = async (
|
||||
const inputProfile = mcpInputSchemaProfileSchema.parse(
|
||||
environment.REA_MCP_INPUT_SCHEMA_PROFILE ?? "compact",
|
||||
);
|
||||
const outputProfile = mcpOutputSchemaProfileSchema.parse(
|
||||
environment.REA_MCP_OUTPUT_SCHEMA_PROFILE ?? "full",
|
||||
);
|
||||
const regressions = [
|
||||
...dialect.advertisedDialectRegressions(
|
||||
dialect.countAdvertisedDialectViolations(
|
||||
@@ -68,7 +63,7 @@ export const verifyCompleteToolCatalog = async (
|
||||
dialect.countAdvertisedDialectViolations(
|
||||
listed.tools.flatMap(dialect.advertisedOutputPatternViolations),
|
||||
),
|
||||
ADVERTISED_OUTPUT_DIALECT_BASELINE[outputProfile],
|
||||
{},
|
||||
),
|
||||
];
|
||||
if (regressions.length > 0)
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
// One exception grows them: each runtime analysis setting adds a branch to
|
||||
// update_analysis_settings with two `const` members (`setting`, `action`).
|
||||
|
||||
// Every advertised pattern, input and output, is enforced at zero: the
|
||||
// presentation re-emits each one in the portable grammar.
|
||||
|
||||
/** Input-side counts, keyed by `REA_MCP_INPUT_SCHEMA_PROFILE`. */
|
||||
export const ADVERTISED_INPUT_DIALECT_BASELINE: Readonly<
|
||||
Record<"compact" | "full", Readonly<Record<string, number>>>
|
||||
@@ -24,9 +27,6 @@ export const ADVERTISED_INPUT_DIALECT_BASELINE: Readonly<
|
||||
"input keyword prefixItems": 2,
|
||||
"input keyword propertyNames": 3,
|
||||
"input keyword uniqueItems": 2,
|
||||
"input pattern escape:\\/": 12,
|
||||
"input pattern shorthand-class": 21,
|
||||
"input pattern unanchored": 9,
|
||||
"input type type-list": 11,
|
||||
"input type type:null": 14,
|
||||
"input type untyped": 40,
|
||||
@@ -52,33 +52,13 @@ export const ADVERTISED_INPUT_DIALECT_BASELINE: Readonly<
|
||||
"input keyword if": 7,
|
||||
"input keyword minProperties": 12,
|
||||
"input keyword not": 10,
|
||||
"input keyword oneOf": 223,
|
||||
"input keyword oneOf": 219,
|
||||
"input keyword prefixItems": 255,
|
||||
"input keyword propertyNames": 53,
|
||||
"input keyword then": 7,
|
||||
"input keyword uniqueItems": 5,
|
||||
"input pattern dot": 5,
|
||||
"input pattern escape:\\/": 105,
|
||||
"input pattern non-ascii-or-braced-hex-escape": 1,
|
||||
"input pattern shorthand-class": 30,
|
||||
"input pattern unanchored": 98,
|
||||
"input pattern unescaped-syntax-in-class:^": 1,
|
||||
"input type type-list": 51,
|
||||
"input type type:null": 372,
|
||||
"input type untyped": 1454,
|
||||
},
|
||||
};
|
||||
|
||||
/** Output-schema pattern counts, keyed by `REA_MCP_OUTPUT_SCHEMA_PROFILE`. */
|
||||
export const ADVERTISED_OUTPUT_DIALECT_BASELINE: Readonly<
|
||||
Record<"full" | "compact" | "off", Readonly<Record<string, number>>>
|
||||
> = {
|
||||
full: {
|
||||
"output pattern dot": 7,
|
||||
"output pattern escape:\\/": 7,
|
||||
"output pattern shorthand-class": 31,
|
||||
"output pattern unanchored": 29,
|
||||
},
|
||||
compact: {},
|
||||
off: {},
|
||||
};
|
||||
@@ -53,13 +53,15 @@ it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
});
|
||||
const Validator = target === "draft-2020-12" ? Ajv2020 : Ajv;
|
||||
const validate = new Validator({ strict: false }).compile(projected);
|
||||
for (const [encoded, valid] of [
|
||||
["AA==", true],
|
||||
["AB==", false],
|
||||
["AAAAAA==", false],
|
||||
// The unbounded pad-bit probe has no stack-safe portable form, so the
|
||||
// advertisement admits `AB==` while the server still refuses it.
|
||||
for (const [encoded, valid, advertised] of [
|
||||
["AA==", true, true],
|
||||
["AB==", false, true],
|
||||
["AAAAAA==", false, false],
|
||||
] as const) {
|
||||
const value = { encoded, literal };
|
||||
expect(validate(value), encoded).toBe(valid);
|
||||
expect(validate(value), encoded).toBe(advertised);
|
||||
expect(input.safeParse(value).success, encoded).toBe(valid);
|
||||
expect(output.safeParse(value).success, encoded).toBe(valid);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
} from "./jsonSchemaReferences.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import { advertisedSchemaPatterns } from "./advertisedDialect.js";
|
||||
import { projectPortablePattern } from "./portablePattern.js";
|
||||
import {
|
||||
schemaArrays,
|
||||
schemaChildren,
|
||||
schemaMaps,
|
||||
schemaPointerSteps,
|
||||
schemaSubschemas,
|
||||
} from "./jsonSchemaKeywords.js";
|
||||
|
||||
@@ -170,8 +173,17 @@ type SchemaNormalization = {
|
||||
readonly changed: boolean;
|
||||
};
|
||||
|
||||
// A branch that also references another schema is not judged by its own
|
||||
// keywords: draft-07 ignores a reference's siblings, and the target may admit
|
||||
// more than a boolean. Such unions keep the `anyOf` fallback.
|
||||
const hasReference = (schema: Readonly<Record<string, unknown>>): boolean =>
|
||||
["$ref", "$dynamicRef", "$recursiveRef"].some(
|
||||
(keyword) => typeof schema[keyword] === "string",
|
||||
);
|
||||
|
||||
const isBooleanLiteralSchema = (schema: unknown): boolean =>
|
||||
isObject(schema) &&
|
||||
!hasReference(schema) &&
|
||||
(typeof schema.const === "boolean" ||
|
||||
(Array.isArray(schema.enum) &&
|
||||
schema.enum.length > 0 &&
|
||||
@@ -179,7 +191,17 @@ const isBooleanLiteralSchema = (schema: unknown): boolean =>
|
||||
|
||||
const isBooleanSchema = (schema: unknown): boolean =>
|
||||
isObject(schema) &&
|
||||
(schema.type === "boolean" || isBooleanLiteralSchema(schema));
|
||||
((schema.type === "boolean" && !hasReference(schema)) ||
|
||||
isBooleanLiteralSchema(schema));
|
||||
|
||||
/**
|
||||
* Whether a union collapses to `type: "boolean"` once a branch changes. The
|
||||
* decision reads the canonical branches, so a branch that becomes boolean only
|
||||
* because presentation relaxed its reference keeps the union, and pointers into
|
||||
* the union stay resolvable (see `throughRemovableLocation`).
|
||||
*/
|
||||
const collapsesToBoolean = (branches: readonly unknown[]): boolean =>
|
||||
branches.length > 0 && branches.every(isBooleanSchema);
|
||||
|
||||
const isObjectKeyword = (key: string) => schemaMaps.has(key);
|
||||
const isArrayKeyword = (key: string) => schemaArrays.has(key);
|
||||
@@ -247,9 +269,10 @@ const referencedDefaultPaths = (
|
||||
const kept = new Set<string>();
|
||||
for (const { target } of new JsonSchemaReferences(schema).references()) {
|
||||
if (target.kind !== "target") continue;
|
||||
const defaultAt = target.path.indexOf("default");
|
||||
if (defaultAt < 0) continue;
|
||||
kept.add(JSON.stringify(target.path.slice(0, defaultAt + 1)));
|
||||
const steps = schemaPointerSteps(target.path);
|
||||
const last = steps.at(-1);
|
||||
if (last?.kind !== "value" || last.keyword !== "default") continue;
|
||||
kept.add(JSON.stringify(target.path.slice(0, steps.length)));
|
||||
}
|
||||
return kept;
|
||||
};
|
||||
@@ -278,8 +301,93 @@ const hasNormalizedLiteral = (schema: Readonly<Record<string, unknown>>) =>
|
||||
schema.enum.every((item) => typeof item === "boolean")) ||
|
||||
schema.enum.includes("")));
|
||||
|
||||
/**
|
||||
* A pattern the portable projection cannot reproduce exactly widens its site.
|
||||
* Exact re-emission keeps the site's meaning, so its dependents stay intact.
|
||||
*/
|
||||
const hasWidenedPattern = (schema: Readonly<Record<string, unknown>>) =>
|
||||
typeof schema.pattern === "string" &&
|
||||
projectPortablePattern(schema.pattern).kind !== "exact";
|
||||
|
||||
interface NormalizationOptions {
|
||||
/** Rewrite boolean and empty-string literals some gateways refuse. */
|
||||
readonly literals: boolean;
|
||||
}
|
||||
const INPUT_NORMALIZATION: NormalizationOptions = { literals: true };
|
||||
const OUTPUT_NORMALIZATION: NormalizationOptions = { literals: false };
|
||||
|
||||
/** One projection owns actual rewrite sites and their scoped validation dependencies. */
|
||||
const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
|
||||
/**
|
||||
* Keywords normalization may remove or rename: a widened `not` or `if` is
|
||||
* dropped with its branches, a widened `oneOf` becomes `anyOf`, and lost
|
||||
* evaluation drops `unevaluatedProperties` and `unevaluatedItems`.
|
||||
*/
|
||||
const REMOVABLE_KEYWORDS = new Set([
|
||||
"oneOf",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether a pointer from `root` passes through a keyword normalization may
|
||||
* remove. An `anyOf` is removed only when it collapses: every branch boolean.
|
||||
*/
|
||||
/** Keywords a relaxed draft-07 reference keeps; its other siblings go. */
|
||||
const DRAFT07_REDUCED_REFERENCE_KEYS = new Set([
|
||||
"$id",
|
||||
"$schema",
|
||||
"$anchor",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"description",
|
||||
"title",
|
||||
"default",
|
||||
"examples",
|
||||
"$comment",
|
||||
]);
|
||||
|
||||
const throughRemovableLocation = (
|
||||
root: unknown,
|
||||
path: readonly string[],
|
||||
draft07: boolean,
|
||||
): boolean => {
|
||||
let value = root;
|
||||
for (const [index, step] of schemaPointerSteps(path).entries()) {
|
||||
// Relaxing a draft-07 reference removes its ignored siblings, including
|
||||
// literal-valued ones such as `const`.
|
||||
if (
|
||||
draft07 &&
|
||||
step.kind !== "entry" &&
|
||||
isObject(value) &&
|
||||
hasReference(value) &&
|
||||
!DRAFT07_REDUCED_REFERENCE_KEYS.has(step.keyword)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
step.kind === "keyword" &&
|
||||
(REMOVABLE_KEYWORDS.has(step.keyword) ||
|
||||
(step.keyword === "anyOf" &&
|
||||
isObject(value) &&
|
||||
Array.isArray(value.anyOf) &&
|
||||
collapsesToBoolean(value.anyOf)))
|
||||
)
|
||||
return true;
|
||||
value =
|
||||
typeof value === "object" && value !== null
|
||||
? Reflect.get(value, path[index]!)
|
||||
: undefined;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const normalizationContext = (
|
||||
root: Readonly<Record<string, unknown>>,
|
||||
options: NormalizationOptions,
|
||||
) => {
|
||||
const references = new JsonSchemaReferences(root);
|
||||
type Fact = {
|
||||
changed: boolean;
|
||||
@@ -318,6 +426,7 @@ const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
|
||||
fact: Fact;
|
||||
}[] = [];
|
||||
const changed: Fact[] = [];
|
||||
const untracked: Fact[] = [];
|
||||
const factAt = (
|
||||
schema: unknown,
|
||||
inherited: JsonSchemaScope | undefined,
|
||||
@@ -343,17 +452,34 @@ const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
|
||||
const { schema, scope, fact } = work[index]!;
|
||||
const ignoresSiblings =
|
||||
scope?.dialect === "draft-07" && typeof schema.$ref === "string";
|
||||
const projection =
|
||||
typeof schema.pattern === "string"
|
||||
? projectPortablePattern(schema.pattern)
|
||||
: undefined;
|
||||
const opaquePatternChanged =
|
||||
!sites.get(scope)?.has(schema) &&
|
||||
projection !== undefined &&
|
||||
(projection.kind !== "exact" || projection.pattern !== schema.pattern);
|
||||
if (
|
||||
!ignoresSiblings &&
|
||||
sites.get(scope)?.has(schema) &&
|
||||
hasNormalizedLiteral(schema)
|
||||
(opaquePatternChanged ||
|
||||
(sites.get(scope)?.has(schema) &&
|
||||
((options.literals && hasNormalizedLiteral(schema)) ||
|
||||
hasWidenedPattern(schema))))
|
||||
) {
|
||||
fact.changed = true;
|
||||
changed.push(fact);
|
||||
}
|
||||
if (typeof schema.$ref === "string") {
|
||||
const target = references.resolve(scope, schema.$ref);
|
||||
if (target.kind === "target") {
|
||||
if (
|
||||
target.kind === "target" &&
|
||||
!throughRemovableLocation(
|
||||
scope?.root,
|
||||
target.path,
|
||||
scope?.dialect === "draft-07",
|
||||
)
|
||||
) {
|
||||
const dependency = factAt(target.value, scope);
|
||||
dependency?.dependents.add(fact);
|
||||
dependency?.evaluationDependents.add(fact);
|
||||
@@ -363,8 +489,24 @@ const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
|
||||
!sites.get(references.enter(target.value, scope))?.has(target.value)
|
||||
)
|
||||
fact.opaqueReference = dependency;
|
||||
}
|
||||
} else if (target.kind !== "unresolved") {
|
||||
// Named anchors and pointers into removable locations are not
|
||||
// followed by this owner. Relax them when normalizing so a widened or
|
||||
// moved target cannot make a negation stricter or leave a dangling
|
||||
// reference.
|
||||
fact.opaqueReference = fact;
|
||||
if (!fact.changed) {
|
||||
fact.changed = true;
|
||||
changed.push(fact);
|
||||
}
|
||||
} else untracked.push(fact);
|
||||
}
|
||||
if (
|
||||
scope?.dialect !== "draft-07" &&
|
||||
(typeof schema.$dynamicRef === "string" ||
|
||||
typeof schema.$recursiveRef === "string")
|
||||
)
|
||||
untracked.push(fact);
|
||||
if (!ignoresSiblings)
|
||||
for (const child of schemaSubschemas(schema, {
|
||||
includeDefinitions: false,
|
||||
@@ -391,6 +533,17 @@ const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
|
||||
factAt(child, scope)?.evaluationDependents.add(fact);
|
||||
}
|
||||
}
|
||||
// Dynamic targets depend on the evaluation path, and an unresolved target
|
||||
// may be an embedded resource this owner rewrote. Neither is followed, so
|
||||
// once anything widens, relax them like named anchors.
|
||||
if (changed.length > 0)
|
||||
for (const fact of untracked) {
|
||||
fact.opaqueReference = fact;
|
||||
if (!fact.changed) {
|
||||
fact.changed = true;
|
||||
changed.push(fact);
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < changed.length; index++)
|
||||
for (const dependent of changed[index]!.dependents)
|
||||
if (!dependent.changed) {
|
||||
@@ -416,6 +569,7 @@ const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
|
||||
}
|
||||
return {
|
||||
references,
|
||||
options,
|
||||
changed: (schema: object, scope: JsonSchemaScope | undefined) =>
|
||||
indexed.get(scope)?.get(schema)?.changed === true,
|
||||
lostEvaluation: (schema: object, scope: JsonSchemaScope | undefined) =>
|
||||
@@ -425,19 +579,51 @@ const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Drop boolean and empty-string literals, keeping the node's type. */
|
||||
const widenGatewayLiterals = (node: Record<string, unknown>): void => {
|
||||
if (typeof node.const === "boolean") {
|
||||
delete node.const;
|
||||
if (node.type === undefined) node.type = "boolean";
|
||||
}
|
||||
if (node.const === "") {
|
||||
delete node.const;
|
||||
if (node.type === undefined) node.type = "string";
|
||||
}
|
||||
if (
|
||||
Array.isArray(node.enum) &&
|
||||
node.enum.length > 0 &&
|
||||
node.enum.every((item) => typeof item === "boolean")
|
||||
) {
|
||||
delete node.enum;
|
||||
if (node.type === undefined) node.type = "boolean";
|
||||
}
|
||||
if (Array.isArray(node.enum) && node.enum.includes("")) {
|
||||
// Removing only the empty member would reject a canonical-valid input.
|
||||
// Gemini transports turn const into enum and refuse empty enum members.
|
||||
const values = node.enum;
|
||||
delete node.enum;
|
||||
if (
|
||||
node.type === undefined &&
|
||||
values.every((item) => typeof item === "string")
|
||||
)
|
||||
node.type = "string";
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeAdvertisedSchema = (
|
||||
value: unknown,
|
||||
options: NormalizationOptions,
|
||||
context?: ReturnType<typeof normalizationContext>,
|
||||
inherited?: JsonSchemaScope,
|
||||
): SchemaNormalization => {
|
||||
if (!isObject(value)) return { schema: value, changed: false };
|
||||
const owner = context ?? normalizationContext(value);
|
||||
const owner = context ?? normalizationContext(value, options);
|
||||
const scope = owner.references.enter(
|
||||
value,
|
||||
context === undefined ? owner.references.rootScope : inherited,
|
||||
);
|
||||
const normalize = (child: unknown) =>
|
||||
normalizeAdvertisedSchema(child, owner, scope);
|
||||
normalizeAdvertisedSchema(child, options, owner, scope);
|
||||
const node: Record<string, unknown> = { ...value };
|
||||
const changed = owner.changed(value, scope);
|
||||
const normalizeMap = (map: Readonly<Record<string, unknown>>) =>
|
||||
@@ -484,33 +670,15 @@ const normalizeAdvertisedSchema = (
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof node.const === "boolean") {
|
||||
delete node.const;
|
||||
if (node.type === undefined) node.type = "boolean";
|
||||
}
|
||||
if (node.const === "") {
|
||||
delete node.const;
|
||||
if (node.type === undefined) node.type = "string";
|
||||
}
|
||||
if (
|
||||
Array.isArray(node.enum) &&
|
||||
node.enum.length > 0 &&
|
||||
node.enum.every((item) => typeof item === "boolean")
|
||||
) {
|
||||
delete node.enum;
|
||||
if (node.type === undefined) node.type = "boolean";
|
||||
}
|
||||
if (Array.isArray(node.enum) && node.enum.includes("")) {
|
||||
// Removing only the empty member would reject a canonical-valid input.
|
||||
// Gemini transports turn const into enum and refuse empty enum members.
|
||||
const values = node.enum;
|
||||
delete node.enum;
|
||||
if (
|
||||
node.type === undefined &&
|
||||
values.every((item) => typeof item === "string")
|
||||
)
|
||||
node.type = "string";
|
||||
if (typeof node.pattern === "string") {
|
||||
// Clients refuse the complete tools request over one pattern their regex
|
||||
// engine cannot compile. A widened or omitted pattern marks this site
|
||||
// changed, so enclosing `not`, `if` and `oneOf` keep only widening.
|
||||
const projected = projectPortablePattern(node.pattern);
|
||||
if (projected.kind === "unexpressible") delete node.pattern;
|
||||
else node.pattern = projected.pattern;
|
||||
}
|
||||
if (options.literals) widenGatewayLiterals(node);
|
||||
|
||||
const negated = Object.hasOwn(value, "not")
|
||||
? normalize(value.not)
|
||||
@@ -571,11 +739,7 @@ const normalizeAdvertisedSchema = (
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
branchesChanged &&
|
||||
schemas.length > 0 &&
|
||||
schemas.every(isBooleanSchema)
|
||||
) {
|
||||
if (branchesChanged && collapsesToBoolean(branches)) {
|
||||
node.type = "boolean";
|
||||
preserveSharedDefault(node, schemas);
|
||||
delete node[combinator];
|
||||
@@ -593,22 +757,12 @@ const normalizeAdvertisedSchema = (
|
||||
// this affected reference rather than copy or mutate its executable view.
|
||||
if (scope?.dialect === "draft-07") {
|
||||
for (const key of Object.keys(node))
|
||||
if (
|
||||
![
|
||||
"$id",
|
||||
"$schema",
|
||||
"$anchor",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"description",
|
||||
"title",
|
||||
"default",
|
||||
"examples",
|
||||
"$comment",
|
||||
].includes(key)
|
||||
)
|
||||
delete node[key];
|
||||
} else delete node.$ref;
|
||||
if (!DRAFT07_REDUCED_REFERENCE_KEYS.has(key)) delete node[key];
|
||||
} else {
|
||||
delete node.$ref;
|
||||
delete node.$dynamicRef;
|
||||
delete node.$recursiveRef;
|
||||
}
|
||||
node.description = [
|
||||
node.description,
|
||||
"This referenced input schema is advertised in reduced form; the server validates the complete canonical input.",
|
||||
@@ -855,10 +1009,8 @@ export const presentInputJsonSchema = (
|
||||
describeProperty,
|
||||
);
|
||||
if (!isObject(described))
|
||||
return normalizeAdvertisedSchema(flattened).schema as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return normalizeAdvertisedSchema(flattened, INPUT_NORMALIZATION)
|
||||
.schema as Record<string, unknown>;
|
||||
const {
|
||||
dependencies: _dependencies,
|
||||
dependentRequired: _dependentRequired,
|
||||
@@ -881,18 +1033,99 @@ export const presentInputJsonSchema = (
|
||||
: [],
|
||||
);
|
||||
if (dependencyGuidance.length === 0)
|
||||
return normalizeAdvertisedSchema(presentation).schema as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return normalizeAdvertisedSchema(presentation, INPUT_NORMALIZATION)
|
||||
.schema as Record<string, unknown>;
|
||||
const description = [
|
||||
typeof presentation.description === "string"
|
||||
? presentation.description
|
||||
: undefined,
|
||||
...dependencyGuidance,
|
||||
].filter((text) => text !== undefined);
|
||||
return normalizeAdvertisedSchema({
|
||||
...presentation,
|
||||
description: description.join(" "),
|
||||
}).schema as Record<string, unknown>;
|
||||
return normalizeAdvertisedSchema(
|
||||
{ ...presentation, description: description.join(" ") },
|
||||
INPUT_NORMALIZATION,
|
||||
).schema as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Present an output schema with portable patterns. SDK clients compile every
|
||||
* listed output schema; literals stay, since no model reads them.
|
||||
*/
|
||||
export const presentOutputJsonSchema = (
|
||||
root: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
const patterns = advertisedSchemaPatterns(root).map(({ pattern }) => ({
|
||||
pattern,
|
||||
projection: projectPortablePattern(pattern),
|
||||
}));
|
||||
if (hasLiteralReference(root))
|
||||
return normalizeAdvertisedSchema(root, OUTPUT_NORMALIZATION)
|
||||
.schema as Record<string, unknown>;
|
||||
// Output schemas reach several megabytes. Exact re-emission changes no
|
||||
// dependent, so only a widened pattern needs the dependency analysis.
|
||||
if (patterns.some(({ projection }) => projection.kind !== "exact"))
|
||||
return normalizeAdvertisedSchema(root, OUTPUT_NORMALIZATION)
|
||||
.schema as Record<string, unknown>;
|
||||
if (
|
||||
patterns.every(
|
||||
({ pattern, projection }) =>
|
||||
projection.kind === "exact" && projection.pattern === pattern,
|
||||
)
|
||||
)
|
||||
return root;
|
||||
return replaceExactPatterns(root) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** A reference into literal data needs dependency analysis, not in-place rewriting. */
|
||||
const hasLiteralReference = (root: Record<string, unknown>): boolean => {
|
||||
const pending: unknown[] = [root];
|
||||
const visited = new WeakSet<object>();
|
||||
while (pending.length > 0) {
|
||||
const value = pending.pop();
|
||||
if (!isObject(value) || visited.has(value)) continue;
|
||||
visited.add(value);
|
||||
if (typeof value.$ref === "string") {
|
||||
try {
|
||||
const reference = decodeURIComponent(value.$ref);
|
||||
if (
|
||||
/(?:^#\/|\/)(?:default|const|enum|examples)(?:\/|$)/u.test(reference)
|
||||
)
|
||||
return true;
|
||||
} catch {
|
||||
// An invalid fragment cannot name one of these locations.
|
||||
}
|
||||
}
|
||||
pending.push(...schemaSubschemas(value, { includeDefinitions: true }));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Copy schema positions, replacing each pattern with its exact portable form. */
|
||||
const replaceExactPatterns = (schema: unknown): unknown => {
|
||||
if (!isObject(schema)) return schema;
|
||||
return Object.fromEntries(
|
||||
Object.entries(schema).map(([key, value]) => {
|
||||
if (key === "pattern" && typeof value === "string") {
|
||||
const projection = projectPortablePattern(value);
|
||||
return [key, projection.kind === "exact" ? projection.pattern : value];
|
||||
}
|
||||
if (schemaMaps.has(key) && isObject(value))
|
||||
return [
|
||||
key,
|
||||
Object.fromEntries(
|
||||
Object.entries(value).map(([name, child]) => [
|
||||
name,
|
||||
replaceExactPatterns(child),
|
||||
]),
|
||||
),
|
||||
];
|
||||
if (
|
||||
(schemaArrays.has(key) || schemaChildren.has(key)) &&
|
||||
Array.isArray(value)
|
||||
)
|
||||
return [key, value.map(replaceExactPatterns)];
|
||||
if (schemaChildren.has(key)) return [key, replaceExactPatterns(value)];
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -50,3 +50,46 @@ export const schemaLiteralValues = new Set([
|
||||
"default",
|
||||
"examples",
|
||||
]);
|
||||
|
||||
/** One segment of a JSON Pointer into a schema, classified by position. */
|
||||
export type SchemaPointerStep =
|
||||
| { readonly kind: "keyword"; readonly keyword: string }
|
||||
| { readonly kind: "entry" }
|
||||
| { readonly kind: "value"; readonly keyword: string };
|
||||
|
||||
/**
|
||||
* Classify each segment of a JSON Pointer into a schema by its position, so a
|
||||
* map entry or property named like a keyword is never read as that keyword.
|
||||
* The walk ends at the first keyword that is not a subschema keyword (such as
|
||||
* `default`); the segments after it address instance data and are omitted.
|
||||
*/
|
||||
export const schemaPointerSteps = (
|
||||
path: readonly string[],
|
||||
): SchemaPointerStep[] => {
|
||||
const steps: SchemaPointerStep[] = [];
|
||||
let position: "schema" | "entry" | "schemaOrIndex" = "schema";
|
||||
for (const segment of path) {
|
||||
if (
|
||||
position === "entry" ||
|
||||
(position === "schemaOrIndex" && /^(?:0|[1-9]\d*)$/u.test(segment))
|
||||
) {
|
||||
steps.push({ kind: "entry" });
|
||||
position = "schema";
|
||||
continue;
|
||||
}
|
||||
if (schemaMaps.has(segment) || schemaArrays.has(segment)) {
|
||||
steps.push({ kind: "keyword", keyword: segment });
|
||||
position = "entry";
|
||||
continue;
|
||||
}
|
||||
if (schemaChildren.has(segment)) {
|
||||
steps.push({ kind: "keyword", keyword: segment });
|
||||
// `items` and its siblings may hold a draft-07 tuple array.
|
||||
position = "schemaOrIndex";
|
||||
continue;
|
||||
}
|
||||
steps.push({ kind: "value", keyword: segment });
|
||||
break;
|
||||
}
|
||||
return steps;
|
||||
};
|
||||
@@ -50,6 +50,27 @@ const dialectOf = (
|
||||
? "draft-2020-12"
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* The fragment of a reference into the current resource: a bare fragment, or
|
||||
* a URI whose resolution against the resource's absolute base names that same
|
||||
* resource. Other resources are external.
|
||||
*/
|
||||
const sameResourceFragment = (
|
||||
reference: string,
|
||||
baseUri: string | undefined,
|
||||
): string | undefined => {
|
||||
if (reference.startsWith("#")) return reference.slice(1);
|
||||
if (baseUri === undefined || !URL.canParse(reference, baseUri))
|
||||
return undefined;
|
||||
const resource = new URL(reference, baseUri);
|
||||
const base = new URL(baseUri);
|
||||
resource.hash = "";
|
||||
base.hash = "";
|
||||
if (resource.href !== base.href) return undefined;
|
||||
const hash = reference.indexOf("#");
|
||||
return hash === -1 ? "" : reference.slice(hash + 1);
|
||||
};
|
||||
|
||||
/** Per-projection scope ownership; no external resource loading or dynamic-reference evaluation. */
|
||||
export class JsonSchemaReferences {
|
||||
readonly rootScope: JsonSchemaScope;
|
||||
@@ -217,11 +238,11 @@ export class JsonSchemaReferences {
|
||||
): LocalReference {
|
||||
if (scope === undefined || this.enter(scope.root, scope) === undefined)
|
||||
return { kind: "unresolved", reason: "scope" };
|
||||
if (!reference.startsWith("#"))
|
||||
return { kind: "unresolved", reason: "external" };
|
||||
const local = sameResourceFragment(reference, scope.baseUri);
|
||||
if (local === undefined) return { kind: "unresolved", reason: "external" };
|
||||
let fragment: string;
|
||||
try {
|
||||
fragment = decodeURIComponent(reference.slice(1));
|
||||
fragment = decodeURIComponent(local);
|
||||
} catch {
|
||||
return { kind: "unresolved", reason: "fragment" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Ajv } from "ajv";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { INVALID_BASE64_PADDING_PATTERN } from "../domain/stringPatterns.js";
|
||||
|
||||
import { portablePatternViolations } from "./advertisedDialect.js";
|
||||
import {
|
||||
presentInputJsonSchema,
|
||||
presentOutputJsonSchema,
|
||||
} from "./inputSchemaPresentation.js";
|
||||
import { projectPortablePattern } from "./portablePattern.js";
|
||||
|
||||
// Short strings over an alphabet that reaches every class boundary the
|
||||
// projection reasons about: ASCII letters, digits, punctuation, whitespace,
|
||||
// line terminators, NUL, non-ASCII spaces and letters, and a lone surrogate.
|
||||
const ALPHABET = [
|
||||
"a",
|
||||
"Z",
|
||||
"0",
|
||||
"7",
|
||||
"_",
|
||||
"-",
|
||||
"/",
|
||||
"\\",
|
||||
".",
|
||||
"=",
|
||||
"+",
|
||||
"^",
|
||||
"$",
|
||||
"[",
|
||||
" ",
|
||||
"\t",
|
||||
"\n",
|
||||
"\r",
|
||||
"\0",
|
||||
" ",
|
||||
"
",
|
||||
"é",
|
||||
"😀",
|
||||
"\ud800",
|
||||
"A",
|
||||
"Q",
|
||||
"x",
|
||||
"c",
|
||||
"o",
|
||||
"n",
|
||||
];
|
||||
|
||||
const samples = (() => {
|
||||
const strings = new Set<string>([""]);
|
||||
let state = 0x5eed;
|
||||
const next = () => {
|
||||
state = (state * 1103515245 + 12345) % 2 ** 31;
|
||||
return state;
|
||||
};
|
||||
for (const character of ALPHABET) strings.add(character);
|
||||
for (let index = 0; index < 4000; index++) {
|
||||
let value = "";
|
||||
const length = next() % 9;
|
||||
for (let position = 0; position < length; position++)
|
||||
value += ALPHABET[next() % ALPHABET.length];
|
||||
strings.add(value);
|
||||
}
|
||||
return [...strings];
|
||||
})();
|
||||
const bmpSamples = samples.filter(
|
||||
(value) => !/[\u{10000}-\u{10FFFF}]/u.test(value),
|
||||
);
|
||||
|
||||
const SOURCES = [
|
||||
// Advertised before projection (input and output catalogs).
|
||||
"^\\/",
|
||||
"^-?\\d+$",
|
||||
"^[^\\s\\x5b\\x5d]+$",
|
||||
"^[^\\x00\\s\"']+$",
|
||||
"^(?:....)*$",
|
||||
"[^A-Za-z0-9+\\x2f=]",
|
||||
"(?:=[^=]|===)",
|
||||
"(?:[^AQgw]==$|[BCDFGHJKLNOPRSTVWXZabdefhijlmnpqrtuvxyz1235679+\\x2f]=$)",
|
||||
"^1\\.0\\.\\d+$",
|
||||
"^\\x2F(?:[^\\x2F\\x5C]|$)",
|
||||
"^(?:\\/.*)?$",
|
||||
"^[!#$%&'*+.^_`\\x7C~0-9A-Za-z\\x2D]+$",
|
||||
"^[\\t\\x20-\\x7e\\x80-\\xff]*$",
|
||||
"^[\\w .:@\\x2f+\\x2d]+$",
|
||||
"^https?:\\/\\/",
|
||||
"^INVOKE_",
|
||||
"^[\\x01-\\x20]*(?:\\+|-)?(?:NaN|Infinity|(?:(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE](?:\\+|-)?\\d+)?[fFdD]?|0[xX](?:[\\da-fA-F]+(?:\\.[\\da-fA-F]*)?|\\.[\\da-fA-F]+)[pP](?:\\+|-)?\\d+[fFdD]?))[\\x01-\\x20]*$",
|
||||
// Constructs other engines refuse.
|
||||
"^[^\\0]*$",
|
||||
"^[^\\uD800-\\uDFFF]*$",
|
||||
"^[A-Za-z0-9._-]+$",
|
||||
"^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])",
|
||||
"^(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$)",
|
||||
"^(?!\\.\\.?$)[^/]+$",
|
||||
"^[^]$",
|
||||
"a|^b|c$",
|
||||
"^\\p{L}+$",
|
||||
"^\\bcon\\b",
|
||||
"^a{2,1001}$",
|
||||
"^a{01}$",
|
||||
"^(?:ab){0,02}$",
|
||||
"^(?<word>[a-z]+)-x$",
|
||||
"^\\u0041\\u{42}\\cJ$",
|
||||
"^\\S\\W\\D$",
|
||||
"^[\\S]$",
|
||||
"^[^\\D]$",
|
||||
"^é+$",
|
||||
];
|
||||
|
||||
describe("portable pattern projection", () => {
|
||||
it.each(SOURCES)(
|
||||
"re-emits %s inside the grammar and its declared relation",
|
||||
(source) => {
|
||||
const projection = projectPortablePattern(source);
|
||||
if (projection.kind === "unexpressible") {
|
||||
expect(
|
||||
presentOutputJsonSchema({ type: "string", pattern: source }),
|
||||
).not.toHaveProperty("pattern");
|
||||
return;
|
||||
}
|
||||
expect(portablePatternViolations(projection.pattern)).toEqual([]);
|
||||
for (const flags of ["", "u", "v"])
|
||||
expect(() => new RegExp(projection.pattern, flags)).not.toThrow();
|
||||
const original = new RegExp(source, "u");
|
||||
const projected = new RegExp(projection.pattern, "u");
|
||||
// Engines differ only on astral characters (see `projectPortablePattern`):
|
||||
// V8 without `u` matches UTF-16 units, the other modes code points.
|
||||
for (const flags of ["", "v"]) {
|
||||
const mode = new RegExp(projection.pattern, flags);
|
||||
expect(
|
||||
bmpSamples
|
||||
.filter((value) => mode.test(value) !== projected.test(value))
|
||||
.map((value) => JSON.stringify(value)),
|
||||
`/${projection.pattern}/${flags}`,
|
||||
).toEqual([]);
|
||||
}
|
||||
const differences = samples.filter(
|
||||
(value) => original.test(value) !== projected.test(value),
|
||||
);
|
||||
if (projection.kind === "exact")
|
||||
expect(differences.map((value) => JSON.stringify(value))).toEqual([]);
|
||||
else
|
||||
// A wider projection may only admit more strings.
|
||||
expect(
|
||||
differences
|
||||
.filter((value) => original.test(value))
|
||||
.map((value) => JSON.stringify(value)),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["^\\d+$", "exact", "^[0-9]+$"],
|
||||
["^[A-Za-z0-9._-]+$", "exact", "^[\\-.0-9A-Z_a-z]+$"],
|
||||
["^\\/", "exact", "^/[^\\x00]*(?:\\x00[^\\x00]*)*$"],
|
||||
["^(?!con$)[a-z]+$", "wider", "^[a-z]+$"],
|
||||
["^[^\\x00\\s\"']+$", "wider", "^[^\\x00\\x09-\\x0d \"']+$"],
|
||||
])("projects %s %s as %s", (source, kind, pattern) => {
|
||||
expect(projectPortablePattern(source)).toEqual({ kind, pattern });
|
||||
});
|
||||
|
||||
// An astral literal is two UTF-16 units in V8 without `u`; a one-or-two
|
||||
// unit atom would backtrack exponentially under a repeat.
|
||||
it.each([
|
||||
"^(a)\\1$",
|
||||
"^(?<n>a)\\k<n>$",
|
||||
"^[]$",
|
||||
"^(?i:a)$",
|
||||
"^😀$",
|
||||
"^é😀+$",
|
||||
"^[😀]$",
|
||||
"^[a😀]+$",
|
||||
"^[\\u{1F600}-\\u{1F64F}]$",
|
||||
"^[^😀]$",
|
||||
])("omits %s, which has no portable form", (source) => {
|
||||
expect(projectPortablePattern(source)).toEqual({
|
||||
kind: "unexpressible",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a repeated class with non-ASCII members in one stack-safe class", () => {
|
||||
const projected = projectPortablePattern("^[\\t\\x20-\\x7e\\x80-\\xff]*$");
|
||||
expect(projected).toEqual({
|
||||
kind: "wider",
|
||||
pattern: "^[^\\x00-\\x08\\x0a-\\x1f\\x7f]*$",
|
||||
});
|
||||
if (projected.kind === "unexpressible") throw new Error("Expected a form");
|
||||
const large = "A".repeat(8 * 1024 * 1024);
|
||||
// V8 `v` mode overflows on any long class repetition, the source included.
|
||||
for (const flags of ["", "u"])
|
||||
expect(new RegExp(projected.pattern, flags).test(large)).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the unsafe unbounded base64 padding projection", () => {
|
||||
expect(
|
||||
projectPortablePattern(INVALID_BASE64_PADDING_PATTERN.source),
|
||||
).toEqual({ kind: "unexpressible" });
|
||||
});
|
||||
|
||||
it.each(["^[^^]+$", "^[^^a]+$"])(
|
||||
"preserves the literal leading caret in %s",
|
||||
(pattern) => {
|
||||
const projected = projectPortablePattern(pattern);
|
||||
expect(projected.kind).toBe("exact");
|
||||
if (projected.kind === "unexpressible")
|
||||
throw new Error("Expected exact projection");
|
||||
for (const input of ["abc", "^", "a", "Z"])
|
||||
expect(new RegExp(projected.pattern, "u").test(input)).toBe(
|
||||
new RegExp(pattern, "u").test(input),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const PRESENTERS = [
|
||||
[
|
||||
"input",
|
||||
(schema: Record<string, unknown>) =>
|
||||
presentInputJsonSchema(schema, () => undefined),
|
||||
],
|
||||
["output", presentOutputJsonSchema],
|
||||
] as const;
|
||||
|
||||
describe("advertised pattern presentation", () => {
|
||||
const lookAround = "^(?!con$)[a-z]+$";
|
||||
const property = (schema: Record<string, unknown>) => ({
|
||||
type: "object",
|
||||
properties: { name: schema },
|
||||
});
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"keeps only widening around a widened %s pattern",
|
||||
(_kind, present) => {
|
||||
// Widening a pattern under `not` would narrow the enclosing schema.
|
||||
const negated = present(
|
||||
property({ type: "string", not: { pattern: lookAround } }),
|
||||
);
|
||||
expect(negated).toEqual(property({ type: "string" }));
|
||||
// Exact re-emission keeps the negation.
|
||||
expect(
|
||||
present(property({ type: "string", not: { pattern: "^\\d+$" } })),
|
||||
).toEqual(property({ type: "string", not: { pattern: "^[0-9]+$" } }));
|
||||
// A widened branch could match alongside a sibling, so `oneOf` becomes `anyOf`.
|
||||
expect(
|
||||
present(
|
||||
property({
|
||||
oneOf: [
|
||||
{ type: "string", pattern: lookAround },
|
||||
{ type: "string", pattern: "^con$" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
property({
|
||||
anyOf: [
|
||||
{ type: "string", pattern: "^[a-z]+$" },
|
||||
{ type: "string", pattern: "^con$" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns an output schema without non-portable patterns unchanged", () => {
|
||||
const schema = property({ type: "string", pattern: "^[a-z]+$" });
|
||||
expect(presentOutputJsonSchema(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"does not narrow %s through a named anchor",
|
||||
(_kind, present) => {
|
||||
const schema = {
|
||||
$defs: {
|
||||
name: { $anchor: "name", type: "string", pattern: lookAround },
|
||||
},
|
||||
type: "object",
|
||||
properties: { name: { not: { $ref: "#name" } } },
|
||||
};
|
||||
const validate = new Ajv2020({ strict: false }).compile(present(schema));
|
||||
expect(validate({ name: "con" })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"does not narrow %s through a dynamic reference",
|
||||
(_kind, present) => {
|
||||
const schema = {
|
||||
$defs: {
|
||||
name: { $dynamicAnchor: "name", type: "string", pattern: lookAround },
|
||||
},
|
||||
type: "object",
|
||||
properties: { name: { not: { $dynamicRef: "#name" } } },
|
||||
};
|
||||
// Ajv does not resolve this dynamic anchor, so check the structure: the
|
||||
// negation of a widened target cannot remain.
|
||||
const presented = present(schema);
|
||||
expect(JSON.stringify(presented)).not.toContain(lookAround);
|
||||
expect(
|
||||
(presented.properties as Record<string, unknown>).name,
|
||||
).not.toHaveProperty("not");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"does not narrow %s through a URI naming an embedded resource",
|
||||
(_kind, present) => {
|
||||
const schema = {
|
||||
$id: "https://example.test/root",
|
||||
$defs: { name: { $id: "name", type: "string", pattern: lookAround } },
|
||||
type: "object",
|
||||
properties: { name: { not: { $ref: "https://example.test/name" } } },
|
||||
};
|
||||
const validate = new Ajv2020({ strict: false }).compile(present(schema));
|
||||
expect(validate({ name: "con" })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"does not narrow %s through a URI naming this resource",
|
||||
(_kind, present) => {
|
||||
const schema = {
|
||||
$id: "https://example.test/root",
|
||||
$defs: { name: { type: "string", pattern: lookAround } },
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { not: { $ref: "https://example.test/root#/$defs/name" } },
|
||||
},
|
||||
};
|
||||
const validate = new Ajv2020({ strict: false }).compile(present(schema));
|
||||
expect(validate({ name: "con" })).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("advertised pattern presentation through pointers", () => {
|
||||
const lookAround = "^(?!con$)[a-z]+$";
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"keeps %s pointers valid into locations presentation may remove",
|
||||
(_kind, present) => {
|
||||
// A widened `oneOf` becomes `anyOf`; a widened `not` or `if` is dropped.
|
||||
const widened = { type: "string", pattern: lookAround };
|
||||
const schema = {
|
||||
$defs: {
|
||||
choice: {
|
||||
oneOf: [
|
||||
{ type: "string", pattern: "^\\s+$" },
|
||||
{ type: "string", pattern: "^ok$" },
|
||||
],
|
||||
},
|
||||
guard: { not: widened },
|
||||
gate: { if: widened, then: { type: "string" }, else: widened },
|
||||
},
|
||||
type: "object",
|
||||
properties: {
|
||||
choice: { $ref: "#/$defs/choice/oneOf/1" },
|
||||
negated: { $ref: "#/$defs/guard/not" },
|
||||
predicate: { $ref: "#/$defs/gate/if" },
|
||||
consequent: { $ref: "#/$defs/gate/then" },
|
||||
alternative: { $ref: "#/$defs/gate/else" },
|
||||
},
|
||||
};
|
||||
const validate = new Ajv2020({ strict: false }).compile(present(schema));
|
||||
expect(
|
||||
validate({
|
||||
choice: "ok",
|
||||
negated: "con",
|
||||
predicate: "y",
|
||||
consequent: "z",
|
||||
alternative: "w",
|
||||
}),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"reads %s pointer segments by schema position, not by spelling",
|
||||
(_kind, present) => {
|
||||
// `properties` and `items` here are definition names, so the `not`
|
||||
// after each is the removable keyword, not a map entry.
|
||||
const widened = { type: "string", pattern: lookAround };
|
||||
const schema = {
|
||||
$defs: { properties: { not: widened }, items: { not: widened } },
|
||||
type: "object",
|
||||
properties: {
|
||||
first: { $ref: "#/$defs/properties/not" },
|
||||
second: { $ref: "#/$defs/items/not" },
|
||||
},
|
||||
};
|
||||
const validate = new Ajv2020({ strict: false }).compile(present(schema));
|
||||
expect(validate({ first: "con", second: "con" })).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"keeps %s pointers valid into unevaluated keywords lost evaluation removes",
|
||||
(_kind, present) => {
|
||||
const schema = {
|
||||
$defs: {
|
||||
guard: {
|
||||
if: { pattern: "^(?!con$)[a-z]+$" },
|
||||
then: { type: "string" },
|
||||
unevaluatedProperties: { type: "string" },
|
||||
unevaluatedItems: { type: "string" },
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
properties: {
|
||||
props: { $ref: "#/$defs/guard/unevaluatedProperties" },
|
||||
items: { $ref: "#/$defs/guard/unevaluatedItems" },
|
||||
},
|
||||
};
|
||||
const validate = new Ajv2020({ strict: false }).compile(present(schema));
|
||||
expect(validate({ props: "a", items: "b" })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"keeps %s pointers valid into draft-07 reference siblings relaxation removes",
|
||||
(_kind, present) => {
|
||||
const schema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
definitions: {
|
||||
word: { $id: "https://example.test/word", type: "object" },
|
||||
},
|
||||
type: "object",
|
||||
properties: {
|
||||
a: {
|
||||
$ref: "https://example.test/word",
|
||||
properties: { kept: { type: "string" } },
|
||||
},
|
||||
b: { $ref: "#/properties/a/properties/kept" },
|
||||
name: { type: "string", pattern: "^(?!con$)[a-z]+$" },
|
||||
},
|
||||
};
|
||||
const validate = new Ajv({ strict: false }).compile(present(schema));
|
||||
expect(validate({ a: {}, b: "x", name: "con" })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"keeps %s chained pointers valid into removed draft-07 literal siblings",
|
||||
(_kind, present) => {
|
||||
const schema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
definitions: { word: { type: "object" } },
|
||||
type: "object",
|
||||
properties: {
|
||||
a: {
|
||||
$ref: "#/definitions/word",
|
||||
properties: { kept: { type: "string" } },
|
||||
},
|
||||
b: {
|
||||
$ref: "#/properties/a/properties/kept",
|
||||
const: { type: "string" },
|
||||
},
|
||||
c: { $ref: "#/properties/b/const" },
|
||||
name: { type: "string", pattern: "^(?!con$)[a-z]+$" },
|
||||
},
|
||||
};
|
||||
const validate = new Ajv({ strict: false }).compile(present(schema));
|
||||
expect(validate({ a: {}, b: "x", c: "y", name: "con" })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
describe("advertised pattern presentation through collapsing unions", () => {
|
||||
const lookAround = "^(?!con$)[a-z]+$";
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"keeps %s pointers valid into a boolean anyOf that collapses",
|
||||
(_kind, present) => {
|
||||
const schema = {
|
||||
$defs: {
|
||||
flags: {
|
||||
anyOf: [
|
||||
{ type: "boolean", pattern: "^\\s+$" },
|
||||
{ type: "boolean" },
|
||||
],
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
properties: { flag: { $ref: "#/$defs/flags/anyOf/0" } },
|
||||
};
|
||||
const validate = new Ajv2020({ strict: false }).compile(present(schema));
|
||||
expect(validate({ flag: true })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"keeps %s pointers valid into a union whose relaxed reference becomes boolean",
|
||||
(_kind, present) => {
|
||||
// Relaxing the reference into `flags` leaves `outer`'s first branch a
|
||||
// plain boolean; `outer` must still keep the branch `item` targets.
|
||||
const schema = {
|
||||
$defs: {
|
||||
flags: { anyOf: [{ type: "boolean" }, { type: "boolean" }] },
|
||||
outer: {
|
||||
anyOf: [
|
||||
{ $ref: "#/$defs/flags/anyOf/0", type: "boolean" },
|
||||
{ type: "boolean" },
|
||||
],
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
properties: {
|
||||
item: { $ref: "#/$defs/outer/anyOf/1" },
|
||||
name: { type: "string", pattern: lookAround },
|
||||
},
|
||||
};
|
||||
const validate = new Ajv2020({ strict: false }).compile(present(schema));
|
||||
expect(validate({ item: true, name: "con" })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"keeps %s pointers valid into a union whose resource reference relaxes",
|
||||
(_kind, present) => {
|
||||
// The widened sibling pattern relaxes the reference to the embedded
|
||||
// resource, leaving both branches boolean after normalization.
|
||||
const schema = {
|
||||
$defs: {
|
||||
bool: { $id: "https://example.test/bool", type: "boolean" },
|
||||
flags: {
|
||||
anyOf: [
|
||||
{ $ref: "https://example.test/bool", type: "boolean" },
|
||||
{ type: "boolean", pattern: "^\\s+$" },
|
||||
],
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
properties: { flag: { $ref: "#/$defs/flags/anyOf/0" } },
|
||||
};
|
||||
const validate = new Ajv2020({ strict: false }).compile(present(schema));
|
||||
expect(validate({ flag: true })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PRESENTERS)(
|
||||
"does not collapse %s draft-07 references with ignored literal siblings",
|
||||
(_kind, present) => {
|
||||
// Embedded resources are unresolved here, so once a pattern widens the
|
||||
// references relax; draft-07 ignores each reference's `const` sibling.
|
||||
const schema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
definitions: {
|
||||
word: { $id: "https://example.test/word", type: "string" },
|
||||
count: { $id: "https://example.test/count", type: "number" },
|
||||
},
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
oneOf: [
|
||||
{ $ref: "https://example.test/word", const: true },
|
||||
{ $ref: "https://example.test/count", const: false },
|
||||
],
|
||||
},
|
||||
name: { type: "string", pattern: lookAround },
|
||||
},
|
||||
};
|
||||
const create = () =>
|
||||
new Ajv({ strict: false, ignoreKeywordsWithRef: true });
|
||||
const canonical = create().compile(schema);
|
||||
const validate = create().compile(present(schema));
|
||||
for (const value of ["word", 1]) {
|
||||
expect(canonical({ value })).toBe(true);
|
||||
expect(validate({ value })).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("relaxes references to nonportable schemas inside literal output data", () => {
|
||||
const literal = { type: "string", pattern: "^\\d+$" };
|
||||
const schema = presentOutputJsonSchema({
|
||||
$ref: "#/default",
|
||||
default: literal,
|
||||
});
|
||||
expect(schema.default).toEqual(literal);
|
||||
expect(schema).not.toHaveProperty("$ref");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,527 @@
|
||||
import { portablePatternViolations } from "./advertisedDialect.js";
|
||||
import {
|
||||
INVALID_BASE64_CHARACTER_PATTERN,
|
||||
INVALID_BASE64_PADDING_PATTERN,
|
||||
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN,
|
||||
} from "../domain/stringPatterns.js";
|
||||
|
||||
/**
|
||||
* An advertised `pattern` re-emitted into the portable regex grammar.
|
||||
* `exact` matches the same strings as the source under ECMAScript `u`
|
||||
* semantics, `wider` matches a superset, and `unexpressible` has neither form
|
||||
* (a backreference, for example), so the advertisement must omit it. Server
|
||||
* validation keeps the canonical expression either way.
|
||||
*
|
||||
* V8's plain, `u` and `v` modes accept the same BMP strings. Known
|
||||
* limitation: V8 without `u` matches UTF-16 units, so where a wildcard,
|
||||
* shorthand or negated class must match one astral character (`^.$`,
|
||||
* `^[^]$`, `^[\S]$`, `^\S\W\D$`), a client compiling without `u` rejects an
|
||||
* astral character at that position, which the canonical pattern accepts. These
|
||||
* are rare and keeping the constraint beats dropping it. Literal and class
|
||||
* astral members are omitted instead (see `#literal`). Repeated classes are
|
||||
* unaffected, and valid base64, which `^(?:....)*$` checks, is ASCII.
|
||||
*/
|
||||
export type PortablePatternProjection =
|
||||
| { readonly kind: "exact"; readonly pattern: string }
|
||||
| { readonly kind: "wider"; readonly pattern: string }
|
||||
| { readonly kind: "unexpressible" };
|
||||
|
||||
class Unexpressible extends Error {}
|
||||
|
||||
// Any string, as runs separated by NUL.
|
||||
const ANY_STRING = "[^\\x00]*(?:\\x00[^\\x00]*)*";
|
||||
const UNBOUNDED_BASE64_PROBES = new Set([
|
||||
INVALID_BASE64_CHARACTER_PATTERN.source,
|
||||
INVALID_BASE64_PADDING_PATTERN.source,
|
||||
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source,
|
||||
]);
|
||||
const ANY_CHARACTER = "(?:[^\\x00]|\\x00)";
|
||||
const NON_ASCII = "[^\\x00-\\x7f]";
|
||||
const ALNUM = /^[A-Za-z0-9]$/u;
|
||||
const LITERAL_OUTSIDE_CLASS = new Set("_ !#%&,:;<=>@`~'\"-/");
|
||||
const ESCAPED_OUTSIDE_CLASS = new Set("^$\\.*+?()[]{}|/");
|
||||
const LITERAL_IN_CLASS = new Set("!#%&,:;<=>@`~'\" _.+*?$");
|
||||
const ESCAPED_IN_CLASS = new Set("^$\\.*+?()[]{}|/-");
|
||||
const MAX_REPEAT = 1000;
|
||||
|
||||
const hex = (code: number) => `\\x${code.toString(16).padStart(2, "0")}`;
|
||||
|
||||
const emitOutside = (code: number): string => {
|
||||
const character = String.fromCodePoint(code);
|
||||
if (ALNUM.test(character) || LITERAL_OUTSIDE_CLASS.has(character))
|
||||
return character;
|
||||
if (ESCAPED_OUTSIDE_CLASS.has(character)) return `\\${character}`;
|
||||
return hex(code);
|
||||
};
|
||||
|
||||
const emitInClass = (code: number, rangeEndpoint: boolean): string => {
|
||||
const character = String.fromCodePoint(code);
|
||||
if (ALNUM.test(character)) return character;
|
||||
// A punctuator that starts a range is read as a literal followed by `-` in
|
||||
// some engines, so range endpoints use the hexadecimal form.
|
||||
if (!rangeEndpoint && LITERAL_IN_CLASS.has(character)) return character;
|
||||
if (!rangeEndpoint && character !== "/" && ESCAPED_IN_CLASS.has(character))
|
||||
return `\\${character}`;
|
||||
return hex(code);
|
||||
};
|
||||
|
||||
/** ASCII members as merged ranges, emitted without repeated punctuators. */
|
||||
const emitAsciiMembers = (members: ReadonlySet<number>): string => {
|
||||
const sorted = [...members].sort((left, right) => left - right);
|
||||
let emitted = "";
|
||||
for (let index = 0; index < sorted.length;) {
|
||||
let end = index;
|
||||
while (end + 1 < sorted.length && sorted[end + 1] === sorted[end]! + 1)
|
||||
end++;
|
||||
const first = sorted[index]!;
|
||||
const last = sorted[end]!;
|
||||
if (last - first >= 2)
|
||||
emitted += `${emitInClass(first, true)}-${emitInClass(last, true)}`;
|
||||
else
|
||||
for (let code = first; code <= last; code++)
|
||||
emitted += emitInClass(code, false);
|
||||
index = end + 1;
|
||||
}
|
||||
return emitted;
|
||||
};
|
||||
|
||||
/** Non-ASCII membership of a class, as far as it changes the projection. */
|
||||
type NonAscii = "none" | "some" | "all";
|
||||
|
||||
const unionNonAscii = (left: NonAscii, right: NonAscii): NonAscii =>
|
||||
left === "all" || right === "all"
|
||||
? "all"
|
||||
: left === "some" || right === "some"
|
||||
? "some"
|
||||
: "none";
|
||||
|
||||
const ASCII_CODES = Array.from({ length: 0x80 }, (_, code) => code);
|
||||
|
||||
/**
|
||||
* Re-emit one character class, given its exact ASCII members (computed with
|
||||
* V8 itself) and how much of the non-ASCII range it contains.
|
||||
*/
|
||||
const emitClass = (
|
||||
ascii: ReadonlySet<number>,
|
||||
nonAscii: NonAscii,
|
||||
negated: boolean,
|
||||
widen: () => void,
|
||||
): string => {
|
||||
if (nonAscii === "some") widen();
|
||||
if (!negated) {
|
||||
if (nonAscii === "none") {
|
||||
if (ascii.size === 0) throw new Unexpressible();
|
||||
return `[${emitAsciiMembers(ascii)}]`;
|
||||
}
|
||||
if (ascii.size === 0) return NON_ASCII;
|
||||
// One negated class admits every non-ASCII character; an alternation of
|
||||
// two classes would cost a backtracking frame per repeated character.
|
||||
const missing = new Set(ASCII_CODES.filter((code) => !ascii.has(code)));
|
||||
return missing.size === 0
|
||||
? ANY_CHARACTER
|
||||
: `[^${emitAsciiMembers(missing)}]`;
|
||||
}
|
||||
if (nonAscii === "all") {
|
||||
const rest = new Set(ASCII_CODES.filter((code) => !ascii.has(code)));
|
||||
if (rest.size === 0) throw new Unexpressible();
|
||||
return `[${emitAsciiMembers(rest)}]`;
|
||||
}
|
||||
// A negated class keeps every non-ASCII character it does not name; with
|
||||
// "some" named, admitting all of them only widens.
|
||||
return ascii.size === 0 ? ANY_CHARACTER : `[^${emitAsciiMembers(ascii)}]`;
|
||||
};
|
||||
|
||||
const SHORTHAND_NON_ASCII: Readonly<Record<string, NonAscii>> = {
|
||||
d: "none",
|
||||
w: "none",
|
||||
s: "some",
|
||||
D: "all",
|
||||
W: "all",
|
||||
S: "some",
|
||||
};
|
||||
|
||||
class Projector {
|
||||
#index = 0;
|
||||
#wider = false;
|
||||
|
||||
constructor(private readonly source: string) {}
|
||||
|
||||
project(): {
|
||||
readonly kind: "exact" | "wider";
|
||||
readonly pattern: string;
|
||||
} {
|
||||
const alternatives = this.#disjunction();
|
||||
if (this.#index !== this.source.length) throw new Unexpressible();
|
||||
// A search for an unanchored alternative is a full match of the same
|
||||
// alternative between two arbitrary strings.
|
||||
const completed = alternatives.map((terms) => {
|
||||
const first = terms[0] === "^" ? 1 : 0;
|
||||
const anchoredEnd = terms.length > first && terms.at(-1) === "$";
|
||||
const body = terms.slice(first, anchoredEnd ? -1 : undefined).join("");
|
||||
return `${first === 1 ? "" : ANY_STRING}${body}${anchoredEnd ? "" : ANY_STRING}`;
|
||||
});
|
||||
const pattern =
|
||||
completed.length === 1
|
||||
? `^${completed[0]!}$`
|
||||
: `^(?:${completed.join("|")})$`;
|
||||
return { kind: this.#wider ? "wider" : "exact", pattern };
|
||||
}
|
||||
|
||||
#peek(offset = 0): string {
|
||||
return this.source[this.#index + offset] ?? "";
|
||||
}
|
||||
|
||||
#widen = (): void => {
|
||||
this.#wider = true;
|
||||
};
|
||||
|
||||
#disjunction(): string[][] {
|
||||
const alternatives = [this.#alternative()];
|
||||
while (this.#peek() === "|") {
|
||||
this.#index++;
|
||||
alternatives.push(this.#alternative());
|
||||
}
|
||||
return alternatives;
|
||||
}
|
||||
|
||||
#group(): string {
|
||||
return this.#disjunction()
|
||||
.map((terms) => terms.join(""))
|
||||
.join("|");
|
||||
}
|
||||
|
||||
#alternative(): string[] {
|
||||
const terms: string[] = [];
|
||||
while (this.#index < this.source.length) {
|
||||
const character = this.#peek();
|
||||
if (character === "|" || character === ")") break;
|
||||
const atom = this.#term();
|
||||
if (atom === undefined) continue;
|
||||
const quantifier = this.#quantifier();
|
||||
if (atom.kind === "assertion") {
|
||||
// A quantified assertion matches the same positions or none.
|
||||
if (quantifier !== "") throw new Unexpressible();
|
||||
terms.push(atom.text);
|
||||
} else
|
||||
terms.push(
|
||||
atom.text === ANY_CHARACTER && quantifier === "*"
|
||||
? ANY_STRING
|
||||
: atom.text + quantifier,
|
||||
);
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
#term():
|
||||
| { readonly kind: "atom" | "assertion"; readonly text: string }
|
||||
| undefined {
|
||||
const character = this.#peek();
|
||||
if (character === "^" || character === "$") {
|
||||
this.#index++;
|
||||
return { kind: "assertion", text: character };
|
||||
}
|
||||
if (character === "(") return this.#parenthesized();
|
||||
if (character === "[") return { kind: "atom", text: this.#class() };
|
||||
if (character === ".") {
|
||||
this.#index++;
|
||||
// Without the `s` flag, `.` excludes four line terminators; two are ASCII.
|
||||
return {
|
||||
kind: "atom",
|
||||
text: emitClass(new Set([0x0a, 0x0d]), "some", true, this.#widen),
|
||||
};
|
||||
}
|
||||
if (character === "\\") return this.#atomEscape();
|
||||
const code = this.source.codePointAt(this.#index)!;
|
||||
this.#index += String.fromCodePoint(code).length;
|
||||
return { kind: "atom", text: this.#literal(code) };
|
||||
}
|
||||
|
||||
#literal(code: number): string {
|
||||
if (code < 0x80) return emitOutside(code);
|
||||
// V8 without `u` reads an astral literal as two UTF-16 units, so one
|
||||
// non-ASCII unit would reject it there, and a one-or-two-unit atom
|
||||
// backtracks exponentially under a repeat.
|
||||
if (code > 0xffff) throw new Unexpressible();
|
||||
this.#widen();
|
||||
return NON_ASCII;
|
||||
}
|
||||
|
||||
#parenthesized():
|
||||
| { readonly kind: "atom" | "assertion"; readonly text: string }
|
||||
| undefined {
|
||||
const rest = this.source.slice(this.#index);
|
||||
const lookAround = /^\(\?<?[=!]/u.exec(rest);
|
||||
if (lookAround !== null) {
|
||||
// Removing a zero-width assertion admits a superset of strings.
|
||||
this.#index += lookAround[0].length;
|
||||
this.#group();
|
||||
this.#expect(")");
|
||||
this.#widen();
|
||||
// A quantifier on a removed assertion has nothing left to repeat.
|
||||
this.#quantifier();
|
||||
return undefined;
|
||||
}
|
||||
let open = "(";
|
||||
if (rest.startsWith("(?:")) {
|
||||
open = "(?:";
|
||||
this.#index += 3;
|
||||
} else {
|
||||
// Named groups capture like plain groups; only backreferences read names.
|
||||
const named = /^\(\?<[A-Za-z_$][\w$]*>/u.exec(rest);
|
||||
if (named !== null) this.#index += named[0].length;
|
||||
else if (rest.startsWith("(?")) throw new Unexpressible();
|
||||
else this.#index++;
|
||||
}
|
||||
const inner = this.#group();
|
||||
this.#expect(")");
|
||||
return { kind: "atom", text: `${open}${inner})` };
|
||||
}
|
||||
|
||||
#expect(character: string): void {
|
||||
if (this.#peek() !== character) throw new Unexpressible();
|
||||
this.#index++;
|
||||
}
|
||||
|
||||
#quantifier(): string {
|
||||
const character = this.#peek();
|
||||
let quantifier = "";
|
||||
if (character === "*" || character === "+" || character === "?") {
|
||||
this.#index++;
|
||||
quantifier = character;
|
||||
} else if (character === "{") {
|
||||
const repeat = /^\{(\d+)(?:(,)(\d*))?\}/u.exec(
|
||||
this.source.slice(this.#index),
|
||||
);
|
||||
if (repeat === null) throw new Unexpressible();
|
||||
this.#index += repeat[0].length;
|
||||
let lower = Number(repeat[1]);
|
||||
let upper =
|
||||
repeat[2] === undefined
|
||||
? lower
|
||||
: repeat[3] === ""
|
||||
? undefined
|
||||
: Number(repeat[3]);
|
||||
if (lower > MAX_REPEAT) {
|
||||
lower = MAX_REPEAT;
|
||||
upper = undefined;
|
||||
this.#widen();
|
||||
}
|
||||
if (upper !== undefined && upper > MAX_REPEAT) {
|
||||
upper = undefined;
|
||||
this.#widen();
|
||||
}
|
||||
quantifier =
|
||||
upper === lower
|
||||
? `{${String(lower)}}`
|
||||
: `{${String(lower)},${upper === undefined ? "" : String(upper)}}`;
|
||||
} else return "";
|
||||
// Laziness changes which match is found, not whether one exists.
|
||||
if (this.#peek() === "?") this.#index++;
|
||||
return quantifier;
|
||||
}
|
||||
|
||||
/** One escaped code point, or a shorthand class letter. */
|
||||
#escape(inClass: boolean): number | { readonly shorthand: string } {
|
||||
this.#index++; // backslash
|
||||
const character = this.#peek();
|
||||
this.#index++;
|
||||
if (character in SHORTHAND_NON_ASCII) return { shorthand: character };
|
||||
if (character === "p" || character === "P") {
|
||||
const property = /^\{[^}]*\}/u.exec(this.source.slice(this.#index));
|
||||
if (property === null) throw new Unexpressible();
|
||||
this.#index += property[0].length;
|
||||
return { shorthand: `${character}${property[0]}` };
|
||||
}
|
||||
const controls: Readonly<Record<string, number>> = {
|
||||
t: 0x09,
|
||||
n: 0x0a,
|
||||
v: 0x0b,
|
||||
f: 0x0c,
|
||||
r: 0x0d,
|
||||
};
|
||||
if (Object.hasOwn(controls, character)) return controls[character]!;
|
||||
if (character === "0" && !/^[0-9]$/u.test(this.#peek())) return 0;
|
||||
if (character === "b" && inClass) return 0x08;
|
||||
if (character === "-" && inClass) return 0x2d;
|
||||
if (character === "c") {
|
||||
const letter = this.#peek();
|
||||
if (!/^[A-Za-z]$/u.test(letter)) throw new Unexpressible();
|
||||
this.#index++;
|
||||
return letter.charCodeAt(0) % 32;
|
||||
}
|
||||
if (character === "x") {
|
||||
const digits = /^[0-9A-Fa-f]{2}/u.exec(this.source.slice(this.#index));
|
||||
if (digits === null) throw new Unexpressible();
|
||||
this.#index += 2;
|
||||
return Number.parseInt(digits[0], 16);
|
||||
}
|
||||
if (character === "u") {
|
||||
const braced = /^\{([0-9A-Fa-f]+)\}/u.exec(
|
||||
this.source.slice(this.#index),
|
||||
);
|
||||
if (braced !== null) {
|
||||
this.#index += braced[0].length;
|
||||
return Number.parseInt(braced[1]!, 16);
|
||||
}
|
||||
const digits = /^[0-9A-Fa-f]{4}/u.exec(this.source.slice(this.#index));
|
||||
if (digits === null) throw new Unexpressible();
|
||||
this.#index += 4;
|
||||
const unit = Number.parseInt(digits[0], 16);
|
||||
const low = /^\\u([Dd][C-Fc-f][0-9A-Fa-f]{2})/u.exec(
|
||||
this.source.slice(this.#index),
|
||||
);
|
||||
if (unit >= 0xd800 && unit <= 0xdbff && low !== null) {
|
||||
this.#index += low[0].length;
|
||||
return (
|
||||
(unit - 0xd800) * 0x400 +
|
||||
(Number.parseInt(low[1]!, 16) - 0xdc00) +
|
||||
0x10000
|
||||
);
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
// Identity escapes of syntax characters; `u` mode admits no others.
|
||||
if (ESCAPED_IN_CLASS.has(character) && character !== "-") {
|
||||
return character.charCodeAt(0);
|
||||
}
|
||||
// Backreferences and word boundaries are handled by the caller.
|
||||
throw new Unexpressible();
|
||||
}
|
||||
|
||||
#atomEscape():
|
||||
| { readonly kind: "atom" | "assertion"; readonly text: string }
|
||||
| undefined {
|
||||
const next = this.#peek(1);
|
||||
if (next === "b" || next === "B") {
|
||||
// Removing a zero-width assertion admits a superset of strings.
|
||||
this.#index += 2;
|
||||
this.#widen();
|
||||
this.#quantifier();
|
||||
return undefined;
|
||||
}
|
||||
// Backreferences have no portable form.
|
||||
if (/^[1-9k]$/u.test(next)) throw new Unexpressible();
|
||||
const escaped = this.#escape(false);
|
||||
if (typeof escaped === "number")
|
||||
return { kind: "atom", text: this.#literal(escaped) };
|
||||
const { ascii, nonAscii } = this.#membership(`[\\${escaped.shorthand}]`, [
|
||||
escaped,
|
||||
]);
|
||||
return {
|
||||
kind: "atom",
|
||||
text: emitClass(ascii, nonAscii, false, this.#widen),
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a class at the cursor and re-emit it. */
|
||||
#class(): string {
|
||||
const start = this.#index;
|
||||
this.#index++;
|
||||
const negated = this.#peek() === "^";
|
||||
if (negated) this.#index++;
|
||||
const members: (number | { shorthand: string } | [number, number])[] = [];
|
||||
while (this.#peek() !== "]") {
|
||||
if (this.#index >= this.source.length) throw new Unexpressible();
|
||||
const low = this.#classAtom();
|
||||
if (
|
||||
typeof low === "number" &&
|
||||
this.#peek() === "-" &&
|
||||
this.#peek(1) !== "]" &&
|
||||
this.#peek(1) !== ""
|
||||
) {
|
||||
this.#index++;
|
||||
const high = this.#classAtom();
|
||||
if (typeof high !== "number") throw new Unexpressible();
|
||||
members.push([low, high]);
|
||||
} else members.push(low);
|
||||
}
|
||||
this.#index++;
|
||||
// As for literals: V8 without `u` reads an astral member as two UTF-16
|
||||
// units, and the one-unit non-ASCII class emitted for it would reject it.
|
||||
if (
|
||||
members.some((member) =>
|
||||
typeof member === "number"
|
||||
? member > 0xffff
|
||||
: Array.isArray(member) && member[1] > 0xffff,
|
||||
)
|
||||
)
|
||||
throw new Unexpressible();
|
||||
const source = this.source.slice(start, this.#index);
|
||||
const { ascii: matched, nonAscii } = this.#membership(source, members);
|
||||
const ascii = negated
|
||||
? new Set(ASCII_CODES.filter((code) => !matched.has(code)))
|
||||
: matched;
|
||||
return emitClass(ascii, nonAscii, negated, this.#widen);
|
||||
}
|
||||
|
||||
#classAtom(): number | { shorthand: string } {
|
||||
if (this.#peek() === "\\") return this.#escape(true);
|
||||
const code = this.source.codePointAt(this.#index)!;
|
||||
this.#index += String.fromCodePoint(code).length;
|
||||
return code;
|
||||
}
|
||||
|
||||
#membership(
|
||||
classSource: string,
|
||||
members: readonly (number | { shorthand: string } | [number, number])[],
|
||||
): { readonly ascii: ReadonlySet<number>; readonly nonAscii: NonAscii } {
|
||||
// V8 decides ASCII membership exactly, including property escapes.
|
||||
const expression = new RegExp(`^${classSource}$`, "u");
|
||||
const ascii = new Set(
|
||||
ASCII_CODES.filter((code) => expression.test(String.fromCharCode(code))),
|
||||
);
|
||||
let nonAscii: NonAscii = "none";
|
||||
for (const member of members) {
|
||||
if (typeof member === "number")
|
||||
nonAscii = unionNonAscii(nonAscii, member >= 0x80 ? "some" : "none");
|
||||
else if (Array.isArray(member))
|
||||
nonAscii = unionNonAscii(
|
||||
nonAscii,
|
||||
member[1] < 0x80
|
||||
? "none"
|
||||
: member[0] <= 0x80 && member[1] >= 0x10ffff
|
||||
? "all"
|
||||
: "some",
|
||||
);
|
||||
else
|
||||
nonAscii = unionNonAscii(
|
||||
nonAscii,
|
||||
SHORTHAND_NON_ASCII[member.shorthand] ?? "some",
|
||||
);
|
||||
}
|
||||
return { ascii, nonAscii };
|
||||
}
|
||||
}
|
||||
|
||||
const projections = new Map<string, PortablePatternProjection>();
|
||||
|
||||
/**
|
||||
* Re-emit an advertised ECMAScript pattern in the portable grammar. Results
|
||||
* are memoized: advertised patterns come from a fixed set of contracts.
|
||||
*/
|
||||
export const projectPortablePattern = (
|
||||
pattern: string,
|
||||
): PortablePatternProjection => {
|
||||
const cached = projections.get(pattern);
|
||||
if (cached !== undefined) return cached;
|
||||
let projection: PortablePatternProjection;
|
||||
try {
|
||||
// Anchoring these unbounded negative probes requires a repeated universal
|
||||
// prefix; V8 v-mode can overflow its stack on a large valid base64 value.
|
||||
if (UNBOUNDED_BASE64_PROBES.has(pattern)) throw new Unexpressible();
|
||||
// JSON Schema validators read `pattern` with Unicode semantics.
|
||||
new RegExp(pattern, "u");
|
||||
const projected = new Projector(pattern).project();
|
||||
// The emitter's own output must stay inside the grammar it targets.
|
||||
projection =
|
||||
portablePatternViolations(projected.pattern).length > 0
|
||||
? { kind: "unexpressible" }
|
||||
: projected;
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof Unexpressible || cause instanceof SyntaxError))
|
||||
throw cause;
|
||||
projection = { kind: "unexpressible" };
|
||||
}
|
||||
projections.set(pattern, projection);
|
||||
return projection;
|
||||
};
|
||||
@@ -6,7 +6,10 @@ import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { advertisedInputExamples } from "./advertisedInputExamples.js";
|
||||
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
|
||||
import {
|
||||
presentInputJsonSchema,
|
||||
presentOutputJsonSchema,
|
||||
} from "./inputSchemaPresentation.js";
|
||||
import {
|
||||
schemaArrays,
|
||||
schemaChildren,
|
||||
@@ -142,10 +145,12 @@ export const toolOutputSchemaWithMetadata = <Contract extends ToolContract>(
|
||||
contract.outputSchema,
|
||||
"output",
|
||||
(project) => (options) => {
|
||||
const shared = project({
|
||||
...options,
|
||||
libraryOptions: { reused: "ref", ...options.libraryOptions },
|
||||
});
|
||||
const shared = presentOutputJsonSchema(
|
||||
project({
|
||||
...options,
|
||||
libraryOptions: { reused: "ref", ...options.libraryOptions },
|
||||
}),
|
||||
);
|
||||
// An explicit ID owns the reference base. Relative external references
|
||||
// also depend on that base, so leave those projections unchanged.
|
||||
if ("$id" in shared || hasRelativeSchemaReference(shared))
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { projectPortablePattern } from "../contracts/portablePattern.js";
|
||||
import { toolOutputSchemaWithMetadata } from "../contracts/toolSchemaMetadata.js";
|
||||
import { javaScriptRouteCandidateSchema } from "../domain/javascript/javascriptRouteFacts.js";
|
||||
import { javaScriptInventoryRouteSchema } from "../domain/javascript/javascriptRouteInventory.js";
|
||||
@@ -47,7 +48,10 @@ it("advertises JavaScript route outputs with standard JSON Schema path constrain
|
||||
addFormats(ajv);
|
||||
expect(() => ajv.compile(advertised as AnySchema)).not.toThrow();
|
||||
|
||||
// The inventory path is nested inside the advertised complete Evidence branch.
|
||||
// The inventory path is nested inside the advertised complete Evidence
|
||||
// branch, re-emitted as a portable anchored pattern.
|
||||
const portable = projectPortablePattern("^\\/");
|
||||
expect(portable.kind).toBe("exact");
|
||||
const patterns: Record<string, unknown>[] = [];
|
||||
const visit = (value: unknown): void => {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
@@ -56,7 +60,11 @@ it("advertises JavaScript route outputs with standard JSON Schema path constrain
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.pattern === "^\\/") patterns.push(record);
|
||||
if (
|
||||
portable.kind !== "unexpressible" &&
|
||||
record.pattern === portable.pattern
|
||||
)
|
||||
patterns.push(record);
|
||||
Object.values(record).forEach(visit);
|
||||
};
|
||||
visit(advertised);
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
portablePatternViolations,
|
||||
type AdvertisedToolDefinition,
|
||||
} from "../../../src/contracts/advertisedDialect.js";
|
||||
import {
|
||||
ADVERTISED_INPUT_DIALECT_BASELINE,
|
||||
ADVERTISED_OUTPUT_DIALECT_BASELINE,
|
||||
} from "../../../src/contracts/advertisedDialectBaseline.js";
|
||||
import { ADVERTISED_INPUT_DIALECT_BASELINE } from "../../../src/contracts/advertisedDialectBaseline.js";
|
||||
import { mcpInputSchemaProfileSchema } from "../../../src/config/mcpInputSchemaProfile.js";
|
||||
import { mcpOutputSchemaProfileSchema } from "../../../src/config/mcpOutputSchemaProfile.js";
|
||||
import { mcpToolCatalogProfileSchema } from "../../../src/config/mcpToolCatalogProfile.js";
|
||||
@@ -98,7 +95,7 @@ const distinct = (
|
||||
|
||||
it("advertises every profile within the portable dialect or its recorded baseline", async () => {
|
||||
const inputs = new Map<string, Map<string, AdvertisedToolDefinition>>();
|
||||
const outputs = new Map<string, Map<string, AdvertisedToolDefinition>>();
|
||||
const outputViolations: string[] = [];
|
||||
const uncompilable: string[] = [];
|
||||
for (const input of mcpInputSchemaProfileSchema.options)
|
||||
for (const output of mcpOutputSchemaProfileSchema.options)
|
||||
@@ -120,19 +117,18 @@ it("advertises every profile within the portable dialect or its recorded baselin
|
||||
).toBeGreaterThan(0);
|
||||
for (const tool of tools) {
|
||||
if (!inputs.has(input)) inputs.set(input, new Map());
|
||||
if (!outputs.has(output)) outputs.set(output, new Map());
|
||||
distinct(inputs.get(input)!, {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
});
|
||||
distinct(outputs.get(output)!, {
|
||||
name: tool.name,
|
||||
inputSchema: {},
|
||||
outputSchema: tool.outputSchema,
|
||||
});
|
||||
// SDK clients compile every listed output schema.
|
||||
for (const violation of advertisedOutputPatternViolations(tool))
|
||||
outputViolations.push(
|
||||
`${combination} ${tool.name} ${violation.path} ${violation.detail ?? ""}`,
|
||||
);
|
||||
// The grammar was checked against seven engines offline; this keeps
|
||||
// V8's own modes, including baselined patterns, checked in CI.
|
||||
// V8's own modes checked in CI.
|
||||
for (const schema of [tool.inputSchema, tool.outputSchema])
|
||||
for (const { path, pattern } of advertisedSchemaPatterns(schema))
|
||||
for (const flags of ["", "u", "v"])
|
||||
@@ -146,30 +142,21 @@ it("advertises every profile within the portable dialect or its recorded baselin
|
||||
}
|
||||
}
|
||||
expect(uncompilable).toEqual([]);
|
||||
expect(outputViolations).toEqual([]);
|
||||
|
||||
const observed = {
|
||||
input: Object.fromEntries(
|
||||
[...inputs].map(([profile, tools]) => [
|
||||
profile,
|
||||
countAdvertisedDialectViolations(
|
||||
[...tools.values()].flatMap(advertisedInputDialectViolations),
|
||||
),
|
||||
]),
|
||||
),
|
||||
output: Object.fromEntries(
|
||||
[...outputs].map(([profile, tools]) => [
|
||||
profile,
|
||||
countAdvertisedDialectViolations(
|
||||
[...tools.values()].flatMap(advertisedOutputPatternViolations),
|
||||
),
|
||||
]),
|
||||
),
|
||||
};
|
||||
const observed = Object.fromEntries(
|
||||
[...inputs].map(([profile, tools]) => [
|
||||
profile,
|
||||
countAdvertisedDialectViolations(
|
||||
[...tools.values()].flatMap(advertisedInputDialectViolations),
|
||||
),
|
||||
]),
|
||||
);
|
||||
// New constructs fail first with a named key; a count that dropped below
|
||||
// the baseline must be recorded so it cannot grow back.
|
||||
expect({
|
||||
input: Object.fromEntries(
|
||||
Object.entries(observed.input).map(([profile, counts]) => [
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
Object.entries(observed).map(([profile, counts]) => [
|
||||
profile,
|
||||
advertisedDialectRegressions(
|
||||
counts,
|
||||
@@ -179,28 +166,6 @@ it("advertises every profile within the portable dialect or its recorded baselin
|
||||
),
|
||||
]),
|
||||
),
|
||||
output: Object.fromEntries(
|
||||
Object.entries(observed.output).map(([profile, counts]) => [
|
||||
profile,
|
||||
advertisedDialectRegressions(
|
||||
counts,
|
||||
ADVERTISED_OUTPUT_DIALECT_BASELINE[
|
||||
profile as keyof typeof ADVERTISED_OUTPUT_DIALECT_BASELINE
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
}).toEqual({
|
||||
input: { compact: [], full: [] },
|
||||
output: { full: [], compact: [], off: [] },
|
||||
});
|
||||
// Absolute local paths are advertised with the host's pattern
|
||||
// (`localPath.ts`): POSIX hosts advertise `^\/`, which the grammar counts,
|
||||
// and Windows hosts a drive-or-root pattern, which it does not. Exact counts
|
||||
// are recorded on POSIX; Windows still enforces them as upper bounds above.
|
||||
if (process.platform !== "win32")
|
||||
expect(observed).toEqual({
|
||||
input: ADVERTISED_INPUT_DIALECT_BASELINE,
|
||||
output: ADVERTISED_OUTPUT_DIALECT_BASELINE,
|
||||
});
|
||||
).toEqual({ compact: [], full: [] });
|
||||
expect(observed).toEqual(ADVERTISED_INPUT_DIALECT_BASELINE);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { advertisedSchemaPatterns } from "../../../src/contracts/advertisedDialect.js";
|
||||
import { projectPortablePattern } from "../../../src/contracts/portablePattern.js";
|
||||
import {
|
||||
BASE64_QUARTET_LENGTH_PATTERN,
|
||||
INVALID_BASE64_CHARACTER_PATTERN,
|
||||
@@ -14,7 +15,14 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
|
||||
// Pattern portability across every profile is owned by
|
||||
// advertisedDialect.test.ts. This file keeps the composed canonical base64
|
||||
// constraint's advertised semantics.
|
||||
// constraint's safe advertisement after portable re-emission.
|
||||
|
||||
const advertised = (pattern: RegExp): string => {
|
||||
const projection = projectPortablePattern(pattern.source);
|
||||
if (projection.kind === "unexpressible")
|
||||
throw new Error(`No portable form for ${pattern.source}`);
|
||||
return projection.pattern;
|
||||
};
|
||||
|
||||
const collectCanonicalBase64Leaves = (
|
||||
value: unknown,
|
||||
@@ -34,7 +42,7 @@ const collectCanonicalBase64Leaves = (
|
||||
entry !== null &&
|
||||
typeof entry === "object" &&
|
||||
"pattern" in entry &&
|
||||
entry.pattern === BASE64_QUARTET_LENGTH_PATTERN.source,
|
||||
entry.pattern === advertised(BASE64_QUARTET_LENGTH_PATTERN),
|
||||
)
|
||||
)
|
||||
leaves.push(record);
|
||||
@@ -61,11 +69,11 @@ const listAdvertisedTools = async () => {
|
||||
return (await client.listTools()).tools;
|
||||
};
|
||||
|
||||
it("advertises the composed canonical base64 constraint with exact SDK semantics", async () => {
|
||||
const advertised = await listAdvertisedTools();
|
||||
it("advertises base64 without rejecting valid inputs or overflowing V8", async () => {
|
||||
const tools = await listAdvertisedTools();
|
||||
const patterns: string[] = [];
|
||||
const canonicalBase64Leaves: Record<string, unknown>[] = [];
|
||||
for (const tool of advertised)
|
||||
for (const tool of tools)
|
||||
for (const schema of [tool.inputSchema, tool.outputSchema]) {
|
||||
if (schema === undefined) continue;
|
||||
patterns.push(
|
||||
@@ -74,31 +82,32 @@ it("advertises the composed canonical base64 constraint with exact SDK semantics
|
||||
collectCanonicalBase64Leaves(schema, canonicalBase64Leaves);
|
||||
}
|
||||
|
||||
expect(patterns).toContain(advertised(BASE64_QUARTET_LENGTH_PATTERN));
|
||||
for (const pattern of [
|
||||
BASE64_QUARTET_LENGTH_PATTERN,
|
||||
INVALID_BASE64_CHARACTER_PATTERN,
|
||||
INVALID_BASE64_PADDING_PATTERN,
|
||||
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN,
|
||||
])
|
||||
expect(
|
||||
patterns.some((advertisedPattern) =>
|
||||
advertisedPattern.includes(pattern.source),
|
||||
),
|
||||
pattern.source,
|
||||
).toBe(true);
|
||||
expect(projectPortablePattern(pattern.source)).toEqual({
|
||||
kind: "unexpressible",
|
||||
});
|
||||
|
||||
expect(canonicalBase64Leaves.length).toBeGreaterThan(0);
|
||||
const sdkValidator = new AjvJsonSchemaValidator();
|
||||
const validators = canonicalBase64Leaves.map((schema) =>
|
||||
sdkValidator.getValidator(schema),
|
||||
);
|
||||
for (const value of ["", "AA==", "AQID", "AB==", "AAA", "AA$=", "AA\n=="])
|
||||
for (const value of ["", "AA==", "AQID"])
|
||||
expect(
|
||||
validators.map((validate) => validate(value).valid),
|
||||
JSON.stringify(value),
|
||||
).toEqual(
|
||||
canonicalBase64Leaves.map(
|
||||
() => value === "" || value === "AA==" || value === "AQID",
|
||||
),
|
||||
);
|
||||
).toEqual(canonicalBase64Leaves.map(() => true));
|
||||
const large = "A".repeat(8 * 1024 * 1024);
|
||||
const base64Patterns = new Set(
|
||||
canonicalBase64Leaves.flatMap((schema) =>
|
||||
advertisedSchemaPatterns(schema).map(({ pattern }) => pattern),
|
||||
),
|
||||
);
|
||||
for (const pattern of base64Patterns)
|
||||
expect(new RegExp(pattern, "v").test(large)).toBe(true);
|
||||
});
|
||||
Reference in new issue
Block a user