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
@@ -2,11 +2,12 @@
2
2
  """atlas 校验器 —— 架构决策记录(ADR)长效资产守卫(2026-10-13)。
3
3
 
4
4
  守一件事:
5
- 项目级长效架构决策唯一落地于 `product/adr/NNNN-<slug>.md`。
5
+ 项目级长效架构决策唯一落地于 `资产根/adr/NNNN-<slug>.md`(资产根默认 `product/`,
6
+ 由项目根 `atlas.config.yaml` 的 `layout.assets_root` 解析)。
6
7
  规范文档形态,保障状态合法性、决策确定性与版本演进追溯。
7
8
 
8
9
  判定(三态 + WARN):
9
- - 无 `product/adr/` 目录或目录为空 ⇒ SKIP(尚未创建任何 ADR,非失败)。
10
+ - 无 `资产根/adr/` 目录或目录为空 ⇒ SKIP(尚未创建任何 ADR,非失败)。
10
11
  - 有 ADR 文件:
11
12
  * 编号唯一:无同号异名冲突(0001-foo ⇔ 0001-bar)。
12
13
  * Frontmatter 合法:status 必须属于 {proposed, accepted, superseded, deprecated}。
@@ -30,7 +31,10 @@ import re
30
31
  import sys
31
32
  from pathlib import Path
32
33
 
33
- ADR_DIR_RELPATH = Path("product") / "adr"
34
+ if str(Path(__file__).resolve().parents[1]) not in sys.path:
35
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
36
+ from _support.layout import LayoutError, load_layout # noqa: E402
37
+
34
38
  ADR_FILENAME_PATTERN = re.compile(r"^(\d{4})-[a-z0-9_-]+\.md$", re.IGNORECASE)
35
39
  VALID_STATUSES = {"proposed", "accepted", "superseded", "deprecated"}
36
40
 
@@ -89,10 +93,16 @@ def extract_section(text: str, section_kw: str) -> str | None:
89
93
 
90
94
  def validate_adr(root: Path) -> Result:
91
95
  res = Result()
92
- adr_dir = root / ADR_DIR_RELPATH
96
+ try:
97
+ lay = load_layout(root)
98
+ except LayoutError as exc:
99
+ res.add("项目布局配置", "FAIL", str(exc))
100
+ return res
101
+ adr_dir = lay.adr_dir
93
102
 
94
103
  if not adr_dir.exists() or not adr_dir.is_dir():
95
- res.add("adr_dir_exists", "SKIP", f"前置未声明(未就绪,非失败):无 {ADR_DIR_RELPATH}/")
104
+ res.add("adr_dir_exists", "SKIP",
105
+ f"前置未声明(未就绪,非失败):无 {lay.print_value('adr')}/")
96
106
  return res
97
107
 
98
108
  files = [f for f in adr_dir.iterdir() if f.is_file() and not f.name.startswith(".")]
@@ -3,7 +3,8 @@
3
3
 
4
4
  守一件事(机器对账取代 prose 约定):
5
5
 
6
- `product/design.md`(视觉设计档案,frontend-design skill 写、atlas 只读)里
6
+ `资产根/design.md`(资产根默认 `product/`,由项目根 `atlas.config.yaml` 解析;
7
+ 视觉设计档案,frontend-design skill 写、atlas 只读)里
7
8
  「已有豁免」中的 detect 类豁免写作 `detect:<rule-id>`;同一组 rule id 必须
8
9
  出现在项目根 `.impeccable/config.json` 的 `detector.ignoreRules` —— **双向
9
10
  集合相等**。
@@ -13,7 +14,7 @@
13
14
  单侧缺失 = 报警关了没人知道为什么(缺台账),或台账说关了其实没关(缺配置)。
14
15
 
15
16
  判定(三态,`§B120`):
16
- - 无 `product/design.md` ⇒ **SKIP**(档案未建 = 未就绪,不是失败)。
17
+ - 无 `资产根/design.md` ⇒ **SKIP**(档案未建 = 未就绪,不是失败)。
17
18
  - 档案 `detect:<id>` 集合 ⇔ 配置 `detector.ignoreRules` 集合相等 ⇒ PASS
18
19
  (两侧皆空是合法退化态)。
19
20
  - 任一侧多出 ⇒ FAIL,双向差集指名。
@@ -38,7 +39,10 @@ import re
38
39
  import sys
39
40
  from pathlib import Path
40
41
 
41
- DESIGN_RELPATH = Path("product") / "design.md"
42
+ if str(Path(__file__).resolve().parents[1]) not in sys.path:
43
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
44
+ from _support.layout import LayoutError, load_layout # noqa: E402
45
+
42
46
  CONFIG_RELPATH = Path(".impeccable") / "config.json"
43
47
  DETECT_TOKEN = re.compile(r"detect:([A-Za-z0-9][A-Za-z0-9_-]*)")
44
48
 
@@ -87,12 +91,17 @@ def config_ignore_rules(cfg_path: Path) -> set[str]:
87
91
 
88
92
  def run(root: Path) -> Result:
89
93
  res = Result()
90
- design = root / DESIGN_RELPATH
94
+ try:
95
+ lay = load_layout(root)
96
+ except LayoutError as exc:
97
+ res.add("项目布局配置", "FAIL", str(exc))
98
+ return res
99
+ design = lay.design
91
100
  if not design.is_file():
92
101
  res.add("设计档案存在", "SKIP",
93
- "无 product/design.md(档案未建,未就绪非失败)")
102
+ f"无 {lay.print_value('design')}(档案未建,未就绪非失败)")
94
103
  return res
95
- res.add("设计档案存在", "PASS", DESIGN_RELPATH.as_posix())
104
+ res.add("设计档案存在", "PASS", lay.print_value("design"))
96
105
 
97
106
  ids = design_detect_ids(design.read_text(encoding="utf-8", errors="replace"))
98
107
  cfg = root / CONFIG_RELPATH
@@ -40,6 +40,10 @@ import re
40
40
  import sys
41
41
  from pathlib import Path
42
42
 
43
+ if str(Path(__file__).resolve().parents[1]) not in sys.path:
44
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
45
+ from _support.layout import LayoutError, load_layout # noqa: E402
46
+
43
47
 
44
48
  def _format_list(items: list[str], max_show: int = 20) -> str:
45
49
  if not items:
@@ -61,6 +65,9 @@ url_merge = load_module("url_merge")
61
65
  # 用例 ID 文法体单源(正文归 rings/e2e/reference.md §5.3;锚点本处自留)
62
66
  case_id = load_module("case_id")
63
67
  stack_profile = load_module("stack_profile")
68
+ # 维度 token 单源(D153):区间展开 / 中文邻接 / 疑似写法出声归 `_support/ac_coverage.py`。
69
+ ac_coverage = load_module("ac_coverage")
70
+ parse_dims_cell = ac_coverage.parse_dims_cell
64
71
  # 契约 §5.5(2026-10-16,issue #R1/#R3):行内注释剥除口径与内核**同一函数对象**(I3)——
65
72
  # 剥注释口径**全域单源**(`_support/stack_profile.strip_inline_comment`,2026-10-17 D104 ⑦ 清偿):
66
73
  # `_code()` 是该口径在本文件的唯一出口,本文件内**不得**再出现 `split("#", 1)[0]`(MB8 钉守着)。
@@ -102,8 +109,8 @@ REFRESH_WORDS = ("刷新", "reload", "refresh")
102
109
  I18N_HINTS = (re.compile(r"\bt\("), re.compile(r"\b[a-z]+\.[a-z]+\.[a-z]+\b"), re.compile(r"\b[A-Z]{2,}_[A-Z_]+\b"))
103
110
 
104
111
  # 分片 step / expected 的动词词表(契约 `rings/e2e/reference.md` §5.5,2026-09-23 定)
105
- ACTION_VERBS = ("click", "fill", "select", "check", "uncheck", "press", "hover", "refresh", "goto", "waitFor", "download", "upload", "dispatch")
106
- ASSERT_VERBS = ("text", "contains", "count", "countOptions", "visible", "hidden", "enabled", "disabled", "checked", "unchecked", "value", "attr", "delta")
112
+ ACTION_VERBS = ("click", "fill", "select", "check", "uncheck", "press", "type", "hover", "refresh", "goto", "waitFor", "download", "upload", "dispatch")
113
+ ASSERT_VERBS = ("text", "contains", "count", "countOptions", "visible", "hidden", "enabled", "disabled", "checked", "unchecked", "value", "attr", "delta", "request")
107
114
  # 契约 §5.6 R9:`expected` 行可加**行首前缀**,显式声明该断言的判据形状。
108
115
  # 本校验器只负责**形态项**(前缀后是词表内的动词 / 不嵌套 / `unchanged:` 的用例有提交性动作);
109
116
  # **不判**断言是否空转,也不判初始态成不成立(那是运行期零步基线的职责,§6.0 C-new)。
@@ -264,10 +271,11 @@ def seed_models_check(root: Path, blocks: dict[str, dict]) -> tuple[str, str]:
264
271
  ("PASS", f"{len(seeds)} 条用例的 seed 与 {len(models)} 个表/模型对账通过")
265
272
 
266
273
 
267
- def upload_fixture_check(root: Path, blocks: dict[str, dict]) -> tuple[str, str]:
274
+ def upload_fixture_check(root: Path, blocks: dict[str, dict], lay=None) -> tuple[str, str]:
268
275
  """`upload` 行的 fixture 引用闭环(契约 §5.5,B201-1,2026-10-12)。
269
276
 
270
- 值 = 相对 `product/e2e/fixtures/` 的文件路径(与 §5.8 mock 同基线、同一 fixtures 树)。
277
+ 值 = 相对 `<资产根>/e2e/fixtures/` 的文件路径(资产根默认 `product/`;
278
+ 与 §5.8 mock 同基线、同一 fixtures 树)。
271
279
  判据(结构门):**值形态**(非空 / 非绝对路径 / 无 `..` 段)+ **文件存在**。无用例声明
272
280
  `upload` ⇒ PASS(空集合法,N/A 不是通过的反面——空集就是合法形态)。生成器侧生成期
273
281
  同款检查(缺 ⇒ exit 2 列明)——两侧同步,防「校验器放行、生成器认不得」的半通状态。
@@ -280,7 +288,7 @@ def upload_fixture_check(root: Path, blocks: dict[str, dict]) -> tuple[str, str]
280
288
  uploads.append((cid, parts[2].strip() if len(parts) > 2 else ""))
281
289
  if not uploads:
282
290
  return "PASS", "无用例声明 `upload`(可选动作词)"
283
- fixtures = root / "product" / "e2e" / "fixtures"
291
+ fixtures = (lay or load_layout(root)).e2e_fixtures_dir
284
292
  bad: list[str] = []
285
293
  for cid, rel in uploads:
286
294
  if not rel:
@@ -334,7 +342,12 @@ def _route_hit(p: str, page_routes) -> bool:
334
342
 
335
343
  def validate(root: Path) -> Result:
336
344
  res = Result()
337
- e2e = root / "product" / "e2e"
345
+ try:
346
+ lay = load_layout(root)
347
+ except LayoutError as exc:
348
+ res.add("项目布局配置", "FAIL", str(exc))
349
+ return res
350
+ e2e = lay.e2e_dir
338
351
  index = e2e / "e2e-index.md"
339
352
  cases = e2e / "cases"
340
353
  shard_files = sorted(cases.glob("*.md")) if cases.is_dir() else []
@@ -444,7 +457,7 @@ def validate(root: Path) -> Result:
444
457
  res.add("页面表合法", "PASS" if not page_bad2 else "FAIL",
445
458
  f"路由非/开头或重复={page_bad2[:8]}" if page_bad2 else "")
446
459
  if known is None:
447
- known = {md.stem for md in (root / "product" / "e2e" / "cases").glob("*.md")}
460
+ known = {md.stem for md in lay.e2e_cases_dir.glob("*.md")}
448
461
  res.add("页面表合法", "WARN", "索引无页面表(E1 起应有;暂按分片文件名推导页清单)")
449
462
  if known is not None and page_rows:
450
463
  miss = sorted({g["页面"] for g in grid} - known)
@@ -505,7 +518,7 @@ def validate(root: Path) -> Result:
505
518
  # 关联AC
506
519
  ac_bad = [g["用例ID"] for g in grid if not AC_RE.match(g["关联AC"])]
507
520
  res.add("关联AC 格式", "PASS" if not ac_bad else "FAIL", f"{ac_bad[:8]}")
508
- prd_files = sorted((root / "product" / "prd").glob("*-prd.md")) if (root / "product" / "prd").is_dir() else []
521
+ prd_files = sorted(lay.prd_dir.glob("*-prd.md")) if lay.prd_dir.is_dir() else []
509
522
  if not prd_files:
510
523
  res.add("关联AC 可达", "N/A", "门控态:无域级 PRD")
511
524
  else:
@@ -583,34 +596,13 @@ def validate(root: Path) -> Result:
583
596
  (detail + ";残留待裁决(清零后升 FAIL,规则在 shared/gates.md §3 AC⇔用例行)")
584
597
  if gaps else detail)
585
598
 
586
- # AC × 覆盖维度(D130,2026-10-21):
587
- # 检验已在域级 PRD 声明适用维度的 AC,其 E2E 用例是否覆盖了声明的维度(C01-C14 前端裁剪版)。
588
- # 仅对已实现域且已有用例挂靠的 AC 计算;未声明适用维度的项目按 N/A 兜底,不报假缺口。
589
- # 解析口径(评审 Minor 修正):① `\b` 对 CJK 邻接失效(「C01正常流」不命中)⇒ 改前后界定符;
590
- # ② 「C01-C03」区间须展开(否则只命中两端、中间维度被误报缺口);③ 不做「含『维度』二字即跳过」
591
- # 的粗暴表头过滤(会误杀「C13 状态维度」这类数据行)——表头/分隔行首格不是 AC 形态,天然不被外层正则命中。
592
- _DIM_TOKEN = r"C(?:0[1-9]|1[0-4])"
593
- _dim_range_re = re.compile(rf"(?<![0-9A-Za-z])({_DIM_TOKEN})\s*[-~至]\s*({_DIM_TOKEN})(?![0-9])")
594
- _dim_code_re = re.compile(rf"(?<![0-9A-Za-z]){_DIM_TOKEN}(?![0-9])")
595
- _dim_looks_re = re.compile(r"[Cc]\d{1,2}")
596
-
597
- def _parse_dims(raw) -> tuple[set[str], bool]:
598
- """覆盖维度文本 → (命中维度集合, 是否含疑似维度写法)。区间展开、中文邻接可命中。"""
599
- text = str(raw or "")
600
- dims: set[str] = set()
601
- for m in _dim_range_re.finditer(text):
602
- lo, hi = int(m.group(1)[1:]), int(m.group(2)[1:])
603
- if lo > hi:
604
- lo, hi = hi, lo
605
- dims |= {f"C{i:02d}" for i in range(lo, hi + 1)}
606
- dims |= set(_dim_code_re.findall(text))
607
- return dims, bool(_dim_looks_re.search(text))
608
-
599
+ # 解析口径归 `_support/ac_coverage.py::parse_dims_cell`(D153 单源,行为与 D130 逐字节一致):
600
+ # 区间展开、中文邻接、疑似写法出声;本文件不再自持第二份 parser。
609
601
  declared_ac_dims: dict[str, set[str]] = {}
610
602
  for p in prd_files:
611
603
  seg = p.read_text(encoding="utf-8")
612
604
  for m in re.finditer(r"^\|\s*`?(AC-[A-Z0-9]+-\d{3})`?\s*\|[^|\n]*\|[^|\n]*\|([^|\n]+)\|", seg, re.M):
613
- dims, _looks = _parse_dims(m.group(2))
605
+ dims, _looks = parse_dims_cell(m.group(2))
614
606
  if dims:
615
607
  declared_ac_dims[m.group(1)] = dims
616
608
 
@@ -621,7 +613,7 @@ def validate(root: Path) -> Result:
621
613
  acid = g["关联AC"]
622
614
  blk = blocks.get(cid, {})
623
615
  for d_str in (blk.get("覆盖维度") or []):
624
- found, looks = _parse_dims(d_str)
616
+ found, looks = parse_dims_cell(d_str)
625
617
  if not found and looks:
626
618
  bad_case_dims.append(f"{cid}:{d_str}")
627
619
  covered_ac_dims.setdefault(acid, set()).update(found)
@@ -683,6 +675,7 @@ def validate(root: Path) -> Result:
683
675
  verb_bad, assert_bad, goto_param, contradict = [], [], [], []
684
676
  goto_path: list[str] = []
685
677
  auth_bad: list[str] = []
678
+ target_bad: list[str] = [] # `靶场:` 取值不在 `e2e.targets` 闭集内(多靶场,beta.16)
686
679
  guard_bad: list[str] = []
687
680
  session_bad: list[str] = []
688
681
  switch_bad: list[str] = []
@@ -697,7 +690,7 @@ def validate(root: Path) -> Result:
697
690
  # 段缺失 / keys 为空 ⇒ 该靶场不允许任何该类预设(用例出现对应行即 FAIL)。
698
691
  # `session_preset`(2026-10-16,issue #13)与 `query_preset`(2026-10-18,`DESIGN.md` D109)
699
692
  # 同族:同为**前提面**,两个载体各一段。
700
- _prof_path = root / "product" / "stack-profile.yaml"
693
+ _prof_path = lay.stack_profile
701
694
 
702
695
  def _preset_keys(sub: str, field: str = "keys") -> set[str]:
703
696
  if not _prof_path.is_file():
@@ -714,11 +707,30 @@ def validate(root: Path) -> Result:
714
707
  _session_keys = _preset_keys("session_preset")
715
708
  _query_keys = _preset_keys("query_preset")
716
709
  _hash_query_keys = _preset_keys("query_preset", "hash_keys")
710
+
711
+ # 契约 §5.4/§5.5(多靶场,beta.16):具名靶场登记表 `e2e.targets`(可选闭集)。
712
+ # 用例 `靶场:` 名字必须 ∈ 此表;表内路由与页面表路由同属「登记地址」——
713
+ # `goto` / `守卫` 路径门同样放行(地址仍单源于 stack-profile,不开放自由路径)。
714
+ def _named_targets() -> dict[str, str]:
715
+ if not _prof_path.is_file():
716
+ return {}
717
+ try:
718
+ cfg = stack_profile.parse_profile(_prof_path.read_text(encoding="utf-8")).get("e2e") or {}
719
+ except Exception:
720
+ return {}
721
+ t = cfg.get("targets") if isinstance(cfg, dict) else None
722
+ if not isinstance(t, dict):
723
+ return {}
724
+ return {str(k).strip(): str(v or "").strip() for k, v in t.items() if str(k).strip()}
725
+
726
+ _named_targets_map = _named_targets()
727
+ _target_routes = list(_named_targets_map.values())
717
728
  nested_prefix, unchanged_no_action = [], []
718
729
  initial_no_reason = []
719
730
  after_bad = []
720
731
  waitfor_assert = []
721
732
  delta_bad = []
733
+ request_bad = []
722
734
  cross_page_bad = []
723
735
  # R10 门用反查表 page_routes_map 已在页面表解析处(上方)构建
724
736
  for g in grid:
@@ -857,7 +869,8 @@ def validate(root: Path) -> Result:
857
869
  p = parts[1].strip()
858
870
  # 契约 §5.5(2026-10-18,D109 / 2026-10-22 hash 查询):`goto` 可带常规查询与
859
871
  # hash 查询 ⇒ 路径门按**去双查询后的路径形态**匹配(`_route_hit` 内部同口径)。
860
- if _route_hit(p, page_routes):
872
+ # 契约 §5.4/§5.5(多靶场,beta.16):已登记靶场路由同属「登记地址」,同门放行。
873
+ if _route_hit(p, page_routes) or _route_hit(p, _target_routes):
861
874
  continue
862
875
  goto_path.append(f"{g['用例ID']}:{p}")
863
876
  # 契约 §5.7(2026-09-30 补):`auth` 取值白名单(preset | none)——文本期挡住,
@@ -865,6 +878,11 @@ def validate(root: Path) -> Result:
865
878
  auth = d.get("auth", "preset")
866
879
  if auth not in ("preset", "none"):
867
880
  auth_bad.append(f"{g['用例ID']}:auth={auth}")
881
+ # 契约 §5.4/§5.5(多靶场,beta.16):`靶场:` 取值闭集 —— 名字必须 ∈ `e2e.targets`;
882
+ # 整段未声明而用例钉名 ⇒ FAIL(与生成器中止双保险,不猜)。
883
+ _target_decl = d.get("靶场")
884
+ if isinstance(_target_decl, str) and _target_decl.strip() and _target_decl.strip() not in _named_targets_map:
885
+ target_bad.append(f"{g['用例ID']}:{_target_decl.strip()}")
868
886
  # 契约 §5.5(2026-10-16 补,issue #13):`会话:` 行形态 + 键白名单。
869
887
  # 与生成器中止**双保险**(文本期挡 + 生成期中止):白名单是项目级声明,
870
888
  # 防把「数据态」走私成会话参数(§5.7 禁令不破)。
@@ -898,7 +916,8 @@ def validate(root: Path) -> Result:
898
916
  # 出现绕过 `parse_block` 的带注释原始值(上游 F1 之后端到端已造不出污染值),
899
917
  # 本门也不把注释当路径。与 `goto` 门 / `会话` 门共用同一 `strip_inline_comment`。
900
918
  _guard_path = strip_inline_comment(_guard).strip()
901
- if page_routes and not _route_hit(_guard_path, page_routes):
919
+ # 契约 §5.4/§5.5(多靶场,beta.16):已登记靶场路由同属「登记地址」,同门放行。
920
+ if page_routes and not _route_hit(_guard_path, page_routes) and not _route_hit(_guard_path, _target_routes):
902
921
  guard_bad.append(f"{g['用例ID']}:{_guard_path}")
903
922
 
904
923
  # 契约 §5.6 R7(2026-09-23 补;**2026-09-25 修正为真矛盾对**):同一条用例内,对同一
@@ -919,7 +938,7 @@ def validate(root: Path) -> Result:
919
938
  for raw in raw_exp:
920
939
  e = _strip_prefix(raw)
921
940
  bits = _code(e).split(" ")
922
- if len(bits) >= 2 and bits[0] in ASSERT_VERBS:
941
+ if len(bits) >= 2 and bits[0] in ASSERT_VERBS and bits[0] != "request":
923
942
  verbs_by_slice.setdefault((_step_anchor(raw), bits[1]), set()).add(bits[0])
924
943
  for (slice_n, tid), verbs in verbs_by_slice.items():
925
944
  where = f"{g['用例ID']}:{tid}" + (f"@after:{slice_n}" if slice_n else "")
@@ -950,6 +969,26 @@ def validate(root: Path) -> Result:
950
969
  m_anchor = AFTER_RE.match(raw.lstrip())
951
970
  if m_anchor is not None or (m_anchor.group(2) if m_anchor else raw).lstrip().startswith(LINE_PREFIXES):
952
971
  delta_bad.append(f"{g['用例ID']}:叠写")
972
+ # D142:`request <api> <json-path> <expected>` 形态门。三态缺席语义、api 后缀、
973
+ # 必须有 step(动作锚点);`(空)` 因无法区分「缺失 / null」而拒绝。
974
+ for raw in raw_exp:
975
+ has_kind = raw.lstrip().startswith(LINE_PREFIXES)
976
+ code = _code(_strip_prefix(raw))
977
+ if not code.startswith("request "):
978
+ continue
979
+ mreq = re.match(r"^request\s+(\S+)\s+(\S+)\s+(.+)$", code)
980
+ if not mreq:
981
+ request_bad.append(f"{g['用例ID']}:形态(需 request <api> <path> <expected>)")
982
+ continue
983
+ api, _path, expected = mreq.groups()
984
+ if has_kind:
985
+ request_bad.append(f"{g['用例ID']}:与 initial/unchanged 叠写")
986
+ if not re.fullmatch(r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+", api):
987
+ request_bad.append(f"{g['用例ID']}:api 须为带点后缀({api})")
988
+ if expected.strip() == "(空)":
989
+ request_bad.append(f"{g['用例ID']}:(空) 三态不明,请用 (缺席)/null/\"\"")
990
+ if not step:
991
+ request_bad.append(f"{g['用例ID']}:无 step(动作锚点不存在)")
953
992
  # 契约 §5.6 R10(2026-10-05 补,P11):跨页用例终态断言必须落首列页(断言落点页)。
954
993
  # 多页用例中,未加 after: 步骤锚的 expected 断言如果在非首列页上求值,会因为目标 DOM 缺失而沦为空洞真。
955
994
  pgs = d.get("pages", [])
@@ -1070,6 +1109,8 @@ def validate(root: Path) -> Result:
1070
1109
  res.add("守卫 路径 ∈ 页面表", "PASS" if not guard_bad else "FAIL",
1071
1110
  f"页面表外路径={_format_list(guard_bad)}(契约 §5.5:守卫导航用的具体路径也不得私藏于分片)" if guard_bad else "")
1072
1111
  res.add("auth 取值合法", "PASS" if not auth_bad else "FAIL", f"白名单外(允许 preset|none)={_format_list(auth_bad)}")
1112
+ res.add("靶场 取值合法", "PASS" if not target_bad else "FAIL",
1113
+ f"不在 `e2e.targets` 闭集内 / 未登记即钉名={_format_list(target_bad)}(契约 §5.4/§5.5:具名靶场必须先在 stack-profile 登记)" if target_bad else "")
1073
1114
  res.add("会话 取值合法", "PASS" if not session_bad else "FAIL",
1074
1115
  f"形态非法 / 白名单外={_format_list(session_bad)}(契约 §5.5:键须在 `e2e.session_preset.keys` 内)" if session_bad else "")
1075
1116
  res.add("参数 声明有效", "PASS" if not inject_bad else "FAIL",
@@ -1093,6 +1134,8 @@ def validate(root: Path) -> Result:
1093
1134
  f"`waitFor` 出现在 expected(应写在 step)= {_format_list(waitfor_assert)}")
1094
1135
  res.add("delta 相对断言形态", "PASS" if not delta_bad else "FAIL",
1095
1136
  f"`delta` 值须为 `±N` 且不得与豁免通道/步骤锚叠写 = {_format_list(delta_bad)}")
1137
+ res.add("request 断言形态", "PASS" if not request_bad else "FAIL",
1138
+ f"`request` 需 api 带点后缀 + json-path + 三态值,且必须有 step = {_format_list(request_bad)}")
1096
1139
  res.add("豁免前缀不嵌套", "PASS" if not nested_prefix else "FAIL", f"{_format_list(nested_prefix)}")
1097
1140
  res.add("步骤锚合法", "PASS" if not after_bad else "FAIL", f"{_format_list(after_bad)}")
1098
1141
  res.add("跨页终态断言落首列页", "PASS" if not cross_page_bad else "FAIL", f"{_format_list(cross_page_bad)}")
@@ -1112,8 +1155,36 @@ def validate(root: Path) -> Result:
1112
1155
  _lvl, _det = seed_models_check(root, blocks)
1113
1156
  res.add("seed × models 对账", _lvl, _det)
1114
1157
  # 契约 §5.5(B201-1,2026-10-12):upload 行的 fixture 引用存在(值形态 + 文件存在)
1115
- _lvl, _det = upload_fixture_check(root, blocks)
1158
+ _lvl, _det = upload_fixture_check(root, blocks, lay)
1116
1159
  res.add("upload fixture 引用存在", _lvl, _det)
1160
+ # D143:manual.md 旧行 + `atlas-manual` 块(解析单源 `_support/manual_block.py`)
1161
+ manual_path = e2e / "manual.md"
1162
+ if not manual_path.is_file():
1163
+ res.add("manual 走查项形态", "N/A", "无 manual.md(可选增强)")
1164
+ else:
1165
+ try:
1166
+ from _support.manual_block import parse_manual
1167
+ manual_items = parse_manual(manual_path.read_text(encoding="utf-8"))
1168
+ except Exception as exc: # 坏块必须响亮失败,不得静默忽略
1169
+ res.add("manual 走查项形态", "FAIL", f"解析失败:{exc}")
1170
+ else:
1171
+ manual_bad: list[str] = []
1172
+ seen_ids: set[str] = set()
1173
+ for m in manual_items:
1174
+ mid = m.get("id") or ""
1175
+ if not mid:
1176
+ manual_bad.append("缺 ID")
1177
+ elif mid in seen_ids:
1178
+ manual_bad.append(f"{mid}:ID 重复")
1179
+ seen_ids.add(mid)
1180
+ if m.get("page") not in known:
1181
+ manual_bad.append(f"{mid}:页面不在页面表({m.get('page')})")
1182
+ steps = m.get("steps") or []
1183
+ for i, s in enumerate(steps, 1):
1184
+ if not s.get("action") or not s.get("expect"):
1185
+ manual_bad.append(f"{mid}:步骤{i} 缺操作/观察")
1186
+ res.add("manual 走查项形态", "PASS" if not manual_bad else "FAIL",
1187
+ f"{_format_list(manual_bad)}" if manual_bad else "")
1117
1188
  return res
1118
1189
 
1119
1190
 
@@ -39,6 +39,7 @@ HERE = Path(__file__).resolve().parent
39
39
  if str(HERE.parent) not in sys.path:
40
40
  sys.path.insert(0, str(HERE.parent))
41
41
  from _support.siblings import load_module # noqa: E402
42
+ from _support.layout import LayoutError, load_layout # noqa: E402
42
43
 
43
44
 
44
45
  class Result:
@@ -152,15 +153,21 @@ def mock_after_step_violations(text: str, case_block_re, list_keys: set[str]) ->
152
153
 
153
154
  def validate(root: Path) -> Result:
154
155
  res = Result()
155
- e2e_dir = root / "product" / "e2e"
156
+ try:
157
+ lay = load_layout(root)
158
+ except LayoutError as exc:
159
+ res.add("项目布局配置", "FAIL", str(exc))
160
+ return res
161
+ e2e_dir = lay.e2e_dir
156
162
  cases_dir = e2e_dir / "cases"
157
163
  index = e2e_dir / "e2e-index.md"
158
164
  if not index.is_file() or not cases_dir.is_dir():
159
- res.add("mock 门前置", "N/A", "E2E 用例资产未建(缺 product/e2e/e2e-index.md 或 cases/)")
165
+ res.add("mock 门前置", "N/A",
166
+ f"E2E 用例资产未建(缺 {lay.print_value('e2e')}/e2e-index.md 或 cases/)")
160
167
  return res
161
168
 
162
169
  gen = load_generator()
163
- prof_path = root / "product" / "stack-profile.yaml"
170
+ prof_path = lay.stack_profile
164
171
  e2e_cfg: dict = {}
165
172
  if prof_path.is_file():
166
173
  e2e_cfg = gen.parse_profile(prof_path.read_text(encoding="utf-8")).get("e2e") or {}
@@ -175,7 +182,7 @@ def validate(root: Path) -> Result:
175
182
  raw_prefix = mock_cfg.get("api_prefix")
176
183
  prefix = raw_prefix if raw_prefix is not None else ("/" if mode in ("on", "auto") else None)
177
184
 
178
- shards = gen.gather_cases(cases_dir)
185
+ shards = gen.gather_cases(cases_dir, lay.print_value("e2e_cases"))
179
186
  mock_pages = sorted(p for p, items in shards.items() if any(c.get("mock") for c in items))
180
187
  mock_case_ids = [c["id"] for items in shards.values() for c in items if c.get("mock")]
181
188
 
@@ -237,7 +244,7 @@ def validate(root: Path) -> Result:
237
244
 
238
245
  missing = sorted({r for r in refs if not _resolve_fixture(r).is_file()})
239
246
  res.add("fixture 引用存在", "PASS" if not missing else "FAIL",
240
- f"缺={missing[:8]}(引用相对 product/e2e/,须以 fixtures/ 开头)")
247
+ f"缺={missing[:8]}(引用相对 {lay.print_value('e2e')}/,须以 fixtures/ 开头)")
241
248
  if fixtures_root.is_dir():
242
249
  on_disk = sorted("fixtures/" + p.relative_to(fixtures_root).as_posix()
243
250
  for p in fixtures_root.glob("**/*.json"))