entrygraph 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.
Files changed (102) hide show
  1. entrygraph/__init__.py +87 -0
  2. entrygraph/__main__.py +8 -0
  3. entrygraph/_version.py +24 -0
  4. entrygraph/api.py +549 -0
  5. entrygraph/cli/__init__.py +0 -0
  6. entrygraph/cli/main.py +387 -0
  7. entrygraph/cli/render.py +136 -0
  8. entrygraph/data/sinks/csharp.toml +106 -0
  9. entrygraph/data/sinks/go.toml +87 -0
  10. entrygraph/data/sinks/java.toml +92 -0
  11. entrygraph/data/sinks/javascript.toml +112 -0
  12. entrygraph/data/sinks/lib_javascript.toml +34 -0
  13. entrygraph/data/sinks/lib_python.toml +39 -0
  14. entrygraph/data/sinks/php.toml +125 -0
  15. entrygraph/data/sinks/python.toml +160 -0
  16. entrygraph/data/sinks/ruby.toml +102 -0
  17. entrygraph/data/sinks/rust.toml +68 -0
  18. entrygraph/db/__init__.py +0 -0
  19. entrygraph/db/engine.py +34 -0
  20. entrygraph/db/meta.py +70 -0
  21. entrygraph/db/models.py +176 -0
  22. entrygraph/db/queries.py +155 -0
  23. entrygraph/detect/__init__.py +0 -0
  24. entrygraph/detect/entrypoints/__init__.py +22 -0
  25. entrygraph/detect/entrypoints/base.py +124 -0
  26. entrygraph/detect/entrypoints/configs.py +139 -0
  27. entrygraph/detect/entrypoints/csharp.py +156 -0
  28. entrygraph/detect/entrypoints/golang.py +158 -0
  29. entrygraph/detect/entrypoints/java.py +187 -0
  30. entrygraph/detect/entrypoints/javascript.py +211 -0
  31. entrygraph/detect/entrypoints/php.py +133 -0
  32. entrygraph/detect/entrypoints/python.py +335 -0
  33. entrygraph/detect/entrypoints/ruby.py +147 -0
  34. entrygraph/detect/entrypoints/rust.py +153 -0
  35. entrygraph/detect/frameworks.py +369 -0
  36. entrygraph/detect/manifests.py +234 -0
  37. entrygraph/detect/taint.py +224 -0
  38. entrygraph/errors.py +27 -0
  39. entrygraph/extract/__init__.py +0 -0
  40. entrygraph/extract/base.py +51 -0
  41. entrygraph/extract/csharp.py +502 -0
  42. entrygraph/extract/golang.py +342 -0
  43. entrygraph/extract/ir.py +105 -0
  44. entrygraph/extract/java.py +329 -0
  45. entrygraph/extract/javascript.py +400 -0
  46. entrygraph/extract/php.py +426 -0
  47. entrygraph/extract/python.py +390 -0
  48. entrygraph/extract/registry.py +43 -0
  49. entrygraph/extract/ruby.py +321 -0
  50. entrygraph/extract/rust.py +482 -0
  51. entrygraph/fs/__init__.py +0 -0
  52. entrygraph/fs/hashing.py +78 -0
  53. entrygraph/fs/lang.py +134 -0
  54. entrygraph/fs/walker.py +167 -0
  55. entrygraph/graph/__init__.py +0 -0
  56. entrygraph/graph/adjacency.py +146 -0
  57. entrygraph/graph/cte.py +123 -0
  58. entrygraph/graph/scoring.py +101 -0
  59. entrygraph/kinds.py +51 -0
  60. entrygraph/parsing/__init__.py +0 -0
  61. entrygraph/parsing/parsers.py +49 -0
  62. entrygraph/parsing/queries.py +39 -0
  63. entrygraph/pipeline/__init__.py +0 -0
  64. entrygraph/pipeline/scanner.py +506 -0
  65. entrygraph/pipeline/worker.py +49 -0
  66. entrygraph/pipeline/writer.py +41 -0
  67. entrygraph/py.typed +0 -0
  68. entrygraph/queries/csharp/calls.scm +4 -0
  69. entrygraph/queries/csharp/definitions.scm +29 -0
  70. entrygraph/queries/csharp/imports.scm +4 -0
  71. entrygraph/queries/go/calls.scm +2 -0
  72. entrygraph/queries/go/definitions.scm +24 -0
  73. entrygraph/queries/go/imports.scm +1 -0
  74. entrygraph/queries/java/calls.scm +2 -0
  75. entrygraph/queries/java/definitions.scm +14 -0
  76. entrygraph/queries/java/imports.scm +2 -0
  77. entrygraph/queries/javascript/calls.scm +4 -0
  78. entrygraph/queries/javascript/definitions.scm +4 -0
  79. entrygraph/queries/javascript/imports.scm +6 -0
  80. entrygraph/queries/php/calls.scm +8 -0
  81. entrygraph/queries/php/definitions.scm +24 -0
  82. entrygraph/queries/php/imports.scm +1 -0
  83. entrygraph/queries/python/calls.scm +2 -0
  84. entrygraph/queries/python/definitions.scm +11 -0
  85. entrygraph/queries/python/imports.scm +2 -0
  86. entrygraph/queries/ruby/calls.scm +4 -0
  87. entrygraph/queries/ruby/definitions.scm +20 -0
  88. entrygraph/queries/ruby/imports.scm +7 -0
  89. entrygraph/queries/rust/calls.scm +5 -0
  90. entrygraph/queries/rust/definitions.scm +26 -0
  91. entrygraph/queries/rust/imports.scm +4 -0
  92. entrygraph/resolve/__init__.py +0 -0
  93. entrygraph/resolve/externals.py +61 -0
  94. entrygraph/resolve/hierarchy.py +152 -0
  95. entrygraph/resolve/resolver.py +275 -0
  96. entrygraph/resolve/symbol_table.py +48 -0
  97. entrygraph/results.py +138 -0
  98. entrygraph-0.1.0.dist-info/METADATA +204 -0
  99. entrygraph-0.1.0.dist-info/RECORD +102 -0
  100. entrygraph-0.1.0.dist-info/WHEEL +4 -0
  101. entrygraph-0.1.0.dist-info/entry_points.txt +2 -0
  102. entrygraph-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,14 @@
1
+ ; Harvest-level queries: find definition nodes; the Java shaper computes
2
+ ; qualified names, kinds, annotations (as decorators), and supertypes by
3
+ ; walking from these.
4
+
5
+ (class_declaration
6
+ name: (identifier) @name) @def.class
7
+
8
+ (interface_declaration
9
+ name: (identifier) @name) @def.interface
10
+
11
+ (method_declaration
12
+ name: (identifier) @name) @def.method
13
+
14
+ (field_declaration) @def.field
@@ -0,0 +1,2 @@
1
+ (import_declaration) @import
2
+ (package_declaration) @package
@@ -0,0 +1,4 @@
1
+ (call_expression) @call
2
+ ; heritage is harvested in the shaper's _heritage(); a `class_heritage
3
+ ; (identifier)` pattern is impossible in the TS grammar (clauses wrap the
4
+ ; identifiers there) and would fail to compile the shared query for TypeScript.
@@ -0,0 +1,4 @@
1
+ (function_declaration name: (identifier) @name) @def.function
2
+ (class_declaration name: (_) @name) @def.class
3
+ (method_definition name: (property_identifier) @name) @def.method
4
+ (variable_declarator name: (identifier) @name) @def.var
@@ -0,0 +1,6 @@
1
+ (import_statement) @import
2
+ (call_expression
3
+ function: (identifier) @req (#eq? @req "require")
4
+ arguments: (arguments (string) @req.module)) @require
5
+ ; re-exports (barrel files): `export { X } from "./y"` / `export * from "./y"`
6
+ (export_statement source: (string)) @export.from
@@ -0,0 +1,8 @@
1
+ (function_call_expression) @call
2
+ (member_call_expression) @member_call
3
+ (scoped_call_expression) @scoped_call
4
+ (object_creation_expression) @new
5
+ (include_expression) @include
6
+ (include_once_expression) @include
7
+ (require_expression) @include
8
+ (require_once_expression) @include
@@ -0,0 +1,24 @@
1
+ ; Harvest-level queries: find definition nodes; the PHP shaper computes
2
+ ; qualified names (namespace-scoped, `\` -> `.`), kinds, attributes (as
3
+ ; decorators), and supertypes by walking from these.
4
+
5
+ (namespace_definition) @namespace
6
+
7
+ (class_declaration
8
+ name: (name) @name) @def.class
9
+
10
+ (interface_declaration
11
+ name: (name) @name) @def.interface
12
+
13
+ (trait_declaration
14
+ name: (name) @name) @def.trait
15
+
16
+ (function_definition
17
+ name: (name) @name) @def.function
18
+
19
+ (method_declaration
20
+ name: (name) @name) @def.method
21
+
22
+ (const_declaration) @def.const
23
+
24
+ (property_declaration) @def.property
@@ -0,0 +1 @@
1
+ (namespace_use_declaration) @import
@@ -0,0 +1,2 @@
1
+ (call) @call
2
+ (decorator) @decorator
@@ -0,0 +1,11 @@
1
+ ; Harvest-level queries: find definition nodes; the Python shaper computes
2
+ ; qualified names, kinds, docstrings, and decorators by walking from these.
3
+
4
+ (class_definition
5
+ name: (identifier) @name) @def.class
6
+
7
+ (function_definition
8
+ name: (identifier) @name) @def.function
9
+
10
+ (assignment
11
+ left: (identifier) @name) @def.assign
@@ -0,0 +1,2 @@
1
+ (import_statement) @import
2
+ (import_from_statement) @import.from
@@ -0,0 +1,4 @@
1
+ ; Method calls. `method` field is the rightmost identifier; `receiver` (when
2
+ ; present) is the object it is called on. Bare calls have no receiver.
3
+ (call
4
+ method: (identifier) @callee) @call
@@ -0,0 +1,20 @@
1
+ ; Harvest-level queries: find definition nodes; the Ruby shaper computes
2
+ ; qualified names, kinds, and scope chains by walking from these.
3
+
4
+ (class
5
+ name: (constant) @name) @def.class
6
+
7
+ (module
8
+ name: (constant) @name) @def.module
9
+
10
+ (method
11
+ name: (identifier) @name) @def.method
12
+
13
+ (singleton_method
14
+ name: (identifier) @name) @def.singleton_method
15
+
16
+ (assignment
17
+ left: (constant) @name) @def.assign.constant
18
+
19
+ (assignment
20
+ left: (identifier) @name) @def.assign.variable
@@ -0,0 +1,7 @@
1
+ ; Ruby "imports" are method calls: require / require_relative / load. The
2
+ ; shaper filters calls by method name; capturing all calls keeps this cheap
3
+ ; and avoids brittle string predicates.
4
+
5
+ (call
6
+ method: (identifier) @import.method
7
+ arguments: (argument_list)) @import.call
@@ -0,0 +1,5 @@
1
+ ; Both call_expression AND macro_invocation must be captured: without macros,
2
+ ; sqlx::query! and route macros are invisible to the sink/entrypoint layer.
3
+
4
+ (call_expression) @call
5
+ (macro_invocation) @macro
@@ -0,0 +1,26 @@
1
+ ; Harvest-level queries: find definition nodes; the Rust shaper computes
2
+ ; qualified names, kinds, impl-receiver methods, attributes (as decorators),
3
+ ; and trait conformance by walking from these.
4
+
5
+ (function_item
6
+ name: (identifier) @name) @def.function
7
+
8
+ (struct_item
9
+ name: (type_identifier) @name) @def.struct
10
+
11
+ (enum_item
12
+ name: (type_identifier) @name) @def.enum
13
+
14
+ (trait_item
15
+ name: (type_identifier) @name) @def.trait
16
+
17
+ (impl_item) @def.impl
18
+
19
+ (const_item
20
+ name: (identifier) @name) @def.const
21
+
22
+ (static_item
23
+ name: (identifier) @name) @def.static
24
+
25
+ (mod_item
26
+ name: (identifier) @name) @def.mod
@@ -0,0 +1,4 @@
1
+ ; Use-declarations bind names into local scope; the Rust shaper unrolls
2
+ ; scoped lists, `use x as y`, and wildcards.
3
+
4
+ (use_declaration) @import
File without changes
@@ -0,0 +1,61 @@
1
+ """Placeholder symbols for callees defined outside the repo.
2
+
3
+ Sinks are overwhelmingly external calls (subprocess.run, child_process.exec),
4
+ and reachability needs real graph nodes to terminate on — so the first time an
5
+ external qualified name is referenced we mint a Symbol row with kind=external
6
+ and no file. Created lazily from actual references, never pre-seeded.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Callable
12
+
13
+ from entrygraph.kinds import SymbolKind
14
+
15
+ LANG_PREFIX = {
16
+ "python": "py",
17
+ "javascript": "js",
18
+ "typescript": "js",
19
+ "tsx": "js",
20
+ "go": "go",
21
+ "java": "java",
22
+ "ruby": "rb",
23
+ "csharp": "cs",
24
+ "php": "php",
25
+ "rust": "rs",
26
+ }
27
+
28
+
29
+ class ExternalRegistry:
30
+ def __init__(self, allocate_id: Callable[[], int]) -> None:
31
+ self._allocate_id = allocate_id
32
+ self.by_qname: dict[str, int] = {}
33
+ self.new_rows: list[dict] = [] # Symbol insert dicts, drained by the writer
34
+
35
+ def preload(self, existing: dict[str, int]) -> None:
36
+ """Seed with external symbols already in the DB (incremental runs)."""
37
+ self.by_qname.update(existing)
38
+
39
+ def get_or_create(self, qname: str) -> int:
40
+ symbol_id = self.by_qname.get(qname)
41
+ if symbol_id is not None:
42
+ return symbol_id
43
+ symbol_id = self._allocate_id()
44
+ self.by_qname[qname] = symbol_id
45
+ self.new_rows.append(
46
+ {
47
+ "id": symbol_id,
48
+ "file_id": None,
49
+ "kind": SymbolKind.EXTERNAL,
50
+ "name": qname.split(":", 1)[-1].rsplit(".", 1)[-1],
51
+ "qname": qname,
52
+ "parent_id": None,
53
+ "start_line": 0,
54
+ "end_line": 0,
55
+ "start_col": 0,
56
+ "signature": None,
57
+ "docstring": None,
58
+ "is_exported": True,
59
+ }
60
+ )
61
+ return symbol_id
@@ -0,0 +1,152 @@
1
+ """Class-hierarchy analysis and shared import-map construction.
2
+
3
+ Runs as an index-time pre-pass (`resolve_hierarchy`) after all symbols are in
4
+ the table but before edge resolution: it expands each class's raw base
5
+ expressions **in the defining file's own import context** and records the
6
+ resolved parents, so `ancestors()` can walk the full chain and `cha_candidates()`
7
+ can enumerate virtual-dispatch targets.
8
+
9
+ Kept import-free of resolver.py to avoid a cycle; resolver imports from here.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from entrygraph.extract.ir import FileExtraction, RawImport
15
+ from entrygraph.kinds import SymbolKind
16
+ from entrygraph.resolve.symbol_table import SymbolTable
17
+
18
+ _MAX_ANCESTOR_DEPTH = 10
19
+ _MAX_CHA_CANDIDATES = 8
20
+
21
+
22
+ def expand_relative(imp: RawImport, module_path: str, is_package: bool) -> str:
23
+ """Resolve a Python-style relative import against the importing module."""
24
+ parts = module_path.split(".")
25
+ if not is_package:
26
+ parts = parts[:-1] # level 1 = the containing package
27
+ drop = imp.relative_level - 1
28
+ if drop >= len(parts):
29
+ return imp.module # over-deep relative import; keep as written
30
+ if drop:
31
+ parts = parts[:-drop]
32
+ base = ".".join(parts)
33
+ return f"{base}.{imp.module}" if imp.module else base
34
+
35
+
36
+ def build_import_map(
37
+ extraction: FileExtraction, is_package: bool
38
+ ) -> tuple[dict[str, str], list[str]]:
39
+ """Return (alias -> dotted target, wildcard source modules).
40
+
41
+ Wildcard (`from x import *`) modules are returned separately so the resolver
42
+ can try `<module>.<name>` for otherwise-unresolved bare names.
43
+ """
44
+ import_map: dict[str, str] = {}
45
+ wildcard_modules: list[str] = []
46
+ for imp in extraction.imports:
47
+ module = (
48
+ expand_relative(imp, extraction.module_path, is_package)
49
+ if imp.is_relative
50
+ else imp.module
51
+ )
52
+ if imp.alias == "*" or imp.imported_name == "*":
53
+ if module:
54
+ wildcard_modules.append(module)
55
+ continue
56
+ if imp.imported_name is None:
57
+ top = module.split(".")[0]
58
+ import_map[imp.alias] = module if imp.alias != top else top
59
+ else:
60
+ import_map[imp.alias] = f"{module}.{imp.imported_name}" if module else imp.imported_name
61
+ return import_map, wildcard_modules
62
+
63
+
64
+ def resolve_hierarchy(
65
+ extractions: list[tuple[str, FileExtraction, bool]], table: SymbolTable
66
+ ) -> None:
67
+ """Populate table.class_parents and table.reexports from freshly extracted files."""
68
+ for _path, x, is_package in extractions:
69
+ import_map, _wild = build_import_map(x, is_package)
70
+ for raw in x.symbols:
71
+ if raw.kind is not SymbolKind.CLASS or not raw.bases:
72
+ continue
73
+ parents: list[str] = []
74
+ for base in raw.bases:
75
+ resolved = _resolve_type_name(base, import_map, x.module_path, table)
76
+ if resolved is not None:
77
+ parents.append(resolved)
78
+ if parents:
79
+ table.class_parents[raw.qualified_name] = parents
80
+ for rx in x.reexports:
81
+ if rx.is_star:
82
+ table.star_reexports.setdefault(x.module_path, []).append(rx.module)
83
+ elif rx.exported_name is not None:
84
+ local_name = rx.alias or rx.exported_name
85
+ table.reexports.setdefault(x.module_path, {})[local_name] = (
86
+ rx.module,
87
+ rx.exported_name,
88
+ )
89
+
90
+
91
+ def _resolve_type_name(
92
+ text: str, import_map: dict[str, str], module_path: str, table: SymbolTable
93
+ ) -> str | None:
94
+ """Resolve a base/interface expression to a project FQN, or None if external."""
95
+ first_seg = text.split(".", 1)[0]
96
+ if first_seg in import_map:
97
+ expanded = import_map[first_seg] + text[len(first_seg):]
98
+ return expanded if expanded in table.by_fqn else None
99
+ local = f"{module_path}.{text}"
100
+ if local in table.by_fqn:
101
+ return local
102
+ return text if text in table.by_fqn else None
103
+
104
+
105
+ def ancestors(class_fqn: str, table: SymbolTable, max_depth: int = _MAX_ANCESTOR_DEPTH) -> list[str]:
106
+ """Transitive project ancestors of a class, nearest first, cycle-safe."""
107
+ seen: set[str] = set()
108
+ order: list[str] = []
109
+ frontier = [(p, 1) for p in table.class_parents.get(class_fqn, [])]
110
+ while frontier:
111
+ parent, depth = frontier.pop(0)
112
+ if parent in seen or depth > max_depth:
113
+ continue
114
+ seen.add(parent)
115
+ order.append(parent)
116
+ for grand in table.class_parents.get(parent, []):
117
+ if grand not in seen:
118
+ frontier.append((grand, depth + 1))
119
+ return order
120
+
121
+
122
+ def cha_candidates(
123
+ table: SymbolTable, method_name: str, exclude: set[int] | None = None
124
+ ) -> list[int]:
125
+ """Symbol ids of all method overrides named `method_name` that share a hierarchy.
126
+
127
+ Class-hierarchy analysis for virtual dispatch on an unknown receiver: only
128
+ fires when >=2 classes defining the method are related by inheritance, and
129
+ bails out past _MAX_CHA_CANDIDATES to avoid common-name blowup (run/get/...).
130
+ """
131
+ candidates = [
132
+ sid
133
+ for sid in table.by_name.get(method_name, [])
134
+ if table.kinds.get(sid) is SymbolKind.METHOD
135
+ ]
136
+ if exclude:
137
+ candidates = [c for c in candidates if c not in exclude]
138
+ if len(candidates) < 2 or len(candidates) > _MAX_CHA_CANDIDATES:
139
+ return []
140
+ # Keep methods whose owning classes are connected in the hierarchy — either
141
+ # one is an ancestor of the other, or they share a common ancestor (siblings
142
+ # implementing the same interface/base). Both cases mean a virtual call could
143
+ # dispatch across them.
144
+ closures = {
145
+ sid: {table.qname_of[sid].rsplit(".", 1)[0], *ancestors(table.qname_of[sid].rsplit(".", 1)[0], table)}
146
+ for sid in candidates
147
+ }
148
+ related: list[int] = []
149
+ for sid, closure in closures.items():
150
+ if any(oid != sid and closure & other for oid, other in closures.items()):
151
+ related.append(sid)
152
+ return related if len(related) >= 2 else []
@@ -0,0 +1,275 @@
1
+ """Pass-2 reference resolution: raw references -> edge rows.
2
+
3
+ Resolution order per reference (first hit wins):
4
+ 1. import-map expansion (project target -> FQN lookup; external -> placeholder)
5
+ 2. module-local lookup (same-module bare names)
6
+ 3. self/cls receiver -> method on the enclosing class, then its bases (1 level)
7
+ 4. unique-name fuzzy match across the project
8
+ 5. unresolved: a language-prefixed guess so sink matching still works
9
+ ("py:*.execute" for attribute calls, "py:eval" for bare calls)
10
+
11
+ Every call edge ends at a real symbol id — project symbol or external
12
+ placeholder — except import-expanded references into project modules whose
13
+ target doesn't exist (yet); those keep dst NULL + a project-style dst_qname so
14
+ incremental re-resolution can heal them when the target appears.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ from entrygraph.extract.ir import FileExtraction, RawReference
22
+ from entrygraph.kinds import Confidence, EdgeKind, SymbolKind
23
+ from entrygraph.resolve.externals import LANG_PREFIX, ExternalRegistry
24
+ from entrygraph.resolve.hierarchy import (
25
+ ancestors,
26
+ build_import_map,
27
+ cha_candidates,
28
+ expand_relative,
29
+ )
30
+ from entrygraph.resolve.symbol_table import SymbolTable
31
+
32
+ # expand_relative re-exported for backward compatibility with callers/tests.
33
+ __all__ = ["FileResolver", "ResolvedEdge", "expand_relative"]
34
+
35
+ _REF_EDGE_KIND = {
36
+ "call": EdgeKind.CALLS,
37
+ "decorator": EdgeKind.CALLS,
38
+ "inherit": EdgeKind.INHERITS,
39
+ "implement": EdgeKind.IMPLEMENTS,
40
+ "annotation": EdgeKind.REFERENCES,
41
+ "callback": EdgeKind.PASSED_AS_CALLBACK,
42
+ "dynamic_call": EdgeKind.CALLS,
43
+ }
44
+
45
+ _SELF_RECEIVERS = frozenset({"self", "cls", "this", "$this"})
46
+ _MAX_REEXPORT_DEPTH = 16
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class ResolvedEdge:
51
+ kind: EdgeKind
52
+ src_symbol_id: int
53
+ dst_symbol_id: int | None
54
+ dst_qname: str
55
+ line: int
56
+ confidence: Confidence
57
+ arg_preview: str | None = None
58
+ via: str | None = None # "dynamic" | "reexport" | "cha" | None
59
+
60
+
61
+ class FileResolver:
62
+ def __init__(
63
+ self,
64
+ extraction: FileExtraction,
65
+ module_symbol_id: int,
66
+ table: SymbolTable,
67
+ externals: ExternalRegistry,
68
+ is_package: bool = False,
69
+ ) -> None:
70
+ self.x = extraction
71
+ self.module_symbol_id = module_symbol_id
72
+ self.table = table
73
+ self.externals = externals
74
+ self.prefix = LANG_PREFIX.get(extraction.language, extraction.language)
75
+ self.import_map, self.wildcard_modules = build_import_map(extraction, is_package)
76
+
77
+ # ---------------- edges ----------------
78
+
79
+ def resolve(self) -> list[ResolvedEdge]:
80
+ edges = [*self._import_edges()]
81
+ for ref in self.x.references:
82
+ edge = self._resolve_reference(ref)
83
+ if edge is None: # callbacks to non-project data args are dropped
84
+ continue
85
+ edges.append(edge)
86
+ edges.extend(self._cha_edges(ref, edge))
87
+ return edges
88
+
89
+ def _cha_edges(self, ref: RawReference, primary: ResolvedEdge) -> list[ResolvedEdge]:
90
+ """Class-hierarchy virtual-dispatch candidates for an unknown-receiver call.
91
+
92
+ Only for method calls that resolved imprecisely (FUZZY or an unresolved
93
+ `prefix:*.name`). Each candidate is a FUZZY, via="cha" edge, so it is
94
+ invisible under the default IMPORT confidence floor and only widens
95
+ results when the caller opts into fuzzy traversal.
96
+ """
97
+ if ref.kind != "call" or ref.receiver_text is None:
98
+ return []
99
+ if ref.receiver_text in _SELF_RECEIVERS:
100
+ return []
101
+ if primary.confidence > Confidence.FUZZY:
102
+ return []
103
+ exclude = {primary.dst_symbol_id} if primary.dst_symbol_id is not None else set()
104
+ out: list[ResolvedEdge] = []
105
+ for cand in cha_candidates(self.table, ref.callee_name, exclude=exclude):
106
+ out.append(
107
+ ResolvedEdge(
108
+ EdgeKind.CALLS, primary.src_symbol_id, cand, self.table.qname_of[cand],
109
+ ref.span.start_line, Confidence.FUZZY, arg_preview=ref.arg_preview, via="cha",
110
+ )
111
+ )
112
+ return out
113
+
114
+ def _import_edges(self) -> list[ResolvedEdge]:
115
+ edges = []
116
+ seen: set[str] = set()
117
+ for imp in self.x.imports:
118
+ module = (
119
+ expand_relative(imp, self.x.module_path, False) if imp.is_relative else imp.module
120
+ )
121
+ if not module or module in seen:
122
+ continue
123
+ seen.add(module)
124
+ if self.table.is_project_path(module):
125
+ dst_id = self.table.module_symbol_ids.get(module) or self.table.by_fqn.get(module)
126
+ edges.append(
127
+ ResolvedEdge(EdgeKind.IMPORTS, self.module_symbol_id, dst_id, module,
128
+ imp.span.start_line, Confidence.IMPORT if dst_id else Confidence.UNRESOLVED)
129
+ )
130
+ else:
131
+ qname = f"{self.prefix}:{module}"
132
+ dst_id = self.externals.get_or_create(qname)
133
+ edges.append(
134
+ ResolvedEdge(EdgeKind.IMPORTS, self.module_symbol_id, dst_id, qname,
135
+ imp.span.start_line, Confidence.IMPORT)
136
+ )
137
+ return edges
138
+
139
+ def _resolve_reference(self, ref: RawReference) -> ResolvedEdge | None:
140
+ src_id = self.table.by_fqn.get(ref.caller_qualified_name or "", self.module_symbol_id)
141
+ kind = _REF_EDGE_KIND.get(ref.kind, EdgeKind.REFERENCES)
142
+
143
+ if ref.kind == "callback":
144
+ # A function name handed to another call. Only a real project
145
+ # function/method is a meaningful edge; anything else is a data arg.
146
+ dst_id = self._bind_project_callable(ref.callee_name)
147
+ if dst_id is None:
148
+ return None
149
+ return ResolvedEdge(kind, src_id, dst_id, self.table.qname_of[dst_id],
150
+ ref.span.start_line, Confidence.IMPORT)
151
+
152
+ if ref.kind == "dynamic_call":
153
+ # getattr/computed/send — target isn't statically knowable. Keep a
154
+ # real placeholder node so the path can flag "may continue".
155
+ if ref.callee_name in ("<dynamic>", "<computed>"):
156
+ guess = f"{self.prefix}:<dynamic>"
157
+ else:
158
+ guess = f"{self.prefix}:{ref.callee_name}.*"
159
+ dst_id = self.externals.get_or_create(guess)
160
+ return ResolvedEdge(kind, src_id, dst_id, guess, ref.span.start_line,
161
+ Confidence.UNRESOLVED, arg_preview=ref.arg_preview, via="dynamic")
162
+
163
+ dst_id, dst_qname, confidence, via = self._bind(ref)
164
+ return ResolvedEdge(
165
+ kind=kind,
166
+ src_symbol_id=src_id,
167
+ dst_symbol_id=dst_id,
168
+ dst_qname=dst_qname,
169
+ line=ref.span.start_line,
170
+ confidence=confidence,
171
+ arg_preview=ref.arg_preview,
172
+ via=via,
173
+ )
174
+
175
+ def _bind_project_callable(self, name: str) -> int | None:
176
+ local = f"{self.x.module_path}.{name}"
177
+ if local in self.table.by_fqn:
178
+ return self.table.by_fqn[local]
179
+ if name in self.import_map:
180
+ target = self.import_map[name]
181
+ if target in self.table.by_fqn:
182
+ return self.table.by_fqn[target]
183
+ return self.table.unique_by_name(name, (SymbolKind.FUNCTION, SymbolKind.METHOD))
184
+
185
+ def _bind(self, ref: RawReference) -> tuple[int | None, str, Confidence, str | None]:
186
+ # 1. import-map expansion (chase re-export chains for project targets)
187
+ first_seg = ref.callee_text.split(".", 1)[0].split("(", 1)[0]
188
+ if first_seg in self.import_map:
189
+ rest = ref.callee_text[len(first_seg):]
190
+ expanded = self.import_map[first_seg] + rest
191
+ if self.table.is_project_path(expanded):
192
+ dst_id = self.table.by_fqn.get(expanded)
193
+ if dst_id is not None:
194
+ return dst_id, expanded, Confidence.IMPORT, None
195
+ chased = self._chase_reexport(expanded)
196
+ if chased is not None:
197
+ return self.table.by_fqn[chased], chased, Confidence.IMPORT, "reexport"
198
+ # project target: missing -> healable NULL edge
199
+ return None, expanded, Confidence.UNRESOLVED, None
200
+ qname = f"{self.prefix}:{expanded}"
201
+ return self.externals.get_or_create(qname), qname, Confidence.IMPORT, None
202
+
203
+ # 2. module-local bare name
204
+ if ref.receiver_text is None:
205
+ local = f"{self.x.module_path}.{ref.callee_name}"
206
+ dst_id = self.table.by_fqn.get(local)
207
+ if dst_id is not None:
208
+ return dst_id, local, Confidence.EXACT, None
209
+
210
+ # 3. self/cls/this receiver -> method on the enclosing class, then up the
211
+ # full ancestor chain (transitive, cycle-safe; see hierarchy.py).
212
+ if ref.receiver_text in _SELF_RECEIVERS and ref.caller_qualified_name:
213
+ class_fqn = ref.caller_qualified_name.rsplit(".", 1)[0]
214
+ if self.table.kinds.get(self.table.by_fqn.get(class_fqn, -1)) == SymbolKind.CLASS:
215
+ candidate = f"{class_fqn}.{ref.callee_name}"
216
+ dst_id = self.table.by_fqn.get(candidate)
217
+ if dst_id is not None:
218
+ return dst_id, candidate, Confidence.EXACT, None
219
+ for base_fqn in ancestors(class_fqn, self.table):
220
+ candidate = f"{base_fqn}.{ref.callee_name}"
221
+ dst_id = self.table.by_fqn.get(candidate)
222
+ if dst_id is not None:
223
+ return dst_id, candidate, Confidence.EXACT, None
224
+
225
+ # 3b. bare name via a wildcard import (`from mod import *`)
226
+ if ref.receiver_text is None:
227
+ for wmod in self.wildcard_modules:
228
+ candidate = f"{wmod}.{ref.callee_name}"
229
+ dst_id = self.table.by_fqn.get(candidate)
230
+ if dst_id is not None:
231
+ return dst_id, candidate, Confidence.IMPORT, None
232
+
233
+ # 4. unique-name fuzzy match (project symbols only, never ambiguous).
234
+ # For attribute calls with an unknown receiver (e.g. `runner.execute()`
235
+ # where runner is a local variable) this is the only way to recover the
236
+ # call-graph edge without local type inference — it's the documented
237
+ # FUZZY tradeoff.
238
+ kinds = (
239
+ (SymbolKind.METHOD,)
240
+ if ref.receiver_text is not None
241
+ else (SymbolKind.FUNCTION, SymbolKind.CLASS)
242
+ )
243
+ dst_id = self.table.unique_by_name(ref.callee_name, kinds)
244
+ if dst_id is not None:
245
+ return dst_id, self.table.qname_of[dst_id], Confidence.FUZZY, None
246
+
247
+ # 5. unresolved: language-prefixed guess, still a real node for sinks
248
+ if ref.receiver_text is not None:
249
+ guess = f"{self.prefix}:*.{ref.callee_name}"
250
+ else:
251
+ guess = f"{self.prefix}:{ref.callee_text}"
252
+ return self.externals.get_or_create(guess), guess, Confidence.UNRESOLVED, None
253
+
254
+ def _chase_reexport(self, qname: str) -> str | None:
255
+ """Follow barrel-file re-export chains to a terminal project symbol."""
256
+ module, _, name = qname.rpartition(".")
257
+ seen: set[tuple[str, str]] = set()
258
+ for _ in range(_MAX_REEXPORT_DEPTH):
259
+ if not module or (module, name) in seen:
260
+ return None
261
+ seen.add((module, name))
262
+ mapping = self.table.reexports.get(module, {})
263
+ if name in mapping:
264
+ tgt_module, tgt_name = mapping[name]
265
+ candidate = f"{tgt_module}.{tgt_name}"
266
+ if candidate in self.table.by_fqn:
267
+ return candidate
268
+ module, name = tgt_module, tgt_name
269
+ continue
270
+ for src_module in self.table.star_reexports.get(module, []):
271
+ candidate = f"{src_module}.{name}"
272
+ if candidate in self.table.by_fqn:
273
+ return candidate
274
+ return None
275
+ return None