tslaws 0.0.0-stage → 0.3.2
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/LICENSE +202 -0
- package/README.md +215 -2
- package/bin/tslaws.js +5 -0
- package/dist/adapters/angular.d.ts +42 -0
- package/dist/adapters/angular.js +40 -0
- package/dist/adapters/dom.d.ts +5 -0
- package/dist/adapters/dom.js +17 -0
- package/dist/adapters/element.d.ts +10 -0
- package/dist/adapters/element.js +28 -0
- package/dist/adapters/react.d.ts +24 -0
- package/dist/adapters/react.js +15 -0
- package/dist/api.d.ts +59 -0
- package/dist/api.js +103 -0
- package/dist/browser.d.ts +24 -0
- package/dist/browser.js +252 -0
- package/dist/browserApi.d.ts +62 -0
- package/dist/browserApi.js +139 -0
- package/dist/bundled.d.ts +17 -0
- package/dist/bundled.js +67 -0
- package/dist/check.d.ts +12 -0
- package/dist/check.js +269 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +360 -0
- package/dist/compile.d.ts +15 -0
- package/dist/compile.js +142 -0
- package/dist/component.d.ts +37 -0
- package/dist/component.js +99 -0
- package/dist/coverage.d.ts +40 -0
- package/dist/coverage.js +237 -0
- package/dist/e2e.d.ts +46 -0
- package/dist/e2e.js +322 -0
- package/dist/e2eApi.d.ts +80 -0
- package/dist/e2eApi.js +140 -0
- package/dist/generators.d.ts +18 -0
- package/dist/generators.js +184 -0
- package/dist/hints.d.ts +3 -0
- package/dist/hints.js +37 -0
- package/dist/init.d.ts +23 -0
- package/dist/init.js +139 -0
- package/dist/lock.d.ts +16 -0
- package/dist/lock.js +40 -0
- package/dist/mcp.d.ts +30 -0
- package/dist/mcp.js +301 -0
- package/dist/mutate.d.ts +54 -0
- package/dist/mutate.js +293 -0
- package/dist/promote.d.ts +38 -0
- package/dist/promote.js +217 -0
- package/dist/property.d.ts +37 -0
- package/dist/property.js +172 -0
- package/dist/propertyWorker.d.ts +1 -0
- package/dist/propertyWorker.js +24 -0
- package/dist/queries.d.ts +37 -0
- package/dist/queries.js +297 -0
- package/dist/regressions.d.ts +31 -0
- package/dist/regressions.js +153 -0
- package/dist/reporters.d.ts +22 -0
- package/dist/reporters.js +160 -0
- package/dist/settings.d.ts +16 -0
- package/dist/settings.js +15 -0
- package/dist/smt.d.ts +33 -0
- package/dist/smt.js +396 -0
- package/dist/symbolic.d.ts +200 -0
- package/dist/symbolic.js +1727 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.js +1 -0
- package/dist/ui.d.ts +16 -0
- package/dist/ui.js +326 -0
- package/dist/uiPage.d.ts +1 -0
- package/dist/uiPage.js +4 -0
- package/hooks/AGENTS.snippet.md +10 -0
- package/hooks/claude-hook.mjs +35 -0
- package/hooks/claude-settings.example.json +8 -0
- package/package.json +85 -4
- package/ui/index.html +1132 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { readdirSync, statSync, watch } from 'node:fs';
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { resolveEngine } from "./browser.js";
|
|
5
|
+
import { bundledCompiler, checkoutCompiler } from "./bundled.js";
|
|
6
|
+
import { check, exemptionsOf } from "./check.js";
|
|
7
|
+
import { readManifest } from "./compile.js";
|
|
8
|
+
import { hintFor } from "./hints.js";
|
|
9
|
+
import { init, nextStep } from "./init.js";
|
|
10
|
+
import { serveMcp } from "./mcp.js";
|
|
11
|
+
import { promoteFiles } from "./promote.js";
|
|
12
|
+
import { clearCorpus, listCorpus } from "./regressions.js";
|
|
13
|
+
import { reporterNames, reportAs } from "./reporters.js";
|
|
14
|
+
import { readConfig } from "./coverage.js";
|
|
15
|
+
import { mutate, renderMutation } from "./mutate.js";
|
|
16
|
+
import { mutateDefaults, resolveSettings } from "./settings.js";
|
|
17
|
+
import { serveUi } from "./ui.js";
|
|
18
|
+
import { writeLock } from "./lock.js";
|
|
19
|
+
const usage = `tslaws init [--force] scaffold tslaws.json, .gitignore, AGENTS.md, Claude Code hooks and a starter law (never writes the lock)
|
|
20
|
+
tslaws check [files...] [--reporter text|json|junit|github] [--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]
|
|
21
|
+
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
|
|
22
|
+
tslaws mutate [files...] [--max N] [--seed N] [--runs N] [--bound N] [--proof-timeout MS] [--property-timeout MS] [--browser ENGINE] [--json] mutation-test the laws: mutate covered code, report mutants no law kills
|
|
23
|
+
tslaws regressions [list|clear] show or delete the stored counterexamples (.tslaws/regressions); check replays them first, --no-regressions skips them
|
|
24
|
+
tslaws accept [files...] record the current laws in tsc-laws.lock (people only: needs a terminal)
|
|
25
|
+
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)
|
|
26
|
+
tslaws mcp [--compiler PATH] serve check, check_proposed, explain and propose as Model Context Protocol tools on stdio (cannot accept or touch the lock)
|
|
27
|
+
|
|
28
|
+
Without files, every *.laws.ts under the current directory is checked (*.proposed.laws.ts only with --proposed).
|
|
29
|
+
Exit status is 0 only when every law is proved, bounded or tested and nothing is open, failed, uncovered or tampered with.
|
|
30
|
+
--json machine-readable report; each result carries a hint with the next action (same as --reporter json)
|
|
31
|
+
--reporter text (default), json, junit (JUnit XML) or github (workflow annotations; appends a table to $GITHUB_STEP_SUMMARY)
|
|
32
|
+
--summary one line per non-passing law, then the totals line
|
|
33
|
+
--watch rerun the check when a .ts file under the current directory changes (Ctrl+C exits)
|
|
34
|
+
--force init: overwrite tslaws.json and replace the AGENTS.md section`;
|
|
35
|
+
const ignoredDirs = new Set(['node_modules', '.tslaws', '.git']);
|
|
36
|
+
const passing = new Set(['proved', 'bounded', 'tested']);
|
|
37
|
+
function discover(cwd, proposed) {
|
|
38
|
+
const found = [];
|
|
39
|
+
const walk = (dir) => {
|
|
40
|
+
for (const entry of readdirSync(dir)) {
|
|
41
|
+
if (ignoredDirs.has(entry))
|
|
42
|
+
continue;
|
|
43
|
+
const full = join(dir, entry);
|
|
44
|
+
if (statSync(full).isDirectory())
|
|
45
|
+
walk(full);
|
|
46
|
+
else if (entry.endsWith('.laws.ts') && entry.endsWith('.proposed.laws.ts') === proposed)
|
|
47
|
+
found.push(full);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(cwd);
|
|
51
|
+
return found.map((f) => relative(cwd, f).replace(/\\/g, '/')).sort();
|
|
52
|
+
}
|
|
53
|
+
function parse(args) {
|
|
54
|
+
const flags = new Map();
|
|
55
|
+
const files = [];
|
|
56
|
+
let command = 'check';
|
|
57
|
+
const valued = new Set([
|
|
58
|
+
'runs',
|
|
59
|
+
'seed',
|
|
60
|
+
'compiler',
|
|
61
|
+
'bound',
|
|
62
|
+
'proof-timeout',
|
|
63
|
+
'property-timeout',
|
|
64
|
+
'browser',
|
|
65
|
+
'port',
|
|
66
|
+
'reporter',
|
|
67
|
+
'max',
|
|
68
|
+
]);
|
|
69
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
70
|
+
const arg = args[i];
|
|
71
|
+
if (arg.startsWith('--')) {
|
|
72
|
+
const name = arg.slice(2);
|
|
73
|
+
if (valued.has(name))
|
|
74
|
+
flags.set(name, args[(i += 1)] ?? '');
|
|
75
|
+
else
|
|
76
|
+
flags.set(name, true);
|
|
77
|
+
}
|
|
78
|
+
else if (i === 0 &&
|
|
79
|
+
(arg === 'check' ||
|
|
80
|
+
arg === 'accept' ||
|
|
81
|
+
arg === 'init' ||
|
|
82
|
+
arg === 'ui' ||
|
|
83
|
+
arg === 'mutate' ||
|
|
84
|
+
arg === 'regressions' ||
|
|
85
|
+
arg === 'mcp' ||
|
|
86
|
+
arg === 'promote'))
|
|
87
|
+
command = arg;
|
|
88
|
+
else
|
|
89
|
+
files.push(arg);
|
|
90
|
+
}
|
|
91
|
+
return { command, flags, files };
|
|
92
|
+
}
|
|
93
|
+
function describe(result) {
|
|
94
|
+
const where = `${result.file}:${result.line}`;
|
|
95
|
+
const head = `${result.status.toUpperCase().padEnd(8)} ${result.name} (${where}) [${result.method}]`;
|
|
96
|
+
const lines = [head];
|
|
97
|
+
if (result.message !== undefined)
|
|
98
|
+
lines.push(` ${result.message}`);
|
|
99
|
+
if (result.counterexample !== undefined)
|
|
100
|
+
lines.push(` counterexample: ${result.counterexample}`);
|
|
101
|
+
if (result.status === 'failed' && result.seed !== undefined)
|
|
102
|
+
lines.push(` reproduce with --seed ${result.seed}`);
|
|
103
|
+
const hint = hintFor(result);
|
|
104
|
+
if (hint !== undefined)
|
|
105
|
+
lines.push(` hint: ${hint}`);
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
function summarise(result) {
|
|
109
|
+
const detail = [
|
|
110
|
+
result.message,
|
|
111
|
+
result.counterexample !== undefined ? `counterexample: ${result.counterexample}` : undefined,
|
|
112
|
+
]
|
|
113
|
+
.filter((d) => d !== undefined)
|
|
114
|
+
.join(' | ')
|
|
115
|
+
.replace(/\s+/g, ' ');
|
|
116
|
+
return `${result.status.toUpperCase()} ${result.name} (${result.file}:${result.line})${detail === '' ? '' : ` ${detail}`}`;
|
|
117
|
+
}
|
|
118
|
+
function totals(report) {
|
|
119
|
+
const count = (status) => report.results.filter((r) => r.status === status).length;
|
|
120
|
+
return `${report.results.length} laws: ${count('proved')} proved, ${count('bounded')} bounded, ${count('tested')} tested, ${count('open')} open, ${count('failed')} failed, ${count('tampered')} tampered, ${count('error')} errors`;
|
|
121
|
+
}
|
|
122
|
+
function render(report, flags) {
|
|
123
|
+
const reporter = String(flags.get('reporter') ?? (flags.has('json') ? 'json' : 'text'));
|
|
124
|
+
if (!reporterNames.includes(reporter)) {
|
|
125
|
+
throw new Error(`unknown reporter '${reporter}' (expected ${reporterNames.join(', ')})`);
|
|
126
|
+
}
|
|
127
|
+
if (reporter === 'junit' || reporter === 'github')
|
|
128
|
+
return reportAs(reporter, report);
|
|
129
|
+
if (reporter === 'json') {
|
|
130
|
+
return `${JSON.stringify({ ...report, results: report.results.map((r) => ({ ...r, hint: hintFor(r) })) }, null, 2)}\n`;
|
|
131
|
+
}
|
|
132
|
+
let text = '';
|
|
133
|
+
if (report.compileDiagnostics !== '')
|
|
134
|
+
text += `${report.compileDiagnostics}\n\n`;
|
|
135
|
+
if (report.lockMissing)
|
|
136
|
+
text += 'tsc-laws.lock is missing: a person must run `tslaws accept`\n\n';
|
|
137
|
+
if (flags.has('summary')) {
|
|
138
|
+
for (const result of report.results)
|
|
139
|
+
if (!passing.has(result.status))
|
|
140
|
+
text += `${summarise(result)}\n`;
|
|
141
|
+
return `${text}${totals(report)}\n`;
|
|
142
|
+
}
|
|
143
|
+
for (const result of report.results)
|
|
144
|
+
text += `${describe(result)}\n`;
|
|
145
|
+
return `${text}\n${totals(report)}\n`;
|
|
146
|
+
}
|
|
147
|
+
function options(cwd, flags, chosen) {
|
|
148
|
+
const settings = resolveSettings(flags, readConfig(cwd));
|
|
149
|
+
return {
|
|
150
|
+
files: chosen,
|
|
151
|
+
cwd,
|
|
152
|
+
runs: settings.runs,
|
|
153
|
+
seed: flags.has('seed') ? Number(flags.get('seed')) : undefined,
|
|
154
|
+
compiler: String(flags.get('compiler') ?? process.env.TSC_LAWS_BIN ?? bundledCompiler() ?? checkoutCompiler(cwd) ?? 'tsc-laws'),
|
|
155
|
+
lockFile: join(cwd, 'tsc-laws.lock'),
|
|
156
|
+
noProve: flags.has('no-prove'),
|
|
157
|
+
noRegressions: flags.has('no-regressions'),
|
|
158
|
+
bound: settings.bound,
|
|
159
|
+
proofTimeoutMs: settings.proofTimeoutMs,
|
|
160
|
+
propertyTimeoutMs: settings.propertyTimeoutMs,
|
|
161
|
+
explicit: settings.explicit,
|
|
162
|
+
browser: flags.has('browser')
|
|
163
|
+
? resolveEngine(String(flags.get('browser')))
|
|
164
|
+
: undefined,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function lawFiles(cwd, flags, files) {
|
|
168
|
+
return files.length > 0
|
|
169
|
+
? files.map((f) => relative(cwd, resolve(cwd, f)).replace(/\\/g, '/'))
|
|
170
|
+
: discover(cwd, flags.has('proposed'));
|
|
171
|
+
}
|
|
172
|
+
/** One check of the project. Resolves the exit status. */
|
|
173
|
+
async function runCheck(cwd, flags, files) {
|
|
174
|
+
const chosen = lawFiles(cwd, flags, files);
|
|
175
|
+
if (chosen.length === 0) {
|
|
176
|
+
process.stderr.write('no laws files found (looking for *.laws.ts)\n');
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
const report = await check(options(cwd, flags, chosen), !flags.has('proposed'));
|
|
180
|
+
process.stdout.write(render(report, flags));
|
|
181
|
+
return report.ok ? 0 : 1;
|
|
182
|
+
}
|
|
183
|
+
function watched(filename) {
|
|
184
|
+
if (filename === null || !filename.endsWith('.ts'))
|
|
185
|
+
return false;
|
|
186
|
+
return !filename.split(/[\\/]/).some((part) => ignoredDirs.has(part));
|
|
187
|
+
}
|
|
188
|
+
/** Checks now and after every .ts change under cwd (debounced 300 ms). Resolves the last exit status on Ctrl+C. */
|
|
189
|
+
async function watchChecks(cwd, flags, files) {
|
|
190
|
+
let last = 0;
|
|
191
|
+
let running = false;
|
|
192
|
+
let queued;
|
|
193
|
+
let timer;
|
|
194
|
+
const cycle = async (why) => {
|
|
195
|
+
if (running) {
|
|
196
|
+
queued = why;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
running = true;
|
|
200
|
+
let next = why;
|
|
201
|
+
while (next !== undefined) {
|
|
202
|
+
queued = undefined;
|
|
203
|
+
process.stdout.write(`\n==== ${new Date().toLocaleTimeString()} tslaws check (${next}) ====\n`);
|
|
204
|
+
try {
|
|
205
|
+
last = await runCheck(cwd, flags, files);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
209
|
+
last = 1;
|
|
210
|
+
}
|
|
211
|
+
process.stdout.write(`==== exit status ${last}${last === 0 ? ' (ok)' : ' (failing)'}; watching for changes, Ctrl+C exits ====\n`);
|
|
212
|
+
next = queued;
|
|
213
|
+
}
|
|
214
|
+
running = false;
|
|
215
|
+
};
|
|
216
|
+
await cycle('initial run');
|
|
217
|
+
return new Promise((done) => {
|
|
218
|
+
const watcher = watch(cwd, { recursive: true }, (_event, filename) => {
|
|
219
|
+
const name = filename === null ? null : String(filename);
|
|
220
|
+
if (name === null || !watched(name))
|
|
221
|
+
return;
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
timer = setTimeout(() => void cycle(`changed: ${name.split(sep).join('/')}`), 300);
|
|
224
|
+
});
|
|
225
|
+
process.once('SIGINT', () => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
watcher.close();
|
|
228
|
+
done(last);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
export async function main(args) {
|
|
233
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
234
|
+
process.stdout.write(`${usage}\n`);
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
const { command, flags, files } = parse(args);
|
|
238
|
+
const cwd = process.cwd();
|
|
239
|
+
if (command === 'init') {
|
|
240
|
+
process.stdout.write(`${init({ cwd, force: flags.has('force') }).join('\n')}\n\n${nextStep}\n`);
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
if (command === 'accept') {
|
|
244
|
+
const chosen = lawFiles(cwd, flags, files);
|
|
245
|
+
if (chosen.length === 0) {
|
|
246
|
+
process.stderr.write('no laws files found (looking for *.laws.ts)\n');
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
if (!process.stdin.isTTY || process.env.TSLAWS_AGENT !== undefined || process.env.CI !== undefined) {
|
|
250
|
+
process.stderr.write('accept records laws as correct and needs a person at a terminal; it will not run here\n');
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
const opts = options(cwd, flags, chosen);
|
|
254
|
+
const laws = readManifest(opts.compiler, opts.files, cwd);
|
|
255
|
+
process.stdout.write(`${laws.length} laws in ${chosen.length} files:\n${laws.map((l) => ` ${l.file}:${l.line} ${l.name}`).join('\n')}\n`);
|
|
256
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
257
|
+
const answer = await rl.question('Type ACCEPT to write tsc-laws.lock: ');
|
|
258
|
+
rl.close();
|
|
259
|
+
if (answer.trim() !== 'ACCEPT')
|
|
260
|
+
return 1;
|
|
261
|
+
const exempt = exemptionsOf(opts);
|
|
262
|
+
if (exempt.length > 0)
|
|
263
|
+
process.stdout.write(`exempt from coverage:\n${exempt.map((e) => ` ${e}`).join('\n')}\n`);
|
|
264
|
+
writeLock(opts.lockFile, laws, exempt);
|
|
265
|
+
process.stdout.write('tsc-laws.lock written\n');
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
if (command === 'mcp')
|
|
269
|
+
return serveMcp({ cwd, compiler: options(cwd, flags, []).compiler });
|
|
270
|
+
if (command === 'promote') {
|
|
271
|
+
if (!process.stdin.isTTY || process.env.TSLAWS_AGENT !== undefined || process.env.CI !== undefined) {
|
|
272
|
+
process.stderr.write('promote moves laws into the accepted set and needs a person at a terminal; it will not run here\n');
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
const proposedFiles = files.length > 0 ? lawFiles(cwd, flags, files) : flags.has('all') ? discover(cwd, true) : [];
|
|
276
|
+
if (proposedFiles.length === 0) {
|
|
277
|
+
const known = discover(cwd, true).map((f) => ` ${f}`);
|
|
278
|
+
process.stderr.write(files.length > 0 || flags.has('all')
|
|
279
|
+
? 'no proposed laws files found (looking for *.proposed.laws.ts)\n'
|
|
280
|
+
: `name a proposed file or pass --all:\n${known.join('\n')}\n`);
|
|
281
|
+
return 1;
|
|
282
|
+
}
|
|
283
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
284
|
+
try {
|
|
285
|
+
await promoteFiles({
|
|
286
|
+
cwd,
|
|
287
|
+
ask: (question) => rl.question(question),
|
|
288
|
+
out: (text) => process.stdout.write(text),
|
|
289
|
+
checkFile: async (file) => {
|
|
290
|
+
const report = await check(options(cwd, flags, [file]), false);
|
|
291
|
+
const brief = (r) => r.message === undefined ? r.status : `${r.status}: ${r.message.split('\n')[0]}`;
|
|
292
|
+
return new Map(report.results.map((r) => [r.name, brief(r)]));
|
|
293
|
+
},
|
|
294
|
+
}, proposedFiles);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
rl.close();
|
|
298
|
+
}
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
if (command === 'mutate') {
|
|
302
|
+
// *.laws.ts arguments choose the laws; other files restrict which covered sources are mutated.
|
|
303
|
+
const chosen = lawFiles(cwd, flags, files.filter((f) => f.endsWith('.laws.ts')));
|
|
304
|
+
if (chosen.length === 0) {
|
|
305
|
+
process.stderr.write('no laws files found (looking for *.laws.ts)\n');
|
|
306
|
+
return 1;
|
|
307
|
+
}
|
|
308
|
+
const settings = resolveSettings(flags, readConfig(cwd), mutateDefaults);
|
|
309
|
+
const report = await mutate({
|
|
310
|
+
cwd,
|
|
311
|
+
lawFiles: chosen,
|
|
312
|
+
lawsNamed: files.some((f) => f.endsWith('.laws.ts')),
|
|
313
|
+
runs: settings.runs,
|
|
314
|
+
bound: settings.bound,
|
|
315
|
+
proofTimeoutMs: settings.proofTimeoutMs,
|
|
316
|
+
propertyTimeoutMs: settings.propertyTimeoutMs,
|
|
317
|
+
browser: options(cwd, flags, chosen).browser,
|
|
318
|
+
targets: files.filter((f) => !f.endsWith('.laws.ts')).map((f) => relative(cwd, resolve(cwd, f))),
|
|
319
|
+
compiler: options(cwd, flags, chosen).compiler,
|
|
320
|
+
max: Number(flags.get('max') ?? 50),
|
|
321
|
+
seed: flags.has('seed') ? Number(flags.get('seed')) : undefined,
|
|
322
|
+
onProgress: flags.has('json') ? undefined : (line) => process.stderr.write(`${line}\n`),
|
|
323
|
+
});
|
|
324
|
+
process.stdout.write(flags.has('json') ? `${JSON.stringify(report, null, 2)}\n` : renderMutation(report));
|
|
325
|
+
return report.ok ? 0 : 1;
|
|
326
|
+
}
|
|
327
|
+
if (command === 'regressions') {
|
|
328
|
+
const action = files[0] ?? 'list';
|
|
329
|
+
if (action === 'clear') {
|
|
330
|
+
process.stdout.write(clearCorpus(cwd) ? 'cleared .tslaws/regressions\n' : 'no regression corpus to clear\n');
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
if (action !== 'list') {
|
|
334
|
+
process.stderr.write(`unknown action '${action}' (expected list or clear)\n`);
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
const corpus = listCorpus(cwd);
|
|
338
|
+
process.stdout.write(corpus.length === 0
|
|
339
|
+
? 'no stored counterexamples\n'
|
|
340
|
+
: `${corpus.map((e) => `${e.file} ${e.law}: ${e.count} example${e.count === 1 ? '' : 's'}`).join('\n')}\n`);
|
|
341
|
+
return 0;
|
|
342
|
+
}
|
|
343
|
+
if (command === 'ui') {
|
|
344
|
+
const chosen = lawFiles(cwd, flags, files);
|
|
345
|
+
if (chosen.length === 0) {
|
|
346
|
+
process.stderr.write('no laws files found (looking for *.laws.ts)\n');
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
return serveUi({
|
|
350
|
+
cwd,
|
|
351
|
+
port: Number(flags.get('port') ?? 51205),
|
|
352
|
+
open: !flags.has('no-open'),
|
|
353
|
+
appWindow: !flags.has('tab'),
|
|
354
|
+
// A watcher sees a browser page only if it stays as the law left it for a moment.
|
|
355
|
+
run: (only, onEvent, laws) => check({ ...options(cwd, flags, only ?? chosen), only: laws, onEvent, browserPauseMs: 120 }, !flags.has('proposed')),
|
|
356
|
+
log: (line) => process.stdout.write(`${line}\n`),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return flags.has('watch') ? watchChecks(cwd, flags, files) : runCheck(cwd, flags, files);
|
|
360
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ManifestLaw } from './types.ts';
|
|
2
|
+
export interface BuildResult {
|
|
3
|
+
outDir: string;
|
|
4
|
+
/** Diagnostics the compiler printed; empty when the files type-check. */
|
|
5
|
+
diagnostics: string;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function readManifest(compiler: string, files: string[], cwd: string): ManifestLaw[];
|
|
9
|
+
/**
|
|
10
|
+
* The compiler flags for the `compilerOptions` of tslaws.json. Only the supported keys pass; anything else is an
|
|
11
|
+
* error rather than an arbitrary flag handed to the compiler. `strict` is the one default these can override.
|
|
12
|
+
*/
|
|
13
|
+
export declare function compilerFlags(options: Record<string, unknown> | undefined): string[];
|
|
14
|
+
/** Compiles `files` to CommonJS plus declarations in `.tslaws/out` under `cwd`, so `node_modules` still resolves. */
|
|
15
|
+
export declare function build(compiler: string, files: string[], cwd: string): BuildResult;
|
package/dist/compile.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { readConfig } from "./coverage.js";
|
|
5
|
+
/**
|
|
6
|
+
* Runs the compiler, trying again when the system could not start it. On Windows a freshly built executable can be
|
|
7
|
+
* briefly unavailable (a scanner holds it, the machine is out of handles), which surfaces as ENOENT or UNKNOWN.
|
|
8
|
+
*/
|
|
9
|
+
function launch(compiler, args, options) {
|
|
10
|
+
let run = spawnSync(compiler, args, options);
|
|
11
|
+
for (let attempt = 0; attempt < 4 && run.error !== undefined && /UNKNOWN|EBUSY|EAGAIN|EMFILE/.test(String(run.error)); attempt += 1) {
|
|
12
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250 * (attempt + 1));
|
|
13
|
+
run = spawnSync(compiler, args, options);
|
|
14
|
+
}
|
|
15
|
+
return run;
|
|
16
|
+
}
|
|
17
|
+
/** The compiler finds its lib files in TSC_TS_LIB_DIR; without it, use the TypeScript 7 libs scriptc ships. */
|
|
18
|
+
function compilerEnv(cwd, compiler) {
|
|
19
|
+
if (process.env.TSC_TS_LIB_DIR !== undefined)
|
|
20
|
+
return process.env;
|
|
21
|
+
// The npm compiler package keeps the lib files beside the executable.
|
|
22
|
+
const beside = dirname(compiler);
|
|
23
|
+
if (existsSync(join(beside, 'lib.d.ts')))
|
|
24
|
+
return { ...process.env, TSC_TS_LIB_DIR: beside };
|
|
25
|
+
for (let dir = cwd;; dir = dirname(dir)) {
|
|
26
|
+
const scope = join(dir, 'node_modules', '@scriptc');
|
|
27
|
+
if (existsSync(scope)) {
|
|
28
|
+
for (const entry of readdirSync(scope)) {
|
|
29
|
+
const lib = join(scope, entry, 'dist', 'lib', 'typescript', 'lib');
|
|
30
|
+
if (existsSync(join(lib, 'lib.d.ts')))
|
|
31
|
+
return { ...process.env, TSC_TS_LIB_DIR: lib };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (dirname(dir) === dir)
|
|
35
|
+
return process.env;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Lists the laws of `files` with the problems that stop them running. */
|
|
39
|
+
/** Says what to do when the compiler cannot start, in particular when none was found at all. */
|
|
40
|
+
function cannotRun(compiler, error) {
|
|
41
|
+
if (error.code === 'ENOENT')
|
|
42
|
+
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
|
+
return `cannot run ${compiler}: ${error.message}`;
|
|
44
|
+
}
|
|
45
|
+
export function readManifest(compiler, files, cwd) {
|
|
46
|
+
const run = launch(compiler, ['--laws-manifest', ...files], {
|
|
47
|
+
cwd,
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
maxBuffer: 64 << 20,
|
|
50
|
+
env: compilerEnv(cwd, compiler),
|
|
51
|
+
});
|
|
52
|
+
if (run.error !== undefined)
|
|
53
|
+
throw new Error(cannotRun(compiler, run.error));
|
|
54
|
+
const text = (run.stdout ?? '').trim();
|
|
55
|
+
if (text === '')
|
|
56
|
+
throw new Error(`${compiler} --laws-manifest printed nothing: ${run.stderr ?? ''}`);
|
|
57
|
+
return JSON.parse(text).laws;
|
|
58
|
+
}
|
|
59
|
+
const stringOptions = ['jsx', 'jsxFactory', 'jsxFragmentFactory', 'jsxImportSource'];
|
|
60
|
+
const booleanOptions = [
|
|
61
|
+
'experimentalDecorators',
|
|
62
|
+
'emitDecoratorMetadata',
|
|
63
|
+
'useDefineForClassFields',
|
|
64
|
+
'strict',
|
|
65
|
+
'noUncheckedIndexedAccess',
|
|
66
|
+
'exactOptionalPropertyTypes',
|
|
67
|
+
'allowJs',
|
|
68
|
+
'checkJs',
|
|
69
|
+
];
|
|
70
|
+
const listOptions = ['lib', 'types'];
|
|
71
|
+
const jsxModes = ['react', 'react-jsx', 'react-jsxdev', 'preserve'];
|
|
72
|
+
const supportedOptions = [...stringOptions, ...booleanOptions, ...listOptions];
|
|
73
|
+
/**
|
|
74
|
+
* The compiler flags for the `compilerOptions` of tslaws.json. Only the supported keys pass; anything else is an
|
|
75
|
+
* error rather than an arbitrary flag handed to the compiler. `strict` is the one default these can override.
|
|
76
|
+
*/
|
|
77
|
+
export function compilerFlags(options) {
|
|
78
|
+
const flags = [];
|
|
79
|
+
for (const [key, value] of Object.entries(options ?? {})) {
|
|
80
|
+
if (!supportedOptions.includes(key))
|
|
81
|
+
throw new Error(`tslaws.json compilerOptions: unsupported option "${key}"; supported options are ${supportedOptions.join(', ')}`);
|
|
82
|
+
const bad = (expected) => new Error(`tslaws.json compilerOptions: "${key}" must be ${expected}, got ${JSON.stringify(value)}`);
|
|
83
|
+
if (stringOptions.includes(key)) {
|
|
84
|
+
if (typeof value !== 'string' || value === '')
|
|
85
|
+
throw bad('a string');
|
|
86
|
+
if (key === 'jsx' && !jsxModes.includes(value))
|
|
87
|
+
throw bad(`one of ${jsxModes.join(', ')}`);
|
|
88
|
+
flags.push(`--${key}`, value);
|
|
89
|
+
}
|
|
90
|
+
else if (booleanOptions.includes(key)) {
|
|
91
|
+
if (typeof value !== 'boolean')
|
|
92
|
+
throw bad('true or false');
|
|
93
|
+
flags.push(`--${key}`);
|
|
94
|
+
if (!value)
|
|
95
|
+
flags.push('false');
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
|
|
99
|
+
throw bad('an array of strings');
|
|
100
|
+
flags.push(`--${key}`, value.join(','));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return flags;
|
|
104
|
+
}
|
|
105
|
+
/** Compiles `files` to CommonJS plus declarations in `.tslaws/out` under `cwd`, so `node_modules` still resolves. */
|
|
106
|
+
export function build(compiler, files, cwd) {
|
|
107
|
+
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
|
+
const extra = compilerFlags(readConfig(cwd).compilerOptions);
|
|
113
|
+
const args = [
|
|
114
|
+
'--ignoreConfig',
|
|
115
|
+
'--target',
|
|
116
|
+
'es2022',
|
|
117
|
+
'--module',
|
|
118
|
+
'commonjs',
|
|
119
|
+
'--moduleResolution',
|
|
120
|
+
'bundler',
|
|
121
|
+
...(extra.includes('--strict') ? [] : ['--strict']),
|
|
122
|
+
'--skipLibCheck',
|
|
123
|
+
'--esModuleInterop',
|
|
124
|
+
'--declaration',
|
|
125
|
+
'--rootDir',
|
|
126
|
+
cwd,
|
|
127
|
+
'--outDir',
|
|
128
|
+
outDir,
|
|
129
|
+
...extra,
|
|
130
|
+
...files,
|
|
131
|
+
];
|
|
132
|
+
let run = launch(compiler, args, { cwd, encoding: 'utf8', maxBuffer: 64 << 20, env: compilerEnv(cwd, compiler) });
|
|
133
|
+
// A compiler that dies without a word (out of memory on a busy machine) is run again; a type error always says so.
|
|
134
|
+
for (let attempt = 0; attempt < 2 &&
|
|
135
|
+
run.error === undefined &&
|
|
136
|
+
run.status !== 0 &&
|
|
137
|
+
`${run.stdout ?? ''}${run.stderr ?? ''}`.trim() === ''; attempt += 1)
|
|
138
|
+
run = launch(compiler, args, { cwd, encoding: 'utf8', maxBuffer: 64 << 20, env: compilerEnv(cwd, compiler) });
|
|
139
|
+
if (run.error !== undefined)
|
|
140
|
+
throw new Error(cannotRun(compiler, run.error));
|
|
141
|
+
return { outDir, diagnostics: `${run.stdout ?? ''}${run.stderr ?? ''}`.trim(), ok: run.status === 0 };
|
|
142
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Queries } from './queries.ts';
|
|
2
|
+
/** What `render` hands back: nothing, an unmount function, or an object that can update and unmount. */
|
|
3
|
+
export type Rendered<P> = void | (() => void) | {
|
|
4
|
+
update?(props: P): void | Promise<void>;
|
|
5
|
+
unmount?(): void;
|
|
6
|
+
};
|
|
7
|
+
/** Teaches `mountComponent` one framework. `render` may be async (Angular bootstraps asynchronously). */
|
|
8
|
+
export interface Adapter<C, P> {
|
|
9
|
+
render(container: HTMLElement, component: C, props: P): Rendered<P> | Promise<Rendered<P>>;
|
|
10
|
+
}
|
|
11
|
+
export interface Mounted<P> {
|
|
12
|
+
/** The container the component rendered into. */
|
|
13
|
+
el: HTMLElement;
|
|
14
|
+
/** The props of the latest render. */
|
|
15
|
+
props: P;
|
|
16
|
+
/** Renders again with new props and waits for the DOM to settle. */
|
|
17
|
+
rerender(next: P): Promise<void>;
|
|
18
|
+
/** Unmounts now; the end of the law does it anyway. */
|
|
19
|
+
unmount(): void;
|
|
20
|
+
/** Queries bound to `el`. */
|
|
21
|
+
query: Queries;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Renders `component` with `props` into a fresh container (see `mount`), waits for the DOM to settle, and unmounts at
|
|
25
|
+
* the end of the law.
|
|
26
|
+
*/
|
|
27
|
+
export declare function mountComponent<C, P>(adapter: Adapter<C, P>, component: C, props: P): Promise<Mounted<P>>;
|
|
28
|
+
/** A callback you can observe: records the arguments of each call. `impl` runs and its result is returned. */
|
|
29
|
+
export declare function spy<A extends unknown[], R>(impl?: (...a: A) => R): ((...a: A) => R) & {
|
|
30
|
+
calls: A[];
|
|
31
|
+
count: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The classic component law: rendering is a pure function of props. Renders with `a`, rerenders with `b`, and compares
|
|
35
|
+
* the markup with a fresh render of `b`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function rendersSame<C, P>(adapter: Adapter<C, P>, component: C, a: P, b: P): Promise<boolean>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// Component-level testing: render ONE component in isolation with generated props, through a thin adapter for the
|
|
3
|
+
// framework (or none). tslaws has no framework dependency: the adapters take the framework's functions.
|
|
4
|
+
import { mount, settle } from "./browserApi.js";
|
|
5
|
+
import { within } from "./queries.js";
|
|
6
|
+
/**
|
|
7
|
+
* Renders `component` with `props` into a fresh container (see `mount`), waits for the DOM to settle, and unmounts at
|
|
8
|
+
* the end of the law.
|
|
9
|
+
*/
|
|
10
|
+
export async function mountComponent(adapter, component, props) {
|
|
11
|
+
// Registered with the same per-law cleanup as mount(): the container is removed and the component unmounted.
|
|
12
|
+
const el = mount(() => () => unmount());
|
|
13
|
+
let handle = {};
|
|
14
|
+
let pending = Promise.resolve();
|
|
15
|
+
let live = true;
|
|
16
|
+
const adopt = (rendered) => {
|
|
17
|
+
handle = typeof rendered === 'function' ? { unmount: rendered } : (rendered ?? {});
|
|
18
|
+
};
|
|
19
|
+
function unmount() {
|
|
20
|
+
if (!live)
|
|
21
|
+
return;
|
|
22
|
+
live = false;
|
|
23
|
+
handle.unmount?.();
|
|
24
|
+
}
|
|
25
|
+
const start = (p) => {
|
|
26
|
+
const rendered = adapter.render(el, component, p);
|
|
27
|
+
if (rendered instanceof Promise)
|
|
28
|
+
pending = rendered.then(adopt);
|
|
29
|
+
else
|
|
30
|
+
adopt(rendered);
|
|
31
|
+
};
|
|
32
|
+
start(props);
|
|
33
|
+
await pending;
|
|
34
|
+
await settle();
|
|
35
|
+
const mounted = {
|
|
36
|
+
el,
|
|
37
|
+
props,
|
|
38
|
+
query: within(el),
|
|
39
|
+
unmount,
|
|
40
|
+
async rerender(next) {
|
|
41
|
+
if (handle.update !== undefined)
|
|
42
|
+
await handle.update(next);
|
|
43
|
+
else {
|
|
44
|
+
handle.unmount?.();
|
|
45
|
+
el.replaceChildren();
|
|
46
|
+
start(next);
|
|
47
|
+
await pending;
|
|
48
|
+
}
|
|
49
|
+
mounted.props = next;
|
|
50
|
+
await settle();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
return mounted;
|
|
54
|
+
}
|
|
55
|
+
/** A callback you can observe: records the arguments of each call. `impl` runs and its result is returned. */
|
|
56
|
+
export function spy(impl) {
|
|
57
|
+
const calls = [];
|
|
58
|
+
const fn = ((...a) => {
|
|
59
|
+
calls.push(a);
|
|
60
|
+
return impl?.(...a);
|
|
61
|
+
});
|
|
62
|
+
fn.calls = calls;
|
|
63
|
+
Object.defineProperty(fn, 'count', { get: () => calls.length, enumerable: true });
|
|
64
|
+
return fn;
|
|
65
|
+
}
|
|
66
|
+
// Markup with the noise removed that differs between a fresh render and an updated one: comment nodes, empty text,
|
|
67
|
+
// whitespace runs and the bookkeeping attributes frameworks add.
|
|
68
|
+
function normalisedHtml(el) {
|
|
69
|
+
const copy = el.cloneNode(true);
|
|
70
|
+
const walker = document.createTreeWalker(copy, NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
|
|
71
|
+
const drop = [];
|
|
72
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
73
|
+
if (node.nodeType === Node.COMMENT_NODE || (node.textContent ?? '').trim() === '')
|
|
74
|
+
drop.push(node);
|
|
75
|
+
}
|
|
76
|
+
for (const node of drop)
|
|
77
|
+
node.parentNode?.removeChild(node);
|
|
78
|
+
for (const node of [copy, ...Array.from(copy.querySelectorAll('*'))]) {
|
|
79
|
+
for (const name of node.getAttributeNames()) {
|
|
80
|
+
if (/^(ng-reflect-|_ng(content|host)-|ng-version$|data-reactroot$|data-react)/.test(name))
|
|
81
|
+
node.removeAttribute(name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return copy.innerHTML.replace(/\s+/g, ' ').replace(/> </g, '><').trim();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The classic component law: rendering is a pure function of props. Renders with `a`, rerenders with `b`, and compares
|
|
88
|
+
* the markup with a fresh render of `b`.
|
|
89
|
+
*/
|
|
90
|
+
export async function rendersSame(adapter, component, a, b) {
|
|
91
|
+
const updated = await mountComponent(adapter, component, a);
|
|
92
|
+
await updated.rerender(b);
|
|
93
|
+
const first = normalisedHtml(updated.el);
|
|
94
|
+
updated.unmount();
|
|
95
|
+
const fresh = await mountComponent(adapter, component, b);
|
|
96
|
+
const second = normalisedHtml(fresh.el);
|
|
97
|
+
fresh.unmount();
|
|
98
|
+
return first === second;
|
|
99
|
+
}
|