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,202 @@
|
|
|
1
|
+
"""The single-hop queries behind the impact walks, and the typed rows they return.
|
|
2
|
+
|
|
3
|
+
Each query expands one distance level for a whole frontier at once (``WHERE x IN $ids``
|
|
4
|
+
or ``$paths``). Rows carry the stored keys the walks match on next to the public
|
|
5
|
+
symbols they return.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from compass_core.contract.models import EdgeRef, SymbolRef
|
|
13
|
+
from compass_core.graph.db import Row
|
|
14
|
+
from compass_core.query.cypher import Query
|
|
15
|
+
from compass_core.query.rows import (
|
|
16
|
+
CLASS_FIELDS,
|
|
17
|
+
CLASS_WIDTH,
|
|
18
|
+
FILE_FIELDS,
|
|
19
|
+
FILE_WIDTH,
|
|
20
|
+
FUNCTION_FIELDS,
|
|
21
|
+
FUNCTION_WIDTH,
|
|
22
|
+
call_type_value,
|
|
23
|
+
class_from_row,
|
|
24
|
+
columns,
|
|
25
|
+
confidence_value,
|
|
26
|
+
file_from_row,
|
|
27
|
+
function_from_row,
|
|
28
|
+
line_value,
|
|
29
|
+
text_value,
|
|
30
|
+
)
|
|
31
|
+
from compass_core.shared_types import EdgeType
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
35
|
+
class CallRow:
|
|
36
|
+
"""A ``CALLS`` edge between two stored functions.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
caller: The calling function.
|
|
40
|
+
callee: The called function.
|
|
41
|
+
confidence: Resolver confidence of the edge.
|
|
42
|
+
edge: The edge as returned in results.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
``def handle(): decode_token(t)`` gives ``CallRow(caller=<handle>,
|
|
46
|
+
callee=<decode_token>, confidence=1.0, edge=EdgeRef(type=EdgeType.CALLS, ...))``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
caller: SymbolRef
|
|
50
|
+
callee: SymbolRef
|
|
51
|
+
confidence: float
|
|
52
|
+
edge: EdgeRef
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
56
|
+
class MethodCallRow:
|
|
57
|
+
"""A call to a method of a frontier class.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
call: The ``CALLS`` edge to the method.
|
|
61
|
+
class_key: Id of the class that owns the method.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
``User().save()`` resolved to ``User.save`` gives
|
|
65
|
+
``MethodCallRow(call=<caller CALLS User.save>, class_key="app/models.py::User")``.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
call: CallRow
|
|
69
|
+
class_key: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
73
|
+
class ExternalCallRow:
|
|
74
|
+
"""A ``CALLS_EXTERN`` edge from a stored function.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
caller: The calling function.
|
|
78
|
+
line: 1-indexed call line, or ``None`` when unknown.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
``def encode(raw): return json.dumps(raw)`` gives
|
|
82
|
+
``ExternalCallRow(caller=<encode>, line=None)`` (call lines are not stored yet).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
caller: SymbolRef
|
|
86
|
+
line: int | None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
90
|
+
class PairRow:
|
|
91
|
+
"""Two nodes joined by a structural edge (``IMPORTS`` or ``INHERITS``).
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
source: The importing file or the subclass.
|
|
95
|
+
target: The imported file or the base class.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
``class Admin(User)`` gives ``PairRow(source=<Admin>, target=<User>)``;
|
|
99
|
+
``from app.auth import decode_token`` in ``app/api.py`` gives
|
|
100
|
+
``PairRow(source=<app/api.py>, target=<app/auth.py>)``.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
source: SymbolRef
|
|
104
|
+
target: SymbolRef
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_CALL_END = 2 * FUNCTION_WIDTH
|
|
108
|
+
_CALL_COLUMNS = "r.confidence, r.call_type, r.line"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _call_row(row: Row) -> CallRow:
|
|
112
|
+
caller = function_from_row(row[:FUNCTION_WIDTH])
|
|
113
|
+
callee = function_from_row(row[FUNCTION_WIDTH:_CALL_END])
|
|
114
|
+
confidence = confidence_value(row[_CALL_END])
|
|
115
|
+
edge = EdgeRef(
|
|
116
|
+
source_id=caller.id,
|
|
117
|
+
target_id=callee.id,
|
|
118
|
+
type=EdgeType.CALLS,
|
|
119
|
+
confidence=confidence,
|
|
120
|
+
call_type=call_type_value(row[_CALL_END + 1]),
|
|
121
|
+
line=line_value(row[_CALL_END + 2], "line"),
|
|
122
|
+
)
|
|
123
|
+
return CallRow(caller=caller, callee=callee, confidence=confidence, edge=edge)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _method_call_row(row: Row) -> MethodCallRow:
|
|
127
|
+
return MethodCallRow(call=_call_row(row), class_key=text_value(row[_CALL_END + 3], "id"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _external_call_row(row: Row) -> ExternalCallRow:
|
|
131
|
+
return ExternalCallRow(
|
|
132
|
+
caller=function_from_row(row[:FUNCTION_WIDTH]),
|
|
133
|
+
line=line_value(row[FUNCTION_WIDTH], "line"),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _class_pair(row: Row) -> PairRow:
|
|
138
|
+
return PairRow(
|
|
139
|
+
source=class_from_row(row[:CLASS_WIDTH]), target=class_from_row(row[CLASS_WIDTH:])
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _file_pair(row: Row) -> PairRow:
|
|
144
|
+
return PairRow(source=file_from_row(row[:FILE_WIDTH]), target=file_from_row(row[FILE_WIDTH:]))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_A_FUNCTION = columns("a", FUNCTION_FIELDS)
|
|
148
|
+
_B_FUNCTION = columns("b", FUNCTION_FIELDS)
|
|
149
|
+
|
|
150
|
+
CALLERS_OF_FUNCTIONS: Query[CallRow] = Query(
|
|
151
|
+
f"""
|
|
152
|
+
MATCH (a:Function)-[r:CALLS]->(b:Function)
|
|
153
|
+
WHERE b.id IN $ids AND r.confidence >= $minc
|
|
154
|
+
RETURN {_A_FUNCTION}, {_B_FUNCTION}, {_CALL_COLUMNS}
|
|
155
|
+
""",
|
|
156
|
+
_call_row,
|
|
157
|
+
)
|
|
158
|
+
CALLERS_OF_METHODS: Query[MethodCallRow] = Query(
|
|
159
|
+
f"""
|
|
160
|
+
MATCH (c:Class)-[:CONTAINS]->(b:Function)<-[r:CALLS]-(a:Function)
|
|
161
|
+
WHERE c.id IN $ids AND r.confidence >= $minc
|
|
162
|
+
RETURN {_A_FUNCTION}, {_B_FUNCTION}, {_CALL_COLUMNS}, c.id
|
|
163
|
+
""",
|
|
164
|
+
_method_call_row,
|
|
165
|
+
)
|
|
166
|
+
CALLERS_OF_EXTERNALS: Query[ExternalCallRow] = Query(
|
|
167
|
+
f"""
|
|
168
|
+
MATCH (a:Function)-[r:CALLS_EXTERN]->(e:ExternalFunction) WHERE e.full_ref IN $ids
|
|
169
|
+
RETURN {_A_FUNCTION}, r.line
|
|
170
|
+
""",
|
|
171
|
+
_external_call_row,
|
|
172
|
+
)
|
|
173
|
+
SUBCLASSES_OF: Query[PairRow] = Query(
|
|
174
|
+
f"""
|
|
175
|
+
MATCH (a:Class)-[:INHERITS]->(b:Class) WHERE b.id IN $ids
|
|
176
|
+
RETURN {columns("a", CLASS_FIELDS)}, {columns("b", CLASS_FIELDS)}
|
|
177
|
+
""",
|
|
178
|
+
_class_pair,
|
|
179
|
+
)
|
|
180
|
+
# File-target variants, keyed by the stored file paths of the frontier.
|
|
181
|
+
IMPORTERS_OF_FILES: Query[PairRow] = Query(
|
|
182
|
+
f"""
|
|
183
|
+
MATCH (a:File)-[:IMPORTS]->(b:File) WHERE b.path IN $paths
|
|
184
|
+
RETURN {columns("a", FILE_FIELDS)}, {columns("b", FILE_FIELDS)}
|
|
185
|
+
""",
|
|
186
|
+
_file_pair,
|
|
187
|
+
)
|
|
188
|
+
CALLERS_IN_FILES: Query[CallRow] = Query(
|
|
189
|
+
f"""
|
|
190
|
+
MATCH (a:Function)-[r:CALLS]->(b:Function)
|
|
191
|
+
WHERE b.file_path IN $paths AND r.confidence >= $minc
|
|
192
|
+
RETURN {_A_FUNCTION}, {_B_FUNCTION}, {_CALL_COLUMNS}
|
|
193
|
+
""",
|
|
194
|
+
_call_row,
|
|
195
|
+
)
|
|
196
|
+
SUBCLASSES_IN_FILES: Query[PairRow] = Query(
|
|
197
|
+
f"""
|
|
198
|
+
MATCH (a:Class)-[:INHERITS]->(b:Class) WHERE b.file_path IN $paths
|
|
199
|
+
RETURN {columns("a", CLASS_FIELDS)}, {columns("b", CLASS_FIELDS)}
|
|
200
|
+
""",
|
|
201
|
+
_class_pair,
|
|
202
|
+
)
|
compass_core/render.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Compact text for every query result, shared by the CLI and the MCP server.
|
|
2
|
+
|
|
3
|
+
One line per item, ``file:line`` first, so the text is cheap for an agent to read and
|
|
4
|
+
easy to grep. Every answer that describes the index starts with a header saying when it
|
|
5
|
+
was built and whether files changed since. Caveats are prefixed with ``!``. JSON output
|
|
6
|
+
does not go through here; it is ``contract.serialize.to_json``.
|
|
7
|
+
|
|
8
|
+
This module imports only the contract and the shared vocabulary.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from typing import assert_never
|
|
15
|
+
|
|
16
|
+
from compass_core.contract.errors import ErrorResponse
|
|
17
|
+
from compass_core.contract.models import Caveat, IndexStatus, ResultItem, SymbolRef, Via
|
|
18
|
+
from compass_core.contract.results import (
|
|
19
|
+
AmbiguousReference,
|
|
20
|
+
ImpactResult,
|
|
21
|
+
NodeDetail,
|
|
22
|
+
OverviewResult,
|
|
23
|
+
RankedSymbol,
|
|
24
|
+
SearchHit,
|
|
25
|
+
SearchResult,
|
|
26
|
+
SymbolNotFound,
|
|
27
|
+
)
|
|
28
|
+
from compass_core.contract.types import SearchMatch, ViaKind
|
|
29
|
+
from compass_core.shared_types import SymbolKind
|
|
30
|
+
|
|
31
|
+
_INDENT = " "
|
|
32
|
+
_ID_SEPARATOR = "::"
|
|
33
|
+
"""Splits a public id into its file part and its ``[Class.]name`` part."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def location(symbol: SymbolRef) -> str:
|
|
37
|
+
"""Where a symbol is: ``path:line`` for a definition, ``path`` for a file.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
symbol: Any symbol.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The 1-indexed location, or the external reference for an external function.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
``location(<User.save at app/models.py line 6>)`` returns ``"app/models.py:6"``.
|
|
47
|
+
"""
|
|
48
|
+
if symbol.file_path is None:
|
|
49
|
+
return symbol.qualified_name
|
|
50
|
+
if symbol.line_start is None:
|
|
51
|
+
return symbol.file_path
|
|
52
|
+
return f"{symbol.file_path}:{symbol.line_start}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def local_name(symbol: SymbolRef) -> str:
|
|
56
|
+
"""A symbol's name within its file: ``Class.method``, ``function`` or the file path.
|
|
57
|
+
|
|
58
|
+
Shorter than the qualified name; shown next to ``location``, which names the file.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
symbol: Any symbol.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The part of the public id after ``::`` for a class or function, the path for a
|
|
65
|
+
file, and the qualified name for an external function.
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
``local_name(<User.save in app/models.py>)`` returns ``"User.save"``.
|
|
69
|
+
"""
|
|
70
|
+
if symbol.kind is SymbolKind.FILE and symbol.file_path is not None:
|
|
71
|
+
return symbol.file_path
|
|
72
|
+
if symbol.kind in (SymbolKind.FILE, SymbolKind.EXTERNAL):
|
|
73
|
+
return symbol.qualified_name
|
|
74
|
+
_, separator, name = symbol.id.partition(_ID_SEPARATOR)
|
|
75
|
+
return name if separator else symbol.name
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _span(symbol: SymbolRef) -> str:
|
|
79
|
+
start, end = symbol.line_start, symbol.line_end
|
|
80
|
+
if symbol.file_path and start and end and end != start:
|
|
81
|
+
return f"{symbol.file_path}:{start}-{end}"
|
|
82
|
+
return location(symbol)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _symbol(symbol: SymbolRef, *extra: str) -> str:
|
|
86
|
+
"""``location kind qualified_name [extra ...]`` on one line."""
|
|
87
|
+
parts = [location(symbol), symbol.kind.value, symbol.qualified_name, *extra]
|
|
88
|
+
return _INDENT.join(part for part in parts if part)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plural(count: int, noun: str) -> str:
|
|
92
|
+
"""``count`` and ``noun``, pluralised.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
count: How many.
|
|
96
|
+
noun: The singular noun.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The phrase.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
``plural(3, "class")`` returns ``"3 classes"``.
|
|
103
|
+
"""
|
|
104
|
+
if count == 1:
|
|
105
|
+
return f"{count} {noun}"
|
|
106
|
+
return f"{count} {noun}es" if noun.endswith("s") else f"{count} {noun}s"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_status(status: IndexStatus) -> str:
|
|
110
|
+
"""One header line describing the index.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
status: The index status every result carries.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The commit, build time and freshness of the index.
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
``index: a1b2c3d · built 2026-11-10 09:14 UTC · 2 files changed since; run
|
|
120
|
+
`compass ingest```
|
|
121
|
+
"""
|
|
122
|
+
commit = status.indexed_commit[:7] if status.indexed_commit else "no git commit"
|
|
123
|
+
built = status.indexed_at.strftime("%Y-%m-%d %H:%M UTC")
|
|
124
|
+
stale = status.stale_file_count
|
|
125
|
+
fresh = (
|
|
126
|
+
f"{plural(stale, 'file')} changed since; run `compass ingest`" if stale else "up to date"
|
|
127
|
+
)
|
|
128
|
+
return f"index: {commit} · built {built} · {fresh}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _caveats(caveats: Iterable[Caveat]) -> list[str]:
|
|
132
|
+
return [f"! {caveat.message}" for caveat in caveats]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _more(shown: int, total: int) -> list[str]:
|
|
136
|
+
hidden = total - shown
|
|
137
|
+
return [f"… {hidden} more; raise the limit to see them"] if hidden > 0 else []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def match_reason(hit: SearchHit) -> str:
|
|
141
|
+
"""Why a search hit matched, with its fan-in for files and functions.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
hit: A search hit.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The reason.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
``"exact name · 1 caller"``.
|
|
151
|
+
"""
|
|
152
|
+
match hit.match:
|
|
153
|
+
case SearchMatch.EXACT:
|
|
154
|
+
reason = "exact name"
|
|
155
|
+
case SearchMatch.QUALIFIED_SUFFIX:
|
|
156
|
+
reason = "qualified name"
|
|
157
|
+
case SearchMatch.PREFIX:
|
|
158
|
+
reason = "name prefix"
|
|
159
|
+
case SearchMatch.SUBSTRING:
|
|
160
|
+
reason = "name contains"
|
|
161
|
+
case _:
|
|
162
|
+
assert_never(hit.match)
|
|
163
|
+
if hit.symbol.kind is SymbolKind.FILE:
|
|
164
|
+
return f"{reason} · {plural(hit.fan_in, 'importer')}"
|
|
165
|
+
if hit.symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
166
|
+
return f"{reason} · {plural(hit.fan_in, 'caller')}"
|
|
167
|
+
return reason
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def render_search(result: SearchResult) -> str:
|
|
171
|
+
"""Search hits, best first, each with why it matched.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
result: A search result.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The text.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
A search for ``save`` renders ``app/models.py:2 method app.models.Base.save exact
|
|
181
|
+
name · 1 caller``.
|
|
182
|
+
"""
|
|
183
|
+
lines = [
|
|
184
|
+
render_status(result.index),
|
|
185
|
+
f'search "{result.query}" · {plural(result.total, "hit")}',
|
|
186
|
+
*(_symbol(hit.symbol, match_reason(hit)) for hit in result.hits),
|
|
187
|
+
*_more(len(result.hits), result.total),
|
|
188
|
+
*_caveats(result.caveats),
|
|
189
|
+
]
|
|
190
|
+
return "\n".join(lines)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _section(title: str, symbols: tuple[SymbolRef, ...]) -> list[str]:
|
|
194
|
+
if not symbols:
|
|
195
|
+
return []
|
|
196
|
+
return ["", f"{title} ({len(symbols)}):", *(_INDENT + _symbol(s) for s in symbols)]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def render_describe(detail: NodeDetail, *, include_source: bool = False) -> str:
|
|
200
|
+
"""One symbol: where it is, its signature and docstring, and its neighbours.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
detail: A describe result.
|
|
204
|
+
include_source: Also print the symbol's source text (off by default to keep the
|
|
205
|
+
answer short; the location says where to read it).
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
The text.
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
Describing ``User.save`` renders ``method app.models.User.save app/models.py:6-7``,
|
|
212
|
+
its id, signature and docstring, then ``callees (1):`` and its callee.
|
|
213
|
+
"""
|
|
214
|
+
symbol = detail.symbol
|
|
215
|
+
lines = [
|
|
216
|
+
render_status(detail.index),
|
|
217
|
+
f"{symbol.kind.value} {symbol.qualified_name} {_span(symbol)}",
|
|
218
|
+
f"id: {symbol.id}",
|
|
219
|
+
]
|
|
220
|
+
if symbol.signature:
|
|
221
|
+
lines.append(symbol.signature)
|
|
222
|
+
if detail.docstring:
|
|
223
|
+
lines += ["", detail.docstring.strip()]
|
|
224
|
+
lines += _section("bases", detail.bases)
|
|
225
|
+
lines += _section("subclasses", detail.subclasses)
|
|
226
|
+
lines += _section("members", detail.members)
|
|
227
|
+
lines += _section("callers", detail.callers)
|
|
228
|
+
lines += _section("callees", detail.callees)
|
|
229
|
+
lines += _section("imports", detail.imports)
|
|
230
|
+
lines += _section("imported by", detail.imported_by)
|
|
231
|
+
if detail.truncated:
|
|
232
|
+
lines += ["", "! some lists were cut short; describe the related symbols directly"]
|
|
233
|
+
if include_source and symbol.source:
|
|
234
|
+
lines += ["", "source:", symbol.source.rstrip()]
|
|
235
|
+
lines += _caveats(detail.caveats)
|
|
236
|
+
return "\n".join(lines)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def via_text(via: Via) -> str:
|
|
240
|
+
"""How an affected symbol depends on the one before it.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
via: The link.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
A verb and a name.
|
|
247
|
+
|
|
248
|
+
Example:
|
|
249
|
+
``"calls decode_token"``.
|
|
250
|
+
"""
|
|
251
|
+
match via.kind:
|
|
252
|
+
case ViaKind.CALLS:
|
|
253
|
+
verb = "calls"
|
|
254
|
+
case ViaKind.IMPORTS:
|
|
255
|
+
verb = "imports"
|
|
256
|
+
case ViaKind.INHERITS:
|
|
257
|
+
verb = "inherits"
|
|
258
|
+
case _:
|
|
259
|
+
assert_never(via.kind)
|
|
260
|
+
return f"{verb} {via.symbol.name}"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _impact_item(item: ResultItem) -> str:
|
|
264
|
+
via = via_text(item.via) if item.via else ""
|
|
265
|
+
confidence = "" if item.min_confidence is None else f"{item.min_confidence:.2f}"
|
|
266
|
+
return _INDENT + _symbol(item.symbol, via, confidence)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def render_impact(result: ImpactResult) -> str:
|
|
270
|
+
"""What may break if the target changes, nearest first.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
result: An impact result.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
The text: the target, a summary, the affected items grouped by distance, and
|
|
277
|
+
caveats (an empty answer is never shown as certain).
|
|
278
|
+
|
|
279
|
+
Example:
|
|
280
|
+
``impact of function app.auth.decode_token (app/auth.py:3) · depth 3`` then
|
|
281
|
+
``distance 1`` and ``app/api.py:4 function app.api.handle calls decode_token
|
|
282
|
+
1.00``.
|
|
283
|
+
"""
|
|
284
|
+
target = result.target
|
|
285
|
+
shown = sum(len(group.items) for group in result.groups)
|
|
286
|
+
lines = [
|
|
287
|
+
render_status(result.index),
|
|
288
|
+
f"impact of {target.kind.value} {target.qualified_name} ({location(target)})"
|
|
289
|
+
f" · depth {result.depth}",
|
|
290
|
+
f"{plural(result.total, 'affected symbol')} in "
|
|
291
|
+
f"{plural(result.affected_file_count, 'file')}",
|
|
292
|
+
]
|
|
293
|
+
distance = None
|
|
294
|
+
for group in result.groups:
|
|
295
|
+
if group.distance != distance:
|
|
296
|
+
distance = group.distance
|
|
297
|
+
lines.append(f"distance {distance}")
|
|
298
|
+
lines += [_impact_item(item) for item in group.items]
|
|
299
|
+
lines += _more(shown, result.total)
|
|
300
|
+
lines += _caveats(result.caveats)
|
|
301
|
+
return "\n".join(lines)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _ranked(title: str, ranked: tuple[RankedSymbol, ...], unit: str) -> list[str]:
|
|
305
|
+
if not ranked:
|
|
306
|
+
return []
|
|
307
|
+
return [
|
|
308
|
+
"",
|
|
309
|
+
f"{title}:",
|
|
310
|
+
*(_INDENT + _symbol(r.symbol, plural(r.count, unit)) for r in ranked),
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def render_overview(result: OverviewResult) -> str:
|
|
315
|
+
"""The shape of the repository: totals, entry points and hot spots.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
result: An overview result.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
The text.
|
|
322
|
+
|
|
323
|
+
Example:
|
|
324
|
+
``8 files · 3 classes · 15 functions`` then ``calls resolved: 12 of 40 (30%)`` and
|
|
325
|
+
the ranked lists.
|
|
326
|
+
"""
|
|
327
|
+
counts = result.counts
|
|
328
|
+
edges = " · ".join(f"{edge.value} {n}" for edge, n in counts.edges_by_type.items())
|
|
329
|
+
resolution = result.call_resolution
|
|
330
|
+
percent = round(100 * resolution.resolved / resolution.total) if resolution.total else 0
|
|
331
|
+
lines = [
|
|
332
|
+
render_status(result.index),
|
|
333
|
+
f"{plural(counts.files, 'file')} · {plural(counts.classes, 'class')} · "
|
|
334
|
+
f"{plural(counts.functions, 'function')}",
|
|
335
|
+
f"edges: {edges}",
|
|
336
|
+
f"calls resolved: {resolution.resolved} of {resolution.total} ({percent}%)",
|
|
337
|
+
]
|
|
338
|
+
lines += _section("entry points", result.entry_points)
|
|
339
|
+
lines += _ranked("most imported files", result.most_imported_files, "importer")
|
|
340
|
+
lines += _ranked("most called functions", result.most_called_functions, "caller")
|
|
341
|
+
lines += _ranked("riskiest to change", result.riskiest_to_change, "affected symbol")
|
|
342
|
+
if result.top_external_libraries:
|
|
343
|
+
lines += ["", "top external libraries:"]
|
|
344
|
+
lines += [
|
|
345
|
+
f"{_INDENT}{lib.library} {plural(lib.call_count, 'call')}"
|
|
346
|
+
for lib in result.top_external_libraries
|
|
347
|
+
]
|
|
348
|
+
lines += _caveats(result.caveats)
|
|
349
|
+
return "\n".join(lines)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def render_ambiguous(result: AmbiguousReference) -> str:
|
|
353
|
+
"""The candidates of an ambiguous reference, with how to pick one.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
result: An ambiguous outcome.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
The text; each candidate's id resolves exactly when passed back.
|
|
360
|
+
|
|
361
|
+
Example:
|
|
362
|
+
``"save" matches 2 symbols; pass one of these ids or a qualified name:`` then
|
|
363
|
+
``app/models.py::Base.save method app/models.py:2``.
|
|
364
|
+
"""
|
|
365
|
+
lines = [
|
|
366
|
+
f'"{result.reference}" matches {len(result.candidates)} symbols; '
|
|
367
|
+
"pass one of these ids or a qualified name:",
|
|
368
|
+
*(_INDENT + _INDENT.join((c.id, c.kind.value, location(c))) for c in result.candidates),
|
|
369
|
+
]
|
|
370
|
+
return "\n".join(lines)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def render_not_found(result: SymbolNotFound) -> str:
|
|
374
|
+
"""A reference that matched nothing, with close suggestions.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
result: A not-found outcome.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
The text.
|
|
381
|
+
|
|
382
|
+
Example:
|
|
383
|
+
``no symbol matches "sav"; similar names:`` then ``app/models.py::User.save
|
|
384
|
+
method app/models.py:6``.
|
|
385
|
+
"""
|
|
386
|
+
if not result.suggestions:
|
|
387
|
+
return f'no symbol matches "{result.reference}"; try `compass search`'
|
|
388
|
+
lines = [
|
|
389
|
+
f'no symbol matches "{result.reference}"; similar names:',
|
|
390
|
+
*(_INDENT + _INDENT.join((s.id, s.kind.value, location(s))) for s in result.suggestions),
|
|
391
|
+
]
|
|
392
|
+
return "\n".join(lines)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def render_error(error: ErrorResponse) -> str:
|
|
396
|
+
"""A failure: its code and the sentence that says how to fix it.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
error: The error envelope.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
``error [CODE]: message``.
|
|
403
|
+
|
|
404
|
+
Example:
|
|
405
|
+
``error [INDEX_NOT_FOUND]: No Compass index found from /repo. Run `compass
|
|
406
|
+
ingest`.``
|
|
407
|
+
"""
|
|
408
|
+
return f"error [{error.code.value}]: {error.message}"
|
compass_core/settings.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
STAGE = os.environ.get("STAGE", None)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
"""Engine settings, read from the environment and ``.env``.
|
|
12
|
+
|
|
13
|
+
Adapters that need more settings (e.g. ``compass_server.settings.ServerSettings``)
|
|
14
|
+
subclass this so they share the same sources and ``STAGE`` handling.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
LADYBUG_DB_PATH: Explicit path to the graph database; when it does not exist,
|
|
18
|
+
the index is found by walking up from the working directory.
|
|
19
|
+
REPO_ROOT: Root path of the repository being analysed.
|
|
20
|
+
QUERY_TIMEOUT_MS: Per-query timeout for graph queries, in milliseconds.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
``QUERY_TIMEOUT_MS=5000 compass ingest .`` gives
|
|
24
|
+
``Settings().QUERY_TIMEOUT_MS == 5000``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
LADYBUG_DB_PATH: str = Field(
|
|
28
|
+
default=".compass/graph.ladybug", description="Path to the LadybugDB graph database file"
|
|
29
|
+
)
|
|
30
|
+
REPO_ROOT: Path = Field(
|
|
31
|
+
default=Path("."), description="Root path of the repository being analysed"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
QUERY_TIMEOUT_MS: int = Field(
|
|
35
|
+
default=10_000, description="Per-query timeout in milliseconds for graph queries"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
model_config = SettingsConfigDict(
|
|
39
|
+
validate_assignment=True,
|
|
40
|
+
case_sensitive=True,
|
|
41
|
+
env_file=f".env.{STAGE}" if STAGE else ".env",
|
|
42
|
+
env_file_encoding="utf-8",
|
|
43
|
+
extra="ignore",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def settings_customise_sources(
|
|
48
|
+
cls,
|
|
49
|
+
_settings_cls: type[BaseSettings],
|
|
50
|
+
init_settings: PydanticBaseSettingsSource,
|
|
51
|
+
env_settings: PydanticBaseSettingsSource,
|
|
52
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
53
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
54
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
55
|
+
if os.environ.get("STAGE", "").lower() == "production":
|
|
56
|
+
return env_settings, init_settings, file_secret_settings
|
|
57
|
+
|
|
58
|
+
return env_settings, dotenv_settings, init_settings, file_secret_settings
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
settings = Settings()
|