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
@@ -0,0 +1,915 @@
1
+ // Converts one test file into the text of a laws file. Every test becomes a `law('describe > name', ...)` call; the
2
+ // bodies are copied from the source, with the hooks and the shared set-up of each `describe` placed in front of them.
3
+ import ts from 'typescript';
4
+ import { assumption, fcMember, mapArbitrary } from "./arbitraries.js";
5
+ import { bindingsOf, chainOf, classify, frameworkModules, globalsModule, structural, } from "./framework.js";
6
+ import { declaredNames, identifiersIn, quote, Source, unwrap } from "./source.js";
7
+ import { awaits, blockStatements, fixturesOf, isAsyncFn, isFn, returnsValue, rowsOf, rowTitle, titleOf, } from "./tests.js";
8
+ const pad = (depth) => ' '.repeat(depth);
9
+ class Converter {
10
+ src;
11
+ b;
12
+ entries = [];
13
+ laws = [];
14
+ lawBlocks = [];
15
+ moduleStatements = [];
16
+ used = new Set();
17
+ lawNames = new Set();
18
+ helpers = new Set();
19
+ aliases = new Map();
20
+ playwright;
21
+ dialect;
22
+ e2eName = 'e2e';
23
+ usesE2e = false;
24
+ testLocals = new Set();
25
+ input;
26
+ constructor(input) {
27
+ this.input = input;
28
+ this.src = new Source(input.path, input.text);
29
+ this.b = bindingsOf(this.src);
30
+ this.playwright = input.from === 'playwright';
31
+ this.dialect = this.dialectOf();
32
+ for (const [local, name] of this.b.imported)
33
+ if (name === 'test' && this.playwright)
34
+ this.testLocals.add(local);
35
+ }
36
+ dialectOf() {
37
+ if (this.input.from !== 'fast-check')
38
+ return this.input.from;
39
+ for (const statement of this.src.file.statements) {
40
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
41
+ continue;
42
+ const found = frameworkModules[statement.moduleSpecifier.text];
43
+ if (found !== undefined && found !== 'fast-check' && found !== 'playwright')
44
+ return found;
45
+ }
46
+ return undefined;
47
+ }
48
+ entry(kind, node, message) {
49
+ this.entries.push({ file: this.input.path, line: this.src.lineOfNode(node), kind, message });
50
+ }
51
+ /** A name for something new in the file that no identifier of the source already uses. */
52
+ fresh(name) {
53
+ let candidate = name;
54
+ while (this.src.taken.has(candidate))
55
+ candidate = `${candidate}_`;
56
+ this.src.taken.add(candidate);
57
+ return candidate;
58
+ }
59
+ helper(name) {
60
+ this.helpers.add(name);
61
+ let alias = this.aliases.get(name);
62
+ if (alias === undefined) {
63
+ alias = this.fresh(name);
64
+ this.aliases.set(name, alias);
65
+ }
66
+ return alias;
67
+ }
68
+ convert() {
69
+ const file = this.src.file;
70
+ if (this.src.taken.has('law') && this.lawIsTaken()) {
71
+ this.entries.push({
72
+ file: this.input.path,
73
+ line: 1,
74
+ kind: 'unconverted',
75
+ message: 'the file already uses the name `law` for something else, and a law must be written with that name',
76
+ });
77
+ return { entries: this.entries, laws: [], e2e: false };
78
+ }
79
+ this.e2eName = this.fresh('e2e');
80
+ this.rewriteSpecifiers();
81
+ const root = { prelude: [], hooks: [] };
82
+ this.walk(file.statements, root, [root], [], undefined, true);
83
+ if (this.laws.length === 0) {
84
+ if (!this.entries.some((e) => e.kind === 'unconverted' || e.kind === 'skipped'))
85
+ this.entries.push({
86
+ file: this.input.path,
87
+ line: 1,
88
+ kind: 'unconverted',
89
+ message: `no ${this.input.from} tests were found in this file`,
90
+ });
91
+ return { entries: this.entries, laws: [], e2e: false };
92
+ }
93
+ const output = this.assemble();
94
+ this.entries.sort((a, b) => a.line - b.line);
95
+ return { entries: this.entries, laws: this.laws, output, e2e: this.playwright };
96
+ }
97
+ /** Whether `law` is declared or imported by the file, rather than being a stray property name. */
98
+ lawIsTaken() {
99
+ return this.b.own.has('law') || this.b.imported.has('law');
100
+ }
101
+ // --- module specifiers -------------------------------------------------------------------------------------
102
+ rewriteSpecifiers() {
103
+ const rewriteLiteral = (literal) => {
104
+ if (literal === undefined || !ts.isStringLiteral(literal) || !literal.text.startsWith('.'))
105
+ return;
106
+ const moved = this.input.rewrite(literal.text);
107
+ if (moved === literal.text)
108
+ return;
109
+ const q = this.src.text[literal.getStart(this.src.file)];
110
+ this.src.edit(literal.getStart(this.src.file), literal.end, `${q}${moved}${q}`);
111
+ };
112
+ const visit = (node) => {
113
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier !== undefined)
114
+ rewriteLiteral(node.moduleSpecifier);
115
+ else if (ts.isCallExpression(node) && node.arguments.length > 0) {
116
+ const callee = node.expression;
117
+ if (callee.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(callee) && callee.text === 'require'))
118
+ rewriteLiteral(node.arguments[0]);
119
+ }
120
+ ts.forEachChild(node, visit);
121
+ };
122
+ visit(this.src.file);
123
+ }
124
+ // --- walking the test structure ----------------------------------------------------------------------------
125
+ classifyCall(node) {
126
+ return ts.isCallExpression(node) ? classify(node.expression, this.b, this.playwright) : undefined;
127
+ }
128
+ /** Whether a test, suite or hook is declared somewhere inside `node`. */
129
+ declaresTests(node) {
130
+ let found = false;
131
+ const visit = (n) => {
132
+ if (found)
133
+ return;
134
+ if (this.classifyCall(n) !== undefined)
135
+ found = true;
136
+ else
137
+ ts.forEachChild(n, visit);
138
+ };
139
+ visit(node);
140
+ return found;
141
+ }
142
+ /** The `fc.assert(...)` a statement is, with an `await` in front of it allowed. */
143
+ assertOf(statement) {
144
+ if (!ts.isExpressionStatement(statement))
145
+ return undefined;
146
+ let e = unwrap(statement.expression);
147
+ if (ts.isAwaitExpression(e))
148
+ e = unwrap(e.expression);
149
+ return ts.isCallExpression(e) && fcMember(e.expression, this.b) === 'assert' ? e : undefined;
150
+ }
151
+ isModuleMock(statement) {
152
+ if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression))
153
+ return false;
154
+ const chain = ts.isPropertyAccessExpression(statement.expression.expression)
155
+ ? [statement.expression.expression.expression.getText(this.src.file), statement.expression.expression.name.text]
156
+ : [];
157
+ const [object, method] = chain;
158
+ return ((object === 'vi' || object === 'jest' || object === 'rs' || object === 'rstest') &&
159
+ (method === 'mock' || method === 'doMock' || method === 'unmock' || method === 'hoisted'));
160
+ }
161
+ walk(statements, level, levels, titles, skip, moduleLevel) {
162
+ const children = [];
163
+ for (const statement of statements) {
164
+ if (ts.isImportDeclaration(statement))
165
+ continue;
166
+ const call = ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression)
167
+ ? statement.expression
168
+ : undefined;
169
+ const callee = call === undefined ? undefined : this.classifyCall(call);
170
+ if (call !== undefined && callee !== undefined) {
171
+ if (callee.kind === 'hook')
172
+ this.addHook(level, call, callee);
173
+ else if (callee.kind === 'config')
174
+ this.entry('unconverted', statement, `test.${callee.config ?? 'use'}(...) configures the Playwright runner and has no equivalent in a law; set it in tslaws.json or in the test`);
175
+ else
176
+ children.push({ statement, call, callee });
177
+ continue;
178
+ }
179
+ if (this.assertOf(statement) !== undefined) {
180
+ children.push({ statement, call: this.assertOf(statement) });
181
+ continue;
182
+ }
183
+ if (this.isModuleMock(statement)) {
184
+ this.entry('unconverted', statement, 'module mocking has no equivalent outside the framework runner; the call is left out');
185
+ continue;
186
+ }
187
+ if (this.declaresTests(statement)) {
188
+ this.entry('unconverted', statement, 'tests declared inside a loop, condition or function are not converted; write them out as laws');
189
+ continue;
190
+ }
191
+ if (moduleLevel)
192
+ this.moduleStatements.push(statement);
193
+ else
194
+ level.prelude.push(statement);
195
+ }
196
+ for (const child of children) {
197
+ if (child.callee === undefined)
198
+ this.bareProperty(child.statement, child.call, levels, titles, skip);
199
+ else if (child.callee.kind === 'describe')
200
+ this.describe(child.call, child.callee, levels, titles, skip);
201
+ else
202
+ this.test(child.call, child.callee, levels, titles, skip);
203
+ }
204
+ }
205
+ addHook(level, call, callee) {
206
+ const fn = call.arguments[0];
207
+ const written = chainOf(call.expression)?.at(-1) ?? '';
208
+ const name = this.b.imported.get(written) ?? written;
209
+ // node:test's `before` and `after` run once, like `beforeAll`.
210
+ const scope = /All$|^(?:before|after)$/.test(name) ? 'all' : 'each';
211
+ const problem = !isFn(fn) ? 'the hook is not a function written in place' : this.hookProblem(fn);
212
+ if (problem !== undefined) {
213
+ this.entry('unconverted', call, `${name}: ${problem}`);
214
+ level.broken ??= `the ${name} hook at line ${this.src.lineOfNode(call)} was not converted (${problem})`;
215
+ return;
216
+ }
217
+ const hook = fn;
218
+ if (scope === 'all')
219
+ this.entry('note', call, `${name} now runs in front of every law instead of once`);
220
+ level.hooks.push({ fn: hook, line: this.src.lineOfNode(call), kind: callee.hook, scope });
221
+ }
222
+ hookProblem(fn) {
223
+ if (this.playwright) {
224
+ const f = fixturesOf(fn);
225
+ if (f.problem !== undefined)
226
+ return f.problem;
227
+ }
228
+ else if (fn.parameters.length > 0)
229
+ return 'it takes a callback or context argument';
230
+ if (returnsValue(fn))
231
+ return 'it returns a value (a clean-up function), which a law cannot run afterwards';
232
+ return this.testUse(fn);
233
+ }
234
+ /** Playwright's `test.step(...)` and friends inside a body have no law equivalent. */
235
+ testUse(fn) {
236
+ if (this.testLocals.size === 0)
237
+ return undefined;
238
+ let found;
239
+ const visit = (n) => {
240
+ if (found !== undefined)
241
+ return;
242
+ if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression) && this.testLocals.has(n.expression.text))
243
+ found = `it uses ${n.expression.text}.${n.name.text}, which only the Playwright runner provides`;
244
+ else
245
+ ts.forEachChild(n, visit);
246
+ };
247
+ visit(fn.body);
248
+ return found;
249
+ }
250
+ describe(call, callee, levels, titles, skip) {
251
+ const [titleArg, fn] = call.arguments;
252
+ const title = titleOf(titleArg, this.src);
253
+ if (callee.each !== undefined || callee.prop !== undefined || callee.conditional !== undefined) {
254
+ this.entry('unconverted', call, `${callee.each !== undefined ? 'describe.each' : callee.conditional !== undefined ? `describe.${callee.conditional}` : 'describe'} is not converted; its tests have no laws`);
255
+ return;
256
+ }
257
+ if (title === undefined || !isFn(fn) || !ts.isBlock(fn.body) || fn.parameters.length > 0) {
258
+ this.entry('unconverted', call, 'this describe has a computed title or a callback that is not a plain block; its tests have no laws');
259
+ return;
260
+ }
261
+ let inner = skip;
262
+ if (callee.mods.has('skip') || callee.mods.has('todo') || callee.mods.has('fixme'))
263
+ inner ??= `inside a skipped describe (${title}) at line ${this.src.lineOfNode(call)}`;
264
+ if (callee.mods.has('only'))
265
+ this.entry('note', call, `describe.only on "${title}": the focus is dropped, every law runs`);
266
+ if (callee.mods.has('fails') || callee.mods.has('failing') || callee.mods.has('fail')) {
267
+ this.entry('unconverted', call, `describe "${title}" is expected to fail; a law cannot expect a failure`);
268
+ return;
269
+ }
270
+ const level = { prelude: [], hooks: [] };
271
+ this.walk(fn.body.statements, level, [...levels, level], [...titles, title], inner, false);
272
+ }
273
+ // --- tests -------------------------------------------------------------------------------------------------
274
+ unique(name) {
275
+ let candidate = name;
276
+ for (let n = 2; this.lawNames.has(candidate); n += 1)
277
+ candidate = `${name} (${n})`;
278
+ this.lawNames.add(candidate);
279
+ return candidate;
280
+ }
281
+ test(call, callee, levels, titles, skip) {
282
+ const args = call.arguments;
283
+ const title = titleOf(args[0], this.src);
284
+ const label = title ?? call.getText(this.src.file).slice(0, 30);
285
+ if (callee.conditional !== undefined) {
286
+ this.entry('unconverted', call, `test.${callee.conditional}(...) chooses at run time; "${label}" has no law`);
287
+ return;
288
+ }
289
+ if (callee.mods.has('fails') || callee.mods.has('failing') || callee.mods.has('fail')) {
290
+ this.entry('unconverted', call, `"${label}" is expected to fail, which a law cannot express`);
291
+ return;
292
+ }
293
+ const fnIndex = args.findIndex((a, i) => i > 0 && isFn(a));
294
+ const fn = fnIndex < 0 ? undefined : args[fnIndex];
295
+ const options = args.find((a, i) => i > 0 && ts.isObjectLiteralExpression(a));
296
+ const optionMods = new Set();
297
+ const dropped = [];
298
+ if (options !== undefined)
299
+ for (const p of options.properties) {
300
+ const key = ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))
301
+ ? p.name.text
302
+ : undefined;
303
+ if (key !== undefined &&
304
+ (key === 'skip' || key === 'todo' || key === 'only') &&
305
+ p.kind === ts.SyntaxKind.PropertyAssignment) {
306
+ const value = p.initializer.kind;
307
+ if (value === ts.SyntaxKind.TrueKeyword || value === ts.SyntaxKind.StringLiteral)
308
+ optionMods.add(key);
309
+ else
310
+ dropped.push(key);
311
+ }
312
+ else
313
+ dropped.push(key ?? 'option');
314
+ }
315
+ if (args.length > 2 && args.some((a, i) => i > 0 && ts.isNumericLiteral(a)))
316
+ dropped.push('timeout');
317
+ if (title === undefined && !this.isPropOrEach(callee)) {
318
+ this.entry('unconverted', call, `a test with a computed title or no title (${label}) has no law`);
319
+ return;
320
+ }
321
+ const skipped = ['skip', 'todo', 'fixme'].find((m) => callee.mods.has(m) || optionMods.has(m));
322
+ if (skipped !== undefined || (fn === undefined && callee.prop === undefined)) {
323
+ this.entry('skipped', call, `${skipped ?? 'todo'}: "${[...titles, title ?? label].join(' > ')}"`);
324
+ return;
325
+ }
326
+ if (skip !== undefined) {
327
+ this.entry('skipped', call, `"${[...titles, title ?? label].join(' > ')}" is ${skip}`);
328
+ return;
329
+ }
330
+ if (callee.mods.has('only') || optionMods.has('only'))
331
+ this.entry('note', call, `only on "${[...titles, title ?? label].join(' > ')}": the focus is dropped, every law runs`);
332
+ if (dropped.length > 0)
333
+ this.entry('note', call, `"${[...titles, title ?? label].join(' > ')}": ${dropped.join(', ')} not kept (a law takes its limits from tslaws.json and the command line)`);
334
+ const broken = levels.find((l) => l.broken !== undefined)?.broken;
335
+ if (broken !== undefined) {
336
+ this.entry('unconverted', call, `"${[...titles, title ?? label].join(' > ')}" has no law because ${broken}`);
337
+ return;
338
+ }
339
+ if (callee.prop !== undefined) {
340
+ this.propTest(call, callee, fn, title, levels, titles);
341
+ return;
342
+ }
343
+ if (callee.each !== undefined) {
344
+ this.eachTest(call, callee, fn, title, levels, titles);
345
+ return;
346
+ }
347
+ this.plainTest(call, fn, title, levels, titles);
348
+ }
349
+ isPropOrEach(callee) {
350
+ return callee.prop !== undefined || callee.each !== undefined;
351
+ }
352
+ nameOf(titles, title) {
353
+ return this.unique([...titles, title].join(' > '));
354
+ }
355
+ plainTest(call, fn, title, levels, titles) {
356
+ const name = this.nameOf(titles, title);
357
+ const problem = this.playwright
358
+ ? (fixturesOf(fn).problem ?? this.testUse(fn))
359
+ : fn.parameters.length > 0
360
+ ? 'it takes a callback or context argument'
361
+ : undefined;
362
+ if (problem !== undefined) {
363
+ this.lawNames.delete(name);
364
+ this.entry('unconverted', call, `"${name}": ${problem}`);
365
+ return;
366
+ }
367
+ const typed = this.propertyOf(fn);
368
+ if (typed !== undefined && typed.ok) {
369
+ this.emitProperty(call, name, typed, levels, [fn]);
370
+ return;
371
+ }
372
+ if (typed !== undefined)
373
+ this.entry('unconverted', typed.node, `fc.assert kept as it is in a zero-parameter law: ${typed.reason}`);
374
+ else
375
+ this.keepStrayAsserts(fn);
376
+ this.emit(call, name, this.callbackBody(fn), levels);
377
+ }
378
+ /** `fc.assert` calls left inside a law that has no parameters: they still run, but nothing is generated by tslaws. */
379
+ keepStrayAsserts(fn) {
380
+ const visit = (n) => {
381
+ if (ts.isCallExpression(n) && fcMember(n.expression, this.b) === 'assert')
382
+ this.entry('unconverted', n, 'fc.assert kept as it is in a zero-parameter law: it is not the only statement of the test');
383
+ else
384
+ ts.forEachChild(n, visit);
385
+ };
386
+ visit(fn.body);
387
+ }
388
+ /** The body of a callback as it stands: its statements, or its expression as one statement. */
389
+ callbackBody(fn, extra) {
390
+ const statements = blockStatements(fn);
391
+ const declared = [...statements.flatMap(declaredNames), ...(extra?.declared ?? [])];
392
+ const asyncBody = isAsyncFn(fn) || awaits(fn.body) || (this.playwright && !ts.isBlock(fn.body));
393
+ return {
394
+ params: [],
395
+ declared,
396
+ async: asyncBody,
397
+ fns: [fn],
398
+ lines: (depth) => [
399
+ ...(extra?.lines.map((l) => pad(depth) + l) ?? []),
400
+ ...this.fnLines(fn, depth, asyncBody, extra?.returns === true),
401
+ ],
402
+ };
403
+ }
404
+ fnLines(fn, depth, awaited, returns = false) {
405
+ this.includeNode(fn.body);
406
+ this.playwrightEdits(fn);
407
+ if (ts.isBlock(fn.body))
408
+ return this.src.statements(fn.body.statements, depth);
409
+ const expression = fn.body;
410
+ const lines = this.src.block(expression.getStart(this.src.file), expression.end, depth);
411
+ if (lines.length === 0)
412
+ return [];
413
+ if (returns)
414
+ lines[0] = `${pad(depth)}return ${lines[0].trimStart()}`;
415
+ else if (awaited && !ts.isAwaitExpression(unwrap(expression)))
416
+ lines[0] = `${pad(depth)}await ${lines[0].trimStart()}`;
417
+ lines[lines.length - 1] += ';';
418
+ return lines;
419
+ }
420
+ includeNode(node) {
421
+ identifiersIn(node, this.used);
422
+ }
423
+ // --- fast-check ---------------------------------------------------------------------------------------------
424
+ /** A test whose whole body is `fc.assert(fc.property(...))`, mapped to typed parameters when every arbitrary has a type. */
425
+ propertyOf(fn) {
426
+ if (!ts.isBlock(fn.body) || fn.body.statements.length !== 1)
427
+ return undefined;
428
+ const assert = this.assertOf(fn.body.statements[0]);
429
+ if (assert === undefined)
430
+ return undefined;
431
+ const [prop, options] = assert.arguments;
432
+ const node = fn.body.statements[0];
433
+ const fail = (reason) => ({ ok: false, reason, node });
434
+ if (prop === undefined)
435
+ return fail('there is no property');
436
+ const property = unwrap(prop);
437
+ if (!ts.isCallExpression(property))
438
+ return fail('the property is built elsewhere');
439
+ const kind = fcMember(property.expression, this.b);
440
+ if (kind !== 'property' && kind !== 'asyncProperty')
441
+ return fail('the property is not fc.property(...) or fc.asyncProperty(...)');
442
+ const fnArg = property.arguments[property.arguments.length - 1];
443
+ if (!isFn(fnArg))
444
+ return fail('the property function is not written in place');
445
+ const note = options === undefined
446
+ ? undefined
447
+ : `fc.assert options (${options.getText(this.src.file).replace(/\s+/g, ' ').slice(0, 60)}) are not kept; use --runs and --seed`;
448
+ return this.typedProperty(property.arguments.slice(0, -1), fnArg, node, note, kind === 'asyncProperty');
449
+ }
450
+ typedProperty(arbitraries, fn, node, note, asyncProperty, names) {
451
+ const fail = (reason) => ({ ok: false, reason, node });
452
+ if (arbitraries.length === 0)
453
+ return fail('the property has no arbitraries');
454
+ if (names === undefined && fn.parameters.length > arbitraries.length)
455
+ return fail('the property function takes more arguments than there are arbitraries');
456
+ const params = [];
457
+ for (const [i, arbitrary] of arbitraries.entries()) {
458
+ const mapped = mapArbitrary(arbitrary, this.b);
459
+ if (!mapped.ok)
460
+ return fail(mapped.reason);
461
+ const parameter = names === undefined ? fn.parameters[i] : undefined;
462
+ if (parameter !== undefined &&
463
+ (!ts.isIdentifier(parameter.name) ||
464
+ parameter.initializer !== undefined ||
465
+ parameter.dotDotDotToken !== undefined))
466
+ return fail('a parameter of the property function is destructured or has a default');
467
+ params.push({
468
+ name: names?.[i] ?? (parameter === undefined ? `_${i + 1}` : parameter.name.text),
469
+ typed: mapped.typed,
470
+ });
471
+ }
472
+ return { ok: true, params, fn, note, async: asyncProperty || isAsyncFn(fn), node };
473
+ }
474
+ emitProperty(call, name, p, levels, extraFns) {
475
+ if (p.note !== undefined)
476
+ this.entry('note', p.node, p.note);
477
+ const lines = [];
478
+ const declared = [];
479
+ const fn = p.fn;
480
+ for (const { name: n, typed } of p.params) {
481
+ const condition = assumption(typed, n);
482
+ if (condition !== undefined)
483
+ lines.push(`${this.helper('assume')}(${condition});`);
484
+ }
485
+ // `fc.pre(x)` is `assume(x)`.
486
+ const visit = (n) => {
487
+ if (ts.isCallExpression(n) && fcMember(n.expression, this.b) === 'pre')
488
+ this.src.edit(n.expression.getStart(this.src.file), n.expression.end, this.helper('assume'));
489
+ ts.forEachChild(n, visit);
490
+ };
491
+ visit(fn.body);
492
+ const body = this.callbackBody(fn, { lines, declared, returns: true });
493
+ body.async = p.async;
494
+ body.fns = [...extraFns];
495
+ body.params = p.params.map((x) => ({ name: x.name, type: this.typeText(x.typed) }));
496
+ this.emit(call, name, body, levels);
497
+ }
498
+ typeText(typed) {
499
+ for (const u of typed.uses)
500
+ this.helper(u);
501
+ return typed.type.replace(/\bint\b/g, this.aliases.get('int') ?? 'int');
502
+ }
503
+ /** A top-level `fc.assert(...)` outside any test. */
504
+ bareProperty(statement, call, levels, titles, skip) {
505
+ const line = this.src.lineOfNode(statement);
506
+ const name = this.nameOf(titles, `property at line ${line}`);
507
+ if (skip !== undefined) {
508
+ this.lawNames.delete(name);
509
+ this.entry('skipped', statement, `property at line ${line} is ${skip}`);
510
+ return;
511
+ }
512
+ const broken = levels.find((l) => l.broken !== undefined)?.broken;
513
+ if (broken !== undefined) {
514
+ this.lawNames.delete(name);
515
+ this.entry('unconverted', statement, `property at line ${line} has no law because ${broken}`);
516
+ return;
517
+ }
518
+ const [prop, options] = call.arguments;
519
+ const property = prop === undefined ? undefined : unwrap(prop);
520
+ const kind = property !== undefined && ts.isCallExpression(property) ? fcMember(property.expression, this.b) : undefined;
521
+ const fnArg = property !== undefined && ts.isCallExpression(property)
522
+ ? property.arguments[property.arguments.length - 1]
523
+ : undefined;
524
+ let result;
525
+ if (property === undefined ||
526
+ !ts.isCallExpression(property) ||
527
+ (kind !== 'property' && kind !== 'asyncProperty') ||
528
+ !isFn(fnArg))
529
+ result = { ok: false, reason: 'the property is not fc.property(...) written in place', node: statement };
530
+ else
531
+ result = this.typedProperty(property.arguments.slice(0, -1), fnArg, statement, options === undefined
532
+ ? undefined
533
+ : `fc.assert options (${options.getText(this.src.file).replace(/\s+/g, ' ').slice(0, 60)}) are not kept; use --runs and --seed`, kind === 'asyncProperty');
534
+ if (result.ok) {
535
+ this.emitProperty(statement, name, result, levels, []);
536
+ return;
537
+ }
538
+ this.entry('unconverted', statement, `fc.assert kept as it is in a zero-parameter law: ${result.reason}`);
539
+ this.includeNode(statement);
540
+ const lines = (depth) => this.src.statements([statement], depth);
541
+ this.emit(statement, name, { params: [], lines, declared: [], async: awaits(statement), fns: [] }, levels);
542
+ }
543
+ /** `it.prop([arbitraries])('name', (a, b) => ...)` of @fast-check/vitest and @fast-check/jest. */
544
+ propTest(call, callee, fn, title, levels, titles) {
545
+ const prop = callee.prop;
546
+ const name = this.nameOf(titles, title ?? `property at line ${this.src.lineOfNode(call)}`);
547
+ const give = (reason) => this.entry('unconverted', call, `"${name}": ${reason}`);
548
+ if (fn === undefined) {
549
+ this.lawNames.delete(name);
550
+ give('the test function is not written in place');
551
+ return;
552
+ }
553
+ const [arbitraries, options] = prop.arguments;
554
+ if (arbitraries === undefined) {
555
+ this.lawNames.delete(name);
556
+ give('there are no arbitraries');
557
+ return;
558
+ }
559
+ const note = options === undefined
560
+ ? undefined
561
+ : `property options (${options.getText(this.src.file).replace(/\s+/g, ' ').slice(0, 60)}) are not kept; use --runs and --seed`;
562
+ const list = unwrap(arbitraries);
563
+ let result;
564
+ let keys;
565
+ if (ts.isArrayLiteralExpression(list)) {
566
+ result = this.typedProperty(list.elements.slice(), fn, call, note, false);
567
+ }
568
+ else if (ts.isObjectLiteralExpression(list)) {
569
+ // `{ a: fc.integer() }` with a callback that destructures `({ a })`.
570
+ const pattern = fn.parameters[0]?.name;
571
+ const members = new Map();
572
+ for (const p of list.properties)
573
+ if (ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)))
574
+ members.set(p.name.text, p.initializer);
575
+ if (pattern === undefined ||
576
+ !ts.isObjectBindingPattern(pattern) ||
577
+ fn.parameters.length !== 1 ||
578
+ members.size !== list.properties.length) {
579
+ result = {
580
+ ok: false,
581
+ reason: 'the object of arbitraries is not matched by a destructured parameter',
582
+ node: call,
583
+ };
584
+ }
585
+ else {
586
+ const picked = [];
587
+ keys = [];
588
+ let ok = true;
589
+ for (const element of pattern.elements) {
590
+ const key = element.propertyName === undefined
591
+ ? ts.isIdentifier(element.name)
592
+ ? element.name.text
593
+ : undefined
594
+ : ts.isIdentifier(element.propertyName) || ts.isStringLiteral(element.propertyName)
595
+ ? element.propertyName.text
596
+ : undefined;
597
+ const arbitrary = key === undefined ? undefined : members.get(key);
598
+ if (arbitrary === undefined ||
599
+ !ts.isIdentifier(element.name) ||
600
+ element.initializer !== undefined ||
601
+ element.dotDotDotToken !== undefined) {
602
+ ok = false;
603
+ break;
604
+ }
605
+ picked.push(arbitrary);
606
+ keys.push(element.name.text);
607
+ }
608
+ if (!ok)
609
+ result = {
610
+ ok: false,
611
+ reason: 'a destructured property is renamed with a default or has no arbitrary',
612
+ node: call,
613
+ };
614
+ else {
615
+ result = this.typedProperty(picked, fn, call, note, false, keys);
616
+ }
617
+ }
618
+ }
619
+ else
620
+ result = { ok: false, reason: 'the arbitraries are not written as an array or an object', node: call };
621
+ if (result.ok) {
622
+ this.emitProperty(call, name, result, levels, []);
623
+ return;
624
+ }
625
+ // No tslaws type: keep the property as fast-check runs it, in a law without parameters.
626
+ this.entry('unconverted', call, `"${name}": kept as fc.assert in a zero-parameter law: ${result.reason}`);
627
+ this.includeNode(call);
628
+ const fc = this.fcName();
629
+ const arbs = ts.isObjectLiteralExpression(list) ? `${fc}.record(${this.src.textOf(list)})` : undefined;
630
+ const arbList = ts.isArrayLiteralExpression(list)
631
+ ? list.elements.map((e) => this.src.textOf(e))
632
+ : [arbs ?? this.src.textOf(list)];
633
+ const asyncProperty = isAsyncFn(fn);
634
+ const head = `${asyncProperty ? 'await ' : ''}${fc}.assert(${fc}.${asyncProperty ? 'asyncProperty' : 'property'}(${arbList.join(', ')}, `;
635
+ const lines = (depth) => {
636
+ const inner = this.src.block(fn.getStart(this.src.file), fn.end, depth);
637
+ if (inner.length === 0)
638
+ return [];
639
+ inner[0] = `${pad(depth)}${head}${inner[0].trimStart()}`;
640
+ inner[inner.length - 1] += '));';
641
+ return inner;
642
+ };
643
+ this.usedFc = true;
644
+ this.emit(call, name, { params: [], lines, declared: [], async: asyncProperty || awaits(fn), fns: [] }, levels);
645
+ }
646
+ usedFc = false;
647
+ fcName() {
648
+ for (const n of this.b.fcNamespaces)
649
+ return n;
650
+ return 'fc';
651
+ }
652
+ // --- test.each ----------------------------------------------------------------------------------------------
653
+ eachTest(call, callee, fn, title, levels, titles) {
654
+ const rows = rowsOf(callee.each, this.src, callee.forRows !== true);
655
+ const here = (reason) => this.entry('unconverted', call, `"${[...titles, title].join(' > ')}": ${reason}`);
656
+ if (!rows.ok)
657
+ return here(rows.reason);
658
+ if (rows.rows.length === 0)
659
+ return here('the table has no rows');
660
+ for (const parameter of fn.parameters)
661
+ if (parameter.initializer !== undefined || parameter.dotDotDotToken !== undefined)
662
+ return here('a parameter has a default or is a rest parameter');
663
+ const problem = this.playwright ? (fixturesOf(fn).problem ?? undefined) : undefined;
664
+ if (problem !== undefined)
665
+ return here(problem);
666
+ for (const [index, row] of rows.rows.entries()) {
667
+ const name = this.unique([...titles, rowTitle(title, row, index, this.src)].join(' > '));
668
+ const texts = row.synthetic === undefined ? row.args.map((a) => this.src.textOf(a)) : [row.synthetic];
669
+ const bindings = [];
670
+ const declared = [];
671
+ for (const [i, parameter] of fn.parameters.entries()) {
672
+ const value = callee.forRows === true && row.args.length === 1 && row.synthetic === undefined && fn.parameters.length === 1
673
+ ? texts[0]
674
+ : texts[i];
675
+ const type = parameter.type === undefined ? '' : `: ${parameter.type.getText(this.src.file)}`;
676
+ bindings.push(`const ${parameter.name.getText(this.src.file)}${type} = ${value ?? 'undefined'};`);
677
+ const pattern = new Set();
678
+ identifiersIn(parameter.name, pattern);
679
+ declared.push(...pattern);
680
+ }
681
+ const statements = blockStatements(fn);
682
+ const asyncBody = isAsyncFn(fn) || awaits(fn.body);
683
+ this.emit(call, name, {
684
+ params: [],
685
+ declared: [...declared, ...statements.flatMap(declaredNames)],
686
+ async: asyncBody,
687
+ fns: [fn],
688
+ lines: (depth) => [...bindings.map((l) => pad(depth) + l), ...this.fnLines(fn, depth, asyncBody)],
689
+ }, levels);
690
+ }
691
+ }
692
+ // --- Playwright ---------------------------------------------------------------------------------------------
693
+ playwrightEdits(fn) {
694
+ if (!this.playwright)
695
+ return;
696
+ const pages = new Set();
697
+ for (const [local, fixture] of fixturesOf(fn).bindings)
698
+ if (fixture === 'page')
699
+ pages.add(local);
700
+ if (pages.size === 0)
701
+ return;
702
+ const relative = (text) => !/^[a-z][a-z\d+.-]*:/i.test(text) && !text.startsWith('//');
703
+ const visit = (n) => {
704
+ if (ts.isCallExpression(n) &&
705
+ ts.isPropertyAccessExpression(n.expression) &&
706
+ n.expression.name.text === 'goto' &&
707
+ ts.isIdentifier(n.expression.expression) &&
708
+ pages.has(n.expression.expression.text)) {
709
+ const url = n.arguments[0];
710
+ const text = url === undefined
711
+ ? undefined
712
+ : ts.isStringLiteral(url) || ts.isNoSubstitutionTemplateLiteral(url)
713
+ ? url.text
714
+ : undefined;
715
+ if (url === undefined) {
716
+ /* page.goto() without a URL is not valid */
717
+ }
718
+ else if (text === undefined)
719
+ this.entry('note', n, 'page.goto with a computed address: a relative one does not resolve against the base URL in a law; use open(path)');
720
+ else if (relative(text)) {
721
+ this.usesE2e = true;
722
+ const quoted = quote(text);
723
+ if (n.arguments.length === 1 && ts.isAwaitExpression(n.parent))
724
+ this.src.edit(n.getStart(this.src.file), n.end, `${this.e2eName}.open(${quoted})`);
725
+ else
726
+ this.src.edit(url.getStart(this.src.file), url.end, `new URL(${quoted}, ${this.e2eName}.baseURL()).toString()`);
727
+ }
728
+ }
729
+ ts.forEachChild(n, visit);
730
+ };
731
+ visit(fn.body);
732
+ }
733
+ // --- placing a law ------------------------------------------------------------------------------------------
734
+ emit(node, name, body, levels) {
735
+ const hooks = levels.flatMap((l) => l.hooks);
736
+ const fixtures = new Map();
737
+ if (this.playwright) {
738
+ for (const fn of [...hooks.map((h) => h.fn), ...body.fns])
739
+ for (const [local, fixture] of fixturesOf(fn).bindings)
740
+ fixtures.set(local, fixture);
741
+ for (const hook of hooks)
742
+ this.playwrightEdits(hook.fn);
743
+ }
744
+ const isAsync = body.async ||
745
+ hooks.some((h) => isAsyncFn(h.fn) || awaits(h.fn.body) || (this.playwright && !ts.isBlock(h.fn.body)));
746
+ const scope = new Set([...body.params.map((p) => p.name), ...fixtures.keys()]);
747
+ const lines = [];
748
+ for (const [local, fixture] of fixtures) {
749
+ this.usesE2e = true;
750
+ lines.push(`${pad(1)}const ${local} = ${this.e2eName}.${fixture}();`);
751
+ }
752
+ lines.push(...this.levelLines(levels, 0, 1, scope, body));
753
+ const params = body.params.map((p) => `${p.name}: ${p.type}`).join(', ');
754
+ const head = `law(${quote(name)}, ${isAsync ? 'async ' : ''}(${params}) => {`;
755
+ this.lawBlocks.push(lines.length === 0 ? `${head.replace(/\{$/, '{}')});` : `${head}\n${lines.join('\n')}\n});`);
756
+ this.laws.push(name);
757
+ void node;
758
+ }
759
+ statementsLines(statements, depth, scope, wrapAlways) {
760
+ const names = statements.flatMap(declaredNames);
761
+ this.includeNode({ forEachChild: () => undefined });
762
+ for (const s of statements)
763
+ this.includeNode(s);
764
+ const inner = this.src.statements(statements, names.length > 0 && (wrapAlways || names.some((n) => scope.has(n))) ? depth + 1 : depth);
765
+ if (names.length > 0 && (wrapAlways || names.some((n) => scope.has(n))))
766
+ return [`${pad(depth)}{`, ...inner, `${pad(depth)}}`];
767
+ for (const n of names)
768
+ scope.add(n);
769
+ return inner;
770
+ }
771
+ hookLines(hook, depth, scope) {
772
+ this.playwrightEdits(hook.fn);
773
+ const fn = hook.fn;
774
+ const awaited = isAsyncFn(fn) || awaits(fn.body) || (this.playwright && !ts.isBlock(fn.body));
775
+ if (!ts.isBlock(fn.body)) {
776
+ this.includeNode(fn.body);
777
+ return this.fnLines(fn, depth, awaited);
778
+ }
779
+ return this.statementsLines(fn.body.statements, depth, scope, true);
780
+ }
781
+ levelLines(levels, index, depth, scope, body) {
782
+ const level = levels[index];
783
+ if (level === undefined) {
784
+ const names = body.declared;
785
+ const wrap = names.some((n) => scope.has(n));
786
+ const lines = body.lines(wrap ? depth + 1 : depth);
787
+ for (const n of body.declared)
788
+ scope.add(n);
789
+ return wrap ? [`${pad(depth)}{`, ...lines, `${pad(depth)}}`] : lines;
790
+ }
791
+ const out = [];
792
+ let here = depth;
793
+ let sc = scope;
794
+ const preludeNames = level.prelude.flatMap(declaredNames);
795
+ const opened = index > 0 && preludeNames.some((n) => scope.has(n));
796
+ if (opened) {
797
+ out.push(`${pad(depth)}{`);
798
+ here += 1;
799
+ sc = new Set();
800
+ }
801
+ out.push(...this.statementsLines(level.prelude, here, sc, false));
802
+ for (const hook of level.hooks.filter((h) => h.kind === 'before'))
803
+ out.push(...this.hookLines(hook, here, sc));
804
+ const after = level.hooks.filter((h) => h.kind === 'after').reverse();
805
+ if (after.length > 0) {
806
+ out.push(`${pad(here)}try {`);
807
+ out.push(...this.levelLines(levels, index + 1, here + 1, new Set(), body));
808
+ out.push(`${pad(here)}} finally {`);
809
+ const finallyScope = new Set();
810
+ for (const hook of after)
811
+ out.push(...this.hookLines(hook, here + 1, finallyScope));
812
+ out.push(`${pad(here)}}`);
813
+ }
814
+ else
815
+ out.push(...this.levelLines(levels, index + 1, here, sc, body));
816
+ if (opened)
817
+ out.push(`${pad(depth)}}`);
818
+ return out;
819
+ }
820
+ // --- the file -----------------------------------------------------------------------------------------------
821
+ assemble() {
822
+ for (const s of this.moduleStatements)
823
+ this.includeNode(s);
824
+ const importLines = [];
825
+ const extra = new Map();
826
+ const tslaws = [
827
+ 'law',
828
+ ...[...this.helpers]
829
+ .filter((h) => h !== 'int')
830
+ .map((h) => (this.aliases.get(h) === h ? h : `${h} as ${this.aliases.get(h)}`)),
831
+ ];
832
+ if (this.helpers.has('int'))
833
+ tslaws.push(this.aliases.get('int') === 'int' ? 'type int' : `type int as ${this.aliases.get('int')}`);
834
+ importLines.push(`import { ${tslaws.join(', ')} } from 'tslaws';`);
835
+ // The import of `tslaws/e2e` is what makes the file an end-to-end law file, so it stays even when no law uses it.
836
+ if (this.playwright)
837
+ importLines.push(this.usesE2e ? `import * as ${this.e2eName} from 'tslaws/e2e';` : `import 'tslaws/e2e';`);
838
+ const globals = this.dialect === undefined ? undefined : globalsModule[this.dialect];
839
+ const provided = new Set();
840
+ for (const statement of this.src.file.statements) {
841
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
842
+ continue;
843
+ const module = statement.moduleSpecifier.text;
844
+ if (frameworkModules[module] === undefined) {
845
+ importLines.push(...this.src.statements([statement], 0));
846
+ continue;
847
+ }
848
+ const rebuilt = this.frameworkImport(statement, module);
849
+ if (rebuilt !== undefined)
850
+ importLines.push(rebuilt);
851
+ for (const element of statement.importClause?.namedBindings &&
852
+ ts.isNamedImports(statement.importClause.namedBindings)
853
+ ? statement.importClause.namedBindings.elements
854
+ : [])
855
+ provided.add(element.name.text);
856
+ }
857
+ if (globals !== undefined) {
858
+ const wanted = globals.names.filter((n) => this.used.has(n) && !provided.has(n) && !this.b.own.has(n));
859
+ if (wanted.length > 0)
860
+ extra.set(globals.module, new Set(wanted));
861
+ }
862
+ for (const [module, names] of extra)
863
+ importLines.push(`import { ${[...names].join(', ')} } from ${quote(module)};`);
864
+ if (this.usedFc &&
865
+ !this.src.file.statements.some((s) => ts.isImportDeclaration(s) &&
866
+ ts.isStringLiteral(s.moduleSpecifier) &&
867
+ s.moduleSpecifier.text === 'fast-check' &&
868
+ this.used.has(this.fcName())))
869
+ importLines.push(`import * as ${this.fcName()} from 'fast-check';`);
870
+ const module = this.src.statements(this.moduleStatements, 0);
871
+ return `${[importLines.join('\n'), module.join('\n'), this.lawBlocks.join('\n\n')].filter((p) => p !== '').join('\n\n')}\n`;
872
+ }
873
+ /** A framework import rebuilt with only what the laws still use: usually `expect`, never the test structure. */
874
+ frameworkImport(statement, module) {
875
+ const clause = statement.importClause;
876
+ if (clause === undefined)
877
+ return undefined;
878
+ const typeOnly = clause.isTypeOnly ? 'type ' : '';
879
+ const fastCheck = module === 'fast-check';
880
+ const parts = [];
881
+ const named = clause.namedBindings;
882
+ const fcFromRunner = module === '@fast-check/vitest' || module === '@fast-check/jest';
883
+ if (fastCheck && clause.name !== undefined && this.used.has(clause.name.text))
884
+ parts.push(clause.name.text);
885
+ if (named !== undefined && ts.isNamespaceImport(named)) {
886
+ if (this.used.has(named.name.text))
887
+ parts.push(`* as ${named.name.text}`);
888
+ }
889
+ else if (named !== undefined) {
890
+ const keep = named.elements.filter((e) => {
891
+ const original = (e.propertyName ?? e.name).text;
892
+ if (!fastCheck && structural.has(original))
893
+ return false;
894
+ if (fcFromRunner && original === 'fc')
895
+ return false;
896
+ return this.used.has(e.name.text);
897
+ });
898
+ if (keep.length > 0) {
899
+ const text = `{ ${keep.map((e) => e.getText(this.src.file)).join(', ')} }`;
900
+ parts.push(text);
901
+ }
902
+ }
903
+ if (fcFromRunner && this.usedFc)
904
+ return undefined;
905
+ if (parts.length === 0)
906
+ return undefined;
907
+ // A default import and a namespace import cannot share the braces of a named list.
908
+ const heads = parts.filter((p) => !p.startsWith('{'));
909
+ const braces = parts.filter((p) => p.startsWith('{'));
910
+ return `import ${typeOnly}${[...heads, ...braces].join(', ')} from ${quote(module)};`;
911
+ }
912
+ }
913
+ export function convertTestFile(input) {
914
+ return new Converter(input).convert();
915
+ }