tslaws 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// A source file with the edits made to it so far, and the means to copy parts of it into a new file: text is taken from
|
|
2
|
+
// the original (comments and all), edits are applied to it, and the indentation is changed without touching template strings.
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
export function scriptKindOf(file) {
|
|
5
|
+
if (file.endsWith('.tsx'))
|
|
6
|
+
return ts.ScriptKind.TSX;
|
|
7
|
+
if (file.endsWith('.jsx'))
|
|
8
|
+
return ts.ScriptKind.JSX;
|
|
9
|
+
if (/\.[cm]?js$/.test(file))
|
|
10
|
+
return ts.ScriptKind.JS;
|
|
11
|
+
return ts.ScriptKind.TS;
|
|
12
|
+
}
|
|
13
|
+
export class Source {
|
|
14
|
+
file;
|
|
15
|
+
text;
|
|
16
|
+
edits = new Map();
|
|
17
|
+
lines;
|
|
18
|
+
protectedLines = new Set();
|
|
19
|
+
/** Every identifier that appears anywhere in the file, so new names can avoid them. */
|
|
20
|
+
taken = new Set();
|
|
21
|
+
path;
|
|
22
|
+
constructor(path, text) {
|
|
23
|
+
this.path = path;
|
|
24
|
+
this.text = text;
|
|
25
|
+
this.file = ts.createSourceFile(path, text, ts.ScriptTarget.ES2022, true, scriptKindOf(path));
|
|
26
|
+
this.lines = text.split('\n');
|
|
27
|
+
const visit = (node) => {
|
|
28
|
+
if (ts.isIdentifier(node))
|
|
29
|
+
this.taken.add(node.text);
|
|
30
|
+
if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node)) {
|
|
31
|
+
const from = this.lineOf(node.getStart(this.file)) - 1;
|
|
32
|
+
const to = this.lineOf(node.end) - 1;
|
|
33
|
+
for (let i = from + 1; i <= to; i += 1)
|
|
34
|
+
this.protectedLines.add(i);
|
|
35
|
+
}
|
|
36
|
+
ts.forEachChild(node, visit);
|
|
37
|
+
};
|
|
38
|
+
visit(this.file);
|
|
39
|
+
}
|
|
40
|
+
/** 1-based line of a position. */
|
|
41
|
+
lineOf(pos) {
|
|
42
|
+
return this.file.getLineAndCharacterOfPosition(pos).line + 1;
|
|
43
|
+
}
|
|
44
|
+
lineOfNode(node) {
|
|
45
|
+
return this.lineOf(node.getStart(this.file));
|
|
46
|
+
}
|
|
47
|
+
/** Replaces `start..end` with a single line of text; the same edit made twice counts once. */
|
|
48
|
+
edit(start, end, text) {
|
|
49
|
+
if (text.includes('\n'))
|
|
50
|
+
throw new Error('an edit may not add or remove lines');
|
|
51
|
+
this.edits.set(`${start}:${end}`, { start, end, text });
|
|
52
|
+
}
|
|
53
|
+
/** The original text of `start..end` with the edits inside it applied. */
|
|
54
|
+
render(start, end) {
|
|
55
|
+
const inside = [...this.edits.values()]
|
|
56
|
+
.filter((e) => e.start >= start && e.end <= end)
|
|
57
|
+
.sort((a, b) => a.start - b.start);
|
|
58
|
+
let out = '';
|
|
59
|
+
let at = start;
|
|
60
|
+
for (const e of inside) {
|
|
61
|
+
if (e.start < at)
|
|
62
|
+
continue;
|
|
63
|
+
out += this.text.slice(at, e.start) + e.text;
|
|
64
|
+
at = e.end;
|
|
65
|
+
}
|
|
66
|
+
return out + this.text.slice(at, end);
|
|
67
|
+
}
|
|
68
|
+
/** The text of a node, edits applied, on its own (no reindentation). */
|
|
69
|
+
textOf(node) {
|
|
70
|
+
return this.render(node.getStart(this.file), node.end);
|
|
71
|
+
}
|
|
72
|
+
/** A statement's range including the comments written above it and a comment after it on the same line. */
|
|
73
|
+
rangeOf(node) {
|
|
74
|
+
const leading = ts.getLeadingCommentRanges(this.text, node.pos) ?? [];
|
|
75
|
+
const trailing = ts.getTrailingCommentRanges(this.text, node.end) ?? [];
|
|
76
|
+
const start = leading[0]?.pos ?? node.getStart(this.file);
|
|
77
|
+
const end = trailing.length > 0 ? trailing[trailing.length - 1].end : node.end;
|
|
78
|
+
return [start, end];
|
|
79
|
+
}
|
|
80
|
+
/** The statements' text at `depth` levels of two spaces, comments included, template strings untouched. */
|
|
81
|
+
statements(nodes, depth) {
|
|
82
|
+
return nodes.flatMap((n) => this.block(...this.rangeOf(n), depth));
|
|
83
|
+
}
|
|
84
|
+
/** Copies `start..end` as lines at `depth`, keeping the shape the code had. */
|
|
85
|
+
block(start, end, depth) {
|
|
86
|
+
const raw = this.render(start, end).split('\n');
|
|
87
|
+
const startLine = this.lineOf(start) - 1;
|
|
88
|
+
let first = 0;
|
|
89
|
+
while (first < raw.length && raw[first].trim() === '')
|
|
90
|
+
first += 1;
|
|
91
|
+
let last = raw.length;
|
|
92
|
+
while (last > first && raw[last - 1].trim() === '')
|
|
93
|
+
last -= 1;
|
|
94
|
+
if (first >= last)
|
|
95
|
+
return [];
|
|
96
|
+
const base = /^[ \t]*/.exec(this.lines[startLine + first] ?? '')[0].length;
|
|
97
|
+
const pad = ' '.repeat(depth);
|
|
98
|
+
const out = [];
|
|
99
|
+
for (let i = first; i < last; i += 1) {
|
|
100
|
+
const line = raw[i];
|
|
101
|
+
if (this.protectedLines.has(startLine + i))
|
|
102
|
+
out.push(line);
|
|
103
|
+
else if (line.trim() === '')
|
|
104
|
+
out.push('');
|
|
105
|
+
else {
|
|
106
|
+
const lead = /^[ \t]*/.exec(line)[0].length;
|
|
107
|
+
out.push(pad + line.slice(Math.min(base, lead)));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Names a statement declares at its top level (variables, destructured ones, functions, classes). */
|
|
114
|
+
export function declaredNames(statement) {
|
|
115
|
+
const names = [];
|
|
116
|
+
const fromBinding = (name) => {
|
|
117
|
+
if (ts.isIdentifier(name))
|
|
118
|
+
names.push(name.text);
|
|
119
|
+
else
|
|
120
|
+
for (const element of name.elements)
|
|
121
|
+
if (ts.isBindingElement(element))
|
|
122
|
+
fromBinding(element.name);
|
|
123
|
+
};
|
|
124
|
+
if (ts.isVariableStatement(statement))
|
|
125
|
+
for (const d of statement.declarationList.declarations)
|
|
126
|
+
fromBinding(d.name);
|
|
127
|
+
else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name !== undefined)
|
|
128
|
+
names.push(statement.name.text);
|
|
129
|
+
return names;
|
|
130
|
+
}
|
|
131
|
+
/** Every identifier written inside `node`. */
|
|
132
|
+
export function identifiersIn(node, into) {
|
|
133
|
+
if (ts.isIdentifier(node))
|
|
134
|
+
into.add(node.text);
|
|
135
|
+
ts.forEachChild(node, (child) => identifiersIn(child, into));
|
|
136
|
+
}
|
|
137
|
+
/** A string as a single-quoted TypeScript literal. */
|
|
138
|
+
export function quote(value) {
|
|
139
|
+
const escapes = {
|
|
140
|
+
'\\': '\\\\',
|
|
141
|
+
"'": "\\'",
|
|
142
|
+
'\n': '\\n',
|
|
143
|
+
'\r': '\\r',
|
|
144
|
+
'\u2028': '\\u2028',
|
|
145
|
+
'\u2029': '\\u2029',
|
|
146
|
+
};
|
|
147
|
+
let out = "'";
|
|
148
|
+
for (const ch of value)
|
|
149
|
+
out += escapes[ch] ?? ch;
|
|
150
|
+
return `${out}'`;
|
|
151
|
+
}
|
|
152
|
+
/** A string literal's value when `node` is a plain string or a template with no substitutions. */
|
|
153
|
+
export function literalText(node) {
|
|
154
|
+
if (node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)))
|
|
155
|
+
return node.text;
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
export function unwrap(node) {
|
|
159
|
+
let current = node;
|
|
160
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isNonNullExpression(current))
|
|
161
|
+
current = current.expression;
|
|
162
|
+
return current;
|
|
163
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import type { Source } from './source.ts';
|
|
3
|
+
export type FnLike = ts.ArrowFunction | ts.FunctionExpression;
|
|
4
|
+
export declare const isFn: (node: ts.Node | undefined) => node is FnLike;
|
|
5
|
+
export declare function isAsyncFn(fn: FnLike): boolean;
|
|
6
|
+
/** The title of a test or suite: a string, a template, or a plain name such as a class. Undefined when it is computed. */
|
|
7
|
+
export declare function titleOf(node: ts.Expression | undefined, src: Source): string | undefined;
|
|
8
|
+
/** True when the function itself (not a function inside it) returns a value. */
|
|
9
|
+
export declare function returnsValue(fn: FnLike): boolean;
|
|
10
|
+
/** True when `node` contains `await`, or `.resolves` / `.rejects`, outside nested functions. */
|
|
11
|
+
export declare function awaits(node: ts.Node): boolean;
|
|
12
|
+
/** The names a function declares at its top level, for telling when inlining two of them in one block would clash. */
|
|
13
|
+
export declare function blockStatements(fn: FnLike): readonly ts.Statement[];
|
|
14
|
+
export interface Fixtures {
|
|
15
|
+
/** Local name to the fixture it stands for. */
|
|
16
|
+
bindings: Map<string, string>;
|
|
17
|
+
problem?: string;
|
|
18
|
+
}
|
|
19
|
+
/** The fixtures a Playwright callback asks for: `async ({ page, context }) => ...`. */
|
|
20
|
+
export declare function fixturesOf(fn: FnLike): Fixtures;
|
|
21
|
+
export interface Row {
|
|
22
|
+
/** The arguments the callback receives. */
|
|
23
|
+
args: ts.Expression[];
|
|
24
|
+
/** For a table written as a template: the one object the callback receives, as text, and its cells by column. */
|
|
25
|
+
synthetic?: string;
|
|
26
|
+
cells?: Map<string, ts.Expression>;
|
|
27
|
+
/** For rows of objects: the object, whose properties `$name` in a title refers to. */
|
|
28
|
+
object?: ts.Expression;
|
|
29
|
+
/** Source text of the arguments for the title. */
|
|
30
|
+
texts: string[];
|
|
31
|
+
}
|
|
32
|
+
export type Rows = {
|
|
33
|
+
ok: true;
|
|
34
|
+
rows: Row[];
|
|
35
|
+
template: boolean;
|
|
36
|
+
header: string[];
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
reason: string;
|
|
40
|
+
};
|
|
41
|
+
/** The rows of ``test.each([...])`` or ``test.each`a | b ...` ``. */
|
|
42
|
+
export declare function rowsOf(each: ts.CallExpression | ts.TaggedTemplateExpression, src: Source, spread: boolean): Rows;
|
|
43
|
+
/** The title of one row of a table, with `%s`-style and `$name` placeholders filled from the row as the framework does. */
|
|
44
|
+
export declare function rowTitle(title: string, row: Row, index: number, src: Source): string;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The pieces of a test file that need reading before they become laws: test titles, the callbacks of tests and hooks,
|
|
2
|
+
// the rows of `test.each` tables and the titles they print, and the fixtures of a Playwright test.
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
import { chainOf } from "./framework.js";
|
|
5
|
+
import { literalText, unwrap } from "./source.js";
|
|
6
|
+
export const isFn = (node) => node !== undefined && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
|
|
7
|
+
export function isAsyncFn(fn) {
|
|
8
|
+
return fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) === true;
|
|
9
|
+
}
|
|
10
|
+
/** The title of a test or suite: a string, a template, or a plain name such as a class. Undefined when it is computed. */
|
|
11
|
+
export function titleOf(node, src) {
|
|
12
|
+
if (node === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
const literal = literalText(node);
|
|
15
|
+
if (literal !== undefined)
|
|
16
|
+
return literal;
|
|
17
|
+
if (ts.isTemplateExpression(node))
|
|
18
|
+
return node.getText(src.file).slice(1, -1);
|
|
19
|
+
if (chainOf(node) !== undefined)
|
|
20
|
+
return node.getText(src.file);
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/** True when the function itself (not a function inside it) returns a value. */
|
|
24
|
+
export function returnsValue(fn) {
|
|
25
|
+
let found = false;
|
|
26
|
+
const visit = (node) => {
|
|
27
|
+
if (found || (node !== fn.body && ts.isFunctionLike(node)))
|
|
28
|
+
return;
|
|
29
|
+
if (ts.isReturnStatement(node) && node.expression !== undefined)
|
|
30
|
+
found = true;
|
|
31
|
+
ts.forEachChild(node, visit);
|
|
32
|
+
};
|
|
33
|
+
if (ts.isBlock(fn.body))
|
|
34
|
+
visit(fn.body);
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
/** True when `node` contains `await`, or `.resolves` / `.rejects`, outside nested functions. */
|
|
38
|
+
export function awaits(node) {
|
|
39
|
+
let found = false;
|
|
40
|
+
const visit = (n) => {
|
|
41
|
+
if (found || (n !== node && ts.isFunctionLike(n)))
|
|
42
|
+
return;
|
|
43
|
+
if (ts.isAwaitExpression(n))
|
|
44
|
+
found = true;
|
|
45
|
+
if (ts.isPropertyAccessExpression(n) && (n.name.text === 'resolves' || n.name.text === 'rejects'))
|
|
46
|
+
found = true;
|
|
47
|
+
ts.forEachChild(n, visit);
|
|
48
|
+
};
|
|
49
|
+
visit(node);
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
/** The names a function declares at its top level, for telling when inlining two of them in one block would clash. */
|
|
53
|
+
export function blockStatements(fn) {
|
|
54
|
+
return ts.isBlock(fn.body) ? fn.body.statements : [];
|
|
55
|
+
}
|
|
56
|
+
/** The fixtures a Playwright callback asks for: `async ({ page, context }) => ...`. */
|
|
57
|
+
export function fixturesOf(fn) {
|
|
58
|
+
const bindings = new Map();
|
|
59
|
+
if (fn.parameters.length === 0)
|
|
60
|
+
return { bindings };
|
|
61
|
+
if (fn.parameters.length > 1)
|
|
62
|
+
return { bindings, problem: 'it takes testInfo, which a law has no equivalent of' };
|
|
63
|
+
const parameter = fn.parameters[0];
|
|
64
|
+
if (!ts.isObjectBindingPattern(parameter.name))
|
|
65
|
+
return { bindings, problem: 'its fixtures are not destructured, so they cannot be mapped' };
|
|
66
|
+
for (const element of parameter.name.elements) {
|
|
67
|
+
if (element.dotDotDotToken !== undefined || element.initializer !== undefined || !ts.isIdentifier(element.name))
|
|
68
|
+
return { bindings, problem: 'a fixture is renamed with a default, a rest or a pattern' };
|
|
69
|
+
const fixture = element.propertyName === undefined
|
|
70
|
+
? element.name.text
|
|
71
|
+
: ts.isIdentifier(element.propertyName)
|
|
72
|
+
? element.propertyName.text
|
|
73
|
+
: undefined;
|
|
74
|
+
if (fixture === undefined)
|
|
75
|
+
return { bindings, problem: 'a fixture has a computed name' };
|
|
76
|
+
if (fixture !== 'page' && fixture !== 'context' && fixture !== 'baseURL')
|
|
77
|
+
return {
|
|
78
|
+
bindings,
|
|
79
|
+
problem: `the fixture ${fixture} has no counterpart in a law (only page, context and baseURL do)`,
|
|
80
|
+
};
|
|
81
|
+
bindings.set(element.name.text, fixture);
|
|
82
|
+
}
|
|
83
|
+
return { bindings };
|
|
84
|
+
}
|
|
85
|
+
/** The rows of ``test.each([...])`` or ``test.each`a | b ...` ``. */
|
|
86
|
+
export function rowsOf(each, src, spread) {
|
|
87
|
+
if (ts.isTaggedTemplateExpression(each)) {
|
|
88
|
+
const template = each.template;
|
|
89
|
+
if (ts.isNoSubstitutionTemplateLiteral(template))
|
|
90
|
+
return { ok: true, rows: [], template: true, header: [] };
|
|
91
|
+
const header = template.head.text
|
|
92
|
+
.trim()
|
|
93
|
+
.split('|')
|
|
94
|
+
.map((h) => h.trim())
|
|
95
|
+
.filter((h) => h !== '');
|
|
96
|
+
const cells = template.templateSpans.map((s) => s.expression);
|
|
97
|
+
if (header.length === 0 || cells.length % header.length !== 0)
|
|
98
|
+
return { ok: false, reason: 'the table has cells that do not line up with its header' };
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (let i = 0; i < cells.length; i += header.length) {
|
|
101
|
+
const row = cells.slice(i, i + header.length);
|
|
102
|
+
const key = (h) => (/^[A-Za-z_$][\w$]*$/.test(h) ? h : JSON.stringify(h));
|
|
103
|
+
const text = `{ ${header.map((h, j) => `${key(h)}: ${src.textOf(row[j])}`).join(', ')} }`;
|
|
104
|
+
const byColumn = new Map(header.map((h, j) => [h, row[j]]));
|
|
105
|
+
rows.push({ args: [], synthetic: text, cells: byColumn, texts: [text] });
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, rows, template: true, header };
|
|
108
|
+
}
|
|
109
|
+
const table = each.arguments[0];
|
|
110
|
+
if (table === undefined || !ts.isArrayLiteralExpression(unwrap(table)))
|
|
111
|
+
return { ok: false, reason: 'the table is not an array written in the file, so its rows cannot be listed' };
|
|
112
|
+
const elements = unwrap(table).elements;
|
|
113
|
+
if (elements.some((e) => ts.isSpreadElement(e)))
|
|
114
|
+
return { ok: false, reason: 'the table spreads another array' };
|
|
115
|
+
const allArrays = spread && elements.length > 0 && elements.every((e) => ts.isArrayLiteralExpression(unwrap(e)));
|
|
116
|
+
const rows = [];
|
|
117
|
+
for (const element of elements) {
|
|
118
|
+
if (allArrays) {
|
|
119
|
+
const cells = unwrap(element).elements;
|
|
120
|
+
if (cells.some((c) => ts.isSpreadElement(c)))
|
|
121
|
+
return { ok: false, reason: 'a row spreads another array' };
|
|
122
|
+
rows.push({ args: cells.slice(), texts: cells.map((c) => oneLine(src.textOf(c))) });
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const e = unwrap(element);
|
|
126
|
+
rows.push({
|
|
127
|
+
args: [element],
|
|
128
|
+
object: ts.isObjectLiteralExpression(e) ? e : undefined,
|
|
129
|
+
texts: [oneLine(src.textOf(element))],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, rows, template: false, header: [] };
|
|
134
|
+
}
|
|
135
|
+
const oneLine = (text) => text.replace(/\s+/g, ' ').trim();
|
|
136
|
+
/** What a value prints as in a title: strings without quotes, other values as written. */
|
|
137
|
+
function shown(node, src) {
|
|
138
|
+
const e = unwrap(node);
|
|
139
|
+
const literal = literalText(e);
|
|
140
|
+
if (literal !== undefined)
|
|
141
|
+
return literal;
|
|
142
|
+
return oneLine(src.textOf(e));
|
|
143
|
+
}
|
|
144
|
+
/** The title of one row of a table, with `%s`-style and `$name` placeholders filled from the row as the framework does. */
|
|
145
|
+
export function rowTitle(title, row, index, src) {
|
|
146
|
+
let argument = 0;
|
|
147
|
+
const filled = title.replace(/%([sdifjopP#$%])/g, (whole, spec) => {
|
|
148
|
+
if (spec === '%')
|
|
149
|
+
return '%';
|
|
150
|
+
if (spec === '#')
|
|
151
|
+
return String(index);
|
|
152
|
+
if (spec === '$')
|
|
153
|
+
return String(index + 1);
|
|
154
|
+
const node = row.args[argument];
|
|
155
|
+
const text = row.texts[argument];
|
|
156
|
+
argument += 1;
|
|
157
|
+
if (text === undefined)
|
|
158
|
+
return whole;
|
|
159
|
+
if (spec === 's' && node !== undefined)
|
|
160
|
+
return shown(node, src);
|
|
161
|
+
return text;
|
|
162
|
+
});
|
|
163
|
+
return filled.replace(/\$(#|[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)*)/g, (whole, path) => {
|
|
164
|
+
if (path === '#')
|
|
165
|
+
return String(index);
|
|
166
|
+
const [head, ...rest] = path.split('.');
|
|
167
|
+
const cell = row.cells?.get(head);
|
|
168
|
+
if (cell !== undefined && rest.length === 0)
|
|
169
|
+
return shown(cell, src);
|
|
170
|
+
const object = row.object;
|
|
171
|
+
if (object !== undefined && ts.isObjectLiteralExpression(object)) {
|
|
172
|
+
for (const p of object.properties) {
|
|
173
|
+
if (ts.isPropertyAssignment(p) &&
|
|
174
|
+
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
|
|
175
|
+
p.name.text === head &&
|
|
176
|
+
rest.length === 0)
|
|
177
|
+
return shown(p.initializer, src);
|
|
178
|
+
if (ts.isShorthandPropertyAssignment(p) && p.name.text === head && rest.length === 0)
|
|
179
|
+
return head;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return whole;
|
|
183
|
+
});
|
|
184
|
+
}
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as fc from 'fast-check';
|
|
2
|
+
export declare class UnsupportedType extends Error {
|
|
3
|
+
}
|
|
4
|
+
export type Plan = {
|
|
5
|
+
k: 'anything';
|
|
6
|
+
} | {
|
|
7
|
+
k: 'const';
|
|
8
|
+
v: undefined | null | boolean | number | string;
|
|
9
|
+
} | {
|
|
10
|
+
k: 'bigconst';
|
|
11
|
+
v: string;
|
|
12
|
+
} | {
|
|
13
|
+
k: 'boolean' | 'number' | 'string' | 'bigint' | 'symbol' | 'object' | 'date' | 'regexp';
|
|
14
|
+
} | {
|
|
15
|
+
k: 'union';
|
|
16
|
+
of: Plan[];
|
|
17
|
+
} | {
|
|
18
|
+
k: 'template';
|
|
19
|
+
texts: string[];
|
|
20
|
+
parts: Plan[];
|
|
21
|
+
}
|
|
22
|
+
/** A type with a registered generator; `fallback` is what to generate when none is registered by the time of use. */
|
|
23
|
+
| {
|
|
24
|
+
k: 'named';
|
|
25
|
+
name: string;
|
|
26
|
+
fallback?: Plan;
|
|
27
|
+
} | {
|
|
28
|
+
k: 'array';
|
|
29
|
+
of: Plan;
|
|
30
|
+
} | {
|
|
31
|
+
k: 'tuple';
|
|
32
|
+
required: Plan[];
|
|
33
|
+
optional: Plan[];
|
|
34
|
+
} | {
|
|
35
|
+
k: 'fn';
|
|
36
|
+
returns: Plan;
|
|
37
|
+
} | {
|
|
38
|
+
k: 'record';
|
|
39
|
+
fields: Record<string, Plan>;
|
|
40
|
+
required: string[];
|
|
41
|
+
} | {
|
|
42
|
+
k: 'dict';
|
|
43
|
+
of: Plan;
|
|
44
|
+
};
|
|
45
|
+
/** Marks a generated function on the wire: browser laws receive their arguments as structured data. */
|
|
46
|
+
export declare const wireKey = "$tslawsFn";
|
|
47
|
+
/** Replaces generated functions inside arguments with a structured-clone-safe marker `{ $tslawsFn: samples }`. */
|
|
48
|
+
export declare function toWire(value: unknown): unknown;
|
|
49
|
+
/** The fast-check arbitrary a plan describes. */
|
|
50
|
+
export declare function arbitraryOf(plan: Plan): fc.Arbitrary<unknown>;
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// A generator plan: what to generate for one parameter type, as plain data. The type checker reads a parameter's type
|
|
2
|
+
// once, in the main process, and writes the plan; a worker turns the plan into fast-check arbitraries without loading
|
|
3
|
+
// TypeScript (about 0.8 s of start-up per worker).
|
|
4
|
+
import { sequence } from "./sequence.js";
|
|
5
|
+
import * as fc from 'fast-check';
|
|
6
|
+
import { arbitraryRegistry } from "./api.js";
|
|
7
|
+
export class UnsupportedType extends Error {
|
|
8
|
+
}
|
|
9
|
+
const numbers = fc.oneof({ weight: 4, arbitrary: fc.integer({ min: -8, max: 8 }) }, { weight: 3, arbitrary: fc.integer({ min: -1000, max: 1000 }) }, { weight: 1, arbitrary: fc.integer() }, { weight: 2, arbitrary: fc.double({ noNaN: true, noDefaultInfinity: true }) });
|
|
10
|
+
// `int` from 'tslaws': whole numbers, biased to small ones.
|
|
11
|
+
arbitraryRegistry().set('int', fc.oneof({ weight: 4, arbitrary: fc.integer({ min: -8, max: 8 }) }, { weight: 3, arbitrary: fc.integer({ min: -1000, max: 1000 }) }, { weight: 1, arbitrary: fc.integer({ min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) }));
|
|
12
|
+
/** Marks a generated function on the wire: browser laws receive their arguments as structured data. */
|
|
13
|
+
export const wireKey = '$tslawsFn';
|
|
14
|
+
function pick(samples, args) {
|
|
15
|
+
let key = '';
|
|
16
|
+
try {
|
|
17
|
+
key = JSON.stringify(args) ?? '';
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Arguments that are not JSON (a node, a cycle) all share the first sample.
|
|
21
|
+
}
|
|
22
|
+
let h = 5381;
|
|
23
|
+
for (let i = 0; i < key.length; i++)
|
|
24
|
+
h = (Math.imul(h, 33) + key.charCodeAt(i)) | 0;
|
|
25
|
+
return samples[(h >>> 0) % samples.length];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A function of a type with one call signature: it returns one of a few values generated from the return type,
|
|
29
|
+
* chosen by its arguments (like `fc.func`: the same arguments give the same result, `void` gives `undefined`).
|
|
30
|
+
* `samples` rides along so a browser law can rebuild the function in the page.
|
|
31
|
+
*/
|
|
32
|
+
function generatedFunction(samples) {
|
|
33
|
+
const fn = (...args) => pick(samples, args);
|
|
34
|
+
Object.defineProperty(fn, wireKey, { value: samples, enumerable: false });
|
|
35
|
+
Object.defineProperty(fn, 'toString', { value: () => `[generated function]`, enumerable: false });
|
|
36
|
+
return fn;
|
|
37
|
+
}
|
|
38
|
+
/** Replaces generated functions inside arguments with a structured-clone-safe marker `{ $tslawsFn: samples }`. */
|
|
39
|
+
export function toWire(value) {
|
|
40
|
+
if (typeof value === 'function') {
|
|
41
|
+
const samples = value[wireKey];
|
|
42
|
+
return { [wireKey]: Array.isArray(samples) ? samples : [undefined] };
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(value))
|
|
45
|
+
return value.map(toWire);
|
|
46
|
+
if (value !== null && typeof value === 'object') {
|
|
47
|
+
const proto = Object.getPrototypeOf(value);
|
|
48
|
+
if (proto === Object.prototype || proto === null)
|
|
49
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toWire(v)]));
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
/** The fast-check arbitrary a plan describes. */
|
|
54
|
+
export function arbitraryOf(plan) {
|
|
55
|
+
switch (plan.k) {
|
|
56
|
+
case 'anything':
|
|
57
|
+
return fc.anything();
|
|
58
|
+
case 'const':
|
|
59
|
+
return fc.constant(plan.v);
|
|
60
|
+
case 'bigconst':
|
|
61
|
+
return fc.constant(BigInt(plan.v));
|
|
62
|
+
case 'boolean':
|
|
63
|
+
return fc.boolean();
|
|
64
|
+
case 'number':
|
|
65
|
+
return numbers;
|
|
66
|
+
case 'string':
|
|
67
|
+
return fc.string({ maxLength: 24 });
|
|
68
|
+
case 'bigint':
|
|
69
|
+
return fc.bigInt();
|
|
70
|
+
case 'symbol':
|
|
71
|
+
return fc.string().map((s) => Symbol(s));
|
|
72
|
+
case 'object':
|
|
73
|
+
return fc.object();
|
|
74
|
+
case 'date':
|
|
75
|
+
return fc.date({ noInvalidDate: true });
|
|
76
|
+
case 'regexp':
|
|
77
|
+
return fc.constant(/x/);
|
|
78
|
+
case 'union':
|
|
79
|
+
return fc.oneof(...plan.of.map(arbitraryOf));
|
|
80
|
+
case 'template': {
|
|
81
|
+
const parts = [fc.constant(plan.texts[0] ?? '')];
|
|
82
|
+
plan.parts.forEach((part, i) => {
|
|
83
|
+
parts.push(arbitraryOf(part).map(String));
|
|
84
|
+
parts.push(fc.constant(plan.texts[i + 1] ?? ''));
|
|
85
|
+
});
|
|
86
|
+
return fc.tuple(...parts).map((p) => p.join(''));
|
|
87
|
+
}
|
|
88
|
+
case 'named': {
|
|
89
|
+
const registered = arbitraryRegistry().get(plan.name);
|
|
90
|
+
if (registered !== undefined)
|
|
91
|
+
return registered;
|
|
92
|
+
if (plan.fallback === undefined)
|
|
93
|
+
throw new UnsupportedType(`no generator registered for ${plan.name}`);
|
|
94
|
+
return arbitraryOf(plan.fallback);
|
|
95
|
+
}
|
|
96
|
+
case 'array':
|
|
97
|
+
return sequence(arbitraryOf(plan.of), { maxLength: 8 });
|
|
98
|
+
case 'tuple': {
|
|
99
|
+
const required = plan.required.map(arbitraryOf);
|
|
100
|
+
const optional = plan.optional.map(arbitraryOf);
|
|
101
|
+
return fc.tuple(...required).chain((head) => {
|
|
102
|
+
if (optional.length === 0)
|
|
103
|
+
return fc.constant(head);
|
|
104
|
+
return fc.tuple(...optional).map((tail) => [...head, ...tail]);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
case 'fn':
|
|
108
|
+
return fc.array(arbitraryOf(plan.returns), { minLength: 1, maxLength: 4 }).map(generatedFunction);
|
|
109
|
+
case 'record': {
|
|
110
|
+
const model = {};
|
|
111
|
+
for (const [key, field] of Object.entries(plan.fields))
|
|
112
|
+
model[key] = arbitraryOf(field);
|
|
113
|
+
return fc.record(model, { requiredKeys: plan.required });
|
|
114
|
+
}
|
|
115
|
+
case 'dict':
|
|
116
|
+
return fc.dictionary(fc.string({ maxLength: 6 }), arbitraryOf(plan.of), { maxKeys: 6 });
|
|
117
|
+
}
|
|
118
|
+
}
|
package/dist/pool.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Worker } from 'node:worker_threads';
|
|
2
|
+
/** How many laws run at once: the machine's cores, up to 8, or TSLAWS_JOBS. */
|
|
3
|
+
export declare function jobs(): number;
|
|
4
|
+
/**
|
|
5
|
+
* A pool of workers where each request holds one exclusively, so a timeout can terminate its worker without
|
|
6
|
+
* disturbing a neighbour. Workers start on demand and are reused.
|
|
7
|
+
*/
|
|
8
|
+
export declare class WorkerPool {
|
|
9
|
+
private readonly idle;
|
|
10
|
+
private readonly waiting;
|
|
11
|
+
private count;
|
|
12
|
+
private readonly start;
|
|
13
|
+
private readonly size;
|
|
14
|
+
constructor(start: () => Worker, size?: number);
|
|
15
|
+
acquire(): Promise<Worker>;
|
|
16
|
+
/** Starts up to `n` workers now, so they finish loading while the caller does other work. */
|
|
17
|
+
warm(n: number): void;
|
|
18
|
+
release(worker: Worker): void;
|
|
19
|
+
/** Terminates a worker that timed out or crashed; a waiting request gets a fresh one. */
|
|
20
|
+
discard(worker: Worker): void;
|
|
21
|
+
}
|
package/dist/pool.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { availableParallelism } from 'node:os';
|
|
2
|
+
/** How many laws run at once: the machine's cores, up to 8, or TSLAWS_JOBS. */
|
|
3
|
+
export function jobs() {
|
|
4
|
+
const given = Number(process.env.TSLAWS_JOBS);
|
|
5
|
+
return Number.isInteger(given) && given > 0 ? given : Math.min(8, availableParallelism());
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A pool of workers where each request holds one exclusively, so a timeout can terminate its worker without
|
|
9
|
+
* disturbing a neighbour. Workers start on demand and are reused.
|
|
10
|
+
*/
|
|
11
|
+
export class WorkerPool {
|
|
12
|
+
idle = [];
|
|
13
|
+
waiting = [];
|
|
14
|
+
count = 0;
|
|
15
|
+
start;
|
|
16
|
+
size;
|
|
17
|
+
constructor(start, size = jobs()) {
|
|
18
|
+
this.start = start;
|
|
19
|
+
this.size = size;
|
|
20
|
+
}
|
|
21
|
+
acquire() {
|
|
22
|
+
const free = this.idle.pop();
|
|
23
|
+
if (free !== undefined)
|
|
24
|
+
return Promise.resolve(free);
|
|
25
|
+
if (this.count < this.size) {
|
|
26
|
+
this.count += 1;
|
|
27
|
+
return Promise.resolve(this.start());
|
|
28
|
+
}
|
|
29
|
+
return new Promise((resolve) => this.waiting.push(resolve));
|
|
30
|
+
}
|
|
31
|
+
/** Starts up to `n` workers now, so they finish loading while the caller does other work. */
|
|
32
|
+
warm(n) {
|
|
33
|
+
while (this.count < Math.min(n, this.size)) {
|
|
34
|
+
this.count += 1;
|
|
35
|
+
this.idle.push(this.start());
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
release(worker) {
|
|
39
|
+
const next = this.waiting.shift();
|
|
40
|
+
if (next !== undefined)
|
|
41
|
+
next(worker);
|
|
42
|
+
else
|
|
43
|
+
this.idle.push(worker);
|
|
44
|
+
}
|
|
45
|
+
/** Terminates a worker that timed out or crashed; a waiting request gets a fresh one. */
|
|
46
|
+
discard(worker) {
|
|
47
|
+
void worker.terminate();
|
|
48
|
+
const next = this.waiting.shift();
|
|
49
|
+
if (next === undefined)
|
|
50
|
+
this.count -= 1;
|
|
51
|
+
else
|
|
52
|
+
next(this.start());
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/promote.d.ts
CHANGED
|
@@ -10,10 +10,13 @@ export interface ParsedLawFile {
|
|
|
10
10
|
imports: string[];
|
|
11
11
|
laws: ParsedLaw[];
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* `foo.proposed.laws.ts` -> `foo.laws.ts`, `foo.browser.proposed.laws.ts` -> `foo.browser.laws.ts` and
|
|
15
|
+
* `foo.e2e.proposed.laws.ts` -> `foo.e2e.laws.ts`, so a proposal lands in a file of its own kind; undefined for any other name.
|
|
16
|
+
*/
|
|
14
17
|
export declare function acceptedPathFor(proposed: string): string | undefined;
|
|
15
18
|
/**
|
|
16
|
-
* Finds the import statements and the `export law ...` declarations
|
|
19
|
+
* Finds the import statements and the laws of a laws file, as `export law ...` declarations or as `law('name', ...)` calls. The keyword is not standard TypeScript, so
|
|
17
20
|
* this scans tokens with the TypeScript scanner (strings, comments and templates are skipped correctly) and matches
|
|
18
21
|
* brackets: a law ends at its closing `}` or at a `;` at depth 0.
|
|
19
22
|
*/
|
|
@@ -36,3 +39,28 @@ export interface PromoteHost {
|
|
|
36
39
|
}
|
|
37
40
|
/** Walks the proposed files, asks per law, and rewrites the accepted and proposed files. Returns the number promoted. */
|
|
38
41
|
export declare function promoteFiles(host: PromoteHost, proposedFiles: string[]): Promise<number>;
|
|
42
|
+
/** The path `promote` suggests for a proposal: beside the source it imports, else under `laws/`. */
|
|
43
|
+
export declare function defaultTarget(cwd: string, proposal: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* A path a promoted proposal may be written to, as a project-relative slash path: inside the project, a name laws
|
|
46
|
+
* files have (`*.laws.ts`, or a path a `laws` glob picks), not in a reserved directory, and not through a link.
|
|
47
|
+
*/
|
|
48
|
+
export declare function promotionTarget(cwd: string, to: string): string;
|
|
49
|
+
export interface PromoteProposalHost {
|
|
50
|
+
cwd: string;
|
|
51
|
+
/** Checks one proposal and returns its results by law name (as `check --proposed` would). */
|
|
52
|
+
checkFile: (file: string) => Promise<Map<string, string>>;
|
|
53
|
+
ask: (question: string) => Promise<string>;
|
|
54
|
+
out: (text: string) => void;
|
|
55
|
+
}
|
|
56
|
+
export interface PromoteProposalResult {
|
|
57
|
+
/** Project-relative path the laws went to; undefined when the person declined or nothing was moved. */
|
|
58
|
+
target?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Moves one proposal into the tree. Shows each law with its result, asks the person to confirm the target path (the
|
|
62
|
+
* default, or `to` when given, or another typed path), rewrites the proposal's relative imports for the new directory,
|
|
63
|
+
* merges into the target if it already exists, and removes the proposal. The lock is never written: the laws stay
|
|
64
|
+
* unaccepted, and `check` fails them as not in the lock until a person runs `tslaws accept`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function promoteProposal(host: PromoteProposalHost, file: string, to?: string): Promise<PromoteProposalResult>;
|