compass-code 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.
- compass_cli/__init__.py +0 -0
- compass_cli/commands/__init__.py +0 -0
- compass_cli/commands/ingest.py +310 -0
- compass_cli/commands/mcp.py +25 -0
- compass_cli/commands/query.py +181 -0
- compass_cli/commands/serve.py +123 -0
- compass_cli/commands/setup.py +171 -0
- compass_cli/display.py +231 -0
- compass_cli/errors.py +30 -0
- compass_cli/main.py +54 -0
- compass_cli/progress.py +191 -0
- compass_cli/result_views.py +389 -0
- compass_cli/types.py +41 -0
- compass_code-0.1.0.dist-info/METADATA +72 -0
- compass_code-0.1.0.dist-info/RECORD +137 -0
- compass_code-0.1.0.dist-info/WHEEL +4 -0
- compass_code-0.1.0.dist-info/entry_points.txt +2 -0
- compass_core/__init__.py +1 -0
- compass_core/contract/__init__.py +6 -0
- compass_core/contract/errors.py +116 -0
- compass_core/contract/models.py +204 -0
- compass_core/contract/params.py +99 -0
- compass_core/contract/results.py +370 -0
- compass_core/contract/serialize.py +59 -0
- compass_core/contract/types.py +76 -0
- compass_core/errors.py +148 -0
- compass_core/graph/__init__.py +0 -0
- compass_core/graph/db.py +205 -0
- compass_core/graph/schema.py +165 -0
- compass_core/graph/stored_ids.py +133 -0
- compass_core/graph/writer.py +482 -0
- compass_core/index/__init__.py +1 -0
- compass_core/index/freshness.py +55 -0
- compass_core/index/locate.py +155 -0
- compass_core/index/metadata.py +107 -0
- compass_core/ir/__init__.py +0 -0
- compass_core/ir/graph.py +65 -0
- compass_core/ir/models.py +75 -0
- compass_core/languages/__init__.py +1 -0
- compass_core/languages/base.py +26 -0
- compass_core/languages/python/__init__.py +6 -0
- compass_core/languages/python/extractor.py +670 -0
- compass_core/languages/python/import_resolver.py +107 -0
- compass_core/languages/registry.py +89 -0
- compass_core/logger.py +42 -0
- compass_core/phases/__init__.py +0 -0
- compass_core/phases/base.py +58 -0
- compass_core/phases/discovery.py +167 -0
- compass_core/phases/parse.py +80 -0
- compass_core/phases/persistence.py +33 -0
- compass_core/phases/pipeline.py +152 -0
- compass_core/phases/resolution.py +572 -0
- compass_core/phases/symbol_table.py +36 -0
- compass_core/phases/validation.py +76 -0
- compass_core/progress.py +45 -0
- compass_core/py.typed +0 -0
- compass_core/query/__init__.py +1 -0
- compass_core/query/cypher.py +92 -0
- compass_core/query/detail.py +241 -0
- compass_core/query/graph_map.py +127 -0
- compass_core/query/impact.py +273 -0
- compass_core/query/impact_walk.py +352 -0
- compass_core/query/overview.py +221 -0
- compass_core/query/references.py +235 -0
- compass_core/query/rows.py +378 -0
- compass_core/query/search.py +183 -0
- compass_core/query/status.py +49 -0
- compass_core/query/walk_queries.py +202 -0
- compass_core/render.py +408 -0
- compass_core/settings.py +61 -0
- compass_core/shared_types.py +78 -0
- compass_mcp/__init__.py +0 -0
- compass_mcp/guide.py +45 -0
- compass_mcp/py.typed +0 -0
- compass_mcp/server.py +261 -0
- compass_server/__init__.py +5 -0
- compass_server/errors.py +84 -0
- compass_server/main.py +42 -0
- compass_server/py.typed +0 -0
- compass_server/routers/__init__.py +0 -0
- compass_server/routers/query.py +182 -0
- compass_server/settings.py +37 -0
- compass_server/static/404.html +1 -0
- compass_server/static/_next/static/chunks/1629039a.eed2a06658ec4f9c.js +1 -0
- compass_server/static/_next/static/chunks/1ff282bf-bb7c483245264fc9.js +1 -0
- compass_server/static/_next/static/chunks/223.f881a22f446259bf.js +508 -0
- compass_server/static/_next/static/chunks/51fa5c19.6396973222c9d6fd.js +485 -0
- compass_server/static/_next/static/chunks/556.443c1682b6b20128.js +1 -0
- compass_server/static/_next/static/chunks/684-1aa0170f59baf77e.js +1 -0
- compass_server/static/_next/static/chunks/92e71660.3a42d4f70d017951.js +342 -0
- compass_server/static/_next/static/chunks/937-8692224bb6d1b605.js +1 -0
- compass_server/static/_next/static/chunks/app/_not-found/page-2c96ddf295eb6a86.js +1 -0
- compass_server/static/_next/static/chunks/app/layout-4bd0ba4a7d18aff9.js +1 -0
- compass_server/static/_next/static/chunks/app/page-5376a0a98bbd1faa.js +1 -0
- compass_server/static/_next/static/chunks/framework-96dcd958cfe08bd4.js +1 -0
- compass_server/static/_next/static/chunks/main-6dee8e626b16731a.js +1 -0
- compass_server/static/_next/static/chunks/main-app-460e8bc670818718.js +1 -0
- compass_server/static/_next/static/chunks/pages/_app-638a3e7363f92b80.js +1 -0
- compass_server/static/_next/static/chunks/pages/_error-6a72dbbb45cae743.js +1 -0
- compass_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- compass_server/static/_next/static/chunks/webpack-597057bf48ce4111.js +1 -0
- compass_server/static/_next/static/css/1b0fbfd8dd653f37.css +3 -0
- compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_buildManifest.js +1 -0
- compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_ssgManifest.js +1 -0
- compass_server/static/_next/static/media/0086f8992871c45b-s.woff2 +0 -0
- compass_server/static/_next/static/media/034d78ad42e9620c-s.woff2 +0 -0
- compass_server/static/_next/static/media/03b436aa846269de-s.woff2 +0 -0
- compass_server/static/_next/static/media/0484562807a97172-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/08aedeceaf1dcd57-s.woff2 +0 -0
- compass_server/static/_next/static/media/0aa834ed78bf6d07-s.woff2 +0 -0
- compass_server/static/_next/static/media/29a4aea02fdee119-s.woff2 +0 -0
- compass_server/static/_next/static/media/29e7bbdce9332268-s.woff2 +0 -0
- compass_server/static/_next/static/media/36008747766f78c6-s.woff2 +0 -0
- compass_server/static/_next/static/media/39969fcf98a3026e-s.woff2 +0 -0
- compass_server/static/_next/static/media/4de1fea1a954a5b6-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/5c0c2bcbaa4149ca-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/67957d42bae0796d-s.woff2 +0 -0
- compass_server/static/_next/static/media/6d38f9d4e0f4772b-s.woff2 +0 -0
- compass_server/static/_next/static/media/6d664cce900333ee-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/886030b0b59bc5a7-s.woff2 +0 -0
- compass_server/static/_next/static/media/8888a3826f4a3af4-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/939c4f875ee75fbb-s.woff2 +0 -0
- compass_server/static/_next/static/media/a1386beebedccca4-s.woff2 +0 -0
- compass_server/static/_next/static/media/ab00a911ac2adf48-s.woff2 +0 -0
- compass_server/static/_next/static/media/b957ea75a84b6ea7-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/bb3ef058b751a6ad-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/c3bc380753a8436c-s.woff2 +0 -0
- compass_server/static/_next/static/media/cce080f35d014443-s.woff2 +0 -0
- compass_server/static/_next/static/media/db911767852bc875-s.woff2 +0 -0
- compass_server/static/_next/static/media/eafabf029ad39a43-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/f10b8e9d91f3edcb-s.woff2 +0 -0
- compass_server/static/_next/static/media/f911b923c6adde36-s.woff2 +0 -0
- compass_server/static/_next/static/media/fe0777f1195381cb-s.woff2 +0 -0
- compass_server/static/index.html +1 -0
- compass_server/static/index.txt +25 -0
- compass_server/web_bundle.py +27 -0
- compass_server/web_limits.py +48 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""tree-sitter-python -> IR. Pure, side-effect-free: no DB calls, no module-
|
|
2
|
+
level parser state -- a fresh Language + Parser is created on every call so
|
|
3
|
+
this class is safe to use inside ProcessPoolExecutor worker processes
|
|
4
|
+
(tree-sitter Parser/Language objects don't pickle cleanly).
|
|
5
|
+
|
|
6
|
+
Moved from compass_core.parser.extractor (Task 5). The tree-sitter
|
|
7
|
+
node-walking helpers below (`_extract_signature` through `_unwrap_decorated`,
|
|
8
|
+
plus `_make_parser`, `_extract_imports`) move verbatim/near-verbatim -- their
|
|
9
|
+
internal logic (tree-sitter node type strings, byte-offset slicing) is
|
|
10
|
+
unchanged. The outer assembly (`_extract_classes`, `_extract_top_level_functions`
|
|
11
|
+
and the removed `_extract_class`/`_extract_function`/`_extract_methods`) is
|
|
12
|
+
adapted to build `compass_core.ir.models` types directly instead of a nested
|
|
13
|
+
`parser.models.ParsedClass`/`ParsedFunction` tree, with `Definition.id`
|
|
14
|
+
namespaced `py::{module_name}::{name}` (class/function) or
|
|
15
|
+
`py::{module_name}::{parent}.{name}` (method) at construction time.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import hashlib
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import tree_sitter_python as tspython
|
|
25
|
+
from tree_sitter import Language, Node, Parser
|
|
26
|
+
|
|
27
|
+
from compass_core.ir.models import CallSite, Definition, ImportStatement, ParsedFile
|
|
28
|
+
from compass_core.shared_types import Language as SourceLanguage
|
|
29
|
+
from compass_core.shared_types import SymbolKind
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PythonExtractor:
|
|
33
|
+
language_id = SourceLanguage.PYTHON
|
|
34
|
+
file_extensions = {".py"}
|
|
35
|
+
|
|
36
|
+
def parse(self, source: bytes, file_path: Path) -> ParsedFile:
|
|
37
|
+
content_hash = hashlib.sha256(source).hexdigest()
|
|
38
|
+
module_name = _derive_module_name(file_path)
|
|
39
|
+
|
|
40
|
+
_, parser = _make_parser()
|
|
41
|
+
tree = parser.parse(source)
|
|
42
|
+
root = tree.root_node
|
|
43
|
+
|
|
44
|
+
imports = _extract_imports(source, root)
|
|
45
|
+
definitions: list[Definition] = []
|
|
46
|
+
call_sites: list[CallSite] = []
|
|
47
|
+
_extract_classes(source, root, module_name, str(file_path), definitions, call_sites)
|
|
48
|
+
_extract_top_level_functions(
|
|
49
|
+
source, root, module_name, str(file_path), definitions, call_sites
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return ParsedFile(
|
|
53
|
+
path=str(file_path),
|
|
54
|
+
language=self.language_id,
|
|
55
|
+
module_name=module_name,
|
|
56
|
+
content_hash=content_hash,
|
|
57
|
+
definitions=definitions,
|
|
58
|
+
imports=imports,
|
|
59
|
+
call_sites=call_sites,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Internal helpers
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _make_parser() -> tuple[Language, Parser]:
|
|
69
|
+
"""Create and return a fresh Language + Parser pair."""
|
|
70
|
+
lang = Language(tspython.language())
|
|
71
|
+
parser = Parser(lang)
|
|
72
|
+
return lang, parser
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _derive_module_name(path: Path) -> str:
|
|
76
|
+
"""Convert a file path to a dotted Python module name.
|
|
77
|
+
|
|
78
|
+
By calling convention, ``file_path`` passed into
|
|
79
|
+
``LanguageExtractor.parse`` (and therefore into this helper) is already
|
|
80
|
+
relative to the repository root -- relativizing an absolute path is
|
|
81
|
+
Phase 1/parse's responsibility (a later task), not this extractor's.
|
|
82
|
+
Given a root-relative path, every path component becomes a dotted
|
|
83
|
+
segment, with a trailing ``__init__`` component stripped so packages are
|
|
84
|
+
referenced by their directory name.
|
|
85
|
+
|
|
86
|
+
A leading ``src`` segment is also stripped: under the standard Python
|
|
87
|
+
src-layout (``packages/<pkg>/src/<import_name>/...``), imports are
|
|
88
|
+
written relative to ``src/``, not the repo root, so keeping ``src`` in
|
|
89
|
+
the dotted name would make every intra-repo import fail to resolve
|
|
90
|
+
against this module name (see PythonImportResolver._resolve_module).
|
|
91
|
+
Only the first ``src`` segment is stripped -- everything before it
|
|
92
|
+
(e.g. ``packages/core``) is dropped too, since it plays no part in the
|
|
93
|
+
actual import path.
|
|
94
|
+
"""
|
|
95
|
+
parts = list(path.with_suffix("").parts)
|
|
96
|
+
if "src" in parts:
|
|
97
|
+
parts = parts[parts.index("src") + 1 :]
|
|
98
|
+
if parts and parts[-1] == "__init__":
|
|
99
|
+
parts = parts[:-1]
|
|
100
|
+
return ".".join(parts) if parts else path.stem
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Import extraction
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _extract_imports(source_bytes: bytes, root_node: Node) -> list[ImportStatement]:
|
|
109
|
+
"""Extract all import statements from the module-level AST node.
|
|
110
|
+
|
|
111
|
+
Handles both `import X` and `from X import Y` forms, including aliases
|
|
112
|
+
and wildcard imports.
|
|
113
|
+
"""
|
|
114
|
+
imports: list[ImportStatement] = []
|
|
115
|
+
for node in root_node.children:
|
|
116
|
+
if node.type == "import_statement":
|
|
117
|
+
imports.extend(_parse_import_statement(node, source_bytes))
|
|
118
|
+
elif node.type == "import_from_statement":
|
|
119
|
+
imports.extend(_parse_import_from_statement(node, source_bytes))
|
|
120
|
+
return imports
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_import_statement(node: Node, source_bytes: bytes) -> list[ImportStatement]:
|
|
124
|
+
"""Parse `import X` and `import X as Y` nodes."""
|
|
125
|
+
result: list[ImportStatement] = []
|
|
126
|
+
line = node.start_point[0] + 1
|
|
127
|
+
|
|
128
|
+
for child in node.named_children:
|
|
129
|
+
if child.type == "dotted_name":
|
|
130
|
+
module = _node_text(child, source_bytes)
|
|
131
|
+
result.append(
|
|
132
|
+
ImportStatement(
|
|
133
|
+
module=module,
|
|
134
|
+
names=[],
|
|
135
|
+
alias=None,
|
|
136
|
+
is_relative=module.startswith("."),
|
|
137
|
+
is_from=False,
|
|
138
|
+
line=line,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
elif child.type == "aliased_import":
|
|
142
|
+
# aliased_import: dotted_name "as" identifier
|
|
143
|
+
name_node = child.child_by_field_name("name")
|
|
144
|
+
alias_node = child.child_by_field_name("alias")
|
|
145
|
+
module = _node_text(name_node, source_bytes) if name_node else ""
|
|
146
|
+
result.append(
|
|
147
|
+
ImportStatement(
|
|
148
|
+
module=module,
|
|
149
|
+
names=[],
|
|
150
|
+
alias=_node_text(alias_node, source_bytes) if alias_node else None,
|
|
151
|
+
is_relative=module.startswith("."),
|
|
152
|
+
is_from=False,
|
|
153
|
+
line=line,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_import_from_statement(node: Node, source_bytes: bytes) -> list[ImportStatement]:
|
|
160
|
+
"""Parse `from X import Y` nodes including wildcards and aliases.
|
|
161
|
+
|
|
162
|
+
`ImportStatement` carries one `alias` field, so a statement renaming
|
|
163
|
+
several names at once (`from x import a as b, c as d`) can't be
|
|
164
|
+
represented as a single entry without losing all but the last alias.
|
|
165
|
+
Each aliased name is therefore split into its own `ImportStatement`;
|
|
166
|
+
plain (non-aliased) names stay grouped together in one, matching the
|
|
167
|
+
pre-existing shape for the common `from x import a, b` case.
|
|
168
|
+
"""
|
|
169
|
+
line = node.start_point[0] + 1
|
|
170
|
+
module = ""
|
|
171
|
+
|
|
172
|
+
children = node.named_children
|
|
173
|
+
if not children:
|
|
174
|
+
return [
|
|
175
|
+
ImportStatement(
|
|
176
|
+
module=module, names=[], alias=None, is_relative=False, is_from=True, line=line
|
|
177
|
+
)
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
# First named child is the module (dotted_name or relative_import)
|
|
181
|
+
first = children[0]
|
|
182
|
+
if first.type in ("dotted_name", "relative_import"):
|
|
183
|
+
module = _node_text(first, source_bytes)
|
|
184
|
+
is_relative = module.startswith(".") or first.type == "relative_import"
|
|
185
|
+
|
|
186
|
+
# Remaining children are the imported names
|
|
187
|
+
plain_names: list[str] = []
|
|
188
|
+
aliased: list[tuple[str, str]] = []
|
|
189
|
+
for child in children[1:]:
|
|
190
|
+
if child.type == "wildcard_import":
|
|
191
|
+
plain_names = ["*"]
|
|
192
|
+
elif child.type == "dotted_name":
|
|
193
|
+
plain_names.append(_node_text(child, source_bytes))
|
|
194
|
+
elif child.type == "aliased_import":
|
|
195
|
+
name_node = child.child_by_field_name("name")
|
|
196
|
+
alias_node = child.child_by_field_name("alias")
|
|
197
|
+
if name_node and alias_node:
|
|
198
|
+
aliased.append(
|
|
199
|
+
(_node_text(name_node, source_bytes), _node_text(alias_node, source_bytes))
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
statements: list[ImportStatement] = []
|
|
203
|
+
if plain_names:
|
|
204
|
+
statements.append(
|
|
205
|
+
ImportStatement(
|
|
206
|
+
module=module,
|
|
207
|
+
names=plain_names,
|
|
208
|
+
alias=None,
|
|
209
|
+
is_relative=is_relative,
|
|
210
|
+
is_from=True,
|
|
211
|
+
line=line,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
for name, alias in aliased:
|
|
215
|
+
statements.append(
|
|
216
|
+
ImportStatement(
|
|
217
|
+
module=module,
|
|
218
|
+
names=[name],
|
|
219
|
+
alias=alias,
|
|
220
|
+
is_relative=is_relative,
|
|
221
|
+
is_from=True,
|
|
222
|
+
line=line,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
if not statements:
|
|
226
|
+
statements.append(
|
|
227
|
+
ImportStatement(
|
|
228
|
+
module=module,
|
|
229
|
+
names=[],
|
|
230
|
+
alias=None,
|
|
231
|
+
is_relative=is_relative,
|
|
232
|
+
is_from=True,
|
|
233
|
+
line=line,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return statements
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Class extraction
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _extract_classes(
|
|
245
|
+
source_bytes: bytes,
|
|
246
|
+
root_node: Node,
|
|
247
|
+
module_name: str,
|
|
248
|
+
file_path: str,
|
|
249
|
+
out_definitions: list[Definition],
|
|
250
|
+
out_call_sites: list[CallSite],
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Extract all top-level class definitions from the module, appending
|
|
253
|
+
Definition(kind=CLASS) and Definition(kind=METHOD) entries directly into
|
|
254
|
+
out_definitions, and each method's raw-call CallSite entries into
|
|
255
|
+
out_call_sites."""
|
|
256
|
+
for node in root_node.children:
|
|
257
|
+
if node.type == "class_definition":
|
|
258
|
+
_extract_class(
|
|
259
|
+
node, source_bytes, module_name, file_path, out_definitions, out_call_sites
|
|
260
|
+
)
|
|
261
|
+
elif node.type == "decorated_definition":
|
|
262
|
+
inner = _unwrap_decorated(node)
|
|
263
|
+
if inner and inner.type == "class_definition":
|
|
264
|
+
_extract_class(
|
|
265
|
+
inner, source_bytes, module_name, file_path, out_definitions, out_call_sites
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _extract_class(
|
|
270
|
+
node: Node,
|
|
271
|
+
source_bytes: bytes,
|
|
272
|
+
module_name: str,
|
|
273
|
+
file_path: str,
|
|
274
|
+
out_definitions: list[Definition],
|
|
275
|
+
out_call_sites: list[CallSite],
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Build a Definition(kind=CLASS) from a class_definition AST node and
|
|
278
|
+
append it (and its methods) into out_definitions."""
|
|
279
|
+
name_node = node.child_by_field_name("name")
|
|
280
|
+
name = _node_text(name_node, source_bytes) if name_node else ""
|
|
281
|
+
class_id = f"py::{module_name}::{name}"
|
|
282
|
+
|
|
283
|
+
# Bases: argument_list child named "superclasses"
|
|
284
|
+
bases: list[str] = []
|
|
285
|
+
superclasses_node = node.child_by_field_name("superclasses")
|
|
286
|
+
if superclasses_node:
|
|
287
|
+
for child in superclasses_node.named_children:
|
|
288
|
+
if child.type in ("identifier", "attribute", "dotted_name"):
|
|
289
|
+
bases.append(_node_text(child, source_bytes))
|
|
290
|
+
|
|
291
|
+
body_node = node.child_by_field_name("body")
|
|
292
|
+
docstring = _extract_docstring(body_node, source_bytes) if body_node else None
|
|
293
|
+
|
|
294
|
+
# If wrapped in decorated_definition, source/line includes decorators
|
|
295
|
+
container = node.parent if node.parent and node.parent.type == "decorated_definition" else node
|
|
296
|
+
line_start = container.start_point[0] + 1
|
|
297
|
+
line_end = container.end_point[0] + 1
|
|
298
|
+
source = _slice_source(source_bytes, container.start_byte, container.end_byte)
|
|
299
|
+
|
|
300
|
+
out_definitions.append(
|
|
301
|
+
Definition(
|
|
302
|
+
id=class_id,
|
|
303
|
+
kind=SymbolKind.CLASS,
|
|
304
|
+
name=name,
|
|
305
|
+
file_path=file_path,
|
|
306
|
+
line_start=line_start,
|
|
307
|
+
line_end=line_end,
|
|
308
|
+
source=source,
|
|
309
|
+
docstring=docstring,
|
|
310
|
+
bases=bases,
|
|
311
|
+
parent_id=None,
|
|
312
|
+
signature=None,
|
|
313
|
+
decorators=[],
|
|
314
|
+
raw_calls=[],
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if body_node:
|
|
319
|
+
_extract_methods(
|
|
320
|
+
source_bytes,
|
|
321
|
+
body_node,
|
|
322
|
+
module_name,
|
|
323
|
+
file_path,
|
|
324
|
+
name,
|
|
325
|
+
class_id,
|
|
326
|
+
out_definitions,
|
|
327
|
+
out_call_sites,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _extract_methods(
|
|
332
|
+
source_bytes: bytes,
|
|
333
|
+
body_node: Node,
|
|
334
|
+
module_name: str,
|
|
335
|
+
file_path: str,
|
|
336
|
+
parent_class: str,
|
|
337
|
+
parent_id: str,
|
|
338
|
+
out_definitions: list[Definition],
|
|
339
|
+
out_call_sites: list[CallSite],
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Extract all method definitions from a class body node."""
|
|
342
|
+
seen_ids: dict[str, int] = {}
|
|
343
|
+
for child in body_node.children:
|
|
344
|
+
if child.type == "function_definition":
|
|
345
|
+
_extract_function(
|
|
346
|
+
child,
|
|
347
|
+
source_bytes,
|
|
348
|
+
module_name,
|
|
349
|
+
file_path,
|
|
350
|
+
out_definitions,
|
|
351
|
+
out_call_sites,
|
|
352
|
+
is_method=True,
|
|
353
|
+
parent_class=parent_class,
|
|
354
|
+
parent_id=parent_id,
|
|
355
|
+
seen_ids=seen_ids,
|
|
356
|
+
)
|
|
357
|
+
elif child.type == "decorated_definition":
|
|
358
|
+
inner = _unwrap_decorated(child)
|
|
359
|
+
if inner and inner.type == "function_definition":
|
|
360
|
+
_extract_function(
|
|
361
|
+
inner,
|
|
362
|
+
source_bytes,
|
|
363
|
+
module_name,
|
|
364
|
+
file_path,
|
|
365
|
+
out_definitions,
|
|
366
|
+
out_call_sites,
|
|
367
|
+
is_method=True,
|
|
368
|
+
parent_class=parent_class,
|
|
369
|
+
parent_id=parent_id,
|
|
370
|
+
seen_ids=seen_ids,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# Function extraction
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _extract_top_level_functions(
|
|
380
|
+
source_bytes: bytes,
|
|
381
|
+
root_node: Node,
|
|
382
|
+
module_name: str,
|
|
383
|
+
file_path: str,
|
|
384
|
+
out_definitions: list[Definition],
|
|
385
|
+
out_call_sites: list[CallSite],
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Extract all top-level (non-method) function definitions."""
|
|
388
|
+
seen_ids: dict[str, int] = {}
|
|
389
|
+
for node in root_node.children:
|
|
390
|
+
if node.type == "function_definition":
|
|
391
|
+
_extract_function(
|
|
392
|
+
node,
|
|
393
|
+
source_bytes,
|
|
394
|
+
module_name,
|
|
395
|
+
file_path,
|
|
396
|
+
out_definitions,
|
|
397
|
+
out_call_sites,
|
|
398
|
+
is_method=False,
|
|
399
|
+
parent_class=None,
|
|
400
|
+
parent_id=None,
|
|
401
|
+
seen_ids=seen_ids,
|
|
402
|
+
)
|
|
403
|
+
elif node.type == "decorated_definition":
|
|
404
|
+
inner = _unwrap_decorated(node)
|
|
405
|
+
if inner and inner.type == "function_definition":
|
|
406
|
+
_extract_function(
|
|
407
|
+
inner,
|
|
408
|
+
source_bytes,
|
|
409
|
+
module_name,
|
|
410
|
+
file_path,
|
|
411
|
+
out_definitions,
|
|
412
|
+
out_call_sites,
|
|
413
|
+
is_method=False,
|
|
414
|
+
seen_ids=seen_ids,
|
|
415
|
+
parent_class=None,
|
|
416
|
+
parent_id=None,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _extract_function(
|
|
421
|
+
node: Node,
|
|
422
|
+
source_bytes: bytes,
|
|
423
|
+
module_name: str,
|
|
424
|
+
file_path: str,
|
|
425
|
+
out_definitions: list[Definition],
|
|
426
|
+
out_call_sites: list[CallSite],
|
|
427
|
+
is_method: bool = False,
|
|
428
|
+
parent_class: str | None = None,
|
|
429
|
+
parent_id: str | None = None,
|
|
430
|
+
seen_ids: dict[str, int] | None = None,
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Build a Definition(kind=FUNCTION|METHOD) from a function_definition AST
|
|
433
|
+
node, appending it into out_definitions and its raw calls as CallSite
|
|
434
|
+
entries into out_call_sites.
|
|
435
|
+
|
|
436
|
+
`seen_ids`, when given, tracks how many definitions have already claimed
|
|
437
|
+
each base id within the enclosing scope (a class body or a module's
|
|
438
|
+
top level). Two defs can legitimately share a name+scope — most commonly
|
|
439
|
+
a `@property` getter and its `@x.setter`/`@x.deleter` — and without
|
|
440
|
+
disambiguation the later one would silently collide with (and overwrite)
|
|
441
|
+
the earlier one's node in the symbol table and graph. On a collision the
|
|
442
|
+
id is suffixed with the decorator that caused it (`#setter`, `#deleter`)
|
|
443
|
+
when recognizable, else a `#2`, `#3`, ... counter.
|
|
444
|
+
"""
|
|
445
|
+
# If inside decorated_definition, use the wrapper for source/line bounds
|
|
446
|
+
container = node.parent if node.parent and node.parent.type == "decorated_definition" else node
|
|
447
|
+
decorators = _extract_decorators(container, source_bytes) if container is not node else []
|
|
448
|
+
|
|
449
|
+
name_node = node.child_by_field_name("name")
|
|
450
|
+
name = _node_text(name_node, source_bytes) if name_node else ""
|
|
451
|
+
|
|
452
|
+
if is_method and parent_class is not None:
|
|
453
|
+
base_id = f"py::{module_name}::{parent_class}.{name}"
|
|
454
|
+
else:
|
|
455
|
+
base_id = f"py::{module_name}::{name}"
|
|
456
|
+
|
|
457
|
+
def_id = base_id
|
|
458
|
+
if seen_ids is not None:
|
|
459
|
+
occurrence = seen_ids.get(base_id, 0)
|
|
460
|
+
if occurrence:
|
|
461
|
+
if any(d.endswith(".setter") for d in decorators):
|
|
462
|
+
def_id = f"{base_id}#setter"
|
|
463
|
+
elif any(d.endswith(".deleter") for d in decorators):
|
|
464
|
+
def_id = f"{base_id}#deleter"
|
|
465
|
+
else:
|
|
466
|
+
def_id = f"{base_id}#{occurrence + 1}"
|
|
467
|
+
seen_ids[base_id] = occurrence + 1
|
|
468
|
+
|
|
469
|
+
signature = _extract_signature(node, source_bytes)
|
|
470
|
+
|
|
471
|
+
body_node = node.child_by_field_name("body")
|
|
472
|
+
docstring = _extract_docstring(body_node, source_bytes) if body_node else None
|
|
473
|
+
raw_calls = _extract_raw_calls(body_node, source_bytes) if body_node else []
|
|
474
|
+
|
|
475
|
+
line_start = container.start_point[0] + 1
|
|
476
|
+
line_end = container.end_point[0] + 1
|
|
477
|
+
source = _slice_source(source_bytes, container.start_byte, container.end_byte)
|
|
478
|
+
|
|
479
|
+
out_definitions.append(
|
|
480
|
+
Definition(
|
|
481
|
+
id=def_id,
|
|
482
|
+
kind=SymbolKind.METHOD if is_method else SymbolKind.FUNCTION,
|
|
483
|
+
name=name,
|
|
484
|
+
file_path=file_path,
|
|
485
|
+
line_start=line_start,
|
|
486
|
+
line_end=line_end,
|
|
487
|
+
source=source,
|
|
488
|
+
docstring=docstring,
|
|
489
|
+
bases=[],
|
|
490
|
+
parent_id=parent_id,
|
|
491
|
+
signature=signature,
|
|
492
|
+
decorators=decorators,
|
|
493
|
+
raw_calls=raw_calls,
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
for call in raw_calls:
|
|
498
|
+
out_call_sites.append(
|
|
499
|
+
CallSite(
|
|
500
|
+
callee_name=call,
|
|
501
|
+
receiver_expr=None,
|
|
502
|
+
line=line_start,
|
|
503
|
+
enclosing_definition_id=def_id,
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _extract_signature(node: Node, source_bytes: bytes) -> str:
|
|
509
|
+
"""Extract the def-line signature up to (not including) the body block.
|
|
510
|
+
|
|
511
|
+
Handles both single-line and multi-line signatures. Includes the return
|
|
512
|
+
type annotation if present.
|
|
513
|
+
"""
|
|
514
|
+
body_node = node.child_by_field_name("body")
|
|
515
|
+
if body_node:
|
|
516
|
+
# Signature is everything from def to the byte before the body block
|
|
517
|
+
sig_bytes = source_bytes[node.start_byte : body_node.start_byte]
|
|
518
|
+
# Strip trailing whitespace/colon/newlines to get a clean def line
|
|
519
|
+
sig = sig_bytes.decode("utf-8", errors="replace").rstrip().rstrip(":")
|
|
520
|
+
return sig + ":"
|
|
521
|
+
# Fallback: first line of the node
|
|
522
|
+
full = source_bytes[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
523
|
+
return full.splitlines()[0]
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ---------------------------------------------------------------------------
|
|
527
|
+
# Docstring extraction
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _extract_docstring(body_node: Node | None, source_bytes: bytes) -> str | None:
|
|
532
|
+
"""Return the docstring of a function or class body, or None.
|
|
533
|
+
|
|
534
|
+
The docstring is the first statement when it is a bare string literal.
|
|
535
|
+
"""
|
|
536
|
+
if body_node is None:
|
|
537
|
+
return None
|
|
538
|
+
for child in body_node.children:
|
|
539
|
+
if child.type == "expression_statement":
|
|
540
|
+
for grandchild in child.children:
|
|
541
|
+
if grandchild.type == "string":
|
|
542
|
+
raw = _node_text(grandchild, source_bytes)
|
|
543
|
+
return _strip_string_literal(raw)
|
|
544
|
+
break # only the first statement counts
|
|
545
|
+
elif child.type not in ("comment", "\n"):
|
|
546
|
+
break
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _strip_string_literal(raw: str) -> str:
|
|
551
|
+
"""Strip quotes from a Python string literal using ast.literal_eval."""
|
|
552
|
+
try:
|
|
553
|
+
value = ast.literal_eval(raw)
|
|
554
|
+
if isinstance(value, str):
|
|
555
|
+
return value
|
|
556
|
+
except (ValueError, SyntaxError):
|
|
557
|
+
pass
|
|
558
|
+
# Fallback: strip common quote styles
|
|
559
|
+
for prefix in ('"""', "'''", '"', "'"):
|
|
560
|
+
if raw.startswith(prefix) and raw.endswith(prefix) and len(raw) >= 2 * len(prefix):
|
|
561
|
+
return raw[len(prefix) : -len(prefix)]
|
|
562
|
+
return raw
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
# Call extraction
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _extract_raw_calls(body_node: Node, source_bytes: bytes) -> list[str]:
|
|
571
|
+
"""Collect all call expression strings from a function body.
|
|
572
|
+
|
|
573
|
+
Walks the AST recursively. Does not descend into nested function or
|
|
574
|
+
class definitions to avoid polluting the caller's call list.
|
|
575
|
+
"""
|
|
576
|
+
calls: list[str] = []
|
|
577
|
+
_walk_calls(body_node, source_bytes, calls)
|
|
578
|
+
return list(dict.fromkeys(calls)) # deduplicate while preserving order
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _walk_calls(node: Node, source_bytes: bytes, calls: list[str]) -> None:
|
|
582
|
+
"""Recursively visit nodes, collecting call strings."""
|
|
583
|
+
for child in node.children:
|
|
584
|
+
if child.type in ("function_definition", "class_definition"):
|
|
585
|
+
# Don't cross into nested definitions
|
|
586
|
+
continue
|
|
587
|
+
if child.type == "call":
|
|
588
|
+
fn_node = child.child_by_field_name("function")
|
|
589
|
+
if fn_node:
|
|
590
|
+
call_str = _call_to_string(fn_node, source_bytes)
|
|
591
|
+
if call_str:
|
|
592
|
+
calls.append(call_str)
|
|
593
|
+
# Still descend into arguments (calls within calls)
|
|
594
|
+
args = child.child_by_field_name("arguments")
|
|
595
|
+
if args:
|
|
596
|
+
_walk_calls(args, source_bytes, calls)
|
|
597
|
+
else:
|
|
598
|
+
_walk_calls(child, source_bytes, calls)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _call_to_string(fn_node: Node, source_bytes: bytes) -> str:
|
|
602
|
+
"""Convert a call's function field node to a dotted string.
|
|
603
|
+
|
|
604
|
+
e.g. `self.save` for `self.save()`, `foo` for `foo()`.
|
|
605
|
+
"""
|
|
606
|
+
if fn_node.type == "identifier":
|
|
607
|
+
return _node_text(fn_node, source_bytes)
|
|
608
|
+
if fn_node.type == "attribute":
|
|
609
|
+
obj_node = fn_node.child_by_field_name("object")
|
|
610
|
+
attr_node = fn_node.child_by_field_name("attribute")
|
|
611
|
+
if obj_node and attr_node:
|
|
612
|
+
obj = _call_to_string(obj_node, source_bytes)
|
|
613
|
+
attr = _node_text(attr_node, source_bytes)
|
|
614
|
+
return f"{obj}.{attr}"
|
|
615
|
+
# For other expressions (subscript, etc.) return the raw text
|
|
616
|
+
return _node_text(fn_node, source_bytes)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
# Decorator extraction
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _extract_decorators(node: Node, source_bytes: bytes) -> list[str]:
|
|
625
|
+
"""Extract decorator names from a decorated_definition node.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
node: A ``decorated_definition`` AST node.
|
|
629
|
+
source_bytes: Raw file bytes for text slicing.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
List of decorator name strings without the leading ``@``.
|
|
633
|
+
"""
|
|
634
|
+
decorators: list[str] = []
|
|
635
|
+
if node.type != "decorated_definition":
|
|
636
|
+
return decorators
|
|
637
|
+
for child in node.children:
|
|
638
|
+
if child.type == "decorator":
|
|
639
|
+
# decorator children: "@" followed by the decorator expression
|
|
640
|
+
# Skip the "@" token (anonymous child) and decode the rest
|
|
641
|
+
deco_text = _node_text(child, source_bytes).lstrip("@").strip()
|
|
642
|
+
# Strip trailing () if it's a decorator call like @app.route("/")
|
|
643
|
+
deco_name = deco_text.split("(")[0].strip()
|
|
644
|
+
decorators.append(deco_name)
|
|
645
|
+
return decorators
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# ---------------------------------------------------------------------------
|
|
649
|
+
# Utility helpers
|
|
650
|
+
# ---------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _node_text(node: Node | None, source_bytes: bytes) -> str:
|
|
654
|
+
"""Decode the source text covered by a tree-sitter node."""
|
|
655
|
+
if node is None:
|
|
656
|
+
return ""
|
|
657
|
+
return source_bytes[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _slice_source(source_bytes: bytes, start: int, end: int) -> str:
|
|
661
|
+
"""Decode a byte slice from the source."""
|
|
662
|
+
return source_bytes[start:end].decode("utf-8", errors="replace")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _unwrap_decorated(node: Node) -> Node | None:
|
|
666
|
+
"""Return the inner function_definition or class_definition from a decorated_definition."""
|
|
667
|
+
for child in node.children:
|
|
668
|
+
if child.type in ("function_definition", "class_definition"):
|
|
669
|
+
return child
|
|
670
|
+
return None
|