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 +18 -0
- ai/ollama_client.py +117 -0
- ai/prose_rewriter.py +81 -0
- ai/semantic_verifier.py +96 -0
- detectors/__init__.py +63 -0
- detectors/default_value.py +135 -0
- detectors/feature_remove.py +143 -0
- detectors/flag_rename.py +152 -0
- detectors/param_change.py +234 -0
- doc_drift_detector-1.0.0.dist-info/METADATA +276 -0
- doc_drift_detector-1.0.0.dist-info/RECORD +19 -0
- doc_drift_detector-1.0.0.dist-info/WHEEL +5 -0
- doc_drift_detector-1.0.0.dist-info/entry_points.txt +2 -0
- doc_drift_detector-1.0.0.dist-info/top_level.txt +4 -0
- drift_detector.py +487 -0
- utils/__init__.py +19 -0
- utils/diff_parser.py +241 -0
- utils/doc_finder.py +560 -0
- utils/reporter.py +338 -0
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
|
+
|
ai/semantic_verifier.py
ADDED
|
@@ -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
|
+
|