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,1514 @@
1
+ """Handlers da API v1 — reutilizam os engines de domínio, nunca SQL solto.
2
+
3
+ Cada handler abre uma conexão read-only por request (ciclo de vida real,
4
+ mesmo padrão do MCP) e retorna ``dict`` serializável. Erros viram
5
+ ``ApiError`` tipados — nunca traceback ao cliente.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import json
12
+ import os
13
+ import re
14
+ import sqlite3
15
+ import sys
16
+ import time
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Any
20
+ from urllib.parse import parse_qs, unquote, urlparse
21
+
22
+ from code_atlas import __version__
23
+ from code_atlas.api.server import ApiError
24
+ from code_atlas.federation import (
25
+ Federation,
26
+ FederationError,
27
+ InvalidKeyError,
28
+ InvalidPinError,
29
+ MemberNotFoundError,
30
+ RepositoryNotFoundError,
31
+ SnapshotNotFoundError,
32
+ SnapshotNotReadyError,
33
+ WorkspaceExistsError,
34
+ WorkspaceNotFoundError,
35
+ WorkspaceSizeError,
36
+ )
37
+ from code_atlas.observability.logging import (
38
+ get_logger,
39
+ log_if_slow,
40
+ slow_ms,
41
+ )
42
+ from code_atlas.storage.store import Store
43
+
44
+ if TYPE_CHECKING:
45
+ from http.server import BaseHTTPRequestHandler
46
+
47
+ from code_atlas.api.server import _AtlasHttpd
48
+
49
+ _MAX_LIMIT = 200
50
+ _QNAME = re.compile(r"^[a-zA-Z0-9_:.<>$/\-]{1,300}$")
51
+
52
+
53
+ def _qs(handler: BaseHTTPRequestHandler) -> dict[str, str]:
54
+ raw = parse_qs(urlparse(handler.path).query)
55
+ return {k: v[0] for k, v in raw.items() if v}
56
+
57
+
58
+ def _int(params: dict[str, str], key: str, default: int = 0,
59
+ cap: int = _MAX_LIMIT) -> int:
60
+ try:
61
+ v = int(params.get(key, "") or default)
62
+ except ValueError as exc:
63
+ raise ApiError("bad-param", f"parâmetro {key} não é inteiro") from exc
64
+ return max(0, min(v, cap))
65
+
66
+
67
+ def _repo(conn: Any, params: dict[str, str]) -> int:
68
+ """Resolve repository_id — escopo obrigatório (Cycle 5.1)."""
69
+ store = Store(conn)
70
+ rid = params.get("repository_id", "")
71
+ if rid:
72
+ try:
73
+ rid_int = int(rid)
74
+ except ValueError as exc:
75
+ raise ApiError("bad-param", "repository_id inválido") from exc
76
+ if store.repository_by_id(rid_int) is None:
77
+ raise ApiError("repo-not-found", "repositório inexistente", 404)
78
+ return rid_int
79
+ repos = [r for r in _all_repos(store)]
80
+ if not repos:
81
+ raise ApiError("no-repository", "nenhum repositório indexado", 404)
82
+ return repos[0]
83
+
84
+
85
+ def _all_repos(store: Store) -> list[int]:
86
+ rows = store.conn.execute(
87
+ "SELECT id FROM repositories ORDER BY id").fetchall()
88
+ return [int(r["id"]) for r in rows]
89
+
90
+
91
+ def _snapshot(conn: Any, repo_id: int, params: dict[str, str]) -> int:
92
+ store = Store(conn)
93
+ sid = params.get("snapshot_id", "")
94
+ if sid:
95
+ try:
96
+ snap = store.snapshot_by_id(int(sid))
97
+ except ValueError as exc:
98
+ raise ApiError("bad-param", "snapshot_id inválido") from exc
99
+ if snap is None or snap.repository_id != repo_id:
100
+ raise ApiError("snapshot-not-found",
101
+ "snapshot inexistente no repositório", 404)
102
+ if snap.status != "READY":
103
+ raise ApiError("snapshot-not-ready",
104
+ f"snapshot {snap.id} está {snap.status}", 409)
105
+ assert snap.id is not None
106
+ return snap.id
107
+ snap = store.latest_ready_snapshot(repo_id)
108
+ if snap is None:
109
+ raise ApiError("snapshot-not-ready", "nenhum snapshot READY", 409)
110
+ assert snap.id is not None
111
+ return snap.id
112
+
113
+
114
+ _NODE_SELECT = (
115
+ "SELECT o.id AS occurrence_id, o.logical_symbol_id AS symbol_id, "
116
+ "s.qualified_name AS qname, s.kind AS kind, s.language AS language, "
117
+ "f.relative_path AS path, o.span_start, o.span_end, o.parse_status "
118
+ "FROM symbol_occurrences o "
119
+ "JOIN symbols s ON s.id = o.logical_symbol_id "
120
+ "JOIN source_files f ON f.id = o.source_file_id ")
121
+
122
+
123
+ def _row_to_node(row: Any) -> dict[str, Any]:
124
+ return {
125
+ "id": f"occ:{row['occurrence_id']}",
126
+ "occurrence_id": row["occurrence_id"],
127
+ "symbol_id": row["symbol_id"],
128
+ "qname": row["qname"],
129
+ "kind": row["kind"],
130
+ "language": row["language"],
131
+ "path": row["path"],
132
+ "span": {"start": row["span_start"], "end": row["span_end"]},
133
+ "parse_status": row["parse_status"],
134
+ }
135
+
136
+
137
+ _NODES_IN_QUERY = (
138
+ _NODE_SELECT +
139
+ "WHERE o.snapshot_id = ? AND o.id IN (SELECT value FROM json_each(?))")
140
+ _NODE_BY_ID_QUERY = _NODE_SELECT + "WHERE o.snapshot_id = ? AND o.id = ?"
141
+
142
+
143
+ _COUNT_QUERIES = {
144
+ "file_inventory":
145
+ "SELECT COUNT(*) AS n FROM file_inventory WHERE snapshot_id = ?",
146
+ "symbol_occurrences":
147
+ "SELECT COUNT(*) AS n FROM symbol_occurrences WHERE snapshot_id = ?",
148
+ "relations":
149
+ "SELECT COUNT(*) AS n FROM relations WHERE snapshot_id = ?",
150
+ "evidence":
151
+ "SELECT COUNT(*) AS n FROM evidence WHERE snapshot_id = ?",
152
+ }
153
+
154
+
155
+ # --- handlers -----------------------------------------------------------
156
+
157
+
158
+ def _health(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
159
+ _b: dict[str, Any] | None) -> dict[str, Any]:
160
+ return {"status": "ok", "service": "code-atlas-api", "version": 1}
161
+
162
+
163
+ def _capabilities(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
164
+ _b: dict[str, Any] | None) -> dict[str, Any]:
165
+ from code_atlas.search.engine import SearchEngine
166
+ caps = dataclasses.asdict(SearchEngine(conn).capabilities())
167
+ return {"search": caps, "api_version": 1, "readonly": True}
168
+
169
+
170
+ def _repositories(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
171
+ _b: dict[str, Any] | None) -> dict[str, Any]:
172
+ rows = conn.execute(
173
+ "SELECT id, identity_key, root_handle FROM repositories ORDER BY id"
174
+ ).fetchall()
175
+ items = [{"id": r["id"], "identity_key": r["identity_key"],
176
+ "root_handle": r["root_handle"]} for r in rows]
177
+ return {"items": items, "total": len(items)}
178
+
179
+
180
+ def _snapshots(server: _AtlasHttpd, conn: Any, repo_id: str,
181
+ _b: dict[str, Any] | None) -> dict[str, Any]:
182
+ try:
183
+ rid = int(repo_id)
184
+ except ValueError as exc:
185
+ raise ApiError("bad-param", "repository_id inválido") from exc
186
+ rows = conn.execute(
187
+ "SELECT id, status, commit_sha, git_ref, created_at FROM snapshots "
188
+ "WHERE repository_id = ? ORDER BY id DESC LIMIT 100", (rid,)).fetchall()
189
+ return {"items": [dict(r) for r in rows]}
190
+
191
+
192
+ def _graph_overview(server: _AtlasHttpd, conn: Any, p: dict[str, str],
193
+ _b: dict[str, Any] | None) -> dict[str, Any]:
194
+ """Visão macro: nós agregados por arquivo — nunca o grafo inteiro."""
195
+ rid = _repo(conn, p)
196
+ sid = _snapshot(conn, rid, p)
197
+ limit = _int(p, "limit", 500, cap=2000)
198
+ rows = conn.execute(
199
+ "SELECT f.id AS file_id, f.relative_path AS path, "
200
+ "COUNT(*) AS symbols, MIN(s.language) AS language "
201
+ "FROM symbol_occurrences o "
202
+ "JOIN symbols s ON s.id = o.logical_symbol_id "
203
+ "JOIN source_files f ON f.id = o.source_file_id "
204
+ "WHERE o.snapshot_id = ? "
205
+ "GROUP BY f.id ORDER BY symbols DESC LIMIT ?",
206
+ (sid, limit)).fetchall()
207
+ total = conn.execute(
208
+ "SELECT COUNT(DISTINCT source_file_id) AS n FROM symbol_occurrences "
209
+ "WHERE snapshot_id = ?", (sid,)).fetchone()["n"]
210
+ return {"snapshot_id": sid, "repository_id": rid,
211
+ "items": [dict(r) for r in rows], "total_files": total,
212
+ "truncated": len(rows) < total}
213
+
214
+
215
+ def _row_to_edge(row: Any, snapshot_id: int) -> dict[str, Any]:
216
+ """DTO completo de aresta — prova da relação, nunca só os endpoints."""
217
+ return {
218
+ "id": row["id"],
219
+ "relation_id": row["id"],
220
+ "snapshot_id": snapshot_id,
221
+ "from_id": row["from_id"],
222
+ "to_id": row["to_id"],
223
+ "to_unresolved": row["to_unresolved"],
224
+ "type": row["type"],
225
+ "resolution_status": row["resolution_status"],
226
+ "confidence_class": row["confidence_class"],
227
+ "resolver": row["resolver"],
228
+ "evidence_ids": json.loads(row["evidence_ids"] or "[]"),
229
+ }
230
+
231
+
232
+ def _graph_neighbors(server: _AtlasHttpd, conn: Any, p: dict[str, str],
233
+ _b: dict[str, Any] | None) -> dict[str, Any]:
234
+ rid = _repo(conn, p)
235
+ sid = _snapshot(conn, rid, p)
236
+ direction = p.get("direction", "out")
237
+ if direction not in ("in", "out", "both"):
238
+ raise ApiError("bad-param", "direction deve ser in|out|both")
239
+ limit = _int(p, "limit", 50)
240
+ occ_id = _int(p, "occurrence_id", 0, cap=10**9)
241
+ if not occ_id:
242
+ raise ApiError("bad-param", "occurrence_id obrigatório")
243
+ edges = conn.execute(
244
+ "SELECT id, from_id, to_id, to_unresolved, type, resolution_status, "
245
+ "confidence_class, resolver, evidence_ids "
246
+ "FROM relations WHERE snapshot_id = ? AND ("
247
+ " (? = 'out' AND from_id = ?) "
248
+ " OR (? = 'in' AND to_id = ?) "
249
+ " OR (? = 'both' AND (from_id = ? OR to_id = ?))"
250
+ ") ORDER BY id LIMIT ?",
251
+ (sid, direction, occ_id, direction, occ_id,
252
+ direction, occ_id, occ_id, limit)).fetchall()
253
+ occ_ids = {e["from_id"] for e in edges} | {e["to_id"] for e in edges}
254
+ occ_ids.discard(0)
255
+ occ_ids.discard(None)
256
+ nodes = []
257
+ if occ_ids:
258
+ nodes = conn.execute(
259
+ _NODES_IN_QUERY,
260
+ (sid, json.dumps(sorted(occ_ids)))).fetchall()
261
+ return {"snapshot_id": sid,
262
+ "edges": [_row_to_edge(e, sid) for e in edges],
263
+ "nodes": [_row_to_node(n) for n in nodes],
264
+ "truncated": len(edges) == limit}
265
+
266
+
267
+ def _file_occurrences(server: _AtlasHttpd, conn: Any, file_id: str,
268
+ p: dict[str, str]) -> dict[str, Any]:
269
+ """Ocorrências de símbolo de um arquivo — escopo snapshot do repo dele."""
270
+ try:
271
+ fid = int(file_id)
272
+ except ValueError as exc:
273
+ raise ApiError("bad-param", "file_id inválido") from exc
274
+ frow = conn.execute(
275
+ "SELECT id, repository_id, relative_path, language FROM source_files "
276
+ "WHERE id = ?", (fid,)).fetchone()
277
+ if frow is None:
278
+ raise ApiError("file-not-found", "arquivo inexistente", 404)
279
+ rid = int(frow["repository_id"])
280
+ sid = _snapshot(conn, rid, p)
281
+ limit = _int(p, "limit", 200, cap=500)
282
+ offset = _int(p, "offset", 0, cap=10**9)
283
+ rows = conn.execute(
284
+ "SELECT o.id AS occurrence_id, o.logical_symbol_id AS symbol_id, "
285
+ "s.qualified_name AS qname, s.kind AS kind, o.span_start, o.span_end, "
286
+ "o.line, o.col, o.content_hash, o.parse_status "
287
+ "FROM symbol_occurrences o "
288
+ "JOIN symbols s ON s.id = o.logical_symbol_id "
289
+ "WHERE o.snapshot_id = ? AND o.source_file_id = ? "
290
+ "ORDER BY o.line, o.col, o.id LIMIT ? OFFSET ?",
291
+ (sid, fid, limit, offset)).fetchall()
292
+ total = conn.execute(
293
+ "SELECT COUNT(*) AS n FROM symbol_occurrences "
294
+ "WHERE snapshot_id = ? AND source_file_id = ?",
295
+ (sid, fid)).fetchone()["n"]
296
+ items = [{
297
+ "id": f"occ:{r['occurrence_id']}",
298
+ "occurrence_id": r["occurrence_id"],
299
+ "symbol_id": r["symbol_id"],
300
+ "qualified_name": r["qname"],
301
+ "kind": r["kind"],
302
+ "span_start": r["span_start"],
303
+ "span_end": r["span_end"],
304
+ "line": r["line"],
305
+ "col": r["col"],
306
+ "content_hash": r["content_hash"],
307
+ "parse_status": r["parse_status"],
308
+ } for r in rows]
309
+ return {"file_id": fid, "repository_id": rid, "snapshot_id": sid,
310
+ "path": frow["relative_path"], "language": frow["language"],
311
+ "items": items, "total": total, "limit": limit, "offset": offset,
312
+ "truncated": offset + len(items) < total}
313
+
314
+
315
+ def _symbol(server: _AtlasHttpd, conn: Any, sym_id: str,
316
+ p: dict[str, str]) -> dict[str, Any]:
317
+ rid = _repo(conn, p)
318
+ sid = _snapshot(conn, rid, p)
319
+ try:
320
+ occ_id = int(sym_id)
321
+ except ValueError as exc:
322
+ raise ApiError("bad-param", "id de símbolo deve ser occurrence_id") from exc
323
+ row = conn.execute(_NODE_BY_ID_QUERY, (sid, occ_id)).fetchone()
324
+ if row is None:
325
+ raise ApiError("symbol-not-found", "símbolo inexistente", 404)
326
+ counts = conn.execute(
327
+ "SELECT type, resolution_status, COUNT(*) AS n FROM relations "
328
+ "WHERE snapshot_id = ? AND (from_id = ? OR to_id = ?) GROUP BY 1,2",
329
+ (sid, occ_id, occ_id)).fetchall()
330
+ out = _row_to_node(row)
331
+ out["relations_summary"] = [dict(c) for c in counts]
332
+ return out
333
+
334
+
335
+ def _search_strategy(p: dict[str, str]) -> str:
336
+ """Estratégia pedida — default hybrid, valor desconhecido → hybrid."""
337
+ s = p.get("strategy", "hybrid")
338
+ return s if s in (
339
+ "lexical", "structural", "hybrid", "vector", "graph") else "hybrid"
340
+
341
+
342
+ def _search_repo_filter(conn: Any, p: dict[str, str]) -> int | None:
343
+ """``repository_id`` opcional validado — narrowing por repositório."""
344
+ rid = p.get("repository_id", "")
345
+ if not rid:
346
+ return None
347
+ try:
348
+ repo_id = int(rid)
349
+ except ValueError as exc:
350
+ raise ApiError("bad-param", "repository_id inválido") from exc
351
+ if Store(conn).repository_by_id(repo_id) is None:
352
+ raise ApiError("repo-not-found", "repositório inexistente", 404)
353
+ return repo_id
354
+
355
+
356
+ def _search(server: _AtlasHttpd, conn: Any, p: dict[str, str],
357
+ _b: dict[str, Any] | None) -> dict[str, Any]:
358
+ """Busca de símbolos + faceta de domínio (lf-0119).
359
+
360
+ ``workspace=``/``federation=`` ativa a busca federada (mesmo
361
+ envelope da família search + identidade da view); ``repository_id``
362
+ seleciona/narrow o repo; ``domain`` filtra a faceta de domínio;
363
+ ``pin=repo:snap[,…]`` aplica overrides da view.
364
+ """
365
+ from code_atlas.search.contracts import SearchRequest
366
+ from code_atlas.search.engine import SearchEngine
367
+ q = p.get("q", "")
368
+ if not q or len(q) > 300:
369
+ raise ApiError("bad-param", "q obrigatório (≤300 chars)")
370
+ store = Store(conn)
371
+ workspace = p.get("workspace") or p.get("federation") or ""
372
+ repo_filter = _search_repo_filter(conn, p)
373
+ doms = _csv_filter(p.get("domain", ""), "domain", max_items=1)
374
+ req = SearchRequest(
375
+ query=q,
376
+ strategy=_search_strategy(p),
377
+ snapshot_id=_int(p, "snapshot_id", 0, cap=10**9) or None,
378
+ limit=_int(p, "limit", 20, cap=100) or 20,
379
+ cursor=p.get("cursor") or None,
380
+ language=p.get("language") or None,
381
+ kind=p.get("kind") or None,
382
+ path=p.get("path") or None,
383
+ domain=doms[0] if doms else None)
384
+ if workspace:
385
+ from code_atlas.search.federated import FederatedSearchEngine
386
+ pins = _parse_pins(p)
387
+ return _fed_call(
388
+ lambda: FederatedSearchEngine(conn).search(
389
+ req, workspace, pins, repository_id=repo_filter
390
+ ).to_dict())
391
+ # repo escolhido: repository_id (explícito) > repository_root > 1º repo
392
+ root = p.get("repository_root", "")
393
+ if repo_filter is not None:
394
+ repo = store.repository_by_id(repo_filter)
395
+ assert repo is not None # validado em _search_repo_filter
396
+ root = repo.root_handle
397
+ elif not root:
398
+ first = conn.execute(
399
+ "SELECT root_handle FROM repositories ORDER BY id LIMIT 1"
400
+ ).fetchone()
401
+ root = str(first["root_handle"]) if first else ""
402
+ elif store.repository_id_for(root) is None:
403
+ raise ApiError("repo-not-indexed", "repositório não indexado", 404)
404
+ res = SearchEngine(conn).search(
405
+ dataclasses.replace(req, repository_root=root))
406
+ return dataclasses.asdict(res)
407
+
408
+
409
+ def _context(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
410
+ body: dict[str, Any] | None) -> dict[str, Any]:
411
+ from code_atlas.context.contracts import ContextRequest
412
+ from code_atlas.context.engine import ContextEngine
413
+ body = body or {}
414
+ task = str(body.get("task", ""))[:2000]
415
+ if not task:
416
+ raise ApiError("bad-param", "task obrigatória")
417
+ pack = ContextEngine(conn).build(ContextRequest(
418
+ task=task,
419
+ repository_root=str(body.get("repository_root", ""))[:1000],
420
+ snapshot_id=body.get("snapshot_id"),
421
+ level=body.get("level", "focus"),
422
+ budget_tokens=min(int(body.get("budget_tokens", 4000)), 64000),
423
+ max_symbols=min(int(body.get("max_symbols", 50)), 200)))
424
+ return pack.to_dict()
425
+
426
+
427
+ def _impact(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
428
+ body: dict[str, Any] | None) -> dict[str, Any]:
429
+ from code_atlas.impact.contracts import ImpactRequest
430
+ from code_atlas.impact.engine import ImpactEngine
431
+ body = body or {}
432
+ root = str(body.get("repository_root", ""))
433
+ if not root or len(root) > 1000:
434
+ raise ApiError("bad-param", "repository_root obrigatório")
435
+ res = ImpactEngine(conn).analyze(ImpactRequest(
436
+ repository_root=root,
437
+ base_revision=str(body.get("base_revision", "HEAD"))[:200],
438
+ target_revision=str(body.get("target_revision", ""))[:200],
439
+ symbol=str(body.get("symbol"))[:300] if body.get("symbol") else None,
440
+ file=str(body.get("file"))[:1000] if body.get("file") else None,
441
+ base_snapshot_id=body.get("base_snapshot_id"),
442
+ target_snapshot_id=body.get("target_snapshot_id"),
443
+ include_possible=bool(body.get("include_possible", True)),
444
+ known_only=bool(body.get("known_only", False))))
445
+ return dataclasses.asdict(res)
446
+
447
+
448
+ def _evidence(server: _AtlasHttpd, conn: Any, ev_id: str,
449
+ p: dict[str, str]) -> dict[str, Any]:
450
+ rid = _repo(conn, p)
451
+ sid = _snapshot(conn, rid, p)
452
+ try:
453
+ occ_id = int(ev_id)
454
+ except ValueError as exc:
455
+ raise ApiError("bad-param", "id de evidência inválido") from exc
456
+ row = conn.execute(
457
+ "SELECT id, extractor, extractor_version, notes, path, span, "
458
+ "content_hash FROM evidence WHERE snapshot_id = ? AND id = ?",
459
+ (sid, occ_id)).fetchone()
460
+ if row is None:
461
+ raise ApiError("evidence-not-found", "evidência inexistente", 404)
462
+ refs = conn.execute(
463
+ "SELECT id, from_id, to_id, type, resolution_status FROM relations "
464
+ "WHERE snapshot_id = ? AND evidence_ids LIKE ? LIMIT 100",
465
+ (sid, f"%{occ_id}%")).fetchall()
466
+ return {"snapshot_id": sid, "evidence": dict(row),
467
+ "referencing_relations": [dict(r) for r in refs]}
468
+
469
+
470
+ def _index_status(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
471
+ _b: dict[str, Any] | None) -> dict[str, Any]:
472
+ repos = conn.execute(
473
+ "SELECT r.id, r.root_handle, "
474
+ "(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
475
+ " AND s.status = 'READY') AS ready, "
476
+ "(SELECT MAX(id) FROM snapshots s WHERE s.repository_id = r.id "
477
+ " AND s.status = 'READY') AS latest_ready "
478
+ "FROM repositories r ORDER BY r.id").fetchall()
479
+ items = []
480
+ for r in repos:
481
+ sid = r["latest_ready"]
482
+ counts = {}
483
+ if sid:
484
+ for tbl, sql in _COUNT_QUERIES.items():
485
+ counts[tbl] = conn.execute(sql, (sid,)).fetchone()["n"]
486
+ items.append({"repository_id": r["id"], "root_handle": r["root_handle"],
487
+ "ready_snapshots": r["ready"], "latest_ready": sid,
488
+ "counts": counts})
489
+ return {"items": items}
490
+
491
+
492
+ # --- diagnósticos operacionais (lf-0141, Cycle 8 Phase 15) --------------
493
+ #
494
+ # ``/diagnostics`` é o painel do operador: versão, uptime, schema,
495
+ # contagens por repositório, inventário de adapters, FTS, tamanho do
496
+ # índice em disco. Estritamente read-only e barato — metadados
497
+ # persistidos + ``stat()`` do arquivo, nunca scan de workspace nem
498
+ # re-execução de adapter/resolver. Bancos pré-v9/v10 podem não ter as
499
+ # tabelas de domínio/federação — presença é conferida em
500
+ # ``sqlite_master`` antes de contar.
501
+
502
+ _LOG = get_logger("code_atlas.api")
503
+
504
+ _DIAG_COUNT_QUERIES = {
505
+ "domain_entity_occurrences":
506
+ "SELECT COUNT(*) AS n FROM domain_entity_occurrences "
507
+ "WHERE snapshot_id = ?",
508
+ "domain_relations":
509
+ "SELECT COUNT(*) AS n FROM domain_relations WHERE snapshot_id = ?",
510
+ }
511
+
512
+
513
+ def _fts5_build_probe() -> bool:
514
+ """Capacidade FTS5 desta build do SQLite — ``True``/``False`` honesto.
515
+
516
+ A conexão da API é ``query_only`` — nem vtable temporária pode ser
517
+ criada nela (``attempt to write a readonly database``). A probe roda
518
+ num ``:memory:`` descartável — mesmo teste do ``doctor`` da CLI.
519
+ """
520
+ try:
521
+ probe = sqlite3.connect(":memory:")
522
+ try:
523
+ probe.execute("CREATE VIRTUAL TABLE t USING fts5(x)")
524
+ finally:
525
+ probe.close()
526
+ return True
527
+ except sqlite3.Error:
528
+ return False
529
+
530
+
531
+ def _diag_repo_job(conn: Any, repo_id: int) -> dict[str, Any] | None:
532
+ """Último ``index_jobs`` do repo — com ``stage_ms_*`` decodificados.
533
+
534
+ ``stage_ms`` é persistido desde o Cycle 2 dentro de ``counts``;
535
+ aqui ele sai do envelope genérico e vira campo próprio inspecionável.
536
+ """
537
+ job = conn.execute(
538
+ "SELECT id, status, started_at, finished_at, counts "
539
+ "FROM index_jobs WHERE repository_id = ? "
540
+ "ORDER BY id DESC LIMIT 1", (repo_id,)).fetchone()
541
+ if job is None:
542
+ return None
543
+ jcounts = json.loads(job["counts"] or "{}")
544
+ return {
545
+ "id": job["id"], "status": job["status"],
546
+ "started_at": job["started_at"],
547
+ "finished_at": job["finished_at"],
548
+ "stage_ms": {
549
+ k.removeprefix("stage_ms_"): v
550
+ for k, v in jcounts.items() if k.startswith("stage_ms_")
551
+ },
552
+ }
553
+
554
+
555
+ def _diagnostics(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
556
+ _b: dict[str, Any] | None) -> dict[str, Any]:
557
+ from code_atlas.search.engine import SearchEngine
558
+ from code_atlas.storage.migrations import schema_version
559
+
560
+ tables = {
561
+ str(r["name"]) for r in conn.execute(
562
+ "SELECT name FROM sqlite_master WHERE type IN ('table','view')")
563
+ }
564
+ db_file = Path(server.db_path)
565
+ wal_file = db_file.with_name(db_file.name + "-wal")
566
+ repos: list[dict[str, Any]] = []
567
+ for r in conn.execute(
568
+ "SELECT r.id, r.identity_key, r.root_handle, "
569
+ "(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
570
+ " AND s.status = 'READY') AS ready, "
571
+ "(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
572
+ " AND s.status = 'FAILED') AS failed, "
573
+ "(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
574
+ " AND s.status = 'STALE') AS stale, "
575
+ "(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
576
+ " AND s.status = 'BUILDING') AS building, "
577
+ "(SELECT MAX(id) FROM snapshots s WHERE s.repository_id = r.id "
578
+ " AND s.status = 'READY') AS latest_ready "
579
+ "FROM repositories r ORDER BY r.id").fetchall():
580
+ sid = r["latest_ready"]
581
+ counts: dict[str, int] = {}
582
+ analyzers: dict[str, str] = {}
583
+ created_at = None
584
+ if sid:
585
+ for tbl, sql in _COUNT_QUERIES.items():
586
+ counts[tbl] = conn.execute(sql, (sid,)).fetchone()["n"]
587
+ for tbl, sql in _DIAG_COUNT_QUERIES.items():
588
+ if tbl in tables:
589
+ counts[tbl] = conn.execute(
590
+ sql, (sid,)).fetchone()["n"]
591
+ srow = conn.execute(
592
+ "SELECT analyzer_versions, created_at FROM snapshots "
593
+ "WHERE id = ?", (sid,)).fetchone()
594
+ if srow is not None:
595
+ analyzers = json.loads(srow["analyzer_versions"] or "{}")
596
+ created_at = srow["created_at"]
597
+ repos.append({
598
+ "repository_id": r["id"],
599
+ "identity_key": r["identity_key"],
600
+ "root_handle": r["root_handle"],
601
+ "snapshots": {"ready": r["ready"], "failed": r["failed"],
602
+ "stale": r["stale"], "building": r["building"]},
603
+ "latest_ready": sid,
604
+ "latest_ready_created_at": created_at,
605
+ "counts": counts,
606
+ # versões reais que produziram o READY — fonte autoritativa
607
+ # de "quais parsers/resolvers/domínios construíram isto".
608
+ "analyzer_versions": analyzers,
609
+ "last_job": _diag_repo_job(conn, r["id"]),
610
+ })
611
+ adapters: list[dict[str, Any]] = []
612
+ adapters_error = ""
613
+ try:
614
+ from code_atlas.domains.cli_surface import adapters_report
615
+ adapters = adapters_report()
616
+ except Exception as exc:
617
+ # inventário é metadado de adapters built-in — uma falha de
618
+ # importação nunca derruba o endpoint; vira campo honesto.
619
+ adapters_error = str(exc)[:200]
620
+ started = getattr(server, "started_at", None)
621
+ return {
622
+ "status": "ok",
623
+ "service": "code-atlas-api",
624
+ "api_version": 1,
625
+ "version": __version__,
626
+ "readonly": True,
627
+ "uptime_s": (round(time.monotonic() - started, 3)
628
+ if isinstance(started, (int, float)) else None),
629
+ "runtime": {"python": sys.version.split()[0],
630
+ "sqlite": sqlite3.sqlite_version},
631
+ "schema_version": schema_version(conn),
632
+ "database": {
633
+ "path": str(db_file),
634
+ "size_bytes": (db_file.stat().st_size
635
+ if db_file.is_file() else 0),
636
+ "wal_bytes": (wal_file.stat().st_size
637
+ if wal_file.is_file() else 0),
638
+ "journal_mode": str(conn.execute(
639
+ "PRAGMA journal_mode").fetchone()[0]),
640
+ "page_count": int(conn.execute(
641
+ "PRAGMA page_count").fetchone()[0]),
642
+ },
643
+ "fts": {
644
+ "fts5_build": _fts5_build_probe(),
645
+ "fts_symbols": "fts_symbols" in tables,
646
+ "fts_occurrences": "fts_occurrences" in tables,
647
+ "domain_fts": "domain_fts" in tables,
648
+ },
649
+ "search": dataclasses.asdict(SearchEngine(conn).capabilities()),
650
+ "repositories": repos,
651
+ "federation": {
652
+ "workspaces": (
653
+ conn.execute("SELECT COUNT(*) AS n "
654
+ "FROM federation_workspaces").fetchone()["n"]
655
+ if "federation_workspaces" in tables else 0),
656
+ },
657
+ "adapters": {"domain": adapters, "error": adapters_error},
658
+ "observability": {
659
+ "log_level": os.environ.get("CODE_ATLAS_LOG_LEVEL", "WARNING"),
660
+ "log_format": os.environ.get("CODE_ATLAS_LOG_FORMAT", "text"),
661
+ "slow_ms": slow_ms(),
662
+ },
663
+ }
664
+
665
+
666
+ # --- federação multi-repo (lf-0118, Phase 10 — somente leitura) -------
667
+ #
668
+ # Views/lookups/graphs são GET sobre conexão ``query_only``; criar/
669
+ # editar workspaces é superfície exclusiva da CLI — POST de federação
670
+ # não existe por desenho nesta fase.
671
+
672
+ _FED_ERROR_MAP: tuple[tuple[type[FederationError], str, int], ...] = (
673
+ (WorkspaceNotFoundError, "workspace-not-found", 404),
674
+ (WorkspaceSizeError, "federation-too-small", 409),
675
+ (WorkspaceExistsError, "workspace-exists", 409),
676
+ (MemberNotFoundError, "member-not-found", 404),
677
+ (RepositoryNotFoundError, "repo-not-found", 404),
678
+ (SnapshotNotFoundError, "snapshot-not-found", 404),
679
+ (SnapshotNotReadyError, "snapshot-not-ready", 409),
680
+ (InvalidPinError, "bad-pin", 400),
681
+ (InvalidKeyError, "bad-key", 400),
682
+ )
683
+
684
+
685
+ def _fed_call[T](fn: Callable[[], T]) -> T:
686
+ """Mapeia ``FederationError`` tipadas para ``ApiError``."""
687
+ try:
688
+ return fn()
689
+ except FederationError as exc:
690
+ for cls, code, status in _FED_ERROR_MAP:
691
+ if isinstance(exc, cls):
692
+ raise ApiError(code, str(exc)[:300], status) from exc
693
+ raise ApiError("federation-error", str(exc)[:300]) from exc
694
+
695
+
696
+ def _parse_pins(p: dict[str, str]) -> dict[int, int]:
697
+ """``?pin=<repo>:<snap>[,<repo>:<snap>…]`` — overrides da view."""
698
+ raw = p.get("pin", "")
699
+ out: dict[int, int] = {}
700
+ if not raw.strip():
701
+ return out
702
+ for part in raw.split(","):
703
+ seg = part.strip()
704
+ if not seg:
705
+ continue
706
+ repo, sep, snap = seg.partition(":")
707
+ if not sep or not repo.isdigit() or not snap.isdigit():
708
+ raise ApiError(
709
+ "bad-param",
710
+ "pin deve ser repo:snapshot[,repo:snapshot…]",
711
+ )
712
+ out[int(repo)] = int(snap)
713
+ return out
714
+
715
+
716
+ def _fed_workspaces(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
717
+ _b: dict[str, Any] | None) -> dict[str, Any]:
718
+ rows = conn.execute(
719
+ "SELECT w.id, w.name, w.created_at, "
720
+ "(SELECT COUNT(*) FROM federation_members m "
721
+ " WHERE m.workspace_id = w.id) AS members "
722
+ "FROM federation_workspaces w ORDER BY w.name").fetchall()
723
+ return {"items": [dict(r) for r in rows], "total": len(rows)}
724
+
725
+
726
+ def _fed_workspace(server: _AtlasHttpd, conn: Any, ws_id: str,
727
+ _p: dict[str, str]) -> dict[str, Any]:
728
+ fed = Federation(conn)
729
+ ws = _fed_call(lambda: fed.get_workspace(int(ws_id)))
730
+ rows = conn.execute(
731
+ "SELECT m.repository_id, m.pinned_snapshot_id, m.added_at, "
732
+ "r.root_handle, r.identity_key "
733
+ "FROM federation_members m "
734
+ "JOIN repositories r ON r.id = m.repository_id "
735
+ "WHERE m.workspace_id = ? ORDER BY m.repository_id",
736
+ (ws.id,)).fetchall()
737
+ return {"id": ws.id, "name": ws.name, "created_at": ws.created_at,
738
+ "members": [dict(r) for r in rows]}
739
+
740
+
741
+ def _fed_view(server: _AtlasHttpd, conn: Any, ws_id: str,
742
+ p: dict[str, str]) -> dict[str, Any]:
743
+ fed = Federation(conn)
744
+ pins = _parse_pins(p)
745
+ return _fed_call(
746
+ lambda: fed.resolve_view(fed.get_workspace(int(ws_id)), pins)
747
+ .to_dict()
748
+ )
749
+
750
+
751
+ def _fed_entity_occurrences(server: _AtlasHttpd, conn: Any, ws_id: str,
752
+ key: str, p: dict[str, str]) -> dict[str, Any]:
753
+ fed = Federation(conn)
754
+ pins = _parse_pins(p)
755
+ logical_key = unquote(key)[:2000]
756
+ return _fed_call(
757
+ lambda: fed.entity_occurrences(
758
+ fed.get_workspace(int(ws_id)), logical_key, pins
759
+ )
760
+ )
761
+
762
+
763
+ def _fed_graph(server: _AtlasHttpd, conn: Any, ws_id: str,
764
+ p: dict[str, str]) -> dict[str, Any]:
765
+ fed = Federation(conn)
766
+ pins = _parse_pins(p)
767
+ depth = _int(p, "depth", 1, cap=8)
768
+ limit = _int(p, "limit", 500, cap=5000)
769
+ return _fed_call(
770
+ lambda: fed.graph(
771
+ fed.get_workspace(int(ws_id)), depth=depth, limit=limit,
772
+ pins=pins,
773
+ ).to_dict()
774
+ )
775
+
776
+
777
+ def _fed_search(server: _AtlasHttpd, conn: Any, ws_id: str,
778
+ p: dict[str, str]) -> dict[str, Any]:
779
+ """Busca federada num workspace (lf-0119).
780
+
781
+ Cada membro é pesquisado no PRÓPRIO escopo ``(repo, snapshot)``
782
+ resolvido pela view — merge posicional determinístico, items
783
+ tagueados ``repository_id``+``snapshot_id``, homônimos cross-repo
784
+ nunca colapsam. ``pin=`` e ``snapshot_id`` ajustam a view;
785
+ ``repository_id`` restringe a um membro.
786
+ """
787
+ from code_atlas.search.contracts import SearchRequest
788
+ from code_atlas.search.federated import FederatedSearchEngine
789
+ q = p.get("q", "")
790
+ if not q or len(q) > 300:
791
+ raise ApiError("bad-param", "q obrigatório (≤300 chars)")
792
+ repo_filter = _search_repo_filter(conn, p)
793
+ doms = _csv_filter(p.get("domain", ""), "domain", max_items=1)
794
+ req = SearchRequest(
795
+ query=q,
796
+ strategy=_search_strategy(p),
797
+ snapshot_id=_int(p, "snapshot_id", 0, cap=10**9) or None,
798
+ limit=_int(p, "limit", 20, cap=100) or 20,
799
+ cursor=p.get("cursor") or None,
800
+ language=p.get("language") or None,
801
+ kind=p.get("kind") or None,
802
+ path=p.get("path") or None,
803
+ domain=doms[0] if doms else None)
804
+ pins = _parse_pins(p)
805
+ return _fed_call(
806
+ lambda: FederatedSearchEngine(conn).search(
807
+ req, int(ws_id), pins, repository_id=repo_filter
808
+ ).to_dict())
809
+
810
+
811
+ # --- domínios (lf-0122, Phase 14 — somente leitura) --------------------
812
+ #
813
+ # Superfície read-only sobre ``domain_entities`` /
814
+ # ``domain_entity_occurrences`` / ``domain_relations`` (schema v9). Todo
815
+ # endpoint resolve ``repository_id`` + ``snapshot_id`` pelo mesmo caminho
816
+ # dos demais handlers — isolamento de snapshot preservado, nada é
817
+ # agregado entre snapshots ou repositórios.
818
+
819
+ _DOMAIN_PARAM = re.compile(r"^[a-zA-Z0-9_.\-]{1,100}$")
820
+ _LINEAGE_KINDS = (
821
+ "data_table", "data_view", "iceberg_table", "data_column",
822
+ "data_transformation",
823
+ )
824
+ # Tipos que carregam fluxo de dados — a travessia upstream/downstream do
825
+ # lineage só segue este vocabulário (READS_FROM/WRITES_TO/TRANSFORMS/
826
+ # DERIVES_FROM), nunca arestas estruturais.
827
+ _LINEAGE_READ = ("READS_FROM", "DERIVES_FROM")
828
+ _LINEAGE_WRITE = ("WRITES_TO", "TRANSFORMS")
829
+ _LINEAGE_MAX_NODES = 300
830
+
831
+
832
+ def _csv_filter(raw: str, param: str, max_items: int = 8) -> list[str]:
833
+ """Filtro ``a,b,c`` validado — lista vazia significa "sem filtro"."""
834
+ out: list[str] = []
835
+ for part in (raw or "").split(","):
836
+ value = part.strip()
837
+ if not value:
838
+ continue
839
+ if not _DOMAIN_PARAM.fullmatch(value):
840
+ raise ApiError(
841
+ "bad-param", f"parâmetro {param} inválido: {value!r}")
842
+ out.append(value)
843
+ if len(out) > max_items:
844
+ raise ApiError(
845
+ "bad-param",
846
+ f"parâmetro {param} aceita no máximo {max_items} valores",
847
+ )
848
+ return out
849
+
850
+
851
+ def _in_clause(values: list[str]) -> str:
852
+ return ", ".join("?" for _ in values)
853
+
854
+
855
+ def _domain_entity_dto(row: Any) -> dict[str, Any]:
856
+ try:
857
+ attrs = json.loads(row["attributes"] or "{}")
858
+ except json.JSONDecodeError:
859
+ attrs = {} # atributo corrompido nunca derruba a leitura
860
+ return {
861
+ "id": row["id"],
862
+ "domain": row["domain"],
863
+ "kind": row["kind"],
864
+ "qualified_name": row["qualified_name"],
865
+ "name": row["name"],
866
+ "catalog": row["catalog"],
867
+ "namespace": row["namespace"],
868
+ "environment": row["environment"],
869
+ "attributes": attrs,
870
+ "occurrences": row["occurrences"],
871
+ "first_occurrence_id": row["first_occurrence_id"],
872
+ "path": row["path"],
873
+ "line": row["line"],
874
+ "extraction_status": row["extraction_status"],
875
+ }
876
+
877
+
878
+ def _domains(server: _AtlasHttpd, conn: Any, p: dict[str, str],
879
+ _b: dict[str, Any] | None) -> dict[str, Any]:
880
+ """Inventário de domínios: kinds presentes + contagens honestas.
881
+
882
+ ``entities`` conta as entidades lógicas do repositório (estáveis
883
+ entre snapshots); ``occurrences`` conta as observadas no snapshot
884
+ resolvido — 0 é resposta válida, nunca omitida.
885
+ """
886
+ rid = _repo(conn, p)
887
+ sid = _snapshot(conn, rid, p)
888
+ rows = conn.execute(
889
+ "SELECT de.domain, de.kind, COUNT(DISTINCT de.id) AS entities, "
890
+ "COUNT(deo.id) AS occurrences "
891
+ "FROM domain_entities de "
892
+ "LEFT JOIN domain_entity_occurrences deo "
893
+ " ON deo.entity_id = de.id AND deo.snapshot_id = ? "
894
+ "WHERE de.repository_id = ? "
895
+ "GROUP BY de.domain, de.kind ORDER BY de.domain, de.kind",
896
+ (sid, rid)).fetchall()
897
+ domains: dict[str, dict[str, Any]] = {}
898
+ for r in rows:
899
+ dom = domains.setdefault(r["domain"], {"domain": r["domain"],
900
+ "kinds": []})
901
+ dom["kinds"].append({"kind": r["kind"],
902
+ "entities": r["entities"],
903
+ "occurrences": r["occurrences"]})
904
+ items = [domains[d] for d in sorted(domains)]
905
+ return {"repository_id": rid, "snapshot_id": sid,
906
+ "items": items, "total_domains": len(items)}
907
+
908
+
909
+ def _domain_entities(server: _AtlasHttpd, conn: Any, p: dict[str, str],
910
+ _b: dict[str, Any] | None) -> dict[str, Any]:
911
+ """Entidades de domínio com ocorrência no snapshot — paginado."""
912
+ rid = _repo(conn, p)
913
+ sid = _snapshot(conn, rid, p)
914
+ doms = _csv_filter(p.get("domain", ""), "domain")
915
+ kinds = _csv_filter(p.get("kind", ""), "kind")
916
+ q = p.get("q", "")[:300].strip()
917
+ limit = _int(p, "limit", 100)
918
+ offset = _int(p, "offset", 0, cap=10**9)
919
+
920
+ where = ["de.repository_id = ?"]
921
+ args: list[Any] = [sid, rid]
922
+ if doms:
923
+ where.append(f"de.domain IN ({_in_clause(doms)})")
924
+ args += doms
925
+ if kinds:
926
+ where.append(f"de.kind IN ({_in_clause(kinds)})")
927
+ args += kinds
928
+ if q:
929
+ like = ("%" + q.replace("\\", "\\\\").replace("%", "\\%")
930
+ .replace("_", "\\_") + "%")
931
+ where.append("(de.qualified_name LIKE ? ESCAPE '\\' "
932
+ "OR de.name LIKE ? ESCAPE '\\')")
933
+ args += [like, like]
934
+ where_sql = " AND ".join(where)
935
+
936
+ # SQLite garante que colunas "bare" num GROUP BY com MIN() vêm da row
937
+ # do mínimo — path/line/extraction_status descrevem a PRIMEIRA
938
+ # ocorrência da entidade no snapshot, de forma determinística.
939
+ entities_sql = (
940
+ "SELECT de.id, de.domain, de.kind, de.qualified_name, de.name, "
941
+ "de.catalog, de.namespace, de.environment, de.attributes, "
942
+ "COUNT(*) AS occurrences, MIN(deo.id) AS first_occurrence_id, "
943
+ "sf.relative_path AS path, deo.line AS line, "
944
+ "deo.extraction_status AS extraction_status "
945
+ "FROM domain_entities de "
946
+ "JOIN domain_entity_occurrences deo "
947
+ " ON deo.entity_id = de.id AND deo.snapshot_id = ? "
948
+ "LEFT JOIN source_files sf ON sf.id = deo.source_file_id "
949
+ f"WHERE {where_sql} "
950
+ "GROUP BY de.id "
951
+ "ORDER BY de.domain, de.kind, de.qualified_name, de.id "
952
+ "LIMIT ? OFFSET ?")
953
+ rows = conn.execute(entities_sql, (*args, limit, offset)).fetchall()
954
+ count_sql = (
955
+ "SELECT COUNT(DISTINCT de.id) AS n "
956
+ "FROM domain_entities de "
957
+ "JOIN domain_entity_occurrences deo "
958
+ " ON deo.entity_id = de.id AND deo.snapshot_id = ? "
959
+ f"WHERE {where_sql}")
960
+ total = conn.execute(count_sql, args).fetchone()["n"]
961
+ return {"repository_id": rid, "snapshot_id": sid,
962
+ "items": [_domain_entity_dto(r) for r in rows],
963
+ "total": total, "limit": limit, "offset": offset,
964
+ "truncated": offset + len(rows) < total}
965
+
966
+
967
+ _DOMAIN_EDGE_SELECT = (
968
+ "SELECT id, snapshot_id, from_entity_occ_id, from_symbol_occ_id, "
969
+ "to_entity_occ_id, to_symbol_occ_id, to_unresolved, type, "
970
+ "resolution_status, confidence_class, resolver, evidence_ids "
971
+ "FROM domain_relations ")
972
+
973
+ _DOMAIN_OCC_SELECT = (
974
+ "SELECT deo.id AS occ_id, deo.entity_id, deo.extraction_status, "
975
+ "deo.line, de.domain, de.kind AS entity_kind, de.qualified_name, "
976
+ "de.name, sf.relative_path AS path "
977
+ "FROM domain_entity_occurrences deo "
978
+ "JOIN domain_entities de ON de.id = deo.entity_id "
979
+ "LEFT JOIN source_files sf ON sf.id = deo.source_file_id ")
980
+
981
+
982
+ def _hydrate_domain_endpoints(
983
+ conn: Any, sid: int, rows: list[Any]
984
+ ) -> tuple[dict[int, dict[str, Any]], dict[int, dict[str, Any]]]:
985
+ """Resolve os endpoints de um lote de arestas — 2 queries no máximo."""
986
+ ent_ids = sorted(
987
+ ({r["from_entity_occ_id"] for r in rows}
988
+ | {r["to_entity_occ_id"] for r in rows}) - {None})
989
+ sym_ids = sorted(
990
+ ({r["from_symbol_occ_id"] for r in rows}
991
+ | {r["to_symbol_occ_id"] for r in rows}) - {None})
992
+ ents: dict[int, dict[str, Any]] = {}
993
+ if ent_ids:
994
+ occ_by_ids_sql = (
995
+ _DOMAIN_OCC_SELECT +
996
+ "WHERE deo.snapshot_id = ? "
997
+ "AND deo.id IN (SELECT value FROM json_each(?))")
998
+ for r in conn.execute(
999
+ occ_by_ids_sql, (sid, json.dumps(ent_ids))).fetchall():
1000
+ occ = int(r["occ_id"])
1001
+ eid = int(r["entity_id"])
1002
+ ents[occ] = {
1003
+ "ref": f"de:{eid}",
1004
+ "endpoint": "entity",
1005
+ "occurrence_id": occ,
1006
+ "entity_id": eid,
1007
+ "domain": r["domain"],
1008
+ "entity_kind": r["entity_kind"],
1009
+ "qualified_name": r["qualified_name"],
1010
+ "name": r["name"],
1011
+ "path": r["path"],
1012
+ "line": r["line"],
1013
+ "extraction_status": r["extraction_status"],
1014
+ }
1015
+ syms: dict[int, dict[str, Any]] = {}
1016
+ if sym_ids:
1017
+ for r in conn.execute(
1018
+ _NODES_IN_QUERY, (sid, json.dumps(sym_ids))).fetchall():
1019
+ occ = int(r["occurrence_id"])
1020
+ node = _row_to_node(r)
1021
+ syms[occ] = {
1022
+ "ref": node["id"], # occ:{id}
1023
+ "endpoint": "symbol",
1024
+ "occurrence_id": occ,
1025
+ "qualified_name": node["qname"],
1026
+ "kind": node["kind"],
1027
+ "language": node["language"],
1028
+ "path": node["path"],
1029
+ }
1030
+ return ents, syms
1031
+
1032
+
1033
+ def _domain_endpoint(
1034
+ row: Any, side: str,
1035
+ ents: dict[int, dict[str, Any]],
1036
+ syms: dict[int, dict[str, Any]],
1037
+ ) -> dict[str, Any]:
1038
+ """Endpoint de uma aresta — entity | symbol | unresolved | stale.
1039
+
1040
+ ``stale`` declara endpoint persistido fora do snapshot resolvido
1041
+ (aresta cross-snapshot auditável) — nunca confundido com resolved.
1042
+ """
1043
+ eid = row[f"{side}_entity_occ_id"]
1044
+ if eid is not None:
1045
+ found = ents.get(int(eid))
1046
+ if found is not None:
1047
+ return found
1048
+ return {"ref": f"de_occ:{eid}", "endpoint": "entity",
1049
+ "occurrence_id": int(eid), "stale": True}
1050
+ oid = row[f"{side}_symbol_occ_id"]
1051
+ if oid is not None:
1052
+ found = syms.get(int(oid))
1053
+ if found is not None:
1054
+ return found
1055
+ return {"ref": f"occ:{oid}", "endpoint": "symbol",
1056
+ "occurrence_id": int(oid), "stale": True}
1057
+ if side == "to" and row["to_unresolved"]:
1058
+ label = str(row["to_unresolved"])
1059
+ return {"ref": f"unresolved:{label}", "endpoint": "unresolved",
1060
+ "label": label}
1061
+ return {"ref": f"{side}:none", "endpoint": "unknown"}
1062
+
1063
+
1064
+ def _domain_edge_dto(
1065
+ row: Any,
1066
+ ents: dict[int, dict[str, Any]],
1067
+ syms: dict[int, dict[str, Any]],
1068
+ ) -> dict[str, Any]:
1069
+ """DTO completo da aresta de domínio — prova, nunca só endpoints."""
1070
+ return {
1071
+ "id": row["id"],
1072
+ "relation_id": row["id"],
1073
+ "snapshot_id": row["snapshot_id"],
1074
+ "type": row["type"],
1075
+ "resolution_status": row["resolution_status"],
1076
+ "confidence_class": row["confidence_class"],
1077
+ "resolver": row["resolver"],
1078
+ "evidence_ids": json.loads(row["evidence_ids"] or "[]"),
1079
+ "from": _domain_endpoint(row, "from", ents, syms),
1080
+ "to": _domain_endpoint(row, "to", ents, syms),
1081
+ }
1082
+
1083
+
1084
+ def _domain_edges_dto(conn: Any, sid: int, rows: list[Any]) -> list[dict[str, Any]]:
1085
+ ents, syms = _hydrate_domain_endpoints(conn, sid, rows)
1086
+ return [_domain_edge_dto(r, ents, syms) for r in rows]
1087
+
1088
+
1089
+ def _domain_relations(server: _AtlasHttpd, conn: Any, p: dict[str, str],
1090
+ _b: dict[str, Any] | None) -> dict[str, Any]:
1091
+ """Arestas de domínio do snapshot — filtro por ``type=a,b``."""
1092
+ rid = _repo(conn, p)
1093
+ sid = _snapshot(conn, rid, p)
1094
+ types = _csv_filter(p.get("type", ""), "type", max_items=20)
1095
+ limit = _int(p, "limit", 100)
1096
+ offset = _int(p, "offset", 0, cap=10**9)
1097
+ where = "WHERE snapshot_id = ?"
1098
+ args: list[Any] = [sid]
1099
+ if types:
1100
+ where += f" AND type IN ({_in_clause(types)})"
1101
+ args += types
1102
+ edges_sql = _DOMAIN_EDGE_SELECT + where + " ORDER BY id LIMIT ? OFFSET ?"
1103
+ rows = conn.execute(edges_sql, (*args, limit, offset)).fetchall()
1104
+ count_sql = f"SELECT COUNT(*) AS n FROM domain_relations {where}"
1105
+ total = conn.execute(count_sql, args).fetchone()["n"]
1106
+ return {"repository_id": rid, "snapshot_id": sid,
1107
+ "edges": _domain_edges_dto(conn, sid, rows),
1108
+ "total": total, "limit": limit, "offset": offset,
1109
+ "truncated": offset + len(rows) < total}
1110
+
1111
+
1112
+ def _domain_entity_neighbors(server: _AtlasHttpd, conn: Any,
1113
+ entity_id: str, p: dict[str, str],
1114
+ ) -> dict[str, Any]:
1115
+ """Ego de uma entidade em ``domain_relations`` (por ocorrências)."""
1116
+ try:
1117
+ eid = int(entity_id)
1118
+ except ValueError as exc:
1119
+ raise ApiError("bad-param", "id de entidade inválido") from exc
1120
+ ent = conn.execute(
1121
+ "SELECT id, repository_id, domain, kind, identity_key, "
1122
+ "qualified_name, name, catalog, namespace, environment, "
1123
+ "attributes FROM domain_entities WHERE id = ?",
1124
+ (eid,)).fetchone()
1125
+ if ent is None:
1126
+ raise ApiError("domain-entity-not-found", "entidade inexistente", 404)
1127
+ rid = _repo(conn, p)
1128
+ if rid != int(ent["repository_id"]):
1129
+ raise ApiError("domain-entity-not-found",
1130
+ "entidade fora do repositório ativo", 404)
1131
+ sid = _snapshot(conn, rid, p)
1132
+ direction = p.get("direction", "both")
1133
+ if direction not in ("in", "out", "both"):
1134
+ raise ApiError("bad-param", "direction deve ser in|out|both")
1135
+ limit = _int(p, "limit", 100)
1136
+ occ_ids = Store(conn).domain_occurrences_for(sid, eid)
1137
+ edges: list[Any] = []
1138
+ if occ_ids:
1139
+ marks = _in_clause([str(o) for o in occ_ids])
1140
+ conds: list[str] = []
1141
+ args: list[Any] = [sid]
1142
+ if direction in ("out", "both"):
1143
+ conds.append(f"from_entity_occ_id IN ({marks})")
1144
+ args += occ_ids
1145
+ if direction in ("in", "both"):
1146
+ conds.append(f"to_entity_occ_id IN ({marks})")
1147
+ args += occ_ids
1148
+ ego_sql = (
1149
+ _DOMAIN_EDGE_SELECT +
1150
+ f"WHERE snapshot_id = ? AND ({' OR '.join(conds)}) "
1151
+ "ORDER BY id LIMIT ?")
1152
+ edges = conn.execute(ego_sql, (*args, limit)).fetchall()
1153
+ entity_dto = {
1154
+ "id": eid, "domain": ent["domain"], "kind": ent["kind"],
1155
+ "qualified_name": ent["qualified_name"], "name": ent["name"],
1156
+ "catalog": ent["catalog"], "namespace": ent["namespace"],
1157
+ "environment": ent["environment"],
1158
+ "attributes": json.loads(ent["attributes"] or "{}"),
1159
+ "occurrences": len(occ_ids),
1160
+ }
1161
+ return {"repository_id": rid, "snapshot_id": sid,
1162
+ "entity": entity_dto,
1163
+ "edges": _domain_edges_dto(conn, sid, edges),
1164
+ "truncated": len(edges) == limit}
1165
+
1166
+
1167
+ def _lineage_root(conn: Any, rid: int, table: str) -> Any:
1168
+ """Resolve ``?table=`` — id numérico ou qualified_name/name exato."""
1169
+ if table.isdigit():
1170
+ row = conn.execute(
1171
+ "SELECT * FROM domain_entities WHERE id = ? AND repository_id = ?",
1172
+ (int(table), rid)).fetchone()
1173
+ if row is None:
1174
+ raise ApiError("lineage-target-not-found",
1175
+ f"entidade {table} inexistente no repositório", 404)
1176
+ return row
1177
+ root_sql = (
1178
+ "SELECT * FROM domain_entities WHERE repository_id = ? "
1179
+ f"AND kind IN ({_in_clause(list(_LINEAGE_KINDS))}) "
1180
+ "AND (qualified_name = ? OR name = ?) ORDER BY id")
1181
+ rows = conn.execute(
1182
+ root_sql, (rid, *_LINEAGE_KINDS, table, table)).fetchall()
1183
+ if not rows:
1184
+ raise ApiError(
1185
+ "lineage-target-not-found",
1186
+ f"tabela {table!r} não encontrada — use o id da entidade "
1187
+ "ou o qualified_name exato", 404)
1188
+ if len(rows) > 1:
1189
+ cands = ", ".join(
1190
+ f"#{r['id']} {r['qualified_name']}" for r in rows[:5])
1191
+ raise ApiError(
1192
+ "lineage-target-ambiguous",
1193
+ f"{table!r} é ambíguo ({len(rows)} entidades: {cands}) — "
1194
+ "refaça com o id da entidade", 409)
1195
+ return rows[0]
1196
+
1197
+
1198
+ def _lineage_step(
1199
+ conn: Any, sid: int, ent_occs: list[int], sym_occs: list[int],
1200
+ upstream: bool,
1201
+ ) -> list[Any]:
1202
+ """Um passo de BFS — regras de direção por tipo de aresta.
1203
+
1204
+ upstream (contra o fluxo): ``N READS_FROM X``/``N DERIVES_FROM X``
1205
+ → X; ``tr WRITES_TO N``/``tr TRANSFORMS N`` → tr. Downstream é o
1206
+ espelho exato. Nada além do vocabulário de dados é atravessado.
1207
+ """
1208
+ if not ent_occs and not sym_occs:
1209
+ return []
1210
+ e_json = json.dumps(ent_occs)
1211
+ s_json = json.dumps(sym_occs)
1212
+ if upstream:
1213
+ cond = ("(type IN ('WRITES_TO','TRANSFORMS') AND ("
1214
+ "to_entity_occ_id IN (SELECT value FROM json_each(?)) OR "
1215
+ "to_symbol_occ_id IN (SELECT value FROM json_each(?)))) "
1216
+ "OR (type IN ('READS_FROM','DERIVES_FROM') AND ("
1217
+ "from_entity_occ_id IN (SELECT value FROM json_each(?)) OR "
1218
+ "from_symbol_occ_id IN (SELECT value FROM json_each(?))))")
1219
+ else:
1220
+ cond = ("(type IN ('WRITES_TO','TRANSFORMS') AND ("
1221
+ "from_entity_occ_id IN (SELECT value FROM json_each(?)) OR "
1222
+ "from_symbol_occ_id IN (SELECT value FROM json_each(?)))) "
1223
+ "OR (type IN ('READS_FROM','DERIVES_FROM') AND ("
1224
+ "to_entity_occ_id IN (SELECT value FROM json_each(?)) OR "
1225
+ "to_symbol_occ_id IN (SELECT value FROM json_each(?))))")
1226
+ step_sql = (
1227
+ _DOMAIN_EDGE_SELECT +
1228
+ f"WHERE snapshot_id = ? AND ({cond}) ORDER BY id")
1229
+ rows: list[Any] = conn.execute(
1230
+ step_sql, (sid, e_json, s_json, e_json, s_json)).fetchall()
1231
+ return rows
1232
+
1233
+
1234
+ def _lineage_neighbor(row: Any, upstream: bool) -> tuple[str, Any, str]:
1235
+ """Endpoint vizinho de um passo — ``(ref, id, side)``.
1236
+
1237
+ ``side`` diz qual endpoint da aresta é o vizinho: 'from' quando a
1238
+ fronteira tocou o ``to``, 'to' quando tocou o ``from``.
1239
+ """
1240
+ if upstream:
1241
+ if row["type"] in _LINEAGE_WRITE:
1242
+ return ("entity", row["from_entity_occ_id"], "from") \
1243
+ if row["from_entity_occ_id"] is not None else (
1244
+ "symbol", row["from_symbol_occ_id"], "from")
1245
+ return ("entity", row["to_entity_occ_id"], "to") \
1246
+ if row["to_entity_occ_id"] is not None else (
1247
+ "symbol", row["to_symbol_occ_id"], "to")
1248
+ if row["type"] in _LINEAGE_WRITE:
1249
+ return ("entity", row["to_entity_occ_id"], "to") \
1250
+ if row["to_entity_occ_id"] is not None else (
1251
+ "symbol", row["to_symbol_occ_id"], "to")
1252
+ return ("entity", row["from_entity_occ_id"], "from") \
1253
+ if row["from_entity_occ_id"] is not None else (
1254
+ "symbol", row["from_symbol_occ_id"], "from")
1255
+
1256
+
1257
+ def _lineage_absorb(
1258
+ conn: Any, store: Store, sid: int, row: Any, upstream: bool,
1259
+ dist: int, lane: str,
1260
+ nodes: dict[str, dict[str, Any]], visited: set[str],
1261
+ queue: list[tuple[str, list[int], list[int], int, str]],
1262
+ ) -> bool:
1263
+ """Absorve o vizinho de uma aresta — False quando o cap de nós bate."""
1264
+ kind, nid, _side = _lineage_neighbor(row, upstream)
1265
+ if nid is None:
1266
+ # alvo textual não resolvido — entra como nó honesto, nunca expande
1267
+ nref = f"unresolved:{row['id']}"
1268
+ nodes.setdefault(nref, {
1269
+ "ref": nref, "endpoint": "unresolved",
1270
+ "label": str(row["to_unresolved"] or ""),
1271
+ "distance": dist + 1, "lane": lane})
1272
+ return True
1273
+ eid: int | None = None
1274
+ if kind == "entity":
1275
+ # dedup por ENTIDADE — uma entidade pode ter várias ocorrências;
1276
+ # o ref público é ``de:{entity_id}``.
1277
+ erow = conn.execute(
1278
+ "SELECT entity_id FROM domain_entity_occurrences WHERE id = ?",
1279
+ (nid,)).fetchone()
1280
+ if erow is None:
1281
+ return True # endpoint fora do snapshot — aresta já consta
1282
+ eid = int(erow["entity_id"])
1283
+ nref = f"de:{eid}"
1284
+ else:
1285
+ nref = f"occ:{nid}"
1286
+ if nref in visited:
1287
+ return True
1288
+ visited.add(nref)
1289
+ if len(visited) > _LINEAGE_MAX_NODES:
1290
+ return False
1291
+ nodes[nref] = {"ref": nref, "occurrence_id": int(nid),
1292
+ "entity_id": eid, "distance": dist + 1, "lane": lane}
1293
+ if kind == "entity" and eid is not None:
1294
+ queue.append((nref, store.domain_occurrences_for(sid, eid), [],
1295
+ dist + 1, lane))
1296
+ else:
1297
+ queue.append((nref, [], [int(nid)], dist + 1, lane))
1298
+ return True
1299
+
1300
+
1301
+ def _lineage_bfs(
1302
+ conn: Any, sid: int, root: Any, direction: str, depth: int,
1303
+ limit: int,
1304
+ ) -> tuple[list[Any], dict[str, dict[str, Any]], bool]:
1305
+ """BFS bounded — retorna ``(edge_rows, nodes, truncated)``."""
1306
+ store = Store(conn)
1307
+ root_ref = f"de:{root['id']}"
1308
+ edges_by_id: dict[int, Any] = {}
1309
+ nodes: dict[str, dict[str, Any]] = {}
1310
+ visited: set[str] = {root_ref}
1311
+ truncated = False
1312
+ queue: list[tuple[str, list[int], list[int], int, str]] = [
1313
+ (root_ref, store.domain_occurrences_for(sid, int(root["id"])),
1314
+ [], 0, "root"),
1315
+ ]
1316
+ while queue and not truncated:
1317
+ _ref, ent_occs, sym_occs, dist, _lane = queue.pop(0)
1318
+ if dist >= depth:
1319
+ continue
1320
+ for upstream, lane in ((True, "upstream"), (False, "downstream")):
1321
+ if direction != "both" and direction != lane:
1322
+ continue
1323
+ for row in _lineage_step(
1324
+ conn, sid, ent_occs, sym_occs, upstream):
1325
+ if len(edges_by_id) >= limit:
1326
+ truncated = True
1327
+ break
1328
+ edges_by_id.setdefault(int(row["id"]), row)
1329
+ if not _lineage_absorb(conn, store, sid, row, upstream,
1330
+ dist, lane, nodes, visited, queue):
1331
+ truncated = True
1332
+ break
1333
+ if truncated:
1334
+ break
1335
+ return ([edges_by_id[k] for k in sorted(edges_by_id)],
1336
+ nodes, truncated)
1337
+
1338
+
1339
+ def _domain_lineage(server: _AtlasHttpd, conn: Any, p: dict[str, str],
1340
+ _b: dict[str, Any] | None) -> dict[str, Any]:
1341
+ """Linhagem bounded: BFS upstream/downstream sobre arestas de dados."""
1342
+ rid = _repo(conn, p)
1343
+ sid = _snapshot(conn, rid, p)
1344
+ table = p.get("table", "").strip()
1345
+ if not table or len(table) > 300:
1346
+ raise ApiError("bad-param", "table obrigatório (≤300 chars)")
1347
+ direction = p.get("direction", "both")
1348
+ if direction not in ("upstream", "downstream", "both"):
1349
+ raise ApiError("bad-param",
1350
+ "direction deve ser upstream|downstream|both")
1351
+ depth = _int(p, "depth", 4, cap=8)
1352
+ limit = _int(p, "limit", 200, cap=500) or 200
1353
+
1354
+ root = _lineage_root(conn, rid, table)
1355
+ root_ref = f"de:{root['id']}"
1356
+ edge_rows, nodes, truncated = _lineage_bfs(
1357
+ conn, sid, root, direction, depth, limit)
1358
+ edge_dtos = _domain_edges_dto(conn, sid, edge_rows)
1359
+
1360
+ # enriquece nós entity/symbol com os descritores já resolvidos
1361
+ endpoint_by_ref: dict[str, dict[str, Any]] = {}
1362
+ for dto in edge_dtos:
1363
+ for side in ("from", "to"):
1364
+ ep = dto[side]
1365
+ if ep["endpoint"] == "entity" and ep.get("entity_id") is not None:
1366
+ endpoint_by_ref.setdefault(f"de:{ep['entity_id']}", ep)
1367
+ elif (ep["endpoint"] == "symbol"
1368
+ and ep.get("occurrence_id") is not None):
1369
+ endpoint_by_ref.setdefault(f"occ:{ep['occurrence_id']}", ep)
1370
+ out_nodes: list[dict[str, Any]] = []
1371
+ for nref, meta in nodes.items():
1372
+ merged = dict(endpoint_by_ref.get(nref, {}))
1373
+ merged.update({k: v for k, v in meta.items() if k not in merged
1374
+ or merged.get(k) is None})
1375
+ merged["distance"] = meta["distance"]
1376
+ merged["lane"] = meta["lane"]
1377
+ out_nodes.append(merged)
1378
+ lane_rank = {"root": 0, "upstream": 1, "downstream": 2}
1379
+ out_nodes.sort(key=lambda n: (
1380
+ lane_rank.get(str(n.get("lane")), 9), int(n.get("distance", 0)),
1381
+ str(n.get("qualified_name") or n.get("label") or n.get("ref"))))
1382
+
1383
+ return {
1384
+ "repository_id": rid, "snapshot_id": sid,
1385
+ "root": {
1386
+ "ref": root_ref, "endpoint": "entity",
1387
+ "entity_id": int(root["id"]), "domain": root["domain"],
1388
+ "entity_kind": root["kind"],
1389
+ "qualified_name": root["qualified_name"],
1390
+ "name": root["name"], "distance": 0, "lane": "root",
1391
+ },
1392
+ "direction": direction, "depth": depth,
1393
+ "nodes": out_nodes, "edges": edge_dtos,
1394
+ "truncated": truncated,
1395
+ }
1396
+
1397
+
1398
+ _NO_MATCH = object() # sentinela: rota não é do grupo parametrizado
1399
+
1400
+
1401
+ def _route_get_detail(server: _AtlasHttpd, conn: Any, route: str,
1402
+ p: dict[str, str], body: Any) -> Any:
1403
+ """Despacha rotas GET ``/<recurso>/<id>`` — ``_NO_MATCH`` fora do escopo."""
1404
+ m = re.fullmatch(r"/repositories/(\d+)/snapshots", route)
1405
+ if m:
1406
+ return _snapshots(server, conn, m.group(1), body)
1407
+ m = re.fullmatch(r"/symbols/(\d+)", route)
1408
+ if m:
1409
+ return _symbol(server, conn, m.group(1), p)
1410
+ m = re.fullmatch(r"/evidence/(\d+)", route)
1411
+ if m:
1412
+ return _evidence(server, conn, m.group(1), p)
1413
+ m = re.fullmatch(r"/files/(\d+)/occurrences", route)
1414
+ if m:
1415
+ return _file_occurrences(server, conn, m.group(1), p)
1416
+ return _NO_MATCH
1417
+
1418
+
1419
+ def _route_domains(server: _AtlasHttpd, conn: Any, route: str,
1420
+ p: dict[str, str]) -> Any:
1421
+ """Despacha rotas GET de domínio — ``_NO_MATCH`` fora do escopo."""
1422
+ m = re.fullmatch(r"/domains/entities/(\d+)/neighbors", route)
1423
+ if m:
1424
+ return _domain_entity_neighbors(server, conn, m.group(1), p)
1425
+ return _NO_MATCH
1426
+
1427
+
1428
+ def _route_federation(server: _AtlasHttpd, conn: Any, route: str,
1429
+ p: dict[str, str]) -> Any:
1430
+ """Despacha rotas GET de federação — ``_NO_MATCH`` fora do escopo."""
1431
+ if route == "/federation/workspaces":
1432
+ return _fed_workspaces(server, conn, p, None)
1433
+ m = re.fullmatch(r"/federation/workspaces/(\d+)/view", route)
1434
+ if m:
1435
+ return _fed_view(server, conn, m.group(1), p)
1436
+ m = re.fullmatch(
1437
+ r"/federation/workspaces/(\d+)/entities/([^/]+)/occurrences",
1438
+ route)
1439
+ if m:
1440
+ return _fed_entity_occurrences(
1441
+ server, conn, m.group(1), m.group(2), p)
1442
+ m = re.fullmatch(r"/federation/workspaces/(\d+)/graph", route)
1443
+ if m:
1444
+ return _fed_graph(server, conn, m.group(1), p)
1445
+ m = re.fullmatch(r"/federation/workspaces/(\d+)/search", route)
1446
+ if m:
1447
+ return _fed_search(server, conn, m.group(1), p)
1448
+ m = re.fullmatch(r"/federation/workspaces/(\d+)", route)
1449
+ if m:
1450
+ return _fed_workspace(server, conn, m.group(1), p)
1451
+ return _NO_MATCH
1452
+
1453
+
1454
+ def handle(server: _AtlasHttpd, method: str, route: str,
1455
+ handler: BaseHTTPRequestHandler) -> Any:
1456
+ conn = server.connect()
1457
+ t0 = time.monotonic()
1458
+ try:
1459
+ p = _qs(handler)
1460
+ body = server_body(handler) if method == "POST" else None
1461
+ if method == "GET":
1462
+ detail = _route_get_detail(server, conn, route, p, body)
1463
+ if detail is not _NO_MATCH:
1464
+ return detail
1465
+ # federação (lf-0118) — rotas GET read-only
1466
+ fed_result = _route_federation(server, conn, route, p)
1467
+ if fed_result is not _NO_MATCH:
1468
+ return fed_result
1469
+ # domínios (lf-0122) — rotas GET read-only
1470
+ dom_result = _route_domains(server, conn, route, p)
1471
+ if dom_result is not _NO_MATCH:
1472
+ return dom_result
1473
+ simple = {
1474
+ "/health": _health, "/capabilities": _capabilities,
1475
+ "/diagnostics": _diagnostics,
1476
+ "/repositories": _repositories,
1477
+ "/graph/overview": _graph_overview,
1478
+ "/graph/neighbors": _graph_neighbors,
1479
+ "/search": _search, "/index/status": _index_status,
1480
+ "/domains": _domains,
1481
+ "/domains/entities": _domain_entities,
1482
+ "/domains/relations": _domain_relations,
1483
+ "/domains/lineage": _domain_lineage,
1484
+ }
1485
+ fn = simple.get(route)
1486
+ if fn:
1487
+ return fn(server, conn, p, body)
1488
+ elif method == "POST":
1489
+ if route == "/context":
1490
+ return _context(server, conn, p, body)
1491
+ if route == "/impact/analyze":
1492
+ return _impact(server, conn, p, body)
1493
+ raise ApiError("not-found", f"{method} {route} inexistente", 404)
1494
+ finally:
1495
+ conn.close()
1496
+ # lf-0141: slow-query guard — o request inteiro é "a query" para
1497
+ # o operador; acima de CODE_ATLAS_SLOW_MS vira linha DEBUG com a
1498
+ # rota crua (query string passa pelo RedactionFilter do logger).
1499
+ log_if_slow(_LOG, "request", t0,
1500
+ f"{method} {getattr(handler, 'path', route)}")
1501
+
1502
+
1503
+ def server_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
1504
+ from code_atlas.api.server import _content_length
1505
+ length = _content_length(handler.headers.get("Content-Length"))
1506
+ import json
1507
+ raw = handler.rfile.read(length) if length else b"{}"
1508
+ try:
1509
+ data = json.loads(raw)
1510
+ except json.JSONDecodeError as exc:
1511
+ raise ApiError("bad-json", "corpo não é JSON válido") from exc
1512
+ if not isinstance(data, dict):
1513
+ raise ApiError("bad-json", "corpo deve ser objeto JSON")
1514
+ return data