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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- 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
|