contextl 1.2.17 → 1.2.18
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/bin/contextl.js +1 -1
- package/package.json +1 -1
- package/python/git_review.py +10 -7
- package/python/graph_builder.py +19 -1
- package/python/mcp_server.py +8 -16
- package/python/obsidian_export.py +17 -21
- package/python/query_engine.py +28 -39
package/bin/contextl.js
CHANGED
|
@@ -106,7 +106,7 @@ function pipInstall(python, packageName) {
|
|
|
106
106
|
function ensureDeps(python) {
|
|
107
107
|
const required = [
|
|
108
108
|
{ importName: "networkx", pipName: "networkx" },
|
|
109
|
-
{ importName: "mcp", pipName: "mcp" },
|
|
109
|
+
{ importName: "mcp", pipName: "mcp>=1.0.0" },
|
|
110
110
|
{ importName: "tree_sitter", pipName: "tree-sitter>=0.22.0" },
|
|
111
111
|
{ importName: "tree_sitter_python", pipName: "tree-sitter-python>=0.22.0" },
|
|
112
112
|
{ importName: "tree_sitter_javascript", pipName: "tree-sitter-javascript>=0.22.0" },
|
package/package.json
CHANGED
package/python/git_review.py
CHANGED
|
@@ -58,13 +58,16 @@ def _run_git(args: list[str], cwd: str) -> list[str]:
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def _is_git_repo(repo_path: str) -> bool:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
try:
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
64
|
+
cwd=repo_path,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
)
|
|
68
|
+
return result.returncode == 0
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
return False
|
|
68
71
|
|
|
69
72
|
|
|
70
73
|
def get_changed_files(
|
package/python/graph_builder.py
CHANGED
|
@@ -34,6 +34,10 @@ class FileNode:
|
|
|
34
34
|
out_degree: int = 0 # files this imports
|
|
35
35
|
centrality: float = 0.0 # PageRank score
|
|
36
36
|
|
|
37
|
+
# Text data (computed once)
|
|
38
|
+
content: str = ""
|
|
39
|
+
doc_length: int = 0
|
|
40
|
+
|
|
37
41
|
|
|
38
42
|
@dataclass
|
|
39
43
|
class RepoGraph:
|
|
@@ -126,16 +130,30 @@ def build_graph(scan_result, parse_result: ParseResult) -> RepoGraph:
|
|
|
126
130
|
"""
|
|
127
131
|
G = nx.DiGraph()
|
|
128
132
|
|
|
133
|
+
import re
|
|
134
|
+
|
|
129
135
|
# Add all scanned files as nodes
|
|
130
136
|
file_nodes: dict[str, FileNode] = {}
|
|
131
137
|
for f in scan_result.files:
|
|
138
|
+
abs_path = str(Path(scan_result.root) / f.path)
|
|
139
|
+
try:
|
|
140
|
+
with open(abs_path, "r", encoding="utf-8") as f_obj:
|
|
141
|
+
content = f_obj.read()
|
|
142
|
+
dl = len(re.findall(r"[a-z0-9]+", content.lower()))
|
|
143
|
+
except Exception:
|
|
144
|
+
content = ""
|
|
145
|
+
dl = 0
|
|
146
|
+
|
|
132
147
|
node = FileNode(
|
|
133
148
|
path=f.path,
|
|
134
149
|
extension=f.extension,
|
|
135
150
|
size_bytes=f.size_bytes,
|
|
151
|
+
content=content,
|
|
152
|
+
doc_length=dl,
|
|
136
153
|
)
|
|
137
154
|
file_nodes[f.path] = node
|
|
138
|
-
|
|
155
|
+
# Add basic node properties to nx graph
|
|
156
|
+
G.add_node(f.path, path=node.path, extension=node.extension, size_bytes=node.size_bytes)
|
|
139
157
|
|
|
140
158
|
# Add edges from import relationships
|
|
141
159
|
for rel in parse_result.relationships:
|
package/python/mcp_server.py
CHANGED
|
@@ -373,10 +373,8 @@ async def _handle_query(args: dict) -> list[types.TextContent]:
|
|
|
373
373
|
query_str = args.get("query", "")
|
|
374
374
|
top_n = min(int(args.get("top_n", 5)), 20)
|
|
375
375
|
|
|
376
|
-
# Run the engine (synchronous — wrap in thread to stay async-safe)
|
|
377
|
-
loop = asyncio.get_event_loop()
|
|
378
376
|
try:
|
|
379
|
-
result = await
|
|
377
|
+
result = await asyncio.to_thread(_run_query, repo_path, query_str, top_n)
|
|
380
378
|
except Exception as e:
|
|
381
379
|
result = {"error": str(e), "query": query_str, "repo": repo_path, "results": []}
|
|
382
380
|
|
|
@@ -409,9 +407,8 @@ def _run_query(repo_path: str, query_str: str, top_n: int) -> dict:
|
|
|
409
407
|
async def _handle_scan(args: dict) -> list[types.TextContent]:
|
|
410
408
|
repo_path = args.get("repo_path", "")
|
|
411
409
|
|
|
412
|
-
loop = asyncio.get_event_loop()
|
|
413
410
|
try:
|
|
414
|
-
result = await
|
|
411
|
+
result = await asyncio.to_thread(_run_scan, repo_path)
|
|
415
412
|
except Exception as e:
|
|
416
413
|
result = {"error": str(e), "repo": repo_path}
|
|
417
414
|
|
|
@@ -436,9 +433,8 @@ async def _handle_impact(args: dict) -> list[types.TextContent]:
|
|
|
436
433
|
target_file = args.get("target_file", "")
|
|
437
434
|
max_depth = min(int(args.get("max_depth", 5)), 10)
|
|
438
435
|
|
|
439
|
-
loop = asyncio.get_event_loop()
|
|
440
436
|
try:
|
|
441
|
-
result = await
|
|
437
|
+
result = await asyncio.to_thread(_run_impact, repo_path, target_file, max_depth)
|
|
442
438
|
except Exception as e:
|
|
443
439
|
result = {"error": str(e), "repo": repo_path, "target_file": target_file}
|
|
444
440
|
|
|
@@ -474,9 +470,8 @@ def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
|
|
|
474
470
|
async def _handle_dead_files(args: dict) -> list[types.TextContent]:
|
|
475
471
|
repo_path = args.get("repo_path", "")
|
|
476
472
|
|
|
477
|
-
loop = asyncio.get_event_loop()
|
|
478
473
|
try:
|
|
479
|
-
result = await
|
|
474
|
+
result = await asyncio.to_thread(_run_dead_files, repo_path)
|
|
480
475
|
except Exception as e:
|
|
481
476
|
result = {"error": str(e), "repo": repo_path}
|
|
482
477
|
|
|
@@ -500,9 +495,8 @@ async def _handle_export_obsidian(args: dict) -> list[types.TextContent]:
|
|
|
500
495
|
repo_path = args.get("repo_path", "")
|
|
501
496
|
output_dir = args.get("output_dir", "")
|
|
502
497
|
|
|
503
|
-
loop = asyncio.get_event_loop()
|
|
504
498
|
try:
|
|
505
|
-
result = await
|
|
499
|
+
result = await asyncio.to_thread(_run_export_obsidian, repo_path, output_dir)
|
|
506
500
|
except Exception as e:
|
|
507
501
|
result = {"error": str(e), "repo": repo_path, "output_dir": output_dir}
|
|
508
502
|
|
|
@@ -527,10 +521,9 @@ async def _handle_review_changes(args: dict) -> list[types.TextContent]:
|
|
|
527
521
|
include_staged = bool(args.get("staged", True))
|
|
528
522
|
include_unstaged = bool(args.get("unstaged", True))
|
|
529
523
|
|
|
530
|
-
loop = asyncio.get_event_loop()
|
|
531
524
|
try:
|
|
532
|
-
result = await
|
|
533
|
-
|
|
525
|
+
result = await asyncio.to_thread(
|
|
526
|
+
_run_review_changes, repo_path, include_staged, include_unstaged
|
|
534
527
|
)
|
|
535
528
|
except Exception as e:
|
|
536
529
|
result = {"error": str(e), "repo": repo_path}
|
|
@@ -564,9 +557,8 @@ def _run_review_changes(repo_path: str, include_staged: bool, include_unstaged:
|
|
|
564
557
|
async def _handle_get_skeleton(args: dict) -> list[types.TextContent]:
|
|
565
558
|
file_path = args.get("file_path", "")
|
|
566
559
|
|
|
567
|
-
loop = asyncio.get_event_loop()
|
|
568
560
|
try:
|
|
569
|
-
result = await
|
|
561
|
+
result = await asyncio.to_thread(extract_skeleton, file_path)
|
|
570
562
|
except Exception as e:
|
|
571
563
|
result = {"error": str(e), "file": file_path}
|
|
572
564
|
|
|
@@ -13,21 +13,18 @@ import re
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
from scanner import scan_repo
|
|
16
|
-
from import_parser import parse_imports
|
|
16
|
+
from import_parser import parse_imports, extract_skeleton
|
|
17
17
|
from graph_builder import build_graph, RepoGraph
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
PYTHON_DOCSTRING_PATTERN = re.compile(r'^\s*["\']{3}(.*?)["\']{3}', re.DOTALL)
|
|
21
|
-
JS_DOC_PATTERN = re.compile(r'^\s*/\*\*(.*?)\*/', re.DOTALL)
|
|
22
|
-
OUTLINE_PATTERN = re.compile(r'^\s*(?:export\s+)?(?:class|def|function|interface|type)\s+([a-zA-Z0-9_]+)', re.MULTILINE)
|
|
23
|
-
|
|
24
19
|
def _extract_file_context(file_path: str, extension: str, root_dir: str) -> tuple[str, list[str], str]:
|
|
25
20
|
"""Returns (explanation, outline_symbols, snippet)."""
|
|
26
21
|
explanation = "*No explanation provided in source code.*"
|
|
27
22
|
outline = []
|
|
28
23
|
snippet = ""
|
|
24
|
+
abs_path = str(Path(root_dir) / file_path)
|
|
25
|
+
|
|
29
26
|
try:
|
|
30
|
-
content =
|
|
27
|
+
content = Path(abs_path).read_text(encoding="utf-8")
|
|
31
28
|
|
|
32
29
|
# 1. Snippet (first 20 lines)
|
|
33
30
|
lines = content.splitlines()
|
|
@@ -35,21 +32,20 @@ def _extract_file_context(file_path: str, extension: str, root_dir: str) -> tupl
|
|
|
35
32
|
if len(lines) > 20:
|
|
36
33
|
snippet += "\n..."
|
|
37
34
|
|
|
38
|
-
# 2. Explanation
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
35
|
+
# 2 & 3. Explanation and Outline from Tree-Sitter
|
|
36
|
+
skeleton = extract_skeleton(abs_path)
|
|
37
|
+
|
|
38
|
+
if "error" not in skeleton:
|
|
39
|
+
# Grab the first docstring if it exists
|
|
40
|
+
if skeleton["docstrings"]:
|
|
41
|
+
explanation = list(skeleton["docstrings"].values())[0]
|
|
42
|
+
|
|
43
|
+
# Collect classes and functions
|
|
44
|
+
for cls in skeleton["classes"]:
|
|
45
|
+
outline.append(cls["name"])
|
|
46
|
+
for fn in skeleton["functions"]:
|
|
47
|
+
outline.append(fn["name"])
|
|
48
48
|
|
|
49
|
-
# 3. Outline
|
|
50
|
-
for match in OUTLINE_PATTERN.finditer(content):
|
|
51
|
-
outline.append(match.group(1))
|
|
52
|
-
|
|
53
49
|
except Exception:
|
|
54
50
|
pass
|
|
55
51
|
|
package/python/query_engine.py
CHANGED
|
@@ -296,48 +296,37 @@ def query(
|
|
|
296
296
|
return []
|
|
297
297
|
|
|
298
298
|
# --- Pre-computation: Global TF-IDF & BM25 ---
|
|
299
|
-
import pathlib
|
|
300
299
|
total_files = len(repo_graph.nodes)
|
|
301
300
|
document_frequency = {term: 0 for term in query_terms}
|
|
302
301
|
|
|
303
|
-
file_contents = {}
|
|
304
|
-
doc_lengths = {}
|
|
305
302
|
total_tokens_count = 0
|
|
306
303
|
|
|
307
304
|
for path, node in repo_graph.nodes.items():
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
305
|
+
content = node.content
|
|
306
|
+
dl = node.doc_length
|
|
307
|
+
total_tokens_count += dl
|
|
308
|
+
|
|
309
|
+
raw_tokens = re.findall(r"[a-z0-9]+", content.lower())
|
|
310
|
+
content_tokens = set(raw_tokens)
|
|
311
|
+
|
|
312
|
+
if path.endswith(".java"):
|
|
313
|
+
camel_tokens = []
|
|
314
|
+
for word in re.findall(r"[a-zA-Z]+", content):
|
|
315
|
+
if any(c.isupper() for c in word):
|
|
316
|
+
sub = re.sub(r"([A-Z])", r" \1", word).lower()
|
|
317
|
+
camel_tokens.extend(re.findall(r"[a-z0-9]+", sub))
|
|
318
|
+
content_tokens.update([t for t in camel_tokens if len(t) > 1])
|
|
319
319
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
for term in query_terms:
|
|
331
|
-
if term in combined_tokens:
|
|
332
|
-
document_frequency[term] += 1
|
|
333
|
-
elif len(term) > 3:
|
|
334
|
-
for ct in combined_tokens:
|
|
335
|
-
if term in ct:
|
|
336
|
-
document_frequency[term] += 1
|
|
337
|
-
break
|
|
338
|
-
except Exception:
|
|
339
|
-
file_contents[path] = ""
|
|
340
|
-
doc_lengths[path] = 0
|
|
320
|
+
path_tokens = set(_path_tokens(path))
|
|
321
|
+
combined_tokens = content_tokens.union(path_tokens)
|
|
322
|
+
for term in query_terms:
|
|
323
|
+
if term in combined_tokens:
|
|
324
|
+
document_frequency[term] += 1
|
|
325
|
+
elif len(term) > 3:
|
|
326
|
+
for ct in combined_tokens:
|
|
327
|
+
if term in ct:
|
|
328
|
+
document_frequency[term] += 1
|
|
329
|
+
break
|
|
341
330
|
|
|
342
331
|
avgdl = total_tokens_count / total_files if total_files > 0 else 1.0
|
|
343
332
|
|
|
@@ -357,8 +346,8 @@ def query(
|
|
|
357
346
|
|
|
358
347
|
for path, node in repo_graph.nodes.items():
|
|
359
348
|
kscore, kterms = _keyword_score(query_terms, path, idf_weights)
|
|
360
|
-
dl =
|
|
361
|
-
cscore, cterms = _content_score(query_terms, path,
|
|
349
|
+
dl = node.doc_length
|
|
350
|
+
cscore, cterms = _content_score(query_terms, path, node.content, idf_weights, dl, avgdl)
|
|
362
351
|
|
|
363
352
|
keyword_scores[path] = kscore
|
|
364
353
|
content_scores[path] = cscore
|
|
@@ -373,10 +362,10 @@ def query(
|
|
|
373
362
|
|
|
374
363
|
base_scores[path] = combined
|
|
375
364
|
|
|
376
|
-
# --- Pass
|
|
365
|
+
# --- Pass 2: neighbor bonus ---
|
|
377
366
|
boosted_scores = _apply_neighbor_bonus(base_scores, repo_graph)
|
|
378
367
|
|
|
379
|
-
# --- Pass
|
|
368
|
+
# --- Pass 3: centrality tiebreaker ---
|
|
380
369
|
results = []
|
|
381
370
|
for path, node in repo_graph.nodes.items():
|
|
382
371
|
neighbor_bonus = boosted_scores[path] - base_scores[path]
|