fix(adapter-wp): 内存上界注释纠正;错误前事件排空确定化;post_id 严格解析;嵌套畸形 fail-fast;类型收紧与测试钉

审查修复六项(wxr-stream):
- 内存上界表述纠正:saxes 按节点内部缓冲整个 text/CDATA 节点,对抗性
  单字段文件峰值 ≤ maxWxrBytes 的 UTF-16 表示(约 2×),并非与文件
  大小解耦;maxFieldLength/maxCdataLength 封顶的是本模块缓冲与产出
  字段长度
- parser.write/close 抛错前先排空待 yield 队列再重抛:错误前已完成
  事件的交付不依赖 chunk 划分(测试钉死单 chunk 与 7 字节切块一致)
- wp:post_id / wp:term_id 严格解析:整串十进制 + Number.isSafeInteger,
  否则 null(表驱动测试:''/'0x10'/'12abc'/超安全域 → null)
- 嵌套 item/wp:term 立即 WxrParseError(fail-fast,防外层截断产出)
- Target.field 经 Exclude 收窄为纯文本字段,消除 as string 断言
- 测试钉死:提前 break 销毁上游流、text+CDATA 混合累计、限额错误
  message 含行列位置

142 测试全绿(新增 12)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 09:21:41 +08:00
parent 25e8b9cb5c
commit 8edbbad45b
2 changed files with 177 additions and 41 deletions
@@ -51,6 +51,17 @@ async function captureRejection(p: Promise<unknown>): Promise<unknown> {
throw new Error('期望抛出但未抛出')
}
/** 迭代到错误或完结:同时保留已交付事件与错误(审查 Fix 2 的交付确定性断言用) */
async function collectUntilError(gen: AsyncGenerator<WxrEvent>): Promise<{ events: WxrEvent[]; error: unknown }> {
const events: WxrEvent[] = []
try {
for await (const ev of gen) events.push(ev)
return { events, error: null }
} catch (e) {
return { events, error: e }
}
}
function itemsOf(events: WxrEvent[]): WxrItem[] {
return events
.filter((ev): ev is Extract<WxrEvent, { kind: 'item' }> => ev.kind === 'item')
@@ -212,6 +223,21 @@ describe('parseWxrStream:限额防线(WxrLimitError,kind 区分)', () =>
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('cdata')
// 限额错误 message 附 saxes 行列位置(钉死,审查 Fix 6c)
expect(err.message).toMatch(/line \d+, column \d+/)
})
it('超限抛错前已完成事件确定性交付:单 chunk 与 7 字节切块产出一致(审查 Fix 2)', async () => {
const single = await collectUntilError(
parseWxrStream(fromString(SMALL_XML), { limits: { maxItems: 2 } }),
)
const chunked = await collectUntilError(
parseWxrStream(fromStringChunked(SMALL_XML), { limits: { maxItems: 2 } }),
)
expect(single.error).toBeInstanceOf(WxrLimitError)
expect(chunked.error).toBeInstanceOf(WxrLimitError)
expect(signature(single.events)).toStrictEqual(['item:1:post', 'item:2:post'])
expect(signature(chunked.events)).toStrictEqual(['item:1:post', 'item:2:post'])
})
it('maxFieldLength=10:单字段累计超限抛 kind fieldLength(防单个 title 灌爆内存)', async () => {
@@ -362,3 +388,74 @@ describe('parseWxrStream:畸形 XML → WxrParseError(带 saxes 行列位置
expect(err.message).toContain('unclosed')
})
})
describe('parseWxrStream:wp:post_id / wp:term_id 严格整数解析(审查 Fix 3)', () => {
it.each([
['', null],
['0x10', null],
['12abc', null],
['-7', -7],
['99999999999999999999', null],
['101', 101],
])('post_id 输入 %j → %j', async (raw, expected) => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
`<item><title>t</title><wp:post_id>${raw}</wp:post_id><wp:post_type>post</wp:post_type></item>` +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(itemsOf(events)[0]?.postId).toBe(expected)
})
it('term_id 同一规则:非纯数字 → null', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<wp:term><wp:term_id>0x10</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy></wp:term>' +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(events[0]?.kind === 'term' && events[0].term.termId).toBeNull()
})
})
describe('parseWxrStream:嵌套畸形 fail-fast(审查 Fix 4)', () => {
it('item 内嵌 item:立即 WxrParseError,外层不被截断产出', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<item><title>outer</title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type>' +
'<item><wp:post_id>2</wp:post_id><wp:post_type>post</wp:post_type></item>' +
'</item></channel></rss>'
const { events, error } = await collectUntilError(parseWxrStream(fromString(xml)))
expect(error).toBeInstanceOf(WxrParseError)
expect((error as WxrParseError).message).toContain('嵌套')
expect(events).toStrictEqual([]) // 嵌套发生在 item 中途——错误前无已完成事件
})
it('wp:term 内嵌 wp:term:同样 fail-fast', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<wp:term><wp:term_id>1</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy>' +
'<wp:term><wp:term_id>2</wp:term_id></wp:term>' +
'</wp:term></channel></rss>'
const err = (await captureRejection(collect(parseWxrStream(fromString(xml))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.message).toContain('嵌套')
})
})
describe('parseWxrStream:流契约钉死(审查 Fix 6)', () => {
it('消费方提前 break:上游流被销毁(finally 兜底契约)', async () => {
const stream = fromString(SMALL_XML)
for await (const _ev of parseWxrStream(stream)) {
break // 收到首个事件即退出
}
expect(stream.destroyed).toBe(true)
})
it('text 与 CDATA 混合内容累计为同一字段值', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<item><title>pre<![CDATA[mid]]>post</title><wp:post_id>5</wp:post_id><wp:post_type>post</wp:post_type></item>' +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(itemsOf(events)[0]?.title).toBe('premidpost')
})
})