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/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
+ }