contextl 1.2.10 → 1.2.11

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.11",
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("."):
@@ -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)