compass-code 0.1.0__py3-none-any.whl

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