feat: 3.2.17中途变更处理(git驱动) + 3.2.9a Worker git强制 + 3.2.5 Phase7 squash merge + Arc git初始化检测

- 新增 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>
This commit is contained in:
AirPlan
2026-06-15 14:45:41 +08:00
parent a60d1a0c04
commit 9702f1b186
11 changed files with 1351 additions and 82 deletions

View File

@@ -15,10 +15,11 @@ from pathlib import Path
class ADRChange:
"""ADR 文件变更记录。"""
adr_id: str
kind: str # "new" | "superseded" | "modified"
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:
@@ -31,16 +32,17 @@ class ADRWatcher:
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。"""
"""启动时记录所有 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)
self._known_hashes[adr_id] = hashlib.sha256(
adr_file.read_bytes()
).hexdigest()
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返回变更列表。"""
@@ -57,35 +59,56 @@ class ADRWatcher:
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)
if status == "superseded":
changes.append(ADRChange(
adr_id=adr_id, kind="superseded",
path=str(adr_file), old_hash=old_hash, new_hash=current_hash,
))
else:
changes.append(ADRChange(
adr_id=adr_id, kind="modified",
path=str(adr_file), old_hash=old_hash, new_hash=current_hash,
))
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'"""