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,268 @@
1
+ """Planner de invalidação dependency-aware (lf-0019).
2
+
3
+ Consome o ``ChangeSet`` + arestas IMPORTS do snapshot anterior e produz
4
+ um ``IndexPlan`` inspecionável **antes** de qualquer execução.
5
+
6
+ Ações por arquivo:
7
+
8
+ - ``reparse`` — conteúdo/presença mudou (added/modified/renamed ou
9
+ analyzer bumped); símbolos e relações precisam ser refeitos.
10
+ - ``revalidate-relations`` — fonte inalterada mas um módulo do qual ela
11
+ depende (direta ou transitivamente) mudou; relações precisam ser
12
+ reavaliadas mesmo sem re-leitura conceitual do conteúdo.
13
+ - ``reuse`` — inalterada e não afetada: ocorrências e relações copiadas
14
+ do snapshot anterior.
15
+ - ``drop`` — deletada.
16
+
17
+ Postura conservadora: informação de dependência desconhecida ou
18
+ incompleta → reavaliar. Ciclos são atravessados com conjunto de
19
+ visitados. Nenhuma relação pode ficar *stale* após mudança de
20
+ dependência — invalidação é superconjunto do necessário, nunca sub.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import posixpath
27
+ from dataclasses import dataclass
28
+ from enum import StrEnum
29
+
30
+ from code_atlas.indexing.change_detector import ChangeKind, ChangeSet
31
+
32
+
33
+ class PlanAction(StrEnum):
34
+ REPARSE = "reparse"
35
+ REVALIDATE_RELATIONS = "revalidate-relations"
36
+ REUSE = "reuse"
37
+ DROP = "drop"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class PlannedFile:
42
+ path: str
43
+ action: PlanAction
44
+ reason: str
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class IndexPlan:
49
+ """Plano de indexação serializável e consultável (lf-0019)."""
50
+
51
+ files: tuple[PlannedFile, ...]
52
+ incremental: bool = True
53
+
54
+ def action_for(self, path: str) -> PlanAction | None:
55
+ for f in self.files:
56
+ if f.path == path:
57
+ return f.action
58
+ return None
59
+
60
+ def paths_for(self, action: PlanAction) -> tuple[str, ...]:
61
+ return tuple(f.path for f in self.files if f.action == action)
62
+
63
+ def counts(self) -> dict[str, int]:
64
+ out: dict[str, int] = {}
65
+ for f in self.files:
66
+ out[str(f.action)] = out.get(str(f.action), 0) + 1
67
+ return dict(sorted(out.items()))
68
+
69
+ def to_dict(self) -> dict[str, object]:
70
+ ordered = sorted(self.files, key=lambda f: f.path)
71
+ return {
72
+ "incremental": self.incremental,
73
+ "counts": self.counts(),
74
+ "files": [
75
+ {"path": f.path, "action": str(f.action), "reason": f.reason}
76
+ for f in ordered
77
+ ],
78
+ }
79
+
80
+ def to_json(self) -> str:
81
+ return json.dumps(self.to_dict(), sort_keys=True)
82
+
83
+
84
+ _TS_EXTS = (".ts", ".tsx", ".d.ts", ".mts", ".cts", "/index.ts", "/index.tsx")
85
+
86
+
87
+ def _candidate_paths(importer: str, text: str) -> set[str]:
88
+ """Caminhos de módulo que o texto de import poderia designar.
89
+
90
+ Cobre python absoluto/relativo e TS relativo, com todos os prefixos
91
+ (``pkg.util.helper`` pode ser satisfeito por ``pkg/util.py``).
92
+ """
93
+ cands: set[str] = set()
94
+
95
+ def py_prefixed(dotted: str) -> None:
96
+ segs = [s for s in dotted.split(".") if s]
97
+ for i in range(len(segs), 0, -1):
98
+ base = "/".join(segs[:i])
99
+ cands.add(base + ".py")
100
+ cands.add(base + "/__init__.py")
101
+
102
+ if text.startswith(("./", "../")):
103
+ base = posixpath.normpath(
104
+ posixpath.join(posixpath.dirname(importer), text)
105
+ )
106
+ for ext in _TS_EXTS:
107
+ cands.add(base + ext)
108
+ cands.add(base)
109
+ # `./util.member` — último segmento pode ser membro, não path
110
+ parent, _, _leaf = base.rpartition("/")
111
+ if parent:
112
+ for ext in _TS_EXTS:
113
+ cands.add(parent + ext)
114
+ elif text.startswith("."):
115
+ dots = len(text) - len(text.lstrip("."))
116
+ rest = text.lstrip(".")
117
+ dir_parts = importer.split("/")[:-1]
118
+ keep = dir_parts[: max(len(dir_parts) - (dots - 1), 0)]
119
+ if rest:
120
+ py_prefixed(".".join([*keep, *rest.split(".")]))
121
+ else:
122
+ py_prefixed(".".join(keep))
123
+ else:
124
+ py_prefixed(text)
125
+ return cands
126
+
127
+
128
+ class InvalidationPlanner:
129
+ """Gera ``IndexPlan`` a partir do ChangeSet e do grafo anterior."""
130
+
131
+ def plan(
132
+ self,
133
+ changeset: ChangeSet,
134
+ prior_import_edges: list[tuple[str, str | None, str, str]],
135
+ prior_parse_status: dict[str, str] | None = None,
136
+ ) -> IndexPlan:
137
+ """``prior_import_edges``: linhas de ``Store.module_import_edges``
138
+ (importer, imported_path|None, to_text, resolution_status).
139
+ ``prior_parse_status``: path → parse_status do snapshot anterior;
140
+ arquivos com parse incompleto têm dependências desconhecidas.
141
+ """
142
+ actions, affected = self._primary_actions(changeset)
143
+ self._propagate_dependents(changeset, prior_import_edges,
144
+ actions, affected)
145
+ self._revalidate_unresolved(changeset, prior_import_edges,
146
+ actions, affected)
147
+ self._revalidate_incomplete(changeset, prior_parse_status or {},
148
+ actions, affected)
149
+ self._revalidate_resolver_bump(changeset, actions)
150
+ return IndexPlan(
151
+ files=tuple(sorted(actions.values(), key=lambda f: f.path))
152
+ )
153
+
154
+ def _primary_actions(
155
+ self, changeset: ChangeSet
156
+ ) -> tuple[dict[str, PlannedFile], set[str]]:
157
+ """1. ações primárias: DROP/REUSE/REPARSE + conjunto afetado."""
158
+ actions: dict[str, PlannedFile] = {}
159
+ affected: set[str] = set()
160
+ for c in changeset.changes:
161
+ if c.kind == ChangeKind.DELETED:
162
+ actions[c.path] = PlannedFile(
163
+ c.path, PlanAction.DROP, f"deleted: {c.reason}"
164
+ )
165
+ affected.add(c.path)
166
+ elif c.kind == ChangeKind.UNCHANGED:
167
+ actions[c.path] = PlannedFile(
168
+ c.path, PlanAction.REUSE, "unchanged and unaffected"
169
+ )
170
+ else:
171
+ actions[c.path] = PlannedFile(
172
+ c.path, PlanAction.REPARSE, f"{c.kind}: {c.reason}"
173
+ )
174
+ affected.add(c.path)
175
+ return actions, affected
176
+
177
+ def _propagate_dependents(
178
+ self,
179
+ changeset: ChangeSet,
180
+ prior_import_edges: list[tuple[str, str | None, str, str]],
181
+ actions: dict[str, PlannedFile],
182
+ affected: set[str],
183
+ ) -> None:
184
+ """2. fechamento transitivo de dependentes via arestas IMPORTS
185
+ resolvidas — cobre cadeias de re-export e ciclos (visitados)."""
186
+ del changeset
187
+ imported_to_importers: dict[str, set[str]] = {}
188
+ for importer, imported, _text, _status in prior_import_edges:
189
+ if imported:
190
+ imported_to_importers.setdefault(imported, set()).add(importer)
191
+
192
+ frontier = set(affected)
193
+ seen = set(affected)
194
+ while frontier:
195
+ target = frontier.pop()
196
+ for importer in sorted(imported_to_importers.get(target, ())):
197
+ if importer in seen:
198
+ continue
199
+ seen.add(importer)
200
+ cur = actions.get(importer)
201
+ if cur is not None and cur.action in (
202
+ PlanAction.REPARSE, PlanAction.DROP,
203
+ ):
204
+ continue # já coberto por ação mais forte
205
+ actions[importer] = PlannedFile(
206
+ importer, PlanAction.REVALIDATE_RELATIONS,
207
+ f"imports changed module {target}",
208
+ )
209
+ frontier.add(importer)
210
+
211
+ def _revalidate_unresolved(
212
+ self,
213
+ changeset: ChangeSet,
214
+ prior_import_edges: list[tuple[str, str | None, str, str]],
215
+ actions: dict[str, PlannedFile],
216
+ affected: set[str],
217
+ ) -> None:
218
+ """3. imports sem alvo persistido: texto → candidatos; mudança
219
+ num candidato pode mudar o resultado da resolução."""
220
+ del changeset
221
+ for importer, imported, text, _status in prior_import_edges:
222
+ if imported or not text:
223
+ continue
224
+ hits = _candidate_paths(importer, text) & affected
225
+ if hits:
226
+ hit = sorted(hits)[0]
227
+ cur = actions.get(importer)
228
+ if cur is None or cur.action == PlanAction.REUSE:
229
+ actions[importer] = PlannedFile(
230
+ importer, PlanAction.REVALIDATE_RELATIONS,
231
+ f"unresolved import '{text}' may now match {hit}",
232
+ )
233
+
234
+ def _revalidate_incomplete(
235
+ self,
236
+ changeset: ChangeSet,
237
+ prior_status: dict[str, str],
238
+ actions: dict[str, PlannedFile],
239
+ affected: set[str],
240
+ ) -> None:
241
+ """4. dependências incompletas → conservador: parse parcial/falho
242
+ no snapshot anterior é reavaliado quando existe mudança real."""
243
+ if not affected:
244
+ return
245
+ for c in changeset.changes:
246
+ if c.kind != ChangeKind.UNCHANGED:
247
+ continue
248
+ st = prior_status.get(c.path)
249
+ if st in ("parse-failed", "parsed-with-errors", "unparsed"):
250
+ actions[c.path] = PlannedFile(
251
+ c.path, PlanAction.REVALIDATE_RELATIONS,
252
+ f"incomplete dependency info (previous {st})",
253
+ )
254
+
255
+ def _revalidate_resolver_bump(
256
+ self, changeset: ChangeSet, actions: dict[str, PlannedFile]
257
+ ) -> None:
258
+ """5. bump de resolver → reavalia relações dos reaproveitados."""
259
+ if not changeset.resolver_changes:
260
+ return
261
+ for c in changeset.changes:
262
+ cur = actions.get(c.path)
263
+ if cur is not None and cur.action == PlanAction.REUSE:
264
+ actions[c.path] = PlannedFile(
265
+ c.path, PlanAction.REVALIDATE_RELATIONS,
266
+ "resolver version changed "
267
+ f"({', '.join(changeset.resolver_changes)})",
268
+ )
@@ -0,0 +1,436 @@
1
+ """Persistência do GraphBuildResult nas tabelas do Blueprint §6.
2
+
3
+ Projeção: nós com ``path`` real (file/module/symbol kinds) viram
4
+ ``symbols`` + ``symbol_occurrences``; ``package``/``repository`` são
5
+ deriváveis dos paths e não ganham ocorrência. Arestas persistem quando
6
+ ``from_id`` tem ocorrência mapeada — CONTAINS estruturais entre
7
+ repo/package/file são deriváveis via ``file_inventory`` + paths.
8
+
9
+ Decisão registrada (lf-0011): relações são entre ocorrências, conforme
10
+ ``cross_snapshot_relations`` exige endpoints no mesmo snapshot.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from collections.abc import Mapping
17
+
18
+ from code_atlas.contracts.enums import NodeKind
19
+ from code_atlas.domains.contracts import (
20
+ DomainExtractionResult,
21
+ ExtractedDomainEntity,
22
+ ExtractedDomainRelation,
23
+ )
24
+ from code_atlas.domains.model import DomainEntity
25
+ from code_atlas.parsers.contracts import GraphBuildResult, SourceSpan
26
+ from code_atlas.search.tokenizer import index_terms
27
+ from code_atlas.storage.store import Store
28
+
29
+ # Docstring truncada no índice FTS — não copiamos o repo integral.
30
+ _DOCSTRING_FTS_MAX = 4000
31
+
32
+ # Kinds que ganham símbolo+ocorrência persistida.
33
+ _PERSISTED_KINDS = frozenset(
34
+ {
35
+ NodeKind.FILE, NodeKind.MODULE, NodeKind.CLASS, NodeKind.INTERFACE,
36
+ NodeKind.FUNCTION, NodeKind.METHOD, NodeKind.SYMBOL, NodeKind.CONFIG,
37
+ NodeKind.UNKNOWN,
38
+ }
39
+ )
40
+
41
+
42
+ def _span_json(span: SourceSpan | None) -> str | None:
43
+ if span is None:
44
+ return None
45
+ return json.dumps(
46
+ [
47
+ span.start_byte, span.end_byte,
48
+ span.start_line, span.start_col, span.end_line, span.end_col,
49
+ ]
50
+ )
51
+
52
+
53
+ def _persist_evidence(
54
+ store: Store,
55
+ snapshot_id: int,
56
+ file_ids: Mapping[str, int],
57
+ graph: GraphBuildResult | DomainExtractionResult,
58
+ ) -> dict[int, int]:
59
+ """Evidências → rows; retorna mapa índice-em-graph.evidence → row_id.
60
+
61
+ Mapa explícito (lf-0015): evidências filtradas (path fora do
62
+ inventário) NÃO podem deslocar os índices das demais — o modelo
63
+ posicional compactado corrompia silenciosamente a associação
64
+ evidence↔relation.
65
+ """
66
+ evi_row_ids: dict[int, int] = {}
67
+ rows: list[tuple[object, ...]] = []
68
+ order: list[int] = [] # índice em graph.evidence alinhado a `rows`
69
+ for i, rec in enumerate(graph.evidence):
70
+ fid = file_ids.get(rec.path)
71
+ if fid is None:
72
+ continue
73
+ rows.append(
74
+ (snapshot_id, fid, rec.path, _span_json(rec.span),
75
+ rec.content_hash, rec.extractor, rec.extractor_version,
76
+ rec.notes, rec.resolver_id, rec.resolver_version,
77
+ rec.evidence_tier, rec.index_hash, rec.validation_status)
78
+ )
79
+ order.append(i)
80
+ for idx, rid in zip(order, store.insert_evidence_batch(rows), strict=True):
81
+ evi_row_ids[idx] = rid
82
+ return evi_row_ids
83
+
84
+
85
+ def persist_graph(
86
+ store: Store,
87
+ repo_id: int,
88
+ snapshot_id: int,
89
+ file_ids: Mapping[str, int],
90
+ graph: GraphBuildResult,
91
+ *,
92
+ fts5: bool,
93
+ file_status: Mapping[str, str] | None = None,
94
+ ) -> dict[str, int]:
95
+ """Grava evidências, símbolos, ocorrências e relações — na transação
96
+ corrente do caller (atomicidade é de quem chama).
97
+
98
+ ``file_status`` mapeia path → parse_status da ocorrência
99
+ (``parsed`` | ``parsed-with-errors`` | ``parse-failed``).
100
+ """
101
+ counts = {
102
+ "evidence": 0, "symbols_new": 0, "occurrences": 0,
103
+ "relations": 0, "edges_skipped": 0,
104
+ }
105
+ status = file_status or {}
106
+
107
+ # 1. evidências primeiro — relations referenciam ids de evidência.
108
+ evi_row_ids = _persist_evidence(store, snapshot_id, file_ids, graph)
109
+ counts["evidence"] = len(evi_row_ids)
110
+
111
+ # 2. nós → símbolo + ocorrência (id do nó → id da ocorrência).
112
+ # lf-0023: dedup em duas fases + multi-row INSERT…RETURNING — o
113
+ # per-row upsert/insert era o custo dominante (~145k round-trips
114
+ # num corpus de 5k arquivos).
115
+ persisted = [
116
+ n for n in graph.nodes
117
+ if n.kind in _PERSISTED_KINDS and n.path in file_ids
118
+ ]
119
+ sym_ids, created = store.ensure_symbols(
120
+ repo_id,
121
+ [(n.language, n.qualified_name, str(n.kind)) for n in persisted],
122
+ )
123
+ counts["symbols_new"] = len(created)
124
+ if fts5 and created:
125
+ store.insert_symbols_batch(
126
+ (sym_ids[k], k[1], k[0], k[2]) for k in sorted(created)
127
+ )
128
+ node_occ: dict[str, int] = {}
129
+ occ_rows: list[tuple[object, ...]] = []
130
+ occ_nodes: list[str] = [] # node_id alinhado a occ_rows
131
+ for n in persisted:
132
+ span = n.span
133
+ occ_rows.append(
134
+ (
135
+ snapshot_id, sym_ids[(n.language, n.qualified_name, str(n.kind))],
136
+ file_ids[n.path],
137
+ span.start_byte if span else None,
138
+ span.end_byte if span else None,
139
+ span.start_line if span else None,
140
+ span.start_col if span else None,
141
+ n.content_hash,
142
+ status.get(n.path, "parsed"),
143
+ )
144
+ )
145
+ occ_nodes.append(n.node_id)
146
+ for nid, rid in zip(
147
+ occ_nodes, store.insert_occurrences_batch(occ_rows), strict=True
148
+ ):
149
+ node_occ[nid] = rid
150
+ counts["occurrences"] = len(node_occ)
151
+
152
+ # 2b. FTS por-ocorrência (lf-0036) — snapshot-scoped; rowid = id da
153
+ # ocorrência. ``terms`` recebe identificadores pré-tokenizados;
154
+ # docstring truncada (não copiamos o repo integral).
155
+ if fts5 and node_occ:
156
+ node_by_id = {n.node_id: n for n in persisted}
157
+ fts_rows = [
158
+ (
159
+ occ_id,
160
+ snapshot_id,
161
+ (n := node_by_id[nid]).name
162
+ or n.qualified_name.rsplit(".", 1)[-1],
163
+ index_terms(
164
+ n.name, n.qualified_name, n.path
165
+ ),
166
+ n.signature or "",
167
+ (n.docstring or "")[:_DOCSTRING_FTS_MAX],
168
+ n.path,
169
+ n.language,
170
+ str(n.kind),
171
+ )
172
+ for nid, occ_id in node_occ.items()
173
+ ]
174
+ store.insert_fts_occurrences_batch(fts_rows)
175
+
176
+ # 3. relações — somente endpoints dentro do snapshot
177
+ rel_rows: list[tuple[object, ...]] = []
178
+ for e in graph.edges:
179
+ frm = node_occ.get(e.from_id)
180
+ if frm is None:
181
+ counts["edges_skipped"] += 1
182
+ continue
183
+ to = node_occ.get(e.to_id) if e.to_id is not None else None
184
+ if e.to_id is not None and to is None:
185
+ counts["edges_skipped"] += 1 # alvo estrutural não persistido
186
+ continue
187
+ ev_ids = json.dumps([evi_row_ids[i] for i in e.evidence_index
188
+ if i in evi_row_ids])
189
+ rel_rows.append(
190
+ (
191
+ snapshot_id, frm, to, e.to_text or None, str(e.type),
192
+ e.resolution_status, e.confidence_class, e.resolver, ev_ids,
193
+ )
194
+ )
195
+ store.insert_relations(rel_rows)
196
+ counts["relations"] = len(rel_rows)
197
+ return counts
198
+
199
+
200
+ # --- persistência de domínio (lf-0110, schema v9 / ADR-0010) -----------
201
+
202
+
203
+ def _domain_occ_for_identity(
204
+ store: Store,
205
+ repo_id: int,
206
+ snapshot_id: int,
207
+ identity_key: str,
208
+ occ_by_key: dict[str, int],
209
+ ) -> int | None:
210
+ """Ocorrência de uma entidade de domínio neste snapshot.
211
+
212
+ Primeiro olha as ocorrências inseridas nesta persist; depois cai para
213
+ ocorrências já gravadas no snapshot (entidade extraída por outro
214
+ adapter — base da resolução cross-domain de lf-0117). Determinístico:
215
+ sempre o menor id disponível.
216
+ """
217
+ if identity_key in occ_by_key:
218
+ return occ_by_key[identity_key]
219
+ entity_id = store.domain_entity_id_for(repo_id, identity_key)
220
+ if entity_id is None:
221
+ return None
222
+ occs = store.domain_occurrences_for(snapshot_id, entity_id)
223
+ return occs[0] if occs else None
224
+
225
+
226
+ def _resolve_domain_target(
227
+ store: Store,
228
+ repo_id: int,
229
+ snapshot_id: int,
230
+ rel: ExtractedDomainRelation,
231
+ occ_by_key: dict[str, int],
232
+ ) -> tuple[int | None, int | None, str | None, str]:
233
+ """Resolve o alvo da relação → (to_entity_occ, to_symbol_occ, unresolved, status).
234
+
235
+ - ``to_identity`` vinculada a ocorrência do snapshot → ``resolved``;
236
+ identidade conhecida fora do snapshot → ``external``; desconhecida
237
+ → ``unresolved`` com a chave canônica preservada como texto.
238
+ - ``to_symbol``: 1 match → ``resolved``; >1 → ``ambiguous``;
239
+ 0 → ``unresolved``.
240
+ - ``to_unresolved`` textual → ``unresolved``, salvo quando o adapter
241
+ declarou ``external`` explicitamente (alvo fora do snapshot).
242
+ """
243
+ status_override = rel.resolution.status if rel.resolution else None
244
+ if rel.to_identity is not None:
245
+ occ = _domain_occ_for_identity(
246
+ store, repo_id, snapshot_id, rel.to_identity.logical_key(),
247
+ occ_by_key,
248
+ )
249
+ if occ is not None:
250
+ return occ, None, None, "resolved"
251
+ text = rel.to_identity.display()
252
+ status = (
253
+ "external" if status_override == "external" else "unresolved"
254
+ )
255
+ return None, None, text, status
256
+ if rel.to_symbol:
257
+ occs = store.symbol_occurrences_for_name(snapshot_id, rel.to_symbol)
258
+ if len(occs) == 1:
259
+ return None, occs[0], None, "resolved"
260
+ if len(occs) > 1:
261
+ return None, None, rel.to_symbol, "ambiguous"
262
+ return None, None, rel.to_symbol, "unresolved"
263
+ status = "external" if status_override == "external" else "unresolved"
264
+ return None, None, rel.to_unresolved or None, status
265
+
266
+
267
+ def persist_domain_result(
268
+ store: Store,
269
+ repo_id: int,
270
+ snapshot_id: int,
271
+ file_ids: Mapping[str, int],
272
+ result: DomainExtractionResult,
273
+ ) -> dict[str, int]:
274
+ """Grava um ``DomainExtractionResult`` nas tabelas v9 — na transação
275
+ do caller, mesmo desenho de ``persist_graph``.
276
+
277
+ Entidades são dedupadas por ``identity_key`` lógica (estáveis por
278
+ repositório); ocorrências ligam entidade↔snapshot↔arquivo; relações
279
+ só persistem com origem vinculada a uma ocorrência do snapshot —
280
+ endpoint ausente conta em ``edges_skipped`` (nunca dangling).
281
+ """
282
+ counts = {
283
+ "evidence": 0, "entities": 0, "occurrences": 0,
284
+ "relations": 0, "edges_skipped": 0,
285
+ }
286
+
287
+ # 1. evidências — mesma tabela/envelope do grafo de código.
288
+ evi_row_ids = _persist_evidence(store, snapshot_id, file_ids, result)
289
+ counts["evidence"] = len(evi_row_ids)
290
+
291
+ # 2. entidades lógicas (dedup por identity_key) + ocorrências.
292
+ entity_rows = [
293
+ DomainEntity(
294
+ identity=e.identity, kind=e.kind, name=e.name,
295
+ attributes=e.attributes,
296
+ )
297
+ for e in result.entities
298
+ ]
299
+ ent_ids = store.ensure_domain_entities(repo_id, entity_rows)
300
+ counts["entities"] = len({e.identity.logical_key() for e in result.entities})
301
+
302
+ occ_rows: list[tuple[object, ...]] = []
303
+ occ_keys: list[str] = [] # logical_key alinhado a occ_rows
304
+ seen_occ: set[str] = set() # uma ocorrência por identidade/snapshot
305
+ for e in result.entities:
306
+ key = e.identity.logical_key()
307
+ if key in seen_occ:
308
+ continue
309
+ seen_occ.add(key)
310
+ entity_id = ent_ids.get(key)
311
+ if entity_id is None:
312
+ continue
313
+ span = e.span
314
+ occ_rows.append(
315
+ (
316
+ snapshot_id, entity_id, file_ids.get(e.path),
317
+ span.start_byte if span else None,
318
+ span.end_byte if span else None,
319
+ span.start_line if span else None,
320
+ span.start_col if span else None,
321
+ e.content_hash or None, "{}", "extracted",
322
+ )
323
+ )
324
+ occ_keys.append(key)
325
+ occ_by_key: dict[str, int] = {}
326
+ for key, rid in zip(
327
+ occ_keys, store.insert_domain_occurrences_batch(occ_rows),
328
+ strict=True,
329
+ ):
330
+ occ_by_key[key] = rid
331
+ counts["occurrences"] = len(occ_by_key)
332
+
333
+ # 2b. FTS de domínio (lf-0133, schema v12) — rowid = id da
334
+ # ocorrência, mesmo desenho de ``fts_occurrences``. ``terms``
335
+ # pré-tokenizado cobre snake/camel/dotted por fragmento; ``path``
336
+ # só entra quando a ocorrência ancorou num arquivo do inventário —
337
+ # mesma semântica do LIKE sobre ``sf.relative_path`` (LEFT JOIN
338
+ # NULL nunca casava). Snapshots anteriores à v12 não são
339
+ # retroalimentados aqui — ``Store.rebuild_domain_fts`` é o backfill.
340
+ if occ_by_key and store.domain_fts_available():
341
+ # mesma regra de ``seen_occ``: primeira entidade por
342
+ # logical_key — a que gerou a ocorrência persistida.
343
+ entity_by_key: dict[str, ExtractedDomainEntity] = {}
344
+ for e in result.entities:
345
+ entity_by_key.setdefault(e.identity.logical_key(), e)
346
+ fts_rows: list[tuple[object, ...]] = []
347
+ for key, occ_id in sorted(occ_by_key.items(), key=lambda kv: kv[1]):
348
+ e = entity_by_key[key]
349
+ path = e.path if e.path in file_ids else ""
350
+ fts_rows.append(
351
+ (
352
+ occ_id, snapshot_id, ent_ids[key],
353
+ e.name or "", e.identity.qualified_name,
354
+ index_terms(
355
+ e.name or "", e.identity.qualified_name, path
356
+ ),
357
+ path, e.identity.domain, str(e.kind),
358
+ )
359
+ )
360
+ store.insert_domain_fts_batch(fts_rows)
361
+
362
+ # 3. relações — endpoints podem ser entidade OU símbolo de código.
363
+ rel_rows: list[tuple[object, ...]] = []
364
+ for r in result.relations:
365
+ row = _domain_relation_row(
366
+ store, repo_id, snapshot_id, result, r, occ_by_key, evi_row_ids
367
+ )
368
+ if row is None:
369
+ counts["edges_skipped"] += 1 # origem sem âncora no snapshot
370
+ continue
371
+ rel_rows.append(row)
372
+ store.insert_domain_relations(rel_rows)
373
+ counts["relations"] = len(rel_rows)
374
+ return counts
375
+
376
+
377
+ def _domain_from_endpoint(
378
+ store: Store,
379
+ repo_id: int,
380
+ snapshot_id: int,
381
+ rel: ExtractedDomainRelation,
382
+ occ_by_key: dict[str, int],
383
+ ) -> tuple[int | None, int | None]:
384
+ """Endpoint de origem → (entity_occ_id, symbol_occ_id); (None, None)
385
+ quando nenhum âncora existe no snapshot — caller conta como skip."""
386
+ if rel.from_identity is not None:
387
+ occ = _domain_occ_for_identity(
388
+ store, repo_id, snapshot_id, rel.from_identity.logical_key(),
389
+ occ_by_key,
390
+ )
391
+ if occ is not None:
392
+ return occ, None
393
+ if rel.from_symbol:
394
+ occs = store.symbol_occurrences_for_name(snapshot_id, rel.from_symbol)
395
+ if len(occs) == 1:
396
+ return None, occs[0]
397
+ return None, None
398
+
399
+
400
+ def _domain_relation_row(
401
+ store: Store,
402
+ repo_id: int,
403
+ snapshot_id: int,
404
+ result: DomainExtractionResult,
405
+ rel: ExtractedDomainRelation,
406
+ occ_by_key: dict[str, int],
407
+ evi_row_ids: dict[int, int],
408
+ ) -> tuple[object, ...] | None:
409
+ """Monta a row de ``domain_relations``; ``None`` = origem sem âncora."""
410
+ frm_ent, frm_sym = _domain_from_endpoint(
411
+ store, repo_id, snapshot_id, rel, occ_by_key
412
+ )
413
+ if frm_ent is None and frm_sym is None:
414
+ return None
415
+ to_ent, to_sym, to_text, status = _resolve_domain_target(
416
+ store, repo_id, snapshot_id, rel, occ_by_key
417
+ )
418
+ confidence = (
419
+ str(rel.resolution.confidence_class)
420
+ if rel.resolution is not None
421
+ else "syntactic"
422
+ )
423
+ if confidence == "inferred" and status == "resolved":
424
+ raise ValueError("inferred evidence can never be reported resolved")
425
+ ev_ids = json.dumps(
426
+ [evi_row_ids[i] for i in rel.evidence if i in evi_row_ids]
427
+ )
428
+ resolver = (
429
+ rel.resolution.resolver
430
+ if rel.resolution is not None
431
+ else f"domain:{result.domain_id}"
432
+ )
433
+ return (
434
+ snapshot_id, frm_ent, frm_sym, to_ent, to_sym, to_text,
435
+ rel.relation_type, status, confidence, resolver, ev_ids,
436
+ )