sourcecode 2.3.0__py3-none-any.whl → 2.5.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,242 @@
1
+ """Step handlers — the reusable, single-responsibility execution units (Fase 4 §3).
2
+
3
+ Each handler is a pure procedure over `(step, StepContext)`: it reads knowledge and/or
4
+ prior slots, and writes its output into the shared blackboard (or the terminal result).
5
+ Handlers are the ONLY place execution logic lives; steps themselves are inert data and
6
+ intents are declarative plans. A handler:
7
+
8
+ * reuses ONLY existing engines/providers — never a new engine, never a re-parse;
9
+ * never touches the parser or source directly;
10
+ * passes results to later steps through named blackboard slots.
11
+
12
+ Fase 4 implements exactly the four handlers the two architecture intents need
13
+ (`ARCHITECTURAL_CORE`, `ARCHITECTURAL_STYLE`). Both intents reuse the SAME handlers — they
14
+ differ only in the `Characterize` dimension parameter, which is the proof that a new intent
15
+ is a new *plan*, not new *runtime*. Steps for other catalogue intents (Resolve, BlastRadius,
16
+ Transactions, Endpoints, Integrations, …) are deliberately NOT built here.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING
21
+
22
+ from sourcecode.retrieval.errors import UnknownDimensionError
23
+ from sourcecode.retrieval.query import (
24
+ BuildResult,
25
+ Characterize,
26
+ CollectEvidence,
27
+ RankByScore,
28
+ Step,
29
+ )
30
+ from sourcecode.retrieval.result import (
31
+ Explanation,
32
+ KnowledgeEntity,
33
+ KnowledgeResult,
34
+ Observation,
35
+ Provenance,
36
+ RetrievalEvidence,
37
+ )
38
+
39
+ if TYPE_CHECKING:
40
+ from sourcecode.retrieval.runtime import StepContext, StepRegistry
41
+
42
+ # ── blackboard slot keys ─────────────────────────────────────────────────────────
43
+ # Generic result slots — read by the GENERIC BuildResult; written by any pack.
44
+ SLOT_ENTITIES = "entities"
45
+ SLOT_EVIDENCE = "evidence"
46
+ SLOT_OBSERVATIONS = "observations"
47
+ SLOT_CONFIDENCE = "confidence"
48
+ SLOT_EXPLANATION = "explanation"
49
+ SLOT_PROVENANCE = "provenance"
50
+ # Archetype-pack slots.
51
+ SLOT_DIMENSION = "dimension_result"
52
+ SLOT_GRAPH_METRICS = "graph_metrics"
53
+ SLOT_SIGNALS_USED = "signals_used"
54
+ SLOT_SIGNALS_MISSING = "signals_missing"
55
+ SLOT_KNOWLEDGE_SOURCE = "knowledge_source"
56
+
57
+
58
+ # ── handlers ─────────────────────────────────────────────────────────────────────
59
+ def characterize(step: Step, ctx: "StepContext") -> None:
60
+ """Reuse `ArchetypeClassifier`; select the requested archetype dimension. Writes the
61
+ dimension result, graph metrics, and signal provenance to the blackboard."""
62
+ assert isinstance(step, Characterize)
63
+ from sourcecode.archetype import ArchetypeClassifier
64
+
65
+ analysis = ArchetypeClassifier().analyze(
66
+ ctx.knowledge.source_map,
67
+ root=ctx.knowledge.root,
68
+ module_graph=ctx.knowledge.module_graph,
69
+ )
70
+ dim = analysis.dimensions.get(step.dimension)
71
+ if dim is None:
72
+ raise UnknownDimensionError(f"archetype has no dimension {step.dimension!r}")
73
+ ctx.put(SLOT_DIMENSION, dim)
74
+ ctx.put(SLOT_GRAPH_METRICS, dict(analysis.graph_metrics))
75
+ ctx.put(SLOT_SIGNALS_USED, tuple(analysis.signals_used))
76
+ ctx.put(SLOT_SIGNALS_MISSING, tuple(analysis.signals_missing))
77
+ ctx.put(SLOT_KNOWLEDGE_SOURCE, analysis.generated_from)
78
+
79
+
80
+ def rank_by_score(step: Step, ctx: "StepContext") -> None:
81
+ """Order the dimension's candidates by their EXISTING engine scores (no new score).
82
+ Emits ranked `KnowledgeEntity`s."""
83
+ dim = ctx.require(SLOT_DIMENSION)
84
+ ranked = sorted(dim.candidates, key=lambda c: (-c.score, c.label))
85
+ ctx.put(
86
+ SLOT_ENTITIES,
87
+ [
88
+ KnowledgeEntity(
89
+ identity=c.label,
90
+ kind=f"{dim.dimension}_candidate",
91
+ score=c.score,
92
+ attributes={"is_primary": c.label == dim.primary},
93
+ )
94
+ for c in ranked
95
+ if c.score > 0
96
+ ],
97
+ )
98
+
99
+
100
+ def collect_evidence(step: Step, ctx: "StepContext") -> None:
101
+ """Assemble the winning candidate's evidence + graph-topology observations AND the
102
+ generic confidence/explanation/provenance slots the shared BuildResult consumes.
103
+ Reuses values already computed; recomputes nothing."""
104
+ dim = ctx.require(SLOT_DIMENSION)
105
+ graph_metrics = ctx.require(SLOT_GRAPH_METRICS)
106
+ winner = _winner(dim)
107
+ runner = _runner_up(dim)
108
+ evidence = [
109
+ RetrievalEvidence(
110
+ signal=e.signal,
111
+ detail=e.detail,
112
+ weight=e.weight,
113
+ strength=e.strength,
114
+ mass=e.mass,
115
+ contribution=e.contribution,
116
+ provider="ArchetypeClassifier",
117
+ )
118
+ for e in sorted(
119
+ (winner.evidence if winner is not None else []),
120
+ key=lambda e: e.contribution,
121
+ reverse=True,
122
+ )
123
+ ]
124
+ ctx.put(SLOT_EVIDENCE, evidence)
125
+ ctx.put(
126
+ SLOT_OBSERVATIONS,
127
+ [
128
+ Observation(
129
+ name=k,
130
+ value=round(v, 4),
131
+ detail="module-graph topology metric (GraphEvidenceProvider)",
132
+ )
133
+ for k, v in sorted(graph_metrics.items())
134
+ ],
135
+ )
136
+ ctx.put(SLOT_CONFIDENCE, dim.confidence)
137
+ ctx.put(
138
+ SLOT_EXPLANATION,
139
+ Explanation(
140
+ winner=dim.primary,
141
+ score=(winner.score if winner is not None else 0.0),
142
+ confidence=dim.confidence,
143
+ top_contributions=tuple((e.signal, e.contribution) for e in evidence[:3]),
144
+ runner_up=(runner.label if runner is not None else None),
145
+ engine_explanation=dim.explanation,
146
+ ),
147
+ )
148
+ ctx.put(
149
+ SLOT_PROVENANCE,
150
+ Provenance(
151
+ services=("ArchetypeClassifier", "GraphEvidenceProvider"),
152
+ signals_used=ctx.get(SLOT_SIGNALS_USED, ()),
153
+ signals_missing=ctx.get(SLOT_SIGNALS_MISSING, ()),
154
+ knowledge_source=ctx.get(SLOT_KNOWLEDGE_SOURCE, ""),
155
+ ),
156
+ )
157
+
158
+
159
+ def build_result(step: Step, ctx: "StepContext") -> None:
160
+ """GENERIC terminal step: assemble the typed `KnowledgeResult` from the generic result
161
+ slots any pack populated. Knows nothing about a specific intent — this is why one
162
+ BuildResult serves every pack (design §4.5).
163
+
164
+ Applies the request's `limit` option (B4): when set (>0) and it bites, the entity tuple
165
+ is capped and an `entities_truncated` observation records the total. `limit == 0`
166
+ (the default) is unbounded, so engine-faithful results are never silently dropped."""
167
+ default_expl = Explanation(
168
+ winner=None,
169
+ score=0.0,
170
+ confidence=ctx.get(SLOT_CONFIDENCE, "low"),
171
+ top_contributions=(),
172
+ runner_up=None,
173
+ engine_explanation="",
174
+ )
175
+ default_prov = Provenance(services=(), signals_used=(), signals_missing=(), knowledge_source="")
176
+ entities = tuple(ctx.get(SLOT_ENTITIES, []))
177
+ observations = list(ctx.get(SLOT_OBSERVATIONS, []))
178
+ limit = getattr(ctx.options, "limit", 0)
179
+ if limit and len(entities) > limit:
180
+ observations.append(
181
+ Observation(
182
+ name="entities_truncated",
183
+ value=len(entities),
184
+ detail=f"limit={limit}; returned first {limit} of {len(entities)}",
185
+ )
186
+ )
187
+ entities = entities[:limit]
188
+ ctx.set_result(
189
+ KnowledgeResult(
190
+ intent=ctx.intent.value,
191
+ entities=entities,
192
+ evidence=tuple(ctx.get(SLOT_EVIDENCE, [])),
193
+ confidence=ctx.get(SLOT_CONFIDENCE, "low"),
194
+ observations=tuple(observations),
195
+ explanation=ctx.get(SLOT_EXPLANATION, default_expl),
196
+ provenance=ctx.get(SLOT_PROVENANCE, default_prov),
197
+ query=tuple(t.step for t in ctx.trace) + (type(step).__name__,),
198
+ )
199
+ )
200
+
201
+
202
+ # ── registry wiring ──────────────────────────────────────────────────────────────
203
+ def default_registry() -> "StepRegistry":
204
+ """The default step registry: the arch handlers + the IMPACT-pack handlers. The
205
+ registry is the sanctioned extension seam — adding a pack registers its handlers here;
206
+ the Runtime engine (`runtime.py`) is unchanged. Adding an intent that composes already
207
+ registered steps needs no change even here."""
208
+ from sourcecode.retrieval.runtime import StepRegistry
209
+ from sourcecode.retrieval.steps_endpoint import register_endpoint_steps
210
+ from sourcecode.retrieval.steps_graph import register_graph_steps
211
+ from sourcecode.retrieval.steps_impact import register_impact_steps
212
+ from sourcecode.retrieval.steps_intf import register_intf_steps
213
+ from sourcecode.retrieval.steps_struct import register_struct_steps
214
+ from sourcecode.retrieval.steps_txsec import register_txsec_steps
215
+
216
+ registry = StepRegistry()
217
+ registry.register(Characterize.kind, characterize)
218
+ registry.register(RankByScore.kind, rank_by_score)
219
+ registry.register(CollectEvidence.kind, collect_evidence)
220
+ registry.register(BuildResult.kind, build_result)
221
+ register_impact_steps(registry)
222
+ register_txsec_steps(registry)
223
+ register_intf_steps(registry)
224
+ register_struct_steps(registry)
225
+ register_endpoint_steps(registry)
226
+ register_graph_steps(registry)
227
+ return registry
228
+
229
+
230
+ # ── helpers over the reused DimensionResult ─────────────────────────────────────
231
+ def _winner(dim) -> object:
232
+ if dim.primary is None:
233
+ return None
234
+ return next((c for c in dim.candidates if c.label == dim.primary), None)
235
+
236
+
237
+ def _runner_up(dim) -> object:
238
+ ranked = sorted(dim.candidates, key=lambda c: (-c.score, c.label))
239
+ for c in ranked:
240
+ if c.label != dim.primary and c.score > 0:
241
+ return c
242
+ return None
@@ -0,0 +1,311 @@
1
+ """ENDPOINT & CALL deep-dive pack step handlers (Fase 10).
2
+
3
+ Three anchored intents (ENDPOINT_CONTRACT, ENDPOINT_SECURITY, CALL_NEIGHBORHOOD)
4
+ expressed purely as compositions of these projection steps. NO new analyzer: every step
5
+ reuses an engine already wired into the shared `KnowledgeContext` and projects its
6
+ already-computed output, scoped to the requested anchor.
7
+
8
+ * Contract → `build_validation_surface` (the SAME engine `validation-points` uses),
9
+ scoped to one endpoint.
10
+ * Security → `SpringSemanticModel.endpoint_index` (per-endpoint `CanonicalSecurity`)
11
+ + `run_security_audit` findings scoped to the handler.
12
+ * Neighborhood → `SpringSemanticModel.call_adj` (forward callees + derived reverse callers
13
+ of the target symbol).
14
+
15
+ Endpoint intents bind an `endpoint` / `symbol` reference via the shared `ResolveEndpointRef`
16
+ step; CALL_NEIGHBORHOOD reuses the impact pack's `ResolveTarget` (cross-pack step reuse).
17
+ No step opens source or parses.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from typing import TYPE_CHECKING
22
+
23
+ from sourcecode.retrieval.errors import StepContractError
24
+ from sourcecode.retrieval.query import (
25
+ CollectEndpointEvidence,
26
+ ResolveCallNeighborhood,
27
+ ResolveEndpointContract,
28
+ ResolveEndpointRef,
29
+ ResolveEndpointSecurity,
30
+ Step,
31
+ )
32
+ from sourcecode.retrieval.result import (
33
+ Explanation,
34
+ KnowledgeEntity,
35
+ Observation,
36
+ Provenance,
37
+ RetrievalEvidence,
38
+ )
39
+ from sourcecode.retrieval.steps import (
40
+ SLOT_CONFIDENCE,
41
+ SLOT_ENTITIES,
42
+ SLOT_EVIDENCE,
43
+ SLOT_EXPLANATION,
44
+ SLOT_OBSERVATIONS,
45
+ SLOT_PROVENANCE,
46
+ )
47
+ from sourcecode.retrieval.steps_impact import SLOT_TARGET
48
+
49
+ if TYPE_CHECKING:
50
+ from sourcecode.retrieval.runtime import StepContext, StepRegistry
51
+
52
+ SLOT_ENDPOINT_REF = "endpoint_ref"
53
+ SLOT_SERVICES = "endpoint_services"
54
+ _REF_ANCHOR_KINDS = ("endpoint", "symbol", "path")
55
+
56
+
57
+ # ── shared endpoint-reference binding step ───────────────────────────────────────
58
+ def resolve_endpoint_ref(step: Step, ctx: "StepContext") -> None:
59
+ """Bind the request's endpoint/symbol/path anchor to a reference token, matched
60
+ structurally against the endpoint model / validation surface downstream."""
61
+ assert isinstance(step, ResolveEndpointRef)
62
+ anchor = next((a for a in ctx.anchors if a.anchor.kind in _REF_ANCHOR_KINDS), None)
63
+ if anchor is None:
64
+ raise StepContractError(
65
+ "endpoint intents require an 'endpoint', 'path', or 'symbol' anchor"
66
+ )
67
+ ctx.put(SLOT_ENDPOINT_REF, anchor.identities[0] if anchor.identities else anchor.anchor.raw)
68
+
69
+
70
+ # ── contract (build_validation_surface, scoped) ──────────────────────────────────
71
+ def resolve_endpoint_contract(step: Step, ctx: "StepContext") -> None:
72
+ assert isinstance(step, ResolveEndpointContract)
73
+ from sourcecode.validation_surface import build_validation_surface
74
+
75
+ ref = ctx.require(SLOT_ENDPOINT_REF)
76
+ surface = build_validation_surface(ctx.knowledge.root, graph=ctx.knowledge.context_graph)
77
+ endpoints = surface.get("endpoints", []) or []
78
+ matched = [ep for ep in endpoints if _endpoint_matches(ep, ref)]
79
+ ctx.put(
80
+ SLOT_ENTITIES,
81
+ [
82
+ KnowledgeEntity(
83
+ identity=_endpoint_id(ep),
84
+ kind="endpoint_contract",
85
+ score=0.0,
86
+ attributes={k: v for k, v in ep.items() if k not in ("source_file",)},
87
+ )
88
+ for ep in matched
89
+ ],
90
+ )
91
+ ctx.put(
92
+ SLOT_EVIDENCE,
93
+ [
94
+ RetrievalEvidence(
95
+ signal="endpoint_contract",
96
+ detail=_endpoint_id(ep),
97
+ weight=1.0,
98
+ strength=1.0,
99
+ mass=1.0,
100
+ contribution=1.0,
101
+ provider="build_validation_surface",
102
+ source_file=ep.get("source_file"),
103
+ )
104
+ for ep in matched
105
+ ],
106
+ )
107
+ ctx.put(
108
+ SLOT_OBSERVATIONS,
109
+ [Observation(name="matched_endpoints", value=len(matched), detail=f"ref={ref!r}")],
110
+ )
111
+ ctx.put(SLOT_CONFIDENCE, "high" if matched else "low")
112
+ _add_service(ctx, "build_validation_surface")
113
+
114
+
115
+ # ── security (endpoint_index + run_security_audit, scoped) ───────────────────────
116
+ def resolve_endpoint_security(step: Step, ctx: "StepContext") -> None:
117
+ assert isinstance(step, ResolveEndpointSecurity)
118
+ ref = ctx.require(SLOT_ENDPOINT_REF)
119
+ index = ctx.knowledge.spring_model.endpoint_index
120
+ endpoints = [ep for eps in index.by_controller.values() for ep in eps]
121
+ matched = [ep for ep in endpoints if _canonical_endpoint_matches(ep, ref)]
122
+ handlers = {ep.handler_symbol for ep in matched if ep.handler_symbol}
123
+ findings = [f for f in _security_findings(ctx) if _finding_on_handler(f, handlers)]
124
+ ctx.put(
125
+ SLOT_ENTITIES,
126
+ [
127
+ KnowledgeEntity(
128
+ identity=ep.handler_symbol or f"{ep.method or ''} {ep.path or ''}".strip(),
129
+ kind="endpoint_security",
130
+ score=0.0,
131
+ attributes={
132
+ "method": ep.method or "",
133
+ "path": ep.path or "",
134
+ "security": _security_summary(ep.security),
135
+ "findings": [f.pattern_id for f in findings if _finding_on_handler(f, {ep.handler_symbol})],
136
+ },
137
+ )
138
+ for ep in matched
139
+ ],
140
+ )
141
+ ctx.put(
142
+ SLOT_EVIDENCE,
143
+ [
144
+ RetrievalEvidence(
145
+ signal=f.pattern_id,
146
+ detail=f.title,
147
+ weight=1.0,
148
+ strength=1.0,
149
+ mass=1.0,
150
+ contribution=1.0,
151
+ provider=f"audit:{f.category}",
152
+ source_file=getattr(f, "source_file", "") or None,
153
+ )
154
+ for f in findings
155
+ ],
156
+ )
157
+ ctx.put(
158
+ SLOT_OBSERVATIONS,
159
+ [
160
+ Observation(name="matched_endpoints", value=len(matched), detail=f"ref={ref!r}"),
161
+ Observation(name="security_findings", value=len(findings), detail="run_security_audit"),
162
+ ],
163
+ )
164
+ ctx.put(SLOT_CONFIDENCE, "high" if matched else "low")
165
+ _add_service(ctx, "SpringSemanticModel")
166
+ _add_service(ctx, "run_security_audit")
167
+
168
+
169
+ # ── call neighborhood (SpringSemanticModel.call_adj) ─────────────────────────────
170
+ def resolve_call_neighborhood(step: Step, ctx: "StepContext") -> None:
171
+ """Forward callees + derived reverse callers of the target symbol. Reads the built
172
+ call adjacency directly; reuses the impact pack's ResolveTarget for the symbol."""
173
+ assert isinstance(step, ResolveCallNeighborhood)
174
+ target = ctx.require(SLOT_TARGET)
175
+ adjacency = ctx.knowledge.spring_model.call_adj.adjacency
176
+ matched_keys = [k for k in adjacency if _sym_matches(k, target)]
177
+ callees: dict[str, int] = {}
178
+ for k in matched_keys:
179
+ for callee in adjacency.get(k, []) or []:
180
+ callees[callee] = callees.get(callee, 0) + 1
181
+ callers: dict[str, int] = {}
182
+ for caller, callee_list in adjacency.items():
183
+ hits = sum(1 for c in (callee_list or []) if _sym_matches(c, target))
184
+ if hits and caller not in matched_keys:
185
+ callers[caller] = hits
186
+ entities = [
187
+ KnowledgeEntity(
188
+ identity=fqn, kind="callee", score=0.0, attributes={"direction": "out", "count": n}
189
+ )
190
+ for fqn, n in sorted(callees.items())
191
+ ] + [
192
+ KnowledgeEntity(
193
+ identity=fqn, kind="caller", score=0.0, attributes={"direction": "in", "count": n}
194
+ )
195
+ for fqn, n in sorted(callers.items())
196
+ ]
197
+ ctx.put(SLOT_ENTITIES, entities)
198
+ ctx.put(
199
+ SLOT_OBSERVATIONS,
200
+ [
201
+ Observation(name="matched_symbols", value=len(matched_keys), detail=f"target={target!r}"),
202
+ Observation(name="callees", value=len(callees), detail="forward call edges"),
203
+ Observation(name="callers", value=len(callers), detail="reverse call edges"),
204
+ ],
205
+ )
206
+ ctx.put(SLOT_CONFIDENCE, "high" if matched_keys else "low")
207
+ _add_service(ctx, "SpringSemanticModel")
208
+
209
+
210
+ # ── terminal evidence (generic across the pack) ──────────────────────────────────
211
+ def collect_endpoint_evidence(step: Step, ctx: "StepContext") -> None:
212
+ """Assemble confidence, a structured explanation (no synthesized prose), and
213
+ provenance from what the resolve step produced. GENERIC across the pack."""
214
+ assert isinstance(step, CollectEndpointEvidence)
215
+ confidence = ctx.get(SLOT_CONFIDENCE, "medium")
216
+ ctx.put(
217
+ SLOT_EXPLANATION,
218
+ Explanation(
219
+ winner=None,
220
+ score=0.0,
221
+ confidence=confidence,
222
+ top_contributions=(),
223
+ runner_up=None,
224
+ engine_explanation="",
225
+ ),
226
+ )
227
+ ctx.put(
228
+ SLOT_PROVENANCE,
229
+ Provenance(
230
+ services=tuple(ctx.get(SLOT_SERVICES, [])),
231
+ signals_used=("endpoint_index", "validation_surface", "security_audit", "call_adj"),
232
+ signals_missing=(),
233
+ knowledge_source="SpringSemanticModel + validation/security engines",
234
+ ),
235
+ )
236
+
237
+
238
+ # ── helpers ──────────────────────────────────────────────────────────────────────
239
+ def _security_findings(ctx: "StepContext") -> list:
240
+ from sourcecode.spring_security_audit import run_security_audit
241
+
242
+ return run_security_audit(
243
+ ctx.knowledge.context_graph.cir, root=ctx.knowledge.root, model=ctx.knowledge.spring_model
244
+ ).findings
245
+
246
+
247
+ def _endpoint_id(ep: dict) -> str:
248
+ return (
249
+ ep.get("handler")
250
+ or f"{ep.get('method', '')} {ep.get('path', '')}".strip()
251
+ or ep.get("path", "")
252
+ or "?"
253
+ )
254
+
255
+
256
+ def _ref_matches(path: str, handler: str, ref: str) -> bool:
257
+ """A path ref (starts with '/') matches the endpoint's path exactly (trailing slash
258
+ normalized); anything else is a handler symbol match. No substring matching — that
259
+ would let '/' match every endpoint."""
260
+ if ref.startswith("/"):
261
+ return path.rstrip("/") == ref.rstrip("/")
262
+ return ref == handler or _sym_matches(handler, ref)
263
+
264
+
265
+ def _endpoint_matches(ep: dict, ref: str) -> bool:
266
+ return _ref_matches(ep.get("path", "") or "", ep.get("handler", "") or "", ref)
267
+
268
+
269
+ def _canonical_endpoint_matches(ep, ref: str) -> bool:
270
+ return _ref_matches(ep.path or "", ep.handler_symbol or "", ref)
271
+
272
+
273
+ def _finding_on_handler(f, handlers: set) -> bool:
274
+ sym = getattr(f, "symbol", "") or ""
275
+ return any(sym == h or (h and (sym.startswith(h) or h.startswith(sym))) for h in handlers if h)
276
+
277
+
278
+ def _sym_matches(fqn: str, target: str) -> bool:
279
+ if not fqn:
280
+ return False
281
+ if fqn == target or fqn.endswith("." + target) or fqn.endswith("#" + target):
282
+ return True
283
+ simple = fqn.rsplit("#", 1)[0].rsplit(".", 1)[-1]
284
+ return simple == target
285
+
286
+
287
+ def _security_summary(security) -> dict:
288
+ if security is None:
289
+ return {}
290
+ return {
291
+ "policy": getattr(security, "policy", None),
292
+ "source_scope": getattr(security, "source_scope", None),
293
+ "effective_roles": list(getattr(security, "effective_roles", []) or []),
294
+ "expression": getattr(security, "expression", "") or "",
295
+ }
296
+
297
+
298
+ def _add_service(ctx: "StepContext", name: str) -> None:
299
+ services = list(ctx.get(SLOT_SERVICES, []))
300
+ if name not in services:
301
+ services.append(name)
302
+ ctx.put(SLOT_SERVICES, services)
303
+
304
+
305
+ def register_endpoint_steps(registry: "StepRegistry") -> None:
306
+ """Register the ENDPOINT & CALL deep-dive handlers (the sanctioned extension seam)."""
307
+ registry.register(ResolveEndpointRef.kind, resolve_endpoint_ref)
308
+ registry.register(ResolveEndpointContract.kind, resolve_endpoint_contract)
309
+ registry.register(ResolveEndpointSecurity.kind, resolve_endpoint_security)
310
+ registry.register(ResolveCallNeighborhood.kind, resolve_call_neighborhood)
311
+ registry.register(CollectEndpointEvidence.kind, collect_endpoint_evidence)