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,572 @@
|
|
|
1
|
+
"""Phase 3: SymbolTable + raw call sites -> ResolvedGraph. Typed,
|
|
2
|
+
confidence-scored edges. Moved from resolver/resolver.py +
|
|
3
|
+
resolver/call_resolver.py, operating on IR types. Cross-language resolution
|
|
4
|
+
(spec S5) is explicitly NOT implemented here -- same-language resolution only.
|
|
5
|
+
|
|
6
|
+
``ctx.symbol_table`` (built by SymbolTablePhase) is an identity map of
|
|
7
|
+
``Definition.id -> Definition.id`` -- effectively a set of known ids, not the
|
|
8
|
+
old resolver's rich multi-key lookup table (short name / class-qualified /
|
|
9
|
+
module-qualified). To resolve a raw call string this phase therefore looks up
|
|
10
|
+
the target *structurally* (walking a file's ``Definition`` list by name/kind/
|
|
11
|
+
parent, exactly as ``_find_class``/``_lookup_symbol_in_file`` did in the old
|
|
12
|
+
call_resolver, just against IR ``Definition`` objects instead of
|
|
13
|
+
``ParsedClass``/``ParsedFunction``) and then validates the resulting id
|
|
14
|
+
against ``ctx.symbol_table`` before returning it -- this is the "constructed
|
|
15
|
+
candidate ID" checked against the symbol table that the resolution pipeline
|
|
16
|
+
is built around.
|
|
17
|
+
|
|
18
|
+
The 10 resolution rules below preserve the exact CODE order of
|
|
19
|
+
resolver/call_resolver.py::_resolve_one (not the old docstring's numeric
|
|
20
|
+
order) and its per-raw_call ``try/except Exception`` isolation, which never
|
|
21
|
+
raises and falls back to UNRESOLVED on any internal error.
|
|
22
|
+
|
|
23
|
+
Every outcome scores from ``ir.graph.CONFIDENCE``. Module-attribute calls
|
|
24
|
+
(``import auth; auth.verify()``) stay at 0.9, distinct from the 1.0 used for
|
|
25
|
+
bare direct-import calls. EXTERNAL scores 0.0 but the score is dropped, because
|
|
26
|
+
ExternalCallEdge has no confidence field. Only ``CallType`` outcomes produce a
|
|
27
|
+
CALLS edge; ``UnlinkedCall`` outcomes do not (EXTERNAL writes CALLS_EXTERN
|
|
28
|
+
instead). Note the old code's ``visited`` set is
|
|
29
|
+
never actually populated anywhere on the ``_resolve_one`` recursion path --
|
|
30
|
+
CIRCULAR is therefore unreachable via the public ``ResolutionPhase.run``
|
|
31
|
+
entry point today, same as it was via the old ``resolve()`` entry point. This
|
|
32
|
+
is a pre-existing quirk, preserved as-is, not fixed here.
|
|
33
|
+
|
|
34
|
+
**Intentional behavior change -- no global bare-name fallback.** The old
|
|
35
|
+
``_lookup_symbol_in_file`` fell back to ``symbol_table.get(name)`` (a bare,
|
|
36
|
+
unqualified global lookup) when the module-qualified lookup missed. This
|
|
37
|
+
phase's lookups (``_lookup_symbol_in_file``, ``_find_class``) are
|
|
38
|
+
file-scoped: given a specific file, they look for a match *within that file*
|
|
39
|
+
(and, for ``_find_class``, first consult the caller's import map to find the
|
|
40
|
+
base class's *actual* defining file before ever scanning). They never fall
|
|
41
|
+
back to "the first same-named thing anywhere in the repo". This is a
|
|
42
|
+
deliberate, permanent divergence from the old behavior, not an oversight: a
|
|
43
|
+
global bare-name fallback is exactly the same-name-collision hazard that
|
|
44
|
+
produced two Critical, non-deterministic bugs during review (MRO/inherit
|
|
45
|
+
edges picking whichever same-named class happened to be scanned first,
|
|
46
|
+
independent of what was actually imported) -- restoring it would reintroduce
|
|
47
|
+
that hazard. The tradeoff is that a few edge cases the old code coincidentally
|
|
48
|
+
resolved via its global fallback (e.g. a re-export chain that would have hit
|
|
49
|
+
1.0 confidence through an accidental global name match) now resolve at a
|
|
50
|
+
lower confidence, or not at all, here. That tradeoff is intentional.
|
|
51
|
+
|
|
52
|
+
**Intentional vocabulary change -- new call_type strings.** ``call_type``
|
|
53
|
+
values are renamed from the old resolver's vocabulary
|
|
54
|
+
(``RESOLVED``/``POLYMORPHIC``) to names that line up with the
|
|
55
|
+
``CONFIDENCE`` dict's own keys: ``DIRECT_IMPORT`` (self.method() and bare
|
|
56
|
+
direct-import, both old ``RESOLVED``@1.0), ``MODULE_ATTR`` (old
|
|
57
|
+
``RESOLVED``@0.9), ``RE_EXPORT``
|
|
58
|
+
(old ``RESOLVED``@0.8 via __init__ chain), ``MRO`` (old ``POLYMORPHIC``@0.8).
|
|
59
|
+
``WILDCARD``, ``DYNAMIC``, ``EXTERNAL``, ``CIRCULAR``, and ``UNRESOLVED``
|
|
60
|
+
keep their old names unchanged. Nothing in-repo branches on the old string
|
|
61
|
+
values, so this rename is safe and was a deliberate choice, not a side
|
|
62
|
+
effect of the port.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import logging
|
|
68
|
+
|
|
69
|
+
from compass_core.ir.graph import (
|
|
70
|
+
CONFIDENCE,
|
|
71
|
+
CallEdge,
|
|
72
|
+
ExternalCallEdge,
|
|
73
|
+
ImportEdge,
|
|
74
|
+
InheritEdge,
|
|
75
|
+
ResolvedGraph,
|
|
76
|
+
)
|
|
77
|
+
from compass_core.ir.models import CallResult, Definition, ParsedFile
|
|
78
|
+
from compass_core.phases.base import IngestContext
|
|
79
|
+
from compass_core.shared_types import CallType, DefinitionKind, SymbolKind, UnlinkedCall
|
|
80
|
+
|
|
81
|
+
logger = logging.getLogger(__name__)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ResolutionPhase:
|
|
85
|
+
name = "resolution"
|
|
86
|
+
|
|
87
|
+
def run(self, ctx: IngestContext) -> IngestContext:
|
|
88
|
+
assert ctx.parsed_files is not None
|
|
89
|
+
assert ctx.symbol_table is not None
|
|
90
|
+
assert ctx.import_map is not None
|
|
91
|
+
|
|
92
|
+
graph = ResolvedGraph(files=list(ctx.parsed_files))
|
|
93
|
+
by_path = {pf.path: pf for pf in ctx.parsed_files}
|
|
94
|
+
|
|
95
|
+
_collect_import_edges(graph, ctx.parsed_files, ctx.import_map)
|
|
96
|
+
_collect_inherit_edges(graph, ctx.parsed_files, ctx.symbol_table, ctx.import_map, by_path)
|
|
97
|
+
_collect_call_edges(graph, ctx.parsed_files, ctx.symbol_table, ctx.import_map, by_path)
|
|
98
|
+
|
|
99
|
+
ctx.resolved_graph = graph
|
|
100
|
+
if ctx.reporter is not None:
|
|
101
|
+
ctx.reporter.update("resolution", 1, 1)
|
|
102
|
+
logger.info(
|
|
103
|
+
"resolved %d call edges, %d external, %d imports, %d inherits",
|
|
104
|
+
len(graph.call_edges),
|
|
105
|
+
len(graph.external_call_edges),
|
|
106
|
+
len(graph.import_edges),
|
|
107
|
+
len(graph.inherit_edges),
|
|
108
|
+
)
|
|
109
|
+
return ctx
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Edge collectors
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _collect_import_edges(
|
|
118
|
+
graph: ResolvedGraph, files: list[ParsedFile], import_map: dict[str, dict[str, str | None]]
|
|
119
|
+
) -> None:
|
|
120
|
+
# moved from resolver/resolver.py::_collect_import_edges, iterating
|
|
121
|
+
# pf.imports (resolved through import_map) -- same dedup-via-seen-set logic.
|
|
122
|
+
seen: set[tuple[str, str]] = set()
|
|
123
|
+
for pf in files:
|
|
124
|
+
for target_path in import_map.get(pf.path, {}).values():
|
|
125
|
+
if target_path is None or target_path == pf.path:
|
|
126
|
+
continue
|
|
127
|
+
pair = (pf.path, target_path)
|
|
128
|
+
if pair in seen:
|
|
129
|
+
continue
|
|
130
|
+
seen.add(pair)
|
|
131
|
+
graph.import_edges.append(ImportEdge(from_path=pf.path, to_path=target_path))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _collect_inherit_edges(
|
|
135
|
+
graph: ResolvedGraph,
|
|
136
|
+
files: list[ParsedFile],
|
|
137
|
+
symbol_table: dict[str, str],
|
|
138
|
+
import_map: dict[str, dict[str, str | None]],
|
|
139
|
+
by_path: dict[str, ParsedFile],
|
|
140
|
+
) -> None:
|
|
141
|
+
# moved from resolver/resolver.py::_collect_inherit_edges -- iterates
|
|
142
|
+
# Definition entries with kind=CLASS, looks up each base name via
|
|
143
|
+
# _find_class (import-map-aware first, see its docstring); unresolved
|
|
144
|
+
# bases still silently produce no edge (unchanged behavior).
|
|
145
|
+
for pf in files:
|
|
146
|
+
file_imports = import_map.get(pf.path, {})
|
|
147
|
+
for defn in pf.definitions:
|
|
148
|
+
if defn.kind != SymbolKind.CLASS:
|
|
149
|
+
continue
|
|
150
|
+
for base in defn.bases:
|
|
151
|
+
parent_def = _find_class(base, pf, file_imports, by_path)
|
|
152
|
+
if parent_def is not None and parent_def.id in symbol_table:
|
|
153
|
+
graph.inherit_edges.append(
|
|
154
|
+
InheritEdge(child_id=defn.id, parent_id=symbol_table[parent_def.id])
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _collect_call_edges(
|
|
159
|
+
graph: ResolvedGraph,
|
|
160
|
+
files: list[ParsedFile],
|
|
161
|
+
symbol_table: dict[str, str],
|
|
162
|
+
import_map: dict[str, dict[str, str | None]],
|
|
163
|
+
by_path: dict[str, ParsedFile],
|
|
164
|
+
) -> None:
|
|
165
|
+
# moved from resolver/resolver.py::_collect_call_edges -- iterates
|
|
166
|
+
# Definition entries with kind in {FUNCTION, METHOD}, calls resolve_calls
|
|
167
|
+
# per raw_calls entry, converts each CallResult into CallEdge/ExternalCallEdge
|
|
168
|
+
# exactly as today (UNRESOLVED/WILDCARD/DYNAMIC/CIRCULAR still produce no edge).
|
|
169
|
+
method_index = _build_method_index(files)
|
|
170
|
+
for pf in files:
|
|
171
|
+
for defn in pf.definitions:
|
|
172
|
+
if defn.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
173
|
+
continue
|
|
174
|
+
results = resolve_calls(defn, pf, symbol_table, import_map, by_path, method_index)
|
|
175
|
+
caller_id = defn.id
|
|
176
|
+
for res in results:
|
|
177
|
+
if res.call_type is UnlinkedCall.EXTERNAL and res.external_library:
|
|
178
|
+
graph.external_call_edges.append(
|
|
179
|
+
ExternalCallEdge(
|
|
180
|
+
caller_id=caller_id,
|
|
181
|
+
full_ref=res.raw,
|
|
182
|
+
library=res.external_library,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
elif isinstance(res.call_type, CallType) and res.target_id is not None:
|
|
186
|
+
graph.call_edges.append(
|
|
187
|
+
CallEdge(
|
|
188
|
+
caller_id=caller_id,
|
|
189
|
+
callee_id=res.target_id,
|
|
190
|
+
call_type=res.call_type,
|
|
191
|
+
confidence=res.confidence,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
# UNRESOLVED / WILDCARD / DYNAMIC / CIRCULAR -> no edge written
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Result type public API -- resolve_calls, _resolve_one, and helpers move
|
|
199
|
+
# from resolver/call_resolver.py with the same code-order and the same outer
|
|
200
|
+
# try/except Exception per raw_call (never raises), now operating on
|
|
201
|
+
# Definition instead of ParsedFunction/ParsedClass.
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def resolve_calls(
|
|
206
|
+
caller: Definition,
|
|
207
|
+
file: ParsedFile,
|
|
208
|
+
symbol_table: dict[str, str],
|
|
209
|
+
import_map: dict[str, dict[str, str | None]],
|
|
210
|
+
by_path: dict[str, ParsedFile],
|
|
211
|
+
method_index: dict[tuple[str, str], str],
|
|
212
|
+
) -> list[CallResult]:
|
|
213
|
+
"""Resolve every raw call in *caller* into a typed CallResult.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
caller: The Definition (FUNCTION or METHOD) whose raw_calls we
|
|
217
|
+
are resolving.
|
|
218
|
+
file: The ParsedFile that contains *caller*.
|
|
219
|
+
symbol_table: Global known-id set (Definition.id -> Definition.id).
|
|
220
|
+
import_map: Per-file local_name -> file_path lookup.
|
|
221
|
+
by_path: All indexed files keyed by path.
|
|
222
|
+
method_index: (parent_class_id, method_name) -> Definition.id, built
|
|
223
|
+
once per _collect_call_edges call.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
One CallResult per entry in caller.raw_calls (same order).
|
|
227
|
+
"""
|
|
228
|
+
results: list[CallResult] = []
|
|
229
|
+
for raw in caller.raw_calls:
|
|
230
|
+
try:
|
|
231
|
+
result = _resolve_one(
|
|
232
|
+
raw, caller, file, symbol_table, import_map, by_path, method_index, frozenset()
|
|
233
|
+
)
|
|
234
|
+
except Exception:
|
|
235
|
+
logger.debug("Unexpected error resolving %r in %s", raw, file.path, exc_info=True)
|
|
236
|
+
result = CallResult(
|
|
237
|
+
raw=raw,
|
|
238
|
+
target_id=None,
|
|
239
|
+
call_type=UnlinkedCall.UNRESOLVED,
|
|
240
|
+
confidence=CONFIDENCE[UnlinkedCall.UNRESOLVED],
|
|
241
|
+
)
|
|
242
|
+
results.append(result)
|
|
243
|
+
return results
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _resolve_one(
|
|
247
|
+
raw: str,
|
|
248
|
+
caller: Definition,
|
|
249
|
+
file: ParsedFile,
|
|
250
|
+
symbol_table: dict[str, str],
|
|
251
|
+
import_map: dict[str, dict[str, str | None]],
|
|
252
|
+
by_path: dict[str, ParsedFile],
|
|
253
|
+
method_index: dict[tuple[str, str], str],
|
|
254
|
+
visited: frozenset[str],
|
|
255
|
+
) -> CallResult:
|
|
256
|
+
file_imports = import_map.get(file.path, {})
|
|
257
|
+
parts = raw.split(".", 1)
|
|
258
|
+
head, tail = parts[0], parts[1] if len(parts) > 1 else None
|
|
259
|
+
|
|
260
|
+
# Rule 7: dynamic -- check early so we don't try to look up "getattr"
|
|
261
|
+
if "getattr" in raw:
|
|
262
|
+
return CallResult(
|
|
263
|
+
raw=raw,
|
|
264
|
+
target_id=None,
|
|
265
|
+
call_type=UnlinkedCall.DYNAMIC,
|
|
266
|
+
confidence=CONFIDENCE[UnlinkedCall.DYNAMIC],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Rule 1: self.method() -- same-class lookup
|
|
270
|
+
if raw.startswith("self.") and caller.parent_id is not None and tail is not None:
|
|
271
|
+
method_name = raw[len("self.") :]
|
|
272
|
+
target_id = method_index.get((caller.parent_id, method_name))
|
|
273
|
+
if target_id is not None and target_id in symbol_table:
|
|
274
|
+
return CallResult(
|
|
275
|
+
raw=raw,
|
|
276
|
+
target_id=symbol_table[target_id],
|
|
277
|
+
call_type=CallType.DIRECT_IMPORT,
|
|
278
|
+
confidence=CONFIDENCE[CallType.DIRECT_IMPORT],
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Rule 2: direct import -- bare name imported explicitly
|
|
282
|
+
if tail is None and head in file_imports:
|
|
283
|
+
resolved_path = file_imports[head]
|
|
284
|
+
if resolved_path is None:
|
|
285
|
+
# Imported but not in indexed repo -> external
|
|
286
|
+
return _external(raw, head)
|
|
287
|
+
target_id = _lookup_symbol_in_file(head, resolved_path, symbol_table, by_path)
|
|
288
|
+
if target_id:
|
|
289
|
+
return CallResult(
|
|
290
|
+
raw=raw,
|
|
291
|
+
target_id=target_id,
|
|
292
|
+
call_type=CallType.DIRECT_IMPORT,
|
|
293
|
+
confidence=CONFIDENCE[CallType.DIRECT_IMPORT],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Rule 3: module attribute -- import auth; auth.verify()
|
|
297
|
+
if tail and head in file_imports:
|
|
298
|
+
resolved_path = file_imports[head]
|
|
299
|
+
if resolved_path is None:
|
|
300
|
+
return _external(raw, head)
|
|
301
|
+
if resolved_path in visited:
|
|
302
|
+
return _circular(raw)
|
|
303
|
+
target_id = _lookup_symbol_in_file(tail, resolved_path, symbol_table, by_path)
|
|
304
|
+
if target_id:
|
|
305
|
+
return CallResult(
|
|
306
|
+
raw=raw,
|
|
307
|
+
target_id=target_id,
|
|
308
|
+
call_type=CallType.MODULE_ATTR,
|
|
309
|
+
confidence=CONFIDENCE[CallType.MODULE_ATTR],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Rule 4: __init__ chain -- re-export through a package __init__.py
|
|
313
|
+
init_result = _resolve_via_init(
|
|
314
|
+
raw, head, tail, file, file_imports, symbol_table, import_map, by_path, visited
|
|
315
|
+
)
|
|
316
|
+
if init_result is not None:
|
|
317
|
+
return init_result
|
|
318
|
+
|
|
319
|
+
# Rule 5: MRO walk -- look for the method on base classes
|
|
320
|
+
if tail and caller.parent_id is not None:
|
|
321
|
+
owning_class = _find_definition_by_id(files_lookup=by_path, def_id=caller.parent_id)
|
|
322
|
+
if owning_class is not None:
|
|
323
|
+
mro_result = _resolve_via_mro(
|
|
324
|
+
raw, tail, owning_class, file, file_imports, symbol_table, by_path, method_index
|
|
325
|
+
)
|
|
326
|
+
if mro_result is not None:
|
|
327
|
+
return mro_result
|
|
328
|
+
|
|
329
|
+
# Rule 6: wildcard import -- can't resolve name but a wildcard import exists
|
|
330
|
+
if _has_wildcard(file):
|
|
331
|
+
return CallResult(
|
|
332
|
+
raw=raw,
|
|
333
|
+
target_id=None,
|
|
334
|
+
call_type=UnlinkedCall.WILDCARD,
|
|
335
|
+
confidence=CONFIDENCE[UnlinkedCall.WILDCARD],
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Rule 8: explicit external -- head is a module-level import not in our repo
|
|
339
|
+
if _is_explicit_external(head, file):
|
|
340
|
+
return _external(raw, head)
|
|
341
|
+
|
|
342
|
+
# Rule 10: unresolved
|
|
343
|
+
logger.debug("Unresolved call %r in %s::%s", raw, file.path, caller.name)
|
|
344
|
+
return CallResult(
|
|
345
|
+
raw=raw,
|
|
346
|
+
target_id=None,
|
|
347
|
+
call_type=UnlinkedCall.UNRESOLVED,
|
|
348
|
+
confidence=CONFIDENCE[UnlinkedCall.UNRESOLVED],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# Resolution helpers
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _resolve_via_init(
|
|
358
|
+
raw: str,
|
|
359
|
+
head: str,
|
|
360
|
+
tail: str | None,
|
|
361
|
+
file: ParsedFile,
|
|
362
|
+
file_imports: dict[str, str | None],
|
|
363
|
+
symbol_table: dict[str, str],
|
|
364
|
+
import_map: dict[str, dict[str, str | None]],
|
|
365
|
+
by_path: dict[str, ParsedFile],
|
|
366
|
+
visited: frozenset[str],
|
|
367
|
+
) -> CallResult | None:
|
|
368
|
+
"""Rule 4: chase re-exports through a package __init__.py.
|
|
369
|
+
|
|
370
|
+
The __init__.py's own re-export is resolved via ``import_map`` (the same
|
|
371
|
+
per-file local_name -> file_path table SymbolTablePhase already built for
|
|
372
|
+
every file, including __init__.py itself) rather than re-deriving it from
|
|
373
|
+
raw ``ImportStatement.module`` text here. Re-deriving it used to mean a
|
|
374
|
+
second, hand-rolled copy of module-name resolution that didn't know an
|
|
375
|
+
__init__.py's relative imports (``from .inner import helper``) are
|
|
376
|
+
relative to its own module_name -- silently failing on the relative-import
|
|
377
|
+
style used by real `__init__.py` re-exports, unlike the plain absolute
|
|
378
|
+
form (`from pkg.inner import helper`) it happened to handle.
|
|
379
|
+
"""
|
|
380
|
+
resolved_path = file_imports.get(head)
|
|
381
|
+
if not resolved_path or not resolved_path.endswith("__init__.py"):
|
|
382
|
+
return None
|
|
383
|
+
if resolved_path in visited:
|
|
384
|
+
return _circular(raw)
|
|
385
|
+
|
|
386
|
+
symbol = tail or head
|
|
387
|
+
inner_path = import_map.get(resolved_path, {}).get(symbol)
|
|
388
|
+
if inner_path and inner_path not in visited:
|
|
389
|
+
target_id = _lookup_symbol_in_file(symbol, inner_path, symbol_table, by_path)
|
|
390
|
+
if target_id:
|
|
391
|
+
return CallResult(
|
|
392
|
+
raw=raw,
|
|
393
|
+
target_id=target_id,
|
|
394
|
+
call_type=CallType.RE_EXPORT,
|
|
395
|
+
confidence=CONFIDENCE[CallType.RE_EXPORT],
|
|
396
|
+
)
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _resolve_via_mro(
|
|
401
|
+
raw: str,
|
|
402
|
+
method_name: str,
|
|
403
|
+
owning_class: Definition,
|
|
404
|
+
file: ParsedFile,
|
|
405
|
+
file_imports: dict[str, str | None],
|
|
406
|
+
symbol_table: dict[str, str],
|
|
407
|
+
by_path: dict[str, ParsedFile],
|
|
408
|
+
method_index: dict[tuple[str, str], str],
|
|
409
|
+
) -> CallResult | None:
|
|
410
|
+
"""Rule 5: look up method_name on each base class of owning_class."""
|
|
411
|
+
for base in owning_class.bases:
|
|
412
|
+
base_def = _find_class(base, file, file_imports, by_path)
|
|
413
|
+
if base_def is None:
|
|
414
|
+
continue
|
|
415
|
+
target_id = method_index.get((base_def.id, method_name))
|
|
416
|
+
if target_id is not None and target_id in symbol_table:
|
|
417
|
+
return CallResult(
|
|
418
|
+
raw=raw,
|
|
419
|
+
target_id=symbol_table[target_id],
|
|
420
|
+
call_type=CallType.MRO,
|
|
421
|
+
confidence=CONFIDENCE[CallType.MRO],
|
|
422
|
+
)
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _lookup_symbol_in_file(
|
|
427
|
+
name: str,
|
|
428
|
+
file_path: str,
|
|
429
|
+
symbol_table: dict[str, str],
|
|
430
|
+
by_path: dict[str, ParsedFile],
|
|
431
|
+
) -> str | None:
|
|
432
|
+
"""Look for *name* defined in *file_path*, module-level first.
|
|
433
|
+
|
|
434
|
+
*name* may be dotted (e.g. ``"Worker.run"`` from ``mod.Worker.run()``,
|
|
435
|
+
where rule 3 already stripped the leading module alias): the first
|
|
436
|
+
segment is looked up as a module-level CLASS/FUNCTION, then each
|
|
437
|
+
remaining segment is walked as a METHOD child of the previous segment's
|
|
438
|
+
Definition (via ``parent_id``). A bare, single-segment *name* behaves
|
|
439
|
+
exactly as before this dotted-tail support was added.
|
|
440
|
+
"""
|
|
441
|
+
pf = by_path.get(file_path)
|
|
442
|
+
if pf is None:
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
parts = name.split(".")
|
|
446
|
+
current = _find_definition_by_name(pf, parts[0], (SymbolKind.CLASS, SymbolKind.FUNCTION))
|
|
447
|
+
if current is None:
|
|
448
|
+
return None
|
|
449
|
+
for part in parts[1:]:
|
|
450
|
+
current = _find_definition_by_name(pf, part, (SymbolKind.METHOD,), parent_id=current.id)
|
|
451
|
+
if current is None:
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
if current.id in symbol_table:
|
|
455
|
+
return symbol_table[current.id]
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _find_definition_by_name(
|
|
460
|
+
pf: ParsedFile,
|
|
461
|
+
name: str,
|
|
462
|
+
kinds: tuple[DefinitionKind, ...],
|
|
463
|
+
parent_id: str | None = None,
|
|
464
|
+
) -> Definition | None:
|
|
465
|
+
"""Find a Definition in *pf* by name/kind, optionally scoped to a
|
|
466
|
+
specific ``parent_id`` (used to walk one dotted segment at a time)."""
|
|
467
|
+
for defn in pf.definitions:
|
|
468
|
+
if (
|
|
469
|
+
defn.name == name
|
|
470
|
+
and defn.kind in kinds
|
|
471
|
+
and (parent_id is None or defn.parent_id == parent_id)
|
|
472
|
+
):
|
|
473
|
+
return defn
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _find_class(
|
|
478
|
+
class_name: str,
|
|
479
|
+
file: ParsedFile,
|
|
480
|
+
file_imports: dict[str, str | None],
|
|
481
|
+
by_path: dict[str, ParsedFile],
|
|
482
|
+
) -> Definition | None:
|
|
483
|
+
"""Find a CLASS Definition by name.
|
|
484
|
+
|
|
485
|
+
Import-map-aware first: if *class_name* is a name *file* actually
|
|
486
|
+
imported, resolve it to that specific source file and look there --
|
|
487
|
+
this is what makes MRO/inherit-edge resolution deterministic when two
|
|
488
|
+
files define same-named classes (see the Critical fix in this phase's
|
|
489
|
+
review history: a bare cross-file name scan previously picked whichever
|
|
490
|
+
same-named class happened to be enumerated first, independent of what
|
|
491
|
+
was actually imported).
|
|
492
|
+
|
|
493
|
+
Only when *class_name* is not a known import of *file* do we fall back
|
|
494
|
+
to scanning: current file first (deterministic, single file), then a
|
|
495
|
+
global scan across every indexed file as a last resort for the
|
|
496
|
+
remaining ambiguous case (a base class referenced without a traceable
|
|
497
|
+
import, e.g. a dotted module-attribute base) -- that residual fallback
|
|
498
|
+
can still theoretically pick an arbitrary same-named class, but it is no
|
|
499
|
+
longer reachable for the common, tested, import-traceable case.
|
|
500
|
+
"""
|
|
501
|
+
resolved_path = file_imports.get(class_name)
|
|
502
|
+
if resolved_path is not None:
|
|
503
|
+
target_file = by_path.get(resolved_path)
|
|
504
|
+
if target_file is not None:
|
|
505
|
+
found = _find_definition_by_name(target_file, class_name, (SymbolKind.CLASS,))
|
|
506
|
+
if found is not None:
|
|
507
|
+
return found
|
|
508
|
+
|
|
509
|
+
for defn in file.definitions:
|
|
510
|
+
if defn.kind == SymbolKind.CLASS and defn.name == class_name:
|
|
511
|
+
return defn
|
|
512
|
+
for pf in by_path.values():
|
|
513
|
+
for defn in pf.definitions:
|
|
514
|
+
if defn.kind == SymbolKind.CLASS and defn.name == class_name:
|
|
515
|
+
return defn
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _find_definition_by_id(files_lookup: dict[str, ParsedFile], def_id: str) -> Definition | None:
|
|
520
|
+
"""Find a Definition by its exact id, searching all indexed files."""
|
|
521
|
+
for pf in files_lookup.values():
|
|
522
|
+
for defn in pf.definitions:
|
|
523
|
+
if defn.id == def_id:
|
|
524
|
+
return defn
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _has_wildcard(file: ParsedFile) -> bool:
|
|
529
|
+
return any("*" in imp.names for imp in file.imports)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _is_explicit_external(head: str, file: ParsedFile) -> bool:
|
|
533
|
+
"""True if *head* was imported at module level as a bare `import X` statement."""
|
|
534
|
+
for imp in file.imports:
|
|
535
|
+
if imp.is_from:
|
|
536
|
+
continue
|
|
537
|
+
top = (imp.alias or imp.module).split(".")[0]
|
|
538
|
+
if top == head:
|
|
539
|
+
return True
|
|
540
|
+
return False
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _circular(raw: str) -> CallResult:
|
|
544
|
+
return CallResult(
|
|
545
|
+
raw=raw,
|
|
546
|
+
target_id=None,
|
|
547
|
+
call_type=UnlinkedCall.CIRCULAR,
|
|
548
|
+
confidence=CONFIDENCE[UnlinkedCall.CIRCULAR],
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _external(raw: str, library: str) -> CallResult:
|
|
553
|
+
return CallResult(
|
|
554
|
+
raw=raw,
|
|
555
|
+
target_id=None,
|
|
556
|
+
call_type=UnlinkedCall.EXTERNAL,
|
|
557
|
+
confidence=CONFIDENCE[UnlinkedCall.EXTERNAL],
|
|
558
|
+
external_library=library,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _build_method_index(files: list[ParsedFile]) -> dict[tuple[str, str], str]:
|
|
563
|
+
"""(parent_class_definition_id, method_name) -> method Definition.id, for
|
|
564
|
+
O(1) self.method()/MRO lookups instead of the old string-keyed multi-key
|
|
565
|
+
symbol table (which ctx.symbol_table, an id->id identity map, no longer
|
|
566
|
+
carries)."""
|
|
567
|
+
index: dict[tuple[str, str], str] = {}
|
|
568
|
+
for pf in files:
|
|
569
|
+
for defn in pf.definitions:
|
|
570
|
+
if defn.kind == SymbolKind.METHOD and defn.parent_id is not None:
|
|
571
|
+
index[(defn.parent_id, defn.name)] = defn.id
|
|
572
|
+
return index
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Phase 2: list[ParsedFile] -> SymbolTable, ImportMap. Global structure
|
|
2
|
+
built from all files' IR. Dispatches to per-language ImportResolver
|
|
3
|
+
strategy objects but feeds one shared global map."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from compass_core.ir.models import ParsedFile
|
|
8
|
+
from compass_core.languages import registry
|
|
9
|
+
from compass_core.phases.base import IngestContext
|
|
10
|
+
from compass_core.shared_types import Language
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SymbolTablePhase:
|
|
14
|
+
name = "symbol_table"
|
|
15
|
+
|
|
16
|
+
def run(self, ctx: IngestContext) -> IngestContext:
|
|
17
|
+
assert ctx.parsed_files is not None, "SymbolTablePhase requires ParsePhase to run first"
|
|
18
|
+
|
|
19
|
+
symbol_table: dict[str, str] = {}
|
|
20
|
+
for pf in ctx.parsed_files:
|
|
21
|
+
for definition in pf.definitions:
|
|
22
|
+
symbol_table[definition.id] = definition.id
|
|
23
|
+
|
|
24
|
+
import_map: dict[str, dict[str, str | None]] = {}
|
|
25
|
+
by_language: dict[Language, list[ParsedFile]] = {}
|
|
26
|
+
for pf in ctx.parsed_files:
|
|
27
|
+
by_language.setdefault(pf.language, []).append(pf)
|
|
28
|
+
for language_id, files in by_language.items():
|
|
29
|
+
resolver = registry.get_import_resolver(language_id)
|
|
30
|
+
import_map.update(resolver.resolve(files))
|
|
31
|
+
|
|
32
|
+
ctx.symbol_table = symbol_table
|
|
33
|
+
ctx.import_map = import_map
|
|
34
|
+
if ctx.reporter is not None:
|
|
35
|
+
ctx.reporter.update("symbol_table", 1, 1)
|
|
36
|
+
return ctx
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Phase 4: ResolvedGraph -> IngestReport. Regression gate before
|
|
2
|
+
persistence: % resolved vs. unresolved (per language), dangling-import
|
|
3
|
+
count, orphan-node count. Catches grammar upgrades / broken extractors
|
|
4
|
+
before they silently degrade agent answers."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from compass_core.ir.graph import ResolvedGraph
|
|
9
|
+
from compass_core.phases.base import IngestContext, IngestReport
|
|
10
|
+
from compass_core.shared_types import Language
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ValidationPhase:
|
|
14
|
+
name = "validation"
|
|
15
|
+
|
|
16
|
+
def run(self, ctx: IngestContext) -> IngestContext:
|
|
17
|
+
assert ctx.resolved_graph is not None
|
|
18
|
+
graph = ctx.resolved_graph
|
|
19
|
+
|
|
20
|
+
degraded = sum(1 for pf in graph.files if pf.degraded)
|
|
21
|
+
|
|
22
|
+
known_paths = {pf.path for pf in graph.files}
|
|
23
|
+
dangling = sum(1 for e in graph.import_edges if e.to_path not in known_paths)
|
|
24
|
+
|
|
25
|
+
resolved_by_language = _resolved_ratio_by_language(graph)
|
|
26
|
+
unresolved_by_language = {lang: 1.0 - ratio for lang, ratio in resolved_by_language.items()}
|
|
27
|
+
|
|
28
|
+
ctx.report = IngestReport(
|
|
29
|
+
resolved_by_language=resolved_by_language,
|
|
30
|
+
unresolved_by_language=unresolved_by_language,
|
|
31
|
+
dangling_import_count=dangling,
|
|
32
|
+
orphan_node_count=_orphan_node_count(graph),
|
|
33
|
+
degraded_file_count=degraded,
|
|
34
|
+
)
|
|
35
|
+
if ctx.reporter is not None:
|
|
36
|
+
ctx.reporter.update("validation", 1, 1)
|
|
37
|
+
return ctx
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolved_ratio_by_language(graph: ResolvedGraph) -> dict[Language, float]:
|
|
41
|
+
call_count_by_language: dict[Language, int] = {}
|
|
42
|
+
for pf in graph.files:
|
|
43
|
+
call_count_by_language.setdefault(pf.language, 0)
|
|
44
|
+
for d in pf.definitions:
|
|
45
|
+
call_count_by_language[pf.language] += len(d.raw_calls)
|
|
46
|
+
|
|
47
|
+
id_to_language: dict[str, Language] = {}
|
|
48
|
+
for pf in graph.files:
|
|
49
|
+
for d in pf.definitions:
|
|
50
|
+
id_to_language[d.id] = pf.language
|
|
51
|
+
|
|
52
|
+
resolved_by_language: dict[Language, int] = {}
|
|
53
|
+
for edge in graph.call_edges:
|
|
54
|
+
lang = id_to_language.get(edge.caller_id)
|
|
55
|
+
if lang is not None:
|
|
56
|
+
resolved_by_language[lang] = resolved_by_language.get(lang, 0) + 1
|
|
57
|
+
|
|
58
|
+
result: dict[Language, float] = {}
|
|
59
|
+
for lang, total_calls in call_count_by_language.items():
|
|
60
|
+
if total_calls == 0:
|
|
61
|
+
result[lang] = 1.0
|
|
62
|
+
continue
|
|
63
|
+
result[lang] = resolved_by_language.get(lang, 0) / total_calls
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _orphan_node_count(graph: ResolvedGraph) -> int:
|
|
68
|
+
referenced: set[str] = set()
|
|
69
|
+
for call_edge in graph.call_edges:
|
|
70
|
+
referenced.add(call_edge.caller_id)
|
|
71
|
+
referenced.add(call_edge.callee_id)
|
|
72
|
+
for inherit_edge in graph.inherit_edges:
|
|
73
|
+
referenced.add(inherit_edge.child_id)
|
|
74
|
+
referenced.add(inherit_edge.parent_id)
|
|
75
|
+
all_ids = {d.id for pf in graph.files for d in pf.definitions}
|
|
76
|
+
return len(all_ids - referenced)
|