@nifrajs/agent-review 3.5.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 +48 -0
- package/dist/canonical.d.ts +11 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +148 -0
- package/dist/canonical.js.map +1 -0
- package/dist/compose.d.ts +8 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +123 -0
- package/dist/compose.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +18 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +664 -0
- package/dist/parser.js.map +1 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +46 -0
- package/src/canonical.ts +174 -0
- package/src/compose.ts +153 -0
- package/src/index.ts +15 -0
- package/src/parser.ts +902 -0
- package/src/types.ts +174 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import { canonicalizeReviewReport, digestReviewReport } from "./canonical.ts"
|
|
2
|
+
import {
|
|
3
|
+
REVIEW_REPORT_VERSION,
|
|
4
|
+
type ReviewCategory,
|
|
5
|
+
type ReviewCheckId,
|
|
6
|
+
type ReviewCheckResult,
|
|
7
|
+
type ReviewCheckStatus,
|
|
8
|
+
type ReviewCounts,
|
|
9
|
+
type ReviewDurationBucket,
|
|
10
|
+
type ReviewEvidenceRef,
|
|
11
|
+
type ReviewEvidenceSource,
|
|
12
|
+
type ReviewFinding,
|
|
13
|
+
type ReviewFixRecipeId,
|
|
14
|
+
type ReviewFixRef,
|
|
15
|
+
type ReviewFixResult,
|
|
16
|
+
type ReviewFixStatus,
|
|
17
|
+
type ReviewLocation,
|
|
18
|
+
type ReviewParserErrorCode,
|
|
19
|
+
type ReviewReasonCode,
|
|
20
|
+
type ReviewReport,
|
|
21
|
+
type ReviewScope,
|
|
22
|
+
type ReviewSeverity,
|
|
23
|
+
type ReviewStatus,
|
|
24
|
+
} from "./types.ts"
|
|
25
|
+
|
|
26
|
+
export const REVIEW_MAX_BYTES = 256 * 1024
|
|
27
|
+
export const REVIEW_MAX_DEPTH = 8
|
|
28
|
+
export const REVIEW_MAX_OBJECT_KEYS = 32
|
|
29
|
+
export const REVIEW_MAX_ARRAY_ITEMS = 1024
|
|
30
|
+
export const REVIEW_MAX_PATH_BYTES = 512
|
|
31
|
+
export const REVIEW_MAX_REF_BYTES = 256
|
|
32
|
+
export const REVIEW_MAX_ID_BYTES = 128
|
|
33
|
+
export const REVIEW_MAX_CODE_BYTES = 64
|
|
34
|
+
|
|
35
|
+
const HEX64 = /^[0-9a-f]{64}$/
|
|
36
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
37
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/
|
|
38
|
+
const CODE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
39
|
+
const GIT_REF_FORBIDDEN = /[\s\\~^:?*[\]]/
|
|
40
|
+
|
|
41
|
+
const FORBIDDEN_KEYS = new Set([
|
|
42
|
+
"message",
|
|
43
|
+
"description",
|
|
44
|
+
"title",
|
|
45
|
+
"prompt",
|
|
46
|
+
"output",
|
|
47
|
+
"payload",
|
|
48
|
+
"snapshot",
|
|
49
|
+
"diagnostic",
|
|
50
|
+
"suggestion",
|
|
51
|
+
"chain",
|
|
52
|
+
"stack",
|
|
53
|
+
"input",
|
|
54
|
+
"body",
|
|
55
|
+
"secret",
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
const REVIEW_CHECK_IDS: readonly ReviewCheckId[] = [
|
|
59
|
+
"typecheck",
|
|
60
|
+
"typed-client",
|
|
61
|
+
"server-boundary",
|
|
62
|
+
"route-boundary",
|
|
63
|
+
"pipeline",
|
|
64
|
+
"security",
|
|
65
|
+
"route-assurance",
|
|
66
|
+
"capability-provenance",
|
|
67
|
+
"manifest",
|
|
68
|
+
"dependency",
|
|
69
|
+
"contract-witness",
|
|
70
|
+
"coverage",
|
|
71
|
+
"hydration",
|
|
72
|
+
"configuration",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
const REVIEW_CHECK_STATUSES: readonly ReviewCheckStatus[] = [
|
|
76
|
+
"pass",
|
|
77
|
+
"fail",
|
|
78
|
+
"skipped",
|
|
79
|
+
"unavailable",
|
|
80
|
+
"error",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const REVIEW_SEVERITIES: readonly ReviewSeverity[] = ["error", "warning", "info"]
|
|
84
|
+
const REVIEW_CATEGORIES: readonly ReviewCategory[] = [
|
|
85
|
+
"correctness",
|
|
86
|
+
"security",
|
|
87
|
+
"boundary",
|
|
88
|
+
"assurance",
|
|
89
|
+
"capability",
|
|
90
|
+
"contract",
|
|
91
|
+
"dependency",
|
|
92
|
+
"hydration",
|
|
93
|
+
"coverage",
|
|
94
|
+
"configuration",
|
|
95
|
+
"operational",
|
|
96
|
+
]
|
|
97
|
+
const REVIEW_EVIDENCE_SOURCES: readonly ReviewEvidenceSource[] = [
|
|
98
|
+
"check",
|
|
99
|
+
"assurance",
|
|
100
|
+
"capability",
|
|
101
|
+
"manifest",
|
|
102
|
+
"contract",
|
|
103
|
+
"coverage",
|
|
104
|
+
"git-scope",
|
|
105
|
+
"collector",
|
|
106
|
+
]
|
|
107
|
+
const REVIEW_DURATIONS: readonly ReviewDurationBucket[] = [
|
|
108
|
+
"none",
|
|
109
|
+
"fast",
|
|
110
|
+
"standard",
|
|
111
|
+
"slow",
|
|
112
|
+
"timeout",
|
|
113
|
+
]
|
|
114
|
+
const REVIEW_REASONS: readonly ReviewReasonCode[] = [
|
|
115
|
+
"not-configured",
|
|
116
|
+
"config-missing",
|
|
117
|
+
"config-invalid",
|
|
118
|
+
"collector-error",
|
|
119
|
+
"collector-unavailable",
|
|
120
|
+
"diagnostics-truncated",
|
|
121
|
+
"missing-typescript",
|
|
122
|
+
"invalid-target",
|
|
123
|
+
"invalid-git-ref",
|
|
124
|
+
"invalid-git-scope",
|
|
125
|
+
"filtered-out-of-scope",
|
|
126
|
+
"fix-failed",
|
|
127
|
+
"fix-no-op",
|
|
128
|
+
"unsupported",
|
|
129
|
+
]
|
|
130
|
+
const REVIEW_FIX_RECIPES: readonly ReviewFixRecipeId[] = ["manifest.sync", "workspace-dist.rebuild"]
|
|
131
|
+
const REVIEW_FIX_STATUSES: readonly ReviewFixStatus[] = ["planned", "changed", "no-op", "failed"]
|
|
132
|
+
|
|
133
|
+
/** Error raised for every malformed, unsafe, or payload-bearing report. */
|
|
134
|
+
export class ReviewParserError extends Error {
|
|
135
|
+
readonly code: ReviewParserErrorCode
|
|
136
|
+
readonly path?: string
|
|
137
|
+
|
|
138
|
+
constructor(code: ReviewParserErrorCode, path?: string) {
|
|
139
|
+
super(`invalid review report (${code})${path === undefined ? "" : ` at ${path}`}`)
|
|
140
|
+
this.name = "ReviewParserError"
|
|
141
|
+
this.code = code
|
|
142
|
+
if (path !== undefined) this.path = path
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function fail(code: ReviewParserErrorCode, path?: string): never {
|
|
147
|
+
throw new ReviewParserError(code, path)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
151
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
|
|
152
|
+
try {
|
|
153
|
+
const prototype = Object.getPrototypeOf(value)
|
|
154
|
+
return prototype === Object.prototype || prototype === null
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
|
161
|
+
return Object.hasOwn(record, key)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function compareText(left: string, right: string): number {
|
|
165
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
class ParseGuard {
|
|
169
|
+
private readonly seen = new WeakSet<object>()
|
|
170
|
+
private chargedBytes = 0
|
|
171
|
+
|
|
172
|
+
enter(value: object, depth: number, path: string): void {
|
|
173
|
+
if (depth > REVIEW_MAX_DEPTH) fail("oversized", path)
|
|
174
|
+
if (this.seen.has(value)) fail("invalid-reference", path)
|
|
175
|
+
this.seen.add(value)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
chargeString(value: string, maxBytes: number, code: ReviewParserErrorCode, path: string): void {
|
|
179
|
+
const bytes = new TextEncoder().encode(value).byteLength
|
|
180
|
+
if (bytes === 0 || bytes > maxBytes) fail(code, path)
|
|
181
|
+
if (value.includes("\u0000") || value.includes("\\")) fail(code, path)
|
|
182
|
+
this.chargedBytes += bytes + 2
|
|
183
|
+
if (this.chargedBytes > REVIEW_MAX_BYTES) fail("oversized", path)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function recordAt(
|
|
188
|
+
value: unknown,
|
|
189
|
+
guard: ParseGuard,
|
|
190
|
+
depth: number,
|
|
191
|
+
path: string,
|
|
192
|
+
): Record<string, unknown> {
|
|
193
|
+
if (!isPlainRecord(value)) fail("invalid-type", path)
|
|
194
|
+
guard.enter(value, depth, path)
|
|
195
|
+
return value
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function arrayAt(
|
|
199
|
+
value: unknown,
|
|
200
|
+
guard: ParseGuard,
|
|
201
|
+
depth: number,
|
|
202
|
+
path: string,
|
|
203
|
+
): readonly unknown[] {
|
|
204
|
+
if (!Array.isArray(value)) fail("invalid-type", path)
|
|
205
|
+
if (value.length > REVIEW_MAX_ARRAY_ITEMS) fail("oversized", path)
|
|
206
|
+
guard.enter(value, depth, path)
|
|
207
|
+
return value
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function assertKeys(
|
|
211
|
+
record: Record<string, unknown>,
|
|
212
|
+
allowed: readonly string[],
|
|
213
|
+
guard: ParseGuard,
|
|
214
|
+
path: string,
|
|
215
|
+
allowSource = false,
|
|
216
|
+
): void {
|
|
217
|
+
const allowedSet = new Set(allowed)
|
|
218
|
+
let ownKeys: readonly (string | symbol)[]
|
|
219
|
+
try {
|
|
220
|
+
ownKeys = Reflect.ownKeys(record)
|
|
221
|
+
} catch {
|
|
222
|
+
fail("invalid-type", path)
|
|
223
|
+
}
|
|
224
|
+
if (ownKeys.length > REVIEW_MAX_OBJECT_KEYS) fail("oversized", path)
|
|
225
|
+
for (const key of ownKeys) {
|
|
226
|
+
if (typeof key !== "string") fail("unknown-key", path)
|
|
227
|
+
guard.chargeString(key, REVIEW_MAX_ID_BYTES, "unknown-key", path)
|
|
228
|
+
const lower = key.toLowerCase()
|
|
229
|
+
if (lower === "source" && (!allowSource || key !== "source")) fail("forbidden-content", path)
|
|
230
|
+
if (FORBIDDEN_KEYS.has(lower)) fail("forbidden-content", path)
|
|
231
|
+
if (!allowedSet.has(key)) fail("unknown-key", path)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function requiredValue(record: Record<string, unknown>, key: string, path: string): unknown {
|
|
236
|
+
if (!hasOwn(record, key) || record[key] === undefined) fail("missing-required", `${path}.${key}`)
|
|
237
|
+
return record[key]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function optionalValue(record: Record<string, unknown>, key: string, path: string): unknown {
|
|
241
|
+
if (!hasOwn(record, key)) return undefined
|
|
242
|
+
if (record[key] === undefined) fail("invalid-type", `${path}.${key}`)
|
|
243
|
+
return record[key]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function stringValue(
|
|
247
|
+
value: unknown,
|
|
248
|
+
guard: ParseGuard,
|
|
249
|
+
path: string,
|
|
250
|
+
maxBytes: number,
|
|
251
|
+
code: ReviewParserErrorCode = "invalid-type",
|
|
252
|
+
): string {
|
|
253
|
+
if (typeof value !== "string") fail("invalid-type", path)
|
|
254
|
+
guard.chargeString(value, maxBytes, code, path)
|
|
255
|
+
return value
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function optionalString(
|
|
259
|
+
record: Record<string, unknown>,
|
|
260
|
+
key: string,
|
|
261
|
+
guard: ParseGuard,
|
|
262
|
+
path: string,
|
|
263
|
+
maxBytes: number,
|
|
264
|
+
code: ReviewParserErrorCode = "invalid-type",
|
|
265
|
+
): string | undefined {
|
|
266
|
+
const value = optionalValue(record, key, path)
|
|
267
|
+
return value === undefined
|
|
268
|
+
? undefined
|
|
269
|
+
: stringValue(value, guard, `${path}.${key}`, maxBytes, code)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function requiredString(
|
|
273
|
+
record: Record<string, unknown>,
|
|
274
|
+
key: string,
|
|
275
|
+
guard: ParseGuard,
|
|
276
|
+
path: string,
|
|
277
|
+
maxBytes: number,
|
|
278
|
+
code: ReviewParserErrorCode = "invalid-type",
|
|
279
|
+
): string {
|
|
280
|
+
return stringValue(requiredValue(record, key, path), guard, `${path}.${key}`, maxBytes, code)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function booleanValue(record: Record<string, unknown>, key: string, path: string): boolean {
|
|
284
|
+
const value = requiredValue(record, key, path)
|
|
285
|
+
if (typeof value !== "boolean") fail("invalid-type", `${path}.${key}`)
|
|
286
|
+
return value
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function integerValue(record: Record<string, unknown>, key: string, path: string): number {
|
|
290
|
+
const value = requiredValue(record, key, path)
|
|
291
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
|
292
|
+
fail("invalid-type", `${path}.${key}`)
|
|
293
|
+
return value
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function enumValue<T extends string>(
|
|
297
|
+
record: Record<string, unknown>,
|
|
298
|
+
key: string,
|
|
299
|
+
values: readonly T[],
|
|
300
|
+
guard: ParseGuard,
|
|
301
|
+
path: string,
|
|
302
|
+
): T {
|
|
303
|
+
const value = requiredString(record, key, guard, path, REVIEW_MAX_ID_BYTES)
|
|
304
|
+
if (!values.includes(value as T)) fail("invalid-enum", `${path}.${key}`)
|
|
305
|
+
return value as T
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function optionalEnum<T extends string>(
|
|
309
|
+
record: Record<string, unknown>,
|
|
310
|
+
key: string,
|
|
311
|
+
values: readonly T[],
|
|
312
|
+
guard: ParseGuard,
|
|
313
|
+
path: string,
|
|
314
|
+
): T | undefined {
|
|
315
|
+
const value = optionalString(record, key, guard, path, REVIEW_MAX_ID_BYTES)
|
|
316
|
+
if (value === undefined) return undefined
|
|
317
|
+
if (!values.includes(value as T)) fail("invalid-enum", `${path}.${key}`)
|
|
318
|
+
return value as T
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function digestValue(
|
|
322
|
+
record: Record<string, unknown>,
|
|
323
|
+
key: string,
|
|
324
|
+
guard: ParseGuard,
|
|
325
|
+
path: string,
|
|
326
|
+
): string {
|
|
327
|
+
const value = requiredString(record, key, guard, path, REVIEW_MAX_CODE_BYTES)
|
|
328
|
+
if (!HEX64.test(value)) fail("invalid-digest", `${path}.${key}`)
|
|
329
|
+
return value
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function identifierValue(
|
|
333
|
+
record: Record<string, unknown>,
|
|
334
|
+
key: string,
|
|
335
|
+
guard: ParseGuard,
|
|
336
|
+
path: string,
|
|
337
|
+
): string {
|
|
338
|
+
const value = requiredString(record, key, guard, path, REVIEW_MAX_ID_BYTES)
|
|
339
|
+
if (!ID.test(value)) fail("invalid-id", `${path}.${key}`)
|
|
340
|
+
return value
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function tokenValue(
|
|
344
|
+
record: Record<string, unknown>,
|
|
345
|
+
key: string,
|
|
346
|
+
guard: ParseGuard,
|
|
347
|
+
path: string,
|
|
348
|
+
): string {
|
|
349
|
+
const value = requiredString(record, key, guard, path, REVIEW_MAX_ID_BYTES)
|
|
350
|
+
if (!TOKEN.test(value)) fail("invalid-id", `${path}.${key}`)
|
|
351
|
+
return value
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function safePath(value: string, path: string): string {
|
|
355
|
+
if (
|
|
356
|
+
value.startsWith("/") ||
|
|
357
|
+
/^[A-Za-z]:($|\/)/.test(value) ||
|
|
358
|
+
value.startsWith("~") ||
|
|
359
|
+
value.includes("//")
|
|
360
|
+
)
|
|
361
|
+
fail("invalid-path", path)
|
|
362
|
+
const parts = value.split("/")
|
|
363
|
+
if (parts.some((part) => part.length === 0 || part === "." || part === ".."))
|
|
364
|
+
fail("invalid-path", path)
|
|
365
|
+
return value
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function pathValue(
|
|
369
|
+
record: Record<string, unknown>,
|
|
370
|
+
key: string,
|
|
371
|
+
guard: ParseGuard,
|
|
372
|
+
path: string,
|
|
373
|
+
): string {
|
|
374
|
+
const value = requiredString(record, key, guard, path, REVIEW_MAX_PATH_BYTES, "invalid-path")
|
|
375
|
+
return safePath(value, `${path}.${key}`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function optionalPath(
|
|
379
|
+
record: Record<string, unknown>,
|
|
380
|
+
key: string,
|
|
381
|
+
guard: ParseGuard,
|
|
382
|
+
path: string,
|
|
383
|
+
): string | undefined {
|
|
384
|
+
const value = optionalString(record, key, guard, path, REVIEW_MAX_PATH_BYTES, "invalid-path")
|
|
385
|
+
return value === undefined ? undefined : safePath(value, `${path}.${key}`)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function safeGitRef(value: string, path: string): string {
|
|
389
|
+
if (
|
|
390
|
+
value.length === 0 ||
|
|
391
|
+
value.startsWith("/") ||
|
|
392
|
+
value.endsWith("/") ||
|
|
393
|
+
value.startsWith("-") ||
|
|
394
|
+
value.includes("..") ||
|
|
395
|
+
value.includes("@{") ||
|
|
396
|
+
value.includes("//") ||
|
|
397
|
+
value.endsWith(".") ||
|
|
398
|
+
GIT_REF_FORBIDDEN.test(value) ||
|
|
399
|
+
value.includes("\u0000")
|
|
400
|
+
)
|
|
401
|
+
fail("invalid-path", path)
|
|
402
|
+
for (const part of value.split("/")) {
|
|
403
|
+
if (
|
|
404
|
+
part.length === 0 ||
|
|
405
|
+
part === "." ||
|
|
406
|
+
part === ".." ||
|
|
407
|
+
part.startsWith(".") ||
|
|
408
|
+
part.endsWith(".lock")
|
|
409
|
+
)
|
|
410
|
+
fail("invalid-path", path)
|
|
411
|
+
}
|
|
412
|
+
return value
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function gitRefValue(
|
|
416
|
+
record: Record<string, unknown>,
|
|
417
|
+
key: string,
|
|
418
|
+
guard: ParseGuard,
|
|
419
|
+
path: string,
|
|
420
|
+
): string | undefined {
|
|
421
|
+
const value = optionalString(record, key, guard, path, REVIEW_MAX_REF_BYTES, "invalid-path")
|
|
422
|
+
return value === undefined ? undefined : safeGitRef(value, `${path}.${key}`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function parseEvidence(
|
|
426
|
+
value: unknown,
|
|
427
|
+
guard: ParseGuard,
|
|
428
|
+
depth: number,
|
|
429
|
+
path: string,
|
|
430
|
+
): ReviewEvidenceRef {
|
|
431
|
+
const record = recordAt(value, guard, depth, path)
|
|
432
|
+
assertKeys(record, ["source", "token", "digest", "path"], guard, path, true)
|
|
433
|
+
const source = enumValue(record, "source", REVIEW_EVIDENCE_SOURCES, guard, path)
|
|
434
|
+
const token = tokenValue(record, "token", guard, path)
|
|
435
|
+
const digest = digestValue(record, "digest", guard, path)
|
|
436
|
+
const evidencePath = optionalPath(record, "path", guard, path)
|
|
437
|
+
return {
|
|
438
|
+
source,
|
|
439
|
+
token,
|
|
440
|
+
digest,
|
|
441
|
+
...(evidencePath === undefined ? {} : { path: evidencePath }),
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function parseEvidenceList(
|
|
446
|
+
value: unknown,
|
|
447
|
+
guard: ParseGuard,
|
|
448
|
+
depth: number,
|
|
449
|
+
path: string,
|
|
450
|
+
requireNonEmpty: boolean,
|
|
451
|
+
): readonly ReviewEvidenceRef[] {
|
|
452
|
+
const values = arrayAt(value, guard, depth, path)
|
|
453
|
+
if (requireNonEmpty && values.length === 0) fail("missing-required", path)
|
|
454
|
+
const result = values.map((item, index) =>
|
|
455
|
+
parseEvidence(item, guard, depth + 1, `${path}[${index}]`),
|
|
456
|
+
)
|
|
457
|
+
assertSortedUnique(
|
|
458
|
+
result,
|
|
459
|
+
(item) => `${item.source}\u0000${item.token}\u0000${item.digest}\u0000${item.path ?? ""}`,
|
|
460
|
+
path,
|
|
461
|
+
"invalid-reference",
|
|
462
|
+
)
|
|
463
|
+
return result
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function parseLocation(
|
|
467
|
+
value: unknown,
|
|
468
|
+
guard: ParseGuard,
|
|
469
|
+
depth: number,
|
|
470
|
+
path: string,
|
|
471
|
+
): ReviewLocation {
|
|
472
|
+
const record = recordAt(value, guard, depth, path)
|
|
473
|
+
assertKeys(record, ["path", "line", "column"], guard, path)
|
|
474
|
+
const lineValue = optionalValue(record, "line", path)
|
|
475
|
+
const columnValue = optionalValue(record, "column", path)
|
|
476
|
+
const line =
|
|
477
|
+
lineValue === undefined
|
|
478
|
+
? undefined
|
|
479
|
+
: typeof lineValue === "number" && Number.isSafeInteger(lineValue) && lineValue > 0
|
|
480
|
+
? lineValue
|
|
481
|
+
: fail("invalid-type", `${path}.line`)
|
|
482
|
+
const column =
|
|
483
|
+
columnValue === undefined
|
|
484
|
+
? undefined
|
|
485
|
+
: typeof columnValue === "number" && Number.isSafeInteger(columnValue) && columnValue > 0
|
|
486
|
+
? columnValue
|
|
487
|
+
: fail("invalid-type", `${path}.column`)
|
|
488
|
+
const locationPath = pathValue(record, "path", guard, path)
|
|
489
|
+
return {
|
|
490
|
+
path: locationPath,
|
|
491
|
+
...(line === undefined ? {} : { line }),
|
|
492
|
+
...(column === undefined ? {} : { column }),
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function parseFixRef(value: unknown, guard: ParseGuard, depth: number, path: string): ReviewFixRef {
|
|
497
|
+
const record = recordAt(value, guard, depth, path)
|
|
498
|
+
assertKeys(record, ["recipe"], guard, path)
|
|
499
|
+
return { recipe: enumValue(record, "recipe", REVIEW_FIX_RECIPES, guard, path) }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function parseFinding(
|
|
503
|
+
value: unknown,
|
|
504
|
+
guard: ParseGuard,
|
|
505
|
+
depth: number,
|
|
506
|
+
path: string,
|
|
507
|
+
): ReviewFinding {
|
|
508
|
+
const record = recordAt(value, guard, depth, path)
|
|
509
|
+
assertKeys(
|
|
510
|
+
record,
|
|
511
|
+
["id", "check", "code", "severity", "category", "location", "evidence", "fix"],
|
|
512
|
+
guard,
|
|
513
|
+
path,
|
|
514
|
+
)
|
|
515
|
+
const id = identifierValue(record, "id", guard, path)
|
|
516
|
+
const check = enumValue(record, "check", REVIEW_CHECK_IDS, guard, path)
|
|
517
|
+
const code = requiredString(record, "code", guard, path, REVIEW_MAX_CODE_BYTES)
|
|
518
|
+
if (!CODE.test(code)) fail("invalid-id", `${path}.code`)
|
|
519
|
+
const severity = enumValue(record, "severity", REVIEW_SEVERITIES, guard, path)
|
|
520
|
+
const category = enumValue(record, "category", REVIEW_CATEGORIES, guard, path)
|
|
521
|
+
const locationValue = optionalValue(record, "location", path)
|
|
522
|
+
const location =
|
|
523
|
+
locationValue === undefined
|
|
524
|
+
? undefined
|
|
525
|
+
: parseLocation(locationValue, guard, depth + 1, `${path}.location`)
|
|
526
|
+
const evidence = parseEvidenceList(
|
|
527
|
+
requiredValue(record, "evidence", path),
|
|
528
|
+
guard,
|
|
529
|
+
depth + 1,
|
|
530
|
+
`${path}.evidence`,
|
|
531
|
+
true,
|
|
532
|
+
)
|
|
533
|
+
const fixValue = optionalValue(record, "fix", path)
|
|
534
|
+
const fix =
|
|
535
|
+
fixValue === undefined ? undefined : parseFixRef(fixValue, guard, depth + 1, `${path}.fix`)
|
|
536
|
+
return {
|
|
537
|
+
id,
|
|
538
|
+
check,
|
|
539
|
+
code,
|
|
540
|
+
severity,
|
|
541
|
+
category,
|
|
542
|
+
...(location === undefined ? {} : { location }),
|
|
543
|
+
evidence,
|
|
544
|
+
...(fix === undefined ? {} : { fix }),
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function parseCounts(value: unknown, guard: ParseGuard, depth: number, path: string): ReviewCounts {
|
|
549
|
+
const record = recordAt(value, guard, depth, path)
|
|
550
|
+
assertKeys(record, ["findings", "errors", "warnings", "info", "outOfScope"], guard, path)
|
|
551
|
+
return {
|
|
552
|
+
findings: integerValue(record, "findings", path),
|
|
553
|
+
errors: integerValue(record, "errors", path),
|
|
554
|
+
warnings: integerValue(record, "warnings", path),
|
|
555
|
+
info: integerValue(record, "info", path),
|
|
556
|
+
outOfScope: integerValue(record, "outOfScope", path),
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function parseFindingIds(
|
|
561
|
+
value: unknown,
|
|
562
|
+
guard: ParseGuard,
|
|
563
|
+
depth: number,
|
|
564
|
+
path: string,
|
|
565
|
+
): readonly string[] {
|
|
566
|
+
const values = arrayAt(value, guard, depth, path)
|
|
567
|
+
const result = values.map((item, index) => {
|
|
568
|
+
if (typeof item !== "string") fail("invalid-type", `${path}[${index}]`)
|
|
569
|
+
guard.chargeString(item, REVIEW_MAX_ID_BYTES, "invalid-id", `${path}[${index}]`)
|
|
570
|
+
if (!ID.test(item)) fail("invalid-id", `${path}[${index}]`)
|
|
571
|
+
return item
|
|
572
|
+
})
|
|
573
|
+
assertSortedUnique(result, (item) => item, path, "duplicate-id")
|
|
574
|
+
return result
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function parseCheck(
|
|
578
|
+
value: unknown,
|
|
579
|
+
guard: ParseGuard,
|
|
580
|
+
depth: number,
|
|
581
|
+
path: string,
|
|
582
|
+
): ReviewCheckResult {
|
|
583
|
+
const record = recordAt(value, guard, depth, path)
|
|
584
|
+
assertKeys(
|
|
585
|
+
record,
|
|
586
|
+
["id", "required", "status", "duration", "counts", "findingIds", "evidence", "reasonCode"],
|
|
587
|
+
guard,
|
|
588
|
+
path,
|
|
589
|
+
)
|
|
590
|
+
const id = enumValue(record, "id", REVIEW_CHECK_IDS, guard, path)
|
|
591
|
+
const required = booleanValue(record, "required", path)
|
|
592
|
+
const status = enumValue(record, "status", REVIEW_CHECK_STATUSES, guard, path)
|
|
593
|
+
const duration = enumValue(record, "duration", REVIEW_DURATIONS, guard, path)
|
|
594
|
+
const counts = parseCounts(
|
|
595
|
+
requiredValue(record, "counts", path),
|
|
596
|
+
guard,
|
|
597
|
+
depth + 1,
|
|
598
|
+
`${path}.counts`,
|
|
599
|
+
)
|
|
600
|
+
const findingIds = parseFindingIds(
|
|
601
|
+
requiredValue(record, "findingIds", path),
|
|
602
|
+
guard,
|
|
603
|
+
depth + 1,
|
|
604
|
+
`${path}.findingIds`,
|
|
605
|
+
)
|
|
606
|
+
const evidence = parseEvidenceList(
|
|
607
|
+
requiredValue(record, "evidence", path),
|
|
608
|
+
guard,
|
|
609
|
+
depth + 1,
|
|
610
|
+
`${path}.evidence`,
|
|
611
|
+
true,
|
|
612
|
+
)
|
|
613
|
+
const reasonCode = optionalEnum(record, "reasonCode", REVIEW_REASONS, guard, path)
|
|
614
|
+
if (status === "skipped" && (required || reasonCode !== "not-configured"))
|
|
615
|
+
fail("derived-field-mismatch", path)
|
|
616
|
+
if ((status === "unavailable" || status === "error") && reasonCode === undefined)
|
|
617
|
+
fail("missing-required", `${path}.reasonCode`)
|
|
618
|
+
if ((status === "pass" || status === "fail") && reasonCode !== undefined)
|
|
619
|
+
fail("derived-field-mismatch", path)
|
|
620
|
+
return {
|
|
621
|
+
id,
|
|
622
|
+
required,
|
|
623
|
+
status,
|
|
624
|
+
duration,
|
|
625
|
+
counts,
|
|
626
|
+
findingIds,
|
|
627
|
+
evidence,
|
|
628
|
+
...(reasonCode === undefined ? {} : { reasonCode }),
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function parsePaths(
|
|
633
|
+
value: unknown,
|
|
634
|
+
guard: ParseGuard,
|
|
635
|
+
depth: number,
|
|
636
|
+
path: string,
|
|
637
|
+
): readonly string[] {
|
|
638
|
+
const values = arrayAt(value, guard, depth, path)
|
|
639
|
+
const result = values.map((item, index) => {
|
|
640
|
+
if (typeof item !== "string") fail("invalid-type", `${path}[${index}]`)
|
|
641
|
+
guard.chargeString(item, REVIEW_MAX_PATH_BYTES, "invalid-path", `${path}[${index}]`)
|
|
642
|
+
return safePath(item, `${path}[${index}]`)
|
|
643
|
+
})
|
|
644
|
+
assertSortedUnique(result, (item) => item, path, "duplicate-id")
|
|
645
|
+
return result
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function parseScope(value: unknown, guard: ParseGuard, depth: number, path: string): ReviewScope {
|
|
649
|
+
const record = recordAt(value, guard, depth, path)
|
|
650
|
+
assertKeys(
|
|
651
|
+
record,
|
|
652
|
+
["kind", "state", "gitRef", "changedPaths", "pathDigest", "outOfScopeCount", "reasonCode"],
|
|
653
|
+
guard,
|
|
654
|
+
path,
|
|
655
|
+
)
|
|
656
|
+
const kind = enumValue(record, "kind", ["project", "diff"] as const, guard, path)
|
|
657
|
+
const state = enumValue(record, "state", ["valid", "invalid"] as const, guard, path)
|
|
658
|
+
const gitRef = gitRefValue(record, "gitRef", guard, path)
|
|
659
|
+
const changedPaths = parsePaths(
|
|
660
|
+
requiredValue(record, "changedPaths", path),
|
|
661
|
+
guard,
|
|
662
|
+
depth + 1,
|
|
663
|
+
`${path}.changedPaths`,
|
|
664
|
+
)
|
|
665
|
+
const pathDigest = digestValue(record, "pathDigest", guard, path)
|
|
666
|
+
const outOfScopeCount = integerValue(record, "outOfScopeCount", path)
|
|
667
|
+
const reasonCode = optionalEnum(record, "reasonCode", REVIEW_REASONS, guard, path)
|
|
668
|
+
if (state === "invalid" && reasonCode === undefined)
|
|
669
|
+
fail("missing-required", `${path}.reasonCode`)
|
|
670
|
+
if (state === "valid" && reasonCode !== undefined) fail("derived-field-mismatch", path)
|
|
671
|
+
return {
|
|
672
|
+
kind,
|
|
673
|
+
state,
|
|
674
|
+
...(gitRef === undefined ? {} : { gitRef }),
|
|
675
|
+
changedPaths,
|
|
676
|
+
pathDigest,
|
|
677
|
+
outOfScopeCount,
|
|
678
|
+
...(reasonCode === undefined ? {} : { reasonCode }),
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function parseFixResult(
|
|
683
|
+
value: unknown,
|
|
684
|
+
guard: ParseGuard,
|
|
685
|
+
depth: number,
|
|
686
|
+
path: string,
|
|
687
|
+
): ReviewFixResult {
|
|
688
|
+
const record = recordAt(value, guard, depth, path)
|
|
689
|
+
assertKeys(record, ["recipe", "status", "changedPaths", "reasonCode"], guard, path)
|
|
690
|
+
const recipe = enumValue(record, "recipe", REVIEW_FIX_RECIPES, guard, path)
|
|
691
|
+
const status = enumValue(record, "status", REVIEW_FIX_STATUSES, guard, path)
|
|
692
|
+
const changedPathsValue = optionalValue(record, "changedPaths", path)
|
|
693
|
+
const changedPaths =
|
|
694
|
+
changedPathsValue === undefined
|
|
695
|
+
? undefined
|
|
696
|
+
: parsePaths(changedPathsValue, guard, depth + 1, `${path}.changedPaths`)
|
|
697
|
+
const reasonCode = optionalEnum(record, "reasonCode", REVIEW_REASONS, guard, path)
|
|
698
|
+
if ((status === "no-op" || status === "failed") && reasonCode === undefined)
|
|
699
|
+
fail("missing-required", `${path}.reasonCode`)
|
|
700
|
+
if ((status === "planned" || status === "changed") && reasonCode !== undefined)
|
|
701
|
+
fail("derived-field-mismatch", path)
|
|
702
|
+
return {
|
|
703
|
+
recipe,
|
|
704
|
+
status,
|
|
705
|
+
...(changedPaths === undefined ? {} : { changedPaths }),
|
|
706
|
+
...(reasonCode === undefined ? {} : { reasonCode }),
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function assertSortedUnique<T>(
|
|
711
|
+
values: readonly T[],
|
|
712
|
+
key: (value: T) => string,
|
|
713
|
+
path: string,
|
|
714
|
+
duplicateCode: ReviewParserErrorCode,
|
|
715
|
+
): void {
|
|
716
|
+
let previous: string | undefined
|
|
717
|
+
for (const value of values) {
|
|
718
|
+
const current = key(value)
|
|
719
|
+
if (previous !== undefined) {
|
|
720
|
+
if (current === previous) fail(duplicateCode, path)
|
|
721
|
+
if (compareText(current, previous) < 0) fail("non-canonical", path)
|
|
722
|
+
}
|
|
723
|
+
previous = current
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function fixKey(value: ReviewFixResult): string {
|
|
728
|
+
return `${value.recipe}\u0000${value.status}\u0000${value.changedPaths?.join("\u0000") ?? ""}\u0000${value.reasonCode ?? ""}`
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function assertReportRelationships(
|
|
732
|
+
checks: readonly ReviewCheckResult[],
|
|
733
|
+
findings: readonly ReviewFinding[],
|
|
734
|
+
fixes: readonly ReviewFixResult[] | undefined,
|
|
735
|
+
path: string,
|
|
736
|
+
): { readonly blocking: number; readonly status: ReviewStatus; readonly ok: boolean } {
|
|
737
|
+
assertSortedUnique(checks, (item) => item.id, `${path}.checks`, "duplicate-id")
|
|
738
|
+
assertSortedUnique(findings, (item) => item.id, `${path}.findings`, "duplicate-id")
|
|
739
|
+
if (fixes !== undefined) assertSortedUnique(fixes, fixKey, `${path}.fixes`, "duplicate-id")
|
|
740
|
+
|
|
741
|
+
const checksById = new Map<ReviewCheckId, ReviewCheckResult>()
|
|
742
|
+
for (const check of checks) checksById.set(check.id, check)
|
|
743
|
+
const findingsById = new Map<string, ReviewFinding>()
|
|
744
|
+
for (const finding of findings) {
|
|
745
|
+
if (findingsById.has(finding.id)) fail("duplicate-id", `${path}.findings`)
|
|
746
|
+
if (!checksById.has(finding.check)) fail("invalid-reference", `${path}.findings`)
|
|
747
|
+
findingsById.set(finding.id, finding)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const referenced = new Set<string>()
|
|
751
|
+
let errorCount = 0
|
|
752
|
+
for (const check of checks) {
|
|
753
|
+
let errors = 0
|
|
754
|
+
let warnings = 0
|
|
755
|
+
let info = 0
|
|
756
|
+
for (const findingId of check.findingIds) {
|
|
757
|
+
const finding = findingsById.get(findingId)
|
|
758
|
+
if (finding === undefined || finding.check !== check.id || referenced.has(findingId))
|
|
759
|
+
fail("invalid-reference", `${path}.checks`)
|
|
760
|
+
referenced.add(findingId)
|
|
761
|
+
if (finding.severity === "error") {
|
|
762
|
+
errors += 1
|
|
763
|
+
errorCount += 1
|
|
764
|
+
} else if (finding.severity === "warning") {
|
|
765
|
+
warnings += 1
|
|
766
|
+
} else {
|
|
767
|
+
info += 1
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (
|
|
771
|
+
check.counts.findings !== check.findingIds.length ||
|
|
772
|
+
check.counts.errors !== errors ||
|
|
773
|
+
check.counts.warnings !== warnings ||
|
|
774
|
+
check.counts.info !== info
|
|
775
|
+
)
|
|
776
|
+
fail("derived-field-mismatch", `${path}.checks`)
|
|
777
|
+
}
|
|
778
|
+
if (referenced.size !== findings.length) fail("invalid-reference", `${path}.findings`)
|
|
779
|
+
|
|
780
|
+
const unresolvedFixes =
|
|
781
|
+
fixes?.filter((fix) => fix.status === "no-op" || fix.status === "failed").length ?? 0
|
|
782
|
+
const blocking = errorCount + unresolvedFixes
|
|
783
|
+
return { blocking, status: blocking > 0 ? "fail" : "pass", ok: blocking === 0 }
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function deriveReportOutcome(
|
|
787
|
+
strict: boolean,
|
|
788
|
+
scope: ReviewScope,
|
|
789
|
+
checks: readonly ReviewCheckResult[],
|
|
790
|
+
findings: readonly ReviewFinding[],
|
|
791
|
+
fixes: readonly ReviewFixResult[] | undefined,
|
|
792
|
+
): { readonly blocking: number; readonly status: ReviewStatus; readonly ok: boolean } {
|
|
793
|
+
let errors = 0
|
|
794
|
+
let warnings = 0
|
|
795
|
+
for (const finding of findings) {
|
|
796
|
+
if (finding.severity === "error") errors += 1
|
|
797
|
+
if (finding.severity === "warning") warnings += 1
|
|
798
|
+
}
|
|
799
|
+
const unresolvedFixes =
|
|
800
|
+
fixes?.filter((fix) => fix.status === "no-op" || fix.status === "failed").length ?? 0
|
|
801
|
+
const blocking = errors + (strict ? warnings : 0) + unresolvedFixes
|
|
802
|
+
const inconclusive =
|
|
803
|
+
scope.state === "invalid" ||
|
|
804
|
+
checks.some(
|
|
805
|
+
(check) => check.required && (check.status === "unavailable" || check.status === "error"),
|
|
806
|
+
)
|
|
807
|
+
const status: ReviewStatus = inconclusive ? "inconclusive" : blocking > 0 ? "fail" : "pass"
|
|
808
|
+
return { blocking, status, ok: status === "pass" }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
|
|
812
|
+
if (typeof value !== "object" || value === null) return value
|
|
813
|
+
const object = value as object
|
|
814
|
+
if (seen.has(object)) return value
|
|
815
|
+
seen.add(object)
|
|
816
|
+
for (const key of Reflect.ownKeys(object)) {
|
|
817
|
+
deepFreeze((object as Record<PropertyKey, unknown>)[key], seen)
|
|
818
|
+
}
|
|
819
|
+
return Object.freeze(value)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function parseObjectReport(value: unknown, guard: ParseGuard): ReviewReport {
|
|
823
|
+
const record = recordAt(value, guard, 0, "$")
|
|
824
|
+
assertKeys(
|
|
825
|
+
record,
|
|
826
|
+
[
|
|
827
|
+
"version",
|
|
828
|
+
"strict",
|
|
829
|
+
"scope",
|
|
830
|
+
"checks",
|
|
831
|
+
"findings",
|
|
832
|
+
"fixes",
|
|
833
|
+
"blocking",
|
|
834
|
+
"ok",
|
|
835
|
+
"status",
|
|
836
|
+
"digest",
|
|
837
|
+
],
|
|
838
|
+
guard,
|
|
839
|
+
"$",
|
|
840
|
+
)
|
|
841
|
+
const version = requiredValue(record, "version", "$")
|
|
842
|
+
if (version !== REVIEW_REPORT_VERSION) fail("invalid-enum", "$.version")
|
|
843
|
+
const strict = booleanValue(record, "strict", "$")
|
|
844
|
+
const scope = parseScope(requiredValue(record, "scope", "$"), guard, 1, "$.scope")
|
|
845
|
+
const checksValue = arrayAt(requiredValue(record, "checks", "$"), guard, 1, "$.checks")
|
|
846
|
+
const checks = checksValue.map((item, index) => parseCheck(item, guard, 2, `$.checks[${index}]`))
|
|
847
|
+
const findingsValue = arrayAt(requiredValue(record, "findings", "$"), guard, 1, "$.findings")
|
|
848
|
+
const findings = findingsValue.map((item, index) =>
|
|
849
|
+
parseFinding(item, guard, 2, `$.findings[${index}]`),
|
|
850
|
+
)
|
|
851
|
+
const fixesValue = optionalValue(record, "fixes", "$")
|
|
852
|
+
const fixes =
|
|
853
|
+
fixesValue === undefined
|
|
854
|
+
? undefined
|
|
855
|
+
: arrayAt(fixesValue, guard, 1, "$.fixes").map((item, index) =>
|
|
856
|
+
parseFixResult(item, guard, 2, `$.fixes[${index}]`),
|
|
857
|
+
)
|
|
858
|
+
const blocking = integerValue(record, "blocking", "$")
|
|
859
|
+
const ok = booleanValue(record, "ok", "$")
|
|
860
|
+
const status = enumValue(record, "status", ["pass", "fail", "inconclusive"] as const, guard, "$")
|
|
861
|
+
const digest = digestValue(record, "digest", guard, "$")
|
|
862
|
+
|
|
863
|
+
const relationshipOutcome = assertReportRelationships(checks, findings, fixes, "$")
|
|
864
|
+
if (relationshipOutcome.blocking < 0) fail("derived-field-mismatch", "$.blocking")
|
|
865
|
+
const outcome = deriveReportOutcome(strict, scope, checks, findings, fixes)
|
|
866
|
+
if (blocking !== outcome.blocking || ok !== outcome.ok || status !== outcome.status)
|
|
867
|
+
fail("derived-field-mismatch", "$")
|
|
868
|
+
const report: ReviewReport = {
|
|
869
|
+
version: REVIEW_REPORT_VERSION,
|
|
870
|
+
strict,
|
|
871
|
+
scope,
|
|
872
|
+
checks,
|
|
873
|
+
findings,
|
|
874
|
+
...(fixes === undefined ? {} : { fixes }),
|
|
875
|
+
blocking,
|
|
876
|
+
ok,
|
|
877
|
+
status,
|
|
878
|
+
digest,
|
|
879
|
+
}
|
|
880
|
+
return report
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Parse a JSON string or unknown value into a bounded, deeply frozen review report. */
|
|
884
|
+
export async function parseReviewReport(input: unknown): Promise<ReviewReport> {
|
|
885
|
+
let value = input
|
|
886
|
+
if (typeof input === "string") {
|
|
887
|
+
const bytes = new TextEncoder().encode(input).byteLength
|
|
888
|
+
if (bytes > REVIEW_MAX_BYTES) fail("oversized", "$")
|
|
889
|
+
try {
|
|
890
|
+
value = JSON.parse(input) as unknown
|
|
891
|
+
} catch {
|
|
892
|
+
fail("invalid-root", "$")
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const report = parseObjectReport(value, new ParseGuard())
|
|
897
|
+
const canonicalBytes = new TextEncoder().encode(canonicalizeReviewReport(report)).byteLength
|
|
898
|
+
if (canonicalBytes > REVIEW_MAX_BYTES) fail("oversized", "$")
|
|
899
|
+
const expectedDigest = await digestReviewReport(report)
|
|
900
|
+
if (report.digest !== expectedDigest) fail("derived-field-mismatch", "$.digest")
|
|
901
|
+
return deepFreeze(report)
|
|
902
|
+
}
|