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,221 @@
|
|
|
1
|
+
"""Repository overview: totals, entry points and the hot spots of the graph.
|
|
2
|
+
|
|
3
|
+
Every list comes from one aggregate query. The riskiest-to-change list additionally
|
|
4
|
+
runs the reverse ``CALLS`` walk of ``impact_walk`` for a bounded set of candidates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from compass_core.contract.models import Caveat, SymbolRef
|
|
10
|
+
from compass_core.contract.results import (
|
|
11
|
+
CallResolution,
|
|
12
|
+
IndexCounts,
|
|
13
|
+
LibraryUsage,
|
|
14
|
+
OverviewResult,
|
|
15
|
+
RankedSymbol,
|
|
16
|
+
)
|
|
17
|
+
from compass_core.contract.types import CaveatCode
|
|
18
|
+
from compass_core.graph.db import GraphDB, Row
|
|
19
|
+
from compass_core.index.locate import IndexLocation
|
|
20
|
+
from compass_core.query.cypher import Query
|
|
21
|
+
from compass_core.query.impact import RAW_CALLERS, bare_call_name
|
|
22
|
+
from compass_core.query.impact_walk import walk_symbols
|
|
23
|
+
from compass_core.query.rows import (
|
|
24
|
+
FILE_FIELDS,
|
|
25
|
+
FILE_WIDTH,
|
|
26
|
+
FUNCTION_FIELDS,
|
|
27
|
+
FUNCTION_WIDTH,
|
|
28
|
+
columns,
|
|
29
|
+
count_value,
|
|
30
|
+
file_from_row,
|
|
31
|
+
function_from_row,
|
|
32
|
+
text_value,
|
|
33
|
+
)
|
|
34
|
+
from compass_core.query.status import index_status
|
|
35
|
+
from compass_core.shared_types import EdgeType
|
|
36
|
+
|
|
37
|
+
TOP_N = 5
|
|
38
|
+
"""Length of every ranked list."""
|
|
39
|
+
|
|
40
|
+
RISK_CANDIDATES = 30
|
|
41
|
+
"""How many most-called functions are walked to rank ``riskiest_to_change``."""
|
|
42
|
+
|
|
43
|
+
RISK_DEPTH = 3
|
|
44
|
+
"""Traversal depth used to count the symbols affected by a change."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _count(row: Row) -> int:
|
|
48
|
+
return count_value(row[0])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_FILE_COUNT: Query[int] = Query("MATCH (n:File) RETURN count(n)", _count)
|
|
52
|
+
_CLASS_COUNT: Query[int] = Query("MATCH (n:Class) RETURN count(n)", _count)
|
|
53
|
+
_FUNCTION_COUNT: Query[int] = Query("MATCH (n:Function) RETURN count(n)", _count)
|
|
54
|
+
_EDGE_COUNTS: dict[EdgeType, Query[int]] = {
|
|
55
|
+
edge: Query(f"MATCH ()-[r:{edge.value}]->() RETURN count(r)", _count) for edge in EdgeType
|
|
56
|
+
}
|
|
57
|
+
"""One count query per relationship table; ``EdgeType`` values are the table names."""
|
|
58
|
+
_ENTRY_POINTS: Query[SymbolRef] = Query(
|
|
59
|
+
f"""
|
|
60
|
+
MATCH (f:File) WHERE NOT EXISTS {{ MATCH (:File)-[:IMPORTS]->(f) }}
|
|
61
|
+
RETURN {columns("f", FILE_FIELDS)} ORDER BY f.path LIMIT $limit
|
|
62
|
+
""",
|
|
63
|
+
file_from_row,
|
|
64
|
+
)
|
|
65
|
+
_MOST_IMPORTED: Query[RankedSymbol] = Query(
|
|
66
|
+
f"""
|
|
67
|
+
MATCH (i:File)-[:IMPORTS]->(f:File)
|
|
68
|
+
RETURN {columns("f", FILE_FIELDS)}, count(DISTINCT i) AS n
|
|
69
|
+
ORDER BY n DESC, f.path LIMIT $limit
|
|
70
|
+
""",
|
|
71
|
+
lambda row: RankedSymbol(symbol=file_from_row(row), count=count_value(row[FILE_WIDTH])),
|
|
72
|
+
)
|
|
73
|
+
_MOST_CALLED: Query[tuple[SymbolRef, int]] = Query(
|
|
74
|
+
f"""
|
|
75
|
+
MATCH (c:Function)-[:CALLS]->(n:Function)
|
|
76
|
+
RETURN {columns("n", FUNCTION_FIELDS)}, count(DISTINCT c) AS k
|
|
77
|
+
ORDER BY k DESC, n.name, n.id LIMIT $limit
|
|
78
|
+
""",
|
|
79
|
+
lambda row: (function_from_row(row), count_value(row[FUNCTION_WIDTH])),
|
|
80
|
+
)
|
|
81
|
+
_LIBRARIES: Query[LibraryUsage] = Query(
|
|
82
|
+
"""
|
|
83
|
+
MATCH (:Function)-[e:CALLS_EXTERN]->(x:ExternalFunction)
|
|
84
|
+
RETURN x.library, count(e) AS n ORDER BY n DESC, x.library LIMIT $limit
|
|
85
|
+
""",
|
|
86
|
+
lambda row: LibraryUsage(library=text_value(row[0], "library"), call_count=count_value(row[1])),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def index_counts(db: GraphDB) -> IndexCounts:
|
|
91
|
+
"""Count the nodes of an index and its edges of every type.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
db: Open, read-only graph database.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The totals. ``edges_by_type`` has every ``EdgeType``, zeros included, and
|
|
98
|
+
``functions`` counts functions and methods.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
IndexIncompatibleError: A stored count is not an integer.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
A repo with one file holding one function has ``files=1``, ``functions=1`` and
|
|
105
|
+
``edges_by_type[EdgeType.CONTAINS] == 1``.
|
|
106
|
+
"""
|
|
107
|
+
return IndexCounts(
|
|
108
|
+
files=_FILE_COUNT.first(db) or 0,
|
|
109
|
+
classes=_CLASS_COUNT.first(db) or 0,
|
|
110
|
+
functions=_FUNCTION_COUNT.first(db) or 0,
|
|
111
|
+
edges_by_type={edge: query.first(db) or 0 for edge, query in _EDGE_COUNTS.items()},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def entry_points(db: GraphDB) -> tuple[SymbolRef, ...]:
|
|
116
|
+
"""List the files nobody imports, by path.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
db: Open, read-only graph database.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
At most ``TOP_N`` ``FILE`` symbols.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
IndexIncompatibleError: A stored row is unreadable.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
If ``app/main.py`` imports ``app/auth.py``, only ``app/main.py`` is returned.
|
|
129
|
+
"""
|
|
130
|
+
return tuple(_ENTRY_POINTS.run(db, {"limit": TOP_N}))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _call_resolution(db: GraphDB) -> CallResolution:
|
|
134
|
+
"""Count call sites, and those whose called name matches a resolved callee of the caller."""
|
|
135
|
+
total = resolved = 0
|
|
136
|
+
for caller in RAW_CALLERS.run(db):
|
|
137
|
+
for raw in caller.raw_calls:
|
|
138
|
+
total += 1
|
|
139
|
+
resolved += bare_call_name(raw) in caller.reached
|
|
140
|
+
return CallResolution(total=total, resolved=resolved)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _riskiest(db: GraphDB, called: list[tuple[SymbolRef, int]]) -> tuple[RankedSymbol, ...]:
|
|
144
|
+
"""Rank candidates (results of ``_MOST_CALLED``) by symbols affected at ``RISK_DEPTH``."""
|
|
145
|
+
ranked = [
|
|
146
|
+
RankedSymbol(
|
|
147
|
+
symbol=symbol,
|
|
148
|
+
count=len(walk_symbols(db, symbol, depth=RISK_DEPTH, min_confidence=0.0).reached),
|
|
149
|
+
)
|
|
150
|
+
for symbol, _ in called
|
|
151
|
+
]
|
|
152
|
+
ranked.sort(key=lambda r: (-r.count, r.symbol.name, r.symbol.id))
|
|
153
|
+
return tuple(ranked[:TOP_N])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _caveats(counts: IndexCounts, stale: int) -> tuple[Caveat, ...]:
|
|
157
|
+
caveats: list[Caveat] = []
|
|
158
|
+
if stale:
|
|
159
|
+
noun = "file has" if stale == 1 else "files have"
|
|
160
|
+
message = f"{stale} indexed {noun} changed since the last ingest; run `compass ingest`"
|
|
161
|
+
caveats.append(Caveat(code=CaveatCode.STALE_INDEX, message=message, count=stale))
|
|
162
|
+
if counts.functions and not counts.edges_by_type[EdgeType.CALLS]:
|
|
163
|
+
message = (
|
|
164
|
+
"no function-to-function calls were resolved among the "
|
|
165
|
+
f"{counts.functions} indexed functions; symbol-level lists may be empty"
|
|
166
|
+
)
|
|
167
|
+
caveats.append(
|
|
168
|
+
Caveat(code=CaveatCode.UNRESOLVED_CALLS, message=message, count=counts.functions)
|
|
169
|
+
)
|
|
170
|
+
return tuple(caveats)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def overview(db: GraphDB, location: IndexLocation) -> OverviewResult:
|
|
174
|
+
"""Summarise an index: totals, entry points and ranked hot spots.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
db: Open, read-only graph database.
|
|
178
|
+
location: Index location, used for the ``IndexStatus``.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
An ``OverviewResult``. ``counts.edges_by_type`` has every ``EdgeType`` (zeros
|
|
182
|
+
included) and ``counts.functions`` counts functions and methods.
|
|
183
|
+
``call_resolution`` counts every call site and those matching a callee the caller
|
|
184
|
+
has an edge to (a constructor call such as ``User()`` has no edge, so it counts as
|
|
185
|
+
unresolved). Each list holds
|
|
186
|
+
at most 5 entries: ``entry_points`` are files nobody imports, by path;
|
|
187
|
+
``most_imported_files`` ranks files by importing files; ``most_called_functions``
|
|
188
|
+
ranks functions and methods by distinct callers; ``top_external_libraries`` ranks
|
|
189
|
+
libraries by ``CALLS_EXTERN`` edges; ``riskiest_to_change`` ranks functions and
|
|
190
|
+
methods by the number of symbols a change affects within 3 hops of reverse
|
|
191
|
+
``CALLS``. To bound the cost only the 30 most-called functions (fan-in at least
|
|
192
|
+
1) are walked, so a rarely called function with a deep caller chain can be
|
|
193
|
+
missed. Ties break by name. Caveats: ``STALE_INDEX`` when indexed files changed,
|
|
194
|
+
and ``UNRESOLVED_CALLS`` (count = functions) when functions exist but no call
|
|
195
|
+
was resolved, which is why the symbol-level lists may be empty.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
IndexIncompatibleError: A stored row or the index metadata is unreadable.
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
On a repo where ``handle``, ``run`` and ``nightly`` call ``decode_token`` and
|
|
202
|
+
``tick`` calls ``nightly``, ``riskiest_to_change[0]`` is ``decode_token`` with
|
|
203
|
+
``count=4``.
|
|
204
|
+
"""
|
|
205
|
+
status = index_status(location, db) # first: rejects an incompatible index
|
|
206
|
+
limit = {"limit": TOP_N}
|
|
207
|
+
counts = index_counts(db)
|
|
208
|
+
called = _MOST_CALLED.run(db, {"limit": RISK_CANDIDATES})
|
|
209
|
+
return OverviewResult(
|
|
210
|
+
index=status,
|
|
211
|
+
caveats=_caveats(counts, status.stale_file_count),
|
|
212
|
+
counts=counts,
|
|
213
|
+
call_resolution=_call_resolution(db),
|
|
214
|
+
entry_points=entry_points(db),
|
|
215
|
+
most_imported_files=tuple(_MOST_IMPORTED.run(db, limit)),
|
|
216
|
+
most_called_functions=tuple(
|
|
217
|
+
RankedSymbol(symbol=symbol, count=count) for symbol, count in called[:TOP_N]
|
|
218
|
+
),
|
|
219
|
+
riskiest_to_change=_riskiest(db, called),
|
|
220
|
+
top_external_libraries=tuple(_LIBRARIES.run(db, limit)),
|
|
221
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Resolve a user-typed symbol reference to exactly one graph node.
|
|
2
|
+
|
|
3
|
+
Rules are tried in the order of the query-layer design (section 4.1): exact id, file
|
|
4
|
+
path (only when ``allow_file``), fully qualified dotted name, ``path::Name``,
|
|
5
|
+
``Class.member`` and bare name. The first rule with any hit decides the outcome: one
|
|
6
|
+
hit resolves, several hits are an ``AmbiguousReference``, and no hit at all is a
|
|
7
|
+
``SymbolNotFound`` with substring suggestions. A reference is never guessed.
|
|
8
|
+
|
|
9
|
+
The graph stores public ids, repo-relative paths and qualified names, so an exact id,
|
|
10
|
+
a file path and a qualified name are equality matches; only the partial rules
|
|
11
|
+
(``path::Name`` with a partial path, ``Class.member``) match on a suffix. User input
|
|
12
|
+
only ever reaches Cypher as a ``$parameter``; the only text composed into a query is
|
|
13
|
+
the constant ``WHERE`` conditions below, at import.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from compass_core.contract.models import SymbolRef
|
|
23
|
+
from compass_core.contract.results import AmbiguousReference, Lookup, SymbolNotFound
|
|
24
|
+
from compass_core.contract.types import MatchRule
|
|
25
|
+
from compass_core.graph.db import GraphDB, QueryParams
|
|
26
|
+
from compass_core.query.cypher import Query
|
|
27
|
+
from compass_core.query.rows import (
|
|
28
|
+
CLASS_FIELDS,
|
|
29
|
+
FILE_FIELDS,
|
|
30
|
+
FUNCTION_FIELDS,
|
|
31
|
+
class_from_row,
|
|
32
|
+
columns,
|
|
33
|
+
count_value,
|
|
34
|
+
external_from_row,
|
|
35
|
+
file_from_row,
|
|
36
|
+
function_from_row,
|
|
37
|
+
text_value,
|
|
38
|
+
)
|
|
39
|
+
from compass_core.shared_types import SymbolKind
|
|
40
|
+
|
|
41
|
+
_SEP = "::"
|
|
42
|
+
_MAX_SUGGESTIONS = 5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class DefinitionQueries:
|
|
47
|
+
"""The class and the function query for one definition filter over node ``n``.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
classes: Matching classes.
|
|
51
|
+
functions: Matching functions and methods.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
``_BY_NAME.functions.run(db, {"q": "save"})`` returns both ``save`` methods.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
classes: Query[SymbolRef]
|
|
58
|
+
functions: Query[SymbolRef]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _definitions_where(condition: str) -> DefinitionQueries:
|
|
62
|
+
"""Build the query pair for a constant ``WHERE`` condition (module load time only)."""
|
|
63
|
+
return DefinitionQueries(
|
|
64
|
+
classes=Query(
|
|
65
|
+
f"MATCH (n:Class) WHERE {condition} RETURN {columns('n', CLASS_FIELDS)}",
|
|
66
|
+
class_from_row,
|
|
67
|
+
),
|
|
68
|
+
functions=Query(
|
|
69
|
+
f"MATCH (n:Function) WHERE {condition} RETURN {columns('n', FUNCTION_FIELDS)}",
|
|
70
|
+
function_from_row,
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_BY_ID = _definitions_where("n.id = $id")
|
|
76
|
+
_BY_ID_OR_ID_SUFFIX = _definitions_where("n.id = $id OR n.id ENDS WITH $suffix")
|
|
77
|
+
_BY_QUALIFIED_NAME = _definitions_where("n.qualified_name = $q")
|
|
78
|
+
_BY_TAIL = _definitions_where("n.id ENDS WITH $tail")
|
|
79
|
+
_BY_NAME = _definitions_where("n.name = $q")
|
|
80
|
+
_NAME_CONTAINS = _definitions_where("lower(n.name) CONTAINS lower($q)")
|
|
81
|
+
|
|
82
|
+
FILES_AT_PATHS: Query[SymbolRef] = Query(
|
|
83
|
+
f"MATCH (f:File) WHERE f.path IN $paths RETURN {columns('f', FILE_FIELDS)}",
|
|
84
|
+
file_from_row,
|
|
85
|
+
)
|
|
86
|
+
"""Files whose repo-relative path is one of ``$paths``."""
|
|
87
|
+
_EXTERNAL: Query[SymbolRef] = Query(
|
|
88
|
+
"MATCH (e:ExternalFunction) WHERE e.full_ref = $q RETURN e.full_ref, e.name",
|
|
89
|
+
external_from_row,
|
|
90
|
+
)
|
|
91
|
+
_FAN_IN: Query[tuple[str, int]] = Query(
|
|
92
|
+
"""
|
|
93
|
+
MATCH (c:Function)-[:CALLS]->(f:Function) WHERE f.id IN $ids
|
|
94
|
+
RETURN f.id, count(c)
|
|
95
|
+
""",
|
|
96
|
+
lambda row: (text_value(row[0], "id"), count_value(row[1])),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
101
|
+
class ResolvedReference:
|
|
102
|
+
"""A reference that resolved to exactly one symbol.
|
|
103
|
+
|
|
104
|
+
Attributes:
|
|
105
|
+
symbol: The resolved symbol.
|
|
106
|
+
rule: The resolution rule that matched.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
``resolve(db, "User.save", repo_root=root)`` returns
|
|
110
|
+
``ResolvedReference(symbol=<app/models.py::User.save>, rule=MatchRule.CLASS_MEMBER)``.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
symbol: SymbolRef
|
|
114
|
+
rule: MatchRule
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _definitions(db: GraphDB, queries: DefinitionQueries, params: QueryParams) -> list[SymbolRef]:
|
|
118
|
+
"""Classes, then functions and methods, matching one definition filter."""
|
|
119
|
+
return queries.classes.run(db, params) + queries.functions.run(db, params)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _normalize_path(text: str, repo_root: Path) -> str:
|
|
123
|
+
"""Make an absolute path under ``repo_root`` relative; leave anything else alone."""
|
|
124
|
+
path = Path(text)
|
|
125
|
+
if path.is_absolute():
|
|
126
|
+
try:
|
|
127
|
+
return path.relative_to(repo_root).as_posix()
|
|
128
|
+
except ValueError:
|
|
129
|
+
return text
|
|
130
|
+
return text.removeprefix("./")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _file(db: GraphDB, path: str) -> list[SymbolRef]:
|
|
134
|
+
return FILES_AT_PATHS.run(db, {"paths": [path]})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _exact_id(db: GraphDB, ref: str, repo_root: Path, allow_file: bool) -> list[SymbolRef]:
|
|
138
|
+
if ref.startswith("ext" + _SEP):
|
|
139
|
+
return _EXTERNAL.run(db, {"q": ref.removeprefix("ext" + _SEP)})
|
|
140
|
+
if ref.startswith("file" + _SEP):
|
|
141
|
+
return _file(db, ref.removeprefix("file" + _SEP)) if allow_file else []
|
|
142
|
+
if _SEP not in ref:
|
|
143
|
+
return []
|
|
144
|
+
path, tail = ref.rsplit(_SEP, 1)
|
|
145
|
+
return _definitions(db, _BY_ID, {"id": f"{_normalize_path(path, repo_root)}{_SEP}{tail}"})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _qualified(db: GraphDB, ref: str) -> list[SymbolRef]:
|
|
149
|
+
"""Match a stored qualified name, ``module.[Class.]name``, exactly."""
|
|
150
|
+
return _definitions(db, _BY_QUALIFIED_NAME, {"q": ref})
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _path_and_name(db: GraphDB, ref: str, repo_root: Path) -> list[SymbolRef]:
|
|
154
|
+
"""``path::Name`` where ``path`` is the file's path or a trailing part of it."""
|
|
155
|
+
path, tail = ref.rsplit(_SEP, 1)
|
|
156
|
+
exact = f"{_normalize_path(path, repo_root)}{_SEP}{tail}"
|
|
157
|
+
return _definitions(db, _BY_ID_OR_ID_SUFFIX, {"id": exact, "suffix": "/" + exact})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _class_member(db: GraphDB, ref: str) -> list[SymbolRef]:
|
|
161
|
+
return _definitions(db, _BY_TAIL, {"tail": _SEP + ref})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _bare_name(db: GraphDB, ref: str) -> list[SymbolRef]:
|
|
165
|
+
return _definitions(db, _BY_NAME, {"q": ref})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _hits(
|
|
169
|
+
db: GraphDB, rule: MatchRule, text: str, repo_root: Path, allow_file: bool
|
|
170
|
+
) -> list[SymbolRef]:
|
|
171
|
+
"""Symbols matching ``text`` under one rule; empty when the rule does not apply."""
|
|
172
|
+
plain = _SEP not in text
|
|
173
|
+
if rule is MatchRule.EXACT_ID:
|
|
174
|
+
return _exact_id(db, text, repo_root, allow_file)
|
|
175
|
+
if rule is MatchRule.FILE_PATH:
|
|
176
|
+
return _file(db, _normalize_path(text, repo_root)) if allow_file else []
|
|
177
|
+
if rule is MatchRule.QUALIFIED_NAME:
|
|
178
|
+
return _qualified(db, text) if plain and "." in text else []
|
|
179
|
+
if rule is MatchRule.PATH_AND_NAME:
|
|
180
|
+
return _path_and_name(db, text, repo_root) if not plain else []
|
|
181
|
+
if rule is MatchRule.CLASS_MEMBER:
|
|
182
|
+
return _class_member(db, text) if plain and "." in text else []
|
|
183
|
+
return _bare_name(db, text) if plain else []
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _fan_in(db: GraphDB, symbols: Sequence[SymbolRef]) -> dict[str, int]:
|
|
187
|
+
"""Incoming ``CALLS`` count per function or method (one query); others count zero."""
|
|
188
|
+
ids = [s.id for s in symbols if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)]
|
|
189
|
+
return dict(_FAN_IN.run(db, {"ids": ids})) if ids else {}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _suggestions(db: GraphDB, text: str) -> tuple[SymbolRef, ...]:
|
|
193
|
+
if not text:
|
|
194
|
+
return ()
|
|
195
|
+
found = _definitions(db, _NAME_CONTAINS, {"q": text})
|
|
196
|
+
found.sort(key=lambda s: (s.name.lower() != text.lower(), len(s.name), s.id))
|
|
197
|
+
return tuple(found[:_MAX_SUGGESTIONS])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def resolve(
|
|
201
|
+
db: GraphDB, ref: str, *, repo_root: Path, allow_file: bool = False
|
|
202
|
+
) -> Lookup[ResolvedReference]:
|
|
203
|
+
"""Resolve a symbol reference using the first rule that has any hit.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
db: Open graph database.
|
|
207
|
+
ref: User-typed reference: public id, file path, dotted name, ``path::Name``,
|
|
208
|
+
``Class.member`` or bare name.
|
|
209
|
+
repo_root: Absolute repository root; an absolute path in ``ref`` is made
|
|
210
|
+
relative to it.
|
|
211
|
+
allow_file: Whether a file path (rule 2) is acceptable for this operation.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
A ``ResolvedReference`` for a single match, an ``AmbiguousReference`` listing
|
|
215
|
+
the candidates (most incoming calls first) when several symbols matched the
|
|
216
|
+
same rule, or a ``SymbolNotFound`` with up to five suggestions.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
IndexIncompatibleError: A stored row does not have the expected shape.
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
``resolve(db, "app/auth.py::decode_token", repo_root=root)`` returns
|
|
223
|
+
``ResolvedReference(symbol=<decode_token>, rule=MatchRule.EXACT_ID)``.
|
|
224
|
+
"""
|
|
225
|
+
text = ref.strip()
|
|
226
|
+
if text:
|
|
227
|
+
for rule in MatchRule:
|
|
228
|
+
found = _hits(db, rule, text, repo_root, allow_file)
|
|
229
|
+
if len(found) == 1:
|
|
230
|
+
return ResolvedReference(symbol=found[0], rule=rule)
|
|
231
|
+
if found:
|
|
232
|
+
fan_in = _fan_in(db, found)
|
|
233
|
+
ordered = sorted(found, key=lambda s: (-fan_in.get(s.id, 0), s.id))
|
|
234
|
+
return AmbiguousReference(reference=ref, candidates=tuple(ordered))
|
|
235
|
+
return SymbolNotFound(reference=ref, suggestions=_suggestions(db, text))
|