diff --git a/README.md b/README.md index 402526c..f5bb93f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ WordPress 站点到静态站点迁移引擎:读取 WXR/Sitemap/媒体归档 ## 快速开始 -前置:Node.js >= 22、Docker、[corepack](https://nodejs.org/api/corepack.html)。 +前置:Node.js >= 22.12、Docker、[corepack](https://nodejs.org/api/corepack.html)。 ```bash corepack enable # 激活 pnpm(或自行安装 pnpm@10) @@ -14,6 +14,13 @@ pnpm db:migrate # 开发库全量迁移(apps/api/drizzle pnpm db:test # 一键验收:起库 + 跑全部测试(测试用临时库,开发库零写入) ``` +WXR 适配器(阶段 2)速览——`pnpm sf` CLI(tsx 直吃 TS,无需构建): + +```bash +pnpm sf wxr:probe --wxr path/to/export.xml # 只读诊断:流式解析→映射→清洗→计数报告(不入库) +pnpm sf source:register --site --wxr [--sitemap ] [--media ] # 快照登记(content-addressed、幂等) +``` + 日常质量门(等价于 CI): ```bash @@ -25,11 +32,12 @@ pnpm lint && pnpm typecheck && pnpm test ## 目录结构 ``` -apps/api/ NestJS standalone 骨架 + Drizzle schema/迁移(21 表,数据库约束即规格) +apps/api/ NestJS standalone 骨架 + Drizzle schema/迁移(21 表,数据库约束即规格)+ source 快照登记 packages/contracts/ UniversalRow / Route / Render / Theme 跨源契约(冻结,变更须升版) +packages/adapter-wordpress/ WXR 适配器(saxes 流式解析 + PHP unserialize + 映射 + HTML 白名单清洗,纯函数无 DB) tests/ 单元 + 集成测试(fixtures:WXR/Sitemap/媒体样例) infra/ docker-compose:PostgreSQL 16(端口 5434,避开 cms 项目 5433) -scripts/ migrate-test.sh 等运维脚本 +scripts/ migrate-test.sh 等运维脚本 · sf.ts(source:register / wxr:probe CLI) docs/ adr/ 决策记录 · evidence/ 基线证据 · superpowers/ 规格与计划 ``` @@ -37,10 +45,11 @@ docs/ adr/ 决策记录 · evidence/ 基线证据 · superpowers/ - 设计基线(表结构/技术决策/阶段划分):`docs/superpowers/specs/2026-10-20-siteforge-vertical-slice-design-v2.md` - 阶段 0+1 实施计划:`docs/superpowers/plans/2026-10-20-slice-foundation-phase0-1.md` -- 架构决策记录(ADR-001~015):`docs/adr/` +- 架构决策记录(ADR-001~016):`docs/adr/` - 基线证据(环境/真实 WXR):`docs/evidence/` ## 当前状态 - **阶段 1 已完成**:契约冻结、21 表数据模型全量落地(不可变触发器/唯一约束/CHECK 均落数据库并被 75 项测试锁定)、空环境一键启动+迁移验收通过。 -- **下一步:阶段 2**——WXR 适配器(流式 SAX 解析 → UniversalRow),计划基于阶段 1 经验另立。 +- **阶段 2 已完成**:WXR 流式适配器(saxes 流式解析 → PHP unserialize → UniversalRow 映射 → HTML 白名单清洗 → 导入前报告)、`source:register` 快照登记(content-addressed、幂等)与 `pnpm sf` CLI;263 测试全绿,真实 hesupipe WXR(5.5MB)冒烟 18 项计数对照全过(峰值堆 62MiB < 100MiB,证据见 `docs/evidence/phase2-smoke.md`)。 +- **下一步:阶段 3**——内容/分类/媒体入库(source_records 落库、稳定身份 upsert、checksum skip、route_registry 生成);输入清单见 Plan 2 文末"移交阶段 3 清单"。 diff --git a/docs/adr/ADR-016-阶段2依赖与接线决策.md b/docs/adr/ADR-016-阶段2依赖与接线决策.md new file mode 100644 index 0000000..79f2535 --- /dev/null +++ b/docs/adr/ADR-016-阶段2依赖与接线决策.md @@ -0,0 +1,23 @@ +# ADR-016:阶段 2 新增依赖与跨包接线 + +- **状态**:已采纳(Plan 2 Task 9 收尾,阶段 2;许可证已逐项核验——saxes ISC,commander/sanitize-html/parse-srcset/launder/tsx 均 MIT) + +## 决策一:新增依赖(选型 / 版本范围 / 定位) + +1. **saxes `^6.0.0`**(@sf/adapter-wp 运行时依赖)——WXR 流式 SAX 解析的唯一解析库。**勘误**:Plan 2 Task 3 曾误写"锁 ^0.9"(0.x 是多年前的旧版本线),实施时按 npm 实际主线锁 `^6.0.0`;默认不解析外部实体(XXE 面由测试钉死)。`versions.ts` 的 `PARSER_VERSION` 常量须与实际解析版本同步(当前 `'saxes 6.0.0'`)。 +2. **commander `^14.0.0`(根依赖)**——CLI 入口在仓库根 `scripts/sf.ts`,而 @sf/adapter-wp 定位纯函数包、不消费 CLI 框架;commander 放根,不给库包带入无关运行时依赖。 +3. **清洗三件套 `sanitize-html ^2` + `parse-srcset ^1` + `launder ^1`**(@sf/adapter-wp 运行时依赖,三条 range 并存)——sanitize-html 是清洗本体;srcset/imagesrcset 审计用 parse-srcset(sanitize-html 内部即同库依赖);URL 拒绝判定**直接调用 `launder.naughtyHref`**——sanitize-html 2.18 内部即解构调用同一函数,镜像判定与库本体零漂移(依赖升级时由参数化对抗测试护栏兜底)。三件套用同一主版本 range 跟随解析:launder/parse-srcset 本是 sanitize-html 的传递依赖,若单独锁死版本,会造成"镜像调用的 launder"与"库内部用的 launder"版本 skew。类型:@types/sanitize-html、@types/parse-srcset 走 devDependencies;launder 无官方类型,本包自带 d.ts 垫片(`src/types/launder.d.ts`)。 +4. **tsx `^4`(根 devDependency)**——CLI 直吃 TS 源码:`pnpm sf` = `tsx scripts/sf.ts`,无需预构建 dist(apps/api 的 Nest 装饰器源码同样可跑);tsx 属开发工具链,不进任何运行时依赖。 + +## 决策二:跨包接线 + +5. **根 tsconfig paths 三方消费(单源)**:`@sf/contracts` 与 `@sf/adapter-wp` 到各自 `src/index.ts` 的映射只在根 `tsconfig.json` compilerOptions.paths 维护一份——包级 tsc 经 extends 继承;vitest 不读 tsconfig paths,由 `vitest.config.ts` 的 `resolve.alias` 镜射同一映射;tsx 原生读 tsconfig paths。三方(包级 tsc / vitest / tsx)全部直连 `src/index.ts`——日常开发链 import 不经 dist、无预构建依赖。 +6. **build 侧 dist + prebuild**:@sf/adapter-wp 的 `tsconfig.build.json` 把 `@sf/contracts` 改指 `packages/contracts/dist/src/index.d.ts`(emit 程序不得包含包外源文件,TS6059),由 prebuild 先 `pnpm --filter @sf/contracts build` 供给 dist 声明。dist 只为发布产物完整性负责;typecheck / test / CLI 通道不依赖它。 +7. **tsconfig 双链型并存**:contracts extends `tsconfig.base.json`(无跨包 import,不需要 paths);adapter-wp extends **根 tsconfig.json**(为继承 baseUrl/paths 跨包映射)。两条 extends 链并存是有意的:包是否需要跨包映射决定挂哪条链,不为形式统一强行并链。**src-vs-dist 的最终口径即上述 5+6:开发链 src 直连、build 链 dist+prebuild,双轨并存**(Plan 1 终审前瞻项在此落定)。 + +## 决策三:engines 口径 + +8. **根 engines 升 `>=22.12`**:sanitize-html 2.18 的 engines 要求 node `>=22.12.0`,根原口径 `>=22` 会放行 sanitize-html 不支持的 22.0–22.11。升根一行对齐(CI 的 node 22 取最新 22.x、本机 24 均满足);否决的替代方案是 pin sanitize-html 旧版——为迁就旧 patch 版 node 反向锁死清洗库,不取。后续新增依赖若 engines 高于根,同理评估升根而非降依赖。 + +- **理由**:全部依赖均为单一职责引入(解析/清洗/CLI/TS 执行各一),无重叠;版本范围统一锁主版本(`^`),与"锁版本 + 核许可证 + 记 ADR"的新增依赖三纪律一致;跨包接线坚持单源配置(paths 一份、alias 镜射),把 skew 面压到最小。 +- **权衡/陷阱**:① vitest alias 是根 paths 的人工镜射,新增 workspace 包时两处要同步改;② build 链与开发链对 `@sf/contracts` 的解析目标不同(dist 声明 vs src),dist 过期只影响产物不影响日常——发布前跑 build 即可暴露;③ launder 镜像判定的等价性依赖"sanitize-html 内部继续用 launder 同一函数",升级 sanitize-html 大版本时须重验(对抗测试在 `test/html-sanitize.test.ts`);④ engines 对齐的是传递依赖里最严的要求,属"就高"策略,接受它对低版本 node 的排斥。 diff --git a/docs/superpowers/plans/2026-10-20-slice-wxr-adapter-phase2.md b/docs/superpowers/plans/2026-10-20-slice-wxr-adapter-phase2.md index 31965fe..5e39f02 100644 --- a/docs/superpowers/plans/2026-10-20-slice-wxr-adapter-phase2.md +++ b/docs/superpowers/plans/2026-10-20-slice-wxr-adapter-phase2.md @@ -138,18 +138,30 @@ cms-one/ - [ ] Step 2: 峰值内存记录——smoke 脚本内 `setInterval(100ms)` 采样 `heapUsed` 与 `rss` 取 max(瞬时值无意义);流式断言:解析 5.5MB 文件峰值 heapUsed < 100MB(不整读) - [ ] Step 3: 耗时记录;结果写 evidence 文档;commit `test(evidence): 阶段2真实WXR冒烟通过` -### Task 9: 阶段 2 收尾 +### Task 9: 阶段 2 收尾 ✅ 完成(ADR-016 定稿 / typecheck 四段链 / 根 engines 升 >=22.12 / 移交阶段 3 清单 / README,同批一个 docs commit;三绿 263 不变;不 push——推送由用户执行) -- [ ] 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 验收前必须补做**——责任链不断) +- [x] ADR-016(saxes/commander/sanitize-html 选型与许可证);根 typecheck 链加 @sf/adapter-wp;lint 全绿;contracts 首个消费者接线验证(adapter-wp workspace 依赖 contracts——**Plan 1 终审前瞻项落实**:import 路径用 dist 还是 src 的决策一并入 ADR-016) +- [x] **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)——①~④ 均已沉淀至文末"移交阶段 3 清单",④ 已落 ADR-016 决策三(根 engines 升 >=22.12) +- [x] 全量三绿 + plan 勾选;**移交阶段 3 清单**(见文末新节定稿);push 由用户执行(本任务不 push) ## 验收(对照 spec §15 阶段 2) -- [ ] `source:register` 三类输入登记幂等,快照 content-addressed 且不可变 -- [ ] 脱敏夹具全链全绿(mini.xml → 5 row + 0 意外 unsupported) -- [ ] 大文件不整读内存(5.5MB 峰值 <100MB) -- [ ] 恶意/畸形输入安全失败(外部实体/超限/截断——带位置错误,不崩溃不静默) -- [ ] 解析器不访问网络(测试钉死) -- [ ] 真实 WXR 冒烟计数与基线逐项一致;unsupported 75 条分类清晰 -- [ ] 三绿(lint/typecheck/test)+ 全部推送 +- [x] `source:register` 三类输入登记幂等,快照 content-addressed 且不可变 +- [x] 脱敏夹具全链全绿(mini.xml → 5 row + 0 意外 unsupported) +- [x] 大文件不整读内存(5.5MB 峰值 <100MB) +- [x] 恶意/畸形输入安全失败(外部实体/超限/截断——带位置错误,不崩溃不静默) +- [x] 解析器不访问网络(测试钉死) +- [x] 真实 WXR 冒烟计数与基线逐项一致;unsupported 75 条分类清晰 +- [x] 三绿(lint/typecheck/test,263 测试);推送由用户执行(Task 9 不 push) + +--- + +## 移交阶段 3 清单(Plan 2 收官定稿——散落各任务的移交项汇总,阶段 3 计划的必读输入) + +1. **taxonomyRefs 解析:wp:term 表不完备(真实数据实证)**——本 WXR 的 term 表缺 category(148 引用/5 nicename)与 post_tag(659 引用/471 nicename)条目(WordPress 7.1.3 导出行为)。阶段 3 做 slug→term 解析时须按 **(domain, nicename) 自建缺失 term**(item 引用的 value 作 name),不能只依赖 wp:term 事件。锚点设计已被实数据正向验证:term 表覆盖的 taxonomy 内 nicename≡slug 100%(706/706,见 `docs/evidence/phase2-smoke.md`)。 +2. **media_bundle → WXR attachment URL 映射策略**——media 清单(snapshot metadata.manifest,相对路径)与 WXR item 的 attachment_url/图库引用之间的映射须定策略(WordPress uploads 路径约定 `wp-content/uploads///`;路径对拍 + 失配报告)——消费清单时面对。 +3. **connection (site_id, name) 库级唯一索引**——现为应用层 select-then-insert(事务 + advisory lock 保护,register.service.ts 注释已注明);**第二写入方(REST 通道)出现前**必须落库级唯一约束。 +4. **REST 通道落地时重审**——路径白名单 TOCTOU(校验与 open 之间的窗口)、CLI cwd 自动放行的去留、connection.kind 枚举扩('cli' 之外的新来源值)。 +5. **导入管道消费接口**——registerSource 返回值与 snapshot metadata 形状(含 storageRoot/manifest)是阶段 3 的输入契约,消费侧若需变形优先加法扩展;source_records 落库用稳定身份键(ADR-006);checksum skip 语义:changed=false → skip(报告 created/skipped 即导入前投影,spec §7.5)。 +6. **续传携带:Task 0.2 cms 数据备份**(用户暂缓)——**阶段 6 L3 验收前必须补做,责任链不断**。 +7. **versions.ts bump 纪律提醒**——映射表(DEFAULT_TYPE_ROUTES / STATUS_MAP / META_KEYS)或清洗白名单(SANITIZE_CONFIG)任何实质变更,须 bump 对应 MAPPING_VERSION / SANITIZER_VERSION(bump 语义 JSDoc 已冻结于 `packages/adapter-wordpress/src/versions.ts`);下游以版本块判断是否需要重新标准化(spec §7.5)。 diff --git a/package.json b/package.json index d45ad0a..8551b9d 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,11 @@ "type": "module", "packageManager": "pnpm@10.7.1", "engines": { - "node": ">=22" + "node": ">=22.12" }, "scripts": { "test": "vitest run", - "typecheck": "tsc -p tsconfig.json --noEmit && pnpm --filter @sf/api typecheck && pnpm --filter @sf/contracts typecheck", + "typecheck": "tsc -p tsconfig.json --noEmit && pnpm --filter @sf/api typecheck && pnpm --filter @sf/contracts typecheck && pnpm --filter @sf/adapter-wp typecheck", "lint": "eslint .", "db:up": "docker compose -f infra/docker-compose.yml up -d --wait", "db:migrate": "pnpm --filter @sf/api db:migrate", diff --git a/packages/adapter-wordpress/tsconfig.build.json b/packages/adapter-wordpress/tsconfig.build.json index 1252a32..895db77 100644 --- a/packages/adapter-wordpress/tsconfig.build.json +++ b/packages/adapter-wordpress/tsconfig.build.json @@ -6,7 +6,7 @@ // Task 4 起 src 引用 @sf/contracts(type-only):emit 程序不得含包外源文件 // (rootDir 下 TS6059),故 build 侧改经 contracts 预构建 dist 声明解析(先 // `pnpm --filter @sf/contracts build`);typecheck/vitest/tsx 仍走根 paths 的 - // src 直连。src-vs-dist 的最终统一归 ADR-016(Task 9)。paths 相对继承的根 + // src 直连。src-vs-dist 双轨决策见 ADR-016。paths 相对继承的根 // baseUrl(仓库根)解析。 "paths": { "@sf/contracts": ["./packages/contracts/dist/src/index.d.ts"] diff --git a/packages/adapter-wordpress/tsconfig.json b/packages/adapter-wordpress/tsconfig.json index 9cd5925..0e5c251 100644 --- a/packages/adapter-wordpress/tsconfig.json +++ b/packages/adapter-wordpress/tsconfig.json @@ -1,4 +1,4 @@ -// extends 根而非 base:为继承 baseUrl/paths 跨包映射(contracts 走 base 不需要)——决策将记入 ADR-016 +// extends 根而非 base:为继承 baseUrl/paths 跨包映射(contracts 走 base 不需要)——决策见 ADR-016 { "extends": "../../tsconfig.json", "compilerOptions": {