bend-schema 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 +230 -0
- package/core/LAWS.bend +379 -0
- package/core/LICENSE +23 -0
- package/core/PROOF.bend +4527 -0
- package/core/core.bend +1585 -0
- package/dist-core/core.d.mts +188 -0
- package/dist-core/core.mjs +2974 -0
- package/package.json +41 -0
- package/src/codec.ts +177 -0
- package/src/gen-cli.ts +61 -0
- package/src/gen.ts +96 -0
- package/src/index.ts +350 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nohzafk
|
|
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,230 @@
|
|
|
1
|
+
# bend-schema
|
|
2
|
+
|
|
3
|
+
A schema library whose checker is **proved correct**, not just tested.
|
|
4
|
+
|
|
5
|
+
Define a schema once. Check JSON against it from TypeScript or from
|
|
6
|
+
[Bend](https://github.com/bendlang/bend), and get the first error with
|
|
7
|
+
its path. The checker is written in Bend, and its laws are machine-checked
|
|
8
|
+
for every schema and every value.
|
|
9
|
+
|
|
10
|
+
The npm package and the GitHub repository are `bend-schema`. The BendHub
|
|
11
|
+
package is `bend-schema-lib`.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun add bend-schema
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Use it with [Bun](https://bun.sh). The package ships TypeScript source, and
|
|
20
|
+
Node.js refuses to run TypeScript from `node_modules`. You do not need Bend
|
|
21
|
+
installed.
|
|
22
|
+
|
|
23
|
+
The `bend-schema` command emits Bend source, and it runs under Bun whether you
|
|
24
|
+
start it with `bunx` or `npx`: its shebang is `env bun`.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { s, type Infer } from "bend-schema";
|
|
30
|
+
|
|
31
|
+
const Plan = s.object({
|
|
32
|
+
name: s.str().len(1, 64),
|
|
33
|
+
seats: s.nat().in(1, 500),
|
|
34
|
+
tier: s.enum(["free", "pro"]),
|
|
35
|
+
email: s.str().refine((x) => x.includes("@"), "must be an email"),
|
|
36
|
+
note: s.str().optional(),
|
|
37
|
+
}).strict();
|
|
38
|
+
type Plan = Infer<typeof Plan>;
|
|
39
|
+
|
|
40
|
+
const r = Plan.parse(JSON.parse(body));
|
|
41
|
+
if (!r.ok) {
|
|
42
|
+
console.error(r.error.text("plan")); // "plan.seats: must be from 1 to 500"
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Every schema has three methods:
|
|
47
|
+
|
|
48
|
+
- `parse(value)` checks the value, then returns it typed.
|
|
49
|
+
- `check(value)` returns the first error, or `null`.
|
|
50
|
+
- `encode(value)` turns a typed value back into JSON-ready data.
|
|
51
|
+
|
|
52
|
+
An error has a `path`, a `message`, and `proved`. `proved: true` means the
|
|
53
|
+
error came from the verified core. `proved: false` means it came from your
|
|
54
|
+
own `.refine()` function, which runs only after the proved check passes.
|
|
55
|
+
|
|
56
|
+
## Schemas
|
|
57
|
+
|
|
58
|
+
| Builder | Accepts |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `s.nat()`, `.in(lo, hi)` | integer 0 to 2^48-1 |
|
|
61
|
+
| `s.str()`, `.len(lo, hi)` | string |
|
|
62
|
+
| `s.bool()`, `s.true()` | boolean, or only `true` |
|
|
63
|
+
| `s.enum([...])` | one of the given strings |
|
|
64
|
+
| `s.list(x)`, `.len(lo, hi)` | array of `x` |
|
|
65
|
+
| `s.tuple(a, b, ...)` | fixed-length array |
|
|
66
|
+
| `s.object({...})`, `.strict()` | object; `.strict()` refuses unknown keys |
|
|
67
|
+
| `s.tagged(key, {...})` | union chosen by a tag key |
|
|
68
|
+
| `s.oneKey({...})` | object with exactly one of the keys |
|
|
69
|
+
| `.optional()` | the object key may be absent |
|
|
70
|
+
| `.nullable()` | the value may be `null` |
|
|
71
|
+
| `.refine(fn, message)` | a custom check (not proved) |
|
|
72
|
+
|
|
73
|
+
See **[docs/schemas.md](https://github.com/nohzafk/bend-schema/blob/main/docs/schemas.md)**
|
|
74
|
+
for how to write schemas:
|
|
75
|
+
objects, unions, custom rules, error messages and encoding.
|
|
76
|
+
|
|
77
|
+
## Examples
|
|
78
|
+
|
|
79
|
+
Each example is a small runnable project with tests:
|
|
80
|
+
|
|
81
|
+
- [`examples/api-server`](https://github.com/nohzafk/bend-schema/tree/main/examples/api-server): an HTTP endpoint that
|
|
82
|
+
validates the request body and returns the error path in a 400.
|
|
83
|
+
- [`examples/config-loader`](https://github.com/nohzafk/bend-schema/tree/main/examples/config-loader): reads a JSON config
|
|
84
|
+
file and reports the first mistake in one line.
|
|
85
|
+
- [`examples/event-log`](https://github.com/nohzafk/bend-schema/tree/main/examples/event-log): an append-only JSON-lines log
|
|
86
|
+
that writes and reads with the same schema.
|
|
87
|
+
|
|
88
|
+
## Ask an AI agent to write a schema
|
|
89
|
+
|
|
90
|
+
New to bend-schema? Paste this into your coding agent to introduce it, then
|
|
91
|
+
work with the agent as usual:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
bend-schema is a TypeScript schema library like zod, whose checker is
|
|
95
|
+
written in Bend and proved correct. Read its README to learn what it is
|
|
96
|
+
and how to install it: https://github.com/nohzafk/bend-schema
|
|
97
|
+
|
|
98
|
+
If we decide to use it, docs/schemas.md in that repo explains how to
|
|
99
|
+
write schemas.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Use from Bend
|
|
103
|
+
|
|
104
|
+
Write a schema once in TypeScript, use it in a Bend function whose laws you
|
|
105
|
+
prove, and call that function from TypeScript again. The loop is:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
schema.ts ──gen──▶ schema.bend ──import──▶ core.bend ──bend-emit──▶ dist/core.mjs ──import──▶ app.ts
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**1. Export your schemas** from a TypeScript module:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// schema.ts
|
|
115
|
+
import { s } from "bend-schema";
|
|
116
|
+
|
|
117
|
+
export const Config = s.object({
|
|
118
|
+
name: s.str().len(1, 64),
|
|
119
|
+
seats: s.nat().in(1, 500),
|
|
120
|
+
}).strict();
|
|
121
|
+
|
|
122
|
+
export const schemas = { config: Config };
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**2. Generate Bend source.** The command executes `schema.ts`, so that file
|
|
126
|
+
should contain only schemas. It runs under Bun — Node cannot strip the types
|
|
127
|
+
from TypeScript in `node_modules`.
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
bunx bend-schema gen schema.ts schema.bend
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`schema.bend` defines `config_schema()` and imports the proved checker from
|
|
134
|
+
this package's `core/core.bend`.
|
|
135
|
+
|
|
136
|
+
**3. Use the schema in your own Bend core.** Import the checker with the same
|
|
137
|
+
path that `schema.bend` uses on its second line:
|
|
138
|
+
|
|
139
|
+
```bend
|
|
140
|
+
# core.bend
|
|
141
|
+
import Base
|
|
142
|
+
import ./node_modules/bend-schema/core/core.bend as S
|
|
143
|
+
import ./schema.bend as Sch
|
|
144
|
+
|
|
145
|
+
def config_ok(r: S.Raw) -> Bool:
|
|
146
|
+
S.conforms0(Sch.config_schema(), r)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Here you can state and prove laws about your own functions, and build on the
|
|
150
|
+
laws in `core/LAWS.bend`. Import `core/PROOF.bend` alongside them: its proofs
|
|
151
|
+
are what close them, and a file that imports `LAWS.bend` alone does not check.
|
|
152
|
+
The test suite proves it.
|
|
153
|
+
|
|
154
|
+
**4. Turn the core into a typed ES module** with
|
|
155
|
+
[bend-emit](https://github.com/nohzafk/bend-emit):
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
bun add -d github:nohzafk/bend-emit
|
|
159
|
+
bunx bend-emit core.bend dist # writes dist/core.mjs and dist/core.d.mts
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**5. Call it from TypeScript.** `toRaw` converts a JSON value into the
|
|
163
|
+
core's `Raw` input:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// app.ts
|
|
167
|
+
import { toRaw } from "bend-schema";
|
|
168
|
+
import { config_ok } from "./dist/core.mjs";
|
|
169
|
+
|
|
170
|
+
config_ok(toRaw({ name: "a", seats: 3 })); // true
|
|
171
|
+
config_ok(toRaw({ name: "a", seats: 999 })); // false
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Commit `dist/`: at run time your package needs neither Bend nor bend-emit.
|
|
175
|
+
|
|
176
|
+
Bend code can also plug in its own rules through a template parameter. The
|
|
177
|
+
laws hold for every rule, so you only need to prove what your rule means. See
|
|
178
|
+
`core/core.bend` for the full schema type and the rule interface.
|
|
179
|
+
|
|
180
|
+
## What is proved
|
|
181
|
+
|
|
182
|
+
The laws in `core/LAWS.bend` hold for every schema, every rule and every value:
|
|
183
|
+
|
|
184
|
+
- **Exact:** `check` reports no error if and only if the value conforms.
|
|
185
|
+
- **Accurate:** the reported path leads to a real error of the reported kind.
|
|
186
|
+
- **Round trip:** decoding an encoded value gives back the same value.
|
|
187
|
+
- **Encoding conforms:** what the encoder writes passes the check whenever each
|
|
188
|
+
enum value is one of its names and each bound holds of the value.
|
|
189
|
+
- Each combinator (enum, tuple, variant, tagged union, strict, bounds) has a
|
|
190
|
+
law that states what it accepts.
|
|
191
|
+
|
|
192
|
+
Not proved: the TypeScript builder, the conversion between JS values and the
|
|
193
|
+
core, and `.refine()` predicates. These are covered by tests.
|
|
194
|
+
|
|
195
|
+
Two things the TypeScript layer does not carry, because they are not Bend values
|
|
196
|
+
and the core never sees them: a non-plain object (a `Date`, `Map` or class
|
|
197
|
+
instance) is refused as not being JSON, and an object key named `__proto__` is
|
|
198
|
+
the JavaScript prototype, not a field.
|
|
199
|
+
|
|
200
|
+
## Limits
|
|
201
|
+
|
|
202
|
+
- **Size:** a value may contain at most 3072 array elements plus object keys
|
|
203
|
+
in total, counted across all nesting levels. A larger value is rejected with
|
|
204
|
+
a `TooLarge` error at the point where it goes over. This keeps the checker
|
|
205
|
+
within the stack.
|
|
206
|
+
- **Width:** an object may have at most 256 keys.
|
|
207
|
+
|
|
208
|
+
## Development
|
|
209
|
+
|
|
210
|
+
Development uses [Bun](https://bun.sh), Bend, and
|
|
211
|
+
[bend-emit](https://github.com/nohzafk/bend-emit) (a dev dependency), which
|
|
212
|
+
builds `dist-core/`.
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
sh test.sh # full gate: proofs, tests, types
|
|
216
|
+
# rebuild the compiled core
|
|
217
|
+
bunx bend-emit core/core.bend dist-core
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
| Path | Contents |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `core/` | the Bend checker, its laws and their proofs |
|
|
223
|
+
| `src/` | the TypeScript API, codec and Bend code generator |
|
|
224
|
+
| `dist-core/` | the compiled core (committed) |
|
|
225
|
+
| `examples/` | example projects |
|
|
226
|
+
| `tools/` | build and verification tools |
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
MIT
|
package/core/LAWS.bend
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import Base
|
|
2
|
+
import ./core.bend as C
|
|
3
|
+
|
|
4
|
+
# The laws of bend-schema, for every schema, every value and every rule:
|
|
5
|
+
# proved once, and every schema built from these combinators has them.
|
|
6
|
+
#
|
|
7
|
+
# check_exact check finds nothing exactly when the value conforms: it
|
|
8
|
+
# neither lets a bad value through nor refuses a good one.
|
|
9
|
+
# At an SRule the value must conform to the rule's schema,
|
|
10
|
+
# and the rule must report nothing.
|
|
11
|
+
# check_accurate what check reports is there: following its path, every
|
|
12
|
+
# value passed on the way conforms, and at the end the value
|
|
13
|
+
# is wrong in the way it says. At an SRule, the schema's
|
|
14
|
+
# shape was not the defect and the rule itself reported
|
|
15
|
+
# this error at the path it names.
|
|
16
|
+
# enum_accepts every name in an SEnum's list is accepted.
|
|
17
|
+
# enum_admits a string SEnum accepts is one of its names, and here is
|
|
18
|
+
# where. Together they pin in_names, which check and
|
|
19
|
+
# conforms share.
|
|
20
|
+
# variant_meaning for a chain of SVariant keys ending in SVEnd, a value
|
|
21
|
+
# conforms exactly when it is an object, exactly one of the
|
|
22
|
+
# keys is in it, and the value under that key conforms: a
|
|
23
|
+
# second reading by counting, which pins none_present.
|
|
24
|
+
# too_large_reported
|
|
25
|
+
# a node the codec refused to build is reported where it
|
|
26
|
+
# stands, with TooLarge: the one thing the core can be told
|
|
27
|
+
# about a size.
|
|
28
|
+
# sbool_meaning SBool accepts a boolean and nothing else.
|
|
29
|
+
# sstr_len_meaning
|
|
30
|
+
# SStrLen{lo, hi, s} accepts a string whose length is in the
|
|
31
|
+
# bounds and that also satisfies s -- str_len_in_meaning,
|
|
32
|
+
# restated for the constructor.
|
|
33
|
+
# snat_in_meaning SNatIn{lo, hi} accepts a number in the bounds --
|
|
34
|
+
# nat_in_meaning, restated for the constructor.
|
|
35
|
+
# A lo past hi is an empty range, not an error: nothing is in
|
|
36
|
+
# bounds, and each of these laws holds for every lo and hi.
|
|
37
|
+
# slist_len_meaning
|
|
38
|
+
# SListLen{lo, hi, s} accepts a list whose element count is
|
|
39
|
+
# in the bounds and that also satisfies s -- str_len_in_meaning
|
|
40
|
+
# over elements. A value that is not a list is not in bounds
|
|
41
|
+
# here either (the count is a list's), and it is the inner
|
|
42
|
+
# schema's to refuse, exactly as check reports it.
|
|
43
|
+
# soptional_meaning
|
|
44
|
+
# SOptional{inner} accepts an absent value (RMissing), and
|
|
45
|
+
# every other value exactly as the inner schema accepts it:
|
|
46
|
+
# the constructor adds absence and nothing else.
|
|
47
|
+
#
|
|
48
|
+
# bounds_meaning died with SBounds: the bounded list is billing's rule now,
|
|
49
|
+
# and its meaning is billing's law.
|
|
50
|
+
#
|
|
51
|
+
# Decided by the human with these laws (acl, 2026-09-24): an SVariant object
|
|
52
|
+
# with none of its keys, or with two of them, is an error, and a key's value
|
|
53
|
+
# is checked by its own schema (so {any: false} is refused by STrue).
|
|
54
|
+
#
|
|
55
|
+
# Decided by the human with these laws: a key the schema names must be
|
|
56
|
+
# present, and an optional value is written null; keys the schema does not
|
|
57
|
+
# name are ignored; the first error is depth first, by position. A rule runs
|
|
58
|
+
# only after the shape it refines holds, and its error's path is relative to
|
|
59
|
+
# the value it was given. (With a key repeated in an object, lookup reads the
|
|
60
|
+
# first: a JSON object has no repeated keys, and a value that repeats one is
|
|
61
|
+
# refused before any lookup is trusted -- repeated_key_refused.)
|
|
62
|
+
|
|
63
|
+
law check_exact:
|
|
64
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
65
|
+
for +s: C.Schema
|
|
66
|
+
for +r: C.Raw
|
|
67
|
+
for +prev: Maybe<&2, Nat>
|
|
68
|
+
{Maybe.is_none(&2, C.Err, C.check(~rule, s, r, prev)) == C.conforms(~rule, s, r, prev) : Bool}
|
|
69
|
+
|
|
70
|
+
law check_accurate:
|
|
71
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
72
|
+
for +s: C.Schema
|
|
73
|
+
for +r: C.Raw
|
|
74
|
+
for +prev: Maybe<&2, Nat>
|
|
75
|
+
for +path: List<&2, C.Step>
|
|
76
|
+
for +why: C.Why
|
|
77
|
+
for h: {C.check(~rule, s, r, prev) == Some{C.Err{path, why}} : Maybe<&2, C.Err>}
|
|
78
|
+
{C.defect(~rule, s, r, prev, path) == Some{why} : Maybe<&2, C.Why>}
|
|
79
|
+
|
|
80
|
+
law enum_accepts:
|
|
81
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
82
|
+
for +pre: List<&2, String>
|
|
83
|
+
for +n: String
|
|
84
|
+
for +post: List<&2, String>
|
|
85
|
+
{C.conforms(~rule, C.SEnum{List.append(&2, String, pre, n <> post)}, C.RStr{n}, None{}) == True{} : Bool}
|
|
86
|
+
|
|
87
|
+
def OneOf(ns: List<&2, String>, x: String) -> Type:
|
|
88
|
+
&pre: List<&2, String> -> &n: String -> &post: List<&2, String> ->
|
|
89
|
+
{ns == List.append(&2, String, pre, n <> post) : List<&2, String>} & {String.eq(x, n) == True{} : Bool}
|
|
90
|
+
|
|
91
|
+
law enum_admits:
|
|
92
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
93
|
+
for +ns: List<&2, String>
|
|
94
|
+
for +x: String
|
|
95
|
+
for h: {C.conforms(~rule, C.SEnum{ns}, C.RStr{x}, None{}) == True{} : Bool}
|
|
96
|
+
OneOf(ns, x)
|
|
97
|
+
|
|
98
|
+
law variant_meaning:
|
|
99
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
100
|
+
for +s: C.Schema
|
|
101
|
+
for +r: C.Raw
|
|
102
|
+
for hc: {C.is_chain(s) == True{} : Bool}
|
|
103
|
+
{C.conforms(~rule, s, r, None{}) == Bool.and(C.is_object(r), Bool.and(Nat.is_eq(C.count_present(s, r), 1n), C.present_conform(~rule, s, r))) : Bool}
|
|
104
|
+
|
|
105
|
+
# ---- a key the schema reads, taken twice ----
|
|
106
|
+
#
|
|
107
|
+
# A key the schema READS must appear at most once in the value: with the key
|
|
108
|
+
# repeated, lookup reads the first one and the second is unreachable, so the
|
|
109
|
+
# value says something the schema cannot mean, and it is refused. Three keys
|
|
110
|
+
# are read that way -- a field key (SField), a variant key (SVariant), and the
|
|
111
|
+
# tag key of a tagged chain (STagged, which is how the chain picks its case).
|
|
112
|
+
# All three are stated, at explicit constructors and nothing else: the value
|
|
113
|
+
# does not conform, and check reports RepeatedKey at that key's own name with
|
|
114
|
+
# no step after it. Nothing else in the gate can catch a key_once that is
|
|
115
|
+
# wrong: check_exact is a consistency law, which a key_once wrong in the same
|
|
116
|
+
# direction on both sides satisfies, and the mutant has key_err stop
|
|
117
|
+
# reporting, which leaves key_once itself untested.
|
|
118
|
+
#
|
|
119
|
+
# The tag key's test sits at the case the tag names, not at the chain, and the
|
|
120
|
+
# two halves of that are the same two as the other keys'. A value whose tag
|
|
121
|
+
# names no case is refused at the chain's end whatever its keys are, so there
|
|
122
|
+
# the key's own test would decide nothing; and a case is the only place it can
|
|
123
|
+
# sit, because the case is checked against the object with the tag key taken
|
|
124
|
+
# out (drop_key), which is the object a case's own schema is about.
|
|
125
|
+
#
|
|
126
|
+
# The other half of the same rule is wf's: a case's own schema may not read
|
|
127
|
+
# the tag key at all (core.bend, no_key). If it did, enc would write that key
|
|
128
|
+
# twice -- the tag, and then the case's own field -- and the host builds the
|
|
129
|
+
# object as a JS object, where the second write overwrites the first, so the
|
|
130
|
+
# value written could not be read back. wf refusing the schema is what keeps
|
|
131
|
+
# encode_conforms true.
|
|
132
|
+
def Refused(s: C.Schema, r: C.Raw, +p: List<&2, C.Step>, +w: C.Why) -> Type:
|
|
133
|
+
{C.conforms(~C.no_rule, s, r, None{}) == False{} : Bool} & {C.check(~C.no_rule, s, r, None{}) == Some{C.Err{p, w}} : Maybe<&2, C.Err>}
|
|
134
|
+
|
|
135
|
+
law repeated_key_refused:
|
|
136
|
+
Refused(C.SField{"a", C.SNat{}, C.SEnd{}}, C.RKey{"a", C.RNum{1n}, C.RKey{"a", C.RNum{2n}, C.REnd{}}}, C.AtField{0n, "a"} <> Nil{}, C.RepeatedKey{"a"}) & Refused(C.SVariant{"a", C.SNat{}, C.SVEnd{}}, C.RKey{"a", C.RNum{1n}, C.RKey{"a", C.RNum{2n}, C.REnd{}}}, C.AtField{0n, "a"} <> Nil{}, C.RepeatedKey{"a"})
|
|
137
|
+
|
|
138
|
+
# The tag key of a tagged chain, taken twice: the value's tag names the case,
|
|
139
|
+
# and the key it was read from is there twice, so the value is refused at that
|
|
140
|
+
# key's own name. The case's schema is SEnd{}, which reads no key and accepts
|
|
141
|
+
# any object, so what is decided here is the tag key's own test and nothing
|
|
142
|
+
# else -- the same test the two keys above are refused by.
|
|
143
|
+
law repeated_tag_refused:
|
|
144
|
+
Refused(C.STagged{"t", "a", C.SEnd{}, C.STagEnd{"t"}}, C.RKey{"t", C.RStr{"a"}, C.RKey{"t", C.RStr{"a"}, C.REnd{}}}, C.AtField{0n, "t"} <> Nil{}, C.RepeatedKey{"t"})
|
|
145
|
+
|
|
146
|
+
# ---- a key the schema does not name ----
|
|
147
|
+
#
|
|
148
|
+
# A key the schema does not name is ignored, so adding one to an object that
|
|
149
|
+
# already conforms leaves the answer alone -- before the keys the schema reads,
|
|
150
|
+
# or after them. Stated at explicit constructors, like repeated_key_refused,
|
|
151
|
+
# and that is the point of it (D3): the general form is FALSE. key_names names
|
|
152
|
+
# the keys of a chain and nothing else -- the SField and the SVariant it walks,
|
|
153
|
+
# not the wrappers a chain may sit in -- so its list says nothing about a
|
|
154
|
+
# wrapper, and a key outside that list can still be one the schema needs. The
|
|
155
|
+
# counterexample, which is why this law is at literals: with
|
|
156
|
+
# s = SOpt{SField{"a", SNat{}, SEnd{}}}, k = "a" and r = REnd{}, the premise
|
|
157
|
+
# Bool.not(in_names(k, key_names(s))) holds, because key_names(SOpt{i}) is Nil{}
|
|
158
|
+
# and Nil{} names no key at all, yet conforms(SOpt{i}, r) is False while
|
|
159
|
+
# conforms(SOpt{i}, RKey{"a", RNum{1}, REnd{}}) is True: the key is not extra
|
|
160
|
+
# at all, it is the one the wrapper's own schema asks for. A variant chain
|
|
161
|
+
# stands beside the record in the law because at literals it costs the same
|
|
162
|
+
# computation: none_present over a concrete chain reduces, and no skip lemma is
|
|
163
|
+
# wanted for it.
|
|
164
|
+
#
|
|
165
|
+
# What is deliberately not here. The reordering half of "non-canonical still
|
|
166
|
+
# decodes" is unproved: no def in the core reorders an object's keys -- lookup,
|
|
167
|
+
# drop_key, has_key and no_extra read them, and none of them moves one -- so a
|
|
168
|
+
# permuted value has nothing here to be an instance of. And a key the schema
|
|
169
|
+
# does not name that appears TWICE is left alone as well (core.bend, "a key the
|
|
170
|
+
# schema does not name"): a key nothing reads is unobservable however often it
|
|
171
|
+
# is written, so it is not refused, unlike the repeated key of
|
|
172
|
+
# repeated_key_refused. Neither is stated as a law: the first has no code to be
|
|
173
|
+
# false about, the second is unobservable by construction.
|
|
174
|
+
|
|
175
|
+
law unnamed_key_ignored:
|
|
176
|
+
{C.conforms(~C.no_rule, C.SField{"a", C.SNat{}, C.SEnd{}}, C.RKey{"z", C.RNum{9n}, C.RKey{"a", C.RNum{1n}, C.REnd{}}}, None{}) == C.conforms(~C.no_rule, C.SField{"a", C.SNat{}, C.SEnd{}}, C.RKey{"a", C.RNum{1n}, C.REnd{}}, None{}) : Bool} &
|
|
177
|
+
{C.conforms(~C.no_rule, C.SField{"a", C.SNat{}, C.SEnd{}}, C.RKey{"a", C.RNum{1n}, C.RKey{"z", C.RNum{9n}, C.REnd{}}}, None{}) == C.conforms(~C.no_rule, C.SField{"a", C.SNat{}, C.SEnd{}}, C.RKey{"a", C.RNum{1n}, C.REnd{}}, None{}) : Bool} &
|
|
178
|
+
{C.conforms(~C.no_rule, C.SVariant{"a", C.SNat{}, C.SVEnd{}}, C.RKey{"z", C.RNum{9n}, C.RKey{"a", C.RNum{1n}, C.REnd{}}}, None{}) == C.conforms(~C.no_rule, C.SVariant{"a", C.SNat{}, C.SVEnd{}}, C.RKey{"a", C.RNum{1n}, C.REnd{}}, None{}) : Bool}
|
|
179
|
+
|
|
180
|
+
# A tuple conforms exactly when the value is a list, as long as the tuple,
|
|
181
|
+
# and each position conforms. Without it, a bug shared by check and conforms
|
|
182
|
+
# (both skipping a position) passes the two laws above.
|
|
183
|
+
law tuple_meaning:
|
|
184
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
185
|
+
for +ss: List<&2, C.Schema>
|
|
186
|
+
for +r: C.Raw
|
|
187
|
+
{C.conforms(~rule, C.tuple_of(ss), r, None{}) == Bool.and(C.raw_list(r), Bool.and(Nat.is_eq(C.raw_len(r), List.length(&2, C.Schema, ss)), C.each_pos(~rule, ss, r, 0n))) : Bool}
|
|
188
|
+
|
|
189
|
+
# ---- a strict object ----
|
|
190
|
+
#
|
|
191
|
+
# SStrict{s} is s, and no key but the ones s names. The right side counts
|
|
192
|
+
# the unknown keys (count_unknown), a second walk that shares only in_names
|
|
193
|
+
# with the core, and in_names is pinned by the enum laws.
|
|
194
|
+
|
|
195
|
+
law strict_meaning:
|
|
196
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
197
|
+
for +s: C.Schema
|
|
198
|
+
for +r: C.Raw
|
|
199
|
+
{C.conforms(~rule, C.SStrict{s}, r, None{}) == Bool.and(C.conforms(~rule, s, r, None{}), Nat.is_eq(C.count_unknown(C.key_names(s), r), 0n)) : Bool}
|
|
200
|
+
|
|
201
|
+
# ---- a tagged union ----
|
|
202
|
+
#
|
|
203
|
+
# tagged_meaning: at a case, a value whose tag is the case's name conforms
|
|
204
|
+
# exactly when the tag key is read once AND the object without the tag
|
|
205
|
+
# conforms to the case; any other value is the rest's to decide -- and there
|
|
206
|
+
# the value goes to the rest whatever its keys, so a repeated tag key is the
|
|
207
|
+
# case's business and not the chain's. At the chain's end nothing conforms.
|
|
208
|
+
# drop_meaning pins drop_key, which conforms, check and dec share: what it
|
|
209
|
+
# leaves under k is what was under k after the first one (so a single tag
|
|
210
|
+
# key is gone), and every other key reads as before.
|
|
211
|
+
|
|
212
|
+
law tagged_meaning:
|
|
213
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
214
|
+
for +k: String
|
|
215
|
+
for +n: String
|
|
216
|
+
for +cs: C.Schema
|
|
217
|
+
for +rest: C.Schema
|
|
218
|
+
for +r: C.Raw
|
|
219
|
+
{C.conforms(~rule, C.STagged{k, n, cs, rest}, r, None{}) == C.pick_bool(C.is_tag(k, n, r), Bool.and(C.key_once(k, r), C.conforms(~rule, cs, C.drop_key(k, r), None{})), C.conforms(~rule, rest, r, None{})) : Bool}
|
|
220
|
+
|
|
221
|
+
law tag_end_refuses:
|
|
222
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
223
|
+
for +k: String
|
|
224
|
+
for +r: C.Raw
|
|
225
|
+
{C.conforms(~rule, C.STagEnd{k}, r, None{}) == False{} : Bool}
|
|
226
|
+
|
|
227
|
+
law drop_first:
|
|
228
|
+
for +k: String
|
|
229
|
+
for +v: C.Raw
|
|
230
|
+
for +o: C.Raw
|
|
231
|
+
{C.drop_key(k, C.RKey{k, v, o}) == o : C.Raw}
|
|
232
|
+
|
|
233
|
+
law drop_other:
|
|
234
|
+
for +k: String
|
|
235
|
+
for +j: String
|
|
236
|
+
for +r: C.Raw
|
|
237
|
+
for h: {False{} == String.eq(k, j) : Bool}
|
|
238
|
+
{C.lookup(j, C.drop_key(k, r)) == C.lookup(j, r) : C.Raw}
|
|
239
|
+
|
|
240
|
+
# ---- ready-made rules ----
|
|
241
|
+
#
|
|
242
|
+
# Each pins its rule to what it means: on the value it is about, it reports
|
|
243
|
+
# nothing exactly when the value is in the bounds (both ends included).
|
|
244
|
+
|
|
245
|
+
law str_len_in_meaning:
|
|
246
|
+
for +lo: Nat
|
|
247
|
+
for +hi: Nat
|
|
248
|
+
for +x: String
|
|
249
|
+
{Maybe.is_none(&2, C.Err, C.str_len_in(lo, hi, C.RStr{x})) == Bool.and(Nat.is_le(lo, String.length(x)), Nat.is_le(String.length(x), hi)) : Bool}
|
|
250
|
+
|
|
251
|
+
law nat_in_meaning:
|
|
252
|
+
for +lo: Nat
|
|
253
|
+
for +hi: Nat
|
|
254
|
+
for +n: Nat
|
|
255
|
+
{Maybe.is_none(&2, C.Err, C.nat_in(lo, hi, C.RNum{n})) == Bool.and(Nat.is_le(lo, n), Nat.is_le(n, hi)) : Bool}
|
|
256
|
+
|
|
257
|
+
# ---- a bound is a constructor ----
|
|
258
|
+
#
|
|
259
|
+
# Approved with these constructors (D4): a bound belongs to the schema, not to
|
|
260
|
+
# a rule's tag, so a host can build "a number from 1 to 6" or "a string of 1 to
|
|
261
|
+
# 64 characters" without a Bend program. Each one keeps the meaning of the rule
|
|
262
|
+
# it comes from -- str_len_in_meaning and nat_in_meaning restated -- and adds a
|
|
263
|
+
# shape of its own. lo > hi is an empty range: nothing is in bounds, and the
|
|
264
|
+
# laws still hold for every lo and hi (nothing in the core refuses the schema
|
|
265
|
+
# itself, it just accepts no value).
|
|
266
|
+
#
|
|
267
|
+
# Every other law covers them too: check_exact, check_accurate,
|
|
268
|
+
# too_large_reported, decode_encode, checked_decodes and encode_conforms are
|
|
269
|
+
# stated for every schema, so the new constructors are cases of them (see
|
|
270
|
+
# encode_conforms: a value outside a bound is written out unchanged, which is
|
|
271
|
+
# why it is a premise of that law rather than something the encoder can know).
|
|
272
|
+
|
|
273
|
+
# SBool accepts a boolean and nothing else: any b, and no other value.
|
|
274
|
+
law sbool_meaning:
|
|
275
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
276
|
+
for +r: C.Raw
|
|
277
|
+
{C.conforms(~rule, C.SBool{}, r, None{}) == C.is_bool(r) : Bool}
|
|
278
|
+
|
|
279
|
+
# SStrLen{lo, hi, s} accepts a string whose length is in the bounds, and that
|
|
280
|
+
# also satisfies s: the length part is str_len_in_meaning's, restated for the
|
|
281
|
+
# constructor (both ends included).
|
|
282
|
+
law sstr_len_meaning:
|
|
283
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
284
|
+
for +lo: Nat
|
|
285
|
+
for +hi: Nat
|
|
286
|
+
for +s: C.Schema
|
|
287
|
+
for +x: String
|
|
288
|
+
{C.conforms(~rule, C.SStrLen{lo, hi, s}, C.RStr{x}, None{}) == Bool.and(C.conforms(~rule, s, C.RStr{x}, None{}), Bool.and(Nat.is_le(lo, String.length(x)), Nat.is_le(String.length(x), hi))) : Bool}
|
|
289
|
+
|
|
290
|
+
# SNatIn{lo, hi} accepts a number in the bounds, both ends included:
|
|
291
|
+
# nat_in_meaning's, restated for the constructor.
|
|
292
|
+
law snat_in_meaning:
|
|
293
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
294
|
+
for +lo: Nat
|
|
295
|
+
for +hi: Nat
|
|
296
|
+
for +n: Nat
|
|
297
|
+
{C.conforms(~rule, C.SNatIn{lo, hi}, C.RNum{n}, None{}) == Bool.and(Nat.is_le(lo, n), Nat.is_le(n, hi)) : Bool}
|
|
298
|
+
|
|
299
|
+
# SListLen{lo, hi, s} accepts a list whose element count is in the bounds, and
|
|
300
|
+
# that also satisfies s. The count is spelled out here rather than left to the
|
|
301
|
+
# helper the core reads, so that a bound off by one is the law's business too:
|
|
302
|
+
# a list of exactly lo or exactly hi elements is in, and one of any other count
|
|
303
|
+
# is not, for every lo and hi (lo > hi accepts no list at all). A value that is
|
|
304
|
+
# not a list is not in bounds, and the inner schema decides it.
|
|
305
|
+
law slist_len_meaning:
|
|
306
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
307
|
+
for +lo: Nat
|
|
308
|
+
for +hi: Nat
|
|
309
|
+
for +s: C.Schema
|
|
310
|
+
for +r: C.Raw
|
|
311
|
+
{C.conforms(~rule, C.SListLen{lo, hi, s}, r, None{}) == Bool.and(Bool.and(C.raw_list(r), Bool.and(Nat.is_le(lo, C.raw_len(r)), Nat.is_le(C.raw_len(r), hi))), C.conforms(~rule, s, r, None{})) : Bool}
|
|
312
|
+
|
|
313
|
+
# SOptional{inner} accepts an absent value, and every other value exactly as
|
|
314
|
+
# the inner schema accepts it: the constructor adds absence and nothing else.
|
|
315
|
+
# enc writes None as RMissing (no key at all, in the host's JSON) and dec reads
|
|
316
|
+
# RMissing back as None; null is not absent, so it is refused unless the inner
|
|
317
|
+
# is nullable itself.
|
|
318
|
+
law soptional_meaning:
|
|
319
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
320
|
+
for +i: C.Schema
|
|
321
|
+
for +r: C.Raw
|
|
322
|
+
{C.conforms(~rule, C.SOptional{i}, r, None{}) == Bool.or(C.is_missing(r), C.conforms(~rule, i, r, None{})) : Bool}
|
|
323
|
+
|
|
324
|
+
# ---- a value too big to walk ----
|
|
325
|
+
|
|
326
|
+
# The codec refuses to build a value past its budget and puts one RTooBig in
|
|
327
|
+
# place of the list or object that ran past it (codec.ts); nothing else in the
|
|
328
|
+
# core knows a size. So the claim about that node is local: it conforms to
|
|
329
|
+
# nothing, and the walk that reaches it reports it right there (the empty
|
|
330
|
+
# path, for the value it was given) with TooLarge -- never the schema kind's
|
|
331
|
+
# own reason, which would say the host sent the wrong shape when what it sent
|
|
332
|
+
# was too much of the right one. A node further down is reported one step
|
|
333
|
+
# along by check's own step machinery, which check_accurate covers, and the
|
|
334
|
+
# codec's budget (that a value inside it converts unchanged) is a run-time
|
|
335
|
+
# test, since the codec is TypeScript.
|
|
336
|
+
law too_large_reported:
|
|
337
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
338
|
+
for +s: C.Schema
|
|
339
|
+
for +prev: Maybe<&2, Nat>
|
|
340
|
+
{C.check(~rule, s, C.RTooBig{}, prev) == Some{C.Err{Nil{}, C.TooLarge{}}} : Maybe<&2, C.Err>}
|
|
341
|
+
|
|
342
|
+
# ---- a schema's meaning: the decoder, derived from the schema ----
|
|
343
|
+
#
|
|
344
|
+
# Meaning, enc and dec are written once for every schema (core.bend), so a
|
|
345
|
+
# project stops writing its reader and its round trip. The three laws below
|
|
346
|
+
# were approved with the design: wf is a premise (a schema is the
|
|
347
|
+
# programmer's, not the wire's), and an enum means its string.
|
|
348
|
+
|
|
349
|
+
# What was written reads back as itself.
|
|
350
|
+
law decode_encode:
|
|
351
|
+
for +s: C.Schema
|
|
352
|
+
for +x: C.Meaning(s)
|
|
353
|
+
for hw: {C.wf(s) == True{} : Bool}
|
|
354
|
+
{C.dec(s, C.enc(s, x)) == Some{x} : Maybe<&2, C.Meaning(s)>}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# A value check accepts can be read: a project's reader needs no
|
|
358
|
+
# "cannot happen" default.
|
|
359
|
+
law checked_decodes:
|
|
360
|
+
for ~rule: Nat -> C.Raw -> Maybe<&2, C.Err>
|
|
361
|
+
for +s: C.Schema
|
|
362
|
+
for +r: C.Raw
|
|
363
|
+
for hc: {C.conforms(~rule, s, r, None{}) == True{} : Bool}
|
|
364
|
+
{Maybe.is_some(&2, C.Meaning(s), C.dec(s, r)) == True{} : Bool}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# What is written is accepted by the schema (with no rule: a rule is the
|
|
368
|
+
# project's, and not the encoder's to satisfy), as long as each enum value is
|
|
369
|
+
# one of its names and each bound a constructor states holds of the value. A
|
|
370
|
+
# bound is a subset of the encoding's own shape, so the encoder -- which
|
|
371
|
+
# writes the meaning unchanged -- can write a value outside one; the host that
|
|
372
|
+
# built that value is the one at fault, and check refuses it on the way back.
|
|
373
|
+
law encode_conforms:
|
|
374
|
+
for +s: C.Schema
|
|
375
|
+
for +x: C.Meaning(s)
|
|
376
|
+
for hw: {C.wf(s) == True{} : Bool}
|
|
377
|
+
for hn: {C.names_ok(s, x) == True{} : Bool}
|
|
378
|
+
for hb: {C.bounds_ok(s, x) == True{} : Bool}
|
|
379
|
+
{C.conforms(~C.no_rule, s, C.enc(s, x), None{}) == True{} : Bool}
|
package/core/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2026 nohzafk
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|