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,760 @@
1
+ """Context packs semeados por entidade de domínio (lf-0121, Cycle 7 Ph13).
2
+
3
+ Mesma disciplina do ``ContextEngine``: seleção determinística sob
4
+ budget, nenhum corpo gerado por LLM — todo conteúdo vem de fatos
5
+ persistidos (``domain_entities``, ``domain_entity_occurrences``,
6
+ ``domain_relations``, ``evidence``, símbolos). Nada é re-extraído de
7
+ arquivo, nenhum código/job/terraform é executado, nenhuma conexão
8
+ externa é aberta — e todo read é ancorado em ``snapshot_id`` +
9
+ ``repository_id`` (isolamento por construção).
10
+
11
+ O pack é um ``ContextPack`` comum (envelope schema v2) com a seção
12
+ aditiva ``domain`` (schema próprio v1): cartão da entidade-semente,
13
+ vizinhos limitados de ``domain_relations`` (top-N por confiança),
14
+ contadores honestos por direção/status — incluindo alvos ``dynamic:``
15
+ e não-resolvidos —, amostras de alvos pendentes, o resumo determinístico
16
+ de ``domains.summarize`` e o contrato de re-consulta dos refs.
17
+
18
+ ``expansion_refs`` carregam refs re-consultáveis: ``dom:<identity_key>``
19
+ (entidade de domínio) ou ``sym:<qualified_name>`` (símbolo de código —
20
+ a ponte ``*_symbol_occ_id``). ``resolve_domain_ref`` é a função que um
21
+ handler ``/domains/entities`` chamaria; até a rota existir (wiring
22
+ documentado em ``docs/cycle-7-domain-context-packs.md``, spec lf-0123)
23
+ o contrato está declarado em ``DomainPackSection.requery``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ import json
30
+ import sqlite3
31
+ from dataclasses import dataclass, field
32
+ from typing import TYPE_CHECKING
33
+
34
+ from code_atlas.context.contracts import (
35
+ DOMAIN_PACK_SCHEMA_VERSION,
36
+ PRIORITY_DEP,
37
+ PRIORITY_DIRECT,
38
+ PRIORITY_ESSENTIAL,
39
+ Budget,
40
+ ContextItem,
41
+ ContextPack,
42
+ DomainEntityCard,
43
+ DomainNeighborRef,
44
+ DomainPackPart,
45
+ DomainPackRequest,
46
+ DomainPackSection,
47
+ ExpansionRef,
48
+ PackEvidence,
49
+ )
50
+ from code_atlas.context.engine import ContextEngine
51
+ from code_atlas.context.tokenizer import TokenCounter
52
+ from code_atlas.contracts.enums import ConfidenceClass, ResolutionStatus
53
+ from code_atlas.domains.contracts import confidence_rank
54
+ from code_atlas.domains.summarize import LineageFact, summarize_lineage
55
+ from code_atlas.storage.store import Store
56
+
57
+ if TYPE_CHECKING:
58
+ from collections.abc import Iterable, Mapping, Sequence
59
+ from typing import Any
60
+
61
+ # Prefixos que adapters usam como marcador de honestidade ("o alvo é
62
+ # dinâmico/ambiente — não é candidato estático"). Espelha
63
+ # ``crossresolve._SKIP_PREFIXES`` — mantido local para não importar nome
64
+ # privado; a semântica é a mesma: conta à parte, nunca pendência.
65
+ DYNAMIC_TARGET_PREFIXES = ("dynamic:", "${dynamic}", "environment:")
66
+
67
+ MAX_NEIGHBORS_CAP = 100 # teto duro do top-N
68
+ _SAMPLE_CAP = 10 # amostras de alvos não-resolvidos/ambíguos
69
+ _EVIDENCE_PER_ITEM = 6 # cap de PackEvidence por vizinho
70
+
71
+ # Contrato de re-consulta — declarado DENTRO do pack (spec lf-0121:
72
+ # "exact re-query contract documented in the pack itself").
73
+ DOMAIN_REQUERY_CONTRACT = (
74
+ "expansion_refs[].ref_id forms: 'dom:<identity_key>' resolves to a "
75
+ "domain entity of this repository — re-query contract (route to be "
76
+ "wired by lf-0123): GET /domains/entities"
77
+ "?identity_key=<ref>&repository_root=<root>&snapshot_id=<sid>; "
78
+ "engine-side backing function: "
79
+ "code_atlas.context.domain.resolve_domain_ref"
80
+ "(conn, repository_id, snapshot_id, ref). "
81
+ "'sym:<qualified_name>' resolves to a code-graph symbol of this "
82
+ "snapshot (structural search / ContextExpander, not /domains/entities)."
83
+ )
84
+
85
+ _RANKING_DOMAIN = (
86
+ "seed card (essential) > vizinhos de domínio por confiança "
87
+ "(resolved>syntactic>heuristic>inferred, desempate por status e ref); "
88
+ "vizinhos além do top-N viram expansion_refs re-consultáveis")
89
+
90
+ # Status da seção — a palavra honesta do build.
91
+ _STATUS_OK = "ok"
92
+ _STATUS_ABSENT = "absent"
93
+ _STATUS_NOT_FOUND = "seed-not-found"
94
+ _STATUS_AMBIGUOUS = "seed-ambiguous"
95
+ _STATUS_NOT_IN_SNAP = "seed-not-in-snapshot"
96
+
97
+ _STATUS_RANK = {
98
+ str(ResolutionStatus.RESOLVED): 0,
99
+ str(ResolutionStatus.EXTERNAL): 1,
100
+ str(ResolutionStatus.AMBIGUOUS): 2,
101
+ str(ResolutionStatus.UNRESOLVED): 3,
102
+ }
103
+
104
+
105
+ def _is_dynamic(text: str) -> bool:
106
+ return text.strip().startswith(DYNAMIC_TARGET_PREFIXES)
107
+
108
+
109
+ def _entity_row(
110
+ conn: sqlite3.Connection, repo_id: int, entity_id: int
111
+ ) -> dict[str, Any] | None:
112
+ """Row de ``domain_entities`` do PRÓPRIO repositório — isolamento."""
113
+ row = conn.execute(
114
+ "SELECT id, domain, kind, identity_key, qualified_name, name,"
115
+ " catalog, namespace, environment, attributes"
116
+ " FROM domain_entities WHERE id = ? AND repository_id = ?",
117
+ (entity_id, repo_id),
118
+ ).fetchone()
119
+ return dict(row) if row is not None else None
120
+
121
+
122
+ def _card(
123
+ entity: Mapping[str, Any],
124
+ occs: Sequence[Mapping[str, Any]],
125
+ repository_id: int,
126
+ snapshot_id: int,
127
+ ) -> DomainEntityCard:
128
+ """Cartão da entidade ancorado na primeira ocorrência do snapshot."""
129
+ first = occs[0]
130
+ return DomainEntityCard(
131
+ entity_id=int(entity["id"]),
132
+ occurrence_id=int(first["occ_id"]),
133
+ occurrence_count=len(occs),
134
+ domain=str(entity["domain"]),
135
+ kind=str(entity["kind"]),
136
+ name=str(entity["name"]),
137
+ qualified_name=str(entity["qualified_name"]),
138
+ identity_key=str(entity["identity_key"]),
139
+ repository_id=repository_id,
140
+ snapshot_id=snapshot_id,
141
+ catalog=str(entity["catalog"]),
142
+ namespace=str(entity["namespace"]),
143
+ environment=str(entity["environment"]),
144
+ source_path=str(first["source_path"] or ""),
145
+ extraction_status=str(first["extraction_status"]),
146
+ )
147
+
148
+
149
+ def resolve_domain_ref(
150
+ conn: sqlite3.Connection,
151
+ repository_id: int,
152
+ snapshot_id: int,
153
+ ref: str,
154
+ ) -> DomainEntityCard | None:
155
+ """Resolve um ref de expansão para o cartão da entidade — a função
156
+ que um handler ``GET /domains/entities`` chamaria.
157
+
158
+ Formas: ``dom:<identity_key>`` (chave lógica canônica) ou
159
+ ``entity:<row_id>``. ``None`` para ref malformado, entidade de outro
160
+ repositório ou entidade sem ocorrência neste snapshot — nunca
161
+ fabrica identidade nem atravessa isolamento.
162
+ """
163
+ store = Store(conn)
164
+ entity_id: int | None = None
165
+ ref = ref.strip()
166
+ if ref.startswith("dom:"):
167
+ entity_id = store.domain_entity_id_for(repository_id, ref)
168
+ elif ref.startswith("entity:") and ref[len("entity:"):].isdigit():
169
+ entity_id = int(ref[len("entity:"):])
170
+ elif ref.isdigit():
171
+ entity_id = int(ref)
172
+ if entity_id is None:
173
+ return None
174
+ entity = _entity_row(conn, repository_id, entity_id)
175
+ if entity is None:
176
+ return None
177
+ occs = [
178
+ dict(r) for r in store.domain_occurrences_snapshot(snapshot_id)
179
+ if int(r["entity_id"]) == entity_id
180
+ ]
181
+ if not occs:
182
+ return None
183
+ return _card(entity, occs, repository_id, snapshot_id)
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class _SeedHit:
188
+ """Desfecho da resolução da seed dentro do snapshot."""
189
+
190
+ status: str
191
+ entity: Mapping[str, Any] | None = None
192
+ occs: tuple[Mapping[str, Any], ...] = ()
193
+ candidates: tuple[str, ...] = ()
194
+
195
+
196
+ @dataclass
197
+ class _Neighbor:
198
+ """Arestas deduplicadas por vizinho (entidade ou símbolo)."""
199
+
200
+ ref: str # dom:… | sym:<qname>
201
+ kind: str # "entity" | "symbol"
202
+ entity_kind: str
203
+ display: str
204
+ source_path: str = ""
205
+ directions: set[str] = field(default_factory=set)
206
+ relation_types: set[str] = field(default_factory=set)
207
+ best_rank: int = -1 # confidence_rank máx.
208
+ best_status_rank: int = 99 # _STATUS_RANK da aresta mais forte
209
+ best_status: str = ""
210
+ best_confidence: str = ""
211
+ first_relation_id: int = 1 << 62
212
+ edge_count: int = 0
213
+ evidence_ids: set[int] = field(default_factory=set)
214
+ occ_paths: set[str] = field(default_factory=set)
215
+
216
+ def direction(self) -> str:
217
+ if self.directions == {"incoming"}:
218
+ return "incoming"
219
+ if self.directions == {"outgoing"}:
220
+ return "outgoing"
221
+ return "both"
222
+
223
+ def touch(
224
+ self, *, direction: str, relation_type: str, relation_id: int,
225
+ status: str, confidence: str, evidence: Iterable[int],
226
+ ) -> None:
227
+ self.directions.add(direction)
228
+ self.relation_types.add(relation_type)
229
+ self.edge_count += 1
230
+ self.first_relation_id = min(self.first_relation_id, relation_id)
231
+ self.evidence_ids.update(int(e) for e in evidence)
232
+ rank = confidence_rank(ConfidenceClass(confidence))
233
+ srank = _STATUS_RANK.get(status, 9)
234
+ if (rank, -srank) > (self.best_rank, -self.best_status_rank):
235
+ self.best_rank = rank
236
+ self.best_status_rank = srank
237
+ self.best_status = status
238
+ self.best_confidence = confidence
239
+
240
+ def order_key(self) -> tuple[int, int, int, str]:
241
+ """Top-N por confiança: rank desc → status melhor → id estável."""
242
+ return (-self.best_rank, self.best_status_rank,
243
+ self.first_relation_id, self.ref)
244
+
245
+ def ref_view(self, item_id: str) -> DomainNeighborRef:
246
+ return DomainNeighborRef(
247
+ item_id=item_id, ref=self.ref, kind=self.kind,
248
+ entity_kind=self.entity_kind, display=self.display,
249
+ direction=self.direction(),
250
+ relation_types=tuple(sorted(self.relation_types)),
251
+ resolution_status=self.best_status,
252
+ confidence_class=self.best_confidence,
253
+ edge_count=self.edge_count,
254
+ evidence_ids=tuple(sorted(self.evidence_ids)))
255
+
256
+
257
+ def _new_neighbor(
258
+ ref: str, occ_by_identity: Mapping[str, Mapping[str, Any]],
259
+ ) -> _Neighbor | None:
260
+ """Cria o vizinho a partir do ref — ``None`` quando a ocorrência
261
+ não está no snapshot (nunca fabrica identidade)."""
262
+ if ref.startswith("dom:"):
263
+ occ = occ_by_identity.get(ref)
264
+ if occ is None:
265
+ return None
266
+ return _Neighbor(
267
+ ref=ref, kind="entity", entity_kind=str(occ["kind"]),
268
+ display=str(occ["qualified_name"]),
269
+ source_path=str(occ["source_path"] or ""))
270
+ if ref.startswith("sym:") and ref[len("sym:"):]:
271
+ return _Neighbor(ref=ref, kind="symbol", entity_kind="symbol",
272
+ display=ref[len("sym:"):])
273
+ return None
274
+
275
+
276
+ class DomainContextEngine(ContextEngine):
277
+ """``ContextEngine`` semeado por entidade de domínio.
278
+
279
+ Reusa o maquinário de budget/seleção do engine base (``_budget``,
280
+ ``_fit``, ``_enforce_payload_budget``) — a seção ``domain`` entra no
281
+ envelope via ``domain_factory`` e é contada no payload estimado.
282
+ """
283
+
284
+ def __init__(
285
+ self, conn: sqlite3.Connection, counter: TokenCounter | None = None
286
+ ) -> None:
287
+ super().__init__(conn, counter)
288
+ self._dstore = Store(conn)
289
+
290
+ # -------------------------------------------------------- público
291
+ def build_domain(self, req: DomainPackRequest) -> ContextPack:
292
+ diags: list[str] = []
293
+ repo_id = self._structural.repo_for_root(req.repository_root)
294
+ if repo_id is None:
295
+ return self._absent(
296
+ req, f"repositório não indexado: {req.repository_root}")
297
+ snap, err = self._structural.resolve_snapshot(
298
+ repo_id, req.snapshot_id)
299
+ if snap is None or snap.id is None:
300
+ return self._absent(req, err or "nenhum snapshot READY")
301
+ sid = int(snap.id or 0)
302
+ budget = self._budget(req)
303
+ max_n = max(0, min(req.max_neighbors, MAX_NEIGHBORS_CAP))
304
+ ck = ("domain", sid, req.seed, req.level, budget.limit, max_n)
305
+ cached = self._pack_cache.get(ck)
306
+ if cached is not None:
307
+ return cached
308
+
309
+ occs = self._dstore.domain_occurrences_snapshot(sid)
310
+ if not occs:
311
+ pack = self._assemble(
312
+ req, sid, budget, card=None, neighbors=(), total=0,
313
+ counts=self._zero_counts(), unresolved=(), ambiguous=(),
314
+ diags=[*diags, "domain-absent",
315
+ "snapshot sem ocorrências de domínio — nenhum "
316
+ "adapter de domínio rodou ou snapshot anterior "
317
+ "à v9"],
318
+ status=_STATUS_ABSENT, snap_status=str(snap.status))
319
+ self._cache_put(ck, pack)
320
+ return pack
321
+
322
+ occ_by_entity: dict[int, list[dict[str, Any]]] = {}
323
+ occ_map: dict[int, dict[str, Any]] = {}
324
+ occ_by_identity: dict[str, dict[str, Any]] = {}
325
+ for row in occs:
326
+ o = dict(row)
327
+ occ_map[int(o["occ_id"])] = o
328
+ occ_by_entity.setdefault(int(o["entity_id"]), []).append(o)
329
+ occ_by_identity.setdefault(str(o["identity_key"]), o)
330
+
331
+ hit = self._resolve_seed(repo_id, req.seed, occ_by_entity)
332
+ if hit.status != _STATUS_OK or hit.entity is None:
333
+ diags.append(f"domain-{hit.status}")
334
+ pack = self._assemble(
335
+ req, sid, budget, card=None, neighbors=(), total=0,
336
+ counts=self._zero_counts(), unresolved=(), ambiguous=(),
337
+ diags=diags, status=hit.status,
338
+ candidates=hit.candidates,
339
+ snap_status=str(snap.status))
340
+ self._cache_put(ck, pack)
341
+ return pack
342
+
343
+ entity = hit.entity
344
+ seed_occ_ids = {int(o["occ_id"]) for o in hit.occs}
345
+ seed_ref = str(entity["identity_key"]) # já carrega "dom:"
346
+ card = _card(entity, list(hit.occs), repo_id, sid)
347
+
348
+ edges, counts, unresolved, ambiguous = self._seed_edges(
349
+ sid, seed_occ_ids, diags)
350
+ sym_names = self._symbol_names({
351
+ int(r[c]) for r in edges
352
+ for c in ("from_symbol_occ_id", "to_symbol_occ_id")
353
+ if r[c] is not None})
354
+ neighbors = self._neighbor_views(
355
+ edges, seed_occ_ids, seed_ref, occ_map, occ_by_identity,
356
+ sym_names)
357
+ ordered = sorted(neighbors.values(), key=_Neighbor.order_key)
358
+ shown, beyond = ordered[:max_n], ordered[max_n:]
359
+ if beyond:
360
+ diags.append(
361
+ f"domain-neighbors-capped:{len(shown)}/{len(ordered)}")
362
+ facts = self._lineage_facts(
363
+ edges, seed_occ_ids, seed_ref, str(entity["qualified_name"]),
364
+ occ_map, sym_names, neighbors)
365
+ pack = self._assemble(
366
+ req, sid, budget, card=card,
367
+ neighbors=tuple(shown), total=len(ordered),
368
+ counts=counts, unresolved=unresolved, ambiguous=ambiguous,
369
+ diags=diags, status=_STATUS_OK,
370
+ beyond=tuple(n.ref for n in beyond),
371
+ summary=summarize_lineage(self._subject(entity), facts),
372
+ snap_status=str(snap.status))
373
+ self._cache_put(ck, pack)
374
+ return pack
375
+
376
+ def _cache_put(
377
+ self, ck: tuple[object, ...], pack: ContextPack
378
+ ) -> None:
379
+ if len(self._pack_cache) >= 32:
380
+ self._pack_cache.pop(next(iter(self._pack_cache)))
381
+ self._pack_cache[ck] = pack
382
+
383
+ # -------------------------------------------------------- seed
384
+ def _resolve_seed(
385
+ self, repo_id: int, seed: str,
386
+ occ_by_entity: Mapping[int, Sequence[Mapping[str, Any]]],
387
+ ) -> _SeedHit:
388
+ """Formas: ``dom:``/``entity:<id>``/dígitos/``qualified_name``."""
389
+ s = seed.strip()
390
+ if not s:
391
+ return _SeedHit(status=_STATUS_NOT_FOUND)
392
+ entity: dict[str, Any] | None = None
393
+ if s.startswith("dom:"):
394
+ eid = self._dstore.domain_entity_id_for(repo_id, s)
395
+ if eid is not None:
396
+ entity = _entity_row(self._conn, repo_id, eid)
397
+ elif s.startswith("entity:") and s[len("entity:"):].isdigit():
398
+ entity = _entity_row(
399
+ self._conn, repo_id, int(s[len("entity:"):]))
400
+ elif s.isdigit():
401
+ entity = _entity_row(self._conn, repo_id, int(s))
402
+ else:
403
+ rows = self._conn.execute(
404
+ "SELECT id, domain, kind, identity_key, qualified_name,"
405
+ " name, catalog, namespace, environment, attributes"
406
+ " FROM domain_entities"
407
+ " WHERE repository_id = ? AND qualified_name = ?"
408
+ " ORDER BY identity_key",
409
+ (repo_id, s),
410
+ ).fetchall()
411
+ if len(rows) > 1:
412
+ return _SeedHit(
413
+ status=_STATUS_AMBIGUOUS,
414
+ candidates=tuple(str(r["identity_key"]) for r in rows))
415
+ if rows:
416
+ entity = dict(rows[0])
417
+ if entity is None:
418
+ return _SeedHit(status=_STATUS_NOT_FOUND)
419
+ occs = occ_by_entity.get(int(entity["id"]), ())
420
+ if not occs:
421
+ return _SeedHit(status=_STATUS_NOT_IN_SNAP, entity=entity)
422
+ return _SeedHit(status=_STATUS_OK, entity=entity, occs=tuple(occs))
423
+
424
+ # -------------------------------------------------------- arestas
425
+ def _seed_edges(
426
+ self, sid: int, seed_occ_ids: set[int], diags: list[str]
427
+ ) -> tuple[
428
+ list[dict[str, Any]], dict[str, int],
429
+ tuple[str, ...], tuple[str, ...],
430
+ ]:
431
+ """Arestas do snapshot que tocam a seed + contadores honestos.
432
+
433
+ ``unresolved`` conta só pendências reais — alvos ``dynamic:``
434
+ (marcador de adapter) contam em ``dynamic`` à parte."""
435
+ counts = self._zero_counts()
436
+ edges: list[dict[str, Any]] = []
437
+ unresolved: list[str] = []
438
+ ambiguous: list[str] = []
439
+ for r in self._dstore.domain_relations_snapshot(sid):
440
+ fe, te = r["from_entity_occ_id"], r["to_entity_occ_id"]
441
+ out = fe is not None and int(fe) in seed_occ_ids
442
+ inc = te is not None and int(te) in seed_occ_ids
443
+ if not out and not inc:
444
+ continue
445
+ edges.append(dict(r))
446
+ if out:
447
+ counts["outgoing"] += 1
448
+ if inc:
449
+ counts["incoming"] += 1
450
+ self._count_status(
451
+ counts, str(r["resolution_status"]),
452
+ str(r["to_unresolved"] or ""), unresolved, ambiguous)
453
+ for key in ("resolved", "ambiguous", "unresolved", "external",
454
+ "dynamic"):
455
+ if counts[key]:
456
+ diags.append(f"domain-{key}:{counts[key]}")
457
+ return edges, counts, tuple(unresolved), tuple(ambiguous)
458
+
459
+ @staticmethod
460
+ def _count_status(
461
+ counts: dict[str, int], status: str, target: str,
462
+ unresolved: list[str], ambiguous: list[str],
463
+ ) -> None:
464
+ """Contador por status; ``dynamic:`` conta à parte de
465
+ ``unresolved`` (marcador de adapter, não pendência)."""
466
+ if status == str(ResolutionStatus.UNRESOLVED):
467
+ if _is_dynamic(target):
468
+ counts["dynamic"] += 1
469
+ else:
470
+ counts["unresolved"] += 1
471
+ if target and len(unresolved) < _SAMPLE_CAP:
472
+ unresolved.append(target)
473
+ elif status == str(ResolutionStatus.AMBIGUOUS):
474
+ counts["ambiguous"] += 1
475
+ if target and len(ambiguous) < _SAMPLE_CAP:
476
+ ambiguous.append(target)
477
+ else:
478
+ counts[status] = counts.get(status, 0) + 1
479
+
480
+ @staticmethod
481
+ def _zero_counts() -> dict[str, int]:
482
+ return {"incoming": 0, "outgoing": 0, "resolved": 0,
483
+ "ambiguous": 0, "unresolved": 0, "external": 0,
484
+ "dynamic": 0}
485
+
486
+ # -------------------------------------------------------- vizinhos
487
+ def _neighbor_views(
488
+ self, edges: list[dict[str, Any]], seed_occ_ids: set[int],
489
+ seed_ref: str,
490
+ occ_map: Mapping[int, Mapping[str, Any]],
491
+ occ_by_identity: Mapping[str, Mapping[str, Any]],
492
+ sym_names: Mapping[int, str],
493
+ ) -> dict[str, _Neighbor]:
494
+ """Agrupa arestas por vizinho — entidade (``dom:``) ou símbolo
495
+ de código (``sym:``). Arestas para ``to_unresolved`` não têm
496
+ vizinho (são contagens/alvos pendentes, nunca itens); arestas
497
+ seed↔seed contam mas não viram vizinho (uma entidade não é
498
+ vizinha de si mesma)."""
499
+ neighbors: dict[str, _Neighbor] = {}
500
+ for r in edges:
501
+ fe, te = r["from_entity_occ_id"], r["to_entity_occ_id"]
502
+ out = fe is not None and int(fe) in seed_occ_ids
503
+ inc = te is not None and int(te) in seed_occ_ids
504
+ rtype = str(r["type"])
505
+ status = str(r["resolution_status"])
506
+ conf = str(r["confidence_class"])
507
+ try:
508
+ evidence = tuple(
509
+ int(e) for e in json.loads(str(r["evidence_ids"])))
510
+ except (ValueError, TypeError):
511
+ evidence = ()
512
+ rel_id = int(r["id"])
513
+ sides: list[tuple[str, str]] = []
514
+ if out:
515
+ sides.append(("outgoing", self._other_ref(
516
+ r["to_entity_occ_id"], r["to_symbol_occ_id"],
517
+ occ_map, sym_names)))
518
+ if inc:
519
+ sides.append(("incoming", self._other_ref(
520
+ r["from_entity_occ_id"], r["from_symbol_occ_id"],
521
+ occ_map, sym_names)))
522
+ for direction, other in sides:
523
+ if not other or other == seed_ref:
524
+ continue
525
+ nb = neighbors.get(other)
526
+ if nb is None:
527
+ nb = _new_neighbor(other, occ_by_identity)
528
+ if nb is None:
529
+ continue # occ fora do snapshot — nunca fabrica
530
+ neighbors[other] = nb
531
+ nb.touch(direction=direction, relation_type=rtype,
532
+ relation_id=rel_id, status=status,
533
+ confidence=conf, evidence=evidence)
534
+ return neighbors
535
+
536
+ @staticmethod
537
+ def _other_ref(
538
+ entity_occ: object, symbol_occ: object,
539
+ occ_map: Mapping[int, Mapping[str, Any]],
540
+ sym_names: Mapping[int, str],
541
+ ) -> str:
542
+ """Ref re-consultável do outro endpoint ("" quando não há)."""
543
+ if isinstance(entity_occ, int):
544
+ occ = occ_map.get(entity_occ)
545
+ if occ is not None:
546
+ return str(occ["identity_key"]) # já carrega "dom:"
547
+ if isinstance(symbol_occ, int):
548
+ qn = sym_names.get(symbol_occ, "")
549
+ if qn:
550
+ return f"sym:{qn}"
551
+ return ""
552
+
553
+ def _symbol_names(self, occ_ids: set[int]) -> dict[int, str]:
554
+ """``occ_id`` → ``qualified_name`` — batched, sem N+1."""
555
+ if not occ_ids:
556
+ return {}
557
+ rows = self._conn.execute(
558
+ "SELECT so.id, s.qualified_name FROM symbol_occurrences so"
559
+ " JOIN symbols s ON s.id = so.logical_symbol_id"
560
+ " WHERE so.id IN (SELECT value FROM json_each(?))",
561
+ (json.dumps(sorted(occ_ids)),),
562
+ ).fetchall()
563
+ return {int(r["id"]): str(r["qualified_name"]) for r in rows}
564
+
565
+ # -------------------------------------------------------- fatos/items
566
+ def _lineage_facts(
567
+ self, edges: list[dict[str, Any]], seed_occ_ids: set[int],
568
+ seed_ref: str, seed_display: str,
569
+ occ_map: Mapping[int, Mapping[str, Any]],
570
+ sym_names: Mapping[int, str],
571
+ neighbors: Mapping[str, _Neighbor],
572
+ ) -> list[LineageFact]:
573
+ """Rows → ``LineageFact`` para o resumo determinístico."""
574
+ facts: list[LineageFact] = []
575
+ for r in edges:
576
+ fe, te = r["from_entity_occ_id"], r["to_entity_occ_id"]
577
+ out = fe is not None and int(fe) in seed_occ_ids
578
+ inc = te is not None and int(te) in seed_occ_ids
579
+ rtype = str(r["type"])
580
+ status = str(r["resolution_status"])
581
+ target = str(r["to_unresolved"] or "")
582
+ dynamic = _is_dynamic(target)
583
+ if out:
584
+ other = self._other_ref(
585
+ r["to_entity_occ_id"], r["to_symbol_occ_id"],
586
+ occ_map, sym_names)
587
+ nb = neighbors.get(other)
588
+ if other == seed_ref:
589
+ label = seed_display
590
+ elif nb is not None:
591
+ label = nb.display
592
+ else:
593
+ label = target or "(no target)"
594
+ facts.append(LineageFact(
595
+ relation_type=rtype, direction="outgoing",
596
+ label=label, resolution_status=status,
597
+ neighbor_kind=nb.entity_kind if nb else "",
598
+ dynamic=dynamic))
599
+ if inc:
600
+ other = self._other_ref(
601
+ r["from_entity_occ_id"], r["from_symbol_occ_id"],
602
+ occ_map, sym_names)
603
+ nb = neighbors.get(other)
604
+ if other == seed_ref:
605
+ label = seed_display
606
+ elif nb is not None:
607
+ label = nb.display
608
+ else:
609
+ label = "(unresolved)"
610
+ facts.append(LineageFact(
611
+ relation_type=rtype, direction="incoming",
612
+ label=label, resolution_status=status,
613
+ neighbor_kind=nb.entity_kind if nb else "",
614
+ dynamic=dynamic))
615
+ return facts
616
+
617
+ @staticmethod
618
+ def _subject(entity: Mapping[str, Any]) -> str:
619
+ """Rótulo da seed no resumo — espelha ``DomainIdentity.display``."""
620
+ head = ".".join(p for p in (
621
+ str(entity["catalog"]), str(entity["namespace"])) if p)
622
+ qn = str(entity["qualified_name"])
623
+ disp = f"{head}.{qn}" if head else qn
624
+ env = str(entity["environment"])
625
+ if env:
626
+ disp = f"{disp}@{env}"
627
+ return f"{entity['kind']} {disp}"
628
+
629
+ def _items(
630
+ self, card: DomainEntityCard,
631
+ neighbors: tuple[_Neighbor, ...],
632
+ evidence_map: Mapping[int, str],
633
+ ) -> list[ContextItem]:
634
+ body = "\n".join(
635
+ ln for ln in (
636
+ f"kind: {card.kind}",
637
+ f"domain: {card.domain}",
638
+ f"identity: {card.identity_key}",
639
+ f"qualified_name: {card.qualified_name}",
640
+ f"catalog: {card.catalog}" if card.catalog else "",
641
+ f"namespace: {card.namespace}" if card.namespace else "",
642
+ f"environment: @{card.environment}"
643
+ if card.environment else "",
644
+ f"source: {card.source_path}" if card.source_path else "",
645
+ f"occurrences: {card.occurrence_count}",
646
+ ) if ln)
647
+ items = [ContextItem(
648
+ item_id=f"dcard:{card.identity_key}", kind="domain-entity",
649
+ priority=PRIORITY_ESSENTIAL, title=card.qualified_name,
650
+ body=body, est_tokens=0, path=card.source_path,
651
+ evidence=(PackEvidence(kind="domain-entity",
652
+ ref=card.identity_key),
653
+ PackEvidence(kind="domain-occurrence",
654
+ ref=str(card.occurrence_id))),
655
+ expandable=False)]
656
+ for nb in neighbors:
657
+ ev = [PackEvidence(kind="domain-relation",
658
+ ref=str(nb.first_relation_id))]
659
+ for eid in sorted(nb.evidence_ids)[:_EVIDENCE_PER_ITEM]:
660
+ ev.append(PackEvidence(kind="evidence", ref=str(eid),
661
+ detail=evidence_map.get(eid, "")))
662
+ rel_desc = ", ".join(
663
+ f"{t} ({nb.direction()})"
664
+ for t in sorted(nb.relation_types))
665
+ nbody = "\n".join((
666
+ f"{nb.entity_kind} {nb.display}",
667
+ f"ref: {nb.ref}",
668
+ f"relations: {rel_desc} — {nb.best_status}/"
669
+ f"{nb.best_confidence} x{nb.edge_count}",
670
+ ))
671
+ items.append(ContextItem(
672
+ item_id=nb.ref, kind="domain-neighbor",
673
+ priority=PRIORITY_DEP, title=nb.display, body=nbody,
674
+ est_tokens=0,
675
+ symbol_key=(nb.display if nb.kind == "symbol" else ""),
676
+ path=nb.source_path,
677
+ evidence=tuple(ev), expandable=True))
678
+ return items
679
+
680
+ def _evidence_paths(self, ids: set[int]) -> dict[int, str]:
681
+ if not ids:
682
+ return {}
683
+ rows = self._conn.execute(
684
+ "SELECT id, path FROM evidence"
685
+ " WHERE id IN (SELECT value FROM json_each(?))",
686
+ (json.dumps(sorted(ids)),),
687
+ ).fetchall()
688
+ return {int(r["id"]): str(r["path"]) for r in rows}
689
+
690
+ # -------------------------------------------------------- assemble
691
+ def _assemble(
692
+ self, req: DomainPackRequest, sid: int, budget: Budget, *,
693
+ card: DomainEntityCard | None, neighbors: tuple[_Neighbor, ...],
694
+ total: int, counts: Mapping[str, int],
695
+ unresolved: tuple[str, ...], ambiguous: tuple[str, ...],
696
+ diags: list[str], status: str, snap_status: str,
697
+ candidates: tuple[str, ...] = (),
698
+ beyond: tuple[str, ...] = (),
699
+ summary: str = "",
700
+ ) -> ContextPack:
701
+ """Empilha itens + fábrica de seção no maquinário de budget."""
702
+ ev_map = self._evidence_paths({
703
+ e for nb in neighbors for e in nb.evidence_ids})
704
+ items = (self._items(card, neighbors, ev_map)
705
+ if card is not None else [])
706
+ items = self._dedup(self._estimate(items))
707
+ items.sort(key=lambda i: (i.priority, i.item_id))
708
+ selected, omitted = self._fit(items, budget.available_items)
709
+ essentials = sum(
710
+ i.est_tokens for i in items if i.priority <= PRIORITY_DIRECT)
711
+ insufficient = len(selected) < len(
712
+ [i for i in items if i.priority <= PRIORITY_DIRECT])
713
+
714
+ neighbor_by_ref = {nb.ref: nb for nb in neighbors}
715
+
716
+ def factory(sel: tuple[ContextItem, ...]) -> DomainPackPart:
717
+ chosen = [
718
+ neighbor_by_ref[i.item_id].ref_view(i.item_id)
719
+ for i in sel if i.item_id in neighbor_by_ref]
720
+ section = DomainPackSection(
721
+ schema_version=DOMAIN_PACK_SCHEMA_VERSION,
722
+ status=status, entity=card,
723
+ neighbors=tuple(chosen),
724
+ neighbors_total=total,
725
+ neighbors_omitted=total - len(chosen),
726
+ counts=dict(counts),
727
+ unresolved_targets=unresolved,
728
+ ambiguous_targets=ambiguous,
729
+ summary=summary,
730
+ requery=DOMAIN_REQUERY_CONTRACT,
731
+ candidates=candidates)
732
+ return DomainPackPart(
733
+ section=section,
734
+ extra_expansion_refs=tuple(
735
+ ExpansionRef(
736
+ ref_id=ref, kind="domain-neighbor",
737
+ reason="além do top-N de vizinhos — "
738
+ "re-consultável via ref")
739
+ for ref in beyond),
740
+ extra_unresolved=(*unresolved, *ambiguous),
741
+ ranking_explanation=_RANKING_DOMAIN)
742
+
743
+ return self._enforce_payload_budget(
744
+ req, sid, budget, selected, omitted, essentials,
745
+ insufficient, diags, snap_status, domain_factory=factory)
746
+
747
+ def _absent(
748
+ self, req: DomainPackRequest, msg: str
749
+ ) -> ContextPack:
750
+ """Falha de resolução (repo/snapshot) — pack vazio honesto."""
751
+ base = self._empty(req, msg)
752
+ return dataclasses.replace(
753
+ base,
754
+ domain=DomainPackSection(
755
+ schema_version=DOMAIN_PACK_SCHEMA_VERSION,
756
+ status=_STATUS_ABSENT, entity=None, neighbors=(),
757
+ neighbors_total=0, neighbors_omitted=0,
758
+ counts=self._zero_counts(), unresolved_targets=(),
759
+ ambiguous_targets=(), summary="", candidates=(),
760
+ requery=DOMAIN_REQUERY_CONTRACT))