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.
Files changed (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. 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,5 @@
1
+ """JavaScript/TypeScript parser."""
2
+
3
+ from .parser import JavaScriptParser
4
+
5
+ __all__ = ["JavaScriptParser"]
@@ -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
+ )