contextl 1.2.19 → 1.2.20
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 +22 -0
- package/python/import_parser.py +8 -7
- package/python/query_engine.py +5 -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:
|
|
@@ -170,6 +180,18 @@ def analyze_impact(
|
|
|
170
180
|
|
|
171
181
|
report.total_affected = len(report.directly_affected) + len(report.transitively_affected)
|
|
172
182
|
|
|
183
|
+
import networkx as nx
|
|
184
|
+
sccs = [scc for scc in nx.strongly_connected_components(repo_graph.graph) if len(scc) > 1]
|
|
185
|
+
for scc in sccs:
|
|
186
|
+
if target_file in scc:
|
|
187
|
+
report.cycle_detected = True
|
|
188
|
+
report.cycle_members = list(scc)
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
target_node = repo_graph.nodes.get(target_file)
|
|
192
|
+
in_degree = target_node.in_degree if target_node else 0
|
|
193
|
+
report.risk_score = (in_degree * 0.5) + (len(report.transitively_affected) * 0.2)
|
|
194
|
+
|
|
173
195
|
return report
|
|
174
196
|
|
|
175
197
|
|
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)]
|
|
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/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) >= 3 and any(term in ft for ft in filename_toks) or any(len(ft) >= 3 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) >= 3 and any(term in pt for pt in path_toks) or any(len(pt) >= 3 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) >= 3 and (t in ct or (len(ct) >= 3 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) >= 3:
|
|
326
326
|
for ct in combined_tokens:
|
|
327
|
-
if term in ct:
|
|
327
|
+
if term in ct or (len(ct) >= 3 and ct in term):
|
|
328
328
|
document_frequency[term] += 1
|
|
329
329
|
break
|
|
330
330
|
|