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