atlas-project-harness 0.2.0-beta.5 → 0.2.0-beta.6

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 (31) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/DESIGN.md +4 -0
  3. package/README.md +1 -1
  4. package/install.sh +8 -0
  5. package/package.json +1 -1
  6. package/rings/e2e/reference.md +2 -0
  7. package/rings/prd/reference.md +14 -3
  8. package/scripts/__pycache__/atlas_check.cpython-312.pyc +0 -0
  9. package/scripts/__pycache__/gen_e2e_scripts.cpython-312.pyc +0 -0
  10. package/scripts/__pycache__/pkg_digest.cpython-312.pyc +0 -0
  11. package/scripts/atlas_check.py +81 -5
  12. package/scripts/gen_e2e_scripts.py +16 -4
  13. package/scripts/pkg_digest.py +42 -5
  14. package/shared/gates.md +6 -6
  15. package/shared/global-rules.md +8 -1
  16. package/templates/prd/domain.md +8 -6
  17. package/tests/__pycache__/test_atlas_check.cpython-312-pytest-9.1.1.pyc +0 -0
  18. package/tests/__pycache__/test_gen_e2e_scripts.cpython-312-pytest-9.1.1.pyc +0 -0
  19. package/tests/__pycache__/test_install_selfcheck.cpython-312-pytest-9.1.1.pyc +0 -0
  20. package/tests/__pycache__/test_tools.cpython-312-pytest-9.1.1.pyc +0 -0
  21. package/tests/__pycache__/test_validators.cpython-312-pytest-9.1.1.pyc +0 -0
  22. package/tests/test_atlas_check.py +87 -0
  23. package/tests/test_gen_e2e_scripts.py +150 -6
  24. package/tests/test_install_selfcheck.py +8 -0
  25. package/tests/test_tools.py +59 -0
  26. package/tests/test_validators.py +89 -0
  27. package/validators/__pycache__/validate_prd.cpython-312.pyc +0 -0
  28. package/validators/__pycache__/validate_structure.cpython-312.pyc +0 -0
  29. package/validators/validate_prd.py +83 -1
  30. package/validators/validate_structure.py +2 -1
  31. package/tests/__pycache__/test_gen_e2e_scripts.cpython-312.pyc +0 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v0.2.0-beta.6] - 2026-10-23
8
+
9
+ ### 🧱 装配基线 manifest:漂移逐文件指名(D145)
10
+
11
+ * **能力**:`.atlas/MANIFEST.json` 逐文件摘要(`{digest, files}`),与 `.atlas/VERSION` 同点、**同一次遍历**产出;写序「先 MANIFEST 后 VERSION」⇒ VERSION 在场即基线已齐(半态可判定)。`check_version` 先做 manifest↔戳配对校验,配对后漂移**逐文件指名**(改/增/删各 cap 10 + 计数);旧装配无 manifest ⇒ 聚合口径 + ok 行尾注「正式判据未齐」,不新增红;`install.sh` 落戳后硬校验 manifest 在场。
12
+ * **口径**:判定「装配态一致」= 摘要相等 ∧ manifest 完整;未逐项解释的差异(`backflow.py` 三态)只能记「不一致(已解释)」,运行时仍 WARN(漂移是常态信号)。
13
+ * **钉**:manifest 形态/配对/空装配 + 逐文件指名 / cap 10 / 不配套 / 形态非法 / 旧装配尾注 + F54 干跑扩 manifest 断言;变异 **M-new-1/4/5**。
14
+
15
+ ### 📋 域级 PRD 变更记录 5 列 + 「只重验受影响项」通则(D146)
16
+
17
+ * **契约**:§七 表头升级为 `版本/日期/变更内容及原因/影响/结论与后续`;「影响」点名受影响域/AC/用例,未触达验证面写 `无(未触达验证面)`。validator 独立 check 行:5 列 PASS / legacy 3 列 WARN(下次真实变更时随迁,不单跑迁移轮)/ 表头不可辨或「影响」空 ⇒ FAIL / 节在无表 WARN。
18
+ * **通则**:`global-rules` §16「变更后只重验受影响项;未受影响结论沿用并留痕」,机器落点指认 D128 stale / `backflow` 三态 / apply §3.2,不新增门;全局 `十一` 修订记录 4 列不扩(边界句)。
19
+ * **钉**:改变更记录四态;变异 **M-new-3**。
20
+
21
+ ### 🕳 显式「无」通则 + `FORBIDDEN` 单源(D147)
22
+
23
+ * **契约**:可无内容不得留空白,`无`(理由可选)或既有语义词(`不适用(理由:…)` / `未覆盖(沿用实然)` / 控制台「未创建」);词表单源 = `global-rules` §5,`FORBIDDEN` 增补 `[必填` / `[条件必填` 并三处同步 + 守恒钉。
24
+ * **机器检查(首版 WARN)**:域级 §六 空表/空节无显式结论 ⇒ WARN(翻转触发器:连续两收口周期真实遗漏 ≥1 且误报 0 ⇒ 升 FAIL;统计落 per-域收口留痕 README「可无项空表:N / 误报:N」);§七「影响」空白 ⇒ FAIL。
25
+ * **钉**:§六 四态 + 词表守恒;变异 **M-new-2**。
26
+
27
+ ### 🐛 E2E `select` 点中错误选项(D144)
28
+
29
+ * **病灶**:`atlasSelect` 采样后按**整页 DOM 下标**再点击。一个面板同时挂多个下拉(关闭态节点仍在 DOM、rc-select 另有零宽 `role=option` 镜像),组件重排后同一数字指向别当下拉的选项 ⇒ 选「不限时间」却点中「自定义范围」镜像节点,重试至 30s 超时。全量 41 条 2/2 红、单跑 2/2 绿(顺序依赖的假绿)。
30
+ * **修复**:唯一命中改为给该元素打一次性标记,点击锚定标记本身(走 Playwright actionability);每次采样先清遗留标记 ⇒ 选择器无歧义;标记失效 ⇒ 重新采样打标再点,有界 3 次,仍不成则带可见选项响亮失败。轮询、三级降级、歧义即拒、原生 `<select>` 分支不变。
31
+ * **钉**:`tests/test_gen_e2e_scripts.py::test_select_clicks_the_marked_option_not_a_global_index`;既有 D112/D117 两条钉按新形态更新(返回 token 而非 idx、候选选择器出现次数 2 → 1)。
32
+
33
+ ---
34
+
7
35
  ## [v0.2.0-beta.5] - 2026-10-23
8
36
 
9
37
  ### ✨ E2E `request` 请求载荷断言(D142)
package/DESIGN.md CHANGED
@@ -610,6 +610,10 @@ adapters:
610
610
  | D141 | `validate_testids` 提取器**剥注释只做一次**(2026-10-23;`validators/validate_testids.py`、`tests/test_validators.py`、`shared/gates.md`;P5-A 性能批) | ① **病灶**:`collect_testids_expanded` 对每个源文件调用 `strip_comments` 两次(第一次循环取 `all_tids`、第二次循环取 `file_testid_decls`),同一份文本重复剥注释;隔离副本 profile 显示 `strip_comments` 3167 次 / 1611 文件、累计 0.238s,提取器占本门 95%。② **定案**:第一次循环就把 `strip_comments` 结果存进 `texts`,第二次循环复用;判据 / 输出不变。隔离副本 5 轮交错实测 796.5ms → 686.4ms(-110.1ms / -13.8%),输出逐字不变。③ **被否**:删判据省时间(页面集合 WARN 构造约 0ms、落点判据约 50ms,删了丢真实 WARN);并行化扫描(引入并发与顺序不确定性,当前不必要);按 mtime 缓存(跨轮脏读风险)。④ **钉与变异**:`tests/test_validators.py` 139 → **140 条**;`test_validate_testids_strip_comments_once_per_file` 数调用次数(每文件 1 次,修复前 2 次)+ 抽取值断言;变异 **M137**(还原第二次 `strip_comments` 调用 ⇒ 钉红;还原逐字节一致;注入 diff 见 `research/2026-10-23-ablation-p5a-mutation.md`);`shared/gates.md` 门行与测试计数同步。 |
611
611
  | D142 | 新增 E2E **`request` 请求载荷断言动词**(2026-10-23;`rings/e2e/reference.md` §5.5/§7.1/§8.2;`scripts/gen_e2e_scripts.py`;`validators/validate_e2e_index.py`;`validators/validate_testids.py`;`tests/test_gen_e2e_scripts.py`、`tests/test_validators.py`) | ① **病灶**:断言动词只覆盖 DOM,无法验证「某提交请求的 JSON 载荷中字段缺席/等于某值」⇒ 只能人工开 DevTools;前端临时脚本用 `page.on('request')` 十行即可稳定断言,证明是 DSL 表达力缺口。② **定案**:`request <api> <json-path> <expected>`;api = `?api=` 的带点唯一后缀(歧义 FAIL);json-path 支持点号键/`[N]`/`[key=value]`,只有叶子可缺席;`(缺席)` / `null` / `""` 三态分离,`(空)` 拒绝;**动作锚定**:`after:N` 在第 N 步前记序号、无锚锚最后一步,只认该时点后**新出现的第一条**匹配请求(历史/自动保存/重试不得满足),无新请求 FAIL;中间缺失/过滤 0 或多命中/越界/请求体解析失败 = 解析错误;不参与零步基线;含该断言的用例默认 `trace: off`(`ATLAS_REQUEST_TRACE=1` opt-in),失败消息不打印载荷。③ **被否**:取「最近一次匹配请求」(历史请求可假绿)、`(空)` 合并缺失与 null、用 mock 替代请求断言(只能验渲染)、完整 JSONPath 依赖(零依赖契约)、允许用例写任意脚本。④ **钉与变异**:生成器“动作锚定 + trace off + 无 request 零变化”、Node 真求值三态/新请求/解析错误、校验器形态正反例、testid 提取不把 api 当 testid;变异 **M145**(摘 watcher 发射)/ **M146**(缺失叶子按空串)/ **M147**(接受无 step 的 request),四段协议还原一致。 |
612
612
  | D143 | 人工走查项**结构化块**(扁平行式),id/title 由标题行提供(2026-10-23;`rings/e2e/reference.md` §8.2/§10.1;`_support/manual_block.py`;`scripts/e2e_console.py`;`templates/e2e/console/app.js`;`validators/validate_e2e_index.py`) | ① **病灶**:`manual.md` 只有 `- ID \| 页面 \| 标题 \| 一段说明`,控制台只能原样渲染 prose;步骤、命令、观察点、证据没有字段承载,UI 无法分步/复制,人工走查体验差。② **定案**:兼容旧行;新增 `## <ID> <标题>` + `atlas-manual` 块(`page / precondition / step / evidence`),`step` 每行 `操作 \| 观察 \| 命令(可空)`,复用 `parse_block` 扁平数组语义;id/title 只从标题行取,块内重写即 FAIL;解析单源 `_support/manual_block.py`(控制台/校验器共用);控制台按步渲染、命令可复制;账本仍保持用例级,不扩 schema。③ **被否**:嵌套 `steps` map + `command: \|` 块标量(需新语法/解析器,零依赖下脆弱)、逐步骤账本(扩账本 schema)、把结构化信息继续塞进 prose。④ **钉与变异**:控制台块解析/旧行兼容/块内 id 拒绝、校验器页面/步骤/重复 ID/解析失败;变异 **M148**(摘块解析或步骤校验),四段协议还原一致。 |
613
+ | D144 | `select` 唯一命中后点**打标元素本身**,废除整页 DOM 下标点击(2026-10-23;`scripts/gen_e2e_scripts.py` 的 `atlasSelect` 发射点;契约 §5.5 `select` 行) | ① **病灶**:旧实现采样时用整页候选集 `[role=option],[role=menuitem],[class*="select-item-option"]`,命中后返回 `idx = all.indexOf(hit[0])`,再点击仍按**整页同一选择器的第 idx 个**。一个面板可同时挂多个下拉(Ant Design 关闭态的 dropdown 节点仍留在 DOM,rc-select 另有零宽 `role=option` a11y 镜像),下标只对「采样那一刻的 DOM 快照」有效 ⇒ 组件一重排,同一数字指向另一个下拉的选项或不可点的镜像节点。现场(百炼 v3 测试环境,全量 41 条 2/2 红、单跑 2/2 绿):步骤要选「不限时间」,点击却解析成 `<div role="option" aria-label="自定义范围" id="rc_select_4_list_3">custom</div>`,重试 52 次 `element is not visible` 至 30s 超时;单跑时挂载的选项节点少、下标恰好还对得上 ⇒ **顺序依赖的假绿**。② **定案**:`atlasPick` 在页面内给唯一命中元素打一次性标记 `data-atlas-pick=<token>` 并返回 token(每次采样先清除全部遗留标记,保证标记至多一枚 ⇒ 选择器无歧义);点击改为 `page.locator('[data-atlas-pick="<token>"]')`,即点**命中的那个元素本身**,由 Playwright 走可见性/稳定性等 actionability 检查;元素被重排回收导致标记失效 ⇒ 重新采样打标再点,**有界 3 次**,仍不成则带可见选项清单响亮失败(不静默换目标、不静默通过)。就绪轮询(`expect.poll`)、三级降级匹配、歧义即拒、原生 `<select>` 分支与失败文案全部不变。③ **被否**:把点击作用域缩到「当前打开的 dropdown」类名(吃组件库 CSS 前缀,破坏实现无关契约,其他库仍会漏);按 label 用 `getByRole('option', {name})` 直取(antd 选项可访问名含描述文案,前缀/包含级联语义会退化,且多个下拉同名时无解);加固定等待等 DOM 稳定(D117 已否决固定等待,且治不了指错元素);把候选集限定为 `role=option`(D122 已证 antd 可见项无该 role,会退回「下拉已打开也采不到」)。④ **钉**:`tests/test_gen_e2e_scripts.py::test_select_clicks_the_marked_option_not_a_global_index`(① 病灶字符串 `all.indexOf` / `pick.idx` / `.nth(pick.idx)` 必须消失 ② 打标与按标点击成对 ③ 遗留标记清理行 ④ 有界重试 ⑤ 失败文案 ⑥ 仍用 `expect.poll` 且无 `waitForTimeout`);既有 D112/D117 两条钉按新形态更新(唯一命中返回 token 而非 idx、候选选择器出现次数由 2 降为 1)。**(编号说明:D143 已被人工走查结构化块占用,本决策顺延为 D144。)** |
614
+ | D145 | **装配基线 manifest**:`.atlas/MANIFEST.json` 逐文件摘要 + 配对校验 + 「未解释差异不得判一致」(2026-10-23;`RFC-20261023-02` ②;`scripts/pkg_digest.py`、`scripts/atlas_check.py`、`install.sh`、`shared/gates.md` §2.2) | ① **病灶**:聚合摘要只回答「变没变」,回答不了「变在哪」——`check_version` 的漂移 WARN 自证「差异明细跑 `backflow.py` 三态对账」;而外部流程规范给的机制是**逐项内容摘要 + 解释制一致性**(缺失字段 / 采集失败 / 未解释差异均不得判一致)。② **定案**:`manifest()` 与 `digest()` 共用同一次遍历产 `{digest, files}`;`stamp_file()` **先 MANIFEST 后 VERSION**(VERSION 在场即 manifest 已齐 ⇒ 半态可判定);`check_version` **配对校验先行**(manifest.digest ≠ 装配戳 ⇒ WARN 指名重装),配对后漂移**逐文件指名**(改/增/删各 cap 10 + 计数);旧装配无 manifest ⇒ 聚合口径 + ok 行尾注「正式判据未齐」(不新增红);两分支 WARN 保 M68 三词;`install.sh` 落戳后硬校验 manifest 在场;判定「一致」= 摘要相等 ∧ manifest 齐,未解释差异只能记「不一致(已解释)」(运行时仍 WARN,漂移是常态信号)。③ **被否**:扩 VERSION 为多行/JSON(旧副本全军 WARN,干跑守卫重走);漂移直接升红门(打断正常开发循环,解释制只属收口/发布判定);接线 `release.py`(过度设计,列为后续可选项)。④ **钉与变异**:`tests/test_tools.py` manifest 形态/配对/空装配三钉;`tests/test_atlas_check.py` 逐文件指名 / cap 10 / 不配套 / 形态非法 / 旧装配尾注五钉;F54 干跑扩 `MANIFEST.json` 逐字节断言;变异 **M-new-1**(摘 diff 分支)、**M-new-4**(摘配对校验)、**M-new-5**(摘干跑闸),四段协议还原逐字节一致。 |
615
+ | D146 | **域级 PRD 变更记录 5 列** + 「变更后只重验受影响项」通则(2026-10-23;`RFC-20261023-02` ③;`templates/prd/domain.md` §六/§七、`validators/validate_prd.py`、`rings/prd/reference.md`、`shared/global-rules.md` §16) | ① **病灶**:§七 只有「版本/日期/变更说明」,无影响、无结论与后续;「受影响才重验」原则散落 `apply` §3.2 / e2e 收口三值② / D128 `stale` 三处而未收拢,变更后哪些结论可沿用无处承载。② **定案**:表头升级 `版本/日期/变更内容及原因/影响/结论与后续`;「影响」点名受影响域/AC/用例,未触达验证面写 `无(未触达验证面)`;validator 独立 check 行:5 列 PASS / legacy(含`变更说明`)WARN「下次真实变更时随迁」/ 表头不可辨或「影响」空 FAIL / 节在无表 WARN;通则落 `global-rules` §16(只重验受影响项、未受影响结论沿用并留痕),机器落点指认 D128 stale / `backflow` 三态 / apply §3.2,不新增门;全局 `十一` 修订记录 4 列**不扩**(边界句)。③ **被否**:语义塞进「变更说明」单元格(无判据);新增「变更→受影响用例集」对账门(语义依赖,先立字段与通则);全局修订记录同步扩列(无消费者)。④ **钉与变异**:`test_rfc20261023_02_change_record_shape_and_impact_column` 四态;**M-new-3** 删 legacy 分支 ⇒ 钉红;测试夹具随迁 5 列(不为迁移单跑一轮)。 |
616
+ | D147 | **显式「无」通则** + `FORBIDDEN` 三处单源 + 两处机器检查首版 WARN(2026-10-23;`RFC-20261023-02` ④;`shared/global-rules.md` §5、`validators/validate_prd.py`、`validators/validate_structure.py`、`templates/prd/domain.md`、`rings/prd/reference.md` §7、`rings/e2e/reference.md` §10.1) | ① **病灶**:`无` / `不适用(理由:…)` / `未覆盖(沿用实然)` / 控制台「未创建」四套写法并存,「空表/空节」与「评估过为无」同形;`FORBIDDEN` 不抓空表,也不抓 `[必填:…]` 占位残留;两校验器与 §5 的词表各说各话。② **定案**:通则 = 「可无内容不得留空白,必须有结论」(`无`(理由可选)/既有语义词,词表单源 = §5);`FORBIDDEN` 增补 `[必填` / `[条件必填` 并三处同步 + 守恒钉 `test_forbidden_vocabulary_single_source`;机器落点只两处(§六 空表/空节无 `无` ⇒ **WARN 首版**;§七「影响」空 ⇒ FAIL),均独立 check 行、登记门册;模板占位指令统一「无则写 无(理由可选)」;翻转触发器 = 连续两收口周期真实遗漏 ≥1 且误报 0 ⇒ 升 FAIL,统计落 per-域收口留痕 README「可无项空表:N / 误报:N」。③ **被否**:全量扫描所有可无节 FAIL(违反「落门先问语料」);把四词语义合并成单一写法(丢语义边界)。④ **钉与变异**:`test_rfc20261023_02_optional_section_explicit_none`(空节 / 空表 / 显式 `无` / 开户最小集四态);**M-new-2** 判据恒真 ⇒ 钉红。 |
613
617
 
614
618
 
615
619
 
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.5-blue.svg" alt="Version" />
4
+ <img src="https://img.shields.io/badge/version-0.2.0--beta.6-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>
package/install.sh CHANGED
@@ -524,6 +524,14 @@ print(stamp_file(Path(sys.argv[1]), Path(sys.argv[2])))
524
524
  PY
525
525
  )"; then
526
526
  say " [ok] .atlas/VERSION 装配版本戳(${OUT})"
527
+ # RFC-20261023-02 ②(2026-10-23):装配基线 manifest 必须与戳同点落下——落戳成功而
528
+ # manifest 缺失 = 逐文件对账面不存在,按装配失败处理(硬错,与 F37 同款理由)。
529
+ if [ -s "$TARGET/.atlas/MANIFEST.json" ]; then
530
+ say " [ok] .atlas/MANIFEST.json 装配基线(逐文件摘要)"
531
+ else
532
+ say " [FAIL] 装配基线 manifest 未落下(.atlas/MANIFEST.json)⇒ 装配态无法逐文件对账。"
533
+ exit 1
534
+ fi
527
535
  else
528
536
  # 硬错而非 WARN(F37,2026-10-15):落戳失败 = 装配集为空(摘要算不出来)或脚本缺失。
529
537
  # 「装配 0 文件」不得被记成一次合法装配,且 WARN 会让后续步骤照常往下走。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas-project-harness",
3
- "version": "0.2.0-beta.5",
3
+ "version": "0.2.0-beta.6",
4
4
  "description": "践行 Project Harness 的全流程 AI 编程工作流治理引擎",
5
5
  "bin": {
6
6
  "atlas": "bin/atlas.js"
@@ -650,6 +650,8 @@ testid:
650
650
 
651
651
  「把用例逐条执行 + 逐条人工评审」的载体**随包分发**,不再由项目自建(原为项目私有产物,见已决 `3509 §B16`)。
652
652
 
653
+ **显式「无」通则(2026-10-23,`RFC-20261023-02` ④)**:`manual.md` 缺失时控制台披露「未创建」是本通则的既有先例(D81 / M73);可无内容不得留空白,统一语义词表与守恒钉归属 = `shared/global-rules.md` §5。本环不重复定义词表。
654
+
653
655
  | 面 | 内容 |
654
656
  |---|---|
655
657
  | 形态 | 本地**纯标准库**小服务(`http.server` + SSE),一张 4 列的列:用例分类 / 用例描述 / 执行用例 / 人工 review 用例;**手工走查项**(2026-10-01,`3508 §171`)并入同一列表——类型 `manual`,数据源 = `product/e2e/manual.md`(旧行 `- <ID> | <页面> | <标题> | <操作说明>` 与 D143 `atlas-manual` 结构化块共存;块按步渲染、命令可复制,账本仍为用例级;文件缺失 = 空清单**且控制台显式披露「未创建」**——不静默空栏,「没有」与「忘了」不得同形,`RFC-20261015-01`;不走用例契约面,不进索引/生成器/统计),按钮 =「打开」:按**索引页面表**路由开系统浏览器到对应页、其余人工自己点,review **直勾**(它的执行就是人自己点,无执行解锁前提);提交/账本与用例同流(索引回写天然跳过非 E2E 行) |
@@ -81,7 +81,7 @@ product/prd/
81
81
  | 八 | 非功能需求 | 性能 / 安全 / 兼容性 / 可维护性 |
82
82
  | 九 | 术语表 | 术语 + 定义(跨域共享的才写这里) |
83
83
  | 十 | 风险与依赖 | 风险(描述/影响/应对)+ 外部依赖 |
84
- | 十一 | 修订记录 | 版本 / 日期 / 修订人 / 变更说明。**不设修订记录的产品**写一行 `不适用(理由:<一句>)` 即合法(与 §4「状态机」的「不适用」同源);把整节**静默删除**不合法(§10 幂等)。 |
84
+ | 十一 | 修订记录 | 版本 / 日期 / 修订人 / 变更说明。**不设修订记录的产品**写一行 `不适用(理由:<一句>)` 即合法(与 §4「状态机」的「不适用」同源);把整节**静默删除**不合法(§10 幂等)。**边界(`RFC-20261023-02` ③)**:全局本节默认 4 列**不扩**——5 列只落在域级 §七。 |
85
85
 
86
86
  **最小必填集(`增量起步` 时)**:只强制 **五 功能架构 / 六 范围与处置 / 七 页面结构全景** 三节。判据是「**有没有下游消费者**」:五 → 域级文件组织;六 → 防静默丢功能;七 → e2e 环页面表的**页清单来源**(`product/e2e/e2e-index.md` 页面表,`rings/e2e/reference.md` §3.1;E1 迁入)。其余章节在 `增量起步` 下可缺省,**但一旦存在就必须满足本节规格**。
87
87
 
@@ -142,7 +142,7 @@ product/prd/
142
142
  | 四 | 业务规则(Rule Set) | 规则 ID / 内容 / **所属功能** / 触发条件 / 优先级 / 影响范围 |
143
143
  | 五 | 验收标准 | GWT **三列**(`AC ID` | 场景 | 回指规则),每条回指规则 ID;末附**结构类 AC 清单**(见下) |
144
144
  | 六 | Assumptions & Defaults | 未证实但按此推进的假设,逐条带默认值 |
145
- | 七 | 变更记录 | 版本 / 日期 / 变更说明 |
145
+ | 七 | 变更记录 | 版本 / 日期 / **变更内容及原因** / **影响** / **结论与后续**(5 列;legacy 3 列过渡见 §7 附加机器检查) |
146
146
 
147
147
  ### 开户最小集(`origin: adopt` 的**首轮开户**;2026-09-27 补,依据 `3509 §B85`)
148
148
 
@@ -156,7 +156,7 @@ product/prd/
156
156
  | 五 验收标准 | `AC-<CODE>-NNN`(GWT + 回指规则)+ **结构类 AC 清单** | 同上;每条断言必须能指回代码事实 |
157
157
 
158
158
  - **三 功能详细说明(六件套)与 六 Assumptions & Defaults 在开户最小集里缺省**,留到该域**真的有需求**时补——那时才有依据写状态机 / 字段校验 / 六类文案 / 异常处理。测试:该域首次由 `apply <需求ID>` 增量时,把这两节一次补齐。
159
- - **缺省 ≠ 占位符**:被缺省的两节**整节不出现**,**不得**写 `TODO`、空表或「待补充」;其余各节的实质内容也不得留空。**章节号不重排**(仍用 一/二/四/五/七),以便日后补齐时编号稳定。
159
+ - **缺省 ≠ 占位符**:被缺省的两节**整节不出现**,**不得**写 `TODO`、空表或「待补充」;其余各节的实质内容也不得留空。**章节号不重排**(仍用 一/二/四/五/七),以便日后补齐时编号稳定。**可无项通则**(2026-10-23,`RFC-20261023-02` ④):允许「无」的内容一律显式 `无`(理由可选)/ 既有语义词,空白与「评估过为无」不得同形(语义词表与守恒钉归属 = `shared/global-rules.md` §5)。
160
160
  - **§五 的 AC 在开户轮是「基线登记断言」**:它断言的是该能力**当前可观察的行为**(即下一条需求要 diff 的对照基线),不是本轮新作出的验收承诺——与 §六 的 `未覆盖(沿用实然)` 同口径。
161
161
  - **「四类样例」不在本环承载(2026-09-28 移交,`3509 §B74`)**:原「开户轮按域计四类样例(正向/反向/边界/异常)」的义务由 **E2E 环的用例 `类型` 字段**承担(`rings/e2e/reference.md` §5.2);本环不再逐 AC 标样例类型,也不再要求「每个关键功能覆盖四类」。
162
162
  - **为何要这一集**(`3509 §B85` 的「先量再动手」实测):开户时若按全量写,7 域 / 24 能力 ≈ **2,006 行**,其中六件套占 **74%**,而这些内容在开户时**只能从代码反推产品意图**(属推断而非事实);开户最小集 ≈ **518 行**(**−74%**),且每一行都能指回结构事实。
@@ -389,6 +389,17 @@ product/prd/
389
389
 
390
390
  **判定**:4/4 → `PASS`;3/4 → `WARN`(列整改项);≤2 或参数 2 失败 → `FAIL`(**拒绝交付**)。
391
391
 
392
+ **附加机器检查(2026-10-23,`RFC-20261023-02` ③④;独立 check 行,不并入参数 4 聚合)**:
393
+
394
+ | 检查 | 判据 | 档位 |
395
+ |---|---|---|
396
+ | 变更记录形态 | 域级 §七 表头含 `影响`(及 `变更内容及原因` 列)⇒ 逐行检查:`影响` 单元格空 ⇒ 该行点名 FAIL;仅含 `变更说明`(legacy 3 列)⇒ WARN(**下次真实变更时迁移 5 列**,不单跑迁移轮——「只重验受影响项」同精神);§七 表头两种都不像 ⇒ FAIL;节在但无表 ⇒ WARN | FAIL / WARN |
397
+ | 可无项显式(§六) | §六 整节不出现(开户最小集)⇒ 跳过;出现且含 `无` / `不适用(理由:…)` ⇒ PASS;空表 / 空节且无显式结论 ⇒ **WARN 首版** | WARN |
398
+
399
+ - **翻转触发器(显式)**:可无项检查连续两个收口周期「真实遗漏 ≥1 且误报 0」⇒ 升 FAIL;出现误报 ⇒ 维持 WARN 并细分判据。统计落 **per-域收口留痕 README** 追加一行「可无项空表:N / 误报:N」(与「手工项」行同格式,`rings/e2e/reference.md` §10 执行纪律 5 先例),由采用项目收口评审填写。
400
+ - **边界(边界句,防执行者自行发挥)**:5 列仅**域级** §七;全局 `十一` 修订记录默认 4 列**不扩**(`不适用(理由:…)` 语义不动)。
401
+ - **词表单源**:`[必填` / `[条件必填` 等 `FORBIDDEN` 以 `shared/global-rules.md` §5 为单源,两校验器同步(守恒钉 = `test_forbidden_vocabulary_single_source`)。
402
+
392
403
  **能力覆盖的判据边界(重要)**:`变更` 形态要求「基线能力都有结论」,但基线的**能力条目没有 ID 体系**(`structure/legacy/*` 按小节组织,上一版实现更没有)⇒ 机器比对只能做到「按小节标题近似」,**误报率高**。故本条**不进校验器**,归 §8 维度 8(人工)。**落一道门之前先问「语料能不能满足它」**:`3508 §74/§75` 两次因为跳过这一步而把门落成了长期不可满足的红门。
393
404
 
394
405
  **Waste Test(原 6 条,拆成「机器」与「人工」两栏)**:
@@ -416,6 +416,22 @@ def check_drift(root: Path, rows: Row) -> None:
416
416
  duration_ms=_elapsed_ms(started))
417
417
 
418
418
 
419
+ def _parse_manifest(path: Path) -> dict | None:
420
+ """读 `.atlas/MANIFEST.json`:`{digest: str, files: {rel: sha256}}` 两键齐才认。
421
+
422
+ 形态非法返回 None(调用方据此给「形态非法」WARN,不猜、不静默)。
423
+ """
424
+ try:
425
+ data = json.loads(path.read_text(encoding="utf-8"))
426
+ except (OSError, ValueError):
427
+ return None
428
+ if (isinstance(data, dict) and isinstance(data.get("digest"), str)
429
+ and isinstance(data.get("files"), dict)
430
+ and all(isinstance(k, str) and isinstance(v, str) for k, v in data["files"].items())):
431
+ return data
432
+ return None
433
+
434
+
419
435
  def check_version(root: Path, rows: Row) -> None:
420
436
  """「装配版本」对账(gates.md §2.2,P3;2026-10-05):.atlas/VERSION 记装配时的
421
437
  源包内容摘要,与当前源包重算摘要比对——不一致 ⇒ WARN(源包已前进/落后于装配态,
@@ -428,6 +444,13 @@ def check_version(root: Path, rows: Row) -> None:
428
444
  安装的源包位于 `<prefix>/lib/node_modules/<pkg>` ⇒ 每个文件都被滤掉 ⇒ 空摘要落戳,
429
445
  而副本侧同错重算 ⇒ 两侧恒等 ⇒ 本门恒 ok。修后 `digest()` 对空装配直接抛
430
446
  `EmptyAssembly`,故该哨兵只可能对应「修复前落的戳」。
447
+
448
+ `RFC-20261023-02` ②(2026-10-23):`.atlas/MANIFEST.json` 装配基线(逐文件摘要)。
449
+ * **配对校验先行**:manifest.digest ≠ 装配戳 ⇒ 半态,退聚合口径并指名重装
450
+ (写序「先 MANIFEST 后 VERSION」保证 VERSION 在场即 manifest 已齐,B1);
451
+ * 配对成立且漂移 ⇒ **逐文件指名**(改/增/删,各 cap 10 + 计数);
452
+ * 旧装配无 manifest ⇒ 聚合口径 + ok 行尾注「正式判据未齐」(status 仍 ok);
453
+ * 两分支 WARN 文案均保留 M68 三词(`atlas upgrade` / `install.sh` / `backflow`)。
431
454
  """
432
455
  version_file = root / ".atlas" / "VERSION"
433
456
  if not version_file.is_file():
@@ -449,14 +472,14 @@ def check_version(root: Path, rows: Row) -> None:
449
472
  return
450
473
  try:
451
474
  sys.path.insert(0, str(Path(__file__).resolve().parent))
452
- from pkg_digest import EmptyAssembly, digest # noqa: E402 延迟导入:旧装配无此文件时走 SKIP
475
+ from pkg_digest import EmptyAssembly, manifest # noqa: E402 延迟导入:旧装配无此文件时走 SKIP
453
476
  except ImportError:
454
477
  rows.add("装配版本", "SKIP",
455
478
  "无 scripts/pkg_digest.py 或版本过旧(无 EmptyAssembly)——重跑 install.sh 补齐")
456
479
  return
457
480
  started = time.perf_counter()
458
481
  try:
459
- current = digest(source_dir)
482
+ current = manifest(source_dir)
460
483
  except EmptyAssembly as e:
461
484
  # 独立子句且**排在 (OSError, ValueError) 之前**:EmptyAssembly 是 ValueError 子类,
462
485
  # 并进同一个 tuple 会被当成 SKIP,而「装配集为空」绝不是合法降级。
@@ -468,12 +491,65 @@ def check_version(root: Path, rows: Row) -> None:
468
491
  duration_ms=_elapsed_ms(started))
469
492
  return
470
493
  duration_ms = _elapsed_ms(started)
471
- if current == stamped:
472
- rows.add("装配版本", "ok", f"源包与装配态一致({stamped})",
494
+
495
+ manifest_file = root / ".atlas" / "MANIFEST.json"
496
+ recorded = _parse_manifest(manifest_file) if manifest_file.is_file() else None
497
+ if manifest_file.is_file() and recorded is None:
498
+ rows.add("装配版本", "WARN",
499
+ ".atlas/MANIFEST.json 形态非法(应为 {digest, files} 两键)——"
500
+ "重跑 install.sh 补齐;源包侧 `atlas upgrade` / `install.sh` 落戳,"
501
+ "细节走 `backflow.py` 三态对账",
502
+ duration_ms=duration_ms)
503
+ return
504
+ if recorded is not None:
505
+ if recorded["digest"] != stamped:
506
+ rows.add("装配版本", "WARN",
507
+ f"manifest 与 VERSION 不配套(manifest {recorded['digest']} ≠ 装配戳 {stamped})"
508
+ "——半态装配,重跑 install.sh;`atlas upgrade` / `install.sh` 落戳后 "
509
+ "`backflow.py` 可对账细节",
510
+ duration_ms=duration_ms)
511
+ return
512
+ if current["digest"] == stamped:
513
+ rows.add("装配版本", "ok",
514
+ f"源包与装配态一致({stamped},{len(current['files'])} 文件基线齐)",
515
+ duration_ms=duration_ms)
516
+ return
517
+ old, new = recorded["files"], current["files"]
518
+ changed = sorted(k for k in set(old) & set(new) if old[k] != new[k])
519
+ added = sorted(set(new) - set(old))
520
+ removed = sorted(set(old) - set(new))
521
+
522
+ def _name(keys: list[str]) -> str:
523
+ shown = "、".join(keys[:10])
524
+ return shown + (f" 等 {len(keys)} 项" if len(keys) > 10 else "")
525
+
526
+ named = []
527
+ if changed:
528
+ named.append("改=" + _name(changed))
529
+ if added:
530
+ named.append("增=" + _name(added))
531
+ if removed:
532
+ named.append("删=" + _name(removed))
533
+ if not named:
534
+ named.append("摘要不一致但逐文件无差异(manifest 与摘要算法不同代)——重跑 install.sh")
535
+ rows.add("装配版本", "WARN",
536
+ f"源包已前进/落后于装配态(装配 {stamped} → 当前 {current['digest']}):"
537
+ f"改 {len(changed)} / 增 {len(added)} / 删 {len(removed)};" + ";".join(named)
538
+ + "。生效:npm 全局源包态 `atlas upgrade` 后在项目里跑 `atlas update`;"
539
+ "本地源仓态重跑 `install.sh`;差异明细跑 `backflow.py` 三态对账"
540
+ "(SOURCE_ONLY 拒绝护栏;D79 ⑤)",
541
+ duration_ms=duration_ms)
542
+ return
543
+
544
+ # 旧装配(VERSION 在场、manifest 缺):干净的历史语义,不新增红;聚合口径 + 补装提示。
545
+ if current["digest"] == stamped:
546
+ rows.add("装配版本", "ok",
547
+ f"源包与装配态一致({stamped});旧装配无 manifest——正式判据未齐,重跑 install.sh 补",
473
548
  duration_ms=duration_ms)
474
549
  return
475
550
  rows.add("装配版本", "WARN",
476
- f"源包已前进/落后于装配态(装配 {stamped} → 当前 {current})——"
551
+ f"源包已前进/落后于装配态(装配 {stamped} → 当前 {current['digest']});"
552
+ "旧装配无 manifest(无法逐文件指名)——重跑 install.sh 补齐。"
477
553
  "生效:npm 全局源包态 `atlas upgrade` 后在项目里跑 `atlas update`;"
478
554
  "本地源仓态重跑 `install.sh`;差异明细跑 `backflow.py` 三态对账"
479
555
  "(SOURCE_ONLY 拒绝护栏;D79 ⑤)",
@@ -1893,22 +1893,34 @@ def main() -> int:
1893
1893
  " await el.click();\n"
1894
1894
  " const atlasPick = () => page.evaluate((want: string) => {\n"
1895
1895
  " const all = Array.from(document.querySelectorAll('[role=option],[role=menuitem],[class*=\"select-item-option\"]:not([class*=\"select-item-option-content\"]):not([class*=\"select-item-option-state\"])'));\n"
1896
+ " document.querySelectorAll('[data-atlas-pick]').forEach((e) => e.removeAttribute('data-atlas-pick'));\n"
1896
1897
  " const shape = (e: Element) => { const r = (e as HTMLElement).getBoundingClientRect(); return r.width > 0 && r.height > 0; };\n"
1897
1898
  " const nameOf = (e: Element) => (e.getAttribute('title') || (e.textContent || '').trim());\n"
1898
1899
  " const shown = all.filter(shape);\n"
1899
1900
  " let hit = shown.filter((e) => e.getAttribute('title') === want);\n"
1900
1901
  " if (hit.length !== 1) { const p = shown.filter((e) => nameOf(e).startsWith(want)); if (p.length) hit = p; }\n"
1901
1902
  " if (hit.length !== 1) { const s = shown.filter((e) => nameOf(e).includes(want)); if (s.length) hit = s; }\n"
1902
- " if (hit.length === 1) return { status: 'unique', idx: all.indexOf(hit[0]), visible: shown.map(nameOf) };\n"
1903
- " return { status: hit.length > 1 ? 'ambiguous' : 'none', idx: -1, visible: shown.map(nameOf) };\n"
1903
+ " if (hit.length !== 1) return { status: hit.length > 1 ? 'ambiguous' : 'none', token: '', visible: shown.map(nameOf) };\n"
1904
+ " const token = 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);\n"
1905
+ " hit[0].setAttribute('data-atlas-pick', token);\n"
1906
+ " return { status: 'unique', token, visible: shown.map(nameOf) };\n"
1904
1907
  " }, label);\n"
1905
- " let pick: any = { status: 'none', idx: -1, visible: [] };\n"
1908
+ " let pick: any = { status: 'none', token: '', visible: [] };\n"
1906
1909
  " try {\n"
1907
1910
  " await expect.poll(async () => { pick = await atlasPick(); return pick.status; },\n"
1908
1911
  " { timeout: ATLAS_WAIT_TIMEOUT_MS }).not.toBe('none');\n"
1909
1912
  " } catch { /* 未就绪超时 ⇒ 落到下面统一抛出(带可见选项) */ }\n"
1910
1913
  " if (pick.status !== 'unique') throw new Error(`select:${tid} 的选项里找不到唯一「${label}」;可见选项=${JSON.stringify(pick.visible)}`);\n"
1911
- " await page.locator('[role=option],[role=menuitem],[class*=\"select-item-option\"]:not([class*=\"select-item-option-content\"]):not([class*=\"select-item-option-state\"])').nth(pick.idx).click();\n"
1914
+ " // D144:点击**打标的那个元素本身**,不按整页 DOM 下标点。一个面板可同时挂多个下拉\n"
1915
+ " // (关闭态的选项节点仍留在 DOM 里),下标只对采样那一刻的 DOM 有效 ⇒ 组件一重排,\n"
1916
+ " // 同一个下标就指向另一下拉的选项(v3 实测:要选「不限时间」却点中「自定义范围」的\n"
1917
+ " // 零宽 a11y 镜像节点,重试至超时)。标记随元素消失而失效 ⇒ 重新采样打标再点,限 3 次。\n"
1918
+ " let clicked = false;\n"
1919
+ " for (let retry = 0; retry < 3 && !clicked; retry++) {\n"
1920
+ " try { await page.locator(`[data-atlas-pick=\"${pick.token}\"]`).click({ timeout: 5000 }); clicked = true; }\n"
1921
+ " catch { pick = await atlasPick(); }\n"
1922
+ " }\n"
1923
+ " if (!clicked) throw new Error(`select:${tid} 的选项「${label}」点击未生效(下拉重排后命中元素不可点);可见选项=${JSON.stringify(pick.visible || [])}`);\n"
1912
1924
  "}\n"
1913
1925
  "async function atlasIntOf(loc: any): Promise<number> {\n"
1914
1926
  " // 契约 §5.5(P5,2026-10-07):`delta` 相对断言的取数 —— 元素文本中的首个整数。\n"
@@ -1,5 +1,15 @@
1
1
  """源包内容摘要(P3 装配版本戳;2026-10-05;F30 装配集收窄 / F37 过滤口径 2026-10-15)。
2
2
 
3
+ **装配基线 manifest(2026-10-23,`RFC-20261023-02` ②)**:聚合摘要只能回答「变没变」,
4
+ 回答不了「变在哪」——逐文件明细此前要另跑 `backflow.py`。`stamp_file()` 现在同点落
5
+ `.atlas/MANIFEST.json`(`{digest, files: {rel_posix: sha256}}`,与 VERSION 同一次遍历产出,
6
+ 无二次读盘)。**写序定死:先 MANIFEST 后 VERSION**——VERSION 在场即 manifest 已齐;
7
+ VERSION 在场而 manifest 缺/空 = 干净的「旧装配」语义(半态协议,评审 B1)。判定
8
+ 「装配态一致」的充分条件 = 摘要相等 ∧ manifest 覆盖装配集完整;未逐项解释的差异
9
+ (`backflow.py` 三态对账)只能记「不一致(已解释)」,不得记一致(运行时仍 WARN——
10
+ 漂移是常态信号,非门禁失败)。manifest 落 target 侧 `.atlas/`,不在装配集白名单内,
11
+ 不参与摘要(无自指)。
12
+
3
13
  双端共用算法:install.sh 装配完成时对源包算摘要落 `<target>/.atlas/VERSION`;
4
14
  副本侧 `atlas_check.py` 导入本模块重算当前源包摘要比对——不一致 ⇒ WARN(源包已
5
15
  前进/落后于装配态;生效路径与差异明细见 `scripts/atlas_check.py` 装配版本门 WARN
@@ -52,8 +62,8 @@ class EmptyAssembly(ValueError):
52
62
  """
53
63
 
54
64
 
55
- def digest(root: Path) -> str:
56
- """源包装配集摘要(前 16 位十六进制)。root 不存在 ⇒ ValueError;装配集为空 ⇒ EmptyAssembly。"""
65
+ def _assembly_files(root: Path) -> list[Path]:
66
+ """装配集内的可摘要文件(已过滤、已排序)。根越界/装配集为空 ⇒ 响亮报错。"""
57
67
  root = Path(root)
58
68
  if not root.is_dir():
59
69
  raise ValueError(f"not a directory: {root}")
@@ -79,21 +89,48 @@ def digest(root: Path) -> str:
79
89
  raise EmptyAssembly(
80
90
  f"装配集为空:{root} 下 {list(ASSEMBLY_COVER_DIRS)} + "
81
91
  f"{list(ASSEMBLY_COVER_FILES)} 内没有可摘要的文件(空摘要不可用于对账)")
92
+ return files
93
+
94
+
95
+ def manifest(root: Path) -> dict:
96
+ """装配基线:`{digest, files}`——聚合摘要 + 逐文件 sha256(同一次遍历产出)。
97
+
98
+ `files` 的键 = 根相对 POSIX 路径;摘要算法与旧 `digest()` 逐字节一致(文件序 =
99
+ 装配集排序,逐文件喂 `rel\\0sha256\\n`)。
100
+ """
101
+ root = Path(root)
102
+ files = _assembly_files(root)
103
+ hashes: dict[str, str] = {}
82
104
  h = hashlib.sha256()
83
105
  for p in files:
84
106
  rel = p.relative_to(root).as_posix()
85
107
  fh = hashlib.sha256(p.read_bytes()).hexdigest()
108
+ hashes[rel] = fh
86
109
  h.update(f"{rel}\0{fh}\n".encode("utf-8"))
87
- return h.hexdigest()[:16]
110
+ return {"digest": h.hexdigest()[:16], "files": hashes}
111
+
112
+
113
+ def digest(root: Path) -> str:
114
+ """源包装配集摘要(前 16 位十六进制)。root 不存在 ⇒ ValueError;装配集为空 ⇒ EmptyAssembly。"""
115
+ return manifest(root)["digest"]
88
116
 
89
117
 
90
118
  def stamp_file(source_dir: Path, target_version_file: Path) -> str:
91
- """装配落戳:VERSION = 摘要行 + 时间行 + 源包绝对路径行。返回摘要。"""
119
+ """装配落戳:**先** MANIFEST.json(逐文件基线)**后** VERSION(摘要行 + 时间行 + 源包路径行)。
120
+
121
+ 写序是半态协议的一部分(评审 B1):任何中断都不会留下「VERSION 新而 manifest 缺」
122
+ 的可信态。返回摘要。
123
+ """
92
124
  import datetime
125
+ import json
93
126
 
94
- d = digest(source_dir)
127
+ m = manifest(source_dir)
128
+ d = m["digest"]
95
129
  now = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
96
130
  target_version_file.parent.mkdir(parents=True, exist_ok=True)
131
+ manifest_file = target_version_file.parent / "MANIFEST.json"
132
+ manifest_file.write_text(
133
+ json.dumps(m, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
97
134
  target_version_file.write_text(f"{d}\n{now}\n{source_dir.resolve()}\n", encoding="utf-8")
98
135
  return d
99
136