easy-coding-harness 0.10.0-beta.4 → 0.10.0-beta.6

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.
@@ -16,8 +16,11 @@ from typing import Any, Iterable
16
16
 
17
17
 
18
18
  SCHEMA = "easy-dev-spec/v1"
19
+ EXECUTION_SCHEMA = "easy-dev-spec-execution/v1"
19
20
  MANIFEST_BEGIN = "<!-- EDS:MANIFEST:BEGIN -->"
20
21
  MANIFEST_END = "<!-- EDS:MANIFEST:END -->"
22
+ EXECUTION_BEGIN = "<!-- EDS:EXECUTION:BEGIN -->"
23
+ EXECUTION_END = "<!-- EDS:EXECUTION:END -->"
21
24
  SECTION_BEGIN_RE = re.compile(r"^<!-- EDS:SECTION:BEGIN id=([a-z0-9][a-z0-9-]*) -->$")
22
25
  SECTION_END_RE = re.compile(r"^<!-- EDS:SECTION:END id=([a-z0-9][a-z0-9-]*) -->$")
23
26
  ID_PATTERNS = {
@@ -31,6 +34,32 @@ ID_PATTERNS = {
31
34
  VALID_STATUSES = {"DRAFT", "BLOCKED", "READY"}
32
35
  VALID_DEPENDENCY_TYPES = {"hard", "contract", "integration"}
33
36
  VALID_ACTIONS = {"add", "modify", "delete"}
37
+ VALID_EXECUTION_TASK_STATUSES = {
38
+ "not_started",
39
+ "in_progress",
40
+ "blocked",
41
+ "implemented",
42
+ "verified",
43
+ "completed",
44
+ "cancelled",
45
+ }
46
+ VALID_EXECUTION_DEPENDENCY_STATUSES = {"pending", "satisfied"}
47
+ VALID_EXECUTION_EVIDENCE_STATUSES = {"passed", "failed", "recorded"}
48
+ VALID_EXECUTION_EVENT_TYPES = {
49
+ "task_status_changed",
50
+ "step_status_changed",
51
+ "dependency_status_changed",
52
+ "spec_revised",
53
+ }
54
+ VALID_EXECUTION_TASK_TRANSITIONS = {
55
+ "not_started": {"in_progress", "cancelled"},
56
+ "in_progress": {"in_progress", "blocked", "implemented", "cancelled"},
57
+ "blocked": {"blocked", "in_progress", "cancelled"},
58
+ "implemented": {"in_progress", "blocked", "verified"},
59
+ "verified": {"in_progress", "blocked", "completed"},
60
+ "completed": {"in_progress"},
61
+ "cancelled": {"in_progress"},
62
+ }
34
63
  REQUIRED_TASK_HEADINGS = (
35
64
  "目标、交付物与非目标",
36
65
  "文件与符号级改动",
@@ -54,6 +83,13 @@ FORBIDDEN_READY_PATTERNS = {
54
83
  r"实施时检查|复用现有机制|在合适位置|新增相关组件|以目标分支为准|接入所有\s*Ability"
55
84
  ),
56
85
  }
86
+ BLOCKED_EVIDENCE_FIELDS = (
87
+ "已问问题",
88
+ "用户原始回答",
89
+ "落入 Spec 的结论与影响范围",
90
+ "剩余阻塞",
91
+ "解除条件与责任方",
92
+ )
57
93
 
58
94
 
59
95
  class CanonicalSpecError(ValueError):
@@ -89,6 +125,9 @@ class ValidationReport:
89
125
  warnings: list[ValidationIssue] = field(default_factory=list)
90
126
  manifest: dict[str, Any] | None = None
91
127
  sections: dict[str, Section] = field(default_factory=dict)
128
+ execution: dict[str, Any] | None = None
129
+ design_sha256: str | None = None
130
+ document_sha256: str | None = None
92
131
 
93
132
  @property
94
133
  def ok(self) -> bool:
@@ -99,6 +138,11 @@ class ValidationReport:
99
138
  "protocol": self.protocol,
100
139
  "status": self.status,
101
140
  "ok": self.ok,
141
+ "design_sha256": self.design_sha256,
142
+ "document_sha256": self.document_sha256,
143
+ "execution_revision": (
144
+ self.execution.get("execution_revision") if self.execution else None
145
+ ),
102
146
  "issues": [issue.to_dict() for issue in self.issues],
103
147
  "warnings": [warning.to_dict() for warning in self.warnings],
104
148
  }
@@ -147,6 +191,51 @@ def parse_manifest(text: str) -> dict[str, Any] | None:
147
191
  return manifest
148
192
 
149
193
 
194
+ def split_execution_region(text: str) -> tuple[str, dict[str, Any] | None]:
195
+ """Return design-only text and the optional shared execution ledger.
196
+
197
+ The execution block is deliberately excluded from the design digest. Runtime
198
+ progress can therefore change without invalidating a previously selected
199
+ design scope.
200
+ """
201
+
202
+ begin_count = text.count(EXECUTION_BEGIN)
203
+ end_count = text.count(EXECUTION_END)
204
+ if begin_count == 0 and end_count == 0:
205
+ design_text = text.rstrip() + "\n"
206
+ return design_text, None
207
+ if begin_count != 1 or end_count != 1:
208
+ raise CanonicalSpecError("文档必须包含零组或一组 execution 边界")
209
+ begin = text.find(EXECUTION_BEGIN)
210
+ end = text.find(EXECUTION_END)
211
+ if end < begin:
212
+ raise CanonicalSpecError("execution 结束标记位于开始标记之前")
213
+ trailing = text[end + len(EXECUTION_END) :]
214
+ if trailing.strip():
215
+ raise CanonicalSpecError("execution 区域必须是文档最后一个非空区域")
216
+ inner = text[begin + len(EXECUTION_BEGIN) : end]
217
+ fenced = re.fullmatch(r"\s*```json\s*\n(.*?)\n```\s*", inner, re.DOTALL)
218
+ if not fenced:
219
+ raise CanonicalSpecError("execution 必须是边界内唯一的 ```json 代码块")
220
+ try:
221
+ execution = json.loads(fenced.group(1))
222
+ except json.JSONDecodeError as exc:
223
+ raise CanonicalSpecError(
224
+ f"execution 不是合法 JSON:第 {exc.lineno} 行第 {exc.colno} 列 {exc.msg}"
225
+ ) from exc
226
+ if not isinstance(execution, dict):
227
+ raise CanonicalSpecError("execution 顶层必须是 JSON object")
228
+ design_text = text[:begin].rstrip() + "\n"
229
+ return design_text, execution
230
+
231
+
232
+ def design_sha256(text: str) -> str:
233
+ """Hash only design-bearing content, excluding the execution ledger."""
234
+
235
+ design_text, _ = split_execution_region(text)
236
+ return hashlib.sha256(design_text.encode("utf-8")).hexdigest()
237
+
238
+
150
239
  def _parse_section_objects(text: str) -> dict[str, Section]:
151
240
  """Parse section objects with line metadata."""
152
241
 
@@ -959,6 +1048,34 @@ def _validate_ready_manifest_values(
959
1048
  )
960
1049
 
961
1050
 
1051
+ def _validate_blocked_evidence(
1052
+ section_objects: dict[str, Section],
1053
+ issues: list[ValidationIssue],
1054
+ ) -> None:
1055
+ """Require proof that BLOCKED was chosen only after a user answer."""
1056
+
1057
+ global_section = section_objects.get("global-context")
1058
+ if not global_section:
1059
+ return
1060
+ content = global_section.content
1061
+ if "用户判断与剩余阻塞" not in content:
1062
+ _issue(
1063
+ issues,
1064
+ "blocked.evidence",
1065
+ "BLOCKED 文档必须包含“用户判断与剩余阻塞”记录,证明已提问并收到回答",
1066
+ "global-context",
1067
+ )
1068
+ return
1069
+ for marker in BLOCKED_EVIDENCE_FIELDS:
1070
+ if not _has_labeled_value(content, marker):
1071
+ _issue(
1072
+ issues,
1073
+ "blocked.evidence",
1074
+ f"BLOCKED 文档缺少 {marker} 的具体值",
1075
+ "global-context",
1076
+ )
1077
+
1078
+
962
1079
  def _validate_ready_non_task_sections(
963
1080
  repo_by_id: dict[str, dict[str, Any]],
964
1081
  contract_by_id: dict[str, dict[str, Any]],
@@ -1375,6 +1492,1176 @@ def _check_dag(
1375
1492
  visit(node)
1376
1493
 
1377
1494
 
1495
+ def _validate_execution_evidence(
1496
+ values: Any,
1497
+ issues: list[ValidationIssue],
1498
+ item_id: str,
1499
+ event_ids: set[str] | None = None,
1500
+ valid_test_ids: set[str] | None = None,
1501
+ ) -> None:
1502
+ if not isinstance(values, list):
1503
+ _issue(issues, "execution.evidence", f"{item_id}.evidence 必须是数组", item_id)
1504
+ return
1505
+ for index, value in enumerate(values):
1506
+ label = f"{item_id}.evidence[{index}]"
1507
+ if not isinstance(value, dict):
1508
+ _issue(issues, "execution.evidence", f"{label} 必须是 object", item_id)
1509
+ continue
1510
+ for field_name in ("kind", "ref"):
1511
+ if not isinstance(value.get(field_name), str) or not value[field_name].strip():
1512
+ _issue(
1513
+ issues,
1514
+ "execution.evidence",
1515
+ f"{label}.{field_name} 必须是非空字符串",
1516
+ item_id,
1517
+ )
1518
+ if value.get("status") not in VALID_EXECUTION_EVIDENCE_STATUSES:
1519
+ _issue(
1520
+ issues,
1521
+ "execution.evidence",
1522
+ f"{label}.status 必须是 passed、failed 或 recorded",
1523
+ item_id,
1524
+ )
1525
+ evidence_event_id = value.get("event_id")
1526
+ if evidence_event_id is not None and (
1527
+ not isinstance(evidence_event_id, str)
1528
+ or (event_ids is not None and evidence_event_id not in event_ids)
1529
+ ):
1530
+ _issue(
1531
+ issues,
1532
+ "execution.evidence",
1533
+ f"{label}.event_id 必须引用存在的执行事件",
1534
+ item_id,
1535
+ )
1536
+ if value.get("kind") == "test" and (
1537
+ not isinstance(value.get("test_id"), str) or not value["test_id"].strip()
1538
+ ):
1539
+ _issue(
1540
+ issues,
1541
+ "execution.evidence",
1542
+ f"{label} 的 test 证据必须声明 test_id",
1543
+ item_id,
1544
+ )
1545
+ elif (
1546
+ value.get("kind") == "test"
1547
+ and valid_test_ids is not None
1548
+ and value.get("test_id") not in valid_test_ids
1549
+ ):
1550
+ _issue(
1551
+ issues,
1552
+ "execution.evidence",
1553
+ f"{label}.test_id 未引用当前 manifest Test",
1554
+ item_id,
1555
+ )
1556
+ evidence_sha256 = value.get("sha256")
1557
+ if evidence_sha256 is not None and (
1558
+ not isinstance(evidence_sha256, str)
1559
+ or not re.fullmatch(r"[0-9a-f]{64}", evidence_sha256)
1560
+ ):
1561
+ _issue(
1562
+ issues,
1563
+ "execution.evidence",
1564
+ f"{label}.sha256 必须是 64 位小写 SHA-256",
1565
+ item_id,
1566
+ )
1567
+
1568
+
1569
+ def _validate_execution_state(
1570
+ manifest: dict[str, Any],
1571
+ execution: dict[str, Any],
1572
+ current_design_sha256: str,
1573
+ issues: list[ValidationIssue],
1574
+ ) -> None:
1575
+ """Validate the additive shared execution ledger."""
1576
+
1577
+ if execution.get("schema") != EXECUTION_SCHEMA:
1578
+ _issue(
1579
+ issues,
1580
+ "execution.schema",
1581
+ f"execution.schema 必须是 {EXECUTION_SCHEMA}",
1582
+ "execution",
1583
+ )
1584
+ if execution.get("spec_id") != manifest.get("spec_id"):
1585
+ _issue(issues, "execution.spec_id", "execution.spec_id 必须与 manifest 一致", "execution")
1586
+ if execution.get("design_revision") != manifest.get("revision"):
1587
+ _issue(
1588
+ issues,
1589
+ "execution.design_revision",
1590
+ "execution.design_revision 必须与 manifest.revision 一致;设计改动后应执行 sync-design",
1591
+ "execution",
1592
+ )
1593
+ if execution.get("design_sha256") != current_design_sha256:
1594
+ _issue(
1595
+ issues,
1596
+ "execution.design_sha256",
1597
+ "execution.design_sha256 与当前静态设计不一致;设计改动后应执行 sync-design",
1598
+ "execution",
1599
+ )
1600
+ execution_revision = execution.get("execution_revision")
1601
+ if (
1602
+ not isinstance(execution_revision, int)
1603
+ or isinstance(execution_revision, bool)
1604
+ or execution_revision < 0
1605
+ ):
1606
+ _issue(
1607
+ issues,
1608
+ "execution.revision",
1609
+ "execution.execution_revision 必须是大于等于 0 的整数",
1610
+ "execution",
1611
+ )
1612
+ updated_at = execution.get("updated_at")
1613
+ if updated_at is not None and (
1614
+ not isinstance(updated_at, str)
1615
+ or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})", updated_at)
1616
+ ):
1617
+ _issue(issues, "execution.timestamp", "execution.updated_at 必须是 ISO-8601 时间或 null", "execution")
1618
+
1619
+ manifest_tasks = {
1620
+ str(task.get("task_id")): task
1621
+ for task in manifest.get("tasks", [])
1622
+ if isinstance(task, dict) and isinstance(task.get("task_id"), str)
1623
+ }
1624
+ manifest_steps = {
1625
+ str(step.get("step_id")): step
1626
+ for step in manifest.get("steps", [])
1627
+ if isinstance(step, dict) and isinstance(step.get("step_id"), str)
1628
+ }
1629
+ manifest_test_ids = {
1630
+ str(test.get("test_id"))
1631
+ for test in manifest.get("tests", [])
1632
+ if isinstance(test, dict) and isinstance(test.get("test_id"), str)
1633
+ }
1634
+
1635
+ events = execution.get("events")
1636
+ if not isinstance(events, list):
1637
+ _issue(issues, "execution.events", "execution.events 必须是数组", "execution")
1638
+ events = []
1639
+ event_ids: set[str] = set()
1640
+ event_by_id: dict[str, dict[str, Any]] = {}
1641
+ idempotency_keys: set[str] = set()
1642
+ for index, event in enumerate(events):
1643
+ label = f"execution.events[{index}]"
1644
+ if not isinstance(event, dict):
1645
+ _issue(issues, "execution.event", f"{label} 必须是 object", "execution")
1646
+ continue
1647
+ event_id = event.get("event_id")
1648
+ if not isinstance(event_id, str) or not re.fullmatch(r"EV-[0-9a-fA-F-]{36}", event_id):
1649
+ _issue(issues, "execution.event", f"{label}.event_id 非法", "execution")
1650
+ elif event_id in event_ids:
1651
+ _issue(issues, "execution.event", f"执行事件 ID 重复:{event_id}", "execution")
1652
+ else:
1653
+ event_ids.add(event_id)
1654
+ event_by_id[event_id] = event
1655
+ idempotency_key = event.get("idempotency_key")
1656
+ if idempotency_key is not None:
1657
+ if not isinstance(idempotency_key, str) or not idempotency_key.strip():
1658
+ _issue(issues, "execution.event", f"{label}.idempotency_key 非法", "execution")
1659
+ elif idempotency_key in idempotency_keys:
1660
+ _issue(
1661
+ issues,
1662
+ "execution.event",
1663
+ f"执行事件幂等键重复:{idempotency_key}",
1664
+ "execution",
1665
+ )
1666
+ else:
1667
+ idempotency_keys.add(idempotency_key)
1668
+ if event.get("type") not in VALID_EXECUTION_EVENT_TYPES:
1669
+ _issue(issues, "execution.event", f"{label}.type 非法", "execution")
1670
+ for field_name in ("app", "agent", "timestamp", "summary"):
1671
+ if not isinstance(event.get(field_name), str) or not event[field_name].strip():
1672
+ _issue(
1673
+ issues,
1674
+ "execution.event",
1675
+ f"{label}.{field_name} 必须是非空字符串",
1676
+ "execution",
1677
+ )
1678
+ event_timestamp = event.get("timestamp")
1679
+ if isinstance(event_timestamp, str):
1680
+ if not re.fullmatch(
1681
+ r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})",
1682
+ event_timestamp,
1683
+ ):
1684
+ _issue(
1685
+ issues,
1686
+ "execution.timestamp",
1687
+ f"{label}.timestamp 必须是 ISO-8601 时间",
1688
+ "execution",
1689
+ )
1690
+ event_design_revision = event.get("design_revision")
1691
+ manifest_revision = manifest.get("revision")
1692
+ if (
1693
+ not isinstance(event_design_revision, int)
1694
+ or isinstance(event_design_revision, bool)
1695
+ or not isinstance(manifest_revision, int)
1696
+ or event_design_revision < 1
1697
+ or event_design_revision > manifest_revision
1698
+ ):
1699
+ _issue(
1700
+ issues,
1701
+ "execution.event",
1702
+ f"{label}.design_revision 必须引用当前或历史静态 revision",
1703
+ "execution",
1704
+ )
1705
+ if event.get("type") == "spec_revised":
1706
+ task_ids = event.get("task_ids")
1707
+ if not isinstance(task_ids, list) or not task_ids or not all(
1708
+ isinstance(task_id, str) and task_id for task_id in task_ids
1709
+ ):
1710
+ _issue(
1711
+ issues,
1712
+ "execution.event",
1713
+ f"{label}.task_ids 必须列出受设计修订影响的任务",
1714
+ "execution",
1715
+ )
1716
+ elif len(task_ids) != len(set(task_ids)):
1717
+ _issue(
1718
+ issues,
1719
+ "execution.event",
1720
+ f"{label}.task_ids 不得重复",
1721
+ "execution",
1722
+ )
1723
+ elif event_design_revision == manifest.get("revision") and not set(task_ids).issubset(
1724
+ manifest_tasks
1725
+ ):
1726
+ _issue(
1727
+ issues,
1728
+ "execution.event",
1729
+ f"{label}.task_ids 含当前 manifest 不存在的任务",
1730
+ "execution",
1731
+ )
1732
+ requested_task_ids = event.get("requested_task_ids")
1733
+ if not isinstance(requested_task_ids, list) or not all(
1734
+ isinstance(task_id, str) and task_id for task_id in requested_task_ids
1735
+ ):
1736
+ _issue(
1737
+ issues,
1738
+ "execution.event",
1739
+ f"{label}.requested_task_ids 必须是字符串数组",
1740
+ "execution",
1741
+ )
1742
+ elif len(requested_task_ids) != len(set(requested_task_ids)):
1743
+ _issue(
1744
+ issues,
1745
+ "execution.event",
1746
+ f"{label}.requested_task_ids 不得重复",
1747
+ "execution",
1748
+ )
1749
+ from_revision = event.get("from_design_revision")
1750
+ to_revision = event.get("to_design_revision")
1751
+ if (
1752
+ not isinstance(from_revision, int)
1753
+ or not isinstance(to_revision, int)
1754
+ or to_revision != from_revision + 1
1755
+ or event_design_revision != to_revision
1756
+ ):
1757
+ _issue(
1758
+ issues,
1759
+ "execution.event",
1760
+ f"{label} 的设计 revision 迁移必须恰好递增 1",
1761
+ "execution",
1762
+ )
1763
+ elif not isinstance(event.get("task_id"), str) or not event["task_id"].strip():
1764
+ _issue(issues, "execution.event", f"{label}.task_id 必须是非空字符串", "execution")
1765
+ if event.get("type") == "task_status_changed":
1766
+ from_status = event.get("from_status")
1767
+ to_status = event.get("to_status")
1768
+ if (
1769
+ from_status not in VALID_EXECUTION_TASK_STATUSES
1770
+ or to_status not in VALID_EXECUTION_TASK_STATUSES
1771
+ or to_status not in VALID_EXECUTION_TASK_TRANSITIONS.get(str(from_status), set())
1772
+ ):
1773
+ _issue(issues, "execution.event", f"{label} 的任务状态迁移字段非法", "execution")
1774
+ if (
1775
+ event_design_revision == manifest.get("revision")
1776
+ and event.get("task_id") not in manifest_tasks
1777
+ ):
1778
+ _issue(
1779
+ issues,
1780
+ "execution.event",
1781
+ f"{label}.task_id 未引用当前 manifest task",
1782
+ "execution",
1783
+ )
1784
+ if event_design_revision == manifest.get("revision"):
1785
+ allowed_test_ids = set(
1786
+ _string_list(manifest_tasks.get(str(event.get("task_id")), {}), "test_ids")
1787
+ )
1788
+ event_test_ids = {
1789
+ str(value.get("test_id"))
1790
+ for value in event.get("evidence", [])
1791
+ if isinstance(value, dict) and value.get("kind") == "test"
1792
+ } if isinstance(event.get("evidence", []), list) else set()
1793
+ if not event_test_ids.issubset(allowed_test_ids):
1794
+ _issue(
1795
+ issues,
1796
+ "execution.event",
1797
+ f"{label} 引用了其他任务的 Test",
1798
+ "execution",
1799
+ )
1800
+ if event.get("type") == "step_status_changed":
1801
+ if event.get("step_status") not in {"completed", "failed"}:
1802
+ _issue(issues, "execution.event", f"{label}.step_status 非法", "execution")
1803
+ if event_design_revision == manifest.get("revision"):
1804
+ event_task_id = str(event.get("task_id"))
1805
+ event_step_id = str(event.get("step_id"))
1806
+ step = manifest_steps.get(event_step_id)
1807
+ if step is None or step.get("task_id") != event_task_id:
1808
+ _issue(
1809
+ issues,
1810
+ "execution.event",
1811
+ f"{label}.step_id 未引用当前 task 的 Step",
1812
+ "execution",
1813
+ )
1814
+ elif event.get("step_status") == "completed":
1815
+ event_evidence = (
1816
+ event.get("evidence", [])
1817
+ if isinstance(event.get("evidence", []), list)
1818
+ else []
1819
+ )
1820
+ passed_test_ids = {
1821
+ str(value.get("test_id"))
1822
+ for value in event_evidence
1823
+ if isinstance(value, dict)
1824
+ and value.get("kind") == "test"
1825
+ and value.get("status") == "passed"
1826
+ }
1827
+ missing_test_ids = set(_string_list(step, "test_ids")) - passed_test_ids
1828
+ if missing_test_ids:
1829
+ _issue(
1830
+ issues,
1831
+ "execution.event",
1832
+ f"{label} 缺少绑定 Test 的 passed 证据:{', '.join(sorted(missing_test_ids))}",
1833
+ "execution",
1834
+ )
1835
+ event_task_test_ids = set(
1836
+ _string_list(manifest_tasks.get(event_task_id, {}), "test_ids")
1837
+ )
1838
+ unknown_test_ids = passed_test_ids - event_task_test_ids
1839
+ if unknown_test_ids:
1840
+ _issue(
1841
+ issues,
1842
+ "execution.event",
1843
+ f"{label} 引用了其他任务或不存在的 Test:{', '.join(sorted(unknown_test_ids))}",
1844
+ "execution",
1845
+ )
1846
+ event_task_test_ids = set(
1847
+ _string_list(manifest_tasks.get(event_task_id, {}), "test_ids")
1848
+ )
1849
+ all_event_test_ids = {
1850
+ str(value.get("test_id"))
1851
+ for value in (
1852
+ event.get("evidence", [])
1853
+ if isinstance(event.get("evidence", []), list)
1854
+ else []
1855
+ )
1856
+ if isinstance(value, dict) and value.get("kind") == "test"
1857
+ }
1858
+ if not all_event_test_ids.issubset(event_task_test_ids):
1859
+ _issue(
1860
+ issues,
1861
+ "execution.event",
1862
+ f"{label} 引用了其他任务的 Test",
1863
+ "execution",
1864
+ )
1865
+ if event.get("type") == "dependency_status_changed" and (
1866
+ event.get("dependency_type") not in VALID_DEPENDENCY_TYPES
1867
+ or event.get("dependency_status")
1868
+ not in VALID_EXECUTION_DEPENDENCY_STATUSES
1869
+ ):
1870
+ _issue(issues, "execution.event", f"{label} 的依赖状态字段非法", "execution")
1871
+ if (
1872
+ event.get("type") == "dependency_status_changed"
1873
+ and event_design_revision == manifest.get("revision")
1874
+ ):
1875
+ source_task = manifest_tasks.get(str(event.get("task_id")))
1876
+ expected_edge = (
1877
+ source_task is not None
1878
+ and any(
1879
+ isinstance(dependency, dict)
1880
+ and dependency.get("task_id") == event.get("dependency_task_id")
1881
+ and dependency.get("type") == event.get("dependency_type")
1882
+ for dependency in source_task.get("depends_on", [])
1883
+ )
1884
+ )
1885
+ if not expected_edge:
1886
+ _issue(
1887
+ issues,
1888
+ "execution.event",
1889
+ f"{label} 未引用当前 manifest 的依赖边",
1890
+ "execution",
1891
+ )
1892
+ source_task_id = str(event.get("task_id"))
1893
+ dependency_task_id = str(event.get("dependency_task_id"))
1894
+ allowed_test_ids = set(
1895
+ _string_list(manifest_tasks.get(source_task_id, {}), "test_ids")
1896
+ ) | set(_string_list(manifest_tasks.get(dependency_task_id, {}), "test_ids"))
1897
+ event_test_ids = {
1898
+ str(value.get("test_id"))
1899
+ for value in (
1900
+ event.get("evidence", [])
1901
+ if isinstance(event.get("evidence", []), list)
1902
+ else []
1903
+ )
1904
+ if isinstance(value, dict) and value.get("kind") == "test"
1905
+ }
1906
+ if not event_test_ids.issubset(allowed_test_ids):
1907
+ _issue(
1908
+ issues,
1909
+ "execution.event",
1910
+ f"{label} 引用了依赖边之外的 Test",
1911
+ "execution",
1912
+ )
1913
+ _validate_execution_evidence(
1914
+ event.get("evidence", []),
1915
+ issues,
1916
+ label,
1917
+ valid_test_ids=(
1918
+ manifest_test_ids
1919
+ if event_design_revision == manifest.get("revision")
1920
+ else None
1921
+ ),
1922
+ )
1923
+
1924
+ active_design_revision: int | None = None
1925
+ for index, event in enumerate(events):
1926
+ if not isinstance(event, dict):
1927
+ continue
1928
+ event_revision = event.get("design_revision")
1929
+ if not isinstance(event_revision, int) or isinstance(event_revision, bool):
1930
+ continue
1931
+ if active_design_revision is None:
1932
+ active_design_revision = (
1933
+ event.get("from_design_revision")
1934
+ if event.get("type") == "spec_revised"
1935
+ and isinstance(event.get("from_design_revision"), int)
1936
+ else event_revision
1937
+ )
1938
+ if event.get("type") == "spec_revised":
1939
+ if event.get("from_design_revision") != active_design_revision:
1940
+ _issue(
1941
+ issues,
1942
+ "execution.revision_chain",
1943
+ f"execution.events[{index}] 未承接前一设计 revision",
1944
+ "execution",
1945
+ )
1946
+ if isinstance(event.get("to_design_revision"), int):
1947
+ active_design_revision = event["to_design_revision"]
1948
+ elif event_revision != active_design_revision:
1949
+ _issue(
1950
+ issues,
1951
+ "execution.revision_chain",
1952
+ f"execution.events[{index}].design_revision 与事件链当前 revision 不一致",
1953
+ "execution",
1954
+ )
1955
+ if events and active_design_revision != execution.get("design_revision"):
1956
+ _issue(
1957
+ issues,
1958
+ "execution.revision_chain",
1959
+ "执行事件的设计 revision 链未到达当前 execution.design_revision",
1960
+ "execution",
1961
+ )
1962
+ current_spec_revision_events = [
1963
+ event
1964
+ for event in events
1965
+ if isinstance(event, dict)
1966
+ and event.get("type") == "spec_revised"
1967
+ and event.get("design_revision") == manifest.get("revision")
1968
+ ]
1969
+ for event in current_spec_revision_events:
1970
+ requested = event.get("requested_task_ids", [])
1971
+ if not isinstance(requested, list):
1972
+ continue
1973
+ requested_set = set(requested) if requested else set(manifest_tasks)
1974
+ if not requested_set.issubset(manifest_tasks):
1975
+ continue
1976
+ reverse_dependencies = {task_id: set() for task_id in manifest_tasks}
1977
+ for dependent_task_id, task in manifest_tasks.items():
1978
+ for dependency in task.get("depends_on", []):
1979
+ if isinstance(dependency, dict) and dependency.get("task_id") in manifest_tasks:
1980
+ reverse_dependencies[str(dependency.get("task_id"))].add(
1981
+ dependent_task_id
1982
+ )
1983
+ expected_reset = set(requested_set)
1984
+ pending_reset = list(requested_set)
1985
+ while pending_reset:
1986
+ changed_task_id = pending_reset.pop()
1987
+ for dependent_task_id in reverse_dependencies.get(changed_task_id, set()):
1988
+ if dependent_task_id not in expected_reset:
1989
+ expected_reset.add(dependent_task_id)
1990
+ pending_reset.append(dependent_task_id)
1991
+ if set(event.get("task_ids", [])) != expected_reset:
1992
+ _issue(
1993
+ issues,
1994
+ "execution.revision_chain",
1995
+ "当前 spec_revised.task_ids 必须等于显式受影响任务及其全部后继闭包",
1996
+ "execution",
1997
+ )
1998
+
1999
+ if isinstance(execution_revision, int) and execution_revision != len(events):
2000
+ _issue(
2001
+ issues,
2002
+ "execution.revision",
2003
+ "execution.execution_revision 必须等于追加事件数量",
2004
+ "execution",
2005
+ )
2006
+ if events:
2007
+ last_timestamp = events[-1].get("timestamp") if isinstance(events[-1], dict) else None
2008
+ if updated_at != last_timestamp:
2009
+ _issue(
2010
+ issues,
2011
+ "execution.timestamp",
2012
+ "execution.updated_at 必须等于最后一条事件时间",
2013
+ "execution",
2014
+ )
2015
+ elif updated_at is not None:
2016
+ _issue(
2017
+ issues,
2018
+ "execution.timestamp",
2019
+ "没有执行事件时 execution.updated_at 必须是 null",
2020
+ "execution",
2021
+ )
2022
+
2023
+ snapshots = execution.get("tasks")
2024
+ if not isinstance(snapshots, list):
2025
+ _issue(issues, "execution.tasks", "execution.tasks 必须是数组", "execution")
2026
+ snapshots = []
2027
+ snapshot_by_id: dict[str, dict[str, Any]] = {}
2028
+ for index, snapshot in enumerate(snapshots):
2029
+ label = f"execution.tasks[{index}]"
2030
+ if not isinstance(snapshot, dict):
2031
+ _issue(issues, "execution.task", f"{label} 必须是 object", "execution")
2032
+ continue
2033
+ task_id = snapshot.get("task_id")
2034
+ if not isinstance(task_id, str) or task_id not in manifest_tasks:
2035
+ _issue(issues, "execution.task", f"{label}.task_id 未引用当前 manifest task", "execution")
2036
+ continue
2037
+ if task_id in snapshot_by_id:
2038
+ _issue(issues, "execution.task", f"执行任务快照重复:{task_id}", task_id)
2039
+ continue
2040
+ snapshot_by_id[task_id] = snapshot
2041
+ if snapshot.get("status") not in VALID_EXECUTION_TASK_STATUSES:
2042
+ _issue(issues, "execution.task", f"{task_id}.status 非法", task_id)
2043
+ expected_steps = set(_string_list(manifest_tasks[task_id], "step_ids"))
2044
+ completed_steps = snapshot.get("completed_step_ids")
2045
+ failed_steps = snapshot.get("failed_step_ids")
2046
+ for field_name, values in (
2047
+ ("completed_step_ids", completed_steps),
2048
+ ("failed_step_ids", failed_steps),
2049
+ ):
2050
+ if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
2051
+ _issue(issues, "execution.task", f"{task_id}.{field_name} 必须是字符串数组", task_id)
2052
+ elif len(values) != len(set(values)) or not set(values).issubset(expected_steps):
2053
+ _issue(
2054
+ issues,
2055
+ "execution.task",
2056
+ f"{task_id}.{field_name} 必须是本任务 Step 的无重复子集",
2057
+ task_id,
2058
+ )
2059
+ if isinstance(completed_steps, list) and isinstance(failed_steps, list) and set(
2060
+ completed_steps
2061
+ ).intersection(failed_steps):
2062
+ _issue(issues, "execution.task", f"{task_id} 的完成和失败 Step 不能重叠", task_id)
2063
+ blockers = snapshot.get("blockers")
2064
+ if not isinstance(blockers, list) or not all(
2065
+ isinstance(blocker, str) and blocker.strip() for blocker in blockers
2066
+ ):
2067
+ _issue(issues, "execution.task", f"{task_id}.blockers 必须是非空字符串数组", task_id)
2068
+ last_event_id = snapshot.get("last_event_id")
2069
+ if last_event_id is not None and last_event_id not in event_ids:
2070
+ _issue(issues, "execution.task", f"{task_id}.last_event_id 未引用存在的事件", task_id)
2071
+ snapshot_updated_at = snapshot.get("updated_at")
2072
+ if snapshot_updated_at is not None and (
2073
+ not isinstance(snapshot_updated_at, str)
2074
+ or not re.fullmatch(
2075
+ r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})",
2076
+ snapshot_updated_at,
2077
+ )
2078
+ ):
2079
+ _issue(
2080
+ issues,
2081
+ "execution.timestamp",
2082
+ f"{task_id}.updated_at 必须是 ISO-8601 时间或 null",
2083
+ task_id,
2084
+ )
2085
+ linked_last_event = event_by_id.get(str(last_event_id)) if last_event_id else None
2086
+ if linked_last_event is not None:
2087
+ owns_task = linked_last_event.get("task_id") == task_id or (
2088
+ linked_last_event.get("type") == "spec_revised"
2089
+ and task_id in linked_last_event.get("task_ids", [])
2090
+ )
2091
+ if not owns_task:
2092
+ _issue(
2093
+ issues,
2094
+ "execution.task",
2095
+ f"{task_id}.last_event_id 引用了其他任务的事件",
2096
+ task_id,
2097
+ )
2098
+ if snapshot_updated_at != linked_last_event.get("timestamp"):
2099
+ _issue(
2100
+ issues,
2101
+ "execution.timestamp",
2102
+ f"{task_id}.updated_at 必须等于 last_event_id 的事件时间",
2103
+ task_id,
2104
+ )
2105
+ elif last_event_id is None and snapshot_updated_at is not None:
2106
+ _issue(
2107
+ issues,
2108
+ "execution.timestamp",
2109
+ f"{task_id} 没有 last_event_id 时 updated_at 必须是 null",
2110
+ task_id,
2111
+ )
2112
+ snapshot_evidence = snapshot.get("evidence", [])
2113
+ _validate_execution_evidence(
2114
+ snapshot_evidence,
2115
+ issues,
2116
+ task_id,
2117
+ event_ids,
2118
+ manifest_test_ids,
2119
+ )
2120
+ if isinstance(snapshot_evidence, list):
2121
+ for evidence_index, evidence in enumerate(snapshot_evidence):
2122
+ if not isinstance(evidence, dict) or not isinstance(
2123
+ evidence.get("event_id"), str
2124
+ ):
2125
+ continue
2126
+ source_event = event_by_id.get(evidence["event_id"])
2127
+ if source_event is None:
2128
+ continue
2129
+ owns_task = source_event.get("task_id") == task_id
2130
+ event_evidence = source_event.get("evidence", [])
2131
+ projected = {
2132
+ key: value for key, value in evidence.items() if key != "event_id"
2133
+ }
2134
+ if not owns_task or projected not in event_evidence:
2135
+ _issue(
2136
+ issues,
2137
+ "execution.evidence",
2138
+ f"{task_id}.evidence[{evidence_index}] 与来源事件不一致",
2139
+ task_id,
2140
+ )
2141
+
2142
+ dependencies = snapshot.get("dependencies")
2143
+ if not isinstance(dependencies, list):
2144
+ _issue(issues, "execution.dependency", f"{task_id}.dependencies 必须是数组", task_id)
2145
+ dependencies = []
2146
+ actual_dependencies: dict[tuple[str, str], dict[str, Any]] = {}
2147
+ for dependency in dependencies:
2148
+ if not isinstance(dependency, dict):
2149
+ _issue(issues, "execution.dependency", f"{task_id} 的依赖快照必须是 object", task_id)
2150
+ continue
2151
+ key = (str(dependency.get("task_id")), str(dependency.get("type")))
2152
+ if key in actual_dependencies:
2153
+ _issue(issues, "execution.dependency", f"{task_id} 的依赖快照重复:{key[0]}", task_id)
2154
+ actual_dependencies[key] = dependency
2155
+ if dependency.get("status") not in VALID_EXECUTION_DEPENDENCY_STATUSES:
2156
+ _issue(issues, "execution.dependency", f"{task_id}->{key[0]} 的执行依赖状态非法", task_id)
2157
+ evidence_event_id = dependency.get("evidence_event_id")
2158
+ if evidence_event_id is not None and evidence_event_id not in event_ids:
2159
+ _issue(
2160
+ issues,
2161
+ "execution.dependency",
2162
+ f"{task_id}->{key[0]} 的 evidence_event_id 未引用存在的事件",
2163
+ task_id,
2164
+ )
2165
+ if dependency.get("status") == "pending" and evidence_event_id is not None:
2166
+ _issue(
2167
+ issues,
2168
+ "execution.dependency",
2169
+ f"{task_id}->{key[0]} 为 pending 时不得保留 evidence_event_id",
2170
+ task_id,
2171
+ )
2172
+ if evidence_event_id is not None:
2173
+ evidence_event = event_by_id.get(str(evidence_event_id))
2174
+ if evidence_event is not None and not (
2175
+ evidence_event.get("type") == "dependency_status_changed"
2176
+ and evidence_event.get("task_id") == task_id
2177
+ and evidence_event.get("dependency_task_id") == key[0]
2178
+ and evidence_event.get("dependency_type") == key[1]
2179
+ and evidence_event.get("dependency_status") == "satisfied"
2180
+ ):
2181
+ _issue(
2182
+ issues,
2183
+ "execution.dependency",
2184
+ f"{task_id}->{key[0]} 的 evidence_event_id 未引用本依赖的 satisfied 事件",
2185
+ task_id,
2186
+ )
2187
+ expected_dependencies = {
2188
+ (str(dependency.get("task_id")), str(dependency.get("type")))
2189
+ for dependency in manifest_tasks[task_id].get("depends_on", [])
2190
+ if isinstance(dependency, dict)
2191
+ }
2192
+ if set(actual_dependencies) != expected_dependencies:
2193
+ _issue(
2194
+ issues,
2195
+ "execution.dependency",
2196
+ f"{task_id}.dependencies 必须与 manifest.depends_on 完全一致",
2197
+ task_id,
2198
+ )
2199
+ if set(snapshot_by_id) != set(manifest_tasks):
2200
+ _issue(
2201
+ issues,
2202
+ "execution.tasks",
2203
+ "execution.tasks 必须为当前 manifest 中每个任务提供且只提供一个快照",
2204
+ "execution",
2205
+ )
2206
+
2207
+ replay_status = {task_id: "not_started" for task_id in manifest_tasks}
2208
+ replay_completed_steps = {task_id: set() for task_id in manifest_tasks}
2209
+ replay_failed_steps = {task_id: set() for task_id in manifest_tasks}
2210
+ replay_passed_test_ids = {task_id: set() for task_id in manifest_tasks}
2211
+ replay_dependencies: dict[str, dict[tuple[str, str], tuple[str, str | None]]] = {
2212
+ task_id: {
2213
+ (str(dependency.get("task_id")), str(dependency.get("type"))): (
2214
+ "satisfied"
2215
+ if dependency.get("type") == "contract" and manifest.get("status") == "READY"
2216
+ else "pending",
2217
+ None,
2218
+ )
2219
+ for dependency in task.get("depends_on", [])
2220
+ if isinstance(dependency, dict)
2221
+ }
2222
+ for task_id, task in manifest_tasks.items()
2223
+ }
2224
+ for event_index, event in enumerate(events):
2225
+ if not isinstance(event, dict):
2226
+ continue
2227
+ event_type = event.get("type")
2228
+ if event_type == "spec_revised":
2229
+ affected_task_ids = event.get("task_ids", [])
2230
+ if not isinstance(affected_task_ids, list):
2231
+ affected_task_ids = []
2232
+ for affected_task_id in affected_task_ids:
2233
+ if affected_task_id not in manifest_tasks:
2234
+ continue
2235
+ replay_status[affected_task_id] = "not_started"
2236
+ replay_completed_steps[affected_task_id] = set()
2237
+ replay_failed_steps[affected_task_id] = set()
2238
+ replay_passed_test_ids[affected_task_id] = set()
2239
+ replay_dependencies[affected_task_id] = {
2240
+ (str(dependency.get("task_id")), str(dependency.get("type"))): (
2241
+ "satisfied"
2242
+ if dependency.get("type") == "contract"
2243
+ and manifest.get("status") == "READY"
2244
+ else "pending",
2245
+ None,
2246
+ )
2247
+ for dependency in manifest_tasks[affected_task_id].get("depends_on", [])
2248
+ if isinstance(dependency, dict)
2249
+ }
2250
+ continue
2251
+ event_task_id = event.get("task_id")
2252
+ if event_task_id not in manifest_tasks:
2253
+ continue
2254
+ event_evidence = (
2255
+ event.get("evidence", [])
2256
+ if isinstance(event.get("evidence", []), list)
2257
+ else []
2258
+ )
2259
+ event_passed_test_ids = {
2260
+ str(value.get("test_id"))
2261
+ for value in event_evidence
2262
+ if isinstance(value, dict)
2263
+ and value.get("kind") == "test"
2264
+ and value.get("status") == "passed"
2265
+ and isinstance(value.get("test_id"), str)
2266
+ }
2267
+ if event_type == "task_status_changed":
2268
+ if event.get("from_status") != replay_status[event_task_id]:
2269
+ _issue(
2270
+ issues,
2271
+ "execution.event_chain",
2272
+ f"execution.events[{event_index}].from_status 与前一事件状态不一致",
2273
+ event_task_id,
2274
+ )
2275
+ from_status = replay_status[event_task_id]
2276
+ to_status = event.get("to_status")
2277
+ if to_status == "in_progress" and from_status in {"completed", "cancelled"}:
2278
+ replay_passed_test_ids[event_task_id] = set()
2279
+ replay_passed_test_ids[event_task_id].update(event_passed_test_ids)
2280
+ if event.get("design_revision") == manifest.get("revision") and to_status in {
2281
+ "in_progress",
2282
+ "implemented",
2283
+ "verified",
2284
+ "completed",
2285
+ }:
2286
+ unsatisfied_dependencies: list[str] = []
2287
+ for dependency in manifest_tasks[event_task_id].get("depends_on", []):
2288
+ if not isinstance(dependency, dict) or dependency.get("type") not in {
2289
+ "hard",
2290
+ "contract",
2291
+ }:
2292
+ continue
2293
+ dependency_key = (
2294
+ str(dependency.get("task_id")),
2295
+ str(dependency.get("type")),
2296
+ )
2297
+ satisfied = replay_dependencies[event_task_id].get(
2298
+ dependency_key, ("pending", None)
2299
+ )[0] == "satisfied"
2300
+ if dependency.get("type") == "contract":
2301
+ satisfied = manifest.get("status") == "READY"
2302
+ elif dependency.get("type") == "hard" and not satisfied:
2303
+ satisfied = (
2304
+ replay_status.get(str(dependency.get("task_id"))) == "completed"
2305
+ )
2306
+ if not satisfied:
2307
+ unsatisfied_dependencies.append(
2308
+ f"{dependency.get('type')}:{dependency.get('task_id')}"
2309
+ )
2310
+ if unsatisfied_dependencies:
2311
+ _issue(
2312
+ issues,
2313
+ "execution.event_chain",
2314
+ f"execution.events[{event_index}] 执行时前置依赖未满足:{', '.join(unsatisfied_dependencies)}",
2315
+ event_task_id,
2316
+ )
2317
+ if event.get("design_revision") == manifest.get("revision") and to_status in {
2318
+ "implemented",
2319
+ "verified",
2320
+ "completed",
2321
+ }:
2322
+ expected_steps = set(_string_list(manifest_tasks[event_task_id], "step_ids"))
2323
+ if (
2324
+ replay_completed_steps[event_task_id] != expected_steps
2325
+ or replay_failed_steps[event_task_id]
2326
+ ):
2327
+ _issue(
2328
+ issues,
2329
+ "execution.event_chain",
2330
+ f"execution.events[{event_index}] 执行时尚未完成全部 Step",
2331
+ event_task_id,
2332
+ )
2333
+ if event.get("design_revision") == manifest.get("revision") and to_status in {
2334
+ "verified",
2335
+ "completed",
2336
+ }:
2337
+ expected_tests = set(_string_list(manifest_tasks[event_task_id], "test_ids"))
2338
+ missing_tests = expected_tests - replay_passed_test_ids[event_task_id]
2339
+ if missing_tests:
2340
+ _issue(
2341
+ issues,
2342
+ "execution.event_chain",
2343
+ f"execution.events[{event_index}] 执行时缺少 Test 证据:{', '.join(sorted(missing_tests))}",
2344
+ event_task_id,
2345
+ )
2346
+ if event.get("design_revision") == manifest.get("revision") and to_status == "completed":
2347
+ pending_integration = [
2348
+ str(dependency.get("task_id"))
2349
+ for dependency in manifest_tasks[event_task_id].get("depends_on", [])
2350
+ if isinstance(dependency, dict)
2351
+ and dependency.get("type") == "integration"
2352
+ and replay_dependencies[event_task_id].get(
2353
+ (str(dependency.get("task_id")), "integration"),
2354
+ ("pending", None),
2355
+ )[0]
2356
+ != "satisfied"
2357
+ ]
2358
+ if pending_integration:
2359
+ _issue(
2360
+ issues,
2361
+ "execution.event_chain",
2362
+ f"execution.events[{event_index}] 执行时 integration 依赖未满足:{', '.join(pending_integration)}",
2363
+ event_task_id,
2364
+ )
2365
+ if to_status in VALID_EXECUTION_TASK_STATUSES:
2366
+ replay_status[event_task_id] = str(to_status)
2367
+ continue
2368
+ if event_type == "step_status_changed":
2369
+ event_step_id = str(event.get("step_id"))
2370
+ step = manifest_steps.get(event_step_id)
2371
+ if step is None or step.get("task_id") != event_task_id:
2372
+ continue
2373
+ if event.get("design_revision") == manifest.get("revision"):
2374
+ if replay_status[event_task_id] != "in_progress":
2375
+ _issue(
2376
+ issues,
2377
+ "execution.event_chain",
2378
+ f"execution.events[{event_index}] 写入 Step 时任务不是 in_progress",
2379
+ event_task_id,
2380
+ )
2381
+ missing_predecessors = set(
2382
+ _string_list(step, "depends_on_step_ids")
2383
+ ) - replay_completed_steps[event_task_id]
2384
+ if missing_predecessors:
2385
+ _issue(
2386
+ issues,
2387
+ "execution.event_chain",
2388
+ f"execution.events[{event_index}] 越过前置 Step:{', '.join(sorted(missing_predecessors))}",
2389
+ event_task_id,
2390
+ )
2391
+ replay_passed_test_ids[event_task_id].update(event_passed_test_ids)
2392
+ if event.get("step_status") == "completed":
2393
+ replay_completed_steps[event_task_id].add(event_step_id)
2394
+ replay_failed_steps[event_task_id].discard(event_step_id)
2395
+ elif event.get("step_status") == "failed":
2396
+ replay_failed_steps[event_task_id].add(event_step_id)
2397
+ replay_completed_steps[event_task_id].discard(event_step_id)
2398
+ replay_status[event_task_id] = "blocked"
2399
+ continue
2400
+ if event_type == "dependency_status_changed":
2401
+ dependency_key = (
2402
+ str(event.get("dependency_task_id")),
2403
+ str(event.get("dependency_type")),
2404
+ )
2405
+ if dependency_key not in replay_dependencies[event_task_id]:
2406
+ continue
2407
+ dependency_status = event.get("dependency_status")
2408
+ if (
2409
+ event.get("design_revision") == manifest.get("revision")
2410
+ and dependency_status == "pending"
2411
+ and replay_status[event_task_id] == "completed"
2412
+ ):
2413
+ _issue(
2414
+ issues,
2415
+ "execution.event_chain",
2416
+ f"execution.events[{event_index}] 不能直接重开 completed 任务的依赖",
2417
+ event_task_id,
2418
+ )
2419
+ replay_passed_test_ids[event_task_id].update(event_passed_test_ids)
2420
+ if dependency_status in VALID_EXECUTION_DEPENDENCY_STATUSES:
2421
+ replay_dependencies[event_task_id][dependency_key] = (
2422
+ str(dependency_status),
2423
+ str(event.get("event_id")) if dependency_status == "satisfied" else None,
2424
+ )
2425
+ if dependency_status == "pending" and replay_status[event_task_id] in {
2426
+ "in_progress",
2427
+ "implemented",
2428
+ "verified",
2429
+ }:
2430
+ replay_status[event_task_id] = "blocked"
2431
+
2432
+ for task_id, snapshot in snapshot_by_id.items():
2433
+ if snapshot.get("status") != replay_status[task_id]:
2434
+ _issue(
2435
+ issues,
2436
+ "execution.event_chain",
2437
+ f"{task_id}.status 与事件链重放结果 {replay_status[task_id]} 不一致",
2438
+ task_id,
2439
+ )
2440
+ if set(_string_list(snapshot, "completed_step_ids")) != replay_completed_steps[task_id]:
2441
+ _issue(
2442
+ issues,
2443
+ "execution.event_chain",
2444
+ f"{task_id}.completed_step_ids 与事件链不一致",
2445
+ task_id,
2446
+ )
2447
+ if set(_string_list(snapshot, "failed_step_ids")) != replay_failed_steps[task_id]:
2448
+ _issue(
2449
+ issues,
2450
+ "execution.event_chain",
2451
+ f"{task_id}.failed_step_ids 与事件链不一致",
2452
+ task_id,
2453
+ )
2454
+ actual_dependency_state = {
2455
+ (str(dependency.get("task_id")), str(dependency.get("type"))): (
2456
+ str(dependency.get("status")),
2457
+ dependency.get("evidence_event_id"),
2458
+ )
2459
+ for dependency in (
2460
+ snapshot.get("dependencies", [])
2461
+ if isinstance(snapshot.get("dependencies", []), list)
2462
+ else []
2463
+ )
2464
+ if isinstance(dependency, dict)
2465
+ }
2466
+ if actual_dependency_state != replay_dependencies[task_id]:
2467
+ _issue(
2468
+ issues,
2469
+ "execution.event_chain",
2470
+ f"{task_id}.dependencies 与事件链重放结果不一致",
2471
+ task_id,
2472
+ )
2473
+ for task_id, snapshot in snapshot_by_id.items():
2474
+ manifest_task = manifest_tasks[task_id]
2475
+ task_status = snapshot.get("status")
2476
+ completed_steps = set(_string_list(snapshot, "completed_step_ids"))
2477
+ failed_steps = set(_string_list(snapshot, "failed_step_ids"))
2478
+ expected_steps = set(_string_list(manifest_task, "step_ids"))
2479
+ if (
2480
+ manifest.get("status") != "READY" or manifest_task.get("status") != "READY"
2481
+ ) and task_status not in {"not_started", "cancelled"}:
2482
+ _issue(
2483
+ issues,
2484
+ "execution.task_state",
2485
+ f"非 READY 设计中的任务 {task_id} 不能保存开发执行状态 {task_status}",
2486
+ task_id,
2487
+ )
2488
+ if task_status == "not_started" and (completed_steps or failed_steps):
2489
+ _issue(
2490
+ issues,
2491
+ "execution.task_state",
2492
+ f"not_started 任务 {task_id} 不能包含 Step 结果",
2493
+ task_id,
2494
+ )
2495
+ blockers = snapshot.get("blockers", [])
2496
+ if task_status == "blocked" and not blockers:
2497
+ _issue(
2498
+ issues,
2499
+ "execution.task_state",
2500
+ f"blocked 任务 {task_id} 必须记录 blocker",
2501
+ task_id,
2502
+ )
2503
+ if task_status != "blocked" and blockers:
2504
+ _issue(
2505
+ issues,
2506
+ "execution.task_state",
2507
+ f"非 blocked 任务 {task_id} 不得保留 blocker",
2508
+ task_id,
2509
+ )
2510
+ if task_status in {"implemented", "verified", "completed"} and (
2511
+ completed_steps != expected_steps or failed_steps
2512
+ ):
2513
+ _issue(
2514
+ issues,
2515
+ "execution.task_state",
2516
+ f"任务 {task_id} 进入 {task_status} 前必须完成全部 Step 且没有失败 Step",
2517
+ task_id,
2518
+ )
2519
+ if task_status in {"implemented", "verified", "completed"}:
2520
+ passed_test_ids = {
2521
+ str(value.get("test_id"))
2522
+ for value in (
2523
+ snapshot.get("evidence", [])
2524
+ if isinstance(snapshot.get("evidence", []), list)
2525
+ else []
2526
+ )
2527
+ if isinstance(value, dict)
2528
+ and value.get("kind") == "test"
2529
+ and value.get("status") == "passed"
2530
+ }
2531
+ missing_test_ids = set(_string_list(manifest_task, "test_ids")) - passed_test_ids
2532
+ if missing_test_ids:
2533
+ _issue(
2534
+ issues,
2535
+ "execution.task_state",
2536
+ f"任务 {task_id} 进入 {task_status} 前缺少 Test 证据:{', '.join(sorted(missing_test_ids))}",
2537
+ task_id,
2538
+ )
2539
+ dependencies = [
2540
+ dependency
2541
+ for dependency in (
2542
+ snapshot.get("dependencies", [])
2543
+ if isinstance(snapshot.get("dependencies", []), list)
2544
+ else []
2545
+ )
2546
+ if isinstance(dependency, dict)
2547
+ ]
2548
+ if manifest.get("status") == "READY" and any(
2549
+ dependency.get("type") == "contract"
2550
+ and dependency.get("status") != "satisfied"
2551
+ for dependency in dependencies
2552
+ ):
2553
+ _issue(
2554
+ issues,
2555
+ "execution.dependency",
2556
+ f"READY 设计中的 {task_id} contract 依赖必须是 satisfied",
2557
+ task_id,
2558
+ )
2559
+ if task_status == "completed":
2560
+ unsatisfied: list[str] = []
2561
+ for dependency in dependencies:
2562
+ dependency_type = dependency.get("type")
2563
+ dependency_task_id = str(dependency.get("task_id"))
2564
+ satisfied = dependency.get("status") == "satisfied"
2565
+ if dependency_type == "contract":
2566
+ satisfied = manifest.get("status") == "READY"
2567
+ elif dependency_type == "hard" and not satisfied:
2568
+ satisfied = (
2569
+ snapshot_by_id.get(dependency_task_id, {}).get("status")
2570
+ == "completed"
2571
+ )
2572
+ if not satisfied:
2573
+ unsatisfied.append(f"{dependency_type}:{dependency_task_id}")
2574
+ if unsatisfied:
2575
+ _issue(
2576
+ issues,
2577
+ "execution.task_state",
2578
+ f"completed 任务 {task_id} 仍有未满足依赖:{', '.join(unsatisfied)}",
2579
+ task_id,
2580
+ )
2581
+
2582
+
2583
+ def _execution_projection(
2584
+ manifest: dict[str, Any],
2585
+ execution: dict[str, Any] | None,
2586
+ selected_task_ids: set[str],
2587
+ direct_dependency_ids: set[str],
2588
+ ) -> dict[str, Any]:
2589
+ relevant_task_ids = selected_task_ids | direct_dependency_ids
2590
+ if execution is None:
2591
+ return {
2592
+ "schema": EXECUTION_SCHEMA,
2593
+ "available": False,
2594
+ "execution_revision": None,
2595
+ "updated_at": None,
2596
+ "tasks": [],
2597
+ "dependency_status": [],
2598
+ }
2599
+ snapshot_by_id = {
2600
+ str(snapshot["task_id"]): snapshot
2601
+ for snapshot in execution.get("tasks", [])
2602
+ if isinstance(snapshot, dict) and isinstance(snapshot.get("task_id"), str)
2603
+ }
2604
+ task_by_id = {
2605
+ str(task["task_id"]): task
2606
+ for task in manifest.get("tasks", [])
2607
+ if isinstance(task, dict) and isinstance(task.get("task_id"), str)
2608
+ }
2609
+ dependency_status: list[dict[str, Any]] = []
2610
+ for source_task_id in sorted(selected_task_ids):
2611
+ snapshot = snapshot_by_id.get(source_task_id, {})
2612
+ dependency_snapshot_by_key = {
2613
+ (str(value.get("task_id")), str(value.get("type"))): value
2614
+ for value in snapshot.get("dependencies", [])
2615
+ if isinstance(value, dict)
2616
+ }
2617
+ for dependency in task_by_id.get(source_task_id, {}).get("depends_on", []):
2618
+ dependency_task_id = str(dependency.get("task_id"))
2619
+ dependency_type = str(dependency.get("type"))
2620
+ recorded = dependency_snapshot_by_key.get((dependency_task_id, dependency_type), {})
2621
+ dependency_task_status = snapshot_by_id.get(dependency_task_id, {}).get("status")
2622
+ if dependency_type == "contract":
2623
+ satisfied = manifest.get("status") == "READY"
2624
+ basis = "design-ready" if satisfied else "design-not-ready"
2625
+ elif dependency_type == "hard":
2626
+ satisfied = (
2627
+ recorded.get("status") == "satisfied"
2628
+ or dependency_task_status == "completed"
2629
+ )
2630
+ basis = (
2631
+ "recorded-evidence"
2632
+ if recorded.get("status") == "satisfied"
2633
+ else "dependency-task-completed"
2634
+ if dependency_task_status == "completed"
2635
+ else "pending"
2636
+ )
2637
+ else:
2638
+ satisfied = recorded.get("status") == "satisfied"
2639
+ basis = "recorded-evidence" if satisfied else "pending-integration"
2640
+ dependency_status.append(
2641
+ {
2642
+ "source_task_id": source_task_id,
2643
+ "task_id": dependency_task_id,
2644
+ "type": dependency_type,
2645
+ "status": "satisfied" if satisfied else "pending",
2646
+ "basis": basis,
2647
+ "required_evidence": dependency.get("required_evidence"),
2648
+ "evidence_event_id": recorded.get("evidence_event_id"),
2649
+ }
2650
+ )
2651
+ return {
2652
+ "schema": EXECUTION_SCHEMA,
2653
+ "available": True,
2654
+ "execution_revision": execution.get("execution_revision"),
2655
+ "updated_at": execution.get("updated_at"),
2656
+ "tasks": [
2657
+ snapshot_by_id[task_id]
2658
+ for task_id in sorted(relevant_task_ids)
2659
+ if task_id in snapshot_by_id
2660
+ ],
2661
+ "dependency_status": dependency_status,
2662
+ }
2663
+
2664
+
1378
2665
  def validate_model(
1379
2666
  manifest: dict[str, Any],
1380
2667
  sections: dict[str, Section] | dict[str, str],
@@ -1666,16 +2953,32 @@ def validate_model(
1666
2953
  if not _has_labeled_value(contract_body, marker):
1667
2954
  _issue(issues, "java.contract", f"{task_id} 的 Java 契约缺少带实际值的字段:{marker}", task_id)
1668
2955
 
2956
+ deliverable_text = "\n".join(
2957
+ (
2958
+ text,
2959
+ json.dumps(manifest, ensure_ascii=False, sort_keys=True),
2960
+ *(section.content for section in section_objects.values()),
2961
+ )
2962
+ )
2963
+ for code, pattern in FORBIDDEN_READY_PATTERNS.items():
2964
+ match = pattern.search(deliverable_text)
2965
+ if match and status in {"DRAFT", "BLOCKED"}:
2966
+ _issue(
2967
+ issues,
2968
+ f"deliverable.{code}",
2969
+ f"{status} 文档也不得包含留白或未展开表达:{match.group(0)}",
2970
+ )
2971
+
2972
+ blocked_requested = status == "BLOCKED" or any(
2973
+ task.get("status") == "BLOCKED" for task in task_by_id.values()
2974
+ )
2975
+ if blocked_requested:
2976
+ _validate_blocked_evidence(section_objects, issues)
2977
+
1669
2978
  if require_ready and status != "READY":
1670
2979
  _issue(issues, "ready.required", f"要求 READY,但文档状态是 {status!r}", "status")
1671
2980
  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
- )
2981
+ ready_text = deliverable_text
1679
2982
  _validate_ready_manifest_values(
1680
2983
  manifest,
1681
2984
  repositories,
@@ -1742,16 +3045,33 @@ def validate_model(
1742
3045
  )
1743
3046
 
1744
3047
 
1745
- def validate_spec(source: str | Path, require_ready: bool = False) -> ValidationReport:
3048
+ def validate_spec(
3049
+ source: str | Path,
3050
+ require_ready: bool = False,
3051
+ require_execution: bool = False,
3052
+ ) -> ValidationReport:
1746
3053
  """Validate a path or document string and return a structured report."""
1747
3054
 
1748
3055
  text, _ = _read_source(source)
3056
+ document_digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
3057
+ try:
3058
+ design_text, execution = split_execution_region(text)
3059
+ except CanonicalSpecError as exc:
3060
+ return ValidationReport(
3061
+ protocol="canonical-invalid",
3062
+ status=None,
3063
+ document_sha256=document_digest,
3064
+ issues=[ValidationIssue("execution.parse", str(exc))],
3065
+ )
3066
+ design_digest = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
1749
3067
  try:
1750
- manifest = parse_manifest(text)
3068
+ manifest = parse_manifest(design_text)
1751
3069
  except CanonicalSpecError as exc:
1752
3070
  return ValidationReport(
1753
3071
  protocol="canonical-invalid",
1754
3072
  status=None,
3073
+ design_sha256=design_digest,
3074
+ document_sha256=document_digest,
1755
3075
  issues=[ValidationIssue("manifest.parse", str(exc))],
1756
3076
  )
1757
3077
  if manifest is None:
@@ -1760,29 +3080,68 @@ def validate_spec(source: str | Path, require_ready: bool = False) -> Validation
1760
3080
  issues.append(
1761
3081
  ValidationIssue("ready.legacy", "legacy Dev Spec 不具备 Canonical v1 READY 证明")
1762
3082
  )
1763
- return ValidationReport(protocol="legacy", status=None, issues=issues)
3083
+ if require_execution:
3084
+ issues.append(
3085
+ ValidationIssue("execution.legacy", "legacy Dev Spec 不支持共享执行状态")
3086
+ )
3087
+ return ValidationReport(
3088
+ protocol="legacy",
3089
+ status=None,
3090
+ design_sha256=design_digest,
3091
+ document_sha256=document_digest,
3092
+ issues=issues,
3093
+ )
1764
3094
  try:
1765
- sections = _parse_section_objects(text)
3095
+ sections = _parse_section_objects(design_text)
1766
3096
  except CanonicalSpecError as exc:
1767
3097
  return ValidationReport(
1768
3098
  protocol="canonical-v1",
1769
3099
  status=manifest.get("status") if isinstance(manifest.get("status"), str) else None,
1770
3100
  manifest=manifest,
3101
+ execution=execution,
3102
+ design_sha256=design_digest,
3103
+ document_sha256=document_digest,
1771
3104
  issues=[ValidationIssue("section.parse", str(exc))],
1772
3105
  )
1773
- model_report = validate_model(manifest, sections, text=text, require_ready=require_ready)
3106
+ model_report = validate_model(
3107
+ manifest,
3108
+ sections,
3109
+ text=design_text,
3110
+ require_ready=require_ready,
3111
+ )
1774
3112
  issues = model_report.issues
1775
- first_section = text.find("<!-- EDS:SECTION:BEGIN")
1776
- if first_section != -1 and text.find(MANIFEST_BEGIN) > first_section:
3113
+ first_section = design_text.find("<!-- EDS:SECTION:BEGIN")
3114
+ if first_section != -1 and design_text.find(MANIFEST_BEGIN) > first_section:
1777
3115
  issues.append(
1778
3116
  ValidationIssue("manifest.position", "manifest 必须位于所有 EDS 正文区域之前")
1779
3117
  )
3118
+ warnings: list[ValidationIssue] = []
3119
+ if execution is None:
3120
+ warnings.append(
3121
+ ValidationIssue(
3122
+ "execution.missing",
3123
+ "文档未初始化共享执行状态;旧版 Canonical v1 仍可只读消费",
3124
+ )
3125
+ )
3126
+ if require_execution:
3127
+ issues.append(
3128
+ ValidationIssue(
3129
+ "execution.required",
3130
+ "要求共享执行状态,但文档缺少 EDS:EXECUTION 区域",
3131
+ )
3132
+ )
3133
+ else:
3134
+ _validate_execution_state(manifest, execution, design_digest, issues)
1780
3135
  return ValidationReport(
1781
3136
  protocol="canonical-v1",
1782
3137
  status=manifest.get("status") if isinstance(manifest.get("status"), str) else None,
1783
3138
  manifest=manifest,
1784
3139
  sections=sections,
3140
+ execution=execution,
3141
+ design_sha256=design_digest,
3142
+ document_sha256=document_digest,
1785
3143
  issues=issues,
3144
+ warnings=warnings,
1786
3145
  )
1787
3146
 
1788
3147
 
@@ -1884,13 +3243,17 @@ def select_scope(
1884
3243
  selected_steps = [item for item in manifest["steps"] if item.get("task_id") in selected_set]
1885
3244
  selected_tests = [item for item in manifest["tests"] if item.get("task_id") in selected_set]
1886
3245
  related_contracts = [contract_by_id[contract_id] for contract_id in related_contract_ids]
1887
- payload: dict[str, Any] = {
3246
+ execution = _execution_projection(
3247
+ manifest,
3248
+ report.execution,
3249
+ selected_set,
3250
+ direct_dependency_ids,
3251
+ )
3252
+ static_scope: dict[str, Any] = {
1888
3253
  "schema": SCHEMA,
1889
3254
  "spec_id": manifest["spec_id"],
1890
3255
  "revision": manifest["revision"],
1891
3256
  "status": manifest["status"],
1892
- "source_path": source_path,
1893
- "source_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
1894
3257
  "repo": repo_by_id[repo_id],
1895
3258
  "selected_task_ids": selected_ids,
1896
3259
  "selected_tasks": selected_tasks,
@@ -1908,6 +3271,32 @@ def select_scope(
1908
3271
  sections["integration-plan"].content, relevant_task_ids
1909
3272
  ),
1910
3273
  }
3274
+ design_scope_digest = hashlib.sha256(
3275
+ json.dumps(
3276
+ static_scope,
3277
+ ensure_ascii=False,
3278
+ sort_keys=True,
3279
+ separators=(",", ":"),
3280
+ ).encode("utf-8")
3281
+ ).hexdigest()
3282
+ execution_scope_digest = hashlib.sha256(
3283
+ json.dumps(
3284
+ execution,
3285
+ ensure_ascii=False,
3286
+ sort_keys=True,
3287
+ separators=(",", ":"),
3288
+ ).encode("utf-8")
3289
+ ).hexdigest()
3290
+ payload: dict[str, Any] = {
3291
+ **static_scope,
3292
+ "source_path": source_path,
3293
+ "source_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
3294
+ "document_sha256": report.document_sha256,
3295
+ "design_sha256": report.design_sha256,
3296
+ "design_scope_sha256": design_scope_digest,
3297
+ "execution_scope_sha256": execution_scope_digest,
3298
+ "execution": execution,
3299
+ }
1911
3300
  if output_format == "json":
1912
3301
  return payload
1913
3302
  if output_format != "markdown":
@@ -1922,6 +3311,10 @@ def select_scope(
1922
3311
  "status",
1923
3312
  "source_path",
1924
3313
  "source_sha256",
3314
+ "document_sha256",
3315
+ "design_sha256",
3316
+ "design_scope_sha256",
3317
+ "execution_scope_sha256",
1925
3318
  "repo",
1926
3319
  "selected_task_ids",
1927
3320
  "selected_tasks",
@@ -1931,6 +3324,7 @@ def select_scope(
1931
3324
  "direct_dependency_summaries",
1932
3325
  "related_contract_ids",
1933
3326
  "related_contracts",
3327
+ "execution",
1934
3328
  )
1935
3329
  }
1936
3330
  manifest_json = json.dumps(routing_manifest, ensure_ascii=False, indent=2)
@@ -1943,7 +3337,10 @@ def select_scope(
1943
3337
  "```",
1944
3338
  "<!-- EDS:CONSUMPTION-SCOPE:END -->",
1945
3339
  "",
1946
- f"> 来源 SHA-256:`{payload['source_sha256']}`",
3340
+ f"> 文档 SHA-256:`{payload['document_sha256']}`",
3341
+ f"> 设计 SHA-256:`{payload['design_sha256']}`",
3342
+ f"> 设计范围 SHA-256:`{payload['design_scope_sha256']}`",
3343
+ f"> 执行修订:`{payload['execution']['execution_revision']}`",
1947
3344
  f"> 选择任务:{', '.join(selected_ids)}",
1948
3345
  "",
1949
3346
  ]
@@ -1961,6 +3358,17 @@ def select_scope(
1961
3358
  )
1962
3359
  else:
1963
3360
  output.append("- 无直接依赖任务。")
3361
+ execution_json = json.dumps(execution, ensure_ascii=False, indent=2)
3362
+ output.extend(
3363
+ [
3364
+ "",
3365
+ "## 共享执行状态",
3366
+ "",
3367
+ "```json",
3368
+ execution_json,
3369
+ "```",
3370
+ ]
3371
+ )
1964
3372
  integration = Section(
1965
3373
  section_id="integration-plan",
1966
3374
  content=payload["integration_plan"],