tslaws 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/api.d.ts +10 -0
- package/dist/api.js +7 -0
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +2 -6
- package/dist/check.d.ts +2 -0
- package/dist/check.js +133 -41
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +149 -54
- package/dist/compile.d.ts +12 -0
- package/dist/compile.js +207 -6
- package/dist/coverage.d.ts +13 -1
- package/dist/coverage.js +16 -23
- package/dist/derived.d.ts +8 -0
- package/dist/derived.js +52 -0
- package/dist/e2e.d.ts +0 -9
- package/dist/e2e.js +0 -20
- package/dist/generators.d.ts +8 -8
- package/dist/generators.js +59 -99
- package/dist/globs.d.ts +3 -0
- package/dist/globs.js +24 -0
- package/dist/init.js +5 -5
- package/dist/lawFiles.d.ts +21 -0
- package/dist/lawFiles.js +95 -0
- package/dist/lawKinds.d.ts +38 -0
- package/dist/lawKinds.js +210 -0
- package/dist/lawNames.d.ts +6 -0
- package/dist/lawNames.js +22 -0
- package/dist/list.d.ts +22 -0
- package/dist/list.js +36 -0
- package/dist/lock.d.ts +16 -3
- package/dist/lock.js +23 -4
- package/dist/lower.d.ts +10 -0
- package/dist/lower.js +178 -0
- package/dist/mcp.d.ts +2 -8
- package/dist/mcp.js +40 -61
- package/dist/migrate/arbitraries.d.ts +23 -0
- package/dist/migrate/arbitraries.js +202 -0
- package/dist/migrate/convert.d.ts +20 -0
- package/dist/migrate/convert.js +915 -0
- package/dist/migrate/framework.d.ts +48 -0
- package/dist/migrate/framework.js +249 -0
- package/dist/migrate/index.d.ts +16 -0
- package/dist/migrate/index.js +155 -0
- package/dist/migrate/report.d.ts +35 -0
- package/dist/migrate/report.js +40 -0
- package/dist/migrate/source.d.ts +42 -0
- package/dist/migrate/source.js +163 -0
- package/dist/migrate/tests.d.ts +44 -0
- package/dist/migrate/tests.js +184 -0
- package/dist/plan.d.ts +50 -0
- package/dist/plan.js +118 -0
- package/dist/pool.d.ts +21 -0
- package/dist/pool.js +54 -0
- package/dist/promote.d.ts +30 -2
- package/dist/promote.js +133 -7
- package/dist/property.d.ts +12 -26
- package/dist/property.js +33 -74
- package/dist/propertyRun.d.ts +24 -0
- package/dist/propertyRun.js +56 -0
- package/dist/propertyWorker.js +7 -9
- package/dist/proposals.d.ts +43 -0
- package/dist/proposals.js +192 -0
- package/dist/regressions.js +23 -2
- package/dist/smt.js +9 -8
- package/dist/types.d.ts +2 -0
- package/hooks/AGENTS.snippet.md +2 -2
- package/package.json +8 -6
package/dist/promote.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
// `tslaws promote`: the pure core. A person moves proposed laws into the accepted file; the lock is not touched here.
|
|
2
|
-
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, posix, relative } from 'node:path';
|
|
4
4
|
import ts from 'typescript';
|
|
5
|
-
|
|
5
|
+
import { ignoredDirs, namesLawsFile } from "./lawFiles.js";
|
|
6
|
+
import { directProjectImports } from "./lawKinds.js";
|
|
7
|
+
import { acceptedLawName, isProposedLawFile } from "./lawNames.js";
|
|
8
|
+
import { existingProposal, importsForTarget, ProposalError } from "./proposals.js";
|
|
9
|
+
/**
|
|
10
|
+
* `foo.proposed.laws.ts` -> `foo.laws.ts`, `foo.browser.proposed.laws.ts` -> `foo.browser.laws.ts` and
|
|
11
|
+
* `foo.e2e.proposed.laws.ts` -> `foo.e2e.laws.ts`, so a proposal lands in a file of its own kind; undefined for any other name.
|
|
12
|
+
*/
|
|
6
13
|
export function acceptedPathFor(proposed) {
|
|
7
|
-
return proposed
|
|
8
|
-
? `${proposed.slice(0, -'.proposed.laws.ts'.length)}.laws.ts`
|
|
9
|
-
: undefined;
|
|
14
|
+
return isProposedLawFile(proposed) ? acceptedLawName(proposed) : undefined;
|
|
10
15
|
}
|
|
11
16
|
/**
|
|
12
|
-
* Finds the import statements and the `export law ...` declarations
|
|
17
|
+
* Finds the import statements and the laws of a laws file, as `export law ...` declarations or as `law('name', ...)` calls. The keyword is not standard TypeScript, so
|
|
13
18
|
* this scans tokens with the TypeScript scanner (strings, comments and templates are skipped correctly) and matches
|
|
14
19
|
* brackets: a law ends at its closing `}` or at a `;` at depth 0.
|
|
15
20
|
*/
|
|
@@ -107,8 +112,37 @@ export function parseLawFile(text) {
|
|
|
107
112
|
kind = scanner.scan();
|
|
108
113
|
}
|
|
109
114
|
finish(text.length);
|
|
115
|
+
for (const call of callLaws(text))
|
|
116
|
+
if (!laws.some((l) => call.start >= l.start && call.start < l.end))
|
|
117
|
+
laws.push(call);
|
|
118
|
+
laws.sort((a, b) => a.start - b.start);
|
|
110
119
|
return { imports, laws };
|
|
111
120
|
}
|
|
121
|
+
/** The call-style laws: top-level `law('name', ...)` and `law.proved('name', ...)` statements, found in the syntax tree. */
|
|
122
|
+
function callLaws(text) {
|
|
123
|
+
const source = ts.createSourceFile('laws.ts', text, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
|
|
124
|
+
const found = [];
|
|
125
|
+
for (const statement of source.statements) {
|
|
126
|
+
if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression))
|
|
127
|
+
continue;
|
|
128
|
+
const { expression: callee, arguments: args } = statement.expression;
|
|
129
|
+
const isLaw = (ts.isIdentifier(callee) && callee.text === 'law') ||
|
|
130
|
+
(ts.isPropertyAccessExpression(callee) &&
|
|
131
|
+
ts.isIdentifier(callee.expression) &&
|
|
132
|
+
callee.expression.text === 'law' &&
|
|
133
|
+
callee.name.text === 'proved');
|
|
134
|
+
const name = args[0];
|
|
135
|
+
if (!isLaw || name === undefined || !(ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)))
|
|
136
|
+
continue;
|
|
137
|
+
found.push({
|
|
138
|
+
name: name.text,
|
|
139
|
+
text: text.slice(statement.getStart(source), statement.end),
|
|
140
|
+
start: statement.getStart(source),
|
|
141
|
+
end: statement.end,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
112
146
|
const squash = (s) => s.replace(/\s+/g, ' ').trim();
|
|
113
147
|
/** Joins the import statements, dropping repeats by whitespace-insensitive text. */
|
|
114
148
|
export function dedupeImports(existing, added) {
|
|
@@ -215,3 +249,95 @@ export async function promoteFiles(host, proposedFiles) {
|
|
|
215
249
|
host.out('\nThe lock is unchanged. Review the diff, then run `tslaws accept` to record these laws.\n');
|
|
216
250
|
return promoted;
|
|
217
251
|
}
|
|
252
|
+
/** The path `promote` suggests for a proposal: beside the source it imports, else under `laws/`. */
|
|
253
|
+
export function defaultTarget(cwd, proposal) {
|
|
254
|
+
const name = posix
|
|
255
|
+
.basename(proposal)
|
|
256
|
+
.replace(/\.(?:ts|mts|cts)$/, '')
|
|
257
|
+
.replace(/\.laws$/, '');
|
|
258
|
+
const source = directProjectImports(cwd, proposal).find((file) => !file.startsWith('.tslaws/'));
|
|
259
|
+
const dir = source === undefined ? 'laws' : posix.dirname(source);
|
|
260
|
+
return `${dir === '.' ? '' : `${dir}/`}${name}.laws.ts`;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A path a promoted proposal may be written to, as a project-relative slash path: inside the project, a name laws
|
|
264
|
+
* files have (`*.laws.ts`, or a path a `laws` glob picks), not in a reserved directory, and not through a link.
|
|
265
|
+
*/
|
|
266
|
+
export function promotionTarget(cwd, to) {
|
|
267
|
+
if (isAbsolute(to))
|
|
268
|
+
throw new ProposalError(`'${to}' must be relative to the project root`);
|
|
269
|
+
const rel = posix.normalize(to.replaceAll('\\', '/'));
|
|
270
|
+
if (rel.startsWith('..') || rel === '.' || rel.startsWith('/'))
|
|
271
|
+
throw new ProposalError(`'${to}' is outside the project`);
|
|
272
|
+
if (rel.split('/').some((part) => ignoredDirs.has(part)))
|
|
273
|
+
throw new ProposalError(`'${to}' is in a reserved directory (node_modules, .tslaws or .git)`);
|
|
274
|
+
if (!namesLawsFile(cwd, rel))
|
|
275
|
+
throw new ProposalError(`'${to}' would not be found as a laws file: name it *.laws.ts, or list a glob for it in "laws" in tslaws.json`);
|
|
276
|
+
const abs = join(cwd, rel);
|
|
277
|
+
let probe = dirname(abs);
|
|
278
|
+
while (!existsSync(probe))
|
|
279
|
+
probe = dirname(probe);
|
|
280
|
+
const back = relative(realpathSync(cwd), realpathSync(probe));
|
|
281
|
+
if (back.startsWith('..') || isAbsolute(back))
|
|
282
|
+
throw new ProposalError(`'${to}' is outside the project`);
|
|
283
|
+
try {
|
|
284
|
+
if (lstatSync(abs).isSymbolicLink())
|
|
285
|
+
throw new ProposalError(`'${to}' is a link`);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (error instanceof ProposalError)
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
return rel;
|
|
292
|
+
}
|
|
293
|
+
const plural = (n) => `${n} law${n === 1 ? '' : 's'}`;
|
|
294
|
+
/**
|
|
295
|
+
* Moves one proposal into the tree. Shows each law with its result, asks the person to confirm the target path (the
|
|
296
|
+
* default, or `to` when given, or another typed path), rewrites the proposal's relative imports for the new directory,
|
|
297
|
+
* merges into the target if it already exists, and removes the proposal. The lock is never written: the laws stay
|
|
298
|
+
* unaccepted, and `check` fails them as not in the lock until a person runs `tslaws accept`.
|
|
299
|
+
*/
|
|
300
|
+
export async function promoteProposal(host, file, to) {
|
|
301
|
+
const { rel, abs } = existingProposal(host.cwd, file);
|
|
302
|
+
const text = readFileSync(abs, 'utf8');
|
|
303
|
+
const parsed = parseLawFile(text);
|
|
304
|
+
if (parsed.laws.length === 0) {
|
|
305
|
+
host.out(`${rel}: no laws\n`);
|
|
306
|
+
return {};
|
|
307
|
+
}
|
|
308
|
+
const results = await host.checkFile(rel);
|
|
309
|
+
for (const law of parsed.laws)
|
|
310
|
+
host.out(`\n${rel}: ${law.name} [${results.get(law.name) ?? 'not checked'}]\n${law.text.trim()}\n`);
|
|
311
|
+
const suggested = to ?? defaultTarget(host.cwd, rel);
|
|
312
|
+
const answer = (await host.ask(`\nmove ${plural(parsed.laws.length)} to ${suggested}?\nEnter accepts that path, another path replaces it, n cancels: `)).trim();
|
|
313
|
+
if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') {
|
|
314
|
+
host.out('nothing moved\n');
|
|
315
|
+
return {};
|
|
316
|
+
}
|
|
317
|
+
let target;
|
|
318
|
+
try {
|
|
319
|
+
target = promotionTarget(host.cwd, answer === '' || answer.toLowerCase() === 'y' ? suggested : answer);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
host.out(`${error instanceof Error ? error.message : String(error)}; nothing moved\n`);
|
|
323
|
+
return {};
|
|
324
|
+
}
|
|
325
|
+
const targetPath = join(host.cwd, target);
|
|
326
|
+
const existing = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined;
|
|
327
|
+
const toDir = posix.dirname(target);
|
|
328
|
+
const moved = parseLawFile(importsForTarget(text, toDir === '.' ? '' : toDir));
|
|
329
|
+
let built;
|
|
330
|
+
try {
|
|
331
|
+
built = buildAccepted(existing, moved.imports, moved.laws);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
host.out(`${target}: ${error instanceof Error ? error.message : String(error)}; nothing moved\n`);
|
|
335
|
+
return {};
|
|
336
|
+
}
|
|
337
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
338
|
+
writeFileSync(targetPath, built);
|
|
339
|
+
rmSync(abs);
|
|
340
|
+
host.out(`${plural(parsed.laws.length)} moved to ${target}; ${rel} removed\n` +
|
|
341
|
+
'The lock is unchanged: until a person runs `tslaws accept`, `tslaws check` fails these laws as not in the lock.\n');
|
|
342
|
+
return { target };
|
|
343
|
+
}
|
package/dist/property.d.ts
CHANGED
|
@@ -1,39 +1,25 @@
|
|
|
1
|
-
import * as fc from 'fast-check';
|
|
1
|
+
import type * as fc from 'fast-check';
|
|
2
2
|
import ts from 'typescript';
|
|
3
|
+
import { type Plan } from './plan.ts';
|
|
4
|
+
import { type PropertyOutcome } from './propertyRun.ts';
|
|
3
5
|
import type { Shape } from './smt.ts';
|
|
4
6
|
import type { CheckOptions, ManifestLaw } from './types.ts';
|
|
7
|
+
export { isDiscard, jsPath, runLaw, runProperty, summarise, type PropertyOutcome } from './propertyRun.ts';
|
|
5
8
|
export interface LawTypes {
|
|
6
9
|
generators: fc.Arbitrary<unknown>[];
|
|
10
|
+
/** The generators as data, for a worker that does not load TypeScript. */
|
|
11
|
+
plans: Plan[];
|
|
7
12
|
shapes: Shape[];
|
|
8
13
|
}
|
|
9
14
|
/** Every declaration file the compiler emitted: the laws' own do not import the code they are about. */
|
|
10
15
|
export declare function declarationFiles(outDir: string): string[];
|
|
11
16
|
export declare function dtsPath(outDir: string, file: string): string;
|
|
12
|
-
export declare function jsPath(outDir: string, file: string): string;
|
|
13
17
|
/** What the prover models of `type`: numbers, `int`s, bigints, strings, booleans, arrays and records of them. */
|
|
14
18
|
export declare function shapeOf(checker: ts.TypeChecker, type: ts.Type): Shape;
|
|
15
|
-
|
|
16
|
-
export declare function
|
|
17
|
-
/** Runs `call` on generated inputs. `call` may be async (a law that waits, or one that runs in a browser). */
|
|
18
|
-
export declare function runProperty(call: (...args: unknown[]) => unknown, generators: fc.Arbitrary<unknown>[], options: CheckOptions): Promise<fc.RunDetails<unknown[]>>;
|
|
19
|
-
export declare const runLaw: typeof runProperty;
|
|
19
|
+
/** `cwd` is given when `program` is the one that compiled the laws, whose source files hold the types the declarations would. */
|
|
20
|
+
export declare function typesOf(program: ts.Program, outDir: string, law: ManifestLaw, cwd?: string): LawTypes;
|
|
20
21
|
export declare const compilerOptions: ts.CompilerOptions;
|
|
21
|
-
/**
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
seed: number;
|
|
26
|
-
/** Absent when the law failed because `assume` discarded nearly every input. */
|
|
27
|
-
counterexample?: string;
|
|
28
|
-
message?: string;
|
|
29
|
-
/** The stack of the error the law threw, to say which function threw. */
|
|
30
|
-
stack?: string;
|
|
31
|
-
screenshot?: string;
|
|
32
|
-
/** The counterexample in the regression corpus's encoding; absent when it cannot be stored. */
|
|
33
|
-
encoded?: string;
|
|
34
|
-
/** Whether the counterexample is one that was replayed from the corpus. */
|
|
35
|
-
regression?: boolean;
|
|
36
|
-
}
|
|
37
|
-
export declare function summarise(details: fc.RunDetails<unknown[]>, options?: CheckOptions): PropertyOutcome;
|
|
38
|
-
/** Runs the property test of `law` in a worker, giving up on it after `options.propertyTimeoutMs`. */
|
|
39
|
-
export declare function runLawGuarded(outDir: string, law: ManifestLaw, options: CheckOptions): Promise<PropertyOutcome | 'timeout'>;
|
|
22
|
+
/** Starts `n` property workers ahead of the first law, so they load while the code compiles. */
|
|
23
|
+
export declare function warmWorkers(n: number): void;
|
|
24
|
+
/** Runs the property test of `law` in a pooled worker, giving up on it after `options.propertyTimeoutMs`. */
|
|
25
|
+
export declare function runLawGuarded(outDir: string, law: ManifestLaw, options: CheckOptions, plans: Plan[]): Promise<PropertyOutcome | 'timeout'>;
|
package/dist/property.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Worker } from 'node:worker_threads';
|
|
2
2
|
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import * as fc from 'fast-check';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
5
4
|
import ts from 'typescript';
|
|
6
|
-
import { GeneratorFactory
|
|
7
|
-
import {
|
|
5
|
+
import { GeneratorFactory } from "./generators.js";
|
|
6
|
+
import { arbitraryOf, UnsupportedType } from "./plan.js";
|
|
7
|
+
import { WorkerPool } from "./pool.js";
|
|
8
|
+
export { isDiscard, jsPath, runLaw, runProperty, summarise } from "./propertyRun.js";
|
|
8
9
|
/** Every declaration file the compiler emitted: the laws' own do not import the code they are about. */
|
|
9
10
|
export function declarationFiles(outDir) {
|
|
10
11
|
const found = [];
|
|
@@ -25,9 +26,6 @@ export function declarationFiles(outDir) {
|
|
|
25
26
|
export function dtsPath(outDir, file) {
|
|
26
27
|
return join(outDir, file.replace(/\.(ts|tsx|mts|cts)$/, '.d.ts'));
|
|
27
28
|
}
|
|
28
|
-
export function jsPath(outDir, file) {
|
|
29
|
-
return join(outDir, file.replace(/\.(ts|tsx|mts|cts)$/, '.js'));
|
|
30
|
-
}
|
|
31
29
|
/** What the prover models of `type`: numbers, `int`s, bigints, strings, booleans, arrays and records of them. */
|
|
32
30
|
export function shapeOf(checker, type) {
|
|
33
31
|
if ((type.aliasSymbol ?? type.getSymbol())?.getName() === 'int' &&
|
|
@@ -61,22 +59,25 @@ export function shapeOf(checker, type) {
|
|
|
61
59
|
}
|
|
62
60
|
return { fields };
|
|
63
61
|
}
|
|
64
|
-
|
|
62
|
+
/** `cwd` is given when `program` is the one that compiled the laws, whose source files hold the types the declarations would. */
|
|
63
|
+
export function typesOf(program, outDir, law, cwd) {
|
|
65
64
|
const checker = program.getTypeChecker();
|
|
66
|
-
const source =
|
|
65
|
+
const source = cwd === undefined
|
|
66
|
+
? program.getSourceFile(dtsPath(outDir, law.file))
|
|
67
|
+
: program.getSourceFile(resolve(cwd, law.file));
|
|
67
68
|
if (source === undefined)
|
|
68
69
|
throw new UnsupportedType(`no declaration output for ${law.file}`);
|
|
69
70
|
const moduleSymbol = checker.getSymbolAtLocation(source);
|
|
70
71
|
const exported = moduleSymbol === undefined
|
|
71
72
|
? undefined
|
|
72
|
-
: checker.getExportsOfModule(moduleSymbol).find((s) => s.getName() === law.name);
|
|
73
|
+
: checker.getExportsOfModule(moduleSymbol).find((s) => s.getName() === (law.symbol ?? law.name));
|
|
73
74
|
if (exported === undefined)
|
|
74
75
|
throw new UnsupportedType(`law ${law.name} is not in the declaration output`);
|
|
75
76
|
const signature = checker.getTypeOfSymbol(exported).getCallSignatures()[0];
|
|
76
77
|
if (signature === undefined)
|
|
77
78
|
throw new UnsupportedType(`law ${law.name} is not a function`);
|
|
78
79
|
const factory = new GeneratorFactory(checker);
|
|
79
|
-
const
|
|
80
|
+
const plans = [];
|
|
80
81
|
const shapes = [];
|
|
81
82
|
for (const parameter of signature.getParameters()) {
|
|
82
83
|
const declaration = parameter.valueDeclaration ?? parameter.declarations?.[0];
|
|
@@ -85,35 +86,10 @@ export function typesOf(program, outDir, law) {
|
|
|
85
86
|
const type = checker.getTypeOfSymbolAtLocation(parameter, declaration);
|
|
86
87
|
shapes.push(shapeOf(checker, type));
|
|
87
88
|
const optional = (parameter.flags & ts.SymbolFlags.Optional) !== 0;
|
|
88
|
-
|
|
89
|
+
plans.push(factory.plan(optional ? checker.getNonNullableType(type) : type));
|
|
89
90
|
}
|
|
90
|
-
return { generators, shapes };
|
|
91
|
-
}
|
|
92
|
-
export function isDiscard(error) {
|
|
93
|
-
return error instanceof Error && error.name === 'LawDiscard';
|
|
94
|
-
}
|
|
95
|
-
/** Runs `call` on generated inputs. `call` may be async (a law that waits, or one that runs in a browser). */
|
|
96
|
-
export async function runProperty(call, generators, options) {
|
|
97
|
-
const property = fc.asyncProperty(...generators, async (...args) => {
|
|
98
|
-
try {
|
|
99
|
-
return (await call(...args)) !== false;
|
|
100
|
-
}
|
|
101
|
-
catch (error) {
|
|
102
|
-
if (isDiscard(error)) {
|
|
103
|
-
fc.pre(false);
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
throw error;
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
return (await fc.check(property, {
|
|
110
|
-
numRuns: options.runs,
|
|
111
|
-
seed: options.seed,
|
|
112
|
-
maxSkipsPerRun: 100,
|
|
113
|
-
...(options.regressionExamples?.length ? { examples: options.regressionExamples.map(decodeExample) } : {}),
|
|
114
|
-
}));
|
|
91
|
+
return { generators: plans.map(arbitraryOf), plans, shapes };
|
|
115
92
|
}
|
|
116
|
-
export const runLaw = runProperty;
|
|
117
93
|
export const compilerOptions = {
|
|
118
94
|
skipLibCheck: true,
|
|
119
95
|
strict: true,
|
|
@@ -122,52 +98,35 @@ export const compilerOptions = {
|
|
|
122
98
|
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
123
99
|
types: [],
|
|
124
100
|
};
|
|
125
|
-
|
|
126
|
-
const message = details.errorInstance instanceof Error ? details.errorInstance.message : undefined;
|
|
127
|
-
let encoded;
|
|
128
|
-
if (details.failed && details.counterexample !== null) {
|
|
129
|
-
try {
|
|
130
|
-
encoded = encodeExample(details.counterexample);
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
// Not storable (a class instance, a function): it is reported but not kept.
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
encoded,
|
|
138
|
-
regression: encoded !== undefined && options?.regressionExamples?.includes(encoded) === true,
|
|
139
|
-
failed: details.failed,
|
|
140
|
-
numRuns: details.numRuns,
|
|
141
|
-
seed: details.seed,
|
|
142
|
-
counterexample: details.counterexample === null ? undefined : fc.stringify(details.counterexample),
|
|
143
|
-
message,
|
|
144
|
-
stack: details.errorInstance instanceof Error ? details.errorInstance.stack : undefined,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
let worker;
|
|
148
|
-
/** Runs the property test of `law` in a worker, giving up on it after `options.propertyTimeoutMs`. */
|
|
149
|
-
export function runLawGuarded(outDir, law, options) {
|
|
101
|
+
const workers = new WorkerPool(() => {
|
|
150
102
|
const ext = import.meta.url.endsWith('.ts') ? '.ts' : '.js';
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
103
|
+
const worker = new Worker(new URL(`./propertyWorker${ext}`, import.meta.url));
|
|
104
|
+
worker.unref();
|
|
105
|
+
return worker;
|
|
106
|
+
});
|
|
107
|
+
/** Starts `n` property workers ahead of the first law, so they load while the code compiles. */
|
|
108
|
+
export function warmWorkers(n) {
|
|
109
|
+
workers.warm(n);
|
|
110
|
+
}
|
|
111
|
+
/** Runs the property test of `law` in a pooled worker, giving up on it after `options.propertyTimeoutMs`. */
|
|
112
|
+
export async function runLawGuarded(outDir, law, options, plans) {
|
|
113
|
+
const current = await workers.acquire();
|
|
156
114
|
return new Promise((resolve, reject) => {
|
|
157
115
|
const timer = setTimeout(() => {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
void current.terminate();
|
|
116
|
+
current.off('message', onMessage);
|
|
117
|
+
workers.discard(current);
|
|
161
118
|
resolve('timeout');
|
|
162
119
|
}, options.propertyTimeoutMs ?? 30000);
|
|
163
|
-
|
|
120
|
+
const onMessage = (message) => {
|
|
164
121
|
clearTimeout(timer);
|
|
122
|
+
workers.release(current);
|
|
165
123
|
if (message.ok !== undefined)
|
|
166
124
|
resolve(message.ok);
|
|
167
125
|
else
|
|
168
126
|
reject(new Error(message.error));
|
|
169
|
-
}
|
|
127
|
+
};
|
|
128
|
+
current.once('message', onMessage);
|
|
170
129
|
// Functions cannot cross to the worker; it needs only the numbers.
|
|
171
|
-
current.postMessage({ outDir, law, options: { ...options, onEvent: undefined } });
|
|
130
|
+
current.postMessage({ outDir, law, plans, options: { ...options, onEvent: undefined } });
|
|
172
131
|
});
|
|
173
132
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as fc from 'fast-check';
|
|
2
|
+
import type { CheckOptions } from './types.ts';
|
|
3
|
+
export declare function jsPath(outDir: string, file: string): string;
|
|
4
|
+
export declare function isDiscard(error: unknown): boolean;
|
|
5
|
+
/** Runs `call` on generated inputs. `call` may be async (a law that waits, or one that runs in a browser). */
|
|
6
|
+
export declare function runProperty(call: (...args: unknown[]) => unknown, generators: fc.Arbitrary<unknown>[], options: CheckOptions): Promise<fc.RunDetails<unknown[]>>;
|
|
7
|
+
export declare const runLaw: typeof runProperty;
|
|
8
|
+
/** The outcome of a law's property test, without the generated values' prototypes so it can cross a thread. */
|
|
9
|
+
export interface PropertyOutcome {
|
|
10
|
+
failed: boolean;
|
|
11
|
+
numRuns: number;
|
|
12
|
+
seed: number;
|
|
13
|
+
/** Absent when the law failed because `assume` discarded nearly every input. */
|
|
14
|
+
counterexample?: string;
|
|
15
|
+
message?: string;
|
|
16
|
+
/** The stack of the error the law threw, to say which function threw. */
|
|
17
|
+
stack?: string;
|
|
18
|
+
screenshot?: string;
|
|
19
|
+
/** The counterexample in the regression corpus's encoding; absent when it cannot be stored. */
|
|
20
|
+
encoded?: string;
|
|
21
|
+
/** Whether the counterexample is one that was replayed from the corpus. */
|
|
22
|
+
regression?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function summarise(details: fc.RunDetails<unknown[]>, options?: CheckOptions): PropertyOutcome;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Running a property test, without the TypeScript compiler: this is all a property worker loads.
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import * as fc from 'fast-check';
|
|
4
|
+
import { decodeExample, encodeExample } from "./regressions.js";
|
|
5
|
+
export function jsPath(outDir, file) {
|
|
6
|
+
return join(outDir, file.replace(/\.(ts|tsx|mts|cts)$/, '.js'));
|
|
7
|
+
}
|
|
8
|
+
export function isDiscard(error) {
|
|
9
|
+
return error instanceof Error && error.name === 'LawDiscard';
|
|
10
|
+
}
|
|
11
|
+
/** Runs `call` on generated inputs. `call` may be async (a law that waits, or one that runs in a browser). */
|
|
12
|
+
export async function runProperty(call, generators, options) {
|
|
13
|
+
// fast-check needs at least one arbitrary: a law without parameters gets a stand-in and runs once.
|
|
14
|
+
const none = generators.length === 0;
|
|
15
|
+
const property = fc.asyncProperty(...(none ? [fc.constant(undefined)] : generators), async (...args) => {
|
|
16
|
+
try {
|
|
17
|
+
return (await call(...(none ? [] : args))) !== false;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (isDiscard(error)) {
|
|
21
|
+
fc.pre(false);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return (await fc.check(property, {
|
|
28
|
+
numRuns: none ? 1 : options.runs,
|
|
29
|
+
seed: options.seed,
|
|
30
|
+
maxSkipsPerRun: 100,
|
|
31
|
+
...(!none && options.regressionExamples?.length ? { examples: options.regressionExamples.map(decodeExample) } : {}),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
export const runLaw = runProperty;
|
|
35
|
+
export function summarise(details, options) {
|
|
36
|
+
const message = details.errorInstance instanceof Error ? details.errorInstance.message : undefined;
|
|
37
|
+
let encoded;
|
|
38
|
+
if (details.failed && details.counterexample !== null) {
|
|
39
|
+
try {
|
|
40
|
+
encoded = encodeExample(details.counterexample);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Not storable (a class instance, a function): it is reported but not kept.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
encoded,
|
|
48
|
+
regression: encoded !== undefined && options?.regressionExamples?.includes(encoded) === true,
|
|
49
|
+
failed: details.failed,
|
|
50
|
+
numRuns: details.numRuns,
|
|
51
|
+
seed: details.seed,
|
|
52
|
+
counterexample: details.counterexample === null ? undefined : fc.stringify(details.counterexample),
|
|
53
|
+
message,
|
|
54
|
+
stack: details.errorInstance instanceof Error ? details.errorInstance.stack : undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/propertyWorker.js
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { parentPort } from 'node:worker_threads';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
import { arbitraryOf } from "./plan.js";
|
|
4
|
+
import { jsPath, runLaw, summarise } from "./propertyRun.js";
|
|
5
5
|
// Property tests run here so that a law over code that never terminates costs a timeout, not the whole check.
|
|
6
|
+
// The main process read the types and sends the generators as plans, so a worker never loads the TypeScript compiler.
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
7
|
-
let programOf;
|
|
8
8
|
parentPort.on('message', async (request) => {
|
|
9
9
|
try {
|
|
10
|
-
const { outDir, law, options } = request;
|
|
11
|
-
|
|
12
|
-
programOf = { outDir, program: ts.createProgram(declarationFiles(outDir), compilerOptions) };
|
|
13
|
-
const types = typesOf(programOf.program, outDir, law);
|
|
10
|
+
const { outDir, law, plans, options } = request;
|
|
11
|
+
const generators = plans.map(arbitraryOf);
|
|
14
12
|
// The worker outlives a check, and the code may have been recompiled since the last one.
|
|
15
13
|
for (const loaded of Object.keys(require.cache))
|
|
16
14
|
if (loaded.startsWith(outDir))
|
|
17
15
|
delete require.cache[loaded];
|
|
18
|
-
const fn = require(jsPath(outDir, law.file))[law.name];
|
|
19
|
-
parentPort.postMessage({ ok: summarise(await runLaw(fn,
|
|
16
|
+
const fn = require(jsPath(outDir, law.file))[law.symbol ?? law.name];
|
|
17
|
+
parentPort.postMessage({ ok: summarise(await runLaw(fn, generators, options), options) });
|
|
20
18
|
}
|
|
21
19
|
catch (error) {
|
|
22
20
|
parentPort.postMessage({ error: error.message });
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** A refusal with a message for the person or agent that asked. */
|
|
2
|
+
export declare class ProposalError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The proposals directory, checked on the real file system: neither `.tslaws` nor `proposals` may be a link (or a
|
|
6
|
+
* junction) that leads out of the project. Creates the directory when `create` is set.
|
|
7
|
+
*/
|
|
8
|
+
export declare function proposalsDirectory(cwd: string, create: boolean): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* The project-relative path of a proposal: `name.ts` or `.tslaws/proposals/name.ts`. Anything that names another
|
|
11
|
+
* directory, climbs out, is absolute or is not a plain `.ts` file name is refused.
|
|
12
|
+
*/
|
|
13
|
+
export declare function proposalName(file: string): string;
|
|
14
|
+
/** The absolute path of an existing proposal for reading or removing; refuses links the same way. */
|
|
15
|
+
export declare function existingProposal(cwd: string, file: string): {
|
|
16
|
+
rel: string;
|
|
17
|
+
abs: string;
|
|
18
|
+
};
|
|
19
|
+
/** Stores root-relative `./x` imports relative to the proposals directory. Idempotent for stored text. */
|
|
20
|
+
export declare function toStoredImports(text: string): string;
|
|
21
|
+
/** Re-expresses the relative imports of a stored proposal for a file in the project-relative directory `toDir`. */
|
|
22
|
+
export declare function importsForTarget(text: string, toDir: string): string;
|
|
23
|
+
/** The proposed file with the proposal's laws appended (same-named laws replaced) and its imports added once. */
|
|
24
|
+
export declare function mergeProposal(current: string, imports: string[], laws: {
|
|
25
|
+
name: string;
|
|
26
|
+
text: string;
|
|
27
|
+
}[]): string;
|
|
28
|
+
export interface Written {
|
|
29
|
+
/** Project-relative path of the proposal. */
|
|
30
|
+
file: string;
|
|
31
|
+
/** The names of the laws the source declared. */
|
|
32
|
+
laws: string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Adds laws to a proposal (appended; a law of the same name is replaced). `source` holds only imports and `export law`
|
|
36
|
+
* declarations; with `only`, it must declare that law. Writes inside `.tslaws/proposals/` and nowhere else.
|
|
37
|
+
*/
|
|
38
|
+
export declare function addProposal(cwd: string, file: string, source: string, only?: string): Written;
|
|
39
|
+
/**
|
|
40
|
+
* Stores the root-relative imports of a proposal someone placed in the directory by hand, so it resolves where it is.
|
|
41
|
+
* Returns whether the file changed. Does nothing for a path that is not a plain file in the directory.
|
|
42
|
+
*/
|
|
43
|
+
export declare function normaliseProposal(cwd: string, file: string): boolean;
|