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,1045 @@
1
+ """Acesso a dados do grafo (GraphStore parcial do Cycle 1).
2
+
3
+ Somente queries parametrizadas. O Cycle 1 escreve inventário + snapshots;
4
+ ``symbols``/``relations``/``evidence`` existem no schema mas não são
5
+ populados por parsing real ainda.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from collections.abc import Iterable
13
+
14
+ from code_atlas.contracts.enums import SnapshotStatus
15
+ from code_atlas.contracts.models import Repository, Snapshot
16
+ from code_atlas.discovery.models import SourceFile, WarningKind
17
+ from code_atlas.domains.model import DomainEntity
18
+
19
+
20
+ def _snapshot_from_row(row: sqlite3.Row) -> Snapshot:
21
+ return Snapshot(
22
+ id=int(row["id"]),
23
+ repository_id=int(row["repository_id"]),
24
+ git_ref=str(row["git_ref"]),
25
+ commit_sha=row["commit_sha"],
26
+ dirty=bool(row["dirty"]),
27
+ worktree_digest=str(row["worktree_digest"]),
28
+ analyzer_versions=json.loads(row["analyzer_versions"]),
29
+ parent_snapshot_id=row["parent_snapshot_id"],
30
+ status=SnapshotStatus(str(row["status"])),
31
+ created_at=str(row["created_at"]),
32
+ )
33
+
34
+
35
+ class Store:
36
+ """Repositório de dados sobre uma conexão sqlite3 já configurada."""
37
+
38
+ def __init__(self, conn: sqlite3.Connection) -> None:
39
+ self._conn = conn
40
+
41
+ @property
42
+ def conn(self) -> sqlite3.Connection:
43
+ """Conexão subjacente — para queries compostas (lf-0019 reuse)."""
44
+ return self._conn
45
+
46
+ # --- repositories -------------------------------------------------
47
+
48
+ def get_or_create_repository(
49
+ self, identity_key: str, root_handle: str = ""
50
+ ) -> int:
51
+ """Repo estável por **identidade lógica** (lf-0021), não por path.
52
+
53
+ ``identity_key`` vem de ``discovery.identity`` (git remote →
54
+ commit raiz → path). ``root_handle`` é só diagnóstico —
55
+ atualizado a cada index para refletir a localização atual
56
+ (realocação mantém identidade).
57
+
58
+ Race-safe (lf-0015 + v5): UNIQUE em ``identity_key`` +
59
+ ``INSERT OR IGNORE`` — dois indexadores concorrentes convergem
60
+ para a mesma linha.
61
+ """
62
+ row = self._conn.execute(
63
+ "SELECT id, root_handle FROM repositories WHERE identity_key = ?",
64
+ (identity_key,),
65
+ ).fetchone()
66
+ if row is not None:
67
+ if root_handle and row["root_handle"] != root_handle:
68
+ self._conn.execute(
69
+ "UPDATE repositories SET root_handle = ? WHERE id = ?",
70
+ (root_handle, int(row["id"])),
71
+ )
72
+ return int(row["id"])
73
+ self._conn.execute(
74
+ "INSERT OR IGNORE INTO repositories (root_handle, identity_key) "
75
+ "VALUES (?, ?)",
76
+ (root_handle, identity_key),
77
+ )
78
+ row2 = self._conn.execute(
79
+ "SELECT id FROM repositories WHERE identity_key = ?", (identity_key,)
80
+ ).fetchone()
81
+ assert row2 is not None # UNIQUE + INSERT OR IGNORE garante a linha
82
+ return int(row2["id"])
83
+
84
+ def repository_id_for(self, key_or_handle: str) -> int | None:
85
+ """Id do repo por ``identity_key`` **ou** ``root_handle``.
86
+
87
+ Compat de lookup: busca por identity primeiro (repos novos) e
88
+ cai para root_handle (repos pré-v5/sem git indexados por path).
89
+ """
90
+ row = self._conn.execute(
91
+ "SELECT id FROM repositories WHERE identity_key = ?"
92
+ " OR root_handle = ? ORDER BY id LIMIT 1",
93
+ (key_or_handle, key_or_handle),
94
+ ).fetchone()
95
+ return int(row["id"]) if row else None
96
+
97
+ def repository_by_id(self, repo_id: int) -> Repository | None:
98
+ row = self._conn.execute(
99
+ "SELECT * FROM repositories WHERE id = ?", (repo_id,)
100
+ ).fetchone()
101
+ if row is None:
102
+ return None
103
+ return Repository(
104
+ id=int(row["id"]),
105
+ root_handle=str(row["root_handle"]),
106
+ origin=str(row["origin"]),
107
+ git_provider=str(row["git_provider"]),
108
+ default_branch=str(row["default_branch"]),
109
+ )
110
+
111
+ # --- snapshots ----------------------------------------------------
112
+
113
+ def latest_ready_snapshot(self, repository_id: int) -> Snapshot | None:
114
+ row = self._conn.execute(
115
+ "SELECT * FROM snapshots WHERE repository_id = ? AND status = 'READY' "
116
+ "ORDER BY id DESC LIMIT 1",
117
+ (repository_id,),
118
+ ).fetchone()
119
+ return _snapshot_from_row(row) if row else None
120
+
121
+ def snapshot_by_id(self, snapshot_id: int) -> Snapshot | None:
122
+ row = self._conn.execute(
123
+ "SELECT * FROM snapshots WHERE id = ?", (snapshot_id,)
124
+ ).fetchone()
125
+ return _snapshot_from_row(row) if row else None
126
+
127
+ def insert_snapshot(
128
+ self,
129
+ repository_id: int,
130
+ worktree_digest: str,
131
+ analyzer_versions: dict[str, str],
132
+ parent_snapshot_id: int | None,
133
+ git_ref: str = "",
134
+ commit_sha: str | None = None,
135
+ dirty: bool = False,
136
+ ) -> int:
137
+ """Cria snapshot em estado BUILDING.
138
+
139
+ ``dirty`` (lf-0021): worktree com alterações não commitadas —
140
+ snapshot é **vinculado ao estado atual**, nunca ao ``commit_sha``
141
+ limpo (queries veem ``commit_sha`` do HEAD mas ``dirty=1`` marca
142
+ que o conteúdo indexado diverge dele).
143
+ """
144
+ cur = self._conn.execute(
145
+ "INSERT INTO snapshots (repository_id, git_ref, commit_sha, dirty, "
146
+ "worktree_digest, analyzer_versions, parent_snapshot_id, status) "
147
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 'BUILDING')",
148
+ (
149
+ repository_id,
150
+ git_ref,
151
+ commit_sha,
152
+ int(dirty),
153
+ worktree_digest,
154
+ json.dumps(analyzer_versions, sort_keys=True),
155
+ parent_snapshot_id,
156
+ ),
157
+ )
158
+ return int(cur.lastrowid) # type: ignore[arg-type]
159
+
160
+ def set_snapshot_status(
161
+ self, snapshot_id: int, status: SnapshotStatus, error: str | None = None
162
+ ) -> None:
163
+ self._conn.execute(
164
+ "UPDATE snapshots SET status = ?, error = COALESCE(?, error) WHERE id = ?",
165
+ (str(status), error, snapshot_id),
166
+ )
167
+
168
+ def publish_snapshot(self, repository_id: int, snapshot_id: int) -> None:
169
+ """Marca o novo READY e torna STALE os anteriores (mesma transação).
170
+
171
+ lf-0132: também renova ``sqlite_stat1`` das tabelas de domínio —
172
+ os índices ``(snapshot_id, endpoint)`` de v11 só vencem o
173
+ range-scan de ``idx_domain_relations_snap`` quando o planner
174
+ conhece a seletividade real. O ANALYZE roda dentro da tx de
175
+ publicação: stats commitam atomicamente com os dados que
176
+ descrevem. Custo é O(tabelas de domínio) por publicação —
177
+ proporcional e medido em ``benchmarks/bench_sqlite.py``.
178
+ ``PRAGMA optimize`` foi medido e não re-analisa tabelas
179
+ crescidas de forma confiável — ANALYZE explícito é o honesto.
180
+ """
181
+ self._conn.execute(
182
+ "UPDATE snapshots SET status = 'STALE' "
183
+ "WHERE repository_id = ? AND status = 'READY' AND id != ?",
184
+ (repository_id, snapshot_id),
185
+ )
186
+ self.set_snapshot_status(snapshot_id, SnapshotStatus.READY)
187
+ self._refresh_domain_stats()
188
+
189
+ def _refresh_domain_stats(self) -> None:
190
+ """``ANALYZE`` escopo-domínio — stats fiéis às rows publicadas."""
191
+ if not self._has_table("domain_relations"):
192
+ return
193
+ # SQL literal por tabela — auditoria AST exige execute() com
194
+ # constante/nome, nunca expressão concatenada.
195
+ self._conn.execute("ANALYZE domain_relations")
196
+ self._conn.execute("ANALYZE domain_entity_occurrences")
197
+ self._conn.execute("ANALYZE domain_entities")
198
+
199
+ def gc_stale_snapshots(self, repository_id: int, keep: int) -> int:
200
+ """Remove snapshots STALE além dos ``keep`` mais recentes.
201
+
202
+ lf-0031 — política de GC explícita: READY nunca é tocado;
203
+ FAILED também é preservado (trilha de auditoria de falhas).
204
+ Filhos caem por ``ON DELETE CASCADE`` — a integridade referencial
205
+ continua garantida pelo próprio schema. Chamado dentro da
206
+ transação de publicação (atomicidade com o READY novo).
207
+ Retorna quantos snapshots foram removidos.
208
+ """
209
+ if keep < 0:
210
+ raise ValueError("keep must be >= 0")
211
+ # ``LIMIT -1 OFFSET keep`` = todos os STALE exceto os ``keep``
212
+ # mais novos — SQL constante (auditoria AST).
213
+ # FKs sem cascade precisam ser desfeitas antes do delete:
214
+ # ``index_jobs.snapshot_id`` (o job fica — só perde o vínculo)
215
+ # e ``snapshots.parent_snapshot_id`` (linhagem dos mais novos).
216
+ _doomed = (
217
+ "SELECT id FROM snapshots"
218
+ " WHERE repository_id = ? AND status = 'STALE'"
219
+ " ORDER BY id DESC LIMIT -1 OFFSET ?"
220
+ )
221
+ _sql_jobs = (
222
+ "UPDATE index_jobs SET snapshot_id = NULL"
223
+ " WHERE snapshot_id IN (" + _doomed + ")"
224
+ )
225
+ _sql_parent = (
226
+ "UPDATE snapshots SET parent_snapshot_id = NULL"
227
+ " WHERE parent_snapshot_id IN (" + _doomed + ")"
228
+ )
229
+ _sql_del = "DELETE FROM snapshots WHERE id IN (" + _doomed + ")"
230
+ _sql_doomed_ids = (
231
+ "SELECT id FROM snapshots"
232
+ " WHERE repository_id = ? AND status = 'STALE'"
233
+ " ORDER BY id DESC LIMIT -1 OFFSET ?"
234
+ )
235
+ doomed_ids = [
236
+ int(r["id"]) for r in self._conn.execute(
237
+ _sql_doomed_ids, (repository_id, keep)
238
+ )
239
+ ]
240
+ self._conn.execute(_sql_jobs, (repository_id, keep))
241
+ self._conn.execute(_sql_parent, (repository_id, keep))
242
+ if doomed_ids and self._has_table("fts_occurrences"):
243
+ # filhos caem por CASCADE, mas FTS não é FK — delete manual
244
+ self._delete_fts_occurrences(doomed_ids)
245
+ if doomed_ids and self._has_table("domain_fts"):
246
+ # lf-0133: mesmo cuidado — domain_fts é FTS standalone, não
247
+ # participa do cascade de snapshots.
248
+ self._delete_domain_fts(doomed_ids)
249
+ cur = self._conn.execute(_sql_del, (repository_id, keep))
250
+ return int(cur.rowcount if cur.rowcount else 0)
251
+
252
+ def _has_table(self, name: str) -> bool:
253
+ row = self._conn.execute(
254
+ "SELECT 1 FROM sqlite_master WHERE type IN ('table','shadow')"
255
+ " AND name = ?",
256
+ (name,),
257
+ ).fetchone()
258
+ return row is not None
259
+
260
+ # --- source files + inventory ------------------------------------
261
+
262
+ def upsert_source_file(self, repository_id: int, f: SourceFile) -> int:
263
+ """Dedup por (repository_id, relative_path) — chave de conteúdo estável."""
264
+ row = self._conn.execute(
265
+ "SELECT id FROM source_files WHERE repository_id = ? AND relative_path = ?",
266
+ (repository_id, f.path),
267
+ ).fetchone()
268
+ if row:
269
+ return int(row["id"])
270
+ cur = self._conn.execute(
271
+ "INSERT INTO source_files (repository_id, relative_path, language, "
272
+ "sha256, size_bytes, ignored) VALUES (?, ?, ?, ?, ?, ?)",
273
+ (repository_id, f.path, f.language, f.sha256, f.size_bytes, int(f.ignored)),
274
+ )
275
+ return int(cur.lastrowid) # type: ignore[arg-type]
276
+
277
+ def ensure_source_files(
278
+ self, repository_id: int, files: Iterable[SourceFile]
279
+ ) -> dict[str, int]:
280
+ """Dedup de ``source_files`` em duas fases — path → id.
281
+
282
+ Mesmo padrão de ``ensure_symbols`` (lf-0023): um SELECT inicial,
283
+ executemany ``INSERT OR IGNORE`` dos ausentes, SELECT final.
284
+ Paridade com o upsert antigo: metadados da linha existente
285
+ NÃO são atualizados (o inventário por snapshot carrega o
286
+ sha256/size frescos em ``file_inventory``).
287
+ """
288
+ flist = list(files)
289
+ existing = self._conn.execute(
290
+ "SELECT id, relative_path FROM source_files"
291
+ " WHERE repository_id = ?",
292
+ (repository_id,),
293
+ ).fetchall()
294
+ ids = {r["relative_path"]: int(r["id"]) for r in existing}
295
+ missing = [f for f in flist if f.path not in ids]
296
+ if missing:
297
+ self._conn.executemany(
298
+ "INSERT OR IGNORE INTO source_files (repository_id,"
299
+ " relative_path, language, sha256, size_bytes, ignored)"
300
+ " VALUES (?, ?, ?, ?, ?, ?)",
301
+ [
302
+ (repository_id, f.path, f.language, f.sha256,
303
+ f.size_bytes, int(f.ignored))
304
+ for f in missing
305
+ ],
306
+ )
307
+ refetch = self._conn.execute(
308
+ "SELECT id, relative_path FROM source_files"
309
+ " WHERE repository_id = ?",
310
+ (repository_id,),
311
+ ).fetchall()
312
+ ids = {r["relative_path"]: int(r["id"]) for r in refetch}
313
+ return ids
314
+
315
+ def insert_inventory_rows(
316
+ self, snapshot_id: int, rows: Iterable[tuple[int, SourceFile]]
317
+ ) -> None:
318
+ """Insere (snapshot, source_file) com atributos da ocorrência."""
319
+ self._conn.executemany(
320
+ "INSERT INTO file_inventory (snapshot_id, source_file_id, sha256, "
321
+ "size_bytes, ignored) VALUES (?, ?, ?, ?, ?)",
322
+ [(snapshot_id, fid, f.sha256, f.size_bytes, int(f.ignored)) for fid, f in rows],
323
+ )
324
+
325
+ def inventory_count(self, snapshot_id: int) -> int:
326
+ row = self._conn.execute(
327
+ "SELECT COUNT(*) AS c FROM file_inventory WHERE snapshot_id = ?",
328
+ (snapshot_id,),
329
+ ).fetchone()
330
+ return int(row["c"])
331
+
332
+ def snapshot_inventory(self, snapshot_id: int) -> list[dict[str, object]]:
333
+ """Inventário do snapshot: path, language, sha256, source_file_id,
334
+ e pior parse_status observado nas ocorrências do arquivo."""
335
+ rows = self._conn.execute(
336
+ """
337
+ SELECT sf.id AS source_file_id, sf.relative_path, sf.language,
338
+ fi.sha256,
339
+ COALESCE(
340
+ (SELECT MAX(CASE so.parse_status
341
+ WHEN 'parse-failed' THEN 2
342
+ WHEN 'parsed-with-errors' THEN 1
343
+ ELSE 0 END)
344
+ FROM symbol_occurrences so
345
+ WHERE so.snapshot_id = fi.snapshot_id
346
+ AND so.source_file_id = fi.source_file_id),
347
+ -1) AS status_rank
348
+ FROM file_inventory fi
349
+ JOIN source_files sf ON sf.id = fi.source_file_id
350
+ WHERE fi.snapshot_id = ?
351
+ ORDER BY sf.relative_path
352
+ """,
353
+ (snapshot_id,),
354
+ ).fetchall()
355
+ rank = {0: "parsed", 1: "parsed-with-errors", 2: "parse-failed"}
356
+ return [
357
+ {
358
+ "path": r["relative_path"],
359
+ "language": r["language"],
360
+ "sha256": r["sha256"],
361
+ "source_file_id": r["source_file_id"],
362
+ "parse_status": rank.get(int(r["status_rank"]), "unparsed"),
363
+ }
364
+ for r in rows
365
+ ]
366
+
367
+ def module_import_edges(
368
+ self, snapshot_id: int
369
+ ) -> list[tuple[str, str | None, str, str]]:
370
+ """Arestas IMPORTS do snapshot mapeadas para paths.
371
+
372
+ Retorna ``(importer_path, imported_path|None, to_unresolved,
373
+ resolution_status)`` — insumo do planner de invalidação (lf-0019).
374
+ """
375
+ rows = self._conn.execute(
376
+ """
377
+ SELECT sf_from.relative_path AS importer,
378
+ sf_to.relative_path AS imported,
379
+ r.to_unresolved AS to_text,
380
+ r.resolution_status AS status
381
+ FROM relations r
382
+ JOIN symbol_occurrences o_from ON o_from.id = r.from_id
383
+ JOIN source_files sf_from ON sf_from.id = o_from.source_file_id
384
+ LEFT JOIN symbol_occurrences o_to ON o_to.id = r.to_id
385
+ LEFT JOIN source_files sf_to ON sf_to.id = o_to.source_file_id
386
+ WHERE r.snapshot_id = ? AND r.type = 'IMPORTS'
387
+ ORDER BY sf_from.relative_path
388
+ """,
389
+ (snapshot_id,),
390
+ ).fetchall()
391
+ return [
392
+ (r["importer"], r["imported"], r["to_text"] or "", r["status"])
393
+ for r in rows
394
+ ]
395
+
396
+ def snapshot_file_symbols(
397
+ self, snapshot_id: int
398
+ ) -> list[tuple[str, str, str, str, int]]:
399
+ """Símbolos com ocorrência no snapshot, por arquivo.
400
+
401
+ Retorna ``(path, kind, qualified_name, language, source_file_id)``
402
+ — usado para reconstruir contextos de módulo reutilizados no
403
+ índice incremental (lf-0019).
404
+ """
405
+ rows = self._conn.execute(
406
+ """
407
+ SELECT DISTINCT sf.relative_path AS path, s.kind, s.qualified_name,
408
+ s.language, so.source_file_id
409
+ FROM symbol_occurrences so
410
+ JOIN symbols s ON s.id = so.logical_symbol_id
411
+ JOIN source_files sf ON sf.id = so.source_file_id
412
+ WHERE so.snapshot_id = ?
413
+ ORDER BY sf.relative_path, s.qualified_name
414
+ """,
415
+ (snapshot_id,),
416
+ ).fetchall()
417
+ return [
418
+ (r["path"], r["kind"], r["qualified_name"], r["language"],
419
+ r["source_file_id"])
420
+ for r in rows
421
+ ]
422
+
423
+ def foreign_key_violations(self) -> list[str]:
424
+ """Lista violações FK pendentes (integrity gate antes do READY)."""
425
+ rows = self._conn.execute("PRAGMA foreign_key_check").fetchall()
426
+ return [str(tuple(r)) for r in rows]
427
+
428
+ def cross_snapshot_relations(self, snapshot_id: int) -> list[int]:
429
+ """Relações cujos endpoints apontam fora do próprio snapshot.
430
+
431
+ ``from_id`` é NOT NULL (JOIN); ``to_id`` pode ser NULL em alvos
432
+ unresolved/external — LEFT JOIN + checagem só quando presente.
433
+ """
434
+ rows = self._conn.execute(
435
+ """
436
+ SELECT r.id FROM relations r
437
+ JOIN symbol_occurrences o1 ON o1.id = r.from_id
438
+ LEFT JOIN symbol_occurrences o2 ON o2.id = r.to_id
439
+ WHERE r.snapshot_id = ?
440
+ AND (o1.snapshot_id != ?
441
+ OR (r.to_id IS NOT NULL AND o2.snapshot_id != ?))
442
+ """,
443
+ (snapshot_id, snapshot_id, snapshot_id),
444
+ ).fetchall()
445
+ return [int(r["id"]) for r in rows]
446
+
447
+ # --- symbols / occurrences / relations / evidence (Cycle 2) -------
448
+ # --- persistência em lote (lf-0023, medido: ~145k execute/5k arquivos) --
449
+
450
+ _BATCH = 400 # linhas por INSERT multi-row (abaixo do limite de vars)
451
+
452
+ def ensure_symbols(
453
+ self, repository_id: int, keys: list[tuple[str, str, str]]
454
+ ) -> tuple[dict[tuple[str, str, str], int], set[tuple[str, str, str]]]:
455
+ """Dedup de símbolos em duas fases — 145k+ round-trips → ~90.
456
+
457
+ Fase 1: SELECT dos existentes numa query. Fase 2: executemany
458
+ ``INSERT OR IGNORE`` só dos ausentes, seguido de SELECT final
459
+ dos ids (``RETURNING`` não devolve ids de linhas ignoradas).
460
+ ``keys``: ``(language, qualified_name, kind)`` dedupados pelo
461
+ caller.
462
+
463
+ Retorna ``(id_map, created)`` — ``created`` é o conjunto exato
464
+ de chaves que passaram a existir nesta chamada (correto mesmo
465
+ sob escritor concorrente: derivado do diff dos SELECTs).
466
+ """
467
+ uniq = sorted(set(keys))
468
+ existing = self._conn.execute(
469
+ "SELECT id, language, qualified_name, kind FROM symbols"
470
+ " WHERE repository_id = ?",
471
+ (repository_id,),
472
+ ).fetchall()
473
+ ids = {
474
+ (r["language"], r["qualified_name"], r["kind"]): int(r["id"])
475
+ for r in existing
476
+ }
477
+ pre = set(ids)
478
+ missing = [k for k in uniq if k not in ids]
479
+ if missing:
480
+ self._conn.executemany(
481
+ "INSERT OR IGNORE INTO symbols "
482
+ "(repository_id, language, qualified_name, kind) "
483
+ "VALUES (?, ?, ?, ?)",
484
+ [(repository_id, *k) for k in missing],
485
+ )
486
+ refetch = self._conn.execute(
487
+ "SELECT id, language, qualified_name, kind FROM symbols"
488
+ " WHERE repository_id = ?",
489
+ (repository_id,),
490
+ ).fetchall()
491
+ ids = {
492
+ (r["language"], r["qualified_name"], r["kind"]): int(r["id"])
493
+ for r in refetch
494
+ }
495
+ return ids, set(ids) - pre
496
+
497
+ def insert_symbols_batch(
498
+ self, rows: Iterable[tuple[int, str, str, str]]
499
+ ) -> None:
500
+ """FTS: inserts em lote — chamado só para símbolos novos."""
501
+ self._conn.executemany(
502
+ "INSERT INTO fts_symbols (rowid, qualified_name, language, kind) "
503
+ "VALUES (?, ?, ?, ?)",
504
+ list(rows),
505
+ )
506
+
507
+ def insert_fts_occurrences_batch(
508
+ self, rows: Iterable[tuple[object, ...]]
509
+ ) -> None:
510
+ """lf-0036 — FTS por-ocorrência (snapshot-scoped): rowid = id da
511
+ ocorrência; campos já vêm tokenizados do persist."""
512
+ self._conn.executemany(
513
+ "INSERT INTO fts_occurrences (rowid, snapshot_id, name, terms,"
514
+ " signature, docstring, path, language, kind)"
515
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
516
+ list(rows),
517
+ )
518
+
519
+ def _delete_fts_occurrences(self, snapshot_ids: Iterable[int]) -> None:
520
+ ids = list(snapshot_ids)
521
+ if not ids:
522
+ return
523
+ # DELETE em FTS regular: placeholder único via json_each (SQL
524
+ # constante — auditoria AST proíbe IN dinâmico).
525
+ self._conn.execute(
526
+ "DELETE FROM fts_occurrences WHERE snapshot_id IN"
527
+ " (SELECT value FROM json_each(?))",
528
+ (json.dumps(ids),),
529
+ )
530
+
531
+ def _delete_domain_fts(self, snapshot_ids: Iterable[int]) -> None:
532
+ ids = list(snapshot_ids)
533
+ if not ids:
534
+ return
535
+ self._conn.execute(
536
+ "DELETE FROM domain_fts WHERE snapshot_id IN"
537
+ " (SELECT value FROM json_each(?))",
538
+ (json.dumps(ids),),
539
+ )
540
+
541
+ def _insert_returning(
542
+ self, sql_prefix: str, rows: list[tuple[object, ...]], ncols: int
543
+ ) -> list[int]:
544
+ """Multi-row ``INSERT ... RETURNING id`` em chunks.
545
+
546
+ SQLite devolve ids na ordem das VALUES — o mapa com as linhas
547
+ de entrada é posicional e estável dentro da transação.
548
+ """
549
+ out: list[int] = []
550
+ ph = ",".join("?" for _ in range(ncols))
551
+ for i in range(0, len(rows), self._BATCH):
552
+ chunk = rows[i : i + self._BATCH]
553
+ stmt = sql_prefix + ",".join(
554
+ "(" + ph + ")" for _ in chunk
555
+ ) + " RETURNING id"
556
+ cur = self._conn.execute(
557
+ stmt, [v for row in chunk for v in row]
558
+ )
559
+ out.extend(int(r["id"]) for r in cur.fetchall())
560
+ return out
561
+
562
+ def insert_occurrences_batch(
563
+ self, rows: list[tuple[object, ...]]
564
+ ) -> list[int]:
565
+ """Batch de ocorrências — retorna ids na ordem das linhas."""
566
+ return self._insert_returning(
567
+ "INSERT INTO symbol_occurrences (snapshot_id, logical_symbol_id,"
568
+ " source_file_id, span_start, span_end, line, col, content_hash,"
569
+ " parse_status) VALUES ",
570
+ rows, 9,
571
+ )
572
+
573
+ def insert_evidence_batch(
574
+ self, rows: list[tuple[object, ...]]
575
+ ) -> list[int]:
576
+ """Batch de evidências — retorna ids na ordem das linhas."""
577
+ return self._insert_returning(
578
+ "INSERT INTO evidence (snapshot_id, source_file_id, path, span,"
579
+ " content_hash, extractor, extractor_version, notes,"
580
+ " resolver_id, resolver_version, evidence_tier, index_hash,"
581
+ " validation_status) VALUES ",
582
+ rows, 13,
583
+ )
584
+
585
+ def insert_evidence(
586
+ self,
587
+ snapshot_id: int,
588
+ source_file_id: int,
589
+ path: str,
590
+ span_json: str | None,
591
+ content_hash: str,
592
+ extractor: str,
593
+ extractor_version: str,
594
+ notes: str = "",
595
+ resolver_id: str = "",
596
+ resolver_version: str = "",
597
+ evidence_tier: int = 0,
598
+ index_hash: str = "",
599
+ validation_status: str = "",
600
+ ) -> int:
601
+ cur = self._conn.execute(
602
+ "INSERT INTO evidence (snapshot_id, source_file_id, path, span, "
603
+ "content_hash, extractor, extractor_version, notes, "
604
+ "resolver_id, resolver_version, evidence_tier, index_hash, "
605
+ "validation_status) "
606
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
607
+ (
608
+ snapshot_id, source_file_id, path, span_json, content_hash,
609
+ extractor, extractor_version, notes, resolver_id,
610
+ resolver_version, evidence_tier, index_hash,
611
+ validation_status,
612
+ ),
613
+ )
614
+ return int(cur.lastrowid) # type: ignore[arg-type]
615
+
616
+ def insert_relations(self, rows: Iterable[tuple[object, ...]]) -> int:
617
+ """Batch de relações — executemany (nenhum id precisa voltar)."""
618
+ self._conn.executemany(
619
+ "INSERT INTO relations (snapshot_id, from_id, to_id, to_unresolved, "
620
+ "type, resolution_status, confidence_class, resolver, evidence_ids) "
621
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
622
+ list(rows),
623
+ )
624
+ return self._conn.total_changes # para telemetria externa
625
+
626
+ # --- domain entities / relations (lf-0110, schema v9) --------------
627
+
628
+ def ensure_domain_entities(
629
+ self, repository_id: int, entities: Iterable[DomainEntity]
630
+ ) -> dict[str, int]:
631
+ """Dedup de entidades de domínio — identity_key → row id.
632
+
633
+ Mesma técnica de ``ensure_symbols``: SELECT inicial, ``INSERT OR
634
+ IGNORE`` em lote dos ausentes, SELECT final. ``identity_key`` é
635
+ a chave lógica canônica (``DomainIdentity.logical_key``) —
636
+ catálogo/namespace/ambiente fazem parte da chave, então
637
+ homônimos em escopos distintos não colidem.
638
+ """
639
+ items: dict[str, DomainEntity] = {}
640
+ for e in entities:
641
+ items[e.identity_key()] = e
642
+ if not items:
643
+ return {}
644
+ existing = self._conn.execute(
645
+ "SELECT id, identity_key FROM domain_entities"
646
+ " WHERE repository_id = ?",
647
+ (repository_id,),
648
+ ).fetchall()
649
+ ids = {r["identity_key"]: int(r["id"]) for r in existing}
650
+ missing = [k for k in sorted(items) if k not in ids]
651
+ if missing:
652
+ self._conn.executemany(
653
+ "INSERT OR IGNORE INTO domain_entities "
654
+ "(repository_id, domain, kind, identity_key, qualified_name,"
655
+ " name, catalog, namespace, environment, attributes)"
656
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
657
+ [
658
+ (
659
+ repository_id,
660
+ items[k].identity.domain,
661
+ str(items[k].kind),
662
+ k,
663
+ items[k].identity.qualified_name,
664
+ items[k].name,
665
+ items[k].identity.catalog,
666
+ items[k].identity.namespace,
667
+ items[k].identity.environment,
668
+ json.dumps(
669
+ dict(items[k].attributes), sort_keys=True
670
+ ),
671
+ )
672
+ for k in missing
673
+ ],
674
+ )
675
+ refetch = self._conn.execute(
676
+ "SELECT id, identity_key FROM domain_entities"
677
+ " WHERE repository_id = ?",
678
+ (repository_id,),
679
+ ).fetchall()
680
+ ids = {r["identity_key"]: int(r["id"]) for r in refetch}
681
+ return ids
682
+
683
+ def insert_domain_occurrences_batch(
684
+ self, rows: list[tuple[object, ...]]
685
+ ) -> list[int]:
686
+ """Batch de ocorrências de domínio — retorna ids na ordem.
687
+
688
+ ``rows``: (snapshot_id, entity_id, source_file_id, span_start,
689
+ span_end, line, col, content_hash, attributes_json,
690
+ extraction_status).
691
+ """
692
+ return self._insert_returning(
693
+ "INSERT INTO domain_entity_occurrences (snapshot_id, entity_id,"
694
+ " source_file_id, span_start, span_end, line, col, content_hash,"
695
+ " attributes, extraction_status) VALUES ",
696
+ rows, 10,
697
+ )
698
+
699
+ def insert_domain_relations(self, rows: Iterable[tuple[object, ...]]) -> int:
700
+ """Batch de relações de domínio — executemany.
701
+
702
+ ``rows``: (snapshot_id, from_entity_occ_id, from_symbol_occ_id,
703
+ to_entity_occ_id, to_symbol_occ_id, to_unresolved, type,
704
+ resolution_status, confidence_class, resolver, evidence_ids).
705
+ """
706
+ self._conn.executemany(
707
+ "INSERT INTO domain_relations (snapshot_id, from_entity_occ_id,"
708
+ " from_symbol_occ_id, to_entity_occ_id, to_symbol_occ_id,"
709
+ " to_unresolved, type, resolution_status, confidence_class,"
710
+ " resolver, evidence_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
711
+ list(rows),
712
+ )
713
+ return self._conn.total_changes
714
+
715
+ # --- domain_fts (lf-0133, schema v12) ---------------------------------
716
+
717
+ def domain_fts_available(self) -> bool:
718
+ """``domain_fts`` presente **e** build com FTS5 — gate de escrita.
719
+
720
+ Bancos migrados por uma build com FTS5 podem ser abertos por uma
721
+ sem o módulo: o INSERT na vtable falharia. A checagem dupla
722
+ (tabela + probe de capacidade) é a condição honesta de escrita.
723
+ """
724
+ if not self._has_table("domain_fts"):
725
+ return False
726
+ from code_atlas.storage.db import fts5_available
727
+
728
+ return fts5_available(self._conn)
729
+
730
+ def insert_domain_fts_batch(
731
+ self, rows: Iterable[tuple[object, ...]]
732
+ ) -> None:
733
+ """FTS de domínio em lote — rowid = id de ``domain_entity_occurrences``.
734
+
735
+ ``rows``: (occ_id, snapshot_id, entity_id, name, qualified_name,
736
+ terms, path, domain, kind) — campos já normalizados pelo caller
737
+ (``terms`` sai de ``search.tokenizer.index_terms``).
738
+ """
739
+ self._conn.executemany(
740
+ "INSERT INTO domain_fts (rowid, snapshot_id, entity_id, name,"
741
+ " qualified_name, terms, path, domain, kind)"
742
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
743
+ list(rows),
744
+ )
745
+
746
+ def rebuild_domain_fts(self, snapshot_id: int) -> int:
747
+ """Backfill in-place de ``domain_fts`` para um snapshot existente.
748
+
749
+ lf-0133: snapshots persistidos antes da v12 não têm rows de FTS
750
+ e a faceta cai no LIKE bounded (declarado). Este é o caminho de
751
+ reconstrução — o equivalente programático de um ``code-atlas
752
+ reindex``: lê o join ocorrência↔entidade↔arquivo e regrava o
753
+ índice do snapshot com os mesmos campos do persist (``terms``
754
+ pré-tokenizado em Python — não é expressível em SQL puro).
755
+ Idempotente dentro da transação do caller (delete + insert do
756
+ mesmo escopo). Retorna o número de rows regravadas; 0 quando a
757
+ tabela não existe ou o snapshot não tem ocorrências de domínio.
758
+ """
759
+ if not self.domain_fts_available():
760
+ return 0
761
+ rows = self._conn.execute(
762
+ "SELECT deo.id AS occ_id, deo.snapshot_id, deo.entity_id,"
763
+ " de.name, de.qualified_name, de.domain, de.kind,"
764
+ " sf.relative_path AS path"
765
+ " FROM domain_entity_occurrences deo"
766
+ " JOIN domain_entities de ON de.id = deo.entity_id"
767
+ " LEFT JOIN source_files sf ON sf.id = deo.source_file_id"
768
+ " WHERE deo.snapshot_id = ? ORDER BY deo.id",
769
+ (snapshot_id,),
770
+ ).fetchall()
771
+ self._conn.execute(
772
+ "DELETE FROM domain_fts WHERE snapshot_id = ?",
773
+ (snapshot_id,),
774
+ )
775
+ if not rows:
776
+ return 0
777
+ # import tardio: storage não pode puxar code_atlas.search em
778
+ # nível de módulo (search/__init__ → engine → domain → store).
779
+ from code_atlas.search.tokenizer import index_terms
780
+
781
+ fts_rows = [
782
+ (
783
+ int(r["occ_id"]), int(r["snapshot_id"]), int(r["entity_id"]),
784
+ str(r["name"] or ""), str(r["qualified_name"]),
785
+ index_terms(
786
+ str(r["name"] or ""), str(r["qualified_name"]),
787
+ str(r["path"] or ""),
788
+ ),
789
+ str(r["path"] or ""), str(r["domain"]), str(r["kind"]),
790
+ )
791
+ for r in rows
792
+ ]
793
+ self.insert_domain_fts_batch(fts_rows)
794
+ return len(fts_rows)
795
+
796
+ def snapshot_domain_entities(
797
+ self, snapshot_id: int
798
+ ) -> list[tuple[str, str, str, str, str | None]]:
799
+ """Entidades com ocorrência no snapshot.
800
+
801
+ Retorna ``(domain, kind, qualified_name, identity_key, path)`` —
802
+ insumo de inspeção/testes e da resolução cross-domain (lf-0117).
803
+ """
804
+ rows = self._conn.execute(
805
+ """
806
+ SELECT de.domain, de.kind, de.qualified_name, de.identity_key,
807
+ sf.relative_path AS path
808
+ FROM domain_entity_occurrences deo
809
+ JOIN domain_entities de ON de.id = deo.entity_id
810
+ LEFT JOIN source_files sf ON sf.id = deo.source_file_id
811
+ WHERE deo.snapshot_id = ?
812
+ ORDER BY de.domain, de.kind, de.qualified_name
813
+ """,
814
+ (snapshot_id,),
815
+ ).fetchall()
816
+ return [
817
+ (r["domain"], r["kind"], r["qualified_name"], r["identity_key"],
818
+ r["path"])
819
+ for r in rows
820
+ ]
821
+
822
+ def domain_entity_id_for(
823
+ self, repository_id: int, identity_key: str
824
+ ) -> int | None:
825
+ """Row id de ``domain_entities`` por chave lógica canônica."""
826
+ row = self._conn.execute(
827
+ "SELECT id FROM domain_entities"
828
+ " WHERE repository_id = ? AND identity_key = ?",
829
+ (repository_id, identity_key),
830
+ ).fetchone()
831
+ return int(row["id"]) if row else None
832
+
833
+ def domain_occurrences_for(
834
+ self, snapshot_id: int, entity_id: int
835
+ ) -> list[int]:
836
+ """Ids de ocorrências de uma entidade num snapshot."""
837
+ rows = self._conn.execute(
838
+ "SELECT id FROM domain_entity_occurrences"
839
+ " WHERE snapshot_id = ? AND entity_id = ? ORDER BY id",
840
+ (snapshot_id, entity_id),
841
+ ).fetchall()
842
+ return [int(r["id"]) for r in rows]
843
+
844
+ def symbol_occurrences_for_name(
845
+ self, snapshot_id: int, qualified_name: str
846
+ ) -> list[int]:
847
+ """Ocorrências de código num snapshot por qualified_name.
848
+
849
+ Ponte código↔domínio (lf-0117/0120): endpoints ``*_symbol_occ_id``
850
+ de ``domain_relations`` referenciam estas rows. Múltiplos matches
851
+ (mesmo qname, kinds distintos) são retornados todos — o caller
852
+ decide entre resolved único / ambiguous honesto.
853
+ """
854
+ rows = self._conn.execute(
855
+ """
856
+ SELECT so.id FROM symbol_occurrences so
857
+ JOIN symbols s ON s.id = so.logical_symbol_id
858
+ WHERE so.snapshot_id = ? AND s.qualified_name = ?
859
+ ORDER BY so.id
860
+ """,
861
+ (snapshot_id, qualified_name),
862
+ ).fetchall()
863
+ return [int(r["id"]) for r in rows]
864
+
865
+ def cross_snapshot_domain_relations(self, snapshot_id: int) -> list[int]:
866
+ """Relações de domínio com endpoint fora do próprio snapshot.
867
+
868
+ Espelha ``cross_snapshot_relations``: endpoints nullable recebem
869
+ LEFT JOIN e só contam quando presentes.
870
+ """
871
+ rows = self._conn.execute(
872
+ """
873
+ SELECT r.id FROM domain_relations r
874
+ LEFT JOIN domain_entity_occurrences fe
875
+ ON fe.id = r.from_entity_occ_id
876
+ LEFT JOIN symbol_occurrences fs
877
+ ON fs.id = r.from_symbol_occ_id
878
+ LEFT JOIN domain_entity_occurrences te
879
+ ON te.id = r.to_entity_occ_id
880
+ LEFT JOIN symbol_occurrences ts
881
+ ON ts.id = r.to_symbol_occ_id
882
+ WHERE r.snapshot_id = ?
883
+ AND ((r.from_entity_occ_id IS NOT NULL AND fe.snapshot_id != ?)
884
+ OR (r.from_symbol_occ_id IS NOT NULL AND fs.snapshot_id != ?)
885
+ OR (r.to_entity_occ_id IS NOT NULL AND te.snapshot_id != ?)
886
+ OR (r.to_symbol_occ_id IS NOT NULL AND ts.snapshot_id != ?))
887
+ """,
888
+ (snapshot_id,) * 5,
889
+ ).fetchall()
890
+ return [int(r["id"]) for r in rows]
891
+
892
+ # --- resolução cross-domain (lf-0117, Phase 9) ---------------------
893
+
894
+ def domain_occurrences_snapshot(
895
+ self, snapshot_id: int
896
+ ) -> list[sqlite3.Row]:
897
+ """Ocorrências de domínio do snapshot com a entidade lógica.
898
+
899
+ Junta ``domain_entity_occurrences`` + ``domain_entities`` +
900
+ path do arquivo-fonte — a visão completa que a resolução
901
+ cross-domain precisa (identidade canônica vem da
902
+ ``identity_key``; ``root`` só existe lá dentro).
903
+ """
904
+ return self._conn.execute(
905
+ """
906
+ SELECT deo.id AS occ_id, deo.entity_id, deo.source_file_id,
907
+ deo.span_start, deo.span_end, deo.extraction_status,
908
+ de.domain, de.kind, de.identity_key, de.qualified_name,
909
+ de.name, de.catalog, de.namespace, de.environment,
910
+ de.attributes,
911
+ sf.relative_path AS source_path
912
+ FROM domain_entity_occurrences deo
913
+ JOIN domain_entities de ON de.id = deo.entity_id
914
+ LEFT JOIN source_files sf ON sf.id = deo.source_file_id
915
+ WHERE deo.snapshot_id = ?
916
+ ORDER BY deo.id
917
+ """,
918
+ (snapshot_id,),
919
+ ).fetchall()
920
+
921
+ def domain_relations_snapshot(
922
+ self, snapshot_id: int
923
+ ) -> list[sqlite3.Row]:
924
+ """Todas as rows de ``domain_relations`` do snapshot (ordem de id).
925
+
926
+ O resolver lê o conjunto inteiro — re-resolve só as
927
+ ``unresolved``, mas precisa das demais para dedup de arestas
928
+ novas e para idempotência (``resolver='cross-domain'``).
929
+ """
930
+ return self._conn.execute(
931
+ """
932
+ SELECT id, type, from_entity_occ_id, from_symbol_occ_id,
933
+ to_entity_occ_id, to_symbol_occ_id, to_unresolved,
934
+ resolution_status, confidence_class, resolver,
935
+ evidence_ids
936
+ FROM domain_relations
937
+ WHERE snapshot_id = ?
938
+ ORDER BY id
939
+ """,
940
+ (snapshot_id,),
941
+ ).fetchall()
942
+
943
+ def evidence_snapshot(self, snapshot_id: int) -> list[sqlite3.Row]:
944
+ """Rows de ``evidence`` do snapshot — insumo de proveniência."""
945
+ return self._conn.execute(
946
+ """
947
+ SELECT id, source_file_id, path, span, extractor, notes
948
+ FROM evidence
949
+ WHERE snapshot_id = ?
950
+ ORDER BY id
951
+ """,
952
+ (snapshot_id,),
953
+ ).fetchall()
954
+
955
+ def file_symbol_occurrences(
956
+ self, snapshot_id: int
957
+ ) -> list[tuple[str, int]]:
958
+ """``(path, occ_id)`` de símbolos FILE com ocorrência no snapshot.
959
+
960
+ Ponte domínio→código (lf-0117): ``qualified_name`` de nó FILE é
961
+ o path relativo — alvo honesto para refs textuais que são paths
962
+ inventariados (artefato de CI, script de job).
963
+ """
964
+ rows = self._conn.execute(
965
+ """
966
+ SELECT s.qualified_name AS path, so.id AS occ_id
967
+ FROM symbol_occurrences so
968
+ JOIN symbols s ON s.id = so.logical_symbol_id
969
+ WHERE so.snapshot_id = ? AND s.kind = 'file'
970
+ ORDER BY s.qualified_name, so.id
971
+ """,
972
+ (snapshot_id,),
973
+ ).fetchall()
974
+ return [(str(r["path"]), int(r["occ_id"])) for r in rows]
975
+
976
+ def persist_cross_resolutions(
977
+ self,
978
+ snapshot_id: int,
979
+ updates: Iterable[tuple[object, ...]],
980
+ rows: Iterable[tuple[object, ...]],
981
+ ) -> dict[str, int]:
982
+ """Aplica o plano do resolver cross-domain — transação do caller.
983
+
984
+ ``updates``: (to_entity_occ, to_symbol_occ, to_unresolved,
985
+ resolution_status, confidence_class, resolver, evidence_ids_json,
986
+ relation_id) — re-resolução in place de rows ``unresolved``;
987
+ ``resolver`` marca a proveniência da decisão (``cross-domain``).
988
+ O ``WHERE`` exige ``snapshot_id`` — isolamento por construção,
989
+ nenhuma row de outro snapshot pode ser tocada.
990
+
991
+ ``rows``: mesmo shape de ``insert_domain_relations`` — arestas
992
+ novas entre ocorrências do snapshot. Retorna contagens.
993
+ """
994
+ upd = list(updates)
995
+ ins = list(rows)
996
+ self._conn.executemany(
997
+ "UPDATE domain_relations"
998
+ " SET to_entity_occ_id = ?, to_symbol_occ_id = ?,"
999
+ " to_unresolved = ?, resolution_status = ?,"
1000
+ " confidence_class = ?, resolver = ?, evidence_ids = ?"
1001
+ " WHERE id = ? AND snapshot_id = ?",
1002
+ [(*u, snapshot_id) for u in upd],
1003
+ )
1004
+ self._conn.executemany(
1005
+ "INSERT INTO domain_relations (snapshot_id, from_entity_occ_id,"
1006
+ " from_symbol_occ_id, to_entity_occ_id, to_symbol_occ_id,"
1007
+ " to_unresolved, type, resolution_status, confidence_class,"
1008
+ " resolver, evidence_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1009
+ ins,
1010
+ )
1011
+ return {"cross_updated": len(upd), "cross_inserted": len(ins)}
1012
+
1013
+ # --- index jobs ---------------------------------------------------
1014
+
1015
+ def insert_index_job(self, repository_id: int, manifest_hash: str) -> int:
1016
+ cur = self._conn.execute(
1017
+ "INSERT INTO index_jobs (repository_id, status, manifest_hash) "
1018
+ "VALUES (?, 'running', ?)",
1019
+ (repository_id, manifest_hash),
1020
+ )
1021
+ return int(cur.lastrowid) # type: ignore[arg-type]
1022
+
1023
+ def finish_index_job(
1024
+ self,
1025
+ job_id: int,
1026
+ status: str,
1027
+ snapshot_id: int | None = None,
1028
+ counts: dict[str, int] | None = None,
1029
+ failures: tuple[str, ...] = (),
1030
+ ) -> None:
1031
+ self._conn.execute(
1032
+ "UPDATE index_jobs SET status = ?, snapshot_id = ?, counts = ?, "
1033
+ "failures = ?, finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') "
1034
+ "WHERE id = ?",
1035
+ (
1036
+ status,
1037
+ snapshot_id,
1038
+ json.dumps(counts or {}, sort_keys=True),
1039
+ json.dumps(list(failures)),
1040
+ job_id,
1041
+ ),
1042
+ )
1043
+
1044
+ def last_warnings_digest(self, warnings: Iterable[WarningKind]) -> str:
1045
+ return json.dumps(sorted({str(w) for w in warnings}))