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,251 @@
1
+ """Ciclo de vida do cliente LSP (lf-0018).
2
+
3
+ spawn → initialize (handshake) → capability negotiation → requests com
4
+ timeout + ``$/cancelRequest`` → shutdown/exit → cleanup de processo.
5
+ Um servidor que trava ou morre vira ``unavailable`` — nunca bloqueia o
6
+ pipeline de indexação.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import itertools
13
+ import threading
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from code_atlas.resolvers.lsp.spawn import (
18
+ LspProcess,
19
+ LspServerSpec,
20
+ SpawnError,
21
+ release_server,
22
+ spawn_server,
23
+ )
24
+ from code_atlas.resolvers.lsp.transport import (
25
+ JsonRpcTransport,
26
+ TransportError,
27
+ )
28
+
29
+ INIT_TIMEOUT_S = 15.0
30
+ SHUTDOWN_TIMEOUT_S = 5.0
31
+
32
+ # métodos que o Atlas usa — cada um só é chamado se o servidor anunciar
33
+ _METHOD_TO_CAP = {
34
+ "textDocument/definition": "definitionProvider",
35
+ "textDocument/references": "referencesProvider",
36
+ "textDocument/documentSymbol": "documentSymbolProvider",
37
+ "workspace/symbol": "workspaceSymbolProvider",
38
+ }
39
+
40
+
41
+ class LspError(RuntimeError):
42
+ """Falha de protocolo/servidor — sempre diagnóstico, nunca crash."""
43
+
44
+
45
+ @dataclass
46
+ class ServerCapabilities:
47
+ """Subconjunto negociado do resultado de ``initialize``."""
48
+
49
+ definition: bool = False
50
+ references: bool = False
51
+ document_symbol: bool = False
52
+ workspace_symbol: bool = False
53
+ server_name: str = ""
54
+ server_version: str = ""
55
+
56
+ def supports(self, method: str) -> bool:
57
+ cap = _METHOD_TO_CAP.get(method)
58
+ return bool(getattr(self, {
59
+ "definitionProvider": "definition",
60
+ "referencesProvider": "references",
61
+ "documentSymbolProvider": "document_symbol",
62
+ "workspaceSymbolProvider": "workspace_symbol",
63
+ }.get(cap or "", ""), False)) if cap else False
64
+
65
+
66
+ @dataclass
67
+ class LspClient:
68
+ """Cliente stdio de um servidor LSP (um por spec habilitada)."""
69
+
70
+ spec: LspServerSpec
71
+ _proc: LspProcess | None = field(default=None, init=False)
72
+ _transport: JsonRpcTransport | None = field(default=None, init=False)
73
+ _ids: itertools.count[int] = field(
74
+ default_factory=lambda: itertools.count(1), init=False)
75
+ _caps: ServerCapabilities = field(
76
+ default_factory=ServerCapabilities, init=False)
77
+ _available: bool = field(default=False, init=False)
78
+ _lock: threading.Lock = field(default_factory=threading.Lock,
79
+ init=False)
80
+ # textDocument/publishDiagnostics por uri (lf-0029)
81
+ _diagnostics: dict[str, list[dict[str, Any]]] = field(
82
+ default_factory=dict, init=False)
83
+
84
+ # -- ciclo de vida ----------------------------------------------------
85
+
86
+ def start(self) -> None:
87
+ """Spawn + handshake initialize; falhas → unavailable."""
88
+ try:
89
+ self._proc = spawn_server(self.spec)
90
+ except SpawnError as exc:
91
+ raise LspError(str(exc)) from exc
92
+ assert self._proc.stdin and self._proc.stdout
93
+ self.attach(JsonRpcTransport(self._proc.stdout, self._proc.stdin))
94
+
95
+ def attach(self, transport: JsonRpcTransport) -> None:
96
+ """Conecta um transporte pronto (testes injetam pipes in-process)
97
+ e faz o handshake initialize."""
98
+ self._transport = transport
99
+ try:
100
+ self.handshake()
101
+ except (LspError, TimeoutError, TransportError):
102
+ self.close()
103
+ raise
104
+
105
+ def handshake(self) -> None:
106
+ """``initialize`` → negocia capabilities → ``initialized``."""
107
+ result = self.request(
108
+ "initialize",
109
+ {
110
+ "processId": None,
111
+ "rootUri": self.spec.root_uri,
112
+ "capabilities": {},
113
+ },
114
+ timeout=min(INIT_TIMEOUT_S, self.spec.request_timeout_s),
115
+ )
116
+ self._parse_initialize(result)
117
+ self.notify("initialized", {})
118
+ self._available = True
119
+
120
+ def _parse_initialize(self, result: Any) -> None:
121
+ caps = (result or {}).get("capabilities", {})
122
+ info = (result or {}).get("serverInfo", {})
123
+ self._caps = ServerCapabilities(
124
+ definition=bool(caps.get("definitionProvider")),
125
+ references=bool(caps.get("referencesProvider")),
126
+ document_symbol=bool(caps.get("documentSymbolProvider")),
127
+ workspace_symbol=bool(caps.get("workspaceSymbolProvider")),
128
+ server_name=info.get("name", self.spec.name),
129
+ server_version=info.get("version", ""),
130
+ )
131
+
132
+ @property
133
+ def available(self) -> bool:
134
+ """Servidor respondendo: handshake feito + transporte vivo +
135
+ processo rodando (quando há processo real)."""
136
+ if not self._available or self._transport is None:
137
+ return False
138
+ if self._proc is not None:
139
+ return self._proc.poll() is None
140
+ return True # transporte injetado (testes)
141
+
142
+ @property
143
+ def capabilities(self) -> ServerCapabilities:
144
+ return self._caps
145
+
146
+ # -- requests ---------------------------------------------------------
147
+
148
+ def request(
149
+ self, method: str, params: dict[str, Any] | None = None,
150
+ timeout: float | None = None,
151
+ ) -> Any:
152
+ """Request com timeout obrigatório + cancelamento LSP."""
153
+ if self._transport is None:
154
+ raise LspError("transport not started")
155
+ timeout = timeout or self.spec.request_timeout_s
156
+ rid = next(self._ids)
157
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "id": rid, "method": method}
158
+ if params is not None:
159
+ payload["params"] = params
160
+ try:
161
+ self._transport.send(payload)
162
+ while True:
163
+ msg = self._transport.recv(timeout)
164
+ if msg.is_notification or msg.request_id != rid:
165
+ self._capture_notification(msg)
166
+ continue # notificações/server-requests não correlacionados
167
+ if msg.error:
168
+ raise LspError(
169
+ f"{method}: rpc error {msg.error.get('code')}: "
170
+ f"{msg.error.get('message')}")
171
+ return msg.result
172
+ except TimeoutError:
173
+ self._cancel(rid)
174
+ raise
175
+ except TransportError as exc:
176
+ self._available = False # stream morreu → crash detectado
177
+ raise LspError(f"{method}: transport died: {exc}") from exc
178
+
179
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
180
+ if self._transport is None:
181
+ return
182
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
183
+ if params is not None:
184
+ payload["params"] = params
185
+ try:
186
+ self._transport.send(payload)
187
+ except TransportError:
188
+ self._available = False
189
+
190
+ def _cancel(self, rid: int) -> None:
191
+ """``$/cancelRequest`` — cancelamento real LSP após timeout."""
192
+ self.notify("$/cancelRequest", {"id": rid})
193
+
194
+ def _capture_notification(self, msg: Any) -> None:
195
+ """lf-0029: ``textDocument/publishDiagnostics`` é a única
196
+ notificação que guardamos — por uri, para o adapter anexar."""
197
+ method = getattr(msg, "method", "")
198
+ if method == "textDocument/publishDiagnostics":
199
+ params = getattr(msg, "params", None) or {}
200
+ uri = params.get("uri")
201
+ if isinstance(uri, str):
202
+ self._diagnostics[uri] = list(
203
+ params.get("diagnostics") or [])
204
+
205
+ def diagnostics_for(self, uri: str) -> list[dict[str, Any]]:
206
+ """Diagnostics publicados para o documento (se o server envia)."""
207
+ return list(self._diagnostics.get(uri, ()))
208
+
209
+ def document_symbol(self, uri: str) -> Any:
210
+ """``textDocument/documentSymbol`` — só se capability anunciada."""
211
+ if not self._caps.document_symbol:
212
+ raise LspError("documentSymbolProvider not advertised")
213
+ return self.request(
214
+ "textDocument/documentSymbol",
215
+ {"textDocument": {"uri": uri}})
216
+
217
+ def restart(self) -> None:
218
+ """lf-0029: recovery pós-crash — fecha tudo e refaz
219
+ spawn+handshake. Bounded: quem chama decide tentativas."""
220
+ self.close()
221
+ self.start()
222
+
223
+ def shutdown(self) -> None:
224
+ """Sequência ordenada shutdown → exit."""
225
+ if self._transport is None:
226
+ return
227
+ with contextlib.suppress(LspError, TimeoutError, TransportError):
228
+ self.request("shutdown", None, timeout=SHUTDOWN_TIMEOUT_S)
229
+ self.notify("exit")
230
+ self.close()
231
+
232
+ def close(self) -> None:
233
+ """Fecha transporte e mata processo (zombie cleanup)."""
234
+ self._available = False
235
+ if self._transport is not None:
236
+ self._transport.close()
237
+ self._transport = None
238
+ self._diagnostics.clear()
239
+ if self._proc is not None:
240
+ release_server(self._proc)
241
+ self._proc = None
242
+
243
+
244
+ def make_client(spec: LspServerSpec) -> LspClient | None:
245
+ """Fábrica segura: spec desabilitado → None; spawn/handshake falho →
246
+ ``LspError`` (caller vira diagnóstico)."""
247
+ if not spec.enabled:
248
+ return None
249
+ client = LspClient(spec)
250
+ client.start()
251
+ return client
@@ -0,0 +1,92 @@
1
+ """Único site de subprocess para servidores LSP (lf-0018).
2
+
3
+ Mesmo padrão de confinamento de ``gitbin.py``: argv sem shell, binário
4
+ resolvido via ``shutil.which``, comando NUNCA derivado do conteúdo do
5
+ repositório indexado — somente de configuração explícita do usuário
6
+ (``config.toml [resolvers.lsp]``). Este arquivo está na whitelist do
7
+ teste de confinamento de subprocess.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+
16
+ # Guardrails de recursos (documentados no review):
17
+ MAX_LSP_SERVERS = 4 # processos simultâneos máximos
18
+ SPAWN_ENV_PREFIXES = ("PATH=", "HOME=", "LANG=", "LC_", "SYSTEMROOT=",
19
+ "WINDIR=", "TEMP=", "TMP=")
20
+
21
+ _active_count = 0
22
+
23
+
24
+ # Tipo do processo spawnado — lifecycle anota via este alias (o audit
25
+ # confina `import subprocess` a este arquivo).
26
+ LspProcess = subprocess.Popen[bytes]
27
+
28
+
29
+ class SpawnError(RuntimeError):
30
+ """Binário ausente, limite atingido ou falha de spawn."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class LspServerSpec:
35
+ """Configuração explícita de um servidor LSP (opt-in do usuário)."""
36
+
37
+ name: str
38
+ cmd: tuple[str, ...] # argv completo — nunca do repo
39
+ languages: frozenset[str] # linguagens cobertas ("" = qualquer)
40
+ root_uri: str = "file:///" # rootUri do initialize
41
+ enabled: bool = False # opt-in: desligado por padrão
42
+ request_timeout_s: float = 10.0
43
+
44
+
45
+ def _leak_env() -> dict[str, str]:
46
+ """Ambiente minimizado — só variáveis necessárias para stdio."""
47
+ import os
48
+
49
+ env: dict[str, str] = {}
50
+ for k, v in os.environ.items():
51
+ if any((k.startswith(p.rstrip("=")) and k == p.rstrip("=")) or
52
+ k.startswith(p) for p in SPAWN_ENV_PREFIXES):
53
+ env[k] = v
54
+ return env
55
+
56
+
57
+ def spawn_server(spec: LspServerSpec) -> subprocess.Popen[bytes]:
58
+ """Spawna o servidor stdio; nunca levanta por binário ausente —
59
+ devolve erro tipado via ``SpawnError``."""
60
+ global _active_count
61
+ if not spec.cmd:
62
+ raise SpawnError(f"lsp {spec.name}: empty command")
63
+ binary = shutil.which(spec.cmd[0])
64
+ if binary is None:
65
+ raise SpawnError(f"lsp {spec.name}: binary {spec.cmd[0]!r} not found")
66
+ if _active_count >= MAX_LSP_SERVERS:
67
+ raise SpawnError(
68
+ f"lsp: server limit {MAX_LSP_SERVERS} reached")
69
+ try:
70
+ proc = subprocess.Popen(
71
+ [binary, *spec.cmd[1:]],
72
+ stdin=subprocess.PIPE,
73
+ stdout=subprocess.PIPE,
74
+ stderr=subprocess.DEVNULL,
75
+ env=_leak_env(),
76
+ )
77
+ except OSError as exc:
78
+ raise SpawnError(f"lsp {spec.name}: spawn failed: {exc}") from exc
79
+ _active_count += 1
80
+ return proc
81
+
82
+
83
+ def release_server(proc: subprocess.Popen[bytes]) -> None:
84
+ """Encerra e reaps o processo (sem zombies)."""
85
+ global _active_count
86
+ try:
87
+ if proc.poll() is None:
88
+ proc.kill()
89
+ proc.wait(timeout=5)
90
+ except (OSError, subprocess.TimeoutExpired):
91
+ pass
92
+ _active_count = max(0, _active_count - 1)
@@ -0,0 +1,240 @@
1
+ """Transporte JSON-RPC 2.0 sobre stdio com framing Content-Length (lf-0018).
2
+
3
+ Funciona sobre qualquer par (reader, writer) de streams com ``fileno()``
4
+ — pipes reais de um processo filho ou pipes in-process dos testes.
5
+ Regras de segurança:
6
+
7
+ - Leitura via ``select`` sobre o fd + ``os.read`` — a thread daemon
8
+ acorda a cada 100ms para conferir o flag de fechamento: ``close()``
9
+ nunca depende do lock de um BufferedReader bloqueado (deadlock).
10
+ - Recebimento sempre com timeout — um servidor travado nunca bloqueia
11
+ o caller para sempre.
12
+ - Payload máximo configurável (DoS).
13
+ - Erros de framing/JSON viram ``TransportError`` com contexto.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import json
20
+ import os
21
+ import queue
22
+ import select
23
+ import threading
24
+ from dataclasses import dataclass
25
+ from typing import IO, Any
26
+
27
+ MAX_MESSAGE_BYTES = 16 * 1024 * 1024 # 16 MiB por mensagem
28
+ _POLL_S = 0.1
29
+ _IS_WINDOWS = os.name == "nt" # select() no Windows só aceita sockets
30
+ # Windows: readers cujo fd não pode ser fechado com read pendente —
31
+ # mantidos vivos até o exit do processo para não travar o close().
32
+ _LEAKED_READERS: set[IO[bytes]] = set()
33
+
34
+
35
+ class TransportError(RuntimeError):
36
+ """Framing inválido, stream fechado ou mensagem corrompida."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class RpcResponse:
41
+ """Resposta JSON-RPC (result OU error, nunca os dois)."""
42
+
43
+ request_id: int | str | None
44
+ result: Any = None
45
+ error: dict[str, Any] | None = None
46
+ is_notification: bool = False
47
+ method: str = ""
48
+ params: Any = None
49
+
50
+
51
+ class _FdReader:
52
+ """Reader incremental sobre um fd — ``os.read``+``select``, sem o
53
+ lock de BufferedReader que causava deadlock em ``close()``."""
54
+
55
+ def __init__(self, fd: int, closed: threading.Event) -> None:
56
+ self._fd = fd
57
+ self._closed = closed
58
+ self._buf = bytearray()
59
+
60
+ def _read_some(self) -> bytes | None:
61
+ """Chunk novo ou None (EOF/fechado). Polla ``_closed`` a cada
62
+ 100ms para shutdown limpo. No Windows ``select`` não aceita
63
+ pipes — usa ``os.read`` bloqueante; ``close()`` destrava a
64
+ thread fechando o fd."""
65
+ while not self._closed.is_set():
66
+ if _IS_WINDOWS:
67
+ try:
68
+ return os.read(self._fd, 65536) or None
69
+ except OSError:
70
+ return None
71
+ try:
72
+ ready, _, _ = select.select([self._fd], [], [], _POLL_S)
73
+ except (OSError, ValueError):
74
+ return None
75
+ if not ready:
76
+ continue
77
+ try:
78
+ data = os.read(self._fd, 65536)
79
+ except OSError:
80
+ return None
81
+ if not data:
82
+ return None
83
+ return data
84
+ return None
85
+
86
+ def readline(self) -> bytes | None:
87
+ """Linha terminada em \\n ou None (EOF/fechado)."""
88
+ while True:
89
+ idx = self._buf.find(b"\n")
90
+ if idx >= 0:
91
+ line = bytes(self._buf[: idx + 1])
92
+ del self._buf[: idx + 1]
93
+ return line
94
+ chunk = self._read_some()
95
+ if chunk is None:
96
+ return None
97
+ self._buf += chunk
98
+
99
+ def read_exact(self, n: int) -> bytes | None:
100
+ """Exatamente ``n`` bytes ou None (EOF/fechado)."""
101
+ while len(self._buf) < n:
102
+ chunk = self._read_some()
103
+ if chunk is None:
104
+ return None
105
+ self._buf += chunk
106
+ out = bytes(self._buf[:n])
107
+ del self._buf[:n]
108
+ return out
109
+
110
+
111
+ class JsonRpcTransport:
112
+ """Framing LSP: ``Content-Length: N\\r\\n\\r\\n<json>``.
113
+
114
+ Thread daemon lê mensagens continuamente e as deposita numa queue;
115
+ ``recv`` consome com timeout — servidor pendurado → ``TimeoutError``.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ reader: IO[bytes],
121
+ writer: IO[bytes],
122
+ *,
123
+ max_bytes: int = MAX_MESSAGE_BYTES,
124
+ ) -> None:
125
+ self._writer = writer
126
+ self._max = max_bytes
127
+ self._inbox: queue.Queue[RpcResponse | BaseException] = queue.Queue()
128
+ self._write_lock = threading.Lock()
129
+ self._closed = threading.Event()
130
+ try:
131
+ fd = reader.fileno()
132
+ except (OSError, AttributeError) as exc:
133
+ raise TransportError(
134
+ f"reader has no usable fileno: {exc}") from exc
135
+ # guarda o objeto reader — se ele for coletado, o fd morre junto
136
+ # e o transport fica surdo silenciosamente
137
+ self._reader = reader
138
+ self._reader_fd = fd
139
+ self._fd_reader = _FdReader(fd, self._closed)
140
+ self._reader_thread = threading.Thread(
141
+ target=self._read_loop, daemon=True, name="atlas-lsp-reader")
142
+ self._reader_thread.start()
143
+
144
+ # -- leitura ---------------------------------------------------------
145
+
146
+ def _read_loop(self) -> None:
147
+ try:
148
+ while not self._closed.is_set():
149
+ msg = self._read_message()
150
+ if msg is None:
151
+ break
152
+ self._inbox.put(msg)
153
+ except BaseException as exc: # stream morreu → erro na inbox
154
+ self._inbox.put(exc)
155
+ finally:
156
+ self._closed.set()
157
+
158
+ def _read_headers(self) -> int | None:
159
+ """None quando o stream fecha de forma limpa."""
160
+ length: int | None = None
161
+ while True:
162
+ line = self._fd_reader.readline()
163
+ if line is None:
164
+ return None
165
+ line = line.strip()
166
+ if not line:
167
+ break
168
+ if line.lower().startswith(b"content-length:"):
169
+ try:
170
+ length = int(line.split(b":", 1)[1].strip())
171
+ except ValueError as exc:
172
+ raise TransportError(
173
+ f"bad Content-Length: {line!r}") from exc
174
+ if length is None:
175
+ raise TransportError("missing Content-Length header")
176
+ if length > self._max or length <= 0:
177
+ raise TransportError(f"message size {length} out of bounds")
178
+ return length
179
+
180
+ def _read_message(self) -> RpcResponse | None:
181
+ length = self._read_headers()
182
+ if length is None:
183
+ return None
184
+ body = self._fd_reader.read_exact(length)
185
+ if body is None or len(body) != length:
186
+ raise TransportError("truncated body / stream closed")
187
+ try:
188
+ obj = json.loads(body.decode("utf-8"))
189
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
190
+ raise TransportError(f"invalid json payload: {exc}") from exc
191
+ if not isinstance(obj, dict):
192
+ raise TransportError("json-rpc payload is not an object")
193
+ return RpcResponse(
194
+ request_id=obj.get("id"),
195
+ result=obj.get("result"),
196
+ error=obj.get("error"),
197
+ is_notification="id" not in obj,
198
+ method=obj.get("method", ""),
199
+ params=obj.get("params"),
200
+ )
201
+
202
+ # -- API pública ------------------------------------------------------
203
+
204
+ def send(self, payload: dict[str, Any]) -> None:
205
+ """Serializa e envia uma mensagem JSON-RPC."""
206
+ body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
207
+ frame = f"Content-Length: {len(body)}\r\n\r\n".encode() + body
208
+ with self._write_lock:
209
+ try:
210
+ self._writer.write(frame)
211
+ self._writer.flush()
212
+ except (OSError, ValueError) as exc:
213
+ raise TransportError(f"send failed: {exc}") from exc
214
+
215
+ def recv(self, timeout: float) -> RpcResponse:
216
+ """Próxima mensagem com timeout obrigatório."""
217
+ try:
218
+ item = self._inbox.get(timeout=timeout)
219
+ except queue.Empty as exc:
220
+ raise TimeoutError(
221
+ f"no LSP message within {timeout}s") from exc
222
+ if isinstance(item, BaseException):
223
+ raise item
224
+ return item
225
+
226
+ def close(self) -> None:
227
+ """Acorda a thread leitora (≤100ms) e fecha os streams — sem
228
+ deadlock: o fd é fechado depois do poll sair. No Windows o
229
+ read é bloqueante e fechar um handle com I/O pendente trava —
230
+ o fd fica vivo até o exit do processo (daemon thread morre
231
+ junto; pipes de teste recebem EOF quando o peer fecha)."""
232
+ self._closed.set()
233
+ self._reader_thread.join(timeout=1.0)
234
+ if _IS_WINDOWS:
235
+ _LEAKED_READERS.add(self._reader)
236
+ else:
237
+ with contextlib.suppress(OSError):
238
+ os.close(self._reader_fd)
239
+ with contextlib.suppress(OSError):
240
+ self._writer.close()
@@ -0,0 +1,33 @@
1
+ """Registro de resolvedores (resolver_id → SemanticResolver)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from code_atlas.resolvers.contracts import SemanticResolver
6
+
7
+
8
+ class ResolverRegistry:
9
+ """Registro explícito — nenhum resolvedor é ativado por padrão.
10
+
11
+ Resolvers opt-in (SCIP/LSP) são registrados pelo caller; o registro
12
+ nunca os constrói sozinho a partir do conteúdo do repositório.
13
+ """
14
+
15
+ def __init__(self) -> None:
16
+ self._resolvers: dict[str, SemanticResolver] = {}
17
+
18
+ def register(self, resolver: SemanticResolver) -> None:
19
+ self._resolvers[resolver.resolver_id] = resolver
20
+
21
+ def get(self, resolver_id: str) -> SemanticResolver | None:
22
+ return self._resolvers.get(resolver_id)
23
+
24
+ def resolvers(self) -> tuple[SemanticResolver, ...]:
25
+ return tuple(self._resolvers.values())
26
+
27
+ def capable(self, language: str, relation_type: str) -> list[SemanticResolver]:
28
+ """Resolvers que cobrem a linguagem+relação, ordenados por tier."""
29
+ ok = [
30
+ r for r in self._resolvers.values()
31
+ if r.capabilities().covers(language, relation_type)
32
+ ]
33
+ return sorted(ok, key=lambda r: r.capabilities().tier)
@@ -0,0 +1,49 @@
1
+ """Integração SCIP (opt-in) — decoder stdlib + resolver semântico.
2
+
3
+ Cobertura do schema ``scip.proto`` (documentada por campo):
4
+
5
+ Interpretados:
6
+ - Index.metadata{version, tool_info{name,version,arguments},
7
+ project_root, text_document_encoding}
8
+ - Index.documents[] e Index.external_symbols[]
9
+ - Document{language, relative_path, occurrences, symbols, text(ignorado
10
+ na correlação), position_encoding}
11
+ - Occurrence{range(deprec.), single_line_range, multi_line_range, symbol,
12
+ symbol_roles, override_documentation, enclosing_range}
13
+ - SymbolInformation{symbol, documentation, relationships, kind,
14
+ display_name, signature_documentation, enclosing_symbol}
15
+ - Relationship{symbol, is_reference, is_implementation,
16
+ is_type_definition, is_definition}
17
+
18
+ Preservados-na-decodificação mas não semânticos hoje:
19
+ - syntax_kind, diagnostics (occurrence)
20
+ - quaisquer campos futuros: skipados por wire-type (forward-compat)
21
+
22
+ Símbolos ``local N`` permanecem locais — nunca promovidos a identidade
23
+ global.
24
+ """
25
+
26
+ from code_atlas.resolvers.scip.decoder import DecodeError
27
+ from code_atlas.resolvers.scip.importer import (
28
+ ScipDocument,
29
+ ScipIndex,
30
+ ScipOccurrence,
31
+ ScipRange,
32
+ ScipSymbolInfo,
33
+ parse_index,
34
+ )
35
+ from code_atlas.resolvers.scip.loader import load_index
36
+ from code_atlas.resolvers.scip.resolver import RESOLVER_ID, ScipResolver
37
+
38
+ __all__ = [
39
+ "RESOLVER_ID",
40
+ "DecodeError",
41
+ "ScipDocument",
42
+ "ScipIndex",
43
+ "ScipOccurrence",
44
+ "ScipRange",
45
+ "ScipResolver",
46
+ "ScipSymbolInfo",
47
+ "load_index",
48
+ "parse_index",
49
+ ]