pabst-checker 0.12.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/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/build-spec.d.ts +2 -0
- package/dist/build-spec.js +46 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +119 -0
- package/dist/codegen.d.ts +6 -0
- package/dist/codegen.js +30 -0
- package/dist/desugar.d.ts +5 -0
- package/dist/desugar.js +136 -0
- package/dist/discover.d.ts +16 -0
- package/dist/discover.js +113 -0
- package/dist/domains.d.ts +47 -0
- package/dist/domains.js +130 -0
- package/dist/emit.d.ts +2 -0
- package/dist/emit.js +74 -0
- package/dist/envelope.d.ts +29 -0
- package/dist/envelope.js +28 -0
- package/dist/equations.d.ts +8 -0
- package/dist/equations.js +256 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +9 -0
- package/dist/extract.d.ts +21 -0
- package/dist/extract.js +177 -0
- package/dist/formula-ast.d.ts +26 -0
- package/dist/formula-ast.js +18 -0
- package/dist/formula-lexer.d.ts +31 -0
- package/dist/formula-lexer.js +191 -0
- package/dist/formula-parser.d.ts +2 -0
- package/dist/formula-parser.js +122 -0
- package/dist/free-idents.d.ts +6 -0
- package/dist/free-idents.js +60 -0
- package/dist/ir.d.ts +45 -0
- package/dist/ir.js +1 -0
- package/dist/issue.d.ts +26 -0
- package/dist/issue.js +18 -0
- package/dist/lower.d.ts +12 -0
- package/dist/lower.js +37 -0
- package/dist/parse-formula.d.ts +12 -0
- package/dist/parse-formula.js +78 -0
- package/dist/prefix-parser.d.ts +7 -0
- package/dist/prefix-parser.js +188 -0
- package/dist/qualified-name.d.ts +5 -0
- package/dist/qualified-name.js +11 -0
- package/dist/range.d.ts +5 -0
- package/dist/range.js +152 -0
- package/dist/regex-guard.d.ts +28 -0
- package/dist/regex-guard.js +100 -0
- package/dist/run.d.ts +26 -0
- package/dist/run.js +50 -0
- package/dist/runtime.d.ts +25 -0
- package/dist/runtime.js +62 -0
- package/dist/seed.d.ts +7 -0
- package/dist/seed.js +20 -0
- package/package.json +67 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { isDomain } from "./domains.js";
|
|
3
|
+
import { PabstError } from "./errors.js";
|
|
4
|
+
import { parseRange } from "./range.js";
|
|
5
|
+
import { parseRegexGuard, scanRegexLiteral, TRUNCATION_HINT, } from "./regex-guard.js";
|
|
6
|
+
import { scanTokens, sliceText } from "./formula-lexer.js";
|
|
7
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
8
|
+
/** prefix ::= FORALL binder-group+ (docs/grammar.ebnf) */
|
|
9
|
+
export function parsePrefix(formula) {
|
|
10
|
+
const { toks, unterminatedSlash } = prefixTokens(formula);
|
|
11
|
+
const head = toks[0];
|
|
12
|
+
if (head && (head.text === "∃" || head.text === "exists")) {
|
|
13
|
+
throw new PabstError("existential quantifiers (∃ / exists) are not supported: property-based " +
|
|
14
|
+
"testing samples inputs, so it can refute ∀ with a counterexample but cannot " +
|
|
15
|
+
"soundly confirm ∃ (a bounded/exhaustive mode would be needed)");
|
|
16
|
+
}
|
|
17
|
+
if (!head || (head.text !== "forall" && head.text !== "∀")) {
|
|
18
|
+
throw new PabstError(`expected 'forall' (or ∀) at start of property: ${formula.trim().slice(0, 60)}`);
|
|
19
|
+
}
|
|
20
|
+
let i = 1;
|
|
21
|
+
const binders = [];
|
|
22
|
+
while (i < toks.length && toks[i].text === "(") {
|
|
23
|
+
const close = groupExtent(toks, i, formula, unterminatedSlash);
|
|
24
|
+
binders.push(...parseBinderGroup(toks, i, close, formula));
|
|
25
|
+
i = close + 1;
|
|
26
|
+
}
|
|
27
|
+
if (binders.length === 0) {
|
|
28
|
+
throw new PabstError(`expected at least one binder group '(x: domain)' after forall`);
|
|
29
|
+
}
|
|
30
|
+
const comma = toks[i];
|
|
31
|
+
if (!comma || comma.text !== ",") {
|
|
32
|
+
const at = comma?.start ?? formula.length;
|
|
33
|
+
throw new PabstError(`expected ',' separating binders from body, got: ${formula.slice(at, at + 60)}`);
|
|
34
|
+
}
|
|
35
|
+
const body = formula.slice(comma.end).trim();
|
|
36
|
+
if (body.length === 0)
|
|
37
|
+
throw new PabstError(`property body is empty`);
|
|
38
|
+
return { binders, body };
|
|
39
|
+
}
|
|
40
|
+
function isMembership(t) {
|
|
41
|
+
return t.text === "∈" || t.text === "in";
|
|
42
|
+
}
|
|
43
|
+
/** Tokenize the annotation for prefix parsing. A regex literal that never
|
|
44
|
+
* closes would swallow the rest of the group as one token, so its leading
|
|
45
|
+
* '/' is emitted alone, scanning restarts after it, and the slash's offset
|
|
46
|
+
* feeds the truncation hint. */
|
|
47
|
+
function prefixTokens(formula) {
|
|
48
|
+
const toks = [];
|
|
49
|
+
const unterminatedSlash = new Set();
|
|
50
|
+
let from = 0;
|
|
51
|
+
scan: while (true) {
|
|
52
|
+
for (const t of scanTokens(formula, from)) {
|
|
53
|
+
if (t.kind === ts.SyntaxKind.RegularExpressionLiteral &&
|
|
54
|
+
scanRegexLiteral(t.text, 0).close === -1) {
|
|
55
|
+
toks.push({
|
|
56
|
+
kind: ts.SyntaxKind.SlashToken,
|
|
57
|
+
text: "/",
|
|
58
|
+
start: t.start,
|
|
59
|
+
end: t.start + 1,
|
|
60
|
+
});
|
|
61
|
+
unterminatedSlash.add(t.start);
|
|
62
|
+
from = t.start + 1;
|
|
63
|
+
continue scan;
|
|
64
|
+
}
|
|
65
|
+
toks.push(t);
|
|
66
|
+
}
|
|
67
|
+
return { toks, unterminatedSlash };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Index of the ')' closing the group opened at toks[open]. Guard tokens
|
|
71
|
+
* never take part in paren counting: an interval's delimiters may be
|
|
72
|
+
* deliberately mismatched — (0, 1] is a legal half-open interval — so a
|
|
73
|
+
* membership token followed by a plausible interval is skipped atomically,
|
|
74
|
+
* and a terminated regex literal is already a single token. */
|
|
75
|
+
function groupExtent(toks, open, formula, unterminatedSlash) {
|
|
76
|
+
let depth = 0;
|
|
77
|
+
let j = open;
|
|
78
|
+
while (j < toks.length) {
|
|
79
|
+
if (isMembership(toks[j])) {
|
|
80
|
+
const e = intervalExtent(toks, j + 1);
|
|
81
|
+
if (e !== -1) {
|
|
82
|
+
j = e + 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const t = toks[j];
|
|
87
|
+
if (t.text === "(")
|
|
88
|
+
depth++;
|
|
89
|
+
else if (t.text === ")") {
|
|
90
|
+
depth--;
|
|
91
|
+
if (depth === 0)
|
|
92
|
+
return j;
|
|
93
|
+
}
|
|
94
|
+
j++;
|
|
95
|
+
}
|
|
96
|
+
const sawUnterminated = [...unterminatedSlash].some((at) => at >= toks[open].start);
|
|
97
|
+
const hint = sawUnterminated
|
|
98
|
+
? ` (if this is a regex guard: ${TRUNCATION_HINT})`
|
|
99
|
+
: "";
|
|
100
|
+
throw new PabstError(`unbalanced parentheses in binder group: ${formula.slice(toks[open].start)}${hint}`);
|
|
101
|
+
}
|
|
102
|
+
/** Index of the token closing a plausible two-endpoint interval starting at
|
|
103
|
+
* toks[at], or -1 when none starts there — the group scan then proceeds as
|
|
104
|
+
* usual and the guard text reaches parseRange for the precise complaint. */
|
|
105
|
+
function intervalExtent(toks, at) {
|
|
106
|
+
const open = toks[at];
|
|
107
|
+
if (!open || (open.text !== "[" && open.text !== "("))
|
|
108
|
+
return -1;
|
|
109
|
+
let commas = 0;
|
|
110
|
+
for (let j = at + 1; j < toks.length; j++) {
|
|
111
|
+
const text = toks[j].text;
|
|
112
|
+
if (text === "]" || text === ")")
|
|
113
|
+
return commas === 1 ? j : -1;
|
|
114
|
+
if (text === ":")
|
|
115
|
+
return -1;
|
|
116
|
+
if (text === ",")
|
|
117
|
+
commas++;
|
|
118
|
+
}
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
/** binder-group ::= "(" var-name+ ":" domain constraint? ")" */
|
|
122
|
+
function parseBinderGroup(toks, open, close, formula) {
|
|
123
|
+
const interior = toks.slice(open + 1, close);
|
|
124
|
+
const groupText = formula.slice(toks[open].end, toks[close].start).trim();
|
|
125
|
+
const colon = interior.findIndex((t) => t.text === ":");
|
|
126
|
+
if (colon === -1) {
|
|
127
|
+
throw new PabstError(`binder group missing ':' — expected '(x: domain)', got: (${groupText})`);
|
|
128
|
+
}
|
|
129
|
+
const domainToks = interior.slice(colon + 1);
|
|
130
|
+
const member = domainToks.findIndex(isMembership);
|
|
131
|
+
const nameEnd = member === -1 ? domainToks.length : member;
|
|
132
|
+
const domainName = sliceText(formula, domainToks.slice(0, nameEnd)).trim();
|
|
133
|
+
if (!isDomain(domainName)) {
|
|
134
|
+
throw new PabstError(`unknown generation domain '${domainName}' — valid domains: int, nat, number, boolean, string, bigint`);
|
|
135
|
+
}
|
|
136
|
+
let range;
|
|
137
|
+
let pattern;
|
|
138
|
+
if (member !== -1) {
|
|
139
|
+
const guardText = formula
|
|
140
|
+
.slice(domainToks[member].end, toks[close].start)
|
|
141
|
+
.trim();
|
|
142
|
+
// A leading '/' is a regex guard when the domain is string or when
|
|
143
|
+
// the literal closes (a deliberate regex on a numeric domain deserves
|
|
144
|
+
// the regex-guard domain complaint). An unterminated '/' on a
|
|
145
|
+
// non-string domain is more likely a mistyped '(' — fall through to
|
|
146
|
+
// parseRange for the precise interval complaint.
|
|
147
|
+
const regexGuard = guardText.startsWith("/") &&
|
|
148
|
+
(domainName === "string" || scanRegexLiteral(guardText, 0).close !== -1);
|
|
149
|
+
if (regexGuard)
|
|
150
|
+
pattern = parseRegexGuard(guardText, domainName);
|
|
151
|
+
else
|
|
152
|
+
range = parseRange(guardText, domainName);
|
|
153
|
+
}
|
|
154
|
+
const names = adjacentRuns(interior.slice(0, colon), formula);
|
|
155
|
+
if (names.length === 0) {
|
|
156
|
+
throw new PabstError(`binder group has no variable names: (${groupText})`);
|
|
157
|
+
}
|
|
158
|
+
for (const n of names) {
|
|
159
|
+
if (!NAME.test(n)) {
|
|
160
|
+
throw new PabstError(`invalid binder variable name '${n}'`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return names.map((varName) => {
|
|
164
|
+
const binder = { varName, domain: domainName };
|
|
165
|
+
if (range)
|
|
166
|
+
binder.range = range;
|
|
167
|
+
if (pattern)
|
|
168
|
+
binder.pattern = pattern;
|
|
169
|
+
return binder;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** Group adjacent tokens (no gap between them) into runs; each run's source
|
|
173
|
+
* text is one candidate name — the token-level analogue of splitting the
|
|
174
|
+
* variable segment on whitespace, so 'x-y' stays one (invalid) name. */
|
|
175
|
+
function adjacentRuns(toks, formula) {
|
|
176
|
+
const runs = [];
|
|
177
|
+
for (let i = 0; i < toks.length;) {
|
|
178
|
+
const start = toks[i].start;
|
|
179
|
+
let end = toks[i].end;
|
|
180
|
+
i++;
|
|
181
|
+
while (i < toks.length && toks[i].start === end) {
|
|
182
|
+
end = toks[i].end;
|
|
183
|
+
i++;
|
|
184
|
+
}
|
|
185
|
+
runs.push(formula.slice(start, end));
|
|
186
|
+
}
|
|
187
|
+
return runs;
|
|
188
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a property's identity: the bare function name, or `Class#method`
|
|
3
|
+
* (instance) / `Class.method` (static) when it lives on a class.
|
|
4
|
+
*/
|
|
5
|
+
export function qualifiedName(functionName, className, isStatic) {
|
|
6
|
+
if (className === undefined)
|
|
7
|
+
return functionName;
|
|
8
|
+
return isStatic
|
|
9
|
+
? `${className}.${functionName}`
|
|
10
|
+
: `${className}#${functionName}`;
|
|
11
|
+
}
|
package/dist/range.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Domain, Range } from "./ir.js";
|
|
2
|
+
export declare function isNumericDomain(d: Domain): boolean;
|
|
3
|
+
/** Parse and validate an interval constraint like "[1, 30]" or "(0, 1]".
|
|
4
|
+
* A round bracket excludes its endpoint. */
|
|
5
|
+
export declare function parseRange(text: string, domain: Domain): Range;
|
package/dist/range.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fc from "fast-check";
|
|
2
|
+
import { bigintBounds, intBounds, numberConstraints } from "./domains.js";
|
|
3
|
+
import { PabstError } from "./errors.js";
|
|
4
|
+
const INT_LITERAL = /^[+-]?\d+$/;
|
|
5
|
+
const BIGINT_LITERAL = /^[+-]?\d+n?$/;
|
|
6
|
+
const NUMBER_LITERAL = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
|
|
7
|
+
const INFINITE_LITERAL = /^([+-]?)(?:∞|Infinity)$/;
|
|
8
|
+
export function isNumericDomain(d) {
|
|
9
|
+
return d === "int" || d === "nat" || d === "number" || d === "bigint";
|
|
10
|
+
}
|
|
11
|
+
/** Interval delimiters may be deliberately mismatched — (0, 1] is legal —
|
|
12
|
+
* so the FIRST of ']' or ')' always closes an interval. */
|
|
13
|
+
const CLOSE_DELIM = /[\])]/;
|
|
14
|
+
/** Parse and validate an interval constraint like "[1, 30]" or "(0, 1]".
|
|
15
|
+
* A round bracket excludes its endpoint. */
|
|
16
|
+
export function parseRange(text, domain) {
|
|
17
|
+
if (!isNumericDomain(domain)) {
|
|
18
|
+
throw new PabstError(`domain '${domain}' does not support ∈ interval constraints — ` +
|
|
19
|
+
`only int, nat, number, and bigint do`);
|
|
20
|
+
}
|
|
21
|
+
const minOpen = text.startsWith("(");
|
|
22
|
+
if (!minOpen && !text.startsWith("[")) {
|
|
23
|
+
throw new PabstError(`expected interval '[lo, hi]' or '(lo, hi]' after ∈, got: ${text || "(nothing)"}`);
|
|
24
|
+
}
|
|
25
|
+
const close = text.search(CLOSE_DELIM);
|
|
26
|
+
if (close === -1) {
|
|
27
|
+
throw new PabstError(`interval is missing its closing ']' or ')' (in: ${text})`);
|
|
28
|
+
}
|
|
29
|
+
if (close !== text.length - 1) {
|
|
30
|
+
throw new PabstError(`unexpected text after interval: '${text.slice(close + 1).trim()}' (in: ${text})`);
|
|
31
|
+
}
|
|
32
|
+
const maxOpen = text[close] === ")";
|
|
33
|
+
const parts = text.slice(1, close).split(",");
|
|
34
|
+
if (parts.length !== 2) {
|
|
35
|
+
throw new PabstError(`expected exactly two endpoints '[lo, hi]', got: ${text}`);
|
|
36
|
+
}
|
|
37
|
+
const min = parseBound(parts[0].trim(), "lower", minOpen, domain);
|
|
38
|
+
const max = parseBound(parts[1].trim(), "upper", maxOpen, domain);
|
|
39
|
+
const range = {};
|
|
40
|
+
if (min !== undefined)
|
|
41
|
+
range.min = min;
|
|
42
|
+
if (max !== undefined)
|
|
43
|
+
range.max = max;
|
|
44
|
+
if (minOpen)
|
|
45
|
+
range.minOpen = true;
|
|
46
|
+
if (maxOpen)
|
|
47
|
+
range.maxOpen = true;
|
|
48
|
+
if (domain === "number") {
|
|
49
|
+
validateNumberInterval(range, text);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
validateIntegerInterval(domain, range, text);
|
|
53
|
+
}
|
|
54
|
+
return range;
|
|
55
|
+
}
|
|
56
|
+
/** Returns the normalized endpoint literal, or undefined for an unbounded
|
|
57
|
+
* (±∞) endpoint. */
|
|
58
|
+
function parseBound(lit, side, open, domain) {
|
|
59
|
+
const inf = INFINITE_LITERAL.exec(lit);
|
|
60
|
+
if (!inf)
|
|
61
|
+
return parseEndpoint(lit, domain);
|
|
62
|
+
if (side === "lower" && inf[1] !== "-") {
|
|
63
|
+
throw new PabstError(`an interval's lower endpoint cannot be +∞ (in endpoint '${lit}')`);
|
|
64
|
+
}
|
|
65
|
+
if (side === "upper" && inf[1] === "-") {
|
|
66
|
+
throw new PabstError(`an interval's upper endpoint cannot be -∞ (in endpoint '${lit}')`);
|
|
67
|
+
}
|
|
68
|
+
if (domain !== "number" && !open) {
|
|
69
|
+
throw new PabstError(`${domain} has no infinite values, so an ∞ endpoint must be open — ` +
|
|
70
|
+
`use ${side === "lower" ? `'(${lit},'` : `'${lit})'`} (a closed ∞ ` +
|
|
71
|
+
`bound is only meaningful for number, where Infinity is a value)`);
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
/** Validity is judged on the exact bounds lowering will emit (intBounds /
|
|
76
|
+
* bigintBounds fold open endpoints into ±1, floor nat at 0, and intersect
|
|
77
|
+
* int/nat with the safe integer range), so validation and generation can
|
|
78
|
+
* never disagree. A nat interval reaching below 0 clamps — `(-2, 5]` and
|
|
79
|
+
* `(-∞, 5]` denote the same naturals — and an int/nat endpoint beyond the
|
|
80
|
+
* safe range clamps with a warning; either is an error only when nothing
|
|
81
|
+
* satisfiable remains. */
|
|
82
|
+
function validateIntegerInterval(domain, range, text) {
|
|
83
|
+
if (domain === "bigint") {
|
|
84
|
+
const { lo, hi } = bigintBounds(range);
|
|
85
|
+
if (lo !== undefined && hi !== undefined && lo > hi) {
|
|
86
|
+
throw new PabstError(`empty interval: no bigint satisfies ${text}`);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const { lo, hi, clamped } = intBounds(domain, range);
|
|
91
|
+
if (lo > hi) {
|
|
92
|
+
throw new PabstError(`empty interval: no ${domain}${clamped ? " within the safe integer range (±9007199254740991)" : ""} satisfies ${text}`);
|
|
93
|
+
}
|
|
94
|
+
if (clamped) {
|
|
95
|
+
console.error(`warning: interval ${text} extends beyond the safe integer range; ` +
|
|
96
|
+
`${domain} generation is clamped to [${lo}, ${hi}]`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** A number interval denotes a set of fc-generatable doubles, so emptiness
|
|
100
|
+
* is fast-check's call, not ours: constructing the exact fc.double
|
|
101
|
+
* constraints lowering will emit lets fc apply its own ordering, in which
|
|
102
|
+
* every double is distinct — -0 sits below 0 (so [0, -0] is empty but
|
|
103
|
+
* (-0, 0] is the singleton {0}) and an excluded bound removes exactly one
|
|
104
|
+
* double, by adjacency rather than numeric equality (so [-1, 0) can
|
|
105
|
+
* generate -0, and (0, 5e-324) is empty). */
|
|
106
|
+
function validateNumberInterval(range, text) {
|
|
107
|
+
const c = numberConstraints(range);
|
|
108
|
+
const opts = {
|
|
109
|
+
noNaN: true,
|
|
110
|
+
minExcluded: c.minExcluded,
|
|
111
|
+
maxExcluded: c.maxExcluded,
|
|
112
|
+
};
|
|
113
|
+
if (c.min)
|
|
114
|
+
opts.min = c.min.val;
|
|
115
|
+
if (c.max)
|
|
116
|
+
opts.max = c.max.val;
|
|
117
|
+
try {
|
|
118
|
+
fc.double(opts);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new PabstError(`empty interval: no number satisfies ${text} (fast-check treats every ` +
|
|
122
|
+
`double as distinct — it orders -0 below 0, and an excluded bound ` +
|
|
123
|
+
`removes exactly one double)`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function parseEndpoint(lit, domain) {
|
|
127
|
+
switch (domain) {
|
|
128
|
+
case "int":
|
|
129
|
+
case "nat": {
|
|
130
|
+
if (!INT_LITERAL.test(lit))
|
|
131
|
+
throw new PabstError(`interval endpoint '${lit}' is not an integer literal (domain ${domain})`);
|
|
132
|
+
return normalizeLiteral(lit);
|
|
133
|
+
}
|
|
134
|
+
case "bigint": {
|
|
135
|
+
if (!BIGINT_LITERAL.test(lit))
|
|
136
|
+
throw new PabstError(`interval endpoint '${lit}' is not an integer literal (domain bigint)`);
|
|
137
|
+
return normalizeLiteral(lit.endsWith("n") ? lit.slice(0, -1) : lit);
|
|
138
|
+
}
|
|
139
|
+
default: {
|
|
140
|
+
if (!NUMBER_LITERAL.test(lit) || !Number.isFinite(Number(lit)))
|
|
141
|
+
throw new PabstError(`interval endpoint '${lit}' is not a finite number literal`);
|
|
142
|
+
return normalizeLiteral(lit);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Strip a leading '+' and redundant leading zeros: endpoints are emitted
|
|
147
|
+
* verbatim into an ES module, where '010' is a SyntaxError. The lookahead
|
|
148
|
+
* keeps a lone (possibly signed) zero intact, so '-0' survives. */
|
|
149
|
+
function normalizeLiteral(lit) {
|
|
150
|
+
const unsigned = lit.startsWith("+") ? lit.slice(1) : lit;
|
|
151
|
+
return unsigned.replace(/^(-?)0+(?=\d)/, "$1");
|
|
152
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { PabstError } from "./errors.js";
|
|
2
|
+
import type { Domain, StringPattern } from "./ir.js";
|
|
3
|
+
export declare const TRUNCATION_HINT: string;
|
|
4
|
+
export interface RegexScan {
|
|
5
|
+
/** Index just past the literal (closing '/' plus flags), or text.length
|
|
6
|
+
* if the literal never closes. */
|
|
7
|
+
end: number;
|
|
8
|
+
/** Index of the closing '/', or -1 if the literal never closes. */
|
|
9
|
+
close: number;
|
|
10
|
+
}
|
|
11
|
+
/** Scan a JS regex literal starting at text[start] === '/'. Tracks
|
|
12
|
+
* backslash escapes and [...] character classes, so '(', ')' and '/'
|
|
13
|
+
* inside them do not close the literal. A line terminator — even after a
|
|
14
|
+
* backslash, which cannot escape one — ends the scan unterminated, as in
|
|
15
|
+
* JS source; admitting one would re-emit it inside a regex literal in the
|
|
16
|
+
* generated spec, where it is a SyntaxError. */
|
|
17
|
+
export declare function scanRegexLiteral(text: string, start: number): RegexScan;
|
|
18
|
+
/** Full-string semantics: '∈ /re/' denotes the language of re, so lowering
|
|
19
|
+
* wraps the pattern before handing it to fc.stringMatching. */
|
|
20
|
+
export declare function anchoredSource(source: string): string;
|
|
21
|
+
/** The complaint a regex guard on a non-string domain raises; shared with
|
|
22
|
+
* arbitraryFor's backstop so the two throw sites cannot drift apart. */
|
|
23
|
+
export declare function regexGuardDomainError(domain: Domain): PabstError;
|
|
24
|
+
/** Parse and validate a regex guard like "/^[a-z]+$/u". Source and flags
|
|
25
|
+
* are kept verbatim; anchoring happens at lowering. Validation probes
|
|
26
|
+
* fast-check itself — a peer dependency, so it resolves to the same copy
|
|
27
|
+
* the generated spec runs against and the accepted subset cannot drift. */
|
|
28
|
+
export declare function parseRegexGuard(text: string, domain: Domain): StringPattern;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { stringMatching } from "fast-check";
|
|
2
|
+
import { PabstError } from "./errors.js";
|
|
3
|
+
// A line comment on purpose: spelling star-slash inside a block comment
|
|
4
|
+
// would end it — which is exactly the hazard this hint is about. An
|
|
5
|
+
// unterminated literal is the telltale symptom of a pattern whose "*/"
|
|
6
|
+
// ended the enclosing JSDoc comment early.
|
|
7
|
+
export const TRUNCATION_HINT = "a '*/' inside a pattern ends the enclosing JSDoc comment early — " +
|
|
8
|
+
"write {0,} instead of a trailing *, or wrap it in (?:...)";
|
|
9
|
+
const FLAG_ERRORS = {
|
|
10
|
+
m: "regex flag 'm' would let the pattern match a single line inside a " +
|
|
11
|
+
"longer string, but ∈ guards match the whole string",
|
|
12
|
+
i: "regex flag 'i' is not supported by fast-check's string generator",
|
|
13
|
+
v: "regex flag 'v' is not supported by fast-check's string generator",
|
|
14
|
+
g: "regex flag 'g' has no effect on generation",
|
|
15
|
+
y: "regex flag 'y' has no effect on generation",
|
|
16
|
+
d: "regex flag 'd' has no effect on generation",
|
|
17
|
+
};
|
|
18
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
19
|
+
/** Scan a JS regex literal starting at text[start] === '/'. Tracks
|
|
20
|
+
* backslash escapes and [...] character classes, so '(', ')' and '/'
|
|
21
|
+
* inside them do not close the literal. A line terminator — even after a
|
|
22
|
+
* backslash, which cannot escape one — ends the scan unterminated, as in
|
|
23
|
+
* JS source; admitting one would re-emit it inside a regex literal in the
|
|
24
|
+
* generated spec, where it is a SyntaxError. */
|
|
25
|
+
export function scanRegexLiteral(text, start) {
|
|
26
|
+
let inClass = false;
|
|
27
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
28
|
+
const c = text[i];
|
|
29
|
+
if (LINE_TERMINATOR.test(c))
|
|
30
|
+
break;
|
|
31
|
+
if (c === "\\") {
|
|
32
|
+
const next = text[i + 1];
|
|
33
|
+
if (next === undefined || LINE_TERMINATOR.test(next))
|
|
34
|
+
break;
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
else if (c === "[")
|
|
38
|
+
inClass = true;
|
|
39
|
+
else if (c === "]")
|
|
40
|
+
inClass = false;
|
|
41
|
+
else if (c === "/" && !inClass) {
|
|
42
|
+
const close = i;
|
|
43
|
+
i++;
|
|
44
|
+
while (i < text.length && /[a-zA-Z]/.test(text[i]))
|
|
45
|
+
i++;
|
|
46
|
+
return { end: i, close };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { end: text.length, close: -1 };
|
|
50
|
+
}
|
|
51
|
+
/** Full-string semantics: '∈ /re/' denotes the language of re, so lowering
|
|
52
|
+
* wraps the pattern before handing it to fc.stringMatching. */
|
|
53
|
+
export function anchoredSource(source) {
|
|
54
|
+
return `^(?:${source})$`;
|
|
55
|
+
}
|
|
56
|
+
/** The complaint a regex guard on a non-string domain raises; shared with
|
|
57
|
+
* arbitraryFor's backstop so the two throw sites cannot drift apart. */
|
|
58
|
+
export function regexGuardDomainError(domain) {
|
|
59
|
+
return new PabstError(`domain '${domain}' does not support ∈ regex guards — only string does`);
|
|
60
|
+
}
|
|
61
|
+
/** Parse and validate a regex guard like "/^[a-z]+$/u". Source and flags
|
|
62
|
+
* are kept verbatim; anchoring happens at lowering. Validation probes
|
|
63
|
+
* fast-check itself — a peer dependency, so it resolves to the same copy
|
|
64
|
+
* the generated spec runs against and the accepted subset cannot drift. */
|
|
65
|
+
export function parseRegexGuard(text, domain) {
|
|
66
|
+
if (domain !== "string") {
|
|
67
|
+
throw regexGuardDomainError(domain);
|
|
68
|
+
}
|
|
69
|
+
const { end, close } = scanRegexLiteral(text, 0);
|
|
70
|
+
if (close === -1) {
|
|
71
|
+
throw new PabstError(`unterminated regular expression (in: ${text}) — ${TRUNCATION_HINT}`);
|
|
72
|
+
}
|
|
73
|
+
if (end !== text.length) {
|
|
74
|
+
throw new PabstError(`unexpected text after regular expression: '${text.slice(end).trim()}' (in: ${text})`);
|
|
75
|
+
}
|
|
76
|
+
const source = text.slice(1, close);
|
|
77
|
+
const flags = text.slice(close + 1, end);
|
|
78
|
+
if (source.length === 0) {
|
|
79
|
+
throw new PabstError("empty regular expression after ∈ — to generate only the empty string, use ∈ /^$/");
|
|
80
|
+
}
|
|
81
|
+
for (const f of flags) {
|
|
82
|
+
if (f === "s" || f === "u")
|
|
83
|
+
continue;
|
|
84
|
+
const why = FLAG_ERRORS[f] ?? `regex flag '${f}' is not supported`;
|
|
85
|
+
throw new PabstError(`${why} (allowed flags: s, u; in: ${text})`);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
new RegExp(source, flags);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
throw new PabstError(`invalid regular expression (in: ${text}): ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
stringMatching(new RegExp(anchoredSource(source), flags));
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
throw new PabstError(`regular expression not supported by fast-check (in: ${text}): ${e.message}`);
|
|
98
|
+
}
|
|
99
|
+
return { source, flags };
|
|
100
|
+
}
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type RunMeta } from "./envelope.js";
|
|
2
|
+
import type { Envelope } from "./issue.js";
|
|
3
|
+
/** Where the spawned vitest writes its JSON results, relative to cwd. */
|
|
4
|
+
export declare const RESULTS_FILE = ".pabst/.last-run.json";
|
|
5
|
+
export type RunResult = {
|
|
6
|
+
kind: "completed";
|
|
7
|
+
envelope: Envelope;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "no-results";
|
|
10
|
+
status: number;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "broken-run";
|
|
15
|
+
status: number;
|
|
16
|
+
messages: string[];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Run vitest over `target` (the generated-test root, or a single generated
|
|
20
|
+
* file) and assemble the run envelope from its JSON results. When vitest
|
|
21
|
+
* produces no parseable results file (e.g. it died on startup before its
|
|
22
|
+
* reporter ran), the run yielded nothing trustworthy to report: instead of an
|
|
23
|
+
* envelope, return vitest's raw output and exit status so the caller can
|
|
24
|
+
* surface the underlying error.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runTests(target: string, meta: RunMeta, resultsFile?: string): RunResult;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { buildEnvelope } from "./envelope.js";
|
|
4
|
+
/** Where the spawned vitest writes its JSON results, relative to cwd. */
|
|
5
|
+
export const RESULTS_FILE = ".pabst/.last-run.json";
|
|
6
|
+
/**
|
|
7
|
+
* Run vitest over `target` (the generated-test root, or a single generated
|
|
8
|
+
* file) and assemble the run envelope from its JSON results. When vitest
|
|
9
|
+
* produces no parseable results file (e.g. it died on startup before its
|
|
10
|
+
* reporter ran), the run yielded nothing trustworthy to report: instead of an
|
|
11
|
+
* envelope, return vitest's raw output and exit status so the caller can
|
|
12
|
+
* surface the underlying error.
|
|
13
|
+
*/
|
|
14
|
+
export function runTests(target, meta, resultsFile = RESULTS_FILE) {
|
|
15
|
+
// A stale results file from a previous run must not be mistaken for this
|
|
16
|
+
// run's output when vitest dies before writing one.
|
|
17
|
+
try {
|
|
18
|
+
rmSync(resultsFile, { force: true });
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
return {
|
|
22
|
+
kind: "no-results",
|
|
23
|
+
status: 1,
|
|
24
|
+
stdout: "",
|
|
25
|
+
stderr: `pabst: cannot clear stale results file ${resultsFile}: ${e instanceof Error ? e.message : String(e)}\n`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const res = spawnSync("npx", ["vitest", "run", target, "--reporter=json", `--outputFile=${resultsFile}`], { encoding: "utf8" });
|
|
29
|
+
let json;
|
|
30
|
+
try {
|
|
31
|
+
json = JSON.parse(readFileSync(resultsFile, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {
|
|
35
|
+
kind: "no-results",
|
|
36
|
+
status: res.status ?? 1,
|
|
37
|
+
stdout: res.stdout ?? "",
|
|
38
|
+
stderr: (res.stderr ?? "") + (res.error ? `${String(res.error)}\n` : ""),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// An unhealthy run vitest couldn't attribute to any test (e.g. a test file
|
|
42
|
+
// that failed to load) must not be reported as a trustworthy envelope. The
|
|
43
|
+
// json reporter keeps the underlying errors in each file's `message`, not
|
|
44
|
+
// on stdout/stderr.
|
|
45
|
+
if (json.success === false && json.numFailedTests === 0) {
|
|
46
|
+
const messages = (json.testResults ?? []).flatMap((f) => f.status === "failed" && f.message ? [f.message] : []);
|
|
47
|
+
return { kind: "broken-run", status: res.status || 1, messages };
|
|
48
|
+
}
|
|
49
|
+
return { kind: "completed", envelope: buildEnvelope(meta, json) };
|
|
50
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subset of fast-check's RunDetails that the reporter consumes. The
|
|
3
|
+
* counterexample is the tuple of generated arguments, in binder order.
|
|
4
|
+
*/
|
|
5
|
+
export interface ReportDetails {
|
|
6
|
+
failed: boolean;
|
|
7
|
+
counterexample: unknown[] | null;
|
|
8
|
+
errorInstance?: {
|
|
9
|
+
message?: string;
|
|
10
|
+
} | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Assert that a property atom evaluated to a genuine boolean (no truthiness
|
|
14
|
+
* coercion), returning it so it composes inside lowered boolean expressions.
|
|
15
|
+
* Throwing here surfaces as a `threw` issue whose message names the atom.
|
|
16
|
+
*/
|
|
17
|
+
export declare function bool(v: unknown, expr: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Reporter used by generated property tests. fast-check invokes this instead of
|
|
20
|
+
* its default throw, so it is solely responsible for failing the test — it must
|
|
21
|
+
* throw on `d.failed`. On failure it throws an Error whose message is a
|
|
22
|
+
* sentinel-tagged JSON Issue that the CLI parses back out of vitest's reporter
|
|
23
|
+
* output.
|
|
24
|
+
*/
|
|
25
|
+
export declare function report(file: string, functionName: string, name: string, varNames: string[], d: ReportDetails): void;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { stringify } from "fast-check";
|
|
2
|
+
import { ISSUE_SENTINEL } from "./issue.js";
|
|
3
|
+
/**
|
|
4
|
+
* Encode a single counterexample value for JSON output: finite numbers,
|
|
5
|
+
* booleans, and strings round-trip as native JSON; everything else (bigint,
|
|
6
|
+
* NaN/Infinity, objects) becomes its lossless fast-check stringify() form.
|
|
7
|
+
*/
|
|
8
|
+
function encodeValue(v) {
|
|
9
|
+
if (typeof v === "string" || typeof v === "boolean")
|
|
10
|
+
return v;
|
|
11
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
12
|
+
return v;
|
|
13
|
+
return stringify(v);
|
|
14
|
+
}
|
|
15
|
+
function throwIssue(issue) {
|
|
16
|
+
throw new Error(ISSUE_SENTINEL + JSON.stringify(issue));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Assert that a property atom evaluated to a genuine boolean (no truthiness
|
|
20
|
+
* coercion), returning it so it composes inside lowered boolean expressions.
|
|
21
|
+
* Throwing here surfaces as a `threw` issue whose message names the atom.
|
|
22
|
+
*/
|
|
23
|
+
export function bool(v, expr) {
|
|
24
|
+
if (v !== true && v !== false) {
|
|
25
|
+
const shown = typeof v === "string"
|
|
26
|
+
? JSON.stringify(v)
|
|
27
|
+
: typeof v === "bigint"
|
|
28
|
+
? `${v}n`
|
|
29
|
+
: String(v);
|
|
30
|
+
throw new Error(`atom ${JSON.stringify(expr)} evaluated to ${shown}, not a boolean`);
|
|
31
|
+
}
|
|
32
|
+
return v;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Reporter used by generated property tests. fast-check invokes this instead of
|
|
36
|
+
* its default throw, so it is solely responsible for failing the test — it must
|
|
37
|
+
* throw on `d.failed`. On failure it throws an Error whose message is a
|
|
38
|
+
* sentinel-tagged JSON Issue that the CLI parses back out of vitest's reporter
|
|
39
|
+
* output.
|
|
40
|
+
*/
|
|
41
|
+
export function report(file, functionName, name, varNames, d) {
|
|
42
|
+
if (!d.failed)
|
|
43
|
+
return;
|
|
44
|
+
const base = { file, function: functionName, property: name };
|
|
45
|
+
if (d.counterexample === null) {
|
|
46
|
+
throwIssue({
|
|
47
|
+
...base,
|
|
48
|
+
kind: "exhausted",
|
|
49
|
+
error: d.errorInstance?.message ?? "too many skipped runs",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const counterexample = {};
|
|
53
|
+
varNames.forEach((n, i) => {
|
|
54
|
+
counterexample[n] = encodeValue(d.counterexample[i]);
|
|
55
|
+
});
|
|
56
|
+
const err = d.errorInstance;
|
|
57
|
+
const threw = !!err && err.message !== "Property failed by returning false";
|
|
58
|
+
if (threw) {
|
|
59
|
+
throwIssue({ ...base, kind: "threw", counterexample, error: err.message });
|
|
60
|
+
}
|
|
61
|
+
throwIssue({ ...base, kind: "falsified", counterexample });
|
|
62
|
+
}
|
package/dist/seed.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A fresh 32-bit unsigned integer, suitable as a fast-check seed. */
|
|
2
|
+
export declare function randomSeed(): number;
|
|
3
|
+
/**
|
|
4
|
+
* Parse and validate a `--seed` CLI argument. fast-check seeds are 32-bit
|
|
5
|
+
* integers, so we require a non-negative integer below 2^32.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseSeed(raw: string): number;
|