kompactor 0.5.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.

Potentially problematic release.


This version of kompactor might be problematic. Click here for more details.

kompactor/graph.py ADDED
@@ -0,0 +1,1398 @@
1
+ """Code graph: files, symbols and the references between them.
2
+
3
+ Standard library only. Python is parsed with `ast`; Go and TypeScript /
4
+ JavaScript / React with targeted regexes over comment- and string-free source.
5
+ The graph lives in SQLite per project and is updated incrementally: only files
6
+ whose mtime or size changed are re-parsed.
7
+
8
+ Units: a Python or TS/JS module is one file; a Go package is one directory.
9
+ Edges between units come from resolved imports; symbol references (calls, JSX
10
+ renders, inheritance) are stored by name and resolved at query time.
11
+ """
12
+
13
+ import ast
14
+ import hashlib
15
+ import json
16
+ import math
17
+ import os
18
+ import re
19
+ import sqlite3
20
+ import time
21
+ from collections import Counter, defaultdict, deque
22
+ from pathlib import Path
23
+
24
+ from . import langs, outline, store, tokens
25
+
26
+ SCHEMA = """
27
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
28
+ CREATE TABLE IF NOT EXISTS files (
29
+ path TEXT PRIMARY KEY, lang TEXT, unit TEXT, mtime_ns INTEGER, size INTEGER, lines INTEGER,
30
+ is_test INTEGER, is_generated INTEGER
31
+ );
32
+ CREATE TABLE IF NOT EXISTS symbols (
33
+ id INTEGER PRIMARY KEY, file TEXT, name TEXT, kind TEXT, parent TEXT, start INTEGER, end INTEGER,
34
+ signature TEXT, doc TEXT, exported INTEGER
35
+ );
36
+ CREATE INDEX IF NOT EXISTS symbols_name ON symbols(name);
37
+ CREATE INDEX IF NOT EXISTS symbols_file ON symbols(file);
38
+ CREATE TABLE IF NOT EXISTS refs (file TEXT, scope TEXT, name TEXT, kind TEXT, line INTEGER);
39
+ CREATE INDEX IF NOT EXISTS refs_name ON refs(name);
40
+ CREATE INDEX IF NOT EXISTS refs_file ON refs(file);
41
+ CREATE TABLE IF NOT EXISTS imports (file TEXT, target TEXT, resolved TEXT, names TEXT, type_only INTEGER DEFAULT 0);
42
+ CREATE TABLE IF NOT EXISTS idents (file TEXT, name TEXT, count INTEGER);
43
+ CREATE INDEX IF NOT EXISTS idents_name ON idents(name);
44
+ CREATE INDEX IF NOT EXISTS imports_file ON imports(file);
45
+ CREATE INDEX IF NOT EXISTS imports_resolved ON imports(resolved);
46
+ """
47
+
48
+ LANG_OF = {".py": "python", ".pyi": "python", ".go": "go", ".ts": "ts", ".tsx": "ts", ".js": "ts", ".jsx": "ts",
49
+ ".mjs": "ts", ".cjs": "ts", ".mts": "ts", ".cts": "ts"}
50
+ MAX_FILE_BYTES = 600_000
51
+ TEST_FILE = re.compile(r"(^|/)(tests?|__tests__|test-d|spec|specs|e2e|testdata)(/|$)|[._-](test|spec)\.\w+$|"
52
+ r"(^|/)test_[^/]+\.py$|_test\.go$|(^|/)conftest\.py$")
53
+ AUX_FILE = re.compile(r"(^|/)(examples?|docs?|benchmarks?|bench|scripts?|fixtures?|demo|samples?)(/|$)")
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # storage
57
+
58
+
59
+ def db_path(root: str) -> Path:
60
+ root = str(Path(root).resolve())
61
+ name = re.sub(r"[^\w.-]", "_", Path(root).name)[:40] or "root"
62
+ d = store.home() / "graphs"
63
+ d.mkdir(parents=True, exist_ok=True)
64
+ return d / f"{name}-{hashlib.sha1(root.encode()).hexdigest()[:10]}.db"
65
+
66
+
67
+ class _Conn(sqlite3.Connection):
68
+ def __exit__(self, *exc):
69
+ try:
70
+ return super().__exit__(*exc)
71
+ finally:
72
+ self.close()
73
+
74
+
75
+ SCHEMA_VERSION = "3"
76
+
77
+
78
+ def connect(root: str) -> _Conn:
79
+ path = db_path(root)
80
+ if path.exists(): # the graph is a cache: rebuild it when the schema changes
81
+ try:
82
+ with sqlite3.connect(path, factory=_Conn) as probe:
83
+ row = probe.execute("SELECT value FROM meta WHERE key='schema'").fetchone()
84
+ if not row or row[0] != SCHEMA_VERSION:
85
+ for suffix in ("", "-wal", "-shm"):
86
+ Path(str(path) + suffix).unlink(missing_ok=True)
87
+ except sqlite3.Error:
88
+ path.unlink(missing_ok=True)
89
+ db = sqlite3.connect(path, timeout=20, factory=_Conn)
90
+ db.execute("PRAGMA journal_mode=WAL")
91
+ db.execute("PRAGMA synchronous=NORMAL")
92
+ db.executescript(SCHEMA)
93
+ db.execute("INSERT OR IGNORE INTO meta VALUES ('schema', ?)", (SCHEMA_VERSION,))
94
+ return db
95
+
96
+
97
+ def exists(root: str) -> bool:
98
+ p = db_path(root)
99
+ if not p.exists():
100
+ return False
101
+ with connect(root) as db:
102
+ return db.execute("SELECT value FROM meta WHERE key='built'").fetchone() is not None
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # helpers
107
+
108
+ _COMMENT_STRING = re.compile(
109
+ r"//[^\n]*|/\*.*?\*/|`(?:\\.|[^`\\])*`|\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*'", re.S)
110
+
111
+
112
+ def _blank(src: str) -> str:
113
+ """Replace comments and string literals with spaces, keeping line numbers."""
114
+ return _COMMENT_STRING.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), src)
115
+
116
+
117
+ def _first_doc_line(text: str) -> str:
118
+ for ln in (text or "").strip().splitlines():
119
+ ln = ln.strip().strip("/*# ").strip()
120
+ if ln:
121
+ return ln[:120]
122
+ return ""
123
+
124
+
125
+ def _scope_at(spans, line: int) -> str:
126
+ """Innermost symbol (by span) containing a line."""
127
+ best, size = "", 1 << 30
128
+ for start, end, qual in spans:
129
+ if start <= line <= end and end - start < size:
130
+ best, size = qual, end - start
131
+ return best
132
+
133
+
134
+ def split_words(name: str):
135
+ """camelCase / snake_case / kebab-case -> lowercase words."""
136
+ parts = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", name))
137
+ return [w.lower() for w in re.split(r"[^A-Za-z0-9]+", parts) if w]
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Python
142
+
143
+
144
+ def _py_sig(node, lines):
145
+ strip = lambda ln: re.sub(r"\s+#.*$", "", ln).strip() # noqa: E731 (drop trailing comments)
146
+ head = strip(lines[node.lineno - 1])
147
+ i = node.lineno
148
+ while not head.endswith(":") and i < len(lines) and i < node.lineno + 8:
149
+ head += " " + strip(lines[i])
150
+ i += 1
151
+ return re.sub(r"\s+", " ", head)[:200]
152
+
153
+
154
+ def _attr_tail(node):
155
+ if isinstance(node, ast.Name):
156
+ return node.id
157
+ if isinstance(node, ast.Attribute):
158
+ return node.attr
159
+ if isinstance(node, ast.Call):
160
+ return _attr_tail(node.func)
161
+ if isinstance(node, ast.Subscript):
162
+ return _attr_tail(node.value)
163
+ return None
164
+
165
+
166
+ def _is_type_checking(test) -> bool:
167
+ return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or \
168
+ (isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING")
169
+
170
+
171
+ def parse_python(path: str, src: str):
172
+ tree = ast.parse(src)
173
+ lines = src.splitlines()
174
+ syms, refs, imps, alias = [], [], [], {}
175
+ type_only = [0]
176
+
177
+ def walk(node, scope, cls):
178
+ for n in ast.iter_child_nodes(node):
179
+ if isinstance(n, ast.If) and _is_type_checking(n.test):
180
+ type_only[0] += 1
181
+ for child in n.body:
182
+ walk(ast.Module(body=[child], type_ignores=[]), scope, cls)
183
+ type_only[0] -= 1
184
+ for child in n.orelse:
185
+ walk(ast.Module(body=[child], type_ignores=[]), scope, cls)
186
+ continue
187
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
188
+ nested = bool(scope) and not cls # a def inside a function body
189
+ qual = f"{cls}.{n.name}" if cls else (f"{scope}.{n.name}" if nested else n.name)
190
+ deco = "".join(f"@{_attr_tail(d) or ast.unparse(d)} " for d in n.decorator_list)
191
+ syms.append((n.name, "method" if cls else "function", cls or (scope if nested else ""), n.lineno,
192
+ n.end_lineno, deco + _py_sig(n, lines), _first_doc_line(ast.get_docstring(n) or ""),
193
+ int(not n.name.startswith("_") and not nested)))
194
+ for d in n.decorator_list:
195
+ t = _attr_tail(d)
196
+ if t:
197
+ refs.append((qual, alias.get(t, t), "decorate", n.lineno))
198
+ walk(n, qual, None)
199
+ elif isinstance(n, ast.ClassDef):
200
+ bases = ", ".join(ast.unparse(b) for b in n.bases)
201
+ syms.append((n.name, "class", cls or "", n.lineno, n.end_lineno,
202
+ f"class {n.name}({bases})" if bases else f"class {n.name}",
203
+ _first_doc_line(ast.get_docstring(n) or ""), int(not n.name.startswith("_"))))
204
+ for b in n.bases:
205
+ t = _attr_tail(b)
206
+ if t:
207
+ refs.append((n.name, alias.get(t, t), "inherit", n.lineno))
208
+ walk(n, n.name, n.name)
209
+ elif isinstance(n, ast.Import):
210
+ for a in n.names:
211
+ imps.append((a.name, 0, "", int(type_only[0] > 0)))
212
+ alias[a.asname or a.name.split(".")[0]] = a.name.split(".")[-1]
213
+ walk(n, scope, cls)
214
+ elif isinstance(n, ast.ImportFrom):
215
+ names = [a.name for a in n.names]
216
+ imps.append((n.module or "", n.level, ",".join(names), int(type_only[0] > 0)))
217
+ for a in n.names:
218
+ if a.asname:
219
+ alias[a.asname] = a.name
220
+ walk(n, scope, cls)
221
+ else:
222
+ if isinstance(n, ast.Call):
223
+ t = _attr_tail(n.func)
224
+ if t:
225
+ refs.append((scope, alias.get(t, t), "call", n.lineno))
226
+ walk(n, scope, cls)
227
+
228
+ walk(tree, "", None)
229
+ idents = Counter()
230
+ for n in ast.walk(tree):
231
+ if isinstance(n, ast.Name):
232
+ idents[n.id] += 1
233
+ elif isinstance(n, ast.Attribute):
234
+ idents[n.attr] += 1
235
+ elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
236
+ idents[n.name] += 1
237
+ elif isinstance(n, ast.alias):
238
+ idents[n.name.split(".")[-1]] += 1
239
+ if n.asname:
240
+ idents[n.asname] += 1
241
+ elif isinstance(n, ast.Constant) and isinstance(n.value, str) and re.fullmatch(r"[A-Za-z_]\w*", n.value):
242
+ idents[n.value] += 1 # __all__ entries, getattr(obj, "name"), string registries
243
+ IDENTS[path] = idents
244
+ for n in tree.body: # module-level CONSTANTS are symbols too
245
+ if isinstance(n, (ast.Assign, ast.AnnAssign)):
246
+ targets = n.targets if isinstance(n, ast.Assign) else [n.target]
247
+ for t in targets:
248
+ if isinstance(t, ast.Name) and re.match(r"^[A-Z][A-Z0-9_]+$", t.id):
249
+ syms.append((t.id, "constant", "", n.lineno, n.end_lineno, lines[n.lineno - 1].strip()[:160], "",
250
+ int(not t.id.startswith("_"))))
251
+ return syms, refs, imps
252
+
253
+
254
+ def _py_module(rel: str) -> str:
255
+ mod = rel[:-3] if rel.endswith(".py") else rel[:-4] if rel.endswith(".pyi") else rel
256
+ mod = mod.replace("/", ".")
257
+ return mod[:-9] if mod.endswith(".__init__") else mod
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Go
262
+
263
+ GO_FUNC = re.compile(r"^func\s+(?:\(\s*\w*\s*\*?\s*([\w]+)(?:\[[^\]]*\])?\s*\)\s*)?([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*\(",
264
+ re.M)
265
+ GO_TYPE = re.compile(r"^(?:type\s+|\t)([A-Za-z_]\w*)(?:\[[^\]]*\])?\s+(struct|interface)\b", re.M)
266
+ GO_TYPE_ALIAS = re.compile(r"^type\s+([A-Za-z_]\w*)\s+(?!struct|interface)([\w.*\[\]]+)", re.M)
267
+ GO_IMPORT_ONE = re.compile(r'^import\s+(?:([\w.]+)\s+)?"([^"]+)"', re.M)
268
+ GO_IMPORT_BLOCK = re.compile(r"^import\s*\((.*?)^\)", re.M | re.S)
269
+ GO_IMPORT_LINE = re.compile(r'^\s*(?:([\w.]+)\s+)?"([^"]+)"', re.M)
270
+ GO_KEYWORDS = {"if", "for", "switch", "func", "return", "make", "len", "append", "cap", "new", "panic", "recover",
271
+ "print", "println", "copy", "delete", "close", "complex", "real", "imag", "string", "int", "int64",
272
+ "int32", "uint", "uint64", "uint32", "byte", "rune", "float64", "float32", "bool", "error", "select",
273
+ "go", "defer", "range", "map", "chan", "struct", "interface", "type", "var", "const", "min", "max",
274
+ "clear", "uintptr", "any"}
275
+
276
+
277
+ IDENTS = {} # path -> Counter of identifier occurrences, filled by the parsers
278
+ IDENT = re.compile(r"[A-Za-z_$][\w$]*")
279
+
280
+
281
+ def _regex_idents(path: str, clean: str, src: str) -> None:
282
+ counts = Counter(IDENT.findall(clean))
283
+ for lit in re.findall(r"""["'`]([A-Za-z_$][\w$]*)["'`]""", src): # string registries / reflection
284
+ counts[lit] += 1
285
+ IDENTS[path] = counts
286
+
287
+
288
+ def parse_go(path: str, src: str):
289
+ lines = src.splitlines()
290
+ clean = _blank(src)
291
+ _regex_idents(path, clean, src)
292
+ syms, refs, imps = [], [], []
293
+ for m in GO_IMPORT_ONE.finditer(src):
294
+ imps.append((m.group(2), 0, m.group(1) or "", 0))
295
+ for b in GO_IMPORT_BLOCK.finditer(src):
296
+ for m in GO_IMPORT_LINE.finditer(b.group(1)):
297
+ imps.append((m.group(2), 0, m.group(1) or "", 0))
298
+ aliases = {}
299
+ for target, _, al, _ in imps:
300
+ aliases[al or target.rstrip("/").split("/")[-1]] = target
301
+ spans = []
302
+ for m in GO_FUNC.finditer(clean):
303
+ line = clean.count("\n", 0, m.start()) + 1
304
+ recv, name = m.group(1), m.group(2)
305
+ end = outline._block_end(lines, line)
306
+ qual = f"{recv}.{name}" if recv else name
307
+ doc = _go_doc(lines, line)
308
+ syms.append((name, "method" if recv else "function", recv or "", line, end, lines[line - 1].strip().rstrip("{").strip()[:200],
309
+ doc, int(name[:1].isupper())))
310
+ spans.append((line, end, qual))
311
+ for m in GO_TYPE.finditer(clean):
312
+ line = clean.count("\n", 0, m.start()) + 1
313
+ end = outline._block_end(lines, line)
314
+ syms.append((m.group(1), m.group(2), "", line, end, lines[line - 1].strip().rstrip("{").strip()[:200],
315
+ _go_doc(lines, line), int(m.group(1)[:1].isupper())))
316
+ for m in GO_TYPE_ALIAS.finditer(clean):
317
+ line = clean.count("\n", 0, m.start()) + 1
318
+ syms.append((m.group(1), "type", "", line, line, lines[line - 1].strip()[:200], _go_doc(lines, line),
319
+ int(m.group(1)[:1].isupper())))
320
+ for i, ln in enumerate(clean.splitlines(), 1):
321
+ scope = None
322
+ for m in re.finditer(r"(?<![\w.])([A-Za-z_]\w*)\s*\.\s*([A-Za-z_]\w*)\s*\(", ln):
323
+ scope = scope if scope is not None else _scope_at(spans, i)
324
+ refs.append((scope, m.group(2), "call", i))
325
+ for m in re.finditer(r"(?<![\w.])([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*\(", ln):
326
+ name = m.group(1)
327
+ if name in GO_KEYWORDS or ln.lstrip().startswith("func ") and ln.find(name) < ln.find("("):
328
+ continue
329
+ scope = scope if scope is not None else _scope_at(spans, i)
330
+ refs.append((scope, name, "call", i))
331
+ for m in re.finditer(r"&?([A-Z]\w*)\s*\{", ln):
332
+ scope = scope if scope is not None else _scope_at(spans, i)
333
+ refs.append((scope, m.group(1), "use", i))
334
+ return syms, refs, imps
335
+
336
+
337
+ def _go_doc(lines, line):
338
+ i, doc = line - 2, []
339
+ while i >= 0 and lines[i].strip().startswith("//"):
340
+ doc.insert(0, lines[i].strip()[2:].strip())
341
+ i -= 1
342
+ return doc[0][:120] if doc else ""
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # TypeScript / JavaScript / React
347
+
348
+ TS_IMPORT = re.compile(r"""(?:^|[;\s])(?:import|export)\s+(type\s+)?(?:([\w*{}\s,$]+?)\s+from\s+)?['"]([^'"\n]+)['"]""",
349
+ re.M)
350
+ TS_REQUIRE = re.compile(r"""(?:require|import)\s*\(\s*['"]([^'"\n]+)['"]\s*\)""")
351
+ TS_KEYWORDS = {"if", "for", "while", "switch", "catch", "return", "function", "typeof", "new", "await", "super", "import",
352
+ "export", "delete", "void", "yield", "in", "of", "else", "do", "try", "throw", "case", "async", "constructor",
353
+ "Array", "Object", "String", "Number", "Boolean", "Promise", "Error", "Symbol", "Map", "Set", "JSON",
354
+ "Math", "Date", "RegExp", "parseInt", "parseFloat", "require", "console", "describe", "it", "test",
355
+ "expect", "beforeEach", "afterEach", "beforeAll", "afterAll", "get", "set", "as", "satisfies", "keyof"}
356
+ TS_DECL = re.compile(
357
+ r"^(?P<ind>[ \t]*)(?P<exp>export\s+(?:default\s+)?)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?"
358
+ r"(?P<kind>function\*?|class|interface|type|enum|const|let|var)\s+(?P<name>[A-Za-z_$][\w$]*)", re.M)
359
+ TS_METHOD = re.compile(r"^(?P<ind>[ \t]+)(?:(?:public|private|protected|static|async|override|readonly|get|set|abstract)\s+)*"
360
+ r"(?P<name>#?[A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\s*\([^;{]*\)\s*(?::\s*[^={;]+)?\s*\{\s*$", re.M)
361
+
362
+
363
+ def parse_ts(path: str, src: str):
364
+ lines = src.splitlines()
365
+ clean = _blank(src)
366
+ _regex_idents(path, clean, src)
367
+ clines = clean.splitlines()
368
+ syms, refs, imps, spans = [], [], [], []
369
+ for m in TS_IMPORT.finditer(src):
370
+ imps.append((m.group(3), 0, _ts_import_names(m.group(2) or ""), int(bool(m.group(1)))))
371
+ for m in TS_REQUIRE.finditer(src):
372
+ imps.append((m.group(1), 0, "", 0))
373
+ is_jsx = path.endswith((".tsx", ".jsx"))
374
+ exported = set(re.findall(r"^export\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s*$", clean, re.M))
375
+ for m in re.finditer(r"^export\s*\{([^}]*)\}", clean, re.M):
376
+ exported |= {p.split(" as ")[0].strip() for p in m.group(1).split(",") if p.strip()}
377
+ for m in TS_DECL.finditer(clean):
378
+ nested = len(m.group("ind").expandtabs(4)) > 0
379
+ if nested and m.group("kind") in ("const", "let", "var"):
380
+ continue
381
+ line = clean.count("\n", 0, m.start()) + 1
382
+ name, kind = m.group("name"), m.group("kind").rstrip("*")
383
+ text = lines[line - 1]
384
+ is_exported = (bool(m.group("exp")) or name in exported) and not nested
385
+ if kind in ("const", "let", "var"):
386
+ if re.search(r"=\s*(async\s*)?(\([^)]*\)|[\w$]+)\s*(:\s*[^=]+)?=>|=\s*(async\s+)?function\b|"
387
+ r"=\s*(React\.)?(memo|forwardRef|lazy)\(|=\s*styled[.(]|=\s*create\w*\(", text):
388
+ kind = "function"
389
+ elif re.search(r"=\s*[\w$.]+\s*(<[^>]*>)?\s*\(", text) and (
390
+ is_exported or re.match(r"use[A-Z]", name) or (is_jsx and name[:1].isupper())):
391
+ kind = "function" # factory-made: const useSWR = withArgs(handler)
392
+ elif not is_exported:
393
+ continue
394
+ else:
395
+ kind = "constant"
396
+ if kind == "function" and name[:1].isupper() and is_jsx:
397
+ kind = "component"
398
+ elif kind == "function" and re.match(r"use[A-Z]", name):
399
+ kind = "hook"
400
+ end = outline._block_end(lines, line)
401
+ if syms and syms[-1][0] == name and syms[-1][1] == kind == "function":
402
+ syms.pop() # TS overload signatures: keep the implementation (last) only
403
+ syms.append((name, kind, "", line, end, text.strip().rstrip("{").strip()[:200], _ts_doc(lines, line),
404
+ int(is_exported)))
405
+ spans.append((line, end, name))
406
+ cls_spans = [(s[3], s[4], s[0]) for s in syms if s[1] == "class"]
407
+ for m in TS_METHOD.finditer(clean):
408
+ name = m.group("name")
409
+ if name in TS_KEYWORDS and name != "constructor":
410
+ continue
411
+ line = clean.count("\n", 0, m.start()) + 1
412
+ cls = _scope_at(cls_spans, line)
413
+ if not cls:
414
+ continue
415
+ end = outline._block_end(lines, line)
416
+ syms.append((name, "method", cls, line, end, lines[line - 1].strip().rstrip("{").strip()[:200],
417
+ _ts_doc(lines, line), int(not name.startswith(("#", "_")))))
418
+ spans.append((line, end, f"{cls}.{name}"))
419
+ for m in re.finditer(r"\bclass\s+(\w+)[^{\n]*?\b(extends|implements)\s+([\w$.]+)", clean):
420
+ refs.append((m.group(1), m.group(3).split(".")[-1], "inherit", clean.count("\n", 0, m.start()) + 1))
421
+ for i, ln in enumerate(clines, 1):
422
+ scope = None
423
+ for m in re.finditer(r"(?<![\w$])(?:\.\s*)?([A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>)?\s*\(", ln):
424
+ name = m.group(1)
425
+ if name in TS_KEYWORDS:
426
+ continue
427
+ if re.match(r"\s*(export\s+)?(default\s+)?(async\s+)?function\b", ln) and ln.find(name) < ln.find("("):
428
+ continue
429
+ scope = scope if scope is not None else _scope_at(spans, i)
430
+ refs.append((scope, name, "call", i))
431
+ for m in re.finditer(r"<([A-Z][\w$]*)(?:\.[\w$]+)*[\s/>]", ln):
432
+ scope = scope if scope is not None else _scope_at(spans, i)
433
+ refs.append((scope, m.group(1), "render", i))
434
+ return syms, refs, imps
435
+
436
+
437
+ def _ts_import_names(spec: str) -> str:
438
+ names = []
439
+ for part in re.split(r"[{},]", spec):
440
+ part = part.strip()
441
+ if not part or part.startswith("*"):
442
+ continue
443
+ names.append(part.split(" as ")[0].replace("type ", "").strip())
444
+ return ",".join(n for n in names if n)
445
+
446
+
447
+ def _ts_doc(lines, line):
448
+ i = line - 2
449
+ while i >= 0 and lines[i].strip().startswith(("*", "/**", "//")):
450
+ i -= 1
451
+ block = "\n".join(lines[i + 1:line - 1])
452
+ return _first_doc_line(re.sub(r"@\w+.*", "", block))
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # resolution of imports to units
457
+
458
+
459
+ class Resolver:
460
+ def __init__(self, root: str, files, all_paths=()):
461
+ self.root = root
462
+ self.files = set(files)
463
+ self.ts_packages = [] # (package name, directory) for the repo and its workspaces
464
+ for pj in all_paths:
465
+ if os.path.basename(pj) == "package.json" and "node_modules/" not in pj:
466
+ try:
467
+ name = json.loads(Path(root, pj).read_text(errors="replace")).get("name")
468
+ except (OSError, ValueError, AttributeError):
469
+ continue
470
+ if isinstance(name, str) and name:
471
+ self.ts_packages.append((name, os.path.dirname(pj)))
472
+ self.ts_packages.sort(key=lambda x: -len(x[0]))
473
+ self.py_index = {}
474
+ # nested Python projects (monorepos, examples/): modules are importable relative to them
475
+ dirs = set()
476
+ for f in files:
477
+ if f.endswith((".py", ".pyi")):
478
+ d = os.path.dirname(f)
479
+ while d and d not in dirs:
480
+ dirs.add(d)
481
+ d = os.path.dirname(d)
482
+ py_roots = sorted((d for d in dirs if any(Path(root, d, m).exists() for m in ("pyproject.toml", "setup.py",
483
+ "setup.cfg"))),
484
+ key=len, reverse=True)
485
+ for f in files:
486
+ if f.endswith((".py", ".pyi")):
487
+ mod = _py_module(f)
488
+ self.py_index.setdefault(mod, f)
489
+ for pr in py_roots:
490
+ if f.startswith(pr + "/"):
491
+ self.py_index.setdefault(_py_module(f[len(pr) + 1:]), f)
492
+ for prefix in ("src.", "lib.", "python."):
493
+ for m in (mod, *(_py_module(f[len(pr) + 1:]) for pr in py_roots if f.startswith(pr + "/"))):
494
+ if m.startswith(prefix):
495
+ self.py_index.setdefault(m[len(prefix):], f)
496
+ self.go_module = ""
497
+ gm = Path(root, "go.mod")
498
+ if gm.exists():
499
+ m = re.search(r"^module\s+(\S+)", gm.read_text(errors="replace"), re.M)
500
+ self.go_module = m.group(1) if m else ""
501
+ self.go_dirs = {os.path.dirname(f) or "." for f in files if f.endswith(".go")}
502
+ self.ts_paths = self._tsconfig_paths()
503
+
504
+ def _tsconfig_paths(self):
505
+ for name in ("tsconfig.json", "jsconfig.json", "tsconfig.base.json"):
506
+ p = Path(self.root, name)
507
+ if not p.exists():
508
+ continue
509
+ txt = re.sub(r"//[^\n]*|/\*.*?\*/", "", p.read_text(errors="replace"), flags=re.S)
510
+ txt = re.sub(r",(\s*[}\]])", r"\1", txt)
511
+ try:
512
+ opts = json.loads(txt).get("compilerOptions", {})
513
+ except ValueError:
514
+ return []
515
+ base = opts.get("baseUrl", ".")
516
+ out = []
517
+ for pat, targets in (opts.get("paths") or {}).items():
518
+ for t in targets[:1]:
519
+ out.append((pat, os.path.normpath(os.path.join(base, t))))
520
+ return out
521
+ return []
522
+
523
+ def unit(self, f: str) -> str:
524
+ return (os.path.dirname(f) or ".") + "/" if f.endswith(".go") else f
525
+
526
+ def resolve(self, f: str, target: str, level: int, names: str):
527
+ if f.endswith((".py", ".pyi")):
528
+ return self._py(f, target, level, names)
529
+ if f.endswith(".go"):
530
+ if self.go_module and (target == self.go_module or target.startswith(self.go_module + "/")):
531
+ d = target[len(self.go_module):].strip("/") or "."
532
+ return d + "/" if d in self.go_dirs else None
533
+ return None
534
+ return self._ts(f, target)
535
+
536
+ def _py(self, f, target, level, names):
537
+ if level:
538
+ pkg = _py_module(f).split(".")
539
+ if not f.endswith("__init__.py"):
540
+ pkg = pkg[:-1]
541
+ pkg = pkg[:len(pkg) - (level - 1)] if level > 1 else pkg
542
+ base = ".".join(pkg + ([target] if target else []))
543
+ else:
544
+ base = target
545
+ for n in (names or "").split(","):
546
+ if n and f"{base}.{n}" in self.py_index:
547
+ return self.py_index[f"{base}.{n}"]
548
+ parts = base.split(".")
549
+ while parts:
550
+ mod = ".".join(parts)
551
+ if mod in self.py_index:
552
+ return self.py_index[mod]
553
+ parts.pop()
554
+ return None
555
+
556
+ def _ts(self, f, target):
557
+ cands = []
558
+ if target.startswith("."):
559
+ cands.append(os.path.normpath(os.path.join(os.path.dirname(f), target)))
560
+ else:
561
+ for pat, dest in self.ts_paths:
562
+ if pat.endswith("*") and target.startswith(pat[:-1]):
563
+ cands.append(os.path.normpath(dest.replace("*", target[len(pat) - 1:])))
564
+ elif pat == target:
565
+ cands.append(dest)
566
+ if target.startswith("@/") or target.startswith("~/"):
567
+ cands.append(os.path.normpath("src/" + target[2:]))
568
+ for name, d in self.ts_packages: # the repo's own package name / workspace packages
569
+ if target == name or target.startswith(name + "/"):
570
+ sub = target[len(name):].lstrip("/")
571
+ if sub:
572
+ cands += [os.path.join(d, sub), os.path.join(d, "src", sub)]
573
+ else:
574
+ cands += [os.path.join(d, "src", "index"), os.path.join(d, "index"), os.path.join(d, "src", "main"),
575
+ os.path.join(d, "src", Path(name).name)]
576
+ break
577
+ for c in cands:
578
+ c = os.path.normpath(c)
579
+ c = c[2:] if c.startswith("./") else c
580
+ for ext in ("", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", "/index.ts", "/index.tsx",
581
+ "/index.js", "/index.jsx"):
582
+ if c + ext in self.files:
583
+ return c + ext
584
+ if c.endswith(".js") and c[:-3] + ".ts" in self.files: # ESM-style ".js" import of a .ts file
585
+ return c[:-3] + ".ts"
586
+ return None
587
+
588
+
589
+ # ---------------------------------------------------------------------------
590
+ # build
591
+
592
+
593
+ def build(root: str, quiet: bool = True, full: bool = False) -> dict:
594
+ """Parse new/changed files and refresh the graph. Returns build stats."""
595
+ root = str(Path(root).resolve())
596
+ t0 = time.time()
597
+ listed = outline.list_files(root)
598
+ all_files = [f for f in listed if Path(f).suffix in outline.SOURCE_EXT]
599
+ gen_re = langs.generated_re(list(langs.PROFILES))
600
+ resolver = Resolver(root, all_files, listed)
601
+ parsed = skipped = errors = 0
602
+ with connect(root) as db:
603
+ if full:
604
+ for t in ("files", "symbols", "refs", "imports", "idents"):
605
+ db.execute(f"DELETE FROM {t}")
606
+ known = {r[0]: (r[1], r[2]) for r in db.execute("SELECT path, mtime_ns, size FROM files")}
607
+ current = set(all_files)
608
+ for gone in set(known) - current:
609
+ _forget(db, gone)
610
+ for f in all_files:
611
+ p = os.path.join(root, f)
612
+ try:
613
+ st = os.stat(p)
614
+ except OSError:
615
+ continue
616
+ if known.get(f) == (st.st_mtime_ns, st.st_size):
617
+ skipped += 1
618
+ continue
619
+ _forget(db, f)
620
+ lang = LANG_OF.get(Path(f).suffix, "other")
621
+ try:
622
+ src = Path(p).read_text(errors="replace") if st.st_size <= MAX_FILE_BYTES else ""
623
+ except OSError:
624
+ continue
625
+ syms, refs, imps = [], [], []
626
+ if src:
627
+ try:
628
+ if lang == "python":
629
+ syms, refs, imps = parse_python(f, src)
630
+ elif lang == "go":
631
+ syms, refs, imps = parse_go(f, src)
632
+ elif lang == "ts":
633
+ syms, refs, imps = parse_ts(f, src)
634
+ else:
635
+ syms = [(_sym_name(sig) or sig[:40], "symbol", "", s, e, sig, "", 1)
636
+ for s, e, d, sig in outline.outline(f, src) if d == 0]
637
+ except (SyntaxError, ValueError, RecursionError):
638
+ errors += 1
639
+ syms = [(_sym_name(sig) or sig[:40], "symbol", "", s, e, sig, "", 1)
640
+ for s, e, d, sig in outline.outline(f, src) if d <= 1]
641
+ db.execute("INSERT INTO files VALUES (?,?,?,?,?,?,?,?)",
642
+ (f, lang, resolver.unit(f), st.st_mtime_ns, st.st_size, src.count("\n") + 1 if src else 0,
643
+ int(bool(TEST_FILE.search(f))), int(bool(gen_re and gen_re.search(f)))))
644
+ db.executemany("INSERT INTO symbols (file, name, kind, parent, start, end, signature, doc, exported) "
645
+ "VALUES (?,?,?,?,?,?,?,?,?)", [(f, *s) for s in syms])
646
+ db.executemany("INSERT INTO refs VALUES (?,?,?,?,?)", [(f, *r) for r in refs])
647
+ db.executemany("INSERT INTO imports VALUES (?,?,?,?,?)",
648
+ [(f, t, resolver.resolve(f, t, lvl, names), names, tonly) for t, lvl, names, tonly in imps])
649
+ db.executemany("INSERT INTO idents VALUES (?,?,?)", [(f, n, c) for n, c in IDENTS.pop(f, {}).items()])
650
+ parsed += 1
651
+ stats = {"files": len(all_files), "parsed": parsed, "unchanged": skipped, "errors": errors,
652
+ "seconds": round(time.time() - t0, 2), "built": time.time()}
653
+ db.execute("INSERT OR REPLACE INTO meta VALUES ('built', ?)", (json.dumps(stats),))
654
+ db.execute("INSERT OR REPLACE INTO meta VALUES ('root', ?)", (root,))
655
+ if not quiet:
656
+ print(f"graph: {len(all_files)} files ({parsed} parsed, {skipped} unchanged) in {stats['seconds']}s "
657
+ f"· {db_path(root)}")
658
+ return stats
659
+
660
+
661
+ def _forget(db, f):
662
+ for t, col in (("files", "path"), ("symbols", "file"), ("refs", "file"), ("imports", "file"), ("idents", "file")):
663
+ db.execute(f"DELETE FROM {t} WHERE {col}=?", (f,))
664
+
665
+
666
+ def _sym_name(sig: str):
667
+ return outline._name(sig)
668
+
669
+
670
+ # ---------------------------------------------------------------------------
671
+ # loading for queries
672
+
673
+
674
+ CALLABLE = {"function", "method", "component", "hook", "class", "struct"}
675
+ KIND_WEIGHT = {"function": 1.0, "method": 1.0, "component": 1.0, "hook": 1.0, "class": 1.0, "struct": 1.0,
676
+ "interface": 0.8, "type": 0.7, "enum": 0.7, "constant": 0.5, "symbol": 0.6}
677
+
678
+
679
+ class Graph:
680
+ """Read-only, in-memory view used by queries, the report and the dashboard."""
681
+
682
+ def __init__(self, root: str):
683
+ self.root = str(Path(root).resolve())
684
+ with connect(self.root) as db:
685
+ self.meta = json.loads((db.execute("SELECT value FROM meta WHERE key='built'").fetchone() or ["{}"])[0])
686
+ self.files = {r[0]: {"lang": r[1], "unit": r[2], "lines": r[3], "test": bool(r[4]), "generated": bool(r[5])}
687
+ for r in db.execute("SELECT path, lang, unit, lines, is_test, is_generated FROM files")}
688
+ self.symbols = [dict(zip(("id", "file", "name", "kind", "parent", "start", "end", "signature", "doc",
689
+ "exported"), r))
690
+ for r in db.execute("SELECT id, file, name, kind, parent, start, end, signature, doc, "
691
+ "exported FROM symbols")]
692
+ self.refs = db.execute("SELECT file, scope, name, kind, line FROM refs").fetchall()
693
+ self.imports = db.execute("SELECT file, target, resolved, names, type_only FROM imports").fetchall()
694
+ self.by_name = defaultdict(list)
695
+ self.by_file = defaultdict(list)
696
+ for s in self.symbols:
697
+ self.by_name[s["name"]].append(s)
698
+ self.by_file[s["file"]].append(s)
699
+ self.unit_files = defaultdict(list)
700
+ for f, info in self.files.items():
701
+ self.unit_files[info["unit"]].append(f)
702
+ self.out_units = defaultdict(set) # unit -> units it imports
703
+ self.in_units = defaultdict(set) # unit -> units importing it
704
+ self.runtime_out = defaultdict(set) # without type-only imports and package re-exports (for cycles)
705
+ for f, _, resolved, _, type_only in self.imports:
706
+ if resolved and f in self.files:
707
+ src_u = self.files[f]["unit"]
708
+ dst_u = resolved if resolved.endswith("/") else self.files.get(resolved, {}).get("unit", resolved)
709
+ if dst_u != src_u:
710
+ self.out_units[src_u].add(dst_u)
711
+ self.in_units[dst_u].add(src_u)
712
+ if not type_only and not re.search(r"(^|/)(__init__\.pyi?|index\.[cm]?[jt]sx?)$", dst_u):
713
+ self.runtime_out[src_u].add(dst_u)
714
+ self.ref_count = Counter()
715
+ self.ref_files = defaultdict(set)
716
+ for f, _, name, _, _ in self.refs:
717
+ self.ref_count[name] += 1
718
+ self.ref_files[name].add(f)
719
+ self._rank = None
720
+
721
+ # -- ranking -----------------------------------------------------------
722
+ def rank(self):
723
+ """PageRank over the unit import graph (units depended on by many rank high)."""
724
+ if self._rank is None:
725
+ units = set(self.unit_files) | set(self.in_units) | set(self.out_units)
726
+ units = {u for u in units if u in self.unit_files}
727
+ n = len(units) or 1
728
+ pr = {u: 1 / n for u in units}
729
+ for _ in range(30):
730
+ nxt = {u: 0.15 / n for u in units}
731
+ for u in units:
732
+ outs = [v for v in self.out_units.get(u, ()) if v in units]
733
+ if outs:
734
+ share = 0.85 * pr[u] / len(outs)
735
+ for v in outs:
736
+ nxt[v] += share
737
+ else:
738
+ for v in units:
739
+ nxt[v] += 0.85 * pr[u] / n
740
+ pr = nxt
741
+ self._rank = pr
742
+ return self._rank
743
+
744
+ def unit_of(self, f: str) -> str:
745
+ return self.files.get(f, {}).get("unit", f)
746
+
747
+ def is_test_unit(self, u: str) -> bool:
748
+ return all(self.files[f]["test"] for f in self.unit_files.get(u, [])) if self.unit_files.get(u) else False
749
+
750
+ def is_aux_unit(self, u: str) -> bool:
751
+ """Examples, docs, benchmarks, scripts: real code, but not what a task is usually about."""
752
+ return bool(AUX_FILE.search(u))
753
+
754
+ def core_units(self):
755
+ pr = self.rank()
756
+ return [u for u in sorted(pr, key=lambda u: -pr[u]) if not self.is_test_unit(u) and not self.is_aux_unit(u)]
757
+
758
+ # -- lookups ------------------------------------------------------------
759
+ def find(self, target: str):
760
+ """Resolve a user/agent target: a file path, a directory/package, Name or Parent.name."""
761
+ t = target.strip().rstrip("/")
762
+ if t in self.files:
763
+ return "file", t
764
+ if t + "/" in self.unit_files:
765
+ return "unit", t + "/"
766
+ ends = [f for f in self.files if f.endswith("/" + t) or f == t]
767
+ if len(ends) == 1:
768
+ return "file", ends[0]
769
+ if "." in t and not t.endswith((".py", ".go", ".ts", ".tsx", ".js", ".jsx")):
770
+ parent, name = t.rsplit(".", 1)
771
+ hits = [s for s in self.by_name.get(name, []) if s["parent"].split(".")[-1] == parent.split(".")[-1]]
772
+ if hits:
773
+ return "symbol", hits
774
+ hits = self.by_name.get(t)
775
+ if hits:
776
+ return "symbol", hits
777
+ lower = [s for n, ss in self.by_name.items() if n.lower() == t.lower() for s in ss]
778
+ if lower:
779
+ return "symbol", lower
780
+ return None, None
781
+
782
+ def callers(self, name: str, limit: int = 40):
783
+ out = []
784
+ for f, scope, n, kind, line in self.refs:
785
+ if n == name and kind in ("call", "render", "inherit", "decorate", "use"):
786
+ out.append((f, line, scope or "<module>", kind))
787
+ out.sort(key=lambda r: (self.files.get(r[0], {}).get("test", False), bool(AUX_FILE.search(r[0])), r[0], r[1]))
788
+ return out[:limit], len(out)
789
+
790
+ def callees(self, sym: dict, limit: int = 30):
791
+ qual = f"{sym['parent']}.{sym['name']}" if sym["parent"] else sym["name"]
792
+ unit = self.unit_of(sym["file"])
793
+ allowed = {unit} | self.out_units.get(unit, set())
794
+ names = Counter(n for f, scope, n, kind, line in self.refs
795
+ if f == sym["file"] and (scope == qual or scope.startswith(qual + ".")) and n != sym["name"]
796
+ and any(d["kind"] in CALLABLE and self.unit_of(d["file"]) in allowed
797
+ for d in self.by_name.get(n, ())))
798
+ return [n for n, _ in names.most_common(limit)]
799
+
800
+ def importers(self, unit: str):
801
+ return sorted(self.in_units.get(unit, ()))
802
+
803
+ def tests_for(self, units):
804
+ """Test files that import one of `units` or reference their exported symbols."""
805
+ units = set(units)
806
+ out = set()
807
+ for u in units:
808
+ for v in self.in_units.get(u, ()):
809
+ out.update(f for f in self.unit_files.get(v, ()) if self.files[f]["test"])
810
+ names = {s["name"] for u in units for f in self.unit_files.get(u, ()) for s in self.by_file[f]
811
+ if s["exported"] and len(s["name"]) > 3}
812
+ for name in names:
813
+ for f in self.ref_files.get(name, ()):
814
+ if self.files.get(f, {}).get("test"):
815
+ fu = self.unit_of(f)
816
+ if fu in units or units & self.out_units.get(fu, set()):
817
+ out.add(f)
818
+ return sorted(f for f in out if not AUX_FILE.search(f))
819
+
820
+
821
+ # ---------------------------------------------------------------------------
822
+ # queries (text output for Claude, token-budgeted)
823
+
824
+
825
+ def _loc(s):
826
+ return f"{s['file']}:{s['start']}" + (f"-{s['end']}" if s["end"] and s["end"] > s["start"] else "")
827
+
828
+
829
+ def _qual(s):
830
+ return f"{s['parent']}.{s['name']}" if s["parent"] else s["name"]
831
+
832
+
833
+ def _fit(lines, budget: int):
834
+ out, used = [], 0
835
+ for ln in lines:
836
+ cost = tokens.count(ln) + 1
837
+ if used + cost > budget and out:
838
+ out.append(f"… (cut to fit {budget} tokens; narrow the query or raise --budget)")
839
+ break
840
+ out.append(ln)
841
+ used += cost
842
+ return "\n".join(out)
843
+
844
+
845
+ def load(root: str, build_if_missing: bool = True) -> Graph:
846
+ if not exists(root):
847
+ if not build_if_missing:
848
+ raise FileNotFoundError("no code graph yet; run `kompactor graph build`")
849
+ build(root)
850
+ return Graph(root)
851
+
852
+
853
+ def query(root: str, text: str, budget: int = 1500) -> str:
854
+ """Find the code most related to a free-text question, with its neighbourhood."""
855
+ g = load(root)
856
+ words = [w for w in split_words(text) if len(w) > 1 and w not in STOP]
857
+ if not words:
858
+ return "query: give some words, e.g. `kompactor graph query \"session cookie signing\"`"
859
+ df = Counter()
860
+ sym_words = []
861
+ for s in g.symbols:
862
+ ws = set(split_words(s["name"])) | set(split_words(s["parent"]))
863
+ ws |= {w for w in split_words(s["doc"]) if len(w) > 3}
864
+ ws |= set(split_words(Path(s["file"]).stem))
865
+ sym_words.append(ws)
866
+ df.update(ws)
867
+ n = len(g.symbols) or 1
868
+ scored = []
869
+ for s, ws in zip(g.symbols, sym_words):
870
+ score = 0.0
871
+ for w in words:
872
+ if w in ws:
873
+ idf = max(0.2, math.log(n / (1 + df[w])))
874
+ score += idf * (2.0 if w in split_words(s["name"]) else 1.0)
875
+ if score:
876
+ score *= KIND_WEIGHT.get(s["kind"], 0.8)
877
+ if g.files.get(s["file"], {}).get("test"):
878
+ score *= 0.4
879
+ elif AUX_FILE.search(s["file"]):
880
+ score *= 0.6
881
+ score *= 1 + min(g.ref_count.get(s["name"], 0), 50) / 50
882
+ scored.append((score, s))
883
+ scored.sort(key=lambda x: -x[0])
884
+ if not scored:
885
+ return f'query "{text}": no matching symbols. Try other words, or `kompactor graph report` for an overview.'
886
+ out = [f'query "{text}": {len(scored)} matches, best first']
887
+ per_file, picked, more = Counter(), [], Counter()
888
+ for score, s in scored:
889
+ if per_file[s["file"]] >= 4:
890
+ more[s["file"]] += 1
891
+ continue
892
+ per_file[s["file"]] += 1
893
+ picked.append((score, s))
894
+ if len(picked) >= 12:
895
+ break
896
+ for i, (score, s) in enumerate(picked, 1):
897
+ out.append(f"{i}. {_loc(s)} {s['kind']} {_qual(s)}" + (f" — {s['doc']}" if s["doc"] else ""))
898
+ out.append(f" {s['signature']}")
899
+ callers, total = g.callers(s["name"], limit=5)
900
+ if callers and s["kind"] not in ("constant",):
901
+ shown = ", ".join(f"{c[0]}:{c[1]}" for c in callers[:4])
902
+ out.append(f" used by: {shown}" + (f" (+{total - 4})" if total > 4 else ""))
903
+ calls = g.callees(s, limit=6) if s["kind"] in ("function", "method", "component", "hook") else []
904
+ if calls:
905
+ out.append(f" calls: {', '.join(calls)}")
906
+ if more:
907
+ out.append("more matches in: " + ", ".join(f"{f} (+{n})" for f, n in more.most_common(5)))
908
+ return _fit(out, budget)
909
+
910
+
911
+ STOP = {"the", "a", "an", "of", "to", "in", "and", "or", "for", "is", "how", "where", "what", "which", "does", "do",
912
+ "with", "on", "by", "from", "it", "this", "that", "are", "be", "when", "why", "who", "find", "code", "file"}
913
+
914
+
915
+ def callers_text(root: str, name: str, budget: int = 1200) -> str:
916
+ g = load(root)
917
+ kind, hits = g.find(name)
918
+ leaf = name.split(".")[-1]
919
+ defs = hits if kind == "symbol" else []
920
+ rows, total = g.callers(leaf, limit=200)
921
+ out = [f"{name}: defined at " + (", ".join(_loc(s) for s in defs[:5]) if defs else "(not found in this repo)"),
922
+ f"referenced {total}× in {len({r[0] for r in rows})} files" + (" (by name; may include same-named symbols)"
923
+ if len(defs) > 1 else "")]
924
+ for f, line, scope, k in rows:
925
+ out.append(f" {f}:{line} {scope}" + ("" if k == "call" else f" [{k}]"))
926
+ return _fit(out, budget)
927
+
928
+
929
+ def deps_text(root: str, target: str, budget: int = 1200) -> str:
930
+ g = load(root)
931
+ kind, val = g.find(target)
932
+ if kind is None:
933
+ return f"{target}: not found (give a file path, package dir, Name or Class.method)"
934
+ if kind == "symbol":
935
+ out = []
936
+ for s in val[:3]:
937
+ calls = g.callees(s, limit=40)
938
+ out.append(f"{_qual(s)} ({_loc(s)}) uses:")
939
+ for c in calls:
940
+ d = g.by_name.get(c, [])
941
+ out.append(f" {c} " + (", ".join(_loc(x) for x in d[:2]) if d else ""))
942
+ return _fit(out, budget)
943
+ unit = g.unit_of(val) if kind == "file" else val
944
+ outs = sorted(g.out_units.get(unit, ()))
945
+ ext = sorted({t for f, t, r, _, _ in g.imports if g.unit_of(f) == unit and not r})
946
+ out = [f"{unit} imports {len(outs)} project modules:"] + [f" {u}" for u in outs]
947
+ if ext:
948
+ out.append(f"external: {', '.join(ext[:30])}" + (" …" if len(ext) > 30 else ""))
949
+ return _fit(out, budget)
950
+
951
+
952
+ def impact(root: str, target: str, depth: int = 3):
953
+ """What may break if `target` (file, package dir, or symbol) changes.
954
+
955
+ Direct users are files that reference the target's symbols and can see them
956
+ (same module/package, or importing it). Indirect users follow the import graph.
957
+ Tests are test files that reference the symbols or import an affected module."""
958
+ g = load(root)
959
+ kind, val = g.find(target)
960
+ if kind is None:
961
+ return None, g
962
+ if kind == "symbol":
963
+ files = {s["file"] for s in val}
964
+ names = {s["name"] for s in val}
965
+ else:
966
+ files = {val} if kind == "file" else set(g.unit_files.get(val, []))
967
+ names = {s["name"] for f in files for s in g.by_file[f] if s["exported"] and len(s["name"]) > 3}
968
+ start = {g.unit_of(f) for f in files}
969
+ direct, importers = set(), set()
970
+ for n in names:
971
+ for f in g.ref_files.get(n, ()):
972
+ if f in files:
973
+ continue
974
+ u = g.unit_of(f)
975
+ if u in start or start & g.out_units.get(u, set()):
976
+ direct.add(f)
977
+ for u in start:
978
+ for v in g.in_units.get(u, ()):
979
+ importers.update(g.unit_files.get(v, ()))
980
+ if kind == "symbol": # a symbol's users are the files that name it; other importers use other names
981
+ if importers and names: # importing it by name, re-exporting it or using it as a type also counts
982
+ with connect(g.root) as db:
983
+ q = ",".join("?" * len(names))
984
+ named = {r[0] for r in db.execute(f"SELECT DISTINCT file FROM idents WHERE name IN ({q})", list(names))}
985
+ direct |= importers & named
986
+ others = importers - direct - files
987
+ else: # a file's importers are affected even without a resolved symbol reference
988
+ direct |= importers
989
+ others = set()
990
+ tests = sorted(f for f in direct | others if g.files[f]["test"] and not AUX_FILE.search(f))
991
+ direct_code = sorted(f for f in direct if not g.files[f]["test"])
992
+
993
+ levels = [direct_code] if direct_code else []
994
+ seen = start | {g.unit_of(f) for f in direct}
995
+ frontier = {g.unit_of(f) for f in direct_code}
996
+ for _ in range(depth - 1):
997
+ nxt = set()
998
+ for u in frontier:
999
+ nxt |= g.in_units.get(u, set())
1000
+ nxt -= seen
1001
+ if not nxt:
1002
+ break
1003
+ seen |= nxt
1004
+ code = sorted(f for v in nxt for f in g.unit_files.get(v, ()) if not g.files[f]["test"])
1005
+ tests += [f for v in nxt for f in g.unit_files.get(v, ()) if g.files[f]["test"] and not AUX_FILE.search(f)]
1006
+ if code:
1007
+ levels.append(code)
1008
+ frontier = nxt
1009
+ if kind == "symbol": # callers of callers aren't affected by a change to this symbol; their tests still run
1010
+ levels = levels[:1]
1011
+ affected = sorted({f for lvl in levels for f in lvl})
1012
+ return {"target": target, "kind": kind, "files": sorted(files), "levels": levels, "affected": affected,
1013
+ "tests": sorted(set(tests)),
1014
+ "importers_only": sorted(f for f in others if not g.files[f]["test"] and f not in affected)}, g
1015
+
1016
+
1017
+ def impact_text(root: str, target: str, budget: int = 1200) -> str:
1018
+ res, g = impact(root, target)
1019
+ if res is None:
1020
+ return f"{target}: not found (give a file path, package dir, Name or Class.method)"
1021
+ aux = [f for f in res["affected"] if AUX_FILE.search(f)]
1022
+ symbol = res["kind"] == "symbol"
1023
+ users = len(res["levels"][0]) if res["levels"] else 0
1024
+ head = (f"{users} files use it" + (f", {len(res['affected']) - users - len(aux)} more through them"
1025
+ if len(res["affected"]) - users - len(aux) > 0 else "")) if symbol \
1026
+ else f"{len(res['affected']) - len(aux)} dependent files"
1027
+ out = [f"impact of {target}: {head}, {len(res['tests'])} test files" + (f", {len(aux)} in examples/tooling" if aux else "")]
1028
+ for i, lvl in enumerate(res["levels"], 1):
1029
+ code = [f for f in lvl if not AUX_FILE.search(f)]
1030
+ if code:
1031
+ label = ("uses it" if symbol else "direct") if i == 1 else f"{i} hops"
1032
+ out.append(f" {label}: " + ", ".join(code[:25]) + (" …" if len(code) > 25 else ""))
1033
+ others = [f for f in res["importers_only"] if not AUX_FILE.search(f)]
1034
+ if others:
1035
+ out.append(f" import its module but not {target}: " + ", ".join(others[:15]) + (" …" if len(others) > 15 else ""))
1036
+ if res["tests"]:
1037
+ out.append(" tests to run: " + ", ".join(res["tests"][:20]) + (" …" if len(res["tests"]) > 20 else ""))
1038
+ return _fit(out, budget)
1039
+
1040
+
1041
+ def path_text(root: str, a: str, b: str) -> str:
1042
+ g = load(root)
1043
+ ka, va = g.find(a)
1044
+ kb, vb = g.find(b)
1045
+ if not ka or not kb:
1046
+ return f"not found: {a if not ka else b}"
1047
+ ua = {g.unit_of(s["file"]) for s in va} if ka == "symbol" else {g.unit_of(va) if ka == "file" else va}
1048
+ ub = {g.unit_of(s["file"]) for s in vb} if kb == "symbol" else {g.unit_of(vb) if kb == "file" else vb}
1049
+ for direction, adj in (("imports", g.out_units), ("is imported by", g.in_units)):
1050
+ prev = {u: None for u in ua}
1051
+ q = deque(ua)
1052
+ while q:
1053
+ u = q.popleft()
1054
+ if u in ub:
1055
+ chain = []
1056
+ while u is not None:
1057
+ chain.append(u)
1058
+ u = prev[u]
1059
+ chain.reverse()
1060
+ return f"{a} {direction} {b} via {len(chain) - 1} step(s):\n " + "\n › ".join(chain)
1061
+ for v in adj.get(u, ()):
1062
+ if v not in prev:
1063
+ prev[v] = u
1064
+ q.append(v)
1065
+ return f"no import path between {a} and {b}"
1066
+
1067
+
1068
+ # ---------------------------------------------------------------------------
1069
+ # report & map
1070
+
1071
+
1072
+ def areas(g: Graph, max_areas: int = 12):
1073
+ """Group units into areas by directory, then merge tiny areas into their parent."""
1074
+ by_dir = defaultdict(list)
1075
+ for u in g.unit_files:
1076
+ d = u.rstrip("/") if u.endswith("/") else os.path.dirname(u)
1077
+ by_dir[d or "."].append(u)
1078
+ return sorted(by_dir.items(), key=lambda kv: -sum(g.rank().get(u, 0) for u in kv[1]))[:max_areas]
1079
+
1080
+
1081
+ def _key_symbols(g: Graph, u: str):
1082
+ seen, out = set(), []
1083
+ syms = [s for f in g.unit_files[u] for s in g.by_file[f] if s["exported"]
1084
+ and s["kind"] not in ("constant", "method", "symbol")]
1085
+ syms.sort(key=lambda s: -g.ref_count.get(s["name"], 0))
1086
+ for s in syms:
1087
+ if s["name"] not in seen and s["name"] not in ("wrapper", "decorator", "inner"):
1088
+ seen.add(s["name"])
1089
+ out.append(s["name"])
1090
+ return out
1091
+
1092
+
1093
+ def import_cycles(g: Graph, limit: int = 8):
1094
+ """Strongly connected groups of modules that import each other (Tarjan)."""
1095
+ index, low, stack, on, out = {}, {}, [], set(), []
1096
+ counter = [0]
1097
+
1098
+ def strong(v):
1099
+ index[v] = low[v] = counter[0]
1100
+ counter[0] += 1
1101
+ stack.append(v)
1102
+ on.add(v)
1103
+ for w in g.runtime_out.get(v, ()):
1104
+ if w not in index:
1105
+ strong(w)
1106
+ low[v] = min(low[v], low[w])
1107
+ elif w in on:
1108
+ low[v] = min(low[v], index[w])
1109
+ if low[v] == index[v]:
1110
+ comp = []
1111
+ while True:
1112
+ w = stack.pop()
1113
+ on.discard(w)
1114
+ comp.append(w)
1115
+ if w == v:
1116
+ break
1117
+ if len(comp) > 1:
1118
+ out.append(sorted(comp))
1119
+
1120
+ import sys
1121
+ old = sys.getrecursionlimit()
1122
+ sys.setrecursionlimit(max(old, 10000))
1123
+ try:
1124
+ for v in list(g.runtime_out):
1125
+ if v not in index:
1126
+ strong(v)
1127
+ finally:
1128
+ sys.setrecursionlimit(old)
1129
+ out.sort(key=len)
1130
+ return out[:limit]
1131
+
1132
+
1133
+ IMPLICIT_METHODS = {
1134
+ # called by the runtime or a framework, not by name in the repo
1135
+ "String", "Error", "ServeHTTP", "Read", "Write", "Close", "Len", "Less", "Swap", "MarshalJSON", "UnmarshalJSON",
1136
+ "MarshalText", "UnmarshalText", "Format", "Unwrap", "Is", "As", "Scan", "Value", "init", "main", "TestMain",
1137
+ "setUp", "tearDown", "setUpClass", "tearDownClass", "asyncSetUp", "asyncTearDown", "run", "handle", "get", "post",
1138
+ "put", "patch", "delete", "dispatch", "render", "componentDidMount", "componentDidUpdate", "componentWillUnmount",
1139
+ "getDerivedStateFromProps", "shouldComponentUpdate", "constructor", "toString", "toJSON", "default", "handler",
1140
+ "middleware", "loader", "action", "generateMetadata", "generateStaticParams", "getServerSideProps",
1141
+ "getStaticProps", "getStaticPaths",
1142
+ }
1143
+ ROUTE_FILE = re.compile(r"(^|/)(pages|app)/.*\.(tsx?|jsx?)$|(^|/)(page|layout|route|loading|error|middleware)\.(tsx?|jsx?)$|"
1144
+ r"(^|/)(manage|wsgi|asgi|conftest|setup|__main__)\.py$|(^|/)migrations/")
1145
+
1146
+
1147
+ def unused(root: str):
1148
+ """Symbols whose name appears nowhere else in the repo. Returns (likely_dead, maybe_public)."""
1149
+ g = load(root)
1150
+ with connect(g.root) as db:
1151
+ totals = dict(db.execute("SELECT name, SUM(count) FROM idents GROUP BY name"))
1152
+ defs = Counter(s["name"] for s in g.symbols)
1153
+ dead, public = [], []
1154
+ for s in g.symbols:
1155
+ f, n = s["file"], s["name"]
1156
+ info = g.files.get(f, {})
1157
+ if info.get("lang") not in ("python", "go", "ts") or info.get("test") or info.get("generated") \
1158
+ or AUX_FILE.search(f) or ROUTE_FILE.search(f):
1159
+ continue
1160
+ if n.startswith("__") or n in IMPLICIT_METHODS or re.match(r"(test|Test|Benchmark|Example|Fuzz)", n):
1161
+ continue
1162
+ if s["signature"].startswith("@"): # decorated: registered with a framework (routes, fixtures, CLI)
1163
+ continue
1164
+ if s["kind"] == "method" and defs[n] > 1: # an override / interface implementation
1165
+ continue
1166
+ if totals.get(n, 0) > defs[n]: # used somewhere besides its own definition(s)
1167
+ continue
1168
+ (public if s["exported"] else dead).append(s)
1169
+ key = lambda s: (s["file"], s["start"]) # noqa: E731
1170
+ return sorted(dead, key=key), sorted(public, key=key)
1171
+
1172
+
1173
+ def unused_text(root: str, budget: int = 1500) -> str:
1174
+ dead, public = unused(root)
1175
+ if not dead and not public:
1176
+ return "No unused functions, classes or methods found."
1177
+ out = [f"Unused code candidates: {len(dead)} likely dead, {len(public)} exported but unreferenced"]
1178
+ if dead:
1179
+ out.append("likely dead (private or local; nothing references the name):")
1180
+ out += [f" {_loc(s)} {s['kind']} {_qual(s)}" for s in dead]
1181
+ if public:
1182
+ out.append("exported but unreferenced in this repo (may be public API):")
1183
+ out += [f" {_loc(s)} {s['kind']} {_qual(s)}" for s in public]
1184
+ out.append("Check before deleting: dynamic calls, reflection and external users are not visible to the graph.")
1185
+ return _fit(out, budget)
1186
+
1187
+
1188
+ DEBT = re.compile(r"(?:(?:^|\s)(?:#|//|/\*+|<!--)|^\s*\*)\s*(shortcut|FIXME|BUG|HACK|XXX|TODO)\b[\s:(\-]*(.*)", re.I)
1189
+
1190
+
1191
+ def debt_text(root: str, budget: int = 1500) -> str:
1192
+ """Ledger of shortcut:/FIXME/HACK/TODO comments, most serious first."""
1193
+ order = {"bug": 0, "fixme": 1, "hack": 2, "xxx": 3, "shortcut": 4, "todo": 5}
1194
+ rows = []
1195
+ for f in outline.list_files(root):
1196
+ if Path(f).suffix not in outline.SOURCE_EXT or AUX_FILE.search(f):
1197
+ continue
1198
+ try:
1199
+ text = Path(root, f).read_text(errors="replace") if os.path.getsize(Path(root, f)) < MAX_FILE_BYTES else ""
1200
+ except OSError:
1201
+ continue
1202
+ for i, ln in enumerate(text.splitlines(), 1):
1203
+ m = DEBT.search(ln)
1204
+ if m:
1205
+ rows.append((order.get(m.group(1).lower(), 9), m.group(1).upper(), f, i, m.group(2).strip()[:140]))
1206
+ if not rows:
1207
+ return "No shortcut:, TODO, FIXME, HACK or XXX comments found."
1208
+ rows.sort()
1209
+ counts = Counter(r[1] for r in rows)
1210
+ out = ["Debt ledger: " + ", ".join(f"{n} {k}" for k, n in counts.most_common())]
1211
+ out += [f" {tag:<8} {f}:{line} {text}" for _, tag, f, line, text in rows]
1212
+ return _fit(out, budget)
1213
+
1214
+
1215
+ def explain_text(root: str, name: str, budget: int = 1200) -> str:
1216
+ """Everything about one symbol: where, what, who uses it, what it uses, its tests."""
1217
+ g = load(root)
1218
+ kind, val = g.find(name)
1219
+ if kind != "symbol":
1220
+ return deps_text(root, name, budget) if kind else f"{name}: not found"
1221
+ out = []
1222
+ for s in val[:3]:
1223
+ out.append(f"{_qual(s)} — {s['kind']} at {_loc(s)}" + (f": {s['doc']}" if s["doc"] else ""))
1224
+ out.append(f" {s['signature']}")
1225
+ rows, total = g.callers(s["name"], limit=8)
1226
+ if rows:
1227
+ out.append(f" used by ({total}): " + ", ".join(f"{r[0]}:{r[1]} {r[2]}" for r in rows[:8]))
1228
+ calls = g.callees(s, limit=12)
1229
+ if calls:
1230
+ out.append(" uses: " + ", ".join(calls))
1231
+ tests = sorted({r[0] for r in g.callers(s["name"], limit=500)[0] if g.files.get(r[0], {}).get("test")})
1232
+ if tests:
1233
+ out.append(" tested in: " + ", ".join(tests[:6]))
1234
+ return _fit(out, budget)
1235
+
1236
+
1237
+ def _is_reexport(g: Graph, u: str) -> bool:
1238
+ return bool(re.search(r"(^|/)(__init__\.pyi?|index\.[cm]?[jt]sx?|mod\.rs)$", u)) and bool(g.out_units.get(u))
1239
+
1240
+
1241
+ def _key_files(g: Graph, n: int):
1242
+ score = Counter()
1243
+ for f, syms in g.by_file.items():
1244
+ if g.files.get(f, {}).get("test") or AUX_FILE.search(f):
1245
+ continue
1246
+ score[f] = sum(g.ref_count.get(s["name"], 0) for s in syms if s["exported"])
1247
+ out = []
1248
+ for f, _ in score.most_common(n):
1249
+ names = list(dict.fromkeys(s["name"] for s in sorted(g.by_file[f], key=lambda s: -g.ref_count.get(s["name"], 0))
1250
+ if s["exported"] and s["kind"] != "constant"))
1251
+ out.append((f, names))
1252
+ return out
1253
+
1254
+
1255
+ def report(root: str, budget: int = 1800) -> str:
1256
+ g = load(root)
1257
+ pr = g.rank()
1258
+ langs_count = Counter(info["lang"] for info in g.files.values())
1259
+ m = g.meta
1260
+ out = [f"# Code graph: {Path(g.root).name}",
1261
+ f"{len(g.files)} files ({', '.join(f'{k} {v}' for k, v in langs_count.most_common())}) · "
1262
+ f"{len(g.symbols)} symbols · {len(g.refs)} references · {sum(len(v) for v in g.out_units.values())} "
1263
+ f"module imports · built in {m.get('seconds', '?')}s"]
1264
+ core = g.core_units()[:10]
1265
+ out.append("\n## Core modules (most depended on)")
1266
+ for u in core:
1267
+ syms = _key_symbols(g, u)
1268
+ if not syms and not _is_reexport(g, u):
1269
+ continue
1270
+ out.append(f"- {u} — imported by {len(g.in_units.get(u, ()))}; "
1271
+ + ("key: " + ", ".join(syms[:6]) if syms else "re-exports"))
1272
+ if len(core) <= 3: # one or few packages (typical Go): rank files instead
1273
+ out.append("\n## Key files")
1274
+ for f, names in _key_files(g, 8):
1275
+ out.append(f"- {f} — " + ", ".join(names[:8]))
1276
+ hubs = [(n, c) for n, c in g.ref_count.most_common(200) if n in g.by_name and len(n) > 2
1277
+ and any(not g.files.get(s["file"], {}).get("test") for s in g.by_name[n])][:12]
1278
+ out.append("\n## Most used symbols")
1279
+ out.append(", ".join(f"{n} ({c}× in {len(g.ref_files[n])} files)" for n, c in hubs))
1280
+ entry = [s for s in g.symbols if s["name"] in ("main", "cli", "run", "app", "App", "create_app", "Execute", "handler")
1281
+ and not g.files.get(s["file"], {}).get("test")]
1282
+ if entry:
1283
+ out.append("\n## Entry points")
1284
+ out.append(", ".join(f"{_qual(s)} ({_loc(s)})" for s in entry[:8]))
1285
+ out.append("\n## Areas")
1286
+ for d, units in areas(g):
1287
+ tests = sum(1 for u in units if g.is_test_unit(u))
1288
+ label = "tests" if tests == len(units) else "examples/docs/tooling" if g.is_aux_unit(d + "/") else ""
1289
+ out.append(f"- {d}/ — {len(units)} modules" + (f" ({label})" if label else f" ({tests} test)" if tests else ""))
1290
+ cycles = import_cycles(g)
1291
+ if cycles:
1292
+ out.append("\n## Import cycles (runtime imports only)")
1293
+ for c in cycles:
1294
+ out.append("- " + (" ⇄ ".join(c) if len(c) <= 4 else f"tangle of {len(c)}: " + ", ".join(c[:5]) + " …"))
1295
+ tested = defaultdict(set)
1296
+ for u in g.unit_files:
1297
+ if g.is_test_unit(u):
1298
+ for v in g.out_units.get(u, ()):
1299
+ if not g.is_test_unit(v) and not g.is_aux_unit(v):
1300
+ tested[v].add(u)
1301
+ if tested:
1302
+ out.append("\n## Tests by module")
1303
+ for v in sorted(tested, key=lambda v: -pr.get(v, 0))[:10]:
1304
+ out.append(f"- {v} ← {', '.join(sorted(tested[v])[:4])}")
1305
+ out.append("\n## Next steps\n`kompactor graph query \"<words>\"` · `kompactor graph impact <file|symbol>` · "
1306
+ "`kompactor graph callers <Name>` · `kompactor symbol <Name>`")
1307
+ return _fit(out, budget)
1308
+
1309
+
1310
+ def session_map(root: str, budget: int = 600) -> str:
1311
+ """Compact orientation map for SessionStart: top modules and their key symbols."""
1312
+ g = load(root, build_if_missing=False)
1313
+ lines = [f"[kompactor] Code map of {Path(g.root).name} (most depended-on modules first; "
1314
+ f"`kompactor graph query \"words\"` finds code by topic):"]
1315
+ core = g.core_units()
1316
+ if len(core) <= 3: # one big package (typical Go): list key files instead
1317
+ for f, names in _key_files(g, 15):
1318
+ lines.append(f"{f}: {', '.join(names[:8])}")
1319
+ else:
1320
+ for u in core:
1321
+ syms = _key_symbols(g, u)
1322
+ if syms:
1323
+ lines.append(f"{u}: " + ", ".join(syms[:8]))
1324
+ elif _is_reexport(g, u):
1325
+ lines.append(f"{u}: (re-exports)")
1326
+ return _fit(lines, budget)
1327
+
1328
+
1329
+ def file_context(root: str, path: str) -> str:
1330
+ """One or two lines placed under an outline: who imports this file and its tests."""
1331
+ if not exists(root):
1332
+ return ""
1333
+ g = Graph(root)
1334
+ rel = os.path.relpath(path, g.root) if os.path.isabs(path) else path
1335
+ if rel not in g.files:
1336
+ return ""
1337
+ u = g.unit_of(rel)
1338
+ imp = [v for v in g.importers(u) if not g.is_test_unit(v)]
1339
+ tests = g.tests_for({u})
1340
+ parts = []
1341
+ if imp:
1342
+ parts.append("imported by " + ", ".join(imp[:6]) + (f" (+{len(imp) - 6})" if len(imp) > 6 else ""))
1343
+ if tests:
1344
+ parts.append("tests: " + ", ".join(tests[:4]) + (f" (+{len(tests) - 4})" if len(tests) > 4 else ""))
1345
+ return "Graph: " + "; ".join(parts) if parts else ""
1346
+
1347
+
1348
+ def viz(root: str, limit: int = 60) -> dict:
1349
+ """Nodes and edges of the top units for the dashboard's graph view: the most
1350
+ central code modules, plus the most central tests/examples flagged `aux`."""
1351
+ g = load(root, build_if_missing=False)
1352
+ pr = g.rank()
1353
+ ranked = sorted(pr, key=lambda u: -pr[u])
1354
+ code = [u for u in ranked if not g.is_test_unit(u) and not g.is_aux_unit(u)][:limit]
1355
+ aux = [u for u in ranked if g.is_test_unit(u) or g.is_aux_unit(u)][:max(10, limit // 2)]
1356
+ top = code + aux
1357
+ keep = set(top)
1358
+ nodes = [{"id": u, "rank": round(pr[u], 5), "test": g.is_test_unit(u), "aux": g.is_test_unit(u) or g.is_aux_unit(u),
1359
+ "in": len(g.in_units.get(u, ())),
1360
+ "out": len(g.out_units.get(u, ())), "lang": g.files[g.unit_files[u][0]]["lang"] if g.unit_files[u] else "",
1361
+ "lines": sum(g.files[f]["lines"] for f in g.unit_files[u]),
1362
+ "symbols": [_qual(s) for f in g.unit_files[u] for s in g.by_file[f] if s["exported"]
1363
+ and s["kind"] not in ("constant",)][:12]} for u in top]
1364
+ edges = [{"source": u, "target": v} for u in top for v in g.out_units.get(u, ()) if v in keep]
1365
+ total_tokens = sum(g.files[f]["lines"] for f in g.files) * 9 # ~9 tokens per source line
1366
+ return {"nodes": nodes, "edges": edges, "meta": g.meta, "files": len(g.files), "symbols": len(g.symbols),
1367
+ "refs": len(g.refs), "units": len(g.unit_files), "approx_source_tokens": total_tokens,
1368
+ "map_tokens": tokens.count(session_map(root, 800)) if g.unit_files else 0}
1369
+
1370
+
1371
+ def build_in_background(root: str) -> None:
1372
+ """Refresh the graph without blocking the hook that asked for it."""
1373
+ import subprocess
1374
+ import sys
1375
+ launch = Path(__file__).with_name("_launch.py")
1376
+ kwargs = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL,
1377
+ "close_fds": True}
1378
+ if os.name == "nt":
1379
+ kwargs["creationflags"] = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
1380
+ else:
1381
+ kwargs["start_new_session"] = True
1382
+ subprocess.Popen([sys.executable, str(launch), "graph", "build", "--root", root], **kwargs)
1383
+
1384
+
1385
+ def ensure(root: str, sync_limit: int = 1500, max_age: float = 60) -> bool:
1386
+ """Make sure a reasonably fresh graph exists. Small repos build inline (well under a
1387
+ second); big ones and refreshes happen in the background. Returns True if a graph exists."""
1388
+ if exists(root):
1389
+ with connect(root) as db:
1390
+ built = json.loads((db.execute("SELECT value FROM meta WHERE key='built'").fetchone() or ["{}"])[0])
1391
+ if time.time() - built.get("built", 0) > max_age:
1392
+ build_in_background(root)
1393
+ return True
1394
+ if len(outline.list_files(root)) <= sync_limit:
1395
+ build(root)
1396
+ return True
1397
+ build_in_background(root)
1398
+ return False