fix(adapter-wp): IO 错误归入 fatalError;rowErrors 封顶;timings 契约与纯 term 流测试;PARSER 版本漂移保险

审查修复(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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 12:04:15 +08:00
parent 6573b8b326
commit 073b9ca39c
2 changed files with 182 additions and 15 deletions
+54 -12
View File
@@ -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('<h3>行级错误</h3>')
if (report.rowErrors.length === 0) {
if (report.rowErrors.length === 0 && report.rowErrorsTruncated === 0) {
lines.push('<p>(无)</p>')
} else {
lines.push('<ul>')
@@ -244,6 +283,9 @@ export function renderReportHtml(report: WxrAdapterReport): string {
lines.push(`<li>${esc(entry.rawLocation)}:${esc(entry.message)}${excerpt}</li>`)
}
lines.push('</ul>')
if (report.rowErrorsTruncated > 0) {
lines.push(`<p>(另有 ${report.rowErrorsTruncated} 条超出固化上限,仅计数)</p>`)
}
}
if (report.fatalError !== null) {
+128 -3
View File
@@ -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('<?xml version="1.0"?><rss><channel>', '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('<?xml version="1.0"?>')
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: '<script></script>'.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: '<p>body</p><script>x</script>' }))
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(
'<wp:term><wp:term_id>31</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy>' +
'<wp:term_slug>company</wp:term_slug><wp:term_name>Company</wp:term_name></wp:term>' +
'<wp:term><wp:term_id>32</wp:term_id><wp:term_taxonomy>product_cat</wp:term_taxonomy>' +
'<wp:term_slug>pipe-fittings</wp:term_slug><wp:term_name>Pipe Fittings</wp:term_name></wp:term>',
)
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]))
})
})