lawspec 0.2.1 → 0.4.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,7 +2,7 @@
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.4 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
 
@@ -11,7 +11,7 @@ WebAssembly with a Node CLI and an asynchronous, typed JavaScript API.
11
11
  Install [LawSpec from npm](https://www.npmjs.com/package/lawspec):
12
12
 
13
13
  ```sh
14
- npm install --save-dev lawspec@0.2.1
14
+ npm install --save-dev lawspec@0.4.0
15
15
  npx lawspec --version
16
16
  ```
17
17
 
@@ -25,8 +25,8 @@ local dependencies so LawSpec can create its `package.json` and test script:
25
25
  ```sh
26
26
  mkdir lawspec-example
27
27
  cd lawspec-example
28
- npm exec --package=lawspec@0.2.1 -- lawspec init --target javascript
29
- npm install --save-dev lawspec@0.2.1
28
+ npm exec --package=lawspec@0.4.0 -- lawspec init --target javascript
29
+ npm install --save-dev lawspec@0.4.0
30
30
  npx lawspec check
31
31
  npx lawspec explain 'example.atoi_codec::itoa and then atoi yields a'
32
32
  npx lawspec doctor
@@ -52,7 +52,7 @@ properties with the selected framework's shrinking and failure reporting.
52
52
 
53
53
  | Target | Build setup | Test libraries | Test command |
54
54
  | --- | --- | --- | --- |
55
- | `java` | Maven, JDK 25, release 25 | JetCheck 0.3.0, JUnit Jupiter 5.14.x | `mvn test` |
55
+ | `java` | Maven, JDK 25, release 25 | JetCheck 0.4.0, JUnit Jupiter 5.14.x | `mvn test` |
56
56
  | `python` | Python 3.13 or 3.14, pyproject | pytest 8.4.x, Hypothesis 6.135.26+ (6.x) | `python -m pytest` |
57
57
  | `javascript` | Node 22+, npm, ESM | fast-check 4.x, node:test | `npm test` |
58
58
  | `typescript` | Node 22+, npm, TypeScript 5.9.x, ESM | fast-check 4.x, node:test | `npm test` |
@@ -61,7 +61,7 @@ properties with the selected framework's shrinking and failure reporting.
61
61
  | `kotlin` | JDK/JVM 25, Gradle 9.1–9.3, Kotlin 2.3.21 | Kotest 5.9.1 | `gradle test` |
62
62
 
63
63
  Java 25 and Python 3.13 are the minimum baselines. New JVM releases are admitted
64
- through compatibility profiles after testing; v0.2's current JVM profile certifies
64
+ through compatibility profiles after testing; v0.4's current JVM profile certifies
65
65
  25. Python templates declare `requires-python = ">=3.13"` and runtime checks
66
66
  currently recognize 3.13 and 3.14. Kotlin templates pin Gradle's supported build
67
67
  configuration to Kotlin 2.3.21 and target JVM 25.
@@ -113,7 +113,7 @@ uses `test/Spec.hs` with `hspec-discover`.
113
113
  Commands:
114
114
 
115
115
  - `check`: parse, resolve, type-check and expand laws without target dependencies.
116
- - `explain [unit::law]`: display expansion steps and inherited example inputs.
116
+ - `explain [unit::law]`: display expansions, example inputs, and expected results.
117
117
  - `doctor`: inspect selected native environments and print corrective instructions.
118
118
  - `generate`: check environments, validate every output, then write artifacts.
119
119
  - `generate --dry-run`: show proposed file operations without applying them.
@@ -139,14 +139,16 @@ law `round trip` is
139
139
  description is
140
140
  "applying {itoa} and then {atoi} recovers the original integer"
141
141
  end
142
- example `negative` is
142
+ example `negative integers use a minus sign and round-trip unchanged` is
143
143
  x = -42
144
+ expect itoa x = "-42"
145
+ expect atoi (itoa x) = -42
144
146
  end
145
147
  end
146
148
  ```
147
149
 
148
150
  The implicit prelude defines `left inverse`, `round trip identity is preserved`,
149
- and `equivalent`.
151
+ `equivalent`, and `idempotent`.
150
152
  A law may reference a local reusable law or a prelude law. The compiler performs
151
153
  capture-avoiding expansion and specializes types; it does not recognize codec
152
154
  function names specially. `explain` shows the final property:
@@ -157,15 +159,18 @@ for all (x :: Int32) . atoi (itoa (x)) = x
157
159
 
158
160
  Reusable laws can declare typed unary function parameters and `requires Eq a`.
159
161
  Definitions support law application, function application/composition, universal
160
- quantification, integer literals and equality. Function signatures use `Int32`
161
- and `Text`; generic variables are supported in reusable laws. v0.2 generates
162
- quantified `Int32` inputs, including multiple inputs. `Text` can be an intermediate
163
- or compared result. Functions are synchronous and unary.
162
+ quantification, integer and text literals, and equality. Function signatures use `Int32`
163
+ and `Text`; generic variables are supported in reusable laws. v0.4 generates
164
+ quantified `Int32` and `Text` inputs, including mixed and multiple inputs. Both
165
+ types can also be intermediate or compared results. Functions are synchronous
166
+ and unary. Text literals are double-quoted, with escapes such as `\"`, `\\`,
167
+ `\n`, and `\t`; examples must bind each input to a literal of its declared type.
168
+ Text values contain Unicode scalar values; surrogate code points are rejected.
164
169
 
165
170
  Examples refer to the expanded input names, including names inherited from the
166
171
  prelude. Bind every input exactly once. Ambiguous names and out-of-range values
167
172
  are errors. Descriptions and rationales use `{function}` references; `{{` and `}}`
168
- produce literal braces. Metadata blocks follow the order shown in `scratch.md`:
173
+ produce literal braces. Law blocks use this order:
169
174
  definition, optional description, optional rationale, examples, optional references.
170
175
  `--` starts a line comment. Names that cannot be emitted portably are diagnosed.
171
176
 
@@ -173,6 +178,40 @@ Additional primitives, external law packages, cross-unit imports beyond the
173
178
  prelude, async functions, direct existing-symbol binding and browser hosting are
174
179
  outside this release.
175
180
 
181
+ ## Expected results and migration to 0.4
182
+
183
+ Every `example` must bind all quantified inputs and then include one or more
184
+ `expect <expression> = <literal>` assertions. The expected literal must have the
185
+ same `Int32` or `Text` type as the expression. Expressions can reference the
186
+ example's inputs and the unit's functions, including composed function calls.
187
+ Input names shadow function names within expectations, following lexical scope.
188
+
189
+ An example passes only when **all its expected results and its enclosing law**
190
+ pass. Expected results are authored specifications, never inferred by executing
191
+ your adapter. They apply to that example's inputs; randomized and boundary tests
192
+ continue to check the general law. Generated assertions show compared values and
193
+ identify the example, input bindings and expression. A failing assertion stops
194
+ that individual test; other tests remain independent.
195
+
196
+ This is an intentional syntax break from 0.3: input-only examples are rejected,
197
+ and `expect` is now a reserved keyword. Laws can still omit examples altogether.
198
+ For an existing input-only example, retain its bindings and add the intended
199
+ result before `end`:
200
+
201
+ ```lawspec
202
+ example `zero renders as 0 and round-trips unchanged` is
203
+ x = 0
204
+ expect itoa x = "0"
205
+ expect atoi (itoa x) = 0
206
+ end
207
+ ```
208
+
209
+ Update every example before running `check` or `generate`. Use
210
+ `npx lawspec explain` to inspect the law expansion, example inputs and expected
211
+ results; this command displays the specification without executing adapters.
212
+ Existing implementation adapters remain user-owned and are never overwritten.
213
+ The historical `scratch.md` is a design draft, not the current syntax reference.
214
+
176
215
  ## Comparing alternative implementations
177
216
 
178
217
  `equivalent` compares two functions with the same input and output types. Its
@@ -189,8 +228,10 @@ law `decimal renderers agree` is
189
228
  definition is
190
229
  `equivalent` render referenceRender
191
230
  end
192
- example `negative integer` is
231
+ example `both renderers produce a negative decimal string` is
193
232
  x = -42
233
+ expect render x = "-42"
234
+ expect referenceRender x = "-42"
194
235
  end
195
236
  end
196
237
  ```
@@ -199,7 +240,7 @@ This expands to `for all (x :: Int32) . render (x) = referenceRender (x)`.
199
240
  The example inherits the input name `x` from the prelude. Both functions are
200
241
  user-owned adapter functions; either may delegate to your existing code.
201
242
 
202
- [The complete example](https://github.com/brain-fuel/lawspec/blob/v0.2.1/examples/specs/equivalent.lawspec) compares decimal
243
+ [The complete example](https://github.com/brain-fuel/lawspec/blob/v0.4.0/examples/specs/equivalent.lawspec) compares decimal
203
244
  renderers and two implementations that clamp negative integers to zero. For
204
245
  JavaScript, their adapters can be:
205
246
 
@@ -212,9 +253,91 @@ export const referenceClamp = x => x < 0 ? 0 : x;
212
253
 
213
254
  The same specification generates native tests for all seven targets. The
214
255
  integration suite checks both examples with matching implementations, then
215
- breaks each alternative separately to verify detection. Agreement does not
216
- establish that either implementation meets an independent specification; two
217
- implementations can share the same bug. Quantified inputs remain `Int32` in v0.2.
256
+ breaks each alternative separately to verify detection. The general equivalence law alone does not establish independent correctness;
257
+ two implementations can share the same bug. Explicit expectations additionally
258
+ check the specified outputs at the supplied example inputs. Quantified inputs can be `Int32` or `Text`.
259
+
260
+ ## Text properties and idempotence
261
+
262
+ ```lawspec
263
+ unit example.slug
264
+
265
+ normalize :: Text -> Text
266
+ referenceNormalize :: Text -> Text
267
+
268
+ law `normalizers agree` is
269
+ definition is
270
+ `equivalent` normalize referenceNormalize
271
+ end
272
+ example `spaces become hyphens; punctuation is preserved` is
273
+ x = "Hello, World!"
274
+ expect normalize x = "Hello,-World!"
275
+ expect referenceNormalize x = "Hello,-World!"
276
+ end
277
+ end
278
+ ```
279
+
280
+ The [slug example](https://github.com/brain-fuel/lawspec/blob/v0.4.0/examples/specs/slug.lawspec)
281
+ compares two implementations of ASCII-space replacement. It includes empty,
282
+ Unicode and escaped text. Each target uses its native string generator:
283
+ JetCheck `Generator.stringsOf(Generator.asciiPrintableChars())`, Hypothesis `st.text()`, fast-check `fc.string()`,
284
+ Rapid `rapid.String()`, Hedgehog `Gen.text`, or Kotest `Arb.string()`.
285
+ Generator distributions differ between libraries; the generated tests also
286
+ exercise deterministic empty, whitespace, Unicode, combining-mark and escaped
287
+ control-character cases. Hedgehog's generated text length range is 0–100.
288
+
289
+ The prelude's `idempotent` law requires `f (f x) = f x`:
290
+
291
+ ```lawspec
292
+ unit example.canonical_url
293
+
294
+ canonicalize :: Text -> Text
295
+
296
+ law `canonicalization reaches a fixed point` is
297
+ definition is
298
+ `idempotent` canonicalize
299
+ end
300
+ example `all trailing slashes are removed in one pass` is
301
+ x = "https://example.com/path///"
302
+ expect canonicalize x = "https://example.com/path"
303
+ end
304
+ end
305
+ ```
306
+
307
+ The [canonical URL example](https://github.com/brain-fuel/lawspec/blob/v0.4.0/examples/specs/canonical_url.lawspec)
308
+ uses removal of **all trailing slashes** as a small fixed-point demonstration,
309
+ not a complete URL canonicalization algorithm. For JavaScript:
310
+
311
+ ```javascript
312
+ export const canonicalize = value => value.replace(/\/+$/, "");
313
+ ```
314
+
315
+ Removing just one trailing slash fails the supplied repeated-slash example.
316
+ The [mixed-input example](https://github.com/brain-fuel/lawspec/blob/v0.4.0/examples/specs/mixed_inputs.lawspec)
317
+ shows `Text` and `Int32` in the same quantified property and executable example.
318
+ The JavaScript API represents input bindings and expected values as `number | string`.
319
+ Each example includes `expectations: { actual: Expr; expected: number | string }[]`.
320
+
321
+ ## Generate all example artifacts
322
+
323
+ ```sh
324
+ npx lawspec examples
325
+ # Or select a target and a relative output directory:
326
+ npx lawspec examples --target java --output example_artifacts
327
+ ```
328
+
329
+ This command works without a project configuration or native build tools. It
330
+ compiles every bundled example and writes its tests and user-owned stubs to
331
+ `example_artifacts/<language>/`, using each target's normal source/test layout.
332
+ By default it exports all seven languages; `--json` returns the file inventory.
333
+ From a checkout, `make examples` runs the same command.
334
+
335
+ These are inspection artifacts, not initialized projects: no build files are
336
+ created and dependency compatibility is not checked. To run them, configure the
337
+ corresponding native project and implement the stubs. Regeneration preserves
338
+ user-owned stubs and refuses to overwrite edited generated tests. Each target
339
+ has its own ownership manifest. Output paths must be relative and cannot use
340
+ parent traversal or symlinks. The default directory is ignored by Git.
218
341
 
219
342
  ## Ownership
220
343
 
@@ -255,7 +378,7 @@ by the JS shim.
255
378
  ## Build and verify
256
379
 
257
380
  For contributors working from a repository checkout, build a local archive with
258
- `npm pack ./npm` and install it with `npm install --save-dev ./lawspec-0.2.1.tgz`.
381
+ `npm pack ./npm` and install it with `npm install --save-dev ./lawspec-0.4.0.tgz`.
259
382
  The package payload lives in `npm/`.
260
383
 
261
384
  ```sh
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];
@@ -150,20 +151,70 @@ async function init() {
150
151
  `Configured ${language}. ${hasBuild ? "Existing build files preserved." : "Created missing project build files."}\n${setup[language]}\nNext: lawspec doctor, then lawspec generate.`,
151
152
  );
152
153
  }
154
+ function showExpression(expr) {
155
+ const value = expr.contents;
156
+ if (expr.tag === "Var") return value;
157
+ if (expr.tag === "Number" || expr.tag === "StringLit")
158
+ return JSON.stringify(value);
159
+ if (expr.tag === "Apply")
160
+ return `${showExpression(value[0])} (${showExpression(value[1])})`;
161
+ return `(${showExpression(value[0])} . ${showExpression(value[1])})`;
162
+ }
163
+ function explainExamples(law) {
164
+ return law.original.examples
165
+ .map(
166
+ (ex) =>
167
+ `\nexample ${JSON.stringify(ex.exampleName)}\n` +
168
+ ex.bindings
169
+ .map(([n, v]) => ` ${n} = ${JSON.stringify(v)}`)
170
+ .join("\n") +
171
+ "\n" +
172
+ ex.expectations
173
+ .map(
174
+ (e) =>
175
+ ` expect ${showExpression(e.actual)} = ${JSON.stringify(e.expected)}`,
176
+ )
177
+ .join("\n"),
178
+ )
179
+ .join("\n");
180
+ }
153
181
  async function main() {
154
182
  if (!verb || ["help", "--help", "-h"].includes(verb)) {
155
183
  output(
156
- "LawSpec 0.2.1\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: " +
184
+ "LawSpec 0.4.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
185
  targets.join(", "),
158
186
  );
159
187
  return;
160
188
  }
161
189
  if (verb === "--version") {
162
- output("0.2.1");
190
+ output("0.4.0");
163
191
  return;
164
192
  }
165
193
  if (positional.length > (verb === "explain" ? 1 : 0))
166
194
  throw new Error(`Unexpected argument: ${positional.join(" ")}`);
195
+ if (verb === "examples") {
196
+ if (
197
+ options.config ||
198
+ options.project ||
199
+ options["dry-run"] ||
200
+ options.check
201
+ )
202
+ throw new Error("examples supports --target, --output and --json only");
203
+ const result = await generateExamples(options);
204
+ output(
205
+ options.json
206
+ ? result
207
+ : result
208
+ .map(
209
+ (r) =>
210
+ `${r.target}: ${r.files.length} artifacts in ${r.directory}; ${r.preservedAdapters.length} user adapters preserved.`,
211
+ )
212
+ .join("\n") +
213
+ "\nInspection artifacts only; native toolchains and dependencies are not checked. Stubs must be implemented before running tests.",
214
+ );
215
+ return;
216
+ }
217
+ if (options.output) throw new Error("--output is only supported by examples");
167
218
  if (verb === "init") return init();
168
219
  if (!["check", "doctor", "explain", "generate"].includes(verb))
169
220
  throw new Error(`Unknown command: ${verb}`);
@@ -226,7 +277,7 @@ async function main() {
226
277
  : indices
227
278
  .map(
228
279
  ({ e, i }) =>
229
- `${e.owner}::${e.name}\n${e.trace.join("\n=> ")}\n=> ${result.expansions[i]}`,
280
+ `${e.owner}::${e.name}\n${e.trace.join("\n=> ")}\n=> ${result.expansions[i]}${explainExamples(e)}`,
230
281
  )
231
282
  .join("\n\n"),
232
283
  );
package/build.json CHANGED
@@ -17,23 +17,23 @@
17
17
  "wasm/lawspec-wasm.cabal"
18
18
  ],
19
19
  "digests": {
20
- "package.yaml": "2cc5254bf0c1de94adbb98ee193dd45f5d8e5efced9ce255508112ccbca96793",
20
+ "package.yaml": "2c1176c912901bd218743a88df1b67fb1ee8d58ed73fcbe7e2f23827f10a3a0f",
21
21
  "src/LawSpec/Api.hs": "00b1e4bc81231ebd387f10652463904084ebf9c3c4486fd2c2217a6d4dbfc157",
22
- "src/LawSpec/Compile.hs": "c1727ba7d6ece9725567f9561d341d8660b3293ff845c2de1d936ebdd2cd5fc6",
23
- "src/LawSpec/Emit.hs": "4e223dfc975c5b66021e7a012a063edd159fe38ca12df7d009aa3de472a2e646",
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": "336eca415ed6c8594612ad5acfc7ff6f29ef387cf3cb374b60b2407f811c31c7",
23
+ "src/LawSpec/Emit.hs": "2e23ca38aa5c4c65305283705633209843dd6ba116c43ba8a42ee8731040f7c3",
24
+ "src/LawSpec/Gen.hs": "305d7710dbc69b25d95fa6c0ac9a59622481f6b89682c1e5c9048def3962315a",
25
+ "src/LawSpec/Model.hs": "d1eda2b420edbc327b1adbf437c6f39a9e576ef0ab9b82f468c28202a433eb96",
26
+ "src/LawSpec/Parser.hs": "7fef1876c52d2204f852704150288910bb0c12bcde1d3159219ad25526a879b6",
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": "139de82d2d6063eb82fc86438a82e59d9b9315318009cad199f488bca14cb56d",
34
- "npm/core.wasm": "943abf2084a7395ef6c5441ef4fd93aa0d57e581a0e71723bf6187afa2c3b48c",
33
+ "wasm/lawspec-wasm.cabal": "098f1d0fc52575907312c95095846d1d4f6e7ace7cfbe0419a305b9fe8a75a92",
34
+ "npm/core.wasm": "0a4ce15cefcd866cd7dd584c32d683c7f8a46823104afcceba1298570883a88a",
35
35
  "npm/core_jsffi.js": "88d136efe92f7cff5758c8fec8d9b6bbc9707fe37741cebeeb415fe34ce3d72b",
36
36
  "npm/api.mjs": "d6df654600172131ac66a55b86876fc29cde2a76a0a63bbefff3183488a4837b",
37
- "npm/index.d.ts": "8003304d88dbb01a07bd3dbdc4d680a193db1ec323c5712b9860e166ca9cc94b"
37
+ "npm/index.d.ts": "85ad409ae09d243d3b3f21243d0784390d7b0209e2a09fc7c3b733e8dd2e8765"
38
38
  }
39
39
  }
package/core.wasm CHANGED
Binary file
@@ -16,12 +16,16 @@ law `itoa and then atoi yields a` is
16
16
  "representing an Int32 as Text must not change its value"
17
17
  end
18
18
 
19
- example `negative integer` is
19
+ example `negative integers use a minus sign and round-trip unchanged` is
20
20
  x = -42
21
+ expect itoa x = "-42"
22
+ expect atoi (itoa x) = -42
21
23
  end
22
24
 
23
- example `zero` is
25
+ example `zero renders as 0 and round-trips unchanged` is
24
26
  x = 0
27
+ expect itoa x = "0"
28
+ expect atoi (itoa x) = 0
25
29
  end
26
30
 
27
31
  references are
@@ -0,0 +1,24 @@
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 `all trailing slashes are removed in one pass` is
13
+ x = "https://example.com/path///"
14
+ expect canonicalize x = "https://example.com/path"
15
+ end
16
+ example `a URL without trailing slashes stays unchanged` is
17
+ x = "https://example.com/path"
18
+ expect canonicalize x = "https://example.com/path"
19
+ end
20
+ example `empty input remains empty` is
21
+ x = ""
22
+ expect canonicalize x = ""
23
+ end
24
+ end
@@ -15,11 +15,15 @@ law `decimal renderers agree` is
15
15
  rationale is
16
16
  "changing the formatting implementation must preserve its result"
17
17
  end
18
- example `negative integer` is
18
+ example `both renderers produce "-42"` is
19
19
  x = -42
20
+ expect render x = "-42"
21
+ expect referenceRender x = "-42"
20
22
  end
21
- example `zero` is
23
+ example `both renderers produce "0"` is
22
24
  x = 0
25
+ expect render x = "0"
26
+ expect referenceRender x = "0"
23
27
  end
24
28
  end
25
29
 
@@ -30,10 +34,14 @@ law `nonnegative clamps agree` is
30
34
  description is
31
35
  "{clamp} agrees with {referenceClamp} when clamping negative inputs to zero"
32
36
  end
33
- example `negative boundary` is
37
+ example `both clamps return 0 for -2147483648` is
34
38
  x = -2147483648
39
+ expect clamp x = 0
40
+ expect referenceClamp x = 0
35
41
  end
36
- example `positive boundary` is
42
+ example `both clamps return 2147483647 for 2147483647` is
37
43
  x = 2147483647
44
+ expect clamp x = 2147483647
45
+ expect referenceClamp x = 2147483647
38
46
  end
39
47
  end
@@ -0,0 +1,41 @@
1
+ unit example.mixed.inputs
2
+
3
+ normalize :: Text -> Text
4
+ identity :: Int32 -> Int32
5
+
6
+ law `text normalization reaches a fixed point with an unused integer input` is
7
+ definition is
8
+ `for all` (text :: Text) (number :: Int32) .
9
+ normalize text = normalize (normalize text)
10
+ end
11
+ example `text normalization and integer identity have explicit results` is
12
+ text = "Hello, World!"
13
+ number = -42
14
+ expect normalize text = "Hello,-World!"
15
+ expect identity number = -42
16
+ end
17
+ end
18
+
19
+ law `integer identity with an unused text input` is
20
+ definition is
21
+ `for all` (text :: Text) (number :: Int32) .
22
+ identity number = number
23
+ end
24
+ example `text normalization and integer identity have explicit results` is
25
+ text = "λ 😀"
26
+ number = 2147483647
27
+ expect normalize text = "λ-😀"
28
+ expect identity number = 2147483647
29
+ end
30
+ end
31
+
32
+ law `normalizing an empty literal` is
33
+ definition is
34
+ `for all` (text :: Text) .
35
+ normalize "" = ""
36
+ end
37
+ example `empty literal stays empty; quantified text is unused` is
38
+ text = "unused context"
39
+ expect normalize "" = ""
40
+ end
41
+ end
@@ -0,0 +1,33 @@
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 `spaces become hyphens; punctuation is preserved` is
14
+ x = "Hello, World!"
15
+ expect normalize x = "Hello,-World!"
16
+ expect referenceNormalize x = "Hello,-World!"
17
+ end
18
+ example `empty text remains empty` is
19
+ x = ""
20
+ expect normalize x = ""
21
+ expect referenceNormalize x = ""
22
+ end
23
+ example `Unicode is preserved while spaces become hyphens` is
24
+ x = "café 日本語 😀"
25
+ expect normalize x = "café-日本語-😀"
26
+ expect referenceNormalize x = "café-日本語-😀"
27
+ end
28
+ example `quotes, backslashes and controls survive space replacement` is
29
+ x = "quote: \" slash: \\ newline: \n tab: \t dollar: $"
30
+ expect normalize x = "quote:-\"-slash:-\\-newline:-\n-tab:-\t-dollar:-$"
31
+ expect referenceNormalize x = "quote:-\"-slash:-\\-newline:-\n-tab:-\t-dollar:-$"
32
+ end
33
+ 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,10 @@ 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 Expectation { actual: Expr; expected: number | string }
11
+ export interface Example { exampleName: string; bindings: [string, number | string][]; expectations: Expectation[] }
11
12
  export interface Law { lawName: string; parameters: [string, Type][]; requirements: Type[]; definition: Definition; description: string; rationale: string; examples: Example[]; references: string[]; location: Location }
12
13
  export interface Expanded { owner: string; name: string; inputs: {inputName: string; inputId: string; inputType: Type}[]; left: Expr; right: Expr; trace: string[]; original: Law }
13
14
  export interface CheckRequest { sources: Source[] }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lawspec",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "State the law once. Check it everywhere.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/starter.lawspec CHANGED
@@ -16,12 +16,16 @@ law `itoa and then atoi yields a` is
16
16
  "representing an Int32 as Text must not change its value"
17
17
  end
18
18
 
19
- example `negative integer` is
19
+ example `negative integers use a minus sign and round-trip unchanged` is
20
20
  x = -42
21
+ expect itoa x = "-42"
22
+ expect atoi (itoa x) = -42
21
23
  end
22
24
 
23
- example `zero` is
25
+ example `zero renders as 0 and round-trips unchanged` is
24
26
  x = 0
27
+ expect itoa x = "0"
28
+ expect atoi (itoa x) = 0
25
29
  end
26
30
 
27
31
  references are