tslaws 0.6.3 → 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.
Files changed (68) hide show
  1. package/README.md +8 -8
  2. package/dist/api.d.ts +10 -0
  3. package/dist/api.js +7 -0
  4. package/dist/browser.d.ts +2 -2
  5. package/dist/browser.js +2 -6
  6. package/dist/check.d.ts +2 -0
  7. package/dist/check.js +133 -41
  8. package/dist/cli.d.ts +18 -0
  9. package/dist/cli.js +149 -54
  10. package/dist/compile.d.ts +12 -0
  11. package/dist/compile.js +207 -6
  12. package/dist/coverage.d.ts +13 -1
  13. package/dist/coverage.js +16 -23
  14. package/dist/derived.d.ts +8 -0
  15. package/dist/derived.js +52 -0
  16. package/dist/e2e.d.ts +0 -9
  17. package/dist/e2e.js +0 -20
  18. package/dist/generators.d.ts +8 -8
  19. package/dist/generators.js +59 -99
  20. package/dist/globs.d.ts +3 -0
  21. package/dist/globs.js +24 -0
  22. package/dist/init.js +5 -5
  23. package/dist/lawFiles.d.ts +21 -0
  24. package/dist/lawFiles.js +95 -0
  25. package/dist/lawKinds.d.ts +38 -0
  26. package/dist/lawKinds.js +210 -0
  27. package/dist/lawNames.d.ts +6 -0
  28. package/dist/lawNames.js +22 -0
  29. package/dist/list.d.ts +22 -0
  30. package/dist/list.js +36 -0
  31. package/dist/lock.d.ts +16 -3
  32. package/dist/lock.js +23 -4
  33. package/dist/lower.d.ts +10 -0
  34. package/dist/lower.js +178 -0
  35. package/dist/mcp.d.ts +2 -8
  36. package/dist/mcp.js +40 -61
  37. package/dist/migrate/arbitraries.d.ts +23 -0
  38. package/dist/migrate/arbitraries.js +202 -0
  39. package/dist/migrate/convert.d.ts +20 -0
  40. package/dist/migrate/convert.js +915 -0
  41. package/dist/migrate/framework.d.ts +48 -0
  42. package/dist/migrate/framework.js +249 -0
  43. package/dist/migrate/index.d.ts +16 -0
  44. package/dist/migrate/index.js +155 -0
  45. package/dist/migrate/report.d.ts +35 -0
  46. package/dist/migrate/report.js +40 -0
  47. package/dist/migrate/source.d.ts +42 -0
  48. package/dist/migrate/source.js +163 -0
  49. package/dist/migrate/tests.d.ts +44 -0
  50. package/dist/migrate/tests.js +184 -0
  51. package/dist/plan.d.ts +50 -0
  52. package/dist/plan.js +118 -0
  53. package/dist/pool.d.ts +21 -0
  54. package/dist/pool.js +54 -0
  55. package/dist/promote.d.ts +30 -2
  56. package/dist/promote.js +133 -7
  57. package/dist/property.d.ts +12 -26
  58. package/dist/property.js +33 -74
  59. package/dist/propertyRun.d.ts +24 -0
  60. package/dist/propertyRun.js +56 -0
  61. package/dist/propertyWorker.js +7 -9
  62. package/dist/proposals.d.ts +43 -0
  63. package/dist/proposals.js +192 -0
  64. package/dist/regressions.js +23 -2
  65. package/dist/smt.js +9 -8
  66. package/dist/types.d.ts +2 -0
  67. package/hooks/AGENTS.snippet.md +2 -2
  68. 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
- /** `foo.proposed.laws.ts` -> `foo.laws.ts`; undefined for any other name. */
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.endsWith('.proposed.laws.ts')
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 of a laws file. `law` is not standard TypeScript, so
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
+ }
@@ -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
- export declare function typesOf(program: ts.Program, outDir: string, law: ManifestLaw): LawTypes;
16
- export declare function isDiscard(error: unknown): boolean;
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
- /** The outcome of a law's property test, without the generated values' prototypes so it can cross a thread. */
22
- export interface PropertyOutcome {
23
- failed: boolean;
24
- numRuns: number;
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, UnsupportedType } from "./generators.js";
7
- import { decodeExample, encodeExample } from "./regressions.js";
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
- export function typesOf(program, outDir, law) {
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 = program.getSourceFile(dtsPath(outDir, law.file));
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 generators = [];
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
- generators.push(factory.of(optional ? checker.getNonNullableType(type) : type));
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
- export function summarise(details, options) {
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
- if (worker === undefined) {
152
- worker = new Worker(new URL(`./propertyWorker${ext}`, import.meta.url));
153
- worker.unref();
154
- }
155
- const current = worker;
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
- if (worker === current)
159
- worker = undefined;
160
- void current.terminate();
116
+ current.off('message', onMessage);
117
+ workers.discard(current);
161
118
  resolve('timeout');
162
119
  }, options.propertyTimeoutMs ?? 30000);
163
- current.once('message', (message) => {
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
+ }
@@ -1,22 +1,20 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { parentPort } from 'node:worker_threads';
3
- import ts from 'typescript';
4
- import { compilerOptions, declarationFiles, jsPath, runLaw, summarise, typesOf } from "./property.js";
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
- if (programOf?.outDir !== outDir)
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, types.generators, options), options) });
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;