@weipertda/sigiljs 0.0.3 → 0.2.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 +157 -155
- package/docs/README.md +20 -0
- package/docs/arrays.md +55 -0
- package/docs/cli.md +48 -0
- package/docs/compiled-validators.md +42 -0
- package/docs/contributing.md +43 -0
- package/docs/exact-mode.md +82 -0
- package/docs/examples.md +92 -0
- package/docs/introduction.md +29 -0
- package/docs/license.md +23 -0
- package/docs/named-sigils.md +93 -0
- package/docs/objects.md +44 -0
- package/docs/optional.md +48 -0
- package/docs/quickstart.md +65 -0
- package/docs/realtype.md +36 -0
- package/docs/roadmap.md +40 -0
- package/docs/sigils.md +94 -0
- package/docs/{functions.md,maps.md,sets.md,tuples.md,intersection.md,rest.md,examples.md,performance.md,contributing.md,license.md +0 -0
- package/examples/api-response.js +21 -0
- package/examples/api-response.md +27 -0
- package/examples/api-validation.js +0 -0
- package/examples/api-validation.md +0 -0
- package/examples/basic-user.js +16 -0
- package/examples/basic-user.md +22 -0
- package/examples/basic-validation.js +0 -0
- package/examples/basic-validation.md +0 -0
- package/examples/config-file.md +16 -0
- package/examples/config-validation.js +18 -0
- package/examples/config-validation.md +24 -0
- package/examples/login-request.js +19 -0
- package/examples/login-request.md +25 -0
- package/examples/nested-order.js +28 -0
- package/examples/nested-order.md +34 -0
- package/examples/realtype-demo.js +6 -0
- package/examples/realtype-demo.md +12 -0
- package/examples/scripts/config-file.js +0 -0
- package/package.json +14 -6
- package/src/core/assert.js +173 -50
- package/src/core/compile.js +43 -9
- package/src/core/errors.js +29 -6
- package/src/core/normalize.js +1 -1
- package/src/core/parser.js +4 -3
- package/src/core/partial.js +1 -0
- package/src/core/registry.js +33 -0
- package/src/core/validate.js +3 -0
- package/src/index.js +1 -0
- package/src/playground/playground.js +1 -1
- package/src/sigil.js +56 -16
- package/CHANGELOG.md +0 -28
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# [examples/basic-user.js](https://github.dev/weipertda/sigiljs/blob/main/examples/basic-user.js)
|
|
2
|
+
|
|
3
|
+
```javascript
|
|
4
|
+
|
|
5
|
+
import { Sigil } from "../src/index.js"
|
|
6
|
+
|
|
7
|
+
const User = Sigil`
|
|
8
|
+
{
|
|
9
|
+
name: string
|
|
10
|
+
age?: number
|
|
11
|
+
tags: string[]
|
|
12
|
+
}
|
|
13
|
+
`
|
|
14
|
+
|
|
15
|
+
const data = {
|
|
16
|
+
name: "Alex",
|
|
17
|
+
tags: ["js", "bun"]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log(User.check(data))
|
|
21
|
+
|
|
22
|
+
```
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Sigil } from '../src/index.js'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
|
|
4
|
+
const Config = Sigil`
|
|
5
|
+
{
|
|
6
|
+
port: number
|
|
7
|
+
host: string
|
|
8
|
+
debug?: boolean
|
|
9
|
+
}
|
|
10
|
+
`
|
|
11
|
+
|
|
12
|
+
const config = JSON.parse(
|
|
13
|
+
fs.readFileSync("./config.json", "utf8")
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
Config.assert(config)
|
|
17
|
+
|
|
18
|
+
console.log("Config valid")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# [examples/config-validation.js](https://github.dev/weipertda/sigiljs/blob/main/examples/scripts/config-validation.js)
|
|
2
|
+
|
|
3
|
+
```javascript
|
|
4
|
+
|
|
5
|
+
import { Sigil } from "../src/index.js"
|
|
6
|
+
import fs from "fs"
|
|
7
|
+
|
|
8
|
+
const Config = Sigil`
|
|
9
|
+
{
|
|
10
|
+
port: number
|
|
11
|
+
host: string
|
|
12
|
+
debug?: boolean
|
|
13
|
+
}
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
const config = JSON.parse(
|
|
17
|
+
fs.readFileSync("./config.json", "utf8")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
Config.assert(config)
|
|
21
|
+
|
|
22
|
+
console.log("Config valid")
|
|
23
|
+
|
|
24
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Sigil } from '../src/index.js'
|
|
2
|
+
|
|
3
|
+
const LoginRequest = Sigil`
|
|
4
|
+
{
|
|
5
|
+
email: string
|
|
6
|
+
password: string
|
|
7
|
+
}
|
|
8
|
+
`
|
|
9
|
+
|
|
10
|
+
function login(body) {
|
|
11
|
+
LoginRequest.assert(body)
|
|
12
|
+
|
|
13
|
+
console.log("Login request valid")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
login({
|
|
17
|
+
email: "hello@example.com",
|
|
18
|
+
password: "secret"
|
|
19
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# [examples/login-request.js](https://github.dev/weipertda/sigiljs/blob/main/examples/scripts/login-request.js)
|
|
2
|
+
|
|
3
|
+
```javascript
|
|
4
|
+
|
|
5
|
+
import { Sigil } from "../src/index.js"
|
|
6
|
+
|
|
7
|
+
const LoginRequest = Sigil`
|
|
8
|
+
{
|
|
9
|
+
email: string
|
|
10
|
+
password: string
|
|
11
|
+
}
|
|
12
|
+
`
|
|
13
|
+
|
|
14
|
+
function login(body) {
|
|
15
|
+
LoginRequest.assert(body)
|
|
16
|
+
|
|
17
|
+
console.log("Login request valid")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
login({
|
|
21
|
+
email: "hello@example.com",
|
|
22
|
+
password: "secret"
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Sigil } from '../src/index.js'
|
|
2
|
+
|
|
3
|
+
const Order = Sigil`
|
|
4
|
+
{
|
|
5
|
+
id: string
|
|
6
|
+
customer: {
|
|
7
|
+
name: string
|
|
8
|
+
email: string
|
|
9
|
+
}
|
|
10
|
+
items: {
|
|
11
|
+
name: string
|
|
12
|
+
price: number
|
|
13
|
+
}[]
|
|
14
|
+
}
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
const order = {
|
|
18
|
+
id: "123",
|
|
19
|
+
customer: {
|
|
20
|
+
name: "Alex",
|
|
21
|
+
email: "alex@example.com"
|
|
22
|
+
},
|
|
23
|
+
items: [
|
|
24
|
+
{ name: "Keyboard", price: 99 }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log(Order.check(order))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# [examples/nested-order.js](https://github.dev/weipertda/sigiljs/blob/main/examples/scripts/nested-order.js)
|
|
2
|
+
|
|
3
|
+
```javascript
|
|
4
|
+
|
|
5
|
+
import { Sigil } from "../src/index.js"
|
|
6
|
+
|
|
7
|
+
const Order = Sigil`
|
|
8
|
+
{
|
|
9
|
+
id: string
|
|
10
|
+
customer: {
|
|
11
|
+
name: string
|
|
12
|
+
email: string
|
|
13
|
+
}
|
|
14
|
+
items: {
|
|
15
|
+
name: string
|
|
16
|
+
price: number
|
|
17
|
+
}[]
|
|
18
|
+
}
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
const order = {
|
|
22
|
+
id: "123",
|
|
23
|
+
customer: {
|
|
24
|
+
name: "Alex",
|
|
25
|
+
email: "alex@example.com"
|
|
26
|
+
},
|
|
27
|
+
items: [
|
|
28
|
+
{ name: "Keyboard", price: 99 }
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(Order.check(order))
|
|
33
|
+
|
|
34
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# [examples/realtype-demo.js](https://github.dev/weipertda/sigiljs/blob/main/examples/scripts/realtype-demo.js)
|
|
2
|
+
|
|
3
|
+
```javascript
|
|
4
|
+
|
|
5
|
+
import { realType } from "../src/index.js"
|
|
6
|
+
|
|
7
|
+
console.log(realType([]))
|
|
8
|
+
console.log(realType(null))
|
|
9
|
+
console.log(realType(new Map()))
|
|
10
|
+
console.log(realType(new Date()))
|
|
11
|
+
|
|
12
|
+
```
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weipertda/sigiljs",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Runtime type sigils for JavaScript",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Runtime-native type sigils for JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"types",
|
|
7
7
|
"validation",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/
|
|
18
|
+
"url": "git+https://github.com/antistructured/sigiljs.git"
|
|
19
19
|
},
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"author": {
|
|
@@ -29,18 +29,26 @@
|
|
|
29
29
|
},
|
|
30
30
|
"main": "./src/index.js",
|
|
31
31
|
"files": [
|
|
32
|
-
"src
|
|
32
|
+
"src",
|
|
33
33
|
"README.md",
|
|
34
34
|
"LICENSE",
|
|
35
|
-
"
|
|
35
|
+
"docs",
|
|
36
|
+
"examples"
|
|
36
37
|
],
|
|
37
38
|
"scripts": {
|
|
38
39
|
"format": "prettier --write src/",
|
|
39
40
|
"lint": "eslint src/",
|
|
40
|
-
"test": "bun test"
|
|
41
|
+
"test": "bun test",
|
|
42
|
+
"bench": "bun run bench/validate-user.js"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
41
46
|
},
|
|
42
47
|
"engines": {
|
|
43
48
|
"bun": ">=1",
|
|
44
49
|
"node": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"zod": "^4.4.3"
|
|
45
53
|
}
|
|
46
54
|
}
|
package/src/core/assert.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validate } from './validate.js';
|
|
2
2
|
import { SigilValidationError } from './errors.js';
|
|
3
3
|
import { realType } from './realType.js';
|
|
4
|
+
import { resolve } from './registry.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Validates a value against a schema and throws a structured `SigilValidationError`
|
|
@@ -13,19 +14,37 @@ import { realType } from './realType.js';
|
|
|
13
14
|
* @throws {SigilValidationError}
|
|
14
15
|
*/
|
|
15
16
|
export function assert(astOrSigil, value, opts) {
|
|
16
|
-
|
|
17
|
+
try {
|
|
18
|
+
if (validate(astOrSigil, value, opts)) return true;
|
|
19
|
+
} catch (e) {
|
|
20
|
+
// validate() may throw when a lazy identifier resolver can't find a named sigil.
|
|
21
|
+
// Re-wrap as a structured SigilValidationError so callers always get a consistent type.
|
|
22
|
+
throw new SigilValidationError(
|
|
23
|
+
e.message ?? 'Validation failed',
|
|
24
|
+
[],
|
|
25
|
+
'unknown',
|
|
26
|
+
realType(value, opts),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
17
29
|
|
|
18
30
|
const ast = astOrSigil.normalized ?? astOrSigil.ast ?? astOrSigil;
|
|
19
31
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
let err;
|
|
33
|
+
try {
|
|
34
|
+
err = findError(ast, value, opts, []);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// findError's identifier case may also throw for unresolvable names.
|
|
23
37
|
throw new SigilValidationError(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
e.message ?? 'Validation failed',
|
|
39
|
+
[],
|
|
40
|
+
'unknown',
|
|
41
|
+
realType(value, opts),
|
|
28
42
|
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (err) {
|
|
46
|
+
throw new SigilValidationError(err.message, err.path, err.expected, err.actual);
|
|
47
|
+
}
|
|
29
48
|
|
|
30
49
|
// Generic fallback (should rarely be reached)
|
|
31
50
|
throw new SigilValidationError(
|
|
@@ -36,33 +55,72 @@ export function assert(astOrSigil, value, opts) {
|
|
|
36
55
|
);
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
// Helpers
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns a human-readable type string for any AST node.
|
|
64
|
+
* Used in error messages as the "expected" description.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} ast
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function describeType(ast) {
|
|
70
|
+
if (!ast) return 'unknown';
|
|
71
|
+
switch (ast.kind) {
|
|
72
|
+
case 'primitive':
|
|
73
|
+
return ast.name;
|
|
74
|
+
case 'literal':
|
|
75
|
+
return JSON.stringify(ast.value);
|
|
76
|
+
case 'literal_union':
|
|
77
|
+
return ast.values.map((v) => JSON.stringify(v)).join(' | ');
|
|
78
|
+
case 'primitive_union':
|
|
79
|
+
return ast.names.join(' | ');
|
|
80
|
+
case 'union':
|
|
81
|
+
return ast.members.map(describeType).join(' | ');
|
|
82
|
+
case 'array':
|
|
83
|
+
return `${describeType(ast.element)}[]`;
|
|
84
|
+
case 'optional':
|
|
85
|
+
return `${describeType(ast.inner)}?`;
|
|
86
|
+
case 'object':
|
|
87
|
+
return 'object';
|
|
88
|
+
case 'identifier':
|
|
89
|
+
return ast.name;
|
|
90
|
+
default:
|
|
91
|
+
return ast.kind ?? 'unknown';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// Core error-finding walker
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
39
99
|
/**
|
|
40
|
-
* Recursively walks the AST to find the deepest
|
|
41
|
-
*
|
|
100
|
+
* Recursively walks the AST to find the deepest, most-specific failure point.
|
|
101
|
+
*
|
|
102
|
+
* Returns a structured error descriptor, or null if no specific failure is found.
|
|
42
103
|
*
|
|
43
104
|
* @param {object} ast
|
|
44
105
|
* @param {*} value
|
|
45
106
|
* @param {object} opts
|
|
46
|
-
* @param {string[]} path -
|
|
107
|
+
* @param {string[]} path - Accumulated property path (e.g. ["user", "address", "zip"])
|
|
47
108
|
* @returns {{ message: string, path: string[], expected: string, actual: string } | null}
|
|
48
109
|
*/
|
|
49
110
|
function findError(ast, value, opts, path) {
|
|
50
111
|
if (!ast) return null;
|
|
51
112
|
|
|
52
113
|
switch (ast.kind) {
|
|
114
|
+
// ── Primitive ────────────────────────────────────────────────────────────
|
|
53
115
|
case 'primitive': {
|
|
54
116
|
const p = ast.name;
|
|
55
117
|
if (p === 'any' || p === 'unknown') return null;
|
|
118
|
+
|
|
56
119
|
if (p === 'never') {
|
|
57
120
|
const actual = realType(value, opts);
|
|
58
|
-
return {
|
|
59
|
-
message: `Expected never, got ${actual}`,
|
|
60
|
-
path,
|
|
61
|
-
expected: 'never',
|
|
62
|
-
actual,
|
|
63
|
-
};
|
|
121
|
+
return { message: `Expected never, got ${actual}`, path, expected: 'never', actual };
|
|
64
122
|
}
|
|
65
|
-
|
|
123
|
+
|
|
66
124
|
if (
|
|
67
125
|
p === 'string' ||
|
|
68
126
|
p === 'number' ||
|
|
@@ -71,29 +129,34 @@ function findError(ast, value, opts, path) {
|
|
|
71
129
|
p === 'bigint' ||
|
|
72
130
|
p === 'undefined'
|
|
73
131
|
) {
|
|
74
|
-
const
|
|
75
|
-
return
|
|
76
|
-
{ message: `Expected ${p}, got ${
|
|
132
|
+
const actual = typeof value;
|
|
133
|
+
return actual !== p ?
|
|
134
|
+
{ message: `Expected ${p}, got ${actual}`, path, expected: p, actual }
|
|
77
135
|
: null;
|
|
78
136
|
}
|
|
137
|
+
|
|
79
138
|
const actual = realType(value, opts);
|
|
80
139
|
return actual !== p ?
|
|
81
140
|
{ message: `Expected ${p}, got ${actual}`, path, expected: p, actual }
|
|
82
141
|
: null;
|
|
83
142
|
}
|
|
84
143
|
|
|
144
|
+
// ── Literal ───────────────────────────────────────────────────────────────
|
|
85
145
|
case 'literal': {
|
|
86
146
|
if (value !== ast.value) {
|
|
147
|
+
const expected = JSON.stringify(ast.value);
|
|
148
|
+
const actual = JSON.stringify(value);
|
|
87
149
|
return {
|
|
88
|
-
message: `Expected literal ${
|
|
150
|
+
message: `Expected literal ${expected}, got ${actual}`,
|
|
89
151
|
path,
|
|
90
|
-
expected
|
|
152
|
+
expected,
|
|
91
153
|
actual: String(value),
|
|
92
154
|
};
|
|
93
155
|
}
|
|
94
156
|
return null;
|
|
95
157
|
}
|
|
96
158
|
|
|
159
|
+
// ── Literal union ─────────────────────────────────────────────────────────
|
|
97
160
|
case 'literal_union': {
|
|
98
161
|
if (!ast.values.includes(value)) {
|
|
99
162
|
const expected = ast.values.map((v) => JSON.stringify(v)).join(' | ');
|
|
@@ -107,6 +170,7 @@ function findError(ast, value, opts, path) {
|
|
|
107
170
|
return null;
|
|
108
171
|
}
|
|
109
172
|
|
|
173
|
+
// ── Primitive union ───────────────────────────────────────────────────────
|
|
110
174
|
case 'primitive_union': {
|
|
111
175
|
const actual = realType(value, opts);
|
|
112
176
|
if (!ast.names.includes(actual)) {
|
|
@@ -121,70 +185,129 @@ function findError(ast, value, opts, path) {
|
|
|
121
185
|
return null;
|
|
122
186
|
}
|
|
123
187
|
|
|
188
|
+
// ── Union (mixed / complex) ───────────────────────────────────────────────
|
|
189
|
+
//
|
|
190
|
+
// Strategy: try every branch and keep the error with the deepest path
|
|
191
|
+
// (most specific failure). If any sub-error reaches deeper than the current
|
|
192
|
+
// path, return that — it gives the user the most actionable location.
|
|
193
|
+
// If all failures are at the same depth, fall back to a union-level message.
|
|
124
194
|
case 'union': {
|
|
125
|
-
|
|
126
|
-
const
|
|
195
|
+
let bestErr = null;
|
|
196
|
+
for (const member of ast.members) {
|
|
197
|
+
const err = findError(member, value, opts, path);
|
|
198
|
+
if (!bestErr || (err && err.path.length > bestErr.path.length)) {
|
|
199
|
+
bestErr = err;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// A sub-branch had a deeper, more specific failure — surface it
|
|
203
|
+
if (bestErr && bestErr.path.length > path.length) {
|
|
204
|
+
return bestErr;
|
|
205
|
+
}
|
|
206
|
+
// All branches failed at the current depth — report the union itself
|
|
207
|
+
const expected = ast.members.map(describeType).join(' | ');
|
|
208
|
+
const actual = realType(value, opts);
|
|
127
209
|
return {
|
|
128
|
-
message: `Expected ${expected}, got ${
|
|
210
|
+
message: `Expected ${expected}, got ${actual}`,
|
|
129
211
|
path,
|
|
130
212
|
expected,
|
|
131
|
-
actual
|
|
213
|
+
actual,
|
|
132
214
|
};
|
|
133
215
|
}
|
|
134
216
|
|
|
217
|
+
// ── Optional ─────────────────────────────────────────────────────────────
|
|
135
218
|
case 'optional': {
|
|
136
|
-
return value === undefined ? null : (
|
|
137
|
-
findError(ast.inner, value, opts, path)
|
|
138
|
-
);
|
|
219
|
+
return value === undefined ? null : findError(ast.inner, value, opts, path);
|
|
139
220
|
}
|
|
140
221
|
|
|
222
|
+
// ── Array ─────────────────────────────────────────────────────────────────
|
|
141
223
|
case 'array': {
|
|
142
224
|
if (!Array.isArray(value)) {
|
|
143
225
|
const actual = realType(value, opts);
|
|
144
|
-
return {
|
|
145
|
-
message: `Expected array, got ${actual}`,
|
|
146
|
-
path,
|
|
147
|
-
expected: 'array',
|
|
148
|
-
actual,
|
|
149
|
-
};
|
|
226
|
+
return { message: `Expected array, got ${actual}`, path, expected: 'array', actual };
|
|
150
227
|
}
|
|
228
|
+
const elemType = describeType(ast.element);
|
|
151
229
|
for (let i = 0; i < value.length; i++) {
|
|
152
|
-
const err = findError(ast.element, value[i], opts, [
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
230
|
+
const err = findError(ast.element, value[i], opts, [...path, String(i)]);
|
|
231
|
+
if (err) {
|
|
232
|
+
return {
|
|
233
|
+
...err,
|
|
234
|
+
message: `Expected item [${i}] to be ${elemType}, got ${err.actual}`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
157
237
|
}
|
|
158
238
|
return null;
|
|
159
239
|
}
|
|
160
240
|
|
|
241
|
+
// ── Object ────────────────────────────────────────────────────────────────
|
|
161
242
|
case 'object': {
|
|
162
243
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
163
244
|
const actual = realType(value, opts);
|
|
164
|
-
return {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
245
|
+
return { message: `Expected object, got ${actual}`, path, expected: 'object', actual };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Exact mode: report the first unexpected property
|
|
249
|
+
if (ast.exact) {
|
|
250
|
+
const allowed = new Set(ast.properties.map((p) => p.key));
|
|
251
|
+
for (const key in value) {
|
|
252
|
+
if (!allowed.has(key)) {
|
|
253
|
+
return {
|
|
254
|
+
message: `Unexpected property "${key}"`,
|
|
255
|
+
path: [...path, key],
|
|
256
|
+
expected: 'never',
|
|
257
|
+
actual: realType(value[key], opts),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
170
261
|
}
|
|
262
|
+
|
|
263
|
+
// Check required properties
|
|
171
264
|
for (const p of ast.properties) {
|
|
172
265
|
if (!p.optional && !(p.key in value)) {
|
|
266
|
+
const expected = describeType(p.value);
|
|
173
267
|
return {
|
|
174
|
-
message: `Missing required property "${p.key}"`,
|
|
268
|
+
message: `Missing required property "${p.key}" (expected ${expected})`,
|
|
175
269
|
path: [...path, p.key],
|
|
176
|
-
expected
|
|
270
|
+
expected,
|
|
177
271
|
actual: 'undefined',
|
|
178
272
|
};
|
|
179
273
|
}
|
|
274
|
+
|
|
180
275
|
if (p.key in value && value[p.key] !== undefined) {
|
|
181
276
|
const err = findError(p.value, value[p.key], opts, [...path, p.key]);
|
|
182
|
-
if (err)
|
|
277
|
+
if (err) {
|
|
278
|
+
// Only override the message if the error is exactly one level deep
|
|
279
|
+
// (i.e., the failure IS the property itself). Deeper errors already
|
|
280
|
+
// carry their own enriched contextual message and must not be re-wrapped.
|
|
281
|
+
const isDirectFailure = err.path.length === path.length + 1;
|
|
282
|
+
return {
|
|
283
|
+
...err,
|
|
284
|
+
message: isDirectFailure ?
|
|
285
|
+
`Expected property "${p.key}" to be ${err.expected}, got ${err.actual}`
|
|
286
|
+
: err.message,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
183
289
|
}
|
|
184
290
|
}
|
|
291
|
+
|
|
185
292
|
return null;
|
|
186
293
|
}
|
|
187
294
|
|
|
295
|
+
// ── Identifier (named sigil reference) ───────────────────────────────────
|
|
296
|
+
case 'identifier': {
|
|
297
|
+
const name = ast.name;
|
|
298
|
+
const sigil = resolve(name);
|
|
299
|
+
if (!sigil) {
|
|
300
|
+
// Unknown at error-finding time — mirror the runtime throw as an error object
|
|
301
|
+
return {
|
|
302
|
+
message: `Unknown sigil reference: ${name}`,
|
|
303
|
+
path,
|
|
304
|
+
expected: name,
|
|
305
|
+
actual: realType(value, opts),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return findError(sigil.normalized ?? sigil.ast, value, opts, path);
|
|
309
|
+
}
|
|
310
|
+
|
|
188
311
|
default:
|
|
189
312
|
return null;
|
|
190
313
|
}
|