tslaws 0.6.3 → 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
package/dist/coverage.js
CHANGED
|
@@ -8,8 +8,10 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
8
8
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
9
9
|
import ts from 'typescript';
|
|
10
10
|
import { parseE2eConfig } from "./e2e.js";
|
|
11
|
+
import { globMatcher } from "./globs.js";
|
|
12
|
+
import { customLawFile } from "./lawFiles.js";
|
|
11
13
|
const settingKeys = ['runs', 'propertyTimeoutMs', 'proofTimeoutMs', 'bound'];
|
|
12
|
-
const configKeys = ['cover', 'compilerOptions', 'e2e', 'bundlerPlugins', ...settingKeys];
|
|
14
|
+
const configKeys = ['cover', 'laws', 'levels', 'compilerOptions', 'e2e', 'bundlerPlugins', ...settingKeys];
|
|
13
15
|
/** Validates the top level of tslaws.json: known keys only, and the run settings are integers of at least 1. */
|
|
14
16
|
export function parseConfig(raw) {
|
|
15
17
|
const fail = (message) => {
|
|
@@ -31,6 +33,14 @@ export function parseConfig(raw) {
|
|
|
31
33
|
if (plugins !== undefined &&
|
|
32
34
|
(!Array.isArray(plugins) || plugins.some((entry) => typeof entry !== 'string' || entry === '')))
|
|
33
35
|
fail(`"bundlerPlugins" must be an array of module paths, got ${JSON.stringify(plugins)}`);
|
|
36
|
+
const levels = object.levels;
|
|
37
|
+
if (levels !== undefined && levels !== 'names' && levels !== 'imports')
|
|
38
|
+
fail(`"levels" must be "names" or "imports", got ${JSON.stringify(levels)}`);
|
|
39
|
+
const laws = object.laws;
|
|
40
|
+
if (laws !== undefined &&
|
|
41
|
+
(!Array.isArray(laws) ||
|
|
42
|
+
laws.some((entry) => typeof entry !== 'string' || entry.replace(/^!/, '') === '' || entry.startsWith('/'))))
|
|
43
|
+
fail(`"laws" must be an array of project-relative globs, got ${JSON.stringify(laws)}`);
|
|
34
44
|
return object;
|
|
35
45
|
}
|
|
36
46
|
export function readConfig(cwd) {
|
|
@@ -42,29 +52,12 @@ export function readConfig(cwd) {
|
|
|
42
52
|
config.e2e = parseE2eConfig(config.e2e);
|
|
43
53
|
return config;
|
|
44
54
|
}
|
|
45
|
-
function globToRegExp(glob) {
|
|
46
|
-
let out = '';
|
|
47
|
-
for (let i = 0; i < glob.length; i += 1) {
|
|
48
|
-
const ch = glob[i];
|
|
49
|
-
if (ch === '*' && glob[i + 1] === '*') {
|
|
50
|
-
out += '.*';
|
|
51
|
-
i += glob[i + 2] === '/' ? 2 : 1;
|
|
52
|
-
}
|
|
53
|
-
else if (ch === '*')
|
|
54
|
-
out += '[^/]*';
|
|
55
|
-
else if (ch === '?')
|
|
56
|
-
out += '[^/]';
|
|
57
|
-
else
|
|
58
|
-
out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
59
|
-
}
|
|
60
|
-
return new RegExp(`^${out}$`);
|
|
61
|
-
}
|
|
62
55
|
/** Source files matching the `cover` globs (a leading `!` excludes), minus laws files and declarations. */
|
|
63
56
|
export function coverFiles(cwd, globs) {
|
|
64
57
|
// Plain JavaScript sources are covered only when the project compiles them (`allowJs`).
|
|
65
58
|
const js = readConfig(cwd).compilerOptions?.allowJs === true;
|
|
66
|
-
const
|
|
67
|
-
const
|
|
59
|
+
const covered = globMatcher(globs);
|
|
60
|
+
const isLaws = customLawFile(cwd);
|
|
68
61
|
const found = [];
|
|
69
62
|
const walk = (dir) => {
|
|
70
63
|
for (const entry of readdirSync(dir)) {
|
|
@@ -77,9 +70,9 @@ export function coverFiles(cwd, globs) {
|
|
|
77
70
|
const file = relative(cwd, full).replaceAll('\\', '/');
|
|
78
71
|
if (!(js ? /\.(ts|tsx|mts|cts|js)$/ : /\.(ts|tsx|mts|cts)$/).test(file))
|
|
79
72
|
continue;
|
|
80
|
-
if (/\.d\.(ts|mts|cts)$/.test(file) ||
|
|
73
|
+
if (/\.d\.(ts|mts|cts)$/.test(file) || file.endsWith('.laws.ts'))
|
|
81
74
|
continue;
|
|
82
|
-
if (
|
|
75
|
+
if (covered(file) && !isLaws(file))
|
|
83
76
|
found.push(file);
|
|
84
77
|
}
|
|
85
78
|
}
|
|
@@ -225,7 +218,7 @@ export function analyzeCoverage(input) {
|
|
|
225
218
|
if (module === undefined)
|
|
226
219
|
continue;
|
|
227
220
|
const file = sourceOf(input.outDir, input.cwd, js);
|
|
228
|
-
for (const name of exportedFunctions(input.program, dtsOf(input.outDir, file))) {
|
|
221
|
+
for (const name of exportedFunctions(input.program, input.moduleOf?.(file) ?? dtsOf(input.outDir, file))) {
|
|
229
222
|
const node = module.nodes.get(name);
|
|
230
223
|
if (node?.exempt === true)
|
|
231
224
|
exempt.push(`${file}::${name}`);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CoverageReport } from './coverage.ts';
|
|
2
|
+
import type { LawTypes } from './property.ts';
|
|
3
|
+
import type { ManifestLaw } from './types.ts';
|
|
4
|
+
export interface DerivedStore {
|
|
5
|
+
law(law: ManifestLaw, compute: () => LawTypes): LawTypes;
|
|
6
|
+
coverage(compute: () => CoverageReport): CoverageReport;
|
|
7
|
+
}
|
|
8
|
+
export declare function derivedStore(cwd: string, key: string | undefined): DerivedStore;
|
package/dist/derived.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// What the type checker works out from a build (each law's generator plans and shapes, the coverage report) depends
|
|
2
|
+
// on nothing but that build, so it is kept next to it: an unchanged project never starts the checker.
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { arbitraryOf } from "./plan.js";
|
|
6
|
+
export function derivedStore(cwd, key) {
|
|
7
|
+
if (key === undefined)
|
|
8
|
+
return { law: (_law, compute) => compute(), coverage: (compute) => compute() };
|
|
9
|
+
const path = join(cwd, '.tslaws', 'derived.json');
|
|
10
|
+
let stored = { key, laws: {} };
|
|
11
|
+
try {
|
|
12
|
+
const read = JSON.parse(readFileSync(path, 'utf8'));
|
|
13
|
+
if (read.key === key)
|
|
14
|
+
stored = read;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Nothing kept yet.
|
|
18
|
+
}
|
|
19
|
+
const save = () => {
|
|
20
|
+
try {
|
|
21
|
+
mkdirSync(join(cwd, '.tslaws'), { recursive: true });
|
|
22
|
+
writeFileSync(path, JSON.stringify(stored));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Not keeping it is only slower.
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
law(law, compute) {
|
|
30
|
+
const id = `${law.file}::${law.symbol ?? law.name}`;
|
|
31
|
+
const kept = stored.laws[id];
|
|
32
|
+
if (kept !== undefined)
|
|
33
|
+
return {
|
|
34
|
+
generators: kept.plans.map(arbitraryOf),
|
|
35
|
+
plans: kept.plans,
|
|
36
|
+
shapes: kept.shapes.map((shape) => shape ?? undefined),
|
|
37
|
+
};
|
|
38
|
+
const types = compute();
|
|
39
|
+
stored.laws[id] = { plans: types.plans, shapes: types.shapes.map((shape) => shape ?? null) };
|
|
40
|
+
save();
|
|
41
|
+
return types;
|
|
42
|
+
},
|
|
43
|
+
coverage(compute) {
|
|
44
|
+
if (stored.coverage !== undefined)
|
|
45
|
+
return stored.coverage;
|
|
46
|
+
const report = compute();
|
|
47
|
+
stored.coverage = report;
|
|
48
|
+
save();
|
|
49
|
+
return report;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/e2e.d.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import type * as fc from 'fast-check';
|
|
2
2
|
import { type PropertyOutcome } from './property.ts';
|
|
3
3
|
import type { CheckOptions } from './types.ts';
|
|
4
|
-
/** `a.e2e.laws.ts` is an accepted alias: it always drives a running app. */
|
|
5
|
-
export declare function isE2eLawFile(file: string): boolean;
|
|
6
|
-
/** Whether a law file imports both helper sets, which would put one file at two levels. */
|
|
7
|
-
export declare function importsBoth(text: string): boolean;
|
|
8
|
-
/**
|
|
9
|
-
* The level a law file runs at. `*.browser.laws.ts` is a component law (in a page) unless it imports from
|
|
10
|
-
* `tslaws/e2e`, which makes it an end-to-end law (in Node, driving a running app); `*.e2e.laws.ts` is always e2e.
|
|
11
|
-
*/
|
|
12
|
-
export declare function lawMode(file: string, text: string): 'node' | 'component' | 'e2e';
|
|
13
4
|
export interface E2eServerConfig {
|
|
14
5
|
/** Shell command that starts the app. It runs from your project, like a package script. */
|
|
15
6
|
command: string;
|
package/dist/e2e.js
CHANGED
|
@@ -7,26 +7,6 @@ import { join, resolve } from 'node:path';
|
|
|
7
7
|
import { launch, resolveEngine } from "./browser.js";
|
|
8
8
|
import { withE2e } from "./e2eApi.js";
|
|
9
9
|
import { runProperty, summarise } from "./property.js";
|
|
10
|
-
/** `a.e2e.laws.ts` is an accepted alias: it always drives a running app. */
|
|
11
|
-
export function isE2eLawFile(file) {
|
|
12
|
-
return /\.e2e\.laws\.[cm]?ts$/.test(file);
|
|
13
|
-
}
|
|
14
|
-
const importsOf = (module) => new RegExp(`(?:from|import)\\s*['"]${module}['"]`);
|
|
15
|
-
/** Whether a law file imports both helper sets, which would put one file at two levels. */
|
|
16
|
-
export function importsBoth(text) {
|
|
17
|
-
return importsOf('tslaws/e2e').test(text) && importsOf('tslaws/browser').test(text);
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* The level a law file runs at. `*.browser.laws.ts` is a component law (in a page) unless it imports from
|
|
21
|
-
* `tslaws/e2e`, which makes it an end-to-end law (in Node, driving a running app); `*.e2e.laws.ts` is always e2e.
|
|
22
|
-
*/
|
|
23
|
-
export function lawMode(file, text) {
|
|
24
|
-
if (isE2eLawFile(file))
|
|
25
|
-
return 'e2e';
|
|
26
|
-
if (!/\.browser\.laws\.[cm]?ts$/.test(file))
|
|
27
|
-
return 'node';
|
|
28
|
-
return importsOf('tslaws/e2e').test(text) ? 'e2e' : 'component';
|
|
29
|
-
}
|
|
30
10
|
const topKeys = ['baseURL', 'server', 'runs', 'timeoutMs'];
|
|
31
11
|
const serverKeys = ['command', 'url', 'timeoutMs', 'reuseExisting', 'cwd'];
|
|
32
12
|
/** Checks the `e2e` section of tslaws.json; unknown keys and wrong types are errors that say where. */
|
package/dist/generators.d.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import * as fc from 'fast-check';
|
|
1
|
+
import type * as fc from 'fast-check';
|
|
2
2
|
import ts from 'typescript';
|
|
3
|
-
|
|
4
|
-
}
|
|
5
|
-
/** Marks a generated function on the wire: browser laws receive their arguments as structured data. */
|
|
6
|
-
export declare const wireKey = "$tslawsFn";
|
|
7
|
-
/** Replaces generated functions inside arguments with a structured-clone-safe marker `{ $tslawsFn: samples }`. */
|
|
8
|
-
export declare function toWire(value: unknown): unknown;
|
|
3
|
+
import { type Plan } from './plan.ts';
|
|
4
|
+
export { toWire, UnsupportedType, wireKey } from './plan.ts';
|
|
9
5
|
export declare class GeneratorFactory {
|
|
10
6
|
private readonly checker;
|
|
11
7
|
private readonly active;
|
|
12
8
|
constructor(checker: ts.TypeChecker);
|
|
13
|
-
|
|
9
|
+
/** The arbitrary for `type`, built in this process. */
|
|
10
|
+
of(type: ts.Type): fc.Arbitrary<unknown>;
|
|
11
|
+
plan(type: ts.Type, depth?: number): Plan;
|
|
12
|
+
private structural;
|
|
13
|
+
/** A type with a registered generator keeps its name in the plan, so a registration made later still applies. */
|
|
14
14
|
private registered;
|
|
15
15
|
private union;
|
|
16
16
|
private template;
|
package/dist/generators.js
CHANGED
|
@@ -1,104 +1,59 @@
|
|
|
1
|
-
// Derives fast-check generators from the declared parameter types of laws, read from the declaration
|
|
2
|
-
// files the compiler emitted. Full TypeScript types map: primitives, literals, unions, enums, arrays,
|
|
3
|
-
// tuples, object types, index signatures, type parameters (via their constraint) and registered names.
|
|
4
|
-
import * as fc from 'fast-check';
|
|
5
1
|
import ts from 'typescript';
|
|
6
2
|
import { arbitraryRegistry } from "./api.js";
|
|
7
|
-
import {
|
|
8
|
-
export
|
|
9
|
-
}
|
|
10
|
-
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 }) });
|
|
11
|
-
// `int` from 'tslaws': whole numbers, biased to small ones.
|
|
12
|
-
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 }) }));
|
|
13
|
-
/** Marks a generated function on the wire: browser laws receive their arguments as structured data. */
|
|
14
|
-
export const wireKey = '$tslawsFn';
|
|
15
|
-
function pick(samples, args) {
|
|
16
|
-
let key = '';
|
|
17
|
-
try {
|
|
18
|
-
key = JSON.stringify(args) ?? '';
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
// Arguments that are not JSON (a node, a cycle) all share the first sample.
|
|
22
|
-
}
|
|
23
|
-
let h = 5381;
|
|
24
|
-
for (let i = 0; i < key.length; i++)
|
|
25
|
-
h = (Math.imul(h, 33) + key.charCodeAt(i)) | 0;
|
|
26
|
-
return samples[(h >>> 0) % samples.length];
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* A function of a type with one call signature: it returns one of a few values generated from the return type,
|
|
30
|
-
* chosen by its arguments (like `fc.func`: the same arguments give the same result, `void` gives `undefined`).
|
|
31
|
-
* `samples` rides along so a browser law can rebuild the function in the page.
|
|
32
|
-
*/
|
|
33
|
-
function generatedFunction(samples) {
|
|
34
|
-
const fn = (...args) => pick(samples, args);
|
|
35
|
-
Object.defineProperty(fn, wireKey, { value: samples, enumerable: false });
|
|
36
|
-
Object.defineProperty(fn, 'toString', { value: () => `[generated function]`, enumerable: false });
|
|
37
|
-
return fn;
|
|
38
|
-
}
|
|
39
|
-
/** Replaces generated functions inside arguments with a structured-clone-safe marker `{ $tslawsFn: samples }`. */
|
|
40
|
-
export function toWire(value) {
|
|
41
|
-
if (typeof value === 'function') {
|
|
42
|
-
const samples = value[wireKey];
|
|
43
|
-
return { [wireKey]: Array.isArray(samples) ? samples : [undefined] };
|
|
44
|
-
}
|
|
45
|
-
if (Array.isArray(value))
|
|
46
|
-
return value.map(toWire);
|
|
47
|
-
if (value !== null && typeof value === 'object') {
|
|
48
|
-
const proto = Object.getPrototypeOf(value);
|
|
49
|
-
if (proto === Object.prototype || proto === null)
|
|
50
|
-
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toWire(v)]));
|
|
51
|
-
}
|
|
52
|
-
return value;
|
|
53
|
-
}
|
|
3
|
+
import { arbitraryOf, UnsupportedType } from "./plan.js";
|
|
4
|
+
export { toWire, UnsupportedType, wireKey } from "./plan.js";
|
|
54
5
|
export class GeneratorFactory {
|
|
55
6
|
checker;
|
|
56
7
|
active = new Set();
|
|
57
8
|
constructor(checker) {
|
|
58
9
|
this.checker = checker;
|
|
59
10
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
11
|
+
/** The arbitrary for `type`, built in this process. */
|
|
12
|
+
of(type) {
|
|
13
|
+
return arbitraryOf(this.plan(type));
|
|
14
|
+
}
|
|
15
|
+
plan(type, depth = 0) {
|
|
16
|
+
return this.registered(type, depth) ?? this.structural(type, depth);
|
|
17
|
+
}
|
|
18
|
+
structural(type, depth) {
|
|
64
19
|
const f = type.flags;
|
|
65
20
|
if (f & ts.TypeFlags.Any || f & ts.TypeFlags.Unknown)
|
|
66
|
-
return
|
|
21
|
+
return { k: 'anything' };
|
|
67
22
|
if (f & ts.TypeFlags.Never)
|
|
68
23
|
throw new UnsupportedType('type never has no values');
|
|
69
24
|
if (f & ts.TypeFlags.Void || f & ts.TypeFlags.Undefined)
|
|
70
|
-
return
|
|
25
|
+
return { k: 'const', v: undefined };
|
|
71
26
|
if (f & ts.TypeFlags.Null)
|
|
72
|
-
return
|
|
27
|
+
return { k: 'const', v: null };
|
|
73
28
|
if (f & ts.TypeFlags.BooleanLiteral)
|
|
74
|
-
return
|
|
29
|
+
return { k: 'const', v: this.checker.typeToString(type) === 'true' };
|
|
75
30
|
if (f & ts.TypeFlags.Boolean)
|
|
76
|
-
return
|
|
31
|
+
return { k: 'boolean' };
|
|
77
32
|
if (f & ts.TypeFlags.NumberLiteral)
|
|
78
|
-
return
|
|
33
|
+
return { k: 'const', v: type.value };
|
|
79
34
|
if (f & ts.TypeFlags.StringLiteral)
|
|
80
|
-
return
|
|
35
|
+
return { k: 'const', v: type.value };
|
|
81
36
|
if (f & ts.TypeFlags.BigIntLiteral) {
|
|
82
37
|
const v = type.value;
|
|
83
|
-
return
|
|
38
|
+
return { k: 'bigconst', v: `${v.negative ? '-' : ''}${v.base10Value}` };
|
|
84
39
|
}
|
|
85
40
|
if (f & ts.TypeFlags.EnumLiteral && f & ts.TypeFlags.Union)
|
|
86
41
|
return this.union(type, depth);
|
|
87
42
|
if (f & ts.TypeFlags.Number)
|
|
88
|
-
return
|
|
43
|
+
return { k: 'number' };
|
|
89
44
|
if (f & ts.TypeFlags.String)
|
|
90
|
-
return
|
|
45
|
+
return { k: 'string' };
|
|
91
46
|
if (f & ts.TypeFlags.BigInt)
|
|
92
|
-
return
|
|
47
|
+
return { k: 'bigint' };
|
|
93
48
|
if (f & ts.TypeFlags.ESSymbol)
|
|
94
|
-
return
|
|
49
|
+
return { k: 'symbol' };
|
|
95
50
|
if (f & ts.TypeFlags.TemplateLiteral)
|
|
96
51
|
return this.template(type, depth);
|
|
97
52
|
if (f & ts.TypeFlags.TypeParameter) {
|
|
98
53
|
const constraint = this.checker.getBaseConstraintOfType(type);
|
|
99
54
|
if (constraint === undefined || constraint === type)
|
|
100
|
-
return
|
|
101
|
-
return this.
|
|
55
|
+
return { k: 'anything' };
|
|
56
|
+
return this.plan(constraint, depth);
|
|
102
57
|
}
|
|
103
58
|
if (f & ts.TypeFlags.Union)
|
|
104
59
|
return this.union(type, depth);
|
|
@@ -107,59 +62,66 @@ export class GeneratorFactory {
|
|
|
107
62
|
if (f & ts.TypeFlags.Object)
|
|
108
63
|
return this.object(type, depth);
|
|
109
64
|
if (f & ts.TypeFlags.NonPrimitive)
|
|
110
|
-
return
|
|
65
|
+
return { k: 'object' };
|
|
111
66
|
throw new UnsupportedType(`no generator for type ${this.checker.typeToString(type)}`);
|
|
112
67
|
}
|
|
113
|
-
|
|
68
|
+
/** A type with a registered generator keeps its name in the plan, so a registration made later still applies. */
|
|
69
|
+
registered(type, depth) {
|
|
114
70
|
const symbol = type.aliasSymbol ?? type.getSymbol();
|
|
115
71
|
if (symbol === undefined)
|
|
116
72
|
return undefined;
|
|
117
|
-
|
|
73
|
+
const name = symbol.getName();
|
|
74
|
+
if (!arbitraryRegistry().has(name))
|
|
75
|
+
return undefined;
|
|
76
|
+
if (name === 'int')
|
|
77
|
+
return { k: 'named', name };
|
|
78
|
+
// What to generate if the registration is not there where the plan is used (a worker has only the built-in ones).
|
|
79
|
+
// `int` and the like are intersections that have no useful structure, so a type that cannot be planned has none.
|
|
80
|
+
try {
|
|
81
|
+
return { k: 'named', name, fallback: this.structural(type, depth) };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof UnsupportedType)
|
|
85
|
+
return { k: 'named', name };
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
118
88
|
}
|
|
119
89
|
union(type, depth) {
|
|
120
90
|
const members = type.types.filter((t) => !(t.flags & ts.TypeFlags.Never));
|
|
121
|
-
return
|
|
91
|
+
return { k: 'union', of: members.map((member) => this.plan(member, depth)) };
|
|
122
92
|
}
|
|
123
93
|
template(type, depth) {
|
|
124
|
-
|
|
125
|
-
type.types.forEach((t, i) => {
|
|
126
|
-
parts.push(this.of(t, depth).map(String));
|
|
127
|
-
parts.push(fc.constant(type.texts[i + 1] ?? ''));
|
|
128
|
-
});
|
|
129
|
-
return fc.tuple(...parts).map((p) => p.join(''));
|
|
94
|
+
return { k: 'template', texts: [...type.texts], parts: type.types.map((t) => this.plan(t, depth)) };
|
|
130
95
|
}
|
|
131
96
|
object(type, depth) {
|
|
132
97
|
const checker = this.checker;
|
|
133
98
|
if (this.active.has(type) || depth > 4)
|
|
134
|
-
return
|
|
99
|
+
return { k: 'const', v: undefined };
|
|
135
100
|
const name = type.getSymbol()?.getName();
|
|
136
101
|
if (name === 'Date')
|
|
137
|
-
return
|
|
102
|
+
return { k: 'date' };
|
|
138
103
|
if (name === 'RegExp')
|
|
139
|
-
return
|
|
104
|
+
return { k: 'regexp' };
|
|
140
105
|
if (checker.isArrayType(type)) {
|
|
141
106
|
const element = checker.getTypeArguments(type)[0];
|
|
142
|
-
return
|
|
107
|
+
return { k: 'array', of: this.plan(element, depth + 1) };
|
|
143
108
|
}
|
|
144
109
|
if (checker.isTupleType(type)) {
|
|
145
110
|
const target = type.target;
|
|
146
111
|
const args = checker.getTypeArguments(type);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return fc.tuple(...optional).map((tail) => [...head, ...tail]);
|
|
153
|
-
});
|
|
112
|
+
return {
|
|
113
|
+
k: 'tuple',
|
|
114
|
+
required: args.slice(0, target.minLength).map((t) => this.plan(t, depth + 1)),
|
|
115
|
+
optional: args.slice(target.minLength).map((t) => this.plan(t, depth + 1)),
|
|
116
|
+
};
|
|
154
117
|
}
|
|
155
118
|
if (type.getCallSignatures().length > 0) {
|
|
156
119
|
const signature = type.getCallSignatures()[0];
|
|
157
|
-
|
|
158
|
-
return fc.array(returns, { minLength: 1, maxLength: 4 }).map(generatedFunction);
|
|
120
|
+
return { k: 'fn', returns: this.plan(checker.getReturnTypeOfSignature(signature), depth + 1) };
|
|
159
121
|
}
|
|
160
122
|
this.active.add(type);
|
|
161
123
|
try {
|
|
162
|
-
const
|
|
124
|
+
const fields = {};
|
|
163
125
|
const required = [];
|
|
164
126
|
for (const property of checker.getPropertiesOfType(type)) {
|
|
165
127
|
const declaration = property.valueDeclaration ?? property.declarations?.[0];
|
|
@@ -167,16 +129,14 @@ export class GeneratorFactory {
|
|
|
167
129
|
continue;
|
|
168
130
|
const propertyType = checker.getTypeOfSymbolAtLocation(property, declaration);
|
|
169
131
|
const optional = (property.flags & ts.SymbolFlags.Optional) !== 0;
|
|
170
|
-
|
|
171
|
-
model[property.getName()] = generator;
|
|
132
|
+
fields[property.getName()] = this.plan(optional ? checker.getNonNullableType(propertyType) : propertyType, depth + 1);
|
|
172
133
|
if (!optional)
|
|
173
134
|
required.push(property.getName());
|
|
174
135
|
}
|
|
175
136
|
const index = checker.getIndexInfosOfType(type)[0];
|
|
176
|
-
if (index !== undefined && Object.keys(
|
|
177
|
-
return
|
|
178
|
-
}
|
|
179
|
-
return fc.record(model, { requiredKeys: required });
|
|
137
|
+
if (index !== undefined && Object.keys(fields).length === 0)
|
|
138
|
+
return { k: 'dict', of: this.plan(index.type, depth + 1) };
|
|
139
|
+
return { k: 'record', fields, required };
|
|
180
140
|
}
|
|
181
141
|
finally {
|
|
182
142
|
this.active.delete(type);
|
package/dist/globs.d.ts
ADDED
package/dist/globs.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Glob matching for the path settings of tslaws.json (`cover`, `laws`): `*` stays in one directory, `**` crosses them.
|
|
2
|
+
export function globToRegExp(glob) {
|
|
3
|
+
let out = '';
|
|
4
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
5
|
+
const ch = glob[i];
|
|
6
|
+
if (ch === '*' && glob[i + 1] === '*') {
|
|
7
|
+
out += '.*';
|
|
8
|
+
i += glob[i + 2] === '/' ? 2 : 1;
|
|
9
|
+
}
|
|
10
|
+
else if (ch === '*')
|
|
11
|
+
out += '[^/]*';
|
|
12
|
+
else if (ch === '?')
|
|
13
|
+
out += '[^/]';
|
|
14
|
+
else
|
|
15
|
+
out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
16
|
+
}
|
|
17
|
+
return new RegExp(`^${out}$`);
|
|
18
|
+
}
|
|
19
|
+
/** A predicate for a list of globs: a path matches when an include glob matches and no `!` glob does. */
|
|
20
|
+
export function globMatcher(globs) {
|
|
21
|
+
const include = globs.filter((g) => !g.startsWith('!')).map(globToRegExp);
|
|
22
|
+
const exclude = globs.filter((g) => g.startsWith('!')).map((g) => globToRegExp(g.slice(1)));
|
|
23
|
+
return (file) => include.some((r) => r.test(file)) && !exclude.some((r) => r.test(file));
|
|
24
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -6,13 +6,13 @@ export const agentsBegin = '<!-- tslaws:begin -->';
|
|
|
6
6
|
export const agentsEnd = '<!-- tslaws:end -->';
|
|
7
7
|
export const nextStep = 'Next, a person at a terminal runs `tslaws accept` to record the laws in tsc-laws.lock.';
|
|
8
8
|
const hooksDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'hooks');
|
|
9
|
-
const starterLaws = `// A starter law
|
|
9
|
+
const starterLaws = `// A starter law, written as a call so it needs only stock TypeScript (with the tsc-laws compiler it can also be
|
|
10
|
+
// written as \`export law additionCommutes(a: int, b: int) { ... }\`). A person owns this file: agents propose laws in
|
|
11
|
+
// *.proposed.laws.ts instead.
|
|
10
12
|
// Replace it with laws about your own code, then run \`tslaws accept\`.
|
|
11
|
-
import type
|
|
13
|
+
import { law, type int } from 'tslaws';
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
return a + b === b + a;
|
|
15
|
-
}
|
|
15
|
+
law('addition commutes', (a: int, b: int) => a + b === b + a);
|
|
16
16
|
`;
|
|
17
17
|
function writeFile(file, text) {
|
|
18
18
|
mkdirSync(dirname(file), { recursive: true });
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Directories no walk enters. */
|
|
2
|
+
export declare const ignoredDirs: Set<string>;
|
|
3
|
+
/** The tool-owned directory proposals live in, relative to the project root. */
|
|
4
|
+
export declare const proposalsDir = ".tslaws/proposals";
|
|
5
|
+
/** Whether the text declares a law at the top level (cheap test first, then the real scan). */
|
|
6
|
+
export declare function declaresLaw(text: string): boolean;
|
|
7
|
+
/** A predicate for files the `laws` globs of tslaws.json pick, which still have to declare a law to count. */
|
|
8
|
+
export declare function customLawFile(cwd: string, globs?: string[] | undefined): (file: string) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* The laws files of the tree, project-relative and sorted: `*.laws.ts` plus the files the `laws` globs find. With
|
|
11
|
+
* `proposed`, the in-tree `*.proposed.laws.ts` files instead (the usual proposals; `discoverProposals` finds the optional ones in `.tslaws/proposals`).
|
|
12
|
+
*/
|
|
13
|
+
export declare function discoverLaws(cwd: string, proposed?: boolean): string[];
|
|
14
|
+
/** The files in `.tslaws/proposals/` (any name ending `.ts`), project-relative and sorted. */
|
|
15
|
+
export declare function discoverProposals(cwd: string): string[];
|
|
16
|
+
/** Whether a project-relative path is a laws file of the tree (not a proposal). */
|
|
17
|
+
export declare function isLawsFile(cwd: string, file: string): boolean;
|
|
18
|
+
/** Whether a project-relative path is inside the proposals directory. */
|
|
19
|
+
export declare function inProposals(file: string): boolean;
|
|
20
|
+
/** Whether a (possibly new) path is a name a laws file may have: `*.laws.ts`, or a path a `laws` glob picks. */
|
|
21
|
+
export declare function namesLawsFile(cwd: string, file: string): boolean;
|
package/dist/lawFiles.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Which files are laws files. Every `*.laws.ts` is one; with a `laws` setting in tslaws.json, so is any file the globs
|
|
2
|
+
// match that declares a top-level `export law`. Proposals are separate: they live in `.tslaws/proposals/`, outside the
|
|
3
|
+
// tree the lock covers (in-tree `*.proposed.laws.ts` files are the usual proposals and keep working).
|
|
4
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import { join, relative } from 'node:path';
|
|
6
|
+
import { readConfig } from "./coverage.js";
|
|
7
|
+
import { globMatcher } from "./globs.js";
|
|
8
|
+
import { isProposedLawFile } from "./lawNames.js";
|
|
9
|
+
import { parseLawFile } from "./promote.js";
|
|
10
|
+
/** Directories no walk enters. */
|
|
11
|
+
export const ignoredDirs = new Set(['node_modules', '.tslaws', '.git']);
|
|
12
|
+
/** The tool-owned directory proposals live in, relative to the project root. */
|
|
13
|
+
export const proposalsDir = '.tslaws/proposals';
|
|
14
|
+
const slash = (path) => path.replaceAll('\\', '/');
|
|
15
|
+
/** Whether the text declares a law at the top level (cheap test first, then the real scan). */
|
|
16
|
+
export function declaresLaw(text) {
|
|
17
|
+
return /\bexport\s+(?:async\s+)?law\b/.test(text) && parseLawFile(text).laws.length > 0;
|
|
18
|
+
}
|
|
19
|
+
function walk(cwd, visit) {
|
|
20
|
+
const step = (dir) => {
|
|
21
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
22
|
+
if (ignoredDirs.has(entry))
|
|
23
|
+
continue;
|
|
24
|
+
const full = join(dir, entry);
|
|
25
|
+
if (statSync(full).isDirectory())
|
|
26
|
+
step(full);
|
|
27
|
+
else
|
|
28
|
+
visit(slash(relative(cwd, full)));
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
step(cwd);
|
|
32
|
+
}
|
|
33
|
+
/** A predicate for files the `laws` globs of tslaws.json pick, which still have to declare a law to count. */
|
|
34
|
+
export function customLawFile(cwd, globs = readConfig(cwd).laws) {
|
|
35
|
+
if (globs === undefined || globs.length === 0)
|
|
36
|
+
return () => false;
|
|
37
|
+
const matches = globMatcher(globs);
|
|
38
|
+
return (file) => {
|
|
39
|
+
if (!/\.(ts|tsx|mts|cts)$/.test(file) || /\.d\.(ts|mts|cts)$/.test(file) || !matches(file))
|
|
40
|
+
return false;
|
|
41
|
+
try {
|
|
42
|
+
return declaresLaw(readFileSync(join(cwd, file), 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The laws files of the tree, project-relative and sorted: `*.laws.ts` plus the files the `laws` globs find. With
|
|
51
|
+
* `proposed`, the in-tree `*.proposed.laws.ts` files instead (the usual proposals; `discoverProposals` finds the optional ones in `.tslaws/proposals`).
|
|
52
|
+
*/
|
|
53
|
+
export function discoverLaws(cwd, proposed = false) {
|
|
54
|
+
const custom = customLawFile(cwd);
|
|
55
|
+
const found = [];
|
|
56
|
+
walk(cwd, (file) => {
|
|
57
|
+
const name = file.slice(file.lastIndexOf('/') + 1);
|
|
58
|
+
if (name.endsWith('.laws.ts')) {
|
|
59
|
+
if (isProposedLawFile(name) === proposed)
|
|
60
|
+
found.push(file);
|
|
61
|
+
}
|
|
62
|
+
else if (!proposed && custom(file))
|
|
63
|
+
found.push(file);
|
|
64
|
+
});
|
|
65
|
+
return found.sort();
|
|
66
|
+
}
|
|
67
|
+
/** The files in `.tslaws/proposals/` (any name ending `.ts`), project-relative and sorted. */
|
|
68
|
+
export function discoverProposals(cwd) {
|
|
69
|
+
const dir = join(cwd, proposalsDir);
|
|
70
|
+
if (!existsSync(dir))
|
|
71
|
+
return [];
|
|
72
|
+
return readdirSync(dir)
|
|
73
|
+
.filter((entry) => /\.(ts|mts|cts)$/.test(entry) && !/\.d\.(ts|mts|cts)$/.test(entry) && lstatSync(join(dir, entry)).isFile())
|
|
74
|
+
.map((entry) => `${proposalsDir}/${entry}`)
|
|
75
|
+
.sort();
|
|
76
|
+
}
|
|
77
|
+
/** Whether a project-relative path is a laws file of the tree (not a proposal). */
|
|
78
|
+
export function isLawsFile(cwd, file) {
|
|
79
|
+
const name = slash(file);
|
|
80
|
+
return name.endsWith('.laws.ts') || customLawFile(cwd)(name);
|
|
81
|
+
}
|
|
82
|
+
/** Whether a project-relative path is inside the proposals directory. */
|
|
83
|
+
export function inProposals(file) {
|
|
84
|
+
return slash(file).startsWith(`${proposalsDir}/`);
|
|
85
|
+
}
|
|
86
|
+
/** Whether a (possibly new) path is a name a laws file may have: `*.laws.ts`, or a path a `laws` glob picks. */
|
|
87
|
+
export function namesLawsFile(cwd, file) {
|
|
88
|
+
const name = slash(file);
|
|
89
|
+
if (isProposedLawFile(name))
|
|
90
|
+
return false;
|
|
91
|
+
if (name.endsWith('.laws.ts'))
|
|
92
|
+
return true;
|
|
93
|
+
const globs = readConfig(cwd).laws;
|
|
94
|
+
return globs !== undefined && /\.(ts|mts|cts)$/.test(name) && globMatcher(globs)(name);
|
|
95
|
+
}
|