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,1239 @@
1
+ """
2
+ WCF (Windows Communication Foundation) framework plugin.
3
+
4
+ Detects service contracts and emits one route per operation:
5
+
6
+ [ServiceContract(Name = "Orders")]
7
+ public interface IOrderService {
8
+ [OperationContract]
9
+ Order GetOrder(string id);
10
+
11
+ [OperationContract(IsOneWay = true)]
12
+ void CancelOrder(string id);
13
+
14
+ [OperationContract]
15
+ [WebGet(UriTemplate = "/orders/{id}")]
16
+ Order GetOrderRest(string id);
17
+
18
+ [OperationContract]
19
+ [WebInvoke(Method = "POST", UriTemplate = "/orders")]
20
+ Order CreateOrderRest(CreateOrderRequest req);
21
+ }
22
+
23
+ Per-operation route emission:
24
+
25
+ | Decorator(s) | method | path | kind |
26
+ |---------------------------------|------------|-----------------------------------|-------------|
27
+ | `[OperationContract]` only | `POST` | `/{ServiceName}/{Operation}` | `wcf_soap` |
28
+ | `[OperationContract(IsOneWay)]` | `POST` | `/{ServiceName}/{Operation}` | `wcf_oneway`|
29
+ | `[WebGet]` | `GET` | `UriTemplate` arg, or `/Operation`| `http` |
30
+ | `[WebInvoke]` | `Method`* | `UriTemplate` arg, or `/Operation`| `http` |
31
+
32
+ * `[WebInvoke]` defaults to POST when its `Method` argument is omitted.
33
+
34
+ The plugin walks **interfaces** (where `[ServiceContract]` lives) rather than
35
+ implementation classes. The interface IS the contract — every operation
36
+ defined on it is callable surface, whether or not we can also see the
37
+ implementing class in the same parse.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import contextlib
43
+ import re
44
+ import xml.etree.ElementTree as ET
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+ from typing import ClassVar
48
+
49
+ from ...core.types import (
50
+ AuthDependencyType,
51
+ AuthSchemeType,
52
+ CodeLocation,
53
+ Confidence,
54
+ Framework,
55
+ HttpMethod,
56
+ Language,
57
+ ParameterLocation,
58
+ QualifiedName,
59
+ )
60
+ from ...parsing.base import ParsedClass, ParsedFile, ParsedFunction
61
+ from ...parsing.csharp.literals import parse_csharp_string_literal
62
+ from ...parsing.services import AnalysisContext
63
+ from ..base import (
64
+ BaseFrameworkPlugin,
65
+ ExtractedAuthDependency,
66
+ ExtractedAuthScheme,
67
+ ExtractedBody,
68
+ ExtractedDependency,
69
+ ExtractedMiddleware,
70
+ ExtractedParameter,
71
+ ExtractedResponse,
72
+ ExtractedRoute,
73
+ FrameworkPluginRegistry,
74
+ )
75
+
76
+ # =============================================================================
77
+ # Constants
78
+ # =============================================================================
79
+
80
+ _WCF_IMPORTS: frozenset[str] = frozenset(
81
+ {
82
+ "System.ServiceModel",
83
+ "System.ServiceModel.Web",
84
+ "System.ServiceModel.Channels",
85
+ "System.ServiceModel.Description",
86
+ "CoreWCF",
87
+ }
88
+ )
89
+
90
+ _SERVICE_CONTRACT_ATTR = "ServiceContract"
91
+ _OPERATION_CONTRACT_ATTR = "OperationContract"
92
+
93
+ # REST-style WCF attributes (WebHttpBinding).
94
+ _WEB_GET_ATTR = "WebGet"
95
+ _WEB_INVOKE_ATTR = "WebInvoke"
96
+
97
+ # Security + fault attributes.
98
+ _PRINCIPAL_PERMISSION_ATTR = "PrincipalPermission"
99
+ _FAULT_CONTRACT_ATTR = "FaultContract"
100
+
101
+ # `typeof(X)` argument syntax from C# attribute args — used by [FaultContract].
102
+ # The C# parser typically surfaces the expression as the string `"typeof(X)"`.
103
+ _TYPEOF_RE = re.compile(r"typeof\s*\(\s*([A-Za-z_][\w\.]*)\s*\)")
104
+
105
+ # Method-string arg on [WebInvoke(Method = "POST", ...)] → HttpMethod.
106
+ # [WebInvoke] defaults to POST when Method is omitted (per WCF docs).
107
+ _WEB_INVOKE_METHOD_MAP: dict[str, HttpMethod] = {
108
+ "GET": HttpMethod.GET,
109
+ "POST": HttpMethod.POST,
110
+ "PUT": HttpMethod.PUT,
111
+ "DELETE": HttpMethod.DELETE,
112
+ "PATCH": HttpMethod.PATCH,
113
+ "HEAD": HttpMethod.HEAD,
114
+ "OPTIONS": HttpMethod.OPTIONS,
115
+ }
116
+
117
+ # UriTemplate path placeholders: `{name}` for segments, `{*name}` for catch-all.
118
+ # WCF UriTemplate doesn't support route constraints like ASP.NET — simpler regex.
119
+ _URI_TEMPLATE_PARAM_RE = re.compile(r"\{\*?([A-Za-z_][\w]*)\}")
120
+
121
+
122
+ # =============================================================================
123
+ # Config-derived endpoint metadata
124
+ # =============================================================================
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class _WcfEndpoint:
129
+ """A single endpoint resolved from Web.config / App.config or a
130
+ CoreWCF `Startup.cs` / `Program.cs` registration.
131
+
132
+ `project_root` is the directory of the enclosing `.csproj` (or None
133
+ when no enclosing project is found — e.g. unit-test setups without
134
+ project files). Endpoints are matched only against contracts in
135
+ the same project, which prevents simple-name collisions across
136
+ unrelated `IFoo` interfaces in different projects."""
137
+
138
+ address: str
139
+ binding: str
140
+ base_address: str | None = None
141
+ project_root: str | None = None
142
+
143
+
144
+ # Methods inherited from System.Object — never WCF operations.
145
+ _OBJECT_METHOD_NAMES: frozenset[str] = frozenset(
146
+ {
147
+ "Equals",
148
+ "GetHashCode",
149
+ "ToString",
150
+ "Finalize",
151
+ }
152
+ )
153
+
154
+ # Parameter types that are part of the WCF plumbing layer, not the request body.
155
+ _PLUMBING_PARAM_TYPES: frozenset[str] = frozenset(
156
+ {
157
+ "Message", # System.ServiceModel.Channels.Message
158
+ "MessageHeaders",
159
+ "OperationContext",
160
+ "AsyncCallback", # Begin/End pattern callback
161
+ "Object", # AsyncState in Begin pattern
162
+ "IAsyncResult", # End* parameter
163
+ "CancellationToken",
164
+ }
165
+ )
166
+
167
+
168
+ # =============================================================================
169
+ # WcfPlugin
170
+ # =============================================================================
171
+
172
+
173
+ class WcfPlugin(BaseFrameworkPlugin):
174
+ """Framework plugin for WCF service contracts (SOAP)."""
175
+
176
+ FRAMEWORK: ClassVar[Framework] = Framework.WCF
177
+ LANGUAGE: ClassVar[Language] = Language.CSHARP
178
+ DETECTION_IMPORTS: ClassVar[frozenset[str]] = _WCF_IMPORTS
179
+
180
+ def __init__(self) -> None:
181
+ # {id(AnalysisContext): {contract_fqn: [_WcfEndpoint, ...]}}. Built
182
+ # lazily on the first extract_routes call per project so the cross-
183
+ # file config scan runs once per analysis run.
184
+ # Keyed by the resolved project_root path string — stable across
185
+ # `AnalysisContext` instances pointing at the same project, and
186
+ # immune to Python memory-address recycling (which `id(context)`
187
+ # is not). Same project root → same scan result → same cache.
188
+ self._endpoint_cache: dict[str, dict[str, list[_WcfEndpoint]]] = {}
189
+
190
+ # -------------------------------------------------------------------------
191
+ # Detection
192
+ # -------------------------------------------------------------------------
193
+
194
+ def detect(self, parsed_file: ParsedFile) -> bool:
195
+ # Primary signal: System.ServiceModel / CoreWCF imports.
196
+ for imp in parsed_file.imports:
197
+ module = imp.module or ""
198
+ if module.startswith("System.ServiceModel") or module.startswith("CoreWCF"):
199
+ return True
200
+ # Fallback: a class carries [ServiceContract] even without the import
201
+ # (rare — usually means global usings or fully-qualified attribute).
202
+ return any(self._is_service_contract(cls) for cls in parsed_file.classes)
203
+
204
+ # -------------------------------------------------------------------------
205
+ # Route extraction
206
+ # -------------------------------------------------------------------------
207
+
208
+ def extract_routes(
209
+ self,
210
+ parsed_file: ParsedFile,
211
+ context: AnalysisContext | None = None,
212
+ ) -> list[ExtractedRoute]:
213
+ routes: list[ExtractedRoute] = []
214
+ endpoint_map = self._load_endpoint_map(context)
215
+ # The project that owns this file scopes which programmatic
216
+ # endpoints apply — distinct projects can declare same-name
217
+ # interfaces (e.g. multiple `IEchoService`s across scenario
218
+ # projects in corewcf-samples).
219
+ file_project_root = _find_enclosing_csproj(
220
+ Path(parsed_file.path) if parsed_file.path else None
221
+ )
222
+
223
+ for cls in parsed_file.classes:
224
+ if not self._is_service_contract(cls):
225
+ continue
226
+
227
+ service_name = self._extract_service_name(cls)
228
+ # When config defines endpoints for this contract, emit one route
229
+ # per (operation, endpoint) pair — the same contract can be
230
+ # exposed via multiple bindings with different addresses /
231
+ # security models.
232
+ scoped = self._endpoints_for_contract(cls, endpoint_map, file_project_root)
233
+ if scoped:
234
+ endpoints = scoped
235
+ elif self._contract_is_registered_elsewhere(cls, endpoint_map):
236
+ # This contract IS registered — but not in this file's
237
+ # project. That's the client-side declaration pattern
238
+ # (e.g. `*_client/IEchoService.cs`): the interface mirrors
239
+ # the server contract for proxy generation, not for hosting.
240
+ # Skip — no routes from a client-side contract file.
241
+ continue
242
+ else:
243
+ # Standalone contract — no Web.config / Startup.cs
244
+ # registration anywhere. Emit a single placeholder route
245
+ # per operation (PR A/B behaviour).
246
+ endpoints = [None]
247
+
248
+ for method in cls.methods:
249
+ if not self._is_operation_contract(method):
250
+ continue
251
+ if method.name in _OBJECT_METHOD_NAMES:
252
+ continue
253
+ if self._is_end_async_helper(method, cls):
254
+ continue
255
+ for endpoint in endpoints:
256
+ route = self._extract_operation_route(
257
+ method,
258
+ cls,
259
+ service_name,
260
+ endpoint=endpoint,
261
+ )
262
+ if route is not None:
263
+ routes.append(route)
264
+
265
+ return routes
266
+
267
+ def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
268
+ """
269
+ Detect WCF auth schemes from:
270
+ 1. [PrincipalPermission] on operation methods → declarative role auth
271
+ 2. wsHttpBinding call sites → CUSTOM (Windows/Kerberos/Message security)
272
+ 3. basicHttpBinding → CUSTOM (defaults to no auth; explicit transport
273
+ security config is needed for actual HTTP Basic — emit CUSTOM only)
274
+ 4. TransportSecurity / MessageSecurity call sites → CUSTOM
275
+ """
276
+ schemes: list[ExtractedAuthScheme] = []
277
+ seen: set[str] = set()
278
+
279
+ def _add(name: str, scheme_type: AuthSchemeType, line: int = 1) -> None:
280
+ if name not in seen:
281
+ seen.add(name)
282
+ schemes.append(
283
+ ExtractedAuthScheme(
284
+ scheme_type=scheme_type,
285
+ name=name,
286
+ location=CodeLocation(file=parsed_file.path, line=line),
287
+ confidence=Confidence.HIGH,
288
+ )
289
+ )
290
+
291
+ # [PrincipalPermission] decorator on any operation method → role auth active.
292
+ # Check decorators directly rather than import names to avoid false positives
293
+ # from System.Security.Claims or Microsoft.IdentityModel.Tokens imports.
294
+ for cls in parsed_file.classes:
295
+ for method in cls.methods:
296
+ for mdec in method.decorators:
297
+ if mdec.name == "PrincipalPermission":
298
+ _add(
299
+ "WcfRoleAuth",
300
+ AuthSchemeType.CUSTOM,
301
+ method.location.line if method.location else 1,
302
+ )
303
+
304
+ # Binding-based auth from call sites (programmatic host/channel configuration)
305
+ for call in parsed_file.call_sites:
306
+ name_lower = call.callee_name.lower()
307
+ line = call.location.line if call.location else 1
308
+ if "wshttpbinding" in name_lower:
309
+ _add("WsHttpBinding", AuthSchemeType.CUSTOM, line)
310
+ elif "basichttpbinding" in name_lower:
311
+ # basicHttpBinding defaults to SecurityMode.None; emit CUSTOM since
312
+ # actual transport security requires additional configuration.
313
+ _add("BasicHttpBinding", AuthSchemeType.CUSTOM, line)
314
+ elif "transportsecurity" in name_lower or "messagesecurity" in name_lower:
315
+ _add("WcfSecurity", AuthSchemeType.CUSTOM, line)
316
+
317
+ return schemes
318
+
319
+ def extract_auth_dependencies(
320
+ self, parsed_file: ParsedFile, known_scheme_names: set[str] | None = None, **kwargs
321
+ ) -> list[ExtractedAuthDependency]:
322
+ """
323
+ Extract `[PrincipalPermission(SecurityAction.Demand, Role = "...")]`
324
+ attributes — WCF's declarative role-based authorisation.
325
+
326
+ Multiple `[PrincipalPermission]` attributes on the same operation
327
+ are an OR (the caller must be in *any* of the listed roles); we
328
+ collapse them into a single dependency whose `requires_roles` is
329
+ the union.
330
+ """
331
+ auth_deps: list[ExtractedAuthDependency] = []
332
+ for cls in parsed_file.classes:
333
+ if not self._is_service_contract(cls):
334
+ continue
335
+ for method in cls.methods:
336
+ if not self._is_operation_contract(method):
337
+ continue
338
+ roles = self._extract_principal_permission_roles(method)
339
+ if not roles:
340
+ continue
341
+ handler_qname = method.qualified_name or QualifiedName(
342
+ module=cls.qualified_name.module if cls.qualified_name else "",
343
+ name=f"{cls.name}.{method.name}",
344
+ )
345
+ auth_deps.append(
346
+ ExtractedAuthDependency(
347
+ name=handler_qname.full,
348
+ qualified_name=handler_qname,
349
+ location=method.location,
350
+ dependency_type=AuthDependencyType.ANNOTATION,
351
+ requires_roles=roles,
352
+ confidence=Confidence.HIGH,
353
+ )
354
+ )
355
+ return auth_deps
356
+
357
+ def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
358
+ return []
359
+
360
+ def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
361
+ return []
362
+
363
+ # -------------------------------------------------------------------------
364
+ # Service-contract detection
365
+ # -------------------------------------------------------------------------
366
+
367
+ def _is_service_contract(self, cls: ParsedClass) -> bool:
368
+ """Whether the class/interface carries [ServiceContract]."""
369
+ return any(d.name == _SERVICE_CONTRACT_ATTR for d in cls.decorators)
370
+
371
+ def _is_operation_contract(self, method: ParsedFunction) -> bool:
372
+ return any(d.name == _OPERATION_CONTRACT_ATTR for d in method.decorators)
373
+
374
+ def _is_end_async_helper(self, method: ParsedFunction, cls: ParsedClass) -> bool:
375
+ """A `EndFoo` method paired with a `BeginFoo` operation is plumbing.
376
+
377
+ Begin/End is the legacy WCF async pattern. Only the `BeginFoo` half
378
+ carries [OperationContract(AsyncPattern = true)]; the `EndFoo` half
379
+ usually doesn't, but defensively detect either spelling so we don't
380
+ double-emit.
381
+ """
382
+ if not method.name.startswith("End"):
383
+ return False
384
+ peer = "Begin" + method.name[len("End") :]
385
+ return any(m.name == peer for m in cls.methods)
386
+
387
+ # -------------------------------------------------------------------------
388
+ # Name extraction
389
+ # -------------------------------------------------------------------------
390
+
391
+ def _extract_service_name(self, cls: ParsedClass) -> str:
392
+ """
393
+ Service name from `[ServiceContract(Name = "...")]`, falling back to
394
+ the interface/class name minus the leading `I` (the established
395
+ .NET convention for service interfaces).
396
+ """
397
+ for dec in cls.decorators:
398
+ if dec.name != _SERVICE_CONTRACT_ATTR:
399
+ continue
400
+ name_arg = dec.arguments.get("Name") if dec.arguments else None
401
+ if isinstance(name_arg, str) and name_arg:
402
+ return name_arg
403
+ # `IOrderService` → `OrderService`; `OrderService` (unprefixed) stays.
404
+ name = cls.name
405
+ if name.startswith("I") and len(name) > 1 and name[1].isupper():
406
+ return name[1:]
407
+ return name
408
+
409
+ def _extract_operation_name(self, method: ParsedFunction) -> str:
410
+ """Operation name from `[OperationContract(Name = "...")]` or method name."""
411
+ for dec in method.decorators:
412
+ if dec.name != _OPERATION_CONTRACT_ATTR:
413
+ continue
414
+ name_arg = dec.arguments.get("Name") if dec.arguments else None
415
+ if isinstance(name_arg, str) and name_arg:
416
+ return name_arg
417
+ # Strip `Begin` prefix on async-pattern operations: `BeginGetOrder`
418
+ # → `GetOrder` (the SOAP wire operation name).
419
+ if method.name.startswith("Begin") and self._is_async_pattern(method):
420
+ return method.name[len("Begin") :]
421
+ return method.name
422
+
423
+ # -------------------------------------------------------------------------
424
+ # OperationContract flag accessors
425
+ # -------------------------------------------------------------------------
426
+
427
+ def _is_one_way(self, method: ParsedFunction) -> bool:
428
+ for dec in method.decorators:
429
+ if dec.name != _OPERATION_CONTRACT_ATTR:
430
+ continue
431
+ val = dec.arguments.get("IsOneWay") if dec.arguments else None
432
+ if _is_truthy(val):
433
+ return True
434
+ return False
435
+
436
+ def _is_async_pattern(self, method: ParsedFunction) -> bool:
437
+ """[OperationContract(AsyncPattern = true)] — the Begin/End style."""
438
+ for dec in method.decorators:
439
+ if dec.name != _OPERATION_CONTRACT_ATTR:
440
+ continue
441
+ val = dec.arguments.get("AsyncPattern") if dec.arguments else None
442
+ if _is_truthy(val):
443
+ return True
444
+ return False
445
+
446
+ def _extract_principal_permission_roles(
447
+ self,
448
+ method: ParsedFunction,
449
+ ) -> list[str]:
450
+ """
451
+ Collect `Role` arguments from every `[PrincipalPermission]` on the
452
+ method. WCF semantics: multiple attributes form an OR — the caller
453
+ must be in *any* of the listed roles. We return the union, in
454
+ deterministic order (preserves the order the developer wrote them).
455
+ """
456
+ roles: list[str] = []
457
+ seen: set[str] = set()
458
+ for dec in method.decorators:
459
+ if dec.name != _PRINCIPAL_PERMISSION_ATTR:
460
+ continue
461
+ role_arg = dec.arguments.get("Role") if dec.arguments else None
462
+ if isinstance(role_arg, str) and role_arg and role_arg not in seen:
463
+ roles.append(role_arg)
464
+ seen.add(role_arg)
465
+ return roles
466
+
467
+ def _extract_fault_types(self, method: ParsedFunction) -> list[str]:
468
+ """
469
+ Collect referenced types from every `[FaultContract(typeof(X))]`.
470
+
471
+ The C# parser surfaces the `typeof(X)` expression as a string-typed
472
+ positional argument. Extract the inner type name; preserve order
473
+ across multiple [FaultContract] attributes.
474
+ """
475
+ faults: list[str] = []
476
+ seen: set[str] = set()
477
+ for dec in method.decorators:
478
+ if dec.name != _FAULT_CONTRACT_ATTR:
479
+ continue
480
+ candidate: object | None = None
481
+ if dec.positional_args:
482
+ candidate = dec.positional_args[0]
483
+ elif dec.arguments:
484
+ candidate = dec.arguments.get("detailType") or dec.arguments.get("DetailType")
485
+ if not isinstance(candidate, str):
486
+ continue
487
+ match = _TYPEOF_RE.search(candidate)
488
+ type_name = match.group(1) if match else candidate.strip()
489
+ if type_name and type_name not in seen:
490
+ faults.append(type_name)
491
+ seen.add(type_name)
492
+ return faults
493
+
494
+ # -------------------------------------------------------------------------
495
+ # Per-operation emission
496
+ # -------------------------------------------------------------------------
497
+
498
+ def _extract_operation_route(
499
+ self,
500
+ method: ParsedFunction,
501
+ cls: ParsedClass,
502
+ service_name: str,
503
+ endpoint: _WcfEndpoint | None = None,
504
+ ) -> ExtractedRoute | None:
505
+ operation_name = self._extract_operation_name(method)
506
+ is_one_way = self._is_one_way(method)
507
+
508
+ # REST-style WCF (WebHttpBinding) overrides SOAP defaults:
509
+ # `[WebGet]` / `[WebInvoke]` provide an HTTP verb and URI template
510
+ # that replace the conventional `/{ServiceName}/{Operation}` path.
511
+ web_route = self._extract_web_route_info(method, operation_name)
512
+ if web_route is not None:
513
+ http_method, template = web_route
514
+ path = self._apply_endpoint_to_rest(template, endpoint)
515
+ kind = "http"
516
+ tags = self._compose_tags("wcf", "rest", endpoint=endpoint)
517
+ else:
518
+ http_method = HttpMethod.POST
519
+ path = self._apply_endpoint_to_soap(service_name, operation_name, endpoint)
520
+ kind = "wcf_oneway" if is_one_way else "wcf_soap"
521
+ tags = self._compose_tags("wcf", "soap", endpoint=endpoint)
522
+
523
+ # [FaultContract(typeof(X))] → `fault:X` tag. Multiple are common.
524
+ for fault_type in self._extract_fault_types(method):
525
+ tags.append(f"fault:{fault_type}")
526
+
527
+ # [PrincipalPermission(..., Role="X")] → dependency_refs pointing
528
+ # at the auth dependency emitted by extract_auth_dependencies. The
529
+ # dep name is the method's fully-qualified name (same convention
530
+ # as MVC's [Authorize] handling).
531
+ dependency_refs: list[str] = []
532
+ if self._extract_principal_permission_roles(method):
533
+ handler_qname = method.qualified_name or QualifiedName(
534
+ module=cls.qualified_name.module if cls.qualified_name else "",
535
+ name=f"{cls.name}.{method.name}",
536
+ )
537
+ dependency_refs.append(handler_qname.full)
538
+
539
+ path_params, query_params, body = self._extract_params(
540
+ method,
541
+ path,
542
+ is_rest=web_route is not None,
543
+ )
544
+ if body is None and web_route is None:
545
+ # SOAP body fallback (REST handles its own body inference above).
546
+ body = self._extract_body(method)
547
+
548
+ response = (
549
+ None
550
+ if is_one_way and web_route is None
551
+ else ExtractedResponse(
552
+ status_code=200,
553
+ model_name=self._extract_response_model(method),
554
+ )
555
+ )
556
+
557
+ handler_qname = method.qualified_name or QualifiedName(
558
+ module=cls.qualified_name.module if cls.qualified_name else "",
559
+ name=f"{cls.name}.{method.name}",
560
+ )
561
+
562
+ return ExtractedRoute(
563
+ method=http_method,
564
+ path=path,
565
+ handler_function=handler_qname,
566
+ handler_location=method.location,
567
+ path_params=path_params,
568
+ query_params=query_params,
569
+ header_params=[],
570
+ cookie_params=[],
571
+ body=body,
572
+ response=response
573
+ if response is not None
574
+ # one-way still needs *some* response shape for the dataclass;
575
+ # 202 Accepted is the closest SOAP equivalent ("acknowledged,
576
+ # no reply").
577
+ else ExtractedResponse(status_code=202, model_name=None),
578
+ tags=tags,
579
+ dependency_refs=dependency_refs,
580
+ confidence=Confidence.HIGH,
581
+ kind=kind,
582
+ )
583
+
584
+ # -------------------------------------------------------------------------
585
+ # REST (WebHttpBinding) extraction
586
+ # -------------------------------------------------------------------------
587
+
588
+ def _extract_web_route_info(
589
+ self,
590
+ method: ParsedFunction,
591
+ operation_name: str,
592
+ ) -> tuple[HttpMethod, str] | None:
593
+ """
594
+ If `method` carries `[WebGet]` or `[WebInvoke]`, return the
595
+ resolved (HttpMethod, normalised path). Otherwise None.
596
+ """
597
+ for dec in method.decorators:
598
+ if dec.name == _WEB_GET_ATTR:
599
+ uri = dec.arguments.get("UriTemplate") if dec.arguments else None
600
+ return HttpMethod.GET, self._normalize_uri_template(uri, operation_name)
601
+
602
+ if dec.name == _WEB_INVOKE_ATTR:
603
+ args = dec.arguments or {}
604
+ method_arg = args.get("Method")
605
+ verb_name = (
606
+ method_arg.upper() if isinstance(method_arg, str) and method_arg else "POST"
607
+ )
608
+ http_method = _WEB_INVOKE_METHOD_MAP.get(verb_name, HttpMethod.POST)
609
+ uri = args.get("UriTemplate")
610
+ return http_method, self._normalize_uri_template(uri, operation_name)
611
+ return None
612
+
613
+ def _normalize_uri_template(
614
+ self,
615
+ uri: object,
616
+ operation_name: str,
617
+ ) -> str:
618
+ """
619
+ WCF default when UriTemplate is absent: the operation name. When
620
+ present, ensure a leading slash and preserve the rest verbatim
621
+ (`{param}` and `?key={value}` portions are handled downstream).
622
+ """
623
+ if isinstance(uri, str) and uri:
624
+ return uri if uri.startswith("/") else "/" + uri
625
+ return "/" + operation_name
626
+
627
+ def _extract_params(
628
+ self,
629
+ method: ParsedFunction,
630
+ path: str,
631
+ is_rest: bool,
632
+ ) -> tuple[list[ExtractedParameter], list[ExtractedParameter], ExtractedBody | None]:
633
+ """
634
+ For REST routes, classify method parameters by where they appear in
635
+ the URI template. For SOAP, return empty lists — the body is the
636
+ whole request and is handled by `_extract_body`.
637
+ """
638
+ if not is_rest:
639
+ return [], [], None
640
+
641
+ path_part, _, query_part = path.partition("?")
642
+ path_names = set(_URI_TEMPLATE_PARAM_RE.findall(path_part))
643
+ query_names = set(_URI_TEMPLATE_PARAM_RE.findall(query_part))
644
+
645
+ path_params: list[ExtractedParameter] = []
646
+ query_params: list[ExtractedParameter] = []
647
+ body: ExtractedBody | None = None
648
+
649
+ for param in method.parameters:
650
+ type_name = (param.type_annotation or "").strip()
651
+ simple = type_name.split("<")[0].strip().rstrip("?").rsplit(".", 1)[-1]
652
+ if simple in _PLUMBING_PARAM_TYPES:
653
+ continue
654
+
655
+ if param.name in path_names:
656
+ path_params.append(
657
+ ExtractedParameter(
658
+ name=param.name,
659
+ location=ParameterLocation.PATH,
660
+ type_annotation=param.type_annotation,
661
+ required=True,
662
+ code_location=param.location,
663
+ )
664
+ )
665
+ elif param.name in query_names:
666
+ query_params.append(
667
+ ExtractedParameter(
668
+ name=param.name,
669
+ location=ParameterLocation.QUERY,
670
+ type_annotation=param.type_annotation,
671
+ required=(param.default_value is None),
672
+ default_value=param.default_value,
673
+ code_location=param.location,
674
+ )
675
+ )
676
+ elif body is None:
677
+ # Any remaining non-plumbing parameter is the request body —
678
+ # which only makes sense for POST/PUT/PATCH in REST.
679
+ body = ExtractedBody(
680
+ content_type="application/xml",
681
+ model_name=type_name or None,
682
+ required=True,
683
+ )
684
+
685
+ return path_params, query_params, body
686
+
687
+ def _extract_body(self, method: ParsedFunction) -> ExtractedBody | None:
688
+ """
689
+ First parameter whose type isn't WCF plumbing becomes the SOAP body.
690
+
691
+ WCF can also use `[MessageContract]` types or accept `Message` directly,
692
+ but for first-pass extraction the "first user-defined parameter" rule
693
+ catches the canonical pattern.
694
+ """
695
+ for param in method.parameters:
696
+ type_name = (param.type_annotation or "").strip()
697
+ if not type_name:
698
+ continue
699
+ simple = type_name.split("<")[0].strip().rstrip("?").rsplit(".", 1)[-1]
700
+ if simple in _PLUMBING_PARAM_TYPES:
701
+ continue
702
+ return ExtractedBody(
703
+ content_type="application/soap+xml",
704
+ model_name=type_name,
705
+ required=True,
706
+ )
707
+ return None
708
+
709
+ def _extract_response_model(self, method: ParsedFunction) -> str | None:
710
+ """Unwrap `Task<T>` / `IAsyncResult` / `void` to the actual response type."""
711
+ ret = (method.return_type or "").strip()
712
+ if not ret or ret in ("void", "Task", "ValueTask", "IAsyncResult"):
713
+ return None
714
+ for wrapper in ("Task<", "ValueTask<"):
715
+ if ret.startswith(wrapper) and ret.endswith(">"):
716
+ inner = ret[len(wrapper) : -1].strip()
717
+ return inner or None
718
+ return ret
719
+
720
+ # -------------------------------------------------------------------------
721
+ # Config-derived endpoint resolution
722
+ # -------------------------------------------------------------------------
723
+
724
+ def _load_endpoint_map(
725
+ self,
726
+ context: AnalysisContext | None,
727
+ ) -> dict[str, list[_WcfEndpoint]]:
728
+ """
729
+ Build `{contract_FQN: [_WcfEndpoint, ...]}` from every Web.config /
730
+ App.config and CoreWCF Startup.cs / Program.cs under the project
731
+ root. Cached per AnalysisContext.
732
+
733
+ XML config is authoritative: when a contract has endpoints declared
734
+ in both XML and programmatic CoreWCF registration, the XML entries
735
+ are used (legacy WCF deployments use XML as the deployment-time
736
+ config; CoreWCF hosts use programmatic, but rarely both at once).
737
+
738
+ Returns {} when no context is provided (unit-test invocation) — the
739
+ plugin then emits placeholder paths from PR A/B behaviour.
740
+ """
741
+ if context is None or context.project_root is None:
742
+ return {}
743
+ # Cache key: resolved absolute path of the project root. Stable
744
+ # across `AnalysisContext` instances; avoids the `id(context)`
745
+ # memory-recycling hazard.
746
+ try:
747
+ key = str(Path(context.project_root).resolve())
748
+ except OSError:
749
+ key = str(context.project_root)
750
+ if key not in self._endpoint_cache:
751
+ xml_map = _scan_config_files(context.project_root)
752
+ startup_map = _scan_startup_files(context.project_root)
753
+ merged = dict(xml_map)
754
+ for contract, eps in startup_map.items():
755
+ merged.setdefault(contract, eps)
756
+ # Dedupe within each contract by full _WcfEndpoint equality
757
+ # (address + binding + base_address). CoreWCF samples commonly
758
+ # have multiple Startup.cs files each registering the same
759
+ # contract at the same address — those collapse to one route
760
+ # here, not one route per registration call.
761
+ self._endpoint_cache[key] = {
762
+ contract: _dedupe_preserve_order(eps) for contract, eps in merged.items()
763
+ }
764
+ return self._endpoint_cache[key]
765
+
766
+ def _contract_is_registered_elsewhere(
767
+ self,
768
+ cls: ParsedClass,
769
+ endpoint_map: dict[str, list[_WcfEndpoint]],
770
+ ) -> bool:
771
+ """Return True iff some endpoint anywhere in the map registers this
772
+ contract — used to distinguish client-side interface declarations
773
+ (no registrations in their own project, but the contract IS
774
+ registered in the server project) from genuinely standalone
775
+ contracts (no registrations anywhere). Independent of
776
+ project_root scoping."""
777
+ if not endpoint_map:
778
+ return False
779
+ if cls.qualified_name and cls.qualified_name.full in endpoint_map:
780
+ return True
781
+ if cls.name in endpoint_map:
782
+ return True
783
+ return any(fqn.rsplit(".", 1)[-1] == cls.name for fqn in endpoint_map)
784
+
785
+ def _endpoints_for_contract(
786
+ self,
787
+ cls: ParsedClass,
788
+ endpoint_map: dict[str, list[_WcfEndpoint]],
789
+ file_project_root: str | None = None,
790
+ ) -> list[_WcfEndpoint]:
791
+ """
792
+ Match a parsed `[ServiceContract]` class to its config endpoints,
793
+ restricted to endpoints registered in the same project.
794
+
795
+ Lookup order (each step filtered by project_root):
796
+ 1. Fully-qualified name (Web.config typically uses FQN)
797
+ 2. Unqualified class name (Startup.cs uses C# typeof short form)
798
+ 3. Suffix match — any map key whose simple-name segment matches
799
+
800
+ `file_project_root` is the contract file's enclosing `.csproj`
801
+ directory. An endpoint with `project_root=None` is treated as
802
+ globally applicable (no .csproj in unit-test setups; legacy
803
+ deployments where Web.config sits outside any project). This
804
+ prevents same-simple-name interfaces in different projects from
805
+ sharing each other's registrations.
806
+ """
807
+ if not endpoint_map:
808
+ return []
809
+
810
+ def _scope(eps: list[_WcfEndpoint]) -> list[_WcfEndpoint]:
811
+ return [
812
+ ep
813
+ for ep in eps
814
+ if ep.project_root is None
815
+ or file_project_root is None
816
+ or ep.project_root == file_project_root
817
+ ]
818
+
819
+ if cls.qualified_name:
820
+ fqn = cls.qualified_name.full
821
+ if fqn in endpoint_map:
822
+ scoped = _scope(endpoint_map[fqn])
823
+ if scoped:
824
+ return scoped
825
+ if cls.name in endpoint_map:
826
+ scoped = _scope(endpoint_map[cls.name])
827
+ if scoped:
828
+ return scoped
829
+ # Suffix match across map keys.
830
+ for fqn, eps in endpoint_map.items():
831
+ if fqn.rsplit(".", 1)[-1] == cls.name:
832
+ scoped = _scope(eps)
833
+ if scoped:
834
+ return scoped
835
+ return []
836
+
837
+ def _apply_endpoint_to_soap(
838
+ self,
839
+ service_name: str,
840
+ operation_name: str,
841
+ endpoint: _WcfEndpoint | None,
842
+ ) -> str:
843
+ """SOAP path: replace `/{ServiceName}/{Operation}` with `<address>/{Operation}`."""
844
+ if endpoint is None:
845
+ return f"/{service_name}/{operation_name}"
846
+ if _is_full_url(endpoint.address):
847
+ return endpoint.address.rstrip("/") + "/" + operation_name
848
+ address = endpoint.address.strip("/")
849
+ if not address:
850
+ return f"/{operation_name}"
851
+ return f"/{address}/{operation_name}"
852
+
853
+ def _apply_endpoint_to_rest(
854
+ self,
855
+ uri_template: str,
856
+ endpoint: _WcfEndpoint | None,
857
+ ) -> str:
858
+ """REST path: prepend the endpoint address to the UriTemplate."""
859
+ if endpoint is None:
860
+ return uri_template
861
+ if _is_full_url(endpoint.address):
862
+ base = endpoint.address.rstrip("/")
863
+ return base + (uri_template if uri_template.startswith("/") else "/" + uri_template)
864
+ address = endpoint.address.strip("/")
865
+ if not address:
866
+ return uri_template
867
+ template = uri_template if uri_template.startswith("/") else "/" + uri_template
868
+ return f"/{address}{template}"
869
+
870
+ def _compose_tags(
871
+ self,
872
+ *base_tags: str,
873
+ endpoint: _WcfEndpoint | None = None,
874
+ ) -> list[str]:
875
+ """Build the route tag list, tagging the binding when known."""
876
+ tags = list(base_tags)
877
+ if endpoint and endpoint.binding:
878
+ tags.append(f"binding:{endpoint.binding}")
879
+ return tags
880
+
881
+
882
+ # =============================================================================
883
+ # Config file scanner
884
+ # =============================================================================
885
+
886
+
887
+ def _scan_config_files(root: Path) -> dict[str, list[_WcfEndpoint]]:
888
+ """
889
+ Walk `root` for `Web.config` / `App.config` files and extract
890
+ `<system.serviceModel>` endpoint registrations.
891
+
892
+ Returns `{contract_full_name: [_WcfEndpoint, ...]}`. Multiple
893
+ endpoints per contract are common (one binding for SOAP, another for
894
+ REST/JSON, etc.) — preserve all of them in registration order.
895
+ """
896
+ mapping: dict[str, list[_WcfEndpoint]] = {}
897
+ try:
898
+ config_paths = sorted(
899
+ {
900
+ *root.rglob("Web.config"),
901
+ *root.rglob("App.config"),
902
+ }
903
+ )
904
+ except (OSError, ValueError):
905
+ return mapping
906
+
907
+ for path in config_paths:
908
+ try:
909
+ tree = ET.parse(path)
910
+ except (ET.ParseError, OSError):
911
+ continue
912
+ # XML config endpoints are deployment-time configuration that
913
+ # applies cross-project: traditional .NET Framework WCF puts
914
+ # the contract interface in one assembly (`*.Contracts.csproj`)
915
+ # and hosts it from a separate project's App.config (e.g. a
916
+ # ConsoleHost / WindowsService). Leaving `project_root=None`
917
+ # lets `_endpoints_for_contract` apply these endpoints to
918
+ # contracts in any project. Programmatic endpoints stay
919
+ # project-scoped (see `_scan_startup_files`).
920
+ for service in tree.getroot().iter("service"):
921
+ base_addresses = [
922
+ add.get("baseAddress", "")
923
+ for add in service.findall("./host/baseAddresses/add")
924
+ if add.get("baseAddress")
925
+ ]
926
+ base_addr = base_addresses[0] if base_addresses else None
927
+ for endpoint in service.findall("./endpoint"):
928
+ contract = endpoint.get("contract")
929
+ if not contract:
930
+ continue
931
+ ep = _WcfEndpoint(
932
+ address=endpoint.get("address", "") or "",
933
+ binding=endpoint.get("binding", "") or "",
934
+ base_address=base_addr,
935
+ project_root=None,
936
+ )
937
+ mapping.setdefault(contract, []).append(ep)
938
+ return mapping
939
+
940
+
941
+ def _is_full_url(s: str) -> bool:
942
+ return s.startswith("http://") or s.startswith("https://") or s.startswith("net.tcp://")
943
+
944
+
945
+ def _find_enclosing_csproj(path: Path | None) -> str | None:
946
+ """Walk up `path`'s ancestors looking for a sibling `*.csproj` file.
947
+ Returns the containing directory as a string, or None when no
948
+ project file is found (unit-test setups, files outside any
949
+ project)."""
950
+ if path is None:
951
+ return None
952
+ try:
953
+ current = path.parent if path.is_file() else path
954
+ except OSError:
955
+ return None
956
+ with contextlib.suppress(OSError):
957
+ current = current.resolve()
958
+ while True:
959
+ try:
960
+ if any(current.glob("*.csproj")):
961
+ return str(current)
962
+ except OSError:
963
+ return None
964
+ if current.parent == current:
965
+ return None
966
+ current = current.parent
967
+
968
+
969
+ def _dedupe_preserve_order(items: list[_WcfEndpoint]) -> list[_WcfEndpoint]:
970
+ """Remove duplicate `_WcfEndpoint` entries while preserving the
971
+ first-seen order — keeps deterministic output across runs.
972
+
973
+ Dedup key:
974
+ - address (lower-cased — CoreWCF / IIS route case-insensitively)
975
+ - binding (lower-cased)
976
+ - project_root (so programmatic endpoints from different projects
977
+ with same address don't collapse before scope-filtering)
978
+
979
+ `base_address` is intentionally NOT part of the key. Two endpoints
980
+ sharing (address, binding) but declared in different host projects'
981
+ `<host><baseAddresses>` blocks at different ports/hostnames produce
982
+ the same route URL (`_apply_endpoint_to_soap` uses `address`, not
983
+ `base_address`) — they're the same logical route surface, just hosted
984
+ on different ports/URLs. Real exposure: QIQO ships both a
985
+ `ConsoleHost/App.config` (port 7473) and a `WindowsServiceHost/App.config`
986
+ (port 7476) declaring the same endpoints.
987
+ """
988
+ seen: set[tuple[str, str, str | None]] = set()
989
+ out: list[_WcfEndpoint] = []
990
+ for ep in items:
991
+ key = (
992
+ ep.address.lower(),
993
+ ep.binding.lower(),
994
+ ep.project_root,
995
+ )
996
+ if key not in seen:
997
+ seen.add(key)
998
+ out.append(ep)
999
+ return out
1000
+
1001
+
1002
+ # =============================================================================
1003
+ # CoreWCF programmatic-endpoint scanner
1004
+ # =============================================================================
1005
+ #
1006
+ # CoreWCF hosts register endpoints in Startup.cs / Program.cs, not in
1007
+ # Web.config. Both REST (AddServiceWebEndpoint) and SOAP (AddServiceEndpoint)
1008
+ # share the same shape:
1009
+ #
1010
+ # builder.AddServiceWebEndpoint<TService, TContract>(
1011
+ # new WebHttpBinding { ... }, "api", behavior => { ... });
1012
+ #
1013
+ # We extract the contract type (2nd type parameter) and the address (2nd
1014
+ # positional argument, when a string literal). Non-literal addresses
1015
+ # (variables, configuration lookups) are out of scope for v1.
1016
+
1017
+
1018
+ _ADD_ENDPOINT_RE = re.compile(
1019
+ r"\.(AddServiceWebEndpoint|AddServiceEndpoint)\s*"
1020
+ r"<\s*[\w.]+\s*,\s*([\w.]+)\s*>\s*\("
1021
+ )
1022
+
1023
+ # Non-generic SOAP form used by `System.ServiceModel.ServiceHost`:
1024
+ # host.AddServiceEndpoint(contract, binding, "address");
1025
+ # where `contract` is either `typeof(X)` inline or a variable previously
1026
+ # assigned from `typeof(X)`. CoreWCF generic form covers most newer code;
1027
+ # this branch picks up legacy / `ServiceHost`-based hosting in
1028
+ # `NetFrameworkServer`-style samples.
1029
+ _NONGENERIC_ADD_ENDPOINT_RE = re.compile(
1030
+ r"\.AddServiceEndpoint\s*\(\s*"
1031
+ r"(typeof\s*\(\s*([\w.]+)\s*\)|[A-Za-z_]\w*)\s*,"
1032
+ )
1033
+
1034
+ # `Type contract = typeof(Foo.IBar);` or `var contract = typeof(Foo.IBar);`
1035
+ # — used to resolve the contract type when AddServiceEndpoint is called
1036
+ # with a variable rather than an inline typeof.
1037
+ _TYPEOF_ASSIGN_RE = re.compile(r"\b(?:Type|var)\s+([A-Za-z_]\w*)\s*=\s*typeof\s*\(\s*([\w.]+)\s*\)")
1038
+
1039
+
1040
+ def _scan_startup_files(root: Path) -> dict[str, list[_WcfEndpoint]]:
1041
+ """
1042
+ Walk `root` for `.cs` files and extract `AddServiceWebEndpoint<T, C>(...)`
1043
+ and `AddServiceEndpoint<T, C>(...)` calls into the same shape used by
1044
+ XML config.
1045
+
1046
+ Returns `{contract_simple_name: [_WcfEndpoint, ...]}`. We key by the
1047
+ simple contract name because programmatic registration uses the
1048
+ `typeof(IFoo)` short form rather than the FQN — `_endpoints_for_contract`
1049
+ already handles both forms.
1050
+ """
1051
+ mapping: dict[str, list[_WcfEndpoint]] = {}
1052
+ try:
1053
+ cs_paths = sorted(root.rglob("*.cs"))
1054
+ except (OSError, ValueError):
1055
+ return mapping
1056
+ for path in cs_paths:
1057
+ try:
1058
+ text = path.read_text(encoding="utf-8", errors="replace")
1059
+ except OSError:
1060
+ continue
1061
+ # Cheap pre-filter: skip files that don't mention either method.
1062
+ if "AddServiceEndpoint" not in text and "AddServiceWebEndpoint" not in text:
1063
+ continue
1064
+ project_root = _find_enclosing_csproj(path)
1065
+ for method, contract, address in _iter_addservice_calls(text):
1066
+ binding = "webHttpBinding" if method == "AddServiceWebEndpoint" else ""
1067
+ ep = _WcfEndpoint(
1068
+ address=address,
1069
+ binding=binding,
1070
+ base_address=None,
1071
+ project_root=project_root,
1072
+ )
1073
+ mapping.setdefault(contract, []).append(ep)
1074
+ return mapping
1075
+
1076
+
1077
+ def _iter_addservice_calls(source: str):
1078
+ """
1079
+ Yield `(method_name, contract_simple_name, address_literal)` for each
1080
+ `AddService(Web)Endpoint(...)` call in `source`. Two shapes covered:
1081
+
1082
+ 1. Generic form (CoreWCF):
1083
+ builder.AddServiceWebEndpoint<TService, TContract>(binding, "addr", ...)
1084
+ builder.AddServiceEndpoint<TService, TContract>(binding, "addr")
1085
+
1086
+ 2. Non-generic form (`System.ServiceModel.ServiceHost`):
1087
+ host.AddServiceEndpoint(contract, binding, "addr")
1088
+ — where `contract` is `typeof(X)` inline or a local variable
1089
+ previously assigned from `typeof(X)`.
1090
+
1091
+ Skips calls where the address is not a plain string literal —
1092
+ interpolated strings and variable references need richer analysis.
1093
+ """
1094
+ for match in _ADD_ENDPOINT_RE.finditer(source):
1095
+ contract = match.group(2)
1096
+ address = _second_positional_string(source, match.end())
1097
+ if address is None:
1098
+ continue
1099
+ yield match.group(1), contract, address
1100
+
1101
+ # Non-generic form — resolve typeof variables one pass per file.
1102
+ var_to_type: dict[str, str] = {}
1103
+ for m in _TYPEOF_ASSIGN_RE.finditer(source):
1104
+ var_to_type[m.group(1)] = m.group(2).rsplit(".", 1)[-1]
1105
+
1106
+ for match in _NONGENERIC_ADD_ENDPOINT_RE.finditer(source):
1107
+ # Skip when this is actually a generic call — `AddServiceEndpoint<T,C>(`
1108
+ # would also match the bare `(` portion otherwise.
1109
+ head = source[match.start() : match.end()]
1110
+ if "<" in head:
1111
+ continue
1112
+ inline_typeof = match.group(2)
1113
+ var_name = match.group(1)
1114
+ if inline_typeof:
1115
+ contract = inline_typeof.rsplit(".", 1)[-1]
1116
+ else:
1117
+ contract = var_to_type.get(var_name)
1118
+ if contract is None:
1119
+ continue
1120
+ # The regex already consumed `(<contract>,` — cursor is at the start
1121
+ # of the binding arg. `_second_positional_string` skips one arg and
1122
+ # returns the next string literal — which here is the address.
1123
+ address = _second_positional_string(source, match.end())
1124
+ if address is None:
1125
+ continue
1126
+ yield "AddServiceEndpoint", contract, address
1127
+
1128
+
1129
+ def _second_positional_string(source: str, start: int) -> str | None:
1130
+ """
1131
+ Given `source` positioned just past the opening `(` of a method call,
1132
+ return the contents of the second positional argument iff it is a plain
1133
+ `"..."` string literal. Returns None for any other case
1134
+ (verbatim strings, interpolated strings, non-literals, only one arg).
1135
+ """
1136
+ depth = 1
1137
+ i = start
1138
+ in_str = False
1139
+ in_chr = False
1140
+ in_line_comment = False
1141
+ in_block_comment = False
1142
+ # Phase 1: scan past the first argument to the first top-level comma.
1143
+ while i < len(source):
1144
+ c = source[i]
1145
+ nxt = source[i + 1] if i + 1 < len(source) else ""
1146
+ if in_line_comment:
1147
+ if c == "\n":
1148
+ in_line_comment = False
1149
+ i += 1
1150
+ continue
1151
+ if in_block_comment:
1152
+ if c == "*" and nxt == "/":
1153
+ in_block_comment = False
1154
+ i += 2
1155
+ continue
1156
+ i += 1
1157
+ continue
1158
+ if in_str:
1159
+ if c == "\\":
1160
+ i += 2
1161
+ continue
1162
+ if c == '"':
1163
+ in_str = False
1164
+ i += 1
1165
+ continue
1166
+ if in_chr:
1167
+ if c == "\\":
1168
+ i += 2
1169
+ continue
1170
+ if c == "'":
1171
+ in_chr = False
1172
+ i += 1
1173
+ continue
1174
+ if c == "/" and nxt == "/":
1175
+ in_line_comment = True
1176
+ i += 2
1177
+ continue
1178
+ if c == "/" and nxt == "*":
1179
+ in_block_comment = True
1180
+ i += 2
1181
+ continue
1182
+ if c == '"':
1183
+ in_str = True
1184
+ i += 1
1185
+ continue
1186
+ if c == "'":
1187
+ in_chr = True
1188
+ i += 1
1189
+ continue
1190
+ if c in "([{":
1191
+ depth += 1
1192
+ elif c in ")]}":
1193
+ depth -= 1
1194
+ if depth == 0:
1195
+ return None # call ended before a second argument
1196
+ elif c == "," and depth == 1:
1197
+ i += 1
1198
+ break
1199
+ i += 1
1200
+ else:
1201
+ return None
1202
+ # Phase 2: parse the second positional as a C# string literal —
1203
+ # plain `"..."` or verbatim `@"..."` (where `""` is an embedded
1204
+ # quote, decoded back to a single `"` in the returned content).
1205
+ #
1206
+ # Interpolated `$"..."` (and the verbatim-interpolated `$@"..."` /
1207
+ # `@$"..."`) are intentionally deferred: emitting interpolated
1208
+ # templates with `{placeholder}` segments would create routes whose
1209
+ # paths can't match a resolved-value GT. Closing that gap is v7
1210
+ # charter work: needs a constant-resolution pass over `const string`
1211
+ # / `static readonly string` fields in scope, plus paired GT
1212
+ # updates for any newly-emitted resolved URLs.
1213
+ parsed = parse_csharp_string_literal(source, i)
1214
+ if parsed is None:
1215
+ return None
1216
+ content, _open, _after = parsed
1217
+ return content
1218
+
1219
+
1220
+ # =============================================================================
1221
+ # Helpers
1222
+ # =============================================================================
1223
+
1224
+
1225
+ def _is_truthy(val: object) -> bool:
1226
+ """Accept Python True or the C# attribute-arg string `"true"` (case-insensitive)."""
1227
+ if val is True:
1228
+ return True
1229
+ if isinstance(val, str) and val.strip().lower() == "true":
1230
+ return True
1231
+ return False
1232
+
1233
+
1234
+ # =============================================================================
1235
+ # Self-registration
1236
+ # =============================================================================
1237
+
1238
+ _wcf_plugin = WcfPlugin()
1239
+ FrameworkPluginRegistry.register(_wcf_plugin)