compass-code 0.1.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.
- compass_cli/__init__.py +0 -0
- compass_cli/commands/__init__.py +0 -0
- compass_cli/commands/ingest.py +310 -0
- compass_cli/commands/mcp.py +25 -0
- compass_cli/commands/query.py +181 -0
- compass_cli/commands/serve.py +123 -0
- compass_cli/commands/setup.py +171 -0
- compass_cli/display.py +231 -0
- compass_cli/errors.py +30 -0
- compass_cli/main.py +54 -0
- compass_cli/progress.py +191 -0
- compass_cli/result_views.py +389 -0
- compass_cli/types.py +41 -0
- compass_code-0.1.0.dist-info/METADATA +72 -0
- compass_code-0.1.0.dist-info/RECORD +137 -0
- compass_code-0.1.0.dist-info/WHEEL +4 -0
- compass_code-0.1.0.dist-info/entry_points.txt +2 -0
- compass_core/__init__.py +1 -0
- compass_core/contract/__init__.py +6 -0
- compass_core/contract/errors.py +116 -0
- compass_core/contract/models.py +204 -0
- compass_core/contract/params.py +99 -0
- compass_core/contract/results.py +370 -0
- compass_core/contract/serialize.py +59 -0
- compass_core/contract/types.py +76 -0
- compass_core/errors.py +148 -0
- compass_core/graph/__init__.py +0 -0
- compass_core/graph/db.py +205 -0
- compass_core/graph/schema.py +165 -0
- compass_core/graph/stored_ids.py +133 -0
- compass_core/graph/writer.py +482 -0
- compass_core/index/__init__.py +1 -0
- compass_core/index/freshness.py +55 -0
- compass_core/index/locate.py +155 -0
- compass_core/index/metadata.py +107 -0
- compass_core/ir/__init__.py +0 -0
- compass_core/ir/graph.py +65 -0
- compass_core/ir/models.py +75 -0
- compass_core/languages/__init__.py +1 -0
- compass_core/languages/base.py +26 -0
- compass_core/languages/python/__init__.py +6 -0
- compass_core/languages/python/extractor.py +670 -0
- compass_core/languages/python/import_resolver.py +107 -0
- compass_core/languages/registry.py +89 -0
- compass_core/logger.py +42 -0
- compass_core/phases/__init__.py +0 -0
- compass_core/phases/base.py +58 -0
- compass_core/phases/discovery.py +167 -0
- compass_core/phases/parse.py +80 -0
- compass_core/phases/persistence.py +33 -0
- compass_core/phases/pipeline.py +152 -0
- compass_core/phases/resolution.py +572 -0
- compass_core/phases/symbol_table.py +36 -0
- compass_core/phases/validation.py +76 -0
- compass_core/progress.py +45 -0
- compass_core/py.typed +0 -0
- compass_core/query/__init__.py +1 -0
- compass_core/query/cypher.py +92 -0
- compass_core/query/detail.py +241 -0
- compass_core/query/graph_map.py +127 -0
- compass_core/query/impact.py +273 -0
- compass_core/query/impact_walk.py +352 -0
- compass_core/query/overview.py +221 -0
- compass_core/query/references.py +235 -0
- compass_core/query/rows.py +378 -0
- compass_core/query/search.py +183 -0
- compass_core/query/status.py +49 -0
- compass_core/query/walk_queries.py +202 -0
- compass_core/render.py +408 -0
- compass_core/settings.py +61 -0
- compass_core/shared_types.py +78 -0
- compass_mcp/__init__.py +0 -0
- compass_mcp/guide.py +45 -0
- compass_mcp/py.typed +0 -0
- compass_mcp/server.py +261 -0
- compass_server/__init__.py +5 -0
- compass_server/errors.py +84 -0
- compass_server/main.py +42 -0
- compass_server/py.typed +0 -0
- compass_server/routers/__init__.py +0 -0
- compass_server/routers/query.py +182 -0
- compass_server/settings.py +37 -0
- compass_server/static/404.html +1 -0
- compass_server/static/_next/static/chunks/1629039a.eed2a06658ec4f9c.js +1 -0
- compass_server/static/_next/static/chunks/1ff282bf-bb7c483245264fc9.js +1 -0
- compass_server/static/_next/static/chunks/223.f881a22f446259bf.js +508 -0
- compass_server/static/_next/static/chunks/51fa5c19.6396973222c9d6fd.js +485 -0
- compass_server/static/_next/static/chunks/556.443c1682b6b20128.js +1 -0
- compass_server/static/_next/static/chunks/684-1aa0170f59baf77e.js +1 -0
- compass_server/static/_next/static/chunks/92e71660.3a42d4f70d017951.js +342 -0
- compass_server/static/_next/static/chunks/937-8692224bb6d1b605.js +1 -0
- compass_server/static/_next/static/chunks/app/_not-found/page-2c96ddf295eb6a86.js +1 -0
- compass_server/static/_next/static/chunks/app/layout-4bd0ba4a7d18aff9.js +1 -0
- compass_server/static/_next/static/chunks/app/page-5376a0a98bbd1faa.js +1 -0
- compass_server/static/_next/static/chunks/framework-96dcd958cfe08bd4.js +1 -0
- compass_server/static/_next/static/chunks/main-6dee8e626b16731a.js +1 -0
- compass_server/static/_next/static/chunks/main-app-460e8bc670818718.js +1 -0
- compass_server/static/_next/static/chunks/pages/_app-638a3e7363f92b80.js +1 -0
- compass_server/static/_next/static/chunks/pages/_error-6a72dbbb45cae743.js +1 -0
- compass_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- compass_server/static/_next/static/chunks/webpack-597057bf48ce4111.js +1 -0
- compass_server/static/_next/static/css/1b0fbfd8dd653f37.css +3 -0
- compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_buildManifest.js +1 -0
- compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_ssgManifest.js +1 -0
- compass_server/static/_next/static/media/0086f8992871c45b-s.woff2 +0 -0
- compass_server/static/_next/static/media/034d78ad42e9620c-s.woff2 +0 -0
- compass_server/static/_next/static/media/03b436aa846269de-s.woff2 +0 -0
- compass_server/static/_next/static/media/0484562807a97172-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/08aedeceaf1dcd57-s.woff2 +0 -0
- compass_server/static/_next/static/media/0aa834ed78bf6d07-s.woff2 +0 -0
- compass_server/static/_next/static/media/29a4aea02fdee119-s.woff2 +0 -0
- compass_server/static/_next/static/media/29e7bbdce9332268-s.woff2 +0 -0
- compass_server/static/_next/static/media/36008747766f78c6-s.woff2 +0 -0
- compass_server/static/_next/static/media/39969fcf98a3026e-s.woff2 +0 -0
- compass_server/static/_next/static/media/4de1fea1a954a5b6-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/5c0c2bcbaa4149ca-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/67957d42bae0796d-s.woff2 +0 -0
- compass_server/static/_next/static/media/6d38f9d4e0f4772b-s.woff2 +0 -0
- compass_server/static/_next/static/media/6d664cce900333ee-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/886030b0b59bc5a7-s.woff2 +0 -0
- compass_server/static/_next/static/media/8888a3826f4a3af4-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/939c4f875ee75fbb-s.woff2 +0 -0
- compass_server/static/_next/static/media/a1386beebedccca4-s.woff2 +0 -0
- compass_server/static/_next/static/media/ab00a911ac2adf48-s.woff2 +0 -0
- compass_server/static/_next/static/media/b957ea75a84b6ea7-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/bb3ef058b751a6ad-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/c3bc380753a8436c-s.woff2 +0 -0
- compass_server/static/_next/static/media/cce080f35d014443-s.woff2 +0 -0
- compass_server/static/_next/static/media/db911767852bc875-s.woff2 +0 -0
- compass_server/static/_next/static/media/eafabf029ad39a43-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/f10b8e9d91f3edcb-s.woff2 +0 -0
- compass_server/static/_next/static/media/f911b923c6adde36-s.woff2 +0 -0
- compass_server/static/_next/static/media/fe0777f1195381cb-s.woff2 +0 -0
- compass_server/static/index.html +1 -0
- compass_server/static/index.txt +25 -0
- compass_server/web_bundle.py +27 -0
- compass_server/web_limits.py +48 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Persist a ResolvedGraph into LadybugDB.
|
|
2
|
+
|
|
3
|
+
This module acts as a pure sink for the graph resolution process. It is
|
|
4
|
+
responsible for taking a fully resolved graph and persisting its nodes
|
|
5
|
+
and edges into the underlying graph database.
|
|
6
|
+
|
|
7
|
+
The writing process follows a strict order, ensuring nodes are created
|
|
8
|
+
before the edges that connect them:
|
|
9
|
+
1. File nodes
|
|
10
|
+
2. Class nodes
|
|
11
|
+
3. Function nodes
|
|
12
|
+
4. ExternalFunction nodes (derived from call edges to external libraries)
|
|
13
|
+
5. CONTAINS edges (File -> Class, File -> Function, Class -> Function)
|
|
14
|
+
6. IMPORTS edges
|
|
15
|
+
7. INHERITS edges
|
|
16
|
+
8. CALLS edges (Internal)
|
|
17
|
+
9. CALLS_EXTERN edges (External)
|
|
18
|
+
|
|
19
|
+
Every edge is written with ``MATCH (a ...), (b ...) CREATE ... RETURN
|
|
20
|
+
count(*)``. When an endpoint was never stored as a node the MATCH yields no
|
|
21
|
+
row and the CREATE silently does nothing, so the writer checks the returned
|
|
22
|
+
count and reports such edges as dropped instead of written.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
from compass_core.graph.db import GraphDB, as_row
|
|
32
|
+
from compass_core.graph.stored_ids import with_stored_ids
|
|
33
|
+
from compass_core.ir.graph import CallEdge, ExternalCallEdge, ImportEdge, InheritEdge, ResolvedGraph
|
|
34
|
+
from compass_core.ir.models import Definition, ParsedFile
|
|
35
|
+
from compass_core.progress import ProgressReporter
|
|
36
|
+
from compass_core.shared_types import SymbolKind
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class WriteResult:
|
|
43
|
+
"""Statistics returned after persisting a graph to the database.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
node_count: Number of nodes successfully written to the DB.
|
|
47
|
+
edge_count: Number of edges actually stored in the DB (verified per
|
|
48
|
+
statement, so it equals ``MATCH ()-[r]->() RETURN count(r)`` on
|
|
49
|
+
a fresh database).
|
|
50
|
+
dropped_edges: Number of edges in the resolved graph that could not be
|
|
51
|
+
stored, because an endpoint is not a stored node of the required
|
|
52
|
+
label (e.g. a resolved call whose callee is a class constructor:
|
|
53
|
+
CALLS only connects ``Function`` nodes) or the write failed.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
A graph with ``run() -> helper()`` and ``build() -> Widget()``
|
|
57
|
+
(``Widget`` a class) plus four CONTAINS edges yields
|
|
58
|
+
``WriteResult(node_count=..., edge_count=5, dropped_edges=1)``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
node_count: int
|
|
62
|
+
edge_count: int
|
|
63
|
+
dropped_edges: int = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _calculate_expected_nodes(graph: ResolvedGraph) -> int:
|
|
67
|
+
"""Calculate the exact number of nodes the writer will attempt to persist.
|
|
68
|
+
|
|
69
|
+
Counts one File node per file, one Class/Function/Method node per
|
|
70
|
+
definition, and one ExternalFunction node per unique ``full_ref`` across
|
|
71
|
+
all external call edges.
|
|
72
|
+
"""
|
|
73
|
+
unique_ext = {e.full_ref for e in graph.external_call_edges}
|
|
74
|
+
return sum(1 + len(f.definitions) for f in graph.files) + len(unique_ext)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _calculate_expected_edges(graph: ResolvedGraph) -> int:
|
|
78
|
+
"""Calculate the exact number of edges the writer will attempt to persist.
|
|
79
|
+
|
|
80
|
+
Counts one CONTAINS edge per class, top-level function, and method,
|
|
81
|
+
plus one edge each for every import, inheritance, internal call, and
|
|
82
|
+
external call relationship.
|
|
83
|
+
"""
|
|
84
|
+
return (
|
|
85
|
+
sum(len(f.definitions) for f in graph.files)
|
|
86
|
+
+ len(graph.import_edges)
|
|
87
|
+
+ len(graph.inherit_edges)
|
|
88
|
+
+ len(graph.call_edges)
|
|
89
|
+
+ len(graph.external_call_edges)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def write(
|
|
94
|
+
db: GraphDB,
|
|
95
|
+
graph: ResolvedGraph,
|
|
96
|
+
reporter: ProgressReporter | None = None,
|
|
97
|
+
) -> WriteResult:
|
|
98
|
+
"""Write all nodes and edges from a resolved graph into the database.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
db: An active ``GraphDB`` connection with an initialized schema.
|
|
102
|
+
graph: A ``ResolvedGraph`` with all extracted nodes and edges.
|
|
103
|
+
reporter: Optional progress reporter. Receives ``"write_nodes"``
|
|
104
|
+
updates during node insertion and ``"write_edges"`` updates
|
|
105
|
+
during edge insertion.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
WriteResult with final node and edge counts. Counts reflect rows
|
|
109
|
+
actually written — failed DB writes and edges whose endpoints do not
|
|
110
|
+
exist are not included; they are counted in ``dropped_edges``.
|
|
111
|
+
"""
|
|
112
|
+
stored = with_stored_ids(graph)
|
|
113
|
+
graph = stored.graph
|
|
114
|
+
node_total = _calculate_expected_nodes(graph)
|
|
115
|
+
edge_total = _calculate_expected_edges(graph)
|
|
116
|
+
node_count = 0
|
|
117
|
+
edge_count = 0
|
|
118
|
+
|
|
119
|
+
def _node() -> None:
|
|
120
|
+
nonlocal node_count
|
|
121
|
+
node_count += 1
|
|
122
|
+
if reporter:
|
|
123
|
+
reporter.update("write_nodes", node_count, node_total)
|
|
124
|
+
|
|
125
|
+
def _edge() -> None:
|
|
126
|
+
nonlocal edge_count
|
|
127
|
+
edge_count += 1
|
|
128
|
+
if reporter:
|
|
129
|
+
reporter.update("write_edges", edge_count, edge_total)
|
|
130
|
+
|
|
131
|
+
id_to_name: dict[str, str] = {d.id: d.name for f in graph.files for d in f.definitions}
|
|
132
|
+
|
|
133
|
+
# --- Nodes first ---
|
|
134
|
+
for pf in graph.files:
|
|
135
|
+
_write_file(db, pf)
|
|
136
|
+
_node()
|
|
137
|
+
for d in pf.definitions:
|
|
138
|
+
qualified = stored.qualified_names[d.id]
|
|
139
|
+
if d.kind is SymbolKind.CLASS:
|
|
140
|
+
_write_class(db, d, qualified)
|
|
141
|
+
else:
|
|
142
|
+
_write_function(db, d, qualified, id_to_name)
|
|
143
|
+
_node()
|
|
144
|
+
|
|
145
|
+
seen_ext: set[str] = set()
|
|
146
|
+
for ext_edge in graph.external_call_edges:
|
|
147
|
+
if ext_edge.full_ref not in seen_ext:
|
|
148
|
+
_write_external_function(db, ext_edge.full_ref, ext_edge.library)
|
|
149
|
+
seen_ext.add(ext_edge.full_ref)
|
|
150
|
+
_node()
|
|
151
|
+
|
|
152
|
+
# --- Edges second ---
|
|
153
|
+
for pf in graph.files:
|
|
154
|
+
_write_contains(db, pf, _edge)
|
|
155
|
+
|
|
156
|
+
for import_edge in graph.import_edges:
|
|
157
|
+
if _write_import_edge(db, import_edge):
|
|
158
|
+
_edge()
|
|
159
|
+
|
|
160
|
+
for inherit_edge in graph.inherit_edges:
|
|
161
|
+
if _write_inherit_edge(db, inherit_edge):
|
|
162
|
+
_edge()
|
|
163
|
+
|
|
164
|
+
for call_edge in graph.call_edges:
|
|
165
|
+
if _write_call_edge(db, call_edge):
|
|
166
|
+
_edge()
|
|
167
|
+
|
|
168
|
+
for extern_call_edge in graph.external_call_edges:
|
|
169
|
+
if _write_external_call_edge(db, extern_call_edge):
|
|
170
|
+
_edge()
|
|
171
|
+
|
|
172
|
+
dropped = edge_total - edge_count
|
|
173
|
+
if dropped:
|
|
174
|
+
logger.info(
|
|
175
|
+
"%d of %d edges were not stored (endpoint not a stored node)", dropped, edge_total
|
|
176
|
+
)
|
|
177
|
+
return WriteResult(node_count=node_count, edge_count=edge_count, dropped_edges=dropped)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Node writers
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _write_file(db: GraphDB, f: ParsedFile) -> None:
|
|
186
|
+
"""Persist a file node to the database.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
db: Active database connection.
|
|
190
|
+
f: The `ParsedFile` metadata containing the path and module info.
|
|
191
|
+
"""
|
|
192
|
+
db.execute(
|
|
193
|
+
"""
|
|
194
|
+
MERGE (n:File {path: $path})
|
|
195
|
+
ON CREATE SET n.module_name = $module_name, n.content_hash = $content_hash
|
|
196
|
+
ON MATCH SET n.module_name = $module_name, n.content_hash = $content_hash
|
|
197
|
+
""",
|
|
198
|
+
{"path": f.path, "module_name": f.module_name, "content_hash": f.content_hash},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _write_class(db: GraphDB, cls: Definition, qualified_name: str) -> None:
|
|
203
|
+
"""Persist a class node to the database.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
db: Active database connection.
|
|
207
|
+
cls: The `Definition` (kind=CLASS) metadata, keyed by its public id, including
|
|
208
|
+
1-indexed line numbers and source content.
|
|
209
|
+
qualified_name: Dotted qualified name, e.g. ``app.models.User``.
|
|
210
|
+
"""
|
|
211
|
+
db.execute(
|
|
212
|
+
"""
|
|
213
|
+
MERGE (n:Class {id: $id})
|
|
214
|
+
ON CREATE SET n.name = $name, n.qualified_name = $qualified_name,
|
|
215
|
+
n.file_path = $file_path,
|
|
216
|
+
n.line_start = $line_start, n.line_end = $line_end,
|
|
217
|
+
n.source = $source, n.docstring = $docstring,
|
|
218
|
+
n.bases = $bases
|
|
219
|
+
ON MATCH SET n.name = $name, n.qualified_name = $qualified_name,
|
|
220
|
+
n.file_path = $file_path,
|
|
221
|
+
n.line_start = $line_start, n.line_end = $line_end,
|
|
222
|
+
n.source = $source, n.docstring = $docstring,
|
|
223
|
+
n.bases = $bases
|
|
224
|
+
""",
|
|
225
|
+
{
|
|
226
|
+
"id": cls.id,
|
|
227
|
+
"name": cls.name,
|
|
228
|
+
"qualified_name": qualified_name,
|
|
229
|
+
"file_path": cls.file_path,
|
|
230
|
+
"line_start": cls.line_start,
|
|
231
|
+
"line_end": cls.line_end,
|
|
232
|
+
"source": cls.source,
|
|
233
|
+
"docstring": cls.docstring or "",
|
|
234
|
+
"bases": cls.bases,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _write_function(
|
|
240
|
+
db: GraphDB, func: Definition, qualified_name: str, id_to_name: dict[str, str]
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Persist a function or method node to the database.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
db: Active database connection.
|
|
246
|
+
func: The `Definition` (kind=FUNCTION or METHOD) metadata, keyed by its public
|
|
247
|
+
id, including 1-indexed line numbers, signature details, and decorators.
|
|
248
|
+
qualified_name: Dotted qualified name, e.g. ``app.models.User.save``.
|
|
249
|
+
id_to_name: Maps every `Definition.id` in the graph to its short name,
|
|
250
|
+
used to recover the parent class's short name from `func.parent_id`.
|
|
251
|
+
"""
|
|
252
|
+
is_method = func.kind is SymbolKind.METHOD
|
|
253
|
+
parent_class = id_to_name.get(func.parent_id, "") if func.parent_id else ""
|
|
254
|
+
db.execute(
|
|
255
|
+
"""
|
|
256
|
+
MERGE (n:Function {id: $id})
|
|
257
|
+
ON CREATE SET n.name = $name, n.qualified_name = $qualified_name,
|
|
258
|
+
n.signature = $signature,
|
|
259
|
+
n.file_path = $file_path, n.line_start = $line_start,
|
|
260
|
+
n.line_end = $line_end, n.source = $source,
|
|
261
|
+
n.docstring = $docstring, n.is_method = $is_method,
|
|
262
|
+
n.parent_class = $parent_class,
|
|
263
|
+
n.decorators = $decorators, n.raw_calls = $raw_calls
|
|
264
|
+
ON MATCH SET n.name = $name, n.qualified_name = $qualified_name,
|
|
265
|
+
n.signature = $signature,
|
|
266
|
+
n.file_path = $file_path, n.line_start = $line_start,
|
|
267
|
+
n.line_end = $line_end, n.source = $source,
|
|
268
|
+
n.docstring = $docstring, n.is_method = $is_method,
|
|
269
|
+
n.parent_class = $parent_class,
|
|
270
|
+
n.decorators = $decorators, n.raw_calls = $raw_calls
|
|
271
|
+
""",
|
|
272
|
+
{
|
|
273
|
+
"id": func.id,
|
|
274
|
+
"name": func.name,
|
|
275
|
+
"qualified_name": qualified_name,
|
|
276
|
+
"signature": func.signature or "",
|
|
277
|
+
"file_path": func.file_path,
|
|
278
|
+
"line_start": func.line_start,
|
|
279
|
+
"line_end": func.line_end,
|
|
280
|
+
"source": func.source,
|
|
281
|
+
"docstring": func.docstring or "",
|
|
282
|
+
"is_method": is_method,
|
|
283
|
+
"parent_class": parent_class,
|
|
284
|
+
"decorators": func.decorators,
|
|
285
|
+
"raw_calls": func.raw_calls,
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _write_external_function(db: GraphDB, full_ref: str, library: str) -> None:
|
|
291
|
+
"""Persist an external function node to the database.
|
|
292
|
+
|
|
293
|
+
External functions are identified during the call resolution phase
|
|
294
|
+
when a call target is found to reside in an external library.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
db: Active database connection.
|
|
298
|
+
full_ref: The fully qualified reference to the function (e.g., 'os.path.join').
|
|
299
|
+
library: The name of the external library containing the function.
|
|
300
|
+
"""
|
|
301
|
+
db.execute(
|
|
302
|
+
"""
|
|
303
|
+
MERGE (n:ExternalFunction {full_ref: $full_ref})
|
|
304
|
+
ON CREATE SET n.name = $name, n.library = $library
|
|
305
|
+
ON MATCH SET n.name = $name, n.library = $library
|
|
306
|
+
""",
|
|
307
|
+
{"full_ref": full_ref, "name": full_ref.split(".")[-1], "library": library},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# Edge writers
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _write_contains(db: GraphDB, f: ParsedFile, on_edge: Callable[[], None]) -> None:
|
|
317
|
+
"""Write structural containment edges (CONTAINS) for a file.
|
|
318
|
+
|
|
319
|
+
Establishes relationships between a file and its defined classes/functions,
|
|
320
|
+
as well as between classes and their methods. Calls ``on_edge`` once per
|
|
321
|
+
edge successfully written, enabling granular progress reporting.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
db: Active database connection.
|
|
325
|
+
f: The `ParsedFile` whose internal structure is being persisted.
|
|
326
|
+
on_edge: Callback invoked once for each edge successfully written.
|
|
327
|
+
"""
|
|
328
|
+
for d in f.definitions:
|
|
329
|
+
if d.kind is SymbolKind.CLASS:
|
|
330
|
+
if _exec(
|
|
331
|
+
db,
|
|
332
|
+
"MATCH (a:File {path: $src}), (b:Class {id: $dst}) CREATE (a)-[:CONTAINS]->(b) "
|
|
333
|
+
"RETURN count(*)",
|
|
334
|
+
{"src": f.path, "dst": d.id},
|
|
335
|
+
):
|
|
336
|
+
on_edge()
|
|
337
|
+
elif d.kind is SymbolKind.METHOD:
|
|
338
|
+
if d.parent_id is not None and _exec(
|
|
339
|
+
db,
|
|
340
|
+
"MATCH (a:Class {id: $src}), (b:Function {id: $dst}) CREATE (a)-[:CONTAINS]->(b) "
|
|
341
|
+
"RETURN count(*)",
|
|
342
|
+
{"src": d.parent_id, "dst": d.id},
|
|
343
|
+
):
|
|
344
|
+
on_edge()
|
|
345
|
+
else:
|
|
346
|
+
if _exec(
|
|
347
|
+
db,
|
|
348
|
+
"MATCH (a:File {path: $src}), (b:Function {id: $dst}) CREATE (a)-[:CONTAINS]->(b) "
|
|
349
|
+
"RETURN count(*)",
|
|
350
|
+
{"src": f.path, "dst": d.id},
|
|
351
|
+
):
|
|
352
|
+
on_edge()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _write_import_edge(db: GraphDB, edge: ImportEdge) -> bool:
|
|
356
|
+
"""Write an IMPORTS edge (File -> File).
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
db: Active database connection.
|
|
360
|
+
edge: The `ImportEdge` describing the importing and imported files.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
``True`` if the edge was stored, ``False`` if either endpoint was
|
|
364
|
+
not found or the write failed.
|
|
365
|
+
"""
|
|
366
|
+
return _exec(
|
|
367
|
+
db,
|
|
368
|
+
"MATCH (a:File {path: $src}), (b:File {path: $dst}) CREATE (a)-[:IMPORTS]->(b) "
|
|
369
|
+
"RETURN count(*)",
|
|
370
|
+
{"src": edge.from_path, "dst": edge.to_path},
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _write_inherit_edge(db: GraphDB, edge: InheritEdge) -> bool:
|
|
375
|
+
"""Write an INHERITS edge (Class -> Class).
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
db: Active database connection.
|
|
379
|
+
edge: The `InheritEdge` describing the child and parent classes.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
``True`` if the edge was stored, ``False`` if either endpoint was
|
|
383
|
+
not found or the write failed.
|
|
384
|
+
"""
|
|
385
|
+
return _exec(
|
|
386
|
+
db,
|
|
387
|
+
"MATCH (a:Class {id: $src}), (b:Class {id: $dst}) CREATE (a)-[:INHERITS]->(b) "
|
|
388
|
+
"RETURN count(*)",
|
|
389
|
+
{"src": edge.child_id, "dst": edge.parent_id},
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _write_call_edge(db: GraphDB, edge: CallEdge) -> bool:
|
|
394
|
+
"""Write a CALLS edge (Function -> Function) for an internal call.
|
|
395
|
+
|
|
396
|
+
A resolved call whose callee is a class (constructor or exception call)
|
|
397
|
+
has no ``Function`` node to match, so it is not stored and is reported
|
|
398
|
+
as dropped. OPEN DESIGN DECISION: storing calls to classes (a CALLS
|
|
399
|
+
Function -> Class relation, or mapping the call to ``__init__``) needs a
|
|
400
|
+
schema and impact-walker change and is deliberately out of scope here.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
db: Active database connection.
|
|
404
|
+
edge: The `CallEdge` describing the caller, callee, and call metadata.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
``True`` if the edge was stored, ``False`` if either endpoint was
|
|
408
|
+
not found or the write failed.
|
|
409
|
+
"""
|
|
410
|
+
return _exec(
|
|
411
|
+
db,
|
|
412
|
+
"""
|
|
413
|
+
MATCH (a:Function {id: $src}), (b:Function {id: $dst})
|
|
414
|
+
CREATE (a)-[:CALLS {confidence: $conf, call_type: $ctype, line: $line}]->(b)
|
|
415
|
+
RETURN count(*)
|
|
416
|
+
""",
|
|
417
|
+
{
|
|
418
|
+
"src": edge.caller_id,
|
|
419
|
+
"dst": edge.callee_id,
|
|
420
|
+
"conf": edge.confidence,
|
|
421
|
+
"ctype": str(edge.call_type),
|
|
422
|
+
"line": edge.line,
|
|
423
|
+
},
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _write_external_call_edge(db: GraphDB, edge: ExternalCallEdge) -> bool:
|
|
428
|
+
"""Write a CALLS_EXTERN edge (Function -> ExternalFunction).
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
db: Active database connection.
|
|
432
|
+
edge: The `ExternalCallEdge` describing the caller and external target.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
``True`` if the edge was stored, ``False`` if either endpoint was
|
|
436
|
+
not found or the write failed.
|
|
437
|
+
"""
|
|
438
|
+
return _exec(
|
|
439
|
+
db,
|
|
440
|
+
"MATCH (a:Function {id: $src}), (b:ExternalFunction {full_ref: $dst}) "
|
|
441
|
+
"CREATE (a)-[:CALLS_EXTERN {line: $line}]->(b) RETURN count(*)",
|
|
442
|
+
{
|
|
443
|
+
"src": edge.caller_id,
|
|
444
|
+
"dst": edge.full_ref,
|
|
445
|
+
"line": edge.line,
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ---------------------------------------------------------------------------
|
|
451
|
+
# Utility
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _exec(db: GraphDB, query: str, params: dict[str, object]) -> bool:
|
|
456
|
+
"""Execute an edge ``MATCH ... CREATE ... RETURN count(*)``; report if it stored one.
|
|
457
|
+
|
|
458
|
+
A MATCH that finds no endpoint yields no row, so the CREATE silently
|
|
459
|
+
does nothing and ``count(*)`` is 0 (verified against LadybugDB: a
|
|
460
|
+
matching statement returns ``[[1]]``, a non-matching one ``[[0]]``).
|
|
461
|
+
Neither a missing endpoint nor a DB error aborts the batch; both are
|
|
462
|
+
logged at DEBUG level.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
db: Active database connection.
|
|
466
|
+
query: The Cypher edge statement, ending in ``RETURN count(*)``.
|
|
467
|
+
params: Key-value pairs for query parameterization.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
``True`` if exactly one edge was created, ``False`` otherwise.
|
|
471
|
+
"""
|
|
472
|
+
try:
|
|
473
|
+
row = as_row(db.execute(query, params))
|
|
474
|
+
except Exception:
|
|
475
|
+
logger.debug(
|
|
476
|
+
"DB write failed — query: %s | params: %s", query.strip(), params, exc_info=True
|
|
477
|
+
)
|
|
478
|
+
return False
|
|
479
|
+
stored = int(row[0]) == 1
|
|
480
|
+
if not stored:
|
|
481
|
+
logger.debug("Edge not stored (endpoint missing) — params: %s", params)
|
|
482
|
+
return stored
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Locating and describing the on-disk Compass index."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Whether indexed files changed on disk since the last ingest.
|
|
2
|
+
|
|
3
|
+
Pure file-system checks with no database access: the caller supplies the indexed
|
|
4
|
+
paths (the query layer reads them from the graph) and the ingest time from
|
|
5
|
+
``metadata.json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_stale(path: Path, indexed_at: int) -> bool:
|
|
15
|
+
"""Whether a file is missing or was modified after the ingest.
|
|
16
|
+
|
|
17
|
+
Metadata stores whole seconds, so mtimes are compared at second resolution to keep
|
|
18
|
+
a just-ingested file from looking stale. Consequently an edit made within the same
|
|
19
|
+
second as the ingest is invisible (a false negative).
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
path: Absolute path of an indexed file.
|
|
23
|
+
indexed_at: Ingest time in epoch seconds.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
``True`` if the file no longer exists or its mtime is later than ``indexed_at``.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
After ``touch app/auth.py`` post-ingest,
|
|
30
|
+
``is_stale(Path("/repo/app/auth.py"), meta_indexed_at)`` returns ``True``.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
return int(path.stat().st_mtime) > indexed_at
|
|
34
|
+
except OSError:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def count_stale(repo_root: Path, paths: Iterable[str], indexed_at: int) -> int:
|
|
39
|
+
"""Count the indexed files that are missing or changed since the ingest.
|
|
40
|
+
|
|
41
|
+
Every path is stat'ed on each call, so callers may want to cache the result.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
repo_root: Absolute repository root.
|
|
45
|
+
paths: Repo-relative POSIX paths of the indexed files.
|
|
46
|
+
indexed_at: Ingest time in epoch seconds.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
How many of ``paths`` are stale.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
With ``app/auth.py`` deleted after ingest,
|
|
53
|
+
``count_stale(Path("/repo"), ["app/auth.py", "app/db.py"], t)`` returns ``1``.
|
|
54
|
+
"""
|
|
55
|
+
return sum(1 for path in paths if is_stale(repo_root / path, indexed_at))
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Locate the on-disk Compass index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from compass_core.errors import IndexNotFoundError
|
|
9
|
+
from compass_core.index.metadata import load_metadata
|
|
10
|
+
from compass_core.settings import settings
|
|
11
|
+
|
|
12
|
+
_INDEX_DIR = ".compass"
|
|
13
|
+
_DB_NAME = "graph.ladybug"
|
|
14
|
+
_META_NAME = "metadata.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
18
|
+
class IndexLocation:
|
|
19
|
+
"""Where an index lives on disk.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
repo_root: Directory that contains ``.compass/``.
|
|
23
|
+
db_path: Path to ``graph.ladybug``.
|
|
24
|
+
metadata_path: Path to ``metadata.json``.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
``find_index(Path("/repo/pkg"))`` with ``/repo/.compass/graph.ladybug`` present
|
|
28
|
+
returns ``IndexLocation(repo_root=Path("/repo"), ...)``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
repo_root: Path
|
|
32
|
+
db_path: Path
|
|
33
|
+
metadata_path: Path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def metadata_path_for(db_path: Path) -> Path:
|
|
37
|
+
"""Where the metadata of the database at ``db_path`` lives: beside it.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
db_path: Path to a ``graph.ladybug`` database.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
``db_path.parent / "metadata.json"``.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
``metadata_path_for(Path("/repo/.compass/graph.ladybug"))`` returns
|
|
47
|
+
``Path("/repo/.compass/metadata.json")``.
|
|
48
|
+
"""
|
|
49
|
+
return db_path.parent / _META_NAME
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _location(repo_root: Path) -> IndexLocation:
|
|
53
|
+
base = repo_root / _INDEX_DIR
|
|
54
|
+
return IndexLocation(
|
|
55
|
+
repo_root=repo_root, db_path=base / _DB_NAME, metadata_path=base / _META_NAME
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_index(start: Path) -> IndexLocation | None:
|
|
60
|
+
"""Walk up from ``start`` to the first directory holding ``.compass/graph.ladybug``.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
start: Directory to begin searching from.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The location, or ``None`` if none exists up to the filesystem root.
|
|
67
|
+
"""
|
|
68
|
+
for candidate in (start, *start.parents):
|
|
69
|
+
if (candidate / _INDEX_DIR / _DB_NAME).exists():
|
|
70
|
+
return _location(candidate)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def require_index(start: Path, override: Path | None = None) -> IndexLocation:
|
|
75
|
+
"""Like ``find_index`` but an explicit ``override`` db path wins, and a miss raises.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
start: Directory to begin searching from.
|
|
79
|
+
override: Explicit path to a ``graph.ladybug`` file.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The index location.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
IndexNotFoundError: If nothing is found.
|
|
86
|
+
"""
|
|
87
|
+
if override is not None:
|
|
88
|
+
return IndexLocation(
|
|
89
|
+
repo_root=override.parent.parent,
|
|
90
|
+
db_path=override,
|
|
91
|
+
metadata_path=metadata_path_for(override),
|
|
92
|
+
)
|
|
93
|
+
location = find_index(start)
|
|
94
|
+
if location is None:
|
|
95
|
+
raise IndexNotFoundError(searched_from=start)
|
|
96
|
+
return location
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def locate_index(start: Path | None = None) -> IndexLocation:
|
|
100
|
+
"""Find and check the index every adapter should use: ``LADYBUG_DB_PATH``, else walk up.
|
|
101
|
+
|
|
102
|
+
When the configured ``LADYBUG_DB_PATH`` exists it wins, resolved so ``repo_root``
|
|
103
|
+
is absolute whatever the working directory. Otherwise the index is found by
|
|
104
|
+
walking up from ``start``, as git does with ``.git/``. Its metadata is then read, so
|
|
105
|
+
an index built by an incompatible version of compass is rejected before any query.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
start: Directory to search from; defaults to the working directory.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The index location.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
IndexNotFoundError: If no configured index exists and none is found above
|
|
115
|
+
``start``.
|
|
116
|
+
IndexIncompatibleError: If the index's metadata is unreadable or its schema
|
|
117
|
+
version is not the one this compass reads; the message says to re-ingest.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
Run from ``/repo/pkg`` with ``/repo/.compass/graph.ladybug`` present and the
|
|
121
|
+
default ``LADYBUG_DB_PATH``, ``locate_index()`` returns
|
|
122
|
+
``IndexLocation(repo_root=Path("/repo"), ...)``.
|
|
123
|
+
"""
|
|
124
|
+
origin = start if start is not None else Path.cwd()
|
|
125
|
+
configured = Path(settings.LADYBUG_DB_PATH).resolve()
|
|
126
|
+
location = (
|
|
127
|
+
require_index(origin, override=configured) if configured.exists() else require_index(origin)
|
|
128
|
+
)
|
|
129
|
+
load_metadata(location.metadata_path)
|
|
130
|
+
return location
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def locate_repo_index(repo: Path) -> IndexLocation:
|
|
134
|
+
"""Find and check the index of an explicitly named repository.
|
|
135
|
+
|
|
136
|
+
Unlike ``locate_index`` this ignores ``LADYBUG_DB_PATH``: the caller named the
|
|
137
|
+
repository, so a configured path elsewhere must not win.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
repo: The repository, or any directory inside it.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The index location.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
IndexNotFoundError: If no index is found at or above ``repo``.
|
|
147
|
+
IndexIncompatibleError: If the index must be rebuilt with ``compass ingest``.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
``locate_repo_index(Path("~/src/app").expanduser())`` returns the index in
|
|
151
|
+
``~/src/app/.compass/``.
|
|
152
|
+
"""
|
|
153
|
+
location = require_index(repo.resolve())
|
|
154
|
+
load_metadata(location.metadata_path)
|
|
155
|
+
return location
|