@warp-drive/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.
- package/declarations/-private/validator/utils.d.ts +37 -2
- package/dist/index.js +235 -53
- package/dist/unpkg/dev/index.js +235 -53
- package/dist/unpkg/dev-deprecated/index.js +235 -53
- package/package.json +4 -4
|
@@ -45,8 +45,43 @@ export declare class Reporter {
|
|
|
45
45
|
capabilities: CacheCapabilitiesManager;
|
|
46
46
|
contextDocument: StructuredDocument<ResourceDocument>;
|
|
47
47
|
errors: ErrorReport[];
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
//
|
|
434
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
//
|
|
481
|
-
|
|
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().WarpDrive.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
|
-
|
|
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
|
|
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(
|
|
963
|
-
const actualField =
|
|
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
|
-
|
|
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);
|
package/dist/unpkg/dev/index.js
CHANGED
|
@@ -236,8 +236,60 @@ class Reporter {
|
|
|
236
236
|
capabilities;
|
|
237
237
|
contextDocument;
|
|
238
238
|
errors = [];
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
//
|
|
433
|
-
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
//
|
|
480
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
957
|
-
const actualField =
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
//
|
|
433
|
-
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
//
|
|
480
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
957
|
-
const actualField =
|
|
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
|
-
|
|
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/json-api",
|
|
3
|
-
"version": "5.9.0-alpha.
|
|
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/core": "5.9.0-alpha.
|
|
43
|
+
"@warp-drive/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.
|
|
56
|
-
"@warp-drive/core": "5.9.0-alpha.
|
|
55
|
+
"@warp-drive/internal-config": "5.9.0-alpha.19",
|
|
56
|
+
"@warp-drive/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",
|