@actuarial-ts/core 0.10.0 → 0.11.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 +9 -7
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +64 -5
- package/dist/canonical.js.map +1 -1
- package/dist/customizationContracts.d.ts +10 -1
- package/dist/customizationContracts.d.ts.map +1 -1
- package/dist/descriptiveStatistics.js +1 -1
- package/dist/descriptiveStatistics.js.map +1 -1
- package/dist/diagnosticAggregation.d.ts +26 -0
- package/dist/diagnosticAggregation.d.ts.map +1 -1
- package/dist/diagnosticAggregation.js +125 -11
- package/dist/diagnosticAggregation.js.map +1 -1
- package/dist/diagnosticAuditText.d.ts +11 -0
- package/dist/diagnosticAuditText.d.ts.map +1 -0
- package/dist/diagnosticAuditText.js +179 -0
- package/dist/diagnosticAuditText.js.map +1 -0
- package/dist/diagnosticFormulas.d.ts.map +1 -1
- package/dist/diagnosticFormulas.js +71 -12
- package/dist/diagnosticFormulas.js.map +1 -1
- package/dist/diagnosticFreeze.d.ts +15 -1
- package/dist/diagnosticFreeze.d.ts.map +1 -1
- package/dist/diagnosticFreeze.js +82 -31
- package/dist/diagnosticFreeze.js.map +1 -1
- package/dist/diagnosticKeys.d.ts +9 -0
- package/dist/diagnosticKeys.d.ts.map +1 -0
- package/dist/diagnosticKeys.js +20 -0
- package/dist/diagnosticKeys.js.map +1 -0
- package/dist/diagnosticOrdering.d.ts.map +1 -1
- package/dist/diagnosticOrdering.js +9 -2
- package/dist/diagnosticOrdering.js.map +1 -1
- package/dist/diagnosticPeriods.d.ts.map +1 -1
- package/dist/diagnosticPeriods.js +19 -1
- package/dist/diagnosticPeriods.js.map +1 -1
- package/dist/diagnosticPreparation.d.ts +19 -0
- package/dist/diagnosticPreparation.d.ts.map +1 -1
- package/dist/diagnosticPreparation.js +544 -231
- package/dist/diagnosticPreparation.js.map +1 -1
- package/dist/diagnosticReview.d.ts.map +1 -1
- package/dist/diagnosticReview.js +419 -186
- package/dist/diagnosticReview.js.map +1 -1
- package/dist/diagnosticReviewSources.d.ts +6 -1
- package/dist/diagnosticReviewSources.d.ts.map +1 -1
- package/dist/diagnosticReviewSources.js +59 -14
- package/dist/diagnosticReviewSources.js.map +1 -1
- package/dist/diagnosticReviewStore.d.ts.map +1 -1
- package/dist/diagnosticReviewStore.js +48 -7
- package/dist/diagnosticReviewStore.js.map +1 -1
- package/dist/diagnosticRunner.d.ts.map +1 -1
- package/dist/diagnosticRunner.js +112 -37
- package/dist/diagnosticRunner.js.map +1 -1
- package/dist/diagnosticRuntime.d.ts.map +1 -1
- package/dist/diagnosticRuntime.js +11 -1
- package/dist/diagnosticRuntime.js.map +1 -1
- package/dist/diagnosticSourceOrdering.d.ts +27 -0
- package/dist/diagnosticSourceOrdering.d.ts.map +1 -1
- package/dist/diagnosticSourceOrdering.js +98 -12
- package/dist/diagnosticSourceOrdering.js.map +1 -1
- package/dist/mack.d.ts.map +1 -1
- package/dist/mack.js +35 -0
- package/dist/mack.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/canonical.ts +63 -5
- package/src/customizationContracts.ts +10 -1
- package/src/descriptiveStatistics.ts +1 -1
- package/src/diagnosticAggregation.ts +173 -9
- package/src/diagnosticAuditText.ts +187 -0
- package/src/diagnosticFormulas.ts +77 -27
- package/src/diagnosticFreeze.ts +75 -30
- package/src/diagnosticKeys.ts +22 -0
- package/src/diagnosticOrdering.ts +9 -8
- package/src/diagnosticPeriods.ts +21 -1
- package/src/diagnosticPreparation.ts +624 -272
- package/src/diagnosticReview.ts +566 -268
- package/src/diagnosticReviewSources.ts +69 -18
- package/src/diagnosticReviewStore.ts +48 -6
- package/src/diagnosticRunner.ts +133 -54
- package/src/diagnosticRuntime.ts +11 -1
- package/src/diagnosticSourceOrdering.ts +115 -11
- package/src/mack.ts +41 -0
- package/src/types.ts +1 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { canonicalJson } from "./canonical.js";
|
|
2
|
+
import type { DiagnosticSourceLocation } from "./diagnosticDefinitions.js";
|
|
3
|
+
import type { DiagnosticAuditedNumericValue } from "./diagnosticExposure.js";
|
|
4
|
+
import type { DiagnosticInputAuditRecord } from "./diagnosticPreparation.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Canonical JSON text of an input audit snapshot, byte-identical to
|
|
8
|
+
* `canonicalJson(record)`. Snapshots have three fixed shapes whose sorted key
|
|
9
|
+
* order is known, so each is rendered directly with the same string escaping
|
|
10
|
+
* and number rules. Any snapshot that does not match its shape exactly (an
|
|
11
|
+
* unexpected key, prototype or value type) defers to `canonicalJson`, so the
|
|
12
|
+
* text and every error stay those of the generic serializer.
|
|
13
|
+
*/
|
|
14
|
+
export function auditRecordCanonicalJson(
|
|
15
|
+
record: DiagnosticInputAuditRecord["record"],
|
|
16
|
+
): string {
|
|
17
|
+
return snapshotText(record) ?? canonicalJson(record);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Snapshot = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `JSON.stringify` for a string. Printable ASCII other than `"` and `\` is
|
|
24
|
+
* never escaped, so such a string is quoted directly; everything else (control
|
|
25
|
+
* characters, `"`, `\`, and any non-ASCII, including lone surrogates) goes to
|
|
26
|
+
* the native serializer, which owns the escape spelling.
|
|
27
|
+
*/
|
|
28
|
+
const quote = (value: string): string => {
|
|
29
|
+
for (let index = 0; index < value.length; index++) {
|
|
30
|
+
const code = value.charCodeAt(index);
|
|
31
|
+
if (code < 0x20 || code > 0x7e || code === 0x22 || code === 0x5c)
|
|
32
|
+
return JSON.stringify(value);
|
|
33
|
+
}
|
|
34
|
+
return `"${value}"`;
|
|
35
|
+
};
|
|
36
|
+
const number = (value: number): string | undefined =>
|
|
37
|
+
Number.isFinite(value) ? (Object.is(value, -0) ? "0" : String(value)) : undefined;
|
|
38
|
+
const hasOwn = (value: object, key: string): boolean =>
|
|
39
|
+
Object.prototype.hasOwnProperty.call(value, key);
|
|
40
|
+
const plain = (value: unknown): value is Snapshot =>
|
|
41
|
+
value !== null &&
|
|
42
|
+
typeof value === "object" &&
|
|
43
|
+
!Array.isArray(value) &&
|
|
44
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
45
|
+
|
|
46
|
+
function stringOrNull(value: unknown): string | undefined {
|
|
47
|
+
return value === null ? "null" : typeof value === "string" ? quote(value) : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Source keys in canonical order: artifactId, sourceCell, sourceFile, sourceRow, sourceSheet. */
|
|
51
|
+
function sourceText(value: unknown): string | undefined {
|
|
52
|
+
if (value === null) return "null";
|
|
53
|
+
if (!plain(value)) return undefined;
|
|
54
|
+
const source = value as Partial<DiagnosticSourceLocation>;
|
|
55
|
+
if (typeof source.artifactId !== "string") return undefined;
|
|
56
|
+
let text = `{"artifactId":${quote(source.artifactId)}`;
|
|
57
|
+
let count = 1;
|
|
58
|
+
for (const field of ["sourceCell", "sourceFile"] as const)
|
|
59
|
+
if (hasOwn(source, field)) {
|
|
60
|
+
const item = source[field];
|
|
61
|
+
if (typeof item !== "string") return undefined;
|
|
62
|
+
text += `,"${field}":${quote(item)}`;
|
|
63
|
+
count++;
|
|
64
|
+
}
|
|
65
|
+
if (hasOwn(source, "sourceRow")) {
|
|
66
|
+
const item = source.sourceRow;
|
|
67
|
+
const rendered = typeof item === "number" ? number(item) : undefined;
|
|
68
|
+
if (rendered === undefined) return undefined;
|
|
69
|
+
text += `,"sourceRow":${rendered}`;
|
|
70
|
+
count++;
|
|
71
|
+
}
|
|
72
|
+
if (hasOwn(source, "sourceSheet")) {
|
|
73
|
+
const item = source.sourceSheet;
|
|
74
|
+
if (typeof item !== "string") return undefined;
|
|
75
|
+
text += `,"sourceSheet":${quote(item)}`;
|
|
76
|
+
count++;
|
|
77
|
+
}
|
|
78
|
+
if (Object.keys(source).length !== count) return undefined;
|
|
79
|
+
return `${text}}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Audited numeric records are immutable SDK-owned values that preparation
|
|
84
|
+
* shares across rows, so each distinct record's text is built once. Only a
|
|
85
|
+
* frozen record is remembered, and its text depends on nothing else.
|
|
86
|
+
*/
|
|
87
|
+
const auditedTexts = new WeakMap<object, string>();
|
|
88
|
+
|
|
89
|
+
function auditedText(value: unknown): string | undefined {
|
|
90
|
+
if (!plain(value)) return undefined;
|
|
91
|
+
const cached = auditedTexts.get(value);
|
|
92
|
+
if (cached !== undefined) return cached;
|
|
93
|
+
const text = auditedTextOf(value);
|
|
94
|
+
if (text !== undefined && Object.isFrozen(value)) auditedTexts.set(value, text);
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function auditedTextOf(value: Snapshot): string | undefined {
|
|
99
|
+
const audited = value as Partial<DiagnosticAuditedNumericValue>;
|
|
100
|
+
const keys = Object.keys(audited).length;
|
|
101
|
+
if (audited.status === "observed") {
|
|
102
|
+
const rendered = typeof audited.value === "number" ? number(audited.value) : undefined;
|
|
103
|
+
return keys === 2 && rendered !== undefined
|
|
104
|
+
? `{"status":"observed","value":${rendered}}`
|
|
105
|
+
: undefined;
|
|
106
|
+
}
|
|
107
|
+
if (audited.status === "missing")
|
|
108
|
+
return keys === 2 && audited.value === null ? '{"status":"missing","value":null}' : undefined;
|
|
109
|
+
if (audited.status === "non-finite")
|
|
110
|
+
return keys === 3 && audited.value === null && typeof audited.nonFiniteKind === "string"
|
|
111
|
+
? `{"nonFiniteKind":${quote(audited.nonFiniteKind)},"status":"non-finite","value":null}`
|
|
112
|
+
: undefined;
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function measuresText(value: unknown): string | undefined {
|
|
117
|
+
if (!plain(value)) return undefined;
|
|
118
|
+
const keys = Object.keys(value);
|
|
119
|
+
for (let index = 1; index < keys.length; index++)
|
|
120
|
+
if (keys[index - 1]! > keys[index]!) {
|
|
121
|
+
keys.sort();
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
let text = "{";
|
|
125
|
+
for (let index = 0; index < keys.length; index++) {
|
|
126
|
+
const key = keys[index]!;
|
|
127
|
+
const rendered = auditedText(value[key]);
|
|
128
|
+
if (rendered === undefined) return undefined;
|
|
129
|
+
text += `${index > 0 ? "," : ""}${quote(key)}:${rendered}`;
|
|
130
|
+
}
|
|
131
|
+
return `${text}}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function snapshotText(record: unknown): string | undefined {
|
|
135
|
+
if (!plain(record)) return undefined;
|
|
136
|
+
const keys = Object.keys(record).length;
|
|
137
|
+
if (hasOwn(record, "rowType")) {
|
|
138
|
+
// Loss: claimId, complete, measures, origin, recordId, rowType, source, sourceGroup, valuation.
|
|
139
|
+
if (keys !== 9) return undefined;
|
|
140
|
+
const claimId = stringOrNull(record.claimId);
|
|
141
|
+
const measures = measuresText(record.measures);
|
|
142
|
+
const source = sourceText(record.source);
|
|
143
|
+
if (
|
|
144
|
+
claimId === undefined ||
|
|
145
|
+
typeof record.complete !== "boolean" ||
|
|
146
|
+
measures === undefined ||
|
|
147
|
+
typeof record.origin !== "string" ||
|
|
148
|
+
typeof record.recordId !== "string" ||
|
|
149
|
+
typeof record.rowType !== "string" ||
|
|
150
|
+
source === undefined ||
|
|
151
|
+
typeof record.sourceGroup !== "string" ||
|
|
152
|
+
typeof record.valuation !== "string"
|
|
153
|
+
)
|
|
154
|
+
return undefined;
|
|
155
|
+
return `{"claimId":${claimId},"complete":${record.complete},"measures":${measures},"origin":${quote(record.origin)},"recordId":${quote(record.recordId)},"rowType":${quote(record.rowType)},"source":${source},"sourceGroup":${quote(record.sourceGroup)},"valuation":${quote(record.valuation)}}`;
|
|
156
|
+
}
|
|
157
|
+
if (hasOwn(record, "measureId")) {
|
|
158
|
+
// Exposure: complete, key, measureId, origin, source, sourceGroup, valuation, value.
|
|
159
|
+
if (keys !== 8) return undefined;
|
|
160
|
+
const source = sourceText(record.source);
|
|
161
|
+
const valuation = stringOrNull(record.valuation);
|
|
162
|
+
const audited = auditedText(record.value);
|
|
163
|
+
if (
|
|
164
|
+
typeof record.complete !== "boolean" ||
|
|
165
|
+
typeof record.key !== "string" ||
|
|
166
|
+
typeof record.measureId !== "string" ||
|
|
167
|
+
typeof record.origin !== "string" ||
|
|
168
|
+
source === undefined ||
|
|
169
|
+
typeof record.sourceGroup !== "string" ||
|
|
170
|
+
valuation === undefined ||
|
|
171
|
+
audited === undefined
|
|
172
|
+
)
|
|
173
|
+
return undefined;
|
|
174
|
+
return `{"complete":${record.complete},"key":${quote(record.key)},"measureId":${quote(record.measureId)},"origin":${quote(record.origin)},"source":${source},"sourceGroup":${quote(record.sourceGroup)},"valuation":${valuation},"value":${audited}}`;
|
|
175
|
+
}
|
|
176
|
+
// Expected cell: origin, source, sourceGroup, valuation.
|
|
177
|
+
if (keys !== 4) return undefined;
|
|
178
|
+
const source = sourceText(record.source);
|
|
179
|
+
if (
|
|
180
|
+
typeof record.origin !== "string" ||
|
|
181
|
+
source === undefined ||
|
|
182
|
+
typeof record.sourceGroup !== "string" ||
|
|
183
|
+
typeof record.valuation !== "string"
|
|
184
|
+
)
|
|
185
|
+
return undefined;
|
|
186
|
+
return `{"origin":${quote(record.origin)},"source":${source},"sourceGroup":${quote(record.sourceGroup)},"valuation":${quote(record.valuation)}}`;
|
|
187
|
+
}
|
|
@@ -188,10 +188,50 @@ function pointer(path: string, segment: string | number): string {
|
|
|
188
188
|
return `${path}/${escaped}`;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Shared immutable empties for clean operands; every consumer copies or iterates.
|
|
192
|
+
const NO_REASONS: readonly DiagnosticRuleNotEvaluatedReason[] = Object.freeze([]);
|
|
193
|
+
const NO_OVERFLOWS: readonly DiagnosticExpressionOverflow[] = Object.freeze([]);
|
|
194
|
+
const NO_SOURCES: readonly import("./diagnosticDefinitions.js").DiagnosticSourceLocation[] =
|
|
195
|
+
Object.freeze([]);
|
|
196
|
+
|
|
197
|
+
// Path segments below the caller's root, pushed and popped during one
|
|
198
|
+
// synchronous evaluation. Rendering happens only when an overflow must cite a
|
|
199
|
+
// path, so the common case allocates nothing. A re-entrant call while the
|
|
200
|
+
// shared stack is in use gets its own stack.
|
|
201
|
+
let sharedSegments: (string | number)[] | null = [];
|
|
202
|
+
// A measure leaf never renders a path; it may evaluate against a fixed empty stack.
|
|
203
|
+
const LEAF_SEGMENTS: (string | number)[] = [];
|
|
204
|
+
|
|
191
205
|
export function evaluateDiagnosticMeasureExpression(
|
|
192
206
|
expression: DiagnosticDeepReadonly<DiagnosticMeasureExpression>,
|
|
193
207
|
measures: Readonly<Record<string, FinalizedDiagnosticMeasure>>,
|
|
194
208
|
path: string,
|
|
209
|
+
): ExpressionResult {
|
|
210
|
+
if (expression.op === "measure")
|
|
211
|
+
return evaluateMeasureExpression(expression, measures, path, LEAF_SEGMENTS);
|
|
212
|
+
const segments = sharedSegments ?? [];
|
|
213
|
+
sharedSegments = null;
|
|
214
|
+
segments.length = 0;
|
|
215
|
+
try {
|
|
216
|
+
return evaluateMeasureExpression(expression, measures, path, segments);
|
|
217
|
+
} finally {
|
|
218
|
+
sharedSegments = segments;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderPath(root: string, segments: readonly (string | number)[]): string {
|
|
223
|
+
let path = root;
|
|
224
|
+
for (let index = 0; index < segments.length; index++)
|
|
225
|
+
path = pointer(path, segments[index]!);
|
|
226
|
+
return path;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Expression paths are only rendered when an overflow has to cite one. */
|
|
230
|
+
function evaluateMeasureExpression(
|
|
231
|
+
expression: DiagnosticDeepReadonly<DiagnosticMeasureExpression>,
|
|
232
|
+
measures: Readonly<Record<string, FinalizedDiagnosticMeasure>>,
|
|
233
|
+
root: string,
|
|
234
|
+
segments: (string | number)[],
|
|
195
235
|
): ExpressionResult {
|
|
196
236
|
if (expression.op === "measure") {
|
|
197
237
|
const value = hasDiagnosticOwn(measures, expression.measureId)
|
|
@@ -201,34 +241,44 @@ export function evaluateDiagnosticMeasureExpression(
|
|
|
201
241
|
? {
|
|
202
242
|
value: value.quantity.value,
|
|
203
243
|
reasons: value.readiness,
|
|
204
|
-
overflows: value.expressionOverflows ??
|
|
205
|
-
sources: value.sources ??
|
|
244
|
+
overflows: value.expressionOverflows ?? NO_OVERFLOWS,
|
|
245
|
+
sources: value.sources ?? NO_SOURCES,
|
|
206
246
|
}
|
|
207
|
-
: {
|
|
247
|
+
: {
|
|
248
|
+
value: null,
|
|
249
|
+
reasons: ["missing"],
|
|
250
|
+
overflows: NO_OVERFLOWS,
|
|
251
|
+
sources: NO_SOURCES,
|
|
252
|
+
};
|
|
208
253
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
254
|
+
let children: ExpressionResult[];
|
|
255
|
+
if (expression.op === "add") {
|
|
256
|
+
children = new Array(expression.terms.length);
|
|
257
|
+
segments.push("terms", 0);
|
|
258
|
+
try {
|
|
259
|
+
for (let index = 0; index < expression.terms.length; index++) {
|
|
260
|
+
segments[segments.length - 1] = index;
|
|
261
|
+
children[index] = evaluateMeasureExpression(expression.terms[index]!, measures, root, segments);
|
|
262
|
+
}
|
|
263
|
+
} finally {
|
|
264
|
+
segments.length -= 2;
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
segments.push("left");
|
|
268
|
+
try {
|
|
269
|
+
const left = evaluateMeasureExpression(expression.left, measures, root, segments);
|
|
270
|
+
segments[segments.length - 1] = "right";
|
|
271
|
+
children = [left, evaluateMeasureExpression(expression.right, measures, root, segments)];
|
|
272
|
+
} finally {
|
|
273
|
+
segments.length -= 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const reasons = children.every((child) => child.reasons.length === 0)
|
|
277
|
+
? NO_REASONS
|
|
278
|
+
: uniqueReasons(children.flatMap((child) => child.reasons));
|
|
279
|
+
const overflows = children.every((child) => child.overflows.length === 0)
|
|
280
|
+
? NO_OVERFLOWS
|
|
281
|
+
: children.flatMap((child) => child.overflows);
|
|
232
282
|
const sources = normalizeDiagnosticSourceLocations(
|
|
233
283
|
children.flatMap((child) => child.sources),
|
|
234
284
|
);
|
|
@@ -242,7 +292,7 @@ export function evaluateDiagnosticMeasureExpression(
|
|
|
242
292
|
return {
|
|
243
293
|
value: null,
|
|
244
294
|
reasons: uniqueReasons([...reasons, "expression-overflow"]),
|
|
245
|
-
overflows: [...overflows, { expressionPath:
|
|
295
|
+
overflows: [...overflows, { expressionPath: renderPath(root, segments), sources }],
|
|
246
296
|
sources,
|
|
247
297
|
};
|
|
248
298
|
}
|
package/src/diagnosticFreeze.ts
CHANGED
|
@@ -1,37 +1,82 @@
|
|
|
1
1
|
import type { DiagnosticDeepReadonly } from "./diagnosticDefinitions.js";
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
3
|
+
// Owned diagnostic graphs are JSON-shaped: shallow trees whose shared nodes are
|
|
4
|
+
// small leaves (source locations, statistics, findings). Visiting a shared leaf
|
|
5
|
+
// once per reference is far cheaper than remembering millions of visited nodes,
|
|
6
|
+
// so ancestors are tracked only past this depth. A cycle must exceed it and is
|
|
7
|
+
// then cut exactly where a visited-set walk would cut it.
|
|
8
|
+
const UNGUARDED_DEPTH = 64;
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
/** True when Object.prototype carries an enumerable key, so for-in needs an own-key guard. */
|
|
11
|
+
function objectPrototypeEnumerates(): boolean {
|
|
12
|
+
for (const key in {}) return key !== undefined;
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function walk(value: unknown, skipFrozen: boolean): void {
|
|
17
|
+
const guardInherited = objectPrototypeEnumerates();
|
|
18
|
+
let ancestors: Set<object> | undefined;
|
|
19
|
+
const visit = (node: unknown, depth: number): void => {
|
|
20
|
+
if (node === null || typeof node !== "object") return;
|
|
21
|
+
if (skipFrozen && Object.isFrozen(node)) return;
|
|
22
|
+
const guarded = depth > UNGUARDED_DEPTH;
|
|
23
|
+
if (guarded) {
|
|
24
|
+
ancestors ??= new Set();
|
|
25
|
+
if (ancestors.has(node)) return;
|
|
26
|
+
ancestors.add(node);
|
|
27
|
+
}
|
|
28
|
+
// Scalars are checked inline: most children are numbers and strings, and a
|
|
29
|
+
// call per scalar would dominate the walk.
|
|
30
|
+
if (Array.isArray(node)) {
|
|
31
|
+
for (let index = 0; index < node.length; index++) {
|
|
32
|
+
const child: unknown = node[index];
|
|
33
|
+
if (child !== null && typeof child === "object") visit(child, depth + 1);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
// for-in visits own enumerable string keys in the same order as
|
|
37
|
+
// Object.keys without allocating a key array per node. Plain records
|
|
38
|
+
// inherit nothing enumerable unless Object.prototype was extended.
|
|
39
|
+
const record = node as Record<string, unknown>;
|
|
40
|
+
const prototype: unknown = Object.getPrototypeOf(record);
|
|
41
|
+
if (!guardInherited && (prototype === Object.prototype || prototype === null)) {
|
|
42
|
+
for (const key in record) {
|
|
43
|
+
const child = record[key];
|
|
44
|
+
if (child !== null && typeof child === "object") visit(child, depth + 1);
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
for (const key in record) {
|
|
48
|
+
if (!Object.hasOwn(record, key)) continue;
|
|
49
|
+
const child = record[key];
|
|
50
|
+
if (child !== null && typeof child === "object") visit(child, depth + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (guarded) ancestors!.delete(node);
|
|
19
55
|
Object.freeze(node);
|
|
20
56
|
};
|
|
21
|
-
visit(value);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
visit(value, 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Freeze an owned plain-data snapshot deeply. Every reachable child is visited
|
|
62
|
+
* and frozen, including children of an already frozen node, because a frozen
|
|
63
|
+
* root is no evidence that a caller-supplied child is immutable. The walk keeps
|
|
64
|
+
* no state between calls and allocates nothing for ordinary acyclic graphs.
|
|
65
|
+
*/
|
|
66
|
+
export function freezeDiagnosticGraph<T>(value: T): DiagnosticDeepReadonly<T> {
|
|
67
|
+
walk(value, false);
|
|
68
|
+
return value as DiagnosticDeepReadonly<T>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Freeze a graph that the SDK built entirely itself, where every node that is
|
|
73
|
+
* already frozen was frozen complete: preparation attaches contributions,
|
|
74
|
+
* statistics, citation lists and shared empties only after freezing them and
|
|
75
|
+
* every child below them. Such nodes are skipped instead of re-walked once per
|
|
76
|
+
* reference. Never use this on a graph that may hold a caller-frozen node with
|
|
77
|
+
* mutable children; `freezeDiagnosticGraph` exists for that case.
|
|
78
|
+
*/
|
|
79
|
+
export function freezeOwnedDiagnosticGraph<T>(value: T): DiagnosticDeepReadonly<T> {
|
|
80
|
+
walk(value, true);
|
|
36
81
|
return value as DiagnosticDeepReadonly<T>;
|
|
37
82
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { canonicalJson } from "./canonical.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `canonicalJson` for a flat array of JSON scalars through the native
|
|
5
|
+
* serializer. Arrays keep their order and each finite number, string, boolean
|
|
6
|
+
* and null has exactly one canonical rendering, so the text is byte-identical
|
|
7
|
+
* to `canonicalJson(values)`. Any other element defers to `canonicalJson`,
|
|
8
|
+
* including its errors, so callers keep the same contract on every input.
|
|
9
|
+
*/
|
|
10
|
+
export function canonicalScalarArrayJson(values: readonly unknown[]): string {
|
|
11
|
+
for (const value of values) {
|
|
12
|
+
if (
|
|
13
|
+
typeof value === "string" ||
|
|
14
|
+
typeof value === "boolean" ||
|
|
15
|
+
value === null ||
|
|
16
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
17
|
+
)
|
|
18
|
+
continue;
|
|
19
|
+
return canonicalJson(values);
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(values);
|
|
22
|
+
}
|
|
@@ -77,15 +77,17 @@ export function compareDiagnosticFindings(
|
|
|
77
77
|
);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Tuple comparisons are written out field by field: the section 15 order of two
|
|
81
|
+
// equal-length tuples is exactly the first non-zero field comparison, and this
|
|
82
|
+
// avoids allocating two arrays for every comparison inside large sorts.
|
|
80
83
|
export function compareDiagnosticContributions(
|
|
81
84
|
left: DiagnosticMeasureContribution,
|
|
82
85
|
right: DiagnosticMeasureContribution,
|
|
83
86
|
): number {
|
|
84
87
|
return (
|
|
85
|
-
compareDiagnosticIdentityValues(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
) ||
|
|
88
|
+
compareDiagnosticIdentityValues(left.sourceId, right.sourceId) ||
|
|
89
|
+
compareDiagnosticIdentityValues(left.status, right.status) ||
|
|
90
|
+
compareDiagnosticIdentityValues(left.deduplicated, right.deduplicated) ||
|
|
89
91
|
compareSources(left.sources, right.sources) ||
|
|
90
92
|
compareDiagnosticIdentityValues(left, right)
|
|
91
93
|
);
|
|
@@ -96,10 +98,9 @@ export function compareDiagnosticBlockers(
|
|
|
96
98
|
right: DiagnosticStructuralBlocker,
|
|
97
99
|
): number {
|
|
98
100
|
return (
|
|
99
|
-
compareDiagnosticIdentityValues(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
) ||
|
|
101
|
+
compareDiagnosticIdentityValues(left.code, right.code) ||
|
|
102
|
+
compareDiagnosticIdentityValues(left.message, right.message) ||
|
|
103
|
+
compareDiagnosticIdentityValues(left.sourceIds, right.sourceIds) ||
|
|
103
104
|
compareSources(left.sources, right.sources) ||
|
|
104
105
|
compareDiagnosticIdentityValues(left.finding, right.finding)
|
|
105
106
|
);
|
package/src/diagnosticPeriods.ts
CHANGED
|
@@ -14,11 +14,28 @@ export interface DiagnosticNormalizedPeriod {
|
|
|
14
14
|
readonly coordinate: number;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Labels repeat across every row, cell and query of a definition, and each
|
|
18
|
+
// normalized period is an immutable value. Cache per axis object and side,
|
|
19
|
+
// bounded so adversarial label streams cannot grow the tables without limit.
|
|
20
|
+
const MAX_MEMOIZED_LABELS = 100_000;
|
|
21
|
+
const normalizedByAxis = new WeakMap<
|
|
22
|
+
DiagnosticPeriodAxis,
|
|
23
|
+
Record<DiagnosticPeriodSide, Map<string, DiagnosticNormalizedPeriod>>
|
|
24
|
+
>();
|
|
25
|
+
|
|
17
26
|
function normalizeWithAxis(
|
|
18
27
|
axis: DiagnosticPeriodAxis,
|
|
19
28
|
side: DiagnosticPeriodSide,
|
|
20
29
|
label: string,
|
|
21
30
|
): DiagnosticNormalizedPeriod {
|
|
31
|
+
let tables = normalizedByAxis.get(axis);
|
|
32
|
+
if (!tables) {
|
|
33
|
+
tables = { origin: new Map(), valuation: new Map() };
|
|
34
|
+
normalizedByAxis.set(axis, tables);
|
|
35
|
+
}
|
|
36
|
+
const table = tables[side];
|
|
37
|
+
const cached = table.get(label);
|
|
38
|
+
if (cached !== undefined) return cached;
|
|
22
39
|
const normalized = normalizeDiagnosticPeriodWithAxis(axis, side, label);
|
|
23
40
|
if (!normalized)
|
|
24
41
|
throw new DiagnosticValidationError([
|
|
@@ -32,7 +49,10 @@ function normalizeWithAxis(
|
|
|
32
49
|
: `Unknown ordered-axis ${side} label ${JSON.stringify(label)}`,
|
|
33
50
|
},
|
|
34
51
|
]);
|
|
35
|
-
|
|
52
|
+
const frozen = Object.freeze(normalized);
|
|
53
|
+
if (table.size >= MAX_MEMOIZED_LABELS) table.clear();
|
|
54
|
+
table.set(label, frozen);
|
|
55
|
+
return frozen;
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
export function normalizeDiagnosticPeriod(
|