lawspec 0.1.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/LICENSE +21 -0
- package/README.md +240 -0
- package/api.mjs +6 -0
- package/bin/lawspec.mjs +292 -0
- package/build.json +39 -0
- package/compatibility.json +38 -0
- package/core.wasm +0 -0
- package/core_jsffi.js +108 -0
- package/doctor.mjs +439 -0
- package/files.mjs +172 -0
- package/index.d.ts +17 -0
- package/launcher.mjs +32 -0
- package/package.json +13 -0
- package/read-only-hpack.mjs +10 -0
- package/starter.lawspec +31 -0
- package/templates.mjs +112 -0
package/core_jsffi.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// This file implements the JavaScript runtime logic for Haskell
|
|
2
|
+
// modules that use JSFFI. It is not an ESM module, but the template
|
|
3
|
+
// of one; the post-linker script will copy all contents into a new
|
|
4
|
+
// ESM module.
|
|
5
|
+
|
|
6
|
+
// Manage a mapping from 32-bit ids to actual JavaScript values.
|
|
7
|
+
class JSValManager {
|
|
8
|
+
#lastk = 0;
|
|
9
|
+
#kv = new Map();
|
|
10
|
+
|
|
11
|
+
newJSVal(v) {
|
|
12
|
+
const k = ++this.#lastk;
|
|
13
|
+
this.#kv.set(k, v);
|
|
14
|
+
return k;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A separate has() call to ensure we can store undefined as a value
|
|
18
|
+
// too. Also, unconditionally check this since the check is cheap
|
|
19
|
+
// anyway, if the check fails then there's a use-after-free to be
|
|
20
|
+
// fixed.
|
|
21
|
+
getJSVal(k) {
|
|
22
|
+
if (!this.#kv.has(k)) {
|
|
23
|
+
throw new WebAssembly.RuntimeError(`getJSVal(${k})`);
|
|
24
|
+
}
|
|
25
|
+
return this.#kv.get(k);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check for double free as well.
|
|
29
|
+
freeJSVal(k) {
|
|
30
|
+
if (!this.#kv.delete(k)) {
|
|
31
|
+
throw new WebAssembly.RuntimeError(`freeJSVal(${k})`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The actual setImmediate() to be used. This is a ESM module top
|
|
37
|
+
// level binding and doesn't pollute the globalThis namespace.
|
|
38
|
+
//
|
|
39
|
+
// To benchmark different setImmediate() implementations in the
|
|
40
|
+
// browser, use https://github.com/jphpsf/setImmediate-shim-demo as a
|
|
41
|
+
// starting point.
|
|
42
|
+
const setImmediate = (() => {
|
|
43
|
+
// node, deno, bun, or other scripts might have set this up in the
|
|
44
|
+
// browser
|
|
45
|
+
if (globalThis.setImmediate) {
|
|
46
|
+
return globalThis.setImmediate;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/postTask
|
|
50
|
+
if (globalThis.scheduler) {
|
|
51
|
+
return (cb, ...args) => scheduler.postTask(() => cb(...args));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Cloudflare workers doesn't support MessageChannel
|
|
55
|
+
if (globalThis.MessageChannel) {
|
|
56
|
+
// A simple & fast setImmediate() implementation for browsers. It's
|
|
57
|
+
// not a drop-in replacement for node.js setImmediate() because:
|
|
58
|
+
// 1. There's no clearImmediate(), and setImmediate() doesn't return
|
|
59
|
+
// anything
|
|
60
|
+
// 2. There's no guarantee that callbacks scheduled by setImmediate()
|
|
61
|
+
// are executed in the same order (in fact it's the opposite lol),
|
|
62
|
+
// but you are never supposed to rely on this assumption anyway
|
|
63
|
+
class SetImmediate {
|
|
64
|
+
#fs = [];
|
|
65
|
+
#mc = new MessageChannel();
|
|
66
|
+
|
|
67
|
+
constructor() {
|
|
68
|
+
this.#mc.port1.addEventListener("message", () => {
|
|
69
|
+
this.#fs.pop()();
|
|
70
|
+
});
|
|
71
|
+
this.#mc.port1.start();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setImmediate(cb, ...args) {
|
|
75
|
+
this.#fs.push(() => cb(...args));
|
|
76
|
+
this.#mc.port2.postMessage(undefined);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sm = new SetImmediate();
|
|
81
|
+
return (cb, ...args) => sm.setImmediate(cb, ...args);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (cb, ...args) => setTimeout(cb, 0, ...args);
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
export default (__exports) => {
|
|
88
|
+
const __ghc_wasm_jsffi_jsval_manager = new JSValManager();
|
|
89
|
+
const __ghc_wasm_jsffi_finalization_registry = globalThis.FinalizationRegistry ? new FinalizationRegistry(sp => __exports.rts_freeStablePtr(sp)) : { register: () => {}, unregister: () => true };
|
|
90
|
+
return {
|
|
91
|
+
newJSVal: (v) => __ghc_wasm_jsffi_jsval_manager.newJSVal(v),
|
|
92
|
+
getJSVal: (k) => __ghc_wasm_jsffi_jsval_manager.getJSVal(k),
|
|
93
|
+
freeJSVal: (k) => __ghc_wasm_jsffi_jsval_manager.freeJSVal(k),
|
|
94
|
+
scheduleWork: () => setImmediate(__exports.rts_schedulerLoop),
|
|
95
|
+
ZC0ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: ($1,$2) => ($1.reject(new WebAssembly.RuntimeError($2))),
|
|
96
|
+
ZC18ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: ($1,$2) => ($1.resolve($2)),
|
|
97
|
+
ZC20ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: ($1) => {$1.throwTo = () => {};},
|
|
98
|
+
ZC21ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: ($1,$2) => {$1.throwTo = (err) => __exports.rts_promiseThrowTo($2, err);},
|
|
99
|
+
ZC22ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: () => {let res, rej; const p = new Promise((resolve, reject) => { res = resolve; rej = reject; }); p.resolve = res; p.reject = rej; return p;},
|
|
100
|
+
ZC0ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1) => (`${$1.stack ? $1.stack : $1}`),
|
|
101
|
+
ZC1ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1,$2) => ((new TextDecoder('utf-8', {fatal: true})).decode(new Uint8Array(__exports.memory.buffer, $1, $2))),
|
|
102
|
+
ZC2ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1,$2,$3) => ((new TextEncoder()).encodeInto($1, new Uint8Array(__exports.memory.buffer, $2, $3)).written),
|
|
103
|
+
ZC3ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1) => ($1.length),
|
|
104
|
+
ZC4ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1) => {try { __ghc_wasm_jsffi_finalization_registry.unregister($1); } catch {}},
|
|
105
|
+
ZC18ZCghczminternalZCGHCziInternalziWasmziPrimziImportsZC: ($1,$2) => ($1.then(() => __exports.rts_promiseResolveUnit($2), err => __exports.rts_promiseReject($2, err))),
|
|
106
|
+
ZC0ZCghczminternalZCGHCziInternalziWasmziPrimziConcziInternalZC: async ($1) => (new Promise(res => setTimeout(res, $1 / 1000))),
|
|
107
|
+
};
|
|
108
|
+
};
|
package/doctor.mjs
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import {
|
|
4
|
+
readFile,
|
|
5
|
+
readdir,
|
|
6
|
+
access,
|
|
7
|
+
mkdtemp,
|
|
8
|
+
rm,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { setup } from "./templates.mjs";
|
|
16
|
+
const exec = promisify(execFile);
|
|
17
|
+
const profiles = JSON.parse(
|
|
18
|
+
await readFile(new URL("./compatibility.json", import.meta.url), "utf8"),
|
|
19
|
+
).targets;
|
|
20
|
+
function versionParts(v) {
|
|
21
|
+
return String(v).replace(/^v/, "").split(".").map(Number);
|
|
22
|
+
}
|
|
23
|
+
function compare(a, b) {
|
|
24
|
+
const x = versionParts(a),
|
|
25
|
+
y = versionParts(b);
|
|
26
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
27
|
+
const d = (x[i] || 0) - (y[i] || 0);
|
|
28
|
+
if (d) return Math.sign(d);
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
export function supported(target, dependency, version) {
|
|
33
|
+
const range = profiles[target]?.[dependency];
|
|
34
|
+
return (
|
|
35
|
+
!!range &&
|
|
36
|
+
/^v?\d+(?:\.\d+)*$/.test(String(version)) &&
|
|
37
|
+
compare(version, range[0]) >= 0 &&
|
|
38
|
+
compare(version, range[1]) < 0
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
export async function run(command, args, cwd, extra = {}) {
|
|
42
|
+
const result = await exec(command, args, {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
46
|
+
timeout: 120000,
|
|
47
|
+
...extra,
|
|
48
|
+
});
|
|
49
|
+
return result.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
|
50
|
+
}
|
|
51
|
+
const major = (version) =>
|
|
52
|
+
Number(String(version).replace(/^v/, "").split(".")[0]);
|
|
53
|
+
function requireThat(condition, message) {
|
|
54
|
+
if (!condition) throw new Error(message);
|
|
55
|
+
}
|
|
56
|
+
async function installed(req, name) {
|
|
57
|
+
let dir = path.dirname(req.resolve(name));
|
|
58
|
+
for (;;) {
|
|
59
|
+
try {
|
|
60
|
+
const pkg = JSON.parse(
|
|
61
|
+
await readFile(path.join(dir, "package.json"), "utf8"),
|
|
62
|
+
);
|
|
63
|
+
if (pkg.name === name) return pkg.version;
|
|
64
|
+
} catch {}
|
|
65
|
+
const parent = path.dirname(dir);
|
|
66
|
+
if (parent === dir) throw new Error(`Cannot resolve installed ${name}`);
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function doctor(target, root) {
|
|
71
|
+
const name = target.language;
|
|
72
|
+
const defaults =
|
|
73
|
+
name === "java"
|
|
74
|
+
? ["src/main/java", "src/test/java"]
|
|
75
|
+
: name === "kotlin"
|
|
76
|
+
? ["src/main/kotlin", "src/test/kotlin"]
|
|
77
|
+
: ["src", name === "python" ? "tests" : "test"];
|
|
78
|
+
const sourceDir = target.sourceDir ?? defaults[0];
|
|
79
|
+
const testDir = target.testDir ?? defaults[1];
|
|
80
|
+
try {
|
|
81
|
+
requireThat(major(process.versions.node) >= 22, "Node 22+ is required");
|
|
82
|
+
const versions = {};
|
|
83
|
+
if (["javascript", "typescript"].includes(name)) {
|
|
84
|
+
const pkg = JSON.parse(
|
|
85
|
+
await readFile(path.join(root, "package.json"), "utf8"),
|
|
86
|
+
);
|
|
87
|
+
requireThat(
|
|
88
|
+
pkg.type === "module",
|
|
89
|
+
"package.json must specify type=module",
|
|
90
|
+
);
|
|
91
|
+
const req = createRequire(path.join(root, "package.json"));
|
|
92
|
+
versions["fast-check"] = await installed(req, "fast-check");
|
|
93
|
+
requireThat(
|
|
94
|
+
/^4\./.test(versions["fast-check"]),
|
|
95
|
+
"fast-check 4.x is required",
|
|
96
|
+
);
|
|
97
|
+
if (name === "typescript") {
|
|
98
|
+
versions.typescript = await installed(req, "typescript");
|
|
99
|
+
requireThat(
|
|
100
|
+
/^5\.9\./.test(versions.typescript),
|
|
101
|
+
"TypeScript 5.9.x is required",
|
|
102
|
+
);
|
|
103
|
+
const ts = req("typescript");
|
|
104
|
+
const configPath = path.join(root, "tsconfig.json");
|
|
105
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
106
|
+
requireThat(!read.error, "Cannot read tsconfig.json");
|
|
107
|
+
const config = ts.parseJsonConfigFileContent(read.config, ts.sys, root);
|
|
108
|
+
requireThat(
|
|
109
|
+
config.errors.every((e) => e.code === 18003),
|
|
110
|
+
"Invalid TypeScript configuration",
|
|
111
|
+
);
|
|
112
|
+
requireThat(
|
|
113
|
+
config.options.module === ts.ModuleKind.NodeNext &&
|
|
114
|
+
config.options.outDir === path.join(root, "dist") &&
|
|
115
|
+
config.options.rootDir === root,
|
|
116
|
+
"TypeScript requires module=NodeNext, rootDir=., outDir=dist",
|
|
117
|
+
);
|
|
118
|
+
requireThat(
|
|
119
|
+
!config.options.noEmit &&
|
|
120
|
+
config.options.target >= ts.ScriptTarget.ES2022,
|
|
121
|
+
"TypeScript requires emission and target ES2022+",
|
|
122
|
+
);
|
|
123
|
+
requireThat(
|
|
124
|
+
config.raw.include?.includes(`${sourceDir}/**/*.ts`) &&
|
|
125
|
+
config.raw.include?.includes(`${testDir}/**/*.ts`),
|
|
126
|
+
"TypeScript must include the configured source and test directories",
|
|
127
|
+
);
|
|
128
|
+
requireThat(
|
|
129
|
+
!config.raw.exclude ||
|
|
130
|
+
config.raw.exclude.every((p) =>
|
|
131
|
+
["node_modules", "dist"].includes(p),
|
|
132
|
+
),
|
|
133
|
+
"Custom TypeScript exclusions cannot be verified",
|
|
134
|
+
);
|
|
135
|
+
versions["@types/node"] = JSON.parse(
|
|
136
|
+
await readFile(req.resolve("@types/node/package.json"), "utf8"),
|
|
137
|
+
).version;
|
|
138
|
+
requireThat(
|
|
139
|
+
major(versions["@types/node"]) >= 22,
|
|
140
|
+
"@types/node 22+ is required",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
} else if (name === "python") {
|
|
144
|
+
const script = `import sys,json,importlib.metadata as m,tomllib,pathlib\np=tomllib.loads(pathlib.Path('pyproject.toml').read_text())\nprint(json.dumps({'python':'.'.join(map(str,sys.version_info[:3])),'pytest':m.version('pytest'),'hypothesis':m.version('hypothesis'),'config':p.get('tool',{}).get('pytest',{}).get('ini_options',{})}))`;
|
|
145
|
+
Object.assign(
|
|
146
|
+
versions,
|
|
147
|
+
JSON.parse(await run(target.python || "python3", ["-c", script], root)),
|
|
148
|
+
);
|
|
149
|
+
requireThat(
|
|
150
|
+
major(versions.python) === 3 &&
|
|
151
|
+
Number(versions.python.split(".")[1]) >= 13,
|
|
152
|
+
"Python 3.13+ is required",
|
|
153
|
+
);
|
|
154
|
+
requireThat(
|
|
155
|
+
/^8\.4\./.test(versions.pytest) && /^6\./.test(versions.hypothesis),
|
|
156
|
+
"pytest 8.4.x and Hypothesis 6.x are required",
|
|
157
|
+
);
|
|
158
|
+
requireThat(
|
|
159
|
+
versions.config.pythonpath?.includes(sourceDir) &&
|
|
160
|
+
versions.config.testpaths?.includes(testDir),
|
|
161
|
+
"pytest must include the configured source and test directories",
|
|
162
|
+
);
|
|
163
|
+
requireThat(
|
|
164
|
+
!versions.config.addopts &&
|
|
165
|
+
!versions.config.python_files &&
|
|
166
|
+
!versions.config.python_functions,
|
|
167
|
+
"Custom pytest selection options cannot be verified",
|
|
168
|
+
);
|
|
169
|
+
requireThat(
|
|
170
|
+
!process.env.PYTEST_ADDOPTS,
|
|
171
|
+
"Clear PYTEST_ADDOPTS before compatibility checks",
|
|
172
|
+
);
|
|
173
|
+
delete versions.config;
|
|
174
|
+
} else if (name === "go") {
|
|
175
|
+
versions.go = (await run("go", ["version"], root)).match(
|
|
176
|
+
/go(\d+\.\d+(?:\.\d+)?)/,
|
|
177
|
+
)?.[1];
|
|
178
|
+
requireThat(
|
|
179
|
+
versions.go && Number(versions.go.split(".")[1]) >= 22,
|
|
180
|
+
"Go 1.22+ is required",
|
|
181
|
+
);
|
|
182
|
+
const data = JSON.parse(
|
|
183
|
+
await run(
|
|
184
|
+
"go",
|
|
185
|
+
["list", "-mod=readonly", "-m", "-json", "pgregory.net/rapid"],
|
|
186
|
+
root,
|
|
187
|
+
{ env: { ...process.env, GOTOOLCHAIN: "local", GOFLAGS: "" } },
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
requireThat(
|
|
191
|
+
!data.Replace && data.Version === "v1.2.0",
|
|
192
|
+
"Rapid v1.2.0 without local replacement is required",
|
|
193
|
+
);
|
|
194
|
+
await run("go", ["list", "-mod=readonly", "pgregory.net/rapid"], root, {
|
|
195
|
+
env: { ...process.env, GOTOOLCHAIN: "local", GOFLAGS: "" },
|
|
196
|
+
});
|
|
197
|
+
versions.rapid = data.Version;
|
|
198
|
+
} else if (name === "haskell") {
|
|
199
|
+
const compiler = (
|
|
200
|
+
await run(
|
|
201
|
+
"stack",
|
|
202
|
+
[
|
|
203
|
+
"--no-terminal",
|
|
204
|
+
"--no-install-ghc",
|
|
205
|
+
"--with-hpack",
|
|
206
|
+
fileURLToPath(new URL("./read-only-hpack.mjs", import.meta.url)),
|
|
207
|
+
"--lock-file",
|
|
208
|
+
"read-only",
|
|
209
|
+
"path",
|
|
210
|
+
"--compiler-exe",
|
|
211
|
+
],
|
|
212
|
+
root,
|
|
213
|
+
)
|
|
214
|
+
).trim();
|
|
215
|
+
await access(compiler);
|
|
216
|
+
versions.ghc = (await run(compiler, ["--numeric-version"], root)).trim();
|
|
217
|
+
const deps = JSON.parse(
|
|
218
|
+
await run(
|
|
219
|
+
"stack",
|
|
220
|
+
[
|
|
221
|
+
"--no-terminal",
|
|
222
|
+
"--with-hpack",
|
|
223
|
+
fileURLToPath(new URL("./read-only-hpack.mjs", import.meta.url)),
|
|
224
|
+
"--lock-file",
|
|
225
|
+
"read-only",
|
|
226
|
+
"ls",
|
|
227
|
+
"dependencies",
|
|
228
|
+
"json",
|
|
229
|
+
"--test",
|
|
230
|
+
"--global-hints",
|
|
231
|
+
],
|
|
232
|
+
root,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
for (const dep of deps) versions[dep.name] = dep.version;
|
|
236
|
+
for (const dep of [
|
|
237
|
+
"hspec",
|
|
238
|
+
"hedgehog",
|
|
239
|
+
"hspec-hedgehog",
|
|
240
|
+
"hspec-discover",
|
|
241
|
+
])
|
|
242
|
+
requireThat(versions[dep], `Stack test plan is missing ${dep}`);
|
|
243
|
+
requireThat(
|
|
244
|
+
/^2\.11\./.test(versions.hspec) &&
|
|
245
|
+
/^1\./.test(versions.hedgehog) &&
|
|
246
|
+
/^0\.3\./.test(versions["hspec-hedgehog"]),
|
|
247
|
+
"Unsupported Hspec/Hedgehog combination; use lts-24.58",
|
|
248
|
+
);
|
|
249
|
+
const entry = await readFile(path.join(root, testDir, "Spec.hs"), "utf8");
|
|
250
|
+
requireThat(
|
|
251
|
+
entry.includes("hspec-discover"),
|
|
252
|
+
"test/Spec.hs must use hspec-discover",
|
|
253
|
+
);
|
|
254
|
+
const cabals = (await readdir(root)).filter((f) => f.endsWith(".cabal"));
|
|
255
|
+
requireThat(
|
|
256
|
+
cabals.length === 1,
|
|
257
|
+
"The Stack profile requires one package per target root",
|
|
258
|
+
);
|
|
259
|
+
const cabal = await readFile(path.join(root, cabals[0]), "utf8");
|
|
260
|
+
const suites = cabal
|
|
261
|
+
.split(/^test-suite\s+/m)
|
|
262
|
+
.slice(1)
|
|
263
|
+
.map((s) => s.split(/\n(?=\S)/)[0]);
|
|
264
|
+
requireThat(
|
|
265
|
+
suites.some(
|
|
266
|
+
(s) =>
|
|
267
|
+
["hspec", "hedgehog", "hspec-hedgehog"].every((dep) =>
|
|
268
|
+
new RegExp(`(?:^|[\\s,])${dep}(?=[\\s,<>=]|$)`).test(s),
|
|
269
|
+
) &&
|
|
270
|
+
s.includes(testDir) &&
|
|
271
|
+
s.includes("Spec.hs"),
|
|
272
|
+
),
|
|
273
|
+
"A test suite must directly depend on Hspec/Hedgehog and discover the configured test directory",
|
|
274
|
+
);
|
|
275
|
+
} else if (name === "java") {
|
|
276
|
+
const cmd = target.maven || "mvn";
|
|
277
|
+
const version = await run(cmd, ["-version"], root);
|
|
278
|
+
versions.java = version.match(/Java version:\s*([\d.]+)/)?.[1];
|
|
279
|
+
requireThat(major(versions.java) >= 25, "Maven must run on Java 25+");
|
|
280
|
+
const pom = await run(cmd, ["help:effective-pom"], root);
|
|
281
|
+
const plugins = [...pom.matchAll(/<plugin>([\s\S]*?)<\/plugin>/g)].map(
|
|
282
|
+
(m) => m[1],
|
|
283
|
+
);
|
|
284
|
+
const compiler =
|
|
285
|
+
plugins
|
|
286
|
+
.filter((p) =>
|
|
287
|
+
p.includes("<artifactId>maven-compiler-plugin</artifactId>"),
|
|
288
|
+
)
|
|
289
|
+
.at(-1) || "";
|
|
290
|
+
requireThat(
|
|
291
|
+
/<version>3\.(?:14|15)\./.test(compiler),
|
|
292
|
+
"Maven compiler plugin 3.14.x or 3.15.x is required",
|
|
293
|
+
);
|
|
294
|
+
requireThat(
|
|
295
|
+
!/<(?:jdkToolchain|executable|jvm)>/.test(pom),
|
|
296
|
+
"Custom JVM/toolchain overrides cannot be verified by the v0.1 Maven profile",
|
|
297
|
+
);
|
|
298
|
+
requireThat(
|
|
299
|
+
!/<(?:release|testRelease)>((?!25<)[^<]+)<\//.test(compiler),
|
|
300
|
+
"All effective compiler releases must be 25",
|
|
301
|
+
);
|
|
302
|
+
requireThat(
|
|
303
|
+
/<(?:maven\.compiler\.release|release)>25<\//.test(pom),
|
|
304
|
+
"The effective Maven compiler release must be 25",
|
|
305
|
+
);
|
|
306
|
+
requireThat(
|
|
307
|
+
pom.includes(
|
|
308
|
+
`<sourceDirectory>${path.join(root, sourceDir)}</sourceDirectory>`,
|
|
309
|
+
) &&
|
|
310
|
+
pom.includes(
|
|
311
|
+
`<testSourceDirectory>${path.join(root, testDir)}</testSourceDirectory>`,
|
|
312
|
+
),
|
|
313
|
+
"Maven source directories must match the configured LawSpec layout",
|
|
314
|
+
);
|
|
315
|
+
const tree = await run(
|
|
316
|
+
cmd,
|
|
317
|
+
["dependency:tree", "-Dscope=test", "-DoutputType=text"],
|
|
318
|
+
root,
|
|
319
|
+
);
|
|
320
|
+
for (const [key, pattern] of Object.entries({
|
|
321
|
+
jetCheck: /org\.jetbrains:jetCheck:jar:(0\.3\.0):test/,
|
|
322
|
+
"junit-jupiter":
|
|
323
|
+
/org\.junit\.jupiter:junit-jupiter(?:-api)?:jar:(5\.14\.\d+):test/,
|
|
324
|
+
})) {
|
|
325
|
+
versions[key] = tree.match(pattern)?.[1];
|
|
326
|
+
requireThat(versions[key], `Missing compatible resolved ${key}`);
|
|
327
|
+
}
|
|
328
|
+
requireThat(
|
|
329
|
+
/<artifactId>maven-surefire-plugin<\/artifactId>\s*<version>3\.5\./.test(
|
|
330
|
+
pom,
|
|
331
|
+
),
|
|
332
|
+
"Surefire 3.5.x is required",
|
|
333
|
+
);
|
|
334
|
+
requireThat(
|
|
335
|
+
!/<skipTests>true<\/skipTests>|<maven.test.skip>true<\/maven.test.skip>/.test(
|
|
336
|
+
pom,
|
|
337
|
+
),
|
|
338
|
+
"Maven test execution is disabled",
|
|
339
|
+
);
|
|
340
|
+
const surefire =
|
|
341
|
+
plugins
|
|
342
|
+
.filter((p) =>
|
|
343
|
+
p.includes("<artifactId>maven-surefire-plugin</artifactId>"),
|
|
344
|
+
)
|
|
345
|
+
.at(-1) || "";
|
|
346
|
+
requireThat(
|
|
347
|
+
!/<(?:includes|excludes|groups|excludedGroups)>|<(?:skip|testFailureIgnore)>true</.test(
|
|
348
|
+
surefire,
|
|
349
|
+
),
|
|
350
|
+
"Custom Surefire filtering or ignored failures cannot be verified",
|
|
351
|
+
);
|
|
352
|
+
requireThat(
|
|
353
|
+
/org\.junit\.jupiter:junit-jupiter-engine:jar:5\.14\.\d+:test/.test(
|
|
354
|
+
tree,
|
|
355
|
+
),
|
|
356
|
+
"The JUnit Jupiter test engine must be on the test classpath",
|
|
357
|
+
);
|
|
358
|
+
} else if (name === "kotlin") {
|
|
359
|
+
const command =
|
|
360
|
+
target.gradle ||
|
|
361
|
+
(await access(path.join(root, "gradlew")).then(
|
|
362
|
+
() => path.join(root, "gradlew"),
|
|
363
|
+
() => "gradle",
|
|
364
|
+
));
|
|
365
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), "lawspec-gradle-"));
|
|
366
|
+
try {
|
|
367
|
+
const report = path.join(tmp, "report.json");
|
|
368
|
+
const init = path.join(tmp, "inspect.gradle");
|
|
369
|
+
await writeFile(
|
|
370
|
+
init,
|
|
371
|
+
`import groovy.json.JsonOutput\ngradle.projectsEvaluated {\n def p = gradle.rootProject\n p.tasks.register('lawspecEnvironmentReport') { doLast {\n def k = p.tasks.findByName('compileTestKotlin')\n def j = p.tasks.findByName('test')\n def plugin = p.plugins.findPlugin('org.jetbrains.kotlin.jvm')\n def kv = plugin.class.classLoader.loadClass('org.jetbrains.kotlin.gradle.plugin.KotlinPluginWrapperKt').getMethod('getKotlinPluginVersion', org.gradle.api.Project).invoke(null, p)\n def deps = p.configurations.testRuntimeClasspath.resolvedConfiguration.resolvedArtifacts.collect { [name: it.moduleVersion.id.group + ':' + it.name, version: it.moduleVersion.id.version] }\n new File(System.getenv('LAWSPEC_REPORT')).text = JsonOutput.toJson([kotlin: kv, gradle: gradle.gradleVersion, java: System.getProperty('java.version'), target: k?.compilerOptions?.jvmTarget?.get()?.target, testJava: j?.javaLauncher?.get()?.metadata?.languageVersion?.asInt(), runner: j?.options?.class?.name, enabled: j.enabled, includes: j.filter.includePatterns, excludes: j.filter.excludePatterns, sourceDirs: p.sourceSets.main.allSource.srcDirs.collect { it.canonicalPath }, testDirs: p.sourceSets.test.allSource.srcDirs.collect { it.canonicalPath }, dependencies: deps])\n } }\n}\n`,
|
|
372
|
+
);
|
|
373
|
+
await run(
|
|
374
|
+
command,
|
|
375
|
+
[
|
|
376
|
+
"--no-daemon",
|
|
377
|
+
"--console=plain",
|
|
378
|
+
"-I",
|
|
379
|
+
init,
|
|
380
|
+
"lawspecEnvironmentReport",
|
|
381
|
+
],
|
|
382
|
+
root,
|
|
383
|
+
{ env: { ...process.env, LAWSPEC_REPORT: report } },
|
|
384
|
+
);
|
|
385
|
+
const data = JSON.parse(await readFile(report, "utf8"));
|
|
386
|
+
requireThat(
|
|
387
|
+
/^9\.[1-3]\./.test(data.gradle) &&
|
|
388
|
+
major(data.java) >= 25 &&
|
|
389
|
+
Number(data.target) >= 25 &&
|
|
390
|
+
data.testJava >= 25,
|
|
391
|
+
"Kotlin requires Gradle 9.1–9.3 and Java/JVM target/test runtime 25+",
|
|
392
|
+
);
|
|
393
|
+
requireThat(
|
|
394
|
+
data.runner?.includes("JUnitPlatform") &&
|
|
395
|
+
data.enabled &&
|
|
396
|
+
!data.includes.length &&
|
|
397
|
+
!data.excludes.length,
|
|
398
|
+
"Gradle test must enable unfiltered JUnit Platform execution",
|
|
399
|
+
);
|
|
400
|
+
requireThat(
|
|
401
|
+
data.sourceDirs.includes(path.join(root, sourceDir)) &&
|
|
402
|
+
data.testDirs.includes(path.join(root, testDir)),
|
|
403
|
+
"Gradle source sets must include the configured LawSpec directories",
|
|
404
|
+
);
|
|
405
|
+
for (const dep of [
|
|
406
|
+
"io.kotest:kotest-property-jvm",
|
|
407
|
+
"io.kotest:kotest-runner-junit5-jvm",
|
|
408
|
+
"io.kotest:kotest-assertions-core-jvm",
|
|
409
|
+
])
|
|
410
|
+
requireThat(
|
|
411
|
+
data.dependencies.some(
|
|
412
|
+
(d) => d.name === dep && d.version === "5.9.1",
|
|
413
|
+
),
|
|
414
|
+
`Missing ${dep}:5.9.1`,
|
|
415
|
+
);
|
|
416
|
+
Object.assign(versions, data);
|
|
417
|
+
requireThat(
|
|
418
|
+
data.kotlin,
|
|
419
|
+
"Cannot determine the Kotlin Gradle plugin version",
|
|
420
|
+
);
|
|
421
|
+
} finally {
|
|
422
|
+
await rm(tmp, { recursive: true, force: true });
|
|
423
|
+
}
|
|
424
|
+
} else throw new Error(`Unsupported target ${name}`);
|
|
425
|
+
for (const dependency of Object.keys(profiles[name] || {}))
|
|
426
|
+
requireThat(
|
|
427
|
+
supported(name, dependency, versions[dependency]),
|
|
428
|
+
`${dependency} ${versions[dependency] || "(unknown)"} is outside the verified ${name} profile [${profiles[name][dependency].join(", ")})`,
|
|
429
|
+
);
|
|
430
|
+
return { target: name, ok: true, versions };
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return {
|
|
433
|
+
target: name,
|
|
434
|
+
ok: false,
|
|
435
|
+
message: String(error.message).slice(0, 4000),
|
|
436
|
+
instructions: setup[name],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|