workflow-loop 0.1.0__py3-none-any.whl
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.
- workflow_loop/__init__.py +6 -0
- workflow_loop/acceptance_records.py +338 -0
- workflow_loop/artifact_paths.py +278 -0
- workflow_loop/artifact_validation.py +1738 -0
- workflow_loop/bug_record.py +203 -0
- workflow_loop/cli.py +3257 -0
- workflow_loop/data/Standardized_Repository/acceptance/acceptance.md +119 -0
- workflow_loop/data/Standardized_Repository/acceptance/acceptance_plan.md +105 -0
- workflow_loop/data/Standardized_Repository/code_design/code_design.md +204 -0
- workflow_loop/data/Standardized_Repository/code_design/project_design_init.md +152 -0
- workflow_loop/data/Standardized_Repository/code_design/revise_code_design.md +32 -0
- workflow_loop/data/Standardized_Repository/code_design/update_code_design.md +94 -0
- workflow_loop/data/Standardized_Repository/global/document_writing.md +77 -0
- workflow_loop/data/Standardized_Repository/global/workflow_lifecycle.md +91 -0
- workflow_loop/data/Standardized_Repository/impl/code_implementation.md +85 -0
- workflow_loop/data/Standardized_Repository/impl/impl.md +164 -0
- workflow_loop/data/Standardized_Repository/qa/test.md +167 -0
- workflow_loop/data/Standardized_Repository/qa/test_code.md +121 -0
- workflow_loop/data/Standardized_Repository/qa/test_code_implementation.md +67 -0
- workflow_loop/data/Standardized_Repository/qa/test_plan.md +160 -0
- workflow_loop/data/Standardized_Repository/reproduce/reproduce.md +60 -0
- workflow_loop/data/Standardized_Repository/spec/spec.md +138 -0
- workflow_loop/data/Standardized_Repository/spike/spike.md +236 -0
- workflow_loop/data/Template_Repository/acceptance/acceptance_plan.md +142 -0
- workflow_loop/data/Template_Repository/acceptance/acceptance_result.md +108 -0
- workflow_loop/data/Template_Repository/code_design/code_design.md +260 -0
- workflow_loop/data/Template_Repository/code_design/project_design_init_evidence.md +39 -0
- workflow_loop/data/Template_Repository/impl/impl.md +112 -0
- workflow_loop/data/Template_Repository/qa/test.md +102 -0
- workflow_loop/data/Template_Repository/qa/test_plan.md +100 -0
- workflow_loop/data/Template_Repository/reproduce/reproduce.md +82 -0
- workflow_loop/data/Template_Repository/spec/spec.md +222 -0
- workflow_loop/data/Template_Repository/spike/spike.md +135 -0
- workflow_loop/installer.py +632 -0
- workflow_loop/journal.py +78 -0
- workflow_loop/path_composer.py +152 -0
- workflow_loop/process_runner.py +176 -0
- workflow_loop/project.py +397 -0
- workflow_loop/role_doc.py +133 -0
- workflow_loop/rollback.py +1738 -0
- workflow_loop/spike_validation.py +379 -0
- workflow_loop/stage_materials.py +169 -0
- workflow_loop/stages/__init__.py +45 -0
- workflow_loop/stages/base.py +164 -0
- workflow_loop/stages/stages.py +1191 -0
- workflow_loop/state.py +582 -0
- workflow_loop/test_entry.py +123 -0
- workflow_loop/test_execution.py +619 -0
- workflow_loop/test_mapping.py +568 -0
- workflow_loop/test_runner.py +134 -0
- workflow_loop/topic.py +114 -0
- workflow_loop/topic_relations.py +202 -0
- workflow_loop/traceability.py +533 -0
- workflow_loop/verification.py +971 -0
- workflow_loop-0.1.0.dist-info/METADATA +187 -0
- workflow_loop-0.1.0.dist-info/RECORD +60 -0
- workflow_loop-0.1.0.dist-info/WHEEL +5 -0
- workflow_loop-0.1.0.dist-info/entry_points.txt +2 -0
- workflow_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
- workflow_loop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1738 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from . import acceptance_records as acceptance_records_mod
|
|
9
|
+
from . import artifact_paths as artifact_paths_mod
|
|
10
|
+
from . import process_runner as process_runner_mod
|
|
11
|
+
from . import test_runner as test_runner_mod
|
|
12
|
+
from .state import load_state
|
|
13
|
+
from .test_mapping import (
|
|
14
|
+
automated_topics,
|
|
15
|
+
automated_test_items,
|
|
16
|
+
parse_test_plan_items,
|
|
17
|
+
)
|
|
18
|
+
from .topic import topic_file_key, topic_paths
|
|
19
|
+
from .traceability import validate_structure as validate_traceability_structure
|
|
20
|
+
from .topic_relations import relation_signature, read_topic_index
|
|
21
|
+
from .verification import (
|
|
22
|
+
compute_code_snapshot_hash,
|
|
23
|
+
compute_file_hashes,
|
|
24
|
+
get_linked_product_design_paths,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PROJECT_INIT_EVIDENCE_PATH = artifact_paths_mod.DESIGN_INIT_EVIDENCE_DOC
|
|
29
|
+
TRACEABILITY_PATH = artifact_paths_mod.TRACEABILITY_DOC
|
|
30
|
+
# 缺陷记录使用稳定中文文件标识:bug/缺陷_<缺陷文件标识>.md
|
|
31
|
+
BUG_FILENAME_RE = re.compile(r"^缺陷_[A-Za-z0-9_\-一-鿿㐀-䶿]+\.md$")
|
|
32
|
+
BUG_INDEX_FILENAMES = {"索引.md"}
|
|
33
|
+
ACCEPTANCE_CRITERION_RE = re.compile(r"^###\s+(AC-\d{2,}):?.*$", re.MULTILINE)
|
|
34
|
+
ACCEPTANCE_PLAN_SECTIONS = [
|
|
35
|
+
"1. 本次需求与验收目标",
|
|
36
|
+
"2. 产品设计依据",
|
|
37
|
+
"3. 验收范围",
|
|
38
|
+
"4. 验收条件",
|
|
39
|
+
"5. 完成判定",
|
|
40
|
+
"6. 上下游文档",
|
|
41
|
+
]
|
|
42
|
+
TEST_PLAN_SECTIONS = [
|
|
43
|
+
"1. 验收条件覆盖",
|
|
44
|
+
"2. 针对性回归范围",
|
|
45
|
+
"3. 测试条件要求",
|
|
46
|
+
"4. 未决测试条件",
|
|
47
|
+
"5. 上下游文档",
|
|
48
|
+
]
|
|
49
|
+
ARCHITECTURE_DOCUMENT_SECTIONS = [
|
|
50
|
+
"1. 文档说明",
|
|
51
|
+
"2. 产品概览",
|
|
52
|
+
"3. 产品设计如何决定代码架构",
|
|
53
|
+
"4. 代码架构分层",
|
|
54
|
+
"5. 架构关键节点",
|
|
55
|
+
"6. 各产品功能的代码设计",
|
|
56
|
+
"7. 多个功能共同使用的代码",
|
|
57
|
+
"8. 产品设计与代码实现的差异",
|
|
58
|
+
"9. 最终同步结论",
|
|
59
|
+
]
|
|
60
|
+
CODE_SUFFIXES = {
|
|
61
|
+
".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".java", ".kt", ".kts",
|
|
62
|
+
".py", ".pyi", ".go", ".rs", ".swift", ".m", ".mm", ".js", ".jsx",
|
|
63
|
+
".ts", ".tsx", ".vue", ".svelte", ".rb", ".php", ".cs", ".fs", ".ets",
|
|
64
|
+
}
|
|
65
|
+
# 最终架构文档不能把计划性表述写成最终事实
|
|
66
|
+
FINAL_DESIGN_FORBIDDEN_WORDS = ("待实施", "待验证", "待补充", "尚未实现")
|
|
67
|
+
MACHINE_RECORD_TOKEN_RE = re.compile(
|
|
68
|
+
r"(?<![A-Za-z0-9_-])(?:RUN|REG)-[^\s,,;;、|()\[\]{}<>`]+"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_text(project_root: str, rel_path: str) -> str:
|
|
73
|
+
with open(os.path.join(project_root, rel_path), "r", encoding="utf-8") as f:
|
|
74
|
+
return f.read()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _field(content: str, label: str) -> str | None:
|
|
78
|
+
match = re.search(rf"^-\s*{re.escape(label)}:\s*(.+?)\s*$", content, re.MULTILINE)
|
|
79
|
+
return match.group(1).strip() if match else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _argv_text(argv: list[str]) -> str:
|
|
83
|
+
"""把参数数组编码成可读且可精确比对的单行 JSON。"""
|
|
84
|
+
return json.dumps(argv, ensure_ascii=False, separators=(",", ":"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _environment_text(platform: str, executable: str) -> str:
|
|
88
|
+
"""固定编码测试平台和实际可执行文件。"""
|
|
89
|
+
return f"平台={platform};可执行文件={executable}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _output_tail_text(output_tail: str) -> str:
|
|
93
|
+
"""把可能含换行的输出摘要编码成单行 JSON 字符串。"""
|
|
94
|
+
return json.dumps(output_tail, ensure_ascii=False)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _topic_test_record_id(topic: str, test_id: str, record) -> str:
|
|
98
|
+
"""按测试执行器的机器事实公式复算主题测试记录编号。"""
|
|
99
|
+
payload = (
|
|
100
|
+
f"{topic}|{test_id}|{record.started_at}|{record.finished_at}|"
|
|
101
|
+
f"{record.exit_code}|{record.output_sha256}"
|
|
102
|
+
)
|
|
103
|
+
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:8]
|
|
104
|
+
compact_time = (record.started_at or "").replace(":", "").replace("-", "")
|
|
105
|
+
return f"RUN-{compact_time}-{digest}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _section(content: str, heading: str) -> str | None:
|
|
109
|
+
match = re.search(
|
|
110
|
+
rf"^##\s+{re.escape(heading)}\s*$\n(.*?)(?=^##\s+|\Z)",
|
|
111
|
+
content,
|
|
112
|
+
re.MULTILINE | re.DOTALL,
|
|
113
|
+
)
|
|
114
|
+
return match.group(1).strip() if match else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _acceptance_criterion_sections(content: str) -> list[tuple[str, str]]:
|
|
118
|
+
"""拆出“验收条件”章节中的每条 AC,供固定字段校验。"""
|
|
119
|
+
criteria_content = _section(content, "4. 验收条件")
|
|
120
|
+
if criteria_content is None:
|
|
121
|
+
return []
|
|
122
|
+
matches = list(ACCEPTANCE_CRITERION_RE.finditer(criteria_content))
|
|
123
|
+
sections: list[tuple[str, str]] = []
|
|
124
|
+
for index, match in enumerate(matches):
|
|
125
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(criteria_content)
|
|
126
|
+
sections.append((match.group(1), criteria_content[match.end() : end].strip()))
|
|
127
|
+
return sections
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _acceptance_result_criterion_sections(content: str) -> list[tuple[str, str]]:
|
|
131
|
+
"""拆出主题验收结果中的每条 AC,检查它们是否全部明确通过。"""
|
|
132
|
+
criteria_content = _section(content, "2. 验收条件结果")
|
|
133
|
+
if criteria_content is None:
|
|
134
|
+
return []
|
|
135
|
+
matches = list(ACCEPTANCE_CRITERION_RE.finditer(criteria_content))
|
|
136
|
+
sections: list[tuple[str, str]] = []
|
|
137
|
+
for index, match in enumerate(matches):
|
|
138
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(criteria_content)
|
|
139
|
+
sections.append((match.group(1), criteria_content[match.end() : end].strip()))
|
|
140
|
+
return sections
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _workflow_section(content: str, workflow_id: str) -> str | None:
|
|
144
|
+
match = re.search(
|
|
145
|
+
rf"^##\s+{re.escape(workflow_id)}\s*$\n(.*?)(?=^##\s+|\Z)",
|
|
146
|
+
content,
|
|
147
|
+
re.MULTILINE | re.DOTALL,
|
|
148
|
+
)
|
|
149
|
+
return match.group(1).strip() if match else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _markdown_table_rows(content: str) -> list[list[str]]:
|
|
153
|
+
rows: list[list[str]] = []
|
|
154
|
+
for line in content.splitlines():
|
|
155
|
+
stripped = line.strip()
|
|
156
|
+
if not stripped.startswith("|") or not stripped.endswith("|"):
|
|
157
|
+
continue
|
|
158
|
+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
159
|
+
if not cells or all(re.fullmatch(r"[-:]+", cell) for cell in cells):
|
|
160
|
+
continue
|
|
161
|
+
rows.append(cells)
|
|
162
|
+
return rows
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _markdown_tables(content: str) -> list[tuple[list[str], list[list[str]]]]:
|
|
166
|
+
"""按表头、数据行拆出 Markdown 表格。"""
|
|
167
|
+
lines = content.splitlines()
|
|
168
|
+
tables: list[tuple[list[str], list[list[str]]]] = []
|
|
169
|
+
index = 0
|
|
170
|
+
while index + 1 < len(lines):
|
|
171
|
+
header_line = lines[index].strip()
|
|
172
|
+
separator_line = lines[index + 1].strip()
|
|
173
|
+
if not (
|
|
174
|
+
header_line.startswith("|")
|
|
175
|
+
and header_line.endswith("|")
|
|
176
|
+
and separator_line.startswith("|")
|
|
177
|
+
and separator_line.endswith("|")
|
|
178
|
+
):
|
|
179
|
+
index += 1
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
headers = [cell.strip() for cell in header_line.strip("|").split("|")]
|
|
183
|
+
separators = [
|
|
184
|
+
cell.strip() for cell in separator_line.strip("|").split("|")
|
|
185
|
+
]
|
|
186
|
+
if (
|
|
187
|
+
len(headers) != len(separators)
|
|
188
|
+
or not headers
|
|
189
|
+
or not all(re.fullmatch(r":?-{3,}:?", cell) for cell in separators)
|
|
190
|
+
):
|
|
191
|
+
index += 1
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
rows: list[list[str]] = []
|
|
195
|
+
index += 2
|
|
196
|
+
while index < len(lines):
|
|
197
|
+
row_line = lines[index].strip()
|
|
198
|
+
if not (row_line.startswith("|") and row_line.endswith("|")):
|
|
199
|
+
break
|
|
200
|
+
row = [cell.strip() for cell in row_line.strip("|").split("|")]
|
|
201
|
+
if len(row) == len(headers):
|
|
202
|
+
rows.append(row)
|
|
203
|
+
index += 1
|
|
204
|
+
tables.append((headers, rows))
|
|
205
|
+
return tables
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _has_real_text(value: str | None, *, allow_none: bool = False) -> bool:
|
|
209
|
+
if value is None:
|
|
210
|
+
return False
|
|
211
|
+
normalized = value.strip()
|
|
212
|
+
if not normalized:
|
|
213
|
+
return False
|
|
214
|
+
if allow_none and normalized in {"无", "暂无"}:
|
|
215
|
+
return True
|
|
216
|
+
lowered = normalized.lower()
|
|
217
|
+
return not (
|
|
218
|
+
normalized in {"无", "暂无", "待补充", "未填写"}
|
|
219
|
+
or "<" in normalized
|
|
220
|
+
or "todo" in lowered
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _is_safe_topic_name(value: str | None) -> bool:
|
|
225
|
+
"""主题显示名称不能包含路径分隔符或目录跳转(文件名另用文件标识)。"""
|
|
226
|
+
if not _has_real_text(value):
|
|
227
|
+
return False
|
|
228
|
+
normalized = value.strip()
|
|
229
|
+
return normalized not in {".", ".."} and os.path.basename(normalized) == normalized
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _machine_record_ids(content: str | None) -> set[str]:
|
|
233
|
+
"""从正式文档字段中解析机器测试和最终回归记录编号。"""
|
|
234
|
+
if not content:
|
|
235
|
+
return set()
|
|
236
|
+
trailing_punctuation = "。.!!??::;;,,))]}>\"'”’"
|
|
237
|
+
return {
|
|
238
|
+
match.group(0).rstrip(trailing_punctuation)
|
|
239
|
+
for match in MACHINE_RECORD_TOKEN_RE.finditer(content)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _reference_parts(raw_reference: str) -> tuple[str, str | None, str | None]:
|
|
244
|
+
"""拆出项目文件引用及其符号、锚点或行号目标。"""
|
|
245
|
+
value = raw_reference.strip()
|
|
246
|
+
if value.startswith("<") and value.endswith(">"):
|
|
247
|
+
value = value[1:-1].strip()
|
|
248
|
+
value = value.replace("\\", "/")
|
|
249
|
+
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", value):
|
|
250
|
+
return value, None, None
|
|
251
|
+
|
|
252
|
+
if "::" in value:
|
|
253
|
+
path, target = value.split("::", 1)
|
|
254
|
+
return path, "symbol", target
|
|
255
|
+
if "#" in value:
|
|
256
|
+
path, target = value.split("#", 1)
|
|
257
|
+
return path, "anchor", target
|
|
258
|
+
line_match = re.fullmatch(r"(.+):(\d+)", value)
|
|
259
|
+
if line_match:
|
|
260
|
+
return line_match.group(1), "line", line_match.group(2)
|
|
261
|
+
return value, None, None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _resolve_project_reference(
|
|
265
|
+
project_root: str,
|
|
266
|
+
raw_reference: str,
|
|
267
|
+
) -> tuple[str, str | None, str | None] | None:
|
|
268
|
+
"""把架构文档中的文件引用解析为项目根下真实文件。"""
|
|
269
|
+
path_text, target_kind, target = _reference_parts(raw_reference)
|
|
270
|
+
if (
|
|
271
|
+
not path_text
|
|
272
|
+
or os.path.isabs(path_text)
|
|
273
|
+
or re.match(r"^[A-Za-z]:/", path_text)
|
|
274
|
+
or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", path_text)
|
|
275
|
+
):
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
if path_text.startswith(("./", "../")):
|
|
279
|
+
relative_path = os.path.normpath(os.path.join("spec", path_text))
|
|
280
|
+
else:
|
|
281
|
+
relative_path = os.path.normpath(path_text)
|
|
282
|
+
project_root_abs = os.path.abspath(project_root)
|
|
283
|
+
full_path = os.path.abspath(os.path.join(project_root_abs, relative_path))
|
|
284
|
+
try:
|
|
285
|
+
if os.path.commonpath([project_root_abs, full_path]) != project_root_abs:
|
|
286
|
+
return None
|
|
287
|
+
except ValueError:
|
|
288
|
+
return None
|
|
289
|
+
if not os.path.isfile(full_path):
|
|
290
|
+
return None
|
|
291
|
+
return relative_path.replace(os.sep, "/"), target_kind, target
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _looks_like_file_reference(raw_reference: str) -> bool:
|
|
295
|
+
"""判断一个 Markdown 标识是否在声明文件路径,而不是代码符号。"""
|
|
296
|
+
path_text, _, _ = _reference_parts(raw_reference)
|
|
297
|
+
suffix = os.path.splitext(path_text)[1].lower()
|
|
298
|
+
known_non_code_suffixes = {
|
|
299
|
+
".md", ".json", ".toml", ".yaml", ".yml", ".ini", ".cfg",
|
|
300
|
+
".sh", ".ps1", ".bat", ".cmd", ".pro", ".pri", ".cmake",
|
|
301
|
+
}
|
|
302
|
+
return (
|
|
303
|
+
"/" in path_text
|
|
304
|
+
or "\\" in raw_reference
|
|
305
|
+
or suffix in CODE_SUFFIXES
|
|
306
|
+
or suffix in known_non_code_suffixes
|
|
307
|
+
or os.path.basename(path_text) in {"Makefile", "CMakeLists.txt"}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _reference_candidates(content: str) -> list[str]:
|
|
312
|
+
"""提取反引号、Markdown 链接和裸写项目路径中的文件引用候选。"""
|
|
313
|
+
candidates = list(re.findall(r"`([^`\n]+)`", content))
|
|
314
|
+
candidates.extend(re.findall(r"\[[^\]]+\]\(([^)]+)\)", content))
|
|
315
|
+
raw_content = re.sub(r"\[[^\]]+\]\([^)]+\)", "", content)
|
|
316
|
+
candidates.extend(
|
|
317
|
+
re.findall(
|
|
318
|
+
r"(?<![A-Za-z0-9_.-])"
|
|
319
|
+
r"(?:\.{0,2}/)?"
|
|
320
|
+
r"(?:[A-Za-z0-9_\-\u3400-\u9fff]+/)+"
|
|
321
|
+
r"[A-Za-z0-9_.\-\u3400-\u9fff]+"
|
|
322
|
+
r"(?:(?:::|#)[A-Za-z0-9_.:()\-\u3400-\u9fff]+|:\d+)?",
|
|
323
|
+
raw_content,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
return list(dict.fromkeys(candidate.strip() for candidate in candidates if candidate.strip()))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _declared_symbol(raw_symbol: str) -> str | None:
|
|
330
|
+
"""解析正式代码位置中声明的类、函数、方法、常量或配置项。"""
|
|
331
|
+
value = raw_symbol.strip()
|
|
332
|
+
value = re.sub(r"\([^()]*\)$", "", value).strip()
|
|
333
|
+
if re.fullmatch(r"--[a-z0-9][a-z0-9-]*", value):
|
|
334
|
+
return value
|
|
335
|
+
if re.fullmatch(
|
|
336
|
+
r"[A-Za-z_][A-Za-z0-9_]*(?:(?:\.|::)[A-Za-z_][A-Za-z0-9_]*)*",
|
|
337
|
+
value,
|
|
338
|
+
):
|
|
339
|
+
return value
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _symbol_is_locatable(file_content: str, raw_symbol: str) -> bool:
|
|
344
|
+
"""以完整标识边界检查符号,避免 `run` 误命中 `runner`。"""
|
|
345
|
+
value = raw_symbol.strip()
|
|
346
|
+
value = re.sub(r"\[[^\]]*\]$", "", value)
|
|
347
|
+
value = re.sub(r"\([^()]*\)$", "", value).strip()
|
|
348
|
+
if value.startswith("--"):
|
|
349
|
+
return re.search(
|
|
350
|
+
rf"(?<![A-Za-z0-9-]){re.escape(value)}(?![A-Za-z0-9-])",
|
|
351
|
+
file_content,
|
|
352
|
+
) is not None
|
|
353
|
+
lookup = re.split(r"::|\.", value)[-1]
|
|
354
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", lookup):
|
|
355
|
+
return False
|
|
356
|
+
return re.search(
|
|
357
|
+
rf"(?<![A-Za-z0-9_]){re.escape(lookup)}(?![A-Za-z0-9_])",
|
|
358
|
+
file_content,
|
|
359
|
+
) is not None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _markdown_anchor_exists(file_content: str, anchor: str) -> bool:
|
|
363
|
+
"""检查 Markdown 标题锚点或显式 HTML id 是否真实存在。"""
|
|
364
|
+
normalized_anchor = anchor.strip().lstrip("#").lower()
|
|
365
|
+
if not normalized_anchor:
|
|
366
|
+
return False
|
|
367
|
+
if re.search(
|
|
368
|
+
rf"\bid\s*=\s*['\"]{re.escape(normalized_anchor)}['\"]",
|
|
369
|
+
file_content,
|
|
370
|
+
re.IGNORECASE,
|
|
371
|
+
):
|
|
372
|
+
return True
|
|
373
|
+
for heading in re.findall(r"^#{1,6}\s+(.+?)\s*#*\s*$", file_content, re.MULTILINE):
|
|
374
|
+
heading = re.sub(r"`([^`]+)`", r"\1", heading)
|
|
375
|
+
slug = re.sub(r"[^\w\u3400-\u9fff -]", "", heading.lower())
|
|
376
|
+
slug = re.sub(r"\s+", "-", slug.strip())
|
|
377
|
+
if slug == normalized_anchor:
|
|
378
|
+
return True
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _reference_target_is_locatable(
|
|
383
|
+
project_root: str,
|
|
384
|
+
reference: tuple[str, str | None, str | None],
|
|
385
|
+
fallback_symbols: list[str],
|
|
386
|
+
) -> bool:
|
|
387
|
+
"""检查文件引用中的行号、锚点、符号或同单元格符号。"""
|
|
388
|
+
relative_path, target_kind, target = reference
|
|
389
|
+
file_content = _read_text(project_root, relative_path)
|
|
390
|
+
if target_kind == "line" and target is not None:
|
|
391
|
+
line_number = int(target)
|
|
392
|
+
return 1 <= line_number <= len(file_content.splitlines())
|
|
393
|
+
if target_kind == "anchor" and target is not None:
|
|
394
|
+
line_match = re.fullmatch(r"L(\d+)", target, re.IGNORECASE)
|
|
395
|
+
if line_match:
|
|
396
|
+
line_number = int(line_match.group(1))
|
|
397
|
+
return 1 <= line_number <= len(file_content.splitlines())
|
|
398
|
+
return _markdown_anchor_exists(file_content, target)
|
|
399
|
+
if target_kind == "symbol" and target is not None:
|
|
400
|
+
return _symbol_is_locatable(file_content, target)
|
|
401
|
+
return any(_symbol_is_locatable(file_content, symbol) for symbol in fallback_symbols)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _feature_code_mappings(
|
|
405
|
+
project_root: str,
|
|
406
|
+
section: str,
|
|
407
|
+
) -> tuple[bool, str, list[str]]:
|
|
408
|
+
"""逐个校验正式功能表中声明的代码文件和全部反引号符号。"""
|
|
409
|
+
mapped_paths: set[str] = set()
|
|
410
|
+
mapping_cells = 0
|
|
411
|
+
for headers, rows in _markdown_tables(section):
|
|
412
|
+
code_indexes = [
|
|
413
|
+
index
|
|
414
|
+
for index, header in enumerate(headers)
|
|
415
|
+
if header.strip("* `") in {"代码位置", "对应代码"}
|
|
416
|
+
]
|
|
417
|
+
for row in rows:
|
|
418
|
+
for code_index in code_indexes:
|
|
419
|
+
mapping_cells += 1
|
|
420
|
+
cell = row[code_index]
|
|
421
|
+
if not _has_real_text(cell):
|
|
422
|
+
return False, "代码位置存在空值或模板占位符", []
|
|
423
|
+
|
|
424
|
+
references: list[tuple[str, str | None, str | None]] = []
|
|
425
|
+
symbols: list[str] = []
|
|
426
|
+
for token in _reference_candidates(cell):
|
|
427
|
+
resolved = _resolve_project_reference(project_root, token)
|
|
428
|
+
if resolved is not None:
|
|
429
|
+
suffix = os.path.splitext(resolved[0])[1].lower()
|
|
430
|
+
if suffix not in CODE_SUFFIXES:
|
|
431
|
+
continue
|
|
432
|
+
if resolved[0].startswith("tests/"):
|
|
433
|
+
return False, f"代码位置不能用测试文件代替产品代码: {resolved[0]}", []
|
|
434
|
+
references.append(resolved)
|
|
435
|
+
mapped_paths.add(resolved[0])
|
|
436
|
+
if resolved[1] == "symbol" and resolved[2]:
|
|
437
|
+
symbols.append(resolved[2])
|
|
438
|
+
continue
|
|
439
|
+
if _looks_like_file_reference(token):
|
|
440
|
+
path_text, _, _ = _reference_parts(token)
|
|
441
|
+
if os.path.splitext(path_text)[1].lower() in CODE_SUFFIXES:
|
|
442
|
+
return False, f"代码位置引用的文件不存在或不在项目内: {token}", []
|
|
443
|
+
|
|
444
|
+
symbol = _declared_symbol(token)
|
|
445
|
+
if symbol is not None:
|
|
446
|
+
symbols.append(symbol)
|
|
447
|
+
|
|
448
|
+
references = list(dict.fromkeys(references))
|
|
449
|
+
symbols = list(dict.fromkeys(symbols))
|
|
450
|
+
if not references:
|
|
451
|
+
return False, f"代码位置没有项目内真实代码文件: {cell}", []
|
|
452
|
+
if not symbols:
|
|
453
|
+
return False, f"代码位置没有反引号可定位符号: {cell}", []
|
|
454
|
+
|
|
455
|
+
file_contents = {
|
|
456
|
+
relative_path: _read_text(project_root, relative_path)
|
|
457
|
+
for relative_path, _, _ in references
|
|
458
|
+
}
|
|
459
|
+
for symbol in symbols:
|
|
460
|
+
if not any(
|
|
461
|
+
_symbol_is_locatable(file_content, symbol)
|
|
462
|
+
for file_content in file_contents.values()
|
|
463
|
+
):
|
|
464
|
+
return (
|
|
465
|
+
False,
|
|
466
|
+
f"代码符号 `{symbol}` 无法在同一代码位置声明的文件中定位",
|
|
467
|
+
[],
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
if mapping_cells == 0 or not mapped_paths:
|
|
471
|
+
return False, "功能段没有按正式表格填写代码位置和对应代码", []
|
|
472
|
+
return True, "", sorted(mapped_paths)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _feature_verification_locations(
|
|
476
|
+
project_root: str,
|
|
477
|
+
section: str,
|
|
478
|
+
) -> tuple[bool, str]:
|
|
479
|
+
"""逐格校验验证位置指向项目内真实文件和可定位目标。"""
|
|
480
|
+
verification_cells = 0
|
|
481
|
+
for headers, rows in _markdown_tables(section):
|
|
482
|
+
verification_indexes = [
|
|
483
|
+
index
|
|
484
|
+
for index, header in enumerate(headers)
|
|
485
|
+
if header.strip("* `") in {"验证位置", "验证依据"}
|
|
486
|
+
]
|
|
487
|
+
for row in rows:
|
|
488
|
+
for verification_index in verification_indexes:
|
|
489
|
+
verification_cells += 1
|
|
490
|
+
cell = row[verification_index]
|
|
491
|
+
if not _has_real_text(cell):
|
|
492
|
+
return False, "验证位置存在空值或模板占位符"
|
|
493
|
+
|
|
494
|
+
references: list[tuple[str, str | None, str | None]] = []
|
|
495
|
+
fallback_symbols: list[str] = []
|
|
496
|
+
for token in _reference_candidates(cell):
|
|
497
|
+
resolved = _resolve_project_reference(project_root, token)
|
|
498
|
+
if resolved is not None:
|
|
499
|
+
references.append(resolved)
|
|
500
|
+
continue
|
|
501
|
+
if _looks_like_file_reference(token):
|
|
502
|
+
return False, f"验证位置引用的文件不存在或不在项目内: {token}"
|
|
503
|
+
symbol = _declared_symbol(token)
|
|
504
|
+
if symbol is not None:
|
|
505
|
+
fallback_symbols.append(symbol)
|
|
506
|
+
|
|
507
|
+
references = list(dict.fromkeys(references))
|
|
508
|
+
fallback_symbols = list(dict.fromkeys(fallback_symbols))
|
|
509
|
+
if not references:
|
|
510
|
+
return False, f"验证位置没有项目内真实文件: {cell}"
|
|
511
|
+
for reference in references:
|
|
512
|
+
if not _reference_target_is_locatable(
|
|
513
|
+
project_root,
|
|
514
|
+
reference,
|
|
515
|
+
fallback_symbols,
|
|
516
|
+
):
|
|
517
|
+
return (
|
|
518
|
+
False,
|
|
519
|
+
f"验证位置没有可定位的行号、标题、测试函数或运行入口: {cell}",
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
if verification_cells == 0:
|
|
523
|
+
return False, "功能段没有按正式表格填写验证位置或验证依据"
|
|
524
|
+
return True, ""
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _required_final_machine_record_ids(wf_state) -> tuple[set[str] | None, str]:
|
|
528
|
+
"""汇总最终设计核对依据必须精确列出的当前机器记录编号。"""
|
|
529
|
+
stage_state = wf_state.stages.get("topic_acceptance")
|
|
530
|
+
if stage_state is None:
|
|
531
|
+
return None, "缺少 topic_acceptance(主题验收阶段)状态"
|
|
532
|
+
|
|
533
|
+
required: set[str] = set()
|
|
534
|
+
for topic in wf_state.topics:
|
|
535
|
+
for criterion_id, record in stage_state.acceptance_records.get(topic, {}).items():
|
|
536
|
+
if not acceptance_records_mod.record_is_current(record, wf_state):
|
|
537
|
+
return None, f"{topic} / {criterion_id} 的程序验收记录已经失效"
|
|
538
|
+
required.update(record.test_record_ids)
|
|
539
|
+
|
|
540
|
+
regression = wf_state.regression_test
|
|
541
|
+
if regression.status != "passed" or not regression.record_id:
|
|
542
|
+
return None, "最终全量回归没有当前有效的机器记录编号"
|
|
543
|
+
required.add(regression.record_id)
|
|
544
|
+
return required, ""
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _architecture_feature_sections(content: str) -> list[str]:
|
|
548
|
+
"""拆出“各产品功能的代码设计”中的每个 6.x 功能段。"""
|
|
549
|
+
feature_content = _section(content, "6. 各产品功能的代码设计") or ""
|
|
550
|
+
matches = list(re.finditer(r"^###\s+6\.\d+\s+.+$", feature_content, re.MULTILINE))
|
|
551
|
+
sections: list[str] = []
|
|
552
|
+
for index, match in enumerate(matches):
|
|
553
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(feature_content)
|
|
554
|
+
sections.append(feature_content[match.start() : end].strip())
|
|
555
|
+
return sections
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def changed_stage_paths(
|
|
559
|
+
project_root: str,
|
|
560
|
+
stage_name: str,
|
|
561
|
+
current_paths: list[str],
|
|
562
|
+
) -> tuple[bool, str, list[str]]:
|
|
563
|
+
"""比较讨论完成时的基线,返回本阶段新增、修改或删除的文件。"""
|
|
564
|
+
state = load_state(project_root)
|
|
565
|
+
if state is None or stage_name not in state.stages:
|
|
566
|
+
return (False, "找不到当前工作流阶段状态,无法判断文件是否属于本次工作", [])
|
|
567
|
+
|
|
568
|
+
stage_state = state.stages[stage_name]
|
|
569
|
+
if stage_state.artifact_baseline_captured_at is None:
|
|
570
|
+
return (False, "没有阶段产物基线;请先执行 --discuss-done,再修改产物文件", [])
|
|
571
|
+
|
|
572
|
+
current_hashes = compute_file_hashes(project_root, current_paths)
|
|
573
|
+
baseline_hashes = stage_state.artifact_baseline_hashes
|
|
574
|
+
all_paths = sorted(set(baseline_hashes) | set(current_hashes))
|
|
575
|
+
changed = [
|
|
576
|
+
rel_path
|
|
577
|
+
for rel_path in all_paths
|
|
578
|
+
if baseline_hashes.get(rel_path) != current_hashes.get(rel_path)
|
|
579
|
+
]
|
|
580
|
+
if not changed:
|
|
581
|
+
return (False, "相关文件与讨论完成时相同,不能证明本阶段已经生成或修改产物", [])
|
|
582
|
+
return (True, f"本阶段发生变化的文件: {changed}", changed)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def validate_project_design_init_evidence(
|
|
586
|
+
project_root: str,
|
|
587
|
+
workflow_id: str,
|
|
588
|
+
) -> tuple[bool, str]:
|
|
589
|
+
full_path = os.path.join(project_root, PROJECT_INIT_EVIDENCE_PATH)
|
|
590
|
+
if not os.path.isfile(full_path):
|
|
591
|
+
return (False, f"缺少 {PROJECT_INIT_EVIDENCE_PATH}")
|
|
592
|
+
|
|
593
|
+
content = _read_text(project_root, PROJECT_INIT_EVIDENCE_PATH)
|
|
594
|
+
if _field(content, "工作流编号") != workflow_id:
|
|
595
|
+
return (False, "项目设计初始化证据中的工作流编号与当前工作流不一致")
|
|
596
|
+
if _field(content, "代码检查状态") != "已完成":
|
|
597
|
+
return (False, "项目设计初始化证据必须写“代码检查状态:已完成”")
|
|
598
|
+
|
|
599
|
+
code_section = _section(content, "1. 已检查代码")
|
|
600
|
+
if not code_section:
|
|
601
|
+
return (False, "项目设计初始化证据缺少“1. 已检查代码”内容")
|
|
602
|
+
|
|
603
|
+
checked_code_paths: list[str] = []
|
|
604
|
+
for line in code_section.splitlines():
|
|
605
|
+
if not line.strip().startswith("|"):
|
|
606
|
+
continue
|
|
607
|
+
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
608
|
+
if len(cells) < 3 or cells[0] in {"代码路径", "---", "-"}:
|
|
609
|
+
continue
|
|
610
|
+
raw_path = cells[0].strip("`")
|
|
611
|
+
rel_path = re.sub(r"(?::\d+|#L\d+)$", "", raw_path)
|
|
612
|
+
full_code_path = os.path.join(project_root, rel_path)
|
|
613
|
+
if os.path.isfile(full_code_path) and os.path.splitext(rel_path)[1].lower() in CODE_SUFFIXES:
|
|
614
|
+
checked_code_paths.append(rel_path)
|
|
615
|
+
else:
|
|
616
|
+
return (False, f"已检查代码中包含不存在或不是代码文件的路径: {raw_path}")
|
|
617
|
+
if not _has_real_text(cells[1]) or not _has_real_text(cells[2]):
|
|
618
|
+
return (False, f"已检查代码必须写清检查内容和得到的事实: {raw_path}")
|
|
619
|
+
|
|
620
|
+
if not checked_code_paths:
|
|
621
|
+
return (False, "项目设计初始化证据至少要列出一个实际检查过的代码文件")
|
|
622
|
+
|
|
623
|
+
run_condition = _field(content, "运行条件")
|
|
624
|
+
run_status = _field(content, "执行状态")
|
|
625
|
+
run_result = _field(content, "执行结果")
|
|
626
|
+
command = _field(content, "执行命令")
|
|
627
|
+
result_summary = _field(content, "结果摘要")
|
|
628
|
+
unavailable_reason = _field(content, "未执行原因")
|
|
629
|
+
unverified_scope = _field(content, "未验证范围")
|
|
630
|
+
|
|
631
|
+
if run_condition == "具备":
|
|
632
|
+
if run_status != "已执行":
|
|
633
|
+
return (False, "运行条件写“具备”时,执行状态必须是“已执行”")
|
|
634
|
+
if run_result not in {"通过", "失败", "部分通过"}:
|
|
635
|
+
return (False, "已经执行时,执行结果必须是“通过”“失败”或“部分通过”")
|
|
636
|
+
if not _has_real_text(command) or not _has_real_text(result_summary):
|
|
637
|
+
return (False, "已经执行时必须写清实际命令和结果摘要")
|
|
638
|
+
elif run_condition == "不具备":
|
|
639
|
+
if run_status != "未执行" or run_result != "未执行":
|
|
640
|
+
return (False, "运行条件写“不具备”时,执行状态和执行结果都必须是“未执行”")
|
|
641
|
+
if not _has_real_text(unavailable_reason) or not _has_real_text(unverified_scope):
|
|
642
|
+
return (False, "无法运行时必须写清未执行原因和未验证范围")
|
|
643
|
+
else:
|
|
644
|
+
return (False, "运行条件只能写“具备”或“不具备”")
|
|
645
|
+
|
|
646
|
+
calibration = _section(content, "3. 产品与代码设计校准结果")
|
|
647
|
+
if not _has_real_text(calibration):
|
|
648
|
+
return (False, "必须写清产品文档与代码设计文档怎样根据调查结果完成校准")
|
|
649
|
+
|
|
650
|
+
return (True, f"项目设计初始化调查证据有效,已核对代码文件: {checked_code_paths}")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def validate_final_code_design_document(
|
|
654
|
+
project_root: str,
|
|
655
|
+
workflow_id: str,
|
|
656
|
+
) -> tuple[bool, str]:
|
|
657
|
+
"""校验最终架构文档已经完成产品、功能、架构和真实代码映射。
|
|
658
|
+
|
|
659
|
+
功能范围以产品总说明当前链接的功能文档为准;每个功能必须唯一匹配一个
|
|
660
|
+
6.x 功能段,映射真实代码文件、可定位符号和验证位置,并拒绝计划性表述。
|
|
661
|
+
"""
|
|
662
|
+
relative_path = artifact_paths_mod.CODE_DESIGN_DOC
|
|
663
|
+
full_path = os.path.join(project_root, relative_path)
|
|
664
|
+
if not os.path.isfile(full_path):
|
|
665
|
+
return False, f"{relative_path} 不存在"
|
|
666
|
+
|
|
667
|
+
content = _read_text(project_root, relative_path)
|
|
668
|
+
product_path = os.path.join(project_root, artifact_paths_mod.PRODUCT_OVERVIEW_DOC)
|
|
669
|
+
if not os.path.isfile(product_path):
|
|
670
|
+
return False, f"{artifact_paths_mod.PRODUCT_OVERVIEW_DOC} 不存在,无法核对最终产品设计"
|
|
671
|
+
missing_sections = [
|
|
672
|
+
heading
|
|
673
|
+
for heading in ARCHITECTURE_DOCUMENT_SECTIONS
|
|
674
|
+
if _section(content, heading) is None
|
|
675
|
+
]
|
|
676
|
+
if missing_sections:
|
|
677
|
+
return False, f"{relative_path} 缺少章节: {missing_sections}"
|
|
678
|
+
|
|
679
|
+
# 功能范围以产品总说明当前链接的功能文档为准,不扫描目录里的废弃文档
|
|
680
|
+
feature_paths = [
|
|
681
|
+
path
|
|
682
|
+
for path in get_linked_product_design_paths(project_root)
|
|
683
|
+
if path != artifact_paths_mod.PRODUCT_OVERVIEW_DOC
|
|
684
|
+
]
|
|
685
|
+
if not feature_paths:
|
|
686
|
+
return False, "产品总说明没有链接任何功能文档,无法建立功能到代码映射"
|
|
687
|
+
|
|
688
|
+
feature_sections = _architecture_feature_sections(content)
|
|
689
|
+
if not feature_sections:
|
|
690
|
+
return False, "最终架构文档没有按产品功能编写 6.x 功能代码设计"
|
|
691
|
+
|
|
692
|
+
for feature_path in feature_paths:
|
|
693
|
+
feature_name = os.path.basename(feature_path)
|
|
694
|
+
matching_sections = [section for section in feature_sections if feature_name in section]
|
|
695
|
+
if len(matching_sections) != 1:
|
|
696
|
+
return False, f"最终架构文档必须唯一映射功能文档: {feature_path}"
|
|
697
|
+
section = matching_sections[0]
|
|
698
|
+
mapping_ok, mapping_detail, mapped_code_paths = _feature_code_mappings(
|
|
699
|
+
project_root,
|
|
700
|
+
section,
|
|
701
|
+
)
|
|
702
|
+
if not mapping_ok:
|
|
703
|
+
return (
|
|
704
|
+
False,
|
|
705
|
+
f"最终架构文档的功能代码映射无效: {feature_path};{mapping_detail}",
|
|
706
|
+
)
|
|
707
|
+
verification_ok, verification_detail = _feature_verification_locations(
|
|
708
|
+
project_root,
|
|
709
|
+
section,
|
|
710
|
+
)
|
|
711
|
+
if not verification_ok:
|
|
712
|
+
return (
|
|
713
|
+
False,
|
|
714
|
+
f"最终架构文档的功能验证位置无效: {feature_path};{verification_detail}",
|
|
715
|
+
)
|
|
716
|
+
forbidden = [word for word in FINAL_DESIGN_FORBIDDEN_WORDS if word in section]
|
|
717
|
+
if forbidden:
|
|
718
|
+
return (
|
|
719
|
+
False,
|
|
720
|
+
f"最终架构文档功能段不能把计划性表述写成最终事实({forbidden}): {feature_path}",
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
feature_code_section = _section(content, "6. 各产品功能的代码设计") or ""
|
|
724
|
+
if "代码位置" not in feature_code_section:
|
|
725
|
+
return False, "最终架构文档的功能代码设计缺少“代码位置”"
|
|
726
|
+
|
|
727
|
+
sync_section = _section(content, "9. 最终同步结论") or ""
|
|
728
|
+
expected_fields = {
|
|
729
|
+
"工作流编号": workflow_id,
|
|
730
|
+
"产品设计核对": "一致",
|
|
731
|
+
"功能文档核对": "一致",
|
|
732
|
+
"代码实现核对": "一致",
|
|
733
|
+
"功能到代码映射": "完整",
|
|
734
|
+
"未处理差异": "暂无",
|
|
735
|
+
}
|
|
736
|
+
for label, expected in expected_fields.items():
|
|
737
|
+
actual = _field(sync_section, label)
|
|
738
|
+
if actual != expected:
|
|
739
|
+
return False, f"最终同步结论的“{label}”必须是“{expected}”,当前是“{actual or '缺少'}”"
|
|
740
|
+
|
|
741
|
+
sync_type = _field(sync_section, "本次同步类型")
|
|
742
|
+
if sync_type not in {"架构变化", "架构未变化"}:
|
|
743
|
+
return False, "最终同步结论的“本次同步类型”只能写“架构变化”或“架构未变化”"
|
|
744
|
+
verification_basis = _field(sync_section, "核对依据")
|
|
745
|
+
if not _has_real_text(verification_basis):
|
|
746
|
+
return False, "最终同步结论必须写清可以复核的核对依据"
|
|
747
|
+
state = load_state(project_root)
|
|
748
|
+
if state is None or state.workflow_id != workflow_id:
|
|
749
|
+
return False, "找不到当前工作流状态,无法核对机器记录编号"
|
|
750
|
+
required_record_ids, record_detail = _required_final_machine_record_ids(state)
|
|
751
|
+
if required_record_ids is None:
|
|
752
|
+
return False, record_detail
|
|
753
|
+
actual_record_ids = _machine_record_ids(verification_basis)
|
|
754
|
+
if actual_record_ids != required_record_ids:
|
|
755
|
+
missing_ids = sorted(required_record_ids - actual_record_ids)
|
|
756
|
+
extra_ids = sorted(actual_record_ids - required_record_ids)
|
|
757
|
+
return (
|
|
758
|
+
False,
|
|
759
|
+
"最终同步结论的核对依据必须精确列出当前机器记录编号;"
|
|
760
|
+
f"缺少={missing_ids},额外或拼接错误={extra_ids}",
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
return (
|
|
764
|
+
True,
|
|
765
|
+
f"最终设计同步有效:已核对 {len(feature_paths)} 个功能,并完成真实代码映射",
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def validate_reproduce_documents(
|
|
770
|
+
project_root: str,
|
|
771
|
+
changed_paths: list[str],
|
|
772
|
+
workflow_id: str,
|
|
773
|
+
) -> tuple[bool, str]:
|
|
774
|
+
index_path = artifact_paths_mod.BUG_INDEX_DOC
|
|
775
|
+
normalized_changed = [path.replace(os.sep, "/") for path in changed_paths]
|
|
776
|
+
if index_path not in normalized_changed:
|
|
777
|
+
return (False, f"{index_path} 没有在本阶段更新")
|
|
778
|
+
if not os.path.isfile(os.path.join(project_root, index_path)):
|
|
779
|
+
return (False, f"{index_path} 不存在")
|
|
780
|
+
|
|
781
|
+
changed_bug_docs = [
|
|
782
|
+
rel_path
|
|
783
|
+
for rel_path in normalized_changed
|
|
784
|
+
if rel_path.startswith("bug/")
|
|
785
|
+
and os.path.basename(rel_path) not in BUG_INDEX_FILENAMES
|
|
786
|
+
and os.path.isfile(os.path.join(project_root, rel_path))
|
|
787
|
+
and rel_path.endswith(".md")
|
|
788
|
+
]
|
|
789
|
+
if not changed_bug_docs:
|
|
790
|
+
return (False, "本阶段没有新增或修改缺陷记录文档")
|
|
791
|
+
|
|
792
|
+
index_content = _read_text(project_root, index_path)
|
|
793
|
+
required_sections = [
|
|
794
|
+
"1. 缺陷现象",
|
|
795
|
+
"2. 真实复现条件",
|
|
796
|
+
"3. 复现步骤",
|
|
797
|
+
"4. 实际结果",
|
|
798
|
+
"5. 期望结果",
|
|
799
|
+
"6. 根因",
|
|
800
|
+
"7. 修复仍存在的不确定性",
|
|
801
|
+
]
|
|
802
|
+
|
|
803
|
+
topics: list[str] = []
|
|
804
|
+
for rel_path in changed_bug_docs:
|
|
805
|
+
filename = os.path.basename(rel_path)
|
|
806
|
+
if not BUG_FILENAME_RE.match(filename):
|
|
807
|
+
return (False, f"缺陷记录文件名必须是 缺陷_<缺陷文件标识>.md: {filename}")
|
|
808
|
+
if not re.search(rf"\((?:\./)?{re.escape(filename)}\)", index_content):
|
|
809
|
+
return (False, f"{index_path} 没有链接本次缺陷记录: {filename}")
|
|
810
|
+
|
|
811
|
+
content = _read_text(project_root, rel_path)
|
|
812
|
+
if _field(content, "工作流编号") != workflow_id:
|
|
813
|
+
return (False, f"{filename} 的工作流编号与当前工作流不一致")
|
|
814
|
+
if _field(content, "复现状态") != "已复现":
|
|
815
|
+
return (False, f"{filename} 必须写“复现状态:已复现”")
|
|
816
|
+
if _field(content, "根因状态") != "已确认":
|
|
817
|
+
return (False, f"{filename} 必须写“根因状态:已确认”")
|
|
818
|
+
topic = _field(content, "验收主题")
|
|
819
|
+
if not _is_safe_topic_name(topic):
|
|
820
|
+
return (False, f"{filename} 必须写清唯一验收主题")
|
|
821
|
+
topics.append(topic or "")
|
|
822
|
+
|
|
823
|
+
for heading in required_sections:
|
|
824
|
+
if not _has_real_text(_section(content, heading), allow_none=heading.startswith("7.")):
|
|
825
|
+
return (False, f"{filename} 的“{heading}”缺少具体内容")
|
|
826
|
+
|
|
827
|
+
condition_section = _section(content, "2. 真实复现条件") or ""
|
|
828
|
+
if not _has_real_text(_field(condition_section, "运行环境")):
|
|
829
|
+
return (False, f"{filename} 必须写清真实运行环境")
|
|
830
|
+
if not _has_real_text(_field(condition_section, "真实输入")):
|
|
831
|
+
return (False, f"{filename} 必须写清触发缺陷的真实输入")
|
|
832
|
+
|
|
833
|
+
root_section = _section(content, "6. 根因") or ""
|
|
834
|
+
for label in ("根因说明", "根因位置", "根因证据"):
|
|
835
|
+
if not _has_real_text(_field(root_section, label)):
|
|
836
|
+
return (False, f"{filename} 必须写清{label}")
|
|
837
|
+
|
|
838
|
+
duplicates = sorted({topic for topic in topics if topics.count(topic) > 1})
|
|
839
|
+
if duplicates:
|
|
840
|
+
return (False, f"多份缺陷记录使用了重复验收主题: {duplicates}")
|
|
841
|
+
|
|
842
|
+
return (
|
|
843
|
+
True,
|
|
844
|
+
f"本阶段缺陷记录已复现、确认根因并确定验收主题: {dict(zip(changed_bug_docs, topics))}",
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def validate_acceptance_plan_documents(
|
|
849
|
+
project_root: str,
|
|
850
|
+
workflow_id: str,
|
|
851
|
+
topics: list[str],
|
|
852
|
+
) -> tuple[bool, str]:
|
|
853
|
+
"""校验主题验收计划和当前工作流的需求交付追踪表。"""
|
|
854
|
+
index_ok, index_detail = validate_acceptance_index(
|
|
855
|
+
project_root,
|
|
856
|
+
workflow_id,
|
|
857
|
+
topics,
|
|
858
|
+
)
|
|
859
|
+
if not index_ok:
|
|
860
|
+
return False, index_detail
|
|
861
|
+
|
|
862
|
+
traceability_ok, traceability_detail = validate_traceability_structure(
|
|
863
|
+
project_root,
|
|
864
|
+
workflow_id,
|
|
865
|
+
topics,
|
|
866
|
+
require_initial_statuses=True,
|
|
867
|
+
)
|
|
868
|
+
if not traceability_ok:
|
|
869
|
+
return False, traceability_detail
|
|
870
|
+
|
|
871
|
+
traceability_full_path = os.path.join(project_root, TRACEABILITY_PATH)
|
|
872
|
+
if not os.path.isfile(traceability_full_path):
|
|
873
|
+
return (False, f"{TRACEABILITY_PATH} 不存在")
|
|
874
|
+
|
|
875
|
+
traceability_content = _read_text(project_root, TRACEABILITY_PATH)
|
|
876
|
+
workflow_content = _workflow_section(traceability_content, workflow_id)
|
|
877
|
+
if workflow_content is None:
|
|
878
|
+
return (False, f"{TRACEABILITY_PATH} 缺少当前工作流章节: {workflow_id}")
|
|
879
|
+
|
|
880
|
+
traceability_rows = [
|
|
881
|
+
row
|
|
882
|
+
for row in _markdown_table_rows(workflow_content)
|
|
883
|
+
if len(row) == 9 and row[0] != "需求来源与设计依据"
|
|
884
|
+
]
|
|
885
|
+
if not traceability_rows:
|
|
886
|
+
return (False, f"{TRACEABILITY_PATH} 当前工作流章节没有九列交付链路记录")
|
|
887
|
+
|
|
888
|
+
all_criteria: list[str] = []
|
|
889
|
+
for topic in topics:
|
|
890
|
+
paths = topic_paths(project_root, topic)
|
|
891
|
+
rel_path = paths["acceptance_plan"]
|
|
892
|
+
full_path = os.path.join(project_root, rel_path)
|
|
893
|
+
if not os.path.isfile(full_path):
|
|
894
|
+
return (False, f"缺少验收计划文档: {rel_path}")
|
|
895
|
+
|
|
896
|
+
content = _read_text(project_root, rel_path)
|
|
897
|
+
if _field(content, "验收主题") != topic:
|
|
898
|
+
return (False, f"{rel_path} 的验收主题显示名称必须是“{topic}”")
|
|
899
|
+
for heading in ACCEPTANCE_PLAN_SECTIONS:
|
|
900
|
+
if _section(content, heading) is None:
|
|
901
|
+
return (False, f"{rel_path} 缺少“{heading}”")
|
|
902
|
+
|
|
903
|
+
criterion_sections = _acceptance_criterion_sections(content)
|
|
904
|
+
criterion_ids = [criterion_id for criterion_id, _ in criterion_sections]
|
|
905
|
+
if not criterion_ids:
|
|
906
|
+
return (False, f"{rel_path} 至少需要一条 AC-01 形式的验收条件")
|
|
907
|
+
if len(criterion_ids) != len(set(criterion_ids)):
|
|
908
|
+
return (False, f"{rel_path} 存在重复验收条件编号")
|
|
909
|
+
for criterion_id, criterion_content in criterion_sections:
|
|
910
|
+
for label in ("条件与触发", "预期结果", "产品设计依据"):
|
|
911
|
+
if not _has_real_text(_field(criterion_content, label)):
|
|
912
|
+
return (False, f"{rel_path} 的 {criterion_id} 缺少具体“{label}”")
|
|
913
|
+
product_basis = _field(criterion_content, "产品设计依据") or ""
|
|
914
|
+
if re.search(r"\[[^\]]+\]\([^)]+\)", product_basis) is None:
|
|
915
|
+
return (False, f"{rel_path} 的 {criterion_id} 产品设计依据必须是可打开的链接")
|
|
916
|
+
if f"../{TRACEABILITY_PATH}" not in content:
|
|
917
|
+
return (False, f"{rel_path} 的上下游文档没有链接 ../{TRACEABILITY_PATH}")
|
|
918
|
+
test_plan_rel = paths["test_plan"]
|
|
919
|
+
if f"../{test_plan_rel}" not in content and os.path.basename(test_plan_rel) not in content:
|
|
920
|
+
return (False, f"{rel_path} 没有写下游测试计划路径 {test_plan_rel}")
|
|
921
|
+
|
|
922
|
+
for criterion_id in criterion_ids:
|
|
923
|
+
matching_rows = [
|
|
924
|
+
row
|
|
925
|
+
for row in traceability_rows
|
|
926
|
+
if paths["acceptance_plan"] in row[1] and criterion_id in row[2]
|
|
927
|
+
]
|
|
928
|
+
if len(matching_rows) != 1:
|
|
929
|
+
return (
|
|
930
|
+
False,
|
|
931
|
+
f"{TRACEABILITY_PATH} 中主题“{topic}”的 {criterion_id} 必须且只能占一行",
|
|
932
|
+
)
|
|
933
|
+
row = matching_rows[0]
|
|
934
|
+
expected_statuses = ["待制定", "待制定", "待执行", "待执行", "待执行", "待更新"]
|
|
935
|
+
if row[3:] != expected_statuses:
|
|
936
|
+
return (
|
|
937
|
+
False,
|
|
938
|
+
f"{TRACEABILITY_PATH} 中主题“{topic}”的 {criterion_id} 后续状态必须是 {expected_statuses}",
|
|
939
|
+
)
|
|
940
|
+
all_criteria.append(f"{topic}:{criterion_id}")
|
|
941
|
+
|
|
942
|
+
return (
|
|
943
|
+
True,
|
|
944
|
+
f"验收计划结构完整,追踪表逐条覆盖 {len(all_criteria)} 条验收条件: {topics}",
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _validate_topic_index_rows(
|
|
949
|
+
project_root: str,
|
|
950
|
+
workflow_id: str,
|
|
951
|
+
topics: list[str],
|
|
952
|
+
relative_path: str,
|
|
953
|
+
expected_headers: list[str],
|
|
954
|
+
expected_links: dict[str, str],
|
|
955
|
+
allowed_text_values: dict[str, set[str]] | None = None,
|
|
956
|
+
) -> tuple[bool, str, list]:
|
|
957
|
+
"""校验主题索引的主题集合、顺序、链接和前置关系。
|
|
958
|
+
|
|
959
|
+
expected_links 的值使用 "{key}" 占位符,按主题文件标识展开。
|
|
960
|
+
"""
|
|
961
|
+
|
|
962
|
+
try:
|
|
963
|
+
relations = read_topic_index(
|
|
964
|
+
project_root,
|
|
965
|
+
relative_path,
|
|
966
|
+
workflow_id,
|
|
967
|
+
expected_headers,
|
|
968
|
+
allowed_text_values,
|
|
969
|
+
)
|
|
970
|
+
except ValueError as exc:
|
|
971
|
+
return False, str(exc), []
|
|
972
|
+
|
|
973
|
+
actual_topics = [relation.topic for relation in relations]
|
|
974
|
+
if len(actual_topics) != len(set(actual_topics)):
|
|
975
|
+
return False, f"{relative_path} 存在重复验收主题", []
|
|
976
|
+
if set(actual_topics) != set(topics):
|
|
977
|
+
return (
|
|
978
|
+
False,
|
|
979
|
+
f"{relative_path} 的主题必须覆盖当前工作流全部主题;当前主题 {topics},索引主题 {actual_topics}",
|
|
980
|
+
[],
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
orders = [relation.order for relation in relations]
|
|
984
|
+
if sorted(orders) != list(range(1, len(relations) + 1)):
|
|
985
|
+
return False, f"{relative_path} 展示顺序必须从 1 开始连续编号", []
|
|
986
|
+
|
|
987
|
+
order_by_topic = {relation.topic: relation.order for relation in relations}
|
|
988
|
+
for relation in relations:
|
|
989
|
+
file_key = topic_file_key(project_root, relation.topic)
|
|
990
|
+
for header, path_template in expected_links.items():
|
|
991
|
+
expected_path = path_template.format(key=file_key)
|
|
992
|
+
actual_value = relation.links.get(header)
|
|
993
|
+
if actual_value != expected_path:
|
|
994
|
+
allowed = (allowed_text_values or {}).get(header, set())
|
|
995
|
+
if actual_value in allowed:
|
|
996
|
+
continue
|
|
997
|
+
return False, f"{relative_path} 主题“{relation.topic}”的“{header}”链接错误", []
|
|
998
|
+
for prerequisite in relation.prerequisites:
|
|
999
|
+
if prerequisite not in order_by_topic:
|
|
1000
|
+
return False, f"{relative_path} 主题“{relation.topic}”引用了不存在的前置主题“{prerequisite}”", []
|
|
1001
|
+
if prerequisite == relation.topic:
|
|
1002
|
+
return False, f"{relative_path} 主题“{relation.topic}”不能依赖自己", []
|
|
1003
|
+
if order_by_topic[prerequisite] >= relation.order:
|
|
1004
|
+
return (
|
|
1005
|
+
False,
|
|
1006
|
+
f"{relative_path} 主题“{relation.topic}”的前置主题“{prerequisite}”必须排在前面",
|
|
1007
|
+
[],
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
# 依赖关系用深度优先搜索(DFS)检查,避免主题互相等待。
|
|
1011
|
+
dependencies = {
|
|
1012
|
+
relation.topic: relation.prerequisites for relation in relations
|
|
1013
|
+
}
|
|
1014
|
+
visiting: set[str] = set()
|
|
1015
|
+
visited: set[str] = set()
|
|
1016
|
+
|
|
1017
|
+
def visit(topic: str) -> bool:
|
|
1018
|
+
if topic in visiting:
|
|
1019
|
+
return False
|
|
1020
|
+
if topic in visited:
|
|
1021
|
+
return True
|
|
1022
|
+
visiting.add(topic)
|
|
1023
|
+
if not all(visit(prerequisite) for prerequisite in dependencies[topic]):
|
|
1024
|
+
return False
|
|
1025
|
+
visiting.remove(topic)
|
|
1026
|
+
visited.add(topic)
|
|
1027
|
+
return True
|
|
1028
|
+
|
|
1029
|
+
if not all(visit(topic) for topic in dependencies):
|
|
1030
|
+
return False, f"{relative_path} 的主题前置关系存在循环", []
|
|
1031
|
+
return True, "", relations
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def validate_acceptance_index(
|
|
1035
|
+
project_root: str,
|
|
1036
|
+
workflow_id: str,
|
|
1037
|
+
topics: list[str],
|
|
1038
|
+
) -> tuple[bool, str]:
|
|
1039
|
+
"""校验验收索引,并作为后续阶段主题关系的来源。"""
|
|
1040
|
+
|
|
1041
|
+
ok, detail, _ = _validate_topic_index_rows(
|
|
1042
|
+
project_root,
|
|
1043
|
+
workflow_id,
|
|
1044
|
+
topics,
|
|
1045
|
+
artifact_paths_mod.ACCEPTANCE_INDEX_DOC,
|
|
1046
|
+
["展示顺序", "验收主题", "前置主题", "验收计划", "主题验收结果"],
|
|
1047
|
+
{
|
|
1048
|
+
"验收计划": "./{key}_验收计划.md",
|
|
1049
|
+
"主题验收结果": "./{key}_验收结果.md",
|
|
1050
|
+
},
|
|
1051
|
+
)
|
|
1052
|
+
return ok, detail or f"{artifact_paths_mod.ACCEPTANCE_INDEX_DOC} 结构完整"
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def validate_inherited_topic_index(
|
|
1056
|
+
project_root: str,
|
|
1057
|
+
workflow_id: str,
|
|
1058
|
+
topics: list[str],
|
|
1059
|
+
relative_path: str,
|
|
1060
|
+
expected_headers: list[str],
|
|
1061
|
+
expected_links: dict[str, str],
|
|
1062
|
+
allowed_text_values: dict[str, set[str]] | None = None,
|
|
1063
|
+
) -> tuple[bool, str]:
|
|
1064
|
+
"""校验 qa/ 或 impl/ 索引是否继承验收索引的主题关系。"""
|
|
1065
|
+
|
|
1066
|
+
source_ok, source_detail, source_relations = _validate_topic_index_rows(
|
|
1067
|
+
project_root,
|
|
1068
|
+
workflow_id,
|
|
1069
|
+
topics,
|
|
1070
|
+
artifact_paths_mod.ACCEPTANCE_INDEX_DOC,
|
|
1071
|
+
["展示顺序", "验收主题", "前置主题", "验收计划", "主题验收结果"],
|
|
1072
|
+
{
|
|
1073
|
+
"验收计划": "./{key}_验收计划.md",
|
|
1074
|
+
"主题验收结果": "./{key}_验收结果.md",
|
|
1075
|
+
},
|
|
1076
|
+
)
|
|
1077
|
+
if not source_ok:
|
|
1078
|
+
return False, source_detail
|
|
1079
|
+
target_ok, target_detail, target_relations = _validate_topic_index_rows(
|
|
1080
|
+
project_root,
|
|
1081
|
+
workflow_id,
|
|
1082
|
+
topics,
|
|
1083
|
+
relative_path,
|
|
1084
|
+
expected_headers,
|
|
1085
|
+
expected_links,
|
|
1086
|
+
allowed_text_values,
|
|
1087
|
+
)
|
|
1088
|
+
if not target_ok:
|
|
1089
|
+
return False, target_detail
|
|
1090
|
+
if relation_signature(source_relations) != relation_signature(target_relations):
|
|
1091
|
+
return False, f"{relative_path} 的主题关系没有继承 {artifact_paths_mod.ACCEPTANCE_INDEX_DOC}"
|
|
1092
|
+
return True, f"{relative_path} 已继承 {artifact_paths_mod.ACCEPTANCE_INDEX_DOC} 的主题关系"
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def validate_downstream_traceability(
|
|
1096
|
+
project_root: str,
|
|
1097
|
+
workflow_id: str,
|
|
1098
|
+
topics: list[str],
|
|
1099
|
+
) -> tuple[bool, str]:
|
|
1100
|
+
"""后续阶段共用的追踪表门禁,不要求测试计划正文结构。"""
|
|
1101
|
+
return validate_traceability_structure(project_root, workflow_id, topics)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def validate_test_plan_documents(
|
|
1105
|
+
project_root: str,
|
|
1106
|
+
workflow_id: str,
|
|
1107
|
+
topics: list[str],
|
|
1108
|
+
) -> tuple[bool, str]:
|
|
1109
|
+
"""校验测试计划结构,以及每条 AC 到 TC 的覆盖关系。"""
|
|
1110
|
+
qa_index = artifact_paths_mod.QA_INDEX_DOC
|
|
1111
|
+
index_path = os.path.join(project_root, qa_index)
|
|
1112
|
+
if not os.path.isfile(index_path):
|
|
1113
|
+
return False, f"{qa_index} 不存在"
|
|
1114
|
+
inherited_ok, inherited_detail = validate_inherited_topic_index(
|
|
1115
|
+
project_root,
|
|
1116
|
+
workflow_id,
|
|
1117
|
+
topics,
|
|
1118
|
+
qa_index,
|
|
1119
|
+
["展示顺序", "验收主题", "前置主题", "验收计划", "测试计划", "测试结果"],
|
|
1120
|
+
{
|
|
1121
|
+
"验收计划": "../acceptance/{key}_验收计划.md",
|
|
1122
|
+
"测试计划": "./{key}_测试计划.md",
|
|
1123
|
+
"测试结果": "./{key}_测试结果.md",
|
|
1124
|
+
},
|
|
1125
|
+
{"测试结果": {"无自动化测试项"}},
|
|
1126
|
+
)
|
|
1127
|
+
if not inherited_ok:
|
|
1128
|
+
return False, inherited_detail
|
|
1129
|
+
index_content = _read_text(project_root, qa_index)
|
|
1130
|
+
index_relations = read_topic_index(
|
|
1131
|
+
project_root,
|
|
1132
|
+
qa_index,
|
|
1133
|
+
workflow_id,
|
|
1134
|
+
["展示顺序", "验收主题", "前置主题", "验收计划", "测试计划", "测试结果"],
|
|
1135
|
+
{"测试结果": {"无自动化测试项"}},
|
|
1136
|
+
)
|
|
1137
|
+
relation_by_topic = {relation.topic: relation for relation in index_relations}
|
|
1138
|
+
|
|
1139
|
+
total_criteria = 0
|
|
1140
|
+
total_test_items = 0
|
|
1141
|
+
for topic in topics:
|
|
1142
|
+
paths = topic_paths(project_root, topic)
|
|
1143
|
+
acceptance_rel_path = paths["acceptance_plan"]
|
|
1144
|
+
test_rel_path = paths["test_plan"]
|
|
1145
|
+
test_plan_name = os.path.basename(test_rel_path)
|
|
1146
|
+
if test_plan_name not in index_content:
|
|
1147
|
+
return False, f"{qa_index} 缺少主题测试计划链接: {test_plan_name}"
|
|
1148
|
+
if not os.path.isfile(os.path.join(project_root, acceptance_rel_path)):
|
|
1149
|
+
return False, f"缺少上游验收计划: {acceptance_rel_path}"
|
|
1150
|
+
if not os.path.isfile(os.path.join(project_root, test_rel_path)):
|
|
1151
|
+
return False, f"缺少测试计划文档: {test_rel_path}"
|
|
1152
|
+
|
|
1153
|
+
acceptance_content = _read_text(project_root, acceptance_rel_path)
|
|
1154
|
+
test_content = _read_text(project_root, test_rel_path)
|
|
1155
|
+
if _field(test_content, "工作流编号") != workflow_id:
|
|
1156
|
+
return False, f"{test_rel_path} 的工作流编号与当前工作流不一致"
|
|
1157
|
+
for heading in TEST_PLAN_SECTIONS:
|
|
1158
|
+
if _section(test_content, heading) is None:
|
|
1159
|
+
return False, f"{test_rel_path} 缺少“{heading}”"
|
|
1160
|
+
if f"../{acceptance_rel_path}" not in test_content:
|
|
1161
|
+
return False, f"{test_rel_path} 缺少上游验收计划链接"
|
|
1162
|
+
if f"../{artifact_paths_mod.IMPL_INDEX_DOC}" not in test_content:
|
|
1163
|
+
return False, f"{test_rel_path} 缺少下游实施计划链接"
|
|
1164
|
+
if re.search(r"测试(?:结果|状态)\s*[::]\s*(?:通过|失败)", test_content):
|
|
1165
|
+
return False, f"{test_rel_path} 不能提前填写测试通过或失败"
|
|
1166
|
+
|
|
1167
|
+
criterion_ids = ACCEPTANCE_CRITERION_RE.findall(acceptance_content)
|
|
1168
|
+
if not criterion_ids:
|
|
1169
|
+
return False, f"{acceptance_rel_path} 没有可覆盖的验收条件"
|
|
1170
|
+
coverage = _section(test_content, "1. 验收条件覆盖") or ""
|
|
1171
|
+
try:
|
|
1172
|
+
test_items = parse_test_plan_items(project_root, topic)
|
|
1173
|
+
except ValueError as exc:
|
|
1174
|
+
return False, str(exc)
|
|
1175
|
+
test_result_name = os.path.basename(paths["test_result"])
|
|
1176
|
+
expected_result_cell = (
|
|
1177
|
+
f"./{test_result_name}"
|
|
1178
|
+
if any(item.requires_test_code for item in test_items)
|
|
1179
|
+
else "无自动化测试项"
|
|
1180
|
+
)
|
|
1181
|
+
if any(item.requires_test_code for item in test_items):
|
|
1182
|
+
if f"./{test_result_name}" not in test_content:
|
|
1183
|
+
return False, f"{test_rel_path} 缺少下游测试结果链接"
|
|
1184
|
+
elif "无自动化测试结果,转主题验收" not in test_content:
|
|
1185
|
+
return False, f"{test_rel_path} 是纯人工验收主题,必须写“无自动化测试结果,转主题验收”"
|
|
1186
|
+
actual_result_cell = relation_by_topic[topic].links.get("测试结果")
|
|
1187
|
+
if actual_result_cell != expected_result_cell:
|
|
1188
|
+
return (
|
|
1189
|
+
False,
|
|
1190
|
+
f"{qa_index} 主题“{topic}”的测试结果位置应为 {expected_result_cell}",
|
|
1191
|
+
)
|
|
1192
|
+
for criterion_id in criterion_ids:
|
|
1193
|
+
criterion_items = [
|
|
1194
|
+
item for item in test_items if item.criterion_id == criterion_id
|
|
1195
|
+
]
|
|
1196
|
+
if not criterion_items:
|
|
1197
|
+
return False, f"{test_rel_path} 没有覆盖 {criterion_id}"
|
|
1198
|
+
criterion_lines = [line for line in coverage.splitlines() if criterion_id in line]
|
|
1199
|
+
if not any(f"../{acceptance_rel_path}#" in line for line in criterion_lines):
|
|
1200
|
+
return False, f"{test_rel_path} 的 {criterion_id} 没有链接到验收计划具体位置"
|
|
1201
|
+
total_criteria += len(criterion_ids)
|
|
1202
|
+
total_test_items += len(test_items)
|
|
1203
|
+
|
|
1204
|
+
return (
|
|
1205
|
+
True,
|
|
1206
|
+
f"测试计划结构完整,{len(topics)} 个主题覆盖 {total_criteria} 条验收条件,包含 {total_test_items} 个测试项",
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _validate_topic_result_file(
|
|
1211
|
+
project_root: str,
|
|
1212
|
+
rel_path: str,
|
|
1213
|
+
workflow_id: str,
|
|
1214
|
+
status_label: str,
|
|
1215
|
+
) -> tuple[bool, str]:
|
|
1216
|
+
full_path = os.path.join(project_root, rel_path)
|
|
1217
|
+
if not os.path.isfile(full_path):
|
|
1218
|
+
return False, f"{rel_path} 不存在"
|
|
1219
|
+
content = _read_text(project_root, rel_path)
|
|
1220
|
+
if _field(content, "工作流编号") != workflow_id:
|
|
1221
|
+
return False, f"{rel_path} 的工作流编号与当前工作流不一致"
|
|
1222
|
+
if _field(content, status_label) != "通过":
|
|
1223
|
+
return False, f"{rel_path} 必须明确写“{status_label}:通过”"
|
|
1224
|
+
return True, ""
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def _validate_topic_acceptance_result(
|
|
1228
|
+
project_root: str,
|
|
1229
|
+
workflow_id: str,
|
|
1230
|
+
topic: str,
|
|
1231
|
+
) -> tuple[bool, str]:
|
|
1232
|
+
"""校验正式主题验收结果只包含全部通过的当前验收条件。"""
|
|
1233
|
+
paths = topic_paths(project_root, topic)
|
|
1234
|
+
rel_path = paths["acceptance_result"]
|
|
1235
|
+
ok, detail = _validate_topic_result_file(
|
|
1236
|
+
project_root,
|
|
1237
|
+
rel_path,
|
|
1238
|
+
workflow_id,
|
|
1239
|
+
"验收结果",
|
|
1240
|
+
)
|
|
1241
|
+
if not ok:
|
|
1242
|
+
return False, detail
|
|
1243
|
+
|
|
1244
|
+
content = _read_text(project_root, rel_path)
|
|
1245
|
+
if _field(content, "验收主题") != topic:
|
|
1246
|
+
return False, f"{rel_path} 的验收主题必须是“{topic}”"
|
|
1247
|
+
if not _has_real_text(_field(content, "验收完成时间")):
|
|
1248
|
+
return False, f"{rel_path} 缺少具体验收完成时间"
|
|
1249
|
+
|
|
1250
|
+
for heading in ("1. 验收依据", "2. 验收条件结果", "3. 上下游文档"):
|
|
1251
|
+
if not _has_real_text(_section(content, heading)):
|
|
1252
|
+
return False, f"{rel_path} 缺少具体“{heading}”"
|
|
1253
|
+
|
|
1254
|
+
state = load_state(project_root)
|
|
1255
|
+
if state is None or state.workflow_id != workflow_id:
|
|
1256
|
+
return False, f"{rel_path} 找不到当前工作流状态"
|
|
1257
|
+
stage_state = state.stages.get("topic_acceptance")
|
|
1258
|
+
if stage_state is None:
|
|
1259
|
+
return False, "缺少 topic_acceptance(主题验收阶段)状态"
|
|
1260
|
+
|
|
1261
|
+
plan_rel_path = paths["acceptance_plan"]
|
|
1262
|
+
if not os.path.isfile(os.path.join(project_root, plan_rel_path)):
|
|
1263
|
+
return False, f"{plan_rel_path} 不存在"
|
|
1264
|
+
plan_content = _read_text(project_root, plan_rel_path)
|
|
1265
|
+
plan_ids = [
|
|
1266
|
+
criterion_id
|
|
1267
|
+
for criterion_id, _ in _acceptance_criterion_sections(plan_content)
|
|
1268
|
+
]
|
|
1269
|
+
result_sections = _acceptance_result_criterion_sections(content)
|
|
1270
|
+
result_ids = [criterion_id for criterion_id, _ in result_sections]
|
|
1271
|
+
if not plan_ids:
|
|
1272
|
+
return False, f"{plan_rel_path} 没有可验收的 AC-xx"
|
|
1273
|
+
if result_ids != plan_ids:
|
|
1274
|
+
return (
|
|
1275
|
+
False,
|
|
1276
|
+
f"{rel_path} 的验收条件必须与验收计划完全一致: 计划={plan_ids}, 结果={result_ids}",
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
try:
|
|
1280
|
+
methods = acceptance_records_mod.criterion_methods(project_root, topic)
|
|
1281
|
+
except ValueError as exc:
|
|
1282
|
+
return False, str(exc)
|
|
1283
|
+
records = stage_state.acceptance_records.get(topic, {})
|
|
1284
|
+
valid_methods = acceptance_records_mod.ACCEPTANCE_METHODS
|
|
1285
|
+
for criterion_id, criterion_content in result_sections:
|
|
1286
|
+
record = records.get(criterion_id)
|
|
1287
|
+
if record is None or not acceptance_records_mod.record_is_current(record, state):
|
|
1288
|
+
return False, f"{rel_path} 的 {criterion_id} 缺少当前有效的程序验收记录"
|
|
1289
|
+
method = _field(criterion_content, "验收方式")
|
|
1290
|
+
if method not in valid_methods or method != methods.get(criterion_id):
|
|
1291
|
+
return False, f"{rel_path} 的 {criterion_id} 验收方式不合法"
|
|
1292
|
+
for label in ("验收条件", "自动化依据", "实际结果", "验收证据", "程序记录"):
|
|
1293
|
+
if not _has_real_text(_field(criterion_content, label)):
|
|
1294
|
+
return False, f"{rel_path} 的 {criterion_id} 缺少具体“{label}”"
|
|
1295
|
+
if _field(criterion_content, "判定") != "通过":
|
|
1296
|
+
return False, f"{rel_path} 的 {criterion_id} 必须明确写“判定:通过”"
|
|
1297
|
+
if _field(criterion_content, "实际结果") != record.actual_result:
|
|
1298
|
+
return False, f"{rel_path} 的 {criterion_id} 实际结果与程序记录不一致"
|
|
1299
|
+
if _field(criterion_content, "验收证据") != record.evidence:
|
|
1300
|
+
return False, f"{rel_path} 的 {criterion_id} 验收证据与程序记录不一致"
|
|
1301
|
+
if _field(criterion_content, "程序记录") != record.record_id:
|
|
1302
|
+
return False, f"{rel_path} 的 {criterion_id} 程序记录编号不一致"
|
|
1303
|
+
|
|
1304
|
+
# 自动化或混合条件必须逐项列出作为依据的精确机器测试记录编号
|
|
1305
|
+
machine_ids_text = _field(criterion_content, "机器测试记录编号")
|
|
1306
|
+
if method == "人工验收":
|
|
1307
|
+
if machine_ids_text not in (None, "不适用"):
|
|
1308
|
+
return False, f"{rel_path} 的 {criterion_id} 是纯人工验收,机器测试记录编号必须写“不适用”"
|
|
1309
|
+
else:
|
|
1310
|
+
if not _has_real_text(machine_ids_text):
|
|
1311
|
+
return False, f"{rel_path} 的 {criterion_id} 缺少机器测试记录编号"
|
|
1312
|
+
expected_machine_ids = set(record.test_record_ids)
|
|
1313
|
+
actual_machine_ids = _machine_record_ids(machine_ids_text)
|
|
1314
|
+
if actual_machine_ids != expected_machine_ids or not expected_machine_ids:
|
|
1315
|
+
missing_ids = sorted(expected_machine_ids - actual_machine_ids)
|
|
1316
|
+
extra_ids = sorted(actual_machine_ids - expected_machine_ids)
|
|
1317
|
+
return (
|
|
1318
|
+
False,
|
|
1319
|
+
f"{rel_path} 的 {criterion_id} 机器测试记录编号与当前记录不一致:"
|
|
1320
|
+
f"缺少={missing_ids},额外或拼接错误={extra_ids}",
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
manual_confirmation = _field(criterion_content, "人工确认")
|
|
1324
|
+
if method == "自动化测试":
|
|
1325
|
+
if manual_confirmation != "不适用":
|
|
1326
|
+
return False, f"{rel_path} 的 {criterion_id} 是纯自动化验收,人工确认必须写“不适用”"
|
|
1327
|
+
for label in ("用户实际回答", "确认时间"):
|
|
1328
|
+
if _field(criterion_content, label) != "不适用":
|
|
1329
|
+
return False, f"{rel_path} 的 {criterion_id} 的“{label}”必须写“不适用”"
|
|
1330
|
+
if not all(test_id in (_field(criterion_content, "自动化依据") or "") for test_id in record.test_ids):
|
|
1331
|
+
return False, f"{rel_path} 的 {criterion_id} 缺少对应自动化测试项"
|
|
1332
|
+
else:
|
|
1333
|
+
if manual_confirmation != "通过":
|
|
1334
|
+
return False, f"{rel_path} 的 {criterion_id} 需要人工验收,人工确认必须写“通过”"
|
|
1335
|
+
for label in (
|
|
1336
|
+
"验收对象",
|
|
1337
|
+
"开始前条件",
|
|
1338
|
+
"观察内容",
|
|
1339
|
+
"预期结果",
|
|
1340
|
+
"用户需要回答",
|
|
1341
|
+
"用户实际回答",
|
|
1342
|
+
"确认时间",
|
|
1343
|
+
):
|
|
1344
|
+
if not _has_real_text(_field(criterion_content, label)):
|
|
1345
|
+
return False, f"{rel_path} 的 {criterion_id} 人工验收步骤缺少具体“{label}”"
|
|
1346
|
+
if re.search(r"^\s*1\.\s+\S+", criterion_content, re.MULTILINE) is None:
|
|
1347
|
+
return False, f"{rel_path} 的 {criterion_id} 人工验收步骤缺少具体操作"
|
|
1348
|
+
if _field(criterion_content, "用户实际回答") != record.user_answer:
|
|
1349
|
+
return False, f"{rel_path} 的 {criterion_id} 用户回答与程序记录不一致"
|
|
1350
|
+
if _field(criterion_content, "确认时间") != record.confirmed_at:
|
|
1351
|
+
return False, f"{rel_path} 的 {criterion_id} 确认时间与程序记录不一致"
|
|
1352
|
+
automated_basis = _field(criterion_content, "自动化依据") or ""
|
|
1353
|
+
if method == "人工验收" and automated_basis != "不适用":
|
|
1354
|
+
return False, f"{rel_path} 的 {criterion_id} 是纯人工验收,自动化依据必须写“不适用”"
|
|
1355
|
+
if method == "自动化测试 + 人工验收" and not all(
|
|
1356
|
+
test_id in automated_basis for test_id in record.test_ids
|
|
1357
|
+
):
|
|
1358
|
+
return False, f"{rel_path} 的 {criterion_id} 缺少混合验收使用的自动化测试项"
|
|
1359
|
+
|
|
1360
|
+
required_links = [
|
|
1361
|
+
f"./{os.path.basename(paths['acceptance_plan'])}",
|
|
1362
|
+
f"../{paths['impl_doc']}",
|
|
1363
|
+
f"../{TRACEABILITY_PATH}",
|
|
1364
|
+
]
|
|
1365
|
+
if any(method != "人工验收" for method in methods.values()):
|
|
1366
|
+
required_links.append(f"../{paths['test_result']}")
|
|
1367
|
+
elif "无自动化测试项" not in content:
|
|
1368
|
+
return False, f"{rel_path} 是纯人工验收主题,必须明确写“无自动化测试项”"
|
|
1369
|
+
for required_link in required_links:
|
|
1370
|
+
if required_link not in content:
|
|
1371
|
+
return False, f"{rel_path} 缺少上下游链接: {required_link}"
|
|
1372
|
+
return True, ""
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _test_result_sections(content: str) -> dict[str, str]:
|
|
1376
|
+
result_content = _section(content, "3. 测试项结果")
|
|
1377
|
+
if result_content is None:
|
|
1378
|
+
return {}
|
|
1379
|
+
matches = list(
|
|
1380
|
+
re.finditer(
|
|
1381
|
+
r"^###\s+(TC-\d{2,})[::].*$",
|
|
1382
|
+
result_content,
|
|
1383
|
+
re.MULTILINE,
|
|
1384
|
+
)
|
|
1385
|
+
)
|
|
1386
|
+
sections: dict[str, str] = {}
|
|
1387
|
+
for index, match in enumerate(matches):
|
|
1388
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(result_content)
|
|
1389
|
+
sections[match.group(1)] = result_content[match.end() : end].strip()
|
|
1390
|
+
return sections
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _validate_topic_test_execution_result(
|
|
1394
|
+
project_root: str,
|
|
1395
|
+
workflow_id: str,
|
|
1396
|
+
topic: str,
|
|
1397
|
+
items,
|
|
1398
|
+
tasks,
|
|
1399
|
+
) -> tuple[bool, str]:
|
|
1400
|
+
"""主题测试结果必须逐字段引用当前机器记录,不接受手写成功。"""
|
|
1401
|
+
rel_path = topic_paths(project_root, topic)["test_result"]
|
|
1402
|
+
full_path = os.path.join(project_root, rel_path)
|
|
1403
|
+
if not os.path.isfile(full_path):
|
|
1404
|
+
return False, f"{rel_path} 不存在"
|
|
1405
|
+
content = _read_text(project_root, rel_path)
|
|
1406
|
+
if _field(content, "工作流编号") != workflow_id:
|
|
1407
|
+
return False, f"{rel_path} 的工作流编号与当前工作流不一致"
|
|
1408
|
+
if _field(content, "验收主题") != topic:
|
|
1409
|
+
return False, f"{rel_path} 的验收主题必须是“{topic}”"
|
|
1410
|
+
if _field(content, "自动化测试结果") != "通过":
|
|
1411
|
+
return False, f"{rel_path} 必须明确写“自动化测试结果:通过”"
|
|
1412
|
+
|
|
1413
|
+
all_plan_items = parse_test_plan_items(project_root, topic)
|
|
1414
|
+
needs_manual = any(item.test_method != "自动化测试" for item in all_plan_items)
|
|
1415
|
+
expected_manual_status = "待主题验收" if needs_manual else "无需人工验收"
|
|
1416
|
+
if _field(content, "人工验收状态") != expected_manual_status:
|
|
1417
|
+
return False, f"{rel_path} 的人工验收状态必须是“{expected_manual_status}”"
|
|
1418
|
+
if needs_manual and _section(content, "4. 人工验收交接") in {None, "", "无需人工验收"}:
|
|
1419
|
+
return False, f"{rel_path} 有人工验收内容,但缺少具体人工验收交接"
|
|
1420
|
+
|
|
1421
|
+
sections = _test_result_sections(content)
|
|
1422
|
+
expected_ids = {item.test_id for item in items}
|
|
1423
|
+
if set(sections) != expected_ids:
|
|
1424
|
+
return False, f"{rel_path} 的测试项结果必须正好覆盖 {sorted(expected_ids)}"
|
|
1425
|
+
|
|
1426
|
+
for item in items:
|
|
1427
|
+
task = tasks.get(item.test_id)
|
|
1428
|
+
if task is None:
|
|
1429
|
+
return False, f"{topic} / {item.test_id} 没有登记测试任务"
|
|
1430
|
+
record = task.current_record
|
|
1431
|
+
if task.status != "passed" or record is None:
|
|
1432
|
+
return False, f"{topic} / {item.test_id} 没有当前有效的通过记录"
|
|
1433
|
+
if record.status != "passed" or record.exit_code != 0:
|
|
1434
|
+
return False, f"{topic} / {item.test_id} 的当前执行记录不是退出码 0 的通过状态"
|
|
1435
|
+
if record.command != task.command:
|
|
1436
|
+
return False, f"{topic} / {item.test_id} 的执行命令和登记命令不一致"
|
|
1437
|
+
if record.test_entries != task.test_entries:
|
|
1438
|
+
return False, f"{topic} / {item.test_id} 的执行入口和登记入口不一致"
|
|
1439
|
+
if record.cwd != task.cwd:
|
|
1440
|
+
return False, f"{topic} / {item.test_id} 的工作目录和登记内容不一致"
|
|
1441
|
+
if record.timeout_seconds != task.timeout_seconds:
|
|
1442
|
+
return False, f"{topic} / {item.test_id} 的超时时间和登记内容不一致"
|
|
1443
|
+
if not record.code_snapshot_hash or not record.test_code_hash:
|
|
1444
|
+
return False, f"{topic} / {item.test_id} 的执行记录没有绑定代码快照"
|
|
1445
|
+
if not record.record_id:
|
|
1446
|
+
return False, f"{topic} / {item.test_id} 的执行记录缺少机器记录编号"
|
|
1447
|
+
if record.record_id != _topic_test_record_id(topic, item.test_id, record):
|
|
1448
|
+
return False, f"{topic} / {item.test_id} 的机器记录编号与执行事实不一致"
|
|
1449
|
+
if (
|
|
1450
|
+
isinstance(record.timeout_seconds, bool)
|
|
1451
|
+
or not isinstance(record.timeout_seconds, int)
|
|
1452
|
+
or record.timeout_seconds <= 0
|
|
1453
|
+
):
|
|
1454
|
+
return False, f"{topic} / {item.test_id} 的机器记录超时时间不合法"
|
|
1455
|
+
if (
|
|
1456
|
+
not isinstance(record.started_at, str)
|
|
1457
|
+
or not isinstance(record.finished_at, str)
|
|
1458
|
+
or isinstance(record.duration_seconds, bool)
|
|
1459
|
+
or not isinstance(record.duration_seconds, (int, float))
|
|
1460
|
+
or record.duration_seconds < 0
|
|
1461
|
+
):
|
|
1462
|
+
return False, f"{topic} / {item.test_id} 的机器记录时间或时长不完整"
|
|
1463
|
+
try:
|
|
1464
|
+
record_started_at = datetime.fromisoformat(record.started_at)
|
|
1465
|
+
record_finished_at = datetime.fromisoformat(record.finished_at)
|
|
1466
|
+
except ValueError:
|
|
1467
|
+
return False, f"{topic} / {item.test_id} 的机器记录时间格式不合法"
|
|
1468
|
+
if (
|
|
1469
|
+
record_started_at.tzinfo is None
|
|
1470
|
+
or record_finished_at.tzinfo is None
|
|
1471
|
+
or record_finished_at < record_started_at
|
|
1472
|
+
):
|
|
1473
|
+
return False, f"{topic} / {item.test_id} 的机器记录时间顺序不合法"
|
|
1474
|
+
if not record.platform or not record.executable:
|
|
1475
|
+
return False, f"{topic} / {item.test_id} 的机器记录缺少平台或实际可执行文件"
|
|
1476
|
+
if not isinstance(record.output_tail, str):
|
|
1477
|
+
return False, f"{topic} / {item.test_id} 的机器记录输出摘要不是文本"
|
|
1478
|
+
if not isinstance(record.output_sha256, str) or re.fullmatch(
|
|
1479
|
+
r"[0-9a-f]{64}",
|
|
1480
|
+
record.output_sha256,
|
|
1481
|
+
) is None:
|
|
1482
|
+
return False, f"{topic} / {item.test_id} 的机器记录输出哈希不完整"
|
|
1483
|
+
if (
|
|
1484
|
+
isinstance(record.output_bytes, bool)
|
|
1485
|
+
or not isinstance(record.output_bytes, int)
|
|
1486
|
+
or record.output_bytes < 0
|
|
1487
|
+
):
|
|
1488
|
+
return False, f"{topic} / {item.test_id} 的机器记录输出字节数不合法"
|
|
1489
|
+
|
|
1490
|
+
section = sections[item.test_id]
|
|
1491
|
+
if item.criterion_id not in (_field(section, "对应验收条件") or ""):
|
|
1492
|
+
return False, f"{rel_path} 的 {item.test_id} 没有对应 {item.criterion_id}"
|
|
1493
|
+
# 机器事实统一编码成单行文本,再与正式结果逐字段精确比对。
|
|
1494
|
+
checks = [
|
|
1495
|
+
("机器记录编号", record.record_id),
|
|
1496
|
+
("工作目录", record.cwd or "项目根"),
|
|
1497
|
+
("测试入口", _argv_text(record.test_entries)),
|
|
1498
|
+
("执行命令", _argv_text(record.command)),
|
|
1499
|
+
("超时(秒)", record.timeout_seconds),
|
|
1500
|
+
("运行环境", _environment_text(record.platform, record.executable)),
|
|
1501
|
+
("开始时间", record.started_at),
|
|
1502
|
+
("结束时间", record.finished_at),
|
|
1503
|
+
("时长(秒)", record.duration_seconds),
|
|
1504
|
+
("退出码", record.exit_code),
|
|
1505
|
+
("输出摘要", _output_tail_text(record.output_tail)),
|
|
1506
|
+
("输出哈希", record.output_sha256),
|
|
1507
|
+
("输出字节数", record.output_bytes),
|
|
1508
|
+
("产品代码哈希", record.code_snapshot_hash),
|
|
1509
|
+
("测试代码哈希", record.test_code_hash),
|
|
1510
|
+
]
|
|
1511
|
+
for label, expected_value in checks:
|
|
1512
|
+
actual_value = _field(section, label)
|
|
1513
|
+
if expected_value is None or actual_value != str(expected_value):
|
|
1514
|
+
return (
|
|
1515
|
+
False,
|
|
1516
|
+
f"{rel_path} 的 {item.test_id} “{label}”与机器记录不一致"
|
|
1517
|
+
f"(记录 {expected_value!r},文档 {actual_value!r})",
|
|
1518
|
+
)
|
|
1519
|
+
if _field(section, "自动化测试结果") != "通过":
|
|
1520
|
+
return False, f"{rel_path} 的 {item.test_id} 必须写“自动化测试结果:通过”"
|
|
1521
|
+
if not _has_real_text(_field(section, "实际结果")):
|
|
1522
|
+
return False, f"{rel_path} 的 {item.test_id} 缺少实际结果"
|
|
1523
|
+
if not _has_real_text(_field(section, "证据")):
|
|
1524
|
+
return False, f"{rel_path} 的 {item.test_id} 缺少可复核证据"
|
|
1525
|
+
return True, ""
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def validate_test_execution_results(
|
|
1529
|
+
project_root: str,
|
|
1530
|
+
workflow_id: str,
|
|
1531
|
+
topics: list[str],
|
|
1532
|
+
) -> tuple[bool, str]:
|
|
1533
|
+
"""测试执行阶段只校验主题测试结果,不能提前要求主题验收结果。
|
|
1534
|
+
|
|
1535
|
+
只接受当前机器记录;不再保留“只看旧文档通过”的兼容放行。
|
|
1536
|
+
"""
|
|
1537
|
+
failures = []
|
|
1538
|
+
try:
|
|
1539
|
+
automated = automated_topics(project_root, topics)
|
|
1540
|
+
automated_items = automated_test_items(project_root, topics)
|
|
1541
|
+
except ValueError as exc:
|
|
1542
|
+
return False, str(exc)
|
|
1543
|
+
manual_only = [topic for topic in topics if topic not in automated]
|
|
1544
|
+
if not automated:
|
|
1545
|
+
return True, f"全部主题都没有自动化测试项,直接进入主题验收: {manual_only}"
|
|
1546
|
+
state = load_state(project_root)
|
|
1547
|
+
if state is None or state.workflow_id != workflow_id:
|
|
1548
|
+
return False, "找不到当前工作流的测试执行状态"
|
|
1549
|
+
stage_state = state.stages.get("test_execution")
|
|
1550
|
+
if stage_state is None:
|
|
1551
|
+
return False, "缺少 test_execution(测试执行阶段)状态,正式结果必须来自真实执行记录"
|
|
1552
|
+
for topic in automated:
|
|
1553
|
+
topic_items = [item for item in automated_items if item.topic == topic]
|
|
1554
|
+
test_ok, test_detail = _validate_topic_test_execution_result(
|
|
1555
|
+
project_root,
|
|
1556
|
+
workflow_id,
|
|
1557
|
+
topic,
|
|
1558
|
+
topic_items,
|
|
1559
|
+
stage_state.test_tasks.get(topic, {}),
|
|
1560
|
+
)
|
|
1561
|
+
if not test_ok:
|
|
1562
|
+
failures.append(test_detail)
|
|
1563
|
+
if failures:
|
|
1564
|
+
return False, ";".join(failures)
|
|
1565
|
+
if manual_only:
|
|
1566
|
+
return (
|
|
1567
|
+
True,
|
|
1568
|
+
f"自动化主题测试结果都明确通过: {automated};无自动化测试项: {manual_only}",
|
|
1569
|
+
)
|
|
1570
|
+
return True, f"全部主题测试执行记录和正式结果一致并明确通过: {automated}"
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def validate_topic_acceptance_results(
|
|
1574
|
+
project_root: str,
|
|
1575
|
+
workflow_id: str,
|
|
1576
|
+
topics: list[str],
|
|
1577
|
+
) -> tuple[bool, str]:
|
|
1578
|
+
"""主题验收阶段只校验主题验收结果,测试前置由调用方先检查。"""
|
|
1579
|
+
failures = []
|
|
1580
|
+
for topic in topics:
|
|
1581
|
+
acceptance_ok, acceptance_detail = _validate_topic_acceptance_result(
|
|
1582
|
+
project_root,
|
|
1583
|
+
workflow_id,
|
|
1584
|
+
topic,
|
|
1585
|
+
)
|
|
1586
|
+
if not acceptance_ok:
|
|
1587
|
+
failures.append(acceptance_detail)
|
|
1588
|
+
if failures:
|
|
1589
|
+
return False, ";".join(failures)
|
|
1590
|
+
return True, f"全部主题验收结果都明确通过: {topics}"
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def validate_topic_execution_results(
|
|
1594
|
+
project_root: str,
|
|
1595
|
+
workflow_id: str,
|
|
1596
|
+
topics: list[str],
|
|
1597
|
+
) -> tuple[bool, str]:
|
|
1598
|
+
"""兼容旧调用:按新顺序分别校验测试结果和主题验收结果。"""
|
|
1599
|
+
test_ok, test_detail = validate_test_execution_results(project_root, workflow_id, topics)
|
|
1600
|
+
if not test_ok:
|
|
1601
|
+
return False, test_detail
|
|
1602
|
+
return validate_topic_acceptance_results(project_root, workflow_id, topics)
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
def validate_final_regression_state(
|
|
1606
|
+
project_root: str,
|
|
1607
|
+
workflow_id: str,
|
|
1608
|
+
) -> tuple[bool, str]:
|
|
1609
|
+
"""逐字段校验最终全量回归的当前机器事实。"""
|
|
1610
|
+
state = load_state(project_root)
|
|
1611
|
+
if state is None or state.workflow_id != workflow_id:
|
|
1612
|
+
return (False, "找不到当前工作流状态,不能确认最终全量回归")
|
|
1613
|
+
result = state.regression_test
|
|
1614
|
+
if result.status == "unavailable":
|
|
1615
|
+
return (False, "最终全量回归明确失败:当前平台没有可执行的项目全量测试入口")
|
|
1616
|
+
if result.status != "passed":
|
|
1617
|
+
return (False, f"最终全量回归状态不是通过:{result.status}")
|
|
1618
|
+
if result.exit_code != 0:
|
|
1619
|
+
return (False, f"最终全量回归退出码不是 0:{result.exit_code}")
|
|
1620
|
+
|
|
1621
|
+
# 入口必须与当前平台登记的项目全量入口一致
|
|
1622
|
+
expected_argv, entry_detail = test_runner_mod.resolve_regression_entry(project_root)
|
|
1623
|
+
if expected_argv is None:
|
|
1624
|
+
return (False, f"当前项目全量测试入口无效:{entry_detail}")
|
|
1625
|
+
if list(result.entry or []) != expected_argv:
|
|
1626
|
+
return (
|
|
1627
|
+
False,
|
|
1628
|
+
f"最终全量回归使用的入口 {result.entry} 与当前平台登记入口 {expected_argv} 不一致,"
|
|
1629
|
+
"必须重新真实执行",
|
|
1630
|
+
)
|
|
1631
|
+
if list(result.command or []) != expected_argv:
|
|
1632
|
+
return (
|
|
1633
|
+
False,
|
|
1634
|
+
f"最终全量回归实际命令 {result.command} 与当前平台登记入口 {expected_argv} 不一致",
|
|
1635
|
+
)
|
|
1636
|
+
if result.cwd != "":
|
|
1637
|
+
return (False, "最终全量回归必须在项目根执行")
|
|
1638
|
+
if result.timeout_seconds != test_runner_mod.TEST_TIMEOUT_SECONDS:
|
|
1639
|
+
return (
|
|
1640
|
+
False,
|
|
1641
|
+
"最终全量回归超时时间与程序固定值不一致:"
|
|
1642
|
+
f"{result.timeout_seconds} != {test_runner_mod.TEST_TIMEOUT_SECONDS}",
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
if not result.started_at or not result.finished_at:
|
|
1646
|
+
return (False, "最终全量回归缺少开始时间或结束时间")
|
|
1647
|
+
try:
|
|
1648
|
+
started_at = datetime.fromisoformat(result.started_at)
|
|
1649
|
+
finished_at = datetime.fromisoformat(result.finished_at)
|
|
1650
|
+
except (TypeError, ValueError):
|
|
1651
|
+
return (False, "最终全量回归开始时间或结束时间不是合法 ISO 8601 时间")
|
|
1652
|
+
if started_at.tzinfo is None or finished_at.tzinfo is None or finished_at < started_at:
|
|
1653
|
+
return (False, "最终全量回归时间缺少时区或结束时间早于开始时间")
|
|
1654
|
+
if (
|
|
1655
|
+
isinstance(result.duration_seconds, bool)
|
|
1656
|
+
or not isinstance(result.duration_seconds, (int, float))
|
|
1657
|
+
or result.duration_seconds < 0
|
|
1658
|
+
):
|
|
1659
|
+
return (False, f"最终全量回归时长不合法:{result.duration_seconds}")
|
|
1660
|
+
|
|
1661
|
+
if not isinstance(result.output_tail, str):
|
|
1662
|
+
return (False, "最终全量回归输出摘要不是文本")
|
|
1663
|
+
if not isinstance(result.output_sha256, str) or re.fullmatch(
|
|
1664
|
+
r"[0-9a-f]{64}",
|
|
1665
|
+
result.output_sha256,
|
|
1666
|
+
) is None:
|
|
1667
|
+
return (False, "最终全量回归输出哈希不是完整 SHA-256")
|
|
1668
|
+
if (
|
|
1669
|
+
isinstance(result.output_bytes, bool)
|
|
1670
|
+
or not isinstance(result.output_bytes, int)
|
|
1671
|
+
or result.output_bytes < 0
|
|
1672
|
+
):
|
|
1673
|
+
return (False, f"最终全量回归输出字节数不合法:{result.output_bytes}")
|
|
1674
|
+
if result.platform != sys.platform:
|
|
1675
|
+
return (
|
|
1676
|
+
False,
|
|
1677
|
+
f"最终全量回归记录平台 {result.platform!r} 与当前平台 {sys.platform!r} 不一致",
|
|
1678
|
+
)
|
|
1679
|
+
expected_executable = process_runner_mod.resolve_executable(expected_argv[0])
|
|
1680
|
+
if result.executable != expected_executable:
|
|
1681
|
+
return (
|
|
1682
|
+
False,
|
|
1683
|
+
f"最终全量回归实际可执行文件 {result.executable!r} "
|
|
1684
|
+
f"与当前入口 {expected_executable!r} 不一致",
|
|
1685
|
+
)
|
|
1686
|
+
|
|
1687
|
+
expected_record_id = test_runner_mod.regression_record_id(
|
|
1688
|
+
result.started_at,
|
|
1689
|
+
result.finished_at,
|
|
1690
|
+
result.exit_code,
|
|
1691
|
+
result.output_sha256,
|
|
1692
|
+
result.command,
|
|
1693
|
+
)
|
|
1694
|
+
if result.record_id != expected_record_id:
|
|
1695
|
+
return (
|
|
1696
|
+
False,
|
|
1697
|
+
f"最终全量回归机器记录编号不一致:{result.record_id!r} != {expected_record_id!r}",
|
|
1698
|
+
)
|
|
1699
|
+
if result.code_snapshot_hash != compute_code_snapshot_hash(project_root):
|
|
1700
|
+
return (False, "最终全量回归完成后代码又发生变化,必须重新执行全量测试")
|
|
1701
|
+
return (
|
|
1702
|
+
True,
|
|
1703
|
+
f"最终全量测试机器事实完整且匹配当前入口和代码:"
|
|
1704
|
+
f"{result.command}(机器记录 {result.record_id})",
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def validate_overall_acceptance_prerequisites(
|
|
1709
|
+
project_root: str,
|
|
1710
|
+
workflow_id: str,
|
|
1711
|
+
topics: list[str],
|
|
1712
|
+
) -> tuple[bool, str]:
|
|
1713
|
+
"""整体验收只校验前置结果,不读取或要求独立的整体结果文档。"""
|
|
1714
|
+
if not topics:
|
|
1715
|
+
return (False, "当前工作流没有验收主题,不能进行整体验收")
|
|
1716
|
+
test_ok, test_detail = validate_test_execution_results(
|
|
1717
|
+
project_root,
|
|
1718
|
+
workflow_id,
|
|
1719
|
+
topics,
|
|
1720
|
+
)
|
|
1721
|
+
if not test_ok:
|
|
1722
|
+
return (False, f"主题测试尚未全部通过,不能进行整体验收: {test_detail}")
|
|
1723
|
+
|
|
1724
|
+
acceptance_ok, acceptance_detail = validate_topic_acceptance_results(
|
|
1725
|
+
project_root,
|
|
1726
|
+
workflow_id,
|
|
1727
|
+
topics,
|
|
1728
|
+
)
|
|
1729
|
+
if not acceptance_ok:
|
|
1730
|
+
return (False, f"主题验收尚未全部通过,不能进行整体验收: {acceptance_detail}")
|
|
1731
|
+
|
|
1732
|
+
regression_ok, regression_detail = validate_final_regression_state(
|
|
1733
|
+
project_root,
|
|
1734
|
+
workflow_id,
|
|
1735
|
+
)
|
|
1736
|
+
if not regression_ok:
|
|
1737
|
+
return (False, regression_detail)
|
|
1738
|
+
return (True, "全部主题验收和最终全量回归都已明确通过,可以请用户确认整体验收")
|