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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/dist/build-spec.d.ts +2 -0
  4. package/dist/build-spec.js +46 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +119 -0
  7. package/dist/codegen.d.ts +6 -0
  8. package/dist/codegen.js +30 -0
  9. package/dist/desugar.d.ts +5 -0
  10. package/dist/desugar.js +136 -0
  11. package/dist/discover.d.ts +16 -0
  12. package/dist/discover.js +113 -0
  13. package/dist/domains.d.ts +47 -0
  14. package/dist/domains.js +130 -0
  15. package/dist/emit.d.ts +2 -0
  16. package/dist/emit.js +74 -0
  17. package/dist/envelope.d.ts +29 -0
  18. package/dist/envelope.js +28 -0
  19. package/dist/equations.d.ts +8 -0
  20. package/dist/equations.js +256 -0
  21. package/dist/errors.d.ts +9 -0
  22. package/dist/errors.js +9 -0
  23. package/dist/extract.d.ts +21 -0
  24. package/dist/extract.js +177 -0
  25. package/dist/formula-ast.d.ts +26 -0
  26. package/dist/formula-ast.js +18 -0
  27. package/dist/formula-lexer.d.ts +31 -0
  28. package/dist/formula-lexer.js +191 -0
  29. package/dist/formula-parser.d.ts +2 -0
  30. package/dist/formula-parser.js +122 -0
  31. package/dist/free-idents.d.ts +6 -0
  32. package/dist/free-idents.js +60 -0
  33. package/dist/ir.d.ts +45 -0
  34. package/dist/ir.js +1 -0
  35. package/dist/issue.d.ts +26 -0
  36. package/dist/issue.js +18 -0
  37. package/dist/lower.d.ts +12 -0
  38. package/dist/lower.js +37 -0
  39. package/dist/parse-formula.d.ts +12 -0
  40. package/dist/parse-formula.js +78 -0
  41. package/dist/prefix-parser.d.ts +7 -0
  42. package/dist/prefix-parser.js +188 -0
  43. package/dist/qualified-name.d.ts +5 -0
  44. package/dist/qualified-name.js +11 -0
  45. package/dist/range.d.ts +5 -0
  46. package/dist/range.js +152 -0
  47. package/dist/regex-guard.d.ts +28 -0
  48. package/dist/regex-guard.js +100 -0
  49. package/dist/run.d.ts +26 -0
  50. package/dist/run.js +50 -0
  51. package/dist/runtime.d.ts +25 -0
  52. package/dist/runtime.js +62 -0
  53. package/dist/seed.d.ts +7 -0
  54. package/dist/seed.js +20 -0
  55. package/package.json +67 -0
@@ -0,0 +1,113 @@
1
+ import { existsSync, globSync } from "node:fs";
2
+ import * as path from "node:path";
3
+ import ts from "typescript";
4
+ import { PabstError } from "./errors.js";
5
+ const TS_EXT = /\.(ts|tsx|mts|cts)$/;
6
+ const DECL_EXT = /\.d\.(ts|mts|cts)$/;
7
+ /**
8
+ * True for TypeScript source files pabst should scan: .ts/.tsx/.mts/.cts,
9
+ * excluding declaration files — tsc copies JSDoc into declarations, so
10
+ * scanning them alongside their sources extracts every property twice.
11
+ */
12
+ export function isTsSource(file) {
13
+ return TS_EXT.test(file) && !DECL_EXT.test(file);
14
+ }
15
+ /**
16
+ * The files pabst should scan: matches of the given patterns or, with no
17
+ * patterns, zero-argument discovery (the tsconfig.json file list, then the
18
+ * src/ convention). Throws PabstError when nothing usable is found.
19
+ */
20
+ export function resolveFiles(patterns) {
21
+ if (patterns.length > 0) {
22
+ return { files: globbedFiles(patterns), source: "arguments" };
23
+ }
24
+ return discoverFiles();
25
+ }
26
+ // A pattern that itself names declaration files (index.d.ts, types/**/*.d.ts)
27
+ // is an explicit ask for them and is honored; any other pattern drops
28
+ // declaration matches (see isTsSource) so build output sitting next to its
29
+ // sources is not scanned twice.
30
+ function globbedFiles(patterns) {
31
+ const files = [
32
+ ...new Set(patterns.flatMap((p) => DECL_EXT.test(p) ? globSync(p) : globSync(p).filter(isTsSource))),
33
+ ];
34
+ if (files.length === 0)
35
+ throw new PabstError("no matching .ts files");
36
+ return files;
37
+ }
38
+ const NO_SOURCES = 'cannot determine where your source code is; pass files or globs (e.g. pabst test "src/**/*.ts")';
39
+ // TS18003 ("No inputs were found in config file") and TS18002 ("The 'files'
40
+ // list in config file is empty") are the config saying it names no files —
41
+ // that is "no answer here", not a broken config, so discovery falls through
42
+ // to the src/ convention.
43
+ const NO_INPUTS_ERRORS = new Set([18002, 18003]);
44
+ // Diagnostics about compilerOptions — unknown option (5023), unknown option
45
+ // with a suggestion (5025), wrong value type (5024), value outside the known
46
+ // set (6046) — cannot change which files the config enumerates, and routinely
47
+ // arise from version skew between the user's TypeScript and the one pabst
48
+ // bundles (a flag or target newer than ours). The file list is still exactly
49
+ // what tsc would compile, so these are ignored.
50
+ const OPTION_ERRORS = new Set([5023, 5024, 5025, 6046]);
51
+ // Every other diagnostic (bad JSON, unresolvable extends, misspelled root
52
+ // options like "excludes", ...) can make the file list differ from what the
53
+ // user's config describes, so it is user-facing and fatal: silently testing
54
+ // different files than the config means would be worse than stopping.
55
+ function fatalError(diagnostics) {
56
+ return diagnostics.find((e) => !NO_INPUTS_ERRORS.has(e.code) && !OPTION_ERRORS.has(e.code));
57
+ }
58
+ function configError(diagnostic) {
59
+ const text = ts.flattenDiagnosticMessageText(diagnostic.messageText, " ");
60
+ return new PabstError(`tsconfig.json: ${text}`);
61
+ }
62
+ // path.relative yields ".."-led (or, across Windows drives, absolute) paths
63
+ // for files outside cwd. Generated tests are rooted in ./.pabst/, so a source
64
+ // outside the package cannot be tested from here — a monorepo neighbor's
65
+ // files are that neighbor's run, not this one's — and is dropped.
66
+ function insideCwd(rel) {
67
+ return (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
68
+ }
69
+ /**
70
+ * The files tsc would compile for ./tsconfig.json, as sorted relative paths
71
+ * filtered to scannable sources; undefined when there is no tsconfig.json,
72
+ * empty when the config resolves to no usable files.
73
+ */
74
+ function tsconfigFiles(cwd) {
75
+ const configPath = path.join(cwd, "tsconfig.json");
76
+ if (!existsSync(configPath))
77
+ return undefined;
78
+ const read = ts.readConfigFile(configPath, ts.sys.readFile);
79
+ if (read.error)
80
+ throw configError(read.error);
81
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, cwd, undefined, configPath);
82
+ const fatal = fatalError(parsed.errors);
83
+ if (fatal)
84
+ throw configError(fatal);
85
+ const files = parsed.fileNames
86
+ .map((f) => path.relative(cwd, f))
87
+ .filter(insideCwd)
88
+ .filter(isTsSource)
89
+ .sort();
90
+ // A "files" list names paths without checking them, so a stale entry comes
91
+ // back with no diagnostic; stop with a real message rather than crashing on
92
+ // the eventual read. (include-glob matches exist by construction.)
93
+ const missing = files.find((f) => !existsSync(path.resolve(cwd, f)));
94
+ if (missing) {
95
+ throw new PabstError(`tsconfig.json: file not found: ${missing}`);
96
+ }
97
+ return files;
98
+ }
99
+ /**
100
+ * Zero-argument mode: find the project's TypeScript sources. Uses the file
101
+ * list of ./tsconfig.json when it names any, then the src/ convention;
102
+ * throws PabstError when nothing is found.
103
+ */
104
+ function discoverFiles() {
105
+ const fromConfig = tsconfigFiles(process.cwd());
106
+ if (fromConfig !== undefined && fromConfig.length > 0) {
107
+ return { files: fromConfig, source: "tsconfig.json" };
108
+ }
109
+ const files = globSync("src/**/*").filter(isTsSource).sort();
110
+ if (files.length > 0)
111
+ return { files, source: "src/" };
112
+ throw new PabstError(NO_SOURCES);
113
+ }
@@ -0,0 +1,47 @@
1
+ import type { Binder, Domain, Range } from "./ir.js";
2
+ export declare const DOMAIN_TABLE: Record<Domain, string>;
3
+ export declare function isDomain(s: string): s is Domain;
4
+ export declare const MAX_SAFE = 9007199254740991n;
5
+ export interface IntBounds {
6
+ lo: bigint;
7
+ hi: bigint;
8
+ /** A finite endpoint fell outside the safe integer range, so the
9
+ * interval was intersected with it (possibly to emptiness: lo > hi). */
10
+ clamped: boolean;
11
+ }
12
+ /** The inclusive fc.integer bounds an int/nat interval lowers to: open
13
+ * endpoints fold into ±1 (fc.integer has no exclusion options), nat floors
14
+ * at 0, and the result is intersected with the safe integer range. Both
15
+ * sides are always concrete — fc.integer's implicit defaults are 32-bit,
16
+ * so a far-out explicit bound with an omitted side would crash it — and an
17
+ * unbounded (∞) side means the safe-range limit. */
18
+ export declare function intBounds(domain: "int" | "nat", range: Range): IntBounds;
19
+ /** The inclusive fc.bigInt bounds a bigint interval lowers to. An
20
+ * unbounded side stays undefined — fc.bigInt, unlike fc.integer, widens
21
+ * its default range around a far-out explicit bound. */
22
+ export declare function bigintBounds(range: Range): {
23
+ lo?: bigint;
24
+ hi?: bigint;
25
+ };
26
+ /** One endpoint of a number interval as fc.double sees it: the literal
27
+ * text emitted into the generated test, and its numeric value (used to
28
+ * validate the constraints with fc itself at parse time). */
29
+ export interface NumberBound {
30
+ lit: string;
31
+ val: number;
32
+ }
33
+ export interface NumberConstraints {
34
+ min?: NumberBound;
35
+ minExcluded: boolean;
36
+ max?: NumberBound;
37
+ maxExcluded: boolean;
38
+ }
39
+ /** The fc.double constraints a number interval lowers to. An open ∞
40
+ * endpoint becomes an excluded infinite bound, which fast-check clamps to
41
+ * ±MAX_VALUE; a closed ∞ endpoint is fc.double's default (inclusive ±∞),
42
+ * so that side is simply omitted. */
43
+ export declare function numberConstraints(range: Range): NumberConstraints;
44
+ /** The binder's guards are mutually exclusive per domain, so arbitraryFor
45
+ * takes the binder itself rather than growing a positional parameter per
46
+ * guard kind. */
47
+ export declare function arbitraryFor(binder: Pick<Binder, "domain" | "range" | "pattern">): string;
@@ -0,0 +1,130 @@
1
+ import { PabstError } from "./errors.js";
2
+ import { anchoredSource, regexGuardDomainError } from "./regex-guard.js";
3
+ export const DOMAIN_TABLE = {
4
+ int: "fc.integer()",
5
+ nat: "fc.nat()",
6
+ number: "fc.double()",
7
+ boolean: "fc.boolean()",
8
+ string: "fc.string()",
9
+ bigint: "fc.bigInt()",
10
+ };
11
+ export function isDomain(s) {
12
+ return Object.prototype.hasOwnProperty.call(DOMAIN_TABLE, s);
13
+ }
14
+ export const MAX_SAFE = 9007199254740991n;
15
+ /** The inclusive fc.integer bounds an int/nat interval lowers to: open
16
+ * endpoints fold into ±1 (fc.integer has no exclusion options), nat floors
17
+ * at 0, and the result is intersected with the safe integer range. Both
18
+ * sides are always concrete — fc.integer's implicit defaults are 32-bit,
19
+ * so a far-out explicit bound with an omitted side would crash it — and an
20
+ * unbounded (∞) side means the safe-range limit. */
21
+ export function intBounds(domain, range) {
22
+ let lo = range.min === undefined
23
+ ? -MAX_SAFE
24
+ : BigInt(range.min) + (range.minOpen ? 1n : 0n);
25
+ let hi = range.max === undefined
26
+ ? MAX_SAFE
27
+ : BigInt(range.max) - (range.maxOpen ? 1n : 0n);
28
+ if (domain === "nat" && lo < 0n)
29
+ lo = 0n;
30
+ const clamped = lo < -MAX_SAFE || lo > MAX_SAFE || hi < -MAX_SAFE || hi > MAX_SAFE;
31
+ if (lo < -MAX_SAFE)
32
+ lo = -MAX_SAFE;
33
+ if (hi > MAX_SAFE)
34
+ hi = MAX_SAFE;
35
+ return { lo, hi, clamped };
36
+ }
37
+ /** The inclusive fc.bigInt bounds a bigint interval lowers to. An
38
+ * unbounded side stays undefined — fc.bigInt, unlike fc.integer, widens
39
+ * its default range around a far-out explicit bound. */
40
+ export function bigintBounds(range) {
41
+ const lo = range.min === undefined
42
+ ? undefined
43
+ : BigInt(range.min) + (range.minOpen ? 1n : 0n);
44
+ const hi = range.max === undefined
45
+ ? undefined
46
+ : BigInt(range.max) - (range.maxOpen ? 1n : 0n);
47
+ const bounds = {};
48
+ if (lo !== undefined)
49
+ bounds.lo = lo;
50
+ if (hi !== undefined)
51
+ bounds.hi = hi;
52
+ return bounds;
53
+ }
54
+ /** The fc.double constraints a number interval lowers to. An open ∞
55
+ * endpoint becomes an excluded infinite bound, which fast-check clamps to
56
+ * ±MAX_VALUE; a closed ∞ endpoint is fc.double's default (inclusive ±∞),
57
+ * so that side is simply omitted. */
58
+ export function numberConstraints(range) {
59
+ const c = {
60
+ minExcluded: range.minOpen === true,
61
+ maxExcluded: range.maxOpen === true,
62
+ };
63
+ if (range.min !== undefined) {
64
+ c.min = { lit: range.min, val: Number(range.min) };
65
+ }
66
+ else if (range.minOpen) {
67
+ c.min = { lit: "Number.NEGATIVE_INFINITY", val: Number.NEGATIVE_INFINITY };
68
+ }
69
+ if (range.max !== undefined) {
70
+ c.max = { lit: range.max, val: Number(range.max) };
71
+ }
72
+ else if (range.maxOpen) {
73
+ c.max = { lit: "Number.POSITIVE_INFINITY", val: Number.POSITIVE_INFINITY };
74
+ }
75
+ return c;
76
+ }
77
+ /** The binder's guards are mutually exclusive per domain, so arbitraryFor
78
+ * takes the binder itself rather than growing a positional parameter per
79
+ * guard kind. */
80
+ export function arbitraryFor(binder) {
81
+ const { domain, range, pattern } = binder;
82
+ if (pattern) {
83
+ if (domain !== "string") {
84
+ // Unreachable via the parser (parseRegexGuard rejects these), kept
85
+ // as a backstop for direct callers.
86
+ throw regexGuardDomainError(domain);
87
+ }
88
+ // Safe to re-emit as a literal: the source came from a literal scan,
89
+ // so any '/' in it is escaped or inside a character class.
90
+ return `fc.stringMatching(/${anchoredSource(pattern.source)}/${pattern.flags})`;
91
+ }
92
+ if (!range)
93
+ return DOMAIN_TABLE[domain];
94
+ switch (domain) {
95
+ case "int":
96
+ case "nat": {
97
+ // A ranged nat is just a bounded integer; fc.nat has no `min`.
98
+ const { lo, hi } = intBounds(domain, range);
99
+ return `fc.integer({ min: ${lo}, max: ${hi} })`;
100
+ }
101
+ case "number": {
102
+ // Bounded fc.double still generates NaN unless told otherwise, and
103
+ // NaN satisfies no interval.
104
+ const c = numberConstraints(range);
105
+ const opts = [];
106
+ if (c.min)
107
+ opts.push(`min: ${c.min.lit}`);
108
+ if (c.minExcluded)
109
+ opts.push(`minExcluded: true`);
110
+ if (c.max)
111
+ opts.push(`max: ${c.max.lit}`);
112
+ if (c.maxExcluded)
113
+ opts.push(`maxExcluded: true`);
114
+ opts.push(`noNaN: true`);
115
+ return `fc.double({ ${opts.join(", ")} })`;
116
+ }
117
+ case "bigint": {
118
+ const { lo, hi } = bigintBounds(range);
119
+ return `fc.bigInt${render(lo === undefined ? undefined : `min: ${lo}n`, hi === undefined ? undefined : `max: ${hi}n`)}`;
120
+ }
121
+ default:
122
+ // Unreachable via the parser (parseRange rejects these), kept as a
123
+ // backstop for direct callers.
124
+ throw new PabstError(`domain '${domain}' does not support interval constraints`);
125
+ }
126
+ }
127
+ function render(...opts) {
128
+ const present = opts.filter((o) => o !== undefined);
129
+ return present.length === 0 ? "()" : `({ ${present.join(", ")} })`;
130
+ }
package/dist/emit.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { PropertySpec } from "./ir.js";
2
+ export declare function emit(specs: PropertySpec[], sourceFile: string, outFile: string, seed: number): string;
package/dist/emit.js ADDED
@@ -0,0 +1,74 @@
1
+ import * as path from "node:path";
2
+ import { arbitraryFor } from "./domains.js";
3
+ import { qualifiedName } from "./qualified-name.js";
4
+ const SRC_EXT = /\.(ts|tsx|mts|cts|js|mjs|cjs)$/;
5
+ export function emit(specs, sourceFile, outFile, seed) {
6
+ const srcAbs = path.resolve(sourceFile).replace(SRC_EXT, "");
7
+ const outDir = path.dirname(path.resolve(outFile));
8
+ let rel = path.relative(outDir, srcAbs).split(path.sep).join("/");
9
+ if (!rel.startsWith("."))
10
+ rel = "./" + rel;
11
+ const allExports = [...new Set(specs.flatMap((s) => s.freeExports))].sort();
12
+ const lines = [];
13
+ lines.push(`import { describe } from "vitest";`);
14
+ lines.push(`import { test, fc } from "@fast-check/vitest";`);
15
+ lines.push(`import { report as __pabstReport, bool as __bool } from "pabst-checker/runtime";`);
16
+ lines.push(`import * as __M from "${rel}";`);
17
+ if (allExports.length > 0)
18
+ lines.push(`const { ${allExports.join(", ")} } = __M;`);
19
+ lines.push("");
20
+ lines.push(`describe("pabst", () => {`);
21
+ // Group by class (undefined = top-level function), then by member name.
22
+ const byClass = new Map();
23
+ for (const s of specs) {
24
+ let methods = byClass.get(s.className);
25
+ if (!methods) {
26
+ methods = new Map();
27
+ byClass.set(s.className, methods);
28
+ }
29
+ const arr = methods.get(s.functionName) ?? [];
30
+ arr.push(s);
31
+ methods.set(s.functionName, arr);
32
+ }
33
+ for (const [className, methods] of byClass) {
34
+ if (className === undefined) {
35
+ for (const [fnName, fnSpecs] of methods) {
36
+ lines.push(` describe(${JSON.stringify(fnName)}, () => {`);
37
+ for (const s of fnSpecs)
38
+ lines.push(emitProp(s, sourceFile, seed, " "));
39
+ lines.push(` });`);
40
+ }
41
+ }
42
+ else {
43
+ lines.push(` describe(${JSON.stringify(className)}, () => {`);
44
+ for (const [methodName, mSpecs] of methods) {
45
+ lines.push(` describe(${JSON.stringify(methodName)}, () => {`);
46
+ for (const s of mSpecs)
47
+ lines.push(emitProp(s, sourceFile, seed, " "));
48
+ lines.push(` });`);
49
+ }
50
+ lines.push(` });`);
51
+ }
52
+ }
53
+ lines.push(`});`);
54
+ lines.push("");
55
+ return lines.join("\n");
56
+ }
57
+ function emitProp(s, sourceFile, seed, indent) {
58
+ const arbs = s.binders.map((b) => arbitraryFor(b)).join(", ");
59
+ const vars = s.binders.map((b) => b.varName).join(", ");
60
+ const varNames = s.binders.map((b) => JSON.stringify(b.varName)).join(", ");
61
+ const name = JSON.stringify(s.name);
62
+ const file = JSON.stringify(sourceFile);
63
+ const fn = JSON.stringify(qualifiedName(s.functionName, s.className, s.isStatic));
64
+ const reporter = `(d) => __pabstReport(${file}, ${fn}, ${name}, [${varNames}], d)`;
65
+ const params = `{ seed: ${seed}, reporter: ${reporter} }`;
66
+ const out = [];
67
+ out.push(`${indent}test.prop([${arbs}], ${params})(${name}, (${vars}) => {`);
68
+ for (const p of s.preconditions)
69
+ out.push(`${indent} fc.pre(${p});`);
70
+ out.push(`${indent} const __r = (${s.body});`);
71
+ out.push(`${indent} return __r;`);
72
+ out.push(`${indent}});`);
73
+ return out.join("\n");
74
+ }
@@ -0,0 +1,29 @@
1
+ import { type Issue, type Envelope } from "./issue.js";
2
+ /** The subset of vitest's JSON reporter output the envelope consumes. */
3
+ export interface AssertionResult {
4
+ status: string;
5
+ failureMessages: string[];
6
+ }
7
+ export interface FileResult {
8
+ status?: string;
9
+ message?: string;
10
+ assertionResults: AssertionResult[];
11
+ }
12
+ export interface VitestJson {
13
+ numPassedTests: number;
14
+ numFailedTests: number;
15
+ success: boolean;
16
+ testResults: FileResult[];
17
+ }
18
+ /** Run-level metadata the CLI captures, independent of the vitest run. */
19
+ export interface RunMeta {
20
+ version: string;
21
+ startedAt: string;
22
+ cwd: string;
23
+ seed: number;
24
+ generated: number;
25
+ }
26
+ /** Parse every failed assertion's tagged payload into an Issue. */
27
+ export declare function collectIssues(json: VitestJson): Issue[];
28
+ /** Assemble the full run envelope from metadata and a parsed vitest run. */
29
+ export declare function buildEnvelope(meta: RunMeta, json: VitestJson): Envelope;
@@ -0,0 +1,28 @@
1
+ import { parseIssue } from "./issue.js";
2
+ /** Parse every failed assertion's tagged payload into an Issue. */
3
+ export function collectIssues(json) {
4
+ const issues = [];
5
+ for (const file of json.testResults ?? []) {
6
+ for (const a of file.assertionResults ?? []) {
7
+ if (a.status !== "failed")
8
+ continue;
9
+ const issue = parseIssue(a.failureMessages[0] ?? "");
10
+ if (issue)
11
+ issues.push(issue);
12
+ }
13
+ }
14
+ return issues;
15
+ }
16
+ /** Assemble the full run envelope from metadata and a parsed vitest run. */
17
+ export function buildEnvelope(meta, json) {
18
+ return {
19
+ version: meta.version,
20
+ startedAt: meta.startedAt,
21
+ cwd: meta.cwd,
22
+ seed: meta.seed,
23
+ generated: meta.generated,
24
+ passed: json.numPassedTests,
25
+ failed: json.numFailedTests,
26
+ issues: collectIssues(json),
27
+ };
28
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Validate an atom's JS and desugar its equation (see README, "Equations"):
3
+ * a depth-0 `A ≡ B` becomes `Object.is(A, B)` and `A ≢ B` becomes
4
+ * `!Object.is(A, B)`. The glyphs are available only at the atom's top
5
+ * level — in nested positions (callbacks, arguments, template
6
+ * substitutions) call Object.is directly.
7
+ */
8
+ export declare function desugarEquations(text: string): string;