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,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("<", "&lt;").replace(">", "&gt;")
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."""