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,231 @@
1
+ """Sink de extração do adapter ``domains.api`` (lf-0114).
2
+
3
+ Acumula entidades, relações, evidência e diagnósticos com dedup por
4
+ ``logical_key``/``(path, span)`` e monta o ``DomainExtractionResult``
5
+ final. Centraliza também:
6
+
7
+ - a fábrica de ``DomainIdentity`` (domain/repository/root/environment
8
+ vêm do request — o adapter só escolhe ``qualified_name``/``namespace``);
9
+ - o índice ``path → endpoints`` usado para **candidatos** de
10
+ ``CALLS_ENDPOINT`` (a resolução literal-URL↔contrato é da Phase 9 —
11
+ aqui só registramos candidatos, nunca resolvemos).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from code_atlas.contracts.enums import ConfidenceClass, ResolutionStatus
17
+ from code_atlas.domains.contracts import (
18
+ DomainDiagnostic,
19
+ DomainExtractionRequest,
20
+ DomainExtractionResult,
21
+ ExtractedDomainEntity,
22
+ ExtractedDomainRelation,
23
+ )
24
+ from code_atlas.domains.evidence import evidence_record
25
+ from code_atlas.domains.identity import DomainIdentity
26
+ from code_atlas.domains.model import DomainEntityKind
27
+ from code_atlas.domains.resolution import DomainResolution
28
+ from code_atlas.parsers.contracts import EvidenceRecord, SourceSpan
29
+
30
+ ADAPTER_DOMAIN = "api"
31
+ RESOLVER_ID = "domain:api"
32
+
33
+
34
+ class Emit:
35
+ """Acumulador determinístico da saída de ``extract``."""
36
+
37
+ def __init__(
38
+ self, request: DomainExtractionRequest, adapter_version: str
39
+ ) -> None:
40
+ self._request = request
41
+ self._version = adapter_version
42
+ self.entities: list[ExtractedDomainEntity] = []
43
+ self.relations: list[ExtractedDomainRelation] = []
44
+ self.evidence: list[EvidenceRecord] = []
45
+ self.diagnostics: list[DomainDiagnostic] = []
46
+ self.stats: dict[str, int] = {}
47
+ self._evi_index: dict[tuple[str, int], int] = {}
48
+ self._entity_keys: set[str] = set()
49
+ self.endpoint_paths: dict[str, list[DomainIdentity]] = {}
50
+
51
+ # -- fábricas ----------------------------------------------------
52
+
53
+ def ident(self, qualified_name: str, namespace: str = "") -> DomainIdentity:
54
+ req = self._request
55
+ return DomainIdentity(
56
+ domain=ADAPTER_DOMAIN,
57
+ repository=req.repository,
58
+ qualified_name=qualified_name,
59
+ root=req.root,
60
+ namespace=namespace,
61
+ environment=req.environment,
62
+ )
63
+
64
+ def evi(
65
+ self,
66
+ path: str,
67
+ content_hash: str,
68
+ span: SourceSpan | None = None,
69
+ notes: str = "",
70
+ ) -> int:
71
+ """Evidência dedup por ``(path, start_byte)`` → índice no envelope."""
72
+ key = (path, span.start_byte if span else -1)
73
+ idx = self._evi_index.get(key)
74
+ if idx is not None:
75
+ return idx
76
+ idx = len(self.evidence)
77
+ self.evidence.append(
78
+ evidence_record(
79
+ adapter_domain=ADAPTER_DOMAIN,
80
+ adapter_version=self._version,
81
+ path=path,
82
+ content_hash=content_hash,
83
+ span=span,
84
+ notes=notes,
85
+ )
86
+ )
87
+ self._evi_index[key] = idx
88
+ return idx
89
+
90
+ def diag(
91
+ self,
92
+ code: str,
93
+ severity: str,
94
+ message: str,
95
+ path: str = "",
96
+ ) -> None:
97
+ self.diagnostics.append(
98
+ DomainDiagnostic(
99
+ code=code, severity=severity, message=message,
100
+ path=path, adapter_id=ADAPTER_DOMAIN,
101
+ )
102
+ )
103
+
104
+ def bump(self, stat: str, n: int = 1) -> None:
105
+ self.stats[stat] = self.stats.get(stat, 0) + n
106
+
107
+ # -- entidades / relações -----------------------------------------
108
+
109
+ def entity(
110
+ self,
111
+ *,
112
+ kind: DomainEntityKind,
113
+ name: str,
114
+ qualified_name: str,
115
+ namespace: str = "",
116
+ path: str = "",
117
+ span: SourceSpan | None = None,
118
+ content_hash: str = "",
119
+ attributes: dict[str, str] | None = None,
120
+ evidence: tuple[int, ...] = (),
121
+ ) -> DomainIdentity:
122
+ """Registra entidade dedup por ``logical_key``; devolve a identidade."""
123
+ ident = self.ident(qualified_name, namespace)
124
+ key = ident.logical_key()
125
+ if key in self._entity_keys:
126
+ self.diag(
127
+ "duplicate-entity", "warning",
128
+ f"entity {ident.display()} already emitted — first kept",
129
+ path,
130
+ )
131
+ return ident
132
+ self._entity_keys.add(key)
133
+ self.entities.append(
134
+ ExtractedDomainEntity(
135
+ identity=ident,
136
+ kind=kind,
137
+ name=name,
138
+ path=path,
139
+ span=span,
140
+ content_hash=content_hash,
141
+ attributes=attributes or {},
142
+ evidence=evidence,
143
+ )
144
+ )
145
+ return ident
146
+
147
+ def endpoint(
148
+ self,
149
+ *,
150
+ name: str,
151
+ qualified_name: str,
152
+ namespace: str,
153
+ full_path: str,
154
+ path: str = "",
155
+ span: SourceSpan | None = None,
156
+ content_hash: str = "",
157
+ attributes: dict[str, str] | None = None,
158
+ evidence: tuple[int, ...] = (),
159
+ ) -> DomainIdentity:
160
+ """``APIEndpoint`` + indexação de ``full_path`` para candidatos."""
161
+ ident = self.entity(
162
+ kind=DomainEntityKind.API_ENDPOINT,
163
+ name=name,
164
+ qualified_name=qualified_name,
165
+ namespace=namespace,
166
+ path=path,
167
+ span=span,
168
+ content_hash=content_hash,
169
+ attributes=attributes,
170
+ evidence=evidence,
171
+ )
172
+ self.endpoint_paths.setdefault(full_path, []).append(ident)
173
+ return ident
174
+
175
+ def resolution(
176
+ self,
177
+ status: ResolutionStatus,
178
+ confidence: ConfidenceClass,
179
+ *,
180
+ target: DomainIdentity | None = None,
181
+ candidates: tuple[DomainIdentity, ...] = (),
182
+ unresolved_text: str = "",
183
+ ) -> DomainResolution:
184
+ return DomainResolution(
185
+ status=status,
186
+ confidence_class=confidence,
187
+ resolver=RESOLVER_ID,
188
+ target=target,
189
+ candidates=candidates,
190
+ unresolved_text=unresolved_text,
191
+ )
192
+
193
+ def relation(
194
+ self,
195
+ *,
196
+ relation_type: str,
197
+ path: str,
198
+ from_identity: DomainIdentity | None = None,
199
+ from_symbol: str = "",
200
+ to_identity: DomainIdentity | None = None,
201
+ to_symbol: str = "",
202
+ to_unresolved: str = "",
203
+ resolution: DomainResolution | None = None,
204
+ span: SourceSpan | None = None,
205
+ evidence: tuple[int, ...] = (),
206
+ ) -> None:
207
+ self.relations.append(
208
+ ExtractedDomainRelation(
209
+ relation_type=relation_type,
210
+ path=path,
211
+ from_identity=from_identity,
212
+ from_symbol=from_symbol,
213
+ to_identity=to_identity,
214
+ to_symbol=to_symbol,
215
+ to_unresolved=to_unresolved,
216
+ resolution=resolution,
217
+ span=span,
218
+ evidence=evidence,
219
+ )
220
+ )
221
+
222
+ def result(self) -> DomainExtractionResult:
223
+ return DomainExtractionResult(
224
+ domain_id=ADAPTER_DOMAIN,
225
+ adapter_version=self._version,
226
+ entities=tuple(self.entities),
227
+ relations=tuple(self.relations),
228
+ evidence=tuple(self.evidence),
229
+ diagnostics=tuple(self.diagnostics),
230
+ stats=dict(self.stats),
231
+ )
@@ -0,0 +1,398 @@
1
+ """Extração de specs OpenAPI 3.x / Swagger 2.0 (lf-0114, Phase 6).
2
+
3
+ Consome o documento já parseado (dict do ``yaml_subset`` ou ``json``)
4
+ e emite:
5
+
6
+ - ``APIContract`` — um por arquivo de spec (identidade = path do spec);
7
+ - ``APIService`` — um por spec, identidade ``<spec_path>#<slug-título>``
8
+ com ``server_url``/``base_path`` do primeiro ``servers[]`` (3.x) ou
9
+ ``host``+``basePath``+``schemes`` (2.0);
10
+ - ``APIEndpoint`` — um por ``paths.<p>.<method>``, identidade
11
+ ``<METHOD> <base_path><path>`` no namespace do serviço;
12
+ - relações ``DEFINES`` (contrato→serviço), ``CONTAINS`` (serviço→endpoint)
13
+ e ``REFERENCES`` (endpoint→schema ``$ref`` — sempre ``unresolved``:
14
+ schemas são texto de referência, não entidades deste domínio).
15
+
16
+ Escopo delimitado honesto: ``$ref`` de ``requestBody``/``responses``
17
+ (e ``items.$ref``) são registrados; ``allOf``/``oneOf`` aninhados,
18
+ callbacks, links e path-level ``parameters`` com body ficam fora —
19
+ documentado, nunca inventado.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from collections.abc import Callable, Mapping
26
+
27
+ from code_atlas.contracts.enums import (
28
+ ConfidenceClass,
29
+ EdgeKind,
30
+ ResolutionStatus,
31
+ )
32
+ from code_atlas.domains.api import common
33
+ from code_atlas.domains.api.emit import Emit
34
+ from code_atlas.domains.api.yaml_subset import YamlValue
35
+ from code_atlas.domains.identity import DomainIdentity
36
+ from code_atlas.domains.model import DomainEntityKind
37
+ from code_atlas.parsers.contracts import SourceSpan
38
+
39
+ HTTP_METHODS = frozenset(
40
+ {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
41
+ )
42
+ _PATH_ITEM_META = frozenset(
43
+ {"parameters", "summary", "description", "servers", "$ref"}
44
+ )
45
+
46
+
47
+ def spec_flavor(doc: YamlValue) -> str | None:
48
+ """``"openapi3"`` | ``"swagger2"`` | None conforme chaves de topo."""
49
+ if not isinstance(doc, dict):
50
+ return None
51
+ ver = doc.get("openapi")
52
+ if isinstance(ver, str | int | float) and str(ver).startswith("3."):
53
+ return "openapi3"
54
+ sw = doc.get("swagger")
55
+ if isinstance(sw, str | int | float) and str(sw).startswith("2."):
56
+ return "swagger2"
57
+ return None
58
+
59
+
60
+ def _as_map(v: YamlValue) -> Mapping[str, YamlValue]:
61
+ return v if isinstance(v, dict) else {}
62
+
63
+
64
+ def _as_list(v: YamlValue) -> list[YamlValue]:
65
+ return v if isinstance(v, list) else []
66
+
67
+
68
+ def _as_str(v: YamlValue) -> str:
69
+ if isinstance(v, str | int | float):
70
+ return str(v)
71
+ return ""
72
+
73
+
74
+ def _schema_refs(schema: YamlValue) -> list[str]:
75
+ """``$ref`` direto + ``items.$ref`` — escopo delimitado documentado."""
76
+ refs: list[str] = []
77
+ m = _as_map(schema)
78
+ ref = m.get("$ref")
79
+ if isinstance(ref, str) and ref:
80
+ refs.append(ref)
81
+ items = _as_map(m.get("items"))
82
+ iref = items.get("$ref")
83
+ if isinstance(iref, str) and iref:
84
+ refs.append(iref)
85
+ return refs
86
+
87
+
88
+ def _media_schema_refs(content: YamlValue) -> list[str]:
89
+ """Refs de ``content.<media-type>.schema`` (todas as media types)."""
90
+ refs: list[str] = []
91
+ for media in _as_map(content).values():
92
+ refs.extend(_schema_refs(_as_map(media).get("schema")))
93
+ return refs
94
+
95
+
96
+ def _operation_refs(op: Mapping[str, YamlValue], flavor: str) -> list[str]:
97
+ """Refs de request/response da operação (3.x e 2.0)."""
98
+ refs: list[str] = []
99
+ if flavor == "openapi3":
100
+ body = _as_map(op.get("requestBody"))
101
+ refs.extend(_media_schema_refs(body.get("content")))
102
+ for resp in _as_map(op.get("responses")).values():
103
+ refs.extend(_media_schema_refs(_as_map(resp).get("content")))
104
+ else: # swagger2
105
+ for param in _as_list(op.get("parameters")):
106
+ pm = _as_map(param)
107
+ if pm.get("in") == "body":
108
+ refs.extend(_schema_refs(pm.get("schema")))
109
+ for resp in _as_map(op.get("responses")).values():
110
+ refs.extend(_schema_refs(_as_map(resp).get("schema")))
111
+ return refs
112
+
113
+
114
+ def _server_info(doc: Mapping[str, YamlValue], flavor: str, sink: Emit,
115
+ path: str) -> tuple[str, str, int]:
116
+ """``(server_url, base_path, servers_count)`` do primeiro servidor."""
117
+ if flavor == "openapi3":
118
+ servers = _as_list(doc.get("servers"))
119
+ url = ""
120
+ for srv in servers:
121
+ u = _as_map(srv).get("url")
122
+ if isinstance(u, str) and u:
123
+ url = u
124
+ break
125
+ if url and "{" in url:
126
+ sink.diag(
127
+ "server-url-template", "info",
128
+ f"server URL carries template vars: {url!r}", path,
129
+ )
130
+ base = common.url_path(url) if url else "/"
131
+ return url, ("" if base == "/" else base), len(servers)
132
+ host = _as_str(doc.get("host"))
133
+ base = _as_str(doc.get("basePath")) or "/"
134
+ scheme = ""
135
+ schemes = _as_list(doc.get("schemes"))
136
+ if schemes:
137
+ scheme = _as_str(schemes[0])
138
+ url = f"{scheme + '://' if scheme else ''}{host}{base}" if host else ""
139
+ return url, ("" if base == "/" else base), 1 if host else 0
140
+
141
+
142
+ def extract_spec(
143
+ *,
144
+ sink: Emit,
145
+ path: str,
146
+ fmt: str,
147
+ doc: YamlValue,
148
+ flavor: str,
149
+ key_line: Callable[[tuple[str, ...]], int | None],
150
+ text: str,
151
+ offsets: list[int],
152
+ content_hash: str,
153
+ ) -> None:
154
+ """Extrai contrato/serviço/endpoints de um documento de spec.
155
+
156
+ ``key_line`` é um callback ``(tuple[str, ...]) -> int | None`` que
157
+ mapeia key-path YAML/JSON para linha 1-based; ``text``/``offsets``
158
+ sustentam ``common.line_span`` (spans honestos por linha).
159
+ """
160
+ top = _as_map(doc)
161
+ info = _as_map(top.get("info"))
162
+ title = _as_str(info.get("title"))
163
+ spec_version = _as_str(info.get("version"))
164
+ stem = path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
165
+ svc_name = common.slugify(title) or common.slugify(stem) or "service"
166
+ server_url, base_path, n_servers = _server_info(top, flavor, sink, path)
167
+ if n_servers > 1:
168
+ sink.diag(
169
+ "multi-server", "info",
170
+ f"{n_servers} servers declared — first used for base_path",
171
+ path,
172
+ )
173
+
174
+ def span_at(ln: int | None) -> SourceSpan | None:
175
+ return common.line_span(text, offsets, ln) if ln else None
176
+
177
+ # APIContract — um por arquivo de spec.
178
+ contract_line = key_line(("openapi",)) or key_line(("swagger",)) or 1
179
+ contract = sink.entity(
180
+ kind=DomainEntityKind.API_CONTRACT,
181
+ name=title or stem,
182
+ qualified_name=path,
183
+ path=path,
184
+ span=span_at(contract_line),
185
+ content_hash=content_hash,
186
+ attributes={
187
+ "format": fmt,
188
+ "spec_version": spec_version,
189
+ "api_version": _as_str(
190
+ top.get("openapi", top.get("swagger", ""))
191
+ ),
192
+ },
193
+ evidence=(sink.evi(path, content_hash, span_at(contract_line)),),
194
+ )
195
+ sink.bump("api_contracts")
196
+
197
+ # APIService — identidade = spec file + título slug.
198
+ svc_qname = f"{path}#{svc_name}"
199
+ service = sink.entity(
200
+ kind=DomainEntityKind.API_SERVICE,
201
+ name=title or svc_name,
202
+ qualified_name=svc_qname,
203
+ path=path,
204
+ span=span_at(key_line(("info", "title")) or contract_line),
205
+ content_hash=content_hash,
206
+ attributes={
207
+ "title": title,
208
+ "version": spec_version,
209
+ "server_url": server_url,
210
+ "base_path": base_path,
211
+ "servers_count": str(n_servers),
212
+ "source": "openapi-spec",
213
+ "spec": path,
214
+ },
215
+ evidence=(sink.evi(path, content_hash, span_at(contract_line)),),
216
+ )
217
+ sink.bump("api_services")
218
+ ev_def = sink.evi(path, content_hash, span_at(contract_line))
219
+ sink.relation(
220
+ relation_type=EdgeKind.DEFINES,
221
+ path=path,
222
+ from_identity=contract,
223
+ to_identity=service,
224
+ resolution=sink.resolution(
225
+ ResolutionStatus.RESOLVED, ConfidenceClass.SYNTACTIC,
226
+ target=service,
227
+ ),
228
+ span=span_at(contract_line),
229
+ evidence=(ev_def,),
230
+ )
231
+
232
+ paths = _as_map(top.get("paths"))
233
+ if not paths:
234
+ sink.diag(
235
+ "empty-paths", "info",
236
+ "spec declares no paths entries", path,
237
+ )
238
+ for raw_path, item in paths.items():
239
+ if not raw_path.startswith("/"):
240
+ if not raw_path.startswith("x-"):
241
+ sink.diag(
242
+ "path-key-ignored", "info",
243
+ f"paths key not a route: {raw_path!r}", path,
244
+ )
245
+ continue
246
+ item_map = _as_map(item)
247
+ for method, op in item_map.items():
248
+ if method.lower() in _PATH_ITEM_META or method.startswith("x-"):
249
+ continue
250
+ if method.lower() not in HTTP_METHODS:
251
+ sink.diag(
252
+ "path-key-ignored", "info",
253
+ f"path item key not an HTTP method: {method!r}", path,
254
+ )
255
+ continue
256
+ _emit_operation(
257
+ sink=sink,
258
+ path=path,
259
+ flavor=flavor,
260
+ raw_path=raw_path,
261
+ method=method,
262
+ op_map=_as_map(op),
263
+ base_path=base_path,
264
+ service=service,
265
+ svc_qname=svc_qname,
266
+ ln=key_line(("paths", raw_path, method)),
267
+ span_at=span_at,
268
+ content_hash=content_hash,
269
+ )
270
+
271
+
272
+ def _emit_operation(
273
+ *,
274
+ sink: Emit,
275
+ path: str,
276
+ flavor: str,
277
+ raw_path: str,
278
+ method: str,
279
+ op_map: Mapping[str, YamlValue],
280
+ base_path: str,
281
+ service: DomainIdentity,
282
+ svc_qname: str,
283
+ ln: int | None,
284
+ span_at: Callable[[int | None], SourceSpan | None],
285
+ content_hash: str,
286
+ ) -> None:
287
+ """Emite um APIEndpoint + CONTAINS + REFERENCES para uma operação."""
288
+ m_upper = method.upper()
289
+ full_path = common.join_paths(base_path, raw_path)
290
+ op_id = _as_str(op_map.get("operationId"))
291
+ attrs = {
292
+ "method": m_upper,
293
+ "path": raw_path,
294
+ "full_path": full_path,
295
+ "base_path": base_path,
296
+ "source": "openapi-spec",
297
+ "spec": path,
298
+ }
299
+ if op_id:
300
+ attrs["operation_id"] = op_id
301
+ tags = [t for t in _as_list(op_map.get("tags")) if isinstance(t, str)]
302
+ if tags:
303
+ attrs["tags"] = "|".join(tags)
304
+ span = span_at(ln)
305
+ ep = sink.endpoint(
306
+ name=op_id or f"{m_upper} {raw_path}",
307
+ qualified_name=f"{m_upper} {full_path}",
308
+ namespace=svc_qname,
309
+ full_path=full_path,
310
+ path=path,
311
+ span=span,
312
+ content_hash=content_hash,
313
+ attributes=attrs,
314
+ evidence=(sink.evi(path, content_hash, span),),
315
+ )
316
+ sink.bump("api_endpoints")
317
+ sink.relation(
318
+ relation_type=EdgeKind.CONTAINS,
319
+ path=path,
320
+ from_identity=service,
321
+ to_identity=ep,
322
+ resolution=sink.resolution(
323
+ ResolutionStatus.RESOLVED, ConfidenceClass.SYNTACTIC,
324
+ target=ep,
325
+ ),
326
+ span=span,
327
+ evidence=(sink.evi(path, content_hash, span),),
328
+ )
329
+ for ref in dict.fromkeys(_operation_refs(op_map, flavor)):
330
+ sink.relation(
331
+ relation_type=EdgeKind.REFERENCES,
332
+ path=path,
333
+ from_identity=ep,
334
+ to_unresolved=ref,
335
+ resolution=sink.resolution(
336
+ ResolutionStatus.UNRESOLVED,
337
+ ConfidenceClass.SYNTACTIC,
338
+ unresolved_text=ref,
339
+ ),
340
+ span=span,
341
+ evidence=(sink.evi(path, content_hash, span),),
342
+ )
343
+ sink.bump("schema_refs")
344
+
345
+
346
+ def make_key_line(
347
+ key_lines: Mapping[tuple[str, ...], int],
348
+ ) -> Callable[[tuple[str, ...]], int | None]:
349
+ """Callback ``key_line`` para YAML — lookup direto do mapa do parser."""
350
+ def _lookup(path: tuple[str, ...]) -> int | None:
351
+ return key_lines.get(path)
352
+ return _lookup
353
+
354
+
355
+ def _json_find(
356
+ lines: list[str], key: str, start: int, end: int
357
+ ) -> int | None:
358
+ pat = re.compile(rf'"{re.escape(key)}"\s*:')
359
+ for i in range(start, min(end, len(lines))):
360
+ if pat.search(lines[i]):
361
+ return i + 1
362
+ return None
363
+
364
+
365
+ def _json_paths_lookup(
366
+ lines: list[str], path: tuple[str, ...]
367
+ ) -> int | None:
368
+ start = _json_find(lines, "paths", 0, len(lines))
369
+ if start is None:
370
+ return None
371
+ pl = _json_find(lines, path[1], start, len(lines))
372
+ if pl is None or len(path) == 2:
373
+ return pl
374
+ return _json_find(lines, path[2], pl, pl + 400)
375
+
376
+
377
+ def make_json_key_line(
378
+ text: str,
379
+ ) -> Callable[[tuple[str, ...]], int | None]:
380
+ """``key_line`` aproximado para JSON — localiza a linha da chave.
381
+
382
+ Busca textual honesta: para ``("paths", p, m)`` acha a linha de
383
+ ``"<p>"`` após ``"paths"`` e a primeira ``"<m>"`` seguinte; para
384
+ chaves de topo, a primeira linha ``"<key>"``. Nunca inventa — None
385
+ quando não encontrado (span cai para None, evidência segue válida).
386
+ """
387
+ lines = text.splitlines()
388
+
389
+ def _lookup(path: tuple[str, ...]) -> int | None:
390
+ if not path:
391
+ return None
392
+ if len(path) == 1:
393
+ return _json_find(lines, path[0], 0, len(lines))
394
+ if path[0] == "paths" and len(path) >= 2:
395
+ return _json_paths_lookup(lines, path)
396
+ return None
397
+
398
+ return _lookup