@ultimat3/schema 30.5.0 → 30.7.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/CLAUDE.md CHANGED
@@ -64,7 +64,11 @@ overlay already holds the raw body. `describe-value.test.ts` is the enforcement.
64
64
 
65
65
  `t.array(items, { min, max })` (`array-schema.ts`) carries its bounds as `minItems`/`maxItems` ON
66
66
  the node — JSON Schema's names — and `json-schema.ts` and `wire-schema.ts` publish both;
67
- `sample.ts`'s walker samples `minItems` items. A bound no count meets is
67
+ `sample.ts`'s walker samples `minItems` items.
68
+ A `pattern` is sampled by walking it (`sample-pattern.ts`, `patternSamples`): the ONE regex
69
+ generator, also what `@ultimat3/testing` seeds `matches()` columns with. Past its subset
70
+ (lookaround, backreference, `\b`, `\p{…}`) it yields nothing and `sampleGaps` names the field.
71
+ `sample-random.ts` (mulberry32) is the leaf both it and `sample-seeded.ts` draw from. A bound no count meets is
68
72
  `X_SCHEMA_BOUNDS_INVALID` where it is declared, the same rule as the discriminant below.
69
73
 
70
74
  `X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "30.5.0",
3
+ "version": "30.7.0",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -4,7 +4,9 @@
4
4
  * share every rule about what a node accepts; only the choices differ.
5
5
  */
6
6
 
7
+ import { charCount } from './char-count';
7
8
  import type { SchemaFormat, SchemaNode } from './node';
9
+ import { patternSamples } from './sample-pattern';
8
10
 
9
11
  /** Every choice a walk makes that its node leaves open. Answers stay inside the node's own bounds. */
10
12
  export interface Draw {
@@ -74,12 +76,47 @@ export function owedItems(node: SchemaNode): number | undefined {
74
76
  }
75
77
 
76
78
  /** `'sample'`, padded to `minLength` and cut to `maxLength`. */
77
- export function minimalString(node: SchemaNode): string {
79
+ function paddedSample(node: SchemaNode): string {
78
80
  const min = node.minLength ?? 0;
79
81
  const padded = SAMPLE_STRING.length >= min ? SAMPLE_STRING : SAMPLE_STRING.padEnd(min, 'x');
80
82
  return node.maxLength === undefined ? padded : padded.slice(0, node.maxLength);
81
83
  }
82
84
 
85
+ /** The seed the minimal draw walks a pattern with: fixed, so the minimal sample never varies. */
86
+ const MINIMAL_PATTERN_SEED = 1;
87
+
88
+ /** How many candidates a pattern draw offers before the length bounds pick among them. */
89
+ const PATTERN_CANDIDATES = 8;
90
+
91
+ /**
92
+ * A string the node's `pattern` accepts, built from the pattern itself (`sample-pattern.ts`) and
93
+ * inside `minLength`/`maxLength`; `undefined` when the pattern is past the generator's subset,
94
+ * uncompilable, or meets no length bound — which `sampleGaps` then reports, never a guess.
95
+ */
96
+ export function patternString(node: SchemaNode, seed: number): string | undefined {
97
+ if (node.pattern === undefined) return undefined;
98
+ let pattern: RegExp;
99
+ try {
100
+ pattern = new RegExp(node.pattern, node.patternFlags ?? '');
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ const fits = (value: string): boolean =>
105
+ charCount(value) >= (node.minLength ?? 0) &&
106
+ (node.maxLength === undefined || charCount(value) <= node.maxLength);
107
+ return patternSamples(pattern, seed, PATTERN_CANDIDATES).find(fits);
108
+ }
109
+
110
+ /**
111
+ * `'sample'`, padded to `minLength` and cut to `maxLength` — or, when the node's `pattern` refuses
112
+ * that, the first value the pattern itself yields. A pattern `'sample'` meets keeps `'sample'`.
113
+ */
114
+ export function minimalString(node: SchemaNode): string {
115
+ const padded = paddedSample(node);
116
+ if (satisfiesPattern(node, padded)) return padded;
117
+ return patternString(node, MINIMAL_PATTERN_SEED) ?? padded;
118
+ }
119
+
83
120
  /** The lower bound, rounded inward for an integer, or the upper one when the lower overshoots. */
84
121
  export function minimalNumber(node: SchemaNode): number {
85
122
  const low = node.minimum ?? 0;
@@ -0,0 +1,216 @@
1
+ // Strings a `pattern` accepts, built by walking the pattern rather than guessing shapes — the ONE
2
+ // generator, read by the input walker (`sample-draw.ts`, `sample-seeded.ts`) and by
3
+ // `@ultimat3/testing`'s seed values. The common subset only — literals, classes, `.`, groups,
4
+ // alternation, quantifiers, anchors — and an honest `[]` past it (lookaround, `\b`, `\p{…}`).
5
+
6
+ import { mulberry32 } from './sample-random';
7
+
8
+ type Node =
9
+ | { readonly kind: 'chars'; readonly chars: readonly string[] }
10
+ | { readonly kind: 'alt'; readonly branches: readonly (readonly Piece[])[] }
11
+ | { readonly kind: 'empty' };
12
+
13
+ interface Piece {
14
+ readonly node: Node;
15
+ readonly min: number;
16
+ readonly max: number;
17
+ }
18
+
19
+ /** How far past its floor an unbounded quantifier draws: enough to vary, short enough to store. */
20
+ const UNBOUNDED_SPAN = 6;
21
+
22
+ /** The universe a class or `.` picks from: printable ASCII, so every sample is plain text. */
23
+ const PRINTABLE = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i));
24
+ const DIGITS = PRINTABLE.filter((c) => c >= '0' && c <= '9');
25
+ const WORD = PRINTABLE.filter((c) => /\w/.test(c));
26
+ const SPACE = [' ', '\t'];
27
+
28
+ const SHORTHAND: Readonly<Record<string, readonly string[]>> = {
29
+ d: DIGITS,
30
+ D: PRINTABLE.filter((c) => !DIGITS.includes(c)),
31
+ w: WORD,
32
+ W: PRINTABLE.filter((c) => !WORD.includes(c)),
33
+ s: SPACE,
34
+ S: PRINTABLE.filter((c) => c !== ' '),
35
+ };
36
+
37
+ const CONTROL: Readonly<Record<string, string>> = { t: '\t', n: '\n', r: '\r', f: '\f', v: '\v' };
38
+
39
+ class Parser {
40
+ private at = 0;
41
+ /** The first construct past the subset; set once, and it ends the walk. */
42
+ private refused: string | undefined;
43
+ constructor(private readonly source: string) {}
44
+
45
+ /** The tree, or `undefined` when any part of the pattern is past the subset. */
46
+ parse(): Node | undefined {
47
+ const node = this.alternation();
48
+ if (this.refused === undefined && this.at < this.source.length) this.refuse('a stray ")"');
49
+ return this.refused === undefined ? node : undefined;
50
+ }
51
+
52
+ /** Records the refusal and moves to the end, so every loop above stops on its next look. */
53
+ private refuse(construct: string): readonly string[] {
54
+ this.refused ??= construct;
55
+ this.at = this.source.length;
56
+ return [];
57
+ }
58
+
59
+ private peek(): string | undefined {
60
+ return this.source[this.at];
61
+ }
62
+
63
+ private alternation(): Node {
64
+ const branches: Piece[][] = [this.sequence()];
65
+ while (this.peek() === '|') {
66
+ this.at += 1;
67
+ branches.push(this.sequence());
68
+ }
69
+ return { kind: 'alt', branches };
70
+ }
71
+
72
+ private sequence(): Piece[] {
73
+ const pieces: Piece[] = [];
74
+ for (let c = this.peek(); c !== undefined && c !== '|' && c !== ')'; c = this.peek()) {
75
+ const node = this.atom();
76
+ const [min, max] = this.quantifier();
77
+ pieces.push({ node, min, max });
78
+ }
79
+ return pieces;
80
+ }
81
+
82
+ private quantifier(): readonly [number, number] {
83
+ const c = this.peek();
84
+ const bounds = /^\{(\d+)(,(\d*))?\}/.exec(this.source.slice(this.at));
85
+ let range: readonly [number, number] | undefined;
86
+ if (c === '*') range = [0, UNBOUNDED_SPAN];
87
+ else if (c === '+') range = [1, 1 + UNBOUNDED_SPAN];
88
+ else if (c === '?') range = [0, 1];
89
+ else if (bounds !== null) {
90
+ const low = Number(bounds[1]);
91
+ const high =
92
+ bounds[2] === undefined ? low : bounds[3] === '' ? low + UNBOUNDED_SPAN : Number(bounds[3]);
93
+ range = [low, high];
94
+ }
95
+ if (range === undefined) return [1, 1];
96
+ this.at += bounds !== null && c === '{' ? bounds[0].length : 1;
97
+ // A lazy quantifier matches the same strings; only the engine's preference differs.
98
+ if (this.peek() === '?') this.at += 1;
99
+ return range;
100
+ }
101
+
102
+ private atom(): Node {
103
+ const c = this.peek() as string;
104
+ this.at += 1;
105
+ if (c === '^' || c === '$') return { kind: 'empty' };
106
+ if (c === '.') return { kind: 'chars', chars: PRINTABLE };
107
+ if (c === '[') return { kind: 'chars', chars: this.charClass() };
108
+ if (c === '(') return this.group();
109
+ if (c === '\\') return { kind: 'chars', chars: this.escape(false) };
110
+ if (c === '*' || c === '+' || c === '?' || c === '{')
111
+ return { kind: 'chars', chars: this.refuse(`a bare "${c}"`) };
112
+ return { kind: 'chars', chars: [c] };
113
+ }
114
+
115
+ private group(): Node {
116
+ if (this.peek() === '?') {
117
+ const head = this.source.slice(this.at, this.at + 3);
118
+ if (head.startsWith('?:')) this.at += 2;
119
+ else if (/^\?<[A-Za-z_$]/.test(head) && this.source.includes('>', this.at)) {
120
+ this.at = this.source.indexOf('>', this.at) + 1;
121
+ } else return { kind: 'chars', chars: this.refuse(`the lookaround "(${head}"`) };
122
+ }
123
+ const inner = this.alternation();
124
+ if (this.peek() !== ')') return { kind: 'chars', chars: this.refuse('an unclosed group') };
125
+ this.at += 1;
126
+ return inner;
127
+ }
128
+
129
+ private escape(inClass: boolean): readonly string[] {
130
+ const c = this.peek();
131
+ if (c === undefined) return this.refuse('a trailing backslash');
132
+ this.at += 1;
133
+ const shorthand = Object.hasOwn(SHORTHAND, c) ? SHORTHAND[c] : undefined;
134
+ if (shorthand !== undefined) return shorthand;
135
+ const control = Object.hasOwn(CONTROL, c) ? CONTROL[c] : undefined;
136
+ if (control !== undefined) return [control];
137
+ const hex = c === 'x' ? 2 : c === 'u' ? 4 : 0;
138
+ if (hex > 0) {
139
+ const digits = this.source.slice(this.at, this.at + hex);
140
+ if (!/^[0-9a-fA-F]+$/.test(digits) || digits.length !== hex) return this.refuse(`\\${c}`);
141
+ this.at += hex;
142
+ return [String.fromCharCode(Number.parseInt(digits, 16))];
143
+ }
144
+ // `\b`, `\B`, a backreference, `\p{…}`, `\k<…>`, `\0`: a position or a reference, not a char.
145
+ if (/[A-Za-z0-9]/.test(c)) return this.refuse(`\\${c}${inClass ? ' in a class' : ''}`);
146
+ return [c];
147
+ }
148
+
149
+ /** The members of `[…]`, drawn from printable ASCII; a negated class is its complement there. */
150
+ private charClass(): readonly string[] {
151
+ const negated = this.peek() === '^';
152
+ if (negated) this.at += 1;
153
+ const members = new Set<string>();
154
+ let first = true;
155
+ for (let c = this.peek(); c !== ']' || first; c = this.peek()) {
156
+ if (c === undefined) return this.refuse('an unclosed class');
157
+ first = false;
158
+ const low = this.classMember();
159
+ if (low.length === 1 && this.peek() === '-' && this.source[this.at + 1] !== ']') {
160
+ this.at += 1;
161
+ const high = this.classMember();
162
+ if (high.length !== 1) return this.refuse('a range ending in a shorthand');
163
+ const [from, to] = [(low[0] as string).charCodeAt(0), (high[0] as string).charCodeAt(0)];
164
+ // Past printable ASCII the range's own first member stands in, so `[À-ÿ]` still yields one.
165
+ const inside = PRINTABLE.filter((ch) => ch.charCodeAt(0) >= from && ch.charCodeAt(0) <= to);
166
+ for (const ch of inside.length > 0 ? inside : [String.fromCharCode(from)]) members.add(ch);
167
+ } else {
168
+ for (const ch of low) members.add(ch);
169
+ }
170
+ }
171
+ this.at += 1;
172
+ return negated ? PRINTABLE.filter((ch) => !members.has(ch)) : [...members];
173
+ }
174
+
175
+ private classMember(): readonly string[] {
176
+ const c = this.peek() as string;
177
+ this.at += 1;
178
+ return c === '\\' ? this.escape(true) : [c];
179
+ }
180
+ }
181
+
182
+ const draw = (node: Node, random: () => number): string => {
183
+ const pick = <T>(items: readonly T[]): T | undefined =>
184
+ items[Math.floor(random() * items.length)];
185
+ if (node.kind === 'empty') return '';
186
+ if (node.kind === 'chars') return pick(node.chars) ?? '';
187
+ const branch = pick(node.branches) ?? [];
188
+ return branch
189
+ .map((piece) => {
190
+ const count = piece.min + Math.floor(random() * (piece.max - piece.min + 1));
191
+ return Array.from({ length: count }, () => draw(piece.node, random)).join('');
192
+ })
193
+ .join('');
194
+ };
195
+
196
+ /** Tries per sample: a draw that crosses an anchor (`a^b`) or a flag is retried, never returned. */
197
+ const ATTEMPTS = 8;
198
+
199
+ /**
200
+ * Up to `count` distinct strings `pattern.test` accepts, the same ones for the same `seed` on any
201
+ * machine. Empty when the pattern is past the subset, or when no draw lands inside it. `seed` is a
202
+ * number because this tier cannot import `fnv1a`; a caller with a text seed hashes it first.
203
+ */
204
+ export function patternSamples(pattern: RegExp, seed: number, count = 4): readonly string[] {
205
+ const tree = new Parser(pattern.source).parse();
206
+ if (tree === undefined) return [];
207
+ const random = mulberry32(seed);
208
+ // `lastIndex` would make `test` stateful under `g`/`y`; a copy without them judges each draw.
209
+ const judge = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
210
+ const out: string[] = [];
211
+ for (let attempt = 0; attempt < count * ATTEMPTS && out.length < count; attempt += 1) {
212
+ const sample = draw(tree, random);
213
+ if (judge.test(sample) && !out.includes(sample)) out.push(sample);
214
+ }
215
+ return out;
216
+ }
@@ -0,0 +1,14 @@
1
+ // The one PRNG the sample walkers draw from. A leaf: `sample-seeded.ts` and `sample-pattern.ts`
2
+ // both read it, and the second is imported by `sample-draw.ts`, which the first imports.
3
+
4
+ /** mulberry32: 32 bits of state, a uniform float in [0, 1) per call, identical on every platform. */
5
+ export function mulberry32(state: number): () => number {
6
+ let s = state >>> 0;
7
+ return () => {
8
+ s = (s + 0x6d2b79f5) >>> 0;
9
+ let x = s;
10
+ x = Math.imul(x ^ (x >>> 15), x | 1);
11
+ x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
12
+ return ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296;
13
+ };
14
+ }
@@ -10,9 +10,11 @@ import {
10
10
  minimalNumber,
11
11
  minimalString,
12
12
  owedItems,
13
+ patternString,
13
14
  SAMPLE_ITEMS_MAX,
14
15
  satisfiesPattern,
15
16
  } from './sample-draw';
17
+ import { mulberry32 } from './sample-random';
16
18
 
17
19
  /** Runs below this one are boundaries, not draws. */
18
20
  export const BOUNDARY_RUNS = 4;
@@ -52,18 +54,6 @@ const INTEGER_HIGH = 2 ** 31 - 1;
52
54
  /** An array draws at most this many items past what `minItems` owes. */
53
55
  const ITEMS_SPAN = 4;
54
56
 
55
- /** mulberry32: 32 bits of state, a uniform float in [0, 1) per call. */
56
- function generator(state: number): () => number {
57
- let s = state >>> 0;
58
- return () => {
59
- s = (s + 0x6d2b79f5) >>> 0;
60
- let x = s;
61
- x = Math.imul(x ^ (x >>> 15), x | 1);
62
- x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
63
- return ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296;
64
- };
65
- }
66
-
67
57
  /** Which side of every bound a boundary run takes, and whether it reaches for the optional parts. */
68
58
  interface Boundary {
69
59
  readonly high: boolean;
@@ -99,7 +89,7 @@ function lengthRange(node: SchemaNode): readonly [number, number] {
99
89
  * `@ultimat3/query` hand it `fnv1a(<name>)`, which this tier cannot import.
100
90
  */
101
91
  export function seededDraw(seed: number, run: number): Draw {
102
- const next = generator((seed ^ Math.imul(run + 1, 0x9e3779b1)) >>> 0);
92
+ const next = mulberry32((seed ^ Math.imul(run + 1, 0x9e3779b1)) >>> 0);
103
93
  const between = (low: number, high: number): number =>
104
94
  low + Math.floor(next() * (high - low + 1));
105
95
  const edge = run >= 0 && run < BOUNDARY_RUNS ? BOUNDARIES[run] : undefined;
@@ -109,9 +99,11 @@ export function seededDraw(seed: number, run: number): Draw {
109
99
  const length = edge === undefined ? between(low, high) : edge.high ? high : low;
110
100
  const chars = Array.from({ length }, () => ALPHABET[between(0, ALPHABET.length - 1)] ?? 'a');
111
101
  const value = chars.join('');
112
- // A regex cannot be inverted: a drawn value that misses it would test only the validator, so
113
- // the one value the walk can build for it stands in (and `sampleGaps` reports when it cannot).
114
- return satisfiesPattern(node, value) ? value : minimalString(node);
102
+ // A drawn value that misses the pattern would test only the validator, so a value walked from
103
+ // the pattern on this run's stream stands in; past the subset, the minimal one (a `sampleGaps`
104
+ // gap when it misses too).
105
+ if (satisfiesPattern(node, value)) return value;
106
+ return patternString(node, Math.floor(next() * 4_294_967_296)) ?? minimalString(node);
115
107
  };
116
108
 
117
109
  const number = (node: SchemaNode): number => {
package/src/sample.ts CHANGED
@@ -21,6 +21,7 @@ import type { StandardSchemaV1 } from './standard';
21
21
 
22
22
  export type { Draw } from './sample-draw';
23
23
  export { minimalDraw, SAMPLE_ITEMS_MAX } from './sample-draw';
24
+ export { patternSamples } from './sample-pattern';
24
25
  export { BOUNDARY_RUNS, seededDraw } from './sample-seeded';
25
26
 
26
27
  /**