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/files.mjs ADDED
@@ -0,0 +1,172 @@
1
+ import {
2
+ readFile,
3
+ mkdir,
4
+ writeFile,
5
+ rename,
6
+ unlink,
7
+ lstat,
8
+ realpath,
9
+ link,
10
+ } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import crypto from "node:crypto";
13
+ export const digest = (text) =>
14
+ crypto.createHash("sha256").update(text).digest("hex");
15
+ export async function readOptional(file) {
16
+ try {
17
+ return await readFile(file, "utf8");
18
+ } catch (e) {
19
+ if (e.code === "ENOENT") return null;
20
+ throw e;
21
+ }
22
+ }
23
+ export async function safePath(root, relative) {
24
+ if (
25
+ typeof relative !== "string" ||
26
+ !relative ||
27
+ path.isAbsolute(relative) ||
28
+ relative.split(/[\\/]/).some((p) => p === ".." || p === ".")
29
+ )
30
+ throw new Error(`Unsafe output path: ${relative}`);
31
+ const resolved = path.resolve(root, relative);
32
+ if (!resolved.startsWith(path.resolve(root) + path.sep))
33
+ throw new Error(`Output escapes project: ${relative}`);
34
+ const realRoot = await realpath(root);
35
+ let cursor = root;
36
+ for (const part of relative.split("/")) {
37
+ cursor = path.join(cursor, part);
38
+ try {
39
+ const stat = await lstat(cursor);
40
+ if (stat.isSymbolicLink())
41
+ throw new Error(`Refusing symbolic-link output: ${relative}`);
42
+ const real = await realpath(cursor);
43
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep))
44
+ throw new Error(`Output escapes project: ${relative}`);
45
+ } catch (e) {
46
+ if (e.code !== "ENOENT") throw e;
47
+ }
48
+ }
49
+ return resolved;
50
+ }
51
+ export async function atomicWrite(file, content, exclusive = false) {
52
+ await mkdir(path.dirname(file), { recursive: true });
53
+ const tmp = `${file}.lawspec-${crypto.randomUUID()}.tmp`;
54
+ try {
55
+ await writeFile(tmp, content, { flag: "wx" });
56
+ if (exclusive) await link(tmp, file);
57
+ else await rename(tmp, file);
58
+ } finally {
59
+ try {
60
+ await unlink(tmp);
61
+ } catch (e) {
62
+ if (e.code !== "ENOENT") throw e;
63
+ }
64
+ }
65
+ }
66
+ export async function planWrites(root, artifacts) {
67
+ const manifestPath = await safePath(root, ".lawspec/generated.json");
68
+ const previousText = await readOptional(manifestPath);
69
+ const previous =
70
+ previousText === null
71
+ ? { version: 1, files: {} }
72
+ : JSON.parse(previousText);
73
+ if (
74
+ previous.version !== 1 ||
75
+ !previous.files ||
76
+ typeof previous.files !== "object"
77
+ )
78
+ throw new Error("Invalid ownership manifest");
79
+ const seen = new Set();
80
+ const changes = [];
81
+ const preserved = [];
82
+ const adapterUpdates = [];
83
+ const adapterHashes = {};
84
+ const next = {};
85
+ for (const artifact of artifacts) {
86
+ if (seen.has(artifact.path))
87
+ throw new Error(`Duplicate output: ${artifact.path}`);
88
+ seen.add(artifact.path);
89
+ const file = await safePath(root, artifact.path);
90
+ const old = await readOptional(file);
91
+ if (artifact.ownership === "user") {
92
+ adapterHashes[artifact.path] = digest(artifact.content);
93
+ if (
94
+ old !== null &&
95
+ previous.adapters?.[artifact.path] !== adapterHashes[artifact.path]
96
+ )
97
+ adapterUpdates.push({
98
+ path: artifact.path,
99
+ requiredAdapter: artifact.content,
100
+ });
101
+ if (old === null)
102
+ changes.push({
103
+ action: "create",
104
+ file,
105
+ relative: artifact.path,
106
+ content: artifact.content,
107
+ expected: null,
108
+ });
109
+ else preserved.push(artifact.path);
110
+ continue;
111
+ }
112
+ if (artifact.ownership !== "generated")
113
+ throw new Error("Unknown artifact ownership");
114
+ const recorded = previous.files[artifact.path];
115
+ if (old !== null && (!recorded || digest(old) !== recorded))
116
+ throw new Error(
117
+ `Refusing to overwrite unowned or edited generated file: ${artifact.path}`,
118
+ );
119
+ next[artifact.path] = digest(artifact.content);
120
+ if (old !== artifact.content)
121
+ changes.push({
122
+ action: old === null ? "create" : "update",
123
+ file,
124
+ relative: artifact.path,
125
+ content: artifact.content,
126
+ expected: old,
127
+ });
128
+ }
129
+ for (const [relative, hash] of Object.entries(previous.files)) {
130
+ if (seen.has(relative)) continue;
131
+ const file = await safePath(root, relative);
132
+ const old = await readOptional(file);
133
+ if (old === null) continue;
134
+ if (digest(old) !== hash)
135
+ throw new Error(`Refusing to remove edited generated file: ${relative}`);
136
+ changes.push({ action: "remove", file, relative, expected: old });
137
+ }
138
+ const manifest =
139
+ JSON.stringify(
140
+ { version: 1, files: next, adapters: adapterHashes },
141
+ null,
142
+ 2,
143
+ ) + "\n";
144
+ if (manifest !== previousText)
145
+ changes.push({
146
+ action: previousText === null ? "create" : "update",
147
+ file: manifestPath,
148
+ relative: ".lawspec/generated.json",
149
+ content: manifest,
150
+ expected: previousText,
151
+ });
152
+ return { root, changes, preserved, adapterUpdates };
153
+ }
154
+ export async function applyWrites(plans) {
155
+ // Recheck every file before the first mutation, including concurrent edits since planning.
156
+ for (const plan of plans)
157
+ for (const change of plan.changes) {
158
+ await safePath(plan.root, change.relative);
159
+ if ((await readOptional(change.file)) !== change.expected)
160
+ throw new Error(`File changed during generation: ${change.relative}`);
161
+ }
162
+ for (const plan of plans)
163
+ for (const change of plan.changes) {
164
+ if (change.action === "remove") await unlink(change.file);
165
+ else
166
+ await atomicWrite(
167
+ change.file,
168
+ change.content,
169
+ change.action === "create",
170
+ );
171
+ }
172
+ }
package/index.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Generated from LawSpec.Gen. Do not edit.
2
+ export type Target = 'java' | 'python' | 'javascript' | 'typescript' | 'go' | 'haskell' | 'kotlin';
3
+ export interface Source { path: string; content: string }
4
+ export interface Location { file: string; line: number; column: number }
5
+ export interface Diagnostic { code: string; message: string; at: Location | null }
6
+ export interface Artifact { path: string; content: string; ownership: 'user' | 'generated' }
7
+ export type Type = {tag: 'Named' | 'Variable'; contents: string} | {tag: 'Arrow'; contents: [Type, Type]};
8
+ export type Expr = {tag: 'Var'; contents: string} | {tag: 'Number'; contents: number} | {tag: 'Apply' | 'Compose'; contents: [Expr, Expr]};
9
+ export type Definition = {tag: 'Forall'; contents: [[string, Type][], Definition]} | {tag: 'Equal'; contents: [Expr, Expr]} | {tag: 'Invoke'; contents: [string, Expr[]]};
10
+ export interface Example { exampleName: string; bindings: [string, number][] }
11
+ export interface Law { lawName: string; parameters: [string, Type][]; requirements: Type[]; definition: Definition; description: string; rationale: string; examples: Example[]; references: string[]; location: Location }
12
+ export interface Expanded { owner: string; name: string; inputs: {inputName: string; inputId: string; inputType: Type}[]; left: Expr; right: Expr; trace: string[]; original: Law }
13
+ export interface CheckRequest { sources: Source[] }
14
+ export interface GenerationRequest extends CheckRequest { target: Target; sourceDir?: string; testDir?: string }
15
+ export interface Result { diagnostics: Diagnostic[]; laws?: Expanded[]; expansions?: string[]; files?: Artifact[] }
16
+ export interface Compiler { check(input: CheckRequest): Promise<Result>; expand(input: CheckRequest): Promise<Result>; planGeneration(input: GenerationRequest): Promise<Result> }
17
+ export function createCompiler(): Promise<Compiler>;
package/launcher.mjs ADDED
@@ -0,0 +1,32 @@
1
+ import { readFile } from "node:fs/promises";
2
+ let compiled;
3
+ export async function loadCore() {
4
+ const { WASI } = await import("node:wasi");
5
+ if (Number(process.versions.node.split(".")[0]) < 22)
6
+ throw new Error("LawSpec requires Node 22+");
7
+ compiled ??= readFile(new URL("./core.wasm", import.meta.url)).then(
8
+ WebAssembly.compile,
9
+ );
10
+ const wasi = new WASI({
11
+ version: "preview1",
12
+ args: ["lawspec"],
13
+ env: { PWD: "/" },
14
+ preopens: { "/": process.cwd() },
15
+ });
16
+ const exports = {};
17
+ const jsffi = (await import("./core_jsffi.js")).default;
18
+ const instance = await WebAssembly.instantiate(await compiled, {
19
+ wasi_snapshot_preview1: wasi.wasiImport,
20
+ ghc_wasm_jsffi: jsffi(exports),
21
+ });
22
+ Object.assign(exports, instance.exports);
23
+ wasi.initialize(instance);
24
+ let pending = Promise.resolve();
25
+ return (input) => {
26
+ const result = pending.then(async () =>
27
+ JSON.parse(await instance.exports.lawspec_call(JSON.stringify(input))),
28
+ );
29
+ pending = result.catch(() => {});
30
+ return result;
31
+ };
32
+ }
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "lawspec",
3
+ "version": "0.1.0",
4
+ "description": "State the law once. Check it everywhere.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": { "node": ">=22" },
8
+ "bin": { "lawspec": "bin/lawspec.mjs" },
9
+ "exports": { ".": { "types": "./index.d.ts", "import": "./api.mjs" } },
10
+ "types": "index.d.ts",
11
+ "files": ["*.mjs", "*.json", "index.d.ts", "bin", "core.wasm", "core_jsffi.js", "README.md", "LICENSE", "starter.lawspec"],
12
+ "scripts": { "test": "node --test test/*.test.mjs" }
13
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { readdirSync } from "node:fs";
3
+ if (process.argv.some((a) => a === "--numeric-version" || a === "--version"))
4
+ console.log("0.38.1");
5
+ else if (!readdirSync(process.cwd()).some((f) => f.endsWith(".cabal"))) {
6
+ console.error(
7
+ "LawSpec doctor requires an existing .cabal file. Run stack build --test --no-run-tests first.",
8
+ );
9
+ process.exitCode = 1;
10
+ }
@@ -0,0 +1,31 @@
1
+ unit example.atoi_codec
2
+
3
+ itoa :: Int32 -> Text
4
+ atoi :: Text -> Int32
5
+
6
+ law `itoa and then atoi yields a` is
7
+ definition is
8
+ `round trip identity is preserved` itoa atoi
9
+ end
10
+
11
+ description is
12
+ "converting an Int32 to Text with {itoa} and then back with {atoi} yields the original Int32"
13
+ end
14
+
15
+ rationale is
16
+ "representing an Int32 as Text must not change its value"
17
+ end
18
+
19
+ example `negative integer` is
20
+ x = -42
21
+ end
22
+
23
+ example `zero` is
24
+ x = 0
25
+ end
26
+
27
+ references are
28
+ "left inverse"
29
+ "round-trip property"
30
+ end
31
+ end
package/templates.mjs ADDED
@@ -0,0 +1,112 @@
1
+ export const targets = [
2
+ "java",
3
+ "python",
4
+ "javascript",
5
+ "typescript",
6
+ "go",
7
+ "haskell",
8
+ "kotlin",
9
+ ];
10
+ export const commands = {
11
+ java: "mvn test",
12
+ python: "python -m pytest",
13
+ javascript: "node --test test/*.test.mjs",
14
+ typescript:
15
+ "npm exec -- tsc -p tsconfig.json && node --test dist/test/*.test.js",
16
+ go: "go test ./...",
17
+ haskell: "stack test",
18
+ kotlin: "gradle test",
19
+ };
20
+ export const setup = {
21
+ java: "Use JDK 25+, Maven, JetCheck 0.3.0, JUnit Jupiter 5.14.x, compiler plugin 3.14.1+, Surefire 3.5.x, and maven.compiler.release=25. Run mvn test-compile.",
22
+ python:
23
+ 'Use Python 3.13+. Install pytest 8.4.x and Hypothesis 6.x into the selected environment: python -m pip install -e ".[test]". Configure pytest pythonpath=["src"] and testpaths=["tests"]. Set the target python field to the interpreter path if needed.',
24
+ javascript:
25
+ "Use Node 22+, package.json type=module, and npm install --save-dev fast-check@4.10.2.",
26
+ typescript:
27
+ 'Use Node 22+, package.json type=module, and npm install --save-dev fast-check@4.10.2 typescript@5.9.3 @types/node@22.20.4. Configure tsconfig.json with module=NodeNext, target=ES2022, rootDir=., outDir=dist, include=["src/**/*.ts","test/**/*.ts"].',
28
+ go: "Use Go 1.22+ and go get pgregory.net/rapid@v1.2.0, then go mod download.",
29
+ haskell:
30
+ "Use Stack with lts-24.58 and test dependencies hspec, hedgehog, hspec-hedgehog, hspec-discover, and a test/Spec.hs using hspec-discover. Run stack build --test --no-run-tests.",
31
+ kotlin:
32
+ "Use JDK 25, Gradle 9.3.0, Kotlin plugin 2.3.21, JVM target 25, Kotest 5.9.1 (runner, assertions, property), and useJUnitPlatform(). Run gradle testClasses.",
33
+ };
34
+ const json = (value) => JSON.stringify(value, null, 2) + "\n";
35
+ export function templates(target) {
36
+ if (!targets.includes(target)) throw new Error(`Unknown target: ${target}`);
37
+ const commonPackage = {
38
+ name: "lawspec-example",
39
+ version: "0.1.0",
40
+ private: true,
41
+ type: "module",
42
+ };
43
+ switch (target) {
44
+ case "javascript":
45
+ return {
46
+ "package.json": json({
47
+ ...commonPackage,
48
+ scripts: { test: commands.javascript },
49
+ devDependencies: { "fast-check": "4.10.2" },
50
+ }),
51
+ };
52
+ case "typescript":
53
+ return {
54
+ "package.json": json({
55
+ ...commonPackage,
56
+ scripts: { test: commands.typescript },
57
+ devDependencies: {
58
+ "fast-check": "4.10.2",
59
+ typescript: "5.9.3",
60
+ "@types/node": "22.20.4",
61
+ },
62
+ }),
63
+ "tsconfig.json": json({
64
+ compilerOptions: {
65
+ target: "ES2022",
66
+ module: "NodeNext",
67
+ rootDir: ".",
68
+ outDir: "dist",
69
+ strict: true,
70
+ esModuleInterop: true,
71
+ skipLibCheck: true,
72
+ },
73
+ include: ["src/**/*.ts", "test/**/*.ts"],
74
+ }),
75
+ };
76
+ case "python":
77
+ return {
78
+ "pyproject.toml": `[build-system]\nrequires = ["setuptools==80.9.0"]\nbuild-backend = "setuptools.build_meta"\n\n[project]\nname = "lawspec-example"\nversion = "0.1.0"\nrequires-python = ">=3.13"\n\n[project.optional-dependencies]\ntest = ["pytest==8.4.2", "hypothesis==6.135.26"]\n\n[tool.setuptools.packages.find]\nwhere = ["src"]\n\n[tool.pytest.ini_options]\npythonpath = ["src"]\ntestpaths = ["tests"]\n`,
79
+ };
80
+ case "java":
81
+ return {
82
+ "pom.xml": `<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
83
+ <modelVersion>4.0.0</modelVersion><groupId>example</groupId><artifactId>lawspec-example</artifactId><version>0.1.0</version>
84
+ <properties><maven.compiler.release>25</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
85
+ <dependencies>
86
+ <dependency><groupId>org.jetbrains</groupId><artifactId>jetCheck</artifactId><version>0.3.0</version><scope>test</scope></dependency>
87
+ <dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.14.0</version><scope>test</scope></dependency>
88
+ </dependencies>
89
+ <build><plugins>
90
+ <plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version></plugin>
91
+ <plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin>
92
+ </plugins></build>
93
+ </project>\n`,
94
+ };
95
+ case "go":
96
+ return {
97
+ "go.mod":
98
+ "module example.com/lawspec-example\n\ngo 1.22\n\nrequire pgregory.net/rapid v1.2.0\n",
99
+ };
100
+ case "haskell":
101
+ return {
102
+ "stack.yaml": "snapshot: lts-24.58\npackages: [.]\n",
103
+ "package.yaml": `name: lawspec-example\nversion: 0.1.0\ndependencies: [base, text]\nlibrary:\n source-dirs: src\ntests:\n laws:\n main: Spec.hs\n source-dirs: test\n dependencies: [lawspec-example, hspec, hedgehog, hspec-hedgehog]\n build-tools: [hspec-discover]\n`,
104
+ "test/Spec.hs": "{-# OPTIONS_GHC -F -pgmF hspec-discover #-}\n",
105
+ };
106
+ case "kotlin":
107
+ return {
108
+ "settings.gradle.kts": 'rootProject.name = "lawspec-example"\n',
109
+ "build.gradle.kts": `plugins { kotlin("jvm") version "2.3.21" }\nrepositories { mavenCentral() }\nkotlin { jvmToolchain(25) }\ndependencies {\n testImplementation("io.kotest:kotest-runner-junit5:5.9.1")\n testImplementation("io.kotest:kotest-assertions-core:5.9.1")\n testImplementation("io.kotest:kotest-property:5.9.1")\n}\ntasks.test { useJUnitPlatform() }\n`,
110
+ };
111
+ }
112
+ }