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,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
|