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,96 @@
1
+ """Coordenador de resolução — cadeia por precedência de evidência.
2
+
3
+ Política (documentada): itera resolvedores em ordem de ``EvidenceTier``
4
+ crescente (índice semântico → LSP → sintático → heurístico). Para no
5
+ primeiro resultado ``resolved`` inequívoco — tiers mais baixos não
6
+ aprimoram um resultado já confirmado. Se nenhum resolver devolve
7
+ ``resolved``, o resultado do **tier mais alto disponível** é devolvido
8
+ com seu status honesto (ambiguous/unresolved/external) — nunca promovido.
9
+ Exceções de resolvedor viram diagnóstico, não crash.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from code_atlas.resolvers.contracts import (
15
+ ResolutionDiagnostic,
16
+ ResolutionRequest,
17
+ ResolutionResult,
18
+ SemanticResolver,
19
+ relation_for_kind,
20
+ )
21
+ from code_atlas.resolvers.registry import ResolverRegistry
22
+
23
+
24
+ class ResolverCoordinator:
25
+ """Encadeia resolvers registrados por ordem de evidência."""
26
+
27
+ def __init__(self, registry: ResolverRegistry) -> None:
28
+ self._registry = registry
29
+
30
+ def candidates(
31
+ self, request: ResolutionRequest
32
+ ) -> list[SemanticResolver]:
33
+ """Resolvers que cobrem o pedido, em ordem de evidência."""
34
+ rel = relation_for_kind(request.kind)
35
+ return self._registry.capable(request.language, rel)
36
+
37
+ def resolve(
38
+ self,
39
+ request: ResolutionRequest,
40
+ *,
41
+ above_tier: int | None = None,
42
+ ) -> ResolutionResult:
43
+ """Resolve seguindo a cadeia de evidência; nunca levanta.
44
+
45
+ ``above_tier`` (lf-0035): quando informado, só consulta
46
+ resolvedores com ``tier < above_tier`` — usado para verificar
47
+ uma resolução existente contra evidência estritamente superior
48
+ (política de precedência determinística)."""
49
+ diagnostics: list[ResolutionDiagnostic] = []
50
+ best: ResolutionResult | None = None
51
+ for resolver in self.candidates(request):
52
+ if (
53
+ above_tier is not None
54
+ and resolver.capabilities().tier >= above_tier
55
+ ):
56
+ continue
57
+ try:
58
+ result = resolver.resolve(request)
59
+ except Exception as exc: # resolvedor isolado → diagnóstico
60
+ diagnostics.append(
61
+ ResolutionDiagnostic(
62
+ code="resolver-error",
63
+ severity="warning",
64
+ message=f"{resolver.resolver_id}: {exc}",
65
+ resolver_id=resolver.resolver_id,
66
+ )
67
+ )
68
+ continue
69
+ diagnostics.extend(result.diagnostics)
70
+ ref = result.reference
71
+ if ref is not None and ref.status == "resolved":
72
+ return ResolutionResult(
73
+ request=request,
74
+ reference=ref,
75
+ diagnostics=tuple(diagnostics),
76
+ )
77
+ if best is None:
78
+ best = result # tier mais alto que respondeu
79
+ if best is not None:
80
+ return ResolutionResult(
81
+ request=request,
82
+ reference=best.reference,
83
+ definitions=best.definitions,
84
+ diagnostics=tuple(diagnostics),
85
+ )
86
+ return ResolutionResult(
87
+ request=request,
88
+ diagnostics=tuple(diagnostics) or (
89
+ ResolutionDiagnostic(
90
+ code="no-resolver",
91
+ severity="warning",
92
+ message=f"no resolver covers '{request.kind}' "
93
+ f"for '{request.language or '*'}'",
94
+ ),
95
+ ),
96
+ )
@@ -0,0 +1,318 @@
1
+ """Enriquecimento semântico do grafo construído (lf-0027).
2
+
3
+ Camada independente entre ``GraphBuilder.build`` e ``persist_graph``:
4
+ re-resolve arestas não-resolvidas via ``ResolverCoordinator`` (ordem de
5
+ ``EvidenceTier``: scip → lsp → …) e promove a aresta SOMENTE quando o
6
+ resultado é ``resolved`` e o alvo existe no snapshot atual.
7
+
8
+ Regras invioláveis:
9
+
10
+ - Uma aresta já ``resolved`` nunca é reescrita silenciosamente — só
11
+ pode ser VERIFICADA por evidência de tier estritamente superior
12
+ (lf-0035): mesmo alvo confirma e sobe a proveniência; alvo divergente
13
+ vira ``ambiguous`` + diagnóstico de conflito, preservando o alvo
14
+ original. Evidência externa nunca sobrescreve relação válida.
15
+ - Resultado não-``resolved`` (ambiguous/unresolved/external/diagnóstico)
16
+ preserva a aresta original verbatim — nenhuma degradação.
17
+ - O ``target_id`` retornado por um resolver precisa ser nó persistível
18
+ DESTE build — vínculo cross-snapshot é impossível por construção.
19
+ - Projeção documentada: a primeira evidência ``resolved`` no tier mais
20
+ alto vence; demais respostas ficam como diagnósticos (coexistência
21
+ de evidências alternativas fica registrada, não projetada).
22
+ - Exceções de resolver viram ``ParseDiagnostic`` — nunca crash.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ from collections.abc import Mapping
29
+
30
+ from code_atlas.contracts.enums import NodeKind
31
+ from code_atlas.parsers.contracts import (
32
+ EvidenceRecord,
33
+ GraphBuildResult,
34
+ GraphEdge,
35
+ ParseDiagnostic,
36
+ )
37
+ from code_atlas.resolvers.capabilities import EvidenceTier
38
+ from code_atlas.resolvers.contracts import (
39
+ ResolutionRequest,
40
+ ResolutionResult,
41
+ )
42
+ from code_atlas.resolvers.coordinator import ResolverCoordinator
43
+
44
+ # Tipos de aresta elegíveis a enriquecimento → kind do ResolutionRequest.
45
+ _EDGE_TO_KIND = {
46
+ "CALLS": "call",
47
+ "IMPORTS": "import",
48
+ "EXTENDS": "type-ref",
49
+ "IMPLEMENTS": "type-ref",
50
+ "REFERENCES": "reference",
51
+ }
52
+
53
+ # Kinds cujos nós viram ocorrências persistidas (espelha _PERSISTED_KINDS
54
+ # de indexing/persist — manter alinhado: um alvo fora deste conjunto
55
+ # seria descartado no persist, perdendo a aresta original).
56
+ _NON_PERSISTED = frozenset({NodeKind.REPOSITORY, NodeKind.PACKAGE})
57
+
58
+
59
+ def _edge_tier(resolver_label: str) -> int:
60
+ """Tier da evidência que produziu a resolução atual (lf-0035).
61
+
62
+ Labels persistidos: ``scip`` → índice semântico, ``lsp:*`` → LSP,
63
+ qualquer outro (``syntactic-scope-v1``, ``tree-sitter-*``) →
64
+ sintático. Desconhecido = sintático (conservador, nunca superestima).
65
+ """
66
+ if resolver_label == "scip" or resolver_label.startswith("resolver:scip"):
67
+ return int(EvidenceTier.SEMANTIC_INDEX)
68
+ if resolver_label.startswith("lsp:"):
69
+ return int(EvidenceTier.LSP)
70
+ return int(EvidenceTier.SYNTACTIC)
71
+
72
+
73
+ class SemanticEnricher:
74
+ """Promove arestas não-resolvidas com evidência de resolvers opt-in."""
75
+
76
+ def __init__(self, coordinator: ResolverCoordinator) -> None:
77
+ self._coord = coordinator
78
+
79
+ def enrich(
80
+ self, build: GraphBuildResult
81
+ ) -> tuple[GraphBuildResult, dict[str, int]]:
82
+ """Devolve (build possivelmente enriquecido, stats).
83
+
84
+ Stats: ``semantic_attempted``, ``semantic_enriched``,
85
+ ``semantic_diagnostics``.
86
+ """
87
+ node_by_id = {n.node_id: n for n in build.nodes}
88
+ persistable = {
89
+ n.node_id for n in build.nodes
90
+ if n.kind not in _NON_PERSISTED and n.path
91
+ }
92
+ evidence: list[EvidenceRecord] = list(build.evidence)
93
+ diagnostics: list[ParseDiagnostic] = list(build.diagnostics)
94
+ stats = {
95
+ "semantic_attempted": 0,
96
+ "semantic_enriched": 0,
97
+ "semantic_verified": 0,
98
+ "semantic_confirmed": 0,
99
+ "semantic_conflicts": 0,
100
+ "semantic_diagnostics": 0,
101
+ }
102
+ edges: list[GraphEdge] = []
103
+ for e in build.edges:
104
+ new_edge = self._enrich_edge(
105
+ e, node_by_id, persistable, evidence, diagnostics, stats
106
+ )
107
+ edges.append(new_edge)
108
+ stats["semantic_diagnostics"] = (
109
+ len(diagnostics) - len(build.diagnostics)
110
+ )
111
+ enriched = dataclasses.replace(
112
+ build,
113
+ edges=tuple(edges),
114
+ evidence=tuple(evidence),
115
+ diagnostics=tuple(diagnostics),
116
+ )
117
+ return enriched, stats
118
+
119
+ # -- por aresta ------------------------------------------------------
120
+
121
+ def _enrich_edge(
122
+ self,
123
+ e: GraphEdge,
124
+ node_by_id: Mapping[str, object],
125
+ persistable: set[str],
126
+ evidence: list[EvidenceRecord],
127
+ diagnostics: list[ParseDiagnostic],
128
+ stats: dict[str, int],
129
+ ) -> GraphEdge:
130
+ kind = _EDGE_TO_KIND.get(str(e.type))
131
+ if kind is None:
132
+ return e
133
+ if e.resolution_status == "resolved":
134
+ return self._verify_edge(
135
+ e, kind, node_by_id, persistable, evidence,
136
+ diagnostics, stats,
137
+ )
138
+ src = node_by_id.get(e.from_id)
139
+ language = getattr(src, "language", "") if src is not None else ""
140
+ request = ResolutionRequest(
141
+ name=e.to_text,
142
+ kind=kind,
143
+ path=e.source_path,
144
+ language=str(language),
145
+ span=e.span,
146
+ )
147
+ if not request.name:
148
+ return e
149
+ stats["semantic_attempted"] += 1
150
+ result = self._coord.resolve(request)
151
+ self._diags(result, e, diagnostics)
152
+ ref = result.reference
153
+ if (
154
+ ref is None
155
+ or ref.status != "resolved"
156
+ or ref.target_id is None
157
+ or ref.target_id not in persistable
158
+ ):
159
+ return e
160
+ prov = ref.provenance
161
+ ev_idx = len(evidence)
162
+ evidence.append(
163
+ EvidenceRecord(
164
+ path=e.source_path,
165
+ span=e.span,
166
+ content_hash="",
167
+ extractor=f"resolver:{prov.resolver_id}",
168
+ extractor_version=prov.resolver_version,
169
+ notes=(
170
+ f"semantic enrichment tier={prov.evidence_tier} "
171
+ f"{prov.detail}".strip()
172
+ ),
173
+ resolver_id=prov.resolver_id,
174
+ resolver_version=prov.resolver_version,
175
+ evidence_tier=prov.evidence_tier,
176
+ index_hash=prov.index_hash,
177
+ validation_status="resolved",
178
+ )
179
+ )
180
+ stats["semantic_enriched"] += 1
181
+ return dataclasses.replace(
182
+ e,
183
+ to_id=ref.target_id,
184
+ resolution_status="resolved",
185
+ confidence_class=ref.confidence_class,
186
+ resolver=prov.resolver_id,
187
+ evidence_index=(*e.evidence_index, ev_idx),
188
+ )
189
+
190
+ def _verify_edge(
191
+ self,
192
+ e: GraphEdge,
193
+ kind: str,
194
+ node_by_id: Mapping[str, object],
195
+ persistable: set[str],
196
+ evidence: list[EvidenceRecord],
197
+ diagnostics: list[ParseDiagnostic],
198
+ stats: dict[str, int],
199
+ ) -> GraphEdge:
200
+ """Verifica aresta ``resolved`` contra tiers superiores (lf-0035).
201
+
202
+ Determinístico:
203
+ - Sem resolver acima do tier atual → aresta intacta.
204
+ - Tier superior confirma o MESMO alvo → proveniência sobe.
205
+ - Tier superior diverge (alvo persistível distinto) →
206
+ ``ambiguous`` + evidência ``conflict`` + diagnóstico; o alvo
207
+ original permanece (evidência externa nunca sobrescreve)."""
208
+ current_tier = _edge_tier(e.resolver)
209
+ if current_tier <= int(EvidenceTier.SEMANTIC_INDEX):
210
+ return e # nada acima do tier mais alto
211
+ name = e.to_text
212
+ if not name:
213
+ tgt = node_by_id.get(e.to_id) if e.to_id else None
214
+ name = getattr(tgt, "name", "") if tgt is not None else ""
215
+ if not name:
216
+ return e
217
+ src = node_by_id.get(e.from_id)
218
+ language = getattr(src, "language", "") if src is not None else ""
219
+ request = ResolutionRequest(
220
+ name=name, kind=kind, path=e.source_path,
221
+ language=str(language), span=e.span,
222
+ )
223
+ above = [
224
+ r for r in self._coord.candidates(request)
225
+ if r.capabilities().tier < current_tier
226
+ ]
227
+ if not above:
228
+ return e # sem resolvedor superior configurado — custo zero
229
+ stats["semantic_verified"] += 1
230
+ result = self._coord.resolve(request, above_tier=current_tier)
231
+ self._diags(result, e, diagnostics)
232
+ ref = result.reference
233
+ if ref is None or ref.status != "resolved" or ref.target_id is None:
234
+ return e
235
+ prov = ref.provenance
236
+ if ref.target_id == e.to_id:
237
+ ev_idx = len(evidence)
238
+ evidence.append(
239
+ EvidenceRecord(
240
+ path=e.source_path, span=e.span, content_hash="",
241
+ extractor=f"resolver:{prov.resolver_id}",
242
+ extractor_version=prov.resolver_version,
243
+ notes=(
244
+ f"semantic confirmed tier={prov.evidence_tier} "
245
+ f"{prov.detail}".strip()
246
+ ),
247
+ resolver_id=prov.resolver_id,
248
+ resolver_version=prov.resolver_version,
249
+ evidence_tier=prov.evidence_tier,
250
+ index_hash=prov.index_hash,
251
+ validation_status="confirmed",
252
+ )
253
+ )
254
+ stats["semantic_confirmed"] += 1
255
+ return dataclasses.replace(
256
+ e,
257
+ confidence_class=ref.confidence_class,
258
+ resolver=prov.resolver_id,
259
+ evidence_index=(*e.evidence_index, ev_idx),
260
+ )
261
+ if ref.target_id not in persistable:
262
+ return e # alvo alternativo não existe no snapshot — ignora
263
+ ev_idx = len(evidence)
264
+ evidence.append(
265
+ EvidenceRecord(
266
+ path=e.source_path, span=e.span, content_hash="",
267
+ extractor=f"resolver:{prov.resolver_id}",
268
+ extractor_version=prov.resolver_version,
269
+ notes=(
270
+ f"semantic conflict tier={prov.evidence_tier}: "
271
+ f"kept={e.to_id} alternative={ref.target_id} "
272
+ f"{prov.detail}".strip()
273
+ ),
274
+ resolver_id=prov.resolver_id,
275
+ resolver_version=prov.resolver_version,
276
+ evidence_tier=prov.evidence_tier,
277
+ index_hash=prov.index_hash,
278
+ validation_status="conflict",
279
+ )
280
+ )
281
+ diagnostics.append(
282
+ ParseDiagnostic(
283
+ code="semantic-conflict",
284
+ severity="warning",
285
+ message=(
286
+ f"{e.source_path}: {e.type} {e.from_id} -> {e.to_id}: "
287
+ f"resolver {prov.resolver_id} aponta {ref.target_id}"
288
+ ),
289
+ span=e.span,
290
+ )
291
+ )
292
+ stats["semantic_conflicts"] += 1
293
+ return dataclasses.replace(
294
+ e,
295
+ resolution_status="ambiguous",
296
+ evidence_index=(*e.evidence_index, ev_idx),
297
+ )
298
+
299
+ @staticmethod
300
+ def _diags(
301
+ result: ResolutionResult,
302
+ e: GraphEdge,
303
+ out: list[ParseDiagnostic],
304
+ ) -> None:
305
+ """Traduz ResolutionDiagnostics do resolver para ParseDiagnostic
306
+ — erros avisam; infos de `no-occurrence`/`no-resolver` só entram
307
+ quando severity ≥ warning para não inundar o snapshot."""
308
+ for d in result.diagnostics:
309
+ if d.severity == "info":
310
+ continue
311
+ out.append(
312
+ ParseDiagnostic(
313
+ code=f"semantic:{d.resolver_id}:{d.code}",
314
+ severity=d.severity,
315
+ message=d.message,
316
+ span=e.span,
317
+ )
318
+ )
@@ -0,0 +1,26 @@
1
+ """Adapters LSP opt-in (lf-0018).
2
+
3
+ - Nenhum servidor é instalado, baixado ou spawnado automaticamente.
4
+ - Ativação exclusivamente por configuração explícita do usuário
5
+ (``config.toml [resolvers.lsp.servers.<name>]``) — wiring de CLI vem
6
+ no lf-0022.
7
+ - Transporte: stdio apenas (sem TCP/socket). Todo request tem timeout
8
+ obrigatório + ``$/cancelRequest``.
9
+ - ``spawn.py`` é o único site de subprocess (whitelist do audit).
10
+ """
11
+
12
+ from code_atlas.resolvers.lsp.adapter import LspResolver
13
+ from code_atlas.resolvers.lsp.lifecycle import LspClient, LspError, make_client
14
+ from code_atlas.resolvers.lsp.spawn import LspServerSpec, SpawnError
15
+ from code_atlas.resolvers.lsp.transport import JsonRpcTransport, TransportError
16
+
17
+ __all__ = [
18
+ "JsonRpcTransport",
19
+ "LspClient",
20
+ "LspError",
21
+ "LspResolver",
22
+ "LspServerSpec",
23
+ "SpawnError",
24
+ "TransportError",
25
+ "make_client",
26
+ ]
@@ -0,0 +1,256 @@
1
+ """LspResolver — resolvedor EvidenceTier.LSP (lf-0018).
2
+
3
+ Envolve um ``LspClient`` ativo. Métodos LSP só são chamados quando o
4
+ servidor anunciou a capability no handshake — caso contrário, diagnóstico
5
+ ``capability-unsupported`` e status honesto (nunca crash).
6
+
7
+ Mapeamento de alvos: ``Location{uri,range}`` → path relativo ao
8
+ ``root_uri`` → nó de módulo do ``ScopeUniverse`` (identidade no nível de
9
+ arquivo, verificável). Precisão símbolo-a-símbolo é trabalho futuro;
10
+ quando o uri não pertence ao snapshot → ``external``/``unresolved``.
11
+
12
+ Proveniência: ``lsp:<server-name>:<version>`` — distinta de SCIP.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import dataclasses
18
+ from typing import Any
19
+
20
+ from code_atlas.resolvers.capabilities import EvidenceTier, ResolverCapability
21
+ from code_atlas.resolvers.contracts import (
22
+ ResolutionDiagnostic,
23
+ ResolutionRequest,
24
+ ResolutionResult,
25
+ ResolvedDefinition,
26
+ ResolvedReference,
27
+ ResolverProvenance,
28
+ )
29
+ from code_atlas.resolvers.lsp.lifecycle import LspClient, LspError
30
+ from code_atlas.resolvers.lsp.transport import TransportError
31
+ from code_atlas.resolvers.syntactic.resolver import ScopeUniverse
32
+
33
+ RESOLVER_ID_PREFIX = "lsp"
34
+ RESOLVER_VERSION = "1.0.0"
35
+
36
+ _KIND_TO_METHOD = {
37
+ "definition": "textDocument/definition",
38
+ "call": "textDocument/references",
39
+ "reference": "textDocument/references",
40
+ "type-ref": "textDocument/definition",
41
+ "import": "textDocument/definition",
42
+ }
43
+
44
+
45
+ class LspResolver:
46
+ """Implementa ``SemanticResolver`` sobre um LspClient ativo."""
47
+
48
+ def __init__(self, client: LspClient, universe: ScopeUniverse) -> None:
49
+ self._client = client
50
+ self._u = universe
51
+
52
+ @property
53
+ def resolver_id(self) -> str:
54
+ return f"{RESOLVER_ID_PREFIX}:{self._client.spec.name}"
55
+
56
+ @property
57
+ def resolver_version(self) -> str:
58
+ return RESOLVER_VERSION
59
+
60
+ def capabilities(self) -> ResolverCapability:
61
+ return ResolverCapability(
62
+ resolver_id=self.resolver_id,
63
+ tier=EvidenceTier.LSP,
64
+ languages=self._client.spec.languages,
65
+ relation_types=frozenset(
66
+ {"REFERENCES", "CALLS", "IMPORTS", "EXTENDS"}),
67
+ needs_external_process=True,
68
+ detail=f"stdio lsp server {self._client.spec.name}",
69
+ )
70
+
71
+ def _prov(self) -> ResolverProvenance:
72
+ caps = self._client.capabilities
73
+ detail = f"{caps.server_name}:{caps.server_version}".rstrip(":")
74
+ return ResolverProvenance(
75
+ resolver_id=self.resolver_id,
76
+ resolver_version=RESOLVER_VERSION,
77
+ evidence_tier=int(EvidenceTier.LSP),
78
+ detail=detail,
79
+ )
80
+
81
+ # -- contrato ----------------------------------------------------------
82
+
83
+ def resolve(self, request: ResolutionRequest) -> ResolutionResult:
84
+ method = _KIND_TO_METHOD.get(request.kind)
85
+ if method is None:
86
+ return self._with_diag(request, "kind-unsupported",
87
+ f"no LSP method for {request.kind}")
88
+ if not self._client.available:
89
+ return self._with_diag(request, "server-unavailable",
90
+ "lsp server not running")
91
+ if not self._client.capabilities.supports(method):
92
+ return self._with_diag(request, "capability-unsupported",
93
+ f"server did not advertise {method}")
94
+
95
+ span = request.span
96
+ if span is None:
97
+ return self._with_diag(request, "span-required",
98
+ "LSP requests need a source span")
99
+ params: dict[str, Any] = {
100
+ "textDocument": {"uri": self._doc_uri(request.path)},
101
+ "position": {"line": span.start_line - 1,
102
+ "character": span.start_col},
103
+ }
104
+ if method == "textDocument/references":
105
+ params["context"] = {"includeDeclaration": False}
106
+ result = self._call(request, method, params)
107
+ if isinstance(result, ResolutionResult):
108
+ return result
109
+ result_diags = tuple(
110
+ ResolutionDiagnostic(
111
+ code="server-diagnostic",
112
+ severity="info",
113
+ message=str(d)[:200],
114
+ resolver_id=self.resolver_id,
115
+ )
116
+ for d in self._client.diagnostics_for(
117
+ self._doc_uri(request.path))
118
+ )
119
+ mapped = self._map_result(request, self._locations(result))
120
+ if result_diags:
121
+ mapped = dataclasses.replace(
122
+ mapped,
123
+ diagnostics=result_diags + mapped.diagnostics,
124
+ )
125
+ return mapped
126
+
127
+ def _call(
128
+ self, request: ResolutionRequest, method: str,
129
+ params: dict[str, Any],
130
+ ) -> Any:
131
+ """Request com recovery único embutido (lf-0029)."""
132
+ try:
133
+ return self._client.request(method, params)
134
+ except (TimeoutError, LspError, TransportError) as exc:
135
+ return self._recover_retry(request, method, params, exc)
136
+
137
+ def _recover_retry(
138
+ self, request: ResolutionRequest, method: str,
139
+ params: dict[str, Any], exc: Exception,
140
+ ) -> Any:
141
+ """lf-0029: recovery pós-falha — UM restart + UM retry.
142
+ Retorna o resultado do retry ou um ResolutionResult de erro
143
+ tipado (fallback sintático segue valendo)."""
144
+ if not self._try_restart():
145
+ code = ("request-timeout" if isinstance(exc, TimeoutError)
146
+ else "server-error")
147
+ return self._with_diag(
148
+ request, code, f"{exc}; restart failed")
149
+ try:
150
+ return self._client.request(method, params)
151
+ except (TimeoutError, LspError, TransportError) as exc2:
152
+ return self._with_diag(
153
+ request, "server-error", f"{exc}; after restart: {exc2}")
154
+
155
+ def _try_restart(self) -> bool:
156
+ """Uma tentativa de recovery — nunca lança."""
157
+ try:
158
+ self._client.restart()
159
+ except Exception:
160
+ return False
161
+ return True
162
+
163
+ # -- mapeamento de resultado -------------------------------------------
164
+
165
+ def _map_result(
166
+ self, request: ResolutionRequest, locations: list[dict[str, Any]]
167
+ ) -> ResolutionResult:
168
+ if not locations:
169
+ return ResolutionResult(
170
+ request=request,
171
+ reference=ResolvedReference(
172
+ status="unresolved",
173
+ confidence_class="syntactic",
174
+ provenance=self._prov(),
175
+ target_text=request.name,
176
+ ),
177
+ )
178
+ nids: list[str] = []
179
+ foreign = False
180
+ for loc in locations:
181
+ path = self._rel_path(loc.get("uri", ""))
182
+ if path is None:
183
+ foreign = True
184
+ continue
185
+ nid = self._u.by_path.get(path)
186
+ if nid and nid not in nids:
187
+ nids.append(nid)
188
+ if len(nids) == 1:
189
+ status, conf = "resolved", "resolved"
190
+ elif len(nids) > 1:
191
+ status, conf = "ambiguous", "syntactic"
192
+ else:
193
+ status = "external" if foreign else "unresolved"
194
+ conf = "syntactic"
195
+ return ResolutionResult(
196
+ request=request,
197
+ reference=ResolvedReference(
198
+ status=status,
199
+ confidence_class=conf,
200
+ provenance=self._prov(),
201
+ target_id=nids[0] if len(nids) == 1 else None,
202
+ target_text=request.name,
203
+ candidates=tuple(sorted(nids)) if len(nids) > 1 else (),
204
+ ),
205
+ definitions=tuple(
206
+ ResolvedDefinition(
207
+ symbol=request.name,
208
+ path=self._rel_path(loc.get("uri", "")) or "",
209
+ provenance=self._prov(),
210
+ )
211
+ for loc in locations
212
+ ),
213
+ )
214
+
215
+ # -- helpers -----------------------------------------------------------
216
+
217
+ def _doc_uri(self, path: str) -> str:
218
+ root = self._client.spec.root_uri.rstrip("/")
219
+ return f"{root}/{path}"
220
+
221
+ def _rel_path(self, uri: str) -> str | None:
222
+ """file:// URI → path relativo ao root_uri; None se fora do repo."""
223
+ root = self._client.spec.root_uri.rstrip("/") + "/"
224
+ if uri.startswith(root):
225
+ return uri[len(root):]
226
+ return None
227
+
228
+ @staticmethod
229
+ def _locations(result: object) -> list[dict[str, Any]]:
230
+ """definition/references → lista de Location (obj ou lista)."""
231
+ if result is None:
232
+ return []
233
+ if isinstance(result, dict): # Location único ou LocationLink
234
+ if "targetUri" in result:
235
+ return [{"uri": result["targetUri"],
236
+ "range": result.get("targetSelectionRange", {})}]
237
+ return [result] if "uri" in result else []
238
+ if isinstance(result, list):
239
+ return [r for r in result if isinstance(r, dict)]
240
+ return []
241
+
242
+ def _with_diag(
243
+ self, request: ResolutionRequest, code: str, message: str
244
+ ) -> ResolutionResult:
245
+ return ResolutionResult(
246
+ request=request,
247
+ reference=ResolvedReference(
248
+ status="unresolved",
249
+ confidence_class="syntactic",
250
+ provenance=self._prov(),
251
+ target_text=request.name,
252
+ ),
253
+ diagnostics=(ResolutionDiagnostic(
254
+ code=code, severity="warning", message=message,
255
+ resolver_id=self.resolver_id),),
256
+ )