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.
- package/README.md +8 -8
- package/dist/api.d.ts +10 -0
- package/dist/api.js +7 -0
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +2 -6
- package/dist/check.d.ts +2 -0
- package/dist/check.js +133 -41
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +149 -54
- package/dist/compile.d.ts +12 -0
- package/dist/compile.js +207 -6
- package/dist/coverage.d.ts +13 -1
- package/dist/coverage.js +16 -23
- package/dist/derived.d.ts +8 -0
- package/dist/derived.js +52 -0
- package/dist/e2e.d.ts +0 -9
- package/dist/e2e.js +0 -20
- package/dist/generators.d.ts +8 -8
- package/dist/generators.js +59 -99
- package/dist/globs.d.ts +3 -0
- package/dist/globs.js +24 -0
- package/dist/init.js +5 -5
- package/dist/lawFiles.d.ts +21 -0
- package/dist/lawFiles.js +95 -0
- package/dist/lawKinds.d.ts +38 -0
- package/dist/lawKinds.js +210 -0
- package/dist/lawNames.d.ts +6 -0
- package/dist/lawNames.js +22 -0
- package/dist/list.d.ts +22 -0
- package/dist/list.js +36 -0
- package/dist/lock.d.ts +16 -3
- package/dist/lock.js +23 -4
- package/dist/lower.d.ts +10 -0
- package/dist/lower.js +178 -0
- package/dist/mcp.d.ts +2 -8
- package/dist/mcp.js +40 -61
- package/dist/migrate/arbitraries.d.ts +23 -0
- package/dist/migrate/arbitraries.js +202 -0
- package/dist/migrate/convert.d.ts +20 -0
- package/dist/migrate/convert.js +915 -0
- package/dist/migrate/framework.d.ts +48 -0
- package/dist/migrate/framework.js +249 -0
- package/dist/migrate/index.d.ts +16 -0
- package/dist/migrate/index.js +155 -0
- package/dist/migrate/report.d.ts +35 -0
- package/dist/migrate/report.js +40 -0
- package/dist/migrate/source.d.ts +42 -0
- package/dist/migrate/source.js +163 -0
- package/dist/migrate/tests.d.ts +44 -0
- package/dist/migrate/tests.js +184 -0
- package/dist/plan.d.ts +50 -0
- package/dist/plan.js +118 -0
- package/dist/pool.d.ts +21 -0
- package/dist/pool.js +54 -0
- package/dist/promote.d.ts +30 -2
- package/dist/promote.js +133 -7
- package/dist/property.d.ts +12 -26
- package/dist/property.js +33 -74
- package/dist/propertyRun.d.ts +24 -0
- package/dist/propertyRun.js +56 -0
- package/dist/propertyWorker.js +7 -9
- package/dist/proposals.d.ts +43 -0
- package/dist/proposals.js +192 -0
- package/dist/regressions.js +23 -2
- package/dist/smt.js +9 -8
- package/dist/types.d.ts +2 -0
- package/hooks/AGENTS.snippet.md +2 -2
- package/package.json +8 -6
package/dist/cli.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
|
-
import {
|
|
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
|
|
30
|
-
tslaws
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
+
}
|
package/dist/coverage.d.ts
CHANGED
|
@@ -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;
|