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.
Files changed (137) hide show
  1. compass_cli/__init__.py +0 -0
  2. compass_cli/commands/__init__.py +0 -0
  3. compass_cli/commands/ingest.py +310 -0
  4. compass_cli/commands/mcp.py +25 -0
  5. compass_cli/commands/query.py +181 -0
  6. compass_cli/commands/serve.py +123 -0
  7. compass_cli/commands/setup.py +171 -0
  8. compass_cli/display.py +231 -0
  9. compass_cli/errors.py +30 -0
  10. compass_cli/main.py +54 -0
  11. compass_cli/progress.py +191 -0
  12. compass_cli/result_views.py +389 -0
  13. compass_cli/types.py +41 -0
  14. compass_code-0.1.0.dist-info/METADATA +72 -0
  15. compass_code-0.1.0.dist-info/RECORD +137 -0
  16. compass_code-0.1.0.dist-info/WHEEL +4 -0
  17. compass_code-0.1.0.dist-info/entry_points.txt +2 -0
  18. compass_core/__init__.py +1 -0
  19. compass_core/contract/__init__.py +6 -0
  20. compass_core/contract/errors.py +116 -0
  21. compass_core/contract/models.py +204 -0
  22. compass_core/contract/params.py +99 -0
  23. compass_core/contract/results.py +370 -0
  24. compass_core/contract/serialize.py +59 -0
  25. compass_core/contract/types.py +76 -0
  26. compass_core/errors.py +148 -0
  27. compass_core/graph/__init__.py +0 -0
  28. compass_core/graph/db.py +205 -0
  29. compass_core/graph/schema.py +165 -0
  30. compass_core/graph/stored_ids.py +133 -0
  31. compass_core/graph/writer.py +482 -0
  32. compass_core/index/__init__.py +1 -0
  33. compass_core/index/freshness.py +55 -0
  34. compass_core/index/locate.py +155 -0
  35. compass_core/index/metadata.py +107 -0
  36. compass_core/ir/__init__.py +0 -0
  37. compass_core/ir/graph.py +65 -0
  38. compass_core/ir/models.py +75 -0
  39. compass_core/languages/__init__.py +1 -0
  40. compass_core/languages/base.py +26 -0
  41. compass_core/languages/python/__init__.py +6 -0
  42. compass_core/languages/python/extractor.py +670 -0
  43. compass_core/languages/python/import_resolver.py +107 -0
  44. compass_core/languages/registry.py +89 -0
  45. compass_core/logger.py +42 -0
  46. compass_core/phases/__init__.py +0 -0
  47. compass_core/phases/base.py +58 -0
  48. compass_core/phases/discovery.py +167 -0
  49. compass_core/phases/parse.py +80 -0
  50. compass_core/phases/persistence.py +33 -0
  51. compass_core/phases/pipeline.py +152 -0
  52. compass_core/phases/resolution.py +572 -0
  53. compass_core/phases/symbol_table.py +36 -0
  54. compass_core/phases/validation.py +76 -0
  55. compass_core/progress.py +45 -0
  56. compass_core/py.typed +0 -0
  57. compass_core/query/__init__.py +1 -0
  58. compass_core/query/cypher.py +92 -0
  59. compass_core/query/detail.py +241 -0
  60. compass_core/query/graph_map.py +127 -0
  61. compass_core/query/impact.py +273 -0
  62. compass_core/query/impact_walk.py +352 -0
  63. compass_core/query/overview.py +221 -0
  64. compass_core/query/references.py +235 -0
  65. compass_core/query/rows.py +378 -0
  66. compass_core/query/search.py +183 -0
  67. compass_core/query/status.py +49 -0
  68. compass_core/query/walk_queries.py +202 -0
  69. compass_core/render.py +408 -0
  70. compass_core/settings.py +61 -0
  71. compass_core/shared_types.py +78 -0
  72. compass_mcp/__init__.py +0 -0
  73. compass_mcp/guide.py +45 -0
  74. compass_mcp/py.typed +0 -0
  75. compass_mcp/server.py +261 -0
  76. compass_server/__init__.py +5 -0
  77. compass_server/errors.py +84 -0
  78. compass_server/main.py +42 -0
  79. compass_server/py.typed +0 -0
  80. compass_server/routers/__init__.py +0 -0
  81. compass_server/routers/query.py +182 -0
  82. compass_server/settings.py +37 -0
  83. compass_server/static/404.html +1 -0
  84. compass_server/static/_next/static/chunks/1629039a.eed2a06658ec4f9c.js +1 -0
  85. compass_server/static/_next/static/chunks/1ff282bf-bb7c483245264fc9.js +1 -0
  86. compass_server/static/_next/static/chunks/223.f881a22f446259bf.js +508 -0
  87. compass_server/static/_next/static/chunks/51fa5c19.6396973222c9d6fd.js +485 -0
  88. compass_server/static/_next/static/chunks/556.443c1682b6b20128.js +1 -0
  89. compass_server/static/_next/static/chunks/684-1aa0170f59baf77e.js +1 -0
  90. compass_server/static/_next/static/chunks/92e71660.3a42d4f70d017951.js +342 -0
  91. compass_server/static/_next/static/chunks/937-8692224bb6d1b605.js +1 -0
  92. compass_server/static/_next/static/chunks/app/_not-found/page-2c96ddf295eb6a86.js +1 -0
  93. compass_server/static/_next/static/chunks/app/layout-4bd0ba4a7d18aff9.js +1 -0
  94. compass_server/static/_next/static/chunks/app/page-5376a0a98bbd1faa.js +1 -0
  95. compass_server/static/_next/static/chunks/framework-96dcd958cfe08bd4.js +1 -0
  96. compass_server/static/_next/static/chunks/main-6dee8e626b16731a.js +1 -0
  97. compass_server/static/_next/static/chunks/main-app-460e8bc670818718.js +1 -0
  98. compass_server/static/_next/static/chunks/pages/_app-638a3e7363f92b80.js +1 -0
  99. compass_server/static/_next/static/chunks/pages/_error-6a72dbbb45cae743.js +1 -0
  100. compass_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  101. compass_server/static/_next/static/chunks/webpack-597057bf48ce4111.js +1 -0
  102. compass_server/static/_next/static/css/1b0fbfd8dd653f37.css +3 -0
  103. compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_buildManifest.js +1 -0
  104. compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_ssgManifest.js +1 -0
  105. compass_server/static/_next/static/media/0086f8992871c45b-s.woff2 +0 -0
  106. compass_server/static/_next/static/media/034d78ad42e9620c-s.woff2 +0 -0
  107. compass_server/static/_next/static/media/03b436aa846269de-s.woff2 +0 -0
  108. compass_server/static/_next/static/media/0484562807a97172-s.p.woff2 +0 -0
  109. compass_server/static/_next/static/media/08aedeceaf1dcd57-s.woff2 +0 -0
  110. compass_server/static/_next/static/media/0aa834ed78bf6d07-s.woff2 +0 -0
  111. compass_server/static/_next/static/media/29a4aea02fdee119-s.woff2 +0 -0
  112. compass_server/static/_next/static/media/29e7bbdce9332268-s.woff2 +0 -0
  113. compass_server/static/_next/static/media/36008747766f78c6-s.woff2 +0 -0
  114. compass_server/static/_next/static/media/39969fcf98a3026e-s.woff2 +0 -0
  115. compass_server/static/_next/static/media/4de1fea1a954a5b6-s.p.woff2 +0 -0
  116. compass_server/static/_next/static/media/5c0c2bcbaa4149ca-s.p.woff2 +0 -0
  117. compass_server/static/_next/static/media/67957d42bae0796d-s.woff2 +0 -0
  118. compass_server/static/_next/static/media/6d38f9d4e0f4772b-s.woff2 +0 -0
  119. compass_server/static/_next/static/media/6d664cce900333ee-s.p.woff2 +0 -0
  120. compass_server/static/_next/static/media/886030b0b59bc5a7-s.woff2 +0 -0
  121. compass_server/static/_next/static/media/8888a3826f4a3af4-s.p.woff2 +0 -0
  122. compass_server/static/_next/static/media/939c4f875ee75fbb-s.woff2 +0 -0
  123. compass_server/static/_next/static/media/a1386beebedccca4-s.woff2 +0 -0
  124. compass_server/static/_next/static/media/ab00a911ac2adf48-s.woff2 +0 -0
  125. compass_server/static/_next/static/media/b957ea75a84b6ea7-s.p.woff2 +0 -0
  126. compass_server/static/_next/static/media/bb3ef058b751a6ad-s.p.woff2 +0 -0
  127. compass_server/static/_next/static/media/c3bc380753a8436c-s.woff2 +0 -0
  128. compass_server/static/_next/static/media/cce080f35d014443-s.woff2 +0 -0
  129. compass_server/static/_next/static/media/db911767852bc875-s.woff2 +0 -0
  130. compass_server/static/_next/static/media/eafabf029ad39a43-s.p.woff2 +0 -0
  131. compass_server/static/_next/static/media/f10b8e9d91f3edcb-s.woff2 +0 -0
  132. compass_server/static/_next/static/media/f911b923c6adde36-s.woff2 +0 -0
  133. compass_server/static/_next/static/media/fe0777f1195381cb-s.woff2 +0 -0
  134. compass_server/static/index.html +1 -0
  135. compass_server/static/index.txt +25 -0
  136. compass_server/web_bundle.py +27 -0
  137. 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