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