sentinel-codegraph 0.3.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.
- codegraph/__init__.py +5 -0
- codegraph/__main__.py +6 -0
- codegraph/cli.py +475 -0
- codegraph/config.py +55 -0
- codegraph/graph_store.py +455 -0
- codegraph/models.py +87 -0
- codegraph/parser/__init__.py +27 -0
- codegraph/parser/base.py +114 -0
- codegraph/parser/lang_go.py +524 -0
- codegraph/parser/lang_python.py +458 -0
- codegraph/parser/lang_typescript.py +554 -0
- codegraph/parser/links.py +314 -0
- codegraph/parser/queries.py +114 -0
- codegraph/parser/raw_core.py +66 -0
- codegraph/parser/rows.py +191 -0
- codegraph/pipeline.py +370 -0
- codegraph/query.py +125 -0
- codegraph/tree.py +230 -0
- codegraph/walk.py +114 -0
- sentinel_codegraph-0.3.0.dist-info/METADATA +251 -0
- sentinel_codegraph-0.3.0.dist-info/RECORD +23 -0
- sentinel_codegraph-0.3.0.dist-info/WHEEL +4 -0
- sentinel_codegraph-0.3.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Cross-file linking: import map + call-edge join (pure).
|
|
2
|
+
|
|
3
|
+
Two-pass join for the Python pipeline:
|
|
4
|
+
|
|
5
|
+
1. Collect (per file, no global state): defs, imports with alias
|
|
6
|
+
originals, buffered bare-name call sites — see
|
|
7
|
+
:mod:`codegraph.parser.lang_python` and :mod:`codegraph.parser.rows`.
|
|
8
|
+
2. Link (here, global state): one :class:`ImportEntry` per bound name
|
|
9
|
+
(module specifier -> resolved ``rel_path``), then every call site
|
|
10
|
+
joins ``(file, name)`` against the definition registry. Anything
|
|
11
|
+
unresolvable yields no edge — never a dangling one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import posixpath
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
|
|
21
|
+
from codegraph.models import Edge, EdgeKind, new_id
|
|
22
|
+
from codegraph.parser.rows import FileRows
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class ImportEntry:
|
|
27
|
+
"""One bound name's import map row: specifier + resolved target file."""
|
|
28
|
+
|
|
29
|
+
module: str # raw module specifier, e.g. "pkg.utils" | ".sibling"
|
|
30
|
+
bound: str # name bound in the importing file, e.g. "h"
|
|
31
|
+
original: str # name in the defining module, e.g. "helper"
|
|
32
|
+
resolved_rel: str | None # indexed rel_path of the target, if any
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_import_index(file_set: Iterable[str]) -> dict[str, str]:
|
|
36
|
+
"""Build the frozen ``absolute dotted module -> rel_path`` index.
|
|
37
|
+
|
|
38
|
+
Pure path math over POSIX rel paths — no parsing, no I/O. Covers
|
|
39
|
+
``pkg/utils.py`` → ``pkg.utils`` plus the ``pkg/__init__.py`` →
|
|
40
|
+
``pkg`` package fallback. First registration wins.
|
|
41
|
+
|
|
42
|
+
Retained for the (currently unwired) TS/Go emitters and their unit
|
|
43
|
+
tests; the Python link phase below resolves modules with
|
|
44
|
+
root-prefix-tolerant suffix matching instead.
|
|
45
|
+
"""
|
|
46
|
+
index: dict[str, str] = {}
|
|
47
|
+
for rel in sorted(file_set):
|
|
48
|
+
# rel->rel overlay: TS/Go emitters probe relative candidates
|
|
49
|
+
# directly via ``import_index.get`` on the same mapping.
|
|
50
|
+
index.setdefault(rel, rel)
|
|
51
|
+
if not rel.endswith(".py"):
|
|
52
|
+
continue
|
|
53
|
+
stem: str = rel[: -len(".py")]
|
|
54
|
+
dotted: str = stem.replace("/", ".")
|
|
55
|
+
index.setdefault(dotted, rel)
|
|
56
|
+
if stem.endswith("/__init__"):
|
|
57
|
+
index.setdefault(dotted[: -len(".__init__")], rel)
|
|
58
|
+
elif stem == "__init__":
|
|
59
|
+
index.setdefault("", rel)
|
|
60
|
+
return index
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_TS_EXTENSIONS: tuple[str, ...] = (".ts", ".tsx", ".js", ".jsx")
|
|
64
|
+
"""Candidate extensions when resolving a relative TS/JS module."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_python_module(
|
|
68
|
+
module: str, importer_rel: str, indexed: set[str]
|
|
69
|
+
) -> str | None:
|
|
70
|
+
"""Resolve a Python module specifier to an indexed ``rel_path``.
|
|
71
|
+
|
|
72
|
+
Relative specifiers resolve against the importer's directory
|
|
73
|
+
(root-prefix agnostic by construction). Absolute specifiers match
|
|
74
|
+
the longest specifier suffix against indexed stems, tolerating a
|
|
75
|
+
root-relative prefix on the indexed side (``src.app.x`` satisfies
|
|
76
|
+
``import app.x``) — the ``src/app`` vs ``app`` skew.
|
|
77
|
+
"""
|
|
78
|
+
if module.startswith("."):
|
|
79
|
+
level: int = len(module) - len(module.lstrip("."))
|
|
80
|
+
rest: str = module.lstrip(".")
|
|
81
|
+
base = PurePosixPath(importer_rel).parent
|
|
82
|
+
for _ in range(level - 1):
|
|
83
|
+
base = base.parent
|
|
84
|
+
target: str = (
|
|
85
|
+
(base / PurePosixPath(*rest.split("."))).as_posix() if rest else base.as_posix()
|
|
86
|
+
)
|
|
87
|
+
for trial in (target + ".py", target + "/__init__.py"):
|
|
88
|
+
if trial in indexed:
|
|
89
|
+
return trial
|
|
90
|
+
return None
|
|
91
|
+
parts: list[str] = [p for p in module.split(".") if p]
|
|
92
|
+
if not parts:
|
|
93
|
+
return None
|
|
94
|
+
stems: dict[str, str] = {}
|
|
95
|
+
for path in indexed:
|
|
96
|
+
if not path.endswith(".py"):
|
|
97
|
+
continue
|
|
98
|
+
stems.setdefault(path[: -len(".py")].replace("/", "."), path)
|
|
99
|
+
for start in range(len(parts)):
|
|
100
|
+
suffix: str = ".".join(parts[start:])
|
|
101
|
+
init_suffix: str = suffix + ".__init__"
|
|
102
|
+
matches: list[str] = sorted(
|
|
103
|
+
path
|
|
104
|
+
for stem, path in stems.items()
|
|
105
|
+
if stem == suffix
|
|
106
|
+
or stem.endswith("." + suffix)
|
|
107
|
+
or stem == init_suffix
|
|
108
|
+
or stem.endswith("." + init_suffix)
|
|
109
|
+
)
|
|
110
|
+
if matches:
|
|
111
|
+
matches.sort(key=lambda p: (len(p), p))
|
|
112
|
+
return matches[0]
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_file_import_map(
|
|
117
|
+
rows: FileRows, indexed: set[str]
|
|
118
|
+
) -> dict[str, ImportEntry]:
|
|
119
|
+
"""Build one file's bound-name -> :class:`ImportEntry` map.
|
|
120
|
+
|
|
121
|
+
First registration wins per bound name. Star imports (``*``) and
|
|
122
|
+
Go dot imports (``.``) are recorded but never resolve a bare call
|
|
123
|
+
site directly — export tracking is out of scope (Go dot-imported
|
|
124
|
+
files are searched separately, see :func:`build_dot_import_targets`).
|
|
125
|
+
"""
|
|
126
|
+
file_map: dict[str, ImportEntry] = {}
|
|
127
|
+
for parsed_import in rows.import_details:
|
|
128
|
+
if parsed_import.name in file_map:
|
|
129
|
+
continue
|
|
130
|
+
if parsed_import.name in ("*", "."):
|
|
131
|
+
file_map.setdefault(
|
|
132
|
+
parsed_import.name,
|
|
133
|
+
ImportEntry(
|
|
134
|
+
module=parsed_import.module,
|
|
135
|
+
bound=parsed_import.name,
|
|
136
|
+
original=parsed_import.effective_original,
|
|
137
|
+
resolved_rel=None,
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
continue
|
|
141
|
+
resolved: str | None = _resolve_module(
|
|
142
|
+
parsed_import.module, rows.rel_path, rows.language, indexed
|
|
143
|
+
)
|
|
144
|
+
file_map.setdefault(
|
|
145
|
+
parsed_import.name,
|
|
146
|
+
ImportEntry(
|
|
147
|
+
module=parsed_import.module,
|
|
148
|
+
bound=parsed_import.name,
|
|
149
|
+
original=parsed_import.effective_original,
|
|
150
|
+
resolved_rel=resolved,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
return file_map
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_dot_import_targets(rows: FileRows, indexed: set[str]) -> list[str]:
|
|
157
|
+
"""Return resolved files this Go file dot-imports, sorted.
|
|
158
|
+
|
|
159
|
+
A dot import (``import . "pkg/utils"``) brings the package's
|
|
160
|
+
exported names into unqualified scope, so bare call sites fall
|
|
161
|
+
back to these files' definitions when no import-map entry matches.
|
|
162
|
+
Non-Go files and files without dot imports yield ``[]``.
|
|
163
|
+
"""
|
|
164
|
+
if rows.language != "go":
|
|
165
|
+
return []
|
|
166
|
+
targets: set[str] = set()
|
|
167
|
+
for parsed_import in rows.import_details:
|
|
168
|
+
if parsed_import.name != ".":
|
|
169
|
+
continue
|
|
170
|
+
resolved: str | None = _resolve_go_module(
|
|
171
|
+
parsed_import.module, rows.rel_path, indexed
|
|
172
|
+
)
|
|
173
|
+
if resolved is not None:
|
|
174
|
+
targets.add(resolved)
|
|
175
|
+
return sorted(targets)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _resolve_ts_module(module: str, importer_rel: str, indexed: set[str]) -> str | None:
|
|
179
|
+
"""Resolve a relative TS/JS module specifier to an indexed ``rel_path``.
|
|
180
|
+
|
|
181
|
+
Bare specifiers (npm packages) never resolve — they have no file.
|
|
182
|
+
"""
|
|
183
|
+
if not module.startswith("."):
|
|
184
|
+
return None
|
|
185
|
+
base: str = posixpath.normpath(
|
|
186
|
+
posixpath.join(posixpath.dirname(importer_rel), module)
|
|
187
|
+
)
|
|
188
|
+
candidates: list[str] = [base]
|
|
189
|
+
candidates.extend(base + ext for ext in _TS_EXTENSIONS)
|
|
190
|
+
candidates.extend(base + "/index" + ext for ext in _TS_EXTENSIONS)
|
|
191
|
+
for trial in candidates:
|
|
192
|
+
if trial in indexed:
|
|
193
|
+
return trial
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _resolve_go_module(module: str, importer_rel: str, indexed: set[str]) -> str | None:
|
|
198
|
+
"""Resolve a Go import path to an indexed ``rel_path``.
|
|
199
|
+
|
|
200
|
+
Relative paths resolve against the importer's directory; otherwise
|
|
201
|
+
the import tail (package base name) matches the indexed ``.go``
|
|
202
|
+
stem, shortest path first for determinism.
|
|
203
|
+
"""
|
|
204
|
+
if module.startswith("."):
|
|
205
|
+
base: str = posixpath.normpath(
|
|
206
|
+
posixpath.join(posixpath.dirname(importer_rel), module)
|
|
207
|
+
)
|
|
208
|
+
for trial in (base, base + ".go"):
|
|
209
|
+
if trial in indexed:
|
|
210
|
+
return trial
|
|
211
|
+
return None
|
|
212
|
+
tail: str = module.rsplit("/", 1)[-1]
|
|
213
|
+
if not tail:
|
|
214
|
+
return None
|
|
215
|
+
candidates: list[str] = sorted(
|
|
216
|
+
path for path in indexed if Path(path).stem == tail and path.endswith(".go")
|
|
217
|
+
)
|
|
218
|
+
if not candidates:
|
|
219
|
+
return None
|
|
220
|
+
candidates.sort(key=lambda p: (len(p), p))
|
|
221
|
+
return candidates[0]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _resolve_module(
|
|
225
|
+
module: str, importer_rel: str, language: str, indexed: set[str]
|
|
226
|
+
) -> str | None:
|
|
227
|
+
"""Resolve a module specifier to an indexed ``rel_path`` (or ``None``)."""
|
|
228
|
+
if language in ("typescript", "javascript"):
|
|
229
|
+
return _resolve_ts_module(module, importer_rel, indexed)
|
|
230
|
+
if language == "go":
|
|
231
|
+
return _resolve_go_module(module, importer_rel, indexed)
|
|
232
|
+
return _resolve_python_module(module, importer_rel, indexed)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def resolve_call_edges(files: list[FileRows], root: str) -> list[Edge]:
|
|
236
|
+
"""Resolve every file's buffered call sites into ``calls`` edges.
|
|
237
|
+
|
|
238
|
+
Resolution order per site: same-file definition first, then the
|
|
239
|
+
callee's import-map entry joined against the resolved file's
|
|
240
|
+
definitions on a single candidate name — the *original*
|
|
241
|
+
(defining-module) name when the import carries one
|
|
242
|
+
(``from utils import helper as h`` → ``helper``), else the bound
|
|
243
|
+
name (plain and default imports). Go files additionally fall back
|
|
244
|
+
to their dot-imported files' definitions. The join key is the
|
|
245
|
+
``(rel_path, name)`` registry pair — dotted module strings never
|
|
246
|
+
take part, so a root-relative prefix on indexed paths (``src/app``
|
|
247
|
+
vs ``import app``) cannot break the join.
|
|
248
|
+
|
|
249
|
+
A callee may be a function, method, class, interface, *or* type
|
|
250
|
+
node — class instantiation and ``new C()`` are calls. Anything
|
|
251
|
+
else (natives, stdlib / third-party / unindexed modules,
|
|
252
|
+
star-import calls, names missing from the target file) yields no
|
|
253
|
+
edge, never a dangling one. One edge per caller → callee pair,
|
|
254
|
+
stamped with the call-site line.
|
|
255
|
+
"""
|
|
256
|
+
indexed: set[str] = {rows.rel_path for rows in files}
|
|
257
|
+
def_ids: dict[tuple[str, str], str] = {}
|
|
258
|
+
for rows in files:
|
|
259
|
+
for name, node_id in rows.definitions.items():
|
|
260
|
+
def_ids.setdefault((rows.rel_path, name), node_id)
|
|
261
|
+
file_maps: dict[str, dict[str, ImportEntry]] = {
|
|
262
|
+
rows.rel_path: build_file_import_map(rows, indexed) for rows in files
|
|
263
|
+
}
|
|
264
|
+
dot_targets: dict[str, list[str]] = {
|
|
265
|
+
rows.rel_path: build_dot_import_targets(rows, indexed) for rows in files
|
|
266
|
+
}
|
|
267
|
+
seen: set[tuple[str, str]] = set()
|
|
268
|
+
edges: list[Edge] = []
|
|
269
|
+
for rows in files:
|
|
270
|
+
file_map: dict[str, ImportEntry] = file_maps[rows.rel_path]
|
|
271
|
+
dots: list[str] = dot_targets[rows.rel_path]
|
|
272
|
+
for call in rows.calls:
|
|
273
|
+
src_id: str | None = def_ids.get((rows.rel_path, call.caller))
|
|
274
|
+
if src_id is None:
|
|
275
|
+
continue
|
|
276
|
+
dst_id: str | None = def_ids.get((rows.rel_path, call.callee))
|
|
277
|
+
if dst_id is None:
|
|
278
|
+
entry: ImportEntry | None = file_map.get(call.callee)
|
|
279
|
+
if entry is not None and entry.resolved_rel is not None:
|
|
280
|
+
candidate: str = (
|
|
281
|
+
entry.original if entry.original else entry.bound
|
|
282
|
+
)
|
|
283
|
+
dst_id = def_ids.get((entry.resolved_rel, candidate))
|
|
284
|
+
if dst_id is None:
|
|
285
|
+
for target in dots:
|
|
286
|
+
dst_id = def_ids.get((target, call.callee))
|
|
287
|
+
if dst_id is not None:
|
|
288
|
+
break
|
|
289
|
+
if dst_id is None:
|
|
290
|
+
continue
|
|
291
|
+
if (src_id, dst_id) in seen:
|
|
292
|
+
continue
|
|
293
|
+
seen.add((src_id, dst_id))
|
|
294
|
+
edges.append(
|
|
295
|
+
Edge(
|
|
296
|
+
id=new_id(),
|
|
297
|
+
root=root,
|
|
298
|
+
src_id=src_id,
|
|
299
|
+
dst_id=dst_id,
|
|
300
|
+
kind=EdgeKind.CALLS,
|
|
301
|
+
target_module=None,
|
|
302
|
+
site_line=call.site_line,
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
return edges
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
__all__ = [
|
|
309
|
+
"ImportEntry",
|
|
310
|
+
"build_dot_import_targets",
|
|
311
|
+
"build_file_import_map",
|
|
312
|
+
"build_import_index",
|
|
313
|
+
"resolve_call_edges",
|
|
314
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Bare-name call-site extraction via compiled tree-sitter queries.
|
|
2
|
+
|
|
3
|
+
The only query surface in the package: each query captures a bare
|
|
4
|
+
``(identifier)`` in call-function position. Attribute calls
|
|
5
|
+
(``obj.method()``, ``self.x()``, ``pkg.Fn()``) never match by
|
|
6
|
+
construction. The caller is the innermost enclosing named definition;
|
|
7
|
+
module-level call sites are dropped. All functions are pure given the
|
|
8
|
+
already-parsed tree.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from tree_sitter import Language, Node, Query, QueryCursor
|
|
14
|
+
from tree_sitter_language_pack import get_parser
|
|
15
|
+
|
|
16
|
+
from codegraph.parser.base import ParsedCall
|
|
17
|
+
from codegraph.parser.raw_core import field_text, node_text
|
|
18
|
+
|
|
19
|
+
_CALL_QUERIES: dict[str, str] = {
|
|
20
|
+
"python": "(call function: (identifier) @callee)",
|
|
21
|
+
"typescript": "(call_expression function: (identifier) @callee)",
|
|
22
|
+
"javascript": "(call_expression function: (identifier) @callee)",
|
|
23
|
+
"go": "(call_expression function: (identifier) @callee)",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_SCOPE_TYPES: dict[str, frozenset[str]] = {
|
|
27
|
+
"python": frozenset({"function_definition"}),
|
|
28
|
+
"typescript": frozenset(
|
|
29
|
+
{
|
|
30
|
+
"function_declaration",
|
|
31
|
+
"function_expression",
|
|
32
|
+
"arrow_function",
|
|
33
|
+
"method_definition",
|
|
34
|
+
}
|
|
35
|
+
),
|
|
36
|
+
"javascript": frozenset(
|
|
37
|
+
{
|
|
38
|
+
"function_declaration",
|
|
39
|
+
"function_expression",
|
|
40
|
+
"arrow_function",
|
|
41
|
+
"method_definition",
|
|
42
|
+
}
|
|
43
|
+
),
|
|
44
|
+
"go": frozenset({"function_declaration", "method_declaration"}),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _scope_name(scope: Node) -> str | None:
|
|
49
|
+
"""Return a scope node's declared name, if it has one."""
|
|
50
|
+
name: str = field_text(scope, "name")
|
|
51
|
+
if name:
|
|
52
|
+
return name
|
|
53
|
+
if scope.type in ("arrow_function", "function_expression"):
|
|
54
|
+
parent: Node | None = scope.parent
|
|
55
|
+
if parent is not None and parent.type == "variable_declarator":
|
|
56
|
+
bound: str = field_text(parent, "name")
|
|
57
|
+
if bound:
|
|
58
|
+
return bound
|
|
59
|
+
if parent is not None and parent.type == "assignment_expression":
|
|
60
|
+
target: Node | None = parent.child_by_field_name("left")
|
|
61
|
+
if target is not None:
|
|
62
|
+
text: str = node_text(target)
|
|
63
|
+
if text.isidentifier():
|
|
64
|
+
return text
|
|
65
|
+
if scope.type == "method_declaration":
|
|
66
|
+
# Go: name is a field_identifier, not identifier.
|
|
67
|
+
name_node: Node | None = scope.child_by_field_name("name")
|
|
68
|
+
if name_node is not None:
|
|
69
|
+
text = node_text(name_node)
|
|
70
|
+
if text:
|
|
71
|
+
return text
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _enclosing_caller(start: Node | None, scopes: frozenset[str]) -> str | None:
|
|
76
|
+
"""Walk up to the innermost enclosing named scope."""
|
|
77
|
+
probe: Node | None = start
|
|
78
|
+
while probe is not None:
|
|
79
|
+
if probe.type in scopes:
|
|
80
|
+
name: str | None = _scope_name(probe)
|
|
81
|
+
if name is not None:
|
|
82
|
+
return name
|
|
83
|
+
probe = probe.parent
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract_calls(language: str, root: Node, grammar: Language) -> list[ParsedCall]:
|
|
88
|
+
"""Extract bare-name call sites from an already-parsed ``root``."""
|
|
89
|
+
query_src: str | None = _CALL_QUERIES.get(language)
|
|
90
|
+
scopes: frozenset[str] | None = _SCOPE_TYPES.get(language)
|
|
91
|
+
if query_src is None or scopes is None:
|
|
92
|
+
return []
|
|
93
|
+
query = Query(grammar, query_src)
|
|
94
|
+
captures: dict[str, list[Node]] = QueryCursor(query).captures(root)
|
|
95
|
+
found: list[ParsedCall] = []
|
|
96
|
+
for node in captures.get("callee", []):
|
|
97
|
+
callee: str = node_text(node)
|
|
98
|
+
if not callee:
|
|
99
|
+
continue
|
|
100
|
+
caller: str | None = _enclosing_caller(node.parent, scopes)
|
|
101
|
+
if caller is None:
|
|
102
|
+
continue
|
|
103
|
+
found.append(ParsedCall(caller=caller, callee=callee))
|
|
104
|
+
return found
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def grammar_of(language: str) -> Language:
|
|
108
|
+
"""Return the compiled grammar for ``language`` (for query binding)."""
|
|
109
|
+
grammar: Language | None = get_parser(language).language
|
|
110
|
+
assert grammar is not None
|
|
111
|
+
return grammar
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["extract_calls", "grammar_of"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Raw tree-sitter surface for the v2 pipeline.
|
|
2
|
+
|
|
3
|
+
Thin wrapper over ``tree_sitter_language_pack.get_parser`` + ``parse``.
|
|
4
|
+
Parsing runs on the calling thread: ``Parser`` / ``Tree`` / ``Node``
|
|
5
|
+
are not thread-safe. Walk helpers are pure and iterative — no recursion,
|
|
6
|
+
no queries here (queries live in ``queries.py``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
from tree_sitter import Node, Parser, Tree
|
|
14
|
+
from tree_sitter_language_pack import get_parser
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse(language: str, source: bytes) -> Tree:
|
|
18
|
+
"""Parse ``source`` with the ``language`` grammar on this thread."""
|
|
19
|
+
parser: Parser = get_parser(language)
|
|
20
|
+
return parser.parse(source)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def node_text(node: Node) -> str:
|
|
24
|
+
"""Decode a node's source text (empty when the node has none)."""
|
|
25
|
+
raw: bytes | None = node.text
|
|
26
|
+
if raw is None:
|
|
27
|
+
return ""
|
|
28
|
+
return raw.decode("utf-8", errors="ignore")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def field_text(node: Node, field: str) -> str:
|
|
32
|
+
"""Decode a named field's text (empty when absent)."""
|
|
33
|
+
child: Node | None = node.child_by_field_name(field)
|
|
34
|
+
if child is None:
|
|
35
|
+
return ""
|
|
36
|
+
return node_text(child)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def span(node: Node) -> tuple[int, int]:
|
|
40
|
+
"""Return a node's 1-based ``(start_line, end_line)`` span."""
|
|
41
|
+
start: int = node.start_point[0] + 1
|
|
42
|
+
end: int = node.end_point[0] + 1
|
|
43
|
+
return (start, max(end, start))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def has_error(root: Node) -> bool:
|
|
47
|
+
"""Return True when the tree contains an error node."""
|
|
48
|
+
stack: list[Node] = [root]
|
|
49
|
+
while stack:
|
|
50
|
+
node: Node = stack.pop()
|
|
51
|
+
if node.type == "ERROR" or node.is_missing:
|
|
52
|
+
return True
|
|
53
|
+
stack.extend(node.named_children)
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def walk(root: Node) -> Iterator[Node]:
|
|
58
|
+
"""Yield every named node in the tree, depth-first (iterative)."""
|
|
59
|
+
stack: list[Node] = [root]
|
|
60
|
+
while stack:
|
|
61
|
+
node: Node = stack.pop()
|
|
62
|
+
yield node
|
|
63
|
+
stack.extend(reversed(node.named_children))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["field_text", "has_error", "node_text", "parse", "span", "walk"]
|
codegraph/parser/rows.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Per-file row builders: source text -> storage rows (pure).
|
|
2
|
+
|
|
3
|
+
Collect phase output: nodes + ``contains`` / ``imports`` edges plus the
|
|
4
|
+
resolution maps (definitions, imports with alias originals, buffered
|
|
5
|
+
call sites). ``calls`` edges are never emitted here — they resolve
|
|
6
|
+
across files in :mod:`codegraph.parser.links`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from codegraph.models import Edge, EdgeKind, Node, NodeKind, new_id
|
|
16
|
+
from codegraph.parser.base import ParsedCall, ParsedFile, ParsedImport
|
|
17
|
+
from codegraph.parser.raw_core import parse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class FileRows:
|
|
22
|
+
"""Per-file build output: storage rows plus resolution maps."""
|
|
23
|
+
|
|
24
|
+
rel_path: str
|
|
25
|
+
language: str
|
|
26
|
+
nodes: list[Node] = field(default_factory=lambda: list[Node]())
|
|
27
|
+
edges: list[Edge] = field(default_factory=lambda: list[Edge]())
|
|
28
|
+
definitions: dict[str, str] = field(default_factory=lambda: dict[str, str]())
|
|
29
|
+
imports: dict[str, str] = field(default_factory=lambda: dict[str, str]())
|
|
30
|
+
import_details: list[ParsedImport] = field(
|
|
31
|
+
default_factory=lambda: list[ParsedImport]()
|
|
32
|
+
)
|
|
33
|
+
calls: list[ParsedCall] = field(default_factory=lambda: list[ParsedCall]())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _node_kind(kind: str) -> NodeKind:
|
|
37
|
+
if kind == "class":
|
|
38
|
+
return NodeKind.CLASS
|
|
39
|
+
if kind == "method":
|
|
40
|
+
return NodeKind.METHOD
|
|
41
|
+
if kind == "function":
|
|
42
|
+
return NodeKind.FUNCTION
|
|
43
|
+
return NodeKind.FUNCTION
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_file_rows(
|
|
47
|
+
root: str,
|
|
48
|
+
rel_path: str,
|
|
49
|
+
language: str,
|
|
50
|
+
parsed: ParsedFile,
|
|
51
|
+
) -> FileRows:
|
|
52
|
+
"""Convert one :class:`ParsedFile` into nodes + ``contains`` / ``imports``.
|
|
53
|
+
|
|
54
|
+
``calls`` edges are NOT emitted here — they resolve across files in
|
|
55
|
+
``links.resolve_call_edges``. The file node always exists, even for
|
|
56
|
+
empty files. Duplicate names in one file resolve to the first
|
|
57
|
+
registration.
|
|
58
|
+
"""
|
|
59
|
+
file_id: str = new_id()
|
|
60
|
+
file_node = Node(
|
|
61
|
+
id=file_id,
|
|
62
|
+
root=root,
|
|
63
|
+
file_path=rel_path,
|
|
64
|
+
kind=NodeKind.FILE,
|
|
65
|
+
name=Path(rel_path).name,
|
|
66
|
+
language=language,
|
|
67
|
+
start_line=1,
|
|
68
|
+
end_line=parsed.total_lines,
|
|
69
|
+
parent_id=None,
|
|
70
|
+
)
|
|
71
|
+
rows = FileRows(rel_path=rel_path, language=language)
|
|
72
|
+
rows.nodes.append(file_node)
|
|
73
|
+
|
|
74
|
+
def_ids: dict[str, str] = {}
|
|
75
|
+
for definition in parsed.definitions:
|
|
76
|
+
node_id: str = new_id()
|
|
77
|
+
parent_id: str = file_id
|
|
78
|
+
if definition.parent is not None and definition.parent in def_ids:
|
|
79
|
+
parent_id = def_ids[definition.parent]
|
|
80
|
+
kind = _node_kind(definition.kind)
|
|
81
|
+
rows.nodes.append(
|
|
82
|
+
Node(
|
|
83
|
+
id=node_id,
|
|
84
|
+
root=root,
|
|
85
|
+
file_path=rel_path,
|
|
86
|
+
kind=kind,
|
|
87
|
+
name=definition.name,
|
|
88
|
+
language=language,
|
|
89
|
+
start_line=definition.start_line,
|
|
90
|
+
end_line=definition.end_line,
|
|
91
|
+
parent_id=parent_id,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
rows.edges.append(
|
|
95
|
+
Edge(
|
|
96
|
+
id=new_id(),
|
|
97
|
+
root=root,
|
|
98
|
+
src_id=parent_id,
|
|
99
|
+
dst_id=node_id,
|
|
100
|
+
kind=EdgeKind.CONTAINS,
|
|
101
|
+
target_module=None,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
def_ids.setdefault(definition.name, node_id)
|
|
105
|
+
rows.definitions.setdefault(definition.name, node_id)
|
|
106
|
+
|
|
107
|
+
for imported in parsed.imports:
|
|
108
|
+
node_id = new_id()
|
|
109
|
+
rows.nodes.append(
|
|
110
|
+
Node(
|
|
111
|
+
id=node_id,
|
|
112
|
+
root=root,
|
|
113
|
+
file_path=rel_path,
|
|
114
|
+
kind=NodeKind.IMPORT,
|
|
115
|
+
name=imported.name,
|
|
116
|
+
language=language,
|
|
117
|
+
start_line=imported.start_line,
|
|
118
|
+
end_line=imported.end_line,
|
|
119
|
+
parent_id=file_id,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
rows.edges.append(
|
|
123
|
+
Edge(
|
|
124
|
+
id=new_id(),
|
|
125
|
+
root=root,
|
|
126
|
+
src_id=file_id,
|
|
127
|
+
dst_id=node_id,
|
|
128
|
+
kind=EdgeKind.IMPORTS,
|
|
129
|
+
target_module=imported.module,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
rows.imports.setdefault(imported.name, imported.module)
|
|
133
|
+
rows.import_details.append(imported)
|
|
134
|
+
|
|
135
|
+
rows.calls.extend(parsed.calls)
|
|
136
|
+
return rows
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_python_file_rows(
|
|
140
|
+
root: str,
|
|
141
|
+
rel_path: str,
|
|
142
|
+
source_text: str,
|
|
143
|
+
_import_index: Mapping[str, str],
|
|
144
|
+
) -> FileRows:
|
|
145
|
+
"""Build one Python file's rows via the collect phase.
|
|
146
|
+
|
|
147
|
+
Nodes + ``contains`` / ``imports`` edges come out of
|
|
148
|
+
:func:`lang_python.collect_python_file` directly; ``calls`` edges
|
|
149
|
+
are never emitted here — ``rows.calls`` carries the buffered call
|
|
150
|
+
sites for :func:`codegraph.parser.links.resolve_call_edges`.
|
|
151
|
+
``_import_index`` is accepted for builder-signature uniformity and
|
|
152
|
+
ignored: the link phase resolves modules from the full file set.
|
|
153
|
+
"""
|
|
154
|
+
from codegraph.parser import lang_python
|
|
155
|
+
|
|
156
|
+
total_lines: int = max(source_text.count("\n") + 1, 1)
|
|
157
|
+
rows = FileRows(rel_path=rel_path, language="python")
|
|
158
|
+
if not source_text.strip():
|
|
159
|
+
file_node = Node(
|
|
160
|
+
id=rel_path,
|
|
161
|
+
root=root,
|
|
162
|
+
file_path=rel_path,
|
|
163
|
+
kind=NodeKind.FILE,
|
|
164
|
+
name=Path(rel_path).name,
|
|
165
|
+
language="python",
|
|
166
|
+
start_line=1,
|
|
167
|
+
end_line=total_lines,
|
|
168
|
+
parent_id=None,
|
|
169
|
+
)
|
|
170
|
+
rows.nodes.append(file_node)
|
|
171
|
+
return rows
|
|
172
|
+
tree = parse("python", source_text.encode("utf-8"))
|
|
173
|
+
|
|
174
|
+
out = lang_python.collect_python_file(
|
|
175
|
+
rel_path, tree.root_node, root, total_lines
|
|
176
|
+
)
|
|
177
|
+
rows.nodes.extend(out.nodes.values())
|
|
178
|
+
rows.edges.extend(out.edges)
|
|
179
|
+
rows.definitions.update(out.definitions)
|
|
180
|
+
for parsed_import in out.imports:
|
|
181
|
+
rows.imports.setdefault(parsed_import.name, parsed_import.module)
|
|
182
|
+
rows.import_details.extend(out.imports)
|
|
183
|
+
rows.calls.extend(out.calls)
|
|
184
|
+
return rows
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = [
|
|
188
|
+
"FileRows",
|
|
189
|
+
"build_file_rows",
|
|
190
|
+
"build_python_file_rows",
|
|
191
|
+
]
|