contextl 1.0.1
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/README.md +132 -0
- package/bin/contextl.js +182 -0
- package/package.json +40 -0
- package/python/graph_builder.py +171 -0
- package/python/import_parser.py +271 -0
- package/python/main.py +237 -0
- package/python/mcp_server.py +201 -0
- package/python/query_engine.py +252 -0
- package/python/scanner.py +125 -0
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# contextl
|
|
2
|
+
|
|
3
|
+
> **Context-selection engine for AI coding assistants.**
|
|
4
|
+
> 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.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
"fix the upload error" → [FileUploader.tsx, lib/upload.ts, UploadSection.tsx]
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Instead of feeding your entire repo to an AI, `contextl` reduces 5 000 files down to the 5 most relevant ones in milliseconds.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
**Requires Python 3.9+** on your PATH. Everything else (`networkx`, `mcp`) is installed automatically on first run.
|
|
17
|
+
|
|
18
|
+
### 1 — Add to your IDE's MCP config
|
|
19
|
+
|
|
20
|
+
Paste this JSON into your IDE's MCP config file (paths listed below):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"contextl": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "contextl"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
#### Config file locations
|
|
34
|
+
|
|
35
|
+
| IDE | Config file |
|
|
36
|
+
|-----|-------------|
|
|
37
|
+
| **Antigravity** | `~/.gemini/antigravity/mcp/` (MCP server directory) |
|
|
38
|
+
| **Cursor** | `~/.cursor/mcp.json` |
|
|
39
|
+
| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` |
|
|
40
|
+
| **Claude Code** | `~/.claude.json` (or run `claude mcp add`) |
|
|
41
|
+
| **VS Code** | `.vscode/mcp.json` in your workspace root |
|
|
42
|
+
|
|
43
|
+
### 2 — Use it
|
|
44
|
+
|
|
45
|
+
Just talk to your IDE's AI normally:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
You: "fix the file upload error handler"
|
|
49
|
+
IDE: calls query_repo → gets [FileUploader.tsx, lib/upload.ts, …]
|
|
50
|
+
IDE: reads only those 5 files instead of the whole repo
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Tools exposed
|
|
56
|
+
|
|
57
|
+
### `query_repo(repo_path, query, top_n?)`
|
|
58
|
+
|
|
59
|
+
Ranks the most relevant files for a change request.
|
|
60
|
+
|
|
61
|
+
**Parameters:**
|
|
62
|
+
- `repo_path` — absolute path to the repository root
|
|
63
|
+
- `query` — natural-language description of the change (e.g. `"change the download button color"`)
|
|
64
|
+
- `top_n` — max results to return (default `5`, max `20`)
|
|
65
|
+
|
|
66
|
+
**Returns:**
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"query": "change the download button",
|
|
70
|
+
"repo": "/path/to/repo",
|
|
71
|
+
"total_files_scanned": 142,
|
|
72
|
+
"results": [
|
|
73
|
+
{
|
|
74
|
+
"rank": 1,
|
|
75
|
+
"path": "components/DownloadButton.tsx",
|
|
76
|
+
"score": 0.9800,
|
|
77
|
+
"confidence": "high",
|
|
78
|
+
"matched_terms": ["button", "download"],
|
|
79
|
+
"reasoning": "Filename strongly matches query terms; file contents heavily reference query terms."
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `scan_repo(repo_path)`
|
|
86
|
+
|
|
87
|
+
Lists all source files the engine can see in a repository. Useful for verifying coverage before querying.
|
|
88
|
+
|
|
89
|
+
**Returns:**
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"repo": "/path/to/repo",
|
|
93
|
+
"total_files": 142,
|
|
94
|
+
"files": [
|
|
95
|
+
{ "path": "components/Button.tsx", "extension": ".tsx", "size_bytes": 1024 }
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## How it works
|
|
103
|
+
|
|
104
|
+
The engine runs **entirely locally** — no network calls, no AI APIs, no data leaves your machine.
|
|
105
|
+
|
|
106
|
+
Scoring uses four signals:
|
|
107
|
+
|
|
108
|
+
| Signal | Weight | Description |
|
|
109
|
+
|--------|--------|-------------|
|
|
110
|
+
| Keyword match | 0.5 | Query terms in the file path / name |
|
|
111
|
+
| Content match | 0.5 | Query terms inside the file source |
|
|
112
|
+
| Neighbor bonus | +0.15 | Files near high-scoring files in the import graph |
|
|
113
|
+
| PageRank | 0.05 | Tiebreaker: more connected files rank slightly higher |
|
|
114
|
+
|
|
115
|
+
Supports **Next.js / React / TypeScript** repos (`.ts`, `.tsx`, `.js`, `.jsx`). Automatically detects `@/` path aliases from `tsconfig.json`.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Requirements
|
|
120
|
+
|
|
121
|
+
| Requirement | Version |
|
|
122
|
+
|-------------|---------|
|
|
123
|
+
| Node.js | ≥ 18 |
|
|
124
|
+
| Python | ≥ 3.9 |
|
|
125
|
+
| `networkx` | auto-installed |
|
|
126
|
+
| `mcp` | auto-installed |
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
package/bin/contextl.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* contextl — npm entry point
|
|
4
|
+
*
|
|
5
|
+
* Locates a suitable Python 3.9+ interpreter, ensures `networkx` and `mcp`
|
|
6
|
+
* are importable (installs them via pip if missing), then spawns mcp_server.py
|
|
7
|
+
* with stdio inherited so any MCP-compatible IDE can talk to it directly.
|
|
8
|
+
*
|
|
9
|
+
* No npm runtime dependencies — only Node.js built-ins.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
const { execSync, spawn } = require("child_process");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Paths
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Directory containing the bundled Python engine files. */
|
|
23
|
+
const PYTHON_DIR = path.resolve(__dirname, "..", "python");
|
|
24
|
+
|
|
25
|
+
/** The MCP server entry point. */
|
|
26
|
+
const MCP_SERVER = path.join(PYTHON_DIR, "mcp_server.py");
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Python discovery
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Try to find a Python 3.9+ interpreter.
|
|
34
|
+
* Tries the candidates in order; returns the first one that works.
|
|
35
|
+
* Throws if none is found.
|
|
36
|
+
*/
|
|
37
|
+
function findPython() {
|
|
38
|
+
const candidates = ["python3", "python", "python3.13", "python3.12", "python3.11", "python3.10", "python3.9"];
|
|
39
|
+
|
|
40
|
+
for (const cmd of candidates) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = execSync(
|
|
43
|
+
`${cmd} -c "import sys; print(sys.version_info.major, sys.version_info.minor)"`,
|
|
44
|
+
{ stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }
|
|
45
|
+
).toString().trim();
|
|
46
|
+
|
|
47
|
+
const [major, minor] = raw.split(" ").map(Number);
|
|
48
|
+
if (major === 3 && minor >= 9) {
|
|
49
|
+
return cmd;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Not found or wrong version — try next candidate
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new Error(
|
|
57
|
+
"Python 3.9+ is required but was not found.\n" +
|
|
58
|
+
"Install it from https://www.python.org/downloads/ and make sure it is on your PATH."
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Dependency check & auto-install
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check whether a Python package is importable.
|
|
68
|
+
* Returns true if it can be imported, false otherwise.
|
|
69
|
+
*/
|
|
70
|
+
function isPyPackageAvailable(python, packageName) {
|
|
71
|
+
try {
|
|
72
|
+
execSync(`${python} -c "import ${packageName}"`, {
|
|
73
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
74
|
+
timeout: 10000,
|
|
75
|
+
});
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Install a pip package quietly.
|
|
84
|
+
* Throws on failure.
|
|
85
|
+
*/
|
|
86
|
+
function pipInstall(python, packageName) {
|
|
87
|
+
process.stderr.write(`[contextl] Installing ${packageName}…\n`);
|
|
88
|
+
try {
|
|
89
|
+
execSync(`${python} -m pip install --quiet ${packageName}`, {
|
|
90
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
91
|
+
timeout: 120_000,
|
|
92
|
+
});
|
|
93
|
+
process.stderr.write(`[contextl] ${packageName} installed.\n`);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Failed to install ${packageName} via pip.\n` +
|
|
97
|
+
`Run manually: pip install ${packageName}\n\n` +
|
|
98
|
+
String(err)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Ensure all required Python packages are available, installing if needed.
|
|
105
|
+
*/
|
|
106
|
+
function ensureDeps(python) {
|
|
107
|
+
const required = [
|
|
108
|
+
{ importName: "networkx", pipName: "networkx" },
|
|
109
|
+
{ importName: "mcp", pipName: "mcp" },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
for (const { importName, pipName } of required) {
|
|
113
|
+
if (!isPyPackageAvailable(python, importName)) {
|
|
114
|
+
pipInstall(python, pipName);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Launch MCP server
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Spawn mcp_server.py and forward signals so the parent IDE can cleanly
|
|
125
|
+
* shut it down.
|
|
126
|
+
*/
|
|
127
|
+
function launchServer(python) {
|
|
128
|
+
if (!fs.existsSync(MCP_SERVER)) {
|
|
129
|
+
throw new Error(`mcp_server.py not found at: ${MCP_SERVER}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const child = spawn(python, [MCP_SERVER], {
|
|
133
|
+
stdio: "inherit",
|
|
134
|
+
env: {
|
|
135
|
+
...process.env,
|
|
136
|
+
PYTHONPATH: PYTHON_DIR,
|
|
137
|
+
PYTHONUNBUFFERED: "1",
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Forward termination signals to the child so it can shut down cleanly
|
|
142
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
143
|
+
process.on(sig, () => {
|
|
144
|
+
if (!child.killed) child.kill(sig);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
child.on("exit", (code, signal) => {
|
|
149
|
+
process.exit(signal ? 1 : (code ?? 0));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
child.on("error", (err) => {
|
|
153
|
+
process.stderr.write(`[contextl] Failed to start mcp_server.py: ${err.message}\n`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Main
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
function main() {
|
|
163
|
+
let python;
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
python = findPython();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
process.stderr.write(`[contextl] ${err.message}\n`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
ensureDeps(python);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
process.stderr.write(`[contextl] Dependency error: ${err.message}\n`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
launchServer(python);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "contextl",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "contextl — finds the most relevant files in your codebase for any change request. MCP server for AI coding agents.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"ai",
|
|
9
|
+
"code-search",
|
|
10
|
+
"context",
|
|
11
|
+
"cursor",
|
|
12
|
+
"windsurf",
|
|
13
|
+
"claude",
|
|
14
|
+
"vscode",
|
|
15
|
+
"typescript",
|
|
16
|
+
"nextjs",
|
|
17
|
+
"repository"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://github.com/dev7shah/prune#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/dev7shah/prune/issues"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "dev7shah",
|
|
25
|
+
"bin": {
|
|
26
|
+
"contextl": "bin/contextl.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin/",
|
|
30
|
+
"python/",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"prepublishOnly": "node -e \"require('fs').rmSync('python/__pycache__', { recursive: true, force: true })\"",
|
|
38
|
+
"test": "node bin/contextl.js --help 2>&1 || true"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -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()
|