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('
(无)
') } else { 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('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( + '