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,191 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { PabstError } from "./errors.js";
|
|
3
|
+
const GLYPH = {
|
|
4
|
+
"¬": "not",
|
|
5
|
+
"∧": "and",
|
|
6
|
+
"∨": "or",
|
|
7
|
+
"→": "implies",
|
|
8
|
+
"↔": "iff",
|
|
9
|
+
};
|
|
10
|
+
const OPEN = new Set([
|
|
11
|
+
ts.SyntaxKind.OpenParenToken,
|
|
12
|
+
ts.SyntaxKind.OpenBracketToken,
|
|
13
|
+
ts.SyntaxKind.OpenBraceToken,
|
|
14
|
+
// `…${ opens a substitution: its contents are never at depth 0.
|
|
15
|
+
ts.SyntaxKind.TemplateHead,
|
|
16
|
+
]);
|
|
17
|
+
const CLOSE = new Set([
|
|
18
|
+
ts.SyntaxKind.CloseParenToken,
|
|
19
|
+
ts.SyntaxKind.CloseBracketToken,
|
|
20
|
+
ts.SyntaxKind.CloseBraceToken,
|
|
21
|
+
// }…` ends the last substitution. TemplateMiddle (}…${) closes one and
|
|
22
|
+
// opens the next — net depth unchanged — so it stays a js token.
|
|
23
|
+
ts.SyntaxKind.TemplateTail,
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Scan `text` with the context fixes the standalone TS scanner does not
|
|
27
|
+
* apply on its own: `/` regex-vs-division, `>` re-merged into >=, >>, … (the
|
|
28
|
+
* base scan splits them for generic-closing-`>` handling), and template
|
|
29
|
+
* continuation after a `${…}` substitution closes (the scanner would
|
|
30
|
+
* otherwise leave template mode and corrupt the middle/tail text).
|
|
31
|
+
*/
|
|
32
|
+
export function* scanTokens(text, start = 0) {
|
|
33
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, /*skipTrivia*/ true);
|
|
34
|
+
scanner.setText(text, start);
|
|
35
|
+
let kind;
|
|
36
|
+
let prev = null;
|
|
37
|
+
let prevText = "";
|
|
38
|
+
// Open-brace count within each active template substitution, innermost last.
|
|
39
|
+
const templateBraces = [];
|
|
40
|
+
while ((kind = scanner.scan()) !== ts.SyntaxKind.EndOfFileToken) {
|
|
41
|
+
if (kind === ts.SyntaxKind.SlashToken ||
|
|
42
|
+
kind === ts.SyntaxKind.SlashEqualsToken) {
|
|
43
|
+
// A `/` right after a `\` is the `\/` (or) fallback, not a regex.
|
|
44
|
+
if (regexCanFollow(prev) && prevText !== "\\") {
|
|
45
|
+
const re = scanner.reScanSlashToken();
|
|
46
|
+
if (re === ts.SyntaxKind.RegularExpressionLiteral)
|
|
47
|
+
kind = re;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (kind === ts.SyntaxKind.GreaterThanToken) {
|
|
51
|
+
kind = scanner.reScanGreaterToken();
|
|
52
|
+
}
|
|
53
|
+
const top = templateBraces.length - 1;
|
|
54
|
+
if (kind === ts.SyntaxKind.TemplateHead) {
|
|
55
|
+
// `` `…${ `` opens a template; its first substitution is now active.
|
|
56
|
+
templateBraces.push(0);
|
|
57
|
+
}
|
|
58
|
+
else if (kind === ts.SyntaxKind.OpenBraceToken && top >= 0) {
|
|
59
|
+
templateBraces[top] = templateBraces[top] + 1;
|
|
60
|
+
}
|
|
61
|
+
else if (kind === ts.SyntaxKind.CloseBraceToken && top >= 0) {
|
|
62
|
+
if (templateBraces[top] > 0) {
|
|
63
|
+
// an ordinary `}` inside the substitution
|
|
64
|
+
templateBraces[top] = templateBraces[top] - 1;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// This `}` ends the substitution: re-scan as template continuation so
|
|
68
|
+
// the following text stays template text rather than loose tokens.
|
|
69
|
+
kind = scanner.reScanTemplateToken(/*isTaggedTemplate*/ false);
|
|
70
|
+
if (kind === ts.SyntaxKind.TemplateTail)
|
|
71
|
+
templateBraces.pop();
|
|
72
|
+
// TemplateMiddle keeps the same level (a new substitution follows).
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const tokenText = scanner.getTokenText();
|
|
76
|
+
yield {
|
|
77
|
+
kind,
|
|
78
|
+
text: tokenText,
|
|
79
|
+
start: scanner.getTokenStart(),
|
|
80
|
+
end: scanner.getTextPos(),
|
|
81
|
+
};
|
|
82
|
+
prev = kind;
|
|
83
|
+
prevText = tokenText;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Tokenize a formula body. Glyphs/fallbacks become connective tokens; the rest is js. */
|
|
87
|
+
export function lexFormula(body) {
|
|
88
|
+
const raw = [];
|
|
89
|
+
for (const { kind, text, start, end } of scanTokens(body)) {
|
|
90
|
+
rejectQuantifiers(text);
|
|
91
|
+
const fkind = GLYPH[text] ??
|
|
92
|
+
(text === "iff"
|
|
93
|
+
? "iff"
|
|
94
|
+
: OPEN.has(kind)
|
|
95
|
+
? "open"
|
|
96
|
+
: CLOSE.has(kind)
|
|
97
|
+
? "close"
|
|
98
|
+
: "js");
|
|
99
|
+
raw.push({ kind: fkind, text, start, end });
|
|
100
|
+
}
|
|
101
|
+
return mergeArrowFallbacks(mergeSlashFallbacks(raw));
|
|
102
|
+
}
|
|
103
|
+
/** The source text spanned by toks (empty when toks is empty). */
|
|
104
|
+
export function sliceText(source, toks) {
|
|
105
|
+
if (toks.length === 0)
|
|
106
|
+
return "";
|
|
107
|
+
return source.slice(toks[0].start, toks[toks.length - 1].end);
|
|
108
|
+
}
|
|
109
|
+
/** A `/` can begin a regex unless the previous token ends a value (then it's division). */
|
|
110
|
+
export function regexCanFollow(prev) {
|
|
111
|
+
if (prev === null)
|
|
112
|
+
return true;
|
|
113
|
+
switch (prev) {
|
|
114
|
+
case ts.SyntaxKind.Identifier:
|
|
115
|
+
case ts.SyntaxKind.CloseParenToken:
|
|
116
|
+
case ts.SyntaxKind.CloseBracketToken:
|
|
117
|
+
case ts.SyntaxKind.CloseBraceToken:
|
|
118
|
+
case ts.SyntaxKind.RegularExpressionLiteral:
|
|
119
|
+
case ts.SyntaxKind.TemplateTail:
|
|
120
|
+
case ts.SyntaxKind.ThisKeyword:
|
|
121
|
+
case ts.SyntaxKind.TrueKeyword:
|
|
122
|
+
case ts.SyntaxKind.FalseKeyword:
|
|
123
|
+
case ts.SyntaxKind.NullKeyword:
|
|
124
|
+
case ts.SyntaxKind.PlusPlusToken:
|
|
125
|
+
case ts.SyntaxKind.MinusMinusToken:
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return !(prev >= ts.SyntaxKind.FirstLiteralToken &&
|
|
129
|
+
prev <= ts.SyntaxKind.LastLiteralToken);
|
|
130
|
+
}
|
|
131
|
+
function rejectQuantifiers(text) {
|
|
132
|
+
if (text === "∃" || text === "exists") {
|
|
133
|
+
throw new PabstError("existential quantifiers (∃ / exists) are not supported: property-based " +
|
|
134
|
+
"testing samples inputs, so it can refute ∀ with a counterexample but cannot " +
|
|
135
|
+
"soundly confirm ∃ (a bounded/exhaustive mode would be needed)");
|
|
136
|
+
}
|
|
137
|
+
if (text === "∀" || text === "forall") {
|
|
138
|
+
throw new PabstError("nested quantifiers are not supported: bind all variables in the leading ∀ prefix");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Merge adjacent js tokens forming -> / ==> (implies) and <-> (iff). */
|
|
142
|
+
function mergeArrowFallbacks(toks) {
|
|
143
|
+
const out = [];
|
|
144
|
+
for (let i = 0; i < toks.length; i++) {
|
|
145
|
+
const a = toks[i], b = toks[i + 1], c = toks[i + 2];
|
|
146
|
+
const adj = (x, y) => !!y && x.end === y.start;
|
|
147
|
+
// <-> : "<" "-" ">"
|
|
148
|
+
if (a.text === "<" &&
|
|
149
|
+
b?.text === "-" &&
|
|
150
|
+
c?.text === ">" &&
|
|
151
|
+
adj(a, b) &&
|
|
152
|
+
adj(b, c)) {
|
|
153
|
+
out.push({ kind: "iff", text: "<->", start: a.start, end: c.end });
|
|
154
|
+
i += 2;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// -> : "-" ">" and ==> : "==" ">"
|
|
158
|
+
if ((a.text === "-" || a.text === "==") && b?.text === ">" && adj(a, b)) {
|
|
159
|
+
out.push({
|
|
160
|
+
kind: "implies",
|
|
161
|
+
text: a.text + ">",
|
|
162
|
+
start: a.start,
|
|
163
|
+
end: b.end,
|
|
164
|
+
});
|
|
165
|
+
i += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
out.push(a);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/** Merge a real slash adjacent to a backslash: /\ → and, \/ → or. */
|
|
173
|
+
function mergeSlashFallbacks(toks) {
|
|
174
|
+
const out = [];
|
|
175
|
+
for (let i = 0; i < toks.length; i++) {
|
|
176
|
+
const a = toks[i], b = toks[i + 1];
|
|
177
|
+
const adj = !!b && a.end === b.start;
|
|
178
|
+
if (adj && a.text === "/" && b.text === "\\") {
|
|
179
|
+
out.push({ kind: "and", text: "/\\", start: a.start, end: b.end });
|
|
180
|
+
i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (adj && a.text === "\\" && b.text === "/") {
|
|
184
|
+
out.push({ kind: "or", text: "\\/", start: a.start, end: b.end });
|
|
185
|
+
i += 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
out.push(a);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { lexFormula, sliceText } from "./formula-lexer.js";
|
|
2
|
+
import { PabstError } from "./errors.js";
|
|
3
|
+
import { desugarEquations } from "./equations.js";
|
|
4
|
+
export function parseBody(body) {
|
|
5
|
+
const toks = lexFormula(body);
|
|
6
|
+
return parseFormula(toks, 0, toks.length, body);
|
|
7
|
+
}
|
|
8
|
+
const BINARY = new Set(["and", "or", "implies", "iff"]);
|
|
9
|
+
/** formula ::= equivalence (docs/grammar.ebnf) — must consume the whole range. */
|
|
10
|
+
function parseFormula(toks, start, end, src) {
|
|
11
|
+
const c = { toks, pos: start, end, src };
|
|
12
|
+
const f = parseEquivalence(c);
|
|
13
|
+
if (c.pos !== c.end) {
|
|
14
|
+
throw new PabstError(`unbalanced parentheses in formula: unexpected '${c.toks[c.pos].text}' (in: ${src})`);
|
|
15
|
+
}
|
|
16
|
+
return f;
|
|
17
|
+
}
|
|
18
|
+
/** equivalence ::= implication (IFF implication)? — non-associative. */
|
|
19
|
+
function parseEquivalence(c) {
|
|
20
|
+
const left = parseImplication(c);
|
|
21
|
+
if (peek(c)?.kind !== "iff")
|
|
22
|
+
return left;
|
|
23
|
+
c.pos++;
|
|
24
|
+
const right = parseImplication(c);
|
|
25
|
+
if (peek(c)?.kind === "iff") {
|
|
26
|
+
throw new PabstError("chained ↔ is ambiguous: parenthesize, e.g. (a ↔ b) ↔ c");
|
|
27
|
+
}
|
|
28
|
+
return { kind: "iff", left, right };
|
|
29
|
+
}
|
|
30
|
+
/** implication ::= disjunction (IMPLIES disjunction)*
|
|
31
|
+
* A chain a → b → c makes every segment but the last an antecedent. */
|
|
32
|
+
function parseImplication(c) {
|
|
33
|
+
const segs = [parseDisjunction(c)];
|
|
34
|
+
while (peek(c)?.kind === "implies") {
|
|
35
|
+
c.pos++;
|
|
36
|
+
segs.push(parseDisjunction(c));
|
|
37
|
+
}
|
|
38
|
+
if (segs.length === 1)
|
|
39
|
+
return segs[0];
|
|
40
|
+
return {
|
|
41
|
+
kind: "implication",
|
|
42
|
+
antecedents: segs.slice(0, -1),
|
|
43
|
+
consequent: segs[segs.length - 1],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** disjunction ::= conjunction (OR conjunction)* — left-associative. */
|
|
47
|
+
function parseDisjunction(c) {
|
|
48
|
+
let f = parseConjunction(c);
|
|
49
|
+
while (peek(c)?.kind === "or") {
|
|
50
|
+
c.pos++;
|
|
51
|
+
f = { kind: "or", left: f, right: parseConjunction(c) };
|
|
52
|
+
}
|
|
53
|
+
return f;
|
|
54
|
+
}
|
|
55
|
+
/** conjunction ::= negation (AND negation)* — left-associative. */
|
|
56
|
+
function parseConjunction(c) {
|
|
57
|
+
let f = parseNegation(c);
|
|
58
|
+
while (peek(c)?.kind === "and") {
|
|
59
|
+
c.pos++;
|
|
60
|
+
f = { kind: "and", left: f, right: parseNegation(c) };
|
|
61
|
+
}
|
|
62
|
+
return f;
|
|
63
|
+
}
|
|
64
|
+
/** negation ::= NOT negation | primary */
|
|
65
|
+
function parseNegation(c) {
|
|
66
|
+
if (peek(c)?.kind === "not") {
|
|
67
|
+
c.pos++;
|
|
68
|
+
return { kind: "not", arg: parseNegation(c) };
|
|
69
|
+
}
|
|
70
|
+
return parsePrimary(c);
|
|
71
|
+
}
|
|
72
|
+
/** primary ::= "(" formula ")" | atom
|
|
73
|
+
* The operand extends to the next depth-0 binary connective (or an
|
|
74
|
+
* unmatched close, or the end). A wholly-wrapped ( … ) is logical
|
|
75
|
+
* grouping; any other parenthesis belongs to the JavaScript island. */
|
|
76
|
+
function parsePrimary(c) {
|
|
77
|
+
const start = c.pos;
|
|
78
|
+
let depth = 0;
|
|
79
|
+
while (c.pos < c.end) {
|
|
80
|
+
const t = c.toks[c.pos];
|
|
81
|
+
if (t.kind === "open")
|
|
82
|
+
depth++;
|
|
83
|
+
else if (t.kind === "close") {
|
|
84
|
+
if (depth === 0)
|
|
85
|
+
break;
|
|
86
|
+
depth--;
|
|
87
|
+
}
|
|
88
|
+
else if (depth === 0 && BINARY.has(t.kind))
|
|
89
|
+
break;
|
|
90
|
+
c.pos++;
|
|
91
|
+
}
|
|
92
|
+
const span = c.toks.slice(start, c.pos);
|
|
93
|
+
if (span.length === 0) {
|
|
94
|
+
throw new PabstError("empty operand: a connective is missing a side");
|
|
95
|
+
}
|
|
96
|
+
if (whollyWrapped(span)) {
|
|
97
|
+
return parseFormula(c.toks, start + 1, c.pos - 1, c.src);
|
|
98
|
+
}
|
|
99
|
+
const text = sliceText(c.src, span).trim();
|
|
100
|
+
return { kind: "atom", text, js: desugarEquations(text) };
|
|
101
|
+
}
|
|
102
|
+
function peek(c) {
|
|
103
|
+
return c.pos < c.end ? c.toks[c.pos] : undefined;
|
|
104
|
+
}
|
|
105
|
+
/** True when span[0] is "(" whose match is the final token. */
|
|
106
|
+
function whollyWrapped(span) {
|
|
107
|
+
if (span.length < 2 || span[0].kind !== "open" || span[0].text !== "(") {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
let depth = 0;
|
|
111
|
+
for (let i = 0; i < span.length; i++) {
|
|
112
|
+
const t = span[i];
|
|
113
|
+
if (t.kind === "open")
|
|
114
|
+
depth++;
|
|
115
|
+
else if (t.kind === "close") {
|
|
116
|
+
depth--;
|
|
117
|
+
if (depth === 0)
|
|
118
|
+
return i === span.length - 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const GLOBALS: Set<string>;
|
|
2
|
+
export declare function freeIdentifiers(expr: string): Set<string>;
|
|
3
|
+
export interface Classification {
|
|
4
|
+
freeExports: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function classify(idents: Set<string>, boundVars: Set<string>, moduleExports: Set<string>): Classification;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { PabstError } from "./errors.js";
|
|
3
|
+
export const GLOBALS = new Set([
|
|
4
|
+
"Math",
|
|
5
|
+
"Number",
|
|
6
|
+
"JSON",
|
|
7
|
+
"Object",
|
|
8
|
+
"Array",
|
|
9
|
+
"String",
|
|
10
|
+
"Boolean",
|
|
11
|
+
"BigInt",
|
|
12
|
+
"Date",
|
|
13
|
+
"isNaN",
|
|
14
|
+
"isFinite",
|
|
15
|
+
"parseInt",
|
|
16
|
+
"parseFloat",
|
|
17
|
+
"undefined",
|
|
18
|
+
"NaN",
|
|
19
|
+
"Infinity",
|
|
20
|
+
"Symbol",
|
|
21
|
+
"Map",
|
|
22
|
+
"Set",
|
|
23
|
+
"RegExp",
|
|
24
|
+
"Error",
|
|
25
|
+
"console",
|
|
26
|
+
]);
|
|
27
|
+
export function freeIdentifiers(expr) {
|
|
28
|
+
const sf = ts.createSourceFile("__expr.ts", `(${expr});`, ts.ScriptTarget.Latest, true);
|
|
29
|
+
const found = new Set();
|
|
30
|
+
const visit = (node) => {
|
|
31
|
+
if (ts.isIdentifier(node)) {
|
|
32
|
+
const p = node.parent;
|
|
33
|
+
const isPropName = ts.isPropertyAccessExpression(p) && p.name === node;
|
|
34
|
+
const isQualified = ts.isQualifiedName(p) && p.right === node;
|
|
35
|
+
const isObjKey = ts.isPropertyAssignment(p) && p.name === node;
|
|
36
|
+
if (!isPropName && !isQualified && !isObjKey)
|
|
37
|
+
found.add(node.text);
|
|
38
|
+
}
|
|
39
|
+
ts.forEachChild(node, visit);
|
|
40
|
+
};
|
|
41
|
+
visit(sf);
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
export function classify(idents, boundVars, moduleExports) {
|
|
45
|
+
const freeExports = [];
|
|
46
|
+
for (const id of idents) {
|
|
47
|
+
if (boundVars.has(id))
|
|
48
|
+
continue;
|
|
49
|
+
if (GLOBALS.has(id))
|
|
50
|
+
continue;
|
|
51
|
+
if (moduleExports.has(id)) {
|
|
52
|
+
freeExports.push(id);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// The per-annotation wrapper in build-spec prefixes file, line, and
|
|
56
|
+
// property name, so naming them here would state them twice.
|
|
57
|
+
throw new PabstError(`references '${id}', which is not exported`);
|
|
58
|
+
}
|
|
59
|
+
return { freeExports: [...new Set(freeExports)] };
|
|
60
|
+
}
|
package/dist/ir.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type Domain = "int" | "nat" | "number" | "boolean" | "string" | "bigint";
|
|
2
|
+
/** Interval constraint on a numeric binder. Endpoints are the user's
|
|
3
|
+
* literal text, kept verbatim so floats are emitted exactly as written —
|
|
4
|
+
* except a leading `+`, redundant leading zeros, and a bigint `n` suffix
|
|
5
|
+
* are stripped (lowering re-adds `n` for bigint). An absent endpoint is
|
|
6
|
+
* unbounded (the user wrote -∞ / ∞); an open flag means that side's
|
|
7
|
+
* endpoint is excluded. */
|
|
8
|
+
export interface Range {
|
|
9
|
+
min?: string;
|
|
10
|
+
max?: string;
|
|
11
|
+
minOpen?: boolean;
|
|
12
|
+
maxOpen?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** Regex guard on a string binder. Source and flags are the user's literal
|
|
15
|
+
* text, kept verbatim; lowering anchors the source (see anchoredSource in
|
|
16
|
+
* regex-guard.ts) so the guard means whole-string membership. */
|
|
17
|
+
export interface StringPattern {
|
|
18
|
+
source: string;
|
|
19
|
+
flags: string;
|
|
20
|
+
}
|
|
21
|
+
export interface Binder {
|
|
22
|
+
varName: string;
|
|
23
|
+
domain: Domain;
|
|
24
|
+
range?: Range;
|
|
25
|
+
pattern?: StringPattern;
|
|
26
|
+
}
|
|
27
|
+
export interface PropertySpec {
|
|
28
|
+
name: string;
|
|
29
|
+
functionName: string;
|
|
30
|
+
/** Set when the property lives on a class method. */
|
|
31
|
+
className?: string;
|
|
32
|
+
/** Meaningful only when className is set: true for a static method. */
|
|
33
|
+
isStatic?: boolean;
|
|
34
|
+
binders: Binder[];
|
|
35
|
+
/** Desugared boolean expression, ready to drop into a predicate. */
|
|
36
|
+
body: string;
|
|
37
|
+
/** Desugared top-level antecedents, each lifted to fc.pre. */
|
|
38
|
+
preconditions: string[];
|
|
39
|
+
/** Module exports the body/preconditions reference. */
|
|
40
|
+
freeExports: string[];
|
|
41
|
+
location: {
|
|
42
|
+
file: string;
|
|
43
|
+
line: number;
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/ir.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/issue.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type IssueKind = "falsified" | "threw" | "exhausted";
|
|
2
|
+
export interface Issue {
|
|
3
|
+
file: string;
|
|
4
|
+
function: string;
|
|
5
|
+
property: string;
|
|
6
|
+
kind: IssueKind;
|
|
7
|
+
counterexample?: Record<string, unknown>;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface Envelope {
|
|
11
|
+
version: string;
|
|
12
|
+
startedAt: string;
|
|
13
|
+
cwd: string;
|
|
14
|
+
seed: number;
|
|
15
|
+
generated: number;
|
|
16
|
+
passed: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
issues: Issue[];
|
|
19
|
+
}
|
|
20
|
+
export declare const ISSUE_SENTINEL = "PABST_ISSUE:";
|
|
21
|
+
/**
|
|
22
|
+
* Extract a pabst Issue from a vitest failure message, or null if the message
|
|
23
|
+
* carries no tagged payload. The payload is single-line JSON, so a stack trace
|
|
24
|
+
* on following lines is ignored.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseIssue(failureMessage: string): Issue | null;
|
package/dist/issue.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const ISSUE_SENTINEL = "PABST_ISSUE:";
|
|
2
|
+
const ISSUE_RE = /PABST_ISSUE:(\{.*\})/;
|
|
3
|
+
/**
|
|
4
|
+
* Extract a pabst Issue from a vitest failure message, or null if the message
|
|
5
|
+
* carries no tagged payload. The payload is single-line JSON, so a stack trace
|
|
6
|
+
* on following lines is ignored.
|
|
7
|
+
*/
|
|
8
|
+
export function parseIssue(failureMessage) {
|
|
9
|
+
const m = ISSUE_RE.exec(failureMessage);
|
|
10
|
+
if (!m)
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(m[1]);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/lower.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Formula } from "./formula-ast.js";
|
|
2
|
+
/** A pure boolean expression string for any sub-formula (implication = material). */
|
|
3
|
+
export declare function lowerExpr(f: Formula): string;
|
|
4
|
+
/**
|
|
5
|
+
* Lower the body root. A top-level implication's antecedents become fc.pre(...)
|
|
6
|
+
* discards (QuickCheck conditional-property semantics); everything else is one
|
|
7
|
+
* boolean expression. NOTE: ↔ never reaches here as a precondition — only `→`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function lowerTop(f: Formula): {
|
|
10
|
+
preconditions: string[];
|
|
11
|
+
body: string;
|
|
12
|
+
};
|
package/dist/lower.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** A pure boolean expression string for any sub-formula (implication = material). */
|
|
2
|
+
export function lowerExpr(f) {
|
|
3
|
+
switch (f.kind) {
|
|
4
|
+
case "atom":
|
|
5
|
+
return `__bool(${f.js}, ${JSON.stringify(f.text)})`;
|
|
6
|
+
case "not":
|
|
7
|
+
return `!(${lowerExpr(f.arg)})`;
|
|
8
|
+
case "and":
|
|
9
|
+
return `(${lowerExpr(f.left)} && ${lowerExpr(f.right)})`;
|
|
10
|
+
case "or":
|
|
11
|
+
return `(${lowerExpr(f.left)} || ${lowerExpr(f.right)})`;
|
|
12
|
+
case "iff":
|
|
13
|
+
return `(${lowerExpr(f.left)} === ${lowerExpr(f.right)})`;
|
|
14
|
+
case "implication":
|
|
15
|
+
return materialImplication(f.antecedents, f.consequent);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function materialImplication(antecedents, consequent) {
|
|
19
|
+
if (antecedents.length === 0)
|
|
20
|
+
return lowerExpr(consequent);
|
|
21
|
+
const [head, ...rest] = antecedents;
|
|
22
|
+
return `(!(${lowerExpr(head)}) || ${materialImplication(rest, consequent)})`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Lower the body root. A top-level implication's antecedents become fc.pre(...)
|
|
26
|
+
* discards (QuickCheck conditional-property semantics); everything else is one
|
|
27
|
+
* boolean expression. NOTE: ↔ never reaches here as a precondition — only `→`.
|
|
28
|
+
*/
|
|
29
|
+
export function lowerTop(f) {
|
|
30
|
+
if (f.kind === "implication") {
|
|
31
|
+
return {
|
|
32
|
+
preconditions: f.antecedents.map(lowerExpr),
|
|
33
|
+
body: lowerExpr(f.consequent),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { preconditions: [], body: lowerExpr(f) };
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { Binder } from "./ir.js";
|
|
3
|
+
export interface ParsedFormula {
|
|
4
|
+
binders: Binder[];
|
|
5
|
+
/** Raw TS expression text of each `pre(...)` argument, in source order. */
|
|
6
|
+
preconditions: string[];
|
|
7
|
+
/** Raw TS expression text of the returned / expression body. */
|
|
8
|
+
body: string;
|
|
9
|
+
/** Precondition arguments + body, as nodes to walk for free identifiers. */
|
|
10
|
+
exprNodes: ts.Expression[];
|
|
11
|
+
}
|
|
12
|
+
export declare function parseFormula(formula: string): ParsedFormula;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { isDomain } from "./domains.js";
|
|
3
|
+
const VALID_DOMAINS = "int, nat, number, boolean, string, bigint";
|
|
4
|
+
export function parseFormula(formula) {
|
|
5
|
+
const sf = ts.createSourceFile("__formula.ts", `(${formula});`, ts.ScriptTarget.Latest, true);
|
|
6
|
+
const stmt = sf.statements[0];
|
|
7
|
+
/* v8 ignore next 3 -- the `(...)` wrapper always parses to an ExpressionStatement; defensive guard only */
|
|
8
|
+
if (!stmt || !ts.isExpressionStatement(stmt)) {
|
|
9
|
+
throw new Error(`property must be an arrow function '(x: domain, ...) => body': ${formula.trim().slice(0, 60)}`);
|
|
10
|
+
}
|
|
11
|
+
let expr = stmt.expression;
|
|
12
|
+
while (ts.isParenthesizedExpression(expr))
|
|
13
|
+
expr = expr.expression;
|
|
14
|
+
if (!ts.isArrowFunction(expr)) {
|
|
15
|
+
throw new Error(`property must be an arrow function '(x: domain, ...) => body': ${formula.trim().slice(0, 60)}`);
|
|
16
|
+
}
|
|
17
|
+
const binders = parseBinders(expr, sf);
|
|
18
|
+
const { preconditions, body, exprNodes } = parseBody(expr.body, sf);
|
|
19
|
+
return { binders, preconditions, body, exprNodes };
|
|
20
|
+
}
|
|
21
|
+
function parseBinders(arrow, sf) {
|
|
22
|
+
const binders = [];
|
|
23
|
+
for (const p of arrow.parameters) {
|
|
24
|
+
if (!ts.isIdentifier(p.name)) {
|
|
25
|
+
throw new Error(`binder must be a simple name '(x: domain)', no destructuring: ${p.getText(sf)}`);
|
|
26
|
+
}
|
|
27
|
+
if (p.dotDotDotToken)
|
|
28
|
+
throw new Error(`rest parameters are not supported: ${p.getText(sf)}`);
|
|
29
|
+
if (p.questionToken)
|
|
30
|
+
throw new Error(`optional binders are not supported: ${p.name.text}`);
|
|
31
|
+
if (p.initializer)
|
|
32
|
+
throw new Error(`default binder values are not supported: ${p.name.text}`);
|
|
33
|
+
if (!p.type)
|
|
34
|
+
throw new Error(`binder '${p.name.text}' needs a domain, e.g. (${p.name.text}: int)`);
|
|
35
|
+
const domain = p.type.getText(sf);
|
|
36
|
+
if (!isDomain(domain)) {
|
|
37
|
+
throw new Error(`unknown generation domain '${domain}' — valid domains: ${VALID_DOMAINS}`);
|
|
38
|
+
}
|
|
39
|
+
binders.push({ varName: p.name.text, domain });
|
|
40
|
+
}
|
|
41
|
+
if (binders.length === 0) {
|
|
42
|
+
throw new Error(`property needs at least one binder, e.g. (x: int) => ...`);
|
|
43
|
+
}
|
|
44
|
+
return binders;
|
|
45
|
+
}
|
|
46
|
+
function parseBody(bodyNode, sf) {
|
|
47
|
+
const preconditions = [];
|
|
48
|
+
const exprNodes = [];
|
|
49
|
+
if (!ts.isBlock(bodyNode)) {
|
|
50
|
+
exprNodes.push(bodyNode);
|
|
51
|
+
return { preconditions, body: bodyNode.getText(sf), exprNodes };
|
|
52
|
+
}
|
|
53
|
+
let returnExpr;
|
|
54
|
+
for (const s of bodyNode.statements) {
|
|
55
|
+
if (ts.isExpressionStatement(s) && isPreCall(s.expression)) {
|
|
56
|
+
const arg = s.expression.arguments[0];
|
|
57
|
+
preconditions.push(arg.getText(sf));
|
|
58
|
+
exprNodes.push(arg);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (ts.isReturnStatement(s)) {
|
|
62
|
+
if (returnExpr)
|
|
63
|
+
throw new Error(`property block body has more than one return`);
|
|
64
|
+
if (!s.expression)
|
|
65
|
+
throw new Error(`property block body must return a boolean expression`);
|
|
66
|
+
returnExpr = s.expression;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`property block body may contain only pre(...) and one return: ${s.getText(sf)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!returnExpr)
|
|
72
|
+
throw new Error(`property block body must return a boolean expression`);
|
|
73
|
+
exprNodes.push(returnExpr);
|
|
74
|
+
return { preconditions, body: returnExpr.getText(sf), exprNodes };
|
|
75
|
+
}
|
|
76
|
+
function isPreCall(e) {
|
|
77
|
+
return ts.isCallExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === "pre" && e.arguments.length === 1;
|
|
78
|
+
}
|