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
compass_cli/__init__.py
ADDED
|
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()
|