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,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))