""" ADR 变更爆炸半径分类 — P1-21 / 3.2.17 Phase 1 组件。 将 ADR 内容变更按影响范围分为 IMPLEMENTATION / INTERFACE / GLOBAL_CONSTRAINT 三级。 """ from __future__ import annotations import enum from dataclasses import dataclass, field from pathlib import Path class BlastRadius(enum.Enum): IMPLEMENTATION = "implementation" INTERFACE = "interface" GLOBAL_CONSTRAINT = "global_constraint" @dataclass class ChangeClassification: adr_id: str blast_radius: BlastRadius reason: str = "" changed_sections: list[str] = field(default_factory=list) class ChangeClassifier: """基于关键词启发式的 ADR 变更爆炸半径分类。 分类规则: - IMPLEMENTATION: 仅实现细节变更,不影响公开接口。关键词:implementation detail, can use, for example, internal, private - INTERFACE: 公开 API/合约变更。关键词:interface, api, contract, signature, must implement, public header, exported, caller - GLOBAL_CONSTRAINT: 跨模块约束变更。关键词:must, shall, all modules, coding standard, every module, entire system, global policy 回退策略:内容不足无法确定时回退到 INTERFACE(保守:宁可多失效不少失效)。 """ _INTERFACE_KEYWORDS = [ "interface", "api", "contract", "signature", "must implement", "public header", "exported", "caller", ] _GLOBAL_KEYWORDS = [ "must", "shall", "all modules", "coding standard", "every module", "entire system", "global policy", ] _IMPLEMENTATION_KEYWORDS = [ "implementation detail", "can use", "for example", "internally", "private", "optional", ] def classify(self, adr_id: str, old_content: str | None, new_content: str | None) -> ChangeClassification: if new_content is None: return ChangeClassification( adr_id=adr_id, blast_radius=BlastRadius.GLOBAL_CONSTRAINT, reason="ADR deleted — all dependent tasks must be invalidated", ) if old_content is None: return ChangeClassification( adr_id=adr_id, blast_radius=BlastRadius.IMPLEMENTATION, reason="new ADR — no existing tasks to invalidate", ) text = new_content.lower() global_hits = sum(1 for kw in self._GLOBAL_KEYWORDS if kw in text) interface_hits = sum(1 for kw in self._INTERFACE_KEYWORDS if kw in text) impl_hits = sum(1 for kw in self._IMPLEMENTATION_KEYWORDS if kw in text) if global_hits >= 2: return ChangeClassification( adr_id=adr_id, blast_radius=BlastRadius.GLOBAL_CONSTRAINT, reason=f"global constraint keywords matched ({global_hits} hits)", ) if interface_hits >= 2: return ChangeClassification( adr_id=adr_id, blast_radius=BlastRadius.INTERFACE, reason=f"interface keywords matched ({interface_hits} hits)", ) if interface_hits >= 1: return ChangeClassification( adr_id=adr_id, blast_radius=BlastRadius.INTERFACE, reason="interface keyword matched — conservative classification", ) if impl_hits >= interface_hits and impl_hits > 0: return ChangeClassification( adr_id=adr_id, blast_radius=BlastRadius.IMPLEMENTATION, reason=f"implementation keywords matched ({impl_hits} hits)", ) # 无法确定 → 保守回退 return ChangeClassification( adr_id=adr_id, blast_radius=BlastRadius.INTERFACE, reason="inconclusive — defaulting to INTERFACE (conservative)", ) def classify_from_files(self, adr_id: str, old_path: Path | None, new_path: Path | None) -> ChangeClassification: old_content = old_path.read_text(encoding="utf-8") if old_path and old_path.exists() else None new_content = new_path.read_text(encoding="utf-8") if new_path and new_path.exists() else None return self.classify(adr_id, old_content, new_content)