@c9up/rune 0.1.10 → 0.1.12

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.
@@ -0,0 +1,3 @@
1
+ /** Validate data against a schema (JSON string). */
2
+ export declare function validate(requestJson: string): string;
3
+ //# sourceMappingURL=generated.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generated.d.ts","sourceRoot":"","sources":["../../src/native/generated.ts"],"names":[],"mappings":"AAMA,oDAAoD;AAEpD,MAAM,CAAC,OAAO,UAAU,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC"}
@@ -0,0 +1,7 @@
1
+ // GENERATED FROM THE RUST — do not edit.
2
+ //
3
+ // Produced by scripts/generate-napi-types.mjs from napi-derive's type-def
4
+ // output. Editing this file by hand puts it back where it started: a
5
+ // description that can disagree with the code it describes.
6
+ export {};
7
+ //# sourceMappingURL=generated.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generated.js","sourceRoot":"","sources":["../../src/native/generated.ts"],"names":[],"mappings":"AAAA,yCAAyC;AACzC,EAAE;AACF,0EAA0E;AAC1E,qEAAqE;AACrE,4DAA4D"}
@@ -1 +1 @@
1
- {"version":3,"file":"native.d.ts","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AA8BH;;GAEG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG;IACpD,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,CAaA;AAED,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAaD,sEAAsE;AACtE,qBAAa,uBAAwB,SAAQ,KAAK;IACjD,QAAQ,CAAC,IAAI,EAAG,oBAAoB,CAAU;;CAQ9C;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C"}
1
+ {"version":3,"file":"native.d.ts","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAuCH;;GAEG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG;IACpD,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,CAaA;AAED,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAaD,sEAAsE;AACtE,qBAAa,uBAAwB,SAAQ,KAAK;IACjD,QAAQ,CAAC,IAAI,EAAG,oBAAoB,CAAU;;CAQ9C;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C"}
@@ -1 +1 @@
1
- {"version":3,"file":"native.js","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAChD,MAAM,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE3D,MAAM,WAAW,GAA2B;IAC3C,WAAW,EAAE,eAAe;IAC5B,aAAa,EAAE,iBAAiB;IAChC,YAAY,EAAE,YAAY;IAC1B,cAAc,EAAE,cAAc;IAC9B,WAAW,EAAE,gBAAgB;CAC7B,CAAC;AAEF,IAAI,MAA0D,CAAC;AAC/D,IAAI,SAAkB,CAAC;AAEvB,IAAI,CAAC;IACJ,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,QAAQ,IAAI,IAAI,EAAE,CAAC,CAAC;IAClD,IAAI,MAAM,EAAE,CAAC;QACZ,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,UAAU,EAAE,YAAY,MAAM,OAAO,CAAC,CAAC,CAAC;IAChE,CAAC;AACF,CAAC;AAAC,OAAO,CAAC,EAAE,CAAC;IACZ,SAAS,GAAG,CAAC,CAAC;AACf,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,cAAc,CAAC,WAAmB;IAKjD,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACd,+DAA+D,SAAS,IAAI,kBAAkB,EAAE,CAChG,CAAC;IACH,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC;IACxD,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;QACxE,MAAM,IAAI,KAAK,CACd,uEAAuE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAC/F,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC;AAED,MAAM,UAAU,iBAAiB;IAChC,OAAO,MAAM,KAAK,SAAS,CAAC;AAC7B,CAAC;AAED,6EAA6E;AAC7E,SAAS,iBAAiB;IACzB,MAAM,MAAM,GAAG,GAAG,QAAQ,IAAI,IAAI,EAAE,CAAC;IACrC,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC7B,OAAO,mBAAmB,SAAS,YAAY,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC;IACjG,CAAC;IACD,OAAO,WAAW,CAAC,MAAM,CAAC,KAAK,SAAS;QACvC,CAAC,CAAC,kBAAkB;QACpB,CAAC,CAAC,0BAA0B,MAAM,EAAE,CAAC;AACvC,CAAC;AAED,sEAAsE;AACtE,MAAM,OAAO,uBAAwB,SAAQ,KAAK;IACxC,IAAI,GAAG,oBAA6B,CAAC;IAC9C;QACC,KAAK,CACJ,gFAAgF,iBAAiB,EAAE,KAAK;YACvG,oFAAoF,CACrF,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACvC,CAAC;CACD;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,qBAAqB;IACpC,IAAI,MAAM,KAAK,SAAS;QAAE,MAAM,IAAI,uBAAuB,EAAE,CAAC;AAC/D,CAAC"}
1
+ {"version":3,"file":"native.js","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAChD,MAAM,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE3D,MAAM,WAAW,GAA2B;IAC3C,WAAW,EAAE,eAAe;IAC5B,aAAa,EAAE,iBAAiB;IAChC,YAAY,EAAE,YAAY;IAC1B,cAAc,EAAE,cAAc;IAC9B,WAAW,EAAE,gBAAgB;CAC7B,CAAC;AAWF,IAAI,MAA8B,CAAC;AACnC,IAAI,SAAkB,CAAC;AAEvB,IAAI,CAAC;IACJ,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,QAAQ,IAAI,IAAI,EAAE,CAAC,CAAC;IAClD,IAAI,MAAM,EAAE,CAAC;QACZ,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,UAAU,EAAE,YAAY,MAAM,OAAO,CAAC,CAAC,CAAC;IAChE,CAAC;AACF,CAAC;AAAC,OAAO,CAAC,EAAE,CAAC;IACZ,SAAS,GAAG,CAAC,CAAC;AACf,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,cAAc,CAAC,WAAmB;IAKjD,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACd,+DAA+D,SAAS,IAAI,kBAAkB,EAAE,CAChG,CAAC;IACH,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC;IACxD,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;QACxE,MAAM,IAAI,KAAK,CACd,uEAAuE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAC/F,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC;AAED,MAAM,UAAU,iBAAiB;IAChC,OAAO,MAAM,KAAK,SAAS,CAAC;AAC7B,CAAC;AAED,6EAA6E;AAC7E,SAAS,iBAAiB;IACzB,MAAM,MAAM,GAAG,GAAG,QAAQ,IAAI,IAAI,EAAE,CAAC;IACrC,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC7B,OAAO,mBAAmB,SAAS,YAAY,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC;IACjG,CAAC;IACD,OAAO,WAAW,CAAC,MAAM,CAAC,KAAK,SAAS;QACvC,CAAC,CAAC,kBAAkB;QACpB,CAAC,CAAC,0BAA0B,MAAM,EAAE,CAAC;AACvC,CAAC;AAED,sEAAsE;AACtE,MAAM,OAAO,uBAAwB,SAAQ,KAAK;IACxC,IAAI,GAAG,oBAA6B,CAAC;IAC9C;QACC,KAAK,CACJ,gFAAgF,iBAAiB,EAAE,KAAK;YACvG,oFAAoF,CACrF,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACvC,CAAC;CACD;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,qBAAqB;IACpC,IAAI,MAAM,KAAK,SAAS;QAAE,MAAM,IAAI,uBAAuB,EAAE,CAAC;AAC/D,CAAC"}
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/rune",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Rune — Validation engine for the Ream framework",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,7 +48,8 @@
48
48
  "prebuild": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
49
49
  "build": "tsc -p tsconfig.build.json",
50
50
  "build:rust": "cargo build --release -p rune-engine-napi",
51
- "build:napi": "pnpm build:rust && node scripts/copy-napi.mjs",
51
+ "build:napi": "pnpm build:rust && pnpm build:napi-types && node scripts/copy-napi.mjs",
52
+ "build:napi-types": "node scripts/build-napi-types.mjs",
52
53
  "test": "vitest run",
53
54
  "test:rust": "cargo test -p rune-engine",
54
55
  "lint": "biome check src/",
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regenerate `src/native/generated.ts` from the Rust.
4
+ *
5
+ * Node rather than a shell script because this runs inside `build:napi`, and
6
+ * that runs on the Windows prebuild runner too: a bash `mktemp` there hands
7
+ * cargo a `/tmp/...` path the native proc-macro cannot write to, so the
8
+ * type-def file comes back empty and the build fails for no visible reason.
9
+ *
10
+ * One crate at a time on purpose: napi-derive APPENDS to `TYPE_DEF_TMP_PATH`
11
+ * while cargo compiles, and a parallel build interleaves the writes —
12
+ * definitions go missing, silently, and the generated file comes out short.
13
+ */
14
+
15
+ import { execFileSync } from 'node:child_process'
16
+ import { createRequire } from 'node:module'
17
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
18
+ import { tmpdir } from 'node:os'
19
+ import { dirname, join } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ const CRATES = ['rune-engine-napi']
23
+
24
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
25
+ const output = join('src', 'native', 'generated.ts')
26
+ const scratch = mkdtempSync(join(tmpdir(), 'napi-types-'))
27
+
28
+ try {
29
+ const lines = []
30
+ for (const crate of CRATES) {
31
+ const perCrate = join(scratch, `${crate}.jsonl`)
32
+ writeFileSync(perCrate, '')
33
+ execFileSync('cargo', ['build', '-p', crate], {
34
+ cwd: packageRoot,
35
+ env: { ...process.env, TYPE_DEF_TMP_PATH: perCrate },
36
+ stdio: ['ignore', 'ignore', 'inherit'],
37
+ })
38
+ const emitted = readFileSync(perCrate, 'utf8').split('\n').filter(Boolean)
39
+ if (emitted.length === 0) {
40
+ throw new Error(
41
+ `[napi-types] ${crate} emitted nothing — is napi-derive's "type-def" feature on?`,
42
+ )
43
+ }
44
+ lines.push(...emitted)
45
+ }
46
+
47
+ const combined = join(scratch, 'combined.jsonl')
48
+ writeFileSync(combined, `${lines.join('\n')}\n`)
49
+
50
+ execFileSync(
51
+ process.execPath,
52
+ [join('scripts', 'generate-napi-types.mjs'), combined, output],
53
+ { cwd: packageRoot, stdio: 'inherit' },
54
+ )
55
+
56
+ // Formatted here rather than excluded from the linter: the file is checked
57
+ // in, so it should read like the rest of the tree — and formatting it at
58
+ // generation means it never shows up as a diff someone has to fix by hand.
59
+ // Resolved through the package entry rather than `node_modules/.bin`, whose
60
+ // shim is a shell script the Windows runner cannot execute directly.
61
+ const biome = createRequire(import.meta.url).resolve('@biomejs/biome/bin/biome')
62
+ execFileSync(process.execPath, [biome, 'format', '--write', output], {
63
+ cwd: packageRoot,
64
+ stdio: ['ignore', 'ignore', 'inherit'],
65
+ })
66
+ } finally {
67
+ rmSync(scratch, { recursive: true, force: true })
68
+ }
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate the TypeScript surface of the native module FROM the Rust.
4
+ *
5
+ * `napi-derive`'s `type-def` feature emits one JSON line per exported item
6
+ * while cargo compiles. Those lines are the source of truth: they are derived
7
+ * from the `#[napi]` items themselves, so a signature cannot drift from the
8
+ * Rust without this file changing.
9
+ *
10
+ * Hand-written interfaces are what this replaces, and they do drift — nothing
11
+ * on the TypeScript side notices when a `pub fn` gains a parameter or stops
12
+ * being `async`.
13
+ *
14
+ * One thing napi-rs cannot infer: the shape of a `JsFunction` callback. It
15
+ * emits `(...args: any[]) => any`. Any such parameter is refined below, by
16
+ * name, and the script fails if a refinement no longer matches anything — so
17
+ * the table cannot rot either.
18
+ *
19
+ * TYPE_DEF_TMP_PATH=<file> cargo build -p <crate>
20
+ * node scripts/generate-napi-types.mjs <file> <out.d.ts>
21
+ */
22
+
23
+ import { readFileSync, writeFileSync } from 'node:fs'
24
+
25
+ /**
26
+ * Callback signatures napi-rs erases to `any`, restored here.
27
+ *
28
+ * Keyed by `<owner>.<method>`; the value replaces the whole parameter. Every
29
+ * entry must match, or the script fails: a stale refinement is how a hand
30
+ * annotation quietly stops describing the Rust.
31
+ *
32
+ * Empty while this crate exposes no `JsFunction` parameter — add an entry the
33
+ * day one appears, rather than letting an `any` through.
34
+ */
35
+ const CALLBACK_REFINEMENTS = {}
36
+
37
+ const [input, output] = process.argv.slice(2)
38
+ if (!input || !output) {
39
+ console.error('usage: generate-napi-types.mjs <type-def file> <out.d.ts>')
40
+ process.exit(2)
41
+ }
42
+
43
+ const entries = readFileSync(input, 'utf8')
44
+ .split('\n')
45
+ .map((l) => l.trim())
46
+ .filter(Boolean)
47
+ .map((l) => JSON.parse(l))
48
+
49
+ const used = new Set()
50
+
51
+ /** Apply the refinements that belong to `owner`, tracking which ones matched. */
52
+ function refine(owner, body) {
53
+ let out = body
54
+ for (const [key, { from, to }] of Object.entries(CALLBACK_REFINEMENTS)) {
55
+ const [entryOwner] = key.split('.')
56
+ if (entryOwner !== owner) continue
57
+ if (out.includes(from)) {
58
+ out = out.replace(from, to)
59
+ used.add(key)
60
+ }
61
+ }
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * JSDoc as emitted by napi-derive, indented to sit above its member.
67
+ *
68
+ * `*` followed by `/` inside the text closes the comment early — a Rust doc
69
+ * example holding a cron expression (`0 *​/5 * * *`) is enough to do it, and
70
+ * the generated file then fails to parse. Escaped rather than stripped, so the
71
+ * example still reads correctly.
72
+ */
73
+ function docBlock(doc, indent = '') {
74
+ if (!doc) return ''
75
+ // Escape every `*/` EXCEPT the one that closes the block. A Rust doc example
76
+ // holding a cron expression (`0 */5 * * *`) closes the comment early
77
+ // otherwise, and the generated file stops parsing — but escaping the closer
78
+ // too breaks it just as thoroughly, whether the block spans one line or many.
79
+ const closer = doc.lastIndexOf('*/')
80
+ const escaped =
81
+ closer === -1
82
+ ? doc
83
+ : doc.slice(0, closer).replaceAll('*/', '*\\/') + doc.slice(closer)
84
+ return (
85
+ escaped
86
+ .split('\n')
87
+ .filter((l) => l.length > 0)
88
+ .map((l) => `${indent}${l}`)
89
+ .join('\n') + '\n'
90
+ )
91
+ }
92
+
93
+ const interfaces = entries.filter((e) => e.kind === 'interface')
94
+ const structs = entries.filter((e) => e.kind === 'struct')
95
+ const impls = new Map(entries.filter((e) => e.kind === 'impl').map((e) => [e.name, e]))
96
+ const fns = entries.filter((e) => e.kind === 'fn')
97
+
98
+ const out = [
99
+ '// GENERATED FROM THE RUST — do not edit.',
100
+ '//',
101
+ "// Produced by scripts/generate-napi-types.mjs from napi-derive's type-def",
102
+ '// output. Editing this file by hand puts it back where it started: a',
103
+ '// description that can disagree with the code it describes.',
104
+ '',
105
+ ]
106
+
107
+ for (const iface of interfaces) {
108
+ out.push(docBlock(iface.js_doc))
109
+ out.push(`export interface ${iface.name} {`)
110
+ for (const line of iface.def.split('\n')) {
111
+ out.push(line ? ` ${line.trim()}` : '')
112
+ }
113
+ out.push('}', '')
114
+ }
115
+
116
+ for (const struct of structs) {
117
+ const impl = impls.get(struct.name)
118
+ out.push(docBlock(struct.js_doc))
119
+ out.push(`export declare class ${struct.name} {`)
120
+ if (impl) {
121
+ for (const line of refine(struct.name, impl.def).split('\n')) {
122
+ out.push(line ? ` ${line.trim()}` : '')
123
+ }
124
+ }
125
+ out.push('}', '')
126
+ }
127
+
128
+ for (const fn of fns) {
129
+ out.push(docBlock(fn.js_doc))
130
+ // napi-derive emits the whole declaration for a function, unlike a struct
131
+ // where `def` holds only the members.
132
+ const declaration = fn.def.trim()
133
+ out.push(
134
+ declaration.startsWith('export declare function')
135
+ ? `${declaration};`
136
+ : `export declare function ${fn.name}${declaration};`,
137
+ '',
138
+ )
139
+ }
140
+
141
+ const stale = Object.keys(CALLBACK_REFINEMENTS).filter((k) => !used.has(k))
142
+ if (stale.length > 0) {
143
+ console.error(
144
+ `[napi-types] refinement(s) that matched nothing: ${stale.join(', ')}\n` +
145
+ '[napi-types] the Rust changed under them — update or remove the entry.',
146
+ )
147
+ process.exit(1)
148
+ }
149
+
150
+ writeFileSync(output, out.join('\n'))
151
+ console.log(
152
+ `[napi-types] ${output} — ${interfaces.length} interface(s), ${structs.length} class(es), ${fns.length} function(s)`,
153
+ )
package/src/Schema.ts CHANGED
@@ -1850,6 +1850,7 @@ export class RuleChain<Output = unknown> {
1850
1850
  #nestedSchema: Record<string, RuleChain> | null = null;
1851
1851
  #arrayItemChain: RuleChain | null = null;
1852
1852
  #allowUnknown = false;
1853
+ #denyUnknown = false;
1853
1854
  #metadata: Record<string, unknown> | null = null;
1854
1855
  /** Extensions / MIME types declared by `file()` / `mimeTypes()`. */
1855
1856
  #declaredExtnames: readonly string[] | null = null;
@@ -1933,6 +1934,11 @@ export class RuleChain<Output = unknown> {
1933
1934
  get allowsUnknown(): boolean {
1934
1935
  return this.#allowUnknown;
1935
1936
  }
1937
+
1938
+ /** Whether an undeclared key fails the payload rather than being dropped. */
1939
+ get deniesUnknown(): boolean {
1940
+ return this.#denyUnknown;
1941
+ }
1936
1942
  /** Free-form JSON Schema metadata attached with `meta()`. */
1937
1943
  get metadata(): Record<string, unknown> | null {
1938
1944
  return this.#metadata;
@@ -1980,6 +1986,7 @@ export class RuleChain<Output = unknown> {
1980
1986
  next.#dateFormats = this.#dateFormats;
1981
1987
  next.#coercions = [...this.#coercions];
1982
1988
  next.#allowUnknown = this.#allowUnknown;
1989
+ next.#denyUnknown = this.#denyUnknown;
1983
1990
  next.#metadata = this.#metadata ? { ...this.#metadata } : null;
1984
1991
  next.#declaredExtnames = this.#declaredExtnames;
1985
1992
  next.#declaredMimeTypes = this.#declaredMimeTypes;
@@ -2290,6 +2297,23 @@ export class RuleChain<Output = unknown> {
2290
2297
  */
2291
2298
  allowUnknownProperties(): this {
2292
2299
  this.#allowUnknown = true;
2300
+ this.#denyUnknown = false;
2301
+ return this;
2302
+ }
2303
+
2304
+ /**
2305
+ * Fail the payload on a key the shape does not declare, instead of dropping
2306
+ * it.
2307
+ *
2308
+ * A deliberate addition: upstream offers keep-or-drop and nothing else, and
2309
+ * dropping stays the default so a migrated schema behaves the same. This is
2310
+ * for an API whose contract is that it refuses what it does not understand
2311
+ * — where a client sending `emial` should be told, not silently ignored and
2312
+ * left wondering why the address never changed.
2313
+ */
2314
+ denyUnknownProperties(): this {
2315
+ this.#denyUnknown = true;
2316
+ this.#allowUnknown = false;
2293
2317
  return this;
2294
2318
  }
2295
2319
 
@@ -2869,11 +2893,31 @@ export class RuleChain<Output = unknown> {
2869
2893
  );
2870
2894
  }
2871
2895
 
2872
- /** Must match a regular expression (TS-only — never dispatched to Rust). */
2896
+ /**
2897
+ * Must match a regular expression (TS-only — never dispatched to Rust).
2898
+ *
2899
+ * `toJSONSchema()` emits the expression's SOURCE. JSON Schema's `pattern`
2900
+ * has nowhere to carry flags, so `i`, `m` and `s` do not survive into the
2901
+ * emitted schema even though they are honoured here — a `/abc/i` validates
2902
+ * case-insensitively and exports as case-sensitive. Keep that in mind when
2903
+ * the emitted schema is handed to another validator.
2904
+ */
2873
2905
  regex(pattern: RegExp): this {
2906
+ // `test()` advances `lastIndex` on a global or sticky expression, so the
2907
+ // SAME value alternates between valid and invalid across calls. Reset it
2908
+ // before each test rather than stripping the flags: `y` anchors the
2909
+ // match at `lastIndex`, and dropping it would let the pattern match
2910
+ // anywhere instead.
2911
+ const matches = (value: string): boolean => {
2912
+ pattern.lastIndex = 0;
2913
+ return pattern.test(value);
2914
+ };
2874
2915
  this.#pushRule({
2875
2916
  name: "regex",
2876
- validate: (v) => typeof v === "string" && pattern.test(v),
2917
+ // The source is carried so `toJSONSchema()` can emit the constraint;
2918
+ // without it the emitted schema silently accepts anything.
2919
+ args: { pattern: pattern.source },
2920
+ validate: (v) => typeof v === "string" && matches(v),
2877
2921
  message: "Invalid format",
2878
2922
  });
2879
2923
  return this;
@@ -3107,7 +3151,7 @@ export class RuleChain<Output = unknown> {
3107
3151
  *
3108
3152
  * Checksums are run where the country defines a short, well-defined one
3109
3153
  * (BE, DE, NL, IT, PT, LU, CH); the others are FORMAT-only, which is stated
3110
- * rather than implied. An unknown country LEVES rather than accepting the
3154
+ * rather than implied. An unknown country RAISES rather than accepting the
3111
3155
  * value unchecked.
3112
3156
  */
3113
3157
  vat(options: VatOptions | ((field: FieldContext) => VatOptions)): this {
@@ -3316,7 +3360,7 @@ export class RuleChain<Output = unknown> {
3316
3360
  return this.#stringMutation("toCamelCase", toCamelCase);
3317
3361
  }
3318
3362
 
3319
- /** HTML-escape `& < > " '` (VineJS `escape`). */
3363
+ /** HTML-escape the eight characters VineJS `escape` does. */
3320
3364
  escape(): this {
3321
3365
  return this.#stringMutation("escape", escapeHtml);
3322
3366
  }
@@ -3977,6 +4021,24 @@ export class RuleChain<Output = unknown> {
3977
4021
  ) ?? grp.branches.find((candidate) => candidate.predicate === null);
3978
4022
  if (branch) shape = { ...shape, ...branch.shape };
3979
4023
  }
4024
+ if (this.#denyUnknown) {
4025
+ const declared = new Set(Object.keys(shape));
4026
+ const undeclared = Object.keys(source).filter(
4027
+ (key) => !declared.has(key),
4028
+ );
4029
+ if (undeclared.length > 0) {
4030
+ // Named one by one, and the shape listed: a typo is the common
4031
+ // case, and "unknown field" without the alternatives leaves the
4032
+ // caller to guess which spelling was expected.
4033
+ for (const key of undeclared) {
4034
+ errors.push({
4035
+ field: field ? `${field}.${key}` : key,
4036
+ rule: "unknownField",
4037
+ message: `unknown field \`${key}\`, expected one of ${[...declared].join(", ")}`,
4038
+ });
4039
+ }
4040
+ }
4041
+ }
3980
4042
  for (const [nestedField, chain] of Object.entries(shape)) {
3981
4043
  const nestedResult = chain._validateWithTransform(
3982
4044
  `${field}.${nestedField}`,
package/src/formats.ts CHANGED
@@ -167,10 +167,13 @@ export const isMobile = (v: string): boolean =>
167
167
  export function escapeHtml(v: string): string {
168
168
  return v
169
169
  .replace(/&/g, "&amp;")
170
+ .replace(/"/g, "&quot;")
171
+ .replace(/'/g, "&#x27;")
170
172
  .replace(/</g, "&lt;")
171
173
  .replace(/>/g, "&gt;")
172
- .replace(/"/g, "&quot;")
173
- .replace(/'/g, "&#x27;");
174
+ .replace(/\//g, "&#x2F;")
175
+ .replace(/\\/g, "&#x5C;")
176
+ .replace(/`/g, "&#96;");
174
177
  }
175
178
 
176
179
  /** Options accepted by `normalizeEmail` (subset of VineJS's). */
@@ -0,0 +1,9 @@
1
+ // GENERATED FROM THE RUST — do not edit.
2
+ //
3
+ // Produced by scripts/generate-napi-types.mjs from napi-derive's type-def
4
+ // output. Editing this file by hand puts it back where it started: a
5
+ // description that can disagree with the code it describes.
6
+
7
+ /** Validate data against a schema (JSON string). */
8
+
9
+ export declare function validate(requestJson: string): string;
package/src/native.ts CHANGED
@@ -20,7 +20,16 @@ const platformMap: Record<string, string> = {
20
20
  "win32-x64": "win32-x64-msvc",
21
21
  };
22
22
 
23
- let native: { validate: (json: string) => string } | undefined;
23
+ /**
24
+ * The engine's surface, as the Rust declares it.
25
+ *
26
+ * Derived from `./native/generated.js` — written by `pnpm build:napi-types`
27
+ * from napi-derive's own `type-def` output — rather than restated here, where
28
+ * nothing would notice a `pub fn` gaining a parameter or changing its return.
29
+ */
30
+ type NativeRune = typeof import("./native/generated.js");
31
+
32
+ let native: NativeRune | undefined;
24
33
  let loadError: unknown;
25
34
 
26
35
  try {