@actuarial-ts/data 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/compactDiagnosticJson.d.ts +24 -0
- package/dist/compactDiagnosticJson.d.ts.map +1 -0
- package/dist/compactDiagnosticJson.js +200 -0
- package/dist/compactDiagnosticJson.js.map +1 -0
- package/dist/diagnosticInput.d.ts +53 -2
- package/dist/diagnosticInput.d.ts.map +1 -1
- package/dist/diagnosticInput.js +177 -61
- package/dist/diagnosticInput.js.map +1 -1
- package/dist/diagnosticPreparedReview.d.ts +64 -1
- package/dist/diagnosticPreparedReview.d.ts.map +1 -1
- package/dist/diagnosticPreparedReview.js +384 -64
- package/dist/diagnosticPreparedReview.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/compactDiagnosticJson.ts +216 -0
- package/src/diagnosticInput.ts +298 -151
- package/src/diagnosticPreparedReview.ts +649 -111
- package/src/version.ts +1 -1
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DiagnosticValidationError,
|
|
3
|
+
diagnosticJsonPreflight,
|
|
4
|
+
isDiagnosticToken,
|
|
3
5
|
assertPreparedDiagnosticData,
|
|
6
|
+
assertCompactPreparedDiagnosticData,
|
|
7
|
+
evaluateDiagnosticReviewRulesCompact,
|
|
8
|
+
getDiagnosticReviewEvaluation,
|
|
9
|
+
getDiagnosticReviewEvaluationSummary,
|
|
10
|
+
getCompactDiagnosticReviewEvaluationsIdentityDocument,
|
|
11
|
+
getCompactPreparedDiagnosticDataFingerprint,
|
|
12
|
+
createDiagnosticIdentityArray,
|
|
13
|
+
createDiagnosticIdentityObject,
|
|
14
|
+
createDiagnosticIdentityValue,
|
|
15
|
+
fingerprintDiagnosticIdentity,
|
|
16
|
+
type DiagnosticIdentityDocument,
|
|
4
17
|
canonicalJson,
|
|
5
18
|
evaluateDiagnosticReviewRules,
|
|
6
19
|
fnv1a64,
|
|
@@ -14,7 +27,12 @@ import {
|
|
|
14
27
|
type DiagnosticReviewRuleEvaluation,
|
|
15
28
|
type DiagnosticSourceLocation,
|
|
16
29
|
type PreparedDiagnosticData,
|
|
30
|
+
type PreparedDiagnosticDataContent,
|
|
31
|
+
type CompactPreparedDiagnosticData,
|
|
32
|
+
type CompactDiagnosticReviewEvaluations,
|
|
33
|
+
type DiagnosticReviewPage,
|
|
17
34
|
} from "@actuarial-ts/core";
|
|
35
|
+
import { CompactDiagnosticJson } from "./compactDiagnosticJson.js";
|
|
18
36
|
import { z } from "zod";
|
|
19
37
|
import {
|
|
20
38
|
createNotEvaluatedDataCheck,
|
|
@@ -73,6 +91,33 @@ export interface ReviewPreparedDiagnosticDataInput {
|
|
|
73
91
|
readonly evidence: DiagnosticReviewEvidence | null;
|
|
74
92
|
}
|
|
75
93
|
|
|
94
|
+
export interface CompactDiagnosticReviewCheck {
|
|
95
|
+
readonly id: string;
|
|
96
|
+
readonly description: string;
|
|
97
|
+
readonly status: DataCheck["status"];
|
|
98
|
+
readonly details: readonly string[];
|
|
99
|
+
readonly findingCount: number;
|
|
100
|
+
}
|
|
101
|
+
declare const compactFindingsBrand: unique symbol;
|
|
102
|
+
export interface CompactDiagnosticReviewFindings {
|
|
103
|
+
readonly [compactFindingsBrand]: true;
|
|
104
|
+
readonly count: number;
|
|
105
|
+
}
|
|
106
|
+
export interface CompactDiagnosticReviewReceipt {
|
|
107
|
+
readonly definitionIntegrity: string;
|
|
108
|
+
readonly report: {
|
|
109
|
+
readonly checks: readonly CompactDiagnosticReviewCheck[];
|
|
110
|
+
readonly summary: DiagnosticDeepReadonly<DataReviewReport["summary"]>;
|
|
111
|
+
};
|
|
112
|
+
readonly evaluations: CompactDiagnosticReviewEvaluations;
|
|
113
|
+
readonly findings: CompactDiagnosticReviewFindings;
|
|
114
|
+
readonly evidence: DiagnosticDeepReadonly<DiagnosticReviewEvidence> | null;
|
|
115
|
+
}
|
|
116
|
+
export interface ReviewPreparedDiagnosticDataCompactInput {
|
|
117
|
+
readonly prepared: CompactPreparedDiagnosticData;
|
|
118
|
+
readonly evidence: DiagnosticReviewEvidence | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
76
121
|
const fixed = [
|
|
77
122
|
["diagnostic/structural/loss-identity", "Loss identities are unique", "fail"],
|
|
78
123
|
[
|
|
@@ -229,25 +274,34 @@ function compareSourceArrays(
|
|
|
229
274
|
return left.length - right.length;
|
|
230
275
|
}
|
|
231
276
|
|
|
277
|
+
function findingMergeSkeleton(finding: DataFinding): DataFinding {
|
|
278
|
+
const context = finding.context;
|
|
279
|
+
const reviewScope = context?.reviewScope;
|
|
280
|
+
return {
|
|
281
|
+
...finding,
|
|
282
|
+
...(context === undefined
|
|
283
|
+
? {}
|
|
284
|
+
: {
|
|
285
|
+
context: {
|
|
286
|
+
...context,
|
|
287
|
+
sources: [],
|
|
288
|
+
...(reviewScope === undefined
|
|
289
|
+
? {}
|
|
290
|
+
: { reviewScope: { ...reviewScope, sources: [] } }),
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function findingMergeKey(finding: DataFinding): string {
|
|
296
|
+
return canonicalJson(findingMergeSkeleton(finding));
|
|
297
|
+
}
|
|
298
|
+
|
|
232
299
|
function normalizeDataFindings(values: readonly DataFinding[]): DataFinding[] {
|
|
233
300
|
const merged = new Map<string, DataFinding>();
|
|
234
301
|
for (const finding of values) {
|
|
235
302
|
const context = finding.context;
|
|
236
303
|
const reviewScope = context?.reviewScope;
|
|
237
|
-
const key =
|
|
238
|
-
...finding,
|
|
239
|
-
...(context === undefined
|
|
240
|
-
? {}
|
|
241
|
-
: {
|
|
242
|
-
context: {
|
|
243
|
-
...context,
|
|
244
|
-
sources: [],
|
|
245
|
-
...(reviewScope === undefined
|
|
246
|
-
? {}
|
|
247
|
-
: { reviewScope: { ...reviewScope, sources: [] } }),
|
|
248
|
-
},
|
|
249
|
-
}),
|
|
250
|
-
});
|
|
304
|
+
const key = findingMergeKey(finding);
|
|
251
305
|
const previous = merged.get(key);
|
|
252
306
|
const previousContext = previous?.context;
|
|
253
307
|
const normalizedContext =
|
|
@@ -283,6 +337,10 @@ function normalizeDataFindings(values: readonly DataFinding[]): DataFinding[] {
|
|
|
283
337
|
: { context: normalizedContext }),
|
|
284
338
|
});
|
|
285
339
|
}
|
|
340
|
+
return [...merged.values()].sort(compareDataFindings);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function compareDataFindings(left: DataFinding, right: DataFinding): number {
|
|
286
344
|
const text = (left: string, right: string) =>
|
|
287
345
|
left < right ? -1 : left > right ? 1 : 0;
|
|
288
346
|
const field = (finding: DataFinding, key: keyof DataFindingContext) =>
|
|
@@ -297,50 +355,49 @@ function normalizeDataFindings(values: readonly DataFinding[]): DataFinding[] {
|
|
|
297
355
|
field(right, key) as string | undefined,
|
|
298
356
|
text,
|
|
299
357
|
);
|
|
300
|
-
return
|
|
301
|
-
(left, right)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
: { ...right.context.reviewScope, sources: [] },
|
|
331
|
-
),
|
|
332
|
-
) ||
|
|
333
|
-
textField(left, right, "sourceFile") ||
|
|
334
|
-
compareOptional(
|
|
335
|
-
field(left, "sourceRow") as number | undefined,
|
|
336
|
-
field(right, "sourceRow") as number | undefined,
|
|
337
|
-
(a, b) => a - b,
|
|
338
|
-
) ||
|
|
339
|
-
text(left.message, right.message) ||
|
|
340
|
-
compareSourceArrays(
|
|
341
|
-
left.context?.sources ?? [],
|
|
342
|
-
right.context?.sources ?? [],
|
|
358
|
+
return (
|
|
359
|
+
text(left.code, right.code) ||
|
|
360
|
+
textField(left, right, "ruleId") ||
|
|
361
|
+
textField(left, right, "measureId") ||
|
|
362
|
+
textField(left, right, "offendingKey") ||
|
|
363
|
+
textField(left, right, "groupingKey") ||
|
|
364
|
+
textField(left, right, "cachedEvidenceId") ||
|
|
365
|
+
textField(left, right, "sourceGroup") ||
|
|
366
|
+
textField(left, right, "group") ||
|
|
367
|
+
textField(left, right, "origin") ||
|
|
368
|
+
textField(left, right, "valuation") ||
|
|
369
|
+
compareOptional(
|
|
370
|
+
field(left, "developmentAge") as number | undefined,
|
|
371
|
+
field(right, "developmentAge") as number | undefined,
|
|
372
|
+
(a, b) => a - b,
|
|
373
|
+
) ||
|
|
374
|
+
textField(left, right, "ageUnit") ||
|
|
375
|
+
textField(left, right, "recordId") ||
|
|
376
|
+
textField(left, right, "claimId") ||
|
|
377
|
+
textField(left, right, "exposureKey") ||
|
|
378
|
+
text(
|
|
379
|
+
canonicalJson(
|
|
380
|
+
left.context?.reviewScope === undefined
|
|
381
|
+
? null
|
|
382
|
+
: { ...left.context.reviewScope, sources: [] },
|
|
383
|
+
),
|
|
384
|
+
canonicalJson(
|
|
385
|
+
right.context?.reviewScope === undefined
|
|
386
|
+
? null
|
|
387
|
+
: { ...right.context.reviewScope, sources: [] },
|
|
343
388
|
),
|
|
389
|
+
) ||
|
|
390
|
+
textField(left, right, "sourceFile") ||
|
|
391
|
+
compareOptional(
|
|
392
|
+
field(left, "sourceRow") as number | undefined,
|
|
393
|
+
field(right, "sourceRow") as number | undefined,
|
|
394
|
+
(a, b) => a - b,
|
|
395
|
+
) ||
|
|
396
|
+
text(left.message, right.message) ||
|
|
397
|
+
compareSourceArrays(
|
|
398
|
+
left.context?.sources ?? [],
|
|
399
|
+
right.context?.sources ?? [],
|
|
400
|
+
)
|
|
344
401
|
);
|
|
345
402
|
}
|
|
346
403
|
|
|
@@ -456,19 +513,14 @@ function findingContext(finding: DiagnosticMetricFinding): DataFindingContext {
|
|
|
456
513
|
};
|
|
457
514
|
}
|
|
458
515
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const evidence =
|
|
464
|
-
input.evidence === null
|
|
465
|
-
? null
|
|
466
|
-
: validateDiagnosticReviewEvidence(input.evidence);
|
|
467
|
-
const evaluations = evaluateDiagnosticReviewRules(input.prepared);
|
|
516
|
+
function structuralChecks(
|
|
517
|
+
prepared: PreparedDiagnosticDataContent,
|
|
518
|
+
evidence: DiagnosticDeepReadonly<DiagnosticReviewEvidence> | null,
|
|
519
|
+
): DataCheck[] {
|
|
468
520
|
const findingsByCheck = new Map<string, DataFinding[]>(
|
|
469
521
|
fixed.map(([id]) => [id, []]),
|
|
470
522
|
);
|
|
471
|
-
for (const finding of
|
|
523
|
+
for (const finding of prepared.findings) {
|
|
472
524
|
const id = codeToCheck[finding.code];
|
|
473
525
|
if (id)
|
|
474
526
|
findingsByCheck.get(id)!.push({
|
|
@@ -530,10 +582,9 @@ export function reviewPreparedDiagnosticData(
|
|
|
530
582
|
});
|
|
531
583
|
}
|
|
532
584
|
|
|
533
|
-
const hasExposureMeasures =
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
);
|
|
585
|
+
const hasExposureMeasures = prepared.definition.definition.measures.some(
|
|
586
|
+
(measure) => measure.source === "exposure",
|
|
587
|
+
);
|
|
537
588
|
const checks: DataCheck[] = fixed.map(
|
|
538
589
|
([id, description, severity], index) => {
|
|
539
590
|
if ([1, 5, 6, 7].includes(index) && !hasExposureMeasures)
|
|
@@ -542,7 +593,7 @@ export function reviewPreparedDiagnosticData(
|
|
|
542
593
|
description,
|
|
543
594
|
"the definition declares no exposure measures",
|
|
544
595
|
);
|
|
545
|
-
if (index === 8 && !
|
|
596
|
+
if (index === 8 && !prepared.expectedCellsProvided)
|
|
546
597
|
return createNotEvaluatedDataCheck(
|
|
547
598
|
id,
|
|
548
599
|
description,
|
|
@@ -562,6 +613,68 @@ export function reviewPreparedDiagnosticData(
|
|
|
562
613
|
);
|
|
563
614
|
},
|
|
564
615
|
);
|
|
616
|
+
return checks;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function evaluationFindings(
|
|
620
|
+
item: DiagnosticReviewRuleEvaluation,
|
|
621
|
+
rule: {
|
|
622
|
+
readonly id: string;
|
|
623
|
+
readonly code: string;
|
|
624
|
+
readonly description: string;
|
|
625
|
+
},
|
|
626
|
+
): DataFinding[] {
|
|
627
|
+
return [
|
|
628
|
+
...item.expressionOverflows.map((overflowItem) => ({
|
|
629
|
+
code: "diagnostic-expression-overflow",
|
|
630
|
+
message: "Measure expression overflowed",
|
|
631
|
+
context: {
|
|
632
|
+
ruleId: rule.id,
|
|
633
|
+
expressionPath: overflowItem.expressionPath,
|
|
634
|
+
reviewScope: item.scope,
|
|
635
|
+
...(overflowItem.coordinate === null ? {} : overflowItem.coordinate),
|
|
636
|
+
sources: overflowItem.sources,
|
|
637
|
+
},
|
|
638
|
+
})),
|
|
639
|
+
...(item.status === "triggered"
|
|
640
|
+
? [
|
|
641
|
+
{
|
|
642
|
+
code: rule.code,
|
|
643
|
+
message: rule.description,
|
|
644
|
+
context: {
|
|
645
|
+
ruleId: rule.id,
|
|
646
|
+
reviewScope: item.scope,
|
|
647
|
+
sources: item.scope.sources,
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
]
|
|
651
|
+
: []),
|
|
652
|
+
...(item.status === "not-evaluated"
|
|
653
|
+
? [
|
|
654
|
+
{
|
|
655
|
+
code: "diagnostic-review-rule-not-evaluated",
|
|
656
|
+
message: "Diagnostic review rule was not evaluated",
|
|
657
|
+
context: {
|
|
658
|
+
ruleId: rule.id,
|
|
659
|
+
reviewScope: item.scope,
|
|
660
|
+
sources: item.scope.sources,
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
]
|
|
664
|
+
: []),
|
|
665
|
+
];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function reviewPreparedDiagnosticData(
|
|
669
|
+
input: ReviewPreparedDiagnosticDataInput,
|
|
670
|
+
): DiagnosticReviewReceipt {
|
|
671
|
+
assertPreparedDiagnosticData(input.prepared);
|
|
672
|
+
const evidence =
|
|
673
|
+
input.evidence === null
|
|
674
|
+
? null
|
|
675
|
+
: validateDiagnosticReviewEvidence(input.evidence);
|
|
676
|
+
const evaluations = evaluateDiagnosticReviewRules(input.prepared);
|
|
677
|
+
const checks = structuralChecks(input.prepared, evidence);
|
|
565
678
|
|
|
566
679
|
for (const rule of input.prepared.definition.definition.reviewRules) {
|
|
567
680
|
const matching = evaluations.filter((item) => item.ruleId === rule.id);
|
|
@@ -579,45 +692,7 @@ export function reviewPreparedDiagnosticData(
|
|
|
579
692
|
: matching.some((item) => item.status === "not-evaluated")
|
|
580
693
|
? "not-evaluated"
|
|
581
694
|
: "pass";
|
|
582
|
-
const findings
|
|
583
|
-
...item.expressionOverflows.map((overflowItem) => ({
|
|
584
|
-
code: "diagnostic-expression-overflow",
|
|
585
|
-
message: "Measure expression overflowed",
|
|
586
|
-
context: {
|
|
587
|
-
ruleId: rule.id,
|
|
588
|
-
expressionPath: overflowItem.expressionPath,
|
|
589
|
-
reviewScope: item.scope,
|
|
590
|
-
...(overflowItem.coordinate === null ? {} : overflowItem.coordinate),
|
|
591
|
-
sources: overflowItem.sources,
|
|
592
|
-
},
|
|
593
|
-
})),
|
|
594
|
-
...(item.status === "triggered"
|
|
595
|
-
? [
|
|
596
|
-
{
|
|
597
|
-
code: rule.code,
|
|
598
|
-
message: rule.description,
|
|
599
|
-
context: {
|
|
600
|
-
ruleId: rule.id,
|
|
601
|
-
reviewScope: item.scope,
|
|
602
|
-
sources: item.scope.sources,
|
|
603
|
-
},
|
|
604
|
-
},
|
|
605
|
-
]
|
|
606
|
-
: []),
|
|
607
|
-
...(item.status === "not-evaluated"
|
|
608
|
-
? [
|
|
609
|
-
{
|
|
610
|
-
code: "diagnostic-review-rule-not-evaluated",
|
|
611
|
-
message: "Diagnostic review rule was not evaluated",
|
|
612
|
-
context: {
|
|
613
|
-
ruleId: rule.id,
|
|
614
|
-
reviewScope: item.scope,
|
|
615
|
-
sources: item.scope.sources,
|
|
616
|
-
},
|
|
617
|
-
},
|
|
618
|
-
]
|
|
619
|
-
: []),
|
|
620
|
-
]);
|
|
695
|
+
const findings = matching.flatMap((item) => evaluationFindings(item, rule));
|
|
621
696
|
const normalizedFindings = normalizeDataFindings(findings);
|
|
622
697
|
checks.push({
|
|
623
698
|
id: rule.id,
|
|
@@ -651,3 +726,466 @@ export function reviewPreparedDiagnosticData(
|
|
|
651
726
|
reportFingerprint: `fnv1a64-jcs-v1:${fnv1a64(canonicalJson({ identityVersion: 1, kind: "diagnostic-review-report", review: identityBody }))}`,
|
|
652
727
|
});
|
|
653
728
|
}
|
|
729
|
+
|
|
730
|
+
interface FindingBlock {
|
|
731
|
+
readonly checkId: string;
|
|
732
|
+
readonly start: number;
|
|
733
|
+
readonly ids: Uint32Array;
|
|
734
|
+
}
|
|
735
|
+
interface FindingState {
|
|
736
|
+
readonly table: CompactDiagnosticJson;
|
|
737
|
+
readonly blocks: readonly FindingBlock[];
|
|
738
|
+
readonly count: number;
|
|
739
|
+
}
|
|
740
|
+
const findingStates = new WeakMap<object, FindingState>();
|
|
741
|
+
const compactReceipts = new WeakSet<object>();
|
|
742
|
+
const compactReceiptPreparations = new WeakMap<
|
|
743
|
+
CompactDiagnosticReviewReceipt,
|
|
744
|
+
CompactPreparedDiagnosticData
|
|
745
|
+
>();
|
|
746
|
+
const compactReceiptFingerprints = new WeakMap<
|
|
747
|
+
CompactDiagnosticReviewReceipt,
|
|
748
|
+
string
|
|
749
|
+
>();
|
|
750
|
+
function compactError(message: string, path = "$"): never {
|
|
751
|
+
throw new DiagnosticValidationError([
|
|
752
|
+
{ domain: "input", code: "invalid-input-relationship", path, message },
|
|
753
|
+
]);
|
|
754
|
+
}
|
|
755
|
+
export function assertCompactDiagnosticReviewReceipt(
|
|
756
|
+
value: unknown,
|
|
757
|
+
): asserts value is CompactDiagnosticReviewReceipt {
|
|
758
|
+
if (
|
|
759
|
+
value === null ||
|
|
760
|
+
typeof value !== "object" ||
|
|
761
|
+
!compactReceipts.has(value)
|
|
762
|
+
)
|
|
763
|
+
compactError("Value is not an authentic compact diagnostic review receipt");
|
|
764
|
+
}
|
|
765
|
+
function findingState(store: CompactDiagnosticReviewFindings): FindingState {
|
|
766
|
+
if (store === null || typeof store !== "object" || !findingStates.has(store))
|
|
767
|
+
compactError("Value is not an authentic compact diagnostic finding store");
|
|
768
|
+
return findingStates.get(store)!;
|
|
769
|
+
}
|
|
770
|
+
function findingLocation(
|
|
771
|
+
state: FindingState,
|
|
772
|
+
index: number,
|
|
773
|
+
): { block: FindingBlock; id: number } {
|
|
774
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= state.count)
|
|
775
|
+
compactError("Finding index is outside this review", "$.index");
|
|
776
|
+
const block = state.blocks.find(
|
|
777
|
+
(block) => index >= block.start && index < block.start + block.ids.length,
|
|
778
|
+
)!;
|
|
779
|
+
return { block, id: block.ids[index - block.start]! };
|
|
780
|
+
}
|
|
781
|
+
export interface DiagnosticReviewFindingEntry {
|
|
782
|
+
readonly index: number;
|
|
783
|
+
readonly checkId: string;
|
|
784
|
+
readonly finding: DiagnosticDeepReadonly<DataFinding>;
|
|
785
|
+
}
|
|
786
|
+
type WithoutSources<T> = T extends readonly (infer V)[]
|
|
787
|
+
? readonly WithoutSources<V>[]
|
|
788
|
+
: T extends object
|
|
789
|
+
? {
|
|
790
|
+
readonly [K in keyof T as K extends "sources"
|
|
791
|
+
? "sourceCount"
|
|
792
|
+
: K]: K extends "sources" ? number : WithoutSources<T[K]>;
|
|
793
|
+
}
|
|
794
|
+
: T;
|
|
795
|
+
export interface DiagnosticReviewFindingSummary {
|
|
796
|
+
readonly index: number;
|
|
797
|
+
readonly checkId: string;
|
|
798
|
+
readonly finding: WithoutSources<DataFinding>;
|
|
799
|
+
}
|
|
800
|
+
export interface DiagnosticReviewFindingQuery {
|
|
801
|
+
readonly checkId?: string;
|
|
802
|
+
readonly offset?: number;
|
|
803
|
+
readonly limit?: number;
|
|
804
|
+
}
|
|
805
|
+
export interface DiagnosticReviewFindingSourceQuery {
|
|
806
|
+
readonly location?: "context" | "scope";
|
|
807
|
+
readonly offset?: number;
|
|
808
|
+
readonly limit?: number;
|
|
809
|
+
}
|
|
810
|
+
const findingQuerySchema = z
|
|
811
|
+
.object({
|
|
812
|
+
checkId: z.string().refine(isDiagnosticToken).optional(),
|
|
813
|
+
offset: z.number().int().nonnegative().safe().optional(),
|
|
814
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
815
|
+
})
|
|
816
|
+
.strict();
|
|
817
|
+
const findingSourceQuerySchema = z
|
|
818
|
+
.object({
|
|
819
|
+
location: z.enum(["context", "scope"]).optional(),
|
|
820
|
+
offset: z.number().int().nonnegative().safe().optional(),
|
|
821
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
822
|
+
})
|
|
823
|
+
.strict();
|
|
824
|
+
function pageResult<T>(
|
|
825
|
+
items: T[],
|
|
826
|
+
total: number,
|
|
827
|
+
offset: number,
|
|
828
|
+
): DiagnosticReviewPage<T> {
|
|
829
|
+
return Object.freeze({
|
|
830
|
+
total,
|
|
831
|
+
offset,
|
|
832
|
+
items: Object.freeze(items),
|
|
833
|
+
nextOffset: offset + items.length < total ? offset + items.length : null,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
export function getDiagnosticReviewFinding(
|
|
837
|
+
store: CompactDiagnosticReviewFindings,
|
|
838
|
+
index: number,
|
|
839
|
+
): DiagnosticReviewFindingEntry {
|
|
840
|
+
const state = findingState(store);
|
|
841
|
+
const { block, id } = findingLocation(state, index);
|
|
842
|
+
return Object.freeze({
|
|
843
|
+
index,
|
|
844
|
+
checkId: block.checkId,
|
|
845
|
+
finding: state.table.read(id) as DiagnosticDeepReadonly<DataFinding>,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
/** Full ordered evidence, including every finding's source lists. */
|
|
849
|
+
export function iterateDiagnosticReviewFindings(
|
|
850
|
+
store: CompactDiagnosticReviewFindings,
|
|
851
|
+
): IterableIterator<DiagnosticReviewFindingEntry> {
|
|
852
|
+
const state = findingState(store);
|
|
853
|
+
return (function* () {
|
|
854
|
+
for (let index = 0; index < state.count; index++)
|
|
855
|
+
yield getDiagnosticReviewFinding(store, index);
|
|
856
|
+
})();
|
|
857
|
+
}
|
|
858
|
+
/** Summary pages never expand source lists, including high-fanout control totals. */
|
|
859
|
+
export function pageDiagnosticReviewFindings(
|
|
860
|
+
store: CompactDiagnosticReviewFindings,
|
|
861
|
+
query: DiagnosticReviewFindingQuery = {},
|
|
862
|
+
): DiagnosticReviewPage<DiagnosticReviewFindingSummary> {
|
|
863
|
+
const state = findingState(store);
|
|
864
|
+
const issues = diagnosticJsonPreflight(query, "input");
|
|
865
|
+
if (issues.length) throw new DiagnosticValidationError(issues);
|
|
866
|
+
const parsed = findingQuerySchema.safeParse(query);
|
|
867
|
+
if (!parsed.success) compactError("Invalid finding page query");
|
|
868
|
+
const { checkId, offset = 0, limit = 100 } = parsed.data;
|
|
869
|
+
const blocks = state.blocks.filter(
|
|
870
|
+
(block) => checkId === undefined || block.checkId === checkId,
|
|
871
|
+
);
|
|
872
|
+
const total = blocks.reduce((sum, block) => sum + block.ids.length, 0);
|
|
873
|
+
const items: DiagnosticReviewFindingSummary[] = [];
|
|
874
|
+
let position = 0;
|
|
875
|
+
for (const block of blocks) {
|
|
876
|
+
for (
|
|
877
|
+
let local = Math.max(0, offset - position);
|
|
878
|
+
local < block.ids.length && items.length < limit;
|
|
879
|
+
local++
|
|
880
|
+
)
|
|
881
|
+
items.push(
|
|
882
|
+
Object.freeze({
|
|
883
|
+
index: block.start + local,
|
|
884
|
+
checkId: block.checkId,
|
|
885
|
+
finding: state.table.read(
|
|
886
|
+
block.ids[local]!,
|
|
887
|
+
true,
|
|
888
|
+
) as WithoutSources<DataFinding>,
|
|
889
|
+
}),
|
|
890
|
+
);
|
|
891
|
+
position += block.ids.length;
|
|
892
|
+
if (items.length >= limit) break;
|
|
893
|
+
}
|
|
894
|
+
return pageResult(items, total, offset);
|
|
895
|
+
}
|
|
896
|
+
export function pageDiagnosticReviewFindingSources(
|
|
897
|
+
store: CompactDiagnosticReviewFindings,
|
|
898
|
+
index: number,
|
|
899
|
+
query: DiagnosticReviewFindingSourceQuery = {},
|
|
900
|
+
): DiagnosticReviewPage<DiagnosticSourceLocation> {
|
|
901
|
+
const state = findingState(store);
|
|
902
|
+
const { id } = findingLocation(state, index);
|
|
903
|
+
const issues = diagnosticJsonPreflight(query, "input");
|
|
904
|
+
if (issues.length) throw new DiagnosticValidationError(issues);
|
|
905
|
+
const parsed = findingSourceQuerySchema.safeParse(query);
|
|
906
|
+
if (!parsed.success) compactError("Invalid finding source-page query");
|
|
907
|
+
const { location = "context", offset = 0, limit = 100 } = parsed.data;
|
|
908
|
+
const context = state.table.property(id, "context");
|
|
909
|
+
const parent =
|
|
910
|
+
location === "scope"
|
|
911
|
+
? state.table.property(context, "reviewScope")
|
|
912
|
+
: context;
|
|
913
|
+
const sources = state.table.property(parent, "sources");
|
|
914
|
+
if (sources === undefined) return pageResult([], 0, offset);
|
|
915
|
+
const count = state.table.length(sources);
|
|
916
|
+
return pageResult(
|
|
917
|
+
Array.from(
|
|
918
|
+
{ length: Math.max(0, Math.min(limit, count - offset)) },
|
|
919
|
+
(_, local) =>
|
|
920
|
+
state.table.read(
|
|
921
|
+
state.table.arrayItem(sources, offset + local),
|
|
922
|
+
) as DiagnosticSourceLocation,
|
|
923
|
+
),
|
|
924
|
+
count,
|
|
925
|
+
offset,
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Compact owner receipt; identity projection/hash are deliberately deferred. */
|
|
930
|
+
export function reviewPreparedDiagnosticDataCompact(
|
|
931
|
+
input: ReviewPreparedDiagnosticDataCompactInput,
|
|
932
|
+
): CompactDiagnosticReviewReceipt {
|
|
933
|
+
assertCompactPreparedDiagnosticData(input.prepared);
|
|
934
|
+
const evidence =
|
|
935
|
+
input.evidence === null
|
|
936
|
+
? null
|
|
937
|
+
: validateDiagnosticReviewEvidence(input.evidence);
|
|
938
|
+
const evaluations = evaluateDiagnosticReviewRulesCompact(input.prepared);
|
|
939
|
+
const table = new CompactDiagnosticJson();
|
|
940
|
+
const blocks: FindingBlock[] = [];
|
|
941
|
+
const checks: CompactDiagnosticReviewCheck[] = [];
|
|
942
|
+
let findingCount = 0;
|
|
943
|
+
const appendFindings = (
|
|
944
|
+
checkId: string,
|
|
945
|
+
values: Iterable<DataFinding>,
|
|
946
|
+
): FindingBlock => {
|
|
947
|
+
type SourceUnion = readonly number[] | Set<number>;
|
|
948
|
+
interface Pending {
|
|
949
|
+
keyId: number;
|
|
950
|
+
contextSources?: SourceUnion;
|
|
951
|
+
scopeSources?: SourceUnion;
|
|
952
|
+
}
|
|
953
|
+
const pending: Pending[] = [];
|
|
954
|
+
const sources: DiagnosticSourceLocation[] = [];
|
|
955
|
+
const sourceIds = new Map<string, number>();
|
|
956
|
+
const candidates = new Map<string, number | number[]>();
|
|
957
|
+
const mergeSources = (
|
|
958
|
+
previous: SourceUnion | undefined,
|
|
959
|
+
incoming: readonly DiagnosticSourceLocation[] | undefined,
|
|
960
|
+
): SourceUnion | undefined => {
|
|
961
|
+
if (incoming === undefined) return previous;
|
|
962
|
+
const ids = incoming.map((source) => {
|
|
963
|
+
const key = canonicalJson(source);
|
|
964
|
+
let id = sourceIds.get(key);
|
|
965
|
+
if (id === undefined) {
|
|
966
|
+
id = sources.length;
|
|
967
|
+
sources.push(source);
|
|
968
|
+
sourceIds.set(key, id);
|
|
969
|
+
}
|
|
970
|
+
return id;
|
|
971
|
+
});
|
|
972
|
+
if (previous === undefined) return ids;
|
|
973
|
+
if (
|
|
974
|
+
ids.every((id) =>
|
|
975
|
+
previous instanceof Set ? previous.has(id) : previous.includes(id),
|
|
976
|
+
)
|
|
977
|
+
)
|
|
978
|
+
return previous;
|
|
979
|
+
const union = previous instanceof Set ? previous : new Set(previous);
|
|
980
|
+
for (const id of ids) union.add(id);
|
|
981
|
+
return union;
|
|
982
|
+
};
|
|
983
|
+
const sourceValues = (union: SourceUnion | undefined) =>
|
|
984
|
+
[...(union ?? [])]
|
|
985
|
+
.map((id) => sources[id]!)
|
|
986
|
+
.sort(compareDiagnosticSourceLocations);
|
|
987
|
+
for (const value of values) {
|
|
988
|
+
const finding = normalizeDataFindings([value])[0]!;
|
|
989
|
+
const key = findingMergeKey(finding);
|
|
990
|
+
// Hashes index candidates only. Exact source-free keys decide equality.
|
|
991
|
+
const hash = fnv1a64(key);
|
|
992
|
+
const bucket = candidates.get(hash);
|
|
993
|
+
const possible =
|
|
994
|
+
bucket === undefined
|
|
995
|
+
? []
|
|
996
|
+
: typeof bucket === "number"
|
|
997
|
+
? [bucket]
|
|
998
|
+
: bucket;
|
|
999
|
+
const matching = possible.find(
|
|
1000
|
+
(index) => canonicalJson(table.read(pending[index]!.keyId)) === key,
|
|
1001
|
+
);
|
|
1002
|
+
let entry: Pending;
|
|
1003
|
+
if (matching === undefined) {
|
|
1004
|
+
const index = pending.length;
|
|
1005
|
+
entry = { keyId: table.add(findingMergeSkeleton(finding)) };
|
|
1006
|
+
pending.push(entry);
|
|
1007
|
+
candidates.set(
|
|
1008
|
+
hash,
|
|
1009
|
+
bucket === undefined ? index : [...possible, index],
|
|
1010
|
+
);
|
|
1011
|
+
} else {
|
|
1012
|
+
entry = pending[matching]!;
|
|
1013
|
+
entry.keyId = table.add(findingMergeSkeleton(finding));
|
|
1014
|
+
}
|
|
1015
|
+
entry.contextSources = mergeSources(
|
|
1016
|
+
entry.contextSources,
|
|
1017
|
+
finding.context?.sources,
|
|
1018
|
+
);
|
|
1019
|
+
entry.scopeSources = mergeSources(
|
|
1020
|
+
entry.scopeSources,
|
|
1021
|
+
finding.context?.reviewScope?.sources,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
// Source-free fields decide nearly every comparison. Expand source IDs only
|
|
1025
|
+
// for the contract's final tie-break, never for each ordinary comparison.
|
|
1026
|
+
pending.sort(
|
|
1027
|
+
(a, b) =>
|
|
1028
|
+
compareDataFindings(
|
|
1029
|
+
table.read(a.keyId) as DataFinding,
|
|
1030
|
+
table.read(b.keyId) as DataFinding,
|
|
1031
|
+
) ||
|
|
1032
|
+
compareSourceArrays(
|
|
1033
|
+
sourceValues(a.contextSources),
|
|
1034
|
+
sourceValues(b.contextSources),
|
|
1035
|
+
),
|
|
1036
|
+
);
|
|
1037
|
+
const ids = pending.map((entry) => {
|
|
1038
|
+
const skeleton = table.read(entry.keyId) as DataFinding;
|
|
1039
|
+
if (skeleton.context === undefined) return table.add(skeleton);
|
|
1040
|
+
const context: DataFindingContext = { ...skeleton.context };
|
|
1041
|
+
if (entry.contextSources === undefined) delete context.sources;
|
|
1042
|
+
else context.sources = sourceValues(entry.contextSources);
|
|
1043
|
+
if (context.reviewScope !== undefined)
|
|
1044
|
+
context.reviewScope = {
|
|
1045
|
+
...context.reviewScope,
|
|
1046
|
+
sources: sourceValues(entry.scopeSources),
|
|
1047
|
+
};
|
|
1048
|
+
// Each final source list is encoded once, after every duplicate was merged.
|
|
1049
|
+
return table.add({ ...skeleton, context });
|
|
1050
|
+
});
|
|
1051
|
+
const block = { checkId, start: findingCount, ids: Uint32Array.from(ids) };
|
|
1052
|
+
findingCount += ids.length;
|
|
1053
|
+
blocks.push(block);
|
|
1054
|
+
return block;
|
|
1055
|
+
};
|
|
1056
|
+
for (const check of structuralChecks(input.prepared, evidence)) {
|
|
1057
|
+
const block = appendFindings(check.id, check.findings);
|
|
1058
|
+
checks.push(
|
|
1059
|
+
Object.freeze({
|
|
1060
|
+
id: check.id,
|
|
1061
|
+
description: check.description,
|
|
1062
|
+
status: check.status,
|
|
1063
|
+
details: Object.freeze([...check.details]),
|
|
1064
|
+
findingCount: block.ids.length,
|
|
1065
|
+
}),
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
for (const [
|
|
1069
|
+
ruleIndex,
|
|
1070
|
+
rule,
|
|
1071
|
+
] of input.prepared.definition.definition.reviewRules.entries()) {
|
|
1072
|
+
const range = evaluations.rules[ruleIndex]!;
|
|
1073
|
+
const counts = range.summary;
|
|
1074
|
+
const status =
|
|
1075
|
+
counts.fail > 0
|
|
1076
|
+
? "fail"
|
|
1077
|
+
: counts.warning > 0
|
|
1078
|
+
? "warning"
|
|
1079
|
+
: counts.notEvaluated > 0
|
|
1080
|
+
? "not-evaluated"
|
|
1081
|
+
: "pass";
|
|
1082
|
+
const values = function* (): IterableIterator<DataFinding> {
|
|
1083
|
+
if (counts.pass === range.count) return;
|
|
1084
|
+
for (
|
|
1085
|
+
let index = range.start;
|
|
1086
|
+
index < range.start + range.count;
|
|
1087
|
+
index++
|
|
1088
|
+
) {
|
|
1089
|
+
if (
|
|
1090
|
+
getDiagnosticReviewEvaluationSummary(evaluations, index)
|
|
1091
|
+
.effectiveStatus === "pass"
|
|
1092
|
+
)
|
|
1093
|
+
continue;
|
|
1094
|
+
yield* evaluationFindings(
|
|
1095
|
+
getDiagnosticReviewEvaluation(evaluations, index),
|
|
1096
|
+
rule,
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
const block = appendFindings(rule.id, values());
|
|
1101
|
+
const details = Array.from(
|
|
1102
|
+
block.ids.subarray(0, 20),
|
|
1103
|
+
(id) => table.read(table.property(id, "message")!) as string,
|
|
1104
|
+
);
|
|
1105
|
+
checks.push(
|
|
1106
|
+
Object.freeze({
|
|
1107
|
+
id: rule.id,
|
|
1108
|
+
description: rule.description,
|
|
1109
|
+
status,
|
|
1110
|
+
details: Object.freeze(details),
|
|
1111
|
+
findingCount: block.ids.length,
|
|
1112
|
+
}),
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
table.seal();
|
|
1116
|
+
const findings = Object.freeze({
|
|
1117
|
+
count: findingCount,
|
|
1118
|
+
}) as CompactDiagnosticReviewFindings;
|
|
1119
|
+
findingStates.set(findings, { table, blocks, count: findingCount });
|
|
1120
|
+
const summary = summarizeDataChecks(
|
|
1121
|
+
checks.map((check) => ({
|
|
1122
|
+
...check,
|
|
1123
|
+
details: [...check.details],
|
|
1124
|
+
findings: [],
|
|
1125
|
+
})),
|
|
1126
|
+
).summary;
|
|
1127
|
+
const receipt = Object.freeze({
|
|
1128
|
+
definitionIntegrity: input.prepared.definition.definitionIntegrity,
|
|
1129
|
+
report: Object.freeze({
|
|
1130
|
+
checks: Object.freeze(checks),
|
|
1131
|
+
summary: Object.freeze(summary),
|
|
1132
|
+
}),
|
|
1133
|
+
evaluations,
|
|
1134
|
+
findings,
|
|
1135
|
+
evidence,
|
|
1136
|
+
});
|
|
1137
|
+
compactReceipts.add(receipt);
|
|
1138
|
+
compactReceiptPreparations.set(receipt, input.prepared);
|
|
1139
|
+
return receipt;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** Exact legacy review identity, available only from an authentic immutable owner. */
|
|
1143
|
+
export function getCompactDiagnosticReviewReceiptIdentityDocument(
|
|
1144
|
+
receipt: CompactDiagnosticReviewReceipt,
|
|
1145
|
+
): DiagnosticIdentityDocument {
|
|
1146
|
+
assertCompactDiagnosticReviewReceipt(receipt);
|
|
1147
|
+
const state = findingState(receipt.findings);
|
|
1148
|
+
const prepared = compactReceiptPreparations.get(receipt)!;
|
|
1149
|
+
return createDiagnosticIdentityObject({
|
|
1150
|
+
definitionIntegrity: createDiagnosticIdentityValue(
|
|
1151
|
+
receipt.definitionIntegrity,
|
|
1152
|
+
),
|
|
1153
|
+
preparationFingerprint: createDiagnosticIdentityValue(
|
|
1154
|
+
getCompactPreparedDiagnosticDataFingerprint(prepared),
|
|
1155
|
+
),
|
|
1156
|
+
evidence: createDiagnosticIdentityValue(receipt.evidence),
|
|
1157
|
+
checks: createDiagnosticIdentityArray(
|
|
1158
|
+
receipt.report.checks.length,
|
|
1159
|
+
(index) => {
|
|
1160
|
+
const check = receipt.report.checks[index]!;
|
|
1161
|
+
const block = state.blocks[index]!;
|
|
1162
|
+
return createDiagnosticIdentityObject({
|
|
1163
|
+
id: createDiagnosticIdentityValue(check.id),
|
|
1164
|
+
status: createDiagnosticIdentityValue(check.status),
|
|
1165
|
+
findings: createDiagnosticIdentityArray(block.ids.length, (local) =>
|
|
1166
|
+
state.table.identityDocument(block.ids[local]!),
|
|
1167
|
+
),
|
|
1168
|
+
});
|
|
1169
|
+
},
|
|
1170
|
+
),
|
|
1171
|
+
summary: createDiagnosticIdentityValue(receipt.report.summary),
|
|
1172
|
+
evaluations: getCompactDiagnosticReviewEvaluationsIdentityDocument(
|
|
1173
|
+
receipt.evaluations,
|
|
1174
|
+
),
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
/** Explicit evidence operation; caches only the small immutable-owner fingerprint. */
|
|
1178
|
+
export function getCompactDiagnosticReviewReceiptFingerprint(
|
|
1179
|
+
receipt: CompactDiagnosticReviewReceipt,
|
|
1180
|
+
): string {
|
|
1181
|
+
assertCompactDiagnosticReviewReceipt(receipt);
|
|
1182
|
+
let fingerprint = compactReceiptFingerprints.get(receipt);
|
|
1183
|
+
if (fingerprint === undefined) {
|
|
1184
|
+
fingerprint = fingerprintDiagnosticIdentity(
|
|
1185
|
+
getCompactDiagnosticReviewReceiptIdentityDocument(receipt),
|
|
1186
|
+
{ kind: "diagnostic-review-report", property: "review" },
|
|
1187
|
+
);
|
|
1188
|
+
compactReceiptFingerprints.set(receipt, fingerprint);
|
|
1189
|
+
}
|
|
1190
|
+
return fingerprint;
|
|
1191
|
+
}
|