- 新增 ChangeClassifier(爆炸半径分类:IMPLEMENTATION/INTERFACE/GLOBAL_CONSTRAINT) - 新增 ImpactPropagator(BFS影响传播:IMPACTED/BOUNDARY/SAFE差异化标记) - task_graph.py:invalidate_by_adr()差异化失效 + CascadeReport扩展字段(向后兼容) - eng_mode.py:三阶段差异化流程(分类→传播→失效→git操作→验证任务→重规划) - eng_mode.py:_git_squash_merge_and_tag() + Phase 7 集成 - do_mode.py:_ensure_all_committed() Worker git操作强制 - adr_watcher.py:内容快照 + get_content_for_classification() - events.py:ADR_CLASSIFIED/IMPACT_PROPAGATED/BOUNDARY_VERIFICATION_GENERATED - partial_replanner.py:replan_with_constraints() + generate_verification_tasks() - project_bootstrap.py:ensure_git_initialized() Arc规划前检测 - test_p1_21_phase2.py:21个新测试,95个全量测试0失败 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
133 lines
5.3 KiB
Python
133 lines
5.3 KiB
Python
"""
|
||
ADR 文件变更监控 — P1-21 ADR 变更自动检测。
|
||
基于 SHA256 hash 对比检测 ADR 文件变更,自动触发级联失效。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
|
||
@dataclass
|
||
class ADRChange:
|
||
"""ADR 文件变更记录。"""
|
||
adr_id: str
|
||
kind: str # "new" | "superseded" | "modified" | "deleted"
|
||
path: str = ""
|
||
old_hash: str = ""
|
||
new_hash: str = ""
|
||
old_content: str | None = None # 3.2.17: 变更前内容,供 ChangeClassifier 分类
|
||
|
||
|
||
class ADRWatcher:
|
||
"""监控 ADR 文件变更,自动触发级联失效。
|
||
|
||
AirEng 在每轮轮询时调用 detect_changes(),
|
||
发现 superseded 或 modified 变更时自动触发 invalidate_by_adr()。
|
||
"""
|
||
|
||
def __init__(self, adr_dir: Path):
|
||
self._adr_dir = adr_dir
|
||
self._known_hashes: dict[str, str] = {}
|
||
self._content_snapshots: dict[str, str] = {} # 3.2.17: 内容快照供 ChangeClassifier 使用
|
||
|
||
def snapshot(self) -> None:
|
||
"""启动时记录所有 ADR 的内容 hash 和内容快照。"""
|
||
if not self._adr_dir.exists():
|
||
return
|
||
for adr_file in sorted(self._adr_dir.glob("ADR-*.md")):
|
||
adr_id = self._extract_adr_id(adr_file)
|
||
raw = adr_file.read_bytes()
|
||
self._known_hashes[adr_id] = hashlib.sha256(raw).hexdigest()
|
||
self._content_snapshots[adr_id] = adr_file.read_text(encoding="utf-8")
|
||
|
||
def detect_changes(self) -> list[ADRChange]:
|
||
"""对比当前 ADR hash 与已知 hash,返回变更列表。"""
|
||
if not self._adr_dir.exists():
|
||
return []
|
||
|
||
changes: list[ADRChange] = []
|
||
seen_ids: set[str] = set()
|
||
|
||
for adr_file in sorted(self._adr_dir.glob("ADR-*.md")):
|
||
adr_id = self._extract_adr_id(adr_file)
|
||
seen_ids.add(adr_id)
|
||
current_hash = hashlib.sha256(adr_file.read_bytes()).hexdigest()
|
||
old_hash = self._known_hashes.get(adr_id)
|
||
|
||
if old_hash is None:
|
||
content = adr_file.read_text(encoding="utf-8")
|
||
changes.append(ADRChange(
|
||
adr_id=adr_id, kind="new",
|
||
path=str(adr_file), old_hash="", new_hash=current_hash,
|
||
))
|
||
self._content_snapshots[adr_id] = content
|
||
elif current_hash != old_hash:
|
||
old_content = self._content_snapshots.get(adr_id)
|
||
new_content = adr_file.read_text(encoding="utf-8")
|
||
status = self._parse_status(adr_file)
|
||
kind = "superseded" if status == "superseded" else "modified"
|
||
changes.append(ADRChange(
|
||
adr_id=adr_id, kind=kind,
|
||
path=str(adr_file), old_hash=old_hash, new_hash=current_hash,
|
||
old_content=old_content,
|
||
))
|
||
self._content_snapshots[adr_id] = new_content
|
||
self._known_hashes[adr_id] = current_hash
|
||
|
||
# 检查被删除的 ADR
|
||
for adr_id in list(self._known_hashes.keys()):
|
||
if adr_id not in seen_ids:
|
||
old_content = self._content_snapshots.get(adr_id)
|
||
changes.append(ADRChange(
|
||
adr_id=adr_id, kind="deleted",
|
||
path="", old_hash=self._known_hashes[adr_id], new_hash="",
|
||
old_content=old_content,
|
||
))
|
||
del self._known_hashes[adr_id]
|
||
|
||
return changes
|
||
|
||
def get_content_for_classification(self, change: ADRChange) -> tuple[str | None, str | None]:
|
||
"""3.2.17: 获取 ADR 变更的旧/新内容,供 ChangeClassifier 爆炸半径分类。
|
||
|
||
返回 (old_content, new_content):
|
||
- modified/superseded/deleted: old=change.old_content, new=从磁盘读取
|
||
- new: old=None, new=从磁盘读取
|
||
"""
|
||
if change.kind == "deleted":
|
||
return (change.old_content, None)
|
||
if change.kind == "new":
|
||
new_path = Path(change.path) if change.path else None
|
||
new_content = new_path.read_text(encoding="utf-8") if new_path and new_path.exists() else None
|
||
return (None, new_content)
|
||
# modified / superseded: old_content already captured in change object
|
||
new_path = Path(change.path) if change.path else None
|
||
new_content = new_path.read_text(encoding="utf-8") if new_path and new_path.exists() else None
|
||
return (change.old_content, new_content)
|
||
|
||
@staticmethod
|
||
def _extract_adr_id(adr_file: Path) -> str:
|
||
"""从文件名提取 ADR ID,如 'ADR-0005-ffmpeg-decode.md' → 'ADR-0005'。"""
|
||
match = re.match(r"(ADR-\d+)", adr_file.stem)
|
||
if match:
|
||
return match.group(1)
|
||
return adr_file.stem
|
||
|
||
@staticmethod
|
||
def _parse_status(adr_file: Path) -> str:
|
||
"""解析 ADR 文件中的 Status 字段。"""
|
||
try:
|
||
text = adr_file.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
return "unknown"
|
||
for line in text.splitlines():
|
||
lower = line.lower().strip()
|
||
if lower.startswith("status:") or lower.startswith("status :"):
|
||
status = line.split(":", 1)[1].strip().lower()
|
||
return status
|
||
return "unknown"
|