apisec-code-bolt 0.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.
- apisec_code_bolt/__init__.py +42 -0
- apisec_code_bolt/__main__.py +11 -0
- apisec_code_bolt/analysis/__init__.py +96 -0
- apisec_code_bolt/analysis/analyzer.py +2309 -0
- apisec_code_bolt/analysis/binding_tracker.py +341 -0
- apisec_code_bolt/analysis/call_graph.py +1197 -0
- apisec_code_bolt/analysis/call_graph_types.py +332 -0
- apisec_code_bolt/analysis/call_resolver.py +988 -0
- apisec_code_bolt/analysis/capability_tagger.py +322 -0
- apisec_code_bolt/analysis/config_scanner.py +197 -0
- apisec_code_bolt/analysis/data_flow.py +1883 -0
- apisec_code_bolt/analysis/dependency_extractor.py +959 -0
- apisec_code_bolt/analysis/flow_analysis.py +1406 -0
- apisec_code_bolt/analysis/hof_catalog.py +61 -0
- apisec_code_bolt/analysis/integration_detector.py +1399 -0
- apisec_code_bolt/analysis/literal_scanner.py +300 -0
- apisec_code_bolt/analysis/path_normalizer.py +55 -0
- apisec_code_bolt/analysis/read_site_detector.py +310 -0
- apisec_code_bolt/analysis/request_patterns.py +162 -0
- apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
- apisec_code_bolt/analysis/sink_evidence.py +333 -0
- apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
- apisec_code_bolt/cli/__init__.py +5 -0
- apisec_code_bolt/cli/exit_codes.py +17 -0
- apisec_code_bolt/cli/main.py +1069 -0
- apisec_code_bolt/cloud/__init__.py +1 -0
- apisec_code_bolt/cloud/apisec_client.py +118 -0
- apisec_code_bolt/cloud/client.py +255 -0
- apisec_code_bolt/core/__init__.py +75 -0
- apisec_code_bolt/core/config.py +528 -0
- apisec_code_bolt/core/credentials.py +65 -0
- apisec_code_bolt/core/discovery.py +433 -0
- apisec_code_bolt/core/log_format.py +115 -0
- apisec_code_bolt/core/manifest.py +1009 -0
- apisec_code_bolt/core/repo.py +280 -0
- apisec_code_bolt/core/state.py +59 -0
- apisec_code_bolt/core/telemetry.py +451 -0
- apisec_code_bolt/core/types.py +587 -0
- apisec_code_bolt/fingerprinting/__init__.py +1 -0
- apisec_code_bolt/frameworks/__init__.py +29 -0
- apisec_code_bolt/frameworks/_jwt_common.py +50 -0
- apisec_code_bolt/frameworks/auth_helpers.py +437 -0
- apisec_code_bolt/frameworks/base.py +608 -0
- apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
- apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
- apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
- apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
- apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
- apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
- apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
- apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
- apisec_code_bolt/frameworks/java/__init__.py +6 -0
- apisec_code_bolt/frameworks/java/_annotations.py +167 -0
- apisec_code_bolt/frameworks/java/_constraints.py +128 -0
- apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
- apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
- apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
- apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
- apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
- apisec_code_bolt/frameworks/js/__init__.py +8 -0
- apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
- apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
- apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
- apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
- apisec_code_bolt/frameworks/python/__init__.py +19 -0
- apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
- apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
- apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
- apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
- apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
- apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
- apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
- apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
- apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
- apisec_code_bolt/parsing/__init__.py +62 -0
- apisec_code_bolt/parsing/base.py +554 -0
- apisec_code_bolt/parsing/csharp/__init__.py +5 -0
- apisec_code_bolt/parsing/csharp/language_services.py +203 -0
- apisec_code_bolt/parsing/csharp/literals.py +72 -0
- apisec_code_bolt/parsing/csharp/parser.py +1158 -0
- apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
- apisec_code_bolt/parsing/js/__init__.py +5 -0
- apisec_code_bolt/parsing/js/language_services.py +118 -0
- apisec_code_bolt/parsing/js/parser.py +622 -0
- apisec_code_bolt/parsing/jvm/__init__.py +7 -0
- apisec_code_bolt/parsing/jvm/language_services.py +270 -0
- apisec_code_bolt/parsing/jvm/parser.py +774 -0
- apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
- apisec_code_bolt/parsing/python/__init__.py +150 -0
- apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
- apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
- apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
- apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
- apisec_code_bolt/parsing/python/expression_utils.py +221 -0
- apisec_code_bolt/parsing/python/extraction_types.py +271 -0
- apisec_code_bolt/parsing/python/language_services.py +487 -0
- apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
- apisec_code_bolt/parsing/python/parser.py +719 -0
- apisec_code_bolt/parsing/python/path_resolver.py +576 -0
- apisec_code_bolt/parsing/python/router_registry.py +806 -0
- apisec_code_bolt/parsing/python/type_resolver.py +730 -0
- apisec_code_bolt/parsing/python/visitors.py +1544 -0
- apisec_code_bolt/parsing/services.py +544 -0
- apisec_code_bolt/query/__init__.py +1 -0
- apisec_code_bolt/query/ast_cache.py +182 -0
- apisec_code_bolt/query/executor.py +283 -0
- apisec_code_bolt/query/handlers.py +832 -0
- apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
- apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
- apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
- apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""
|
|
2
|
+
C# cross-file type resolver with transitive inheritance.
|
|
3
|
+
|
|
4
|
+
Implements the TypeResolver protocol for C# projects.
|
|
5
|
+
|
|
6
|
+
Resolution order for a type name N seen in file F:
|
|
7
|
+
1. Direct qualified-name lookup (``Namespace.ClassName``)
|
|
8
|
+
2. Generic stripping + retry (``List<User>`` → ``User``)
|
|
9
|
+
3. Same-namespace implicit (types in the same namespace as F)
|
|
10
|
+
4. ``using``-namespace scan (``using Foo.Bar;`` → try ``Foo.Bar.N``)
|
|
11
|
+
5. Project-wide simple-name index (unambiguous cross-file fallback)
|
|
12
|
+
|
|
13
|
+
Transitive inheritance:
|
|
14
|
+
``all_ancestors`` is computed with BFS through the symbol table so that
|
|
15
|
+
``is_subclass_of("Foo", "ControllerBase")`` works even when the chain spans
|
|
16
|
+
many files. Results are cached to avoid quadratic re-traversal.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from ..base import ParsedClass, ParsedField
|
|
27
|
+
from ..services import ResolvedField, ResolvedType
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from ..base import ParsedFile
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# C#-specific constants
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
# Built-in and BCL types that are never in the project symbol table.
|
|
40
|
+
_CS_BUILTINS: frozenset[str] = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"string",
|
|
43
|
+
"int",
|
|
44
|
+
"long",
|
|
45
|
+
"double",
|
|
46
|
+
"float",
|
|
47
|
+
"bool",
|
|
48
|
+
"byte",
|
|
49
|
+
"short",
|
|
50
|
+
"char",
|
|
51
|
+
"decimal",
|
|
52
|
+
"object",
|
|
53
|
+
"void",
|
|
54
|
+
"var",
|
|
55
|
+
"dynamic",
|
|
56
|
+
"String",
|
|
57
|
+
"Int32",
|
|
58
|
+
"Int64",
|
|
59
|
+
"Int16",
|
|
60
|
+
"UInt32",
|
|
61
|
+
"UInt64",
|
|
62
|
+
"UInt16",
|
|
63
|
+
"Double",
|
|
64
|
+
"Single",
|
|
65
|
+
"Boolean",
|
|
66
|
+
"Byte",
|
|
67
|
+
"Char",
|
|
68
|
+
"Decimal",
|
|
69
|
+
"Object",
|
|
70
|
+
"Void",
|
|
71
|
+
"Guid",
|
|
72
|
+
"DateTime",
|
|
73
|
+
"DateTimeOffset",
|
|
74
|
+
"TimeSpan",
|
|
75
|
+
"Task",
|
|
76
|
+
"ValueTask",
|
|
77
|
+
"Thread",
|
|
78
|
+
"CancellationToken",
|
|
79
|
+
"IActionResult",
|
|
80
|
+
"ActionResult",
|
|
81
|
+
"IResult",
|
|
82
|
+
"Results",
|
|
83
|
+
"Ok",
|
|
84
|
+
"NotFound",
|
|
85
|
+
"BadRequest",
|
|
86
|
+
"Created",
|
|
87
|
+
"NoContent",
|
|
88
|
+
"List",
|
|
89
|
+
"IList",
|
|
90
|
+
"IEnumerable",
|
|
91
|
+
"ICollection",
|
|
92
|
+
"IReadOnlyList",
|
|
93
|
+
"IReadOnlyCollection",
|
|
94
|
+
"Dictionary",
|
|
95
|
+
"IDictionary",
|
|
96
|
+
"HashSet",
|
|
97
|
+
"ISet",
|
|
98
|
+
"Queue",
|
|
99
|
+
"Stack",
|
|
100
|
+
"Tuple",
|
|
101
|
+
"Nullable",
|
|
102
|
+
"Exception",
|
|
103
|
+
"ArgumentException",
|
|
104
|
+
"InvalidOperationException",
|
|
105
|
+
"NotImplementedException",
|
|
106
|
+
"NullReferenceException",
|
|
107
|
+
"HttpContext",
|
|
108
|
+
"HttpRequest",
|
|
109
|
+
"HttpResponse",
|
|
110
|
+
"ILogger",
|
|
111
|
+
"ILoggerFactory",
|
|
112
|
+
"IConfiguration",
|
|
113
|
+
"IServiceProvider",
|
|
114
|
+
"IServiceCollection",
|
|
115
|
+
"ClaimsPrincipal",
|
|
116
|
+
"Claim",
|
|
117
|
+
"ClaimsIdentity",
|
|
118
|
+
"JsonElement",
|
|
119
|
+
"JsonDocument",
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Single-parameter container types whose inner type is more useful to resolve.
|
|
124
|
+
_SINGLE_PARAM_CONTAINERS: frozenset[str] = frozenset(
|
|
125
|
+
{
|
|
126
|
+
"Task",
|
|
127
|
+
"ValueTask",
|
|
128
|
+
"Nullable",
|
|
129
|
+
"Optional",
|
|
130
|
+
"Lazy",
|
|
131
|
+
"IEnumerable",
|
|
132
|
+
"IList",
|
|
133
|
+
"ICollection",
|
|
134
|
+
"IReadOnlyList",
|
|
135
|
+
"IReadOnlyCollection",
|
|
136
|
+
"ISet",
|
|
137
|
+
"List",
|
|
138
|
+
"HashSet",
|
|
139
|
+
"IActionResult",
|
|
140
|
+
"ActionResult",
|
|
141
|
+
"ResponseEntity", # Spring (shouldn't appear in C# but harmless)
|
|
142
|
+
# Ardalis / Minimal API wrappers
|
|
143
|
+
"WithRequest",
|
|
144
|
+
"WithResponse",
|
|
145
|
+
"WithActionResult",
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Attributes that signal a data model / entity.
|
|
150
|
+
_MODEL_ATTRIBUTES: frozenset[str] = frozenset(
|
|
151
|
+
{
|
|
152
|
+
"Table",
|
|
153
|
+
"Entity",
|
|
154
|
+
"Serializable",
|
|
155
|
+
"DataContract",
|
|
156
|
+
"JsonObject", # Newtonsoft
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Naming conventions for DTO / view-model types.
|
|
161
|
+
_MODEL_SUFFIXES: tuple[str, ...] = (
|
|
162
|
+
"Request",
|
|
163
|
+
"Response",
|
|
164
|
+
"Dto",
|
|
165
|
+
"DTO",
|
|
166
|
+
"ViewModel",
|
|
167
|
+
"Model",
|
|
168
|
+
"Input",
|
|
169
|
+
"Output",
|
|
170
|
+
"Payload",
|
|
171
|
+
"Body",
|
|
172
|
+
"Message",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Base classes that indicate a data-model / value-object hierarchy.
|
|
176
|
+
_MODEL_BASE_NAMES: frozenset[str] = frozenset(
|
|
177
|
+
{
|
|
178
|
+
"BaseEntity",
|
|
179
|
+
"Entity",
|
|
180
|
+
"ValueObject",
|
|
181
|
+
"AggregateRoot",
|
|
182
|
+
"AuditableEntity",
|
|
183
|
+
"DomainEvent",
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Base classes / attributes that indicate a controller (NOT a model).
|
|
188
|
+
_CONTROLLER_BASES: frozenset[str] = frozenset(
|
|
189
|
+
{
|
|
190
|
+
"Controller",
|
|
191
|
+
"ControllerBase",
|
|
192
|
+
"ApiController",
|
|
193
|
+
"EndpointBaseAsync",
|
|
194
|
+
"PageModel", # Razor Pages
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Regex to strip generic type arguments: ``IEndpoint<T1, T2>`` → ``IEndpoint``
|
|
199
|
+
_GENERIC_RE = re.compile(r"<[^<>]*(?:<[^<>]*>[^<>]*)?>") # strips one nesting level
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _strip_generics(name: str) -> str:
|
|
203
|
+
"""Remove generic type arguments from a C# type name.
|
|
204
|
+
|
|
205
|
+
``List<User>`` → ``List``
|
|
206
|
+
``Task<ActionResult<T>>`` → ``Task``
|
|
207
|
+
``IDictionary<K,V>`` → ``IDictionary``
|
|
208
|
+
"""
|
|
209
|
+
return _GENERIC_RE.sub("", name).rstrip("<>").strip()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _unwrap_container(name: str) -> str | None:
|
|
213
|
+
"""For single-param containers return the inner type; otherwise None.
|
|
214
|
+
|
|
215
|
+
``Task<AuthenticateResponse>`` → ``AuthenticateResponse``
|
|
216
|
+
``List<UserDto>`` → ``UserDto``
|
|
217
|
+
``IDictionary<K,V>`` → ``None`` (multi-param, ambiguous)
|
|
218
|
+
"""
|
|
219
|
+
m = re.match(r"^(\w+)<([^,>]+)>$", name.strip())
|
|
220
|
+
if not m:
|
|
221
|
+
return None
|
|
222
|
+
outer = m.group(1)
|
|
223
|
+
inner = m.group(2).strip()
|
|
224
|
+
if outer in _SINGLE_PARAM_CONTAINERS:
|
|
225
|
+
return inner
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# CSharpTypeResolver
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class CSharpTypeResolver:
|
|
235
|
+
"""
|
|
236
|
+
Cross-file type resolver for C# projects.
|
|
237
|
+
|
|
238
|
+
Builds an in-memory symbol table from all successfully parsed .cs files
|
|
239
|
+
and resolves types using C# namespace semantics.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __init__(self, parsed_files: list[ParsedFile]) -> None:
|
|
243
|
+
# Qualified-name → ParsedClass (primary symbol table)
|
|
244
|
+
self._symbol_table: dict[str, ParsedClass] = {}
|
|
245
|
+
# Simple name → list[qualified_name] (for unambiguous fallback)
|
|
246
|
+
self._simple_name_index: dict[str, list[str]] = {}
|
|
247
|
+
# file → own namespace (for same-namespace resolution)
|
|
248
|
+
self._file_namespace: dict[Path, str] = {}
|
|
249
|
+
# file → [imported namespaces] (from `using` directives)
|
|
250
|
+
self._file_usings: dict[Path, list[str]] = {}
|
|
251
|
+
# namespace → [qualified_names] (namespace membership index)
|
|
252
|
+
self._namespace_index: dict[str, list[str]] = {}
|
|
253
|
+
# Ancestor cache: qualified_name → [ancestor_qualified_names]
|
|
254
|
+
self._ancestor_cache: dict[str, list[str]] = {}
|
|
255
|
+
|
|
256
|
+
self._build(parsed_files)
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
# Build
|
|
260
|
+
# ------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def _build(self, parsed_files: list[ParsedFile]) -> None:
|
|
263
|
+
"""Two-phase build: symbol table first, then import/using maps."""
|
|
264
|
+
# Phase 1 — symbol table + namespace index
|
|
265
|
+
for pf in parsed_files:
|
|
266
|
+
if not pf.success:
|
|
267
|
+
continue
|
|
268
|
+
for cls in pf.classes:
|
|
269
|
+
qname = cls.qualified_name.full
|
|
270
|
+
if not qname:
|
|
271
|
+
continue
|
|
272
|
+
self._symbol_table[qname] = cls
|
|
273
|
+
self._simple_name_index.setdefault(cls.name, []).append(qname)
|
|
274
|
+
|
|
275
|
+
# Namespace membership
|
|
276
|
+
if "." in qname:
|
|
277
|
+
ns = qname.rsplit(".", 1)[0]
|
|
278
|
+
self._namespace_index.setdefault(ns, []).append(qname)
|
|
279
|
+
|
|
280
|
+
# Phase 2 — per-file using + namespace
|
|
281
|
+
for pf in parsed_files:
|
|
282
|
+
if not pf.success:
|
|
283
|
+
continue
|
|
284
|
+
usings: list[str] = []
|
|
285
|
+
ns = ""
|
|
286
|
+
for imp in pf.imports:
|
|
287
|
+
# C# `using Foo.Bar;` → module="Foo.Bar", names=[]
|
|
288
|
+
if imp.module and not imp.names:
|
|
289
|
+
usings.append(imp.module)
|
|
290
|
+
# Infer own namespace from first class
|
|
291
|
+
for cls in pf.classes:
|
|
292
|
+
qn = cls.qualified_name.full
|
|
293
|
+
if "." in qn:
|
|
294
|
+
ns = qn.rsplit(".", 1)[0]
|
|
295
|
+
break
|
|
296
|
+
self._file_usings[pf.path] = usings
|
|
297
|
+
self._file_namespace[pf.path] = ns
|
|
298
|
+
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
# TypeResolver protocol
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def resolve_type(
|
|
304
|
+
self,
|
|
305
|
+
name: str,
|
|
306
|
+
in_file: Path | None = None,
|
|
307
|
+
) -> ResolvedType | None:
|
|
308
|
+
"""Resolve a C# type name to its definition."""
|
|
309
|
+
if not name or name in _CS_BUILTINS:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
# Strip array and nullable suffixes
|
|
313
|
+
name = name.rstrip("[]?").strip()
|
|
314
|
+
if not name:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
# 1. Direct qualified-name lookup
|
|
318
|
+
if name in self._symbol_table:
|
|
319
|
+
return self._to_resolved_type(self._symbol_table[name])
|
|
320
|
+
|
|
321
|
+
# 2. Generic stripping + retry
|
|
322
|
+
stripped = _strip_generics(name)
|
|
323
|
+
if stripped != name and stripped:
|
|
324
|
+
result = self.resolve_type(stripped, in_file)
|
|
325
|
+
if result:
|
|
326
|
+
return result
|
|
327
|
+
# Also try unwrapping container to inner type
|
|
328
|
+
inner = _unwrap_container(name)
|
|
329
|
+
if inner:
|
|
330
|
+
result = self.resolve_type(inner, in_file)
|
|
331
|
+
if result:
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
# 3. Same-namespace implicit visibility
|
|
335
|
+
if in_file is not None:
|
|
336
|
+
own_ns = self._file_namespace.get(in_file, "")
|
|
337
|
+
if own_ns:
|
|
338
|
+
candidate = f"{own_ns}.{name}"
|
|
339
|
+
if candidate in self._symbol_table:
|
|
340
|
+
return self._to_resolved_type(self._symbol_table[candidate])
|
|
341
|
+
|
|
342
|
+
# 4. using-namespace scan
|
|
343
|
+
for ns in self._file_usings.get(in_file, []):
|
|
344
|
+
candidate = f"{ns}.{name}"
|
|
345
|
+
if candidate in self._symbol_table:
|
|
346
|
+
return self._to_resolved_type(self._symbol_table[candidate])
|
|
347
|
+
|
|
348
|
+
# 5. Project-wide simple-name index (unambiguous)
|
|
349
|
+
candidates = self._simple_name_index.get(name, [])
|
|
350
|
+
if len(candidates) == 1:
|
|
351
|
+
return self._to_resolved_type(self._symbol_table[candidates[0]])
|
|
352
|
+
if len(candidates) > 1:
|
|
353
|
+
logger.debug("Ambiguous C# type '%s': %s — using first", name, candidates)
|
|
354
|
+
return self._to_resolved_type(self._symbol_table[candidates[0]])
|
|
355
|
+
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
def is_model_type(
|
|
359
|
+
self,
|
|
360
|
+
name: str,
|
|
361
|
+
in_file: Path | None = None,
|
|
362
|
+
) -> bool:
|
|
363
|
+
resolved = self.resolve_type(name, in_file)
|
|
364
|
+
return resolved.is_model if resolved else False
|
|
365
|
+
|
|
366
|
+
def get_model_fields(
|
|
367
|
+
self,
|
|
368
|
+
model_name: str,
|
|
369
|
+
in_file: Path | None = None,
|
|
370
|
+
include_inherited: bool = True,
|
|
371
|
+
) -> list[ResolvedField]:
|
|
372
|
+
resolved = self.resolve_type(model_name, in_file)
|
|
373
|
+
if resolved is None:
|
|
374
|
+
return []
|
|
375
|
+
|
|
376
|
+
fields: list[ResolvedField] = list(resolved.fields)
|
|
377
|
+
|
|
378
|
+
if include_inherited:
|
|
379
|
+
seen_names = {f.name for f in fields}
|
|
380
|
+
for ancestor_qname in resolved.all_ancestors:
|
|
381
|
+
ancestor_cls = self._symbol_table.get(ancestor_qname)
|
|
382
|
+
if ancestor_cls is None:
|
|
383
|
+
# Try simple name
|
|
384
|
+
candidates = self._simple_name_index.get(ancestor_qname, [])
|
|
385
|
+
if candidates:
|
|
386
|
+
ancestor_cls = self._symbol_table.get(candidates[0])
|
|
387
|
+
if ancestor_cls is None:
|
|
388
|
+
continue
|
|
389
|
+
for f in ancestor_cls.fields:
|
|
390
|
+
if getattr(f, "access_modifier", "public") != "public":
|
|
391
|
+
continue
|
|
392
|
+
if f.name not in seen_names:
|
|
393
|
+
seen_names.add(f.name)
|
|
394
|
+
fields.append(
|
|
395
|
+
ResolvedField(
|
|
396
|
+
name=f.name,
|
|
397
|
+
type_annotation=f.type_annotation,
|
|
398
|
+
default_value=f.default_value,
|
|
399
|
+
is_required=f.default_value is None,
|
|
400
|
+
constraints=self._constraints_from_decorators(f),
|
|
401
|
+
defined_in_class=ancestor_cls.qualified_name.full,
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
return fields
|
|
406
|
+
|
|
407
|
+
def is_subclass_of(
|
|
408
|
+
self,
|
|
409
|
+
class_name: str,
|
|
410
|
+
base_name: str,
|
|
411
|
+
in_file: Path | None = None,
|
|
412
|
+
) -> bool:
|
|
413
|
+
resolved = self.resolve_type(class_name, in_file)
|
|
414
|
+
if resolved is None:
|
|
415
|
+
return False
|
|
416
|
+
# Check direct bases and full ancestor list
|
|
417
|
+
if base_name in resolved.base_classes:
|
|
418
|
+
return True
|
|
419
|
+
return base_name in resolved.all_ancestors or any(
|
|
420
|
+
a.endswith(f".{base_name}") or a == base_name for a in resolved.all_ancestors
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def get_all_models(self) -> dict[str, ResolvedType]:
|
|
424
|
+
result: dict[str, ResolvedType] = {}
|
|
425
|
+
for qname in self._symbol_table:
|
|
426
|
+
rt = self._to_resolved_type(self._symbol_table[qname])
|
|
427
|
+
if rt.is_model:
|
|
428
|
+
result[qname] = rt
|
|
429
|
+
return result
|
|
430
|
+
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
# Helpers
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
def _to_resolved_type(self, cls: ParsedClass) -> ResolvedType:
|
|
436
|
+
"""Convert a ParsedClass to a ResolvedType with transitive ancestors."""
|
|
437
|
+
qname = cls.qualified_name.full
|
|
438
|
+
attr_names = {d.name for d in (cls.decorators or [])}
|
|
439
|
+
|
|
440
|
+
all_ancestors = self._collect_ancestors(qname, list(cls.base_classes))
|
|
441
|
+
|
|
442
|
+
# Only public fields count for model detection / are part of the API surface
|
|
443
|
+
public_fields = [
|
|
444
|
+
f for f in cls.fields if f.name and getattr(f, "access_modifier", "public") == "public"
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
# Model detection: naming convention, attribute, base class, or field count
|
|
448
|
+
is_model = (
|
|
449
|
+
self._is_model_by_name(cls.name)
|
|
450
|
+
or bool(attr_names & _MODEL_ATTRIBUTES)
|
|
451
|
+
or any(
|
|
452
|
+
a in _MODEL_BASE_NAMES or any(a.endswith(f".{m}") for m in _MODEL_BASE_NAMES)
|
|
453
|
+
for a in [cls.name] + list(cls.base_classes) + all_ancestors
|
|
454
|
+
)
|
|
455
|
+
or (len(public_fields) >= 2 and not self._is_controller(cls, all_ancestors))
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
resolved_fields: list[ResolvedField] = [
|
|
459
|
+
ResolvedField(
|
|
460
|
+
name=f.name,
|
|
461
|
+
type_annotation=f.type_annotation,
|
|
462
|
+
default_value=f.default_value,
|
|
463
|
+
is_required=f.default_value is None,
|
|
464
|
+
constraints=self._constraints_from_decorators(f),
|
|
465
|
+
defined_in_class=qname,
|
|
466
|
+
)
|
|
467
|
+
for f in public_fields
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
return ResolvedType(
|
|
471
|
+
name=cls.name,
|
|
472
|
+
qualified_name=qname,
|
|
473
|
+
file_path=Path(cls.location.file) if cls.location and cls.location.file else None,
|
|
474
|
+
is_class=True,
|
|
475
|
+
is_model=is_model,
|
|
476
|
+
is_enum=getattr(cls, "is_enum", False),
|
|
477
|
+
base_classes=list(cls.base_classes),
|
|
478
|
+
all_ancestors=all_ancestors,
|
|
479
|
+
fields=resolved_fields,
|
|
480
|
+
definition=cls,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _constraints_from_decorators(f: ParsedField) -> dict:
|
|
485
|
+
"""Map C# data-annotation decorators to a constraints dict."""
|
|
486
|
+
if not f.decorators:
|
|
487
|
+
return {}
|
|
488
|
+
constraints: dict = {}
|
|
489
|
+
for dec in f.decorators:
|
|
490
|
+
name = dec.name
|
|
491
|
+
args = dec.positional_args or []
|
|
492
|
+
kw = dec.arguments or {}
|
|
493
|
+
if name == "Required":
|
|
494
|
+
constraints["required"] = True
|
|
495
|
+
elif name == "Range" and len(args) >= 2:
|
|
496
|
+
constraints["minimum"] = args[0]
|
|
497
|
+
constraints["maximum"] = args[1]
|
|
498
|
+
elif name == "MinLength" and args:
|
|
499
|
+
constraints["minLength"] = args[0]
|
|
500
|
+
elif name == "MaxLength" and args:
|
|
501
|
+
constraints["maxLength"] = args[0]
|
|
502
|
+
elif name == "StringLength" and args:
|
|
503
|
+
constraints["maxLength"] = args[0]
|
|
504
|
+
if "MinimumLength" in kw:
|
|
505
|
+
constraints["minLength"] = kw["MinimumLength"]
|
|
506
|
+
elif name == "RegularExpression" and args:
|
|
507
|
+
constraints["pattern"] = args[0]
|
|
508
|
+
elif name == "EmailAddress":
|
|
509
|
+
constraints["format"] = "email"
|
|
510
|
+
elif name == "Url":
|
|
511
|
+
constraints["format"] = "uri"
|
|
512
|
+
return constraints
|
|
513
|
+
|
|
514
|
+
@staticmethod
|
|
515
|
+
def _is_model_by_name(name: str) -> bool:
|
|
516
|
+
return any(name.endswith(sfx) for sfx in _MODEL_SUFFIXES)
|
|
517
|
+
|
|
518
|
+
@staticmethod
|
|
519
|
+
def _is_controller(cls: ParsedClass, all_ancestors: list[str]) -> bool:
|
|
520
|
+
"""Return True if this class is a controller / handler, not a DTO."""
|
|
521
|
+
if cls.name in _CONTROLLER_BASES:
|
|
522
|
+
return True
|
|
523
|
+
for base in list(cls.base_classes) + all_ancestors:
|
|
524
|
+
simple = base.rsplit(".", 1)[-1]
|
|
525
|
+
if simple in _CONTROLLER_BASES or base in _CONTROLLER_BASES:
|
|
526
|
+
return True
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
def _collect_ancestors(self, qname: str, direct_bases: list[str]) -> list[str]:
|
|
530
|
+
"""BFS through the symbol table to collect all transitive ancestors.
|
|
531
|
+
|
|
532
|
+
Results are cached by the starting class's qualified name.
|
|
533
|
+
"""
|
|
534
|
+
if qname in self._ancestor_cache:
|
|
535
|
+
return list(self._ancestor_cache[qname])
|
|
536
|
+
|
|
537
|
+
ancestors: list[str] = []
|
|
538
|
+
seen: set[str] = {qname}
|
|
539
|
+
queue: list[str] = list(direct_bases)
|
|
540
|
+
|
|
541
|
+
while queue:
|
|
542
|
+
base = queue.pop(0)
|
|
543
|
+
if base in seen:
|
|
544
|
+
continue
|
|
545
|
+
seen.add(base)
|
|
546
|
+
ancestors.append(base)
|
|
547
|
+
|
|
548
|
+
# Resolve the base to its ParsedClass to continue the chain
|
|
549
|
+
base_cls = self._symbol_table.get(base)
|
|
550
|
+
if base_cls is None:
|
|
551
|
+
# Try simple-name index
|
|
552
|
+
candidates = self._simple_name_index.get(base, [])
|
|
553
|
+
if candidates:
|
|
554
|
+
base_cls = self._symbol_table.get(candidates[0])
|
|
555
|
+
if base_cls:
|
|
556
|
+
# Replace simple name with qualified name in ancestors;
|
|
557
|
+
# keep the original simple name in `seen` too so that
|
|
558
|
+
# cyclic references via simple names don't re-enqueue.
|
|
559
|
+
ancestors[-1] = candidates[0]
|
|
560
|
+
seen.add(candidates[0]) # keep `base` (simple) in seen
|
|
561
|
+
|
|
562
|
+
if base_cls is not None:
|
|
563
|
+
for grandparent in base_cls.base_classes:
|
|
564
|
+
if grandparent not in seen:
|
|
565
|
+
queue.append(grandparent)
|
|
566
|
+
|
|
567
|
+
self._ancestor_cache[qname] = ancestors
|
|
568
|
+
return ancestors
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JavaScript/TypeScript language services.
|
|
3
|
+
|
|
4
|
+
No-op implementations of the TypeResolver, ConstantResolver, and PathResolver
|
|
5
|
+
protocols. JavaScript/TypeScript does not yet have cross-file type resolution;
|
|
6
|
+
these stubs satisfy the LanguageServices contract so the framework plugins
|
|
7
|
+
(Express, NestJS) can receive an AnalysisContext.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from ..services import (
|
|
16
|
+
AnalysisContext,
|
|
17
|
+
LanguageServices,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..base import ParsedFile
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _NoOpTypeResolver:
|
|
25
|
+
"""No-op TypeResolver for JavaScript — type resolution not yet implemented."""
|
|
26
|
+
|
|
27
|
+
def resolve_type(self, *a: Any, **kw: Any) -> None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def is_model_type(self, *a: Any, **kw: Any) -> bool:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def get_model_fields(self, *a: Any, **kw: Any) -> list:
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
def is_subclass_of(self, *a: Any, **kw: Any) -> bool:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
def get_all_models(self) -> dict:
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NoOpConstantResolver:
|
|
44
|
+
"""No-op ConstantResolver for JavaScript."""
|
|
45
|
+
|
|
46
|
+
def resolve(self, *a: Any, **kw: Any) -> None:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def resolve_value(self, *a: Any, **kw: Any) -> None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def get_all_constants(self) -> dict:
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _NoOpPathResolver:
|
|
57
|
+
"""No-op PathResolver for JavaScript."""
|
|
58
|
+
|
|
59
|
+
def resolve(self, *a: Any, **kw: Any) -> None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def get_all_constants(self) -> dict:
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class JavaScriptLanguageServices(LanguageServices):
|
|
67
|
+
"""
|
|
68
|
+
LanguageServices factory for JavaScript/TypeScript.
|
|
69
|
+
|
|
70
|
+
Returns no-op resolvers. The framework plugins (Express, NestJS)
|
|
71
|
+
do not rely on cross-file type resolution — they work directly
|
|
72
|
+
from parsed call sites and decorator annotations.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def build_type_resolver(
|
|
76
|
+
self,
|
|
77
|
+
parsed_files: list[ParsedFile],
|
|
78
|
+
project_root: Path | None = None,
|
|
79
|
+
) -> _NoOpTypeResolver:
|
|
80
|
+
return _NoOpTypeResolver()
|
|
81
|
+
|
|
82
|
+
def build_constant_resolver(
|
|
83
|
+
self,
|
|
84
|
+
parsed_files: list[ParsedFile],
|
|
85
|
+
project_root: Path | None = None,
|
|
86
|
+
) -> _NoOpConstantResolver:
|
|
87
|
+
return _NoOpConstantResolver()
|
|
88
|
+
|
|
89
|
+
def build_path_resolver(
|
|
90
|
+
self,
|
|
91
|
+
parsed_files: list[ParsedFile],
|
|
92
|
+
project_root: Path | None = None,
|
|
93
|
+
) -> _NoOpPathResolver:
|
|
94
|
+
return _NoOpPathResolver()
|
|
95
|
+
|
|
96
|
+
def build_framework_services(
|
|
97
|
+
self,
|
|
98
|
+
framework: str,
|
|
99
|
+
parsed_files: list[ParsedFile],
|
|
100
|
+
project_root: Path | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
def build_context(
|
|
105
|
+
self,
|
|
106
|
+
parsed_files: list[ParsedFile],
|
|
107
|
+
project_root: Path | None = None,
|
|
108
|
+
framework: str | None = None,
|
|
109
|
+
) -> AnalysisContext:
|
|
110
|
+
return AnalysisContext(
|
|
111
|
+
type_resolver=self.build_type_resolver(parsed_files, project_root),
|
|
112
|
+
constant_resolver=self.build_constant_resolver(parsed_files, project_root),
|
|
113
|
+
path_resolver=self.build_path_resolver(parsed_files, project_root),
|
|
114
|
+
router_registry=None,
|
|
115
|
+
project_root=project_root,
|
|
116
|
+
all_parsed_files=list(parsed_files),
|
|
117
|
+
language_services={},
|
|
118
|
+
)
|