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
File without changes
File without changes
@@ -0,0 +1,310 @@
1
+ """The `compass ingest` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ import compass_core.languages.python # noqa: F401 — registers the Python plugin
11
+ from compass_cli import display
12
+ from compass_cli.errors import handle_errors
13
+ from compass_cli.progress import IngestReporter
14
+ from compass_cli.types import IngestSummary
15
+ from compass_core.ir.graph import ResolvedGraph
16
+ from compass_core.logger import setup as setup_logging
17
+ from compass_core.phases.base import IngestContext
18
+ from compass_core.phases.pipeline import Pipeline
19
+ from compass_core.shared_types import Language, SymbolKind
20
+
21
+
22
+ def _format_debug_section(phase: str, ctx: IngestContext) -> str:
23
+ """Human-readable dump of everything a phase produced, for --debug-log."""
24
+ lines: list[str] = [f"===== {phase} =====", ""]
25
+
26
+ if phase == "discovery":
27
+ assert ctx.manifest is not None
28
+ m = ctx.manifest
29
+ lines.append(
30
+ f"dirs_scanned={m.dirs_scanned} ignored_count={m.ignored_count} files={len(m.files)}"
31
+ )
32
+ lines.append("")
33
+ for f in m.files:
34
+ lines.append(f" {f.path} [{f.language}] {f.content_hash}")
35
+
36
+ elif phase == "parse":
37
+ assert ctx.parsed_files is not None
38
+ for pf in ctx.parsed_files:
39
+ lines.append(f" {pf.path} [{pf.language}] module={pf.module_name}")
40
+ if pf.degraded:
41
+ lines.append(f" DEGRADED: {pf.error}")
42
+ lines.append(
43
+ f" definitions={len(pf.definitions)} imports={len(pf.imports)} "
44
+ f"call_sites={len(pf.call_sites)}"
45
+ )
46
+ for d in pf.definitions:
47
+ lines.append(f" {d.kind.value}: {d.id} (lines {d.line_start}-{d.line_end})")
48
+ if d.signature:
49
+ lines.append(f" signature: {d.signature}")
50
+ if d.docstring:
51
+ lines.append(f" docstring: {d.docstring!r}")
52
+ if d.raw_calls:
53
+ lines.append(f" raw_calls: {d.raw_calls}")
54
+ lines.append(" source:")
55
+ for src_line in d.source.splitlines():
56
+ lines.append(f" {src_line}")
57
+
58
+ elif phase == "symbol_table":
59
+ assert ctx.symbol_table is not None
60
+ assert ctx.import_map is not None
61
+ lines.append(f"symbol_table ({len(ctx.symbol_table)} entries):")
62
+ for symbol_key, symbol_id in ctx.symbol_table.items():
63
+ lines.append(f" {symbol_key} -> {symbol_id}")
64
+ lines.append("")
65
+ lines.append(f"import_map ({len(ctx.import_map)} entries):")
66
+ for module_path, imported_names in ctx.import_map.items():
67
+ lines.append(f" {module_path} -> {imported_names}")
68
+
69
+ elif phase == "resolution":
70
+ assert ctx.resolved_graph is not None
71
+ g = ctx.resolved_graph
72
+ lines.append(f"call_edges ({len(g.call_edges)}):")
73
+ for call in g.call_edges:
74
+ lines.append(
75
+ f" {call.caller_id} -> {call.callee_id} [{call.call_type} conf={call.confidence}]"
76
+ )
77
+ lines.append("")
78
+ lines.append(f"external_call_edges ({len(g.external_call_edges)}):")
79
+ for ext_call in g.external_call_edges:
80
+ lines.append(f" {ext_call.caller_id} -> {ext_call.full_ref} [{ext_call.library}]")
81
+ lines.append("")
82
+ lines.append(f"import_edges ({len(g.import_edges)}):")
83
+ for imp in g.import_edges:
84
+ lines.append(f" {imp.from_path} -> {imp.to_path}")
85
+ lines.append("")
86
+ lines.append(f"inherit_edges ({len(g.inherit_edges)}):")
87
+ for inherit in g.inherit_edges:
88
+ lines.append(f" {inherit.child_id} -> {inherit.parent_id}")
89
+
90
+ elif phase == "validation":
91
+ assert ctx.report is not None
92
+ r = ctx.report
93
+ lines.append(f"orphan_node_count={r.orphan_node_count}")
94
+ lines.append(f"dangling_import_count={r.dangling_import_count}")
95
+ lines.append(f"degraded_file_count={r.degraded_file_count}")
96
+ lines.append(f"resolved_by_language={_by_language_name(r.resolved_by_language)}")
97
+ lines.append(f"unresolved_by_language={_by_language_name(r.unresolved_by_language)}")
98
+
99
+ elif phase == "persistence":
100
+ assert ctx.write_result is not None
101
+ lines.append(f"node_count={ctx.write_result.node_count}")
102
+ lines.append(f"edge_count={ctx.write_result.edge_count}")
103
+ lines.append(f"dropped_edges={ctx.write_result.dropped_edges}")
104
+
105
+ lines.append("")
106
+ return "\n".join(lines)
107
+
108
+
109
+ def _count_definitions(resolved_graph: ResolvedGraph) -> tuple[int, int]:
110
+ """Return (class_count, function_count) across all files in the graph."""
111
+ class_count = 0
112
+ function_count = 0
113
+ for pf in resolved_graph.files:
114
+ for d in pf.definitions:
115
+ if d.kind is SymbolKind.CLASS:
116
+ class_count += 1
117
+ elif d.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
118
+ function_count += 1
119
+ return class_count, function_count
120
+
121
+
122
+ def _db_size(db_path: Path) -> int:
123
+ """Total bytes on disk for the database — a single file or a directory."""
124
+ if db_path.is_file():
125
+ return db_path.stat().st_size
126
+ if db_path.is_dir():
127
+ return sum(f.stat().st_size for f in db_path.rglob("*") if f.is_file())
128
+ return 0
129
+
130
+
131
+ def _resolved_pct(graph: ResolvedGraph) -> float:
132
+ """Overall resolved-call ratio across all languages (1.0 if no calls at all)."""
133
+ total_raw = sum(len(d.raw_calls) for pf in graph.files for d in pf.definitions)
134
+ if total_raw == 0:
135
+ return 1.0
136
+ return len(graph.call_edges) / total_raw
137
+
138
+
139
+ def _by_language_name(ratios: dict[Language, float]) -> dict[str, float]:
140
+ return {language.value: ratio for language, ratio in ratios.items()}
141
+
142
+
143
+ def _language_counts(ctx: IngestContext) -> dict[Language, int]:
144
+ assert ctx.manifest is not None
145
+ counts: dict[Language, int] = {}
146
+ for f in ctx.manifest.files:
147
+ counts[f.language] = counts.get(f.language, 0) + 1
148
+ return counts
149
+
150
+
151
+ def _relative_db_path(db_path: Path, repo_path: Path) -> Path:
152
+ """Express db_path relative to repo_path for display, falling back to the absolute path."""
153
+ try:
154
+ return db_path.relative_to(repo_path)
155
+ except ValueError:
156
+ return db_path
157
+
158
+
159
+ def _stage_result(
160
+ phase: str, ctx: IngestContext, *, repo_path: Path, db_path: Path
161
+ ) -> tuple[str, str, bool]:
162
+ """Build (primary, secondary, warn) display text for a just-finished phase."""
163
+ if phase == "discovery":
164
+ assert ctx.manifest is not None
165
+ m = ctx.manifest
166
+ num_languages = len(_language_counts(ctx))
167
+ language_word = "language" if num_languages == 1 else "languages"
168
+ primary = f"{len(m.files):,} files"
169
+ secondary = f"{num_languages} {language_word} · {m.dirs_scanned:,} dirs · {m.ignored_count:,} ignored"
170
+ return primary, secondary, False
171
+
172
+ if phase == "parse":
173
+ assert ctx.parsed_files is not None
174
+ degraded = sum(1 for pf in ctx.parsed_files if pf.degraded)
175
+ primary = f"{len(ctx.parsed_files):,} parsed"
176
+ secondary = f"{degraded:,} degraded"
177
+ return primary, secondary, degraded > 0
178
+
179
+ if phase == "symbol_table":
180
+ assert ctx.symbol_table is not None
181
+ assert ctx.import_map is not None
182
+ primary = f"{len(ctx.symbol_table):,} symbols"
183
+ secondary = f"{len(ctx.import_map):,} import entries"
184
+ return primary, secondary, False
185
+
186
+ if phase == "resolution":
187
+ assert ctx.resolved_graph is not None
188
+ g = ctx.resolved_graph
189
+ total = (
190
+ len(g.import_edges)
191
+ + len(g.inherit_edges)
192
+ + len(g.call_edges)
193
+ + len(g.external_call_edges)
194
+ )
195
+ primary = f"{total:,} edges"
196
+ secondary = (
197
+ f"import {len(g.import_edges):,} · call {len(g.call_edges):,} · "
198
+ f"external {len(g.external_call_edges):,} · inherit {len(g.inherit_edges):,}"
199
+ )
200
+ return primary, secondary, False
201
+
202
+ if phase == "validation":
203
+ assert ctx.report is not None
204
+ assert ctx.resolved_graph is not None
205
+ r = ctx.report
206
+ primary = f"{r.orphan_node_count:,} orphan nodes"
207
+ secondary = (
208
+ f"{r.dangling_import_count:,} dangling imports · {r.degraded_file_count:,} degraded files · "
209
+ f"{_resolved_pct(ctx.resolved_graph):.0%} resolved"
210
+ )
211
+ return primary, secondary, r.orphan_node_count > 0
212
+
213
+ if phase == "persistence":
214
+ assert ctx.write_result is not None
215
+ display_db = _relative_db_path(db_path, repo_path)
216
+ primary = f"{ctx.write_result.node_count:,} nodes · {ctx.write_result.edge_count:,} edges"
217
+ secondary = f"{display.fmt_size(_db_size(db_path))} · {display_db}"
218
+ dropped = ctx.write_result.dropped_edges
219
+ if dropped:
220
+ secondary = f"{dropped:,} skipped · {secondary}"
221
+ return primary, secondary, dropped > 0
222
+
223
+ return "", "", False
224
+
225
+
226
+ def _run_ingest(
227
+ repo_path: Path,
228
+ db_path: Path,
229
+ *,
230
+ workers: int | None,
231
+ verbose: bool,
232
+ debug_log: Path | None,
233
+ ) -> IngestSummary:
234
+ """Run the ingest pipeline and return its summary."""
235
+ start = time.monotonic()
236
+
237
+ display.ingest_header(repo_path)
238
+
239
+ if debug_log is not None:
240
+ debug_log.parent.mkdir(parents=True, exist_ok=True)
241
+ debug_log.write_text("", encoding="utf-8")
242
+
243
+ with IngestReporter(console=display.console) as reporter:
244
+
245
+ def _on_phase_complete(phase: str, ctx: IngestContext) -> None:
246
+ primary, secondary, warn = _stage_result(
247
+ phase, ctx, repo_path=repo_path, db_path=db_path
248
+ )
249
+ reporter.complete(phase, primary, secondary, warn=warn)
250
+ if debug_log is not None:
251
+ with debug_log.open("a", encoding="utf-8") as f:
252
+ f.write(_format_debug_section(phase, ctx) + "\n")
253
+
254
+ ctx = Pipeline(max_workers=workers).run(
255
+ repo_path, db_path, reporter=reporter, on_phase_complete=_on_phase_complete
256
+ )
257
+ reporter.finalize()
258
+ display.console.print()
259
+
260
+ assert ctx.manifest is not None
261
+ assert ctx.resolved_graph is not None
262
+ assert ctx.report is not None
263
+ assert ctx.write_result is not None
264
+
265
+ display_db = _relative_db_path(db_path, repo_path)
266
+ class_count, function_count = _count_definitions(ctx.resolved_graph)
267
+
268
+ return IngestSummary(
269
+ file_count=len(ctx.manifest.files),
270
+ class_count=class_count,
271
+ function_count=function_count,
272
+ elapsed=time.monotonic() - start,
273
+ node_count=ctx.write_result.node_count,
274
+ edge_count=ctx.write_result.edge_count,
275
+ db_path=display_db,
276
+ db_size=_db_size(db_path),
277
+ skipped=ctx.report.degraded_file_count,
278
+ resolved_pct=_resolved_pct(ctx.resolved_graph),
279
+ orphan_count=ctx.report.orphan_node_count,
280
+ language_counts=_language_counts(ctx),
281
+ dropped_edges=ctx.write_result.dropped_edges,
282
+ )
283
+
284
+
285
+ @handle_errors
286
+ def ingest(
287
+ path: str = typer.Argument(..., help="Path to the repository to ingest"),
288
+ db: str | None = typer.Option(
289
+ None, "--db", help="Path to LadybugDB database (default: <repo>/.compass/graph.ladybug)"
290
+ ),
291
+ workers: int | None = typer.Option(None, "--workers", help="Number of parallel parse workers"),
292
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging"),
293
+ debug_log: bool = typer.Option(
294
+ False, "--debug-log", help="Append phase-by-phase output to .compass/ingest_debug.log"
295
+ ),
296
+ ) -> None:
297
+ """Ingest a repository for code intelligence."""
298
+ setup_logging(level="DEBUG" if verbose else "WARNING", show_path=verbose)
299
+
300
+ repo_path = Path(path).resolve()
301
+ db_path = Path(db).resolve() if db else repo_path / ".compass" / "graph.ladybug"
302
+ db_path.parent.mkdir(parents=True, exist_ok=True)
303
+ debug_log_path = repo_path / ".compass" / "ingest_debug.log" if debug_log else None
304
+
305
+ summary = _run_ingest(
306
+ repo_path, db_path, workers=workers, verbose=verbose, debug_log=debug_log_path
307
+ )
308
+
309
+ display.summary(summary)
310
+ display.next_steps()
@@ -0,0 +1,25 @@
1
+ """The ``compass mcp`` command: serve the code graph to coding agents over MCP (stdio)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from compass_mcp.server import build_server
10
+
11
+ REPO = typer.Option(
12
+ None,
13
+ "--repo",
14
+ file_okay=False,
15
+ help="Repository to serve; default: the one containing the working directory.",
16
+ )
17
+
18
+
19
+ def mcp(repo: Path | None = REPO) -> None:
20
+ """Run the MCP server on stdin/stdout for Claude Code, Codex and other MCP clients.
21
+
22
+ Nothing else is printed: stdout carries the protocol. Register it with
23
+ `compass setup --mcp`, or point a client at the command `compass mcp`.
24
+ """
25
+ build_server(repo).run("stdio")
@@ -0,0 +1,181 @@
1
+ """Query commands: ``status``, ``search``, ``describe``, ``impact`` and ``overview``.
2
+
3
+ Each command finds the index, opens it read-only, calls one ``compass_core.query``
4
+ function and prints its result: in a terminal, Rich tables and panels
5
+ (``compass_cli.result_views``); when piped, as for a script or a coding agent, the compact
6
+ one-line-per-item text of ``compass_core.render``; and the contract JSON with
7
+ ``--format json``. Queries never write; without an index they exit with a message saying
8
+ to run ``compass ingest``.
9
+
10
+ Answers go to stdout. An ambiguous or unknown reference is an answer too (the candidates
11
+ or suggestions), printed to stdout with its own exit code; failures go to stderr. Exit
12
+ codes come from ``ERROR_MAPPINGS``: 0 ok, 2 invalid argument, 3 not found, 4 ambiguous,
13
+ 5 no index, 6 index busy, 7 index must be rebuilt, 8 timeout.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import NoReturn, TypeVar
21
+
22
+ import typer
23
+ from rich.console import RenderableType
24
+
25
+ from compass_cli import result_views
26
+ from compass_cli.display import console
27
+ from compass_cli.types import OutputFormat
28
+ from compass_core.contract.errors import ERROR_MAPPINGS, ErrorResponse, to_error_response
29
+ from compass_core.contract.params import (
30
+ DEFAULT_IMPACT_DEPTH,
31
+ DEFAULT_LIMIT,
32
+ DEFAULT_MIN_CONFIDENCE,
33
+ MAX_DEPTH,
34
+ MAX_LIMIT,
35
+ ImpactParams,
36
+ SearchParams,
37
+ )
38
+ from compass_core.contract.results import AmbiguousReference, Lookup, SymbolNotFound
39
+ from compass_core.contract.serialize import Serializable, to_json
40
+ from compass_core.errors import ErrorCode, QueryError
41
+ from compass_core.graph.db import GraphDB
42
+ from compass_core.index.locate import IndexLocation, locate_index, locate_repo_index
43
+ from compass_core.query.detail import describe as describe_symbol
44
+ from compass_core.query.impact import impact as impact_of
45
+ from compass_core.query.overview import overview as repo_overview
46
+ from compass_core.query.search import find_symbols
47
+ from compass_core.query.status import index_status
48
+ from compass_core.render import (
49
+ render_ambiguous,
50
+ render_describe,
51
+ render_error,
52
+ render_impact,
53
+ render_not_found,
54
+ render_overview,
55
+ render_search,
56
+ render_status,
57
+ )
58
+ from compass_core.shared_types import SymbolKind
59
+
60
+ R = TypeVar("R")
61
+
62
+ FORMAT = typer.Option(
63
+ OutputFormat.TEXT,
64
+ "--format",
65
+ "-f",
66
+ help="text (default; tables in a terminal, compact lines when piped) or json.",
67
+ )
68
+ REPO = typer.Option(
69
+ None,
70
+ "--repo",
71
+ help="Repository to query; default: the one containing the current directory.",
72
+ file_okay=False,
73
+ )
74
+ KIND = typer.Option(None, "--kind", "-k", help="Only this kind of symbol.")
75
+ LIMIT = typer.Option(DEFAULT_LIMIT, "--limit", "-n", min=1, max=MAX_LIMIT, help="Most items.")
76
+
77
+
78
+ def _fail(error: ErrorResponse, fmt: OutputFormat) -> NoReturn:
79
+ message = to_json(error) if fmt is OutputFormat.JSON else render_error(error)
80
+ typer.echo(message, err=True)
81
+ raise typer.Exit(code=ERROR_MAPPINGS[error.code].exit_code)
82
+
83
+
84
+ def _run(
85
+ repo: Path | None, fmt: OutputFormat, operation: Callable[[GraphDB, IndexLocation], R]
86
+ ) -> R:
87
+ """Locate and open the index read-only, then run ``operation`` on it."""
88
+ try:
89
+ location = locate_repo_index(repo) if repo else locate_index()
90
+ with GraphDB(location.db_path, read_only=True) as db:
91
+ return operation(db, location)
92
+ except QueryError as exc:
93
+ _fail(to_error_response(exc), fmt)
94
+
95
+
96
+ def _print(result: Serializable, text: str, fmt: OutputFormat) -> None:
97
+ typer.echo(to_json(result) if fmt is OutputFormat.JSON else text)
98
+
99
+
100
+ def _show(
101
+ result: Serializable,
102
+ text: Callable[[], str],
103
+ view: Callable[[], RenderableType],
104
+ fmt: OutputFormat,
105
+ ) -> None:
106
+ """Print a result: Rich ``view`` in a terminal, else compact ``text`` or JSON."""
107
+ if fmt is OutputFormat.TEXT and console.is_terminal:
108
+ console.print(view())
109
+ console.print()
110
+ else:
111
+ _print(result, text(), fmt)
112
+
113
+
114
+ def _found(result: Lookup[R], fmt: OutputFormat) -> R:
115
+ """Return a found result; print the candidates or suggestions and exit otherwise."""
116
+ if isinstance(result, AmbiguousReference):
117
+ _print(result, render_ambiguous(result), fmt)
118
+ raise typer.Exit(code=ERROR_MAPPINGS[ErrorCode.AMBIGUOUS].exit_code)
119
+ if isinstance(result, SymbolNotFound):
120
+ _print(result, render_not_found(result), fmt)
121
+ raise typer.Exit(code=ERROR_MAPPINGS[ErrorCode.NOT_FOUND].exit_code)
122
+ return result
123
+
124
+
125
+ def status(fmt: OutputFormat = FORMAT, repo: Path | None = REPO) -> None:
126
+ """Show which index is used, when it was built and whether files changed since."""
127
+ result = _run(repo, fmt, lambda db, location: index_status(location, db))
128
+ _show(result, lambda: render_status(result), lambda: result_views.status_view(result), fmt)
129
+
130
+
131
+ def search(
132
+ text: str = typer.Argument(..., help="Name, qualified name, or part of either."),
133
+ kind: SymbolKind | None = KIND,
134
+ limit: int = LIMIT,
135
+ fmt: OutputFormat = FORMAT,
136
+ repo: Path | None = REPO,
137
+ ) -> None:
138
+ """Find files, classes, functions and methods by name. Best matches first."""
139
+ params = SearchParams(query=text, kind=kind, limit=limit)
140
+ result = _run(repo, fmt, lambda db, location: find_symbols(db, location, params))
141
+ _show(result, lambda: render_search(result), lambda: result_views.search_view(result), fmt)
142
+
143
+
144
+ def describe(
145
+ ref: str = typer.Argument(..., help="Symbol id, file path, qualified name or name."),
146
+ source: bool = typer.Option(False, "--source", help="Also print the source text."),
147
+ fmt: OutputFormat = FORMAT,
148
+ repo: Path | None = REPO,
149
+ ) -> None:
150
+ """Show one symbol: location, signature, docstring, callers, callees and members."""
151
+ detail = _found(_run(repo, fmt, lambda db, location: describe_symbol(db, location, ref)), fmt)
152
+ _show(
153
+ detail,
154
+ lambda: render_describe(detail, include_source=source),
155
+ lambda: result_views.describe_view(detail, include_source=source),
156
+ fmt,
157
+ )
158
+
159
+
160
+ def impact(
161
+ ref: str = typer.Argument(..., help="Symbol id, file path, qualified name or name."),
162
+ depth: int = typer.Option(
163
+ DEFAULT_IMPACT_DEPTH, "--depth", "-d", min=1, max=MAX_DEPTH, help="Most hops."
164
+ ),
165
+ min_confidence: float = typer.Option(
166
+ DEFAULT_MIN_CONFIDENCE, "--min-confidence", min=0.0, max=1.0, help="Skip weaker calls."
167
+ ),
168
+ limit: int = LIMIT,
169
+ fmt: OutputFormat = FORMAT,
170
+ repo: Path | None = REPO,
171
+ ) -> None:
172
+ """List what may break if a symbol or file changes, nearest first."""
173
+ params = ImpactParams(depth=depth, min_confidence=min_confidence, limit=limit)
174
+ result = _found(_run(repo, fmt, lambda db, location: impact_of(db, location, ref, params)), fmt)
175
+ _show(result, lambda: render_impact(result), lambda: result_views.impact_view(result), fmt)
176
+
177
+
178
+ def overview(fmt: OutputFormat = FORMAT, repo: Path | None = REPO) -> None:
179
+ """Summarise the repository: totals, entry points and the most connected symbols."""
180
+ result = _run(repo, fmt, lambda db, location: repo_overview(db, location))
181
+ _show(result, lambda: render_overview(result), lambda: result_views.overview_view(result), fmt)
@@ -0,0 +1,123 @@
1
+ """The `compass serve` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ import urllib.request
9
+ import webbrowser
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+
13
+ import typer
14
+
15
+ from compass_cli import display
16
+ from compass_cli.errors import handle_errors
17
+ from compass_core.logger import setup as setup_logging
18
+ from compass_server.settings import server_settings
19
+ from compass_server.web_bundle import has_bundled_ui
20
+
21
+
22
+ def _wait_for_server(url: str, timeout: int = 30) -> bool:
23
+ """Poll url until it responds or timeout (seconds) is reached."""
24
+ deadline = time.monotonic() + timeout
25
+ while time.monotonic() < deadline:
26
+ try:
27
+ urllib.request.urlopen(url, timeout=1)
28
+ return True
29
+ except Exception:
30
+ time.sleep(0.5)
31
+ return False
32
+
33
+
34
+ class UiMode(StrEnum):
35
+ """Where ``compass serve`` gets the web UI from.
36
+
37
+ Attributes:
38
+ BUNDLED: The static UI built into ``compass_server``, served on the API's own port
39
+ (an installed package).
40
+ DEV: The Next.js dev server from a source checkout, with hot reload.
41
+ NONE: No UI; only the API.
42
+ """
43
+
44
+ BUNDLED = "bundled"
45
+ DEV = "dev"
46
+ NONE = "none"
47
+
48
+
49
+ WEB_DIR = Path(__file__).parents[5] / "apps" / "web"
50
+ """The web app in a source checkout; absent in an installed package."""
51
+
52
+
53
+ def ui_mode(*, no_frontend: bool, dev: bool) -> UiMode:
54
+ """Pick the UI to serve: the dev server when asked or unbundled, else the bundle.
55
+
56
+ Args:
57
+ no_frontend: Serve only the API.
58
+ dev: Prefer the Next.js dev server even when a bundled UI exists.
59
+
60
+ Returns:
61
+ The mode.
62
+
63
+ Example:
64
+ In an installed package, ``ui_mode(no_frontend=False, dev=False)`` is
65
+ ``UiMode.BUNDLED``.
66
+ """
67
+ if no_frontend:
68
+ return UiMode.NONE
69
+ if WEB_DIR.exists() and (dev or not has_bundled_ui()):
70
+ return UiMode.DEV
71
+ return UiMode.BUNDLED if has_bundled_ui() else UiMode.NONE
72
+
73
+
74
+ @handle_errors
75
+ def serve(
76
+ host: str = typer.Option(server_settings.API_HOST, help="Host for the API server"),
77
+ port: int = typer.Option(server_settings.API_PORT, help="Port for the API server"),
78
+ no_frontend: bool = typer.Option(False, "--no-frontend", help="Serve only the API"),
79
+ dev: bool = typer.Option(
80
+ False, "--dev", help="Run the web UI's dev server from a source checkout"
81
+ ),
82
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging"),
83
+ ) -> None:
84
+ """Start the API server and the web UI, then open the UI in a browser."""
85
+ setup_logging(level="DEBUG" if verbose else "WARNING", show_path=verbose)
86
+
87
+ mode = ui_mode(no_frontend=no_frontend, dev=dev)
88
+ api_url = f"http://{host}:{port}"
89
+ procs: list[subprocess.Popen[bytes]] = []
90
+
91
+ try:
92
+ display.console.print(f"[bold green]Starting API server[/bold green] on {api_url}")
93
+ command = [sys.executable, "-m", "uvicorn", "compass_server.main:app"]
94
+ command += ["--host", host, "--port", str(port)]
95
+ if mode is UiMode.DEV:
96
+ command.append("--reload")
97
+ procs.append(subprocess.Popen(command))
98
+
99
+ if mode is UiMode.BUNDLED:
100
+ display.console.print(f"[bold green]Web UI[/bold green] on {api_url}")
101
+ if _wait_for_server(f"{api_url}/health"):
102
+ webbrowser.open(api_url)
103
+ elif mode is UiMode.DEV:
104
+ frontend_url = server_settings.FRONTEND_URL
105
+ display.console.print(f"[bold green]Starting frontend[/bold green] on {frontend_url}")
106
+ procs.append(subprocess.Popen(["pnpm", "dev"], cwd=WEB_DIR))
107
+ # Poll until the dev server is ready, then open the browser.
108
+ if _wait_for_server(
109
+ f"http://{server_settings.FRONTEND_HOST}:{server_settings.FRONTEND_PORT}"
110
+ ):
111
+ webbrowser.open(frontend_url)
112
+
113
+ display.console.print("[dim]Press Ctrl+C to stop.[/dim]")
114
+
115
+ for proc in procs:
116
+ proc.wait()
117
+
118
+ except KeyboardInterrupt:
119
+ display.console.print("\n[yellow]Shutting down...[/yellow]")
120
+ for proc in procs:
121
+ proc.terminate()
122
+ for proc in procs:
123
+ proc.wait()