tslaws 0.5.1 → 0.6.1
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/dist/check.js +23 -2
- package/dist/cli.js +17 -3
- package/dist/exercises.d.ts +64 -0
- package/dist/exercises.js +284 -0
- package/dist/property.d.ts +2 -0
- package/dist/property.js +1 -0
- package/dist/reporters.d.ts +2 -2
- package/dist/reporters.js +2 -2
- package/dist/types.d.ts +3 -0
- package/package.json +6 -6
package/dist/check.js
CHANGED
|
@@ -10,6 +10,7 @@ import { proveLaw } from "./smt.js";
|
|
|
10
10
|
import { decodeExample, loadExamples, regressionMessage, saveExample } from "./regressions.js";
|
|
11
11
|
import { closeBrowser, runBrowserLaw } from "./browser.js";
|
|
12
12
|
import { ensureServer, importsBoth, lawMode, runE2eLaw, stopServer } from "./e2e.js";
|
|
13
|
+
import { declarationOf, exercisesOf, markFailing, Sources } from "./exercises.js";
|
|
13
14
|
/** The `@tslaws-exempt` functions of the project, for `tslaws accept` to record. */
|
|
14
15
|
export function exemptionsOf(options) {
|
|
15
16
|
const covered = coverFiles(options.cwd, readConfig(options.cwd).cover ?? []);
|
|
@@ -28,8 +29,27 @@ export async function check(options, enforceLock) {
|
|
|
28
29
|
const laws = readManifest(options.compiler, options.files, options.cwd);
|
|
29
30
|
const results = [];
|
|
30
31
|
const blocked = new Set();
|
|
32
|
+
const sources = new Sources(options.cwd);
|
|
33
|
+
// What a law reaches is syntactic and cheap; a project that cannot be read this way just has no locations.
|
|
34
|
+
const exercisesFor = (law) => {
|
|
35
|
+
try {
|
|
36
|
+
return exercisesOf(sources, law);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
};
|
|
31
42
|
const add = (law, status, method, extra = {}) => {
|
|
32
|
-
const
|
|
43
|
+
const exercises = status === 'tampered' ? [] : (extra.exercises ?? exercisesFor(law));
|
|
44
|
+
const result = {
|
|
45
|
+
file: law.file,
|
|
46
|
+
name: law.name,
|
|
47
|
+
line: law.line,
|
|
48
|
+
status,
|
|
49
|
+
method,
|
|
50
|
+
...extra,
|
|
51
|
+
...(exercises.length > 0 ? { exercises } : {}),
|
|
52
|
+
};
|
|
33
53
|
results.push(result);
|
|
34
54
|
blocked.add(lawKey(law.file, law.name));
|
|
35
55
|
options.onEvent?.({ type: 'result', result });
|
|
@@ -164,6 +184,7 @@ export async function check(options, enforceLock) {
|
|
|
164
184
|
? 'returned false'
|
|
165
185
|
: `threw: ${details.message}`;
|
|
166
186
|
add(law, 'failed', method, {
|
|
187
|
+
exercises: markFailing(exercisesFor(law), details.stack, built.outDir),
|
|
167
188
|
runs: details.numRuns,
|
|
168
189
|
seed: details.seed,
|
|
169
190
|
counterexample: details.counterexample,
|
|
@@ -240,7 +261,7 @@ export async function check(options, enforceLock) {
|
|
|
240
261
|
results.push({
|
|
241
262
|
file,
|
|
242
263
|
name,
|
|
243
|
-
line: 0,
|
|
264
|
+
line: declarationOf(sources, file, name)?.line ?? 0,
|
|
244
265
|
status: 'uncovered',
|
|
245
266
|
method: 'coverage',
|
|
246
267
|
message: 'no law reaches this exported function; add a law, or a person can mark it @tslaws-exempt and accept',
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { serveUi } from "./ui.js";
|
|
|
18
18
|
import { acceptGate, defaultDuration, describeGrants, fileHash, grantFor, logDelegatedAccept, parseDuration, personOnly, revokeFor, } from "./delegation.js";
|
|
19
19
|
import { writeLock } from "./lock.js";
|
|
20
20
|
const usage = `tslaws init [--force] scaffold tslaws.json, .gitignore, AGENTS.md, Claude Code hooks and a starter law (never writes the lock)
|
|
21
|
-
tslaws check [files...] [--reporter text|json|junit|github] [--json] [--summary] [--watch] [--runs N] [--seed N] [--no-prove] [--no-regressions] [--bound N] [--proof-timeout MS] [--property-timeout MS] [--proposed] [--compiler PATH] [--browser chromium|firefox|webkit]
|
|
21
|
+
tslaws check [files...] [--reporter text|json|junit|github|ndjson] [--law NAME[,NAME]] [--json] [--summary] [--watch] [--runs N] [--seed N] [--no-prove] [--no-regressions] [--bound N] [--proof-timeout MS] [--property-timeout MS] [--proposed] [--compiler PATH] [--browser chromium|firefox|webkit]
|
|
22
22
|
tslaws ui [files...] [--port N] [--no-open] [--tab] open a dashboard of the laws in its own desktop window (--tab: a browser tab): tree, filters, search, source, live reruns on save
|
|
23
23
|
tslaws mutate [files...] [--max N] [--seed N] [--runs N] [--bound N] [--proof-timeout MS] [--property-timeout MS] [--browser ENGINE] [--json] mutation-test the laws: mutate covered code, report mutants no law kills
|
|
24
24
|
tslaws regressions [list|clear] show or delete the stored counterexamples (.tslaws/regressions); check replays them first, --no-regressions skips them
|
|
@@ -32,7 +32,8 @@ tslaws mcp [--compiler PATH] serve check, check_proposed, explain and propose
|
|
|
32
32
|
Without files, every *.laws.ts under the current directory is checked (*.proposed.laws.ts only with --proposed).
|
|
33
33
|
Exit status is 0 only when every law is proved, bounded or tested and nothing is open, failed, uncovered or tampered with.
|
|
34
34
|
--json machine-readable report; each result carries a hint with the next action (same as --reporter json)
|
|
35
|
-
--reporter text (default), json, junit (JUnit XML)
|
|
35
|
+
--reporter text (default), json, junit (JUnit XML), github (workflow annotations; appends a table to $GITHUB_STEP_SUMMARY) or ndjson (one JSON event per line as the check proceeds, then the report; for editors)
|
|
36
|
+
--law check only the laws with these names (comma-separated), in the files chosen
|
|
36
37
|
--summary one line per non-passing law, then the totals line
|
|
37
38
|
--watch rerun the check when a .ts file under the current directory changes (Ctrl+C exits)
|
|
38
39
|
--force init: overwrite tslaws.json and replace the AGENTS.md section`;
|
|
@@ -68,6 +69,7 @@ function parse(args) {
|
|
|
68
69
|
'browser',
|
|
69
70
|
'port',
|
|
70
71
|
'reporter',
|
|
72
|
+
'law',
|
|
71
73
|
'max',
|
|
72
74
|
'for',
|
|
73
75
|
'root',
|
|
@@ -136,6 +138,9 @@ function render(report, flags) {
|
|
|
136
138
|
if (reporter === 'json') {
|
|
137
139
|
return `${JSON.stringify({ ...report, results: report.results.map((r) => ({ ...r, hint: hintFor(r) })) }, null, 2)}\n`;
|
|
138
140
|
}
|
|
141
|
+
if (reporter === 'ndjson') {
|
|
142
|
+
return `${JSON.stringify({ type: 'report', ...report, results: report.results.map((r) => ({ ...r, hint: hintFor(r) })) })}\n`;
|
|
143
|
+
}
|
|
139
144
|
let text = '';
|
|
140
145
|
if (report.compileDiagnostics !== '')
|
|
141
146
|
text += `${report.compileDiagnostics}\n\n`;
|
|
@@ -162,6 +167,7 @@ function options(cwd, flags, chosen) {
|
|
|
162
167
|
lockFile: join(cwd, 'tsc-laws.lock'),
|
|
163
168
|
noProve: flags.has('no-prove'),
|
|
164
169
|
noRegressions: flags.has('no-regressions'),
|
|
170
|
+
only: typeof flags.get('law') === 'string' ? String(flags.get('law')).split(',').filter(Boolean) : undefined,
|
|
165
171
|
bound: settings.bound,
|
|
166
172
|
proofTimeoutMs: settings.proofTimeoutMs,
|
|
167
173
|
propertyTimeoutMs: settings.propertyTimeoutMs,
|
|
@@ -183,7 +189,15 @@ async function runCheck(cwd, flags, files) {
|
|
|
183
189
|
process.stderr.write('no laws files found (looking for *.laws.ts)\n');
|
|
184
190
|
return 1;
|
|
185
191
|
}
|
|
186
|
-
|
|
192
|
+
// `--reporter ndjson` streams one JSON event per line as the check proceeds, then the report, so an editor can show progress.
|
|
193
|
+
const stream = flags.get('reporter') === 'ndjson';
|
|
194
|
+
const onEvent = stream
|
|
195
|
+
? (event) => {
|
|
196
|
+
if (event.type !== 'frame')
|
|
197
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
198
|
+
}
|
|
199
|
+
: undefined;
|
|
200
|
+
const report = await check({ ...options(cwd, flags, chosen), onEvent }, !flags.has('proposed'));
|
|
187
201
|
process.stdout.write(render(report, flags));
|
|
188
202
|
return report.ok ? 0 : 1;
|
|
189
203
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import type { ManifestLaw } from './types.ts';
|
|
3
|
+
/** A range in a project file: lines and columns are 1-based, the end is exclusive. `file` is relative to the project. */
|
|
4
|
+
export interface SourceSpan {
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
endLine: number;
|
|
9
|
+
endColumn: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Exercised {
|
|
12
|
+
name: string;
|
|
13
|
+
/** The function's name where it is declared. */
|
|
14
|
+
declaration: SourceSpan;
|
|
15
|
+
/** The first reference to it in the law itself; absent for a function the law reaches only through others. */
|
|
16
|
+
call?: SourceSpan;
|
|
17
|
+
/** The failure came from this function: the stack of the error the law threw names it. */
|
|
18
|
+
failing?: true;
|
|
19
|
+
}
|
|
20
|
+
interface Binding {
|
|
21
|
+
/** The module the local name comes from, and the name it has there (`*` for a namespace import). */
|
|
22
|
+
module: string;
|
|
23
|
+
imported: string;
|
|
24
|
+
}
|
|
25
|
+
interface Declared {
|
|
26
|
+
name: string;
|
|
27
|
+
nameNode: ts.Node;
|
|
28
|
+
body: ts.Node;
|
|
29
|
+
}
|
|
30
|
+
interface Module {
|
|
31
|
+
file: string;
|
|
32
|
+
text: string;
|
|
33
|
+
source: ts.SourceFile;
|
|
34
|
+
nodes: Map<string, Declared>;
|
|
35
|
+
imports: Map<string, Binding>;
|
|
36
|
+
/** `export { local as exported }` of this module and `export { a as b } from './m'`. */
|
|
37
|
+
exports: Map<string, Binding | string>;
|
|
38
|
+
stars: string[];
|
|
39
|
+
}
|
|
40
|
+
/** Reads and indexes project modules once per check. */
|
|
41
|
+
export declare class Sources {
|
|
42
|
+
private readonly modules;
|
|
43
|
+
readonly cwd: string;
|
|
44
|
+
constructor(cwd: string);
|
|
45
|
+
/** The module at project-relative `file`, or undefined when it is not a readable TypeScript source. */
|
|
46
|
+
module(file: string): Module | undefined;
|
|
47
|
+
/** The project file a specifier names, or undefined for a package, a declaration file or a path outside the project. */
|
|
48
|
+
private resolveImport;
|
|
49
|
+
private index;
|
|
50
|
+
/** The declaration `name` exports from `file`, following re-exports. */
|
|
51
|
+
find(file: string, name: string, depth?: number): {
|
|
52
|
+
module: Module;
|
|
53
|
+
node: Declared;
|
|
54
|
+
} | undefined;
|
|
55
|
+
}
|
|
56
|
+
/** The functions `law` reaches: the ones it names first, in the order it names them, then those they use. */
|
|
57
|
+
export declare function exercisesOf(sources: Sources, law: ManifestLaw): Exercised[];
|
|
58
|
+
/** The stack frames of `stack` that run project code: `[function, ...]` innermost first. */
|
|
59
|
+
export declare function frameFunctions(stack: string, outDir: string): string[];
|
|
60
|
+
/** Marks the function an error's stack names: the innermost project frame that is one of the law's functions. */
|
|
61
|
+
export declare function markFailing(exercised: Exercised[], stack: string | undefined, outDir: string): Exercised[];
|
|
62
|
+
/** The declaration of `name` in project `file`, for a result that names a function but not where it is. */
|
|
63
|
+
export declare function declarationOf(sources: Sources, file: string, name: string): SourceSpan | undefined;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Which project functions a law exercises, and where they are: the declaration of each (its name) and, for a function the
|
|
2
|
+
// law names itself, the reference in the law. An editor shows a failing law on both ends with this. It is a syntactic
|
|
3
|
+
// walk over the sources, the same reach the coverage rule uses: calls and references to functions declared at the top of
|
|
4
|
+
// a project module, followed through imports and re-exports, and through the functions those functions use.
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { relative, resolve, sep } from 'node:path';
|
|
7
|
+
import ts from 'typescript';
|
|
8
|
+
const resolution = {
|
|
9
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
10
|
+
module: ts.ModuleKind.ESNext,
|
|
11
|
+
allowImportingTsExtensions: true,
|
|
12
|
+
allowJs: false,
|
|
13
|
+
};
|
|
14
|
+
const ownSource = /\.(ts|tsx|mts|cts)$/;
|
|
15
|
+
/** Reads and indexes project modules once per check. */
|
|
16
|
+
export class Sources {
|
|
17
|
+
modules = new Map();
|
|
18
|
+
cwd;
|
|
19
|
+
constructor(cwd) {
|
|
20
|
+
this.cwd = cwd;
|
|
21
|
+
}
|
|
22
|
+
/** The module at project-relative `file`, or undefined when it is not a readable TypeScript source. */
|
|
23
|
+
module(file) {
|
|
24
|
+
if (this.modules.has(file))
|
|
25
|
+
return this.modules.get(file);
|
|
26
|
+
const full = resolve(this.cwd, file);
|
|
27
|
+
let module;
|
|
28
|
+
if (ownSource.test(file) && !file.endsWith('.d.ts') && existsSync(full)) {
|
|
29
|
+
const text = readFileSync(full, 'utf8');
|
|
30
|
+
module = this.index(file, text);
|
|
31
|
+
}
|
|
32
|
+
this.modules.set(file, module);
|
|
33
|
+
return module;
|
|
34
|
+
}
|
|
35
|
+
/** The project file a specifier names, or undefined for a package, a declaration file or a path outside the project. */
|
|
36
|
+
resolveImport(specifier, from) {
|
|
37
|
+
const found = ts.resolveModuleName(specifier, resolve(this.cwd, from), resolution, ts.sys).resolvedModule;
|
|
38
|
+
if (found === undefined || found.isExternalLibraryImport === true)
|
|
39
|
+
return undefined;
|
|
40
|
+
const file = relative(this.cwd, found.resolvedFileName).split(sep).join('/');
|
|
41
|
+
if (file.startsWith('..') || file.split('/').includes('node_modules'))
|
|
42
|
+
return undefined;
|
|
43
|
+
return ownSource.test(file) && !file.endsWith('.d.ts') ? file : undefined;
|
|
44
|
+
}
|
|
45
|
+
index(file, text) {
|
|
46
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS);
|
|
47
|
+
const module = { file, text, source, nodes: new Map(), imports: new Map(), exports: new Map(), stars: [] };
|
|
48
|
+
const declare = (name, nameNode, body) => {
|
|
49
|
+
module.nodes.set(name, { name, nameNode, body });
|
|
50
|
+
};
|
|
51
|
+
/** The `default` keyword of a declaration, to point at when the function has no name. */
|
|
52
|
+
const defaultKeyword = (node) => node.getChildren(source).find((c) => c.kind === ts.SyntaxKind.DefaultKeyword) ??
|
|
53
|
+
(ts.canHaveModifiers(node)
|
|
54
|
+
? ts.getModifiers(node)?.find((m) => m.kind === ts.SyntaxKind.DefaultKeyword)
|
|
55
|
+
: undefined) ??
|
|
56
|
+
node;
|
|
57
|
+
const isDefault = (node) => ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
58
|
+
for (const statement of source.statements) {
|
|
59
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
60
|
+
const target = this.resolveImport(statement.moduleSpecifier.text, file);
|
|
61
|
+
const clause = statement.importClause;
|
|
62
|
+
if (target === undefined || clause === undefined)
|
|
63
|
+
continue;
|
|
64
|
+
if (clause.name !== undefined)
|
|
65
|
+
module.imports.set(clause.name.text, { module: target, imported: 'default' });
|
|
66
|
+
const bindings = clause.namedBindings;
|
|
67
|
+
if (bindings !== undefined && ts.isNamespaceImport(bindings))
|
|
68
|
+
module.imports.set(bindings.name.text, { module: target, imported: '*' });
|
|
69
|
+
else if (bindings !== undefined)
|
|
70
|
+
for (const element of bindings.elements)
|
|
71
|
+
module.imports.set(element.name.text, {
|
|
72
|
+
module: target,
|
|
73
|
+
imported: (element.propertyName ?? element.name).text,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
else if (ts.isExportDeclaration(statement)) {
|
|
77
|
+
const target = statement.moduleSpecifier !== undefined && ts.isStringLiteral(statement.moduleSpecifier)
|
|
78
|
+
? this.resolveImport(statement.moduleSpecifier.text, file)
|
|
79
|
+
: undefined;
|
|
80
|
+
const clause = statement.exportClause;
|
|
81
|
+
if (clause === undefined || ts.isNamespaceExport(clause)) {
|
|
82
|
+
if (target !== undefined && clause === undefined)
|
|
83
|
+
module.stars.push(target);
|
|
84
|
+
}
|
|
85
|
+
else
|
|
86
|
+
for (const element of clause.elements)
|
|
87
|
+
module.exports.set(element.name.text, target === undefined
|
|
88
|
+
? (element.propertyName ?? element.name).text
|
|
89
|
+
: { module: target, imported: (element.propertyName ?? element.name).text });
|
|
90
|
+
}
|
|
91
|
+
else if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) {
|
|
92
|
+
if (statement.name !== undefined)
|
|
93
|
+
declare(statement.name.text, statement.name, statement);
|
|
94
|
+
if (isDefault(statement))
|
|
95
|
+
declare('default', statement.name ?? defaultKeyword(statement), statement);
|
|
96
|
+
}
|
|
97
|
+
else if (ts.isExportAssignment(statement)) {
|
|
98
|
+
// `export default helper;` and `export = helper;` name a function declared elsewhere; `export default () => ...` is one.
|
|
99
|
+
const value = statement.expression;
|
|
100
|
+
if (ts.isIdentifier(value))
|
|
101
|
+
module.exports.set('default', value.text);
|
|
102
|
+
else if (ts.isArrowFunction(value) || ts.isFunctionExpression(value) || ts.isClassExpression(value))
|
|
103
|
+
declare('default', defaultKeyword(statement), value);
|
|
104
|
+
}
|
|
105
|
+
else if (ts.isVariableStatement(statement)) {
|
|
106
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
107
|
+
const init = declaration.initializer;
|
|
108
|
+
if (ts.isIdentifier(declaration.name) &&
|
|
109
|
+
init !== undefined &&
|
|
110
|
+
(ts.isArrowFunction(init) || ts.isFunctionExpression(init) || ts.isClassExpression(init)))
|
|
111
|
+
declare(declaration.name.text, declaration.name, init);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return module;
|
|
116
|
+
}
|
|
117
|
+
/** The declaration `name` exports from `file`, following re-exports. */
|
|
118
|
+
find(file, name, depth = 0) {
|
|
119
|
+
const module = this.module(file);
|
|
120
|
+
if (module === undefined || depth > 8)
|
|
121
|
+
return undefined;
|
|
122
|
+
const direct = module.nodes.get(name);
|
|
123
|
+
if (direct !== undefined)
|
|
124
|
+
return { module, node: direct };
|
|
125
|
+
const exported = module.exports.get(name);
|
|
126
|
+
if (typeof exported === 'string') {
|
|
127
|
+
const local = exported === name ? undefined : this.find(file, exported, depth + 1);
|
|
128
|
+
if (local !== undefined)
|
|
129
|
+
return local;
|
|
130
|
+
// `import { f } from './m'; export default f;` re-exports an imported function.
|
|
131
|
+
const binding = module.imports.get(exported);
|
|
132
|
+
return binding === undefined || binding.imported === '*'
|
|
133
|
+
? undefined
|
|
134
|
+
: this.find(binding.module, binding.imported, depth + 1);
|
|
135
|
+
}
|
|
136
|
+
if (exported !== undefined)
|
|
137
|
+
return this.find(exported.module, exported.imported, depth + 1);
|
|
138
|
+
for (const star of module.stars) {
|
|
139
|
+
const hit = this.find(star, name, depth + 1);
|
|
140
|
+
if (hit !== undefined)
|
|
141
|
+
return hit;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function spanOf(module, node, start = node.getStart(module.source), end = node.getEnd()) {
|
|
147
|
+
const from = module.source.getLineAndCharacterOfPosition(start);
|
|
148
|
+
const to = module.source.getLineAndCharacterOfPosition(end);
|
|
149
|
+
return {
|
|
150
|
+
file: module.file,
|
|
151
|
+
line: from.line + 1,
|
|
152
|
+
column: from.character + 1,
|
|
153
|
+
endLine: to.line + 1,
|
|
154
|
+
endColumn: to.character + 1,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** The law's own text parsed as TypeScript, with a way back from positions in it to the law's file. */
|
|
158
|
+
function parseLaw(law, module) {
|
|
159
|
+
// A law is `name(params) { ... }` or `name(params) => expression;`: the first is a function, the second an arrow.
|
|
160
|
+
const arrow = law.body.startsWith('=>');
|
|
161
|
+
const prefix = arrow ? `const ${law.name} = ` : 'function ';
|
|
162
|
+
const wrapped = arrow ? `${prefix}${law.text.slice(law.name.length)}` : `${prefix}${law.text}`;
|
|
163
|
+
// The law's text is exact in its file; the declaration line says which copy when a text repeats.
|
|
164
|
+
let lineStart = 0;
|
|
165
|
+
for (let line = 1; line < law.line; line += 1) {
|
|
166
|
+
const next = module.text.indexOf('\n', lineStart);
|
|
167
|
+
if (next < 0)
|
|
168
|
+
break;
|
|
169
|
+
lineStart = next + 1;
|
|
170
|
+
}
|
|
171
|
+
const at = module.text.indexOf(law.text, lineStart);
|
|
172
|
+
const found = at >= 0 ? at : module.text.indexOf(law.text);
|
|
173
|
+
if (found < 0)
|
|
174
|
+
return undefined;
|
|
175
|
+
const shift = arrow ? prefix.length - law.name.length : prefix.length;
|
|
176
|
+
return {
|
|
177
|
+
source: ts.createSourceFile(law.file, wrapped, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS),
|
|
178
|
+
offset: (pos) => found + pos - shift,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** The functions `law` reaches: the ones it names first, in the order it names them, then those they use. */
|
|
182
|
+
export function exercisesOf(sources, law) {
|
|
183
|
+
const lawModule = sources.module(law.file);
|
|
184
|
+
if (lawModule === undefined)
|
|
185
|
+
return [];
|
|
186
|
+
const parsed = parseLaw(law, lawModule);
|
|
187
|
+
if (parsed === undefined)
|
|
188
|
+
return [];
|
|
189
|
+
const lawParams = new Set(law.params);
|
|
190
|
+
const out = [];
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const queue = [];
|
|
193
|
+
const reach = (hit, call) => {
|
|
194
|
+
const id = `${hit.module.file}::${hit.node.nameNode.getStart(hit.module.source)}`;
|
|
195
|
+
if (seen.has(id))
|
|
196
|
+
return;
|
|
197
|
+
seen.add(id);
|
|
198
|
+
const name = hit.node.name === 'default' && ts.isIdentifier(hit.node.nameNode) ? hit.node.nameNode.text : hit.node.name;
|
|
199
|
+
out.push({ name, declaration: spanOf(hit.module, hit.node.nameNode), ...(call === undefined ? {} : { call }) });
|
|
200
|
+
queue.push(hit);
|
|
201
|
+
};
|
|
202
|
+
/** The function an identifier (or `namespace.name`) in `module` stands for. */
|
|
203
|
+
const resolveName = (module, node) => {
|
|
204
|
+
if (ts.isIdentifier(node)) {
|
|
205
|
+
const local = module.nodes.get(node.text);
|
|
206
|
+
if (local !== undefined)
|
|
207
|
+
return { module, node: local };
|
|
208
|
+
const binding = module.imports.get(node.text);
|
|
209
|
+
return binding === undefined || binding.imported === '*'
|
|
210
|
+
? undefined
|
|
211
|
+
: sources.find(binding.module, binding.imported);
|
|
212
|
+
}
|
|
213
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
214
|
+
const binding = module.imports.get(node.expression.text);
|
|
215
|
+
return binding?.imported === '*' ? sources.find(binding.module, node.name.text) : undefined;
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
};
|
|
219
|
+
const walk = (module, root, own, inLaw) => {
|
|
220
|
+
const visit = (node) => {
|
|
221
|
+
if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) {
|
|
222
|
+
const parent = node.parent;
|
|
223
|
+
const isMember = parent !== undefined && ts.isPropertyAccessExpression(parent) && parent.name === node;
|
|
224
|
+
const isBinding = parent !== undefined &&
|
|
225
|
+
((ts.isParameter(parent) && parent.name === node) ||
|
|
226
|
+
(ts.isVariableDeclaration(parent) && parent.name === node) ||
|
|
227
|
+
(ts.isPropertyAssignment(parent) && parent.name === node));
|
|
228
|
+
const wholeName = ts.isIdentifier(node) ? node.text : undefined;
|
|
229
|
+
if (!isMember && !isBinding && !(inLaw && wholeName !== undefined && lawParams.has(wholeName))) {
|
|
230
|
+
const hit = resolveName(module, node);
|
|
231
|
+
if (hit !== undefined && hit.node !== own) {
|
|
232
|
+
const call = inLaw && parsed !== undefined
|
|
233
|
+
? spanOfLaw(lawModule, parsed.offset(node.getStart(root.getSourceFile())), parsed.offset(node.getEnd()))
|
|
234
|
+
: undefined;
|
|
235
|
+
reach(hit, call);
|
|
236
|
+
if (ts.isPropertyAccessExpression(node))
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
ts.forEachChild(node, visit);
|
|
242
|
+
};
|
|
243
|
+
visit(root);
|
|
244
|
+
};
|
|
245
|
+
walk(lawModule, parsed.source, undefined, true);
|
|
246
|
+
for (let i = 0; i < queue.length && queue.length < 400; i += 1) {
|
|
247
|
+
const { module, node } = queue[i];
|
|
248
|
+
walk(module, node.body, node, false);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
function spanOfLaw(module, start, end) {
|
|
253
|
+
return spanOf(module, module.source, start, end);
|
|
254
|
+
}
|
|
255
|
+
/** The stack frames of `stack` that run project code: `[function, ...]` innermost first. */
|
|
256
|
+
export function frameFunctions(stack, outDir) {
|
|
257
|
+
const dir = outDir.split(sep).join('/').toLowerCase();
|
|
258
|
+
const names = [];
|
|
259
|
+
for (const line of stack.split('\n')) {
|
|
260
|
+
const frame = /^\s*at (?:async )?(?:(\S+) )?\(?(.+?):\d+:\d+\)?\s*$/.exec(line);
|
|
261
|
+
if (frame === null || frame[1] === undefined)
|
|
262
|
+
continue;
|
|
263
|
+
const file = frame[2]
|
|
264
|
+
.replaceAll('\\', '/')
|
|
265
|
+
.replace(/^file:\/\/\/?/, '')
|
|
266
|
+
.toLowerCase();
|
|
267
|
+
if (file.includes(dir))
|
|
268
|
+
names.push(frame[1].split('.').pop());
|
|
269
|
+
}
|
|
270
|
+
return names;
|
|
271
|
+
}
|
|
272
|
+
/** Marks the function an error's stack names: the innermost project frame that is one of the law's functions. */
|
|
273
|
+
export function markFailing(exercised, stack, outDir) {
|
|
274
|
+
if (stack === undefined)
|
|
275
|
+
return exercised;
|
|
276
|
+
const names = frameFunctions(stack, outDir);
|
|
277
|
+
const hit = names.map((n) => exercised.find((e) => e.name === n)).find((e) => e !== undefined);
|
|
278
|
+
return hit === undefined ? exercised : exercised.map((e) => (e === hit ? { ...e, failing: true } : e));
|
|
279
|
+
}
|
|
280
|
+
/** The declaration of `name` in project `file`, for a result that names a function but not where it is. */
|
|
281
|
+
export function declarationOf(sources, file, name) {
|
|
282
|
+
const hit = sources.find(file, name);
|
|
283
|
+
return hit === undefined ? undefined : spanOf(hit.module, hit.node.nameNode);
|
|
284
|
+
}
|
package/dist/property.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface PropertyOutcome {
|
|
|
26
26
|
/** Absent when the law failed because `assume` discarded nearly every input. */
|
|
27
27
|
counterexample?: string;
|
|
28
28
|
message?: string;
|
|
29
|
+
/** The stack of the error the law threw, to say which function threw. */
|
|
30
|
+
stack?: string;
|
|
29
31
|
screenshot?: string;
|
|
30
32
|
/** The counterexample in the regression corpus's encoding; absent when it cannot be stored. */
|
|
31
33
|
encoded?: string;
|
package/dist/property.js
CHANGED
|
@@ -141,6 +141,7 @@ export function summarise(details, options) {
|
|
|
141
141
|
seed: details.seed,
|
|
142
142
|
counterexample: details.counterexample === null ? undefined : fc.stringify(details.counterexample),
|
|
143
143
|
message,
|
|
144
|
+
stack: details.errorInstance instanceof Error ? details.errorInstance.stack : undefined,
|
|
144
145
|
};
|
|
145
146
|
}
|
|
146
147
|
let worker;
|
package/dist/reporters.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CheckReport } from './check.ts';
|
|
2
|
-
/** The values of `tslaws check --reporter`. `text` and `
|
|
3
|
-
export declare const reporterNames: readonly ["text", "json", "junit", "github"];
|
|
2
|
+
/** The values of `tslaws check --reporter`. `text`, `json` and `ndjson` are rendered by the CLI itself. */
|
|
3
|
+
export declare const reporterNames: readonly ["text", "json", "junit", "github", "ndjson"];
|
|
4
4
|
export type ReporterName = (typeof reporterNames)[number];
|
|
5
5
|
/** Escape text for XML, dropping characters XML 1.0 cannot carry at all. */
|
|
6
6
|
export declare function escapeXml(text: string): string;
|
package/dist/reporters.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFileSync } from 'node:fs';
|
|
2
2
|
import { hintFor } from "./hints.js";
|
|
3
|
-
/** The values of `tslaws check --reporter`. `text` and `
|
|
4
|
-
export const reporterNames = ['text', 'json', 'junit', 'github'];
|
|
3
|
+
/** The values of `tslaws check --reporter`. `text`, `json` and `ndjson` are rendered by the CLI itself. */
|
|
4
|
+
export const reporterNames = ['text', 'json', 'junit', 'github', 'ndjson'];
|
|
5
5
|
const passing = new Set(['proved', 'bounded', 'tested']);
|
|
6
6
|
const isPassing = (result) => passing.has(result.status);
|
|
7
7
|
/** Whether XML 1.0 can carry the code point at all. */
|
package/dist/types.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface ManifestLaw {
|
|
|
11
11
|
text: string;
|
|
12
12
|
problems: string[];
|
|
13
13
|
}
|
|
14
|
+
import type { Exercised } from './exercises.ts';
|
|
14
15
|
export interface LawResult {
|
|
15
16
|
file: string;
|
|
16
17
|
name: string;
|
|
@@ -24,6 +25,8 @@ export interface LawResult {
|
|
|
24
25
|
message?: string;
|
|
25
26
|
/** Project-relative path of a screenshot of the page at the counterexample (browser laws). */
|
|
26
27
|
screenshot?: string;
|
|
28
|
+
/** The project functions the law reaches and where they are declared, for an editor to mark on both ends. */
|
|
29
|
+
exercises?: Exercised[];
|
|
27
30
|
}
|
|
28
31
|
export interface CheckOptions {
|
|
29
32
|
files: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tslaws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Runs and checks `law` declarations compiled by tsc-laws: property tests, SMT proofs, tamper lock.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tslaws": "bin/tslaws.js"
|
|
@@ -71,11 +71,11 @@
|
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
73
|
"optionalDependencies": {
|
|
74
|
-
"tslaws-compiler-darwin-arm64": "0.
|
|
75
|
-
"tslaws-compiler-darwin-x64": "0.
|
|
76
|
-
"tslaws-compiler-linux-arm64-gnu": "0.
|
|
77
|
-
"tslaws-compiler-linux-x64-gnu": "0.
|
|
78
|
-
"tslaws-compiler-win32-x64-msvc": "0.
|
|
74
|
+
"tslaws-compiler-darwin-arm64": "0.6.1",
|
|
75
|
+
"tslaws-compiler-darwin-x64": "0.6.1",
|
|
76
|
+
"tslaws-compiler-linux-arm64-gnu": "0.6.1",
|
|
77
|
+
"tslaws-compiler-linux-x64-gnu": "0.6.1",
|
|
78
|
+
"tslaws-compiler-win32-x64-msvc": "0.6.1"
|
|
79
79
|
},
|
|
80
80
|
"publishConfig": {
|
|
81
81
|
"access": "public"
|