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,235 @@
|
|
|
1
|
+
"""Implements mermaid queries to render diagram."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from cgis.core.models import VIRTUAL_FILE_PATH, Edge, Node, NodeNamespace, NodeType
|
|
6
|
+
from cgis.extractors.python_extractor import file_path_to_module_fqn
|
|
7
|
+
|
|
8
|
+
_RAW_CALL_PREFIX = "raw_call:"
|
|
9
|
+
_UNRESOLVED_STYLE = ":::unresolvedNode"
|
|
10
|
+
|
|
11
|
+
# Bare ids that collide with Mermaid keywords break the parser (`end` closes a
|
|
12
|
+
# subgraph, etc.); such slugs — and any starting with a digit — are prefixed.
|
|
13
|
+
_MERMAID_RESERVED = frozenset(
|
|
14
|
+
{"graph", "subgraph", "end", "class", "classdef", "click", "style", "linkstyle", "direction"}
|
|
15
|
+
)
|
|
16
|
+
_NON_ID_CHARS = re.compile(r"[^0-9A-Za-z]+")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sanitize_token(text: str) -> str:
|
|
20
|
+
"""Collapse anything outside [A-Za-z0-9_] to single underscores; never empty."""
|
|
21
|
+
token = _NON_ID_CHARS.sub("_", text).strip("_")
|
|
22
|
+
return token or "node"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _guard_token(slug: str) -> str:
|
|
26
|
+
"""Prefix to dodge a leading digit or a Mermaid reserved word."""
|
|
27
|
+
if slug and (slug[0].isdigit() or slug.lower() in _MERMAID_RESERVED):
|
|
28
|
+
return f"id_{slug}"
|
|
29
|
+
return slug
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _file_stem(file_path: str) -> str:
|
|
33
|
+
"""Basename without extension, e.g. ``src/cgis/pipeline.py`` → ``pipeline``."""
|
|
34
|
+
name = file_path.replace("\\", "/").rsplit("/", maxsplit=1)[-1]
|
|
35
|
+
return name.rsplit(".", maxsplit=1)[0] or name
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fqn_slug(fqn: str) -> str:
|
|
39
|
+
"""Readable id for a bare FQN (phantom/external/raw_call): its last two segments."""
|
|
40
|
+
body = fqn.removeprefix(_RAW_CALL_PREFIX)
|
|
41
|
+
tail = "_".join(body.split(".")[-2:])
|
|
42
|
+
return _guard_token(_sanitize_token(tail))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _internal_node_slug(node_id: str, path_fqn: str) -> str | None:
|
|
46
|
+
"""Slug an internal node by matching the longest module suffix of its path FQN.
|
|
47
|
+
|
|
48
|
+
The extractor builds node ids relative to the ingest root, which may be a
|
|
49
|
+
suffix of the file-path-derived FQN (e.g. id ``cgis.pipeline.run`` when the
|
|
50
|
+
path yields ``src.cgis.pipeline``). Trying successively shorter suffixes
|
|
51
|
+
keeps the ``<file_stem>`` prefix even when the two roots differ. The stem is
|
|
52
|
+
always the deepest segment of the path FQN. Returns None on no match.
|
|
53
|
+
"""
|
|
54
|
+
parts = path_fqn.split(".")
|
|
55
|
+
stem = parts[-1]
|
|
56
|
+
for i in range(len(parts)):
|
|
57
|
+
candidate = ".".join(parts[i:])
|
|
58
|
+
if node_id == candidate:
|
|
59
|
+
return _guard_token(_sanitize_token(stem))
|
|
60
|
+
if node_id.startswith(candidate + "."):
|
|
61
|
+
suffix = node_id[len(candidate) + 1 :]
|
|
62
|
+
return _guard_token(_sanitize_token(f"{stem}_{suffix}"))
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _node_slug(node: Node) -> str:
|
|
67
|
+
"""Readable id for a node: ``<file_stem>_<Class>_<method>`` (#210).
|
|
68
|
+
|
|
69
|
+
For internal nodes the symbol suffix is peeled off the module FQN derived
|
|
70
|
+
from the file path; module/file nodes slug to the bare stem. Non-internal or
|
|
71
|
+
virtual nodes (and any that don't match the path FQN) fall back to
|
|
72
|
+
:func:`_fqn_slug`. Collisions are disambiguated later by :class:`_IdAllocator`.
|
|
73
|
+
"""
|
|
74
|
+
if node.namespace == NodeNamespace.INTERNAL and node.file_path != VIRTUAL_FILE_PATH:
|
|
75
|
+
slug = _internal_node_slug(node.id, file_path_to_module_fqn(node.file_path))
|
|
76
|
+
if slug is not None:
|
|
77
|
+
return slug
|
|
78
|
+
return _fqn_slug(node.id)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _IdAllocator:
|
|
82
|
+
"""Hands out unique, readable, deterministic Mermaid ids for one diagram.
|
|
83
|
+
|
|
84
|
+
Same key → same id (idempotent); a fresh slug colliding with an already-used
|
|
85
|
+
one gets a numeric ``_2`` / ``_3`` … suffix, deterministic in call order.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
"""Start with empty key→id and used-id registries."""
|
|
90
|
+
self._by_key: dict[str, str] = {}
|
|
91
|
+
self._used: set[str] = set()
|
|
92
|
+
|
|
93
|
+
def _claim(self, key: str, base: str) -> str:
|
|
94
|
+
"""Return the id for ``key``, allocating ``base`` (suffixed on collision) if new."""
|
|
95
|
+
existing = self._by_key.get(key)
|
|
96
|
+
if existing is not None:
|
|
97
|
+
return existing
|
|
98
|
+
slug = base
|
|
99
|
+
counter = 2
|
|
100
|
+
while slug in self._used:
|
|
101
|
+
slug = f"{base}_{counter}"
|
|
102
|
+
counter += 1
|
|
103
|
+
self._used.add(slug)
|
|
104
|
+
self._by_key[key] = slug
|
|
105
|
+
return slug
|
|
106
|
+
|
|
107
|
+
def for_node(self, node: Node) -> str:
|
|
108
|
+
"""Allocate (or reuse) the id for a graph node."""
|
|
109
|
+
return self._claim(node.id, _node_slug(node))
|
|
110
|
+
|
|
111
|
+
def for_fqn(self, fqn: str) -> str:
|
|
112
|
+
"""Allocate (or reuse) the id for a bare FQN endpoint (phantom stub)."""
|
|
113
|
+
return self._claim(fqn, _fqn_slug(fqn))
|
|
114
|
+
|
|
115
|
+
def for_subgraph(self, file_path: str) -> str:
|
|
116
|
+
"""Allocate (or reuse) the id for a file subgraph block."""
|
|
117
|
+
return self._claim(
|
|
118
|
+
f"sg::{file_path}", _guard_token(_sanitize_token(f"sg_{_file_stem(file_path)}"))
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _escape(text: str) -> str:
|
|
123
|
+
"""Escape special characters that break Mermaid double-quoted node labels."""
|
|
124
|
+
return (
|
|
125
|
+
text.replace("\\", "\\\\").replace('"', "#quot;").replace("<", "<").replace(">", ">")
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class MermaidCompiler:
|
|
130
|
+
"""
|
|
131
|
+
Compiles a subgraph (Nodes, Edges) into highly readable, valid Mermaid.js diagrams.
|
|
132
|
+
Handles ID normalization and injects CSS classDefs for clean code visualization.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self) -> None:
|
|
136
|
+
"""Initialise with the default CSS class definitions for node styling."""
|
|
137
|
+
self._style_defs = [
|
|
138
|
+
"classDef classNode fill:#e8f5e9,stroke:#2e7d32,stroke-width:1.5px,color:#1b5e20;",
|
|
139
|
+
"classDef funcNode fill:#e3f2fd,stroke:#1565c0,stroke-width:1.5px,color:#0d47a1;",
|
|
140
|
+
"classDef methodNode fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c;",
|
|
141
|
+
(
|
|
142
|
+
"classDef unresolvedNode fill:#fffde7,stroke:#fbc02d,stroke-width:1.5px,"
|
|
143
|
+
"stroke-dasharray: 4 4,color:#f57f17;"
|
|
144
|
+
),
|
|
145
|
+
"classDef defaultNode fill:#fafafa,stroke:#9e9e9e,stroke-width:1.5px,color:#212121;",
|
|
146
|
+
"classDef stdlibNode fill:#eceff1,stroke:#607d8b,stroke-width:1px,color:#455a64;",
|
|
147
|
+
(
|
|
148
|
+
"classDef externalNode fill:#fff3e0,stroke:#e65100,stroke-width:1px,"
|
|
149
|
+
"stroke-dasharray: 3 3,color:#bf360c;"
|
|
150
|
+
),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
def _get_node_label(self, node: Node) -> str:
|
|
154
|
+
"""Formats the visible text inside a node (Name + file name + line range)."""
|
|
155
|
+
filename = node.file_path.replace("\\", "/").split("/")[-1]
|
|
156
|
+
return _escape(f"{node.name} ({filename}:{node.start_line})")
|
|
157
|
+
|
|
158
|
+
def _get_style_class(self, node: Node) -> str:
|
|
159
|
+
"""Map a node to its Mermaid CSS class suffix based on namespace and type."""
|
|
160
|
+
if node.namespace == NodeNamespace.STDLIB:
|
|
161
|
+
return ":::stdlibNode"
|
|
162
|
+
if node.namespace == NodeNamespace.EXTERNAL:
|
|
163
|
+
return ":::externalNode"
|
|
164
|
+
if node.namespace == NodeNamespace.UNKNOWN:
|
|
165
|
+
return _UNRESOLVED_STYLE
|
|
166
|
+
if node.file_path == VIRTUAL_FILE_PATH and node.namespace == NodeNamespace.INTERNAL:
|
|
167
|
+
return _UNRESOLVED_STYLE
|
|
168
|
+
if node.type == NodeType.CLASS:
|
|
169
|
+
return ":::classNode"
|
|
170
|
+
if node.type == NodeType.FUNCTION:
|
|
171
|
+
return ":::funcNode"
|
|
172
|
+
if node.type == NodeType.METHOD:
|
|
173
|
+
return ":::methodNode"
|
|
174
|
+
return ":::defaultNode"
|
|
175
|
+
|
|
176
|
+
def _render_node_line(self, node: Node, id_map: dict[str, str], indent: str) -> str:
|
|
177
|
+
"""Format a single node declaration with its label and style class."""
|
|
178
|
+
safe_id = id_map[node.id]
|
|
179
|
+
return f'{indent}{safe_id}["{self._get_node_label(node)}"]{self._get_style_class(node)}'
|
|
180
|
+
|
|
181
|
+
def _render_subgraphs(
|
|
182
|
+
self, file_groups: dict[str, list[Node]], id_map: dict[str, str], alloc: "_IdAllocator"
|
|
183
|
+
) -> list[str]:
|
|
184
|
+
"""Render nodes grouped into subgraph blocks, one block per source file."""
|
|
185
|
+
lines: list[str] = []
|
|
186
|
+
for file_path, group_nodes in file_groups.items():
|
|
187
|
+
sg_id = alloc.for_subgraph(file_path)
|
|
188
|
+
sg_label = _escape(file_path.replace("\\", "/").split("/")[-1])
|
|
189
|
+
lines.append(f' subgraph {sg_id}["{sg_label}"]')
|
|
190
|
+
lines.extend(self._render_node_line(n, id_map, " ") for n in group_nodes)
|
|
191
|
+
lines.append(" end")
|
|
192
|
+
return lines
|
|
193
|
+
|
|
194
|
+
def _render_edges(
|
|
195
|
+
self, edges: list[Edge], id_map: dict[str, str], alloc: "_IdAllocator"
|
|
196
|
+
) -> list[str]:
|
|
197
|
+
"""Render edge declarations, injecting phantom node stubs for unknown endpoints."""
|
|
198
|
+
lines: list[str] = []
|
|
199
|
+
for edge in edges:
|
|
200
|
+
source_safe = id_map.get(edge.source)
|
|
201
|
+
if not source_safe:
|
|
202
|
+
source_safe = alloc.for_fqn(edge.source)
|
|
203
|
+
lines.append(f' {source_safe}["{_escape(edge.source)}"]:::defaultNode')
|
|
204
|
+
id_map[edge.source] = source_safe
|
|
205
|
+
|
|
206
|
+
target_safe = id_map.get(edge.target)
|
|
207
|
+
if not target_safe:
|
|
208
|
+
target_safe = alloc.for_fqn(edge.target)
|
|
209
|
+
is_unresolved = edge.target.startswith(_RAW_CALL_PREFIX)
|
|
210
|
+
clean_target = _escape(edge.target.removeprefix(_RAW_CALL_PREFIX))
|
|
211
|
+
target_style = _UNRESOLVED_STYLE if is_unresolved else ":::defaultNode"
|
|
212
|
+
lines.append(f' {target_safe}["{clean_target}"]{target_style}')
|
|
213
|
+
id_map[edge.target] = target_safe
|
|
214
|
+
|
|
215
|
+
lines.append(f" {source_safe} -->|{edge.type.value}| {target_safe}")
|
|
216
|
+
return lines
|
|
217
|
+
|
|
218
|
+
def compile(self, nodes: list[Node], edges: list[Edge]) -> str:
|
|
219
|
+
"""Generates Mermaid Graph Definition, grouping nodes by file into subgraphs."""
|
|
220
|
+
lines = ["graph TD", *self._style_defs, ""]
|
|
221
|
+
|
|
222
|
+
alloc = _IdAllocator()
|
|
223
|
+
id_map = {node.id: alloc.for_node(node) for node in nodes}
|
|
224
|
+
|
|
225
|
+
file_groups: dict[str, list[Node]] = {}
|
|
226
|
+
for node in nodes:
|
|
227
|
+
file_groups.setdefault(node.file_path, []).append(node)
|
|
228
|
+
|
|
229
|
+
if len(file_groups) > 1:
|
|
230
|
+
lines.extend(self._render_subgraphs(file_groups, id_map, alloc))
|
|
231
|
+
else:
|
|
232
|
+
lines.extend(self._render_node_line(n, id_map, " ") for n in nodes)
|
|
233
|
+
|
|
234
|
+
lines.extend(self._render_edges(edges, id_map, alloc))
|
|
235
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""DuckDB analytical layer — whole-graph architectural metrics (#16).
|
|
2
|
+
|
|
3
|
+
SQLite is great for the point reads/writes of ingestion and BFS traversal, but
|
|
4
|
+
slow for full-graph aggregations (degree, coupling, God-object detection). DuckDB
|
|
5
|
+
is an embedded OLAP engine that *attaches* to the existing ``graph.db`` SQLite
|
|
6
|
+
file with zero copy and runs vectorized queries without loading the graph into
|
|
7
|
+
Python — so it scales to large monorepos where a NetworkX-in-RAM approach would
|
|
8
|
+
OOM.
|
|
9
|
+
|
|
10
|
+
DuckDB is an **optional** dependency (``pip install codegraph-brain[analytics]``):
|
|
11
|
+
importing this module is fine without it, but constructing :class:`DuckDBAnalyzer`
|
|
12
|
+
raises a clear error so the CLI can degrade gracefully.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict
|
|
20
|
+
|
|
21
|
+
from cgis.core.models import VIRTUAL_FILE_PATH, NodeType
|
|
22
|
+
|
|
23
|
+
# Optional dependency. Annotated ``Any`` (not ``Module | None``) so the import reads
|
|
24
|
+
# the same — and ``duckdb.connect(...)`` type-checks — whether or not duckdb is
|
|
25
|
+
# installed in the type-checking environment.
|
|
26
|
+
duckdb: Any
|
|
27
|
+
try:
|
|
28
|
+
import duckdb
|
|
29
|
+
except ImportError: # pragma: no cover - exercised only without the optional extra
|
|
30
|
+
duckdb = None
|
|
31
|
+
|
|
32
|
+
_DUCKDB_MISSING = (
|
|
33
|
+
"DuckDB is required for analytical metrics but is not installed. "
|
|
34
|
+
"Install it with: pip install 'codegraph-brain[analytics]' (or: uv add duckdb)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _segment_exclusion(id_expr: str, segments: Sequence[str]) -> tuple[str, list[str]]:
|
|
39
|
+
"""Build a WHERE fragment excluding FQNs that contain any of ``segments``.
|
|
40
|
+
|
|
41
|
+
A segment matches a whole dot-delimited component **anywhere** in the id, so
|
|
42
|
+
``tests`` drops both ``tests.utils.x`` and ``domains.resv.tests.test_x`` while
|
|
43
|
+
keeping ``domains.testservice`` (substring, not a segment). Returns
|
|
44
|
+
``("", [])`` when ``segments`` is empty. The fragment uses ``?`` placeholders
|
|
45
|
+
(the segments ride in as query parameters, never string-interpolated) and
|
|
46
|
+
``LIKE … ESCAPE '\\'`` with ``%``/``_``/``\\`` escaped, so a segment is matched
|
|
47
|
+
literally and the path stays injection-safe.
|
|
48
|
+
"""
|
|
49
|
+
if not segments: # reachable empty case (default ()) — and guards a None caller
|
|
50
|
+
return "", []
|
|
51
|
+
clauses: list[str] = []
|
|
52
|
+
params: list[str] = []
|
|
53
|
+
# dict.fromkeys dedupes while preserving order; skip empty/whitespace segments
|
|
54
|
+
# (they would yield a '%..%' pattern that matches no real FQN — a silent no-op).
|
|
55
|
+
for seg in dict.fromkeys(segments):
|
|
56
|
+
if not seg.strip():
|
|
57
|
+
continue
|
|
58
|
+
escaped = seg.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
59
|
+
clauses.append(f"('.' || {id_expr} || '.') NOT LIKE ? ESCAPE '\\'")
|
|
60
|
+
params.append(f"%.{escaped}.%")
|
|
61
|
+
if not clauses:
|
|
62
|
+
return "", []
|
|
63
|
+
return " AND " + " AND ".join(clauses), params
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _coupling_query(exclude_sql: str) -> str:
|
|
67
|
+
"""Coupling query with an optional segment-exclusion fragment on the node list."""
|
|
68
|
+
return f"""
|
|
69
|
+
WITH incoming AS (
|
|
70
|
+
SELECT target AS node_id, COUNT(*) AS in_deg
|
|
71
|
+
FROM edges WHERE type = 'CALLS' GROUP BY target
|
|
72
|
+
),
|
|
73
|
+
outgoing AS (
|
|
74
|
+
SELECT e.source AS node_id, COUNT(*) AS out_deg
|
|
75
|
+
FROM edges e
|
|
76
|
+
JOIN nodes t ON e.target = t.id
|
|
77
|
+
WHERE e.type = 'CALLS'
|
|
78
|
+
AND t.namespace = 'INTERNAL'
|
|
79
|
+
AND t.file_path != '{VIRTUAL_FILE_PATH}'
|
|
80
|
+
GROUP BY e.source
|
|
81
|
+
)
|
|
82
|
+
SELECT
|
|
83
|
+
n.id,
|
|
84
|
+
n.type,
|
|
85
|
+
COALESCE(i.in_deg, 0) AS in_degree,
|
|
86
|
+
COALESCE(o.out_deg, 0) AS out_degree
|
|
87
|
+
FROM nodes n
|
|
88
|
+
LEFT JOIN incoming i ON n.id = i.node_id
|
|
89
|
+
LEFT JOIN outgoing o ON n.id = o.node_id
|
|
90
|
+
WHERE n.namespace = 'INTERNAL'
|
|
91
|
+
AND n.type IN ('FUNCTION', 'METHOD')
|
|
92
|
+
AND n.file_path != '{VIRTUAL_FILE_PATH}'{exclude_sql}
|
|
93
|
+
ORDER BY (COALESCE(i.in_deg, 0) + COALESCE(o.out_deg, 0)) DESC, n.id
|
|
94
|
+
LIMIT ?
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _god_class_query(exclude_sql: str) -> str:
|
|
99
|
+
"""God-class query with an optional segment-exclusion fragment on the class id."""
|
|
100
|
+
return f"""
|
|
101
|
+
SELECT e.source AS node_id, COUNT(*) AS declares
|
|
102
|
+
FROM edges e
|
|
103
|
+
JOIN nodes n ON e.source = n.id
|
|
104
|
+
WHERE e.type = 'DECLARES' AND n.type = 'CLASS'{exclude_sql}
|
|
105
|
+
GROUP BY e.source
|
|
106
|
+
ORDER BY declares DESC, e.source
|
|
107
|
+
LIMIT ?
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Vectorized PageRank: 20 fixed iterations of the standard damped formula, run as
|
|
112
|
+
# DuckDB temp-table joins (Python only drives the loop — the data never leaves the
|
|
113
|
+
# in-memory DuckDB). Dangling nodes (no outgoing internal CALLS) redistribute their
|
|
114
|
+
# mass uniformly so rank isn't silently lost on a graph full of leaf functions.
|
|
115
|
+
_PAGERANK_DAMPING = 0.85
|
|
116
|
+
_PAGERANK_ITERATIONS = 20
|
|
117
|
+
_PAGERANK_INTERNAL = (
|
|
118
|
+
f"namespace = 'INTERNAL' AND file_path != '{VIRTUAL_FILE_PATH}' "
|
|
119
|
+
"AND type IN ('FUNCTION', 'METHOD', 'CLASS')"
|
|
120
|
+
)
|
|
121
|
+
_PAGERANK_STEP = """
|
|
122
|
+
CREATE OR REPLACE TEMP TABLE pr_next AS
|
|
123
|
+
WITH dangling AS (
|
|
124
|
+
SELECT COALESCE(SUM(r.r), 0.0) AS mass
|
|
125
|
+
FROM pr_rank r
|
|
126
|
+
WHERE r.id NOT IN (SELECT src FROM pr_out)
|
|
127
|
+
),
|
|
128
|
+
inflow AS (
|
|
129
|
+
SELECT e.dst AS id, SUM(r.r / o.deg) AS s
|
|
130
|
+
FROM pr_edges e
|
|
131
|
+
JOIN pr_rank r ON e.src = r.id
|
|
132
|
+
JOIN pr_out o ON e.src = o.src
|
|
133
|
+
GROUP BY e.dst
|
|
134
|
+
)
|
|
135
|
+
SELECT nd.id AS id,
|
|
136
|
+
? + ? * (COALESCE(i.s, 0.0) + (SELECT mass FROM dangling) / ?) AS r
|
|
137
|
+
FROM pr_nodes nd
|
|
138
|
+
LEFT JOIN inflow i ON nd.id = i.id
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class NodeMetric(BaseModel):
|
|
143
|
+
"""Per-node architectural metric: fan-in (coupling) and fan-out (complexity)."""
|
|
144
|
+
|
|
145
|
+
model_config = ConfigDict(frozen=True)
|
|
146
|
+
|
|
147
|
+
node_id: str
|
|
148
|
+
node_type: str
|
|
149
|
+
in_degree: int
|
|
150
|
+
out_degree: int
|
|
151
|
+
page_rank: float = 0.0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ArchitectureReport(BaseModel):
|
|
155
|
+
"""Whole-graph summary an agent or human can read to spot hotspots."""
|
|
156
|
+
|
|
157
|
+
model_config = ConfigDict(frozen=True)
|
|
158
|
+
|
|
159
|
+
bottlenecks: list[NodeMetric]
|
|
160
|
+
god_classes: list[NodeMetric]
|
|
161
|
+
critical: list[NodeMetric] = []
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class DuckDBAnalyzer:
|
|
165
|
+
"""Run vectorized architectural metrics over a SQLite graph via DuckDB.
|
|
166
|
+
|
|
167
|
+
Attaches read-only to the SQLite file (zero copy) so the live graph is never
|
|
168
|
+
mutated and ``database is locked`` errors are avoided. Use as a context
|
|
169
|
+
manager so the DuckDB connection is always closed.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, sqlite_db_path: str) -> None:
|
|
173
|
+
"""Open an in-memory DuckDB and attach ``sqlite_db_path`` read-only.
|
|
174
|
+
|
|
175
|
+
Raises ``RuntimeError`` if the optional duckdb dependency is missing and
|
|
176
|
+
``FileNotFoundError`` if the database file does not exist.
|
|
177
|
+
"""
|
|
178
|
+
if duckdb is None:
|
|
179
|
+
raise RuntimeError(_DUCKDB_MISSING)
|
|
180
|
+
if not Path(sqlite_db_path).is_file():
|
|
181
|
+
msg = f"Database not found: {sqlite_db_path}. Run `cgis ingest` first."
|
|
182
|
+
raise FileNotFoundError(msg)
|
|
183
|
+
self.conn = duckdb.connect(":memory:")
|
|
184
|
+
try:
|
|
185
|
+
self.conn.execute("INSTALL sqlite;")
|
|
186
|
+
self.conn.execute("LOAD sqlite;")
|
|
187
|
+
# The path can't be a bound parameter in ATTACH; single-quote-escape it
|
|
188
|
+
# (and the is_file check above) keeps the literal injection-safe.
|
|
189
|
+
safe_path = sqlite_db_path.replace("'", "''")
|
|
190
|
+
self.conn.execute(f"ATTACH '{safe_path}' AS gdb (TYPE SQLITE, READ_ONLY);")
|
|
191
|
+
self.conn.execute("USE gdb;")
|
|
192
|
+
except Exception:
|
|
193
|
+
# INSTALL/LOAD/ATTACH can fail (offline extension fetch, non-SQLite file).
|
|
194
|
+
# Close the just-opened connection so it never leaks past a failed __init__.
|
|
195
|
+
self.conn.close()
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
def __enter__(self) -> "DuckDBAnalyzer":
|
|
199
|
+
"""Enter the context manager, returning this analyzer."""
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
def __exit__(self, *exc: object) -> None:
|
|
203
|
+
"""Close the DuckDB connection on context exit."""
|
|
204
|
+
self.close()
|
|
205
|
+
|
|
206
|
+
def close(self) -> None:
|
|
207
|
+
"""Close the underlying DuckDB connection."""
|
|
208
|
+
self.conn.close()
|
|
209
|
+
|
|
210
|
+
def _rows_to_metrics(self, rows: list[tuple[Any, ...]]) -> list[NodeMetric]:
|
|
211
|
+
"""Map ``(id, type, in_degree, out_degree)`` rows to NodeMetric models."""
|
|
212
|
+
return [
|
|
213
|
+
NodeMetric(
|
|
214
|
+
node_id=str(node_id),
|
|
215
|
+
node_type=str(node_type),
|
|
216
|
+
in_degree=int(in_degree),
|
|
217
|
+
out_degree=int(out_degree),
|
|
218
|
+
)
|
|
219
|
+
for node_id, node_type, in_degree, out_degree in rows
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
def get_coupling_metrics(
|
|
223
|
+
self, limit: int = 10, exclude: Sequence[str] = ()
|
|
224
|
+
) -> list[NodeMetric]:
|
|
225
|
+
"""Top INTERNAL functions/methods by total coupling (fan-in + fan-out).
|
|
226
|
+
|
|
227
|
+
High in-degree marks a critical bottleneck (many callers); high out-degree
|
|
228
|
+
marks an over-orchestrating or complex unit. External/stdlib and
|
|
229
|
+
unresolved ``raw_call:`` targets are excluded so ``len``/``print`` noise
|
|
230
|
+
never tops the list. ``exclude`` drops any node whose FQN contains one of
|
|
231
|
+
the given dot-segments (e.g. ``["tests"]``) from the ranked list.
|
|
232
|
+
"""
|
|
233
|
+
where, params = _segment_exclusion("n.id", exclude)
|
|
234
|
+
rows = self.conn.execute(_coupling_query(where), [*params, limit]).fetchall()
|
|
235
|
+
return self._rows_to_metrics(rows)
|
|
236
|
+
|
|
237
|
+
def get_god_classes(self, limit: int = 5, exclude: Sequence[str] = ()) -> list[NodeMetric]:
|
|
238
|
+
"""Top classes by declared-member count (DECLARES fan-out).
|
|
239
|
+
|
|
240
|
+
``out_degree`` carries the number of methods/attributes the class
|
|
241
|
+
declares — a high value is the classic God-object smell. ``exclude`` drops
|
|
242
|
+
classes whose FQN contains one of the given dot-segments.
|
|
243
|
+
"""
|
|
244
|
+
where, params = _segment_exclusion("n.id", exclude)
|
|
245
|
+
rows = self.conn.execute(_god_class_query(where), [*params, limit]).fetchall()
|
|
246
|
+
return [
|
|
247
|
+
NodeMetric(
|
|
248
|
+
node_id=str(node_id),
|
|
249
|
+
node_type=NodeType.CLASS.value,
|
|
250
|
+
in_degree=0,
|
|
251
|
+
out_degree=int(declares),
|
|
252
|
+
)
|
|
253
|
+
for node_id, declares in rows
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
def get_pagerank(self, limit: int = 10, exclude: Sequence[str] = ()) -> list[NodeMetric]:
|
|
257
|
+
"""Top INTERNAL nodes by PageRank over the internal CALLS graph.
|
|
258
|
+
|
|
259
|
+
PageRank weights a node by the *transitive* importance of what reaches it,
|
|
260
|
+
not just direct in-degree, so a function called by other heavily-used
|
|
261
|
+
functions ranks above one called by many trivial leaves. External/stdlib
|
|
262
|
+
and resolver virtual pseudo-nodes are excluded.
|
|
263
|
+
|
|
264
|
+
Unlike the coupling metric (FUNCTION/METHOD only), the PageRank universe
|
|
265
|
+
also includes CLASS nodes — a constructor call is a CALLS edge to the
|
|
266
|
+
class, so a heavily-instantiated class (e.g. a core data model) is
|
|
267
|
+
legitimately central. Uncalled classes simply rest at the floor rank.
|
|
268
|
+
|
|
269
|
+
``exclude`` removes any node whose FQN contains one of the given
|
|
270
|
+
dot-segments from the PageRank universe entirely (so excluded nodes leave
|
|
271
|
+
the propagation graph, not just the final ranking).
|
|
272
|
+
|
|
273
|
+
Each row also carries its ``in_degree``/``out_degree`` **within the same
|
|
274
|
+
internal, exclude-applied CALLS graph PageRank ran on** (#237) — so a
|
|
275
|
+
high rank paired with ``in_degree == 0`` is legible as a dangling-mass
|
|
276
|
+
artifact (a leaf sink that accrued redistributed rank, not a real hub)
|
|
277
|
+
rather than a genuine centrality signal.
|
|
278
|
+
"""
|
|
279
|
+
conn = self.conn
|
|
280
|
+
where, params = _segment_exclusion("id", exclude)
|
|
281
|
+
conn.execute(
|
|
282
|
+
"CREATE OR REPLACE TEMP TABLE pr_nodes AS "
|
|
283
|
+
f"SELECT id FROM nodes WHERE {_PAGERANK_INTERNAL}{where}",
|
|
284
|
+
params,
|
|
285
|
+
)
|
|
286
|
+
n = int(conn.execute("SELECT COUNT(*) FROM pr_nodes").fetchone()[0])
|
|
287
|
+
if n == 0:
|
|
288
|
+
return []
|
|
289
|
+
conn.execute(
|
|
290
|
+
"CREATE OR REPLACE TEMP TABLE pr_edges AS "
|
|
291
|
+
"SELECT e.source AS src, e.target AS dst FROM edges e "
|
|
292
|
+
"JOIN pr_nodes s ON e.source = s.id JOIN pr_nodes d ON e.target = d.id "
|
|
293
|
+
"WHERE e.type = 'CALLS'"
|
|
294
|
+
)
|
|
295
|
+
conn.execute(
|
|
296
|
+
"CREATE OR REPLACE TEMP TABLE pr_out AS "
|
|
297
|
+
"SELECT src, COUNT(*) AS deg FROM pr_edges GROUP BY src"
|
|
298
|
+
)
|
|
299
|
+
conn.execute(
|
|
300
|
+
"CREATE OR REPLACE TEMP TABLE pr_in AS "
|
|
301
|
+
"SELECT dst, COUNT(*) AS deg FROM pr_edges GROUP BY dst"
|
|
302
|
+
)
|
|
303
|
+
conn.execute(
|
|
304
|
+
"CREATE OR REPLACE TEMP TABLE pr_rank AS SELECT id, 1.0 / ? AS r FROM pr_nodes", [n]
|
|
305
|
+
)
|
|
306
|
+
base = (1.0 - _PAGERANK_DAMPING) / n
|
|
307
|
+
for _ in range(_PAGERANK_ITERATIONS):
|
|
308
|
+
conn.execute(_PAGERANK_STEP, [base, _PAGERANK_DAMPING, n])
|
|
309
|
+
conn.execute("CREATE OR REPLACE TEMP TABLE pr_rank AS SELECT * FROM pr_next")
|
|
310
|
+
rows = conn.execute(
|
|
311
|
+
"SELECT r.id, nd.type, r.r, "
|
|
312
|
+
"COALESCE(pin.deg, 0) AS in_deg, COALESCE(pout.deg, 0) AS out_deg "
|
|
313
|
+
"FROM pr_rank r JOIN nodes nd ON r.id = nd.id "
|
|
314
|
+
"LEFT JOIN pr_in pin ON r.id = pin.dst "
|
|
315
|
+
"LEFT JOIN pr_out pout ON r.id = pout.src "
|
|
316
|
+
"ORDER BY r.r DESC, r.id LIMIT ?",
|
|
317
|
+
[limit],
|
|
318
|
+
).fetchall()
|
|
319
|
+
return [
|
|
320
|
+
NodeMetric(
|
|
321
|
+
node_id=str(i),
|
|
322
|
+
node_type=str(t),
|
|
323
|
+
in_degree=int(in_deg),
|
|
324
|
+
out_degree=int(out_deg),
|
|
325
|
+
page_rank=float(r),
|
|
326
|
+
)
|
|
327
|
+
for i, t, r, in_deg, out_deg in rows
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
def architecture_report(
|
|
331
|
+
self,
|
|
332
|
+
bottleneck_limit: int = 10,
|
|
333
|
+
god_limit: int = 5,
|
|
334
|
+
critical_limit: int = 10,
|
|
335
|
+
exclude: Sequence[str] = (),
|
|
336
|
+
) -> ArchitectureReport:
|
|
337
|
+
"""Bundle the coupling bottlenecks, God classes, and PageRank-critical nodes.
|
|
338
|
+
|
|
339
|
+
``exclude`` is threaded into all three sections — any node whose FQN
|
|
340
|
+
contains one of the given dot-segments (e.g. ``["tests"]``) is dropped.
|
|
341
|
+
"""
|
|
342
|
+
return ArchitectureReport(
|
|
343
|
+
bottlenecks=self.get_coupling_metrics(bottleneck_limit, exclude),
|
|
344
|
+
god_classes=self.get_god_classes(god_limit, exclude),
|
|
345
|
+
critical=self.get_pagerank(critical_limit, exclude),
|
|
346
|
+
)
|
cgis/resolver/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CGIS resolver package — FQN resolution and semantic uplift engine."""
|