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.
Files changed (74) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +215 -2
  3. package/bin/tslaws.js +5 -0
  4. package/dist/adapters/angular.d.ts +42 -0
  5. package/dist/adapters/angular.js +40 -0
  6. package/dist/adapters/dom.d.ts +5 -0
  7. package/dist/adapters/dom.js +17 -0
  8. package/dist/adapters/element.d.ts +10 -0
  9. package/dist/adapters/element.js +28 -0
  10. package/dist/adapters/react.d.ts +24 -0
  11. package/dist/adapters/react.js +15 -0
  12. package/dist/api.d.ts +59 -0
  13. package/dist/api.js +103 -0
  14. package/dist/browser.d.ts +24 -0
  15. package/dist/browser.js +252 -0
  16. package/dist/browserApi.d.ts +62 -0
  17. package/dist/browserApi.js +139 -0
  18. package/dist/bundled.d.ts +17 -0
  19. package/dist/bundled.js +67 -0
  20. package/dist/check.d.ts +12 -0
  21. package/dist/check.js +269 -0
  22. package/dist/cli.d.ts +1 -0
  23. package/dist/cli.js +360 -0
  24. package/dist/compile.d.ts +15 -0
  25. package/dist/compile.js +142 -0
  26. package/dist/component.d.ts +37 -0
  27. package/dist/component.js +99 -0
  28. package/dist/coverage.d.ts +40 -0
  29. package/dist/coverage.js +237 -0
  30. package/dist/e2e.d.ts +46 -0
  31. package/dist/e2e.js +322 -0
  32. package/dist/e2eApi.d.ts +80 -0
  33. package/dist/e2eApi.js +140 -0
  34. package/dist/generators.d.ts +18 -0
  35. package/dist/generators.js +184 -0
  36. package/dist/hints.d.ts +3 -0
  37. package/dist/hints.js +37 -0
  38. package/dist/init.d.ts +23 -0
  39. package/dist/init.js +139 -0
  40. package/dist/lock.d.ts +16 -0
  41. package/dist/lock.js +40 -0
  42. package/dist/mcp.d.ts +30 -0
  43. package/dist/mcp.js +301 -0
  44. package/dist/mutate.d.ts +54 -0
  45. package/dist/mutate.js +293 -0
  46. package/dist/promote.d.ts +38 -0
  47. package/dist/promote.js +217 -0
  48. package/dist/property.d.ts +37 -0
  49. package/dist/property.js +172 -0
  50. package/dist/propertyWorker.d.ts +1 -0
  51. package/dist/propertyWorker.js +24 -0
  52. package/dist/queries.d.ts +37 -0
  53. package/dist/queries.js +297 -0
  54. package/dist/regressions.d.ts +31 -0
  55. package/dist/regressions.js +153 -0
  56. package/dist/reporters.d.ts +22 -0
  57. package/dist/reporters.js +160 -0
  58. package/dist/settings.d.ts +16 -0
  59. package/dist/settings.js +15 -0
  60. package/dist/smt.d.ts +33 -0
  61. package/dist/smt.js +396 -0
  62. package/dist/symbolic.d.ts +200 -0
  63. package/dist/symbolic.js +1727 -0
  64. package/dist/types.d.ts +77 -0
  65. package/dist/types.js +1 -0
  66. package/dist/ui.d.ts +16 -0
  67. package/dist/ui.js +326 -0
  68. package/dist/uiPage.d.ts +1 -0
  69. package/dist/uiPage.js +4 -0
  70. package/hooks/AGENTS.snippet.md +10 -0
  71. package/hooks/claude-hook.mjs +35 -0
  72. package/hooks/claude-settings.example.json +8 -0
  73. package/package.json +85 -4
  74. package/ui/index.html +1132 -0
package/dist/mutate.js ADDED
@@ -0,0 +1,293 @@
1
+ // Mutation testing: change the covered code in small ways and re-run the laws. A mutant that no law fails
2
+ // (SURVIVED) shows the laws cannot tell that behaviour apart; a failed, erroring or timed-out check KILLS it.
3
+ // Every mutant is checked in a scratch copy of the project under .tslaws/mutants/<n>; the originals are never written.
4
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, relative } from 'node:path';
6
+ import ts from 'typescript';
7
+ import { check } from "./check.js";
8
+ import { coverFiles, readConfig } from "./coverage.js";
9
+ import { mutateDefaults } from "./settings.js";
10
+ const ignoredDirs = new Set(['node_modules', '.tslaws', '.git']);
11
+ const sourceExt = /\.(ts|tsx|mts|cts)$/;
12
+ const copiedExt = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|json)$/;
13
+ const relational = {
14
+ [ts.SyntaxKind.LessThanToken]: ['<=', '>='],
15
+ [ts.SyntaxKind.LessThanEqualsToken]: ['<', '>'],
16
+ [ts.SyntaxKind.GreaterThanToken]: ['>=', '<='],
17
+ [ts.SyntaxKind.GreaterThanEqualsToken]: ['>', '<'],
18
+ [ts.SyntaxKind.EqualsEqualsToken]: ['!='],
19
+ [ts.SyntaxKind.ExclamationEqualsToken]: ['=='],
20
+ [ts.SyntaxKind.EqualsEqualsEqualsToken]: ['!=='],
21
+ [ts.SyntaxKind.ExclamationEqualsEqualsToken]: ['==='],
22
+ [ts.SyntaxKind.PlusToken]: ['-'],
23
+ [ts.SyntaxKind.MinusToken]: ['+'],
24
+ [ts.SyntaxKind.AsteriskToken]: ['/'],
25
+ [ts.SyntaxKind.SlashToken]: ['*'],
26
+ [ts.SyntaxKind.AmpersandAmpersandToken]: ['||'],
27
+ [ts.SyntaxKind.BarBarToken]: ['&&'],
28
+ };
29
+ /** The source files the laws cover: the `cover` globs, or every project module the laws import. */
30
+ export function mutableFiles(cwd, lawFiles, reachOnly = false) {
31
+ const globs = readConfig(cwd).cover ?? [];
32
+ const isLaws = (f) => f.endsWith('.laws.ts');
33
+ const found = new Set();
34
+ const reached = new Set();
35
+ if (globs.length > 0 && !reachOnly)
36
+ for (const f of coverFiles(cwd, globs))
37
+ found.add(f);
38
+ else {
39
+ const seen = new Set();
40
+ const visit = (file) => {
41
+ if (seen.has(file))
42
+ return;
43
+ seen.add(file);
44
+ const full = join(cwd, file);
45
+ if (!existsSync(full))
46
+ return;
47
+ for (const imp of ts.preProcessFile(readFileSync(full, 'utf8'), true, true).importedFiles) {
48
+ if (!imp.fileName.startsWith('.'))
49
+ continue;
50
+ const base = join(dirname(file), imp.fileName).replaceAll('\\', '/');
51
+ const stem = base.replace(/\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/, '');
52
+ const hit = [
53
+ base,
54
+ `${stem}.ts`,
55
+ `${stem}.tsx`,
56
+ `${stem}.mts`,
57
+ `${stem}.cts`,
58
+ `${base}/index.ts`,
59
+ `${base}/index.tsx`,
60
+ ].find((c) => sourceExt.test(c) && existsSync(join(cwd, c)) && statSync(join(cwd, c)).isFile());
61
+ if (hit === undefined)
62
+ continue;
63
+ if (!isLaws(hit))
64
+ found.add(hit);
65
+ visit(hit);
66
+ }
67
+ };
68
+ for (const f of lawFiles)
69
+ visit(f);
70
+ // Named laws with a `cover` list: mutate only the covered files those laws reach.
71
+ if (globs.length > 0) {
72
+ const covered = new Set(coverFiles(cwd, globs));
73
+ for (const f of found)
74
+ if (covered.has(f))
75
+ reached.add(f);
76
+ found.clear();
77
+ for (const f of reached)
78
+ found.add(f);
79
+ }
80
+ }
81
+ return [...found]
82
+ .filter((f) => !/\.d\.(ts|mts|cts)$/.test(f) && !isLaws(f) && !/\.(test|spec)\.(ts|tsx|mts|cts)$/.test(f))
83
+ .filter((f) => !f.split('/').some((p) => p === 'test' || p === 'tests' || p === '__tests__'))
84
+ .sort();
85
+ }
86
+ /** Every mutation of one file, in source order. */
87
+ export function mutantsOf(file, text) {
88
+ const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.jsx') ? ts.ScriptKind.JSX : ts.ScriptKind.TS;
89
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, kind);
90
+ const out = [];
91
+ const add = (node, start, end, replacement) => {
92
+ const ns = node.getStart(source);
93
+ const ne = node.getEnd();
94
+ const snip = (s) => {
95
+ const flat = s.replace(/\s+/g, ' ').trim();
96
+ return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
97
+ };
98
+ out.push({
99
+ file,
100
+ line: source.getLineAndCharacterOfPosition(start).line + 1,
101
+ start,
102
+ end,
103
+ replacement,
104
+ original: snip(text.slice(ns, ne)),
105
+ mutated: snip(text.slice(ns, start) + replacement + text.slice(end, ne)),
106
+ });
107
+ };
108
+ const visit = (node) => {
109
+ if (ts.isTypeNode(node))
110
+ return;
111
+ if (ts.isBinaryExpression(node)) {
112
+ const token = node.operatorToken;
113
+ for (const op of relational[token.kind] ?? [])
114
+ add(node, token.getStart(source), token.getEnd(), op);
115
+ }
116
+ else if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) {
117
+ add(node, node.getStart(source), node.getEnd(), node.kind === ts.SyntaxKind.TrueKeyword ? 'false' : 'true');
118
+ }
119
+ else if (ts.isNumericLiteral(node)) {
120
+ const v = Number(node.text);
121
+ const wrap = (n) => (n < 0 ? `(${n})` : String(n));
122
+ for (const n of new Set([v + 1, v - 1, 0])) {
123
+ if (n !== v && Number.isFinite(n))
124
+ add(node, node.getStart(source), node.getEnd(), wrap(n));
125
+ }
126
+ }
127
+ else if (ts.isIfStatement(node)) {
128
+ const c = node.expression;
129
+ add(node.expression, c.getStart(source), c.getEnd(), `!(${c.getText(source)})`);
130
+ }
131
+ else if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
132
+ add(node, node.getStart(source), node.operand.getStart(source), '');
133
+ }
134
+ ts.forEachChild(node, visit);
135
+ };
136
+ visit(source);
137
+ return out;
138
+ }
139
+ function prng(seed) {
140
+ let a = seed >>> 0;
141
+ return () => {
142
+ a = (a + 0x6d2b79f5) >>> 0;
143
+ let t = a;
144
+ t = Math.imul(t ^ (t >>> 15), t | 1);
145
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
146
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
147
+ };
148
+ }
149
+ /** At most `max` mutants: all of them when they fit, else a seeded sample kept in source order. */
150
+ export function sample(all, max, seed) {
151
+ if (all.length <= max)
152
+ return all;
153
+ const rand = prng(seed);
154
+ const idx = all.map((_, i) => i);
155
+ for (let i = idx.length - 1; i > 0; i -= 1) {
156
+ const j = Math.floor(rand() * (i + 1));
157
+ [idx[i], idx[j]] = [idx[j], idx[i]];
158
+ }
159
+ return idx
160
+ .slice(0, max)
161
+ .sort((a, b) => a - b)
162
+ .map((i) => all[i]);
163
+ }
164
+ function copyProject(cwd, to) {
165
+ rmSync(to, { recursive: true, force: true });
166
+ mkdirSync(to, { recursive: true });
167
+ const walk = (dir) => {
168
+ for (const entry of readdirSync(dir)) {
169
+ if (ignoredDirs.has(entry))
170
+ continue;
171
+ const full = join(dir, entry);
172
+ if (statSync(full).isDirectory())
173
+ walk(full);
174
+ else if (copiedExt.test(entry) || entry === 'tsc-laws.lock') {
175
+ const dest = join(to, relative(cwd, full));
176
+ mkdirSync(dirname(dest), { recursive: true });
177
+ cpSync(full, dest);
178
+ }
179
+ }
180
+ };
181
+ walk(cwd);
182
+ }
183
+ export async function mutate(options) {
184
+ const { cwd } = options;
185
+ const say = options.onProgress ?? (() => undefined);
186
+ const scratchRoot = join(cwd, '.tslaws', 'mutants');
187
+ const config = readConfig(cwd);
188
+ const checkOptions = (dir) => ({
189
+ files: options.lawFiles,
190
+ cwd: dir,
191
+ runs: options.runs ?? config.runs ?? mutateDefaults.runs,
192
+ seed: options.seed,
193
+ compiler: options.compiler,
194
+ lockFile: join(dir, 'tsc-laws.lock'),
195
+ noProve: false,
196
+ bound: options.bound ?? config.bound ?? mutateDefaults.bound,
197
+ proofTimeoutMs: options.proofTimeoutMs ?? config.proofTimeoutMs ?? mutateDefaults.proofTimeoutMs,
198
+ propertyTimeoutMs: options.propertyTimeoutMs ?? config.propertyTimeoutMs ?? mutateDefaults.propertyTimeoutMs,
199
+ browser: options.browser,
200
+ // The lock is not enforced on mutants; it is copied so the scratch project is complete.
201
+ explicit: options.runs === undefined ? [] : ['runs'],
202
+ });
203
+ const empty = (baselineError) => ({
204
+ results: [],
205
+ killed: 0,
206
+ survived: 0,
207
+ skipped: 0,
208
+ score: 100,
209
+ baselineError,
210
+ ok: baselineError === undefined,
211
+ });
212
+ try {
213
+ // The unmutated copy must pass, or every mutant would count as killed.
214
+ const baseDir = join(scratchRoot, '0');
215
+ copyProject(cwd, baseDir);
216
+ const base = await check(checkOptions(baseDir), false);
217
+ if (!base.compileOk)
218
+ return empty(`the project does not compile:\n${base.compileDiagnostics}`);
219
+ if (!base.ok) {
220
+ const failing = base.results.find((r) => r.status !== 'proved' && r.status !== 'bounded' && r.status !== 'tested');
221
+ const first = failing?.message?.split(/\r?\n/)[0];
222
+ const which = failing === undefined
223
+ ? 'unknown'
224
+ : `${failing.name} (${failing.file}:${failing.line}) is ${failing.status}${first === undefined ? '' : `: ${first}`}`;
225
+ return empty(`the laws do not pass on the unmutated code; fix them before mutation testing. First failure: ${which}. If it timed out, raise --proof-timeout or --property-timeout (or set them in tslaws.json)`);
226
+ }
227
+ const wanted = new Set((options.targets ?? []).map((t) => t.replaceAll('\\', '/')));
228
+ const files = mutableFiles(cwd, options.lawFiles, options.lawsNamed === true).filter((f) => wanted.size === 0 || wanted.has(f));
229
+ const originals = new Map(files.map((f) => [f, readFileSync(join(cwd, f), 'utf8')]));
230
+ const all = files.flatMap((f) => mutantsOf(f, originals.get(f)));
231
+ const chosen = sample(all, options.max, options.seed ?? 1);
232
+ say(`${chosen.length} of ${all.length} mutants in ${files.length} files`);
233
+ const results = [];
234
+ for (const [n, mutant] of chosen.entries()) {
235
+ const dir = join(scratchRoot, String(n + 1));
236
+ let result;
237
+ try {
238
+ copyProject(cwd, dir);
239
+ const text = originals.get(mutant.file);
240
+ writeFileSync(join(dir, mutant.file), text.slice(0, mutant.start) + mutant.replacement + text.slice(mutant.end));
241
+ const report = await check(checkOptions(dir), false);
242
+ if (!report.compileOk)
243
+ result = { ...mutant, status: 'skipped', reason: 'does not compile' };
244
+ else if (report.ok)
245
+ result = { ...mutant, status: 'survived' };
246
+ else {
247
+ const failing = report.results.find((r) => r.status !== 'proved' && r.status !== 'bounded' && r.status !== 'tested');
248
+ result = {
249
+ ...mutant,
250
+ status: 'killed',
251
+ reason: failing === undefined ? 'check failed' : `${failing.name}: ${failing.status}`,
252
+ };
253
+ }
254
+ }
255
+ catch (error) {
256
+ result = {
257
+ ...mutant,
258
+ status: 'killed',
259
+ reason: `check errored: ${error instanceof Error ? error.message : String(error)}`,
260
+ };
261
+ }
262
+ finally {
263
+ rmSync(dir, { recursive: true, force: true });
264
+ }
265
+ results.push(result);
266
+ say(`[${n + 1}/${chosen.length}] ${result.status} ${mutant.file}:${mutant.line} ${mutant.original} -> ${mutant.mutated}`);
267
+ }
268
+ const killed = results.filter((r) => r.status === 'killed').length;
269
+ const survived = results.filter((r) => r.status === 'survived').length;
270
+ const tried = killed + survived;
271
+ return {
272
+ results,
273
+ killed,
274
+ survived,
275
+ skipped: results.length - tried,
276
+ score: tried === 0 ? 100 : Math.round((killed / tried) * 1000) / 10,
277
+ ok: survived === 0,
278
+ };
279
+ }
280
+ finally {
281
+ rmSync(scratchRoot, { recursive: true, force: true });
282
+ }
283
+ }
284
+ export function renderMutation(report) {
285
+ if (report.baselineError !== undefined)
286
+ return `mutation testing not run: ${report.baselineError}\n`;
287
+ let text = '';
288
+ for (const r of report.results.filter((x) => x.status === 'survived')) {
289
+ text += `SURVIVED ${r.file}:${r.line} ${r.original} -> ${r.mutated}\n hint: add or strengthen a law that tells these apart\n`;
290
+ }
291
+ const tried = report.killed + report.survived;
292
+ return `${text}${text === '' ? '' : '\n'}mutation score ${report.score}% (${report.killed} killed, ${report.survived} survived of ${tried} tried, ${report.skipped} skipped)\n`;
293
+ }
@@ -0,0 +1,38 @@
1
+ export interface ParsedLaw {
2
+ name: string;
3
+ /** The declaration as written, from `export` to its end. */
4
+ text: string;
5
+ start: number;
6
+ end: number;
7
+ }
8
+ export interface ParsedLawFile {
9
+ /** Whole import statements, in order. */
10
+ imports: string[];
11
+ laws: ParsedLaw[];
12
+ }
13
+ /** `foo.proposed.laws.ts` -> `foo.laws.ts`; undefined for any other name. */
14
+ export declare function acceptedPathFor(proposed: string): string | undefined;
15
+ /**
16
+ * Finds the import statements and the `export law ...` declarations of a laws file. `law` is not standard TypeScript, so
17
+ * this scans tokens with the TypeScript scanner (strings, comments and templates are skipped correctly) and matches
18
+ * brackets: a law ends at its closing `}` or at a `;` at depth 0.
19
+ */
20
+ export declare function parseLawFile(text: string): ParsedLawFile;
21
+ /** Joins the import statements, dropping repeats by whitespace-insensitive text. */
22
+ export declare function dedupeImports(existing: string[], added: string[]): string[];
23
+ /**
24
+ * The accepted file after promoting `laws` (with the proposed file's imports) into it. `existing` is the current text
25
+ * of the accepted file, or undefined when it does not exist yet. Throws when a law name is already taken there.
26
+ */
27
+ export declare function buildAccepted(existing: string | undefined, imports: string[], laws: ParsedLaw[]): string;
28
+ /** The proposed file without the promoted laws, or undefined when no law is left (the file is deleted). */
29
+ export declare function buildRemaining(text: string, promoted: string[]): string | undefined;
30
+ export interface PromoteHost {
31
+ cwd: string;
32
+ /** Checks one proposed file and returns its results by law name (as `check --proposed` would). */
33
+ checkFile: (file: string) => Promise<Map<string, string>>;
34
+ ask: (question: string) => Promise<string>;
35
+ out: (text: string) => void;
36
+ }
37
+ /** Walks the proposed files, asks per law, and rewrites the accepted and proposed files. Returns the number promoted. */
38
+ export declare function promoteFiles(host: PromoteHost, proposedFiles: string[]): Promise<number>;
@@ -0,0 +1,217 @@
1
+ // `tslaws promote`: the pure core. A person moves proposed laws into the accepted file; the lock is not touched here.
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import ts from 'typescript';
5
+ /** `foo.proposed.laws.ts` -> `foo.laws.ts`; undefined for any other name. */
6
+ export function acceptedPathFor(proposed) {
7
+ return proposed.endsWith('.proposed.laws.ts')
8
+ ? `${proposed.slice(0, -'.proposed.laws.ts'.length)}.laws.ts`
9
+ : undefined;
10
+ }
11
+ /**
12
+ * Finds the import statements and the `export law ...` declarations of a laws file. `law` is not standard TypeScript, so
13
+ * this scans tokens with the TypeScript scanner (strings, comments and templates are skipped correctly) and matches
14
+ * brackets: a law ends at its closing `}` or at a `;` at depth 0.
15
+ */
16
+ export function parseLawFile(text) {
17
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, text);
18
+ const imports = [];
19
+ const laws = [];
20
+ const templates = []; // depth at which each open template substitution started
21
+ let depth = 0;
22
+ let statement;
23
+ let arrow = false;
24
+ const finish = (end) => {
25
+ if (statement === undefined)
26
+ return;
27
+ if (statement.kind === 'import')
28
+ imports.push(text.slice(statement.start, end));
29
+ else
30
+ laws.push({ name: statement.name, text: text.slice(statement.start, end), start: statement.start, end });
31
+ statement = undefined;
32
+ arrow = false;
33
+ };
34
+ let kind = scanner.scan();
35
+ while (kind !== ts.SyntaxKind.EndOfFileToken) {
36
+ const tokenStart = scanner.getTokenStart();
37
+ if (kind === ts.SyntaxKind.CloseBraceToken && templates.length > 0 && templates[templates.length - 1] === depth) {
38
+ templates.pop();
39
+ kind = scanner.reScanTemplateToken(false);
40
+ if (kind === ts.SyntaxKind.TemplateMiddle)
41
+ templates.push(depth);
42
+ }
43
+ else if (kind === ts.SyntaxKind.TemplateHead) {
44
+ templates.push(depth);
45
+ }
46
+ else if (depth === 0 && statement === undefined) {
47
+ if (kind === ts.SyntaxKind.ImportKeyword) {
48
+ statement = { kind: 'import', start: tokenStart, name: '', sawString: false };
49
+ }
50
+ else if (kind === ts.SyntaxKind.ExportKeyword) {
51
+ const rest = scanner.getTokenEnd();
52
+ const next = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, text, undefined, rest, text.length - rest);
53
+ const word = next.scan();
54
+ if (word === ts.SyntaxKind.Identifier && next.getTokenValue() === 'law') {
55
+ let n = next.scan();
56
+ if (n === ts.SyntaxKind.Identifier && next.getTokenValue() === 'proved')
57
+ n = next.scan();
58
+ if (n === ts.SyntaxKind.Identifier || n > ts.SyntaxKind.LastReservedWord)
59
+ statement = { kind: 'law', start: tokenStart, name: next.getTokenValue(), sawString: false };
60
+ }
61
+ }
62
+ }
63
+ if (kind === ts.SyntaxKind.OpenBraceToken ||
64
+ kind === ts.SyntaxKind.OpenParenToken ||
65
+ kind === ts.SyntaxKind.OpenBracketToken) {
66
+ depth += 1;
67
+ }
68
+ else if (kind === ts.SyntaxKind.CloseBraceToken ||
69
+ kind === ts.SyntaxKind.CloseParenToken ||
70
+ kind === ts.SyntaxKind.CloseBracketToken) {
71
+ depth = Math.max(0, depth - 1);
72
+ if (depth === 0 && kind === ts.SyntaxKind.CloseBraceToken && statement?.kind === 'law') {
73
+ // A block-bodied law ends here; the arrow form ends at its `;`.
74
+ if (!arrow) {
75
+ let end = scanner.getTokenEnd();
76
+ const peek = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, text, undefined, end, text.length - end);
77
+ if (peek.scan() === ts.SyntaxKind.SemicolonToken)
78
+ end = peek.getTokenEnd();
79
+ finish(end);
80
+ if (end > scanner.getTokenEnd())
81
+ scanner.resetTokenState(end);
82
+ kind = scanner.scan();
83
+ continue;
84
+ }
85
+ }
86
+ }
87
+ else if (depth === 0 && kind === ts.SyntaxKind.EqualsGreaterThanToken && statement?.kind === 'law') {
88
+ arrow = true;
89
+ }
90
+ else if (depth === 0 && kind === ts.SyntaxKind.SemicolonToken) {
91
+ finish(scanner.getTokenEnd());
92
+ }
93
+ else if (depth === 0 && statement?.kind === 'import' && kind === ts.SyntaxKind.StringLiteral) {
94
+ statement.sawString = true;
95
+ const end = scanner.getTokenEnd();
96
+ const peek = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, text, undefined, end, text.length - end);
97
+ const after = peek.scan();
98
+ const attributes = after === ts.SyntaxKind.WithKeyword ||
99
+ (after === ts.SyntaxKind.Identifier && peek.getTokenValue() === 'assert');
100
+ if (!attributes) {
101
+ const stop = after === ts.SyntaxKind.SemicolonToken ? peek.getTokenEnd() : end;
102
+ finish(stop);
103
+ if (stop > end)
104
+ scanner.resetTokenState(stop);
105
+ }
106
+ }
107
+ kind = scanner.scan();
108
+ }
109
+ finish(text.length);
110
+ return { imports, laws };
111
+ }
112
+ const squash = (s) => s.replace(/\s+/g, ' ').trim();
113
+ /** Joins the import statements, dropping repeats by whitespace-insensitive text. */
114
+ export function dedupeImports(existing, added) {
115
+ const seen = new Set(existing.map(squash));
116
+ const out = [...existing];
117
+ for (const statement of added) {
118
+ if (seen.has(squash(statement)))
119
+ continue;
120
+ seen.add(squash(statement));
121
+ out.push(statement);
122
+ }
123
+ return out;
124
+ }
125
+ /**
126
+ * The accepted file after promoting `laws` (with the proposed file's imports) into it. `existing` is the current text
127
+ * of the accepted file, or undefined when it does not exist yet. Throws when a law name is already taken there.
128
+ */
129
+ export function buildAccepted(existing, imports, laws) {
130
+ const base = existing ?? '';
131
+ const parsed = parseLawFile(base);
132
+ const taken = new Set(parsed.laws.map((l) => l.name));
133
+ for (const law of laws)
134
+ if (taken.has(law.name))
135
+ throw new Error(`law '${law.name}' already exists in the accepted file`);
136
+ const missing = dedupeImports(parsed.imports, imports).slice(parsed.imports.length);
137
+ let text = base;
138
+ if (missing.length > 0) {
139
+ // New imports go after the last existing import, or at the top.
140
+ const lastEnd = parsed.imports.length > 0
141
+ ? base.indexOf(parsed.imports[parsed.imports.length - 1]) + parsed.imports[parsed.imports.length - 1].length
142
+ : 0;
143
+ const block = missing.map((m) => (m.endsWith(';') ? m : `${m};`)).join('\n');
144
+ text =
145
+ lastEnd === 0
146
+ ? `${block}\n${base === '' ? '' : `${base.startsWith('\n') ? '' : '\n'}`}${base}`
147
+ : `${base.slice(0, lastEnd)}\n${block}${base.slice(lastEnd)}`;
148
+ }
149
+ const body = laws.map((l) => l.text.trimEnd()).join('\n\n');
150
+ const trimmed = text.trimEnd();
151
+ return `${trimmed === '' ? '' : `${trimmed}\n\n`}${body}\n`;
152
+ }
153
+ /** The proposed file without the promoted laws, or undefined when no law is left (the file is deleted). */
154
+ export function buildRemaining(text, promoted) {
155
+ const parsed = parseLawFile(text);
156
+ const drop = new Set(promoted);
157
+ if (parsed.laws.every((l) => drop.has(l.name)))
158
+ return undefined;
159
+ let out = text;
160
+ for (const law of [...parsed.laws].reverse()) {
161
+ if (!drop.has(law.name))
162
+ continue;
163
+ let end = law.end;
164
+ while (out[end] === '\r' || out[end] === '\n')
165
+ end += 1;
166
+ out = out.slice(0, law.start) + out.slice(end);
167
+ }
168
+ return out;
169
+ }
170
+ /** Walks the proposed files, asks per law, and rewrites the accepted and proposed files. Returns the number promoted. */
171
+ export async function promoteFiles(host, proposedFiles) {
172
+ let promoted = 0;
173
+ for (const file of proposedFiles) {
174
+ const accepted = acceptedPathFor(file);
175
+ if (accepted === undefined) {
176
+ host.out(`${file}: not a *.proposed.laws.ts file, skipped\n`);
177
+ continue;
178
+ }
179
+ const proposedPath = join(host.cwd, file);
180
+ const acceptedPath = join(host.cwd, accepted);
181
+ const text = readFileSync(proposedPath, 'utf8');
182
+ const parsed = parseLawFile(text);
183
+ if (parsed.laws.length === 0) {
184
+ host.out(`${file}: no laws\n`);
185
+ continue;
186
+ }
187
+ const results = await host.checkFile(file);
188
+ const chosen = [];
189
+ for (const law of parsed.laws) {
190
+ host.out(`\n${file}: ${law.name} [${results.get(law.name) ?? 'not checked'}]\n${law.text.trim()}\n`);
191
+ if ((await host.ask(`promote ${law.name}? [y/N] `)).trim().toLowerCase() === 'y')
192
+ chosen.push(law);
193
+ }
194
+ if (chosen.length === 0)
195
+ continue;
196
+ const existing = existsSync(acceptedPath) ? readFileSync(acceptedPath, 'utf8') : undefined;
197
+ let built;
198
+ try {
199
+ built = buildAccepted(existing, parsed.imports, chosen);
200
+ }
201
+ catch (error) {
202
+ host.out(`${accepted}: ${error instanceof Error ? error.message : String(error)}; nothing promoted from ${file}\n`);
203
+ continue;
204
+ }
205
+ writeFileSync(acceptedPath, built);
206
+ const remaining = buildRemaining(text, chosen.map((l) => l.name));
207
+ if (remaining === undefined)
208
+ rmSync(proposedPath);
209
+ else
210
+ writeFileSync(proposedPath, remaining);
211
+ promoted += chosen.length;
212
+ host.out(`${chosen.length} law${chosen.length === 1 ? '' : 's'} moved to ${accepted}${remaining === undefined ? `; ${file} removed` : ''}\n`);
213
+ }
214
+ if (promoted > 0)
215
+ host.out('\nThe lock is unchanged. Review the diff, then run `tslaws accept` to record these laws.\n');
216
+ return promoted;
217
+ }
@@ -0,0 +1,37 @@
1
+ import * as fc from 'fast-check';
2
+ import ts from 'typescript';
3
+ import type { Shape } from './smt.ts';
4
+ import type { CheckOptions, ManifestLaw } from './types.ts';
5
+ export interface LawTypes {
6
+ generators: fc.Arbitrary<unknown>[];
7
+ shapes: Shape[];
8
+ }
9
+ /** Every declaration file the compiler emitted: the laws' own do not import the code they are about. */
10
+ export declare function declarationFiles(outDir: string): string[];
11
+ export declare function dtsPath(outDir: string, file: string): string;
12
+ export declare function jsPath(outDir: string, file: string): string;
13
+ /** What the prover models of `type`: numbers, `int`s, bigints, strings, booleans, arrays and records of them. */
14
+ export declare function shapeOf(checker: ts.TypeChecker, type: ts.Type): Shape;
15
+ export declare function typesOf(program: ts.Program, outDir: string, law: ManifestLaw): LawTypes;
16
+ export declare function isDiscard(error: unknown): boolean;
17
+ /** Runs `call` on generated inputs. `call` may be async (a law that waits, or one that runs in a browser). */
18
+ export declare function runProperty(call: (...args: unknown[]) => unknown, generators: fc.Arbitrary<unknown>[], options: CheckOptions): Promise<fc.RunDetails<unknown[]>>;
19
+ export declare const runLaw: typeof runProperty;
20
+ export declare const compilerOptions: ts.CompilerOptions;
21
+ /** The outcome of a law's property test, without the generated values' prototypes so it can cross a thread. */
22
+ export interface PropertyOutcome {
23
+ failed: boolean;
24
+ numRuns: number;
25
+ seed: number;
26
+ /** Absent when the law failed because `assume` discarded nearly every input. */
27
+ counterexample?: string;
28
+ message?: string;
29
+ screenshot?: string;
30
+ /** The counterexample in the regression corpus's encoding; absent when it cannot be stored. */
31
+ encoded?: string;
32
+ /** Whether the counterexample is one that was replayed from the corpus. */
33
+ regression?: boolean;
34
+ }
35
+ export declare function summarise(details: fc.RunDetails<unknown[]>, options?: CheckOptions): PropertyOutcome;
36
+ /** Runs the property test of `law` in a worker, giving up on it after `options.propertyTimeoutMs`. */
37
+ export declare function runLawGuarded(outDir: string, law: ManifestLaw, options: CheckOptions): Promise<PropertyOutcome | 'timeout'>;