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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jesse Alama
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Pabst: A blue-ribbon approach to **P**roperty-**B**ased **T**esting
|
|
2
|
+
|
|
3
|
+
Annotate your functions with properties they're supposed to have, then try to invalidate them with [fast-check](https://fast-check.dev/).
|
|
4
|
+
|
|
5
|
+
Put the properties your functions should have in a JSDoc comment, run Pabst,
|
|
6
|
+
and get either "cases passed" or a counterexample that shows the property doesn't hold.
|
|
7
|
+
|
|
8
|
+
_Example_ Look at this code. We're trying to assert that the value of the function is
|
|
9
|
+
non-zero provided the second argument is an integer. Can you spot the error?
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
/**
|
|
13
|
+
* @ensures{nonzero} forall (x: bigint) (y: number),
|
|
14
|
+
* Number.isInteger(y) ==> foo(x, y) !== 0
|
|
15
|
+
*/
|
|
16
|
+
export function foo(x: bigint, y: number): number {
|
|
17
|
+
return Number(x % 2n) + (y % 2) + 1;
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Each remainder looks like it should be 0 or 1, so the sum looks like it's at
|
|
22
|
+
least 1. But JavaScript's `%` returns _negative_ remainders for negative
|
|
23
|
+
operands: `foo(-1n, 0)` is `-1 + 0 + 1 === 0`. You don't have to spot that —
|
|
24
|
+
`pabst test` falsifies the property and reports a counterexample.
|
|
25
|
+
|
|
26
|
+
## Philosophy
|
|
27
|
+
|
|
28
|
+
Pabst is a property-based testing tool. Instead of checking
|
|
29
|
+
your function against a handful of hand-picked examples,
|
|
30
|
+
pabst (delegating to `fast-check`) generates many random
|
|
31
|
+
inputs and works hard to **refute** the property you
|
|
32
|
+
attached; when it succeeds, it shrinks the failure to a
|
|
33
|
+
small, readable counterexample. One thing should be
|
|
34
|
+
understood, though: failing to invalidate a property — even
|
|
35
|
+
across many runs — is no _proof_ that the property holds. It
|
|
36
|
+
is evidence that it holds, but the property might still be
|
|
37
|
+
false on inputs the generator never tried. If your goal is
|
|
38
|
+
to prove the absence of counterexamples, you need
|
|
39
|
+
proof-based tools such as
|
|
40
|
+
[Thales](https://github.com/jessealama/thales). That said,
|
|
41
|
+
property-based testing is a powerful technique that exposes
|
|
42
|
+
a lot of bugs for very little effort, and it sits
|
|
43
|
+
comfortably alongside proof-based approaches.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install --save-dev pabst-checker
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The package is `pabst-checker`; the command it installs is `pabst`.
|
|
52
|
+
|
|
53
|
+
Requires Node 24+. Pabst bundles its own [vitest](https://vitest.dev/) and
|
|
54
|
+
declares [fast-check](https://fast-check.dev/) as a peer dependency (npm
|
|
55
|
+
installs it for you), so nothing else is needed. The peer relationship
|
|
56
|
+
means pabst validates your annotations against the same fast-check copy
|
|
57
|
+
the generated tests run with — if your project pins an incompatible
|
|
58
|
+
fast-check, npm says so at install time instead of your tests failing
|
|
59
|
+
mysteriously.
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pabst test # discover sources, test, print a JSON report
|
|
65
|
+
pabst test <files-or-globs> # same, on an explicit file list
|
|
66
|
+
pabst test --seed <n> <files-or-globs> # reproduce a prior run's generation
|
|
67
|
+
pabst gen [files-or-globs] # generate only; run your own vitest against .pabst/
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
With no file arguments, pabst discovers your sources: if `tsconfig.json`
|
|
71
|
+
exists, it scans exactly the files `tsc` would compile; otherwise it falls
|
|
72
|
+
back to `src/**`. If neither yields anything, it exits with an error asking
|
|
73
|
+
for an explicit glob. Discovery stays inside the current directory — a
|
|
74
|
+
tsconfig reaching outside it (say, a monorepo `include` of `../shared`) has
|
|
75
|
+
those files skipped; run pabst in the package that owns them.
|
|
76
|
+
|
|
77
|
+
Declaration files (`.d.ts`) are skipped by default — tsc copies JSDoc into
|
|
78
|
+
them, so scanning both a declaration and its source would extract every
|
|
79
|
+
property twice. A pattern that explicitly names declarations
|
|
80
|
+
(`pabst gen "index.d.ts"`) is honored, for packages whose hand-written types
|
|
81
|
+
are the source.
|
|
82
|
+
|
|
83
|
+
Pabst writes the test files it generates to a `.pabst/` directory in your
|
|
84
|
+
project. Those files are regenerated on every run, so there is no reason to
|
|
85
|
+
commit them — add `.pabst/` to your `.gitignore`:
|
|
86
|
+
|
|
87
|
+
```gitignore
|
|
88
|
+
.pabst/
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Output
|
|
92
|
+
|
|
93
|
+
`pabst test` prints a single JSON object to **stdout**; **stderr** carries only
|
|
94
|
+
progress and crashes. The envelope is always present — a clean run just has an
|
|
95
|
+
empty `issues` array:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"version": "0.7.0",
|
|
100
|
+
"startedAt": "2026-06-26T17:42:03.000Z",
|
|
101
|
+
"cwd": "/path/to/project",
|
|
102
|
+
"seed": 1834592013,
|
|
103
|
+
"generated": 5,
|
|
104
|
+
"passed": 5,
|
|
105
|
+
"failed": 0,
|
|
106
|
+
"issues": []
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Each issue records where the property lived and why it failed:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"file": "src/math.ts",
|
|
115
|
+
"function": "add",
|
|
116
|
+
"property": "commutes",
|
|
117
|
+
"kind": "falsified",
|
|
118
|
+
"counterexample": { "x": 1, "y": 2 }
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- `kind` is `"falsified"` (returned `false`), `"threw"` (raised an exception —
|
|
123
|
+
see `error`), or `"exhausted"` (too many precondition skips — `error` explains,
|
|
124
|
+
and there is no `counterexample`).
|
|
125
|
+
- Counterexample values are JSON-native where they round-trip; bigints and
|
|
126
|
+
non-finite numbers appear as fast-check strings (e.g. `"1n"`).
|
|
127
|
+
- The `seed` is generated per run and echoed back; pass it to `--seed` to
|
|
128
|
+
reproduce a failing run exactly.
|
|
129
|
+
|
|
130
|
+
The process exits `0` when `issues` is empty, `1` when there is at least one
|
|
131
|
+
issue, and `2` on usage errors — including annotation errors such as a
|
|
132
|
+
malformed formula, an unsupported domain, or a reference to an unexported
|
|
133
|
+
symbol, which are reported as a one-line message on stderr.
|
|
134
|
+
|
|
135
|
+
## Grammar
|
|
136
|
+
|
|
137
|
+
The normative grammar lives in [`docs/grammar.ebnf`](docs/grammar.ebnf);
|
|
138
|
+
this section is the guided tour.
|
|
139
|
+
|
|
140
|
+
A property is a universally quantified formula in Pabst's **logic surface**.
|
|
141
|
+
Non-ASCII symbols are the canonical form; most have ASCII fallbacks
|
|
142
|
+
(negation `¬` and the equation glyphs `≡`/`≢` are glyph-only — the ASCII
|
|
143
|
+
spelling of an equation is a plain `Object.is` call).
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
/**
|
|
147
|
+
* @ensures{guarded} forall (x: int),
|
|
148
|
+
* isPrime(x) ∧ x > 2 → isOdd(x)
|
|
149
|
+
*/
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
- **Quantifier:** `forall` / `∀`, one-or-more binder groups, then a comma, then
|
|
153
|
+
the body. Lean-style grouping `(x y: int)` is supported. Existential `∃` /
|
|
154
|
+
`exists` is intentionally rejected (PBT cannot soundly confirm existence).
|
|
155
|
+
- **Domains:** `int`, `nat`, `number`, `boolean`, `string`, `bigint`.
|
|
156
|
+
A numeric domain (`int`, `nat`, `number`, `bigint`) may be constrained to
|
|
157
|
+
an interval: `forall (x: int ∈ [1, 30])` (ASCII fallback: `in`). Each
|
|
158
|
+
bound is independently inclusive (`[`/`]`) or exclusive (`(`/`)`), so
|
|
159
|
+
`(0, 1]`, `[0, 30)`, and `(0, 30)` all work — for `int`/`nat`/`bigint`
|
|
160
|
+
an exclusive bound is a ±1 adjustment. An endpoint may be unbounded:
|
|
161
|
+
`-∞`/`∞` (ASCII: `Infinity`), so `(x: number ∈ (0, ∞))` is a strictly
|
|
162
|
+
positive number (excluding `-0` — and `Infinity`, since the bound is
|
|
163
|
+
exclusive; `[0, ∞]` may generate `Infinity` itself). For `int`, `nat`,
|
|
164
|
+
and `bigint` an ∞ endpoint must be exclusive; for `int`/`nat` it means
|
|
165
|
+
the safe integer limit (±2^53 − 1), and a finite endpoint beyond that
|
|
166
|
+
limit clamps to it with a warning. A `nat` interval reaching below 0
|
|
167
|
+
clamps to 0 (`(-2, 5]` and `(-∞, 5]` denote the same naturals).
|
|
168
|
+
`number` intervals follow fast-check's double ordering, in which every
|
|
169
|
+
double is distinct: `-0` sits below `0`, and an exclusive bound removes
|
|
170
|
+
exactly one adjacent double — so `[-1, 0)` can generate `-0` (which
|
|
171
|
+
`== 0`), and `(-0, 0]` is the singleton `{0}`. A bounded `number` never
|
|
172
|
+
generates `NaN`.
|
|
173
|
+
- **Regex guards** constrain a string binder to strings matching a JS
|
|
174
|
+
regular expression: `forall (s: string ∈ /[a-z]+/)` (ASCII fallback:
|
|
175
|
+
`in`). Membership means the _whole_ string matches — pabst anchors the
|
|
176
|
+
pattern for you (lowering to `fc.stringMatching(/^(?:[a-z]+)$/)`), so
|
|
177
|
+
`/[a-z]+/` never generates `"3fk!"`. Flags `s` and `u` are allowed (`u`
|
|
178
|
+
enables `\p{...}` escapes); everything else is rejected — `m` because it
|
|
179
|
+
would reintroduce substring matching, `i`/`v` because fast-check's
|
|
180
|
+
generator lacks them, `g`/`y`/`d` because they don't affect generation.
|
|
181
|
+
Patterns outside fast-check's supported subset (lookarounds,
|
|
182
|
+
backreferences, `\b`) are compile-time errors. Careful inside JSDoc: a
|
|
183
|
+
`*/` in a pattern (e.g. the trailing star in a pattern matching
|
|
184
|
+
zero-or-more) ends the comment early — write `{0,}` instead of a
|
|
185
|
+
trailing `*`, or wrap it in `(?:...)`.
|
|
186
|
+
- **Connectives** (tightest→loosest): `¬` > `∧` > `∨` > `→` > `↔`.
|
|
187
|
+
Fallbacks: `∧`=`/\`, `∨`=`\/`, `→`=`->`/`==>`, `↔`=`<->`/`iff`.
|
|
188
|
+
Negation `¬` is glyph-only.
|
|
189
|
+
- **Equations:** `A ≡ B` means identity — sugar for `Object.is(A, B)`;
|
|
190
|
+
`A ≢ B` is its negation. Both are glyph-only, like `¬`: in plain ASCII,
|
|
191
|
+
call `Object.is(A, B)` directly (negate at an atom's top level with `≢` or
|
|
192
|
+
`¬(Object.is(A, B))`; nested `!Object.is(A, B)` is fine). This is
|
|
193
|
+
SameValue, not mathematical equality: `NaN ≡ NaN` holds and `-0 ≡ 0` does
|
|
194
|
+
not, so `x + 0 ≡ x` is refutable at `x = -0` (guard with `x ≢ -0 →` if
|
|
195
|
+
that is intended).
|
|
196
|
+
An equation lives at the **top level of an atom** — it splits the atom
|
|
197
|
+
into two JS sides. In nested positions (callbacks, call arguments,
|
|
198
|
+
template substitutions), call `Object.is` directly:
|
|
199
|
+
`xs.every(x => Object.is(x, 0))`, not `xs.every(x => x ≡ 0)`.
|
|
200
|
+
An unparenthesized `??` or ternary beside `≡` is an error — parenthesize
|
|
201
|
+
the intended grouping, e.g. `a ≡ (b ?? c)` or `a ≡ (b ? c : d)`.
|
|
202
|
+
Chains like `a ≡ b ≡ c` are errors — write
|
|
203
|
+
`a ≡ b ∧ b ≡ c`. Loose `==`/`!=` are errors (use `≡`/`≢` or `===`/`!==`);
|
|
204
|
+
`===`/`!==` keep their exact JS meaning; assignments — plain `=` and
|
|
205
|
+
compound forms like `+=` — cannot appear in a formula (default-parameter
|
|
206
|
+
initializers in callbacks are fine); `≠` is rejected with a hint to
|
|
207
|
+
write `≢`.
|
|
208
|
+
- **Atoms are JavaScript** and must be genuine booleans — every atom is checked
|
|
209
|
+
at runtime (`5 ∧ true` is an error, not a coercion). You may **not** use JS
|
|
210
|
+
`&&`/`||`/`!` at an atom's top level — use the glyphs. They remain legal
|
|
211
|
+
_inside_ a leaf (e.g. a callback `xs.every(x => x > 0 && x < 10)`).
|
|
212
|
+
- **Implication discard:** a **top-level** `→`'s antecedents become `fc.pre(...)`
|
|
213
|
+
(QuickCheck-style discarded cases, reported as `exhausted` if too many skip);
|
|
214
|
+
a **parenthesised** `→` is ordinary material implication `¬P ∨ Q`.
|
|
215
|
+
- **Biconditional** `↔` is non-associative (parenthesise chains) and is _not_ a
|
|
216
|
+
discard — it lowers to boolean equality.
|
|
217
|
+
- **Scoping:** every symbol an atom references must be `export`ed from its module.
|
|
218
|
+
|
|
219
|
+
Each `@ensures{name}` becomes one issue (keyed by file, function, and property
|
|
220
|
+
name) if it fails. Generated files land in the `.pabst/` directory (see
|
|
221
|
+
[Usage](#usage)) mirroring the source tree; they are regenerated every run and
|
|
222
|
+
must never be hand-edited.
|
|
223
|
+
|
|
224
|
+
## Development
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
npm install
|
|
228
|
+
npm test # vitest
|
|
229
|
+
npm run build # tsc -> dist/
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Requires Node 24+.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { extract } from "./extract.js";
|
|
2
|
+
import { parsePrefix } from "./prefix-parser.js";
|
|
3
|
+
import { parseBody } from "./formula-parser.js";
|
|
4
|
+
import { lowerTop } from "./lower.js";
|
|
5
|
+
import { collectAtoms } from "./formula-ast.js";
|
|
6
|
+
import { freeIdentifiers, classify } from "./free-idents.js";
|
|
7
|
+
import { PabstError } from "./errors.js";
|
|
8
|
+
export function buildSpecs(file) {
|
|
9
|
+
const { exports, annotations } = extract(file);
|
|
10
|
+
const specs = [];
|
|
11
|
+
for (const a of annotations) {
|
|
12
|
+
try {
|
|
13
|
+
specs.push(buildSpec(a, exports, file));
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
if (e instanceof PabstError) {
|
|
17
|
+
throw new PabstError(`${file}:${a.line}: @ensures{${a.propertyName}}: ${e.message}`, { cause: e });
|
|
18
|
+
}
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return specs;
|
|
23
|
+
}
|
|
24
|
+
function buildSpec(a, exports, file) {
|
|
25
|
+
const { binders, body } = parsePrefix(a.formula);
|
|
26
|
+
const ast = parseBody(body);
|
|
27
|
+
const { preconditions, body: loweredBody } = lowerTop(ast);
|
|
28
|
+
const boundVars = new Set(binders.map((b) => b.varName));
|
|
29
|
+
const idents = new Set();
|
|
30
|
+
for (const atom of collectAtoms(ast)) {
|
|
31
|
+
for (const id of freeIdentifiers(atom))
|
|
32
|
+
idents.add(id);
|
|
33
|
+
}
|
|
34
|
+
const { freeExports } = classify(idents, boundVars, exports);
|
|
35
|
+
return {
|
|
36
|
+
name: a.propertyName,
|
|
37
|
+
functionName: a.functionName,
|
|
38
|
+
className: a.className,
|
|
39
|
+
isStatic: a.isStatic,
|
|
40
|
+
binders,
|
|
41
|
+
body: loweredBody,
|
|
42
|
+
preconditions,
|
|
43
|
+
freeExports,
|
|
44
|
+
location: { file, line: a.line },
|
|
45
|
+
};
|
|
46
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { generate } from "./codegen.js";
|
|
6
|
+
import { resolveFiles } from "./discover.js";
|
|
7
|
+
import { PabstError } from "./errors.js";
|
|
8
|
+
import { runTests } from "./run.js";
|
|
9
|
+
import { randomSeed, parseSeed } from "./seed.js";
|
|
10
|
+
function readVersion() {
|
|
11
|
+
const url = new URL("../package.json", import.meta.url);
|
|
12
|
+
return JSON.parse(readFileSync(url, "utf8")).version;
|
|
13
|
+
}
|
|
14
|
+
const USAGE = "usage: pabst <test|gen> [--seed <n>] [files-or-globs...]";
|
|
15
|
+
const HELP = `${USAGE}
|
|
16
|
+
|
|
17
|
+
commands:
|
|
18
|
+
test generate property tests from @ensures annotations, run them, and
|
|
19
|
+
print a JSON report to stdout
|
|
20
|
+
gen generate only; run your own vitest against .pabst/
|
|
21
|
+
|
|
22
|
+
when no files are given, pabst discovers your sources: the files that
|
|
23
|
+
tsconfig.json would compile or, failing that, src/**. declaration files
|
|
24
|
+
(.d.ts) are skipped unless a pattern names them (e.g. pabst gen index.d.ts).
|
|
25
|
+
|
|
26
|
+
options:
|
|
27
|
+
--seed <n> reproduce a prior run's generation (n is echoed in the report)
|
|
28
|
+
-h, --help show this help`;
|
|
29
|
+
export function main(argv = process.argv.slice(2)) {
|
|
30
|
+
// parseArgs throws on unknown options and the like — usage errors, which
|
|
31
|
+
// map to the documented exit-2 mode; anything else crashes loudly.
|
|
32
|
+
let positionals;
|
|
33
|
+
let values;
|
|
34
|
+
try {
|
|
35
|
+
({ positionals, values } = parseArgs({
|
|
36
|
+
args: argv,
|
|
37
|
+
allowPositionals: true,
|
|
38
|
+
options: {
|
|
39
|
+
seed: { type: "string" },
|
|
40
|
+
help: { type: "boolean", short: "h" },
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (e instanceof TypeError &&
|
|
46
|
+
"code" in e &&
|
|
47
|
+
typeof e.code === "string" &&
|
|
48
|
+
e.code.startsWith("ERR_PARSE_ARGS_")) {
|
|
49
|
+
console.error(USAGE);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
if (values.help) {
|
|
55
|
+
console.log(HELP);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const command = positionals[0];
|
|
59
|
+
const patterns = positionals.slice(1);
|
|
60
|
+
if (command !== "test" && command !== "gen") {
|
|
61
|
+
console.error(USAGE);
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
// User-facing errors anywhere below — a bad --seed, file resolution coming
|
|
65
|
+
// up empty, a malformed tsconfig, compile errors — are PabstErrors and map
|
|
66
|
+
// to the documented exit-2 error mode; anything else is an internal bug
|
|
67
|
+
// and crashes loudly.
|
|
68
|
+
try {
|
|
69
|
+
return run(command, patterns, values.seed);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (e instanceof PabstError) {
|
|
73
|
+
console.error(`error: ${e.message}`);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function run(command, patterns, seedArg) {
|
|
80
|
+
const seed = seedArg !== undefined ? parseSeed(seedArg) : randomSeed();
|
|
81
|
+
const { files, source } = resolveFiles(patterns);
|
|
82
|
+
if (source !== "arguments") {
|
|
83
|
+
console.error(`pabst: no files given; discovered ${files.length} file(s) via ${source}`);
|
|
84
|
+
}
|
|
85
|
+
const startedAt = new Date().toISOString();
|
|
86
|
+
const cwd = process.cwd();
|
|
87
|
+
const version = readVersion();
|
|
88
|
+
const results = generate(files, ".pabst", seed);
|
|
89
|
+
const generated = results.reduce((n, r) => n + r.propertyCount, 0);
|
|
90
|
+
console.error(`pabst: generated ${generated} propert${generated === 1 ? "y" : "ies"} across ${results.length} file(s) into .pabst/`);
|
|
91
|
+
if (command === "gen")
|
|
92
|
+
return 0;
|
|
93
|
+
const result = runTests(".pabst", {
|
|
94
|
+
version,
|
|
95
|
+
startedAt,
|
|
96
|
+
cwd,
|
|
97
|
+
seed,
|
|
98
|
+
generated,
|
|
99
|
+
});
|
|
100
|
+
if (result.kind === "no-results") {
|
|
101
|
+
process.stderr.write(result.stdout);
|
|
102
|
+
process.stderr.write(result.stderr);
|
|
103
|
+
return result.status;
|
|
104
|
+
}
|
|
105
|
+
if (result.kind === "broken-run") {
|
|
106
|
+
for (const m of result.messages)
|
|
107
|
+
console.error(`error: ${m}`);
|
|
108
|
+
return result.status;
|
|
109
|
+
}
|
|
110
|
+
console.log(JSON.stringify(result.envelope, null, 2));
|
|
111
|
+
return result.envelope.failed > 0 ? 1 : 0;
|
|
112
|
+
}
|
|
113
|
+
// npm installs the bin as a symlink (node_modules/.bin/pabst -> this file).
|
|
114
|
+
// Node resolves the main module to its realpath, but argv[1] keeps the
|
|
115
|
+
// symlink path, so argv[1] must be realpath'd before comparing.
|
|
116
|
+
if (process.argv[1] &&
|
|
117
|
+
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
118
|
+
process.exit(main());
|
|
119
|
+
}
|
package/dist/codegen.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { buildSpecs } from "./build-spec.js";
|
|
4
|
+
import { emit } from "./emit.js";
|
|
5
|
+
import { PabstError } from "./errors.js";
|
|
6
|
+
import { randomSeed } from "./seed.js";
|
|
7
|
+
const SRC_EXT = /\.(ts|tsx|mts|cts|js|mjs|cjs)$/;
|
|
8
|
+
export function generate(files, outRoot = ".pabst", seed = randomSeed()) {
|
|
9
|
+
const results = [];
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
const rel = path.relative(process.cwd(), path.resolve(file));
|
|
12
|
+
// Out files mirror rel under outRoot, so a ".."-led (or, across Windows
|
|
13
|
+
// drives, absolute) rel would place them outside it — where the runner
|
|
14
|
+
// never looks. Refuse rather than silently skip what looks generated.
|
|
15
|
+
if (rel === ".." ||
|
|
16
|
+
rel.startsWith(`..${path.sep}`) ||
|
|
17
|
+
path.isAbsolute(rel)) {
|
|
18
|
+
throw new PabstError(`${file} is outside the current directory; run pabst from the directory containing it`);
|
|
19
|
+
}
|
|
20
|
+
const specs = buildSpecs(file);
|
|
21
|
+
if (specs.length === 0)
|
|
22
|
+
continue;
|
|
23
|
+
const noExt = rel.replace(SRC_EXT, "");
|
|
24
|
+
const outFile = path.join(outRoot, noExt + ".pabst.test.ts");
|
|
25
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
26
|
+
fs.writeFileSync(outFile, emit(specs, file, outFile, seed), "utf8");
|
|
27
|
+
results.push({ sourceFile: file, outFile, propertyCount: specs.length });
|
|
28
|
+
}
|
|
29
|
+
return results;
|
|
30
|
+
}
|
package/dist/desugar.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Split `s` at depth-0 implication arrows (==>, ->, →). Returns trimmed segments. */
|
|
2
|
+
function splitTopArrows(s) {
|
|
3
|
+
const segs = [];
|
|
4
|
+
let depth = 0;
|
|
5
|
+
let start = 0;
|
|
6
|
+
let str = null;
|
|
7
|
+
for (let i = 0; i < s.length; i++) {
|
|
8
|
+
const c = s[i];
|
|
9
|
+
if (str) {
|
|
10
|
+
if (c === "\\") {
|
|
11
|
+
i++;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (c === str)
|
|
15
|
+
str = null;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
19
|
+
str = c;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (c === "(" || c === "[" || c === "{") {
|
|
23
|
+
depth++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (c === ")" || c === "]" || c === "}") {
|
|
27
|
+
depth--;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (depth !== 0)
|
|
31
|
+
continue;
|
|
32
|
+
if (s.startsWith("==>", i)) {
|
|
33
|
+
segs.push(s.slice(start, i).trim());
|
|
34
|
+
i += 2;
|
|
35
|
+
start = i + 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (s.startsWith("->", i)) {
|
|
39
|
+
segs.push(s.slice(start, i).trim());
|
|
40
|
+
i += 1;
|
|
41
|
+
start = i + 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === "→") {
|
|
45
|
+
segs.push(s.slice(start, i).trim());
|
|
46
|
+
start = i + 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
segs.push(s.slice(start).trim());
|
|
51
|
+
return segs;
|
|
52
|
+
}
|
|
53
|
+
/** Index of the `)` matching the `(` at `open`. */
|
|
54
|
+
function matchParen(s, open) {
|
|
55
|
+
let depth = 0;
|
|
56
|
+
let str = null;
|
|
57
|
+
for (let i = open; i < s.length; i++) {
|
|
58
|
+
const c = s[i];
|
|
59
|
+
if (str) {
|
|
60
|
+
if (c === "\\") {
|
|
61
|
+
i++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (c === str)
|
|
65
|
+
str = null;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
69
|
+
str = c;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (c === "(")
|
|
73
|
+
depth++;
|
|
74
|
+
else if (c === ")") {
|
|
75
|
+
depth--;
|
|
76
|
+
if (depth === 0)
|
|
77
|
+
return i;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`unbalanced parentheses in property body: ${s}`);
|
|
81
|
+
}
|
|
82
|
+
/** Desugar arrows that appear inside parentheses; pass everything else through. */
|
|
83
|
+
function desugarNested(s) {
|
|
84
|
+
let out = "";
|
|
85
|
+
let i = 0;
|
|
86
|
+
let str = null;
|
|
87
|
+
while (i < s.length) {
|
|
88
|
+
const c = s[i];
|
|
89
|
+
if (str) {
|
|
90
|
+
out += c;
|
|
91
|
+
if (c === "\\" && i + 1 < s.length) {
|
|
92
|
+
out += s[i + 1];
|
|
93
|
+
i += 2;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (c === str)
|
|
97
|
+
str = null;
|
|
98
|
+
i++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
102
|
+
str = c;
|
|
103
|
+
out += c;
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (c === "(") {
|
|
108
|
+
const close = matchParen(s, i);
|
|
109
|
+
out += "(" + desugarExpr(s.slice(i + 1, close)) + ")";
|
|
110
|
+
i = close + 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
out += c;
|
|
114
|
+
i++;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Fully desugar an expression: arrows at this level → !(P)||(Q), then recurse into parens. */
|
|
119
|
+
function desugarExpr(s) {
|
|
120
|
+
return foldImplication(splitTopArrows(s));
|
|
121
|
+
}
|
|
122
|
+
function foldImplication(segs) {
|
|
123
|
+
if (segs.length === 1)
|
|
124
|
+
return desugarNested(segs[0]);
|
|
125
|
+
return `!(${desugarNested(segs[0])}) || (${foldImplication(segs.slice(1))})`;
|
|
126
|
+
}
|
|
127
|
+
export function desugar(body) {
|
|
128
|
+
const segs = splitTopArrows(body);
|
|
129
|
+
if (segs.length === 1) {
|
|
130
|
+
return { preconditions: [], body: desugarNested(segs[0]) };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
preconditions: segs.slice(0, -1).map(desugarNested),
|
|
134
|
+
body: desugarNested(segs[segs.length - 1]),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True for TypeScript source files pabst should scan: .ts/.tsx/.mts/.cts,
|
|
3
|
+
* excluding declaration files — tsc copies JSDoc into declarations, so
|
|
4
|
+
* scanning them alongside their sources extracts every property twice.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isTsSource(file: string): boolean;
|
|
7
|
+
export interface Discovery {
|
|
8
|
+
files: string[];
|
|
9
|
+
source: "arguments" | "tsconfig.json" | "src/";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The files pabst should scan: matches of the given patterns or, with no
|
|
13
|
+
* patterns, zero-argument discovery (the tsconfig.json file list, then the
|
|
14
|
+
* src/ convention). Throws PabstError when nothing usable is found.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveFiles(patterns: string[]): Discovery;
|