Files
cms-website/docs/superpowers/plans/2026-10-20-slice-wxr-adapter-phase2.md
T
2026-10-21 13:28:03 +08:00

156 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SiteForge 纵向切片 · Plan 2:WXR 流式适配器(阶段 2)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 建立 `source:register` 输入登记(content-addressed 快照)与 WXR 流式解析适配器:任意 WXR → 限额安全的流式解析 → UniversalRow 流 + 未支持对象/清洗差异报告;真实 hesupipe WXR(5.5MB)冒烟计数对照基线通过。
**Architecture:** 依据设计基线 `docs/superpowers/specs/2026-10-20-siteforge-vertical-slice-design-v2.md` §7(WXR 导入管道)、§15 阶段 2、ADR-004/005/006/009。新增 `packages/adapter-wordpress`(saxes 流式解析 + PHP unserialize + 映射器 + HTML 清洗,纯函数无 DB);`apps/api` 内新增 source 模块(快照登记服务,复用既有表);`scripts/sf.ts` CLI 入口。Plan 1 地基复用:contracts 守卫、21 表、globalSetup 测试基建、mini.xml 夹具。
**Tech Stack:** saxes(流式 SAX 解析)、commander(CLI)、sanitize-html(白名单清洗)+ 既有栈。**新增依赖三条 ADR 纪律**:锁版本、检查许可证(saxes ISC / commander MIT / sanitize-html MIT 均可)、记入 ADR-016。
**边界(不做)**:内容入库/修订写入(阶段 3)、媒体魔数校验与 usage(阶段 3)、REST 通道、增量/差量、爬虫。
---
## 前置事实(执行者必读)
1. Plan 1 已收官:75 测试全绿、21 表、四契约冻结(**UniversalRow 任何字段变更须升版**——本阶段只消费不修改)。
2. **Plan 1 移交的责任清单**(本计划落实):canonical `status` 值域钉在转换测试;适配器 property 测试(taxonomyRefs/attachmentRefs 元素形状、sourceId/sourceTermId 非空);如需字段级错误用**加法式** `validateUniversalRow(): issue[]`(不动布尔守卫)。
3. 夹具:`tests/fixtures/wxr/mini.xml`(5 item/2 term,含 wppop_sections PHP 串与**有意悬空**的图库引用 203/204);真实基线:`docs/evidence/real-wxr-baseline.md`(1122 item = 855 attachment/129 post/48 product/15 page/68 nav_menu_item/3 wpcf7_contact_form/2 elementor_library/2 wp_navigation;451 term)。
4. 解析器**绝不访问网络**(saxes 默认不解析外部实体——用测试钉死);媒体输入只接受已解包目录。
5. 既有纪律:TDD(红→绿→commit)、测试不碰开发库(globalSetup 临时库 + `DATABASE_URL_TEST`)、身份断言、无包级 test 脚本、typecheck 门禁、cms 目录绝对只读。
## 仓库结构增量
```text
cms-one/
packages/adapter-wordpress/
src/parse/wxr-stream.ts # saxes 流式解析 → AsyncIterable<WxrItem|WxrTerm>
src/parse/php-unserialize.ts # 受限 PHP serialize 解析(wppop_sections)
src/map/item-to-row.ts # WxrItem → UniversalRow(类型路由/meta 提取/known-extras 分流)
src/sanitize/html.ts # 白名单清洗 + 差异收集
src/adapter.ts # 组装:parse→map→sanitize → AsyncIterable<RowResult>
src/limits.ts # 限额配置类型与默认值
test/*.test.ts # 纯单元(不连库)
apps/api/src/modules/source/
register.service.ts # 快照登记(copy→sha256→source_snapshots 落库)
source.module.ts
scripts/sf.ts # CLI(source:register / wxr:probe)
tests/integration/source-register.test.ts
```
---
### Task 1: adapter-wordpress 包骨架 + 限额类型 + 跨包接线决策 ✅ 完成(commit dc4557c + 注释补充,双审通过;78 测试;dist 独立性实证;限额值经真实基线校准余量 10x+)
**Files:** Create `packages/adapter-wordpress/package.json`(@sf/adapter-wp,无 runtime 依赖本任务)、`tsconfig.json`/`tsconfig.build.json`(照 contracts 先例)、`src/limits.ts`;Test `test/limits.test.ts`
- [ ] **Step 0: 跨包接线决策落地(评审 Issue 2 前置)**:`@sf/adapter-wp` 加 workspace 依赖 `@sf/contracts`;**统一用根 tsconfig `compilerOptions.paths`** 把 `@sf/contracts` 与 `@sf/adapter-wp` 映射到各自 `src/index.ts`——一份配置三方消费(包级 tsc 读 paths;vitest.config.ts 的 `resolve.alias` 指向同一映射;tsx 原生读 tsconfig paths)——**tsx 下 import 不经 dist,无需预构建**。结论记入 Task 9 的 ADR-016(不阻塞 Task 6)。
- [ ] Step 1 失败测试:默认限额值冻结(maxWxrBytes 64MB、maxItems 200k、maxDepth 64、maxCdataLength 1MB、**maxFieldLength 1MB**、maxTermCount 50k、maxMetaPerItem 200、timeBudgetMs 120s、phpMaxDepth 8、phpMaxElements 1000)+ 类型字段齐全。注:timeBudgetMs 在 Task 3 以可注入时钟测试(构造函数接受 `now()`),Task 8 实测覆盖
- [ ] Step 2 实现 `WxrLimits` 接口 + `DEFAULT_WXR_LIMITS` 常量(as const)+ 类型锁(照 contracts 单源派生先例锁值域)
- [ ] Step 3 绿 → commit `feat(adapter-wp): 包骨架与限额契约`
### Task 2: PHP unserialize 受限解析器 ✅ 完成(5550952 + 修复 4f90b63,双审通过;108 测试;**实施者纠正了计划中 UTF-8/UTF-16 等价性的错误前提**(CJK 数据源会全军覆没),按字节宽度正确实现并测试钉死;审查修复:phpValueToJson 防 Map JSON 陷阱、错误摘录、PhpError 基类)
**Files:** Create `src/parse/php-unserialize.ts`;Test `test/php-unserialize.test.ts`
- [ ] Step 1 失败测试(TDD 用例集):
- 夹具真实串 `a:1:{i:0;a:2:{s:5:"title";s:8:"SpecsTab";s:7:"content";s:10:"<p>Tab</p>";}}` → 深层对象等值
- 标量串 `s:5:"hello"` / `i:42` / `b:1`
- **长度前缀错误**(s:7:"SpecsTab")→ 抛 `PhpParseError`(不静默)
- 深度超 phpMaxDepth → 抛限额错误;元素数超 phpMaxElements → 抛
- 空串/截断串 → 抛(带位置信息)
- [ ] Step 2 实现递归下降解析器(约 80 行:i:/b:/d:/s:/a: 四类型,NUL 分隔,位置追踪)
- [ ] Step 3 绿 → commit `feat(adapter-wp): PHP serialize 受限解析器`
- [ ] 注:只支持 saxes 产出的字符串输入;**绝不 eval**;深度/元素数双限额
### Task 3: saxes 流式 WXR 解析器 ✅ 完成(25e8b9c + 修复 8edbbad,双审通过;142 测试;审查修复:内存上界注释纠正(真实上界 O(maxWxrBytes))、错误前事件排空确定化(chunk 无关)、post_id 严格安全整数解析、嵌套畸形 fail-fast、类型清零强转。遗留至 Task 4:'-0' 归一、负数 id 策略在映射层统一)
**Files:** Create `src/parse/wxr-stream.ts`(依赖 saxes,锁 ^0.9);Test `test/wxr-stream.test.ts`(用 mini.xml)
- [ ] Step 1 失败测试:
- mini.xml 流式产出 5 个 item + 2 个 term(顺序与文档一致);每 item 含 post_id/post_type/status/post_name/link/title/content:encoded/excerpt/post_date/post_modified/attachment_url/全部 postmeta/以及**原始字节区间**(rawLocation: `wxr://item/<序号>`)
- term 产出:term_id/taxonomy/slug/name
- CDATA 正确解包;`<wp:postmeta>` 聚合为 `{key, value}[]`
- **限额**:maxItems=2 时跑到第 3 个 item 抛限额错误;maxCdataLength=10 时超长 CDATA 抛
- [ ] Step 2 实现:`parseWxrStream(readable: Readable, limits): AsyncGenerator<WxrEvent>`——saxes 前缀解析、状态机聚合 item/term、每 item 完成即 yield(**不整文件进内存**:断言峰值内存——用大夹具在 Task 8 验证,此处以"不 buffer item 列表"的代码结构保证)
- [ ] Step 3 绿 → commit `feat(adapter-wp): saxes 流式 WXR 解析器`
- [ ] **安全测试并入**:外部实体(`<!ENTITY` DOCTYPE)→ saxes 配置拒绝/不解析(`additionalEntities: false` 或等价);畸形 XML 提前闭合 → 抛带位置错误
### Task 4: WxrItem → UniversalRow 映射器 ✅ 完成(c99bbbb + 修复 4c4c43f,双审通过;184 测试;审查修复:原型键击穿防御(constructor/valueOf 对抗)、meta 冲突三方策略冻结、__proto__ 污染防御、prebuild 固化。遗留 Task 6:issues 机器码化;Task 8:nicename 锚点实数据冻结)
**Files:** Create `src/map/item-to-row.ts`;Test `test/item-to-row.test.ts`
- [ ] Step 1 失败测试(**Plan 1 移交责任清单在此落实**):
- post(101) → sourceType 'wp:post'、sourceUrl=permalink、slug=fixture-news-a、seo={title,description}(Yoast)、taxonomyRefs=[{category,31}]、attachmentRefs=[{role:'featured',sourceId:'201'}]、knownFields 含 post_date/post_modified/excerpt、bodyHtml 原文
- product(111) → attachmentRefs 含 featured 202 + gallery 203,204(**悬空保留——歧义不猜**,role 区分)、extras 含 wppop_sections 的**已解析对象**(Task 2 产物;解析失败则原串进 extras.phpRaw + 报告 issue)
- page(121) → basic_page 路由
- attachment(201) → sourceType 'wp:attachment'、attachment_url 进 knownFields
- nav_menu_item / wpcf7_contact_form / elementor_library / wp_navigation → **UnsupportedResult**(带类型与原因),不产 UniversalRow
- **canonical status 值域**:publish/draft/future/pending/private/trash/inherit → 原样;未知值 → 'unknown' + issue(测试钉死映射表)
- **property 断言**:每条产出 row 的 sourceId/sourceType 非空字符串、taxonomyRefs/attachmentRefs 元素形状合法、rawLocation 存在
- **Task 2 审查移交的补测**:d: 作数组键拒绝、s: 内容含 NUL、数字串键 s:1:"0" 形状归属(→Map)、1MB 性能烟雾(防 O(n²) 回归)
- **Task 3 审查移交的映射层策略**:postId 为 null/负值/-0 时映射器如何产出 sourceId(建议:String(postId) 且 null→记录 issue 并跳过该 item,勿静默猜)
- 注:publishedAt/modifiedAt/title 本阶段留在 knownFields(UniversalRow 顶层提升是阶段 3 导入映射的事,有意不提前)
- [ ] Step 2 实现 `mapItem(item, config): MappedResult`(config: 类型路由表 {post:'wp:post', page:'wp:page', product:'wp:product', attachment:'wp:attachment'} + site locale)——纯函数
- [ ] Step 3 绿 → commit `feat(adapter-wp): WXR→UniversalRow 映射器(status 值域/悬空保留/unsupported 分流)`
### Task 5: HTML 白名单清洗 ✅ 完成(8dd6eaf + 修复 7506fef,双审通过;228 测试;审查修复:srcset 审计三子情形精确化(parse-srcset 直依赖)、false 形态防御、**镜像改为直调 launder.naughtyHref(与库同一函数零漂移)**、等价测试参数化 15 对抗样本)
**Files:** Create `src/sanitize/html.ts`(sanitize-html 锁 ^2);Test `test/html-sanitize.test.ts`
- [ ] Step 1 失败测试:
- `<script>`/onclick/`javascript:`/`<iframe>` 未批准 → 移除且进差异清单
- `<a href>`/`<img src|srcset|alt>`/`<p|strong|em|h2-h6|ul|ol|li|table|tbody|tr|td|th>` 白名单保留
- 清洗差异以 `SanitizeDiff[]`(路径/类型 before/after 摘要)返回
- mini.xml 正文(内链 + 外链图片)清洗后结构保留、URL 原样
- [ ] Step 2 实现:`sanitizeBodyHtml(html): { html, diffs }`——白名单配置集中导出(后续阶段可复用到 preview CSP 对齐)
- [ ] Step 3 绿 → commit `feat(adapter-wp): HTML 白名单清洗与差异收集`
### Task 6: 适配器组装 + 报告 ✅ 完成(6573b8b + 修复 073b9ca,双审通过;253 测试;审查修复:IO 错误归 fatalError 三分支、rowErrors 封顶 10k+truncated、timings 契约/纯 term 流/版本漂移三钉)
**Files:** Create `src/adapter.ts`(parse→map→sanitize 串联为 `AsyncIterable<RowResult>`,RowResult = {kind:'row', row}|{kind:'unsupported',…}|{kind:'error',…});`src/report.ts`(计数汇总);Test `test/adapter.test.ts`
- [ ] Step 1 失败测试:mini.xml 全链 → 3 row(post/product/page)+ 2 attachment row + 0 unsupported + 差异计数(wppop 解析 1 成功);报告结构与 spec §7.7 字段对齐(**首条版本块:Adapter/Mapping/Sanitizer 版本常量——sanitize 模块需导出 SANITIZER_VERSION**、按类型 Created/Skipped/Failed/Unsupported、清洗统计、行级错误带 rawLocation)
- [ ] **Task 5 审查移交的对接清单**:HTML 报告渲染 SanitizeDiff.excerpt 时必须转义 `<>&"`(含恶意标记注入面测试);"内容变更"统计用 `html !== input` 字符串比较(diffs.length===0 ≠ 未变更——空值属性删除/裸 & 转义零 diff);清洗统计按 diff.type 分计数;清洗耗时纳入报告耗时统计(timeBudgetMs 是解析侧预算)
- [ ] Step 2 实现(含 validateUniversalRow **加法式**检查:复用 isUniversalRow + 返回 issue[] 的浅扩展,不动 contracts 包)
- [ ] Step 3 绿 → commit `feat(adapter-wp): 全链组装与导入前报告`
### Task 7: source:register 服务与 CLI ✅ 完成(586d715→46b823e→9732e1b→c28c165 四轮,双审通过;263 测试;审查战果:中间段 junction 逃逸堵截(PoC 红绿双向)、DB 事务化+advisory lock(25P02 教科书式重试)、storageRoot 固化入不可变表、并发双池探针验证) ✅ 完成(586d715,260 测试;CLI 冒烟双命令通过——probe mini.xml 5 row/2 term + 报告落盘、register 对临时库建站/快照/幂等复用/越界拒绝,验后 DROP 无残留)
**Files:** Create `apps/api/src/modules/source/{register.service.ts,source.module.ts}`;`scripts/sf.ts`(commander,命令 `source:register --site <key> --wxr <path> [--sitemap <path>] [--media <dir>]`、`wxr:probe --wxr <path>` 只解析出报告不入库);Test `tests/integration/source-register.test.ts`
- [x] Step 1 失败测试(临时库):
- register WXR+sitemap+media 目录 → 三条 source_snapshots 落库(kind 各异、sha256 与文件一致、storage_path 指向 content-addressed 副本)、source_connections upsert 幂等(**判重键 (site_id, name),应用层 select-then-insert**——表无此库级唯一,注释注明)
- **media_bundle 的 sha256 定义(钉死)**:规范化清单哈希 = 目录内全部文件的排序相对路径(分隔符统一 `/`)+ 每文件 sha256 → canonical JSON(键排序、无空白)→ 对该 JSON 串再 sha256。**不含 mtime、不依赖遍历顺序**。清单本体(路径+大小+每文件哈希)存入 snapshot 的 metadata jsonb(兼满足 spec §7.1 媒体清单)
- **重复 register 同文件/同目录** → 复用既有 snapshot(UNIQUE(site,kind,sha256) 生效,不重复行)——媒体目录重跑哈希必须一致(分隔符归一化保证)
- 副本落 `storage/snapshots/<kind>/<sha256前2位>/<sha256>`;原输入不再被后续任务读取
- 路径安全:register 的输入路径必须在允许目录(storage/incoming 或显式绝对路径白名单校验——拒绝越界读取);**wxr:probe 例外:只读诊断命令,接受任意可读路径(Task 8 直读 cms 即用此通道),不写任何东西**
- [x] Step 2 实现服务(copy 流式+sha256 双算、媒体清单哈希、DB 写入、报告输出 **artifacts/register/<runId>/reports/**——阶段 2 无 import_jobs,runId 为本次 register 运行生成)+ CLI 接线
- [x] **CLI 执行机制(钉死)**:根 devDependency 加 `tsx`;根 package.json scripts 加 `"sf": "tsx scripts/sf.ts"`;`commander` **定为根依赖**(CLI 在根,adapter-wp 纯函数包不消费);CLI 经 tsx 直吃 TS 源码(**无需预构建 dist**;apps/api 的 Nest 装饰器源码同样由 tsx 运行)。Task 8 命令相应为 `pnpm sf wxr:probe --wxr ...`
- [x] Step 3 绿(75+新增)→ commit `feat(source): 输入登记服务与 sf CLI(content-addressed 快照)`
### Task 8: 真实 WXR 冒烟(阶段 2 验收)✅ 完成(commit 21ef372,规格审查通过含复跑——**18 项计数全对、内存 62MiB<100MiB**;重大发现:真实 WXR term 表缺 category/post_tag(覆盖内 nicename≡slug 706/706),阶段 3 须自建缺失 term)
**Files:** Create `scripts/smoke-real-wxr.mjs`(wxr:probe 真实文件 → 计数 JSON);`docs/evidence/phase2-smoke.md`
- [ ] Step 1: `pnpm sf wxr:probe --wxr "D:/project/cms/storage/wxr/fd04d6ba8a1316b6.xml"`(**只读 cms**;probe 为诊断命令接受任意可读路径)→ 计数对照 real-wxr-baseline.md:855/129/48/15 attachment/post/product/page + 75 unsupported(nav_menu_item 68 等)+ 451 term,逐项一致才算过
- [ ] Step 2: 峰值内存记录——smoke 脚本内 `setInterval(100ms)` 采样 `heapUsed` 与 `rss` 取 max(瞬时值无意义);流式断言:解析 5.5MB 文件峰值 heapUsed < 100MB(不整读)
- [ ] Step 3: 耗时记录;结果写 evidence 文档;commit `test(evidence): 阶段2真实WXR冒烟通过`
### Task 9: 阶段 2 收尾
- [ ] ADR-016(saxes/commander/sanitize-html 选型与许可证);根 typecheck 链加 @sf/adapter-wp;lint 全绿;contracts 首个消费者接线验证(adapter-wp workspace 依赖 contracts——**Plan 1 终审前瞻项落实**:import 路径用 dist 还是 src 的决策一并入 ADR-016)
- [ ] **Task 7 审查移交**:① connection (site_id,name) 库级唯一索引须在第二个写入方(REST 通道)出现前完成 ② media_bundle→WXR attachment URL 的映射策略(uploads 路径约定)记入阶段 3 计划输入 ③ REST 化时重审:白名单 TOCTOU、cwd 自动放行、connection.kind 枚举扩 ④ sanitize-html engines>=22.12 与根 engines>=22 的口径(升根或记 ADR)
- [ ] 全量三绿 + push;plan 勾选;**移交阶段 3 清单**(内容/分类/媒体入库:media 魔数校验、source_records 落库、稳定身份 upsert、checksum skip、route_registry 生成;**续传携带:Task 0.2 cms 数据备份(用户暂缓,阶段 6 L3 验收前必须补做**——责任链不断)
## 验收(对照 spec §15 阶段 2)
- [ ] `source:register` 三类输入登记幂等,快照 content-addressed 且不可变
- [ ] 脱敏夹具全链全绿(mini.xml → 5 row + 0 意外 unsupported)
- [ ] 大文件不整读内存(5.5MB 峰值 <100MB)
- [ ] 恶意/畸形输入安全失败(外部实体/超限/截断——带位置错误,不崩溃不静默)
- [ ] 解析器不访问网络(测试钉死)
- [ ] 真实 WXR 冒烟计数与基线逐项一致;unsupported 75 条分类清晰
- [ ] 三绿(lint/typecheck/test)+ 全部推送