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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 brain-fuel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# LawSpec
|
|
2
|
+
|
|
3
|
+
**State the law once. Check it everywhere.**
|
|
4
|
+
|
|
5
|
+
LawSpec 0.1 compiles reusable laws into native property tests, executable examples,
|
|
6
|
+
and implementation adapters. The compiler is Haskell, distributed as prebuilt
|
|
7
|
+
WebAssembly with a Node CLI and an asynchronous, typed JavaScript API.
|
|
8
|
+
|
|
9
|
+
## Install and try it
|
|
10
|
+
|
|
11
|
+
The checkout includes the npm package in `npm/`. Build an installable archive:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm pack ./npm
|
|
15
|
+
npm install --save-dev ./lawspec-0.1.0.tgz
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
No Haskell toolchain is needed to install or run the npm package. Node 22+ and the
|
|
19
|
+
selected target's build tools are required. The reference platforms are macOS
|
|
20
|
+
and Linux. This checkout has not been published to the npm registry.
|
|
21
|
+
|
|
22
|
+
From an empty application directory, use the installed `lawspec` command:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
lawspec init --target javascript
|
|
26
|
+
npm install
|
|
27
|
+
lawspec check
|
|
28
|
+
lawspec explain 'example.atoi_codec::itoa and then atoi yields a'
|
|
29
|
+
lawspec doctor
|
|
30
|
+
lawspec generate
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Implement `src/example/atoi_codec.mjs`, then run `npm test`:
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
export function itoa(value) { return String(value); }
|
|
37
|
+
export function atoi(value) { return Number(value); }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The starter adapters intentionally throw until implemented. Generated tests
|
|
41
|
+
include the supplied examples, signed 32-bit boundary cases, and randomized
|
|
42
|
+
properties with the selected framework's shrinking and failure reporting.
|
|
43
|
+
|
|
44
|
+
## Targets
|
|
45
|
+
|
|
46
|
+
| Target | Build setup | Test libraries | Test command |
|
|
47
|
+
| --- | --- | --- | --- |
|
|
48
|
+
| `java` | Maven, JDK 25, release 25 | JetCheck 0.3.0, JUnit Jupiter 5.14.x | `mvn test` |
|
|
49
|
+
| `python` | Python 3.13 or 3.14, pyproject | pytest 8.4.x, Hypothesis 6.135.26+ (6.x) | `python -m pytest` |
|
|
50
|
+
| `javascript` | Node 22+, npm, ESM | fast-check 4.x, node:test | `npm test` |
|
|
51
|
+
| `typescript` | Node 22+, npm, TypeScript 5.9.x, ESM | fast-check 4.x, node:test | `npm test` |
|
|
52
|
+
| `go` | Go modules, Go 1.22–1.26 | Rapid 1.2.0, testing | `go test ./...` |
|
|
53
|
+
| `haskell` | Stack, GHC 9.10, LTS 24.58 | Hspec 2.11, Hedgehog 1.5, hspec-hedgehog 0.3 | `stack test` |
|
|
54
|
+
| `kotlin` | JDK/JVM 25, Gradle 9.1–9.3, Kotlin 2.3.21 | Kotest 5.9.1 | `gradle test` |
|
|
55
|
+
|
|
56
|
+
Java 25 and Python 3.13 are the minimum baselines. New JVM releases are admitted
|
|
57
|
+
through compatibility profiles after testing; v0.1's current JVM profile certifies
|
|
58
|
+
25. Python templates declare `requires-python = ">=3.13"` and runtime checks
|
|
59
|
+
currently recognize 3.13 and 3.14. Kotlin templates pin Gradle's supported build
|
|
60
|
+
configuration to Kotlin 2.3.21 and target JVM 25.
|
|
61
|
+
|
|
62
|
+
`npm/compatibility.json` records inclusive minimum/exclusive maximum dependency
|
|
63
|
+
bounds. Unknown, prerelease, missing, and incompatible versions fail preflight.
|
|
64
|
+
Build-tool probes inspect resolved dependencies, compiler settings, source roots,
|
|
65
|
+
and runner configuration. Unverifiable custom filtering/configuration is rejected
|
|
66
|
+
with setup instructions. Build tools may populate their normal caches while
|
|
67
|
+
resolving dependencies; LawSpec does not run dependency installers during generation.
|
|
68
|
+
|
|
69
|
+
## Existing projects and configuration
|
|
70
|
+
|
|
71
|
+
`init` creates `lawspec.json` and a starter specification. It creates build
|
|
72
|
+
files only when no existing build setup is detected. Existing build files are
|
|
73
|
+
preserved, and setup instructions describe the changes you need to make yourself.
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"version": 1,
|
|
78
|
+
"sources": ["laws"],
|
|
79
|
+
"targets": [
|
|
80
|
+
{"language": "java", "root": "java"},
|
|
81
|
+
{"language": "python", "root": "python", "python": ".venv/bin/python"},
|
|
82
|
+
{"language": "haskell", "root": "haskell"}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Source entries are files or directories, relative to the configuration file.
|
|
88
|
+
Directories are scanned for `.lawspec` files. Every target has its own project
|
|
89
|
+
root. Use `init --target python --project python` to add a target; `--config`
|
|
90
|
+
selects a different configuration file. Targets can override `sourceDir` and
|
|
91
|
+
`testDir` with relative paths. Configure the native build to include those paths
|
|
92
|
+
before generation. Go's source and test directories must be the same.
|
|
93
|
+
|
|
94
|
+
Defaults are `src`/`test` for JS, TS and Haskell; `src`/`tests` for Python;
|
|
95
|
+
`src/main/java`/`src/test/java` for Java; the corresponding Kotlin directories;
|
|
96
|
+
and unit-based package directories at the Go project root. Python accepts a
|
|
97
|
+
`python` interpreter override; Java accepts `maven`; Kotlin accepts `gradle` and
|
|
98
|
+
otherwise uses a local `gradlew` or `gradle` on PATH.
|
|
99
|
+
|
|
100
|
+
For Python, create and select a 3.13+ virtual environment and install the printed
|
|
101
|
+
test dependencies. For Stack, run `stack build --test --no-run-tests` once before
|
|
102
|
+
`doctor`; this resolves the snapshot and generates the Cabal description through
|
|
103
|
+
Hpack. Doctor uses that existing description without rewriting it. Test discovery
|
|
104
|
+
uses `test/Spec.hs` with `hspec-discover`.
|
|
105
|
+
|
|
106
|
+
Commands:
|
|
107
|
+
|
|
108
|
+
- `check`: parse, resolve, type-check and expand laws without target dependencies.
|
|
109
|
+
- `explain [unit::law]`: display expansion steps and inherited example inputs.
|
|
110
|
+
- `doctor`: inspect selected native environments and print corrective instructions.
|
|
111
|
+
- `generate`: check environments, validate every output, then write artifacts.
|
|
112
|
+
- `generate --dry-run`: show proposed file operations without applying them.
|
|
113
|
+
- `generate --check`: fail when generated files need updating, without writing.
|
|
114
|
+
|
|
115
|
+
Use `--target <language>` to select a configured language, and `--json` for
|
|
116
|
+
machine-readable check, explanation, doctor and generation output. Generation
|
|
117
|
+
fails before writing if any selected target is incompatible or any output
|
|
118
|
+
conflicts with file ownership.
|
|
119
|
+
|
|
120
|
+
## Language slice
|
|
121
|
+
|
|
122
|
+
```lawspec
|
|
123
|
+
unit example.atoi_codec
|
|
124
|
+
|
|
125
|
+
itoa :: Int32 -> Text
|
|
126
|
+
atoi :: Text -> Int32
|
|
127
|
+
|
|
128
|
+
law `round trip` is
|
|
129
|
+
definition is
|
|
130
|
+
`left inverse` atoi itoa
|
|
131
|
+
end
|
|
132
|
+
description is
|
|
133
|
+
"applying {itoa} and then {atoi} recovers the original integer"
|
|
134
|
+
end
|
|
135
|
+
example `negative` is
|
|
136
|
+
x = -42
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The implicit prelude defines `left inverse` and `round trip identity is preserved`.
|
|
142
|
+
A law may reference a local reusable law or a prelude law. The compiler performs
|
|
143
|
+
capture-avoiding expansion and specializes types; it does not recognize codec
|
|
144
|
+
function names specially. `explain` shows the final property:
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
for all (x :: Int32) . atoi (itoa (x)) = x
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Reusable laws can declare typed unary function parameters and `requires Eq a`.
|
|
151
|
+
Definitions support law application, function application/composition, universal
|
|
152
|
+
quantification, integer literals and equality. Function signatures use `Int32`
|
|
153
|
+
and `Text`; generic variables are supported in reusable laws. v0.1 generates
|
|
154
|
+
quantified `Int32` inputs, including multiple inputs. `Text` can be an intermediate
|
|
155
|
+
or compared result. Functions are synchronous and unary.
|
|
156
|
+
|
|
157
|
+
Examples refer to the expanded input names, including names inherited from the
|
|
158
|
+
prelude. Bind every input exactly once. Ambiguous names and out-of-range values
|
|
159
|
+
are errors. Descriptions and rationales use `{function}` references; `{{` and `}}`
|
|
160
|
+
produce literal braces. Metadata blocks follow the order shown in `scratch.md`:
|
|
161
|
+
definition, optional description, optional rationale, examples, optional references.
|
|
162
|
+
`--` starts a line comment. Names that cannot be emitted portably are diagnosed.
|
|
163
|
+
|
|
164
|
+
Additional primitives, external law packages, cross-unit imports beyond the
|
|
165
|
+
prelude, async functions, direct existing-symbol binding and browser hosting are
|
|
166
|
+
outside this release.
|
|
167
|
+
|
|
168
|
+
## Ownership
|
|
169
|
+
|
|
170
|
+
Implementation adapters are created once and belong to you. Implement them or
|
|
171
|
+
have them delegate to existing application functions. Regeneration never rewrites
|
|
172
|
+
them. If the required adapter contract changes, generation prints the new stub
|
|
173
|
+
shape for you to apply manually.
|
|
174
|
+
|
|
175
|
+
Generated tests are tracked in `.lawspec/generated.json` with content hashes.
|
|
176
|
+
Commit that manifest alongside generated tests. LawSpec refuses to overwrite
|
|
177
|
+
unowned files or edited generated files, even if an unowned file has matching
|
|
178
|
+
contents. Obsolete tests are deleted only when they still match their recorded
|
|
179
|
+
hash. User adapters remain. Output paths cannot traverse outside a target root
|
|
180
|
+
or pass through symlinks. Writes use temporary files and atomic replacement;
|
|
181
|
+
concurrent edits detected during preflight abort generation.
|
|
182
|
+
|
|
183
|
+
## JavaScript API
|
|
184
|
+
|
|
185
|
+
```javascript
|
|
186
|
+
import { createCompiler } from 'lawspec';
|
|
187
|
+
|
|
188
|
+
const compiler = await createCompiler();
|
|
189
|
+
const result = await compiler.planGeneration({
|
|
190
|
+
sources: [{ path: 'codec.lawspec', content: sourceText }],
|
|
191
|
+
target: 'python'
|
|
192
|
+
});
|
|
193
|
+
if (result.diagnostics.length) console.error(result.diagnostics);
|
|
194
|
+
else console.log(result.files);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`check`, `expand`, and `planGeneration` are asynchronous and share structured
|
|
198
|
+
source/diagnostic types. `planGeneration` returns proposed paths, contents and
|
|
199
|
+
ownership; it does not inspect a host environment or write files. The CLI applies
|
|
200
|
+
compatibility and ownership checks. TypeScript declarations ship in the package.
|
|
201
|
+
The same compiler instance supports repeated and concurrent requests, serialized
|
|
202
|
+
by the JS shim.
|
|
203
|
+
|
|
204
|
+
## Build and verify
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
stack test
|
|
208
|
+
# With wasm32-wasi-cabal and wasm32-wasi-ghc installed:
|
|
209
|
+
tools/wasm.sh
|
|
210
|
+
node --test npm/test/*.test.mjs
|
|
211
|
+
node tools/parity.mjs
|
|
212
|
+
node tools/build-integrity.mjs
|
|
213
|
+
node tools/package-smoke.mjs
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Stack is the native development tool. The WASM build uses GHC's wasm32-wasi backend
|
|
217
|
+
and Cabal, following Rice's Tax. `tools/wasm.sh` reads `~/.ghc-wasm/env` when present.
|
|
218
|
+
The current artifact was built using GHC WASM 9.14.1.20260731; WASM dependencies
|
|
219
|
+
are frozen in `wasm/cabal.project.freeze`. Install the cross compiler through
|
|
220
|
+
[ghc-wasm-meta](https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta).
|
|
221
|
+
|
|
222
|
+
Haskell's export table generates the JS API and `.d.ts` files. The build records
|
|
223
|
+
compiler-source and artifact hashes in `npm/build.json`; CI rejects stale WASM
|
|
224
|
+
or hand-edited generated wrappers. The npm archive is a self-contained consumer
|
|
225
|
+
artifact, with no install-time compilation or download hook.
|
|
226
|
+
|
|
227
|
+
For all seven native integrations, install their build tools, then:
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
# Set LAWSPEC_GRADLE to a Gradle 9.3.0 executable if it is not on PATH.
|
|
231
|
+
node tools/bootstrap-integration.mjs
|
|
232
|
+
node tools/integration.mjs
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
These scripts use isolated `.integration/` projects. Bootstrap installs the pinned
|
|
236
|
+
test dependencies; integration verifies that correct adapters pass, broken adapters
|
|
237
|
+
fail, regeneration preserves implementations, and generation leaves build files
|
|
238
|
+
unchanged. Arguments select individual targets. `LAWSPEC_PYTHON=3.14` selects the
|
|
239
|
+
additional Python reference environment. CI also exercises Node 22/24/26 and packs
|
|
240
|
+
and installs the npm archive. Registry publication is a separate release action.
|
package/api.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Generated from LawSpec.Gen. Do not edit.
|
|
2
|
+
import { loadCore } from './launcher.mjs';
|
|
3
|
+
export async function createCompiler() {
|
|
4
|
+
const call = await loadCore();
|
|
5
|
+
return { check: (input) => call({ ...input, method: 'check' }), expand: (input) => call({ ...input, method: 'expand' }), planGeneration: (input) => call({ ...input, method: 'planGeneration' }) };
|
|
6
|
+
}
|
package/bin/lawspec.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createCompiler } from "../api.mjs";
|
|
5
|
+
import { targets, templates, commands, setup } from "../templates.mjs";
|
|
6
|
+
import { doctor } from "../doctor.mjs";
|
|
7
|
+
import {
|
|
8
|
+
readOptional,
|
|
9
|
+
planWrites,
|
|
10
|
+
applyWrites,
|
|
11
|
+
atomicWrite,
|
|
12
|
+
safePath,
|
|
13
|
+
} from "../files.mjs";
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const verb = args.shift();
|
|
16
|
+
const options = {};
|
|
17
|
+
const positional = [];
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
const arg = args[i];
|
|
20
|
+
if (["--target", "--project", "--config"].includes(arg)) {
|
|
21
|
+
if (!args[i + 1] || args[i + 1].startsWith("--"))
|
|
22
|
+
throw new Error(`Missing value for ${arg}`);
|
|
23
|
+
options[arg.slice(2)] = args[++i];
|
|
24
|
+
} else if (["--dry-run", "--check", "--json"].includes(arg))
|
|
25
|
+
options[arg.slice(2)] = true;
|
|
26
|
+
else if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`);
|
|
27
|
+
else positional.push(arg);
|
|
28
|
+
}
|
|
29
|
+
const configFile = path.resolve(options.config || "lawspec.json");
|
|
30
|
+
const configRoot = path.dirname(configFile);
|
|
31
|
+
const output = (value) =>
|
|
32
|
+
console.log(
|
|
33
|
+
typeof value === "string" ? value : JSON.stringify(value, null, 2),
|
|
34
|
+
);
|
|
35
|
+
const shellQuote = (value) =>
|
|
36
|
+
"'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
37
|
+
function testCommand(target, root) {
|
|
38
|
+
const testDir = target.testDir || "test";
|
|
39
|
+
let command = commands[target.language];
|
|
40
|
+
if (target.language === "python")
|
|
41
|
+
command = `${shellQuote(target.python || "python3")} -m pytest`;
|
|
42
|
+
if (target.language === "java")
|
|
43
|
+
command = `${shellQuote(target.maven || "mvn")} test`;
|
|
44
|
+
if (target.language === "kotlin")
|
|
45
|
+
command = `${shellQuote(target.gradle || "gradle")} test`;
|
|
46
|
+
if (target.language === "javascript")
|
|
47
|
+
command = `node --test ${shellQuote(testDir)}/*.test.mjs`;
|
|
48
|
+
if (target.language === "typescript")
|
|
49
|
+
command = `npm exec -- tsc -p tsconfig.json && node --test ${shellQuote("dist/" + testDir)}/*.test.js`;
|
|
50
|
+
return `(cd ${shellQuote(root)} && ${command})`;
|
|
51
|
+
}
|
|
52
|
+
function diagnostics(result) {
|
|
53
|
+
if (result.diagnostics?.length)
|
|
54
|
+
throw Object.assign(
|
|
55
|
+
new Error(
|
|
56
|
+
result.diagnostics
|
|
57
|
+
.map(
|
|
58
|
+
(d) =>
|
|
59
|
+
`${d.at ? `${d.at.file}:${d.at.line}:${d.at.column}: ` : ""}${d.code}: ${d.message}`,
|
|
60
|
+
)
|
|
61
|
+
.join("\n"),
|
|
62
|
+
),
|
|
63
|
+
{ diagnostics: result.diagnostics },
|
|
64
|
+
);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
async function sources(config) {
|
|
68
|
+
const files = [];
|
|
69
|
+
async function visit(file) {
|
|
70
|
+
const info = await stat(file);
|
|
71
|
+
if (info.isDirectory())
|
|
72
|
+
for (const entry of (await readdir(file)).sort())
|
|
73
|
+
await visit(path.join(file, entry));
|
|
74
|
+
else if (file.endsWith(".lawspec")) files.push(file);
|
|
75
|
+
}
|
|
76
|
+
for (const source of config.sources)
|
|
77
|
+
await visit(path.resolve(configRoot, source));
|
|
78
|
+
if (!files.length) throw new Error("No .lawspec source files found");
|
|
79
|
+
return Promise.all(
|
|
80
|
+
[...new Set(files)].sort().map(async (file) => ({
|
|
81
|
+
path: path.relative(configRoot, file).split(path.sep).join("/"),
|
|
82
|
+
content: await readFile(file, "utf8"),
|
|
83
|
+
})),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
async function init() {
|
|
87
|
+
const language = options.target;
|
|
88
|
+
if (!targets.includes(language))
|
|
89
|
+
throw new Error(`Choose --target ${targets.join("|")}`);
|
|
90
|
+
const root = path.resolve(configRoot, options.project || ".");
|
|
91
|
+
const current = await readOptional(configFile);
|
|
92
|
+
const config =
|
|
93
|
+
current === null
|
|
94
|
+
? { version: 1, sources: ["laws"], targets: [] }
|
|
95
|
+
: JSON.parse(current);
|
|
96
|
+
if (
|
|
97
|
+
config.targets.some(
|
|
98
|
+
(t) =>
|
|
99
|
+
t.language === language && path.resolve(configRoot, t.root) === root,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
throw new Error("Target is already configured");
|
|
103
|
+
await mkdir(root, { recursive: true });
|
|
104
|
+
const buildFiles = [
|
|
105
|
+
"pom.xml",
|
|
106
|
+
"pyproject.toml",
|
|
107
|
+
"package.json",
|
|
108
|
+
"go.mod",
|
|
109
|
+
"stack.yaml",
|
|
110
|
+
"package.yaml",
|
|
111
|
+
"build.gradle",
|
|
112
|
+
"build.gradle.kts",
|
|
113
|
+
"settings.gradle.kts",
|
|
114
|
+
];
|
|
115
|
+
const hasBuild = (await readdir(root)).some(
|
|
116
|
+
(f) => buildFiles.includes(f) || f.endsWith(".cabal"),
|
|
117
|
+
);
|
|
118
|
+
const additions = hasBuild ? {} : templates(language);
|
|
119
|
+
for (const name of Object.keys(additions)) {
|
|
120
|
+
const file = await safePath(root, name);
|
|
121
|
+
if ((await readOptional(file)) !== null)
|
|
122
|
+
throw new Error(`Will not overwrite ${file}`);
|
|
123
|
+
}
|
|
124
|
+
const starter = await safePath(configRoot, "laws/atoi_codec.lawspec");
|
|
125
|
+
await safePath(configRoot, path.basename(configFile));
|
|
126
|
+
if (current === null && (await readOptional(starter)) !== null)
|
|
127
|
+
throw new Error(
|
|
128
|
+
"Starter specification already exists; create lawspec.json manually to use it",
|
|
129
|
+
);
|
|
130
|
+
if (!hasBuild && language !== "go")
|
|
131
|
+
await mkdir(path.join(root, "src"), { recursive: true });
|
|
132
|
+
for (const [name, content] of Object.entries(additions))
|
|
133
|
+
await atomicWrite(path.join(root, name), content, true);
|
|
134
|
+
if (current === null)
|
|
135
|
+
await atomicWrite(
|
|
136
|
+
starter,
|
|
137
|
+
await readFile(new URL("../starter.lawspec", import.meta.url), "utf8"),
|
|
138
|
+
true,
|
|
139
|
+
);
|
|
140
|
+
config.targets.push({
|
|
141
|
+
language,
|
|
142
|
+
root: path.relative(configRoot, root) || ".",
|
|
143
|
+
});
|
|
144
|
+
await atomicWrite(
|
|
145
|
+
configFile,
|
|
146
|
+
JSON.stringify(config, null, 2) + "\n",
|
|
147
|
+
current === null,
|
|
148
|
+
);
|
|
149
|
+
output(
|
|
150
|
+
`Configured ${language}. ${hasBuild ? "Existing build files preserved." : "Created missing project build files."}\n${setup[language]}\nNext: lawspec doctor, then lawspec generate.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
async function main() {
|
|
154
|
+
if (!verb || ["help", "--help", "-h"].includes(verb)) {
|
|
155
|
+
output(
|
|
156
|
+
"LawSpec 0.1.0\nUsage: lawspec init --target <language> [--project <directory>]\n lawspec check | doctor | explain <unit>::<law> | generate\nOptions: --config <path>, --target <language>, --json\nGeneration: --dry-run, --check\nTargets: " +
|
|
157
|
+
targets.join(", "),
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (verb === "--version") {
|
|
162
|
+
output("0.1.0");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (positional.length > (verb === "explain" ? 1 : 0))
|
|
166
|
+
throw new Error(`Unexpected argument: ${positional.join(" ")}`);
|
|
167
|
+
if (verb === "init") return init();
|
|
168
|
+
if (!["check", "doctor", "explain", "generate"].includes(verb))
|
|
169
|
+
throw new Error(`Unknown command: ${verb}`);
|
|
170
|
+
const config = JSON.parse(await readFile(configFile, "utf8"));
|
|
171
|
+
if (
|
|
172
|
+
config.version !== 1 ||
|
|
173
|
+
!Array.isArray(config.sources) ||
|
|
174
|
+
!Array.isArray(config.targets)
|
|
175
|
+
)
|
|
176
|
+
throw new Error(
|
|
177
|
+
"Expected lawspec.json version 1 with sources and targets arrays",
|
|
178
|
+
);
|
|
179
|
+
const selected = config.targets.filter(
|
|
180
|
+
(t) => !options.target || t.language === options.target,
|
|
181
|
+
);
|
|
182
|
+
if (["doctor", "generate"].includes(verb) && !selected.length)
|
|
183
|
+
throw new Error("No matching configured target");
|
|
184
|
+
const roots = selected.map((t) => path.resolve(configRoot, t.root));
|
|
185
|
+
if (new Set(roots).size !== roots.length)
|
|
186
|
+
throw new Error("Each configured target needs its own project root");
|
|
187
|
+
if (verb === "doctor") {
|
|
188
|
+
const reports = await Promise.all(
|
|
189
|
+
selected.map((t, i) => doctor(t, roots[i])),
|
|
190
|
+
);
|
|
191
|
+
output(
|
|
192
|
+
options.json
|
|
193
|
+
? reports
|
|
194
|
+
: reports
|
|
195
|
+
.map(
|
|
196
|
+
(r) =>
|
|
197
|
+
`${r.target}: ${r.ok ? "ready" : `${r.message}\n${r.instructions}`}`,
|
|
198
|
+
)
|
|
199
|
+
.join("\n"),
|
|
200
|
+
);
|
|
201
|
+
if (reports.some((r) => !r.ok)) process.exitCode = 1;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const input = { sources: await sources(config) };
|
|
205
|
+
const compiler = await createCompiler();
|
|
206
|
+
if (verb === "check") {
|
|
207
|
+
const result = diagnostics(await compiler.check(input));
|
|
208
|
+
output(
|
|
209
|
+
options.json
|
|
210
|
+
? result
|
|
211
|
+
: `Checked ${result.laws.length} executable law(s).`,
|
|
212
|
+
);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (verb === "explain") {
|
|
216
|
+
const result = diagnostics(await compiler.expand(input));
|
|
217
|
+
const indices = result.laws
|
|
218
|
+
.map((e, i) => ({ e, i }))
|
|
219
|
+
.filter(
|
|
220
|
+
({ e }) => !positional[0] || `${e.owner}::${e.name}` === positional[0],
|
|
221
|
+
);
|
|
222
|
+
if (!indices.length) throw new Error("No matching executable law");
|
|
223
|
+
output(
|
|
224
|
+
options.json
|
|
225
|
+
? indices.map(({ e, i }) => ({ ...e, expansion: result.expansions[i] }))
|
|
226
|
+
: indices
|
|
227
|
+
.map(
|
|
228
|
+
({ e, i }) =>
|
|
229
|
+
`${e.owner}::${e.name}\n${e.trace.join("\n=> ")}\n=> ${result.expansions[i]}`,
|
|
230
|
+
)
|
|
231
|
+
.join("\n\n"),
|
|
232
|
+
);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (options["dry-run"] && options.check)
|
|
236
|
+
throw new Error("--dry-run and --check are mutually exclusive");
|
|
237
|
+
const artifacts = [];
|
|
238
|
+
for (const target of selected)
|
|
239
|
+
artifacts.push(
|
|
240
|
+
diagnostics(
|
|
241
|
+
await compiler.planGeneration({
|
|
242
|
+
...input,
|
|
243
|
+
target: target.language,
|
|
244
|
+
sourceDir: target.sourceDir,
|
|
245
|
+
testDir: target.testDir,
|
|
246
|
+
}),
|
|
247
|
+
).files,
|
|
248
|
+
);
|
|
249
|
+
const reports = await Promise.all(
|
|
250
|
+
selected.map((t, i) => doctor(t, roots[i])),
|
|
251
|
+
);
|
|
252
|
+
const failed = reports.filter((r) => !r.ok);
|
|
253
|
+
if (failed.length)
|
|
254
|
+
throw new Error(
|
|
255
|
+
failed
|
|
256
|
+
.map((r) => `${r.target}: ${r.message}\n${r.instructions}`)
|
|
257
|
+
.join("\n"),
|
|
258
|
+
);
|
|
259
|
+
const plans = await Promise.all(
|
|
260
|
+
artifacts.map((files, i) => planWrites(roots[i], files)),
|
|
261
|
+
);
|
|
262
|
+
if (!options["dry-run"] && !options.check) await applyWrites(plans);
|
|
263
|
+
const summary = plans.map((plan, i) => ({
|
|
264
|
+
target: selected[i].language,
|
|
265
|
+
changes: plan.changes.map((c) => ({ action: c.action, path: c.relative })),
|
|
266
|
+
preservedAdapters: plan.preserved,
|
|
267
|
+
adapterUpdates: plan.adapterUpdates,
|
|
268
|
+
test: testCommand(selected[i], roots[i]),
|
|
269
|
+
}));
|
|
270
|
+
output(
|
|
271
|
+
options.json
|
|
272
|
+
? summary
|
|
273
|
+
: summary
|
|
274
|
+
.map(
|
|
275
|
+
(r) =>
|
|
276
|
+
`${r.target}: ${r.changes.length} ${options["dry-run"] || options.check ? "planned" : "applied"} change(s), ${r.preservedAdapters.length} user adapter(s) preserved.${r.adapterUpdates.length ? "\nReview required adapter signatures:\n" + r.adapterUpdates.map((a) => a.path + "\n" + a.requiredAdapter).join("\n") : ""}\nTest: ${r.test}`,
|
|
277
|
+
)
|
|
278
|
+
.join("\n"),
|
|
279
|
+
);
|
|
280
|
+
if (options.check && plans.some((p) => p.changes.length))
|
|
281
|
+
process.exitCode = 1;
|
|
282
|
+
}
|
|
283
|
+
main().catch((error) => {
|
|
284
|
+
if (options.json)
|
|
285
|
+
output({
|
|
286
|
+
diagnostics: error.diagnostics || [
|
|
287
|
+
{ code: "cli", message: error.message, at: null },
|
|
288
|
+
],
|
|
289
|
+
});
|
|
290
|
+
else console.error(`lawspec: ${error.message}`);
|
|
291
|
+
process.exitCode = 1;
|
|
292
|
+
});
|
package/build.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"compilerSources": [
|
|
4
|
+
"package.yaml",
|
|
5
|
+
"src/LawSpec/Api.hs",
|
|
6
|
+
"src/LawSpec/Compile.hs",
|
|
7
|
+
"src/LawSpec/Emit.hs",
|
|
8
|
+
"src/LawSpec/Gen.hs",
|
|
9
|
+
"src/LawSpec/Model.hs",
|
|
10
|
+
"src/LawSpec/Parser.hs",
|
|
11
|
+
"src/LawSpec/Prelude.hs",
|
|
12
|
+
"stack.yaml",
|
|
13
|
+
"stack.yaml.lock",
|
|
14
|
+
"wasm/app/Exports.hs",
|
|
15
|
+
"wasm/cabal.project",
|
|
16
|
+
"wasm/cabal.project.freeze",
|
|
17
|
+
"wasm/lawspec-wasm.cabal"
|
|
18
|
+
],
|
|
19
|
+
"digests": {
|
|
20
|
+
"package.yaml": "cb68fcf21def8f7a880bbab7088208a86fcf313a83fe2c02e18957872cf65c67",
|
|
21
|
+
"src/LawSpec/Api.hs": "00b1e4bc81231ebd387f10652463904084ebf9c3c4486fd2c2217a6d4dbfc157",
|
|
22
|
+
"src/LawSpec/Compile.hs": "5ea31f4a66df87ddba2ff0b498d12eea5cbbefb0e10e3bf1c1d2bf4a88f5792e",
|
|
23
|
+
"src/LawSpec/Emit.hs": "27ba0cafbea8d5a82aac439f4e72f98e003cfdc177f99a902f8d11f033684b8e",
|
|
24
|
+
"src/LawSpec/Gen.hs": "b283001bc18971ddb2159f2ec7e51ac58aa7e191b52577006140694b09a06987",
|
|
25
|
+
"src/LawSpec/Model.hs": "8f501e134cd1480261ae8bb9e3df8255efd38a02eb883468c6bc2c342e200970",
|
|
26
|
+
"src/LawSpec/Parser.hs": "f546502c6eedb0a4ccd30d11e3b017aedf5a7145080ea58cfd34753fd9b509c6",
|
|
27
|
+
"src/LawSpec/Prelude.hs": "d4b0ae08eab0f30b4b8c819ce360f7cccb1f77d5ea38ded3b88dbb07936b10b6",
|
|
28
|
+
"stack.yaml": "20ccf4d599e355e60b7aa4f814a7cd4299fe2048616cc2e6dbdc22a7bd8cec73",
|
|
29
|
+
"stack.yaml.lock": "ae222b9c81af920c56e50fa4596fa57786e7fa5a7b461390362b2b2ff63818c7",
|
|
30
|
+
"wasm/app/Exports.hs": "4ecbdac8faa2449e6fc61b93c82e6278fa14374154f29b4dacf0eef433f06a47",
|
|
31
|
+
"wasm/cabal.project": "021e560afdc5eb4cb7169e7119ecb8f92c9ee170245af909c94b612941bff5cc",
|
|
32
|
+
"wasm/cabal.project.freeze": "733dbed3d2ecccb26e874fd58f136296dad772184deb1658c196b5d54a0814dc",
|
|
33
|
+
"wasm/lawspec-wasm.cabal": "d11ccf7991e406f0fd26f124c54d6552613b892328f8e22a964e38d03c7d6de4",
|
|
34
|
+
"npm/core.wasm": "14d88c08f92b10ac2939d6b92bc428b0ee255d56e88890fd46abb1e959a26b2b",
|
|
35
|
+
"npm/core_jsffi.js": "88d136efe92f7cff5758c8fec8d9b6bbc9707fe37741cebeeb415fe34ce3d72b",
|
|
36
|
+
"npm/api.mjs": "d6df654600172131ac66a55b86876fc29cde2a76a0a63bbefff3183488a4837b",
|
|
37
|
+
"npm/index.d.ts": "8003304d88dbb01a07bd3dbdc4d680a193db1ec323c5712b9860e166ca9cc94b"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"targets": {
|
|
4
|
+
"java": {
|
|
5
|
+
"java": ["25", "26"],
|
|
6
|
+
"jetCheck": ["0.3.0", "0.3.1"],
|
|
7
|
+
"junit-jupiter": ["5.14.0", "5.15.0"]
|
|
8
|
+
},
|
|
9
|
+
"python": {
|
|
10
|
+
"python": ["3.13", "3.15"],
|
|
11
|
+
"pytest": ["8.4.0", "8.5.0"],
|
|
12
|
+
"hypothesis": ["6.135.26", "7"]
|
|
13
|
+
},
|
|
14
|
+
"javascript": {
|
|
15
|
+
"fast-check": ["4", "5"]
|
|
16
|
+
},
|
|
17
|
+
"typescript": {
|
|
18
|
+
"fast-check": ["4", "5"],
|
|
19
|
+
"typescript": ["5.9", "5.10"],
|
|
20
|
+
"@types/node": ["22", "23"]
|
|
21
|
+
},
|
|
22
|
+
"go": {
|
|
23
|
+
"go": ["1.22", "1.27"],
|
|
24
|
+
"rapid": ["1.2.0", "1.2.1"]
|
|
25
|
+
},
|
|
26
|
+
"haskell": {
|
|
27
|
+
"hspec": ["2.11", "2.12"],
|
|
28
|
+
"hedgehog": ["1.5", "1.6"],
|
|
29
|
+
"hspec-hedgehog": ["0.3.0.0", "0.3.0.1"],
|
|
30
|
+
"ghc": ["9.10", "9.11"]
|
|
31
|
+
},
|
|
32
|
+
"kotlin": {
|
|
33
|
+
"java": ["25", "26"],
|
|
34
|
+
"gradle": ["9.1", "9.4"],
|
|
35
|
+
"kotlin": ["2.3.21", "2.3.22"]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/core.wasm
ADDED
|
Binary file
|