# 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 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 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:"

Tab

";}}` → 深层对象等值 - 标量串 `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 正确解包;`` 聚合为 `{key, value}[]` - **限额**:maxItems=2 时跑到第 3 个 item 抛限额错误;maxCdataLength=10 时超长 CDATA 抛 - [ ] Step 2 实现:`parseWxrStream(readable: Readable, limits): AsyncGenerator`——saxes 前缀解析、状态机聚合 item/term、每 item 完成即 yield(**不整文件进内存**:断言峰值内存——用大夹具在 Task 8 验证,此处以"不 buffer item 列表"的代码结构保证) - [ ] Step 3 绿 → commit `feat(adapter-wp): saxes 流式 WXR 解析器` - [ ] **安全测试并入**:外部实体(``/onclick/`javascript:`/`