contextl 1.2.15 → 1.2.17
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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/python/import_parser.py +85 -0
- package/python/mcp_server.py +24 -14
- package/python/scanner.py +3 -2
package/README.md
CHANGED
|
@@ -140,6 +140,8 @@ Extracts the structural skeleton (API surface) of a source file using Tree-sitte
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
## How the ranking works
|
|
144
|
+
|
|
143
145
|
1. **Keyword match** — does the filename contain query terms?
|
|
144
146
|
2. **Content match** — does the file's source code mention the terms?
|
|
145
147
|
3. **Graph proximity** — files connected to high-scoring files get a relevance boost
|
package/package.json
CHANGED
package/python/import_parser.py
CHANGED
|
@@ -1367,6 +1367,85 @@ def _skeleton_rust(root: "Node") -> dict:
|
|
|
1367
1367
|
return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
|
|
1368
1368
|
|
|
1369
1369
|
|
|
1370
|
+
|
|
1371
|
+
def _skeleton_go(root: "Node") -> dict:
|
|
1372
|
+
classes = []
|
|
1373
|
+
functions = []
|
|
1374
|
+
exports = []
|
|
1375
|
+
docstrings = {}
|
|
1376
|
+
|
|
1377
|
+
def visit_fn(node: "Node") -> dict:
|
|
1378
|
+
name = ""
|
|
1379
|
+
params = ""
|
|
1380
|
+
ret_type = ""
|
|
1381
|
+
is_method = (node.type == "method_declaration")
|
|
1382
|
+
receiver = ""
|
|
1383
|
+
|
|
1384
|
+
for child in node.children:
|
|
1385
|
+
if child.type == "identifier" or child.type == "field_identifier":
|
|
1386
|
+
if not name:
|
|
1387
|
+
name = _node_text(child)
|
|
1388
|
+
elif child.type == "parameter_list":
|
|
1389
|
+
if is_method and not receiver:
|
|
1390
|
+
receiver = _node_text(child)
|
|
1391
|
+
else:
|
|
1392
|
+
params = _node_text(child)
|
|
1393
|
+
elif child.type == "type_identifier":
|
|
1394
|
+
ret_type = _node_text(child)
|
|
1395
|
+
|
|
1396
|
+
return {"name": name, "params": params, "return_type": ret_type, "visibility": "pub" if name and name[0].isupper() else "private", "is_async": False, "decorators": [], "receiver": receiver}
|
|
1397
|
+
|
|
1398
|
+
def visit(node: "Node"):
|
|
1399
|
+
if node.type in ("function_declaration", "method_declaration"):
|
|
1400
|
+
functions.append(visit_fn(node))
|
|
1401
|
+
elif node.type == "type_spec":
|
|
1402
|
+
name = ""
|
|
1403
|
+
for child in node.children:
|
|
1404
|
+
if child.type == "type_identifier":
|
|
1405
|
+
name = _node_text(child)
|
|
1406
|
+
elif child.type == "struct_type" or child.type == "interface_type":
|
|
1407
|
+
if name:
|
|
1408
|
+
classes.append({"name": name, "fields": [], "methods": [], "extends": "", "implements": [], "properties": []})
|
|
1409
|
+
for child in node.children:
|
|
1410
|
+
visit(child)
|
|
1411
|
+
|
|
1412
|
+
visit(root)
|
|
1413
|
+
return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
|
|
1414
|
+
|
|
1415
|
+
def _skeleton_cpp(root: "Node") -> dict:
|
|
1416
|
+
classes = []
|
|
1417
|
+
functions = []
|
|
1418
|
+
exports = []
|
|
1419
|
+
docstrings = {}
|
|
1420
|
+
|
|
1421
|
+
def visit_fn(node: "Node") -> dict:
|
|
1422
|
+
name = ""
|
|
1423
|
+
params = ""
|
|
1424
|
+
for child in node.children:
|
|
1425
|
+
if child.type == "function_declarator":
|
|
1426
|
+
for sub in child.children:
|
|
1427
|
+
if sub.type == "identifier" or sub.type == "field_identifier":
|
|
1428
|
+
name = _node_text(sub)
|
|
1429
|
+
elif sub.type == "parameter_list":
|
|
1430
|
+
params = _node_text(sub)
|
|
1431
|
+
return {"name": name, "params": params, "return_type": "", "visibility": "pub", "is_async": False, "decorators": []}
|
|
1432
|
+
|
|
1433
|
+
def visit(node: "Node"):
|
|
1434
|
+
if node.type == "function_definition":
|
|
1435
|
+
functions.append(visit_fn(node))
|
|
1436
|
+
elif node.type in ("class_specifier", "struct_specifier"):
|
|
1437
|
+
name = ""
|
|
1438
|
+
for child in node.children:
|
|
1439
|
+
if child.type == "type_identifier":
|
|
1440
|
+
name = _node_text(child)
|
|
1441
|
+
if name:
|
|
1442
|
+
classes.append({"name": name, "fields": [], "methods": [], "extends": "", "implements": [], "properties": []})
|
|
1443
|
+
for child in node.children:
|
|
1444
|
+
visit(child)
|
|
1445
|
+
|
|
1446
|
+
visit(root)
|
|
1447
|
+
return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
|
|
1448
|
+
|
|
1370
1449
|
_SKELETON_HANDLERS = {
|
|
1371
1450
|
".py": _skeleton_python,
|
|
1372
1451
|
".ts": _skeleton_ts_js,
|
|
@@ -1375,6 +1454,12 @@ _SKELETON_HANDLERS = {
|
|
|
1375
1454
|
".jsx": _skeleton_ts_js,
|
|
1376
1455
|
".java": _skeleton_java,
|
|
1377
1456
|
".rs": _skeleton_rust,
|
|
1457
|
+
".go": _skeleton_go,
|
|
1458
|
+
".cpp": _skeleton_cpp,
|
|
1459
|
+
".h": _skeleton_cpp,
|
|
1460
|
+
".hpp": _skeleton_cpp,
|
|
1461
|
+
".cc": _skeleton_cpp,
|
|
1462
|
+
".c": _skeleton_cpp,
|
|
1378
1463
|
}
|
|
1379
1464
|
|
|
1380
1465
|
|
package/python/mcp_server.py
CHANGED
|
@@ -14,7 +14,7 @@ Tools exposed:
|
|
|
14
14
|
query_repo — rank files by relevance to a natural-language query
|
|
15
15
|
scan_repo — list all source files the engine can see in a repo
|
|
16
16
|
analyze_impact — find every file affected by changing a target file
|
|
17
|
-
|
|
17
|
+
find_dead_files — find unimported files with in-degree 0
|
|
18
18
|
export_obsidian_vault — export repo graph to an Obsidian markdown vault
|
|
19
19
|
|
|
20
20
|
Performance:
|
|
@@ -61,10 +61,11 @@ class _CacheEntry:
|
|
|
61
61
|
mtime_hash: float # max mtime across all source files at build time
|
|
62
62
|
scan: object # ScanResult
|
|
63
63
|
repo_graph: object # RepoGraph
|
|
64
|
+
last_checked_time: float = 0.0
|
|
64
65
|
|
|
65
66
|
|
|
66
67
|
_cache_lock = threading.Lock()
|
|
67
|
-
_cache:
|
|
68
|
+
_cache: dict[str, _CacheEntry] = {}
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
def _compute_mtime_hash(repo_path: str) -> float:
|
|
@@ -95,16 +96,24 @@ def _get_graph(repo_path: str):
|
|
|
95
96
|
On subsequent calls with unchanged files: returns instantly from cache.
|
|
96
97
|
"""
|
|
97
98
|
global _cache
|
|
99
|
+
import time
|
|
98
100
|
|
|
99
101
|
resolved = str(Path(repo_path).resolve())
|
|
100
|
-
mtime_hash = _compute_mtime_hash(resolved)
|
|
101
102
|
|
|
102
103
|
with _cache_lock:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
now = time.time()
|
|
105
|
+
entry = _cache.get(resolved)
|
|
106
|
+
|
|
107
|
+
# Fast path: bypass filesystem walk if checked within last 5 seconds
|
|
108
|
+
if entry and (now - entry.last_checked_time < 5.0):
|
|
109
|
+
return entry.scan, entry.repo_graph
|
|
110
|
+
|
|
111
|
+
mtime_hash = _compute_mtime_hash(resolved)
|
|
112
|
+
|
|
113
|
+
if entry and entry.mtime_hash == mtime_hash:
|
|
114
|
+
entry.last_checked_time = now
|
|
106
115
|
print("[contextl] graph cache hit", file=sys.stderr, flush=True)
|
|
107
|
-
return
|
|
116
|
+
return entry.scan, entry.repo_graph
|
|
108
117
|
|
|
109
118
|
# Cache miss — build the pipeline
|
|
110
119
|
print("[contextl] building graph...", file=sys.stderr, flush=True)
|
|
@@ -112,11 +121,12 @@ def _get_graph(repo_path: str):
|
|
|
112
121
|
parse = parse_imports(scan)
|
|
113
122
|
repo_graph = build_graph(scan, parse)
|
|
114
123
|
|
|
115
|
-
_cache = _CacheEntry(
|
|
124
|
+
_cache[resolved] = _CacheEntry(
|
|
116
125
|
repo_path=resolved,
|
|
117
126
|
mtime_hash=mtime_hash,
|
|
118
127
|
scan=scan,
|
|
119
128
|
repo_graph=repo_graph,
|
|
129
|
+
last_checked_time=now,
|
|
120
130
|
)
|
|
121
131
|
print(f"[contextl] graph built: {scan.total_files} files", file=sys.stderr, flush=True)
|
|
122
132
|
return scan, repo_graph
|
|
@@ -228,7 +238,7 @@ async def list_tools() -> list[types.Tool]:
|
|
|
228
238
|
},
|
|
229
239
|
),
|
|
230
240
|
types.Tool(
|
|
231
|
-
name="
|
|
241
|
+
name="find_dead_files",
|
|
232
242
|
description=(
|
|
233
243
|
"Find files in the repository that are never imported by any other file "
|
|
234
244
|
"(in-degree of 0 in the dependency graph). Automatically filters out "
|
|
@@ -340,8 +350,8 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
|
340
350
|
if name == "analyze_impact":
|
|
341
351
|
return await _handle_impact(arguments)
|
|
342
352
|
|
|
343
|
-
if name == "
|
|
344
|
-
return await
|
|
353
|
+
if name == "find_dead_files":
|
|
354
|
+
return await _handle_dead_files(arguments)
|
|
345
355
|
|
|
346
356
|
if name == "export_obsidian_vault":
|
|
347
357
|
return await _handle_export_obsidian(arguments)
|
|
@@ -461,19 +471,19 @@ def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
|
|
|
461
471
|
}
|
|
462
472
|
|
|
463
473
|
|
|
464
|
-
async def
|
|
474
|
+
async def _handle_dead_files(args: dict) -> list[types.TextContent]:
|
|
465
475
|
repo_path = args.get("repo_path", "")
|
|
466
476
|
|
|
467
477
|
loop = asyncio.get_event_loop()
|
|
468
478
|
try:
|
|
469
|
-
result = await loop.run_in_executor(None,
|
|
479
|
+
result = await loop.run_in_executor(None, _run_dead_files, repo_path)
|
|
470
480
|
except Exception as e:
|
|
471
481
|
result = {"error": str(e), "repo": repo_path}
|
|
472
482
|
|
|
473
483
|
return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
|
|
474
484
|
|
|
475
485
|
|
|
476
|
-
def
|
|
486
|
+
def _run_dead_files(repo_path: str) -> dict:
|
|
477
487
|
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
478
488
|
scan, repo_graph = _get_graph(repo_path)
|
|
479
489
|
standalone_files = find_standalone_files(repo_graph)
|
package/python/scanner.py
CHANGED
|
@@ -3,7 +3,7 @@ Repository Intelligence Engine
|
|
|
3
3
|
Step 1: Repository Scanner
|
|
4
4
|
|
|
5
5
|
Walks a repository and discovers all relevant source files.
|
|
6
|
-
Filters by supported extensions
|
|
6
|
+
Filters by supported extensions across 9 language families.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import os
|
|
@@ -17,7 +17,8 @@ SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java", ".go", ".r
|
|
|
17
17
|
ignore_dirs = {
|
|
18
18
|
".git", "node_modules", "__pycache__", "venv", ".venv",
|
|
19
19
|
"env", ".env", ".next", ".nuxt", "out", "build", "dist",
|
|
20
|
-
"coverage", ".nyc_output", ".pytest_cache", "npm", "pypi"
|
|
20
|
+
"coverage", ".nyc_output", ".pytest_cache", "npm", "pypi",
|
|
21
|
+
"vendor", "target", ".cargo", "Pods", "third_party"
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
def _should_ignore(path: str) -> bool:
|