codegraph-brain 0.6.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.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Function/method-definition handling for the Python extractor.
|
|
2
|
+
|
|
3
|
+
Emits FUNCTION/METHOD nodes plus their CALLS, DECLARES/CONTAINS, DEPENDS_ON
|
|
4
|
+
edges, and collects local type information (assignments and typed parameters)
|
|
5
|
+
used downstream for call resolution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from tree_sitter import Node as BaseNode
|
|
12
|
+
|
|
13
|
+
from cgis.core.models import Edge, EdgeType, Node, NodeType
|
|
14
|
+
from cgis.extractors._python_ast import (
|
|
15
|
+
PYTHON_LANG,
|
|
16
|
+
extract_node_name,
|
|
17
|
+
get_id,
|
|
18
|
+
get_identifier,
|
|
19
|
+
is_method,
|
|
20
|
+
)
|
|
21
|
+
from cgis.extractors._python_types import TypeResolver
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FunctionHandler:
|
|
25
|
+
"""Extracts function/method nodes, call edges and local type metadata."""
|
|
26
|
+
|
|
27
|
+
_DI_CALL_NAMES: frozenset[str] = frozenset({"Depends", "Security"})
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
pick_source_root: Callable[[str], str | None],
|
|
32
|
+
type_resolver: TypeResolver,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Store the source-root picker and the shared type resolver."""
|
|
35
|
+
self._pick_source_root = pick_source_root
|
|
36
|
+
self._types = type_resolver
|
|
37
|
+
|
|
38
|
+
def process_function_node(
|
|
39
|
+
self,
|
|
40
|
+
node: BaseNode,
|
|
41
|
+
code_bytes: bytes,
|
|
42
|
+
file_path: str,
|
|
43
|
+
nodes: list[Node],
|
|
44
|
+
edges: list[Edge],
|
|
45
|
+
module_fqn: str,
|
|
46
|
+
decorators: list[str] | None = None,
|
|
47
|
+
) -> Node:
|
|
48
|
+
"""Process function or method definition node."""
|
|
49
|
+
child = node.child_by_field_name("name")
|
|
50
|
+
node_id = get_id(node, code_bytes, file_path, self._pick_source_root(file_path))
|
|
51
|
+
node_name = extract_node_name(child, code_bytes)
|
|
52
|
+
node_type = NodeType.METHOD if is_method(node) else NodeType.FUNCTION
|
|
53
|
+
|
|
54
|
+
metadata: dict[str, Any] = {}
|
|
55
|
+
if decorators:
|
|
56
|
+
metadata["decorators"] = decorators
|
|
57
|
+
if decorators and any(
|
|
58
|
+
d == "abstractmethod" or d.endswith(".abstractmethod") for d in decorators
|
|
59
|
+
):
|
|
60
|
+
metadata["is_abstract"] = True
|
|
61
|
+
|
|
62
|
+
func_node = Node(
|
|
63
|
+
id=node_id,
|
|
64
|
+
type=node_type,
|
|
65
|
+
name=node_name,
|
|
66
|
+
file_path=file_path,
|
|
67
|
+
start_line=node.start_point.row + 1,
|
|
68
|
+
end_line=node.end_point.row + 1,
|
|
69
|
+
language=PYTHON_LANG,
|
|
70
|
+
metadata=metadata,
|
|
71
|
+
)
|
|
72
|
+
nodes.append(func_node)
|
|
73
|
+
|
|
74
|
+
edges.extend(
|
|
75
|
+
Edge(
|
|
76
|
+
id=f"{node_id}:decorator:{i}:{deco_name}",
|
|
77
|
+
type=EdgeType.CALLS,
|
|
78
|
+
source=node_id,
|
|
79
|
+
target=f"raw_call:{deco_name}",
|
|
80
|
+
confidence=0.5,
|
|
81
|
+
file_path=file_path,
|
|
82
|
+
)
|
|
83
|
+
for i, deco_name in enumerate(decorators or [])
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
parts = node_id.rsplit(".", maxsplit=1)
|
|
87
|
+
parent_fqn = parts[0] if len(parts) > 1 else module_fqn
|
|
88
|
+
edge_type = EdgeType.DECLARES if node_type == NodeType.METHOD else EdgeType.CONTAINS
|
|
89
|
+
edges.append(
|
|
90
|
+
Edge(
|
|
91
|
+
id=f"{parent_fqn}:structural:{node_id}",
|
|
92
|
+
type=edge_type,
|
|
93
|
+
source=parent_fqn,
|
|
94
|
+
target=node_id,
|
|
95
|
+
confidence=1.0,
|
|
96
|
+
file_path=file_path,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return func_node
|
|
100
|
+
|
|
101
|
+
def process_call_node(
|
|
102
|
+
self, node: BaseNode, code_bytes: bytes, file_path: str, source_id: str, edges: list[Edge]
|
|
103
|
+
) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Finds call expressions node.
|
|
106
|
+
"""
|
|
107
|
+
child = node.child_by_field_name("function")
|
|
108
|
+
edge_id = f"{file_path}:edge_{node.start_byte}_{node.end_byte}"
|
|
109
|
+
if child:
|
|
110
|
+
call_name = get_identifier(child, code_bytes)
|
|
111
|
+
if call_name == "unknown":
|
|
112
|
+
return
|
|
113
|
+
target_id = f"raw_call:{call_name}"
|
|
114
|
+
|
|
115
|
+
edges.append(
|
|
116
|
+
Edge(
|
|
117
|
+
id=edge_id,
|
|
118
|
+
type=EdgeType.CALLS,
|
|
119
|
+
source=source_id,
|
|
120
|
+
target=target_id,
|
|
121
|
+
confidence=0.5,
|
|
122
|
+
context=f"Call to {call_name}",
|
|
123
|
+
file_path=file_path,
|
|
124
|
+
line_number=node.start_point.row + 1,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
if call_name in self._DI_CALL_NAMES:
|
|
128
|
+
provider = self._di_provider_name(node, code_bytes)
|
|
129
|
+
if provider:
|
|
130
|
+
edges.append(
|
|
131
|
+
Edge(
|
|
132
|
+
id=f"{file_path}:dep_{node.start_byte}_{node.end_byte}",
|
|
133
|
+
type=EdgeType.DEPENDS_ON,
|
|
134
|
+
source=source_id,
|
|
135
|
+
target=f"raw_call:{provider}",
|
|
136
|
+
confidence=0.5,
|
|
137
|
+
context=f"DI dependency on {provider}",
|
|
138
|
+
file_path=file_path,
|
|
139
|
+
line_number=node.start_point.row + 1,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _di_provider_name(self, call_node: BaseNode, code_bytes: bytes) -> str | None:
|
|
144
|
+
"""Return the first positional argument's identifier/dotted name, or None.
|
|
145
|
+
|
|
146
|
+
None for argless calls, keyword-only calls, and non-name arguments
|
|
147
|
+
(lambdas, calls, subscripts) — those emit no DEPENDS_ON edge (spec §3.2a/b).
|
|
148
|
+
|
|
149
|
+
Note: ``child_by_field_name("arguments")`` returns a truthy
|
|
150
|
+
``argument_list`` node even for ``Depends()`` (argless), so the
|
|
151
|
+
``if not args`` guard here only covers a *missing* arguments field
|
|
152
|
+
(should not occur in practice). The argless case — where
|
|
153
|
+
``args.named_children`` is empty — is handled by the loop falling
|
|
154
|
+
through to the final ``return None``.
|
|
155
|
+
"""
|
|
156
|
+
args = call_node.child_by_field_name("arguments")
|
|
157
|
+
if not args:
|
|
158
|
+
return None
|
|
159
|
+
for child in args.named_children:
|
|
160
|
+
if child.type == "keyword_argument":
|
|
161
|
+
continue
|
|
162
|
+
if child.type in ("identifier", "attribute"):
|
|
163
|
+
name = get_identifier(child, code_bytes)
|
|
164
|
+
return name if name != "unknown" else None
|
|
165
|
+
return None
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def _find_di_calls(self, node: BaseNode, code_bytes: bytes) -> list[BaseNode]:
|
|
169
|
+
"""Return all call nodes in the subtree whose callee is a DI name (spec §3.2a)."""
|
|
170
|
+
found: list[BaseNode] = []
|
|
171
|
+
stack = [node]
|
|
172
|
+
while stack:
|
|
173
|
+
curr = stack.pop()
|
|
174
|
+
if curr.type == "call":
|
|
175
|
+
fn = curr.child_by_field_name("function")
|
|
176
|
+
if fn and get_identifier(fn, code_bytes) in self._DI_CALL_NAMES:
|
|
177
|
+
found.append(curr)
|
|
178
|
+
stack.extend(curr.children)
|
|
179
|
+
return found
|
|
180
|
+
|
|
181
|
+
def process_module_assignment(
|
|
182
|
+
self,
|
|
183
|
+
node: BaseNode,
|
|
184
|
+
code_bytes: bytes,
|
|
185
|
+
file_path: str,
|
|
186
|
+
nodes: list[Node],
|
|
187
|
+
edges: list[Edge],
|
|
188
|
+
module_fqn: str,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Emit a VARIABLE alias node + DEPENDS_ON edges for module-level DI assignments.
|
|
191
|
+
|
|
192
|
+
Only fires for `Name = <RHS containing Depends/Security>` with a plain
|
|
193
|
+
identifier LHS at true module level (class bodies excluded by the
|
|
194
|
+
caller). Plain constants never reach the node list (spec §3.2a).
|
|
195
|
+
"""
|
|
196
|
+
left = node.child_by_field_name("left")
|
|
197
|
+
right = node.child_by_field_name("right")
|
|
198
|
+
if not left or not right or left.type != "identifier":
|
|
199
|
+
return
|
|
200
|
+
di_calls = self._find_di_calls(right, code_bytes)
|
|
201
|
+
if not di_calls:
|
|
202
|
+
return
|
|
203
|
+
name = get_identifier(left, code_bytes)
|
|
204
|
+
if name == "unknown":
|
|
205
|
+
return
|
|
206
|
+
alias_id = f"{module_fqn}.{name}" if module_fqn else name
|
|
207
|
+
nodes.append(
|
|
208
|
+
Node(
|
|
209
|
+
id=alias_id,
|
|
210
|
+
type=NodeType.VARIABLE,
|
|
211
|
+
name=name,
|
|
212
|
+
file_path=file_path,
|
|
213
|
+
start_line=node.start_point.row + 1,
|
|
214
|
+
end_line=node.end_point.row + 1,
|
|
215
|
+
language=PYTHON_LANG,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
for call in di_calls:
|
|
219
|
+
provider = self._di_provider_name(call, code_bytes)
|
|
220
|
+
if not provider:
|
|
221
|
+
continue
|
|
222
|
+
edges.append(
|
|
223
|
+
Edge(
|
|
224
|
+
id=f"{file_path}:dep_{call.start_byte}_{call.end_byte}",
|
|
225
|
+
type=EdgeType.DEPENDS_ON,
|
|
226
|
+
source=alias_id,
|
|
227
|
+
target=f"raw_call:{provider}",
|
|
228
|
+
confidence=0.5,
|
|
229
|
+
context=f"DI alias for {provider}",
|
|
230
|
+
file_path=file_path,
|
|
231
|
+
line_number=node.start_point.row + 1,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def collect_assignment_type(
|
|
236
|
+
self,
|
|
237
|
+
node: BaseNode,
|
|
238
|
+
code_bytes: bytes,
|
|
239
|
+
import_map: dict[str, str] | None,
|
|
240
|
+
func_node: Node,
|
|
241
|
+
acc: dict[str, dict[str, str]],
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Populate acc with var→FQN for `var = ClassName(...)` assignments."""
|
|
244
|
+
left_node = node.child_by_field_name("left")
|
|
245
|
+
right_node = node.child_by_field_name("right")
|
|
246
|
+
if not left_node or not right_node or right_node.type != "call":
|
|
247
|
+
return
|
|
248
|
+
var_name = get_identifier(left_node, code_bytes)
|
|
249
|
+
if var_name == "unknown":
|
|
250
|
+
return
|
|
251
|
+
func_call_node = right_node.child_by_field_name("function")
|
|
252
|
+
if not func_call_node:
|
|
253
|
+
return
|
|
254
|
+
class_name = get_identifier(func_call_node, code_bytes)
|
|
255
|
+
if class_name == "unknown":
|
|
256
|
+
return
|
|
257
|
+
if "." in class_name:
|
|
258
|
+
# Module-qualified constructor (e.g. models.Store()): keep only if prefix
|
|
259
|
+
# is a known import alias. Otherwise it's a method-call result — skip.
|
|
260
|
+
module_part, _, _ = class_name.partition(".")
|
|
261
|
+
if not import_map or module_part not in import_map:
|
|
262
|
+
return
|
|
263
|
+
acc.setdefault(func_node.id, {})[var_name] = self._types.resolve_type_fqn(
|
|
264
|
+
class_name, import_map, func_node.file_path
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def collect_param_type(
|
|
268
|
+
self,
|
|
269
|
+
node: BaseNode,
|
|
270
|
+
code_bytes: bytes,
|
|
271
|
+
import_map: dict[str, str] | None,
|
|
272
|
+
func_node: Node,
|
|
273
|
+
acc: dict[str, dict[str, str]],
|
|
274
|
+
edges: list[Edge],
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Populate acc with param→FQN for typed parameter annotations.
|
|
277
|
+
|
|
278
|
+
Also emits a speculative `raw_dep:` DEPENDS_ON candidate per typed
|
|
279
|
+
parameter; the resolver keeps it only when it resolves to a DI alias
|
|
280
|
+
(VARIABLE node) and drops it otherwise (spec §3.2c).
|
|
281
|
+
"""
|
|
282
|
+
if not node.named_children:
|
|
283
|
+
return
|
|
284
|
+
name_node = node.named_children[0]
|
|
285
|
+
type_node = node.child_by_field_name("type")
|
|
286
|
+
if not type_node:
|
|
287
|
+
return
|
|
288
|
+
var_name = get_identifier(name_node, code_bytes)
|
|
289
|
+
if var_name == "unknown":
|
|
290
|
+
return
|
|
291
|
+
# Slice raw bytes to capture union/generic types like `A | None` or `list[X]`
|
|
292
|
+
raw_type = (
|
|
293
|
+
code_bytes[type_node.start_byte : type_node.end_byte].decode("utf-8").strip("\"'")
|
|
294
|
+
)
|
|
295
|
+
clean_type = self._types.clean_python_type_string(raw_type)
|
|
296
|
+
if not clean_type:
|
|
297
|
+
return
|
|
298
|
+
acc.setdefault(func_node.id, {})[var_name] = self._types.resolve_type_fqn(
|
|
299
|
+
clean_type, import_map, func_node.file_path
|
|
300
|
+
)
|
|
301
|
+
edges.append(
|
|
302
|
+
Edge(
|
|
303
|
+
id=f"{func_node.file_path}:rawdep_{node.start_byte}_{node.end_byte}",
|
|
304
|
+
type=EdgeType.DEPENDS_ON,
|
|
305
|
+
source=func_node.id,
|
|
306
|
+
target=f"raw_dep:{clean_type}",
|
|
307
|
+
confidence=0.1,
|
|
308
|
+
context=f"Annotation candidate {clean_type}",
|
|
309
|
+
file_path=func_node.file_path,
|
|
310
|
+
line_number=node.start_point.row + 1,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Import-statement handling for the Python extractor.
|
|
2
|
+
|
|
3
|
+
Walks ``import`` / ``import_from`` AST nodes, builds the per-file ``import_map``
|
|
4
|
+
(local name -> target FQN) and emits IMPORTS / IMPORTS_SYMBOL edges.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from tree_sitter import Node as BaseNode
|
|
8
|
+
|
|
9
|
+
from cgis.core.models import Edge, EdgeType
|
|
10
|
+
from cgis.extractors._python_ast import resolve_relative_module
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImportHandler:
|
|
14
|
+
"""Extracts import edges and populates the import map from import AST nodes."""
|
|
15
|
+
|
|
16
|
+
def handle(
|
|
17
|
+
self,
|
|
18
|
+
node: BaseNode,
|
|
19
|
+
code_bytes: bytes,
|
|
20
|
+
file_path: str,
|
|
21
|
+
import_map: dict[str, str] | None,
|
|
22
|
+
module_fqn: str | None,
|
|
23
|
+
edges: list[Edge],
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Dispatch import or import-from AST nodes to the appropriate handler."""
|
|
26
|
+
if import_map is None or module_fqn is None:
|
|
27
|
+
return
|
|
28
|
+
if node.type == "import_statement":
|
|
29
|
+
self._process_import_statement(
|
|
30
|
+
node, code_bytes, module_fqn, file_path, import_map, edges
|
|
31
|
+
)
|
|
32
|
+
else:
|
|
33
|
+
self._process_import_from_statement(
|
|
34
|
+
node, code_bytes, module_fqn, file_path, import_map, edges
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _process_import_statement(
|
|
38
|
+
self,
|
|
39
|
+
node: BaseNode,
|
|
40
|
+
code_bytes: bytes,
|
|
41
|
+
module_fqn: str,
|
|
42
|
+
file_path: str,
|
|
43
|
+
import_map: dict[str, str],
|
|
44
|
+
edges: list[Edge],
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Handle `import X` and `import X as Y` statements."""
|
|
47
|
+
for child in node.children:
|
|
48
|
+
if child.type == "dotted_name":
|
|
49
|
+
module_str = code_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
|
50
|
+
local_name = module_str.split(".")[0]
|
|
51
|
+
import_map[local_name] = local_name
|
|
52
|
+
edges.append(
|
|
53
|
+
Edge(
|
|
54
|
+
id=f"{module_fqn}:imports:{module_str}",
|
|
55
|
+
type=EdgeType.IMPORTS,
|
|
56
|
+
source=module_fqn,
|
|
57
|
+
target=module_str,
|
|
58
|
+
confidence=1.0,
|
|
59
|
+
file_path=file_path,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
elif child.type == "aliased_import":
|
|
63
|
+
name_node = child.child_by_field_name("name")
|
|
64
|
+
alias_node = child.child_by_field_name("alias")
|
|
65
|
+
if name_node and alias_node:
|
|
66
|
+
module_str = code_bytes[name_node.start_byte : name_node.end_byte].decode(
|
|
67
|
+
"utf-8"
|
|
68
|
+
)
|
|
69
|
+
alias = code_bytes[alias_node.start_byte : alias_node.end_byte].decode("utf-8")
|
|
70
|
+
import_map[alias] = module_str
|
|
71
|
+
edges.append(
|
|
72
|
+
Edge(
|
|
73
|
+
id=f"{module_fqn}:imports:{module_str}",
|
|
74
|
+
type=EdgeType.IMPORTS,
|
|
75
|
+
source=module_fqn,
|
|
76
|
+
target=module_str,
|
|
77
|
+
confidence=1.0,
|
|
78
|
+
file_path=file_path,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _parse_relative_import(self, node: BaseNode, code_bytes: bytes) -> tuple[int, str]:
|
|
83
|
+
"""Return (leading_dots, raw_module_str) from a relative_import node."""
|
|
84
|
+
leading_dots = 0
|
|
85
|
+
raw_module_str = ""
|
|
86
|
+
for sub in node.children:
|
|
87
|
+
if sub.type == "import_prefix":
|
|
88
|
+
leading_dots = sub.end_byte - sub.start_byte
|
|
89
|
+
elif sub.type == "dotted_name":
|
|
90
|
+
raw_module_str = code_bytes[sub.start_byte : sub.end_byte].decode("utf-8")
|
|
91
|
+
return leading_dots, raw_module_str
|
|
92
|
+
|
|
93
|
+
def _collect_imported_symbols(
|
|
94
|
+
self, children: list[BaseNode], code_bytes: bytes
|
|
95
|
+
) -> list[tuple[str, str]]:
|
|
96
|
+
"""Collect (local_name, qualified_symbol) pairs from a list of sibling nodes.
|
|
97
|
+
|
|
98
|
+
Flattens parenthesized imports (`import_list` node) transparently.
|
|
99
|
+
"""
|
|
100
|
+
items: list[BaseNode] = []
|
|
101
|
+
for child in children:
|
|
102
|
+
if child.type == "import_list":
|
|
103
|
+
items.extend(child.children)
|
|
104
|
+
else:
|
|
105
|
+
items.append(child)
|
|
106
|
+
symbols: list[tuple[str, str]] = []
|
|
107
|
+
for child in items:
|
|
108
|
+
if child.type in ("dotted_name", "identifier"):
|
|
109
|
+
sym = code_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
|
110
|
+
symbols.append((sym.split(".")[-1], sym))
|
|
111
|
+
elif child.type == "aliased_import":
|
|
112
|
+
name_node = child.child_by_field_name("name")
|
|
113
|
+
alias_node = child.child_by_field_name("alias")
|
|
114
|
+
if name_node and alias_node:
|
|
115
|
+
sym = code_bytes[name_node.start_byte : name_node.end_byte].decode("utf-8")
|
|
116
|
+
alias = code_bytes[alias_node.start_byte : alias_node.end_byte].decode("utf-8")
|
|
117
|
+
symbols.append((alias, sym))
|
|
118
|
+
# wildcard_import, punctuation → skip
|
|
119
|
+
return symbols
|
|
120
|
+
|
|
121
|
+
def _process_import_from_statement(
|
|
122
|
+
self,
|
|
123
|
+
node: BaseNode,
|
|
124
|
+
code_bytes: bytes,
|
|
125
|
+
module_fqn: str,
|
|
126
|
+
file_path: str,
|
|
127
|
+
import_map: dict[str, str],
|
|
128
|
+
edges: list[Edge],
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Handle `from X import Y [as Z]` and relative imports."""
|
|
131
|
+
leading_dots = 0
|
|
132
|
+
raw_module_str = ""
|
|
133
|
+
past_import_kw = False
|
|
134
|
+
import_symbol_node: BaseNode | None = None
|
|
135
|
+
|
|
136
|
+
for child in node.children:
|
|
137
|
+
if child.type == "relative_import":
|
|
138
|
+
leading_dots, raw_module_str = self._parse_relative_import(child, code_bytes)
|
|
139
|
+
elif child.type == "dotted_name" and not past_import_kw:
|
|
140
|
+
raw_module_str = code_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
|
141
|
+
elif child.type == "import":
|
|
142
|
+
past_import_kw = True
|
|
143
|
+
import_symbol_node = child
|
|
144
|
+
# imported symbols are siblings after the 'import' keyword — collected below
|
|
145
|
+
|
|
146
|
+
# Collect all sibling nodes that come after the 'import' keyword
|
|
147
|
+
symbols: list[tuple[str, str]] = []
|
|
148
|
+
if import_symbol_node is not None:
|
|
149
|
+
idx = node.children.index(import_symbol_node)
|
|
150
|
+
symbols = self._collect_imported_symbols(node.children[idx + 1 :], code_bytes)
|
|
151
|
+
|
|
152
|
+
norm_path = file_path.replace("\\", "/")
|
|
153
|
+
is_package = norm_path == "__init__.py" or norm_path.endswith("/__init__.py")
|
|
154
|
+
base_module = (
|
|
155
|
+
resolve_relative_module(module_fqn, leading_dots, raw_module_str, is_package=is_package)
|
|
156
|
+
if leading_dots > 0
|
|
157
|
+
else raw_module_str
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
for local_name, sym in symbols:
|
|
161
|
+
target_fqn = f"{base_module}.{sym}" if base_module else sym
|
|
162
|
+
import_map[local_name] = target_fqn
|
|
163
|
+
# Symbol-level import edge (#161 slice 2): raw_import: candidates are
|
|
164
|
+
# resolved to an existing node by the ResolverEngine or DROPPED —
|
|
165
|
+
# they never leak into output (spec §2.2). Literal prefix mirrors
|
|
166
|
+
# the raw_dep:/raw_call: convention used elsewhere in this file.
|
|
167
|
+
edges.append(
|
|
168
|
+
Edge(
|
|
169
|
+
id=f"{module_fqn}:imports_symbol:{target_fqn}",
|
|
170
|
+
type=EdgeType.IMPORTS_SYMBOL,
|
|
171
|
+
source=module_fqn,
|
|
172
|
+
target=f"raw_import:{target_fqn}",
|
|
173
|
+
confidence=0.1,
|
|
174
|
+
file_path=file_path,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if base_module:
|
|
179
|
+
edges.append(
|
|
180
|
+
Edge(
|
|
181
|
+
id=f"{module_fqn}:imports:{base_module}",
|
|
182
|
+
type=EdgeType.IMPORTS,
|
|
183
|
+
source=module_fqn,
|
|
184
|
+
target=base_module,
|
|
185
|
+
confidence=1.0,
|
|
186
|
+
file_path=file_path,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Type-annotation resolution for the Python extractor."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from cgis.extractors._python_ast import file_path_to_module_fqn
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TypeResolver:
|
|
9
|
+
"""Resolves Python type annotations to fully qualified names.
|
|
10
|
+
|
|
11
|
+
Owns the type-string cleanup (stripping Optionals/Unions/generics) and the
|
|
12
|
+
import-map lookup that turns a bare or module-prefixed type name into a FQN.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# `Annotated[T, ...]` unwraps to its first arg T (the type); the remaining
|
|
16
|
+
# args are metadata (e.g. FastAPI `Depends(...)`, captured separately as a
|
|
17
|
+
# DEPENDS_ON edge via the call-node path). See #194.
|
|
18
|
+
_GENERIC_WRAPPERS: frozenset[str] = frozenset({"Optional", "Union", "Annotated"})
|
|
19
|
+
|
|
20
|
+
def __init__(self, pick_source_root: Callable[[str], str | None]) -> None:
|
|
21
|
+
"""Store the per-file source-root picker used for FQN construction."""
|
|
22
|
+
self._pick_source_root = pick_source_root
|
|
23
|
+
|
|
24
|
+
def resolve_type_fqn(
|
|
25
|
+
self,
|
|
26
|
+
type_name: str,
|
|
27
|
+
import_map: dict[str, str] | None,
|
|
28
|
+
file_path: str,
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Resolve a type name (possibly module-prefixed) to a FQN."""
|
|
31
|
+
if import_map:
|
|
32
|
+
if type_name in import_map:
|
|
33
|
+
return import_map[type_name]
|
|
34
|
+
if "." in type_name:
|
|
35
|
+
module_part, _, rest = type_name.partition(".")
|
|
36
|
+
if module_part in import_map:
|
|
37
|
+
return f"{import_map[module_part]}.{rest}"
|
|
38
|
+
module = file_path_to_module_fqn(file_path, self._pick_source_root(file_path))
|
|
39
|
+
return f"{module}.{type_name}"
|
|
40
|
+
|
|
41
|
+
def clean_python_type_string(self, type_str: str) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Extract the main class name from type strings with Optionals/Unions.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
"SQLiteStore | None" -> "SQLiteStore"
|
|
47
|
+
"Optional[SQLiteStore]" -> "SQLiteStore"
|
|
48
|
+
"Union[SQLiteStore, None]" -> "SQLiteStore"
|
|
49
|
+
"list[Node]" -> "list"
|
|
50
|
+
"""
|
|
51
|
+
# Handle union types (PEP 604): "A | None" -> "A"
|
|
52
|
+
if " | " in type_str:
|
|
53
|
+
type_str = type_str.split(" | ", maxsplit=1)[0]
|
|
54
|
+
# Handle generic wrappers: Optional[X] -> X, Union[X, Y] -> X, list[X] -> list
|
|
55
|
+
if "[" in type_str:
|
|
56
|
+
outer, _, inner = type_str.partition("[")
|
|
57
|
+
# Support both `Optional[X]` and `typing.Optional[X]`
|
|
58
|
+
outer_base = outer.strip().split(".")[-1]
|
|
59
|
+
if outer_base in self._GENERIC_WRAPPERS:
|
|
60
|
+
# Strip only the single outermost `]` to avoid mangling nested generics
|
|
61
|
+
if inner.endswith("]"):
|
|
62
|
+
inner = inner[:-1]
|
|
63
|
+
first_arg = self._first_top_level_arg(inner)
|
|
64
|
+
return self.clean_python_type_string(first_arg)
|
|
65
|
+
type_str = outer
|
|
66
|
+
return type_str.strip()
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _first_top_level_arg(args: str) -> str:
|
|
70
|
+
"""Return the first argument of a generic's arg list.
|
|
71
|
+
|
|
72
|
+
Splits on the first comma at bracket depth 0 so nested generics keep
|
|
73
|
+
their inner commas intact, e.g. "dict[str, int], Meta" -> "dict[str, int]"
|
|
74
|
+
(a naive ``split(",")[0]`` would truncate to "dict[str").
|
|
75
|
+
"""
|
|
76
|
+
depth = 0
|
|
77
|
+
for idx, char in enumerate(args):
|
|
78
|
+
if char == "[":
|
|
79
|
+
depth += 1
|
|
80
|
+
elif char == "]":
|
|
81
|
+
depth -= 1
|
|
82
|
+
elif char == "," and depth == 0:
|
|
83
|
+
return args[:idx].strip()
|
|
84
|
+
return args.strip()
|
cgis/extractors/base.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Base Extractor Interface"""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from cgis.core.models import Edge, Node
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseExtractor(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract Base Class for all language-specific extractors.
|
|
11
|
+
|
|
12
|
+
Every extractor must implement the 'parse' method, which converts
|
|
13
|
+
raw source code into our standardized Node and Edge atoms.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, source_roots: list[str] | None = None) -> None:
|
|
17
|
+
"""Store optional source roots used to strip prefixes from FQNs."""
|
|
18
|
+
# Pre-normalize once at init; _pick_source_root is called per node during parsing.
|
|
19
|
+
self._source_roots: list[tuple[str, str]] = [
|
|
20
|
+
(sr, sr.replace("\\", "/").strip("/") + "/") for sr in (source_roots or [])
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
def _pick_source_root(self, file_path: str) -> str | None:
|
|
24
|
+
"""Return the first source root that matches file_path as a prefix, or None."""
|
|
25
|
+
clean = file_path.replace("\\", "/").lstrip("/")
|
|
26
|
+
for sr, sr_norm in self._source_roots:
|
|
27
|
+
if clean.startswith(sr_norm):
|
|
28
|
+
return sr
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def parse(self, code: str, file_path: str) -> tuple[list[Node], list[Edge]]:
|
|
33
|
+
"""
|
|
34
|
+
Parses the provided code and returns a list of Nodes and Edges.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
code: The raw source code as a string.
|
|
38
|
+
file_path: The path to the file (used for FQN construction).
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A tuple containing (list_of_nodes, list_of_edges).
|
|
42
|
+
"""
|