@actuarial-ts/core 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 +11 -2
- package/dist/canonical.d.ts +11 -0
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +136 -26
- package/dist/canonical.js.map +1 -1
- package/dist/diagnosticEvidenceIntern.d.ts +16 -0
- package/dist/diagnosticEvidenceIntern.d.ts.map +1 -0
- package/dist/diagnosticEvidenceIntern.js +139 -0
- package/dist/diagnosticEvidenceIntern.js.map +1 -0
- package/dist/diagnosticExposure.d.ts.map +1 -1
- package/dist/diagnosticExposure.js +97 -7
- package/dist/diagnosticExposure.js.map +1 -1
- package/dist/diagnosticIdentity.d.ts.map +1 -1
- package/dist/diagnosticIdentity.js +6 -3
- package/dist/diagnosticIdentity.js.map +1 -1
- package/dist/diagnosticIdentityStream.d.ts +56 -0
- package/dist/diagnosticIdentityStream.d.ts.map +1 -0
- package/dist/diagnosticIdentityStream.js +472 -0
- package/dist/diagnosticIdentityStream.js.map +1 -0
- package/dist/diagnosticPreparation.d.ts +28 -0
- package/dist/diagnosticPreparation.d.ts.map +1 -1
- package/dist/diagnosticPreparation.js +160 -36
- package/dist/diagnosticPreparation.js.map +1 -1
- package/dist/diagnosticReview.d.ts +4 -1
- package/dist/diagnosticReview.d.ts.map +1 -1
- package/dist/diagnosticReview.js +203 -51
- package/dist/diagnosticReview.js.map +1 -1
- package/dist/diagnosticReviewSources.d.ts +18 -0
- package/dist/diagnosticReviewSources.d.ts.map +1 -0
- package/dist/diagnosticReviewSources.js +76 -0
- package/dist/diagnosticReviewSources.js.map +1 -0
- package/dist/diagnosticReviewStore.d.ts +88 -0
- package/dist/diagnosticReviewStore.d.ts.map +1 -0
- package/dist/diagnosticReviewStore.js +455 -0
- package/dist/diagnosticReviewStore.js.map +1 -0
- package/dist/diagnosticRunner.d.ts +25 -1
- package/dist/diagnosticRunner.d.ts.map +1 -1
- package/dist/diagnosticRunner.js +115 -7
- package/dist/diagnosticRunner.js.map +1 -1
- package/dist/fnvAccumulator.d.ts +6 -0
- package/dist/fnvAccumulator.d.ts.map +1 -0
- package/dist/fnvAccumulator.js +21 -0
- package/dist/fnvAccumulator.js.map +1 -0
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.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 +144 -26
- package/src/diagnosticEvidenceIntern.ts +151 -0
- package/src/diagnosticExposure.ts +126 -17
- package/src/diagnosticIdentity.ts +15 -6
- package/src/diagnosticIdentityStream.ts +553 -0
- package/src/diagnosticPreparation.ts +268 -84
- package/src/diagnosticReview.ts +293 -85
- package/src/diagnosticReviewSources.ts +85 -0
- package/src/diagnosticReviewStore.ts +692 -0
- package/src/diagnosticRunner.ts +206 -9
- package/src/fnvAccumulator.ts +20 -0
- package/src/index.ts +38 -1
- package/src/version.ts +1 -1
package/src/canonical.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ReservingError } from "./types.js";
|
|
2
|
+
import { createFnvAccumulator } from "./fnvAccumulator.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Canonical JSON serialization and the FNV-1a integrity hash — the SDK's
|
|
@@ -34,18 +35,35 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
34
35
|
return proto === Object.prototype || proto === null;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
interface CanonicalSink {
|
|
39
|
+
write(text: string): void;
|
|
40
|
+
quote(text: string): void;
|
|
41
|
+
startContainer(token: "[" | "{"): void;
|
|
42
|
+
endContainer(token: "]" | "}"): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One traversal owns validation and property access order for both outputs. */
|
|
46
|
+
function canonicalize(value: unknown, path: string, seen: Set<object>, sink: CanonicalSink): void {
|
|
47
|
+
if (value === null) {
|
|
48
|
+
sink.write("null");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
39
51
|
switch (typeof value) {
|
|
40
52
|
case "string":
|
|
41
|
-
|
|
53
|
+
sink.quote(value);
|
|
54
|
+
return;
|
|
42
55
|
case "boolean":
|
|
43
|
-
|
|
56
|
+
sink.write(value ? "true" : "false");
|
|
57
|
+
return;
|
|
44
58
|
case "number": {
|
|
45
59
|
if (!Number.isFinite(value)) {
|
|
46
|
-
throw new ReservingError(
|
|
60
|
+
throw new ReservingError(
|
|
61
|
+
"UNSUPPORTED_VALUE",
|
|
62
|
+
`non-finite number (${String(value)}) at ${path}`,
|
|
63
|
+
);
|
|
47
64
|
}
|
|
48
|
-
|
|
65
|
+
sink.write(Object.is(value, -0) ? "0" : String(value));
|
|
66
|
+
return;
|
|
49
67
|
}
|
|
50
68
|
case "undefined":
|
|
51
69
|
throw new ReservingError("UNSUPPORTED_VALUE", `undefined at ${path}`);
|
|
@@ -62,20 +80,24 @@ function canonicalize(value: unknown, path: string, seen: Set<object>): string {
|
|
|
62
80
|
throw new ReservingError("UNSUPPORTED_VALUE", `circular reference at ${path}`);
|
|
63
81
|
}
|
|
64
82
|
seen.add(obj);
|
|
65
|
-
let out: string;
|
|
66
83
|
if (Array.isArray(obj)) {
|
|
67
|
-
|
|
84
|
+
sink.startContainer("[");
|
|
68
85
|
for (let i = 0; i < obj.length; i++) {
|
|
69
|
-
|
|
86
|
+
if (i > 0) sink.write(",");
|
|
87
|
+
canonicalize(obj[i], `${path}[${i}]`, seen, sink);
|
|
70
88
|
}
|
|
71
|
-
|
|
89
|
+
sink.endContainer("]");
|
|
72
90
|
} else if (isPlainObject(obj)) {
|
|
73
91
|
const keys = Object.keys(obj).sort();
|
|
74
|
-
|
|
75
|
-
for (
|
|
76
|
-
|
|
92
|
+
sink.startContainer("{");
|
|
93
|
+
for (let i = 0; i < keys.length; i++) {
|
|
94
|
+
if (i > 0) sink.write(",");
|
|
95
|
+
const key = keys[i]!;
|
|
96
|
+
sink.quote(key);
|
|
97
|
+
sink.write(":");
|
|
98
|
+
canonicalize(obj[key], `${path}.${key}`, seen, sink);
|
|
77
99
|
}
|
|
78
|
-
|
|
100
|
+
sink.endContainer("}");
|
|
79
101
|
} else {
|
|
80
102
|
const name = (obj.constructor as { name?: string } | undefined)?.name ?? "unknown";
|
|
81
103
|
throw new ReservingError(
|
|
@@ -84,7 +106,6 @@ function canonicalize(value: unknown, path: string, seen: Set<object>): string {
|
|
|
84
106
|
);
|
|
85
107
|
}
|
|
86
108
|
seen.delete(obj);
|
|
87
|
-
return out;
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
/**
|
|
@@ -95,12 +116,59 @@ function canonicalize(value: unknown, path: string, seen: Set<object>): string {
|
|
|
95
116
|
* "$.rows[2].ultimate" — for any value JSON cannot faithfully represent.
|
|
96
117
|
*/
|
|
97
118
|
export function canonicalJson(value: unknown): string {
|
|
98
|
-
|
|
119
|
+
// Join finished subtrees as before, rather than retaining a flat token array
|
|
120
|
+
// for the entire input graph alongside the final serialized string.
|
|
121
|
+
const frames: string[][] = [[]];
|
|
122
|
+
const write = (text: string) => {
|
|
123
|
+
frames[frames.length - 1]!.push(text);
|
|
124
|
+
};
|
|
125
|
+
canonicalize(value, "$", new Set(), {
|
|
126
|
+
write,
|
|
127
|
+
quote(text) {
|
|
128
|
+
write(JSON.stringify(text));
|
|
129
|
+
},
|
|
130
|
+
startContainer(token) {
|
|
131
|
+
frames.push([token]);
|
|
132
|
+
},
|
|
133
|
+
endContainer(token) {
|
|
134
|
+
const parts = frames.pop()!;
|
|
135
|
+
parts.push(token);
|
|
136
|
+
write(parts.join(""));
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return frames[0]!.join("");
|
|
99
140
|
}
|
|
100
141
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
142
|
+
const UTF8_CHUNK_CODE_UNITS = 16_384;
|
|
143
|
+
const QUOTED_CHUNK_CODE_UNITS = 4_096;
|
|
144
|
+
|
|
145
|
+
/** A valid surrogate pair must reach native escaping/encoding together. */
|
|
146
|
+
function chunkEnd(text: string, start: number, limit: number): number {
|
|
147
|
+
let end = Math.min(start + limit, text.length);
|
|
148
|
+
const before = text.charCodeAt(end - 1);
|
|
149
|
+
const after = text.charCodeAt(end);
|
|
150
|
+
if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end -= 1;
|
|
151
|
+
return end;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Reuse at most 48 KiB for long strings instead of duplicating all UTF-8 bytes. */
|
|
155
|
+
function* utf8Chunks(text: string): Generator<Uint8Array> {
|
|
156
|
+
const encoder = new TextEncoder();
|
|
157
|
+
// Retain TextEncoder's runtime coercion for untyped JavaScript callers.
|
|
158
|
+
if (typeof text !== "string" || text.length <= UTF8_CHUNK_CODE_UNITS) {
|
|
159
|
+
yield encoder.encode(text);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// A UTF-16 code unit needs at most three UTF-8 bytes (a surrogate pair
|
|
163
|
+
// needs four bytes for two code units), so encodeInto always consumes a chunk.
|
|
164
|
+
const buffer = new Uint8Array(UTF8_CHUNK_CODE_UNITS * 3);
|
|
165
|
+
for (let start = 0; start < text.length; ) {
|
|
166
|
+
const end = chunkEnd(text, start, UTF8_CHUNK_CODE_UNITS);
|
|
167
|
+
const { written } = encoder.encodeInto(text.slice(start, end), buffer);
|
|
168
|
+
yield buffer.subarray(0, written);
|
|
169
|
+
start = end;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
104
172
|
|
|
105
173
|
/**
|
|
106
174
|
* FNV-1a 64-bit hash over the UTF-8 bytes of `text`, returned as a 16-hex-char
|
|
@@ -112,11 +180,61 @@ const MASK_64 = 0xffffffffffffffffn;
|
|
|
112
180
|
* needing tamper evidence must sign or cryptographically hash the payload.
|
|
113
181
|
*/
|
|
114
182
|
export function fnv1a64(text: string): string {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
183
|
+
const hash = createFnvAccumulator();
|
|
184
|
+
for (const bytes of utf8Chunks(text)) hash.update(bytes);
|
|
185
|
+
return hash.digest();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The exact integrity tag `fnv1a64(canonicalJson(value))`, without constructing
|
|
190
|
+
* the complete canonical string or UTF-8 byte array. Validation, key ordering,
|
|
191
|
+
* escaping and unsupported-value error paths match `canonicalJson`.
|
|
192
|
+
*
|
|
193
|
+
* Text and byte buffers are bounded, including for a single large string/key;
|
|
194
|
+
* object-key sorting, ancestor tracking and paths still scale with input shape.
|
|
195
|
+
* The caller's input remains in memory. This is not a constant-memory dataset
|
|
196
|
+
* pipeline, a collision-resistant hash, or a substitute for exact equality.
|
|
197
|
+
*/
|
|
198
|
+
export function canonicalFnv1a64(value: unknown): string {
|
|
199
|
+
const hash = createFnvAccumulator();
|
|
200
|
+
const encoder = new TextEncoder();
|
|
201
|
+
const bytes = new Uint8Array(UTF8_CHUNK_CODE_UNITS * 3);
|
|
202
|
+
let buffered = "";
|
|
203
|
+
const flush = () => {
|
|
204
|
+
if (!buffered.length) return;
|
|
205
|
+
// Three bytes per UTF-16 code unit always fit; pairs need only four for two.
|
|
206
|
+
const { written } = encoder.encodeInto(buffered, bytes);
|
|
207
|
+
hash.update(bytes.subarray(0, written));
|
|
208
|
+
buffered = "";
|
|
209
|
+
};
|
|
210
|
+
const write = (text: string) => {
|
|
211
|
+
for (let start = 0; start < text.length; ) {
|
|
212
|
+
const end = chunkEnd(text, start, UTF8_CHUNK_CODE_UNITS - buffered.length);
|
|
213
|
+
if (end === start) {
|
|
214
|
+
flush();
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
buffered += text.slice(start, end);
|
|
218
|
+
start = end;
|
|
219
|
+
if (buffered.length >= UTF8_CHUNK_CODE_UNITS - 1) flush();
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
canonicalize(value, "$", new Set(), {
|
|
223
|
+
write,
|
|
224
|
+
startContainer: write,
|
|
225
|
+
endContainer: write,
|
|
226
|
+
quote(text) {
|
|
227
|
+
write('"');
|
|
228
|
+
for (let start = 0; start < text.length; ) {
|
|
229
|
+
const end = chunkEnd(text, start, QUOTED_CHUNK_CODE_UNITS);
|
|
230
|
+
// Bounded native escaping retains minimal JSON escape spelling and lone
|
|
231
|
+
// surrogate escapes without allocating one full escaped string atom.
|
|
232
|
+
write(JSON.stringify(text.slice(start, end)).slice(1, -1));
|
|
233
|
+
start = end;
|
|
234
|
+
}
|
|
235
|
+
write('"');
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
flush();
|
|
239
|
+
return hash.digest();
|
|
122
240
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private, invocation-scoped sharing of small, newly owned evidence nodes.
|
|
3
|
+
* This is not a validation boundary: callers must finish normalizing/cloning
|
|
4
|
+
* every child first, and must never submit caller-owned objects or SDK brands.
|
|
5
|
+
*/
|
|
6
|
+
export interface DiagnosticEvidenceInternOptions {
|
|
7
|
+
readonly maxEntries?: number;
|
|
8
|
+
readonly maxSignatureCharacters?: number;
|
|
9
|
+
readonly maxCandidateProperties?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type EvidenceMode = "plain" | "source" | "free";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX_ENTRIES = 100_000;
|
|
15
|
+
const DEFAULT_MAX_SIGNATURE_CHARACTERS = 16 * 1024 * 1024;
|
|
16
|
+
// Wide result envelopes are usually unique; focus the budget on their small
|
|
17
|
+
// repeated coordinates, sources, scopes, states, and short child lists.
|
|
18
|
+
const DEFAULT_MAX_CANDIDATE_PROPERTIES = 10;
|
|
19
|
+
const MAX_CANDIDATE_STRING_CHARACTERS = 4096;
|
|
20
|
+
|
|
21
|
+
function sameOwnedNode(left: object, right: object): boolean {
|
|
22
|
+
if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right))
|
|
23
|
+
return false;
|
|
24
|
+
const leftKeys = Reflect.ownKeys(left);
|
|
25
|
+
const rightKeys = Reflect.ownKeys(right);
|
|
26
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
27
|
+
return leftKeys.every((key, index) => {
|
|
28
|
+
if (key !== rightKeys[index]) return false;
|
|
29
|
+
const a = Object.getOwnPropertyDescriptor(left, key)!;
|
|
30
|
+
const b = Object.getOwnPropertyDescriptor(right, key)!;
|
|
31
|
+
return (
|
|
32
|
+
"value" in a &&
|
|
33
|
+
"value" in b &&
|
|
34
|
+
a.enumerable === b.enumerable &&
|
|
35
|
+
a.configurable === b.configurable &&
|
|
36
|
+
a.writable === b.writable &&
|
|
37
|
+
Object.is(a.value, b.value)
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createDiagnosticEvidenceInterner(
|
|
43
|
+
options: DiagnosticEvidenceInternOptions = {},
|
|
44
|
+
): {
|
|
45
|
+
internOwned<T extends object>(candidate: T, mode: EvidenceMode): T;
|
|
46
|
+
} {
|
|
47
|
+
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
48
|
+
const maxSignatureCharacters =
|
|
49
|
+
options.maxSignatureCharacters ?? DEFAULT_MAX_SIGNATURE_CHARACTERS;
|
|
50
|
+
const maxCandidateProperties =
|
|
51
|
+
options.maxCandidateProperties ?? DEFAULT_MAX_CANDIDATE_PROPERTIES;
|
|
52
|
+
const pool = new Map<string, object>();
|
|
53
|
+
const childIds = new WeakMap<object, number>();
|
|
54
|
+
let nextChildId = 1;
|
|
55
|
+
let signatureCharacters = 0;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
internOwned<T extends object>(candidate: T, mode: EvidenceMode): T {
|
|
59
|
+
if (!Object.isFrozen(candidate)) return candidate;
|
|
60
|
+
const prototype = Object.getPrototypeOf(candidate);
|
|
61
|
+
const array = Array.isArray(candidate);
|
|
62
|
+
if (
|
|
63
|
+
array
|
|
64
|
+
? prototype !== Array.prototype
|
|
65
|
+
: prototype !== null && prototype !== Object.prototype
|
|
66
|
+
)
|
|
67
|
+
return candidate;
|
|
68
|
+
if (array && candidate.length >= maxCandidateProperties) return candidate;
|
|
69
|
+
const keys = Reflect.ownKeys(candidate);
|
|
70
|
+
if (keys.length > maxCandidateProperties) return candidate;
|
|
71
|
+
const signatureParts: unknown[] = [
|
|
72
|
+
mode,
|
|
73
|
+
array ? "array" : prototype === null ? "null" : "object",
|
|
74
|
+
];
|
|
75
|
+
// A declined candidate must not grow persistent bookkeeping. Stage at
|
|
76
|
+
// most this small candidate's children, including repeated references,
|
|
77
|
+
// and commit their IDs only if the candidate enters the bounded pool.
|
|
78
|
+
let pendingChildIds: Map<object, number> | undefined;
|
|
79
|
+
let stringCharacters = 0;
|
|
80
|
+
for (const key of keys) {
|
|
81
|
+
// Diagnostic JSON does not contain symbols. Declining unexpected
|
|
82
|
+
// shapes is harmless and never invokes an accessor or caller method.
|
|
83
|
+
if (typeof key !== "string") return candidate;
|
|
84
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, key)!;
|
|
85
|
+
if (!("value" in descriptor)) return candidate;
|
|
86
|
+
const value: unknown = descriptor.value;
|
|
87
|
+
let tokenType: string;
|
|
88
|
+
let tokenValue: unknown;
|
|
89
|
+
if (value === null) {
|
|
90
|
+
tokenType = "null";
|
|
91
|
+
tokenValue = null;
|
|
92
|
+
} else if (typeof value === "object") {
|
|
93
|
+
if (!Object.isFrozen(value)) return candidate;
|
|
94
|
+
tokenType = "object";
|
|
95
|
+
const existingId = childIds.get(value);
|
|
96
|
+
if (existingId !== undefined) tokenValue = existingId;
|
|
97
|
+
else {
|
|
98
|
+
pendingChildIds ??= new Map();
|
|
99
|
+
let pendingId = pendingChildIds.get(value);
|
|
100
|
+
if (pendingId === undefined) {
|
|
101
|
+
pendingId = nextChildId + pendingChildIds.size;
|
|
102
|
+
pendingChildIds.set(value, pendingId);
|
|
103
|
+
}
|
|
104
|
+
tokenValue = pendingId;
|
|
105
|
+
}
|
|
106
|
+
} else if (typeof value === "number") {
|
|
107
|
+
if (!Number.isFinite(value)) return candidate;
|
|
108
|
+
tokenType = "number";
|
|
109
|
+
tokenValue = Object.is(value, -0) ? "-0" : value;
|
|
110
|
+
} else if (
|
|
111
|
+
typeof value === "string" ||
|
|
112
|
+
typeof value === "boolean" ||
|
|
113
|
+
value === undefined
|
|
114
|
+
) {
|
|
115
|
+
tokenType = typeof value;
|
|
116
|
+
tokenValue = value;
|
|
117
|
+
if (typeof value === "string") stringCharacters += value.length;
|
|
118
|
+
} else return candidate;
|
|
119
|
+
stringCharacters += key.length;
|
|
120
|
+
if (stringCharacters > MAX_CANDIDATE_STRING_CHARACTERS)
|
|
121
|
+
return candidate;
|
|
122
|
+
// Frozen data properties are necessarily non-configurable and
|
|
123
|
+
// non-writable. Keep the varying enumerability bit (including array
|
|
124
|
+
// length) and a fixed four-slot typed tuple, without temporary nested
|
|
125
|
+
// arrays. The independent equality check still verifies every flag.
|
|
126
|
+
signatureParts.push(key, descriptor.enumerable, tokenType, tokenValue);
|
|
127
|
+
}
|
|
128
|
+
const signature = JSON.stringify(signatureParts);
|
|
129
|
+
// An unseen child cannot occur in an existing entry: every admitted
|
|
130
|
+
// entry committed all its child IDs. No equality hit is skipped here.
|
|
131
|
+
const existing = pendingChildIds ? undefined : pool.get(signature);
|
|
132
|
+
// Signatures encode exact children and descriptor/order information;
|
|
133
|
+
// independently check equality before sharing rather than treating a
|
|
134
|
+
// fingerprint as proof of equality.
|
|
135
|
+
if (existing !== undefined && sameOwnedNode(existing, candidate))
|
|
136
|
+
return existing as T;
|
|
137
|
+
if (
|
|
138
|
+
pool.size < maxEntries &&
|
|
139
|
+
signatureCharacters + signature.length <= maxSignatureCharacters
|
|
140
|
+
) {
|
|
141
|
+
if (pendingChildIds) {
|
|
142
|
+
for (const [child, id] of pendingChildIds) childIds.set(child, id);
|
|
143
|
+
nextChildId += pendingChildIds.size;
|
|
144
|
+
}
|
|
145
|
+
pool.set(signature, candidate);
|
|
146
|
+
signatureCharacters += signature.length;
|
|
147
|
+
}
|
|
148
|
+
return candidate;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -14,12 +14,119 @@ import {
|
|
|
14
14
|
} from "./types.js";
|
|
15
15
|
import {
|
|
16
16
|
diagnosticJsonPreflight,
|
|
17
|
+
diagnosticRecord,
|
|
17
18
|
hasDiagnosticOwn,
|
|
18
19
|
isDiagnosticPlainRecord,
|
|
19
20
|
isDiagnosticToken,
|
|
20
21
|
snapshotDiagnosticJson,
|
|
22
|
+
MAX_DIAGNOSTIC_JSON_DEPTH,
|
|
21
23
|
} from "./diagnosticRuntime.js";
|
|
22
24
|
|
|
25
|
+
// Bound the collection separately from each untrusted record. The generic
|
|
26
|
+
// million-node JSON cap otherwise rejects legitimate files at ~71k–100k rows,
|
|
27
|
+
// depending only on how much source provenance each observation carries.
|
|
28
|
+
const MAX_EXPOSURE_OBSERVATIONS = 250_000;
|
|
29
|
+
|
|
30
|
+
// JSON enumeration skips hidden properties, but the strict exposure schema
|
|
31
|
+
// reads recognized own fields. Refuse hidden accessors before those reads.
|
|
32
|
+
function hiddenExposureAccessorIssues(
|
|
33
|
+
value: unknown,
|
|
34
|
+
path: string,
|
|
35
|
+
): DiagnosticValidationIssue[] {
|
|
36
|
+
if (!isDiagnosticPlainRecord(value)) return [];
|
|
37
|
+
const issues: DiagnosticValidationIssue[] = [];
|
|
38
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
39
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
|
40
|
+
if (!descriptor.enumerable && !("value" in descriptor))
|
|
41
|
+
issues.push({
|
|
42
|
+
domain: "input",
|
|
43
|
+
code: "invalid-json-value",
|
|
44
|
+
path: propertyPath(path, key),
|
|
45
|
+
message: "JSON objects may contain only data properties",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return issues;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function exposureCollectionPreflight(
|
|
52
|
+
value: unknown,
|
|
53
|
+
): readonly DiagnosticValidationIssue[] {
|
|
54
|
+
if (!Array.isArray(value)) return diagnosticJsonPreflight(value, "input");
|
|
55
|
+
if (Object.getPrototypeOf(value) !== Array.prototype)
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
domain: "input",
|
|
59
|
+
code: "invalid-json-value",
|
|
60
|
+
path: "$",
|
|
61
|
+
message: "Value must use a plain object or array prototype",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
if (value.length > MAX_EXPOSURE_OBSERVATIONS)
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
domain: "input",
|
|
68
|
+
code: "expression-limit",
|
|
69
|
+
path: "$",
|
|
70
|
+
message: `Exposure observation count exceeds ${MAX_EXPOSURE_OBSERVATIONS}`,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
const issues: DiagnosticValidationIssue[] = [];
|
|
74
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
75
|
+
if (key === "length") continue;
|
|
76
|
+
const index = typeof key === "string" ? Number(key) : Number.NaN;
|
|
77
|
+
if (
|
|
78
|
+
Number.isInteger(index) &&
|
|
79
|
+
index >= 0 &&
|
|
80
|
+
index < value.length &&
|
|
81
|
+
String(index) === key
|
|
82
|
+
)
|
|
83
|
+
continue;
|
|
84
|
+
issues.push({
|
|
85
|
+
domain: "input",
|
|
86
|
+
code: "invalid-json-value",
|
|
87
|
+
path: typeof key === "symbol" ? "$" : propertyPath("$", key),
|
|
88
|
+
message: "JSON arrays may contain only indexed data properties",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
for (let index = 0; index < value.length; index++) {
|
|
92
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
93
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
94
|
+
issues.push({
|
|
95
|
+
domain: "input",
|
|
96
|
+
code: "invalid-json-value",
|
|
97
|
+
path: `$[${index}]`,
|
|
98
|
+
message: "JSON arrays may contain only indexed data properties",
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
issues.push(
|
|
103
|
+
...hiddenExposureAccessorIssues(descriptor.value, `$[${index}]`),
|
|
104
|
+
);
|
|
105
|
+
if (isDiagnosticPlainRecord(descriptor.value)) {
|
|
106
|
+
const source = Object.getOwnPropertyDescriptor(
|
|
107
|
+
descriptor.value,
|
|
108
|
+
"source",
|
|
109
|
+
);
|
|
110
|
+
if (source && "value" in source)
|
|
111
|
+
issues.push(
|
|
112
|
+
...hiddenExposureAccessorIssues(source.value, `$[${index}].source`),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
// The outer collection used to occupy depth 1: keep the exact depth
|
|
116
|
+
// contract, and retain the default million-node guard for each record.
|
|
117
|
+
for (const issue of diagnosticJsonPreflight(descriptor.value, "input", {
|
|
118
|
+
maxDepth: MAX_DIAGNOSTIC_JSON_DEPTH - 1,
|
|
119
|
+
}))
|
|
120
|
+
issues.push({ ...issue, path: `$[${index}]${issue.path.slice(1)}` });
|
|
121
|
+
}
|
|
122
|
+
return issues;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Only for new SDK-owned objects whose leaves have already been validated. */
|
|
126
|
+
function freezeExposureRecord<T extends object>(value: T): T {
|
|
127
|
+
return Object.freeze(Object.assign(diagnosticRecord<unknown>(), value)) as T;
|
|
128
|
+
}
|
|
129
|
+
|
|
23
130
|
export interface DiagnosticExposureObservation {
|
|
24
131
|
readonly key: string;
|
|
25
132
|
readonly sourceGroup: string;
|
|
@@ -115,7 +222,7 @@ function validateExposureArguments(
|
|
|
115
222
|
// Nonfinite exposure amounts are supported audited inputs. They are the
|
|
116
223
|
// only non-JSON numbers permitted here; metadata still must be finite.
|
|
117
224
|
const issues: DiagnosticValidationIssue[] = [
|
|
118
|
-
...
|
|
225
|
+
...exposureCollectionPreflight(observations)
|
|
119
226
|
.filter(
|
|
120
227
|
(issue) =>
|
|
121
228
|
!(
|
|
@@ -396,7 +503,7 @@ export function reconcileDiagnosticExposures(
|
|
|
396
503
|
cohort.push(observation);
|
|
397
504
|
cohorts.set(identity, cohort);
|
|
398
505
|
}
|
|
399
|
-
return
|
|
506
|
+
return Object.freeze(
|
|
400
507
|
[...cohorts.values()]
|
|
401
508
|
.map((cohort): ReconciledDiagnosticExposure => {
|
|
402
509
|
const first = cohort[0]!;
|
|
@@ -404,18 +511,20 @@ export function reconcileDiagnosticExposures(
|
|
|
404
511
|
? timingByMeasure[first.measureId]
|
|
405
512
|
: undefined;
|
|
406
513
|
const audited = cohort
|
|
407
|
-
.map((item): DiagnosticExposureAuditObservation =>
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
514
|
+
.map((item): DiagnosticExposureAuditObservation =>
|
|
515
|
+
snapshotDiagnosticJson({
|
|
516
|
+
sourceGroup: item.sourceGroup,
|
|
517
|
+
origin: item.origin,
|
|
518
|
+
...(item.valuation === undefined
|
|
519
|
+
? {}
|
|
520
|
+
: { valuation: item.valuation }),
|
|
521
|
+
value: auditDiagnosticNumber(item.value),
|
|
522
|
+
complete: item.complete,
|
|
523
|
+
...(item.source === undefined
|
|
524
|
+
? {}
|
|
525
|
+
: { source: Object.freeze({ ...item.source }) }),
|
|
526
|
+
}),
|
|
527
|
+
)
|
|
419
528
|
.sort(observationOrder);
|
|
420
529
|
const issues: (
|
|
421
530
|
"missing" | "incomplete" | "non-finite" | "duplicate" | "conflict"
|
|
@@ -432,7 +541,7 @@ export function reconcileDiagnosticExposures(
|
|
|
432
541
|
const validStaticCopies =
|
|
433
542
|
timing === "origin-static" && issues.length === 0;
|
|
434
543
|
if (issues.length > 0)
|
|
435
|
-
return
|
|
544
|
+
return freezeExposureRecord({
|
|
436
545
|
measureId: first.measureId,
|
|
437
546
|
key: first.key,
|
|
438
547
|
status: "invalid",
|
|
@@ -445,8 +554,8 @@ export function reconcileDiagnosticExposures(
|
|
|
445
554
|
throw new Error("unreachable invalid exposure state");
|
|
446
555
|
const sources = normalizeDiagnosticSourceLocations(
|
|
447
556
|
audited.map((item) => item.source),
|
|
448
|
-
);
|
|
449
|
-
return
|
|
557
|
+
).map((source) => snapshotDiagnosticJson(source));
|
|
558
|
+
return freezeExposureRecord({
|
|
450
559
|
measureId: first.measureId,
|
|
451
560
|
key: first.key,
|
|
452
561
|
status: "valid",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { canonicalJson, fnv1a64 } from "./canonical.js";
|
|
2
|
+
import { createDiagnosticEvidenceInterner } from "./diagnosticEvidenceIntern.js";
|
|
2
3
|
import type {
|
|
3
4
|
AmountBasisDefinition,
|
|
4
5
|
AmountLimitation,
|
|
@@ -181,6 +182,7 @@ export function projectDiagnosticIdentity<T>(
|
|
|
181
182
|
maxNodes: Number.MAX_SAFE_INTEGER,
|
|
182
183
|
});
|
|
183
184
|
if (issues.length) throw new DiagnosticValidationError(issues);
|
|
185
|
+
const sharing = createDiagnosticEvidenceInterner();
|
|
184
186
|
const clone = (
|
|
185
187
|
item: unknown,
|
|
186
188
|
sourceSlot = false,
|
|
@@ -189,11 +191,15 @@ export function projectDiagnosticIdentity<T>(
|
|
|
189
191
|
): unknown => {
|
|
190
192
|
if (item === null || typeof item !== "object")
|
|
191
193
|
return typeof item === "number" && Object.is(item, -0) ? 0 : item;
|
|
194
|
+
const mode = freeJson ? "free" : sourceSlot ? "source" : "plain";
|
|
192
195
|
if (Array.isArray(item))
|
|
193
|
-
return
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
return sharing.internOwned(
|
|
197
|
+
Object.freeze(
|
|
198
|
+
item.map((child, index) =>
|
|
199
|
+
clone(child, sourceSlot, freeJson, `${path}[${index}]`),
|
|
200
|
+
),
|
|
196
201
|
),
|
|
202
|
+
mode,
|
|
197
203
|
);
|
|
198
204
|
const record = item as Record<string, unknown>;
|
|
199
205
|
if (sourceSlot && typeof record.artifactId === "string") {
|
|
@@ -243,8 +249,11 @@ export function projectDiagnosticIdentity<T>(
|
|
|
243
249
|
}
|
|
244
250
|
if (sourceIssues.length)
|
|
245
251
|
throw new DiagnosticValidationError(sourceIssues);
|
|
246
|
-
return
|
|
247
|
-
|
|
252
|
+
return sharing.internOwned(
|
|
253
|
+
normalizeDiagnosticSourceLocation(
|
|
254
|
+
record as unknown as DiagnosticSourceLocation,
|
|
255
|
+
),
|
|
256
|
+
mode,
|
|
248
257
|
);
|
|
249
258
|
}
|
|
250
259
|
const result = Object.create(null) as Record<string, unknown>;
|
|
@@ -258,7 +267,7 @@ export function projectDiagnosticIdentity<T>(
|
|
|
258
267
|
`${path}.${key}`,
|
|
259
268
|
);
|
|
260
269
|
}
|
|
261
|
-
return Object.freeze(result);
|
|
270
|
+
return sharing.internOwned(Object.freeze(result), mode);
|
|
262
271
|
};
|
|
263
272
|
return clone(value) as DiagnosticIdentityProjection<T>;
|
|
264
273
|
}
|