contextl 1.2.10 → 1.2.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.10",
3
+ "version": "1.2.12",
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",
@@ -22,7 +22,7 @@ from scanner import ScannedFile, ScanResult
22
22
 
23
23
  # Matches any ES6 import statement and captures the module path
24
24
  IMPORT_PATTERN = re.compile(
25
- r"""import\s+(?:type\s+)? # import or import type
25
+ r"""(?:import|export)\s+(?:type\s+)? # import or import type or export
26
26
  (?:
27
27
  \{[^}]*\} # named imports: { Foo, Bar }
28
28
  |[\w*]+ # default or namespace: Foo or *
@@ -38,11 +38,12 @@ IMPORT_PATTERN = re.compile(
38
38
  DYNAMIC_IMPORT_PATTERN = re.compile(r"""import\s*\(\s*['"](.*?)['"]\s*\)""")
39
39
 
40
40
  # Python
41
- PYTHON_FROM_IMPORT_PATTERN = re.compile(r"^from\s+([.\w]+)\s+import", re.MULTILINE)
42
- PYTHON_IMPORT_PATTERN = re.compile(r"^import\s+([.\w]+)", re.MULTILINE)
41
+ PYTHON_FROM_IMPORT_PATTERN = re.compile(r"^[ \t]*from\s+([.\w]+)\s+import\s+(?:\(([^)]+)\)|([^#\n]+))", re.MULTILINE)
42
+ PYTHON_IMPORT_PATTERN = re.compile(r"^[ \t]*import\s+([.\w]+)", re.MULTILINE)
43
43
 
44
44
  # Java
45
- JAVA_IMPORT_PATTERN = re.compile(r"^import\s+(?:static\s+)?([\w.]+);", re.MULTILINE)
45
+ JAVA_IMPORT_PATTERN = re.compile(r"^import\s+(?:static\s+)?([\w.*]+);", re.MULTILINE)
46
+ JAVA_PACKAGE_PATTERN = re.compile(r"^package\s+([\w.]+);", re.MULTILINE)
46
47
 
47
48
 
48
49
  @dataclass
@@ -83,7 +84,15 @@ def _extract_raw_imports(source_code: str, extension: str) -> list[str]:
83
84
  paths.append(match.group(1))
84
85
  elif extension == ".py":
85
86
  for match in PYTHON_FROM_IMPORT_PATTERN.finditer(source_code):
86
- paths.append(match.group(1))
87
+ base = match.group(1)
88
+ paths.append(base)
89
+ symbols_str = match.group(2) or match.group(3)
90
+ if symbols_str:
91
+ symbols_str = symbols_str.replace('(', '').replace(')', '').replace('\\', '').strip()
92
+ for sym in symbols_str.split(','):
93
+ sym = sym.split(' as ')[0].strip()
94
+ if sym and sym != '*':
95
+ paths.append(f"{base}.{sym}")
87
96
  for match in PYTHON_IMPORT_PATTERN.finditer(source_code):
88
97
  paths.append(match.group(1))
89
98
  elif extension == ".java":
@@ -191,6 +200,16 @@ def _find_file_in_repo(
191
200
  for path in file_index:
192
201
  if path.endswith(suffix):
193
202
  return path
203
+
204
+ # Fallback for static method/field imports: import static com.example.MyClass.myMethod;
205
+ # In this case, candidate_str is 'com/example/MyClass/myMethod'
206
+ if "/" in candidate_str:
207
+ parent_candidate = candidate_str.rsplit("/", 1)[0]
208
+ parent_suffix = parent_candidate + ".java"
209
+ for path in file_index:
210
+ if path.endswith(parent_suffix):
211
+ return path
212
+
194
213
  return None
195
214
 
196
215
  if extension == ".py":
@@ -215,11 +234,21 @@ def _find_file_in_repo(
215
234
  if with_ext in file_index:
216
235
  return with_ext
217
236
 
218
- # Node.js: Try as a directory index file
237
+ # Node.js / Deno: Try as a directory index/mod file
219
238
  for ext in [".tsx", ".ts", ".jsx", ".js"]:
220
239
  index_path = candidate_str + "/index" + ext
221
240
  if index_path in file_index:
222
241
  return index_path
242
+ mod_path = candidate_str + "/mod" + ext
243
+ if mod_path in file_index:
244
+ return mod_path
245
+
246
+ # Fuzzy fallback for dynamic paths (like P4A recipes)
247
+ if extension == ".py":
248
+ candidate_suffix = "/" + candidate_str + ".py"
249
+ matches = [p for p in file_index if p.endswith(candidate_suffix)]
250
+ if len(matches) == 1:
251
+ return matches[0]
223
252
 
224
253
  return None
225
254
 
@@ -311,6 +340,29 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
311
340
  if alias_key not in alias_map:
312
341
  alias_map[alias_key] = target_val
313
342
 
343
+ # Detect NPM Workspaces (monorepos)
344
+ for pkg in root.rglob("package.json"):
345
+ if any(p in ignore for p in pkg.parts):
346
+ continue
347
+ try:
348
+ import json
349
+ data = json.loads(pkg.read_text(encoding="utf-8"))
350
+ name = data.get("name")
351
+ if name and isinstance(name, str):
352
+ pkg_dir = pkg.parent
353
+ try:
354
+ repo_rel = str(pkg_dir.relative_to(root)).replace("\\", "/")
355
+ if repo_rel == ".":
356
+ continue # Skip root package.json
357
+ # For absolute imports like "@meshtastic/sdk-react"
358
+ alias_map[name] = repo_rel
359
+ # For sub-path imports like "@meshtastic/sdk-react/src/foo"
360
+ alias_map[name + "/"] = repo_rel + "/"
361
+ except ValueError:
362
+ pass
363
+ except Exception:
364
+ pass
365
+
314
366
  if not alias_map:
315
367
  # Heuristic fallback: single top-level source dir
316
368
  top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
@@ -342,6 +394,27 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
342
394
  # Auto-detect alias map (e.g. @/ → frontend/)
343
395
  alias_map = _detect_alias_map(scan_result)
344
396
 
397
+ # Pre-pass for Java: build package map and implicit same-package relationships
398
+ java_packages = {}
399
+ for scanned_file in scan_result.files:
400
+ if scanned_file.extension == ".java":
401
+ try:
402
+ source_code = Path(scanned_file.absolute_path).read_text(encoding="utf-8")
403
+ pkg_match = JAVA_PACKAGE_PATTERN.search(source_code)
404
+ if pkg_match:
405
+ pkg = pkg_match.group(1)
406
+ java_packages.setdefault(pkg, []).append(scanned_file.path)
407
+ except Exception:
408
+ pass
409
+
410
+ for pkg, files in java_packages.items():
411
+ for source_file in files:
412
+ for target_file in files:
413
+ if source_file != target_file:
414
+ result.relationships.append(
415
+ ImportRelationship(source=source_file, target=target_file, raw_import=f"implicit_package:{pkg}")
416
+ )
417
+
345
418
  for scanned_file in scan_result.files:
346
419
  try:
347
420
  source_code = Path(scanned_file.absolute_path).read_text(encoding="utf-8")
@@ -357,6 +430,14 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
357
430
 
358
431
  # Resolve to a candidate path string
359
432
  if scanned_file.extension == ".java":
433
+ if raw.endswith(".*"):
434
+ pkg = raw[:-2]
435
+ for target_file in java_packages.get(pkg, []):
436
+ if target_file != scanned_file.path:
437
+ result.relationships.append(
438
+ ImportRelationship(source=scanned_file.path, target=target_file, raw_import=raw)
439
+ )
440
+ continue
360
441
  candidate = raw.replace(".", "/")
361
442
  elif scanned_file.extension == ".py":
362
443
  if raw.startswith("."):
package/python/main.py CHANGED
@@ -198,9 +198,9 @@ def build_parser() -> argparse.ArgumentParser:
198
198
  search_parser.add_argument("--top", "-n", type=int, default=5, help="Number of results to return (default: 5)")
199
199
  search_parser.add_argument("--json", action="store_true", help="Output clean JSON instead of human-readable text")
200
200
 
201
- # 2. Dead Code
202
- dead_parser = subparsers.add_parser("dead-code", help="Find files with 0 in-degree dependencies")
203
- dead_parser.add_argument("repo_path", help="Path to the repository root")
201
+ # 2. Standalone Files
202
+ standalone_parser = subparsers.add_parser("standalone", help="Find files with 0 in-degree dependencies (unimported)")
203
+ standalone_parser.add_argument("repo_path", help="Path to the repository root")
204
204
 
205
205
  # 3. Impact Analysis
206
206
  impact_parser = subparsers.add_parser("impact", help="Analyze the impact of changing a file")
@@ -218,7 +218,7 @@ def build_parser() -> argparse.ArgumentParser:
218
218
  def main():
219
219
  # To maintain backward compatibility with old `contextl <repo> <query>`
220
220
  # we manually inject "search" if the first argument isn't a known command.
221
- if len(sys.argv) >= 2 and sys.argv[1] not in ["search", "dead-code", "impact", "obsidian", "-h", "--help"]:
221
+ if len(sys.argv) >= 2 and sys.argv[1] not in ["search", "standalone", "impact", "obsidian", "-h", "--help"]:
222
222
  sys.argv.insert(1, "search")
223
223
 
224
224
  parser = build_parser()
@@ -243,14 +243,14 @@ def main():
243
243
  else:
244
244
  print(_format_human(args.query, args.repo_path, results, repo_graph, elapsed))
245
245
 
246
- elif args.command == "dead-code":
247
- from dead_code import find_dead_files
246
+ elif args.command == "standalone":
247
+ from standalone import find_standalone_files
248
248
  scan = scan_repo(args.repo_path)
249
249
  parse = parse_imports(scan)
250
250
  repo_graph = build_graph(scan, parse)
251
- dead = find_dead_files(repo_graph)
252
- print(f"Found {len(dead)} potentially dead files:")
253
- for d in sorted(dead):
251
+ standalone = find_standalone_files(repo_graph)
252
+ print(f"Found {len(standalone)} standalone files:")
253
+ for d in sorted(standalone):
254
254
  print(f" {d}")
255
255
 
256
256
  elif args.command == "impact":
@@ -35,7 +35,7 @@ from graph_builder import build_graph
35
35
  from query_engine import query as engine_query
36
36
  from main import _confidence, _reasoning
37
37
  from impact_analysis import analyze_impact
38
- from dead_code import find_dead_files
38
+ from standalone import find_standalone_files
39
39
  from obsidian_export import export_obsidian_vault
40
40
 
41
41
 
@@ -145,11 +145,11 @@ async def list_tools() -> list[types.Tool]:
145
145
  },
146
146
  ),
147
147
  types.Tool(
148
- name="find_dead_files",
148
+ name="find_standalone_files",
149
149
  description=(
150
150
  "Find files in the repository that are never imported by any other file "
151
151
  "(in-degree of 0 in the dependency graph). Automatically filters out "
152
- "standard entry points and test files. Use this to identify dead code "
152
+ "standard entry points and test files. Use this to identify standalone code "
153
153
  "and unused files that can potentially be deleted."
154
154
  ),
155
155
  inputSchema={
@@ -203,8 +203,8 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
203
203
  if name == "analyze_impact":
204
204
  return await _handle_impact(arguments)
205
205
 
206
- if name == "find_dead_files":
207
- return await _handle_dead_files(arguments)
206
+ if name == "find_standalone_files":
207
+ return await _handle_standalone_files(arguments)
208
208
 
209
209
  if name == "export_obsidian_vault":
210
210
  return await _handle_export_obsidian(arguments)
@@ -321,30 +321,30 @@ def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
321
321
  }
322
322
 
323
323
 
324
- async def _handle_dead_files(args: dict) -> list[types.TextContent]:
324
+ async def _handle_standalone_files(args: dict) -> list[types.TextContent]:
325
325
  repo_path = args.get("repo_path", "")
326
326
 
327
327
  loop = asyncio.get_event_loop()
328
328
  try:
329
- result = await loop.run_in_executor(None, _run_dead_files, repo_path)
329
+ result = await loop.run_in_executor(None, _run_standalone_files, repo_path)
330
330
  except Exception as e:
331
331
  result = {"error": str(e), "repo": repo_path}
332
332
 
333
333
  return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
334
334
 
335
335
 
336
- def _run_dead_files(repo_path: str) -> dict:
336
+ def _run_standalone_files(repo_path: str) -> dict:
337
337
  """Synchronous pipeline — called from a thread executor."""
338
338
  scan = scan_repo(repo_path)
339
339
  parse = parse_imports(scan)
340
340
  repo_graph = build_graph(scan, parse)
341
- dead_files = find_dead_files(repo_graph)
341
+ standalone_files = find_standalone_files(repo_graph)
342
342
 
343
343
  return {
344
344
  "repo": scan.root,
345
345
  "total_files_scanned": scan.total_files,
346
- "total_dead_files": len(dead_files),
347
- "dead_files": dead_files
346
+ "total_standalone_files": len(standalone_files),
347
+ "standalone_files": standalone_files
348
348
  }
349
349
 
350
350
 
@@ -19,68 +19,95 @@ from graph_builder import build_graph, RepoGraph
19
19
 
20
20
  PYTHON_DOCSTRING_PATTERN = re.compile(r'^\s*["\']{3}(.*?)["\']{3}', re.DOTALL)
21
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)
22
23
 
23
- def _extract_explanation(file_path: str, extension: str, root_dir: str) -> str:
24
- """Reads the top-level docstring or JSDoc block from the file."""
24
+ def _extract_file_context(file_path: str, extension: str, root_dir: str) -> tuple[str, list[str], str]:
25
+ """Returns (explanation, outline_symbols, snippet)."""
26
+ explanation = "*No explanation provided in source code.*"
27
+ outline = []
28
+ snippet = ""
25
29
  try:
26
30
  content = (Path(root_dir) / file_path).read_text(encoding="utf-8")
31
+
32
+ # 1. Snippet (first 20 lines)
33
+ lines = content.splitlines()
34
+ snippet = "\n".join(lines[:20])
35
+ if len(lines) > 20:
36
+ snippet += "\n..."
37
+
38
+ # 2. Explanation
27
39
  if extension == ".py":
28
40
  match = PYTHON_DOCSTRING_PATTERN.search(content)
29
41
  if match:
30
- return match.group(1).strip()
42
+ explanation = match.group(1).strip()
31
43
  elif extension in {".ts", ".tsx", ".js", ".jsx", ".java"}:
32
44
  match = JS_DOC_PATTERN.search(content)
33
45
  if match:
34
- # Clean up leading asterisks from JSDoc lines
35
- lines = match.group(1).split('\n')
36
- cleaned = [line.strip().lstrip('*').strip() for line in lines]
37
- return "\n".join(cleaned).strip()
46
+ clean_lines = [line.strip().lstrip('*').strip() for line in match.group(1).split('\n')]
47
+ explanation = "\n".join(clean_lines).strip()
48
+
49
+ # 3. Outline
50
+ for match in OUTLINE_PATTERN.finditer(content):
51
+ outline.append(match.group(1))
52
+
38
53
  except Exception:
39
54
  pass
40
- return "*No explanation provided in source code.*"
55
+
56
+ return explanation, outline, snippet
41
57
 
42
58
 
43
59
  def export_obsidian_vault(repo_graph: RepoGraph, output_dir: str) -> str:
44
60
  """
45
- Generates a markdown file for every node in the graph, with
46
- Obsidian wikilinks connecting them based on imports.
61
+ Generates a markdown file for every node in the graph in a hierarchical folder structure,
62
+ with Obsidian wikilinks connecting them based on imports.
47
63
  """
48
64
  out_path = Path(output_dir).resolve()
49
65
 
50
- # Create the output directory (clean it if it exists)
51
66
  if out_path.exists():
52
67
  shutil.rmtree(out_path)
53
68
  out_path.mkdir(parents=True)
54
69
 
55
70
  for path, node in repo_graph.nodes.items():
56
- # We replace slashes with dashes to keep a flat folder structure
57
- # so Obsidian graph view looks clean, but keep original name as title
58
- safe_name = path.replace("/", "-").replace("\\", "-")
59
- md_file = out_path / f"{safe_name}.md"
71
+ md_file = out_path / f"{path}.md"
72
+ md_file.parent.mkdir(parents=True, exist_ok=True)
60
73
 
61
74
  dependencies = repo_graph.get_dependencies(path)
62
75
  dependents = repo_graph.get_dependents(path)
63
76
 
64
- explanation = _extract_explanation(path, node.extension, repo_graph.root)
77
+ explanation, outline, snippet = _extract_file_context(path, node.extension, repo_graph.root)
65
78
 
66
79
  content = [
67
- f"# {path}",
80
+ f"# {Path(path).name}",
81
+ "",
82
+ "## Architecture Metrics",
83
+ f"- **Path:** `{path}`",
84
+ f"- **Extension:** `{node.extension}`",
85
+ f"- **Size:** {node.size_bytes} bytes",
86
+ f"- **Centrality Score:** {node.centrality:.4f}",
87
+ f"- **In-Degree (Imported By):** {len(dependents)}",
88
+ f"- **Out-Degree (Imports):** {len(dependencies)}",
68
89
  "",
69
90
  "## Explanation",
70
91
  explanation,
71
92
  "",
72
- "## Metrics",
73
- f"**Extension:** `{node.extension}`",
74
- f"**Size:** {node.size_bytes} bytes",
75
- f"**Centrality Score:** {node.centrality:.4f}",
76
- "",
77
- "## Imports (Dependencies)",
93
+ "## Structural Outline",
78
94
  ]
79
95
 
96
+ if outline:
97
+ for symbol in outline:
98
+ content.append(f"- `{symbol}`")
99
+ else:
100
+ content.append("*No major classes or functions detected.*")
101
+
102
+ content.extend([
103
+ "",
104
+ "## Imports (Dependencies)"
105
+ ])
106
+
80
107
  if dependencies:
81
108
  for dep in sorted(dependencies):
82
- dep_safe = dep.replace("/", "-").replace("\\", "-")
83
- content.append(f"- [[{dep_safe}]]")
109
+ # Use full relative path for accurate linking in hierarchical vaults
110
+ content.append(f"- [[{dep}.md|{dep}]]")
84
111
  else:
85
112
  content.append("*No internal imports*")
86
113
 
@@ -91,11 +118,18 @@ def export_obsidian_vault(repo_graph: RepoGraph, output_dir: str) -> str:
91
118
 
92
119
  if dependents:
93
120
  for dep in sorted(dependents):
94
- dep_safe = dep.replace("/", "-").replace("\\", "-")
95
- content.append(f"- [[{dep_safe}]]")
121
+ content.append(f"- [[{dep}.md|{dep}]]")
96
122
  else:
97
123
  content.append("*Not imported by any file*")
98
124
 
125
+ content.extend([
126
+ "",
127
+ "## Source Code Snippet",
128
+ f"```{node.extension.lstrip('.')}",
129
+ snippet,
130
+ "```"
131
+ ])
132
+
99
133
  md_file.write_text("\n".join(content), encoding="utf-8")
100
134
 
101
135
  return str(out_path)
@@ -1,6 +1,6 @@
1
1
  """
2
2
  Repository Intelligence Engine
3
- Step 7: Dead Code Analysis
3
+ Step 7: Standalone Files Analysis
4
4
 
5
5
  Finds files in the dependency graph that have an in-degree of 0
6
6
  (meaning they are never imported by any other file), while filtering
@@ -19,29 +19,33 @@ ENTRY_POINT_MARKERS = {
19
19
  "globals.css", "global.css", "tailwind.css",
20
20
  "index.ts", "index.js", "index.tsx", "index.jsx",
21
21
  "main.ts", "main.js", "main.py",
22
+ "app.ts", "app.js", "app.tsx", "app.jsx", "app.py",
23
+ "mcp_server.py", "server.ts", "server.js", "server.py",
22
24
  "__main__.py", "__init__.py", "setup.py", "conftest.py",
23
25
  "next.config", "tailwind.config", "postcss.config", "vite.config", "webpack.config"
24
26
  }
25
27
 
26
28
  def _is_entry_point(path: str) -> bool:
27
29
  lowered = path.lower()
30
+ if lowered.endswith(".d.ts"):
31
+ return True
28
32
  for marker in ENTRY_POINT_MARKERS:
29
33
  if marker in lowered:
30
34
  return True
31
35
  return False
32
36
 
33
- def find_dead_files(repo_graph: RepoGraph) -> list[str]:
37
+ def find_standalone_files(repo_graph: RepoGraph) -> list[str]:
34
38
  """
35
39
  Find files with in-degree 0, excluding entry points and tests.
36
40
  """
37
- dead_files = []
41
+ standalone_files = []
38
42
 
39
43
  for path, node in repo_graph.nodes.items():
40
44
  if node.in_degree == 0:
41
45
  if not _is_test_file(path) and not _is_entry_point(path):
42
- dead_files.append(path)
46
+ standalone_files.append(path)
43
47
 
44
- return sorted(dead_files)
48
+ return sorted(standalone_files)
45
49
 
46
50
  if __name__ == "__main__":
47
51
  import sys
@@ -50,7 +54,7 @@ if __name__ == "__main__":
50
54
  from graph_builder import build_graph
51
55
 
52
56
  if len(sys.argv) < 2:
53
- print("Usage: python dead_code.py <repo_path>")
57
+ print("Usage: python standalone.py <repo_path>")
54
58
  sys.exit(1)
55
59
 
56
60
  repo_path = sys.argv[1]
@@ -58,8 +62,8 @@ if __name__ == "__main__":
58
62
  parse = parse_imports(scan)
59
63
  repo_graph = build_graph(scan, parse)
60
64
 
61
- dead = find_dead_files(repo_graph)
65
+ standalone = find_standalone_files(repo_graph)
62
66
 
63
- print(f"Found {len(dead)} potentially dead files:")
64
- for f in dead:
67
+ print(f"Found {len(standalone)} standalone files:")
68
+ for f in standalone:
65
69
  print(f" - {f}")