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.
Files changed (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. 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()
@@ -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
+ """