Files
AgentLab/backend/app/bundle/verifier.js
T
ThearchyHelios b080302e77 可验证报告复核修正(第 1 轮):只给 id 的遮罩工件,服务端的文件和 id 对不上的标 mismatch、解析不出来的标 unparsable,核对页对不上的判不通过、解析不出来的照实说认不出是否含遮罩列
上一轮让被改过、解析不出的口径卡不再原样嵌入(号码不进文件),可这样一来它在页面上和正常的遮罩卡一模一样:「? 无法核对 含遮罩列,未随文件附带」,总结论从「有 1 项不通过」变成「可核对的项全部通过」,篡改的证据丢了;截断的卡服务端其实认不出输入,却被说成「含遮罩列」。现在 _Artifacts.add 对只给 id 的遮罩工件(口径卡、查询快照都算,带遮罩的快照被改过原来就有同样的问题)照实带上服务端看到的:store.intact 为假标 mismatch,解析不出对象标 unparsable。verifier.js 在遮罩分支认这两个标记:mismatch 判不通过,原因「导出时服务端的这份文件与工件 id(哈希)不一致,可能被改过;按含遮罩列处理,未随文件附带」;unparsable 是正常的无法核对,原因「导出时服务端的这份文件无法解析,认不出是否含遮罩列,按含遮罩列处理,未随文件附带」,引用它的数字跟着用这句。pytest 补被改过、截断、不是对象、哈希对得上却解析不出的卡和被改过的遮罩快照;check-bundle 在 masked-card 样本上补两条篡改检查。
2026-10-06 13:43:04 +08:00

1239 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 可验证报告的浏览器端核对:原生 JS,无依赖,不连网。
*
* 数据在页面末尾 id 为 bundle-data 的 JSON 数据块里(服务端 bundle/build.py 生成),逐项核对:
* ① 每条带规范字符串的事件:sha256(canonical) 等于它的 data_sha256;
* ② 封存清单:照 runner 的拼法(每行 seq|type|node_id|data_sha256,node_id 为空写空串,按 seq 排序,换行连接,
* 没有结尾换行)重算,等于封存哈希;页面顶部直接写出的那份封存哈希(不执行脚本也看得到)要和它一致;
* ③ 每份嵌入的工件:sha256(原字节) 等于工件 id;
* ④ 引用链:报告文档的 id 是这个报告节点最后一次自查事件(已核对)记下的那份,文档记的运行、节点对得上;
* 口径卡和快照的 id 出现在已核对的事件内容里,或者已核对的报告文档的证据目录里;
* ⑤ 报告里的每个数字和取值:引用的值等于口径卡里那个指标的值,或者快照里那一格的值。判等照搬服务端
* engine/expressions.py 的 cell_value / same_value;数字怎么显示是服务端渲染好的,这里不重新排版;
* ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称
* 绑不进封存,页面上写明它们取自运行记录。
* 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、口径卡标着遮罩)
* 一律算不通过:只可能是文件被改过。这些只证明内部一致,真伪要拿封存哈希去系统里对照,页面上写明。
*
* SHA-256 优先用浏览器的 crypto.subtle;拿不到(非安全上下文、某些浏览器打开本地文件时)用下面的纯 JS 实现,
* 两者结果一致。纯 JS 实现启动时先拿标准测试向量自检,自检不过就不下任何结论。
* 脚本放在 head 里:一开始执行就给 html 标上 data-js(样式表据此把「尚未核对」换成「正在核对」,附件预览这类不执行
* 脚本的地方看到的是前一句),等文档解析完再核对。来自数据的文字一律经 textContent 写进页面,不拼 HTML。
*/
(() => {
'use strict'
const PASS = 'pass'
const FAIL = 'fail'
const NA = 'na'
/** 汇总用(分块、报告的徽标):一组里有通过也有无法核对,没有不通过 */
const PARTIAL = 'partial'
/** 一块里条目超过这么多就收起来,只把不通过和无法核对的列在外面 */
const FOLD_AT = 20
// ------------------------------------------------------------------ 文案
const RESULT = { pass: '通过', fail: '不通过', na: '无法核对', partial: '部分无法核对' }
const MARK = { pass: '✓', fail: '✗', na: '?', partial: '?' }
const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照' }
const TIER_LABEL = { formal: '完整出具', degraded: '降档出具', withheld: '不予出具' }
const CLASS_LABEL = { formal: '正式运行', exploratory: '探索运行' }
const STATUS_LABEL = { succeeded: '已完成', failed: '已失败', cancelled: '已取消' }
/** 结束类事件 → 它说明的运行结果(runner:成功、失败、取消各发一条,落库之后才封存) */
const TERMINAL = { 'run.finished': 'succeeded', 'run.failed': 'failed', 'run.cancelled': 'cancelled' }
const EVENT_LABEL = {
'run.started': '运行开始', 'run.finished': '运行结束', 'run.failed': '运行失败', 'run.cancelled': '运行取消',
'run.interrupted': '等待人工介入', 'run.resumed': '恢复运行', 'node.started': '节点开始', 'node.finished': '节点完成',
'node.failed': '节点失败', 'node.skipped': '节点跳过', 'edge.taken': '分支走向', 'llm.start': '模型调用开始',
'llm.end': '模型调用结束', 'llm.thinking': '模型思考', 'tool.start': '工具调用', 'tool.end': '工具返回',
'tool.error': '工具出错', 'tool.gated': '门控判定', 'retrieve.end': '知识检索', 'memory.end': '记忆读写',
'merge.end': '合并查询', 'sandbox.start': '沙箱开始', 'sandbox.end': '沙箱结束', 'agent.step.start': 'Agent 步骤开始',
'agent.step.end': 'Agent 步骤结束', 'agent.route.start': '调度决策开始', 'agent.route.end': '调度决策结束',
'human.requested': '请求人工审批', 'human.resolved': '人工审批结果', 'approval.inquiry': '审批质询',
'issuance': '出具判定', 'caliber.upgrade': '口径卡升版处置', 'catalog.drift': '数据目录变化',
'report.checked': '报告自查', 'evidence.judged': '按需裁判', 'log': '日志', 'usage': '用量',
}
const T = {
allPass: '全部通过',
passWithNa: '可核对的项全部通过',
failed: (n) => `有 ${n} 项不通过`,
incomplete: (n) => `有 ${n} 份报告未能核对`,
tally: (p, f, n) => `通过 ${p} 项 · 不通过 ${f} 项 · 无法核对 ${n} 项`,
broken: '页面数据无法解析,无法核对',
selfTestFailed: '本页的 SHA-256 实现在这个浏览器中自检未通过,不给出核对结论',
metaRun: '运行 ID', metaStatus: '运行结果', metaClass: '运行类别', metaTier: '出具档位', metaRange: '封存范围',
metaExported: '导出时间', metaWorkflow: '工作流', metaVersion: '版本', metaVersionHash: '版本哈希', metaStarted: '开始',
metaFinished: '结束',
recordNote: '以下几项和页面上的节点名称取自运行记录,不在封存范围内,需要在系统中一并对照',
// 序号不连续(模型的流式输出占序号、不落库),后面注明范围内实际有几条。说法同记录页的封存凭证
range: (seq, count) => `第 1–${seq} 条事件(范围内 ${count} 条)`,
rangeLegacy: '(早期运行,范围按结束事件推算)',
markPass: '已核对',
markFail: (claim) => `文件里写的是「${claim}」,见核对明细 ⑥`,
markNa: '无法核对',
noReports: '这次运行没有报告文档,下方只核对运行记录。',
reportTitle: (label) => `报告「${label}」`,
reportParts: (doc, nums) => `文档 ${doc} · 数字与取值 ${nums}`,
reportNoNumbers: '无',
docUntrusted: '这份报告文档没有通过核对(见核对明细 ③ ④),正文仅供参考,其中的数字不予核对。',
docUnreadable: '这份报告文档的哈希一致,但内容无法解析,无法显示正文。',
docMissing: '导出时服务端已没有这份报告文档,无法显示正文,其中的数字也无法核对。',
docAbsent: '文件里没有附带这份报告文档。',
checkEvents: '① 事件内容与哈希',
checkManifest: '② 封存清单',
checkArtifacts: '③ 工件与哈希',
checkChain: '④ 引用链',
checkNumbers: '⑤ 报告中的数字与取值',
checkMeta: '⑥ 运行信息',
sumEvents: (n) => `${n} 条带内容的事件;其余事件只附哈希,参与 ② 的清单核对`,
sumManifest: (n) => `按 ${n} 条事件的哈希重算`,
sumCount: (n, unit) => `${n} ${unit}`,
sumNumbers: (numbers, values) => `数字 ${numbers} 个 · 取值 ${values} 个`,
sumMeta: '与封存范围内已核对的事件对照',
showAll: (n) => `展开全部 ${n} 项`,
eventMismatch: '内容与记录的哈希不一致',
eventNotJson: '内容不是有效的 JSON',
manifestComputed: '重算结果',
manifestShown: '页面顶部写出的封存哈希',
manifestLines: (n) => `查看参与清单核对的 ${n} 行(每行 序号|类型|节点|内容哈希,可复制后用任意 SHA-256 工具自行计算)`,
manifestOk: '与封存清单哈希一致',
manifestMismatch: '与封存清单哈希不一致:事件被增删、改动或换了顺序',
beyondSeal: (seq) => `第 ${seq} 条事件的序号超出封存范围`,
rangeMismatch: (seq, last) => `封存范围写的是第 1–${seq} 条,范围内最后一条事件却是第 ${last} 条`,
shownOk: '与核对用的封存哈希一致',
shownMismatch: '与数据里核对用的封存哈希不一致,页面被改过;上方现在显示的是核对用的那一份',
artifactMismatch: '内容与工件 id(哈希)不一致',
artifactUndecodable: '内容无法解码',
artifactUnparsable: '哈希一致,但内容无法解析',
masked: '含遮罩列,未随文件附带,只能核对到哈希',
maskedMismatch: '导出时服务端的这份文件与工件 id(哈希)不一致,可能被改过;按含遮罩列处理,未随文件附带',
maskedUnparsable: '导出时服务端的这份文件无法解析,认不出是否含遮罩列,按含遮罩列处理,未随文件附带',
maskedOdd: '只有查询快照和口径卡会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过',
artifactMissing: '导出时服务端已没有这份工件的文件',
artifactAbsent: '内容未随文件附带:导出时不会出现这种情况,文件可能被改过',
chainDoc: (label) => `报告「${label}」的文档`,
chainByEvent: (seq, what) => `第 ${seq} 条事件(${what})`,
chainByDoc: (label, alias) => `报告「${label}」的证据目录(${alias})`,
chainFrom: '引用自',
chainNoCheckEvent: '找不到这个报告节点的报告自查事件',
chainCheckEventFailed: '报告自查事件的内容没有通过核对',
chainOtherDoc: '这个节点最后一次报告自查记下的不是这份文档',
chainDocNotListed: '文件的工件清单里没有这份报告文档:导出时不会出现这种情况,文件可能被改过',
chainDocUnverified: '文档内容没有通过核对,无法确认它记下的运行和节点',
chainDocMissing: '导出时服务端已没有这份报告文档,无法确认它记下的运行和节点',
chainDocUnparsable: '文档的哈希一致,但内容无法解析,无法确认它记下的运行和节点',
chainDocElsewhere: '文档记下的运行或节点与这次不符',
chainDocLeftOut: '运行记录里有这个报告节点的自查事件,文件里却没有附带它的文档',
chainUnreferenced: '已核对的事件和报告文档里都没有引用这份工件',
srcMetric: (name) => `口径卡指标「${name}」`,
srcCell: (alias, row, column) => `查询 ${alias} · 第 ${row + 1} 行 · ${column}`,
srcQuery: (alias) => `查询 ${alias}`,
srcInput: (field) => `运行输入「${field}」`,
srcNone: '没有出处',
srcOther: '其他引用',
numUncited: '该数字未使用引用标记,无法核对其来源',
numUnresolved: (why) => `报告生成时这处引用未能解析${why ? `:${why}` : ''}`,
numInput: '引用的是运行输入,运行输入未随文件附带',
numOther: '这类引用不在核对范围内',
numDocUntrusted: '所在的报告文档没有通过核对',
numNoEntry: '报告的证据目录里没有这处引用指向的条目',
numArtifactNotListed: '引用的工件不在文件的工件清单里:导出时不会出现这种情况,文件可能被改过',
numArtifactFailed: '引用的工件没有通过核对(见核对明细 ③)',
numArtifactUnparsable: '引用的工件哈希一致,但内容无法解析',
numNotChained: '引用的工件不在引用链上',
numNoMetric: '口径卡里没有这个指标',
numMetricDiffers: '引用的值与口径卡里的值不一致',
numNoCell: (why) => `快照里找不到这一格:${why}`,
numCellDiffers: '引用的值与快照里那一格的值不一致',
numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`,
statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`,
statusMismatch: (claim, seq, what) => `文件里写的是「${claim}」,与封存范围内最后一条结束类事件(第 ${seq} 条,${what})不符`,
statusLegacy: '早期运行在结束事件落库之前封存,结束事件不在封存范围内,运行结果无法核对',
statusNoEvent: '封存范围内没有运行结束、失败或取消的事件,运行结果无法核对',
statusContinued: (claim, seq, what) => `文件里写的是「${claim}」,但封存范围内第 ${seq} 条事件(${what})之后运行又继续执行,`
+ '它不是运行的结束,范围内也没有更晚的结束事件可以印证',
classOk: (seq) => `与第 ${seq} 条事件(运行开始)记下的运行参数一致`,
classMismatch: (claim, seq) => `文件里写的是「${claim}」,与第 ${seq} 条事件(运行开始)记下的运行参数不符`,
classLegacy: (seq) => `第 ${seq} 条事件(运行开始)出自早期运行,没有记下区分运行类别的参数,运行类别无法核对`,
classUnchecked: (seq) => `第 ${seq} 条事件(运行开始)的内容没有通过核对,运行类别无法核对`,
classAbsent: (seq) => `第 ${seq} 条事件(运行开始)的内容未随文件附带:导出时不会出现这种情况,文件可能被改过`,
classNoEvent: '封存范围内没有运行开始事件,运行类别无法核对',
tierOk: (seq) => `与第 ${seq} 条事件(出具判定)一致`,
tierMismatch: (claim, seq) => `文件里写的是「${claim}」,与封存范围内最后一条出具判定(第 ${seq} 条)不符`,
tierNoEvent: (claim) => `文件里写着出具档位「${claim}」,封存范围内却没有出具判定`,
tierUnchecked: (seq) => `第 ${seq} 条事件(出具判定)的内容没有通过核对,出具档位无法核对`,
tierAbsent: (seq) => `第 ${seq} 条事件(出具判定)的内容未随文件附带:导出时不会出现这种情况,文件可能被改过`,
metaNeedsManifest: '封存清单没有通过核对,事件的类型和序号不可信,无法对照',
dResult: '核对结果', dSource: '出处', dNode: '来源节点', dCaliber: '口径', dCited: '引用的值', dCard: '口径卡里的值',
dCell: '快照里的值', dShown: '正文显示', dShownNote: '显示格式由服务端渲染', dArtifact: '所在工件', dReport: '所在报告',
dSql: 'SQL', dRow: (row) => `快照第 ${row + 1} 行`,
nullValue: '空值(NULL)',
appendedNote: '这些事件追加在封存之后,不在封存范围内,不参与清单核对,也不能当作证据。',
appendedTag: '封存之后追加,不在封存范围内',
shaSubtle: 'SHA-256 由浏览器内置实现计算(crypto.subtle)',
shaJs: 'SHA-256 由本页内置的实现计算(浏览器未提供 crypto.subtle),结果与浏览器内置实现一致',
renderNote: '数字的显示格式(千分位、单位、小数位)由服务端渲染;本页核对的是引用的值,不重新排版。',
schema: (s) => `文件格式 ${s}`,
}
// ------------------------------------------------------------------ SHA-256
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
])
/** 纯 JS 的 SHA-256(FIPS 180-4):字节 → 64 位小写十六进制 */
function sha256js(bytes) {
const n = bytes.length
const padded = new Uint8Array(Math.ceil((n + 9) / 64) * 64)
padded.set(bytes)
padded[n] = 0x80
const view = new DataView(padded.buffer)
// 消息长度按 64 位大端写在最后:位数可能超过 2^32,拆成高低两半
view.setUint32(padded.length - 8, Math.floor(n / 0x20000000))
view.setUint32(padded.length - 4, (n % 0x20000000) * 8)
const H = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
const W = new Uint32Array(64)
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) W[i] = view.getUint32(off + i * 4)
for (let i = 16; i < 64; i++) {
const a = W[i - 15]
const b = W[i - 2]
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3)
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10)
W[i] = W[i - 16] + s0 + W[i - 7] + s1
}
let a = H[0]; let b = H[1]; let c = H[2]; let d = H[3]
let e = H[4]; let f = H[5]; let g = H[6]; let h = H[7]
for (let i = 0; i < 64; i++) {
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7))
const ch = (e & f) ^ (~e & g)
const t1 = (h + S1 + ch + K[i] + W[i]) | 0
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10))
const maj = (a & b) ^ (a & c) ^ (b & c)
const t2 = (S0 + maj) | 0
h = g; g = f; f = e; e = (d + t1) | 0
d = c; c = b; b = a; a = (t1 + t2) | 0
}
H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h
}
let out = ''
for (let i = 0; i < 8; i++) out += H[i].toString(16).padStart(8, '0')
return out
}
const encoder = new TextEncoder()
const decoder = new TextDecoder('utf-8', { fatal: true })
const utf8 = (text) => encoder.encode(text)
const bytesHex = (buf) => Array.from(new Uint8Array(buf), (x) => x.toString(16).padStart(2, '0')).join('')
// 标准测试向量(空串、abc、跨块的 448 位串、中文、1000 个 a)
const VECTORS = [
['', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'],
['abc', 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'],
['abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq', '248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1'],
['可验证报告', 'e49666cf360bf3171c8e7d9645148517861e1966a3b4c6b6785c1cd3a2014a13'],
['a'.repeat(1000), '41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3'],
]
/** 用哪一种 SHA-256:{ kind: 'subtle' | 'js', digest(字节) → Promise<十六进制> };纯 JS 实现自检不过返回 null */
async function pickDigest() {
if (!VECTORS.every(([text, sum]) => sha256js(utf8(text)) === sum)) return null
const subtle = typeof crypto !== 'undefined' && crypto ? crypto.subtle : undefined
if (subtle && typeof subtle.digest === 'function') {
try {
if (bytesHex(await subtle.digest('SHA-256', utf8('abc'))) === VECTORS[1][1]) {
return { kind: 'subtle', digest: async (bytes) => bytesHex(await subtle.digest('SHA-256', bytes)) }
}
} catch (e) {
// 非安全上下文、打开本地文件时可能拿不到或者用不了:退回内置实现
}
}
return { kind: 'js', digest: async (bytes) => sha256js(bytes) }
}
function fromBase64(text) {
const bin = atob(text)
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
return out
}
const NONFINITE = { NaN: NaN, Infinity: Infinity, '-Infinity': -Infinity }
/**
* JSON.parse,另认 Python 的 json.dumps 写出的裸 NaN、Infinity、-Infinity(JSON 标准不认)。规范字符串和工件文件
* 都是 json.dumps 写的,查询结果里浮点的 NaN 会原样落进去,标准的 JSON.parse 一读就报错,没被改过的文件也会被判成
* 不通过。字符串外面的这几个词先换成占位的字符串再解析,解析时换回对应的数:判等和服务端一样,NaN 和谁都不相等。
* 只用来读内容,哈希照旧按原字节算
*/
function parseJson(text) {
try {
return JSON.parse(text)
} catch (e) {
// 下面按宽松的认法再试一次
}
let mark = '\uE000nonfinite:'
while (text.includes(mark)) mark += '\uE000'
let out = ''
let from = 0
let inString = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (inString) {
if (c === '\\') i++
else if (c === '"') inString = false
continue
}
if (c === '"') {
inString = true
continue
}
const word = c === '-' || c === 'I' || c === 'N' ? ['-Infinity', 'Infinity', 'NaN'].find((w) => text.startsWith(w, i)) : null
if (word) {
out += text.slice(from, i) + JSON.stringify(mark + word)
i += word.length - 1
from = i + 1
}
}
out += text.slice(from)
return JSON.parse(out, (_, v) => (typeof v === 'string' && v.startsWith(mark) && v.slice(mark.length) in NONFINITE
? NONFINITE[v.slice(mark.length)] : v))
}
// ------------------------------------------------------------------ 判等:照搬 engine/expressions.py
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const own = (obj, key) => (isObj(obj) && typeof key === 'string' && Object.prototype.hasOwnProperty.call(obj, key)
? obj[key] : undefined)
// 数据源把 DECIMAL 落成数字文本("45678.50"、"45678"、"1E-7"):按数算;带前导零的是编号,照文本(同 _NUMERIC_TEXT)
const NUMERIC_TEXT = /^\s*-?(?:0|[1-9]\d*)(?:\.\d+)?(?:E[+-]?\d+)?\s*$/
/** cell_value:快照里的一格 → 参与比对的值。kind 是查询时记下的列类型,text 列原样 */
function cellValue(raw, kind) {
if (kind === 'text') return raw
return typeof raw === 'string' && NUMERIC_TEXT.test(raw) ? Number(raw) : raw
}
/** math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-9) */
function isClose(a, b) {
if (a === b) return true
if (!Number.isFinite(a) || !Number.isFinite(b)) return false
return Math.abs(a - b) <= Math.max(1e-9 * Math.max(Math.abs(a), Math.abs(b)), 1e-9)
}
const textOf = (v) => (typeof v === 'string' ? v : isObj(v) || Array.isArray(v) ? JSON.stringify(v) : String(v))
/** same_value:数按数比(数字文本也算数),布尔按 Python 的规矩(True == 1),其余压成一行按文本比 */
function sameValue(model, truth) {
if (model == null || truth == null) return model == null && truth == null
const m = cellValue(model)
const t = cellValue(truth)
if (typeof m === 'boolean' || typeof t === 'boolean') {
const num = (x) => (typeof x === 'boolean' ? Number(x) : typeof x === 'number' ? x : NaN)
return num(m) === num(t)
}
if (typeof m === 'number' && typeof t === 'number') return isClose(m, t)
return textOf(m).replace(/\s+/g, ' ').trim() === textOf(t).replace(/\s+/g, ' ').trim()
}
/** locate_cell:(那一格的原值, 列名)。列号换成列名;行是对象时按键取 */
function locateCell(table, row, column) {
if (!isObj(table) || !Array.isArray(table.rows)) throw new Error('快照里没有表格数据')
const rows = table.rows
if (!Number.isInteger(row)) throw new Error(`行号需为从 0 开始的整数,当前为「${row}」`)
if (!rows.length) throw new Error('查询结果为空(0 行)')
if (row < 0 || row >= rows.length) throw new Error(`请求第 ${row} 行,但查询结果只有 ${rows.length} 行(行号从 0 开始)`)
const record = rows[row]
let columns = (Array.isArray(table.columns) ? table.columns : []).map(String)
if (isObj(record) && !columns.length) columns = Object.keys(record)
let name = column
if (Number.isInteger(column)) {
if (column < 0 || column >= columns.length) throw new Error(`请求第 ${column} 列,但查询结果只有 ${columns.length} 列`)
name = columns[column]
} else if (typeof column !== 'string') {
throw new Error(`列需填写列名或从 0 开始的列号,当前为「${column}」`)
}
if (isObj(record)) {
if (!Object.prototype.hasOwnProperty.call(record, name)) throw new Error(`没有列「${name}」`)
return [record[name], name]
}
const index = columns.indexOf(name)
if (index < 0) throw new Error(`没有列「${name}」`)
if (!Array.isArray(record) || index >= record.length) throw new Error(`第 ${row} 行中没有「${name}」对应的单元格`)
return [record[index], name]
}
function columnKind(table, column) {
const kind = own(own(table, 'column_types'), column)
return typeof kind === 'string' ? kind : null
}
// ------------------------------------------------------------------ 核对
function push(map, key, value) {
if (!map.has(key)) map.set(key, [])
map.get(key).push(value)
}
/** 内容里出现过的工件 id(64 位十六进制的字符串),不论在哪一层 */
function collectIds(value, found) {
if (typeof value === 'string') {
if (/^[0-9a-f]{64}$/.test(value)) found.add(value)
} else if (Array.isArray(value)) {
for (const v of value) collectIds(v, found)
} else if (isObj(value)) {
for (const v of Object.values(value)) collectIds(v, found)
}
return found
}
function* segmentsOf(doc) {
for (const block of Array.isArray(doc.blocks) ? doc.blocks : []) {
for (const unit of isObj(block) && Array.isArray(block.units) ? block.units : []) {
for (const seg of isObj(unit) && Array.isArray(unit.segments) ? unit.segments : []) {
if (isObj(seg)) yield seg
}
}
}
}
/** shownSeal:页面顶部直接写出的封存哈希(服务端渲染进模板,不执行脚本也看得到的那一份) */
async function verify(data, digest, shownSeal) {
const out = {
events: [], manifest: [], artifacts: [], chain: [], numbers: [], docs: [], meta: [], checked: new Map(), order: [],
}
const events = Array.isArray(data.events) ? data.events.filter(isObj) : []
const meta = data.meta
// ① 带规范字符串的事件
for (const ev of events) {
if (typeof ev.canonical !== 'string') continue
const item = { ev, result: FAIL, reason: '' }
if ((await digest(utf8(ev.canonical))) !== ev.data_sha256) {
item.reason = T.eventMismatch
} else {
try {
out.checked.set(ev, parseJson(ev.canonical))
item.result = PASS
} catch (e) {
item.reason = T.eventNotJson
}
}
out.events.push(item)
}
// ② 封存清单:seq 相同的几条按文件里的先后(服务端按数据库的顺序给,和封存时一样)
const order = events.map((ev, i) => [ev, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1]).map(([ev]) => ev)
out.order = order
const lines = order.map((ev) => `${ev.seq}|${ev.type}|${ev.node_id == null ? '' : ev.node_id}|${ev.data_sha256}`)
const computed = await digest(utf8(lines.join('\n')))
const beyond = order.find((ev) => !(ev.seq <= meta.manifest_seq))
const last = order[order.length - 1]
const manifest = { kind: 'computed', computed, count: lines.length, lines, result: FAIL, reason: '' }
if (computed !== meta.manifest_hash) manifest.reason = T.manifestMismatch
else if (beyond) manifest.reason = T.beyondSeal(beyond.seq)
// 新运行封存时记下的就是当时最后一条事件的序号(runner._seal);早期运行的范围是推算的,不一定落在某条事件上
else if (!meta.legacy && last && last.seq !== meta.manifest_seq) manifest.reason = T.rangeMismatch(meta.manifest_seq, last.seq)
else {
manifest.result = PASS
manifest.reason = T.manifestOk
}
out.manifest.push(manifest)
// 页面顶部写出的那一份要和核对用的一致:不然改过事件、重算了数据里的哈希,顶上却留着原来那串,读的人拿它去系统
// 里对照会对得上。脚本执行后顶上换成核对用的这一份
const seal = String(meta.manifest_hash ?? '')
out.manifest.push({ kind: 'shown', shown: shownSeal, result: shownSeal === seal ? PASS : FAIL,
reason: shownSeal === seal ? T.shownOk : T.shownMismatch })
// ③ 工件。服务端给每份工件的是三样之一:原字节、遮罩(查询快照、口径卡,可另带 mismatch、unparsable)、服务端已没有文件
const arts = new Map()
for (const a of Array.isArray(data.artifacts) ? data.artifacts.filter(isObj) : []) {
const item = { a, result: NA, reason: '', json: null, loose: null, parsed: false }
if (typeof a.b64 === 'string') {
let bytes = null
try {
bytes = fromBase64(a.b64)
} catch (e) {
item.result = FAIL
item.reason = T.artifactUndecodable
}
if (bytes) {
const intact = (await digest(bytes)) === a.id
try {
// 对不上的也解析出来(loose):被改过的报告仍显示正文,醒目地标着不可信
item.loose = parseJson(decoder.decode(bytes))
item.parsed = true
} catch (e) {
item.loose = null
}
if (!intact) {
item.result = FAIL
item.reason = T.artifactMismatch
} else if (!item.parsed) {
// 哈希对得上,就是系统里存的那一份:读不懂不是被改过,照实说核对不了
item.reason = T.artifactUnparsable
} else {
item.result = PASS
item.json = item.loose
}
}
} else if (a.masked) {
// 查询快照带遮罩列;口径卡上有输入取自遮罩列(卡上记着输入的值和代入式)。两种都只给 id,报告文档不会。
// 内容不随文件附带,浏览器算不出它对不对得上:导出时服务端看到自己的文件和 id 对不上(mismatch)、解析不出来
// (unparsable)的照实说,对不上的判不通过——不然被改过的文件看起来和正常的遮罩一样,篡改的证据就丢了
if (a.kind !== 'query' && a.kind !== 'metric_set') {
item.result = FAIL
item.reason = T.maskedOdd
} else if (a.mismatch) {
item.result = FAIL
item.reason = T.maskedMismatch
} else {
item.reason = a.unparsable ? T.maskedUnparsable : T.masked
}
} else if (a.missing) {
item.reason = T.artifactMissing
} else {
item.result = FAIL
item.reason = T.artifactAbsent
}
arts.set(a.id, item)
out.artifacts.push(item)
}
// ④ 引用链
const byEvent = new Map() // 工件 id → 引用它的已核对事件
for (const [ev, body] of out.checked) for (const id of collectIds(body, new Set())) push(byEvent, id, ev)
// 每个报告节点认最后一次记下了文档的自查(和服务端、证据接口同一个认法)。内容没核对通过的那条
// 不知道记没记文档,也算上:宁可报不通过,也不能跳过它去认一份更早的
const lastChecked = new Map()
for (const ev of order) {
if (ev.type !== 'report.checked' || !ev.node_id) continue
const body = out.checked.get(ev)
if (body !== undefined && !own(body, 'doc_artifact')) continue
lastChecked.set(ev.node_id, ev)
}
const reports = Array.isArray(data.reports) ? data.reports.filter(isObj) : []
const trusted = new Map() // 报告 → 已核对的文档
const chained = new Set()
for (const report of reports) {
const art = arts.get(report.doc_artifact)
const ev = lastChecked.get(report.node_id)
const link = { kind: 'report_doc', report, art, ev, result: FAIL, reason: '' }
if (!ev) link.reason = T.chainNoCheckEvent
else if (!out.checked.has(ev)) link.reason = T.chainCheckEventFailed
else if (own(out.checked.get(ev), 'doc_artifact') !== report.doc_artifact) link.reason = T.chainOtherDoc
else if (!art) link.reason = T.chainDocNotListed
else if (art.result === FAIL) Object.assign(link, { result: NA, reason: T.chainDocUnverified })
else if (art.result !== PASS) {
Object.assign(link, { result: NA, reason: art.a.missing ? T.chainDocMissing : T.chainDocUnparsable })
} else if (own(art.json, 'run_id') !== meta.run_id || own(art.json, 'node_id') !== report.node_id) {
link.reason = T.chainDocElsewhere
} else link.result = PASS
out.chain.push(link)
if (link.result === PASS) {
trusted.set(report, art.json)
chained.add(report.doc_artifact)
}
}
// 运行记录里自查过、文件却没附带的报告:不能悄悄少一份
for (const [nodeId, ev] of lastChecked) {
if (!reports.some((r) => r.node_id === nodeId)) {
const body = out.checked.get(ev)
out.chain.push({ kind: 'report_doc', report: { node_id: nodeId, doc_artifact: own(body, 'doc_artifact') },
art: null, ev, result: FAIL, reason: body === undefined ? T.chainCheckEventFailed : T.chainDocLeftOut })
}
}
const byDoc = new Map() // 工件 id → [{ report, alias }]
for (const [report, doc] of trusted) {
const catalog = isObj(doc.catalog) ? doc.catalog : {}
for (const alias of Object.keys(catalog)) {
const entry = catalog[alias]
if (isObj(entry) && typeof entry.artifact === 'string') push(byDoc, entry.artifact, { report, alias })
}
}
for (const item of out.artifacts) {
if (item.a.kind === 'report_doc') continue
const evs = byEvent.get(item.a.id) || []
const docs = byDoc.get(item.a.id) || []
const ok = evs.length > 0 || docs.length > 0
out.chain.push({ kind: item.a.kind, art: item, events: evs, docs, result: ok ? PASS : FAIL,
reason: ok ? '' : T.chainUnreferenced })
if (ok) chained.add(item.a.id)
}
// ⑤ 报告里的数字和取值
for (const report of reports) {
const art = arts.get(report.doc_artifact)
const link = out.chain.find((c) => c.kind === 'report_doc' && c.report === report)
const doc = trusted.get(report) || (art && art.parsed && isObj(art.loose) ? art.loose : null)
out.docs.push({ report, art, link, doc, trusted: trusted.has(report) })
if (!doc) continue
for (const seg of segmentsOf(doc)) {
if (seg.kind !== 'number' && seg.kind !== 'value') continue
const check = trusted.has(report) ? checkNumber(seg, doc, arts, chained)
: { result: NA, reason: T.numDocUntrusted, source: describe(seg, doc) }
out.numbers.push({ ...check, seg, report, key: `${report.node_id}|${seg.id}` })
}
}
// ⑥ 运行信息
out.meta = checkMeta(meta, out)
return out
}
/** 片段的出处说一句(核对前就能说的那部分) */
function describe(seg, doc) {
const cite = isObj(seg.cite) ? seg.cite : null
if (!cite) return T.srcNone
const entry = own(doc.catalog, cite.alias)
const loc = isObj(cite.locator) ? cite.locator : {}
if (cite.kind === 'metric') return T.srcMetric(own(entry, 'name') || loc.metric || cite.ref || '')
if (cite.kind === 'cell') {
return Number.isInteger(loc.row) ? T.srcCell(cite.alias, loc.row, String(loc.column ?? '')) : T.srcQuery(cite.alias)
}
if (cite.kind === 'input') return T.srcInput(loc.field || cite.ref || '')
return T.srcOther
}
function checkNumber(seg, doc, arts, chained) {
const cite = isObj(seg.cite) ? seg.cite : null
const source = describe(seg, doc)
if (!cite) return { result: NA, reason: T.numUncited, source }
if (cite.status !== 'resolved') return { result: NA, reason: T.numUnresolved(cite.reason), source }
if (cite.kind === 'input') return { result: NA, reason: T.numInput, source }
if (cite.kind !== 'metric' && cite.kind !== 'cell') return { result: NA, reason: T.numOther, source }
const entry = own(doc.catalog, cite.alias)
const info = { source, kind: cite.kind, cite }
const wanted = cite.kind === 'metric' ? 'metric' : 'query'
if (!isObj(entry) || entry.kind !== wanted || typeof entry.artifact !== 'string') {
return { ...info, result: FAIL, reason: T.numNoEntry }
}
info.artifact = entry.artifact
info.node = entry.node_id
const art = arts.get(entry.artifact)
// 服务端把已核对的文档引用到的口径卡、快照逐个列进清单(拿不到内容的也列,标明原因):不在清单里只能是被拿掉了
if (!art) return { ...info, result: FAIL, reason: T.numArtifactNotListed }
if (art.result === FAIL) return { ...info, result: NA, reason: T.numArtifactFailed }
if (art.result !== PASS) {
return { ...info, result: NA, reason: art.a.masked ? art.reason : art.a.missing ? T.artifactMissing : T.numArtifactUnparsable }
}
if (!chained.has(entry.artifact)) return { ...info, result: NA, reason: T.numNotChained }
if (cite.kind === 'metric') {
const metricId = own(cite.locator, 'metric') ?? own(entry.locator, 'metric')
const metrics = Array.isArray(art.json.metrics) ? art.json.metrics : []
const metric = metrics.find((m) => isObj(m) && m.id === metricId)
info.card = art.json
if (!metric) return { ...info, result: FAIL, reason: T.numNoMetric }
info.truth = metric.value
return sameValue(cite.value, metric.value) ? { ...info, result: PASS } : { ...info, result: FAIL, reason: T.numMetricDiffers }
}
const loc = isObj(cite.locator) ? cite.locator : {}
info.snapshot = art.json
try {
const [raw, column] = locateCell(art.json, loc.row, loc.column)
info.row = loc.row
info.column = column
info.truth = cellValue(raw, columnKind(art.json, column))
} catch (e) {
return { ...info, result: FAIL, reason: T.numNoCell(e.message) }
}
return sameValue(cite.value, info.truth) ? { ...info, result: PASS } : { ...info, result: FAIL, reason: T.numCellDiffers }
}
/**
* ⑥ 运行信息:运行结果、运行类别、出具档位不信元信息,拿封存范围内已核对的事件对照,页面上显示的也是事件里的。
* 事件的类型和序号由 ② 的清单绑定,清单没通过就无从对照。每项 { field, shown(显示的值), claim(元信息写的), result, reason }
*/
function checkMeta(meta, out) {
const items = []
const add = (field, shown, claim, result, reason) => items.push({ field, shown, claim, result, reason })
const order = out.order
const sealOk = out.manifest[0].result === PASS
const label = (map, value) => map[value] || (value ? String(value) : '—')
const what = (ev) => EVENT_LABEL[ev.type] || ev.type
// 运行结果:按封存范围内真正截止的那条结束类事件核对——它之后再没有更晚的事件。新运行在结束事件落库之后马上封存,
// 它就是范围内的最后一条;早期运行在 run.finished 落库之前封存,成功结束的那条在范围外,失败、取消的那条在范围的最后。
// 先失败、经继续运行接着跑的,范围里那条运行失败之后运行又继续了,它不是运行的结束:拿它去对,元信息改成「已失败」
// 也会被标成已核对。真正截止的那条不在范围里时,只有早期运行成功结束说得通(无法核对),别的说法都没有事件印证
const statusClaim = label(STATUS_LABEL, meta.status)
const lastSeq = order.length ? order[order.length - 1].seq : null
const final = [...order].reverse().find((ev) => TERMINAL[ev.type])
// 同一序号的几条(老数据里有)都算最后:结束事件和别的事件撞了序号,不能因此说它之后运行又继续了
const end = final && final.seq === lastSeq ? final : null
if (!sealOk) add('status', statusClaim, statusClaim, NA, T.metaNeedsManifest)
else if (end) {
const ok = TERMINAL[end.type] === meta.status
add('status', label(STATUS_LABEL, TERMINAL[end.type]), statusClaim, ok ? PASS : FAIL,
ok ? T.statusOk(end.seq, what(end)) : T.statusMismatch(statusClaim, end.seq, what(end)))
} else if (meta.legacy && meta.status === 'succeeded') add('status', statusClaim, statusClaim, NA, T.statusLegacy)
// 显示的值写「—」:范围里没有说明运行怎样结束的事件,不能把文件里写的当成核对出来的
else if (final) add('status', '—', statusClaim, FAIL, T.statusContinued(statusClaim, final.seq, what(final)))
else add('status', statusClaim, statusClaim, NA, T.statusNoEvent)
// 运行类别:运行开始事件记下的工具信任档。正式运行记 formal,探索运行记各工具的档位(一个对象),升级前的运行没有
const classClaim = label(CLASS_LABEL, meta.run_class)
const started = order.find((ev) => ev.type === 'run.started')
if (!sealOk) add('class', classClaim, classClaim, NA, T.metaNeedsManifest)
else if (!started) add('class', classClaim, classClaim, NA, T.classNoEvent)
else if (typeof started.canonical !== 'string') add('class', classClaim, classClaim, FAIL, T.classAbsent(started.seq))
else if (!out.checked.has(started)) add('class', classClaim, classClaim, NA, T.classUnchecked(started.seq))
else {
const trust = own(out.checked.get(started), 'tool_trust')
const expected = trust === 'formal' ? 'formal' : isObj(trust) ? 'exploratory' : null
if (!expected) add('class', classClaim, classClaim, NA, T.classLegacy(started.seq))
else {
const ok = expected === meta.run_class
add('class', label(CLASS_LABEL, expected), classClaim, ok ? PASS : FAIL,
ok ? T.classOk(started.seq) : T.classMismatch(classClaim, started.seq))
}
}
// 出具档位:封存范围内最后一条记下了档位的出具判定(和服务端 build._tier 同一个认法),还要是元信息指的那一条
const tierClaim = isObj(meta.tier) ? label(TIER_LABEL, meta.tier.value) : '—'
let found = null
for (const ev of [...order].reverse()) {
if (ev.type !== 'issuance') continue
if (typeof ev.canonical !== 'string') found = { result: FAIL, reason: T.tierAbsent(ev.seq) }
else if (!out.checked.has(ev)) found = { result: NA, reason: T.tierUnchecked(ev.seq) }
else if (typeof own(out.checked.get(ev), 'tier') === 'string') found = { ev, value: out.checked.get(ev).tier }
if (found) break
}
if (!sealOk) {
if (meta.tier != null || found) add('tier', tierClaim, tierClaim, NA, T.metaNeedsManifest)
} else if (!found) {
if (meta.tier != null) add('tier', tierClaim, tierClaim, FAIL, T.tierNoEvent(tierClaim))
} else if (!found.ev) add('tier', tierClaim, tierClaim, found.result, found.reason)
else {
const ok = isObj(meta.tier) && meta.tier.value === found.value && meta.tier.seq === found.ev.seq
add('tier', label(TIER_LABEL, found.value), tierClaim, ok ? PASS : FAIL,
ok ? T.tierOk(found.ev.seq) : T.tierMismatch(tierClaim, found.ev.seq))
}
return items
}
// ------------------------------------------------------------------ 渲染:工具
const $ = (id) => document.getElementById(id)
function h(tag, props, ...kids) {
const el = document.createElement(tag)
for (const [key, value] of Object.entries(props || {})) {
if (value == null || value === false) continue
if (key === 'class') el.className = value
else if (key.startsWith('on')) el.addEventListener(key.slice(2), value)
else el.setAttribute(key, value === true ? '' : String(value))
}
for (const kid of kids.flat(Infinity)) {
if (kid != null && kid !== false) el.append(kid instanceof Node ? kid : String(kid))
}
return el
}
const short = (id) => (typeof id === 'string' && id.length > 12 ? `${id.slice(0, 12)}…` : String(id ?? ''))
const badge = (result) => h('span', { class: 'badge', 'data-result': result }, `${MARK[result]} ${RESULT[result]}`)
const pad = (n) => String(n).padStart(2, '0')
/** 时间按读者所在时区显示,后面注明时区:文件会被转给别的时区的人 */
function when(iso) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
const offset = -d.getTimezoneOffset()
const zone = `UTC${offset < 0 ? '-' : '+'}${Math.floor(Math.abs(offset) / 60)}${Math.abs(offset) % 60 ? `:${pad(Math.abs(offset) % 60)}` : ''}`
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}(${zone})`
}
function valueText(v) {
if (v == null) return T.nullValue
if (typeof v === 'string') return `「${v}」`
return textOf(v)
}
function kv(rows) {
return h('dl', { class: 'kv' }, rows.filter(Boolean).map(([k, v]) => [h('dt', {}, k), h('dd', {}, v)]))
}
/** 一组结果汇总成一个:有不通过就是不通过;全通过、全无法核对照实;通过和无法核对都有是「部分无法核对」 */
function summarize(items) {
if (!items.length) return NA
if (items.some((i) => i.result === FAIL)) return FAIL
const pass = items.some((i) => i.result === PASS)
const na = items.some((i) => i.result === NA)
return pass && na ? PARTIAL : pass ? PASS : NA
}
/** 分项计数「通过 24 · 无法核对 3」,是零的不写 */
function tallyText(items) {
return [PASS, FAIL, NA].map((r) => [r, items.filter((i) => i.result === r).length])
.filter(([, n]) => n > 0).map(([r, n]) => `${RESULT[r]} ${n}`).join(' · ')
}
// ------------------------------------------------------------------ 渲染:报告正文
// 行内语法:代码、链接(只显示文字,不给链接:页面不连网)、粗体、斜体、删除线,和前端 run/Markdown.tsx 的规则一致。
// 斜体要求左边不是 *(否则 **粗体** 会被它先拆掉):前端用后行断言,这里不用(较早的 Safari 不认,构造正则时
// 就会报错,整页核对跟着失败),匹配到以后自己看左边那个字
const INLINE = '(`+)([^`]+?)\\1|\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)|\\*\\*([^*]+?)\\*\\*|__([^_]+?)__'
+ '|\\*([^*\\n]+?)\\*(?!\\*)|~~([^~]+?)~~'
const BIT = { code: 1, bold: 2, strike: 4, link: 8, em: 16 }
// 列表项的开头:行首的 -、*、+ 或者序号
const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/
/** 整句算一次行内样式:服务端把一句切成了好几个片段,粗体、代码可能跨片段,每个片段取自己那一截 */
function inlineStyles(text) {
const hide = new Uint8Array(text.length)
const style = new Uint8Array(text.length)
const walk = (from, to, inherited, depth) => {
const part = text.slice(from, to)
const re = new RegExp(INLINE, 'g')
let last = 0
for (let m = re.exec(part); m; m = re.exec(part)) {
const [whole, ticks, code, linkText, , bold1, bold2, italic, strike] = m
if (italic !== undefined && m.index > 0 && part[m.index - 1] === '*') {
// 左边紧挨着 *:不算斜体(前端后行断言排除的情形),从下一个字接着找
re.lastIndex = m.index + 1
continue
}
for (let i = last; i < m.index; i++) style[from + i] = inherited
const inner = code ?? linkText ?? bold1 ?? bold2 ?? italic ?? strike ?? ''
const base = from + m.index
const off = code !== undefined ? ticks.length : whole.indexOf(inner)
for (let i = 0; i < whole.length; i++) hide[base + i] = 1
for (let i = 0; i < inner.length; i++) hide[base + off + i] = 0
const bit = code !== undefined ? BIT.code : linkText !== undefined ? BIT.link
: bold1 !== undefined || bold2 !== undefined ? BIT.bold : italic !== undefined ? BIT.em : BIT.strike
if (code !== undefined || linkText !== undefined || depth >= 3) {
for (let i = 0; i < inner.length; i++) style[base + off + i] = inherited | bit
} else {
walk(base + off, base + off + inner.length, inherited | bit, depth + 1)
}
last = m.index + whole.length
}
for (let i = last; i < part.length; i++) style[from + i] = inherited
}
walk(0, text.length, 0, 0)
return { hide, style }
}
function styledSlice(text, styles, from, to) {
const frag = document.createDocumentFragment()
let i = from
while (i < to) {
if (styles.hide[i]) { i++; continue }
const st = styles.style[i]
let j = i + 1
while (j < to && !styles.hide[j] && styles.style[j] === st) j++
let node = document.createTextNode(text.slice(i, j))
if (st & BIT.code) node = h('code', {}, node)
if (st & BIT.em) node = h('em', {}, node)
if (st & BIT.bold) node = h('strong', {}, node)
if (st & BIT.strike) node = h('s', {}, node)
if (st & BIT.link) node = h('span', { class: 'link' }, node)
frag.append(node)
i = j
}
return frag
}
const unitText = (unit) => (Array.isArray(unit.segments) ? unit.segments : [])
.filter((s) => isObj(s) && s.kind !== 'structural').map((s) => String(s.text ?? '')).join('')
function renderUnit(unit, ctx) {
const segs = (Array.isArray(unit.segments) ? unit.segments : []).filter((s) => isObj(s) && s.kind !== 'structural')
const text = segs.map((s) => String(s.text ?? '')).join('')
const styles = inlineStyles(text)
const frag = document.createDocumentFragment()
let pos = 0
for (const seg of segs) {
const from = pos
pos += String(seg.text ?? '').length
let body = styledSlice(text, styles, from, pos)
if (seg.strong) body = h('strong', {}, body)
const number = ctx.numbers.get(`${ctx.report.node_id}|${seg.id}`)
frag.append(number ? numberButton(number, body) : h('span', {}, body))
}
return frag
}
const NUMERIC_CELL = /^[-+−]?[\d,]+(?:\.\d+)?%?[^\d\s]{0,4}$/
function renderBlock(block, ctx) {
const units = Array.isArray(block.units) ? block.units.filter(isObj) : []
const inline = (u) => renderUnit(u, ctx)
switch (block.type) {
case 'heading': {
const level = Math.min(Math.max(Number(block.level) || 2, 1), 3)
return h(`h${level + 3}`, { class: `doc-h doc-h${level}` }, units.map(inline))
}
case 'hr':
return h('hr')
case 'quote':
return h('blockquote', {}, units.map(inline))
case 'code':
return h('pre', {}, h('code', {}, units.map(unitText).join('')))
case 'list': {
const items = []
for (const u of units) {
const lead = Array.isArray(u.segments) ? u.segments[0] : null
const starts = !items.length || (isObj(lead) && lead.kind === 'structural' && ITEM_START.test(String(lead.text ?? '')))
if (starts) items.push({ depth: Number(u.depth) || 0, units: [u] })
else items[items.length - 1].units.push(u)
}
const list = h(block.ordered ? 'ol' : 'ul')
if (block.ordered && Number.isInteger(block.start) && block.start !== 1) list.setAttribute('start', String(block.start))
for (const item of items) {
const li = h('li', {}, item.units.map(inline))
if (item.depth) li.style.marginLeft = `${item.depth * 14}px`
list.append(li)
}
return list
}
case 'table': {
// 表头 row = -1;一格可能被分成几句,按位置归拢
const cells = new Map()
const rowIds = []
let cols = 0
for (const u of units) {
const loc = isObj(u.loc) ? u.loc : { row: 0, col: 0 }
push(cells, `${loc.row}:${loc.col}`, u)
cols = Math.max(cols, (Number(loc.col) || 0) + 1)
if (loc.row >= 0 && !rowIds.includes(loc.row)) rowIds.push(loc.row)
}
rowIds.sort((a, b) => a - b)
const textAt = (r, c) => (cells.get(`${r}:${c}`) || []).map(unitText).join('').replace(/[*`]/g, '').trim()
const numeric = Array.from({ length: cols }, (_, c) => {
const texts = rowIds.map((r) => textAt(r, c)).filter(Boolean)
return texts.length > 0 && texts.every((t) => NUMERIC_CELL.test(t))
})
const cell = (tag, r, c) => h(tag, { class: tag === 'td' && numeric[c] ? 'n' : null },
(cells.get(`${r}:${c}`) || []).map(inline))
return h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, Array.from({ length: cols }, (_, c) => cell('th', -1, c)))),
h('tbody', {}, rowIds.map((r) => h('tr', {}, Array.from({ length: cols }, (_, c) => cell('td', r, c)))))))
}
default:
return h('p', {}, units.map(inline))
}
}
function renderDoc(doc, ctx) {
const blocks = Array.isArray(doc.blocks) ? doc.blocks.filter(isObj) : []
if (!blocks.length) return h('div', { class: 'doc' }, h('pre', {}, String(doc.markdown ?? '')))
return h('div', { class: 'doc' }, blocks.map((b) => renderBlock(b, ctx)))
}
// ------------------------------------------------------------------ 渲染:数字的出处
const view = { data: null, out: null, active: null, back: null }
const nodeLabel = (id) => {
const node = own(view.data.nodes, id)
return isObj(node) && node.label ? String(node.label) : String(id ?? '—')
}
const eventWhat = (ev) => [EVENT_LABEL[ev.type] || ev.type, ev.node_id ? nodeLabel(ev.node_id) : null].filter(Boolean).join(' · ')
function numberButton(number, body) {
const kind = number.seg.kind === 'value' ? 'value' : 'number'
const btn = h('button', {
type: 'button', class: 'num', 'data-num': number.key, 'data-kind': kind, 'data-result': number.result,
'aria-label': T.numAria(kind, String(number.seg.text ?? ''), RESULT[number.result]),
onclick: () => openNumber(number),
}, body)
number.button = btn
return btn
}
function artifactState(id) {
const item = view.out.artifacts.find((a) => a.a.id === id)
if (!item) return ''
return [' ', badge(item.result), item.reason ? h('span', { class: 'muted' }, ` ${item.reason}`) : null]
}
function detailOf(number) {
const cite = isObj(number.seg.cite) ? number.seg.cite : null
const card = number.card
// 只有带出处的数字是服务端按引用的值渲染的;模型自己写的数、表格里的文字取值都是原文
const rendered = number.seg.kind === 'number' && cite && cite.status === 'resolved'
const rows = [
[T.dResult, [badge(number.result), number.reason ? ` ${number.reason}` : '']],
[T.dSource, number.source],
number.node ? [T.dNode, nodeLabel(number.node)] : null,
card ? [T.dCaliber, [card.caliber || '—', card.caliber_version ? ` ${card.caliber_version}` : ''].join('')] : null,
cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, valueText(cite.value)] : null,
'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, valueText(number.truth)] : null,
[T.dShown, [String(number.seg.text ?? ''), rendered ? h('span', { class: 'muted' }, `(${T.dShownNote})`) : null]],
number.artifact ? [T.dArtifact, [h('code', {}, short(number.artifact)), artifactState(number.artifact)]] : null,
[T.dReport, nodeLabel(number.report.node_id)],
]
const parts = [kv(rows)]
const snap = number.snapshot
if (snap && typeof snap.sql === 'string' && snap.sql) parts.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, snap.sql))
const record = snap && Number.isInteger(number.row) && Array.isArray(snap.rows) ? snap.rows[number.row] : undefined
if (Array.isArray(record) || isObj(record)) {
// 被引用的那一行:快照随文件附带(没有遮罩)才走到这里,整行照原样列出,引用的那一格高亮
let columns = (Array.isArray(snap.columns) ? snap.columns : []).map(String)
if (isObj(record) && !columns.length) columns = Object.keys(record)
const cellAt = (c, i) => (isObj(record) ? own(record, c) : record[i])
parts.push(h('p', { class: 'sub' }, T.dRow(number.row)), h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, columns.map((c) => h('th', { class: c === number.column ? 'hit' : null }, c)))),
h('tbody', {}, h('tr', {}, columns.map((c, i) => h('td', { class: c === number.column ? 'hit' : null },
cellAt(c, i) == null ? '—' : textOf(cellAt(c, i)))))))))
}
return parts
}
function openNumber(number) {
const panel = $('detail')
if (panel.hidden) view.back = document.activeElement
if (view.active) view.active.classList.remove('is-active')
view.active = number.button || null
if (view.active) view.active.classList.add('is-active')
$('detail-title').textContent = String(number.seg.text ?? '')
$('detail-body').replaceChildren(...detailOf(number))
panel.hidden = false
panel.setAttribute('data-open', number.key)
// 宽屏上正文给面板让出右边一栏(样式表 html.detail-open):版面变窄、换了行,点的数字可能挪了位置
document.documentElement.classList.add('detail-open')
$('detail-close').focus({ preventScroll: true })
if (!view.active) return
const sheet = panel.getBoundingClientRect()
const spot = view.active.getBoundingClientRect()
if (sheet.top > 0) {
// 窄屏上面板从底部抽出:点的数字被盖住时把它滚到面板上方
if (spot.bottom > sheet.top) window.scrollBy({ top: spot.top - Math.max(16, sheet.top / 3) })
} else {
// 宽屏:让开之后数字还在视野里(表格横向滚动时也滚到它)
view.active.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
}
function closeDetail() {
const panel = $('detail')
if (panel.hidden) return
panel.hidden = true
panel.removeAttribute('data-open')
document.documentElement.classList.remove('detail-open')
if (view.active) view.active.classList.remove('is-active')
if (view.back && typeof view.back.focus === 'function') view.back.focus()
view.active = null
}
// ------------------------------------------------------------------ 渲染:页面各块
function rowsOf(rows) {
return rows.filter(Boolean).flatMap(([k, v]) => [h('dt', {}, k), h('dd', {}, v)])
}
function renderHead(data, out) {
const meta = data.meta
$('title').textContent = String(meta.workflow_name || '')
// 顶上换成核对用的这一份(② 已经核过它和原来写的是否一致)
$('seal-hash').textContent = String(meta.manifest_hash || '')
for (const el of document.querySelectorAll('.run-id')) el.textContent = String(meta.run_id || '')
const checked = (field, fallback) => {
const item = out.meta.find((m) => m.field === field)
if (!item) return fallback
const note = item.result === PASS ? T.markPass : item.result === FAIL ? T.markFail(item.claim) : T.markNa
return h('span', { 'data-meta': field, 'data-result': item.result }, item.shown,
h('span', { class: item.result === FAIL ? 'bad' : 'muted' }, `(${note})`))
}
$('meta').replaceChildren(...rowsOf([
[T.metaRun, h('code', {}, String(meta.run_id || ''))],
[T.metaStatus, checked('status', '—')],
[T.metaClass, checked('class', '—')],
[T.metaTier, checked('tier', '—')],
[T.metaRange, `${T.range(meta.manifest_seq, out.order.length)}${meta.legacy ? T.rangeLegacy : ''}`],
[T.metaExported, when(meta.exported_at)],
]))
// 绑不进封存的几样单独一组,写明出处:改了它们,五项核对照样通过,去系统里对照封存哈希也发现不了
const note = $('record-note')
note.textContent = T.recordNote
note.hidden = false
$('record').replaceChildren(...rowsOf([
[T.metaWorkflow, String(meta.workflow_name || '—')],
meta.version != null ? [T.metaVersion, `v${meta.version}`] : null,
meta.version_hash ? [T.metaVersionHash, h('code', {}, String(meta.version_hash))] : null,
[T.metaStarted, when(meta.started_at)],
[T.metaFinished, when(meta.finished_at)],
]))
}
/** 报告文档没核对上的原因:{ key, text }。key 进 data-why,服务端已没有文件、读不懂的用中性色,不是红的 */
function docWhy(item) {
if (!item.art) return { key: 'absent', text: T.docAbsent }
if (item.art.result === FAIL || (item.link && item.link.result === FAIL)) return { key: 'untrusted', text: T.docUntrusted }
if (item.art.a.missing) return { key: 'missing', text: T.docMissing }
return { key: 'unreadable', text: T.docUnreadable }
}
function renderReports(out) {
const root = $('reports')
if (!out.docs.length) {
root.append(h('p', { class: 'note' }, T.noReports))
return
}
const numbers = new Map(out.numbers.map((n) => [n.key, n]))
for (const item of out.docs) {
const label = nodeLabel(item.report.node_id)
// 报告的徽标连同正文里的数字一起算:重算了全部哈希的伪造只在 ⑤ 露馅,标题旁不能还写着通过
const nums = out.numbers.filter((n) => n.report === item.report)
const docResult = [item.art, item.link].some((x) => x && x.result === FAIL) || !item.art ? FAIL : item.trusted ? PASS : NA
const result = summarize([{ result: docResult }, ...nums])
const box = h('article', { class: 'report', 'data-report': item.report.node_id, 'data-result': result },
h('header', { class: 'report-head' }, h('h3', {}, T.reportTitle(label)), badge(result),
h('span', { class: 'mono muted' }, short(item.report.doc_artifact)),
h('span', { class: 'report-parts', 'data-report-parts': '' },
T.reportParts(RESULT[docResult], nums.length ? tallyText(nums) : T.reportNoNumbers))))
if (!item.trusted) {
const why = docWhy(item)
box.append(h('p', { class: 'warn', 'data-why': why.key }, why.text))
}
if (item.doc) box.append(renderDoc(item.doc, { report: item.report, numbers }))
root.append(box)
}
}
function checkBlock(id, title, summary, items, line) {
const result = summarize(items)
const tally = items.length > 1 ? tallyText(items) : ''
const block = h('section', { class: 'check', 'data-check': id, 'data-result': result },
h('div', { class: 'check-head' }, badge(result), h('h3', {}, title),
h('span', { class: 'check-sum' }, tally ? `${summary} · ${tally}` : summary)))
const list = (rows) => h('ul', { class: 'items' }, rows.map((item) => {
const li = h('li', { 'data-result': item.result }, badge(item.result), h('span', { class: 'what' }, line(item)))
if (item.reason) li.append(h('span', { class: 'why' }, item.reason))
return li
}))
if (items.length <= FOLD_AT) {
block.append(list(items))
} else {
// 条目多(事件、数字常有几百条):不通过的和无法核对的列在外面(不通过的在前),没核到的最需要被看见;
// 全部的收起来
const open = [...items.filter((i) => i.result === FAIL), ...items.filter((i) => i.result === NA)]
if (open.length) block.append(list(open))
block.append(h('details', {}, h('summary', {}, T.showAll(items.length)), list(items)))
}
return block
}
function renderChecks(out) {
const root = $('checks')
root.append(checkBlock('events', T.checkEvents, T.sumEvents(out.events.length), out.events,
(i) => `${T.chainByEvent(i.ev.seq, eventWhat(i.ev))}`))
const computed = out.manifest[0]
const manifest = checkBlock('manifest', T.checkManifest, T.sumManifest(computed.count), out.manifest,
(m) => [m.kind === 'computed' ? T.manifestComputed : T.manifestShown, ' ', h('code', {}, m.kind === 'computed' ? m.computed : m.shown || '—')])
// 参与核对的原文照拼法列出来:读的人不信这一页的脚本,也能复制出去用别的工具算一遍
manifest.append(h('details', {}, h('summary', {}, T.manifestLines(computed.count)),
h('pre', { class: 'sql', 'data-manifest-lines': '' }, computed.lines.join('\n'))))
root.append(manifest)
root.append(checkBlock('artifacts', T.checkArtifacts, T.sumCount(out.artifacts.length, '份'), out.artifacts,
(i) => [KIND_LABEL[i.a.kind] || i.a.kind, i.a.node_id ? ` · ${nodeLabel(i.a.node_id)}` : '', ' · ',
h('code', {}, short(i.a.id))]))
root.append(checkBlock('chain', T.checkChain, T.sumCount(out.chain.length, '条'), out.chain, (c) => {
if (c.kind === 'report_doc') {
const from = c.ev ? ` ← ${T.chainByEvent(c.ev.seq, eventWhat(c.ev))}` : ''
return [T.chainDoc(nodeLabel(c.report.node_id)), ' ', h('code', {}, short(c.report.doc_artifact)), from]
}
const refs = [...c.docs.map((d) => T.chainByDoc(nodeLabel(d.report.node_id), d.alias)),
...c.events.map((ev) => T.chainByEvent(ev.seq, eventWhat(ev)))]
return [KIND_LABEL[c.kind] || c.kind, ' ', h('code', {}, short(c.art.a.id)),
refs.length ? ` ← ${T.chainFrom}${refs.join('、')}` : '']
}))
const values = out.numbers.filter((n) => n.seg.kind === 'value').length
root.append(checkBlock('numbers', T.checkNumbers, T.sumNumbers(out.numbers.length - values, values), out.numbers,
(n) => h('button', { type: 'button', 'data-num-item': n.key, onclick: () => {
if (n.button) n.button.scrollIntoView({ block: 'center' })
openNumber(n)
} }, `${String(n.seg.text ?? '')} · ${n.source}`)))
const metaLabel = { status: T.metaStatus, class: T.metaClass, tier: T.metaTier }
root.append(checkBlock('meta', T.checkMeta, T.sumMeta, out.meta, (m) => `${metaLabel[m.field]}:${m.shown}`))
}
function renderAppended(data) {
const later = Array.isArray(data.appended) ? data.appended.filter(isObj) : []
if (!later.length) return
const root = $('appended')
root.hidden = false
root.append(h('p', { class: 'note' }, T.appendedNote), h('ul', { class: 'items check' }, later.map((ev) =>
h('li', { 'data-appended': ev.seq }, h('span', { class: 'badge', 'data-result': NA }, T.appendedTag),
h('span', { class: 'what' }, `${T.chainByEvent(ev.seq, eventWhat(ev))}${ev.summary ? `:${ev.summary}` : ''}`)))))
}
function renderOverall(out) {
const all = [...out.events, ...out.manifest, ...out.artifacts, ...out.chain, ...out.numbers, ...out.meta]
const count = (r) => all.filter((i) => i.result === r).length
const [pass, fail, na] = [count(PASS), count(FAIL), count(NA)]
// 报告文档核对不了(导出时服务端已没有文件、读不懂):页面上没有可核的正文,不能写成绿色的「通过」
const unverified = out.docs.filter((d) => !d.trusted).length
const result = fail ? FAIL : unverified ? 'incomplete' : PASS
const box = $('overall')
box.setAttribute('data-overall', result)
box.querySelector('.overall-mark').textContent = fail ? MARK.fail : unverified ? MARK.na : MARK.pass
$('overall-text').textContent = fail ? T.failed(fail) : unverified ? T.incomplete(unverified) : na ? T.passWithNa : T.allPass
$('overall-sub').textContent = T.tally(pass, fail, na)
}
function renderFoot(data, kind) {
$('foot').replaceChildren(
h('p', { 'data-sha': kind }, kind === 'subtle' ? T.shaSubtle : T.shaJs),
h('p', {}, T.renderNote),
h('p', {}, T.schema(String(data.schema || ''))))
}
/** 下不了结论:总览写明原因。也标上核对结束(data-verified="fatal"),等着看结果的一方不必干等 */
function fatal(message) {
const box = $('overall')
box.setAttribute('data-overall', FAIL)
box.querySelector('.overall-mark').textContent = MARK.fail
$('overall-text').textContent = message
document.documentElement.setAttribute('data-verified', 'fatal')
}
async function main() {
let data
try {
data = JSON.parse($('bundle-data').textContent)
if (!isObj(data) || !isObj(data.meta) || !Array.isArray(data.events)) throw new Error('bad data')
} catch (e) {
fatal(T.broken)
return
}
view.data = data
const picked = await pickDigest()
if (!picked) {
fatal(T.selfTestFailed)
return
}
view.out = await verify(data, picked.digest, $('seal-hash').textContent.trim())
renderHead(data, view.out)
renderReports(view.out)
renderChecks(view.out)
renderAppended(data)
renderFoot(data, picked.kind)
renderOverall(view.out)
$('detail-close').addEventListener('click', closeDetail)
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail() })
document.documentElement.setAttribute('data-verified', '')
}
// 脚本在 head 里:一开始执行就标上 data-js,样式表据此把「尚未核对」换成「正在核对」;文档解析完再核对
document.documentElement.setAttribute('data-js', '')
const start = () => main().catch((e) => {
console.error(e)
fatal(T.broken)
})
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true })
else start()
})()