easy-coding-harness 0.9.1 → 0.10.0-beta.1

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.
@@ -0,0 +1,1971 @@
1
+ #!/usr/bin/env python3
2
+ """Canonical Dev Spec v1 parser, validator, and scope selector.
3
+
4
+ The module intentionally uses only the Python standard library so the skill can
5
+ run in a clean repository without installing dependencies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import Any, Iterable
16
+
17
+
18
+ SCHEMA = "easy-dev-spec/v1"
19
+ MANIFEST_BEGIN = "<!-- EDS:MANIFEST:BEGIN -->"
20
+ MANIFEST_END = "<!-- EDS:MANIFEST:END -->"
21
+ SECTION_BEGIN_RE = re.compile(r"^<!-- EDS:SECTION:BEGIN id=([a-z0-9][a-z0-9-]*) -->$")
22
+ SECTION_END_RE = re.compile(r"^<!-- EDS:SECTION:END id=([a-z0-9][a-z0-9-]*) -->$")
23
+ ID_PATTERNS = {
24
+ "repository": re.compile(r"^R[1-9][0-9]*$"),
25
+ "contract": re.compile(r"^C[1-9][0-9]*$"),
26
+ "task": re.compile(r"^(R[1-9][0-9]*)-T[1-9][0-9]*$"),
27
+ "change": re.compile(r"^F[1-9][0-9]*$"),
28
+ "step": re.compile(r"^S[1-9][0-9]*$"),
29
+ "test": re.compile(r"^T[1-9][0-9]*$"),
30
+ }
31
+ VALID_STATUSES = {"DRAFT", "BLOCKED", "READY"}
32
+ VALID_DEPENDENCY_TYPES = {"hard", "contract", "integration"}
33
+ VALID_ACTIONS = {"add", "modify", "delete"}
34
+ REQUIRED_TASK_HEADINGS = (
35
+ "目标、交付物与非目标",
36
+ "文件与符号级改动",
37
+ "调用链 Diff",
38
+ "新增或修改类型契约",
39
+ "存储、消息与配置闭环",
40
+ "符号级实施步骤",
41
+ "测试映射",
42
+ "风险、回退与完成证据",
43
+ )
44
+ FORBIDDEN_READY_PATTERNS = {
45
+ "placeholder.todo": re.compile(r"(?i)(?<![A-Za-z])TODO(?![A-Za-z])"),
46
+ "placeholder.tbd": re.compile(r"(?i)(?<![A-Za-z])TBD(?![A-Za-z])"),
47
+ "placeholder.template": re.compile(r"\[\[EDS_TODO:[^\]]+\]\]"),
48
+ "placeholder.weak_value": re.compile(
49
+ r"[::]\s*(?:`|\*|_)?(?:完成|已完成|同上|见上文)(?:`|\*|_)?\s*(?:$|[。;;,,])",
50
+ re.MULTILINE,
51
+ ),
52
+ "vague.pending": re.compile(r"待补充|待确认|后续确认|视情况|按需"),
53
+ "vague.implementation": re.compile(
54
+ r"实施时检查|复用现有机制|在合适位置|新增相关组件|以目标分支为准|接入所有\s*Ability"
55
+ ),
56
+ }
57
+
58
+
59
+ class CanonicalSpecError(ValueError):
60
+ """Raised when a document cannot be parsed as a Canonical Spec."""
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Section:
65
+ section_id: str
66
+ content: str
67
+ begin_line: int
68
+ end_line: int
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ValidationIssue:
73
+ code: str
74
+ message: str
75
+ item_id: str | None = None
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ value: dict[str, Any] = {"code": self.code, "message": self.message}
79
+ if self.item_id:
80
+ value["item_id"] = self.item_id
81
+ return value
82
+
83
+
84
+ @dataclass
85
+ class ValidationReport:
86
+ protocol: str
87
+ status: str | None
88
+ issues: list[ValidationIssue] = field(default_factory=list)
89
+ warnings: list[ValidationIssue] = field(default_factory=list)
90
+ manifest: dict[str, Any] | None = None
91
+ sections: dict[str, Section] = field(default_factory=dict)
92
+
93
+ @property
94
+ def ok(self) -> bool:
95
+ return not self.issues
96
+
97
+ def to_dict(self) -> dict[str, Any]:
98
+ return {
99
+ "protocol": self.protocol,
100
+ "status": self.status,
101
+ "ok": self.ok,
102
+ "issues": [issue.to_dict() for issue in self.issues],
103
+ "warnings": [warning.to_dict() for warning in self.warnings],
104
+ }
105
+
106
+
107
+ def _read_source(source: str | Path) -> tuple[str, str | None]:
108
+ if isinstance(source, Path):
109
+ return source.read_text(encoding="utf-8"), str(source)
110
+ if "\n" not in source:
111
+ try:
112
+ candidate = Path(source)
113
+ if candidate.is_file():
114
+ return candidate.read_text(encoding="utf-8"), str(candidate)
115
+ except (OSError, ValueError):
116
+ # A document can legitimately be a long single line. If the
117
+ # operating system cannot even probe it as a path, it is content.
118
+ pass
119
+ return source, None
120
+
121
+
122
+ def parse_manifest(text: str) -> dict[str, Any] | None:
123
+ """Parse the single embedded JSON manifest, or return None for legacy docs."""
124
+
125
+ begin_count = text.count(MANIFEST_BEGIN)
126
+ end_count = text.count(MANIFEST_END)
127
+ if begin_count == 0 and end_count == 0:
128
+ return None
129
+ if begin_count != 1 or end_count != 1:
130
+ raise CanonicalSpecError("文档必须且只能包含一组 manifest 边界")
131
+ begin = text.index(MANIFEST_BEGIN) + len(MANIFEST_BEGIN)
132
+ end = text.index(MANIFEST_END)
133
+ if end <= begin:
134
+ raise CanonicalSpecError("manifest 结束标记位于开始标记之前")
135
+ block = text[begin:end].strip()
136
+ fenced = re.fullmatch(r"```json\s*\n([\s\S]*?)\n```", block)
137
+ if not fenced:
138
+ raise CanonicalSpecError("manifest 必须是边界内唯一的 ```json 代码块")
139
+ try:
140
+ manifest = json.loads(fenced.group(1))
141
+ except json.JSONDecodeError as exc:
142
+ raise CanonicalSpecError(
143
+ f"manifest 不是合法 JSON:第 {exc.lineno} 行第 {exc.colno} 列 {exc.msg}"
144
+ ) from exc
145
+ if not isinstance(manifest, dict):
146
+ raise CanonicalSpecError("manifest 顶层必须是 JSON object")
147
+ return manifest
148
+
149
+
150
+ def _parse_section_objects(text: str) -> dict[str, Section]:
151
+ """Parse section objects with line metadata."""
152
+
153
+ sections: dict[str, Section] = {}
154
+ current_id: str | None = None
155
+ current_begin = 0
156
+ current_lines: list[str] = []
157
+ for line_number, line in enumerate(text.splitlines(), start=1):
158
+ begin_match = SECTION_BEGIN_RE.fullmatch(line.strip())
159
+ end_match = SECTION_END_RE.fullmatch(line.strip())
160
+ if begin_match:
161
+ section_id = begin_match.group(1)
162
+ if current_id is not None:
163
+ raise CanonicalSpecError(
164
+ f"第 {line_number} 行 section {section_id} 嵌套在 {current_id} 内"
165
+ )
166
+ if section_id in sections:
167
+ raise CanonicalSpecError(f"section id 重复:{section_id}")
168
+ current_id = section_id
169
+ current_begin = line_number
170
+ current_lines = []
171
+ continue
172
+ if end_match:
173
+ section_id = end_match.group(1)
174
+ if current_id is None:
175
+ raise CanonicalSpecError(f"第 {line_number} 行存在无开始标记的 section:{section_id}")
176
+ if section_id != current_id:
177
+ raise CanonicalSpecError(
178
+ f"第 {line_number} 行结束 {section_id},但当前 section 是 {current_id}"
179
+ )
180
+ sections[section_id] = Section(
181
+ section_id=section_id,
182
+ content="\n".join(current_lines).strip(),
183
+ begin_line=current_begin,
184
+ end_line=line_number,
185
+ )
186
+ current_id = None
187
+ current_begin = 0
188
+ current_lines = []
189
+ continue
190
+ if current_id is not None:
191
+ current_lines.append(line)
192
+ if current_id is not None:
193
+ raise CanonicalSpecError(f"section 缺少结束标记:{current_id}")
194
+ return sections
195
+
196
+
197
+ def parse_sections(text: str) -> dict[str, str]:
198
+ """Parse non-nested section markers and return section text by ID."""
199
+
200
+ return {
201
+ section_id: section.content
202
+ for section_id, section in _parse_section_objects(text).items()
203
+ }
204
+
205
+
206
+ def _normalize_sections(sections: dict[str, Section] | dict[str, str]) -> dict[str, Section]:
207
+ normalized: dict[str, Section] = {}
208
+ for index, (section_id, section) in enumerate(sections.items(), start=1):
209
+ if isinstance(section, Section):
210
+ normalized[section_id] = section
211
+ elif isinstance(section, str):
212
+ normalized[section_id] = Section(section_id, section, index, index)
213
+ else:
214
+ raise CanonicalSpecError(f"section {section_id} 的值必须是字符串或 Section")
215
+ return normalized
216
+
217
+
218
+ def _issue(issues: list[ValidationIssue], code: str, message: str, item_id: str | None = None) -> None:
219
+ issues.append(ValidationIssue(code=code, message=message, item_id=item_id))
220
+
221
+
222
+ def _objects(manifest: dict[str, Any], key: str, issues: list[ValidationIssue]) -> list[dict[str, Any]]:
223
+ value = manifest.get(key)
224
+ if not isinstance(value, list):
225
+ _issue(issues, "manifest.field_type", f"manifest.{key} 必须是数组", key)
226
+ return []
227
+ result: list[dict[str, Any]] = []
228
+ for index, item in enumerate(value):
229
+ if not isinstance(item, dict):
230
+ _issue(issues, "manifest.item_type", f"manifest.{key}[{index}] 必须是 object", key)
231
+ else:
232
+ result.append(item)
233
+ return result
234
+
235
+
236
+ def _index_objects(
237
+ values: Iterable[dict[str, Any]], id_key: str, kind: str, issues: list[ValidationIssue]
238
+ ) -> dict[str, dict[str, Any]]:
239
+ result: dict[str, dict[str, Any]] = {}
240
+ pattern = ID_PATTERNS[kind]
241
+ for item in values:
242
+ item_id = item.get(id_key)
243
+ if not isinstance(item_id, str) or not pattern.fullmatch(item_id):
244
+ _issue(issues, "id.invalid", f"{id_key} 格式非法:{item_id!r}", str(item_id))
245
+ continue
246
+ if item_id in result:
247
+ _issue(issues, "id.duplicate", f"{id_key} 重复:{item_id}", item_id)
248
+ continue
249
+ result[item_id] = item
250
+ return result
251
+
252
+
253
+ def _require_string(
254
+ item: dict[str, Any], key: str, issues: list[ValidationIssue], item_id: str
255
+ ) -> str | None:
256
+ value = item.get(key)
257
+ if not isinstance(value, str) or not value.strip():
258
+ _issue(issues, "field.required", f"{item_id}.{key} 必须是非空字符串", item_id)
259
+ return None
260
+ return value
261
+
262
+
263
+ def _require_string_list(
264
+ item: dict[str, Any], key: str, issues: list[ValidationIssue], item_id: str, nonempty: bool = False
265
+ ) -> list[str]:
266
+ value = item.get(key)
267
+ if not isinstance(value, list) or any(not isinstance(entry, str) or not entry for entry in value):
268
+ _issue(issues, "field.list", f"{item_id}.{key} 必须是字符串数组", item_id)
269
+ return []
270
+ if nonempty and not value:
271
+ _issue(issues, "field.nonempty", f"{item_id}.{key} 不能为空", item_id)
272
+ if len(value) != len(set(value)):
273
+ _issue(issues, "field.duplicate_ref", f"{item_id}.{key} 包含重复 ID", item_id)
274
+ return value
275
+
276
+
277
+ def _string_list(item: dict[str, Any], key: str) -> list[str]:
278
+ value = item.get(key)
279
+ if not isinstance(value, list):
280
+ return []
281
+ return [entry for entry in value if isinstance(entry, str)]
282
+
283
+
284
+ def _valid_repo_path(value: Any) -> bool:
285
+ if not isinstance(value, str) or not value or value != value.strip():
286
+ return False
287
+ if any(ord(character) < 32 or ord(character) == 127 for character in value):
288
+ return False
289
+ if "\\" in value or value.startswith(("/", "~/", "//")):
290
+ return False
291
+ if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value):
292
+ return False
293
+ raw_parts = value.split("/")
294
+ if any(part in {"", ".", ".."} for part in raw_parts):
295
+ return False
296
+ path = PurePosixPath(value)
297
+ return not path.is_absolute() and ".." not in path.parts and value not in {".", "./"}
298
+
299
+
300
+ def _is_java_path(value: Any) -> bool:
301
+ """Return whether a manifest path identifies a Java source file."""
302
+
303
+ return isinstance(value, str) and value.lower().endswith(".java")
304
+
305
+
306
+ def _task_uses_java(
307
+ task_id: str,
308
+ change_by_id: dict[str, dict[str, Any]],
309
+ test_by_id: dict[str, dict[str, Any]],
310
+ ) -> bool:
311
+ """Identify Java per task from routed implementation and test files."""
312
+
313
+ return any(
314
+ change.get("task_id") == task_id and _is_java_path(change.get("path"))
315
+ for change in change_by_id.values()
316
+ ) or any(
317
+ test.get("task_id") == task_id and _is_java_path(test.get("file"))
318
+ for test in test_by_id.values()
319
+ )
320
+
321
+
322
+ def _task_subsections(content: str) -> dict[str, str]:
323
+ """Return fixed task subsection bodies keyed by their protocol heading."""
324
+
325
+ bodies: dict[str, list[str]] = {}
326
+ current: str | None = None
327
+ heading_re = re.compile(r"^#{3,6}\s+(?:\d+(?:\.\d+)*\s+)?(.+?)\s*$")
328
+ for line in content.splitlines():
329
+ match = heading_re.match(line.strip())
330
+ if match:
331
+ title = match.group(1)
332
+ current = next((heading for heading in REQUIRED_TASK_HEADINGS if heading in title), None)
333
+ if current is not None:
334
+ bodies.setdefault(current, [])
335
+ continue
336
+ if current is not None:
337
+ bodies[current].append(line)
338
+ return {heading: "\n".join(lines).strip() for heading, lines in bodies.items()}
339
+
340
+
341
+ def _table_values(content: str, marker: str) -> list[str]:
342
+ values: list[str] = []
343
+ lines = content.splitlines()
344
+ for index, line in enumerate(lines):
345
+ if marker not in line or not line.strip().startswith("|"):
346
+ continue
347
+ header_cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
348
+ marker_columns = [position for position, cell in enumerate(header_cells) if marker in cell]
349
+ for candidate in lines[index + 1 :]:
350
+ if not candidate.strip().startswith("|"):
351
+ break
352
+ cells = [cell.strip() for cell in candidate.strip().strip("|").split("|")]
353
+ if cells and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
354
+ continue
355
+ for position in marker_columns:
356
+ if position < len(cells) and _is_substantive_value(cells[position]):
357
+ values.append(cells[position])
358
+ return values
359
+
360
+
361
+ def _is_substantive_value(value: str) -> bool:
362
+ normalized = value.strip().strip("`*_ 。;;,,")
363
+ return bool(normalized) and normalized.lower() not in {
364
+ "n/a",
365
+ "na",
366
+ "无",
367
+ "-",
368
+ "完成",
369
+ "已完成",
370
+ "同上",
371
+ "见上文",
372
+ "是",
373
+ "否",
374
+ "开发完成",
375
+ "已处理",
376
+ "依赖已满足",
377
+ "确认完成",
378
+ }
379
+
380
+
381
+ def _labeled_values(content: str, marker: str) -> list[str]:
382
+ values = _table_values(content, marker)
383
+ for line in content.splitlines():
384
+ position = line.find(marker)
385
+ if position < 0 or line.strip().startswith("|"):
386
+ continue
387
+ suffix = line[position + len(marker) :]
388
+ separator = re.search(r"[::]", suffix)
389
+ if separator:
390
+ value = suffix[separator.end() :]
391
+ if _is_substantive_value(value):
392
+ values.append(value.strip())
393
+ return values
394
+
395
+
396
+ def _has_labeled_value(content: str, marker: str) -> bool:
397
+ return bool(_labeled_values(content, marker))
398
+
399
+
400
+ def _semantic_values(content: str, marker: str) -> list[str]:
401
+ """Read a field from tables or common Chinese prose label forms."""
402
+
403
+ values = _labeled_values(content, marker)
404
+ for line in content.splitlines():
405
+ if marker not in line or line.strip().startswith("|"):
406
+ continue
407
+ suffix = line.split(marker, 1)[1]
408
+ match = re.search(r"(?:[::]|为|是|包括|采用|位于|在|时)\s*(.+)", suffix)
409
+ if match and _is_substantive_value(match.group(1)):
410
+ values.append(match.group(1).strip())
411
+ return values
412
+
413
+
414
+ def _bounded_field_values(content: str, marker: str) -> list[str]:
415
+ """Read one labeled field without leaking into the next semicolon-delimited field."""
416
+
417
+ values = _table_values(content, marker)
418
+ for line in content.splitlines():
419
+ if marker not in line or line.strip().startswith("|"):
420
+ continue
421
+ suffix = line.split(marker, 1)[1]
422
+ match = re.search(r"(?:[::]|为|是|包括|采用|位于|在|时)\s*(.+)", suffix)
423
+ if not match:
424
+ continue
425
+ value = re.split(r"[;;]", match.group(1), maxsplit=1)[0].strip()
426
+ if _is_substantive_value(value):
427
+ values.append(value)
428
+ return values
429
+
430
+
431
+ def _has_semantic_value(content: str, marker: str) -> bool:
432
+ return bool(_semantic_values(content, marker))
433
+
434
+
435
+ def _category_values(content: str, marker: str) -> list[str]:
436
+ """Read a closure category from either prose or the protocol table shape."""
437
+
438
+ values = _semantic_values(content, marker)
439
+ for line in content.splitlines():
440
+ if not line.strip().startswith("|"):
441
+ continue
442
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
443
+ if not cells or marker not in cells[0] or all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
444
+ continue
445
+ value = ";".join(cell for cell in cells[1:] if cell)
446
+ if _is_substantive_value(value):
447
+ values.append(value)
448
+ return values
449
+
450
+
451
+ def _entry_context(content: str, object_id: str) -> str:
452
+ """Return the prose line or table header and row that define an object ID."""
453
+
454
+ lines = content.splitlines()
455
+ contexts: list[str] = []
456
+ id_pattern = re.compile(
457
+ rf"(?<![A-Za-z0-9-]){re.escape(object_id)}(?![A-Za-z0-9-])"
458
+ )
459
+ for index, line in enumerate(lines):
460
+ if not id_pattern.search(line):
461
+ continue
462
+ if not line.strip().startswith("|"):
463
+ contexts.append(line)
464
+ continue
465
+ block_start = index
466
+ while block_start > 0 and lines[block_start - 1].strip().startswith("|"):
467
+ block_start -= 1
468
+ header = lines[block_start] if block_start < index else ""
469
+ contexts.append("\n".join(part for part in (header, line) if part))
470
+ return "\n".join(contexts)
471
+
472
+
473
+ def _contains_exact_repo_path(content: str, value: Any) -> bool:
474
+ """Match one complete repo-relative path instead of accepting a suffix."""
475
+
476
+ if not isinstance(value, str):
477
+ return False
478
+ if f"`{value}`" in content:
479
+ return True
480
+ path_boundary = r"A-Za-z0-9_./-"
481
+ return bool(
482
+ re.search(
483
+ rf"(?<![{path_boundary}]){re.escape(value)}(?![{path_boundary}])",
484
+ content,
485
+ )
486
+ )
487
+
488
+
489
+ def _contains_exact_code_value(content: str, value: Any) -> bool:
490
+ return isinstance(value, str) and f"`{value}`" in content
491
+
492
+
493
+ def _contains_exact_id(content: str, value: Any) -> bool:
494
+ return isinstance(value, str) and bool(
495
+ re.search(
496
+ rf"(?<![A-Za-z0-9-]){re.escape(value)}(?![A-Za-z0-9-])",
497
+ content,
498
+ )
499
+ )
500
+
501
+
502
+ def _is_protocol_id(value: str) -> bool:
503
+ return any(pattern.fullmatch(value) for pattern in ID_PATTERNS.values())
504
+
505
+
506
+ def _contains_exact_action(content: str, value: Any) -> bool:
507
+ return isinstance(value, str) and bool(
508
+ re.search(rf"(?<![A-Za-z]){re.escape(value)}(?![A-Za-z])", content)
509
+ )
510
+
511
+
512
+ def _contains_exact_command(content: str, value: Any) -> bool:
513
+ return isinstance(value, str) and f"`{value}`" in content
514
+
515
+
516
+ def _is_complete_generic_signature(value: str) -> bool:
517
+ """Accept a concrete non-Java type/API/config signature, not a prose label."""
518
+
519
+ normalized = value.strip().strip("` 。")
520
+ compact = re.sub(r"\s+", "", normalized)
521
+ if not _is_substantive_value(normalized) or len(compact) < 10:
522
+ return False
523
+ return bool(
524
+ re.search(r"\{[^{}]+\}", normalized)
525
+ or re.search(r"[A-Za-z_$][\w$.-]*\s*\([^)]*\)\s*(?::|->|=>)", normalized)
526
+ or re.search(r"\b(?:GET|POST|PUT|PATCH|DELETE)\s+/\S+", normalized, re.IGNORECASE)
527
+ or re.search(r"[A-Za-z_$][\w$.-]*\s*:\s*[A-Za-z_$][\w$<>,.?\[\] |/-]*", normalized)
528
+ )
529
+
530
+
531
+ def _is_validation_command(value: Any) -> bool:
532
+ if not isinstance(value, str) or not _is_substantive_value(value):
533
+ return False
534
+ if "\n" in value or "\r" in value:
535
+ return False
536
+ return bool(
537
+ re.search(
538
+ r"(?:^|[/_.-])(?:test|verify|check|lint|build|package|compile|e2e)(?:$|[/_. -])|"
539
+ r"\b(?:mvn|mvnw|gradle|gradlew|bazel|pytest|unittest|jest|vitest|phpunit|"
540
+ r"rspec|tox|ctest|playwright|go\s+test|cargo\s+test|dotnet\s+test|"
541
+ r"swift\s+test|mix\s+test|xcodebuild|meson\s+test|ninja\s+test|make(?:\s|$)|"
542
+ r"npm\s+(?:test|run)|pnpm\s+(?:test|run)|yarn\s+(?:test|run)|curl|httpie)\b",
543
+ value,
544
+ re.IGNORECASE,
545
+ )
546
+ )
547
+
548
+
549
+ def _contains_evidence(value: str) -> bool:
550
+ reason = re.search(r"(?:原因|依据)(?:是|为|[::])?|因为|由于", value)
551
+ if not reason:
552
+ return False
553
+ detail = value[reason.end() :]
554
+ normalized = re.sub(r"[\s`*_。,,;;::()()\[\]{}]", "", detail)
555
+ return len(normalized) >= 8 and normalized.lower() not in {
556
+ "现有机制",
557
+ "当前设计",
558
+ "无需处理",
559
+ "没有影响",
560
+ }
561
+
562
+
563
+ def _call_chain_entries(content: str) -> list[tuple[str, str, str, str]]:
564
+ """Extract every prose or table entry from a call-chain Diff subsection."""
565
+
566
+ entries: list[tuple[str, str, str, str]] = []
567
+ lines = content.splitlines()
568
+
569
+ def inline_value(line: str, marker: str) -> str:
570
+ if marker not in line:
571
+ return ""
572
+ suffix = line.split(marker, 1)[1]
573
+ suffix = re.sub(r"^\s*(?:[::]|为|是)?\s*", "", suffix)
574
+ return re.split(r"[;;]|改造后", suffix, maxsplit=1)[0].strip()
575
+
576
+ for line in lines:
577
+ if line.strip().startswith("|") or "入口" not in line:
578
+ continue
579
+ before = inline_value(line, "改造前")
580
+ after = inline_value(line, "改造后")
581
+ entry_match = re.search(r"入口\s*([^::;;]+)", line)
582
+ entries.append(
583
+ (
584
+ entry_match.group(1).strip() if entry_match else "",
585
+ before,
586
+ after,
587
+ line,
588
+ )
589
+ )
590
+
591
+ for index, line in enumerate(lines):
592
+ if not line.strip().startswith("|"):
593
+ continue
594
+ headers = [cell.strip() for cell in line.strip().strip("|").split("|")]
595
+ positions: dict[str, int] = {}
596
+ for marker in ("入口", "改造前", "改造后"):
597
+ position = next(
598
+ (cell_index for cell_index, cell in enumerate(headers) if marker in cell),
599
+ None,
600
+ )
601
+ if position is not None:
602
+ positions[marker] = position
603
+ if len(positions) != 3:
604
+ continue
605
+ for candidate in lines[index + 1 :]:
606
+ if not candidate.strip().startswith("|"):
607
+ break
608
+ cells = [cell.strip() for cell in candidate.strip().strip("|").split("|")]
609
+ if cells and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
610
+ continue
611
+ if max(positions.values()) >= len(cells):
612
+ entries.append(("", "", "", candidate))
613
+ continue
614
+ entries.append(
615
+ (
616
+ cells[positions["入口"]],
617
+ cells[positions["改造前"]],
618
+ cells[positions["改造后"]],
619
+ candidate,
620
+ )
621
+ )
622
+ break
623
+ return entries
624
+
625
+
626
+ def _is_negative_closure(value: str) -> bool:
627
+ return bool(re.search(r"N/A|不涉及|无相关|不新增|不生产|不消费|不修改|不提供", value, re.IGNORECASE))
628
+
629
+
630
+ def _task_objects(
631
+ task_id: str, objects: dict[str, dict[str, Any]]
632
+ ) -> list[tuple[str, dict[str, Any]]]:
633
+ return [(object_id, value) for object_id, value in objects.items() if value.get("task_id") == task_id]
634
+
635
+
636
+ def _validate_ready_task_closure(
637
+ task_id: str,
638
+ subsection_bodies: dict[str, str],
639
+ change_by_id: dict[str, dict[str, Any]],
640
+ step_by_id: dict[str, dict[str, Any]],
641
+ test_by_id: dict[str, dict[str, Any]],
642
+ issues: list[ValidationIssue],
643
+ ) -> None:
644
+ """Require each READY task subsection to close its implementation contract."""
645
+
646
+ missing: list[str] = []
647
+ goal_body = subsection_bodies.get("目标、交付物与非目标", "")
648
+ for marker in ("目标", "交付物", "非目标"):
649
+ if not _has_semantic_value(goal_body, marker):
650
+ missing.append(f"目标小节缺少 {marker} 的具体值")
651
+
652
+ file_body = subsection_bodies.get("文件与符号级改动", "")
653
+ task_changes = _task_objects(task_id, change_by_id)
654
+ for change_id, change in task_changes:
655
+ change_context = _entry_context(file_body, change_id)
656
+ absent: list[str] = []
657
+ if not _contains_exact_action(change_context, change.get("action")):
658
+ absent.append(f"action={change.get('action')}")
659
+ for symbol in _string_list(change, "symbols"):
660
+ if not _contains_exact_code_value(change_context, symbol):
661
+ absent.append(f"symbol={symbol}")
662
+ for field_name in ("repo_id", "module"):
663
+ value = change.get(field_name)
664
+ if not _contains_exact_code_value(change_context, value):
665
+ absent.append(f"{field_name}={value}")
666
+ if not _contains_exact_repo_path(change_context, change.get("path")):
667
+ absent.append(str(change.get("path")))
668
+ if absent:
669
+ missing.append(f"{change_id} 文件清单缺少 {', '.join(absent)}")
670
+
671
+ call_body = subsection_bodies.get("调用链 Diff", "")
672
+ call_entries = _call_chain_entries(call_body)
673
+ if not call_entries:
674
+ missing.append("调用链缺少逐入口的改造前和改造后记录")
675
+ for index, (entry, before, after, entry_text) in enumerate(call_entries, start=1):
676
+ entry_label = entry or f"第 {index} 条入口"
677
+ if not _is_substantive_value(entry):
678
+ missing.append(f"调用链 {entry_label} 缺少具体入口")
679
+ if not _is_substantive_value(before):
680
+ missing.append(f"调用链 {entry_label} 缺少改造前")
681
+ elif "->" not in before and "→" not in before and not re.search(
682
+ r"不存在|无此入口|尚无|未提供", before
683
+ ):
684
+ missing.append(f"调用链 {entry_label} 的改造前缺少节点链路或不存在说明")
685
+ if not _is_substantive_value(after):
686
+ missing.append(f"调用链 {entry_label} 缺少改造后")
687
+ elif "->" not in after and "→" not in after:
688
+ missing.append(f"调用链 {entry_label} 的改造后缺少节点链路")
689
+ if not re.search(
690
+ r"失败|异常|回滚|拒绝|404|Nack|Reject|错误|超时|重试|返回",
691
+ entry_text,
692
+ re.IGNORECASE,
693
+ ):
694
+ missing.append(f"调用链 {entry_label} 缺少行为差异或失败路径")
695
+
696
+ contract_body = subsection_bodies.get("新增或修改类型契约", "")
697
+ for marker in ("package", "字段类型", "空值", "异常", "调用方", "实现方"):
698
+ if not _has_labeled_value(contract_body, marker):
699
+ missing.append(f"类型契约缺少 {marker} 的具体值")
700
+ signature_values = [
701
+ value
702
+ for marker in ("完整 Java 签名", "完整签名", "接口签名", "等价签名")
703
+ for value in _labeled_values(contract_body, marker)
704
+ ]
705
+ if not signature_values:
706
+ missing.append("类型契约缺少完整签名")
707
+ elif not _task_uses_java(task_id, change_by_id, test_by_id) and not all(
708
+ _is_complete_generic_signature(value) for value in signature_values
709
+ ):
710
+ missing.append("非 Java 类型契约的完整签名缺少可执行结构")
711
+
712
+ closure_body = subsection_bodies.get("存储、消息与配置闭环", "")
713
+ for marker in ("DDL", "DO", "Mapper", "Repo", "消息", "配置"):
714
+ values = _category_values(closure_body, marker)
715
+ if not values:
716
+ missing.append(f"闭环小节缺少 {marker} 的具体值")
717
+ elif any(_is_negative_closure(value) and not _contains_evidence(value) for value in values):
718
+ missing.append(f"{marker} 声明不涉及但缺少事实原因或代码证据")
719
+
720
+ storage_values = [
721
+ value
722
+ for marker in ("DDL", "DO", "Mapper", "Repo")
723
+ for value in _category_values(closure_body, marker)
724
+ ]
725
+ if storage_values and not any(_is_negative_closure(value) for value in storage_values):
726
+ storage_text = "\n".join(storage_values)
727
+ if not re.search(r"`[^`]+(?:#[^`]+|/[^`]+)`", storage_text):
728
+ missing.append("存储闭环缺少 DDL/DO/Mapper/Repo 的实现文件或符号")
729
+ if not re.search(r"事务|回滚|幂等|锁|唯一键|transaction|rollback|idempoten", storage_text, re.IGNORECASE):
730
+ missing.append("存储闭环缺少事务、幂等或回退语义")
731
+
732
+ message_values = _category_values(closure_body, "消息")
733
+ if message_values and not any(_is_negative_closure(value) for value in message_values):
734
+ message_text = "\n".join(message_values)
735
+ if not re.search(r"生产|发布|发送|消费|订阅|producer|consumer|publish|subscribe", message_text, re.IGNORECASE):
736
+ missing.append("消息闭环缺少生产或消费语义")
737
+ if not re.search(
738
+ r"幂等|去重|唯一键|业务键|insertIfAbsent|putIfAbsent|setnx|dedup|idempoten",
739
+ message_text,
740
+ re.IGNORECASE,
741
+ ):
742
+ missing.append("消息闭环缺少幂等实现")
743
+ if not re.search(r"重试|退避|死信|Nack|Reject|retry|backoff|dead.?letter", message_text, re.IGNORECASE):
744
+ missing.append("消息闭环缺少重试或终止处理")
745
+
746
+ config_values = _category_values(closure_body, "配置")
747
+ if config_values and not any(_is_negative_closure(value) for value in config_values):
748
+ config_text = "\n".join(config_values)
749
+ if not re.search(r"`[^`]+`", config_text):
750
+ missing.append("配置闭环缺少可复制的配置 key")
751
+ if not re.search(r"类型|boolean|string|integer|int|long|number|enum", config_text, re.IGNORECASE):
752
+ missing.append("配置闭环缺少字段类型")
753
+ if not re.search(r"默认|环境|灰度|default|environment|env", config_text, re.IGNORECASE):
754
+ missing.append("配置闭环缺少默认值或环境差异")
755
+ if not re.search(r"回退|关闭|恢复|rollback|disable|restore", config_text, re.IGNORECASE):
756
+ missing.append("配置闭环缺少回退值或关闭动作")
757
+
758
+ step_body = subsection_bodies.get("符号级实施步骤", "")
759
+ covered_change_ids: set[str] = set()
760
+ covered_test_ids: set[str] = set()
761
+ covered_symbols: set[tuple[str, str]] = set()
762
+ for step_id, step in _task_objects(task_id, step_by_id):
763
+ step_context = _entry_context(step_body, step_id)
764
+ if not step_context:
765
+ missing.append(f"实施步骤缺少 {step_id}")
766
+ continue
767
+ step_change_ids = _string_list(step, "change_ids")
768
+ step_test_ids = _string_list(step, "test_ids")
769
+ for reference in (*step_change_ids, *step_test_ids):
770
+ if not _contains_exact_id(step_context, reference):
771
+ missing.append(f"{step_id} 缺少追踪引用 {reference}")
772
+ covered_change_ids.update(step_change_ids)
773
+ covered_test_ids.update(step_test_ids)
774
+ linked_symbols = [
775
+ (change_id, symbol)
776
+ for change_id in step_change_ids
777
+ for symbol in _string_list(change_by_id.get(change_id, {}), "symbols")
778
+ ]
779
+ step_symbols = {
780
+ (change_id, symbol)
781
+ for change_id, symbol in linked_symbols
782
+ if _contains_exact_code_value(step_context, symbol)
783
+ }
784
+ covered_symbols.update(step_symbols)
785
+ if linked_symbols and not step_symbols:
786
+ missing.append(f"{step_id} 缺少 Class#method 或等价符号插入位置")
787
+ has_relative_location = bool(
788
+ re.search(
789
+ r"插入位置|(?:在|于).{0,120}(?:之前|之后|前|后|首行|末尾|内部)",
790
+ step_context,
791
+ )
792
+ )
793
+ linked_changes = [change_by_id.get(change_id, {}) for change_id in step_change_ids]
794
+ defines_added_symbol = bool(linked_changes) and all(
795
+ change.get("action") == "add" for change in linked_changes
796
+ ) and "新增" in step_context
797
+ if not has_relative_location and not defines_added_symbol:
798
+ missing.append(f"{step_id} 缺少明确插入位置")
799
+ if "输入" not in step_context:
800
+ missing.append(f"{step_id} 缺少输入语义")
801
+ if "输出" not in step_context and "返回" not in step_context:
802
+ missing.append(f"{step_id} 缺少输出语义")
803
+ if not re.search(r"失败|异常|回滚|拒绝|404|Nack|Reject", step_context, re.IGNORECASE):
804
+ missing.append(f"{step_id} 缺少失败处理")
805
+ for change_id, _ in task_changes:
806
+ if change_id not in covered_change_ids:
807
+ missing.append(f"{change_id} 没有被任何实施步骤覆盖")
808
+ for change_id, change in task_changes:
809
+ for symbol in _string_list(change, "symbols"):
810
+ if (change_id, symbol) not in covered_symbols:
811
+ missing.append(f"{change_id} 的符号 {symbol} 没有被任何实施步骤覆盖")
812
+
813
+ test_body = subsection_bodies.get("测试映射", "")
814
+ for test_id, test in _task_objects(task_id, test_by_id):
815
+ test_context = _entry_context(test_body, test_id)
816
+ covered_steps = [
817
+ step_id
818
+ for step_id, step in _task_objects(task_id, step_by_id)
819
+ if test_id in _string_list(step, "test_ids")
820
+ ]
821
+ for value, label in (
822
+ (test_id, "Test ID"),
823
+ (test.get("command"), "验证命令"),
824
+ ):
825
+ present = (
826
+ _contains_exact_id(test_context, value)
827
+ if label == "Test ID"
828
+ else _contains_exact_command(test_context, value)
829
+ )
830
+ if isinstance(value, str) and not present:
831
+ missing.append(f"{test_id} 缺少{label} {value}")
832
+ if not _contains_exact_repo_path(test_context, test.get("file")):
833
+ missing.append(f"{test_id} 缺少测试文件 {test.get('file')}")
834
+ if test_id not in covered_test_ids:
835
+ missing.append(f"{test_id} 没有被任何实施步骤覆盖")
836
+ for step_id in covered_steps:
837
+ if not _contains_exact_id(test_context, step_id):
838
+ missing.append(f"{test_id} 缺少覆盖 Step {step_id}")
839
+ for marker in ("场景", "Mock"):
840
+ if not _has_semantic_value(test_context, marker):
841
+ missing.append(f"{test_id} 缺少 {marker}")
842
+ if not any(
843
+ _has_semantic_value(test_context, marker)
844
+ for marker in ("期望证据", "证据", "断言")
845
+ ):
846
+ missing.append(f"{test_id} 缺少期望证据或断言")
847
+
848
+ risk_body = subsection_bodies.get("风险、回退与完成证据", "")
849
+ for marker in ("风险", "回退", "完成证据"):
850
+ if not _has_semantic_value(risk_body, marker):
851
+ missing.append(f"风险小节缺少 {marker} 的具体值")
852
+
853
+ for detail in missing:
854
+ _issue(issues, "ready.task_closure", f"{task_id} 实施闭环不完整:{detail}", task_id)
855
+
856
+
857
+ def _section_detail(content: str) -> str:
858
+ lines = []
859
+ for line in content.splitlines():
860
+ stripped = line.strip()
861
+ if not stripped or stripped.startswith("#"):
862
+ continue
863
+ if stripped.startswith("|"):
864
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
865
+ if cells and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
866
+ continue
867
+ lines.append(stripped)
868
+ return "\n".join(lines)
869
+
870
+
871
+ def _is_verifiable_dependency_evidence(value: Any) -> bool:
872
+ if not isinstance(value, str) or not _is_substantive_value(value):
873
+ return False
874
+ compact = re.sub(r"[\s`*_。,,;;::()()\[\]{}]", "", value)
875
+ if len(compact) < 8:
876
+ return False
877
+ return bool(
878
+ re.search(
879
+ r"测试|命令|文件|路径|版本|revision|commit|sha|签名|契约|冻结|"
880
+ r"接口|响应|状态码|日志|报告|审批|记录|通过|pass(?:es|ed)?|frozen|result",
881
+ value,
882
+ re.IGNORECASE,
883
+ )
884
+ or re.search(r"(?:^|\s)(?:\./|/)?[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+", value)
885
+ )
886
+
887
+
888
+ def _validate_ready_manifest_values(
889
+ manifest: dict[str, Any],
890
+ repositories: Iterable[dict[str, Any]],
891
+ contracts: Iterable[dict[str, Any]],
892
+ tasks: Iterable[dict[str, Any]],
893
+ changes: Iterable[dict[str, Any]],
894
+ tests: Iterable[dict[str, Any]],
895
+ issues: list[ValidationIssue],
896
+ ) -> None:
897
+ """Reject syntactically present but implementation-empty routing values."""
898
+
899
+ scalar_values: list[tuple[str, Any, str]] = [
900
+ ("manifest.spec_id", manifest.get("spec_id"), "manifest"),
901
+ ("manifest.title", manifest.get("title"), "manifest"),
902
+ ]
903
+ for repo in repositories:
904
+ repo_id = str(repo.get("repo_id", "repository"))
905
+ scalar_values.extend(
906
+ (
907
+ (f"{repo_id}.name", repo.get("name"), repo_id),
908
+ (f"{repo_id}.path_hint", repo.get("path_hint"), repo_id),
909
+ )
910
+ )
911
+ baseline = repo.get("baseline")
912
+ if isinstance(baseline, dict):
913
+ scalar_values.append((f"{repo_id}.baseline.ref", baseline.get("ref"), repo_id))
914
+ for index, tech in enumerate(_string_list(repo, "tech_stack")):
915
+ scalar_values.append((f"{repo_id}.tech_stack[{index}]", tech, repo_id))
916
+ for contract in contracts:
917
+ contract_id = str(contract.get("contract_id", "contract"))
918
+ scalar_values.append((f"{contract_id}.name", contract.get("name"), contract_id))
919
+ for task in tasks:
920
+ task_id = str(task.get("task_id", "task"))
921
+ scalar_values.append((f"{task_id}.title", task.get("title"), task_id))
922
+ dependencies = task.get("depends_on")
923
+ if isinstance(dependencies, list):
924
+ for dependency in dependencies:
925
+ if not isinstance(dependency, dict):
926
+ continue
927
+ evidence = dependency.get("required_evidence")
928
+ if not _is_verifiable_dependency_evidence(evidence):
929
+ _issue(
930
+ issues,
931
+ "ready.dependency_evidence",
932
+ f"{task_id} 对 {dependency.get('task_id')} 的 required_evidence 不可检查",
933
+ task_id,
934
+ )
935
+ for change in changes:
936
+ change_id = str(change.get("change_id", "change"))
937
+ scalar_values.append((f"{change_id}.module", change.get("module"), change_id))
938
+ for index, symbol in enumerate(_string_list(change, "symbols")):
939
+ scalar_values.append((f"{change_id}.symbols[{index}]", symbol, change_id))
940
+ for test in tests:
941
+ test_id = str(test.get("test_id", "test"))
942
+ command = test.get("command")
943
+ scalar_values.append((f"{test_id}.command", command, test_id))
944
+ if isinstance(command, str) and _is_substantive_value(command) and not _is_validation_command(command):
945
+ _issue(
946
+ issues,
947
+ "ready.test_command",
948
+ f"{test_id}.command 不是可识别的测试、构建、检查或接口验证命令",
949
+ test_id,
950
+ )
951
+
952
+ for field_name, value, item_id in scalar_values:
953
+ if not isinstance(value, str) or not _is_substantive_value(value):
954
+ _issue(
955
+ issues,
956
+ "ready.manifest_value",
957
+ f"{field_name} 仍是占位值,不能标记 READY",
958
+ item_id,
959
+ )
960
+
961
+
962
+ def _validate_ready_non_task_sections(
963
+ repo_by_id: dict[str, dict[str, Any]],
964
+ contract_by_id: dict[str, dict[str, Any]],
965
+ task_by_id: dict[str, dict[str, Any]],
966
+ section_objects: dict[str, Section],
967
+ issues: list[ValidationIssue],
968
+ ) -> None:
969
+ """Validate implementation-bearing Canonical regions outside task bodies."""
970
+
971
+ def require_tokens(
972
+ section_id: str,
973
+ tokens: Iterable[str],
974
+ label: str,
975
+ any_token: bool = False,
976
+ include_headings: bool = False,
977
+ ) -> None:
978
+ section = section_objects.get(section_id)
979
+ if not section:
980
+ return
981
+ detail = _section_detail(section.content)
982
+ if not _is_substantive_value(detail):
983
+ _issue(
984
+ issues,
985
+ "ready.section_closure",
986
+ f"{section_id} 只有标题或占位结论,缺少 {label}",
987
+ section_id,
988
+ )
989
+ return
990
+ search_content = section.content if include_headings else detail
991
+ token_list = [token for token in tokens if token]
992
+ present = [
993
+ token
994
+ for token in token_list
995
+ if (
996
+ _contains_exact_id(search_content, token)
997
+ if _is_protocol_id(token)
998
+ else token in search_content
999
+ )
1000
+ ]
1001
+ if (any_token and not present) or (not any_token and len(present) != len(token_list)):
1002
+ missing = (
1003
+ token_list
1004
+ if any_token
1005
+ else [token for token in token_list if token not in present]
1006
+ )
1007
+ _issue(
1008
+ issues,
1009
+ "ready.section_closure",
1010
+ f"{section_id} 缺少 {label}:{', '.join(missing)}",
1011
+ section_id,
1012
+ )
1013
+
1014
+ def require_markers(section_id: str, markers: Iterable[str], label: str) -> None:
1015
+ section = section_objects.get(section_id)
1016
+ if not section:
1017
+ return
1018
+ detail = _section_detail(section.content)
1019
+ missing = [marker for marker in markers if not _has_semantic_value(detail, marker)]
1020
+ if missing:
1021
+ _issue(
1022
+ issues,
1023
+ "ready.section_closure",
1024
+ f"{section_id} 缺少带具体值的 {label}:{', '.join(missing)}",
1025
+ section_id,
1026
+ )
1027
+
1028
+ def require_field_tokens(
1029
+ section_id: str, marker: str, tokens: Iterable[str], label: str
1030
+ ) -> None:
1031
+ section = section_objects.get(section_id)
1032
+ if not section:
1033
+ return
1034
+ values = _bounded_field_values(_section_detail(section.content), marker)
1035
+ field_content = "\n".join(values)
1036
+ missing = [
1037
+ token
1038
+ for token in tokens
1039
+ if token
1040
+ and not (
1041
+ _contains_exact_id(field_content, token)
1042
+ if _is_protocol_id(token)
1043
+ else token in field_content
1044
+ )
1045
+ ]
1046
+ if missing:
1047
+ _issue(
1048
+ issues,
1049
+ "ready.section_closure",
1050
+ f"{section_id} 的 {marker} 缺少 {label}:{', '.join(missing)}",
1051
+ section_id,
1052
+ )
1053
+
1054
+ require_markers(
1055
+ "global-context",
1056
+ ("总目标", "成功指标", "输入与证据", "范围", "非目标", "兼容约束", "安全与性能约束", "已关闭架构决策"),
1057
+ "全局约束字段",
1058
+ )
1059
+
1060
+ for contract_id, contract in contract_by_id.items():
1061
+ section_id = str(contract.get("section_id"))
1062
+ consumer_task_ids = _string_list(contract, "consumer_task_ids")
1063
+ require_tokens(
1064
+ section_id,
1065
+ (
1066
+ str(contract.get("name", "")),
1067
+ ),
1068
+ "契约名称",
1069
+ include_headings=True,
1070
+ )
1071
+ require_field_tokens(
1072
+ section_id,
1073
+ "定义方任务",
1074
+ (str(contract.get("owner_task_id", "")),),
1075
+ "定义方任务 ID",
1076
+ )
1077
+ require_field_tokens(
1078
+ section_id,
1079
+ "消费方任务",
1080
+ consumer_task_ids,
1081
+ "消费方任务 ID",
1082
+ )
1083
+ require_markers(
1084
+ section_id,
1085
+ ("定义方任务", "消费方任务", "package", "字段", "类型", "空值", "异常", "调用方", "实现方", "兼容"),
1086
+ "共享契约字段",
1087
+ )
1088
+ section = section_objects.get(section_id)
1089
+ detail = _section_detail(section.content) if section else ""
1090
+ java_signature_values = _semantic_values(detail, "完整 Java 签名")
1091
+ generic_signature_values = _semantic_values(detail, "完整签名")
1092
+ if section and not java_signature_values and not generic_signature_values:
1093
+ _issue(
1094
+ issues,
1095
+ "ready.section_closure",
1096
+ f"{section_id} 缺少带具体值的完整签名",
1097
+ section_id,
1098
+ )
1099
+ elif java_signature_values and not _complete_java_signatures(detail):
1100
+ _issue(
1101
+ issues,
1102
+ "ready.section_closure",
1103
+ f"{section_id} 的完整 Java 签名不合法",
1104
+ section_id,
1105
+ )
1106
+ elif generic_signature_values and not all(
1107
+ _is_complete_generic_signature(value) for value in generic_signature_values
1108
+ ):
1109
+ _issue(
1110
+ issues,
1111
+ "ready.section_closure",
1112
+ f"{section_id} 的完整签名缺少可执行的类型、接口或配置结构",
1113
+ section_id,
1114
+ )
1115
+
1116
+ for repo_id, repo in repo_by_id.items():
1117
+ section_id = str(repo.get("section_id"))
1118
+ baseline = repo.get("baseline") if isinstance(repo.get("baseline"), dict) else {}
1119
+ repo_task_ids = [
1120
+ task_id for task_id, task in task_by_id.items() if task.get("repo_id") == repo_id
1121
+ ]
1122
+ require_tokens(
1123
+ section_id,
1124
+ (
1125
+ repo_id,
1126
+ str(repo.get("name", "")),
1127
+ ),
1128
+ "仓库 ID 与名称",
1129
+ include_headings=True,
1130
+ )
1131
+ require_tokens(
1132
+ section_id,
1133
+ (
1134
+ str(baseline.get("ref", "")),
1135
+ str(baseline.get("commit", "")),
1136
+ *repo_task_ids,
1137
+ ),
1138
+ "仓库基线和任务 ID",
1139
+ )
1140
+ require_markers(
1141
+ section_id,
1142
+ ("职责边界", "normalized remote", "基线", "技术栈与本地规范", "当前代码证据", "本仓库任务与波次"),
1143
+ "仓库身份、基线、证据和任务波次字段",
1144
+ )
1145
+ remotes = _string_list(repo, "remote_urls")
1146
+ if remotes:
1147
+ section = section_objects.get(section_id)
1148
+ remote_values = (
1149
+ _bounded_field_values(_section_detail(section.content), "normalized remote")
1150
+ if section
1151
+ else []
1152
+ )
1153
+ if not any(remote in "\n".join(remote_values) for remote in remotes):
1154
+ _issue(
1155
+ issues,
1156
+ "ready.section_closure",
1157
+ f"{section_id} 的 normalized remote 未包含 manifest 中的 remote",
1158
+ section_id,
1159
+ )
1160
+ require_field_tokens(
1161
+ section_id,
1162
+ "基线",
1163
+ (str(baseline.get("ref", "")), str(baseline.get("commit", ""))),
1164
+ "manifest 基线",
1165
+ )
1166
+
1167
+ dependency_types = sorted(
1168
+ {
1169
+ dependency.get("type")
1170
+ for task in task_by_id.values()
1171
+ for dependency in (
1172
+ task.get("depends_on") if isinstance(task.get("depends_on"), list) else []
1173
+ )
1174
+ if isinstance(dependency, dict) and isinstance(dependency.get("type"), str)
1175
+ }
1176
+ )
1177
+ require_tokens(
1178
+ "integration-plan",
1179
+ (*task_by_id.keys(), *contract_by_id.keys(), *dependency_types),
1180
+ "任务、契约和依赖类型",
1181
+ )
1182
+ require_markers(
1183
+ "integration-plan",
1184
+ ("参与任务", "联调入口", "完成证据"),
1185
+ "联调字段",
1186
+ )
1187
+ require_tokens("rollout-plan", repo_by_id.keys(), "仓库发布范围")
1188
+ require_markers(
1189
+ "rollout-plan", ("发布顺序", "全链路回退触发条件与动作"), "发布与回退字段"
1190
+ )
1191
+ require_markers(
1192
+ "rollout-plan", ("配置 / DDL / 消息切换顺序", "兼容窗口"), "切换与兼容字段"
1193
+ )
1194
+ require_tokens(
1195
+ "rollout-plan", ("配置", "DDL", "消息"), "配置、DDL 或消息切换顺序", any_token=True
1196
+ )
1197
+ require_tokens(
1198
+ "end-to-end-acceptance",
1199
+ task_by_id.keys(),
1200
+ "验收任务",
1201
+ )
1202
+ require_markers(
1203
+ "end-to-end-acceptance",
1204
+ ("覆盖任务", "场景", "前置数据 / Mock 边界", "执行命令或入口", "通过标准"),
1205
+ "全链路验收字段",
1206
+ )
1207
+
1208
+
1209
+ def _java_packages(content: str) -> set[str]:
1210
+ packages: set[str] = set()
1211
+ for value in _labeled_values(content, "package"):
1212
+ normalized = value.strip().strip("` ;。").removeprefix("package ").removesuffix(";")
1213
+ if re.fullmatch(r"[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+", normalized):
1214
+ packages.add(normalized)
1215
+ return packages
1216
+
1217
+
1218
+ def _complete_java_signatures(content: str) -> list[str]:
1219
+ signatures: list[str] = []
1220
+ for value in _labeled_values(content, "完整 Java 签名"):
1221
+ normalized = value.strip().strip("` 。")
1222
+ type_declaration = re.search(
1223
+ r"\b(?:class|interface|record|enum)\s+[A-Za-z_$][\w$]*[^{}]*\{[\s\S]*\}\s*;?$",
1224
+ normalized,
1225
+ )
1226
+ method_declaration = re.search(
1227
+ r"(?:^|\s)(?:public|protected|private)\s+(?:static\s+)?"
1228
+ r"[A-Za-z_$][\w$<>,.?\[\] ]*\s+[A-Za-z_$][\w$]*\s*"
1229
+ r"\([^)]*\)(?:\s+throws\s+[A-Za-z_$][\w$., ]*)?\s*;?$",
1230
+ normalized,
1231
+ )
1232
+ if type_declaration or method_declaration:
1233
+ signatures.append(normalized)
1234
+ return signatures
1235
+
1236
+
1237
+ def _java_package_from_path(value: Any) -> str | None:
1238
+ if not isinstance(value, str):
1239
+ return None
1240
+ parts = PurePosixPath(value).parts
1241
+ try:
1242
+ java_index = parts.index("java")
1243
+ except ValueError:
1244
+ return None
1245
+ package_parts = parts[java_index + 1 : -1]
1246
+ if not package_parts:
1247
+ return None
1248
+ package_name = ".".join(package_parts)
1249
+ if re.fullmatch(r"[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+", package_name):
1250
+ return package_name
1251
+ return None
1252
+
1253
+
1254
+ def _java_symbol_covered(
1255
+ symbol: str, signatures: list[str], task_class_names: set[str]
1256
+ ) -> bool:
1257
+ class_name, separator, member_name = symbol.partition("#")
1258
+ class_name = class_name.rsplit(".", 1)[-1]
1259
+ for signature in signatures:
1260
+ declared_types = set(
1261
+ re.findall(r"\b(?:class|interface|record|enum)\s+([A-Za-z_$][\w$]*)", signature)
1262
+ )
1263
+ if declared_types:
1264
+ if class_name not in declared_types:
1265
+ continue
1266
+ if not separator or re.search(rf"\b{re.escape(member_name)}\s*\(", signature):
1267
+ return True
1268
+ continue
1269
+ if len(task_class_names) == 1 and class_name in task_class_names:
1270
+ if separator and re.search(rf"\b{re.escape(member_name)}\s*\(", signature):
1271
+ return True
1272
+ return False
1273
+
1274
+
1275
+ def _is_closed_decision(value: str) -> bool:
1276
+ normalized = re.sub(r"^[\s>*_`#\-::]+", "", value).strip()
1277
+ return bool(
1278
+ re.match(
1279
+ r"^(?:无(?:开放项|待决项|需决策事项)?|不涉及|已关闭|均已关闭|全部已关闭)(?:[,,。;;::].*)?$",
1280
+ normalized,
1281
+ )
1282
+ )
1283
+
1284
+
1285
+ def _has_open_architecture_decision(text: str) -> bool:
1286
+ keywords = ("待用户决策", "开放决策", "未关闭架构决策")
1287
+ lines = text.splitlines()
1288
+ for index, line in enumerate(lines):
1289
+ keyword = next((candidate for candidate in keywords if candidate in line), None)
1290
+ if keyword is None:
1291
+ continue
1292
+ if re.search(
1293
+ r"(?:无|没有|不存在)\s*(?:待用户决策|开放决策|未关闭架构决策)", line
1294
+ ):
1295
+ continue
1296
+ suffix = line.split(keyword, 1)[1]
1297
+ if suffix.strip(" \t::。"):
1298
+ if not _is_closed_decision(suffix):
1299
+ return True
1300
+ continue
1301
+ next_content = ""
1302
+ for candidate in lines[index + 1 :]:
1303
+ if candidate.strip():
1304
+ next_content = candidate
1305
+ break
1306
+ if not next_content or not _is_closed_decision(next_content):
1307
+ return True
1308
+ return False
1309
+
1310
+
1311
+ def _has_unresolved_choice_language(value: str) -> bool:
1312
+ pending = re.compile(r"待选择|待决定|待拍板|尚未决定|未决定|二选一|多选一|任选")
1313
+ for match in pending.finditer(value):
1314
+ prefix = value[max(0, match.start() - 8) : match.start()]
1315
+ if not re.search(r"(?:无|没有|不存在|已无)\s*$", prefix):
1316
+ return True
1317
+ alternatives = re.search(
1318
+ r"方案\s*[A-ZA-Z]\s*(?:或|/|、)\s*[A-ZA-Z]", value, re.IGNORECASE
1319
+ )
1320
+ if alternatives:
1321
+ prefix = value[max(0, alternatives.start() - 8) : alternatives.start()]
1322
+ if not re.search(r"(?:不采用|已排除|已拒绝|均不采用)\s*$", prefix):
1323
+ return True
1324
+ return bool(
1325
+ re.search(
1326
+ r"(?:由|交由)(?:开发者|研发|实施方|实现方).{0,12}(?:选择|决定|确认|拍板)|"
1327
+ r"实现时.{0,8}(?:选择|决定|确认)",
1328
+ value,
1329
+ re.IGNORECASE,
1330
+ )
1331
+ )
1332
+
1333
+
1334
+ def _has_developer_owned_choice(text: str) -> bool:
1335
+ """Find unresolved implementation choices even when hidden outside the decision field."""
1336
+
1337
+ pending = re.compile(r"待选择|待决定|待拍板|尚未决定|未决定|二选一|多选一|任选")
1338
+ for match in pending.finditer(text):
1339
+ prefix = text[max(0, match.start() - 8) : match.start()]
1340
+ if not re.search(r"(?:无|没有|不存在|已无)\s*$", prefix):
1341
+ return True
1342
+ return bool(
1343
+ re.search(
1344
+ r"(?:由|交由)(?:开发者|研发|实施方|实现方).{0,16}(?:选择|决定|确认|拍板)|"
1345
+ r"实现时.{0,12}(?:选择|决定|确认)",
1346
+ text,
1347
+ re.IGNORECASE,
1348
+ )
1349
+ )
1350
+
1351
+
1352
+ def _check_dag(
1353
+ nodes: Iterable[str], edges: dict[str, list[str]], issues: list[ValidationIssue], code: str
1354
+ ) -> None:
1355
+ state: dict[str, int] = {}
1356
+ stack: list[str] = []
1357
+
1358
+ def visit(node: str) -> None:
1359
+ if state.get(node) == 2:
1360
+ return
1361
+ if state.get(node) == 1:
1362
+ start = stack.index(node) if node in stack else 0
1363
+ cycle = " -> ".join(stack[start:] + [node])
1364
+ _issue(issues, code, f"依赖图存在环:{cycle}", node)
1365
+ return
1366
+ state[node] = 1
1367
+ stack.append(node)
1368
+ for dependency in edges.get(node, []):
1369
+ if dependency in edges:
1370
+ visit(dependency)
1371
+ stack.pop()
1372
+ state[node] = 2
1373
+
1374
+ for node in nodes:
1375
+ visit(node)
1376
+
1377
+
1378
+ def validate_model(
1379
+ manifest: dict[str, Any],
1380
+ sections: dict[str, Section] | dict[str, str],
1381
+ text: str = "",
1382
+ require_ready: bool = False,
1383
+ ) -> ValidationReport:
1384
+ """Validate manifest structure, traceability, section contracts, and READY gates."""
1385
+
1386
+ issues: list[ValidationIssue] = []
1387
+ section_objects = _normalize_sections(sections)
1388
+ if manifest.get("schema") != SCHEMA:
1389
+ _issue(issues, "schema.unsupported", f"schema 必须是 {SCHEMA}", "schema")
1390
+ _require_string(manifest, "spec_id", issues, "manifest")
1391
+ _require_string(manifest, "title", issues, "manifest")
1392
+ revision = manifest.get("revision")
1393
+ if not isinstance(revision, int) or isinstance(revision, bool) or revision < 1:
1394
+ _issue(issues, "revision.invalid", "revision 必须是大于等于 1 的整数", "revision")
1395
+ status = manifest.get("status")
1396
+ if status not in VALID_STATUSES:
1397
+ _issue(issues, "status.invalid", "文档 status 必须是 DRAFT、BLOCKED 或 READY", "status")
1398
+ ready_requested = require_ready or status == "READY"
1399
+
1400
+ repositories = _objects(manifest, "repositories", issues)
1401
+ contracts = _objects(manifest, "contracts", issues)
1402
+ tasks = _objects(manifest, "tasks", issues)
1403
+ changes = _objects(manifest, "changes", issues)
1404
+ steps = _objects(manifest, "steps", issues)
1405
+ tests = _objects(manifest, "tests", issues)
1406
+ repo_by_id = _index_objects(repositories, "repo_id", "repository", issues)
1407
+ contract_by_id = _index_objects(contracts, "contract_id", "contract", issues)
1408
+ task_by_id = _index_objects(tasks, "task_id", "task", issues)
1409
+ change_by_id = _index_objects(changes, "change_id", "change", issues)
1410
+ step_by_id = _index_objects(steps, "step_id", "step", issues)
1411
+ test_by_id = _index_objects(tests, "test_id", "test", issues)
1412
+
1413
+ if not repo_by_id:
1414
+ _issue(issues, "repositories.empty", "至少需要一个仓库", "repositories")
1415
+ if not task_by_id:
1416
+ _issue(issues, "tasks.empty", "至少需要一个实施任务", "tasks")
1417
+
1418
+ required_section_ids = {"global-context", "integration-plan", "rollout-plan", "end-to-end-acceptance"}
1419
+ for repo_id, repo in repo_by_id.items():
1420
+ _require_string(repo, "name", issues, repo_id)
1421
+ remote_urls = _require_string_list(repo, "remote_urls", issues, repo_id, nonempty=True)
1422
+ for remote in remote_urls:
1423
+ if not re.match(r"^(https?://|ssh://|git://|git@|file://)", remote):
1424
+ _issue(issues, "repository.remote", f"{repo_id}.remote_urls 含非法 remote:{remote}", repo_id)
1425
+ _require_string(repo, "path_hint", issues, repo_id)
1426
+ _require_string_list(repo, "tech_stack", issues, repo_id, nonempty=True)
1427
+ baseline = repo.get("baseline")
1428
+ if not isinstance(baseline, dict):
1429
+ _issue(issues, "repository.baseline", f"{repo_id}.baseline 必须是 object", repo_id)
1430
+ else:
1431
+ _require_string(baseline, "ref", issues, repo_id)
1432
+ commit = baseline.get("commit")
1433
+ if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", commit):
1434
+ _issue(issues, "repository.commit", f"{repo_id}.baseline.commit 必须是完整 40 位 SHA", repo_id)
1435
+ section_id = _require_string(repo, "section_id", issues, repo_id)
1436
+ expected = f"repo-{repo_id.lower()}"
1437
+ if section_id and section_id != expected:
1438
+ _issue(issues, "section.naming", f"{repo_id}.section_id 必须是 {expected}", repo_id)
1439
+ if section_id:
1440
+ required_section_ids.add(section_id)
1441
+
1442
+ for contract_id, contract in contract_by_id.items():
1443
+ _require_string(contract, "name", issues, contract_id)
1444
+ owner = _require_string(contract, "owner_task_id", issues, contract_id)
1445
+ consumers = _require_string_list(contract, "consumer_task_ids", issues, contract_id, nonempty=True)
1446
+ if owner and owner not in task_by_id:
1447
+ _issue(issues, "reference.missing", f"{contract_id} 引用不存在的 owner task:{owner}", contract_id)
1448
+ for consumer in consumers:
1449
+ if consumer not in task_by_id:
1450
+ _issue(issues, "reference.missing", f"{contract_id} 引用不存在的 consumer task:{consumer}", contract_id)
1451
+ section_id = _require_string(contract, "section_id", issues, contract_id)
1452
+ expected = f"contract-{contract_id.lower()}"
1453
+ if section_id and section_id != expected:
1454
+ _issue(issues, "section.naming", f"{contract_id}.section_id 必须是 {expected}", contract_id)
1455
+ if section_id:
1456
+ required_section_ids.add(section_id)
1457
+
1458
+ task_edges: dict[str, list[str]] = {}
1459
+ for task_id, task in task_by_id.items():
1460
+ repo_id = task.get("repo_id")
1461
+ match = ID_PATTERNS["task"].fullmatch(task_id)
1462
+ if repo_id not in repo_by_id:
1463
+ _issue(issues, "reference.missing", f"{task_id}.repo_id 不存在:{repo_id}", task_id)
1464
+ if match and repo_id != match.group(1):
1465
+ _issue(issues, "task.repo_mismatch", f"{task_id} 必须隶属于仓库 {match.group(1)}", task_id)
1466
+ _require_string(task, "title", issues, task_id)
1467
+ task_status = task.get("status")
1468
+ if task_status not in VALID_STATUSES:
1469
+ _issue(issues, "status.invalid", f"{task_id}.status 非法:{task_status}", task_id)
1470
+ section_id = _require_string(task, "section_id", issues, task_id)
1471
+ expected = f"task-{task_id.lower()}"
1472
+ if section_id and section_id != expected:
1473
+ _issue(issues, "section.naming", f"{task_id}.section_id 必须是 {expected}", task_id)
1474
+ if section_id:
1475
+ required_section_ids.add(section_id)
1476
+ dependencies = task.get("depends_on")
1477
+ if not isinstance(dependencies, list):
1478
+ _issue(issues, "dependency.type", f"{task_id}.depends_on 必须是数组", task_id)
1479
+ dependencies = []
1480
+ dependency_ids: list[str] = []
1481
+ seen_dependencies: set[str] = set()
1482
+ for dependency in dependencies:
1483
+ if not isinstance(dependency, dict):
1484
+ _issue(issues, "dependency.item", f"{task_id}.depends_on 元素必须是 object", task_id)
1485
+ continue
1486
+ dependency_id = dependency.get("task_id")
1487
+ dependency_type = dependency.get("type")
1488
+ evidence = dependency.get("required_evidence")
1489
+ if dependency_id not in task_by_id:
1490
+ _issue(issues, "reference.missing", f"{task_id} 依赖不存在的任务:{dependency_id}", task_id)
1491
+ if dependency_id == task_id:
1492
+ _issue(issues, "dependency.self", f"{task_id} 不能依赖自身", task_id)
1493
+ if dependency_id in seen_dependencies:
1494
+ _issue(issues, "dependency.duplicate", f"{task_id} 重复依赖 {dependency_id}", task_id)
1495
+ if isinstance(dependency_id, str):
1496
+ dependency_ids.append(dependency_id)
1497
+ seen_dependencies.add(dependency_id)
1498
+ if dependency_type not in VALID_DEPENDENCY_TYPES:
1499
+ _issue(issues, "dependency.kind", f"{task_id} 依赖类型非法:{dependency_type}", task_id)
1500
+ if not isinstance(evidence, str) or not evidence.strip():
1501
+ _issue(issues, "dependency.evidence", f"{task_id} 的依赖必须声明 required_evidence", task_id)
1502
+ task_edges[task_id] = dependency_ids
1503
+ _require_string_list(task, "change_ids", issues, task_id, nonempty=True)
1504
+ _require_string_list(task, "step_ids", issues, task_id, nonempty=True)
1505
+ _require_string_list(task, "test_ids", issues, task_id, nonempty=True)
1506
+ _check_dag(task_by_id, task_edges, issues, "dependency.cycle")
1507
+
1508
+ for change_id, change in change_by_id.items():
1509
+ task_id = change.get("task_id")
1510
+ repo_id = change.get("repo_id")
1511
+ if task_id not in task_by_id:
1512
+ _issue(issues, "reference.missing", f"{change_id}.task_id 不存在:{task_id}", change_id)
1513
+ if repo_id not in repo_by_id:
1514
+ _issue(issues, "reference.missing", f"{change_id}.repo_id 不存在:{repo_id}", change_id)
1515
+ if task_id in task_by_id and repo_id != task_by_id[task_id].get("repo_id"):
1516
+ _issue(issues, "change.repo_mismatch", f"{change_id} 与所属任务仓库不一致", change_id)
1517
+ _require_string(change, "module", issues, change_id)
1518
+ path = change.get("path")
1519
+ if not _valid_repo_path(path):
1520
+ _issue(issues, "path.invalid", f"{change_id}.path 必须是安全的 repo-relative path:{path!r}", change_id)
1521
+ if change.get("action") not in VALID_ACTIONS:
1522
+ _issue(issues, "change.action", f"{change_id}.action 必须是 add、modify 或 delete", change_id)
1523
+ _require_string_list(change, "symbols", issues, change_id, nonempty=True)
1524
+
1525
+ step_edges: dict[str, list[str]] = {}
1526
+ for step_id, step in step_by_id.items():
1527
+ task_id = step.get("task_id")
1528
+ if task_id not in task_by_id:
1529
+ _issue(issues, "reference.missing", f"{step_id}.task_id 不存在:{task_id}", step_id)
1530
+ change_ids = _require_string_list(step, "change_ids", issues, step_id, nonempty=True)
1531
+ test_ids = _require_string_list(step, "test_ids", issues, step_id, nonempty=True)
1532
+ dependencies = _require_string_list(step, "depends_on_step_ids", issues, step_id)
1533
+ for change_id in change_ids:
1534
+ if change_id not in change_by_id:
1535
+ _issue(issues, "reference.missing", f"{step_id} 引用不存在的 change:{change_id}", step_id)
1536
+ elif change_by_id[change_id].get("task_id") != task_id:
1537
+ _issue(issues, "step.cross_task", f"{step_id} 不能引用其他任务的 change:{change_id}", step_id)
1538
+ for test_id in test_ids:
1539
+ if test_id not in test_by_id:
1540
+ _issue(issues, "reference.missing", f"{step_id} 引用不存在的 test:{test_id}", step_id)
1541
+ elif test_by_id[test_id].get("task_id") != task_id:
1542
+ _issue(issues, "step.cross_task", f"{step_id} 不能引用其他任务的 test:{test_id}", step_id)
1543
+ for dependency in dependencies:
1544
+ if dependency not in step_by_id:
1545
+ _issue(issues, "reference.missing", f"{step_id} 依赖不存在的 step:{dependency}", step_id)
1546
+ elif step_by_id[dependency].get("task_id") != task_id:
1547
+ _issue(issues, "step.cross_task", f"{step_id} 不能依赖其他任务的 step:{dependency}", step_id)
1548
+ step_edges[step_id] = dependencies
1549
+ _check_dag(step_by_id, step_edges, issues, "step.cycle")
1550
+
1551
+ for test_id, test in test_by_id.items():
1552
+ task_id = test.get("task_id")
1553
+ if task_id not in task_by_id:
1554
+ _issue(issues, "reference.missing", f"{test_id}.task_id 不存在:{task_id}", test_id)
1555
+ if not _valid_repo_path(test.get("file")):
1556
+ _issue(issues, "path.invalid", f"{test_id}.file 必须是安全的 repo-relative path", test_id)
1557
+ _require_string(test, "command", issues, test_id)
1558
+
1559
+ for task_id, task in task_by_id.items():
1560
+ expected_changes = {key for key, value in change_by_id.items() if value.get("task_id") == task_id}
1561
+ expected_steps = {key for key, value in step_by_id.items() if value.get("task_id") == task_id}
1562
+ expected_tests = {key for key, value in test_by_id.items() if value.get("task_id") == task_id}
1563
+ for field_name, expected in (
1564
+ ("change_ids", expected_changes),
1565
+ ("step_ids", expected_steps),
1566
+ ("test_ids", expected_tests),
1567
+ ):
1568
+ actual = set(task.get(field_name, [])) if isinstance(task.get(field_name), list) else set()
1569
+ if actual != expected:
1570
+ _issue(
1571
+ issues,
1572
+ "traceability.reverse",
1573
+ f"{task_id}.{field_name} 必须与反向归属完全一致;期望 {sorted(expected)},实际 {sorted(actual)}",
1574
+ task_id,
1575
+ )
1576
+
1577
+ for section_id in sorted(required_section_ids):
1578
+ if section_id not in section_objects:
1579
+ _issue(issues, "section.missing", f"缺少正文区域:{section_id}", section_id)
1580
+ known_section_ids = required_section_ids
1581
+ for section_id in section_objects:
1582
+ if section_id not in known_section_ids:
1583
+ _issue(issues, "section.unreferenced", f"manifest 未引用正文区域:{section_id}", section_id)
1584
+
1585
+ expected_section_order = ["global-context"]
1586
+ expected_section_order.extend(
1587
+ contract["section_id"]
1588
+ for contract in contracts
1589
+ if isinstance(contract.get("section_id"), str)
1590
+ )
1591
+ expected_section_order.extend(
1592
+ repo["section_id"] for repo in repositories if isinstance(repo.get("section_id"), str)
1593
+ )
1594
+ expected_section_order.extend(
1595
+ task["section_id"] for task in tasks if isinstance(task.get("section_id"), str)
1596
+ )
1597
+ expected_section_order.extend(("integration-plan", "rollout-plan", "end-to-end-acceptance"))
1598
+ actual_section_order = [
1599
+ section_id
1600
+ for section_id, _ in sorted(section_objects.items(), key=lambda item: item[1].begin_line)
1601
+ if section_id in required_section_ids
1602
+ ]
1603
+ if all(section_id in section_objects for section_id in expected_section_order) and actual_section_order != expected_section_order:
1604
+ _issue(
1605
+ issues,
1606
+ "section.order",
1607
+ f"正文区域顺序不符合协议;期望 {expected_section_order},实际 {actual_section_order}",
1608
+ )
1609
+
1610
+ for task_id, task in task_by_id.items():
1611
+ section = section_objects.get(str(task.get("section_id")))
1612
+ if not section:
1613
+ continue
1614
+ subsection_bodies = _task_subsections(section.content)
1615
+ for heading in REQUIRED_TASK_HEADINGS:
1616
+ if heading not in subsection_bodies:
1617
+ _issue(issues, "task.heading", f"{task_id} 缺少实施级小节:{heading}", task_id)
1618
+ elif not subsection_bodies[heading]:
1619
+ _issue(issues, "task.section_empty", f"{task_id} 的实施级小节没有正文:{heading}", task_id)
1620
+ for field_name in ("change_ids", "step_ids", "test_ids"):
1621
+ for referenced_id in _string_list(task, field_name):
1622
+ if not _contains_exact_id(section.content, referenced_id):
1623
+ _issue(
1624
+ issues,
1625
+ "task.body_traceability",
1626
+ f"{task_id} 正文没有出现 {field_name} 中的 ID:{referenced_id}",
1627
+ task_id,
1628
+ )
1629
+ if ready_requested and _task_uses_java(task_id, change_by_id, test_by_id):
1630
+ contract_body = subsection_bodies.get("新增或修改类型契约", "")
1631
+ packages = _java_packages(contract_body)
1632
+ expected_packages = {
1633
+ package
1634
+ for change in change_by_id.values()
1635
+ if change.get("task_id") == task_id and _is_java_path(change.get("path"))
1636
+ if (package := _java_package_from_path(change.get("path")))
1637
+ }
1638
+ for package in sorted(expected_packages - packages):
1639
+ _issue(
1640
+ issues,
1641
+ "java.contract",
1642
+ f"{task_id} 的 Java 契约缺少文件路径对应的 package:{package}",
1643
+ task_id,
1644
+ )
1645
+ signatures = _complete_java_signatures(contract_body)
1646
+ if not signatures:
1647
+ _issue(issues, "java.contract", f"{task_id} 的 Java 契约缺少完整 Java 签名", task_id)
1648
+ java_symbols = [
1649
+ symbol
1650
+ for change in change_by_id.values()
1651
+ if change.get("task_id") == task_id and _is_java_path(change.get("path"))
1652
+ for symbol in _string_list(change, "symbols")
1653
+ ]
1654
+ task_class_names = {
1655
+ symbol.partition("#")[0].rsplit(".", 1)[-1] for symbol in java_symbols
1656
+ }
1657
+ for symbol in java_symbols:
1658
+ if not _java_symbol_covered(symbol, signatures, task_class_names):
1659
+ _issue(
1660
+ issues,
1661
+ "java.contract",
1662
+ f"{task_id} 的 Java 契约没有完整覆盖符号:{symbol}",
1663
+ task_id,
1664
+ )
1665
+ for marker in ("字段类型", "空值", "异常", "调用方", "实现方"):
1666
+ if not _has_labeled_value(contract_body, marker):
1667
+ _issue(issues, "java.contract", f"{task_id} 的 Java 契约缺少带实际值的字段:{marker}", task_id)
1668
+
1669
+ if require_ready and status != "READY":
1670
+ _issue(issues, "ready.required", f"要求 READY,但文档状态是 {status!r}", "status")
1671
+ if ready_requested:
1672
+ ready_text = "\n".join(
1673
+ (
1674
+ text,
1675
+ json.dumps(manifest, ensure_ascii=False, sort_keys=True),
1676
+ *(section.content for section in section_objects.values()),
1677
+ )
1678
+ )
1679
+ _validate_ready_manifest_values(
1680
+ manifest,
1681
+ repositories,
1682
+ contracts,
1683
+ tasks,
1684
+ changes,
1685
+ tests,
1686
+ issues,
1687
+ )
1688
+ _validate_ready_non_task_sections(
1689
+ repo_by_id,
1690
+ contract_by_id,
1691
+ task_by_id,
1692
+ section_objects,
1693
+ issues,
1694
+ )
1695
+ for task_id, task in task_by_id.items():
1696
+ if task.get("status") != "READY":
1697
+ _issue(issues, "ready.task_status", f"READY 文档中的任务必须全部为 READY:{task_id}", task_id)
1698
+ section = section_objects.get(str(task.get("section_id")))
1699
+ if section:
1700
+ _validate_ready_task_closure(
1701
+ task_id,
1702
+ _task_subsections(section.content),
1703
+ change_by_id,
1704
+ step_by_id,
1705
+ test_by_id,
1706
+ issues,
1707
+ )
1708
+ for code, pattern in FORBIDDEN_READY_PATTERNS.items():
1709
+ match = pattern.search(ready_text)
1710
+ if match:
1711
+ snippet = match.group(0)
1712
+ _issue(issues, code, f"READY 文档包含未展开表达:{snippet}")
1713
+ not_applicable_pattern = re.compile(r"(?:^|[::;;,,\s])(N/A|不涉及|无相关)(?:$|[::;;,,。\s])", re.IGNORECASE)
1714
+ for task_id, task in task_by_id.items():
1715
+ section = section_objects.get(str(task.get("section_id")))
1716
+ if not section:
1717
+ continue
1718
+ for line_number, line in enumerate(section.content.splitlines(), start=section.begin_line + 1):
1719
+ if not_applicable_pattern.search(line) and not _contains_evidence(line):
1720
+ _issue(
1721
+ issues,
1722
+ "ready.not_applicable_evidence",
1723
+ f"第 {line_number} 行声明不涉及,但同一行没有原因或代码证据",
1724
+ task_id,
1725
+ )
1726
+ global_section = section_objects.get("global-context")
1727
+ decision_values = (
1728
+ _semantic_values(_section_detail(global_section.content), "已关闭架构决策")
1729
+ if global_section
1730
+ else []
1731
+ )
1732
+ if _has_open_architecture_decision(ready_text) or _has_developer_owned_choice(ready_text) or any(
1733
+ _has_unresolved_choice_language(value) for value in decision_values
1734
+ ):
1735
+ _issue(issues, "ready.open_decision", "READY 文档仍包含未关闭的架构决策")
1736
+ return ValidationReport(
1737
+ protocol="canonical-v1",
1738
+ status=manifest.get("status") if isinstance(manifest.get("status"), str) else None,
1739
+ manifest=manifest,
1740
+ sections=section_objects,
1741
+ issues=issues,
1742
+ )
1743
+
1744
+
1745
+ def validate_spec(source: str | Path, require_ready: bool = False) -> ValidationReport:
1746
+ """Validate a path or document string and return a structured report."""
1747
+
1748
+ text, _ = _read_source(source)
1749
+ try:
1750
+ manifest = parse_manifest(text)
1751
+ except CanonicalSpecError as exc:
1752
+ return ValidationReport(
1753
+ protocol="canonical-invalid",
1754
+ status=None,
1755
+ issues=[ValidationIssue("manifest.parse", str(exc))],
1756
+ )
1757
+ if manifest is None:
1758
+ issues: list[ValidationIssue] = []
1759
+ if require_ready:
1760
+ issues.append(
1761
+ ValidationIssue("ready.legacy", "legacy Dev Spec 不具备 Canonical v1 READY 证明")
1762
+ )
1763
+ return ValidationReport(protocol="legacy", status=None, issues=issues)
1764
+ try:
1765
+ sections = _parse_section_objects(text)
1766
+ except CanonicalSpecError as exc:
1767
+ return ValidationReport(
1768
+ protocol="canonical-v1",
1769
+ status=manifest.get("status") if isinstance(manifest.get("status"), str) else None,
1770
+ manifest=manifest,
1771
+ issues=[ValidationIssue("section.parse", str(exc))],
1772
+ )
1773
+ model_report = validate_model(manifest, sections, text=text, require_ready=require_ready)
1774
+ issues = model_report.issues
1775
+ first_section = text.find("<!-- EDS:SECTION:BEGIN")
1776
+ if first_section != -1 and text.find(MANIFEST_BEGIN) > first_section:
1777
+ issues.append(
1778
+ ValidationIssue("manifest.position", "manifest 必须位于所有 EDS 正文区域之前")
1779
+ )
1780
+ return ValidationReport(
1781
+ protocol="canonical-v1",
1782
+ status=manifest.get("status") if isinstance(manifest.get("status"), str) else None,
1783
+ manifest=manifest,
1784
+ sections=sections,
1785
+ issues=issues,
1786
+ )
1787
+
1788
+
1789
+ def _render_section(section: Section) -> str:
1790
+ return (
1791
+ f"<!-- EDS:SECTION:BEGIN id={section.section_id} -->\n"
1792
+ f"{section.content}\n"
1793
+ f"<!-- EDS:SECTION:END id={section.section_id} -->"
1794
+ )
1795
+
1796
+
1797
+ def _integration_slice(content: str, relevant_task_ids: set[str]) -> str:
1798
+ lines = content.splitlines()
1799
+ selected: list[str] = []
1800
+ task_reference = re.compile(r"R[1-9][0-9]*-T[1-9][0-9]*")
1801
+ for line in lines:
1802
+ mentioned = set(task_reference.findall(line))
1803
+ if not mentioned or mentioned.intersection(relevant_task_ids):
1804
+ selected.append(line)
1805
+ return "\n".join(selected).strip() or "## 联调计划\n\n当前选择范围没有联调条目。"
1806
+
1807
+
1808
+ def select_scope(
1809
+ source: str | Path,
1810
+ repo_id: str,
1811
+ task_ids: Iterable[str] | None = None,
1812
+ output_format: str = "markdown",
1813
+ ) -> str | dict[str, Any]:
1814
+ """Return the deterministic consumption closure for one repository."""
1815
+
1816
+ text, source_path = _read_source(source)
1817
+ report = validate_spec(text)
1818
+ if report.protocol != "canonical-v1":
1819
+ raise CanonicalSpecError("消费闭包选择只支持 Canonical Spec v1")
1820
+ if not report.ok or report.manifest is None:
1821
+ details = ";".join(issue.message for issue in report.issues[:5])
1822
+ raise CanonicalSpecError(f"Spec 校验失败,不能选择消费闭包:{details}")
1823
+ manifest = report.manifest
1824
+ repo_by_id = {item["repo_id"]: item for item in manifest["repositories"]}
1825
+ task_by_id = {item["task_id"]: item for item in manifest["tasks"]}
1826
+ contract_by_id = {item["contract_id"]: item for item in manifest["contracts"]}
1827
+ if repo_id not in repo_by_id:
1828
+ raise CanonicalSpecError(f"仓库不存在:{repo_id}")
1829
+ requested = list(task_ids or [])
1830
+ if requested:
1831
+ selected_ids: list[str] = []
1832
+ for task_id in requested:
1833
+ task = task_by_id.get(task_id)
1834
+ if task is None:
1835
+ raise CanonicalSpecError(f"任务不存在:{task_id}")
1836
+ if task.get("repo_id") != repo_id:
1837
+ raise CanonicalSpecError(f"任务 {task_id} 不属于仓库 {repo_id}")
1838
+ if task_id not in selected_ids:
1839
+ selected_ids.append(task_id)
1840
+ else:
1841
+ selected_ids = [item["task_id"] for item in manifest["tasks"] if item.get("repo_id") == repo_id]
1842
+ if not selected_ids:
1843
+ raise CanonicalSpecError(f"仓库 {repo_id} 没有可选择任务")
1844
+
1845
+ selected_set = set(selected_ids)
1846
+ direct_dependency_ids = {
1847
+ dependency["task_id"]
1848
+ for task_id in selected_ids
1849
+ for dependency in task_by_id[task_id].get("depends_on", [])
1850
+ if dependency.get("task_id") not in selected_set
1851
+ }
1852
+ related_contract_ids = [
1853
+ contract_id
1854
+ for contract_id, contract in contract_by_id.items()
1855
+ if contract.get("owner_task_id") in selected_set
1856
+ or selected_set.intersection(contract.get("consumer_task_ids", []))
1857
+ ]
1858
+ relevant_task_ids = selected_set | direct_dependency_ids
1859
+ section_ids = ["global-context", repo_by_id[repo_id]["section_id"]]
1860
+ section_ids.extend(contract_by_id[contract_id]["section_id"] for contract_id in related_contract_ids)
1861
+ section_ids.extend(task_by_id[task_id]["section_id"] for task_id in selected_ids)
1862
+ sections = report.sections
1863
+ dependencies = []
1864
+ for dependency_id in sorted(direct_dependency_ids):
1865
+ dependency = task_by_id[dependency_id]
1866
+ edge_details = [
1867
+ edge
1868
+ for task_id in selected_ids
1869
+ for edge in task_by_id[task_id].get("depends_on", [])
1870
+ if edge.get("task_id") == dependency_id
1871
+ ]
1872
+ dependencies.append(
1873
+ {
1874
+ "task_id": dependency_id,
1875
+ "repo_id": dependency["repo_id"],
1876
+ "title": dependency["title"],
1877
+ "status": dependency["status"],
1878
+ "edges": edge_details,
1879
+ }
1880
+ )
1881
+
1882
+ selected_tasks = [task_by_id[task_id] for task_id in selected_ids]
1883
+ selected_changes = [item for item in manifest["changes"] if item.get("task_id") in selected_set]
1884
+ selected_steps = [item for item in manifest["steps"] if item.get("task_id") in selected_set]
1885
+ selected_tests = [item for item in manifest["tests"] if item.get("task_id") in selected_set]
1886
+ related_contracts = [contract_by_id[contract_id] for contract_id in related_contract_ids]
1887
+ payload: dict[str, Any] = {
1888
+ "schema": SCHEMA,
1889
+ "spec_id": manifest["spec_id"],
1890
+ "revision": manifest["revision"],
1891
+ "status": manifest["status"],
1892
+ "source_path": source_path,
1893
+ "source_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
1894
+ "repo": repo_by_id[repo_id],
1895
+ "selected_task_ids": selected_ids,
1896
+ "selected_tasks": selected_tasks,
1897
+ "selected_changes": selected_changes,
1898
+ "selected_steps": selected_steps,
1899
+ "selected_tests": selected_tests,
1900
+ "direct_dependency_summaries": dependencies,
1901
+ "related_contract_ids": related_contract_ids,
1902
+ "related_contracts": related_contracts,
1903
+ "sections": [
1904
+ {"section_id": section_id, "content": sections[section_id].content}
1905
+ for section_id in section_ids
1906
+ ],
1907
+ "integration_plan": _integration_slice(
1908
+ sections["integration-plan"].content, relevant_task_ids
1909
+ ),
1910
+ }
1911
+ if output_format == "json":
1912
+ return payload
1913
+ if output_format != "markdown":
1914
+ raise CanonicalSpecError(f"不支持的输出格式:{output_format}")
1915
+
1916
+ routing_manifest = {
1917
+ key: payload[key]
1918
+ for key in (
1919
+ "schema",
1920
+ "spec_id",
1921
+ "revision",
1922
+ "status",
1923
+ "source_path",
1924
+ "source_sha256",
1925
+ "repo",
1926
+ "selected_task_ids",
1927
+ "selected_tasks",
1928
+ "selected_changes",
1929
+ "selected_steps",
1930
+ "selected_tests",
1931
+ "direct_dependency_summaries",
1932
+ "related_contract_ids",
1933
+ "related_contracts",
1934
+ )
1935
+ }
1936
+ manifest_json = json.dumps(routing_manifest, ensure_ascii=False, indent=2)
1937
+ output = [
1938
+ f"# {manifest['title']} · {repo_id} 消费闭包",
1939
+ "",
1940
+ "<!-- EDS:CONSUMPTION-SCOPE:BEGIN -->",
1941
+ "```json",
1942
+ manifest_json,
1943
+ "```",
1944
+ "<!-- EDS:CONSUMPTION-SCOPE:END -->",
1945
+ "",
1946
+ f"> 来源 SHA-256:`{payload['source_sha256']}`",
1947
+ f"> 选择任务:{', '.join(selected_ids)}",
1948
+ "",
1949
+ ]
1950
+ for section_id in section_ids:
1951
+ output.extend([_render_section(sections[section_id]), ""])
1952
+ output.extend(["## 直接依赖任务摘要", ""])
1953
+ if dependencies:
1954
+ for dependency in dependencies:
1955
+ edge_text = ";".join(
1956
+ f"{edge['type']},完成证据:{edge['required_evidence']}" for edge in dependency["edges"]
1957
+ )
1958
+ output.append(
1959
+ f"- `{dependency['task_id']}`(仓库 `{dependency['repo_id']}`,"
1960
+ f"状态 `{dependency['status']}`):{dependency['title']};{edge_text}"
1961
+ )
1962
+ else:
1963
+ output.append("- 无直接依赖任务。")
1964
+ integration = Section(
1965
+ section_id="integration-plan",
1966
+ content=payload["integration_plan"],
1967
+ begin_line=0,
1968
+ end_line=0,
1969
+ )
1970
+ output.extend(["", _render_section(integration), ""])
1971
+ return "\n".join(output).rstrip() + "\n"