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,971 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import copy
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
from .project import load_project
|
|
10
|
+
from . import artifact_paths as artifact_paths_mod
|
|
11
|
+
from .state import (
|
|
12
|
+
RecoveryContext,
|
|
13
|
+
RegressionTestState,
|
|
14
|
+
WorkflowState,
|
|
15
|
+
StageState,
|
|
16
|
+
GateState,
|
|
17
|
+
load_state,
|
|
18
|
+
now_iso,
|
|
19
|
+
)
|
|
20
|
+
from .test_mapping import automated_topics
|
|
21
|
+
from .topic import candidate_topics, topic_paths
|
|
22
|
+
from . import traceability as traceability_mod
|
|
23
|
+
from . import acceptance_records as acceptance_records_mod
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# 产品总说明 功能清单中的本地 Markdown 链接
|
|
27
|
+
# 只接受 spec/ 下的中文 功能_*.md,外部链接和其它文件不算产品功能文档
|
|
28
|
+
PRODUCT_FEATURE_LINK_RE = re.compile(
|
|
29
|
+
r"\[[^\]]+\]\((?:\./)?(功能_[^/)#\s]+\.md)(?:#[^)]+)?\)"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def hash_text(content: str) -> str:
|
|
34
|
+
"""计算 UTF-8 文本的 SHA256,供阶段材料和状态内容绑定使用。"""
|
|
35
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _hash_file_path(full_path: str) -> str:
|
|
39
|
+
digest = hashlib.sha256()
|
|
40
|
+
with open(full_path, "rb") as stream:
|
|
41
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
42
|
+
digest.update(chunk)
|
|
43
|
+
return digest.hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# 计算单个文件的 SHA256 哈希
|
|
47
|
+
# 用于 Verification Invalidation:绑定上游内容,检测变化
|
|
48
|
+
# 文件不存在时返回 None(还没产出过的 stage)
|
|
49
|
+
def compute_file_hash(project_root: str, rel_path: str) -> str | None:
|
|
50
|
+
# 拼出文件的完整路径(项目根 + 相对路径)
|
|
51
|
+
full_path = os.path.join(project_root, rel_path)
|
|
52
|
+
# 文件不存在 → 返回 None
|
|
53
|
+
if not os.path.exists(full_path):
|
|
54
|
+
return None
|
|
55
|
+
return _hash_file_path(full_path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compute_file_hashes(
|
|
59
|
+
project_root: str,
|
|
60
|
+
rel_paths: list[str],
|
|
61
|
+
) -> dict[str, str | None]:
|
|
62
|
+
"""计算一组相对路径的文件哈希,保留当时不存在的文件。"""
|
|
63
|
+
return {
|
|
64
|
+
rel_path: compute_file_hash(project_root, rel_path)
|
|
65
|
+
for rel_path in sorted(set(rel_paths))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def compute_project_file_hashes(project_root: str) -> dict[str, str]:
|
|
70
|
+
"""记录实施阶段可能修改的代码、脚本和配置,用于发现计划外改动。
|
|
71
|
+
|
|
72
|
+
不把 IDE 工作区、说明文档等与实现无关的文件算作代码变化。实施计划明确
|
|
73
|
+
列出的其它类型文件由回退清单单独比较,因此不会漏掉计划内的资源文件。
|
|
74
|
+
"""
|
|
75
|
+
excluded_roots = {
|
|
76
|
+
".git",
|
|
77
|
+
".workflow_loop",
|
|
78
|
+
".venv",
|
|
79
|
+
"node_modules",
|
|
80
|
+
"__pycache__",
|
|
81
|
+
".pytest_cache",
|
|
82
|
+
"dist",
|
|
83
|
+
"build",
|
|
84
|
+
".idea",
|
|
85
|
+
".vscode",
|
|
86
|
+
"spec",
|
|
87
|
+
"acceptance",
|
|
88
|
+
"qa",
|
|
89
|
+
"impl",
|
|
90
|
+
"bug",
|
|
91
|
+
}
|
|
92
|
+
excluded_files = {artifact_paths_mod.TRACEABILITY_DOC}
|
|
93
|
+
_, test_entry_path = _project_test_entry(project_root)
|
|
94
|
+
hashes: dict[str, str] = {}
|
|
95
|
+
for root, dirs, files in os.walk(project_root):
|
|
96
|
+
dirs[:] = [directory for directory in dirs if directory not in excluded_roots]
|
|
97
|
+
for filename in files:
|
|
98
|
+
relative_path = os.path.relpath(os.path.join(root, filename), project_root)
|
|
99
|
+
normalized = relative_path.replace(os.sep, "/")
|
|
100
|
+
if normalized in excluded_files:
|
|
101
|
+
continue
|
|
102
|
+
if not is_implementation_related_path(normalized, test_entry_path):
|
|
103
|
+
continue
|
|
104
|
+
full_path = os.path.join(project_root, relative_path)
|
|
105
|
+
if os.path.islink(full_path) or not os.path.isfile(full_path):
|
|
106
|
+
continue
|
|
107
|
+
hashes[normalized] = _hash_file_path(full_path)
|
|
108
|
+
return dict(sorted(hashes.items()))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_test_related_path(project_root: str, relative_path: str) -> bool:
|
|
112
|
+
"""判断文件是否需要在 test_code 前保存真实内容。"""
|
|
113
|
+
normalized = relative_path.replace(os.sep, "/")
|
|
114
|
+
filename = os.path.basename(normalized)
|
|
115
|
+
_, test_entry_path = _project_test_entry(project_root)
|
|
116
|
+
suffix = os.path.splitext(filename)[1].lower()
|
|
117
|
+
return (
|
|
118
|
+
_is_test_path(normalized)
|
|
119
|
+
or _is_standalone_test_config(normalized, test_entry_path)
|
|
120
|
+
or filename in CONFIG_NAMES
|
|
121
|
+
or suffix in CONFIG_SUFFIXES
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def compute_test_related_file_hashes(project_root: str) -> dict[str, str]:
|
|
126
|
+
return {
|
|
127
|
+
path: content_hash
|
|
128
|
+
for path, content_hash in compute_project_file_hashes(project_root).items()
|
|
129
|
+
if is_test_related_path(project_root, path)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# 读取 产品总说明.md 中真实链接的功能文档路径
|
|
134
|
+
# 产品设计整体哈希以这里返回的文件为准,不扫描目录里的废弃功能文档
|
|
135
|
+
# 已移除历史功能文档只要不再被链接,就不参与当前哈希
|
|
136
|
+
def get_linked_product_design_paths(project_root: str) -> list[str]:
|
|
137
|
+
product_rel = artifact_paths_mod.PRODUCT_OVERVIEW_DOC
|
|
138
|
+
product_path = os.path.join(project_root, product_rel)
|
|
139
|
+
paths = [product_rel]
|
|
140
|
+
if not os.path.exists(product_path):
|
|
141
|
+
return paths
|
|
142
|
+
|
|
143
|
+
with open(product_path, "r", encoding="utf-8") as f:
|
|
144
|
+
content = f.read()
|
|
145
|
+
|
|
146
|
+
for filename in PRODUCT_FEATURE_LINK_RE.findall(content):
|
|
147
|
+
paths.append(os.path.join("spec", filename))
|
|
148
|
+
return sorted(set(paths))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# 对一组文档计算稳定的整体 SHA256
|
|
152
|
+
# 路径也参与哈希,所以新增、删除或替换链接都会改变结果
|
|
153
|
+
def compute_document_set_hash(project_root: str, rel_paths: list[str]) -> str:
|
|
154
|
+
parts = []
|
|
155
|
+
for rel_path in sorted(set(rel_paths)):
|
|
156
|
+
file_hash = compute_file_hash(project_root, rel_path)
|
|
157
|
+
parts.append(f"{rel_path}:{file_hash or '<missing>'}")
|
|
158
|
+
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def normalize_topics(topics: str | list[str] | None) -> list[str]:
|
|
162
|
+
"""兼容旧版单主题参数,并统一返回主题列表。"""
|
|
163
|
+
if topics is None:
|
|
164
|
+
return []
|
|
165
|
+
if isinstance(topics, str):
|
|
166
|
+
return [topics] if topics else []
|
|
167
|
+
return [topic for topic in topics if topic]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# 计算产品总说明及其功能清单链接文档的整体哈希
|
|
171
|
+
def compute_product_design_hash(project_root: str) -> tuple[str | None, list[str]]:
|
|
172
|
+
paths = get_linked_product_design_paths(project_root)
|
|
173
|
+
if compute_file_hash(project_root, artifact_paths_mod.PRODUCT_OVERVIEW_DOC) is None:
|
|
174
|
+
return (None, paths)
|
|
175
|
+
return (compute_document_set_hash(project_root, paths), paths)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# 计算代码设计文档哈希
|
|
179
|
+
def compute_code_design_hash(project_root: str) -> str | None:
|
|
180
|
+
return compute_file_hash(project_root, artifact_paths_mod.CODE_DESIGN_DOC)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# 代码文件后缀;文档、状态和日志不属于代码快照。
|
|
184
|
+
CODE_SUFFIXES = {
|
|
185
|
+
".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx",
|
|
186
|
+
".py", ".pyi", ".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte",
|
|
187
|
+
".go", ".rs", ".java", ".kt", ".kts", ".swift", ".ets",
|
|
188
|
+
".rb", ".php", ".cs", ".fs", ".fsx", ".m", ".mm", ".qml",
|
|
189
|
+
".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd",
|
|
190
|
+
}
|
|
191
|
+
CONFIG_NAMES = {
|
|
192
|
+
"pyproject.toml", "uv.lock", "package.json", "package-lock.json", "yarn.lock",
|
|
193
|
+
"pnpm-lock.yaml", "Cargo.toml", "Cargo.lock", "go.mod", "go.sum",
|
|
194
|
+
"CMakeLists.txt", "CMakePresets.json", "Makefile", "justfile",
|
|
195
|
+
"setup.py", "setup.cfg", "requirements.txt",
|
|
196
|
+
"pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle",
|
|
197
|
+
"settings.gradle.kts", "Gemfile", "Gemfile.lock", "composer.json",
|
|
198
|
+
"composer.lock",
|
|
199
|
+
}
|
|
200
|
+
CONFIG_SUFFIXES = {".pro", ".pri", ".cmake", ".yml", ".yaml"}
|
|
201
|
+
EXCLUDED_CODE_DIRS = {
|
|
202
|
+
".git", ".workflow_loop", "__pycache__", ".venv", "node_modules",
|
|
203
|
+
".pytest_cache", "dist", "build",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
STANDALONE_TEST_CONFIG_NAMES = {
|
|
208
|
+
"pytest.ini", "tox.ini", ".coveragerc", "conftest.py",
|
|
209
|
+
"requirements-test.txt", "requirements-dev.txt", "dev-requirements.txt",
|
|
210
|
+
}
|
|
211
|
+
TEST_CONFIG_PREFIXES = (
|
|
212
|
+
"jest.config.", "vitest.config.", "playwright.config.", "cypress.config.",
|
|
213
|
+
"karma.conf.",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _is_test_path(relative_path: str) -> bool:
|
|
218
|
+
"""判断相对路径是否属于测试代码。"""
|
|
219
|
+
parts = [part.lower() for part in relative_path.replace(os.sep, "/").split("/")]
|
|
220
|
+
filename = parts[-1].lower()
|
|
221
|
+
stem = os.path.splitext(filename)[0]
|
|
222
|
+
test_directories = {
|
|
223
|
+
"tests", "test", "__tests__", "testdata", "test_data",
|
|
224
|
+
"integration_tests", "e2e",
|
|
225
|
+
}
|
|
226
|
+
if any(part in test_directories for part in parts[:-1]):
|
|
227
|
+
return True
|
|
228
|
+
if stem.endswith(("_test", "_spec", ".test", ".spec")):
|
|
229
|
+
return True
|
|
230
|
+
return "src" not in parts[:-1] and filename.startswith(("test_", "tst_"))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _stable_payload(value) -> str:
|
|
234
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _split_pyproject_config(full_path: str) -> tuple[str, str]:
|
|
238
|
+
"""把 pyproject.toml 的测试专用配置和产品配置分开。"""
|
|
239
|
+
with open(full_path, "rb") as stream:
|
|
240
|
+
data = tomllib.load(stream)
|
|
241
|
+
product_data = copy.deepcopy(data)
|
|
242
|
+
test_data: dict = {}
|
|
243
|
+
|
|
244
|
+
tool_data = data.get("tool", {})
|
|
245
|
+
selected_tools = {
|
|
246
|
+
key: value
|
|
247
|
+
for key, value in tool_data.items()
|
|
248
|
+
if key in {"pytest", "coverage", "tox"}
|
|
249
|
+
}
|
|
250
|
+
if selected_tools:
|
|
251
|
+
test_data["tool"] = selected_tools
|
|
252
|
+
product_tool = product_data.get("tool", {})
|
|
253
|
+
for key in selected_tools:
|
|
254
|
+
product_tool.pop(key, None)
|
|
255
|
+
if not product_tool:
|
|
256
|
+
product_data.pop("tool", None)
|
|
257
|
+
|
|
258
|
+
optional_dependencies = data.get("project", {}).get("optional-dependencies", {})
|
|
259
|
+
selected_dependencies = {
|
|
260
|
+
key: value
|
|
261
|
+
for key, value in optional_dependencies.items()
|
|
262
|
+
if key.lower() in {"dev", "test", "tests"}
|
|
263
|
+
}
|
|
264
|
+
if selected_dependencies:
|
|
265
|
+
test_data.setdefault("project", {})["optional-dependencies"] = selected_dependencies
|
|
266
|
+
product_optional = (
|
|
267
|
+
product_data.get("project", {}).get("optional-dependencies", {})
|
|
268
|
+
)
|
|
269
|
+
for key in selected_dependencies:
|
|
270
|
+
product_optional.pop(key, None)
|
|
271
|
+
if not product_optional:
|
|
272
|
+
product_data.get("project", {}).pop("optional-dependencies", None)
|
|
273
|
+
|
|
274
|
+
dependency_groups = data.get("dependency-groups", {})
|
|
275
|
+
selected_groups = {
|
|
276
|
+
key: value
|
|
277
|
+
for key, value in dependency_groups.items()
|
|
278
|
+
if key.lower() in {"dev", "test", "tests"}
|
|
279
|
+
}
|
|
280
|
+
if selected_groups:
|
|
281
|
+
test_data["dependency-groups"] = selected_groups
|
|
282
|
+
product_groups = product_data.get("dependency-groups", {})
|
|
283
|
+
for key in selected_groups:
|
|
284
|
+
product_groups.pop(key, None)
|
|
285
|
+
if not product_groups:
|
|
286
|
+
product_data.pop("dependency-groups", None)
|
|
287
|
+
|
|
288
|
+
return _stable_payload(test_data), _stable_payload(product_data)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _split_package_json_config(full_path: str) -> tuple[str, str]:
|
|
292
|
+
"""把 package.json 中的测试脚本、测试工具配置和测试依赖分开。"""
|
|
293
|
+
with open(full_path, "r", encoding="utf-8") as stream:
|
|
294
|
+
data = json.load(stream)
|
|
295
|
+
product_data = copy.deepcopy(data)
|
|
296
|
+
test_data: dict = {}
|
|
297
|
+
|
|
298
|
+
scripts = data.get("scripts", {})
|
|
299
|
+
selected_scripts = {
|
|
300
|
+
key: value
|
|
301
|
+
for key, value in scripts.items()
|
|
302
|
+
if key == "test" or key.startswith("test:")
|
|
303
|
+
}
|
|
304
|
+
if selected_scripts:
|
|
305
|
+
test_data["scripts"] = selected_scripts
|
|
306
|
+
product_scripts = product_data.get("scripts", {})
|
|
307
|
+
for key in selected_scripts:
|
|
308
|
+
product_scripts.pop(key, None)
|
|
309
|
+
if not product_scripts:
|
|
310
|
+
product_data.pop("scripts", None)
|
|
311
|
+
|
|
312
|
+
for key in ("jest", "vitest", "playwright", "cypress"):
|
|
313
|
+
if key in data:
|
|
314
|
+
test_data[key] = data[key]
|
|
315
|
+
product_data.pop(key, None)
|
|
316
|
+
|
|
317
|
+
dev_dependencies = data.get("devDependencies", {})
|
|
318
|
+
selected_dev_dependencies = {
|
|
319
|
+
key: value
|
|
320
|
+
for key, value in dev_dependencies.items()
|
|
321
|
+
if any(
|
|
322
|
+
token in key.lower()
|
|
323
|
+
for token in (
|
|
324
|
+
"test", "jest", "vitest", "mocha", "chai", "sinon", "ava", "tap",
|
|
325
|
+
"playwright", "cypress", "testing-library", "nyc", "coverage",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
if selected_dev_dependencies:
|
|
330
|
+
test_data["devDependencies"] = selected_dev_dependencies
|
|
331
|
+
product_dev_dependencies = product_data.get("devDependencies", {})
|
|
332
|
+
for key in selected_dev_dependencies:
|
|
333
|
+
product_dev_dependencies.pop(key, None)
|
|
334
|
+
if not product_dev_dependencies:
|
|
335
|
+
product_data.pop("devDependencies", None)
|
|
336
|
+
|
|
337
|
+
return _stable_payload(test_data), _stable_payload(product_data)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _split_setup_cfg(full_path: str) -> tuple[str, str]:
|
|
341
|
+
parser = configparser.ConfigParser()
|
|
342
|
+
parser.read(full_path, encoding="utf-8")
|
|
343
|
+
test_sections = {
|
|
344
|
+
section: dict(parser[section])
|
|
345
|
+
for section in parser.sections()
|
|
346
|
+
if section.startswith(("tool:pytest", "coverage:", "tox:"))
|
|
347
|
+
}
|
|
348
|
+
product_sections = {
|
|
349
|
+
section: dict(parser[section])
|
|
350
|
+
for section in parser.sections()
|
|
351
|
+
if section not in test_sections
|
|
352
|
+
}
|
|
353
|
+
return _stable_payload(test_sections), _stable_payload(product_sections)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _project_test_entry(project_root: str) -> tuple[str, str | None]:
|
|
357
|
+
"""返回 (稳定编码后的入口配置, 入口脚本相对路径)。
|
|
358
|
+
|
|
359
|
+
入口配置是操作系统到参数数组的完整映射,稳定编码进测试代码快照;
|
|
360
|
+
入口脚本路径用于识别测试相关文件(脚本内容变化 → 测试快照变化)。
|
|
361
|
+
"""
|
|
362
|
+
project = load_project(project_root)
|
|
363
|
+
raw_config = project.test_entry if project is not None else {}
|
|
364
|
+
if isinstance(raw_config, str):
|
|
365
|
+
raw_config = {"default": [raw_config]} if raw_config.strip() else {}
|
|
366
|
+
if not isinstance(raw_config, dict):
|
|
367
|
+
raw_config = {}
|
|
368
|
+
encoded = json.dumps(raw_config, ensure_ascii=False, sort_keys=True)
|
|
369
|
+
|
|
370
|
+
# 在任一平台参数中找项目内脚本路径(含 / 或常见脚本后缀的参数)
|
|
371
|
+
entry_path = None
|
|
372
|
+
for argv in raw_config.values():
|
|
373
|
+
if not isinstance(argv, list):
|
|
374
|
+
continue
|
|
375
|
+
for part in argv:
|
|
376
|
+
if not isinstance(part, str) or part.startswith("-"):
|
|
377
|
+
continue
|
|
378
|
+
if "/" in part or "\\" in part or part.endswith(
|
|
379
|
+
(".sh", ".bash", ".zsh", ".py", ".js", ".ts", ".ps1", ".bat", ".cmd")
|
|
380
|
+
):
|
|
381
|
+
entry_path = part.replace("\\", "/")
|
|
382
|
+
break
|
|
383
|
+
if entry_path:
|
|
384
|
+
break
|
|
385
|
+
if entry_path is not None:
|
|
386
|
+
entry_path = os.path.normpath(entry_path).replace(os.sep, "/")
|
|
387
|
+
if os.path.isabs(entry_path):
|
|
388
|
+
try:
|
|
389
|
+
relative_entry = os.path.relpath(entry_path, project_root)
|
|
390
|
+
except ValueError:
|
|
391
|
+
relative_entry = entry_path
|
|
392
|
+
if relative_entry != ".." and not relative_entry.startswith(f"..{os.sep}"):
|
|
393
|
+
entry_path = relative_entry.replace(os.sep, "/")
|
|
394
|
+
return encoded, entry_path
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _is_standalone_test_config(relative_path: str, test_entry_path: str | None) -> bool:
|
|
398
|
+
normalized = relative_path.replace(os.sep, "/")
|
|
399
|
+
filename = os.path.basename(normalized).lower()
|
|
400
|
+
return (
|
|
401
|
+
normalized == test_entry_path
|
|
402
|
+
or filename in STANDALONE_TEST_CONFIG_NAMES
|
|
403
|
+
or filename.startswith(TEST_CONFIG_PREFIXES)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def is_implementation_related_path(
|
|
408
|
+
relative_path: str,
|
|
409
|
+
test_entry_path: str | None = None,
|
|
410
|
+
) -> bool:
|
|
411
|
+
"""判断路径是否属于实施代码、脚本、测试或项目配置。"""
|
|
412
|
+
normalized = relative_path.replace(os.sep, "/")
|
|
413
|
+
filename = os.path.basename(normalized)
|
|
414
|
+
suffix = os.path.splitext(filename)[1].lower()
|
|
415
|
+
return (
|
|
416
|
+
_is_test_path(normalized)
|
|
417
|
+
or _is_standalone_test_config(normalized, test_entry_path)
|
|
418
|
+
or suffix in CODE_SUFFIXES
|
|
419
|
+
or filename in CONFIG_NAMES
|
|
420
|
+
or suffix in CONFIG_SUFFIXES
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _snapshot_parts(project_root: str) -> tuple[list[str], list[str]]:
|
|
425
|
+
"""返回测试部分和产品部分的稳定哈希输入。"""
|
|
426
|
+
test_parts: list[str] = []
|
|
427
|
+
product_parts: list[str] = []
|
|
428
|
+
test_entry, test_entry_path = _project_test_entry(project_root)
|
|
429
|
+
test_parts.append(f".workflow_loop/project.json#test_entry:{hashlib.sha256(test_entry.encode('utf-8')).hexdigest()}")
|
|
430
|
+
|
|
431
|
+
for root, dirs, files in os.walk(project_root):
|
|
432
|
+
dirs[:] = [directory for directory in dirs if directory not in EXCLUDED_CODE_DIRS]
|
|
433
|
+
for filename in files:
|
|
434
|
+
relative_path = os.path.relpath(os.path.join(root, filename), project_root)
|
|
435
|
+
is_test_path = _is_test_path(relative_path)
|
|
436
|
+
is_test_config = _is_standalone_test_config(relative_path, test_entry_path)
|
|
437
|
+
suffix = os.path.splitext(filename)[1].lower()
|
|
438
|
+
is_project_config = filename in CONFIG_NAMES or suffix in CONFIG_SUFFIXES
|
|
439
|
+
if (
|
|
440
|
+
not is_test_path
|
|
441
|
+
and not is_test_config
|
|
442
|
+
and suffix not in CODE_SUFFIXES
|
|
443
|
+
and not is_project_config
|
|
444
|
+
):
|
|
445
|
+
continue
|
|
446
|
+
full_path = os.path.join(project_root, relative_path)
|
|
447
|
+
try:
|
|
448
|
+
raw_hash = _hash_file_path(full_path)
|
|
449
|
+
except OSError:
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
if relative_path == "pyproject.toml":
|
|
453
|
+
try:
|
|
454
|
+
test_payload, product_payload = _split_pyproject_config(full_path)
|
|
455
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
456
|
+
test_parts.append(f"{relative_path}#test-fallback:{raw_hash}")
|
|
457
|
+
product_parts.append(f"{relative_path}#product-fallback:{raw_hash}")
|
|
458
|
+
else:
|
|
459
|
+
test_parts.append(
|
|
460
|
+
f"{relative_path}#test:{hashlib.sha256(test_payload.encode('utf-8')).hexdigest()}"
|
|
461
|
+
)
|
|
462
|
+
product_parts.append(
|
|
463
|
+
f"{relative_path}#product:{hashlib.sha256(product_payload.encode('utf-8')).hexdigest()}"
|
|
464
|
+
)
|
|
465
|
+
continue
|
|
466
|
+
if relative_path == "package.json":
|
|
467
|
+
try:
|
|
468
|
+
test_payload, product_payload = _split_package_json_config(full_path)
|
|
469
|
+
except (OSError, json.JSONDecodeError):
|
|
470
|
+
test_parts.append(f"{relative_path}#test-fallback:{raw_hash}")
|
|
471
|
+
product_parts.append(f"{relative_path}#product-fallback:{raw_hash}")
|
|
472
|
+
else:
|
|
473
|
+
test_parts.append(
|
|
474
|
+
f"{relative_path}#test:{hashlib.sha256(test_payload.encode('utf-8')).hexdigest()}"
|
|
475
|
+
)
|
|
476
|
+
product_parts.append(
|
|
477
|
+
f"{relative_path}#product:{hashlib.sha256(product_payload.encode('utf-8')).hexdigest()}"
|
|
478
|
+
)
|
|
479
|
+
continue
|
|
480
|
+
if relative_path == "setup.cfg":
|
|
481
|
+
try:
|
|
482
|
+
test_payload, product_payload = _split_setup_cfg(full_path)
|
|
483
|
+
except (OSError, configparser.Error):
|
|
484
|
+
test_parts.append(f"{relative_path}#test-fallback:{raw_hash}")
|
|
485
|
+
product_parts.append(f"{relative_path}#product-fallback:{raw_hash}")
|
|
486
|
+
else:
|
|
487
|
+
test_parts.append(
|
|
488
|
+
f"{relative_path}#test:{hashlib.sha256(test_payload.encode('utf-8')).hexdigest()}"
|
|
489
|
+
)
|
|
490
|
+
product_parts.append(
|
|
491
|
+
f"{relative_path}#product:{hashlib.sha256(product_payload.encode('utf-8')).hexdigest()}"
|
|
492
|
+
)
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
target = test_parts if is_test_path or is_test_config else product_parts
|
|
496
|
+
target.append(f"{relative_path}:{raw_hash}")
|
|
497
|
+
return sorted(test_parts), sorted(product_parts)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _compute_code_snapshot_hash(project_root: str, *, test_only: bool | None) -> str:
|
|
501
|
+
"""按范围计算代码快照:全部、仅测试或排除测试。"""
|
|
502
|
+
test_parts, product_parts = _snapshot_parts(project_root)
|
|
503
|
+
if test_only is True:
|
|
504
|
+
parts = test_parts
|
|
505
|
+
elif test_only is False:
|
|
506
|
+
parts = product_parts
|
|
507
|
+
else:
|
|
508
|
+
parts = [*test_parts, *product_parts]
|
|
509
|
+
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# 计算项目全部代码的快照哈希(impl_hash 和全量测试基线使用)
|
|
513
|
+
def compute_code_snapshot_hash(project_root: str) -> str:
|
|
514
|
+
return _compute_code_snapshot_hash(project_root, test_only=None)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# 计算测试代码快照哈希(test_code 阶段确认是否真的写了测试代码)
|
|
518
|
+
def compute_test_code_snapshot_hash(project_root: str) -> str:
|
|
519
|
+
return _compute_code_snapshot_hash(project_root, test_only=True)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# 计算排除测试代码后的产品代码快照哈希(阻止 test_code 阶段修改产品代码)
|
|
523
|
+
def compute_non_test_code_snapshot_hash(project_root: str) -> str:
|
|
524
|
+
return _compute_code_snapshot_hash(project_root, test_only=False)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# 计算实施阶段使用的实施综合哈希(impl_hash)
|
|
528
|
+
# 包含两部分:impl/ 下全部实施记录内容哈希 + 非测试代码快照哈希
|
|
529
|
+
# test_code 阶段后续修改测试代码,不应让已经确认的实施结果失效。
|
|
530
|
+
def compute_impl_hash(project_root: str, topics: str | list[str] | None = None) -> str:
|
|
531
|
+
# 收集哈希的各部分
|
|
532
|
+
parts = []
|
|
533
|
+
# 实施任务与验收主题不一定一一对应,因此绑定 impl/ 下全部实施记录。
|
|
534
|
+
impl_dir = os.path.join(project_root, "impl")
|
|
535
|
+
if os.path.isdir(impl_dir):
|
|
536
|
+
impl_paths = [
|
|
537
|
+
os.path.join("impl", filename)
|
|
538
|
+
for filename in os.listdir(impl_dir)
|
|
539
|
+
if filename.endswith(".md")
|
|
540
|
+
]
|
|
541
|
+
if impl_paths:
|
|
542
|
+
parts.append(f"impl_docs:{compute_document_set_hash(project_root, impl_paths)}")
|
|
543
|
+
# 只加入非测试代码快照,测试代码由 test_code 阶段单独校验。
|
|
544
|
+
parts.append(f"code_snapshot:{compute_non_test_code_snapshot_hash(project_root)}")
|
|
545
|
+
# 合并所有部分算最终 SHA256
|
|
546
|
+
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# 计算测试计划文件 qa/<主题文件标识>_测试计划.md 的 SHA256
|
|
550
|
+
# 在 gate test_plan --confirmed 时记录;变化时使主题执行及其后续结果失效
|
|
551
|
+
def compute_test_plan_hash(project_root: str, topics: str | list[str] | None) -> str | None:
|
|
552
|
+
topic_list = normalize_topics(topics)
|
|
553
|
+
if not topic_list:
|
|
554
|
+
return None
|
|
555
|
+
paths = [topic_paths(project_root, topic)["test_plan"] for topic in topic_list]
|
|
556
|
+
return compute_document_set_hash(project_root, paths)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# 计算验收计划文件和验收主题索引的 SHA256
|
|
560
|
+
# 在 gate acceptance_plan --confirmed 时记录
|
|
561
|
+
# acceptance_plan 或主题关系变化时把 test_plan 和后续阶段退回待检查
|
|
562
|
+
def compute_acceptance_plan_hash(project_root: str, topics: str | list[str] | None) -> str | None:
|
|
563
|
+
topic_list = normalize_topics(topics)
|
|
564
|
+
if not topic_list:
|
|
565
|
+
return None
|
|
566
|
+
paths = [
|
|
567
|
+
artifact_paths_mod.ACCEPTANCE_INDEX_DOC,
|
|
568
|
+
*[topic_paths(project_root, topic)["acceptance_plan"] for topic in topic_list],
|
|
569
|
+
]
|
|
570
|
+
return compute_document_set_hash(project_root, paths)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# 计算测试结果文件 qa/<主题文件标识>_测试结果.md 的 SHA256
|
|
574
|
+
# 在 gate test_execution --confirmed 时记录;变化时使主题验收及后续阶段失效
|
|
575
|
+
def compute_test_result_hash(project_root: str, topics: str | list[str] | None) -> str | None:
|
|
576
|
+
topic_list = normalize_topics(topics)
|
|
577
|
+
if not topic_list:
|
|
578
|
+
return None
|
|
579
|
+
paths = [
|
|
580
|
+
topic_paths(project_root, topic)["test_result"]
|
|
581
|
+
for topic in automated_topics(project_root, topic_list)
|
|
582
|
+
]
|
|
583
|
+
if not paths:
|
|
584
|
+
return hashlib.sha256(b"<no-automated-test-results>").hexdigest()
|
|
585
|
+
return compute_document_set_hash(project_root, paths)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# 计算主题验收结果文件 acceptance/<主题文件标识>_验收结果.md 的 SHA256
|
|
589
|
+
# 在 gate topic_acceptance --confirmed 时记录;变化时使最终回归及后续阶段失效
|
|
590
|
+
def compute_acceptance_result_hash(project_root: str, topics: str | list[str] | None) -> str | None:
|
|
591
|
+
topic_list = normalize_topics(topics)
|
|
592
|
+
if not topic_list:
|
|
593
|
+
return None
|
|
594
|
+
paths = [topic_paths(project_root, topic)["acceptance_result"] for topic in topic_list]
|
|
595
|
+
document_hash = compute_document_set_hash(project_root, paths)
|
|
596
|
+
state = load_state(project_root)
|
|
597
|
+
records = (
|
|
598
|
+
acceptance_records_mod.acceptance_records_payload(state, topic_list)
|
|
599
|
+
if state is not None
|
|
600
|
+
else {}
|
|
601
|
+
)
|
|
602
|
+
payload = json.dumps(records, ensure_ascii=False, sort_keys=True)
|
|
603
|
+
return hashlib.sha256(
|
|
604
|
+
f"documents:{document_hash}\nrecords:{payload}".encode("utf-8")
|
|
605
|
+
).hexdigest()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def compute_regression_test_result_hash(project_root: str) -> str | None:
|
|
609
|
+
state = load_state(project_root)
|
|
610
|
+
if state is None:
|
|
611
|
+
return None
|
|
612
|
+
payload = json.dumps(state.regression_test.__dict__, ensure_ascii=False, sort_keys=True)
|
|
613
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# 清零单个 stage 的所有门禁状态(3 道闸全清,状态回 pending)
|
|
617
|
+
# 用于 Verification Invalidation:上游变化时清零下游
|
|
618
|
+
def clear_stage_gates(stage: StageState) -> None:
|
|
619
|
+
# 重置 3 道闸为全新 GateState(全 False)
|
|
620
|
+
stage.gate = GateState()
|
|
621
|
+
# stage 状态回到 pending(需要重新走 7 步模式)
|
|
622
|
+
stage.status = "pending"
|
|
623
|
+
# 下游失效后,旧产物基线和 impl 代码基线也不能继续复用。
|
|
624
|
+
stage.artifact_produced_at = None
|
|
625
|
+
stage.artifact_baseline_captured_at = None
|
|
626
|
+
stage.artifact_baseline_hashes = {}
|
|
627
|
+
stage.code_baseline_hash = None
|
|
628
|
+
stage.test_code_baseline_hash = None
|
|
629
|
+
stage.non_test_code_baseline_hash = None
|
|
630
|
+
stage.existing_code_accepted_hash = None
|
|
631
|
+
stage.existing_test_code_accepted_hash = None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def set_recovery_context(
|
|
635
|
+
state: WorkflowState,
|
|
636
|
+
source_stage: str,
|
|
637
|
+
affected_stages: list[str],
|
|
638
|
+
reason: str,
|
|
639
|
+
) -> None:
|
|
640
|
+
"""保存退回原因,让后续命令能解释当前阶段是复核还是重做。"""
|
|
641
|
+
state.recovery = RecoveryContext(
|
|
642
|
+
source_stage=source_stage,
|
|
643
|
+
reason=reason,
|
|
644
|
+
affected_stages=list(affected_stages),
|
|
645
|
+
created_at=now_iso(),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def clear_completed_material_recovery(state: WorkflowState) -> bool:
|
|
650
|
+
"""引发恢复的阶段重新确认完成后,清除当前提示,历史留在 Journal。"""
|
|
651
|
+
recovery = state.recovery
|
|
652
|
+
if not recovery.source_stage or not recovery.reason:
|
|
653
|
+
return False
|
|
654
|
+
source_state = state.stages.get(recovery.source_stage)
|
|
655
|
+
if source_state is None or source_state.status != "done":
|
|
656
|
+
return False
|
|
657
|
+
state.recovery = RecoveryContext()
|
|
658
|
+
return True
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def recovery_stage_action(state: WorkflowState, stage_name: str) -> str | None:
|
|
662
|
+
"""返回当前恢复阶段的具体动作,避免把复核误说成重新开发。"""
|
|
663
|
+
recovery = state.recovery
|
|
664
|
+
if not recovery.source_stage or stage_name not in recovery.affected_stages:
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
if recovery.reason and "流程模板或规范" in recovery.reason:
|
|
668
|
+
return (
|
|
669
|
+
"重新阅读更新后的流程材料,并按新规则核对当前产出;"
|
|
670
|
+
"只有新规则使现有产出不合格时才修改"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if stage_name in {"spec", "reproduce", "code_design", "revise_code_design", "spike"}:
|
|
674
|
+
return "重新核对上游事实和设计;只有内容确实不一致时才修改文档"
|
|
675
|
+
if stage_name in {"acceptance_plan", "test_plan"}:
|
|
676
|
+
return "重新核对上游文档和当前计划;已有内容仍正确时不需要为了门禁重写"
|
|
677
|
+
if stage_name == "impl":
|
|
678
|
+
return (
|
|
679
|
+
"重新核对实施计划、实施记录和现有代码是否符合最新上游计划;"
|
|
680
|
+
"一致时确认既有代码,不一致时才修改代码"
|
|
681
|
+
)
|
|
682
|
+
if stage_name == "test_code":
|
|
683
|
+
return (
|
|
684
|
+
"重新核对测试计划与现有测试代码的对应关系;一致时确认既有测试代码,"
|
|
685
|
+
"不一致时才修改测试代码"
|
|
686
|
+
)
|
|
687
|
+
if stage_name == "test_execution":
|
|
688
|
+
return "旧测试结果不能继续使用,重新登记并执行需要测试的主题"
|
|
689
|
+
if stage_name == "topic_acceptance":
|
|
690
|
+
return "使用新的主题测试结果重新逐条验收;不能直接沿用旧验收结果"
|
|
691
|
+
if stage_name == "regression_test":
|
|
692
|
+
return "重新执行全量回归;旧回归状态不能代表当前代码"
|
|
693
|
+
if stage_name == "overall_acceptance":
|
|
694
|
+
return "根据最新主题验收和全量回归结果重新做整体验收"
|
|
695
|
+
if stage_name == "update_code_design":
|
|
696
|
+
return "根据重新确认后的真实代码和验收结果更新详细代码设计"
|
|
697
|
+
return "重新核对当前阶段产出是否仍符合上游结果"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def recovery_summary(state: WorkflowState) -> str | None:
|
|
701
|
+
"""返回一行可直接显示给用户的恢复原因。"""
|
|
702
|
+
recovery = state.recovery
|
|
703
|
+
if not recovery.source_stage or not recovery.reason:
|
|
704
|
+
return None
|
|
705
|
+
return f"{recovery.source_stage} 相关内容需要重新处理:{recovery.reason}"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def reset_stages_and_move_current(state: WorkflowState, stage_names: list[str]) -> None:
|
|
709
|
+
"""清零指定阶段,并把当前阶段退回到路径中最早的受影响阶段。"""
|
|
710
|
+
affected = []
|
|
711
|
+
for stage_name in stage_names:
|
|
712
|
+
if stage_name in state.stages:
|
|
713
|
+
clear_stage_gates(state.stages[stage_name])
|
|
714
|
+
affected.append(stage_name)
|
|
715
|
+
|
|
716
|
+
if not affected:
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
order = {stage_name: index for index, stage_name in enumerate(state.stage_path)}
|
|
720
|
+
earliest = min(affected, key=lambda stage_name: order.get(stage_name, len(order)))
|
|
721
|
+
state.current_stage = earliest
|
|
722
|
+
state.stages[earliest].status = "in_progress"
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _invalidate_test_execution_outputs(
|
|
726
|
+
project_root: str,
|
|
727
|
+
state: WorkflowState,
|
|
728
|
+
topics: list[str],
|
|
729
|
+
) -> None:
|
|
730
|
+
"""上游内容变化时,清掉不能继续使用的主题测试状态和结果文件。"""
|
|
731
|
+
stage_state = state.stages.get("test_execution")
|
|
732
|
+
if stage_state is not None:
|
|
733
|
+
stage_state.test_tasks = {}
|
|
734
|
+
for topic in topics:
|
|
735
|
+
paths = topic_paths(project_root, topic)
|
|
736
|
+
for kind in ("test_result", "acceptance_result"):
|
|
737
|
+
result_path = os.path.join(project_root, paths[kind])
|
|
738
|
+
if os.path.isfile(result_path):
|
|
739
|
+
os.remove(result_path)
|
|
740
|
+
acceptance_records_mod.clear_topic_records(project_root, state, topics)
|
|
741
|
+
state.regression_test = RegressionTestState()
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# 检查 Verification Invalidation:上游内容是否变化,变化则清零下游
|
|
745
|
+
# 在进入下游 stage 的第 2 道闸(gate 无 flag)时调用
|
|
746
|
+
# 返回失效列表:[(变化的源头, 被清零的下游), ...]
|
|
747
|
+
def check_invalidation(state: WorkflowState, project_root: str) -> list[tuple[str, str]]:
|
|
748
|
+
invalidations: list[tuple[str, str]] = []
|
|
749
|
+
topics = state.topics or ([state.topic] if state.topic else [])
|
|
750
|
+
|
|
751
|
+
# 验收计划决定后续全部工作。内容、主题新增或主题删除后,从验收计划重新开始。
|
|
752
|
+
if state.verification.acceptance_plan_hash is not None:
|
|
753
|
+
current_topics = candidate_topics(project_root)
|
|
754
|
+
current_ap = compute_acceptance_plan_hash(project_root, current_topics)
|
|
755
|
+
if current_ap != state.verification.acceptance_plan_hash:
|
|
756
|
+
affected_stages = [
|
|
757
|
+
"acceptance_plan",
|
|
758
|
+
"test_plan",
|
|
759
|
+
"impl",
|
|
760
|
+
"test_code",
|
|
761
|
+
"test_execution",
|
|
762
|
+
"topic_acceptance",
|
|
763
|
+
"regression_test",
|
|
764
|
+
"overall_acceptance",
|
|
765
|
+
"update_code_design",
|
|
766
|
+
]
|
|
767
|
+
reset_stages_and_move_current(
|
|
768
|
+
state,
|
|
769
|
+
affected_stages,
|
|
770
|
+
)
|
|
771
|
+
set_recovery_context(
|
|
772
|
+
state,
|
|
773
|
+
"acceptance_plan",
|
|
774
|
+
affected_stages,
|
|
775
|
+
"验收主题或验收条件已经改变,后续计划、代码和结果必须重新核对",
|
|
776
|
+
)
|
|
777
|
+
state.verification.acceptance_plan_hash = None
|
|
778
|
+
state.verification.test_plan_hash = None
|
|
779
|
+
state.verification.impl_hash = None
|
|
780
|
+
state.verification.test_code_hash = None
|
|
781
|
+
state.verification.test_result_hash = None
|
|
782
|
+
state.verification.acceptance_result_hash = None
|
|
783
|
+
state.verification.regression_test_result_hash = None
|
|
784
|
+
traceability_mod.reset_after_upstream_invalidation(
|
|
785
|
+
project_root,
|
|
786
|
+
state.workflow_id,
|
|
787
|
+
topics,
|
|
788
|
+
"acceptance_plan",
|
|
789
|
+
)
|
|
790
|
+
_invalidate_test_execution_outputs(project_root, state, topics)
|
|
791
|
+
invalidations.append(("acceptance_plan", "acceptance_plan 及全部后续阶段"))
|
|
792
|
+
return invalidations
|
|
793
|
+
|
|
794
|
+
# 测试计划变化后,测试计划本身、实施计划和执行结果都必须重新确认。
|
|
795
|
+
if state.verification.test_plan_hash is not None:
|
|
796
|
+
current_tp = compute_test_plan_hash(project_root, topics)
|
|
797
|
+
if current_tp != state.verification.test_plan_hash:
|
|
798
|
+
affected_stages = [
|
|
799
|
+
"test_plan",
|
|
800
|
+
"impl",
|
|
801
|
+
"test_code",
|
|
802
|
+
"test_execution",
|
|
803
|
+
"topic_acceptance",
|
|
804
|
+
"regression_test",
|
|
805
|
+
"overall_acceptance",
|
|
806
|
+
"update_code_design",
|
|
807
|
+
]
|
|
808
|
+
reset_stages_and_move_current(
|
|
809
|
+
state,
|
|
810
|
+
affected_stages,
|
|
811
|
+
)
|
|
812
|
+
set_recovery_context(
|
|
813
|
+
state,
|
|
814
|
+
"test_plan",
|
|
815
|
+
affected_stages,
|
|
816
|
+
"测试项、测试方式或测试范围已经改变,后续实施和测试必须重新核对",
|
|
817
|
+
)
|
|
818
|
+
state.verification.test_plan_hash = None
|
|
819
|
+
state.verification.impl_hash = None
|
|
820
|
+
state.verification.test_code_hash = None
|
|
821
|
+
state.verification.test_result_hash = None
|
|
822
|
+
state.verification.acceptance_result_hash = None
|
|
823
|
+
state.verification.regression_test_result_hash = None
|
|
824
|
+
traceability_mod.reset_after_upstream_invalidation(
|
|
825
|
+
project_root,
|
|
826
|
+
state.workflow_id,
|
|
827
|
+
topics,
|
|
828
|
+
"test_plan",
|
|
829
|
+
)
|
|
830
|
+
_invalidate_test_execution_outputs(project_root, state, topics)
|
|
831
|
+
invalidations.append(("test_plan", "test_plan 及全部后续阶段"))
|
|
832
|
+
return invalidations
|
|
833
|
+
|
|
834
|
+
# 实施代码或实施记录变化后,必须返回实施阶段重新确认。
|
|
835
|
+
if state.verification.impl_hash is not None:
|
|
836
|
+
current_impl = compute_impl_hash(project_root, topics)
|
|
837
|
+
if current_impl != state.verification.impl_hash:
|
|
838
|
+
affected_stages = [
|
|
839
|
+
"impl",
|
|
840
|
+
"test_code",
|
|
841
|
+
"test_execution",
|
|
842
|
+
"topic_acceptance",
|
|
843
|
+
"regression_test",
|
|
844
|
+
"overall_acceptance",
|
|
845
|
+
"update_code_design",
|
|
846
|
+
]
|
|
847
|
+
reset_stages_and_move_current(
|
|
848
|
+
state,
|
|
849
|
+
affected_stages,
|
|
850
|
+
)
|
|
851
|
+
set_recovery_context(
|
|
852
|
+
state,
|
|
853
|
+
"impl",
|
|
854
|
+
affected_stages,
|
|
855
|
+
"实施代码或实施记录已经改变,原测试和验收结果不能继续代表当前实现",
|
|
856
|
+
)
|
|
857
|
+
state.verification.impl_hash = None
|
|
858
|
+
state.verification.test_code_hash = None
|
|
859
|
+
state.verification.test_result_hash = None
|
|
860
|
+
state.verification.acceptance_result_hash = None
|
|
861
|
+
state.verification.regression_test_result_hash = None
|
|
862
|
+
_invalidate_test_execution_outputs(project_root, state, topics)
|
|
863
|
+
invalidations.append(("impl", "impl 及全部后续阶段"))
|
|
864
|
+
return invalidations
|
|
865
|
+
|
|
866
|
+
# 已确认测试代码或测试配置变化后,返回测试代码阶段。
|
|
867
|
+
if state.verification.test_code_hash is not None:
|
|
868
|
+
current_test_code = compute_test_code_snapshot_hash(project_root)
|
|
869
|
+
if current_test_code != state.verification.test_code_hash:
|
|
870
|
+
affected_stages = [
|
|
871
|
+
"test_code",
|
|
872
|
+
"test_execution",
|
|
873
|
+
"topic_acceptance",
|
|
874
|
+
"regression_test",
|
|
875
|
+
"overall_acceptance",
|
|
876
|
+
"update_code_design",
|
|
877
|
+
]
|
|
878
|
+
reset_stages_and_move_current(
|
|
879
|
+
state,
|
|
880
|
+
affected_stages,
|
|
881
|
+
)
|
|
882
|
+
set_recovery_context(
|
|
883
|
+
state,
|
|
884
|
+
"test_code",
|
|
885
|
+
affected_stages,
|
|
886
|
+
"测试代码、测试配置或统一测试入口已经改变,旧执行记录必须作废",
|
|
887
|
+
)
|
|
888
|
+
state.verification.test_code_hash = None
|
|
889
|
+
state.verification.test_result_hash = None
|
|
890
|
+
state.verification.acceptance_result_hash = None
|
|
891
|
+
state.verification.regression_test_result_hash = None
|
|
892
|
+
_invalidate_test_execution_outputs(project_root, state, topics)
|
|
893
|
+
invalidations.append(("test_code", "test_code 及全部后续阶段"))
|
|
894
|
+
return invalidations
|
|
895
|
+
|
|
896
|
+
# 某个主题的测试结果变化后,从主题验收重新确认。
|
|
897
|
+
if state.verification.test_result_hash is not None:
|
|
898
|
+
current_test_result = compute_test_result_hash(project_root, topics)
|
|
899
|
+
if current_test_result != state.verification.test_result_hash:
|
|
900
|
+
affected_stages = [
|
|
901
|
+
"topic_acceptance",
|
|
902
|
+
"regression_test",
|
|
903
|
+
"overall_acceptance",
|
|
904
|
+
"update_code_design",
|
|
905
|
+
]
|
|
906
|
+
reset_stages_and_move_current(
|
|
907
|
+
state,
|
|
908
|
+
affected_stages,
|
|
909
|
+
)
|
|
910
|
+
set_recovery_context(
|
|
911
|
+
state,
|
|
912
|
+
"test_execution",
|
|
913
|
+
affected_stages,
|
|
914
|
+
"主题测试结果已经改变,旧主题验收和后续结论必须重新确认",
|
|
915
|
+
)
|
|
916
|
+
state.verification.test_result_hash = None
|
|
917
|
+
state.verification.acceptance_result_hash = None
|
|
918
|
+
state.verification.regression_test_result_hash = None
|
|
919
|
+
acceptance_records_mod.clear_topic_records(project_root, state, topics)
|
|
920
|
+
invalidations.append(("test_execution", "topic_acceptance 及全部后续阶段"))
|
|
921
|
+
return invalidations
|
|
922
|
+
|
|
923
|
+
# 某个主题的验收结果变化后,从最终全量回归重新确认。
|
|
924
|
+
if state.verification.acceptance_result_hash is not None:
|
|
925
|
+
current_acceptance_result = compute_acceptance_result_hash(project_root, topics)
|
|
926
|
+
if current_acceptance_result != state.verification.acceptance_result_hash:
|
|
927
|
+
affected_stages = ["regression_test", "overall_acceptance", "update_code_design"]
|
|
928
|
+
reset_stages_and_move_current(
|
|
929
|
+
state,
|
|
930
|
+
affected_stages,
|
|
931
|
+
)
|
|
932
|
+
set_recovery_context(
|
|
933
|
+
state,
|
|
934
|
+
"topic_acceptance",
|
|
935
|
+
affected_stages,
|
|
936
|
+
"主题验收结果已经改变,旧全量回归和整体验收结论不能继续使用",
|
|
937
|
+
)
|
|
938
|
+
state.verification.acceptance_result_hash = None
|
|
939
|
+
state.verification.regression_test_result_hash = None
|
|
940
|
+
invalidations.append(("topic_acceptance", "regression_test、overall_acceptance 和 update_code_design"))
|
|
941
|
+
return invalidations
|
|
942
|
+
|
|
943
|
+
# 最终全量回归结果或代码变化后,从最终全量回归重新开始。
|
|
944
|
+
# 回归结果现在保存在 state.json,不再通过结果 Markdown 文件判断。
|
|
945
|
+
if state.verification.regression_test_result_hash is not None:
|
|
946
|
+
current_regression = compute_regression_test_result_hash(project_root)
|
|
947
|
+
regression_code_changed = (
|
|
948
|
+
state.regression_test.code_snapshot_hash != compute_code_snapshot_hash(project_root)
|
|
949
|
+
)
|
|
950
|
+
if current_regression != state.verification.regression_test_result_hash or regression_code_changed:
|
|
951
|
+
affected_stages = ["regression_test", "overall_acceptance", "update_code_design"]
|
|
952
|
+
reset_stages_and_move_current(
|
|
953
|
+
state,
|
|
954
|
+
affected_stages,
|
|
955
|
+
)
|
|
956
|
+
reason = (
|
|
957
|
+
"全量回归后代码又发生变化,必须重新执行全量回归"
|
|
958
|
+
if regression_code_changed
|
|
959
|
+
else "全量回归状态已经改变,后续整体验收不能继续使用旧结论"
|
|
960
|
+
)
|
|
961
|
+
set_recovery_context(
|
|
962
|
+
state,
|
|
963
|
+
"regression_test",
|
|
964
|
+
affected_stages,
|
|
965
|
+
reason,
|
|
966
|
+
)
|
|
967
|
+
state.verification.regression_test_result_hash = None
|
|
968
|
+
invalidations.append(("regression_test", "regression_test、overall_acceptance 和 update_code_design"))
|
|
969
|
+
return invalidations
|
|
970
|
+
|
|
971
|
+
return invalidations
|