contextl-mcp 0.1.2__py3-none-any.whl
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.
- contextl_mcp-0.1.2.dist-info/METADATA +150 -0
- contextl_mcp-0.1.2.dist-info/RECORD +12 -0
- contextl_mcp-0.1.2.dist-info/WHEEL +4 -0
- contextl_mcp-0.1.2.dist-info/entry_points.txt +2 -0
- prune_mcp/__init__.py +1 -0
- prune_mcp/__main__.py +46 -0
- python/graph_builder.py +171 -0
- python/import_parser.py +271 -0
- python/main.py +237 -0
- python/mcp_server.py +201 -0
- python/query_engine.py +252 -0
- python/scanner.py +125 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contextl-mcp
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Repository Intelligence Engine — MCP server for AI coding agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/dev7shah/prune
|
|
6
|
+
Project-URL: Issues, https://github.com/dev7shah/prune/issues
|
|
7
|
+
Author: dev7shah
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ai,claude,code-search,context,cursor,mcp,model-context-protocol,nextjs,repository,typescript,vscode,windsurf
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: mcp
|
|
23
|
+
Requires-Dist: networkx
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# contextl
|
|
27
|
+
|
|
28
|
+
> **Context-selection engine for AI coding assistants.**
|
|
29
|
+
> Finds the most relevant files in your codebase for a natural-language change request — no LLM, no embeddings, no vector database. Pure graph + text scoring.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
"fix the upload error" → [FileUploader.tsx, lib/upload.ts, UploadSection.tsx]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Instead of feeding your entire repo to an AI, `contextl` reduces 5 000 files down to the 5 most relevant ones in milliseconds.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install contextl
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Python 3.9+ required. `networkx` and `mcp` are installed automatically as dependencies.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Quick start — connect to your IDE
|
|
50
|
+
|
|
51
|
+
Paste this into your IDE's MCP config file:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"contextl": {
|
|
57
|
+
"command": "contextl"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
#### Config file locations
|
|
64
|
+
|
|
65
|
+
| IDE | Config file |
|
|
66
|
+
|-----|-------------|
|
|
67
|
+
| **Antigravity** | `~/.gemini/antigravity/mcp/` (MCP server directory) |
|
|
68
|
+
| **Cursor** | `~/.cursor/mcp.json` |
|
|
69
|
+
| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` |
|
|
70
|
+
| **Claude Code** | `~/.claude.json` (or run `claude mcp add`) |
|
|
71
|
+
| **VS Code** | `.vscode/mcp.json` in your workspace root |
|
|
72
|
+
|
|
73
|
+
### Use it
|
|
74
|
+
|
|
75
|
+
Just talk to your IDE's AI normally:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
You: "fix the file upload error handler"
|
|
79
|
+
IDE: calls query_repo → gets [FileUploader.tsx, lib/upload.ts, …]
|
|
80
|
+
IDE: reads only those 5 files instead of the whole repo
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Tools exposed
|
|
86
|
+
|
|
87
|
+
### `query_repo(repo_path, query, top_n?)`
|
|
88
|
+
|
|
89
|
+
Ranks the most relevant files for a change request.
|
|
90
|
+
|
|
91
|
+
**Parameters:**
|
|
92
|
+
- `repo_path` — absolute path to the repository root
|
|
93
|
+
- `query` — natural-language description of the change (e.g. `"change the download button color"`)
|
|
94
|
+
- `top_n` — max results to return (default `5`, max `20`)
|
|
95
|
+
|
|
96
|
+
**Returns:**
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"query": "change the download button",
|
|
100
|
+
"repo": "/path/to/repo",
|
|
101
|
+
"total_files_scanned": 142,
|
|
102
|
+
"results": [
|
|
103
|
+
{
|
|
104
|
+
"rank": 1,
|
|
105
|
+
"path": "components/DownloadButton.tsx",
|
|
106
|
+
"score": 0.9800,
|
|
107
|
+
"confidence": "high",
|
|
108
|
+
"matched_terms": ["button", "download"],
|
|
109
|
+
"reasoning": "Filename strongly matches query terms; file contents heavily reference query terms."
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `scan_repo(repo_path)`
|
|
116
|
+
|
|
117
|
+
Lists all source files the engine can see in a repository.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## How it works
|
|
122
|
+
|
|
123
|
+
The engine runs **entirely locally** — no network calls, no AI APIs, no data leaves your machine.
|
|
124
|
+
|
|
125
|
+
Scoring uses four signals:
|
|
126
|
+
|
|
127
|
+
| Signal | Weight | Description |
|
|
128
|
+
|--------|--------|-------------|
|
|
129
|
+
| Keyword match | 0.5 | Query terms in the file path / name |
|
|
130
|
+
| Content match | 0.5 | Query terms inside the file source |
|
|
131
|
+
| Neighbor bonus | +0.15 | Files near high-scoring files in the import graph |
|
|
132
|
+
| PageRank | 0.05 | Tiebreaker: more connected files rank slightly higher |
|
|
133
|
+
|
|
134
|
+
Supports **Next.js / React / TypeScript** repos (`.ts`, `.tsx`, `.js`, `.jsx`). Automatically detects `@/` path aliases from `tsconfig.json`.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Also available as an npm package
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npx contextl
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
For IDE configs that prefer `npx` over a Python command.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
prune_mcp/__init__.py,sha256=YvuYzWnKtqBb-IqG8HAu-nhIYAsgj9Vmc_b9o7vO-js,22
|
|
2
|
+
prune_mcp/__main__.py,sha256=H0u5ZJVpl1c9Mq914yWTBEl_TJfcBqwlChT6Ne70fhw,1463
|
|
3
|
+
python/graph_builder.py,sha256=NRpBQxDmCXmlBOpxHmiHv2LZKI8ece-Pngv8pQKEk7c,5536
|
|
4
|
+
python/import_parser.py,sha256=acvH6iy9S_aMa_DqV-jZuTZANHv6hMOT998pTQt-0Kk,9252
|
|
5
|
+
python/main.py,sha256=KdD_RZW8xZ126zKwFXMyOlm4V3U5n-8qY7N0xuRJ9N0,7616
|
|
6
|
+
python/mcp_server.py,sha256=tyzuI2dwIN_ISinX95WuKQZ2cHoUvHh4uZYOHiKbggw,6946
|
|
7
|
+
python/query_engine.py,sha256=gfwte3o2Wyl95vxA0lUdDYrrxuqxwyJYgcKEF7CUA0c,8212
|
|
8
|
+
python/scanner.py,sha256=Vz38YhQf6iDfx6of0lhIT6LQFbzGN8L6uD7BLQLvZlg,3251
|
|
9
|
+
contextl_mcp-0.1.2.dist-info/METADATA,sha256=41xSu1UUWZSYyVWJmeSFj5VHGJYq2w1vr7B4ACr1flM,4060
|
|
10
|
+
contextl_mcp-0.1.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
contextl_mcp-0.1.2.dist-info/entry_points.txt,sha256=iPuOmo8ThSrk9UQojtAvo_oGcizUOPjTZn4Ca1A6wUU,53
|
|
12
|
+
contextl_mcp-0.1.2.dist-info/RECORD,,
|
prune_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
prune_mcp/__main__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
prune-mcp — entry point
|
|
3
|
+
|
|
4
|
+
Called when a user runs:
|
|
5
|
+
prune-mcp (console_scripts shim installed by pip)
|
|
6
|
+
python -m prune_mcp (module execution)
|
|
7
|
+
|
|
8
|
+
Resolves the bundled mcp_server.py, sets up the environment, then uses
|
|
9
|
+
os.execve to *replace* the current process with Python running mcp_server.py.
|
|
10
|
+
This means no subprocess wrapper, no extra PID, and clean signal forwarding —
|
|
11
|
+
the IDE talks directly to mcp_server.py over stdio.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
# The bundled engine files live in python/ next to this package.
|
|
21
|
+
# Installed layout (wheel):
|
|
22
|
+
# site-packages/
|
|
23
|
+
# prune_mcp/ ← this file lives here
|
|
24
|
+
# python/ ← engine files live here
|
|
25
|
+
python_dir = Path(__file__).parent.parent / "python"
|
|
26
|
+
mcp_server = python_dir / "mcp_server.py"
|
|
27
|
+
|
|
28
|
+
if not mcp_server.exists():
|
|
29
|
+
print(
|
|
30
|
+
f"Error: cannot find mcp_server.py at {mcp_server}\n"
|
|
31
|
+
"The package may be corrupted — try reinstalling:\n"
|
|
32
|
+
" pip install --force-reinstall prune-mcp",
|
|
33
|
+
file=sys.stderr,
|
|
34
|
+
)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
env = os.environ.copy()
|
|
38
|
+
env["PYTHONPATH"] = str(python_dir)
|
|
39
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
40
|
+
|
|
41
|
+
# Replace this process entirely — clean stdio passthrough, no extra PID.
|
|
42
|
+
os.execve(sys.executable, [sys.executable, str(mcp_server)], env)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
python/graph_builder.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository Intelligence Engine
|
|
3
|
+
Step 3: Graph Builder
|
|
4
|
+
|
|
5
|
+
Takes the import relationships from the parser and builds a directed graph
|
|
6
|
+
where nodes are files and edges are import dependencies.
|
|
7
|
+
|
|
8
|
+
Adds useful metadata to each node:
|
|
9
|
+
- in_degree: how many files import this file (how "shared" it is)
|
|
10
|
+
- out_degree: how many files this file imports (how many deps it has)
|
|
11
|
+
- centrality: PageRank score (overall importance in the graph)
|
|
12
|
+
|
|
13
|
+
Also computes connected clusters so we can understand which files
|
|
14
|
+
belong to the same logical feature.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import networkx as nx
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from scanner import scan_repo
|
|
22
|
+
from import_parser import parse_imports, ParseResult
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FileNode:
|
|
27
|
+
"""A file in the repository graph with computed metrics."""
|
|
28
|
+
path: str
|
|
29
|
+
extension: str
|
|
30
|
+
size_bytes: int
|
|
31
|
+
|
|
32
|
+
# Graph metrics (computed after graph is built)
|
|
33
|
+
in_degree: int = 0 # files that import this
|
|
34
|
+
out_degree: int = 0 # files this imports
|
|
35
|
+
centrality: float = 0.0 # PageRank score
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class RepoGraph:
|
|
40
|
+
"""
|
|
41
|
+
The complete dependency graph of the repository.
|
|
42
|
+
Wraps a NetworkX DiGraph with helper methods.
|
|
43
|
+
"""
|
|
44
|
+
graph: nx.DiGraph
|
|
45
|
+
nodes: dict[str, FileNode] # path → FileNode
|
|
46
|
+
root: str
|
|
47
|
+
|
|
48
|
+
def get_dependents(self, file_path: str) -> list[str]:
|
|
49
|
+
"""Files that directly import this file (who uses me?)."""
|
|
50
|
+
return list(self.graph.predecessors(file_path))
|
|
51
|
+
|
|
52
|
+
def get_dependencies(self, file_path: str) -> list[str]:
|
|
53
|
+
"""Files this file directly imports (what do I use?)."""
|
|
54
|
+
return list(self.graph.successors(file_path))
|
|
55
|
+
|
|
56
|
+
def get_neighbors(self, file_path: str, depth: int = 1) -> set[str]:
|
|
57
|
+
"""
|
|
58
|
+
All files within `depth` hops of file_path (both directions).
|
|
59
|
+
depth=1 → direct imports + direct importers
|
|
60
|
+
depth=2 → their imports/importers too
|
|
61
|
+
"""
|
|
62
|
+
neighbors = set()
|
|
63
|
+
frontier = {file_path}
|
|
64
|
+
|
|
65
|
+
for _ in range(depth):
|
|
66
|
+
next_frontier = set()
|
|
67
|
+
for node in frontier:
|
|
68
|
+
next_frontier.update(self.graph.predecessors(node))
|
|
69
|
+
next_frontier.update(self.graph.successors(node))
|
|
70
|
+
new_nodes = next_frontier - neighbors - {file_path}
|
|
71
|
+
neighbors.update(new_nodes)
|
|
72
|
+
frontier = new_nodes
|
|
73
|
+
|
|
74
|
+
return neighbors
|
|
75
|
+
|
|
76
|
+
def most_central_files(self, top_n: int = 5) -> list[FileNode]:
|
|
77
|
+
"""Return the top N files by PageRank centrality."""
|
|
78
|
+
sorted_nodes = sorted(
|
|
79
|
+
self.nodes.values(),
|
|
80
|
+
key=lambda n: n.centrality,
|
|
81
|
+
reverse=True,
|
|
82
|
+
)
|
|
83
|
+
return sorted_nodes[:top_n]
|
|
84
|
+
|
|
85
|
+
def summary(self) -> str:
|
|
86
|
+
lines = [
|
|
87
|
+
f"Repository: {self.root}",
|
|
88
|
+
f"Nodes (files): {self.graph.number_of_nodes()}",
|
|
89
|
+
f"Edges (imports): {self.graph.number_of_edges()}",
|
|
90
|
+
f"Connected components: {nx.number_weakly_connected_components(self.graph)}",
|
|
91
|
+
"",
|
|
92
|
+
"Most central files (PageRank):",
|
|
93
|
+
]
|
|
94
|
+
for node in self.most_central_files():
|
|
95
|
+
lines.append(
|
|
96
|
+
f" {node.centrality:.4f} {node.path}"
|
|
97
|
+
f" (imported by {node.in_degree}, imports {node.out_degree})"
|
|
98
|
+
)
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
def print_adjacency(self) -> None:
|
|
102
|
+
"""Print a human-readable view of the full graph."""
|
|
103
|
+
print("Full dependency graph:")
|
|
104
|
+
for node_path in sorted(self.graph.nodes):
|
|
105
|
+
deps = self.get_dependencies(node_path)
|
|
106
|
+
used_by = self.get_dependents(node_path)
|
|
107
|
+
print(f"\n {node_path}")
|
|
108
|
+
if deps:
|
|
109
|
+
for d in deps:
|
|
110
|
+
print(f" imports → {d}")
|
|
111
|
+
if used_by:
|
|
112
|
+
for u in used_by:
|
|
113
|
+
print(f" used by ← {u}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_graph(scan_result, parse_result: ParseResult) -> RepoGraph:
|
|
117
|
+
"""
|
|
118
|
+
Build a directed dependency graph from scan + parse results.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
scan_result: Output from scan_repo()
|
|
122
|
+
parse_result: Output from parse_imports()
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
RepoGraph with computed metrics on every node.
|
|
126
|
+
"""
|
|
127
|
+
G = nx.DiGraph()
|
|
128
|
+
|
|
129
|
+
# Add all scanned files as nodes
|
|
130
|
+
file_nodes: dict[str, FileNode] = {}
|
|
131
|
+
for f in scan_result.files:
|
|
132
|
+
node = FileNode(
|
|
133
|
+
path=f.path,
|
|
134
|
+
extension=f.extension,
|
|
135
|
+
size_bytes=f.size_bytes,
|
|
136
|
+
)
|
|
137
|
+
file_nodes[f.path] = node
|
|
138
|
+
G.add_node(f.path, **vars(node))
|
|
139
|
+
|
|
140
|
+
# Add edges from import relationships
|
|
141
|
+
for rel in parse_result.relationships:
|
|
142
|
+
if rel.source in G and rel.target in G:
|
|
143
|
+
G.add_edge(rel.source, rel.target, raw_import=rel.raw_import)
|
|
144
|
+
|
|
145
|
+
# Compute PageRank (importance score)
|
|
146
|
+
try:
|
|
147
|
+
pagerank = nx.pagerank(G, alpha=0.85)
|
|
148
|
+
except nx.PowerIterationFailedConvergence:
|
|
149
|
+
pagerank = {n: 1.0 / len(G.nodes) for n in G.nodes}
|
|
150
|
+
|
|
151
|
+
# Attach metrics to each node
|
|
152
|
+
for path, node in file_nodes.items():
|
|
153
|
+
node.in_degree = G.in_degree(path)
|
|
154
|
+
node.out_degree = G.out_degree(path)
|
|
155
|
+
node.centrality = pagerank.get(path, 0.0)
|
|
156
|
+
|
|
157
|
+
return RepoGraph(graph=G, nodes=file_nodes, root=scan_result.root)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
import sys
|
|
162
|
+
|
|
163
|
+
target = sys.argv[1] if len(sys.argv) > 1 else "."
|
|
164
|
+
|
|
165
|
+
scan = scan_repo(target)
|
|
166
|
+
parse = parse_imports(scan)
|
|
167
|
+
repo_graph = build_graph(scan, parse)
|
|
168
|
+
|
|
169
|
+
print(repo_graph.summary())
|
|
170
|
+
print()
|
|
171
|
+
repo_graph.print_adjacency()
|
python/import_parser.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository Intelligence Engine
|
|
3
|
+
Step 2: Import Parser
|
|
4
|
+
|
|
5
|
+
Reads each scanned file and extracts import relationships.
|
|
6
|
+
Handles:
|
|
7
|
+
- Relative imports: import X from "./foo" or "../../lib/bar"
|
|
8
|
+
- Alias imports: import X from "@/components/Button"
|
|
9
|
+
- Type imports: import type { X } from "@/types"
|
|
10
|
+
- Named imports: import { X, Y } from "./utils"
|
|
11
|
+
|
|
12
|
+
Returns only imports that resolve to other files in the repository.
|
|
13
|
+
External packages (react, next, etc.) are ignored.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from scanner import ScannedFile, ScanResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Matches any ES6 import statement and captures the module path
|
|
24
|
+
IMPORT_PATTERN = re.compile(
|
|
25
|
+
r"""import\s+(?:type\s+)? # import or import type
|
|
26
|
+
(?:
|
|
27
|
+
\{[^}]*\} # named imports: { Foo, Bar }
|
|
28
|
+
|[\w*]+ # default or namespace: Foo or *
|
|
29
|
+
|[\w*]+\s*,\s*\{[^}]*\} # mixed: Foo, { Bar }
|
|
30
|
+
)?
|
|
31
|
+
\s*(?:from\s+)? # optional "from"
|
|
32
|
+
['"](.*?)['"] # the module path in quotes
|
|
33
|
+
""",
|
|
34
|
+
re.VERBOSE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Also catch: import("./foo") — dynamic imports
|
|
38
|
+
DYNAMIC_IMPORT_PATTERN = re.compile(r"""import\s*\(\s*['"](.*?)['"]\s*\)""")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ImportRelationship:
|
|
43
|
+
"""A resolved import from one file to another."""
|
|
44
|
+
source: str # Relative path of the file doing the importing
|
|
45
|
+
target: str # Relative path of the file being imported
|
|
46
|
+
raw_import: str # The original import string (e.g. "@/components/Button")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ParseResult:
|
|
51
|
+
"""All import relationships discovered across the repository."""
|
|
52
|
+
relationships: list[ImportRelationship] = field(default_factory=list)
|
|
53
|
+
unresolved: list[tuple[str, str]] = field(default_factory=list)
|
|
54
|
+
# unresolved = [(source_file, raw_import), ...] — external packages or not found
|
|
55
|
+
|
|
56
|
+
def summary(self) -> str:
|
|
57
|
+
lines = [
|
|
58
|
+
f"Import relationships found: {len(self.relationships)}",
|
|
59
|
+
f"Unresolved (external/missing): {len(self.unresolved)}",
|
|
60
|
+
"",
|
|
61
|
+
"Resolved imports:",
|
|
62
|
+
]
|
|
63
|
+
for rel in self.relationships:
|
|
64
|
+
lines.append(f" {rel.source}")
|
|
65
|
+
lines.append(f" → {rel.target} (from '{rel.raw_import}')")
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_raw_imports(source_code: str) -> list[str]:
|
|
70
|
+
"""Pull all import paths out of a TypeScript/JSX file."""
|
|
71
|
+
paths = []
|
|
72
|
+
for match in IMPORT_PATTERN.finditer(source_code):
|
|
73
|
+
paths.append(match.group(1))
|
|
74
|
+
for match in DYNAMIC_IMPORT_PATTERN.finditer(source_code):
|
|
75
|
+
paths.append(match.group(1))
|
|
76
|
+
return paths
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_external(import_path: str) -> bool:
|
|
80
|
+
"""Return True if this is an npm package rather than a local file."""
|
|
81
|
+
# Local files start with . (relative) or @ followed by / (alias like @/)
|
|
82
|
+
# but NOT @scope/package style from npm — those don't contain our alias prefix
|
|
83
|
+
if import_path.startswith("./") or import_path.startswith("../"):
|
|
84
|
+
return False
|
|
85
|
+
if import_path.startswith("@/"):
|
|
86
|
+
return False
|
|
87
|
+
return True # Everything else is an external package
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_alias(import_path: str, alias_map: dict[str, str]) -> str | None:
|
|
91
|
+
"""
|
|
92
|
+
Convert an aliased import path to a repo-relative path.
|
|
93
|
+
Example: "@/components/Button" → "frontend/components/Button"
|
|
94
|
+
"""
|
|
95
|
+
for alias, real_prefix in alias_map.items():
|
|
96
|
+
if import_path.startswith(alias):
|
|
97
|
+
suffix = import_path[len(alias):].lstrip("/")
|
|
98
|
+
prefix = real_prefix.rstrip("/")
|
|
99
|
+
if prefix:
|
|
100
|
+
return prefix + "/" + suffix
|
|
101
|
+
else:
|
|
102
|
+
# alias maps directly to the repo root — no leading slash
|
|
103
|
+
return suffix
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_relative(import_path: str, source_file: str) -> str:
|
|
108
|
+
"""
|
|
109
|
+
Resolve a relative import path against the source file's directory.
|
|
110
|
+
Example: source="frontend/app/page.tsx", import="../lib/api" → "frontend/lib/api"
|
|
111
|
+
"""
|
|
112
|
+
source_dir = Path(source_file).parent
|
|
113
|
+
resolved = (source_dir / import_path).resolve()
|
|
114
|
+
# Make it relative again (we'll strip the absolute prefix below)
|
|
115
|
+
return str(resolved)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _find_file_in_repo(
|
|
119
|
+
candidate: str,
|
|
120
|
+
file_index: dict[str, str],
|
|
121
|
+
root: str,
|
|
122
|
+
) -> str | None:
|
|
123
|
+
"""
|
|
124
|
+
Given a candidate path (without extension), find the matching file
|
|
125
|
+
in the repository. Tries adding common extensions if missing.
|
|
126
|
+
|
|
127
|
+
file_index maps absolute_path → relative_path.
|
|
128
|
+
"""
|
|
129
|
+
root_path = Path(root)
|
|
130
|
+
candidate_path = Path(candidate)
|
|
131
|
+
|
|
132
|
+
# If candidate is absolute, make it relative to root
|
|
133
|
+
if candidate_path.is_absolute():
|
|
134
|
+
try:
|
|
135
|
+
candidate_path = candidate_path.relative_to(root_path)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
candidate_str = str(candidate_path)
|
|
140
|
+
|
|
141
|
+
# Try exact match first (already has extension)
|
|
142
|
+
if candidate_str in file_index:
|
|
143
|
+
return candidate_str
|
|
144
|
+
|
|
145
|
+
# Try adding each supported extension
|
|
146
|
+
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
147
|
+
with_ext = candidate_str + ext
|
|
148
|
+
if with_ext in file_index:
|
|
149
|
+
return with_ext
|
|
150
|
+
|
|
151
|
+
# Try as a directory index file (e.g. components/Button/index.tsx)
|
|
152
|
+
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
153
|
+
index_path = candidate_str + "/index" + ext
|
|
154
|
+
if index_path in file_index:
|
|
155
|
+
return index_path
|
|
156
|
+
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
161
|
+
"""
|
|
162
|
+
Auto-detect the @/ alias by finding tsconfig.json or next.config files
|
|
163
|
+
and inferring the project root. Falls back to a heuristic.
|
|
164
|
+
|
|
165
|
+
Returns a map like {"@/": "frontend/"} or {"@/": ""}
|
|
166
|
+
"""
|
|
167
|
+
root = Path(scan_result.root)
|
|
168
|
+
|
|
169
|
+
# Look for tsconfig.json to find paths config
|
|
170
|
+
for tsconfig in root.rglob("tsconfig.json"):
|
|
171
|
+
try:
|
|
172
|
+
import json
|
|
173
|
+
data = json.loads(tsconfig.read_text())
|
|
174
|
+
paths = data.get("compilerOptions", {}).get("paths", {})
|
|
175
|
+
base_url = data.get("compilerOptions", {}).get("baseUrl", ".")
|
|
176
|
+
|
|
177
|
+
alias_map = {}
|
|
178
|
+
for alias, targets in paths.items():
|
|
179
|
+
if not targets:
|
|
180
|
+
continue
|
|
181
|
+
# "@/*": ["./src/*"] → strip trailing /* from both
|
|
182
|
+
clean_alias = alias.rstrip("/*").rstrip("*")
|
|
183
|
+
clean_target = targets[0].rstrip("/*").rstrip("*").lstrip("./")
|
|
184
|
+
|
|
185
|
+
tsconfig_dir = tsconfig.parent.relative_to(root)
|
|
186
|
+
prefix = str(tsconfig_dir / clean_target).lstrip("./")
|
|
187
|
+
alias_map[clean_alias + "/"] = prefix + "/" if prefix else ""
|
|
188
|
+
|
|
189
|
+
if alias_map:
|
|
190
|
+
return alias_map
|
|
191
|
+
except Exception:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Heuristic fallback: if all files share a common top-level dir, use that
|
|
195
|
+
top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
|
|
196
|
+
if len(top_dirs) == 1:
|
|
197
|
+
top = list(top_dirs)[0]
|
|
198
|
+
return {"@/": top + "/"}
|
|
199
|
+
|
|
200
|
+
return {"@/": ""}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
204
|
+
"""
|
|
205
|
+
Parse all import statements from scanned files and resolve them
|
|
206
|
+
to concrete file paths within the repository.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
scan_result: Output from scan_repo().
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
ParseResult containing all resolved ImportRelationships.
|
|
213
|
+
"""
|
|
214
|
+
result = ParseResult()
|
|
215
|
+
|
|
216
|
+
# Build lookup: relative_path → ScannedFile
|
|
217
|
+
file_index: dict[str, ScannedFile] = {f.path: f for f in scan_result.files}
|
|
218
|
+
|
|
219
|
+
# Auto-detect alias map (e.g. @/ → frontend/)
|
|
220
|
+
alias_map = _detect_alias_map(scan_result)
|
|
221
|
+
|
|
222
|
+
for scanned_file in scan_result.files:
|
|
223
|
+
try:
|
|
224
|
+
source_code = Path(scanned_file.absolute_path).read_text(encoding="utf-8")
|
|
225
|
+
except Exception:
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
raw_imports = _extract_raw_imports(source_code)
|
|
229
|
+
|
|
230
|
+
for raw in raw_imports:
|
|
231
|
+
if _is_external(raw):
|
|
232
|
+
result.unresolved.append((scanned_file.path, raw))
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Resolve to a candidate path string
|
|
236
|
+
if raw.startswith("@/"):
|
|
237
|
+
resolved_alias = _resolve_alias(raw, alias_map)
|
|
238
|
+
if resolved_alias is None:
|
|
239
|
+
result.unresolved.append((scanned_file.path, raw))
|
|
240
|
+
continue
|
|
241
|
+
candidate = resolved_alias
|
|
242
|
+
else:
|
|
243
|
+
# Relative import
|
|
244
|
+
candidate = _resolve_relative(raw, scanned_file.path)
|
|
245
|
+
|
|
246
|
+
# Find the actual file in the repo
|
|
247
|
+
matched = _find_file_in_repo(candidate, file_index, scan_result.root)
|
|
248
|
+
|
|
249
|
+
if matched:
|
|
250
|
+
result.relationships.append(
|
|
251
|
+
ImportRelationship(
|
|
252
|
+
source=scanned_file.path,
|
|
253
|
+
target=matched,
|
|
254
|
+
raw_import=raw,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
result.unresolved.append((scanned_file.path, raw))
|
|
259
|
+
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
import sys
|
|
265
|
+
from scanner import scan_repo
|
|
266
|
+
|
|
267
|
+
target = sys.argv[1] if len(sys.argv) > 1 else "."
|
|
268
|
+
scan = scan_repo(target)
|
|
269
|
+
parse = parse_imports(scan)
|
|
270
|
+
|
|
271
|
+
print(parse.summary())
|