worktree-shield 0.1.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.
Files changed (56) hide show
  1. worktree_shield/__init__.py +8 -0
  2. worktree_shield/__main__.py +6 -0
  3. worktree_shield/cli.py +377 -0
  4. worktree_shield/collectors/__init__.py +1 -0
  5. worktree_shield/collectors/base.py +63 -0
  6. worktree_shield/collectors/compose.py +315 -0
  7. worktree_shield/collectors/envfiles.py +130 -0
  8. worktree_shield/collectors/layout.py +106 -0
  9. worktree_shield/collectors/node.py +87 -0
  10. worktree_shield/collectors/procfile.py +134 -0
  11. worktree_shield/collectors/python.py +128 -0
  12. worktree_shield/collectors/runtime.py +167 -0
  13. worktree_shield/collectors/static.py +25 -0
  14. worktree_shield/collectors/vite.py +133 -0
  15. worktree_shield/collisions.py +115 -0
  16. worktree_shield/config.py +188 -0
  17. worktree_shield/exit_codes.py +22 -0
  18. worktree_shield/files.py +70 -0
  19. worktree_shield/git/__init__.py +1 -0
  20. worktree_shield/git/discovery.py +85 -0
  21. worktree_shield/git/porcelain.py +48 -0
  22. worktree_shield/models.py +356 -0
  23. worktree_shield/names.py +83 -0
  24. worktree_shield/orchestrator.py +121 -0
  25. worktree_shield/overlap.py +175 -0
  26. worktree_shield/parsers/__init__.py +1 -0
  27. worktree_shield/parsers/commands.py +158 -0
  28. worktree_shield/parsers/compose.py +148 -0
  29. worktree_shield/parsers/dburl.py +156 -0
  30. worktree_shield/parsers/dotenv.py +91 -0
  31. worktree_shield/parsers/interpolate.py +142 -0
  32. worktree_shield/parsers/jsonc.py +48 -0
  33. worktree_shield/parsers/jstext.py +142 -0
  34. worktree_shield/plan.py +175 -0
  35. worktree_shield/privacy.py +237 -0
  36. worktree_shield/reports/__init__.py +1 -0
  37. worktree_shield/reports/findings.py +176 -0
  38. worktree_shield/reports/json_report.py +61 -0
  39. worktree_shield/reports/markdown.py +103 -0
  40. worktree_shield/reports/sarif.py +116 -0
  41. worktree_shield/reports/terminal.py +327 -0
  42. worktree_shield/rules/__init__.py +1 -0
  43. worktree_shield/rules/catalog.py +28 -0
  44. worktree_shield/rules/common.py +61 -0
  45. worktree_shield/rules/context.py +201 -0
  46. worktree_shield/rules/engine.py +60 -0
  47. worktree_shield/rules/explain.py +27 -0
  48. worktree_shield/rules/rules.md +112 -0
  49. worktree_shield/rules/runtime.py +164 -0
  50. worktree_shield/rules/shared.py +216 -0
  51. worktree_shield/runner.py +273 -0
  52. worktree_shield-0.1.0.dist-info/METADATA +151 -0
  53. worktree_shield-0.1.0.dist-info/RECORD +56 -0
  54. worktree_shield-0.1.0.dist-info/WHEEL +4 -0
  55. worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
  56. worktree_shield-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,356 @@
1
+ """Typed facts and findings shared by collectors, rules and renderers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from enum import StrEnum
9
+
10
+ SCHEMA_VERSION = 1
11
+ """Version of the snapshot/JSON report layout; bumped only for incompatible changes."""
12
+
13
+ FINDING_ID = re.compile(r"WTS-[A-Z]+-[0-9]{3}")
14
+ """Finding codes are part of the public contract: ``WTS-<AREA>-<NNN>``, e.g. ``WTS-PORT-001``."""
15
+
16
+
17
+ class Status(StrEnum):
18
+ """Outcome of one check.
19
+
20
+ Only ``PASS`` means that a check ran and found nothing shared. A check that could not
21
+ run is ``UNKNOWN``; one that was deliberately skipped (runtime inspection nobody asked
22
+ for) is ``NOT_TESTED``; one that does not apply to this repository is
23
+ ``NOT_APPLICABLE``.
24
+ """
25
+
26
+ PASS = "pass" # noqa: S105 - a status name, not a password
27
+ WARN = "warn"
28
+ FAIL = "fail"
29
+ UNKNOWN = "unknown"
30
+ NOT_APPLICABLE = "not_applicable"
31
+ NOT_TESTED = "not_tested"
32
+
33
+
34
+ class Severity(StrEnum):
35
+ """How much a finding matters, independent of certainty."""
36
+
37
+ INFO = "info"
38
+ LOW = "low"
39
+ MEDIUM = "medium"
40
+ HIGH = "high"
41
+
42
+
43
+ class Confidence(StrEnum):
44
+ """How well the evidence supports a finding."""
45
+
46
+ LOW = "low"
47
+ MEDIUM = "medium"
48
+ HIGH = "high"
49
+
50
+
51
+ class Certainty(StrEnum):
52
+ """How sure a collision is, from the kind of evidence behind it.
53
+
54
+ Static evidence (two configs declare the same port) is ``PROBABLE`` at most: it only
55
+ matters if both run at once on the same host. ``CONFIRMED`` needs a running process
56
+ attributed to a worktree by its working directory. ``POSSIBLE`` covers looser matches
57
+ (``localhost`` against ``127.0.0.1``); ``UNKNOWN`` means it could not be decided.
58
+ """
59
+
60
+ CONFIRMED = "confirmed"
61
+ PROBABLE = "probable"
62
+ POSSIBLE = "possible"
63
+ UNKNOWN = "unknown"
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class SourceRef:
68
+ """Where a claim comes from: a file (relative to its worktree) and, if known, a line."""
69
+
70
+ worktree: str
71
+ path: str
72
+ line: int | None = None
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class Evidence:
77
+ """One observed fact that a finding relies on; ``value`` is ``None`` when missing."""
78
+
79
+ source: str
80
+ key: str
81
+ value: str | None
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class Finding:
86
+ """A diagnosis with everything a user needs to judge and verify it.
87
+
88
+ ``resource_key`` is the normalised shared resource (``tcp://*:5173``,
89
+ ``db://postgres/localhost:5432/app_dev``…), ``worktrees`` the worktrees involved and
90
+ ``sources`` where each claim was found. ``certainty`` applies to collisions.
91
+ ``verify_command`` re-checks the result after acting on ``recommended_action``;
92
+ ``caused_by`` names the finding this one follows from, so reports show one root
93
+ cause instead of a cascade.
94
+ """
95
+
96
+ id: str
97
+ status: Status
98
+ severity: Severity
99
+ confidence: Confidence
100
+ summary: str
101
+ reason: str
102
+ resource_key: str | None = None
103
+ certainty: Certainty | None = None
104
+ worktrees: tuple[str, ...] = ()
105
+ sources: tuple[SourceRef, ...] = ()
106
+ evidence: tuple[Evidence, ...] = ()
107
+ recommended_action: str | None = None
108
+ verify_command: str | None = None
109
+ limitations: tuple[str, ...] = ()
110
+ caused_by: str | None = None
111
+
112
+ def __post_init__(self) -> None:
113
+ if not FINDING_ID.fullmatch(self.id):
114
+ raise ValueError(f"finding id {self.id!r} does not match WTS-<AREA>-<NNN>")
115
+ if self.caused_by is not None and not FINDING_ID.fullmatch(self.caused_by):
116
+ raise ValueError(f"caused_by {self.caused_by!r} does not match WTS-<AREA>-<NNN>")
117
+ if not self.summary.strip():
118
+ raise ValueError(f"finding {self.id} has an empty summary")
119
+
120
+
121
+ class FailureKind(StrEnum):
122
+ """Why a probe produced no usable facts."""
123
+
124
+ NOT_FOUND = "not_found"
125
+ PERMISSION_DENIED = "permission_denied"
126
+ TIMEOUT = "timeout"
127
+ NONZERO_EXIT = "nonzero_exit"
128
+ MALFORMED_OUTPUT = "malformed_output"
129
+ OS_ERROR = "os_error"
130
+ PROBE_ERROR = "probe_error"
131
+ """The probe ran, but reported that part of its own work failed."""
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class ProbeFailure:
136
+ """A probe that could not deliver its facts; collectors return these instead of raising."""
137
+
138
+ probe: str
139
+ kind: FailureKind
140
+ detail: str
141
+
142
+
143
+ def utc_timestamp() -> str:
144
+ """Current time as an ISO 8601 UTC string with second precision."""
145
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class Worktree:
150
+ """One entry of ``git worktree list --porcelain``.
151
+
152
+ ``branch`` is the full ref (``refs/heads/feature``) or ``None`` when detached or
153
+ bare. ``locked`` and ``prunable`` carry Git's reason, ``""`` when it gave none and
154
+ ``None`` when the flag is not set. A prunable worktree is listed but its directory
155
+ is gone; it is reported, never pruned.
156
+ """
157
+
158
+ path: str
159
+ head: str | None = None
160
+ branch: str | None = None
161
+ bare: bool = False
162
+ detached: bool = False
163
+ locked: str | None = None
164
+ prunable: str | None = None
165
+
166
+
167
+ @dataclass(frozen=True, slots=True)
168
+ class Repository:
169
+ """The repository the scan started in, and every worktree that shares its ``.git``.
170
+
171
+ ``common_dir`` is the shared Git directory, ``current`` the top of the worktree the
172
+ scan started in (``None`` in a bare repository), ``worktrees`` Git's own list of all
173
+ worktrees attached to ``common_dir``, nothing found by searching the disk.
174
+ """
175
+
176
+ common_dir: str
177
+ current: str | None
178
+ bare: bool
179
+ worktrees: tuple[Worktree, ...]
180
+
181
+
182
+ class PolicyLevel(StrEnum):
183
+ """What a kind of shared resource means for ``--strict``.
184
+
185
+ ``allow`` reports it as information, ``warn`` as a warning, ``deny`` as a failure,
186
+ which makes ``--strict`` exit with 1.
187
+ """
188
+
189
+ ALLOW = "allow"
190
+ WARN = "warn"
191
+ DENY = "deny"
192
+
193
+
194
+ @dataclass(frozen=True, slots=True)
195
+ class Policy:
196
+ """The effective policy: built-in defaults, then the repository's file, then flags.
197
+
198
+ ``allow_ports`` are shared *infrastructure endpoints* that every worktree may
199
+ connect to (one Postgres on 5432 for all of them); they never excuse two processes
200
+ trying to *listen* on the same port. ``source`` is the policy file that was read,
201
+ ``None`` for the defaults alone.
202
+ """
203
+
204
+ include_runtime: bool = True
205
+ include_env_files: bool = False
206
+ max_file_bytes: int = 262_144
207
+ shared_host_ports: PolicyLevel = PolicyLevel.DENY
208
+ shared_database: PolicyLevel = PolicyLevel.WARN
209
+ shared_compose_projects: PolicyLevel = PolicyLevel.WARN
210
+ fixed_container_names: PolicyLevel = PolicyLevel.WARN
211
+ shared_absolute_paths: PolicyLevel = PolicyLevel.WARN
212
+ allow_ports: tuple[int, ...] = ()
213
+ allow_paths: tuple[str, ...] = ()
214
+ source: str | None = None
215
+
216
+
217
+ class ResourceKind(StrEnum):
218
+ """What a worktree claims.
219
+
220
+ A *listen* port is one its own process binds; a *client endpoint* is a server it
221
+ connects to (a shared Postgres on 5432). Two listeners on one port collide; two
222
+ clients of one server do not, which is why they are different kinds.
223
+ """
224
+
225
+ LISTEN_PORT = "listen_port"
226
+ CLIENT_ENDPOINT = "client_endpoint"
227
+ DATABASE = "database"
228
+ COMPOSE_PROJECT = "compose_project"
229
+ CONTAINER_NAME = "container_name"
230
+ VOLUME = "volume"
231
+ ABSOLUTE_PATH = "absolute_path"
232
+
233
+
234
+ class Observation(StrEnum):
235
+ """How a claim was found: read from configuration, or seen on the running system."""
236
+
237
+ STATIC = "static"
238
+ RUNTIME = "runtime"
239
+
240
+
241
+ @dataclass(frozen=True, slots=True)
242
+ class ResourceClaim:
243
+ """One resource a worktree declares or uses, with where it was found.
244
+
245
+ ``key`` identifies the resource (``tcp://*:5173``, ``db://postgres/localhost:5432/app``);
246
+ claims of different worktrees with overlapping keys are collision candidates.
247
+ ``detail`` is what was read, already reduced to safe facts: never a password or the
248
+ value of a variable no rule needs. ``strict`` says whether the program fails when
249
+ the port is taken (``True``) or moves to another one (``False``); ``None`` when the
250
+ configuration does not say.
251
+ """
252
+
253
+ kind: ResourceKind
254
+ key: str
255
+ worktree: str
256
+ source: SourceRef
257
+ detail: str
258
+ observation: Observation = Observation.STATIC
259
+ strict: bool | None = None
260
+
261
+
262
+ @dataclass(frozen=True, slots=True)
263
+ class SkippedFile:
264
+ """A configuration file that was found but not read, and why (too large, a link
265
+ out of the worktree, not UTF-8…). A skipped file can hide a collision."""
266
+
267
+ source: SourceRef
268
+ reason: str
269
+
270
+
271
+ @dataclass(frozen=True, slots=True)
272
+ class Collision:
273
+ """Two or more worktrees claiming the same resource.
274
+
275
+ ``key`` names the resource (the first of the overlapping keys), ``claims`` are all
276
+ claims involved, ``certainty`` says how sure the overlap is and ``reason`` why, in a
277
+ sentence a report can show.
278
+ """
279
+
280
+ kind: ResourceKind
281
+ key: str
282
+ certainty: Certainty
283
+ reason: str
284
+ claims: tuple[ResourceClaim, ...]
285
+
286
+ @property
287
+ def worktrees(self) -> tuple[str, ...]:
288
+ """The worktrees involved, in the order of their first claim."""
289
+ return tuple(dict.fromkeys(claim.worktree for claim in self.claims))
290
+
291
+
292
+ @dataclass(frozen=True, slots=True)
293
+ class UnreadableValue:
294
+ """A value that names a resource but could not be read safely, such as a database
295
+ URL whose host is uncertain. ``name`` is the variable or field, never its value."""
296
+
297
+ kind: ResourceKind
298
+ source: SourceRef
299
+ name: str
300
+ reason: str
301
+
302
+
303
+ @dataclass(frozen=True, slots=True)
304
+ class Listener:
305
+ """A listening socket that could not be tied to a worktree, kept when one of the
306
+ worktrees configures its port. ``note`` says why it is not attributed; ``process``
307
+ is the process name only, never its command line."""
308
+
309
+ protocol: str
310
+ address: str
311
+ port: int
312
+ pid: int | None
313
+ process: str | None
314
+ note: str
315
+
316
+
317
+ @dataclass(frozen=True, slots=True)
318
+ class RuntimeFacts:
319
+ """What the runtime observation saw beyond the claims it made: listeners on
320
+ configured ports that belong to no worktree, and what limited the view."""
321
+
322
+ unattributed: tuple[Listener, ...] = ()
323
+ limitations: tuple[str, ...] = ()
324
+
325
+
326
+ @dataclass(frozen=True, slots=True)
327
+ class RedactionCount:
328
+ """How many values of one kind (``home_path``, ``secret_value``…) were redacted."""
329
+
330
+ kind: str
331
+ count: int
332
+
333
+
334
+ @dataclass(frozen=True, slots=True)
335
+ class Snapshot:
336
+ """Everything one scan observed and concluded; the input of every renderer.
337
+
338
+ ``repository`` is ``None`` when discovery failed (see ``probe_failures``). ``policy``
339
+ is the effective policy, ``claims`` every resource found in the worktrees,
340
+ ``skipped`` the configuration files that could not be read and ``unreadable`` the
341
+ values that name a resource without saying safely which one. ``runtime`` is
342
+ ``None`` when running processes were not looked at.
343
+ """
344
+
345
+ tool_version: str
346
+ created_at: str
347
+ schema_version: int = SCHEMA_VERSION
348
+ repository: Repository | None = None
349
+ policy: Policy | None = None
350
+ claims: tuple[ResourceClaim, ...] = ()
351
+ skipped: tuple[SkippedFile, ...] = ()
352
+ unreadable: tuple[UnreadableValue, ...] = ()
353
+ runtime: RuntimeFacts | None = None
354
+ probe_failures: tuple[ProbeFailure, ...] = ()
355
+ findings: tuple[Finding, ...] = ()
356
+ redactions: tuple[RedactionCount, ...] = ()
@@ -0,0 +1,83 @@
1
+ """What a variable name says about its value, and which literal values are plain hosts.
2
+
3
+ Shared by every collector, so ``DB_PORT`` means the same thing in a ``.env`` file and
4
+ in an npm script.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+
11
+ from worktree_shield.models import ResourceKind
12
+
13
+
14
+ def _words(text: str) -> frozenset[str]:
15
+ return frozenset(text.split())
16
+
17
+
18
+ # A *_PORT with one of these words names a server the app connects to, not one it runs.
19
+ CLIENT_WORDS = _words(
20
+ "DB DATABASE POSTGRES POSTGRESQL PG MYSQL MARIADB REDIS MONGO MONGODB RABBITMQ AMQP SMTP "
21
+ "MAIL EMAIL ELASTICSEARCH ELASTIC OPENSEARCH KAFKA MEMCACHED MINIO S3 NATS CLICKHOUSE"
22
+ )
23
+ _PATH_WORDS = _words(
24
+ "DIR DIRECTORY PATH ROOT FOLDER CACHE OUT OUTPUT OUTDIR STORAGE UPLOAD UPLOADS DATA LOG "
25
+ "LOGS TMP TEMP TMPDIR FILE LOCATION"
26
+ )
27
+ # Read-only by nature: search paths, installations, certificates and configuration.
28
+ _SEARCH_PATHS = _words(
29
+ "PATH PYTHONPATH NODE_PATH LD_LIBRARY_PATH DYLD_LIBRARY_PATH MANPATH CLASSPATH GOPATH "
30
+ "PKG_CONFIG_PATH CDPATH"
31
+ )
32
+ _READ_ONLY_WORDS = _words("HOME CERT CERTS KEY CA SSL TLS CONFIG CONF ETC")
33
+
34
+
35
+ def port_variable(name: str) -> ResourceKind | None:
36
+ """``LISTEN_PORT`` for ``PORT`` and ``API_PORT``, ``CLIENT_ENDPOINT`` for ``DB_PORT``
37
+ and ``PGPORT``, ``None`` for a name that holds no port."""
38
+ upper = name.upper()
39
+ if upper == "PGPORT":
40
+ return ResourceKind.CLIENT_ENDPOINT
41
+ if upper != "PORT" and not upper.endswith("_PORT"):
42
+ return None
43
+ if CLIENT_WORDS.intersection(upper.split("_")):
44
+ return ResourceKind.CLIENT_ENDPOINT
45
+ return ResourceKind.LISTEN_PORT
46
+
47
+
48
+ def host_variable(port_name: str) -> str:
49
+ """The host variable that goes with a port variable: ``API_PORT`` → ``API_HOST``."""
50
+ upper = port_name.upper()
51
+ return "PGHOST" if upper == "PGPORT" else upper.removesuffix("PORT") + "HOST"
52
+
53
+
54
+ def is_mutable_path_variable(name: str) -> bool:
55
+ """Whether a variable that holds a path is likely written to (``UPLOAD_DIR``,
56
+ ``CACHE_PATH``) rather than read (``PYTHONPATH``, ``JAVA_HOME``, ``SSL_CERT_FILE``)."""
57
+ upper = name.upper()
58
+ if upper in _SEARCH_PATHS:
59
+ return False
60
+ words = set(upper.split("_"))
61
+ return bool(words & _PATH_WORDS) and not words & _READ_ONLY_WORDS
62
+
63
+
64
+ def port_number(text: str) -> int | None:
65
+ """A port written as plain ASCII digits, 1 to 65535."""
66
+ if not text.isascii() or not text.isdigit() or len(text) > 5:
67
+ return None
68
+ port = int(text)
69
+ return port if 1 <= port <= 65535 else None
70
+
71
+
72
+ def literal_host(text: str) -> str | None:
73
+ """A host name or address as written, lower-cased; ``None`` for anything else
74
+ (references, spaces, non-ASCII names)."""
75
+ value = text.strip().lower()
76
+ try:
77
+ return str(ipaddress.ip_address(value.strip("[]")))
78
+ except ValueError:
79
+ pass
80
+ labels = value.split(".")
81
+ if value.isascii() and all(label and label.replace("-", "").isalnum() for label in labels):
82
+ return value
83
+ return None
@@ -0,0 +1,121 @@
1
+ """Runs the collectors and assembles the snapshot; no diagnosis happens here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+
9
+ import psutil
10
+
11
+ from worktree_shield import __version__
12
+ from worktree_shield.collectors.base import Collected, Scope
13
+ from worktree_shield.collectors.runtime import ProcessTable, PsutilTable, observe_runtime
14
+ from worktree_shield.collectors.static import collect_worktree
15
+ from worktree_shield.config import apply_overrides, policy_of
16
+ from worktree_shield.git.discovery import discover
17
+ from worktree_shield.models import (
18
+ ResourceClaim,
19
+ ResourceKind,
20
+ RuntimeFacts,
21
+ SkippedFile,
22
+ Snapshot,
23
+ SourceRef,
24
+ utc_timestamp,
25
+ )
26
+ from worktree_shield.runner import CommandRunner
27
+
28
+
29
+ def inspect_worktrees(runner: CommandRunner, start: Path) -> Snapshot:
30
+ """The repository containing ``start`` and its worktrees.
31
+
32
+ Raises ``NotARepositoryError`` when ``start`` is not inside a repository.
33
+ """
34
+ repository, failures = discover(runner, start)
35
+ return Snapshot(
36
+ tool_version=__version__,
37
+ created_at=utc_timestamp(),
38
+ repository=repository,
39
+ probe_failures=failures,
40
+ )
41
+
42
+
43
+ _PORT = re.compile(r"([a-z]+)://.*:([0-9]+)")
44
+
45
+
46
+ def inspect_resources(
47
+ runner: CommandRunner,
48
+ start: Path,
49
+ *,
50
+ include_env_files: bool | None = None,
51
+ include_runtime: bool | None = None,
52
+ table: ProcessTable | None = None,
53
+ ) -> Snapshot:
54
+ """The worktrees of the repository, the effective policy and every resource the
55
+ worktrees declare in their configuration or, with ``include_runtime``, hold with a
56
+ running server.
57
+
58
+ Bare and prunable worktrees have no files to read and are left out. Raises
59
+ ``NotARepositoryError`` outside a repository and ``PolicyError`` for a policy file
60
+ that cannot be used.
61
+ """
62
+ snapshot = inspect_worktrees(runner, start)
63
+ if snapshot.repository is None:
64
+ return snapshot
65
+ policy = apply_overrides(
66
+ policy_of(snapshot.repository),
67
+ include_env_files=include_env_files,
68
+ include_runtime=include_runtime,
69
+ )
70
+ out = Collected()
71
+ with_files: list[str] = []
72
+ for worktree in snapshot.repository.worktrees:
73
+ if worktree.bare or worktree.prunable is not None:
74
+ continue
75
+ root = Path(worktree.path)
76
+ if not root.is_dir():
77
+ reason = "is listed by Git but is not a directory"
78
+ out.skipped.append(SkippedFile(SourceRef(worktree.path, "."), reason))
79
+ continue
80
+ with_files.append(worktree.path)
81
+ collect_worktree(Scope(root, worktree.path, policy.max_file_bytes), policy, out)
82
+ runtime = None
83
+ if policy.include_runtime:
84
+ runtime = _observe(table or PsutilTable(), with_files, out.claims)
85
+ return replace(
86
+ snapshot,
87
+ policy=policy,
88
+ claims=tuple(out.claims),
89
+ skipped=tuple(out.skipped),
90
+ unreadable=tuple(out.unreadable),
91
+ runtime=runtime,
92
+ )
93
+
94
+
95
+ def _observe(
96
+ table: ProcessTable, worktrees: list[str], claims: list[ResourceClaim]
97
+ ) -> RuntimeFacts:
98
+ """Add the runtime claims to ``claims``; keep the unattributed listeners only on
99
+ ports some worktree configures, since only those can block one."""
100
+ try:
101
+ found, unattributed, limitations = observe_runtime(table, worktrees)
102
+ except (psutil.Error, OSError) as error:
103
+ return RuntimeFacts(limitations=(f"running processes could not be listed ({error})",))
104
+ configured = set()
105
+ for claim in claims:
106
+ match = _PORT.fullmatch(claim.key)
107
+ if claim.kind is ResourceKind.LISTEN_PORT and match is not None:
108
+ configured.add((match.group(1), int(match.group(2))))
109
+ claims += found
110
+ relevant = tuple(u for u in unattributed if (u.protocol, u.port) in configured)
111
+ return RuntimeFacts(relevant, tuple(limitations))
112
+
113
+
114
+ def diagnose(snapshot: Snapshot) -> Snapshot:
115
+ """The snapshot with the findings of every built-in rule added."""
116
+ from worktree_shield.rules.catalog import builtin_rules
117
+ from worktree_shield.rules.engine import evaluate
118
+
119
+ if snapshot.repository is None:
120
+ return snapshot
121
+ return replace(snapshot, findings=evaluate(snapshot, builtin_rules()))