diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md
index 3908911..b1b326f 100644
--- a/.trellis/spec/pipeline/cli-and-stages.md
+++ b/.trellis/spec/pipeline/cli-and-stages.md
@@ -1464,6 +1464,72 @@ parity 校验依赖 stage 的 stdout 标记来判断阶段是否跑到(如 `SC
---
+## Native Road Parity
+
+### 1. Scope / Trigger
+
+拆分 native road compiler、移动其模块,或声称 native-road JSON / GeoJSON 未变化时,
+用 `scripts/road-parity.js`。它独立于 build stage,只读现有 native-road 输出。
+
+### 2. Signatures
+
+```bash
+node scripts/road-parity.js --config config/areas/.json --snapshot
+node scripts/road-parity.js --config config/areas/.json --compare
+```
+
+`--config` 必填,且 `--snapshot` 与 `--compare` 必须二选一。
+
+### 3. Contracts
+
+- 快照覆盖 `outputs//native-road/` 下全部文件和同级
+ `native-traffic-signals.json`。
+- 普通 JSON 递归按对象 key 排序后生成 `contentHash`。
+- GeoJSON 同时生成排序 feature 的 `contentHash` 与原序 `orderHash`;任一不同都算
+ parity 失败。
+- 绝对路径归一化为 `/...` 或 `/`,随机
+ `native-road-*` staging 名归一化为 ``。坐标不做容差或舍入。
+
+### 4. Validation & Error Matrix
+
+| 条件 | 结果 |
+|---|---|
+| 缺 `--config` 或同时/均未传 snapshot、compare | 打印 usage,退出非零 |
+| native-road 输出目录不存在 | 抛出 `Native road output not found`,退出非零 |
+| baseline 缺文件、出现额外文件或任一 hash/count/bytes 不同 | 每项打印 `ROAD_PARITY_DIFF`,退出非零 |
+| 完全一致 | 打印 `ROAD_PARITY_OK`,退出 0 |
+
+### 5. Good/Base/Bad Cases
+
+- Good: 移动编译器前后,两个 hash 均一致。
+- Base: 绝对 OSM 输入路径变化但归一化后内容一致。
+- Bad: 只比较排序后的 GeoJSON 内容并忽略 `orderHash`,会掩盖输出顺序漂移。
+
+### 6. Tests Required
+
+- `npm run test:road-parity`:相等基线、顺序 hash 变化与缺文件都应被断言。
+- `node --check scripts/road-parity.js`。
+- 对每个受支持区域运行 `--compare` 对已提交基线自比对。
+- 迁移前先连续编译两次;只有 control 实验全绿,基线才可作为 oracle。
+
+### 7. Wrong vs Correct
+
+Wrong:
+
+```js
+// Sorting hides a changed feature order.
+assert.equal(sortedContentHash, baseline.contentHash);
+```
+
+Correct:
+
+```js
+assert.equal(contentHash, baseline.contentHash);
+assert.equal(orderHash, baseline.orderHash);
+```
+
+---
+
## 反模式
| 反模式 | 后果 |
diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/check.jsonl b/.trellis/tasks/08-25-rc-p0-contract-baseline/check.jsonl
new file mode 100644
index 0000000..9dd3234
--- /dev/null
+++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/check.jsonl
@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/design.md b/.trellis/tasks/08-25-rc-p0-contract-baseline/design.md
new file mode 100644
index 0000000..720823f
--- /dev/null
+++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/design.md
@@ -0,0 +1,81 @@
+# Phase 0 技术设计
+
+契约本体见父任务 `design.md` §1,本文件只补 Phase 0 自身的实现设计。
+
+## 1. 确定性核查的具体做法
+
+编译器的潜在非确定性来源,按可疑度排序:
+
+| 来源 | 表现 | 处置 |
+|---|---|---|
+| `fs.mkdtempSync(path.join(pipelineDir, "native-road-"))` | staging 目录名随机 | 若名字出现在 `compiled.json` 的 `source` 字段里,需归一化 |
+| `compiled.json` 内的绝对路径 | `source.osm` / `source.overrides` / `source.trafficSignals` 是绝对路径 | 归一化为相对 repo root |
+| 时间戳 | 未确认是否存在 | 核查 `compile-native-roads.js` 写入的 metadata |
+| `Map` / `Set` 迭代顺序 | JS 保证插入序,若插入源本身有序则确定 | 观测两次运行 `orderHash` 是否一致即可暴露 |
+| 浮点累加顺序 | 几何坐标末位抖动 | 若出现,说明存在顺序依赖,属真 bug,需修 |
+| `crypto` 随机(`traffic-signals.js` require 了 `crypto`) | 信号 ID 可能含随机成分 | 重点核查 —— `native-traffic-signals.json` 是 `loadOrGenerate`,已存在则复用,首次生成可能不确定 |
+
+**核查顺序**:
+1. 备份两个有效区域的 `native-road/` 与 `native-traffic-signals.json`
+2. 删除 `native-road/`(保留 signals,因为 `loadOrGenerate` 语义是已存在则复用)
+3. 跑第一次 → snapshot A
+4. 再删 `native-road/`,跑第二次 → snapshot B
+5. diff A/B → volatile 字段清单
+6. 额外一轮:删掉 signals 文件也重生成一次,确认 signal ID 是否确定
+
+## 2. `scripts/road-parity.js` 设计
+
+```
+用法:
+ node scripts/road-parity.js --config --snapshot
+ node scripts/road-parity.js --config --compare
+```
+
+### 归一化规则(写死在脚本里,并回写进契约文档)
+
+1. **路径**:任何绝对路径 → 相对 repo root;staging 目录名 → 字面量 ``
+2. **JSON**:递归按 key 排序后 `JSON.stringify`,再 hash
+3. **GeoJSON**:
+ - `contentHash` = features 按 `properties.native_id`(缺失则按几何首坐标)排序后 hash
+ - `orderHash` = 原序 hash
+ - 两者都记录;比对时 `contentHash` 不一致 = 语义变化(严重),
+ 仅 `orderHash` 不一致 = 顺序抖动(需解释但可能可接受)
+4. **浮点**:不做舍入。坐标变化就是变化,不允许用容差掩盖
+ (拆分是纯搬迁,任何坐标位移都是 bug)
+
+### 输出形状
+
+```json
+{
+ "contract": "native-road-package/v1",
+ "areaId": "fengshu-er-road",
+ "files": {
+ "compiled.json": { "contentHash": "…", "bytes": 12345 },
+ "diagnostics.json": { "contentHash": "…", "bytes": 678, "count": 42 },
+ "layers/road_surface.geojson": {
+ "contentHash": "…", "orderHash": "…", "features": 128 }
+ },
+ "volatileExcluded": ["source.osm", "source.overrides", ""]
+}
+```
+
+`count` / `features` 是冗余的人类可读字段 —— hash 变了但计数没变,
+说明是内容变化;计数也变了,说明是增删。加速归因。
+
+## 3. 基线目录布局
+
+```
+.trellis/tasks/08-25-road-compiler-extraction/baseline/
+ fengshu-er-road.json
+ nantaizi-lake-innovation-valley.json
+ README.md ← 记录生成时的 git commit、命令、volatile 字段清单
+```
+
+`README.md` 必须记录生成基线时的 commit hash,否则将来无法判断
+"基线是在哪个代码状态下产生的"。
+
+## 4. 为什么校验脚本放宿主而不是直接放未来的编译器仓库
+
+Phase 0 时编译器仓库还不存在。脚本先落宿主 `scripts/`,
+Phase 2 随编译器一起搬走,届时宿主保留一个薄封装(或直接调编译器包的 CLI)。
+这样 Phase 1 的 parity 验证不需要等仓库拆分。
diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.jsonl b/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.jsonl
new file mode 100644
index 0000000..9dd3234
--- /dev/null
+++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.jsonl
@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.md b/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.md
new file mode 100644
index 0000000..1b6c675
--- /dev/null
+++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.md
@@ -0,0 +1,118 @@
+# Phase 0 执行计划
+
+## 前置
+
+- [ ] 分支:`experiment/road-compiler-rethink`(已在)
+- [ ] 确认工作区干净(`.trellis/tmp-*.js` 五个未跟踪文件与本任务无关,可先清理或忽略)
+- [ ] 阅读父任务 `design.md` §1 契约定义
+
+## Step 1 — 摸清两区域当前状态
+
+```bash
+ls config/areas/
+for d in outputs/*/native-road/layers; do echo "$d: $(ls $d | wc -l)"; done
+```
+
+判定:`hanyang-block` 为废案,不纳入本阶段;只核查两个有效区域的配置与当前产物。
+
+- [x] 逐区域检查 config 的 `nativeRoad.edgeLines` / `junctionTemplates.enabled`
+- [x] 将两个有效区域的配置差异记录在基线 README
+
+**门槛**:两个有效区域的图层差异有书面解释。
+
+## Step 2 — 确定性核查
+
+```bash
+# 备份
+cp -r outputs//native-road /tmp/rc-baseline-a
+# 重编
+rm -rf outputs//native-road
+npm run road:compile -- --config config/areas/.json
+cp -r outputs//native-road /tmp/rc-baseline-b
+# 比对
+diff -r /tmp/rc-baseline-a /tmp/rc-baseline-b
+```
+
+- [x] 对两个有效区域各跑一遍
+- [x] 记录所有差异字段 → volatile 清单
+- [x] 额外一轮:删掉 `native-traffic-signals.json` 重生成,
+ 确认信号 ID 是否确定(`traffic-signals.js` require 了 `crypto`,重点核查)
+- [ ] 若发现**几何坐标级**的非确定性 → 停止本阶段,先开 bug 任务修掉
+
+**门槛** 🔴:确定性已证明,或非确定性已定位并修复。这是全案的地基。
+
+## Step 3 — 写 `scripts/road-parity.js`
+
+按 `design.md` §2 实现。
+
+- [x] `--snapshot` 生成归一化 checksum 清单
+- [x] `--compare` 比对并逐文件报差异,不一致时非零退出
+- [x] GeoJSON 双 hash(`contentHash` 排序后 / `orderHash` 原序)
+- [x] volatile 字段按 Step 2 结论归一化
+- [x] 自验:同一区域连跑两次 snapshot,`--compare` 必须通过
+
+```bash
+node scripts/road-parity.js --config config/areas/fengshu-er-road.json --snapshot /tmp/s1.json
+node scripts/road-parity.js --config config/areas/fengshu-er-road.json --compare /tmp/s1.json
+echo "exit=$?" # 必须为 0
+```
+
+**门槛**:脚本对未改动的代码报告一致。
+
+## Step 4 — 建立两区域基线
+
+```bash
+mkdir -p .trellis/tasks/08-25-road-compiler-extraction/baseline
+for a in fengshu-er-road nantaizi-lake-innovation-valley; do
+ rm -rf outputs/$a/native-road
+ npm run road:compile -- --config config/areas/$a.json
+ node scripts/road-parity.js --config config/areas/$a.json \
+ --snapshot .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json
+done
+```
+
+- [x] 两个基线 JSON 生成
+- [x] 写 `baseline/README.md`:生成时的 git commit、命令、volatile 字段清单、
+ 两个有效区域图层差异的解释
+- [x] 反向自验:再跑一次 `--compare` 两个基线全部通过
+
+## Step 5 — K2 决策
+
+- [x] 检查 `comparison.json` 当前是否真被消费(grep 宿主与 workbench)
+- [x] 若有 → 方案 B(可选注入),在契约里定义 `comparisonDir` 为 optional
+
+## Step 6 — 落契约文档
+
+- [x] 写 `docs/native-road-package-v1.md`,内容源自父任务 `design.md` §1
+- [x] 补入 Step 2 的 volatile 字段清单
+- [x] 补入 Step 5 的 K2 决策
+- [x] 在 `docs/changelog.md` 记一行
+
+## 验证命令汇总
+
+```bash
+# parity 两区域全绿
+for a in fengshu-er-road nantaizi-lake-innovation-valley; do
+ node scripts/road-parity.js --config config/areas/$a.json \
+ --compare .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json || echo "FAIL $a"
+done
+
+# 既有测试未破
+npm run test:native-road
+npm run test:road-workbench
+
+# 确认无生产代码改动
+git diff --stat -- scripts/lib blender
+```
+
+最后一条必须为空输出 —— Phase 0 不碰生产代码。
+
+## Review Gate
+
+全部 AC0.1–AC0.6 打勾后,再进 Phase 1。**基线不可靠时不要前进。**
+
+## Rollback
+
+本阶段纯新增(文档 / 脚本 / 基线数据)。回滚 = 删除新增文件。
+唯一例外:Step 2 若发现并修复了非确定性 bug,那部分是真实代码改动,
+应拆成独立 commit 并单独保留。
diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/prd.md b/.trellis/tasks/08-25-rc-p0-contract-baseline/prd.md
new file mode 100644
index 0000000..be74352
--- /dev/null
+++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/prd.md
@@ -0,0 +1,84 @@
+# Phase 0:冻结契约与 parity 基线
+
+父任务:`.trellis/tasks/08-25-road-compiler-extraction/`
+契约权威定义:父任务 `design.md` §1
+
+## Goal
+
+在任何代码移动之前,把 `native-road-package/v1` 契约落成仓库内文档,
+并为两个有效区域建立可复跑的逐文件 parity 基线,作为全案唯一 oracle。
+
+**本阶段不移动、不重构任何生产代码。** 只新增文档、基线数据和一个校验脚本。
+
+## Requirements
+
+### R0.1 契约文档
+
+- 把父任务 `design.md` §1(输入契约 / 输出契约 / 消费方式)落成
+ `docs/native-road-package-v1.md`。
+- 文档必须列全 12 个图层名、`compiled.json` 顶层结构、`diagnostics.json` 记录形状、
+ stdout 标记格式。
+- Phase 2 会把该文档搬进编译器仓库,此处先落在宿主。
+
+### R0.2 确定性前置核查 🔴
+
+**基线只有在编译器确定性的前提下才有意义。** 必须先证明这一点。
+
+- 对同一输入连续跑两次 `road:compile`,比较全部输出文件。
+- 识别并记录所有 volatile 字段(时间戳、绝对路径、staging 目录名、
+ 任何来自 `mkdtemp` 的随机名、Map/Set 迭代顺序敏感的输出)。
+- 校验脚本必须归一化或排除这些字段。
+- 若发现真实的非确定性(相同输入产出不同几何),**必须先修掉再继续**,
+ 否则整个拆分没有 oracle。
+
+### R0.3 parity 校验脚本
+
+- 新增 `scripts/road-parity.js`(宿主侧,Phase 2 随编译器搬走)。
+- 能力:
+ - `--snapshot ` 遍历指定区域的 `native-road/` 全部文件 +
+ `native-traffic-signals.json`,归一化后输出排序稳定的 checksum 清单
+ - `--compare ` 与基线比对,差异逐文件报告
+ - 非零退出码表示不一致
+- GeoJSON 需按稳定键排序后再 hash(避免 feature 顺序抖动造成假阳性),
+ 但**顺序本身若变化必须被报告**——用两个 hash:`contentHash`(排序后)
+ 与 `orderHash`(原序),分别报告。
+
+### R0.4 两区域基线
+
+- 区域:`fengshu-er-road`、`nantaizi-lake-innovation-valley`
+- 从干净状态跑 `npm run road:compile`,产出基线 JSON 提交进版本控制
+- 基线文件位置:`.trellis/tasks/08-25-road-compiler-extraction/baseline/.json`
+ (Phase 2 搬进编译器仓库当测试语料,见父任务 R4)
+
+`hanyang-block` 是废案,不纳入编译、快照或后续 parity 兼容范围。
+
+### R0.5 K2 决策定档
+
+`comparison.json` 依赖宿主 osm2streets 产物(`compile-native-roads.js:87` 读
+`area.outputs.geojsonDir`),拆出后不可达。本阶段必须做出决策并写入契约文档:
+
+- 方案 A:移除 `comparison.json`(osm2streets 已 legacy)
+- 方案 B:保留,`comparisonDir` 作为可选输入注入
+- 倾向 A。决策写入 `docs/native-road-package-v1.md` 的「已移除能力」一节。
+
+## Acceptance Criteria
+
+- [x] AC0.1 `docs/native-road-package-v1.md` 存在,覆盖输入契约、输出契约、
+ 12 图层清单、stdout 标记、已移除能力
+- [x] AC0.2 连续两次编译的 parity 比对通过(确定性已证明),volatile 字段清单已记录在文档内
+- [x] AC0.3 `scripts/road-parity.js` 可用,`--snapshot` / `--compare` 均工作,
+ 不一致时非零退出
+- [x] AC0.4 两个区域基线 JSON 已提交,且每个都能用 `--compare` 自比对通过
+- [x] AC0.5 K2 决策已定档并写入契约文档
+- [x] AC0.6 未修改任何生产代码(`git diff` 只含新增文档 / 脚本 / 基线)
+
+## 依赖与顺序
+
+- 无前置依赖,本阶段是全案入口
+- **阻塞** Phase 1、2、3、4 —— 没有基线就没有 oracle
+
+## Out of Scope
+
+- 任何生产代码改动(含"顺手修一下")
+- 目录结构调整
+- `compiled.json` 结构变更
diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/task.json b/.trellis/tasks/08-25-rc-p0-contract-baseline/task.json
new file mode 100644
index 0000000..a0f05ad
--- /dev/null
+++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/task.json
@@ -0,0 +1,26 @@
+{
+ "id": "rc-p0-contract-baseline",
+ "name": "rc-p0-contract-baseline",
+ "title": "Phase 0:冻结契约与 parity 基线",
+ "description": "写死 native-road-package/v1 输入输出契约,并对两个有效区域建立逐文件 checksum 基线作为全案 oracle",
+ "status": "in_progress",
+ "dev_type": null,
+ "scope": null,
+ "package": null,
+ "priority": "P1",
+ "creator": "dingkang",
+ "assignee": "dingkang",
+ "createdAt": "2026-08-25",
+ "completedAt": null,
+ "branch": null,
+ "base_branch": "main",
+ "worktree_path": null,
+ "commit": null,
+ "pr_url": null,
+ "subtasks": [],
+ "children": [],
+ "parent": "08-25-road-compiler-extraction",
+ "relatedFiles": [],
+ "notes": "",
+ "meta": {}
+}
diff --git a/.trellis/tasks/08-25-road-compiler-extraction/baseline/README.md b/.trellis/tasks/08-25-road-compiler-extraction/baseline/README.md
new file mode 100644
index 0000000..d8beb7d
--- /dev/null
+++ b/.trellis/tasks/08-25-road-compiler-extraction/baseline/README.md
@@ -0,0 +1,21 @@
+# Native Road Parity Baselines
+
+Generated from commit `25cf82e7c75ea8d881639c4a80648121efbd39f6` on 2026-08-25.
+
+```bash
+npm run road:compile -- --config config/areas/fengshu-er-road.json
+node scripts/road-parity.js --config config/areas/fengshu-er-road.json \
+ --snapshot .trellis/tasks/08-25-road-compiler-extraction/baseline/fengshu-er-road.json
+
+npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
+node scripts/road-parity.js --config config/areas/nantaizi-lake-innovation-valley.json \
+ --snapshot .trellis/tasks/08-25-road-compiler-extraction/baseline/nantaizi-lake-innovation-valley.json
+```
+
+Both samples emit all twelve native-road layers. `fengshu-er-road` enables its
+configured complex-junction template; `nantaizi-lake-innovation-valley` uses
+the default, template-free compiler path. `hanyang-block` is an abandoned area
+and deliberately excluded from this corpus.
+
+The snapshot normalizes absolute source paths and random `native-road-*`
+staging names. It does not round coordinates or ignore feature ordering.
diff --git a/.trellis/tasks/08-25-road-compiler-extraction/baseline/fengshu-er-road.json b/.trellis/tasks/08-25-road-compiler-extraction/baseline/fengshu-er-road.json
new file mode 100644
index 0000000..1d10646
--- /dev/null
+++ b/.trellis/tasks/08-25-road-compiler-extraction/baseline/fengshu-er-road.json
@@ -0,0 +1,108 @@
+{
+ "contract": "native-road-package/v1",
+ "areaId": "fengshu-er-road",
+ "files": {
+ "../native-traffic-signals.json": {
+ "contentHash": "88e7f0ef4fc7bdb9ef202af018b3fb3179035ffc8ed5eeba03276c263e1f0476",
+ "bytes": 5886
+ },
+ "comparison.json": {
+ "contentHash": "651eb4000a44a7521a79c3e1429795b83d20f8899e0c67c4bb25b56cf4a6f75d",
+ "bytes": 1295
+ },
+ "compiled.json": {
+ "contentHash": "2ab82eafcdab70155078fae3559692ef1fde0645a52c6decbaea762a9ffeb24a",
+ "bytes": 159230
+ },
+ "diagnostics.json": {
+ "contentHash": "1d9fdae06ddcbc19b5262b797a4a35c494fc30a98a66dbfa111533b12c5fdfc7",
+ "bytes": 6862
+ },
+ "layers/center_lines.geojson": {
+ "contentHash": "04db66df5821b319d59297b8515502fabcad95faf083cae19edb338be2f6024b",
+ "orderHash": "04db66df5821b319d59297b8515502fabcad95faf083cae19edb338be2f6024b",
+ "features": 5,
+ "bytes": 6909
+ },
+ "layers/connectors.geojson": {
+ "contentHash": "e7979d4642c7c10f33fb23e92721f22533a851ae1a2b4d35f224d50927bf2fe9",
+ "orderHash": "c327b7b154d4d9ca04cd895523eb0d8ad67c37051ef1d2135b2b46c51e12df58",
+ "features": 90,
+ "bytes": 197885
+ },
+ "layers/crosswalks.geojson": {
+ "contentHash": "354cd4d7ed105a11b95904b44848dce567b3a3383f76755936d8c00691fe29af",
+ "orderHash": "d24376438ac956bde52fcbfa955dd72c2ba31a97995608aaed1c79f6fd3633ab",
+ "features": 152,
+ "bytes": 219286
+ },
+ "layers/direction_arrows.geojson": {
+ "contentHash": "7b8362ad4912c997a7661befde55ddfe58757c3c8d139c15867e1938ea8f6918",
+ "orderHash": "5274e8b433946410a7cdffdaa452c1750e04f53e73a86bf81d034cfb26d4c254",
+ "features": 686,
+ "bytes": 1053308
+ },
+ "layers/edge_lines.geojson": {
+ "contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8",
+ "orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8",
+ "features": 0,
+ "bytes": 52
+ },
+ "layers/intersection_surface.geojson": {
+ "contentHash": "b2022ca70339aa470f3be451bf3ba2f55b59c3900645c864538372173811309f",
+ "orderHash": "b2022ca70339aa470f3be451bf3ba2f55b59c3900645c864538372173811309f",
+ "features": 5,
+ "bytes": 13048
+ },
+ "layers/lane_centerlines.geojson": {
+ "contentHash": "19c550c794810c1071de55dee158295235106a56e3877a2ba10da920ee214593",
+ "orderHash": "67f8e7d33ec578644416e4837dd4aafef62a55aaeb4d438d07ab07eddfbb1df1",
+ "features": 84,
+ "bytes": 73294
+ },
+ "layers/lane_separators.geojson": {
+ "contentHash": "02fdf2c7b6e89c2850a60ea2ac72b9f62910259bdb2949681fcaa7e67d2b51f0",
+ "orderHash": "81782a059c4ff9f69fdf7de9107d20a8d6cf55d34bb0620ec28a9a8c5106c982",
+ "features": 3596,
+ "bytes": 4164106
+ },
+ "layers/road_surface.geojson": {
+ "contentHash": "5a2936a705e64338d0d79b395a13d761f1893dd72330a9b9ee4696ae75eab295",
+ "orderHash": "d8402ae6e772e0778b862b190abfa291de1b02a15537d455f7dcd8596245dfd8",
+ "features": 30,
+ "bytes": 49554
+ },
+ "layers/sidewalk_surface.geojson": {
+ "contentHash": "b6f8f876e9f18fd2ef6749125a09d654c73d986e916bd262ceb9967bca1a54c6",
+ "orderHash": "3fd42a67e18e3b8743fa538428e039ef66a1f44150a6881a7aaa18326607b5cb",
+ "features": 26,
+ "bytes": 54997
+ },
+ "layers/turn_arrows.geojson": {
+ "contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8",
+ "orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8",
+ "features": 0,
+ "bytes": 52
+ },
+ "layers/vehicle_stop_lines.geojson": {
+ "contentHash": "cb904550ab8eec011cf33e5edfa81044de6dfca0280dbfe488e77229055ed590",
+ "orderHash": "8a4b877a17a5af60adc8656465afad6756944736291664139e878d46c5a4f363",
+ "features": 4,
+ "bytes": 4191
+ },
+ "traffic-signal-assemblies.json": {
+ "contentHash": "1788026a0c8c531b2fc7dd49628f7fbadd809c7b2f7f8819cffb3ac7d30689ca",
+ "orderHash": "b409ea6b77b6c8639f63c33a156581f9b184a84fde34d265b5deb05705828251",
+ "features": 7,
+ "bytes": 5411
+ },
+ "traffic-signals.json": {
+ "contentHash": "625b79b6eb82b41f2099ba51960717c7f794fdfbd87310eb12e99da966540d89",
+ "bytes": 15491
+ }
+ },
+ "volatileExcluded": [
+ "absolute paths -> or ",
+ "native-road-* staging directory -> "
+ ]
+}
diff --git a/.trellis/tasks/08-25-road-compiler-extraction/baseline/nantaizi-lake-innovation-valley.json b/.trellis/tasks/08-25-road-compiler-extraction/baseline/nantaizi-lake-innovation-valley.json
new file mode 100644
index 0000000..554b0e5
--- /dev/null
+++ b/.trellis/tasks/08-25-road-compiler-extraction/baseline/nantaizi-lake-innovation-valley.json
@@ -0,0 +1,108 @@
+{
+ "contract": "native-road-package/v1",
+ "areaId": "nantaizi-lake-innovation-valley",
+ "files": {
+ "../native-traffic-signals.json": {
+ "contentHash": "36cb9f069fb44a6e24f0af0d130ef8cb5b4a93250dc9fb0891191d1ba039a5b2",
+ "bytes": 28329
+ },
+ "comparison.json": {
+ "contentHash": "5aa11da443221a48288641ac6950c6e4d75d834713a93110bbf265859202a265",
+ "bytes": 1153
+ },
+ "compiled.json": {
+ "contentHash": "ea5ae4a04f1259b1ccec9fc1cca7ef8ba81426a77584a447480b6047b37f5e01",
+ "bytes": 184985
+ },
+ "diagnostics.json": {
+ "contentHash": "7e9327f0424dd612f389c560c114a4cafee1349bbfa36f49b2b4eab846e5ca91",
+ "bytes": 9755
+ },
+ "layers/center_lines.geojson": {
+ "contentHash": "2b2710fce06e75b0a3e4941c2da6119cea7b8bb7834345607bbee384de20ee6b",
+ "orderHash": "ea71a8841370689d6fc2b75d0b30ec7104638fb2904da6d707dc82ff3131e065",
+ "features": 511,
+ "bytes": 694102
+ },
+ "layers/connectors.geojson": {
+ "contentHash": "901a86bc6c536d38bb73ae4654ec1ce7d1c0790c2d6eba92543c48f584cd15ac",
+ "orderHash": "4df882ad59b65a97d892df64de0156b35beaf071b04eed671ef7b535e9e2a7b9",
+ "features": 76,
+ "bytes": 160866
+ },
+ "layers/crosswalks.geojson": {
+ "contentHash": "53fda76182c0d1db5f9e7ac284337ece62655d7fee8bb446fe170ea26abe8bbc",
+ "orderHash": "53fda76182c0d1db5f9e7ac284337ece62655d7fee8bb446fe170ea26abe8bbc",
+ "features": 48,
+ "bytes": 50536
+ },
+ "layers/direction_arrows.geojson": {
+ "contentHash": "38e80bbfe02d4f12ce40e7d0c8342e3bef6b602c64f81dd320b366b6a6b3d205",
+ "orderHash": "579aa6c37585bf4edf9d6b6b1e120f976b93ec2d0c303d82a154b75c114a45f5",
+ "features": 324,
+ "bytes": 493431
+ },
+ "layers/edge_lines.geojson": {
+ "contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8",
+ "orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8",
+ "features": 0,
+ "bytes": 52
+ },
+ "layers/intersection_surface.geojson": {
+ "contentHash": "7f79e57215e3fd07c5c0f703c71e516873db770cb869efdf18925a3184059ca3",
+ "orderHash": "d69df8d92d0fce8f7ee172959b978d941fd011e094857e9ab7304b3d1e98bf92",
+ "features": 12,
+ "bytes": 38528
+ },
+ "layers/lane_centerlines.geojson": {
+ "contentHash": "3fea4ebfe62e0d720c6977e2289f4e6517ed7b3c5ab361270d03378971aec780",
+ "orderHash": "69a8ebaa42a74da4f7d5940c5a81bd82ceb104efd3f858723dccfadbae12121e",
+ "features": 50,
+ "bytes": 43582
+ },
+ "layers/lane_separators.geojson": {
+ "contentHash": "dc0b50b53da191e65f3dba01162acb7561966d1796a9c0789527cebfd6ed06a4",
+ "orderHash": "0bfd873e5698869fb4855783fe30bb6db29456958e4d154b7264374a5fdb4a10",
+ "features": 731,
+ "bytes": 835933
+ },
+ "layers/road_surface.geojson": {
+ "contentHash": "81af19c71cf6fc8a3884488c00cfb590e1a7fe9ed9e4351fce65825ceadd50fa",
+ "orderHash": "f2ddec5258411fbdbc050b7ecefbb157b59e00b8226afcc30d2b772e1859e357",
+ "features": 25,
+ "bytes": 38275
+ },
+ "layers/sidewalk_surface.geojson": {
+ "contentHash": "bb8f4516e27b16238d753b112d18b8acfa5cfad2cea522a65b7972a609be9563",
+ "orderHash": "4fa773a6f39c5b6d23551e1338c6d99ef8a5ecd9ebfcafa16154c9765fef8c6e",
+ "features": 73,
+ "bytes": 122432
+ },
+ "layers/turn_arrows.geojson": {
+ "contentHash": "a65bb8fe079f27e00bf2e6208148fc6b73d2d07e76d48b1cd1bf8a610d7e2d66",
+ "orderHash": "66966ac2c65464f9c6715ca9cda5a3620520bf1d066cba2dee36114dc46af936",
+ "features": 108,
+ "bytes": 132536
+ },
+ "layers/vehicle_stop_lines.geojson": {
+ "contentHash": "4f45caf3c0bf13bbf10d9cac15fa80b1a9be3969ed84803ba48e56c4a0080992",
+ "orderHash": "4f45caf3c0bf13bbf10d9cac15fa80b1a9be3969ed84803ba48e56c4a0080992",
+ "features": 8,
+ "bytes": 8475
+ },
+ "traffic-signal-assemblies.json": {
+ "contentHash": "9f8d032c00784bb72bb1322238cd7b0a80e621d435b43c728da86f1e77ca352a",
+ "orderHash": "0b9cae13094dba1994faca4bf1d4d80e8bc5497daa46df5899bf5e6fc7f26d61",
+ "features": 35,
+ "bytes": 26398
+ },
+ "traffic-signals.json": {
+ "contentHash": "c4b68cc1cefeb0d393a18eef6c7eb79933fba28630826745165ed433c25779d0",
+ "bytes": 74151
+ }
+ },
+ "volatileExcluded": [
+ "absolute paths -> or ",
+ "native-road-* staging directory -> "
+ ]
+}
diff --git a/.trellis/tasks/08-25-road-compiler-extraction/check.jsonl b/.trellis/tasks/08-25-road-compiler-extraction/check.jsonl
new file mode 100644
index 0000000..9dd3234
--- /dev/null
+++ b/.trellis/tasks/08-25-road-compiler-extraction/check.jsonl
@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
diff --git a/.trellis/tasks/08-25-road-compiler-extraction/design.md b/.trellis/tasks/08-25-road-compiler-extraction/design.md
new file mode 100644
index 0000000..4273102
--- /dev/null
+++ b/.trellis/tasks/08-25-road-compiler-extraction/design.md
@@ -0,0 +1,294 @@
+# 道路编译器独立化 — 技术设计(parent)
+
+本文件是 **契约的权威定义**,供所有子任务引用。
+Phase 0 的交付物是把本节内容落成编译器仓库内的正式文档 + 校验脚本,
+而不是重新发明契约。
+
+---
+
+## 1. 契约:`native-road-package/v1`
+
+### 1.1 输入契约
+
+现状:`compile-native-roads.js` 接收整个 normalized areaConfig,但**实际只用 9 个字段**。
+
+```
+area.id → areaId
+area.input → osmFile (OSM XML 路径)
+area.nativeRoad.edgeLines → options.edgeLines
+area.nativeRoad.junctionTemplates → options.junctionTemplates
+area.outputs.nativeRoadOverrides → overridesFile
+area.outputs.nativeTrafficSignals → trafficSignalsFile
+area.outputs.nativeRoadDir → outDir
+area.outputs.pipelineDir → stagingDir
+area.outputs.geojsonDir → comparisonDir (见 K2,倾向移除)
+```
+
+目标形状:
+
+```js
+// RoadCompilerInput —— 编译器唯一入口参数
+{
+ areaId: string,
+ osmFile: string, // 绝对路径
+ outDir: string, // native-road/ 的目标位置
+ stagingDir: string, // 原子提升用的临时目录父级
+ overridesFile: string,
+ trafficSignalsFile: string,
+ options: {
+ edgeLines: boolean,
+ junctionTemplates: {
+ enabled: boolean,
+ references: [],
+ clusters: [{ id, template, referenceFile, nodeIds, ...几何参数 }]
+ }
+ }
+}
+```
+
+**宿主侧责任**:`scripts/lib/area-config.js` 把 areaConfig 映射成 `RoadCompilerInput`。
+**编译器侧责任**:不认识 areaConfig,不读 `config/areas/*.json`,不推导任何路径。
+
+### 1.2 输出契约
+
+`compiled.json` 顶层键(已核实):
+
+```
+schema, areaId, source, model, movements, trafficSignals, diagnostics, layers
+```
+
+`source` 含 `{ osm, overrides, trafficSignals }` 三个路径 —— 是 parity 归一化的重点对象。
+
+```
+/ # 原子提升:先写 staging 再 rename
+ manifest.json # ← Phase 3 新增,图层自声明
+ compiled.json # 单一读模型,含 model + movements
+ diagnostics.json # severity / subject / sourceIds / rule / message / geometry?
+ comparison.json # ← 见 K2,可能移除
+ layers/
+ road_surface.geojson
+ intersection_surface.geojson
+ sidewalk_surface.geojson
+ edge_lines.geojson
+ lane_separators.geojson
+ center_lines.geojson
+ crosswalks.geojson
+ vehicle_stop_lines.geojson
+ direction_arrows.geojson
+ turn_arrows.geojson
+ lane_centerlines.geojson # 语义层,不渲染
+ connectors.geojson # 语义层,不渲染
+
+ # 兄弟文件,不在 outDir 内
+```
+
+**stdout 完成标记**(与宿主 `SCENE_DONE` / `CESIUM_EXPORT_DONE` 同约定):
+
+```
+NATIVE_ROAD_COMPILE_DONE {"areaId":…,"roads":N,"endpoints":N,"diagnostics":N,"output":…,"comparison":…}
+```
+
+### 1.3 消费方式:子进程为主契约
+
+| 方式 | 定位 |
+|---|---|
+| CLI 子进程 + 读 `outDir` + 解析 stdout 标记 | **主契约**。宿主 pipeline 层本就是唯一能启动外部进程的层 |
+| `require()` in-process | 仅作性能优化,不得成为唯一路径 |
+
+选子进程的理由:
+1. 语言无关 —— 编译器将来若换 TS/Rust,宿主零改动。
+2. 强制文件契约成为真契约,无法偷传对象绕过边界。
+3. 与既有 QGIS / GDAL / Blender 调用方式一致。
+
+`build-area.js:7` 现在是 in-process `require`。Phase 2 改为子进程调用。
+
+---
+
+## 2. 模块清单:什么搬、什么留
+
+### 2.1 搬(约 3400 行核心 + workbench)
+
+| 文件 | 行数 | 依赖 |
+|---|---|---|
+| `scripts/lib/native-road.js` | 1695 | fs, path, turn-lane-arrows, complex-junction |
+| `scripts/lib/complex-junction.js` | 474 | fs, gaode-junction-reference |
+| `scripts/lib/turn-lane-arrows.js` | 502 | fs, path, lane-geometry |
+| `scripts/lib/gaode-junction-reference.js` | 231 | fs |
+| `scripts/lib/lane-geometry.js` | 161 | 无(纯函数) |
+| `scripts/lib/native-traffic-signals.js` | 49 | osm, traffic-signals ← **见 K1** |
+| `scripts/lib/osm.js` | 102 | 无 |
+| `scripts/compile-native-roads.js` | — | area-config ← **要换成窄契约** |
+| `scripts/check-native-roads.js` | — | area-config ← 同上 |
+| `scripts/road-workbench.js` + `scripts/workbench/app.js` | 173 + — | area-config ← 同上 |
+| `scripts/test-native-road.js`、`scripts/test-road-workbench.js` | — | fixture ← **见 K5** |
+
+依赖图(已核实,`native-road.js` 对宿主零耦合):
+
+```
+native-road.js ──→ turn-lane-arrows ──→ lane-geometry (纯)
+ └──────────→ complex-junction ──→ gaode-junction-reference
+native-traffic-signals ──→ osm.js
+ └──→ traffic-signals.js ← 共用,需拆
+```
+
+### 2.2 留
+
+- `scripts/lib/area-config.js` —— 宿主拥有,新增 `toRoadCompilerInput()` 映射
+- `scripts/build-area.js` —— 改为子进程调用编译器
+- `scripts/lib/traffic-signals.js` 的 legacy 读取器部分(见 K1)
+- `blender/` 全部 —— Phase 3 内部重组,但不搬出仓库
+- `scripts/lib/scene-layers.js`、osm2streets / QGIS legacy 链路 —— 完全不动
+
+### 2.3 K1 的拆分建议(Phase 1 需细读确认)
+
+`lib/traffic-signals.js` 当前混了两类东西:
+
+| 类别 | 使用方 | 归属 |
+|---|---|---|
+| OSM 信号节点提取 + 信号文档 schema | `native-traffic-signals.js` | **随编译器走** —— 编译器生成该文档,就该拥有其契约 |
+| `readTrafficSignalFeatures` | `build-osm2streets-qgis.js` | 留宿主 |
+| `readTrafficSignals` | `build-area.js` | 留宿主 |
+| `buildTrafficSignals` | `test-preview-assets.js` | 待判定 |
+
+拆完后宿主从编译器包 import 信号文档 schema,反向依赖为 0 不受影响
+(宿主依赖编译器是允许的方向)。
+
+---
+
+## 3. Phase 3:渲染分离的设计
+
+### 3.1 现状问题
+
+`blender/osmassets/catalog.py:53` 的 `NATIVE_ROAD_LAYERS` 是一张跨仓库重复表:
+
+```python
+# 注释自陈:"It is a provider adapter, not a second scene-layer registry."
+NATIVE_ROAD_LAYERS = (
+ {"source": "road_surface", "material_layer": "road_surface"},
+ {"source": "edge_lines", "material_layer": "lane_separators"},
+ ... 共 10 项
+)
+```
+
+编译器写 12 个 geojson,这张表只列 10 项 —— `lane_centerlines` / `connectors`
+是语义层不参与渲染。**但这个事实只存在于这张表的省略里,编译器侧没有任何声明。**
+
+后果:编译器新增图层 → 必须有人记得去另一个仓库改 `catalog.py` → 忘了就静默少渲染一层。
+这正是 `.trellis/spec/pipeline/index.md` 首页警告的「最容易出静默错误」。
+
+### 3.2 目标:编译器自声明图层
+
+`/manifest.json`:
+
+```json
+{
+ "contract": "native-road-package/v1",
+ "areaId": "fengshu-er-road",
+ "layers": [
+ { "source": "road_surface", "role": "surface",
+ "materialLayer": "road_surface" },
+
+ { "source": "lane_centerlines", "role": "semantic" },
+ { "source": "connectors", "role": "semantic" },
+
+ { "source": "center_lines", "role": "marking",
+ "materialLayer": "center_lines",
+ "splitBy": { "prop": "color",
+ "cases": [ { "match": "white", "material": "native_center_line_white" },
+ { "default": true, "material": "center_lines" } ] } },
+
+ { "source": "lane_separators", "role": "marking",
+ "materialLayer": "lane_separators",
+ "splitBy": { "prop": "color",
+ "cases": [ { "match": "yellow", "material": "native_lane_separator_yellow" },
+ { "default": true, "material": "lane_separators" } ] } }
+ ]
+}
+```
+
+- `role: "semantic"` 的图层 Blender 直接跳过 —— 把"不渲染"从省略变成显式声明。
+- `splitBy` 表达当前 `generate_scene.py:815-819` 里硬编码的
+ `color != "white"` / `color == "yellow"` 分流逻辑。
+- 材质本体(颜色、z 高度、贴图)仍归宿主 `catalog.py::MATERIALS` —— 编译器不懂渲染。
+ 编译器只声明"我有这些图层、它们是什么角色、该用哪个材质槽"。
+
+### 3.3 Blender 侧改造
+
+- `generate_scene.py` 内道路分支抽为 `blender/osmassets/native_roads.py`,读 manifest 遍历。
+- 建筑(`handle_building` / `dispatch_ways`)、植被、水体保持原位不动。
+- `catalog.NATIVE_ROAD_LAYERS` 删除。
+
+### 3.4 AC5 的验证方法
+
+向编译器加一个 throwaway 图层(例如 `debug_probe.geojson` + manifest 声明),
+不改宿主任何代码,跑 blender 阶段,确认它被渲染出来。验证完回滚该图层。
+
+---
+
+## 4. 决策记录
+
+### D1 不用 drawtonomy 替代编译器 ❌
+
+评估结论(2026-08-25):
+
+- 克隆仓库内**没有编辑器本体**。`packages/` 只有 SDK / dev-server / mcp-server;
+ 白板闭源,托管在 drawtonomy.com。README 卖点里的 topology-aware lanes、
+ lane tool、intersection/roundabout templates、Map→lanes **全部不在开源代码内**。
+- **它完全不做 raw OSM 推导**。`exporter/osmParser.ts` 首行自陈是
+ "Lanelet2 OSM (.osm XML) parser" —— 读的是 Lanelet2(车道左右边界已显式)。
+ 整个 `exporter/` 目录 grep `highway` 命中 1 次,是 `opendrive.ts:685` 的
+ 车道类型字符串,不是 OSM 标签解析。从不读 `highway=*` / `lanes` / `turn:lanes`。
+- 本编译器的核心能力恰是"从稀疏有歧义的中国 OSM 标签推导车道几何",方向垂直。
+- 语义成熟度对比:本编译器有逐值 provenance
+ (`tag:lanes:forward` / `inferred:highway-default` / `override:`)+ 31 条语义诊断规则;
+ drawtonomy SDK 两者皆无,其 25 条校验规则全是 OpenDRIVE 结构/XML 完整性检查。
+
+### D2 drawtonomy 作为**下游后端 + 编辑器**(后续独立 PoC)✅
+
+可用能力(扩展 API,`types.ts:257` 8 个 capability):
+`shapes:write` + `ui:panel` 足以把编译产物注入编辑器;
+`snapshot:read` 回读后由本地跑其开源 `exportToOpenDrive` / `lanelet2`。
+
+不可用能力(决定 junctionTools 不能做成扩展):
+- 无 canvas / overlay 能力,UI 只能是侧栏 iframe
+- 无工具注册,画布指针事件完全归宿主
+- **无任何 change 事件推送** —— `ExtensionClient.handleMessage` 入站只有
+ `ext:init` / 按 requestId 匹配的 5 个 `*-response` / `ext:error`,所有读取靠轮询
+
+结论:junctionTools 留在自有 workbench(画布自己的,可任意绘制)。
+drawtonomy 承担场景编排 + 工业格式导出。二者是不同的活,不强行合并 UI。
+
+依赖风险记录:`drawtonomy-dev-server` 是 `https://www.drawtonomy.com` 的
+**缓存代理**(TTL 1 小时),非自托管。manifest 有 `minHostVersion` 字段,
+说明宿主协议会漂。后续 PoC 不把它放进关键路径。
+
+### D3 值得从 drawtonomy 借用的(Apache-2.0,需保留 NOTICE/署名)
+
+| 来源 | 行数 | 用途 | 建议阶段 |
+|---|---|---|---|
+| `exporter/odrGeometryFit.ts` | 610 | 折线→解析曲线拟合(中位数去噪 + 贪心生长 + 最简原语优先 + 逐拟合回验 + G1 硬不变量)。可替掉手调的 `approachWidthMultiplier=1.45` / `coreRadiusMeters=28`,改为对 `referenceFile` 拟合、残差作质量指标 | 拆分后独立任务 |
+| validator 的 mutation-proven 方法 | — | 故意破坏合法输入、断言校验器必须抓到。本编译器 31 条诊断规则目前无任何触发证明 | 拆分后独立任务,成本低 |
+| validator 的分层 + 命名空间(`xml.*` → `ref.*` → `junction.*` → `geom.*`) | — | 替代当前 31 条平铺规则 | 同上 |
+| OpenDRIVE + Lanelet2 导出器 | 3059 + 869 | 补齐工业格式输出(Lanelet2 是 Autoware 的输入格式) | 后续独立 PoC |
+
+### D4 为什么 Phase 1 与 Phase 2 必须分开
+
+Phase 1 只改"包边界与入口契约",仓库不变 → 若产物变化,成因必在代码改动。
+Phase 2 只改"仓库位置与消费方式",代码不变 → 若产物变化,成因必在搬迁。
+合并执行则两者混淆,parity oracle 失去诊断价值。
+
+### D5 IR 重构推迟到拆分之后
+
+见 prd.md C1。拆分的正确性完全建立在"产物逐字节不变"上,
+同期改 IR 会同时摧毁 oracle 与归因能力。
+
+---
+
+## 5. 回滚形状
+
+| Phase | 回滚方式 |
+|---|---|
+| 0 | 无代码改动,仅新增文档与基线,无需回滚 |
+| 1 | `git revert`;`packages/road-compiler/` 与旧 `scripts/lib/*` 并存过渡期内可切回旧路径 |
+| 2 | 宿主依赖回指本仓库内路径(`file:packages/road-compiler`),编译器仓库保留不动 |
+| 3 | Blender 侧恢复 `catalog.NATIVE_ROAD_LAYERS`,manifest 保留但不消费 |
diff --git a/.trellis/tasks/08-25-road-compiler-extraction/implement.jsonl b/.trellis/tasks/08-25-road-compiler-extraction/implement.jsonl
new file mode 100644
index 0000000..9dd3234
--- /dev/null
+++ b/.trellis/tasks/08-25-road-compiler-extraction/implement.jsonl
@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
diff --git a/.trellis/tasks/08-25-road-compiler-extraction/prd.md b/.trellis/tasks/08-25-road-compiler-extraction/prd.md
new file mode 100644
index 0000000..fe22f1d
--- /dev/null
+++ b/.trellis/tasks/08-25-road-compiler-extraction/prd.md
@@ -0,0 +1,139 @@
+# 道路编译器独立化(parent)
+
+## Goal
+
+把 native road compiler 从本仓库拆成可独立维护的项目,与本项目通过**版本化文件契约**相辅相成;
+同时把道路渲染与 OSM 建筑渲染分离,使二者可各自演进。
+
+本任务是 parent:它拥有源需求、契约定义、子任务地图、跨子任务验收标准和最终集成评审。
+**它自身不承担实现工作**,所有可交付物在子任务中完成。
+
+## 背景:为什么现在拆
+
+- `scripts/lib/native-road.js` 已 1695 行,`compileGeometry()` 单函数编排 15+ 个 pass,
+ 顺序依赖只由行号编码,每加一个特性就往既有函数尾部挂位置参数
+ (`compileLaneMarkings` 已 7 个位置参数)。
+- 道路与建筑渲染纠缠在 `blender/generate_scene.py` 一个脚本内,
+ 道路图层表 `NATIVE_ROAD_LAYERS` 硬编码在 `blender/osmassets/catalog.py:53`。
+- 道路编译的迭代节奏(几何、路口、标线、V2X 语义)与建筑/植被/水体渲染完全不同,
+ 放在一个仓库里互相牵制。
+
+拆分的可行性前提(已核实):
+- `native-road.js` 只 require `fs` / `path` / 两个同族道路模块,**对宿主项目零耦合**。
+- 对宿主的唯一耦合是三个 CLI 入口里的 `readAreaConfig`。
+- 输出侧 `native-road/` **已经是文件契约**,`blender/generate_scene.py:801` 按图层名读取。
+- stdout 标记 `NATIVE_ROAD_COMPILE_DONE` 已存在,与 `SCENE_DONE` / `CESIUM_EXPORT_DONE`
+ 同一约定 —— 子进程边界事实上已预留。
+
+## Requirements
+
+### R1 契约先行
+
+- R1.1 输入输出契约必须在任何代码移动之前写死并版本化为 `native-road-package/v1`,
+ 命名对齐既有 `osm-asset-package/v1` 约定。
+- R1.2 契约的权威定义见本任务 `design.md`;Phase 0 负责把它落成仓库内文档 + 校验脚本。
+
+### R2 可证明的等价性
+
+- R2.1 拆分过程中每一步都必须对两个有效区域
+ (`fengshu-er-road` / `nantaizi-lake-innovation-valley`)
+ 验证产物与基线一致。
+- R2.2 任何差异必须逐条书面解释后才可接受,禁止"看起来差不多"。
+- R2.3 验证方法沿用既有 `.trellis/spec/guides/artifact-parity-guide.md`。
+
+### R3 独立可维护
+
+- R3.1 拆出的项目必须能脱离宿主自测(自带 fixture,不读宿主 `inputs/` 或 `outputs/`)。
+- R3.2 保留 git 历史(`git subtree split` 或 `git filter-repo`)——
+ 1695 行几何逻辑的 blame 是踩坑记录,丢失后无人敢改。
+- R3.3 编译器不得反向依赖宿主任何模块、配置或目录布局。
+
+### R4 相辅相成(防漂移)
+
+四个机制缺一不可:
+
+| 机制 | 作用 | 落地于 |
+|---|---|---|
+| `native-road-package/v1` 版本号 | 破坏性变更必须升版本,宿主主动 opt-in | Phase 0 |
+| 编译器自声明图层(layer manifest) | 加图层不需要改宿主代码 | Phase 3 |
+| 两区域 parity 基线留在编译器仓库当测试语料 | 编译器无法静默弄坏宿主 | Phase 0 → Phase 2 |
+| 宿主锁版本依赖,不用 `latest` | 升级是决定,不是意外 | Phase 2 |
+
+### R5 渲染分离
+
+- R5.1 道路图层表必须从宿主 `catalog.py` 的硬编码变为编译器输出的 manifest。
+- R5.2 `blender/generate_scene.py` 内道路渲染分支抽离为独立模块;
+ 建筑 / 植被 / 水体保持原位。
+- R5.3 分离后,编译器新增图层不需要修改宿主任何代码即可被渲染。
+
+## Constraints(硬约束)
+
+### C1 不得在拆分过程中重构 IR 🔴
+
+lane graph 提为一等 IR 是正确方向,但**必须在 Phase 2 完成之后作为独立任务**。
+
+理由:拆分的正确性完全依赖"产物逐字节不变"这一 oracle。
+一旦同时改 IR,oracle 失效,且无法判断产物变化来自搬迁还是重设计。
+
+未来若做,形式是**新增输出**而非替换 `compiled.json`,
+靠"解锁 drawtonomy / OpenDRIVE / Lanelet2 导出"赚取存在理由。
+
+### C2 拆分期不引入构建步骤
+
+编译器保持 CommonJS、无构建步骤,与宿主 pipeline 层运行时一致
+(见 `.trellis/spec/pipeline/index.md`)。
+TypeScript / ESM 是独立决策;若后续 drawtonomy PoC 需要 SDK 互操作,只能在其独立任务的扩展目录内处理。
+
+### C3 Phase 1 必须先于 Phase 2
+
+"包边界"与"换仓库"分两步做。合并执行时若产物变化,无法区分成因。
+
+## 子任务地图
+
+| Phase | 子任务 | 交付物 | 阻塞后续 |
+|---|---|---|---|
+| 0 | `08-25-rc-p0-contract-baseline` | 契约文档 + 两区域 checksum 基线 + 校验脚本 | 是 |
+| 1 | `08-25-rc-p1-package-boundary` | 本仓库内 `packages/road-compiler/`,窄输入契约 | 是 |
+| 2 | `08-25-rc-p2-repo-split` | 独立仓库 + 宿主锁版本消费 | 是 |
+| 3 | `08-25-rc-p3-render-separation` | layer manifest + Blender 道路模块抽离 | 否 |
+
+顺序约束:**0 → 1 → 2 必须串行**。3 依赖 2 完成。
+
+## 跨子任务验收标准
+
+- [ ] AC1 Phase 0 基线建立后,两区域每个输出文件的 checksum 已提交进版本控制
+- [ ] AC2 Phase 1 结束时,`npm run build:area` 两区域产物对 AC1 基线逐字节一致
+- [ ] AC3 Phase 2 结束时,宿主从打包依赖构建,两区域 parity 仍成立
+- [ ] AC4 Phase 2 结束时,编译器仓库 `npm test` 在不访问宿主仓库的情况下通过
+- [ ] AC5 Phase 3 结束时,向编译器新增一个图层,宿主**零代码改动**即可渲染出来(实测验证)
+- [ ] AC6 Phase 3 结束时,`.blend` 结构摘要对基线一致(走 artifact-parity-guide)
+- [ ] AC7 全程未修改 `compiled.json` 的结构(C1 未被违反)
+- [ ] AC8 编译器仓库对宿主的反向依赖数为 0(grep 验证)
+
+## 已识别风险(跨子任务,逐个必须有归属)
+
+| # | 风险 | 归属 Phase | 处置 |
+|---|---|---|---|
+| K1 | `lib/traffic-signals.js` 是真共用模块:`reimport-gpkg.js` / `build-osm2streets-qgis.js` / `build-area.js` / `test-preview-assets.js` 都在用,而 `native-traffic-signals.js` 也依赖它 | 1 | 需细读后拆分:信号文档 schema + OSM 信号节点提取随编译器走(编译器生成它就该拥有契约),legacy QGIS/预览读取器留宿主。**全案唯一需要细读再动的地方** |
+| K2 | `comparison.json` 依赖宿主产物 —— `compile-native-roads.js:87` 读 `area.outputs.geojsonDir` 里 osm2streets 输出做对比,拆出后摸不到 | 0(决策)/ 1(执行) | osm2streets 已 legacy,倾向直接砍掉,用高德参考 + 规范校验取代。决策需在 Phase 0 定档 |
+| K3 | config 内绝对路径指向宿主仓库:`config/areas/fengshu-er-road.json` 的 `referenceFile: "/Users/que01/osm2streets-qgis-workflow/inputs/osm/珠山湖大道(枫树二路)口.geojson"` | 1 | 需定参考文件解析约定(相对 config 目录 / 显式 basePath) |
+| K4 | workbench 的 OpenLayers 来自宿主 `node_modules`(design.md 的 import map 方案) | 2 | 新仓库自带依赖 |
+| K5 | 测试 fixture 依赖宿主:`test-native-road.js:167` 读 `inputs/osm/枫树二路.osm`,其余为内联合成 OSM | 1 | 该文件(或裁剪版)作为测试数据提交进编译器仓库 |
+
+## Out of Scope
+
+- lane graph IR 重构(见 C1,未来独立任务)
+- pass manager / 显式依赖声明重构(同上,属编译器内部演进,不属本次拆分)
+- 替换编译器为 drawtonomy 或 osm2streets —— 已评估否决:
+ drawtonomy 开源部分只读 Lanelet2(显式车道边界),不做 OSM `highway=*` 推导,
+ 与本编译器的核心能力方向垂直
+- osm2streets / QGIS legacy 链路的任何改动
+- 宿主侧建筑 / 植被 / 水体渲染逻辑的改动(Phase 3 只抽离道路部分)
+
+## Notes
+
+- 评估结论与选型依据见 `design.md` 的「决策记录」一节。
+- 时间估计:Phase 0-2 约 3 天(搬家 + 证明没搬坏),Phase 3 约 1-2 天。
+ 拿到"道路与建筑渲染分离、可独立维护"是在 Phase 3 结束。
+- 父任务验收完成后,下一项候选任务是独立的
+ `08-25-rc-p4-drawtonomy-ext`(drawtonomy 扩展 PoC);它不属于本任务完成条件。
diff --git a/.trellis/tasks/08-25-road-compiler-extraction/task.json b/.trellis/tasks/08-25-road-compiler-extraction/task.json
new file mode 100644
index 0000000..dcf2b37
--- /dev/null
+++ b/.trellis/tasks/08-25-road-compiler-extraction/task.json
@@ -0,0 +1,33 @@
+{
+ "id": "road-compiler-extraction",
+ "name": "road-compiler-extraction",
+ "title": "道路编译器独立化(parent)",
+ "description": "把 native road compiler 拆成可独立维护的项目,并与本项目通过版本化契约相辅相成;同时分离道路与 OSM 建筑渲染",
+ "status": "planning",
+ "dev_type": null,
+ "scope": null,
+ "package": null,
+ "priority": "P1",
+ "creator": "dingkang",
+ "assignee": "dingkang",
+ "createdAt": "2026-08-25",
+ "completedAt": null,
+ "branch": null,
+ "base_branch": "main",
+ "worktree_path": null,
+ "commit": null,
+ "pr_url": null,
+ "subtasks": [],
+ "children": [
+ "08-25-rc-p0-contract-baseline",
+ "08-25-rc-p1-package-boundary",
+ "08-25-rc-p2-repo-split",
+ "08-25-rc-p3-render-separation"
+ ],
+ "parent": null,
+ "relatedFiles": [],
+ "notes": "",
+ "meta": {
+ "next_task": "08-25-rc-p4-drawtonomy-ext"
+ }
+}
\ No newline at end of file
diff --git a/docs/changelog.md b/docs/changelog.md
index 256ec6c..7ffbdd2 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,15 @@
# Changelog
+## 2026-08-25
+
+- Frozen the `native-road-package/v1` host/compiler contract in
+ `docs/native-road-package-v1.md`, added `scripts/road-parity.js`, and
+ committed checksum baselines for `fengshu-er-road` and
+ `nantaizi-lake-innovation-valley`. The control experiment covers two clean
+ compiles plus editable signal regeneration for each area; JSON key ordering,
+ absolute paths, and temporary staging names are normalized while geometry and
+ GeoJSON feature order remain strict.
+
## 2026-08-04
- 重构区域构建入口的 preview 实现边界:`build-area.js` 现在只调度阶段、写 preview
diff --git a/docs/native-road-package-v1.md b/docs/native-road-package-v1.md
new file mode 100644
index 0000000..b508157
--- /dev/null
+++ b/docs/native-road-package-v1.md
@@ -0,0 +1,98 @@
+# Native Road Package v1
+
+`native-road-package/v1` defines the boundary between the host area pipeline
+and the native road compiler. The compiler accepts only the input below; it
+does not read `config/areas/*.json` or derive host paths.
+
+## Input
+
+```js
+{
+ areaId: string,
+ osmFile: string,
+ outDir: string,
+ stagingDir: string,
+ overridesFile: string,
+ trafficSignalsFile: string,
+ comparisonDir?: string,
+ options: {
+ edgeLines: boolean,
+ junctionTemplates: {
+ enabled: boolean,
+ references: [],
+ clusters: []
+ }
+ }
+}
+```
+
+All paths are absolute at the process boundary. `stagingDir` is the parent for
+the compiler's temporary output and `outDir` is promoted atomically only after
+all files are written. The host maps normalized area configuration to this
+shape and owns all path derivation.
+
+## Output
+
+`outDir` contains these JSON documents:
+
+- `compiled.json`: top-level keys are `schema`, `areaId`, `source`, `model`,
+ `movements`, `trafficSignals`, `diagnostics`, and `layers`. `source` has
+ `osm`, `overrides`, and `trafficSignals` paths.
+- `diagnostics.json`: `{ schema: "native-road-diagnostics/v1", diagnostics: [] }`.
+ Each diagnostic has `id`, `severity`, `subjectId`, `sourceIds`, `rule`,
+ `message`, and `geometry` (a GeoJSON geometry or `null`).
+- `comparison.json`: coverage counts for the road workbench.
+- `traffic-signal-assemblies.json` and `traffic-signals.json`: compiler-owned
+ signal assembly and runtime views.
+
+`layers/` always contains these twelve GeoJSON FeatureCollections:
+
+1. `road_surface.geojson`
+2. `intersection_surface.geojson`
+3. `sidewalk_surface.geojson`
+4. `edge_lines.geojson`
+5. `lane_separators.geojson`
+6. `center_lines.geojson`
+7. `crosswalks.geojson`
+8. `vehicle_stop_lines.geojson`
+9. `direction_arrows.geojson`
+10. `turn_arrows.geojson`
+11. `lane_centerlines.geojson` (semantic, not rendered)
+12. `connectors.geojson` (semantic, not rendered)
+
+The editable signal source at `trafficSignalsFile` is a sibling of `outDir`.
+It is included in the parity baseline because regeneration must be stable.
+
+Successful CLI execution prints exactly one completion marker:
+
+```text
+NATIVE_ROAD_COMPILE_DONE {"areaId":...,"roads":N,"endpoints":N,"diagnostics":N,"output":...,"comparison":...}
+```
+
+Consumers use the CLI process plus files in `outDir` and parse this marker.
+An in-process import may be an optimization, never the sole contract.
+
+## Comparison Input Decision
+
+`comparison.json` remains part of v1. The road workbench currently reads it,
+so removing it would break a real consumer. `comparisonDir` is therefore an
+optional compiler input for a future standalone package: when absent, the
+compiler emits native-only counts and marks osm2streets coverage unavailable.
+
+## Parity Rules
+
+`scripts/road-parity.js` snapshots every file below `outDir` plus the sibling
+editable signal document. JSON object keys are recursively sorted before
+hashing. GeoJSON records both a sorted-feature `contentHash` and original-order
+`orderHash`; either difference is reported.
+
+The only normalization is intentional volatility removal:
+
+- absolute paths below this repository become `/...`;
+- other absolute paths become `/`;
+- `native-road-*` temporary directory names become ``.
+
+No coordinates are rounded and no feature order changes are ignored. Control
+experiments on 2026-08-25 compiled `fengshu-er-road` and
+`nantaizi-lake-innovation-valley` twice, then regenerated their editable
+signal documents. All 18 tracked files per area matched.
diff --git a/package.json b/package.json
index d879b30..465ca6f 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"serve:v2x-preview": "node scripts/v2x-preview-server.js",
"test:road-workbench": "node scripts/test-road-workbench.js",
"test:native-road": "node scripts/test-native-road.js",
+ "test:road-parity": "node scripts/test-road-parity.js",
"test:gaode-junction-reference": "node scripts/test-gaode-junction-reference.js",
"test:preflight": "node scripts/test-area-preflight.js",
"test:build-stages": "node scripts/test-build-stages.js",
diff --git a/scripts/road-parity.js b/scripts/road-parity.js
new file mode 100644
index 0000000..ecd1c38
--- /dev/null
+++ b/scripts/road-parity.js
@@ -0,0 +1,152 @@
+#!/usr/bin/env node
+"use strict";
+
+const crypto = require("crypto");
+const fs = require("fs");
+const path = require("path");
+const { readAreaConfig } = require("./lib/area-config");
+
+const repoRoot = path.resolve(__dirname, "..");
+
+function parseArgs(argv) {
+ const result = {};
+ for (let index = 0; index < argv.length; index += 1) {
+ if (!argv[index].startsWith("--")) continue;
+ const key = argv[index].slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
+ result[key] = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : true;
+ }
+ return result;
+}
+
+function usage() {
+ return "Usage: node scripts/road-parity.js --config (--snapshot | --compare )";
+}
+
+function hash(value) {
+ return crypto.createHash("sha256").update(value).digest("hex");
+}
+
+function stable(value) {
+ if (Array.isArray(value)) return value.map(stable);
+ if (!value || typeof value !== "object") return normalizeString(value);
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
+}
+
+function normalizeString(value) {
+ if (typeof value !== "string") return value;
+ const normalized = value.replace(/([\\/])native-road-[^\\/]+/g, "$1");
+ if (!path.isAbsolute(normalized)) return normalized;
+ const relative = path.relative(repoRoot, normalized);
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative)
+ ? `/${relative.split(path.sep).join("/")}`
+ : `/${path.basename(normalized)}`;
+}
+
+function encoded(value) {
+ return JSON.stringify(stable(value));
+}
+
+function featureKey(feature) {
+ const nativeId = feature?.properties?.native_id ?? feature?.properties?.nativeId ?? feature?.id;
+ if (nativeId !== undefined && nativeId !== null) return `id:${nativeId}`;
+ return `geometry:${encoded(feature?.geometry ?? null)}`;
+}
+
+function geoJsonRecord(value, bytes) {
+ if (value?.type !== "FeatureCollection" || !Array.isArray(value.features)) return null;
+ const normalized = { ...value, features: value.features.map(stable) };
+ const ordered = encoded(normalized);
+ const content = encoded({ ...normalized, features: [...normalized.features].sort((left, right) => featureKey(left).localeCompare(featureKey(right))) });
+ return { contentHash: hash(content), orderHash: hash(ordered), features: value.features.length, bytes };
+}
+
+function jsonRecord(text, bytes) {
+ const value = JSON.parse(text);
+ return geoJsonRecord(value, bytes) || { contentHash: hash(encoded(value)), bytes };
+}
+
+function filesBelow(directory) {
+ const result = [];
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const full = path.join(directory, entry.name);
+ if (entry.isDirectory()) result.push(...filesBelow(full));
+ else if (entry.isFile()) result.push(full);
+ }
+ return result;
+}
+
+function snapshot(configPath) {
+ const area = readAreaConfig(configPath, { repoRoot });
+ if (!fs.existsSync(area.outputs.nativeRoadDir)) throw new Error(`Native road output not found: ${area.outputs.nativeRoadDir}`);
+ const files = {};
+ for (const file of filesBelow(area.outputs.nativeRoadDir).sort()) {
+ const relative = path.relative(area.outputs.nativeRoadDir, file).split(path.sep).join("/");
+ const text = fs.readFileSync(file, "utf8");
+ files[relative] = jsonRecord(text, Buffer.byteLength(text));
+ }
+ if (fs.existsSync(area.outputs.nativeTrafficSignals)) {
+ const text = fs.readFileSync(area.outputs.nativeTrafficSignals, "utf8");
+ files["../native-traffic-signals.json"] = jsonRecord(text, Buffer.byteLength(text));
+ }
+ return {
+ contract: "native-road-package/v1",
+ areaId: area.id,
+ files: Object.fromEntries(Object.keys(files).sort().map((file) => [file, files[file]])),
+ volatileExcluded: ["absolute paths -> or ", "native-road-* staging directory -> "],
+ };
+}
+
+function compare(actual, baseline) {
+ const differences = [];
+ if (actual.contract !== baseline.contract) differences.push(`contract: expected ${baseline.contract}, got ${actual.contract}`);
+ if (actual.areaId !== baseline.areaId) differences.push(`areaId: expected ${baseline.areaId}, got ${actual.areaId}`);
+ const names = new Set([...Object.keys(baseline.files || {}), ...Object.keys(actual.files || {})]);
+ for (const name of [...names].sort()) {
+ const expected = baseline.files?.[name];
+ const received = actual.files?.[name];
+ if (!expected) { differences.push(`${name}: unexpected file`); continue; }
+ if (!received) { differences.push(`${name}: missing file`); continue; }
+ for (const field of ["contentHash", "orderHash", "features", "bytes"]) {
+ if ((expected[field] ?? null) !== (received[field] ?? null)) {
+ differences.push(`${name}: ${field} expected ${expected[field] ?? ""}, got ${received[field] ?? ""}`);
+ }
+ }
+ }
+ return differences;
+}
+
+function writeJson(file, value) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+function main() {
+ const args = parseArgs(process.argv.slice(2));
+ if (!args.config || Boolean(args.snapshot) === Boolean(args.compare)) throw new Error(usage());
+ const actual = snapshot(path.resolve(args.config));
+ if (args.snapshot) {
+ const output = path.resolve(args.snapshot);
+ writeJson(output, actual);
+ console.log(`ROAD_PARITY_SNAPSHOT ${JSON.stringify({ areaId: actual.areaId, output, files: Object.keys(actual.files).length })}`);
+ return;
+ }
+ const baseline = JSON.parse(fs.readFileSync(path.resolve(args.compare), "utf8"));
+ const differences = compare(actual, baseline);
+ if (differences.length) {
+ for (const difference of differences) console.error(`ROAD_PARITY_DIFF ${difference}`);
+ process.exitCode = 1;
+ return;
+ }
+ console.log(`ROAD_PARITY_OK ${JSON.stringify({ areaId: actual.areaId, baseline: path.resolve(args.compare), files: Object.keys(actual.files).length })}`);
+}
+
+if (require.main === module) {
+ try {
+ main();
+ } catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+ }
+}
+
+module.exports = { compare, snapshot };
diff --git a/scripts/test-road-parity.js b/scripts/test-road-parity.js
new file mode 100644
index 0000000..38c5555
--- /dev/null
+++ b/scripts/test-road-parity.js
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("assert/strict");
+const { compare } = require("./road-parity");
+
+const baseline = {
+ contract: "native-road-package/v1",
+ areaId: "fixture",
+ files: {
+ "layers/road_surface.geojson": {
+ contentHash: "content-a",
+ orderHash: "order-a",
+ features: 2,
+ bytes: 100,
+ },
+ },
+};
+
+assert.deepEqual(compare(baseline, baseline), []);
+assert.match(
+ compare({ ...baseline, files: { "layers/road_surface.geojson": { ...baseline.files["layers/road_surface.geojson"], orderHash: "order-b" } } }, baseline).join("\n"),
+ /orderHash/,
+);
+assert.match(
+ compare({ ...baseline, files: {} }, baseline).join("\n"),
+ /missing file/,
+);
+
+console.log("road parity tests passed");