blast-scope 0.3.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,3 @@
1
+ """blast-scope: contextual blast radius scoring for shell commands."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,117 @@
1
+ """Weighted PageRank over the dependency graph — pure Python, no numpy/scipy.
2
+
3
+ Raw in-degree (how many edges point at a file) is a shallow proxy for blast
4
+ radius: a file imported by two leaf scripts looks identical to one imported by
5
+ two hub modules that the whole app routes through. PageRank fixes this — a node
6
+ is important if *important* nodes depend on it, computed transitively over the
7
+ whole graph.
8
+
9
+ Edges run ``source → target`` meaning "source depends on target" (``a.py``
10
+ ``IMPORTS_FROM`` ``b.py`` is an edge ``a → b``). So importance accumulates on
11
+ the depended-upon ``target`` side, which is exactly the blast-radius signal we
12
+ want: deleting a high-PageRank file breaks a lot.
13
+
14
+ Edge kinds are weighted — a runtime ``IMPORTS_FROM`` carries more structural
15
+ consequence than a ``TESTED_BY`` or ``CONTAINS`` edge.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ # Structural significance of each edge kind. Higher = a heavier dependency.
21
+ EDGE_WEIGHTS: dict[str, float] = {
22
+ "IMPORTS_FROM": 1.0,
23
+ "DEPENDS_ON": 1.0,
24
+ "INHERITS": 0.9,
25
+ "IMPLEMENTS": 0.9,
26
+ "CALLS": 0.8,
27
+ "REFERENCES": 0.5,
28
+ "CONTAINS": 0.3,
29
+ "TESTED_BY": 0.2,
30
+ }
31
+ DEFAULT_EDGE_WEIGHT: float = 0.5
32
+
33
+
34
+ def edge_weight(kind: str) -> float:
35
+ """Return the structural weight for an edge kind.
36
+
37
+ Example::
38
+
39
+ >>> edge_weight("IMPORTS_FROM")
40
+ 1.0
41
+ >>> edge_weight("MYSTERY_EDGE")
42
+ 0.5
43
+ """
44
+ return EDGE_WEIGHTS.get(kind, DEFAULT_EDGE_WEIGHT)
45
+
46
+
47
+ def pagerank(
48
+ edges: list[tuple[str, str, str]],
49
+ *,
50
+ damping: float = 0.85,
51
+ max_iter: int = 100,
52
+ tol: float = 1.0e-6,
53
+ ) -> dict[str, float]:
54
+ """Compute weighted PageRank over a dependency graph.
55
+
56
+ Args:
57
+ edges: ``(source, target, kind)`` triples. ``source`` depends on
58
+ ``target``; importance flows toward ``target``.
59
+ damping: Probability of following an edge vs. teleporting (classic 0.85).
60
+ max_iter: Hard cap on power-iteration steps.
61
+ tol: L1-convergence threshold for early stopping.
62
+
63
+ Returns:
64
+ ``{qualified_name: score}`` with scores normalized so the most central
65
+ node is ``1.0``. Returns ``{}`` for an empty graph.
66
+
67
+ Example::
68
+
69
+ >>> pr = pagerank([("a", "c", "IMPORTS_FROM"), ("b", "c", "IMPORTS_FROM")])
70
+ >>> max(pr, key=pr.get)
71
+ 'c'
72
+ """
73
+ if not edges:
74
+ return {}
75
+
76
+ # Build node set and weighted outgoing adjacency.
77
+ nodes: set[str] = set()
78
+ out: dict[str, list[tuple[str, float]]] = {}
79
+ out_weight: dict[str, float] = {}
80
+ for source, target, kind in edges:
81
+ nodes.add(source)
82
+ nodes.add(target)
83
+ w = edge_weight(kind)
84
+ out.setdefault(source, []).append((target, w))
85
+ out_weight[source] = out_weight.get(source, 0.0) + w
86
+
87
+ n = len(nodes)
88
+ if n == 0:
89
+ return {}
90
+
91
+ base = (1.0 - damping) / n
92
+ rank: dict[str, float] = {node: 1.0 / n for node in nodes}
93
+
94
+ for _ in range(max_iter):
95
+ # Dangling nodes (no outgoing edges) redistribute their mass uniformly.
96
+ dangling = sum(rank[node] for node in nodes if out_weight.get(node, 0.0) == 0.0)
97
+ dangling_share = damping * dangling / n
98
+
99
+ new_rank: dict[str, float] = {node: base + dangling_share for node in nodes}
100
+ for source, targets in out.items():
101
+ src_rank = rank[source]
102
+ total_w = out_weight[source]
103
+ if total_w == 0.0:
104
+ continue
105
+ contribution = damping * src_rank / total_w
106
+ for target, w in targets:
107
+ new_rank[target] += contribution * w
108
+
109
+ delta = sum(abs(new_rank[node] - rank[node]) for node in nodes)
110
+ rank = new_rank
111
+ if delta < tol:
112
+ break
113
+
114
+ peak = max(rank.values())
115
+ if peak <= 0.0:
116
+ return {node: 0.0 for node in nodes}
117
+ return {node: score / peak for node, score in rank.items()}
@@ -0,0 +1,129 @@
1
+ """Consequence *classes* — per-command-class blast-radius analyzers.
2
+
3
+ This generalizes the proven consequence-analyzer pattern (``vcs`` / ``infra`` /
4
+ ``config_refs``) into a registry of command *classes* (git, docker, pip/uv,
5
+ SQL), each running two stages so the common case stays cheap:
6
+
7
+ 1. **triage** — a near-free check ("is this my class, and is it destructive?").
8
+ Pure string/flag inspection, *no* subprocess. The vast majority of commands
9
+ match nothing here and exit immediately.
10
+ 2. **assess** — run only for triaged candidates. Performs a strictly
11
+ side-effect-free *probe* when one is available, otherwise falls back to a
12
+ labeled heuristic estimate. Either way it returns a :class:`Consequence`
13
+ floor the scorer already knows how to consume.
14
+
15
+ The eligibility filter is structural: a class is only allowed a live probe when
16
+ both (a) a safe, side-effect-free read can observe the impact and (b) its
17
+ reversibility is authorable in a static table. State-mutating probes are out of
18
+ scope by construction — :meth:`ConsequenceClass.probe_commands` declares exactly
19
+ what a class may run, and the test-suite asserts that surface is read-only.
20
+
21
+ Adding a class = implement the protocol and list it in :func:`registry`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Protocol, runtime_checkable
30
+
31
+ from blast_scope.command_parser import ParsedCommand
32
+ from blast_scope.consequences import Consequence
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Candidate:
39
+ """Stage-1 result: a destructive operation a class recognized in a command.
40
+
41
+ Example::
42
+
43
+ Candidate(cls="git", operation="reset_hard", raw="git reset --hard")
44
+ """
45
+
46
+ cls: str # class name, e.g. "git"
47
+ operation: str # class-specific op id, e.g. "reset_hard" | "volume_rm"
48
+ raw: str # the original command segment (probes may re-derive operands)
49
+ operands: tuple[str, ...] = field(default_factory=tuple)
50
+
51
+
52
+ @runtime_checkable
53
+ class ConsequenceClass(Protocol):
54
+ """A command class that can triage and assess its destructive operations."""
55
+
56
+ name: str
57
+
58
+ def triage(self, raw: str, parsed: ParsedCommand) -> Candidate | None:
59
+ """Cheaply decide if ``raw`` is a destructive op of this class.
60
+
61
+ Must be near-free (no subprocess / network). Returns a
62
+ :class:`Candidate` for destructive matches, else ``None``.
63
+ """
64
+ ...
65
+
66
+ def probe_commands(self, candidate: Candidate) -> list[list[str]]:
67
+ """The exact, **read-only** operations ``assess`` may issue to probe impact.
68
+
69
+ Each entry is a token list — a shell command (``["docker", "ps", ...]``)
70
+ or a query (``["SELECT", "count(*) ..."]``). Declared separately so tests
71
+ can assert the probe surface never mutates state. A class that probes only
72
+ by reading files (no command/query) returns ``[]``.
73
+ """
74
+ ...
75
+
76
+ def assess(self, candidate: Candidate, cwd: Path) -> Consequence | None:
77
+ """Probe (if safe + available) or estimate; return a floor consequence.
78
+
79
+ Must never raise for an unavailable probe — degrade to a heuristic and
80
+ mark the result ``estimated=True``.
81
+ """
82
+ ...
83
+
84
+
85
+ def registry() -> list[ConsequenceClass]:
86
+ """Return the registered consequence classes.
87
+
88
+ Imported lazily so the package stays import-cycle-free and classes that
89
+ shell out are only loaded when consequence analysis actually runs.
90
+ """
91
+ from blast_scope.classes.docker import DockerClass
92
+ from blast_scope.classes.git import GitClass
93
+ from blast_scope.classes.packages import PackagesClass
94
+ from blast_scope.classes.sql import SqlClass
95
+
96
+ return [GitClass(), DockerClass(), PackagesClass(), SqlClass()]
97
+
98
+
99
+ def gather_classes(parsed: ParsedCommand, raw: str, cwd: Path | str) -> list[Consequence]:
100
+ """Triage every class against one command; assess only the matches.
101
+
102
+ This is the integration point ``consequences.gather`` calls. Triage is
103
+ cheap and runs for all classes; the (potentially probing) ``assess`` runs
104
+ only for triaged destructive candidates. Any class error degrades to
105
+ "no consequence" — analysis is advisory and must never block a command.
106
+
107
+ Example::
108
+
109
+ >>> gather_classes(parse_command("git reset --hard"), "git reset --hard", cwd)
110
+ [Consequence(domain='vcs', floor=0.6, ...)]
111
+ """
112
+ cwd_path = Path(cwd)
113
+ out: list[Consequence] = []
114
+ for cls in registry():
115
+ try:
116
+ candidate = cls.triage(raw, parsed)
117
+ except Exception: # a triage bug must never break assessment
118
+ logger.debug("triage failed for class %s", getattr(cls, "name", cls), exc_info=True)
119
+ continue
120
+ if candidate is None:
121
+ continue
122
+ try:
123
+ consequence = cls.assess(candidate, cwd_path)
124
+ except Exception: # probe/heuristic bug → degrade to silent for this class
125
+ logger.debug("assess failed for %s", candidate, exc_info=True)
126
+ continue
127
+ if consequence is not None:
128
+ out.append(consequence)
129
+ return out
@@ -0,0 +1,281 @@
1
+ """Docker consequence class — container/volume blast radius.
2
+
3
+ Docker's destructive verbs split cleanly along reversibility:
4
+
5
+ - **volumes** carry data with *no image to rebuild from* — ``docker volume rm``
6
+ or a ``system prune --volumes`` is irreversible.
7
+ - **containers / images** are recreatable: ``docker rm -f`` drops a container
8
+ that its image can recreate; ``system prune -a`` removes images that are
9
+ re-pullable (a slow rebuild, not a loss).
10
+
11
+ The safe probe is the read-only daemon API (``volume inspect`` / ``ps`` /
12
+ ``volume ls`` / ``images`` — see :meth:`DockerClass.probe_commands`). When the
13
+ docker CLI is missing or the daemon is unreachable, the class degrades to a
14
+ heuristic floor and labels it ``estimated``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import shlex
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ from blast_scope.classes import Candidate
25
+ from blast_scope.command_parser import ParsedCommand
26
+ from blast_scope.consequences import Consequence
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _PROBE_TIMEOUT = 3.0
31
+ _DOCKER_OBJECTS = frozenset({"volume", "system", "container", "image", "network"})
32
+
33
+
34
+ class DockerClass:
35
+ """Consequence class for destructive docker operations."""
36
+
37
+ name = "docker"
38
+
39
+ # -- Stage 1: triage -----------------------------------------------------
40
+
41
+ def triage(self, raw: str, parsed: ParsedCommand) -> Candidate | None:
42
+ """Recognize ``docker volume rm`` / ``system prune`` / ``rm -f`` cheaply."""
43
+ if parsed.get("command") != "docker":
44
+ return None
45
+ spec = _parse_docker(raw)
46
+ if spec is None:
47
+ return None
48
+ op = _docker_op(spec)
49
+ if op is None:
50
+ return None
51
+ operation, operands = op
52
+ return Candidate(cls=self.name, operation=operation, raw=raw, operands=operands)
53
+
54
+ # -- declared read-only probe surface ------------------------------------
55
+
56
+ def probe_commands(self, candidate: Candidate) -> list[list[str]]:
57
+ """The read-only docker reads ``assess`` may run for this candidate."""
58
+ cmds: list[list[str]] = [["docker", "volume", "ls", "-q"]]
59
+ if candidate.operation == "volume_rm":
60
+ cmds += [
61
+ ["docker", "volume", "inspect", "<vol>"],
62
+ ["docker", "ps", "-a", "--filter", "volume=<vol>", "--format", "{{.Names}}"],
63
+ ]
64
+ elif candidate.operation in ("system_prune", "volume_prune"):
65
+ cmds += [
66
+ ["docker", "volume", "ls", "-f", "dangling=true", "-q"],
67
+ ["docker", "ps", "-a", "-q"],
68
+ ["docker", "images", "-q"],
69
+ ]
70
+ elif candidate.operation == "container_rm":
71
+ cmds += [["docker", "ps", "-a", "--filter", "name=<c>", "--format", "{{.Names}}"]]
72
+ return cmds
73
+
74
+ # -- Stage 2: assess -----------------------------------------------------
75
+
76
+ def assess(self, candidate: Candidate, cwd: Path) -> Consequence | None:
77
+ """Probe the daemon (if reachable) or estimate; return a floor."""
78
+ daemon = _daemon_up(cwd)
79
+ op = candidate.operation
80
+ if op == "volume_rm":
81
+ return _assess_volume_rm(candidate.operands, cwd, daemon)
82
+ if op == "volume_prune":
83
+ return _assess_volume_prune(cwd, daemon)
84
+ if op == "system_prune":
85
+ return _assess_system_prune(candidate.raw, cwd, daemon)
86
+ if op == "container_rm":
87
+ return _assess_container_rm(candidate.operands, cwd, daemon)
88
+ return None
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Parsing (pure)
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _parse_docker(raw: str) -> tuple[str, str, list[str], list[str]] | None:
97
+ """Split a docker command into (object, action, args, flags)."""
98
+ try:
99
+ tokens = shlex.split(raw)
100
+ except ValueError:
101
+ tokens = raw.split()
102
+ if not tokens:
103
+ return None
104
+ if tokens[0] == "sudo" and len(tokens) >= 2 and tokens[1] == "docker":
105
+ tokens = tokens[1:]
106
+ if not tokens or tokens[0] != "docker":
107
+ return None
108
+
109
+ rest = tokens[1:]
110
+ flags = [t for t in rest if t.startswith("-")]
111
+ positional = [t for t in rest if not t.startswith("-")]
112
+ if not positional:
113
+ return None
114
+
115
+ if positional[0] in _DOCKER_OBJECTS:
116
+ obj = positional[0]
117
+ action = positional[1] if len(positional) > 1 else ""
118
+ args = positional[2:]
119
+ else:
120
+ obj = ""
121
+ action = positional[0]
122
+ args = positional[1:]
123
+ return obj, action, args, flags
124
+
125
+
126
+ def _docker_op(spec: tuple[str, str, list[str], list[str]]) -> tuple[str, tuple[str, ...]] | None:
127
+ """Map a parsed docker command to a destructive operation id, or ``None``."""
128
+ obj, action, args, flags = spec
129
+ force = any(f in ("-f", "--force") for f in flags)
130
+ if obj == "volume" and action == "rm":
131
+ return ("volume_rm", tuple(args))
132
+ if obj == "volume" and action == "prune":
133
+ return ("volume_prune", ())
134
+ if obj == "system" and action == "prune":
135
+ return ("system_prune", ())
136
+ if action == "rm" and force and obj in ("", "container"):
137
+ return ("container_rm", tuple(args))
138
+ return None
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Probe helpers (read-only)
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ def _run_docker(cwd: Path, *args: str) -> tuple[bool, str]:
147
+ """Run a read-only docker command → (succeeded, stdout). Never raises."""
148
+ try:
149
+ result = subprocess.run(
150
+ ["docker", *args],
151
+ cwd=str(cwd),
152
+ capture_output=True,
153
+ text=True,
154
+ timeout=_PROBE_TIMEOUT,
155
+ )
156
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
157
+ return (False, "")
158
+ return (result.returncode == 0, result.stdout.strip())
159
+
160
+
161
+ def _daemon_up(cwd: Path) -> bool:
162
+ """True if the docker CLI exists and the daemon answers a read-only call."""
163
+ return _run_docker(cwd, "volume", "ls", "-q")[0]
164
+
165
+
166
+ def _count_lines(out: str) -> int:
167
+ return len([ln for ln in out.splitlines() if ln.strip()])
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Per-operation assessment
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def _assess_volume_rm(volumes: tuple[str, ...], cwd: Path, daemon: bool) -> Consequence:
176
+ names = ", ".join(volumes) or "volume(s)"
177
+ if not daemon:
178
+ return Consequence(
179
+ "docker", 0.7,
180
+ f"removing docker volume(s) {names} — if they hold data this is "
181
+ f"irreversible (daemon unreachable, could not confirm)",
182
+ estimated=True,
183
+ )
184
+
185
+ existing: list[str] = []
186
+ in_use: list[str] = []
187
+ for v in volumes:
188
+ ok, out = _run_docker(cwd, "volume", "inspect", v)
189
+ if ok and out:
190
+ existing.append(v)
191
+ users_ok, users = _run_docker(
192
+ cwd, "ps", "-a", "--filter", f"volume={v}", "--format", "{{.Names}}"
193
+ )
194
+ if users_ok and users:
195
+ in_use.append(v)
196
+
197
+ if not existing:
198
+ return Consequence(
199
+ "docker", 0.1,
200
+ f"volume(s) {names} do not exist — nothing to remove",
201
+ )
202
+ floor = 0.9 if in_use else 0.85
203
+ suffix = f" (in use by {', '.join(in_use)})" if in_use else ""
204
+ return Consequence(
205
+ "docker", floor,
206
+ f"removing volume(s) {', '.join(existing)} permanently deletes their "
207
+ f"data{suffix} — a volume has no image to rebuild from",
208
+ )
209
+
210
+
211
+ def _assess_volume_prune(cwd: Path, daemon: bool) -> Consequence:
212
+ if not daemon:
213
+ return Consequence(
214
+ "docker", 0.6,
215
+ "volume prune removes all unused volumes and their data — "
216
+ "irreversible (daemon unreachable)",
217
+ estimated=True,
218
+ )
219
+ ok, out = _run_docker(cwd, "volume", "ls", "-f", "dangling=true", "-q")
220
+ n = _count_lines(out) if ok else 0
221
+ if n == 0:
222
+ return Consequence("docker", 0.15, "no unused volumes to prune")
223
+ floor = min(0.85, 0.5 + 0.05 * n)
224
+ return Consequence(
225
+ "docker", floor,
226
+ f"volume prune would delete {n} unused volume(s) and their data (irreversible)",
227
+ )
228
+
229
+
230
+ def _assess_system_prune(raw: str, cwd: Path, daemon: bool) -> Consequence:
231
+ spec = _parse_docker(raw)
232
+ flags = spec[3] if spec else []
233
+ has_volumes = "--volumes" in flags
234
+ has_all = any(f in ("-a", "--all") for f in flags)
235
+
236
+ if not daemon:
237
+ floor = 0.75 if has_volumes else (0.5 if has_all else 0.4)
238
+ extra = " including volumes and their data" if has_volumes else ""
239
+ return Consequence(
240
+ "docker", floor,
241
+ f"system prune removes all unused docker objects{extra} — "
242
+ f"images are re-pullable, volume data is not (daemon unreachable)",
243
+ estimated=True,
244
+ )
245
+
246
+ if has_volumes:
247
+ ok, out = _run_docker(cwd, "volume", "ls", "-f", "dangling=true", "-q")
248
+ nv = _count_lines(out) if ok else 0
249
+ if nv > 0:
250
+ return Consequence(
251
+ "docker", min(0.85, 0.6 + 0.05 * nv),
252
+ f"system prune --volumes would delete {nv} unused volume(s) and "
253
+ f"their data — irreversible",
254
+ )
255
+ return Consequence(
256
+ "docker", 0.4,
257
+ "system prune --volumes — no unused volumes; removes containers/images "
258
+ "(rebuildable)",
259
+ )
260
+ if has_all:
261
+ return Consequence(
262
+ "docker", 0.5,
263
+ "system prune -a removes every unused image — re-pullable, but a slow "
264
+ "rebuild; no volume data is touched",
265
+ )
266
+ return Consequence(
267
+ "docker", 0.4,
268
+ "system prune removes stopped containers, dangling images and build cache "
269
+ "— all rebuildable",
270
+ )
271
+
272
+
273
+ def _assess_container_rm(containers: tuple[str, ...], cwd: Path, daemon: bool) -> Consequence:
274
+ names = ", ".join(containers) or "container(s)"
275
+ evidence = (
276
+ f"force-removes container(s) {names}; recreatable from their image "
277
+ f"(any in-container state not on a volume is lost)"
278
+ )
279
+ if not daemon:
280
+ return Consequence("docker", 0.4, evidence + " (daemon unreachable)", estimated=True)
281
+ return Consequence("docker", 0.4, evidence)