@warp-drive-mirror/json-api 5.9.0-alpha.18 → 5.9.0-alpha.19

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.
@@ -45,8 +45,43 @@ export declare class Reporter {
45
45
  capabilities: CacheCapabilitiesManager;
46
46
  contextDocument: StructuredDocument<ResourceDocument>;
47
47
  errors: ErrorReport[];
48
- ast: ReturnType<typeof jsonToAst>;
49
- jsonStr: string;
48
+ /**
49
+ * The maximum number of source lines the reporter will annotate and emit
50
+ * in a single `console.log` call when producing a report. Documents with
51
+ * more lines than this are chunked across multiple calls so that no
52
+ * single call ever has to spread an unbounded number of colorization
53
+ * args (which can exceed engine call-argument/stack limits for very
54
+ * large payloads).
55
+ */
56
+ maxLines: number;
57
+ /**
58
+ * The number of source lines to show before/after each reported line when
59
+ * annotating the document. Stretches of source between annotated lines
60
+ * that are larger than this get collapsed into a single
61
+ * `... N lines skipped ...` marker rather than printed in full.
62
+ */
63
+ contextLines: number;
64
+ /**
65
+ * The number of occurrences of an identical error message to annotate
66
+ * inline before collapsing the rest. When a message recurs beyond this
67
+ * count, the last shown occurrence's annotation is suffixed with
68
+ * `(recurs N more times)` and the remaining occurrences are omitted from
69
+ * the printed document entirely (though they still count toward the
70
+ * totals in the summary line).
71
+ */
72
+ maxOccurrencesPerGroup: number;
73
+ /**
74
+ * The number of distinct error messages to annotate inline before
75
+ * refusing to show any more. Once this many distinct messages have been
76
+ * shown, further distinct messages are omitted entirely and rolled up
77
+ * into a single trailing `... and N more distinct issues ... not shown`
78
+ * line so nothing is dropped silently.
79
+ */
80
+ maxDistinctIssues: number;
81
+ _ast: ReturnType<typeof jsonToAst> | undefined;
82
+ _jsonStr: string | undefined;
83
+ get jsonStr(): string;
84
+ get ast(): ReturnType<typeof jsonToAst>;
50
85
  strict: {
51
86
  linkage: boolean;
52
87
  unknownType: boolean;
package/dist/index.js CHANGED
@@ -237,8 +237,60 @@ class Reporter {
237
237
  capabilities;
238
238
  contextDocument;
239
239
  errors = [];
240
- ast;
241
- jsonStr;
240
+
241
+ /**
242
+ * The maximum number of source lines the reporter will annotate and emit
243
+ * in a single `console.log` call when producing a report. Documents with
244
+ * more lines than this are chunked across multiple calls so that no
245
+ * single call ever has to spread an unbounded number of colorization
246
+ * args (which can exceed engine call-argument/stack limits for very
247
+ * large payloads).
248
+ */
249
+ maxLines = 500;
250
+
251
+ /**
252
+ * The number of source lines to show before/after each reported line when
253
+ * annotating the document. Stretches of source between annotated lines
254
+ * that are larger than this get collapsed into a single
255
+ * `... N lines skipped ...` marker rather than printed in full.
256
+ */
257
+ contextLines = 2;
258
+
259
+ /**
260
+ * The number of occurrences of an identical error message to annotate
261
+ * inline before collapsing the rest. When a message recurs beyond this
262
+ * count, the last shown occurrence's annotation is suffixed with
263
+ * `(recurs N more times)` and the remaining occurrences are omitted from
264
+ * the printed document entirely (though they still count toward the
265
+ * totals in the summary line).
266
+ */
267
+ maxOccurrencesPerGroup = 1;
268
+
269
+ /**
270
+ * The number of distinct error messages to annotate inline before
271
+ * refusing to show any more. Once this many distinct messages have been
272
+ * shown, further distinct messages are omitted entirely and rolled up
273
+ * into a single trailing `... and N more distinct issues ... not shown`
274
+ * line so nothing is dropped silently.
275
+ */
276
+ maxDistinctIssues = 50;
277
+ // lazy: only parse the document into a string/AST if we actually need to
278
+ // locate an error, warning, or info within it. Clean documents (the vast
279
+ // majority) never pay this cost.
280
+ get jsonStr() {
281
+ if (this._jsonStr === undefined) {
282
+ this._jsonStr = JSON.stringify(this.contextDocument.content, null, 2);
283
+ }
284
+ return this._jsonStr;
285
+ }
286
+ get ast() {
287
+ if (!this._ast) {
288
+ this._ast = jsonToAst(this.jsonStr, {
289
+ loc: true
290
+ });
291
+ }
292
+ return this._ast;
293
+ }
242
294
 
243
295
  // TODO @runspired make this configurable to consuming apps before
244
296
  // activating by default
@@ -287,10 +339,6 @@ class Reporter {
287
339
  constructor(capabilities, doc) {
288
340
  this.capabilities = capabilities;
289
341
  this.contextDocument = doc;
290
- this.jsonStr = JSON.stringify(doc.content, null, 2);
291
- this.ast = jsonToAst(this.jsonStr, {
292
- loc: true
293
- });
294
342
  }
295
343
  searchTypes(type) {
296
344
  if (!this._typeFilter) {
@@ -417,8 +465,6 @@ class Reporter {
417
465
  return REGISTERED_EXTENSIONS.get(extensionName);
418
466
  }
419
467
  report(colorize = true) {
420
- const lines = this.jsonStr.split('\n');
421
-
422
468
  // sort the errors by line, then by column, then by type
423
469
  const {
424
470
  errors
@@ -426,59 +472,163 @@ class Reporter {
426
472
  if (!errors.length) {
427
473
  return;
428
474
  }
475
+ const lines = this.jsonStr.split('\n');
429
476
  errors.sort((a, b) => {
430
477
  return a.loc.end.line < b.loc.end.line ? -1 : a.loc.end.column < b.loc.end.column ? -1 : compareType(a.type, b.type);
431
478
  });
432
479
 
433
- // store the errors in a map by line
434
- const errorMap = new Map();
435
- for (const error of errors) {
436
- const line = error.loc.end.line;
437
- if (!errorMap.has(line)) {
438
- errorMap.set(line, []);
439
- }
440
- errorMap.get(line).push(error);
441
- }
442
-
443
- // splice the errors into the lines
444
- const errorLines = [];
445
- const colors = [];
480
+ // counts reflect every error/warning/info regardless of whether it ends
481
+ // up annotated inline below, so the header total is always accurate.
446
482
  const counts = {
447
483
  error: 0,
448
484
  warning: 0,
449
485
  info: 0
450
486
  };
451
- const LINE_SIZE = String(lines.length).length;
452
- for (let i = 0; i < lines.length; i++) {
453
- const line = lines[i];
454
- errorLines.push(colorize ? `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t%c${line}%c` : `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t${line}`);
455
- colors.push(`color: grey; background-color: transparent;`,
456
- // first color sets color
457
- `color: inherit; background-color: transparent;` // second color resets the color profile
458
- );
459
- if (errorMap.has(i + 1)) {
460
- const errorsForLine = errorMap.get(i + 1);
461
- for (const error of errorsForLine) {
462
- counts[error.type]++;
463
- const {
464
- loc,
465
- message
466
- } = error;
467
- const start = loc.end.line === loc.start.line ? loc.start.column - 1 : loc.end.column - 1;
468
- const end = loc.end.column - 1;
469
- const symbol = error.type === 'error' ? '❌' : error.type === 'warning' ? '⚠️' : 'ℹ️';
470
- const errorLine = colorize ? `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}%c^${'~'.repeat(end - start)} %c//%c ${message}%c` : `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}^${'~'.repeat(end - start)} // ${message}`;
471
- errorLines.push(errorLine);
472
- colors.push(error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: grey;', error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: inherit; background-color: transparent;' // reset color
473
- );
487
+ for (const error of errors) {
488
+ counts[error.type]++;
489
+ }
490
+ const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
491
+
492
+ // group identical messages together so repeats can be collapsed down to
493
+ // a representative sample instead of printed in full.
494
+ const groups = new Map();
495
+ for (const error of errors) {
496
+ let group = groups.get(error.message);
497
+ if (!group) {
498
+ group = [];
499
+ groups.set(error.message, group);
500
+ }
501
+ group.push(error);
502
+ }
503
+ const activeErrors = new Set();
504
+ const recurrenceNoteFor = new Map();
505
+ let shownGroupCount = 0;
506
+ let hiddenGroupCount = 0;
507
+ let hiddenOccurrenceCount = 0;
508
+ for (const group of groups.values()) {
509
+ if (shownGroupCount < this.maxDistinctIssues) {
510
+ shownGroupCount++;
511
+ const showCount = Math.min(this.maxOccurrencesPerGroup, group.length);
512
+ for (let i = 0; i < showCount; i++) {
513
+ activeErrors.add(group[i]);
514
+ }
515
+ const extra = group.length - showCount;
516
+ if (extra > 0) {
517
+ recurrenceNoteFor.set(group[showCount - 1], extra);
474
518
  }
519
+ } else {
520
+ hiddenGroupCount++;
521
+ hiddenOccurrenceCount += group.length;
475
522
  }
476
523
  }
477
- const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
478
- const errorString = contextStr + `\n\n` + errorLines.join('\n');
479
524
 
480
- // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
481
- colorize ? console.log(errorString, ...colors) : console.log(errorString);
525
+ // store the active (to-be-annotated) errors in a map by line
526
+ const errorMap = new Map();
527
+ for (const error of activeErrors) {
528
+ const line = error.loc.end.line;
529
+ let errorsForLine = errorMap.get(line);
530
+ if (!errorsForLine) {
531
+ errorsForLine = [];
532
+ errorMap.set(line, errorsForLine);
533
+ }
534
+ errorsForLine.push(error);
535
+ }
536
+
537
+ // determine which stretches of source to display: a window of
538
+ // `contextLines` around every active error line, merging windows that
539
+ // are close enough together that a skip marker wouldn't save anything.
540
+ const MERGE_GAP = 3;
541
+ const activeLines = Array.from(errorMap.keys()).sort((a, b) => a - b);
542
+ const ranges = [];
543
+ for (const line of activeLines) {
544
+ const start = Math.max(1, line - this.contextLines);
545
+ const end = Math.min(lines.length, line + this.contextLines);
546
+ const lastRange = ranges[ranges.length - 1];
547
+ if (lastRange && start <= lastRange[1] + MERGE_GAP + 1) {
548
+ lastRange[1] = Math.max(lastRange[1], end);
549
+ } else {
550
+ ranges.push([start, end]);
551
+ }
552
+ }
553
+
554
+ // extend the first/last range to the document boundary when the
555
+ // leading/trailing stretch is too small for a skip marker to be worth it
556
+ const firstRange = ranges[0];
557
+ if (firstRange && firstRange[0] - 1 <= MERGE_GAP + 1) {
558
+ firstRange[0] = 1;
559
+ }
560
+ const lastRangeOverall = ranges[ranges.length - 1];
561
+ if (lastRangeOverall && lines.length - lastRangeOverall[1] <= MERGE_GAP + 1) {
562
+ lastRangeOverall[1] = lines.length;
563
+ }
564
+
565
+ // render into chunks so that no single `console.log` call has to spread
566
+ // more than `this.maxLines` worth of rendered lines as colorization
567
+ // args.
568
+ const chunks = [{
569
+ text: [],
570
+ colors: []
571
+ }];
572
+ let renderedCount = 0;
573
+ const nextLine = (text, lineColors) => {
574
+ if (renderedCount > 0 && renderedCount % this.maxLines === 0) {
575
+ chunks.push({
576
+ text: [],
577
+ colors: []
578
+ });
579
+ }
580
+ const chunk = chunks[chunks.length - 1];
581
+ chunk.text.push(text);
582
+ chunk.colors.push(...lineColors);
583
+ renderedCount++;
584
+ };
585
+ const pushSkipMarker = gap => {
586
+ nextLine(colorize ? `%c... ${gap} line${gap === 1 ? '' : 's'} skipped (no errors) ...%c` : `... ${gap} line${gap === 1 ? '' : 's'} skipped (no errors) ...`, ['color: grey; font-style: italic;', 'color: inherit; background-color: transparent;']);
587
+ };
588
+ const LINE_SIZE = String(lines.length).length;
589
+ for (let r = 0; r < ranges.length; r++) {
590
+ const [rangeStart, rangeEnd] = ranges[r];
591
+ if (r === 0) {
592
+ if (rangeStart > 1) {
593
+ pushSkipMarker(rangeStart - 1);
594
+ }
595
+ } else {
596
+ const gap = rangeStart - ranges[r - 1][1] - 1;
597
+ if (gap > 0) {
598
+ pushSkipMarker(gap);
599
+ }
600
+ }
601
+ for (let i = rangeStart; i <= rangeEnd; i++) {
602
+ const line = lines[i - 1];
603
+ nextLine(colorize ? `${String(i).padEnd(LINE_SIZE, ' ')} \t%c${line}%c` : `${String(i).padEnd(LINE_SIZE, ' ')} \t${line}`, [`color: grey; background-color: transparent;`, `color: inherit; background-color: transparent;`]);
604
+ if (errorMap.has(i)) {
605
+ for (const error of errorMap.get(i)) {
606
+ const {
607
+ loc
608
+ } = error;
609
+ const start = loc.end.line === loc.start.line ? loc.start.column - 1 : loc.end.column - 1;
610
+ const end = loc.end.column - 1;
611
+ const symbol = error.type === 'error' ? '❌' : error.type === 'warning' ? '⚠️' : 'ℹ️';
612
+ const extra = recurrenceNoteFor.get(error);
613
+ const message = extra ? `${error.message} (recurs ${extra} more time${extra === 1 ? '' : 's'})` : error.message;
614
+ nextLine(colorize ? `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}%c^${'~'.repeat(end - start)} %c//%c ${message}%c` : `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}^${'~'.repeat(end - start)} // ${message}`, [error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: grey;', error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: inherit; background-color: transparent;']);
615
+ }
616
+ }
617
+ }
618
+ }
619
+ const lastRenderedRange = ranges[ranges.length - 1];
620
+ if (lastRenderedRange && lastRenderedRange[1] < lines.length) {
621
+ pushSkipMarker(lines.length - lastRenderedRange[1]);
622
+ }
623
+ if (hiddenGroupCount > 0) {
624
+ nextLine(colorize ? `%c... and ${hiddenGroupCount} more distinct issue${hiddenGroupCount === 1 ? '' : 's'} (${hiddenOccurrenceCount} occurrence${hiddenOccurrenceCount === 1 ? '' : 's'}) not shown ...%c` : `... and ${hiddenGroupCount} more distinct issues (${hiddenOccurrenceCount} occurrences) not shown ...`, ['color: grey; font-style: italic;', 'color: inherit; background-color: transparent;']);
625
+ }
626
+ chunks.forEach((chunk, index) => {
627
+ const prefix = index === 0 ? `${contextStr}\n\n` : '';
628
+ const chunkString = prefix + chunk.text.join('\n');
629
+ // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
630
+ colorize ? console.log(chunkString, ...chunk.colors) : console.log(chunkString);
631
+ });
482
632
  if (macroCondition(getGlobalConfig().WarpDriveMirror.features.JSON_API_CACHE_VALIDATION_ERRORS)) {
483
633
  if (counts.error > 0) {
484
634
  throw new Error(contextStr);
@@ -539,6 +689,27 @@ function getRemoteField(fields, key) {
539
689
  }
540
690
  return field;
541
691
  }
692
+
693
+ /**
694
+ * Detects the common mistake of providing a field's `name` in a payload
695
+ * when the field's schema defines a `sourceKey` that should be used instead.
696
+ *
697
+ * @internal
698
+ */
699
+ function getSourceKeyMismatch(fields, key) {
700
+ const field = getRemoteField(fields, key);
701
+ if (!field) {
702
+ return undefined;
703
+ }
704
+ const sourceKey = 'sourceKey' in field ? field.sourceKey : undefined;
705
+ if (sourceKey && sourceKey !== key) {
706
+ return {
707
+ field,
708
+ sourceKey
709
+ };
710
+ }
711
+ return undefined;
712
+ }
542
713
  function addResourceToMap(map, resource, index, location) {
543
714
  if (!map.has(resource.type)) {
544
715
  map.set(resource.type, new Map());
@@ -930,7 +1101,11 @@ function validateResourceAttributes(reporter, type, resource, path) {
930
1101
  if (!field && actualField) {
931
1102
  reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's attributes. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
932
1103
  } else if (!field) {
933
- if (key.includes(':')) {
1104
+ const sourceKeyMismatch = getSourceKeyMismatch(fields, key);
1105
+ if (sourceKeyMismatch) {
1106
+ const method = reporter.strict.unknownAttribute ? 'error' : 'warn';
1107
+ reporter[method]([...path, key], `Expected the "${sourceKeyMismatch.field.kind}" field "${key}" to be provided using its sourceKey "${sourceKeyMismatch.sourceKey}" instead of its field name "${key}". Update the payload to use "${sourceKeyMismatch.sourceKey}" as the key, or remove the sourceKey from the field's definition in the ResourceSchema for "${type}" if the field name should be used instead.`);
1108
+ } else if (key.includes(':')) {
934
1109
  const extensionName = key.split(':')[0];
935
1110
  if (reporter.hasExtension(extensionName)) {
936
1111
  const extension = reporter.getExtension(extensionName);
@@ -955,16 +1130,23 @@ function validateResourceAttributes(reporter, type, resource, path) {
955
1130
  // TODO @runspired we should validate that field values are valid JSON and not instances
956
1131
  }
957
1132
  function validateResourceRelationships(reporter, type, resource, path) {
958
- const schema = reporter.schema.fields({
1133
+ const fields = reporter.schema.fields({
959
1134
  type
960
1135
  });
1136
+ const cacheFields = reporter.schema.cacheFields?.({
1137
+ type
1138
+ }) ?? fields;
961
1139
  for (const [key] of Object.entries(resource)) {
962
- const field = getRemoteField(schema, key);
963
- const actualField = schema.get(key);
1140
+ const field = getRemoteField(cacheFields, key);
1141
+ const actualField = cacheFields.get(key);
964
1142
  if (!field && actualField) {
965
1143
  reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's relationships. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
966
1144
  } else if (!field) {
967
- if (key.includes(':')) {
1145
+ const sourceKeyMismatch = getSourceKeyMismatch(fields, key);
1146
+ if (sourceKeyMismatch) {
1147
+ const method = reporter.strict.unknownRelationship ? 'error' : 'warn';
1148
+ reporter[method]([...path, key], `Expected the "${sourceKeyMismatch.field.kind}" field "${key}" to be provided using its sourceKey "${sourceKeyMismatch.sourceKey}" instead of its field name "${key}". Update the payload to use "${sourceKeyMismatch.sourceKey}" as the key, or remove the sourceKey from the field's definition in the ResourceSchema for "${type}" if the field name should be used instead.`);
1149
+ } else if (key.includes(':')) {
968
1150
  const extensionName = key.split(':')[0];
969
1151
  if (reporter.hasExtension(extensionName)) {
970
1152
  const extension = reporter.getExtension(extensionName);
@@ -236,8 +236,60 @@ class Reporter {
236
236
  capabilities;
237
237
  contextDocument;
238
238
  errors = [];
239
- ast;
240
- jsonStr;
239
+
240
+ /**
241
+ * The maximum number of source lines the reporter will annotate and emit
242
+ * in a single `console.log` call when producing a report. Documents with
243
+ * more lines than this are chunked across multiple calls so that no
244
+ * single call ever has to spread an unbounded number of colorization
245
+ * args (which can exceed engine call-argument/stack limits for very
246
+ * large payloads).
247
+ */
248
+ maxLines = 500;
249
+
250
+ /**
251
+ * The number of source lines to show before/after each reported line when
252
+ * annotating the document. Stretches of source between annotated lines
253
+ * that are larger than this get collapsed into a single
254
+ * `... N lines skipped ...` marker rather than printed in full.
255
+ */
256
+ contextLines = 2;
257
+
258
+ /**
259
+ * The number of occurrences of an identical error message to annotate
260
+ * inline before collapsing the rest. When a message recurs beyond this
261
+ * count, the last shown occurrence's annotation is suffixed with
262
+ * `(recurs N more times)` and the remaining occurrences are omitted from
263
+ * the printed document entirely (though they still count toward the
264
+ * totals in the summary line).
265
+ */
266
+ maxOccurrencesPerGroup = 1;
267
+
268
+ /**
269
+ * The number of distinct error messages to annotate inline before
270
+ * refusing to show any more. Once this many distinct messages have been
271
+ * shown, further distinct messages are omitted entirely and rolled up
272
+ * into a single trailing `... and N more distinct issues ... not shown`
273
+ * line so nothing is dropped silently.
274
+ */
275
+ maxDistinctIssues = 50;
276
+ // lazy: only parse the document into a string/AST if we actually need to
277
+ // locate an error, warning, or info within it. Clean documents (the vast
278
+ // majority) never pay this cost.
279
+ get jsonStr() {
280
+ if (this._jsonStr === undefined) {
281
+ this._jsonStr = JSON.stringify(this.contextDocument.content, null, 2);
282
+ }
283
+ return this._jsonStr;
284
+ }
285
+ get ast() {
286
+ if (!this._ast) {
287
+ this._ast = jsonToAst(this.jsonStr, {
288
+ loc: true
289
+ });
290
+ }
291
+ return this._ast;
292
+ }
241
293
 
242
294
  // TODO @runspired make this configurable to consuming apps before
243
295
  // activating by default
@@ -286,10 +338,6 @@ class Reporter {
286
338
  constructor(capabilities, doc) {
287
339
  this.capabilities = capabilities;
288
340
  this.contextDocument = doc;
289
- this.jsonStr = JSON.stringify(doc.content, null, 2);
290
- this.ast = jsonToAst(this.jsonStr, {
291
- loc: true
292
- });
293
341
  }
294
342
  searchTypes(type) {
295
343
  if (!this._typeFilter) {
@@ -416,8 +464,6 @@ class Reporter {
416
464
  return REGISTERED_EXTENSIONS.get(extensionName);
417
465
  }
418
466
  report(colorize = true) {
419
- const lines = this.jsonStr.split('\n');
420
-
421
467
  // sort the errors by line, then by column, then by type
422
468
  const {
423
469
  errors
@@ -425,59 +471,163 @@ class Reporter {
425
471
  if (!errors.length) {
426
472
  return;
427
473
  }
474
+ const lines = this.jsonStr.split('\n');
428
475
  errors.sort((a, b) => {
429
476
  return a.loc.end.line < b.loc.end.line ? -1 : a.loc.end.column < b.loc.end.column ? -1 : compareType(a.type, b.type);
430
477
  });
431
478
 
432
- // store the errors in a map by line
433
- const errorMap = new Map();
434
- for (const error of errors) {
435
- const line = error.loc.end.line;
436
- if (!errorMap.has(line)) {
437
- errorMap.set(line, []);
438
- }
439
- errorMap.get(line).push(error);
440
- }
441
-
442
- // splice the errors into the lines
443
- const errorLines = [];
444
- const colors = [];
479
+ // counts reflect every error/warning/info regardless of whether it ends
480
+ // up annotated inline below, so the header total is always accurate.
445
481
  const counts = {
446
482
  error: 0,
447
483
  warning: 0,
448
484
  info: 0
449
485
  };
450
- const LINE_SIZE = String(lines.length).length;
451
- for (let i = 0; i < lines.length; i++) {
452
- const line = lines[i];
453
- errorLines.push(colorize ? `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t%c${line}%c` : `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t${line}`);
454
- colors.push(`color: grey; background-color: transparent;`,
455
- // first color sets color
456
- `color: inherit; background-color: transparent;` // second color resets the color profile
457
- );
458
- if (errorMap.has(i + 1)) {
459
- const errorsForLine = errorMap.get(i + 1);
460
- for (const error of errorsForLine) {
461
- counts[error.type]++;
462
- const {
463
- loc,
464
- message
465
- } = error;
466
- const start = loc.end.line === loc.start.line ? loc.start.column - 1 : loc.end.column - 1;
467
- const end = loc.end.column - 1;
468
- const symbol = error.type === 'error' ? '❌' : error.type === 'warning' ? '⚠️' : 'ℹ️';
469
- const errorLine = colorize ? `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}%c^${'~'.repeat(end - start)} %c//%c ${message}%c` : `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}^${'~'.repeat(end - start)} // ${message}`;
470
- errorLines.push(errorLine);
471
- colors.push(error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: grey;', error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: inherit; background-color: transparent;' // reset color
472
- );
486
+ for (const error of errors) {
487
+ counts[error.type]++;
488
+ }
489
+ const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
490
+
491
+ // group identical messages together so repeats can be collapsed down to
492
+ // a representative sample instead of printed in full.
493
+ const groups = new Map();
494
+ for (const error of errors) {
495
+ let group = groups.get(error.message);
496
+ if (!group) {
497
+ group = [];
498
+ groups.set(error.message, group);
499
+ }
500
+ group.push(error);
501
+ }
502
+ const activeErrors = new Set();
503
+ const recurrenceNoteFor = new Map();
504
+ let shownGroupCount = 0;
505
+ let hiddenGroupCount = 0;
506
+ let hiddenOccurrenceCount = 0;
507
+ for (const group of groups.values()) {
508
+ if (shownGroupCount < this.maxDistinctIssues) {
509
+ shownGroupCount++;
510
+ const showCount = Math.min(this.maxOccurrencesPerGroup, group.length);
511
+ for (let i = 0; i < showCount; i++) {
512
+ activeErrors.add(group[i]);
513
+ }
514
+ const extra = group.length - showCount;
515
+ if (extra > 0) {
516
+ recurrenceNoteFor.set(group[showCount - 1], extra);
473
517
  }
518
+ } else {
519
+ hiddenGroupCount++;
520
+ hiddenOccurrenceCount += group.length;
474
521
  }
475
522
  }
476
- const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
477
- const errorString = contextStr + `\n\n` + errorLines.join('\n');
478
523
 
479
- // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
480
- colorize ? console.log(errorString, ...colors) : console.log(errorString);
524
+ // store the active (to-be-annotated) errors in a map by line
525
+ const errorMap = new Map();
526
+ for (const error of activeErrors) {
527
+ const line = error.loc.end.line;
528
+ let errorsForLine = errorMap.get(line);
529
+ if (!errorsForLine) {
530
+ errorsForLine = [];
531
+ errorMap.set(line, errorsForLine);
532
+ }
533
+ errorsForLine.push(error);
534
+ }
535
+
536
+ // determine which stretches of source to display: a window of
537
+ // `contextLines` around every active error line, merging windows that
538
+ // are close enough together that a skip marker wouldn't save anything.
539
+ const MERGE_GAP = 3;
540
+ const activeLines = Array.from(errorMap.keys()).sort((a, b) => a - b);
541
+ const ranges = [];
542
+ for (const line of activeLines) {
543
+ const start = Math.max(1, line - this.contextLines);
544
+ const end = Math.min(lines.length, line + this.contextLines);
545
+ const lastRange = ranges[ranges.length - 1];
546
+ if (lastRange && start <= lastRange[1] + MERGE_GAP + 1) {
547
+ lastRange[1] = Math.max(lastRange[1], end);
548
+ } else {
549
+ ranges.push([start, end]);
550
+ }
551
+ }
552
+
553
+ // extend the first/last range to the document boundary when the
554
+ // leading/trailing stretch is too small for a skip marker to be worth it
555
+ const firstRange = ranges[0];
556
+ if (firstRange && firstRange[0] - 1 <= MERGE_GAP + 1) {
557
+ firstRange[0] = 1;
558
+ }
559
+ const lastRangeOverall = ranges[ranges.length - 1];
560
+ if (lastRangeOverall && lines.length - lastRangeOverall[1] <= MERGE_GAP + 1) {
561
+ lastRangeOverall[1] = lines.length;
562
+ }
563
+
564
+ // render into chunks so that no single `console.log` call has to spread
565
+ // more than `this.maxLines` worth of rendered lines as colorization
566
+ // args.
567
+ const chunks = [{
568
+ text: [],
569
+ colors: []
570
+ }];
571
+ let renderedCount = 0;
572
+ const nextLine = (text, lineColors) => {
573
+ if (renderedCount > 0 && renderedCount % this.maxLines === 0) {
574
+ chunks.push({
575
+ text: [],
576
+ colors: []
577
+ });
578
+ }
579
+ const chunk = chunks[chunks.length - 1];
580
+ chunk.text.push(text);
581
+ chunk.colors.push(...lineColors);
582
+ renderedCount++;
583
+ };
584
+ const pushSkipMarker = gap => {
585
+ nextLine(colorize ? `%c... ${gap} line${gap === 1 ? '' : 's'} skipped (no errors) ...%c` : `... ${gap} line${gap === 1 ? '' : 's'} skipped (no errors) ...`, ['color: grey; font-style: italic;', 'color: inherit; background-color: transparent;']);
586
+ };
587
+ const LINE_SIZE = String(lines.length).length;
588
+ for (let r = 0; r < ranges.length; r++) {
589
+ const [rangeStart, rangeEnd] = ranges[r];
590
+ if (r === 0) {
591
+ if (rangeStart > 1) {
592
+ pushSkipMarker(rangeStart - 1);
593
+ }
594
+ } else {
595
+ const gap = rangeStart - ranges[r - 1][1] - 1;
596
+ if (gap > 0) {
597
+ pushSkipMarker(gap);
598
+ }
599
+ }
600
+ for (let i = rangeStart; i <= rangeEnd; i++) {
601
+ const line = lines[i - 1];
602
+ nextLine(colorize ? `${String(i).padEnd(LINE_SIZE, ' ')} \t%c${line}%c` : `${String(i).padEnd(LINE_SIZE, ' ')} \t${line}`, [`color: grey; background-color: transparent;`, `color: inherit; background-color: transparent;`]);
603
+ if (errorMap.has(i)) {
604
+ for (const error of errorMap.get(i)) {
605
+ const {
606
+ loc
607
+ } = error;
608
+ const start = loc.end.line === loc.start.line ? loc.start.column - 1 : loc.end.column - 1;
609
+ const end = loc.end.column - 1;
610
+ const symbol = error.type === 'error' ? '❌' : error.type === 'warning' ? '⚠️' : 'ℹ️';
611
+ const extra = recurrenceNoteFor.get(error);
612
+ const message = extra ? `${error.message} (recurs ${extra} more time${extra === 1 ? '' : 's'})` : error.message;
613
+ nextLine(colorize ? `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}%c^${'~'.repeat(end - start)} %c//%c ${message}%c` : `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}^${'~'.repeat(end - start)} // ${message}`, [error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: grey;', error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: inherit; background-color: transparent;']);
614
+ }
615
+ }
616
+ }
617
+ }
618
+ const lastRenderedRange = ranges[ranges.length - 1];
619
+ if (lastRenderedRange && lastRenderedRange[1] < lines.length) {
620
+ pushSkipMarker(lines.length - lastRenderedRange[1]);
621
+ }
622
+ if (hiddenGroupCount > 0) {
623
+ nextLine(colorize ? `%c... and ${hiddenGroupCount} more distinct issue${hiddenGroupCount === 1 ? '' : 's'} (${hiddenOccurrenceCount} occurrence${hiddenOccurrenceCount === 1 ? '' : 's'}) not shown ...%c` : `... and ${hiddenGroupCount} more distinct issues (${hiddenOccurrenceCount} occurrences) not shown ...`, ['color: grey; font-style: italic;', 'color: inherit; background-color: transparent;']);
624
+ }
625
+ chunks.forEach((chunk, index) => {
626
+ const prefix = index === 0 ? `${contextStr}\n\n` : '';
627
+ const chunkString = prefix + chunk.text.join('\n');
628
+ // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
629
+ colorize ? console.log(chunkString, ...chunk.colors) : console.log(chunkString);
630
+ });
481
631
  }
482
632
  }
483
633
 
@@ -533,6 +683,27 @@ function getRemoteField(fields, key) {
533
683
  }
534
684
  return field;
535
685
  }
686
+
687
+ /**
688
+ * Detects the common mistake of providing a field's `name` in a payload
689
+ * when the field's schema defines a `sourceKey` that should be used instead.
690
+ *
691
+ * @internal
692
+ */
693
+ function getSourceKeyMismatch(fields, key) {
694
+ const field = getRemoteField(fields, key);
695
+ if (!field) {
696
+ return undefined;
697
+ }
698
+ const sourceKey = 'sourceKey' in field ? field.sourceKey : undefined;
699
+ if (sourceKey && sourceKey !== key) {
700
+ return {
701
+ field,
702
+ sourceKey
703
+ };
704
+ }
705
+ return undefined;
706
+ }
536
707
  function addResourceToMap(map, resource, index, location) {
537
708
  if (!map.has(resource.type)) {
538
709
  map.set(resource.type, new Map());
@@ -924,7 +1095,11 @@ function validateResourceAttributes(reporter, type, resource, path) {
924
1095
  if (!field && actualField) {
925
1096
  reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's attributes. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
926
1097
  } else if (!field) {
927
- if (key.includes(':')) {
1098
+ const sourceKeyMismatch = getSourceKeyMismatch(fields, key);
1099
+ if (sourceKeyMismatch) {
1100
+ const method = reporter.strict.unknownAttribute ? 'error' : 'warn';
1101
+ reporter[method]([...path, key], `Expected the "${sourceKeyMismatch.field.kind}" field "${key}" to be provided using its sourceKey "${sourceKeyMismatch.sourceKey}" instead of its field name "${key}". Update the payload to use "${sourceKeyMismatch.sourceKey}" as the key, or remove the sourceKey from the field's definition in the ResourceSchema for "${type}" if the field name should be used instead.`);
1102
+ } else if (key.includes(':')) {
928
1103
  const extensionName = key.split(':')[0];
929
1104
  if (reporter.hasExtension(extensionName)) {
930
1105
  const extension = reporter.getExtension(extensionName);
@@ -949,16 +1124,23 @@ function validateResourceAttributes(reporter, type, resource, path) {
949
1124
  // TODO @runspired we should validate that field values are valid JSON and not instances
950
1125
  }
951
1126
  function validateResourceRelationships(reporter, type, resource, path) {
952
- const schema = reporter.schema.fields({
1127
+ const fields = reporter.schema.fields({
953
1128
  type
954
1129
  });
1130
+ const cacheFields = reporter.schema.cacheFields?.({
1131
+ type
1132
+ }) ?? fields;
955
1133
  for (const [key] of Object.entries(resource)) {
956
- const field = getRemoteField(schema, key);
957
- const actualField = schema.get(key);
1134
+ const field = getRemoteField(cacheFields, key);
1135
+ const actualField = cacheFields.get(key);
958
1136
  if (!field && actualField) {
959
1137
  reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's relationships. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
960
1138
  } else if (!field) {
961
- if (key.includes(':')) {
1139
+ const sourceKeyMismatch = getSourceKeyMismatch(fields, key);
1140
+ if (sourceKeyMismatch) {
1141
+ const method = reporter.strict.unknownRelationship ? 'error' : 'warn';
1142
+ reporter[method]([...path, key], `Expected the "${sourceKeyMismatch.field.kind}" field "${key}" to be provided using its sourceKey "${sourceKeyMismatch.sourceKey}" instead of its field name "${key}". Update the payload to use "${sourceKeyMismatch.sourceKey}" as the key, or remove the sourceKey from the field's definition in the ResourceSchema for "${type}" if the field name should be used instead.`);
1143
+ } else if (key.includes(':')) {
962
1144
  const extensionName = key.split(':')[0];
963
1145
  if (reporter.hasExtension(extensionName)) {
964
1146
  const extension = reporter.getExtension(extensionName);
@@ -236,8 +236,60 @@ class Reporter {
236
236
  capabilities;
237
237
  contextDocument;
238
238
  errors = [];
239
- ast;
240
- jsonStr;
239
+
240
+ /**
241
+ * The maximum number of source lines the reporter will annotate and emit
242
+ * in a single `console.log` call when producing a report. Documents with
243
+ * more lines than this are chunked across multiple calls so that no
244
+ * single call ever has to spread an unbounded number of colorization
245
+ * args (which can exceed engine call-argument/stack limits for very
246
+ * large payloads).
247
+ */
248
+ maxLines = 500;
249
+
250
+ /**
251
+ * The number of source lines to show before/after each reported line when
252
+ * annotating the document. Stretches of source between annotated lines
253
+ * that are larger than this get collapsed into a single
254
+ * `... N lines skipped ...` marker rather than printed in full.
255
+ */
256
+ contextLines = 2;
257
+
258
+ /**
259
+ * The number of occurrences of an identical error message to annotate
260
+ * inline before collapsing the rest. When a message recurs beyond this
261
+ * count, the last shown occurrence's annotation is suffixed with
262
+ * `(recurs N more times)` and the remaining occurrences are omitted from
263
+ * the printed document entirely (though they still count toward the
264
+ * totals in the summary line).
265
+ */
266
+ maxOccurrencesPerGroup = 1;
267
+
268
+ /**
269
+ * The number of distinct error messages to annotate inline before
270
+ * refusing to show any more. Once this many distinct messages have been
271
+ * shown, further distinct messages are omitted entirely and rolled up
272
+ * into a single trailing `... and N more distinct issues ... not shown`
273
+ * line so nothing is dropped silently.
274
+ */
275
+ maxDistinctIssues = 50;
276
+ // lazy: only parse the document into a string/AST if we actually need to
277
+ // locate an error, warning, or info within it. Clean documents (the vast
278
+ // majority) never pay this cost.
279
+ get jsonStr() {
280
+ if (this._jsonStr === undefined) {
281
+ this._jsonStr = JSON.stringify(this.contextDocument.content, null, 2);
282
+ }
283
+ return this._jsonStr;
284
+ }
285
+ get ast() {
286
+ if (!this._ast) {
287
+ this._ast = jsonToAst(this.jsonStr, {
288
+ loc: true
289
+ });
290
+ }
291
+ return this._ast;
292
+ }
241
293
 
242
294
  // TODO @runspired make this configurable to consuming apps before
243
295
  // activating by default
@@ -286,10 +338,6 @@ class Reporter {
286
338
  constructor(capabilities, doc) {
287
339
  this.capabilities = capabilities;
288
340
  this.contextDocument = doc;
289
- this.jsonStr = JSON.stringify(doc.content, null, 2);
290
- this.ast = jsonToAst(this.jsonStr, {
291
- loc: true
292
- });
293
341
  }
294
342
  searchTypes(type) {
295
343
  if (!this._typeFilter) {
@@ -416,8 +464,6 @@ class Reporter {
416
464
  return REGISTERED_EXTENSIONS.get(extensionName);
417
465
  }
418
466
  report(colorize = true) {
419
- const lines = this.jsonStr.split('\n');
420
-
421
467
  // sort the errors by line, then by column, then by type
422
468
  const {
423
469
  errors
@@ -425,59 +471,163 @@ class Reporter {
425
471
  if (!errors.length) {
426
472
  return;
427
473
  }
474
+ const lines = this.jsonStr.split('\n');
428
475
  errors.sort((a, b) => {
429
476
  return a.loc.end.line < b.loc.end.line ? -1 : a.loc.end.column < b.loc.end.column ? -1 : compareType(a.type, b.type);
430
477
  });
431
478
 
432
- // store the errors in a map by line
433
- const errorMap = new Map();
434
- for (const error of errors) {
435
- const line = error.loc.end.line;
436
- if (!errorMap.has(line)) {
437
- errorMap.set(line, []);
438
- }
439
- errorMap.get(line).push(error);
440
- }
441
-
442
- // splice the errors into the lines
443
- const errorLines = [];
444
- const colors = [];
479
+ // counts reflect every error/warning/info regardless of whether it ends
480
+ // up annotated inline below, so the header total is always accurate.
445
481
  const counts = {
446
482
  error: 0,
447
483
  warning: 0,
448
484
  info: 0
449
485
  };
450
- const LINE_SIZE = String(lines.length).length;
451
- for (let i = 0; i < lines.length; i++) {
452
- const line = lines[i];
453
- errorLines.push(colorize ? `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t%c${line}%c` : `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t${line}`);
454
- colors.push(`color: grey; background-color: transparent;`,
455
- // first color sets color
456
- `color: inherit; background-color: transparent;` // second color resets the color profile
457
- );
458
- if (errorMap.has(i + 1)) {
459
- const errorsForLine = errorMap.get(i + 1);
460
- for (const error of errorsForLine) {
461
- counts[error.type]++;
462
- const {
463
- loc,
464
- message
465
- } = error;
466
- const start = loc.end.line === loc.start.line ? loc.start.column - 1 : loc.end.column - 1;
467
- const end = loc.end.column - 1;
468
- const symbol = error.type === 'error' ? '❌' : error.type === 'warning' ? '⚠️' : 'ℹ️';
469
- const errorLine = colorize ? `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}%c^${'~'.repeat(end - start)} %c//%c ${message}%c` : `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}^${'~'.repeat(end - start)} // ${message}`;
470
- errorLines.push(errorLine);
471
- colors.push(error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: grey;', error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: inherit; background-color: transparent;' // reset color
472
- );
486
+ for (const error of errors) {
487
+ counts[error.type]++;
488
+ }
489
+ const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
490
+
491
+ // group identical messages together so repeats can be collapsed down to
492
+ // a representative sample instead of printed in full.
493
+ const groups = new Map();
494
+ for (const error of errors) {
495
+ let group = groups.get(error.message);
496
+ if (!group) {
497
+ group = [];
498
+ groups.set(error.message, group);
499
+ }
500
+ group.push(error);
501
+ }
502
+ const activeErrors = new Set();
503
+ const recurrenceNoteFor = new Map();
504
+ let shownGroupCount = 0;
505
+ let hiddenGroupCount = 0;
506
+ let hiddenOccurrenceCount = 0;
507
+ for (const group of groups.values()) {
508
+ if (shownGroupCount < this.maxDistinctIssues) {
509
+ shownGroupCount++;
510
+ const showCount = Math.min(this.maxOccurrencesPerGroup, group.length);
511
+ for (let i = 0; i < showCount; i++) {
512
+ activeErrors.add(group[i]);
513
+ }
514
+ const extra = group.length - showCount;
515
+ if (extra > 0) {
516
+ recurrenceNoteFor.set(group[showCount - 1], extra);
473
517
  }
518
+ } else {
519
+ hiddenGroupCount++;
520
+ hiddenOccurrenceCount += group.length;
474
521
  }
475
522
  }
476
- const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
477
- const errorString = contextStr + `\n\n` + errorLines.join('\n');
478
523
 
479
- // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
480
- colorize ? console.log(errorString, ...colors) : console.log(errorString);
524
+ // store the active (to-be-annotated) errors in a map by line
525
+ const errorMap = new Map();
526
+ for (const error of activeErrors) {
527
+ const line = error.loc.end.line;
528
+ let errorsForLine = errorMap.get(line);
529
+ if (!errorsForLine) {
530
+ errorsForLine = [];
531
+ errorMap.set(line, errorsForLine);
532
+ }
533
+ errorsForLine.push(error);
534
+ }
535
+
536
+ // determine which stretches of source to display: a window of
537
+ // `contextLines` around every active error line, merging windows that
538
+ // are close enough together that a skip marker wouldn't save anything.
539
+ const MERGE_GAP = 3;
540
+ const activeLines = Array.from(errorMap.keys()).sort((a, b) => a - b);
541
+ const ranges = [];
542
+ for (const line of activeLines) {
543
+ const start = Math.max(1, line - this.contextLines);
544
+ const end = Math.min(lines.length, line + this.contextLines);
545
+ const lastRange = ranges[ranges.length - 1];
546
+ if (lastRange && start <= lastRange[1] + MERGE_GAP + 1) {
547
+ lastRange[1] = Math.max(lastRange[1], end);
548
+ } else {
549
+ ranges.push([start, end]);
550
+ }
551
+ }
552
+
553
+ // extend the first/last range to the document boundary when the
554
+ // leading/trailing stretch is too small for a skip marker to be worth it
555
+ const firstRange = ranges[0];
556
+ if (firstRange && firstRange[0] - 1 <= MERGE_GAP + 1) {
557
+ firstRange[0] = 1;
558
+ }
559
+ const lastRangeOverall = ranges[ranges.length - 1];
560
+ if (lastRangeOverall && lines.length - lastRangeOverall[1] <= MERGE_GAP + 1) {
561
+ lastRangeOverall[1] = lines.length;
562
+ }
563
+
564
+ // render into chunks so that no single `console.log` call has to spread
565
+ // more than `this.maxLines` worth of rendered lines as colorization
566
+ // args.
567
+ const chunks = [{
568
+ text: [],
569
+ colors: []
570
+ }];
571
+ let renderedCount = 0;
572
+ const nextLine = (text, lineColors) => {
573
+ if (renderedCount > 0 && renderedCount % this.maxLines === 0) {
574
+ chunks.push({
575
+ text: [],
576
+ colors: []
577
+ });
578
+ }
579
+ const chunk = chunks[chunks.length - 1];
580
+ chunk.text.push(text);
581
+ chunk.colors.push(...lineColors);
582
+ renderedCount++;
583
+ };
584
+ const pushSkipMarker = gap => {
585
+ nextLine(colorize ? `%c... ${gap} line${gap === 1 ? '' : 's'} skipped (no errors) ...%c` : `... ${gap} line${gap === 1 ? '' : 's'} skipped (no errors) ...`, ['color: grey; font-style: italic;', 'color: inherit; background-color: transparent;']);
586
+ };
587
+ const LINE_SIZE = String(lines.length).length;
588
+ for (let r = 0; r < ranges.length; r++) {
589
+ const [rangeStart, rangeEnd] = ranges[r];
590
+ if (r === 0) {
591
+ if (rangeStart > 1) {
592
+ pushSkipMarker(rangeStart - 1);
593
+ }
594
+ } else {
595
+ const gap = rangeStart - ranges[r - 1][1] - 1;
596
+ if (gap > 0) {
597
+ pushSkipMarker(gap);
598
+ }
599
+ }
600
+ for (let i = rangeStart; i <= rangeEnd; i++) {
601
+ const line = lines[i - 1];
602
+ nextLine(colorize ? `${String(i).padEnd(LINE_SIZE, ' ')} \t%c${line}%c` : `${String(i).padEnd(LINE_SIZE, ' ')} \t${line}`, [`color: grey; background-color: transparent;`, `color: inherit; background-color: transparent;`]);
603
+ if (errorMap.has(i)) {
604
+ for (const error of errorMap.get(i)) {
605
+ const {
606
+ loc
607
+ } = error;
608
+ const start = loc.end.line === loc.start.line ? loc.start.column - 1 : loc.end.column - 1;
609
+ const end = loc.end.column - 1;
610
+ const symbol = error.type === 'error' ? '❌' : error.type === 'warning' ? '⚠️' : 'ℹ️';
611
+ const extra = recurrenceNoteFor.get(error);
612
+ const message = extra ? `${error.message} (recurs ${extra} more time${extra === 1 ? '' : 's'})` : error.message;
613
+ nextLine(colorize ? `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}%c^${'~'.repeat(end - start)} %c//%c ${message}%c` : `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}^${'~'.repeat(end - start)} // ${message}`, [error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: grey;', error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: inherit; background-color: transparent;']);
614
+ }
615
+ }
616
+ }
617
+ }
618
+ const lastRenderedRange = ranges[ranges.length - 1];
619
+ if (lastRenderedRange && lastRenderedRange[1] < lines.length) {
620
+ pushSkipMarker(lines.length - lastRenderedRange[1]);
621
+ }
622
+ if (hiddenGroupCount > 0) {
623
+ nextLine(colorize ? `%c... and ${hiddenGroupCount} more distinct issue${hiddenGroupCount === 1 ? '' : 's'} (${hiddenOccurrenceCount} occurrence${hiddenOccurrenceCount === 1 ? '' : 's'}) not shown ...%c` : `... and ${hiddenGroupCount} more distinct issues (${hiddenOccurrenceCount} occurrences) not shown ...`, ['color: grey; font-style: italic;', 'color: inherit; background-color: transparent;']);
624
+ }
625
+ chunks.forEach((chunk, index) => {
626
+ const prefix = index === 0 ? `${contextStr}\n\n` : '';
627
+ const chunkString = prefix + chunk.text.join('\n');
628
+ // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
629
+ colorize ? console.log(chunkString, ...chunk.colors) : console.log(chunkString);
630
+ });
481
631
  }
482
632
  }
483
633
 
@@ -533,6 +683,27 @@ function getRemoteField(fields, key) {
533
683
  }
534
684
  return field;
535
685
  }
686
+
687
+ /**
688
+ * Detects the common mistake of providing a field's `name` in a payload
689
+ * when the field's schema defines a `sourceKey` that should be used instead.
690
+ *
691
+ * @internal
692
+ */
693
+ function getSourceKeyMismatch(fields, key) {
694
+ const field = getRemoteField(fields, key);
695
+ if (!field) {
696
+ return undefined;
697
+ }
698
+ const sourceKey = 'sourceKey' in field ? field.sourceKey : undefined;
699
+ if (sourceKey && sourceKey !== key) {
700
+ return {
701
+ field,
702
+ sourceKey
703
+ };
704
+ }
705
+ return undefined;
706
+ }
536
707
  function addResourceToMap(map, resource, index, location) {
537
708
  if (!map.has(resource.type)) {
538
709
  map.set(resource.type, new Map());
@@ -924,7 +1095,11 @@ function validateResourceAttributes(reporter, type, resource, path) {
924
1095
  if (!field && actualField) {
925
1096
  reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's attributes. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
926
1097
  } else if (!field) {
927
- if (key.includes(':')) {
1098
+ const sourceKeyMismatch = getSourceKeyMismatch(fields, key);
1099
+ if (sourceKeyMismatch) {
1100
+ const method = reporter.strict.unknownAttribute ? 'error' : 'warn';
1101
+ reporter[method]([...path, key], `Expected the "${sourceKeyMismatch.field.kind}" field "${key}" to be provided using its sourceKey "${sourceKeyMismatch.sourceKey}" instead of its field name "${key}". Update the payload to use "${sourceKeyMismatch.sourceKey}" as the key, or remove the sourceKey from the field's definition in the ResourceSchema for "${type}" if the field name should be used instead.`);
1102
+ } else if (key.includes(':')) {
928
1103
  const extensionName = key.split(':')[0];
929
1104
  if (reporter.hasExtension(extensionName)) {
930
1105
  const extension = reporter.getExtension(extensionName);
@@ -949,16 +1124,23 @@ function validateResourceAttributes(reporter, type, resource, path) {
949
1124
  // TODO @runspired we should validate that field values are valid JSON and not instances
950
1125
  }
951
1126
  function validateResourceRelationships(reporter, type, resource, path) {
952
- const schema = reporter.schema.fields({
1127
+ const fields = reporter.schema.fields({
953
1128
  type
954
1129
  });
1130
+ const cacheFields = reporter.schema.cacheFields?.({
1131
+ type
1132
+ }) ?? fields;
955
1133
  for (const [key] of Object.entries(resource)) {
956
- const field = getRemoteField(schema, key);
957
- const actualField = schema.get(key);
1134
+ const field = getRemoteField(cacheFields, key);
1135
+ const actualField = cacheFields.get(key);
958
1136
  if (!field && actualField) {
959
1137
  reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's relationships. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
960
1138
  } else if (!field) {
961
- if (key.includes(':')) {
1139
+ const sourceKeyMismatch = getSourceKeyMismatch(fields, key);
1140
+ if (sourceKeyMismatch) {
1141
+ const method = reporter.strict.unknownRelationship ? 'error' : 'warn';
1142
+ reporter[method]([...path, key], `Expected the "${sourceKeyMismatch.field.kind}" field "${key}" to be provided using its sourceKey "${sourceKeyMismatch.sourceKey}" instead of its field name "${key}". Update the payload to use "${sourceKeyMismatch.sourceKey}" as the key, or remove the sourceKey from the field's definition in the ResourceSchema for "${type}" if the field name should be used instead.`);
1143
+ } else if (key.includes(':')) {
962
1144
  const extensionName = key.split(':')[0];
963
1145
  if (reporter.hasExtension(extensionName)) {
964
1146
  const extension = reporter.getExtension(extensionName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warp-drive-mirror/json-api",
3
- "version": "5.9.0-alpha.18",
3
+ "version": "5.9.0-alpha.19",
4
4
  "description": "A {json:api} Cache Implementation for WarpDrive",
5
5
  "keywords": [
6
6
  "ember-addon"
@@ -40,7 +40,7 @@
40
40
  }
41
41
  },
42
42
  "peerDependencies": {
43
- "@warp-drive-mirror/core": "5.9.0-alpha.18"
43
+ "@warp-drive-mirror/core": "5.9.0-alpha.19"
44
44
  },
45
45
  "dependencies": {
46
46
  "@embroider/macros": "^1.19.6",
@@ -52,8 +52,8 @@
52
52
  "@babel/plugin-transform-typescript": "^7.28.0",
53
53
  "@babel/preset-typescript": "^7.27.1",
54
54
  "@types/json-to-ast": "^2.1.4",
55
- "@warp-drive/internal-config": "5.9.0-alpha.18",
56
- "@warp-drive-mirror/core": "5.9.0-alpha.18",
55
+ "@warp-drive/internal-config": "5.9.0-alpha.19",
56
+ "@warp-drive-mirror/core": "5.9.0-alpha.19",
57
57
  "decorator-transforms": "^2.3.0",
58
58
  "expect-type": "^1.2.2",
59
59
  "typescript": "^5.9.3",