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
cgis/core/.gitkeep
ADDED
|
File without changes
|
cgis/core/models.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Basic atoms for ontology."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NodeNamespace(StrEnum):
|
|
10
|
+
"""Classifies whether a node belongs to the ingested repo, stdlib, or third-party."""
|
|
11
|
+
|
|
12
|
+
INTERNAL = "INTERNAL"
|
|
13
|
+
STDLIB = "STDLIB"
|
|
14
|
+
EXTERNAL = "EXTERNAL"
|
|
15
|
+
UNKNOWN = "UNKNOWN"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
VIRTUAL_FILE_PATH = "EXTERNAL"
|
|
19
|
+
|
|
20
|
+
# Edge-target encoding conventions for raw (pre-resolution) targets.
|
|
21
|
+
# These mirror the raw_call: / raw_dep: prefixes used by extractors and the
|
|
22
|
+
# resolver, but apply to self-method dispatch and class-inheritance edges.
|
|
23
|
+
SELF_PREFIX = "self."
|
|
24
|
+
RAW_CLASS_PREFIX = "raw_class:"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NodeType(StrEnum):
|
|
28
|
+
"""All possible node types in the Code Graph."""
|
|
29
|
+
|
|
30
|
+
# Structural (L0/L1)
|
|
31
|
+
FILE = "FILE"
|
|
32
|
+
MODULE = "MODULE"
|
|
33
|
+
CLASS = "CLASS"
|
|
34
|
+
FUNCTION = "FUNCTION"
|
|
35
|
+
METHOD = "METHOD"
|
|
36
|
+
VARIABLE = "VARIABLE"
|
|
37
|
+
IMPORT = "IMPORT"
|
|
38
|
+
EXPORT = "EXPORT"
|
|
39
|
+
|
|
40
|
+
# Runtime/Behavioral (L1/L2)
|
|
41
|
+
API_ENDPOINT = "API_ENDPOINT"
|
|
42
|
+
ROUTE_HANDLER = "ROUTE_HANDLER"
|
|
43
|
+
DB_TABLE = "DB_TABLE"
|
|
44
|
+
DB_QUERY = "DB_QUERY"
|
|
45
|
+
EVENT = "EVENT"
|
|
46
|
+
|
|
47
|
+
# Semantic (L3/Ontology)
|
|
48
|
+
DOMAIN_CONCEPT = "DOMAIN_CONCEPT"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EdgeType(StrEnum):
|
|
52
|
+
"""All possible relationship types between nodes."""
|
|
53
|
+
|
|
54
|
+
# Structural
|
|
55
|
+
CONTAINS = "CONTAINS"
|
|
56
|
+
DECLARES = "DECLARES"
|
|
57
|
+
BELONGS_TO = "BELONGS_TO"
|
|
58
|
+
IMPORTS = "IMPORTS"
|
|
59
|
+
IMPORTS_SYMBOL = "IMPORTS_SYMBOL"
|
|
60
|
+
EXPORTS = "EXPORTS"
|
|
61
|
+
EXTENDS = "EXTENDS"
|
|
62
|
+
|
|
63
|
+
# Behavioral/Execution
|
|
64
|
+
CALLS = "CALLS"
|
|
65
|
+
INSTANTIATES = "INSTANTIATES"
|
|
66
|
+
REFERENCES = "REFERENCES"
|
|
67
|
+
USES = "USES"
|
|
68
|
+
ROUTES_TO = "ROUTES_TO"
|
|
69
|
+
TRIGGERS = "TRIGGERS"
|
|
70
|
+
EMITS = "EMITS"
|
|
71
|
+
CONSUMES = "CONSUMES"
|
|
72
|
+
DEPENDS_ON = "DEPENDS_ON"
|
|
73
|
+
|
|
74
|
+
# Semantic (The OWL-Lite Layer)
|
|
75
|
+
HANDLES = "HANDLES"
|
|
76
|
+
TRANSFORMS = "TRANSFORMS"
|
|
77
|
+
VALIDATES = "VALIDATES"
|
|
78
|
+
AUTHORIZES = "AUTHORIZES"
|
|
79
|
+
PERSISTS = "PERSISTS"
|
|
80
|
+
DOMAIN_DEPENDS_ON = "DOMAIN_DEPENDS_ON"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Node(BaseModel):
|
|
84
|
+
"""
|
|
85
|
+
Represents a single semantic entity in the code graph.
|
|
86
|
+
The 'id' must be a Fully Qualified Name (FQN).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
model_config = ConfigDict(frozen=True) # Nodes should be immutable once created
|
|
90
|
+
|
|
91
|
+
id: str = Field(..., description="Fully Qualified Name (e.g., 'pkg.mod.Class.method')")
|
|
92
|
+
type: NodeType
|
|
93
|
+
name: str
|
|
94
|
+
|
|
95
|
+
# Location metadata
|
|
96
|
+
file_path: str
|
|
97
|
+
start_line: int
|
|
98
|
+
end_line: int
|
|
99
|
+
|
|
100
|
+
# Language context
|
|
101
|
+
language: str = Field(default="python")
|
|
102
|
+
|
|
103
|
+
# Semantic enrichment (L2/L3)
|
|
104
|
+
ontology_class: str | None = Field(
|
|
105
|
+
default=None, description="Mapping to the formal ontology class"
|
|
106
|
+
)
|
|
107
|
+
domains: list[str] = Field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
# Classification
|
|
110
|
+
namespace: NodeNamespace = Field(default=NodeNamespace.INTERNAL)
|
|
111
|
+
|
|
112
|
+
# Reliability
|
|
113
|
+
confidence_score: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
114
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Edge(BaseModel):
|
|
118
|
+
"""
|
|
119
|
+
Represents a directed relationship between two nodes.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
model_config = ConfigDict(frozen=True)
|
|
123
|
+
|
|
124
|
+
id: str = Field(..., description="Unique edge identifier")
|
|
125
|
+
source: str = Field(..., description="Source Node FQN")
|
|
126
|
+
target: str = Field(..., description="Target Node FQN")
|
|
127
|
+
type: EdgeType
|
|
128
|
+
|
|
129
|
+
# Strength and certainty
|
|
130
|
+
weight: float = Field(
|
|
131
|
+
default=1.0,
|
|
132
|
+
ge=0.0,
|
|
133
|
+
description="Edge strength: 1.0 for plain edges, aggregated count (>1) on quotient edges",
|
|
134
|
+
)
|
|
135
|
+
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
136
|
+
|
|
137
|
+
# Contextual info (e.g., the code snippet or line number of the call)
|
|
138
|
+
context: str | None = None
|
|
139
|
+
file_path: str | None = None
|
|
140
|
+
line_number: int | None = None
|
cgis/extractors/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Pure AST naming and path helpers shared by the Python extractor handlers.
|
|
2
|
+
|
|
3
|
+
This is a leaf module: it imports only tree-sitter types and holds no state.
|
|
4
|
+
Every function is pure (its output depends solely on its arguments), which lets
|
|
5
|
+
the extractor's handler collaborators depend on it without import cycles.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from tree_sitter import Node as BaseNode
|
|
9
|
+
|
|
10
|
+
PYTHON_LANG = "python"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_relative_module(
|
|
14
|
+
module_fqn: str, leading_dots: int, relative_path: str, is_package: bool = False
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Resolve a relative import to an absolute FQN.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
module_fqn: FQN of the current file, e.g. "src.cgis.extractors.python_extractor"
|
|
20
|
+
leading_dots: number of leading dots in the import statement
|
|
21
|
+
relative_path: module path after the dots, e.g. "core" for "from ..core import x"
|
|
22
|
+
is_package: True when the importing file is a package ``__init__.py``. Its
|
|
23
|
+
FQN already had the ``/__init__`` suffix stripped, so it IS the
|
|
24
|
+
package — one leading dot refers to the package itself and must trim
|
|
25
|
+
one fewer segment than a regular module would.
|
|
26
|
+
|
|
27
|
+
Returns "src.cgis.core" for leading_dots=2, relative_path="core" in the example above.
|
|
28
|
+
"""
|
|
29
|
+
segments = module_fqn.split(".")
|
|
30
|
+
effective_dots = leading_dots - 1 if is_package and leading_dots > 0 else leading_dots
|
|
31
|
+
trim = min(effective_dots, len(segments))
|
|
32
|
+
if trim == 0:
|
|
33
|
+
# leading_dots == 0: not a relative import — return the module unchanged.
|
|
34
|
+
# (Guards against `segments[:-0]` == `[]`, which would drop the whole FQN.)
|
|
35
|
+
base = module_fqn
|
|
36
|
+
elif trim == len(segments):
|
|
37
|
+
base = ""
|
|
38
|
+
else:
|
|
39
|
+
base = ".".join(segments[:-trim])
|
|
40
|
+
if relative_path:
|
|
41
|
+
return f"{base}.{relative_path}" if base else relative_path
|
|
42
|
+
return base
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def file_path_to_module_fqn(file_path: str, source_root: str | None = None) -> str:
|
|
46
|
+
"""Convert a file path to a dot-separated module namespace.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
src/cgis/pipeline.py -> src.cgis.pipeline
|
|
50
|
+
src/cgis/__init__.py -> src.cgis
|
|
51
|
+
/abs/path/mod.py -> abs.path.mod
|
|
52
|
+
C:\\path\\to\\mod.py -> path.to.mod
|
|
53
|
+
|
|
54
|
+
With source_root="src":
|
|
55
|
+
src/cgis/pipeline.py -> cgis.pipeline
|
|
56
|
+
"""
|
|
57
|
+
clean = file_path
|
|
58
|
+
# Strip Windows drive letter (e.g. "C:") before normalising slashes
|
|
59
|
+
if len(clean) >= 2 and clean[1] == ":" and clean[0].isalpha():
|
|
60
|
+
clean = clean[2:]
|
|
61
|
+
clean = clean.replace("\\", "/").lstrip("/")
|
|
62
|
+
if source_root:
|
|
63
|
+
# removeprefix("./") so CI-style roots ("cgis ingest ./src") strip too
|
|
64
|
+
sr = source_root.replace("\\", "/").removeprefix("./").strip("/") + "/"
|
|
65
|
+
if clean.startswith(sr):
|
|
66
|
+
clean = clean[len(sr) :]
|
|
67
|
+
if clean.endswith(".py"):
|
|
68
|
+
clean = clean[:-3]
|
|
69
|
+
if clean.endswith("/__init__"):
|
|
70
|
+
clean = clean[:-9]
|
|
71
|
+
return clean.replace("/", ".")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_node_name(node: BaseNode | None, code_bytes: bytes) -> str:
|
|
75
|
+
"""Extract node name from name node using byte slicing."""
|
|
76
|
+
if node:
|
|
77
|
+
start, end = node.start_byte, node.end_byte
|
|
78
|
+
return code_bytes[start:end].decode("utf8", errors="replace")
|
|
79
|
+
return "unknown"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_identifier(node: BaseNode, code_bytes: bytes) -> str:
|
|
83
|
+
"""Extract name from AST node using byte slicing."""
|
|
84
|
+
if node.type == "identifier":
|
|
85
|
+
start, end = node.start_byte, node.end_byte
|
|
86
|
+
return code_bytes[start:end].decode("utf8", errors="replace")
|
|
87
|
+
if node.type == "parenthesized_expression":
|
|
88
|
+
for child in node.children:
|
|
89
|
+
if child.type not in ("(", ")", "comment"):
|
|
90
|
+
return get_identifier(child, code_bytes)
|
|
91
|
+
if node.type in ("attribute", "call", "subscript"):
|
|
92
|
+
return extract_nested_name(node, code_bytes)
|
|
93
|
+
return "unknown"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def extract_object_attr_name(node: BaseNode, code_bytes: bytes) -> str:
|
|
97
|
+
"""Extract identifier from object/attribute nodes."""
|
|
98
|
+
obj_node = node.child_by_field_name("object")
|
|
99
|
+
attr_node = node.child_by_field_name("attribute")
|
|
100
|
+
if obj_node and attr_node:
|
|
101
|
+
obj_id = get_identifier(obj_node, code_bytes)
|
|
102
|
+
attr_id = get_identifier(attr_node, code_bytes)
|
|
103
|
+
defined = obj_id != "unknown" and attr_id != "unknown"
|
|
104
|
+
return f"{obj_id}.{attr_id}" if defined else "unknown"
|
|
105
|
+
if attr_node:
|
|
106
|
+
return get_identifier(attr_node, code_bytes)
|
|
107
|
+
return "unknown"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def extract_nested_name(node: BaseNode, code_bytes: bytes) -> str:
|
|
111
|
+
"""Extract nested identifier from attribute/call/subscript nodes."""
|
|
112
|
+
if node.type == "attribute":
|
|
113
|
+
return extract_object_attr_name(node, code_bytes)
|
|
114
|
+
if node.type == "call":
|
|
115
|
+
func_node = node.child_by_field_name("function")
|
|
116
|
+
if func_node:
|
|
117
|
+
return get_identifier(func_node, code_bytes)
|
|
118
|
+
elif node.type == "subscript":
|
|
119
|
+
value_node = node.child_by_field_name("value")
|
|
120
|
+
if value_node:
|
|
121
|
+
return get_identifier(value_node, code_bytes)
|
|
122
|
+
return "unknown"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_fqn_prefix(node: BaseNode, code_bytes: bytes) -> str | None:
|
|
126
|
+
"""Traverse up to find class and function names, returning them joined by dots."""
|
|
127
|
+
parts = []
|
|
128
|
+
curr = node.parent
|
|
129
|
+
extract_types = ("class_definition", "function_definition", "async_function_definition")
|
|
130
|
+
while curr:
|
|
131
|
+
if curr.type in extract_types:
|
|
132
|
+
name_node = curr.child_by_field_name("name")
|
|
133
|
+
parts.append(extract_node_name(name_node, code_bytes))
|
|
134
|
+
curr = curr.parent
|
|
135
|
+
return ".".join(reversed(parts)) if parts else None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_id(node: BaseNode, code_bytes: bytes, file_path: str, source_root: str | None) -> str:
|
|
139
|
+
"""Generate a fully qualified function/method ID including class/function context."""
|
|
140
|
+
name = node.child_by_field_name("name")
|
|
141
|
+
node_name = extract_node_name(name, code_bytes)
|
|
142
|
+
prefix = get_fqn_prefix(node, code_bytes)
|
|
143
|
+
module = file_path_to_module_fqn(file_path, source_root)
|
|
144
|
+
if not module:
|
|
145
|
+
return f"{prefix}.{node_name}" if prefix else node_name
|
|
146
|
+
return f"{module}.{prefix}.{node_name}" if prefix else f"{module}.{node_name}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_method(node: BaseNode) -> bool:
|
|
150
|
+
"""Check if a function node is a method (defined inside a class)."""
|
|
151
|
+
curr = node.parent
|
|
152
|
+
while curr:
|
|
153
|
+
if curr.type == "class_definition":
|
|
154
|
+
return True
|
|
155
|
+
if curr.type in ("function_definition", "async_function_definition"):
|
|
156
|
+
return False
|
|
157
|
+
curr = curr.parent
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_module_level_assignment(
|
|
162
|
+
node: BaseNode, code_bytes: bytes, current_func_node: object | None
|
|
163
|
+
) -> bool:
|
|
164
|
+
"""True for assignments at true module level (not function, not class body)."""
|
|
165
|
+
return (
|
|
166
|
+
node.type == "assignment"
|
|
167
|
+
and current_func_node is None
|
|
168
|
+
and get_fqn_prefix(node, code_bytes) is None
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_decorator_name(decorator_node: BaseNode, code_bytes: bytes) -> str | None:
|
|
173
|
+
"""Return the decorator's callable name from a single decorator node, or None.
|
|
174
|
+
|
|
175
|
+
Skips the leading '@' token and any comments; passes the first real expression
|
|
176
|
+
node to get_identifier, which handles identifier / attribute / call / PEP-614
|
|
177
|
+
parenthesized expressions.
|
|
178
|
+
"""
|
|
179
|
+
for inner in decorator_node.children:
|
|
180
|
+
if inner.type not in ("@", "comment"):
|
|
181
|
+
name = get_identifier(inner, code_bytes)
|
|
182
|
+
return name if name != "unknown" else None
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def extract_decorator_names(node: BaseNode, code_bytes: bytes) -> list[str]:
|
|
187
|
+
"""Extract decorator names from a decorated_definition node."""
|
|
188
|
+
names: list[str] = []
|
|
189
|
+
for child in node.children:
|
|
190
|
+
if child.type == "decorator":
|
|
191
|
+
name = get_decorator_name(child, code_bytes)
|
|
192
|
+
if name:
|
|
193
|
+
names.append(name)
|
|
194
|
+
return names
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Class-definition handling for the Python extractor.
|
|
2
|
+
|
|
3
|
+
Emits CLASS nodes plus their CONTAINS/EXTENDS structural edges and the
|
|
4
|
+
abstract-class / metaclass metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tree_sitter import Node as BaseNode
|
|
11
|
+
|
|
12
|
+
from cgis.core.models import Edge, EdgeType, Node, NodeType
|
|
13
|
+
from cgis.extractors._python_ast import (
|
|
14
|
+
PYTHON_LANG,
|
|
15
|
+
extract_node_name,
|
|
16
|
+
get_id,
|
|
17
|
+
get_identifier,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_ABC_NAMES: frozenset[str] = frozenset({"ABC", "ABCMeta"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ClassHandler:
|
|
24
|
+
"""Extracts CLASS nodes and their inheritance metadata from class AST nodes."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, pick_source_root: Callable[[str], str | None]) -> None:
|
|
27
|
+
"""Store the per-file source-root picker used for FQN construction."""
|
|
28
|
+
self._pick_source_root = pick_source_root
|
|
29
|
+
|
|
30
|
+
def process_class_node(
|
|
31
|
+
self,
|
|
32
|
+
node: BaseNode,
|
|
33
|
+
code_bytes: bytes,
|
|
34
|
+
file_path: str,
|
|
35
|
+
nodes: list[Node],
|
|
36
|
+
edges: list[Edge],
|
|
37
|
+
module_fqn: str,
|
|
38
|
+
decorators: list[str] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Process class definition node."""
|
|
41
|
+
child = node.child_by_field_name("name")
|
|
42
|
+
node_id = get_id(node, code_bytes, file_path, self._pick_source_root(file_path))
|
|
43
|
+
node_name = extract_node_name(child, code_bytes)
|
|
44
|
+
|
|
45
|
+
superclass_names = self._collect_superclasses(node, node_id, file_path, code_bytes, edges)
|
|
46
|
+
|
|
47
|
+
metadata: dict[str, Any] = {}
|
|
48
|
+
if decorators:
|
|
49
|
+
metadata["decorators"] = decorators
|
|
50
|
+
if self._is_abstract_class(superclass_names, decorators):
|
|
51
|
+
metadata["is_abstract"] = True
|
|
52
|
+
|
|
53
|
+
nodes.append(
|
|
54
|
+
Node(
|
|
55
|
+
id=node_id,
|
|
56
|
+
type=NodeType.CLASS,
|
|
57
|
+
name=node_name,
|
|
58
|
+
file_path=file_path,
|
|
59
|
+
start_line=node.start_point.row + 1,
|
|
60
|
+
end_line=node.end_point.row + 1,
|
|
61
|
+
language=PYTHON_LANG,
|
|
62
|
+
metadata=metadata,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
parts = node_id.rsplit(".", maxsplit=1)
|
|
67
|
+
parent_fqn = parts[0] if len(parts) > 1 else module_fqn
|
|
68
|
+
edges.append(
|
|
69
|
+
Edge(
|
|
70
|
+
id=f"{parent_fqn}:structural:{node_id}",
|
|
71
|
+
type=EdgeType.CONTAINS,
|
|
72
|
+
source=parent_fqn,
|
|
73
|
+
target=node_id,
|
|
74
|
+
confidence=1.0,
|
|
75
|
+
file_path=file_path,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _collect_superclasses(
|
|
80
|
+
self,
|
|
81
|
+
node: BaseNode,
|
|
82
|
+
node_id: str,
|
|
83
|
+
file_path: str,
|
|
84
|
+
code_bytes: bytes,
|
|
85
|
+
edges: list[Edge],
|
|
86
|
+
) -> list[str]:
|
|
87
|
+
"""Collect superclass names and emit EXTENDS edges for a class definition node."""
|
|
88
|
+
names: list[str] = []
|
|
89
|
+
superclasses_node = node.child_by_field_name("superclasses")
|
|
90
|
+
if not superclasses_node:
|
|
91
|
+
return names
|
|
92
|
+
for sc in superclasses_node.children:
|
|
93
|
+
if sc.type in ("identifier", "attribute", "subscript"):
|
|
94
|
+
sc_name = get_identifier(sc, code_bytes)
|
|
95
|
+
if sc_name != "unknown":
|
|
96
|
+
names.append(sc_name)
|
|
97
|
+
edges.append(
|
|
98
|
+
Edge(
|
|
99
|
+
id=f"{node_id}:extends:{sc_name}",
|
|
100
|
+
type=EdgeType.EXTENDS,
|
|
101
|
+
source=node_id,
|
|
102
|
+
target=f"raw_class:{sc_name}",
|
|
103
|
+
confidence=1.0,
|
|
104
|
+
file_path=file_path,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
elif sc.type == "keyword_argument":
|
|
108
|
+
meta_name = self._extract_metaclass_name(sc, code_bytes)
|
|
109
|
+
if meta_name:
|
|
110
|
+
names.append(meta_name)
|
|
111
|
+
return names
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _is_abstract_class(superclass_names: list[str], decorators: list[str] | None) -> bool:
|
|
115
|
+
"""Return True if class should be marked abstract (ABC/ABCMeta in bases or decorators)."""
|
|
116
|
+
candidates = (*superclass_names, *(decorators or []))
|
|
117
|
+
return any(n in _ABC_NAMES or n.rpartition(".")[-1] in _ABC_NAMES for n in candidates)
|
|
118
|
+
|
|
119
|
+
def _extract_metaclass_name(self, node: BaseNode, code_bytes: bytes) -> str | None:
|
|
120
|
+
"""Return the metaclass name from a keyword_argument node like `metaclass=ABCMeta`."""
|
|
121
|
+
name_node = node.child_by_field_name("name")
|
|
122
|
+
value_node = node.child_by_field_name("value")
|
|
123
|
+
if name_node and value_node and get_identifier(name_node, code_bytes) == "metaclass":
|
|
124
|
+
meta_name = get_identifier(value_node, code_bytes)
|
|
125
|
+
return meta_name if meta_name != "unknown" else None
|
|
126
|
+
return None
|