@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/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
+ }