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