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,1032 @@
1
+ """IndexCoordinator: scan → parse → graph → BUILDING → validação → READY.
2
+
3
+ Pipeline completo do Cycle 2 (Blueprint §6): Discovery → Manifest →
4
+ Parser Registry → Símbolos → Graph Builder → Validação → READY.
5
+
6
+ Regras:
7
+
8
+ - Inventário idêntico (mesmo ``worktree_digest`` + mesmas versões de
9
+ analisadores **incluindo parser/grammar**) é **no-op** — mudança de
10
+ versão de extrator derrota o no-op por design.
11
+ - Parsing e construção do grafo acontecem **fora** do ``BEGIN
12
+ IMMEDIATE`` — lock de escrita fica curto.
13
+ - Falha dentro da transação faz rollback completo; o snapshot é marcado
14
+ ``FAILED`` em transação separada para diagnóstico e o ``READY`` anterior
15
+ permanece intacto.
16
+ - Falha de parse por arquivo vira ``files_failed`` + failures no job e
17
+ evidence com notes — o snapshot pode ser READY com perda explícita e
18
+ auditável. Falha fatal (engine/grafo inválido) → snapshot FAILED.
19
+ - Escritor único via ``BEGIN IMMEDIATE``; leitores concorrentes sob WAL.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextlib
25
+ import dataclasses
26
+ import hashlib
27
+ import sqlite3
28
+ import time
29
+ from collections.abc import Callable, Mapping, Sequence
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any, TypeGuard
33
+
34
+ from code_atlas.contracts.enums import SnapshotStatus
35
+ from code_atlas.contracts.models import Snapshot
36
+ from code_atlas.discovery.identity import identity_key
37
+ from code_atlas.discovery.models import ScanResult
38
+ from code_atlas.discovery.scanner import read_file_verified
39
+ from code_atlas.discovery.scanner import scan as default_scan
40
+ from code_atlas.domains.contracts import (
41
+ DiscoveredArtifact,
42
+ DomainAdapter,
43
+ DomainExtractionRequest,
44
+ DomainExtractionResult,
45
+ )
46
+ from code_atlas.domains.crossresolve import CrossDomainResolver
47
+ from code_atlas.domains.registry import DomainRegistry
48
+ from code_atlas.domains.validation import validate_extraction
49
+ from code_atlas.graph.builder import GraphBuilder
50
+ from code_atlas.graph.validation import validate_graph
51
+ from code_atlas.indexing.cache import CacheKey, ParseResultCache
52
+ from code_atlas.indexing.change_detector import detect_changes
53
+ from code_atlas.indexing.dependency_planner import (
54
+ IndexPlan,
55
+ InvalidationPlanner,
56
+ PlanAction,
57
+ PlannedFile,
58
+ )
59
+ from code_atlas.indexing.persist import persist_domain_result, persist_graph
60
+ from code_atlas.indexing.reuse import cache_key_for, config_digest
61
+ from code_atlas.parsers.contracts import (
62
+ GraphBuildResult,
63
+ ParseDiagnostic,
64
+ ParseResult,
65
+ )
66
+ from code_atlas.parsers.engine import ParseEngine
67
+ from code_atlas.resolvers.bootstrap import ResolverFactory
68
+ from code_atlas.resolvers.contracts import SemanticResolver
69
+ from code_atlas.resolvers.coordinator import ResolverCoordinator
70
+ from code_atlas.resolvers.enrichment import SemanticEnricher
71
+ from code_atlas.resolvers.registry import ResolverRegistry
72
+ from code_atlas.resolvers.syntactic.resolver import (
73
+ RESOLVER_ID as _SYN_RESOLVER_ID,
74
+ )
75
+ from code_atlas.resolvers.syntactic.resolver import (
76
+ RESOLVER_VERSION as _SYN_RESOLVER_VERSION,
77
+ )
78
+ from code_atlas.storage.db import fts5_available
79
+ from code_atlas.storage.store import Store
80
+
81
+ ANALYZER_NAME = "code-atlas"
82
+ ANALYZER_VERSION = "0.1.0"
83
+
84
+ # lf-0141 (Cycle 8 Phase 15): callback de progresso local — a CLI o
85
+ # conecta a stderr com ``--verbose``; ``None`` (default) = zero custo.
86
+ # Eventos: ``"stage"`` (``stage``, ``ms`` — um por estágio medido,
87
+ # mesmos nomes das chaves ``stage_ms_*`` do job), ``"parse-progress"``
88
+ # (``parsed``, ``total``, ``files_per_sec`` — a cada 64 arquivos e ao
89
+ # fim da fase) e ``"noop"`` (``snapshot_id``). Nenhum evento carrega
90
+ # paths nem dados indexados — só contadores e tempos.
91
+ ProgressFn = Callable[[str, Mapping[str, Any]], None]
92
+
93
+
94
+ def _emit_progress(progress: ProgressFn | None, event: str,
95
+ **fields: Any) -> None:
96
+ """Emite um evento de progresso quando o caller registra callback."""
97
+ if progress is not None:
98
+ progress(event, fields)
99
+
100
+
101
+ def _files_per_sec(done: int, t0: float) -> float:
102
+ """Throughput da fase de parse; com elapsed abaixo de um tick do
103
+ clock (resolução ~15.6ms no Windows) usa a resolução como piso —
104
+ bound inferior honesto, nunca 0.0 inventado."""
105
+ el = time.monotonic() - t0
106
+ eff = el if el > 0 else time.get_clock_info("monotonic").resolution
107
+ return round(done / eff, 1)
108
+
109
+
110
+ def _parse_tick(progress: ProgressFn | None, done: int, total: int,
111
+ t0: float) -> None:
112
+ """Tick periódico do parse — a cada 64 arquivos, sem custo default."""
113
+ if progress is not None and done % 64 == 0:
114
+ _emit_progress(progress, "parse-progress", parsed=done,
115
+ total=total, files_per_sec=_files_per_sec(done, t0))
116
+
117
+
118
+ def _parse_tick_final(progress: ProgressFn | None, total: int,
119
+ t0: float) -> None:
120
+ """Tick final do parse — fecha sempre em ``parsed == total``."""
121
+ if progress is not None and total:
122
+ _emit_progress(progress, "parse-progress", parsed=total,
123
+ total=total, files_per_sec=_files_per_sec(total, t0))
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class IndexOutcome:
128
+ """Resultado de uma tentativa de indexação."""
129
+
130
+ snapshot_id: int | None
131
+ job_id: int | None # None quando nem o job pôde ser registrado
132
+ status: str # success | failed | noop
133
+ files_indexed: int
134
+ noop: bool = False
135
+ error: str = ""
136
+ secrets_skipped: int = 0
137
+ warnings: tuple[str, ...] = ()
138
+ stats: dict[str, int] = field(default_factory=dict)
139
+
140
+
141
+ class IndexCoordinator:
142
+ """Coordena indexação sobre uma conexão sqlite3 migrada."""
143
+
144
+ def __init__(
145
+ self,
146
+ conn: sqlite3.Connection,
147
+ scanner: Callable[[Path], ScanResult] = default_scan,
148
+ engine: ParseEngine | None = None,
149
+ builder: GraphBuilder | None = None,
150
+ resolver_factories: Sequence[ResolverFactory] = (),
151
+ resolver_versions: Mapping[str, str] | None = None,
152
+ snapshot_retention: int = 0,
153
+ domain_registry: DomainRegistry | None = None,
154
+ cross_resolver: CrossDomainResolver | None = None,
155
+ ) -> None:
156
+ self._conn = conn
157
+ self._store = Store(conn)
158
+ self._scanner = scanner
159
+ self._engine = engine or ParseEngine()
160
+ self._builder = builder or GraphBuilder()
161
+ self._planner = InvalidationPlanner()
162
+ self._resolver_factories = tuple(resolver_factories)
163
+ self._resolver_versions = dict(resolver_versions or {})
164
+ # lf-0031: 0 = retenção ilimitada (histórico completo); >0 =
165
+ # máximo de STALE mantidos, podados na transação de publicação.
166
+ self._snapshot_retention = snapshot_retention
167
+ # lf-0111 (Cycle 7 Ph.3): domínio é OPT-IN — ``None`` preserva o
168
+ # comportamento anterior exatamente (zero trabalho de domínio).
169
+ # Um registry injetado roda os adapters habilitados durante o
170
+ # build e persiste via ``persist_domain_result`` na mesma
171
+ # transação de publicação do grafo de código.
172
+ self._domain_registry = domain_registry
173
+ # lf-0117 (Cycle 7 Ph.9): resolução cross-domain também é OPT-IN
174
+ # — ``None`` (default) = zero trabalho. Com um resolver injetado,
175
+ # após a persistência dos adapters o coordinator roda
176
+ # ``resolver.plan`` + ``persist_cross_resolutions`` dentro do
177
+ # mesmo BEGIN de publicação. Só faz sentido com domain_registry;
178
+ # sem adapters não há rows de domínio para ligar — o plano sai
179
+ # vazio (documentado, não erro).
180
+ self._cross_resolver = cross_resolver
181
+ self.last_plan: IndexPlan | None = None # plano do último index
182
+
183
+ def _analyzer_versions(self, scan: ScanResult) -> dict[str, str]:
184
+ """Versões que compõem a identidade do índice.
185
+
186
+ Inclui parser+grammar por linguagem registrada — manifest
187
+ idêntico com extrator novo **não** é no-op (lf-0011).
188
+ """
189
+ versions = {
190
+ "scanner": f"{ANALYZER_NAME}-scanner/{ANALYZER_VERSION}",
191
+ "scan_mode": str(scan.mode),
192
+ }
193
+ reg = self._engine.registry
194
+ for lang in reg.languages:
195
+ adapter = reg.for_language(lang)
196
+ if adapter is not None:
197
+ versions[f"parser:{lang}"] = (
198
+ f"{adapter.adapter_id}/{adapter.parser_version}"
199
+ f"+{adapter.grammar_version}"
200
+ )
201
+ # O SyntacticResolver é fixo no builder — sua versão define a
202
+ # extração de relações e deve invalidar o índice (lf-0023).
203
+ versions[f"resolver:{_SYN_RESOLVER_ID}"] = _SYN_RESOLVER_VERSION
204
+ # Resolvers opt-in ativos mudam o resultado da resolução —
205
+ # entrar/sair da config invalida relações (lf-0027).
206
+ if self._resolver_factories:
207
+ versions["resolver:external"] = "enabled/1"
208
+ # identidade dos resolvers externos (lf-0030): índice SCIP novo
209
+ # → sha muda → invalidação completa, conservadora e correta.
210
+ versions.update(self._resolver_versions)
211
+ # Domínio opt-in (lf-0111): adapter registrado muda o resultado
212
+ # do índice — habilitar/desabilitar/atualizar derrota o no-op
213
+ # por design, exatamente como um parser novo.
214
+ if self._domain_registry is not None:
215
+ for dom in self._domain_registry.enabled():
216
+ versions[f"domain:{dom.domain_id}"] = (
217
+ dom.adapter_version
218
+ )
219
+ # Cross-resolution opt-in (lf-0117): ligar/desligar ou trocar a
220
+ # versão muda as rows de domínio — derrota o no-op por design.
221
+ if self._cross_resolver is not None:
222
+ versions["resolver:cross-domain"] = (
223
+ self._cross_resolver.resolver_version
224
+ )
225
+ return versions
226
+
227
+ def _enrich_semantic(
228
+ self, graph: GraphBuildResult
229
+ ) -> tuple[GraphBuildResult, dict[str, int]]:
230
+ """Etapa de enriquecimento semântico (lf-0027): resolvers opt-in
231
+ (factories injetadas) re-resolvem arestas não-resolvidas via
232
+ ``ResolverCoordinator`` por tier de evidência. Falha de resolver
233
+ vira diagnóstico no grafo — o índice nunca para por resolver."""
234
+ if not self._resolver_factories:
235
+ return graph, {
236
+ "semantic_attempted": 0,
237
+ "semantic_enriched": 0,
238
+ "semantic_diagnostics": 0,
239
+ }
240
+ registry = ResolverRegistry()
241
+ resolvers: list[SemanticResolver] = []
242
+ diags: list[ParseDiagnostic] = []
243
+ for factory in self._resolver_factories:
244
+ try:
245
+ resolvers.append(factory(self._builder.universe))
246
+ except Exception as exc:
247
+ diags.append(
248
+ ParseDiagnostic(
249
+ code="semantic:bootstrap:resolver-error",
250
+ severity="warning",
251
+ message=f"resolver factory failed: {exc}",
252
+ )
253
+ )
254
+ for r in resolvers:
255
+ registry.register(r)
256
+ try:
257
+ enriched, stats = SemanticEnricher(
258
+ ResolverCoordinator(registry)
259
+ ).enrich(graph)
260
+ finally:
261
+ # LSP e outros resolvers com lifecycle fecham ao fim do
262
+ # enriquecimento — nenhum processo sobrevive ao index().
263
+ for r in resolvers:
264
+ closer = getattr(r, "close", None)
265
+ if callable(closer):
266
+ with contextlib.suppress(Exception):
267
+ closer()
268
+ if diags:
269
+ enriched = dataclasses.replace(
270
+ enriched,
271
+ diagnostics=tuple(diags) + enriched.diagnostics,
272
+ )
273
+ return enriched, stats
274
+
275
+ def _extract_domains(
276
+ self,
277
+ scan: ScanResult,
278
+ repository: str,
279
+ digest: str,
280
+ ) -> tuple[
281
+ list[tuple[DomainAdapter, DomainExtractionResult]], list[str]
282
+ ]:
283
+ """Fase de domínio opt-in (lf-0111, Cycle 7 — ADR-0010).
284
+
285
+ Só roda quando o caller injeta um ``DomainRegistry`` — default
286
+ ``None`` preserva o comportamento anterior byte a byte. Para
287
+ cada adapter **habilitado**, artefatos do scan que casam com
288
+ ``supported_artifacts`` viram um ``DomainExtractionRequest``
289
+ (path + sha do inventário — o adapter relê e confere). Roda
290
+ **fora** do lock de escrita, como o parse.
291
+
292
+ Disciplina de falha espelhando resolvers: crash de adapter ou
293
+ resultado que viola ``validate_extraction`` vira failure string
294
+ auditável e o adapter é pulado — nunca derruba o snapshot.
295
+ Diagnósticos ``error`` do próprio adapter também viram failures.
296
+ """
297
+ out: list[tuple[DomainAdapter, DomainExtractionResult]] = []
298
+ failures: list[str] = []
299
+ if self._domain_registry is None:
300
+ return out, failures
301
+ sha = {f.path: (f.sha256 or "") for f in scan.files}
302
+ for adapter in self._domain_registry.enabled():
303
+ support = adapter.supported_artifacts()
304
+ paths = sorted(p for p in sha if support.matches(p))
305
+ if not paths:
306
+ continue
307
+ request = DomainExtractionRequest(
308
+ repository=repository,
309
+ artifacts=tuple(
310
+ DiscoveredArtifact(path=p, sha256=sha[p])
311
+ for p in paths
312
+ ),
313
+ snapshot=digest,
314
+ )
315
+ try:
316
+ result = adapter.extract(request)
317
+ except Exception as exc:
318
+ failures.append(
319
+ f"domain:{adapter.domain_id}:extract-crash:{exc}"
320
+ )
321
+ continue
322
+ violations = validate_extraction(adapter, request, result)
323
+ if violations:
324
+ failures.append(
325
+ f"domain:{adapter.domain_id}:contract-violation:"
326
+ f"{violations[0]}"
327
+ )
328
+ continue
329
+ for d in result.diagnostics:
330
+ if d.severity == "error":
331
+ failures.append(
332
+ f"{d.path or adapter.domain_id}:domain-{d.code}"
333
+ )
334
+ out.append((adapter, result))
335
+ return out, failures
336
+
337
+ def index(
338
+ self, root: Path, force: bool = False, incremental: bool = False,
339
+ progress: ProgressFn | None = None,
340
+ ) -> IndexOutcome:
341
+ """Escaneia `root` e publica snapshot READY (ou no-op) atomicamente.
342
+
343
+ ``force=True`` ignora o atalho de no-op por digest idêntico.
344
+ Um scan parcial/truncado nunca vira READY — é registrado como falha.
345
+
346
+ ``incremental=True`` (lf-0019) difa o manifesto contra o último
347
+ READY e produz um ``IndexPlan`` inspecionável. Arquivos ``REUSE``
348
+ são servidos do cache versionado de ``ParseResult`` (lf-0020) —
349
+ hit pula leitura e tree-sitter; miss cai para parse real.
350
+ ``REPARSE``/``REVALIDATE`` sempre re-parseiam. O grafo é montado
351
+ com resultados de todos os arquivos e persistido inteiro no
352
+ snapshot novo (isolamento lógico). ``force`` desativa o caminho
353
+ incremental.
354
+
355
+ ``progress`` (lf-0141): callback opcional de telemetria local —
356
+ eventos ``"stage"``/``"parse-progress"``/``"noop"`` documentados
357
+ em ``ProgressFn``. Default ``None`` preserva o comportamento
358
+ anterior com custo zero.
359
+ """
360
+ stage_ms: dict[str, int] = {}
361
+ t_scan = time.monotonic()
362
+ scan = self._scanner(Path(root))
363
+ digest = scan.manifest_sha256()
364
+ versions = self._analyzer_versions(scan)
365
+ store = self._store
366
+ stage_ms["discovery"] = int(
367
+ (time.monotonic() - t_scan) * 1000)
368
+ stage_ms["hashing"] = scan.hash_ms
369
+ _emit_progress(progress, "stage", stage="discovery",
370
+ ms=stage_ms["discovery"], files=len(scan.files),
371
+ secrets=scan.secret_count)
372
+ _emit_progress(progress, "stage", stage="hashing",
373
+ ms=stage_ms["hashing"])
374
+
375
+ # Job registrado em transação própria para sobreviver a rollback —
376
+ # falhas precisam ficar inspecionáveis. Sob lock de escrita preso,
377
+ # até esta escrita mínima falha → outcome failed honesto (lf-0015).
378
+ try:
379
+ with self._conn:
380
+ # lf-0021: identidade lógica (git remote/raiz/path) —
381
+ # realocação do diretório mantém o mesmo repo_id.
382
+ ikey = identity_key(scan.git, Path(root))
383
+ repo_id = store.get_or_create_repository(
384
+ ikey, str(Path(root).resolve()))
385
+ job_id = store.insert_index_job(repo_id, digest)
386
+ except sqlite3.OperationalError as exc:
387
+ return IndexOutcome(
388
+ snapshot_id=None, job_id=None, status="failed",
389
+ files_indexed=len(scan.files), error=str(exc),
390
+ secrets_skipped=scan.secret_count,
391
+ )
392
+
393
+ if scan.partial or scan.truncated:
394
+ error = "scan incomplete (partial or truncated; not publishing READY)"
395
+ snap_id = self._mark_failed(repo_id, job_id, error)
396
+ return IndexOutcome(
397
+ snapshot_id=snap_id, job_id=job_id, status="failed",
398
+ files_indexed=len(scan.files), error=error,
399
+ secrets_skipped=scan.secret_count,
400
+ warnings=tuple(str(w.kind) for w in scan.warnings),
401
+ )
402
+
403
+ # Fast-path noop ANTES de parsear (Blueprint §6: no-op não refaz
404
+ # parse). A checagem se repete dentro do lock para cobrir corrida
405
+ # com outro escritor.
406
+ current = store.latest_ready_snapshot(repo_id)
407
+ if not force and self._is_unchanged(current, digest, versions):
408
+ with self._conn:
409
+ store.finish_index_job(
410
+ job_id, "noop", snapshot_id=current.id,
411
+ counts={"files": 0}, failures=(),
412
+ )
413
+ _emit_progress(progress, "noop", snapshot_id=current.id)
414
+ return self._noop_outcome(current.id, job_id)
415
+
416
+ # Planejamento incremental ANTES de parsear (lf-0019): o plano é
417
+ # produzido e inspecionável antes de qualquer trabalho. Com o
418
+ # cache de ParseResult (lf-0020), ``REUSE``/``REVALIDATE`` tentam
419
+ # o cache primeiro — miss cai para leitura+parse reais. O grafo
420
+ # é construído com resultados de todos os arquivos e as arestas
421
+ # são re-resolvidas sobre o universo novo — relações nunca são
422
+ # reutilizadas prontas.
423
+ self.last_plan = None
424
+ plan: IndexPlan | None = None
425
+ reparse_paths: set[str] = set()
426
+ cache_paths: set[str] = set()
427
+ t_plan = time.monotonic()
428
+ if incremental and not force:
429
+ plan, reparse_paths, cache_paths = self._plan_incremental(
430
+ scan, repo_id, versions
431
+ )
432
+ stage_ms["invalidation"] = int(
433
+ (time.monotonic() - t_plan) * 1000)
434
+ _emit_progress(progress, "stage", stage="invalidation",
435
+ ms=stage_ms["invalidation"])
436
+
437
+ cfg_digest = config_digest(self._engine)
438
+ cache = ParseResultCache(self._conn, repo_id)
439
+
440
+ # Fase de análise FORA do lock de escrita: parse + grafo + validação.
441
+ t_parse = time.monotonic()
442
+ results, file_status, read_failures, cache_puts, cache_hits = (
443
+ self._parse_files(
444
+ root, scan, cache, cfg_digest,
445
+ reparse=reparse_paths, cache_candidates=cache_paths,
446
+ incremental=plan is not None,
447
+ timings=stage_ms,
448
+ progress=progress,
449
+ )
450
+ )
451
+ stage_ms["parse"] = int((time.monotonic() - t_parse) * 1000)
452
+ _emit_progress(progress, "stage", stage="parse",
453
+ ms=stage_ms["parse"])
454
+ t_graph = time.monotonic()
455
+ graph = self._builder.build(Path(root).name, scan, results)
456
+ stage_ms["graph_build"] = int(
457
+ (time.monotonic() - t_graph) * 1000)
458
+ _emit_progress(progress, "stage", stage="graph_build",
459
+ ms=stage_ms["graph_build"])
460
+ t_sem = time.monotonic()
461
+ graph, sem_stats = self._enrich_semantic(graph)
462
+ stage_ms["semantic"] = int((time.monotonic() - t_sem) * 1000)
463
+ _emit_progress(progress, "stage", stage="semantic",
464
+ ms=stage_ms["semantic"])
465
+ # Domínio opt-in (lf-0111): extração fora do lock de escrita,
466
+ # mesmo desenho do parse — adapters leem os artefatos do
467
+ # request dentro do workspace; nada é executado.
468
+ t_dom = time.monotonic()
469
+ domain_results, domain_failures = self._extract_domains(
470
+ scan, ikey, digest
471
+ )
472
+ stage_ms["domains"] = int((time.monotonic() - t_dom) * 1000)
473
+ _emit_progress(progress, "stage", stage="domains",
474
+ ms=stage_ms["domains"])
475
+ t_val = time.monotonic()
476
+ g_errors = validate_graph(graph)
477
+ stage_ms["validation"] = int(
478
+ (time.monotonic() - t_val) * 1000)
479
+ _emit_progress(progress, "stage", stage="validation",
480
+ ms=stage_ms["validation"])
481
+ if g_errors:
482
+ # invariante violada = bug/violação interna → FAILED, READY salvo
483
+ error = f"graph integrity violated: {'; '.join(g_errors[:3])}"
484
+ snap_id = self._mark_failed(repo_id, job_id, error)
485
+ return IndexOutcome(
486
+ snapshot_id=snap_id, job_id=job_id, status="failed",
487
+ files_indexed=len(scan.files), error=error,
488
+ secrets_skipped=scan.secret_count,
489
+ )
490
+
491
+ # Escritor único: BEGIN IMMEDIATE serializa com outros escritores.
492
+ # busy_timeout do connect é 5s — persistências grandes podem
493
+ # segurar o lock mais que isso; retry limitado dá ao perdedor uma
494
+ # chance honesta antes de virar FAILED (lf-0015).
495
+ begin_err = self._acquire_write_lock()
496
+ if begin_err is not None:
497
+ snap_id = self._mark_failed(repo_id, job_id, str(begin_err))
498
+ return IndexOutcome(
499
+ snapshot_id=snap_id, job_id=job_id, status="failed",
500
+ files_indexed=len(scan.files), error=str(begin_err),
501
+ secrets_skipped=scan.secret_count,
502
+ )
503
+ t_persist = time.monotonic()
504
+ try:
505
+ current = store.latest_ready_snapshot(repo_id)
506
+
507
+ if not force and self._is_unchanged(current, digest, versions):
508
+ store.finish_index_job(
509
+ job_id, "noop", snapshot_id=current.id,
510
+ counts={"files": 0}, failures=(),
511
+ )
512
+ self._conn.commit()
513
+ _emit_progress(progress, "noop", snapshot_id=current.id)
514
+ return self._noop_outcome(current.id, job_id)
515
+
516
+ snapshot_id = store.insert_snapshot(
517
+ repo_id,
518
+ worktree_digest=digest,
519
+ analyzer_versions=versions,
520
+ parent_snapshot_id=current.id if current else None,
521
+ git_ref=scan.git.branch,
522
+ commit_sha=scan.git.head_sha or None,
523
+ dirty=scan.git.dirty,
524
+ )
525
+ file_ids = store.ensure_source_files(repo_id, scan.files)
526
+ rows = [(file_ids[f.path], f) for f in scan.files]
527
+ store.insert_inventory_rows(snapshot_id, rows)
528
+ t_w = time.monotonic()
529
+ pcounts = persist_graph(
530
+ store, repo_id, snapshot_id, file_ids, graph,
531
+ fts5=fts5_available(self._conn), file_status=file_status,
532
+ )
533
+ # evidência explícita para arquivos que falharam no parse —
534
+ # nenhuma perda silenciosa vira READY
535
+ pcounts["evidence"] += self._record_parse_failures(
536
+ store, snapshot_id, scan, file_ids, read_failures
537
+ )
538
+ # domínio opt-in (lf-0111): mesmo BEGIN, isolamento por
539
+ # snapshot — persist_domain_result é atômico com o snapshot
540
+ # inteiro: uma exceção aqui derruba a publicação como
541
+ # qualquer outra escrita (rollback completo, READY anterior
542
+ # intacto).
543
+ dcounts = _persist_domains(
544
+ store, repo_id, snapshot_id, file_ids,
545
+ domain_results, domain_failures,
546
+ )
547
+ # lf-0117 (Cycle 7 Ph.9): pós-extração opt-in — depois de
548
+ # TODOS os adapters persistirem, dentro da mesma transação.
549
+ self._run_cross_resolution(
550
+ store, snapshot_id, dcounts, domain_failures
551
+ )
552
+ stage_ms["persist_rows"] = int(
553
+ (time.monotonic() - t_w) * 1000)
554
+ _emit_progress(progress, "stage", stage="persist_rows",
555
+ ms=stage_ms["persist_rows"])
556
+ t_fk = time.monotonic()
557
+ self._validate(snapshot_id)
558
+ stage_ms["fk_check"] = int(
559
+ (time.monotonic() - t_fk) * 1000)
560
+ _emit_progress(progress, "stage", stage="fk_check",
561
+ ms=stage_ms["fk_check"])
562
+ t_pub = time.monotonic()
563
+ store.publish_snapshot(repo_id, snapshot_id)
564
+ snaps_pruned = (
565
+ store.gc_stale_snapshots(repo_id, self._snapshot_retention)
566
+ if self._snapshot_retention > 0 else 0
567
+ )
568
+ pruned = self._apply_cache(
569
+ cache, repo_id, snapshot_id, cache_puts, cache_hits
570
+ )
571
+ stage_ms["publish_gc"] = int(
572
+ (time.monotonic() - t_pub) * 1000)
573
+ _emit_progress(progress, "stage", stage="publish_gc",
574
+ ms=stage_ms["publish_gc"])
575
+ counts = {
576
+ "files": len(rows),
577
+ "files_parsed": len(results) - len(cache_hits),
578
+ "files_reused": len(cache_hits),
579
+ "cache_puts": len(cache_puts),
580
+ "cache_pruned": pruned,
581
+ "snapshots_pruned": snaps_pruned,
582
+ "files_failed": sum(
583
+ 1 for x in read_failures
584
+ if not x.endswith(":syntax-errors")
585
+ ),
586
+ "files_with_errors": sum(
587
+ 1 for x in read_failures
588
+ if x.endswith(":syntax-errors")
589
+ ),
590
+ "secrets_skipped": scan.secret_count,
591
+ **sem_stats,
592
+ **pcounts,
593
+ **dcounts,
594
+ }
595
+ stage_ms["persist_publish"] = int(
596
+ (time.monotonic() - t_persist) * 1000)
597
+ _emit_progress(progress, "stage", stage="persist_publish",
598
+ ms=stage_ms["persist_publish"])
599
+ counts.update({f"stage_ms_{k}": v
600
+ for k, v in stage_ms.items()})
601
+ failures = (
602
+ tuple(str(w.kind) for w in scan.warnings)
603
+ + tuple(read_failures)
604
+ + tuple(domain_failures)
605
+ )
606
+ store.finish_index_job(
607
+ job_id, "success", snapshot_id=snapshot_id,
608
+ counts=counts, failures=failures,
609
+ )
610
+ self._conn.commit()
611
+ return IndexOutcome(
612
+ snapshot_id=snapshot_id, job_id=job_id, status="success",
613
+ files_indexed=len(rows), secrets_skipped=scan.secret_count,
614
+ warnings=tuple(str(w.kind) for w in scan.warnings),
615
+ stats=counts,
616
+ )
617
+ except Exception as exc:
618
+ self._conn.rollback()
619
+ failed_snap = self._mark_failed(repo_id=repo_id, job_id=job_id, error=str(exc))
620
+ return IndexOutcome(
621
+ snapshot_id=failed_snap, job_id=job_id, status="failed",
622
+ files_indexed=0, error=str(exc),
623
+ )
624
+
625
+ @staticmethod
626
+ def _apply_cache(
627
+ cache: ParseResultCache,
628
+ repo_id: int,
629
+ snapshot_id: int,
630
+ puts: dict[str, tuple[CacheKey, ParseResult]],
631
+ hits: dict[str, CacheKey],
632
+ ) -> int:
633
+ """Cache versionado (lf-0020) na transação de publicação.
634
+
635
+ Registra resultados novos e marca os hits — rollback de build
636
+ falho não deixa entradas órfãs. GC roda após o publish (o
637
+ snapshot novo já conta como READY mais recente).
638
+ """
639
+ for key, res in puts.values():
640
+ cache.put(key, res, snapshot_id=snapshot_id)
641
+ for key in hits.values():
642
+ cache.mark_used(key, snapshot_id)
643
+ return cache.gc(repo_id)
644
+
645
+ def plan_dry_run(
646
+ self, root: Path
647
+ ) -> tuple[IndexPlan | None, dict[str, str]]:
648
+ """``index-plan`` (lf-0022): scan + diff + plano, **zero writes**.
649
+
650
+ Retorna ``(plan, diagnostics)``. ``plan=None`` quando não há
651
+ READY anterior — o caller renderiza o plano full implícito
652
+ (todos os arquivos ``reparse``). Nunca cria repo/snapshot/job.
653
+ """
654
+ scan = self._scanner(Path(root))
655
+ versions = self._analyzer_versions(scan)
656
+ store = self._store
657
+ ikey = identity_key(scan.git, Path(root))
658
+ repo_id = store.repository_id_for(ikey)
659
+ if repo_id is None:
660
+ # compat: repo indexado por path antes do v5
661
+ repo_id = store.repository_id_for(str(Path(root).resolve())
662
+ )
663
+ if repo_id is not None:
664
+ plan, _, _ = self._plan_incremental(scan, repo_id, versions)
665
+ if plan is not None:
666
+ return plan, {}
667
+ # Sem READY anterior (ou repo jamais indexado): o plano real
668
+ # seria full — sintetiza tudo como reparse, marcado incremental.
669
+ full = IndexPlan(
670
+ files=tuple(
671
+ PlannedFile(f.path, PlanAction.REPARSE,
672
+ "no prior READY snapshot — full build")
673
+ for f in scan.files
674
+ ),
675
+ incremental=False,
676
+ )
677
+ return full, {"note": "no prior READY — dry-run rendered as full build"}
678
+
679
+ def _plan_incremental(
680
+ self, scan: ScanResult, repo_id: int, versions: dict[str, str]
681
+ ) -> tuple[IndexPlan | None, set[str], set[str]]:
682
+ """Diff + plano de invalidação contra o último READY (lf-0019).
683
+
684
+ Retorna ``(plan, reparse_paths, cache_candidates)``; ``plan=None``
685
+ quando não há snapshot anterior (a chamada inteira vira full
686
+ index). Arquivos ``REPARSE`` + ``REVALIDATE`` são lidos/parseados
687
+ direto; ``REUSE`` tenta o cache primeiro (lf-0020) e cai para
688
+ parse real em miss — as relações são sempre re-resolvidas pelo
689
+ builder sobre o universo novo.
690
+ """
691
+ store = self._store
692
+ prior_snap = store.latest_ready_snapshot(repo_id)
693
+ if prior_snap is None or prior_snap.id is None:
694
+ return None, set(), set()
695
+ prior_id = prior_snap.id
696
+ prior_inv = store.snapshot_inventory(prior_id)
697
+ changeset = detect_changes(
698
+ scan, prior_inv, dict(prior_snap.analyzer_versions), versions,
699
+ )
700
+ prior_status = {
701
+ str(r["path"]): str(r["parse_status"]) for r in prior_inv
702
+ }
703
+ plan = self._planner.plan(
704
+ changeset,
705
+ store.module_import_edges(prior_id),
706
+ prior_status,
707
+ )
708
+ self.last_plan = plan
709
+ reparse_paths = set(plan.paths_for(PlanAction.REPARSE)) | set(
710
+ plan.paths_for(PlanAction.REVALIDATE_RELATIONS)
711
+ )
712
+ # Spec lf-0020: result file-level só é reutilizado quando o
713
+ # conjunto de dependências está inalterado (REUSE). REVALIDATE
714
+ # re-parseia — "reevaluate" = re-extração + re-resolução.
715
+ cache_paths = set(plan.paths_for(PlanAction.REUSE))
716
+ return plan, reparse_paths, cache_paths
717
+
718
+ @staticmethod
719
+ def _record_parse_failures(
720
+ store: Store,
721
+ snapshot_id: int,
722
+ scan: ScanResult,
723
+ file_ids: dict[str, int],
724
+ read_failures: list[str],
725
+ ) -> int:
726
+ """Evidência de parse-failure por arquivo; retorna quantas rows."""
727
+ sha_by_path = {f.path: f.sha256 for f in scan.files}
728
+ n = 0
729
+ for fail in read_failures:
730
+ path, _, code = fail.partition(":")
731
+ fid = file_ids.get(path)
732
+ if fid is not None:
733
+ store.insert_evidence(
734
+ snapshot_id, fid, path, None,
735
+ sha_by_path.get(path) or "", "parse-engine",
736
+ ANALYZER_VERSION, notes=f"parse-failure:{code}",
737
+ )
738
+ n += 1
739
+ return n
740
+
741
+ @staticmethod
742
+ def _noop_outcome(snapshot_id: int | None, job_id: int) -> IndexOutcome:
743
+ return IndexOutcome(
744
+ snapshot_id=snapshot_id, job_id=job_id, status="noop",
745
+ files_indexed=0, noop=True,
746
+ )
747
+
748
+ @staticmethod
749
+ def _is_unchanged(
750
+ current: Snapshot | None,
751
+ digest: str,
752
+ versions: dict[str, str],
753
+ ) -> TypeGuard[Snapshot]:
754
+ """READY anterior com mesmo digest+versões → no-op (Blueprint §6)."""
755
+ return (
756
+ current is not None
757
+ and current.worktree_digest == digest
758
+ and dict(current.analyzer_versions) == versions
759
+ )
760
+
761
+ _BEGIN_ATTEMPTS = 4
762
+ _BEGIN_BACKOFF_S = 0.05
763
+
764
+ def _acquire_write_lock(self) -> sqlite3.OperationalError | None:
765
+ """BEGIN IMMEDIATE com backoff; retorna o erro final ou None."""
766
+ last: sqlite3.OperationalError | None = None
767
+ for attempt in range(self._BEGIN_ATTEMPTS):
768
+ try:
769
+ self._conn.execute("BEGIN IMMEDIATE")
770
+ return None
771
+ except sqlite3.OperationalError as exc:
772
+ last = exc
773
+ time.sleep(self._BEGIN_BACKOFF_S * (attempt + 1))
774
+ return last
775
+
776
+ @staticmethod
777
+ def _prefetch_cache(
778
+ cache: ParseResultCache,
779
+ keys: dict[str, CacheKey | None],
780
+ cache_candidates: set[str],
781
+ reparse: set[str],
782
+ incremental: bool,
783
+ timings: dict[str, int] | None,
784
+ progress: ProgressFn | None = None,
785
+ ) -> dict[str, ParseResult]:
786
+ """lf-0031: lookup batchado — um ``json_each`` IN-chunk por 400
787
+ paths em vez de um SELECT por arquivo candidato."""
788
+ prefetched: dict[str, ParseResult] = {}
789
+ if incremental:
790
+ wanted = [
791
+ k for p, k in keys.items()
792
+ if k is not None
793
+ and p in cache_candidates and p not in reparse
794
+ ]
795
+ if wanted:
796
+ t_cl = time.monotonic()
797
+ prefetched = cache.get_many(wanted)
798
+ if timings is not None:
799
+ timings["cache_lookup"] = int(
800
+ (time.monotonic() - t_cl) * 1000)
801
+ _emit_progress(progress, "stage",
802
+ stage="cache_lookup",
803
+ ms=timings["cache_lookup"],
804
+ keys=len(wanted))
805
+ return prefetched
806
+
807
+ def _parse_files(
808
+ self,
809
+ root: Path,
810
+ scan: ScanResult,
811
+ cache: ParseResultCache,
812
+ cfg_digest: str,
813
+ *,
814
+ reparse: set[str],
815
+ cache_candidates: set[str],
816
+ incremental: bool,
817
+ timings: dict[str, int] | None = None,
818
+ progress: ProgressFn | None = None,
819
+ ) -> tuple[
820
+ list[ParseResult], dict[str, str], list[str],
821
+ dict[str, tuple[CacheKey, ParseResult]],
822
+ dict[str, CacheKey],
823
+ ]:
824
+ """Lê e parseia arquivos suportados — fora da transação de escrita.
825
+
826
+ Retorna ``(results, file_status, failures, puts, hits)``:
827
+ ``puts`` = resultados novos para ``cache.put`` na transação de
828
+ publicação; ``hits`` = chaves servidas do cache para
829
+ ``mark_used``.
830
+
831
+ lf-0020: ``cache_candidates`` (REUSE + REVALIDATE) tentam o
832
+ cache primeiro — hit devolve o ``ParseResult`` sem ler nem
833
+ parsear o arquivo; miss cai para o caminho normal (o sha do
834
+ manifesto prova conteúdo idêntico → mesmo resultado). Parse
835
+ real só acontece para ``reparse`` ou em modo full.
836
+
837
+ ``progress`` (lf-0141): emite ``"parse-progress"`` a cada 64
838
+ arquivos e ao fim — ``files_per_sec`` medido sobre o total de
839
+ arquivos do scan (processados ou não), o throughput real da
840
+ fase. ``cache_lookup`` sai como evento ``"stage"``.
841
+
842
+ Integridade scan→parse (lf-0015): os bytes parseados precisam ser
843
+ exatamente os bytes hasheados pelo manifest — leitura verificada
844
+ (``read_file_verified``) + comparação de sha256. Divergência vira
845
+ falha de arquivo auditável, nunca READY com identidade errada.
846
+ """
847
+ root_real = Path(root).resolve()
848
+ results: list[ParseResult] = []
849
+ file_status: dict[str, str] = {}
850
+ failures: list[str] = []
851
+ puts: dict[str, tuple[CacheKey, ParseResult]] = {}
852
+ hits: dict[str, CacheKey] = {}
853
+ by_path = {f.path: f for f in scan.files}
854
+ adapters = {
855
+ p: self._engine.registry.for_path(p) for p in by_path
856
+ }
857
+ keys = {
858
+ p: cache_key_for(by_path[p], a, cfg_digest)
859
+ for p, a in adapters.items() if a is not None
860
+ }
861
+ prefetched = self._prefetch_cache(
862
+ cache, keys, cache_candidates, reparse, incremental, timings,
863
+ progress)
864
+ total_files = len(scan.files)
865
+ t_files = time.monotonic()
866
+ for i, f in enumerate(scan.files, 1):
867
+ adapter = self._engine.registry.for_path(f.path)
868
+ if adapter is None:
869
+ continue # extensão sem parser — não conta como falha
870
+ key = keys[f.path]
871
+ if f.sha256 is None:
872
+ failures.append(f"{f.path}:hash-unavailable")
873
+ file_status[f.path] = "parse-failed"
874
+ continue
875
+ # lf-0020: candidato a cache → hit pula leitura e tree-sitter
876
+ if (
877
+ incremental
878
+ and key is not None
879
+ and f.path in cache_candidates
880
+ and f.path not in reparse
881
+ ):
882
+ cached = prefetched.get(f.path)
883
+ if cached is not None:
884
+ results.append(cached)
885
+ hits[f.path] = key
886
+ self._status_of(cached, f.path, file_status, failures)
887
+ continue
888
+ data = read_file_verified(Path(root) / f.path, root_real)
889
+ if data is None:
890
+ failures.append(f"{f.path}:read-error")
891
+ file_status[f.path] = "parse-failed"
892
+ continue
893
+ if hashlib.sha256(data).hexdigest() != f.sha256:
894
+ failures.append(f"{f.path}:content-mismatch")
895
+ file_status[f.path] = "parse-failed"
896
+ continue
897
+ # lf-0130: falha por arquivo é auditável, nunca derruba os
898
+ # arquivos seguintes. O engine já isola crash de adapter
899
+ # (adapter-crash); esta camada extra cobre falhas internas
900
+ # do próprio engine/registry — file N falha, N+1..M seguem.
901
+ try:
902
+ res = self._engine.parse(f.path, data)
903
+ except Exception:
904
+ failures.append(f"{f.path}:engine-crash")
905
+ file_status[f.path] = "parse-failed"
906
+ continue
907
+ results.append(res)
908
+ # só resultados suportados entram no cache — falhas são
909
+ # re-tentadas a cada run (determinístico, honesto e barato)
910
+ if key is not None and res.supported:
911
+ puts[f.path] = (key, res)
912
+ self._status_of(res, f.path, file_status, failures)
913
+ _parse_tick(progress, i, total_files, t_files)
914
+ _parse_tick_final(progress, total_files, t_files)
915
+ return results, file_status, failures, puts, hits
916
+
917
+ @staticmethod
918
+ def _status_of(
919
+ res: ParseResult,
920
+ path: str,
921
+ file_status: dict[str, str],
922
+ failures: list[str],
923
+ ) -> None:
924
+ """Deriva parse_status + failure string de um ParseResult."""
925
+ if not res.supported:
926
+ failures.append(
927
+ f"{path}:{res.diagnostics[0].code if res.diagnostics else 'parse'}"
928
+ )
929
+ file_status[path] = "parse-failed"
930
+ elif res.has_error:
931
+ failures.append(f"{path}:syntax-errors")
932
+ file_status[path] = "parsed-with-errors"
933
+ else:
934
+ file_status[path] = "parsed"
935
+
936
+ def _validate(self, snapshot_id: int) -> None:
937
+ """Integrity gate antes do READY; levanta para abortar a transação."""
938
+ violations = self._store.foreign_key_violations()
939
+ if violations:
940
+ raise sqlite3.IntegrityError(
941
+ f"FK violations in snapshot {snapshot_id}: {violations[:5]}"
942
+ )
943
+ cross = self._store.cross_snapshot_relations(snapshot_id)
944
+ if cross:
945
+ raise sqlite3.IntegrityError(
946
+ f"cross-snapshot relations {cross[:5]} in snapshot {snapshot_id}"
947
+ )
948
+ # lf-0117: mesma auditoria para relações de domínio — arestas
949
+ # cross-domain só podem ligar ocorrências do próprio snapshot.
950
+ cross_dom = self._store.cross_snapshot_domain_relations(snapshot_id)
951
+ if cross_dom:
952
+ raise sqlite3.IntegrityError(
953
+ f"cross-snapshot domain relations {cross_dom[:5]} "
954
+ f"in snapshot {snapshot_id}"
955
+ )
956
+
957
+ def _run_cross_resolution(
958
+ self,
959
+ store: Store,
960
+ snapshot_id: int,
961
+ dcounts: dict[str, int],
962
+ domain_failures: list[str],
963
+ ) -> None:
964
+ """lf-0117 (Cycle 7 Ph.9): resolução cross-domain opt-in.
965
+
966
+ Chamado depois de TODOS os adapters persistirem, dentro da
967
+ mesma transação de publicação — um erro aqui sofre rollback
968
+ junto com o snapshot inteiro. O plano lê as rows recém-gravadas
969
+ (mesma conexão) e só casa por identidade qualificada entre
970
+ domínios distintos; updates/edges carregam evidência dos dois
971
+ lados. ``None`` (default) = feature desligada, zero custo.
972
+ """
973
+ if self._cross_resolver is None:
974
+ return
975
+ xplan = self._cross_resolver.plan(store, snapshot_id)
976
+ xc = store.persist_cross_resolutions(
977
+ snapshot_id, xplan.update_rows(),
978
+ xplan.insert_rows(snapshot_id),
979
+ )
980
+ for k, v in xplan.stats.items():
981
+ dcounts[k] = dcounts.get(k, 0) + v
982
+ for k, v in xc.items():
983
+ dcounts[k] = dcounts.get(k, 0) + v
984
+ for d in xplan.diagnostics:
985
+ if d.severity in ("warning", "error"):
986
+ domain_failures.append(f"cross:{d.code}:{d.message}")
987
+
988
+ def _mark_failed(self, repo_id: int, job_id: int, error: str) -> int:
989
+ """Insere snapshot FAILED para diagnóstico (transação separada).
990
+
991
+ O BUILDING abortado já sofreu rollback; registramos um FAILED novo
992
+ para que falhas fiquem inspecionáveis sem tocar no READY anterior.
993
+ """
994
+ with self._conn:
995
+ snap_id = self._store.insert_snapshot(
996
+ repo_id,
997
+ worktree_digest="",
998
+ analyzer_versions={},
999
+ parent_snapshot_id=None,
1000
+ )
1001
+ self._store.set_snapshot_status(snap_id, SnapshotStatus.FAILED, error[:500])
1002
+ self._store.finish_index_job(
1003
+ job_id, "failed", snapshot_id=snap_id, failures=(error[:500],)
1004
+ )
1005
+ return snap_id
1006
+
1007
+
1008
+ def _persist_domains(
1009
+ store: Store,
1010
+ repo_id: int,
1011
+ snapshot_id: int,
1012
+ file_ids: dict[str, int],
1013
+ domain_results: list[tuple[DomainAdapter, DomainExtractionResult]],
1014
+ domain_failures: list[str],
1015
+ ) -> dict[str, int]:
1016
+ """Persiste resultados de domínio no snapshot (opt-in, lf-0111).
1017
+
1018
+ Roda dentro da transação de publicação — cada chamada a
1019
+ ``persist_domain_result`` compartilha o BEGIN do grafo de código.
1020
+ Retorna contagens prefixadas ``domain_<id>_*`` para o job stats.
1021
+ """
1022
+ counts: dict[str, int] = {}
1023
+ for adapter, dres in domain_results:
1024
+ dc = persist_domain_result(
1025
+ store, repo_id, snapshot_id, file_ids, dres
1026
+ )
1027
+ for k, v in dc.items():
1028
+ key = f"domain_{adapter.domain_id}_{k}"
1029
+ counts[key] = counts.get(key, 0) + v
1030
+ counts["domain_adapters"] = len(domain_results)
1031
+ counts["domain_failures"] = len(domain_failures)
1032
+ return counts