lawspec 0.2.0 → 0.3.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 CHANGED
@@ -2,34 +2,41 @@
2
2
 
3
3
  **State the law once. Check it everywhere.**
4
4
 
5
- LawSpec 0.2 compiles reusable laws into native property tests, executable examples,
5
+ LawSpec 0.3 compiles reusable laws into native property tests, executable examples,
6
6
  and implementation adapters. The compiler is Haskell, distributed as prebuilt
7
7
  WebAssembly with a Node CLI and an asynchronous, typed JavaScript API.
8
8
 
9
9
  ## Install and try it
10
10
 
11
- The checkout includes the npm package in `npm/`. Build an installable archive:
11
+ Install [LawSpec from npm](https://www.npmjs.com/package/lawspec):
12
12
 
13
13
  ```sh
14
- npm pack ./npm
15
- npm install --save-dev ./lawspec-0.2.0.tgz
14
+ npm install --save-dev lawspec@0.3.0
15
+ npx lawspec --version
16
16
  ```
17
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. Stable releases are available as the `lawspec` package on npm.
18
+ Node 22+ and the selected target's build tools are required. No Haskell toolchain
19
+ is needed to install or run LawSpec. The reference platforms are macOS and Linux.
20
+ Use `npx lawspec` to run the locally installed CLI.
21
21
 
22
- From an empty application directory, use the installed `lawspec` command:
22
+ To try it in a **new, empty directory**, initialize the starter before installing
23
+ local dependencies so LawSpec can create its `package.json` and test script:
23
24
 
24
25
  ```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
26
+ mkdir lawspec-example
27
+ cd lawspec-example
28
+ npm exec --package=lawspec@0.3.0 -- lawspec init --target javascript
29
+ npm install --save-dev lawspec@0.3.0
30
+ npx lawspec check
31
+ npx lawspec explain 'example.atoi_codec::itoa and then atoi yields a'
32
+ npx lawspec doctor
33
+ npx lawspec generate
31
34
  ```
32
35
 
36
+ In an existing project, install LawSpec first, then run
37
+ `npx lawspec init --target javascript`. Existing build files are preserved;
38
+ apply the printed dependency and test-runner setup instructions before generation.
39
+
33
40
  Implement `src/example/atoi_codec.mjs`, then run `npm test`:
34
41
 
35
42
  ```javascript
@@ -54,7 +61,7 @@ properties with the selected framework's shrinking and failure reporting.
54
61
  | `kotlin` | JDK/JVM 25, Gradle 9.1–9.3, Kotlin 2.3.21 | Kotest 5.9.1 | `gradle test` |
55
62
 
56
63
  Java 25 and Python 3.13 are the minimum baselines. New JVM releases are admitted
57
- through compatibility profiles after testing; v0.2's current JVM profile certifies
64
+ through compatibility profiles after testing; v0.3's current JVM profile certifies
58
65
  25. Python templates declare `requires-python = ">=3.13"` and runtime checks
59
66
  currently recognize 3.13 and 3.14. Kotlin templates pin Gradle's supported build
60
67
  configuration to Kotlin 2.3.21 and target JVM 25.
@@ -139,7 +146,7 @@ end
139
146
  ```
140
147
 
141
148
  The implicit prelude defines `left inverse`, `round trip identity is preserved`,
142
- and `equivalent`.
149
+ `equivalent`, and `idempotent`.
143
150
  A law may reference a local reusable law or a prelude law. The compiler performs
144
151
  capture-avoiding expansion and specializes types; it does not recognize codec
145
152
  function names specially. `explain` shows the final property:
@@ -150,10 +157,13 @@ for all (x :: Int32) . atoi (itoa (x)) = x
150
157
 
151
158
  Reusable laws can declare typed unary function parameters and `requires Eq a`.
152
159
  Definitions support law application, function application/composition, universal
153
- quantification, integer literals and equality. Function signatures use `Int32`
154
- and `Text`; generic variables are supported in reusable laws. v0.2 generates
155
- quantified `Int32` inputs, including multiple inputs. `Text` can be an intermediate
156
- or compared result. Functions are synchronous and unary.
160
+ quantification, integer and text literals, and equality. Function signatures use `Int32`
161
+ and `Text`; generic variables are supported in reusable laws. v0.3 generates
162
+ quantified `Int32` and `Text` inputs, including mixed and multiple inputs. Both
163
+ types can also be intermediate or compared results. Functions are synchronous
164
+ and unary. Text literals are double-quoted, with escapes such as `\"`, `\\`,
165
+ `\n`, and `\t`; examples must bind each input to a literal of its declared type.
166
+ Text values contain Unicode scalar values; surrogate code points are rejected.
157
167
 
158
168
  Examples refer to the expanded input names, including names inherited from the
159
169
  prelude. Bind every input exactly once. Ambiguous names and out-of-range values
@@ -192,7 +202,7 @@ This expands to `for all (x :: Int32) . render (x) = referenceRender (x)`.
192
202
  The example inherits the input name `x` from the prelude. Both functions are
193
203
  user-owned adapter functions; either may delegate to your existing code.
194
204
 
195
- [The complete example](examples/specs/equivalent.lawspec) compares decimal
205
+ [The complete example](https://github.com/brain-fuel/lawspec/blob/v0.3.0/examples/specs/equivalent.lawspec) compares decimal
196
206
  renderers and two implementations that clamp negative integers to zero. For
197
207
  JavaScript, their adapters can be:
198
208
 
@@ -207,7 +217,82 @@ The same specification generates native tests for all seven targets. The
207
217
  integration suite checks both examples with matching implementations, then
208
218
  breaks each alternative separately to verify detection. Agreement does not
209
219
  establish that either implementation meets an independent specification; two
210
- implementations can share the same bug. Quantified inputs remain `Int32` in v0.2.
220
+ implementations can share the same bug. Quantified inputs can be `Int32` or `Text`.
221
+
222
+ ## Text properties and idempotence
223
+
224
+ ```lawspec
225
+ unit example.slug
226
+
227
+ normalize :: Text -> Text
228
+ referenceNormalize :: Text -> Text
229
+
230
+ law `normalizers agree` is
231
+ definition is
232
+ `equivalent` normalize referenceNormalize
233
+ end
234
+ example `ordinary text` is
235
+ x = "Hello, World!"
236
+ end
237
+ end
238
+ ```
239
+
240
+ The [slug example](https://github.com/brain-fuel/lawspec/blob/v0.3.0/examples/specs/slug.lawspec)
241
+ compares two implementations of ASCII-space replacement. It includes empty,
242
+ Unicode and escaped text. Each target uses its native string generator:
243
+ JetCheck `Generator.stringsOf(Generator.asciiPrintableChars())`, Hypothesis `st.text()`, fast-check `fc.string()`,
244
+ Rapid `rapid.String()`, Hedgehog `Gen.text`, or Kotest `Arb.string()`.
245
+ Generator distributions differ between libraries; the generated tests also
246
+ exercise deterministic empty, whitespace, Unicode, combining-mark and escaped
247
+ control-character cases. Hedgehog's generated text length range is 0–100.
248
+
249
+ The prelude's `idempotent` law requires `f (f x) = f x`:
250
+
251
+ ```lawspec
252
+ unit example.canonical_url
253
+
254
+ canonicalize :: Text -> Text
255
+
256
+ law `canonicalization reaches a fixed point` is
257
+ definition is
258
+ `idempotent` canonicalize
259
+ end
260
+ end
261
+ ```
262
+
263
+ The [canonical URL example](https://github.com/brain-fuel/lawspec/blob/v0.3.0/examples/specs/canonical_url.lawspec)
264
+ uses removal of **all trailing slashes** as a small fixed-point demonstration,
265
+ not a complete URL canonicalization algorithm. For JavaScript:
266
+
267
+ ```javascript
268
+ export const canonicalize = value => value.replace(/\/+$/, "");
269
+ ```
270
+
271
+ Removing just one trailing slash fails the supplied repeated-slash example.
272
+ The [mixed-input example](https://github.com/brain-fuel/lawspec/blob/v0.3.0/examples/specs/mixed_inputs.lawspec)
273
+ shows `Text` and `Int32` in the same quantified property and executable example.
274
+ The JavaScript API represents example values as `number | string`.
275
+
276
+ ## Generate all example artifacts
277
+
278
+ ```sh
279
+ npx lawspec examples
280
+ # Or select a target and a relative output directory:
281
+ npx lawspec examples --target java --output example_artifacts
282
+ ```
283
+
284
+ This command works without a project configuration or native build tools. It
285
+ compiles every bundled example and writes its tests and user-owned stubs to
286
+ `example_artifacts/<language>/`, using each target's normal source/test layout.
287
+ By default it exports all seven languages; `--json` returns the file inventory.
288
+ From a checkout, `make examples` runs the same command.
289
+
290
+ These are inspection artifacts, not initialized projects: no build files are
291
+ created and dependency compatibility is not checked. To run them, configure the
292
+ corresponding native project and implement the stubs. Regeneration preserves
293
+ user-owned stubs and refuses to overwrite edited generated tests. Each target
294
+ has its own ownership manifest. Output paths must be relative and cannot use
295
+ parent traversal or symlinks. The default directory is ignored by Git.
211
296
 
212
297
  ## Ownership
213
298
 
@@ -247,6 +332,10 @@ by the JS shim.
247
332
 
248
333
  ## Build and verify
249
334
 
335
+ For contributors working from a repository checkout, build a local archive with
336
+ `npm pack ./npm` and install it with `npm install --save-dev ./lawspec-0.3.0.tgz`.
337
+ The package payload lives in `npm/`.
338
+
250
339
  ```sh
251
340
  stack test
252
341
  # With wasm32-wasi-cabal and wasm32-wasi-ghc installed:
package/bin/lawspec.mjs CHANGED
@@ -3,6 +3,7 @@ import { readFile, mkdir, readdir, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { createCompiler } from "../api.mjs";
5
5
  import { targets, templates, commands, setup } from "../templates.mjs";
6
+ import { generateExamples } from "../examples-command.mjs";
6
7
  import { doctor } from "../doctor.mjs";
7
8
  import {
8
9
  readOptional,
@@ -17,7 +18,7 @@ const options = {};
17
18
  const positional = [];
18
19
  for (let i = 0; i < args.length; i++) {
19
20
  const arg = args[i];
20
- if (["--target", "--project", "--config"].includes(arg)) {
21
+ if (["--target", "--project", "--config", "--output"].includes(arg)) {
21
22
  if (!args[i + 1] || args[i + 1].startsWith("--"))
22
23
  throw new Error(`Missing value for ${arg}`);
23
24
  options[arg.slice(2)] = args[++i];
@@ -153,17 +154,40 @@ async function init() {
153
154
  async function main() {
154
155
  if (!verb || ["help", "--help", "-h"].includes(verb)) {
155
156
  output(
156
- "LawSpec 0.2.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
+ "LawSpec 0.3.0\nUsage: lawspec init --target <language> [--project <directory>]\n lawspec check | doctor | explain <unit>::<law> | generate\n lawspec examples [--target <language>] [--output example_artifacts]\nOptions: --config <path>, --target <language>, --json\nGeneration: --dry-run, --check\nTargets: " +
157
158
  targets.join(", "),
158
159
  );
159
160
  return;
160
161
  }
161
162
  if (verb === "--version") {
162
- output("0.2.0");
163
+ output("0.3.0");
163
164
  return;
164
165
  }
165
166
  if (positional.length > (verb === "explain" ? 1 : 0))
166
167
  throw new Error(`Unexpected argument: ${positional.join(" ")}`);
168
+ if (verb === "examples") {
169
+ if (
170
+ options.config ||
171
+ options.project ||
172
+ options["dry-run"] ||
173
+ options.check
174
+ )
175
+ throw new Error("examples supports --target, --output and --json only");
176
+ const result = await generateExamples(options);
177
+ output(
178
+ options.json
179
+ ? result
180
+ : result
181
+ .map(
182
+ (r) =>
183
+ `${r.target}: ${r.files.length} artifacts in ${r.directory}; ${r.preservedAdapters.length} user adapters preserved.`,
184
+ )
185
+ .join("\n") +
186
+ "\nInspection artifacts only; native toolchains and dependencies are not checked. Stubs must be implemented before running tests.",
187
+ );
188
+ return;
189
+ }
190
+ if (options.output) throw new Error("--output is only supported by examples");
167
191
  if (verb === "init") return init();
168
192
  if (!["check", "doctor", "explain", "generate"].includes(verb))
169
193
  throw new Error(`Unknown command: ${verb}`);
package/build.json CHANGED
@@ -17,23 +17,23 @@
17
17
  "wasm/lawspec-wasm.cabal"
18
18
  ],
19
19
  "digests": {
20
- "package.yaml": "f8d2b6bbf4308890903787813994500d4843cd08ee5bc1ae3808a49461d51380",
20
+ "package.yaml": "a04aefef41a1245f28d4f1d63ea38a5266b1f5302242c9fc3a840252ea7ce3ec",
21
21
  "src/LawSpec/Api.hs": "00b1e4bc81231ebd387f10652463904084ebf9c3c4486fd2c2217a6d4dbfc157",
22
- "src/LawSpec/Compile.hs": "c1727ba7d6ece9725567f9561d341d8660b3293ff845c2de1d936ebdd2cd5fc6",
23
- "src/LawSpec/Emit.hs": "24edca3a078ea10349b3a229b56464c37bfd5303c688a11ed45f9479ce3b7bd1",
24
- "src/LawSpec/Gen.hs": "b283001bc18971ddb2159f2ec7e51ac58aa7e191b52577006140694b09a06987",
25
- "src/LawSpec/Model.hs": "8f501e134cd1480261ae8bb9e3df8255efd38a02eb883468c6bc2c342e200970",
26
- "src/LawSpec/Parser.hs": "f546502c6eedb0a4ccd30d11e3b017aedf5a7145080ea58cfd34753fd9b509c6",
27
- "src/LawSpec/Prelude.hs": "f04078dbc03eb793f9f3343db562cf56f84b5fbe6fd4aea6481bad38a7c6f328",
22
+ "src/LawSpec/Compile.hs": "cb32f1ff2f2ded5e93e25b11658fec6a2ffb1d24e0caaa2ffec94b510f4bfe40",
23
+ "src/LawSpec/Emit.hs": "c72ff20088dcaf74576710e144e79d35f9441f37fc933603033584fdff309fbe",
24
+ "src/LawSpec/Gen.hs": "b3d924fddc7e8417665635f049a3f641ea15c2f6f997571287db17a70b1f37b6",
25
+ "src/LawSpec/Model.hs": "5d550a5b75cd6b00f1b01d276100527a066c35bb69e95adb6b549b6603bab196",
26
+ "src/LawSpec/Parser.hs": "0a70a57b6535cfa33121e9ada595f518ced4f7b35bf09bc4732a1362c05a253f",
27
+ "src/LawSpec/Prelude.hs": "0b60dd85bdf0642077dfb497043c63606d507e71e12d3ed8bfe2bf773214c66b",
28
28
  "stack.yaml": "20ccf4d599e355e60b7aa4f814a7cd4299fe2048616cc2e6dbdc22a7bd8cec73",
29
29
  "stack.yaml.lock": "ae222b9c81af920c56e50fa4596fa57786e7fa5a7b461390362b2b2ff63818c7",
30
30
  "wasm/app/Exports.hs": "4ecbdac8faa2449e6fc61b93c82e6278fa14374154f29b4dacf0eef433f06a47",
31
31
  "wasm/cabal.project": "021e560afdc5eb4cb7169e7119ecb8f92c9ee170245af909c94b612941bff5cc",
32
32
  "wasm/cabal.project.freeze": "733dbed3d2ecccb26e874fd58f136296dad772184deb1658c196b5d54a0814dc",
33
- "wasm/lawspec-wasm.cabal": "48a9a7587f183565043e313e20e531c72d2eeb4ec706a1052ff0e7b461961002",
34
- "npm/core.wasm": "fe4048e604bbdf5ddbecf9ea2e9b27ac60161788fff35524cbcf9825e2e8faeb",
33
+ "wasm/lawspec-wasm.cabal": "9f61a4c9e5c7715d1de2c42e518f3fe9cf088a3677182bda4c3f4601aa930ba9",
34
+ "npm/core.wasm": "674f19955c07f0bd987df27463cb0ba35c5e9756a8d22309aa21bff7488e7ed0",
35
35
  "npm/core_jsffi.js": "88d136efe92f7cff5758c8fec8d9b6bbc9707fe37741cebeeb415fe34ce3d72b",
36
36
  "npm/api.mjs": "d6df654600172131ac66a55b86876fc29cde2a76a0a63bbefff3183488a4837b",
37
- "npm/index.d.ts": "8003304d88dbb01a07bd3dbdc4d680a193db1ec323c5712b9860e166ca9cc94b"
37
+ "npm/index.d.ts": "94c4842654d280796aeedcf7b438ffc63e96d0ff639203f93d04610f1de07c53"
38
38
  }
39
39
  }
package/core.wasm CHANGED
Binary file
@@ -0,0 +1,21 @@
1
+ unit example.canonical_url
2
+
3
+ canonicalize :: Text -> Text
4
+
5
+ law `canonicalization reaches a fixed point` is
6
+ definition is
7
+ `idempotent` canonicalize
8
+ end
9
+ description is
10
+ "{canonicalize} removes trailing slashes until the result is stable"
11
+ end
12
+ example `trailing slashes` is
13
+ x = "https://example.com/path///"
14
+ end
15
+ example `already canonical` is
16
+ x = "https://example.com/path"
17
+ end
18
+ example `empty` is
19
+ x = ""
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ unit example.mixed.inputs
2
+
3
+ normalize :: Text -> Text
4
+ identity :: Int32 -> Int32
5
+
6
+ law `text and integer inputs remain independent` is
7
+ definition is
8
+ `for all` (text :: Text) (number :: Int32) .
9
+ normalize text = normalize (normalize text)
10
+ end
11
+ example `mixed inputs` is
12
+ text = "Hello, World!"
13
+ number = -42
14
+ end
15
+ end
16
+
17
+ law `integer behavior with a text context` is
18
+ definition is
19
+ `for all` (text :: Text) (number :: Int32) .
20
+ identity number = number
21
+ end
22
+ example `Unicode context` is
23
+ text = "λ 😀"
24
+ number = 2147483647
25
+ end
26
+ end
27
+
28
+ law `normalizing an empty literal` is
29
+ definition is
30
+ `for all` (text :: Text) .
31
+ normalize "" = ""
32
+ end
33
+ end
@@ -0,0 +1,25 @@
1
+ unit example.slug
2
+
3
+ normalize :: Text -> Text
4
+ referenceNormalize :: Text -> Text
5
+
6
+ law `normalizers agree` is
7
+ definition is
8
+ `equivalent` normalize referenceNormalize
9
+ end
10
+ description is
11
+ "{normalize} and {referenceNormalize} replace ASCII spaces with hyphens"
12
+ end
13
+ example `ordinary text` is
14
+ x = "Hello, World!"
15
+ end
16
+ example `empty` is
17
+ x = ""
18
+ end
19
+ example `Unicode` is
20
+ x = "café 日本語 😀"
21
+ end
22
+ example `escaped text` is
23
+ x = "quote: \" slash: \\ newline: \n tab: \t dollar: $"
24
+ end
25
+ end
@@ -0,0 +1,51 @@
1
+ import { readFile, readdir, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createCompiler } from "./api.mjs";
4
+ import { targets } from "./templates.mjs";
5
+ import { safePath, planWrites, applyWrites } from "./files.mjs";
6
+
7
+ export async function generateExamples(options) {
8
+ const selected = options.target ? [options.target] : targets;
9
+ if (selected.some((t) => !targets.includes(t)))
10
+ throw new Error(`Unknown target: ${options.target}`);
11
+ const directory = new URL("./examples/specs/", import.meta.url);
12
+ const sources = await Promise.all(
13
+ (await readdir(directory))
14
+ .filter((n) => n.endsWith(".lawspec"))
15
+ .sort()
16
+ .map(async (name) => ({
17
+ path: name,
18
+ content: await readFile(new URL(name, directory), "utf8"),
19
+ })),
20
+ );
21
+ const compiler = await createCompiler();
22
+ const artifacts = [];
23
+ for (const target of selected) {
24
+ const result = await compiler.planGeneration({ sources, target });
25
+ if (result.diagnostics.length)
26
+ throw Object.assign(new Error("Bundled examples failed to compile"), {
27
+ diagnostics: result.diagnostics,
28
+ });
29
+ artifacts.push(result.files);
30
+ }
31
+ const root = await safePath(
32
+ process.cwd(),
33
+ options.output || "example_artifacts",
34
+ );
35
+ await mkdir(root, { recursive: true });
36
+ const plans = [];
37
+ for (let i = 0; i < selected.length; i++) {
38
+ const targetRoot = await safePath(root, selected[i]);
39
+ await mkdir(targetRoot, { recursive: true });
40
+ plans.push(await planWrites(targetRoot, artifacts[i]));
41
+ }
42
+ await applyWrites(plans);
43
+ return plans.map((plan, i) => ({
44
+ target: selected[i],
45
+ directory: plan.root,
46
+ files: artifacts[i].map((f) => ({ path: f.path, ownership: f.ownership })),
47
+ changes: plan.changes.length,
48
+ preservedAdapters: plan.preserved,
49
+ adapterUpdates: plan.adapterUpdates,
50
+ }));
51
+ }
package/index.d.ts CHANGED
@@ -5,9 +5,9 @@ export interface Location { file: string; line: number; column: number }
5
5
  export interface Diagnostic { code: string; message: string; at: Location | null }
6
6
  export interface Artifact { path: string; content: string; ownership: 'user' | 'generated' }
7
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]};
8
+ export type Expr = {tag: 'Var' | 'StringLit'; contents: string} | {tag: 'Number'; contents: number} | {tag: 'Apply' | 'Compose'; contents: [Expr, Expr]};
9
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][] }
10
+ export interface Example { exampleName: string; bindings: [string, number | string][] }
11
11
  export interface Law { lawName: string; parameters: [string, Type][]; requirements: Type[]; definition: Definition; description: string; rationale: string; examples: Example[]; references: string[]; location: Location }
12
12
  export interface Expanded { owner: string; name: string; inputs: {inputName: string; inputId: string; inputType: Type}[]; left: Expr; right: Expr; trace: string[]; original: Law }
13
13
  export interface CheckRequest { sources: Source[] }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lawspec",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "State the law once. Check it everywhere.",
5
5
  "license": "MIT",
6
6
  "type": "module",