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.
@@ -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"]
@@ -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
+ ]