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