ata-validator 0.14.0 → 0.15.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.
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Build a map of JSON pointer → { line, col, text } by scanning JSON text.
5
+ *
6
+ * Approach: use JSON.parse for correctness, then do one structural scan
7
+ * tracking bracket depth and key positions. This avoids hand-rolling a
8
+ * full JSON parser while delivering keyword-level positions sufficient
9
+ * for source frames.
10
+ *
11
+ * Limitations (acceptable for source frames):
12
+ * - Duplicate keys: last wins.
13
+ * - Whitespace inside string values does not affect tracking (strings
14
+ * are skipped over wholesale).
15
+ * - Comments / trailing commas: JSON only, no JSON5.
16
+ */
17
+
18
+ function escapePtr (s) {
19
+ return s.replace(/~/g, '~0').replace(/\//g, '~1');
20
+ }
21
+
22
+ function buildPositionMap (text) {
23
+ const map = Object.create(null);
24
+ const lines = text.split('\n');
25
+ const lineStart = new Array(lines.length + 1);
26
+ lineStart[0] = 0;
27
+ for (let i = 0; i < lines.length; i++) lineStart[i + 1] = lineStart[i] + lines[i].length + 1;
28
+
29
+ function offsetToLineCol (off) {
30
+ // Binary search lineStart
31
+ let lo = 0, hi = lineStart.length - 1;
32
+ while (lo < hi) {
33
+ const mid = (lo + hi + 1) >> 1;
34
+ if (lineStart[mid] <= off) lo = mid; else hi = mid - 1;
35
+ }
36
+ return { line: lo + 1, col: off - lineStart[lo] + 1, text: lines[lo] || '' };
37
+ }
38
+
39
+ let i = 0;
40
+ const n = text.length;
41
+
42
+ function skipWs () {
43
+ while (i < n) {
44
+ const ch = text.charCodeAt(i);
45
+ if (ch === 0x20 || ch === 0x09 || ch === 0x0a || ch === 0x0d) i++;
46
+ else break;
47
+ }
48
+ }
49
+
50
+ function readString () {
51
+ // Assumes text[i] === '"'
52
+ const start = i;
53
+ i++;
54
+ while (i < n) {
55
+ const ch = text.charCodeAt(i);
56
+ if (ch === 0x5c) { i += 2; continue; } // backslash escape
57
+ if (ch === 0x22) { i++; return JSON.parse(text.slice(start, i)); }
58
+ i++;
59
+ }
60
+ throw new Error('unterminated string at offset ' + start);
61
+ }
62
+
63
+ function skipValue () {
64
+ skipWs();
65
+ if (i >= n) return;
66
+ const ch = text.charCodeAt(i);
67
+ if (ch === 0x22) { readString(); return; }
68
+ if (ch === 0x7b || ch === 0x5b) { // { or [
69
+ const open = ch;
70
+ const close = open === 0x7b ? 0x7d : 0x5d;
71
+ let depth = 1;
72
+ i++;
73
+ while (i < n && depth > 0) {
74
+ const c = text.charCodeAt(i);
75
+ if (c === 0x22) { readString(); continue; }
76
+ if (c === open) depth++;
77
+ else if (c === close) depth--;
78
+ i++;
79
+ }
80
+ return;
81
+ }
82
+ // primitive: read until ,, }, ], or whitespace
83
+ while (i < n) {
84
+ const c = text.charCodeAt(i);
85
+ if (c === 0x2c || c === 0x7d || c === 0x5d || c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) return;
86
+ i++;
87
+ }
88
+ }
89
+
90
+ function pointerOf (path) {
91
+ if (path.length === 0) return '';
92
+ return '/' + path.map(escapePtr).join('/');
93
+ }
94
+
95
+ function walk (path) {
96
+ skipWs();
97
+ if (i >= n) return;
98
+ // Record position of the *value* at this path
99
+ const pos = offsetToLineCol(i);
100
+ map[pointerOf(path)] = pos;
101
+
102
+ const ch = text.charCodeAt(i);
103
+
104
+ if (ch === 0x7b) { // object
105
+ i++;
106
+ while (true) {
107
+ skipWs();
108
+ if (text.charCodeAt(i) === 0x7d) { i++; return; }
109
+ if (text.charCodeAt(i) === 0x2c) { i++; continue; }
110
+ skipWs();
111
+ // Key
112
+ const keyStart = i;
113
+ const key = readString();
114
+ // Record key position too — keyed by `<path>/<key>` so source frame
115
+ // can point at the keyword name, not its value.
116
+ const keyPos = offsetToLineCol(keyStart);
117
+ skipWs();
118
+ if (text.charCodeAt(i) !== 0x3a) throw new Error('expected ":" at offset ' + i);
119
+ i++;
120
+ const childPath = path.concat([key]);
121
+ // Mark the key location at <path>/<key>#key (used by renderers
122
+ // for keyword-name framing); the value location overwrites in walk().
123
+ map[pointerOf(childPath) + '#key'] = keyPos;
124
+ walk(childPath);
125
+ }
126
+ } else if (ch === 0x5b) { // array
127
+ i++;
128
+ let idx = 0;
129
+ while (true) {
130
+ skipWs();
131
+ if (text.charCodeAt(i) === 0x5d) { i++; return; }
132
+ if (text.charCodeAt(i) === 0x2c) { i++; continue; }
133
+ walk(path.concat([String(idx)]));
134
+ idx++;
135
+ }
136
+ } else {
137
+ skipValue();
138
+ }
139
+ }
140
+
141
+ // Strip BOM if present
142
+ if (text.charCodeAt(0) === 0xfeff) i = 1;
143
+
144
+ walk([]);
145
+ return map;
146
+ }
147
+
148
+ module.exports = { buildPositionMap, escapePtr };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ const { levenshtein } = require('./levenshtein');
4
+
5
+ // Hand-coded format hints. Keep <=60 chars per text.
6
+ const FORMAT_HINTS = {
7
+ email: (val) => {
8
+ if (typeof val !== 'string') return null;
9
+ if (!val.includes('@')) return "missing '@' and domain part";
10
+ if (val.split('@').length > 2) return "multiple '@' characters";
11
+ const [, dom] = val.split('@');
12
+ if (!dom || !dom.includes('.')) return 'domain part missing dot';
13
+ return null;
14
+ },
15
+ date: (val) => {
16
+ if (typeof val !== 'string') return null;
17
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(val);
18
+ if (!m) return 'expected YYYY-MM-DD layout';
19
+ const mo = +m[2]; if (mo < 1 || mo > 12) return 'month must be 01-12';
20
+ const d = +m[3]; if (d < 1 || d > 31) return 'day must be 01-31';
21
+ return null;
22
+ },
23
+ uuid: (val) => {
24
+ if (typeof val !== 'string') return null;
25
+ if (!/^[0-9a-fA-F-]+$/.test(val)) return 'expected hex digits and dashes';
26
+ return 'expected 8-4-4-4-12 hex layout';
27
+ },
28
+ ipv4: (val) => typeof val === 'string' ? 'expected four 0-255 octets separated by dots' : null,
29
+ };
30
+
31
+ function suggestEnumTypo (received, enumValues) {
32
+ if (typeof received !== 'string') return null;
33
+ if (!Array.isArray(enumValues) || enumValues.length === 0 || enumValues.length > 30) return null;
34
+ let best = null;
35
+ let bestDist = Infinity;
36
+ let tied = false;
37
+ for (const v of enumValues) {
38
+ if (typeof v !== 'string') continue;
39
+ const d = levenshtein(received, v, 2);
40
+ if (d < bestDist) { best = v; bestDist = d; tied = false; }
41
+ else if (d === bestDist) tied = true;
42
+ }
43
+ if (best && bestDist <= 2 && !tied) {
44
+ return { text: `did you mean \`${best}\`?`, kind: 'typo' };
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function suggestRequiredTypo (missing, presentKeys) {
50
+ if (!missing || !Array.isArray(presentKeys)) return null;
51
+ for (const k of presentKeys) {
52
+ if (typeof k !== 'string') continue;
53
+ const d = levenshtein(missing, k, 2);
54
+ if (d <= 2 && d > 0) {
55
+ return { text: `did you mean \`${missing}\` instead of \`${k}\`?`, kind: 'similar-key' };
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function suggestFormat (format, received) {
62
+ const fn = FORMAT_HINTS[format];
63
+ if (!fn) return null;
64
+ // received is repr-truncated string like '"foo"'; unwrap for testing
65
+ let raw = received;
66
+ if (typeof raw === 'string' && raw.startsWith('"') && raw.endsWith('"')) {
67
+ try { raw = JSON.parse(raw); } catch {}
68
+ }
69
+ const text = fn(raw);
70
+ return text ? { text, kind: 'format' } : null;
71
+ }
72
+
73
+ function suggestCoercion (expectedType, received) {
74
+ if (typeof received !== 'string' || !received.startsWith('"') || !received.endsWith('"')) return null;
75
+ let raw;
76
+ try { raw = JSON.parse(received); } catch { return null; }
77
+ if (typeof raw !== 'string') return null;
78
+ if (expectedType === 'integer' && /^-?\d+$/.test(raw)) {
79
+ return { text: 'value would coerce; enable `coerceTypes` or pass an integer', kind: 'coercion' };
80
+ }
81
+ if (expectedType === 'number' && /^-?\d+(\.\d+)?$/.test(raw)) {
82
+ return { text: 'value would coerce; enable `coerceTypes` or pass a number', kind: 'coercion' };
83
+ }
84
+ if (expectedType === 'boolean' && (raw === 'true' || raw === 'false')) {
85
+ return { text: 'value would coerce; enable `coerceTypes` or pass a boolean', kind: 'coercion' };
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Apply suggestion sources in priority order. Returns the first hit, or null.
92
+ * @param err Enriched ValidationError (with `received`, `params`, `keyword`)
93
+ * @param data The full input data (for required-typo)
94
+ */
95
+ function suggestFor (err, data) {
96
+ if (err.keyword === 'enum') {
97
+ return suggestEnumTypo(parseReceived(err.received), err.params && err.params.allowedValues);
98
+ }
99
+ if (err.keyword === 'required') {
100
+ const missing = err.params && err.params.missingProperty;
101
+ let parentPath = err.path || '';
102
+ if (parentPath.endsWith('/' + missing)) parentPath = parentPath.slice(0, -missing.length - 1);
103
+ const parent = walk(data, parentPath);
104
+ if (parent && typeof parent === 'object') {
105
+ return suggestRequiredTypo(missing, Object.keys(parent));
106
+ }
107
+ return null;
108
+ }
109
+ if (err.keyword === 'format') {
110
+ return suggestFormat(err.params && err.params.format, err.received);
111
+ }
112
+ if (err.keyword === 'type') {
113
+ return suggestCoercion(err.params && err.params.type, err.received);
114
+ }
115
+ return null;
116
+ }
117
+
118
+ function parseReceived (r) {
119
+ if (typeof r !== 'string') return r;
120
+ if (r.startsWith('"') && r.endsWith('"')) { try { return JSON.parse(r); } catch { return r; } }
121
+ return r;
122
+ }
123
+
124
+ function walk (data, pointer) {
125
+ if (!pointer) return data;
126
+ const parts = pointer.replace(/^\//, '').split('/').map(s => s.replace(/~1/g, '/').replace(/~0/g, '~'));
127
+ let cur = data;
128
+ for (const p of parts) { if (cur == null) return undefined; cur = cur[p]; }
129
+ return cur;
130
+ }
131
+
132
+ module.exports = { suggestFor, suggestEnumTypo, suggestRequiredTypo, suggestFormat, suggestCoercion };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "0.14.0",
4
- "description": "Ultra-fast JSON Schema validator. 5x faster validation, 159,000x faster compilation. Works without native addon. Cross-schema $ref, Draft 2020-12 + Draft 7, V8-optimized JS codegen, simdjson, RE2, multi-core. Standard Schema V1 compatible.",
3
+ "version": "0.15.0",
4
+ "description": "JSON Schema validation with first-class TypeScript and zero runtime cost. AOT compile to per-schema ESM modules with zero validator dependency. Generic Validator<T> for TypeBox/Zod/Valibot composition. Optional runtime API. Standard Schema V1 compatible.",
5
5
  "main": "index.js",
6
6
  "module": "index.mjs",
7
7
  "types": "index.d.ts",
@@ -36,12 +36,14 @@
36
36
  },
37
37
  "scripts": {
38
38
  "install": "node scripts/install.js",
39
- "prepublishOnly": "node scripts/check-prebuilds.js",
39
+ "prepublishOnly": "node scripts/check-prebuilds.js && ATA_ALLOW_DOC_PLACEHOLDERS=1 node scripts/check-doc-coverage.js && node tests/test_error_codes_lock.js",
40
+ "release:check": "node scripts/check-prebuilds.js && node scripts/check-doc-coverage.js && node tests/test_error_codes_lock.js",
40
41
  "build": "cmake-js build --target ata",
41
42
  "rebuild": "cmake-js rebuild --target ata",
42
43
  "prebuild": "pkg-prebuilds-copy --baseDir build/Release --source ata.node --name=ata --strip --napi_version=10",
43
44
  "prebuild-all": "npm run prebuild -- --arch x64 && npm run prebuild -- --arch arm64",
44
- "test": "node test.js && node tests/test_no_native.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_typed_validator_runner.js",
45
+ "test": "node test.js && node tests/test_no_native.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_typed_validator_runner.js && node tests/test_error_codes_lock.js && node tests/test_enrich_error.js && node tests/test_rich_errors_optout.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node benchmark/bench_aot_size.mjs",
46
+ "bench:size": "node benchmark/bench_aot_size.mjs",
45
47
  "test:suite": "node tests/run_suite.js",
46
48
  "test:compat": "node tests/test_compat.js",
47
49
  "test:standard-schema": "node tests/test_standard_schema.js",
@@ -128,6 +130,7 @@
128
130
  "@sinclair/typebox": "^0.34.49",
129
131
  "@types/node": "^25.8.0",
130
132
  "cmake-js": "^8.0.0",
133
+ "jsonc-parser": "^3.3.1",
131
134
  "mitata": "^1.0.34",
132
135
  "typebox": "^1.1.7",
133
136
  "typescript": "^6.0.3",
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { all } = require('../lib/error-codes');
7
+
8
+ const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'error-codes.md'), 'utf8');
9
+
10
+ const missing = [];
11
+ const placeholder = [];
12
+ for (const code of all()) {
13
+ const headingRe = new RegExp(`^### ${code} `, 'm');
14
+ if (!headingRe.test(doc)) {
15
+ missing.push(code);
16
+ continue;
17
+ }
18
+ // Find the section, ensure it doesn't still say "_TODO — fill before release._"
19
+ const start = doc.search(headingRe);
20
+ const next = doc.slice(start + 4).search(/^### /m);
21
+ const section = next === -1 ? doc.slice(start) : doc.slice(start, start + 4 + next);
22
+ if (section.includes('_TODO')) placeholder.push(code);
23
+ }
24
+
25
+ if (missing.length) {
26
+ console.error(`docs/error-codes.md is missing entries for: ${missing.join(', ')}`);
27
+ process.exit(1);
28
+ }
29
+ if (placeholder.length && process.env.ATA_ALLOW_DOC_PLACEHOLDERS !== '1') {
30
+ console.error(`docs/error-codes.md has placeholder _TODO sections for: ${placeholder.join(', ')}`);
31
+ console.error('Run: ATA_ALLOW_DOC_PLACEHOLDERS=1 node scripts/check-doc-coverage.js (development only)');
32
+ process.exit(1);
33
+ }
34
+ console.log(`ok: ${all().length} codes documented`);
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { CODES, all } = require('../lib/error-codes');
7
+
8
+ const lines = [];
9
+ lines.push('# ata Error Codes');
10
+ lines.push('');
11
+ lines.push('Stable registry of `ATA####` codes emitted by ata-validator. Each section is the target of `https://ata-validator.com/e/<code>`.');
12
+ lines.push('');
13
+
14
+ const byCategory = {};
15
+ for (const c of all()) {
16
+ const cat = CODES[c].category;
17
+ (byCategory[cat] ||= []).push(c);
18
+ }
19
+
20
+ const order = ['type', 'shape', 'constraint', 'format', 'enum', 'composition', 'ref', 'system'];
21
+ for (const cat of order) {
22
+ if (!byCategory[cat]) continue;
23
+ lines.push(`## ${cat}`);
24
+ lines.push('');
25
+ for (const code of byCategory[cat]) {
26
+ const meta = CODES[code];
27
+ lines.push(`### ${code} — ${meta.headline}`);
28
+ lines.push('');
29
+ lines.push(`Keyword: \`${meta.keyword}\`${meta.format ? ` (format: \`${meta.format}\`)` : ''}`);
30
+ lines.push('');
31
+ lines.push('**Cause.** _TODO — fill before release._');
32
+ lines.push('');
33
+ lines.push('**Fix.** _TODO — fill before release._');
34
+ lines.push('');
35
+ }
36
+ }
37
+
38
+ const docPath = path.join(__dirname, '..', 'docs', 'error-codes.md');
39
+ fs.writeFileSync(docPath, lines.join('\n') + '\n');
40
+ console.log(`wrote ${all().length} sections to docs/error-codes.md`);
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { CODES, all } = require('../lib/error-codes');
7
+
8
+ const out = {};
9
+ for (const c of all()) {
10
+ out[c] = {
11
+ keyword: CODES[c].keyword,
12
+ category: CODES[c].category,
13
+ format: CODES[c].format || null,
14
+ };
15
+ }
16
+
17
+ const lockPath = path.join(__dirname, '..', 'tests', 'error-codes.lock.json');
18
+ fs.writeFileSync(lockPath, JSON.stringify(out, null, 2) + '\n');
19
+ console.log(`wrote ${all().length} codes to ${path.relative(process.cwd(), lockPath)}`);