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,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,5 @@
1
+ """Coordenação de indexação incremental e publicação atômica de snapshots."""
2
+
3
+ from code_atlas.indexing.coordinator import IndexCoordinator, IndexOutcome
4
+
5
+ __all__ = ["IndexCoordinator", "IndexOutcome"]
@@ -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)