/* * 可验证报告的浏览器端核对:原生 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);正文显示的字等于按服务端的显示规则(engine/evidence.py 的 * render_number / render_cell:千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)把那个值渲染出来的字, * 两边都只去掉首尾空白。本页渲染不出来的写法判无法核对,不判通过。运行输入不随文件附带,照旧无法核对,但显示的字 * 照样拿引用的值核对,对不上判不通过。核对通过的字要原样显示在正文里(checkLayout):正文里根本不显示的判不通过, * 紧挨着还有数字、负号或显示成什么说不准的字,或者同一句里有改变文字方向的字,读起来可能不是这个值的判无法核对; * 列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数; * ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称 * 绑不进封存,页面上写明它们取自运行记录。 * 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、报告文档标着遮罩) * 一律算不通过:只可能是文件被改过。这些只证明内部一致,真伪要拿封存哈希去系统里对照,页面上写明。 * * 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: '哈希一致,但内容无法解析', /** 查询快照只给哈希:服务端证明不了这条查询没碰设了遮罩的列(没碰的照常随文件附带) */ maskedQuery: '这条查询用到或可能用到设了遮罩的列,快照未随文件附带,只能核对到哈希', /** 口径卡只给哈希:卡上有输入取自遮罩列(卡上记着输入的值和代入式) */ 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: '引用的值与快照里那一格的值不一致', /** cited 已经加好引号(空值写「空值(NULL)」);expected 和引用的值写法一样时不写(文字取值) */ numShownDiffers: (shown, cited, expected) => `正文显示「${shown}」,引用的值是${cited}` + (expected == null ? '' : `,按显示规则应显示为「${expected}」`), numShownUnknown: (why) => `无法核对显示文字:${why}`, shownFormat: (fmt) => `显示格式「${fmt}」无法识别`, shownConv: (conv) => `换算「${conv}」无法识别`, shownTextConv: (conv) => `文字不能换算为「${conv}」`, shownRatioScale: (conv) => `比率不能换算为「${conv}」`, shownPctUnit: '单位已是百分号,不能再换算为百分数', shownDecimals: (d) => `小数位「${d}」无法识别`, shownZero: '按显示规则会显示为 0', shownTooLong: '数值位数超出可显示的范围', shownNotFinite: '不是有限的数值', shownInexact: '数值位数过多,本页无法精确核对', shownUnit: '口径卡记下的单位不是文字', shownShape: '这个值是结构化数据,本页无法按系统的显示规则核对', shownDigits: '数字里有全角或其他文字的数字,本页无法按系统的显示规则核对', shownDisplay: (v) => `报告文档记下的单元格显示规则版本「${v}」无法识别`, shownOld: '这个浏览器版本较旧,请用新版浏览器打开本文件', shownBroken: '本页按系统的显示规则处理这个值时出错', /** 正文的排法(checkLayout):显示文字核对过了,读起来却不是这个值 */ shownGlued: '紧挨着的正文里还有数字或负号,读起来和核对过的不是同一个值', shownBlurred: '紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值', shownTurned: '这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样', numNotShown: '正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过', 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: '正文显示', dShownSame: '与按显示规则渲染的结果一致', dExpected: '按显示规则应为', 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, 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, index] } function columnKind(table, column) { const kind = own(own(table, 'column_types'), column) return typeof kind === 'string' ? kind : null } // ------------------------------------------------------------------ 显示规则:照搬 engine/evidence.py // // 正文里带出处的数字和取值,字是服务端按引用的值渲染的(render_number / render_cell)。这里按同样的规则把口径卡、快照里 // 的值再渲染一遍,和正文显示的字逐字比对:只改显示文字、重算全部哈希的伪造(74,613.65元 改成 84,613.65元、华南改成 // 华东)在这里露馅。数照 Python 的 Decimal 算:系数用 BigInt 存,位数照原样记,四舍五入是 ROUND_HALF_UP(一半远离 0); // 浮点取 repr 的那串字(和 JS 的 String 同一个最短写法),整数取 JSON 原文(超过 2^53 的,JSON.parse 读成 double 会丢位)。 // 服务端渲染不出来的(引用就算解析不了,正常导出不会出现)和本页没有移植的写法一律抛 Unshown:核对结果是「无法核对」, // 不是通过 /** 显示文字核对不了:不认识的格式、本页没有移植的写法 */ class Unshown extends Error {} const FORMATS = ['plain', 'thousands', 'percent_of_ratio'] const MISSING = '—' /** 换算成万、亿:小数点左移几位 */ const CONV_SCALE = { '万': 4, '亿': 8 } const CONV_PLACES = /^\.([0-6])$/ const MAX_PLACES = 100 const MAX_DIGITS = 1000 /** Python 的 int() 最多认这么多位:更长的整数文本 cell_value 照文本 */ const INT_DIGITS = 4300 /** Python 的空白(str.isspace(),re 的 \s 同一套):比 JS 的 \s 多 \x1c–\x1f、\x85,少 U+FEFF */ const PY_SPACE = '\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000' const PY_RUNS = new RegExp(`[${PY_SPACE}]+`, 'g') const PY_EDGES = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'g') // 较早的浏览器没有 BigInt、不认 \p{…}:只是显示文字核对不了(判无法核对),整段脚本不能因此出错,别的照常核对 const BIG = typeof BigInt === 'function' ? BigInt : null const unicode = (source) => { try { return new RegExp(source, 'u') } catch (e) { return null } } // Python 的 \d 认所有 Unicode 十进制数字:_NUMERIC_TEXT、_ISO_DATETIME 照这个认 const PY_NUMERIC = unicode(`^[${PY_SPACE}]*-?(?:0|[1-9]\\p{Nd}*)(?:\\.\\p{Nd}+)?(?:E[+-]?\\p{Nd}+)?[${PY_SPACE}]*$`) const ISO_DATETIME = unicode('^(\\p{Nd}{4}-\\p{Nd}{2}-\\p{Nd}{2})T(\\p{Nd}{2}:\\p{Nd}{2}(?::\\p{Nd}{2}(?:\\.\\p{Nd}+)?)?' + '(?:Z|[+-]\\p{Nd}{2}:?\\p{Nd}{2})?)$') const pyStrip = (text) => text.replace(PY_EDGES, '') /** _clean:文本值压成一行 */ const pyClean = (text) => pyStrip(text.replace(PY_RUNS, ' ')) /** 十进制数 { neg, coef, exp }:(−1)^neg × coef × 10^exp,coef 是非负的 BigInt。text 是 JSON 的数、数字文本或 String(数) */ function decimalOf(text) { if (!BIG) throw new Unshown(T.shownOld) const m = /^(-?)(\d+)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text) if (!m) throw new Unshown(T.shownBroken) const frac = m[3] || '' return { neg: m[1] === '-', coef: BIG(m[2] + frac), exp: Number(m[4] || 0) - frac.length } } const isZero = (d) => d.coef === BIG(0) const width = (coef) => coef.toString().length /** Decimal.adjusted():最高位的指数 */ const adjusted = (d) => (isZero(d) ? d.exp : width(d.coef) - 1 + d.exp) /** Decimal.quantize:舍入到 10^exp 这一位,ROUND_HALF_UP(一半远离 0) */ function quantize(d, exp) { if (d.exp >= exp) return { neg: d.neg, coef: d.coef * BIG(10) ** BIG(d.exp - exp), exp } const shift = exp - d.exp // 要舍掉的位数比系数还多:不到一半,舍成 0(不必真去算 10 的几万次方) if (shift > width(d.coef)) return { neg: d.neg, coef: BIG(0), exp } const unit = BIG(10) ** BIG(shift) const up = (d.coef % unit) * BIG(2) >= unit ? BIG(1) : BIG(0) return { neg: d.neg, coef: d.coef / unit + up, exp } } /** Decimal.normalize:去掉尾零,0 写成 0E0 */ function normalize(d) { if (isZero(d)) return { neg: d.neg, coef: d.coef, exp: 0 } let { coef, exp } = d while (coef % BIG(10) === BIG(0)) { coef /= BIG(10) exp++ } return { neg: d.neg, coef, exp } } /** format(Decimal, ',f'):定点写法,group 时整数部分千分位。0 不带负号(服务端 copy_abs),正指数的 0 写成「0」 */ function fixed(d, group) { const exp = isZero(d) && d.exp > 0 ? 0 : d.exp let digits = d.coef.toString() let int = digits let frac = '' if (exp > 0) int = digits + '0'.repeat(exp) else if (exp < 0) { digits = digits.padStart(1 - exp, '0') int = digits.slice(0, digits.length + exp) frac = digits.slice(digits.length + exp) } if (group) { let out = '' for (let end = int.length; end > 0; end -= 3) out = int.slice(Math.max(0, end - 3), end) + (out ? `,${out}` : '') int = out } return (d.neg && !isZero(d) ? '-' : '') + int + (frac ? `.${frac}` : '') } /** _digits:按小数位四舍五入后写出来。不是 0 的值写出来成了 0,服务端不写(引用算解析不了) */ function digitsOf(d, places, group, strip) { const q = quantize(d, places == null ? -10 : -places) if (isZero(q) && !isZero(d)) throw new Unshown(T.shownZero) return fixed(places == null || strip ? normalize(q) : q, group) } const shifted = (d, k) => ({ neg: d.neg, coef: d.coef, exp: d.exp + k }) /** _render_numeric:万 / 亿留两位、去尾零,单位接在后面;pct、比率乘 100 加 %(不接单位);int、.N 改小数位 */ function renderNumeric(d, unit, decimals, fmt, conv) { if (adjusted(d) >= MAX_DIGITS) throw new Unshown(T.shownTooLong) const ratio = fmt === 'percent_of_ratio' const group = fmt !== 'plain' if (typeof conv === 'string' && Object.prototype.hasOwnProperty.call(CONV_SCALE, conv)) { if (ratio) throw new Unshown(T.shownRatioScale(conv)) return digitsOf(shifted(d, -CONV_SCALE[conv]), 2, true, true) + conv + unit } if (conv === 'pct') { if (unit.includes('%') || unit.includes('%')) throw new Unshown(T.shownPctUnit) return `${digitsOf(shifted(d, 2), decimals != null ? Math.max(decimals - 2, 0) : 1, group, false)}%` } let places if (conv === 'int') places = 0 else if (typeof conv === 'string' && CONV_PLACES.test(conv)) places = Number(conv.slice(1)) else if (conv) throw new Unshown(T.shownConv(conv)) else places = decimals == null ? null : ratio ? Math.max(decimals - 2, 0) : decimals if (ratio) return `${digitsOf(shifted(d, 2), places, group, false)}%` return digitsOf(d, places, group, false) + unit } /** _decimal:JSON 里的数 → 十进制数。token 是它在 JSON 里的原文:整数按原文取精确值,浮点按 repr 的那串字 */ function pyDecimal(value, token) { if (typeof token === 'string' && /^-?\d+$/.test(token)) return decimalOf(token) if (!Number.isFinite(value)) throw new Unshown(T.shownNotFinite) // 没有原文(找不到它在文件里的位置):浮点和 2^53 以内的整数,String 就是 Python 的写法;更大的整数不知道原来的位 if (typeof token !== 'string' && Number.isInteger(value) && !Number.isSafeInteger(value)) { throw new Unshown(T.shownInexact) } return decimalOf(String(value)) } /** * render_number:口径卡里的指标按卡上的格式渲染(单元格也走这里,格式是千分位)。token 是值在 JSON 里的原文(数才有), * decimalsToken 是小数位的原文:写成 2.0 的在 Python 里是浮点,服务端不认 */ function renderNumber(value, token, { unit = '', decimals = null, decimalsToken = null, fmt = null, conv = null } = {}) { const format = fmt || 'thousands' if (!FORMATS.includes(format)) throw new Unshown(T.shownFormat(format)) if (value == null) return MISSING if (typeof value !== 'number') { if (conv) throw new Unshown(T.shownTextConv(conv)) if (typeof value === 'boolean') return value ? '是' : '否' if (typeof value === 'string') return pyClean(value) throw new Unshown(T.shownShape) } if (decimals != null && (typeof decimals !== 'number' || !Number.isInteger(decimals) || Math.abs(decimals) > MAX_PLACES || (typeof decimalsToken === 'string' && !/^-?\d+$/.test(decimalsToken)))) { throw new Unshown(T.shownDecimals(decimalsToken ?? decimals)) } return renderNumeric(pyDecimal(value, token), unit, decimals, format, conv) } /** * render_cell(shown(raw)):快照里的一格。kind 是查询时记下的列类型,display 是报告文档记下的单元格显示规则版本 * (2 版起 ISO 日期时间的 T 换成空格)。DECIMAL 落成的数字文本按数渲染、不经 float;文字压成一行,竖线换成 ¦ */ function renderCell(raw, token, conv, kind, display) { if (display !== 1 && display !== 2) throw new Unshown(T.shownDisplay(display)) if (!PY_NUMERIC || !ISO_DATETIME) throw new Unshown(T.shownOld) let value = raw if (display >= 2 && typeof raw === 'string') { const m = ISO_DATETIME.exec(pyStrip(raw)) if (m) value = `${m[1]} ${m[2]}` } if (typeof value === 'string' && kind !== 'text' && PY_NUMERIC.test(value)) { const text = pyStrip(value) // Python 把全角、阿拉伯文这些数字也当数(int('1٢٣') 是 123),本页只照 ASCII 算 if (/[^\x00-\x7f]/.test(text)) throw new Unshown(T.shownDigits) if (!/^-?\d+$/.test(text) || text.replace('-', '').length <= INT_DIGITS) { return renderNumeric(decimalOf(text), '', null, 'thousands', conv) } } // 对象、列表:服务端按 Python 的 json.dumps 写(键的先后、浮点的写法),本页没有移植 if (isObj(value) || Array.isArray(value)) throw new Unshown(T.shownShape) return renderNumber(value, token, { conv }).replace(/\|/g, '¦') } /** * JSON 文本里每个数的原文。交回和 JSON.parse 同形状的一棵树:对象是 Map(键重复时后面的算,同 JSON.parse),数组是 * 数组,数是它的原文,别的值是 null。Python 写出的裸 NaN、Infinity、-Infinity 也认(同 parseJson)。读不了抛错 */ function numberTokens(text) { const NUMBER = /-?(?:Infinity|(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|NaN/y const WORD = /true|false|null/y let at = 0 const fail = () => { throw new Error('JSON 格式有误') } const skip = () => { while (at < text.length && ' \t\n\r'.includes(text[at])) at++ } const string = () => { const from = at++ while (at < text.length && text[at] !== '"') at += text[at] === '\\' ? 2 : 1 if (at >= text.length) fail() at++ return text.slice(from, at) } const value = () => { skip() const c = text[at] if (c === '{' || c === '[') { const object = c === '{' const close = object ? '}' : ']' const out = object ? new Map() : [] at++ skip() if (text[at] === close) { at++ return out } for (;;) { if (object) { skip() if (text[at] !== '"') fail() const key = JSON.parse(string()) skip() if (text[at++] !== ':') fail() out.set(key, value()) } else { out.push(value()) } skip() const next = text[at++] if (next === close) return out if (next !== ',') fail() } } if (c === '"') { string() return null } for (const re of [NUMBER, WORD]) { re.lastIndex = at const m = re.exec(text) if (m) { at = re.lastIndex return re === NUMBER ? m[0] : null } } return fail() } const tree = value() skip() if (at !== text.length) fail() return tree } /** numberTokens 的树里一处的原文:path 是一串键和下标。不是数、找不到都是 null */ function tokenAt(tree, path) { let node = tree for (const key of path) { if (node instanceof Map) node = node.has(key) ? node.get(key) : null else if (Array.isArray(node) && Number.isInteger(key)) node = key >= 0 && key < node.length ? node[key] : null else return null } return typeof node === 'string' ? node : null } // ------------------------------------------------------------------ 正文的排法:核对和显示共用 // // 正文怎样排,核对时就怎样算(不碰页面,node 里也算得了):一个数字、取值在正文里显示成什么字、紧挨着它的是什么字, // 都按这一节的函数算;显示时(renderBlock / renderUnit)也走它们,两边不会各算各的。 // 行内语法:代码、链接(只显示文字,不给链接:页面不连网)、粗体、斜体、删除线,和前端 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 } } // 列表最多显示这么多级缩进(每级 14px)。服务端按原文的缩进算级数(两个空格一级),没有上限:伪造的级数写成十万、 // 负数,那一项会被挤出屏幕或挤到左边看不见的地方,读的人看不到核对通过的数,页面却照样通过;窄屏上也会横向溢出 const MAX_DEPTH = 8 /** 列表的各项:一项从行首写着列表符号或序号的结构片段开始,后面的句子接在这一项里。depth 是显示的缩进级数 */ function listItems(units) { 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 ?? ''))) const depth = Math.min(Math.max(Math.floor(Number(u.depth)) || 0, 0), MAX_DEPTH) if (starts) items.push({ depth, units: [u] }) else items[items.length - 1].units.push(u) } return items } /** * 表格:{ cells(位置 → 句子,一格可能被分成几句,按位置归拢), rows(表身各行的行号,表头是 -1,另算), cols(有格的 * 列号,从小到大)}。服务端每行从第 0 列起一格不落,列号连着;只排有格的列:伪造的列号写成十万,那一格紧接着前一列 * 显示,不会被几万个空列挤到表格右边看不见的地方(也不会建出几十万个空格、拖垮页面) */ function tableCells(units) { const cells = new Map() const rows = [] const cols = new Set() for (const u of units) { const loc = isObj(u.loc) ? u.loc : { row: 0, col: 0 } push(cells, `${loc.row}:${loc.col}`, u) const col = Number(loc.col) if (Number.isInteger(col) && col >= 0) cols.add(col) if (loc.row >= 0 && !rows.includes(loc.row)) rows.push(loc.row) } rows.sort((a, b) => a - b) return { cells, rows, cols: [...cols].sort((a, b) => a - b) } } /** * 一块里连着显示的几组句子:段落、标题、引用整块一组,列表每项一组,表格每格一组(只有表头和表身各行各列上的格 * 显示出来)。分隔线不显示句子,代码块按原文显示、不标数字:都交回空的 */ function flowsOf(block) { const units = Array.isArray(block.units) ? block.units.filter(isObj) : [] switch (block.type) { case 'hr': case 'code': return [] case 'list': return listItems(units).map((item) => item.units) case 'table': { const { cells, rows, cols } = tableCells(units) return [-1, ...rows].flatMap((r) => cols.map((c) => cells.get(`${r}:${c}`) || [])) } default: return [units] } } const plans = new WeakMap() /** * 一句怎样显示:{ segs(结构片段以外的片段), text(连起来的字), styles(行内样式), at(每个片段在 text 里的起止), * raw }。数字、取值片段里有字会被行内标记藏起来时(伪造的链接把它塞进不显示的链接地址、取值里本身带 * 这类),整句 * 按未排版的原文显示(raw):核对过的字一个不少地露出来,读的人不会只看到旁边伪造的那个数。核对和显示各算一次会是 * 同一个结果,算过的记下来 */ function unitPlan(unit) { let plan = plans.get(unit) if (plan) return plan const segs = (Array.isArray(unit.segments) ? unit.segments : []).filter((s) => isObj(s) && s.kind !== 'structural') const at = [] let text = '' for (const seg of segs) { const piece = String(seg.text ?? '') at.push([text.length, text.length + piece.length]) text += piece } let styles = inlineStyles(text) const raw = segs.some((seg, i) => (seg.kind === 'number' || seg.kind === 'value') && styles.hide.subarray(at[i][0], at[i][1]).some((x) => x === 1)) if (raw) styles = { hide: new Uint8Array(text.length), style: new Uint8Array(text.length) } plan = { segs, text, styles, at, raw } plans.set(unit, plan) return plan } // 紧挨着核对通过的数字、取值的字,按码点分几类(kindOf): // - digit:数字。Python 的 \d 认的全都算(全角的、数学字母区的、阿拉伯文的也是); // - minus:负号。横线类标点(连字符、各种破折号,全角的、小号的也算;波浪形的不算),减号类符号(数学减号、上下标 // 负号、修饰字母负号、粗减号),以及显示出来同样是一道横线的字:Unicode 易混字符表里映射到「-」「ー」的(汉字「一」、 // 假名长音、制表符的横线这类),和在 Chrome 的常用字体里逐个画出来看是一道横线、高度和负号相当的字; // - sep:半角逗号、句点和长得一样的窄标点(低位单引号、单点前导符、小号的逗号句点这类,取自易混字符表):「1,」后面 // 接着数字也算紧挨着。全角的「,」「.」不算:字宽,中文里两个数之间本来就常用「,」隔开; // - skip:显示时不占位置的字(零宽空格、连接符、变体选择符、韩文填充符这类格式字符和默认不显示的字),隔着它们照样 // 算紧挨着; // - blur:显示成什么说不准的字:控制字符(制表、换行这几个空白除外)、未分配和私用区的字、孤立的代理项、组合附加符号 // (叠在前一个字上:叠在空格上能在数字前面画出一道横线)、可选连字符(在它那里换行时显示成连字符)、发丝空格、对象 // 替换符,以及在 Chrome 的常用字体里量出来几乎不占宽度的几个字。各家浏览器、字体把它们显示成数字、横线还是什么都 // 不显示说不准:隔着它们照样往外找数字、负号;找不到的也判无法核对,原因另写; // - other:别的字(汉字、字母、空格、别的标点)把数字隔开。 // 改变文字方向的控制字符(BIDI)和从右往左书写的文字(RTL:希伯来文、阿拉伯文这类)另算:它们能把一位数挪到数字的 // 另一边显示(逻辑上在数字后面的「5」显示在数字前面),按字的先后看不出来,同一句里出现就判无法核对 const DIGIT = unicode('^\\p{Nd}$') || /^[0-9\uff10-\uff19]$/ const DASH_LIKE = '\\u02d7\\u0c7c\\u1173\\u172d\\u174d\\u180a\\u1b78\\u1bc7\\u1bf3\\u1c7c\\u2043\\u207b\\u208b\\u2212' + '\\u23af\\u23ba-\\u23bd\\u23e4\\u2500\\u2501\\u2504\\u2505\\u2508\\u2509\\u254c\\u254d\\u2550\\u2574\\u2576\\u2578' + '\\u257a\\u257c\\u257e\\u2796\\u29ff\\u2cba\\u2cbb\\u2d67\\u2f00\\u30fc\\u3127\\u3161\\u3192\\u31d0\\u4e00\\ua7f7' + '\\uaa7d\\uff70\\uffda' const DASH_LIKE_ASTRAL = '\\u{10110}\\u{10191}\\u{11049}\\u{1104b}\\u{11052}\\u{110be}\\u{13404}\\u{1bc03}\\u{1bc08}' + '\\u{1bc0d}\\u{1bc86}\\u{1d369}' const MINUS = unicode(`^(?![\\u301c\\u3030])[\\p{Pd}${DASH_LIKE}${DASH_LIKE_ASTRAL}]$`) || new RegExp(`^[-\\u2010-\\u2015\\u2e3a\\u2e3b\\ufe58\\ufe63\\uff0d${DASH_LIKE}]$`) const SEP = unicode('^[.,\\u00b8\\u201a\\u2024\\u2e12\\ua4f8\\ua4f9\\ua60e\\uabec\\ufe50\\ufe52\\u{16f53}\\u{1d16d}]$') || /^[.,\u00b8\u201a\u2024\u2e12\ua4f8\ua4f9\ua60e\uabec\ufe50\ufe52]$/ // 变体选择符只换前一个字的字形(文字样式还是表情样式),算 skip:表情后面常带一个,不能因此把数字判成核对不了 const VARIATION = unicode('^[\\u180b-\\u180d\\u180f\\ufe00-\\ufe0f\\u{e0100}-\\u{e01ef}]$') || /^[\u180b-\u180d\u180f\ufe00-\ufe0f]$/ const BLUR_EXTRA = '\\u00ad\\u02dd\\u1843\\u1cd3\\u200a\\u2044\\ufffc' const BLUR = unicode(`^(?![\\t\\n\\f\\r])[\\p{Cc}\\p{Cn}\\p{Co}\\p{Cs}\\p{Mn}\\p{Me}${BLUR_EXTRA}]$`) || new RegExp('^(?![\\t\\n\\f\\r])[\\x00-\\x1f\\x7f-\\x9f\\u0300-\\u036f\\u0483-\\u0489\\u1ab0-\\u1aff\\u1dc0-\\u1dff' + `\\u20d0-\\u20ff\\ud800-\\udfff\\ue000-\\uf8ff\\ufe20-\\ufe2f\\ufff0-\\ufff8${BLUR_EXTRA}]$`) const SKIP = unicode('^[\\p{Cf}\\p{Default_Ignorable_Code_Point}]$') || /^[\u034f\u061c\u115f\u1160\u17b4\u17b5\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\u3164\ufeff\uffa0]$/ const BIDI = /^[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]$/ const RTL = unicode('^[\\u0590-\\u08ff\\ufb1d-\\ufdff\\ufe70-\\ufefe\\u{10800}-\\u{10fff}\\u{1e800}-\\u{1efff}]$') || /^[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefe]$/ /** 一个字(码点)算哪一类,见上 */ function kindOf(c) { if (VARIATION.test(c)) return 'skip' if (DIGIT.test(c)) return 'digit' if (MINUS.test(c)) return 'minus' if (SEP.test(c)) return 'sep' if (BLUR.test(c)) return 'blur' return SKIP.test(c) ? 'skip' : 'other' } const NOWHERE = { at: -1, kind: null, blurred: false } /** * ⑤ 的最后一步:核对通过的数字、取值,读的人在正文里看到的就该是核对过的那串字。items 是这份报告的 片段 → 核对结果, * 每一项记下正文里显示出来的字(onPage)。 * - 正文里根本不显示它(分隔线里、表格里不在各行各列上的格):伪造的字可以写在别处,读的人看不到核对过的那个数。 * 服务端导出时不会出现(分隔线不带句子,表格的格都在行列上),判不通过; * - 紧挨着的字里还有数字或负号(前面补一位、后面接一位或一段小数、前面加负号;隔着不占位置的字、显示时藏起来的排版 * 标记也算紧挨着),紧挨着的是显示成什么说不准的字,或者同一句里有改变文字方向的字:读起来的数可能不止核对过的 * 这几位,判无法核对——真报告里也可能有(放行的数字、日期紧贴着引用),不能说成被改过。两个数之间的连字符是区间, * 不算负号。 * 代码块按原文显示、不标数字,照旧 */ function checkLayout(doc, items) { const numeric = (seg) => seg.kind === 'number' || seg.kind === 'value' const soft = (kind) => kind === 'skip' || kind === 'blur' for (const block of Array.isArray(doc.blocks) ? doc.blocks.filter(isObj) : []) { if (block.type === 'code') { for (const unit of Array.isArray(block.units) ? block.units.filter(isObj) : []) { for (const seg of unitPlan(unit).segs) if (items.has(seg)) items.get(seg).onPage = String(seg.text ?? '') } continue } for (const flow of flowsOf(block)) { // 这一组显示出来的字(藏起来的排版标记不算),逐个记下属于哪个片段 const shownUnits = [] for (const unit of flow) { const { segs, text, styles, at } = unitPlan(unit) segs.forEach((seg, i) => { let shown = '' for (let k = at[i][0]; k < at[i][1]; k++) { if (styles.hide[k]) continue shown += text[k] shownUnits.push([text[k], seg]) } if (items.has(seg)) items.get(seg).onPage = shown }) } // 拼成码点:代理对的两半在页面上挨着就显示成一个字(数学字母区的数字这类),分在两个文字片段里、中间隔着 // 藏起来的排版标记也一样;不成对的半个单独算一个字(显示成替换字符),不和后面的字拼。可点的数字自成一块,它的 // 字不和外面的拼 const chars = [] for (let k = 0; k < shownUnits.length; k++) { const [c, seg] = shownUnits[k] const next = shownUnits[k + 1] const paired = next && c >= '\ud800' && c < '\udc00' && next[0] >= '\udc00' && next[0] < '\ue000' && (next[1] === seg || (!items.has(seg) && !items.has(next[1]))) chars.push(paired ? [c + shownUnits[++k][0], seg] : [c, seg]) } // 可点的数字自成一块,它里面的字改变不了外面的方向:只看别的片段 const turned = chars.some(([c, seg]) => !items.has(seg) && (BIDI.test(c) || RTL.test(c))) const kinds = chars.map(([c]) => kindOf(c)) const spans = new Map() chars.forEach(([, seg], k) => { if (spans.has(seg)) spans.get(seg)[1] = k else spans.set(seg, [k, k]) }) // 从 k 往 step 方向找下一个看得见的字:skip 跳过,blur 也越过去、记下来。{ at, kind, blurred } const look = (k, step) => { let blurred = false for (let j = k + step; j >= 0 && j < chars.length; j += step) { if (kinds[j] === 'skip') continue if (kinds[j] === 'blur') { blurred = true continue } return { at: j, kind: kinds[j], blurred } } return { ...NOWHERE, blurred } } for (const [seg, item] of items) { if (item.result !== PASS || !spans.has(seg)) continue const [s0, s1] = spans.get(seg) // 片段自己的头一个、末一个字:越过它自己头尾不占位置、说不准的字(取值里本来就带的组合符号这类) let first = s0 while (first < s1 && soft(kinds[first])) first++ let last = s1 while (last > first && soft(kinds[last])) last-- const lead = kinds[first] === 'digit' || kinds[first] === 'minus' const tail = kinds[last] === 'digit' const l1 = look(s0, -1) const l2 = l1.at >= 0 ? look(l1.at, -1) : NOWHERE const r1 = look(s1, 1) const r2 = r1.at >= 0 ? look(r1.at, 1) : NOWHERE const before = kinds[first] === 'digit' && (l1.kind === 'digit' || (l1.kind === 'sep' && l2.kind === 'digit')) const sign = lead && l1.kind === 'minus' && l2.kind !== 'digit' && !(l2.at >= 0 && numeric(chars[l2.at][1])) const after = tail && (r1.kind === 'digit' || (r1.kind === 'sep' && r2.kind === 'digit')) // 数字头尾紧挨着(或隔着一个逗号、句点)的是说不准的字:它本身就可能显示成数字、负号 const near = (a, b) => a.blurred || (a.kind === 'sep' && b.blurred) const blurred = (lead && near(l1, l2)) || (tail && near(r1, r2)) if (turned || before || sign || after || blurred) { const why = turned ? T.shownTurned : before || sign || after ? T.shownGlued : T.shownBlurred Object.assign(item, { result: NA, reason: T.numShownUnknown(why) }) } } } } for (const item of items.values()) { if (item.onPage === undefined && item.result === PASS) Object.assign(item, { result: FAIL, reason: T.numNotShown }) } } // ------------------------------------------------------------------ 核对 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 } /** 文档里的每个片段和它在文档 JSON 里的位置(引用的值按原文取,见 numberTokens) */ function* segmentsOf(doc) { const list = (holder, key) => (isObj(holder) && Array.isArray(holder[key]) ? holder[key] : []) for (const [b, block] of list(doc, 'blocks').entries()) { for (const [u, unit] of list(block, 'units').entries()) { for (const [s, seg] of list(unit, 'segments').entries()) { if (isObj(seg)) yield [seg, ['blocks', b, 'units', u, 'segments', s]] } } } } /** 一份嵌入工件里每个数的原文(numberTokens):用到时才算,只算一次;算不出来是 null */ function tokensOf(item) { if (item.tokens === undefined) { try { item.tokens = numberTokens(decoder.decode(fromBase64(item.a.b64))) } catch (e) { item.tokens = null } } return item.tokens } /** 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 : a.kind === 'query' ? T.maskedQuery : 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, path] of segmentsOf(doc)) { if (seg.kind !== 'number' && seg.kind !== 'value') continue // 引用的值在文档 JSON 里的原文:超过 2^53 的整数照原文写进原因和出处面板 const citedToken = () => tokenAt(tokensOf(art), [...path, 'cite', 'value']) const check = trusted.has(report) ? checkNumber(seg, doc, arts, chained, citedToken) : { result: NA, reason: T.numDocUntrusted, source: describe(seg, doc) } out.numbers.push({ ...check, seg, report, key: `${report.node_id}|${seg.id}` }) } // 核对通过的,还要看读的人在正文里看到的是不是这串字 if (trusted.has(report)) checkLayout(doc, new Map(out.numbers.filter((n) => n.report === report).map((n) => [n.seg, n]))) } // ⑥ 运行信息 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 } /** * citedToken() 交回引用的值在文档 JSON 里的原文(数才有)。引用的值对上了,再核正文显示的字(checkShown):两样都对上 * 才算通过 */ function checkNumber(seg, doc, arts, chained, citedToken = () => null) { 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') { // 运行输入不随文件附带,引用的值核对不了,照旧无法核对。正文显示的字照样拿引用的值按服务端的写法(render_number // 不分组、不接单位,可带换算)重新渲染比对:只改显示文字(统计周期、地区这类)的伪造在这里露馅,判不通过 const info = { source, kind: cite.kind, cite, citedToken: citedToken() } const shown = checkShown(info, seg, () => renderNumber(cite.value, info.citedToken, { fmt: 'plain', conv: convOf(cite) })) return shown.result === FAIL ? shown : { ...shown, result: NA, reason: T.numInput } } 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 } info.citedToken = citedToken() 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 at = metrics.findIndex((m) => isObj(m) && m.id === metricId) const metric = metrics[at] info.card = art.json if (!metric) return { ...info, result: FAIL, reason: T.numNoMetric } info.truth = metric.value info.truthToken = tokenAt(tokensOf(art), ['metrics', at, 'value']) if (!sameValue(cite.value, metric.value)) return { ...info, result: FAIL, reason: T.numMetricDiffers } // 口径卡上记着这个指标的单位、小数位、显示格式,报告里另写的换算记在引用上 return checkShown(info, seg, () => renderNumber(metric.value, info.truthToken, { unit: unitOf(metric.unit), decimals: metric.decimals, decimalsToken: tokenAt(tokensOf(art), ['metrics', at, 'decimals']), fmt: metric.format, conv: convOf(cite) })) } const loc = isObj(cite.locator) ? cite.locator : {} info.snapshot = art.json let cell try { const [raw, column, key] = locateCell(art.json, loc.row, loc.column) info.row = loc.row info.column = column info.truth = cellValue(raw, columnKind(art.json, column)) info.truthToken = tokenAt(tokensOf(art), ['rows', loc.row, key]) // 落成数字文本的(DECIMAL)按数比对,出处面板照快照的原文写:读成 double 后超过 2^53 的订单号会丢位, // 和一位不差的「引用的值」并排时自相矛盾 if (typeof raw === 'string' && typeof info.truth === 'number') info.truthText = pyStrip(raw) cell = { raw, column } } catch (e) { return { ...info, result: FAIL, reason: T.numNoCell(e.message) } } if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numCellDiffers } const display = Object.prototype.hasOwnProperty.call(doc, 'cell_display') ? doc.cell_display : 1 return checkShown(info, seg, () => renderCell(cell.raw, info.truthToken, convOf(cite), columnKind(art.json, cell.column), display)) } /** 引用上记的换算(|万、|pct 这类),没写是 null */ const convOf = (cite) => (own(cite, 'conv') === undefined ? null : cite.conv) /** 口径卡的单位:服务端是 str(unit or ""),不是文字的本页不认 */ function unitOf(unit) { if (!unit) return '' if (typeof unit !== 'string') throw new Unshown(T.shownUnit) return unit } /** 数的写法:超过 2^53 的整数照 JSON 原文(String 会丢位),别的同 String */ const numberText = (value, token) => (typeof token === 'string' && /^-?\d+$/.test(token) && !Number.isSafeInteger(value) ? token : String(value)) /** 出处面板里的值:数同 numberText,别的同 valueText */ const exactText = (value, token) => (typeof value === 'number' ? numberText(value, token) : valueText(value)) /** * 正文显示的字和按显示规则渲染出来的字逐字比对,两边都只去掉首尾空白:口径卡的单位带尾随空白(「元 」)时,服务端照原样 * 接在数后面,正文和渲染出来的都是「74,613.65元 」,只去一边的话原样导出的报告也对不上。渲染不出来的判无法核对: * 不认识的格式不能当成核对过了。对上了记 shownOk(出处面板据此注明一致);对不上的点名两样:正文显示的字、引用的值 * (加上按显示规则应显示的字,和引用的值写法一样时不写) */ function checkShown(info, seg, render) { let expected try { expected = render() } catch (e) { return { ...info, result: NA, reason: T.numShownUnknown(e instanceof Unshown ? e.message : T.shownBroken) } } info.expected = expected const shown = String(seg.text ?? '') if (pyStrip(shown) === pyStrip(expected)) return { ...info, result: PASS, shownOk: true } const value = info.cite.value const cited = typeof value === 'number' ? numberText(value, info.citedToken) : value == null ? null : textOf(value) return { ...info, result: FAIL, reason: T.numShownDiffers(shown, cited == null ? T.nullValue : `「${cited}」`, expected === cited ? null : expected) } } /** * ⑥ 运行信息:运行结果、运行类别、出具档位不信元信息,拿封存范围内已核对的事件对照,页面上显示的也是事件里的。 * 事件的类型和序号由 ② 的清单绑定,清单没通过就无从对照。每项 { 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(' · ') } // ------------------------------------------------------------------ 渲染:报告正文 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, text, styles, at } = unitPlan(unit) const frag = document.createDocumentFragment() segs.forEach((seg, i) => { let body = styledSlice(text, styles, at[i][0], at[i][1]) if (seg.strong) body = h('strong', {}, body) // 按片段本身找它的核对结果,不按片段 id:伪造的文字片段写上和数字一样的 id,也借不到它的结果、显示不成可点的数字 const number = ctx.numbers.get(seg) 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 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 listItems(units)) { 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;一格可能被分成几句,按位置归拢(tableCells) const { cells, rows: rowIds, cols } = tableCells(units) const textAt = (r, c) => (cells.get(`${r}:${c}`) || []).map(unitText).join('').replace(/[*`]/g, '').trim() const numeric = new Set(cols.filter((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.has(c) ? 'n' : null }, (cells.get(`${r}:${c}`) || []).map(inline)) return h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, cols.map((c) => cell('th', -1, c)))), h('tbody', {}, rowIds.map((r) => h('tr', {}, cols.map((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 checked = typeof number.expected === 'string' const same = checked && number.shownOk === true 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, exactText(cite.value, number.citedToken)] : null, 'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, number.truthText ?? exactText(number.truth, number.truthToken)] : null, checked && !same ? [T.dExpected, number.expected] : null, [T.dShown, [String(number.seg.text ?? ''), same ? h('span', { class: 'muted' }, `(${T.dShownSame})`) : 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)) { // 被引用的那一行:快照随文件附带(没碰设了遮罩的列)才走到这里,整行照原样列出,引用的那一格高亮。数照 JSON // 原文写(同上面「快照里的值」):超过 2^53 的订单号读成 double 会丢位,和一位不差的「引用的值」对不上 let columns = (Array.isArray(snap.columns) ? snap.columns : []).map(String) if (isObj(record) && !columns.length) columns = Object.keys(record) const art = view.out.artifacts.find((a) => a.a.id === number.artifact) const tokens = art ? tokensOf(art) : null const cellText = (c, i) => { const value = isObj(record) ? own(record, c) : record[i] if (value == null) return '—' if (typeof value !== 'number') return textOf(value) return numberText(value, tokenAt(tokens, ['rows', number.row, isObj(record) ? c : 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, 'data-cell': c }, cellText(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 } for (const item of out.docs) { const label = nodeLabel(item.report.node_id) // 报告的徽标连同正文里的数字一起算:重算了全部哈希的伪造只在 ⑤ 露馅,标题旁不能还写着通过 const nums = out.numbers.filter((n) => n.report === item.report) // 正文里按片段本身找核对结果(renderUnit) const numbers = new Map(nums.map((n) => [n.seg, n])) 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', '') } // 在 node 里载入(tests/test_bundle_shown.py 拿服务端的渲染逐条对照、整份核对):没有页面,交出核对用的纯函数就停 if (typeof document === 'undefined') { if (typeof module === 'object' && module) { module.exports = { verify, renderNumber, renderCell, numberTokens, tokenAt, parseJson, Unshown, listItems, tableCells } } return } // 脚本在 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() })()