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,38 @@
|
|
|
1
|
+
export type LawKind = 'node' | 'browser' | 'e2e';
|
|
2
|
+
/** Where a laws file's level comes from: its name (the default) or its import graph (opt-in). */
|
|
3
|
+
export type LevelsMode = 'names' | 'imports';
|
|
4
|
+
export interface ImportFacts {
|
|
5
|
+
/** The chain of project files from the laws file to the one that imports a browser law entry point, if any. */
|
|
6
|
+
browser: string[] | undefined;
|
|
7
|
+
e2e: string[] | undefined;
|
|
8
|
+
}
|
|
9
|
+
export interface KindVerdict {
|
|
10
|
+
kind: LawKind;
|
|
11
|
+
/** Set when the file cannot run at one level; the message names the files involved. */
|
|
12
|
+
error?: string;
|
|
13
|
+
/** Things worth saying that do not stop the file: a name hint that the imports contradict or do not back. */
|
|
14
|
+
notices: string[];
|
|
15
|
+
imports: ImportFacts;
|
|
16
|
+
}
|
|
17
|
+
export interface Specifier {
|
|
18
|
+
value: string;
|
|
19
|
+
/** Offsets of the text between the quotes, for a rewrite. */
|
|
20
|
+
start: number;
|
|
21
|
+
end: number;
|
|
22
|
+
/** Used only as a type: erased from the output, so it loads nothing at run time. */
|
|
23
|
+
typeOnly: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Every module specifier of a file: imports, re-exports, `import x = require()`, `require()` and `import()`. */
|
|
26
|
+
export declare function moduleSpecifiers(text: string, fileName?: string): Specifier[];
|
|
27
|
+
/** The module specifiers a file loads at run time: static imports, re-exports, `require` and dynamic `import()`. */
|
|
28
|
+
export declare function runtimeSpecifiers(text: string, fileName?: string): Specifier[];
|
|
29
|
+
/** Walks the import graph from `file` and finds the first chain that reaches each level's entry point. */
|
|
30
|
+
export declare function importFacts(cwd: string, file: string): ImportFacts;
|
|
31
|
+
/** Classifies a laws file (project-relative path) by its name (default) or, with `levels: "imports"`, its imports. */
|
|
32
|
+
export declare function classifyLawFile(cwd: string, file: string, mode?: LevelsMode): KindVerdict;
|
|
33
|
+
/** The project sources a file imports directly, project-relative, in the order they are imported. */
|
|
34
|
+
export declare function directProjectImports(cwd: string, file: string): string[];
|
|
35
|
+
/** The resolved level of each laws file, as the lock records it; none when levels come from names (the default). */
|
|
36
|
+
export declare function lockLevels(cwd: string, laws: {
|
|
37
|
+
file: string;
|
|
38
|
+
}[], mode: LevelsMode): Record<string, LawKind> | undefined;
|
package/dist/lawKinds.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The level a laws file runs at. By default (`levels: "names"` in tslaws.json) it comes from the file name:
|
|
2
|
+
// `*.browser.laws.ts` is a browser law (an end-to-end law when it imports `tslaws/e2e`), `*.e2e.laws.ts` is end to end,
|
|
3
|
+
// anything else is a Node law. With `levels: "imports"` it comes from what the file imports, as follows.
|
|
4
|
+
//
|
|
5
|
+
// From what it imports. A file that reaches the browser law API (`tslaws/browser`, or one
|
|
6
|
+
// of the `tslaws/adapters/*` entry points) is a browser law; a file that reaches the end-to-end API (`tslaws/e2e`) is an
|
|
7
|
+
// end-to-end law; a file that reaches both is refused; a file that reaches neither is a Node law. "Reaches" follows the
|
|
8
|
+
// project's own import graph: a helper module the law imports, or a re-export, counts. Imports used only as types, and
|
|
9
|
+
// modules under node_modules, do not.
|
|
10
|
+
//
|
|
11
|
+
// The `.browser.laws.ts` and `.e2e.laws.ts` names stay as hints. They never override an import, and a hint that
|
|
12
|
+
// disagrees with the imports is reported.
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
15
|
+
import ts from 'typescript';
|
|
16
|
+
import { suffixKind } from "./lawNames.js";
|
|
17
|
+
const browserEntry = /^tslaws\/(?:browser|adapters\/(?:dom|react|angular|element))$/;
|
|
18
|
+
const e2eEntry = 'tslaws/e2e';
|
|
19
|
+
/** Every module specifier of a file: imports, re-exports, `import x = require()`, `require()` and `import()`. */
|
|
20
|
+
export function moduleSpecifiers(text, fileName = 'laws.ts') {
|
|
21
|
+
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
22
|
+
const found = [];
|
|
23
|
+
const literal = (node, typeOnly) => {
|
|
24
|
+
if (node !== undefined && ts.isStringLiteralLike(node))
|
|
25
|
+
found.push({ value: node.text, start: node.getStart(source) + 1, end: node.getEnd() - 1, typeOnly });
|
|
26
|
+
};
|
|
27
|
+
const visit = (node) => {
|
|
28
|
+
if (ts.isImportDeclaration(node)) {
|
|
29
|
+
const clause = node.importClause;
|
|
30
|
+
const bindings = clause?.namedBindings;
|
|
31
|
+
const typeOnly = clause !== undefined &&
|
|
32
|
+
(clause.isTypeOnly ||
|
|
33
|
+
(clause.name === undefined &&
|
|
34
|
+
bindings !== undefined &&
|
|
35
|
+
ts.isNamedImports(bindings) &&
|
|
36
|
+
bindings.elements.length > 0 &&
|
|
37
|
+
bindings.elements.every((e) => e.isTypeOnly)));
|
|
38
|
+
literal(node.moduleSpecifier, typeOnly);
|
|
39
|
+
}
|
|
40
|
+
else if (ts.isExportDeclaration(node)) {
|
|
41
|
+
const clause = node.exportClause;
|
|
42
|
+
literal(node.moduleSpecifier, node.isTypeOnly ||
|
|
43
|
+
(clause !== undefined &&
|
|
44
|
+
ts.isNamedExports(clause) &&
|
|
45
|
+
clause.elements.length > 0 &&
|
|
46
|
+
clause.elements.every((e) => e.isTypeOnly)));
|
|
47
|
+
}
|
|
48
|
+
else if (ts.isImportEqualsDeclaration(node)) {
|
|
49
|
+
if (ts.isExternalModuleReference(node.moduleReference))
|
|
50
|
+
literal(node.moduleReference.expression, node.isTypeOnly);
|
|
51
|
+
}
|
|
52
|
+
else if (ts.isCallExpression(node) && node.arguments.length >= 1) {
|
|
53
|
+
const callee = node.expression;
|
|
54
|
+
if (callee.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(callee) && callee.text === 'require'))
|
|
55
|
+
literal(node.arguments[0], false);
|
|
56
|
+
}
|
|
57
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
58
|
+
literal(node.argument.literal, true);
|
|
59
|
+
}
|
|
60
|
+
ts.forEachChild(node, visit);
|
|
61
|
+
};
|
|
62
|
+
visit(source);
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
/** The module specifiers a file loads at run time: static imports, re-exports, `require` and dynamic `import()`. */
|
|
66
|
+
export function runtimeSpecifiers(text, fileName = 'laws.ts') {
|
|
67
|
+
return moduleSpecifiers(text, fileName).filter((s) => !s.typeOnly);
|
|
68
|
+
}
|
|
69
|
+
const resolveOptions = {
|
|
70
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
71
|
+
module: ts.ModuleKind.ESNext,
|
|
72
|
+
target: ts.ScriptTarget.ES2022,
|
|
73
|
+
allowJs: true,
|
|
74
|
+
resolveJsonModule: false,
|
|
75
|
+
};
|
|
76
|
+
/** The project source a specifier names, or undefined for a package, a declaration file or something unresolved. */
|
|
77
|
+
function projectSource(specifier, from) {
|
|
78
|
+
if (!specifier.startsWith('.') && !specifier.startsWith('/'))
|
|
79
|
+
return undefined;
|
|
80
|
+
const resolved = ts.resolveModuleName(specifier, from, resolveOptions, ts.sys).resolvedModule;
|
|
81
|
+
if (resolved === undefined || resolved.isExternalLibraryImport === true)
|
|
82
|
+
return undefined;
|
|
83
|
+
const file = resolved.resolvedFileName;
|
|
84
|
+
if (/[\\/]node_modules[\\/]/.test(file) || /\.d\.[cm]?ts$/.test(file) || !existsSync(file))
|
|
85
|
+
return undefined;
|
|
86
|
+
return file;
|
|
87
|
+
}
|
|
88
|
+
/** Walks the import graph from `file` and finds the first chain that reaches each level's entry point. */
|
|
89
|
+
export function importFacts(cwd, file) {
|
|
90
|
+
const start = resolve(cwd, file);
|
|
91
|
+
const facts = { browser: undefined, e2e: undefined };
|
|
92
|
+
const seen = new Set([start]);
|
|
93
|
+
const queue = [{ path: start, chain: [file.replaceAll('\\', '/')] }];
|
|
94
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
text = readFileSync(next.path, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const { value } of runtimeSpecifiers(text, next.path)) {
|
|
103
|
+
if (browserEntry.test(value))
|
|
104
|
+
facts.browser ??= next.chain;
|
|
105
|
+
else if (value === e2eEntry)
|
|
106
|
+
facts.e2e ??= next.chain;
|
|
107
|
+
const target = projectSource(value, join(dirname(next.path), 'x.ts'));
|
|
108
|
+
if (target === undefined || seen.has(target))
|
|
109
|
+
continue;
|
|
110
|
+
seen.add(target);
|
|
111
|
+
const inside = relative(resolve(cwd), resolve(target));
|
|
112
|
+
const shown = inside.startsWith('..') || isAbsolute(inside) ? target : inside;
|
|
113
|
+
queue.push({ path: target, chain: [...next.chain, shown.replaceAll('\\', '/')] });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return facts;
|
|
117
|
+
}
|
|
118
|
+
const via = (chain) => (chain.length <= 1 ? 'directly' : `through ${chain.slice(1).join(' > ')}`);
|
|
119
|
+
const importsModule = (text, module) => new RegExp(`(?:from|import)\\s*['"]${module}['"]`).test(text);
|
|
120
|
+
/** The level a file's name gives it: the way levels were always decided, and still the default. */
|
|
121
|
+
function classifyByName(cwd, file) {
|
|
122
|
+
const imports = { browser: undefined, e2e: undefined };
|
|
123
|
+
let text = '';
|
|
124
|
+
try {
|
|
125
|
+
text = readFileSync(resolve(cwd, file), 'utf8');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// The compiler already reports an unreadable file.
|
|
129
|
+
}
|
|
130
|
+
const base = file.replaceAll('\\', '/');
|
|
131
|
+
if (importsModule(text, 'tslaws/e2e') && importsModule(text, 'tslaws/browser'))
|
|
132
|
+
return {
|
|
133
|
+
kind: 'node',
|
|
134
|
+
imports,
|
|
135
|
+
notices: [],
|
|
136
|
+
error: `${base}: a browser laws file is either component level (tslaws/browser) or end-to-end (tslaws/e2e), not both`,
|
|
137
|
+
};
|
|
138
|
+
const hint = suffixKind(file);
|
|
139
|
+
const kind = hint === 'e2e' ? 'e2e' : hint === 'browser' ? (importsModule(text, 'tslaws/e2e') ? 'e2e' : 'browser') : 'node';
|
|
140
|
+
return { kind, imports, notices: [] };
|
|
141
|
+
}
|
|
142
|
+
/** Classifies a laws file (project-relative path) by its name (default) or, with `levels: "imports"`, its imports. */
|
|
143
|
+
export function classifyLawFile(cwd, file, mode = 'names') {
|
|
144
|
+
if (mode === 'names')
|
|
145
|
+
return classifyByName(cwd, file);
|
|
146
|
+
return classifyByImports(cwd, file);
|
|
147
|
+
}
|
|
148
|
+
/** Classifies a laws file by its imports, checking the name hint against them. */
|
|
149
|
+
function classifyByImports(cwd, file) {
|
|
150
|
+
const imports = importFacts(cwd, file);
|
|
151
|
+
const hint = suffixKind(file);
|
|
152
|
+
const notices = [];
|
|
153
|
+
const base = file.replaceAll('\\', '/');
|
|
154
|
+
if (imports.browser !== undefined && imports.e2e !== undefined)
|
|
155
|
+
return {
|
|
156
|
+
kind: 'node',
|
|
157
|
+
imports,
|
|
158
|
+
notices,
|
|
159
|
+
error: `${base}: a laws file is either component level (tslaws/browser) or end-to-end (tslaws/e2e), not both ` +
|
|
160
|
+
`(browser API reached ${via(imports.browser)}; end-to-end API reached ${via(imports.e2e)})`,
|
|
161
|
+
};
|
|
162
|
+
const kind = imports.e2e !== undefined ? 'e2e' : imports.browser !== undefined ? 'browser' : 'node';
|
|
163
|
+
if (hint === undefined || hint === kind)
|
|
164
|
+
return { kind, imports, notices };
|
|
165
|
+
if (kind === 'node') {
|
|
166
|
+
// Nothing imported says which level, so the name still stands: a law may use the page's globals directly.
|
|
167
|
+
notices.push(`${base}: the name says ${hint}, but nothing it imports is the ${hint === 'e2e' ? 'end-to-end' : 'browser'} law API; ` +
|
|
168
|
+
`the name is used as the level. Import ${hint === 'e2e' ? 'tslaws/e2e' : 'tslaws/browser'} (the name is only a hint)`);
|
|
169
|
+
return { kind: hint, imports, notices };
|
|
170
|
+
}
|
|
171
|
+
if (hint === 'browser') {
|
|
172
|
+
// A browser-named file that drives an app is an end-to-end law, as it always was.
|
|
173
|
+
notices.push(`${base}: the name says browser, but it reaches the end-to-end API ${via(imports.e2e)}; it runs end to end`);
|
|
174
|
+
return { kind, imports, notices };
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
kind,
|
|
178
|
+
imports,
|
|
179
|
+
notices,
|
|
180
|
+
error: `${base}: the name says end-to-end, but it reaches the browser API ${via(imports.browser)} and not tslaws/e2e; rename it or fix its imports`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** The project sources a file imports directly, project-relative, in the order they are imported. */
|
|
184
|
+
export function directProjectImports(cwd, file) {
|
|
185
|
+
const from = resolve(cwd, file);
|
|
186
|
+
const found = [];
|
|
187
|
+
let text;
|
|
188
|
+
try {
|
|
189
|
+
text = readFileSync(from, 'utf8');
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
for (const { value } of moduleSpecifiers(text, from)) {
|
|
195
|
+
const target = projectSource(value, from);
|
|
196
|
+
if (target === undefined)
|
|
197
|
+
continue;
|
|
198
|
+
const inside = relative(resolve(cwd), resolve(target)).replaceAll('\\', '/');
|
|
199
|
+
if (!inside.startsWith('..') && !isAbsolute(inside) && !found.includes(inside))
|
|
200
|
+
found.push(inside);
|
|
201
|
+
}
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
/** The resolved level of each laws file, as the lock records it; none when levels come from names (the default). */
|
|
205
|
+
export function lockLevels(cwd, laws, mode) {
|
|
206
|
+
if (mode !== 'imports')
|
|
207
|
+
return undefined;
|
|
208
|
+
const files = [...new Set(laws.map((l) => l.file.replaceAll(String.fromCharCode(92), '/')))];
|
|
209
|
+
return Object.fromEntries(files.map((f) => [f, classifyLawFile(cwd, f, mode).kind]));
|
|
210
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Whether the file is a proposed laws file, of any kind. */
|
|
2
|
+
export declare function isProposedLawFile(file: string): boolean;
|
|
3
|
+
/** The accepted name a proposed file stands for; other names come back unchanged. */
|
|
4
|
+
export declare function acceptedLawName(file: string): string;
|
|
5
|
+
/** The level a file name hints at: `.browser.laws.ts` and `.e2e.laws.ts` (proposed or accepted). Only a hint; imports decide. */
|
|
6
|
+
export declare function suffixKind(file: string): 'browser' | 'e2e' | undefined;
|
package/dist/lawNames.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Law file names. By default a law's level (Node, browser or end to end) comes from the name: `.browser.` and `.e2e.`
|
|
2
|
+
// before `.laws.ts`; with `levels: "imports"` in tslaws.json it comes from the imports (see lawKinds.ts) and the suffixes are
|
|
3
|
+
// hints. A proposed file is the accepted name with `.proposed` before `.laws`: `a.proposed.laws.ts` proposes
|
|
4
|
+
// `a.laws.ts`, `a.browser.proposed.laws.ts` proposes `a.browser.laws.ts` and `a.e2e.proposed.laws.ts` proposes
|
|
5
|
+
// `a.e2e.laws.ts`. A proposed file keeps the kind of the file it proposes, so it runs at the same level.
|
|
6
|
+
/** Whether the file is a proposed laws file, of any kind. */
|
|
7
|
+
export function isProposedLawFile(file) {
|
|
8
|
+
return file.endsWith('.proposed.laws.ts');
|
|
9
|
+
}
|
|
10
|
+
/** The accepted name a proposed file stands for; other names come back unchanged. */
|
|
11
|
+
export function acceptedLawName(file) {
|
|
12
|
+
return isProposedLawFile(file) ? `${file.slice(0, -'.proposed.laws.ts'.length)}.laws.ts` : file;
|
|
13
|
+
}
|
|
14
|
+
/** The level a file name hints at: `.browser.laws.ts` and `.e2e.laws.ts` (proposed or accepted). Only a hint; imports decide. */
|
|
15
|
+
export function suffixKind(file) {
|
|
16
|
+
const accepted = acceptedLawName(file);
|
|
17
|
+
if (/\.e2e\.laws\.[cm]?ts$/.test(accepted))
|
|
18
|
+
return 'e2e';
|
|
19
|
+
if (/\.browser\.laws\.[cm]?ts$/.test(accepted))
|
|
20
|
+
return 'browser';
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
package/dist/list.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type LawKind } from './lawKinds.ts';
|
|
2
|
+
export interface ListedFile {
|
|
3
|
+
/** Project-relative path with forward slashes. */
|
|
4
|
+
file: string;
|
|
5
|
+
/** The level it runs at: from its name by default, from what it imports with `levels: "imports"`. */
|
|
6
|
+
kind: LawKind;
|
|
7
|
+
/**
|
|
8
|
+
* `accepted`: the lock has laws of this file. `unaccepted`: in the tree, but the lock has none of it, so `check`
|
|
9
|
+
* fails it until a person runs `tslaws accept`. `proposed`: a proposal (in `.tslaws/proposals`, or an
|
|
10
|
+
* in-tree `*.proposed.laws.ts`); no lock, no coverage.
|
|
11
|
+
*/
|
|
12
|
+
status: 'accepted' | 'unaccepted' | 'proposed';
|
|
13
|
+
/** Set when the file cannot run at one level (it reaches both APIs). */
|
|
14
|
+
error?: string;
|
|
15
|
+
/** Where a proposal lives: `proposals` (the directory) or `name` (an in-tree `*.proposed.laws.ts` name). */
|
|
16
|
+
proposal?: 'proposals' | 'name';
|
|
17
|
+
notices: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Every laws file and proposal of the project, sorted by path, accepted and unaccepted before proposed. */
|
|
20
|
+
export declare function listLawFiles(cwd: string): ListedFile[];
|
|
21
|
+
/** One line per file: `kind status path`. */
|
|
22
|
+
export declare function renderList(files: ListedFile[]): string;
|
package/dist/list.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// `tslaws list`: the laws files of the project with the level each runs at and where it stands, so an editor or an agent
|
|
2
|
+
// can ask the tool instead of matching file names. It reads files and the lock; it compiles and runs nothing.
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { discoverLaws, discoverProposals } from "./lawFiles.js";
|
|
5
|
+
import { readConfig } from "./coverage.js";
|
|
6
|
+
import { classifyLawFile } from "./lawKinds.js";
|
|
7
|
+
import { readLock } from "./lock.js";
|
|
8
|
+
/** Every laws file and proposal of the project, sorted by path, accepted and unaccepted before proposed. */
|
|
9
|
+
export function listLawFiles(cwd) {
|
|
10
|
+
const mode = readConfig(cwd).levels ?? 'names';
|
|
11
|
+
const lock = readLock(join(cwd, 'tsc-laws.lock'));
|
|
12
|
+
const locked = new Set(Object.keys(lock?.laws ?? {}).map((key) => key.slice(0, key.indexOf('::'))));
|
|
13
|
+
const entry = (file, status, proposal) => {
|
|
14
|
+
const verdict = classifyLawFile(cwd, file, mode);
|
|
15
|
+
return {
|
|
16
|
+
file,
|
|
17
|
+
kind: verdict.kind,
|
|
18
|
+
status,
|
|
19
|
+
...(verdict.error === undefined ? {} : { error: verdict.error }),
|
|
20
|
+
...(proposal === undefined ? {} : { proposal }),
|
|
21
|
+
notices: verdict.notices,
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
return [
|
|
25
|
+
...discoverLaws(cwd).map((file) => entry(file, locked.has(file) ? 'accepted' : 'unaccepted')),
|
|
26
|
+
...discoverLaws(cwd, true).map((file) => entry(file, 'proposed', 'name')),
|
|
27
|
+
...discoverProposals(cwd).map((file) => entry(file, 'proposed', 'proposals')),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
/** One line per file: `kind status path`. */
|
|
31
|
+
export function renderList(files) {
|
|
32
|
+
if (files.length === 0)
|
|
33
|
+
return 'no laws files found\n';
|
|
34
|
+
const width = Math.max(...files.map((f) => f.kind.length));
|
|
35
|
+
return `${files.map((f) => `${f.kind.padEnd(width)} ${f.status.padEnd(10)} ${f.file}${f.error === undefined ? '' : ` (${f.error})`}`).join('\n')}\n`;
|
|
36
|
+
}
|
package/dist/lock.d.ts
CHANGED
|
@@ -4,13 +4,26 @@ export interface Lock {
|
|
|
4
4
|
laws: LockEntries;
|
|
5
5
|
/** `file::name` of the functions a person exempted from coverage. */
|
|
6
6
|
exempt: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Only in a lock accepted with `levels: "imports"`: the level each laws file resolved to. A lock accepted by name
|
|
9
|
+
* (the default) has no such section and keeps the format it always had.
|
|
10
|
+
*/
|
|
11
|
+
levels?: Record<string, string>;
|
|
7
12
|
}
|
|
8
13
|
export declare function lawKey(file: string, name: string): string;
|
|
9
14
|
export declare function lawHash(law: ManifestLaw): string;
|
|
10
15
|
export declare function readLock(path: string): Lock | undefined;
|
|
11
|
-
export declare function writeLock(path: string, laws: ManifestLaw[], exempt?: string[]): void;
|
|
16
|
+
export declare function writeLock(path: string, laws: ManifestLaw[], exempt?: string[], levels?: Record<string, string>): void;
|
|
12
17
|
export interface LockViolation {
|
|
13
18
|
key: string;
|
|
14
|
-
reason: 'changed' | 'added' | 'removed';
|
|
19
|
+
reason: 'changed' | 'added' | 'removed' | 'level';
|
|
15
20
|
}
|
|
16
|
-
|
|
21
|
+
/**
|
|
22
|
+
* `levels` is the level each laws file resolves to now, or undefined when levels come from names and the lock has no
|
|
23
|
+
* levels section (then nothing about levels is compared). A law whose file's level is not the locked one, or is not
|
|
24
|
+
* locked at all, is a violation until a person accepts again.
|
|
25
|
+
*/
|
|
26
|
+
export declare function compareLock(lock: LockEntries | undefined, laws: ManifestLaw[], levels?: {
|
|
27
|
+
locked: Record<string, string> | undefined;
|
|
28
|
+
now: Record<string, string>;
|
|
29
|
+
}): LockViolation[];
|
package/dist/lock.js
CHANGED
|
@@ -13,15 +13,29 @@ export function readLock(path) {
|
|
|
13
13
|
if (!existsSync(path))
|
|
14
14
|
return undefined;
|
|
15
15
|
const lock = JSON.parse(readFileSync(path, 'utf8'));
|
|
16
|
-
return { laws: lock.laws, exempt: lock.exempt ?? [] };
|
|
16
|
+
return { laws: lock.laws, exempt: lock.exempt ?? [], ...(lock.levels === undefined ? {} : { levels: lock.levels }) };
|
|
17
17
|
}
|
|
18
|
-
export function writeLock(path, laws, exempt = []) {
|
|
18
|
+
export function writeLock(path, laws, exempt = [], levels) {
|
|
19
19
|
const entries = {};
|
|
20
20
|
for (const law of [...laws].sort((a, b) => lawKey(a.file, a.name).localeCompare(lawKey(b.file, b.name))))
|
|
21
21
|
entries[lawKey(law.file, law.name)] = lawHash(law);
|
|
22
|
-
|
|
22
|
+
const recorded = {};
|
|
23
|
+
for (const file of Object.keys(levels ?? {}).sort())
|
|
24
|
+
recorded[file.replaceAll('\\', '/')] = levels[file];
|
|
25
|
+
const body = {
|
|
26
|
+
version: 1,
|
|
27
|
+
laws: entries,
|
|
28
|
+
exempt: [...exempt].sort(),
|
|
29
|
+
...(levels === undefined ? {} : { levels: recorded }),
|
|
30
|
+
};
|
|
31
|
+
writeFileSync(path, JSON.stringify(body, null, 2) + '\n');
|
|
23
32
|
}
|
|
24
|
-
|
|
33
|
+
/**
|
|
34
|
+
* `levels` is the level each laws file resolves to now, or undefined when levels come from names and the lock has no
|
|
35
|
+
* levels section (then nothing about levels is compared). A law whose file's level is not the locked one, or is not
|
|
36
|
+
* locked at all, is a violation until a person accepts again.
|
|
37
|
+
*/
|
|
38
|
+
export function compareLock(lock, laws, levels) {
|
|
25
39
|
const violations = [];
|
|
26
40
|
const seen = new Set();
|
|
27
41
|
for (const law of laws) {
|
|
@@ -32,6 +46,11 @@ export function compareLock(lock, laws) {
|
|
|
32
46
|
violations.push({ key, reason: 'added' });
|
|
33
47
|
else if (locked !== lawHash(law))
|
|
34
48
|
violations.push({ key, reason: 'changed' });
|
|
49
|
+
else if (levels !== undefined) {
|
|
50
|
+
const file = law.file.replaceAll('\\', '/');
|
|
51
|
+
if (levels.locked?.[file] !== levels.now[file])
|
|
52
|
+
violations.push({ key, reason: 'level' });
|
|
53
|
+
}
|
|
35
54
|
}
|
|
36
55
|
for (const key of Object.keys(lock ?? {}))
|
|
37
56
|
if (!seen.has(key))
|
package/dist/lower.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ManifestLaw } from './types.ts';
|
|
2
|
+
export interface Lowered {
|
|
3
|
+
/** The file text with every law call replaced by an exported function. */
|
|
4
|
+
text: string;
|
|
5
|
+
laws: ManifestLaw[];
|
|
6
|
+
}
|
|
7
|
+
/** The pre-call syntax (`export law name(...)`), which only the tsc-laws compiler parses. */
|
|
8
|
+
export declare function usesLegacySyntax(text: string): boolean;
|
|
9
|
+
/** Lowers the law calls of one file; `file` is the project-relative path the results carry. */
|
|
10
|
+
export declare function lowerLawFile(file: string, text: string): Lowered;
|
package/dist/lower.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Laws as calls: `law('name', (x: int) => ...)` and `law.proved(...)` in a `*.laws.ts` file are ordinary TypeScript. The
|
|
2
|
+
// runner lowers each top-level call to the exported function the rest of tslaws already runs, proves and tests, keeping
|
|
3
|
+
// every line where it was so diagnostics and counterexamples point at the file the person wrote.
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
/** The pre-call syntax (`export law name(...)`), which only the tsc-laws compiler parses. */
|
|
6
|
+
export function usesLegacySyntax(text) {
|
|
7
|
+
if (!/\blaw\b/.test(text))
|
|
8
|
+
return false;
|
|
9
|
+
// Scan tokens, so a declaration shown in a comment or a string is not mistaken for one.
|
|
10
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, text);
|
|
11
|
+
const tokens = [];
|
|
12
|
+
// The scanner does not follow `${...}` inside a template on its own: track the braces and rescan the template parts.
|
|
13
|
+
const braces = [];
|
|
14
|
+
for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) {
|
|
15
|
+
if (kind === ts.SyntaxKind.TemplateHead)
|
|
16
|
+
braces.push('template');
|
|
17
|
+
else if (kind === ts.SyntaxKind.OpenBraceToken)
|
|
18
|
+
braces.push('brace');
|
|
19
|
+
else if (kind === ts.SyntaxKind.CloseBraceToken && braces.pop() === 'template') {
|
|
20
|
+
kind = scanner.reScanTemplateToken(false);
|
|
21
|
+
if (kind === ts.SyntaxKind.TemplateMiddle)
|
|
22
|
+
braces.push('template');
|
|
23
|
+
}
|
|
24
|
+
tokens.push({ kind, text: scanner.getTokenText(), newline: scanner.hasPrecedingLineBreak() });
|
|
25
|
+
}
|
|
26
|
+
// A law may be named with a word that is also a keyword (`never`, `type`), so any word-like token is a name.
|
|
27
|
+
const word = (i, value) => {
|
|
28
|
+
const token = tokens[i];
|
|
29
|
+
if (token === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
const wordLike = token.kind === ts.SyntaxKind.Identifier ||
|
|
32
|
+
(token.kind >= ts.SyntaxKind.FirstKeyword && token.kind <= ts.SyntaxKind.LastKeyword);
|
|
33
|
+
return wordLike && (value === undefined || token.text === value);
|
|
34
|
+
};
|
|
35
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
36
|
+
if (tokens[i].text !== 'law')
|
|
37
|
+
continue;
|
|
38
|
+
// Starts a statement: first on its line, or right after `export` / `async` that does.
|
|
39
|
+
let start = i;
|
|
40
|
+
while (start > 0 && ['export', 'async'].includes(tokens[start - 1].text))
|
|
41
|
+
start -= 1;
|
|
42
|
+
if (start > 0 && !tokens[start].newline)
|
|
43
|
+
continue;
|
|
44
|
+
let next = i + 1;
|
|
45
|
+
if (word(next, 'proved') && word(next + 1))
|
|
46
|
+
next += 1;
|
|
47
|
+
const after = tokens[next + 1]?.kind;
|
|
48
|
+
if (word(next) && (after === ts.SyntaxKind.OpenParenToken || after === ts.SyntaxKind.LessThanToken))
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
function newlines(text) {
|
|
54
|
+
let count = 0;
|
|
55
|
+
for (const ch of text)
|
|
56
|
+
if (ch === '\n')
|
|
57
|
+
count += 1;
|
|
58
|
+
return count;
|
|
59
|
+
}
|
|
60
|
+
/** `law` or `law.proved` as the callee of a call; undefined when the call is something else. */
|
|
61
|
+
function lawCallee(call) {
|
|
62
|
+
const callee = call.expression;
|
|
63
|
+
if (ts.isIdentifier(callee) && callee.text === 'law')
|
|
64
|
+
return { proved: false };
|
|
65
|
+
if (ts.isPropertyAccessExpression(callee) &&
|
|
66
|
+
ts.isIdentifier(callee.expression) &&
|
|
67
|
+
callee.expression.text === 'law' &&
|
|
68
|
+
callee.name.text === 'proved')
|
|
69
|
+
return { proved: true };
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
function identifierFor(name, taken) {
|
|
73
|
+
let id = name.replace(/[^\w$]/g, '_');
|
|
74
|
+
if (id === '' || /^\d/.test(id))
|
|
75
|
+
id = `_${id}`;
|
|
76
|
+
let unique = id;
|
|
77
|
+
for (let n = 2; taken.has(unique); n += 1)
|
|
78
|
+
unique = `${id}_${n}`;
|
|
79
|
+
taken.add(unique);
|
|
80
|
+
return unique;
|
|
81
|
+
}
|
|
82
|
+
/** Why a law's function cannot be run, or undefined when it can. */
|
|
83
|
+
function problemsOf(fn) {
|
|
84
|
+
const problems = [];
|
|
85
|
+
if (!ts.isArrowFunction(fn) && fn.asteriskToken !== undefined)
|
|
86
|
+
problems.push('a law cannot be a generator');
|
|
87
|
+
for (const parameter of fn.parameters) {
|
|
88
|
+
if (!ts.isIdentifier(parameter.name))
|
|
89
|
+
problems.push('a law parameter must be a plain identifier');
|
|
90
|
+
else if (parameter.type === undefined)
|
|
91
|
+
problems.push(`parameter ${parameter.name.text} of a law needs a type annotation so inputs can be generated`);
|
|
92
|
+
}
|
|
93
|
+
return problems;
|
|
94
|
+
}
|
|
95
|
+
/** Lowers the law calls of one file; `file` is the project-relative path the results carry. */
|
|
96
|
+
export function lowerLawFile(file, text) {
|
|
97
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, scriptKind(file));
|
|
98
|
+
const taken = new Set();
|
|
99
|
+
for (const statement of source.statements)
|
|
100
|
+
if (ts.isFunctionDeclaration(statement) && statement.name !== undefined)
|
|
101
|
+
taken.add(statement.name.text);
|
|
102
|
+
else if (ts.isVariableStatement(statement))
|
|
103
|
+
for (const d of statement.declarationList.declarations)
|
|
104
|
+
if (ts.isIdentifier(d.name))
|
|
105
|
+
taken.add(d.name.text);
|
|
106
|
+
const laws = [];
|
|
107
|
+
const edits = [];
|
|
108
|
+
const checks = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (const statement of source.statements) {
|
|
111
|
+
if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression))
|
|
112
|
+
continue;
|
|
113
|
+
const call = statement.expression;
|
|
114
|
+
const callee = lawCallee(call);
|
|
115
|
+
if (callee === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
const start = call.getStart(source);
|
|
118
|
+
const line = source.getLineAndCharacterOfPosition(start).line + 1;
|
|
119
|
+
const [nameArg, bodyArg] = call.arguments;
|
|
120
|
+
const lawText = text.slice(start, call.end);
|
|
121
|
+
const base = { file, exported: true, proved: callee.proved, line, text: lawText };
|
|
122
|
+
if (nameArg === undefined || !(ts.isStringLiteral(nameArg) || ts.isNoSubstitutionTemplateLiteral(nameArg))) {
|
|
123
|
+
laws.push({
|
|
124
|
+
...base,
|
|
125
|
+
name: `law@${line}`,
|
|
126
|
+
params: [],
|
|
127
|
+
body: '',
|
|
128
|
+
problems: ['the name of a law must be a string literal'],
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const name = nameArg.text;
|
|
133
|
+
if (bodyArg === undefined || !(ts.isArrowFunction(bodyArg) || ts.isFunctionExpression(bodyArg))) {
|
|
134
|
+
laws.push({ ...base, name, params: [], body: '', problems: ['a law needs a function as its second argument'] });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const problems = problemsOf(bodyArg);
|
|
138
|
+
if (seen.has(name))
|
|
139
|
+
problems.push(`there is already a law named "${name}" in this file`);
|
|
140
|
+
seen.add(name);
|
|
141
|
+
const params = bodyArg.parameters.map((p) => p.name.getText(source));
|
|
142
|
+
const body = text.slice(bodyArg.body.getStart(source), bodyArg.body.end);
|
|
143
|
+
const entry = { ...base, name, symbol: name, params, body, problems };
|
|
144
|
+
laws.push(entry);
|
|
145
|
+
if (problems.length > 0)
|
|
146
|
+
continue;
|
|
147
|
+
const id = identifierFor(name, taken);
|
|
148
|
+
entry.symbol = id;
|
|
149
|
+
const isAsync = bodyArg.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) === true;
|
|
150
|
+
const typeParameters = bodyArg.typeParameters === undefined
|
|
151
|
+
? ''
|
|
152
|
+
: `<${text.slice(bodyArg.typeParameters.pos, bodyArg.typeParameters.end)}>`;
|
|
153
|
+
const parameterText = text.slice(bodyArg.parameters.pos, bodyArg.parameters.end);
|
|
154
|
+
const returnType = bodyArg.type === undefined ? '' : `: ${text.slice(bodyArg.type.getStart(source), bodyArg.type.end)}`;
|
|
155
|
+
const header = `export ${isAsync ? 'async ' : ''}function ${id}${typeParameters}(${parameterText})${returnType}`;
|
|
156
|
+
const bodyStart = bodyArg.body.getStart(source);
|
|
157
|
+
const dropped = newlines(text.slice(start, bodyStart)) - newlines(parameterText) - newlines(returnType);
|
|
158
|
+
const pad = '\n'.repeat(Math.max(0, dropped));
|
|
159
|
+
const tail = '\n'.repeat(newlines(text.slice(bodyArg.body.end, statement.end)));
|
|
160
|
+
const inner = text.slice(bodyStart, bodyArg.body.end);
|
|
161
|
+
const replacement = ts.isBlock(bodyArg.body)
|
|
162
|
+
? `${header} ${pad}${inner}${tail}`
|
|
163
|
+
: `${header} {${pad} return ${inner};${tail} }`;
|
|
164
|
+
edits.push({ start: statement.getStart(source), end: statement.end, replacement });
|
|
165
|
+
checks.push(`type __LawResult_${id} = __LawReturns<typeof ${id}>;`);
|
|
166
|
+
}
|
|
167
|
+
let lowered = text;
|
|
168
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
169
|
+
lowered = lowered.slice(0, edit.start) + edit.replacement + lowered.slice(edit.end);
|
|
170
|
+
if (checks.length > 0)
|
|
171
|
+
lowered += `\ntype __LawReturns<F extends (...args: never[]) => boolean | void | Promise<boolean | void>> = F;${checks.join('')}`;
|
|
172
|
+
return { text: lowered, laws };
|
|
173
|
+
}
|
|
174
|
+
function scriptKind(file) {
|
|
175
|
+
if (file.endsWith('.tsx'))
|
|
176
|
+
return ts.ScriptKind.TSX;
|
|
177
|
+
return ts.ScriptKind.TS;
|
|
178
|
+
}
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mergeProposal } from './proposals.ts';
|
|
1
2
|
interface Tool {
|
|
2
3
|
name: string;
|
|
3
4
|
description: string;
|
|
@@ -8,8 +9,6 @@ export interface McpOptions {
|
|
|
8
9
|
cwd: string;
|
|
9
10
|
compiler: string;
|
|
10
11
|
}
|
|
11
|
-
/** The only files a tool may write. */
|
|
12
|
-
export declare function proposalPath(cwd: string, file: string): string;
|
|
13
12
|
export declare class Session {
|
|
14
13
|
private readonly last;
|
|
15
14
|
private readonly opts;
|
|
@@ -18,13 +17,8 @@ export declare class Session {
|
|
|
18
17
|
private run;
|
|
19
18
|
call(name: string, args: Record<string, unknown>): Promise<unknown>;
|
|
20
19
|
}
|
|
21
|
-
|
|
22
|
-
export declare function mergeProposal(current: string, imports: string[], laws: {
|
|
23
|
-
name: string;
|
|
24
|
-
text: string;
|
|
25
|
-
}[]): string;
|
|
20
|
+
export { mergeProposal };
|
|
26
21
|
/** The reply line for one request line, or undefined for a notification. */
|
|
27
22
|
export declare function handleLine(session: Session, line: string): Promise<string | undefined>;
|
|
28
23
|
/** Serves MCP on stdin/stdout until stdin closes. Resolves the exit status. */
|
|
29
24
|
export declare function serveMcp(opts: McpOptions): Promise<number>;
|
|
30
|
-
export {};
|