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
@@ -51,14 +51,14 @@ strip_inline_comment = load_module("stack_profile").strip_inline_comment
51
51
  _BLOCK_KEYS = ("pages", "precondition", "seed", "mock", "step", "expected", "testid", "落点", "会话", "参数", "hash参数", "覆盖维度")
52
52
 
53
53
 
54
- def parse_block(body: str) -> dict:
55
- """解析 `atlas-case` 块体 → dict(键见 `_BLOCK_KEYS`)。
54
+ def parse_block(body: str, extra_keys: tuple[str, ...] = ()) -> dict:
55
+ """解析 `atlas-case` 块体 → dict(键见 `_BLOCK_KEYS`;`extra_keys` 供人工块复用同一行式解析)。
56
56
 
57
57
  语义(与原两处实现逐字一致;**唯一例外** = 键空间外的标量键剥行内注释,见模块 docstring):
58
58
  `- ` 开头的行归当前键;`<key>:` 开新键(键在键空间内);`key: value` 带值形态追加一条;
59
59
  键空间外的 `key: value` 以标量入表(**标量值剥行内 `#` 注释**)。
60
60
  """
61
- d: dict = {k: [] for k in _BLOCK_KEYS}
61
+ d: dict = {k: [] for k in _BLOCK_KEYS + tuple(extra_keys)}
62
62
  key = None
63
63
  for line in body.splitlines():
64
64
  s = line.strip()
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env python3
2
+ """atlas 共享内核 —— 项目级资产根布局解析(RFC-20261026-01)。
3
+
4
+ 守一件事:项目级资产根(默认 `product/`)与 ADR 根(默认 `<assets_root>/adr`)
5
+ 由项目根 `atlas.config.yaml` 单一声明;所有消费者经本模块解析,禁止各自写死。
6
+
7
+ - 文件缺失 ⇒ 默认布局(存量项目零变化)。
8
+ - 文件存在 ⇒ 严格校验:`version: 1`、只允许 `layout.assets_root` / `layout.adr`、
9
+ 重复键 / 未知键 / 非字符串 / 路径逃逸 / symlink 逃逸 ⇒ `LayoutError`(响亮,不回落)。
10
+ - 路径统一项目根相对 POSIX;对外返回绝对 `Path`。
11
+ - 纯标准库;YAML 子集复用 `_support/stack_profile.py` 的 `strip_inline_comment` /
12
+ `yaml_scalar`,不新造第二套方言。
13
+
14
+ CLI:
15
+ python3 _support/layout.py --root <项目根> --print <assets_root|adr|prd|...>
16
+ python3 _support/layout.py --root <项目根> --print-abs <key>
17
+ python3 _support/layout.py --root <项目根> --validate
18
+ python3 _support/layout.py --root <项目根> --validate-value <assets_root|adr> <值>
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import importlib.util
24
+ import re
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ CONFIG_NAME = "atlas.config.yaml"
30
+ DEFAULT_ASSETS_ROOT = Path("product")
31
+ _ALLOWED_TOP = frozenset({"version", "layout"})
32
+ _ALLOWED_LAYOUT = frozenset({"assets_root", "adr"})
33
+ _FORBIDDEN_FIRST_PARTS = frozenset({".atlas", ".trellis"})
34
+
35
+
36
+ class LayoutError(ValueError):
37
+ """布局配置非法(缺失字段 / 未知键 / 路径逃逸 / 文件与目录形态不符)。"""
38
+
39
+
40
+ def _load_stack_profile():
41
+ """按文件路径加载兄弟模块,兼容源包态、装配态与 load_module 独立加载。"""
42
+ key = "_atlas_layout_stack_profile"
43
+ if key in sys.modules:
44
+ return sys.modules[key]
45
+ path = Path(__file__).with_name("stack_profile.py")
46
+ spec = importlib.util.spec_from_file_location(key, path)
47
+ if spec is None or spec.loader is None:
48
+ raise RuntimeError(f"无法加载 {path}")
49
+ mod = importlib.util.module_from_spec(spec)
50
+ sys.modules[key] = mod
51
+ spec.loader.exec_module(mod)
52
+ return mod
53
+
54
+
55
+ _SP = _load_stack_profile()
56
+ strip_inline_comment = _SP.strip_inline_comment
57
+ yaml_scalar = _SP.yaml_scalar
58
+ parse_profile = _SP.parse_profile
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Layout:
63
+ root: Path
64
+ assets_root: Path
65
+ adr_dir: Path
66
+
67
+ def _rel(self, path: Path) -> str:
68
+ return path.relative_to(self.root).as_posix()
69
+
70
+ @property
71
+ def stack_profile(self) -> Path:
72
+ return self.assets_root / "stack-profile.yaml"
73
+
74
+ @property
75
+ def design(self) -> Path:
76
+ return self.assets_root / "design.md"
77
+
78
+ @property
79
+ def prd_dir(self) -> Path:
80
+ return self.assets_root / "prd"
81
+
82
+ @property
83
+ def prd_readme(self) -> Path:
84
+ return self.prd_dir / "README.md"
85
+
86
+ @property
87
+ def prd_global(self) -> Path:
88
+ return self.prd_dir / "prd.md"
89
+
90
+ @property
91
+ def e2e_dir(self) -> Path:
92
+ return self.assets_root / "e2e"
93
+
94
+ @property
95
+ def e2e_index(self) -> Path:
96
+ return self.e2e_dir / "e2e-index.md"
97
+
98
+ @property
99
+ def e2e_manual(self) -> Path:
100
+ return self.e2e_dir / "manual.md"
101
+
102
+ @property
103
+ def e2e_cases_dir(self) -> Path:
104
+ return self.e2e_dir / "cases"
105
+
106
+ @property
107
+ def e2e_scripts_dir(self) -> Path:
108
+ return self.e2e_dir / "scripts"
109
+
110
+ @property
111
+ def e2e_fixtures_dir(self) -> Path:
112
+ return self.e2e_dir / "fixtures"
113
+
114
+ @property
115
+ def e2e_reviews_dir(self) -> Path:
116
+ return self.e2e_dir / "reviews"
117
+
118
+ @property
119
+ def e2e_run_dir(self) -> Path:
120
+ return self.e2e_scripts_dir / ".atlas-run"
121
+
122
+ @property
123
+ def e2e_auth_dir(self) -> Path:
124
+ return self.e2e_scripts_dir / ".atlas-auth"
125
+
126
+ @property
127
+ def console_dir(self) -> Path:
128
+ return self.e2e_reviews_dir / "console"
129
+
130
+ @property
131
+ def console_ledger(self) -> Path:
132
+ return self.console_dir / "events.jsonl"
133
+
134
+ @property
135
+ def reviews_dir(self) -> Path:
136
+ return self.assets_root / "reviews"
137
+
138
+ @property
139
+ def reports_dir(self) -> Path:
140
+ return self.assets_root / "atlas-reports"
141
+
142
+ def path_for(self, key: str) -> Path:
143
+ mapping = {
144
+ "assets_root": self.assets_root,
145
+ "adr": self.adr_dir,
146
+ "prd": self.prd_dir,
147
+ "e2e": self.e2e_dir,
148
+ "design": self.design,
149
+ "stack_profile": self.stack_profile,
150
+ "reviews": self.reviews_dir,
151
+ "reports": self.reports_dir,
152
+ "e2e_index": self.e2e_index,
153
+ "e2e_manual": self.e2e_manual,
154
+ "e2e_cases": self.e2e_cases_dir,
155
+ "e2e_scripts": self.e2e_scripts_dir,
156
+ "e2e_fixtures": self.e2e_fixtures_dir,
157
+ "e2e_reviews": self.e2e_reviews_dir,
158
+ "console_ledger": self.console_ledger,
159
+ }
160
+ if key not in mapping:
161
+ allowed = ", ".join(sorted(mapping))
162
+ raise LayoutError(f"未知输出键 {key!r}(允许:{allowed})")
163
+ return mapping[key]
164
+
165
+ def print_value(self, key: str) -> str:
166
+ return self._rel(self.path_for(key))
167
+
168
+ def warnings(self) -> list[dict[str, str]]:
169
+ """配置与现场不一致的提示(首版 WARN,不阻断)。"""
170
+ out: list[dict[str, str]] = []
171
+ default_root = self.root / DEFAULT_ASSETS_ROOT
172
+ if self.assets_root != default_root and (default_root / "stack-profile.yaml").is_file():
173
+ out.append({
174
+ "code": "double_root",
175
+ "detail": (
176
+ f"默认 {DEFAULT_ASSETS_ROOT.as_posix()}/stack-profile.yaml 与配置根 "
177
+ f"{self._rel(self.assets_root)} 同时存在资产;确认是否双根,"
178
+ "改根需手工迁移,不自动搬移"),
179
+ })
180
+ default_adr = self.assets_root / "adr"
181
+ if self.adr_dir != default_adr and default_adr.is_dir():
182
+ out.append({
183
+ "code": "adr_double_root",
184
+ "detail": (
185
+ f"{self._rel(default_adr)}/ 与配置 ADR 根 {self._rel(self.adr_dir)} "
186
+ "同时存在;确认是否 ADR 双根"),
187
+ })
188
+ if (self.assets_root != default_root and not self.assets_root.is_dir()
189
+ and default_root.is_dir()):
190
+ out.append({
191
+ "code": "configured_root_missing",
192
+ "detail": (
193
+ f"配置资产根 {self._rel(self.assets_root)} 不存在,但默认 "
194
+ f"{DEFAULT_ASSETS_ROOT.as_posix()}/ 有资产;疑似配置错误"),
195
+ })
196
+ if (self.assets_root != default_root and self.e2e_dir.is_dir()
197
+ and self.stack_profile.is_file()):
198
+ try:
199
+ prof = parse_profile(self.stack_profile.read_text(encoding="utf-8"))
200
+ except (OSError, UnicodeDecodeError):
201
+ return out
202
+ e2e = prof.get("e2e") or {}
203
+ keys = ("cases_dir", "index", "scripts_dir")
204
+ values = [e2e.get(k) for k in keys]
205
+ if all(isinstance(v, str) and (v == "product" or v.startswith("product/"))
206
+ for v in values):
207
+ out.append({
208
+ "code": "profile_path_drift",
209
+ "detail": (
210
+ f"{self._rel(self.stack_profile)} 的 e2e 三路径仍指向 product/,"
211
+ f"但配置资产根为 {self._rel(self.assets_root)};疑似模板未渲染 / 路径漂移"),
212
+ })
213
+ return out
214
+
215
+
216
+ def _parse_config(text: str) -> dict:
217
+ top: dict[str, object] = {}
218
+ layout: dict[str, str] = {}
219
+ seen_top: set[str] = set()
220
+ seen_layout: set[str] = set()
221
+ section: str | None = None
222
+ saw_layout = False
223
+ saw_version = False
224
+
225
+ for raw in text.splitlines():
226
+ line = strip_inline_comment(raw.rstrip())
227
+ if not line.strip():
228
+ continue
229
+ indent = len(line) - len(line.lstrip(" "))
230
+ if "\t" in line[:indent]:
231
+ raise LayoutError(f"缩进不得使用 Tab:{raw!r}")
232
+ s = line.strip()
233
+ if indent == 0:
234
+ section = None
235
+ key, sep, value = s.partition(":")
236
+ if not sep:
237
+ raise LayoutError(f"无法解析行(缺冒号):{raw!r}")
238
+ key = key.strip()
239
+ if key in seen_top:
240
+ raise LayoutError(f"重复顶层键:{key}")
241
+ seen_top.add(key)
242
+ if key not in _ALLOWED_TOP:
243
+ raise LayoutError(f"未知顶层键:{key}(只允许 version / layout)")
244
+ if key == "version":
245
+ if value.strip():
246
+ version = yaml_scalar(value)
247
+ if str(version) != "1":
248
+ raise LayoutError(f"version 只支持 1,收到 {version!r}")
249
+ top["version"] = 1
250
+ saw_version = True
251
+ else:
252
+ raise LayoutError("version 必须是标量 1")
253
+ continue
254
+ if value.strip():
255
+ raise LayoutError("layout 必须是块(`layout:` 后换行)")
256
+ section = "layout"
257
+ saw_layout = True
258
+ continue
259
+
260
+ if section != "layout":
261
+ raise LayoutError(f"缩进行不在 layout 段下:{raw!r}")
262
+ key, sep, value = s.partition(":")
263
+ if not sep:
264
+ raise LayoutError(f"无法解析行(缺冒号):{raw!r}")
265
+ key = key.strip()
266
+ if key in seen_layout:
267
+ raise LayoutError(f"重复 layout 键:{key}")
268
+ seen_layout.add(key)
269
+ if key not in _ALLOWED_LAYOUT:
270
+ raise LayoutError(f"未知 layout 键:{key}(只允许 assets_root / adr)")
271
+ val = yaml_scalar(value)
272
+ if not isinstance(val, str) or not val.strip():
273
+ raise LayoutError(f"layout.{key} 必须是非空字符串")
274
+ layout[key] = val.strip()
275
+
276
+ if not saw_version:
277
+ raise LayoutError("缺少 version: 1")
278
+ if not saw_layout:
279
+ raise LayoutError("缺少 layout: 段")
280
+ if "assets_root" not in layout:
281
+ raise LayoutError("缺少 layout.assets_root")
282
+ return {"version": top.get("version"), "layout": layout}
283
+
284
+
285
+ def _validate_rel_path(root: Path, key: str, value: str) -> Path:
286
+ raw = value.strip()
287
+ if not raw:
288
+ raise LayoutError(f"layout.{key} 不能为空")
289
+ if "\\" in raw:
290
+ raise LayoutError(f"layout.{key} 使用 POSIX '/' 分隔符,收到 {raw!r}")
291
+ if any(ch in raw for ch in ('"', "'", "#", "\n", "\r")):
292
+ raise LayoutError(f"layout.{key} 不得包含引号 / '#' / 换行:{raw!r}")
293
+ if re.match(r"^[A-Za-z]:", raw) or raw.startswith("/"):
294
+ raise LayoutError(f"layout.{key} 必须是项目相对路径,不能是绝对路径:{raw!r}")
295
+ parts = raw.split("/")
296
+ if any(part in ("", ".", "..") for part in parts):
297
+ raise LayoutError(f"layout.{key} 不得包含空段、'.' 或 '..':{raw!r}")
298
+ if parts[0].casefold() in _FORBIDDEN_FIRST_PARTS:
299
+ raise LayoutError(f"layout.{key} 不得落在 {parts[0]}/:{raw!r}")
300
+
301
+ root_resolved = root.resolve()
302
+ candidate = (root_resolved / Path(*parts)).resolve()
303
+ try:
304
+ candidate.relative_to(root_resolved)
305
+ except ValueError as exc:
306
+ raise LayoutError(f"layout.{key} 解析后逃出项目根:{raw!r} -> {candidate}") from exc
307
+ if candidate.exists() and not candidate.is_dir():
308
+ raise LayoutError(f"layout.{key} 已存在但不是目录:{raw!r}")
309
+ return root / Path(*parts)
310
+
311
+
312
+ def validate_value(root: Path, key: str, value: str) -> Path:
313
+ if key not in _ALLOWED_LAYOUT:
314
+ raise LayoutError(f"未知 layout 键:{key!r}(只允许 assets_root / adr)")
315
+ return _validate_rel_path(Path(root).absolute(), key, value)
316
+
317
+
318
+ def write_config(root: Path, assets_root: str, adr: str | None = None,
319
+ overwrite: bool = False) -> Path:
320
+ """原子写 `atlas.config.yaml`;已存在且未显式 overwrite ⇒ LayoutError。"""
321
+ root = Path(root).absolute()
322
+ config = root / CONFIG_NAME
323
+ if config.exists() and not overwrite:
324
+ raise LayoutError(f"布局配置已存在,拒绝覆盖:{config}")
325
+ _validate_rel_path(root, "assets_root", assets_root)
326
+ if adr is not None:
327
+ _validate_rel_path(root, "adr", adr)
328
+ lines = ["version: 1", "layout:", f" assets_root: {assets_root.strip()}"]
329
+ if adr is not None:
330
+ lines.append(f" adr: {adr.strip()}")
331
+ tmp = config.parent / (config.name + ".tmp")
332
+ tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
333
+ tmp.replace(config)
334
+ return config
335
+
336
+
337
+ def load_layout(root: Path, config_path: Path | None = None) -> Layout:
338
+ root = Path(root).absolute()
339
+ config = Path(config_path) if config_path is not None else root / CONFIG_NAME
340
+ if not config.is_file():
341
+ if config_path is not None:
342
+ raise LayoutError(f"指定的布局配置不存在:{config}")
343
+ assets_root = root / DEFAULT_ASSETS_ROOT
344
+ return Layout(root=root, assets_root=assets_root, adr_dir=assets_root / "adr")
345
+ try:
346
+ text = config.read_text(encoding="utf-8")
347
+ except (OSError, UnicodeDecodeError) as exc:
348
+ raise LayoutError(f"无法读取布局配置 {config}:{exc}") from exc
349
+ data = _parse_config(text)
350
+ assets_root = _validate_rel_path(root, "assets_root", data["layout"]["assets_root"])
351
+ adr_raw = data["layout"].get("adr")
352
+ if adr_raw is not None:
353
+ adr_dir = _validate_rel_path(root, "adr", adr_raw)
354
+ else:
355
+ adr_dir = assets_root / "adr"
356
+ if adr_dir.exists() and not adr_dir.is_dir():
357
+ raise LayoutError(
358
+ f"layout.adr 默认路径已存在但不是目录:{adr_dir.relative_to(root).as_posix()}")
359
+ return Layout(root=root, assets_root=assets_root, adr_dir=adr_dir)
360
+
361
+
362
+ def _main(argv: list[str] | None = None) -> int:
363
+ parser = argparse.ArgumentParser(description="atlas 项目级资产根布局解析")
364
+ parser.add_argument("--root", type=Path, default=Path("."), help="项目根目录")
365
+ group = parser.add_mutually_exclusive_group(required=True)
366
+ group.add_argument("--print", dest="print_key", metavar="KEY",
367
+ help="输出项目相对路径(assets_root / adr / prd / e2e / ...)")
368
+ group.add_argument("--print-abs", dest="print_abs_key", metavar="KEY",
369
+ help="输出绝对路径")
370
+ group.add_argument("--validate", action="store_true", help="校验配置;非法 exit 2")
371
+ group.add_argument("--validate-value", nargs=2, metavar=("KEY", "VALUE"),
372
+ help="校验单个 layout 值(init 提示用)")
373
+ group.add_argument("--write", action="store_true",
374
+ help="写 atlas.config.yaml(需 --assets-root,可选 --adr)")
375
+ parser.add_argument("--assets-root", dest="write_assets_root", metavar="DIR",
376
+ help="--write 的资产根(项目相对)")
377
+ parser.add_argument("--adr", dest="write_adr", metavar="DIR",
378
+ help="--write 的可选 ADR 根(项目相对)")
379
+ args = parser.parse_args(argv)
380
+
381
+ try:
382
+ if args.write:
383
+ if not args.write_assets_root:
384
+ raise LayoutError("--write 需要 --assets-root")
385
+ write_config(args.root, args.write_assets_root, args.write_adr)
386
+ print("ok")
387
+ return 0
388
+ if args.validate_value:
389
+ key, value = args.validate_value
390
+ validate_value(args.root, key, value)
391
+ print("ok")
392
+ return 0
393
+ layout = load_layout(args.root)
394
+ if args.validate:
395
+ for warning in layout.warnings():
396
+ print(f"WARN [layout] {warning['code']}: {warning['detail']}", file=sys.stderr)
397
+ print("ok")
398
+ return 0
399
+ key = args.print_key or args.print_abs_key
400
+ if args.print_abs_key:
401
+ print(layout.path_for(key))
402
+ else:
403
+ print(layout.print_value(key))
404
+ return 0
405
+ except LayoutError as exc:
406
+ print(f"ERROR: {exc}", file=sys.stderr)
407
+ return 2
408
+
409
+
410
+ if __name__ == "__main__":
411
+ sys.exit(_main())
@@ -0,0 +1,67 @@
1
+ """手工走查清单解析(D143)——旧单行格式 + 可选 `atlas-manual` 块,共用同一行式解析。
2
+
3
+ 旧行:`- <ID> | <页面> | <标题> | <操作说明>`。
4
+ 新块:`## <ID> <标题>` 后接 ```` ```atlas-manual ````;id/title 只从标题行取,块里只写
5
+ `page / precondition / step / evidence`。`step` 每行 `操作 | 观察 | 命令(可空)`,复用
6
+ `_support/case_block.parse_block` 的扁平数组语义,不引入第二套嵌套语法。
7
+
8
+ 纯标准库;控制台(e2e_console.py)与校验器共用本模块,避免两份解析漂移。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from _support.case_block import parse_block
15
+
16
+ _HEADING = re.compile(r"^##\s+(MANUAL-[A-Za-z0-9-]+)\s+(.+?)\s*$")
17
+ _FENCE = "```atlas-manual"
18
+
19
+
20
+ def parse_manual(text: str) -> list[dict]:
21
+ items: list[dict] = []
22
+ heading: tuple[str, str] | None = None
23
+ lines = text.splitlines()
24
+ i = 0
25
+ while i < len(lines):
26
+ line = lines[i]
27
+ m = _HEADING.match(line)
28
+ if m:
29
+ heading = (m.group(1), m.group(2).strip())
30
+ if line.strip().startswith(_FENCE):
31
+ body: list[str] = []
32
+ i += 1
33
+ while i < len(lines) and not lines[i].strip().startswith("```"):
34
+ body.append(lines[i])
35
+ i += 1
36
+ parsed = parse_block("\n".join(body), ("page", "evidence"))
37
+ if "id" in parsed or "title" in parsed:
38
+ raise ValueError("atlas-manual 块的 id/title 由 ## 标题行提供,块内不得再写")
39
+ page = (parsed.get("page") or [""])[0].strip() if parsed.get("page") else ""
40
+ steps: list[dict] = []
41
+ for raw in parsed.get("step") or []:
42
+ parts = [p.strip() for p in raw.split("|", 2)]
43
+ steps.append({
44
+ "action": parts[0] if parts else "",
45
+ "expect": parts[1] if len(parts) > 1 else "",
46
+ "command": parts[2] if len(parts) > 2 else "",
47
+ })
48
+ id_, title = heading or ("", "")
49
+ items.append({
50
+ "id": id_, "page": page, "title": title,
51
+ "note": f"结构化走查({len(steps)} 步)" if steps else "",
52
+ "precondition": list(parsed.get("precondition") or []),
53
+ "steps": steps,
54
+ "evidence": list(parsed.get("evidence") or []),
55
+ })
56
+ i += 1
57
+ continue
58
+ s = line.strip()
59
+ if s.startswith("- "):
60
+ parts = [x.strip() for x in s[2:].split("|")]
61
+ if len(parts) >= 4:
62
+ items.append({
63
+ "id": parts[0], "page": parts[1], "title": parts[2],
64
+ "note": " | ".join(parts[3:]),
65
+ })
66
+ i += 1
67
+ return items
@@ -26,7 +26,9 @@ _DICT_SECTIONS = ("graph", "backend_test")
26
26
  # `e2e` 下「缩进 2 且值为空」开子块的键(§2 e2e 表;`app_login` / `seed` / `mock` / `review`)
27
27
  # `query_preset`:宿主注入型 **URL 参数**白名单(2026-10-18,契约 §5.5 / `DESIGN.md` D109),
28
28
  # 与 `session_preset`(会话载体)对称:同为「前提面」而非「数据态面」。
29
- _E2E_SUBBLOCK_KEYS = ("app_login", "seed", "mock", "review", "session_preset", "query_preset")
29
+ # `targets`(2026-10-27,多靶场):具名靶场登记表(`名: 路由` 映射,闭集);值非空的映射子块,
30
+ # 走同一「缩进 2 开子块」通道(`cur = "targets"`),更深缩进的 `名: 路由` 逐条入子 dict。
31
+ _E2E_SUBBLOCK_KEYS = ("app_login", "seed", "mock", "review", "session_preset", "query_preset", "targets")
30
32
 
31
33
 
32
34
  def strip_inline_comment(line: str) -> str:
@@ -28,7 +28,8 @@
28
28
  未安装 / 不可达时 `detect()` 返回 None,由调用方决定响亮降级(适配器显式 WARN +
29
29
  回退正则;报告器显式 not_ready),本层绝不静默。
30
30
 
31
- 声明:`product/stack-profile.yaml` 的可选 `graph` 段(契约见 `shared/stack-profile.md` §2):
31
+ 声明:资产根/stack-profile.yaml 的可选 `graph` 段(资产根默认 `product/`;
32
+ 契约见 `shared/stack-profile.md` §2):
32
33
 
33
34
  graph:
34
35
  backend: cgc # 枚举见 GRAPH_BACKENDS;缺省整个段 = 不用图谱
@@ -47,6 +48,10 @@ import subprocess
47
48
  import sys
48
49
  from pathlib import Path
49
50
 
51
+ if str(Path(__file__).resolve().parents[1]) not in sys.path:
52
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
53
+ from _support.layout import load_layout # noqa: E402
54
+
50
55
  ADAPTER = "graph"
51
56
 
52
57
  # backend 词表(shared/stack-profile.md §2 同源;出厂测试守恒)
@@ -106,7 +111,7 @@ def load_graph_config(root: Path) -> dict | None:
106
111
 
107
112
  graph 段不要求是文件末段:遇下一个顶层段即结束收集,已收集内容保留
108
113
  (2026-10-05 实测咬出:早版在段尾重置时把已收集的配置一并丢弃)。"""
109
- p = Path(root) / "product" / "stack-profile.yaml"
114
+ p = load_layout(root).stack_profile
110
115
  if not p.is_file():
111
116
  return None
112
117
  found: dict | None = None