repograph-codeintel 0.1.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.
- repograph/__init__.py +3 -0
- repograph/__main__.py +3 -0
- repograph/agent_setup.py +224 -0
- repograph/cli.py +476 -0
- repograph/config.py +145 -0
- repograph/extract.py +616 -0
- repograph/impact.py +558 -0
- repograph/indexer.py +184 -0
- repograph/mcp_server.py +130 -0
- repograph/query.py +485 -0
- repograph/resolve.py +755 -0
- repograph/scanner.py +107 -0
- repograph/store.py +287 -0
- repograph/test_gate.py +169 -0
- repograph_codeintel-0.1.0.dist-info/METADATA +295 -0
- repograph_codeintel-0.1.0.dist-info/RECORD +20 -0
- repograph_codeintel-0.1.0.dist-info/WHEEL +5 -0
- repograph_codeintel-0.1.0.dist-info/entry_points.txt +2 -0
- repograph_codeintel-0.1.0.dist-info/licenses/LICENSE +202 -0
- repograph_codeintel-0.1.0.dist-info/top_level.txt +1 -0
repograph/impact.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""Evidence-backed change-impact analysis.
|
|
2
|
+
|
|
3
|
+
Design rule: an empty result is reported as *absence of static evidence*, never
|
|
4
|
+
as safety. Every relationship carries its extractor, location and confidence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import posixpath
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config import INDEX_DIR, safe_path
|
|
17
|
+
from .query import _doc_mentions, _classify_layer
|
|
18
|
+
from .store import Store
|
|
19
|
+
|
|
20
|
+
CAVEATS = [
|
|
21
|
+
"This is a static analysis of statically resolvable relationships. Absence of a relationship is not evidence that a change is safe.",
|
|
22
|
+
"Dynamic imports, reflection, runtime dependency injection, generated code and external consumers are not modeled.",
|
|
23
|
+
"Test selection uses import edges and naming conventions, not execution traces; it can miss tests and include irrelevant ones.",
|
|
24
|
+
"Route and database-model detection use framework-specific heuristics and may produce false positives or miss frameworks.",
|
|
25
|
+
"Confidence levels describe extraction certainty, not the risk of the change itself.",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_UNUSED = None
|
|
29
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess:
|
|
33
|
+
return subprocess.run(
|
|
34
|
+
["git", "-C", str(root), *args],
|
|
35
|
+
capture_output=True, text=True, timeout=60, check=False,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def git_changes(root: Path, base: str = "HEAD") -> dict:
|
|
40
|
+
"""Changed paths plus added-line ranges from the current worktree.
|
|
41
|
+
|
|
42
|
+
Includes staged and unstaged edits against ``base`` and untracked files.
|
|
43
|
+
Deleted files are reported separately because they have no current symbols.
|
|
44
|
+
"""
|
|
45
|
+
probe = _git(root, "rev-parse", "--is-inside-work-tree")
|
|
46
|
+
if probe.returncode != 0:
|
|
47
|
+
return {"available": False, "error": "not a git repository", "paths": [], "lines": {},
|
|
48
|
+
"deleted": [], "untracked": []}
|
|
49
|
+
diff = _git(root, "diff", "--unified=0", "--no-color", "--no-prefix", base, "--")
|
|
50
|
+
if diff.returncode != 0:
|
|
51
|
+
return {"available": False, "error": diff.stderr.strip()[:300] or "git diff failed",
|
|
52
|
+
"paths": [], "lines": {}, "deleted": [], "untracked": []}
|
|
53
|
+
|
|
54
|
+
lines: dict[str, set[int]] = defaultdict(set)
|
|
55
|
+
deleted: set[str] = set()
|
|
56
|
+
current: str | None = None
|
|
57
|
+
old_path: str | None = None
|
|
58
|
+
for raw in diff.stdout.splitlines():
|
|
59
|
+
if raw.startswith("--- "):
|
|
60
|
+
p = raw[4:].strip()
|
|
61
|
+
old_path = None if p == "/dev/null" else p[2:] if p.startswith("a/") else p
|
|
62
|
+
elif raw.startswith("+++ "):
|
|
63
|
+
p = raw[4:].strip()
|
|
64
|
+
current = None if p == "/dev/null" else p[2:] if p.startswith("b/") else p
|
|
65
|
+
if current is None and old_path:
|
|
66
|
+
deleted.add(old_path)
|
|
67
|
+
elif raw.startswith("@@ ") and current:
|
|
68
|
+
m = _HUNK_RE.match(raw)
|
|
69
|
+
if m:
|
|
70
|
+
start = int(m.group(1))
|
|
71
|
+
count = int(m.group(2) or 1)
|
|
72
|
+
for n in range(start, start + count):
|
|
73
|
+
lines[current].add(n)
|
|
74
|
+
|
|
75
|
+
untracked = [
|
|
76
|
+
p for p in _git(root, "ls-files", "--others", "--exclude-standard", "-z").stdout.split("\0") if p
|
|
77
|
+
]
|
|
78
|
+
# The index directory is our own artifact. Users who have not gitignored it
|
|
79
|
+
# would otherwise see it reported as a changed file, and the SQLite WAL adds
|
|
80
|
+
# a new path on every index run.
|
|
81
|
+
own = (INDEX_DIR + "/", INDEX_DIR)
|
|
82
|
+
untracked = [p for p in untracked if not p.startswith(own)]
|
|
83
|
+
lines = {k: v for k, v in lines.items() if not k.startswith(own)}
|
|
84
|
+
paths = sorted(set(lines) | set(untracked))
|
|
85
|
+
return {
|
|
86
|
+
"available": True,
|
|
87
|
+
"paths": paths,
|
|
88
|
+
"lines": {k: sorted(v) for k, v in lines.items()},
|
|
89
|
+
"deleted": sorted(deleted - set(paths)),
|
|
90
|
+
"untracked": untracked,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _normalize_path(root: Path, path: str) -> str:
|
|
95
|
+
p = Path(path)
|
|
96
|
+
if p.is_absolute():
|
|
97
|
+
resolved = safe_path(root, p)
|
|
98
|
+
return resolved.relative_to(root.resolve()).as_posix()
|
|
99
|
+
rel = posixpath.normpath(path.replace("\\", "/"))
|
|
100
|
+
if rel.startswith("../"):
|
|
101
|
+
raise PermissionError(f"path escapes repository root: {path}")
|
|
102
|
+
return rel.lstrip("./")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stem(path: str) -> str:
|
|
106
|
+
base = posixpath.basename(path)
|
|
107
|
+
for ext in (".py", ".pyi", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".prisma"):
|
|
108
|
+
if base.endswith(ext):
|
|
109
|
+
base = base[: -len(ext)]
|
|
110
|
+
break
|
|
111
|
+
for prefix in ("test_",):
|
|
112
|
+
if base.startswith(prefix):
|
|
113
|
+
base = base[len(prefix):]
|
|
114
|
+
for suffix in ("_test", ".test", ".spec", "_spec", "-test"):
|
|
115
|
+
if base.endswith(suffix):
|
|
116
|
+
base = base[: -len(suffix)]
|
|
117
|
+
return base
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_test_module(path: str) -> bool:
|
|
121
|
+
"""True only for a runnable test module, not every file under `tests/`.
|
|
122
|
+
|
|
123
|
+
`is_test_path` treats fixture apps such as `tests/test_apps/cliapp/app.py`
|
|
124
|
+
as tests; those are application code that pytest does not collect.
|
|
125
|
+
"""
|
|
126
|
+
base = posixpath.basename(path)
|
|
127
|
+
if base == "conftest.py":
|
|
128
|
+
return True
|
|
129
|
+
if base.startswith("test_") or base.endswith("_test" + posixpath.splitext(base)[1]):
|
|
130
|
+
return True
|
|
131
|
+
return any(f".{kind}." in base for kind in ("test", "spec"))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _find_symbol(store: Store, name: str) -> tuple[dict | None, list[dict]]:
|
|
135
|
+
rows = [dict(r) for r in store.conn.execute(
|
|
136
|
+
"""SELECT s.*, f.path FROM symbols s JOIN files f ON f.id=s.file_id
|
|
137
|
+
WHERE s.qualified_name=? OR s.name=?""",
|
|
138
|
+
(name, name),
|
|
139
|
+
).fetchall()]
|
|
140
|
+
if not rows:
|
|
141
|
+
return None, []
|
|
142
|
+
# A bare name also matches nested symbols (a test-local `class View` has the
|
|
143
|
+
# qualified name `test_x.View`). Prefer a top-level definition so a fixture or
|
|
144
|
+
# test class cannot shadow the real one; several top-level matches stay
|
|
145
|
+
# ambiguous.
|
|
146
|
+
exact = [r for r in rows if r["qualified_name"] == name]
|
|
147
|
+
if len(exact) == 1:
|
|
148
|
+
return exact[0], []
|
|
149
|
+
candidates = exact or rows
|
|
150
|
+
if len(candidates) == 1:
|
|
151
|
+
return candidates[0], []
|
|
152
|
+
return None, [{"name": r["name"], "qualified_name": r["qualified_name"], "path": r["path"],
|
|
153
|
+
"kind": r["kind"]} for r in candidates][:20]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _symbols_in_files(store: Store, paths: list[str]) -> list[dict]:
|
|
157
|
+
if not paths:
|
|
158
|
+
return []
|
|
159
|
+
marks = ",".join("?" * len(paths))
|
|
160
|
+
rows = store.conn.execute(
|
|
161
|
+
f"""SELECT s.*, f.path FROM symbols s JOIN files f ON f.id=s.file_id
|
|
162
|
+
WHERE f.path IN ({marks}) ORDER BY f.path, s.start_line""",
|
|
163
|
+
paths,
|
|
164
|
+
).fetchall()
|
|
165
|
+
return [dict(r) for r in rows]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _importers(store: Store, path: str) -> list[dict]:
|
|
169
|
+
rows = store.conn.execute(
|
|
170
|
+
"""SELECT s.path AS src, i.line, i.method, i.confidence, i.module, i.kind
|
|
171
|
+
FROM imports i JOIN files s ON s.id=i.file_id JOIN files t ON t.id=i.target_file_id
|
|
172
|
+
WHERE t.path=? AND s.path != t.path ORDER BY s.path, i.line""",
|
|
173
|
+
(path,),
|
|
174
|
+
).fetchall()
|
|
175
|
+
return [dict(r) for r in rows]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _ref_sites(store: Store, path: str, symbol_names: set[str] | None) -> list[dict]:
|
|
179
|
+
rows = store.conn.execute(
|
|
180
|
+
"""SELECT r.name, r.target_name, r.line, r.kind, r.confidence, r.method, s.path AS src
|
|
181
|
+
FROM refs r JOIN files s ON s.id=r.file_id JOIN files t ON t.id=r.target_file_id
|
|
182
|
+
WHERE t.path=? AND s.path != t.path ORDER BY s.path, r.line""",
|
|
183
|
+
(path,),
|
|
184
|
+
).fetchall()
|
|
185
|
+
out = []
|
|
186
|
+
for r in rows:
|
|
187
|
+
if symbol_names is not None and r["target_name"] not in symbol_names:
|
|
188
|
+
continue
|
|
189
|
+
out.append(dict(r))
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def analyze_impact(
|
|
194
|
+
store: Store,
|
|
195
|
+
root: Path,
|
|
196
|
+
*,
|
|
197
|
+
paths: list[str] | None = None,
|
|
198
|
+
symbol: str | None = None,
|
|
199
|
+
diff: bool = False,
|
|
200
|
+
depth: int = 2,
|
|
201
|
+
include_tests: bool = True,
|
|
202
|
+
) -> dict:
|
|
203
|
+
depth = max(1, min(int(depth), 5))
|
|
204
|
+
source = "files"
|
|
205
|
+
changed_lines: dict[str, list[int]] = {}
|
|
206
|
+
deleted: list[str] = []
|
|
207
|
+
diff_info: dict | None = None
|
|
208
|
+
target_paths: list[str] = []
|
|
209
|
+
|
|
210
|
+
if diff:
|
|
211
|
+
source = "git-diff"
|
|
212
|
+
diff_info = git_changes(root)
|
|
213
|
+
if not diff_info["available"]:
|
|
214
|
+
return {"error": diff_info["error"], "caveats": CAVEATS}
|
|
215
|
+
target_paths = list(diff_info["paths"])
|
|
216
|
+
changed_lines = diff_info["lines"]
|
|
217
|
+
deleted = diff_info["deleted"]
|
|
218
|
+
elif symbol:
|
|
219
|
+
source = "symbol"
|
|
220
|
+
found, candidates = _find_symbol(store, symbol)
|
|
221
|
+
if not found:
|
|
222
|
+
if candidates:
|
|
223
|
+
return {"error": f"ambiguous symbol: {symbol}", "candidates": candidates,
|
|
224
|
+
"caveats": CAVEATS}
|
|
225
|
+
return {"error": f"symbol not found in index: {symbol}", "caveats": CAVEATS}
|
|
226
|
+
target_paths = [found["path"]]
|
|
227
|
+
elif paths:
|
|
228
|
+
try:
|
|
229
|
+
target_paths = [_normalize_path(root, p) for p in paths]
|
|
230
|
+
except (PermissionError, ValueError) as exc:
|
|
231
|
+
return {"error": str(exc), "caveats": CAVEATS}
|
|
232
|
+
else:
|
|
233
|
+
return {"error": "provide paths, a symbol, or --diff", "caveats": CAVEATS}
|
|
234
|
+
|
|
235
|
+
indexed = {r["path"] for r in store.all_files()}
|
|
236
|
+
missing = [p for p in target_paths if p not in indexed]
|
|
237
|
+
target_paths = [p for p in target_paths if p in indexed]
|
|
238
|
+
|
|
239
|
+
symbols_in_targets = _symbols_in_files(store, target_paths)
|
|
240
|
+
changed_symbols = symbols_in_targets
|
|
241
|
+
if changed_lines:
|
|
242
|
+
changed_symbols = [
|
|
243
|
+
s for s in symbols_in_targets
|
|
244
|
+
if any(s["start_line"] <= n <= s["end_line"] for n in changed_lines.get(s["path"], []))
|
|
245
|
+
]
|
|
246
|
+
changed_symbol_names: dict[str, set[str]] = defaultdict(set)
|
|
247
|
+
for s in changed_symbols:
|
|
248
|
+
changed_symbol_names[s["path"]].add(s["name"])
|
|
249
|
+
|
|
250
|
+
# Direct dependents: static importers plus resolved reference sites.
|
|
251
|
+
direct: dict[str, dict] = {}
|
|
252
|
+
for p in target_paths:
|
|
253
|
+
for imp in _importers(store, p):
|
|
254
|
+
entry = direct.setdefault(imp["src"], {"path": imp["src"], "reasons": [],
|
|
255
|
+
"confidences": set()})
|
|
256
|
+
entry["reasons"].append({
|
|
257
|
+
"relation": "imports-changed-file", "target": p,
|
|
258
|
+
"line": imp["line"], "module": imp["module"],
|
|
259
|
+
"method": imp["method"], "confidence": imp["confidence"],
|
|
260
|
+
"evidence": {"location": f"{imp['src']}:{imp['line']}"},
|
|
261
|
+
})
|
|
262
|
+
entry["confidences"].add(imp["confidence"])
|
|
263
|
+
names = changed_symbol_names.get(p) if changed_lines else None
|
|
264
|
+
for ref in _ref_sites(store, p, names):
|
|
265
|
+
entry = direct.setdefault(ref["src"], {"path": ref["src"], "reasons": [],
|
|
266
|
+
"confidences": set()})
|
|
267
|
+
entry["reasons"].append({
|
|
268
|
+
"relation": "references-changed-symbol", "target": p, "symbol": ref["target_name"],
|
|
269
|
+
"line": ref["line"], "reference_kind": ref["kind"],
|
|
270
|
+
"method": ref["method"], "confidence": ref["confidence"],
|
|
271
|
+
"evidence": {"location": f"{ref['src']}:{ref['line']}"},
|
|
272
|
+
})
|
|
273
|
+
entry["confidences"].add(ref["confidence"])
|
|
274
|
+
|
|
275
|
+
def _conf(confidences: set[str]) -> str:
|
|
276
|
+
return "confirmed" if confidences and confidences <= {"confirmed"} else "inferred"
|
|
277
|
+
|
|
278
|
+
direct_list = []
|
|
279
|
+
for entry in sorted(direct.values(), key=lambda e: e["path"]):
|
|
280
|
+
direct_list.append({
|
|
281
|
+
"path": entry["path"],
|
|
282
|
+
"confidence": _conf(entry["confidences"]),
|
|
283
|
+
"reasons": entry["reasons"][:12],
|
|
284
|
+
"reason_count": len(entry["reasons"]),
|
|
285
|
+
"evidence": entry["reasons"][0]["evidence"],
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
# Transitive dependents: reverse-import BFS.
|
|
289
|
+
edges = store.file_edges()
|
|
290
|
+
reverse: dict[str, list[dict]] = defaultdict(list)
|
|
291
|
+
for e in edges:
|
|
292
|
+
reverse[e["dst"]].append(e)
|
|
293
|
+
seen_depth = {p: 0 for p in target_paths}
|
|
294
|
+
transitive: dict[str, dict] = {}
|
|
295
|
+
queue = [(p, 0) for p in target_paths]
|
|
296
|
+
while queue:
|
|
297
|
+
cur, d = queue.pop(0)
|
|
298
|
+
if d >= depth:
|
|
299
|
+
continue
|
|
300
|
+
for e in reverse.get(cur, []):
|
|
301
|
+
src = e["src"]
|
|
302
|
+
if src in target_paths:
|
|
303
|
+
continue
|
|
304
|
+
if src in seen_depth and seen_depth[src] <= d + 1:
|
|
305
|
+
continue
|
|
306
|
+
seen_depth[src] = d + 1
|
|
307
|
+
chain = transitive.get(src)
|
|
308
|
+
step = {"from": src, "to": cur, "line": e["line"], "method": e["method"],
|
|
309
|
+
"confidence": e["confidence"]}
|
|
310
|
+
if chain is None:
|
|
311
|
+
transitive[src] = {"path": src, "depth": d + 1, "chain": [step],
|
|
312
|
+
"confidences": {e["confidence"]}}
|
|
313
|
+
else:
|
|
314
|
+
chain["depth"] = min(chain["depth"], d + 1)
|
|
315
|
+
if len(chain["chain"]) < 5:
|
|
316
|
+
chain["chain"].append(step)
|
|
317
|
+
chain["confidences"].add(e["confidence"])
|
|
318
|
+
queue.append((src, d + 1))
|
|
319
|
+
|
|
320
|
+
transitive_list = [{
|
|
321
|
+
"path": v["path"], "depth": v["depth"], "confidence": _conf(v["confidences"]),
|
|
322
|
+
"via": v["chain"],
|
|
323
|
+
"evidence": {"location": f"{v['chain'][-1]['from']}:{v['chain'][-1]['line']}"},
|
|
324
|
+
} for v in sorted(transitive.values(), key=lambda x: (x["depth"], x["path"]))]
|
|
325
|
+
transitive_list = [t for t in transitive_list if t["path"] not in direct]
|
|
326
|
+
|
|
327
|
+
related_tests: list[dict] = []
|
|
328
|
+
reach: dict[str, dict] = {}
|
|
329
|
+
if include_tests:
|
|
330
|
+
test_rows = [dict(r) for r in store.conn.execute(
|
|
331
|
+
"SELECT path FROM files WHERE is_test=1 ORDER BY path"
|
|
332
|
+
).fetchall()]
|
|
333
|
+
test_rows = [r for r in test_rows if _is_test_module(r["path"])]
|
|
334
|
+
changed_stems = {_stem(p) for p in target_paths if _stem(p)}
|
|
335
|
+
dependent_stems = {_stem(d["path"]) for d in direct_list}
|
|
336
|
+
included: dict[str, dict] = {}
|
|
337
|
+
importer_map: dict[str, list[dict]] = defaultdict(list)
|
|
338
|
+
for e in edges:
|
|
339
|
+
importer_map[e["dst"]].append(e)
|
|
340
|
+
test_paths = {row["path"] for row in test_rows}
|
|
341
|
+
# A test often reaches a changed file through intermediate modules: the
|
|
342
|
+
# test imports the package, the package imports the changed file. Direct
|
|
343
|
+
# import rules miss that. Follow importers up to a bounded depth for test
|
|
344
|
+
# files only: tests are the leaves a user runs, and --depth controls the
|
|
345
|
+
# dependent listing, not test discovery.
|
|
346
|
+
seen_reach = set(target_paths)
|
|
347
|
+
frontier = list(target_paths)
|
|
348
|
+
for hop in range(1, 6):
|
|
349
|
+
next_frontier: list[str] = []
|
|
350
|
+
for dst in frontier:
|
|
351
|
+
for e in importer_map.get(dst, []):
|
|
352
|
+
src = e["src"]
|
|
353
|
+
if src in test_paths and src not in reach:
|
|
354
|
+
reach[src] = {"depth": hop, "line": e["line"], "via": dst}
|
|
355
|
+
if src not in seen_reach:
|
|
356
|
+
seen_reach.add(src)
|
|
357
|
+
next_frontier.append(src)
|
|
358
|
+
frontier = next_frontier
|
|
359
|
+
if not frontier:
|
|
360
|
+
break
|
|
361
|
+
for row in test_rows:
|
|
362
|
+
tp = row["path"]
|
|
363
|
+
entry = included.setdefault(tp, {"path": tp, "rules": [], "confidences": set()})
|
|
364
|
+
for p in target_paths:
|
|
365
|
+
for e in importer_map.get(p, []):
|
|
366
|
+
if e["src"] == tp:
|
|
367
|
+
entry["rules"].append({
|
|
368
|
+
"rule": "test-imports-changed-file", "target": p,
|
|
369
|
+
"line": e["line"], "method": e["method"],
|
|
370
|
+
"confidence": "confirmed",
|
|
371
|
+
"evidence": {"location": f"{tp}:{e['line']}"},
|
|
372
|
+
})
|
|
373
|
+
entry["confidences"].add("confirmed")
|
|
374
|
+
if _stem(tp) in changed_stems:
|
|
375
|
+
entry["rules"].append({
|
|
376
|
+
"rule": "test-name-matches-changed-file",
|
|
377
|
+
"confidence": "inferred",
|
|
378
|
+
"evidence": {"location": tp, "method": "path-name-convention"},
|
|
379
|
+
})
|
|
380
|
+
entry["confidences"].add("inferred")
|
|
381
|
+
if _stem(tp) in dependent_stems:
|
|
382
|
+
entry["rules"].append({
|
|
383
|
+
"rule": "test-name-matches-dependent",
|
|
384
|
+
"confidence": "inferred",
|
|
385
|
+
"evidence": {"location": tp, "method": "path-name-convention"},
|
|
386
|
+
})
|
|
387
|
+
entry["confidences"].add("inferred")
|
|
388
|
+
related_tests = [{
|
|
389
|
+
"path": v["path"],
|
|
390
|
+
"confidence": _conf(v["confidences"]),
|
|
391
|
+
"rules": v["rules"],
|
|
392
|
+
"evidence": v["rules"][0]["evidence"],
|
|
393
|
+
} for v in sorted(included.values(), key=lambda x: x["path"]) if v["rules"]][:50]
|
|
394
|
+
|
|
395
|
+
affected = target_paths + [d["path"] for d in direct_list] + [t["path"] for t in transitive_list]
|
|
396
|
+
marks = ",".join("?" * len(affected)) if affected else "''"
|
|
397
|
+
route_rows = store.conn.execute(
|
|
398
|
+
f"""SELECT r.method,r.path,r.handler,r.line,r.framework,f.path AS file
|
|
399
|
+
FROM routes r JOIN files f ON f.id=r.file_id
|
|
400
|
+
WHERE f.path IN ({marks}) ORDER BY f.path, r.line LIMIT 100""",
|
|
401
|
+
affected,
|
|
402
|
+
).fetchall()
|
|
403
|
+
affected_routes = [{
|
|
404
|
+
"method": r["method"], "path": r["path"], "handler": r["handler"],
|
|
405
|
+
"file": r["file"], "line": r["line"],
|
|
406
|
+
"confidence": "heuristic",
|
|
407
|
+
"reason": "route-defined-in-changed-or-dependent-file",
|
|
408
|
+
"evidence": {"extractor": r["framework"], "location": f"{r['file']}:{r['line']}"},
|
|
409
|
+
} for r in route_rows]
|
|
410
|
+
|
|
411
|
+
model_rows = store.conn.execute(
|
|
412
|
+
f"""SELECT s.name,s.qualified_name,s.kind,f.path,s.start_line
|
|
413
|
+
FROM symbols s JOIN files f ON f.id=s.file_id
|
|
414
|
+
WHERE s.tags LIKE '%db_model%' AND f.path IN ({marks})
|
|
415
|
+
ORDER BY f.path, s.start_line LIMIT 100""",
|
|
416
|
+
affected,
|
|
417
|
+
).fetchall()
|
|
418
|
+
related_models = [{
|
|
419
|
+
"name": m["name"], "qualified_name": m["qualified_name"], "kind": m["kind"],
|
|
420
|
+
"path": m["path"], "line": m["start_line"],
|
|
421
|
+
"confidence": "heuristic",
|
|
422
|
+
"reason": "model-defined-in-changed-or-dependent-file",
|
|
423
|
+
"evidence": {"method": "base-class-name-heuristic",
|
|
424
|
+
"location": f"{m['path']}:{m['start_line']}"},
|
|
425
|
+
} for m in model_rows]
|
|
426
|
+
|
|
427
|
+
# Explicit blind spots, not silent omissions.
|
|
428
|
+
blind: list[dict] = []
|
|
429
|
+
all_unresolved = store.conn.execute(
|
|
430
|
+
"SELECT COUNT(*) FROM imports WHERE kind='unresolved'"
|
|
431
|
+
).fetchone()[0]
|
|
432
|
+
if target_paths:
|
|
433
|
+
marks_t = ",".join("?" * len(target_paths))
|
|
434
|
+
unres = store.conn.execute(
|
|
435
|
+
f"""SELECT f.path, i.line, i.module, i.note FROM imports i JOIN files f ON f.id=i.file_id
|
|
436
|
+
WHERE f.path IN ({marks_t}) AND i.kind='unresolved' ORDER BY f.path, i.line""",
|
|
437
|
+
target_paths,
|
|
438
|
+
).fetchall()
|
|
439
|
+
dyn = store.conn.execute(
|
|
440
|
+
f"""SELECT f.path, i.line, i.module FROM imports i JOIN files f ON f.id=i.file_id
|
|
441
|
+
WHERE f.path IN ({marks_t}) AND i.kind='dynamic' ORDER BY f.path, i.line""",
|
|
442
|
+
target_paths,
|
|
443
|
+
).fetchall()
|
|
444
|
+
ext = store.conn.execute(
|
|
445
|
+
f"""SELECT DISTINCT i.module FROM imports i JOIN files f ON f.id=i.file_id
|
|
446
|
+
WHERE f.path IN ({marks_t}) AND i.kind='external' ORDER BY i.module LIMIT 30""",
|
|
447
|
+
target_paths,
|
|
448
|
+
).fetchall()
|
|
449
|
+
errs = store.conn.execute(
|
|
450
|
+
f"SELECT path, parse_error FROM files WHERE parsed=0 AND path IN ({marks_t})",
|
|
451
|
+
target_paths,
|
|
452
|
+
).fetchall()
|
|
453
|
+
if unres:
|
|
454
|
+
blind.append({"kind": "unresolved-internal-imports", "count": len(unres),
|
|
455
|
+
"message": "Imports that look internal but could not be resolved; dependencies through them are unknown.",
|
|
456
|
+
"items": [{"file": r["path"], "line": r["line"], "module": r["module"],
|
|
457
|
+
"note": r["note"]} for r in unres[:20]]})
|
|
458
|
+
if all_unresolved:
|
|
459
|
+
blind.append({"kind": "repo-unresolved-imports", "count": all_unresolved,
|
|
460
|
+
"message": "Repository-wide unresolved imports can hide paths into the changed files."})
|
|
461
|
+
if dyn:
|
|
462
|
+
blind.append({"kind": "dynamic-imports", "count": len(dyn),
|
|
463
|
+
"message": "Dynamic imports were resolved syntactically but their runtime targets may differ.",
|
|
464
|
+
"items": [{"file": r["path"], "line": r["line"], "module": r["module"]}
|
|
465
|
+
for r in dyn[:20]]})
|
|
466
|
+
if ext:
|
|
467
|
+
blind.append({"kind": "external-imports", "count": len(ext),
|
|
468
|
+
"message": "External packages were not analyzed; only their local import sites are known.",
|
|
469
|
+
"items": [r["module"] for r in ext]})
|
|
470
|
+
if errs:
|
|
471
|
+
blind.append({"kind": "parse-errors", "count": len(errs),
|
|
472
|
+
"message": "Files that failed to parse contribute no symbols or edges.",
|
|
473
|
+
"items": [{"file": r["path"], "error": r["parse_error"]} for r in errs]})
|
|
474
|
+
unreached_tests = sorted(set(reach) - {t["path"] for t in related_tests})
|
|
475
|
+
if unreached_tests:
|
|
476
|
+
blind.append({
|
|
477
|
+
"kind": "test-selection-reachability",
|
|
478
|
+
"count": len(unreached_tests),
|
|
479
|
+
"message": ("Test files reachable through dependent modules but not in the "
|
|
480
|
+
"selected list. The list is a selection, not the full impact; run "
|
|
481
|
+
"these or the suite before trusting it."),
|
|
482
|
+
"items": unreached_tests[:50],
|
|
483
|
+
})
|
|
484
|
+
no_dep = [p for p in target_paths if not _importers(store, p)]
|
|
485
|
+
if no_dep:
|
|
486
|
+
blind.append({"kind": "no-static-dependents", "count": len(no_dep),
|
|
487
|
+
"message": "No static dependents were found. The code may still be used dynamically, as an entry point, or outside this repository.",
|
|
488
|
+
"items": no_dep})
|
|
489
|
+
repo_dynamic = store.conn.execute(
|
|
490
|
+
"SELECT COUNT(*) FROM imports WHERE kind='dynamic'"
|
|
491
|
+
).fetchone()[0]
|
|
492
|
+
if repo_dynamic:
|
|
493
|
+
blind.append({"kind": "repo-dynamic-imports", "count": repo_dynamic,
|
|
494
|
+
"message": "Dynamic imports exist in the repository; their runtime targets may include the changed files."})
|
|
495
|
+
repo_parse_errors = store.conn.execute(
|
|
496
|
+
"SELECT COUNT(*) FROM files WHERE parsed=0"
|
|
497
|
+
).fetchone()[0]
|
|
498
|
+
if repo_parse_errors:
|
|
499
|
+
blind.append({"kind": "repo-parse-errors", "count": repo_parse_errors,
|
|
500
|
+
"message": "Files with parse errors contribute no symbols or edges anywhere in the index."})
|
|
501
|
+
if deleted:
|
|
502
|
+
blind.append({"kind": "deleted-files", "count": len(deleted),
|
|
503
|
+
"message": "Deleted files have no current symbols; impact is based on remaining importers only.",
|
|
504
|
+
"items": deleted[:20]})
|
|
505
|
+
|
|
506
|
+
needles = {_stem(p) for p in target_paths if _stem(p)}
|
|
507
|
+
needles |= {s["name"] for s in changed_symbols[:5]}
|
|
508
|
+
docs = _doc_mentions(root, needles, limit=10)
|
|
509
|
+
|
|
510
|
+
by_layer = defaultdict(int)
|
|
511
|
+
for p in target_paths:
|
|
512
|
+
by_layer[_classify_layer(p)] += 1
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
"schema_version": 1,
|
|
516
|
+
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
517
|
+
"source": source,
|
|
518
|
+
"target": {
|
|
519
|
+
"paths": target_paths,
|
|
520
|
+
"symbols": [{
|
|
521
|
+
"name": s["name"], "qualified_name": s["qualified_name"], "kind": s["kind"],
|
|
522
|
+
"path": s["path"], "lines": [s["start_line"], s["end_line"]],
|
|
523
|
+
"changed_by_hunk": s["id"] in {x["id"] for x in changed_symbols},
|
|
524
|
+
"evidence": {"location": f"{s['path']}:{s['start_line']}"},
|
|
525
|
+
} for s in changed_symbols[:200]],
|
|
526
|
+
"changed_lines": changed_lines,
|
|
527
|
+
"deleted": deleted,
|
|
528
|
+
"missing_from_index": missing,
|
|
529
|
+
"diff": {k: v for k, v in (diff_info or {}).items() if k in ("untracked",)} if diff_info else None,
|
|
530
|
+
},
|
|
531
|
+
"changed_files": target_paths,
|
|
532
|
+
"direct_dependents": direct_list,
|
|
533
|
+
"transitive_dependents": transitive_list[:200],
|
|
534
|
+
"related_tests": related_tests,
|
|
535
|
+
"affected_routes": affected_routes,
|
|
536
|
+
"related_models": related_models,
|
|
537
|
+
"architecture": {
|
|
538
|
+
"layers": dict(by_layer),
|
|
539
|
+
"documentation_mentions": docs,
|
|
540
|
+
"note": "Documentation matches are lexical and only point to where a concept is discussed.",
|
|
541
|
+
},
|
|
542
|
+
"blind_spots": blind,
|
|
543
|
+
"caveats": CAVEATS,
|
|
544
|
+
"summary": {
|
|
545
|
+
"changed_files": len(target_paths),
|
|
546
|
+
"changed_symbols": len(changed_symbols),
|
|
547
|
+
"direct_dependents": len(direct_list),
|
|
548
|
+
"transitive_dependents": len(transitive_list),
|
|
549
|
+
"related_tests": len(related_tests),
|
|
550
|
+
"affected_routes": len(affected_routes),
|
|
551
|
+
"related_models": len(related_models),
|
|
552
|
+
"completeness": "partial" if blind else "partial-static",
|
|
553
|
+
"confidence_counts": {
|
|
554
|
+
"confirmed": sum(1 for d in direct_list if d["confidence"] == "confirmed"),
|
|
555
|
+
"inferred": sum(1 for d in direct_list if d["confidence"] == "inferred"),
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
}
|