- validateInputPath 原先只在末段是符号链接时 realpath——中间目录段含 junction (链接在白名单内、目标在外,Windows 免特权可构造)时词法前缀判定被绕过, 会读到白名单外内容(评审 PoC 复现)。改为 lstat 存在性探测后无条件 realpath(abs),整条路径解析到物理位置再做白名单与类型判定;断链场景 realpath 抛 ENOENT 仍归入 INPUT_NOT_FOUND(语义不变) - 回归测试:中间段 junction 逃逸用例(incoming/junc → pkg-outside/escape.xml, 断言 PATH_NOT_ALLOWED + 零落库);已对修复前代码验证为红(逃逸被接受)、 修复后绿 - 注释口径修正:registerSource 的"任何失败零落库零复制"只在校验阶段成立—— 摄取中途失败不回滚已落位副本(content-addressed、幂等无害),但 DB 写入 在全部摄取完成后才开始,任何失败零落库不变 - 计划勾选:Task 7 "CLI 执行机制(钉死)"(已实现并双命令冒烟验证) 全套 261 测试绿(+1);typecheck / lint 绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
17 KiB
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 通道、增量/差量、爬虫。
前置事实(执行者必读)
- Plan 1 已收官:75 测试全绿、21 表、四契约冻结(UniversalRow 任何字段变更须升版——本阶段只消费不修改)。
- Plan 1 移交的责任清单(本计划落实):canonical
status值域钉在转换测试;适配器 property 测试(taxonomyRefs/attachmentRefs 元素形状、sourceId/sourceTermId 非空);如需字段级错误用加法式validateUniversalRow(): issue[](不动布尔守卫)。 - 夹具:
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)。 - 解析器绝不访问网络(saxes 默认不解析外部实体——用测试钉死);媒体输入只接受已解包目录。
- 既有纪律: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;统一用根 tsconfigcompilerOptions.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 抛
- 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:
- 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 解析器 - 安全测试并入:外部实体(
<!ENTITYDOCTYPE)→ 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,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 验收)
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)
- 全量三绿 + 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)+ 全部推送