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.
- worktree_shield/__init__.py +8 -0
- worktree_shield/__main__.py +6 -0
- worktree_shield/cli.py +377 -0
- worktree_shield/collectors/__init__.py +1 -0
- worktree_shield/collectors/base.py +63 -0
- worktree_shield/collectors/compose.py +315 -0
- worktree_shield/collectors/envfiles.py +130 -0
- worktree_shield/collectors/layout.py +106 -0
- worktree_shield/collectors/node.py +87 -0
- worktree_shield/collectors/procfile.py +134 -0
- worktree_shield/collectors/python.py +128 -0
- worktree_shield/collectors/runtime.py +167 -0
- worktree_shield/collectors/static.py +25 -0
- worktree_shield/collectors/vite.py +133 -0
- worktree_shield/collisions.py +115 -0
- worktree_shield/config.py +188 -0
- worktree_shield/exit_codes.py +22 -0
- worktree_shield/files.py +70 -0
- worktree_shield/git/__init__.py +1 -0
- worktree_shield/git/discovery.py +85 -0
- worktree_shield/git/porcelain.py +48 -0
- worktree_shield/models.py +356 -0
- worktree_shield/names.py +83 -0
- worktree_shield/orchestrator.py +121 -0
- worktree_shield/overlap.py +175 -0
- worktree_shield/parsers/__init__.py +1 -0
- worktree_shield/parsers/commands.py +158 -0
- worktree_shield/parsers/compose.py +148 -0
- worktree_shield/parsers/dburl.py +156 -0
- worktree_shield/parsers/dotenv.py +91 -0
- worktree_shield/parsers/interpolate.py +142 -0
- worktree_shield/parsers/jsonc.py +48 -0
- worktree_shield/parsers/jstext.py +142 -0
- worktree_shield/plan.py +175 -0
- worktree_shield/privacy.py +237 -0
- worktree_shield/reports/__init__.py +1 -0
- worktree_shield/reports/findings.py +176 -0
- worktree_shield/reports/json_report.py +61 -0
- worktree_shield/reports/markdown.py +103 -0
- worktree_shield/reports/sarif.py +116 -0
- worktree_shield/reports/terminal.py +327 -0
- worktree_shield/rules/__init__.py +1 -0
- worktree_shield/rules/catalog.py +28 -0
- worktree_shield/rules/common.py +61 -0
- worktree_shield/rules/context.py +201 -0
- worktree_shield/rules/engine.py +60 -0
- worktree_shield/rules/explain.py +27 -0
- worktree_shield/rules/rules.md +112 -0
- worktree_shield/rules/runtime.py +164 -0
- worktree_shield/rules/shared.py +216 -0
- worktree_shield/runner.py +273 -0
- worktree_shield-0.1.0.dist-info/METADATA +151 -0
- worktree_shield-0.1.0.dist-info/RECORD +56 -0
- worktree_shield-0.1.0.dist-info/WHEEL +4 -0
- worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
- worktree_shield-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Making collected text safe to print and to share.
|
|
2
|
+
|
|
3
|
+
Everything that leaves the tool (terminal, JSON, Markdown) is built from redacted data:
|
|
4
|
+
redaction runs on the snapshot *before* any renderer sees it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import getpass
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, fields, is_dataclass, replace
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from worktree_shield.models import Snapshot
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
_ESCAPE_SEQUENCE = re.compile(
|
|
25
|
+
r"\x1b\[[0-?]*[ -/]*[@-~]" # CSI: colours, cursor movement, screen clearing
|
|
26
|
+
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?" # OSC: window titles, hyperlinks
|
|
27
|
+
r"|\x1b[@-_]" # other two-byte escapes
|
|
28
|
+
)
|
|
29
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def strip_control(text: str) -> str:
|
|
33
|
+
"""Remove terminal escape sequences and control characters, keeping tabs and newlines.
|
|
34
|
+
|
|
35
|
+
Collected output can come from a broken or hostile environment; without this a
|
|
36
|
+
crafted package name could rewrite the user's terminal when a report is shown.
|
|
37
|
+
"""
|
|
38
|
+
return _CONTROL.sub("", _ESCAPE_SEQUENCE.sub("", text))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class RedactionContext:
|
|
43
|
+
"""The identifying values of the machine the report is made on."""
|
|
44
|
+
|
|
45
|
+
home: str | None = None
|
|
46
|
+
user: str | None = None
|
|
47
|
+
hostname: str | None = None
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def current(cls) -> RedactionContext:
|
|
51
|
+
"""Read the values for the current user; missing ones stay ``None``."""
|
|
52
|
+
return cls(
|
|
53
|
+
home=_safe(lambda: str(Path.home())),
|
|
54
|
+
user=_safe(getpass.getuser),
|
|
55
|
+
hostname=_safe(socket.gethostname),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _safe(read: Callable[[], str]) -> str | None:
|
|
60
|
+
try:
|
|
61
|
+
value = read()
|
|
62
|
+
except (OSError, KeyError, RuntimeError):
|
|
63
|
+
return None
|
|
64
|
+
return value or None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Replacement = str | Callable[[re.Match[str]], str]
|
|
68
|
+
|
|
69
|
+
_REDACTED = "<redacted>"
|
|
70
|
+
_REDACTED_TOKEN = "<redacted-token>" # noqa: S105 - the placeholder, not a token
|
|
71
|
+
|
|
72
|
+
_URL_CREDENTIALS = re.compile(r"\b([a-zA-Z][a-zA-Z0-9+.-]*://)([^/\s?#]*)@")
|
|
73
|
+
"""``scheme://user:password@`` — greedy up to the last ``@`` of the authority."""
|
|
74
|
+
|
|
75
|
+
_NAME_AND_SEPARATOR = re.compile(r"""(?<![\w-])([A-Za-z_][A-Za-z0-9_]*)(["']?\s*[=:]\s*)""")
|
|
76
|
+
_VALUE = re.compile(r"""("[^"]*"|'[^']*'|[^\s,;}]+)""")
|
|
77
|
+
"""``NAME=value``, ``name: value``, ``"name": "value"``; the name decides if it is secret."""
|
|
78
|
+
|
|
79
|
+
_SECRET_SUFFIXES = ("token", "secret", "password", "passwd", "apikey", "credential", "credentials")
|
|
80
|
+
_SECRET_PARTS = frozenset({"key", "auth", "pass", "pat"})
|
|
81
|
+
|
|
82
|
+
_TOKENS = re.compile(
|
|
83
|
+
r"(?<![A-Za-z0-9_-])(?:"
|
|
84
|
+
r"gh[pousr]_[A-Za-z0-9]{36,}" # GitHub
|
|
85
|
+
r"|github_pat_[A-Za-z0-9_]{22,}"
|
|
86
|
+
r"|hf_[A-Za-z0-9]{30,}" # Hugging Face
|
|
87
|
+
r"|sk-[A-Za-z0-9_-]{20,}" # OpenAI, Anthropic
|
|
88
|
+
r"|(?:AKIA|ASIA)[0-9A-Z]{16}" # AWS access key id
|
|
89
|
+
r"|xox[abprs]-[A-Za-z0-9-]{10,}" # Slack
|
|
90
|
+
r"|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" # JWT
|
|
91
|
+
r")(?![A-Za-z0-9_-])"
|
|
92
|
+
)
|
|
93
|
+
_BEARER = re.compile(r"(?i)\b(bearer\s+)[A-Za-z0-9._~+/=-]{8,}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_secret_name(name: str) -> bool:
|
|
97
|
+
"""``HF_TOKEN``, ``accessToken``, ``DB_PASSWORD``, ``OPENAI_API_KEY``: yes.
|
|
98
|
+
``tokenizers``, ``CUDA_HOME``, ``author``, ``KEYBOARD``: no."""
|
|
99
|
+
parts = [part for part in name.lower().split("_") if part]
|
|
100
|
+
return any(part in _SECRET_PARTS or part.endswith(_SECRET_SUFFIXES) for part in parts)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_MIN_NAME_LENGTH = 3
|
|
104
|
+
_GENERIC_NAMES = frozenset(
|
|
105
|
+
{
|
|
106
|
+
"root", "user", "admin", "guest", "localhost", "linux", "ubuntu", "debian",
|
|
107
|
+
"fedora", "arch", "archlinux", "manjaro", "pop-os", "mint", "kali", "raspberrypi",
|
|
108
|
+
}
|
|
109
|
+
) # fmt: skip
|
|
110
|
+
"""Names that identify nobody and appear in ordinary text (distribution names as hosts)."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _word_pattern(name: str) -> re.Pattern[str] | None:
|
|
114
|
+
if len(name) < _MIN_NAME_LENGTH or name.lower() in _GENERIC_NAMES:
|
|
115
|
+
return None
|
|
116
|
+
# Whole words only: not inside "malice" or "alice_env", not an already redacted
|
|
117
|
+
# "<user>", but a sentence-ending period is fine.
|
|
118
|
+
return re.compile(rf"(?<![\w.<>-]){re.escape(name)}(?![\w<>-]|\.\w)")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Other accounts' home directories. The name may not start with "<", so already redacted
|
|
122
|
+
# paths are left alone.
|
|
123
|
+
_OTHER_HOMES: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
124
|
+
(re.compile(r"(/home/)[^/\s:'\"<>]+"), r"\1<user>"),
|
|
125
|
+
(re.compile(r"(/Users/)[^/\s:'\"<>]+"), r"\1<user>"),
|
|
126
|
+
(re.compile(r"([A-Za-z]:\\Users\\)[^\\\s:'\"<>]+"), r"\1<user>"),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Redactor:
|
|
131
|
+
"""Removes identifying and secret values from text and from whole snapshots.
|
|
132
|
+
|
|
133
|
+
One redactor is used per report; :attr:`summary` then tells how many values of each
|
|
134
|
+
kind were removed, which the report shows so the user knows redaction happened.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, context: RedactionContext) -> None:
|
|
138
|
+
self._counts: Counter[str] = Counter()
|
|
139
|
+
self._steps: list[tuple[str, re.Pattern[str], Replacement]] = []
|
|
140
|
+
home = context.home.rstrip("/\\") if context.home else ""
|
|
141
|
+
if len(home) > 1: # a home of "/" would redact every absolute path
|
|
142
|
+
boundary = r"(?=[/\\]|$|[^\w.-])"
|
|
143
|
+
self._steps.append(("home_path", re.compile(re.escape(home) + boundary), "~"))
|
|
144
|
+
for pattern, replacement in _OTHER_HOMES:
|
|
145
|
+
self._steps.append(("home_path", pattern, replacement))
|
|
146
|
+
# Host before user: "alice-laptop" is a host name even when the user is "alice".
|
|
147
|
+
for category, name, placeholder in (
|
|
148
|
+
("host_name", context.hostname, "<host>"),
|
|
149
|
+
("user_name", context.user, "<user>"),
|
|
150
|
+
):
|
|
151
|
+
word = _word_pattern(name) if name else None
|
|
152
|
+
if word is not None:
|
|
153
|
+
self._steps.append((category, word, placeholder))
|
|
154
|
+
|
|
155
|
+
def text(self, value: str) -> str:
|
|
156
|
+
"""Return ``value`` without control characters, identifying or secret values."""
|
|
157
|
+
result = strip_control(value)
|
|
158
|
+
result = _URL_CREDENTIALS.sub(self._url_credentials, result)
|
|
159
|
+
result = self._secret_assignments(result)
|
|
160
|
+
for token_pattern, placeholder in (
|
|
161
|
+
(_TOKENS, _REDACTED_TOKEN),
|
|
162
|
+
(_BEARER, r"\1" + _REDACTED_TOKEN),
|
|
163
|
+
):
|
|
164
|
+
result, count = token_pattern.subn(placeholder, result)
|
|
165
|
+
self._counts["token"] += count
|
|
166
|
+
for category, pattern, replacement in self._steps:
|
|
167
|
+
result, count = pattern.subn(replacement, result)
|
|
168
|
+
self._counts[category] += count
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
def redact(self, obj: T) -> T:
|
|
172
|
+
"""Return a redacted copy of ``obj``.
|
|
173
|
+
|
|
174
|
+
Strings are redacted; dataclasses (frozen ones too), tuples, lists and dicts are
|
|
175
|
+
copied with their contents redacted; enums, numbers, booleans and ``None`` are
|
|
176
|
+
returned unchanged. The original object is never modified.
|
|
177
|
+
"""
|
|
178
|
+
return cast(T, self._redact(obj))
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def summary(self) -> dict[str, int]:
|
|
182
|
+
"""How many values of each kind this redactor has removed so far."""
|
|
183
|
+
return {category: count for category, count in self._counts.items() if count}
|
|
184
|
+
|
|
185
|
+
def _redact(self, obj: Any) -> Any:
|
|
186
|
+
if isinstance(obj, Enum): # StrEnum members are str; keep them as they are
|
|
187
|
+
return obj
|
|
188
|
+
if isinstance(obj, str):
|
|
189
|
+
return self.text(obj)
|
|
190
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
191
|
+
changes = {f.name: self._redact(getattr(obj, f.name)) for f in fields(obj) if f.init}
|
|
192
|
+
return replace(obj, **changes)
|
|
193
|
+
if isinstance(obj, tuple):
|
|
194
|
+
return tuple(self._redact(item) for item in obj)
|
|
195
|
+
if isinstance(obj, list):
|
|
196
|
+
return [self._redact(item) for item in obj]
|
|
197
|
+
if isinstance(obj, dict):
|
|
198
|
+
return {self._redact(key): self._redact(item) for key, item in obj.items()}
|
|
199
|
+
return obj
|
|
200
|
+
|
|
201
|
+
def _url_credentials(self, match: re.Match[str]) -> str:
|
|
202
|
+
scheme, credentials = match.group(1), match.group(2)
|
|
203
|
+
if credentials == _REDACTED:
|
|
204
|
+
return match.group(0)
|
|
205
|
+
self._counts["url_credentials"] += 1
|
|
206
|
+
return f"{scheme}{_REDACTED}@"
|
|
207
|
+
|
|
208
|
+
def _secret_assignments(self, text: str) -> str:
|
|
209
|
+
# Scanned by hand rather than with re.sub: the value of a harmless name must not be
|
|
210
|
+
# consumed, or "env: DB_PASSWORD=hunter2" would hide the password inside the value
|
|
211
|
+
# of "env". After a harmless name the scan resumes right after its separator.
|
|
212
|
+
parts: list[str] = []
|
|
213
|
+
pos = 0
|
|
214
|
+
while match := _NAME_AND_SEPARATOR.search(text, pos):
|
|
215
|
+
parts.append(text[pos : match.end()])
|
|
216
|
+
pos = match.end()
|
|
217
|
+
value = _VALUE.match(text, pos)
|
|
218
|
+
if (
|
|
219
|
+
value is not None
|
|
220
|
+
and _is_secret_name(match.group(1))
|
|
221
|
+
and not value.group(0).startswith("<redacted")
|
|
222
|
+
):
|
|
223
|
+
parts.append(_REDACTED)
|
|
224
|
+
pos = value.end()
|
|
225
|
+
self._counts["secret_value"] += 1
|
|
226
|
+
parts.append(text[pos:])
|
|
227
|
+
return "".join(parts)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def redact_snapshot(snapshot: Snapshot, context: RedactionContext | None = None) -> Snapshot:
|
|
231
|
+
"""The snapshot redacted for output, with a record of what was removed."""
|
|
232
|
+
from worktree_shield.models import RedactionCount
|
|
233
|
+
|
|
234
|
+
redactor = Redactor(context or RedactionContext.current())
|
|
235
|
+
redacted = redactor.redact(snapshot)
|
|
236
|
+
counts = tuple(RedactionCount(kind, n) for kind, n in sorted(redactor.summary.items()))
|
|
237
|
+
return replace(redacted, redactions=counts)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Renderers: they receive an already redacted snapshot and only format it."""
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Terminal report of ``scan``: what needs attention in full, everything else in a line.
|
|
2
|
+
|
|
3
|
+
Collected text is added as plain ``Text``. Findings that follow from another one
|
|
4
|
+
(``caused_by``) are shown under it rather than as separate problems.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from worktree_shield.collectors.envfiles import LOCAL_FILES
|
|
18
|
+
from worktree_shield.models import Finding, Snapshot, Status
|
|
19
|
+
from worktree_shield.plan import Suggestion
|
|
20
|
+
from worktree_shield.reports.terminal import (
|
|
21
|
+
display_path,
|
|
22
|
+
redaction_line,
|
|
23
|
+
render_failed_discovery,
|
|
24
|
+
worktree_names,
|
|
25
|
+
)
|
|
26
|
+
from worktree_shield.rules.common import VERIFY, VERIFY_WITH_ENV
|
|
27
|
+
|
|
28
|
+
PROBLEMS = (Status.FAIL, Status.WARN, Status.UNKNOWN)
|
|
29
|
+
LABEL = {
|
|
30
|
+
Status.FAIL: "FAIL",
|
|
31
|
+
Status.WARN: "WARN",
|
|
32
|
+
Status.UNKNOWN: "UNKNOWN",
|
|
33
|
+
Status.PASS: "PASS",
|
|
34
|
+
Status.NOT_APPLICABLE: "N/A",
|
|
35
|
+
Status.NOT_TESTED: "NOT TESTED",
|
|
36
|
+
}
|
|
37
|
+
_STYLE = {Status.FAIL: "bold red", Status.WARN: "bold yellow", Status.UNKNOWN: "bold magenta"}
|
|
38
|
+
_ORDER = {status: index for index, status in enumerate(PROBLEMS)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def counts_line(findings: tuple[Finding, ...]) -> str:
|
|
42
|
+
"""``2 fail, 3 warn, 1 unknown, 6 pass, 1 not applicable``."""
|
|
43
|
+
counts = Counter(f.status for f in findings)
|
|
44
|
+
parts = [
|
|
45
|
+
f"{counts[status]} {label}"
|
|
46
|
+
for status, label in (
|
|
47
|
+
(Status.FAIL, "fail"),
|
|
48
|
+
(Status.WARN, "warn"),
|
|
49
|
+
(Status.UNKNOWN, "unknown"),
|
|
50
|
+
(Status.PASS, "pass"),
|
|
51
|
+
(Status.NOT_APPLICABLE, "not applicable"),
|
|
52
|
+
(Status.NOT_TESTED, "not tested"),
|
|
53
|
+
)
|
|
54
|
+
if counts[status]
|
|
55
|
+
]
|
|
56
|
+
return ", ".join(parts) if parts else "no findings"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_scan(snapshot: Snapshot, console: Console, *, show_all: bool = False) -> None:
|
|
60
|
+
"""The scan report of an already redacted, diagnosed snapshot."""
|
|
61
|
+
repository = snapshot.repository
|
|
62
|
+
console.print(Text(f"worktree-shield {snapshot.tool_version}", style="bold"))
|
|
63
|
+
if repository is None:
|
|
64
|
+
render_failed_discovery(snapshot, console)
|
|
65
|
+
return
|
|
66
|
+
name, parent = worktree_names(repository)
|
|
67
|
+
count = len(repository.worktrees)
|
|
68
|
+
policy = snapshot.policy
|
|
69
|
+
source = display_path(policy.source) if policy and policy.source else "built-in defaults"
|
|
70
|
+
console.print(Text(f"{count} worktree{'s' if count != 1 else ''}, policy: {source}"))
|
|
71
|
+
if parent is not None:
|
|
72
|
+
console.print(Text(f"Worktrees are named relative to {display_path(parent)}", style="dim"))
|
|
73
|
+
console.print(Text(counts_line(snapshot.findings), style="bold"))
|
|
74
|
+
problems = [f for f in snapshot.findings if f.status in PROBLEMS and f.caused_by is None]
|
|
75
|
+
problems.sort(key=lambda f: _ORDER[f.status]) # stable: catalog order within a status
|
|
76
|
+
followers = [f for f in snapshot.findings if f.status in PROBLEMS and f.caused_by]
|
|
77
|
+
for finding in problems:
|
|
78
|
+
console.print()
|
|
79
|
+
_render_problem(finding, name, console)
|
|
80
|
+
for follower in followers:
|
|
81
|
+
if follower.caused_by == finding.id:
|
|
82
|
+
console.print(
|
|
83
|
+
Text(f" Follows from it: {follower.id} {follower.summary}", style="dim")
|
|
84
|
+
)
|
|
85
|
+
orphans = [f for f in followers if f.caused_by not in {p.id for p in problems}]
|
|
86
|
+
for finding in orphans:
|
|
87
|
+
console.print()
|
|
88
|
+
_render_problem(finding, name, console)
|
|
89
|
+
rest = [f for f in snapshot.findings if f.status not in PROBLEMS]
|
|
90
|
+
if rest and show_all:
|
|
91
|
+
console.print()
|
|
92
|
+
table = Table(box=None, show_edge=False, pad_edge=False, show_header=False)
|
|
93
|
+
table.add_column("Status", no_wrap=True)
|
|
94
|
+
table.add_column("Rule", no_wrap=True)
|
|
95
|
+
table.add_column("Summary", overflow="fold")
|
|
96
|
+
for finding in rest:
|
|
97
|
+
table.add_row(Text(LABEL[finding.status]), Text(finding.id), Text(finding.summary))
|
|
98
|
+
console.print(table)
|
|
99
|
+
elif rest:
|
|
100
|
+
console.print()
|
|
101
|
+
console.print(
|
|
102
|
+
Text(
|
|
103
|
+
f"{len(rest)} more checks passed or did not apply (--all lists them).", style="dim"
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
console.print()
|
|
107
|
+
footer = "Read-only: nothing in the repository was run, changed or locked."
|
|
108
|
+
line = redaction_line(snapshot)
|
|
109
|
+
console.print(Text(footer + (f" {line}." if line else ""), style="dim"))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _render_problem(finding: Finding, label: Callable[[str], str], console: Console) -> None:
|
|
113
|
+
header = Text()
|
|
114
|
+
header.append(f"{LABEL[finding.status]:<8}", style=_STYLE.get(finding.status, "bold"))
|
|
115
|
+
header.append(f"{finding.id} ", style="bold")
|
|
116
|
+
header.append(finding.summary)
|
|
117
|
+
if finding.certainty is not None:
|
|
118
|
+
header.append(f" ({finding.certainty.value})", style="dim")
|
|
119
|
+
console.print(header)
|
|
120
|
+
console.print(Text(f" {finding.reason}"))
|
|
121
|
+
if finding.evidence:
|
|
122
|
+
table = Table(
|
|
123
|
+
box=None, show_edge=False, pad_edge=False, show_header=False, padding=(0, 2, 0, 0)
|
|
124
|
+
)
|
|
125
|
+
table.add_column("indent", width=6)
|
|
126
|
+
table.add_column("Worktree", no_wrap=True)
|
|
127
|
+
table.add_column("Where", overflow="fold")
|
|
128
|
+
table.add_column("What", overflow="fold")
|
|
129
|
+
for item in finding.evidence:
|
|
130
|
+
table.add_row(
|
|
131
|
+
Text(""),
|
|
132
|
+
Text(label(item.key)),
|
|
133
|
+
Text(display_path(item.source)),
|
|
134
|
+
Text(item.value or ""),
|
|
135
|
+
)
|
|
136
|
+
console.print(table)
|
|
137
|
+
if finding.recommended_action:
|
|
138
|
+
console.print(Text(f" Next: {finding.recommended_action}"))
|
|
139
|
+
if finding.verify_command:
|
|
140
|
+
console.print(Text(f" Check: {finding.verify_command}", style="dim"))
|
|
141
|
+
for limitation in finding.limitations:
|
|
142
|
+
console.print(Text(f" Note: {limitation}", style="dim"))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def render_plan(snapshot: Snapshot, suggestions: list[Suggestion], console: Console) -> None:
|
|
146
|
+
"""The suggested changes, grouped by worktree; nothing is applied."""
|
|
147
|
+
console.print(Text(f"worktree-shield {snapshot.tool_version}", style="bold"))
|
|
148
|
+
if snapshot.repository is None:
|
|
149
|
+
render_failed_discovery(snapshot, console)
|
|
150
|
+
return
|
|
151
|
+
if not suggestions:
|
|
152
|
+
console.print(Text("Nothing to change: no collision needs attention."))
|
|
153
|
+
return
|
|
154
|
+
name, _ = worktree_names(snapshot.repository)
|
|
155
|
+
in_env = any(PurePosixPath(s.where.split(":", 1)[0]).name in LOCAL_FILES for s in suggestions)
|
|
156
|
+
reads_env = snapshot.policy is not None and snapshot.policy.include_env_files
|
|
157
|
+
check = VERIFY_WITH_ENV if in_env and not reads_env else VERIFY
|
|
158
|
+
console.print(
|
|
159
|
+
Text(f"Suggested changes. Nothing was changed: make them yourself, then run {check}.")
|
|
160
|
+
)
|
|
161
|
+
by_worktree: dict[str, list[Suggestion]] = {}
|
|
162
|
+
for suggestion in suggestions:
|
|
163
|
+
by_worktree.setdefault(suggestion.worktree, []).append(suggestion)
|
|
164
|
+
for worktree, items in by_worktree.items():
|
|
165
|
+
console.print()
|
|
166
|
+
console.print(Text(name(worktree), style="bold"))
|
|
167
|
+
table = Table(box=None, show_edge=False, pad_edge=False, show_header=False)
|
|
168
|
+
table.add_column("indent", width=2)
|
|
169
|
+
table.add_column("Where", no_wrap=True)
|
|
170
|
+
table.add_column("Change", overflow="fold")
|
|
171
|
+
table.add_column("Rule", no_wrap=True, style="dim")
|
|
172
|
+
for item in items:
|
|
173
|
+
table.add_row(
|
|
174
|
+
Text(""), Text(display_path(item.where)), Text(item.change), Text(item.rule)
|
|
175
|
+
)
|
|
176
|
+
console.print(table)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""JSON output: the diagnosed snapshot as plain data, for CI, agents and bug reports.
|
|
2
|
+
|
|
3
|
+
The format is versioned by ``schema_version`` and described by docs/json-schema-v1.json;
|
|
4
|
+
a contract test keeps the two in step. Pass a redacted snapshot.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from dataclasses import fields, is_dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from worktree_shield.models import Snapshot, Status
|
|
16
|
+
|
|
17
|
+
WORST_FIRST = (
|
|
18
|
+
Status.FAIL,
|
|
19
|
+
Status.WARN,
|
|
20
|
+
Status.UNKNOWN,
|
|
21
|
+
Status.PASS,
|
|
22
|
+
Status.NOT_TESTED,
|
|
23
|
+
Status.NOT_APPLICABLE,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def summary(snapshot: Snapshot) -> dict[str, Any]:
|
|
28
|
+
"""What a script looks at first; every value follows from the findings."""
|
|
29
|
+
counts = Counter(finding.status for finding in snapshot.findings)
|
|
30
|
+
worst = next((status.value for status in WORST_FIRST if counts.get(status)), None)
|
|
31
|
+
repository = snapshot.repository
|
|
32
|
+
return {
|
|
33
|
+
"worktrees": len(repository.worktrees) if repository is not None else None,
|
|
34
|
+
"counts": {status.value: counts.get(status, 0) for status in Status},
|
|
35
|
+
"worst": worst,
|
|
36
|
+
"strict_failure": counts.get(Status.FAIL, 0) > 0,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def snapshot_to_dict(snapshot: Snapshot) -> dict[str, Any]:
|
|
41
|
+
"""Plain dicts, lists and scalars; enums become their documented string values."""
|
|
42
|
+
plain = _plain(snapshot)
|
|
43
|
+
if not isinstance(plain, dict): # pragma: no cover - a dataclass becomes a dict
|
|
44
|
+
raise TypeError("snapshot did not serialise to an object")
|
|
45
|
+
plain["summary"] = summary(snapshot)
|
|
46
|
+
return plain
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_json(snapshot: Snapshot) -> str:
|
|
50
|
+
"""Indented JSON with a trailing newline."""
|
|
51
|
+
return json.dumps(snapshot_to_dict(snapshot), indent=2, ensure_ascii=False) + "\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _plain(value: Any) -> Any:
|
|
55
|
+
if isinstance(value, Enum):
|
|
56
|
+
return value.value
|
|
57
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
58
|
+
return {f.name: _plain(getattr(value, f.name)) for f in fields(value)}
|
|
59
|
+
if isinstance(value, tuple | list):
|
|
60
|
+
return [_plain(item) for item in value]
|
|
61
|
+
return value
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Markdown output: an issue- or PR-ready report of an already redacted snapshot.
|
|
2
|
+
|
|
3
|
+
Collected text is escaped, so a path containing ``|`` cannot break a table and
|
|
4
|
+
``<tag>`` cannot become HTML in an issue tracker; values go in code spans whose fence is
|
|
5
|
+
longer than any backtick run inside them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from worktree_shield.models import Finding, Snapshot
|
|
14
|
+
from worktree_shield.reports.findings import LABEL, PROBLEMS, counts_line
|
|
15
|
+
from worktree_shield.reports.terminal import redaction_line, worktree_names
|
|
16
|
+
|
|
17
|
+
PROJECT_URL = "https://github.com/BhuwanJoshi-01/worktree-shield"
|
|
18
|
+
_SPECIAL = re.compile(r"([\\`*_\[\]<>|#])")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def text(value: str) -> str:
|
|
22
|
+
"""Plain text that Markdown and HTML will not interpret; one line."""
|
|
23
|
+
return _SPECIAL.sub(r"\\\1", " ".join(value.split()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def code(value: str) -> str:
|
|
27
|
+
"""An inline code span that survives any backticks inside the value."""
|
|
28
|
+
flat = " ".join(value.split())
|
|
29
|
+
longest = max((len(run) for run in re.findall(r"`+", flat)), default=0)
|
|
30
|
+
fence = "`" * (longest + 1)
|
|
31
|
+
padding = " " if flat.startswith("`") or flat.endswith("`") else ""
|
|
32
|
+
return f"{fence}{padding}{flat}{padding}{fence}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cell_code(value: str) -> str:
|
|
36
|
+
"""A code span for a table cell: GitHub splits cells at ``|`` even inside code spans
|
|
37
|
+
unless it is escaped."""
|
|
38
|
+
return code(value).replace("|", "\\|")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_markdown(snapshot: Snapshot) -> str:
|
|
42
|
+
"""The report: counts, a table of every finding, then what needs attention."""
|
|
43
|
+
lines = ["# Worktree collision report", ""]
|
|
44
|
+
repository = snapshot.repository
|
|
45
|
+
if repository is None:
|
|
46
|
+
lines += ["The worktrees could not be listed:", ""]
|
|
47
|
+
lines += [f"- {code(f.probe)}: {text(f.detail)}" for f in snapshot.probe_failures]
|
|
48
|
+
return "\n".join([*lines, "", _footer(snapshot), ""])
|
|
49
|
+
name, _ = worktree_names(repository)
|
|
50
|
+
policy = snapshot.policy
|
|
51
|
+
source = code(policy.source) if policy and policy.source else "built-in defaults"
|
|
52
|
+
count = len(repository.worktrees)
|
|
53
|
+
lines += [
|
|
54
|
+
f"**{text(counts_line(snapshot.findings))}** across {count} "
|
|
55
|
+
f"worktree{'s' if count != 1 else ''}. Policy: {source}.",
|
|
56
|
+
"",
|
|
57
|
+
f"worktree-shield {text(snapshot.tool_version)}, {text(snapshot.created_at)}.",
|
|
58
|
+
"",
|
|
59
|
+
]
|
|
60
|
+
if snapshot.findings:
|
|
61
|
+
lines += ["| Status | Rule | Finding | Certainty |", "| --- | --- | --- | --- |"]
|
|
62
|
+
for finding in snapshot.findings:
|
|
63
|
+
certainty = finding.certainty.value if finding.certainty else "—"
|
|
64
|
+
lines.append(
|
|
65
|
+
f"| {LABEL[finding.status]} | {code(finding.id)} | {text(finding.summary)} "
|
|
66
|
+
f"| {certainty} |"
|
|
67
|
+
)
|
|
68
|
+
lines.append("")
|
|
69
|
+
problems = [f for f in snapshot.findings if f.status in PROBLEMS]
|
|
70
|
+
if problems:
|
|
71
|
+
lines += ["## What needs attention", ""]
|
|
72
|
+
for finding in problems:
|
|
73
|
+
lines += _details(finding, name)
|
|
74
|
+
return "\n".join([*lines, _footer(snapshot), ""])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _details(finding: Finding, label: Callable[[str], str]) -> list[str]:
|
|
78
|
+
title = f"### {LABEL[finding.status]} {code(finding.id)}: {text(finding.summary)}"
|
|
79
|
+
lines = [title, "", text(finding.reason), ""]
|
|
80
|
+
if finding.caused_by:
|
|
81
|
+
lines += [f"Follows from {code(finding.caused_by)}.", ""]
|
|
82
|
+
if finding.evidence:
|
|
83
|
+
lines += ["| Worktree | Where | What |", "| --- | --- | --- |"]
|
|
84
|
+
for item in finding.evidence:
|
|
85
|
+
value = cell_code(item.value) if item.value is not None else "not reported"
|
|
86
|
+
lines.append(f"| {text(label(item.key))} | {cell_code(item.source)} | {value} |")
|
|
87
|
+
lines.append("")
|
|
88
|
+
if finding.recommended_action:
|
|
89
|
+
lines += [f"**Next step:** {text(finding.recommended_action)}", ""]
|
|
90
|
+
if finding.verify_command:
|
|
91
|
+
lines += [f"**Check afterwards:** {code(finding.verify_command)}", ""]
|
|
92
|
+
for limitation in finding.limitations:
|
|
93
|
+
lines += [f"*Note:* {text(limitation)}", ""]
|
|
94
|
+
return lines
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _footer(snapshot: Snapshot) -> str:
|
|
98
|
+
closing = (
|
|
99
|
+
f"---\n\nMade by [worktree-shield]({PROJECT_URL}), read-only: nothing in the "
|
|
100
|
+
"repository was run, changed or locked."
|
|
101
|
+
)
|
|
102
|
+
line = redaction_line(snapshot)
|
|
103
|
+
return closing + (f" {text(line)}." if line else "")
|