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/query.py ADDED
@@ -0,0 +1,485 @@
1
+ """Read-side queries: search, dependency graph, cycles, architecture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import posixpath
7
+ import re
8
+ from collections import defaultdict, deque
9
+ from pathlib import Path
10
+
11
+ from .config import Settings
12
+ from .scanner import scan
13
+ from .store import Store
14
+
15
+ MAX_DOC_BYTES = 400_000
16
+ LAYER_RULES = [
17
+ ("tests", re.compile(r"(^|/)(tests?|__tests__|specs?|e2e)(/|$)|(^|/)(test_|conftest)|\.(test|spec)\.")),
18
+ ("api", re.compile(r"(^|/)(api|routes?|controllers?|endpoints?|handlers?|graphql|rpc)(\.\w+)?(/|$)")),
19
+ ("data", re.compile(r"(^|/)(models?|entities|schema|schemas|migrations?|db|database|storage|repositories)(\.\w+)?(/|$)")),
20
+ ("ui", re.compile(r"(^|/)(ui|components?|pages?|views?|screens?|layouts?|styles?|hooks)(\.\w+)?(/|$)")),
21
+ ("infra", re.compile(r"(^|/)(infra|deploy|docker|k8s|terraform|scripts?|ci|config|settings)(\.\w+)?(/|$)")),
22
+ ("domain", re.compile(r"(^|/)(core|domain|services?|lib|utils?|common|shared|internal|app)(\.\w+)?(/|$)")),
23
+ ]
24
+
25
+
26
+ def _dicts(rows) -> list[dict]:
27
+ return [dict(r) for r in rows]
28
+
29
+
30
+ def search_symbols(store: Store, query: str, *, kind: str | None = None,
31
+ limit: int = 20, path_prefix: str | None = None) -> list[dict]:
32
+ q = query.strip()
33
+ if not q:
34
+ return []
35
+ like = "%" + q.lower().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
36
+ sql = (
37
+ "SELECT s.id,s.name,s.qualified_name,s.kind,s.start_line,s.end_line,s.tags,f.path "
38
+ "FROM symbols s JOIN files f ON f.id=s.file_id "
39
+ "WHERE (lower(s.name) LIKE ? ESCAPE '\\' OR lower(s.qualified_name) LIKE ? ESCAPE '\\')"
40
+ )
41
+ params: list[object] = [like, like]
42
+ if kind:
43
+ sql += " AND s.kind=?"
44
+ params.append(kind)
45
+ if path_prefix:
46
+ sql += " AND f.path LIKE ?"
47
+ params.append(path_prefix.rstrip("/") + "%")
48
+ sql += " LIMIT 500"
49
+ rows = _dicts(store.conn.execute(sql, params).fetchall())
50
+ low = q.lower()
51
+
52
+ def score(row: dict) -> tuple:
53
+ name = row["name"].lower()
54
+ qn = row["qualified_name"].lower()
55
+ if name == low:
56
+ base = 100
57
+ elif name.startswith(low):
58
+ base = 80
59
+ elif low in name:
60
+ base = 60
61
+ elif low in qn:
62
+ base = 40
63
+ else:
64
+ base = 10
65
+ if row["kind"] in ("class", "function", "model", "interface"):
66
+ base += 3
67
+ return (-base, len(row["qualified_name"]), row["path"])
68
+
69
+ rows.sort(key=score)
70
+ out = []
71
+ for r in rows[:limit]:
72
+ out.append({
73
+ "name": r["name"],
74
+ "qualified_name": r["qualified_name"],
75
+ "kind": r["kind"],
76
+ "path": r["path"],
77
+ "lines": [r["start_line"], r["end_line"]],
78
+ "tags": [t for t in r["tags"].split(",") if t],
79
+ "evidence": {"extractor": "tree-sitter", "location": f"{r['path']}:{r['start_line']}"},
80
+ })
81
+ return out
82
+
83
+
84
+ def _doc_files(root: Path) -> list[Path]:
85
+ candidates: list[Path] = []
86
+ for name in ("README.md", "ARCHITECTURE.md", "AGENTS.md", "CONTRIBUTING.md"):
87
+ p = root / name
88
+ if p.is_file():
89
+ candidates.append(p)
90
+ for sub in ("docs", "doc", "adr", "decisions"):
91
+ d = root / sub
92
+ if d.is_dir():
93
+ for dirpath, dirnames, filenames in os.walk(d):
94
+ dirnames[:] = [x for x in dirnames if x not in (".git", "node_modules", "images", "assets")]
95
+ for fn in filenames:
96
+ if fn.lower().endswith((".md", ".mdx", ".rst", ".txt")):
97
+ candidates.append(Path(dirpath, fn))
98
+ if len(candidates) > 80:
99
+ break
100
+ return candidates
101
+
102
+
103
+ def text_search(root: Path, store: Store, query: str, *, limit: int = 20,
104
+ case_sensitive: bool = False) -> list[dict]:
105
+ needle = query if case_sensitive else query.lower()
106
+ if not needle:
107
+ return []
108
+ tokens = [t for t in re.split(r"[^\w-]+", needle) if t]
109
+ hits: list[dict] = []
110
+
111
+ def scan_file(full: Path, rel: str, kind: str) -> None:
112
+ nonlocal hits
113
+ try:
114
+ if full.stat().st_size > MAX_DOC_BYTES:
115
+ return
116
+ text = full.read_text(encoding="utf-8", errors="replace")
117
+ except OSError:
118
+ return
119
+ per_file = 0
120
+ for lineno, line in enumerate(text.splitlines(), start=1):
121
+ hay = line if case_sensitive else line.lower()
122
+ matched = (all(tok in hay for tok in tokens) if len(tokens) > 1 else needle in hay)
123
+ if not matched:
124
+ continue
125
+ hits.append({
126
+ "path": rel,
127
+ "line": lineno,
128
+ "text": line.strip()[:240],
129
+ "kind": kind,
130
+ "evidence": {"method": "text-scan", "location": f"{rel}:{lineno}"},
131
+ })
132
+ per_file += 1
133
+ if per_file >= 3 or len(hits) >= limit:
134
+ return
135
+
136
+ for row in store.conn.execute("SELECT path FROM files ORDER BY path").fetchall():
137
+ scan_file(root / row["path"], row["path"], "source")
138
+ if len(hits) >= limit:
139
+ return hits
140
+ for doc in _doc_files(root):
141
+ try:
142
+ rel = doc.relative_to(root).as_posix()
143
+ except ValueError:
144
+ continue
145
+ scan_file(doc, rel, "docs")
146
+ if len(hits) >= limit:
147
+ break
148
+ return hits
149
+
150
+
151
+ def search(store: Store, root: Path, query: str, *, limit: int = 20,
152
+ related: bool = False) -> dict:
153
+ symbols = search_symbols(store, query, limit=limit)
154
+ text = text_search(root, store, query, limit=limit)
155
+ result: dict = {"query": query, "symbols": symbols, "text": text}
156
+ if related:
157
+ paths = list(dict.fromkeys([s["path"] for s in symbols] + [t["path"] for t in text]))
158
+ rel: list[dict] = []
159
+ edges = store.file_edges()
160
+ for p in paths:
161
+ for e in edges:
162
+ if e["dst"] == p:
163
+ rel.append({"path": e["src"], "relation": "imported-by", "target": p,
164
+ "evidence": {"line": e["line"], "method": e["method"],
165
+ "confidence": e["confidence"]}})
166
+ elif e["src"] == p:
167
+ rel.append({"path": e["dst"], "relation": "imports", "target": p,
168
+ "evidence": {"line": e["line"], "method": e["method"],
169
+ "confidence": e["confidence"]}})
170
+ seen = set()
171
+ deduped = []
172
+ for r in rel:
173
+ key = (r["path"], r["relation"], r["target"])
174
+ if key not in seen:
175
+ seen.add(key)
176
+ deduped.append(r)
177
+ result["related_files"] = deduped[:limit]
178
+ return result
179
+
180
+
181
+ # --------------------------------------------------------------------------
182
+ # Graph
183
+ # --------------------------------------------------------------------------
184
+
185
+ def _adjacency(edges: list[dict]) -> tuple[dict[str, list[dict]], dict[str, list[dict]]]:
186
+ forward: dict[str, list[dict]] = defaultdict(list)
187
+ reverse: dict[str, list[dict]] = defaultdict(list)
188
+ for e in edges:
189
+ forward[e["src"]].append(e)
190
+ reverse[e["dst"]].append(e)
191
+ return forward, reverse
192
+
193
+
194
+ def dependency_graph(store: Store, *, path: str | None = None, direction: str = "both",
195
+ depth: int = 1, max_nodes: int = 2000) -> dict:
196
+ edges = store.file_edges()
197
+ forward, reverse = _adjacency(edges)
198
+ if path is None:
199
+ nodes = sorted({e["src"] for e in edges} | {e["dst"] for e in edges})
200
+ out_edges = edges
201
+ else:
202
+ depth = max(1, min(depth, 10))
203
+ seen = {path: 0}
204
+ queue = deque([(path, 0)])
205
+ out_edges: list[dict] = []
206
+ edge_keys = set()
207
+ while queue:
208
+ cur, d = queue.popleft()
209
+ if d >= depth:
210
+ continue
211
+ nexts = []
212
+ if direction in ("in", "both"):
213
+ nexts.extend(reverse.get(cur, []))
214
+ if direction in ("out", "both"):
215
+ nexts.extend(forward.get(cur, []))
216
+ for e in nexts:
217
+ key = (e["src"], e["dst"], e["line"])
218
+ if key not in edge_keys:
219
+ edge_keys.add(key)
220
+ out_edges.append(e)
221
+ other = e["src"] if e["dst"] == cur else e["dst"]
222
+ if other not in seen:
223
+ seen[other] = d + 1
224
+ queue.append((other, d + 1))
225
+ nodes = sorted(seen)
226
+ if len(nodes) > max_nodes:
227
+ nodes = nodes[:max_nodes]
228
+ out_edges = [e for e in out_edges if e["src"] in nodes and e["dst"] in nodes]
229
+ return {
230
+ "nodes": nodes,
231
+ "edges": [
232
+ {"from": e["src"], "to": e["dst"], "confidence": e["confidence"],
233
+ "method": e["method"], "line": e["line"],
234
+ "evidence": {"location": f"{e['src']}:{e['line']}"}}
235
+ for e in out_edges
236
+ ],
237
+ "truncated": len(out_edges) >= 5000,
238
+ "note": "Only statically resolved imports are shown; external and dynamic imports are excluded.",
239
+ }
240
+
241
+
242
+ def find_cycles(store: Store, limit: int = 50) -> list[list[str]]:
243
+ """Strongly connected components with more than one file (Kosaraju)."""
244
+ edges = store.file_edges()
245
+ nodes = {e["src"] for e in edges} | {e["dst"] for e in edges}
246
+ adj: dict[str, list[str]] = defaultdict(list)
247
+ radj: dict[str, list[str]] = defaultdict(list)
248
+ for e in edges:
249
+ adj[e["src"]].append(e["dst"])
250
+ radj[e["dst"]].append(e["src"])
251
+
252
+ visited: set[str] = set()
253
+ order: list[str] = []
254
+ for start in nodes:
255
+ if start in visited:
256
+ continue
257
+ stack = [(start, iter(adj.get(start, [])))]
258
+ visited.add(start)
259
+ while stack:
260
+ node, it = stack[-1]
261
+ advanced = False
262
+ for nxt in it:
263
+ if nxt not in visited:
264
+ visited.add(nxt)
265
+ stack.append((nxt, iter(adj.get(nxt, []))))
266
+ advanced = True
267
+ break
268
+ if not advanced:
269
+ order.append(node)
270
+ stack.pop()
271
+
272
+ assigned: set[str] = set()
273
+ comps: list[list[str]] = []
274
+ for start in reversed(order):
275
+ if start in assigned:
276
+ continue
277
+ comp = []
278
+ stack = [start]
279
+ assigned.add(start)
280
+ while stack:
281
+ n = stack.pop()
282
+ comp.append(n)
283
+ for nxt in radj.get(n, []):
284
+ if nxt not in assigned:
285
+ assigned.add(nxt)
286
+ stack.append(nxt)
287
+ if len(comp) > 1:
288
+ comps.append(sorted(comp))
289
+ comps.sort(key=lambda c: (len(c), c), reverse=True)
290
+ return comps[:limit]
291
+
292
+
293
+ # --------------------------------------------------------------------------
294
+ # References
295
+ # --------------------------------------------------------------------------
296
+
297
+ def find_references(store: Store, query: str, *, limit: int = 100) -> dict:
298
+ q = query.strip()
299
+ defs = _dicts(store.conn.execute(
300
+ """SELECT s.*, f.path FROM symbols s JOIN files f ON f.id=s.file_id
301
+ WHERE s.qualified_name=? OR s.name=? ORDER BY s.qualified_name LIMIT 50""",
302
+ (q, q),
303
+ ).fetchall())
304
+ if not defs:
305
+ defs = _dicts(store.conn.execute(
306
+ """SELECT s.*, f.path FROM symbols s JOIN files f ON f.id=s.file_id
307
+ WHERE s.qualified_name LIKE ? ORDER BY length(s.qualified_name) LIMIT 50""",
308
+ ("%" + q,),
309
+ ).fetchall())
310
+
311
+ definitions = [{
312
+ "path": d["path"], "name": d["name"], "qualified_name": d["qualified_name"],
313
+ "kind": d["kind"], "lines": [d["start_line"], d["end_line"]],
314
+ "evidence": {"method": "symbol-definition", "location": f"{d['path']}:{d['start_line']}"},
315
+ } for d in defs]
316
+
317
+ refs: list[dict] = []
318
+ if defs:
319
+ pairs = {(d["path"], d["name"]) for d in defs}
320
+ rows = store.conn.execute(
321
+ """SELECT r.name,r.target_name,r.line,r.kind,r.confidence,r.method,f.path AS ref_path,
322
+ tf.path AS target_path
323
+ FROM refs r JOIN files f ON f.id=r.file_id
324
+ LEFT JOIN files tf ON tf.id=r.target_file_id
325
+ WHERE r.target_file_id IS NOT NULL ORDER BY r.line LIMIT 5000"""
326
+ ).fetchall()
327
+ for r in rows:
328
+ if (r["target_path"], r["target_name"]) in pairs:
329
+ refs.append({
330
+ "path": r["ref_path"], "line": r["line"], "kind": r["kind"],
331
+ "confidence": r["confidence"], "method": r["method"],
332
+ "target": f"{r['target_path']}:{r['name']}",
333
+ "evidence": {"location": f"{r['ref_path']}:{r['line']}",
334
+ "extractor": r["method"], "confidence": r["confidence"]},
335
+ })
336
+ return {
337
+ "query": q,
338
+ "definitions": definitions,
339
+ "references": refs[:limit],
340
+ "references_truncated": len(refs) > limit,
341
+ "note": "References are statically resolved call/inheritance sites; reflective or dynamic uses are not included.",
342
+ }
343
+
344
+
345
+ # --------------------------------------------------------------------------
346
+ # Architecture
347
+ # --------------------------------------------------------------------------
348
+
349
+ def _classify_layer(path: str) -> str:
350
+ for name, rx in LAYER_RULES:
351
+ if rx.search(path):
352
+ return name
353
+ return "other"
354
+
355
+
356
+ def _doc_mentions(root: Path, needles: set[str], limit: int = 20) -> list[dict]:
357
+ if not needles:
358
+ return []
359
+ candidates = _doc_files(root)
360
+ out: list[dict] = []
361
+ for p in candidates[:80]:
362
+ try:
363
+ if p.stat().st_size > MAX_DOC_BYTES:
364
+ continue
365
+ lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
366
+ except OSError:
367
+ continue
368
+ rel = p.relative_to(root).as_posix()
369
+ for lineno, line in enumerate(lines, start=1):
370
+ low = line.lower()
371
+ for needle in needles:
372
+ if needle and needle.lower() in low:
373
+ out.append({"path": rel, "line": lineno, "excerpt": line.strip()[:220],
374
+ "matched": needle,
375
+ "evidence": {"method": "documentation-text-match",
376
+ "location": f"{rel}:{lineno}"}})
377
+ break
378
+ if len(out) >= limit:
379
+ return out
380
+ return out
381
+
382
+
383
+ def architecture_summary(store: Store, root: Path) -> dict:
384
+ stats = store.stats()
385
+ files = store.all_files()
386
+ edges = store.file_edges()
387
+ # The edge count is only as complete as the resolved imports. Report the
388
+ # imports that were classified but not turned into edges, so the summary
389
+ # does not read as a census.
390
+ unresolved = store.conn.execute(
391
+ "SELECT COUNT(*) FROM imports WHERE kind='unresolved'").fetchone()[0]
392
+ dynamic = store.conn.execute(
393
+ "SELECT COUNT(*) FROM imports WHERE kind='dynamic'").fetchone()[0]
394
+ in_deg: dict[str, int] = defaultdict(int)
395
+ out_deg: dict[str, int] = defaultdict(int)
396
+ for e in edges:
397
+ in_deg[e["dst"]] += 1
398
+ out_deg[e["src"]] += 1
399
+
400
+ layers: dict[str, int] = defaultdict(int)
401
+ for f in files:
402
+ layers[_classify_layer(f["path"])] += 1
403
+
404
+ top_dirs: dict[str, dict] = {}
405
+ for f in files:
406
+ top = f["path"].split("/")[0] if "/" in f["path"] else "."
407
+ entry = top_dirs.setdefault(top, {"files": 0, "inbound": 0, "outbound": 0})
408
+ entry["files"] += 1
409
+ entry["inbound"] += in_deg.get(f["path"], 0)
410
+ entry["outbound"] += out_deg.get(f["path"], 0)
411
+
412
+ entry_names = {"main.py", "__main__.py", "app.py", "cli.py", "server.py", "manage.py",
413
+ "index.ts", "main.ts", "server.ts", "index.js", "app.js", "mod.rs"}
414
+ entries = [f["path"] for f in files if posixpath.basename(f["path"]) in entry_names]
415
+
416
+ routes = _dicts(store.conn.execute(
417
+ """SELECT r.method,r.path,r.handler,r.line,r.framework,r.confidence,f.path AS file
418
+ FROM routes r JOIN files f ON f.id=r.file_id ORDER BY f.path, r.line LIMIT 300"""
419
+ ).fetchall())
420
+ models = _dicts(store.conn.execute(
421
+ """SELECT s.name,s.qualified_name,s.kind,f.path,s.start_line FROM symbols s
422
+ JOIN files f ON f.id=s.file_id WHERE s.tags LIKE '%db_model%'
423
+ ORDER BY f.path, s.start_line LIMIT 300"""
424
+ ).fetchall())
425
+
426
+ hubs = sorted(
427
+ ({"path": p, "inbound": n, "outbound": out_deg.get(p, 0)} for p, n in in_deg.items()),
428
+ key=lambda x: (-x["inbound"], x["path"]),
429
+ )[:10]
430
+
431
+ return {
432
+ "root": stats.get("root"),
433
+ "indexed_at": stats.get("indexed_at"),
434
+ "files": stats["files"],
435
+ "symbols": stats["symbols"],
436
+ "internal_edges": len(edges),
437
+ "unresolved_imports": unresolved,
438
+ "dynamic_imports": dynamic,
439
+ "languages": stats["languages"],
440
+ "symbol_kinds": stats["symbol_kinds"],
441
+ "layers": dict(sorted(layers.items(), key=lambda kv: -kv[1])),
442
+ "top_directories": dict(sorted(top_dirs.items(), key=lambda kv: -kv[1]["files"])),
443
+ "dependency_hubs": hubs,
444
+ "entry_points": entries[:50],
445
+ "http_routes": [{
446
+ "method": r["method"], "path": r["path"], "handler": r["handler"],
447
+ "file": r["file"], "line": r["line"], "framework": r["framework"],
448
+ "confidence": r["confidence"],
449
+ "evidence": {"extractor": r["framework"], "location": f"{r['file']}:{r['line']}"},
450
+ } for r in routes],
451
+ "database_models": [{
452
+ "name": m["name"], "qualified_name": m["qualified_name"], "kind": m["kind"],
453
+ "path": m["path"], "line": m["start_line"],
454
+ "confidence": "heuristic",
455
+ "evidence": {"method": "base-class-name-heuristic",
456
+ "location": f"{m['path']}:{m['start_line']}"},
457
+ } for m in models],
458
+ "parse_errors": _dicts(store.conn.execute(
459
+ "SELECT path,parse_error FROM files WHERE parsed=0 ORDER BY path LIMIT 50"
460
+ ).fetchall()),
461
+ "limitations": [
462
+ "Layer names are path heuristics, not framework semantics.",
463
+ "Routes and models are detected from decorator/base-class patterns; other frameworks are not recognized.",
464
+ "Only statically resolvable imports are counted. Dynamic imports, reflection and runtime DI are not.",
465
+ "`internal_edges` is a resolved-import count, not a census: unresolved and dynamic imports are reported separately and can hide real dependencies.",
466
+ ],
467
+ }
468
+
469
+
470
+ def quick_status(store: Store, settings: Settings) -> dict:
471
+ stats = store.stats()
472
+ existing = store.existing_files()
473
+ scanned = {e.path: e for e in scan(settings.root, settings).files}
474
+ added = len(set(scanned) - set(existing))
475
+ deleted = len(set(existing) - set(scanned))
476
+ changed = sum(
477
+ 1 for p, e in scanned.items()
478
+ if p in existing and (e.size != existing[p].size or e.mtime != existing[p].mtime)
479
+ )
480
+ return {
481
+ **stats,
482
+ "db_bytes": sum(p.stat().st_size for p in (settings.db_path, Path(str(settings.db_path) + "-wal")) if p.exists()),
483
+ "stale": {"added": added, "changed": changed, "deleted": deleted},
484
+ "needs_reindex": bool(added or deleted or changed),
485
+ }