graphlens-go 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.3
2
+ Name: graphlens-go
3
+ Version: 0.5.0
4
+ Summary: Go language adapter for graphlens
5
+ Requires-Dist: graphlens
6
+ Requires-Dist: tree-sitter>=0.24
7
+ Requires-Dist: tree-sitter-go>=0.23
8
+ Requires-Python: >=3.13
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "graphlens-go"
3
+ version = "0.5.0"
4
+ description = "Go language adapter for graphlens"
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "graphlens",
8
+ "tree-sitter>=0.24",
9
+ "tree-sitter-go>=0.23",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.9.18,<0.12.0"]
14
+ build-backend = "uv_build"
15
+
16
+ [tool.uv.sources]
17
+ graphlens = { workspace = true }
18
+
19
+ [project.entry-points."graphlens.adapters"]
20
+ go = "graphlens_go:GoAdapter"
21
+
22
+ [tool.bandit]
23
+ skips = ["B101", "B404", "B603"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+
28
+ [tool.coverage.run]
29
+ source = ["graphlens_go"]
30
+
31
+ [tool.coverage.report]
32
+ fail_under = 100
33
+ show_missing = true
34
+ exclude_lines = [
35
+ "pragma: no cover",
36
+ "if TYPE_CHECKING:",
37
+ "\\.\\.\\.",
38
+ ]
@@ -0,0 +1,6 @@
1
+ """Go language adapter for graphlens."""
2
+
3
+ from graphlens_go._adapter import GoAdapter
4
+ from graphlens_go._resolver import GoplsResolver, GoResolver
5
+
6
+ __all__ = ["GoAdapter", "GoResolver", "GoplsResolver"]
@@ -0,0 +1,471 @@
1
+ """GoAdapter — orchestrates structural analysis of Go projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from functools import partial
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from graphlens import (
11
+ RESOLVER_STATUS_KEY,
12
+ AdapterError,
13
+ BoundaryRef,
14
+ GraphLens,
15
+ LanguageAdapter,
16
+ Node,
17
+ NodeKind,
18
+ Relation,
19
+ RelationKind,
20
+ ResolverStatus,
21
+ make_boundary_id,
22
+ )
23
+ from graphlens.utils import SpanIndex, make_node_id
24
+ from graphlens.utils.roots import filter_nested_root_files
25
+
26
+ from graphlens_go._boundary import (
27
+ GO_DEFAULT_BOUNDARY_EXTRACTORS,
28
+ GoBoundaryExtractor,
29
+ )
30
+ from graphlens_go._deps import (
31
+ GO_DEFAULT_DEP_PARSERS,
32
+ classify_go_import,
33
+ read_module_path,
34
+ )
35
+ from graphlens_go._project_detector import find_go_roots, is_go_project
36
+ from graphlens_go._resolver import GoResolver
37
+ from graphlens_go._visitor import (
38
+ GoFileContext,
39
+ GoStructureExtractor,
40
+ parse_go,
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ from graphlens.contracts import DependencyFileParser, SymbolResolver
45
+ from tree_sitter import Node as TSNode
46
+
47
+ from graphlens_go._visitor import OccurrenceRef
48
+
49
+ # Occurrence role -> the edge kind the resolution pass emits for it.
50
+ _ROLE_TO_KIND = {
51
+ "call": RelationKind.CALLS,
52
+ "base": RelationKind.INHERITS_FROM,
53
+ }
54
+
55
+ logger = logging.getLogger("graphlens_go")
56
+
57
+
58
+ class GoAdapter(LanguageAdapter):
59
+ """Language adapter for Go projects (structure + imports)."""
60
+
61
+ def __init__(
62
+ self,
63
+ dep_parsers: list[DependencyFileParser] | None = None,
64
+ resolver: SymbolResolver | None = None,
65
+ boundary_extractors: list[GoBoundaryExtractor] | None = None,
66
+ ) -> None:
67
+ """Initialise with optional custom dep parsers and resolver."""
68
+ self._dep_parsers = (
69
+ dep_parsers
70
+ if dep_parsers is not None
71
+ else GO_DEFAULT_DEP_PARSERS
72
+ )
73
+ self._resolver = resolver if resolver is not None else GoResolver()
74
+ self._boundary_extractors = (
75
+ boundary_extractors
76
+ if boundary_extractors is not None
77
+ else GO_DEFAULT_BOUNDARY_EXTRACTORS
78
+ )
79
+
80
+ def language(self) -> str:
81
+ return "go"
82
+
83
+ def file_extensions(self) -> set[str]:
84
+ return {".go"}
85
+
86
+ def can_handle(self, project_root: str | Path) -> bool:
87
+ return is_go_project(Path(project_root))
88
+
89
+ def analyze(
90
+ self,
91
+ project_root: str | Path,
92
+ files: list[Path] | None = None,
93
+ *,
94
+ strict: bool = False,
95
+ ) -> GraphLens:
96
+ project_root = Path(project_root).resolve()
97
+ graph = GraphLens()
98
+ statuses: list[ResolverStatus] = []
99
+
100
+ if files is not None:
101
+ _analyze_root(
102
+ graph,
103
+ project_root,
104
+ project_root,
105
+ files,
106
+ self._dep_parsers,
107
+ self._resolver,
108
+ self._boundary_extractors,
109
+ )
110
+ statuses.append(self._resolver.status())
111
+ else:
112
+ roots = find_go_roots(project_root)
113
+ for go_root in roots:
114
+ root_files = filter_nested_root_files(
115
+ self.collect_files(go_root),
116
+ go_root,
117
+ roots,
118
+ )
119
+ _analyze_root(
120
+ graph,
121
+ project_root,
122
+ go_root,
123
+ root_files,
124
+ self._dep_parsers,
125
+ self._resolver,
126
+ self._boundary_extractors,
127
+ )
128
+ statuses.append(self._resolver.status())
129
+
130
+ status = ResolverStatus.combine(statuses)
131
+ graph.metadata[RESOLVER_STATUS_KEY] = status.value
132
+ if strict and status is not ResolverStatus.OK:
133
+ msg = (
134
+ f"Go resolver status is '{status.value}'; refusing to "
135
+ "return a degraded graph in strict mode"
136
+ )
137
+ raise AdapterError(msg)
138
+ return graph
139
+
140
+
141
+ def _analyze_root( # noqa: PLR0913
142
+ graph: GraphLens,
143
+ project_root: Path,
144
+ go_root: Path,
145
+ files: list[Path],
146
+ dep_parsers: list[DependencyFileParser],
147
+ resolver: SymbolResolver,
148
+ boundary_extractors: list[GoBoundaryExtractor],
149
+ ) -> None:
150
+ """Analyse one Go module root and populate ``graph`` in place."""
151
+ module_path = read_module_path(go_root) or go_root.name
152
+ project_name = module_path.rstrip("/").split("/")[-1]
153
+
154
+ required: set[str] = set()
155
+ for parser in dep_parsers:
156
+ if parser.can_parse(go_root):
157
+ required |= parser.parse(go_root)
158
+ classify = partial(
159
+ classify_go_import, module_path=module_path, required=required
160
+ )
161
+
162
+ project_id = make_node_id(
163
+ project_name, module_path, NodeKind.PROJECT.value
164
+ )
165
+ if project_id not in graph.nodes:
166
+ graph.add_node(
167
+ Node(
168
+ id=project_id,
169
+ kind=NodeKind.PROJECT,
170
+ qualified_name=module_path,
171
+ name=project_name,
172
+ )
173
+ )
174
+
175
+ packages: dict[str, str] = {}
176
+ parsed_files: list[tuple[str, str, TSNode]] = []
177
+ occurrences: list[tuple[str, OccurrenceRef]] = []
178
+ internal_imports: list[tuple[str, str]] = []
179
+ for file in files:
180
+ pkg_qname = _package_qname(file, go_root, module_path)
181
+ module_id = _ensure_package(
182
+ graph, project_name, pkg_qname, project_id, packages
183
+ )
184
+ file_id = _ensure_file(
185
+ graph, project_name, project_root, go_root, file, module_id
186
+ )
187
+ try:
188
+ source = file.read_bytes()
189
+ except OSError as exc:
190
+ logger.warning("Cannot read %s: %s — skipping", file, exc)
191
+ continue
192
+ file_rel = graph.nodes[file_id].qualified_name
193
+ ctx = GoFileContext(
194
+ project_name=project_name,
195
+ package_qname=pkg_qname,
196
+ file_id=file_id,
197
+ file_rel=file_rel,
198
+ )
199
+ root = parse_go(source).root_node
200
+ extractor = GoStructureExtractor(graph, ctx, classify)
201
+ extractor.extract(root)
202
+ parsed_files.append((file_rel, file_id, root))
203
+ occurrences.extend(
204
+ (str(file), occ) for occ in extractor.occurrences
205
+ )
206
+ internal_imports.extend(extractor.internal_imports)
207
+
208
+ # Bind internal imports to the MODULE node they reference (now that every
209
+ # package exists), falling back to an EXTERNAL_SYMBOL when none matches.
210
+ _resolve_internal_imports(graph, project_name, internal_imports, packages)
211
+
212
+ # Resolution pass: bind occurrences to real nodes or EXTERNAL_SYMBOL.
213
+ span_index = SpanIndex.from_graph(graph)
214
+ resolver.prepare(go_root, files)
215
+ _resolve_occurrences(
216
+ graph, project_name, project_root, resolver, span_index, occurrences
217
+ )
218
+
219
+ _extract_boundaries(graph, parsed_files, boundary_extractors)
220
+
221
+
222
+ def _resolve_internal_imports(
223
+ graph: GraphLens,
224
+ project_name: str,
225
+ internal_imports: list[tuple[str, str]],
226
+ packages: dict[str, str],
227
+ ) -> None:
228
+ """
229
+ Resolve each ``internal`` import to its MODULE node (per CLAUDE.md §9).
230
+
231
+ A Go import path of an internal package equals that package's qualified
232
+ name, so a direct lookup binds ``RESOLVES_TO`` to the real MODULE node.
233
+ Imports whose package was not analyzed fall back to an EXTERNAL_SYMBOL so
234
+ the edge is never missing.
235
+ """
236
+ for imp_id, import_path in internal_imports:
237
+ target_id = packages.get(import_path)
238
+ if target_id is None:
239
+ target_id = _ensure_external_symbol(
240
+ graph, project_name, import_path, "internal"
241
+ )
242
+ graph.add_relation(
243
+ Relation(imp_id, target_id, RelationKind.RESOLVES_TO)
244
+ )
245
+
246
+
247
+ def _ensure_external_symbol(
248
+ graph: GraphLens, project_name: str, qname: str, origin: str
249
+ ) -> str:
250
+ """Return the id of an EXTERNAL_SYMBOL node, creating it if needed."""
251
+ sym_id = make_node_id(
252
+ project_name, qname, NodeKind.EXTERNAL_SYMBOL.value
253
+ )
254
+ if sym_id not in graph.nodes:
255
+ graph.add_node(
256
+ Node(
257
+ id=sym_id,
258
+ kind=NodeKind.EXTERNAL_SYMBOL,
259
+ qualified_name=qname,
260
+ name=qname.rsplit(".", maxsplit=1)[-1],
261
+ metadata={"origin": origin},
262
+ )
263
+ )
264
+ return sym_id
265
+
266
+
267
+ def _relative_to(file_path: Path, project_root: Path) -> str:
268
+ """
269
+ Map an absolute resolver path to the graph's project-relative form.
270
+
271
+ The resolver (gopls) returns absolute paths, but graph nodes store
272
+ file paths relative to ``project_root``; reconcile them so SpanIndex
273
+ lookups hit. Falls back to the absolute string for paths outside the
274
+ project (which then resolve to an EXTERNAL_SYMBOL).
275
+ """
276
+ try:
277
+ return str(file_path.resolve().relative_to(project_root))
278
+ except (ValueError, OSError):
279
+ return str(file_path)
280
+
281
+
282
+ def _resolve_occurrences( # noqa: PLR0913
283
+ graph: GraphLens,
284
+ project_name: str,
285
+ project_root: Path,
286
+ resolver: SymbolResolver,
287
+ span_index: SpanIndex,
288
+ occurrences: list[tuple[str, OccurrenceRef]],
289
+ ) -> None:
290
+ """Resolve each occurrence to a definition node and emit its edge."""
291
+ for abs_path, occ in occurrences:
292
+ rel_kind = _ROLE_TO_KIND[occ.role]
293
+ ref = resolver.definition_at(Path(abs_path), occ.line, occ.col)
294
+ if ref is None:
295
+ continue
296
+ target_id: str | None = None
297
+ if ref.origin == "internal" and ref.file_path is not None:
298
+ target_id = span_index.at(
299
+ _relative_to(ref.file_path, project_root),
300
+ ref.line,
301
+ ref.col,
302
+ )
303
+ if target_id is None:
304
+ fallback_qname = (
305
+ ref.full_name
306
+ if ref.full_name
307
+ else f"{occ.role}@{occ.line}:{occ.col}"
308
+ )
309
+ target_id = _ensure_external_symbol(
310
+ graph, project_name, fallback_qname, ref.origin
311
+ )
312
+ graph.add_relation(
313
+ Relation(
314
+ source_id=occ.enclosing_id,
315
+ target_id=target_id,
316
+ kind=rel_kind,
317
+ metadata={"span": occ.span},
318
+ )
319
+ )
320
+
321
+
322
+ def _extract_boundaries(
323
+ graph: GraphLens,
324
+ parsed_files: list[tuple[str, str, TSNode]],
325
+ extractors: list[GoBoundaryExtractor],
326
+ ) -> None:
327
+ """Run boundary extractors and emit BOUNDARY nodes + EXPOSES/CONSUMES."""
328
+ if not extractors:
329
+ return
330
+ enclosers: dict[str, list[Node]] = {}
331
+ for node in graph.nodes.values():
332
+ if (
333
+ node.kind in (NodeKind.FUNCTION, NodeKind.METHOD)
334
+ and node.span is not None
335
+ and node.file_path is not None
336
+ ):
337
+ enclosers.setdefault(node.file_path, []).append(node)
338
+
339
+ for file_rel, file_id, root in parsed_files:
340
+ candidates = enclosers.get(file_rel, [])
341
+ for extractor in extractors:
342
+ for ref in extractor.extract(root):
343
+ enclosing_id = (
344
+ _innermost_enclosing(candidates, ref.line, ref.col)
345
+ or file_id
346
+ )
347
+ _add_boundary(graph, enclosing_id, ref)
348
+
349
+
350
+ def _innermost_enclosing(
351
+ candidates: list[Node], line: int, col: int
352
+ ) -> str | None:
353
+ """Return the id of the deepest function/method containing (line, col)."""
354
+ best_id: str | None = None
355
+ best_start: tuple[int, int] | None = None
356
+ for node in candidates:
357
+ span = node.span
358
+ if span is None:
359
+ continue # pragma: no cover - filtered before insertion
360
+ start = (span.start_line, span.start_col)
361
+ end = (span.end_line, span.end_col)
362
+ if start <= (line, col) <= end and (
363
+ best_start is None or start >= best_start
364
+ ):
365
+ best_id = node.id
366
+ best_start = start
367
+ return best_id
368
+
369
+
370
+ def _add_boundary(
371
+ graph: GraphLens, enclosing_id: str, ref: BoundaryRef
372
+ ) -> None:
373
+ boundary_id = make_boundary_id(ref.mechanism, ref.key)
374
+ if boundary_id not in graph.nodes:
375
+ graph.add_node(
376
+ Node(
377
+ id=boundary_id,
378
+ kind=NodeKind.BOUNDARY,
379
+ qualified_name=f"{ref.mechanism}:{ref.key}",
380
+ name=ref.key,
381
+ metadata={"mechanism": ref.mechanism, "key": ref.key},
382
+ )
383
+ )
384
+ kind = (
385
+ RelationKind.EXPOSES
386
+ if ref.role == "server"
387
+ else RelationKind.CONSUMES
388
+ )
389
+ metadata: dict[str, object] = {
390
+ "mechanism": ref.mechanism,
391
+ "key": ref.key,
392
+ "confidence": ref.confidence,
393
+ "role": ref.role,
394
+ "line": ref.line,
395
+ "col": ref.col,
396
+ }
397
+ metadata.update(ref.detail)
398
+ graph.add_relation(
399
+ Relation(
400
+ source_id=enclosing_id,
401
+ target_id=boundary_id,
402
+ kind=kind,
403
+ metadata=metadata,
404
+ )
405
+ )
406
+
407
+
408
+ def _package_qname(file: Path, go_root: Path, module_path: str) -> str:
409
+ try:
410
+ rel_dir = file.parent.relative_to(go_root)
411
+ except ValueError:
412
+ # A file passed explicitly (incremental update) that lives outside
413
+ # the module root — treat it as the root package rather than crash.
414
+ return module_path
415
+ if str(rel_dir) == ".":
416
+ return module_path
417
+ return f"{module_path}/{rel_dir.as_posix()}"
418
+
419
+
420
+ def _ensure_package(
421
+ graph: GraphLens,
422
+ project_name: str,
423
+ pkg_qname: str,
424
+ project_id: str,
425
+ packages: dict[str, str],
426
+ ) -> str:
427
+ if pkg_qname in packages:
428
+ return packages[pkg_qname]
429
+ module_id = make_node_id(project_name, pkg_qname, NodeKind.MODULE.value)
430
+ graph.add_node(
431
+ Node(
432
+ id=module_id,
433
+ kind=NodeKind.MODULE,
434
+ qualified_name=pkg_qname,
435
+ name=pkg_qname.rsplit("/", maxsplit=1)[-1],
436
+ )
437
+ )
438
+ graph.add_relation(
439
+ Relation(project_id, module_id, RelationKind.CONTAINS)
440
+ )
441
+ packages[pkg_qname] = module_id
442
+ return module_id
443
+
444
+
445
+ def _ensure_file( # noqa: PLR0913
446
+ graph: GraphLens,
447
+ project_name: str,
448
+ project_root: Path,
449
+ go_root: Path,
450
+ file: Path,
451
+ module_id: str,
452
+ ) -> str:
453
+ try:
454
+ file_rel = str(file.relative_to(project_root))
455
+ except ValueError:
456
+ file_rel = str(file.relative_to(go_root))
457
+ file_id = make_node_id(project_name, file_rel, NodeKind.FILE.value)
458
+ if file_id not in graph.nodes:
459
+ graph.add_node(
460
+ Node(
461
+ id=file_id,
462
+ kind=NodeKind.FILE,
463
+ qualified_name=file_rel,
464
+ name=file.name,
465
+ file_path=file_rel,
466
+ )
467
+ )
468
+ graph.add_relation(
469
+ Relation(module_id, file_id, RelationKind.CONTAINS)
470
+ )
471
+ return file_id