|
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
|
|