@nifrajs/agent-app 3.3.0 → 4.0.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/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -1
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/transport.d.ts +5 -2
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +117 -12
- package/dist/transport.js.map +1 -1
- package/dist/view-models.d.ts +62 -1
- package/dist/view-models.d.ts.map +1 -1
- package/dist/view-models.js +455 -4
- package/dist/view-models.js.map +1 -1
- package/package.json +5 -2
- package/src/client.ts +7 -1
- package/src/index.ts +6 -0
- package/src/transport.ts +125 -12
- package/src/view-models.ts +575 -3
package/src/view-models.ts
CHANGED
|
@@ -18,11 +18,15 @@ import {
|
|
|
18
18
|
type BoundaryCommand,
|
|
19
19
|
type HandoffLifecycleState,
|
|
20
20
|
type HandoffSnapshot,
|
|
21
|
+
isAgentEvent,
|
|
21
22
|
nextApprovalState,
|
|
22
23
|
nextHandoffState,
|
|
23
24
|
type RunSnapshot,
|
|
24
25
|
} from "@nifrajs/agent-protocol"
|
|
25
26
|
|
|
27
|
+
/** Code-unit order, the same in every locale, so a view lists the same ids in the same order everywhere. */
|
|
28
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
|
|
29
|
+
|
|
26
30
|
/** A session reduced to lifecycle and capability facts. The working directory is deliberately omitted. */
|
|
27
31
|
export interface SessionView {
|
|
28
32
|
readonly id: string
|
|
@@ -172,6 +176,10 @@ export type AgentEventView =
|
|
|
172
176
|
|
|
173
177
|
/** Project one protocol event to its content-free view. Total over the event union - never returns undefined. */
|
|
174
178
|
export function toEventView(event: AgentEvent): AgentEventView {
|
|
179
|
+
// Keep this boundary total even when a caller bypasses the transport's parser with an
|
|
180
|
+
// untrusted cast. The protocol validator is discriminated and bounded; throwing only this
|
|
181
|
+
// generic message prevents malformed payloads from reaching content projections or error text.
|
|
182
|
+
if (!isAgentEvent(event)) throw new TypeError("agent event is invalid")
|
|
175
183
|
const base = { seq: event.seq, at: event.at }
|
|
176
184
|
switch (event.type) {
|
|
177
185
|
case "session.started":
|
|
@@ -446,9 +454,12 @@ const HANDOFF_STATES: ReadonlySet<string> = new Set([
|
|
|
446
454
|
const APPROVAL_UI_OPS = ["approve", "deny", "cancel"] as const
|
|
447
455
|
const HANDOFF_UI_OPS = ["assign", "resolve", "cancel"] as const
|
|
448
456
|
|
|
449
|
-
/**
|
|
457
|
+
/**
|
|
458
|
+
* True once `now` reaches or passes the boundary's expiry, or when either is not a number. A stale
|
|
459
|
+
* boundary fails every command closed.
|
|
460
|
+
*/
|
|
450
461
|
export function boundaryIsStale(item: BoundaryStateView, now: number): boolean {
|
|
451
|
-
return now
|
|
462
|
+
return !(now < item.expiresAt)
|
|
452
463
|
}
|
|
453
464
|
|
|
454
465
|
/**
|
|
@@ -721,6 +732,7 @@ export function toRunStudioView(value: unknown): RunStudioView | undefined {
|
|
|
721
732
|
if (value.traceRef !== undefined && !studioToken(value.traceRef)) return undefined
|
|
722
733
|
if (value.replayRef !== undefined && !studioToken(value.replayRef)) return undefined
|
|
723
734
|
const nodes: RunStudioNodeView[] = []
|
|
735
|
+
const seen = new Set<string>()
|
|
724
736
|
for (const raw of value.nodes) {
|
|
725
737
|
if (
|
|
726
738
|
!studioRecord(raw) ||
|
|
@@ -741,6 +753,7 @@ export function toRunStudioView(value: unknown): RunStudioView | undefined {
|
|
|
741
753
|
return undefined
|
|
742
754
|
if (
|
|
743
755
|
!studioToken(raw.nodeId) ||
|
|
756
|
+
seen.has(raw.nodeId) ||
|
|
744
757
|
!Array.isArray(raw.dependsOn) ||
|
|
745
758
|
!raw.dependsOn.every(studioToken) ||
|
|
746
759
|
typeof raw.state !== "string" ||
|
|
@@ -752,6 +765,7 @@ export function toRunStudioView(value: unknown): RunStudioView | undefined {
|
|
|
752
765
|
typeof raw.recovered !== "boolean"
|
|
753
766
|
)
|
|
754
767
|
return undefined
|
|
768
|
+
seen.add(raw.nodeId)
|
|
755
769
|
nodes.push(
|
|
756
770
|
Object.freeze({
|
|
757
771
|
nodeId: raw.nodeId,
|
|
@@ -765,7 +779,7 @@ export function toRunStudioView(value: unknown): RunStudioView | undefined {
|
|
|
765
779
|
}),
|
|
766
780
|
)
|
|
767
781
|
}
|
|
768
|
-
nodes.sort((a, b) => a.nodeId
|
|
782
|
+
nodes.sort((a, b) => byCodeUnit(a.nodeId, b.nodeId))
|
|
769
783
|
return Object.freeze({
|
|
770
784
|
runId: value.runId,
|
|
771
785
|
planId: value.planId,
|
|
@@ -917,3 +931,561 @@ export function virtualizeEvidenceRows<T>(
|
|
|
917
931
|
)
|
|
918
932
|
return Object.freeze({ offset, rows: Object.freeze(rows.slice(offset, offset + windowSize)) })
|
|
919
933
|
}
|
|
934
|
+
|
|
935
|
+
// ---------------------------------------------------------------------------
|
|
936
|
+
// Review projections
|
|
937
|
+
// ---------------------------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
export type ReviewViewStatus = "pass" | "fail" | "inconclusive" | "unavailable"
|
|
940
|
+
|
|
941
|
+
export interface ReviewViewEvidence {
|
|
942
|
+
readonly source: string
|
|
943
|
+
readonly token: string
|
|
944
|
+
readonly digest: string
|
|
945
|
+
readonly path?: string
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export interface ReviewViewFinding {
|
|
949
|
+
readonly id: string
|
|
950
|
+
readonly check: string
|
|
951
|
+
readonly code: string
|
|
952
|
+
readonly severity: "error" | "warning" | "info"
|
|
953
|
+
readonly category: string
|
|
954
|
+
readonly location?: { readonly path: string; readonly line?: number }
|
|
955
|
+
readonly evidence: readonly ReviewViewEvidence[]
|
|
956
|
+
readonly fix?: { readonly recipe: string }
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export interface ReviewViewCheck {
|
|
960
|
+
readonly id: string
|
|
961
|
+
readonly required: boolean
|
|
962
|
+
readonly status: "pass" | "fail" | "skipped" | "unavailable" | "error"
|
|
963
|
+
readonly findings: number
|
|
964
|
+
readonly errors: number
|
|
965
|
+
readonly warnings: number
|
|
966
|
+
readonly info: number
|
|
967
|
+
readonly outOfScope: number
|
|
968
|
+
readonly reasonCode?: string
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export interface ReviewView {
|
|
972
|
+
readonly version: 1
|
|
973
|
+
readonly status: ReviewViewStatus
|
|
974
|
+
readonly ok: boolean
|
|
975
|
+
readonly strict: boolean
|
|
976
|
+
readonly blocking: number
|
|
977
|
+
readonly digest?: string
|
|
978
|
+
readonly processStatus?: number | null
|
|
979
|
+
readonly reasonCode?: "invalid-report" | "unavailable" | "process-failed"
|
|
980
|
+
readonly scope?: {
|
|
981
|
+
readonly kind: "project" | "diff"
|
|
982
|
+
readonly state: "valid" | "invalid"
|
|
983
|
+
readonly pathDigest: string
|
|
984
|
+
readonly changedPathCount: number
|
|
985
|
+
readonly outOfScopeCount: number
|
|
986
|
+
}
|
|
987
|
+
readonly checks: readonly ReviewViewCheck[]
|
|
988
|
+
readonly findings: readonly ReviewViewFinding[]
|
|
989
|
+
readonly fixes: readonly {
|
|
990
|
+
readonly recipe: string
|
|
991
|
+
readonly status: "planned" | "changed" | "no-op" | "failed"
|
|
992
|
+
}[]
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const REVIEW_VIEW_CODE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
996
|
+
const REVIEW_VIEW_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/
|
|
997
|
+
const REVIEW_VIEW_DIGEST = /^[0-9a-f]{64}$/
|
|
998
|
+
const REVIEW_VIEW_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
999
|
+
const REVIEW_VIEW_EVIDENCE_SOURCES = new Set([
|
|
1000
|
+
"check",
|
|
1001
|
+
"assurance",
|
|
1002
|
+
"capability",
|
|
1003
|
+
"manifest",
|
|
1004
|
+
"contract",
|
|
1005
|
+
"coverage",
|
|
1006
|
+
"git-scope",
|
|
1007
|
+
"collector",
|
|
1008
|
+
])
|
|
1009
|
+
const REVIEW_VIEW_REASONS = new Set([
|
|
1010
|
+
"not-configured",
|
|
1011
|
+
"config-missing",
|
|
1012
|
+
"config-invalid",
|
|
1013
|
+
"collector-error",
|
|
1014
|
+
"collector-unavailable",
|
|
1015
|
+
"diagnostics-truncated",
|
|
1016
|
+
"missing-typescript",
|
|
1017
|
+
"invalid-target",
|
|
1018
|
+
"invalid-git-ref",
|
|
1019
|
+
"invalid-git-scope",
|
|
1020
|
+
"filtered-out-of-scope",
|
|
1021
|
+
"fix-failed",
|
|
1022
|
+
"fix-no-op",
|
|
1023
|
+
"unsupported",
|
|
1024
|
+
])
|
|
1025
|
+
const REVIEW_VIEW_FIX_RECIPES = new Set(["manifest.sync", "workspace-dist.rebuild"])
|
|
1026
|
+
const REVIEW_VIEW_FIX_STATUSES = new Set(["planned", "changed", "no-op", "failed"])
|
|
1027
|
+
const REVIEW_VIEW_RPC_ERRORS = new Set([
|
|
1028
|
+
"timeout",
|
|
1029
|
+
"output-truncated",
|
|
1030
|
+
"invalid-report",
|
|
1031
|
+
"spawn-failed",
|
|
1032
|
+
])
|
|
1033
|
+
const REVIEW_VIEW_CHECKS = new Set([
|
|
1034
|
+
"typecheck",
|
|
1035
|
+
"typed-client",
|
|
1036
|
+
"server-boundary",
|
|
1037
|
+
"route-boundary",
|
|
1038
|
+
"pipeline",
|
|
1039
|
+
"security",
|
|
1040
|
+
"route-assurance",
|
|
1041
|
+
"capability-provenance",
|
|
1042
|
+
"manifest",
|
|
1043
|
+
"dependency",
|
|
1044
|
+
"contract-witness",
|
|
1045
|
+
"coverage",
|
|
1046
|
+
"hydration",
|
|
1047
|
+
"configuration",
|
|
1048
|
+
])
|
|
1049
|
+
const REVIEW_VIEW_STATUSES = new Set(["pass", "fail", "skipped", "unavailable", "error"])
|
|
1050
|
+
const REVIEW_VIEW_SEVERITIES = new Set(["error", "warning", "info"])
|
|
1051
|
+
const REVIEW_VIEW_CATEGORIES = new Set([
|
|
1052
|
+
"correctness",
|
|
1053
|
+
"security",
|
|
1054
|
+
"boundary",
|
|
1055
|
+
"assurance",
|
|
1056
|
+
"capability",
|
|
1057
|
+
"contract",
|
|
1058
|
+
"dependency",
|
|
1059
|
+
"hydration",
|
|
1060
|
+
"coverage",
|
|
1061
|
+
"configuration",
|
|
1062
|
+
"operational",
|
|
1063
|
+
])
|
|
1064
|
+
const REVIEW_VIEW_FORBIDDEN = new Set([
|
|
1065
|
+
"prompt",
|
|
1066
|
+
"message",
|
|
1067
|
+
"text",
|
|
1068
|
+
"input",
|
|
1069
|
+
"output",
|
|
1070
|
+
"arguments",
|
|
1071
|
+
"body",
|
|
1072
|
+
"response",
|
|
1073
|
+
"secret",
|
|
1074
|
+
"credential",
|
|
1075
|
+
"diagnostic",
|
|
1076
|
+
"stack",
|
|
1077
|
+
"content",
|
|
1078
|
+
"transcript",
|
|
1079
|
+
"request",
|
|
1080
|
+
"payload",
|
|
1081
|
+
])
|
|
1082
|
+
|
|
1083
|
+
function reviewViewRecord(value: unknown): value is Record<string, unknown> {
|
|
1084
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function reviewViewSafeKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
|
|
1088
|
+
return Object.keys(value).every(
|
|
1089
|
+
(key) => !REVIEW_VIEW_FORBIDDEN.has(key.toLowerCase()) && allowed.has(key),
|
|
1090
|
+
)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function reviewViewEvidence(value: unknown): value is ReviewViewEvidence {
|
|
1094
|
+
if (!reviewViewRecord(value)) return false
|
|
1095
|
+
return (
|
|
1096
|
+
reviewViewSafeKeys(value, new Set(["source", "token", "digest", "path"])) &&
|
|
1097
|
+
typeof value.source === "string" &&
|
|
1098
|
+
REVIEW_VIEW_EVIDENCE_SOURCES.has(value.source) &&
|
|
1099
|
+
typeof value.token === "string" &&
|
|
1100
|
+
REVIEW_VIEW_TOKEN.test(value.token) &&
|
|
1101
|
+
typeof value.digest === "string" &&
|
|
1102
|
+
REVIEW_VIEW_DIGEST.test(value.digest) &&
|
|
1103
|
+
(value.path === undefined || reviewViewPath(value.path))
|
|
1104
|
+
)
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function reviewViewReason(value: unknown): value is string {
|
|
1108
|
+
return typeof value === "string" && REVIEW_VIEW_REASONS.has(value)
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function reviewViewPath(value: unknown): value is string {
|
|
1112
|
+
return (
|
|
1113
|
+
typeof value === "string" &&
|
|
1114
|
+
value.length > 0 &&
|
|
1115
|
+
value.length <= 1024 &&
|
|
1116
|
+
!value.includes("\\") &&
|
|
1117
|
+
!value.includes("\u0000") &&
|
|
1118
|
+
!value.startsWith("/") &&
|
|
1119
|
+
!/^[A-Za-z]:/.test(value) &&
|
|
1120
|
+
value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..")
|
|
1121
|
+
)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function reviewViewInt(value: unknown, min = 0): value is number {
|
|
1125
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= min
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function reviewUnavailableView(
|
|
1129
|
+
processStatus: number | null,
|
|
1130
|
+
reasonCode: "invalid-report" | "unavailable" | "process-failed",
|
|
1131
|
+
): ReviewView {
|
|
1132
|
+
return Object.freeze({
|
|
1133
|
+
version: 1,
|
|
1134
|
+
status: "unavailable",
|
|
1135
|
+
ok: false,
|
|
1136
|
+
strict: false,
|
|
1137
|
+
blocking: 0,
|
|
1138
|
+
processStatus,
|
|
1139
|
+
reasonCode,
|
|
1140
|
+
checks: Object.freeze([]),
|
|
1141
|
+
findings: Object.freeze([]),
|
|
1142
|
+
fixes: Object.freeze([]),
|
|
1143
|
+
})
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function parseReviewViewReport(value: Record<string, unknown>): ReviewView | undefined {
|
|
1147
|
+
if (
|
|
1148
|
+
!reviewViewSafeKeys(
|
|
1149
|
+
value,
|
|
1150
|
+
new Set([
|
|
1151
|
+
"version",
|
|
1152
|
+
"strict",
|
|
1153
|
+
"scope",
|
|
1154
|
+
"checks",
|
|
1155
|
+
"findings",
|
|
1156
|
+
"fixes",
|
|
1157
|
+
"blocking",
|
|
1158
|
+
"ok",
|
|
1159
|
+
"status",
|
|
1160
|
+
"digest",
|
|
1161
|
+
]),
|
|
1162
|
+
) ||
|
|
1163
|
+
value.version !== 1 ||
|
|
1164
|
+
typeof value.strict !== "boolean" ||
|
|
1165
|
+
(value.status !== "pass" && value.status !== "fail" && value.status !== "inconclusive") ||
|
|
1166
|
+
typeof value.ok !== "boolean" ||
|
|
1167
|
+
value.ok !== (value.status === "pass") ||
|
|
1168
|
+
!reviewViewInt(value.blocking) ||
|
|
1169
|
+
typeof value.digest !== "string" ||
|
|
1170
|
+
!REVIEW_VIEW_DIGEST.test(value.digest) ||
|
|
1171
|
+
!Array.isArray(value.checks) ||
|
|
1172
|
+
!Array.isArray(value.findings) ||
|
|
1173
|
+
value.scope === undefined
|
|
1174
|
+
)
|
|
1175
|
+
return undefined
|
|
1176
|
+
|
|
1177
|
+
let scope: ReviewView["scope"]
|
|
1178
|
+
if (value.scope !== undefined) {
|
|
1179
|
+
if (
|
|
1180
|
+
!reviewViewRecord(value.scope) ||
|
|
1181
|
+
!reviewViewSafeKeys(
|
|
1182
|
+
value.scope,
|
|
1183
|
+
new Set([
|
|
1184
|
+
"kind",
|
|
1185
|
+
"state",
|
|
1186
|
+
"gitRef",
|
|
1187
|
+
"changedPaths",
|
|
1188
|
+
"pathDigest",
|
|
1189
|
+
"outOfScopeCount",
|
|
1190
|
+
"reasonCode",
|
|
1191
|
+
]),
|
|
1192
|
+
) ||
|
|
1193
|
+
(value.scope.kind !== "project" && value.scope.kind !== "diff") ||
|
|
1194
|
+
(value.scope.state !== "valid" && value.scope.state !== "invalid") ||
|
|
1195
|
+
!Array.isArray(value.scope.changedPaths) ||
|
|
1196
|
+
!value.scope.changedPaths.every(reviewViewPath) ||
|
|
1197
|
+
typeof value.scope.pathDigest !== "string" ||
|
|
1198
|
+
!REVIEW_VIEW_DIGEST.test(value.scope.pathDigest) ||
|
|
1199
|
+
!reviewViewInt(value.scope.outOfScopeCount)
|
|
1200
|
+
)
|
|
1201
|
+
return undefined
|
|
1202
|
+
if (value.scope.gitRef !== undefined && !REVIEW_VIEW_TOKEN.test(String(value.scope.gitRef)))
|
|
1203
|
+
return undefined
|
|
1204
|
+
if (value.scope.state === "invalid") {
|
|
1205
|
+
if (!reviewViewReason(value.scope.reasonCode)) return undefined
|
|
1206
|
+
} else if (value.scope.reasonCode !== undefined) return undefined
|
|
1207
|
+
scope = Object.freeze({
|
|
1208
|
+
kind: value.scope.kind,
|
|
1209
|
+
state: value.scope.state,
|
|
1210
|
+
pathDigest: value.scope.pathDigest,
|
|
1211
|
+
changedPathCount: value.scope.changedPaths.length,
|
|
1212
|
+
outOfScopeCount: value.scope.outOfScopeCount,
|
|
1213
|
+
})
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (value.checks.length > 512 || value.findings.length > 4096) return undefined
|
|
1217
|
+
const checks: ReviewViewCheck[] = []
|
|
1218
|
+
const checkFindingIds = new Map<string, readonly string[]>()
|
|
1219
|
+
for (const raw of value.checks) {
|
|
1220
|
+
if (
|
|
1221
|
+
!reviewViewRecord(raw) ||
|
|
1222
|
+
!reviewViewSafeKeys(
|
|
1223
|
+
raw,
|
|
1224
|
+
new Set([
|
|
1225
|
+
"id",
|
|
1226
|
+
"required",
|
|
1227
|
+
"status",
|
|
1228
|
+
"duration",
|
|
1229
|
+
"counts",
|
|
1230
|
+
"findingIds",
|
|
1231
|
+
"evidence",
|
|
1232
|
+
"reasonCode",
|
|
1233
|
+
]),
|
|
1234
|
+
) ||
|
|
1235
|
+
typeof raw.id !== "string" ||
|
|
1236
|
+
!REVIEW_VIEW_CHECKS.has(raw.id) ||
|
|
1237
|
+
checks.some((check) => check.id === raw.id) ||
|
|
1238
|
+
typeof raw.required !== "boolean" ||
|
|
1239
|
+
typeof raw.status !== "string" ||
|
|
1240
|
+
!REVIEW_VIEW_STATUSES.has(raw.status) ||
|
|
1241
|
+
!reviewViewRecord(raw.counts) ||
|
|
1242
|
+
!reviewViewSafeKeys(
|
|
1243
|
+
raw.counts,
|
|
1244
|
+
new Set(["findings", "errors", "warnings", "info", "outOfScope"]),
|
|
1245
|
+
) ||
|
|
1246
|
+
!reviewViewInt(raw.counts.findings) ||
|
|
1247
|
+
!reviewViewInt(raw.counts.errors) ||
|
|
1248
|
+
!reviewViewInt(raw.counts.warnings) ||
|
|
1249
|
+
!reviewViewInt(raw.counts.info) ||
|
|
1250
|
+
!reviewViewInt(raw.counts.outOfScope) ||
|
|
1251
|
+
!Array.isArray(raw.findingIds) ||
|
|
1252
|
+
!raw.findingIds.every((id) => typeof id === "string" && REVIEW_VIEW_ID.test(id)) ||
|
|
1253
|
+
new Set(raw.findingIds).size !== raw.findingIds.length ||
|
|
1254
|
+
!Array.isArray(raw.evidence) ||
|
|
1255
|
+
raw.evidence.length === 0 ||
|
|
1256
|
+
raw.evidence.length > 1024 ||
|
|
1257
|
+
!raw.evidence.every(reviewViewEvidence)
|
|
1258
|
+
)
|
|
1259
|
+
return undefined
|
|
1260
|
+
if (raw.status === "skipped") {
|
|
1261
|
+
if (raw.required || raw.reasonCode !== "not-configured") return undefined
|
|
1262
|
+
} else if (raw.status === "unavailable" || raw.status === "error") {
|
|
1263
|
+
if (!reviewViewReason(raw.reasonCode)) return undefined
|
|
1264
|
+
} else if (raw.reasonCode !== undefined) return undefined
|
|
1265
|
+
checkFindingIds.set(raw.id, [...raw.findingIds])
|
|
1266
|
+
checks.push(
|
|
1267
|
+
Object.freeze({
|
|
1268
|
+
id: raw.id,
|
|
1269
|
+
required: raw.required,
|
|
1270
|
+
status: raw.status as ReviewViewCheck["status"],
|
|
1271
|
+
findings: raw.counts.findings,
|
|
1272
|
+
errors: raw.counts.errors,
|
|
1273
|
+
warnings: raw.counts.warnings,
|
|
1274
|
+
info: raw.counts.info,
|
|
1275
|
+
outOfScope: raw.counts.outOfScope,
|
|
1276
|
+
...(raw.reasonCode === undefined ? {} : { reasonCode: raw.reasonCode as string }),
|
|
1277
|
+
}),
|
|
1278
|
+
)
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const findings: ReviewViewFinding[] = []
|
|
1282
|
+
const findingById = new Map<string, ReviewViewFinding>()
|
|
1283
|
+
for (const raw of value.findings) {
|
|
1284
|
+
if (
|
|
1285
|
+
!reviewViewRecord(raw) ||
|
|
1286
|
+
!reviewViewSafeKeys(
|
|
1287
|
+
raw,
|
|
1288
|
+
new Set(["id", "check", "code", "severity", "category", "location", "evidence", "fix"]),
|
|
1289
|
+
) ||
|
|
1290
|
+
typeof raw.id !== "string" ||
|
|
1291
|
+
!REVIEW_VIEW_ID.test(raw.id) ||
|
|
1292
|
+
findingById.has(raw.id) ||
|
|
1293
|
+
typeof raw.check !== "string" ||
|
|
1294
|
+
!REVIEW_VIEW_CHECKS.has(raw.check) ||
|
|
1295
|
+
typeof raw.code !== "string" ||
|
|
1296
|
+
!REVIEW_VIEW_CODE.test(raw.code) ||
|
|
1297
|
+
typeof raw.severity !== "string" ||
|
|
1298
|
+
!REVIEW_VIEW_SEVERITIES.has(raw.severity) ||
|
|
1299
|
+
typeof raw.category !== "string" ||
|
|
1300
|
+
!REVIEW_VIEW_CATEGORIES.has(raw.category) ||
|
|
1301
|
+
!Array.isArray(raw.evidence) ||
|
|
1302
|
+
raw.evidence.length === 0 ||
|
|
1303
|
+
raw.evidence.length > 32
|
|
1304
|
+
)
|
|
1305
|
+
return undefined
|
|
1306
|
+
let location: ReviewViewFinding["location"]
|
|
1307
|
+
if (raw.location !== undefined) {
|
|
1308
|
+
if (
|
|
1309
|
+
!reviewViewRecord(raw.location) ||
|
|
1310
|
+
!reviewViewSafeKeys(raw.location, new Set(["path", "line", "column"])) ||
|
|
1311
|
+
!reviewViewPath(raw.location.path) ||
|
|
1312
|
+
(raw.location.line !== undefined && !reviewViewInt(raw.location.line, 1)) ||
|
|
1313
|
+
(raw.location.column !== undefined && !reviewViewInt(raw.location.column, 1))
|
|
1314
|
+
)
|
|
1315
|
+
return undefined
|
|
1316
|
+
location = Object.freeze({
|
|
1317
|
+
path: raw.location.path,
|
|
1318
|
+
...(raw.location.line === undefined ? {} : { line: raw.location.line }),
|
|
1319
|
+
})
|
|
1320
|
+
}
|
|
1321
|
+
const evidence: ReviewViewEvidence[] = []
|
|
1322
|
+
for (const rawEvidence of raw.evidence) {
|
|
1323
|
+
if (!reviewViewEvidence(rawEvidence)) return undefined
|
|
1324
|
+
evidence.push(
|
|
1325
|
+
Object.freeze({
|
|
1326
|
+
source: rawEvidence.source,
|
|
1327
|
+
token: rawEvidence.token,
|
|
1328
|
+
digest: rawEvidence.digest,
|
|
1329
|
+
...(rawEvidence.path === undefined ? {} : { path: rawEvidence.path }),
|
|
1330
|
+
}),
|
|
1331
|
+
)
|
|
1332
|
+
}
|
|
1333
|
+
let fix: ReviewViewFinding["fix"]
|
|
1334
|
+
if (raw.fix !== undefined) {
|
|
1335
|
+
if (
|
|
1336
|
+
!reviewViewRecord(raw.fix) ||
|
|
1337
|
+
!reviewViewSafeKeys(raw.fix, new Set(["recipe"])) ||
|
|
1338
|
+
typeof raw.fix.recipe !== "string" ||
|
|
1339
|
+
!REVIEW_VIEW_FIX_RECIPES.has(raw.fix.recipe)
|
|
1340
|
+
)
|
|
1341
|
+
return undefined
|
|
1342
|
+
fix = Object.freeze({ recipe: raw.fix.recipe })
|
|
1343
|
+
}
|
|
1344
|
+
const finding: ReviewViewFinding = Object.freeze({
|
|
1345
|
+
id: raw.id,
|
|
1346
|
+
check: raw.check,
|
|
1347
|
+
code: raw.code,
|
|
1348
|
+
severity: raw.severity as ReviewViewFinding["severity"],
|
|
1349
|
+
category: raw.category,
|
|
1350
|
+
...(location === undefined ? {} : { location }),
|
|
1351
|
+
evidence: Object.freeze(evidence),
|
|
1352
|
+
...(fix === undefined ? {} : { fix }),
|
|
1353
|
+
})
|
|
1354
|
+
findings.push(finding)
|
|
1355
|
+
findingById.set(finding.id, finding)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const fixes: Array<ReviewView["fixes"][number]> = []
|
|
1359
|
+
if (value.fixes !== undefined) {
|
|
1360
|
+
if (!Array.isArray(value.fixes) || value.fixes.length > 256) return undefined
|
|
1361
|
+
for (const raw of value.fixes) {
|
|
1362
|
+
if (
|
|
1363
|
+
!reviewViewRecord(raw) ||
|
|
1364
|
+
!reviewViewSafeKeys(raw, new Set(["recipe", "status", "changedPaths", "reasonCode"])) ||
|
|
1365
|
+
typeof raw.recipe !== "string" ||
|
|
1366
|
+
!REVIEW_VIEW_FIX_RECIPES.has(raw.recipe) ||
|
|
1367
|
+
typeof raw.status !== "string" ||
|
|
1368
|
+
!REVIEW_VIEW_FIX_STATUSES.has(raw.status) ||
|
|
1369
|
+
(raw.changedPaths !== undefined &&
|
|
1370
|
+
(!Array.isArray(raw.changedPaths) || !raw.changedPaths.every(reviewViewPath)))
|
|
1371
|
+
)
|
|
1372
|
+
return undefined
|
|
1373
|
+
if (raw.status === "no-op" || raw.status === "failed") {
|
|
1374
|
+
if (!reviewViewReason(raw.reasonCode)) return undefined
|
|
1375
|
+
} else if (raw.reasonCode !== undefined) return undefined
|
|
1376
|
+
fixes.push(
|
|
1377
|
+
Object.freeze({
|
|
1378
|
+
recipe: raw.recipe,
|
|
1379
|
+
status: raw.status as (typeof fixes)[number]["status"],
|
|
1380
|
+
}),
|
|
1381
|
+
)
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const referenced = new Set<string>()
|
|
1386
|
+
for (const check of checks) {
|
|
1387
|
+
const ids = checkFindingIds.get(check.id) ?? []
|
|
1388
|
+
const ownFindings = findings.filter((finding) => finding.check === check.id)
|
|
1389
|
+
if (
|
|
1390
|
+
ownFindings.length !== ids.length ||
|
|
1391
|
+
check.findings !== ownFindings.length ||
|
|
1392
|
+
check.errors !== ownFindings.filter((finding) => finding.severity === "error").length ||
|
|
1393
|
+
check.warnings !== ownFindings.filter((finding) => finding.severity === "warning").length ||
|
|
1394
|
+
check.info !== ownFindings.filter((finding) => finding.severity === "info").length
|
|
1395
|
+
)
|
|
1396
|
+
return undefined
|
|
1397
|
+
for (const id of ids) {
|
|
1398
|
+
const finding = findingById.get(id)
|
|
1399
|
+
if (finding === undefined || finding.check !== check.id || referenced.has(id))
|
|
1400
|
+
return undefined
|
|
1401
|
+
referenced.add(id)
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (referenced.size !== findings.length) return undefined
|
|
1405
|
+
|
|
1406
|
+
const errorCount = findings.filter((finding) => finding.severity === "error").length
|
|
1407
|
+
const warningCount = findings.filter((finding) => finding.severity === "warning").length
|
|
1408
|
+
const unresolvedFixes = fixes.filter(
|
|
1409
|
+
(fix) => fix.status === "no-op" || fix.status === "failed",
|
|
1410
|
+
).length
|
|
1411
|
+
const expectedBlocking = errorCount + (value.strict ? warningCount : 0) + unresolvedFixes
|
|
1412
|
+
const expectedInconclusive =
|
|
1413
|
+
value.scope.state === "invalid" ||
|
|
1414
|
+
checks.some(
|
|
1415
|
+
(check) => check.required && (check.status === "unavailable" || check.status === "error"),
|
|
1416
|
+
)
|
|
1417
|
+
const expectedStatus = expectedInconclusive
|
|
1418
|
+
? "inconclusive"
|
|
1419
|
+
: expectedBlocking > 0
|
|
1420
|
+
? "fail"
|
|
1421
|
+
: "pass"
|
|
1422
|
+
if (value.blocking !== expectedBlocking || value.status !== expectedStatus) return undefined
|
|
1423
|
+
|
|
1424
|
+
checks.sort((left, right) => byCodeUnit(left.id, right.id))
|
|
1425
|
+
findings.sort((left, right) => byCodeUnit(left.id, right.id))
|
|
1426
|
+
return Object.freeze({
|
|
1427
|
+
version: 1,
|
|
1428
|
+
status: value.status,
|
|
1429
|
+
ok: value.ok,
|
|
1430
|
+
strict: value.strict,
|
|
1431
|
+
blocking: value.blocking,
|
|
1432
|
+
digest: value.digest,
|
|
1433
|
+
...(scope === undefined ? {} : { scope }),
|
|
1434
|
+
checks: Object.freeze(checks),
|
|
1435
|
+
findings: Object.freeze(findings),
|
|
1436
|
+
fixes: Object.freeze(fixes),
|
|
1437
|
+
})
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** Project a review report or host RPC result into a bounded, content-free browser view. */
|
|
1441
|
+
export function toReviewView(value: unknown): ReviewView | undefined {
|
|
1442
|
+
if (!reviewViewRecord(value)) return undefined
|
|
1443
|
+
if (
|
|
1444
|
+
!Object.hasOwn(value, "report") &&
|
|
1445
|
+
Object.hasOwn(value, "ok") &&
|
|
1446
|
+
Object.hasOwn(value, "status") &&
|
|
1447
|
+
(value.status === null || typeof value.status === "number")
|
|
1448
|
+
) {
|
|
1449
|
+
if (
|
|
1450
|
+
!reviewViewSafeKeys(value, new Set(["ok", "status", "errorCode"])) ||
|
|
1451
|
+
typeof value.ok !== "boolean" ||
|
|
1452
|
+
(value.status !== null && !reviewViewInt(value.status)) ||
|
|
1453
|
+
(value.errorCode !== undefined &&
|
|
1454
|
+
(typeof value.errorCode !== "string" || !REVIEW_VIEW_RPC_ERRORS.has(value.errorCode)))
|
|
1455
|
+
)
|
|
1456
|
+
return reviewUnavailableView(null, "invalid-report")
|
|
1457
|
+
const reason =
|
|
1458
|
+
value.errorCode === "invalid-report"
|
|
1459
|
+
? "invalid-report"
|
|
1460
|
+
: value.errorCode === undefined
|
|
1461
|
+
? value.ok
|
|
1462
|
+
? "invalid-report"
|
|
1463
|
+
: "unavailable"
|
|
1464
|
+
: "process-failed"
|
|
1465
|
+
return reviewUnavailableView(value.status as number | null, reason)
|
|
1466
|
+
}
|
|
1467
|
+
if (Object.hasOwn(value, "report")) {
|
|
1468
|
+
if (
|
|
1469
|
+
!reviewViewSafeKeys(value, new Set(["ok", "status", "report", "errorCode"])) ||
|
|
1470
|
+
(value.status !== null && !reviewViewInt(value.status)) ||
|
|
1471
|
+
typeof value.ok !== "boolean" ||
|
|
1472
|
+
(value.errorCode !== undefined &&
|
|
1473
|
+
(typeof value.errorCode !== "string" || !REVIEW_VIEW_RPC_ERRORS.has(value.errorCode))) ||
|
|
1474
|
+
value.errorCode !== undefined
|
|
1475
|
+
)
|
|
1476
|
+
return reviewUnavailableView(null, "invalid-report")
|
|
1477
|
+
if (value.report === undefined)
|
|
1478
|
+
return reviewUnavailableView(
|
|
1479
|
+
value.status as number | null,
|
|
1480
|
+
value.ok ? "invalid-report" : "unavailable",
|
|
1481
|
+
)
|
|
1482
|
+
const report = reviewViewRecord(value.report) ? parseReviewViewReport(value.report) : undefined
|
|
1483
|
+
return report === undefined
|
|
1484
|
+
? reviewUnavailableView(value.status as number | null, "invalid-report")
|
|
1485
|
+
: Object.freeze({
|
|
1486
|
+
...report,
|
|
1487
|
+
...(value.status === undefined ? {} : { processStatus: value.status as number | null }),
|
|
1488
|
+
})
|
|
1489
|
+
}
|
|
1490
|
+
return parseReviewViewReport(value)
|
|
1491
|
+
}
|