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,394 @@
1
+ """Servidor HTTP local — stdlib only, loopback, hardening de segurança.
2
+
3
+ Decisão (ADR cycle-6): ``http.server`` + ``ThreadingHTTPServer`` em vez de
4
+ FastAPI/Starlette — zero dependências novas (regra do repo), distribuição
5
+ mínima, superfície pequena e auditável. Read-only por contrato: a conexão
6
+ SQLite abre com ``read_only=True`` e nenhum handler escreve.
7
+
8
+ Segurança implementada:
9
+ - bind somente em ``127.0.0.1`` (host configurável mas nunca ``0.0.0.0``
10
+ por default);
11
+ - allowlist de ``Host`` (127.0.0.1/localhost[:porta]) — anti DNS-rebinding;
12
+ - ``Origin`` estrangeiro → 403 (CORS restritivo; sem Origin → permitido,
13
+ clientes locais/curl);
14
+ - ``Content-Length`` limitado; corpo JSON limitado; resposta limitada;
15
+ - rate limit por cliente em memória (API somente; assets estáticos isentos)
16
+ — 429 + ``Retry-After`` acima do teto;
17
+ - timeout por conexão — keep-alive ocioso e leitura travada são encerrados;
18
+ - erros tipados (``atlas-error``), nunca traceback ao cliente;
19
+ - ``X-Content-Type-Options``, ``Cache-Control: no-store``, sem cookies;
20
+ - sem execução de código indexado, sem leitura de arquivos arbitrária.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import contextlib
26
+ import json
27
+ import re
28
+ import sqlite3
29
+ import sys
30
+ import threading
31
+ import time
32
+ from collections import deque
33
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34
+ from pathlib import Path
35
+ from typing import Any
36
+ from urllib.parse import urlparse
37
+
38
+ from code_atlas.storage.db import connect
39
+
40
+ API_PREFIX = "/api/v1"
41
+ MAX_BODY = 64 * 1024 # 64 KiB — payloads de context/impact são pequenos
42
+ MAX_RESPONSE = 4 * 1024 * 1024 # 4 MiB
43
+ RATE_LIMIT = 300 # req/min por cliente, rotas /api/v1 apenas
44
+ RATE_WINDOW = 60.0
45
+ REQUEST_TIMEOUT = 30.0 # s — teto de ociosidade por conexão
46
+ _ALLOWED_HOSTS = re.compile(r"^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$")
47
+
48
+
49
+ def _content_length(raw: str | None) -> int:
50
+ """``Content-Length`` validado — negativo/não-inteiro nunca chega
51
+ ao ``read()`` (``read(-1)`` bloquearia até o timeout e um header
52
+ malformado virava 500 ``internal`` em vez de 400 tipado)."""
53
+ try:
54
+ length = int(raw or 0)
55
+ except (TypeError, ValueError) as exc:
56
+ raise ApiError("bad-content-length",
57
+ "Content-Length inválido", 400) from exc
58
+ if length < 0 or length > MAX_BODY:
59
+ raise ApiError("payload-too-large",
60
+ "corpo fora do limite (0..64 KiB)", 413)
61
+ return length
62
+
63
+
64
+ class ApiError(Exception):
65
+ """Erro tipado retornado ao cliente."""
66
+
67
+ def __init__(self, code: str, message: str, status: int = 400,
68
+ headers: dict[str, str] | None = None) -> None:
69
+ super().__init__(message)
70
+ self.code = code
71
+ self.message = message
72
+ self.status = status
73
+ self.headers = headers or {}
74
+
75
+
76
+ class _RateLimiter:
77
+ """Janela deslizante em memória por chave de cliente (loopback)."""
78
+
79
+ def __init__(self, limit: int, window: float = RATE_WINDOW) -> None:
80
+ self.limit = limit
81
+ self.window = window
82
+ self._hits: dict[str, deque[float]] = {}
83
+ self._lock = threading.Lock()
84
+
85
+ def allow(self, key: str) -> tuple[bool, float]:
86
+ """True se o request passa; senão False + segundos de Retry-After."""
87
+ now = time.monotonic()
88
+ with self._lock:
89
+ q = self._hits.setdefault(key, deque())
90
+ while q and now - q[0] >= self.window:
91
+ q.popleft()
92
+ if len(q) >= self.limit:
93
+ return False, max(1.0, self.window - (now - q[0]))
94
+ q.append(now)
95
+ return True, 0.0
96
+
97
+
98
+ def _security_headers(h: BaseHTTPRequestHandler, *, cors: bool) -> None:
99
+ h.send_header("X-Content-Type-Options", "nosniff")
100
+ h.send_header("Cache-Control", "no-store")
101
+ h.send_header("Referrer-Policy", "no-referrer")
102
+ h.send_header("X-Frame-Options", "DENY")
103
+ h.send_header(
104
+ "Content-Security-Policy",
105
+ "default-src 'self'; script-src 'self'; "
106
+ "style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
107
+ "connect-src 'self'; font-src 'self'; worker-src 'self'; "
108
+ "object-src 'none'; base-uri 'self'; form-action 'self'; "
109
+ "frame-ancestors 'none'")
110
+ if cors:
111
+ h.send_header("Access-Control-Allow-Origin", "http://127.0.0.1")
112
+ h.send_header("Vary", "Origin")
113
+
114
+
115
+ class _AtlasHandler(BaseHTTPRequestHandler):
116
+ server: _AtlasHttpd
117
+ protocol_version = "HTTP/1.1"
118
+ # lf-0153: ``Server: BaseHTTP/0.6 Python/3.12.3`` fingerprintava a
119
+ # stack em toda resposta — versão do produto só, sem a do runtime.
120
+ server_version = "code-atlas-api"
121
+ sys_version = ""
122
+
123
+ def setup(self) -> None:
124
+ super().setup()
125
+ self.connection.settimeout(self.server.request_timeout)
126
+
127
+ def handle(self) -> None:
128
+ try:
129
+ super().handle()
130
+ except TimeoutError:
131
+ # keep-alive ocioso ou leitura travada — encerra sem ruído
132
+ self.close_connection = True
133
+
134
+ def log_message(self, fmt: str, *args: Any) -> None: # quiet
135
+ self.server.log(format(fmt % args)) if self.server.verbose else None
136
+
137
+ # --- plumbing -----------------------------------------------------
138
+ def _json(self, payload: Any, status: int = 200,
139
+ extra_headers: dict[str, str] | None = None) -> None:
140
+ body = json.dumps(payload, ensure_ascii=False, default=str).encode()
141
+ if len(body) > MAX_RESPONSE:
142
+ err = {"error": {"code": "response-too-large",
143
+ "message": "resposta excedeu o limite interno"}}
144
+ body = json.dumps(err).encode()
145
+ status = 500
146
+ self.send_response(status)
147
+ self.send_header("Content-Type", "application/json; charset=utf-8")
148
+ self.send_header("Content-Length", str(len(body)))
149
+ for k, v in (extra_headers or {}).items():
150
+ self.send_header(k, v)
151
+ _security_headers(self, cors=self._origin_ok())
152
+ self.end_headers()
153
+ self.wfile.write(body)
154
+
155
+ def _error(self, e: Exception) -> None:
156
+ if isinstance(e, ApiError):
157
+ self._json({"error": {"code": e.code, "message": e.message}},
158
+ e.status, e.headers)
159
+ return
160
+ self._json({"error": {"code": "internal", "message": str(e)[:200]}}, 500)
161
+
162
+ def _origin_ok(self) -> bool:
163
+ origin = self.headers.get("Origin")
164
+ if not origin:
165
+ return True
166
+ host = urlparse(origin).netloc
167
+ return bool(_ALLOWED_HOSTS.match(host))
168
+
169
+ def _guard(self) -> None:
170
+ host = self.headers.get("Host", "")
171
+ if not _ALLOWED_HOSTS.match(host):
172
+ raise ApiError("bad-host", "Host não permitido", 403)
173
+ if not self._origin_ok():
174
+ raise ApiError("bad-origin", "Origin não permitida", 403)
175
+
176
+ def _body(self) -> dict[str, Any]:
177
+ length = _content_length(self.headers.get("Content-Length"))
178
+ raw = self.rfile.read(length) if length else b"{}"
179
+ try:
180
+ data = json.loads(raw)
181
+ except json.JSONDecodeError as exc:
182
+ raise ApiError("bad-json", "corpo não é JSON válido") from exc
183
+ if not isinstance(data, dict):
184
+ raise ApiError("bad-json", "corpo deve ser um objeto JSON")
185
+ return data
186
+
187
+ def _serve_static(self, path: str) -> None:
188
+ """Assets compilados do Explorer — allowlist de tipos, sem traversal."""
189
+ import mimetypes
190
+ assert self.server.static_root is not None
191
+ root = self.server.static_root.resolve()
192
+ rel = "index.html" if path in ("", "/") else path.lstrip("/")
193
+ target = (root / rel).resolve()
194
+ # Contenção real via pathlib — ``startswith`` em string aceita
195
+ # irmãos com prefixo comum (``static-evil`` sob ``static``) e
196
+ # permitia servir arquivos fora do root via ``..`` (lf-c8 F1).
197
+ if not target.is_relative_to(root) or not target.is_file():
198
+ # SPA fallback — rotas do Angular caem no index.html
199
+ target = root / "index.html"
200
+ if not target.is_file():
201
+ raise ApiError("not-found", "asset inexistente", 404)
202
+ allowed = {".html", ".js", ".css", ".json", ".svg", ".png", ".ico",
203
+ ".woff", ".woff2", ".txt", ".map"}
204
+ if target.suffix.lower() not in allowed:
205
+ raise ApiError("not-found", "tipo de asset não permitido", 404)
206
+ # lf-0153: stat ANTES do read — um asset gigante plantado na
207
+ # dist não pode ser carregado inteiro em memória só para ser
208
+ # rejeitado depois (o check era pós-leitura).
209
+ if target.stat().st_size > MAX_RESPONSE:
210
+ raise ApiError("response-too-large", "asset excede o limite", 500)
211
+ body = target.read_bytes()
212
+ if len(body) > MAX_RESPONSE:
213
+ raise ApiError("response-too-large", "asset excede o limite", 500)
214
+ ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
215
+ self.send_response(200)
216
+ self.send_header("Content-Type", ctype)
217
+ self.send_header("Content-Length", str(len(body)))
218
+ _security_headers(self, cors=False)
219
+ self.end_headers()
220
+ self.wfile.write(body)
221
+
222
+ # --- verbs ---------------------------------------------------------
223
+ def do_GET(self) -> None:
224
+ self._dispatch("GET")
225
+
226
+ def do_POST(self) -> None:
227
+ self._dispatch("POST")
228
+
229
+ def do_OPTIONS(self) -> None:
230
+ if not self._origin_ok():
231
+ self._json({"error": {"code": "bad-origin",
232
+ "message": "Origin não permitida"}}, 403)
233
+ return
234
+ self.send_response(204)
235
+ self.send_header("Access-Control-Allow-Origin", "http://127.0.0.1")
236
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
237
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
238
+ self.send_header("Access-Control-Max-Age", "300")
239
+ _security_headers(self, cors=True)
240
+ self.end_headers()
241
+
242
+ def _dispatch(self, method: str) -> None:
243
+ try:
244
+ self._guard()
245
+ # RFC 9110: o request-target nunca carrega controles C0/DEL.
246
+ # Rejeita cedo — depender do ``resolve()`` estourar em NUL é
247
+ # não-determinístico entre plataformas/versões (Win py3.13
248
+ # retorna 200 via SPA fallback onde POSIX/py3.12 dava 400).
249
+ if any(ord(c) < 0x20 or ord(c) == 0x7F for c in self.path):
250
+ raise ApiError(
251
+ "bad-request", "request-target malformado", 400)
252
+ path = urlparse(self.path).path
253
+ if not path.startswith(API_PREFIX):
254
+ if method == "GET" and self.server.static_root:
255
+ self._serve_static(path)
256
+ return
257
+ raise ApiError("not-found", "rota inexistente", 404)
258
+ allowed, retry = self.server.rate_limiter.allow(
259
+ self.client_address[0])
260
+ if not allowed:
261
+ raise ApiError(
262
+ "rate-limited",
263
+ "limite de requisições por minuto excedido", 429,
264
+ headers={"Retry-After": str(int(retry + 0.5))})
265
+ route = path[len(API_PREFIX):]
266
+ from code_atlas.api.handlers import handle
267
+ self._json(handle(self.server, method, route, self))
268
+ except ApiError as e:
269
+ self._error(e)
270
+ except ValueError:
271
+ # lf-0153: request-target hostil que estoura no parse —
272
+ # NUL em ``resolve()`` (``GET /a\x00b``), IPv6 malformado
273
+ # em absolute-URI (``GET http://[``) — era 500 ``internal``
274
+ # para o que é erro do cliente; agora 400 tipado.
275
+ self._error(ApiError(
276
+ "bad-request", "request-target malformado", 400))
277
+ except TimeoutError:
278
+ with contextlib.suppress(OSError):
279
+ self._error(ApiError(
280
+ "request-timeout", "tempo de request excedido", 408))
281
+ except Exception as e: # nunca expor traceback
282
+ self._error(e)
283
+
284
+
285
+ class _AtlasHttpd(ThreadingHTTPServer):
286
+ daemon_threads = True
287
+ allow_reuse_address = True
288
+
289
+ def __init__(self, addr: tuple[str, int], db_path: str,
290
+ verbose: bool = False,
291
+ static_root: Path | None = None,
292
+ rate_limit: int = RATE_LIMIT,
293
+ request_timeout: float = REQUEST_TIMEOUT) -> None:
294
+ super().__init__(addr, _AtlasHandler)
295
+ self.db_path = db_path
296
+ self.verbose = verbose
297
+ # lf-0141: instante de subida para o uptime do ``/diagnostics``.
298
+ self.started_at = time.monotonic()
299
+ self.static_root = static_root
300
+ self.request_timeout = request_timeout
301
+ self.rate_limiter = _RateLimiter(rate_limit)
302
+
303
+ def log(self, msg: str) -> None:
304
+ # lf-0153: a request line crua carrega bytes de controle do
305
+ # cliente (``GET /\x1b[31m``) — escape antes de tocar o stderr,
306
+ # senão ``--verbose`` vira canal de ANSI/forged-line injection.
307
+ from code_atlas.observability.logging import escape_control
308
+ print(f"[atlas-api] {escape_control(msg)}", flush=True)
309
+
310
+ def handle_error(self, request: Any, client_address: Any) -> None:
311
+ # lf-0153: ``socketserver`` despejava um traceback por conexão
312
+ # RST — um cliente loopback hostil inundava stderr à vontade
313
+ # (log-flood). Erros de transporte são rotina e calam; bugs
314
+ # reais de handler continuam com o traceback default.
315
+ exc = sys.exc_info()[1]
316
+ if isinstance(exc, (ConnectionError, TimeoutError)):
317
+ return
318
+ super().handle_error(request, client_address)
319
+
320
+ def connect(self) -> sqlite3.Connection:
321
+ conn = connect(Path(self.db_path), read_only=True)
322
+ conn.execute("PRAGMA query_only = ON")
323
+ return conn
324
+
325
+
326
+ class AtlasApiServer:
327
+ """Wrapper de ciclo de vida — usado pela CLI e testes."""
328
+
329
+ def __init__(self, db_path: str, host: str = "127.0.0.1",
330
+ port: int = 8741, verbose: bool = False,
331
+ static_root: Path | None = None,
332
+ rate_limit: int = RATE_LIMIT,
333
+ request_timeout: float = REQUEST_TIMEOUT) -> None:
334
+ if host not in ("127.0.0.1", "localhost", "::1"):
335
+ raise ApiError("unsafe-bind",
336
+ "bind público proibido — use 127.0.0.1")
337
+ self._httpd = _AtlasHttpd(
338
+ (host, port), db_path, verbose, static_root=static_root,
339
+ rate_limit=rate_limit, request_timeout=request_timeout)
340
+ self.port = self._httpd.server_address[1]
341
+ self.host = host
342
+
343
+ @property
344
+ def url(self) -> str:
345
+ return f"http://{self.host}:{self.port}{API_PREFIX}"
346
+
347
+ def serve_forever(self) -> None:
348
+ self._httpd.serve_forever()
349
+
350
+ def shutdown(self) -> None:
351
+ self._httpd.shutdown()
352
+ self._httpd.server_close()
353
+
354
+
355
+ def explorer_static_root(repo_hint: Path | None = None) -> Path | None:
356
+ """Resolve a dist compilada do Explorer para ``static_root``.
357
+
358
+ Ordem (lf-0148, cycle-8.1 Phase 3): ``code_atlas/explorer_dist``
359
+ empacotado no wheel via ``importlib.resources`` → checkout do repo
360
+ em modo dev (``explorer/dist/explorer/browser`` ou
361
+ ``explorer/dist`` relativo a ``repo_hint``). Retorna ``None``
362
+ quando nenhum candidato tem ``index.html`` — o servidor sobe
363
+ API-only nesse caso.
364
+
365
+ Wheels são sempre instaladas descompactadas, então o traversable é
366
+ um ``Path`` real; loaders exóticos (zipimport) caem no fallback em
367
+ vez de quebrar.
368
+ """
369
+ try:
370
+ from importlib.resources import files
371
+ packaged = files("code_atlas").joinpath("explorer_dist")
372
+ if (isinstance(packaged, Path)
373
+ and (packaged / "index.html").is_file()):
374
+ return packaged
375
+ except (ModuleNotFoundError, TypeError, OSError):
376
+ pass
377
+ if repo_hint is not None:
378
+ for candidate in (
379
+ repo_hint / "explorer" / "dist" / "explorer" / "browser",
380
+ repo_hint / "explorer" / "dist"):
381
+ if (candidate / "index.html").is_file():
382
+ return candidate
383
+ return None
384
+
385
+
386
+ def serve(db_path: str, host: str = "127.0.0.1", port: int = 8741,
387
+ verbose: bool = False,
388
+ static_root: Path | None = None,
389
+ rate_limit: int = RATE_LIMIT,
390
+ request_timeout: float = REQUEST_TIMEOUT) -> AtlasApiServer:
391
+ """Sobe o servidor — retorna handle para shutdown limpo."""
392
+ return AtlasApiServer(db_path, host=host, port=port, verbose=verbose,
393
+ static_root=static_root, rate_limit=rate_limit,
394
+ request_timeout=request_timeout)