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
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
|
|
4
4
|
依据 B209-1 / 3507 BT 决策(2026-10-12):
|
|
5
5
|
* **无主 AC 拦截(Dangling ACs)**:测试中挂载的 AC ID 必须在现有 PRD 资产中存在(无主即 FAIL)。
|
|
6
|
-
* **无效跳过拦截(Unwaived Skip/Xfail)**:挂载了 AC
|
|
7
|
-
|
|
6
|
+
* **无效跳过拦截(Unwaived Skip/Xfail)**:挂载了 AC 的测试若被标记为跳过,必须附带显式豁免说明
|
|
7
|
+
(`reason` 包含 `waiver:` 或 docstring 声明 `waiver:`),否则 FAIL。
|
|
8
8
|
* **双向对账与覆盖清单(Alignment Coverage)**:
|
|
9
9
|
提取各需求 PRD 的 AC 集合与测试代码映射。若配置为 enforce 模式,检查对应后端 AC 挂载覆盖度。
|
|
10
10
|
* **差量防退化(Delta Ratchet)**:
|
|
11
11
|
与上一次执行留存的基线快照比对,挂载 AC 的测试用例数与覆盖的 AC 数净增不得为负。
|
|
12
|
+
* **AC × 覆盖维度(后端通道,D153)**:
|
|
13
|
+
域级 PRD 验收表可选列 `后端适用覆盖维度` ⇔ 测试记录的 `dims`;
|
|
14
|
+
缺维度 / 仅弱证据 / 零测试 / 未知 / 孤儿 ⇒ WARN(不 FAIL)。第 4 列仍归 E2E 门(D130)。
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
**语言中立架构**:本文件不识别任何具体测试框架,也不识别测试文件命名约定;
|
|
17
|
+
框架知识只在 `adapters/ac/` 的提取器内。本文件只消费归一化记录,并按项目形态决定门是否适用。
|
|
18
|
+
|
|
19
|
+
判定:`PASS`/`WARN`/`N/A` → 退出码 0;`FAIL` → 1。
|
|
16
20
|
|
|
17
21
|
用法:
|
|
18
22
|
python3 validate_ac_alignment.py --root <项目根> [--json]
|
|
@@ -20,7 +24,6 @@
|
|
|
20
24
|
from __future__ import annotations
|
|
21
25
|
|
|
22
26
|
import argparse
|
|
23
|
-
import ast
|
|
24
27
|
import json
|
|
25
28
|
import re
|
|
26
29
|
import sys
|
|
@@ -28,18 +31,27 @@ from pathlib import Path
|
|
|
28
31
|
from typing import Any
|
|
29
32
|
|
|
30
33
|
# 随包内核引导(`_support/`,2026-10-12 单源批):profile 解析走单源实现
|
|
31
|
-
# (本文件原持第三份手写解析,B209-1 引入时未收敛,见 `_support/stack_profile.py` 头注)。
|
|
32
34
|
if str(Path(__file__).resolve().parents[1]) not in sys.path:
|
|
33
35
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
34
36
|
from _support.siblings import load_module # noqa: E402
|
|
37
|
+
from _support.layout import LayoutError, load_layout # noqa: E402
|
|
35
38
|
|
|
36
39
|
_sp = load_module("stack_profile")
|
|
40
|
+
ac_coverage = load_module("ac_coverage")
|
|
41
|
+
parse_dims_cell = ac_coverage.parse_dims_cell
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
import adapters.ac as ac_adapters
|
|
45
|
+
from adapters.ac import command as ac_command
|
|
46
|
+
except Exception as exc: # 装配缺失 / 加载失败必须响亮,不静默降级
|
|
47
|
+
ac_adapters = None
|
|
48
|
+
ac_command = None
|
|
49
|
+
_ADAPTER_IMPORT_ERROR = exc
|
|
37
50
|
|
|
38
51
|
AC_ID_RE = re.compile(r"\b(AC-[A-Z0-9]+-\d+)\b")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
52
|
+
AC_ID_STRICT_RE = re.compile(r"^AC-[A-Z0-9]+-\d{3}$")
|
|
53
|
+
BACKEND_DIM_HEADER = "后端适用覆盖维度"
|
|
54
|
+
E2E_DIM_HEADER = "适用覆盖维度"
|
|
43
55
|
BASELINE_PATH = Path(".atlas/runs/backend-ac-alignment.json")
|
|
44
56
|
|
|
45
57
|
|
|
@@ -57,28 +69,24 @@ class Result:
|
|
|
57
69
|
return "FAIL"
|
|
58
70
|
if "WARN" in statuses:
|
|
59
71
|
return "WARN"
|
|
72
|
+
if self.checks and statuses <= {"N/A"}:
|
|
73
|
+
return "N/A"
|
|
60
74
|
return "PASS"
|
|
61
75
|
|
|
62
76
|
|
|
63
|
-
def load_stack_profile(root: Path) -> dict[str, Any]:
|
|
64
|
-
"""经随包内核读取 stack-profile(单源口径,2026-10-12 单源批)。
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
`_support/stack_profile.py` 唯一实现(`_support.siblings.load_module` 引导)。
|
|
68
|
-
"""
|
|
69
|
-
path = root / "product" / "stack-profile.yaml"
|
|
77
|
+
def load_stack_profile(root: Path, lay=None) -> dict[str, Any]:
|
|
78
|
+
"""经随包内核读取 stack-profile(单源口径,2026-10-12 单源批)。"""
|
|
79
|
+
lay = lay or load_layout(root)
|
|
80
|
+
path = lay.stack_profile
|
|
70
81
|
if not path.is_file():
|
|
71
82
|
return {}
|
|
72
83
|
return _sp.parse_profile(path.read_text(encoding="utf-8"))
|
|
73
84
|
|
|
74
85
|
|
|
75
|
-
def collect_prd_acs(root: Path) -> dict[str, set[str]]:
|
|
76
|
-
"""
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
显式排除 reviews/ 等子目录及 README.md,避免历史评审或说明文档中残留的旧 AC 污染。
|
|
80
|
-
"""
|
|
81
|
-
prd_dir = root / "product" / "prd"
|
|
86
|
+
def collect_prd_acs(root: Path, lay=None) -> dict[str, set[str]]:
|
|
87
|
+
"""收集资产根/prd/ 目录下所有已声明的 AC ID。返回 {文件名: {AC_ID, ...}}。"""
|
|
88
|
+
lay = lay or load_layout(root)
|
|
89
|
+
prd_dir = lay.prd_dir
|
|
82
90
|
if not prd_dir.is_dir():
|
|
83
91
|
return {}
|
|
84
92
|
acs_by_file: dict[str, set[str]] = {}
|
|
@@ -95,155 +103,201 @@ def collect_prd_acs(root: Path) -> dict[str, set[str]]:
|
|
|
95
103
|
return acs_by_file
|
|
96
104
|
|
|
97
105
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def __init__(self, rel_path: str) -> None:
|
|
102
|
-
self.rel_path = rel_path
|
|
103
|
-
# list of {name, file, line, acs, skipped, waiver}
|
|
104
|
-
self.tests: list[dict[str, Any]] = []
|
|
105
|
-
|
|
106
|
-
def _extract_acs_from_decorator(self, dec: ast.AST) -> tuple[set[str], bool, str]:
|
|
107
|
-
"""返回 (acs, is_skipped, waiver_reason)"""
|
|
108
|
-
acs: set[str] = set()
|
|
109
|
-
is_skipped = False
|
|
110
|
-
waiver = ""
|
|
111
|
-
|
|
112
|
-
# 形如 @pytest.mark.ac("AC-xxx") or @pytest.mark.skip(reason="waiver: ...")
|
|
113
|
-
if isinstance(dec, ast.Call):
|
|
114
|
-
func = dec.func
|
|
115
|
-
# 装饰器名称提取
|
|
116
|
-
names = []
|
|
117
|
-
cur = func
|
|
118
|
-
while isinstance(cur, ast.Attribute):
|
|
119
|
-
names.append(cur.attr)
|
|
120
|
-
cur = cur.value
|
|
121
|
-
if isinstance(cur, ast.Name):
|
|
122
|
-
names.append(cur.id)
|
|
123
|
-
names.reverse()
|
|
124
|
-
call_path = ".".join(names)
|
|
125
|
-
|
|
126
|
-
if call_path in ("pytest.mark.ac", "mark.ac", "ac"):
|
|
127
|
-
for arg in dec.args:
|
|
128
|
-
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
129
|
-
for m in AC_ID_RE.findall(arg.value):
|
|
130
|
-
acs.add(m)
|
|
131
|
-
elif isinstance(arg, (ast.List, ast.Tuple)):
|
|
132
|
-
for elt in arg.elts:
|
|
133
|
-
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
134
|
-
for m in AC_ID_RE.findall(elt.value):
|
|
135
|
-
acs.add(m)
|
|
136
|
-
elif call_path in ("pytest.mark.skip", "pytest.mark.xfail", "mark.skip", "mark.xfail", "skip", "xfail"):
|
|
137
|
-
is_skipped = True
|
|
138
|
-
for kw in dec.keywords:
|
|
139
|
-
if kw.arg == "reason" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
140
|
-
waiver = kw.value.value
|
|
141
|
-
for arg in dec.args:
|
|
142
|
-
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
143
|
-
waiver = arg.value
|
|
144
|
-
elif isinstance(dec, ast.Attribute):
|
|
145
|
-
# 无参 @pytest.mark.skip
|
|
146
|
-
if dec.attr in ("skip", "xfail"):
|
|
147
|
-
is_skipped = True
|
|
148
|
-
|
|
149
|
-
return acs, is_skipped, waiver
|
|
150
|
-
|
|
151
|
-
def _visit_test_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef, prefix: str = "") -> None:
|
|
152
|
-
if not node.name.startswith("test_"):
|
|
153
|
-
return
|
|
154
|
-
func_name = f"{prefix}{node.name}" if prefix else node.name
|
|
155
|
-
acs: set[str] = set()
|
|
156
|
-
is_skipped = False
|
|
157
|
-
waiver_reason = ""
|
|
158
|
-
|
|
159
|
-
for dec in node.decorator_list:
|
|
160
|
-
d_acs, d_skip, d_waiver = self._extract_acs_from_decorator(dec)
|
|
161
|
-
acs.update(d_acs)
|
|
162
|
-
if d_skip:
|
|
163
|
-
is_skipped = True
|
|
164
|
-
if d_waiver:
|
|
165
|
-
waiver_reason = d_waiver
|
|
166
|
-
|
|
167
|
-
# 检查 docstring 兜底
|
|
168
|
-
doc = ast.get_docstring(node) or ""
|
|
169
|
-
if doc:
|
|
170
|
-
for m in AC_ID_RE.findall(doc):
|
|
171
|
-
acs.add(m)
|
|
172
|
-
if "waiver:" in doc.lower():
|
|
173
|
-
waiver_reason = waiver_reason or doc
|
|
174
|
-
|
|
175
|
-
self.tests.append({
|
|
176
|
-
"name": func_name,
|
|
177
|
-
"file": self.rel_path,
|
|
178
|
-
"line": node.lineno,
|
|
179
|
-
"acs": acs,
|
|
180
|
-
"skipped": is_skipped,
|
|
181
|
-
"waiver": waiver_reason,
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
185
|
-
self._visit_test_func(node)
|
|
186
|
-
self.generic_visit(node)
|
|
187
|
-
|
|
188
|
-
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
189
|
-
self._visit_test_func(node)
|
|
190
|
-
self.generic_visit(node)
|
|
191
|
-
|
|
192
|
-
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
193
|
-
if node.name.startswith("Test"):
|
|
194
|
-
for item in node.body:
|
|
195
|
-
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
196
|
-
self._visit_test_func(item, prefix=f"{node.name}.")
|
|
197
|
-
self.generic_visit(node)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def scan_backend_tests(test_dir: Path, root: Path) -> list[dict[str, Any]]:
|
|
201
|
-
"""扫描 Python 测试目录提取所有挂载了 AC 的测试用例。"""
|
|
202
|
-
collected: list[dict[str, Any]] = []
|
|
203
|
-
if not test_dir.is_dir():
|
|
204
|
-
return collected
|
|
205
|
-
|
|
206
|
-
for p in test_dir.rglob("test_*.py"):
|
|
207
|
-
if any(part in SKIP_DIRS for part in p.parts):
|
|
208
|
-
continue
|
|
209
|
-
try:
|
|
210
|
-
rel = str(p.relative_to(root))
|
|
211
|
-
source = p.read_text(encoding="utf-8")
|
|
212
|
-
tree = ast.parse(source, filename=str(p))
|
|
213
|
-
visitor = PyTestACVisitor(rel)
|
|
214
|
-
visitor.visit(tree)
|
|
215
|
-
collected.extend(visitor.tests)
|
|
216
|
-
except Exception:
|
|
217
|
-
# 语法解析失败等,暂跳过或记录
|
|
218
|
-
continue
|
|
219
|
-
return collected
|
|
106
|
+
def _strip_cell(raw) -> str:
|
|
107
|
+
return ac_coverage.strip_md_cell(raw)
|
|
220
108
|
|
|
221
109
|
|
|
222
|
-
def
|
|
223
|
-
|
|
224
|
-
profile = load_stack_profile(root)
|
|
225
|
-
backend_test_cfg = profile.get("backend_test") or {}
|
|
226
|
-
if not isinstance(backend_test_cfg, dict):
|
|
227
|
-
backend_test_cfg = {}
|
|
110
|
+
def _empty_dim_cell(raw) -> bool:
|
|
111
|
+
return _strip_cell(raw) in ("", "N/A", "—", "-")
|
|
228
112
|
|
|
229
|
-
test_dir_str = backend_test_cfg.get("test_dir") or "backend/tests"
|
|
230
|
-
test_dir = root / test_dir_str
|
|
231
113
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
all_declared_acs: set[str] = set()
|
|
235
|
-
for acs in prd_acs_map.values():
|
|
236
|
-
all_declared_acs.update(acs)
|
|
114
|
+
def collect_backend_dims(prd_dir: Path) -> tuple[dict[str, set[str]], list[str], bool]:
|
|
115
|
+
"""解析 `*-prd.md` 的 `后端适用覆盖维度` 列。
|
|
237
116
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
117
|
+
返回 (declared, issues, had_legal)。had_legal = 至少一张 AC 表出现该表头。
|
|
118
|
+
逐表处理:一个 4 列旧表 / 错表头文件只跳过自身,不擦掉其他域。
|
|
119
|
+
"""
|
|
120
|
+
declared: dict[str, set[str]] = {}
|
|
121
|
+
issues: list[str] = []
|
|
122
|
+
had_legal = False
|
|
123
|
+
if not prd_dir.is_dir():
|
|
124
|
+
return declared, issues, had_legal
|
|
125
|
+
|
|
126
|
+
for p in sorted(prd_dir.glob("*-prd.md")):
|
|
127
|
+
text = p.read_text(encoding="utf-8")
|
|
128
|
+
for header, rows in ac_coverage.iter_md_tables(text):
|
|
129
|
+
hdr = [_strip_cell(c) for c in header]
|
|
130
|
+
if "AC ID" not in hdr or "回指规则" not in hdr:
|
|
131
|
+
continue
|
|
132
|
+
ac_idx = hdr.index("AC ID")
|
|
133
|
+
if BACKEND_DIM_HEADER in hdr:
|
|
134
|
+
had_legal = True
|
|
135
|
+
dim_idx = hdr.index(BACKEND_DIM_HEADER)
|
|
136
|
+
if dim_idx == 3:
|
|
137
|
+
issues.append(
|
|
138
|
+
f"{p.name}: 后端维度列表头位于第 4 列(疑似与 E2E 列位混淆;建议保留第 4 列 E2E、把后端列放第 5 列)"
|
|
139
|
+
)
|
|
140
|
+
for row in rows:
|
|
141
|
+
if ac_idx >= len(row):
|
|
142
|
+
continue
|
|
143
|
+
ac = _strip_cell(row[ac_idx])
|
|
144
|
+
if not AC_ID_RE.fullmatch(ac):
|
|
145
|
+
continue
|
|
146
|
+
if not AC_ID_STRICT_RE.fullmatch(ac):
|
|
147
|
+
issues.append(f"{ac} 不符合 NNN 契约")
|
|
148
|
+
cell = row[dim_idx] if dim_idx < len(row) else ""
|
|
149
|
+
if _empty_dim_cell(cell):
|
|
150
|
+
continue
|
|
151
|
+
dims, _looks = parse_dims_cell(cell)
|
|
152
|
+
unknown = ac_coverage.unknown_dim_tokens(cell)
|
|
153
|
+
if unknown:
|
|
154
|
+
issues.append(f"{p.name}:{ac} 后端维度含未知 token:{','.join(unknown)}")
|
|
155
|
+
if not dims:
|
|
156
|
+
issues.append(f"{p.name}:{ac} 后端维度无法解析:{cell!r}")
|
|
157
|
+
continue
|
|
158
|
+
if ac in declared:
|
|
159
|
+
suffix = "(后者覆盖前者)" if declared[ac] != dims else ""
|
|
160
|
+
issues.append(f"{p.name}:{ac} 后端维度重复声明{suffix}")
|
|
161
|
+
declared[ac] = dims
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# 没有合法后端表头:只对「表头含 维度/备注/后端 或列位 ≥5」的列做疑似扫描;
|
|
165
|
+
# 永远排除第 4 列 `适用覆盖维度`(标准 E2E 表不得误判)。
|
|
166
|
+
suspect = [
|
|
167
|
+
i for i, h in enumerate(hdr)
|
|
168
|
+
if h != E2E_DIM_HEADER
|
|
169
|
+
and (("维度" in h) or ("备注" in h) or ("后端" in h) or i >= 4)
|
|
170
|
+
]
|
|
171
|
+
hit = False
|
|
172
|
+
for row in rows:
|
|
173
|
+
if ac_idx >= len(row):
|
|
174
|
+
continue
|
|
175
|
+
ac = _strip_cell(row[ac_idx])
|
|
176
|
+
if not AC_ID_RE.fullmatch(ac):
|
|
177
|
+
continue
|
|
178
|
+
for i in suspect:
|
|
179
|
+
if i < len(row) and ac_coverage.has_dim_claim(row[i]):
|
|
180
|
+
hit = True
|
|
181
|
+
break
|
|
182
|
+
if hit:
|
|
183
|
+
break
|
|
184
|
+
if hit:
|
|
185
|
+
issues.append(f"{p.name}: 疑似后端维度列但表头/列位不符")
|
|
186
|
+
|
|
187
|
+
seen: set[str] = set()
|
|
188
|
+
uniq: list[str] = []
|
|
189
|
+
for issue in issues:
|
|
190
|
+
if issue not in seen:
|
|
191
|
+
seen.add(issue)
|
|
192
|
+
uniq.append(issue)
|
|
193
|
+
return declared, uniq, had_legal
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def backend_channel_reason(root: Path, profile: dict[str, Any], backend_test_cfg: dict[str, Any]) -> str:
|
|
197
|
+
"""维度子项的通道适用/可用判据;返回空串 = 可用。
|
|
198
|
+
|
|
199
|
+
具体框架支持性不在这里判:extractor 解析已在主流程完成;本函数只判
|
|
200
|
+
「形态 / 多后端 / 是否声明 backend_test / test_dir 是否存在」。
|
|
201
|
+
"""
|
|
202
|
+
apps = profile.get("apps")
|
|
203
|
+
if not isinstance(apps, list) or not apps:
|
|
204
|
+
return "未声明 apps(无法判定后端通道)"
|
|
205
|
+
kinds = [a.get("kind") for a in apps if isinstance(a, dict)]
|
|
206
|
+
backend_kinds = [k for k in kinds if k in ("backend", "fullstack")]
|
|
207
|
+
if not backend_kinds:
|
|
208
|
+
return "无 backend/fullstack 应用(当前栈不适用)"
|
|
209
|
+
if not backend_test_cfg:
|
|
210
|
+
return "未声明 backend_test(合法降级)"
|
|
211
|
+
test_dir = backend_test_cfg.get("test_dir") or "backend/tests"
|
|
212
|
+
if not (root / test_dir).is_dir():
|
|
213
|
+
return f"test_dir 不存在:{test_dir}"
|
|
214
|
+
return ""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _cap(items: list[str], n: int = 8) -> str:
|
|
218
|
+
if len(items) <= n:
|
|
219
|
+
return "、".join(items)
|
|
220
|
+
return "、".join(items[:n]) + f"…(共 {len(items)})"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _as_str_set(value: Any) -> set[str] | None:
|
|
224
|
+
if not isinstance(value, (list, tuple, set)):
|
|
225
|
+
return None
|
|
226
|
+
if not all(isinstance(x, str) for x in value):
|
|
227
|
+
return None
|
|
228
|
+
return set(value)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _record_schema_errors(tests: list[dict[str, Any]], root: Path) -> list[str]:
|
|
232
|
+
"""校验 adapter 归一化记录的不变量;返回空列表 = 合法。"""
|
|
233
|
+
errors: list[str] = []
|
|
234
|
+
seen: set[tuple[str, int]] = set()
|
|
235
|
+
required = (
|
|
236
|
+
"name", "file", "line", "acs", "marker_acs", "doc_acs",
|
|
237
|
+
"dims", "dims_bad", "dims_shape_ok", "dims_present",
|
|
238
|
+
"skipped", "waiver", "weak", "marker_scope_unsupported",
|
|
239
|
+
)
|
|
240
|
+
for idx, t in enumerate(tests):
|
|
241
|
+
if not isinstance(t, dict):
|
|
242
|
+
errors.append(f"#{idx} 记录不是对象")
|
|
243
|
+
continue
|
|
244
|
+
missing = [k for k in required if k not in t]
|
|
245
|
+
if missing:
|
|
246
|
+
errors.append(f"#{idx} 缺字段 {missing}")
|
|
247
|
+
continue
|
|
248
|
+
name, file, line = t["name"], t["file"], t["line"]
|
|
249
|
+
if not isinstance(name, str) or not name.strip():
|
|
250
|
+
errors.append(f"#{idx} name 非空字符串")
|
|
251
|
+
if not isinstance(file, str) or not file.strip():
|
|
252
|
+
errors.append(f"#{idx} file 非空字符串")
|
|
253
|
+
else:
|
|
254
|
+
p = Path(file)
|
|
255
|
+
if p.is_absolute() or ".." in p.parts:
|
|
256
|
+
errors.append(f"#{idx} file 必须是项目根相对路径:{file!r}")
|
|
257
|
+
if isinstance(line, bool) or not isinstance(line, int) or line <= 0:
|
|
258
|
+
errors.append(f"#{idx} line 必须为正整数:{line!r}")
|
|
259
|
+
acs = _as_str_set(t["acs"])
|
|
260
|
+
marker_acs = _as_str_set(t["marker_acs"])
|
|
261
|
+
doc_acs = _as_str_set(t["doc_acs"])
|
|
262
|
+
dims = _as_str_set(t["dims"])
|
|
263
|
+
dims_bad = _as_str_set(t["dims_bad"])
|
|
264
|
+
if acs is None or marker_acs is None or doc_acs is None or dims is None or dims_bad is None:
|
|
265
|
+
errors.append(f"#{idx} acs/marker_acs/doc_acs/dims/dims_bad 必须是字符串集合")
|
|
266
|
+
continue
|
|
267
|
+
if marker_acs - acs or doc_acs - acs:
|
|
268
|
+
errors.append(f"#{idx} marker_acs/doc_acs 必须是 acs 的子集")
|
|
269
|
+
if acs != (marker_acs | doc_acs):
|
|
270
|
+
errors.append(f"#{idx} acs 必须等于 marker_acs ∪ doc_acs")
|
|
271
|
+
if not isinstance(t["dims_shape_ok"], bool) or not isinstance(t["dims_present"], bool):
|
|
272
|
+
errors.append(f"#{idx} dims_shape_ok/dims_present 必须是布尔")
|
|
273
|
+
if not isinstance(t["skipped"], bool) or not isinstance(t["weak"], bool):
|
|
274
|
+
errors.append(f"#{idx} skipped/weak 必须是布尔")
|
|
275
|
+
if not isinstance(t["waiver"], str):
|
|
276
|
+
errors.append(f"#{idx} waiver 必须是字符串")
|
|
277
|
+
if not isinstance(t["marker_scope_unsupported"], bool):
|
|
278
|
+
errors.append(f"#{idx} marker_scope_unsupported 必须是布尔")
|
|
279
|
+
if isinstance(file, str) and isinstance(line, int) and not isinstance(line, bool) and line > 0:
|
|
280
|
+
key = (file, line)
|
|
281
|
+
if key in seen:
|
|
282
|
+
errors.append(f"#{idx} (file,line) 重复:{file}:{line}")
|
|
283
|
+
seen.add(key)
|
|
284
|
+
return errors
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _run_checks(
|
|
288
|
+
res: Result,
|
|
289
|
+
result,
|
|
290
|
+
root: Path,
|
|
291
|
+
lay,
|
|
292
|
+
profile: dict[str, Any],
|
|
293
|
+
backend_test_cfg: dict[str, Any],
|
|
294
|
+
all_declared_acs: set[str],
|
|
295
|
+
mode: str,
|
|
296
|
+
) -> Result:
|
|
297
|
+
tests = result.tests
|
|
298
|
+
parse_errors = result.parse_errors
|
|
299
|
+
scan_face = result.scan_face
|
|
245
300
|
|
|
246
|
-
# 3. 统计挂载情况
|
|
247
301
|
test_with_ac = [t for t in tests if t["acs"]]
|
|
248
302
|
all_cited_acs: set[str] = set()
|
|
249
303
|
dangling_acs: set[str] = set()
|
|
@@ -259,39 +313,145 @@ def validate_ac_alignment(root: Path) -> Result:
|
|
|
259
313
|
if not waiver or "waiver:" not in waiver.lower():
|
|
260
314
|
unwaived_skips.append(f"{t['file']}:{t['name']} (挂载 {','.join(t['acs'])})")
|
|
261
315
|
|
|
262
|
-
# ---- 判据 1:无主 AC 拦截 (Dangling ACs)
|
|
263
316
|
if dangling_acs:
|
|
264
317
|
res.add(
|
|
265
|
-
"无主AC拦截",
|
|
266
|
-
"
|
|
267
|
-
f"测试中引用的 AC ID 未在任何 product/prd/*.md 中声明:{sorted(dangling_acs)}"
|
|
318
|
+
"无主AC拦截", "FAIL",
|
|
319
|
+
f"测试中引用的 AC ID 未在任何 {lay.print_value('prd')}/*.md 中声明:{sorted(dangling_acs)}",
|
|
268
320
|
)
|
|
269
321
|
else:
|
|
270
322
|
res.add("无主AC拦截", "PASS", f"测试中引用的 {len(all_cited_acs)} 个 AC 均有合法 PRD 声明")
|
|
271
323
|
|
|
272
|
-
# ---- 判据 2:无效跳过测试拦截 (Unwaived Skip)
|
|
273
324
|
if unwaived_skips:
|
|
274
325
|
res.add(
|
|
275
|
-
"无效跳过拦截",
|
|
276
|
-
"
|
|
277
|
-
f"挂载 AC 的测试被 skip/xfail 但未附带合法的 waiver 豁免理由:{unwaived_skips}"
|
|
326
|
+
"无效跳过拦截", "FAIL",
|
|
327
|
+
f"挂载 AC 的测试被 skip/xfail 但未附带合法的 waiver 豁免理由:{unwaived_skips}",
|
|
278
328
|
)
|
|
279
329
|
else:
|
|
280
330
|
res.add("无效跳过拦截", "PASS", "所有挂载 AC 的跳过测试均具备有效豁免声明或无跳过")
|
|
281
331
|
|
|
282
|
-
|
|
283
|
-
|
|
332
|
+
if parse_errors:
|
|
333
|
+
status = "FAIL" if mode == "enforce" else "WARN"
|
|
334
|
+
res.add("测试提取完整性", status,
|
|
335
|
+
f"[PARSE] 解析失败={len(parse_errors)}个:{'; '.join(parse_errors[:3])}")
|
|
336
|
+
else:
|
|
337
|
+
res.add("测试提取完整性", "PASS", "所有测试文件解析成功")
|
|
338
|
+
|
|
339
|
+
if scan_face:
|
|
340
|
+
res.add("测试扫描面", "WARN", f"[SCAN] {';'.join(scan_face[:4])}")
|
|
341
|
+
|
|
284
342
|
total_declared = len(all_declared_acs)
|
|
285
343
|
total_cited = len(all_cited_acs)
|
|
286
344
|
coverage_ratio = (total_cited / total_declared) if total_declared > 0 else 1.0
|
|
287
|
-
|
|
288
345
|
detail_msg = f"已声明 AC: {total_declared},后端测试挂载覆盖: {total_cited} ({coverage_ratio:.1%})"
|
|
289
346
|
if mode == "enforce" and total_declared > 0 and total_cited == 0:
|
|
290
347
|
res.add("AC覆盖对账", "FAIL", f"{detail_msg}(enforce 模式下不允许零测试挂载)")
|
|
348
|
+
elif total_declared > 0 and total_cited == 0:
|
|
349
|
+
res.add("AC覆盖对账", "WARN", f"[ZERO-COVERAGE] {detail_msg}")
|
|
291
350
|
else:
|
|
292
|
-
# advisory 模式或有覆盖
|
|
293
351
|
res.add("AC覆盖对账", "PASS", detail_msg)
|
|
294
352
|
|
|
353
|
+
# ---- 判据 5:AC × 覆盖维度(后端通道)
|
|
354
|
+
declared_dims, dim_issues, had_legal = collect_backend_dims(lay.prd_dir)
|
|
355
|
+
reason = backend_channel_reason(root, profile, backend_test_cfg)
|
|
356
|
+
check = "AC × 覆盖维度(后端通道)"
|
|
357
|
+
|
|
358
|
+
if reason:
|
|
359
|
+
detail = reason
|
|
360
|
+
if dim_issues:
|
|
361
|
+
detail += ";" + ";".join(dim_issues[:4])
|
|
362
|
+
res.add(check, "WARN" if dim_issues else "N/A", detail)
|
|
363
|
+
elif not had_legal:
|
|
364
|
+
if dim_issues:
|
|
365
|
+
res.add(check, "WARN", ";".join(dim_issues[:4]))
|
|
366
|
+
else:
|
|
367
|
+
res.add(check, "N/A", "未声明「后端适用覆盖维度」列(按需启用)")
|
|
368
|
+
elif not declared_dims:
|
|
369
|
+
detail = "后端列已存在但未声明任何维度"
|
|
370
|
+
if dim_issues:
|
|
371
|
+
detail += ";" + ";".join(dim_issues[:4])
|
|
372
|
+
res.add(check, "WARN" if dim_issues else "N/A", detail)
|
|
373
|
+
else:
|
|
374
|
+
unique: list[dict[str, Any]] = []
|
|
375
|
+
seen: set[tuple[str, int]] = set()
|
|
376
|
+
for t in tests:
|
|
377
|
+
key = (t["file"], t["line"])
|
|
378
|
+
if key in seen:
|
|
379
|
+
continue
|
|
380
|
+
seen.add(key)
|
|
381
|
+
unique.append(t)
|
|
382
|
+
|
|
383
|
+
zero: list[str] = []
|
|
384
|
+
missing: list[str] = []
|
|
385
|
+
weak_only: list[str] = []
|
|
386
|
+
bad: list[str] = []
|
|
387
|
+
empty: list[str] = []
|
|
388
|
+
multi: list[str] = []
|
|
389
|
+
orphan: list[str] = []
|
|
390
|
+
shape: list[str] = []
|
|
391
|
+
pm_ac: set[str] = set()
|
|
392
|
+
|
|
393
|
+
for t in unique:
|
|
394
|
+
label = f"{t['file']}:{t['name']}"
|
|
395
|
+
if t.get("marker_scope_unsupported"):
|
|
396
|
+
pm_ac.add(t["file"])
|
|
397
|
+
if t.get("dims_bad"):
|
|
398
|
+
bad.append(f"{label}={'/'.join(t['dims_bad'])}")
|
|
399
|
+
if t.get("dims_present") and not t.get("dims_shape_ok"):
|
|
400
|
+
shape.append(label)
|
|
401
|
+
if t.get("dims_present") and not t.get("dims_bad") and t.get("dims_shape_ok") and not t.get("dims"):
|
|
402
|
+
empty.append(label)
|
|
403
|
+
if t.get("dims_present") and not t.get("marker_acs"):
|
|
404
|
+
orphan.append(label)
|
|
405
|
+
if t.get("dims_present") and len(t.get("marker_acs") or ()) > 1:
|
|
406
|
+
multi.append(label)
|
|
407
|
+
|
|
408
|
+
for ac, dims in sorted(declared_dims.items()):
|
|
409
|
+
ac_tests = [t for t in unique if ac in t["marker_acs"]]
|
|
410
|
+
if not ac_tests:
|
|
411
|
+
zero.append(ac)
|
|
412
|
+
continue
|
|
413
|
+
active: set[str] = set()
|
|
414
|
+
weak: set[str] = set()
|
|
415
|
+
for t in ac_tests:
|
|
416
|
+
if t.get("dims_present") and len(t.get("marker_acs") or ()) != 1:
|
|
417
|
+
continue
|
|
418
|
+
if not t.get("dims_shape_ok", True):
|
|
419
|
+
continue
|
|
420
|
+
(weak if t.get("weak") else active).update(t.get("dims") or ())
|
|
421
|
+
covered = active | weak
|
|
422
|
+
miss = dims - covered
|
|
423
|
+
if miss:
|
|
424
|
+
missing.append(f"{ac}缺{','.join(sorted(miss))}")
|
|
425
|
+
weak_only_dims = (dims & weak) - active
|
|
426
|
+
if weak_only_dims:
|
|
427
|
+
weak_only.append(f"{ac}仅弱证据={'/'.join(sorted(weak_only_dims))}")
|
|
428
|
+
|
|
429
|
+
parts: list[str] = []
|
|
430
|
+
if zero:
|
|
431
|
+
parts.append(f"零测试={_cap(zero)}")
|
|
432
|
+
if missing:
|
|
433
|
+
parts.append(f"缺维度={_cap(missing)}")
|
|
434
|
+
if weak_only:
|
|
435
|
+
parts.append(f"弱证据={_cap(weak_only)}")
|
|
436
|
+
if bad:
|
|
437
|
+
parts.append(f"未知维度={_cap(bad, 4)}")
|
|
438
|
+
if empty:
|
|
439
|
+
parts.append(f"空dims={_cap(empty, 4)}")
|
|
440
|
+
if multi:
|
|
441
|
+
parts.append(f"一测多AC带dims={_cap(multi, 4)}")
|
|
442
|
+
if orphan:
|
|
443
|
+
parts.append(f"孤儿dims={_cap(orphan, 4)}")
|
|
444
|
+
if shape:
|
|
445
|
+
parts.append(f"dims形态不支持={_cap(shape, 4)}")
|
|
446
|
+
if pm_ac:
|
|
447
|
+
parts.append(f"模块/类级AC标记不支持={_cap(sorted(pm_ac), 4)}")
|
|
448
|
+
if dim_issues:
|
|
449
|
+
parts.append(";".join(dim_issues[:4]))
|
|
450
|
+
|
|
451
|
+
status = "WARN" if parts else "PASS"
|
|
452
|
+
detail = ";".join(parts) if parts else f"{len(declared_dims)} 条 AC 后端维度全量覆盖"
|
|
453
|
+
res.add(check, status, detail)
|
|
454
|
+
|
|
295
455
|
# ---- 判据 4:差量防回退 (Delta Ratchet)
|
|
296
456
|
baseline_file = root / BASELINE_PATH
|
|
297
457
|
delta_issues = []
|
|
@@ -320,17 +480,113 @@ def validate_ac_alignment(root: Path) -> Result:
|
|
|
320
480
|
else:
|
|
321
481
|
res.add("差量防回退", "PASS", f"当前挂载测试数: {len(test_with_ac)},覆盖 AC 数: {len(all_cited_acs)}")
|
|
322
482
|
|
|
323
|
-
#
|
|
324
|
-
if res.status != "FAIL":
|
|
483
|
+
# 解析失败时快照不完整:不写 baseline,避免把坏快照冻成新基线
|
|
484
|
+
if res.status != "FAIL" and not parse_errors:
|
|
325
485
|
try:
|
|
326
486
|
baseline_file.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
-
baseline_file.write_text(
|
|
487
|
+
baseline_file.write_text(
|
|
488
|
+
json.dumps(current_snapshot, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
489
|
+
)
|
|
328
490
|
except Exception:
|
|
329
491
|
pass
|
|
330
492
|
|
|
331
493
|
return res
|
|
332
494
|
|
|
333
495
|
|
|
496
|
+
def validate_ac_alignment(root: Path) -> Result:
|
|
497
|
+
res = Result()
|
|
498
|
+
try:
|
|
499
|
+
lay = load_layout(root)
|
|
500
|
+
except LayoutError as exc:
|
|
501
|
+
res.add("项目布局配置", "FAIL", str(exc))
|
|
502
|
+
return res
|
|
503
|
+
if ac_adapters is None:
|
|
504
|
+
res.add("AC提取器注册表", "FAIL",
|
|
505
|
+
f"adapters/ac 加载失败:{_ADAPTER_IMPORT_ERROR}")
|
|
506
|
+
return res
|
|
507
|
+
|
|
508
|
+
profile = load_stack_profile(root, lay)
|
|
509
|
+
backend_test_cfg = profile.get("backend_test") or {}
|
|
510
|
+
if not isinstance(backend_test_cfg, dict):
|
|
511
|
+
backend_test_cfg = {}
|
|
512
|
+
mode = backend_test_cfg.get("mode", "advisory")
|
|
513
|
+
if mode not in ("advisory", "enforce"):
|
|
514
|
+
mode = "advisory"
|
|
515
|
+
|
|
516
|
+
apps = profile.get("apps")
|
|
517
|
+
apps_declared = isinstance(apps, list) and bool(apps)
|
|
518
|
+
backend_apps = [
|
|
519
|
+
a for a in (apps or [])
|
|
520
|
+
if isinstance(a, dict) and a.get("kind") in ("backend", "fullstack")
|
|
521
|
+
] if apps_declared else []
|
|
522
|
+
|
|
523
|
+
if apps_declared and not backend_apps:
|
|
524
|
+
res.add("门控态", "N/A", "无 backend/fullstack 应用(当前形态不适用)")
|
|
525
|
+
return res
|
|
526
|
+
|
|
527
|
+
prd_acs_map = collect_prd_acs(root, lay)
|
|
528
|
+
all_declared_acs: set[str] = set()
|
|
529
|
+
for acs in prd_acs_map.values():
|
|
530
|
+
all_declared_acs.update(acs)
|
|
531
|
+
|
|
532
|
+
explicit = backend_test_cfg.get("extractor") or backend_test_cfg.get("framework")
|
|
533
|
+
command = backend_test_cfg.get("extractor_command")
|
|
534
|
+
test_dir_str = backend_test_cfg.get("test_dir") or "backend/tests"
|
|
535
|
+
if command:
|
|
536
|
+
try:
|
|
537
|
+
result = ac_command.run(command, root, root / test_dir_str)
|
|
538
|
+
except ac_command.ExtractError as exc:
|
|
539
|
+
res.add("测试提取通道", "FAIL", f"[EXTRACTOR] {exc}")
|
|
540
|
+
return res
|
|
541
|
+
else:
|
|
542
|
+
adapter = ac_adapters.resolve(explicit) if explicit else ac_adapters.resolve(None)
|
|
543
|
+
if explicit and adapter is None:
|
|
544
|
+
if not all_declared_acs:
|
|
545
|
+
res.add("AC对账门禁运行态", "N/A", "无 PRD AC 且 extractor 未注册(无输入面)")
|
|
546
|
+
return res
|
|
547
|
+
res.add(
|
|
548
|
+
"后端测试提取通道",
|
|
549
|
+
"WARN" if mode == "advisory" else "FAIL",
|
|
550
|
+
f"[UNVERIFIED] extractor={explicit!r} 未注册;后端 AC 覆盖未验证",
|
|
551
|
+
)
|
|
552
|
+
return res
|
|
553
|
+
result = adapter.extract(root, root / test_dir_str, backend_test_cfg)
|
|
554
|
+
input_present = result.input_state == "present"
|
|
555
|
+
|
|
556
|
+
if not all_declared_acs and not input_present:
|
|
557
|
+
res.add("AC对账门禁运行态", "N/A", "无 PRD 验收标准资产且无测试输入(不适用)")
|
|
558
|
+
return res
|
|
559
|
+
|
|
560
|
+
if not apps_declared:
|
|
561
|
+
res.add(
|
|
562
|
+
"后端通道适用性",
|
|
563
|
+
"WARN" if mode == "advisory" else "FAIL",
|
|
564
|
+
"[UNVERIFIED] stack-profile 未声明 apps,后端通道适用性无法判定",
|
|
565
|
+
)
|
|
566
|
+
if not input_present:
|
|
567
|
+
return res
|
|
568
|
+
|
|
569
|
+
# backend_test 已声明但既没有 extractor、默认提取器也没发现输入:
|
|
570
|
+
# 不假设项目是 Python,报未验证而不是 ZERO-COVERAGE。
|
|
571
|
+
if backend_test_cfg and not explicit and not command and not input_present:
|
|
572
|
+
res.add(
|
|
573
|
+
"后端测试提取通道",
|
|
574
|
+
"WARN" if mode == "advisory" else "FAIL",
|
|
575
|
+
"[UNVERIFIED] 未声明 extractor 且默认提取器未发现测试输入;"
|
|
576
|
+
"非 Python 后端请声明 extractor",
|
|
577
|
+
)
|
|
578
|
+
return res
|
|
579
|
+
|
|
580
|
+
schema_errors = _record_schema_errors(result.tests, root)
|
|
581
|
+
if schema_errors:
|
|
582
|
+
res.add("测试提取契约", "FAIL", f"[EXTRACTOR] {_cap(schema_errors, 6)}")
|
|
583
|
+
return res
|
|
584
|
+
|
|
585
|
+
return _run_checks(
|
|
586
|
+
res, result, root, lay, profile, backend_test_cfg, all_declared_acs, mode
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
334
590
|
def main() -> int:
|
|
335
591
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
336
592
|
parser.add_argument("--root", type=Path, default=Path("."), help="项目根目录")
|