graphlens 0.1.1__tar.gz
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.
- graphlens-0.1.1/LICENSE +21 -0
- graphlens-0.1.1/PKG-INFO +243 -0
- graphlens-0.1.1/README.md +196 -0
- graphlens-0.1.1/pyproject.toml +84 -0
- graphlens-0.1.1/src/graphlens/__init__.py +52 -0
- graphlens-0.1.1/src/graphlens/contracts/__init__.py +18 -0
- graphlens-0.1.1/src/graphlens/contracts/adapter.py +79 -0
- graphlens-0.1.1/src/graphlens/contracts/backend.py +27 -0
- graphlens-0.1.1/src/graphlens/contracts/deps.py +65 -0
- graphlens-0.1.1/src/graphlens/contracts/reader.py +33 -0
- graphlens-0.1.1/src/graphlens/exceptions.py +25 -0
- graphlens-0.1.1/src/graphlens/models/__init__.py +7 -0
- graphlens-0.1.1/src/graphlens/models/graph.py +41 -0
- graphlens-0.1.1/src/graphlens/models/nodes.py +45 -0
- graphlens-0.1.1/src/graphlens/models/relations.py +29 -0
- graphlens-0.1.1/src/graphlens/py.typed +0 -0
- graphlens-0.1.1/src/graphlens/registry.py +76 -0
- graphlens-0.1.1/src/graphlens/utils/__init__.py +6 -0
- graphlens-0.1.1/src/graphlens/utils/ids.py +16 -0
- graphlens-0.1.1/src/graphlens/utils/span.py +15 -0
graphlens-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Neko1313
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
graphlens-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: graphlens
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Extensible polyglot code analysis framework with a graph IR
|
|
5
|
+
Keywords: code-analysis,graphlens,ast,tree-sitter,static-analysis,graph,polyglot,dependency-analysis,python,code-intelligence
|
|
6
|
+
Author: Neko1313
|
|
7
|
+
Author-email: Neko1313 <nikita.ribalchencko@yandex.ru>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Neko1313
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
34
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
35
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
36
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
37
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
38
|
+
Classifier: Typing :: Typed
|
|
39
|
+
Classifier: Operating System :: OS Independent
|
|
40
|
+
Requires-Dist: graphlens-python ; extra == 'python'
|
|
41
|
+
Requires-Python: >=3.13
|
|
42
|
+
Project-URL: Repository, https://github.com/Neko1313/graphlens
|
|
43
|
+
Project-URL: Issues, https://github.com/Neko1313/graphlens/issues
|
|
44
|
+
Project-URL: Changelog, https://github.com/Neko1313/graphlens/blob/main/CHANGELOG.md
|
|
45
|
+
Provides-Extra: python
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
<div align="center">
|
|
49
|
+
|
|
50
|
+
<h1>graphlens</h1>
|
|
51
|
+
|
|
52
|
+
<p>Extensible polyglot code analysis framework that parses source projects, normalizes their structure into a shared graph IR, and exposes it for dependency analysis, navigation, and code intelligence tooling.</p>
|
|
53
|
+
|
|
54
|
+
[](https://pypi.org/project/graphlens/)
|
|
55
|
+
[](https://pypi.org/project/graphlens/)
|
|
56
|
+
[](LICENSE)
|
|
57
|
+
[](https://github.com/Neko1313/graphlens/actions)
|
|
58
|
+
[](https://codecov.io/gh/Neko1313/graphlens)
|
|
59
|
+
|
|
60
|
+
[Repository](https://github.com/Neko1313/graphlens) · [Issues](https://github.com/Neko1313/graphlens/issues)
|
|
61
|
+
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Architecture
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
Repository → Language Adapter → GraphLens (IR) → Graph Backend
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Layer | Responsibility |
|
|
73
|
+
|---|---|
|
|
74
|
+
| **Language Adapter** | Parses source files, produces `GraphLens` |
|
|
75
|
+
| **GraphLens** | Typed nodes + directed relations (the IR) |
|
|
76
|
+
| **Graph Backend** | Persists or queries the graph (Neo4j, in-memory, …) |
|
|
77
|
+
|
|
78
|
+
Adapters are **pure data producers** — they never write to any backend. The graph is the only output.
|
|
79
|
+
|
|
80
|
+
## Why graph IR?
|
|
81
|
+
|
|
82
|
+
- **Language-agnostic** — one shared model for Python, TypeScript, Rust, …
|
|
83
|
+
- **Plugin-based adapters** — each language is a separate package, registered via Python entry points
|
|
84
|
+
- **Tree-sitter powered** — all adapters use tree-sitter for error-tolerant CST parsing and exact span positions
|
|
85
|
+
- **Monorepo aware** — `can_handle()` and `find_*_roots()` handle multi-language repos correctly
|
|
86
|
+
- **Deterministic node IDs** — SHA-256 hash of `project::kind::qualified_name` → stable across re-scans
|
|
87
|
+
|
|
88
|
+
## Installation
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Core library only (models, contracts, registry)
|
|
92
|
+
pip install graphlens
|
|
93
|
+
|
|
94
|
+
# Core + Python adapter
|
|
95
|
+
pip install "graphlens[python]"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
With uv:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv add graphlens
|
|
102
|
+
uv add "graphlens[python]"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Quick start
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from pathlib import Path
|
|
109
|
+
from graphlens import adapter_registry
|
|
110
|
+
|
|
111
|
+
# Load and instantiate the Python adapter
|
|
112
|
+
adapter = adapter_registry.load("python")()
|
|
113
|
+
|
|
114
|
+
# Analyze a project — returns a GraphLens
|
|
115
|
+
graph = adapter.analyze(Path("./my-project"))
|
|
116
|
+
|
|
117
|
+
print(f"Nodes: {len(graph.nodes)}")
|
|
118
|
+
print(f"Relations: {len(graph.relations)}")
|
|
119
|
+
|
|
120
|
+
# Inspect nodes by kind
|
|
121
|
+
from graphlens import NodeKind
|
|
122
|
+
|
|
123
|
+
modules = [n for n in graph.nodes.values() if n.kind == NodeKind.MODULE]
|
|
124
|
+
classes = [n for n in graph.nodes.values() if n.kind == NodeKind.CLASS]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Graph model
|
|
128
|
+
|
|
129
|
+
### Node kinds
|
|
130
|
+
|
|
131
|
+
| Kind | Description |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `PROJECT` | Root project node |
|
|
134
|
+
| `MODULE` | Python/TS/… module (directory or file) |
|
|
135
|
+
| `FILE` | Source file |
|
|
136
|
+
| `CLASS` | Class declaration |
|
|
137
|
+
| `FUNCTION` | Top-level function |
|
|
138
|
+
| `METHOD` | Method inside a class |
|
|
139
|
+
| `PARAMETER` | Function/method parameter |
|
|
140
|
+
| `IMPORT` | Import statement |
|
|
141
|
+
| `DEPENDENCY` | Declared package dependency |
|
|
142
|
+
| `SYMBOL` | Internal symbol reference |
|
|
143
|
+
| `EXTERNAL_SYMBOL` | External symbol (stdlib, third-party, unknown) |
|
|
144
|
+
|
|
145
|
+
### Relation kinds
|
|
146
|
+
|
|
147
|
+
| Kind | Description |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `CONTAINS` | Structural containment (project → module → file → class) |
|
|
150
|
+
| `DECLARES` | Declaration (file declares function, class declares method) |
|
|
151
|
+
| `IMPORTS` | Import edge (file → import node) |
|
|
152
|
+
| `RESOLVES_TO` | Import resolved to a module or external symbol |
|
|
153
|
+
| `CALLS` | Function/method call |
|
|
154
|
+
| `REFERENCES` | Symbol reference |
|
|
155
|
+
| `INHERITS_FROM` | Class inheritance |
|
|
156
|
+
| `DEPENDS_ON` | Package dependency |
|
|
157
|
+
|
|
158
|
+
## Adapter plugin system
|
|
159
|
+
|
|
160
|
+
Language adapters register themselves via Python entry points — no changes to the core needed:
|
|
161
|
+
|
|
162
|
+
```toml
|
|
163
|
+
# packages/graphlens-python/pyproject.toml
|
|
164
|
+
[project.entry-points."graphlens.adapters"]
|
|
165
|
+
python = "graphlens_python:PythonAdapter"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The registry discovers installed adapters automatically at runtime:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from graphlens import adapter_registry
|
|
172
|
+
|
|
173
|
+
adapter_registry.available() # ["python", ...]
|
|
174
|
+
adapter_cls = adapter_registry.load("python")
|
|
175
|
+
adapter = adapter_cls()
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Adapters can also be registered manually (useful for testing):
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
adapter_registry.register("python", MyPythonAdapter)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Implementing an adapter
|
|
185
|
+
|
|
186
|
+
Subclass `LanguageAdapter` and implement four methods:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from pathlib import Path
|
|
190
|
+
from graphlens import GraphLens, LanguageAdapter
|
|
191
|
+
|
|
192
|
+
class MyLangAdapter(LanguageAdapter):
|
|
193
|
+
def language(self) -> str:
|
|
194
|
+
return "mylang"
|
|
195
|
+
|
|
196
|
+
def file_extensions(self) -> set[str]:
|
|
197
|
+
return {".ml", ".mli"}
|
|
198
|
+
|
|
199
|
+
def can_handle(self, project_root: Path) -> bool:
|
|
200
|
+
return (project_root / "dune-project").exists()
|
|
201
|
+
|
|
202
|
+
def analyze(
|
|
203
|
+
self, project_root: Path, files: list[Path] | None = None
|
|
204
|
+
) -> GraphLens:
|
|
205
|
+
graph = GraphLens()
|
|
206
|
+
files = files or self.collect_files(project_root)
|
|
207
|
+
# ... parse and populate graph ...
|
|
208
|
+
return graph
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Register in `pyproject.toml` and the core registry finds it automatically.
|
|
212
|
+
|
|
213
|
+
## Project structure
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
graphlens/ ← uv workspace root (core library)
|
|
217
|
+
src/graphlens/ ← models, contracts, registry, exceptions, utils
|
|
218
|
+
packages/
|
|
219
|
+
graphlens-python/ ← Python adapter (tree-sitter)
|
|
220
|
+
tests/ ← core tests (100% coverage)
|
|
221
|
+
examples/ ← runnable usage examples
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Development
|
|
225
|
+
|
|
226
|
+
Requires Python 3.13+, [uv](https://docs.astral.sh/uv/), [task](https://taskfile.dev/).
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
task install # uv sync --all-groups
|
|
230
|
+
task lint # ruff + ty + bandit for all packages
|
|
231
|
+
task tests # all tests with coverage
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Individual package tasks:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
task core:lint task core:test
|
|
238
|
+
task python:lint task python:test
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<h1>graphlens</h1>
|
|
4
|
+
|
|
5
|
+
<p>Extensible polyglot code analysis framework that parses source projects, normalizes their structure into a shared graph IR, and exposes it for dependency analysis, navigation, and code intelligence tooling.</p>
|
|
6
|
+
|
|
7
|
+
[](https://pypi.org/project/graphlens/)
|
|
8
|
+
[](https://pypi.org/project/graphlens/)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](https://github.com/Neko1313/graphlens/actions)
|
|
11
|
+
[](https://codecov.io/gh/Neko1313/graphlens)
|
|
12
|
+
|
|
13
|
+
[Repository](https://github.com/Neko1313/graphlens) · [Issues](https://github.com/Neko1313/graphlens/issues)
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Repository → Language Adapter → GraphLens (IR) → Graph Backend
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Layer | Responsibility |
|
|
26
|
+
|---|---|
|
|
27
|
+
| **Language Adapter** | Parses source files, produces `GraphLens` |
|
|
28
|
+
| **GraphLens** | Typed nodes + directed relations (the IR) |
|
|
29
|
+
| **Graph Backend** | Persists or queries the graph (Neo4j, in-memory, …) |
|
|
30
|
+
|
|
31
|
+
Adapters are **pure data producers** — they never write to any backend. The graph is the only output.
|
|
32
|
+
|
|
33
|
+
## Why graph IR?
|
|
34
|
+
|
|
35
|
+
- **Language-agnostic** — one shared model for Python, TypeScript, Rust, …
|
|
36
|
+
- **Plugin-based adapters** — each language is a separate package, registered via Python entry points
|
|
37
|
+
- **Tree-sitter powered** — all adapters use tree-sitter for error-tolerant CST parsing and exact span positions
|
|
38
|
+
- **Monorepo aware** — `can_handle()` and `find_*_roots()` handle multi-language repos correctly
|
|
39
|
+
- **Deterministic node IDs** — SHA-256 hash of `project::kind::qualified_name` → stable across re-scans
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Core library only (models, contracts, registry)
|
|
45
|
+
pip install graphlens
|
|
46
|
+
|
|
47
|
+
# Core + Python adapter
|
|
48
|
+
pip install "graphlens[python]"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
With uv:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv add graphlens
|
|
55
|
+
uv add "graphlens[python]"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
from graphlens import adapter_registry
|
|
63
|
+
|
|
64
|
+
# Load and instantiate the Python adapter
|
|
65
|
+
adapter = adapter_registry.load("python")()
|
|
66
|
+
|
|
67
|
+
# Analyze a project — returns a GraphLens
|
|
68
|
+
graph = adapter.analyze(Path("./my-project"))
|
|
69
|
+
|
|
70
|
+
print(f"Nodes: {len(graph.nodes)}")
|
|
71
|
+
print(f"Relations: {len(graph.relations)}")
|
|
72
|
+
|
|
73
|
+
# Inspect nodes by kind
|
|
74
|
+
from graphlens import NodeKind
|
|
75
|
+
|
|
76
|
+
modules = [n for n in graph.nodes.values() if n.kind == NodeKind.MODULE]
|
|
77
|
+
classes = [n for n in graph.nodes.values() if n.kind == NodeKind.CLASS]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Graph model
|
|
81
|
+
|
|
82
|
+
### Node kinds
|
|
83
|
+
|
|
84
|
+
| Kind | Description |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `PROJECT` | Root project node |
|
|
87
|
+
| `MODULE` | Python/TS/… module (directory or file) |
|
|
88
|
+
| `FILE` | Source file |
|
|
89
|
+
| `CLASS` | Class declaration |
|
|
90
|
+
| `FUNCTION` | Top-level function |
|
|
91
|
+
| `METHOD` | Method inside a class |
|
|
92
|
+
| `PARAMETER` | Function/method parameter |
|
|
93
|
+
| `IMPORT` | Import statement |
|
|
94
|
+
| `DEPENDENCY` | Declared package dependency |
|
|
95
|
+
| `SYMBOL` | Internal symbol reference |
|
|
96
|
+
| `EXTERNAL_SYMBOL` | External symbol (stdlib, third-party, unknown) |
|
|
97
|
+
|
|
98
|
+
### Relation kinds
|
|
99
|
+
|
|
100
|
+
| Kind | Description |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `CONTAINS` | Structural containment (project → module → file → class) |
|
|
103
|
+
| `DECLARES` | Declaration (file declares function, class declares method) |
|
|
104
|
+
| `IMPORTS` | Import edge (file → import node) |
|
|
105
|
+
| `RESOLVES_TO` | Import resolved to a module or external symbol |
|
|
106
|
+
| `CALLS` | Function/method call |
|
|
107
|
+
| `REFERENCES` | Symbol reference |
|
|
108
|
+
| `INHERITS_FROM` | Class inheritance |
|
|
109
|
+
| `DEPENDS_ON` | Package dependency |
|
|
110
|
+
|
|
111
|
+
## Adapter plugin system
|
|
112
|
+
|
|
113
|
+
Language adapters register themselves via Python entry points — no changes to the core needed:
|
|
114
|
+
|
|
115
|
+
```toml
|
|
116
|
+
# packages/graphlens-python/pyproject.toml
|
|
117
|
+
[project.entry-points."graphlens.adapters"]
|
|
118
|
+
python = "graphlens_python:PythonAdapter"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The registry discovers installed adapters automatically at runtime:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from graphlens import adapter_registry
|
|
125
|
+
|
|
126
|
+
adapter_registry.available() # ["python", ...]
|
|
127
|
+
adapter_cls = adapter_registry.load("python")
|
|
128
|
+
adapter = adapter_cls()
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Adapters can also be registered manually (useful for testing):
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
adapter_registry.register("python", MyPythonAdapter)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Implementing an adapter
|
|
138
|
+
|
|
139
|
+
Subclass `LanguageAdapter` and implement four methods:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from pathlib import Path
|
|
143
|
+
from graphlens import GraphLens, LanguageAdapter
|
|
144
|
+
|
|
145
|
+
class MyLangAdapter(LanguageAdapter):
|
|
146
|
+
def language(self) -> str:
|
|
147
|
+
return "mylang"
|
|
148
|
+
|
|
149
|
+
def file_extensions(self) -> set[str]:
|
|
150
|
+
return {".ml", ".mli"}
|
|
151
|
+
|
|
152
|
+
def can_handle(self, project_root: Path) -> bool:
|
|
153
|
+
return (project_root / "dune-project").exists()
|
|
154
|
+
|
|
155
|
+
def analyze(
|
|
156
|
+
self, project_root: Path, files: list[Path] | None = None
|
|
157
|
+
) -> GraphLens:
|
|
158
|
+
graph = GraphLens()
|
|
159
|
+
files = files or self.collect_files(project_root)
|
|
160
|
+
# ... parse and populate graph ...
|
|
161
|
+
return graph
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Register in `pyproject.toml` and the core registry finds it automatically.
|
|
165
|
+
|
|
166
|
+
## Project structure
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
graphlens/ ← uv workspace root (core library)
|
|
170
|
+
src/graphlens/ ← models, contracts, registry, exceptions, utils
|
|
171
|
+
packages/
|
|
172
|
+
graphlens-python/ ← Python adapter (tree-sitter)
|
|
173
|
+
tests/ ← core tests (100% coverage)
|
|
174
|
+
examples/ ← runnable usage examples
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
Requires Python 3.13+, [uv](https://docs.astral.sh/uv/), [task](https://taskfile.dev/).
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
task install # uv sync --all-groups
|
|
183
|
+
task lint # ruff + ty + bandit for all packages
|
|
184
|
+
task tests # all tests with coverage
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Individual package tasks:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
task core:lint task core:test
|
|
191
|
+
task python:lint task python:test
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "graphlens"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Extensible polyglot code analysis framework with a graph IR"
|
|
5
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
6
|
+
license = { file = "LICENSE" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Neko1313", email = "nikita.ribalchencko@yandex.ru" }
|
|
9
|
+
]
|
|
10
|
+
keywords = [
|
|
11
|
+
"code-analysis",
|
|
12
|
+
"graphlens",
|
|
13
|
+
"ast",
|
|
14
|
+
"tree-sitter",
|
|
15
|
+
"static-analysis",
|
|
16
|
+
"graph",
|
|
17
|
+
"polyglot",
|
|
18
|
+
"dependency-analysis",
|
|
19
|
+
"python",
|
|
20
|
+
"code-intelligence",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 3 - Alpha",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
30
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
"Operating System :: OS Independent",
|
|
34
|
+
]
|
|
35
|
+
requires-python = ">=3.13"
|
|
36
|
+
dependencies = []
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Repository = "https://github.com/Neko1313/graphlens"
|
|
40
|
+
Issues = "https://github.com/Neko1313/graphlens/issues"
|
|
41
|
+
Changelog = "https://github.com/Neko1313/graphlens/blob/main/CHANGELOG.md"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.9.18,<0.11.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[tool.uv.workspace]
|
|
48
|
+
members = ["packages/*"]
|
|
49
|
+
|
|
50
|
+
[project.optional-dependencies]
|
|
51
|
+
python = [
|
|
52
|
+
"graphlens-python"
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[dependency-groups]
|
|
56
|
+
lint = [
|
|
57
|
+
"bandit>=1.9.3",
|
|
58
|
+
"ruff>=0.15.0",
|
|
59
|
+
"ty>=0.0.15",
|
|
60
|
+
]
|
|
61
|
+
test = [
|
|
62
|
+
"pytest>=9.0.2",
|
|
63
|
+
"pytest-asyncio>=1.3.0",
|
|
64
|
+
"pytest-cov>=7.0.0",
|
|
65
|
+
"httpx>=0.28.0",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.uv.sources]
|
|
69
|
+
graphlens-python = { workspace = true }
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
testpaths = ["tests", "packages/graphlens-python/tests"]
|
|
73
|
+
|
|
74
|
+
[tool.coverage.run]
|
|
75
|
+
source = ["graphlens", "graphlens_python"]
|
|
76
|
+
|
|
77
|
+
[tool.coverage.report]
|
|
78
|
+
fail_under = 90
|
|
79
|
+
show_missing = true
|
|
80
|
+
exclude_lines = [
|
|
81
|
+
"pragma: no cover",
|
|
82
|
+
"if TYPE_CHECKING:",
|
|
83
|
+
"\\.\\.\\.",
|
|
84
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Models, contracts, registry, and utilities for polyglot code analysis."""
|
|
2
|
+
|
|
3
|
+
from graphlens.contracts import (
|
|
4
|
+
DependencyFileParser,
|
|
5
|
+
DiscoveredProject,
|
|
6
|
+
GraphBackend,
|
|
7
|
+
LanguageAdapter,
|
|
8
|
+
ProjectReader,
|
|
9
|
+
normalize_pkg_name,
|
|
10
|
+
)
|
|
11
|
+
from graphlens.exceptions import (
|
|
12
|
+
AdapterError,
|
|
13
|
+
AdapterNotFoundError,
|
|
14
|
+
BackendError,
|
|
15
|
+
DiscoveryError,
|
|
16
|
+
DuplicateNodeError,
|
|
17
|
+
GraphLensError,
|
|
18
|
+
)
|
|
19
|
+
from graphlens.models import (
|
|
20
|
+
GraphLens,
|
|
21
|
+
Node,
|
|
22
|
+
NodeKind,
|
|
23
|
+
Relation,
|
|
24
|
+
RelationKind,
|
|
25
|
+
)
|
|
26
|
+
from graphlens.registry import AdapterRegistry, adapter_registry
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AdapterError",
|
|
30
|
+
"AdapterNotFoundError",
|
|
31
|
+
# registry
|
|
32
|
+
"AdapterRegistry",
|
|
33
|
+
"BackendError",
|
|
34
|
+
# contracts
|
|
35
|
+
"DependencyFileParser",
|
|
36
|
+
"DiscoveredProject",
|
|
37
|
+
"DiscoveryError",
|
|
38
|
+
"DuplicateNodeError",
|
|
39
|
+
"GraphBackend",
|
|
40
|
+
# models
|
|
41
|
+
"GraphLens",
|
|
42
|
+
# exceptions
|
|
43
|
+
"GraphLensError",
|
|
44
|
+
"LanguageAdapter",
|
|
45
|
+
"Node",
|
|
46
|
+
"NodeKind",
|
|
47
|
+
"ProjectReader",
|
|
48
|
+
"Relation",
|
|
49
|
+
"RelationKind",
|
|
50
|
+
"adapter_registry",
|
|
51
|
+
"normalize_pkg_name",
|
|
52
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Public contracts (ABCs) for graphlens adapters and backends."""
|
|
2
|
+
|
|
3
|
+
from graphlens.contracts.adapter import LanguageAdapter
|
|
4
|
+
from graphlens.contracts.backend import GraphBackend
|
|
5
|
+
from graphlens.contracts.deps import (
|
|
6
|
+
DependencyFileParser,
|
|
7
|
+
normalize_pkg_name,
|
|
8
|
+
)
|
|
9
|
+
from graphlens.contracts.reader import DiscoveredProject, ProjectReader
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DependencyFileParser",
|
|
13
|
+
"DiscoveredProject",
|
|
14
|
+
"GraphBackend",
|
|
15
|
+
"LanguageAdapter",
|
|
16
|
+
"ProjectReader",
|
|
17
|
+
"normalize_pkg_name",
|
|
18
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""LanguageAdapter contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from graphlens.models.graph import GraphLens
|
|
12
|
+
|
|
13
|
+
_EXCLUDED_DIRS: frozenset[str] = frozenset(
|
|
14
|
+
{
|
|
15
|
+
".venv", "venv", "__pycache__", ".git",
|
|
16
|
+
"dist", "build", ".eggs", "node_modules",
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LanguageAdapter(ABC):
|
|
22
|
+
"""Contract that every language adapter package must implement."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def language(self) -> str:
|
|
26
|
+
"""Return the language identifier, e.g. 'python', 'typescript'."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def can_handle(self, project_root: Path) -> bool:
|
|
31
|
+
"""
|
|
32
|
+
Return True if this adapter can handle the project at the given root.
|
|
33
|
+
|
|
34
|
+
Typically checks for marker files
|
|
35
|
+
(pyproject.toml, package.json, Cargo.toml).
|
|
36
|
+
"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def analyze(
|
|
41
|
+
self, project_root: Path, files: list[Path] | None = None
|
|
42
|
+
) -> GraphLens:
|
|
43
|
+
"""
|
|
44
|
+
Parse the project and return a GraphLens with nodes and relations.
|
|
45
|
+
|
|
46
|
+
If ``files`` is None, the adapter collects source files itself via
|
|
47
|
+
``collect_files()``. Pass an explicit list to override (e.g. for
|
|
48
|
+
incremental updates or custom filtering in a pipeline).
|
|
49
|
+
|
|
50
|
+
Adapters must not write to any backend — they return data only.
|
|
51
|
+
"""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def file_extensions(self) -> set[str]:
|
|
55
|
+
"""
|
|
56
|
+
Return file extensions this adapter handles, e.g. {'.py'}.
|
|
57
|
+
|
|
58
|
+
Used by ``collect_files()`` for automatic discovery.
|
|
59
|
+
"""
|
|
60
|
+
return set()
|
|
61
|
+
|
|
62
|
+
def collect_files(self, project_root: Path) -> list[Path]:
|
|
63
|
+
"""
|
|
64
|
+
Return all source files under project_root for this adapter.
|
|
65
|
+
|
|
66
|
+
Excludes common non-source directories
|
|
67
|
+
(.venv, __pycache__, .git, etc.).
|
|
68
|
+
Override for custom discovery logic.
|
|
69
|
+
"""
|
|
70
|
+
extensions = self.file_extensions()
|
|
71
|
+
if not extensions:
|
|
72
|
+
return []
|
|
73
|
+
return sorted(
|
|
74
|
+
p
|
|
75
|
+
for p in project_root.rglob("*")
|
|
76
|
+
if p.is_file()
|
|
77
|
+
and p.suffix in extensions
|
|
78
|
+
and not (_EXCLUDED_DIRS & set(p.relative_to(project_root).parts))
|
|
79
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""GraphBackend contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from graphlens.models.graph import GraphLens
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GraphBackend(ABC):
|
|
13
|
+
"""Contract for graph persistence backends."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def store(self, graph: GraphLens) -> None:
|
|
17
|
+
"""
|
|
18
|
+
Persist the given graph.
|
|
19
|
+
|
|
20
|
+
Implementation decides merge/replace semantics.
|
|
21
|
+
"""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def clear(self) -> None:
|
|
26
|
+
"""Remove all stored data."""
|
|
27
|
+
...
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""DependencyFileParser contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DependencyFileParser(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Extracts declared third-party dependency names from a project manifest.
|
|
15
|
+
|
|
16
|
+
Each implementation targets one file format (pyproject.toml, package.json,
|
|
17
|
+
requirements.txt, Cargo.toml, …). Language adapters ship default parsers
|
|
18
|
+
for their ecosystem; users can pass custom parsers to handle non-standard
|
|
19
|
+
package managers (poetry, pnpm workspaces, pip-tools, etc.).
|
|
20
|
+
|
|
21
|
+
``parse()`` returns *normalized* top-level distribution names so that
|
|
22
|
+
callers can compare them against the first segment of an import path::
|
|
23
|
+
|
|
24
|
+
"requests" # PyPI
|
|
25
|
+
"scikit_learn" # normalized: scikit-learn → scikit_learn
|
|
26
|
+
"@types/node" # npm scoped package (keep as-is)
|
|
27
|
+
|
|
28
|
+
Normalization rule: lowercase, hyphens → underscores, drop extras/version
|
|
29
|
+
specifiers. Scoped npm names (``@scope/pkg``) are kept unchanged.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def can_parse(self, project_root: Path) -> bool:
|
|
34
|
+
"""Return True if this parser applies to the given project root."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def parse(self, project_root: Path) -> frozenset[str]:
|
|
39
|
+
"""Return normalized top-level package names declared as deps."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_pkg_name(name: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Normalize a distribution name for import-name comparison.
|
|
46
|
+
|
|
47
|
+
* Strips version specifiers and extras:
|
|
48
|
+
``requests>=2.0 [security]`` → ``requests``
|
|
49
|
+
* Lowercases
|
|
50
|
+
* Replaces hyphens with underscores
|
|
51
|
+
|
|
52
|
+
Scoped npm names (``@scope/pkg``) are returned as-is (lowercased).
|
|
53
|
+
"""
|
|
54
|
+
# Strip inline comments (requirements.txt style)
|
|
55
|
+
name = name.split("#", maxsplit=1)[0].strip()
|
|
56
|
+
# Strip extras and version specifiers: Foo[bar]>=1.0 → Foo
|
|
57
|
+
for sep in ("[", ">", "<", "=", "!", "~", ";", " "):
|
|
58
|
+
name = name.split(sep)[0]
|
|
59
|
+
name = name.strip()
|
|
60
|
+
if not name:
|
|
61
|
+
return ""
|
|
62
|
+
# Scoped npm packages keep their structure
|
|
63
|
+
if name.startswith("@"):
|
|
64
|
+
return name.lower()
|
|
65
|
+
return name.lower().replace("-", "_")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""ProjectReader contract and DiscoveredProject model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class DiscoveredProject:
|
|
15
|
+
"""Result of project discovery: a root path, language, and source files."""
|
|
16
|
+
|
|
17
|
+
root: Path
|
|
18
|
+
language: str
|
|
19
|
+
files: list[Path] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProjectReader(ABC):
|
|
23
|
+
"""Contract for project discovery and source file enumeration."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def discover(self, root: Path) -> list[DiscoveredProject]:
|
|
27
|
+
"""
|
|
28
|
+
Scan the root directory and return discovered projects.
|
|
29
|
+
|
|
30
|
+
A monorepo may contain multiple projects (e.g., a Python backend and
|
|
31
|
+
a TypeScript frontend). Each gets its own DiscoveredProject entry.
|
|
32
|
+
"""
|
|
33
|
+
...
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Base exceptions for graphlens."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GraphLensError(Exception):
|
|
5
|
+
"""Base exception for all graphlens errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AdapterNotFoundError(GraphLensError):
|
|
9
|
+
"""No adapter found for the requested language."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdapterError(GraphLensError):
|
|
13
|
+
"""Error raised during adapter execution."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DuplicateNodeError(GraphLensError):
|
|
17
|
+
"""A node with this ID already exists in the graph."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DiscoveryError(GraphLensError):
|
|
21
|
+
"""Error raised during project discovery."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BackendError(GraphLensError):
|
|
25
|
+
"""Error raised during graph backend operation."""
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Graph model classes: GraphLens, Node, Relation, and their enums."""
|
|
2
|
+
|
|
3
|
+
from graphlens.models.graph import GraphLens
|
|
4
|
+
from graphlens.models.nodes import Node, NodeKind
|
|
5
|
+
from graphlens.models.relations import Relation, RelationKind
|
|
6
|
+
|
|
7
|
+
__all__ = ["GraphLens", "Node", "NodeKind", "Relation", "RelationKind"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""In-memory code graph container."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from graphlens.exceptions import DuplicateNodeError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from graphlens.models.nodes import Node
|
|
12
|
+
from graphlens.models.relations import Relation
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class GraphLens:
|
|
17
|
+
"""Accumulator for nodes and relations produced by language adapters."""
|
|
18
|
+
|
|
19
|
+
nodes: dict[str, Node] = field(default_factory=dict)
|
|
20
|
+
relations: list[Relation] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
def add_node(self, node: Node) -> None:
|
|
23
|
+
"""Add a node; raise DuplicateNodeError on ID collision."""
|
|
24
|
+
if node.id in self.nodes:
|
|
25
|
+
msg = f"Node with id '{node.id}' already exists"
|
|
26
|
+
raise DuplicateNodeError(msg)
|
|
27
|
+
self.nodes[node.id] = node
|
|
28
|
+
|
|
29
|
+
def add_relation(self, relation: Relation) -> None:
|
|
30
|
+
"""Append a relation to the graph."""
|
|
31
|
+
self.relations.append(relation)
|
|
32
|
+
|
|
33
|
+
def merge(self, other: GraphLens) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Merge another graph into this one.
|
|
36
|
+
|
|
37
|
+
Raises DuplicateNodeError on ID collision.
|
|
38
|
+
"""
|
|
39
|
+
for node in other.nodes.values():
|
|
40
|
+
self.add_node(node)
|
|
41
|
+
self.relations.extend(other.relations)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Node (entity) model for the code graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from graphlens.utils.span import Span
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NodeKind(enum.Enum):
|
|
14
|
+
"""Discriminator for the kind of entity a node represents."""
|
|
15
|
+
|
|
16
|
+
PROJECT = "project"
|
|
17
|
+
MODULE = "module"
|
|
18
|
+
FILE = "file"
|
|
19
|
+
CLASS = "class"
|
|
20
|
+
FUNCTION = "function"
|
|
21
|
+
METHOD = "method"
|
|
22
|
+
PARAMETER = "parameter"
|
|
23
|
+
IMPORT = "import"
|
|
24
|
+
DEPENDENCY = "dependency"
|
|
25
|
+
SYMBOL = "symbol"
|
|
26
|
+
EXTERNAL_SYMBOL = "external_symbol"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Node:
|
|
31
|
+
"""
|
|
32
|
+
A single entity in the code graph.
|
|
33
|
+
|
|
34
|
+
Uses a kind discriminator instead of a class-per-entity hierarchy to
|
|
35
|
+
keep the model flat, serialization-friendly, and easy to produce in
|
|
36
|
+
adapter tight loops.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
id: str
|
|
40
|
+
kind: NodeKind
|
|
41
|
+
qualified_name: str
|
|
42
|
+
name: str
|
|
43
|
+
file_path: str | None = None
|
|
44
|
+
span: Span | None = None
|
|
45
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Relation (edge) model for the code graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RelationKind(enum.Enum):
|
|
10
|
+
"""Discriminator for the kind of directed edge between two nodes."""
|
|
11
|
+
|
|
12
|
+
CONTAINS = "contains"
|
|
13
|
+
DECLARES = "declares"
|
|
14
|
+
IMPORTS = "imports"
|
|
15
|
+
CALLS = "calls"
|
|
16
|
+
REFERENCES = "references"
|
|
17
|
+
DEPENDS_ON = "depends_on"
|
|
18
|
+
RESOLVES_TO = "resolves_to"
|
|
19
|
+
INHERITS_FROM = "inherits_from"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Relation:
|
|
24
|
+
"""A directed edge between two nodes, referenced by ID."""
|
|
25
|
+
|
|
26
|
+
source_id: str
|
|
27
|
+
target_id: str
|
|
28
|
+
kind: RelationKind
|
|
29
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Adapter registry — discovers and loads language adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from graphlens.exceptions import AdapterNotFoundError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from graphlens.contracts.adapter import LanguageAdapter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AdapterRegistry:
|
|
15
|
+
"""
|
|
16
|
+
Registry for language adapters.
|
|
17
|
+
|
|
18
|
+
Supports two registration mechanisms (in resolution order):
|
|
19
|
+
1. In-memory registration via ``register()`` — for manual setup
|
|
20
|
+
and testing.
|
|
21
|
+
2. Automatic discovery via ``importlib.metadata`` entry points
|
|
22
|
+
under the ``"graphlens.adapters"`` group — for installed
|
|
23
|
+
adapter packages.
|
|
24
|
+
|
|
25
|
+
Adapter packages register themselves in their ``pyproject.toml``::
|
|
26
|
+
|
|
27
|
+
[project.entry-points."graphlens.adapters"]
|
|
28
|
+
python = "graphlens_python:PythonAdapter"
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
ENTRY_POINT_GROUP = "graphlens.adapters"
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
"""Initialise the registry with an empty in-memory store."""
|
|
35
|
+
self._adapters: dict[str, type[LanguageAdapter]] = {}
|
|
36
|
+
|
|
37
|
+
def register(self, name: str, adapter_cls: type[LanguageAdapter]) -> None:
|
|
38
|
+
"""Register an adapter class for the given language name."""
|
|
39
|
+
self._adapters[name] = adapter_cls
|
|
40
|
+
|
|
41
|
+
def load(self, name: str) -> type[LanguageAdapter]:
|
|
42
|
+
"""
|
|
43
|
+
Return the adapter class for the given language name.
|
|
44
|
+
|
|
45
|
+
Checks in-memory registry first, then entry points.
|
|
46
|
+
Raises :exc:`AdapterNotFoundError` if not found.
|
|
47
|
+
"""
|
|
48
|
+
if name in self._adapters:
|
|
49
|
+
return self._adapters[name]
|
|
50
|
+
|
|
51
|
+
for ep in importlib.metadata.entry_points(
|
|
52
|
+
group=self.ENTRY_POINT_GROUP
|
|
53
|
+
):
|
|
54
|
+
if ep.name == name:
|
|
55
|
+
adapter_cls = ep.load()
|
|
56
|
+
self._adapters[name] = adapter_cls
|
|
57
|
+
return adapter_cls
|
|
58
|
+
|
|
59
|
+
msg = (
|
|
60
|
+
f"No adapter found for language '{name}'. "
|
|
61
|
+
f"Install a graphlens-{name} package or register an adapter"
|
|
62
|
+
" manually."
|
|
63
|
+
)
|
|
64
|
+
raise AdapterNotFoundError(msg)
|
|
65
|
+
|
|
66
|
+
def available(self) -> list[str]:
|
|
67
|
+
"""Return names of all available adapters (registered + entry pts)."""
|
|
68
|
+
names: set[str] = set(self._adapters)
|
|
69
|
+
for ep in importlib.metadata.entry_points(
|
|
70
|
+
group=self.ENTRY_POINT_GROUP
|
|
71
|
+
):
|
|
72
|
+
names.add(ep.name)
|
|
73
|
+
return sorted(names)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
adapter_registry = AdapterRegistry()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Deterministic node ID generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_node_id(project_name: str, qualified_name: str, kind: str) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Return a stable, deterministic node ID.
|
|
11
|
+
|
|
12
|
+
Uses a truncated SHA-256 hex digest so the same inputs always produce
|
|
13
|
+
the same ID across runs, enabling incremental updates and graph diffing.
|
|
14
|
+
"""
|
|
15
|
+
key = f"{project_name}::{kind}::{qualified_name}"
|
|
16
|
+
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Source location utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Span:
|
|
10
|
+
"""A source location range. All values are 1-based."""
|
|
11
|
+
|
|
12
|
+
start_line: int
|
|
13
|
+
start_col: int
|
|
14
|
+
end_line: int
|
|
15
|
+
end_col: int
|