@ultimat3/testing 30.3.0 → 30.5.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 +4 -4
- package/README.md +2 -1
- package/package.json +17 -17
- package/src/define-exercise.ts +11 -0
- package/src/errors.ts +6 -2
- package/src/exercise-crash.ts +1 -0
- package/src/exercise-defect.ts +71 -0
- package/src/exercise-job-tenant.ts +29 -2
- package/src/exercise-report.ts +13 -3
- package/src/exercise-setup.ts +68 -0
- package/src/exercise-subjects.ts +19 -5
- package/src/exercise.ts +21 -4
- package/src/factory-registry.ts +14 -2
- package/src/index.ts +4 -0
- package/src/job-crash-proof.ts +28 -4
- package/src/job-sim-effects.ts +7 -2
- package/src/job-sim-errors.ts +36 -5
- package/src/job-sim-world.ts +4 -1
- package/src/mock-route.ts +21 -0
- package/src/plan-oracle.ts +5 -2
- package/src/plan-sweep-fix.ts +59 -7
- package/src/plan-sweep-report.ts +22 -3
- package/src/registry-leak-guard.ts +2 -2
- package/src/sealed-dns.ts +86 -0
- package/src/sealed-network.ts +48 -7
- package/src/seed-graph-order.ts +9 -5
- package/src/seed-graph-pass.ts +31 -2
- package/src/seed-graph-patterns.ts +51 -0
- package/src/seed-graph-values.ts +72 -8
- package/src/seed-graph.ts +7 -0
- package/src/seed-pattern-sample.ts +216 -0
- package/src/subject-proof.ts +3 -1
package/src/seed-graph-pass.ts
CHANGED
|
@@ -41,6 +41,31 @@ const refused = (write: SeedRow | SeedUpdate, error: unknown): unknown => {
|
|
|
41
41
|
});
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
const refusedRow = (error: unknown): boolean =>
|
|
45
|
+
!isUltimateError(error) || ROW_REFUSALS.has(error.code) || error.code === 'X_ENTITY_APPEND_ONLY';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A deferred key the database refuses to set is left null: only a nullable key is ever deferred
|
|
49
|
+
* (`seed-graph-order.ts`), so the row as inserted is already a valid one. The refusal is a rule no
|
|
50
|
+
* declaration carries — a trigger that refuses every UPDATE of the table. An environment failure
|
|
51
|
+
* still rejects with its own code. `onRefused` hears each update left out.
|
|
52
|
+
*/
|
|
53
|
+
export const deferredMayStayNull = (
|
|
54
|
+
writer: SeedWriter,
|
|
55
|
+
onRefused: (update: SeedUpdate) => void = () => undefined,
|
|
56
|
+
): SeedWriter => ({
|
|
57
|
+
insert: (row) => writer.insert(row),
|
|
58
|
+
...(writer.insertMany === undefined
|
|
59
|
+
? {}
|
|
60
|
+
: { insertMany: (rows: readonly SeedRow[]) => writer.insertMany?.(rows) ?? Promise.resolve() }),
|
|
61
|
+
update: (update) =>
|
|
62
|
+
writer.update(update).catch((error: unknown) => {
|
|
63
|
+
if (!refusedRow(error)) return Promise.reject(error);
|
|
64
|
+
onRefused(update);
|
|
65
|
+
}),
|
|
66
|
+
snapshot: (rows) => writer.snapshot(rows),
|
|
67
|
+
});
|
|
68
|
+
|
|
44
69
|
/** Every write coded: a row refusal names its factory, an environment failure keeps its own fix. */
|
|
45
70
|
const coded = (writer: SeedWriter): SeedWriter => ({
|
|
46
71
|
insert: (row) =>
|
|
@@ -146,6 +171,10 @@ export async function seedPass(
|
|
|
146
171
|
seed: options.seed,
|
|
147
172
|
...(options.factories === undefined ? {} : { factories: options.factories }),
|
|
148
173
|
});
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
const refused = new Set<SeedUpdate>();
|
|
175
|
+
await insertSeedGraph(graph, coded(deferredMayStayNull(writer, (update) => refused.add(update))));
|
|
176
|
+
// What was written: an update the database refused is no part of the seeded state.
|
|
177
|
+
return refused.size === 0
|
|
178
|
+
? graph
|
|
179
|
+
: { ...graph, updates: graph.updates.filter((update) => !refused.has(update)) };
|
|
151
180
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The `matches(/…/)` patterns an entity declares, per property. Read back from each invariant's
|
|
2
|
+
// own CHECK text, which `expr.ts` builds as `<column> ~ <literal(source)>` (or `btrim(<column>)`,
|
|
3
|
+
// `~*` for `/i`) — the same source `pattern.test` runs, so nothing here is a guess. A rule of any
|
|
4
|
+
// other shape (`iff`, a predicate, two columns) is not a pattern and is skipped.
|
|
5
|
+
|
|
6
|
+
import type { SeedEntity } from './seed-graph-shape';
|
|
7
|
+
|
|
8
|
+
/** `col ~ E'src'` / `btrim(col) ~* 'src'`: the whole CHECK, one column, one quoted literal. */
|
|
9
|
+
const PATTERN_CHECK = /^(?:btrim\(([a-z_][a-z0-9_$]*)\)|([a-z_][a-z0-9_$]*)) (~\*?) (E?)'(.*)'$/s;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The `RegExp` one CHECK enforces on `column`, or `undefined`. The literal is undone exactly as
|
|
13
|
+
* `@ultimat3/db`'s `literal()` made it: `''` is one quote, and under `E'…'` `\\` is one backslash.
|
|
14
|
+
*/
|
|
15
|
+
export function patternOfCheck(sql: string, column: string): RegExp | undefined {
|
|
16
|
+
const match = PATTERN_CHECK.exec(sql);
|
|
17
|
+
if (match === null || (match[1] ?? match[2]) !== column) return undefined;
|
|
18
|
+
const body = match[5] ?? '';
|
|
19
|
+
// A lone quote inside the body is not something `literal()` writes: not this shape after all.
|
|
20
|
+
if (body.replaceAll("''", '').includes("'")) return undefined;
|
|
21
|
+
const unquoted = body.replaceAll("''", "'");
|
|
22
|
+
const source = match[4] === 'E' ? unquoted.replaceAll('\\\\', '\\') : unquoted;
|
|
23
|
+
try {
|
|
24
|
+
return new RegExp(source, match[3] === '~*' ? 'i' : '');
|
|
25
|
+
} catch {
|
|
26
|
+
// A source JavaScript cannot compile is no pattern to sample from; the solver still judges
|
|
27
|
+
// every candidate by the rule itself, so skipping it loses a hint and never a check.
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Property -> the patterns its `check` invariants declare. Empty for an entity with none. */
|
|
33
|
+
export function declaredPatterns(
|
|
34
|
+
entity: Pick<SeedEntity, '$invariants' | '$describe'>,
|
|
35
|
+
): ReadonlyMap<string, readonly RegExp[]> {
|
|
36
|
+
const propertyOf = new Map(
|
|
37
|
+
entity.$describe().columns.map((column) => [column.column, column.property] as const),
|
|
38
|
+
);
|
|
39
|
+
const out = new Map<string, RegExp[]>();
|
|
40
|
+
for (const invariant of entity.$invariants) {
|
|
41
|
+
const [column] = invariant.columns;
|
|
42
|
+
if (invariant.kind !== 'check' || invariant.sql === null || invariant.columns.length !== 1) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const property = column === undefined ? undefined : propertyOf.get(column);
|
|
46
|
+
const pattern = column === undefined ? undefined : patternOfCheck(invariant.sql, column);
|
|
47
|
+
if (property === undefined || pattern === undefined) continue;
|
|
48
|
+
out.set(property, [...(out.get(property) ?? []), pattern]);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
package/src/seed-graph-values.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// A value for one column, read off the column's own declaration — its kind, nullability, enumerated
|
|
2
|
-
// values, length, money parts and
|
|
3
|
-
// column's `$parse`, which refuses exactly what its CHECK
|
|
2
|
+
// values, length, money parts, default, `matches()` patterns and `json(schema)` — never off its
|
|
3
|
+
// name. Every candidate is run through the column's `$parse`, which refuses exactly what its CHECK
|
|
4
|
+
// refuses, so what survives is storable.
|
|
4
5
|
|
|
6
|
+
import { fnv1a } from '@ultimat3/core';
|
|
5
7
|
import type { ColumnMeta } from '@ultimat3/entity';
|
|
8
|
+
import type * as Sample from '@ultimat3/schema/sample';
|
|
9
|
+
import { patternSamples } from './seed-pattern-sample';
|
|
6
10
|
|
|
7
11
|
/** A column as `entity()` declares it, structurally: this barrel never loads `@ultimat3/entity`. */
|
|
8
12
|
export interface SeedColumn {
|
|
@@ -18,6 +22,11 @@ export interface ValueContext {
|
|
|
18
22
|
readonly canary: string;
|
|
19
23
|
/** Declaration position of the column, so two instants on one row are ordered as declared. */
|
|
20
24
|
readonly position: number;
|
|
25
|
+
/**
|
|
26
|
+
* The `matches(/…/)` patterns the entity's invariants hold this column to (`declaredPatterns`).
|
|
27
|
+
* Each one adds strings built from the pattern itself, so a rule no fixed shape meets is met.
|
|
28
|
+
*/
|
|
29
|
+
readonly patterns?: readonly RegExp[] | undefined;
|
|
21
30
|
uuid(): string;
|
|
22
31
|
}
|
|
23
32
|
|
|
@@ -59,7 +68,46 @@ const textCandidates = (property: string, meta: ColumnMeta, ctx: ValueContext):
|
|
|
59
68
|
`https://seed.test/${word}/${ctx.canary}`,
|
|
60
69
|
ctx.canary,
|
|
61
70
|
];
|
|
62
|
-
|
|
71
|
+
const sized = meta.length === undefined ? shapes : [...shapes, ctx.canary.slice(0, meta.length)];
|
|
72
|
+
return [...sized, ...fromPatterns(property, ctx)];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Strings drawn from each declared pattern, kept only where every other pattern agrees. */
|
|
76
|
+
const fromPatterns = (property: string, ctx: ValueContext): string[] => {
|
|
77
|
+
const patterns = ctx.patterns ?? [];
|
|
78
|
+
return patterns
|
|
79
|
+
.flatMap((pattern) => patternSamples(pattern, `${property}:${ctx.canary}`))
|
|
80
|
+
.filter((sample) => patterns.every((pattern) => pattern.test(sample)));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
let loadedSampler: typeof Sample | undefined;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `@ultimat3/schema/sample`, loaded on the first `json(schema)` column and never at import: this
|
|
87
|
+
* module is on the barrel, and the walker is a dynamic-only dependency there (`CLAUDE.md`).
|
|
88
|
+
* `require` and not `import()` because every caller of `candidatesFor` is synchronous.
|
|
89
|
+
*/
|
|
90
|
+
const sampler = (): typeof Sample => {
|
|
91
|
+
loadedSampler ??= require('@ultimat3/schema/sample') as typeof Sample;
|
|
92
|
+
return loadedSampler;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Values the column's own schema accepts, seeded by the row so two rows differ and a rerun does
|
|
97
|
+
* not; then the bare `{}`/`[]`, which a schema-less `jsonb` column stores.
|
|
98
|
+
*/
|
|
99
|
+
const jsonCandidates = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[] => {
|
|
100
|
+
const schema = meta.schema;
|
|
101
|
+
if (schema === undefined) return [{}, []];
|
|
102
|
+
const { arbitrary, sampleInput, BOUNDARY_RUNS } = sampler();
|
|
103
|
+
const seed = fnv1a(`${property}:${ctx.canary}`);
|
|
104
|
+
return [
|
|
105
|
+
arbitrary(schema, { seed, run: BOUNDARY_RUNS }),
|
|
106
|
+
arbitrary(schema, { seed, run: 0 }),
|
|
107
|
+
sampleInput(schema),
|
|
108
|
+
{},
|
|
109
|
+
[],
|
|
110
|
+
];
|
|
63
111
|
};
|
|
64
112
|
|
|
65
113
|
const byKind = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[] => {
|
|
@@ -84,7 +132,7 @@ const byKind = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[
|
|
|
84
132
|
case 'date':
|
|
85
133
|
return [`2026-01-${String((n % 28) + 1).padStart(2, '0')}`];
|
|
86
134
|
case 'jsonb':
|
|
87
|
-
return
|
|
135
|
+
return jsonCandidates(property, meta, ctx);
|
|
88
136
|
case 'bytea':
|
|
89
137
|
return [new Uint8Array([n % 256])];
|
|
90
138
|
case 'array':
|
|
@@ -104,6 +152,12 @@ const parsed = (column: SeedColumn, value: unknown): { readonly ok: unknown } |
|
|
|
104
152
|
}
|
|
105
153
|
};
|
|
106
154
|
|
|
155
|
+
/**
|
|
156
|
+
* What every driver binds as SQL NULL — a JSON `null` included (`pg-row.ts` `bindable`): `t.json()`
|
|
157
|
+
* accepts it and the sampler draws it, so a NOT NULL `jsonb` column must never be offered one.
|
|
158
|
+
*/
|
|
159
|
+
const storesAsSqlNull = (value: unknown): boolean => value === null || value === undefined;
|
|
160
|
+
|
|
107
161
|
/**
|
|
108
162
|
* Every storable value for this column, most plausible first: the declared default, then what the
|
|
109
163
|
* kind offers, then `null` when the column allows it. Empty means nothing generated is storable.
|
|
@@ -119,13 +173,23 @@ export function candidatesFor(
|
|
|
119
173
|
const out: unknown[] = [];
|
|
120
174
|
for (const value of raw) {
|
|
121
175
|
const result = parsed(column, value);
|
|
122
|
-
if (result
|
|
123
|
-
|
|
124
|
-
}
|
|
176
|
+
if (result === undefined || (meta.notNull && storesAsSqlNull(result.ok))) continue;
|
|
177
|
+
if (!out.some((seen) => Bun.deepEquals(seen, result.ok))) out.push(result.ok);
|
|
125
178
|
}
|
|
126
|
-
return out;
|
|
179
|
+
return keepsPatternsFirst(out, ctx.patterns ?? []);
|
|
127
180
|
}
|
|
128
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Stable: what keeps every declared pattern moves ahead, so `factoriesFor`, which takes the FIRST
|
|
184
|
+
* candidate and runs no solver, builds a row its own rules accept.
|
|
185
|
+
*/
|
|
186
|
+
const keepsPatternsFirst = (values: unknown[], patterns: readonly RegExp[]): unknown[] => {
|
|
187
|
+
if (patterns.length === 0) return values;
|
|
188
|
+
const keeps = (value: unknown): boolean =>
|
|
189
|
+
typeof value === 'string' && patterns.every((pattern) => pattern.test(value));
|
|
190
|
+
return [...values.filter(keeps), ...values.filter((value) => !keeps(value))];
|
|
191
|
+
};
|
|
192
|
+
|
|
129
193
|
/** Candidates offered verbatim (a factory's value), kept only where the column stores them. */
|
|
130
194
|
export function storable(column: SeedColumn, values: readonly unknown[]): readonly unknown[] {
|
|
131
195
|
return values.flatMap((value) => {
|
package/src/seed-graph.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { ReferenceDescription } from '@ultimat3/entity';
|
|
|
7
7
|
import { DEFAULT_SEED, seededUuid } from './determinism';
|
|
8
8
|
import { seedGraphUnsatisfied } from './seed-graph-error';
|
|
9
9
|
import { edgeKey, insertOrder } from './seed-graph-order';
|
|
10
|
+
import { declaredPatterns } from './seed-graph-patterns';
|
|
10
11
|
import type {
|
|
11
12
|
SeedCanary,
|
|
12
13
|
SeedGraph,
|
|
@@ -91,6 +92,7 @@ export function seedGraph(
|
|
|
91
92
|
[...shapes.values()].map((shape) => ({
|
|
92
93
|
name: shape.name,
|
|
93
94
|
references: [...shape.references.values()],
|
|
95
|
+
updatable: shape.entity.$describe().appendOnly !== true,
|
|
94
96
|
})),
|
|
95
97
|
);
|
|
96
98
|
const tenantStream = seededUuid(streamSeed(seed, '$tenants'));
|
|
@@ -163,6 +165,8 @@ export function seedGraph(
|
|
|
163
165
|
const allUniques = [...shape.uniques, ...shape.partialUniques];
|
|
164
166
|
const taken = allUniques.map(() => new Set<string>());
|
|
165
167
|
const rootProperty = roots.get(name);
|
|
168
|
+
// Read once per table: each `matches(/…/)` an invariant holds a column to, sampled from.
|
|
169
|
+
const patterns = declaredPatterns(entity);
|
|
166
170
|
const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
|
|
167
171
|
let ordinal = 0;
|
|
168
172
|
for (const [bucket, tenant] of buckets.entries()) {
|
|
@@ -195,6 +199,7 @@ export function seedGraph(
|
|
|
195
199
|
ordinal,
|
|
196
200
|
canary,
|
|
197
201
|
position,
|
|
202
|
+
patterns: patterns.get(property),
|
|
198
203
|
uuid: stream,
|
|
199
204
|
});
|
|
200
205
|
return [
|
|
@@ -257,6 +262,8 @@ export function seedGraph(
|
|
|
257
262
|
const updates: SeedUpdate[] = [];
|
|
258
263
|
for (const name of order) {
|
|
259
264
|
const shape = shapes.get(name) as EntityShape;
|
|
265
|
+
// An appendOnly row is never updated (its trigger refuses): a key deferred there stays null.
|
|
266
|
+
if (shape.entity.$describe().appendOnly === true) continue;
|
|
260
267
|
const later = shape.columns.filter(([property]) => deferred.has(edgeKey(name, property)));
|
|
261
268
|
if (later.length === 0) continue;
|
|
262
269
|
for (const { tenant, row, index } of placed.get(name) ?? []) {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Strings a declared `matches(/…/)` pattern accepts, built by walking the pattern rather than
|
|
2
|
+
// guessing shapes. The common subset only — literals, classes, `.`, groups, alternation,
|
|
3
|
+
// quantifiers, anchors — and an honest `[]` for anything past it (lookaround, backreference, `\b`,
|
|
4
|
+
// `\p{…}`): a pattern this cannot read leaves the column's other candidates to the solver.
|
|
5
|
+
|
|
6
|
+
import { fnv1a } from '@ultimat3/core';
|
|
7
|
+
import { seededRandom } from './determinism';
|
|
8
|
+
|
|
9
|
+
type Node =
|
|
10
|
+
| { readonly kind: 'chars'; readonly chars: readonly string[] }
|
|
11
|
+
| { readonly kind: 'alt'; readonly branches: readonly (readonly Piece[])[] }
|
|
12
|
+
| { readonly kind: 'empty' };
|
|
13
|
+
|
|
14
|
+
interface Piece {
|
|
15
|
+
readonly node: Node;
|
|
16
|
+
readonly min: number;
|
|
17
|
+
readonly max: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** How far past its floor an unbounded quantifier draws: enough to vary, short enough to store. */
|
|
21
|
+
const UNBOUNDED_SPAN = 6;
|
|
22
|
+
|
|
23
|
+
/** The universe a class or `.` picks from: printable ASCII, so every sample is plain text. */
|
|
24
|
+
const PRINTABLE = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i));
|
|
25
|
+
const DIGITS = PRINTABLE.filter((c) => c >= '0' && c <= '9');
|
|
26
|
+
const WORD = PRINTABLE.filter((c) => /\w/.test(c));
|
|
27
|
+
const SPACE = [' ', '\t'];
|
|
28
|
+
|
|
29
|
+
const SHORTHAND: Readonly<Record<string, readonly string[]>> = {
|
|
30
|
+
d: DIGITS,
|
|
31
|
+
D: PRINTABLE.filter((c) => !DIGITS.includes(c)),
|
|
32
|
+
w: WORD,
|
|
33
|
+
W: PRINTABLE.filter((c) => !WORD.includes(c)),
|
|
34
|
+
s: SPACE,
|
|
35
|
+
S: PRINTABLE.filter((c) => c !== ' '),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const CONTROL: Readonly<Record<string, string>> = { t: '\t', n: '\n', r: '\r', f: '\f', v: '\v' };
|
|
39
|
+
|
|
40
|
+
class Parser {
|
|
41
|
+
private at = 0;
|
|
42
|
+
/** The first construct past the subset; set once, and it ends the walk. */
|
|
43
|
+
private refused: string | undefined;
|
|
44
|
+
constructor(private readonly source: string) {}
|
|
45
|
+
|
|
46
|
+
/** The tree, or `undefined` when any part of the pattern is past the subset. */
|
|
47
|
+
parse(): Node | undefined {
|
|
48
|
+
const node = this.alternation();
|
|
49
|
+
if (this.refused === undefined && this.at < this.source.length) this.refuse('a stray ")"');
|
|
50
|
+
return this.refused === undefined ? node : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Records the refusal and moves to the end, so every loop above stops on its next look. */
|
|
54
|
+
private refuse(construct: string): readonly string[] {
|
|
55
|
+
this.refused ??= construct;
|
|
56
|
+
this.at = this.source.length;
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private peek(): string | undefined {
|
|
61
|
+
return this.source[this.at];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private alternation(): Node {
|
|
65
|
+
const branches: Piece[][] = [this.sequence()];
|
|
66
|
+
while (this.peek() === '|') {
|
|
67
|
+
this.at += 1;
|
|
68
|
+
branches.push(this.sequence());
|
|
69
|
+
}
|
|
70
|
+
return { kind: 'alt', branches };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private sequence(): Piece[] {
|
|
74
|
+
const pieces: Piece[] = [];
|
|
75
|
+
for (let c = this.peek(); c !== undefined && c !== '|' && c !== ')'; c = this.peek()) {
|
|
76
|
+
const node = this.atom();
|
|
77
|
+
const [min, max] = this.quantifier();
|
|
78
|
+
pieces.push({ node, min, max });
|
|
79
|
+
}
|
|
80
|
+
return pieces;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private quantifier(): readonly [number, number] {
|
|
84
|
+
const c = this.peek();
|
|
85
|
+
const bounds = /^\{(\d+)(,(\d*))?\}/.exec(this.source.slice(this.at));
|
|
86
|
+
let range: readonly [number, number] | undefined;
|
|
87
|
+
if (c === '*') range = [0, UNBOUNDED_SPAN];
|
|
88
|
+
else if (c === '+') range = [1, 1 + UNBOUNDED_SPAN];
|
|
89
|
+
else if (c === '?') range = [0, 1];
|
|
90
|
+
else if (bounds !== null) {
|
|
91
|
+
const low = Number(bounds[1]);
|
|
92
|
+
const high =
|
|
93
|
+
bounds[2] === undefined ? low : bounds[3] === '' ? low + UNBOUNDED_SPAN : Number(bounds[3]);
|
|
94
|
+
range = [low, high];
|
|
95
|
+
}
|
|
96
|
+
if (range === undefined) return [1, 1];
|
|
97
|
+
this.at += bounds !== null && c === '{' ? bounds[0].length : 1;
|
|
98
|
+
// A lazy quantifier matches the same strings; only the engine's preference differs.
|
|
99
|
+
if (this.peek() === '?') this.at += 1;
|
|
100
|
+
return range;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private atom(): Node {
|
|
104
|
+
const c = this.peek() as string;
|
|
105
|
+
this.at += 1;
|
|
106
|
+
if (c === '^' || c === '$') return { kind: 'empty' };
|
|
107
|
+
if (c === '.') return { kind: 'chars', chars: PRINTABLE };
|
|
108
|
+
if (c === '[') return { kind: 'chars', chars: this.charClass() };
|
|
109
|
+
if (c === '(') return this.group();
|
|
110
|
+
if (c === '\\') return { kind: 'chars', chars: this.escape(false) };
|
|
111
|
+
if (c === '*' || c === '+' || c === '?' || c === '{')
|
|
112
|
+
return { kind: 'chars', chars: this.refuse(`a bare "${c}"`) };
|
|
113
|
+
return { kind: 'chars', chars: [c] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private group(): Node {
|
|
117
|
+
if (this.peek() === '?') {
|
|
118
|
+
const head = this.source.slice(this.at, this.at + 3);
|
|
119
|
+
if (head.startsWith('?:')) this.at += 2;
|
|
120
|
+
else if (/^\?<[A-Za-z_$]/.test(head) && this.source.includes('>', this.at)) {
|
|
121
|
+
this.at = this.source.indexOf('>', this.at) + 1;
|
|
122
|
+
} else return { kind: 'chars', chars: this.refuse(`the lookaround "(${head}"`) };
|
|
123
|
+
}
|
|
124
|
+
const inner = this.alternation();
|
|
125
|
+
if (this.peek() !== ')') return { kind: 'chars', chars: this.refuse('an unclosed group') };
|
|
126
|
+
this.at += 1;
|
|
127
|
+
return inner;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private escape(inClass: boolean): readonly string[] {
|
|
131
|
+
const c = this.peek();
|
|
132
|
+
if (c === undefined) return this.refuse('a trailing backslash');
|
|
133
|
+
this.at += 1;
|
|
134
|
+
const shorthand = Object.hasOwn(SHORTHAND, c) ? SHORTHAND[c] : undefined;
|
|
135
|
+
if (shorthand !== undefined) return shorthand;
|
|
136
|
+
const control = Object.hasOwn(CONTROL, c) ? CONTROL[c] : undefined;
|
|
137
|
+
if (control !== undefined) return [control];
|
|
138
|
+
const hex = c === 'x' ? 2 : c === 'u' ? 4 : 0;
|
|
139
|
+
if (hex > 0) {
|
|
140
|
+
const digits = this.source.slice(this.at, this.at + hex);
|
|
141
|
+
if (!/^[0-9a-fA-F]+$/.test(digits) || digits.length !== hex) return this.refuse(`\\${c}`);
|
|
142
|
+
this.at += hex;
|
|
143
|
+
return [String.fromCharCode(Number.parseInt(digits, 16))];
|
|
144
|
+
}
|
|
145
|
+
// `\b`, `\B`, a backreference, `\p{…}`, `\k<…>`, `\0`: a position or a reference, not a char.
|
|
146
|
+
if (/[A-Za-z0-9]/.test(c)) return this.refuse(`\\${c}${inClass ? ' in a class' : ''}`);
|
|
147
|
+
return [c];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The members of `[…]`, drawn from printable ASCII; a negated class is its complement there. */
|
|
151
|
+
private charClass(): readonly string[] {
|
|
152
|
+
const negated = this.peek() === '^';
|
|
153
|
+
if (negated) this.at += 1;
|
|
154
|
+
const members = new Set<string>();
|
|
155
|
+
let first = true;
|
|
156
|
+
for (let c = this.peek(); c !== ']' || first; c = this.peek()) {
|
|
157
|
+
if (c === undefined) return this.refuse('an unclosed class');
|
|
158
|
+
first = false;
|
|
159
|
+
const low = this.classMember();
|
|
160
|
+
if (low.length === 1 && this.peek() === '-' && this.source[this.at + 1] !== ']') {
|
|
161
|
+
this.at += 1;
|
|
162
|
+
const high = this.classMember();
|
|
163
|
+
if (high.length !== 1) return this.refuse('a range ending in a shorthand');
|
|
164
|
+
const [from, to] = [(low[0] as string).charCodeAt(0), (high[0] as string).charCodeAt(0)];
|
|
165
|
+
// Past printable ASCII the range's own first member stands in, so `[À-ÿ]` still yields one.
|
|
166
|
+
const inside = PRINTABLE.filter((ch) => ch.charCodeAt(0) >= from && ch.charCodeAt(0) <= to);
|
|
167
|
+
for (const ch of inside.length > 0 ? inside : [String.fromCharCode(from)]) members.add(ch);
|
|
168
|
+
} else {
|
|
169
|
+
for (const ch of low) members.add(ch);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.at += 1;
|
|
173
|
+
return negated ? PRINTABLE.filter((ch) => !members.has(ch)) : [...members];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private classMember(): readonly string[] {
|
|
177
|
+
const c = this.peek() as string;
|
|
178
|
+
this.at += 1;
|
|
179
|
+
return c === '\\' ? this.escape(true) : [c];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const draw = (node: Node, random: () => number): string => {
|
|
184
|
+
const pick = <T>(items: readonly T[]): T | undefined =>
|
|
185
|
+
items[Math.floor(random() * items.length)];
|
|
186
|
+
if (node.kind === 'empty') return '';
|
|
187
|
+
if (node.kind === 'chars') return pick(node.chars) ?? '';
|
|
188
|
+
const branch = pick(node.branches) ?? [];
|
|
189
|
+
return branch
|
|
190
|
+
.map((piece) => {
|
|
191
|
+
const count = piece.min + Math.floor(random() * (piece.max - piece.min + 1));
|
|
192
|
+
return Array.from({ length: count }, () => draw(piece.node, random)).join('');
|
|
193
|
+
})
|
|
194
|
+
.join('');
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Tries per sample: a draw that crosses an anchor (`a^b`) or a flag is retried, never returned. */
|
|
198
|
+
const ATTEMPTS = 8;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Up to `count` distinct strings `pattern.test` accepts, the same ones for the same `seed` on any
|
|
202
|
+
* machine. Empty when the pattern is past the subset, or when no draw lands inside it.
|
|
203
|
+
*/
|
|
204
|
+
export function patternSamples(pattern: RegExp, seed: string, count = 4): readonly string[] {
|
|
205
|
+
const tree = new Parser(pattern.source).parse();
|
|
206
|
+
if (tree === undefined) return [];
|
|
207
|
+
const random = seededRandom(fnv1a(`${pattern.source}/${pattern.flags}:${seed}`) || 1);
|
|
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
|
+
}
|
package/src/subject-proof.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { ctxOf, finiteOption, runWithContext, serviceActor } from '@ultimat3/cor
|
|
|
8
8
|
import type { Subject, SubjectPlan } from '@ultimat3/jobs';
|
|
9
9
|
import { memoryStepStore, planSubject, stepRunner, subjectEraseTables } from '@ultimat3/jobs';
|
|
10
10
|
import { seedGraph } from './seed-graph';
|
|
11
|
+
import { deferredMayStayNull } from './seed-graph-pass';
|
|
11
12
|
import type { SeedFactory, SeedGraph, SeedWriter } from './seed-graph-plan';
|
|
12
13
|
import { insertSeedGraph } from './seed-graph-plan';
|
|
13
14
|
import type { SeedEntity } from './seed-graph-shape';
|
|
@@ -111,7 +112,8 @@ export async function subjectProof<I>(
|
|
|
111
112
|
tenants: 2,
|
|
112
113
|
...(options.factories === undefined ? {} : { factories: options.factories }),
|
|
113
114
|
});
|
|
114
|
-
|
|
115
|
+
// The pass's rule: a nullable cycle key the database refuses to set stays null.
|
|
116
|
+
await insertSeedGraph(graph, deferredMayStayNull(options.writer));
|
|
115
117
|
const tenant = graph.tenants[0] ?? null;
|
|
116
118
|
const person = graph.inserts.find(
|
|
117
119
|
(i) => i.entity === subject.entity && (i.tenant === tenant || i.tenant === null),
|