sweepito 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.
- sweepito/__init__.py +3 -0
- sweepito/__main__.py +3 -0
- sweepito/api.py +72 -0
- sweepito/cli.py +551 -0
- sweepito/config.py +268 -0
- sweepito/doctor.py +102 -0
- sweepito/errors.py +30 -0
- sweepito/executor.py +108 -0
- sweepito/git.py +127 -0
- sweepito/hooks.py +82 -0
- sweepito/models.py +147 -0
- sweepito/planner.py +335 -0
- sweepito/profiles/__init__.py +1 -0
- sweepito/profiles/common.toml +33 -0
- sweepito/profiles/cpp.toml +43 -0
- sweepito/profiles/dart.toml +21 -0
- sweepito/profiles/dotnet.toml +37 -0
- sweepito/profiles/elixir.toml +39 -0
- sweepito/profiles/go.toml +21 -0
- sweepito/profiles/jupyter.toml +38 -0
- sweepito/profiles/jvm.toml +38 -0
- sweepito/profiles/node-pm.toml +17 -0
- sweepito/profiles/node.toml +98 -0
- sweepito/profiles/php.toml +54 -0
- sweepito/profiles/python.toml +82 -0
- sweepito/profiles/r.toml +42 -0
- sweepito/profiles/ruby.toml +45 -0
- sweepito/profiles/rust.toml +15 -0
- sweepito/profiles/swift.toml +31 -0
- sweepito/profiles/terraform.toml +31 -0
- sweepito/registry.py +77 -0
- sweepito/ui.py +267 -0
- sweepito-0.1.0.dist-info/METADATA +313 -0
- sweepito-0.1.0.dist-info/RECORD +37 -0
- sweepito-0.1.0.dist-info/WHEEL +4 -0
- sweepito-0.1.0.dist-info/entry_points.txt +2 -0
- sweepito-0.1.0.dist-info/licenses/LICENSE +21 -0
sweepito/__init__.py
ADDED
sweepito/__main__.py
ADDED
sweepito/api.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""UI-free core API. The command line is a thin wrapper around these functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sweepito import git
|
|
9
|
+
from sweepito.config import (
|
|
10
|
+
FileConfig,
|
|
11
|
+
Options,
|
|
12
|
+
Settings,
|
|
13
|
+
find_config,
|
|
14
|
+
load_config,
|
|
15
|
+
merge,
|
|
16
|
+
)
|
|
17
|
+
from sweepito.errors import ConfigError
|
|
18
|
+
from sweepito.executor import apply_plan
|
|
19
|
+
from sweepito.models import CleanResult, Plan
|
|
20
|
+
from sweepito.planner import build_plan
|
|
21
|
+
from sweepito.registry import Registry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Context:
|
|
26
|
+
root: Path
|
|
27
|
+
settings: Settings
|
|
28
|
+
registry: Registry
|
|
29
|
+
config: FileConfig
|
|
30
|
+
in_git: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_root(path: str | None = None) -> Path:
|
|
34
|
+
"""The project root: ``path``, else the git root of the cwd, else the cwd."""
|
|
35
|
+
if path:
|
|
36
|
+
root = Path(path).resolve()
|
|
37
|
+
if not root.is_dir():
|
|
38
|
+
raise ConfigError(f"Not a directory: {path}")
|
|
39
|
+
return root
|
|
40
|
+
cwd = Path.cwd().resolve()
|
|
41
|
+
top = git.toplevel(cwd)
|
|
42
|
+
return top.resolve() if top else cwd
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def prepare(
|
|
46
|
+
root: Path, options: Options | None = None, config_file: str | None = None
|
|
47
|
+
) -> Context:
|
|
48
|
+
options = options or Options()
|
|
49
|
+
top = git.toplevel(root)
|
|
50
|
+
if config_file:
|
|
51
|
+
path = Path(config_file)
|
|
52
|
+
if not path.is_file():
|
|
53
|
+
raise ConfigError(f"Config file not found: {config_file}")
|
|
54
|
+
else:
|
|
55
|
+
path = find_config(root, stop=top or root)
|
|
56
|
+
config = load_config(path) if path else FileConfig()
|
|
57
|
+
registry = Registry.build(config.profile_defs, config.rules)
|
|
58
|
+
return Context(
|
|
59
|
+
root=root,
|
|
60
|
+
settings=merge(options, config),
|
|
61
|
+
registry=registry,
|
|
62
|
+
config=config,
|
|
63
|
+
in_git=top is not None,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def scan(ctx: Context) -> Plan:
|
|
68
|
+
return build_plan(ctx.root, ctx.settings, ctx.registry)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clean(ctx: Context, plan: Plan) -> CleanResult:
|
|
72
|
+
return apply_plan(plan, in_git=ctx.in_git)
|
sweepito/cli.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Command line interface: a thin wrapper over :mod:`sweepito.api`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from sweepito import __version__, api, doctor, git, hooks
|
|
10
|
+
from sweepito.config import Options
|
|
11
|
+
from sweepito.errors import (
|
|
12
|
+
EXIT_FAILED,
|
|
13
|
+
EXIT_INTERRUPTED,
|
|
14
|
+
EXIT_OK,
|
|
15
|
+
EXIT_UNEXPECTED,
|
|
16
|
+
EXIT_USAGE,
|
|
17
|
+
ConfigError,
|
|
18
|
+
SafetyRefusal,
|
|
19
|
+
SweepitoError,
|
|
20
|
+
)
|
|
21
|
+
from sweepito.models import Plan, Risk, format_size
|
|
22
|
+
from sweepito.planner import build_plan
|
|
23
|
+
from sweepito.registry import Registry
|
|
24
|
+
from sweepito.ui import (
|
|
25
|
+
error,
|
|
26
|
+
info,
|
|
27
|
+
make_console,
|
|
28
|
+
print_checks,
|
|
29
|
+
print_config,
|
|
30
|
+
print_hook_status,
|
|
31
|
+
print_plan,
|
|
32
|
+
print_profile_detail,
|
|
33
|
+
print_profiles,
|
|
34
|
+
print_report,
|
|
35
|
+
print_result,
|
|
36
|
+
print_rule_detail,
|
|
37
|
+
print_rules,
|
|
38
|
+
print_summary,
|
|
39
|
+
success,
|
|
40
|
+
warning,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
RISK_CHOICES = ["custom", "safe", "configurable", "high-impact"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _global_parent() -> argparse.ArgumentParser:
|
|
47
|
+
p = argparse.ArgumentParser(add_help=False)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--config", metavar="FILE", help="Use this config file (skips discovery)."
|
|
50
|
+
)
|
|
51
|
+
p.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
|
|
52
|
+
p.add_argument("-q", "--quiet", action="store_true", help="Print only when needed.")
|
|
53
|
+
p.add_argument(
|
|
54
|
+
"-v", "--verbose", action="store_true", help="Explain skipped paths."
|
|
55
|
+
)
|
|
56
|
+
p.add_argument("--no-color", action="store_true", help="Disable colored output.")
|
|
57
|
+
p.add_argument("--hook", action="store_true", help=argparse.SUPPRESS)
|
|
58
|
+
return p
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _selection_parent() -> argparse.ArgumentParser:
|
|
62
|
+
p = argparse.ArgumentParser(add_help=False)
|
|
63
|
+
p.add_argument("path", nargs="?", help="Project root (default: git root or cwd).")
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"-p",
|
|
66
|
+
"--profile",
|
|
67
|
+
action="append",
|
|
68
|
+
default=[],
|
|
69
|
+
dest="profiles",
|
|
70
|
+
metavar="NAME",
|
|
71
|
+
help="Only use this profile (repeatable).",
|
|
72
|
+
)
|
|
73
|
+
p.add_argument(
|
|
74
|
+
"--enable",
|
|
75
|
+
action="append",
|
|
76
|
+
default=[],
|
|
77
|
+
metavar="RULE",
|
|
78
|
+
help="Enable a rule by ID (repeatable).",
|
|
79
|
+
)
|
|
80
|
+
p.add_argument(
|
|
81
|
+
"--disable",
|
|
82
|
+
action="append",
|
|
83
|
+
default=[],
|
|
84
|
+
metavar="RULE",
|
|
85
|
+
help="Disable a rule by ID (repeatable).",
|
|
86
|
+
)
|
|
87
|
+
p.add_argument(
|
|
88
|
+
"-x",
|
|
89
|
+
"--exclude",
|
|
90
|
+
action="append",
|
|
91
|
+
default=[],
|
|
92
|
+
metavar="PATTERN",
|
|
93
|
+
help="Never touch matching paths (repeatable).",
|
|
94
|
+
)
|
|
95
|
+
p.add_argument(
|
|
96
|
+
"--risk",
|
|
97
|
+
choices=RISK_CHOICES,
|
|
98
|
+
help="Highest risk level to include (custom: only your own rules).",
|
|
99
|
+
)
|
|
100
|
+
p.add_argument(
|
|
101
|
+
"--no-git-check",
|
|
102
|
+
action="store_true",
|
|
103
|
+
help="Allow running outside a Git repository (high impact).",
|
|
104
|
+
)
|
|
105
|
+
return p
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
109
|
+
glob, sel = _global_parent(), _selection_parent()
|
|
110
|
+
parser = argparse.ArgumentParser(
|
|
111
|
+
prog="sweepito",
|
|
112
|
+
description="Safely clean caches and build artifacts across technologies.",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--version", action="version", version=f"sweepito {__version__}"
|
|
116
|
+
)
|
|
117
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
118
|
+
|
|
119
|
+
scan = sub.add_parser("scan", parents=[glob, sel], help="List cleanup candidates.")
|
|
120
|
+
scan.set_defaults(func=cmd_scan)
|
|
121
|
+
|
|
122
|
+
clean = sub.add_parser(
|
|
123
|
+
"clean", parents=[glob, sel], help="Delete cleanup candidates."
|
|
124
|
+
)
|
|
125
|
+
clean.add_argument("-n", "--dry-run", action="store_true", help="Same as scan.")
|
|
126
|
+
clean.add_argument(
|
|
127
|
+
"-y", "--yes", action="store_true", help="Do not ask for confirmation."
|
|
128
|
+
)
|
|
129
|
+
clean.add_argument(
|
|
130
|
+
"--non-interactive",
|
|
131
|
+
action="store_true",
|
|
132
|
+
help="Never prompt; proceed with the configured rules.",
|
|
133
|
+
)
|
|
134
|
+
clean.set_defaults(func=cmd_clean)
|
|
135
|
+
|
|
136
|
+
check = sub.add_parser(
|
|
137
|
+
"check", parents=[glob, sel], help="Exit with code 1 if candidates exist."
|
|
138
|
+
)
|
|
139
|
+
check.set_defaults(func=cmd_check)
|
|
140
|
+
|
|
141
|
+
report = sub.add_parser(
|
|
142
|
+
"report",
|
|
143
|
+
parents=[glob, sel],
|
|
144
|
+
help="Summarise reclaimable space by profile, rule and risk.",
|
|
145
|
+
)
|
|
146
|
+
report.set_defaults(func=cmd_report)
|
|
147
|
+
|
|
148
|
+
rules = sub.add_parser("rules", parents=[glob], help="List profiles and rules.")
|
|
149
|
+
rules.add_argument("path", nargs="?", help="Project root (for custom rules).")
|
|
150
|
+
rules.add_argument("-p", "--profile", action="append", default=[], dest="profiles")
|
|
151
|
+
rules.add_argument(
|
|
152
|
+
"--risk", choices=RISK_CHOICES, help="Only rules at exactly this risk level."
|
|
153
|
+
)
|
|
154
|
+
rules.add_argument("--explain", metavar="RULE", help="Describe one rule.")
|
|
155
|
+
rules.set_defaults(func=cmd_rules)
|
|
156
|
+
|
|
157
|
+
profiles = sub.add_parser("profiles", parents=[glob], help="List profiles.")
|
|
158
|
+
profiles.add_argument("path", nargs="?", help="Project root (for custom profiles).")
|
|
159
|
+
profiles.add_argument("--explain", metavar="PROFILE", help="Describe one profile.")
|
|
160
|
+
profiles.set_defaults(func=cmd_profiles)
|
|
161
|
+
|
|
162
|
+
cfg = sub.add_parser("config", help="Show or validate configuration.")
|
|
163
|
+
cfg_sub = cfg.add_subparsers(dest="action", metavar="ACTION", required=True)
|
|
164
|
+
for name, text in (
|
|
165
|
+
("show", "Show effective settings."),
|
|
166
|
+
("validate", "Validate the configuration."),
|
|
167
|
+
):
|
|
168
|
+
leaf = cfg_sub.add_parser(name, parents=[glob], help=text)
|
|
169
|
+
leaf.add_argument("path", nargs="?", help="Project root.")
|
|
170
|
+
leaf.set_defaults(func=cmd_config)
|
|
171
|
+
|
|
172
|
+
init = sub.add_parser("init", parents=[glob], help="Write a starter sweepito.toml.")
|
|
173
|
+
init.add_argument("path", nargs="?", help="Project root.")
|
|
174
|
+
init.add_argument(
|
|
175
|
+
"--force", action="store_true", help="Overwrite an existing file."
|
|
176
|
+
)
|
|
177
|
+
init.set_defaults(func=cmd_init)
|
|
178
|
+
|
|
179
|
+
hook = sub.add_parser("hook", help="Manage the native Git hook.")
|
|
180
|
+
hook_sub = hook.add_subparsers(dest="action", metavar="ACTION", required=True)
|
|
181
|
+
for name, text in (
|
|
182
|
+
("install", "Install the hook."),
|
|
183
|
+
("uninstall", "Remove the hook."),
|
|
184
|
+
("status", "Show installed hooks."),
|
|
185
|
+
):
|
|
186
|
+
leaf = hook_sub.add_parser(name, parents=[glob], help=text)
|
|
187
|
+
leaf.add_argument("path", nargs="?", help="Project root.")
|
|
188
|
+
if name != "status":
|
|
189
|
+
leaf.add_argument(
|
|
190
|
+
"--stage", choices=sorted(hooks.STAGES), default="post-commit"
|
|
191
|
+
)
|
|
192
|
+
leaf.set_defaults(func=cmd_hook)
|
|
193
|
+
|
|
194
|
+
doc = sub.add_parser("doctor", parents=[glob], help="Diagnose the environment.")
|
|
195
|
+
doc.add_argument("path", nargs="?", help="Project root.")
|
|
196
|
+
doc.set_defaults(func=cmd_doctor)
|
|
197
|
+
return parser
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _options(args: argparse.Namespace) -> Options:
|
|
201
|
+
return Options(
|
|
202
|
+
profiles=tuple(getattr(args, "profiles", ()) or ()),
|
|
203
|
+
enable=tuple(getattr(args, "enable", ()) or ()),
|
|
204
|
+
disable=tuple(getattr(args, "disable", ()) or ()),
|
|
205
|
+
exclude=tuple(getattr(args, "exclude", ()) or ()),
|
|
206
|
+
risk=(Risk.parse(args.risk) if getattr(args, "risk", None) else None),
|
|
207
|
+
no_git_check=getattr(args, "no_git_check", False),
|
|
208
|
+
hook=args.hook,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _context(args: argparse.Namespace) -> api.Context:
|
|
213
|
+
root = api.resolve_root(args.path)
|
|
214
|
+
return api.prepare(root, _options(args), args.config)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _emit(payload: dict) -> None:
|
|
218
|
+
print(json.dumps(payload, indent=2))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _plan(args: argparse.Namespace) -> tuple[api.Context, Plan]:
|
|
222
|
+
ctx = _context(args)
|
|
223
|
+
return ctx, api.scan(ctx)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _quiet(args: argparse.Namespace, ctx: api.Context) -> bool:
|
|
227
|
+
return args.quiet or ctx.settings.quiet
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _scan_and_report(args: argparse.Namespace) -> Plan:
|
|
231
|
+
ctx, plan = _plan(args)
|
|
232
|
+
if args.json:
|
|
233
|
+
_emit({**plan.to_dict(), "dry_run": True})
|
|
234
|
+
return plan
|
|
235
|
+
console = make_console(args.no_color)
|
|
236
|
+
if _quiet(args, ctx):
|
|
237
|
+
if plan.candidates:
|
|
238
|
+
info(
|
|
239
|
+
console,
|
|
240
|
+
f"sweepito: {len(plan.candidates)} cleanup candidate(s), "
|
|
241
|
+
f"{format_size(plan.total_bytes)} reclaimable (run 'sweepito scan').",
|
|
242
|
+
)
|
|
243
|
+
return plan
|
|
244
|
+
print_plan(console, plan, args.verbose)
|
|
245
|
+
print_summary(console, plan)
|
|
246
|
+
return plan
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
250
|
+
_scan_and_report(args)
|
|
251
|
+
return EXIT_OK
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
255
|
+
plan = _scan_and_report(args)
|
|
256
|
+
return EXIT_FAILED if plan.candidates else EXIT_OK
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _confirm(count: int, size: int) -> bool:
|
|
260
|
+
if not sys.stdin.isatty():
|
|
261
|
+
return False
|
|
262
|
+
try:
|
|
263
|
+
answer = input(f"Delete {count} item(s), {format_size(size)}? [y/N] ")
|
|
264
|
+
except EOFError:
|
|
265
|
+
raise SafetyRefusal(
|
|
266
|
+
"Confirmation required but no input is available. "
|
|
267
|
+
"Pass --yes or --non-interactive."
|
|
268
|
+
) from None
|
|
269
|
+
return answer.strip().lower() in ("y", "yes")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_clean(args: argparse.Namespace) -> int:
|
|
273
|
+
ctx, plan = _plan(args)
|
|
274
|
+
quiet = _quiet(args, ctx)
|
|
275
|
+
console = make_console(args.no_color)
|
|
276
|
+
if args.dry_run:
|
|
277
|
+
if args.json:
|
|
278
|
+
_emit({**plan.to_dict(), "dry_run": True})
|
|
279
|
+
else:
|
|
280
|
+
print_plan(console, plan, args.verbose)
|
|
281
|
+
print_summary(console, plan)
|
|
282
|
+
return EXIT_OK
|
|
283
|
+
if not plan.candidates:
|
|
284
|
+
if args.json:
|
|
285
|
+
_emit({**plan.to_dict(), "dry_run": False, **_empty_result()})
|
|
286
|
+
elif not quiet:
|
|
287
|
+
print_summary(console, plan, deleting=True)
|
|
288
|
+
return EXIT_OK
|
|
289
|
+
if not (args.yes or args.non_interactive):
|
|
290
|
+
if not sys.stdin.isatty():
|
|
291
|
+
raise SafetyRefusal(
|
|
292
|
+
"Confirmation required but no terminal is attached. "
|
|
293
|
+
"Pass --yes or --non-interactive."
|
|
294
|
+
)
|
|
295
|
+
print_plan(console, plan, args.verbose)
|
|
296
|
+
if not _confirm(len(plan.candidates), plan.total_bytes):
|
|
297
|
+
info(console, "Aborted. Nothing deleted.")
|
|
298
|
+
return EXIT_OK
|
|
299
|
+
result = api.clean(ctx, plan)
|
|
300
|
+
if args.json:
|
|
301
|
+
_emit({**plan.to_dict(), "dry_run": False, **result.to_dict()})
|
|
302
|
+
elif not quiet or result.failed:
|
|
303
|
+
print_result(make_console(args.no_color, stderr=quiet), result)
|
|
304
|
+
return EXIT_FAILED if result.failed else EXIT_OK
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _empty_result() -> dict:
|
|
308
|
+
return {"removed": [], "failed": [], "freed_bytes": 0}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
312
|
+
ctx = _context(args)
|
|
313
|
+
# Informational only: plan everything, delete nothing.
|
|
314
|
+
from dataclasses import replace
|
|
315
|
+
|
|
316
|
+
settings = replace(ctx.settings, risk=Risk.HIGH_IMPACT)
|
|
317
|
+
plan = build_plan(ctx.root, settings, ctx.registry)
|
|
318
|
+
groups: dict[str, dict[str, dict]] = {"profile": {}, "rule": {}, "risk": {}}
|
|
319
|
+
for cand in plan.candidates:
|
|
320
|
+
for kind, key in (
|
|
321
|
+
("profile", cand.profile),
|
|
322
|
+
("rule", cand.rule),
|
|
323
|
+
("risk", cand.risk.label),
|
|
324
|
+
):
|
|
325
|
+
slot = groups[kind].setdefault(key, {"count": 0, "bytes": 0})
|
|
326
|
+
if kind == "rule":
|
|
327
|
+
slot["risk"] = cand.risk.label
|
|
328
|
+
slot["count"] += 1
|
|
329
|
+
slot["bytes"] += cand.size
|
|
330
|
+
if args.json:
|
|
331
|
+
_emit({"root": str(plan.root), "total_bytes": plan.total_bytes, **groups})
|
|
332
|
+
return EXIT_OK
|
|
333
|
+
print_report(make_console(args.no_color), str(plan.root), groups, plan.total_bytes)
|
|
334
|
+
return EXIT_OK
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def cmd_rules(args: argparse.Namespace) -> int:
|
|
338
|
+
root = api.resolve_root(args.path)
|
|
339
|
+
ctx = api.prepare(root, Options(hook=args.hook), args.config)
|
|
340
|
+
registry: Registry = ctx.registry
|
|
341
|
+
if args.explain:
|
|
342
|
+
rule = registry.rules.get(args.explain)
|
|
343
|
+
if rule is None:
|
|
344
|
+
raise ConfigError(f"Unknown rule ID {args.explain!r}.")
|
|
345
|
+
details = {
|
|
346
|
+
"id": rule.id,
|
|
347
|
+
"profile": rule.profile,
|
|
348
|
+
"risk": rule.risk.label,
|
|
349
|
+
"paths": list(rule.paths),
|
|
350
|
+
"requires": list(rule.requires or registry.profiles[rule.profile].markers),
|
|
351
|
+
"description": rule.description,
|
|
352
|
+
"allow_untracked": rule.allow_untracked,
|
|
353
|
+
}
|
|
354
|
+
if args.json:
|
|
355
|
+
_emit(details)
|
|
356
|
+
else:
|
|
357
|
+
print_rule_detail(make_console(args.no_color), details)
|
|
358
|
+
return EXIT_OK
|
|
359
|
+
rows = []
|
|
360
|
+
for profile in registry.profiles.values():
|
|
361
|
+
if args.profiles and profile.id not in args.profiles:
|
|
362
|
+
continue
|
|
363
|
+
for rule in profile.rules:
|
|
364
|
+
if args.risk and rule.risk.label != args.risk:
|
|
365
|
+
continue
|
|
366
|
+
rows.append(rule)
|
|
367
|
+
if args.json:
|
|
368
|
+
_emit(
|
|
369
|
+
{
|
|
370
|
+
"rules": [
|
|
371
|
+
{
|
|
372
|
+
"id": r.id,
|
|
373
|
+
"profile": r.profile,
|
|
374
|
+
"risk": r.risk.label,
|
|
375
|
+
"default": r.risk in (Risk.SAFE, Risk.CUSTOM),
|
|
376
|
+
"description": r.description,
|
|
377
|
+
}
|
|
378
|
+
for r in rows
|
|
379
|
+
]
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
return EXIT_OK
|
|
383
|
+
print_rules(make_console(args.no_color), rows)
|
|
384
|
+
return EXIT_OK
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def cmd_profiles(args: argparse.Namespace) -> int:
|
|
388
|
+
root = api.resolve_root(args.path)
|
|
389
|
+
registry = api.prepare(root, Options(hook=args.hook), args.config).registry
|
|
390
|
+
if args.explain:
|
|
391
|
+
profile = registry.profiles.get(args.explain)
|
|
392
|
+
if profile is None:
|
|
393
|
+
raise ConfigError(f"Unknown profile {args.explain!r}.")
|
|
394
|
+
if args.json:
|
|
395
|
+
_emit(
|
|
396
|
+
{
|
|
397
|
+
"id": profile.id,
|
|
398
|
+
"description": profile.description,
|
|
399
|
+
"markers": list(profile.markers),
|
|
400
|
+
"always_active": profile.always,
|
|
401
|
+
"rules": [
|
|
402
|
+
{
|
|
403
|
+
"id": r.id,
|
|
404
|
+
"risk": r.risk.label,
|
|
405
|
+
"paths": list(r.paths),
|
|
406
|
+
"requires": list(r.requires or profile.markers),
|
|
407
|
+
"description": r.description,
|
|
408
|
+
}
|
|
409
|
+
for r in profile.rules
|
|
410
|
+
],
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
else:
|
|
414
|
+
print_profile_detail(make_console(args.no_color), profile)
|
|
415
|
+
return EXIT_OK
|
|
416
|
+
profiles = list(registry.profiles.values())
|
|
417
|
+
if args.json:
|
|
418
|
+
_emit(
|
|
419
|
+
{
|
|
420
|
+
"profiles": [
|
|
421
|
+
{
|
|
422
|
+
"id": p.id,
|
|
423
|
+
"description": p.description,
|
|
424
|
+
"markers": list(p.markers),
|
|
425
|
+
"always_active": p.always,
|
|
426
|
+
"rules": len(p.rules),
|
|
427
|
+
**{k.replace("-", "_"): v for k, v in p.risk_counts().items()},
|
|
428
|
+
}
|
|
429
|
+
for p in profiles
|
|
430
|
+
]
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
else:
|
|
434
|
+
print_profiles(make_console(args.no_color), profiles)
|
|
435
|
+
return EXIT_OK
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def cmd_config(args: argparse.Namespace) -> int:
|
|
439
|
+
ctx = _context(args)
|
|
440
|
+
if args.action == "validate":
|
|
441
|
+
if args.json:
|
|
442
|
+
_emit({"valid": True})
|
|
443
|
+
else:
|
|
444
|
+
success(make_console(args.no_color), "Configuration OK.")
|
|
445
|
+
return EXIT_OK
|
|
446
|
+
payload = {
|
|
447
|
+
"config_file": str(ctx.config.path) if ctx.config.path else None,
|
|
448
|
+
"settings": ctx.settings.to_dict(),
|
|
449
|
+
"custom_rules": [r.id for r in ctx.config.rules],
|
|
450
|
+
}
|
|
451
|
+
if args.json:
|
|
452
|
+
_emit(payload)
|
|
453
|
+
else:
|
|
454
|
+
print_config(make_console(args.no_color), payload)
|
|
455
|
+
return EXIT_OK
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
_TEMPLATE = """\
|
|
459
|
+
# Sweepito configuration. See 'sweepito rules' for rule IDs.
|
|
460
|
+
|
|
461
|
+
[defaults]
|
|
462
|
+
profiles = ["auto"] # or an explicit list, e.g. ["python", "node"]
|
|
463
|
+
risk = "safe" # custom | safe | configurable | high-impact
|
|
464
|
+
|
|
465
|
+
[clean]
|
|
466
|
+
enable = [] # e.g. ["rust.target", "node.next"]
|
|
467
|
+
disable = []
|
|
468
|
+
exclude = [] # e.g. ["dist/", "build/keep-me/"]
|
|
469
|
+
|
|
470
|
+
[hook]
|
|
471
|
+
quiet = true
|
|
472
|
+
|
|
473
|
+
# Custom path rule: only git-ignored, untracked paths are ever removed.
|
|
474
|
+
# [[rule]]
|
|
475
|
+
# id = "custom.test-output"
|
|
476
|
+
# paths = ["test-results/", "tmp/e2e/**"]
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
481
|
+
root = api.resolve_root(args.path)
|
|
482
|
+
target = root / "sweepito.toml"
|
|
483
|
+
if target.exists() and not args.force:
|
|
484
|
+
raise SafetyRefusal(f"{target} already exists. Use --force to overwrite.")
|
|
485
|
+
ctx = api.prepare(root, Options(), args.config)
|
|
486
|
+
detected = api.scan(ctx).profiles if ctx.in_git else []
|
|
487
|
+
header = (
|
|
488
|
+
f"# Detected profiles: {', '.join(detected) or 'none'}\n" if detected else ""
|
|
489
|
+
)
|
|
490
|
+
target.write_text(header + _TEMPLATE, encoding="utf-8", newline="\n")
|
|
491
|
+
success(make_console(args.no_color), f"Wrote {target}")
|
|
492
|
+
return EXIT_OK
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def cmd_hook(args: argparse.Namespace) -> int:
|
|
496
|
+
root = api.resolve_root(args.path)
|
|
497
|
+
if git.toplevel(root) is None:
|
|
498
|
+
raise SafetyRefusal("Hooks need a Git repository.")
|
|
499
|
+
console = make_console(args.no_color)
|
|
500
|
+
if args.action == "install":
|
|
501
|
+
path = hooks.install(root, args.stage)
|
|
502
|
+
success(console, f"Installed {args.stage} hook: {path}")
|
|
503
|
+
elif args.action == "uninstall":
|
|
504
|
+
if hooks.uninstall(root, args.stage):
|
|
505
|
+
success(console, f"Removed {args.stage} hook.")
|
|
506
|
+
else:
|
|
507
|
+
info(console, f"No {args.stage} hook installed.")
|
|
508
|
+
else:
|
|
509
|
+
state = hooks.status(root)
|
|
510
|
+
if args.json:
|
|
511
|
+
_emit(state)
|
|
512
|
+
else:
|
|
513
|
+
print_hook_status(console, state)
|
|
514
|
+
return EXIT_OK
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
518
|
+
checks = doctor.run_checks(args.path, args.config)
|
|
519
|
+
healthy = doctor.passed(checks)
|
|
520
|
+
if args.json:
|
|
521
|
+
_emit({"healthy": healthy, "checks": [c.to_dict() for c in checks]})
|
|
522
|
+
else:
|
|
523
|
+
print_checks(make_console(args.no_color), checks)
|
|
524
|
+
return EXIT_OK if healthy else EXIT_FAILED
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _stderr(args: argparse.Namespace):
|
|
528
|
+
return make_console(getattr(args, "no_color", False), stderr=True)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def main(argv: list[str] | None = None) -> int:
|
|
532
|
+
parser = build_parser()
|
|
533
|
+
args = parser.parse_args(argv)
|
|
534
|
+
if not getattr(args, "func", None):
|
|
535
|
+
parser.print_help()
|
|
536
|
+
return EXIT_USAGE
|
|
537
|
+
try:
|
|
538
|
+
return args.func(args)
|
|
539
|
+
except SweepitoError as exc:
|
|
540
|
+
error(_stderr(args), str(exc))
|
|
541
|
+
return exc.exit_code
|
|
542
|
+
except KeyboardInterrupt:
|
|
543
|
+
warning(_stderr(args), "sweepito: interrupted")
|
|
544
|
+
return EXIT_INTERRUPTED
|
|
545
|
+
except Exception as exc: # noqa: BLE001 - last-resort handler
|
|
546
|
+
error(_stderr(args), f"unexpected error: {exc!r}")
|
|
547
|
+
return EXIT_UNEXPECTED
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
if __name__ == "__main__":
|
|
551
|
+
raise SystemExit(main())
|