cloudmap 1.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.
@@ -0,0 +1,296 @@
1
+ """The queries an answer is actually made of - every one computed from the graph.
2
+
3
+ Nothing in this module asks a model anything. "What breaks if I touch X" is not a
4
+ language problem, it is a traversal: walk the edges that point AT X. Computing it
5
+ here rather than prompting for it is what makes the answer worth trusting, and it
6
+ is why a model can never promote a guess to a fact in the Ask layer - it is never
7
+ the thing producing the facts.
8
+
9
+ Trust aggregation: a conclusion is only as good as the weakest hop behind it. A
10
+ path is `verified` only when EVERY edge on it came from a deterministic rule and
11
+ no node along the way is an unverified external reference. One model-proposed hop
12
+ makes the whole conclusion a guess, and it is labelled as one.
13
+ """
14
+
15
+ from ..graph import friendly_type
16
+
17
+ QUERIES = ("impact", "depends", "paths", "shared", "guesses", "summary")
18
+
19
+
20
+ def _adjacency(graph):
21
+ """edges indexed by source (downstream) and by target (upstream)."""
22
+ down, up = {}, {}
23
+ for e in graph.edges:
24
+ down.setdefault(e.source, []).append(e)
25
+ up.setdefault(e.target, []).append(e)
26
+ return down, up
27
+
28
+
29
+ def _name(graph, nid):
30
+ node = graph.nodes.get(nid)
31
+ return (node.name if node and node.name else nid)
32
+
33
+
34
+ def _hop(graph, edge):
35
+ """One step, carrying its own proof so the reader can audit instead of trust."""
36
+ return {
37
+ "source": _name(graph, edge.source),
38
+ "target": _name(graph, edge.target),
39
+ "kind": edge.kind,
40
+ "origin": edge.origin,
41
+ "evidence": edge.evidence,
42
+ }
43
+
44
+
45
+ def _chain(subject, path):
46
+ """The ordered node ids along a path, whichever direction it was walked in."""
47
+ chain = [subject]
48
+ for e in path:
49
+ chain.append(e.target if e.source == chain[-1] else e.source)
50
+ return chain
51
+
52
+
53
+ def _trust(graph, path, subject):
54
+ """Grade a path: verified, or a guess with the reason it is one.
55
+
56
+ Only the hops in BETWEEN count as crossings. That an endpoint is an unverified
57
+ external reference is already reported on the finding itself (`external`), and
58
+ the edge reaching it is real - the app genuinely names that host. Passing
59
+ *through* something unverified is different: the conclusion then rests on a
60
+ resource nobody confirmed exists, so the whole path becomes a guess.
61
+ """
62
+ for e in path:
63
+ if e.origin != "extracted":
64
+ return "unverified", (f"a model proposed the hop {_name(graph, e.source)} "
65
+ f"--{e.kind}--> {_name(graph, e.target)}")
66
+ for nid in _chain(subject, path)[1:-1]:
67
+ node = graph.nodes.get(nid)
68
+ if node is not None and node.external:
69
+ return "unverified", (f"the path passes through {node.name}, which was referenced "
70
+ "but never verified as a real resource")
71
+ return "verified", ""
72
+
73
+
74
+ def _walk(graph, start, adjacency, pick, max_hops=None):
75
+ """BFS over ONE direction, keeping the shortest path (as edges) to each node.
76
+
77
+ One direction only, never reversing - the same rule blast_radius uses, so a
78
+ shared resource (plan, vault, vnet) cannot bridge the subject to resources it
79
+ has nothing to do with."""
80
+ paths, queue, order = {start: []}, [(start, 0)], []
81
+ while queue:
82
+ cur, dist = queue.pop(0)
83
+ if max_hops is not None and dist >= max_hops:
84
+ continue
85
+ for e in adjacency.get(cur, []):
86
+ nxt = pick(e)
87
+ if nxt in paths or nxt not in graph.nodes:
88
+ continue
89
+ paths[nxt] = paths[cur] + [e]
90
+ order.append(nxt)
91
+ queue.append((nxt, dist + 1))
92
+ return paths, order
93
+
94
+
95
+ def _findings(graph, subject, paths, order):
96
+ out = []
97
+ for nid in order:
98
+ path = paths[nid]
99
+ node = graph.nodes[nid]
100
+ trust, why = _trust(graph, path, subject)
101
+ out.append({
102
+ "id": nid,
103
+ "name": node.name,
104
+ "type": friendly_type(node.type),
105
+ "hops": len(path),
106
+ # what the number MEANS for this query - the renderer should not have to
107
+ # guess whether a count is hops, steps or dependents.
108
+ "metric": f"{len(path)} hop(s)",
109
+ "trust": trust,
110
+ "why": why,
111
+ "external": node.external,
112
+ "path": [_hop(graph, e) for e in path],
113
+ })
114
+ out.sort(key=lambda f: (f["hops"], f["name"]))
115
+ return out
116
+
117
+
118
+ def _tally(findings):
119
+ return {
120
+ "total": len(findings),
121
+ "verified": sum(1 for f in findings if f["trust"] == "verified"),
122
+ "unverified": sum(1 for f in findings if f["trust"] != "verified"),
123
+ }
124
+
125
+
126
+ def impact(graph, subject, max_hops=None):
127
+ """What breaks if `subject` changes: everything that depends ON it (upstream)."""
128
+ down, up = _adjacency(graph)
129
+ paths, order = _walk(graph, subject, up, lambda e: e.source, max_hops=max_hops)
130
+ findings = _findings(graph, subject, paths, order)
131
+ name = _name(graph, subject)
132
+ headline = (f"Nothing in this map depends on {name}."
133
+ if not findings else
134
+ f"{len(findings)} resource(s) depend on {name} - changing it can break them.")
135
+ result = {"query": "impact", "subject": subject, "subject_name": name,
136
+ "headline": headline, "findings": findings, "facts": _tally(findings)}
137
+ # An empty answer is easy to misread as "nothing to see here" when the map was
138
+ # simply walked the other way round. Say what IS there - a fact, not a guess.
139
+ if not findings and down.get(subject):
140
+ result["hint"] = (f"{name} does have {len(down[subject])} dependency(ies) of its own - "
141
+ f"ask what {name} depends on.")
142
+ return result
143
+
144
+
145
+ def depends(graph, subject, max_hops=None):
146
+ """What `subject` itself needs to work (downstream)."""
147
+ down, up = _adjacency(graph)
148
+ paths, order = _walk(graph, subject, down, lambda e: e.target, max_hops=max_hops)
149
+ findings = _findings(graph, subject, paths, order)
150
+ name = _name(graph, subject)
151
+ headline = (f"{name} has no dependencies in this map."
152
+ if not findings else
153
+ f"{name} depends on {len(findings)} resource(s).")
154
+ result = {"query": "depends", "subject": subject, "subject_name": name,
155
+ "headline": headline, "findings": findings, "facts": _tally(findings)}
156
+ if not findings and up.get(subject):
157
+ result["hint"] = (f"{len(up[subject])} resource(s) do depend on {name} - "
158
+ f"ask what breaks if you touch {name}.")
159
+ return result
160
+
161
+
162
+ def paths(graph, subject, target, max_paths=25, max_depth=8):
163
+ """Every way `subject` reaches `target`, following dependency direction."""
164
+ down, _up = _adjacency(graph)
165
+ found, stack = [], [(subject, [], {subject})]
166
+ truncated = False
167
+ while stack:
168
+ cur, path, seen = stack.pop()
169
+ if len(path) >= max_depth:
170
+ continue
171
+ for e in down.get(cur, []):
172
+ if e.target in seen:
173
+ continue
174
+ if e.target == target:
175
+ found.append(path + [e])
176
+ if len(found) >= max_paths:
177
+ truncated = True
178
+ stack = []
179
+ break
180
+ else:
181
+ stack.append((e.target, path + [e], seen | {e.target}))
182
+
183
+ findings = []
184
+ for path in sorted(found, key=len):
185
+ trust, why = _trust(graph, path, subject)
186
+ findings.append({
187
+ "id": None, "name": " -> ".join([_name(graph, subject)]
188
+ + [_name(graph, e.target) for e in path]),
189
+ "type": "path", "hops": len(path), "metric": f"{len(path)} step(s)",
190
+ "trust": trust, "why": why,
191
+ "external": False, "path": [_hop(graph, e) for e in path],
192
+ })
193
+ a, b = _name(graph, subject), _name(graph, target)
194
+ headline = (f"No path from {a} to {b} in this map."
195
+ if not findings else
196
+ f"{len(findings)} path(s) from {a} to {b}"
197
+ f"{' (list capped)' if truncated else ''}.")
198
+ facts = _tally(findings)
199
+ facts["capped"] = truncated
200
+ return {"query": "paths", "subject": subject, "subject_name": a, "target": target,
201
+ "target_name": b, "headline": headline, "findings": findings, "facts": facts}
202
+
203
+
204
+ def shared(graph, min_dependents=2):
205
+ """Resources several others depend on - where one change hits more than one team."""
206
+ _down, up = _adjacency(graph)
207
+ findings = []
208
+ for nid, edges in up.items():
209
+ node = graph.nodes.get(nid)
210
+ if node is None:
211
+ continue
212
+ dependents = sorted({f"{_name(graph, e.source)} ({e.kind})" for e in edges})
213
+ if len({e.source for e in edges}) < min_dependents:
214
+ continue
215
+ unproven = [e for e in edges if e.origin != "extracted"]
216
+ findings.append({
217
+ "id": nid, "name": node.name, "type": friendly_type(node.type),
218
+ "hops": 0, "metric": f"{len({e.source for e in edges})} dependents",
219
+ "external": node.external,
220
+ "trust": "unverified" if unproven else "verified",
221
+ "why": (f"{len(unproven)} of the incoming dependencies are model-proposed"
222
+ if unproven else ""),
223
+ "dependents": dependents,
224
+ # the dependents ARE the finding here, so listing them twice (once as a
225
+ # path, once as a name) would just be noise.
226
+ "path": [],
227
+ })
228
+ findings.sort(key=lambda f: (-len(f["dependents"]), f["name"]))
229
+ headline = ("Nothing in this map is depended on by more than one resource."
230
+ if not findings else
231
+ f"{len(findings)} shared resource(s): one change there affects several "
232
+ "dependents.")
233
+ return {"query": "shared", "subject": None, "subject_name": None,
234
+ "headline": headline, "findings": findings, "facts": _tally(findings)}
235
+
236
+
237
+ def guesses(graph):
238
+ """What NOT to trust in this map: model-proposed edges and unverified references."""
239
+ findings = []
240
+ for e in graph.edges:
241
+ if e.origin == "extracted":
242
+ continue
243
+ findings.append({
244
+ "id": None, "name": f"{_name(graph, e.source)} --{e.kind}--> {_name(graph, e.target)}",
245
+ "type": "model-proposed edge", "hops": 1, "metric": "", "trust": "unverified",
246
+ "external": False,
247
+ "why": e.evidence or "proposed by the local model, no deterministic proof",
248
+ "path": [_hop(graph, e)],
249
+ })
250
+ for node in graph.nodes.values():
251
+ if not node.external:
252
+ continue
253
+ findings.append({
254
+ "id": node.id, "name": node.name,
255
+ "type": f"unverified reference ({node.type or 'unknown type'})",
256
+ "hops": 0, "metric": "", "trust": "unverified", "external": True,
257
+ "why": node.note or "referenced by a resource but not found in the scanned scope",
258
+ "path": [],
259
+ })
260
+ headline = ("Every edge in this map was verified by a deterministic rule, and every "
261
+ "node was found in the scanned scope."
262
+ if not findings else
263
+ f"{len(findings)} item(s) in this map are NOT proven and should be treated "
264
+ "as guesses.")
265
+ return {"query": "guesses", "subject": None, "subject_name": None,
266
+ "headline": headline, "findings": findings, "facts": _tally(findings)}
267
+
268
+
269
+ def summary(graph):
270
+ """Explain the map itself: what is in it, and how much of it is proven."""
271
+ by_type = {}
272
+ for node in graph.nodes.values():
273
+ by_type[friendly_type(node.type)] = by_type.get(friendly_type(node.type), 0) + 1
274
+ model_edges = sum(1 for e in graph.edges if e.origin != "extracted")
275
+ external = sum(1 for n in graph.nodes.values() if n.external)
276
+ meta = getattr(graph, "meta", None) or {}
277
+ seed = meta.get("seed")
278
+ findings = [
279
+ {"id": None, "name": f"{count} x {label}", "type": "resource type", "hops": 0,
280
+ "metric": "", "trust": "verified", "why": "", "external": False, "path": []}
281
+ for label, count in sorted(by_type.items(), key=lambda kv: (-kv[1], kv[0]))
282
+ ]
283
+ headline = (f"{len(graph.nodes)} resource(s) and {len(graph.edges)} dependency edge(s)"
284
+ + (f", traced from {_name(graph, seed)}" if seed else "") + ".")
285
+ return {
286
+ "query": "summary", "subject": seed, "subject_name": _name(graph, seed) if seed else None,
287
+ "headline": headline, "findings": findings,
288
+ "facts": {
289
+ "resources": len(graph.nodes),
290
+ "edges": len(graph.edges),
291
+ "verified_edges": len(graph.edges) - model_edges,
292
+ "model_edges": model_edges,
293
+ "external_unverified": external,
294
+ "complete": meta.get("complete", True),
295
+ },
296
+ }