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.
- entrygraph/__init__.py +87 -0
- entrygraph/__main__.py +8 -0
- entrygraph/_version.py +24 -0
- entrygraph/api.py +549 -0
- entrygraph/cli/__init__.py +0 -0
- entrygraph/cli/main.py +387 -0
- entrygraph/cli/render.py +136 -0
- entrygraph/data/sinks/csharp.toml +106 -0
- entrygraph/data/sinks/go.toml +87 -0
- entrygraph/data/sinks/java.toml +92 -0
- entrygraph/data/sinks/javascript.toml +112 -0
- entrygraph/data/sinks/lib_javascript.toml +34 -0
- entrygraph/data/sinks/lib_python.toml +39 -0
- entrygraph/data/sinks/php.toml +125 -0
- entrygraph/data/sinks/python.toml +160 -0
- entrygraph/data/sinks/ruby.toml +102 -0
- entrygraph/data/sinks/rust.toml +68 -0
- entrygraph/db/__init__.py +0 -0
- entrygraph/db/engine.py +34 -0
- entrygraph/db/meta.py +70 -0
- entrygraph/db/models.py +176 -0
- entrygraph/db/queries.py +155 -0
- entrygraph/detect/__init__.py +0 -0
- entrygraph/detect/entrypoints/__init__.py +22 -0
- entrygraph/detect/entrypoints/base.py +124 -0
- entrygraph/detect/entrypoints/configs.py +139 -0
- entrygraph/detect/entrypoints/csharp.py +156 -0
- entrygraph/detect/entrypoints/golang.py +158 -0
- entrygraph/detect/entrypoints/java.py +187 -0
- entrygraph/detect/entrypoints/javascript.py +211 -0
- entrygraph/detect/entrypoints/php.py +133 -0
- entrygraph/detect/entrypoints/python.py +335 -0
- entrygraph/detect/entrypoints/ruby.py +147 -0
- entrygraph/detect/entrypoints/rust.py +153 -0
- entrygraph/detect/frameworks.py +369 -0
- entrygraph/detect/manifests.py +234 -0
- entrygraph/detect/taint.py +224 -0
- entrygraph/errors.py +27 -0
- entrygraph/extract/__init__.py +0 -0
- entrygraph/extract/base.py +51 -0
- entrygraph/extract/csharp.py +502 -0
- entrygraph/extract/golang.py +342 -0
- entrygraph/extract/ir.py +105 -0
- entrygraph/extract/java.py +329 -0
- entrygraph/extract/javascript.py +400 -0
- entrygraph/extract/php.py +426 -0
- entrygraph/extract/python.py +390 -0
- entrygraph/extract/registry.py +43 -0
- entrygraph/extract/ruby.py +321 -0
- entrygraph/extract/rust.py +482 -0
- entrygraph/fs/__init__.py +0 -0
- entrygraph/fs/hashing.py +78 -0
- entrygraph/fs/lang.py +134 -0
- entrygraph/fs/walker.py +167 -0
- entrygraph/graph/__init__.py +0 -0
- entrygraph/graph/adjacency.py +146 -0
- entrygraph/graph/cte.py +123 -0
- entrygraph/graph/scoring.py +101 -0
- entrygraph/kinds.py +51 -0
- entrygraph/parsing/__init__.py +0 -0
- entrygraph/parsing/parsers.py +49 -0
- entrygraph/parsing/queries.py +39 -0
- entrygraph/pipeline/__init__.py +0 -0
- entrygraph/pipeline/scanner.py +506 -0
- entrygraph/pipeline/worker.py +49 -0
- entrygraph/pipeline/writer.py +41 -0
- entrygraph/py.typed +0 -0
- entrygraph/queries/csharp/calls.scm +4 -0
- entrygraph/queries/csharp/definitions.scm +29 -0
- entrygraph/queries/csharp/imports.scm +4 -0
- entrygraph/queries/go/calls.scm +2 -0
- entrygraph/queries/go/definitions.scm +24 -0
- entrygraph/queries/go/imports.scm +1 -0
- entrygraph/queries/java/calls.scm +2 -0
- entrygraph/queries/java/definitions.scm +14 -0
- entrygraph/queries/java/imports.scm +2 -0
- entrygraph/queries/javascript/calls.scm +4 -0
- entrygraph/queries/javascript/definitions.scm +4 -0
- entrygraph/queries/javascript/imports.scm +6 -0
- entrygraph/queries/php/calls.scm +8 -0
- entrygraph/queries/php/definitions.scm +24 -0
- entrygraph/queries/php/imports.scm +1 -0
- entrygraph/queries/python/calls.scm +2 -0
- entrygraph/queries/python/definitions.scm +11 -0
- entrygraph/queries/python/imports.scm +2 -0
- entrygraph/queries/ruby/calls.scm +4 -0
- entrygraph/queries/ruby/definitions.scm +20 -0
- entrygraph/queries/ruby/imports.scm +7 -0
- entrygraph/queries/rust/calls.scm +5 -0
- entrygraph/queries/rust/definitions.scm +26 -0
- entrygraph/queries/rust/imports.scm +4 -0
- entrygraph/resolve/__init__.py +0 -0
- entrygraph/resolve/externals.py +61 -0
- entrygraph/resolve/hierarchy.py +152 -0
- entrygraph/resolve/resolver.py +275 -0
- entrygraph/resolve/symbol_table.py +48 -0
- entrygraph/results.py +138 -0
- entrygraph-0.1.0.dist-info/METADATA +204 -0
- entrygraph-0.1.0.dist-info/RECORD +102 -0
- entrygraph-0.1.0.dist-info/WHEEL +4 -0
- entrygraph-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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,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,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,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
|
|
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
|