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,273 @@
1
+ """Impact analysis: what may break if a file or symbol changes.
2
+
3
+ Semantics follow the query-layer design (section 4.2). A symbol target walks reverse
4
+ ``CALLS`` (a class also pulls in subclasses and callers of its methods); a file target
5
+ walks importers, files of callers and files of subclasses. The walks live in
6
+ ``impact_walk``; this module resolves the reference, orders, groups and truncates the
7
+ items, and adds the ``UNRESOLVED_CALLS`` and ``STALE_INDEX`` caveats.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable, Sequence
13
+ from dataclasses import dataclass
14
+ from itertools import groupby
15
+
16
+ from compass_core.contract.models import Caveat, EdgeRef, ResultItem, SymbolRef
17
+ from compass_core.contract.params import ImpactParams
18
+ from compass_core.contract.results import ImpactGroup, ImpactResult, Lookup
19
+ from compass_core.contract.types import CaveatCode, RepoPath
20
+ from compass_core.graph.db import GraphDB, Row
21
+ from compass_core.index.locate import IndexLocation
22
+ from compass_core.query.cypher import Query
23
+ from compass_core.query.impact_walk import Walk, walk_files, walk_key, walk_symbols
24
+ from compass_core.query.references import ResolvedReference, resolve
25
+ from compass_core.query.rows import text_list_value, text_value
26
+ from compass_core.query.status import index_status
27
+ from compass_core.shared_types import SymbolKind
28
+
29
+
30
+ @dataclass(frozen=True, slots=True, kw_only=True)
31
+ class RawCaller:
32
+ """A function with raw call sites, and the names its resolved edges reach.
33
+
34
+ Attributes:
35
+ key: Id of the function.
36
+ path: Its file path.
37
+ raw_calls: Its raw call strings, in source order.
38
+ reached: Names of the functions and externals it has a ``CALLS`` or
39
+ ``CALLS_EXTERN`` edge to.
40
+
41
+ Example:
42
+ ``def run(): parse(x); getattr(o, n)()`` with only ``parse`` resolved gives
43
+ ``RawCaller(key="app/cli.py::run", path="app/cli.py",
44
+ raw_calls=("parse", "getattr(o, n)"), reached=frozenset({"parse"}))``.
45
+ """
46
+
47
+ key: str
48
+ path: str
49
+ raw_calls: tuple[str, ...]
50
+ reached: frozenset[str]
51
+
52
+
53
+ RAW_CALLERS: Query[RawCaller] = Query(
54
+ """
55
+ MATCH (f:Function) WHERE size(f.raw_calls) > 0
56
+ OPTIONAL MATCH (f)-[:CALLS]->(g:Function) WITH f, collect(g.name) AS internal
57
+ OPTIONAL MATCH (f)-[:CALLS_EXTERN]->(x:ExternalFunction)
58
+ RETURN f.id, f.file_path, f.raw_calls, internal, collect(x.name)
59
+ """,
60
+ lambda row: RawCaller(
61
+ key=text_value(row[0], "id"),
62
+ path=text_value(row[1], "file_path"),
63
+ raw_calls=text_list_value(row[2], "raw_calls"),
64
+ reached=frozenset(text_list_value(row[3], "name") + text_list_value(row[4], "name")),
65
+ ),
66
+ )
67
+ """Every function with raw calls, with the names its resolved edges reach."""
68
+
69
+
70
+ def _name(row: Row) -> str:
71
+ return text_value(row[0], "name")
72
+
73
+
74
+ _METHOD_NAMES: Query[str] = Query(
75
+ "MATCH (c:Class)-[:CONTAINS]->(m:Function) WHERE c.id IN $ids RETURN m.name", _name
76
+ )
77
+ _FILE_DEFINITION_NAMES: Query[str] = Query(
78
+ """
79
+ MATCH (f:Function) WHERE f.file_path = $path RETURN f.name
80
+ UNION MATCH (c:Class) WHERE c.file_path = $path RETURN c.name
81
+ """,
82
+ _name,
83
+ )
84
+ """Names of every definition in a file: functions, methods and classes."""
85
+
86
+
87
+ def bare_call_name(raw_call: str) -> str:
88
+ """The called name of a raw call string: last dotted segment, arguments removed.
89
+
90
+ Args:
91
+ raw_call: A ``Function.raw_calls`` entry.
92
+
93
+ Returns:
94
+ The bare name, or ``""`` when nothing is left.
95
+
96
+ Example:
97
+ ``bare_call_name("User().save")`` returns ``"save"``;
98
+ ``bare_call_name("getattr(obj, name)")`` returns ``"getattr"``.
99
+ """
100
+ depth = 0
101
+ kept: list[str] = []
102
+ for char in raw_call:
103
+ if char == "(":
104
+ depth += 1
105
+ elif char == ")":
106
+ depth = max(depth - 1, 0)
107
+ elif depth == 0:
108
+ kept.append(char)
109
+ return "".join(kept).rsplit(".", 1)[-1].strip()
110
+
111
+
112
+ def _watched_names(db: GraphDB, target: SymbolRef, target_key: str, walk: Walk) -> set[str]:
113
+ """Bare names whose unresolved calls may hide callers: the target, every affected
114
+ symbol and the methods of the target and affected classes."""
115
+ names = {target.name} | {r.item.symbol.name for r in walk.reached}
116
+ classes = [r.key for r in walk.reached if r.item.symbol.kind is SymbolKind.CLASS]
117
+ if target.kind is SymbolKind.CLASS:
118
+ classes.append(target_key)
119
+ if classes:
120
+ names |= set(_METHOD_NAMES.run(db, {"ids": sorted(classes)}))
121
+ return names
122
+
123
+
124
+ def _file_watched_names(db: GraphDB, path: str) -> set[str]:
125
+ """Bare names whose unresolved calls may hide dependents of the file at ``path``:
126
+ every function, method and class defined in it."""
127
+ return set(_FILE_DEFINITION_NAMES.run(db, {"path": path}))
128
+
129
+
130
+ def _unresolved_callers(
131
+ db: GraphDB, names: set[str], *, skip_id: str | None = None, skip_file: str | None = None
132
+ ) -> set[str]:
133
+ """Functions with a raw call to one of ``names`` that resolved to no edge to a
134
+ function (or external) of that name.
135
+
136
+ Only such calls can hide further callers of the target; a dependent's other
137
+ unresolved calls (builtins, methods on locals) cannot, so they are not counted.
138
+
139
+ Args:
140
+ db: Open graph database.
141
+ names: Watched bare names.
142
+ skip_id: Id of a function not to count (the symbol target itself).
143
+ skip_file: Path whose functions are not counted (the file target).
144
+
145
+ Returns:
146
+ The ids of the flagged functions.
147
+ """
148
+ found: set[str] = set()
149
+ for caller in RAW_CALLERS.run(db):
150
+ if caller.key == skip_id or caller.path == skip_file:
151
+ continue
152
+ called = {bare_call_name(c) for c in caller.raw_calls}
153
+ if (called & names) - caller.reached:
154
+ found.add(caller.key)
155
+ return found
156
+
157
+
158
+ def _unresolved_caveat(functions: set[str]) -> tuple[Caveat, ...]:
159
+ count = len(functions)
160
+ if not count:
161
+ return ()
162
+ noun = "function contains" if count == 1 else "functions contain"
163
+ message = f"{count} {noun} calls that could not be resolved; there may be more callers"
164
+ return (Caveat(code=CaveatCode.UNRESOLVED_CALLS, message=message, count=count),)
165
+
166
+
167
+ def _stale_caveat(stale: int) -> tuple[Caveat, ...]:
168
+ if not stale:
169
+ return ()
170
+ noun = "file has" if stale == 1 else "files have"
171
+ message = f"{stale} indexed {noun} changed since the last ingest; run `compass ingest`"
172
+ return (Caveat(code=CaveatCode.STALE_INDEX, message=message, count=stale),)
173
+
174
+
175
+ def _file_of(item: ResultItem) -> RepoPath:
176
+ path = item.symbol.file_path
177
+ assert path is not None, "impact walks only reach files and definitions"
178
+ return path
179
+
180
+
181
+ def _groups(items: Sequence[ResultItem]) -> tuple[ImpactGroup, ...]:
182
+ """Group already-sorted items by ``(distance, file_path)``."""
183
+ return tuple(
184
+ ImpactGroup(distance=distance, file_path=path, items=tuple(members))
185
+ for (distance, path), members in groupby(items, key=lambda i: (i.distance, _file_of(i)))
186
+ )
187
+
188
+
189
+ def _edges(walk: Walk, kept: Iterable[ResultItem]) -> tuple[EdgeRef, ...]:
190
+ """Edges owned by the kept items, deduplicated and in a stable order."""
191
+ ids = {i.symbol.id for i in kept}
192
+ unique = {owned.edge for owned in walk.edges if owned.owner in ids}
193
+ return tuple(sorted(unique, key=lambda e: (e.type, e.source_id, e.target_id, e.line or 0)))
194
+
195
+
196
+ def _walk(db: GraphDB, target: SymbolRef, params: ImpactParams) -> tuple[Walk, set[str]]:
197
+ """Run the walk for ``target`` and collect the functions behind its caveat."""
198
+ key = walk_key(target)
199
+ depth, min_confidence = params.depth, params.min_confidence
200
+ if target.kind is SymbolKind.FILE:
201
+ walk = walk_files(db, target, depth=depth, min_confidence=min_confidence)
202
+ return walk, _unresolved_callers(db, _file_watched_names(db, key), skip_file=key)
203
+ walk = walk_symbols(db, target, depth=depth, min_confidence=min_confidence)
204
+ return walk, _unresolved_callers(db, _watched_names(db, target, key, walk), skip_id=key)
205
+
206
+
207
+ def impact(
208
+ db: GraphDB, location: IndexLocation, ref: str, params: ImpactParams
209
+ ) -> Lookup[ImpactResult]:
210
+ """List what may be affected by changing a file or symbol.
211
+
212
+ Args:
213
+ db: Open, read-only graph database.
214
+ location: Index location, used for ``repo_root`` and the ``IndexStatus``.
215
+ ref: File path or symbol reference (section 4.1 rules; files allowed).
216
+ params: Depth, minimum ``CALLS`` confidence and item limit.
217
+
218
+ Returns:
219
+ An ``ImpactResult``, or the ``AmbiguousReference`` / ``SymbolNotFound`` from
220
+ resolution. Groups are ordered by distance then file path, items by name.
221
+ ``total`` counts items before the limit; ``affected_file_count`` counts the
222
+ distinct files of all items. A symbol target lists affected functions, methods
223
+ and classes; a file target lists one ``FILE`` item per affected file. An
224
+ external target (``ext::json.dumps``) lists its ``CALLS_EXTERN`` callers at
225
+ distance 1 (``min_confidence`` ``None``) and walks reverse ``CALLS`` from there.
226
+ Each item's ``min_confidence`` is that of the chosen shortest path (see
227
+ ``impact_walk`` for tie-breaks), not the best path overall. ``edges`` are the
228
+ edges traversed from the returned items; their endpoints may not be items
229
+ (non-tree edges, and function/class-level edges behind a file dependency).
230
+
231
+ Raises:
232
+ IndexIncompatibleError: A stored row or the index metadata is unreadable.
233
+
234
+ Note:
235
+ ``UNRESOLVED_CALLS`` counts functions that have a raw call to a watched bare
236
+ name with no resolved edge to a function or external of that name. For a
237
+ symbol target the watched names are the target, every affected symbol and
238
+ the methods of the target and affected classes (the target itself is not
239
+ counted); for a file target they are the names of every function, method and
240
+ class defined in the file (its own functions are not counted). A dependent's
241
+ other unresolved calls (builtins, methods on locals) are not counted.
242
+ ``min_confidence`` is compared with a ``1e-6`` tolerance because stored
243
+ confidences are float32. Overriding methods in subclasses are not walked.
244
+
245
+ Example:
246
+ ``impact(db, loc, "app/auth.py::decode_token", ImpactParams(depth=2))`` returns
247
+ ``handle``, ``run`` and ``nightly`` at distance 1 (``via`` calls
248
+ ``decode_token``, ``min_confidence`` 1.0) and ``tick`` at distance 2.
249
+ """
250
+ status = index_status(location, db) # first: rejects an incompatible index
251
+ repo_root = location.repo_root.resolve()
252
+ resolved = resolve(db, ref, repo_root=repo_root, allow_file=True)
253
+ if not isinstance(resolved, ResolvedReference):
254
+ return resolved
255
+ target = resolved.symbol
256
+ walk, unresolved = _walk(db, target, params)
257
+ items = sorted(
258
+ (r.item for r in walk.reached),
259
+ key=lambda i: (i.distance, _file_of(i), i.symbol.name, i.symbol.id),
260
+ )
261
+ kept = items[: params.limit]
262
+ return ImpactResult(
263
+ index=status,
264
+ caveats=_unresolved_caveat(unresolved) + _stale_caveat(status.stale_file_count),
265
+ target=target,
266
+ matched_by=resolved.rule,
267
+ depth=params.depth,
268
+ groups=_groups(kept),
269
+ affected_file_count=len({_file_of(i) for i in items}),
270
+ edges=_edges(walk, kept),
271
+ total=len(items),
272
+ truncated=len(items) > params.limit,
273
+ )
@@ -0,0 +1,352 @@
1
+ """Breadth-first reverse walks behind ``impact``.
2
+
3
+ Each walk expands one distance level at a time with a fixed set of parameterized,
4
+ single-hop queries (``WHERE x IN $ids``), so the cost is one query per level per edge
5
+ family, never one per node. Nodes are keyed by ``walk_key``: the id of a definition, the
6
+ path of a file; a node is visited once, at its shortest distance, so cycles terminate and
7
+ the target never lists itself. ``CALLS`` edges below ``min_confidence`` are not traversed;
8
+ stored confidences are float32, so the threshold is compared with a ``1e-6`` tolerance
9
+ (a stored ``0.9`` reads back as ``0.8999999761``).
10
+
11
+ The recorded edges are every edge traversed from an affected node, including non-tree
12
+ edges (a second, equally short or longer route) and, for file targets, the function- and
13
+ class-level edges behind a file dependency, so an edge endpoint may not be an item.
14
+
15
+ When several edges reach a new node at the same distance, the winner is chosen by via
16
+ kind (``IMPORTS`` before ``CALLS`` before ``INHERITS``), then by the higher path
17
+ confidence, then by the via symbol's id, so results are deterministic.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ from compass_core.contract.models import EdgeRef, ResultItem, SymbolRef, Via
25
+ from compass_core.contract.types import SymbolId, ViaKind
26
+ from compass_core.errors import IndexIncompatibleError
27
+ from compass_core.graph.db import GraphDB
28
+ from compass_core.query.references import FILES_AT_PATHS
29
+ from compass_core.query.walk_queries import (
30
+ CALLERS_IN_FILES,
31
+ CALLERS_OF_EXTERNALS,
32
+ CALLERS_OF_FUNCTIONS,
33
+ CALLERS_OF_METHODS,
34
+ IMPORTERS_OF_FILES,
35
+ SUBCLASSES_IN_FILES,
36
+ SUBCLASSES_OF,
37
+ CallRow,
38
+ ExternalCallRow,
39
+ )
40
+ from compass_core.shared_types import EdgeType, SymbolKind
41
+
42
+ _PRIORITY = {ViaKind.IMPORTS: 0, ViaKind.CALLS: 1, ViaKind.INHERITS: 2}
43
+ _CONFIDENCE_TOLERANCE = 1e-6
44
+
45
+
46
+ def _threshold(min_confidence: float) -> float:
47
+ """The ``$minc`` parameter: ``min_confidence`` lowered by the float32 tolerance."""
48
+ return min_confidence - _CONFIDENCE_TOLERANCE
49
+
50
+
51
+ @dataclass(frozen=True, slots=True, kw_only=True)
52
+ class Reached:
53
+ """A node the walk reached, with the item describing how.
54
+
55
+ Attributes:
56
+ key: ``walk_key`` of the node: id of a definition, path of a file.
57
+ item: Distance, via and path confidence of the node.
58
+
59
+ Example:
60
+ ``Reached(key="app/jobs.py::nightly", item=ResultItem(symbol=<nightly>,
61
+ distance=1, via=Via(kind=ViaKind.CALLS, symbol=<decode_token>),
62
+ min_confidence=1.0))``
63
+ """
64
+
65
+ key: str
66
+ item: ResultItem
67
+
68
+
69
+ @dataclass(frozen=True, slots=True, kw_only=True)
70
+ class OwnedEdge:
71
+ """A traversed edge and the affected item it belongs to.
72
+
73
+ Attributes:
74
+ owner: Public id of the affected item that the edge pulled in or leaves from;
75
+ used to drop edges of items cut by the result limit.
76
+ edge: The traversed edge.
77
+
78
+ Example:
79
+ ``OwnedEdge(owner=SymbolId("file::app/api.py"), edge=<api.py IMPORTS auth.py>)``
80
+ """
81
+
82
+ owner: SymbolId
83
+ edge: EdgeRef
84
+
85
+
86
+ @dataclass(frozen=True, slots=True, kw_only=True)
87
+ class Walk:
88
+ """Everything a walk reached.
89
+
90
+ Attributes:
91
+ reached: Affected nodes in discovery order (the target is never included).
92
+ edges: Traversed edges whose owner is an affected node, deduplicated.
93
+
94
+ Example:
95
+ Walking callers of ``decode_token`` at depth 1 yields three ``Reached`` items
96
+ (``handle``, ``run``, ``nightly``) and their three ``CALLS`` edges.
97
+ """
98
+
99
+ reached: tuple[Reached, ...]
100
+ edges: tuple[OwnedEdge, ...]
101
+
102
+
103
+ class _Walker:
104
+ """Mutable BFS bookkeeping shared by the symbol and file walks."""
105
+
106
+ def __init__(self, target_key: str, target: SymbolRef) -> None:
107
+ self.symbols: dict[str, SymbolRef] = {target_key: target}
108
+ self._visited: set[str] = {target_key}
109
+ self._path_min: dict[str, float | None] = {target_key: None}
110
+ self._reached: list[Reached] = []
111
+ self._level: dict[str, tuple[tuple[int, float, str], Reached]] = {}
112
+ self._edges: list[tuple[str, EdgeRef]] = []
113
+
114
+ def offer(
115
+ self,
116
+ key: str,
117
+ symbol: SymbolRef,
118
+ distance: int,
119
+ via: Via,
120
+ parent_key: str,
121
+ confidence: float | None,
122
+ ) -> None:
123
+ """Propose ``symbol`` at ``distance``; the best proposal per level wins."""
124
+ if key in self._visited:
125
+ return
126
+ path = self._path_min.get(parent_key)
127
+ if confidence is not None:
128
+ path = confidence if path is None else min(path, confidence)
129
+ rank = (_PRIORITY[via.kind], -(1.0 if path is None else path), via.symbol.id)
130
+ current = self._level.get(key)
131
+ if current is None or rank < current[0]:
132
+ item = ResultItem(symbol=symbol, distance=distance, via=via, min_confidence=path)
133
+ self._level[key] = (rank, Reached(key=key, item=item))
134
+
135
+ def edge(self, owner_key: str, edge: EdgeRef) -> None:
136
+ """Record a traversed edge owned by the node ``owner_key``."""
137
+ self._edges.append((owner_key, edge))
138
+
139
+ def close_level(self) -> list[str]:
140
+ """Commit this level's proposals; return the new frontier keys, sorted."""
141
+ new = sorted(self._level)
142
+ for key in new:
143
+ reached = self._level[key][1]
144
+ self._visited.add(key)
145
+ self._path_min[key] = reached.item.min_confidence
146
+ self.symbols[key] = reached.item.symbol
147
+ self._reached.append(reached)
148
+ self._level = {}
149
+ return new
150
+
151
+ def result(self) -> Walk:
152
+ """The walk so far; edges are kept only when their owner was reached."""
153
+ owners = {r.key: r.item.symbol.id for r in self._reached}
154
+ seen: set[tuple[SymbolId, EdgeRef]] = set()
155
+ edges: list[OwnedEdge] = []
156
+ for owner_key, edge in self._edges:
157
+ owner = owners.get(owner_key)
158
+ if owner is not None and (owner, edge) not in seen:
159
+ seen.add((owner, edge))
160
+ edges.append(OwnedEdge(owner=owner, edge=edge))
161
+ return Walk(reached=tuple(self._reached), edges=tuple(edges))
162
+
163
+
164
+ def _structural_edge(source: SymbolRef, target: SymbolRef, kind: EdgeType) -> EdgeRef:
165
+ """An ``IMPORTS`` or ``INHERITS`` edge (no confidence, call type or line)."""
166
+ return EdgeRef(
167
+ source_id=source.id,
168
+ target_id=target.id,
169
+ type=kind,
170
+ confidence=None,
171
+ call_type=None,
172
+ line=None,
173
+ )
174
+
175
+
176
+ def walk_key(symbol: SymbolRef) -> str:
177
+ """The key a walk uses for a node: what the graph matches the node on.
178
+
179
+ Args:
180
+ symbol: A file, class, function, method or external function.
181
+
182
+ Returns:
183
+ The id of a class or function, the path of a file, the full reference of an
184
+ external function.
185
+
186
+ Example:
187
+ ``<app/auth.py::parse>`` maps to ``"app/auth.py::parse"``, ``<file::app/a.py>`` to
188
+ ``"app/a.py"`` and ``<ext::json.dumps>`` to ``"json.dumps"``.
189
+ """
190
+ if symbol.kind is SymbolKind.FILE:
191
+ assert symbol.file_path is not None, "a file symbol always has a path"
192
+ return symbol.file_path
193
+ if symbol.kind is SymbolKind.EXTERNAL:
194
+ return symbol.id.removeprefix("ext::")
195
+ return symbol.id
196
+
197
+
198
+ def walk_symbols(db: GraphDB, target: SymbolRef, *, depth: int, min_confidence: float) -> Walk:
199
+ """Walk reverse ``CALLS`` (and, for classes, reverse ``INHERITS``) from a symbol.
200
+
201
+ A function or method in the frontier pulls in its callers (``Via(CALLS, callee)``).
202
+ A class in the frontier pulls in its subclasses (``Via(INHERITS, class)``) and the
203
+ callers of its methods (``Via(CALLS, method)``). An external target pulls in its
204
+ callers over ``CALLS_EXTERN`` (``Via(CALLS, external)``, no confidence); the walk then
205
+ continues over reverse ``CALLS``. Each item's ``min_confidence`` is that of the chosen
206
+ shortest path, not the best path overall.
207
+
208
+ Args:
209
+ db: Open graph database.
210
+ target: The resolved class, function, method or external function.
211
+ depth: Maximum distance.
212
+ min_confidence: ``CALLS`` edges below this are not traversed.
213
+
214
+ Returns:
215
+ The affected symbols and every edge traversed from them; an edge endpoint may
216
+ not be an item (non-tree edges, or the target's class methods).
217
+
218
+ Raises:
219
+ IndexIncompatibleError: A stored row does not have the expected shape.
220
+
221
+ Example:
222
+ From ``decode_token`` at depth 2 the walk reaches ``handle``, ``run`` and
223
+ ``nightly`` at distance 1 and ``tick`` (which calls ``nightly``) at distance 2.
224
+ """
225
+ target_key = walk_key(target)
226
+ walker = _Walker(target_key, target)
227
+ frontier = [target_key]
228
+ for distance in range(1, depth + 1):
229
+ kinds = {k: walker.symbols[k].kind for k in frontier}
230
+ functions = [k for k in frontier if kinds[k] in (SymbolKind.FUNCTION, SymbolKind.METHOD)]
231
+ classes = [k for k in frontier if kinds[k] is SymbolKind.CLASS]
232
+ externals = [k for k in frontier if kinds[k] is SymbolKind.EXTERNAL]
233
+ if externals:
234
+ for ext in CALLERS_OF_EXTERNALS.run(db, {"ids": externals}):
235
+ _offer_external_caller(walker, ext, distance, target_key)
236
+ minc = _threshold(min_confidence)
237
+ if functions:
238
+ for call in CALLERS_OF_FUNCTIONS.run(db, {"ids": functions, "minc": minc}):
239
+ _offer_caller(walker, call, distance, call.callee.id)
240
+ if classes:
241
+ params = {"ids": classes, "minc": minc}
242
+ for method_call in CALLERS_OF_METHODS.run(db, params):
243
+ _offer_caller(walker, method_call.call, distance, method_call.class_key)
244
+ for pair in SUBCLASSES_OF.run(db, {"ids": classes}):
245
+ child, parent = pair.source, pair.target
246
+ via = Via(kind=ViaKind.INHERITS, symbol=parent)
247
+ walker.offer(child.id, child, distance, via, parent.id, None)
248
+ walker.edge(child.id, _structural_edge(child, parent, EdgeType.INHERITS))
249
+ frontier = walker.close_level()
250
+ if not frontier:
251
+ break
252
+ return walker.result()
253
+
254
+
255
+ def _offer_caller(walker: _Walker, call: CallRow, distance: int, parent_key: str) -> None:
256
+ via = Via(kind=ViaKind.CALLS, symbol=call.callee)
257
+ walker.offer(call.caller.id, call.caller, distance, via, parent_key, call.confidence)
258
+ walker.edge(call.caller.id, call.edge)
259
+
260
+
261
+ def _offer_external_caller(
262
+ walker: _Walker, ext: ExternalCallRow, distance: int, external_key: str
263
+ ) -> None:
264
+ external = walker.symbols[external_key]
265
+ via = Via(kind=ViaKind.CALLS, symbol=external)
266
+ walker.offer(ext.caller.id, ext.caller, distance, via, external_key, None)
267
+ edge = EdgeRef(
268
+ source_id=ext.caller.id,
269
+ target_id=external.id,
270
+ type=EdgeType.CALLS_EXTERN,
271
+ confidence=None,
272
+ call_type=None,
273
+ line=ext.line,
274
+ )
275
+ walker.edge(ext.caller.id, edge)
276
+
277
+
278
+ def walk_files(db: GraphDB, target: SymbolRef, *, depth: int, min_confidence: float) -> Walk:
279
+ """Walk dependent files: importers, files of callers and files of subclasses.
280
+
281
+ Each affected file is one ``FILE`` item. Its via is ``IMPORTS`` (symbol: the
282
+ imported file), ``CALLS`` (symbol: the called function) or ``INHERITS`` (symbol:
283
+ the base class), whichever reaches it first.
284
+
285
+ Args:
286
+ db: Open graph database.
287
+ target: The resolved file.
288
+ depth: Maximum distance.
289
+ min_confidence: ``CALLS`` edges below this are not traversed.
290
+
291
+ Returns:
292
+ The affected files and the edges traversed (``IMPORTS`` between files,
293
+ ``CALLS`` and ``INHERITS`` between the definitions involved). Endpoints of the
294
+ ``CALLS`` and ``INHERITS`` edges are functions and classes, never items here.
295
+
296
+ Raises:
297
+ IndexIncompatibleError: A stored row does not have the expected shape.
298
+
299
+ Example:
300
+ From ``app/auth.py`` at depth 2: ``app/api.py``, ``app/cli.py``,
301
+ ``app/jobs.py`` and ``app/__init__.py`` at distance 1 (they import it) and
302
+ ``app/scheduler.py`` at distance 2 (it imports ``app/jobs.py``).
303
+ """
304
+ target_key = walk_key(target)
305
+ walker = _Walker(target_key, target)
306
+ frontier = [target_key]
307
+ for distance in range(1, depth + 1):
308
+ # (owner path, via, parent path, confidence) per proposal; files resolved below.
309
+ pending: list[tuple[str, Via, str, float | None]] = []
310
+ for pair in IMPORTERS_OF_FILES.run(db, {"paths": frontier}):
311
+ importer, imported = pair.source, pair.target
312
+ importer_key = walk_key(importer)
313
+ walker.symbols.setdefault(importer_key, importer)
314
+ via = Via(kind=ViaKind.IMPORTS, symbol=imported)
315
+ pending.append((importer_key, via, walk_key(imported), None))
316
+ walker.edge(importer_key, _structural_edge(importer, imported, EdgeType.IMPORTS))
317
+ params = {"paths": frontier, "minc": _threshold(min_confidence)}
318
+ for call in CALLERS_IN_FILES.run(db, params):
319
+ owner = _path(call.caller)
320
+ via = Via(kind=ViaKind.CALLS, symbol=call.callee)
321
+ pending.append((owner, via, _path(call.callee), call.confidence))
322
+ walker.edge(owner, call.edge)
323
+ for pair in SUBCLASSES_IN_FILES.run(db, {"paths": frontier}):
324
+ child, parent = pair.source, pair.target
325
+ via = Via(kind=ViaKind.INHERITS, symbol=parent)
326
+ pending.append((_path(child), via, _path(parent), None))
327
+ walker.edge(_path(child), _structural_edge(child, parent, EdgeType.INHERITS))
328
+ _load_files(db, walker, [p[0] for p in pending])
329
+ for owner, via, parent_path, path_confidence in pending:
330
+ walker.offer(owner, walker.symbols[owner], distance, via, parent_path, path_confidence)
331
+ frontier = walker.close_level()
332
+ if not frontier:
333
+ break
334
+ return walker.result()
335
+
336
+
337
+ def _path(symbol: SymbolRef) -> str:
338
+ """The file path of a class or function, which keys its file in a file walk."""
339
+ assert symbol.file_path is not None, "definitions always have a file path"
340
+ return symbol.file_path
341
+
342
+
343
+ def _load_files(db: GraphDB, walker: _Walker, paths: list[str]) -> None:
344
+ """Make sure every stored path in ``paths`` has a ``FILE`` symbol (one query)."""
345
+ missing = sorted({p for p in paths if p not in walker.symbols})
346
+ if not missing:
347
+ return
348
+ for file in FILES_AT_PATHS.run(db, {"paths": missing}):
349
+ walker.symbols[walk_key(file)] = file
350
+ absent = [p for p in missing if p not in walker.symbols]
351
+ if absent:
352
+ raise IndexIncompatibleError(f"definitions reference unindexed files: {absent}")