graphlint 0.1.0__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.
- graphlint/__init__.py +25 -0
- graphlint/agent_tools.py +263 -0
- graphlint/analyzer/__init__.py +1 -0
- graphlint/analyzer/_ast_visitor.py +361 -0
- graphlint/analyzer/_graph_algo.py +437 -0
- graphlint/analyzer/_types.py +65 -0
- graphlint/analyzer/decorators.py +169 -0
- graphlint/analyzer/entry_detect.py +509 -0
- graphlint/analyzer/graph.py +766 -0
- graphlint/analyzer/imports.py +120 -0
- graphlint/analyzer/parser.py +133 -0
- graphlint/analyzer/warnings.py +361 -0
- graphlint/api.py +419 -0
- graphlint/cli.py +247 -0
- graphlint/config/__init__.py +1 -0
- graphlint/config/defaults.py +129 -0
- graphlint/config/manager.py +222 -0
- graphlint/exceptions.py +43 -0
- graphlint/i18n/__init__.py +125 -0
- graphlint/i18n/en.py +75 -0
- graphlint/i18n/zh_CN.py +75 -0
- graphlint/incremental/__init__.py +1 -0
- graphlint/incremental/_db_ops.py +438 -0
- graphlint/incremental/indexer.py +343 -0
- graphlint/params.py +313 -0
- graphlint/query/__init__.py +1 -0
- graphlint/query/engine.py +436 -0
- graphlint/query/formatter.py +332 -0
- graphlint/query/volume.py +92 -0
- graphlint/storage/__init__.py +1 -0
- graphlint/storage/db.py +165 -0
- graphlint/storage/hashing.py +54 -0
- graphlint/storage/schema.py +140 -0
- graphlint-0.1.0.dist-info/METADATA +181 -0
- graphlint-0.1.0.dist-info/RECORD +39 -0
- graphlint-0.1.0.dist-info/WHEEL +5 -0
- graphlint-0.1.0.dist-info/entry_points.txt +2 -0
- graphlint-0.1.0.dist-info/licenses/LICENSE +21 -0
- graphlint-0.1.0.dist-info/top_level.txt +1 -0
graphlint/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""graphlint — Code Dependency Graph Analyzer."""
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = ["query", "build", "configure", "__version__"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name: str) -> Any:
|
|
12
|
+
"""Lazy import public API names."""
|
|
13
|
+
if name == "query":
|
|
14
|
+
from graphlint.api import query as _query
|
|
15
|
+
|
|
16
|
+
return _query
|
|
17
|
+
if name == "build":
|
|
18
|
+
from graphlint.api import build as _build
|
|
19
|
+
|
|
20
|
+
return _build
|
|
21
|
+
if name == "configure":
|
|
22
|
+
from graphlint.api import configure as _configure
|
|
23
|
+
|
|
24
|
+
return _configure
|
|
25
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
graphlint/agent_tools.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Agent tool integration — install/uninstall graphlint prompts for AI coding tools.
|
|
3
|
+
|
|
4
|
+
Configures agent tools at the global level so graphlint's usage prompt is
|
|
5
|
+
available in every project the agent opens.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import List, Tuple
|
|
13
|
+
|
|
14
|
+
AGENT_PROMPT = """# graphlint — Dead Code Detection for Python
|
|
15
|
+
|
|
16
|
+
## When to Use It
|
|
17
|
+
- **After code modifications**: Run to check if your edits left behind dead or redundant code — components no longer reachable from any entry point
|
|
18
|
+
- **Before analyzing a codebase**: Run to verify the dependency graph is correctly built and all expected entry points are recognized
|
|
19
|
+
|
|
20
|
+
## Quick Commands
|
|
21
|
+
```bash
|
|
22
|
+
graphlint build --force # Build/rebuild index (Full codebase scan, time consuming for large codebase)
|
|
23
|
+
graphlint query # List dependency graphs (recommanded, auto incremental rebuild)
|
|
24
|
+
graphlint query --json # JSON output
|
|
25
|
+
graphlint query -g <id> --detail full # Full detail on one graph
|
|
26
|
+
graphlint config show # View current config
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use the -h option in each command to query detailed instructions (use only when necessary).
|
|
30
|
+
|
|
31
|
+
## Key Parameters
|
|
32
|
+
- `-g, --graph-id <int>` — Inspect a specific dependency graph
|
|
33
|
+
- `--json, -j` — Structured output (JSON)
|
|
34
|
+
- `-w, --warn-types <str>` — Filter: `dead_code`, `circular_ref`, `unused_import`
|
|
35
|
+
- `-t, --include-tests` — Include test files in analysis
|
|
36
|
+
- `-d, --detail <level>` — Detail: `auto`/`summary`/`full`/`minimal`
|
|
37
|
+
- `-r, --root-dir <path>` — Project root directory
|
|
38
|
+
- `-C, --exclude-clean` — Show only graphs with issues
|
|
39
|
+
- `-f, --force` — Force full index rebuild
|
|
40
|
+
- `--sort-by <field>` — Sort: `warnings`/`nodes`/`edges`/`name`
|
|
41
|
+
|
|
42
|
+
## Usage Examples
|
|
43
|
+
```bash
|
|
44
|
+
# Check for dead code after a refactor
|
|
45
|
+
graphlint query --json
|
|
46
|
+
|
|
47
|
+
# Inspect a specific component's connections
|
|
48
|
+
graphlint query -g 5 -d full
|
|
49
|
+
|
|
50
|
+
# Scan all warnings sorted by severity
|
|
51
|
+
graphlint query -C --sort-by warnings --json
|
|
52
|
+
```\
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
MARKER_START = "<!-- graphlint:start -->"
|
|
56
|
+
MARKER_END = "<!-- graphlint:end -->"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _prompt_block() -> str:
|
|
60
|
+
return f"\n{MARKER_START}\n{AGENT_PROMPT}\n{MARKER_END}\n"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _expand(path: str) -> str:
|
|
64
|
+
"""Expand ~ to home directory, normalize separators."""
|
|
65
|
+
return os.path.normpath(os.path.expanduser(path))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Tool definitions: (id, display_name, global_config_path, description)
|
|
69
|
+
# All paths use ~ which is expanded at install/uninstall time.
|
|
70
|
+
TOOLS: List[Tuple[str, str, str, str]] = [
|
|
71
|
+
(
|
|
72
|
+
"opencode",
|
|
73
|
+
"OpenCode CLI",
|
|
74
|
+
"~/.config/opencode/AGENTS.md",
|
|
75
|
+
"Global AGENTS.md — read by opencode in every project",
|
|
76
|
+
),
|
|
77
|
+
(
|
|
78
|
+
"cursor",
|
|
79
|
+
"Cursor Editor",
|
|
80
|
+
"~/.cursorrules",
|
|
81
|
+
"Global .cursorrules — applies to all Cursor projects",
|
|
82
|
+
),
|
|
83
|
+
(
|
|
84
|
+
"codex",
|
|
85
|
+
"Codex CLI",
|
|
86
|
+
"~/.codex/rules/graphlint.md",
|
|
87
|
+
"Global rules directory — recognized by Codex CLI",
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
"cc",
|
|
91
|
+
"Claude Code (CLI)",
|
|
92
|
+
"~/.claude/CLAUDE.md",
|
|
93
|
+
"Global CLAUDE.md — read by Claude Code in every project",
|
|
94
|
+
),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _prompt_installed_in(filepath: str) -> bool:
|
|
99
|
+
if not os.path.isfile(filepath):
|
|
100
|
+
return False
|
|
101
|
+
with open(filepath, encoding="utf-8") as f:
|
|
102
|
+
return MARKER_START in f.read()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _write_prompt(filepath: str) -> bool:
|
|
106
|
+
try:
|
|
107
|
+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
108
|
+
if os.path.isfile(filepath) and _prompt_installed_in(filepath):
|
|
109
|
+
return False
|
|
110
|
+
block = _prompt_block()
|
|
111
|
+
if os.path.isfile(filepath):
|
|
112
|
+
with open(filepath, "a", encoding="utf-8") as f:
|
|
113
|
+
f.write(block)
|
|
114
|
+
else:
|
|
115
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
116
|
+
f.write(block)
|
|
117
|
+
return True
|
|
118
|
+
except OSError:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _remove_prompt(filepath: str) -> bool:
|
|
123
|
+
if not os.path.isfile(filepath):
|
|
124
|
+
return False
|
|
125
|
+
try:
|
|
126
|
+
with open(filepath, encoding="utf-8") as f:
|
|
127
|
+
content = f.read()
|
|
128
|
+
if MARKER_START not in content:
|
|
129
|
+
return False
|
|
130
|
+
start = content.index(MARKER_START)
|
|
131
|
+
end = content.index(MARKER_END) + len(MARKER_END)
|
|
132
|
+
new_content = content[:start] + content[end:]
|
|
133
|
+
lines = new_content.splitlines(keepends=True)
|
|
134
|
+
cleaned = []
|
|
135
|
+
prev_empty = False
|
|
136
|
+
for line in lines:
|
|
137
|
+
if line.strip() == "":
|
|
138
|
+
if prev_empty:
|
|
139
|
+
continue
|
|
140
|
+
prev_empty = True
|
|
141
|
+
else:
|
|
142
|
+
prev_empty = False
|
|
143
|
+
cleaned.append(line)
|
|
144
|
+
while cleaned and cleaned[0].strip() == "":
|
|
145
|
+
cleaned.pop(0)
|
|
146
|
+
while cleaned and cleaned[-1].strip() == "":
|
|
147
|
+
cleaned.pop()
|
|
148
|
+
new_content = "".join(cleaned)
|
|
149
|
+
if new_content.strip() == "":
|
|
150
|
+
os.remove(filepath)
|
|
151
|
+
else:
|
|
152
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
153
|
+
f.write(new_content)
|
|
154
|
+
return True
|
|
155
|
+
except (OSError, ValueError):
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_paths(cwd: str = None) -> List[Tuple[str, str, str, str, str]]:
|
|
160
|
+
"""Resolve tool paths, expanded from ~."""
|
|
161
|
+
resolved = []
|
|
162
|
+
for tool_id, display_name, rel_path, desc in TOOLS:
|
|
163
|
+
full_path = _expand(rel_path)
|
|
164
|
+
resolved.append((tool_id, display_name, rel_path, full_path, desc))
|
|
165
|
+
return resolved
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _select_tools(message: str, resolved: List[Tuple]) -> List[Tuple]:
|
|
169
|
+
"""Interactive multi-select prompt for agent tools."""
|
|
170
|
+
print(f"\n{message}\n")
|
|
171
|
+
for i, (_, display_name, rel_path, full_path, desc) in enumerate(resolved, 1):
|
|
172
|
+
status = "✓" if os.path.isfile(full_path) else " "
|
|
173
|
+
print(f" [{i}] {display_name:<20} {rel_path}")
|
|
174
|
+
print(f" {desc}")
|
|
175
|
+
print()
|
|
176
|
+
while True:
|
|
177
|
+
try:
|
|
178
|
+
raw = input(
|
|
179
|
+
"Enter numbers separated by comma (e.g. 1,3) or 'all': "
|
|
180
|
+
).strip()
|
|
181
|
+
if raw.lower() == "all":
|
|
182
|
+
return list(resolved)
|
|
183
|
+
if not raw:
|
|
184
|
+
print("No selection. Aborting.")
|
|
185
|
+
return []
|
|
186
|
+
indices = [int(x.strip()) for x in raw.split(",")]
|
|
187
|
+
selected = []
|
|
188
|
+
for idx in indices:
|
|
189
|
+
if 1 <= idx <= len(resolved):
|
|
190
|
+
selected.append(resolved[idx - 1])
|
|
191
|
+
else:
|
|
192
|
+
print(f" Invalid number: {idx}")
|
|
193
|
+
break
|
|
194
|
+
else:
|
|
195
|
+
return selected
|
|
196
|
+
except (ValueError, KeyboardInterrupt):
|
|
197
|
+
print("Invalid input. Try again.")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def install_tools(cwd: str = None) -> str:
|
|
201
|
+
"""Interactively install graphlint prompt to selected agent tools (global)."""
|
|
202
|
+
resolved = _resolve_paths(cwd)
|
|
203
|
+
selected = _select_tools("Select agent tool(s) to install graphlint prompt:", resolved)
|
|
204
|
+
if not selected:
|
|
205
|
+
return "No tools selected."
|
|
206
|
+
results = []
|
|
207
|
+
for tool_id, display_name, rel_path, full_path, desc in selected:
|
|
208
|
+
if _write_prompt(full_path):
|
|
209
|
+
results.append(f" ✓ {display_name} -> {full_path}")
|
|
210
|
+
else:
|
|
211
|
+
if _prompt_installed_in(full_path):
|
|
212
|
+
results.append(f" - {display_name} ({rel_path}) — already installed")
|
|
213
|
+
else:
|
|
214
|
+
results.append(f" ✗ {display_name} ({rel_path}) — failed to write")
|
|
215
|
+
return "Install results:\n" + "\n".join(results)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def uninstall_tools(cwd: str = None) -> str:
|
|
219
|
+
"""Interactively uninstall graphlint prompt from selected agent tools."""
|
|
220
|
+
resolved = _resolve_paths(cwd)
|
|
221
|
+
installed = [
|
|
222
|
+
t for t in resolved if _prompt_installed_in(t[3])
|
|
223
|
+
]
|
|
224
|
+
if not installed:
|
|
225
|
+
return "No agent tools with graphlint prompt found."
|
|
226
|
+
print("\nDetected installations:\n")
|
|
227
|
+
for i, (tool_id, display_name, rel_path, full_path, desc) in enumerate(
|
|
228
|
+
installed, 1
|
|
229
|
+
):
|
|
230
|
+
print(f" [{i}] {display_name:<20} {rel_path}")
|
|
231
|
+
print()
|
|
232
|
+
while True:
|
|
233
|
+
try:
|
|
234
|
+
raw = input(
|
|
235
|
+
"Enter numbers to uninstall (comma separated) or 'all': "
|
|
236
|
+
).strip()
|
|
237
|
+
if raw.lower() == "all":
|
|
238
|
+
selected = list(installed)
|
|
239
|
+
break
|
|
240
|
+
if not raw:
|
|
241
|
+
print("No selection. Aborting.")
|
|
242
|
+
return []
|
|
243
|
+
indices = [int(x.strip()) for x in raw.split(",")]
|
|
244
|
+
selected = []
|
|
245
|
+
for idx in indices:
|
|
246
|
+
if 1 <= idx <= len(installed):
|
|
247
|
+
selected.append(installed[idx - 1])
|
|
248
|
+
else:
|
|
249
|
+
print(f" Invalid number: {idx}")
|
|
250
|
+
break
|
|
251
|
+
else:
|
|
252
|
+
break
|
|
253
|
+
except (ValueError, KeyboardInterrupt):
|
|
254
|
+
print("Invalid input. Try again.")
|
|
255
|
+
if not selected:
|
|
256
|
+
return "No tools selected."
|
|
257
|
+
results = []
|
|
258
|
+
for tool_id, display_name, rel_path, full_path, desc in selected:
|
|
259
|
+
if _remove_prompt(full_path):
|
|
260
|
+
results.append(f" ✓ {display_name} ({rel_path}) — removed")
|
|
261
|
+
else:
|
|
262
|
+
results.append(f" ✗ {display_name} ({rel_path}) — failed to remove")
|
|
263
|
+
return "Uninstall results:\n" + "\n".join(results)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AST parsing, dependency graph building, and entry point detection."""
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""AST visitor — traverses AST to extract nodes, imports, and name usages."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
from typing import List, Set
|
|
8
|
+
|
|
9
|
+
from graphlint.analyzer._types import NodeInfo
|
|
10
|
+
from graphlint.analyzer.decorators import DecoratorResolver
|
|
11
|
+
from graphlint.analyzer.imports import ImportAnalyzer, ImportInfo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ASTVisitor(ast.NodeVisitor):
|
|
15
|
+
"""Custom AST visitor that extracts nodes, imports, and name usages."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
module_qualified: str,
|
|
20
|
+
file_path: str,
|
|
21
|
+
import_analyzer: ImportAnalyzer,
|
|
22
|
+
decorator_resolver: DecoratorResolver,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Initialize the AST visitor."""
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.module_qualified: str = module_qualified
|
|
27
|
+
self.file_path: str = file_path
|
|
28
|
+
self.import_analyzer: ImportAnalyzer = import_analyzer
|
|
29
|
+
self.decorator_resolver: DecoratorResolver = decorator_resolver
|
|
30
|
+
|
|
31
|
+
self.nodes: List[NodeInfo] = []
|
|
32
|
+
self.imports: List[ImportInfo] = []
|
|
33
|
+
self.name_usages: Set[str] = set()
|
|
34
|
+
|
|
35
|
+
self._context: List[str] = [module_qualified]
|
|
36
|
+
self._current_class_id: int = 0
|
|
37
|
+
self._current_func_id: int = 0
|
|
38
|
+
self._node_id: int = 1
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# Generic visit
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def visit(self, node: ast.AST) -> None:
|
|
45
|
+
"""Override visit to gracefully degrade on error."""
|
|
46
|
+
try:
|
|
47
|
+
super().visit(node)
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
import sys
|
|
50
|
+
|
|
51
|
+
print(
|
|
52
|
+
f"[graphlint] AST visit error in {self.file_path}: {exc}",
|
|
53
|
+
file=sys.stderr,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def generic_visit(self, node: ast.AST) -> None:
|
|
57
|
+
"""Generic visit: collect name usages."""
|
|
58
|
+
if isinstance(node, ast.Name):
|
|
59
|
+
if isinstance(node.ctx, ast.Load):
|
|
60
|
+
self.name_usages.add(node.id)
|
|
61
|
+
elif isinstance(node, ast.Attribute):
|
|
62
|
+
self.name_usages.add(node.attr)
|
|
63
|
+
if isinstance(node.value, ast.Name):
|
|
64
|
+
self.name_usages.add(node.value.id)
|
|
65
|
+
super().generic_visit(node)
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Import visit
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
72
|
+
"""Process import xxx statements."""
|
|
73
|
+
infos = self.import_analyzer.analyze_import(node)
|
|
74
|
+
self.imports.extend(infos)
|
|
75
|
+
self.generic_visit(node)
|
|
76
|
+
|
|
77
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
78
|
+
"""Process from xxx import yyy statements."""
|
|
79
|
+
infos = self.import_analyzer.analyze_import(node)
|
|
80
|
+
self.imports.extend(infos)
|
|
81
|
+
self.generic_visit(node)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Class definition
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
88
|
+
"""Process a class definition."""
|
|
89
|
+
qualified = ".".join(self._context + [node.name])
|
|
90
|
+
dec_infos = self.decorator_resolver.extract_decorator_names(
|
|
91
|
+
node.decorator_list, self.module_qualified
|
|
92
|
+
)
|
|
93
|
+
dec_names = [d.qualified_name for d in dec_infos]
|
|
94
|
+
docstring = self._get_docstring(node)
|
|
95
|
+
is_deprecated, dep_msg = DecoratorResolver.check_deprecated(
|
|
96
|
+
node.decorator_list, docstring
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
class_node = NodeInfo(
|
|
100
|
+
file_id=0,
|
|
101
|
+
name=node.name,
|
|
102
|
+
qualified_name=qualified,
|
|
103
|
+
node_type="class",
|
|
104
|
+
line_start=node.lineno,
|
|
105
|
+
line_end=node.end_lineno or node.lineno,
|
|
106
|
+
col_offset=node.col_offset,
|
|
107
|
+
parent_node_id=0,
|
|
108
|
+
is_deprecated=is_deprecated,
|
|
109
|
+
deprecation_msg=dep_msg,
|
|
110
|
+
type_annotation="",
|
|
111
|
+
is_async=False,
|
|
112
|
+
decorators=dec_names,
|
|
113
|
+
docstring=docstring,
|
|
114
|
+
is_entry=False,
|
|
115
|
+
)
|
|
116
|
+
class_node_id = self._add_node(class_node)
|
|
117
|
+
prev_class_id = self._current_class_id
|
|
118
|
+
self._current_class_id = class_node_id
|
|
119
|
+
self._context.append(node.name)
|
|
120
|
+
|
|
121
|
+
for base in node.bases:
|
|
122
|
+
self.visit(base)
|
|
123
|
+
for dec in node.decorator_list:
|
|
124
|
+
self.visit(dec)
|
|
125
|
+
for item in node.body:
|
|
126
|
+
self.visit(item)
|
|
127
|
+
|
|
128
|
+
self._context.pop()
|
|
129
|
+
self._current_class_id = prev_class_id
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Function definition
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
136
|
+
"""Process a function definition."""
|
|
137
|
+
self._handle_function(node, is_async=False)
|
|
138
|
+
|
|
139
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
140
|
+
"""Process an async function definition."""
|
|
141
|
+
self._handle_function(node, is_async=True)
|
|
142
|
+
|
|
143
|
+
def _handle_function(
|
|
144
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, is_async: bool
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Shared logic for function/method definitions."""
|
|
147
|
+
is_method = self._current_class_id != 0
|
|
148
|
+
qualified = ".".join(self._context + [node.name])
|
|
149
|
+
node_type = "method" if is_method else "function"
|
|
150
|
+
|
|
151
|
+
dec_infos = self.decorator_resolver.extract_decorator_names(
|
|
152
|
+
node.decorator_list, self.module_qualified
|
|
153
|
+
)
|
|
154
|
+
dec_names = [d.qualified_name for d in dec_infos]
|
|
155
|
+
docstring = self._get_docstring(node)
|
|
156
|
+
is_deprecated, dep_msg = DecoratorResolver.check_deprecated(
|
|
157
|
+
node.decorator_list, docstring
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
type_ann = ""
|
|
161
|
+
if node.returns:
|
|
162
|
+
try:
|
|
163
|
+
type_ann = ast.unparse(node.returns)
|
|
164
|
+
except Exception:
|
|
165
|
+
type_ann = ""
|
|
166
|
+
|
|
167
|
+
func_node = NodeInfo(
|
|
168
|
+
file_id=0,
|
|
169
|
+
name=node.name,
|
|
170
|
+
qualified_name=qualified,
|
|
171
|
+
node_type=node_type,
|
|
172
|
+
line_start=node.lineno,
|
|
173
|
+
line_end=node.end_lineno or node.lineno,
|
|
174
|
+
col_offset=node.col_offset,
|
|
175
|
+
parent_node_id=self._current_class_id,
|
|
176
|
+
is_deprecated=is_deprecated,
|
|
177
|
+
deprecation_msg=dep_msg,
|
|
178
|
+
type_annotation=type_ann,
|
|
179
|
+
is_async=is_async,
|
|
180
|
+
decorators=dec_names,
|
|
181
|
+
docstring=docstring,
|
|
182
|
+
is_entry=False,
|
|
183
|
+
)
|
|
184
|
+
func_node_id = self._add_node(func_node)
|
|
185
|
+
|
|
186
|
+
for dec in node.decorator_list:
|
|
187
|
+
self.visit(dec)
|
|
188
|
+
if node.returns:
|
|
189
|
+
self.visit(node.returns)
|
|
190
|
+
if hasattr(node, "args") and isinstance(node.args, ast.arguments):
|
|
191
|
+
for arg in ast.iter_child_nodes(node.args):
|
|
192
|
+
self.visit(arg)
|
|
193
|
+
|
|
194
|
+
prev_class_id = self._current_class_id
|
|
195
|
+
prev_func_id = self._current_func_id
|
|
196
|
+
self._current_class_id = 0
|
|
197
|
+
self._current_func_id = func_node_id
|
|
198
|
+
self._context.append(node.name)
|
|
199
|
+
for item in node.body:
|
|
200
|
+
self.visit(item)
|
|
201
|
+
self._context.pop()
|
|
202
|
+
self._current_class_id = prev_class_id
|
|
203
|
+
self._current_func_id = prev_func_id
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Variable / field
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
210
|
+
"""Process assignment statements (module-level or class fields)."""
|
|
211
|
+
is_class_level = self._current_class_id != 0
|
|
212
|
+
is_func_level = not is_class_level and self._current_func_id != 0
|
|
213
|
+
node_type = "field" if is_class_level else "variable"
|
|
214
|
+
if is_class_level:
|
|
215
|
+
parent_id = self._current_class_id
|
|
216
|
+
elif is_func_level:
|
|
217
|
+
parent_id = self._current_func_id
|
|
218
|
+
else:
|
|
219
|
+
parent_id = 0
|
|
220
|
+
|
|
221
|
+
for target in node.targets:
|
|
222
|
+
self._extract_target(target, node_type, parent_id, node)
|
|
223
|
+
|
|
224
|
+
self.generic_visit(node)
|
|
225
|
+
|
|
226
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
227
|
+
"""Process annotated assignment statements."""
|
|
228
|
+
if node.target is None:
|
|
229
|
+
self.generic_visit(node)
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
is_class_level = self._current_class_id != 0
|
|
233
|
+
is_func_level = not is_class_level and self._current_func_id != 0
|
|
234
|
+
node_type = "field" if is_class_level else "variable"
|
|
235
|
+
if is_class_level:
|
|
236
|
+
parent_id = self._current_class_id
|
|
237
|
+
elif is_func_level:
|
|
238
|
+
parent_id = self._current_func_id
|
|
239
|
+
else:
|
|
240
|
+
parent_id = 0
|
|
241
|
+
|
|
242
|
+
type_ann = ""
|
|
243
|
+
if node.annotation:
|
|
244
|
+
try:
|
|
245
|
+
type_ann = ast.unparse(node.annotation)
|
|
246
|
+
except Exception:
|
|
247
|
+
type_ann = ""
|
|
248
|
+
self.visit(node.annotation)
|
|
249
|
+
|
|
250
|
+
self._extract_annotated_target(
|
|
251
|
+
node.target, node_type, parent_id, node, type_ann
|
|
252
|
+
)
|
|
253
|
+
self.generic_visit(node)
|
|
254
|
+
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
# Target extraction helpers
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def _extract_target(
|
|
260
|
+
self,
|
|
261
|
+
target: ast.expr,
|
|
262
|
+
node_type: str,
|
|
263
|
+
parent_id: int,
|
|
264
|
+
assign_node: ast.Assign,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Extract variable/field nodes from assignment targets."""
|
|
267
|
+
if isinstance(target, ast.Name):
|
|
268
|
+
qualified = ".".join(self._context + [target.id])
|
|
269
|
+
self._add_node(
|
|
270
|
+
NodeInfo(
|
|
271
|
+
file_id=0,
|
|
272
|
+
name=target.id,
|
|
273
|
+
qualified_name=qualified,
|
|
274
|
+
node_type=node_type,
|
|
275
|
+
line_start=assign_node.lineno,
|
|
276
|
+
line_end=assign_node.end_lineno or assign_node.lineno,
|
|
277
|
+
col_offset=assign_node.col_offset,
|
|
278
|
+
parent_node_id=parent_id,
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
elif isinstance(target, ast.Attribute):
|
|
282
|
+
if isinstance(target.value, ast.Attribute):
|
|
283
|
+
return
|
|
284
|
+
qualified = ".".join(self._context + [target.attr])
|
|
285
|
+
self._add_node(
|
|
286
|
+
NodeInfo(
|
|
287
|
+
file_id=0,
|
|
288
|
+
name=target.attr,
|
|
289
|
+
qualified_name=qualified,
|
|
290
|
+
node_type=node_type,
|
|
291
|
+
line_start=assign_node.lineno,
|
|
292
|
+
line_end=assign_node.end_lineno or assign_node.lineno,
|
|
293
|
+
col_offset=assign_node.col_offset,
|
|
294
|
+
parent_node_id=parent_id,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
298
|
+
for elt in target.elts:
|
|
299
|
+
self._extract_target(elt, node_type, parent_id, assign_node)
|
|
300
|
+
|
|
301
|
+
def _extract_annotated_target(
|
|
302
|
+
self,
|
|
303
|
+
target: ast.expr,
|
|
304
|
+
node_type: str,
|
|
305
|
+
parent_id: int,
|
|
306
|
+
node: ast.AnnAssign,
|
|
307
|
+
type_ann: str,
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Extract nodes from annotated assignment targets."""
|
|
310
|
+
if isinstance(target, ast.Name):
|
|
311
|
+
qualified = ".".join(self._context + [target.id])
|
|
312
|
+
self._add_node(
|
|
313
|
+
NodeInfo(
|
|
314
|
+
file_id=0,
|
|
315
|
+
name=target.id,
|
|
316
|
+
qualified_name=qualified,
|
|
317
|
+
node_type=node_type,
|
|
318
|
+
line_start=node.lineno,
|
|
319
|
+
line_end=node.end_lineno or node.lineno,
|
|
320
|
+
col_offset=node.col_offset,
|
|
321
|
+
parent_node_id=parent_id,
|
|
322
|
+
type_annotation=type_ann,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
elif isinstance(target, ast.Attribute):
|
|
326
|
+
if isinstance(target.value, ast.Attribute):
|
|
327
|
+
return
|
|
328
|
+
qualified = ".".join(self._context + [target.attr])
|
|
329
|
+
self._add_node(
|
|
330
|
+
NodeInfo(
|
|
331
|
+
file_id=0,
|
|
332
|
+
name=target.attr,
|
|
333
|
+
qualified_name=qualified,
|
|
334
|
+
node_type=node_type,
|
|
335
|
+
line_start=node.lineno,
|
|
336
|
+
line_end=node.end_lineno or node.lineno,
|
|
337
|
+
col_offset=node.col_offset,
|
|
338
|
+
parent_node_id=parent_id,
|
|
339
|
+
type_annotation=type_ann,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
# Helpers
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def _add_node(self, node: NodeInfo) -> int:
|
|
348
|
+
"""Add a node and return its ID."""
|
|
349
|
+
node_id = self._node_id
|
|
350
|
+
self._node_id += 1
|
|
351
|
+
node.id = node_id
|
|
352
|
+
self.nodes.append(node)
|
|
353
|
+
return node_id
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def _get_docstring(node: ast.AST) -> str:
|
|
357
|
+
"""Extract docstring (truncated to 500 chars)."""
|
|
358
|
+
ds = ast.get_docstring(node) or "" # type: ignore[arg-type]
|
|
359
|
+
if len(ds) > 500:
|
|
360
|
+
ds = ds[:497] + "..."
|
|
361
|
+
return ds
|