Navegador

navegador / navegador / diff.py
Blame History Raw 328 lines
1
"""
2
DiffAnalyzer — maps git diff output to affected knowledge-graph nodes.
3
4
Given uncommitted changes in a repository, this module tells you:
5
- which files changed
6
- which functions/classes/methods fall within the changed line ranges
7
- which knowledge nodes (Concept, Rule, Decision, WikiPage) are linked
8
to those symbols via ANNOTATES, IMPLEMENTS, or GOVERNS edges
9
- a single impact_summary dict bundling all of the above
10
11
Usage::
12
13
from pathlib import Path
14
from navegador.diff import DiffAnalyzer
15
from navegador.graph.store import GraphStore
16
17
store = GraphStore.sqlite(".navegador/graph.db")
18
analyzer = DiffAnalyzer(store, Path("."))
19
print(analyzer.impact_summary())
20
"""
21
22
from __future__ import annotations
23
24
import json
25
import subprocess
26
from pathlib import Path
27
from typing import Any
28
29
from navegador.graph.store import GraphStore
30
from navegador.vcs import GitAdapter
31
32
# ── Cypher helpers ────────────────────────────────────────────────────────────
33
34
# All symbols (Function / Class / Method) in a given file with their line ranges
35
_SYMBOLS_IN_FILE = """
36
MATCH (n)
37
WHERE (n:Function OR n:Class OR n:Method)
38
AND n.file_path = $file_path
39
RETURN labels(n)[0] AS type,
40
n.name AS name,
41
n.file_path AS file_path,
42
n.line_start AS line_start,
43
n.line_end AS line_end
44
ORDER BY n.line_start
45
"""
46
47
# Knowledge nodes reachable from a code symbol in one hop via cross-layer edges.
48
# Handles:
49
# (symbol)-[:ANNOTATES|IMPLEMENTS]->(knowledge) — code → knowledge
50
# (knowledge)-[:GOVERNS|ANNOTATES]->(symbol) — knowledge → code
51
_KNOWLEDGE_FOR_SYMBOL = """
52
MATCH (sym)
53
WHERE (sym:Function OR sym:Class OR sym:Method)
54
AND sym.name = $name
55
AND sym.file_path = $file_path
56
OPTIONAL MATCH (sym)-[:ANNOTATES|IMPLEMENTS]->(k1)
57
WHERE (k1:Concept OR k1:Rule OR k1:Decision OR k1:WikiPage)
58
OPTIONAL MATCH (k2)-[:GOVERNS|ANNOTATES]->(sym)
59
WHERE (k2:Concept OR k2:Rule OR k2:Decision OR k2:WikiPage)
60
WITH collect(DISTINCT k1) + collect(DISTINCT k2) AS knowledge_nodes
61
UNWIND knowledge_nodes AS k
62
RETURN DISTINCT
63
labels(k)[0] AS type,
64
k.name AS name,
65
coalesce(k.description, '') AS description,
66
coalesce(k.domain, '') AS domain,
67
coalesce(k.status, '') AS status
68
"""
69
70
71
def _parse_unified_diff_hunks(diff_output: str) -> dict[str, list[tuple[int, int]]]:
72
"""
73
Parse the output of ``git diff -U0 HEAD`` and return a mapping of
74
file path → list of (start_line, end_line) changed ranges.
75
76
Only new/modified lines (``+`` prefix) are tracked; deleted-only hunks
77
contribute the surrounding context line instead (line before deletion).
78
"""
79
result: dict[str, list[tuple[int, int]]] = {}
80
current_file: str | None = None
81
current_new_start = 0
82
current_new_count = 0
83
84
for line in diff_output.splitlines():
85
# New file header: +++ b/path/to/file
86
if line.startswith("+++ b/"):
87
current_file = line[6:]
88
if current_file not in result:
89
result[current_file] = []
90
elif line.startswith("+++ /dev/null"):
91
current_file = None # deleted file — skip
92
elif line.startswith("@@ "):
93
# Hunk header: @@ -old_start[,old_count] +new_start[,new_count] @@
94
try:
95
new_info = line.split("+")[1].split("@@")[0].strip()
96
if "," in new_info:
97
new_start_str, new_count_str = new_info.split(",", 1)
98
current_new_start = int(new_start_str)
99
current_new_count = int(new_count_str)
100
else:
101
current_new_start = int(new_info)
102
current_new_count = 1
103
if current_file and current_new_count > 0:
104
end = current_new_start + max(current_new_count - 1, 0)
105
result.setdefault(current_file, []).append((current_new_start, end))
106
except (ValueError, IndexError):
107
pass
108
109
return result
110
111
112
def _lines_overlap(
113
changed_ranges: list[tuple[int, int]],
114
line_start: int | None,
115
line_end: int | None,
116
) -> bool:
117
"""Return True when any changed range overlaps with [line_start, line_end]."""
118
if line_start is None:
119
return False
120
sym_start = line_start
121
sym_end = line_end if line_end is not None else line_start
122
for r_start, r_end in changed_ranges:
123
if r_start <= sym_end and r_end >= sym_start:
124
return True
125
return False
126
127
128
# ── DiffAnalyzer ─────────────────────────────────────────────────────────────
129
130
131
class DiffAnalyzer:
132
"""Maps git diff output to affected graph nodes.
133
134
Parameters
135
----------
136
store:
137
An open :class:`~navegador.graph.store.GraphStore`.
138
repo_path:
139
Root of the git repository to inspect.
140
"""
141
142
def __init__(self, store: GraphStore, repo_path: Path) -> None:
143
self.store = store
144
self.repo_path = Path(repo_path)
145
self._git = GitAdapter(self.repo_path)
146
147
# ------------------------------------------------------------------
148
# Public API
149
# ------------------------------------------------------------------
150
151
def changed_files(self) -> list[str]:
152
"""Return paths of files with uncommitted changes (staged + unstaged).
153
154
Delegates to :class:`~navegador.vcs.GitAdapter` which runs
155
``git diff HEAD --name-only``.
156
"""
157
return self._git.changed_files()
158
159
def changed_lines(self) -> dict[str, list[tuple[int, int]]]:
160
"""Return a mapping of file path → list of (start, end) changed line ranges.
161
162
Runs ``git diff -U0 HEAD`` and parses the unified diff hunk headers.
163
Falls back to the whole file (line 1 to a large sentinel) when the
164
diff cannot be parsed precisely — ensuring callers always get a result.
165
"""
166
result = subprocess.run(
167
["git", "diff", "-U0", "HEAD"],
168
cwd=self.repo_path,
169
capture_output=True,
170
text=True,
171
check=False,
172
)
173
if result.returncode != 0 or not result.stdout.strip():
174
# No HEAD yet (initial commit) or empty diff — treat all changed
175
# files as fully-changed using a wide sentinel range.
176
return {f: [(1, 999_999)] for f in self.changed_files()}
177
178
parsed = _parse_unified_diff_hunks(result.stdout)
179
180
# Files returned by changed_files() but missing from the parsed diff
181
# (e.g. binary files, new untracked files added with --intent-to-add)
182
# get a full-file sentinel range.
183
for f in self.changed_files():
184
if f not in parsed:
185
parsed[f] = [(1, 999_999)]
186
187
return parsed
188
189
def affected_symbols(self) -> list[dict[str, Any]]:
190
"""Return functions/classes/methods whose line ranges overlap changed lines.
191
192
Each entry is a dict with the keys:
193
``type``, ``name``, ``file_path``, ``line_start``, ``line_end``.
194
"""
195
line_map = self.changed_lines()
196
symbols: list[dict[str, Any]] = []
197
seen: set[tuple[str, str]] = set()
198
199
for file_path, changed_ranges in line_map.items():
200
rows = self.store.query(_SYMBOLS_IN_FILE, {"file_path": file_path})
201
if not rows or not rows.result_set:
202
continue
203
for row in rows.result_set:
204
sym_type, name, fp, line_start, line_end = row
205
key = (name, fp or file_path)
206
if key in seen:
207
continue
208
if _lines_overlap(changed_ranges, line_start, line_end):
209
seen.add(key)
210
symbols.append(
211
{
212
"type": sym_type,
213
"name": name,
214
"file_path": fp or file_path,
215
"line_start": line_start,
216
"line_end": line_end,
217
}
218
)
219
220
return symbols
221
222
def affected_knowledge(self) -> list[dict[str, Any]]:
223
"""Return knowledge nodes linked to affected code symbols.
224
225
Traverses ANNOTATES, IMPLEMENTS (code → knowledge) and
226
GOVERNS, ANNOTATES (knowledge → code) edges from each affected symbol.
227
228
Each entry is a dict with the keys:
229
``type``, ``name``, ``description``, ``domain``, ``status``.
230
"""
231
symbols = self.affected_symbols()
232
knowledge: list[dict[str, Any]] = []
233
seen: set[str] = set()
234
235
for sym in symbols:
236
rows = self.store.query(
237
_KNOWLEDGE_FOR_SYMBOL,
238
{"name": sym["name"], "file_path": sym["file_path"]},
239
)
240
if not rows or not rows.result_set:
241
continue
242
for row in rows.result_set:
243
k_type, k_name, k_desc, k_domain, k_status = row
244
if k_name in seen:
245
continue
246
seen.add(k_name)
247
knowledge.append(
248
{
249
"type": k_type,
250
"name": k_name,
251
"description": k_desc,
252
"domain": k_domain,
253
"status": k_status,
254
}
255
)
256
257
return knowledge
258
259
def impact_summary(self) -> dict[str, Any]:
260
"""Return a full impact summary: files, symbols, and knowledge nodes.
261
262
The returned dict has the keys:
263
``files`` — list of changed file paths
264
``symbols`` — list of affected symbol dicts
265
``knowledge`` — list of affected knowledge node dicts
266
``counts`` — sub-dict with ``files``, ``symbols``, ``knowledge`` counts
267
"""
268
files = self.changed_files()
269
symbols = self.affected_symbols()
270
knowledge = self.affected_knowledge()
271
272
return {
273
"files": files,
274
"symbols": symbols,
275
"knowledge": knowledge,
276
"counts": {
277
"files": len(files),
278
"symbols": len(symbols),
279
"knowledge": len(knowledge),
280
},
281
}
282
283
# ------------------------------------------------------------------
284
# Formatters
285
# ------------------------------------------------------------------
286
287
def to_json(self) -> str:
288
"""Serialise impact_summary() as a JSON string."""
289
return json.dumps(self.impact_summary(), indent=2, default=str)
290
291
def to_markdown(self) -> str:
292
"""Render impact_summary() as a human-readable Markdown string."""
293
summary = self.impact_summary()
294
lines: list[str] = ["# Diff Impact Summary\n"]
295
296
# Files
297
lines.append(f"## Changed Files ({summary['counts']['files']})\n")
298
if summary["files"]:
299
for f in summary["files"]:
300
lines.append(f"- `{f}`")
301
else:
302
lines.append("_No changed files._")
303
lines.append("")
304
305
# Symbols
306
lines.append(f"## Affected Symbols ({summary['counts']['symbols']})\n")
307
if summary["symbols"]:
308
for sym in summary["symbols"]:
309
loc = f"line {sym['line_start']}" if sym.get("line_start") else ""
310
lines.append(
311
f"- **{sym['type']}** `{sym['name']}` — `{sym['file_path']}`"
312
+ (f" ({loc})" if loc else "")
313
)
314
else:
315
lines.append("_No affected symbols found in graph._")
316
lines.append("")
317
318
# Knowledge
319
lines.append(f"## Affected Knowledge ({summary['counts']['knowledge']})\n")
320
if summary["knowledge"]:
321
for k in summary["knowledge"]:
322
desc = f" — {k['description']}" if k.get("description") else ""
323
lines.append(f"- **{k['type']}** `{k['name']}`{desc}")
324
else:
325
lines.append("_No linked knowledge nodes._")
326
327
return "\n".join(lines)
328

Keyboard Shortcuts

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