contextl 1.2.9 → 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.9",
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":
@@ -134,12 +143,26 @@ def _resolve_alias(import_path: str, alias_map: dict[str, str]) -> str | None:
134
143
  def _resolve_relative(import_path: str, source_file: str) -> str:
135
144
  """
136
145
  Resolve a relative import path against the source file's directory.
137
- Example: source="frontend/app/page.tsx", import="../lib/api" "frontend/lib/api"
146
+ Returns a repo-relative path string (NOT absolute).
147
+
148
+ Example: source="apps/web/src/routes.tsx", import="./App.tsx"
149
+ → "apps/web/src/App.tsx"
138
150
  """
139
- source_dir = Path(source_file).parent
140
- resolved = (source_dir / import_path).resolve()
141
- # Make it relative again (we'll strip the absolute prefix below)
142
- return str(resolved)
151
+ # Use pure PurePosixPath arithmetic — never call .resolve() which
152
+ # anchors to the OS cwd and produces a wrong absolute path.
153
+ from pathlib import PurePosixPath
154
+ source_dir = PurePosixPath(source_file).parent
155
+ # Normalise away any .. and . components without touching the filesystem
156
+ resolved = (source_dir / import_path)
157
+ # PurePosixPath doesn't have .resolve(), so manually normalise ".."
158
+ parts = []
159
+ for part in resolved.parts:
160
+ if part == "..":
161
+ if parts:
162
+ parts.pop()
163
+ elif part != ".":
164
+ parts.append(part)
165
+ return str(PurePosixPath(*parts)) if parts else "."
143
166
 
144
167
 
145
168
  def _find_file_in_repo(
@@ -177,6 +200,16 @@ def _find_file_in_repo(
177
200
  for path in file_index:
178
201
  if path.endswith(suffix):
179
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
+
180
213
  return None
181
214
 
182
215
  if extension == ".py":
@@ -201,50 +234,137 @@ def _find_file_in_repo(
201
234
  if with_ext in file_index:
202
235
  return with_ext
203
236
 
204
- # Node.js: Try as a directory index file
237
+ # Node.js / Deno: Try as a directory index/mod file
205
238
  for ext in [".tsx", ".ts", ".jsx", ".js"]:
206
239
  index_path = candidate_str + "/index" + ext
207
240
  if index_path in file_index:
208
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]
209
252
 
210
253
  return None
211
254
 
212
255
 
256
+ def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
257
+ """
258
+ Load compilerOptions.paths from a tsconfig.json, following `extends` chains.
259
+ Returns merged paths dict (child overrides parent).
260
+ """
261
+ import json
262
+ visited = set()
263
+ merged_paths = {}
264
+
265
+ def _load(path: Path):
266
+ if path in visited:
267
+ return
268
+ visited.add(path)
269
+ try:
270
+ text = path.read_text(encoding="utf-8")
271
+ pattern = r'(".*?(?<!\\)")|(/\*.*?\*/|//[^\r\n]*)'
272
+ text = re.sub(pattern, lambda m: m.group(1) if m.group(1) else '', text, flags=re.S)
273
+ data = json.loads(text)
274
+ except Exception:
275
+ return
276
+
277
+ # Follow extends first (parent paths are base, child overrides)
278
+ extends = data.get("extends")
279
+ if extends:
280
+ parent = (path.parent / extends).resolve()
281
+ # handle both with and without .json
282
+ if not parent.suffix:
283
+ parent = parent.with_suffix(".json")
284
+ _load(parent)
285
+
286
+ # Then apply this tsconfig's own paths (child wins)
287
+ paths = data.get("compilerOptions", {}).get("paths", {})
288
+ for alias, targets in paths.items():
289
+ if targets:
290
+ merged_paths[alias] = targets[0]
291
+
292
+ _load(tsconfig_path)
293
+ return merged_paths
294
+
295
+
213
296
  def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
214
297
  """
215
- Auto-detect the @/ alias by finding tsconfig.json or next.config files
216
- and inferring the project root. Falls back to a heuristic.
217
-
218
- Returns a map like {"@/": "frontend/"} or {"@/": ""}
298
+ Auto-detect TypeScript path aliases by reading every tsconfig.json in the repo.
299
+ Each tsconfig's aliases are scoped relative to its own directory so that
300
+ multiple packages with conflicting @/* aliases don't clobber each other.
301
+
302
+ Returns a flat map: alias_prefix -> resolved_repo_relative_prefix
303
+ e.g. {
304
+ '@/' : 'packages/ui/src/',
305
+ '@components/' : 'apps/web/src/components/',
306
+ '@core/' : 'apps/web/src/core/',
307
+ }
219
308
  """
220
309
  root = Path(scan_result.root)
221
- alias_map = {}
310
+ alias_map: dict[str, str] = {}
222
311
 
223
- # Look for tsconfig.json to find paths config
224
- for tsconfig in root.rglob("tsconfig.json"):
312
+ # Walk all tsconfig.json files (skip node_modules / build dirs)
313
+ ignore = {"node_modules", "dist", "build", ".next", "coverage", "out"}
314
+ tsconfigs = []
315
+ for tc in root.rglob("tsconfig.json"):
316
+ if any(p in ignore for p in tc.parts):
317
+ continue
318
+ tsconfigs.append(tc)
319
+
320
+ for tsconfig in tsconfigs:
321
+ tsconfig_dir = tsconfig.parent
322
+ paths = _load_tsconfig_paths(tsconfig)
323
+
324
+ for alias, target in paths.items():
325
+ # Normalise: strip trailing /* or *
326
+ clean_alias = alias.rstrip("/*").rstrip("*")
327
+ clean_target = target.rstrip("/*").rstrip("*").lstrip("./")
328
+
329
+ # Resolve target relative to the tsconfig's own directory
330
+ resolved = (tsconfig_dir / clean_target).resolve()
331
+ try:
332
+ repo_rel = str(resolved.relative_to(root))
333
+ except ValueError:
334
+ continue # outside repo — skip
335
+
336
+ alias_key = clean_alias + "/"
337
+ target_val = repo_rel.replace("\\", "/") + "/"
338
+
339
+ # Only add if not already defined (first match / most specific wins)
340
+ if alias_key not in alias_map:
341
+ alias_map[alias_key] = target_val
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
225
347
  try:
226
348
  import json
227
- # naive regex to strip comments from tsconfig before parsing
228
- text = tsconfig.read_text()
229
- text = re.sub(r'//.*?\n|/\*.*?\*/', '', text, flags=re.S)
230
- data = json.loads(text)
231
- paths = data.get("compilerOptions", {}).get("paths", {})
232
-
233
- for alias, targets in paths.items():
234
- if not targets:
235
- continue
236
- # "@/*": ["./src/*"] strip trailing /* from both
237
- clean_alias = alias.rstrip("/*").rstrip("*")
238
- clean_target = targets[0].rstrip("/*").rstrip("*").lstrip("./")
239
-
240
- tsconfig_dir = tsconfig.parent.relative_to(root)
241
- prefix = str(tsconfig_dir / clean_target).lstrip("./")
242
- alias_map[clean_alias + "/"] = prefix + "/" if prefix else ""
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
243
363
  except Exception:
244
- continue
364
+ pass
245
365
 
246
366
  if not alias_map:
247
- # Heuristic fallback: if all files share a common top-level dir, use that
367
+ # Heuristic fallback: single top-level source dir
248
368
  top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
249
369
  if len(top_dirs) == 1:
250
370
  top = list(top_dirs)[0]
@@ -274,6 +394,27 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
274
394
  # Auto-detect alias map (e.g. @/ → frontend/)
275
395
  alias_map = _detect_alias_map(scan_result)
276
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
+
277
418
  for scanned_file in scan_result.files:
278
419
  try:
279
420
  source_code = Path(scanned_file.absolute_path).read_text(encoding="utf-8")
@@ -289,6 +430,14 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
289
430
 
290
431
  # Resolve to a candidate path string
291
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
292
441
  candidate = raw.replace(".", "/")
293
442
  elif scanned_file.extension == ".py":
294
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)
@@ -161,6 +161,13 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
161
161
  for part in re.findall(r"[a-z0-9]+", sub):
162
162
  if len(part) > 3:
163
163
  java_methods.add(part)
164
+
165
+ py_defs = set()
166
+ if file_path.endswith(".py"):
167
+ defs = re.findall(r"\b(?:def|async def)\s+([a-zA-Z0-9_]+)\s*\(", file_content)
168
+ for d in defs:
169
+ if d.lower() not in {"main", "init", "get", "set"}:
170
+ py_defs.add(d.lower())
164
171
 
165
172
  if is_ts:
166
173
  exports = re.findall(r"\bexport\s+(?:const|let|var|class|interface|function|default)\s+([a-zA-Z0-9_]+)", file_content)
@@ -213,7 +220,7 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
213
220
  multiplier = 8.0
214
221
  elif t in declared_classes:
215
222
  multiplier = class_mult
216
- elif t in java_methods:
223
+ elif t in java_methods or t in py_defs:
217
224
  multiplier = 4.0
218
225
  elif t in annotations:
219
226
  multiplier = ann_mult
@@ -340,9 +347,10 @@ def query(
340
347
  if df == 0:
341
348
  idf_weights[term] = 0.0
342
349
  else:
343
- idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
350
+ idf_weights[term] = max(0.1, math.log(total_files / (1 + df))) if total_files > 0 else 1.0
344
351
 
345
352
  # --- Pass 1: keyword + content scores per file ---
353
+ base_scores: dict[str, float] = {}
346
354
  keyword_scores: dict[str, float] = {}
347
355
  content_scores: dict[str, float] = {}
348
356
  matched_terms: dict[str, list[str]] = {}
@@ -355,14 +363,15 @@ def query(
355
363
  keyword_scores[path] = kscore
356
364
  content_scores[path] = cscore
357
365
  matched_terms[path] = list(set(kterms + cterms))
358
-
359
- # --- Pass 2: combine into base score ---
360
- base_scores: dict[str, float] = {}
361
- for path in repo_graph.nodes:
362
- base_scores[path] = (
363
- keyword_scores.get(path, 0.0) * 0.5 +
364
- content_scores.get(path, 0.0) * 0.5
365
- )
366
+
367
+ # Base combination: 50/50 keyword vs content
368
+ combined = (kscore * 0.5) + (cscore * 0.5)
369
+
370
+ # Tiebreaker: Slightly penalize deep paths so src/x.py wins over build/npm/python/x.py
371
+ depth_penalty = 0.0001 * path.count("/")
372
+ combined -= depth_penalty
373
+
374
+ base_scores[path] = combined
366
375
 
367
376
  # --- Pass 3: neighbor bonus ---
368
377
  boosted_scores = _apply_neighbor_bonus(base_scores, repo_graph)
package/python/scanner.py CHANGED
@@ -7,24 +7,23 @@ Filters by supported extensions for the MVP (Next.js / React / TypeScript).
7
7
  """
8
8
 
9
9
  import os
10
+ import pathlib
10
11
  from pathlib import Path
11
12
  from dataclasses import dataclass, field
12
13
 
13
14
 
14
15
  SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java"}
15
16
 
16
- IGNORED_DIRS = {
17
- "node_modules",
18
- ".git",
19
- ".next",
20
- "dist",
21
- "build",
22
- ".cache",
23
- "__pycache__",
24
- ".turbo",
25
- "coverage",
17
+ ignore_dirs = {
18
+ ".git", "node_modules", "__pycache__", "venv", ".venv",
19
+ "env", ".env", ".next", ".nuxt", "out", "build", "dist",
20
+ "coverage", ".nyc_output", ".pytest_cache", "npm", "pypi"
26
21
  }
27
22
 
23
+ def _should_ignore(path: str) -> bool:
24
+ parts = pathlib.Path(path).parts
25
+ return any(part in ignore_dirs for part in parts)
26
+
28
27
 
29
28
  @dataclass
30
29
  class ScannedFile:
@@ -87,7 +86,7 @@ def scan_repo(repo_path: str) -> ScanResult:
87
86
 
88
87
  for dirpath, dirnames, filenames in os.walk(root):
89
88
  # Prune ignored directories in-place so os.walk skips them
90
- dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRS]
89
+ dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
91
90
 
92
91
  for filename in filenames:
93
92
  filepath = Path(dirpath) / filename