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/__init__.py +3 -0
- kompactor/__main__.py +4 -0
- kompactor/_launch.py +22 -0
- kompactor/cli.py +446 -0
- kompactor/config.py +231 -0
- kompactor/estimate.py +145 -0
- kompactor/filters.py +617 -0
- kompactor/graph.py +1398 -0
- kompactor/hooks.py +353 -0
- kompactor/installer.py +344 -0
- kompactor/langs.py +102 -0
- kompactor/outline.py +274 -0
- kompactor/prove.py +317 -0
- kompactor/rules.py +54 -0
- kompactor/samples.py +217 -0
- kompactor/server.py +301 -0
- kompactor/shellwrap.py +149 -0
- kompactor/static/fonts/OFL.txt +93 -0
- kompactor/static/fonts/plex-mono-400.woff2 +0 -0
- kompactor/static/fonts/plex-mono-500.woff2 +0 -0
- kompactor/static/fonts/plex-sans-400.woff2 +0 -0
- kompactor/static/fonts/plex-sans-500.woff2 +0 -0
- kompactor/static/fonts/plex-sans-600.woff2 +0 -0
- kompactor/static/logo-mark.svg +5 -0
- kompactor/static/usage.html +342 -0
- kompactor/store.py +213 -0
- kompactor/tokens.py +29 -0
- kompactor/ui.html +749 -0
- kompactor-0.5.0.dist-info/METADATA +274 -0
- kompactor-0.5.0.dist-info/RECORD +34 -0
- kompactor-0.5.0.dist-info/WHEEL +5 -0
- kompactor-0.5.0.dist-info/entry_points.txt +2 -0
- kompactor-0.5.0.dist-info/licenses/LICENSE +21 -0
- kompactor-0.5.0.dist-info/top_level.txt +1 -0
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
|