Navegador

navegador / navegador / churn.py
Blame History Raw 292 lines
1
"""
2
Code churn correlation — git history analysis for behavioural coupling.
3
4
Parses git log to find files that change frequently (churn) and files
5
that frequently change together (behavioural coupling). Results are
6
stored in the graph as properties on File nodes and COUPLED_WITH edges.
7
8
Usage::
9
10
from pathlib import Path
11
from navegador.churn import ChurnAnalyzer
12
from navegador.graph.store import GraphStore
13
14
store = GraphStore.sqlite(".navegador/graph.db")
15
analyzer = ChurnAnalyzer(Path("."), limit=500)
16
17
churn = analyzer.file_churn()
18
pairs = analyzer.coupling_pairs(min_co_changes=3, min_confidence=0.5)
19
stats = analyzer.store_churn(store)
20
"""
21
22
from __future__ import annotations
23
24
import subprocess
25
from collections import defaultdict
26
from dataclasses import dataclass
27
from itertools import combinations
28
from pathlib import Path
29
30
# ── Data models ───────────────────────────────────────────────────────────────
31
32
33
@dataclass
34
class ChurnEntry:
35
"""Per-file churn statistics derived from git history."""
36
37
file_path: str
38
commit_count: int
39
lines_changed: int
40
41
42
@dataclass
43
class CouplingPair:
44
"""A pair of files that frequently change together in the same commits."""
45
46
file_a: str
47
file_b: str
48
co_change_count: int
49
confidence: float # co_change_count / max(changes_a, changes_b)
50
51
52
# ── Analyser ──────────────────────────────────────────────────────────────────
53
54
55
class ChurnAnalyzer:
56
"""Analyze git history for churn and behavioural coupling.
57
58
Parameters
59
----------
60
repo_path:
61
Path to the root of the git repository.
62
limit:
63
Maximum number of commits to inspect (most-recent first).
64
"""
65
66
def __init__(self, repo_path: Path, limit: int = 500) -> None:
67
self.repo_path = Path(repo_path)
68
self.limit = limit
69
70
# ── Internal helpers ──────────────────────────────────────────────────────
71
72
def _run(self, args: list[str]) -> str:
73
"""Run a git sub-command and return stdout as a string."""
74
result = subprocess.run(
75
["git", *args],
76
cwd=self.repo_path,
77
capture_output=True,
78
text=True,
79
check=False, # caller inspects output; non-zero exit is safe to ignore
80
)
81
return result.stdout
82
83
def _commit_file_map(self) -> dict[str, list[str]]:
84
"""
85
Return a mapping of commit hash → list of changed files.
86
87
Uses ``git log --format="%H" --name-only`` which emits blocks like::
88
89
<hash>
90
91
file_a.py
92
file_b.py
93
94
Empty lines separate commit blocks.
95
"""
96
raw = self._run(
97
[
98
"log",
99
f"--max-count={self.limit}",
100
"--format=%H",
101
"--name-only",
102
]
103
)
104
105
commits: dict[str, list[str]] = {}
106
current_hash: str = ""
107
108
for line in raw.splitlines():
109
line = line.strip()
110
if not line:
111
continue
112
# A 40-char hex string is a commit hash
113
if len(line) == 40 and all(c in "0123456789abcdefABCDEF" for c in line):
114
current_hash = line
115
commits[current_hash] = []
116
elif current_hash:
117
commits[current_hash].append(line)
118
119
return commits
120
121
def _numstat_map(self) -> dict[str, int]:
122
"""
123
Return a mapping of file_path → total lines changed (added + deleted).
124
125
Uses ``git log --numstat`` which emits lines like::
126
127
<added>\t<deleted>\t<file>
128
"""
129
raw = self._run(
130
[
131
"log",
132
f"--max-count={self.limit}",
133
"--numstat",
134
"--format=", # suppress commit header lines
135
]
136
)
137
138
lines_changed: dict[str, int] = defaultdict(int)
139
for line in raw.splitlines():
140
parts = line.split("\t")
141
if len(parts) < 3:
142
continue
143
added_str, deleted_str, file_path = parts[0], parts[1], parts[2]
144
# Binary files show "-" for counts; skip them
145
try:
146
added = int(added_str)
147
deleted = int(deleted_str)
148
except ValueError:
149
continue
150
lines_changed[file_path] += added + deleted
151
152
return dict(lines_changed)
153
154
# ── Public API ────────────────────────────────────────────────────────────
155
156
def file_churn(self) -> list[ChurnEntry]:
157
"""Return per-file churn stats from git log.
158
159
Each entry carries:
160
161
* ``commit_count`` — number of commits that touched the file
162
* ``lines_changed`` — total lines added + deleted across those commits
163
164
Results are sorted by ``commit_count`` descending.
165
"""
166
commit_map = self._commit_file_map()
167
numstat = self._numstat_map()
168
169
# Count commits per file
170
commit_counts: dict[str, int] = defaultdict(int)
171
for files in commit_map.values():
172
for f in files:
173
commit_counts[f] += 1
174
175
entries = [
176
ChurnEntry(
177
file_path=fp,
178
commit_count=count,
179
lines_changed=numstat.get(fp, 0),
180
)
181
for fp, count in commit_counts.items()
182
]
183
entries.sort(key=lambda e: e.commit_count, reverse=True)
184
return entries
185
186
def coupling_pairs(
187
self,
188
min_co_changes: int = 3,
189
min_confidence: float = 0.5,
190
) -> list[CouplingPair]:
191
"""Find files that frequently change together in the same commits.
192
193
Parameters
194
----------
195
min_co_changes:
196
Minimum number of commits where both files appear together.
197
min_confidence:
198
Minimum confidence score (co_changes / max(changes_a, changes_b)).
199
A value of 1.0 means one file always changes when the other does.
200
201
Returns a list sorted by ``co_change_count`` descending.
202
"""
203
commit_map = self._commit_file_map()
204
205
# Count commits per file and co-change counts per pair
206
commit_counts: dict[str, int] = defaultdict(int)
207
co_changes: dict[tuple[str, str], int] = defaultdict(int)
208
209
for files in commit_map.values():
210
unique_files = list(dict.fromkeys(files)) # deduplicate, preserve order
211
for f in unique_files:
212
commit_counts[f] += 1
213
for fa, fb in combinations(sorted(unique_files), 2):
214
co_changes[(fa, fb)] += 1
215
216
pairs: list[CouplingPair] = []
217
for (fa, fb), co_count in co_changes.items():
218
if co_count < min_co_changes:
219
continue
220
max_changes = max(commit_counts[fa], commit_counts[fb])
221
if max_changes == 0:
222
continue
223
confidence = co_count / max_changes
224
if confidence < min_confidence:
225
continue
226
pairs.append(
227
CouplingPair(
228
file_a=fa,
229
file_b=fb,
230
co_change_count=co_count,
231
confidence=round(confidence, 4),
232
)
233
)
234
235
pairs.sort(key=lambda p: p.co_change_count, reverse=True)
236
return pairs
237
238
def store_churn(self, store: "GraphStore") -> dict[str, int]: # type: ignore[name-defined] # noqa: F821
239
"""Write churn data to the graph.
240
241
* Sets ``churn_score`` (commit count) on existing File nodes.
242
* Creates ``COUPLED_WITH`` edges between behaviourally coupled files,
243
carrying ``co_change_count`` and ``confidence`` as edge properties.
244
245
Only updates nodes/edges that already exist in the graph — this method
246
does not create new File nodes.
247
248
Returns
249
-------
250
dict
251
``{"churn_updated": int, "couplings_written": int}``
252
"""
253
churn_updated = 0
254
couplings_written = 0
255
256
# -- Update File node churn scores ------------------------------------
257
for entry in self.file_churn():
258
cypher = (
259
"MATCH (f:File {file_path: $fp}) SET f.churn_score = $score, f.lines_changed = $lc"
260
)
261
result = store.query(
262
cypher,
263
{"fp": entry.file_path, "score": entry.commit_count, "lc": entry.lines_changed},
264
)
265
# FalkorDB returns stats; count rows affected if available
266
if getattr(result, "nodes_modified", None) or getattr(result, "properties_set", None):
267
churn_updated += 1
268
else:
269
# Fallback: assume the match succeeded if no error was raised
270
churn_updated += 1
271
272
# -- Write COUPLED_WITH edges -----------------------------------------
273
pairs = self.coupling_pairs()
274
for pair in pairs:
275
cypher = (
276
"MATCH (a:File {file_path: $fa}), (b:File {file_path: $fb}) "
277
"MERGE (a)-[r:COUPLED_WITH]->(b) "
278
"SET r.co_change_count = $co, r.confidence = $conf"
279
)
280
store.query(
281
cypher,
282
{
283
"fa": pair.file_a,
284
"fb": pair.file_b,
285
"co": pair.co_change_count,
286
"conf": pair.confidence,
287
},
288
)
289
couplings_written += 1
290
291
return {"churn_updated": churn_updated, "couplings_written": couplings_written}
292

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button