code-atlas-forge 1.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 (181) hide show
  1. code_atlas/__init__.py +6 -0
  2. code_atlas/__main__.py +6 -0
  3. code_atlas/api/__init__.py +5 -0
  4. code_atlas/api/handlers.py +1514 -0
  5. code_atlas/api/server.py +394 -0
  6. code_atlas/cli.py +2084 -0
  7. code_atlas/config.py +149 -0
  8. code_atlas/context/__init__.py +66 -0
  9. code_atlas/context/contracts.py +277 -0
  10. code_atlas/context/domain.py +760 -0
  11. code_atlas/context/engine.py +442 -0
  12. code_atlas/context/expand.py +133 -0
  13. code_atlas/context/renderers.py +71 -0
  14. code_atlas/context/tokenizer.py +37 -0
  15. code_atlas/contracts/__init__.py +38 -0
  16. code_atlas/contracts/enums.py +67 -0
  17. code_atlas/contracts/models.py +175 -0
  18. code_atlas/contracts/ports.py +70 -0
  19. code_atlas/discovery/__init__.py +25 -0
  20. code_atlas/discovery/gitignore.py +119 -0
  21. code_atlas/discovery/identity.py +52 -0
  22. code_atlas/discovery/models.py +204 -0
  23. code_atlas/discovery/scanner.py +495 -0
  24. code_atlas/domains/__init__.py +107 -0
  25. code_atlas/domains/api/__init__.py +28 -0
  26. code_atlas/domains/api/adapter.py +405 -0
  27. code_atlas/domains/api/common.py +308 -0
  28. code_atlas/domains/api/consumers.py +224 -0
  29. code_atlas/domains/api/emit.py +231 -0
  30. code_atlas/domains/api/openapi.py +398 -0
  31. code_atlas/domains/api/routes.py +582 -0
  32. code_atlas/domains/api/yaml_subset.py +683 -0
  33. code_atlas/domains/aws/__init__.py +28 -0
  34. code_atlas/domains/aws/adapter.py +382 -0
  35. code_atlas/domains/aws/common.py +261 -0
  36. code_atlas/domains/aws/configs.py +1164 -0
  37. code_atlas/domains/aws/emit.py +411 -0
  38. code_atlas/domains/aws/pyfiles.py +2220 -0
  39. code_atlas/domains/aws/sqlbridge.py +448 -0
  40. code_atlas/domains/cicd/__init__.py +50 -0
  41. code_atlas/domains/cicd/adapter.py +762 -0
  42. code_atlas/domains/cicd/classify.py +136 -0
  43. code_atlas/domains/cicd/workflow.py +504 -0
  44. code_atlas/domains/cicd/yamlmini.py +946 -0
  45. code_atlas/domains/cli_surface.py +739 -0
  46. code_atlas/domains/contracts.py +274 -0
  47. code_atlas/domains/crossresolve.py +1208 -0
  48. code_atlas/domains/evidence.py +57 -0
  49. code_atlas/domains/identity.py +149 -0
  50. code_atlas/domains/model.py +202 -0
  51. code_atlas/domains/registry.py +250 -0
  52. code_atlas/domains/resolution.py +65 -0
  53. code_atlas/domains/spark/__init__.py +31 -0
  54. code_atlas/domains/spark/adapter.py +146 -0
  55. code_atlas/domains/spark/emit.py +344 -0
  56. code_atlas/domains/spark/extract.py +108 -0
  57. code_atlas/domains/spark/sqllit.py +192 -0
  58. code_atlas/domains/spark/walk.py +1943 -0
  59. code_atlas/domains/sql/__init__.py +30 -0
  60. code_atlas/domains/sql/adapter.py +382 -0
  61. code_atlas/domains/sql/extract.py +1271 -0
  62. code_atlas/domains/sql/nodes.py +264 -0
  63. code_atlas/domains/sql/parser.py +1510 -0
  64. code_atlas/domains/sql/tokens.py +318 -0
  65. code_atlas/domains/summarize.py +126 -0
  66. code_atlas/domains/terraform/__init__.py +20 -0
  67. code_atlas/domains/terraform/adapter.py +127 -0
  68. code_atlas/domains/terraform/catalog.py +43 -0
  69. code_atlas/domains/terraform/extract.py +1277 -0
  70. code_atlas/domains/terraform/hcl.py +538 -0
  71. code_atlas/domains/terraform/refs.py +181 -0
  72. code_atlas/domains/terraform/tfjson.py +163 -0
  73. code_atlas/domains/terraform/tfvars.py +98 -0
  74. code_atlas/domains/validation.py +120 -0
  75. code_atlas/env_override.py +27 -0
  76. code_atlas/eval/__init__.py +26 -0
  77. code_atlas/eval/domain_eval.py +681 -0
  78. code_atlas/eval/ground_truth.py +688 -0
  79. code_atlas/eval/impact_truth.py +758 -0
  80. code_atlas/eval/metrics.py +178 -0
  81. code_atlas/eval/pack_value.py +610 -0
  82. code_atlas/federation/__init__.py +72 -0
  83. code_atlas/federation/federation.py +676 -0
  84. code_atlas/federation/model.py +355 -0
  85. code_atlas/federation/store.py +194 -0
  86. code_atlas/gitbin.py +48 -0
  87. code_atlas/graph/__init__.py +20 -0
  88. code_atlas/graph/builder.py +378 -0
  89. code_atlas/graph/identity.py +54 -0
  90. code_atlas/graph/queries.py +47 -0
  91. code_atlas/graph/validation.py +92 -0
  92. code_atlas/impact/__init__.py +37 -0
  93. code_atlas/impact/blast_radius.py +171 -0
  94. code_atlas/impact/contract_analyzer.py +104 -0
  95. code_atlas/impact/contracts.py +315 -0
  96. code_atlas/impact/diff_reader.py +142 -0
  97. code_atlas/impact/domain_traversal.py +768 -0
  98. code_atlas/impact/engine.py +392 -0
  99. code_atlas/impact/packs.py +128 -0
  100. code_atlas/impact/report.py +136 -0
  101. code_atlas/impact/revision_binding.py +220 -0
  102. code_atlas/impact/risk_classifier.py +83 -0
  103. code_atlas/impact/snapshot_resolver.py +136 -0
  104. code_atlas/impact/symbol_diff.py +210 -0
  105. code_atlas/impact/test_selector.py +166 -0
  106. code_atlas/indexing/__init__.py +5 -0
  107. code_atlas/indexing/cache.py +396 -0
  108. code_atlas/indexing/change_detector.py +278 -0
  109. code_atlas/indexing/coordinator.py +1032 -0
  110. code_atlas/indexing/dependency_planner.py +268 -0
  111. code_atlas/indexing/persist.py +436 -0
  112. code_atlas/indexing/reuse.py +85 -0
  113. code_atlas/install/__init__.py +7 -0
  114. code_atlas/install/cli.py +168 -0
  115. code_atlas/install/lifecycle.py +286 -0
  116. code_atlas/mcp.py +674 -0
  117. code_atlas/observability/__init__.py +19 -0
  118. code_atlas/observability/logging.py +199 -0
  119. code_atlas/parsers/__init__.py +43 -0
  120. code_atlas/parsers/contracts.py +209 -0
  121. code_atlas/parsers/engine.py +125 -0
  122. code_atlas/parsers/languages/__init__.py +1 -0
  123. code_atlas/parsers/languages/python.py +391 -0
  124. code_atlas/parsers/languages/tsx.py +28 -0
  125. code_atlas/parsers/languages/typescript.py +909 -0
  126. code_atlas/parsers/registry.py +51 -0
  127. code_atlas/parsers/tree_sitter_runtime.py +118 -0
  128. code_atlas/resolvers/__init__.py +33 -0
  129. code_atlas/resolvers/bootstrap.py +246 -0
  130. code_atlas/resolvers/capabilities.py +33 -0
  131. code_atlas/resolvers/contracts.py +133 -0
  132. code_atlas/resolvers/coordinator.py +96 -0
  133. code_atlas/resolvers/enrichment.py +318 -0
  134. code_atlas/resolvers/lsp/__init__.py +26 -0
  135. code_atlas/resolvers/lsp/adapter.py +256 -0
  136. code_atlas/resolvers/lsp/lifecycle.py +251 -0
  137. code_atlas/resolvers/lsp/spawn.py +92 -0
  138. code_atlas/resolvers/lsp/transport.py +240 -0
  139. code_atlas/resolvers/registry.py +33 -0
  140. code_atlas/resolvers/scip/__init__.py +49 -0
  141. code_atlas/resolvers/scip/decoder.py +216 -0
  142. code_atlas/resolvers/scip/importer.py +248 -0
  143. code_atlas/resolvers/scip/loader.py +90 -0
  144. code_atlas/resolvers/scip/mapper.py +271 -0
  145. code_atlas/resolvers/scip/provenance.py +211 -0
  146. code_atlas/resolvers/scip/resolver.py +289 -0
  147. code_atlas/resolvers/scip/validation.py +101 -0
  148. code_atlas/resolvers/syntactic/__init__.py +8 -0
  149. code_atlas/resolvers/syntactic/resolver.py +254 -0
  150. code_atlas/search/__init__.py +32 -0
  151. code_atlas/search/contracts.py +176 -0
  152. code_atlas/search/domain.py +561 -0
  153. code_atlas/search/engine.py +530 -0
  154. code_atlas/search/federated.py +276 -0
  155. code_atlas/search/graph.py +282 -0
  156. code_atlas/search/hybrid.py +151 -0
  157. code_atlas/search/intent.py +48 -0
  158. code_atlas/search/lexical.py +176 -0
  159. code_atlas/search/refs.py +359 -0
  160. code_atlas/search/structural.py +505 -0
  161. code_atlas/search/tokenizer.py +43 -0
  162. code_atlas/search/vector/__init__.py +35 -0
  163. code_atlas/search/vector/contracts.py +76 -0
  164. code_atlas/search/vector/embeddings.py +96 -0
  165. code_atlas/search/vector/index.py +169 -0
  166. code_atlas/search/vector/provider.py +50 -0
  167. code_atlas/storage/__init__.py +14 -0
  168. code_atlas/storage/db.py +47 -0
  169. code_atlas/storage/migrations.py +552 -0
  170. code_atlas/storage/store.py +1045 -0
  171. code_atlas/ui/__init__.py +29 -0
  172. code_atlas/ui/i18n.py +143 -0
  173. code_atlas/ui/kit.py +689 -0
  174. code_atlas/ui/wizard.py +317 -0
  175. code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
  176. code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
  177. code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
  178. code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
  179. code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
  180. code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
  181. code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,359 @@
1
+ """Contratos canônicos de evidência + validador (lf-0051).
2
+
3
+ Toda referência carrega identidade suficiente para resolver sem
4
+ ambiguidade: repository_id + snapshot_id + ids de domínio. O
5
+ ``EvidenceValidator`` revalida contra SQLite — cross-repo/cross-
6
+ snapshot/spans inválidos falham com ``EvidenceValidationResult``
7
+ tipado, nunca silencioso.
8
+
9
+ Kinds de evidência diferenciados:
10
+ - ``lexical-match`` — casou via FTS/bm25 (matched_fields)
11
+ - ``syntactic-occurrence`` — ocorrência sintática persistida
12
+ - ``graph-relation`` — aresta do grafo (relation_id)
13
+ - ``resolver-resolution`` — resolução externa (SCIP/LSP)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sqlite3
19
+ from dataclasses import dataclass
20
+
21
+ EVIDENCE_KINDS = frozenset({
22
+ "lexical-match", "syntactic-occurrence", "graph-relation",
23
+ "resolver-resolution",
24
+ })
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SnapshotRef:
29
+ repository_id: int
30
+ snapshot_id: int
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SymbolRef:
35
+ repository_id: int
36
+ symbol_id: int
37
+ qualified_name: str
38
+ kind: str
39
+ language: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class OccurrenceRef:
44
+ repository_id: int
45
+ snapshot_id: int
46
+ occurrence_id: int
47
+ symbol_id: int
48
+ qualified_name: str
49
+ source_path: str
50
+ span_start: int | None
51
+ span_end: int | None
52
+ content_hash: str | None
53
+ parse_status: str
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class RelationRef:
58
+ repository_id: int
59
+ snapshot_id: int
60
+ relation_id: int
61
+ type: str
62
+ from_occurrence: int
63
+ to_occurrence: int | None
64
+ to_unresolved: str | None
65
+ resolution_status: str
66
+ confidence_class: str
67
+ resolver: str | None
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class SourceFileRef:
72
+ repository_id: int
73
+ snapshot_id: int
74
+ file_id: int
75
+ relative_path: str
76
+ content_hash: str | None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class EvidenceRef:
81
+ """Uma evidência tipada apontando para um ref canônico."""
82
+
83
+ kind: str # EVIDENCE_KINDS
84
+ ref: OccurrenceRef | RelationRef | SourceFileRef | SymbolRef
85
+ origin_strategy: str # lexical|structural|graph|vector|hybrid
86
+ detail: str = ""
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class EvidenceValidationResult:
91
+ ok: bool
92
+ code: str # "" | not-found | cross-repo | cross-snapshot
93
+ # | bad-span | metadata-mismatch
94
+ message: str
95
+
96
+
97
+ _OK = EvidenceValidationResult(True, "", "")
98
+
99
+
100
+ def _mismatch(field: str) -> EvidenceValidationResult:
101
+ """Metadado declarado contradiz o persistido (lf-0058).
102
+
103
+ ``None`` na ref = "não assertado" → não validado. Declarado e
104
+ divergente → rejeitado, mesmo que o id exista."""
105
+ return EvidenceValidationResult(
106
+ False, "metadata-mismatch", f"campo '{field}' diverge")
107
+
108
+
109
+ class EvidenceValidator:
110
+ """Revalida refs contra o banco — sem fabricar evidência."""
111
+
112
+ def __init__(self, conn: sqlite3.Connection) -> None:
113
+ self._conn = conn
114
+ conn.row_factory = sqlite3.Row
115
+
116
+ def _repo_of_snapshot(self, snapshot_id: int) -> int | None:
117
+ row = self._conn.execute(
118
+ "SELECT repository_id FROM snapshots WHERE id = ?",
119
+ (snapshot_id,)).fetchone()
120
+ return int(row["repository_id"]) if row else None
121
+
122
+ def validate_occurrence(
123
+ self, ref: OccurrenceRef
124
+ ) -> EvidenceValidationResult:
125
+ repo = self._repo_of_snapshot(ref.snapshot_id)
126
+ if repo is None:
127
+ return EvidenceValidationResult(
128
+ False, "not-found", "snapshot inexistente")
129
+ if repo != ref.repository_id:
130
+ return EvidenceValidationResult(
131
+ False, "cross-repo",
132
+ f"occurrence em repo {repo}, ref declara "
133
+ f"{ref.repository_id}")
134
+ row = self._conn.execute(
135
+ "SELECT o.id, o.logical_symbol_id, o.span_start, o.span_end,"
136
+ " o.content_hash, o.parse_status, f.relative_path,"
137
+ " s.qualified_name FROM symbol_occurrences o"
138
+ " JOIN source_files f ON f.id = o.source_file_id"
139
+ " JOIN symbols s ON s.id = o.logical_symbol_id"
140
+ " WHERE o.id = ? AND o.snapshot_id = ?",
141
+ (ref.occurrence_id, ref.snapshot_id)).fetchone()
142
+ if row is None:
143
+ return EvidenceValidationResult(
144
+ False, "not-found",
145
+ f"occurrence {ref.occurrence_id} ausente no snapshot")
146
+ if row["relative_path"] != ref.source_path:
147
+ return EvidenceValidationResult(
148
+ False, "cross-snapshot",
149
+ "occurrence existe mas em outro arquivo")
150
+ return self._check_occurrence_metadata(ref, row)
151
+
152
+ def _check_occurrence_metadata(
153
+ self, ref: OccurrenceRef, row: sqlite3.Row
154
+ ) -> EvidenceValidationResult:
155
+ """lf-0058: metadados declarados são revalidados — id existente
156
+ não basta (adulteração de qname/símbolo/span/hash/status)."""
157
+ if (ref.symbol_id
158
+ and int(row["logical_symbol_id"]) != ref.symbol_id):
159
+ return _mismatch("symbol_id")
160
+ if (ref.qualified_name
161
+ and str(row["qualified_name"]) != ref.qualified_name):
162
+ return _mismatch("qualified_name")
163
+ for col, span in (("span_start", ref.span_start),
164
+ ("span_end", ref.span_end)):
165
+ if span is not None and row[col] != span:
166
+ return EvidenceValidationResult(
167
+ False, "bad-span", f"{col} diverge do persistido")
168
+ if (ref.content_hash is not None
169
+ and row["content_hash"] != ref.content_hash):
170
+ return _mismatch("content_hash")
171
+ if (ref.parse_status
172
+ and str(row["parse_status"]) != ref.parse_status):
173
+ return _mismatch("parse_status")
174
+ return _OK
175
+
176
+ def validate_relation(
177
+ self, ref: RelationRef
178
+ ) -> EvidenceValidationResult:
179
+ repo = self._repo_of_snapshot(ref.snapshot_id)
180
+ if repo is None or repo != ref.repository_id:
181
+ return EvidenceValidationResult(
182
+ False, "cross-repo", "relação fora do repo/snapshot")
183
+ row = self._conn.execute(
184
+ "SELECT id, from_id, to_id, to_unresolved, type,"
185
+ " resolution_status, confidence_class, resolver"
186
+ " FROM relations WHERE id = ? AND snapshot_id = ?",
187
+ (ref.relation_id, ref.snapshot_id)).fetchone()
188
+ if row is None:
189
+ return EvidenceValidationResult(
190
+ False, "not-found",
191
+ f"relation {ref.relation_id} ausente")
192
+ if row["from_id"] != ref.from_occurrence:
193
+ return EvidenceValidationResult(
194
+ False, "bad-span", "origem da relação diverge")
195
+ # lf-0058: endpoints/tipo/status/resolver/confiança declarados
196
+ # são revalidados — aresta existe ≠ metadados corretos.
197
+ if (ref.to_occurrence is not None
198
+ and row["to_id"] != ref.to_occurrence):
199
+ return _mismatch("to_occurrence")
200
+ if (ref.to_unresolved is not None
201
+ and row["to_unresolved"] != ref.to_unresolved):
202
+ return _mismatch("to_unresolved")
203
+ if ref.type and str(row["type"]) != ref.type:
204
+ return _mismatch("type")
205
+ if (ref.resolution_status
206
+ and str(row["resolution_status"]) != ref.resolution_status):
207
+ return _mismatch("resolution_status")
208
+ if (ref.confidence_class
209
+ and str(row["confidence_class"]) != ref.confidence_class):
210
+ return _mismatch("confidence_class")
211
+ if (ref.resolver is not None
212
+ and row["resolver"] != ref.resolver):
213
+ return _mismatch("resolver")
214
+ return _OK
215
+
216
+ def validate_symbol(
217
+ self, ref: SymbolRef, snapshot_id: int
218
+ ) -> EvidenceValidationResult:
219
+ """Símbolo é repo-level: valida repo + identidade persistida."""
220
+ repo = self._repo_of_snapshot(snapshot_id)
221
+ if repo is None or repo != ref.repository_id:
222
+ return EvidenceValidationResult(
223
+ False, "cross-repo", "símbolo fora do repo")
224
+ row = self._conn.execute(
225
+ "SELECT qualified_name, kind, language"
226
+ " FROM symbols WHERE id = ?",
227
+ (ref.symbol_id,)).fetchone()
228
+ if row is None:
229
+ return EvidenceValidationResult(
230
+ False, "not-found",
231
+ f"symbol {ref.symbol_id} ausente")
232
+ if ref.qualified_name and row["qualified_name"] != ref.qualified_name:
233
+ return _mismatch("qualified_name")
234
+ if ref.kind and str(row["kind"]) != ref.kind:
235
+ return _mismatch("kind")
236
+ if ref.language and str(row["language"]) != ref.language:
237
+ return _mismatch("language")
238
+ return _OK
239
+
240
+ def validate_source_file(
241
+ self, ref: SourceFileRef
242
+ ) -> EvidenceValidationResult:
243
+ repo = self._repo_of_snapshot(ref.snapshot_id)
244
+ if repo is None or repo != ref.repository_id:
245
+ return EvidenceValidationResult(
246
+ False, "cross-repo", "arquivo fora do repo/snapshot")
247
+ row = self._conn.execute(
248
+ "SELECT relative_path, content_hash FROM source_files"
249
+ " WHERE id = ? AND snapshot_id = ?",
250
+ (ref.file_id, ref.snapshot_id)).fetchone()
251
+ if row is None:
252
+ return EvidenceValidationResult(
253
+ False, "not-found",
254
+ f"source_file {ref.file_id} ausente")
255
+ if (ref.relative_path
256
+ and str(row["relative_path"]) != ref.relative_path):
257
+ return _mismatch("relative_path")
258
+ if (ref.content_hash is not None
259
+ and row["content_hash"] != ref.content_hash):
260
+ return _mismatch("content_hash")
261
+ return _OK
262
+
263
+ def validate_snapshot(
264
+ self, ref: SnapshotRef
265
+ ) -> EvidenceValidationResult:
266
+ repo = self._repo_of_snapshot(ref.snapshot_id)
267
+ if repo is None:
268
+ return EvidenceValidationResult(
269
+ False, "not-found", "snapshot inexistente")
270
+ if repo != ref.repository_id:
271
+ return EvidenceValidationResult(
272
+ False, "cross-repo",
273
+ f"snapshot pertence ao repo {repo}, "
274
+ f"ref declara {ref.repository_id}")
275
+ return _OK
276
+
277
+
278
+ class EvidenceGrounder:
279
+ """Materializa ``EvidenceRef``s tipadas a partir de um SearchHit —
280
+ rastreabilidade hit → ocorrência → origem (lf-0051)."""
281
+
282
+ def __init__(self, conn: sqlite3.Connection) -> None:
283
+ self._conn = conn
284
+ conn.row_factory = sqlite3.Row
285
+
286
+ def occurrence_ref(
287
+ self, repository_id: int, snapshot_id: int, occurrence_id: int,
288
+ ) -> OccurrenceRef | None:
289
+ row = self._conn.execute(
290
+ "SELECT o.id, o.logical_symbol_id, o.span_start, o.span_end,"
291
+ " o.content_hash, o.parse_status, f.relative_path,"
292
+ " s.qualified_name FROM symbol_occurrences o"
293
+ " JOIN source_files f ON f.id = o.source_file_id"
294
+ " JOIN symbols s ON s.id = o.logical_symbol_id"
295
+ " WHERE o.id = ? AND o.snapshot_id = ?",
296
+ (occurrence_id, snapshot_id)).fetchone()
297
+ if row is None:
298
+ return None
299
+ # lf-0056: repository_id do caller deve bater com o dono REAL
300
+ # do snapshot — sem essa checagem uma ref cross-repo recebia
301
+ # identidade falsificada.
302
+ srow = self._conn.execute(
303
+ "SELECT repository_id FROM snapshots WHERE id = ?",
304
+ (snapshot_id,)).fetchone()
305
+ if srow is None or int(srow["repository_id"]) != repository_id:
306
+ return None
307
+ return OccurrenceRef(
308
+ repository_id=repository_id, snapshot_id=snapshot_id,
309
+ occurrence_id=occurrence_id,
310
+ symbol_id=int(row["logical_symbol_id"]),
311
+ qualified_name=str(row["qualified_name"]),
312
+ source_path=str(row["relative_path"]),
313
+ span_start=row["span_start"], span_end=row["span_end"],
314
+ content_hash=row["content_hash"],
315
+ parse_status=str(row["parse_status"]))
316
+
317
+ def ground_hit(
318
+ self,
319
+ repository_id: int,
320
+ snapshot_id: int,
321
+ hit: object,
322
+ ) -> tuple[EvidenceRef, ...]:
323
+ """Refs tipadas para um hit: ocorrência + relations + resolver.
324
+
325
+ Sem evidência persistida → tupla vazia (ausência identificada,
326
+ nunca fabricada)."""
327
+ occ_id = int(getattr(hit, "occurrence_id", 0) or 0)
328
+ strategy = str(getattr(hit, "strategy", ""))
329
+ out: list[EvidenceRef] = []
330
+ occ = self.occurrence_ref(repository_id, snapshot_id, occ_id)
331
+ if occ is not None:
332
+ kind = ("lexical-match" if strategy == "lexical"
333
+ else "syntactic-occurrence")
334
+ out.append(EvidenceRef(kind=kind, ref=occ,
335
+ origin_strategy=strategy))
336
+ rows = self._conn.execute(
337
+ "SELECT id, from_id, to_id, to_unresolved, type,"
338
+ " resolution_status, confidence_class, resolver"
339
+ " FROM relations WHERE snapshot_id = ? AND ("
340
+ " from_id = ? OR to_id = ?) ORDER BY id LIMIT 50",
341
+ (snapshot_id, occ_id, occ_id)).fetchall()
342
+ for r in rows:
343
+ out.append(EvidenceRef(
344
+ kind="graph-relation",
345
+ ref=RelationRef(
346
+ repository_id=repository_id,
347
+ snapshot_id=snapshot_id,
348
+ relation_id=int(r["id"]), type=str(r["type"]),
349
+ from_occurrence=int(r["from_id"]),
350
+ to_occurrence=r["to_id"],
351
+ to_unresolved=r["to_unresolved"],
352
+ resolution_status=str(r["resolution_status"]),
353
+ confidence_class=str(r["confidence_class"]),
354
+ resolver=r["resolver"]),
355
+ origin_strategy="graph" if strategy == "graph"
356
+ else strategy,
357
+ detail="outgoing" if int(r["from_id"]) == occ_id
358
+ else "incoming"))
359
+ return tuple(out)