contextl 1.2.19 → 1.2.21
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 +4 -2
- package/package.json +1 -1
- package/python/graph_builder.py +9 -0
- package/python/impact_analysis.py +27 -0
- package/python/import_parser.py +8 -7
- package/python/mcp_server.py +6 -4
- package/python/obsidian_export.py +5 -1
- package/python/query_engine.py +12 -5
package/README.md
CHANGED
|
@@ -90,9 +90,11 @@ Walks the dependency graph upstream from any file to find every direct and trans
|
|
|
90
90
|
Lists every source file `contextl` can see — useful for the agent to orient itself before doing anything else.
|
|
91
91
|
|
|
92
92
|
### `find_dead_files`
|
|
93
|
-
|
|
93
|
+
Find files in the repository that are never imported by any other file (in-degree of 0 in the dependency graph).
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
*Note: The engine automatically detects and excludes standard entry points (e.g. `src/index.ts`, `main.py`, `app.tsx`) and test files from these results, as they are intentionally not imported by other files.*
|
|
96
|
+
|
|
97
|
+
**Arguments:**
|
|
96
98
|
|
|
97
99
|
```json
|
|
98
100
|
{
|
package/package.json
CHANGED
package/python/graph_builder.py
CHANGED
|
@@ -163,6 +163,15 @@ def build_graph(scan_result, parse_result: ParseResult) -> RepoGraph:
|
|
|
163
163
|
# Compute PageRank (importance score)
|
|
164
164
|
try:
|
|
165
165
|
pagerank = nx.pagerank(G, alpha=0.85)
|
|
166
|
+
|
|
167
|
+
# Penalize cycle-locked nodes to prevent inflation
|
|
168
|
+
sccs = [scc for scc in nx.strongly_connected_components(G) if len(scc) > 1]
|
|
169
|
+
for scc in sccs:
|
|
170
|
+
for node in scc:
|
|
171
|
+
# If a node only has incoming edges from within its own cycle, dampen it
|
|
172
|
+
predecessors = set(G.predecessors(node))
|
|
173
|
+
if predecessors.issubset(scc):
|
|
174
|
+
pagerank[node] *= 0.1
|
|
166
175
|
except nx.PowerIterationFailedConvergence:
|
|
167
176
|
pagerank = {n: 1.0 / len(G.nodes) for n in G.nodes}
|
|
168
177
|
|
|
@@ -40,14 +40,24 @@ class ImpactReport:
|
|
|
40
40
|
transitively_affected: list[ImpactedFile] = field(default_factory=list)
|
|
41
41
|
test_files: list[str] = field(default_factory=list)
|
|
42
42
|
total_affected: int = 0
|
|
43
|
+
cycle_detected: bool = False
|
|
44
|
+
cycle_members: list[str] = field(default_factory=list)
|
|
45
|
+
risk_score: float = 0.0
|
|
43
46
|
|
|
44
47
|
def summary(self) -> str:
|
|
45
48
|
lines = [
|
|
46
49
|
f"Impact analysis for: {self.target}",
|
|
47
50
|
f"Total affected files: {self.total_affected}",
|
|
51
|
+
f"Risk score: {self.risk_score:.1f}",
|
|
48
52
|
"",
|
|
49
53
|
]
|
|
50
54
|
|
|
55
|
+
if self.cycle_detected:
|
|
56
|
+
lines.append("⚠️ WARNING: Circular dependency detected ⚠️")
|
|
57
|
+
lines.append(f"This file is part of a cycle containing {len(self.cycle_members)} files.")
|
|
58
|
+
lines.append("Members: " + ", ".join(self.cycle_members))
|
|
59
|
+
lines.append("")
|
|
60
|
+
|
|
51
61
|
if self.directly_affected:
|
|
52
62
|
lines.append("Directly affected (import this file):")
|
|
53
63
|
for f in self.directly_affected:
|
|
@@ -117,6 +127,9 @@ def analyze_impact(
|
|
|
117
127
|
Raises:
|
|
118
128
|
ValueError: If target_file is not found in the graph.
|
|
119
129
|
"""
|
|
130
|
+
if not target_file:
|
|
131
|
+
raise ValueError("target_file cannot be empty")
|
|
132
|
+
|
|
120
133
|
if target_file not in repo_graph.nodes:
|
|
121
134
|
raise ValueError(
|
|
122
135
|
f"File not found in repository graph: {target_file}. "
|
|
@@ -170,6 +183,20 @@ def analyze_impact(
|
|
|
170
183
|
|
|
171
184
|
report.total_affected = len(report.directly_affected) + len(report.transitively_affected)
|
|
172
185
|
|
|
186
|
+
import networkx as nx
|
|
187
|
+
sccs = [scc for scc in nx.strongly_connected_components(repo_graph.graph) if len(scc) > 1]
|
|
188
|
+
for scc in sccs:
|
|
189
|
+
if target_file in scc:
|
|
190
|
+
report.cycle_detected = True
|
|
191
|
+
report.cycle_members = list(scc)
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
target_node = repo_graph.nodes.get(target_file)
|
|
195
|
+
in_degree = target_node.in_degree if target_node else 0
|
|
196
|
+
report.risk_score = (in_degree * 0.5) + (len(report.transitively_affected) * 0.2)
|
|
197
|
+
if report.cycle_detected:
|
|
198
|
+
report.risk_score += len(report.cycle_members) * 0.8
|
|
199
|
+
|
|
173
200
|
return report
|
|
174
201
|
|
|
175
202
|
|
package/python/import_parser.py
CHANGED
|
@@ -563,8 +563,15 @@ def _find_file_in_repo(
|
|
|
563
563
|
for path in file_index:
|
|
564
564
|
if path.endswith(suffix) or path == py_ext:
|
|
565
565
|
return path
|
|
566
|
+
|
|
567
|
+
# Fallback for Python cross-language imports (e.g. from tests to .ts/.js core logic)
|
|
568
|
+
for ext in [".ts", ".tsx", ".js", ".jsx", ".java", ".go", ".rs", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"]:
|
|
569
|
+
alt_suffix = "/" + candidate_str + ext
|
|
570
|
+
alt_matches = [p for p in file_index if p.endswith(alt_suffix) or p == alt_suffix.lstrip("/")]
|
|
571
|
+
if len(alt_matches) == 1:
|
|
572
|
+
return alt_matches[0]
|
|
573
|
+
|
|
566
574
|
return None
|
|
567
|
-
|
|
568
575
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
569
576
|
with_ext = candidate_str + ext
|
|
570
577
|
if with_ext in file_index:
|
|
@@ -578,13 +585,7 @@ def _find_file_in_repo(
|
|
|
578
585
|
if mod_path in file_index:
|
|
579
586
|
return mod_path
|
|
580
587
|
|
|
581
|
-
if extension == ".py":
|
|
582
|
-
candidate_suffix = "/" + candidate_str + ".py"
|
|
583
|
-
matches = [p for p in file_index if p.endswith(candidate_suffix)]
|
|
584
|
-
if len(matches) == 1:
|
|
585
|
-
return matches[0]
|
|
586
588
|
|
|
587
|
-
return None
|
|
588
589
|
|
|
589
590
|
|
|
590
591
|
def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
|
package/python/mcp_server.py
CHANGED
|
@@ -58,7 +58,7 @@ from import_parser import extract_skeleton
|
|
|
58
58
|
@dataclass
|
|
59
59
|
class _CacheEntry:
|
|
60
60
|
repo_path: str # resolved absolute path
|
|
61
|
-
mtime_hash: float # max mtime across all source files at build time
|
|
61
|
+
mtime_hash: tuple[float, int] # max mtime across all source files at build time, and total file count
|
|
62
62
|
scan: object # ScanResult
|
|
63
63
|
repo_graph: object # RepoGraph
|
|
64
64
|
last_checked_time: float = 0.0
|
|
@@ -68,9 +68,10 @@ _cache_lock = threading.Lock()
|
|
|
68
68
|
_cache: dict[str, _CacheEntry] = {}
|
|
69
69
|
|
|
70
70
|
|
|
71
|
-
def _compute_mtime_hash(repo_path: str) -> float:
|
|
72
|
-
"""Walk repo_path and return
|
|
71
|
+
def _compute_mtime_hash(repo_path: str) -> tuple[float, int]:
|
|
72
|
+
"""Walk repo_path and return (maximum file modification time, file count)."""
|
|
73
73
|
max_mtime = 0.0
|
|
74
|
+
file_count = 0
|
|
74
75
|
for dirpath, _, filenames in os.walk(repo_path):
|
|
75
76
|
# Skip hidden dirs and common noise dirs
|
|
76
77
|
dirpath_obj = Path(dirpath)
|
|
@@ -82,9 +83,10 @@ def _compute_mtime_hash(repo_path: str) -> float:
|
|
|
82
83
|
mtime = os.path.getmtime(os.path.join(dirpath, fname))
|
|
83
84
|
if mtime > max_mtime:
|
|
84
85
|
max_mtime = mtime
|
|
86
|
+
file_count += 1
|
|
85
87
|
except OSError:
|
|
86
88
|
pass
|
|
87
|
-
return max_mtime
|
|
89
|
+
return (max_mtime, file_count)
|
|
88
90
|
|
|
89
91
|
|
|
90
92
|
def _get_graph(repo_path: str):
|
|
@@ -52,11 +52,15 @@ def _extract_file_context(file_path: str, extension: str, root_dir: str) -> tupl
|
|
|
52
52
|
return explanation, outline, snippet
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
def export_obsidian_vault(
|
|
55
|
+
def export_obsidian_vault(repo_path: str, output_dir: str) -> str:
|
|
56
56
|
"""
|
|
57
57
|
Generates a markdown file for every node in the graph in a hierarchical folder structure,
|
|
58
58
|
with Obsidian wikilinks connecting them based on imports.
|
|
59
59
|
"""
|
|
60
|
+
scan = scan_repo(repo_path)
|
|
61
|
+
parse = parse_imports(scan)
|
|
62
|
+
repo_graph = build_graph(scan, parse)
|
|
63
|
+
|
|
60
64
|
out_path = Path(output_dir).resolve()
|
|
61
65
|
|
|
62
66
|
if out_path.exists():
|
package/python/query_engine.py
CHANGED
|
@@ -98,7 +98,7 @@ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str
|
|
|
98
98
|
score += 2.0 * idf_weights.get(term, 1.0) # Massive signal
|
|
99
99
|
matched.append(term)
|
|
100
100
|
# Substring match on filename
|
|
101
|
-
elif len(term)
|
|
101
|
+
elif len(term) >= 2 and any(term in ft for ft in filename_toks) or any(len(ft) >= 2 and ft in term for ft in filename_toks):
|
|
102
102
|
score += 1.5 * idf_weights.get(term, 1.0)
|
|
103
103
|
matched.append(term)
|
|
104
104
|
# Exact match on path
|
|
@@ -107,7 +107,7 @@ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str
|
|
|
107
107
|
if term not in matched:
|
|
108
108
|
matched.append(term)
|
|
109
109
|
# Substring match on path
|
|
110
|
-
elif len(term)
|
|
110
|
+
elif len(term) >= 2 and any(term in pt for pt in path_toks) or any(len(pt) >= 2 and pt in term for pt in path_toks):
|
|
111
111
|
score += 0.4 * idf_weights.get(term, 1.0)
|
|
112
112
|
if term not in matched:
|
|
113
113
|
matched.append(term)
|
|
@@ -186,7 +186,7 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
|
|
|
186
186
|
for ct in content_tokens_list:
|
|
187
187
|
if t == ct:
|
|
188
188
|
count += 1
|
|
189
|
-
elif len(t)
|
|
189
|
+
elif len(t) >= 2 and (t in ct or (len(ct) >= 2 and ct in t)):
|
|
190
190
|
count += 1
|
|
191
191
|
if count > 0:
|
|
192
192
|
term_counts[t] = count
|
|
@@ -322,9 +322,9 @@ def query(
|
|
|
322
322
|
for term in query_terms:
|
|
323
323
|
if term in combined_tokens:
|
|
324
324
|
document_frequency[term] += 1
|
|
325
|
-
elif len(term)
|
|
325
|
+
elif len(term) >= 2:
|
|
326
326
|
for ct in combined_tokens:
|
|
327
|
-
if term in ct:
|
|
327
|
+
if term in ct or (len(ct) >= 2 and ct in term):
|
|
328
328
|
document_frequency[term] += 1
|
|
329
329
|
break
|
|
330
330
|
|
|
@@ -381,6 +381,13 @@ def query(
|
|
|
381
381
|
matched_terms=matched_terms.get(path, []),
|
|
382
382
|
))
|
|
383
383
|
|
|
384
|
+
# Down-rank test files unless the user specifically searched for tests
|
|
385
|
+
is_test_query = "test" in query_str.lower() or "spec" in query_str.lower()
|
|
386
|
+
from impact_analysis import _is_test_file
|
|
387
|
+
for r in results:
|
|
388
|
+
if _is_test_file(r.path) and not is_test_query:
|
|
389
|
+
r.total_score *= 0.5
|
|
390
|
+
|
|
384
391
|
# Sort by total score descending
|
|
385
392
|
results.sort(key=lambda r: r.total_score, reverse=True)
|
|
386
393
|
|