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.
- package/CHANGELOG.md +267 -0
- package/DESIGN.md +46 -3
- package/README.md +24 -14
- package/_support/__pycache__/ac_coverage.cpython-312.pyc +0 -0
- package/_support/__pycache__/case_block.cpython-312.pyc +0 -0
- package/_support/__pycache__/layout.cpython-312.pyc +0 -0
- package/_support/__pycache__/manual_block.cpython-312.pyc +0 -0
- package/_support/__pycache__/stack_profile.cpython-312.pyc +0 -0
- package/_support/ac_coverage.py +120 -0
- package/_support/case_block.py +3 -3
- package/_support/layout.py +411 -0
- package/_support/manual_block.py +67 -0
- package/_support/stack_profile.py +3 -1
- package/adapters/__pycache__/_graph.cpython-312.pyc +0 -0
- package/adapters/__pycache__/_jpa.cpython-312.pyc +0 -0
- package/adapters/__pycache__/_lib.cpython-312.pyc +0 -0
- package/adapters/__pycache__/api.cpython-312.pyc +0 -0
- package/adapters/__pycache__/models.cpython-312.pyc +0 -0
- package/adapters/__pycache__/tests.cpython-312.pyc +0 -0
- package/adapters/_graph.py +7 -2
- package/adapters/_jpa.py +291 -0
- package/adapters/_lib.py +8 -3
- package/adapters/ac/__init__.py +80 -0
- package/adapters/ac/__pycache__/__init__.cpython-312.pyc +0 -0
- package/adapters/ac/__pycache__/command.cpython-312.pyc +0 -0
- package/adapters/ac/__pycache__/js.cpython-312.pyc +0 -0
- package/adapters/ac/__pycache__/junit.cpython-312.pyc +0 -0
- package/adapters/ac/__pycache__/pytest.cpython-312.pyc +0 -0
- package/adapters/ac/command.py +88 -0
- package/adapters/ac/js.py +334 -0
- package/adapters/ac/junit.py +266 -0
- package/adapters/ac/pytest.py +380 -0
- package/adapters/api.py +331 -8
- package/adapters/models.py +126 -2
- package/adapters/routes.py +7 -3
- package/adapters/tests.py +43 -5
- package/apply/reference.md +6 -3
- package/bin/atlas.js +66 -28
- package/extensions/pi.ts +39 -8
- package/install.sh +179 -19
- package/package.json +1 -1
- package/patches/project-wiring/README.md +4 -3
- package/patches/project-wiring/__pycache__/apply-wiring.cpython-312.pyc +0 -0
- package/patches/project-wiring/agent-pointer.md +1 -1
- package/patches/project-wiring/apply-wiring.py +146 -153
- package/patches/workflow-plan-apply/1.6-plan-apply.insert.md +1 -1
- package/rings/e2e/reference.md +37 -20
- package/rings/prd/reference.md +26 -8
- package/rings/structure/reference.md +3 -2
- package/scripts/__pycache__/atlas_check.cpython-312.pyc +0 -0
- package/scripts/__pycache__/atlas_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/atlas_verify.cpython-312.pyc +0 -0
- package/scripts/__pycache__/closeout_derive.cpython-312.pyc +0 -0
- package/scripts/__pycache__/e2e_console.cpython-312.pyc +0 -0
- package/scripts/__pycache__/gen_e2e_scripts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/gen_testid_stub.cpython-312.pyc +0 -0
- package/scripts/__pycache__/make_review_inputs.cpython-312.pyc +0 -0
- package/scripts/__pycache__/pkg_digest.cpython-312.pyc +0 -0
- package/scripts/__pycache__/refresh_structure.cpython-312.pyc +0 -0
- package/scripts/__pycache__/release.cpython-312.pyc +0 -0
- package/scripts/__pycache__/write_claim.cpython-312.pyc +0 -0
- package/scripts/atlas_check.py +329 -86
- package/scripts/atlas_report.py +10 -2
- package/scripts/atlas_verify.py +435 -0
- package/scripts/closeout_derive.py +296 -0
- package/scripts/e2e_console.py +46 -31
- package/scripts/gen_e2e_scripts.py +424 -66
- package/scripts/gen_testid_stub.py +8 -3
- package/scripts/make_review_inputs.py +20 -6
- package/scripts/pkg_digest.py +42 -5
- package/scripts/refresh_structure.py +29 -8
- package/scripts/release.py +238 -7
- package/scripts/write_claim.py +204 -0
- package/shared/closeout.md +7 -0
- package/shared/gates.md +24 -19
- package/shared/global-rules.md +8 -1
- package/shared/independent-review.md +4 -3
- package/shared/layout.md +28 -3
- package/shared/single-source.md +2 -0
- package/shared/stack-profile.md +19 -10
- package/skills/atlas-apply/SKILL.md +1 -0
- package/skills/atlas-design-review/SKILL.md +3 -3
- package/skills/atlas-e2e/SKILL.md +1 -0
- package/skills/atlas-prd/SKILL.md +1 -0
- package/skills/atlas-release/SKILL.md +9 -3
- package/skills/atlas-structure/SKILL.md +1 -0
- package/skills/frontend-design/SKILL.md +2 -2
- package/skills/frontend-design/references/tuning-verbs.md +1 -1
- package/templates/e2e/case-shard.md +1 -1
- package/templates/e2e/console/app.js +25 -4
- package/templates/e2e/console/console.css +2 -0
- package/templates/e2e/console/index.html +2 -2
- package/templates/prd/domain.md +14 -12
- package/templates/prd/global.md +2 -2
- package/templates/stack-profile.yaml +12 -11
- package/tests/__pycache__/test_ac_adapters.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_adapter_api.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_adapter_models.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_apply_wiring.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_atlas_check.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_atlas_verify.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_atlas_verify_playground.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_canonical_consistency.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_closeout_derive.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_duty_bug_families.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_e2e_console.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_gen_e2e_scripts.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_gen_e2e_scripts.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_install_selfcheck.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_layout.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_map_anchors.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_no_framework_names.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_project_wiring.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_refresh_facts.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_shared_kernel.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_tools.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_tools.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_validate_ac_alignment.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_validate_adr.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_validate_walkthrough.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_validators.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_write_claim.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/fixtures/e2e_coverage_dims_golden.json +12 -0
- package/tests/fixtures/verify_playground/product/e2e/cases/home.md +40 -0
- package/tests/fixtures/verify_playground/product/e2e/e2e-index.md +18 -0
- package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/README.md +3 -0
- package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/review.json +17 -0
- package/tests/fixtures/verify_playground/product/e2e/scripts/atlas-prepare.mjs +4 -0
- package/tests/fixtures/verify_playground/product/prd/README.md +5 -0
- package/tests/fixtures/verify_playground/product/prd/home-prd.md +62 -0
- package/tests/fixtures/verify_playground/product/prd/prd.md +52 -0
- package/tests/fixtures/verify_playground/product/stack-profile.yaml.tpl +18 -0
- package/tests/fixtures/verify_playground/site/index.html +6 -0
- package/tests/test_ac_adapters.py +312 -0
- package/tests/test_adapter_api.py +253 -0
- package/tests/test_adapter_models.py +236 -0
- package/tests/test_apply_wiring.py +136 -23
- package/tests/test_atlas_check.py +371 -0
- package/tests/test_atlas_verify.py +335 -0
- package/tests/test_atlas_verify_playground.py +162 -0
- package/tests/test_canonical_consistency.py +5 -2
- package/tests/test_closeout_derive.py +264 -0
- package/tests/test_duty_bug_families.py +355 -3
- package/tests/test_e2e_console.py +49 -0
- package/tests/test_gen_e2e_scripts.py +650 -17
- package/tests/test_install_selfcheck.py +76 -0
- package/tests/test_layout.py +252 -0
- package/tests/test_map_anchors.py +475 -0
- package/tests/test_no_framework_names.py +86 -0
- package/tests/test_project_wiring.py +12 -4
- package/tests/test_refresh_facts.py +18 -3
- package/tests/test_shared_kernel.py +50 -0
- package/tests/test_tools.py +257 -1
- package/tests/test_validate_ac_alignment.py +1081 -0
- package/tests/test_validate_adr.py +24 -0
- package/tests/test_validate_walkthrough.py +313 -0
- package/tests/test_validators.py +499 -1
- package/tests/test_write_claim.py +236 -0
- package/validators/__pycache__/validate_ac_alignment.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_adr.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_design_exemptions.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_e2e_index.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_e2e_mocks.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_prd.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_stack_profile.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_structure.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_testids.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_walkthrough.cpython-312.pyc +0 -0
- package/validators/validate_ac_alignment.py +439 -183
- package/validators/validate_adr.py +15 -5
- package/validators/validate_design_exemptions.py +15 -6
- package/validators/validate_e2e_index.py +109 -38
- package/validators/validate_e2e_mocks.py +12 -5
- package/validators/validate_prd.py +109 -14
- package/validators/validate_stack_profile.py +62 -8
- package/validators/validate_structure.py +9 -2
- package/validators/validate_testids.py +36 -23
- package/validators/validate_walkthrough.py +300 -0
package/_support/case_block.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/adapters/_graph.py
CHANGED
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
未安装 / 不可达时 `detect()` 返回 None,由调用方决定响亮降级(适配器显式 WARN +
|
|
29
29
|
回退正则;报告器显式 not_ready),本层绝不静默。
|
|
30
30
|
|
|
31
|
-
|
|
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 =
|
|
114
|
+
p = load_layout(root).stack_profile
|
|
110
115
|
if not p.is_file():
|
|
111
116
|
return None
|
|
112
117
|
found: dict | None = None
|