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 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
- *"Which files are never imported by anything?"*
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
- Finds unused files and dead code by analyzing the dependency graph for files with an in-degree of 0. Automatically filters out standard entry points (like `page.tsx` or `index.ts`) and test files.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.19",
3
+ "version": "1.2.20",
4
4
  "description": "contextl — finds the most relevant files in your codebase for any change request. MCP server for AI coding agents.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -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
 
@@ -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:
@@ -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) > 3 and any(term in ft for ft in filename_toks):
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) > 3 and any(term in pt for pt in path_toks):
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) > 3 and t in ct:
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) > 3:
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