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

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 (178) hide show
  1. package/CHANGELOG.md +267 -0
  2. package/DESIGN.md +46 -3
  3. package/README.md +24 -14
  4. package/_support/__pycache__/ac_coverage.cpython-312.pyc +0 -0
  5. package/_support/__pycache__/case_block.cpython-312.pyc +0 -0
  6. package/_support/__pycache__/layout.cpython-312.pyc +0 -0
  7. package/_support/__pycache__/manual_block.cpython-312.pyc +0 -0
  8. package/_support/__pycache__/stack_profile.cpython-312.pyc +0 -0
  9. package/_support/ac_coverage.py +120 -0
  10. package/_support/case_block.py +3 -3
  11. package/_support/layout.py +411 -0
  12. package/_support/manual_block.py +67 -0
  13. package/_support/stack_profile.py +3 -1
  14. package/adapters/__pycache__/_graph.cpython-312.pyc +0 -0
  15. package/adapters/__pycache__/_jpa.cpython-312.pyc +0 -0
  16. package/adapters/__pycache__/_lib.cpython-312.pyc +0 -0
  17. package/adapters/__pycache__/api.cpython-312.pyc +0 -0
  18. package/adapters/__pycache__/models.cpython-312.pyc +0 -0
  19. package/adapters/__pycache__/tests.cpython-312.pyc +0 -0
  20. package/adapters/_graph.py +7 -2
  21. package/adapters/_jpa.py +291 -0
  22. package/adapters/_lib.py +8 -3
  23. package/adapters/ac/__init__.py +80 -0
  24. package/adapters/ac/__pycache__/__init__.cpython-312.pyc +0 -0
  25. package/adapters/ac/__pycache__/command.cpython-312.pyc +0 -0
  26. package/adapters/ac/__pycache__/js.cpython-312.pyc +0 -0
  27. package/adapters/ac/__pycache__/junit.cpython-312.pyc +0 -0
  28. package/adapters/ac/__pycache__/pytest.cpython-312.pyc +0 -0
  29. package/adapters/ac/command.py +88 -0
  30. package/adapters/ac/js.py +334 -0
  31. package/adapters/ac/junit.py +266 -0
  32. package/adapters/ac/pytest.py +380 -0
  33. package/adapters/api.py +331 -8
  34. package/adapters/models.py +126 -2
  35. package/adapters/routes.py +7 -3
  36. package/adapters/tests.py +43 -5
  37. package/apply/reference.md +6 -3
  38. package/bin/atlas.js +66 -28
  39. package/extensions/pi.ts +39 -8
  40. package/install.sh +179 -19
  41. package/package.json +1 -1
  42. package/patches/project-wiring/README.md +4 -3
  43. package/patches/project-wiring/__pycache__/apply-wiring.cpython-312.pyc +0 -0
  44. package/patches/project-wiring/agent-pointer.md +1 -1
  45. package/patches/project-wiring/apply-wiring.py +146 -153
  46. package/patches/workflow-plan-apply/1.6-plan-apply.insert.md +1 -1
  47. package/rings/e2e/reference.md +37 -20
  48. package/rings/prd/reference.md +26 -8
  49. package/rings/structure/reference.md +3 -2
  50. package/scripts/__pycache__/atlas_check.cpython-312.pyc +0 -0
  51. package/scripts/__pycache__/atlas_report.cpython-312.pyc +0 -0
  52. package/scripts/__pycache__/atlas_verify.cpython-312.pyc +0 -0
  53. package/scripts/__pycache__/closeout_derive.cpython-312.pyc +0 -0
  54. package/scripts/__pycache__/e2e_console.cpython-312.pyc +0 -0
  55. package/scripts/__pycache__/gen_e2e_scripts.cpython-312.pyc +0 -0
  56. package/scripts/__pycache__/gen_testid_stub.cpython-312.pyc +0 -0
  57. package/scripts/__pycache__/make_review_inputs.cpython-312.pyc +0 -0
  58. package/scripts/__pycache__/pkg_digest.cpython-312.pyc +0 -0
  59. package/scripts/__pycache__/refresh_structure.cpython-312.pyc +0 -0
  60. package/scripts/__pycache__/release.cpython-312.pyc +0 -0
  61. package/scripts/__pycache__/write_claim.cpython-312.pyc +0 -0
  62. package/scripts/atlas_check.py +329 -86
  63. package/scripts/atlas_report.py +10 -2
  64. package/scripts/atlas_verify.py +435 -0
  65. package/scripts/closeout_derive.py +296 -0
  66. package/scripts/e2e_console.py +46 -31
  67. package/scripts/gen_e2e_scripts.py +424 -66
  68. package/scripts/gen_testid_stub.py +8 -3
  69. package/scripts/make_review_inputs.py +20 -6
  70. package/scripts/pkg_digest.py +42 -5
  71. package/scripts/refresh_structure.py +29 -8
  72. package/scripts/release.py +238 -7
  73. package/scripts/write_claim.py +204 -0
  74. package/shared/closeout.md +7 -0
  75. package/shared/gates.md +24 -19
  76. package/shared/global-rules.md +8 -1
  77. package/shared/independent-review.md +4 -3
  78. package/shared/layout.md +28 -3
  79. package/shared/single-source.md +2 -0
  80. package/shared/stack-profile.md +19 -10
  81. package/skills/atlas-apply/SKILL.md +1 -0
  82. package/skills/atlas-design-review/SKILL.md +3 -3
  83. package/skills/atlas-e2e/SKILL.md +1 -0
  84. package/skills/atlas-prd/SKILL.md +1 -0
  85. package/skills/atlas-release/SKILL.md +9 -3
  86. package/skills/atlas-structure/SKILL.md +1 -0
  87. package/skills/frontend-design/SKILL.md +2 -2
  88. package/skills/frontend-design/references/tuning-verbs.md +1 -1
  89. package/templates/e2e/case-shard.md +1 -1
  90. package/templates/e2e/console/app.js +25 -4
  91. package/templates/e2e/console/console.css +2 -0
  92. package/templates/e2e/console/index.html +2 -2
  93. package/templates/prd/domain.md +14 -12
  94. package/templates/prd/global.md +2 -2
  95. package/templates/stack-profile.yaml +12 -11
  96. package/tests/__pycache__/test_ac_adapters.cpython-312-pytest-9.1.1.pyc +0 -0
  97. package/tests/__pycache__/test_adapter_api.cpython-312-pytest-9.1.1.pyc +0 -0
  98. package/tests/__pycache__/test_adapter_models.cpython-312-pytest-9.1.1.pyc +0 -0
  99. package/tests/__pycache__/test_apply_wiring.cpython-312-pytest-9.1.1.pyc +0 -0
  100. package/tests/__pycache__/test_atlas_check.cpython-312-pytest-9.1.1.pyc +0 -0
  101. package/tests/__pycache__/test_atlas_verify.cpython-312-pytest-9.1.1.pyc +0 -0
  102. package/tests/__pycache__/test_atlas_verify_playground.cpython-312-pytest-9.1.1.pyc +0 -0
  103. package/tests/__pycache__/test_canonical_consistency.cpython-312-pytest-9.1.1.pyc +0 -0
  104. package/tests/__pycache__/test_closeout_derive.cpython-312-pytest-9.1.1.pyc +0 -0
  105. package/tests/__pycache__/test_duty_bug_families.cpython-312-pytest-9.1.1.pyc +0 -0
  106. package/tests/__pycache__/test_e2e_console.cpython-312-pytest-9.1.1.pyc +0 -0
  107. package/tests/__pycache__/test_gen_e2e_scripts.cpython-312-pytest-9.1.1.pyc +0 -0
  108. package/tests/__pycache__/test_gen_e2e_scripts.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_install_selfcheck.cpython-312-pytest-9.1.1.pyc +0 -0
  110. package/tests/__pycache__/test_layout.cpython-312-pytest-9.1.1.pyc +0 -0
  111. package/tests/__pycache__/test_map_anchors.cpython-312-pytest-9.1.1.pyc +0 -0
  112. package/tests/__pycache__/test_no_framework_names.cpython-312-pytest-9.1.1.pyc +0 -0
  113. package/tests/__pycache__/test_project_wiring.cpython-312-pytest-9.1.1.pyc +0 -0
  114. package/tests/__pycache__/test_refresh_facts.cpython-312-pytest-9.1.1.pyc +0 -0
  115. package/tests/__pycache__/test_shared_kernel.cpython-312-pytest-9.1.1.pyc +0 -0
  116. package/tests/__pycache__/test_tools.cpython-312-pytest-9.1.1.pyc +0 -0
  117. package/tests/__pycache__/test_tools.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_validate_ac_alignment.cpython-312-pytest-9.1.1.pyc +0 -0
  119. package/tests/__pycache__/test_validate_adr.cpython-312-pytest-9.1.1.pyc +0 -0
  120. package/tests/__pycache__/test_validate_walkthrough.cpython-312-pytest-9.1.1.pyc +0 -0
  121. package/tests/__pycache__/test_validators.cpython-312-pytest-9.1.1.pyc +0 -0
  122. package/tests/__pycache__/test_write_claim.cpython-312-pytest-9.1.1.pyc +0 -0
  123. package/tests/fixtures/e2e_coverage_dims_golden.json +12 -0
  124. package/tests/fixtures/verify_playground/product/e2e/cases/home.md +40 -0
  125. package/tests/fixtures/verify_playground/product/e2e/e2e-index.md +18 -0
  126. package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/README.md +3 -0
  127. package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/review.json +17 -0
  128. package/tests/fixtures/verify_playground/product/e2e/scripts/atlas-prepare.mjs +4 -0
  129. package/tests/fixtures/verify_playground/product/prd/README.md +5 -0
  130. package/tests/fixtures/verify_playground/product/prd/home-prd.md +62 -0
  131. package/tests/fixtures/verify_playground/product/prd/prd.md +52 -0
  132. package/tests/fixtures/verify_playground/product/stack-profile.yaml.tpl +18 -0
  133. package/tests/fixtures/verify_playground/site/index.html +6 -0
  134. package/tests/test_ac_adapters.py +312 -0
  135. package/tests/test_adapter_api.py +253 -0
  136. package/tests/test_adapter_models.py +236 -0
  137. package/tests/test_apply_wiring.py +136 -23
  138. package/tests/test_atlas_check.py +371 -0
  139. package/tests/test_atlas_verify.py +335 -0
  140. package/tests/test_atlas_verify_playground.py +162 -0
  141. package/tests/test_canonical_consistency.py +5 -2
  142. package/tests/test_closeout_derive.py +264 -0
  143. package/tests/test_duty_bug_families.py +355 -3
  144. package/tests/test_e2e_console.py +49 -0
  145. package/tests/test_gen_e2e_scripts.py +650 -17
  146. package/tests/test_install_selfcheck.py +76 -0
  147. package/tests/test_layout.py +252 -0
  148. package/tests/test_map_anchors.py +475 -0
  149. package/tests/test_no_framework_names.py +86 -0
  150. package/tests/test_project_wiring.py +12 -4
  151. package/tests/test_refresh_facts.py +18 -3
  152. package/tests/test_shared_kernel.py +50 -0
  153. package/tests/test_tools.py +257 -1
  154. package/tests/test_validate_ac_alignment.py +1081 -0
  155. package/tests/test_validate_adr.py +24 -0
  156. package/tests/test_validate_walkthrough.py +313 -0
  157. package/tests/test_validators.py +499 -1
  158. package/tests/test_write_claim.py +236 -0
  159. package/validators/__pycache__/validate_ac_alignment.cpython-312.pyc +0 -0
  160. package/validators/__pycache__/validate_adr.cpython-312.pyc +0 -0
  161. package/validators/__pycache__/validate_design_exemptions.cpython-312.pyc +0 -0
  162. package/validators/__pycache__/validate_e2e_index.cpython-312.pyc +0 -0
  163. package/validators/__pycache__/validate_e2e_mocks.cpython-312.pyc +0 -0
  164. package/validators/__pycache__/validate_prd.cpython-312.pyc +0 -0
  165. package/validators/__pycache__/validate_stack_profile.cpython-312.pyc +0 -0
  166. package/validators/__pycache__/validate_structure.cpython-312.pyc +0 -0
  167. package/validators/__pycache__/validate_testids.cpython-312.pyc +0 -0
  168. package/validators/__pycache__/validate_walkthrough.cpython-312.pyc +0 -0
  169. package/validators/validate_ac_alignment.py +439 -183
  170. package/validators/validate_adr.py +15 -5
  171. package/validators/validate_design_exemptions.py +15 -6
  172. package/validators/validate_e2e_index.py +109 -38
  173. package/validators/validate_e2e_mocks.py +12 -5
  174. package/validators/validate_prd.py +109 -14
  175. package/validators/validate_stack_profile.py +62 -8
  176. package/validators/validate_structure.py +9 -2
  177. package/validators/validate_testids.py +36 -23
  178. package/validators/validate_walkthrough.py +300 -0
@@ -315,7 +315,13 @@ PRD_DEMO = """# demo 域 PRD
315
315
 
316
316
  ## 六、Assumptions & Defaults
317
317
 
318
+ 无。
319
+
318
320
  ## 七、变更记录
321
+
322
+ | 版本 | 日期 | 变更内容及原因 | 影响 | 结论与后续 |
323
+ |---|---|---|---|---|
324
+ | 1.0 | 2026-09-25 | 初始 | 无(未触达验证面) | 无 |
319
325
  """
320
326
 
321
327
 
@@ -1350,6 +1356,36 @@ def test_e2e_index_coverage_dimensions_pass_and_warn() -> None:
1350
1356
  assert "未知维度" in check_bad["detail"], check_bad
1351
1357
 
1352
1358
 
1359
+ def test_e2e_dimension_check_uses_shared_parser(monkeypatch) -> None:
1360
+ """D153:E2E 门必须实际调用共享 parse_dims_cell(防保留别名、实际走本地副本)。"""
1361
+ INDEX_WITH_PAGE = INDEX_GOOD + "\n## 页面\n\n| 页面 | 路由 | 所属域 | 状态 |\n|---|---|---|---|\n| home | / | home | 已实现 |\n"
1362
+ with tempfile.TemporaryDirectory() as td:
1363
+ root = Path(td)
1364
+ mk_prototype(root)
1365
+ mk_index(root, INDEX_WITH_PAGE)
1366
+ prd_dir = root / "product" / "prd"
1367
+ prd_dir.mkdir(parents=True, exist_ok=True)
1368
+ (prd_dir / "home-prd.md").write_text(
1369
+ "---\ndomain: home\n---\n\n"
1370
+ "## 五、验收标准\n\n"
1371
+ "| AC ID | 场景 | 回指规则 | 适用覆盖维度 |\n"
1372
+ "|---|---|---|---|\n"
1373
+ "| `AC-HOME-001` | 首页展示正常 | HOME-R-001 | C01 |\n",
1374
+ encoding="utf-8",
1375
+ )
1376
+ case_with_c01 = CASE_GOOD.strip()[:-3] + "覆盖维度:\n - C01\n```\n"
1377
+ mk_cases(root, case_with_c01)
1378
+
1379
+ base = ve.validate(root)
1380
+ base_check = next(c for c in base.checks if c["check"] == "AC × 覆盖维度")
1381
+ assert base_check["level"] == "PASS", base_check
1382
+
1383
+ monkeypatch.setattr(ve, "parse_dims_cell", lambda raw: (set(), False))
1384
+ res = ve.validate(root)
1385
+ check = next(c for c in res.checks if c["check"] == "AC × 覆盖维度")
1386
+ assert check["level"] == "N/A", check
1387
+
1388
+
1353
1389
  def test_e2e_index_verb_fail() -> None:
1354
1390
  """新门(契约 §5.5,2026-09-23):step/expected 首 token 不在动词词表内 → FAIL。"""
1355
1391
  with tempfile.TemporaryDirectory() as td:
@@ -1440,6 +1476,97 @@ def test_e2e_index_new_assert_verbs_render_and_validate() -> None:
1440
1476
  ), [(c["check"], c["level"]) for c in res.checks]
1441
1477
 
1442
1478
 
1479
+ def test_e2e_index_request_assertion_shape() -> None:
1480
+ """D142:`request <api> <json-path> <expected>` 的形态门;api 不得被 testid 提取器误算。
1481
+
1482
+ 正向:合法 request 断言 PASS;反向:缺 path/expected、api 无点、`(空)`、与
1483
+ initial/unchanged 叠写均 FAIL。两侧同测(校验器放行 + 生成器可渲染)。
1484
+ """
1485
+ good = CASE_GOOD.replace(
1486
+ "- text home-title 首页 # 页面标题文案为「首页」",
1487
+ "- request app.updateApp Data.reqDTO.config.nodeParam.mailStatus (缺席) # 字段缺席")
1488
+
1489
+ def level(case_text: str) -> tuple[str, list[dict]]:
1490
+ with tempfile.TemporaryDirectory() as td:
1491
+ root = Path(td)
1492
+ mk_prototype(root)
1493
+ mk_cases(root, case_text)
1494
+ mk_index(root)
1495
+ res = ve.validate(root)
1496
+ return res.status, res.checks
1497
+
1498
+ status, checks = level(good)
1499
+ assert not any(c["check"] == "request 断言形态" and c["level"] == "FAIL" for c in checks), checks
1500
+ assert any(c["check"] == "request 断言形态" and c["level"] == "PASS" for c in checks), checks
1501
+ assert gn.ts_case(
1502
+ {"id": "E2E-HOME-001", "_title": "t", "step": ["click home-cta-btn"],
1503
+ "expected": ["request app.updateApp Data.a (缺席)"], "testid": ["home-cta-btn"]},
1504
+ "/",
1505
+ ).count("await atlasExpectRequest(") == 1
1506
+
1507
+ bad_cases = [
1508
+ ("request app.updateApp", "形态"),
1509
+ ("request app Data.a (缺席)", "api 须为带点后缀"),
1510
+ ("request app.updateApp Data.a (空)", "(空) 三态不明"),
1511
+ ("initial: request app.updateApp Data.a null", "叠写"),
1512
+ ]
1513
+ for bad, needle in bad_cases:
1514
+ status, checks = level(CASE_GOOD.replace(
1515
+ "- text home-title 首页 # 页面标题文案为「首页」", f"- {bad} # bad"))
1516
+ row = next(c for c in checks if c["check"] == "request 断言形态")
1517
+ assert row["level"] == "FAIL", (bad, row)
1518
+ assert needle in row["detail"], (bad, row)
1519
+
1520
+ # testid 提取:api 不是 testid(不得报「原型无 app.updateApp」)
1521
+ with tempfile.TemporaryDirectory() as td:
1522
+ root = Path(td)
1523
+ mk_prototype(root)
1524
+ mk_cases(root, good)
1525
+ mk_index(root)
1526
+ tres = vt.validate(root)
1527
+ assert not any("app.updateApp" in (c.get("detail") or "") for c in tres.checks), tres.checks
1528
+
1529
+
1530
+ def test_e2e_index_manual_block_shape() -> None:
1531
+ """D143:manual.md 的 `atlas-manual` 块必须 ID 唯一、页面在页面表、步骤含操作/观察;
1532
+ 旧单行格式继续可用。坏块/坏页面/重复 ID ⇒ FAIL,解析失败也 FAIL。"""
1533
+ valid = """## MANUAL-HOME-001 结构化走查
1534
+
1535
+ ```atlas-manual
1536
+ page: home
1537
+ step:
1538
+ - 打开首页 | 首页出现 |
1539
+ - 保存 | payload 不含字段 | const p = 1
1540
+ ```
1541
+ """
1542
+ with tempfile.TemporaryDirectory() as td:
1543
+ root = Path(td)
1544
+ mk_prototype(root)
1545
+ mk_cases(root, CASE_GOOD)
1546
+ mk_index(root)
1547
+ manual = root / "product" / "e2e" / "manual.md"
1548
+ manual.write_text(valid, encoding="utf-8")
1549
+ res = ve.validate(root)
1550
+ row = next(c for c in res.checks if c["check"] == "manual 走查项形态")
1551
+ assert row["level"] == "PASS", row
1552
+
1553
+ def level(text: str) -> dict:
1554
+ with tempfile.TemporaryDirectory() as td:
1555
+ root = Path(td)
1556
+ mk_prototype(root)
1557
+ mk_cases(root, CASE_GOOD)
1558
+ mk_index(root)
1559
+ (root / "product" / "e2e" / "manual.md").write_text(text, encoding="utf-8")
1560
+ res = ve.validate(root)
1561
+ return next(c for c in res.checks if c["check"] == "manual 走查项形态")
1562
+
1563
+ assert level(valid.replace("page: home", "page: nope"))["level"] == "FAIL"
1564
+ assert level(valid.replace(" - 打开首页 | 首页出现 |", " - 打开首页 | |"))["level"] == "FAIL"
1565
+ dup = valid + "\n" + valid.replace("MANUAL-HOME-001", "MANUAL-HOME-001")
1566
+ assert level(dup)["level"] == "FAIL"
1567
+ assert level(valid.replace("page: home", "id: MANUAL-HOME-001\npage: home"))["detail"].startswith("解析失败")
1568
+
1569
+
1443
1570
  def test_e2e_index_title_must_match_shard() -> None:
1444
1571
  """契约 §8.2(2026-09-25 补,`3509 §B31`):索引 `中文标题` == 分片
1445
1572
  `## <ID> <标题>` 的**标题段**(空白归一后全等)。
@@ -1487,6 +1614,25 @@ def test_e2e_index_download_is_action_not_assertion() -> None:
1487
1614
  assert bad["expected 动词合法"] == "FAIL", bad
1488
1615
 
1489
1616
 
1617
+ def test_e2e_index_type_is_action_not_assertion() -> None:
1618
+ """契约 §5.5(D134,2026-10-23):`type` 属**动作**(写在 `step` 侧)。
1619
+
1620
+ 两侧同测:写在 `step` ⇒ 校验器放行(`step 动词合法` PASS);写在 `expected` ⇒ 被拒
1621
+ (断言词表里没有它)——反向证据:它确实落在动作侧,不是两边都收。
1622
+ """
1623
+ ok = _levels(CASE_GOOD.replace(
1624
+ "- click home-cta-btn # 点击主操作按钮",
1625
+ "- type home-title 首页 # 逐字键入标题",
1626
+ ))
1627
+ assert ok["step 动词合法"] == "PASS", ok
1628
+
1629
+ bad = _levels(CASE_GOOD.replace(
1630
+ "- text home-title 首页 # 页面标题文案为「首页」",
1631
+ "- type home-title 首页",
1632
+ ))
1633
+ assert bad["expected 动词合法"] == "FAIL", bad
1634
+
1635
+
1490
1636
  def test_e2e_index_upload_is_action_and_fixture_reference_closed() -> None:
1491
1637
  """契约 §5.5(B201-1,2026-10-12):`upload` 属**动作**(写在 `step` 侧),
1492
1638
  值 = **相对 `product/e2e/fixtures/` 的文件路径**(与 §5.8 mock 同一 fixtures 树)。
@@ -1995,6 +2141,88 @@ def test_stack_profile_origin_invalid_fails() -> None:
1995
2141
  assert _sp_check(res, "origin 枚举")["level"] == "FAIL"
1996
2142
 
1997
2143
 
2144
+ def test_stack_profile_backend_test_extractor_is_generic() -> None:
2145
+ """D163:extractor 是注册表名,不在 profile 层枚举具体框架。"""
2146
+ with tempfile.TemporaryDirectory() as td:
2147
+ root = Path(td)
2148
+ text = PROFILE_BASE.format(origin="greenfield") + (
2149
+ "backend_test:\n extractor: vitest\n test_dir: web\n mode: advisory\n"
2150
+ )
2151
+ mk_profile(root, text)
2152
+ res = vsp.validate(root)
2153
+ assert _sp_check(res, "backend_test.extractor")["level"] == "PASS"
2154
+ assert res.status != "FAIL", [(c["check"], c["level"]) for c in res.checks]
2155
+
2156
+
2157
+ def test_stack_profile_backend_test_extractor_alias_conflict_fails() -> None:
2158
+ with tempfile.TemporaryDirectory() as td:
2159
+ root = Path(td)
2160
+ text = PROFILE_BASE.format(origin="greenfield") + (
2161
+ "backend_test:\n framework: pytest\n extractor: vitest\n test_dir: web\n"
2162
+ )
2163
+ mk_profile(root, text)
2164
+ res = vsp.validate(root)
2165
+ assert _sp_check(res, "backend_test.extractor 别名")["level"] == "FAIL"
2166
+ assert res.status == "FAIL"
2167
+
2168
+
2169
+ def test_stack_profile_backend_test_extractor_shape_fails() -> None:
2170
+ with tempfile.TemporaryDirectory() as td:
2171
+ root = Path(td)
2172
+ text = PROFILE_BASE.format(origin="greenfield") + (
2173
+ "backend_test:\n extractor: 'JUnit 5'\n test_dir: web\n"
2174
+ )
2175
+ mk_profile(root, text)
2176
+ res = vsp.validate(root)
2177
+ assert _sp_check(res, "backend_test.extractor 形态")["level"] == "FAIL"
2178
+
2179
+
2180
+ def test_stack_profile_backend_test_legacy_framework_alias_passes() -> None:
2181
+ with tempfile.TemporaryDirectory() as td:
2182
+ root = Path(td)
2183
+ text = PROFILE_BASE.format(origin="greenfield") + (
2184
+ "backend_test:\n framework: pytest\n test_dir: web\n"
2185
+ )
2186
+ mk_profile(root, text)
2187
+ res = vsp.validate(root)
2188
+ assert _sp_check(res, "backend_test.extractor")["level"] == "PASS"
2189
+
2190
+
2191
+ def test_stack_profile_backend_test_external_command_passes() -> None:
2192
+ with tempfile.TemporaryDirectory() as td:
2193
+ root = Path(td)
2194
+ text = PROFILE_BASE.format(origin="greenfield") + (
2195
+ "backend_test:\n extractor_command: java -jar tools/ac.jar\n test_dir: web\n"
2196
+ )
2197
+ mk_profile(root, text)
2198
+ res = vsp.validate(root)
2199
+ assert _sp_check(res, "backend_test.extractor_command")["level"] == "PASS"
2200
+
2201
+
2202
+ def test_stack_profile_backend_test_external_command_mutual_exclusion_fails() -> None:
2203
+ with tempfile.TemporaryDirectory() as td:
2204
+ root = Path(td)
2205
+ text = PROFILE_BASE.format(origin="greenfield") + (
2206
+ "backend_test:\n extractor: pytest\n"
2207
+ " extractor_command: java -jar tools/ac.jar\n test_dir: web\n"
2208
+ )
2209
+ mk_profile(root, text)
2210
+ res = vsp.validate(root)
2211
+ assert _sp_check(res, "backend_test.extractor_command 互斥")["level"] == "FAIL"
2212
+ assert res.status == "FAIL"
2213
+
2214
+
2215
+ def test_stack_profile_backend_test_external_command_empty_fails() -> None:
2216
+ with tempfile.TemporaryDirectory() as td:
2217
+ root = Path(td)
2218
+ text = PROFILE_BASE.format(origin="greenfield") + (
2219
+ "backend_test:\n extractor_command: ''\n test_dir: web\n"
2220
+ )
2221
+ mk_profile(root, text)
2222
+ res = vsp.validate(root)
2223
+ assert _sp_check(res, "backend_test.extractor_command 形态")["level"] == "FAIL"
2224
+
2225
+
1998
2226
  def test_stack_profile_top_level_scalar_after_section_is_parsed() -> None:
1999
2227
  """段头之后的顶层标量必须被解析。
2000
2228
 
@@ -2467,6 +2695,89 @@ def test_prd_change_form_fields_and_revision_log_body() -> None:
2467
2695
  assert c["status"] == "FAIL" and "正文为空" in c["detail"], c
2468
2696
 
2469
2697
 
2698
+ def test_rfc20261023_02_change_record_shape_and_impact_column() -> None:
2699
+ """RFC-20261023-02 ③:§七 表头三态 + 「影响」单元格空 ⇒ FAIL(独立 check 行)。
2700
+
2701
+ 变异:**M-new-3** 删 legacy 分支(3 列直接 FAIL)⇒ 第二侧红;摘「影响」空判据
2702
+ ⇒ 第三侧红;表头不可辨分支摘掉 ⇒ 第四侧红。
2703
+ """
2704
+ with tempfile.TemporaryDirectory() as td:
2705
+ root = Path(td)
2706
+ dom = FM_DOMAIN + PRD_DEMO.format(ac="AC-HOME-001", rows="| `AC-HOME-001` | 纯渲染 |")
2707
+ plain = FM_GLOBAL + PRD_GLOBAL
2708
+
2709
+ def row() -> dict:
2710
+ return {c["check"]: c for c in vpr.validate(root).checks}["变更记录形态"]
2711
+
2712
+ mk_prd_raw(root, plain, dom)
2713
+ assert row()["status"] == "PASS", row()
2714
+
2715
+ legacy = dom.replace(
2716
+ "| 版本 | 日期 | 变更内容及原因 | 影响 | 结论与后续 |\n|---|---|---|---|---|\n"
2717
+ "| 1.0 | 2026-09-25 | 初始 | 无(未触达验证面) | 无 |",
2718
+ "| 版本 | 日期 | 变更说明 |\n|---|---|---|\n| 1.0 | 2026-09-25 | 初始 |")
2719
+ mk_prd_raw(root, plain, legacy)
2720
+ assert row()["status"] == "WARN" and "legacy" in row()["detail"], row()
2721
+
2722
+ blank = dom.replace("| 无(未触达验证面) | 无 |", "| | 无 |")
2723
+ mk_prd_raw(root, plain, blank)
2724
+ assert row()["status"] == "FAIL" and "影响" in row()["detail"], row()
2725
+
2726
+ weird = dom.replace("| 变更内容及原因 | 影响 | 结论与后续 |", "| 甲 | 乙 | 丙 |")
2727
+ mk_prd_raw(root, plain, weird)
2728
+ assert row()["status"] == "FAIL", row()
2729
+
2730
+
2731
+ def test_rfc20261023_02_optional_section_explicit_none() -> None:
2732
+ """RFC-20261023-02 ④:§六 空表/空节且无显式结论 ⇒ WARN 首版;`无` ⇒ PASS;
2733
+ 开户最小集(§六 整节不出现)⇒ 本检查不参与(PASS 行)。
2734
+
2735
+ 变异:**M-new-2** 空表判据恒真(恒 PASS)⇒ 第一侧红。
2736
+ """
2737
+ with tempfile.TemporaryDirectory() as td:
2738
+ root = Path(td)
2739
+ plain = FM_GLOBAL + PRD_GLOBAL
2740
+ dom = FM_DOMAIN + PRD_DEMO.format(ac="AC-HOME-001", rows="| `AC-HOME-001` | 纯渲染 |")
2741
+
2742
+ def row() -> dict:
2743
+ return {c["check"]: c for c in vpr.validate(root).checks}["可无项显式(§六)"]
2744
+
2745
+ empty = dom.replace("## 六、Assumptions & Defaults\n\n无。",
2746
+ "## 六、Assumptions & Defaults\n\n")
2747
+ mk_prd_raw(root, plain, empty)
2748
+ assert row()["status"] == "WARN" and "显式结论" in row()["detail"], row()
2749
+
2750
+ empty_tbl = dom.replace("## 六、Assumptions & Defaults\n\n无。",
2751
+ "## 六、Assumptions & Defaults\n\n"
2752
+ "| 假设内容 | 采用的默认值 | 影响面 | 何时需要澄清 |\n"
2753
+ "|---|---|---|---|\n")
2754
+ mk_prd_raw(root, plain, empty_tbl)
2755
+ assert row()["status"] == "WARN", row()
2756
+
2757
+ mk_prd_raw(root, plain, dom)
2758
+ assert row()["status"] == "PASS", row()
2759
+
2760
+ na = dom.replace("## 六、Assumptions & Defaults\n\n无。\n\n", "")
2761
+ mk_prd_raw(root, plain, na)
2762
+ assert row()["status"] == "PASS", row() # 节缺席 = 开户最小集,不参与
2763
+
2764
+
2765
+ def test_forbidden_vocabulary_single_source() -> None:
2766
+ """RFC-20261023-02 ④ 词表守恒:两校验器 `FORBIDDEN` ⊆ `global-rules` §5 词表,
2767
+ 且 `[必填` / `[条件必填` 三方齐在(改任一侧 ⇒ 红)。"""
2768
+ rules = (PKG_ROOT / "shared" / "global-rules.md").read_text(encoding="utf-8")
2769
+ m = re.search(r"## 5\. 占位符禁令.*?(?=\n## )", rules, re.S)
2770
+ assert m, "找不到 global-rules §5 占位符禁令"
2771
+ tokens = set(re.findall(r"`([^`\n]+)`", m.group(0)))
2772
+ vs = load("validate_structure", "validators/validate_structure.py")
2773
+ for mod, name in ((vpr, "validate_prd"), (vs, "validate_structure")):
2774
+ extra = set(mod.FORBIDDEN) - tokens
2775
+ assert not extra, f"{name} FORBIDDEN 超出 §5 单源:{sorted(extra)}"
2776
+ for tok in ("[必填", "[条件必填"):
2777
+ assert tok in mod.FORBIDDEN, (name, "缺", tok)
2778
+ assert tok in tokens, ("§5 缺", tok)
2779
+
2780
+
2470
2781
  def test_testids_stub_missing_fails() -> None:
2471
2782
  """契约 §4.1(2026-09-26 补,`3509 §B73`)/F32(R7 升格为机器兜底):薄桩由生成器
2472
2783
  产出;缺失 ⇒ 本器**自动再生 + PASS 一行知悉**(R6 机器兜底哲学——缺失的派生工件
@@ -2759,6 +3070,11 @@ def test_f22_imperative_testid_extraction_and_counts() -> None:
2759
3070
  li.dataset.testid = "notes-item";
2760
3071
  // setAttribute 字面量
2761
3072
  btn.setAttribute("data-testid", "notes-submit");
3073
+ // setAttribute 多行 + Prettier 尾逗号
3074
+ img.setAttribute(
3075
+ "data-testid",
3076
+ "notes-avatar",
3077
+ );
2762
3078
  // setAttribute 模板展开
2763
3079
  el.setAttribute("data-testid", `${slug}-header`);
2764
3080
  """,
@@ -2773,6 +3089,7 @@ def test_f22_imperative_testid_extraction_and_counts() -> None:
2773
3089
  # 1. 抽取源覆盖:字面量与注入全部抽取
2774
3090
  assert "notes-item" in counts
2775
3091
  assert "notes-submit" in counts
3092
+ assert "notes-avatar" in counts
2776
3093
  # 2. counts 为声明文件数:同文件出现多次 notes-item,文件数仍为 1
2777
3094
  assert counts["notes-item"] == 1
2778
3095
  assert counts["notes-submit"] == 1
@@ -3550,7 +3867,8 @@ def test_prd_unverifiable_list_ghost_fails() -> None:
3550
3867
 
3551
3868
  def test_prd_illegal_nested_directories_fails() -> None:
3552
3869
  """契约 rings/prd/reference.md §2(平铺规范):
3553
- product/prd/ 严禁子目录(如 domains/)或散落非标准 markdown 文件。
3870
+ product/prd/ 除独立审查报告留痕 reviews/ 外严禁子目录(如 domains/)或散落非标准
3871
+ markdown 文件;reviews/ 是唯一保留子目录(issue #43)。
3554
3872
  存在非法子目录或非标准文件时,参数0 目录规范门必须 FAIL(防静默穿透门控)。
3555
3873
  """
3556
3874
  with tempfile.TemporaryDirectory() as td:
@@ -3565,6 +3883,15 @@ def test_prd_illegal_nested_directories_fails() -> None:
3565
3883
  row0 = [c for c in res.checks if c["check"] == "参数0 目录规范"][0]
3566
3884
  assert row0["status"] == "PASS"
3567
3885
 
3886
+ # reviews/ 是唯一保留子目录(独立审查报告留痕,issue #43):PASS
3887
+ reviews = root / "product" / "prd" / "reviews" / "2026-10-28-x"
3888
+ reviews.mkdir(parents=True)
3889
+ (reviews / "README.md").write_text("# review\n", encoding="utf-8")
3890
+ (reviews / "review.json").write_text("{}", encoding="utf-8")
3891
+ res_reviews = vpr.validate(root)
3892
+ row0_reviews = [c for c in res_reviews.checks if c["check"] == "参数0 目录规范"][0]
3893
+ assert row0_reviews["status"] == "PASS", row0_reviews
3894
+
3568
3895
  # 变异注入:创建非法子目录 domains/ 并放置 markdown
3569
3896
  nested_dir = root / "product" / "prd" / "domains"
3570
3897
  nested_dir.mkdir(parents=True, exist_ok=True)
@@ -3607,6 +3934,41 @@ def test_validate_testids_strip_comments() -> None:
3607
3934
  assert "notes-commented-html" not in counts
3608
3935
 
3609
3936
 
3937
+ def test_validate_testids_strip_comments_once_per_file() -> None:
3938
+ """P5-A(D141):剥注释每文件只做一次;第二次循环复用第一次结果。
3939
+
3940
+ 直接数 `strip_comments` 的调用次数:修复前每文件 2 次(第一次循环 + 第二次循环),
3941
+ 修复后 1 次。输出仍要正确(静态属性 / dataset / setAttribute 都要抽到)。
3942
+ """
3943
+ with tempfile.TemporaryDirectory() as td:
3944
+ root = Path(td)
3945
+ src = root / "src"
3946
+ src.mkdir(parents=True)
3947
+ (src / "a.js").write_text('el.dataset.testid = "p5a-a";', encoding="utf-8")
3948
+ (src / "b.js").write_text('<i data-testid="p5a-b"></i>', encoding="utf-8")
3949
+ (src / "c.js").write_text('el.setAttribute("data-testid", "p5a-c");', encoding="utf-8")
3950
+ (root / "product").mkdir(parents=True, exist_ok=True)
3951
+ (root / "product" / "stack-profile.yaml").write_text(
3952
+ "product: p5a-test\norigin: greenfield\napps:\n - name: web\n kind: frontend\n path: src\n",
3953
+ encoding="utf-8",
3954
+ )
3955
+ original = vt.strip_comments
3956
+ calls: list[str] = []
3957
+
3958
+ def counted(text: str) -> str:
3959
+ calls.append(text)
3960
+ return original(text)
3961
+
3962
+ vt.strip_comments = counted
3963
+ try:
3964
+ _per_page, counts, files, _expanded, _dups = vt.collect_testids_expanded(root, ["src"])
3965
+ finally:
3966
+ vt.strip_comments = original
3967
+ assert len(files) == 3 # 夹具自检:三个源文件
3968
+ assert len(calls) == len(files), f"每文件只应剥一次;实际 {len(calls)} 次(修复前为 6)"
3969
+ assert {"p5a-a", "p5a-b", "p5a-c"} <= set(counts), counts
3970
+
3971
+
3610
3972
  def test_stack_profile_review_section_validation() -> None:
3611
3973
  """v0.1.0-beta.14: 校验 e2e.review 配置段(mode: gate | auto-baseline | off)。"""
3612
3974
  with tempfile.TemporaryDirectory() as td:
@@ -3737,3 +4099,139 @@ testid:
3737
4099
  lv = {c["check"]: c["level"] for c in res.checks}
3738
4100
  assert lv["ID 格式"] == "PASS", lv["ID 格式"]
3739
4101
  assert lv["索引↔分片标题一致"] == "PASS", lv["索引↔分片标题一致"]
4102
+
4103
+
4104
+ def test_testids_scan_skips_runtime_artifact_dirs() -> None:
4105
+ """D150 合成靶场咬合批(2026-10-25):实然扫描**必须跳过运行期产物目录**
4106
+ (`.atlas-run/` / `test-results/` 等,SKIP_DIRS 新增段)。
4107
+
4108
+ 现场:执行器时序下「失败批 ⇒ 立即重验」成为常态路径,而 Playwright 失败批会在
4109
+ `.atlas-run/<用例>/error-context.md` 留下含 testid 字面的快照文本——不排除则
4110
+ `validate_testids` 扫出「testid 全站唯一 —— 重复」**假红**(真源只声明一次)。
4111
+ 产物是**回声**,与测试文件同族(issue #27/#33 同判据)。
4112
+ 变异 **MB21**(摘 `.atlas-run` 排除 ⇒ 本钉红)。
4113
+ """
4114
+ with tempfile.TemporaryDirectory() as td:
4115
+ root = Path(td)
4116
+ # 复现 playground 场景:apps[].path = "."(整仓扫描)——只有这时 .atlas-run/ 才落在扫描域内
4117
+ (root / "product").mkdir(parents=True, exist_ok=True)
4118
+ (root / "product/stack-profile.yaml").write_text(
4119
+ "product: demo\napps:\n - name: web\n path: .\n kind: frontend\n"
4120
+ " role: landing\n stack: demo\n", encoding="utf-8")
4121
+ (root / "page.html").write_text(
4122
+ '<button data-testid="home-title">ok</button>', encoding="utf-8")
4123
+ mk_stub(root)
4124
+ mk_cases(root)
4125
+ mk_index(root)
4126
+ art = root / ".atlas-run" / "some-case-chromium"
4127
+ art.mkdir(parents=True)
4128
+ (art / "error-context.md").write_text(
4129
+ '<h1 data-testid="home-title">快照回声</h1>', encoding="utf-8")
4130
+ tr = root / "test-results" / "x"
4131
+ tr.mkdir(parents=True)
4132
+ (tr / "trace.md").write_text('data-testid="home-title"', encoding="utf-8")
4133
+ import importlib.util as _iu
4134
+ spec_ = _iu.spec_from_file_location(
4135
+ "vtid_scan", PKG_ROOT / "validators" / "validate_testids.py")
4136
+ vtid_ = _iu.module_from_spec(spec_)
4137
+ spec_.loader.exec_module(vtid_)
4138
+ files = {str(f.relative_to(root)) for f in vtid_._scan_files([root])}
4139
+ assert not any(f.startswith((".atlas-run/", "test-results/")) for f in files), \
4140
+ sorted(f for f in files if f.startswith((".atlas-run/", "test-results/")))
4141
+
4142
+
4143
+ # ── D157(2026-10-27):`靶场:` 具名靶场闭集门 + 路径门放行已登记靶场 ─────────────
4144
+ _TARGET_PROFILE = "e2e:\n targets:\n kb: /kb-canvas\n"
4145
+ _TARGET_CASE = CASE_GOOD.replace("step:", "靶场: kb\nstep:", 1)
4146
+
4147
+
4148
+ def test_named_target_in_registry_passes() -> None:
4149
+ """正向(D157):`靶场: kb` 且 `e2e.targets` 已登记该名 ⇒ 「靶场 取值合法」PASS。"""
4150
+ rows = _ve_rows(_TARGET_CASE, _TARGET_PROFILE)
4151
+ assert rows["靶场 取值合法"]["level"] == "PASS", rows["靶场 取值合法"]
4152
+
4153
+
4154
+ def test_named_target_without_registry_fails() -> None:
4155
+ """坏输入 1(D157):项目未声明 `e2e.targets` 而用例钉名 ⇒ FAIL(不猜)。"""
4156
+ rows = _ve_rows(_TARGET_CASE)
4157
+ assert rows["靶场 取值合法"]["level"] == "FAIL", rows["靶场 取值合法"]
4158
+
4159
+
4160
+ def test_named_target_unknown_name_fails() -> None:
4161
+ """坏输入 2(D157):钉名不在闭集内(白名单只放了别的名)⇒ FAIL。"""
4162
+ rows = _ve_rows(_TARGET_CASE, "e2e:\n targets:\n other: /other-canvas\n")
4163
+ assert rows["靶场 取值合法"]["level"] == "FAIL", rows["靶场 取值合法"]
4164
+
4165
+
4166
+ def _ve_rows_with_page_table(case: str, profile_extra: str = "") -> dict:
4167
+ """`_ve_rows` + 带页面表的索引(路径门需要非空 `page_routes` 才启用)。"""
4168
+ with tempfile.TemporaryDirectory() as td:
4169
+ root = Path(td)
4170
+ mk_prototype(root)
4171
+ if profile_extra:
4172
+ p = root / "product" / "stack-profile.yaml"
4173
+ p.write_text(p.read_text(encoding="utf-8") + profile_extra, encoding="utf-8")
4174
+ mk_cases(root, case)
4175
+ mk_index(root, _with_page_table("已实现"))
4176
+ return {c["check"]: c for c in ve.validate(root).checks}
4177
+
4178
+
4179
+ def test_named_target_route_accepted_by_goto_and_guard_gates() -> None:
4180
+ """正向(D157):`goto`/`守卫` 显式路径命中**已登记靶场路由** ⇒ 两道路径门放行。"""
4181
+ body = _TARGET_CASE.replace(
4182
+ "- click home-cta-btn",
4183
+ "- goto /kb-canvas").replace(
4184
+ "靶场: kb\n", "靶场: kb\n守卫: /kb-canvas\n", 1)
4185
+ rows = _ve_rows_with_page_table(body, _TARGET_PROFILE)
4186
+ assert rows["goto 路径 ∈ 页面表"]["level"] == "PASS", rows["goto 路径 ∈ 页面表"]
4187
+ assert rows["守卫 路径 ∈ 页面表"]["level"] == "PASS", rows["守卫 路径 ∈ 页面表"]
4188
+
4189
+
4190
+ def test_unregistered_route_still_fails_gates() -> None:
4191
+ """反向(D157):路径门放行的只有**登记表内**路由——自由路径仍 FAIL(单源不破)。"""
4192
+ body = CASE_GOOD.replace("- click home-cta-btn", "- goto /free-canvas")
4193
+ rows = _ve_rows_with_page_table(body, _TARGET_PROFILE)
4194
+ assert rows["goto 路径 ∈ 页面表"]["level"] == "FAIL", rows["goto 路径 ∈ 页面表"]
4195
+ body2 = CASE_GOOD.replace("step:", "守卫: /free-canvas\nstep:", 1)
4196
+ rows2 = _ve_rows_with_page_table(body2, _TARGET_PROFILE)
4197
+ assert rows2["守卫 路径 ∈ 页面表"]["level"] == "FAIL", rows2["守卫 路径 ∈ 页面表"]
4198
+
4199
+
4200
+ # ── D157(2026-10-27):`e2e.targets` 形状门(stack-profile 校验器)───────────────
4201
+ def test_stack_profile_targets_shape_pass() -> None:
4202
+ """正向(D157):合法 `targets` 登记 ⇒ 「e2e.targets 形态」PASS。"""
4203
+ with tempfile.TemporaryDirectory() as td:
4204
+ root = Path(td)
4205
+ mk_profile(root, PROFILE_BASE.format(origin="greenfield")
4206
+ + " targets:\n kb: /kb-canvas\n")
4207
+ res = vsp.validate(root)
4208
+ assert _sp_check(res, "e2e.targets 形态")["level"] == "PASS", \
4209
+ [(c["check"], c["level"], c["detail"]) for c in res.checks]
4210
+
4211
+
4212
+ def test_stack_profile_targets_bad_shape_fails() -> None:
4213
+ """坏输入(D157):路由不以 / 开头 ⇒ FAIL;名字含非法字符 ⇒ FAIL。"""
4214
+ with tempfile.TemporaryDirectory() as td:
4215
+ root = Path(td)
4216
+ mk_profile(root, PROFILE_BASE.format(origin="greenfield")
4217
+ + " targets:\n kb: kb-canvas\n")
4218
+ res = vsp.validate(root)
4219
+ assert _sp_check(res, "e2e.targets 形态")["level"] == "FAIL", \
4220
+ [(c["check"], c["level"], c["detail"]) for c in res.checks]
4221
+ with tempfile.TemporaryDirectory() as td:
4222
+ root = Path(td)
4223
+ mk_profile(root, PROFILE_BASE.format(origin="greenfield")
4224
+ + " targets:\n Bad_Name: /kb-canvas\n")
4225
+ res = vsp.validate(root)
4226
+ assert _sp_check(res, "e2e.targets 形态")["level"] == "FAIL", \
4227
+ [(c["check"], c["level"], c["detail"]) for c in res.checks]
4228
+
4229
+
4230
+ def test_stack_profile_targets_absent_pass() -> None:
4231
+ """零变化承诺(D157):未声明 `targets` ⇒ 可选段 PASS(单靶场现行为)。"""
4232
+ with tempfile.TemporaryDirectory() as td:
4233
+ root = Path(td)
4234
+ mk_profile(root, PROFILE_BASE.format(origin="greenfield"))
4235
+ res = vsp.validate(root)
4236
+ assert _sp_check(res, "e2e.targets(可选段)")["level"] == "PASS", \
4237
+ [(c["check"], c["level"], c["detail"]) for c in res.checks]