compass-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- compass_cli/__init__.py +0 -0
- compass_cli/commands/__init__.py +0 -0
- compass_cli/commands/ingest.py +310 -0
- compass_cli/commands/mcp.py +25 -0
- compass_cli/commands/query.py +181 -0
- compass_cli/commands/serve.py +123 -0
- compass_cli/commands/setup.py +171 -0
- compass_cli/display.py +231 -0
- compass_cli/errors.py +30 -0
- compass_cli/main.py +54 -0
- compass_cli/progress.py +191 -0
- compass_cli/result_views.py +389 -0
- compass_cli/types.py +41 -0
- compass_code-0.1.0.dist-info/METADATA +72 -0
- compass_code-0.1.0.dist-info/RECORD +137 -0
- compass_code-0.1.0.dist-info/WHEEL +4 -0
- compass_code-0.1.0.dist-info/entry_points.txt +2 -0
- compass_core/__init__.py +1 -0
- compass_core/contract/__init__.py +6 -0
- compass_core/contract/errors.py +116 -0
- compass_core/contract/models.py +204 -0
- compass_core/contract/params.py +99 -0
- compass_core/contract/results.py +370 -0
- compass_core/contract/serialize.py +59 -0
- compass_core/contract/types.py +76 -0
- compass_core/errors.py +148 -0
- compass_core/graph/__init__.py +0 -0
- compass_core/graph/db.py +205 -0
- compass_core/graph/schema.py +165 -0
- compass_core/graph/stored_ids.py +133 -0
- compass_core/graph/writer.py +482 -0
- compass_core/index/__init__.py +1 -0
- compass_core/index/freshness.py +55 -0
- compass_core/index/locate.py +155 -0
- compass_core/index/metadata.py +107 -0
- compass_core/ir/__init__.py +0 -0
- compass_core/ir/graph.py +65 -0
- compass_core/ir/models.py +75 -0
- compass_core/languages/__init__.py +1 -0
- compass_core/languages/base.py +26 -0
- compass_core/languages/python/__init__.py +6 -0
- compass_core/languages/python/extractor.py +670 -0
- compass_core/languages/python/import_resolver.py +107 -0
- compass_core/languages/registry.py +89 -0
- compass_core/logger.py +42 -0
- compass_core/phases/__init__.py +0 -0
- compass_core/phases/base.py +58 -0
- compass_core/phases/discovery.py +167 -0
- compass_core/phases/parse.py +80 -0
- compass_core/phases/persistence.py +33 -0
- compass_core/phases/pipeline.py +152 -0
- compass_core/phases/resolution.py +572 -0
- compass_core/phases/symbol_table.py +36 -0
- compass_core/phases/validation.py +76 -0
- compass_core/progress.py +45 -0
- compass_core/py.typed +0 -0
- compass_core/query/__init__.py +1 -0
- compass_core/query/cypher.py +92 -0
- compass_core/query/detail.py +241 -0
- compass_core/query/graph_map.py +127 -0
- compass_core/query/impact.py +273 -0
- compass_core/query/impact_walk.py +352 -0
- compass_core/query/overview.py +221 -0
- compass_core/query/references.py +235 -0
- compass_core/query/rows.py +378 -0
- compass_core/query/search.py +183 -0
- compass_core/query/status.py +49 -0
- compass_core/query/walk_queries.py +202 -0
- compass_core/render.py +408 -0
- compass_core/settings.py +61 -0
- compass_core/shared_types.py +78 -0
- compass_mcp/__init__.py +0 -0
- compass_mcp/guide.py +45 -0
- compass_mcp/py.typed +0 -0
- compass_mcp/server.py +261 -0
- compass_server/__init__.py +5 -0
- compass_server/errors.py +84 -0
- compass_server/main.py +42 -0
- compass_server/py.typed +0 -0
- compass_server/routers/__init__.py +0 -0
- compass_server/routers/query.py +182 -0
- compass_server/settings.py +37 -0
- compass_server/static/404.html +1 -0
- compass_server/static/_next/static/chunks/1629039a.eed2a06658ec4f9c.js +1 -0
- compass_server/static/_next/static/chunks/1ff282bf-bb7c483245264fc9.js +1 -0
- compass_server/static/_next/static/chunks/223.f881a22f446259bf.js +508 -0
- compass_server/static/_next/static/chunks/51fa5c19.6396973222c9d6fd.js +485 -0
- compass_server/static/_next/static/chunks/556.443c1682b6b20128.js +1 -0
- compass_server/static/_next/static/chunks/684-1aa0170f59baf77e.js +1 -0
- compass_server/static/_next/static/chunks/92e71660.3a42d4f70d017951.js +342 -0
- compass_server/static/_next/static/chunks/937-8692224bb6d1b605.js +1 -0
- compass_server/static/_next/static/chunks/app/_not-found/page-2c96ddf295eb6a86.js +1 -0
- compass_server/static/_next/static/chunks/app/layout-4bd0ba4a7d18aff9.js +1 -0
- compass_server/static/_next/static/chunks/app/page-5376a0a98bbd1faa.js +1 -0
- compass_server/static/_next/static/chunks/framework-96dcd958cfe08bd4.js +1 -0
- compass_server/static/_next/static/chunks/main-6dee8e626b16731a.js +1 -0
- compass_server/static/_next/static/chunks/main-app-460e8bc670818718.js +1 -0
- compass_server/static/_next/static/chunks/pages/_app-638a3e7363f92b80.js +1 -0
- compass_server/static/_next/static/chunks/pages/_error-6a72dbbb45cae743.js +1 -0
- compass_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- compass_server/static/_next/static/chunks/webpack-597057bf48ce4111.js +1 -0
- compass_server/static/_next/static/css/1b0fbfd8dd653f37.css +3 -0
- compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_buildManifest.js +1 -0
- compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_ssgManifest.js +1 -0
- compass_server/static/_next/static/media/0086f8992871c45b-s.woff2 +0 -0
- compass_server/static/_next/static/media/034d78ad42e9620c-s.woff2 +0 -0
- compass_server/static/_next/static/media/03b436aa846269de-s.woff2 +0 -0
- compass_server/static/_next/static/media/0484562807a97172-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/08aedeceaf1dcd57-s.woff2 +0 -0
- compass_server/static/_next/static/media/0aa834ed78bf6d07-s.woff2 +0 -0
- compass_server/static/_next/static/media/29a4aea02fdee119-s.woff2 +0 -0
- compass_server/static/_next/static/media/29e7bbdce9332268-s.woff2 +0 -0
- compass_server/static/_next/static/media/36008747766f78c6-s.woff2 +0 -0
- compass_server/static/_next/static/media/39969fcf98a3026e-s.woff2 +0 -0
- compass_server/static/_next/static/media/4de1fea1a954a5b6-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/5c0c2bcbaa4149ca-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/67957d42bae0796d-s.woff2 +0 -0
- compass_server/static/_next/static/media/6d38f9d4e0f4772b-s.woff2 +0 -0
- compass_server/static/_next/static/media/6d664cce900333ee-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/886030b0b59bc5a7-s.woff2 +0 -0
- compass_server/static/_next/static/media/8888a3826f4a3af4-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/939c4f875ee75fbb-s.woff2 +0 -0
- compass_server/static/_next/static/media/a1386beebedccca4-s.woff2 +0 -0
- compass_server/static/_next/static/media/ab00a911ac2adf48-s.woff2 +0 -0
- compass_server/static/_next/static/media/b957ea75a84b6ea7-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/bb3ef058b751a6ad-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/c3bc380753a8436c-s.woff2 +0 -0
- compass_server/static/_next/static/media/cce080f35d014443-s.woff2 +0 -0
- compass_server/static/_next/static/media/db911767852bc875-s.woff2 +0 -0
- compass_server/static/_next/static/media/eafabf029ad39a43-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/f10b8e9d91f3edcb-s.woff2 +0 -0
- compass_server/static/_next/static/media/f911b923c6adde36-s.woff2 +0 -0
- compass_server/static/_next/static/media/fe0777f1195381cb-s.woff2 +0 -0
- compass_server/static/index.html +1 -0
- compass_server/static/index.txt +25 -0
- compass_server/web_bundle.py +27 -0
- compass_server/web_limits.py +48 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The ``compass setup`` command: tell coding agents in a repository about Code Compass.
|
|
2
|
+
|
|
3
|
+
It keeps one managed block, between ``<!-- compass:begin -->`` and ``<!-- compass:end -->``,
|
|
4
|
+
in ``AGENTS.md`` (read by Codex, Cursor and others) and ``CLAUDE.md`` (read by Claude
|
|
5
|
+
Code), creating the files when missing and never touching text outside the markers.
|
|
6
|
+
``CLAUDE.md`` is left alone when it already imports ``@AGENTS.md``. With ``--mcp`` it also
|
|
7
|
+
registers the MCP server in the project's ``.mcp.json``. Running it again refreshes the
|
|
8
|
+
block and changes nothing else.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from compass_core.index.locate import find_index
|
|
20
|
+
from compass_mcp.guide import AGENT_GUIDE
|
|
21
|
+
from compass_mcp.server import SERVER_NAME
|
|
22
|
+
|
|
23
|
+
BEGIN = "<!-- compass:begin -->"
|
|
24
|
+
END = "<!-- compass:end -->"
|
|
25
|
+
AGENTS_FILE = "AGENTS.md"
|
|
26
|
+
CLAUDE_FILE = "CLAUDE.md"
|
|
27
|
+
CLAUDE_IMPORTS_AGENTS = "@AGENTS.md"
|
|
28
|
+
MCP_FILE = ".mcp.json"
|
|
29
|
+
MCP_ENTRY: dict[str, object] = {"command": "compass", "args": ["mcp"]}
|
|
30
|
+
CODEX_HINT = f"""\
|
|
31
|
+
For Codex, add this to ~/.codex/config.toml:
|
|
32
|
+
[mcp_servers.{SERVER_NAME}]
|
|
33
|
+
command = "compass"
|
|
34
|
+
args = ["mcp"]"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Change(StrEnum):
|
|
38
|
+
"""What ``setup`` did to one file.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
CREATED: The file did not exist and was written.
|
|
42
|
+
UPDATED: The file existed and its managed part was added or replaced.
|
|
43
|
+
UNCHANGED: The file already had exactly this content.
|
|
44
|
+
SKIPPED: The file was deliberately left alone.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
CREATED = "created"
|
|
48
|
+
UPDATED = "updated"
|
|
49
|
+
UNCHANGED = "unchanged"
|
|
50
|
+
SKIPPED = "skipped"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def managed_block() -> str:
|
|
54
|
+
"""The agent guide between the markers, ending with a newline.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The block ``setup`` keeps in agent instruction files.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
``managed_block().splitlines()[0]`` is ``"<!-- compass:begin -->"``.
|
|
61
|
+
"""
|
|
62
|
+
return f"{BEGIN}\n{AGENT_GUIDE}{END}\n"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def upsert_block(text: str, block: str) -> str:
|
|
66
|
+
"""Replace the managed block in ``text``, or append it; other text is kept as is.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
text: Current file content (may be empty).
|
|
70
|
+
block: The new managed block, markers included.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The new content.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
``upsert_block("# Rules\\n", block)`` returns ``"# Rules\\n\\n" + block``; calling it
|
|
77
|
+
again on that result with a newer block replaces only the block.
|
|
78
|
+
"""
|
|
79
|
+
start = text.find(BEGIN)
|
|
80
|
+
end = text.find(END, start)
|
|
81
|
+
if start != -1 and end != -1:
|
|
82
|
+
tail = text[end + len(END) :].removeprefix("\n")
|
|
83
|
+
return text[:start] + block + tail
|
|
84
|
+
if not text.strip():
|
|
85
|
+
return block
|
|
86
|
+
return text.rstrip("\n") + "\n\n" + block
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def write_guide(path: Path, block: str) -> Change:
|
|
90
|
+
"""Put the managed block into one agent instruction file.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
path: ``AGENTS.md`` or ``CLAUDE.md``.
|
|
94
|
+
block: The managed block.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
What happened to the file.
|
|
98
|
+
"""
|
|
99
|
+
existed = path.exists()
|
|
100
|
+
current = path.read_text(encoding="utf-8") if existed else ""
|
|
101
|
+
if path.name == CLAUDE_FILE and CLAUDE_IMPORTS_AGENTS in current.replace(block, ""):
|
|
102
|
+
return Change.SKIPPED
|
|
103
|
+
updated = upsert_block(current, block)
|
|
104
|
+
if updated == current:
|
|
105
|
+
return Change.UNCHANGED
|
|
106
|
+
path.write_text(updated, encoding="utf-8")
|
|
107
|
+
return Change.UPDATED if existed else Change.CREATED
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def register_mcp(path: Path) -> Change:
|
|
111
|
+
"""Add or refresh the Code Compass entry in a project's ``.mcp.json``.
|
|
112
|
+
|
|
113
|
+
Other servers and keys are kept. A file that is not a JSON object is not touched.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
path: The ``.mcp.json`` file.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
What happened to the file.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ValueError: The existing file is not a JSON object with an ``mcpServers`` object.
|
|
123
|
+
"""
|
|
124
|
+
existed = path.exists()
|
|
125
|
+
data = json.loads(path.read_text(encoding="utf-8")) if existed else {}
|
|
126
|
+
servers = data.setdefault("mcpServers", {}) if isinstance(data, dict) else None
|
|
127
|
+
if not isinstance(servers, dict):
|
|
128
|
+
raise ValueError(f"{path} is not a JSON object with an 'mcpServers' object")
|
|
129
|
+
if servers.get(SERVER_NAME) == MCP_ENTRY:
|
|
130
|
+
return Change.UNCHANGED
|
|
131
|
+
servers[SERVER_NAME] = MCP_ENTRY
|
|
132
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
133
|
+
return Change.UPDATED if existed else Change.CREATED
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
REPO = typer.Option(
|
|
137
|
+
Path("."), "--repo", file_okay=False, help="Repository to set up; default: here."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def setup(
|
|
142
|
+
repo: Path = REPO,
|
|
143
|
+
mcp: bool = typer.Option(False, "--mcp", help=f"Also register the server in {MCP_FILE}."),
|
|
144
|
+
print_only: bool = typer.Option(
|
|
145
|
+
False, "--print", help="Print the agent guide and write nothing."
|
|
146
|
+
),
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Tell coding agents in this repository to use Code Compass.
|
|
149
|
+
|
|
150
|
+
Writes a managed section to AGENTS.md and CLAUDE.md (and, with --mcp, the MCP server
|
|
151
|
+
to .mcp.json). Safe to run again; text outside the section is never changed.
|
|
152
|
+
"""
|
|
153
|
+
block = managed_block()
|
|
154
|
+
if print_only:
|
|
155
|
+
typer.echo(block, nl=False)
|
|
156
|
+
return
|
|
157
|
+
root = repo.resolve()
|
|
158
|
+
for name in (AGENTS_FILE, CLAUDE_FILE):
|
|
159
|
+
change = write_guide(root / name, block)
|
|
160
|
+
note = f" (it imports {CLAUDE_IMPORTS_AGENTS})" if change is Change.SKIPPED else ""
|
|
161
|
+
typer.echo(f"{name}: {change.value}{note}")
|
|
162
|
+
if mcp:
|
|
163
|
+
try:
|
|
164
|
+
change = register_mcp(root / MCP_FILE)
|
|
165
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
166
|
+
typer.echo(f"{MCP_FILE}: not changed: {exc}", err=True)
|
|
167
|
+
raise typer.Exit(code=1) from exc
|
|
168
|
+
typer.echo(f"{MCP_FILE}: {change.value} ({SERVER_NAME} runs `compass mcp`)")
|
|
169
|
+
typer.echo(CODEX_HINT)
|
|
170
|
+
if find_index(root) is None:
|
|
171
|
+
typer.echo("No index yet: run `compass ingest` here before asking questions.")
|
compass_cli/display.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Rich-based display rendering for the Code Compass CLI.
|
|
2
|
+
|
|
3
|
+
All terminal output formatting lives here — commands/ handles argument
|
|
4
|
+
parsing and phase orchestration, this module handles how things look.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.console import Console, Group
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.rule import Rule
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from compass_cli.progress import fmt_duration
|
|
18
|
+
from compass_cli.types import IngestSummary
|
|
19
|
+
from compass_core.shared_types import Language
|
|
20
|
+
|
|
21
|
+
GOLD = "color(214)"
|
|
22
|
+
"""Accent colour of every Code Compass panel, rule and command name."""
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Help
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_COMMANDS: tuple[tuple[str, str], ...] = (
|
|
33
|
+
("ingest", "Index a repository (run again after code changes)"),
|
|
34
|
+
("status", "Show which index is used and whether it is up to date"),
|
|
35
|
+
("search", "Find files, classes, functions and methods by name"),
|
|
36
|
+
("describe", "Show one symbol: location, docstring, callers, callees"),
|
|
37
|
+
("impact", "List what may break if a symbol or file changes"),
|
|
38
|
+
("overview", "Summarise the repository: entry points and hot spots"),
|
|
39
|
+
("setup", "Tell coding agents in this repo to use Code Compass"),
|
|
40
|
+
("mcp", "Serve the code graph to coding agents over MCP"),
|
|
41
|
+
("serve", "Start the API server and the web UI"),
|
|
42
|
+
("help", "Show this list"),
|
|
43
|
+
)
|
|
44
|
+
"""Commands listed by ``compass`` with no arguments, in the order a user needs them."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def help_commands() -> None:
|
|
48
|
+
"""Print the available CLI commands with one line each."""
|
|
49
|
+
width = max(len(name) for name, _ in _COMMANDS)
|
|
50
|
+
console.print(f"[bold {GOLD}]Code Compass[/bold {GOLD}] — Navigate your codebase with ease\n")
|
|
51
|
+
console.print("[bold]Usage:[/bold] compass [OPTIONS] COMMAND [ARGS]...\n")
|
|
52
|
+
console.print("[bold]Commands:[/bold]")
|
|
53
|
+
for name, summary in _COMMANDS:
|
|
54
|
+
console.print(f" [bold {GOLD}]{name:<{width}}[/bold {GOLD}] {summary}")
|
|
55
|
+
console.print()
|
|
56
|
+
console.print("[dim]Run [bold]compass COMMAND --help[/bold] for a command's options.[/dim]")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Ingest command display helpers
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ingest_header(repo_path: Path) -> None:
|
|
65
|
+
"""Print the opening line for an ingest run.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
repo_path: The filesystem path to the repository being ingested.
|
|
69
|
+
"""
|
|
70
|
+
console.print(f"\n[dim]Ingesting[/dim] [bold]{repo_path}[/bold]\n")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def phase_header(num: int, total: int, title: str) -> None:
|
|
74
|
+
"""Print a centered phase separator rule.
|
|
75
|
+
|
|
76
|
+
Example output: `Phase 1 of 2 · Ingestion` (centered with a gold rule).
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
num: The 1-indexed phase number currently starting.
|
|
80
|
+
total: The total number of phases in the run.
|
|
81
|
+
title: A descriptive name for the current phase.
|
|
82
|
+
"""
|
|
83
|
+
console.print(
|
|
84
|
+
Rule(
|
|
85
|
+
f"[bold {GOLD}]Phase {num} of {total}[/bold {GOLD}]"
|
|
86
|
+
f"[dim] · [/dim]"
|
|
87
|
+
f"[bold]{title}[/bold]",
|
|
88
|
+
style=GOLD,
|
|
89
|
+
align="center",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
console.print()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _language_grid(language_counts: dict[Language, int]) -> Table:
|
|
96
|
+
"""Lay out a "Languages" label followed by a language -> file-count
|
|
97
|
+
breakdown, three columns wide."""
|
|
98
|
+
grid = Table.grid(padding=(0, 3))
|
|
99
|
+
grid.add_column(style="dim", min_width=13)
|
|
100
|
+
for _ in range(3):
|
|
101
|
+
grid.add_column(style="bold")
|
|
102
|
+
grid.add_column(style="dim", min_width=6)
|
|
103
|
+
|
|
104
|
+
items = sorted(language_counts.items(), key=lambda kv: kv[1], reverse=True)
|
|
105
|
+
for i in range(0, len(items), 3):
|
|
106
|
+
row: list[str] = ["Languages" if i == 0 else ""]
|
|
107
|
+
for lang, count in items[i : i + 3]:
|
|
108
|
+
row += [lang, f"{count:,}"]
|
|
109
|
+
row += [""] * (7 - len(row))
|
|
110
|
+
grid.add_row(*row)
|
|
111
|
+
return grid
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def summary(summary: IngestSummary) -> None:
|
|
115
|
+
"""Print the final summary in a gold-bordered panel.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
summary: Aggregate results of the ingest run to display.
|
|
119
|
+
"""
|
|
120
|
+
left = Table.grid(padding=(0, 3))
|
|
121
|
+
left.add_column(style="dim", min_width=13)
|
|
122
|
+
left.add_column(style="bold")
|
|
123
|
+
left.add_row("Files indexed", f"{summary.file_count:,}")
|
|
124
|
+
left.add_row("Classes", f"{summary.class_count:,}")
|
|
125
|
+
left.add_row("Functions", f"{summary.function_count:,}")
|
|
126
|
+
left.add_row("Skipped", str(summary.skipped))
|
|
127
|
+
left.add_row("Elapsed", fmt_duration(summary.elapsed))
|
|
128
|
+
|
|
129
|
+
right = Table.grid(padding=(0, 3))
|
|
130
|
+
right.add_column(style="dim", min_width=9)
|
|
131
|
+
right.add_column(style="bold")
|
|
132
|
+
right.add_row("Nodes", f"{summary.node_count:,}")
|
|
133
|
+
right.add_row("Edges", f"{summary.edge_count:,}")
|
|
134
|
+
if summary.dropped_edges:
|
|
135
|
+
right.add_row("Skipped", f"{summary.dropped_edges:,} edges")
|
|
136
|
+
right.add_row("Resolved", f"{summary.resolved_pct:.0%}")
|
|
137
|
+
right.add_row("Orphans", f"{summary.orphan_count:,}")
|
|
138
|
+
right.add_row("DB size", fmt_size(summary.db_size))
|
|
139
|
+
|
|
140
|
+
columns = Table.grid(padding=(0, 6))
|
|
141
|
+
columns.add_column()
|
|
142
|
+
columns.add_column()
|
|
143
|
+
columns.add_row(left, right)
|
|
144
|
+
|
|
145
|
+
db_line = Text(no_wrap=True, overflow="fold")
|
|
146
|
+
db_line.append("Database ", style="dim")
|
|
147
|
+
db_line.append(str(summary.db_path), style="bold")
|
|
148
|
+
|
|
149
|
+
parts: list[Table | Text] = [columns, Text("")]
|
|
150
|
+
if summary.language_counts:
|
|
151
|
+
parts.append(_language_grid(summary.language_counts))
|
|
152
|
+
parts.append(Text(""))
|
|
153
|
+
parts.append(db_line)
|
|
154
|
+
|
|
155
|
+
console.print(
|
|
156
|
+
Panel(
|
|
157
|
+
Group(*parts),
|
|
158
|
+
title=f"[bold {GOLD}]compass ingest complete[/bold {GOLD}]",
|
|
159
|
+
border_style=GOLD,
|
|
160
|
+
padding=(1, 2),
|
|
161
|
+
expand=True,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
if summary.dropped_edges:
|
|
165
|
+
console.print(f" [dim]{skipped_edges_note(summary.dropped_edges)}[/dim]", soft_wrap=True)
|
|
166
|
+
console.print()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def skipped_edges_note(count: int) -> str:
|
|
170
|
+
"""Explain why some resolved edges are missing from the database.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
count: Number of edges the writer could not store.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
A one-line explanation, e.g. ``"3 edges skipped (callee is not a stored
|
|
177
|
+
function, e.g. class constructor calls)"``.
|
|
178
|
+
"""
|
|
179
|
+
noun = "edge" if count == 1 else "edges"
|
|
180
|
+
return (
|
|
181
|
+
f"{count:,} {noun} skipped (callee is not a stored function, e.g. class constructor calls)"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
NEXT_STEPS: tuple[tuple[str, str], ...] = (
|
|
186
|
+
("compass overview", "See entry points and hot spots"),
|
|
187
|
+
("compass search NAME", "Find where something is defined"),
|
|
188
|
+
("compass setup", "Let coding agents (Claude Code, Codex) use this index"),
|
|
189
|
+
("compass serve", "Explore the graph in the web UI"),
|
|
190
|
+
("compass help", "See every command"),
|
|
191
|
+
)
|
|
192
|
+
"""Commands suggested after an ingest, most useful first."""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def next_steps() -> None:
|
|
196
|
+
"""Print the commands worth running after a successful ingest."""
|
|
197
|
+
width = max(len(command) for command, _ in NEXT_STEPS)
|
|
198
|
+
console.print("[bold]What's next:[/bold]")
|
|
199
|
+
for command, summary in NEXT_STEPS:
|
|
200
|
+
console.print(f" [bold {GOLD}]{command:<{width}}[/bold {GOLD}] {summary}")
|
|
201
|
+
console.print()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Utilities
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def error(message: str) -> None:
|
|
210
|
+
"""Print a formatted error message.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
message: The error text to display.
|
|
214
|
+
"""
|
|
215
|
+
console.print(f"[bold red]Error:[/bold red] {message}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def fmt_size(size_bytes: int) -> str:
|
|
219
|
+
"""Format a byte count as a human-readable string.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
size_bytes: The number of bytes to format.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
A formatted string (e.g., "1.5 KB", "300 B").
|
|
226
|
+
"""
|
|
227
|
+
if size_bytes < 1024:
|
|
228
|
+
return f"{size_bytes} B"
|
|
229
|
+
if size_bytes < 1024 * 1024:
|
|
230
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
231
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
compass_cli/errors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared CLI error-boundary decorator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from compass_cli import display
|
|
12
|
+
|
|
13
|
+
F = TypeVar("F", bound=Callable[..., None])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def handle_errors(func: F) -> F:
|
|
17
|
+
"""Catch expected OS-level failures, print a clean message, and exit(1).
|
|
18
|
+
|
|
19
|
+
Anything not an OSError still propagates with a full traceback.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@functools.wraps(func)
|
|
23
|
+
def wrapper(*args: object, **kwargs: object) -> None:
|
|
24
|
+
try:
|
|
25
|
+
func(*args, **kwargs)
|
|
26
|
+
except OSError as exc:
|
|
27
|
+
display.error(str(exc))
|
|
28
|
+
raise typer.Exit(code=1) from exc
|
|
29
|
+
|
|
30
|
+
return wrapper # type: ignore[return-value] # functools.wraps keeps the signature; mypy cannot prove wrapper is F
|
compass_cli/main.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from compass_cli import display
|
|
6
|
+
from compass_cli.commands.ingest import ingest
|
|
7
|
+
from compass_cli.commands.mcp import mcp
|
|
8
|
+
from compass_cli.commands.query import describe, impact, overview, search, status
|
|
9
|
+
from compass_cli.commands.serve import serve
|
|
10
|
+
from compass_cli.commands.setup import setup
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
help="Code Compass CLI",
|
|
14
|
+
no_args_is_help=False,
|
|
15
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DISTRIBUTION = "compass-code"
|
|
20
|
+
"""The PyPI package that installs the ``compass`` command."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _print_version(value: bool) -> None:
|
|
24
|
+
if value:
|
|
25
|
+
typer.echo(f"{DISTRIBUTION} {version(DISTRIBUTION)}")
|
|
26
|
+
raise typer.Exit()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
VERSION = typer.Option(
|
|
30
|
+
False, "--version", "-V", callback=_print_version, is_eager=True, help="Print the version."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback(invoke_without_command=True)
|
|
35
|
+
def main(ctx: typer.Context, show_version: bool = VERSION) -> None:
|
|
36
|
+
"""Code Compass — Navigate your codebase with ease."""
|
|
37
|
+
if ctx.invoked_subcommand is None:
|
|
38
|
+
display.help_commands()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
app.command(name="ingest")(ingest)
|
|
42
|
+
app.command(name="status")(status)
|
|
43
|
+
app.command(name="search")(search)
|
|
44
|
+
app.command(name="describe")(describe)
|
|
45
|
+
app.command(name="impact")(impact)
|
|
46
|
+
app.command(name="overview")(overview)
|
|
47
|
+
app.command(name="serve")(serve)
|
|
48
|
+
app.command(name="mcp")(mcp)
|
|
49
|
+
app.command(name="setup")(setup)
|
|
50
|
+
app.command(name="help")(display.help_commands)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
app()
|
compass_cli/progress.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Rich Live table reporter for the CLI ingest pipeline.
|
|
2
|
+
|
|
3
|
+
``IngestReporter`` owns a single Rich ``Live`` table with one row per
|
|
4
|
+
pipeline stage (STAGE / RESULT / TIME). A row appears the moment its stage
|
|
5
|
+
starts, shows a live current/total counter while running (for stages that
|
|
6
|
+
report granular progress — parse, persistence), and freezes to its final
|
|
7
|
+
summary text once the stage completes via ``complete()``.
|
|
8
|
+
|
|
9
|
+
Adding a new phase
|
|
10
|
+
-------------------
|
|
11
|
+
1. Add a ``(phase_name, stage_label)`` entry to ``_STAGE_LABELS``.
|
|
12
|
+
2. If the phase reports granular progress, map its ``ProgressReporter``
|
|
13
|
+
phase name(s) to it in ``_REPORTER_TO_STAGE``.
|
|
14
|
+
3. Call ``reporter.complete(phase_name, primary, secondary, warn=...)`` from
|
|
15
|
+
the CLI once that phase finishes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from rich.console import Console, Group, RenderableType
|
|
24
|
+
from rich.live import Live
|
|
25
|
+
from rich.spinner import Spinner
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
|
|
29
|
+
from compass_core.progress import Phase
|
|
30
|
+
|
|
31
|
+
_GOLD = "color(214)"
|
|
32
|
+
_GREEN = "green"
|
|
33
|
+
_WARN = "color(208)"
|
|
34
|
+
_DIM = "dim"
|
|
35
|
+
_BAR_WIDTH = 14
|
|
36
|
+
_DOT_THRESHOLD = 0.12
|
|
37
|
+
|
|
38
|
+
_STAGE_LABELS: dict[str, str] = {
|
|
39
|
+
"discovery": "discover",
|
|
40
|
+
"parse": "parse",
|
|
41
|
+
"symbol_table": "symbols",
|
|
42
|
+
"resolution": "resolve",
|
|
43
|
+
"validation": "validate",
|
|
44
|
+
"persistence": "write",
|
|
45
|
+
}
|
|
46
|
+
_STAGE_ORDER = list(_STAGE_LABELS)
|
|
47
|
+
|
|
48
|
+
# ProgressReporter phase names (fine-grained, item-level) mapped onto the
|
|
49
|
+
# pipeline stage whose row they should update while it runs.
|
|
50
|
+
_REPORTER_TO_STAGE: dict[str, str] = {
|
|
51
|
+
"parse": "parse",
|
|
52
|
+
"write_nodes": "persistence",
|
|
53
|
+
"write_edges": "persistence",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class _StageRow:
|
|
59
|
+
status: str = "pending" # pending | running | done
|
|
60
|
+
primary: str = ""
|
|
61
|
+
secondary: str = ""
|
|
62
|
+
warn: bool = False
|
|
63
|
+
start: float | None = None
|
|
64
|
+
duration: float | None = None
|
|
65
|
+
current: int = 0
|
|
66
|
+
total: int = 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fmt_duration(seconds: float) -> str:
|
|
70
|
+
if seconds >= 60:
|
|
71
|
+
minutes, rest = divmod(seconds, 60)
|
|
72
|
+
return f"{int(minutes)}m {rest:.0f}s"
|
|
73
|
+
return f"{seconds:.1f}s"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _bar(frac: float, *, style: str) -> Text:
|
|
77
|
+
frac = max(0.0, min(1.0, frac))
|
|
78
|
+
if frac < _DOT_THRESHOLD:
|
|
79
|
+
return Text("●", style=_DIM)
|
|
80
|
+
filled = max(1, round(frac * _BAR_WIDTH))
|
|
81
|
+
return Text("━" * filled, style=style)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class IngestReporter:
|
|
85
|
+
"""Live Rich table of pipeline stages; also satisfies ``ProgressReporter``."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, console: Console | None = None) -> None:
|
|
88
|
+
self._rows: dict[str, _StageRow] = {name: _StageRow() for name in _STAGE_ORDER}
|
|
89
|
+
self._show_total = False
|
|
90
|
+
self._live = Live(self._render(), console=console, refresh_per_second=8, transient=False)
|
|
91
|
+
|
|
92
|
+
def __enter__(self) -> IngestReporter:
|
|
93
|
+
self._live.start()
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def __exit__(self, *_: object) -> None:
|
|
97
|
+
self._live.stop()
|
|
98
|
+
|
|
99
|
+
# -- ProgressReporter protocol -----------------------------------------
|
|
100
|
+
|
|
101
|
+
def update(self, phase: Phase, current: int, total: int) -> None:
|
|
102
|
+
"""Update the live current/total counter for the stage *phase* maps to."""
|
|
103
|
+
stage = _REPORTER_TO_STAGE.get(phase)
|
|
104
|
+
if stage is None:
|
|
105
|
+
return
|
|
106
|
+
row = self._rows[stage]
|
|
107
|
+
if row.status == "pending":
|
|
108
|
+
row.status = "running"
|
|
109
|
+
row.start = time.monotonic()
|
|
110
|
+
row.current, row.total = current, total
|
|
111
|
+
self._refresh()
|
|
112
|
+
|
|
113
|
+
# -- CLI-facing API -------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def complete(self, phase: str, primary: str, secondary: str, *, warn: bool = False) -> None:
|
|
116
|
+
"""Freeze *phase*'s row with its final summary text and elapsed duration."""
|
|
117
|
+
row = self._rows[phase]
|
|
118
|
+
now = time.monotonic()
|
|
119
|
+
if row.start is None:
|
|
120
|
+
row.start = now
|
|
121
|
+
row.duration = now - row.start
|
|
122
|
+
row.status = "done"
|
|
123
|
+
row.primary = primary
|
|
124
|
+
row.secondary = secondary
|
|
125
|
+
row.warn = warn
|
|
126
|
+
self._refresh()
|
|
127
|
+
|
|
128
|
+
def finalize(self) -> None:
|
|
129
|
+
"""Show the total row and stop the live display."""
|
|
130
|
+
self._show_total = True
|
|
131
|
+
self._refresh()
|
|
132
|
+
self._live.stop()
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def total_duration(self) -> float:
|
|
136
|
+
return sum(r.duration or 0.0 for r in self._rows.values())
|
|
137
|
+
|
|
138
|
+
# -- rendering --------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _refresh(self) -> None:
|
|
141
|
+
self._live.update(self._render())
|
|
142
|
+
|
|
143
|
+
def _render(self) -> RenderableType:
|
|
144
|
+
table = Table.grid(padding=(0, 3))
|
|
145
|
+
table.add_column(min_width=9)
|
|
146
|
+
table.add_column(min_width=26)
|
|
147
|
+
table.add_column(justify="right", min_width=10)
|
|
148
|
+
|
|
149
|
+
table.add_row(
|
|
150
|
+
Text("STAGE", style=_DIM), Text("RESULT", style=_DIM), Text("TIME", style=_DIM)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
max_duration = max((r.duration for r in self._rows.values() if r.duration), default=0.0)
|
|
154
|
+
for name in _STAGE_ORDER:
|
|
155
|
+
row = self._rows[name]
|
|
156
|
+
if row.status == "pending":
|
|
157
|
+
continue
|
|
158
|
+
label = _STAGE_LABELS[name]
|
|
159
|
+
|
|
160
|
+
if row.status == "running":
|
|
161
|
+
stage_text = Text(label, style=f"bold {_GOLD}")
|
|
162
|
+
progress_text = Text(
|
|
163
|
+
f"{row.current:,} / {row.total:,}" if row.total else "working…"
|
|
164
|
+
)
|
|
165
|
+
table.add_row(stage_text, progress_text, Spinner("dots", style=_GOLD))
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
style = _WARN if row.warn else "bold"
|
|
169
|
+
stage_text = Text(label, style=style)
|
|
170
|
+
result: RenderableType = (
|
|
171
|
+
Group(Text(row.primary, style=style), Text(row.secondary, style=_DIM))
|
|
172
|
+
if row.secondary
|
|
173
|
+
else Text(row.primary, style=style)
|
|
174
|
+
)
|
|
175
|
+
frac = ((row.duration or 0.0) / max_duration) if max_duration else 0.0
|
|
176
|
+
bar_style = _WARN if row.warn else (_GREEN if frac >= _DOT_THRESHOLD else _DIM)
|
|
177
|
+
time_cell = Group(
|
|
178
|
+
Text(fmt_duration(row.duration or 0.0), justify="right"),
|
|
179
|
+
_bar(frac, style=bar_style),
|
|
180
|
+
)
|
|
181
|
+
table.add_row(stage_text, result, time_cell)
|
|
182
|
+
table.add_row("", "", "")
|
|
183
|
+
|
|
184
|
+
if self._show_total:
|
|
185
|
+
table.add_row("", "", "")
|
|
186
|
+
table.add_row(
|
|
187
|
+
Text("total", style=_DIM),
|
|
188
|
+
Text(""),
|
|
189
|
+
Text(fmt_duration(self.total_duration), style="bold", justify="right"),
|
|
190
|
+
)
|
|
191
|
+
return table
|