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

18 KiB
Raw Blame History

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 目录绝对只读。

仓库结构增量

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

  • 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 即用此通道),不写任何东西
  • Step 2 实现服务(copy 流式+sha256 双算、媒体清单哈希、DB 写入、报告输出 artifacts/register//reports/——阶段 2 无 import_jobs,runId 为本次 register 运行生成)+ CLI 接线
  • 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 ...
  • 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)+ 全部推送