compos-cli 0.0.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.
- compos/cli/__init__.py +0 -0
- compos/cli/analyzers/__init__.py +10 -0
- compos/cli/analyzers/base.py +66 -0
- compos/cli/analyzers/docker_compose.py +137 -0
- compos/cli/analyzers/python.py +604 -0
- compos/cli/analyzers/typescript.py +823 -0
- compos/cli/git.py +92 -0
- compos/cli/main.py +464 -0
- compos/cli/watcher.py +1 -0
- compos/core/__init__.py +119 -0
- compos/core/diff.py +131 -0
- compos/core/graph.py +648 -0
- compos/core/integrity.py +346 -0
- compos/core/merge.py +289 -0
- compos/core/merge_log.py +128 -0
- compos/core/versioning.py +43 -0
- compos/core/write_pipeline.py +574 -0
- compos/schema/__init__.py +57 -0
- compos/schema/models.py +440 -0
- compos/schema/validation.py +1 -0
- compos/schema/versioning.py +1 -0
- compos/storage/__init__.py +29 -0
- compos/storage/local.py +209 -0
- compos/storage/locking.py +74 -0
- compos/storage/merge_log.py +92 -0
- compos_cli-0.0.0.dist-info/METADATA +16 -0
- compos_cli-0.0.0.dist-info/RECORD +29 -0
- compos_cli-0.0.0.dist-info/WHEEL +4 -0
- compos_cli-0.0.0.dist-info/entry_points.txt +7 -0
compos/core/graph.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""Relationship graph traversal (blast radius, cycle detection)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict, deque
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
from compos.schema import (
|
|
11
|
+
Component,
|
|
12
|
+
ComposMap,
|
|
13
|
+
Constraint,
|
|
14
|
+
ConstraintStatus,
|
|
15
|
+
Decision,
|
|
16
|
+
DecisionStatus,
|
|
17
|
+
ObjectStatus,
|
|
18
|
+
Relationship,
|
|
19
|
+
RelationshipPattern,
|
|
20
|
+
RelationshipType,
|
|
21
|
+
Risk,
|
|
22
|
+
RiskStatus,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Enums
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Direction(StrEnum):
|
|
31
|
+
"""Direction for graph traversal."""
|
|
32
|
+
|
|
33
|
+
OUTGOING = "outgoing"
|
|
34
|
+
INCOMING = "incoming"
|
|
35
|
+
BOTH = "both"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ChangeType(StrEnum):
|
|
39
|
+
"""Type of proposed change — determines blast radius direction."""
|
|
40
|
+
|
|
41
|
+
MODIFY = "modify"
|
|
42
|
+
REMOVE = "remove"
|
|
43
|
+
REPLACE = "replace"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Result dataclasses (frozen, slots)
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class ComponentContext:
|
|
53
|
+
"""Context around one or more focal components."""
|
|
54
|
+
|
|
55
|
+
focal_components: tuple[Component, ...]
|
|
56
|
+
neighbor_components: tuple[Component, ...]
|
|
57
|
+
connecting_relationships: tuple[Relationship, ...]
|
|
58
|
+
applicable_constraints: tuple[Constraint, ...]
|
|
59
|
+
applicable_risks: tuple[Risk, ...]
|
|
60
|
+
applicable_decisions: tuple[Decision, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class AffectedComponent:
|
|
65
|
+
"""A component affected by a change, with distance and path info."""
|
|
66
|
+
|
|
67
|
+
component: Component
|
|
68
|
+
hop_distance: int
|
|
69
|
+
path: tuple[str, ...]
|
|
70
|
+
relationship_types: frozenset[RelationshipType]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class BlastRadiusResult:
|
|
75
|
+
"""Result of a blast radius analysis."""
|
|
76
|
+
|
|
77
|
+
source_component: Component
|
|
78
|
+
change_type: ChangeType
|
|
79
|
+
affected: tuple[AffectedComponent, ...]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class CycleInfo:
|
|
84
|
+
"""A detected dependency cycle."""
|
|
85
|
+
|
|
86
|
+
component_ids: tuple[str, ...]
|
|
87
|
+
relationship_ids: tuple[str, ...]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class SPOFInfo:
|
|
92
|
+
"""A single point of failure candidate."""
|
|
93
|
+
|
|
94
|
+
component: Component
|
|
95
|
+
dependent_count: int
|
|
96
|
+
synchronous_dependents: tuple[Component, ...]
|
|
97
|
+
has_error_behavior: bool
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class ConstraintConflict:
|
|
102
|
+
"""A constraint that may be violated by a proposed change."""
|
|
103
|
+
|
|
104
|
+
constraint: Constraint
|
|
105
|
+
reason: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class ChangeEvaluation:
|
|
110
|
+
"""Result of evaluating a proposed change."""
|
|
111
|
+
|
|
112
|
+
cycles: tuple[CycleInfo, ...]
|
|
113
|
+
spofs: tuple[SPOFInfo, ...]
|
|
114
|
+
constraint_conflicts: tuple[ConstraintConflict, ...]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# RelationshipGraph — pre-indexed adjacency structure
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RelationshipGraph:
|
|
123
|
+
"""Pre-indexed relationship graph built from a ComposMap.
|
|
124
|
+
|
|
125
|
+
Builds adjacency indexes in __init__ by iterating relationships once.
|
|
126
|
+
All queries are then O(1) lookups or BFS/DFS over the pre-built indexes.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self, compos_map: ComposMap, *, include_removed: bool = False
|
|
131
|
+
) -> None:
|
|
132
|
+
self._map = compos_map
|
|
133
|
+
|
|
134
|
+
# Index components by id, filtering removed unless opted in
|
|
135
|
+
self._components: dict[str, Component] = {}
|
|
136
|
+
removed_component_ids: set[str] = set()
|
|
137
|
+
for comp in compos_map.components:
|
|
138
|
+
if not include_removed and comp.status == ObjectStatus.REMOVED:
|
|
139
|
+
removed_component_ids.add(comp.id)
|
|
140
|
+
continue
|
|
141
|
+
self._components[comp.id] = comp
|
|
142
|
+
|
|
143
|
+
# Index relationships, building adjacency lists in a single pass.
|
|
144
|
+
# Skip removed relationships and relationships referencing removed components.
|
|
145
|
+
self._relationships: dict[str, Relationship] = {}
|
|
146
|
+
self._outgoing: dict[str, list[Relationship]] = defaultdict(list)
|
|
147
|
+
self._incoming: dict[str, list[Relationship]] = defaultdict(list)
|
|
148
|
+
self._out_neighbors: dict[str, set[str]] = defaultdict(set)
|
|
149
|
+
self._in_neighbors: dict[str, set[str]] = defaultdict(set)
|
|
150
|
+
|
|
151
|
+
for rel in compos_map.relationships:
|
|
152
|
+
if not include_removed and rel.status == ObjectStatus.REMOVED:
|
|
153
|
+
continue
|
|
154
|
+
if not include_removed and (
|
|
155
|
+
rel.source in removed_component_ids
|
|
156
|
+
or rel.target in removed_component_ids
|
|
157
|
+
):
|
|
158
|
+
continue
|
|
159
|
+
self._relationships[rel.id] = rel
|
|
160
|
+
self._outgoing[rel.source].append(rel)
|
|
161
|
+
self._incoming[rel.target].append(rel)
|
|
162
|
+
self._out_neighbors[rel.source].add(rel.target)
|
|
163
|
+
self._in_neighbors[rel.target].add(rel.source)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def component_ids(self) -> frozenset[str]:
|
|
167
|
+
return frozenset(self._components)
|
|
168
|
+
|
|
169
|
+
def neighbors(
|
|
170
|
+
self, component_id: str, *, direction: Direction = Direction.BOTH
|
|
171
|
+
) -> frozenset[str]:
|
|
172
|
+
"""Return IDs of neighboring components. Safe for unknown IDs (empty result)."""
|
|
173
|
+
out = self._out_neighbors.get(component_id, set())
|
|
174
|
+
inc = self._in_neighbors.get(component_id, set())
|
|
175
|
+
if direction == Direction.OUTGOING:
|
|
176
|
+
return frozenset(out)
|
|
177
|
+
if direction == Direction.INCOMING:
|
|
178
|
+
return frozenset(inc)
|
|
179
|
+
return frozenset(out | inc)
|
|
180
|
+
|
|
181
|
+
def relationships_of(
|
|
182
|
+
self, component_id: str, *, direction: Direction = Direction.BOTH
|
|
183
|
+
) -> tuple[Relationship, ...]:
|
|
184
|
+
"""Return Relationship objects connected to a component."""
|
|
185
|
+
out = self._outgoing.get(component_id, [])
|
|
186
|
+
inc = self._incoming.get(component_id, [])
|
|
187
|
+
if direction == Direction.OUTGOING:
|
|
188
|
+
return tuple(out)
|
|
189
|
+
if direction == Direction.INCOMING:
|
|
190
|
+
return tuple(inc)
|
|
191
|
+
# Both: combine, dedup by id (a self-loop would appear in both lists)
|
|
192
|
+
seen: set[str] = set()
|
|
193
|
+
result: list[Relationship] = []
|
|
194
|
+
for rel in (*out, *inc):
|
|
195
|
+
if rel.id not in seen:
|
|
196
|
+
seen.add(rel.id)
|
|
197
|
+
result.append(rel)
|
|
198
|
+
return tuple(result)
|
|
199
|
+
|
|
200
|
+
def relationships_between(
|
|
201
|
+
self, source_id: str, target_id: str
|
|
202
|
+
) -> tuple[Relationship, ...]:
|
|
203
|
+
"""Return all relationships from source_id to target_id (directed)."""
|
|
204
|
+
return tuple(
|
|
205
|
+
rel
|
|
206
|
+
for rel in self._outgoing.get(source_id, [])
|
|
207
|
+
if rel.target == target_id
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def reachable(
|
|
211
|
+
self,
|
|
212
|
+
component_id: str,
|
|
213
|
+
*,
|
|
214
|
+
direction: Direction = Direction.OUTGOING,
|
|
215
|
+
max_hops: int | None = None,
|
|
216
|
+
) -> dict[str, int]:
|
|
217
|
+
"""BFS from component_id, returning {reachable_id: hop_distance}.
|
|
218
|
+
|
|
219
|
+
The source component itself is excluded from results.
|
|
220
|
+
Direction determines which adjacency index to follow.
|
|
221
|
+
max_hops limits search depth (None = unlimited).
|
|
222
|
+
"""
|
|
223
|
+
distances: dict[str, int] = {}
|
|
224
|
+
queue: deque[tuple[str, int]] = deque([(component_id, 0)])
|
|
225
|
+
visited: set[str] = {component_id}
|
|
226
|
+
|
|
227
|
+
while queue:
|
|
228
|
+
current, depth = queue.popleft()
|
|
229
|
+
if max_hops is not None and depth >= max_hops:
|
|
230
|
+
continue
|
|
231
|
+
for neighbor in self.neighbors(current, direction=direction):
|
|
232
|
+
if neighbor not in visited:
|
|
233
|
+
visited.add(neighbor)
|
|
234
|
+
distances[neighbor] = depth + 1
|
|
235
|
+
queue.append((neighbor, depth + 1))
|
|
236
|
+
|
|
237
|
+
return distances
|
|
238
|
+
|
|
239
|
+
def find_cycles(self) -> tuple[CycleInfo, ...]:
|
|
240
|
+
"""Detect all elementary cycles using iterative DFS with coloring.
|
|
241
|
+
|
|
242
|
+
WHITE = unvisited, GRAY = on current path, BLACK = fully explored.
|
|
243
|
+
A back-edge to a GRAY node reveals a cycle. The cycle path is
|
|
244
|
+
extracted from the DFS stack.
|
|
245
|
+
"""
|
|
246
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
247
|
+
color: dict[str, int] = {cid: WHITE for cid in self._components}
|
|
248
|
+
cycles: list[CycleInfo] = []
|
|
249
|
+
|
|
250
|
+
for start in self._components:
|
|
251
|
+
if color[start] != WHITE:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
# Iterative DFS: stack holds (node, iterator_over_outgoing_rels)
|
|
255
|
+
# path tracks the current DFS path for cycle extraction
|
|
256
|
+
path: list[str] = []
|
|
257
|
+
path_set: set[str] = set()
|
|
258
|
+
# rel_on_path[i] = relationship used to reach path[i] from path[i-1]
|
|
259
|
+
rel_on_path: list[str] = []
|
|
260
|
+
stack: list[tuple[str, int]] = [(start, 0)]
|
|
261
|
+
color[start] = GRAY
|
|
262
|
+
path.append(start)
|
|
263
|
+
path_set.add(start)
|
|
264
|
+
|
|
265
|
+
while stack:
|
|
266
|
+
node, idx = stack[-1]
|
|
267
|
+
out_rels = self._outgoing.get(node, [])
|
|
268
|
+
|
|
269
|
+
if idx < len(out_rels):
|
|
270
|
+
# Advance iterator
|
|
271
|
+
stack[-1] = (node, idx + 1)
|
|
272
|
+
rel = out_rels[idx]
|
|
273
|
+
neighbor = rel.target
|
|
274
|
+
|
|
275
|
+
if color.get(neighbor) == GRAY and neighbor in path_set:
|
|
276
|
+
# Back-edge → cycle found. Extract from path.
|
|
277
|
+
cycle_start = path.index(neighbor)
|
|
278
|
+
cycle_nodes = path[cycle_start:] + [neighbor]
|
|
279
|
+
cycle_rels = rel_on_path[cycle_start:] + [rel.id]
|
|
280
|
+
cycles.append(
|
|
281
|
+
CycleInfo(
|
|
282
|
+
component_ids=tuple(cycle_nodes),
|
|
283
|
+
relationship_ids=tuple(cycle_rels),
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
elif color.get(neighbor) == WHITE:
|
|
287
|
+
color[neighbor] = GRAY
|
|
288
|
+
path.append(neighbor)
|
|
289
|
+
path_set.add(neighbor)
|
|
290
|
+
rel_on_path.append(rel.id)
|
|
291
|
+
stack.append((neighbor, 0))
|
|
292
|
+
else:
|
|
293
|
+
# All neighbors explored — backtrack
|
|
294
|
+
color[node] = BLACK
|
|
295
|
+
stack.pop()
|
|
296
|
+
path.pop()
|
|
297
|
+
path_set.discard(node)
|
|
298
|
+
if rel_on_path:
|
|
299
|
+
rel_on_path.pop()
|
|
300
|
+
|
|
301
|
+
return tuple(cycles)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# High-level functions — compose graph primitives with schema lookups
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def get_component_context(
|
|
310
|
+
compos_map: ComposMap,
|
|
311
|
+
focal_component_ids: Sequence[str],
|
|
312
|
+
*,
|
|
313
|
+
include_removed: bool = False,
|
|
314
|
+
) -> ComponentContext:
|
|
315
|
+
"""Collect one-hop neighborhood + applicable constraints/risks/decisions.
|
|
316
|
+
|
|
317
|
+
Raises KeyError if any focal_component_id is not in the graph.
|
|
318
|
+
"""
|
|
319
|
+
graph = RelationshipGraph(compos_map, include_removed=include_removed)
|
|
320
|
+
|
|
321
|
+
# Validate focal IDs exist
|
|
322
|
+
for cid in focal_component_ids:
|
|
323
|
+
if cid not in graph._components:
|
|
324
|
+
msg = f"Component not found: {cid!r}"
|
|
325
|
+
raise KeyError(msg)
|
|
326
|
+
|
|
327
|
+
focal_set = set(focal_component_ids)
|
|
328
|
+
focal_components = tuple(graph._components[cid] for cid in focal_component_ids)
|
|
329
|
+
|
|
330
|
+
# Collect one-hop neighbors (excluding focal components themselves)
|
|
331
|
+
neighbor_ids: set[str] = set()
|
|
332
|
+
for cid in focal_component_ids:
|
|
333
|
+
neighbor_ids |= graph.neighbors(cid, direction=Direction.BOTH)
|
|
334
|
+
neighbor_ids -= focal_set
|
|
335
|
+
neighbor_components = tuple(
|
|
336
|
+
graph._components[nid] for nid in neighbor_ids if nid in graph._components
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Collect relationships connecting focal components to their neighbors
|
|
340
|
+
all_relevant_ids = focal_set | neighbor_ids
|
|
341
|
+
rel_set: set[str] = set()
|
|
342
|
+
rels: list[Relationship] = []
|
|
343
|
+
for cid in focal_component_ids:
|
|
344
|
+
for rel in graph.relationships_of(cid, direction=Direction.BOTH):
|
|
345
|
+
if rel.id not in rel_set:
|
|
346
|
+
rel_set.add(rel.id)
|
|
347
|
+
rels.append(rel)
|
|
348
|
+
|
|
349
|
+
# Filter constraints that apply to any focal or neighbor component
|
|
350
|
+
constraint_filter = _active_filter(compos_map, include_removed)
|
|
351
|
+
applicable_constraints = tuple(
|
|
352
|
+
c
|
|
353
|
+
for c in constraint_filter.constraints
|
|
354
|
+
if set(c.applies_to) & all_relevant_ids
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Filter risks involving any focal or neighbor component/relationship
|
|
358
|
+
applicable_risks = tuple(
|
|
359
|
+
r
|
|
360
|
+
for r in constraint_filter.risks
|
|
361
|
+
if set(r.components_involved) & all_relevant_ids
|
|
362
|
+
or set(r.relationships_involved) & rel_set
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Filter decisions affecting any focal or neighbor component/relationship
|
|
366
|
+
applicable_decisions = tuple(
|
|
367
|
+
d
|
|
368
|
+
for d in constraint_filter.decisions
|
|
369
|
+
if set(d.components_affected) & all_relevant_ids
|
|
370
|
+
or set(d.relationships_affected) & rel_set
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return ComponentContext(
|
|
374
|
+
focal_components=focal_components,
|
|
375
|
+
neighbor_components=neighbor_components,
|
|
376
|
+
connecting_relationships=tuple(rels),
|
|
377
|
+
applicable_constraints=applicable_constraints,
|
|
378
|
+
applicable_risks=applicable_risks,
|
|
379
|
+
applicable_decisions=applicable_decisions,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def get_blast_radius(
|
|
384
|
+
compos_map: ComposMap,
|
|
385
|
+
component_id: str,
|
|
386
|
+
change_type: ChangeType,
|
|
387
|
+
*,
|
|
388
|
+
include_removed: bool = False,
|
|
389
|
+
max_hops: int | None = None,
|
|
390
|
+
) -> BlastRadiusResult:
|
|
391
|
+
"""Compute which components are affected by a change.
|
|
392
|
+
|
|
393
|
+
Direction is determined by change_type:
|
|
394
|
+
- MODIFY/REPLACE → outgoing (downstream consumers)
|
|
395
|
+
- REMOVE → both directions (upstream and downstream)
|
|
396
|
+
|
|
397
|
+
Raises KeyError if component_id is not in the graph.
|
|
398
|
+
"""
|
|
399
|
+
graph = RelationshipGraph(compos_map, include_removed=include_removed)
|
|
400
|
+
|
|
401
|
+
if component_id not in graph._components:
|
|
402
|
+
msg = f"Component not found: {component_id!r}"
|
|
403
|
+
raise KeyError(msg)
|
|
404
|
+
|
|
405
|
+
# Direction depends on change type
|
|
406
|
+
if change_type == ChangeType.REMOVE:
|
|
407
|
+
direction = Direction.BOTH
|
|
408
|
+
else:
|
|
409
|
+
direction = Direction.OUTGOING
|
|
410
|
+
|
|
411
|
+
affected = _bfs_affected(graph, component_id, direction, max_hops)
|
|
412
|
+
|
|
413
|
+
return BlastRadiusResult(
|
|
414
|
+
source_component=graph._components[component_id],
|
|
415
|
+
change_type=change_type,
|
|
416
|
+
affected=tuple(sorted(affected, key=lambda a: a.hop_distance)),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def get_constraints_for_components(
|
|
421
|
+
compos_map: ComposMap,
|
|
422
|
+
component_ids: Sequence[str],
|
|
423
|
+
*,
|
|
424
|
+
include_removed: bool = False,
|
|
425
|
+
) -> tuple[Constraint, ...]:
|
|
426
|
+
"""Return constraints that apply to any of the given component IDs."""
|
|
427
|
+
target_set = set(component_ids)
|
|
428
|
+
filtered = _active_filter(compos_map, include_removed)
|
|
429
|
+
return tuple(
|
|
430
|
+
c for c in filtered.constraints if set(c.applies_to) & target_set
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def evaluate_proposed_change(
|
|
435
|
+
compos_map: ComposMap,
|
|
436
|
+
*,
|
|
437
|
+
proposed_relationships: Sequence[Relationship] = (),
|
|
438
|
+
include_removed: bool = False,
|
|
439
|
+
) -> ChangeEvaluation:
|
|
440
|
+
"""Evaluate a hypothetical change by merging proposed relationships into the map.
|
|
441
|
+
|
|
442
|
+
Builds a temporary map with proposed relationships appended, then runs
|
|
443
|
+
cycle detection, SPOF analysis, and constraint conflict checks.
|
|
444
|
+
"""
|
|
445
|
+
# Build hypothetical map with proposed relationships added
|
|
446
|
+
if proposed_relationships:
|
|
447
|
+
hypothetical = ComposMap(
|
|
448
|
+
schema_version=compos_map.schema_version,
|
|
449
|
+
map_version=compos_map.map_version,
|
|
450
|
+
generated_at=compos_map.generated_at,
|
|
451
|
+
git_commit=compos_map.git_commit,
|
|
452
|
+
parent_version=compos_map.parent_version,
|
|
453
|
+
produced_by=compos_map.produced_by,
|
|
454
|
+
project=compos_map.project,
|
|
455
|
+
components=compos_map.components,
|
|
456
|
+
relationships=compos_map.relationships + tuple(proposed_relationships),
|
|
457
|
+
constraints=compos_map.constraints,
|
|
458
|
+
risks=compos_map.risks,
|
|
459
|
+
decisions=compos_map.decisions,
|
|
460
|
+
last_merge=compos_map.last_merge,
|
|
461
|
+
)
|
|
462
|
+
else:
|
|
463
|
+
hypothetical = compos_map
|
|
464
|
+
|
|
465
|
+
graph = RelationshipGraph(hypothetical, include_removed=include_removed)
|
|
466
|
+
|
|
467
|
+
# Detect cycles in the hypothetical graph
|
|
468
|
+
cycles = graph.find_cycles()
|
|
469
|
+
|
|
470
|
+
# Detect SPOFs
|
|
471
|
+
spofs = _find_spofs(graph)
|
|
472
|
+
|
|
473
|
+
# Detect constraint conflicts: any constraint applying to a component
|
|
474
|
+
# touched by the proposed relationships
|
|
475
|
+
constraint_conflicts: list[ConstraintConflict] = []
|
|
476
|
+
if proposed_relationships:
|
|
477
|
+
# Collect component IDs touched by proposed relationships
|
|
478
|
+
touched_ids: set[str] = set()
|
|
479
|
+
for rel in proposed_relationships:
|
|
480
|
+
touched_ids.add(rel.source)
|
|
481
|
+
touched_ids.add(rel.target)
|
|
482
|
+
|
|
483
|
+
filtered = _active_filter(hypothetical, include_removed)
|
|
484
|
+
for con in filtered.constraints:
|
|
485
|
+
if set(con.applies_to) & touched_ids:
|
|
486
|
+
constraint_conflicts.append(
|
|
487
|
+
ConstraintConflict(
|
|
488
|
+
constraint=con,
|
|
489
|
+
reason=(
|
|
490
|
+
f"Proposed relationship touches constrained "
|
|
491
|
+
f"component(s): {set(con.applies_to) & touched_ids}"
|
|
492
|
+
),
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
return ChangeEvaluation(
|
|
497
|
+
cycles=cycles,
|
|
498
|
+
spofs=spofs,
|
|
499
|
+
constraint_conflicts=tuple(constraint_conflicts),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
# Private helpers
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@dataclass(frozen=True, slots=True)
|
|
509
|
+
class _FilteredObjects:
|
|
510
|
+
"""Typed container for soft-deletion-filtered ancillary objects."""
|
|
511
|
+
|
|
512
|
+
constraints: tuple[Constraint, ...]
|
|
513
|
+
risks: tuple[Risk, ...]
|
|
514
|
+
decisions: tuple[Decision, ...]
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _active_filter(
|
|
518
|
+
compos_map: ComposMap, include_removed: bool
|
|
519
|
+
) -> _FilteredObjects:
|
|
520
|
+
"""Filter constraints/risks/decisions based on soft-deletion status."""
|
|
521
|
+
if include_removed:
|
|
522
|
+
return _FilteredObjects(
|
|
523
|
+
constraints=compos_map.constraints,
|
|
524
|
+
risks=compos_map.risks,
|
|
525
|
+
decisions=compos_map.decisions,
|
|
526
|
+
)
|
|
527
|
+
return _FilteredObjects(
|
|
528
|
+
constraints=tuple(
|
|
529
|
+
c for c in compos_map.constraints
|
|
530
|
+
if c.status != ConstraintStatus.REMOVED
|
|
531
|
+
),
|
|
532
|
+
# dismissed risks are still visible — only exclude administratively removed
|
|
533
|
+
risks=tuple(
|
|
534
|
+
r for r in compos_map.risks
|
|
535
|
+
if r.status != RiskStatus.REMOVED
|
|
536
|
+
),
|
|
537
|
+
decisions=tuple(
|
|
538
|
+
d for d in compos_map.decisions
|
|
539
|
+
if d.status not in {DecisionStatus.REVERSED, DecisionStatus.REMOVED}
|
|
540
|
+
),
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _next_node(rel: Relationship, current: str, direction: Direction) -> str:
|
|
545
|
+
"""Return the neighbor reached by traversing *rel* from *current*."""
|
|
546
|
+
if direction == Direction.OUTGOING:
|
|
547
|
+
return rel.target
|
|
548
|
+
if direction == Direction.INCOMING:
|
|
549
|
+
return rel.source
|
|
550
|
+
return rel.target if rel.source == current else rel.source
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _bfs_affected(
|
|
554
|
+
graph: RelationshipGraph,
|
|
555
|
+
source_id: str,
|
|
556
|
+
direction: Direction,
|
|
557
|
+
max_hops: int | None,
|
|
558
|
+
) -> list[AffectedComponent]:
|
|
559
|
+
"""Single-pass BFS that collects distance, path, and all relationship types.
|
|
560
|
+
|
|
561
|
+
For each hop in the path, ALL relationships between the pair are inspected
|
|
562
|
+
so that parallel edges (e.g. CALL + DATA_FLOW between the same components)
|
|
563
|
+
contribute their types to the result.
|
|
564
|
+
"""
|
|
565
|
+
# parent: child_id → (parent_id, representative_rel_id)
|
|
566
|
+
parent: dict[str, tuple[str, str]] = {}
|
|
567
|
+
distances: dict[str, int] = {}
|
|
568
|
+
queue: deque[tuple[str, int]] = deque([(source_id, 0)])
|
|
569
|
+
visited: set[str] = {source_id}
|
|
570
|
+
|
|
571
|
+
while queue:
|
|
572
|
+
current, depth = queue.popleft()
|
|
573
|
+
if max_hops is not None and depth >= max_hops:
|
|
574
|
+
continue
|
|
575
|
+
for rel in graph.relationships_of(current, direction=direction):
|
|
576
|
+
next_id = _next_node(rel, current, direction)
|
|
577
|
+
if next_id not in visited:
|
|
578
|
+
visited.add(next_id)
|
|
579
|
+
distances[next_id] = depth + 1
|
|
580
|
+
parent[next_id] = (current, rel.id)
|
|
581
|
+
queue.append((next_id, depth + 1))
|
|
582
|
+
|
|
583
|
+
# Reconstruct paths and collect ALL relationship types per hop
|
|
584
|
+
result: list[AffectedComponent] = []
|
|
585
|
+
for comp_id, dist in distances.items():
|
|
586
|
+
path_ids: list[str] = []
|
|
587
|
+
rel_types: set[RelationshipType] = set()
|
|
588
|
+
node = comp_id
|
|
589
|
+
while node in parent:
|
|
590
|
+
prev, rep_rel_id = parent[node]
|
|
591
|
+
path_ids.append(rep_rel_id)
|
|
592
|
+
# Collect types from ALL relationships between prev and node
|
|
593
|
+
for rel in graph.relationships_between(prev, node):
|
|
594
|
+
rel_types.add(rel.type)
|
|
595
|
+
# For BOTH/INCOMING, also check the reverse direction
|
|
596
|
+
if direction != Direction.OUTGOING:
|
|
597
|
+
for rel in graph.relationships_between(node, prev):
|
|
598
|
+
rel_types.add(rel.type)
|
|
599
|
+
node = prev
|
|
600
|
+
path_ids.reverse()
|
|
601
|
+
result.append(
|
|
602
|
+
AffectedComponent(
|
|
603
|
+
component=graph._components[comp_id],
|
|
604
|
+
hop_distance=dist,
|
|
605
|
+
path=tuple(path_ids),
|
|
606
|
+
relationship_types=frozenset(rel_types),
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
return result
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _find_spofs(graph: RelationshipGraph) -> tuple[SPOFInfo, ...]:
|
|
614
|
+
"""Identify single points of failure in the graph.
|
|
615
|
+
|
|
616
|
+
A SPOF candidate is a component with 2+ incoming synchronous relationships.
|
|
617
|
+
The has_error_behavior flag indicates whether ALL sync dependents define
|
|
618
|
+
error_behavior — if they do, the SPOF is lower severity but still reported.
|
|
619
|
+
"""
|
|
620
|
+
spofs: list[SPOFInfo] = []
|
|
621
|
+
|
|
622
|
+
for comp_id, comp in graph._components.items():
|
|
623
|
+
incoming = graph.relationships_of(comp_id, direction=Direction.INCOMING)
|
|
624
|
+
# Filter to synchronous relationships only
|
|
625
|
+
sync_rels = [
|
|
626
|
+
r for r in incoming if r.pattern == RelationshipPattern.SYNCHRONOUS
|
|
627
|
+
]
|
|
628
|
+
if len(sync_rels) < 2:
|
|
629
|
+
continue
|
|
630
|
+
|
|
631
|
+
sync_dependents = tuple(
|
|
632
|
+
graph._components[r.source]
|
|
633
|
+
for r in sync_rels
|
|
634
|
+
if r.source in graph._components
|
|
635
|
+
)
|
|
636
|
+
# has_error_behavior is True only if ALL sync rels define it
|
|
637
|
+
has_error_behavior = all(r.error_behavior is not None for r in sync_rels)
|
|
638
|
+
|
|
639
|
+
spofs.append(
|
|
640
|
+
SPOFInfo(
|
|
641
|
+
component=comp,
|
|
642
|
+
dependent_count=len(sync_rels),
|
|
643
|
+
synchronous_dependents=sync_dependents,
|
|
644
|
+
has_error_behavior=has_error_behavior,
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
return tuple(spofs)
|