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,582 @@
1
+ """Rotas declaradas em código — FastAPI (Python) e Express (TS/JS) (lf-0114).
2
+
3
+ FastAPI (.py): consome artefatos de AST reais — ``ParseResult`` do
4
+ ``ParseEngine`` (Tree-sitter vive atrás da fronteira ``parsers/``).
5
+ Decoradores ``@app.get("/x")``/``@router.post(...)`` chegam como texto de
6
+ decorator nos símbolos (``decorated_definition``); receivers
7
+ ``FastAPI()``/``APIRouter(prefix=...)`` e ``include_router`` chegam como
8
+ referências ``call`` com span real — argumentos são fatiados do código
9
+ fonte na posição do callee (nunca regex solto em texto bruto).
10
+
11
+ Express (.ts/.tsx): mesmo pipeline de AST; convenção de receiver
12
+ (``app``/``router``/vars de ``express()``/``Router()``) → confiança
13
+ ``heuristic`` declarada. ``.js``/``.jsx``/``.mjs``/``.cjs`` não têm
14
+ adaptador Tree-sitter neste repo — regex-static honesto por linha.
15
+
16
+ Relações emitidas:
17
+
18
+ - ``APIService --CONTAINS--> APIEndpoint`` (resolvido internamente);
19
+ - ``<handler symbol> --DEFINES--> APIEndpoint`` — ponte código→domínio
20
+ (``EXPOSES`` não consta do vocabulário fechado de ``DomainRelationKind``;
21
+ ``DEFINES`` é o membro honesto: o handler literalmente define a rota);
22
+ - ``<app service> --CONTAINS--> <router service>`` para
23
+ ``include_router``/``use`` observados no mesmo arquivo.
24
+
25
+ Limitações declaradas: prefixo de ``include_router`` só se aplica quando
26
+ o router var é declarado no mesmo arquivo (cross-file → diagnóstico);
27
+ paths de rota não-literais (f-string, variável) → diagnóstico
28
+ ``dynamic-route-path`` e nenhum endpoint inventado.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from dataclasses import dataclass
35
+
36
+ from code_atlas.contracts.enums import (
37
+ ConfidenceClass,
38
+ EdgeKind,
39
+ ResolutionStatus,
40
+ )
41
+ from code_atlas.domains.api import common
42
+ from code_atlas.domains.api.emit import Emit
43
+ from code_atlas.domains.identity import DomainIdentity
44
+ from code_atlas.domains.model import DomainEntityKind
45
+ from code_atlas.parsers.contracts import ParseResult, SourceSpan
46
+
47
+ HTTP_METHODS = frozenset(
48
+ {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
49
+ )
50
+ _ROUTE_DEC = re.compile(
51
+ r"^([\w.]+)\.(get|put|post|delete|options|head|patch|trace"
52
+ r"|route|api_route|websocket|webhooks)\s*\("
53
+ )
54
+ _PY_CTOR = re.compile(r"^(?:[\w.]+\.)?(FastAPI|APIRouter|Flask|Blueprint)$")
55
+ _PY_VAR_LHS = re.compile(r"^\s*([A-Za-z_]\w*)\s*(?::[^=]+)?=")
56
+ _TS_CTOR = re.compile(r"^(?:express\.Router|express|Router)$")
57
+ _TS_VAR_LHS = re.compile(
58
+ r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*="
59
+ )
60
+ _SIMPLE_RECV = re.compile(r"^[A-Za-z_$][\w$]*$")
61
+ _JS_ROUTE = re.compile(
62
+ r"^\s*([A-Za-z_$][\w$]*)\."
63
+ r"(get|put|post|delete|options|head|patch|all|use)\s*\("
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class _Route:
69
+ receiver: str
70
+ method: str # UPPER ou "ANY"
71
+ path: str
72
+ span: SourceSpan | None
73
+ handler: str # qname do símbolo que define a rota
74
+
75
+
76
+ @dataclass
77
+ class _ServiceInfo:
78
+ kind: str # "app" | "router" | "unknown"
79
+ prefix: str # prefixo declarado (APIRouter/root_path)
80
+ mount: str # prefixo de mount (include_router/app.use)
81
+ declared: bool
82
+ span: SourceSpan | None
83
+
84
+
85
+ def _args_at(text: str, span_end_byte: int) -> str | None:
86
+ """Argumentos ``(...)`` logo após o fim do callee (byte offset)."""
87
+ pos = common.byte_to_char(text, span_end_byte)
88
+ return common.args_after(text, pos)
89
+
90
+
91
+ def _line_at(text: str, lineno: int) -> str:
92
+ lines = text.splitlines()
93
+ return lines[lineno - 1] if 1 <= lineno <= len(lines) else ""
94
+
95
+
96
+ def _method_lit(token: str, *, lang: str) -> str | None:
97
+ lit = common.parse_str_literal(token, lang=lang)
98
+ if lit is None or lit.dynamic:
99
+ return None
100
+ m = lit.value.strip().upper()
101
+ return m if m in {x.upper() for x in HTTP_METHODS} else None
102
+
103
+
104
+ def _first_lit(args: list[str], *, lang: str) -> common.StrLiteral | None:
105
+ if not args:
106
+ return None
107
+ return common.parse_str_literal(args[0], lang=lang)
108
+
109
+
110
+ # -- FastAPI / Python ------------------------------------------------------
111
+
112
+
113
+ def _py_receiver_maps(
114
+ sink: Emit, path: str, text: str, result: ParseResult
115
+ ) -> tuple[dict[str, _ServiceInfo], list[tuple[str, str, str]]]:
116
+ """Vars ``X = FastAPI()/APIRouter()`` e ``X.include_router(Y, ...)``."""
117
+ services: dict[str, _ServiceInfo] = {}
118
+ includes: list[tuple[str, str, str]] = [] # (app_var, router_var, prefix)
119
+ for ref in result.references:
120
+ if ref.context != "call":
121
+ continue
122
+ callee = ref.name.strip()
123
+ m = _PY_CTOR.match(callee)
124
+ if m:
125
+ lhs = _PY_VAR_LHS.match(_line_at(text, ref.span.start_line))
126
+ if not lhs:
127
+ continue
128
+ args = common.split_args(_args_at(text, ref.span.end_byte) or "")
129
+ kw = "prefix" if m.group(1) in ("APIRouter", "Blueprint") else "root_path"
130
+ prefix_lit = common.parse_str_literal(
131
+ common.kwarg(args, kw) or "", lang="py"
132
+ )
133
+ prefix = (
134
+ prefix_lit.value if prefix_lit and not prefix_lit.dynamic else ""
135
+ )
136
+ var = lhs.group(1)
137
+ services[var] = _ServiceInfo(
138
+ kind="router" if m.group(1) in ("APIRouter", "Blueprint") else "app",
139
+ prefix=prefix,
140
+ mount="",
141
+ declared=True,
142
+ span=ref.span,
143
+ )
144
+ continue
145
+ if callee.endswith(".include_router"):
146
+ app_var = callee[: -len(".include_router")].rsplit(".", 1)[-1]
147
+ args_text = _args_at(text, ref.span.end_byte) or ""
148
+ args = common.split_args(args_text)
149
+ if not args:
150
+ continue
151
+ router_var = args[0].strip()
152
+ if not _SIMPLE_RECV.match(router_var):
153
+ continue
154
+ prefix_lit = common.parse_str_literal(
155
+ common.kwarg(args, "prefix") or "", lang="py"
156
+ )
157
+ prefix = (
158
+ prefix_lit.value if prefix_lit and not prefix_lit.dynamic else ""
159
+ )
160
+ includes.append((app_var, router_var, prefix))
161
+ return services, includes
162
+
163
+
164
+ def _py_routes(result: ParseResult) -> tuple[list[_Route], int]:
165
+ """Rotas a partir dos decorators reais extraídos pelo Tree-sitter."""
166
+ routes: list[_Route] = []
167
+ skipped_nonhttp = 0
168
+ for sym in result.symbols:
169
+ for dec in sym.decorators:
170
+ m = _ROUTE_DEC.match(dec.strip())
171
+ if not m:
172
+ continue
173
+ recv, verb = m.group(1), m.group(2)
174
+ if verb in ("websocket", "webhooks"):
175
+ skipped_nonhttp += 1
176
+ continue
177
+ open_paren = dec.find("(", m.end() - 1)
178
+ args = common.split_args(common.scan_args(dec, open_paren) or "")
179
+ lit = _first_lit(args, lang="py")
180
+ if lit is None or lit.dynamic:
181
+ routes.append(
182
+ _Route(recv, "?", "", sym.span, sym.qualified_name)
183
+ )
184
+ continue
185
+ if verb in ("route", "api_route"):
186
+ methods_raw = common.kwarg(args, "methods") or ""
187
+ methods = re.findall(r"['\"](\w+)['\"]", methods_raw)
188
+ if not methods:
189
+ methods = ["ANY"]
190
+ for mm in methods:
191
+ routes.append(
192
+ _Route(
193
+ recv, mm.upper(), lit.value,
194
+ sym.span, sym.qualified_name,
195
+ )
196
+ )
197
+ continue
198
+ routes.append(
199
+ _Route(recv, verb.upper(), lit.value,
200
+ sym.span, sym.qualified_name)
201
+ )
202
+ return routes, skipped_nonhttp
203
+
204
+
205
+ def extract_py_routes(
206
+ *,
207
+ sink: Emit,
208
+ path: str,
209
+ text: str,
210
+ result: ParseResult,
211
+ module: str,
212
+ content_hash: str,
213
+ ) -> None:
214
+ """FastAPI: decorators + receivers via AST real — ``syntactic``."""
215
+ services, includes = _py_receiver_maps(sink, path, text, result)
216
+ routes, skipped_nonhttp = _py_routes(result)
217
+ if skipped_nonhttp:
218
+ sink.diag(
219
+ "non-http-decorator", "info",
220
+ f"{skipped_nonhttp} websocket/webhooks decorator(s) skipped "
221
+ f"in {path} — not HTTP API endpoints",
222
+ path,
223
+ )
224
+ _emit_routes(
225
+ sink=sink, path=path, text=text, module=module,
226
+ content_hash=content_hash, services=services, includes=includes,
227
+ routes=routes, framework="fastapi",
228
+ confidence=ConfidenceClass.SYNTACTIC,
229
+ source="fastapi-decorator",
230
+ )
231
+
232
+
233
+ # -- Express / TypeScript -----------------------------------------------------
234
+
235
+
236
+ def _ts_receiver_maps(
237
+ text: str, result: ParseResult
238
+ ) -> tuple[dict[str, _ServiceInfo], list[tuple[str, str, str]]]:
239
+ """``X = express()/Router()`` e ``X.use("/p", Y)`` — spans reais."""
240
+ services: dict[str, _ServiceInfo] = {}
241
+ mounts: list[tuple[str, str, str]] = [] # (app_var, router_var, prefix)
242
+ for ref in result.references:
243
+ if ref.context != "call":
244
+ continue
245
+ callee = ref.name.strip()
246
+ if _TS_CTOR.match(callee):
247
+ lhs = _TS_VAR_LHS.match(_line_at(text, ref.span.start_line))
248
+ if not lhs:
249
+ continue
250
+ kind = "router" if callee.endswith("Router") else "app"
251
+ services[lhs.group(1)] = _ServiceInfo(
252
+ kind=kind, prefix="", mount="",
253
+ declared=True, span=ref.span,
254
+ )
255
+ continue
256
+ if callee.endswith(".use"):
257
+ recv = callee[: -len(".use")]
258
+ if not _SIMPLE_RECV.match(recv):
259
+ continue
260
+ args = common.split_args(_args_at(text, ref.span.end_byte) or "")
261
+ if len(args) < 2:
262
+ continue
263
+ mount_lit = common.parse_str_literal(args[0], lang="ts")
264
+ router_var = args[1].strip()
265
+ if (
266
+ mount_lit and not mount_lit.dynamic
267
+ and _SIMPLE_RECV.match(router_var)
268
+ ):
269
+ mounts.append((recv, router_var, mount_lit.value))
270
+ continue
271
+ return services, mounts
272
+
273
+
274
+ def _ts_routes(
275
+ text: str,
276
+ result: ParseResult,
277
+ module: str,
278
+ services: dict[str, _ServiceInfo],
279
+ ) -> list[_Route]:
280
+ routes: list[_Route] = []
281
+ for ref in result.references:
282
+ if ref.context != "call":
283
+ continue
284
+ callee = ref.name.strip()
285
+ base, _, verb = callee.rpartition(".")
286
+ if verb not in HTTP_METHODS and verb != "all":
287
+ continue
288
+ if not _SIMPLE_RECV.match(base):
289
+ continue
290
+ if base not in services and base not in ("app", "router"):
291
+ continue
292
+ args = common.split_args(_args_at(text, ref.span.end_byte) or "")
293
+ lit = _first_lit(args, lang="ts")
294
+ scope = ".".join(ref.scope_path)
295
+ caller = f"{module}.{scope}" if scope else module
296
+ if lit is None or lit.dynamic:
297
+ routes.append(_Route(base, "?", "", ref.span, caller))
298
+ continue
299
+ method = "ANY" if verb == "all" else verb.upper()
300
+ routes.append(_Route(base, method, lit.value, ref.span, caller))
301
+ return routes
302
+
303
+
304
+ def extract_ts_routes(
305
+ *,
306
+ sink: Emit,
307
+ path: str,
308
+ text: str,
309
+ result: ParseResult,
310
+ module: str,
311
+ content_hash: str,
312
+ ) -> None:
313
+ """Express em .ts/.tsx: AST real + convenção de receiver — ``heuristic``."""
314
+ services, mounts = _ts_receiver_maps(text, result)
315
+ routes = _ts_routes(text, result, module, services)
316
+ _emit_routes(
317
+ sink=sink, path=path, text=text, module=module,
318
+ content_hash=content_hash, services=services, includes=mounts,
319
+ routes=routes, framework="express",
320
+ confidence=ConfidenceClass.HEURISTIC,
321
+ source="express-literal",
322
+ )
323
+
324
+
325
+ # -- Express / JavaScript regex-static (sem adaptador Tree-sitter) -----------
326
+
327
+
328
+ def extract_js_routes(
329
+ *,
330
+ sink: Emit,
331
+ path: str,
332
+ text: str,
333
+ module: str,
334
+ content_hash: str,
335
+ ) -> None:
336
+ """Regex-static honesto para .js — sem AST; sempre ``heuristic``."""
337
+ services: dict[str, _ServiceInfo] = {}
338
+ mounts: list[tuple[str, str, str]] = []
339
+ routes: list[_Route] = []
340
+ lines = text.splitlines()
341
+ byte_offs = common.byte_offsets(text)
342
+ for lineno, line in enumerate(lines, 1):
343
+ vm = _TS_VAR_LHS.match(line)
344
+ ctor = re.search(
345
+ r"=\s*(?:[\w.]+\.)?(express\.Router|express|Router)\s*\(", line
346
+ )
347
+ if vm and ctor:
348
+ services[vm.group(1)] = _ServiceInfo(
349
+ kind="router" if ctor.group(1).endswith("Router") else "app",
350
+ prefix="", mount="", declared=True,
351
+ span=common.line_span(text, byte_offs, lineno),
352
+ )
353
+ continue
354
+ rm = _JS_ROUTE.match(line)
355
+ if not rm:
356
+ continue
357
+ recv, verb = rm.group(1), rm.group(2).lower()
358
+ args = common.split_args(common.scan_args(line, rm.end() - 1) or "")
359
+ span = common.line_span(text, byte_offs, lineno)
360
+ if verb == "use":
361
+ if len(args) >= 2:
362
+ mount_lit = common.parse_str_literal(args[0], lang="ts")
363
+ router_var = args[1].strip()
364
+ if (
365
+ mount_lit and not mount_lit.dynamic
366
+ and _SIMPLE_RECV.match(router_var)
367
+ ):
368
+ mounts.append((recv, router_var, mount_lit.value))
369
+ continue
370
+ lit = _first_lit(args, lang="ts")
371
+ if lit is None or lit.dynamic:
372
+ routes.append(_Route(recv, "?", "", span, module))
373
+ continue
374
+ method = "ANY" if verb == "all" else verb.upper()
375
+ routes.append(_Route(recv, method, lit.value, span, module))
376
+ _emit_routes(
377
+ sink=sink, path=path, text=text, module=module,
378
+ content_hash=content_hash, services=services, includes=mounts,
379
+ routes=routes, framework="express",
380
+ confidence=ConfidenceClass.HEURISTIC,
381
+ source="express-regex",
382
+ )
383
+
384
+
385
+ # -- emissão compartilhada ----------------------------------------------------
386
+
387
+
388
+ def _mount_prefixes(
389
+ *,
390
+ sink: Emit,
391
+ path: str,
392
+ services: dict[str, _ServiceInfo],
393
+ includes: list[tuple[str, str, str]],
394
+ ) -> dict[str, str]:
395
+ """``router_var`` → prefixo de mount (primeiro observado no arquivo)."""
396
+ mount_of: dict[str, list[tuple[str, str]]] = {}
397
+ for app_var, router_var, prefix in includes:
398
+ mount_of.setdefault(router_var, []).append((app_var, prefix))
399
+ if router_var not in services:
400
+ sink.diag(
401
+ "cross-file-router-prefix", "info",
402
+ f"{router_var!r} mounted but not declared in {path} — "
403
+ "prefix may be incomplete",
404
+ path,
405
+ )
406
+ mount_str: dict[str, str] = {}
407
+ for router_var, entries in mount_of.items():
408
+ if len(entries) > 1:
409
+ sink.diag(
410
+ "multi-mount", "info",
411
+ f"{router_var!r} mounted {len(entries)}x — first prefix used",
412
+ path,
413
+ )
414
+ mount_str[router_var] = entries[0][1]
415
+ return mount_str
416
+
417
+
418
+ def _emit_routes(
419
+ *,
420
+ sink: Emit,
421
+ path: str,
422
+ text: str,
423
+ module: str,
424
+ content_hash: str,
425
+ services: dict[str, _ServiceInfo],
426
+ includes: list[tuple[str, str, str]],
427
+ routes: list[_Route],
428
+ framework: str,
429
+ confidence: ConfidenceClass,
430
+ source: str,
431
+ ) -> None:
432
+ mount_str = _mount_prefixes(
433
+ sink=sink, path=path, services=services, includes=includes,
434
+ )
435
+
436
+ svc_idents: dict[str, DomainIdentity] = {}
437
+
438
+ def _service_ident(recv: str) -> DomainIdentity:
439
+ cached = svc_idents.get(recv)
440
+ if cached is not None:
441
+ return cached
442
+ info = services.get(recv)
443
+ if info is None:
444
+ info = _ServiceInfo("unknown", "", "", False, None)
445
+ services[recv] = info
446
+ sink.diag(
447
+ "undeclared-receiver", "info",
448
+ f"route receiver {recv!r} has no {framework} constructor "
449
+ f"in {path}",
450
+ path,
451
+ )
452
+ mount = mount_str.get(recv, "")
453
+ qname = f"{module}#{recv}"
454
+ ident = sink.entity(
455
+ kind=DomainEntityKind.API_SERVICE,
456
+ name=recv,
457
+ qualified_name=qname,
458
+ path=path,
459
+ span=info.span,
460
+ content_hash=content_hash,
461
+ attributes={
462
+ "framework": framework,
463
+ "receiver": recv,
464
+ "prefix": info.prefix,
465
+ "mounted_prefix": mount,
466
+ "source": source,
467
+ "receiver_declared": "true" if info.declared else "false",
468
+ },
469
+ evidence=(
470
+ sink.evi(path, content_hash, info.span, f"receiver {recv}"),
471
+ ) if info.span else (),
472
+ )
473
+ svc_idents[recv] = ident
474
+ sink.bump("api_services")
475
+ return ident
476
+
477
+ # CONTAINS app→router para mounts observados.
478
+ for app_var, router_var, _mount in includes:
479
+ app_ident = _service_ident(app_var)
480
+ router_ident = _service_ident(router_var)
481
+ span = services[router_var].span or services[app_var].span
482
+ sink.relation(
483
+ relation_type=EdgeKind.CONTAINS,
484
+ path=path,
485
+ from_identity=app_ident,
486
+ to_identity=router_ident,
487
+ resolution=sink.resolution(
488
+ ResolutionStatus.RESOLVED, confidence,
489
+ target=router_ident,
490
+ ),
491
+ span=span,
492
+ evidence=(sink.evi(path, content_hash, span),) if span else (),
493
+ )
494
+
495
+ for route in routes:
496
+ recv_ident = _service_ident(route.receiver)
497
+ if route.method == "?" or not route.path:
498
+ sink.diag(
499
+ "dynamic-route-path", "warning",
500
+ f"non-literal route path on {route.receiver!r} in {path} "
501
+ f"— no endpoint identity emitted",
502
+ path,
503
+ )
504
+ continue
505
+ _emit_route_endpoint(
506
+ sink=sink,
507
+ path=path,
508
+ module=module,
509
+ content_hash=content_hash,
510
+ route=route,
511
+ recv_ident=recv_ident,
512
+ full_prefix=common.join_paths(
513
+ mount_str.get(route.receiver, ""),
514
+ services[route.receiver].prefix,
515
+ ),
516
+ framework=framework,
517
+ confidence=confidence,
518
+ source=source,
519
+ )
520
+
521
+
522
+ def _emit_route_endpoint(
523
+ *,
524
+ sink: Emit,
525
+ path: str,
526
+ module: str,
527
+ content_hash: str,
528
+ route: _Route,
529
+ recv_ident: DomainIdentity,
530
+ full_prefix: str,
531
+ framework: str,
532
+ confidence: ConfidenceClass,
533
+ source: str,
534
+ ) -> None:
535
+ """APIEndpoint + CONTAINS (serviço→rota) + DEFINES (handler→rota)."""
536
+ # mount vem antes do prefixo declarado: app.include_router(r, "/api")
537
+ # + APIRouter(prefix="/v1") + @r.get("/x") → /api/v1/x.
538
+ full_path = common.join_paths(full_prefix, route.path)
539
+ ep = sink.endpoint(
540
+ name=f"{route.method} {route.path}",
541
+ qualified_name=f"{route.method} {full_path}",
542
+ namespace=f"{module}#{route.receiver}",
543
+ full_path=full_path,
544
+ path=path,
545
+ span=route.span,
546
+ content_hash=content_hash,
547
+ attributes={
548
+ "method": route.method,
549
+ "path": route.path,
550
+ "full_path": full_path,
551
+ "framework": framework,
552
+ "handler": route.handler,
553
+ "source": source,
554
+ },
555
+ evidence=(sink.evi(path, content_hash, route.span),)
556
+ if route.span else (),
557
+ )
558
+ sink.bump("api_endpoints")
559
+ sink.relation(
560
+ relation_type=EdgeKind.CONTAINS,
561
+ path=path,
562
+ from_identity=recv_ident,
563
+ to_identity=ep,
564
+ resolution=sink.resolution(
565
+ ResolutionStatus.RESOLVED, confidence, target=ep,
566
+ ),
567
+ span=route.span,
568
+ evidence=(sink.evi(path, content_hash, route.span),)
569
+ if route.span else (),
570
+ )
571
+ sink.relation(
572
+ relation_type=EdgeKind.DEFINES,
573
+ path=path,
574
+ from_symbol=route.handler,
575
+ to_identity=ep,
576
+ resolution=sink.resolution(
577
+ ResolutionStatus.RESOLVED, confidence, target=ep,
578
+ ),
579
+ span=route.span,
580
+ evidence=(sink.evi(path, content_hash, route.span),)
581
+ if route.span else (),
582
+ )