atlas-project-harness 0.2.0-beta.21 → 0.2.0-beta.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/DESIGN.md +3 -0
  3. package/README.md +6 -5
  4. package/package.json +1 -1
  5. package/patches/project-wiring/__pycache__/apply-wiring.cpython-312.pyc +0 -0
  6. package/rings/e2e/reference.md +10 -6
  7. package/scripts/__pycache__/e2e_console.cpython-312.pyc +0 -0
  8. package/scripts/__pycache__/gen_e2e_scripts.cpython-312.pyc +0 -0
  9. package/scripts/__pycache__/make_review_inputs.cpython-312.pyc +0 -0
  10. package/scripts/__pycache__/refresh_structure.cpython-312.pyc +0 -0
  11. package/scripts/__pycache__/release.cpython-312.pyc +0 -0
  12. package/scripts/e2e_console.py +296 -14
  13. package/scripts/gen_e2e_scripts.py +189 -52
  14. package/scripts/make_review_inputs.py +41 -3
  15. package/shared/gates.md +11 -6
  16. package/shared/independent-review.md +1 -0
  17. package/templates/e2e/console/app.js +2 -2
  18. package/templates/e2e/e2e-index.md +5 -5
  19. package/tests/__pycache__/test_atlas_check.cpython-312-pytest-9.1.1.pyc +0 -0
  20. package/tests/__pycache__/test_atlas_verify_playground.cpython-312-pytest-9.1.1.pyc +0 -0
  21. package/tests/__pycache__/test_e2e_console.cpython-312-pytest-9.1.1.pyc +0 -0
  22. package/tests/__pycache__/test_gen_e2e_scripts.cpython-312-pytest-9.1.1.pyc +0 -0
  23. package/tests/__pycache__/test_gen_e2e_scripts.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_map_anchors.cpython-312-pytest-9.1.1.pyc +0 -0
  25. package/tests/__pycache__/test_map_anchors.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_validate_ac_alignment.cpython-312-pytest-9.1.1.pyc +0 -0
  27. package/tests/__pycache__/test_validators.cpython-312-pytest-9.1.1.pyc +0 -0
  28. package/tests/__pycache__/test_validators.cpython-312.pyc +0 -0
  29. package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/inputs/MANIFEST.json +13 -0
  30. package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/review.json +6 -9
  31. package/tests/test_atlas_check.py +64 -0
  32. package/tests/test_atlas_verify_playground.py +2 -0
  33. package/tests/test_e2e_console.py +173 -27
  34. package/tests/test_gen_e2e_scripts.py +194 -0
  35. package/tests/test_map_anchors.py +1 -1
  36. package/tests/test_validate_ac_alignment.py +54 -0
  37. package/tests/test_validators.py +335 -0
  38. package/validators/__pycache__/validate_ac_alignment.cpython-312.pyc +0 -0
  39. package/validators/__pycache__/validate_e2e_index.cpython-312.pyc +0 -0
  40. package/validators/__pycache__/validate_testids.cpython-312.pyc +0 -0
  41. package/validators/validate_ac_alignment.py +11 -2
  42. package/validators/validate_e2e_index.py +83 -12
  43. package/validators/validate_testids.py +35 -2
  44. package/adapters/__pycache__/api.cpython-312.pyc +0 -0
  45. package/adapters/__pycache__/models.cpython-312.pyc +0 -0
  46. package/adapters/__pycache__/tests.cpython-312.pyc +0 -0
  47. package/skills/atlas-release/SKILL.md +0 -126
  48. package/tests/__pycache__/test_tools.cpython-312.pyc +0 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v0.2.0-beta.23] - 2026-10-30
8
+
9
+ ### 🧭 登记测试有效性与 CRAP 候选观测面(D175)
10
+
11
+ * **新增**:`shared/gates.md` §3 新增两个候选观测面缺口行:「测试 ⇔ 生产代码变异(变异得分)」「实现代码 ⇔ 覆盖×复杂度(CRAP)」,§4 新立「候选观测面(非门)」小节登记。来源 = ub-mode(Uncle Bob)变异测试 / CRAP 分数思想。
12
+ * **非门**:候选形态 WARN/N-A,不建 FAIL 门、不进 `atlas_check`、不装工具进 Atlas 依赖;升格条件 = 真实仓**副本**试跑信噪比证据。与 M 系列变异钉正交(M 向判据注入脏数据,变异测试篡改生产代码考测试)。
13
+ * **同步面**:`DESIGN.md` D175、`docs/map/gates.md` 与 `docs/map/atlas-map.html` 行数锚(149 → 152 行)。
14
+
15
+ ### 🧷 评审重审范围收敛到「真正变化的内容」(D174)
16
+
17
+ * **改进**:控制台评审陈旧度从「HEAD 指针 + 整目录 `scripts_hash`」改为**逐用例内容指纹**。只改一条 test ⇒ 只有该条需重审;同文件块外代码变化 ⇒ 该文件其余用例需重审;共享脚本(spec / config / setup / prepare 的静态 import 闭包)或应用代码内容变化 ⇒ 全部需重审。commit 与提交账本本身不再作废评审。
18
+ * **指纹形状**:逐用例快照落 `prov.fp = {script, block, rest, shared, app}`;应用代码读**工作区内容**(git 已跟踪 + 未忽略新文件,排除 E2E 环 / `.atlas` / `node_modules` / `*.md`);未被引用的探针脚本不参与;相对 import 解析不出 ⇒ 保守回退全部非 spec 脚本。
19
+ * **兼容**:旧账本条目(无 `fp`)沿用 D128 的 HEAD + 全局散列口径(fail-closed),重新评审提交后升级。
20
+ * **测试**:`tests/test_e2e_console.py` 25 → 28。
21
+ * **契约 / 设计**:`rings/e2e/reference.md` §10.1、`DESIGN.md` D174、`shared/gates.md`、`docs/innovations/e2e-console.md` §5、`docs/map` 行数与锚。
22
+
23
+ ### 🛡️ E2E 审查门输入指纹 + 页面级覆盖就绪(issue #48 / #49)
24
+
25
+ * **修复(#48)**:`gen_e2e_scripts.py` 的独立审查门在读取最新报告 `status` 之外,核对 `inputs/MANIFEST.json` 与本次待生成分片的 `sha256`;分片在审查后变化 ⇒ `stale`,`--apply` 拒绝写盘。`basis: auto-baseline` 的报告不再与真审同权,只在 `origin: adopt` / `e2e.review.mode: auto-baseline` 显式通道放行(WARN)。
26
+ * **修复(#49)**:页面表新增可选 `覆盖就绪` 列(`是/否/—`);`是` 让「实现中」页参与 AC 反向覆盖与 AC×覆盖维度对账,缺列 / `—` 回落 `实现状态 == 已实现`;零挂靠 AC 无页归属,反向覆盖门仍要求域内全部页覆盖就绪(避免误报),有页级用例的 AC 与维度门按页级 ready。`validators/validate_e2e_index.py` 的两道追溯门从「域全已实现」改为页级 ready;模板 / 契约同步。
27
+ * **测试**:`tests/test_gen_e2e_scripts.py` 118 → 124;`tests/test_validators.py` 168 → 180;`tests/test_atlas_check.py` 48 → 49。
28
+ * **契约 / 设计**:`rings/e2e/reference.md` §3.2 / §8.2 / §12.6、`shared/independent-review.md` §4、`templates/e2e/e2e-index.md`、`DESIGN.md` D173、`shared/gates.md` 计数。
29
+
30
+ ---
31
+
32
+ ## [v0.2.0-beta.22] - 2026-10-30
33
+
34
+ ### 🧭 FAIL 修复指引试点(RFC-20261030-01)
35
+
36
+ * **新增**:`validate_testids` / `validate_e2e_index` / `validate_ac_alignment` 的 7 条核心 FAIL 追加 `|修复: <最小动作>|复跑: <命令>` 后缀;不改判定、状态与退出码,PASS / WARN / N/A 不加。
37
+ * **命令自适配**:装配态提示 `python3 .atlas/scripts/atlas_check.py --fast`,源包态提示 `python3 scripts/atlas_check.py --fast`,不依赖全局 CLI。
38
+ * **文档**:`docs/concepts/architecture.md` 新增「Agent Harness 五子系统 ⇄ Atlas 资产」类比映射;`shared/gates.md` 登记反馈格式约定。
39
+ * **同步面**:`shared/gates.md` 门册计数(`tests/test_validators.py` 168 条 / `tests/test_validate_ac_alignment.py` 62 条)、`docs/map/gates.md` 行数与测试文件数(36 → 42)。
40
+
41
+ ---
42
+
7
43
  ## [v0.2.0-beta.21] - 2026-10-29
8
44
 
9
45
  ### 🐛 api 适配器运行时 schema 只认构造调用(D172 / #46)
package/DESIGN.md CHANGED
@@ -646,6 +646,9 @@ adapters:
646
646
  | D170 | **Go 结构适配器(API / 测试清单 / 模型)(Batch D3b)**(2026-10-29;`adapters/api.py`、`adapters/tests.py`、`adapters/models.py`) | ① **病灶**:Go 后端的 `api` / `tests` / `models` 事实只能 agent 手写,结构↔代码对账缺口大。② **定案**:`api.py` 识别 gin/echo/chi/net/http 常见注册(`r.GET`、`http.HandleFunc("GET /x")`、`mux.HandleFunc("/x")`);无动词记 `ANY`,`_test.go` 不扫。`tests.py` 盘点 `*_test.go` + `go.mod`,命令 `go test ./...`;其它配置族冲突置 null + WARN。`models.py` 解析 Go struct 的 `gorm`/`json`/`db` tag(主键/自增/非空/唯一/size/column、`TableName()`),不猜表名。③ **被否**:Go AST/JavaParser(行级正则足够);按 `.Any` 猜具体动词(记 ANY);内置 Go AC extractor(v1 用 `extractor_command` 外部适配器,避免再铺一套测试标记 parser);Go 内嵌 struct/接口纳入 v1(边界明示)。④ **钉**:`tests/test_adapter_api.py` 9 条(Go 路由/注释忽略);`tests/test_tools.py` 31 条(Go 命令/配置冲突);`tests/test_adapter_models.py` 12 条(Go struct/tag/TableName/_test.go 排除);变异 **M-GO1/M-GO2/M-GO3** 均咬,逐字节还原一致。⑤ **边界**:Go AC 门需 `extractor_command`(无内置 Go AC extractor);Go 内嵌 struct/接口/多 backend app 逐 app 归属不在 v1;Kotlin/TestNG 不在 v1。 |
647
647
  | D171 | **真实仓烟测修复:`.scratch` / MyBatis-Plus / NestJS-Midway**(2026-10-29;`adapters/_lib.py`、`adapters/_jpa.py`、`adapters/api.py`) | ① **病灶(真实仓复现)**:Java 仓 `.scratch/` 下的影子测试被当测试源(182→166 的重复事实);MyBatis-Plus `@TableName` 实体不被 JPA 解析器识别(0 张表),且 getter 的 `return id;` 被误当字段(`type=return`、重复行);NestJS/Midway `@Controller` + `@Get` 控制器产出 0 端点。② **定案**:共享忽略集补 `.scratch`/`target`/`.gradle`/`.idea`;`_jpa.py` 支持 `@TableName`/`@TableId`/`@TableField`、普通基类字段合并(仅 MyBatis entity)、跳过 `return/throw/...` 行;`api.py` 新增 `@Controller(prefix)` + `@Get/@Post/.../@All` 路由(无动词 `ANY`)。③ **被否**:把 `.scratch` 当合法测试源;继续只认 JPA(真实仓是 MyBatis-Plus);用裸 `@Get` 全仓扫描(只处理含 `@Controller` 的 TS/JS 文件)。④ **钉**:`tests/test_tools.py` 33 条(`.scratch` / `.atlas` 排除);`tests/test_adapter_models.py` 14 条(MyBatis 表/基类字段/getter 不产 `return` 行);`tests/test_adapter_api.py` 10 条(NestJS 控制器);变异 **M-REAL1..3** 均咬,逐字节还原。真仓烟测:nanwang AC 1/1 PASS、MyBatis 数据模型生成;cn-console AC 1/1 PASS、API 51 条、测试 20 条、`atlas check --fast` 无失败。⑤ **边界**:`.scratch` 是项目约定(不保证别名);MyBatis XML/注解 SQL 不产模型;NestJS DTO `class` 未纳入 TS 模型(真实 core 无 DTO 类,暂不铺)。 |
648
648
  | D172 | **api 适配器运行时 schema 只认构造调用(AST)**(2026-10-29;issue #46) | ① **病灶**:`SCHEMA_MARKERS` 按原始文本匹配 `.py`,任何文件里的 `APIRouter(`/`FastAPI(` 字面量——适配器自身注释、自身正则源码、测试夹具字符串——都被当成框架事实 ⇒ 输出 `spec_entry="/openapi.json"` + `items=0` 的**假阳性指针**(仓内无描述文件,且 `refresh_structure`/`validate_structure` 会把它当合法指针接线)。issue 上报的另一半「`_lib.IGNORE_DIRS` 未排除 `.atlas/.trellis`」已随 D171 落地(带回归钉)。② **定案**:判定改走 AST——`ast.Call` 的 `func` 为 `Name`/`Attribute` 且名字 ∈ {`FastAPI`,`APIRouter`}(含 `from fastapi import FastAPI as App` 的别名展开);注释/文档字符串/字符串字面量天然不是 `Call`。文本预筛(不含标记名不解析)控开销;解析失败(py2/非法源码)WARN 跳过该文件,**不回退文本匹配**(回退即把缺陷带回;跑不起来的文件不构成运行时 schema 证据)。测试文件(`tests?/`、`test_*.py`、`*_test.py`、`conftest.py`)整类不作描述源判定——夹具里真起一个 app 也不是产品接口面。共享忽略集补 `.adapter-out`。③ **被否**:按路径豁免扫描器自身(挡不住把适配器源码拷进项目的形态);把源码枚举根限定 `apps[].path`(生产本就按 `--app` 调用,根级 `path: .` 的 app 触发面不变;§3.5「整项目」只约束**描述文件**探测);语法错误回退文本正则;`openapi_url=None` 等框架细节(登记边界不铺)。④ **钉**:`tests/test_adapter_api.py` +3(真构造调用仍产指针 / 别名构造 / 注释+字符串+夹具+适配器源码拷贝不产指针)+ `.adapter-out` 夹具入既有排除钉。⑤ **同步面**:`shared/stack-profile.md` §3.5、`adapters/api.py` 注释、map 行数锚。 |
649
+ | D173 | **E2E 审查门输入指纹 + 页面级覆盖就绪(issue #48 / #49)**(2026-10-30;`scripts/gen_e2e_scripts.py`、`validators/validate_e2e_index.py`) | ① **病灶**:审查门只读最新报告 `status`,旧 PASS 可为新分片放行;baseline 报告与真审同权;AC 覆盖门以「域内页面全已实现」为前提,长期「实现中」的增量页整域跳过。② **定案**:报告 `inputs/MANIFEST.json` 对本次待生成分片逐一核对 `sha256`,不匹配判 `stale`;baseline 只在 `origin: adopt` / `e2e.review.mode: auto-baseline` 显式通道放行;页面表加可选 `覆盖就绪` 列(`是/否/—`),`是` 让实现中页参与 AC 反向覆盖与维度门,缺列回落 `实现状态 == 已实现`;零挂靠 AC 无页归属,反向覆盖门仍要求域内全部页覆盖就绪(避免误报),有页级用例的 AC 与维度门按页级 ready。③ **被否**:用 mtime 判陈旧(checkout/编辑器保存会误伤);只按报告 `scope` 字符串匹配用例 ID(多页增量无法精确);强制整页全量复审(成本过高)。④ **钉**:`tests/test_gen_e2e_scripts.py` +6(stale / 缺 manifest / baseline 非同权 / stale 自愈 / 输入清单边界 / 同日真报告复用基线);`tests/test_validators.py` +12(覆盖就绪参与/跳过/词表/维度 + mixed-page 2 + `已实现+否` 1 + mixed 零挂靠 1 + 备注列/全 `—` 混合 2 + 模板表头 1 + 多表头 1);`tests/test_atlas_check.py` +1(manifest cases_dir override);⑤ **边界**:`覆盖就绪` 是项目显式 opt-in,不替代 `实现状态`;测试分片以外的输入变更不纳入本指纹。 |
650
+ | D174 | **评审陈旧度改为逐用例内容指纹(用户 2026-10-30 拍板;`scripts/e2e_console.py`、`templates/e2e/console/app.js`、`tests/test_e2e_console.py`;契约 §10.1)** | ① **病灶**:D128 的 `scripts_hash` 是**整目录一个摘要** + `head_sha` 全局比较 ⇒ 改一条用例、提交一次账本都会把全部已审用例翻成 STALE。v3 现场:16:54 提交的 16 条,16:59 一次 commit 后全部 `HEAD变动`;工作区改一个 spec 又全部 `脚本变动`——「审过的还得全审」把重审成本推到不可用。② **定案**:评审指纹从「提交指针 + 目录摘要」改为**内容指纹**,逐用例落 `prov.fp = {script, block, rest, shared, app}`:`block` = 该 `test(...)` 行到下一个 `test(` 行的原文(只改一条 test ⇒ 只作废该条);`rest` = 同文件用例块之外的原文(imports / 文件内 helper 变化 ⇒ 该文件全部用例作废);`shared` = 从 spec / `playwright.config.ts` / `*.setup.ts` / `atlas-prepare.mjs` 出发**静态 import 闭包**可达的非 spec 脚本聚合散列(未被引用的探针不参与;相对 import 解析不出 ⇒ 保守回退全部非 spec);`app` = 应用代码**工作区内容**散列(git 已跟踪 + 未忽略新文件,排除 E2E 环 / `.atlas` / `node_modules` / `*.md`)——commit 与提交账本本身不再触发重审,只有内容真变才触发;用例换脚本文件或当前脚本里找不到 ⇒ 作废。旧账本条目(无 `fp`)沿用 D128 的 HEAD + 全局散列口径(fail-closed,重提交后升级)。③ **被否**:只做「按 spec 文件」而不区分单条(v3 最大文件装 33/58 条,仍不可用);按 mtime 判陈旧(checkout / 编辑器保存误伤);逐个脚本算应用哈希再全量比较(成本高且不解决连坐);未引用的探针也算共享(改探针会误伤全部用例);放弃代码变动校验但不引入内容指纹(丢「代码变了要复验」防线)。④ **钉与变异**:`tests/test_e2e_console.py` 25 → **28**——改写 `test_case_review_state_lifecycle`(HEAD / 全局散列变化仍 fresh;块 / rest / 共享 / 应用各自作废;换文件 / 找不到 / 旧条目回退)+ 新增 `test_case_fingerprints_slice_per_case_and_share_rest` / `test_shared_scripts_import_closure_ignores_unreferenced_probes` / `test_app_fingerprint_content_not_commits`(真实 git 靶场:commit 不失效、src 内容变才失效);变异 **M156**(摘 `fp` 分支 ⇒ lifecycle + submit 两钉红)/ **M157**(`rest` 恒空 ⇒ 切片钉红)/ **M158**(共享闭包改回全部非 spec ⇒ 探针钉红)/ **M159**(应用指纹退回 `head_sha` ⇒ 提交不失效钉红),四段协议还原逐字节一致(sha256 复核)。⑤ **边界**:块内文本按行切分(用例间夹层归前一条),「这条断言是否等价」仍归人;应用指纹只覆盖 git 工作区(非 git 项目记 null,跳过该维度);`e2e-index.md` 的用例定义变化不在本指纹(另行处理);旧账本条目在下一次评审提交前仍按 D128 口径显示 stale。 |
651
+ | D175 | **登记「测试有效性 / CRAP」候选观测面(用户 2026-10-30 拍板;`shared/gates.md` §3/§4)** | ① **病灶**:对账平面索引缺「测试有效性」与「复杂度×覆盖组合」两面——现行套件(含 M 系列变异钉)证明「判据/实现坏了测试会红」,未证明「测试能杀死生产代码变异」;覆盖率与复杂度单看各有盲区。② **定案**:按「少建投机硬门」先登记缺口不建门——§3 新增「测试 ⇔ 生产代码变异(变异得分)」「实现代码 ⇔ 覆盖×复杂度(CRAP)」两行候选观测面(❌ 缺口、**非门**),§4 另立「候选观测面(非门)」小节;来源 = ub-mode(Uncle Bob)思想;升格条件 = 真实仓**副本**试跑信噪比证据(原目录只读),升格形态 WARN/N-A、不设 profile 开关。③ **被否**:直接装 Crapper/Dryer/Mutator 进 Atlas 依赖并建 FAIL 门(投机硬门;三工具为 Python 生态,候选烟测仓 cn-console / nanwang 均无 Python);把变异测试写成 M 系列的「工具化对偶」(两者**正交**:M 系列向判据注入脏数据,变异测试篡改生产代码考测试)。④ **钉**:纯文档登记,无新机器判据;行数锚同步(`docs/map/gates.md` / `atlas-map.html`,149 → 152 行)。⑤ **边界**:试跑批(语言 go/no-go 最小夹具 → TS/Java 工具选型 StrykerJS / PIT → 真仓副本信噪比报告 → 升格/搁置/退役)另行确认后执行;本登记不构成任何门的取证。 |
649
652
  ---
650
653
 
651
654
  ## 13. 待实现事项
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Atlas — 践行 Project Harness 的全流程 AI 编程工作流
2
2
 
3
3
  <p align="left">
4
- <img src="https://img.shields.io/badge/version-0.2.0--beta.21-blue.svg" alt="Version" />
4
+ <img src="https://img.shields.io/badge/version-0.2.0--beta.23-blue.svg" alt="Version" />
5
5
  <img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" />
6
6
  <img src="https://img.shields.io/badge/tests-passing-success.svg" alt="Tests" />
7
7
  <a href="https://sunwenzhe-git.github.io/atlas/"><img src="https://img.shields.io/badge/docs-VitePress-green.svg" alt="Docs" /></a>
@@ -313,10 +313,11 @@ Atlas 随包自带了一个**纯 Python 标准库编写的本地轻量级可视
313
313
  * 向 `<资产根>/e2e/reviews/console/events.jsonl`(默认 `product/e2e/reviews/console/events.jsonl`)追加一条不可篡改的事件快照;
314
314
  * 派生人读的 `ledger.md` 评审账本;
315
315
  * 原子回写总索引 `<资产根>/e2e/e2e-index.md`(默认 `product/e2e/e2e-index.md`)的状态列。
316
- 5. **评审指纹与陈旧度(代码一变,勾就作废)**:
317
- * 每条提交事件与逐用例快照都绑定工作区指纹 `prov = {head_sha, scripts_hash, git_dirty, actor_type}`——**人审的到底是哪一版,账本自己说得出**;
318
- * `scripts_hash` 只对源码脚本与数据准备钩子敏感,**对运行期产物免疫**(`test-results/`、trace、截图一律不入指纹),不会跑一轮就假漂移;
319
- * 控制台按当前指纹实时复算 `review_state` 四态:`fresh`(有效)/ `stale`(HEAD 或脚本已变 ⇒ 标红「需重审」、禁勾、不计已评审)/ `legacy`(旧账本无指纹,不冒充已核验)/ `unreviewed`;
316
+ 5. **评审指纹与陈旧度(内容一变,勾就作废;无关改动不连坐)**:
317
+ * 逐用例快照绑定内容指纹 `prov.fp = {script, block, rest, shared, app}`——**人审的到底是哪一版,账本自己说得出**;
318
+ * 只改一条 test ⇒ 只作废该条;改同文件块外代码 ⇒ 该文件其余用例作废;共享脚本(spec/config/setup/prepare 的静态 import 闭包)或应用代码内容变 ⇒ 全部作废;commit 与提交账本本身不作废评审;
319
+ * 指纹**对运行期产物免疫**(`test-results/`、trace、截图一律不入指纹),不会跑一轮就假漂移;
320
+ * 控制台按当前指纹实时复算 `review_state` 四态:`fresh`(有效)/ `stale`(上述内容已变 ⇒ 标红「需重审」、禁勾、不计已评审)/ `legacy`(旧账本无指纹,不冒充已核验)/ `unreviewed`;
320
321
  * 重新执行一条用例会**自动清空它的旧勾选**——杜绝「上次审过」挂在新的执行结果上;
321
322
  * 顶栏单列「需重审」计数,「只看未评审」筛选同样把 stale/legacy 一并捞出来。
322
323
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas-project-harness",
3
- "version": "0.2.0-beta.21",
3
+ "version": "0.2.0-beta.23",
4
4
  "description": "践行 Project Harness 的全流程 AI 编程工作流治理引擎",
5
5
  "bin": {
6
6
  "atlas": "bin/atlas.js"
@@ -73,8 +73,10 @@ product/e2e/
73
73
 
74
74
  `e2e-index.md` 含三张表。**页面表**(E1 起,页 ↔ 路由 ↔ **实现状态**的唯一真相源,生成器 URL 的唯一来源)列(顺序固定):
75
75
 
76
- | 页面 | 路由 | 域 | 实现状态 |
77
- |---|---|---|---|
76
+ | 页面 | 路由 | 域 | 实现状态 | 覆盖就绪(可选) |
77
+ |---|---|---|---|---|
78
+
79
+ **`覆盖就绪`(可选第 5 列,2026-10-30,issue #49)**:`是` / `否` / `—`(空同 `—`)。`是` ⇒ 即使 `实现状态` 为 `实现中` / `未开始`,该页也参与 AC 反向覆盖与 AC×覆盖维度对账;`否` ⇒ 显式不参与(含 `已实现` 页);缺列 / `—` ⇒ 回落到 `实现状态 == 已实现`(存量项目行为不变)。**出现该列即切页级作用域(即使全 `—`);只有缺列才保留旧域级判据。`—` 只是逐页值的回落,不是作用域开关。** 该列只影响追溯门的作用域,不改变 testid 双向集合门与 `实现状态` 的判据。
78
80
 
79
81
  (`页面` = slug;`路由` **以 `/` 开头**且全表唯一,**可含 hash 段**(如 `/console/#/app/agent/detail`——hash 路由应用的壳前缀与 `#` 都属**路径形态**;`#` 必须紧邻 `/`,否则会被 `.atlas-case` 的行内注释语义吞掉,见 §5.5);**宿主注入的 query(如 tenant)不进 `路由`**,由 `e2e.app_base_url` 承载(见 §5.5「宿主注入型 base query 的转发」);`域` = 域级 PRD 的域 slug;`实现状态` ∈ `已实现` / `实现中` / `未开始`。主表引用的每个 `页面` 必须在页面表里。)
80
82
 
@@ -330,7 +332,7 @@ testid:
330
332
  - **零变化承诺**:项目未声明 `e2e.targets` 且无用例钉名 ⇒ 生成物与未加本字段时逐字节一致(出厂测试钉住)。
331
333
  - **`覆盖维度:`(可选,2026-10-21 补,`DESIGN.md` D130)**:**E2E 通道 C01-C14 场景维度列表**。行形态 `- <维度ID>`(多条表示覆盖多个场景,取值见 §5.2 闭集)。
332
334
  - **为何要它**:既有「AC 反向覆盖门」只能回答「每条 AC 有没有至少一条用例指向」,**无法回答每条 AC 的异常/边界/权限/会话/状态转换是否覆盖**;导致用例可能生成大量表面正向用例,而真正易错的异常分支全部落空。
333
- - **对账规则(`validate_e2e_index.py` 的「AC × 覆盖维度」门)**:域级 PRD 验收标准表可选声明第 4 列「适用覆盖维度」(如 `C01, C02`);校验器对「已实现域且已有用例挂靠」的 AC 聚合用例维度进行差集比对;缺维度 ⇒ **WARN**(先 WARN 后 FAIL,不产生假阻断)。未声明第 4 列的项目按 `N/A` 兜底,不报假缺口。**第 4 列 = E2E 通道;后端列 `后端适用覆盖维度` 由 `validate_ac_alignment.py` 另查(D153),本门不读、不并集。**
335
+ - **对账规则(`validate_e2e_index.py` 的「AC × 覆盖维度」门)**:域级 PRD 验收标准表可选声明第 4 列「适用覆盖维度」(如 `C01, C02`);校验器对「覆盖就绪页且已有用例挂靠」的 AC 聚合用例维度进行差集比对;缺维度 ⇒ **WARN**(先 WARN 后 FAIL,不产生假阻断)。未声明第 4 列的项目按 `N/A` 兜底,不报假缺口。**无 `覆盖就绪` 第 5 列的旧 4 列表沿用「域内页面全 `已实现` 才参与」的域级判据;出现该列后按页级 ready 判(即使全 `—` 也按页级)。** **第 4 列 = E2E 通道;后端列 `后端适用覆盖维度` 由 `validate_ac_alignment.py` 另查(D153),本门不读、不并集。**
334
336
  - **断言数 ≥ 1**:`expected` 至少一行断言动词。
335
337
  - `intent` = **可证伪的一句话声明**(这条用例证明什么);不得写成"页面显示正确"这类模糊表述。
336
338
 
@@ -598,8 +600,8 @@ testid:
598
600
 
599
601
  | 项 | 判定 |
600
602
  |---|---|
601
- | AC 反向覆盖 | 追溯链末段「AC → 用例」(`DESIGN.md` §5.2「功能 → 规则 → AC → E2E 用例,双向强制」):**域内页面全 `已实现`** 的域,其域级 PRD §五 定义的每个 AC 必须被索引主表 ≥ 1 条 `关联AC` 指向(或登记于不可验清单,见下);零挂靠 ⇒ **WARN** + 逐域清单。含 `实现中` / `未开始` 页面的域**不参与**(用例尚未长出,不是缺口)——分级沿用 §8.1 testid 门先例。覆盖关系为**派生量**(§3.2),不落手工表。**豁免落点已定形**(2026-10-12,`3509 §B201-3`):域级 PRD §五「不可验 AC 清单」(PRD 环契约 §五,小节标题固定)登记当前 UI 无观测面的 AC,本门从零挂靠清单中**减去**它;清单自洽(幽灵豁免引用了不存在的 AC)由 `validate_prd.py` 参数 4 检 FAIL。**升 FAIL 通道就绪**:级别改 FAIL 的唯一前置 = 三域残留逐条裁决清零(补用例 / 登记不可验,归 P9,随各域 B124 走查批)——先让数据能过、再上严判(`3509 §B36` 过早 FAIL 被量推翻的教训) |
602
- | AC × 覆盖维度 | **E2E 场景维度对账(2026-10-21,`DESIGN.md` D130;2026-10-26 D153 明确通道边界)**:域级 PRD §五 验收标准表第 4 列可选声明「适用覆盖维度」(`C01-C14` E2E 通道闭集,如 `C01, C02`);对全已实现域中已有用例挂靠的 AC,比对分片 `覆盖维度:` 聚合结果。适用维度未被任一用例覆盖 ⇒ **WARN** + 缺口列表;发现未知维度词 ⇒ **WARN**;未声明第 4 列的项目 ⇒ **N/A** 兜底(按需启用,零误报)。先 WARN 后 FAIL。**本门只查第 4 列 E2E;后端列 `后端适用覆盖维度` 归 `validate_ac_alignment`(D153),不并集。** |
603
+ | AC 反向覆盖 | 追溯链末段「AC → 用例」(`DESIGN.md` §5.2「功能 → 规则 → AC → E2E 用例,双向强制」):**域内页面全部覆盖就绪**的域(`覆盖就绪: 是`,或缺列/`—` 且 `实现状态=已实现`;显式 `否` 不参与),其域级 PRD §五 定义的每个 AC 必须被索引主表 ≥ 1 条 `关联AC` 指向(或登记于不可验清单,见下);零挂靠 ⇒ **WARN** + 逐域清单。`实现中` / `未开始` 且未显式 `覆盖就绪: 是` 的域**不参与**(用例尚未长出,不是缺口)——分级沿用 §8.1 testid 门先例;2026-10-30 issue #49 把「域全已实现」改为页级 opt-in,避免长期「实现中」的增量页整域绕过。覆盖关系为**派生量**(§3.2),不落手工表。**豁免落点已定形**(2026-10-12,`3509 §B201-3`):域级 PRD §五「不可验 AC 清单」(PRD 环契约 §五,小节标题固定)登记当前 UI 无观测面的 AC,本门从零挂靠清单中**减去**它;清单自洽(幽灵豁免引用了不存在的 AC)由 `validate_prd.py` 参数 4 检 FAIL。**升 FAIL 通道就绪**:级别改 FAIL 的唯一前置 = 三域残留逐条裁决清零(补用例 / 登记不可验,归 P9,随各域 B124 走查批)——先让数据能过、再上严判(`3509 §B36` 过早 FAIL 被量推翻的教训) |
604
+ | AC × 覆盖维度 | **E2E 场景维度对账(2026-10-21,`DESIGN.md` D130;2026-10-26 D153 明确通道边界;2026-10-30 D173 页级覆盖就绪)**:域级 PRD §五 验收标准表第 4 列可选声明「适用覆盖维度」(`C01-C14` E2E 通道闭集,如 `C01, C02`);对覆盖就绪页上已有用例挂靠的 AC,比对分片 `覆盖维度:` 聚合结果(只采信覆盖就绪页上的用例声明)。适用维度未被任一用例覆盖 ⇒ **WARN** + 缺口列表;发现未知维度词 ⇒ **WARN**;未声明第 4 列的项目 ⇒ **N/A** 兜底(按需启用,零误报)。先 WARN 后 FAIL。**本门只查第 4 列 E2E;后端列 `后端适用覆盖维度` 归 `validate_ac_alignment`(D153),不并集。** |
603
605
 
604
606
  > 语义判不了的项("没有它什么 bug 会漏"、去重 intent、"这个断言是否真的异源")**不进校验器**,写在 §10 的执行纪律里由 agent 执行。依据:Autonoma 用 regex 判"句子意思"的代价是 **680 次重试 / $11 / 零用例**(`research/e2e-open-source-survey.md` §1.7 A12)。
605
607
  >
@@ -673,7 +675,7 @@ testid:
673
675
  | 只读输入 | `product/e2e/e2e-index.md` + `product/e2e/cases/*.md`;格式解析**复用** `validate_e2e_index.py`,不写第二份 |
674
676
  | 执行 | 一次一条(**并发 = 1**)、node 单轨(`<e2e.node_modules>/.bin/playwright test -c playwright.config.ts <file>.spec.ts:<line> --headed`(慢动作经 `ATLAS_SLOWMO` 注入配置的 `launchOptions.slowMo`;F11,2026-10-14),可执行解析与 `atlas_check`「E2E 真跑」门同一规则(含**浏览器就绪判据**,2026-10-20,`DESIGN.md` D124:`ATLAS_PLAYWRIGHT_CHANNEL` 未设时,playwright 自算的 chromium 可执行路径必须**存在**——npm 装包不自动下载浏览器,缺 ⇒ 起跑前诚实拒绝并点名 `npx playwright install chromium`);缺或不可达 ⇒ 服务端诚实拒绝,不起注定失败的子进程;「跑完留窗 + 结果哨兵」由 `atlas-hold.ts` 承载,2026-10-12 迁移批;**持窗模式向运行器传 `--timeout=0`(2026-10-21,`DESIGN.md` D126)**——Playwright 默认 test timeout(30s)**计入 fixture teardown**,留窗等待会在约 30s 处被判超时并强制关窗(浏览器「跑完仍自动关闭」的真因;哨兵此时已写 `exitstatus:0`,界面还会误示「已保留」);控制台的 wall-clock `--timeout` 是唯一超时真相源(到点 kill 并如实报「超时」),自动关窗模式不传该旗标、保留运行器原生 30s 兜底);**用例执行前数据准备钩子(硬,2026-10-18,`DESIGN.md` D110;v3 现场实证移植批)**:项目自带 `product/e2e/scripts/atlas-prepare.mjs` ⇒ 每条用例执行前先跑它(`node`,cwd = 项目根,180s 上限、**不占**用例超时窗——在计时起点之前;运行时 env 必带 `ATLAS_CASE_ID` = 当前用例 ID 且同步注入 `NODE_PATH`(与真跑子进程依赖解析环境一致,2026-10-20,`DESIGN.md` D125);项目脚本可据此只准备该用例依赖的数据,不读该变量的存量脚本行为不变——2026-10-20,`DESIGN.md` D121);失败/超时/起不动 ⇒ **诚实拒绝执行该条并在 UI 显式分流为「前置失败」**(`done` 事件完整形状 + `prep_failed` 标记,UI 呈现为 warn 级 pill 与「与用例脚本本身无关」通告,消除误导;失败原文进「输出」面板),不静默跑注定偏移的用例(共享状态型靶场的残留实体会让 count 类断言系统性偏移);地址 = `e2e.app_base_url`(E1 单段;来源下拉随 E1 删除,`#155`——meta 行保留 base_url 展示)。**已提交视图(2026-10-21,`DESIGN.md` D127)**:控制台「已提交」= 事件流中**所有 submit 快照按序累积合并**(后提交覆盖同名用例),不是最后一条事件;单次提交只携带页面草稿(差量),避免部分提交抹掉历史用例。**blocked / skipped 用例执行按钮禁用**(title 带索引状态原因;服务端同判诚实拒绝,`#158`——生成物为 `test.skip` 本无 node id,不得误报「重跑生成」);**验收执行器(2026-10-25,`RFC-20261025-02` / `DESIGN.md` D150;backfill = 执行层②,2026-10-26 附录 B v2 / `DESIGN.md` D151 随评审通过落刀)**:真跑可经 `scripts/atlas_verify.py` 起跑(步序 pre → run → **backfill** → report 留档;**全批只跑一次真跑**);**backfill(仅全链绿批,run 后、留档前)**:claim(`scripts/write_claim.py`,task=`verify-backfill`,粒度 = 整文件 `product/e2e/e2e-index.md`)→ 复用 `e2e_console.writeback` **同一函数**回填索引 `状态` 列(签名/行为零改动;`sys.path[0]` 装配态同目录解析 = 与控制台同副本同版本)→ 写后复检账本基线(events.jsonl 字节/行数 + 尾 submit (ts, prov)——尾 submit 变 ⇒ 撞窗 FAIL,出路 = 人工经 console 重提交)→ release;**机器标记锚 = 执行器留档**(report.json 回填章 + README 声明行),索引行与校验器词表零改动;**评审账本 `events.jsonl` 永不写入**(人签字载体;console 无「导入执行器结果」通道,人工评审 = console 逐条执行解锁);写前三断言(draft 键集 ⊆ 索引 ID / 留档键集 = 改动集 / 报告含章)不过 ⇒ exit 非 0;**批级 `ATLAS_CASE_ID` 语义 = 显式串 `verify-batch`(非用例 ID——逐用例分段准备仍归控制台逐条执行,D121 不变;不读该变量的存量脚本行为逐字节不变)** |
675
677
  | **桩披露(2026-10-12,§5.8)** | 含 `mock:` 行的用例卡片渲染 **MOCKED 徽标**(分片静态事实)+ 展开区 mock 清单;执行输出的 `[atlas-mock]` 命中行随日志流呈现(运行时事实)——人工走查者必须能看见「这条绿是桩撑的」 |
676
- | **评审指纹与陈旧度(2026-10-21,`DESIGN.md` D128)** | 账本快照与每条提交绑定 **HEAD 提交与脚本散列指纹**(`prov: {head_sha, scripts_hash, git_dirty, actor_type}`)。`scripts_hash` 白名单覆盖 `scripts/` 下源码及 `atlas-prepare.mjs`(**候选顺序 = Runner 实际执行面:`product/e2e/scripts/` 优先**),排除运行时产物(`test-results/`、trace、png 等)——否则跑一轮就脏一次。控制台按当前工作区指纹实时重算 `review_state`(`fresh` / `stale` / `legacy` / `unreviewed`):代码或脚本变动 ⇒ **`stale`**,旧账本无指纹 ⇒ **`legacy`**;**两者同等按「需重审」处理**(剔除已评审、禁用复选框、顶栏单列计数、纳入「只看未评审」筛选),**读不到当前指纹时按 `legacy` 而非 `fresh`**(fail-closed,不冒充已核验);重新执行该用例自动清空旧评审态。`actor_type` ∈ `human \| agent`,由环境变量 **`ATLAS_ACTOR_TYPE`** 声明(缺省 `human`;自动化跑请显式设 `agent`)——它是**操作者自述、不是密码学证明**,故本环只如实暴露该字段,收口阻断仍由门与 `shared/closeout.md` 承担。提交另记 `reviewed_scope`(本次签字覆盖的用例集),消费者 = 人读账本 `ledger.md` 的「本次评审范围」行与控制台顶栏(不写不读 = 死字段,禁止)。**当前无 git / 生成脚本未落盘** ⇒ 相应维度记 `null`,不得用空串哈希冒充「匹配」 |
678
+ | **评审指纹与陈旧度(2026-10-21,`DESIGN.md` D128)** | 账本快照与每条提交绑定 **HEAD 提交与脚本散列指纹**(`prov: {head_sha, scripts_hash, git_dirty, actor_type}`)。`scripts_hash` 白名单覆盖 `scripts/` 下源码及 `atlas-prepare.mjs`(**候选顺序 = Runner 实际执行面:`product/e2e/scripts/` 优先**),排除运行时产物(`test-results/`、trace、png 等)——否则跑一轮就脏一次。控制台按当前工作区指纹实时重算 `review_state`(`fresh` / `stale` / `legacy` / `unreviewed`):代码或脚本变动 ⇒ **`stale`**,旧账本无指纹 ⇒ **`legacy`**;**两者同等按「需重审」处理**(剔除已评审、禁用复选框、顶栏单列计数、纳入「只看未评审」筛选),**读不到当前指纹时按 `legacy` 而非 `fresh`**(fail-closed,不冒充已核验);重新执行该用例自动清空旧评审态。`actor_type` ∈ `human \| agent`,由环境变量 **`ATLAS_ACTOR_TYPE`** 声明(缺省 `human`;自动化跑请显式设 `agent`)——它是**操作者自述、不是密码学证明**,故本环只如实暴露该字段,收口阻断仍由门与 `shared/closeout.md` 承担。提交另记 `reviewed_scope`(本次签字覆盖的用例集),消费者 = 人读账本 `ledger.md` 的「本次评审范围」行与控制台顶栏(不写不读 = 死字段,禁止)。**当前无 git / 生成脚本未落盘** ⇒ 相应维度记 `null`,不得用空串哈希冒充「匹配」**逐用例内容指纹(2026-10-30,`DESIGN.md` D174,用户拍板)**:上述「HEAD + 目录摘要」改为**按内容**判定——逐用例快照落 `prov.fp = {script, block, rest, shared, app}`:`block` = 该 `test(...)` 行到下一个 `test(` 行的原文(只改一条 test ⇒ 只作废该条);`rest` = 同文件用例块之外的原文(imports / 文件内 helper 变化 ⇒ 该文件全部用例作废);`shared` = 从 spec / `playwright.config.ts` / `*.setup.ts` / `atlas-prepare.mjs` 出发**静态 import 闭包**可达的非 spec 脚本聚合散列(未被引用的探针不参与;相对 import 解析不出 ⇒ 保守回退全部非 spec);`app` = 应用代码**工作区内容**散列(git 已跟踪 + 未忽略新文件,排除 E2E 环 / `.atlas` / `node_modules` / `*.md`)——commit 与提交账本本身不再触发重审,只有内容真变才触发;用例换脚本文件或当前脚本里找不到 ⇒ 作废。旧账本条目(无 `fp`)沿用 D128 的 HEAD + 全局散列口径(fail-closed,重提交后升级) |
677
679
  | **评审维度与执行状态正交** | 页面 `localStorage` 草稿 = 人的勾选;索引 `状态` 列(`red` / `green` / `blocked` / `skipped`;E0 历史态 `prototype-pass` 见 §5.4)= 执行结果。**两者不得互相推导**;「人工 review 通过」只在**执行通过**后解锁 |
678
680
  | 提交(唯一写盘点) | 点「提交」才一次性落两处:① 账本 `product/e2e/reviews/console/events.jsonl` 追加**一条快照**(append-only,唯一真相源;派生人读 `ledger.md`);② 回写索引 `状态` 列(先预览、确认后写)。不提交 ⇒ 服务端不知道任何过程 |
679
681
  | 运行时产物落点 | 账本与人读件**只落 `product/e2e/reviews/console/`**;控制台本体与静态资源**不落** `product/`(`.atlas/` 才是包的落点,且 `install.sh` 会整包刷新)。界面文案**面向用户**(去黑话:headful →「执行时弹出浏览器窗口」,`#156`);说明/提示样式与表头视觉区分(`#157`) |
@@ -757,6 +759,8 @@ testid:
757
759
  |---|---|---|
758
760
  | 人工评审(console 账本) | **不阻断** | 审查意见是给评审者的**输入** |
759
761
  | **脚本生成(§7.1)** | **阻断** | 报告**存在**(`product/e2e/reviews/` 下**日期前缀最大的审查报告目录**,排除 `<日期>-acceptance-*` / `<日期>-first-run` / `<日期>-verify` 留痕;共享契约 §4)**且** `review.json` 的 `status != "FAIL"`(无 `Critical`) |
762
+ | **报告输入指纹(2026-10-30,issue #48)** | **阻断** | 最新报告的 `inputs/MANIFEST.json` 必须覆盖本次待生成分片,且各分片 `sha256` 与当前文件一致;分片在审查后变化 ⇒ `stale`(不得放行;`origin: adopt` / `e2e.review.mode: auto-baseline` 显式通道除外)。指纹只证明「审查输入=当前分片」,**不证明每个用例都被审过**;逐用例覆盖由 `review.json` 的 `scope` / `expected` 与 §12.2 维度承担 |
763
+ | **baseline 报告(2026-10-30,issue #48)** | **阻断** | `basis: auto-baseline` 的报告不是真审;只有 `origin: adopt` 或 `e2e.review.mode: auto-baseline` 的显式通道放行(WARN),其余项目不得以 baseline 通过 |
760
764
  | 报告缺失 | —— | **缺失 = 未审**,**不是「通过」** |
761
765
 
762
766
  - **两层分开**:「**存在性**」挂在写盘动作(§7.1 前置检查);「**严重性**」由审查器自身退出码承载(`status == "FAIL"` ⇒ 非零)。
@@ -104,6 +104,19 @@ def _git_output(root: Path, *args: str) -> str | None:
104
104
  return None
105
105
 
106
106
 
107
+ def _git_paths(root: Path, *args: str) -> list[str] | None:
108
+ """`git -C root <args>` 的 NUL 分隔路径输出(配 `-z` 用,绕开 core.quotePath 转义)。"""
109
+ try:
110
+ res = subprocess.run(["git", "-C", str(root), *args], capture_output=True, check=True)
111
+ except Exception:
112
+ return None
113
+ return [p for p in res.stdout.decode("utf-8", "replace").split("\0") if p]
114
+
115
+
116
+ def _sha16(data: str | bytes) -> str:
117
+ return hashlib.sha256(data.encode("utf-8") if isinstance(data, str) else data).hexdigest()[:16]
118
+
119
+
107
120
  def repo_head(root: Path) -> str | None:
108
121
  return _git_output(root, "rev-parse", "HEAD")
109
122
 
@@ -151,6 +164,226 @@ def scripts_hash(root: Path) -> str | None:
151
164
  return h.hexdigest()[:16]
152
165
 
153
166
 
167
+ def script_file_map(root: Path) -> dict[str, Path]:
168
+ """白名单脚本的逐文件清单 `{相对路径: 路径}`。
169
+
170
+ 白名单与 `scripts_hash` 同源(源码脚本 + 可选 `atlas-prepare.mjs`,排除运行期产物),
171
+ 但按文件去重——逐用例指纹与共享闭包都从这里取,不另写第二份枚举。
172
+ """
173
+ s_dir = load_layout(root).e2e_scripts_dir
174
+ if not s_dir.is_dir():
175
+ return {}
176
+ out: dict[str, Path] = {}
177
+ for p in sorted(s_dir.rglob("*")):
178
+ if not p.is_file():
179
+ continue
180
+ rel = p.relative_to(s_dir).as_posix()
181
+ if any(part in rel for part in ("test-results", "playwright-report", ".atlas-auth", "node_modules")):
182
+ continue
183
+ if rel.endswith((".trace", ".png", ".webm", ".last-run.json")):
184
+ continue
185
+ if rel.endswith((".ts", ".js", ".mjs", ".json", ".yaml", ".yml")):
186
+ out[f"scripts/{rel}"] = p
187
+ for prep in (s_dir / "atlas-prepare.mjs", root / "atlas-prepare.mjs"):
188
+ if prep.is_file():
189
+ out.setdefault("scripts/atlas-prepare.mjs" if prep.parent == s_dir else "atlas-prepare.mjs", prep)
190
+ break
191
+ return dict(sorted(out.items()))
192
+
193
+
194
+ def script_file_hashes(root: Path) -> dict[str, str]:
195
+ """白名单脚本的逐文件内容散列(16 字符前缀)。"""
196
+ out: dict[str, str] = {}
197
+ for rel, p in script_file_map(root).items():
198
+ try:
199
+ out[rel] = _sha16(p.read_bytes())
200
+ except OSError:
201
+ out[rel] = "missing"
202
+ return out
203
+
204
+
205
+ _CASE_TEST_LINE = re.compile(rf"\s*test\(['\"]({case_id.GRAMMAR})[ '\"].*async")
206
+ _LOCAL_IMPORT = re.compile(r"""(?:\bfrom\s+|\bimport\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)['"](\.[^'"]*)['"]""")
207
+ _JS_EXTS = (".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json")
208
+
209
+
210
+ def case_fingerprints(scripts_dir: Path) -> dict[str, dict]:
211
+ """逐用例脚本指纹 `{用例ID: {script, block, rest}}`。
212
+
213
+ `block` = 该 `test(...)` 行到下一个 `test(` 行之间的原文;`rest` = 同一文件里
214
+ 所有用例块之外的原文(imports / 文件内 helper)。判据:
215
+ * 只改一条用例的块 ⇒ 只有该条 `block` 变;
216
+ * 改块外共享代码 ⇒ 该文件全部用例 `rest` 变(保守:块外代码可能被任一用例调用)。
217
+ 用例之间的夹层文本归属前一条用例(生成物里用例块连续,夹层只有空行/注释)。
218
+ """
219
+ out: dict[str, dict] = {}
220
+ if not scripts_dir.is_dir():
221
+ return out
222
+ for ts in sorted(scripts_dir.glob("*.spec.ts")):
223
+ try:
224
+ lines = ts.read_text(encoding="utf-8").splitlines(keepends=True)
225
+ except OSError:
226
+ continue
227
+ marks: list[tuple[int, str]] = []
228
+ for i, line in enumerate(lines):
229
+ m = _CASE_TEST_LINE.match(line)
230
+ if m:
231
+ marks.append((i, m.group(1)))
232
+ if not marks:
233
+ continue
234
+ spans = [(start, marks[i + 1][0] if i + 1 < len(marks) else len(lines))
235
+ for i, (start, _cid) in enumerate(marks)]
236
+ inside: set[int] = set()
237
+ for start, end in spans:
238
+ inside.update(range(start, end))
239
+ rest = _sha16("".join(line for i, line in enumerate(lines) if i not in inside))
240
+ script = f"scripts/{ts.name}"
241
+ for (start, end), (_i, cid) in zip(spans, marks):
242
+ out[cid] = {"script": script, "block": _sha16("".join(lines[start:end])), "rest": rest}
243
+ return out
244
+
245
+
246
+ def _resolve_local_import(src: Path, spec: str, known: dict[Path, str]) -> str | None:
247
+ base = src.parent / spec
248
+ cands = [base] + [Path(str(base) + ext) for ext in _JS_EXTS]
249
+ cands += [base / f"index{ext}" for ext in _JS_EXTS]
250
+ for cand in cands:
251
+ try:
252
+ rel = known.get(cand.resolve())
253
+ except OSError:
254
+ rel = None
255
+ if rel:
256
+ return rel
257
+ return None
258
+
259
+
260
+ def shared_script_hashes(root: Path) -> tuple[dict[str, str], str | None, bool]:
261
+ """静态可达的非 spec 脚本(config / setup / prepare / helper)逐文件散列与聚合散列。
262
+
263
+ 可达性 = 从 `.spec.ts`、`playwright.config.ts`、`*.setup.ts`、`atlas-prepare.mjs`
264
+ 出发解析相对 import(传递)。没被任何执行入口引用的脚本(如一次性探针)不进指纹
265
+ ——它们不是验收证据,改它们不该作废评审。
266
+ 任一相对 import 解析不出 ⇒ **保守回退**:全部非 spec 脚本都算共享(宁可多判 stale)。
267
+ 返回 `(逐文件散列, 聚合散列, 是否保守回退)`;无共享脚本 ⇒ 聚合为 `None`。
268
+ """
269
+ files = script_file_map(root)
270
+ if not files:
271
+ return {}, None, False
272
+ known: dict[Path, str] = {}
273
+ for rel, p in files.items():
274
+ try:
275
+ known[p.resolve()] = rel
276
+ except OSError:
277
+ pass
278
+ roots = [(rel, p) for rel, p in files.items()
279
+ if p.name.endswith(".spec.ts") or p.name == "playwright.config.ts"
280
+ or p.name.endswith(".setup.ts") or p.name == "atlas-prepare.mjs"]
281
+ if not roots:
282
+ return {}, None, False
283
+ spec_rels = {rel for rel, p in roots if p.name.endswith(".spec.ts")}
284
+ seen: set[str] = set()
285
+ queue = [p for _rel, p in roots]
286
+ unresolved = False
287
+ while queue:
288
+ cur = queue.pop()
289
+ try:
290
+ text = cur.read_text(encoding="utf-8")
291
+ except OSError:
292
+ continue
293
+ for spec in _LOCAL_IMPORT.findall(text):
294
+ if "${" in spec:
295
+ unresolved = True
296
+ continue
297
+ rel = _resolve_local_import(cur, spec, known)
298
+ if rel is None:
299
+ unresolved = True
300
+ continue
301
+ if rel not in seen:
302
+ seen.add(rel)
303
+ queue.append(files[rel])
304
+ if unresolved:
305
+ shared = {rel for rel, p in files.items() if not p.name.endswith(".spec.ts")}
306
+ else:
307
+ shared = ({rel for rel, p in roots if not p.name.endswith(".spec.ts")} | seen) - spec_rels
308
+ hashes: dict[str, str] = {}
309
+ for rel in sorted(shared):
310
+ try:
311
+ hashes[rel] = _sha16(files[rel].read_bytes())
312
+ except OSError:
313
+ hashes[rel] = "missing"
314
+ agg = _sha16("\n".join(f"{rel}\0{h}" for rel, h in hashes.items())) if hashes else None
315
+ return hashes, agg, unresolved
316
+
317
+
318
+ _APP_FILE_CACHE: dict[str, tuple[int, int, str]] = {}
319
+
320
+
321
+ def _is_app_path(rel: str, e2e_rel: str) -> bool:
322
+ if rel == e2e_rel or rel.startswith(e2e_rel + "/"):
323
+ return False
324
+ if rel == ".atlas" or rel.startswith(".atlas/"):
325
+ return False
326
+ if rel == "node_modules" or rel.startswith("node_modules/") or "/node_modules/" in rel:
327
+ return False
328
+ if rel.endswith(".md"):
329
+ return False
330
+ return True
331
+
332
+
333
+ def _content_digest(path: Path) -> str:
334
+ try:
335
+ st = path.stat()
336
+ except OSError:
337
+ return "missing"
338
+ key = str(path)
339
+ hit = _APP_FILE_CACHE.get(key)
340
+ if hit and hit[0] == st.st_mtime_ns and hit[1] == st.st_size:
341
+ return hit[2]
342
+ try:
343
+ digest = _sha16(path.read_bytes())
344
+ except OSError:
345
+ return "missing"
346
+ _APP_FILE_CACHE[key] = (st.st_mtime_ns, st.st_size, digest)
347
+ return digest
348
+
349
+
350
+ def app_fingerprint(root: Path) -> str | None:
351
+ """应用代码的**内容**指纹(与提交指针无关;D174)。
352
+
353
+ 评审绑定的应是「当时被测应用的内容」,不是 `HEAD`:commit 本身、提交账本
354
+ 都不该让全部用例失效。范围 = 工作区里 git 已跟踪 + 未忽略的新文件,
355
+ 排除 E2E 环(脚本/索引/账本)、`.atlas/`、`node_modules/` 与 `*.md`
356
+ (文档不是运行时证据);未提交改动天然计入(读的是工作区内容)。
357
+ """
358
+ if _git_output(root, "rev-parse", "--is-inside-work-tree") != "true":
359
+ return None
360
+ tracked = _git_paths(root, "ls-files", "-z")
361
+ if tracked is None:
362
+ return None
363
+ untracked = _git_paths(root, "ls-files", "-o", "--exclude-standard", "-z") or []
364
+ lay = load_layout(root)
365
+ try:
366
+ e2e_rel = lay.e2e_dir.resolve().relative_to(Path(root).resolve()).as_posix()
367
+ except ValueError:
368
+ e2e_rel = "product/e2e"
369
+ entries = []
370
+ for rel in sorted(set(tracked) | set(untracked)):
371
+ if not _is_app_path(rel, e2e_rel):
372
+ continue
373
+ entries.append(f"{rel}\0{_content_digest(Path(root) / rel)}")
374
+ return _sha16("\n".join(entries))
375
+
376
+
377
+ def review_fingerprint(root: Path) -> dict:
378
+ """`case_review_state` 复算用的当前指纹面(不进 `provenance` 的前端负载)。"""
379
+ _shared, shared_hash, _fallback = shared_script_hashes(root)
380
+ return {
381
+ "scripts": script_file_hashes(root),
382
+ "shared_hash": shared_hash,
383
+ "cases": case_fingerprints(load_layout(root).e2e_scripts_dir),
384
+ }
385
+
386
+
154
387
  def repo_provenance(root: Path) -> dict:
155
388
  """提取当前工作区的代码提交与测试脚本指纹,供评审账本留痕与陈旧度(stale)判定。"""
156
389
  head = repo_head(root)
@@ -164,18 +397,27 @@ def repo_provenance(root: Path) -> dict:
164
397
  "head_short": head[:7] if head else None,
165
398
  "git_dirty": dirty,
166
399
  "scripts_hash": shash,
400
+ "app_hash": app_fingerprint(root),
167
401
  "actor_type": actor_type,
168
402
  }
169
403
 
170
404
 
171
- def case_review_state(snap: dict, current: dict | None) -> dict:
172
- """计算单个用例在当前代码/脚本下的评审新鲜度。
405
+ def case_review_state(cid: str, snap: dict, current: dict | None) -> dict:
406
+ """计算单个用例在当前内容下的评审新鲜度。
173
407
 
174
- 状态闭集:
408
+ 状态闭集(D128 四态语义不变):
175
409
  - unreviewed: 尚未标记评审通过
176
- - fresh: 评审指纹与当前 HEAD 及生成脚本一致(有效评审)
177
- - stale: 评审后 HEAD 或生成脚本已变化(需重跑/重审)
178
- - legacy: 历史账本条目无提交指纹(来源无法验证)
410
+ - fresh: 评审指纹与当前**内容**一致(有效评审)
411
+ - stale: 评审后该用例的脚本块 / 文件内共享代码 / 共享脚本 / 应用代码已变化
412
+ - legacy: 历史账本条目无可用指纹(来源无法验证)
413
+
414
+ D174:新账本条目按「内容」判——
415
+ ① 用例自己的 test 块(`fp.block`)变了 ⇒ 只作废这一条;
416
+ ② 同文件块外代码(`fp.rest`)变了 ⇒ 该文件全部用例作废;
417
+ ③ 共享脚本(`fp.shared`)变了 ⇒ 全部作废;
418
+ ④ 应用代码(`fp.app`)变了 ⇒ 全部作废;
419
+ ⑤ 用例换了脚本文件 / 当前脚本里找不到 ⇒ 作废。
420
+ 旧条目(无 `fp`)沿用 D128 的 HEAD + 全局脚本散列比较(fail-closed,重提交后升级)。
179
421
  """
180
422
  reviewed = snap.get("reviewed") or {}
181
423
  if not reviewed.get("pass"):
@@ -185,6 +427,31 @@ def case_review_state(snap: dict, current: dict | None) -> dict:
185
427
  return {"state": "legacy", "reason": "历史快照无提交指纹,需重审"}
186
428
  if current is None:
187
429
  return {"state": "legacy", "reason": "未提供当前工作区指纹,新鲜度无法核验"}
430
+ fp = prov.get("fp")
431
+ if isinstance(fp, dict):
432
+ mismatches = []
433
+ cur = (current.get("cases") or {}).get(cid)
434
+ script = fp.get("script")
435
+ if script:
436
+ if not isinstance(cur, dict):
437
+ mismatches.append(f"脚本中找不到该用例({script})")
438
+ else:
439
+ if cur.get("script") != script:
440
+ mismatches.append(f"脚本位置变动({script}→{cur.get('script')})")
441
+ elif cur.get("block") != fp.get("block"):
442
+ mismatches.append(f"用例脚本变动({script})")
443
+ if cur.get("rest") != fp.get("rest"):
444
+ mismatches.append(f"同文件共享代码变动({script})")
445
+ if fp.get("shared") and current.get("shared_hash") and fp["shared"] != current["shared_hash"]:
446
+ mismatches.append("共享脚本变动")
447
+ if fp.get("app") and current.get("app_hash") and fp["app"] != current["app_hash"]:
448
+ mismatches.append("应用代码变动")
449
+ if mismatches:
450
+ return {"state": "stale", "reason": ";".join(mismatches), "prov": prov}
451
+ res = {"state": "fresh", "prov": prov}
452
+ if prov.get("git_dirty"):
453
+ res["warning"] = "提交时工作区有未提交改动(dirty),提交后请先落库"
454
+ return res
188
455
  mismatches = []
189
456
  cur_head = current.get("head_sha")
190
457
  old_head = prov.get("head_sha")
@@ -366,7 +633,7 @@ def node_ids(scripts_dir: Path) -> dict:
366
633
  return out
367
634
  for ts in sorted(scripts_dir.glob("*.spec.ts")):
368
635
  for lineno, line in enumerate(ts.read_text(encoding="utf-8").splitlines(), start=1):
369
- m = re.match(rf"\s*test\(['\"]({case_id.GRAMMAR})[ '\"].*async", line)
636
+ m = _CASE_TEST_LINE.match(line)
370
637
  if m:
371
638
  out[m.group(1)] = f"{ts.name}:{lineno}"
372
639
  return out
@@ -525,9 +792,9 @@ class Ledger:
525
792
  s = e.get("reviewed_scope")
526
793
  if isinstance(s, list):
527
794
  scope = [str(x) for x in s]
528
- for snap in merged.values():
795
+ for cid, snap in merged.items():
529
796
  if isinstance(snap, dict):
530
- snap["review_state"] = case_review_state(snap, current)
797
+ snap["review_state"] = case_review_state(cid, snap, current)
531
798
  return {"ts": ts, "actor": actor, "cases": merged,
532
799
  "reviewed_scope": scope, "reviewed_count": len(scope)}
533
800
 
@@ -790,8 +1057,12 @@ def _snap_cases(entry: dict) -> dict:
790
1057
  return cases if isinstance(cases, dict) else {}
791
1058
 
792
1059
 
793
- def _compact(entry: dict, prov: dict | None = None) -> dict:
794
- """落账本的快照条目:失败才带输出尾部(80 行,与运行期同长),避免账本被 traceback 撑爆。"""
1060
+ def _compact(entry: dict, prov: dict | None = None, cid: str | None = None) -> dict:
1061
+ """落账本的快照条目:失败才带输出尾部(80 行,与运行期同长),避免账本被 traceback 撑爆。
1062
+
1063
+ D174:`prov` 带当前指纹面(`cases` / `shared_hash` / `app_hash`)时,逐用例落
1064
+ `prov.fp = {script, block, rest, shared, app}` —— 评审新鲜度按内容复算,不再绑 HEAD。
1065
+ """
795
1066
  out = {"ok": bool(entry.get("ok")), "exit": entry.get("exit"),
796
1067
  "duration_ms": entry.get("duration_ms"), "timeout": bool(entry.get("timeout")),
797
1068
  "ts": entry.get("ts")}
@@ -808,6 +1079,15 @@ def _compact(entry: dict, prov: dict | None = None) -> dict:
808
1079
  "git_dirty": p.get("git_dirty"),
809
1080
  "actor_type": p.get("actor_type", "human"),
810
1081
  }
1082
+ if cid and isinstance(prov, dict) and ("cases" in prov or "shared_hash" in prov):
1083
+ cf = (prov.get("cases") or {}).get(cid) or {}
1084
+ out["prov"]["fp"] = {
1085
+ "script": cf.get("script"),
1086
+ "block": cf.get("block"),
1087
+ "rest": cf.get("rest"),
1088
+ "shared": prov.get("shared_hash"),
1089
+ "app": prov.get("app_hash"),
1090
+ }
811
1091
  if not out["ok"] and entry.get("tail"):
812
1092
  out["tail"] = "\n".join(str(entry["tail"]).splitlines()[-80:])
813
1093
  return out
@@ -871,7 +1151,8 @@ class App:
871
1151
  "ledger": self.layout.print_value("console_ledger"), "cases": [], "nodeids": {},
872
1152
  "committed": {"ts": None, "actor": None, "cases": {}},
873
1153
  "provenance": current_prov}
874
- committed = self.ledger.committed_snapshot(source, current_prov)
1154
+ current = dict(current_prov, **review_fingerprint(self.root))
1155
+ committed = self.ledger.committed_snapshot(source, current)
875
1156
  return {
876
1157
  "source": source,
877
1158
  "sources": read_sources(self.root),
@@ -1031,15 +1312,16 @@ class Handler(BaseHTTPRequestHandler):
1031
1312
  apply = bool(body.get("apply"))
1032
1313
  # 指纹在 writeback 前获取,避免 writeback 自身修改索引使 git_dirty 假阳性(D128)
1033
1314
  prov = repo_provenance(self.app.root)
1315
+ current = dict(prov, **review_fingerprint(self.app.root))
1034
1316
  res = writeback(self.app.root, draft, apply, source)
1035
1317
  if apply:
1036
- snap = {cid: _compact(v, prov) for cid, v in draft.items()}
1318
+ snap = {cid: _compact(v, current, cid) for cid, v in draft.items()}
1037
1319
  reviewed_scope = [cid for cid, v in draft.items() if (v.get("reviewed") or {}).get("pass")]
1038
1320
  self.app.ledger.append({"kind": "submit", "source": source,
1039
1321
  "changed": res["changed"], "cases": snap,
1040
1322
  "prov": prov, "actor_type": prov.get("actor_type", "human"),
1041
1323
  "reviewed_scope": reviewed_scope})
1042
- res["committed_ts"] = self.app.ledger.committed_snapshot(source, prov).get("ts")
1324
+ res["committed_ts"] = self.app.ledger.committed_snapshot(source, current).get("ts")
1043
1325
  res["provenance"] = prov
1044
1326
  self._json(res)
1045
1327