From 073b9ca39c1da2ff12782f8403d5b951e20c2a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 21 Oct 2026 12:04:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(adapter-wp):=20IO=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=BD=92=E5=85=A5=20fatalError=EF=BC=9BrowErrors=20=E5=B0=81?= =?UTF-8?q?=E9=A1=B6=EF=BC=9Btimings=20=E5=A5=91=E7=BA=A6=E4=B8=8E?= =?UTF-8?q?=E7=BA=AF=20term=20=E6=B5=81=E6=B5=8B=E8=AF=95=EF=BC=9BPARSER?= =?UTF-8?q?=20=E7=89=88=E6=9C=AC=E6=BC=82=E7=A7=BB=E4=BF=9D=E9=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查修复(Task 6 质量审查 5 项): - Fix 1:Node 系统错误(Error 且 code 字符串——ENOENT/EACCES 等 IO 形态)与 WxrError 同路记入 fatalError(message 含 code 与 path),报告不上抛保留已聚合部分;无 code 的 程序 bug 仍原样上抛;模块头契约注释同步修正 - Fix 2:rowErrors 固化封顶 10_000 条 + rowErrorsTruncated 计数(超出只计数不固化, 防对抗性输入使报告体积失去上界;counts/byDiffType 统计维度不受截断影响);HTML 渲染追加截断说明行 - Fix 3:timings 注入契约测试(多 chunk 流消费完成后外部对象 parseMs>0/sanitizeMs>=0) - Fix 4:纯 wp:term 流测试(counts 空对象/rows=0/terms 计数) - Fix 5:PARSER_VERSION 漂移保险(createRequire 读 saxes/package.json,主版本不一致 即爆红,fail loud) 247 → 253 测试全绿;tsc/lint/build 绿。 Co-Authored-By: Claude Code --- packages/adapter-wordpress/src/report.ts | 66 +++++++-- .../adapter-wordpress/test/adapter.test.ts | 131 +++++++++++++++++- 2 files changed, 182 insertions(+), 15 deletions(-) diff --git a/packages/adapter-wordpress/src/report.ts b/packages/adapter-wordpress/src/report.ts index 88766b5..ea46317 100644 --- a/packages/adapter-wordpress/src/report.ts +++ b/packages/adapter-wordpress/src/report.ts @@ -16,9 +16,12 @@ // sourceType 报告锚点。行级错误(rowErrors)收录全部非致命问题:映射 issues、契约复检 // issues、清洗差异(含 excerpt)、unsupported/error 事件——宁多报不漏报(审计定位用途)。 // -// 致命错误策略:WxrError(解析限额/结构错误)使流终止——报告不向上抛,记入 fatalError -// 并保留已聚合部分(Task 7 wxr:probe 对损坏文件要出报告而非崩溃);非 WxrError 的意外 -// 异常视为程序 bug,原样上抛。 +// 致命错误策略:WxrError(解析限额/结构错误)与 **Node 系统错误**(IO 失败:Error 且 +// code 为字符串,如 ENOENT/EACCES——createReadStream 读源文件中途失败即此形态)使流 +// 终止——报告不向上抛,记入 fatalError 并保留已聚合部分(Task 7 wxr:probe 对损坏文件/ +// 不可读文件要出报告而非崩溃,message 携带 code 与 path 便于定位);其余异常视为程序 +// bug,原样上抛。rowErrors 封顶(超出部分只计数不固化,见 rowErrorsTruncated): +// 对抗性输入的单条 item 可产出上万清洗差异,固化全部会使报告体积失去上界。 import type { Readable } from 'node:stream' import { runWxrAdapter, type AdapterEvent, type AdapterOptions, type AdapterTimings } from './adapter.js' @@ -49,6 +52,13 @@ export interface RowErrorEntry { excerpt?: string } +/** + * rowErrors 固化上限(条):超出部分只计数不固化(rowErrorsTruncated),防对抗性输入 + * (单 item 上万清洗差异)使报告体积失去上界;统计维度(counts/sanitize.byDiffType) + * 不受影响,始终完整。 + */ +const ROW_ERRORS_CAP = 10_000 + /** 导入前报告对象(纯数据,JSON.stringify 直接可序列化——无 Map/循环引用) */ export interface WxrAdapterReport { versions: WxrVersions @@ -58,12 +68,24 @@ export interface WxrAdapterReport { byDiffType: { 'removed-tag': number; 'removed-attr': number } changedRows: number } + /** 行级错误(文档序,含非致命 issue;封顶 ROW_ERRORS_CAP 条) */ rowErrors: RowErrorEntry[] + /** 超出 {@link ROW_ERRORS_CAP} 被丢弃固化的行级错误条数(只计数) */ + rowErrorsTruncated: number timing: { parseMs: number; sanitizeMs: number; totalMs: number } - /** 致命错误(WxrError.name + message);流正常完结为 null */ + /** 致命错误(WxrError 或 Node 系统错误的 name+message,含 code/path);流正常完结为 null */ fatalError: string | null } +/** + * Node 系统错误判定(IO 形态):Error 且 code 为字符串(fs/net 错误的公约形态—— + * ENOENT/EACCES/EISDIR 等;path/errno 为可选伴随字段)。不含 code 的普通 Error + * 一律视为程序 bug,不吞——见模块头致命错误策略。 + */ +function isNodeSystemError(err: unknown): err is NodeJS.ErrnoException { + return err instanceof Error && typeof (err as NodeJS.ErrnoException).code === 'string' +} + /** 取指定类型的计数桶(缺则建桶——报告对象自建自持,无需预注册类型清单) */ function countsBucket(report: WxrAdapterReport, sourceType: string): SourceTypeCounts { let bucket = report.counts[sourceType] @@ -85,6 +107,15 @@ function diffEntry(rawLocation: string, diff: SanitizeDiff): RowErrorEntry { } } +/** rowErrors 入账(封顶:数组满后只累计 truncated 计数,不固化条目) */ +function pushRowError(report: WxrAdapterReport, entry: RowErrorEntry): void { + if (report.rowErrors.length < ROW_ERRORS_CAP) { + report.rowErrors.push(entry) + } else { + report.rowErrorsTruncated++ + } +} + /** 单事件聚合(纯函数式地累积进 report——顺序敏感仅在于 rowErrors 的文档序) */ function aggregate(report: WxrAdapterReport, ev: AdapterEvent): void { switch (ev.kind) { @@ -96,18 +127,18 @@ function aggregate(report: WxrAdapterReport, ev: AdapterEvent): void { report.totals.rows++ if (ev.changed) report.sanitize.changedRows++ for (const issue of ev.issues) { - report.rowErrors.push({ rawLocation: ev.row.rawLocation, message: issue }) + pushRowError(report, { rawLocation: ev.row.rawLocation, message: issue }) } for (const diff of ev.sanitizeDiffs) { report.sanitize.byDiffType[diff.type]++ - report.rowErrors.push(diffEntry(ev.row.rawLocation, diff)) + pushRowError(report, diffEntry(ev.row.rawLocation, diff)) } break } case 'unsupported': countsBucket(report, ev.sourceType).unsupported++ report.totals.unsupported++ - report.rowErrors.push({ + pushRowError(report, { rawLocation: ev.rawLocation, message: `${ev.sourceType}:${ev.reason}`, }) @@ -115,7 +146,7 @@ function aggregate(report: WxrAdapterReport, ev: AdapterEvent): void { case 'error': countsBucket(report, ev.sourceType).failed++ report.totals.failed++ - report.rowErrors.push({ rawLocation: ev.rawLocation, message: ev.issue }) + pushRowError(report, { rawLocation: ev.rawLocation, message: ev.issue }) break } } @@ -142,6 +173,7 @@ export async function runWxrAdapterReport( totals: { rows: 0, terms: 0, unsupported: 0, failed: 0 }, sanitize: { byDiffType: { 'removed-tag': 0, 'removed-attr': 0 }, changedRows: 0 }, rowErrors: [], + rowErrorsTruncated: 0, timing: { parseMs: 0, sanitizeMs: 0, totalMs: 0 }, fatalError: null, } @@ -151,9 +183,16 @@ export async function runWxrAdapterReport( aggregate(report, ev) } } catch (err) { - // 致命错误策略见模块头:WxrError 记账不抛(保留已聚合部分),其余视为程序 bug - if (!(err instanceof WxrError)) throw err - report.fatalError = `${err.name}: ${err.message}` + // 致命错误策略见模块头:WxrError 与 Node 系统错误(IO)记入 fatalError 不抛 + // (保留已聚合部分);其余视为程序 bug 原样上抛 + if (err instanceof WxrError) { + report.fatalError = `${err.name}: ${err.message}` + } else if (isNodeSystemError(err)) { + const path = typeof err.path === 'string' ? `; path: ${err.path}` : '' + report.fatalError = `${err.name} [${err.code}${path}]: ${err.message}` + } else { + throw err + } } report.timing = { @@ -234,7 +273,7 @@ export function renderReportHtml(report: WxrAdapterReport): string { ) lines.push('

行级错误

') - if (report.rowErrors.length === 0) { + if (report.rowErrors.length === 0 && report.rowErrorsTruncated === 0) { lines.push('

(无)

') } else { lines.push('') + if (report.rowErrorsTruncated > 0) { + lines.push(`

(另有 ${report.rowErrorsTruncated} 条超出固化上限,仅计数)

`) + } } if (report.fatalError !== null) { diff --git a/packages/adapter-wordpress/test/adapter.test.ts b/packages/adapter-wordpress/test/adapter.test.ts index 3bf274d..7c97a0b 100644 --- a/packages/adapter-wordpress/test/adapter.test.ts +++ b/packages/adapter-wordpress/test/adapter.test.ts @@ -9,14 +9,25 @@ // 手构样本经真实 parseWxrStream 喂入(组装层的输入契约就是 WXR 字节流,不走 mapItem // 直调捷径——端到端才是本任务的被测对象)。 import { createReadStream } from 'node:fs' +import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' -import { PassThrough, type Readable } from 'node:stream' +import { PassThrough, Readable } from 'node:stream' import { describe, expect, it } from 'vitest' import { isUniversalRow } from '@sf/contracts' import { WxrParseError } from '../src/parse/wxr-stream.js' -import { runWxrAdapter, type AdapterEvent, type RowResult } from '../src/adapter.js' +import { + runWxrAdapter, + type AdapterEvent, + type AdapterTimings, + type RowResult, +} from '../src/adapter.js' import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '../src/report.js' -import { ADAPTER_VERSION, MAPPING_VERSION, SANITIZER_VERSION } from '../src/versions.js' +import { + ADAPTER_VERSION, + MAPPING_VERSION, + PARSER_VERSION, + SANITIZER_VERSION, +} from '../src/versions.js' const FIXTURE = fileURLToPath(new URL('../../../tests/fixtures/wxr/mini.xml', import.meta.url)) @@ -27,6 +38,36 @@ function fromXml(inner: string): Readable { return s } +/** 按字节切块喂入(多 chunk 路径;wxr-stream 测试同款,7 字节必拆多字节字符) */ +function fromXmlChunked(xml: string, size = 7): Readable { + return Readable.from( + (function* () { + const buf = Buffer.from(xml, 'utf8') + for (let i = 0; i < buf.length; i += size) yield buf.subarray(i, i + size) + })(), + ) +} + +/** 模拟文件读取中途 IO 失败:交付一个 chunk 后 destroy(err)(code/path 形态同 fs 错误) */ +function ioErrorStream(prefix: string, code: string, path: string): Readable { + let sent = false + return new Readable({ + read() { + if (sent) return + sent = true + this.push(prefix) + queueMicrotask(() => { + this.destroy( + Object.assign(new Error(`${code}: no such file or directory, open '${path}'`), { + code, + path, + }), + ) + }) + }, + }) +} + /** WXR 骨架(命名空间照 mini.xml;saxes xmlns:false 只认字面限定名,声明仅为形态完整) */ function wxrDoc(inner: string): string { return ( @@ -297,6 +338,7 @@ describe('恶意样本:清洗记账 + 报告 removed-tag 计数 + HTML 渲染 totals: { rows: 0, terms: 0, unsupported: 0, failed: 0 }, sanitize: { byDiffType: { 'removed-tag': 0, 'removed-attr': 0 }, changedRows: 0 }, rowErrors: [], + rowErrorsTruncated: 0, timing: { parseMs: 0, sanitizeMs: 0, totalMs: 0 }, fatalError: null, } @@ -403,3 +445,86 @@ describe('致命解析错误:流拒绝 + 报告记 fatalError(Task 7 wxr:pro expect(report.counts['wp:post']).toMatchObject({ skipped: 1 }) }) }) + +describe('IO 错误(Node 系统错误)归入 fatalError(审查 Fix 1)', () => { + it('流中途 ENOENT 形态错误:报告不抛、fatalError 含 code 与 path', async () => { + const report = await runWxrAdapterReport( + ioErrorStream('', 'ENOENT', 'D:\\wxr\\missing.xml'), + { mapConfig: { locale: 'zh-CN' } }, + ) + expect(report.fatalError).toContain('ENOENT') + expect(report.fatalError).toContain('missing.xml') + // 已聚合部分照常保留(错误发生于首个事件完成前——零行零错误) + expect(report.totals.rows).toBe(0) + }) + + it('无 code 的程序 bug(TypeError):原样上抛,不静默记入报告', async () => { + let sent = false + const buggy = new Readable({ + read() { + if (sent) return + sent = true + this.push('') + queueMicrotask(() => this.destroy(new TypeError('program bug'))) + }, + }) + await expect( + runWxrAdapterReport(buggy, { mapConfig: { locale: 'zh-CN' } }), + ).rejects.toThrow(TypeError) + }) +}) + +describe('rowErrors 封顶(审查 Fix 2:超出部分只计数不固化)', () => { + it('对抗性输入 >10_000 差异:数组截断至上限、truncated 计数准确、清洗统计不截断', async () => { + const diffCount = 10_050 + const xml = wxrDoc( + itemXml({ postId: 901, content: ''.repeat(diffCount) }), + ) + const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }) + expect(report.sanitize.byDiffType['removed-tag']).toBe(diffCount) // 统计维度完整 + expect(report.sanitize.changedRows).toBe(1) + expect(report.rowErrors).toHaveLength(10_000) + expect(report.rowErrorsTruncated).toBe(diffCount - 10_000) + // 截断不破坏对象可序列化性 + expect(JSON.parse(JSON.stringify(report) as string).rowErrorsTruncated).toBe(50) + }) +}) + +describe('timings 注入契约(审查 Fix 3:分段计时侧信道)', () => { + it('多 chunk 流消费完成后:parseMs>0、sanitizeMs>=0(外部注入对象被原地累计)', async () => { + const timings: AdapterTimings = { parseMs: 0, sanitizeMs: 0 } + const xml = wxrDoc(itemXml({ postId: 801, content: '

body

' })) + const events = await collect( + runWxrAdapter(fromXmlChunked(xml), { mapConfig: { locale: 'zh-CN' }, timings }), + ) + expect(rowEvents(events)).toHaveLength(1) + expect(timings.parseMs).toBeGreaterThan(0) // 解析真实发生且被计时(await 窗口累计) + expect(timings.sanitizeMs).toBeGreaterThanOrEqual(0) // bodyHtml 非空 → 清洗计时分支已走 + }) +}) + +describe('纯 term 流(审查 Fix 4:无 item 输入的报告形态)', () => { + it('仅 wp:term:counts 空对象、rows=0、terms=2、无行级错误', async () => { + const xml = wxrDoc( + '31category' + + 'companyCompany' + + '32product_cat' + + 'pipe-fittingsPipe Fittings', + ) + const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }) + expect(report.counts).toStrictEqual({}) + expect(report.totals).toStrictEqual({ rows: 0, terms: 2, unsupported: 0, failed: 0 }) + expect(report.rowErrors).toStrictEqual([]) + expect(report.fatalError).toBeNull() + }) +}) + +describe('PARSER_VERSION 漂移保险(审查 Fix 5:fail loud)', () => { + it('声明主版本 === 实际安装的 saxes 主版本(升级依赖而漏改常量 → 此处爆红)', () => { + const req = createRequire(import.meta.url) + const installed = (req('saxes/package.json') as { version: string }).version + const declared = PARSER_VERSION.match(/^saxes (\d+)\./) + expect(declared).not.toBeNull() + expect(Number(declared?.[1])).toBe(Number(installed.split('.')[0])) + }) +})