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,166 @@
|
|
|
1
|
+
"""Seleção de testes candidatos (Phase 7).
|
|
2
|
+
|
|
3
|
+
Evidências, em ordem de força:
|
|
4
|
+
1. aresta ``TESTS``/``CALLS``/``IMPORTS`` resolvida de arquivo de teste
|
|
5
|
+
para símbolo alterado → ``direct``/``verified``;
|
|
6
|
+
2. consumidor transitivo localizado em arquivo de teste → ``transitive``;
|
|
7
|
+
3. convenção de nome no mesmo módulo (``test_foo.py`` p/ ``foo.py``) →
|
|
8
|
+
``heuristic`` — nunca declarado como cobertura real.
|
|
9
|
+
|
|
10
|
+
Comandos de execução são *sugestões* estáticas — nada é executado.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sqlite3
|
|
16
|
+
|
|
17
|
+
from code_atlas.impact.contracts import (
|
|
18
|
+
EvidenceStrength,
|
|
19
|
+
ImpactCandidate,
|
|
20
|
+
SymbolChange,
|
|
21
|
+
TestCandidate,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_TEST_SUFFIXES = (
|
|
25
|
+
".test.ts", ".spec.ts", ".test.tsx", ".spec.tsx",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# cap default de candidatos — declarado em ``truncation_reasons``
|
|
29
|
+
# quando a saída chega ao limite (lf-0151, convenção at-cap).
|
|
30
|
+
MAX_TEST_CANDIDATES = 50
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_test_path(path: str) -> bool:
|
|
34
|
+
p = path.replace("\\", "/").lower()
|
|
35
|
+
name = p.rsplit("/", 1)[-1]
|
|
36
|
+
return ((name.startswith("test_") and name.endswith(".py"))
|
|
37
|
+
or name.endswith("_test.py")
|
|
38
|
+
or any(name.endswith(s) for s in _TEST_SUFFIXES)
|
|
39
|
+
or "/__tests__/" in p
|
|
40
|
+
or "/tests/" in p or p.startswith("tests/"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _edge_candidates(
|
|
44
|
+
conn: sqlite3.Connection, snapshot_id: int,
|
|
45
|
+
symbol_changes: tuple[SymbolChange, ...],
|
|
46
|
+
out: list[TestCandidate], seen: set[str],
|
|
47
|
+
max_candidates: int,
|
|
48
|
+
*,
|
|
49
|
+
occs: set[int] | None = None,
|
|
50
|
+
revision: str = "target",
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Arestas resolvidas teste→alterado = evidência real de cobertura."""
|
|
53
|
+
changed_occs = occs if occs is not None else {
|
|
54
|
+
oid for s in symbol_changes
|
|
55
|
+
for oid in (s.new_occurrence_id, s.old_occurrence_id) if oid}
|
|
56
|
+
if not changed_occs:
|
|
57
|
+
return
|
|
58
|
+
marks = ",".join("?" for _ in changed_occs)
|
|
59
|
+
edge_sql = (
|
|
60
|
+
"SELECT r.from_id, r.type, r.resolution_status"
|
|
61
|
+
" FROM relations r WHERE r.snapshot_id = ?"
|
|
62
|
+
f" AND r.to_id IN ({marks})"
|
|
63
|
+
" ORDER BY r.id LIMIT ?")
|
|
64
|
+
rows = conn.execute(
|
|
65
|
+
edge_sql,
|
|
66
|
+
(snapshot_id, *sorted(changed_occs), max_candidates)
|
|
67
|
+
).fetchall()
|
|
68
|
+
file_of: dict[int, sqlite3.Row | None] = {}
|
|
69
|
+
for r in rows:
|
|
70
|
+
fid = int(r["from_id"])
|
|
71
|
+
if fid not in file_of:
|
|
72
|
+
file_of[fid] = conn.execute(
|
|
73
|
+
"SELECT f.relative_path, o.id AS occ FROM"
|
|
74
|
+
" symbol_occurrences o JOIN source_files f"
|
|
75
|
+
" ON f.id = o.source_file_id"
|
|
76
|
+
" WHERE o.id = ?", (fid,)).fetchone()
|
|
77
|
+
frow = file_of[fid]
|
|
78
|
+
path = str(frow["relative_path"]) if frow else ""
|
|
79
|
+
if frow and is_test_path(path) and path not in seen:
|
|
80
|
+
seen.add(path)
|
|
81
|
+
out.append(TestCandidate(
|
|
82
|
+
test_file=path, test_symbol=None,
|
|
83
|
+
relationship="direct",
|
|
84
|
+
reason=f"aresta {r['type']}→símbolo alterado",
|
|
85
|
+
strength=(EvidenceStrength.VERIFIED
|
|
86
|
+
if r["resolution_status"] == "resolved"
|
|
87
|
+
else EvidenceStrength.POSSIBLE),
|
|
88
|
+
occurrence_id=int(frow["occ"]),
|
|
89
|
+
revision=revision))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def select(
|
|
93
|
+
conn: sqlite3.Connection, snapshot_id: int,
|
|
94
|
+
symbol_changes: tuple[SymbolChange, ...],
|
|
95
|
+
impacts: tuple[ImpactCandidate, ...],
|
|
96
|
+
*,
|
|
97
|
+
base_snapshot_id: int | None = None,
|
|
98
|
+
max_candidates: int = MAX_TEST_CANDIDATES,
|
|
99
|
+
) -> tuple[TestCandidate, ...]:
|
|
100
|
+
conn.row_factory = sqlite3.Row
|
|
101
|
+
out: list[TestCandidate] = []
|
|
102
|
+
seen: set[str] = set()
|
|
103
|
+
|
|
104
|
+
# 0a) arestas resolvidas teste→símbolo-alterado no ALVO.
|
|
105
|
+
target_occs = {
|
|
106
|
+
oid for s in symbol_changes
|
|
107
|
+
for oid in (s.new_occurrence_id,) if oid}
|
|
108
|
+
_edge_candidates(
|
|
109
|
+
conn, snapshot_id, symbol_changes, out, seen, max_candidates,
|
|
110
|
+
occs=target_occs or None, revision="target")
|
|
111
|
+
|
|
112
|
+
# 0b) lf-0080: arestas HISTÓRICAS na base — testes que referenciavam
|
|
113
|
+
# símbolos removidos (ocorrência só existe na base).
|
|
114
|
+
if base_snapshot_id is not None and base_snapshot_id != snapshot_id:
|
|
115
|
+
base_occs = {
|
|
116
|
+
s.old_occurrence_id for s in symbol_changes
|
|
117
|
+
if s.old_occurrence_id is not None}
|
|
118
|
+
_edge_candidates(
|
|
119
|
+
conn, base_snapshot_id, symbol_changes, out, seen,
|
|
120
|
+
max_candidates, occs=base_occs or None, revision="base")
|
|
121
|
+
|
|
122
|
+
# 1) impact candidates que já vivem em arquivos de teste.
|
|
123
|
+
for c in impacts:
|
|
124
|
+
if c.source_path and is_test_path(c.source_path):
|
|
125
|
+
rel = ("direct" if c.path and c.path.hops == 1
|
|
126
|
+
else "transitive")
|
|
127
|
+
if c.source_path not in seen:
|
|
128
|
+
seen.add(c.source_path)
|
|
129
|
+
out.append(TestCandidate(
|
|
130
|
+
test_file=c.source_path,
|
|
131
|
+
test_symbol=c.qualified_name,
|
|
132
|
+
relationship=rel,
|
|
133
|
+
reason=f"grafo: {c.path.reason if c.path else ''}",
|
|
134
|
+
strength=c.strength,
|
|
135
|
+
occurrence_id=c.occurrence_id,
|
|
136
|
+
revision=("base" if c.consumer_state else "target")))
|
|
137
|
+
|
|
138
|
+
# 2) convenção de nome: test_<mod>.py / <mod>.test.ts por arquivo
|
|
139
|
+
# de origem alterado.
|
|
140
|
+
changed_paths = {s.new_path or s.old_path for s in symbol_changes}
|
|
141
|
+
rows = conn.execute(
|
|
142
|
+
"SELECT DISTINCT f.relative_path, o.id"
|
|
143
|
+
" FROM symbol_occurrences o"
|
|
144
|
+
" JOIN source_files f ON f.id = o.source_file_id"
|
|
145
|
+
" WHERE o.snapshot_id = ? ORDER BY f.relative_path",
|
|
146
|
+
(snapshot_id,)).fetchall()
|
|
147
|
+
stems = {p.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
|
148
|
+
for p in changed_paths if p}
|
|
149
|
+
for r in rows:
|
|
150
|
+
path = str(r["relative_path"])
|
|
151
|
+
if path in seen or not is_test_path(path):
|
|
152
|
+
continue
|
|
153
|
+
name = path.rsplit("/", 1)[-1]
|
|
154
|
+
stem = name.replace("test_", "").replace("_test", "")
|
|
155
|
+
stem = stem.split(".")[0]
|
|
156
|
+
if stem and stem in stems:
|
|
157
|
+
seen.add(path)
|
|
158
|
+
out.append(TestCandidate(
|
|
159
|
+
test_file=path, test_symbol=None,
|
|
160
|
+
relationship="heuristic",
|
|
161
|
+
reason="convenção de nome sobre módulo alterado",
|
|
162
|
+
strength=EvidenceStrength.POSSIBLE,
|
|
163
|
+
occurrence_id=int(r["id"])))
|
|
164
|
+
if len(out) >= max_candidates:
|
|
165
|
+
break
|
|
166
|
+
return tuple(out[:max_candidates])
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Cache versionado de ``ParseResult`` por arquivo (lf-0020).
|
|
2
|
+
|
|
3
|
+
Store: tabela ``parse_result_cache`` (schema v4) — **mesmo banco** do
|
|
4
|
+
índice, escolhido porque (a) a transação de publicação já cobre o cache
|
|
5
|
+
(rollback de build falho não deixa entradas órfãs novas), (b) zero
|
|
6
|
+
infraestrutura extra, (c) o cache nunca pertence a snapshots — GC não
|
|
7
|
+
pode corromper linhas vivas.
|
|
8
|
+
|
|
9
|
+
Chave de validade completa (grill): ``(repo_id, path, content_sha256,
|
|
10
|
+
language, parser_version, grammar_version, extractor_version,
|
|
11
|
+
resolver_version, builder_version, config_digest)`` — nunca por path
|
|
12
|
+
sozinho. Valor: ``ParseResult`` serializado em JSON estruturado (sem
|
|
13
|
+
pickle — entrada é disco não-confiável). ``payload_sha256`` verifica
|
|
14
|
+
integridade na leitura: blob corrompido → miss, nunca crash.
|
|
15
|
+
|
|
16
|
+
GC: ``gc`` remove entradas cujo ``last_used_snapshot_id`` é anterior ao
|
|
17
|
+
N-ésimo READY mais recente do repo, e aplica cap ``MAX_ENTRIES`` por
|
|
18
|
+
repo (eviction por ``last_used_snapshot_id`` mais antigo). Roda dentro
|
|
19
|
+
da transação de publicação → atômica com o build.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import sqlite3
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from code_atlas.parsers.contracts import (
|
|
31
|
+
ExtractedReference,
|
|
32
|
+
ExtractedRelation,
|
|
33
|
+
ExtractedSymbol,
|
|
34
|
+
ParseDiagnostic,
|
|
35
|
+
ParseResult,
|
|
36
|
+
SourceSpan,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Versões de contrato — bump quando a semântica muda (invalida o cache).
|
|
40
|
+
RESOLVER_VERSION = "syntactic/1.0.0"
|
|
41
|
+
BUILDER_VERSION = "graph-builder/1.0.0"
|
|
42
|
+
CACHE_FORMAT_VERSION = 1
|
|
43
|
+
|
|
44
|
+
# Política de retenção (bounded cache): entradas não usadas pelos
|
|
45
|
+
# últimos ``KEEP_SNAPSHOTS`` READY são removidas; cap global por repo.
|
|
46
|
+
KEEP_SNAPSHOTS = 5
|
|
47
|
+
MAX_ENTRIES = 50_000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class CacheKey:
|
|
52
|
+
"""Chave de validade completa — qualquer componente novo invalida."""
|
|
53
|
+
|
|
54
|
+
path: str
|
|
55
|
+
content_sha256: str
|
|
56
|
+
language: str
|
|
57
|
+
parser_version: str
|
|
58
|
+
grammar_version: str
|
|
59
|
+
extractor_version: str
|
|
60
|
+
config_digest: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- codec ParseResult <-> JSON (estruturado, sem pickle) ---------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _span_to_json(s: SourceSpan | None) -> list[int] | None:
|
|
67
|
+
if s is None:
|
|
68
|
+
return None
|
|
69
|
+
return [s.start_byte, s.end_byte, s.start_line, s.start_col,
|
|
70
|
+
s.end_line, s.end_col]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _span_from_json(v: Any) -> SourceSpan | None:
|
|
74
|
+
if v is None:
|
|
75
|
+
return None
|
|
76
|
+
return SourceSpan(int(v[0]), int(v[1]), int(v[2]), int(v[3]),
|
|
77
|
+
int(v[4]), int(v[5]))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def result_to_payload(res: ParseResult) -> str:
|
|
81
|
+
"""Serializa ParseResult em JSON canônico (chaves ordenadas)."""
|
|
82
|
+
doc = {
|
|
83
|
+
"format": CACHE_FORMAT_VERSION,
|
|
84
|
+
"path": res.path,
|
|
85
|
+
"language": res.language,
|
|
86
|
+
"parser": res.parser,
|
|
87
|
+
"parser_version": res.parser_version,
|
|
88
|
+
"grammar_version": res.grammar_version,
|
|
89
|
+
"content_hash": res.content_hash,
|
|
90
|
+
"has_error": res.has_error,
|
|
91
|
+
"error_node_count": res.error_node_count,
|
|
92
|
+
"missing_node_count": res.missing_node_count,
|
|
93
|
+
"node_count": res.node_count,
|
|
94
|
+
"supported": res.supported,
|
|
95
|
+
"diagnostics": [
|
|
96
|
+
{
|
|
97
|
+
"code": d.code, "severity": d.severity,
|
|
98
|
+
"message": d.message, "span": _span_to_json(d.span),
|
|
99
|
+
}
|
|
100
|
+
for d in res.diagnostics
|
|
101
|
+
],
|
|
102
|
+
"symbols": [
|
|
103
|
+
{
|
|
104
|
+
"language": s.language, "kind": s.kind, "name": s.name,
|
|
105
|
+
"qualified_name": s.qualified_name,
|
|
106
|
+
"span": _span_to_json(s.span),
|
|
107
|
+
"name_span": _span_to_json(s.name_span),
|
|
108
|
+
"scope_path": list(s.scope_path),
|
|
109
|
+
"signature": s.signature, "params": list(s.params),
|
|
110
|
+
"docstring": s.docstring, "decorators": list(s.decorators),
|
|
111
|
+
"bases": list(s.bases), "implements": list(s.implements),
|
|
112
|
+
"modifiers": list(s.modifiers),
|
|
113
|
+
}
|
|
114
|
+
for s in res.symbols
|
|
115
|
+
],
|
|
116
|
+
"references": [
|
|
117
|
+
{
|
|
118
|
+
"name": r.name, "context": r.context,
|
|
119
|
+
"span": _span_to_json(r.span),
|
|
120
|
+
"scope_path": list(r.scope_path), "target": r.target,
|
|
121
|
+
}
|
|
122
|
+
for r in res.references
|
|
123
|
+
],
|
|
124
|
+
"relations": [
|
|
125
|
+
{
|
|
126
|
+
"type": r.type, "from_name": r.from_name,
|
|
127
|
+
"to_name": r.to_name, "span": _span_to_json(r.span),
|
|
128
|
+
"confidence_class": r.confidence_class,
|
|
129
|
+
"resolution_status": r.resolution_status,
|
|
130
|
+
}
|
|
131
|
+
for r in res.relations
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
return json.dumps(doc, sort_keys=True, separators=(",", ":"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def result_from_payload(payload: str) -> ParseResult:
|
|
138
|
+
"""Desserializa; levanta ValueError em formato/versão inválidos."""
|
|
139
|
+
doc = json.loads(payload)
|
|
140
|
+
if not isinstance(doc, dict) or doc.get("format") != CACHE_FORMAT_VERSION:
|
|
141
|
+
raise ValueError("unsupported cache payload format")
|
|
142
|
+
return ParseResult(
|
|
143
|
+
path=str(doc["path"]),
|
|
144
|
+
language=str(doc["language"]),
|
|
145
|
+
parser=str(doc["parser"]),
|
|
146
|
+
parser_version=str(doc["parser_version"]),
|
|
147
|
+
grammar_version=str(doc["grammar_version"]),
|
|
148
|
+
content_hash=str(doc["content_hash"]),
|
|
149
|
+
has_error=bool(doc["has_error"]),
|
|
150
|
+
error_node_count=int(doc["error_node_count"]),
|
|
151
|
+
missing_node_count=int(doc["missing_node_count"]),
|
|
152
|
+
node_count=int(doc["node_count"]),
|
|
153
|
+
supported=bool(doc["supported"]),
|
|
154
|
+
diagnostics=tuple(
|
|
155
|
+
ParseDiagnostic(
|
|
156
|
+
code=str(d["code"]), severity=str(d["severity"]),
|
|
157
|
+
message=str(d["message"]),
|
|
158
|
+
span=_span_from_json(d["span"]),
|
|
159
|
+
)
|
|
160
|
+
for d in doc["diagnostics"]
|
|
161
|
+
),
|
|
162
|
+
symbols=tuple(
|
|
163
|
+
ExtractedSymbol(
|
|
164
|
+
language=str(s["language"]), kind=str(s["kind"]),
|
|
165
|
+
name=str(s["name"]),
|
|
166
|
+
qualified_name=str(s["qualified_name"]),
|
|
167
|
+
span=_span_from_json(s["span"]) or _ZERO_SPAN,
|
|
168
|
+
name_span=_span_from_json(s["name_span"]),
|
|
169
|
+
scope_path=tuple(str(x) for x in s["scope_path"]),
|
|
170
|
+
signature=s["signature"],
|
|
171
|
+
params=tuple(str(x) for x in s["params"]),
|
|
172
|
+
docstring=s["docstring"],
|
|
173
|
+
decorators=tuple(str(x) for x in s["decorators"]),
|
|
174
|
+
bases=tuple(str(x) for x in s["bases"]),
|
|
175
|
+
implements=tuple(str(x) for x in s["implements"]),
|
|
176
|
+
modifiers=tuple(str(x) for x in s["modifiers"]),
|
|
177
|
+
)
|
|
178
|
+
for s in doc["symbols"]
|
|
179
|
+
),
|
|
180
|
+
references=tuple(
|
|
181
|
+
ExtractedReference(
|
|
182
|
+
name=str(r["name"]), context=str(r["context"]),
|
|
183
|
+
span=_span_from_json(r["span"]) or _ZERO_SPAN,
|
|
184
|
+
scope_path=tuple(str(x) for x in r["scope_path"]),
|
|
185
|
+
target=str(r["target"]),
|
|
186
|
+
)
|
|
187
|
+
for r in doc["references"]
|
|
188
|
+
),
|
|
189
|
+
relations=tuple(
|
|
190
|
+
ExtractedRelation(
|
|
191
|
+
type=str(r["type"]), from_name=str(r["from_name"]),
|
|
192
|
+
to_name=str(r["to_name"]),
|
|
193
|
+
span=_span_from_json(r["span"]) or _ZERO_SPAN,
|
|
194
|
+
confidence_class=str(r["confidence_class"]),
|
|
195
|
+
resolution_status=str(r["resolution_status"]),
|
|
196
|
+
)
|
|
197
|
+
for r in doc["relations"]
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
_ZERO_SPAN = SourceSpan(0, 0, 0, 0, 0, 0)
|
|
203
|
+
_BATCH_CHUNK = 400 # bem abaixo do limite de variáveis do SQLite (999)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ParseResultCache:
|
|
207
|
+
"""Store versionado de ParseResult — leitura nunca executa nada."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, conn: sqlite3.Connection, repo_id: int) -> None:
|
|
210
|
+
self._conn = conn
|
|
211
|
+
self._repo_id = repo_id
|
|
212
|
+
|
|
213
|
+
def _key_params(self, key: CacheKey) -> tuple[object, ...]:
|
|
214
|
+
return (
|
|
215
|
+
self._repo_id, key.path, key.content_sha256, key.language,
|
|
216
|
+
key.parser_version, key.grammar_version, key.extractor_version,
|
|
217
|
+
RESOLVER_VERSION, BUILDER_VERSION, key.config_digest,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def get(self, key: CacheKey) -> ParseResult | None:
|
|
221
|
+
"""Retorna o resultado cacheado ou ``None`` (miss/inválido).
|
|
222
|
+
|
|
223
|
+
Leitura pura — sem writes, pode rodar fora de transação sob WAL.
|
|
224
|
+
Integridade: ``payload_sha256`` precisa bater — blob corrompido
|
|
225
|
+
ou formato desconhecido → miss honesto, nunca exceção.
|
|
226
|
+
``mark_used`` (chamado na transação de publicação) registra o
|
|
227
|
+
hit e o ``last_used_snapshot_id`` para o GC.
|
|
228
|
+
"""
|
|
229
|
+
row = self._conn.execute(
|
|
230
|
+
"""
|
|
231
|
+
SELECT id, payload, payload_sha256 FROM parse_result_cache
|
|
232
|
+
WHERE repository_id = ? AND path = ? AND content_sha256 = ?
|
|
233
|
+
AND language = ? AND parser_version = ?
|
|
234
|
+
AND grammar_version = ? AND extractor_version = ?
|
|
235
|
+
AND resolver_version = ? AND builder_version = ?
|
|
236
|
+
AND config_digest = ?
|
|
237
|
+
""",
|
|
238
|
+
self._key_params(key),
|
|
239
|
+
).fetchone()
|
|
240
|
+
if row is None:
|
|
241
|
+
return None
|
|
242
|
+
if hashlib.sha256(row["payload"].encode()).hexdigest() != (
|
|
243
|
+
row["payload_sha256"]
|
|
244
|
+
):
|
|
245
|
+
return None # blob corrompido → miss
|
|
246
|
+
try:
|
|
247
|
+
return result_from_payload(row["payload"])
|
|
248
|
+
except (ValueError, KeyError, TypeError, json.JSONDecodeError):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def get_many(
|
|
252
|
+
self, keys: list[CacheKey]
|
|
253
|
+
) -> dict[str, ParseResult]:
|
|
254
|
+
"""Lookup batchado — um ``IN`` por chunk de paths em vez de um
|
|
255
|
+
SELECT por arquivo (lf-0031: 2019 round-trips → ~5 num corpus
|
|
256
|
+
2k). Mesmas garantias de ``get``: payload_sha256 verificado,
|
|
257
|
+
blob inválido/corrompido → ausência (miss honesto)."""
|
|
258
|
+
wanted: dict[str, CacheKey] = {k.path: k for k in keys}
|
|
259
|
+
out: dict[str, ParseResult] = {}
|
|
260
|
+
paths = sorted(wanted)
|
|
261
|
+
for i in range(0, len(paths), _BATCH_CHUNK):
|
|
262
|
+
chunk = paths[i:i + _BATCH_CHUNK]
|
|
263
|
+
# SQL constante (auditoria AST): a lista de paths entra como
|
|
264
|
+
# UM parâmetro JSON via json_each — sem placeholders
|
|
265
|
+
# dinâmicos e sem interpolar dados na query.
|
|
266
|
+
rows = self._conn.execute(
|
|
267
|
+
"""
|
|
268
|
+
SELECT path, content_sha256, language, parser_version,
|
|
269
|
+
grammar_version, extractor_version,
|
|
270
|
+
resolver_version, builder_version, config_digest,
|
|
271
|
+
payload, payload_sha256
|
|
272
|
+
FROM parse_result_cache
|
|
273
|
+
WHERE repository_id = ?
|
|
274
|
+
AND path IN (SELECT value FROM json_each(?))
|
|
275
|
+
""",
|
|
276
|
+
(self._repo_id, json.dumps(chunk)),
|
|
277
|
+
).fetchall()
|
|
278
|
+
for row in rows:
|
|
279
|
+
res = self._row_result(wanted, row)
|
|
280
|
+
if res is not None:
|
|
281
|
+
out[row["path"]] = res
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
def _row_result(
|
|
285
|
+
self, wanted: dict[str, CacheKey], row: sqlite3.Row
|
|
286
|
+
) -> ParseResult | None:
|
|
287
|
+
"""Valida a linha contra a CacheKey exata + integridade do blob."""
|
|
288
|
+
key = wanted.get(row["path"])
|
|
289
|
+
if key is None:
|
|
290
|
+
return None
|
|
291
|
+
params = self._key_params(key)
|
|
292
|
+
row_key = (
|
|
293
|
+
self._repo_id, row["path"], row["content_sha256"],
|
|
294
|
+
row["language"], row["parser_version"],
|
|
295
|
+
row["grammar_version"], row["extractor_version"],
|
|
296
|
+
row["resolver_version"], row["builder_version"],
|
|
297
|
+
row["config_digest"],
|
|
298
|
+
)
|
|
299
|
+
if row_key != params:
|
|
300
|
+
return None # chave de validade diverge → miss
|
|
301
|
+
if hashlib.sha256(row["payload"].encode()).hexdigest() != (
|
|
302
|
+
row["payload_sha256"]
|
|
303
|
+
):
|
|
304
|
+
return None
|
|
305
|
+
try:
|
|
306
|
+
return result_from_payload(row["payload"])
|
|
307
|
+
except (ValueError, KeyError, TypeError, json.JSONDecodeError):
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
def put(
|
|
311
|
+
self,
|
|
312
|
+
key: CacheKey,
|
|
313
|
+
res: ParseResult,
|
|
314
|
+
*,
|
|
315
|
+
snapshot_id: int | None = None,
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Insere/substitui o resultado na chave de validade."""
|
|
318
|
+
payload = result_to_payload(res)
|
|
319
|
+
digest = hashlib.sha256(payload.encode()).hexdigest()
|
|
320
|
+
self._conn.execute(
|
|
321
|
+
"""
|
|
322
|
+
INSERT INTO parse_result_cache (
|
|
323
|
+
repository_id, path, content_sha256, language,
|
|
324
|
+
parser_version, grammar_version, extractor_version,
|
|
325
|
+
resolver_version, builder_version, config_digest,
|
|
326
|
+
payload, payload_sha256, last_used_snapshot_id
|
|
327
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
328
|
+
ON CONFLICT (repository_id, path, content_sha256, language,
|
|
329
|
+
parser_version, grammar_version,
|
|
330
|
+
extractor_version, resolver_version,
|
|
331
|
+
builder_version, config_digest)
|
|
332
|
+
DO UPDATE SET payload = excluded.payload,
|
|
333
|
+
payload_sha256 = excluded.payload_sha256,
|
|
334
|
+
last_used_snapshot_id =
|
|
335
|
+
excluded.last_used_snapshot_id
|
|
336
|
+
""",
|
|
337
|
+
(*self._key_params(key), payload, digest, snapshot_id),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def mark_used(self, key: CacheKey, snapshot_id: int) -> None:
|
|
341
|
+
"""Marca a entrada como usada pelo snapshot (para o GC)."""
|
|
342
|
+
self._conn.execute(
|
|
343
|
+
"UPDATE parse_result_cache SET last_used_snapshot_id = ?,"
|
|
344
|
+
" hit_count = hit_count + 1 "
|
|
345
|
+
"WHERE repository_id = ? AND path = ? AND content_sha256 = ?"
|
|
346
|
+
" AND language = ? AND parser_version = ?"
|
|
347
|
+
" AND grammar_version = ? AND extractor_version = ?"
|
|
348
|
+
" AND resolver_version = ? AND builder_version = ?"
|
|
349
|
+
" AND config_digest = ?",
|
|
350
|
+
(snapshot_id, *self._key_params(key)),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def gc(
|
|
354
|
+
self,
|
|
355
|
+
repo_id: int,
|
|
356
|
+
*,
|
|
357
|
+
keep_snapshots: int = KEEP_SNAPSHOTS,
|
|
358
|
+
max_entries: int = MAX_ENTRIES,
|
|
359
|
+
) -> int:
|
|
360
|
+
"""Remove entradas frias; retorna quantas foram removidas.
|
|
361
|
+
|
|
362
|
+
Política: entradas cujo ``last_used_snapshot_id`` é anterior ao
|
|
363
|
+
``keep_snapshots``-ésimo READY mais recente são removidas; além
|
|
364
|
+
disso o repo fica limitado a ``max_entries`` linhas (eviction
|
|
365
|
+
das menos recentemente usadas). Linhas de cache nunca pertencem
|
|
366
|
+
a snapshots — não há risco de remover dado vivo.
|
|
367
|
+
"""
|
|
368
|
+
recent = self._conn.execute(
|
|
369
|
+
"SELECT id FROM snapshots WHERE repository_id = ?"
|
|
370
|
+
" AND status = 'READY' ORDER BY id DESC LIMIT ?",
|
|
371
|
+
(repo_id, keep_snapshots),
|
|
372
|
+
).fetchall()
|
|
373
|
+
floor = min((int(r["id"]) for r in recent), default=0)
|
|
374
|
+
cur = self._conn.execute(
|
|
375
|
+
"DELETE FROM parse_result_cache WHERE repository_id = ?"
|
|
376
|
+
" AND COALESCE(last_used_snapshot_id, 0) < ?",
|
|
377
|
+
(repo_id, floor),
|
|
378
|
+
)
|
|
379
|
+
removed = cur.rowcount if cur.rowcount else 0
|
|
380
|
+
# cap global por repo — evict LRU (menor last_used_snapshot_id)
|
|
381
|
+
over = self._conn.execute(
|
|
382
|
+
"SELECT COUNT(*) AS c FROM parse_result_cache"
|
|
383
|
+
" WHERE repository_id = ?",
|
|
384
|
+
(repo_id,),
|
|
385
|
+
).fetchone()["c"] - max_entries
|
|
386
|
+
if over > 0:
|
|
387
|
+
cur2 = self._conn.execute(
|
|
388
|
+
"DELETE FROM parse_result_cache WHERE id IN ("
|
|
389
|
+
" SELECT id FROM parse_result_cache"
|
|
390
|
+
" WHERE repository_id = ?"
|
|
391
|
+
" ORDER BY COALESCE(last_used_snapshot_id, 0), id"
|
|
392
|
+
" LIMIT ?)",
|
|
393
|
+
(repo_id, over),
|
|
394
|
+
)
|
|
395
|
+
removed += cur2.rowcount if cur2.rowcount else 0
|
|
396
|
+
return int(removed)
|