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,308 @@
|
|
|
1
|
+
"""Helpers compartilhados do adapter ``domains.api`` (lf-0114).
|
|
2
|
+
|
|
3
|
+
Utilitários puros de texto/sintaxe: extração de lista de argumentos a
|
|
4
|
+
partir de uma posição de call-site (span do callee vindo do Tree-sitter
|
|
5
|
+
via ``ParseResult``), classificação de literais de string (Python/TS),
|
|
6
|
+
split de URL **sem** ``urllib`` (barrado fora de ``code_atlas.api`` pela
|
|
7
|
+
auditoria de rede lf-0086), nomes de módulo derivados de path e spans de
|
|
8
|
+
linha. Nenhuma I/O, nenhuma importação de ``tree_sitter``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from code_atlas.parsers.contracts import SourceSpan
|
|
18
|
+
|
|
19
|
+
_ARGS_MAX = 16_384
|
|
20
|
+
_ARGS_MAX_ITEMS = 64
|
|
21
|
+
|
|
22
|
+
PY_MODULE_SUFFIXES = (".py", ".pyi")
|
|
23
|
+
TS_MODULE_SUFFIXES = (".d.ts", ".tsx", ".ts", ".jsx", ".mjs", ".cjs", ".js")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# -- módulos / nomes ----------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def module_name(path: str, suffixes: tuple[str, ...]) -> str:
|
|
30
|
+
"""``app/routers.py`` → ``app.routers``; ``web/x.d.ts`` → ``web.x``."""
|
|
31
|
+
p = path
|
|
32
|
+
for suf in suffixes:
|
|
33
|
+
if p.endswith(suf):
|
|
34
|
+
p = p[: -len(suf)]
|
|
35
|
+
break
|
|
36
|
+
parts = [seg for seg in p.split("/") if seg]
|
|
37
|
+
if parts and parts[-1] == "__init__":
|
|
38
|
+
parts = parts[:-1]
|
|
39
|
+
return ".".join(parts) or path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def slugify(text: str) -> str:
|
|
43
|
+
"""Slug determinístico para nomes de serviço (``Pet Store`` → ``pet-store``)."""
|
|
44
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
45
|
+
return slug[:64]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def join_paths(prefix: str, path: str) -> str:
|
|
49
|
+
"""Junta prefixo + path de endpoint sem duplicar ``/``."""
|
|
50
|
+
if not prefix:
|
|
51
|
+
return path or "/"
|
|
52
|
+
if not path:
|
|
53
|
+
return prefix
|
|
54
|
+
return prefix.rstrip("/") + "/" + path.lstrip("/")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# -- offsets / spans ------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def line_offsets(text: str) -> list[int]:
|
|
61
|
+
"""Offset de **char** do início de cada linha (1-based pelo índice)."""
|
|
62
|
+
offs = [0]
|
|
63
|
+
for i, ch in enumerate(text):
|
|
64
|
+
if ch == "\n":
|
|
65
|
+
offs.append(i + 1)
|
|
66
|
+
return offs
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def byte_offsets(text: str) -> list[int]:
|
|
70
|
+
"""Offset de **byte UTF-8** do início de cada linha (alinhado a SourceSpan)."""
|
|
71
|
+
offs = [0]
|
|
72
|
+
total = 0
|
|
73
|
+
for raw in text.splitlines(keepends=True):
|
|
74
|
+
total += len(raw.encode("utf-8"))
|
|
75
|
+
offs.append(total)
|
|
76
|
+
return offs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def line_span(text: str, byte_offs: list[int], lineno: int) -> SourceSpan | None:
|
|
80
|
+
"""``SourceSpan`` da linha inteira (sem o ``\\n``); None se fora de faixa."""
|
|
81
|
+
if lineno < 1 or lineno > len(byte_offs):
|
|
82
|
+
return None
|
|
83
|
+
start = byte_offs[lineno - 1]
|
|
84
|
+
lines = text.splitlines()
|
|
85
|
+
if lineno > len(lines):
|
|
86
|
+
return None
|
|
87
|
+
raw = lines[lineno - 1]
|
|
88
|
+
end = start + len(raw.encode("utf-8"))
|
|
89
|
+
ncols = len(raw.encode("utf-8"))
|
|
90
|
+
return SourceSpan(
|
|
91
|
+
start_byte=start, end_byte=end,
|
|
92
|
+
start_line=lineno, start_col=0,
|
|
93
|
+
end_line=lineno, end_col=ncols,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def byte_to_char(text: str, byte_off: int) -> int:
|
|
98
|
+
"""Converte offset de byte UTF-8 em offset de char ``str``.
|
|
99
|
+
|
|
100
|
+
Necessário porque spans do Tree-sitter são byte offsets e o fatiamento
|
|
101
|
+
de argumentos opera sobre ``str`` decodificado.
|
|
102
|
+
"""
|
|
103
|
+
return len(text.encode("utf-8")[:byte_off].decode("utf-8", "replace"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# -- argumentos de call-site -------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _quote_step(ch: str, quote: str) -> tuple[bool, str]:
|
|
110
|
+
"""Passo de scan dentro de aspas → ``(escapou?, quote_atual)``."""
|
|
111
|
+
if ch == "\\":
|
|
112
|
+
return True, quote
|
|
113
|
+
if ch == quote:
|
|
114
|
+
return False, ""
|
|
115
|
+
return False, quote
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def scan_args(text: str, open_paren: int) -> str | None:
|
|
119
|
+
"""Conteúdo interno de ``(...)`` começando em ``open_paren``.
|
|
120
|
+
|
|
121
|
+
Balanceado e ciente de aspas (``'`` ``"`` `` ` ``) com escapes —
|
|
122
|
+
não é um parser de expressões: cobre o caso comum de call-sites de
|
|
123
|
+
rota/cliente. Limitado a ``_ARGS_MAX`` chars; None se desbalanceado
|
|
124
|
+
ou além do limite.
|
|
125
|
+
"""
|
|
126
|
+
if open_paren >= len(text) or text[open_paren] != "(":
|
|
127
|
+
return None
|
|
128
|
+
depth = 0
|
|
129
|
+
quote = ""
|
|
130
|
+
esc = False
|
|
131
|
+
i = open_paren
|
|
132
|
+
while i < len(text) and i - open_paren <= _ARGS_MAX:
|
|
133
|
+
ch = text[i]
|
|
134
|
+
if esc:
|
|
135
|
+
esc = False
|
|
136
|
+
elif quote:
|
|
137
|
+
esc, quote = _quote_step(ch, quote)
|
|
138
|
+
elif ch in "'\"`":
|
|
139
|
+
quote = ch
|
|
140
|
+
elif ch in "([{":
|
|
141
|
+
depth += 1
|
|
142
|
+
elif ch in ")]}":
|
|
143
|
+
depth -= 1
|
|
144
|
+
if depth == 0:
|
|
145
|
+
return text[open_paren + 1:i]
|
|
146
|
+
i += 1
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def args_after(text: str, pos: int) -> str | None:
|
|
151
|
+
"""Argumentos logo após ``pos`` (fim do callee): pula espaço, exige ``(``."""
|
|
152
|
+
i = pos
|
|
153
|
+
while i < len(text) and text[i] in " \t":
|
|
154
|
+
i += 1
|
|
155
|
+
if i < len(text) and text[i] == "(":
|
|
156
|
+
return scan_args(text, i)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def iter_args(args: str) -> Iterator[str]:
|
|
161
|
+
"""Itera argumentos top-level — ciente de aspas e profundidade."""
|
|
162
|
+
depth = 0
|
|
163
|
+
quote = ""
|
|
164
|
+
esc = False
|
|
165
|
+
emitted = False
|
|
166
|
+
cur: list[str] = []
|
|
167
|
+
for ch in args:
|
|
168
|
+
if esc:
|
|
169
|
+
cur.append(ch)
|
|
170
|
+
esc = False
|
|
171
|
+
continue
|
|
172
|
+
if quote:
|
|
173
|
+
cur.append(ch)
|
|
174
|
+
esc, quote = _quote_step(ch, quote)
|
|
175
|
+
continue
|
|
176
|
+
if ch in "'\"`":
|
|
177
|
+
quote = ch
|
|
178
|
+
elif ch in "([{":
|
|
179
|
+
depth += 1
|
|
180
|
+
elif ch in ")]}":
|
|
181
|
+
depth -= 1
|
|
182
|
+
elif ch == "," and depth == 0:
|
|
183
|
+
emitted = True
|
|
184
|
+
yield "".join(cur).strip()
|
|
185
|
+
cur = []
|
|
186
|
+
continue
|
|
187
|
+
cur.append(ch)
|
|
188
|
+
tail = "".join(cur).strip()
|
|
189
|
+
if tail or emitted:
|
|
190
|
+
yield tail
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def split_args(args: str) -> list[str]:
|
|
194
|
+
"""Split top-level por vírgula — limitado a ``_ARGS_MAX_ITEMS``."""
|
|
195
|
+
out: list[str] = []
|
|
196
|
+
for a in iter_args(args):
|
|
197
|
+
out.append(a)
|
|
198
|
+
if len(out) >= _ARGS_MAX_ITEMS:
|
|
199
|
+
break
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def kwarg(args: list[str], name: str) -> str | None:
|
|
204
|
+
"""Valor textual de ``name=...`` numa lista de argumentos."""
|
|
205
|
+
prefix = f"{name}="
|
|
206
|
+
for a in args:
|
|
207
|
+
a = a.strip()
|
|
208
|
+
if a.startswith(prefix):
|
|
209
|
+
return a[len(prefix):].strip()
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# -- literais de string -----------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class StrLiteral:
|
|
218
|
+
"""Literal de string classificado."""
|
|
219
|
+
|
|
220
|
+
value: str
|
|
221
|
+
dynamic: bool # f-string com ``{}`` ou template ``${}`` — nunca resolve
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
_PY_STR = re.compile(r"^([rRbBfFuU]{0,3})('''|\"\"\"|'|\")", re.S)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _quoted_end(tok: str, start: int, quote: str) -> int:
|
|
228
|
+
"""Índice do primeiro ``quote`` não-escapado >= start; -1 se ausente."""
|
|
229
|
+
i = start
|
|
230
|
+
while i < len(tok):
|
|
231
|
+
if tok[i] == "\\":
|
|
232
|
+
i += 2
|
|
233
|
+
continue
|
|
234
|
+
if tok.startswith(quote, i):
|
|
235
|
+
return i
|
|
236
|
+
i += 1
|
|
237
|
+
return -1
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def parse_str_literal(tok: str, *, lang: str) -> StrLiteral | None:
|
|
241
|
+
"""Classifica um token como literal de string.
|
|
242
|
+
|
|
243
|
+
``lang`` = ``"py"`` (prefixos r/b/f/u, aspas simples/duplas/triplas)
|
|
244
|
+
ou ``"ts"`` (aspas simples/duplas/backtick). Devolve ``StrLiteral`` —
|
|
245
|
+
``dynamic=True`` quando o literal carrega interpolação (``f".."`` /
|
|
246
|
+
template ``${}``); None quando o token não é literal puro —
|
|
247
|
+
identificador, expressão, concatenação (``"a" + "b"`` não é literal
|
|
248
|
+
estático e nunca deve ser resolvido como URL).
|
|
249
|
+
"""
|
|
250
|
+
tok = tok.strip()
|
|
251
|
+
if not tok:
|
|
252
|
+
return None
|
|
253
|
+
if lang == "py":
|
|
254
|
+
m = _PY_STR.match(tok)
|
|
255
|
+
if not m:
|
|
256
|
+
return None
|
|
257
|
+
prefix, quote = m.group(1).lower(), m.group(2)
|
|
258
|
+
if "b" in prefix:
|
|
259
|
+
return None # bytes literal — não é URL
|
|
260
|
+
close = _quoted_end(tok, m.end(), quote)
|
|
261
|
+
if close < 0 or tok[close + len(quote):].strip():
|
|
262
|
+
return None # sem fechamento, ou concatenação após o literal
|
|
263
|
+
body = tok[m.end():close]
|
|
264
|
+
dynamic = "f" in prefix and ("{" in body)
|
|
265
|
+
return StrLiteral(value=body, dynamic=dynamic)
|
|
266
|
+
# ts
|
|
267
|
+
q = tok[0]
|
|
268
|
+
if q in ("'", '"', "`"):
|
|
269
|
+
close = _quoted_end(tok, 1, q)
|
|
270
|
+
if close < 0 or tok[close + 1:].strip():
|
|
271
|
+
return None
|
|
272
|
+
body = tok[1:close]
|
|
273
|
+
return StrLiteral(value=body, dynamic=(q == "`" and "${" in body))
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# -- URLs (sem urllib — fronteira lf-0086) ----------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*)://(.*)$")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def url_path(url: str) -> str:
|
|
284
|
+
"""Componente de path de uma URL absoluta/relativa.
|
|
285
|
+
|
|
286
|
+
Split mínimo por string — ``urllib.parse`` é proibido fora de
|
|
287
|
+
``code_atlas.api`` (auditoria lf-0086). URLs com ``{var}`` (templates
|
|
288
|
+
de servidor OpenAPI) passam intactas.
|
|
289
|
+
"""
|
|
290
|
+
s = url.strip()
|
|
291
|
+
m = _SCHEME_RE.match(s)
|
|
292
|
+
if m:
|
|
293
|
+
rest = m.group(2)
|
|
294
|
+
slash = rest.find("/")
|
|
295
|
+
path = rest[slash:] if slash >= 0 else "/"
|
|
296
|
+
else:
|
|
297
|
+
path = s
|
|
298
|
+
for sep in ("?", "#"):
|
|
299
|
+
cut = path.find(sep)
|
|
300
|
+
if cut >= 0:
|
|
301
|
+
path = path[:cut]
|
|
302
|
+
return path or "/"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def is_absolute_url(url: str) -> bool:
|
|
306
|
+
"""True para ``scheme://`` absoluta (http/https/ws)."""
|
|
307
|
+
m = _SCHEME_RE.match(url.strip())
|
|
308
|
+
return bool(m and m.group(1).lower() in ("http", "https", "ws", "wss"))
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Chamadas HTTP cliente em código — consumidores de endpoints (lf-0114).
|
|
2
|
+
|
|
3
|
+
Somente **literais estáticos** viram URL: ``fetch("/x")``,
|
|
4
|
+
``requests.get("http://a/b")``, ``axios.post(...)``. Qualquer forma
|
|
5
|
+
dinâmica (f-string, template ``${}``, variável, concatenação, objeto de
|
|
6
|
+
config sem ``url`` literal) vira marcador ``dynamic:<expr>`` com status
|
|
7
|
+
``unresolved`` — nunca resolvido, nunca inventado.
|
|
8
|
+
|
|
9
|
+
Vínculo literal-URL ↔ contrato/endpoint local é da Phase 9
|
|
10
|
+
(cross-domain resolution): aqui emitimos ``CALLS_ENDPOINT`` com status
|
|
11
|
+
``unresolved`` (URL relativa) ou ``external`` (URL absoluta
|
|
12
|
+
``http(s)://`` fora do snapshot) + ``candidates`` com endpoints locais
|
|
13
|
+
cujo ``full_path`` casa textualmente — dado de pista, não resolução.
|
|
14
|
+
|
|
15
|
+
Spans/callees vêm do ``ParseResult`` (AST real) para .py/.ts; ``.js`` usa
|
|
16
|
+
regex-static por linha (``heuristic``). O método HTTP vai em ``notes``
|
|
17
|
+
da evidência (``method=POST``) quando declarado literalmente.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
from code_atlas.contracts.enums import ConfidenceClass, ResolutionStatus
|
|
25
|
+
from code_atlas.domains.api import common
|
|
26
|
+
from code_atlas.domains.api.emit import Emit
|
|
27
|
+
from code_atlas.domains.identity import DomainIdentity
|
|
28
|
+
from code_atlas.domains.model import DomainRelationKind
|
|
29
|
+
from code_atlas.parsers.contracts import ParseResult, SourceSpan
|
|
30
|
+
|
|
31
|
+
# callee → índice do argumento que carrega a URL.
|
|
32
|
+
PY_URL_ARG: dict[str, int] = {
|
|
33
|
+
"requests.get": 0, "requests.post": 0, "requests.put": 0,
|
|
34
|
+
"requests.delete": 0, "requests.patch": 0, "requests.head": 0,
|
|
35
|
+
"requests.options": 0, "requests.request": 1,
|
|
36
|
+
"httpx.get": 0, "httpx.post": 0, "httpx.put": 0,
|
|
37
|
+
"httpx.delete": 0, "httpx.patch": 0, "httpx.head": 0,
|
|
38
|
+
"httpx.options": 0, "httpx.request": 1,
|
|
39
|
+
"urllib.request.urlopen": 0,
|
|
40
|
+
}
|
|
41
|
+
TS_URL_ARG: dict[str, int] = {
|
|
42
|
+
"fetch": 0, "axios": 0, "axios.request": 0,
|
|
43
|
+
"axios.get": 0, "axios.post": 0, "axios.put": 0,
|
|
44
|
+
"axios.delete": 0, "axios.patch": 0, "axios.head": 0,
|
|
45
|
+
"axios.options": 0,
|
|
46
|
+
}
|
|
47
|
+
_AXIOS_URL_PROP = re.compile(r"\burl\s*:\s*('([^']*)'|\"([^\"]*)\")")
|
|
48
|
+
_METHOD_PROP = re.compile(r"\bmethod\s*:\s*['\"](\w+)['\"]")
|
|
49
|
+
_JS_CALL = re.compile(r"\b(fetch|axios(?:\.\w+)?)\s*\(")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _method_note(callee: str, args: list[str], url_idx: int) -> str:
|
|
53
|
+
"""Método HTTP declarado literalmente — vai em ``evidence.notes``."""
|
|
54
|
+
method = ""
|
|
55
|
+
if url_idx == 1 and args: # requests.request("GET", url)
|
|
56
|
+
lit = common.parse_str_literal(args[0], lang="py")
|
|
57
|
+
if lit and not lit.dynamic:
|
|
58
|
+
method = lit.value.upper()
|
|
59
|
+
elif callee == "fetch" and len(args) > 1:
|
|
60
|
+
m = _METHOD_PROP.search(args[1])
|
|
61
|
+
if m:
|
|
62
|
+
method = m.group(1).upper()
|
|
63
|
+
elif callee.startswith("axios."):
|
|
64
|
+
method = callee.rsplit(".", 1)[-1].upper()
|
|
65
|
+
if method == "REQUEST":
|
|
66
|
+
method = ""
|
|
67
|
+
elif callee.startswith(("requests.", "httpx.")):
|
|
68
|
+
method = callee.rsplit(".", 1)[-1].upper()
|
|
69
|
+
return f"method={method}" if method else ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _candidates(sink: Emit, url: str) -> tuple[DomainIdentity, ...]:
|
|
73
|
+
"""Endpoints locais cujo path casa — pista para Phase 9, não resolução."""
|
|
74
|
+
path = common.url_path(url)
|
|
75
|
+
found: list[DomainIdentity] = []
|
|
76
|
+
seen: set[str] = set()
|
|
77
|
+
for ident in sink.endpoint_paths.get(path, ()):
|
|
78
|
+
key = ident.logical_key()
|
|
79
|
+
if key not in seen:
|
|
80
|
+
seen.add(key)
|
|
81
|
+
found.append(ident)
|
|
82
|
+
return tuple(found)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def emit_call(
|
|
86
|
+
*,
|
|
87
|
+
sink: Emit,
|
|
88
|
+
path: str,
|
|
89
|
+
caller: str,
|
|
90
|
+
callee: str,
|
|
91
|
+
args_text: str | None,
|
|
92
|
+
url_idx: int,
|
|
93
|
+
span: SourceSpan | None,
|
|
94
|
+
content_hash: str,
|
|
95
|
+
confidence: ConfidenceClass,
|
|
96
|
+
lang: str,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Uma call-site HTTP → ``CALLS_ENDPOINT`` com estado honesto."""
|
|
99
|
+
args = common.split_args(args_text or "")
|
|
100
|
+
if len(args) <= url_idx:
|
|
101
|
+
sink.diag(
|
|
102
|
+
"call-args-unparsed", "info",
|
|
103
|
+
f"{callee}(...) without readable args at {path}",
|
|
104
|
+
path,
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
token = args[url_idx]
|
|
108
|
+
# axios({url: "/x", method: "post"}) — objeto de config com url literal.
|
|
109
|
+
if token.startswith("{") and callee.startswith("axios"):
|
|
110
|
+
m = _AXIOS_URL_PROP.search(token)
|
|
111
|
+
if m:
|
|
112
|
+
token = m.group(1)
|
|
113
|
+
lit = common.parse_str_literal(token, lang=lang)
|
|
114
|
+
notes = _method_note(callee, args, url_idx)
|
|
115
|
+
evi = sink.evi(path, content_hash, span, notes)
|
|
116
|
+
|
|
117
|
+
if lit is None or lit.dynamic:
|
|
118
|
+
marker = (lit.value if lit else token).strip()[:80]
|
|
119
|
+
sink.relation(
|
|
120
|
+
relation_type=DomainRelationKind.CALLS_ENDPOINT,
|
|
121
|
+
path=path,
|
|
122
|
+
from_symbol=caller,
|
|
123
|
+
to_unresolved=f"dynamic:{marker}",
|
|
124
|
+
resolution=sink.resolution(
|
|
125
|
+
ResolutionStatus.UNRESOLVED, ConfidenceClass.HEURISTIC,
|
|
126
|
+
unresolved_text=f"dynamic:{marker}",
|
|
127
|
+
),
|
|
128
|
+
span=span,
|
|
129
|
+
evidence=(evi,),
|
|
130
|
+
)
|
|
131
|
+
sink.diag(
|
|
132
|
+
"dynamic-url", "info",
|
|
133
|
+
f"{callee} call with dynamic URL marker — never resolved "
|
|
134
|
+
f"({marker[:60]!r})",
|
|
135
|
+
path,
|
|
136
|
+
)
|
|
137
|
+
sink.bump("consumer_calls_dynamic")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
url = lit.value
|
|
141
|
+
if not url.strip():
|
|
142
|
+
sink.diag(
|
|
143
|
+
"empty-url", "warning",
|
|
144
|
+
f"{callee} call with empty URL literal", path,
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
candidates = _candidates(sink, url)
|
|
148
|
+
if common.is_absolute_url(url):
|
|
149
|
+
status = ResolutionStatus.EXTERNAL
|
|
150
|
+
else:
|
|
151
|
+
status = ResolutionStatus.UNRESOLVED
|
|
152
|
+
sink.relation(
|
|
153
|
+
relation_type=DomainRelationKind.CALLS_ENDPOINT,
|
|
154
|
+
path=path,
|
|
155
|
+
from_symbol=caller,
|
|
156
|
+
to_unresolved=url,
|
|
157
|
+
resolution=sink.resolution(
|
|
158
|
+
status, confidence,
|
|
159
|
+
candidates=candidates,
|
|
160
|
+
unresolved_text=url,
|
|
161
|
+
),
|
|
162
|
+
span=span,
|
|
163
|
+
evidence=(evi,),
|
|
164
|
+
)
|
|
165
|
+
sink.bump("consumer_calls")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def extract_consumers(
|
|
169
|
+
*,
|
|
170
|
+
sink: Emit,
|
|
171
|
+
path: str,
|
|
172
|
+
text: str,
|
|
173
|
+
result: ParseResult,
|
|
174
|
+
module: str,
|
|
175
|
+
content_hash: str,
|
|
176
|
+
table: dict[str, int],
|
|
177
|
+
lang: str,
|
|
178
|
+
confidence: ConfidenceClass,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Call-sites HTTP a partir de refs ``call`` do AST real."""
|
|
181
|
+
for ref in result.references:
|
|
182
|
+
if ref.context != "call":
|
|
183
|
+
continue
|
|
184
|
+
callee = ref.name.strip()
|
|
185
|
+
url_idx = table.get(callee)
|
|
186
|
+
if url_idx is None:
|
|
187
|
+
continue
|
|
188
|
+
scope = ".".join(ref.scope_path)
|
|
189
|
+
caller = f"{module}.{scope}" if scope else module
|
|
190
|
+
emit_call(
|
|
191
|
+
sink=sink, path=path, caller=caller, callee=callee,
|
|
192
|
+
args_text=common.args_after(
|
|
193
|
+
text, common.byte_to_char(text, ref.span.end_byte)
|
|
194
|
+
),
|
|
195
|
+
url_idx=url_idx, span=ref.span,
|
|
196
|
+
content_hash=content_hash,
|
|
197
|
+
confidence=confidence, lang=lang,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def extract_js_consumers(
|
|
202
|
+
*,
|
|
203
|
+
sink: Emit,
|
|
204
|
+
path: str,
|
|
205
|
+
text: str,
|
|
206
|
+
module: str,
|
|
207
|
+
content_hash: str,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Regex-static por linha para .js — sem AST; ``heuristic``."""
|
|
210
|
+
byte_offs = common.byte_offsets(text)
|
|
211
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
212
|
+
for m in _JS_CALL.finditer(line):
|
|
213
|
+
callee = m.group(1)
|
|
214
|
+
url_idx = TS_URL_ARG.get(callee)
|
|
215
|
+
if url_idx is None:
|
|
216
|
+
continue
|
|
217
|
+
span = common.line_span(text, byte_offs, lineno)
|
|
218
|
+
emit_call(
|
|
219
|
+
sink=sink, path=path, caller=module, callee=callee,
|
|
220
|
+
args_text=common.scan_args(line, m.end() - 1),
|
|
221
|
+
url_idx=url_idx, span=span,
|
|
222
|
+
content_hash=content_hash,
|
|
223
|
+
confidence=ConfidenceClass.HEURISTIC, lang="ts",
|
|
224
|
+
)
|