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,338 @@
|
|
|
1
|
+
"""主题验收的结构化记录和当前有效性检查。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
|
|
10
|
+
from . import artifact_paths as artifact_paths_mod
|
|
11
|
+
from . import state as state_mod
|
|
12
|
+
from .test_mapping import TestPlanItem, parse_test_plan_items
|
|
13
|
+
from .topic import topic_paths
|
|
14
|
+
from .topic_relations import read_topic_index
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ACCEPTANCE_METHODS = {"自动化测试", "人工验收", "自动化测试 + 人工验收"}
|
|
18
|
+
RESULT_CHOICES = {"passed", "failed", "blocked"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compute_record_id(record: state_mod.AcceptanceCriterionRecord) -> str:
|
|
22
|
+
payload = asdict(record)
|
|
23
|
+
payload["record_id"] = None
|
|
24
|
+
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
25
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _criterion_groups(project_root: str, topic: str) -> dict[str, list[TestPlanItem]]:
|
|
29
|
+
groups: dict[str, list[TestPlanItem]] = {}
|
|
30
|
+
for item in parse_test_plan_items(project_root, topic):
|
|
31
|
+
groups.setdefault(item.criterion_id, []).append(item)
|
|
32
|
+
return groups
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def criterion_methods(project_root: str, topic: str) -> dict[str, str]:
|
|
36
|
+
"""按一条 AC 的全部测试项归并最终验收方式。"""
|
|
37
|
+
methods: dict[str, str] = {}
|
|
38
|
+
for criterion_id, items in _criterion_groups(project_root, topic).items():
|
|
39
|
+
item_methods = {item.test_method for item in items}
|
|
40
|
+
if item_methods == {"自动化测试"}:
|
|
41
|
+
method = "自动化测试"
|
|
42
|
+
elif item_methods == {"人工验收"}:
|
|
43
|
+
method = "人工验收"
|
|
44
|
+
else:
|
|
45
|
+
method = "自动化测试 + 人工验收"
|
|
46
|
+
methods[criterion_id] = method
|
|
47
|
+
return methods
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def criterion_names(project_root: str, topic: str) -> dict[str, str]:
|
|
51
|
+
return {
|
|
52
|
+
criterion_id: items[0].criterion_name
|
|
53
|
+
for criterion_id, items in _criterion_groups(project_root, topic).items()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def automated_test_ids(project_root: str, topic: str, criterion_id: str) -> list[str]:
|
|
58
|
+
return [
|
|
59
|
+
item.test_id
|
|
60
|
+
for item in _criterion_groups(project_root, topic).get(criterion_id, [])
|
|
61
|
+
if item.requires_test_code
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def record_is_current(
|
|
66
|
+
record: state_mod.AcceptanceCriterionRecord,
|
|
67
|
+
wf_state: state_mod.WorkflowState,
|
|
68
|
+
) -> bool:
|
|
69
|
+
"""判断当前主题记录是否仍被程序保留为有效。
|
|
70
|
+
|
|
71
|
+
上游全局哈希只用于发现变化和触发清理,不能直接拿来让所有主题一起失效。
|
|
72
|
+
用户只退回一个主题时,程序已经清理该主题及其依赖主题;没有被清理的独立
|
|
73
|
+
主题应继续保留当前验收记录。
|
|
74
|
+
|
|
75
|
+
自动化或混合记录还必须逐项指向当前任务的精确机器记录编号;
|
|
76
|
+
执行记录被替换后,旧验收记录不能继续通过。旧记录缺少编号时同样失效。
|
|
77
|
+
"""
|
|
78
|
+
if record.result != "passed" or record.record_id != compute_record_id(record):
|
|
79
|
+
return False
|
|
80
|
+
if record.method in ("自动化测试", "自动化测试 + 人工验收") and record.test_ids:
|
|
81
|
+
tasks = wf_state.stages.get(
|
|
82
|
+
"test_execution",
|
|
83
|
+
state_mod.StageState(),
|
|
84
|
+
).test_tasks.get(record.topic, {})
|
|
85
|
+
current_ids: list[str] = []
|
|
86
|
+
for test_id in record.test_ids:
|
|
87
|
+
task = tasks.get(test_id)
|
|
88
|
+
if (
|
|
89
|
+
task is None
|
|
90
|
+
or task.current_record is None
|
|
91
|
+
or task.current_record.status != "passed"
|
|
92
|
+
or not task.current_record.record_id
|
|
93
|
+
):
|
|
94
|
+
return False
|
|
95
|
+
current_ids.append(task.current_record.record_id)
|
|
96
|
+
if not record.test_record_ids:
|
|
97
|
+
return False
|
|
98
|
+
if sorted(current_ids) != sorted(record.test_record_ids):
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _automated_items_are_current(
|
|
104
|
+
wf_state: state_mod.WorkflowState,
|
|
105
|
+
topic: str,
|
|
106
|
+
test_ids: list[str],
|
|
107
|
+
) -> tuple[bool, str, list[str]]:
|
|
108
|
+
"""核对每个测试项当前机器记录有效,并返回精确记录编号列表。"""
|
|
109
|
+
stage_state = wf_state.stages.get("test_execution")
|
|
110
|
+
if stage_state is None:
|
|
111
|
+
return False, "缺少 test_execution(测试执行阶段)状态", []
|
|
112
|
+
tasks = stage_state.test_tasks.get(topic, {})
|
|
113
|
+
record_ids: list[str] = []
|
|
114
|
+
for test_id in test_ids:
|
|
115
|
+
task = tasks.get(test_id)
|
|
116
|
+
if task is None or task.status != "passed" or task.current_record is None:
|
|
117
|
+
return False, f"{topic} / {test_id} 没有当前有效的通过记录", []
|
|
118
|
+
record = task.current_record
|
|
119
|
+
if record.status != "passed" or record.exit_code != 0:
|
|
120
|
+
return False, f"{topic} / {test_id} 当前测试记录不是通过状态", []
|
|
121
|
+
if not record.record_id:
|
|
122
|
+
return False, f"{topic} / {test_id} 的执行记录缺少机器记录编号,必须重新执行", []
|
|
123
|
+
record_ids.append(record.record_id)
|
|
124
|
+
return True, "", record_ids
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _topic_relations(project_root: str, wf_state: state_mod.WorkflowState):
|
|
128
|
+
return read_topic_index(
|
|
129
|
+
project_root,
|
|
130
|
+
artifact_paths_mod.ACCEPTANCE_INDEX_DOC,
|
|
131
|
+
wf_state.workflow_id,
|
|
132
|
+
["展示顺序", "验收主题", "前置主题", "验收计划", "主题验收结果"],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def topic_records_complete(
|
|
137
|
+
project_root: str,
|
|
138
|
+
wf_state: state_mod.WorkflowState,
|
|
139
|
+
topic: str,
|
|
140
|
+
) -> bool:
|
|
141
|
+
methods = criterion_methods(project_root, topic)
|
|
142
|
+
records = wf_state.stages.get(
|
|
143
|
+
"topic_acceptance",
|
|
144
|
+
state_mod.StageState(),
|
|
145
|
+
).acceptance_records.get(topic, {})
|
|
146
|
+
return bool(methods) and set(records) == set(methods) and all(
|
|
147
|
+
records[criterion_id].method == method
|
|
148
|
+
and record_is_current(records[criterion_id], wf_state)
|
|
149
|
+
for criterion_id, method in methods.items()
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def incomplete_prerequisites(
|
|
154
|
+
project_root: str,
|
|
155
|
+
wf_state: state_mod.WorkflowState,
|
|
156
|
+
topic: str,
|
|
157
|
+
) -> list[str]:
|
|
158
|
+
relations = {relation.topic: relation for relation in _topic_relations(project_root, wf_state)}
|
|
159
|
+
relation = relations.get(topic)
|
|
160
|
+
if relation is None:
|
|
161
|
+
return [f"验收索引缺少主题:{topic}"]
|
|
162
|
+
return [
|
|
163
|
+
prerequisite
|
|
164
|
+
for prerequisite in relation.prerequisites
|
|
165
|
+
if not topic_records_complete(project_root, wf_state, prerequisite)
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def ensure_automated_records(
|
|
170
|
+
project_root: str,
|
|
171
|
+
wf_state: state_mod.WorkflowState,
|
|
172
|
+
) -> list[state_mod.AcceptanceCriterionRecord]:
|
|
173
|
+
"""为纯自动化 AC 建立当前有效记录,不替代人工验收。"""
|
|
174
|
+
stage_state = wf_state.stages.get("topic_acceptance")
|
|
175
|
+
if stage_state is None:
|
|
176
|
+
return []
|
|
177
|
+
created: list[state_mod.AcceptanceCriterionRecord] = []
|
|
178
|
+
for relation in _topic_relations(project_root, wf_state):
|
|
179
|
+
if incomplete_prerequisites(project_root, wf_state, relation.topic):
|
|
180
|
+
continue
|
|
181
|
+
methods = criterion_methods(project_root, relation.topic)
|
|
182
|
+
topic_records = stage_state.acceptance_records.setdefault(relation.topic, {})
|
|
183
|
+
for criterion_id, method in methods.items():
|
|
184
|
+
if method != "自动化测试":
|
|
185
|
+
continue
|
|
186
|
+
existing = topic_records.get(criterion_id)
|
|
187
|
+
if existing is not None and record_is_current(existing, wf_state):
|
|
188
|
+
continue
|
|
189
|
+
test_ids = automated_test_ids(project_root, relation.topic, criterion_id)
|
|
190
|
+
current, detail, machine_record_ids = _automated_items_are_current(
|
|
191
|
+
wf_state,
|
|
192
|
+
relation.topic,
|
|
193
|
+
test_ids,
|
|
194
|
+
)
|
|
195
|
+
if not current:
|
|
196
|
+
continue
|
|
197
|
+
record = state_mod.AcceptanceCriterionRecord(
|
|
198
|
+
topic=relation.topic,
|
|
199
|
+
criterion_id=criterion_id,
|
|
200
|
+
method=method,
|
|
201
|
+
result="passed",
|
|
202
|
+
actual_result=f"对应自动化测试项均有当前有效通过记录:{', '.join(test_ids)}",
|
|
203
|
+
user_answer=None,
|
|
204
|
+
evidence=topic_paths(project_root, relation.topic)["test_result"],
|
|
205
|
+
confirmed_at=state_mod.now_iso(),
|
|
206
|
+
acceptance_plan_hash=wf_state.verification.acceptance_plan_hash,
|
|
207
|
+
impl_hash=wf_state.verification.impl_hash,
|
|
208
|
+
test_result_hash=wf_state.verification.test_result_hash,
|
|
209
|
+
test_ids=test_ids,
|
|
210
|
+
test_record_ids=machine_record_ids,
|
|
211
|
+
)
|
|
212
|
+
record.record_id = compute_record_id(record)
|
|
213
|
+
topic_records[criterion_id] = record
|
|
214
|
+
created.append(record)
|
|
215
|
+
return created
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def record_user_result(
|
|
219
|
+
project_root: str,
|
|
220
|
+
wf_state: state_mod.WorkflowState,
|
|
221
|
+
*,
|
|
222
|
+
topic: str,
|
|
223
|
+
criterion_id: str,
|
|
224
|
+
result: str,
|
|
225
|
+
actual_result: str,
|
|
226
|
+
user_answer: str,
|
|
227
|
+
evidence: str,
|
|
228
|
+
) -> state_mod.AcceptanceCriterionRecord:
|
|
229
|
+
if result not in RESULT_CHOICES:
|
|
230
|
+
raise ValueError(f"验收结果必须是 {sorted(RESULT_CHOICES)}")
|
|
231
|
+
if topic not in wf_state.topics:
|
|
232
|
+
raise ValueError(f"主题不属于当前工作流:{topic}")
|
|
233
|
+
methods = criterion_methods(project_root, topic)
|
|
234
|
+
method = methods.get(criterion_id)
|
|
235
|
+
if method is None:
|
|
236
|
+
raise ValueError(f"{topic} 没有验收条件 {criterion_id}")
|
|
237
|
+
if method == "自动化测试":
|
|
238
|
+
raise ValueError(f"{topic} / {criterion_id} 是纯自动化条件,不需要用户重复确认")
|
|
239
|
+
prerequisites = incomplete_prerequisites(project_root, wf_state, topic)
|
|
240
|
+
if prerequisites:
|
|
241
|
+
raise ValueError(f"前置主题尚未验收通过:{prerequisites}")
|
|
242
|
+
test_ids = automated_test_ids(project_root, topic, criterion_id)
|
|
243
|
+
machine_record_ids: list[str] = []
|
|
244
|
+
if method == "自动化测试 + 人工验收":
|
|
245
|
+
current, detail, machine_record_ids = _automated_items_are_current(
|
|
246
|
+
wf_state,
|
|
247
|
+
topic,
|
|
248
|
+
test_ids,
|
|
249
|
+
)
|
|
250
|
+
if not current:
|
|
251
|
+
raise ValueError(detail)
|
|
252
|
+
if not actual_result.strip():
|
|
253
|
+
raise ValueError("必须记录用户实际观察到的结果")
|
|
254
|
+
if not user_answer.strip():
|
|
255
|
+
raise ValueError("必须记录用户实际回答")
|
|
256
|
+
|
|
257
|
+
record = state_mod.AcceptanceCriterionRecord(
|
|
258
|
+
topic=topic,
|
|
259
|
+
criterion_id=criterion_id,
|
|
260
|
+
method=method,
|
|
261
|
+
result=result,
|
|
262
|
+
actual_result=actual_result.strip(),
|
|
263
|
+
user_answer=user_answer.strip(),
|
|
264
|
+
evidence=evidence.strip() or actual_result.strip(),
|
|
265
|
+
confirmed_at=state_mod.now_iso(),
|
|
266
|
+
acceptance_plan_hash=wf_state.verification.acceptance_plan_hash,
|
|
267
|
+
impl_hash=wf_state.verification.impl_hash,
|
|
268
|
+
test_result_hash=wf_state.verification.test_result_hash,
|
|
269
|
+
test_ids=test_ids,
|
|
270
|
+
test_record_ids=machine_record_ids,
|
|
271
|
+
)
|
|
272
|
+
record.record_id = compute_record_id(record)
|
|
273
|
+
stage_state = wf_state.stages["topic_acceptance"]
|
|
274
|
+
if result == "passed":
|
|
275
|
+
stage_state.acceptance_records.setdefault(topic, {})[criterion_id] = record
|
|
276
|
+
else:
|
|
277
|
+
stage_state.acceptance_records.pop(topic, None)
|
|
278
|
+
result_path = os.path.join(
|
|
279
|
+
project_root,
|
|
280
|
+
topic_paths(project_root, topic)["acceptance_result"],
|
|
281
|
+
)
|
|
282
|
+
if os.path.isfile(result_path):
|
|
283
|
+
os.remove(result_path)
|
|
284
|
+
return record
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def clear_topic_records(
|
|
288
|
+
project_root: str,
|
|
289
|
+
wf_state: state_mod.WorkflowState,
|
|
290
|
+
topics: list[str],
|
|
291
|
+
) -> None:
|
|
292
|
+
stage_state = wf_state.stages.get("topic_acceptance")
|
|
293
|
+
if stage_state is not None:
|
|
294
|
+
for topic in topics:
|
|
295
|
+
stage_state.acceptance_records.pop(topic, None)
|
|
296
|
+
for topic in topics:
|
|
297
|
+
result_path = os.path.join(
|
|
298
|
+
project_root,
|
|
299
|
+
topic_paths(project_root, topic)["acceptance_result"],
|
|
300
|
+
)
|
|
301
|
+
if os.path.isfile(result_path):
|
|
302
|
+
os.remove(result_path)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def acceptance_records_payload(
|
|
306
|
+
wf_state: state_mod.WorkflowState,
|
|
307
|
+
topics: list[str],
|
|
308
|
+
) -> dict:
|
|
309
|
+
stage_state = wf_state.stages.get("topic_acceptance")
|
|
310
|
+
records = stage_state.acceptance_records if stage_state is not None else {}
|
|
311
|
+
return {
|
|
312
|
+
topic: {
|
|
313
|
+
criterion_id: asdict(record)
|
|
314
|
+
for criterion_id, record in sorted(records.get(topic, {}).items())
|
|
315
|
+
}
|
|
316
|
+
for topic in topics
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def acceptance_progress(
|
|
321
|
+
project_root: str,
|
|
322
|
+
wf_state: state_mod.WorkflowState,
|
|
323
|
+
) -> list[str]:
|
|
324
|
+
lines: list[str] = []
|
|
325
|
+
stage_state = wf_state.stages.get("topic_acceptance", state_mod.StageState())
|
|
326
|
+
for topic in wf_state.topics:
|
|
327
|
+
methods = criterion_methods(project_root, topic)
|
|
328
|
+
records = stage_state.acceptance_records.get(topic, {})
|
|
329
|
+
remaining = [
|
|
330
|
+
criterion_id
|
|
331
|
+
for criterion_id in methods
|
|
332
|
+
if criterion_id not in records or not record_is_current(records[criterion_id], wf_state)
|
|
333
|
+
]
|
|
334
|
+
if remaining:
|
|
335
|
+
lines.append(f"{topic}: 待验收 {remaining}")
|
|
336
|
+
else:
|
|
337
|
+
lines.append(f"{topic}: 验收条件已全部通过,待生成或复核主题结果文件")
|
|
338
|
+
return lines
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""正式产物路径的唯一来源。
|
|
2
|
+
|
|
3
|
+
所有阶段、校验、哈希、追踪、清理和恢复代码都只能调用本模块取得正式产物路径,
|
|
4
|
+
不能自行拼接后缀。面向用户的正式产物固定使用中文文件名;`spec`、`acceptance`、
|
|
5
|
+
`qa`、`impl`、`bug` 等程序固定目录名保持英文。
|
|
6
|
+
|
|
7
|
+
显示名称与文件标识分离:功能、主题、穿刺项和缺陷在文档标题与正文中保留用户
|
|
8
|
+
确认的完整中文显示名称;进入文件名时使用稳定的中文文件标识。已有映射保存在
|
|
9
|
+
`.workflow_loop/project.json` 的 `artifact_file_keys` 中,后续阶段读取已保存的
|
|
10
|
+
对应关系,不重新猜测。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import unicodedata
|
|
18
|
+
|
|
19
|
+
# ─── 固定正式产物路径(相对项目根) ───
|
|
20
|
+
PRODUCT_OVERVIEW_DOC = "spec/产品总说明.md"
|
|
21
|
+
CODE_DESIGN_DOC = "spec/代码架构设计.md"
|
|
22
|
+
DESIGN_INIT_EVIDENCE_DOC = "spec/项目设计初始化证据.md"
|
|
23
|
+
SPIKE_INDEX_DOC = "spec/穿刺清单.md"
|
|
24
|
+
ACCEPTANCE_INDEX_DOC = "acceptance/索引.md"
|
|
25
|
+
QA_INDEX_DOC = "qa/索引.md"
|
|
26
|
+
IMPL_INDEX_DOC = "impl/索引.md"
|
|
27
|
+
BUG_INDEX_DOC = "bug/索引.md"
|
|
28
|
+
TRACEABILITY_DOC = "需求交付追踪表.md"
|
|
29
|
+
|
|
30
|
+
# 文件标识分类:功能、验收主题、穿刺项、缺陷
|
|
31
|
+
FILE_KEY_CATEGORIES = ("feature", "topic", "spike", "bug")
|
|
32
|
+
|
|
33
|
+
# 文件标识允许的字符:中文、英文字母、数字、下划线和连字符
|
|
34
|
+
_ALLOWED_CHAR = re.compile(r"[A-Za-z0-9_\-一-鿿㐀-䶿]")
|
|
35
|
+
# 文件标识长度上限(字符数),避免超出各平台路径限制
|
|
36
|
+
MAX_FILE_KEY_LENGTH = 80
|
|
37
|
+
# Windows 保留名称(大小写不敏感;文件标识必须避开)
|
|
38
|
+
_WINDOWS_RESERVED = {
|
|
39
|
+
"CON", "PRN", "AUX", "NUL",
|
|
40
|
+
*{f"COM{i}" for i in range(1, 10)},
|
|
41
|
+
*{f"LPT{i}" for i in range(1, 10)},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_file_key(display_name: str) -> str:
|
|
46
|
+
"""把显示名称清理成跨平台安全的中文文件标识(纯函数,不查映射)。
|
|
47
|
+
|
|
48
|
+
只保留中文字符、英文字母、数字、下划线和连字符;空格、斜杠、反斜杠、冒号、
|
|
49
|
+
问号、引号、换行和其它标点统一替换为单个下划线;去掉开头与结尾的点、空格、
|
|
50
|
+
下划线并限制长度;避开 Windows 保留名称。无法得到安全标识时抛 ValueError。
|
|
51
|
+
"""
|
|
52
|
+
if display_name is None:
|
|
53
|
+
raise ValueError("显示名称不能为空")
|
|
54
|
+
# Unicode 规范化:同一个视觉名称在不同输入法下得到同一标识
|
|
55
|
+
normalized = unicodedata.normalize("NFC", str(display_name))
|
|
56
|
+
|
|
57
|
+
pieces: list[str] = []
|
|
58
|
+
previous_was_placeholder = False
|
|
59
|
+
for char in normalized:
|
|
60
|
+
if _ALLOWED_CHAR.fullmatch(char):
|
|
61
|
+
pieces.append(char)
|
|
62
|
+
previous_was_placeholder = False
|
|
63
|
+
else:
|
|
64
|
+
# 连续的非法字符只折叠成一个下划线
|
|
65
|
+
if not previous_was_placeholder:
|
|
66
|
+
pieces.append("_")
|
|
67
|
+
previous_was_placeholder = True
|
|
68
|
+
key = "".join(pieces)
|
|
69
|
+
# 去掉开头与结尾的点、空格和下划线
|
|
70
|
+
key = key.strip("._ \t")
|
|
71
|
+
# 限制长度后再清理一次结尾
|
|
72
|
+
key = key[:MAX_FILE_KEY_LENGTH].strip("._ \t")
|
|
73
|
+
if not key:
|
|
74
|
+
raise ValueError(f"显示名称无法生成安全文件标识: {display_name!r}")
|
|
75
|
+
if key.upper() in _WINDOWS_RESERVED:
|
|
76
|
+
# 保留名称追加下划线避开,例如 CON → CON_
|
|
77
|
+
key = key + "_"
|
|
78
|
+
return key
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_file_key(
|
|
82
|
+
saved_keys: dict[str, str],
|
|
83
|
+
all_used_keys: set[str],
|
|
84
|
+
display_name: str,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""在一个分类内解析显示名称的文件标识。
|
|
87
|
+
|
|
88
|
+
saved_keys 是该分类已保存的 显示名称→文件标识 映射;all_used_keys 是全部
|
|
89
|
+
分类已占用的标识(大小写不敏感比较,避免只在大小写上不同的路径冲突)。
|
|
90
|
+
已保存的名称直接返回旧标识;新名称生成标识,冲突时依次追加 `_2`、`_3`。
|
|
91
|
+
"""
|
|
92
|
+
if display_name in saved_keys:
|
|
93
|
+
return saved_keys[display_name]
|
|
94
|
+
base_key = make_file_key(display_name)
|
|
95
|
+
used_lower = {key.lower() for key in all_used_keys}
|
|
96
|
+
candidate = base_key
|
|
97
|
+
suffix = 2
|
|
98
|
+
while candidate.lower() in used_lower:
|
|
99
|
+
candidate = f"{base_key}_{suffix}"
|
|
100
|
+
suffix += 1
|
|
101
|
+
return candidate
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def register_file_keys(project, category: str, display_names: list[str]) -> dict[str, str]:
|
|
105
|
+
"""把一批显示名称的文件标识登记进项目状态(不落盘,调用方负责保存)。
|
|
106
|
+
|
|
107
|
+
拒绝两类漂移:同一显示名称改绑到另一标识;一个标识被另一显示名称占用。
|
|
108
|
+
返回本次新增的 显示名称→文件标识。
|
|
109
|
+
"""
|
|
110
|
+
if category not in FILE_KEY_CATEGORIES:
|
|
111
|
+
raise ValueError(f"未知文件标识分类: {category}")
|
|
112
|
+
keys = project.artifact_file_keys.setdefault(category, {})
|
|
113
|
+
all_used = {
|
|
114
|
+
key
|
|
115
|
+
for mapping in project.artifact_file_keys.values()
|
|
116
|
+
for key in mapping.values()
|
|
117
|
+
}
|
|
118
|
+
reverse = {key: name for name, key in keys.items()}
|
|
119
|
+
added: dict[str, str] = {}
|
|
120
|
+
for display_name in display_names:
|
|
121
|
+
if display_name in keys:
|
|
122
|
+
continue
|
|
123
|
+
key = resolve_file_key(keys, all_used, display_name)
|
|
124
|
+
owner = reverse.get(key)
|
|
125
|
+
if owner is not None and owner != display_name:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"文件标识 {key!r} 已被显示名称 {owner!r} 占用,不能再分配给 {display_name!r}"
|
|
128
|
+
)
|
|
129
|
+
keys[display_name] = key
|
|
130
|
+
reverse[key] = display_name
|
|
131
|
+
all_used.add(key)
|
|
132
|
+
added[display_name] = key
|
|
133
|
+
return added
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def lookup_file_key(project, category: str, display_name: str) -> str | None:
|
|
137
|
+
"""读取已保存的文件标识;没有登记时返回 None。"""
|
|
138
|
+
if project is None:
|
|
139
|
+
return None
|
|
140
|
+
return project.artifact_file_keys.get(category, {}).get(display_name)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_key_for(project, category: str, display_name: str) -> str:
|
|
144
|
+
"""取得显示名称的稳定文件标识:优先已保存映射,否则确定性生成(不保存)。"""
|
|
145
|
+
saved = project.artifact_file_keys.get(category, {}) if project is not None else {}
|
|
146
|
+
all_used = (
|
|
147
|
+
{
|
|
148
|
+
key
|
|
149
|
+
for mapping in project.artifact_file_keys.values()
|
|
150
|
+
for key in mapping.values()
|
|
151
|
+
}
|
|
152
|
+
if project is not None
|
|
153
|
+
else set()
|
|
154
|
+
)
|
|
155
|
+
return resolve_file_key(saved, all_used, display_name)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ─── 按文件标识生成动态正式产物路径 ───
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def feature_doc(file_key: str) -> str:
|
|
162
|
+
return f"spec/功能_{file_key}.md"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def spike_doc(file_key: str) -> str:
|
|
166
|
+
return f"spec/穿刺_{file_key}.md"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def bug_doc(file_key: str) -> str:
|
|
170
|
+
return f"bug/缺陷_{file_key}.md"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def topic_acceptance_plan(file_key: str) -> str:
|
|
174
|
+
return f"acceptance/{file_key}_验收计划.md"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def topic_acceptance_result(file_key: str) -> str:
|
|
178
|
+
return f"acceptance/{file_key}_验收结果.md"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def topic_test_plan(file_key: str) -> str:
|
|
182
|
+
return f"qa/{file_key}_测试计划.md"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def topic_test_result(file_key: str) -> str:
|
|
186
|
+
return f"qa/{file_key}_测试结果.md"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def topic_impl_doc(file_key: str) -> str:
|
|
190
|
+
return f"impl/{file_key}_实施记录.md"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def managed_artifact_paths(project, wf_state=None, project_root: str | None = None) -> list[str]:
|
|
194
|
+
"""列出工作流拥有的正式产物路径,不把目录中的任意 Markdown 算进来。
|
|
195
|
+
|
|
196
|
+
固定文档、项目中已经登记的文件标识、当前主题,以及磁盘上使用工作流保留
|
|
197
|
+
命名格式的文件都属于受管范围。最后一类用于覆盖“文档已经创建、第三道门尚未
|
|
198
|
+
登记文件标识就作废”的真实场景;`spec/notes.md` 之类用户自有文件不会命中。
|
|
199
|
+
|
|
200
|
+
project 可以是 ``ProjectState``,也可以为 None。wf_state 只读取 topics/topic,
|
|
201
|
+
因此旧状态和当前状态都可安全传入。
|
|
202
|
+
"""
|
|
203
|
+
paths = {
|
|
204
|
+
PRODUCT_OVERVIEW_DOC,
|
|
205
|
+
CODE_DESIGN_DOC,
|
|
206
|
+
DESIGN_INIT_EVIDENCE_DOC,
|
|
207
|
+
SPIKE_INDEX_DOC,
|
|
208
|
+
ACCEPTANCE_INDEX_DOC,
|
|
209
|
+
QA_INDEX_DOC,
|
|
210
|
+
IMPL_INDEX_DOC,
|
|
211
|
+
BUG_INDEX_DOC,
|
|
212
|
+
TRACEABILITY_DOC,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
mappings = getattr(project, "artifact_file_keys", {}) if project is not None else {}
|
|
216
|
+
for file_key in mappings.get("feature", {}).values():
|
|
217
|
+
paths.add(feature_doc(file_key))
|
|
218
|
+
for file_key in mappings.get("spike", {}).values():
|
|
219
|
+
paths.add(spike_doc(file_key))
|
|
220
|
+
for file_key in mappings.get("bug", {}).values():
|
|
221
|
+
paths.add(bug_doc(file_key))
|
|
222
|
+
for file_key in mappings.get("topic", {}).values():
|
|
223
|
+
paths.update(
|
|
224
|
+
{
|
|
225
|
+
topic_acceptance_plan(file_key),
|
|
226
|
+
topic_acceptance_result(file_key),
|
|
227
|
+
topic_test_plan(file_key),
|
|
228
|
+
topic_test_result(file_key),
|
|
229
|
+
topic_impl_doc(file_key),
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
topics = list(getattr(wf_state, "topics", []) or []) if wf_state is not None else []
|
|
234
|
+
legacy_topic = getattr(wf_state, "topic", None) if wf_state is not None else None
|
|
235
|
+
if not topics and legacy_topic:
|
|
236
|
+
topics = [legacy_topic]
|
|
237
|
+
for topic in topics:
|
|
238
|
+
file_key = resolve_key_for(project, "topic", topic)
|
|
239
|
+
paths.update(
|
|
240
|
+
{
|
|
241
|
+
topic_acceptance_plan(file_key),
|
|
242
|
+
topic_acceptance_result(file_key),
|
|
243
|
+
topic_test_plan(file_key),
|
|
244
|
+
topic_test_result(file_key),
|
|
245
|
+
topic_impl_doc(file_key),
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# 保留命名格式是工作流的正式命名空间。只接纳普通文件名,不递归扫描目录,
|
|
250
|
+
# 从而不会把用户自己的说明、样本或笔记纳入整轮恢复。
|
|
251
|
+
reserved_patterns = {
|
|
252
|
+
"spec": (
|
|
253
|
+
re.compile(r"^功能_[A-Za-z0-9_\-一-鿿㐀-䶿]+\.md$"),
|
|
254
|
+
re.compile(r"^穿刺_[A-Za-z0-9_\-一-鿿㐀-䶿]+\.md$"),
|
|
255
|
+
),
|
|
256
|
+
"acceptance": (
|
|
257
|
+
re.compile(r"^[A-Za-z0-9_\-一-鿿㐀-䶿]+_(?:验收计划|验收结果)\.md$"),
|
|
258
|
+
),
|
|
259
|
+
"qa": (
|
|
260
|
+
re.compile(r"^[A-Za-z0-9_\-一-鿿㐀-䶿]+_(?:测试计划|测试结果)\.md$"),
|
|
261
|
+
),
|
|
262
|
+
"impl": (
|
|
263
|
+
re.compile(r"^[A-Za-z0-9_\-一-鿿㐀-䶿]+_实施记录\.md$"),
|
|
264
|
+
),
|
|
265
|
+
"bug": (
|
|
266
|
+
re.compile(r"^缺陷_[A-Za-z0-9_\-一-鿿㐀-䶿]+\.md$"),
|
|
267
|
+
),
|
|
268
|
+
}
|
|
269
|
+
if isinstance(project_root, str):
|
|
270
|
+
for directory, patterns in reserved_patterns.items():
|
|
271
|
+
full_dir = os.path.join(project_root, directory)
|
|
272
|
+
if not os.path.isdir(full_dir):
|
|
273
|
+
continue
|
|
274
|
+
for filename in os.listdir(full_dir):
|
|
275
|
+
if any(pattern.fullmatch(filename) for pattern in patterns):
|
|
276
|
+
paths.add(f"{directory}/{filename}")
|
|
277
|
+
|
|
278
|
+
return sorted(paths)
|