sourcecode 3.1.0__py3-none-any.whl → 3.2.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.

Potentially problematic release.


This version of sourcecode might be problematic. Click here for more details.

@@ -0,0 +1,232 @@
1
+ """Migration blast radius — which request surface a migration finding sits under.
2
+
3
+ `migrate-check` answers *what must change*: the files whose `javax.*` imports, XML
4
+ descriptors and build coordinates block a Spring Boot 2→3 upgrade. It does not answer
5
+ *what has to be re-tested when that change lands*, which is the question a migration
6
+ plan is actually built around.
7
+
8
+ This module answers it as a rule over the existing IR — no new detector, no second
9
+ traversal engine. Each affected file resolves to the types it declares, those types are
10
+ run through the same caller BFS `impact-chain` uses (`spring_impact._bfs_callers` +
11
+ `_collect_endpoints`), and the reachable HTTP endpoints become that file's re-test set.
12
+
13
+ Honesty contract:
14
+ * A `javax→jakarta` finding breaks compilation whether or not an endpoint reaches it.
15
+ `endpoints_reached` ranks *regression scope*, never "what fails to build".
16
+ * A file whose declared types the IR never resolved is reported in `files_unresolved`
17
+ with reach `unknown` — never folded into the zero-reach bucket.
18
+ * A repository the IR sees no endpoints in gets `request_surface_share: null` and a
19
+ note saying ranking fell back to caller counts, never a manufactured 0.0.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING, Any, Optional
25
+
26
+ from sourcecode.spring_impact import _bfs_callers, _collect_endpoints
27
+
28
+ if TYPE_CHECKING: # pragma: no cover - typing only
29
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
30
+ from sourcecode.migrate_check import MigrationFinding
31
+ from sourcecode.spring_model import SpringSemanticModel
32
+
33
+ SCHEMA_VERSION = "1.0"
34
+
35
+ DEFAULT_DEPTH = 4
36
+ DEFAULT_TOP_N = 25
37
+ _ENDPOINTS_PER_FILE = 10
38
+
39
+ _SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
40
+ _TYPE_KINDS = ("class", "interface", "enum", "annotation")
41
+
42
+ _REACH_NOTE = (
43
+ "endpoints_reached ranks REGRESSION SCOPE — the requests whose call path runs "
44
+ "through the file — not what fails to build. A javax→jakarta finding breaks "
45
+ "compilation whether or not any endpoint reaches it."
46
+ )
47
+
48
+
49
+ def _types_by_file(cir: "CanonicalRepositoryIR") -> dict[str, list[str]]:
50
+ """Map source file → the type FQNs declared in it, from the IR's graph nodes."""
51
+ raw = getattr(cir, "_raw_ir", None) or {}
52
+ nodes = (raw.get("graph") or {}).get("nodes") or []
53
+ out: dict[str, list[str]] = {}
54
+ for node in nodes:
55
+ if not isinstance(node, dict):
56
+ continue
57
+ src = node.get("source_file") or ""
58
+ fqn = node.get("fqn") or ""
59
+ if not src or not fqn or "#" in fqn:
60
+ continue
61
+ if (node.get("symbol_kind") or node.get("type")) not in _TYPE_KINDS:
62
+ continue
63
+ out.setdefault(src, []).append(fqn)
64
+ for src in out:
65
+ out[src] = sorted(set(out[src]))
66
+ return out
67
+
68
+
69
+ def _max_severity(findings: list["MigrationFinding"]) -> str:
70
+ return min(
71
+ (f.severity for f in findings),
72
+ key=lambda s: _SEVERITY_ORDER.get(s, 3),
73
+ default="low",
74
+ )
75
+
76
+
77
+ def compute_migration_blast(
78
+ cir: "CanonicalRepositoryIR",
79
+ model: "SpringSemanticModel",
80
+ findings: list["MigrationFinding"],
81
+ *,
82
+ depth: int = DEFAULT_DEPTH,
83
+ top_n: int = DEFAULT_TOP_N,
84
+ ) -> dict:
85
+ """Resolve the request surface behind each migration finding.
86
+
87
+ Args:
88
+ cir: CanonicalRepositoryIR for the same repository that was scanned.
89
+ model: SpringSemanticModel built from that CIR (endpoint index).
90
+ findings: MigrationFinding list from `run_migrate_check`.
91
+ depth: Caller BFS depth, the same knob `impact-chain --depth` exposes.
92
+ top_n: Files retained in `files`; the rest are counted in `files_omitted`.
93
+
94
+ Returns a JSON-serializable dict. Never raises on a partial IR: anything it
95
+ could not resolve is reported as unresolved rather than dropped.
96
+ """
97
+ by_file: dict[str, list["MigrationFinding"]] = {}
98
+ non_source = 0
99
+ non_product = 0
100
+ for f in findings:
101
+ src = getattr(f, "source_file", "") or ""
102
+ if not src.endswith(".java"):
103
+ # XML descriptors and build coordinates carry no type the call graph
104
+ # can reach; they are counted, never ranked.
105
+ non_source += 1
106
+ continue
107
+ if getattr(f, "code_context", "main") != "main":
108
+ # Test and generated sources are outside the product blast radius, the
109
+ # same boundary blocking_count draws.
110
+ non_product += 1
111
+ continue
112
+ by_file.setdefault(src, []).append(f)
113
+
114
+ declared = _types_by_file(cir)
115
+ impl_graph = getattr(cir, "implementation_graph", None)
116
+ reverse_graph = getattr(cir, "reverse_graph", None) or {}
117
+ endpoints_in_repo = len(getattr(cir, "endpoints", None) or [])
118
+
119
+ rows: list[dict[str, Any]] = []
120
+ unresolved: list[str] = []
121
+ reached_ids: set[str] = set()
122
+
123
+ for src in sorted(by_file):
124
+ seeds = declared.get(src) or []
125
+ if not seeds:
126
+ unresolved.append(src)
127
+ continue
128
+ direct, indirect, truncated, *_rest = _bfs_callers(
129
+ seeds, reverse_graph, depth, impl_graph=impl_graph,
130
+ )
131
+ endpoints = _collect_endpoints(list(direct) + list(indirect), seeds, model)
132
+ for ep in endpoints:
133
+ reached_ids.add(ep.endpoint_id)
134
+ file_findings = by_file[src]
135
+ shown = sorted(
136
+ (e.to_dict() for e in endpoints),
137
+ key=lambda e: (e["path"], e["method"], e["endpoint_id"]),
138
+ )
139
+ rows.append({
140
+ "file": src,
141
+ "max_severity": _max_severity(file_findings),
142
+ "finding_count": len(file_findings),
143
+ "rule_ids": sorted({f.rule_id for f in file_findings}),
144
+ "types": seeds,
145
+ "direct_callers": len(direct),
146
+ "indirect_callers": len(indirect),
147
+ "traversal_truncated": bool(truncated),
148
+ "endpoints_reached": len(endpoints),
149
+ "endpoints": shown[:_ENDPOINTS_PER_FILE],
150
+ "endpoints_omitted": max(0, len(shown) - _ENDPOINTS_PER_FILE),
151
+ })
152
+
153
+ rows.sort(key=lambda r: (
154
+ -r["endpoints_reached"],
155
+ -(r["direct_callers"] + r["indirect_callers"]),
156
+ _SEVERITY_ORDER.get(r["max_severity"], 3),
157
+ r["file"],
158
+ ))
159
+
160
+ on_path = sum(1 for r in rows if r["endpoints_reached"] > 0)
161
+ notes: list[str] = [_REACH_NOTE]
162
+ if endpoints_in_repo:
163
+ share = round(len(reached_ids) / endpoints_in_repo, 4)
164
+ else:
165
+ share = None
166
+ notes.append(
167
+ "The IR resolved no HTTP endpoints in this repository, so no request "
168
+ "surface can be attributed — files are ranked by caller count alone."
169
+ )
170
+ if unresolved:
171
+ notes.append(
172
+ f"{len(unresolved)} file(s) carry a finding but declare no type the IR "
173
+ "resolved (parse gap or non-type source); their reach is unknown, not zero."
174
+ )
175
+ if any(r["traversal_truncated"] for r in rows):
176
+ notes.append(
177
+ "At least one traversal hit the hub-class guard and was capped — those "
178
+ "files' reach is a lower bound."
179
+ )
180
+
181
+ return {
182
+ "schema_version": SCHEMA_VERSION,
183
+ "depth": depth,
184
+ "endpoints_in_repo": endpoints_in_repo,
185
+ "endpoints_reached": len(reached_ids),
186
+ "request_surface_share": share,
187
+ "files_analyzed": len(rows),
188
+ "files_on_request_path": on_path,
189
+ "files_off_request_path": len(rows) - on_path,
190
+ "files_unresolved": sorted(unresolved),
191
+ "findings_not_source": non_source,
192
+ "findings_not_product": non_product,
193
+ "files": rows[:top_n],
194
+ "files_omitted": max(0, len(rows) - top_n),
195
+ "notes": notes,
196
+ }
197
+
198
+
199
+ def render_blast_text(blast: Optional[dict], *, top_n: int = 10) -> str:
200
+ """Render the blast-radius block as the text-format section."""
201
+ if not blast:
202
+ return ""
203
+ lines: list[str] = ["Migration blast radius (regression scope)"]
204
+ share = blast.get("request_surface_share")
205
+ if share is None:
206
+ lines.append(
207
+ f" {blast.get('endpoints_in_repo', 0)} endpoints in IR — "
208
+ "no request surface attributable; ranked by callers."
209
+ )
210
+ else:
211
+ lines.append(
212
+ f" {blast.get('endpoints_reached', 0)}/{blast.get('endpoints_in_repo', 0)} "
213
+ f"endpoints reached ({share * 100:.1f}% of the request surface)"
214
+ )
215
+ lines.append(
216
+ f" Files on a request path: {blast.get('files_on_request_path', 0)}"
217
+ f" of {blast.get('files_analyzed', 0)} analyzed"
218
+ + (f" [{len(blast.get('files_unresolved') or [])} unresolved]"
219
+ if blast.get("files_unresolved") else "")
220
+ )
221
+ for row in (blast.get("files") or [])[:top_n]:
222
+ lines.append(
223
+ f" {row['endpoints_reached']:>4} endpoints "
224
+ f"[{row['max_severity'].upper()}] {row['file']} "
225
+ f"({row['direct_callers']}+{row['indirect_callers']} callers)"
226
+ )
227
+ omitted = blast.get("files_omitted", 0) + max(0, len(blast.get("files") or []) - top_n)
228
+ if omitted:
229
+ lines.append(f" +{omitted} more file(s)")
230
+ for note in blast.get("notes") or []:
231
+ lines.append(f" note: {note}")
232
+ return "\n".join(lines)
@@ -26,6 +26,10 @@ from typing import Any, Iterable, Optional
26
26
  from sourcecode.caller_metrics import CALLER_METRIC_RECONCILIATION
27
27
  from sourcecode.endpoint_metrics import ENDPOINT_SURFACE_RECONCILIATION
28
28
  from sourcecode.fqn_utils import normalize_owner_fqn as _normalize_owner_fqn
29
+ from sourcecode.spring_semantic import (
30
+ boundaries_declared_within as _boundaries_declared_within,
31
+ build_tx_index_from_ir as _build_tx_index_from_ir,
32
+ )
29
33
  from sourcecode.path_filters import (
30
34
  is_test_path as _is_test_path,
31
35
  is_test_fixture_module_path as _is_test_fixture_module_path,
@@ -6803,6 +6807,7 @@ def compute_blast_radius(
6803
6807
  "security_surface_affected": [],
6804
6808
  "cross_module_impact": [],
6805
6809
  "transactional_boundaries_touched": [],
6810
+ "transactional_boundaries_reaching": [],
6806
6811
  "risk_score": 0.0,
6807
6812
  "risk_level": "unknown",
6808
6813
  "confidence_score": 0.0,
@@ -6828,6 +6833,7 @@ def compute_blast_radius(
6828
6833
  "security_surface_affected": [],
6829
6834
  "cross_module_impact": [],
6830
6835
  "transactional_boundaries_touched": [],
6836
+ "transactional_boundaries_reaching": [],
6831
6837
  "risk_score": 0.0,
6832
6838
  "risk_level": "unknown",
6833
6839
  "confidence_score": 0.0,
@@ -7134,23 +7140,19 @@ def compute_blast_radius(
7134
7140
  })
7135
7141
  cross_module_impact = cross_module_impact[:10]
7136
7142
 
7137
- # ── 7. Transactional boundaries touched ───────────────────────────────────
7138
- txn_nodes: list[str] = []
7139
- for node_dict in graph_nodes:
7140
- fqn = node_dict.get("fqn") or ""
7141
- role = node_dict.get("role") or ""
7142
- symbol_kind = node_dict.get("symbol_kind") or ""
7143
- if role == "transaction_boundary" or "Transactional" in (node_dict.get("canonical_name") or ""):
7144
- if fqn in affected_classes or _enclosing_class(fqn) in affected_classes:
7145
- txn_nodes.append(fqn)
7146
- elif symbol_kind == "method" and fqn in affected_classes:
7147
- enc = _enclosing_class(fqn)
7148
- for n2 in graph_nodes:
7149
- if n2.get("fqn") == enc and n2.get("role") == "transaction_boundary":
7150
- txn_nodes.append(fqn)
7151
- break
7152
-
7153
- txn_nodes = sorted(set(txn_nodes))
7143
+ # ── 7. Transactional boundaries ───────────────────────────────────────────
7144
+ # Both figures come from the one transaction-boundary authority (ADR-0008 R1);
7145
+ # they differ only in which symbols are asked about:
7146
+ # touched — the target and everything that calls it (what this change sits in)
7147
+ # reaching — the callers alone (what would run this change inside a transaction)
7148
+ # This used to be derived here from a graph node `role == "transaction_boundary"`
7149
+ # that no producer ever assigns, so `impact` reported 0 boundaries on repos where
7150
+ # `retrieve transactions-reaching` reported dozens, and `n_txn` never contributed
7151
+ # to the risk score or its escalation rules.
7152
+ _tx_index = _build_tx_index_from_ir(ir)
7153
+ _caller_symbols = set(direct_callers) | set(indirect_callers)
7154
+ txn_nodes = sorted({b.symbol for b in _boundaries_declared_within(_tx_index, affected_classes)})
7155
+ txn_reaching = sorted({b.symbol for b in _boundaries_declared_within(_tx_index, _caller_symbols)})
7154
7156
 
7155
7157
  # ── 8. Risk score ─────────────────────────────────────────────────────────
7156
7158
  n_direct = len(direct_callers)
@@ -7288,6 +7290,11 @@ def compute_blast_radius(
7288
7290
  "security_surface_affected": security_surface_affected,
7289
7291
  "cross_module_impact": cross_module_impact,
7290
7292
  "transactional_boundaries_touched": txn_nodes,
7293
+ # The callers-only subset: the boundaries that would run this change inside a
7294
+ # transaction. Same authority, narrower question — and the figure
7295
+ # `retrieve transactions-reaching` answers, so the two commands agree by
7296
+ # construction rather than by coincidence.
7297
+ "transactional_boundaries_reaching": txn_reaching,
7291
7298
  "depth_reached": _effective_depth, # actual BFS depth used, not the requested max
7292
7299
  "bfs_truncated": _bfs_truncated,
7293
7300
  "stats": {
@@ -7297,6 +7304,7 @@ def compute_blast_radius(
7297
7304
  "indirect_callers_sampled": _indirect_sampled,
7298
7305
  "endpoints_affected_count": n_ep,
7299
7306
  "transactional_boundaries_count": n_txn,
7307
+ "transactional_boundaries_reaching_count": len(txn_reaching),
7300
7308
  "mappers_affected_count": n_mappers,
7301
7309
  "modules_affected_count": n_modules,
7302
7310
  "security_surface_count": n_sec,
@@ -93,16 +93,18 @@ def resolve_execution_paths(step: Step, ctx: "StepContext") -> None:
93
93
  # ── transactions reaching (impact chain callers ∩ tx_index) ──────────────────────
94
94
  def resolve_transactions_reaching(step: Step, ctx: "StepContext") -> None:
95
95
  """The @Transactional boundaries whose enclosing class can reach the target through the
96
- call chain. Reuses run_impact_chain (reachability) + the tx boundary index."""
96
+ call chain. Reuses run_impact_chain (reachability) + the tx boundary index.
97
+
98
+ Membership is decided by the one transaction-boundary authority (ADR-0008 R1), the
99
+ same call `impact` makes for `transactional_boundaries_reaching` — so the two
100
+ commands answer this question with one number instead of two."""
97
101
  assert isinstance(step, ResolveTransactionsReaching)
102
+ from sourcecode.spring_semantic import boundaries_declared_within
103
+
98
104
  result = _impact_chain(ctx)
99
- caller_classes = {_enclosing_class(c) for c in result.direct_callers} | {
100
- _enclosing_class(c) for c in result.indirect_callers
101
- }
102
- boundaries = [
103
- b for b in ctx.knowledge.spring_model.tx_index.all_declared
104
- if _enclosing_class(b.symbol) in caller_classes
105
- ]
105
+ callers = set(result.direct_callers) | set(result.indirect_callers)
106
+ caller_classes = {_enclosing_class(c) for c in callers}
107
+ boundaries = boundaries_declared_within(ctx.knowledge.spring_model.tx_index, callers)
106
108
  ctx.put(
107
109
  SLOT_ENTITIES,
108
110
  [
@@ -123,7 +123,29 @@ def resolve_integration_inventory(step: Step, ctx: "StepContext") -> None:
123
123
  ],
124
124
  )
125
125
  ctx.put(SLOT_EVIDENCE, [_integration_evidence(i) for i in integrations])
126
- ctx.put(SLOT_OBSERVATIONS, _kind_histogram(integrations, key=lambda i: i.kind, label="integration"))
126
+ observations = _kind_histogram(integrations, key=lambda i: i.kind, label="integration")
127
+ # A client library the build declares but whose construct nothing imports is not an
128
+ # entry in this inventory — it is not an observed integration — but leaving it unsaid
129
+ # is how a `count: 0` came to sit in the same document as a directory client library
130
+ # in the stack block (ADR-0008 R12). Same coordinate authority `export --integrations`
131
+ # reads, so both surfaces recognize the same libraries.
132
+ from sourcecode.integration_coordinates import declared_integration_coordinates
133
+
134
+ _declared = declared_integration_coordinates(getattr(ctx.knowledge, "root", None))
135
+ _observed_kinds = {i.kind for i in integrations}
136
+ for d in _declared:
137
+ if d.kind in _observed_kinds:
138
+ continue
139
+ observations.append(Observation(
140
+ name=f"declared_not_observed:{d.kind}",
141
+ value=d.coordinate,
142
+ detail=(
143
+ f"the build declares this client at {d.evidence}; no client construct "
144
+ "found in source (a properties/YAML/XML-wired client has none) — "
145
+ "declared, not observed"
146
+ ),
147
+ ))
148
+ ctx.put(SLOT_OBSERVATIONS, observations)
127
149
  ctx.put(SLOT_CONFIDENCE, "high" if integrations else "medium")
128
150
  _add_service(ctx, "SemanticIntegrationEngine")
129
151
 
@@ -228,14 +228,18 @@ def _collect_usage(
228
228
  cir: "CanonicalRepositoryIR",
229
229
  ) -> tuple[dict[str, _AnnotationUsage], int, int, set[str]]:
230
230
  """Scan existing per-symbol annotations. Returns (usage_by_simple,
231
- total_handler_methods, total_non_handler_methods, handler_symbols).
231
+ handler_declarations, non_handler_declarations, handler_symbols).
232
232
 
233
233
  Controller-handler methods = the handler symbols of `cir.endpoints`; this is
234
234
  the authoritative endpoint surface (already structurally derived by the IR).
235
+
236
+ The two integers count **method declarations**, not distinct symbols: overloaded
237
+ methods share one FQN and are separate annotation sites, so on a real monolith 75
238
+ distinct handler symbols produced 90 declarations. Coverage below is measured
239
+ against declarations because that is what was scanned — the payload says so rather
240
+ than letting a reader assume it was endpoints (3574) or distinct methods (75).
235
241
  """
236
- handler_symbols: set[str] = {
237
- ep.handler_symbol for ep in cir.endpoints if getattr(ep, "handler_symbol", "")
238
- }
242
+ handler_symbols: set[str] = set(_handler_symbols(cir))
239
243
  controller_classes: set[str] = {
240
244
  ep.controller_class for ep in cir.endpoints if getattr(ep, "controller_class", "")
241
245
  }
@@ -389,6 +393,9 @@ def _detect_custom_gates(
389
393
  usage, total_handler, total_non_handler, _ = _collect_usage(cir)
390
394
  if total_handler == 0:
391
395
  return []
396
+ _population = endpoint_population(cir)
397
+ population_handler_methods = _population["handler_methods"]
398
+ population_endpoints = _population["endpoints"]
392
399
 
393
400
  verdicts: list[Verdict] = []
394
401
  for simple, u in sorted(usage.items()):
@@ -415,9 +422,22 @@ def _detect_custom_gates(
415
422
  # concrete name lives ONLY here, as evidence (VAI §0.5)
416
423
  "annotation": u.token,
417
424
  "coverage": round(coverage, 4),
418
- "coverage_pct": f"{round(coverage * 100)}% of controller handlers",
425
+ "coverage_pct": f"{round(coverage * 100)}% of controller handler method declarations",
426
+ # The denominator is stated in its own unit: handler *methods*, not
427
+ # endpoints. A repo with 936 handler methods can expose several thousand
428
+ # endpoints, so this percentage must never be read against an endpoint
429
+ # count (ADR-0008 — the payload states the unit, the reader does not guess).
430
+ "denominator": "controller handler method declarations",
419
431
  "handler_hits": u.handler_hits,
420
- "handler_total": total_handler,
432
+ # What the percentage was actually measured against: annotation sites on
433
+ # handler methods. Overloads share an FQN, so this is ≥ the distinct-method
434
+ # count and unrelated to the endpoint count.
435
+ "handler_declarations_total": total_handler,
436
+ # The other two units, stated beside it so no reader has to guess which one
437
+ # a percentage used — the exact confusion this payload caused in the field.
438
+ "handler_methods_total": population_handler_methods,
439
+ "endpoints_total": population_endpoints,
440
+ "handler_total": total_handler, # deprecated alias of handler_declarations_total
421
441
  "non_handler_hits": u.non_handler_hits,
422
442
  "specificity_ratio": (
423
443
  round(handler_rate / non_handler_rate, 2)
@@ -489,6 +509,106 @@ def gate_bearing_handlers(
489
509
  _GATE_COVERAGE_LIST_CAP = 200
490
510
 
491
511
 
512
+ def _handler_symbols(cir: "CanonicalRepositoryIR") -> set[str]:
513
+ """The distinct handler-method symbols behind the endpoint surface — the one place
514
+ this set is derived, so a count of it means the same thing everywhere."""
515
+ return {ep.handler_symbol for ep in cir.endpoints if getattr(ep, "handler_symbol", "")}
516
+
517
+
518
+ def endpoint_population(cir: "CanonicalRepositoryIR") -> dict:
519
+ """THE population every posture partition is measured against (ADR-0008 R1).
520
+
521
+ Two units live in this projection and they are not interchangeable: a repository
522
+ has more *endpoints* than *handler methods*, because one method can answer several
523
+ mappings. Reporting a count of one next to a count of the other under names that
524
+ both read as "handlers" is how a payload came to show 698 + 2635 against a total of
525
+ 3574 — three figures, no stated unit, and no way for a reader to tell which
526
+ denominator any percentage used.
527
+
528
+ Every count derived from either population MUST come from here and MUST say which
529
+ one it is.
530
+ """
531
+ return {
532
+ "endpoints": len(cir.endpoints),
533
+ "handler_methods": len(_handler_symbols(cir)),
534
+ "note": (
535
+ "Two units. 'endpoints' counts mapped routes (the denominator for every "
536
+ "endpoint partition below); 'handler_methods' counts the distinct methods "
537
+ "serving them. One method can serve several endpoints — measured on a real "
538
+ "monolith, 220 endpoints came from 75 methods — so the two never have to "
539
+ "match, and a figure is only comparable to others in its own unit."
540
+ ),
541
+ }
542
+
543
+
544
+ def unknown_endpoint_surface(reason: str, declared_policy: str = "") -> dict:
545
+ """The honest answer for an endpoint this authority cannot speak about.
546
+
547
+ Absence of a posture entry is absence of evidence: it is `coverage_unknown`
548
+ with the reason, never a coverage claim (ADR-0008 R9). The declared per-method
549
+ policy, if known, travels along as evidence — it is not the answer.
550
+ """
551
+ return {
552
+ "verdict": "coverage_unknown",
553
+ "confidence": Confidence.UNKNOWN.value,
554
+ "declared_policy": declared_policy or "unknown",
555
+ "reason": reason,
556
+ }
557
+
558
+
559
+ def endpoint_security_surface(
560
+ cir: "CanonicalRepositoryIR",
561
+ *,
562
+ root: Optional[Path] = None,
563
+ posture: Optional[Any] = None,
564
+ ) -> dict[str, dict]:
565
+ """THE per-endpoint security answer, keyed by ``endpoint_id`` (ADR-0008 R1).
566
+
567
+ One question — "what is known about the security of this endpoint?" — had two
568
+ producers: this module's four-way verdict, and `impact-chain`, which printed the
569
+ raw per-method policy under the name `security_policy`. Measured on Keycloak
570
+ (2026-07-28): the posture projection called 406 endpoints ``protected_custom``,
571
+ and **324 of those carry the declared policy ``none_detected``** because their
572
+ gate is a bespoke annotation with no standard vocabulary. `impact-chain` reported
573
+ exactly that string for the same `endpoint_id` — so one run of one tool answered
574
+ "guarded by a custom mechanism (Likely)" and "no security detected" about
575
+ ``DELETE /admin/realms/{realm}``. The declared policy is a fact about an
576
+ annotation; it was never an answer about coverage.
577
+
578
+ Consumers bind here instead of re-deriving. ``posture`` accepts an already-computed
579
+ :class:`SecurityPostureResult` or its ``to_dict()`` form, so a caller that has run
580
+ the security audit reuses that derivation rather than paying for a second one.
581
+
582
+ Never raises: any structural gap yields :func:`unknown_endpoint_surface`.
583
+ """
584
+ if posture is None:
585
+ try:
586
+ posture = infer_security_posture(cir, root=root)
587
+ except Exception:
588
+ return {}
589
+
590
+ verdicts: Any
591
+ if isinstance(posture, dict):
592
+ verdicts = posture.get("endpoints") or []
593
+ else:
594
+ verdicts = getattr(posture, "endpoint_verdicts", None) or []
595
+
596
+ out: dict[str, dict] = {}
597
+ for v in verdicts:
598
+ if not isinstance(v, dict):
599
+ continue
600
+ ep_id = v.get("endpoint_id") or ""
601
+ if not ep_id:
602
+ continue
603
+ out[ep_id] = {
604
+ "verdict": v.get("verdict") or "coverage_unknown",
605
+ "confidence": v.get("confidence") or Confidence.UNKNOWN.value,
606
+ # Evidence, not answer: the guard vocabulary declared on the handler.
607
+ "declared_policy": v.get("policy") or "unknown",
608
+ }
609
+ return out
610
+
611
+
492
612
  @dataclass
493
613
  class SecurityPostureResult:
494
614
  """Repo-level security-posture projection (P1-A)."""
@@ -499,6 +619,9 @@ class SecurityPostureResult:
499
619
  endpoint_verdicts: list[dict] = field(default_factory=list)
500
620
  rollup: dict = field(default_factory=dict)
501
621
  limitations: list[str] = field(default_factory=list)
622
+ # The population every partition in this projection is measured against, stated
623
+ # once so a reader never has to infer a denominator from a percentage.
624
+ population: dict = field(default_factory=dict)
502
625
  # Gate-coverage projection (P1-A follow-up): when a custom gate IS the detected
503
626
  # mechanism, which controller handlers do NOT carry it and are not standard-guarded
504
627
  # — the residue that relies solely on a centralized filter, if one exists. None when
@@ -522,6 +645,7 @@ class SecurityPostureResult:
522
645
  self.interception.to_dict() if self.interception else None
523
646
  ),
524
647
  "endpoints": self.endpoint_verdicts,
648
+ "population": dict(self.population),
525
649
  "rollup": self.rollup,
526
650
  **({"gate_coverage": self.gate_coverage} if self.gate_coverage else {}),
527
651
  "limitations": list(self.limitations),
@@ -552,6 +676,7 @@ def infer_security_posture(
552
676
  Never raises; never emits a categorical "unsecured" verdict.
553
677
  """
554
678
  security_model = str(cir.metadata.get("security_model", "unknown"))
679
+ population = endpoint_population(cir)
555
680
 
556
681
  # (0) config: names never drive detection — only upgrade confidence.
557
682
  configured: dict[str, Any] = {}
@@ -588,6 +713,11 @@ def infer_security_posture(
588
713
  # escape set: handlers that do NOT carry the detected custom gate and have no
589
714
  # standard guard of their own — the residue that relies solely on a filter.
590
715
  escaping: list[dict] = []
716
+ # Three-way partition of the SAME population (endpoints), counted in the one loop
717
+ # that visits every endpoint exactly once, so the parts sum to the whole by
718
+ # construction rather than by three producers agreeing after the fact.
719
+ n_gated = 0 # carries the detected custom gate
720
+ n_standard_only = 0 # no custom gate, but a standard guard of its own
591
721
 
592
722
  for ep in cir.endpoints:
593
723
  policy = ep.security.policy if ep.security is not None else "none_detected"
@@ -640,12 +770,12 @@ def infer_security_posture(
640
770
 
641
771
  # escape set (only meaningful when a custom gate is the mechanism): a handler
642
772
  # that neither carries the gate nor a standard guard nor a config-custom policy.
643
- if (
644
- has_custom_gate
645
- and not carries_gate
646
- and policy != "custom"
647
- and not _is_standard_guard_policy(policy)
648
- ):
773
+ _gated = carries_gate or policy == "custom"
774
+ if _gated:
775
+ n_gated += 1
776
+ elif _is_standard_guard_policy(policy):
777
+ n_standard_only += 1
778
+ if has_custom_gate and not _gated and not _is_standard_guard_policy(policy):
649
779
  escaping.append({
650
780
  "endpoint_id": ep.id,
651
781
  "method": ep.method,
@@ -692,13 +822,26 @@ def infer_security_posture(
692
822
  else:
693
823
  no_pattern += 1
694
824
 
695
- total_handlers = len(cir.endpoints)
825
+ total_handlers = population["endpoints"]
696
826
  not_covered = len(escaping)
697
827
  _patterns = (
698
828
  filter_surface.patterns if filter_surface and not filter_surface.is_empty() else []
699
829
  )
700
830
  gate_coverage = {
701
831
  "gate_annotations": sorted(gate_names),
832
+ # ── the endpoint partition: three parts, one population, they sum ──────
833
+ "population": "endpoints",
834
+ "endpoints_total": population["endpoints"],
835
+ "endpoints_carrying_gate": n_gated,
836
+ "endpoints_standard_guarded": n_standard_only,
837
+ "endpoints_not_carrying_gate": not_covered,
838
+ # ── a different unit, named as one ────────────────────────────────────
839
+ "handler_methods_total": population["handler_methods"],
840
+ "handler_methods_carrying_gate": len(gate_bearers),
841
+ # ── deprecated aliases (two-minor window) ─────────────────────────────
842
+ # `total_controller_handlers` always counted endpoints, and `gate_bearing`
843
+ # counted handler methods, so the two never belonged in one sum. Kept so no
844
+ # consumer breaks; read the explicit keys above.
702
845
  "total_controller_handlers": total_handlers,
703
846
  "gate_bearing": len(gate_bearers),
704
847
  "not_carrying_gate": not_covered,
@@ -713,12 +856,14 @@ def infer_security_posture(
713
856
  escaping, key=lambda d: (d["path"] or "", d["method"] or "")
714
857
  )[:_GATE_COVERAGE_LIST_CAP],
715
858
  "note": (
716
- "Handlers that do not carry the detected custom gate and have no standard "
717
- "guard. 'filter_pattern_match' marks those a declarative servlet filter "
718
- "(@WebFilter / web.xml) pattern covers — still review, pattern presence is "
719
- "not proof of enforcement. Spring FilterRegistrationBean / HttpSecurity DSL "
720
- "/ AspectJ pointcuts are NOT reconstructed, so a missing match is not proof "
721
- "of exposure."
859
+ "Endpoints whose handler does not carry the detected custom gate and has no "
860
+ "standard guard. 'filter_pattern_match' marks those a declarative servlet "
861
+ "filter (@WebFilter / web.xml) pattern covers — still review, pattern "
862
+ "presence is not proof of enforcement. Spring FilterRegistrationBean / "
863
+ "HttpSecurity DSL / AspectJ pointcuts are NOT reconstructed, so a missing "
864
+ "match is not proof of exposure. Counts marked endpoints_* partition the "
865
+ "endpoint population and sum to endpoints_total; handler_methods_* count a "
866
+ "different unit and are not comparable to them."
722
867
  ),
723
868
  "truncated": not_covered > _GATE_COVERAGE_LIST_CAP,
724
869
  }
@@ -730,6 +875,7 @@ def infer_security_posture(
730
875
  endpoint_verdicts=verdicts,
731
876
  rollup=rollup,
732
877
  limitations=limitations,
878
+ population=population,
733
879
  gate_coverage=gate_coverage,
734
880
  )
735
881
  # keep reference to suppress-set so callers (SEC-001) can dedupe false alarms