sourcecode 2.4.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,247 @@
1
+ """INTERFACES & INTEGRATIONS pack step handlers (Fase 7).
2
+
3
+ Three whole-repo inventory intents (ENDPOINT_INVENTORY, INTEGRATION_INVENTORY,
4
+ EVENT_TOPOLOGY) expressed purely as compositions of these projection steps. NO new
5
+ analyzer: every step reuses an engine already wired into the shared `KnowledgeContext`
6
+ and projects its already-computed output.
7
+
8
+ * Endpoints → `SpringSemanticModel.endpoint_index` (the SAME index the `endpoints` /
9
+ SEC audit path uses).
10
+ * Integrations → `SemanticIntegrationEngine.detect()` — the whole-repo counterpart of the
11
+ impact pack's cone-scoped `ResolveIntegrations` (same engine, no cone filter).
12
+ * Events → `SpringSemanticModel.event_graph` (publishers / listeners per event).
13
+
14
+ No step opens source or parses: the ContextGraph / SpringSemanticModel are acquired once by
15
+ the shared `KnowledgeContext`; steps only read them. Ordering is deterministic (by
16
+ identity). The terminal `CollectInterfaceEvidence` is generic across the pack.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING
21
+
22
+ from sourcecode.retrieval.query import (
23
+ CollectInterfaceEvidence,
24
+ ResolveEndpointInventory,
25
+ ResolveEventTopology,
26
+ ResolveIntegrationInventory,
27
+ Step,
28
+ )
29
+ from sourcecode.retrieval.result import (
30
+ Explanation,
31
+ KnowledgeEntity,
32
+ Observation,
33
+ Provenance,
34
+ RetrievalEvidence,
35
+ )
36
+ from sourcecode.retrieval.steps import (
37
+ SLOT_CONFIDENCE,
38
+ SLOT_ENTITIES,
39
+ SLOT_EVIDENCE,
40
+ SLOT_EXPLANATION,
41
+ SLOT_OBSERVATIONS,
42
+ SLOT_PROVENANCE,
43
+ )
44
+ from sourcecode.retrieval.steps_impact import _integration_evidence
45
+
46
+ if TYPE_CHECKING:
47
+ from sourcecode.retrieval.runtime import StepContext, StepRegistry
48
+
49
+ SLOT_SERVICES = "intf_services"
50
+
51
+
52
+ # ── endpoints (SpringSemanticModel.endpoint_index) ───────────────────────────────
53
+ def resolve_endpoint_inventory(step: Step, ctx: "StepContext") -> None:
54
+ assert isinstance(step, ResolveEndpointInventory)
55
+ index = ctx.knowledge.spring_model.endpoint_index
56
+ endpoints = [ep for eps in index.by_controller.values() for ep in eps]
57
+ endpoints.sort(key=_endpoint_id)
58
+ ctx.put(SLOT_ENTITIES, [_endpoint_entity(ep) for ep in endpoints])
59
+ ctx.put(SLOT_EVIDENCE, [_endpoint_evidence(ep) for ep in endpoints])
60
+ ctx.put(
61
+ SLOT_OBSERVATIONS,
62
+ [
63
+ Observation(name="endpoints", value=len(endpoints), detail="endpoint_index"),
64
+ Observation(
65
+ name="controllers",
66
+ value=len(index.by_controller),
67
+ detail="endpoint_index controllers",
68
+ ),
69
+ ],
70
+ )
71
+ ctx.put(SLOT_CONFIDENCE, "high" if endpoints else "medium")
72
+ _add_service(ctx, "SpringSemanticModel")
73
+
74
+
75
+ # ── integrations (SemanticIntegrationEngine — whole repo, no cone) ───────────────
76
+ def resolve_integration_inventory(step: Step, ctx: "StepContext") -> None:
77
+ assert isinstance(step, ResolveIntegrationInventory)
78
+ from sourcecode.semantic_integration_engine import SemanticIntegrationEngine
79
+
80
+ integrations = SemanticIntegrationEngine(ctx.knowledge.context_graph).detect()
81
+ integrations = sorted(integrations, key=lambda i: (i.kind, i.client, i.target or ""))
82
+ ctx.put(
83
+ SLOT_ENTITIES,
84
+ [
85
+ KnowledgeEntity(
86
+ identity=f"{i.kind}:{i.target or i.client}",
87
+ kind="integration",
88
+ score=0.0,
89
+ attributes={
90
+ "integration_kind": i.kind,
91
+ "client": i.client,
92
+ "target": i.target,
93
+ "basis": i.basis,
94
+ },
95
+ )
96
+ for i in integrations
97
+ ],
98
+ )
99
+ ctx.put(SLOT_EVIDENCE, [_integration_evidence(i) for i in integrations])
100
+ ctx.put(SLOT_OBSERVATIONS, _kind_histogram(integrations, key=lambda i: i.kind, label="integration"))
101
+ ctx.put(SLOT_CONFIDENCE, "high" if integrations else "medium")
102
+ _add_service(ctx, "SemanticIntegrationEngine")
103
+
104
+
105
+ # ── events (SpringSemanticModel.event_graph) ─────────────────────────────────────
106
+ def resolve_event_topology(step: Step, ctx: "StepContext") -> None:
107
+ assert isinstance(step, ResolveEventTopology)
108
+ eg = ctx.knowledge.spring_model.event_graph
109
+ event_types = sorted(eg.event_types)
110
+ entities = [
111
+ KnowledgeEntity(
112
+ identity=et,
113
+ kind="event_type",
114
+ score=0.0,
115
+ attributes={
116
+ "publishers": list(eg.publishers_of(et)),
117
+ "listeners": list(eg.listeners_of(et)),
118
+ "publisher_count": len(eg.publishers_of(et)),
119
+ "listener_count": len(eg.listeners_of(et)),
120
+ },
121
+ )
122
+ for et in event_types
123
+ ]
124
+ ctx.put(SLOT_ENTITIES, entities)
125
+ ctx.put(
126
+ SLOT_EVIDENCE,
127
+ [
128
+ RetrievalEvidence(
129
+ signal="event",
130
+ detail=f"{et}: {len(eg.publishers_of(et))} pub / {len(eg.listeners_of(et))} sub",
131
+ weight=1.0,
132
+ strength=1.0,
133
+ mass=1.0,
134
+ contribution=1.0,
135
+ provider="SpringSemanticModel.event_graph",
136
+ )
137
+ for et in event_types
138
+ ],
139
+ )
140
+ ctx.put(
141
+ SLOT_OBSERVATIONS,
142
+ [
143
+ Observation(name="event_types", value=len(event_types), detail="event_graph"),
144
+ Observation(name="event_edges", value=eg.total_edges, detail="event_graph total edges"),
145
+ ],
146
+ )
147
+ ctx.put(SLOT_CONFIDENCE, "high" if event_types else "medium")
148
+ _add_service(ctx, "SpringSemanticModel")
149
+
150
+
151
+ # ── terminal evidence (generic across the pack) ──────────────────────────────────
152
+ def collect_interface_evidence(step: Step, ctx: "StepContext") -> None:
153
+ """Assemble confidence, a structured explanation (no synthesized prose), and
154
+ provenance from what the resolve step produced. GENERIC across the pack."""
155
+ assert isinstance(step, CollectInterfaceEvidence)
156
+ confidence = ctx.get(SLOT_CONFIDENCE, "medium")
157
+ ctx.put(
158
+ SLOT_EXPLANATION,
159
+ Explanation(
160
+ winner=None,
161
+ score=0.0,
162
+ confidence=confidence,
163
+ top_contributions=(),
164
+ runner_up=None,
165
+ engine_explanation="",
166
+ ),
167
+ )
168
+ ctx.put(
169
+ SLOT_PROVENANCE,
170
+ Provenance(
171
+ services=tuple(ctx.get(SLOT_SERVICES, [])),
172
+ signals_used=("endpoint_index", "integration_engine", "event_graph"),
173
+ signals_missing=(),
174
+ knowledge_source="SpringSemanticModel + SemanticIntegrationEngine",
175
+ ),
176
+ )
177
+
178
+
179
+ # ── helpers ──────────────────────────────────────────────────────────────────────
180
+ def _endpoint_id(ep) -> str:
181
+ return ep.handler_symbol or f"{ep.method or ''} {ep.path or ''}".strip()
182
+
183
+
184
+ def _endpoint_entity(ep) -> KnowledgeEntity:
185
+ return KnowledgeEntity(
186
+ identity=_endpoint_id(ep),
187
+ kind="endpoint",
188
+ score=0.0,
189
+ attributes={
190
+ "method": ep.method or "",
191
+ "path": ep.path or "",
192
+ "controller_class": ep.controller_class or "",
193
+ "handler_symbol": ep.handler_symbol or "",
194
+ "security": _security_summary(ep.security),
195
+ },
196
+ )
197
+
198
+
199
+ def _endpoint_evidence(ep) -> RetrievalEvidence:
200
+ return RetrievalEvidence(
201
+ signal="endpoint",
202
+ detail=f"{ep.method or ''} {ep.path or ''}".strip(),
203
+ weight=1.0,
204
+ strength=1.0,
205
+ mass=1.0,
206
+ contribution=1.0,
207
+ provider="SpringSemanticModel.endpoint_index",
208
+ source_file=ep.source_file or None,
209
+ )
210
+
211
+
212
+ def _security_summary(security) -> dict:
213
+ """Compact, JSON-safe projection of the endpoint's CanonicalSecurity (no new
214
+ analysis — just the engine's own fields)."""
215
+ if security is None:
216
+ return {}
217
+ return {
218
+ "policy": getattr(security, "policy", None),
219
+ "source_scope": getattr(security, "source_scope", None),
220
+ "effective_roles": list(getattr(security, "effective_roles", []) or []),
221
+ }
222
+
223
+
224
+ def _kind_histogram(items, *, key, label: str) -> list:
225
+ hist: dict[str, int] = {}
226
+ for it in items:
227
+ k = key(it)
228
+ hist[k] = hist.get(k, 0) + 1
229
+ return [
230
+ Observation(name=f"{label}_{k}", value=n, detail=f"{label} count")
231
+ for k, n in sorted(hist.items())
232
+ ]
233
+
234
+
235
+ def _add_service(ctx: "StepContext", name: str) -> None:
236
+ services = list(ctx.get(SLOT_SERVICES, []))
237
+ if name not in services:
238
+ services.append(name)
239
+ ctx.put(SLOT_SERVICES, services)
240
+
241
+
242
+ def register_intf_steps(registry: "StepRegistry") -> None:
243
+ """Register the INTERFACES & INTEGRATIONS handlers (the sanctioned extension seam)."""
244
+ registry.register(ResolveEndpointInventory.kind, resolve_endpoint_inventory)
245
+ registry.register(ResolveIntegrationInventory.kind, resolve_integration_inventory)
246
+ registry.register(ResolveEventTopology.kind, resolve_event_topology)
247
+ registry.register(CollectInterfaceEvidence.kind, collect_interface_evidence)
@@ -0,0 +1,338 @@
1
+ """SUBSYSTEMS & DEPENDENCIES pack step handlers (Fase 8).
2
+
3
+ Four intents (SUBSYSTEM_INVENTORY, SUBSYSTEM_MEMBERS, MODULE_DEPENDENTS,
4
+ MODULE_DEPENDENCIES) expressed purely as compositions of these projection steps. NO new
5
+ analyzer: every step reuses knowledge already built into the shared `KnowledgeContext`.
6
+
7
+ * Subsystems → `repo_ir['subsystems']` (the SAME subsystem partition the `impact` /
8
+ cross-module engine uses).
9
+ * Dependencies → the module graph (`SourceMap.module_graph`, built by `GraphAnalyzer` —
10
+ the SAME graph the `archetype` / graph commands use). Dependents/dependencies are the
11
+ reverse/forward edges of a scope, read directly off the built graph.
12
+
13
+ The two dependency intents are ANCHORED: a shared `ResolveScope` step binds the request's
14
+ subsystem / module / package anchor to a scope token, which the projection step matches
15
+ against the graph's OWN node ids (a structural membership filter over the built index —
16
+ never a fuzzy text scan of source). Cycle *enumeration* is intentionally NOT here: the
17
+ graph engine only counts import cycles, so surfacing the members would require new
18
+ analysis — out of scope for a reuse-only pack.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ from sourcecode.retrieval.errors import StepContractError
25
+ from sourcecode.retrieval.query import (
26
+ CollectStructureEvidence,
27
+ ResolveModuleDependencies,
28
+ ResolveModuleDependents,
29
+ ResolveScope,
30
+ ResolveSubsystemInventory,
31
+ ResolveSubsystemMembers,
32
+ ResolveTypeDependents,
33
+ Step,
34
+ )
35
+ from sourcecode.retrieval.result import (
36
+ Explanation,
37
+ KnowledgeEntity,
38
+ Observation,
39
+ Provenance,
40
+ RetrievalEvidence,
41
+ )
42
+ from sourcecode.retrieval.steps import (
43
+ SLOT_CONFIDENCE,
44
+ SLOT_ENTITIES,
45
+ SLOT_EVIDENCE,
46
+ SLOT_EXPLANATION,
47
+ SLOT_OBSERVATIONS,
48
+ SLOT_PROVENANCE,
49
+ )
50
+ from sourcecode.retrieval.steps_impact import SLOT_TARGET
51
+
52
+ if TYPE_CHECKING:
53
+ from sourcecode.retrieval.runtime import StepContext, StepRegistry
54
+
55
+ SLOT_SCOPE = "structure_scope"
56
+ SLOT_SERVICES = "structure_services"
57
+ _SCOPE_ANCHOR_KINDS = ("subsystem", "module", "package")
58
+ _MODULE_PREFIX = "module:"
59
+
60
+
61
+ # ── shared scope-binding step (anchored intents) ─────────────────────────────────
62
+ def resolve_scope(step: Step, ctx: "StepContext") -> None:
63
+ """Bind the request's subsystem/module/package anchor to a scope token. The token is
64
+ matched structurally against the subsystem partition / graph node ids downstream."""
65
+ assert isinstance(step, ResolveScope)
66
+ anchor = next((a for a in ctx.anchors if a.anchor.kind in _SCOPE_ANCHOR_KINDS), None)
67
+ if anchor is None:
68
+ raise StepContractError(
69
+ "subsystem/dependency intents require a 'subsystem', 'module', or 'package' anchor"
70
+ )
71
+ ctx.put(SLOT_SCOPE, anchor.identities[0] if anchor.identities else anchor.anchor.raw)
72
+
73
+
74
+ # ── subsystems (repo_ir['subsystems']) ───────────────────────────────────────────
75
+ def resolve_subsystem_inventory(step: Step, ctx: "StepContext") -> None:
76
+ assert isinstance(step, ResolveSubsystemInventory)
77
+ subsystems = ctx.knowledge.repo_ir.get("subsystems", []) or []
78
+ ordered = sorted(subsystems, key=lambda s: (-int(s.get("member_count", 0)), s.get("label", "")))
79
+ ctx.put(
80
+ SLOT_ENTITIES,
81
+ [
82
+ KnowledgeEntity(
83
+ identity=s.get("label", "") or s.get("package_prefix", ""),
84
+ kind="subsystem",
85
+ score=0.0,
86
+ attributes={
87
+ "package_prefix": s.get("package_prefix", ""),
88
+ "member_count": s.get("member_count", 0),
89
+ "summary": s.get("summary", ""),
90
+ },
91
+ )
92
+ for s in ordered
93
+ ],
94
+ )
95
+ ctx.put(
96
+ SLOT_OBSERVATIONS,
97
+ [Observation(name="subsystems", value=len(ordered), detail="repo_ir subsystem partition")],
98
+ )
99
+ ctx.put(SLOT_CONFIDENCE, "high" if ordered else "medium")
100
+ _add_service(ctx, "build_repo_ir(subsystems)")
101
+
102
+
103
+ def resolve_subsystem_members(step: Step, ctx: "StepContext") -> None:
104
+ """The member symbols of the subsystem named by the scope anchor. Reuses the repo_ir
105
+ subsystem partition; matches the scope against the subsystem label / package prefix."""
106
+ assert isinstance(step, ResolveSubsystemMembers)
107
+ scope = ctx.require(SLOT_SCOPE)
108
+ subsystems = ctx.knowledge.repo_ir.get("subsystems", []) or []
109
+ matched = [s for s in subsystems if _subsystem_matches(s, scope)]
110
+ members = [(s, m) for s in matched for m in (s.get("members", []) or [])]
111
+ ctx.put(
112
+ SLOT_ENTITIES,
113
+ [
114
+ KnowledgeEntity(
115
+ identity=m,
116
+ kind="subsystem_member",
117
+ score=0.0,
118
+ attributes={"subsystem": s.get("label", ""), "package_prefix": s.get("package_prefix", "")},
119
+ )
120
+ for s, m in members
121
+ ],
122
+ )
123
+ ctx.put(
124
+ SLOT_OBSERVATIONS,
125
+ [
126
+ Observation(name="matched_subsystems", value=len(matched), detail=f"scope={scope!r}"),
127
+ Observation(name="members", value=len(members), detail="subsystem members"),
128
+ ],
129
+ )
130
+ ctx.put(SLOT_CONFIDENCE, "high" if members else "low")
131
+ _add_service(ctx, "build_repo_ir(subsystems)")
132
+
133
+
134
+ # ── dependencies (SourceMap.module_graph) ────────────────────────────────────────
135
+ def resolve_module_dependents(step: Step, ctx: "StepContext") -> None:
136
+ """Modules that depend ON the scope: sources of edges whose target is in the scope.
137
+ Reads the built module graph directly (reverse edges)."""
138
+ assert isinstance(step, ResolveModuleDependents)
139
+ _project_edges(ctx, incoming=True)
140
+
141
+
142
+ def resolve_module_dependencies(step: Step, ctx: "StepContext") -> None:
143
+ """Modules the scope DEPENDS ON: targets of edges whose source is in the scope.
144
+ Reads the built module graph directly (forward edges)."""
145
+ assert isinstance(step, ResolveModuleDependencies)
146
+ _project_edges(ctx, incoming=False)
147
+
148
+
149
+ def _project_edges(ctx: "StepContext", *, incoming: bool) -> None:
150
+ scope = ctx.require(SLOT_SCOPE)
151
+ mg = ctx.knowledge.module_graph
152
+ if mg is None:
153
+ raise StepContractError("module graph unavailable (name-mass-only acquisition)")
154
+ scope_nodes = _match_nodes(mg, scope)
155
+ relation = "dependent" if incoming else "dependency"
156
+ hits: dict[str, set] = {}
157
+ for e in mg.edges:
158
+ source, target = getattr(e, "source", None), getattr(e, "target", None)
159
+ kind = getattr(e, "kind", "") or ""
160
+ if incoming and target in scope_nodes and source not in scope_nodes:
161
+ hits.setdefault(source, set()).add(kind)
162
+ elif not incoming and source in scope_nodes and target not in scope_nodes:
163
+ hits.setdefault(target, set()).add(kind)
164
+ ordered = sorted(hits.items(), key=lambda kv: _node_path(kv[0]))
165
+ ctx.put(
166
+ SLOT_ENTITIES,
167
+ [
168
+ KnowledgeEntity(
169
+ identity=_node_path(node),
170
+ kind="module",
171
+ score=0.0,
172
+ attributes={"relation": relation, "via": sorted(k for k in kinds if k)},
173
+ )
174
+ for node, kinds in ordered
175
+ ],
176
+ )
177
+ ctx.put(
178
+ SLOT_EVIDENCE,
179
+ [
180
+ RetrievalEvidence(
181
+ signal=f"module_{relation}",
182
+ detail=f"{_node_path(node)} ({', '.join(sorted(k for k in kinds if k))})",
183
+ weight=1.0,
184
+ strength=1.0,
185
+ mass=1.0,
186
+ contribution=1.0,
187
+ provider="GraphAnalyzer(module_graph)",
188
+ source_file=_node_path(node),
189
+ )
190
+ for node, kinds in ordered
191
+ ],
192
+ )
193
+ ctx.put(
194
+ SLOT_OBSERVATIONS,
195
+ [
196
+ Observation(name="scope_nodes", value=len(scope_nodes), detail=f"scope={scope!r}"),
197
+ Observation(name=f"{relation}s", value=len(ordered), detail="module graph edges"),
198
+ ],
199
+ )
200
+ ctx.put(SLOT_CONFIDENCE, "high" if scope_nodes else "low")
201
+ _add_service(ctx, "GraphAnalyzer(module_graph)")
202
+
203
+
204
+ # ── type dependents (repo_ir reverse graph) ──────────────────────────────────────
205
+ def resolve_type_dependents(step: Step, ctx: "StepContext") -> None:
206
+ """The type-level dependents of the target symbol: the reverse-graph edges
207
+ (imports / implements / injects / contained_in) that point AT the target. Reuses the
208
+ repo_ir reverse graph and the impact pack's ResolveTarget (cross-pack step reuse)."""
209
+ assert isinstance(step, ResolveTypeDependents)
210
+ target = ctx.require(SLOT_TARGET)
211
+ reverse = ctx.knowledge.repo_ir.get("reverse_graph", {}) or {}
212
+ matched = {k: v for k, v in reverse.items() if _type_matches(k, target)}
213
+ dependents: dict[str, set] = {}
214
+ for edges in matched.values():
215
+ for edge_kind, fqns in (edges or {}).items():
216
+ for fqn in fqns or []:
217
+ dependents.setdefault(fqn, set()).add(edge_kind)
218
+ ordered = sorted(dependents.items(), key=lambda kv: kv[0])
219
+ ctx.put(
220
+ SLOT_ENTITIES,
221
+ [
222
+ KnowledgeEntity(
223
+ identity=fqn,
224
+ kind="type_dependent",
225
+ score=0.0,
226
+ attributes={"relation": "dependent", "via": sorted(kinds)},
227
+ )
228
+ for fqn, kinds in ordered
229
+ ],
230
+ )
231
+ ctx.put(
232
+ SLOT_EVIDENCE,
233
+ [
234
+ RetrievalEvidence(
235
+ signal="type_dependent",
236
+ detail=f"{fqn} ({', '.join(sorted(kinds))})",
237
+ weight=1.0,
238
+ strength=1.0,
239
+ mass=1.0,
240
+ contribution=1.0,
241
+ provider="build_repo_ir(reverse_graph)",
242
+ )
243
+ for fqn, kinds in ordered
244
+ ],
245
+ )
246
+ ctx.put(
247
+ SLOT_OBSERVATIONS,
248
+ [
249
+ Observation(name="matched_types", value=len(matched), detail=f"target={target!r}"),
250
+ Observation(name="dependents", value=len(ordered), detail="reverse graph edges"),
251
+ ],
252
+ )
253
+ ctx.put(SLOT_CONFIDENCE, "high" if matched else "low")
254
+ _add_service(ctx, "build_repo_ir(reverse_graph)")
255
+
256
+
257
+ # ── terminal evidence (generic across the pack) ──────────────────────────────────
258
+ def collect_structure_evidence(step: Step, ctx: "StepContext") -> None:
259
+ """Assemble confidence, a structured explanation (no synthesized prose), and
260
+ provenance from what the resolve step produced. GENERIC across the pack."""
261
+ assert isinstance(step, CollectStructureEvidence)
262
+ confidence = ctx.get(SLOT_CONFIDENCE, "medium")
263
+ ctx.put(
264
+ SLOT_EXPLANATION,
265
+ Explanation(
266
+ winner=None,
267
+ score=0.0,
268
+ confidence=confidence,
269
+ top_contributions=(),
270
+ runner_up=None,
271
+ engine_explanation="",
272
+ ),
273
+ )
274
+ ctx.put(
275
+ SLOT_PROVENANCE,
276
+ Provenance(
277
+ services=tuple(ctx.get(SLOT_SERVICES, [])),
278
+ signals_used=("subsystems", "module_graph"),
279
+ signals_missing=(),
280
+ knowledge_source="repo IR (subsystems) + module graph (GraphAnalyzer)",
281
+ ),
282
+ )
283
+
284
+
285
+ # ── helpers ──────────────────────────────────────────────────────────────────────
286
+ def _subsystem_matches(subsystem: dict, scope: str) -> bool:
287
+ label = (subsystem.get("label", "") or "").lower()
288
+ prefix = (subsystem.get("package_prefix", "") or "").lower()
289
+ s = scope.lower()
290
+ return s == label or s == prefix or s in label or s in prefix
291
+
292
+
293
+ def _type_matches(key: str, target: str) -> bool:
294
+ """Match a reverse-graph type key against the target symbol: exact FQN, or the target
295
+ is the key's simple name / a dotted suffix. Structural, over the built index."""
296
+ if key == target:
297
+ return True
298
+ simple = key.rsplit(".", 1)[-1]
299
+ return target == simple or key.endswith("." + target)
300
+
301
+
302
+ def _normalize_scope(scope: str) -> str:
303
+ """A package token (dotted) becomes a path fragment so it matches the graph's
304
+ file-path node ids; a raw path fragment is used as-is."""
305
+ return scope.replace(".", "/") if "/" not in scope and "." in scope else scope
306
+
307
+
308
+ def _match_nodes(mg, scope: str) -> set:
309
+ frag = _normalize_scope(scope).lower()
310
+ return {
311
+ getattr(n, "id", None)
312
+ for n in mg.nodes
313
+ if frag in _node_path(getattr(n, "id", None)).lower()
314
+ }
315
+
316
+
317
+ def _node_path(node_id) -> str:
318
+ if not node_id:
319
+ return ""
320
+ return node_id[len(_MODULE_PREFIX):] if node_id.startswith(_MODULE_PREFIX) else node_id
321
+
322
+
323
+ def _add_service(ctx: "StepContext", name: str) -> None:
324
+ services = list(ctx.get(SLOT_SERVICES, []))
325
+ if name not in services:
326
+ services.append(name)
327
+ ctx.put(SLOT_SERVICES, services)
328
+
329
+
330
+ def register_struct_steps(registry: "StepRegistry") -> None:
331
+ """Register the SUBSYSTEMS & DEPENDENCIES handlers (the sanctioned extension seam)."""
332
+ registry.register(ResolveScope.kind, resolve_scope)
333
+ registry.register(ResolveSubsystemInventory.kind, resolve_subsystem_inventory)
334
+ registry.register(ResolveSubsystemMembers.kind, resolve_subsystem_members)
335
+ registry.register(ResolveModuleDependents.kind, resolve_module_dependents)
336
+ registry.register(ResolveModuleDependencies.kind, resolve_module_dependencies)
337
+ registry.register(ResolveTypeDependents.kind, resolve_type_dependents)
338
+ registry.register(CollectStructureEvidence.kind, collect_structure_evidence)