@script-it/json-views-core 0.1.0-beta.1 → 2026.10.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,6 +51,8 @@ The compiler returns the untouched effective `metadata`, its `metadataSource` (`
51
51
 
52
52
  Only integer version `1` is supported. Other positive integer versions produce `unsupported-metadata-version`; malformed annotations produce `invalid-metadata`. The raw future metadata is retained for round trips and source editing. Consumers must not overwrite it with inferred metadata or version 1 settings.
53
53
 
54
+ Checking every document value against the schema is most of the compile time on large documents. Callers that only need the compiled schema and views, such as a route or settings panel, can pass `{ validateValues: false }`: the schema, views, and every other diagnostic are identical, and value diagnostics are omitted.
55
+
54
56
  ### Paths and precedence
55
57
 
56
58
  All paths start at `$`. Supported segments are `.property`, `['quoted property']`, `[0]`, and `[*]`. Quoted properties support single-quote, backslash, slash, control-character, and `\uXXXX` escapes. Array indices are nonnegative safe integers without leading zeros. A numeric-looking object key is a string property, such as `$['0']`; it is different from `$[0]`.
@@ -161,7 +163,7 @@ Patching helpers preserve unrelated bytes. The agent-facing mutation API accepts
161
163
 
162
164
  Compiled metadata includes the root and each view's `value` snapshot for direct rendering. `applyJsonViewViewRows`, `projectJsonViewCollection`, and `resolveJsonViewRowPath` resolve against their explicit current-root argument. `getJsonViewViewRows(view, root)` does the same; `getJsonViewViewRows(view)` intentionally reads the compiled snapshot. Recompile when annotations or registry behavior change, or to refresh diagnostics and inferred options for a new document revision.
163
165
 
164
- The synchronous compiler and scanner have no universal document-size guarantee. Table virtualization limits mounted rows, not parsing, validation, projection, or Kanban work. Source revision caching avoids unnecessary repeat work; applications handling very large documents should perform compilation/inspection in a worker and choose their own measured size budgets. The test workload covers 1,000 independent edits within a 10,000-row document, and comparator/adversarial-pattern regressions are retained in the core suite.
166
+ The synchronous compiler and scanner have no universal document-size guarantee. Table virtualization limits mounted rows, not parsing, validation, projection, or Kanban work. `inspectJsonSource` keeps the diagnostics of the most recently inspected text, so hosts that inspect one revision several times pay for one scan; applications handling very large documents should perform compilation/inspection in a worker and choose their own measured size budgets. The test workload covers 1,000 independent edits within a 10,000-row document, and comparator/adversarial-pattern regressions are retained in the core suite.
165
167
 
166
168
  ### Host acknowledgements and reloads
167
169
 
@@ -48,62 +48,92 @@ export function jsonFormatForSource(source) {
48
48
  export function jsonFormatForRoot(root) {
49
49
  return Array.isArray(root) ? jsonArrayFormat : jsonObjectFormat;
50
50
  }
51
+ // Outside strings, valid JSON only contains these four whitespace characters.
52
+ function isJsonWhitespace(code) {
53
+ return code === 0x20 || code === 0x0a || code === 0x0d || code === 0x09;
54
+ }
55
+ function isStructural(code) {
56
+ return code === 0x22 || code === 0x7b || code === 0x5b || code === 0x7d || code === 0x5d || code === 0x2c || code === 0x3a;
57
+ }
51
58
  /** Pretty-print JSON without changing numeric spelling, duplicate keys, or string contents. */
52
59
  export function formatJsonSource(source) {
53
60
  assertJsonDepth(JSON.parse(source));
54
- let formatted = '';
55
- let depth = 0;
56
- let escaped = false;
57
- let inString = false;
61
+ const parts = [];
58
62
  const expandedContainers = [];
59
- const indentation = () => ' '.repeat(depth);
60
- for (let index = 0; index < source.length; index += 1) {
61
- const character = source[index];
62
- if (inString) {
63
- formatted += character;
64
- if (escaped)
65
- escaped = false;
66
- else if (character === '\\')
67
- escaped = true;
68
- else if (character === '"')
69
- inString = false;
63
+ const length = source.length;
64
+ let depth = 0;
65
+ let indentation = '';
66
+ const indent = (next) => {
67
+ depth = next;
68
+ indentation = ' '.repeat(depth);
69
+ };
70
+ let index = 0;
71
+ while (index < length) {
72
+ const code = source.charCodeAt(index);
73
+ if (code === 0x22) {
74
+ // Strings are copied whole, escapes included.
75
+ let end = index + 1;
76
+ while (end < length) {
77
+ const inner = source.charCodeAt(end);
78
+ end += 1;
79
+ if (inner === 0x5c)
80
+ end += 1;
81
+ else if (inner === 0x22)
82
+ break;
83
+ }
84
+ parts.push(source.slice(index, end));
85
+ index = end;
70
86
  continue;
71
87
  }
72
- if (/\s/.test(character))
73
- continue;
74
- if (character === '"') {
75
- inString = true;
76
- formatted += character;
88
+ if (isJsonWhitespace(code)) {
89
+ index += 1;
77
90
  continue;
78
91
  }
79
- if (character === '{' || character === '[') {
80
- const closingCharacter = character === '{' ? '}' : ']';
92
+ if (code === 0x7b || code === 0x5b) {
93
+ const closing = code === 0x7b ? 0x7d : 0x5d;
81
94
  let nextIndex = index + 1;
82
- while (nextIndex < source.length && /\s/.test(source[nextIndex]))
95
+ while (nextIndex < length && isJsonWhitespace(source.charCodeAt(nextIndex)))
83
96
  nextIndex += 1;
84
- const expanded = source[nextIndex] !== closingCharacter;
97
+ const expanded = source.charCodeAt(nextIndex) !== closing;
85
98
  expandedContainers.push(expanded);
86
- formatted += character;
99
+ parts.push(source[index]);
87
100
  if (expanded) {
88
- depth += 1;
89
- formatted += `\n${indentation()}`;
101
+ indent(depth + 1);
102
+ parts.push(`\n${indentation}`);
90
103
  }
104
+ index += 1;
91
105
  continue;
92
106
  }
93
- if (character === '}' || character === ']') {
107
+ if (code === 0x7d || code === 0x5d) {
94
108
  const expanded = expandedContainers.pop() ?? false;
95
109
  if (expanded) {
96
- depth -= 1;
97
- formatted += `\n${indentation()}`;
110
+ indent(depth - 1);
111
+ parts.push(`\n${indentation}`);
98
112
  }
99
- formatted += character;
113
+ parts.push(source[index]);
114
+ index += 1;
100
115
  continue;
101
116
  }
102
- if (character === ',') {
103
- formatted += `,\n${indentation()}`;
117
+ if (code === 0x2c) {
118
+ parts.push(`,\n${indentation}`);
119
+ index += 1;
104
120
  continue;
105
121
  }
106
- formatted += character === ':' ? ': ' : character;
122
+ if (code === 0x3a) {
123
+ parts.push(': ');
124
+ index += 1;
125
+ continue;
126
+ }
127
+ // Numbers and literals are copied as one run.
128
+ let end = index + 1;
129
+ while (end < length) {
130
+ const inner = source.charCodeAt(end);
131
+ if (isStructural(inner) || isJsonWhitespace(inner))
132
+ break;
133
+ end += 1;
134
+ }
135
+ parts.push(source.slice(index, end));
136
+ index = end;
107
137
  }
108
- return formatted;
138
+ return parts.join('');
109
139
  }
@@ -15,6 +15,25 @@ function assertSameEditableAtomicType(current, next, path) {
15
15
  throw new TypeError(`Replacement at ${describeValuePath(path)} must keep type ${currentType}`);
16
16
  }
17
17
  }
18
+ // Character codes the scanner dispatches on. The text is already valid JSON.
19
+ const QUOTE = 0x22;
20
+ const COMMA = 0x2c;
21
+ const MINUS = 0x2d;
22
+ const COLON = 0x3a;
23
+ const OPEN_BRACKET = 0x5b;
24
+ const BACKSLASH = 0x5c;
25
+ const CLOSE_BRACKET = 0x5d;
26
+ const OPEN_BRACE = 0x7b;
27
+ const CLOSE_BRACE = 0x7d;
28
+ function isJsonWhitespace(code) {
29
+ return code === 0x20 || code === 0x0a || code === 0x0d || code === 0x09;
30
+ }
31
+ function isDigit(code) {
32
+ return code >= 0x30 && code <= 0x39;
33
+ }
34
+ function isNumberCharacter(code) {
35
+ return isDigit(code) || code === 0x2e || code === MINUS || code === 0x2b || code === 0x65 || code === 0x45;
36
+ }
18
37
  class JsonSourceScanner {
19
38
  index = 0;
20
39
  source;
@@ -23,6 +42,7 @@ class JsonSourceScanner {
23
42
  }
24
43
  parse() {
25
44
  // Callers validate once with JSON.parse before scanning source spans.
45
+ const source = this.source;
26
46
  const root = this.parseValue();
27
47
  const frames = [];
28
48
  const push = (node, property) => {
@@ -37,8 +57,8 @@ class JsonSourceScanner {
37
57
  const frame = frames[frames.length - 1];
38
58
  const node = frame.node;
39
59
  this.skipWhitespace();
40
- const close = node.kind === 'array' ? ']' : '}';
41
- if (this.source[this.index] === close) {
60
+ const close = node.kind === 'array' ? CLOSE_BRACKET : CLOSE_BRACE;
61
+ if (source.charCodeAt(this.index) === close) {
42
62
  node.end = ++this.index;
43
63
  if (frame.property)
44
64
  frame.property.end = node.end;
@@ -46,7 +66,7 @@ class JsonSourceScanner {
46
66
  continue;
47
67
  }
48
68
  if (frame.afterValue) {
49
- if (this.source[this.index++] !== ',')
69
+ if (source.charCodeAt(this.index++) !== COMMA)
50
70
  throw new SyntaxError('Expected comma');
51
71
  this.skipWhitespace();
52
72
  }
@@ -58,10 +78,12 @@ class JsonSourceScanner {
58
78
  }
59
79
  else {
60
80
  const start = this.index;
61
- this.scanString();
62
- const key = JSON.parse(this.source.slice(start, this.index));
81
+ // A key without escapes is its own text; JSON.parse decodes the rest.
82
+ const key = this.scanString()
83
+ ? JSON.parse(source.slice(start, this.index))
84
+ : source.slice(start + 1, this.index - 1);
63
85
  this.skipWhitespace();
64
- if (this.source[this.index++] !== ':')
86
+ if (source.charCodeAt(this.index++) !== COLON)
65
87
  throw new SyntaxError('Expected colon');
66
88
  const value = this.parseValue();
67
89
  const property = { key, start, end: value.end, value };
@@ -71,50 +93,61 @@ class JsonSourceScanner {
71
93
  }
72
94
  }
73
95
  this.skipWhitespace();
74
- if (this.index !== this.source.length)
96
+ if (this.index !== source.length)
75
97
  throw new SyntaxError('Unexpected content after JSON value');
76
98
  return root;
77
99
  }
78
100
  skipWhitespace() {
79
- while (this.index < this.source.length && /[\t\n\r ]/.test(this.source[this.index]))
80
- this.index++;
101
+ const source = this.source;
102
+ let index = this.index;
103
+ while (index < source.length && isJsonWhitespace(source.charCodeAt(index)))
104
+ index += 1;
105
+ this.index = index;
81
106
  }
82
107
  parseValue() {
83
108
  this.skipWhitespace();
84
109
  const start = this.index;
85
- const token = this.source[this.index];
86
- if (token === '{') {
87
- this.index++;
110
+ const code = this.source.charCodeAt(start);
111
+ if (code === OPEN_BRACE) {
112
+ this.index += 1;
88
113
  return { start, end: this.index, kind: 'object', properties: [], propertyIndex: new Map() };
89
114
  }
90
- if (token === '[') {
91
- this.index++;
115
+ if (code === OPEN_BRACKET) {
116
+ this.index += 1;
92
117
  return { start, end: this.index, kind: 'array', items: [] };
93
118
  }
94
- if (token === '"') {
119
+ if (code === QUOTE) {
95
120
  this.scanString();
96
121
  return { start, end: this.index, kind: 'atomic' };
97
122
  }
98
- if (token === 't')
123
+ if (code === 0x74)
99
124
  return this.scanLiteral(start, 'true');
100
- if (token === 'f')
125
+ if (code === 0x66)
101
126
  return this.scanLiteral(start, 'false');
102
- if (token === 'n')
127
+ if (code === 0x6e)
103
128
  return this.scanLiteral(start, 'null');
104
129
  return this.scanNumber(start);
105
130
  }
131
+ /** Moves past the closing quote and reports whether the string used escapes. */
106
132
  scanString() {
107
- if (this.source[this.index] !== '"')
133
+ const source = this.source;
134
+ if (source.charCodeAt(this.index) !== QUOTE)
108
135
  throw new SyntaxError('Expected JSON string');
109
- this.index++;
110
- while (this.index < this.source.length) {
111
- const token = this.source[this.index];
112
- this.index++;
113
- if (token === '"')
114
- return;
115
- if (token === '\\')
116
- this.index++;
136
+ let index = this.index + 1;
137
+ let escaped = false;
138
+ while (index < source.length) {
139
+ const code = source.charCodeAt(index);
140
+ index += 1;
141
+ if (code === QUOTE) {
142
+ this.index = index;
143
+ return escaped;
144
+ }
145
+ if (code === BACKSLASH) {
146
+ escaped = true;
147
+ index += 1;
148
+ }
117
149
  }
150
+ this.index = index;
118
151
  throw new SyntaxError('Unterminated JSON string');
119
152
  }
120
153
  scanLiteral(start, literal) {
@@ -122,11 +155,14 @@ class JsonSourceScanner {
122
155
  return { start, end: this.index, kind: 'atomic' };
123
156
  }
124
157
  scanNumber(start) {
125
- while (this.index < this.source.length && /[0-9eE+.-]/.test(this.source[this.index]))
126
- this.index++;
127
- if (this.index === start)
158
+ const source = this.source;
159
+ let index = start;
160
+ while (index < source.length && isNumberCharacter(source.charCodeAt(index)))
161
+ index += 1;
162
+ if (index === start)
128
163
  throw new SyntaxError('Expected JSON value');
129
- return { start, end: this.index, kind: 'atomic' };
164
+ this.index = index;
165
+ return { start, end: index, kind: 'atomic' };
130
166
  }
131
167
  }
132
168
  function sourceNodeAtPath(root, path) {
@@ -229,9 +265,26 @@ function serializeReplacement(next, path, space = 0) {
229
265
  throw new TypeError(`Replacement at ${describeValuePath(path)} must be valid JSON data`, { cause: error });
230
266
  }
231
267
  }
232
- /** Reads a source-preserving inspection value and identifies information JSON.parse cannot retain. */
233
- export function inspectJsonSource(source) {
234
- const value = JSON.parse(source);
268
+ /** Paths are rebuilt from parent links only for the few nodes that report something. */
269
+ function inspectionPath(frame) {
270
+ const path = [];
271
+ for (let current = frame; current.parent; current = current.parent)
272
+ path.push(current.segment);
273
+ return path.reverse();
274
+ }
275
+ /** Integers of at most 15 digits round-trip exactly; -0 is the one exception. */
276
+ function isPlainSafeInteger(token) {
277
+ let index = token.charCodeAt(0) === MINUS ? 1 : 0;
278
+ if (token.length - index < 1 || token.length - index > 15)
279
+ return false;
280
+ if (index === 1 && token.length === 2 && token.charCodeAt(1) === 0x30)
281
+ return false;
282
+ for (; index < token.length; index += 1)
283
+ if (!isDigit(token.charCodeAt(index)))
284
+ return false;
285
+ return true;
286
+ }
287
+ function inspectSourceDiagnostics(source) {
235
288
  const tree = new JsonSourceScanner(source).parse();
236
289
  const diagnostics = [];
237
290
  const decimalKey = (token) => {
@@ -245,38 +298,42 @@ export function inspectJsonSource(source) {
245
298
  const significant = digits.replace(/0+$/, '');
246
299
  return `${negative ? '-' : ''}${significant}e${Number(exponent) - fraction.length + digits.length - significant.length}`;
247
300
  };
248
- const pending = [{ node: tree, path: [], shadowed: false }];
301
+ const pending = [{ node: tree, parent: undefined, segment: '', shadowed: false }];
249
302
  while (pending.length) {
250
- const { node, path, shadowed } = pending.pop();
303
+ const frame = pending.pop();
304
+ const { node, shadowed } = frame;
251
305
  if (node.kind === 'object') {
252
306
  const seen = new Set();
253
- for (const property of node.properties ?? []) {
254
- const childPath = [...path, property.key];
255
- const childShadowed = shadowed || node.propertyIndex?.get(property.key) !== property.value;
307
+ for (const property of node.properties) {
308
+ const childShadowed = shadowed || node.propertyIndex.get(property.key) !== property.value;
309
+ const child = { node: property.value, parent: frame, segment: property.key, shadowed: childShadowed };
256
310
  if (seen.has(property.key))
257
311
  diagnostics.push({
258
- code: 'duplicate-key', sourcePath: childPath, token: JSON.stringify(property.key),
312
+ code: 'duplicate-key', sourcePath: inspectionPath(child), token: JSON.stringify(property.key),
259
313
  ...(childShadowed ? { shadowed: true } : {}),
260
314
  start: property.start, end: property.end,
261
315
  message: 'Duplicate object key; structured inspection shows the last occurrence. Source retains every occurrence.',
262
316
  });
263
317
  seen.add(property.key);
264
- pending.push({ node: property.value, path: childPath, shadowed: childShadowed });
318
+ pending.push(child);
265
319
  }
266
320
  }
267
321
  else if (node.kind === 'array') {
268
- node.items?.forEach((child, index) => pending.push({ node: child, path: [...path, index], shadowed }));
322
+ node.items.forEach((item, index) => pending.push({ node: item, parent: frame, segment: index, shadowed }));
269
323
  }
270
324
  else {
325
+ const first = source.charCodeAt(node.start);
326
+ if (first !== MINUS && !isDigit(first))
327
+ continue;
271
328
  const token = source.slice(node.start, node.end);
272
- if (!/^-?[0-9]/.test(token))
329
+ if (isPlainSafeInteger(token))
273
330
  continue;
274
331
  const number = Number(token);
275
332
  if (!Number.isFinite(number) || Object.is(number, -0)
276
333
  || (Number.isInteger(number) && !Number.isSafeInteger(number))
277
334
  || decimalKey(token) !== decimalKey(String(number))) {
278
335
  diagnostics.push({
279
- code: 'unsafe-number', sourcePath: path, token, start: node.start, end: node.end,
336
+ code: 'unsafe-number', sourcePath: inspectionPath(frame), token, start: node.start, end: node.end,
280
337
  ...(shadowed ? { shadowed: true } : {}),
281
338
  message: 'This numeric token cannot be safely edited through JavaScript numbers. Edit its exact value in Source.',
282
339
  });
@@ -284,7 +341,20 @@ export function inspectJsonSource(source) {
284
341
  }
285
342
  }
286
343
  diagnostics.sort((a, b) => a.start - b.start);
287
- return { value, diagnostics: diagnostics.map((item) => ({ ...item, help: sourceDiagnosticHelp(item.code) })) };
344
+ return diagnostics;
345
+ }
346
+ let lastInspection;
347
+ /** Reads a source-preserving inspection value and identifies information JSON.parse cannot retain. */
348
+ export function inspectJsonSource(source) {
349
+ const value = JSON.parse(source);
350
+ // Hosts inspect one text several times per change. The scan is the expensive
351
+ // part, so its result is kept for the most recent source; callers still get
352
+ // their own value, diagnostic objects, and paths.
353
+ const cached = lastInspection !== undefined && lastInspection.source === source ? lastInspection.diagnostics : undefined;
354
+ const diagnostics = cached ?? inspectSourceDiagnostics(source);
355
+ if (!cached)
356
+ lastInspection = { source, diagnostics };
357
+ return { value, diagnostics: diagnostics.map((item) => ({ ...item, sourcePath: [...item.sourcePath], help: sourceDiagnosticHelp(item.code) })) };
288
358
  }
289
359
  /** Replaces one existing JSON value while preserving every unrelated source byte. */
290
360
  export function replaceJsonValueInSource(source, path, next) {
@@ -133,5 +133,9 @@ export interface CompileJsonViewMetadataOptions {
133
133
  metadata?: unknown;
134
134
  /** Temporarily hides required-value diagnostics below paths for unfinished record drafts. */
135
135
  suppressRequiredPaths?: readonly ValuePath[];
136
+ /** `false` skips checking every document value against the schema, which is
137
+ * most of the compile time on large documents. The compiled schema, views, and
138
+ * all other diagnostics are unchanged; value diagnostics are omitted. */
139
+ validateValues?: boolean;
136
140
  }
137
141
  export declare function compileJsonViewMetadata(root: unknown, typeRegistry?: JsonViewTypeRegistry, options?: CompileJsonViewMetadataOptions): CompiledJsonViewMetadata;
package/dist/metadata.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { MAX_HTML_VIEW_BYTES } from './html-view-limits.js';
2
2
  import { assertJsonDepth } from './json-limits.js';
3
- import { getValueAtPath, VALUE_PATH_MISSING } from './json-path.js';
3
+ import { getValueAtPath, VALUE_PATH_MISSING, valuePathKey } from './json-path.js';
4
4
  import { compileSafePattern } from './safe-pattern.js';
5
5
  import { defaultTypeRegistry, JSON_VIEW_OPTION_COLORS } from './type-registry.js';
6
6
  import { compareDateValues, parseDateValue } from './date-type.js';
@@ -369,23 +369,34 @@ function canonicalValue(value) {
369
369
  return JSON.stringify(value);
370
370
  }
371
371
  export function matchesJsonViewPath(path, sourcePath) {
372
- if (path.root !== '$' || path.segments.length !== sourcePath.length)
372
+ const segments = path.segments;
373
+ if (path.root !== '$' || segments.length !== sourcePath.length)
373
374
  return false;
374
- return path.segments.every((segment, index) => {
375
- const sourceSegment = sourcePath[index];
375
+ for (let index = 0; index < segments.length; index += 1) {
376
+ const segment = segments[index];
376
377
  if (segment.kind === 'wildcard')
377
- return true;
378
- if (segment.kind === 'property')
379
- return segment.key === sourceSegment;
380
- return segment.index === sourceSegment;
381
- });
378
+ continue;
379
+ if ((segment.kind === 'property' ? segment.key : segment.index) !== sourcePath[index])
380
+ return false;
381
+ }
382
+ return true;
383
+ }
384
+ /** More concrete segments win, then longer paths, then the earlier declaration. */
385
+ function outranks(candidate, current) {
386
+ if (candidate.specificity[0] !== current.specificity[0])
387
+ return candidate.specificity[0] > current.specificity[0];
388
+ if (candidate.specificity[1] !== current.specificity[1])
389
+ return candidate.specificity[1] > current.specificity[1];
390
+ return candidate.declarationIndex < current.declarationIndex;
382
391
  }
383
392
  export function schemaForJsonViewPath(schema, sourcePath) {
384
- return schema
385
- .filter((entry) => matchesJsonViewPath(entry.path, sourcePath))
386
- .sort((left, right) => (right.specificity[0] - left.specificity[0]
387
- || right.specificity[1] - left.specificity[1]
388
- || left.declarationIndex - right.declarationIndex))[0];
393
+ // Keeps the earliest of the best-ranked matches, exactly as a stable sort would.
394
+ let winner;
395
+ for (const entry of schema) {
396
+ if (matchesJsonViewPath(entry.path, sourcePath) && (!winner || outranks(entry, winner)))
397
+ winner = entry;
398
+ }
399
+ return winner;
389
400
  }
390
401
  function parseDeclaredPath(value, expectedRoot, allowWildcard, metadataPath, diagnostics, scope, code, details = {}) {
391
402
  if (typeof value !== 'string') {
@@ -445,20 +456,20 @@ function compileSchema(value, diagnostics, typeRegistry) {
445
456
  }
446
457
  function inferSchemaOptions(root, schema) {
447
458
  for (const entry of schema) {
448
- if (entry.descriptor.type !== 'select' && entry.descriptor.type !== 'multi-select')
459
+ const type = entry.descriptor.type;
460
+ if (type !== 'select' && type !== 'multi-select')
449
461
  continue;
450
462
  const options = new Set(entry.descriptor.options ?? []);
451
- for (const location of expandSchemaLocations(root, entry)) {
452
- if (entry.descriptor.type === 'select' && typeof location.value === 'string' && location.value !== '') {
453
- options.add(location.value);
454
- }
455
- if (entry.descriptor.type === 'multi-select' && Array.isArray(location.value)) {
456
- location.value.forEach((value) => {
457
- if (typeof value === 'string' && value !== '')
458
- options.add(value);
463
+ visitSchemaLocations(root, entry, (value) => {
464
+ if (type === 'select' && typeof value === 'string' && value !== '')
465
+ options.add(value);
466
+ if (type === 'multi-select' && Array.isArray(value)) {
467
+ value.forEach((item) => {
468
+ if (typeof item === 'string' && item !== '')
469
+ options.add(item);
459
470
  });
460
471
  }
461
- }
472
+ });
462
473
  entry.descriptor = { ...entry.descriptor, options: Array.from(options) };
463
474
  }
464
475
  }
@@ -622,10 +633,15 @@ function validateViewField(root, rows, viewPath, fieldPath, metadataPath, diagno
622
633
  }
623
634
  if (rows.length === 0)
624
635
  return;
625
- const resolved = rows.map((row) => resolveJsonViewRowPath(root, row, fieldPath));
626
- if (resolved.every((item) => item.value === JSON_VIEW_PATH_MISSING)) {
627
- diagnostic(diagnostics, 'view', code, `Path ${JSON.stringify(fieldPath.source)} does not resolve on any record selected by ${JSON.stringify(viewPath.source)}`, metadataPath, { viewId, sourcePath: resolved[0]?.sourcePath, help: fieldHelp() });
636
+ // Only a field missing from every record is reported, so stop at the first record that has it.
637
+ let first;
638
+ for (const row of rows) {
639
+ const resolved = resolveJsonViewRowPath(root, row, fieldPath);
640
+ if (resolved.value !== JSON_VIEW_PATH_MISSING)
641
+ return;
642
+ first ??= resolved;
628
643
  }
644
+ diagnostic(diagnostics, 'view', code, `Path ${JSON.stringify(fieldPath.source)} does not resolve on any record selected by ${JSON.stringify(viewPath.source)}`, metadataPath, { viewId, sourcePath: first?.sourcePath, help: fieldHelp() });
629
645
  }
630
646
  function reserveViewId(raw, declarationIndex, usedIds) {
631
647
  const requested = typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : `view-${declarationIndex + 1}`;
@@ -798,93 +814,140 @@ function compileViews(root, value, diagnostics, schema, typeRegistry) {
798
814
  }];
799
815
  });
800
816
  }
801
- function expandSchemaLocations(root, entry) {
802
- const results = [];
803
- const visit = (value, segmentIndex, sourcePath) => {
804
- if (segmentIndex === entry.path.segments.length) {
805
- results.push({ value, sourcePath });
817
+ /** Visits every location a schema path selects, in document order, without
818
+ * allocating a path per location. Missing data is visited once at its deepest
819
+ * concrete prefix.
820
+ */
821
+ function visitSchemaLocations(root, entry, visit) {
822
+ const segments = entry.path.segments;
823
+ const stack = [];
824
+ let snapshot;
825
+ const path = () => snapshot ??= stack.slice();
826
+ const report = (value, complete) => {
827
+ snapshot = undefined;
828
+ visit(value, path, complete);
829
+ };
830
+ const missing = (segmentIndex) => {
831
+ let pushed = 0;
832
+ for (let index = segmentIndex; index < segments.length; index += 1) {
833
+ const part = segments[index];
834
+ if (part.kind === 'wildcard')
835
+ break;
836
+ stack.push(part.kind === 'property' ? part.key : part.index);
837
+ pushed += 1;
838
+ }
839
+ report(JSON_VIEW_PATH_MISSING, stack.length === segments.length);
840
+ stack.length -= pushed;
841
+ };
842
+ const descend = (value, segmentIndex) => {
843
+ if (segmentIndex === segments.length) {
844
+ report(value, true);
806
845
  return;
807
846
  }
808
- const segment = entry.path.segments[segmentIndex];
809
- const missing = () => {
810
- const remainder = entry.path.segments.slice(segmentIndex);
811
- const concrete = [...sourcePath];
812
- for (const part of remainder) {
813
- if (part.kind === 'wildcard')
814
- break;
815
- concrete.push(part.kind === 'property' ? part.key : part.index);
816
- }
817
- results.push({ value: JSON_VIEW_PATH_MISSING, sourcePath: concrete });
818
- };
847
+ const segment = segments[segmentIndex];
819
848
  if (segment.kind === 'property') {
820
- if (!isRecord(value)) {
821
- missing();
849
+ if (!isRecord(value) || !own(value, segment.key)) {
850
+ missing(segmentIndex);
822
851
  return;
823
852
  }
824
- if (own(value, segment.key))
825
- visit(value[segment.key], segmentIndex + 1, [...sourcePath, segment.key]);
826
- else
827
- missing();
853
+ stack.push(segment.key);
854
+ descend(value[segment.key], segmentIndex + 1);
855
+ stack.pop();
828
856
  return;
829
857
  }
830
858
  if (segment.kind === 'index') {
831
- if (!Array.isArray(value)) {
832
- missing();
859
+ if (!Array.isArray(value) || !own(value, String(segment.index))) {
860
+ missing(segmentIndex);
833
861
  return;
834
862
  }
835
- if (own(value, String(segment.index)))
836
- visit(value[segment.index], segmentIndex + 1, [...sourcePath, segment.index]);
837
- else
838
- missing();
863
+ stack.push(segment.index);
864
+ descend(value[segment.index], segmentIndex + 1);
865
+ stack.pop();
839
866
  return;
840
867
  }
841
868
  if (Array.isArray(value)) {
842
- value.forEach((child, index) => visit(child, segmentIndex + 1, [...sourcePath, index]));
869
+ value.forEach((child, index) => {
870
+ stack.push(index);
871
+ descend(child, segmentIndex + 1);
872
+ stack.pop();
873
+ });
843
874
  return;
844
875
  }
845
- if (isRecord(value)) {
846
- Object.entries(value).forEach(([key, child]) => visit(child, segmentIndex + 1, [...sourcePath, key]));
876
+ if (!isRecord(value)) {
877
+ missing(segmentIndex);
878
+ return;
879
+ }
880
+ for (const key of Object.keys(value)) {
881
+ stack.push(key);
882
+ descend(value[key], segmentIndex + 1);
883
+ stack.pop();
847
884
  }
848
- else
849
- missing();
850
885
  };
851
- visit(root, 0, []);
852
- return results;
886
+ descend(root, 0);
887
+ }
888
+ function underAnyPath(paths, sourcePath) {
889
+ return paths.some((path) => path.length <= sourcePath.length && path.every((part, index) => part === sourcePath[index]));
853
890
  }
854
- function validateResolvedSchemaValue(value, descriptor, entry, sourcePath, diagnostics, typeRegistry, suppressRequiredPaths) {
855
- const details = { declaration: entry.declaration, sourcePath };
891
+ function validateResolvedSchemaValue(value, descriptor, entry, path, diagnostics, typeRegistry, suppressRequiredPaths) {
892
+ // Most values are valid, so the path is only materialized for the ones that report.
893
+ const suppressed = (sourcePath) => suppressRequiredPaths.length > 0 && underAnyPath(suppressRequiredPaths, sourcePath);
856
894
  if (value === JSON_VIEW_PATH_MISSING) {
857
- if (descriptor.required && !suppressRequiredPaths.some((path) => path.length <= sourcePath.length && path.every((part, index) => part === sourcePath[index]))) {
858
- diagnostic(diagnostics, 'value', 'required-value-missing', 'Required value is missing', entry.metadataPath, details);
895
+ if (!descriptor.required)
896
+ return;
897
+ const sourcePath = path();
898
+ if (!suppressed(sourcePath)) {
899
+ diagnostic(diagnostics, 'value', 'required-value-missing', 'Required value is missing', entry.metadataPath, { declaration: entry.declaration, sourcePath });
859
900
  }
860
901
  return;
861
902
  }
862
903
  const issue = typeRegistry.validate(value, descriptor);
863
- const suppressRequired = suppressRequiredPaths.some((path) => path.length <= sourcePath.length && path.every((part, index) => part === sourcePath[index]));
864
- if (issue && !(suppressRequired && issue === 'A value is required')) {
865
- diagnostic(diagnostics, 'value', 'invalid-typed-value', issue, entry.metadataPath, details);
866
- return;
904
+ if (issue) {
905
+ const sourcePath = path();
906
+ if (!(issue === 'A value is required' && suppressed(sourcePath))) {
907
+ diagnostic(diagnostics, 'value', 'invalid-typed-value', issue, entry.metadataPath, { declaration: entry.declaration, sourcePath });
908
+ return;
909
+ }
867
910
  }
868
- for (const warning of typeRegistry.warnings(value, descriptor)) {
869
- diagnostic(diagnostics, 'value', 'typed-value-warning', warning, entry.metadataPath, { ...details, severity: 'warning' });
911
+ const warnings = typeRegistry.warnings(value, descriptor);
912
+ if (warnings.length === 0)
913
+ return;
914
+ const sourcePath = path();
915
+ for (const warning of warnings) {
916
+ diagnostic(diagnostics, 'value', 'typed-value-warning', warning, entry.metadataPath, { declaration: entry.declaration, sourcePath, severity: 'warning' });
870
917
  }
871
918
  }
872
919
  function validateSchemaValues(root, schema, diagnostics, typeRegistry, suppressRequiredPaths) {
920
+ // A complete location has one winner among the entries of its length, so it
921
+ // can only be claimed away from this entry by an overlapping higher-ranked
922
+ // entry. Cut-off locations are the only ones several entries can share.
873
923
  const visited = new Set();
874
924
  for (const entry of schema) {
875
- for (const location of expandSchemaLocations(root, entry)) {
876
- const winner = schemaForJsonViewPath(schema, location.sourcePath);
877
- if (winner && winner !== entry)
878
- continue;
879
- const visitKey = JSON.stringify(location.sourcePath);
880
- if (visited.has(visitKey))
881
- continue;
882
- visited.add(visitKey);
883
- validateResolvedSchemaValue(location.value, entry.descriptor, entry, location.sourcePath, diagnostics, typeRegistry, suppressRequiredPaths);
884
- }
925
+ const rivals = schema.filter((other) => other !== entry
926
+ && other.path.segments.length === entry.path.segments.length
927
+ && outranks(other, entry) && pathsOverlap(other.path, entry.path));
928
+ visitSchemaLocations(root, entry, (value, path, complete) => {
929
+ if (complete) {
930
+ if (rivals.length > 0) {
931
+ const sourcePath = path();
932
+ if (rivals.some((rival) => matchesJsonViewPath(rival.path, sourcePath)))
933
+ return;
934
+ }
935
+ }
936
+ else {
937
+ const sourcePath = path();
938
+ const winner = schemaForJsonViewPath(schema, sourcePath);
939
+ if (winner && winner !== entry)
940
+ return;
941
+ const visitKey = valuePathKey(sourcePath);
942
+ if (visited.has(visitKey))
943
+ return;
944
+ visited.add(visitKey);
945
+ }
946
+ validateResolvedSchemaValue(value, entry.descriptor, entry, path, diagnostics, typeRegistry, suppressRequiredPaths);
947
+ });
885
948
  }
886
949
  }
887
- function compileMetadataValue(root, metadata, typeRegistry, recognized, inference, metadataSource = 'embedded', suppressRequiredPaths = []) {
950
+ function compileMetadataValue(root, metadata, typeRegistry, recognized, inference, metadataSource = 'embedded', suppressRequiredPaths = [], validateValues = true) {
888
951
  const diagnostics = [];
889
952
  diagnoseUnknownProperties(metadata, ANNOTATION_PROPERTIES, ['$jsonviews'], diagnostics, 'metadata', 'unknown-annotation-property');
890
953
  const schema = compileSchema(metadata.schema, diagnostics, typeRegistry);
@@ -899,7 +962,7 @@ function compileMetadataValue(root, metadata, typeRegistry, recognized, inferenc
899
962
  // Inference is a presentation hint, not a user-authored validation contract.
900
963
  // A sampled collection can contain later values that do not match the guessed
901
964
  // type, and those values must not become upload-time errors.
902
- if (metadataSource !== 'inferred') {
965
+ if (metadataSource !== 'inferred' && validateValues) {
903
966
  validateSchemaValues(root, schema, diagnostics, typeRegistry, suppressRequiredPaths);
904
967
  }
905
968
  return {
@@ -918,12 +981,13 @@ function compileMetadataValue(root, metadata, typeRegistry, recognized, inferenc
918
981
  export function compileJsonViewMetadata(root, typeRegistry = defaultTypeRegistry, options = {}) {
919
982
  assertJsonDepth(root);
920
983
  assertJsonDepth(options.metadata);
984
+ const validateValues = options.validateValues !== false;
921
985
  const inactive = () => ({
922
986
  root, status: 'none', recognized: false, active: false, schema: [], views: [], diagnostics: [],
923
987
  });
924
988
  const supplied = (metadata, metadataSource) => {
925
989
  if (isRecord(metadata) && metadata.version === 1) {
926
- return compileMetadataValue(root, metadata, typeRegistry, true, undefined, metadataSource, options.suppressRequiredPaths);
990
+ return compileMetadataValue(root, metadata, typeRegistry, true, undefined, metadataSource, options.suppressRequiredPaths, validateValues);
927
991
  }
928
992
  const unsupported = isRecord(metadata) && Number.isInteger(metadata.version) && Number(metadata.version) > 0;
929
993
  return {
@@ -948,5 +1012,5 @@ export function compileJsonViewMetadata(root, typeRegistry = defaultTypeRegistry
948
1012
  const inference = inferJsonViewMetadata(root);
949
1013
  if (Object.keys(inference.schema).length === 0 && inference.views.length === 0)
950
1014
  return inactive();
951
- return compileMetadataValue(root, inference, typeRegistry, false, inference, 'inferred', options.suppressRequiredPaths);
1015
+ return compileMetadataValue(root, inference, typeRegistry, false, inference, 'inferred', options.suppressRequiredPaths, validateValues);
952
1016
  }
@@ -1,17 +1,39 @@
1
1
  import { compileJsonViewMetadata } from './metadata.js';
2
2
  import { isJsonViewTableCandidate } from './table-suitability.js';
3
+ /** Counts scalar values, stopping once the count passes the limit. */
4
+ function countLeaves(root, limit) {
5
+ let leaves = 0;
6
+ const pending = [root];
7
+ while (pending.length) {
8
+ const value = pending.pop();
9
+ if (value !== null && typeof value === 'object')
10
+ pending.push(...Object.values(value));
11
+ else if ((leaves += 1) > limit)
12
+ break;
13
+ }
14
+ return leaves;
15
+ }
3
16
  /** Initial presentation only. Never changes a user's selection during edits. */
4
17
  export function prefersSource(content, metadata) {
5
18
  try {
6
19
  const root = JSON.parse(content);
7
- const compiled = compileJsonViewMetadata(root, undefined, metadata === undefined ? {} : { metadata });
8
- if (compiled.metadataSource !== 'inferred' || compiled.views.length || isJsonViewTableCandidate(root))
20
+ // Every scalar takes a pretty-printed line of its own, so a document with
21
+ // more than 100 of them fails the size check below without a compile.
22
+ if (countLeaves(root, 100) > 100)
9
23
  return false;
10
24
  const formatted = JSON.stringify(root, null, 2);
11
25
  const lines = formatted.split('\n').length;
12
26
  if (formatted.length > 8000 || lines > 100)
13
27
  return false;
28
+ const compiled = compileJsonViewMetadata(root, undefined, metadata === undefined ? {} : { metadata });
29
+ if (compiled.metadataSource !== 'inferred' || compiled.views.length || isJsonViewTableCandidate(root))
30
+ return false;
14
31
  const entries = root !== null && typeof root === 'object' ? Object.values(root) : [];
32
+ // Dictionaries now fall back to properties when their fields differ. Keep
33
+ // that structured presentation instead of switching them to source.
34
+ if (!Array.isArray(root) && entries.length >= 2
35
+ && entries.every((value) => value !== null && typeof value === 'object' && !Array.isArray(value)))
36
+ return false;
15
37
  // A compact root with little visible information is easier to read whole
16
38
  // than by opening each nested value, even when nesting is only one level.
17
39
  if (entries.length <= 5 && formatted.length <= 3000 && lines <= 40
@@ -1,5 +1,5 @@
1
1
  /** Conservative eligibility shared by predicted views and automatic JSON tables. */
2
- /** Two or more records with fields can render as rows and columns. */
2
+ /** Arrays imply rows; dictionaries need a substantial shared field structure. */
3
3
  export declare function isJsonViewTableCandidate(value: unknown): boolean;
4
4
  /** Shared scalar paths only; never explodes arrays or rewrites source records. */
5
5
  export declare function inferFlatColumnPaths(value: unknown): string[][] | undefined;
@@ -9,11 +9,24 @@ function isReadable(value) {
9
9
  || (typeof value === 'number' && Number.isFinite(value))
10
10
  || (typeof value === 'string' && value.trim().length > 0);
11
11
  }
12
- /** Two or more records with fields can render as rows and columns. */
12
+ /** Arrays imply rows; dictionaries need a substantial shared field structure. */
13
13
  export function isJsonViewTableCandidate(value) {
14
14
  const rows = Array.isArray(value) ? value : isRecord(value) ? Object.values(value) : [];
15
- return rows.length >= MIN_RECORDS && rows.every(isRecord)
16
- && rows.some((row) => Object.keys(row).some((key) => key !== '$jsonviews'));
15
+ if (rows.length < MIN_RECORDS || !rows.every(isRecord))
16
+ return false;
17
+ const fields = rows.map((row) => Object.keys(row).filter((key) => key !== '$jsonviews'));
18
+ if (Array.isArray(value))
19
+ return fields.some((keys) => keys.length > 0);
20
+ // Inspect the whole dictionary so late, unrelated sections cannot be missed.
21
+ // Nested values remain intact: only the fields used as columns matter here.
22
+ const counts = new Map();
23
+ for (const keys of fields) {
24
+ for (const key of keys)
25
+ counts.set(key, (counts.get(key) ?? 0) + 1);
26
+ }
27
+ const shared = new Set([...counts].filter(([, count]) => count / rows.length >= 0.8).map(([key]) => key));
28
+ return shared.size > 0 && shared.size / counts.size >= 0.5
29
+ && fields.every((keys) => keys.some((key) => shared.has(key)));
17
30
  }
18
31
  /** Shared scalar paths only; never explodes arrays or rewrites source records. */
19
32
  export function inferFlatColumnPaths(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@script-it/json-views-core",
3
- "version": "0.1.0-beta.1",
3
+ "version": "2026.10.25",
4
4
  "description": "JSON Views metadata, path, validation, and source-preserving editing core",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  "devDependencies": {
30
30
  "ajv": "^8.20.0",
31
31
  "typescript": "^5.9.3",
32
- "vitest": "3.2.7"
32
+ "vitest": "4.1.11"
33
33
  },
34
34
  "dependencies": {
35
35
  "re2js": "^2.8.6"