sourcecode 4.2.0__py3-none-any.whl → 4.3.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.
@@ -0,0 +1,305 @@
1
+ """client_calls.py — the other end of the route, when it lives in the same repository.
2
+
3
+ Roadmap M10.5 item 4. A monolith's endpoint census answers *what is exposed*; it cannot
4
+ answer *what anybody calls*, and on a 2 635-endpoint surface **deleting dead API is the
5
+ cheapest possible reduction of exposure**. In a monorepo the answer is right there: the
6
+ TypeScript or JavaScript client that calls the routes is checked in next to the server
7
+ that serves them, and nobody joins the two because doing it needs an effective-route
8
+ model on both sides.
9
+
10
+ What this module extracts is deliberately narrow, and its narrowness is published rather
11
+ than implied:
12
+
13
+ * **Only a call whose verb and path are written at the call site.** `httpClient.get(...)`,
14
+ `axios.post(...)`, `fetch(url, {method: "PUT"})` — the shapes where the request is
15
+ legible without evaluating the program. A URL assembled at runtime from a variable is
16
+ **not** a call this module can see, and a route it never saw must never be reported as
17
+ uncalled: the join publishes `unmatched_client_calls` and the census states its
18
+ direction (`under_reports` for the client side).
19
+ * **A template segment is a template segment.** `${bank-id}` on the client and `{bankId}`
20
+ on the server are the same position with different names, so the join key replaces every
21
+ variable segment with a placeholder — and both spellings travel with the row, because
22
+ that normalisation is exactly what `request_identity` refuses to do inside one tree
23
+ (there, `/{id:.*}` and `/{id:[^0-9].*}` are genuinely different URLs; here, one side is
24
+ not even the same language).
25
+ * **A base path is a prefix nobody wrote.** `${this.configuration.basePath}/v1/x` is a
26
+ call to `/v1/x` plus whatever the deployment configures. The prefix is dropped and the
27
+ suffix is matched; a call that resolves to no leading `/` is reported unresolved rather
28
+ than guessed.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Iterable, Optional
37
+
38
+ SCHEMA_VERSION = "client-usage-v1"
39
+
40
+ #: Files this module reads. A client written in something else is not covered, and the
41
+ #: payload says so rather than reporting its routes as uncalled.
42
+ CLIENT_SUFFIXES: "tuple[str, ...]" = (".ts", ".tsx", ".js", ".jsx", ".mjs")
43
+
44
+ _SKIP_DIRS = frozenset({
45
+ "node_modules", "dist", "build", "out", "target", ".git", "coverage", ".angular",
46
+ })
47
+
48
+ #: `<receiver>.<verb>(<quote><url>` — the Angular/axios/jQuery shape, verb at the call.
49
+ _VERB_CALL = re.compile(
50
+ r"""(?P<receiver>[\w$.]*?)\.?(?P<verb>get|post|put|patch|delete|head|options)\s*"""
51
+ r"""(?:<[^()]*?>\s*)?\(\s*(?P<quote>['"`])(?P<url>[^'"`]*)(?P=quote)""",
52
+ re.IGNORECASE,
53
+ )
54
+
55
+ #: The same shape with a non-literal first argument: `this.http.get<any>(url)`. It is a
56
+ #: call this module cannot resolve, and it has to be *seen* to be reported: a call that
57
+ #: matched nothing at all would silently shrink the census and could make a live route
58
+ #: look dead — the one failure this join must not have.
59
+ _VERB_CALL_DYNAMIC = re.compile(
60
+ r"""(?P<receiver>[\w$.]*?)\.(?P<verb>get|post|put|patch|delete|head|options)\s*"""
61
+ r"""(?:<[^()]*?>\s*)?\(\s*(?P<arg>[A-Za-z_$][\w$.\[\]()]*)""",
62
+ re.IGNORECASE,
63
+ )
64
+
65
+ #: `fetch("<url>", { method: "<verb>" })` — verb in the options object, GET by default.
66
+ _FETCH_CALL = re.compile(
67
+ r"""fetch\s*\(\s*(?P<quote>['"`])(?P<url>[^'"`]*)(?P=quote)"""
68
+ r"""(?P<rest>\s*,\s*\{[^}]{0,200}\})?""",
69
+ re.IGNORECASE | re.DOTALL,
70
+ )
71
+ _METHOD_IN_OPTIONS = re.compile(r"""method\s*:\s*['"`](?P<verb>\w+)['"`]""", re.IGNORECASE)
72
+
73
+ #: A template expression: `${...}`. Whatever it holds, it is one path position.
74
+ _TEMPLATE_EXPR = re.compile(r"\$\{[^}]*\}")
75
+
76
+ #: Receivers that make a verb call an HTTP call rather than a map lookup. Structural, not
77
+ #: proprietary: the name is evidence, and the evidence required is that it looks like a
78
+ #: client. `get(...)` on its own is a Map, a cache or a DOM query far more often than it
79
+ #: is a request, and admitting it would fill the census with calls nobody makes.
80
+ _HTTP_RECEIVER = re.compile(
81
+ r"(http|api|client|axios|request|rest|service|ajax|\$http)", re.IGNORECASE
82
+ )
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ClientCall:
87
+ """One HTTP call written in a client file, as written."""
88
+
89
+ method: str
90
+ path: str # as written, template expressions collapsed to {}
91
+ source_file: str
92
+ line: int
93
+ receiver: str
94
+
95
+ @property
96
+ def key(self) -> str:
97
+ """The join key: METHOD plus the path with variable segments neutralised."""
98
+ return f"{self.method} {normalize_path(self.path)}"
99
+
100
+ def to_dict(self) -> dict:
101
+ return {
102
+ "method": self.method,
103
+ "path": self.path,
104
+ "join_key": self.key,
105
+ "source_file": self.source_file,
106
+ "line": self.line,
107
+ "receiver": self.receiver,
108
+ }
109
+
110
+
111
+ def normalize_path(path: str) -> str:
112
+ """Path with every variable segment replaced by `{}` — the cross-language key.
113
+
114
+ Server: `/v1/pis/banks/{bank-id}/accounts`. Client: `/v1/pis/banks/${bankId}/accounts`.
115
+ Same request, two languages, two naming conventions. Names are dropped **only** in the
116
+ join key; both original spellings stay on the row that reports the match.
117
+ """
118
+ if not path:
119
+ return ""
120
+ cleaned = _TEMPLATE_EXPR.sub("{}", path.strip())
121
+ cleaned = cleaned.split("?", 1)[0].split("#", 1)[0]
122
+ segments = []
123
+ for segment in cleaned.split("/"):
124
+ if segment.startswith("{") and segment.endswith("}"):
125
+ segments.append("{}")
126
+ elif segment.startswith(":") and len(segment) > 1: # /users/:id (express style)
127
+ segments.append("{}")
128
+ else:
129
+ segments.append(segment)
130
+ normalized = "/".join(segments)
131
+ while "//" in normalized:
132
+ normalized = normalized.replace("//", "/")
133
+ if len(normalized) > 1 and normalized.endswith("/"):
134
+ normalized = normalized[:-1]
135
+ return normalized
136
+
137
+
138
+ def _iter_client_files(root: Path, extra: "Optional[Iterable[Path]]" = None) -> "list[Path]":
139
+ roots = [Path(root)] if extra is None else [Path(p) for p in extra]
140
+ found: list[Path] = []
141
+ for base in roots:
142
+ if not base.is_dir():
143
+ continue
144
+ for path in base.rglob("*"):
145
+ if path.suffix.lower() not in CLIENT_SUFFIXES:
146
+ continue
147
+ if any(part in _SKIP_DIRS for part in path.parts):
148
+ continue
149
+ if path.name.endswith((".spec.ts", ".test.ts", ".spec.js", ".test.js")):
150
+ continue
151
+ found.append(path)
152
+ return sorted(found)
153
+
154
+
155
+ def _path_from_url(url: str) -> Optional[str]:
156
+ """The request path of a URL as written, or None when it is not a path at all."""
157
+ collapsed = _TEMPLATE_EXPR.sub("{}", url.strip())
158
+ if collapsed.startswith(("http://", "https://")):
159
+ rest = collapsed.split("://", 1)[1]
160
+ collapsed = "/" + rest.split("/", 1)[1] if "/" in rest else "/"
161
+ elif collapsed.startswith("{}"):
162
+ # `${basePath}/v1/x` — a configured prefix plus a written suffix.
163
+ collapsed = collapsed[2:]
164
+ if not collapsed.startswith("/"):
165
+ return None
166
+ return collapsed
167
+
168
+
169
+ def extract_client_calls(
170
+ root: Path, *, client_dirs: "Optional[Iterable[Path]]" = None
171
+ ) -> "tuple[list[ClientCall], list[dict]]":
172
+ """(calls, unresolved) written in the client files under `root`.
173
+
174
+ `unresolved` carries the call sites this module found and could not turn into a
175
+ path — a URL built from a variable, an interpolation with no literal prefix. They
176
+ are published, never dropped: they are precisely the calls that would make a live
177
+ route look dead.
178
+ """
179
+ calls: list[ClientCall] = []
180
+ unresolved: list[dict] = []
181
+ base = Path(root)
182
+ for file_path in _iter_client_files(base, client_dirs):
183
+ try:
184
+ text = file_path.read_text(encoding="utf-8", errors="replace")
185
+ except OSError:
186
+ continue
187
+ try:
188
+ relative = str(file_path.relative_to(base))
189
+ except ValueError:
190
+ relative = str(file_path)
191
+ for match in _VERB_CALL.finditer(text):
192
+ receiver = match.group("receiver") or ""
193
+ if not _HTTP_RECEIVER.search(receiver):
194
+ continue
195
+ line = text.count("\n", 0, match.start()) + 1
196
+ path = _path_from_url(match.group("url"))
197
+ if path is None:
198
+ unresolved.append({
199
+ "source_file": relative, "line": line,
200
+ "written": match.group("url")[:120],
201
+ "reason": "the URL has no literal path at the call site",
202
+ })
203
+ continue
204
+ calls.append(ClientCall(
205
+ method=match.group("verb").upper(), path=path,
206
+ source_file=relative, line=line, receiver=receiver.strip("."),
207
+ ))
208
+ for match in _VERB_CALL_DYNAMIC.finditer(text):
209
+ receiver = match.group("receiver") or ""
210
+ if not _HTTP_RECEIVER.search(receiver):
211
+ continue
212
+ line = text.count("\n", 0, match.start()) + 1
213
+ unresolved.append({
214
+ "source_file": relative, "line": line,
215
+ "written": match.group(0)[:120],
216
+ "reason": "the URL is an expression, not a literal at the call site",
217
+ })
218
+ for match in _FETCH_CALL.finditer(text):
219
+ line = text.count("\n", 0, match.start()) + 1
220
+ path = _path_from_url(match.group("url"))
221
+ verb_match = _METHOD_IN_OPTIONS.search(match.group("rest") or "")
222
+ verb = (verb_match.group("verb") if verb_match else "GET").upper()
223
+ if path is None:
224
+ unresolved.append({
225
+ "source_file": relative, "line": line,
226
+ "written": match.group("url")[:120],
227
+ "reason": "the URL has no literal path at the call site",
228
+ })
229
+ continue
230
+ calls.append(ClientCall(
231
+ method=verb, path=path, source_file=relative, line=line, receiver="fetch",
232
+ ))
233
+ # One call site, one row; the same route called twice is two witnesses of one usage.
234
+ calls.sort(key=lambda c: (c.path, c.method, c.source_file, c.line))
235
+ return calls, unresolved
236
+
237
+
238
+ def build_client_usage(
239
+ root: Path,
240
+ endpoints: "Iterable[dict]",
241
+ *,
242
+ client_dirs: "Optional[Iterable[Path]]" = None,
243
+ ) -> dict:
244
+ """Join the client calls found under `root` against the server's own routes."""
245
+ from sourcecode import non_coverage
246
+
247
+ calls, unresolved = extract_client_calls(root, client_dirs=client_dirs)
248
+ by_key: "dict[str, list[ClientCall]]" = {}
249
+ for call in calls:
250
+ by_key.setdefault(call.key, []).append(call)
251
+
252
+ matched_keys: set[str] = set()
253
+ routes: list[dict] = []
254
+ for endpoint in endpoints:
255
+ method = str(endpoint.get("method") or "").upper()
256
+ path = str(endpoint.get("path") or endpoint.get("effective_path") or "")
257
+ key = f"{method} {normalize_path(path)}"
258
+ witnesses = by_key.get(key, [])
259
+ if witnesses:
260
+ matched_keys.add(key)
261
+ routes.append({
262
+ "method": method,
263
+ "path": path,
264
+ "join_key": key,
265
+ "called_by_client": bool(witnesses),
266
+ "call_sites": [w.to_dict() for w in witnesses],
267
+ })
268
+
269
+ called = [r for r in routes if r["called_by_client"]]
270
+ uncalled = [r for r in routes if not r["called_by_client"]]
271
+ unmatched = [
272
+ {"join_key": key, "call_sites": [c.to_dict() for c in group]}
273
+ for key, group in sorted(by_key.items()) if key not in matched_keys
274
+ ]
275
+ scanned = len(_iter_client_files(Path(root), client_dirs))
276
+
277
+ return {
278
+ "schema_version": SCHEMA_VERSION,
279
+ "client_files_scanned": scanned,
280
+ "calls_found": len(calls),
281
+ "calls_unresolved": len(unresolved),
282
+ "unresolved_call_sites": unresolved[:50],
283
+ "summary": {
284
+ "routes": len(routes),
285
+ "called_by_a_client_in_this_repository": len(called),
286
+ "not_called_by_any_client_in_this_repository": len(uncalled),
287
+ "client_calls_matching_no_route": len(unmatched),
288
+ },
289
+ "routes": routes,
290
+ "unmatched_client_calls": unmatched,
291
+ "direction": "under_reports",
292
+ "reading": (
293
+ "A route with no call site here is a **candidate** for deletion, never a "
294
+ "proven-dead one: this reads only the client code in this repository, only "
295
+ "the call shapes whose verb and path are written at the call site, and no "
296
+ "other consumer of the API exists in what was read. A client call matching "
297
+ "no route is the more decisive direction — it is a request this repository's "
298
+ "own front end makes and this repository's server does not serve."
299
+ if scanned else
300
+ "No client files were found, so nothing was joined: every route below is "
301
+ "`called_by_client: false` because there was no client to call it. That is "
302
+ "an absence of evidence, published as one."
303
+ ),
304
+ "non_coverage": non_coverage.block("client_usage"),
305
+ }
@@ -703,6 +703,17 @@ class ContextGraph:
703
703
  self._body_index = _BodyIndex(self._cir.body_facts)
704
704
  return self._body_index
705
705
 
706
+ def body_owners(self) -> tuple[str, ...]:
707
+ """Every owner FQN the body-fact layer carries an atom for, ordered.
708
+
709
+ The population of *bodies*, which is not the population of any one symbol
710
+ kind: a handler method is a `Symbol(kind="endpoint")`, not `kind="method"`,
711
+ so a consumer that enumerates method and constructor nodes to walk bodies
712
+ silently skips every controller body in the repository. Bodies are what the
713
+ body-fact layer is keyed by, so this is the only honest way to enumerate them.
714
+ """
715
+ return tuple(sorted(self._body().by_owner))
716
+
706
717
  def invocations_in(self, owner_fqn: str) -> tuple[MethodInvocation, ...]:
707
718
  """Ordered method-call atoms inside a method/constructor body. Empty tuple
708
719
  when the owner has none (never None)."""
@@ -0,0 +1,353 @@
1
+ """data_exposure.py — one declared label, and every route that can carry it.
2
+
3
+ Roadmap M10.5 item 3. The question a public-sector or health system asks first is
4
+ not *"is this endpoint authenticated?"* but *"which routes expose personal data, and
5
+ which of those does anyone reach without authenticating?"*. Nothing here detects
6
+ sensitive data — that is a judgement about a domain, and a tool that guessed it from
7
+ a field name would be guessing (see `data_labels`). What this module does is take a
8
+ label somebody declared once and answer the second half **by measurement**, over
9
+ authorities that already exist:
10
+
11
+ * reach — `spring_impact.run_impact_chain`, the same traversal `impact-chain` and
12
+ `ask risk` walk (`caller_reach`), so the endpoint set behind a type cannot differ
13
+ between commands;
14
+ * the route's own shape — `ContextGraph.structural_references_to_type`, the class
15
+ type-reference atoms, which say whether a handler *itself* names the type in its
16
+ signature;
17
+ * access — `security_posture.endpoint_security_surface` and, with `--profile`,
18
+ `posture.endpoint_access_under`: the same two authorities `risk` weighs.
19
+
20
+ **The two evidence classes are published separately and never added together**,
21
+ because they answer differently strong questions:
22
+
23
+ ``signature`` the handler declares the labelled type in its own parameters or
24
+ return type. The route carries it, as written.
25
+ ``call_reach`` the handler's call chain reaches the labelled type. It is a route
26
+ through which the data *can* travel — a supremum, not a proof: a
27
+ chain that touches `Customer` may map it to a DTO that drops every
28
+ labelled member, and this analyzer does not model that mapping.
29
+
30
+ Both directions are stated (I-8) rather than averaged into one number nobody can
31
+ read: `call_reach` **over-reports** exposure, and every evidence class here
32
+ **under-reports** any flow that leaves through reflection, a serializer configured
33
+ at runtime, an untyped `Map` payload or a native query, none of which name a type.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import TYPE_CHECKING, Any, Optional
41
+
42
+ from sourcecode.data_labels import DataLabel, LabelDeclaration, load_labels
43
+
44
+ if TYPE_CHECKING: # pragma: no cover - typing only
45
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
46
+
47
+ SCHEMA_VERSION = "data-exposure-v1"
48
+
49
+ #: Evidence classes, strongest first — the order rows are ranked in.
50
+ SIGNATURE = "signature"
51
+ CALL_REACH = "call_reach"
52
+ _EVIDENCE_RANK = {SIGNATURE: 0, CALL_REACH: 1}
53
+
54
+ _EVIDENCE_MEANING: dict[str, str] = {
55
+ SIGNATURE: (
56
+ "The handler names the labelled type in its own signature (parameter or "
57
+ "return type), so the route carries it as declared."
58
+ ),
59
+ CALL_REACH: (
60
+ "The handler's call chain reaches the labelled type. The route can carry it; "
61
+ "whether a mapping drops the labelled member on the way out is not modelled, "
62
+ "so this class over-reports."
63
+ ),
64
+ }
65
+
66
+ #: Class-scope type-reference contexts that put a type in a member's own signature.
67
+ _SIGNATURE_CONTEXTS = frozenset({"param_type", "return_type"})
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ExposedRoute:
72
+ """One endpoint, the labels it can carry, and what decides access to it."""
73
+
74
+ endpoint_id: str
75
+ method: str
76
+ path: str
77
+ controller_class: str
78
+ handler_symbol: str
79
+ labels: tuple[str, ...]
80
+ seeds: tuple[str, ...]
81
+ evidence: str
82
+ verdict: str
83
+ access: Optional[str] = None
84
+ access_decision: Optional[str] = None
85
+
86
+ def to_dict(self) -> dict:
87
+ out = {
88
+ "endpoint_id": self.endpoint_id,
89
+ "method": self.method,
90
+ "path": self.path,
91
+ "controller_class": self.controller_class,
92
+ "handler_symbol": self.handler_symbol,
93
+ "labels": list(self.labels),
94
+ "seeds": list(self.seeds),
95
+ "evidence": self.evidence,
96
+ "evidence_meaning": _EVIDENCE_MEANING.get(self.evidence, ""),
97
+ "security_verdict": self.verdict,
98
+ }
99
+ if self.access is not None:
100
+ out["access_under_profile"] = self.access
101
+ out["access_decision"] = self.access_decision
102
+ return out
103
+
104
+
105
+ def _endpoint_fields(endpoint: Any) -> dict:
106
+ """The five fields every endpoint shape in this codebase carries."""
107
+ get = (
108
+ endpoint.get if isinstance(endpoint, dict)
109
+ else lambda key, default=None: getattr(endpoint, key, default)
110
+ )
111
+ return {
112
+ "endpoint_id": str(get("endpoint_id", "") or get("id", "") or ""),
113
+ "method": str(get("method", "") or ""),
114
+ "path": str(get("path", "") or ""),
115
+ "controller_class": str(get("controller_class", "") or ""),
116
+ "handler_symbol": str(get("handler_symbol", "") or ""),
117
+ }
118
+
119
+
120
+ def _simple(name: str) -> str:
121
+ return name.rsplit(".", 1)[-1].split("#", 1)[0]
122
+
123
+
124
+ def build_data_exposure(
125
+ root: Path,
126
+ cir: "Optional[CanonicalRepositoryIR]" = None,
127
+ *,
128
+ model: Optional[Any] = None,
129
+ profiles: "Optional[set[str]]" = None,
130
+ declaration: Optional[LabelDeclaration] = None,
131
+ depth: int = 4,
132
+ ) -> dict:
133
+ """Every route that can carry a declared label, with what decides access to it."""
134
+ from sourcecode import non_coverage
135
+ from sourcecode.context_graph import ContextGraph
136
+
137
+ root = Path(root)
138
+ decl = declaration if declaration is not None else load_labels(root)
139
+
140
+ payload: dict = {
141
+ "schema_version": SCHEMA_VERSION,
142
+ "declaration": decl.to_dict(),
143
+ "profiles": sorted(profiles) if profiles else None,
144
+ }
145
+
146
+ if not decl.labels:
147
+ # No declaration is not "nothing is exposed" — it is a question nobody asked
148
+ # yet, and the counts stay null rather than reading as a clean bill (I-3/R9).
149
+ payload.update(
150
+ {
151
+ "answered": False,
152
+ "statement": (
153
+ "No data labels are declared, so this run measured nothing. A "
154
+ "label is a judgement about a domain and is never inferred from a "
155
+ "field name: declare one under `dataLabels` in "
156
+ "sourcecode.config.json and re-run."
157
+ ),
158
+ "summary": {
159
+ "labels": 0,
160
+ "seeds": 0,
161
+ "endpoints_exposed": None,
162
+ "by_evidence": {},
163
+ "by_security_verdict": {},
164
+ },
165
+ "routes": [],
166
+ "seeds": [],
167
+ "non_coverage": non_coverage.block("data_exposure"),
168
+ }
169
+ )
170
+ return payload
171
+
172
+ if cir is None:
173
+ graph = ContextGraph.build_from_root(root)
174
+ cir = graph.cir
175
+ else:
176
+ graph = ContextGraph.from_cir(cir)
177
+
178
+ from sourcecode.security_posture import endpoint_security_surface
179
+ from sourcecode.spring_impact import run_impact_chain
180
+
181
+ surface = endpoint_security_surface(cir, root=root)
182
+ profile_access: dict[str, str] = {}
183
+ if profiles:
184
+ from sourcecode.posture import endpoint_access_under
185
+
186
+ profile_access = endpoint_access_under(root, set(profiles), cir=cir)
187
+
188
+ handlers = {
189
+ str(getattr(e, "handler_symbol", "") or (e.get("handler_symbol") if isinstance(e, dict) else "")): e
190
+ for e in (getattr(cir, "endpoints", None) or [])
191
+ }
192
+ handlers.pop("", None)
193
+
194
+ # endpoint_id → (fields, labels, seeds, best evidence)
195
+ rows: dict[str, dict] = {}
196
+ seeds_report: list[dict] = []
197
+
198
+ for label in decl.labels:
199
+ for seed in label.seed_types:
200
+ found_signature = _signature_routes(graph, seed, handlers)
201
+ chain = run_impact_chain(cir, seed, root=root, model=model, depth=depth)
202
+ resolution = str(getattr(chain, "resolution", "not_found"))
203
+ reached = list(getattr(chain, "endpoints_affected", []) or [])
204
+
205
+ for endpoint in reached:
206
+ _record(rows, _endpoint_fields(endpoint), label, seed, CALL_REACH)
207
+ for fields in found_signature:
208
+ _record(rows, fields, label, seed, SIGNATURE)
209
+
210
+ seeds_report.append(
211
+ {
212
+ "label": label.label,
213
+ "declared": seed,
214
+ "resolved_symbol": str(getattr(chain, "symbol", seed)),
215
+ "resolution": resolution,
216
+ "routes_by_signature": len(found_signature),
217
+ "routes_by_call_reach": len(reached),
218
+ # A declared type the graph never resolved cannot be reported as
219
+ # "exposes nothing": nothing was measured about it (R9/I-3).
220
+ "measured": resolution != "not_found",
221
+ "note": (
222
+ "The declared type was not found in the analysed population, "
223
+ "so no exposure was measured for it — this is not evidence "
224
+ "that it is unexposed."
225
+ if resolution == "not_found" else ""
226
+ ),
227
+ }
228
+ )
229
+
230
+ routes = [
231
+ _finish_route(row, surface, profile_access, bool(profiles))
232
+ for row in rows.values()
233
+ ]
234
+ routes.sort(
235
+ key=lambda r: (
236
+ _EVIDENCE_RANK.get(r.evidence, 9), r.path, r.method, r.endpoint_id
237
+ )
238
+ )
239
+
240
+ payload.update(
241
+ {
242
+ "answered": True,
243
+ "summary": _summary(decl, seeds_report, routes, bool(profiles)),
244
+ "seeds": seeds_report,
245
+ "routes": [r.to_dict() for r in routes],
246
+ "evidence_classes": {
247
+ name: _EVIDENCE_MEANING[name] for name in (SIGNATURE, CALL_REACH)
248
+ },
249
+ "reading": (
250
+ "`signature` routes carry the labelled type as declared. "
251
+ "`call_reach` routes can reach it and are a supremum: a mapping that "
252
+ "drops the labelled member on the way out is not modelled. Neither "
253
+ "class sees a flow that never names the type — reflection, a runtime "
254
+ "serializer, an untyped payload, a native query — so a route absent "
255
+ "from this answer is not evidence that it carries nothing."
256
+ ),
257
+ "non_coverage": non_coverage.block("data_exposure"),
258
+ }
259
+ )
260
+ return payload
261
+
262
+
263
+ def _record(
264
+ rows: dict[str, dict], fields: dict, label: DataLabel, seed: str, evidence: str
265
+ ) -> None:
266
+ endpoint_id = fields["endpoint_id"]
267
+ if not endpoint_id:
268
+ return
269
+ row = rows.setdefault(
270
+ endpoint_id, {**fields, "labels": set(), "seeds": set(), "evidence": evidence}
271
+ )
272
+ row["labels"].add(label.label)
273
+ row["seeds"].add(seed)
274
+ if _EVIDENCE_RANK[evidence] < _EVIDENCE_RANK[row["evidence"]]:
275
+ row["evidence"] = evidence
276
+
277
+
278
+ def _signature_routes(graph: Any, seed: str, handlers: dict) -> list[dict]:
279
+ """Handlers whose own parameter/return types name the seed type.
280
+
281
+ Matched on the type as written, which is how the class type-reference atoms are
282
+ keyed, and on the simple name of a fully-qualified declaration — a repository
283
+ writes `Customer` in a signature and `com.example.Customer` in a configuration
284
+ file, and the label must find the route either way.
285
+ """
286
+ out: dict[str, dict] = {}
287
+ for name in {seed, _simple(seed)}:
288
+ for ref in graph.structural_references_to_type(name):
289
+ if ref.context not in _SIGNATURE_CONTEXTS:
290
+ continue
291
+ endpoint = handlers.get(ref.member_fqn)
292
+ if endpoint is None:
293
+ continue
294
+ fields = _endpoint_fields(endpoint)
295
+ out[fields["endpoint_id"]] = fields
296
+ return list(out.values())
297
+
298
+
299
+ def _finish_route(
300
+ row: dict, surface: dict, profile_access: dict, with_profile: bool
301
+ ) -> ExposedRoute:
302
+ from sourcecode.posture import access_verdict
303
+
304
+ endpoint_id = row["endpoint_id"]
305
+ bound = surface.get(endpoint_id) or {}
306
+ verdict = str(bound.get("verdict") or "coverage_unknown")
307
+ access = decision = None
308
+ if with_profile:
309
+ decision = str(profile_access.get(endpoint_id, "no_rule_matched"))
310
+ access = access_verdict(decision)
311
+ return ExposedRoute(
312
+ endpoint_id=endpoint_id,
313
+ method=row["method"],
314
+ path=row["path"],
315
+ controller_class=row["controller_class"],
316
+ handler_symbol=row["handler_symbol"],
317
+ labels=tuple(sorted(row["labels"])),
318
+ seeds=tuple(sorted(row["seeds"])),
319
+ evidence=row["evidence"],
320
+ verdict=verdict,
321
+ access=access,
322
+ access_decision=decision,
323
+ )
324
+
325
+
326
+ def _summary(
327
+ decl: LabelDeclaration,
328
+ seeds: list[dict],
329
+ routes: list[ExposedRoute],
330
+ with_profile: bool,
331
+ ) -> dict:
332
+ by_evidence: dict[str, int] = {}
333
+ by_verdict: dict[str, int] = {}
334
+ by_access: dict[str, int] = {}
335
+ for route in routes:
336
+ by_evidence[route.evidence] = by_evidence.get(route.evidence, 0) + 1
337
+ by_verdict[route.verdict] = by_verdict.get(route.verdict, 0) + 1
338
+ if route.access is not None:
339
+ by_access[route.access] = by_access.get(route.access, 0) + 1
340
+ unmeasured = [s for s in seeds if not s["measured"]]
341
+ out = {
342
+ "labels": len(decl.labels),
343
+ "seeds": len(seeds),
344
+ "seeds_not_resolved": len(unmeasured),
345
+ # Counted over the routes themselves, never over a rendered list.
346
+ "endpoints_exposed": len(routes),
347
+ "by_evidence": dict(sorted(by_evidence.items())),
348
+ "by_security_verdict": dict(sorted(by_verdict.items())),
349
+ "declaration_problems": len(decl.problems),
350
+ }
351
+ if with_profile:
352
+ out["by_access_under_profile"] = dict(sorted(by_access.items()))
353
+ return out