doc-drift-detector 1.0.0__py3-none-any.whl

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.
ai/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ ai
3
+ ==
4
+ AI-Powered Semantic Verification and Prose Rewriting module.
5
+ Integrates with local Ollama daemon (e.g. Llama 3.2, Mistral).
6
+ """
7
+
8
+ from .ollama_client import OllamaClient
9
+ from .semantic_verifier import SemanticVerifier, VerificationResult
10
+ from .prose_rewriter import ProseRewriter
11
+
12
+ __all__ = [
13
+ "OllamaClient",
14
+ "SemanticVerifier",
15
+ "VerificationResult",
16
+ "ProseRewriter",
17
+ ]
18
+
ai/ollama_client.py ADDED
@@ -0,0 +1,117 @@
1
+ """
2
+ ai/ollama_client.py
3
+ ===================
4
+ Lightweight, zero-dependency REST client for local Ollama instance.
5
+ Communicates directly with http://localhost:11434 (or OLLAMA_HOST).
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import urllib.error
11
+ import urllib.request
12
+ from typing import Any, Dict, List, Optional
13
+
14
+
15
+ DEFAULT_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
16
+ PREFERRED_MODELS = [
17
+ "llama3.2", "llama3.2:1b", "llama3.2:3b", "llama3.1",
18
+ "llama3", "mistral", "qwen2.5", "qwen2.5-coder", "phi3"
19
+ ]
20
+
21
+
22
+ class OllamaClient:
23
+ """Zero-dependency HTTP client for interacting with a local Ollama daemon."""
24
+
25
+ def __init__(self, host: str = DEFAULT_OLLAMA_HOST, timeout: float = 15.0):
26
+ self.host = host.rstrip("/")
27
+ self.timeout = timeout
28
+ self._cached_available: Optional[bool] = None
29
+ self._cached_models: Optional[List[str]] = None
30
+
31
+ def is_available(self) -> bool:
32
+ """Checks if the local Ollama server is running and responsive."""
33
+ try:
34
+ req = urllib.request.Request(
35
+ f"{self.host}/api/tags",
36
+ headers={"User-Agent": "DocDrift/1.0"}
37
+ )
38
+ with urllib.request.urlopen(req, timeout=2.0) as resp:
39
+ if resp.status == 200:
40
+ self._cached_available = True
41
+ return True
42
+ except Exception:
43
+ pass
44
+ self._cached_available = False
45
+ return False
46
+
47
+ def list_models(self) -> List[str]:
48
+ """Returns list of model names downloaded in local Ollama."""
49
+ if not self.is_available():
50
+ return []
51
+ try:
52
+ req = urllib.request.Request(f"{self.host}/api/tags")
53
+ with urllib.request.urlopen(req, timeout=3.0) as resp:
54
+ data = json.loads(resp.read().decode("utf-8"))
55
+ models = [m.get("name", "") for m in data.get("models", [])]
56
+ self._cached_models = models
57
+ return models
58
+ except Exception:
59
+ return []
60
+
61
+ def get_preferred_model(self) -> Optional[str]:
62
+ """
63
+ Determines the best available model on the user's Ollama instance.
64
+ Prioritizes fast models: llama3.2 -> llama3 -> mistral -> first available.
65
+ """
66
+ installed = self.list_models()
67
+ if not installed:
68
+ return None
69
+
70
+ # Check preferred list
71
+ for preferred in PREFERRED_MODELS:
72
+ for inst in installed:
73
+ if inst == preferred or inst.startswith(f"{preferred}:"):
74
+ return inst
75
+
76
+ # Return first installed model as fallback
77
+ return installed[0]
78
+
79
+ def chat(
80
+ self,
81
+ messages: List[Dict[str, str]],
82
+ model: Optional[str] = None,
83
+ json_format: bool = False,
84
+ temperature: float = 0.1
85
+ ) -> Optional[str]:
86
+ """
87
+ Executes a chat completion request to Ollama /api/chat.
88
+ """
89
+ active_model = model or self.get_preferred_model()
90
+ if not active_model:
91
+ return None
92
+
93
+ payload: Dict[str, Any] = {
94
+ "model": active_model,
95
+ "messages": messages,
96
+ "stream": False,
97
+ "options": {
98
+ "temperature": temperature,
99
+ }
100
+ }
101
+ if json_format:
102
+ payload["format"] = "json"
103
+
104
+ try:
105
+ req_data = json.dumps(payload).encode("utf-8")
106
+ req = urllib.request.Request(
107
+ f"{self.host}/api/chat",
108
+ data=req_data,
109
+ headers={"Content-Type": "application/json"}
110
+ )
111
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
112
+ result = json.loads(resp.read().decode("utf-8"))
113
+ message = result.get("message", {})
114
+ return message.get("content", "").strip()
115
+ except Exception:
116
+ return None
117
+
ai/prose_rewriter.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ ai/prose_rewriter.py
3
+ ====================
4
+ Layer 3: AI Prose Rewriting via Ollama.
5
+
6
+ Generates natural, context-aware sentence and paragraph updates for confirmed
7
+ documentation drift issues using local Ollama LLMs.
8
+ """
9
+
10
+ from typing import Optional
11
+
12
+ from detectors import CodeChange
13
+ from .ollama_client import OllamaClient
14
+
15
+
16
+ SYSTEM_PROMPT = """You are an expert technical documentation editor.
17
+ Your job is to update a single documentation sentence so it accurately reflects a code change.
18
+
19
+ Strict Rules:
20
+ 1. Replace ONLY the outdated value, flag name, or parameter with the new one from the code change.
21
+ 2. Preserve the author's tone, formatting, and markdown (backticks ``, bold **, list markers -).
22
+ 3. Do NOT output code blocks, markdown headers, or multiple lines.
23
+ 4. Do NOT add explanations, commentary, or filler text.
24
+ 5. Output ONLY the single updated sentence — nothing else."""
25
+
26
+
27
+ class ProseRewriter:
28
+ """Generates natural documentation sentence rewrites using local Ollama LLMs."""
29
+
30
+ def __init__(self, client: Optional[OllamaClient] = None):
31
+ self.client = client or OllamaClient()
32
+
33
+ def rewrite_sentence(
34
+ self,
35
+ sentence: str,
36
+ change: CodeChange,
37
+ model: Optional[str] = None
38
+ ) -> Optional[str]:
39
+ """
40
+ Uses Ollama to generate a fluent rewrite of the outdated sentence.
41
+ """
42
+ if not self.client.is_available():
43
+ return None
44
+
45
+ diff_summary = ""
46
+ if change.old_value is not None and change.new_value is not None:
47
+ diff_summary = f"- Old value/signature: {change.old_value}\n- New value/signature: {change.new_value}"
48
+ elif change.old_name is not None and change.new_name is not None:
49
+ diff_summary = f"- Old name: {change.old_name}\n- New name: {change.new_name}"
50
+
51
+ user_content = (
52
+ f"Original sentence: \"{sentence}\"\n"
53
+ f"Code Change: '{change.name}' {diff_summary} in {change.file}:{change.line}.\n"
54
+ f"Write ONLY the corrected sentence (no code blocks, no extra lines):"
55
+ )
56
+
57
+ messages = [
58
+ {"role": "system", "content": SYSTEM_PROMPT},
59
+ {"role": "user", "content": user_content}
60
+ ]
61
+
62
+ rewritten = self.client.chat(messages, model=model, temperature=0.1)
63
+ if not rewritten:
64
+ return None
65
+
66
+ # Clean up surrounding quotes the LLM may have added
67
+ cleaned = rewritten.strip()
68
+ if (cleaned.startswith('"') and cleaned.endswith('"')) or (cleaned.startswith("'") and cleaned.endswith("'")):
69
+ cleaned = cleaned[1:-1].strip()
70
+
71
+ # If LLM returned a code block, extract first usable line
72
+ if "```" in cleaned:
73
+ lines = [ln for ln in cleaned.splitlines() if ln.strip() and not ln.strip().startswith("```")]
74
+ cleaned = lines[0].strip() if lines else ""
75
+
76
+ # If LLM returned multiple lines, take only the first (the sentence itself)
77
+ if "\n" in cleaned:
78
+ cleaned = cleaned.splitlines()[0].strip()
79
+
80
+ return cleaned if cleaned else None
81
+
@@ -0,0 +1,96 @@
1
+ """
2
+ ai/semantic_verifier.py
3
+ =======================
4
+ Layer 2: AI Semantic Verification via Ollama.
5
+
6
+ Evaluates candidate (code_change, doc_match) pairs using a local LLM to:
7
+ 1. Verify whether the prose is genuinely out-of-date or merely mentions the feature.
8
+ 2. Produce a clear, human-like explanation of WHY the documentation drifted.
9
+ 3. Eliminate edge-case false positives that static rules might miss.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ import json
14
+ from typing import Optional, Tuple
15
+
16
+ from detectors import CodeChange
17
+ from utils.doc_finder import DocMatch
18
+ from .ollama_client import OllamaClient
19
+
20
+
21
+ @dataclass
22
+ class VerificationResult:
23
+ is_drift: bool
24
+ confidence: float
25
+ reasoning: str
26
+ suggested_action: str
27
+
28
+
29
+ SYSTEM_PROMPT = """You are an expert Documentation Drift Verification Agent.
30
+ You compare a code change against a documentation sentence to check if the documentation is now factually outdated.
31
+
32
+ Rules:
33
+ 1. If the documentation mentions an old value (e.g. '30 seconds') but the code changed it (e.g. to '60'), this is DRIFT: return {"is_drift": true, ...}.
34
+ 2. If the documentation mentions an old flag (e.g. '--legacy-auth') or deleted function, this is DRIFT: return {"is_drift": true, ...}.
35
+ 3. If the documentation does not contradict the code change, return {"is_drift": false, ...}.
36
+
37
+ Always respond in valid JSON format:
38
+ {
39
+ "is_drift": boolean,
40
+ "confidence": float (between 0.0 and 1.0),
41
+ "reasoning": "1-sentence explanation of why this is or is not drift",
42
+ "suggested_action": "Specific edit instruction if drifted"
43
+ }"""
44
+
45
+
46
+ class SemanticVerifier:
47
+ """Verifies potential drift issues using Ollama LLM reasoning."""
48
+
49
+ def __init__(self, client: Optional[OllamaClient] = None):
50
+ self.client = client or OllamaClient()
51
+
52
+ def verify_candidate(
53
+ self,
54
+ change: CodeChange,
55
+ match: DocMatch,
56
+ model: Optional[str] = None
57
+ ) -> Optional[VerificationResult]:
58
+ """
59
+ Sends candidate drift pair to Ollama to verify if documentation genuinely drifted.
60
+ """
61
+ if not self.client.is_available():
62
+ return None
63
+
64
+ # Build context prompt
65
+ diff_summary = ""
66
+ if change.old_value is not None and change.new_value is not None:
67
+ diff_summary = f"Changed from '{change.old_value}' to '{change.new_value}'"
68
+ elif change.old_name is not None and change.new_name is not None:
69
+ diff_summary = f"Renamed from '{change.old_name}' to '{change.new_name}'"
70
+
71
+ user_content = (
72
+ f"Code Change: Identifier '{change.name}' {diff_summary} in {change.file}:{change.line}.\n"
73
+ f"Documentation Sentence: \"{match.sentence}\"\n\n"
74
+ f"Does this documentation sentence contain factually outdated information based on this code change? Respond in JSON:"
75
+ )
76
+
77
+ messages = [
78
+ {"role": "system", "content": SYSTEM_PROMPT},
79
+ {"role": "user", "content": user_content}
80
+ ]
81
+
82
+ response_text = self.client.chat(messages, model=model, json_format=True, temperature=0.1)
83
+ if not response_text:
84
+ return None
85
+
86
+ try:
87
+ data = json.loads(response_text)
88
+ return VerificationResult(
89
+ is_drift=bool(data.get("is_drift", False)),
90
+ confidence=float(data.get("confidence", 0.8)),
91
+ reasoning=str(data.get("reasoning", "")),
92
+ suggested_action=str(data.get("suggested_action", ""))
93
+ )
94
+ except Exception:
95
+ return None
96
+
detectors/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """
2
+ detectors
3
+ =========
4
+ STEP 2: EXTRACT CODE CHANGES (semantic)
5
+
6
+ Contains specialized semantic detectors for:
7
+ - DEFAULT_VALUE_CHANGED: constants, kwargs, config keys, .env
8
+ - FLAG_RENAMED: CLI flags (--old-flag -> --new-flag) and config keys
9
+ - PARAM_CHANGED: function signature & default parameter modifications
10
+ - FEATURE_REMOVED: deleted functions/classes no longer in codebase
11
+ """
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, List, Optional
15
+
16
+
17
+ @dataclass
18
+ class CodeChange:
19
+ """Represents a semantic code change detected in a PR diff."""
20
+ change_type: str
21
+ name: str
22
+ file: str
23
+ line: int
24
+ old_value: Optional[str] = None
25
+ new_value: Optional[str] = None
26
+ old_name: Optional[str] = None
27
+ new_name: Optional[str] = None
28
+ details: Dict[str, Any] = field(default_factory=dict)
29
+
30
+ def to_dict(self) -> Dict[str, Any]:
31
+ """Convert code change object to dictionary format."""
32
+ data = {
33
+ "type": self.change_type,
34
+ "name": self.name,
35
+ "file": self.file,
36
+ "line": self.line,
37
+ }
38
+ if self.old_value is not None:
39
+ data["old_value"] = self.old_value
40
+ if self.new_value is not None:
41
+ data["new_value"] = self.new_value
42
+ if self.old_name is not None:
43
+ data["old_name"] = self.old_name
44
+ if self.new_name is not None:
45
+ data["new_name"] = self.new_name
46
+ if self.details:
47
+ data["details"] = self.details
48
+ return data
49
+
50
+
51
+ from .default_value import detect_default_values
52
+ from .flag_rename import detect_flag_renames
53
+ from .param_change import detect_param_changes
54
+ from .feature_remove import detect_feature_removals
55
+
56
+ __all__ = [
57
+ "CodeChange",
58
+ "detect_default_values",
59
+ "detect_flag_renames",
60
+ "detect_param_changes",
61
+ "detect_feature_removals",
62
+ ]
63
+
@@ -0,0 +1,135 @@
1
+ """
2
+ detectors/default_value.py
3
+ ==========================
4
+ STEP 2A: DEFAULT VALUE CHANGED DETECTOR
5
+
6
+ Detects when default values, configuration constants, or parameter defaults have
7
+ changed in code files.
8
+
9
+ Patterns handled:
10
+ 1. Python:
11
+ - UPPER_CASE constants: `DEFAULT_TIMEOUT = 30` -> `DEFAULT_TIMEOUT = 60`
12
+ - Lowercase config variables: `timeout = 30` -> `timeout = 60`
13
+ - Dictionary key/value defaults: `"timeout": 30` -> `"timeout": 60`
14
+ - Function parameter default values: `def foo(timeout=30)` -> `def foo(timeout=60)`
15
+ 2. YAML / TOML / JSON:
16
+ - `timeout: 30` -> `timeout: 60`
17
+ - `port = 8080` -> `port = 9000`
18
+ 3. .env / .ini / .cfg files:
19
+ - `DEFAULT_PORT=3000` -> `DEFAULT_PORT=8080`
20
+
21
+ Output:
22
+ CodeChange(type="DEFAULT_VALUE_CHANGED", name=..., old_value=..., new_value=..., file=..., line=...)
23
+ """
24
+
25
+ from pathlib import Path
26
+ import re
27
+ from typing import Dict, List, Optional, Tuple
28
+
29
+ from utils.diff_parser import FileDiff, DiffLine
30
+ from . import CodeChange
31
+
32
+
33
+ def clean_value(val: str) -> str:
34
+ """
35
+ Strips surrounding whitespace, quotes, trailing commas, and trailing comments.
36
+ E.g. '"30", # timeout in seconds' -> '30'
37
+ """
38
+ if not val:
39
+ return ""
40
+ val = val.strip()
41
+ # Strip inline comments (e.g., # or //)
42
+ val = re.sub(r"\s*(?:#|//).*$", "", val).strip()
43
+ # Strip trailing commas
44
+ val = val.rstrip(",;").strip()
45
+ # Strip matching surrounding quotes
46
+ if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
47
+ val = val[1:-1]
48
+ return val.strip()
49
+
50
+
51
+ def extract_assignment(line_text: str) -> Optional[Tuple[str, str]]:
52
+ """
53
+ Extracts variable/key name and assigned value from a line of code or config.
54
+ Supports:
55
+ - Variable assignment: `NAME = VALUE` or `self.name = VALUE`
56
+ - Dictionary / YAML / JSON: `'key': value`, `"key": value`, `key: value`
57
+ - TOML / .env: `KEY = VALUE`, `KEY=VALUE`
58
+ """
59
+ stripped = line_text.strip()
60
+ if not stripped or stripped.startswith(("#", "//", "/*", "*")):
61
+ return None
62
+
63
+ # Pattern 1: Dict or YAML/JSON style -> "key": value or key: value
64
+ dict_match = re.match(r"^['\"]?([a-zA-Z0-9_\.\-]+)['\"]?\s*:\s*([^#\n]+)", stripped)
65
+ if dict_match:
66
+ name = dict_match.group(1).strip()
67
+ val = clean_value(dict_match.group(2))
68
+ return name, val
69
+
70
+ # Pattern 2: Assignment style -> NAME = VALUE or self.NAME = VALUE
71
+ assign_match = re.match(r"^(?:(?:self|cls)\.)?([a-zA-Z0-9_\.\-]+)\s*=\s*([^#\n]+)", stripped)
72
+ if assign_match:
73
+ name = assign_match.group(1).strip()
74
+ val = clean_value(assign_match.group(2))
75
+ return name, val
76
+
77
+ # Pattern 3: Function kwarg default within a def/function line: def foo(..., name=value, ...)
78
+ kwarg_match = re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([a-zA-Z0-9_\"'\.\-]+)", stripped)
79
+ # If it's a def line with a single kwarg change or we need to extract kwargs
80
+ if stripped.startswith("def ") or stripped.startswith("function "):
81
+ # Return first kwarg match if any
82
+ if kwarg_match:
83
+ return kwarg_match[0][0], clean_value(kwarg_match[0][1])
84
+
85
+ return None
86
+
87
+
88
+ def detect_default_values(file_diff: FileDiff) -> List[CodeChange]:
89
+ """
90
+ STEP 2A: Detect changed default values in a FileDiff.
91
+ Compares removed lines (-) against added lines (+) looking for identical
92
+ variable/key names whose values differ.
93
+ """
94
+ changes: List[CodeChange] = []
95
+
96
+ # Map removed assignments: name -> (old_value, line_number)
97
+ removed_assignments: Dict[str, Tuple[str, int]] = {}
98
+
99
+ for line in file_diff.removed_lines:
100
+ extracted = extract_assignment(line.content)
101
+ if extracted:
102
+ name, old_val = extracted
103
+ # Only store non-empty values
104
+ if old_val:
105
+ removed_assignments[name] = (old_val, line.line_number)
106
+
107
+ # Now inspect added lines to find matching names with new values
108
+ matched_names = set()
109
+ for line in file_diff.added_lines:
110
+ extracted = extract_assignment(line.content)
111
+ if extracted:
112
+ name, new_val = extracted
113
+ if name in removed_assignments:
114
+ old_val, old_line_num = removed_assignments[name]
115
+ # Only flag if value has actually changed!
116
+ if old_val != new_val and name not in matched_names:
117
+ matched_names.add(name)
118
+ changes.append(
119
+ CodeChange(
120
+ change_type="DEFAULT_VALUE_CHANGED",
121
+ name=name,
122
+ file=file_diff.file_path,
123
+ line=line.line_number,
124
+ old_value=old_val,
125
+ new_value=new_val,
126
+ details={
127
+ "old_line": old_line_num,
128
+ "new_line": line.line_number,
129
+ "raw_diff": f"- {name} = {old_val} / + {name} = {new_val}"
130
+ }
131
+ )
132
+ )
133
+
134
+ return changes
135
+
@@ -0,0 +1,143 @@
1
+ """
2
+ detectors/feature_remove.py
3
+ ===========================
4
+ STEP 2D: FEATURE REMOVED DETECTOR
5
+
6
+ Detects when functions, classes, or public features have been deleted from code:
7
+ - Python: `def feature_name(` or `class FeatureName`
8
+ - JavaScript / TypeScript: `function featureName(`, `class FeatureName`
9
+ - Go: `func FeatureName(`
10
+ - Rust: `fn feature_name(`
11
+
12
+ Verification check:
13
+ - Present in removed lines (-)
14
+ - NOT present in added lines (+)
15
+ - NOT found anywhere else in the repository (to avoid false alarms if a function
16
+ was merely moved or refactored into a different module).
17
+
18
+ Output:
19
+ CodeChange(type="FEATURE_REMOVED", name=feature_name, file=..., line=...)
20
+ """
21
+
22
+ from pathlib import Path
23
+ import re
24
+ from typing import List, Optional, Set, Tuple
25
+
26
+ from utils.diff_parser import FileDiff, DiffLine, CODE_EXTENSIONS
27
+ from . import CodeChange
28
+
29
+
30
+ FEATURE_DEF_REGEX = re.compile(
31
+ r"""
32
+ ^\s*(?:async\s+)?(?:export\s+)?(?:default\s+)?
33
+ (?:def|class|function|func|fn)\s+
34
+ ([a-zA-Z_][a-zA-Z0-9_]*)
35
+ """,
36
+ re.VERBOSE
37
+ )
38
+
39
+
40
+ def extract_feature_name(line_text: str) -> Optional[str]:
41
+ """Extracts function or class name from a declaration line."""
42
+ match = FEATURE_DEF_REGEX.match(line_text.strip())
43
+ if match:
44
+ name = match.group(1)
45
+ # Skip special dunder methods like __init__, __str__
46
+ if name.startswith("__") and name.endswith("__"):
47
+ return None
48
+ return name
49
+ return None
50
+
51
+
52
+ def exists_elsewhere_in_repo(feature_name: str, repo_dir: str, skip_file: str) -> bool:
53
+ """
54
+ Checks if feature_name definition still exists anywhere else in the repo.
55
+ Searches only code files, excluding documentation and ignored directories.
56
+ """
57
+ repo_path = Path(repo_dir)
58
+ target_pattern = re.compile(
59
+ rf"(?:def|class|function|func|fn)\s+{re.escape(feature_name)}\b"
60
+ )
61
+
62
+ ignore_dirs = {".git", "node_modules", "venv", ".venv", "dist", "build", "__pycache__", ".tox"}
63
+
64
+ try:
65
+ for p in repo_path.rglob("*"):
66
+ if not p.is_file():
67
+ continue
68
+ # Skip ignored directories
69
+ if any(ignored in p.parts for ignored in ignore_dirs):
70
+ continue
71
+ # Skip the file being modified
72
+ if str(p.relative_to(repo_path)) == skip_file or str(p) == skip_file:
73
+ continue
74
+ # Only check code files
75
+ if p.suffix.lower() not in CODE_EXTENSIONS:
76
+ continue
77
+
78
+ try:
79
+ content = p.read_text(encoding="utf-8", errors="ignore")
80
+ if target_pattern.search(content):
81
+ return True
82
+ except Exception:
83
+ continue
84
+ except Exception:
85
+ pass
86
+
87
+ return False
88
+
89
+
90
+ def detect_feature_removals(
91
+ file_diff: FileDiff,
92
+ all_added_lines_by_name: Optional[Set[str]] = None,
93
+ repo_dir: str = "."
94
+ ) -> List[CodeChange]:
95
+ """
96
+ STEP 2D: Detect removed functions or classes in a FileDiff.
97
+ Ensures feature is NOT added back anywhere in the diff, and does NOT exist
98
+ elsewhere in the repo.
99
+ """
100
+ changes: List[CodeChange] = []
101
+
102
+ # Features added in this file
103
+ added_in_file = set()
104
+ for line in file_diff.added_lines:
105
+ feat = extract_feature_name(line.content)
106
+ if feat:
107
+ added_in_file.add(feat)
108
+
109
+ # Features removed in this file
110
+ removed_features: List[Tuple[str, int]] = []
111
+ for line in file_diff.removed_lines:
112
+ feat = extract_feature_name(line.content)
113
+ if feat:
114
+ removed_features.append((feat, line.line_number))
115
+
116
+ for feat_name, line_num in removed_features:
117
+ # 1. Must NOT be in added lines of this file
118
+ if feat_name in added_in_file:
119
+ continue
120
+
121
+ # 2. Must NOT be added in any other modified file in the diff
122
+ if all_added_lines_by_name and feat_name in all_added_lines_by_name:
123
+ continue
124
+
125
+ # 3. Must NOT exist elsewhere in the repo
126
+ if exists_elsewhere_in_repo(feat_name, repo_dir, file_diff.file_path):
127
+ continue
128
+
129
+ changes.append(
130
+ CodeChange(
131
+ change_type="FEATURE_REMOVED",
132
+ name=feat_name,
133
+ file=file_diff.file_path,
134
+ line=line_num,
135
+ details={
136
+ "kind": "function_or_class",
137
+ "raw_diff": f"- deleted {feat_name}"
138
+ }
139
+ )
140
+ )
141
+
142
+ return changes
143
+