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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- 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
|
+
]
|