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 +1 -1
- package/python/import_parser.py +87 -6
- package/python/main.py +9 -9
- package/python/mcp_server.py +11 -11
- package/python/obsidian_export.py +61 -27
- package/python/{dead_code.py → standalone.py} +13 -9
package/package.json
CHANGED
package/python/import_parser.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
202
|
-
|
|
203
|
-
|
|
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", "
|
|
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 == "
|
|
247
|
-
from
|
|
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
|
-
|
|
252
|
-
print(f"Found {len(
|
|
253
|
-
for d in sorted(
|
|
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":
|
package/python/mcp_server.py
CHANGED
|
@@ -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
|
|
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="
|
|
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
|
|
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 == "
|
|
207
|
-
return await
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
347
|
-
"
|
|
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
|
|
24
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
57
|
-
|
|
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 =
|
|
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
|
-
"##
|
|
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
|
-
|
|
83
|
-
content.append(f"- [[{
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
standalone_files.append(path)
|
|
43
47
|
|
|
44
|
-
return sorted(
|
|
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
|
|
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
|
-
|
|
65
|
+
standalone = find_standalone_files(repo_graph)
|
|
62
66
|
|
|
63
|
-
print(f"Found {len(
|
|
64
|
-
for f in
|
|
67
|
+
print(f"Found {len(standalone)} standalone files:")
|
|
68
|
+
for f in standalone:
|
|
65
69
|
print(f" - {f}")
|