@ultimat3/testing 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 +5 -5
- package/package.json +17 -17
- package/src/define-exercise.ts +19 -7
- package/src/errors.ts +6 -6
- package/src/exercise-actor-user.ts +63 -0
- package/src/exercise-actor.ts +36 -7
- package/src/exercise-crash.ts +7 -3
- package/src/exercise-drift-hint.ts +28 -0
- package/src/exercise-input.ts +3 -0
- package/src/exercise-job-run.ts +68 -0
- package/src/exercise-job-tenant.ts +10 -5
- package/src/exercise-override-input.ts +26 -5
- package/src/exercise-report.ts +38 -2
- package/src/exercise-subjects.ts +6 -4
- package/src/exercise-sweeps.ts +49 -7
- package/src/exercise.ts +50 -34
- package/src/fixture-jobs.ts +17 -5
- package/src/index.ts +4 -0
- package/src/job-crash-points.ts +36 -0
- package/src/job-crash-proof.ts +26 -6
- package/src/job-sim-errors.ts +24 -0
- package/src/job-sim-world.ts +16 -5
- package/src/plan-conditions.ts +106 -0
- package/src/plan-oracle.ts +65 -49
- package/src/plan-sort-key.ts +65 -0
- package/src/plan-sweep-fix.ts +87 -21
- package/src/plan-sweep-report.ts +48 -4
- package/src/seed-graph-values.ts +20 -11
- package/src/seed-graph.ts +30 -1
- package/src/tenant-proof-report.ts +12 -3
- package/src/tenant-proof.ts +32 -4
- package/src/seed-pattern-sample.ts +0 -216
package/src/seed-graph.ts
CHANGED
|
@@ -63,6 +63,33 @@ function ownedByTenant(
|
|
|
63
63
|
return owned;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Every untenanted entity (`shared`, or of no tenant) a tenant-owned table points at through a key
|
|
68
|
+
* unique across the TABLE — a unique set holding the reference and not the tenant column. Each
|
|
69
|
+
* tenant's children then take parent rows no other tenant may reuse, so the parent seeds
|
|
70
|
+
* `perTable` rows per tenant: `perTable` alone ran out before tenant 2 (`X_SEED_GRAPH_UNSATISFIED`).
|
|
71
|
+
*/
|
|
72
|
+
function fannedOut(
|
|
73
|
+
shapes: ReadonlyMap<string, EntityShape>,
|
|
74
|
+
scoped: (name: string) => boolean,
|
|
75
|
+
): ReadonlySet<string> {
|
|
76
|
+
const out = new Set<string>();
|
|
77
|
+
for (const shape of shapes.values()) {
|
|
78
|
+
if (!scoped(shape.name)) continue;
|
|
79
|
+
const tenantColumn = shape.entity.$tenantColumn;
|
|
80
|
+
const tableWide = [...shape.uniques, ...shape.partialUniques].filter(
|
|
81
|
+
(set) => tenantColumn === null || !set.includes(tenantColumn),
|
|
82
|
+
);
|
|
83
|
+
for (const reference of shape.references.values()) {
|
|
84
|
+
if (scoped(reference.targetEntity) || reference.targetEntity === shape.name) continue;
|
|
85
|
+
if (tableWide.some((set) => set.includes(reference.property))) {
|
|
86
|
+
out.add(reference.targetEntity);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
66
93
|
export function seedGraph(
|
|
67
94
|
entities: Readonly<Record<string, SeedEntity>>,
|
|
68
95
|
options: SeedGraphOptions = {},
|
|
@@ -108,6 +135,7 @@ export function seedGraph(
|
|
|
108
135
|
}
|
|
109
136
|
const owned = ownedByTenant(shapes, roots);
|
|
110
137
|
const scoped = (name: string): boolean => owned.has(name);
|
|
138
|
+
const fanned = fannedOut(shapes, scoped);
|
|
111
139
|
for (const factory of Object.values(options.factories ?? {})) factory.reset();
|
|
112
140
|
|
|
113
141
|
const placed = new Map<string, Placed[]>();
|
|
@@ -170,7 +198,8 @@ export function seedGraph(
|
|
|
170
198
|
const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
|
|
171
199
|
let ordinal = 0;
|
|
172
200
|
for (const [bucket, tenant] of buckets.entries()) {
|
|
173
|
-
const size =
|
|
201
|
+
const size =
|
|
202
|
+
rootProperty !== undefined ? 1 : fanned.has(name) ? perTable * tenantCount : perTable;
|
|
174
203
|
for (let index = 0; index < size; index += 1) {
|
|
175
204
|
ordinal += 1;
|
|
176
205
|
const canary = stream().replace(/-/g, '').slice(0, 10);
|
|
@@ -34,10 +34,19 @@ export function tenantFindingLines(records: readonly ExerciseRecord[]): readonly
|
|
|
34
34
|
);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* B sends A's own first input, A's ids bound in only where it takes any: `{ occurrenceMs }` carries
|
|
39
|
+
* none, and B still crosses when a statement it ran names no tenant. Both readings, always.
|
|
40
|
+
*/
|
|
37
41
|
const causeOf = (record: ExerciseRecord, finding: TenantFinding): string =>
|
|
38
42
|
finding.code === 'X_TENANT_LEAK'
|
|
39
|
-
? `${record.kind} ${record.name}, called by tenant B with
|
|
40
|
-
: `${record.kind} ${record.name}, called by tenant B with
|
|
43
|
+
? `${record.kind} ${record.name}, called by tenant B with A's own input ${LEG[finding.leg]}, handed back A's seeded data at ${where(finding)}: a read reached A's row past the tenant guard — by an id the caller chose, or by a statement no tenant predicate scopes`
|
|
44
|
+
: `${record.kind} ${record.name}, called by tenant B with A's own input ${LEG[finding.leg]}, changed A's ${where(finding)}: a write reached A's row past the tenant guard — by an id the caller chose, or by a statement no tenant predicate scopes`;
|
|
45
|
+
|
|
46
|
+
const fixOf = (name: string, finding: TenantFinding): string =>
|
|
47
|
+
finding.code === 'X_TENANT_LEAK'
|
|
48
|
+
? `x exercise ${name} --json — then read the row behind ${where(finding)} through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement`
|
|
49
|
+
: `x exercise ${name} --json — then write ${where(finding)} through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement`;
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* One coded finding per tenant finding. The fix reruns the one primitive: what it reads or writes
|
|
@@ -54,7 +63,7 @@ export function tenantProofFindings(
|
|
|
54
63
|
(finding): TenantProofFinding => ({
|
|
55
64
|
code: finding.code,
|
|
56
65
|
cause: causeOf(record, finding),
|
|
57
|
-
fix:
|
|
66
|
+
fix: fixOf(name, finding),
|
|
58
67
|
at: `${record.kind} ${record.name}`,
|
|
59
68
|
}),
|
|
60
69
|
);
|
package/src/tenant-proof.ts
CHANGED
|
@@ -79,6 +79,12 @@ export interface TenantAttack {
|
|
|
79
79
|
* half is skipped. An action or a query answers B directly, so a declared scope excuses nothing.
|
|
80
80
|
*/
|
|
81
81
|
readonly sweep: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* A job declaring `tenant: 'none'` (`bindJobTenant`'s `none`): no org reaches its run. Handed
|
|
84
|
+
* nothing of A's, B's run is A's run — what it writes to A's rows it writes for every enqueuer,
|
|
85
|
+
* so the write half's premise, B choosing A's row, cannot hold: skipped (`tenantlessReason`).
|
|
86
|
+
*/
|
|
87
|
+
readonly tenantless: boolean;
|
|
82
88
|
attack(leg: TenantLeg): Promise<LegAnswer>;
|
|
83
89
|
/** A's positive control: today's exercise of the primitive, and what A's last answer was. */
|
|
84
90
|
control(): Promise<{ readonly record: ExerciseRecord; readonly output: unknown }>;
|
|
@@ -129,11 +135,25 @@ export function sweepReason(
|
|
|
129
135
|
return `crossTenant(${JSON.stringify(reason)}) declares this job reads across tenants, and B's input named nothing of A's: read half skipped, write half kept`;
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The reason the write half is skipped, or `undefined`: a `tenant: 'none'` job whose input carries
|
|
140
|
+
* none of A's marks — no org and no id of B's choosing reaches the run. Handed one of A's ids, B
|
|
141
|
+
* chose A's row and the write half stands. An action or a query runs as B's actor, so B's org
|
|
142
|
+
* reaches it: a write to A's row with no id handed is an unscoped statement, crossed as ever.
|
|
143
|
+
*/
|
|
144
|
+
export function tenantlessReason(
|
|
145
|
+
plan: Pick<TenantAttack, 'tenantless' | 'taint'>,
|
|
146
|
+
hunted: readonly string[],
|
|
147
|
+
): string | undefined {
|
|
148
|
+
if (!plan.tenantless || hunted.length !== plan.taint.marks.length) return undefined;
|
|
149
|
+
return "tenant: 'none' runs this job under no org, and B's input named nothing of A's: B's run is A's run, so what it writes it writes for every enqueuer — write half skipped, read half kept";
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
const verdictOf = (
|
|
133
153
|
plan: TenantAttack,
|
|
134
154
|
control: { readonly record: ExerciseRecord; readonly output: unknown },
|
|
135
155
|
findings: readonly TenantFinding[],
|
|
136
|
-
|
|
156
|
+
skipped: readonly string[],
|
|
137
157
|
): TenantProof => {
|
|
138
158
|
const { record } = control;
|
|
139
159
|
if (findings.length > 0) return { verdict: 'crossed', findings };
|
|
@@ -144,7 +164,7 @@ const verdictOf = (
|
|
|
144
164
|
};
|
|
145
165
|
}
|
|
146
166
|
if (plan.public !== undefined) return { verdict: 'public', reason: plan.public };
|
|
147
|
-
if (
|
|
167
|
+
if (skipped.length > 0) return { verdict: 'public', reason: skipped.join('; ') };
|
|
148
168
|
// Every mark of A's, handed ones included: A was handed its own ids, and reading them is the point.
|
|
149
169
|
if (plan.read && findTaint(control.output, 'output', plan.taint.marks).length === 0) {
|
|
150
170
|
return {
|
|
@@ -159,12 +179,19 @@ const verdictOf = (
|
|
|
159
179
|
export async function proveTenant(plan: TenantAttack): Promise<ExerciseRecord> {
|
|
160
180
|
const marks = huntedMarks(plan.taint, plan.input);
|
|
161
181
|
const findings: TenantFinding[] = [];
|
|
182
|
+
const unwritten = tenantlessReason(plan, marks);
|
|
162
183
|
let swept: string | undefined;
|
|
163
|
-
|
|
184
|
+
/** B's call, between two snapshots unless the write half is skipped: then none is read. */
|
|
185
|
+
const watched = async (name: TenantLeg): Promise<LegAnswer> => {
|
|
186
|
+
if (unwritten !== undefined) return plan.attack(name);
|
|
164
187
|
const before = await plan.snapshot(plan.taint.rows);
|
|
165
188
|
const answer = await plan.attack(name);
|
|
166
189
|
const after = await plan.snapshot(plan.taint.rows);
|
|
167
190
|
findings.push(...crossedWrites(name, plan.taint, before, after));
|
|
191
|
+
return answer;
|
|
192
|
+
};
|
|
193
|
+
const leg = async (name: TenantLeg): Promise<void> => {
|
|
194
|
+
const answer = await watched(name);
|
|
168
195
|
if (plan.public !== undefined) return;
|
|
169
196
|
const sweep = sweepReason(plan, answer, marks);
|
|
170
197
|
if (sweep === undefined) findings.push(...leaksIn(name, answer, marks));
|
|
@@ -173,5 +200,6 @@ export async function proveTenant(plan: TenantAttack): Promise<ExerciseRecord> {
|
|
|
173
200
|
await leg('before');
|
|
174
201
|
const control = await plan.control();
|
|
175
202
|
await leg('after');
|
|
176
|
-
|
|
203
|
+
const skipped = [swept, unwritten].filter((reason) => reason !== undefined);
|
|
204
|
+
return { ...control.record, tenant: verdictOf(plan, control, findings, skipped) };
|
|
177
205
|
}
|
|
@@ -1,216 +0,0 @@
|
|
|
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
|
-
}
|