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.
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/cli.js CHANGED
@@ -1,22 +1,28 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
- import { readdirSync, statSync, watch } from 'node:fs';
2
+ import { readFileSync, watch } from 'node:fs';
3
3
  import { join, relative, resolve, sep } from 'node:path';
4
4
  import { resolveEngine } from "./browser.js";
5
5
  import { bundledCompiler, checkoutCompiler } from "./bundled.js";
6
6
  import { check, exemptionsOf } from "./check.js";
7
7
  import { readManifest } from "./compile.js";
8
8
  import { hintFor } from "./hints.js";
9
+ import { discoverLaws, discoverProposals, ignoredDirs } from "./lawFiles.js";
10
+ import { isProposedLawFile } from "./lawNames.js";
9
11
  import { init, nextStep } from "./init.js";
12
+ import { listLawFiles, renderList } from "./list.js";
10
13
  import { serveMcp } from "./mcp.js";
11
- import { promoteFiles } from "./promote.js";
14
+ import { promoteFiles, promoteProposal } from "./promote.js";
15
+ import { addProposal, ProposalError } from "./proposals.js";
12
16
  import { clearCorpus, listCorpus } from "./regressions.js";
13
17
  import { reporterNames, reportAs } from "./reporters.js";
14
18
  import { readConfig } from "./coverage.js";
19
+ import { migrateCommand, migrateUsage } from "./migrate/index.js";
15
20
  import { mutate, renderMutation } from "./mutate.js";
16
21
  import { mutateDefaults, resolveSettings } from "./settings.js";
17
22
  import { serveUi } from "./ui.js";
18
23
  import { acceptGate, defaultDuration, describeGrants, fileHash, grantFor, logDelegatedAccept, parseDuration, personOnly, revokeFor, } from "./delegation.js";
19
24
  import { writeLock } from "./lock.js";
25
+ import { lockLevels } from "./lawKinds.js";
20
26
  const usage = `tslaws init [--force] scaffold tslaws.json, .gitignore, AGENTS.md, Claude Code hooks and a starter law (never writes the lock)
21
27
  tslaws check [files...] [--reporter text|json|junit|github|ndjson] [--law NAME[,NAME]] [--json] [--summary] [--watch] [--runs N] [--seed N] [--no-prove] [--no-regressions] [--bound N] [--proof-timeout MS] [--property-timeout MS] [--proposed] [--compiler PATH] [--browser chromium|firefox|webkit]
22
28
  tslaws ui [files...] [--port N] [--no-open] [--tab] open a dashboard of the laws in its own desktop window (--tab: a browser tab): tree, filters, search, source, live reruns on save
@@ -26,10 +32,14 @@ tslaws accept [files...] record the current laws in tsc-laws.lock (people on
26
32
  tslaws delegate grant [--for 1h|8h|1d|7d] [--root DIR] lend accept to an agent for a limited time (people only; default 8h, max 7d)
27
33
  tslaws delegate revoke [--root DIR] withdraw the delegation for a project (people only)
28
34
  tslaws delegate status list delegations and when they end
29
- tslaws promote [file] [--all] move proposed laws (*.proposed.laws.ts) into the accepted *.laws.ts, asking per law (people only: needs a terminal; run accept afterwards)
30
- tslaws mcp [--compiler PATH] serve check, check_proposed, explain and propose as Model Context Protocol tools on stdio (cannot accept or touch the lock)
35
+ tslaws propose <file.ts> [--from PATH] write a proposal into .tslaws/proposals/ from PATH or stdin (outside the tree, git-ignored; never touches the lock or any other file)
36
+ tslaws promote <proposal> [--to PATH] move a proposal from .tslaws/proposals/ into the tree at a path you confirm (people only: needs a terminal; run accept afterwards). In-tree *.proposed.laws.ts files (the usual proposals) promote with --all or by name
37
+ tslaws list [--json] the laws files and proposals of the project: the level each runs at (from its name, or its imports with levels: imports) and whether it is accepted, unaccepted or proposed (reads files and the lock; runs nothing)
38
+ tslaws mcp [--compiler PATH] serve check, check_proposed, explain and propose as Model Context Protocol tools on stdio (cannot accept, promote or touch the lock)
39
+ ${migrateUsage}
31
40
 
32
- Without files, every *.laws.ts under the current directory is checked (*.proposed.laws.ts only with --proposed).
41
+ Without files, every *.laws.ts under the current directory (and every file the "laws" globs of tslaws.json pick that declares a law) is checked; with --proposed, the in-tree *.proposed.laws.ts files are (and any proposals in .tslaws/proposals/), with no lock and no coverage gate.
42
+ A laws file\'s level comes from its name by default: *.browser.laws.ts is a browser law, *.e2e.laws.ts an end-to-end law, anything else runs in Node. With "levels": "imports" in tslaws.json it comes from the import graph instead (tslaws/browser, tslaws/adapters/* or tslaws/e2e, directly or through the file\'s own modules), and the names are hints.
33
43
  Exit status is 0 only when every law is proved, bounded or tested and nothing is open, failed, uncovered or tampered with.
34
44
  --json machine-readable report; each result carries a hint with the next action (same as --reporter json)
35
45
  --reporter text (default), json, junit (JUnit XML), github (workflow annotations; appends a table to $GITHUB_STEP_SUMMARY) or ndjson (one JSON event per line as the check proceeds, then the report; for editors)
@@ -37,23 +47,9 @@ Exit status is 0 only when every law is proved, bounded or tested and nothing is
37
47
  --summary one line per non-passing law, then the totals line
38
48
  --watch rerun the check when a .ts file under the current directory changes (Ctrl+C exits)
39
49
  --force init: overwrite tslaws.json and replace the AGENTS.md section`;
40
- const ignoredDirs = new Set(['node_modules', '.tslaws', '.git']);
41
50
  const passing = new Set(['proved', 'bounded', 'tested']);
42
51
  function discover(cwd, proposed) {
43
- const found = [];
44
- const walk = (dir) => {
45
- for (const entry of readdirSync(dir)) {
46
- if (ignoredDirs.has(entry))
47
- continue;
48
- const full = join(dir, entry);
49
- if (statSync(full).isDirectory())
50
- walk(full);
51
- else if (entry.endsWith('.laws.ts') && entry.endsWith('.proposed.laws.ts') === proposed)
52
- found.push(full);
53
- }
54
- };
55
- walk(cwd);
56
- return found.map((f) => relative(cwd, f).replace(/\\/g, '/')).sort();
52
+ return proposed ? [...discoverLaws(cwd, true), ...discoverProposals(cwd)] : discoverLaws(cwd, false);
57
53
  }
58
54
  function parse(args) {
59
55
  const flags = new Map();
@@ -73,6 +69,9 @@ function parse(args) {
73
69
  'max',
74
70
  'for',
75
71
  'root',
72
+ 'to',
73
+ 'from',
74
+ 'out',
76
75
  ]);
77
76
  for (let i = 0; i < args.length; i += 1) {
78
77
  const arg = args[i];
@@ -92,6 +91,9 @@ function parse(args) {
92
91
  arg === 'regressions' ||
93
92
  arg === 'mcp' ||
94
93
  arg === 'promote' ||
94
+ arg === 'propose' ||
95
+ arg === 'list' ||
96
+ arg === 'migrate' ||
95
97
  arg === 'delegate'))
96
98
  command = arg;
97
99
  else
@@ -146,6 +148,10 @@ function render(report, flags) {
146
148
  text += `${report.compileDiagnostics}\n\n`;
147
149
  if (report.lockMissing)
148
150
  text += 'tsc-laws.lock is missing: a person must run `tslaws accept`\n\n';
151
+ for (const notice of report.notices)
152
+ text += `notice: ${notice}\n`;
153
+ if (report.notices.length > 0)
154
+ text += '\n';
149
155
  if (flags.has('summary')) {
150
156
  for (const result of report.results)
151
157
  if (!passing.has(result.status))
@@ -201,10 +207,14 @@ async function runCheck(cwd, flags, files) {
201
207
  process.stdout.write(render(report, flags));
202
208
  return report.ok ? 0 : 1;
203
209
  }
204
- function watched(filename) {
210
+ export function watched(filename) {
205
211
  if (filename === null || !filename.endsWith('.ts'))
206
212
  return false;
207
- return !filename.split(/[\\/]/).some((part) => ignoredDirs.has(part));
213
+ const parts = filename.split(/[\\/]/);
214
+ // The proposals directory is watched (a saved proposal reruns `check --proposed`); the rest of .tslaws is not.
215
+ if (parts[0] === '.tslaws' && parts[1] === 'proposals' && !parts.slice(2, -1).some((p) => ignoredDirs.has(p)))
216
+ return true;
217
+ return !parts.some((part) => ignoredDirs.has(part));
208
218
  }
209
219
  /** Checks now and after every .ts change under cwd (debounced 300 ms). Resolves the last exit status on Ctrl+C. */
210
220
  async function watchChecks(cwd, flags, files) {
@@ -270,16 +280,22 @@ export async function acceptCommand(cwd, flags, files, ctx) {
270
280
  out(`${laws.length} laws in ${chosen.length} files:\n${laws.map((l) => ` ${l.file}:${l.line} ${l.name}`).join('\n')}\n`);
271
281
  if (!gate.delegated) {
272
282
  const rl = createInterface({ input: process.stdin, output: process.stdout });
273
- const answer = await rl.question('Type ACCEPT to write tsc-laws.lock: ');
283
+ const answer = await rl.question('Write tsc-laws.lock? Type accept, yes or y to confirm: ');
274
284
  rl.close();
275
- if (answer.trim() !== 'ACCEPT')
285
+ if (!['accept', 'yes', 'y'].includes(answer.trim().toLowerCase())) {
286
+ out('Nothing written: the answer was not accept, yes or y. Run `tslaws accept` again to retry.\n');
276
287
  return 1;
288
+ }
277
289
  }
278
290
  const before = fileHash(opts.lockFile);
279
291
  const exempt = exemptionsOf(opts);
280
292
  if (exempt.length > 0)
281
293
  out(`exempt from coverage:\n${exempt.map((e) => ` ${e}`).join('\n')}\n`);
282
- writeLock(opts.lockFile, laws, exempt);
294
+ // With levels from imports the lock records each laws file's resolved level, so a helper that starts importing
295
+ // tslaws/browser reads as tampered until a person accepts again. By name (the default) the lock is unchanged.
296
+ const mode = readConfig(cwd).levels ?? 'names';
297
+ const levels = lockLevels(cwd, laws, mode);
298
+ writeLock(opts.lockFile, laws, exempt, levels);
283
299
  out('tsc-laws.lock written\n');
284
300
  if (gate.delegated) {
285
301
  logDelegatedAccept(cwd, gate.grant, before, fileHash(opts.lockFile), ctx.now ?? new Date());
@@ -333,6 +349,105 @@ function delegateCommand(cwd, action, flags, ctx) {
333
349
  out(`accept is lent to agents for ${granted.root} until ${granted.expires}\n`);
334
350
  return 0;
335
351
  }
352
+ const readStdin = async () => {
353
+ let text = '';
354
+ for await (const chunk of process.stdin)
355
+ text += String(chunk);
356
+ return text;
357
+ };
358
+ /**
359
+ * `tslaws propose <file> [--from PATH]`: writes a proposal into `.tslaws/proposals/` from `--from PATH` or from stdin.
360
+ * Anyone may run it, agents included: it writes only that directory and never touches the lock or the tree.
361
+ */
362
+ export async function proposeCommand(cwd, flags, files, ctx) {
363
+ const out = ctx.out ?? stdoutWrite;
364
+ const err = ctx.err ?? stderrWrite;
365
+ const [file] = files;
366
+ if (file === undefined || files.length > 1) {
367
+ err('usage: tslaws propose <file.ts> [--from PATH] (the proposal comes from PATH, or from stdin)\n');
368
+ return 1;
369
+ }
370
+ const from = flags.get('from');
371
+ if (from === true || from === '') {
372
+ err('--from takes a path\n');
373
+ return 1;
374
+ }
375
+ if (from === undefined && ctx.isTTY && ctx.stdin === undefined) {
376
+ err('pipe the proposal in on stdin, or name a file with --from PATH\n');
377
+ return 1;
378
+ }
379
+ try {
380
+ const source = from === undefined ? await (ctx.stdin ?? readStdin)() : readFileSync(resolve(cwd, from), 'utf8');
381
+ const written = addProposal(cwd, file, source);
382
+ out(`${written.file}: ${written.laws.length} law${written.laws.length === 1 ? '' : 's'} (${written.laws.join(', ')})\n` +
383
+ `Try it with \`tslaws check --proposed\`. A proposal protects nothing: a person promotes it with \`tslaws promote ${written.file}\`.\n`);
384
+ return 0;
385
+ }
386
+ catch (error) {
387
+ if (error instanceof ProposalError || error.code === 'ENOENT') {
388
+ err(`${error instanceof Error ? error.message : String(error)}\n`);
389
+ return 1;
390
+ }
391
+ throw error;
392
+ }
393
+ }
394
+ /**
395
+ * `tslaws promote <proposal> [--to PATH]`: a person moves a proposal out of `.tslaws/proposals/` into the tree. Refused
396
+ * without a terminal, under CI, as an agent and with TSLAWS_AGENT set; a delegation does not cover it. Never writes the
397
+ * lock. In-tree `*.proposed.laws.ts` files promote, per law, into the file of the same kind.
398
+ */
399
+ export async function promoteCommand(cwd, flags, files, ctx) {
400
+ const out = ctx.out ?? stdoutWrite;
401
+ const err = ctx.err ?? stderrWrite;
402
+ if (!personOnly({ isTTY: ctx.isTTY, env: ctx.env })) {
403
+ err('promote moves laws into the tree and needs a person at a terminal; it will not run here\n');
404
+ return 1;
405
+ }
406
+ const older = discoverLaws(cwd, true);
407
+ const known = [...discoverProposals(cwd), ...older];
408
+ const chosen = files.length > 0 ? files : flags.has('all') ? older : [];
409
+ if (chosen.length === 0) {
410
+ err(known.length === 0
411
+ ? 'no proposals found (they live in .tslaws/proposals/)\n'
412
+ : `name a proposal to promote:\n${known.map((f) => ` ${f}`).join('\n')}\n`);
413
+ return 1;
414
+ }
415
+ const to = flags.get('to');
416
+ if (to === true || to === '' || (to !== undefined && chosen.length > 1)) {
417
+ err('--to takes one path, for one proposal\n');
418
+ return 1;
419
+ }
420
+ const rl = ctx.ask === undefined ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
421
+ const ask = ctx.ask ?? ((question) => rl.question(question));
422
+ const checkFile = ctx.checkFile ??
423
+ (async (file) => {
424
+ const report = await check(options(cwd, flags, [file]), false);
425
+ const brief = (r) => r.message === undefined ? r.status : `${r.status}: ${r.message.split('\n')[0]}`;
426
+ return new Map(report.results.map((r) => [r.name, brief(r)]));
427
+ });
428
+ try {
429
+ for (const file of chosen) {
430
+ const rel = relative(cwd, resolve(cwd, file)).replaceAll('\\', '/');
431
+ if (isProposedLawFile(rel) && !file.startsWith('.tslaws')) {
432
+ await promoteFiles({ cwd, ask, out, checkFile }, [rel]);
433
+ continue;
434
+ }
435
+ try {
436
+ await promoteProposal({ cwd, ask, out, checkFile }, file, to);
437
+ }
438
+ catch (error) {
439
+ if (!(error instanceof ProposalError))
440
+ throw error;
441
+ err(`${error.message}\n`);
442
+ return 1;
443
+ }
444
+ }
445
+ }
446
+ finally {
447
+ rl?.close();
448
+ }
449
+ return 0;
450
+ }
336
451
  export async function main(args) {
337
452
  if (args.includes('--help') || args.includes('-h')) {
338
453
  process.stdout.write(`${usage}\n`);
@@ -348,39 +463,19 @@ export async function main(args) {
348
463
  return acceptCommand(cwd, flags, files, { isTTY: process.stdin.isTTY === true, env: process.env });
349
464
  if (command === 'delegate')
350
465
  return delegateCommand(cwd, files[0], flags, { isTTY: process.stdin.isTTY === true, env: process.env });
466
+ if (command === 'migrate')
467
+ return migrateCommand(cwd, files[0], files.slice(1), flags, (text) => process.stdout.write(text));
351
468
  if (command === 'mcp')
352
469
  return serveMcp({ cwd, compiler: options(cwd, flags, []).compiler });
353
- if (command === 'promote') {
354
- if (!process.stdin.isTTY || process.env.TSLAWS_AGENT !== undefined || process.env.CI !== undefined) {
355
- process.stderr.write('promote moves laws into the accepted set and needs a person at a terminal; it will not run here\n');
356
- return 1;
357
- }
358
- const proposedFiles = files.length > 0 ? lawFiles(cwd, flags, files) : flags.has('all') ? discover(cwd, true) : [];
359
- if (proposedFiles.length === 0) {
360
- const known = discover(cwd, true).map((f) => ` ${f}`);
361
- process.stderr.write(files.length > 0 || flags.has('all')
362
- ? 'no proposed laws files found (looking for *.proposed.laws.ts)\n'
363
- : `name a proposed file or pass --all:\n${known.join('\n')}\n`);
364
- return 1;
365
- }
366
- const rl = createInterface({ input: process.stdin, output: process.stdout });
367
- try {
368
- await promoteFiles({
369
- cwd,
370
- ask: (question) => rl.question(question),
371
- out: (text) => process.stdout.write(text),
372
- checkFile: async (file) => {
373
- const report = await check(options(cwd, flags, [file]), false);
374
- const brief = (r) => r.message === undefined ? r.status : `${r.status}: ${r.message.split('\n')[0]}`;
375
- return new Map(report.results.map((r) => [r.name, brief(r)]));
376
- },
377
- }, proposedFiles);
378
- }
379
- finally {
380
- rl.close();
381
- }
470
+ if (command === 'promote')
471
+ return promoteCommand(cwd, flags, files, { isTTY: process.stdin.isTTY === true, env: process.env });
472
+ if (command === 'list') {
473
+ const listed = listLawFiles(cwd);
474
+ process.stdout.write(flags.has('json') ? `${JSON.stringify({ files: listed }, null, 2)}\n` : renderList(listed));
382
475
  return 0;
383
476
  }
477
+ if (command === 'propose')
478
+ return proposeCommand(cwd, flags, files, { isTTY: process.stdin.isTTY === true, env: process.env });
384
479
  if (command === 'mutate') {
385
480
  // *.laws.ts arguments choose the laws; other files restrict which covered sources are mutated.
386
481
  const chosen = lawFiles(cwd, flags, files.filter((f) => f.endsWith('.laws.ts')));
package/dist/compile.d.ts CHANGED
@@ -1,10 +1,22 @@
1
+ import ts from 'typescript';
1
2
  import type { ManifestLaw } from './types.ts';
2
3
  export interface BuildResult {
3
4
  outDir: string;
4
5
  /** Diagnostics the compiler printed; empty when the files type-check. */
5
6
  diagnostics: string;
6
7
  ok: boolean;
8
+ /** Names this exact build (laws, options, every file read); set when the compiler ran in-process, so derived results can be kept. */
9
+ key?: string;
10
+ /** The program the in-process compiler built, still holding its type checker; not stored with the build cache. */
11
+ program?: ts.Program;
7
12
  }
13
+ /** The compiler name that selects the built-in path: stock TypeScript, with laws written as `law(...)` calls. */
14
+ export declare const builtinCompiler = "builtin";
15
+ /**
16
+ * Whether these files go through the built-in path. An explicit `builtin` always does; otherwise a project that
17
+ * still writes `law name(...)` keeps using the tsc-laws compiler, the only parser of that syntax.
18
+ */
19
+ export declare function usesBuiltin(compiler: string, files: string[], cwd: string): boolean;
8
20
  export declare function readManifest(compiler: string, files: string[], cwd: string): ManifestLaw[];
9
21
  /**
10
22
  * The compiler flags for the `compilerOptions` of tslaws.json. Only the supported keys pass; anything else is an
package/dist/compile.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { transformSync } from 'esbuild';
6
+ import ts from 'typescript';
7
+ import { lowerLawFile, usesLegacySyntax } from "./lower.js";
4
8
  import { readConfig } from "./coverage.js";
5
9
  /**
6
10
  * Runs the compiler, trying again when the system could not start it. On Windows a freshly built executable can be
@@ -42,7 +46,63 @@ function cannotRun(compiler, error) {
42
46
  return `no tsc-laws compiler found (${compiler}): install the tslaws-compiler package for this platform, or set TSC_LAWS_BIN / --compiler to the compiler executable`;
43
47
  return `cannot run ${compiler}: ${error.message}`;
44
48
  }
49
+ /** The compiler name that selects the built-in path: stock TypeScript, with laws written as `law(...)` calls. */
50
+ export const builtinCompiler = 'builtin';
51
+ const slash = (path) => path.replaceAll(String.fromCharCode(92), '/');
52
+ const lowered = new Map();
53
+ /** Lowers `file` (project-relative), reusing the last result while its text is unchanged. */
54
+ function lowerFile(file, cwd) {
55
+ const text = readFileSync(join(cwd, file), 'utf8');
56
+ const key = slash(resolve(cwd, file));
57
+ const cached = lowered.get(key);
58
+ if (cached?.text === text)
59
+ return cached.result;
60
+ const result = lowerLawFile(slash(file), text);
61
+ lowered.set(key, { text, result });
62
+ return result;
63
+ }
64
+ /**
65
+ * Whether the file holds laws: a `.laws.ts` name, or any file the project points at (a "laws" glob, a proposal in
66
+ * `.tslaws/proposals`) whose text declares a law, as a call or with the keyword.
67
+ */
68
+ function isLawFile(file, cwd) {
69
+ if (/\.laws\.tsx?$/.test(file))
70
+ return true;
71
+ if (!existsSync(join(cwd, file)))
72
+ return false;
73
+ const text = readFileSync(join(cwd, file), 'utf8');
74
+ return /\blaw\b/.test(text) && (usesLegacySyntax(text) || lowerFile(file, cwd).laws.length > 0);
75
+ }
76
+ /**
77
+ * Whether these files go through the built-in path. An explicit `builtin` always does; otherwise a project that
78
+ * still writes `law name(...)` keeps using the tsc-laws compiler, the only parser of that syntax.
79
+ */
80
+ export function usesBuiltin(compiler, files, cwd) {
81
+ if (compiler === builtinCompiler)
82
+ return true;
83
+ return !files.some((f) => isLawFile(f, cwd) && existsSync(join(cwd, f)) && usesLegacySyntax(readFileSync(join(cwd, f), 'utf8')));
84
+ }
85
+ /**
86
+ * The tsc-laws compiler runs a project that uses the keyword syntax (`export law name(...)`) and does not know
87
+ * `law('name', ...)` calls, so a project holding both would silently lose the calls. Say so instead.
88
+ */
89
+ function assertOneSyntax(files, cwd) {
90
+ const present = files.filter((f) => isLawFile(f, cwd) && existsSync(join(cwd, f)));
91
+ const keyword = present.filter((f) => usesLegacySyntax(readFileSync(join(cwd, f), 'utf8')));
92
+ if (keyword.length === 0)
93
+ return;
94
+ const calls = present.filter((f) => lowerFile(f, cwd).laws.length > 0);
95
+ if (calls.length === 0)
96
+ return;
97
+ throw new Error(`this project mixes the two ways of writing a law: ${keyword.join(', ')} use the keyword syntax (\`export law name(...)\`, which needs the tsc-laws compiler) and ${calls.join(', ')} call \`law('name', ...)\`. ` +
98
+ 'Write every law one way: convert the keyword laws to calls to drop the custom compiler, or keep the keyword syntax throughout.');
99
+ }
45
100
  export function readManifest(compiler, files, cwd) {
101
+ if (usesBuiltin(compiler, files, cwd))
102
+ return files
103
+ .filter((f) => isLawFile(f, cwd))
104
+ .flatMap((f) => (existsSync(join(cwd, f)) ? lowerFile(f, cwd).laws : []));
105
+ assertOneSyntax(files, cwd);
46
106
  const run = launch(compiler, ['--laws-manifest', ...files], {
47
107
  cwd,
48
108
  encoding: 'utf8',
@@ -105,10 +165,6 @@ export function compilerFlags(options) {
105
165
  /** Compiles `files` to CommonJS plus declarations in `.tslaws/out` under `cwd`, so `node_modules` still resolves. */
106
166
  export function build(compiler, files, cwd) {
107
167
  const outDir = join(cwd, '.tslaws', 'out');
108
- rmSync(outDir, { recursive: true, force: true });
109
- mkdirSync(outDir, { recursive: true });
110
- // The output is CommonJS whatever the project's own package.json says.
111
- writeFileSync(join(outDir, 'package.json'), JSON.stringify({ type: 'commonjs' }));
112
168
  const extra = compilerFlags(readConfig(cwd).compilerOptions);
113
169
  const args = [
114
170
  '--ignoreConfig',
@@ -129,6 +185,16 @@ export function build(compiler, files, cwd) {
129
185
  ...extra,
130
186
  ...files,
131
187
  ];
188
+ const builtin = usesBuiltin(compiler, files, cwd);
189
+ const reused = builtin ? cachedBuild(args, files, cwd, outDir) : undefined;
190
+ if (reused !== undefined)
191
+ return reused;
192
+ rmSync(outDir, { recursive: true, force: true });
193
+ mkdirSync(outDir, { recursive: true });
194
+ // The output is CommonJS whatever the project's own package.json says.
195
+ writeFileSync(join(outDir, 'package.json'), JSON.stringify({ type: 'commonjs' }));
196
+ if (builtin)
197
+ return buildBuiltin(args, files, cwd, outDir);
132
198
  let run = launch(compiler, args, { cwd, encoding: 'utf8', maxBuffer: 64 << 20, env: compilerEnv(cwd, compiler) });
133
199
  // A compiler that dies without a word (out of memory on a busy machine) is run again; a type error always says so.
134
200
  for (let attempt = 0; attempt < 2 &&
@@ -140,3 +206,138 @@ export function build(compiler, files, cwd) {
140
206
  throw new Error(cannotRun(compiler, run.error));
141
207
  return { outDir, diagnostics: `${run.stdout ?? ''}${run.stderr ?? ''}`.trim(), ok: run.status === 0 };
142
208
  }
209
+ /** Every `node_modules/@types` from `cwd` upwards: where the compiler looks for global declarations by default. */
210
+ function typeRootsOf(cwd) {
211
+ const roots = [];
212
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
213
+ const candidate = join(dir, 'node_modules', '@types');
214
+ if (existsSync(candidate))
215
+ roots.push(candidate);
216
+ if (dirname(dir) === dir)
217
+ return roots;
218
+ }
219
+ }
220
+ /** Compiles in-process with stock TypeScript: law files are lowered in memory, nothing is written next to them. */
221
+ function buildBuiltin(args, files, cwd, outDir) {
222
+ const at = args.indexOf('--moduleResolution');
223
+ const parsed = ts.parseCommandLine(at < 0 ? args : [...args.slice(0, at), ...args.slice(at + 2)]);
224
+ // Types resolve the bundler way, so package `exports` (tslaws/browser) work; the ES output is made CommonJS below.
225
+ const options = {
226
+ ...parsed.options,
227
+ module: ts.ModuleKind.ESNext,
228
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
229
+ noEmitOnError: false,
230
+ // Every @types package in the tree is a parse the laws rarely need; none is read unless `types` says so or the laws fail
231
+ // to compile without them (below).
232
+ ...(parsed.options.types === undefined ? { types: [] } : {}),
233
+ // The DOM declarations are a megabyte to parse; only laws that run in a page (or an `lib` setting) ask for them.
234
+ ...(parsed.options.lib === undefined && !files.some((f) => /\.(browser|e2e)\.laws\.[cm]?ts$/.test(f))
235
+ ? { lib: ['lib.es2022.d.ts'] }
236
+ : {}),
237
+ };
238
+ const overrides = new Map();
239
+ for (const file of files)
240
+ if (isLawFile(file, cwd) && existsSync(join(cwd, file)))
241
+ overrides.set(slash(resolve(cwd, file)), lowerFile(file, cwd).text);
242
+ const host = ts.createCompilerHost(options);
243
+ const getSourceFile = host.getSourceFile.bind(host);
244
+ host.getSourceFile = (name, languageVersion, onError, shouldCreate) => {
245
+ const text = overrides.get(slash(resolve(name)));
246
+ return text === undefined
247
+ ? getSourceFile(name, languageVersion, onError, shouldCreate)
248
+ : ts.createSourceFile(name, text, languageVersion, true);
249
+ };
250
+ const roots = parsed.fileNames.map((f) => resolve(cwd, f));
251
+ let program = ts.createProgram(roots, options, host);
252
+ let diagnostics = ts.getPreEmitDiagnostics(program);
253
+ // An error may only be a global the narrow setup left out (`process`, `document`, a test runner's types): check again
254
+ // with exactly what the compiler's own defaults include before reporting it, so no law is judged on less than before.
255
+ if ((options.types !== parsed.options.types || options.lib !== parsed.options.lib) &&
256
+ diagnostics.some((d) => d.category === ts.DiagnosticCategory.Error)) {
257
+ // `@types` is looked up from the project, not from wherever the process happens to be (the compiler remembers the first directory it saw).
258
+ const typeRoots = parsed.options.typeRoots ?? typeRootsOf(cwd);
259
+ const full = { ...options, types: parsed.options.types, lib: parsed.options.lib, typeRoots };
260
+ program = ts.createProgram(roots, full, host);
261
+ diagnostics = ts.getPreEmitDiagnostics(program);
262
+ }
263
+ program.emit(undefined, (name, text) => {
264
+ mkdirSync(dirname(name), { recursive: true });
265
+ const code = name.endsWith('.js')
266
+ ? transformSync(text, { format: 'cjs', target: 'es2022', sourcefile: name }).code
267
+ : text;
268
+ writeFileSync(name, code);
269
+ });
270
+ const text = ts.formatDiagnostics(diagnostics, {
271
+ getCanonicalFileName: (f) => f,
272
+ getCurrentDirectory: () => cwd,
273
+ getNewLine: () => String.fromCharCode(10),
274
+ });
275
+ const result = {
276
+ outDir,
277
+ diagnostics: text.trim(),
278
+ ok: diagnostics.every((d) => d.category !== ts.DiagnosticCategory.Error),
279
+ };
280
+ Object.defineProperty(result, 'program', { value: program, enumerable: false });
281
+ saveBuildCache(args, files, cwd, result, program
282
+ .getSourceFiles()
283
+ .map((f) => f.fileName)
284
+ .filter((f) => !overrides.has(slash(resolve(f)))));
285
+ return result;
286
+ }
287
+ const buildCachePath = (cwd) => join(cwd, '.tslaws', 'build-cache.json');
288
+ function buildKey(args, files, cwd) {
289
+ const hash = createHash('sha256');
290
+ hash.update(JSON.stringify([args, ts.version, toolStamp]));
291
+ for (const file of files)
292
+ if (isLawFile(file, cwd) && existsSync(join(cwd, file)))
293
+ hash.update(`
294
+ ${file}
295
+ ${lowerFile(file, cwd).text}`);
296
+ return hash.digest('hex');
297
+ }
298
+ /** Changes whenever this compiler code does, so a new tslaws never reuses output made by an old one. */
299
+ const toolStamp = (() => {
300
+ try {
301
+ return statSync(new URL(import.meta.url)).mtimeMs;
302
+ }
303
+ catch {
304
+ return 0;
305
+ }
306
+ })();
307
+ function cachedBuild(args, files, cwd, outDir) {
308
+ try {
309
+ const cache = JSON.parse(readFileSync(buildCachePath(cwd), 'utf8'));
310
+ if (cache.base !== buildKey(args, files, cwd) || !existsSync(join(outDir, 'package.json')))
311
+ return undefined;
312
+ for (const [file, [size, mtime]] of Object.entries(cache.files)) {
313
+ const stat = statSync(file);
314
+ if (stat.size !== size || stat.mtimeMs !== mtime)
315
+ return undefined;
316
+ }
317
+ return cache.result;
318
+ }
319
+ catch {
320
+ return undefined;
321
+ }
322
+ }
323
+ function saveBuildCache(args, files, cwd, result, read) {
324
+ try {
325
+ const stats = {};
326
+ for (const file of read) {
327
+ const stat = statSync(file);
328
+ stats[file] = [stat.size, stat.mtimeMs];
329
+ }
330
+ const base = buildKey(args, files, cwd);
331
+ // The key also names the files read, so anything worked out from this build is dropped when one of them changes.
332
+ result.key = createHash('sha256').update(base).update(JSON.stringify(stats)).digest('hex');
333
+ // A failed build is not kept: the file it could not find is not among the files read, so creating it would not
334
+ // invalidate the entry and the failure would be served again.
335
+ if (!result.ok)
336
+ return;
337
+ const cache = { base, result, files: stats };
338
+ writeFileSync(buildCachePath(cwd), JSON.stringify(cache));
339
+ }
340
+ catch {
341
+ // No cache is only slower.
342
+ }
343
+ }
@@ -6,6 +6,16 @@ export interface CoverageConfig {
6
6
  compilerOptions?: Record<string, unknown>;
7
7
  /** End-to-end laws: the app to drive; see `parseE2eConfig`. */
8
8
  e2e?: E2eConfig;
9
+ /**
10
+ * Extra globs for laws files with other names: any file they match that declares a top-level `export law` is a laws
11
+ * file, besides every file named `*.laws.ts`. A leading `!` excludes. Default: none.
12
+ */
13
+ laws?: string[];
14
+ /**
15
+ * Where a laws file's level (Node, browser, end to end) comes from. `names` (the default): the file name
16
+ * (`*.browser.laws.ts`, `*.e2e.laws.ts`). `imports`: the file's import graph; names are still recognised as hints.
17
+ */
18
+ levels?: 'names' | 'imports';
9
19
  /** esbuild plugin modules (paths relative to tslaws.json) that teach the browser bundler new source formats. */
10
20
  bundlerPlugins?: string[];
11
21
  /** Run settings; a command line flag beats them and they beat the built-in default. */
@@ -36,7 +46,9 @@ export interface CoverageInput {
36
46
  lawFiles: string[];
37
47
  /** Files named by the `cover` globs; empty means every module the laws reach. */
38
48
  coverFiles: string[];
39
- /** Declarations the compiler emitted for the program. */
49
+ /** Declarations the compiler emitted for the program, or the program that compiled the code (then `moduleOf` is given). */
40
50
  program: ts.Program;
51
+ /** Where a module's exports are read from, by its path relative to `cwd`; the emitted declaration when absent. */
52
+ moduleOf?: (file: string) => string;
41
53
  }
42
54
  export declare function analyzeCoverage(input: CoverageInput): CoverageReport;