tears-cli 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.
- tears/__init__.py +1 -0
- tears/__main__.py +4 -0
- tears/checker.py +153 -0
- tears/cli.py +341 -0
- tears/config.py +235 -0
- tears/exclude.py +38 -0
- tears/graph/__init__.py +42 -0
- tears/graph/grimp_builder.py +145 -0
- tears/header.py +30 -0
- tears/hook.py +84 -0
- tears/mutate.py +124 -0
- tears/rules.py +52 -0
- tears/scan.py +83 -0
- tears/styles.py +91 -0
- tears_cli-0.1.0.dist-info/METADATA +29 -0
- tears_cli-0.1.0.dist-info/RECORD +18 -0
- tears_cli-0.1.0.dist-info/WHEEL +4 -0
- tears_cli-0.1.0.dist-info/entry_points.txt +3 -0
tears/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# @tear: 3
|
tears/__main__.py
ADDED
tears/checker.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# @tear: 3
|
|
2
|
+
"""The pure checker: ImportGraph + TearsConfig -> list of FileReports.
|
|
3
|
+
|
|
4
|
+
This module knows nothing about the filesystem, parsing, or output formatting.
|
|
5
|
+
It composes the rule functions over the data the graph exposes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from tears.config import TearsConfig
|
|
15
|
+
from tears.graph import ImportGraph
|
|
16
|
+
from tears.rules import can_import, check_directory_requirement
|
|
17
|
+
|
|
18
|
+
Severity = Literal["fail", "warn"]
|
|
19
|
+
Status = Literal["ok", "warn", "fail"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Issue:
|
|
24
|
+
severity: Severity
|
|
25
|
+
message: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class FileReport:
|
|
30
|
+
path: Path
|
|
31
|
+
tier: int | None
|
|
32
|
+
effective_tier: int
|
|
33
|
+
is_defaulted: bool = False
|
|
34
|
+
issues: tuple[Issue, ...] = field(default_factory=lambda: ())
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def status(self) -> Status:
|
|
38
|
+
if any(i.severity == "fail" for i in self.issues):
|
|
39
|
+
return "fail"
|
|
40
|
+
if any(i.severity == "warn" for i in self.issues):
|
|
41
|
+
return "warn"
|
|
42
|
+
return "ok"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class CheckReport:
|
|
47
|
+
files: tuple[FileReport, ...]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def exit_code(self) -> int:
|
|
51
|
+
return 1 if any(f.status == "fail" for f in self.files) else 0
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def failure_count(self) -> int:
|
|
55
|
+
return sum(1 for f in self.files if f.status == "fail")
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def warning_count(self) -> int:
|
|
59
|
+
return sum(1 for f in self.files if f.status == "warn")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check(
|
|
63
|
+
graph: ImportGraph,
|
|
64
|
+
config: TearsConfig,
|
|
65
|
+
*,
|
|
66
|
+
repo_root: Path,
|
|
67
|
+
) -> CheckReport:
|
|
68
|
+
"""Run all v1 checks: missing headers, directory requirements, import tiers."""
|
|
69
|
+
resolved_rules = config.resolved_import_rules()
|
|
70
|
+
missing_severity: Severity = "fail" if config.missing_header == "error" else "warn"
|
|
71
|
+
|
|
72
|
+
reports: list[FileReport] = []
|
|
73
|
+
for file_path in sorted(graph.files(), key=str):
|
|
74
|
+
tier = graph.tier_of(file_path)
|
|
75
|
+
rel_path = _relative_posix(file_path, repo_root)
|
|
76
|
+
issues: list[Issue] = []
|
|
77
|
+
|
|
78
|
+
is_defaulted = False
|
|
79
|
+
if tier is not None:
|
|
80
|
+
effective_tier = tier
|
|
81
|
+
else:
|
|
82
|
+
effective_tier, is_defaulted = config.resolve_missing_tier(rel_path)
|
|
83
|
+
if not is_defaulted:
|
|
84
|
+
issues.append(
|
|
85
|
+
Issue(
|
|
86
|
+
severity=missing_severity,
|
|
87
|
+
message=f"missing @tear header (treated as tear {effective_tier})",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if not check_directory_requirement(rel_path, effective_tier, config.directory_requirements):
|
|
92
|
+
required = _required_tier(rel_path, config.directory_requirements)
|
|
93
|
+
issues.append(
|
|
94
|
+
Issue(
|
|
95
|
+
severity="fail",
|
|
96
|
+
message=f"directory requires tear {required}, file is tear {effective_tier}",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
for target in sorted(graph.imports_of(file_path), key=str):
|
|
101
|
+
target_tier = graph.tier_of(target)
|
|
102
|
+
if target_tier is not None:
|
|
103
|
+
target_effective = target_tier
|
|
104
|
+
else:
|
|
105
|
+
target_rel_path = _relative_posix(target, repo_root)
|
|
106
|
+
target_effective, _ = config.resolve_missing_tier(target_rel_path)
|
|
107
|
+
if can_import(effective_tier, target_effective, resolved_rules):
|
|
108
|
+
continue
|
|
109
|
+
target_rel = _relative_posix(target, repo_root)
|
|
110
|
+
issues.append(
|
|
111
|
+
Issue(
|
|
112
|
+
severity="fail",
|
|
113
|
+
message=(
|
|
114
|
+
f"imports {target_rel} (tear {target_effective}): "
|
|
115
|
+
f"tear {effective_tier} cannot import from tear {target_effective}"
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
reports.append(
|
|
121
|
+
FileReport(
|
|
122
|
+
path=file_path,
|
|
123
|
+
tier=tier,
|
|
124
|
+
effective_tier=effective_tier,
|
|
125
|
+
is_defaulted=is_defaulted,
|
|
126
|
+
issues=tuple(issues),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return CheckReport(files=tuple(reports))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _relative_posix(path: Path, root: Path) -> str:
|
|
134
|
+
try:
|
|
135
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
136
|
+
except ValueError:
|
|
137
|
+
return path.as_posix()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _required_tier(rel_path: str, requirements: dict[str, int]) -> int | None:
|
|
141
|
+
file_segments = tuple(p for p in rel_path.strip("/").split("/") if p)
|
|
142
|
+
longest_match: int | None = None
|
|
143
|
+
longest_len = -1
|
|
144
|
+
for dir_key, required_tier in requirements.items():
|
|
145
|
+
dir_segments = tuple(p for p in dir_key.strip("/").split("/") if p)
|
|
146
|
+
if len(dir_segments) > len(file_segments):
|
|
147
|
+
continue
|
|
148
|
+
if file_segments[: len(dir_segments)] != dir_segments:
|
|
149
|
+
continue
|
|
150
|
+
if len(dir_segments) > longest_len:
|
|
151
|
+
longest_len = len(dir_segments)
|
|
152
|
+
longest_match = required_tier
|
|
153
|
+
return longest_match
|
tears/cli.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# @tear: 3
|
|
2
|
+
"""`tears` — CLI entry point."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from importlib.metadata import version as _pkg_version
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from tears.config import ConfigError, TearsConfig, load_config
|
|
13
|
+
from tears.exclude import is_excluded
|
|
14
|
+
from tears.header import parse_tear_level
|
|
15
|
+
from tears.mutate import find_repo_root, process_file, set_tear
|
|
16
|
+
from tears.scan import run_scan
|
|
17
|
+
|
|
18
|
+
_SUBCOMMANDS = frozenset({"up", "down", "set", "init"})
|
|
19
|
+
|
|
20
|
+
_DEFAULT_TOML = "# @tear: 3\nmax_tear = 3\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: list[str] | None = None) -> int:
|
|
24
|
+
if argv is None:
|
|
25
|
+
argv = sys.argv[1:]
|
|
26
|
+
|
|
27
|
+
first_positional = next((a for a in argv if not a.startswith("-")), None)
|
|
28
|
+
if first_positional in _SUBCOMMANDS:
|
|
29
|
+
return _dispatch(argv)
|
|
30
|
+
return _cmd_scan(argv)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _dispatch(argv: list[str]) -> int:
|
|
34
|
+
cmd, rest = argv[0], argv[1:]
|
|
35
|
+
if cmd == "up":
|
|
36
|
+
return _cmd_up(rest)
|
|
37
|
+
if cmd == "down":
|
|
38
|
+
return _cmd_down(rest)
|
|
39
|
+
if cmd == "set":
|
|
40
|
+
return _cmd_set(rest)
|
|
41
|
+
if cmd == "init":
|
|
42
|
+
return _cmd_init(rest)
|
|
43
|
+
return 2 # unreachable
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cmd_scan(argv: list[str]) -> int:
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
prog="tears",
|
|
49
|
+
description="Tiered Enforcement, Authorship Review System — scan a repo.",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"path",
|
|
53
|
+
nargs="?",
|
|
54
|
+
default=".",
|
|
55
|
+
help="Path to scan (defaults to the current directory).",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--version",
|
|
59
|
+
action="version",
|
|
60
|
+
version=f"%(prog)s {_pkg_version('tears-cli')}",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--default-tear",
|
|
64
|
+
type=int,
|
|
65
|
+
metavar="N",
|
|
66
|
+
dest="default_tear",
|
|
67
|
+
help="Treat files without a @tear header as tier N (overrides config).",
|
|
68
|
+
)
|
|
69
|
+
args = parser.parse_args(argv)
|
|
70
|
+
|
|
71
|
+
color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
72
|
+
repo_root = Path(args.path).resolve()
|
|
73
|
+
try:
|
|
74
|
+
report, output = run_scan(repo_root, color=color, default_tear=args.default_tear)
|
|
75
|
+
except ConfigError as exc:
|
|
76
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
|
|
79
|
+
sys.stdout.write(output)
|
|
80
|
+
return report.exit_code
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_mutate_argv(
|
|
84
|
+
argv: list[str], prog: str, desc: str
|
|
85
|
+
) -> tuple[Path, int, TearsConfig, Path] | int:
|
|
86
|
+
"""Parse path + --tear, load config. Returns (path, target, config, repo_root) or exit code."""
|
|
87
|
+
parser = argparse.ArgumentParser(prog=prog, description=desc)
|
|
88
|
+
parser.add_argument("path", type=Path, help="File or directory to mark.")
|
|
89
|
+
parser.add_argument("--tear", type=int, required=True, metavar="N", help="Target tear level.")
|
|
90
|
+
try:
|
|
91
|
+
args = parser.parse_args(argv)
|
|
92
|
+
except SystemExit as exc:
|
|
93
|
+
return int(exc.code) if isinstance(exc.code, int) else 2
|
|
94
|
+
|
|
95
|
+
path = args.path.resolve()
|
|
96
|
+
repo_root = find_repo_root(path)
|
|
97
|
+
try:
|
|
98
|
+
config = load_config(repo_root)
|
|
99
|
+
except ConfigError as exc:
|
|
100
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
101
|
+
return 2
|
|
102
|
+
|
|
103
|
+
target = args.tear
|
|
104
|
+
if not 0 <= target <= config.max_tear:
|
|
105
|
+
print(f"error: tear {target} out of range [0, {config.max_tear}]", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
|
|
108
|
+
return path, target, config, repo_root
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cmd_up(argv: list[str]) -> int:
|
|
112
|
+
ctx = _parse_mutate_argv(
|
|
113
|
+
argv,
|
|
114
|
+
"tears up",
|
|
115
|
+
"Mark a file or directory as less trusted (tear number goes up).",
|
|
116
|
+
)
|
|
117
|
+
if isinstance(ctx, int):
|
|
118
|
+
return ctx
|
|
119
|
+
path, target, config, repo_root = ctx
|
|
120
|
+
|
|
121
|
+
if path.is_file():
|
|
122
|
+
return _apply_up_file(path, target, config, repo_root, bulk=False)
|
|
123
|
+
|
|
124
|
+
for file_path in _walk(path, config, repo_root):
|
|
125
|
+
_apply_up_file(file_path, target, config, repo_root, bulk=True)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _apply_up_file(
|
|
130
|
+
path: Path, target: int, config: TearsConfig, repo_root: Path, *, bulk: bool
|
|
131
|
+
) -> int:
|
|
132
|
+
try:
|
|
133
|
+
content = path.read_text()
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
136
|
+
return 1
|
|
137
|
+
current = parse_tear_level(content, max_tear=config.max_tear)
|
|
138
|
+
if current is not None and target < current:
|
|
139
|
+
if not bulk:
|
|
140
|
+
print(
|
|
141
|
+
f"error: {path.name} is already at tear {current}; "
|
|
142
|
+
f"to lower the number use 'tears down'",
|
|
143
|
+
file=sys.stderr,
|
|
144
|
+
)
|
|
145
|
+
return 1
|
|
146
|
+
return 0 # silently skip in bulk mode
|
|
147
|
+
changed = process_file(path, tear=target, exclude=config.exclude, repo_root=repo_root)
|
|
148
|
+
if changed:
|
|
149
|
+
prev = str(current) if current is not None else "∅"
|
|
150
|
+
print(f"{path.name} {prev} → {target}")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _cmd_down(argv: list[str]) -> int:
|
|
155
|
+
ctx = _parse_mutate_argv(
|
|
156
|
+
argv,
|
|
157
|
+
"tears down",
|
|
158
|
+
"Mark a file or directory as more trusted (tear number goes down).",
|
|
159
|
+
)
|
|
160
|
+
if isinstance(ctx, int):
|
|
161
|
+
return ctx
|
|
162
|
+
path, target, config, repo_root = ctx
|
|
163
|
+
|
|
164
|
+
if path.is_file():
|
|
165
|
+
return _apply_down_file(path, target, config, repo_root, bulk=False)
|
|
166
|
+
|
|
167
|
+
for file_path in _walk(path, config, repo_root):
|
|
168
|
+
_apply_down_file(file_path, target, config, repo_root, bulk=True)
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _apply_down_file(
|
|
173
|
+
path: Path, target: int, config: TearsConfig, repo_root: Path, *, bulk: bool
|
|
174
|
+
) -> int:
|
|
175
|
+
try:
|
|
176
|
+
content = path.read_text()
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
current = parse_tear_level(content, max_tear=config.max_tear)
|
|
181
|
+
effective_current = current if current is not None else config.max_tear
|
|
182
|
+
if target >= effective_current:
|
|
183
|
+
if not bulk:
|
|
184
|
+
noun = str(current) if current is not None else f"∅ (implicit {config.max_tear})"
|
|
185
|
+
print(
|
|
186
|
+
f"error: {path.name} is at tear {noun}; to raise the number use 'tears up'",
|
|
187
|
+
file=sys.stderr,
|
|
188
|
+
)
|
|
189
|
+
return 1
|
|
190
|
+
return 0 # silently skip in bulk mode
|
|
191
|
+
changed = process_file(path, tear=target, exclude=config.exclude, repo_root=repo_root)
|
|
192
|
+
if changed:
|
|
193
|
+
prev = str(current) if current is not None else "∅"
|
|
194
|
+
print(f"{path.name} {prev} → {target}")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _cmd_set(argv: list[str]) -> int:
|
|
199
|
+
ctx = _parse_mutate_argv(
|
|
200
|
+
argv,
|
|
201
|
+
"tears set",
|
|
202
|
+
"Set a file or directory to an exact tear level (no direction check).",
|
|
203
|
+
)
|
|
204
|
+
if isinstance(ctx, int):
|
|
205
|
+
return ctx
|
|
206
|
+
path, target, config, repo_root = ctx
|
|
207
|
+
|
|
208
|
+
if path.is_file():
|
|
209
|
+
return _apply_set_file(path, target, config, repo_root)
|
|
210
|
+
|
|
211
|
+
for file_path in _walk(path, config, repo_root):
|
|
212
|
+
_apply_set_file(file_path, target, config, repo_root)
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _apply_set_file(path: Path, target: int, config: TearsConfig, repo_root: Path) -> int:
|
|
217
|
+
try:
|
|
218
|
+
content = path.read_text()
|
|
219
|
+
except OSError as exc:
|
|
220
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
current = parse_tear_level(content, max_tear=config.max_tear)
|
|
223
|
+
changed = process_file(path, tear=target, exclude=config.exclude, repo_root=repo_root)
|
|
224
|
+
if changed:
|
|
225
|
+
prev = str(current) if current is not None else "∅"
|
|
226
|
+
print(f"{path.name} {prev} → {target}")
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _walk(root: Path, config: TearsConfig, repo_root: Path) -> list[Path]:
|
|
231
|
+
results: list[Path] = []
|
|
232
|
+
for file_path in sorted(root.rglob("*")):
|
|
233
|
+
if not file_path.is_file():
|
|
234
|
+
continue
|
|
235
|
+
if ".git" in file_path.parts or "__pycache__" in file_path.parts:
|
|
236
|
+
continue
|
|
237
|
+
if is_excluded(file_path, repo_root, config.exclude):
|
|
238
|
+
continue
|
|
239
|
+
results.append(file_path)
|
|
240
|
+
return results
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _cmd_init(argv: list[str]) -> int:
|
|
244
|
+
"""Scaffold .tears.toml and tag all headerless files."""
|
|
245
|
+
parser = argparse.ArgumentParser(
|
|
246
|
+
prog="tears init",
|
|
247
|
+
description="Scaffold .tears.toml and insert @tear headers on untagged files.",
|
|
248
|
+
)
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"path",
|
|
251
|
+
nargs="?",
|
|
252
|
+
default=".",
|
|
253
|
+
type=Path,
|
|
254
|
+
help="Repo root (default: .).",
|
|
255
|
+
)
|
|
256
|
+
parser.add_argument(
|
|
257
|
+
"--tear",
|
|
258
|
+
type=int,
|
|
259
|
+
default=None,
|
|
260
|
+
metavar="N",
|
|
261
|
+
help="Tear level to assign (default: max_tear from config).",
|
|
262
|
+
)
|
|
263
|
+
args = parser.parse_args(argv)
|
|
264
|
+
|
|
265
|
+
repo_root = args.path.resolve()
|
|
266
|
+
|
|
267
|
+
config_path = repo_root / ".tears.toml"
|
|
268
|
+
if not config_path.exists():
|
|
269
|
+
config_path.write_text(_DEFAULT_TOML)
|
|
270
|
+
print(f"created {config_path.name}")
|
|
271
|
+
else:
|
|
272
|
+
print(f"{config_path.name} already exists, skipping")
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
config = load_config(repo_root)
|
|
276
|
+
except ConfigError as exc:
|
|
277
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
278
|
+
return 2
|
|
279
|
+
|
|
280
|
+
if args.tear is not None:
|
|
281
|
+
target = args.tear
|
|
282
|
+
if not 0 <= target <= config.max_tear:
|
|
283
|
+
print(f"error: tear {target} out of range [0, {config.max_tear}]", file=sys.stderr)
|
|
284
|
+
return 2
|
|
285
|
+
elif sys.stdin.isatty():
|
|
286
|
+
target = _prompt_init_tear(config.max_tear)
|
|
287
|
+
else:
|
|
288
|
+
target = config.max_tear
|
|
289
|
+
|
|
290
|
+
count = 0
|
|
291
|
+
for file_path in _walk(repo_root, config, repo_root):
|
|
292
|
+
try:
|
|
293
|
+
content = file_path.read_text()
|
|
294
|
+
except (OSError, UnicodeDecodeError):
|
|
295
|
+
continue
|
|
296
|
+
if parse_tear_level(content, max_tear=config.max_tear) is not None:
|
|
297
|
+
continue
|
|
298
|
+
new_content = set_tear(
|
|
299
|
+
content,
|
|
300
|
+
tear=target,
|
|
301
|
+
extension=file_path.suffix,
|
|
302
|
+
filename=file_path.name,
|
|
303
|
+
)
|
|
304
|
+
if new_content != content:
|
|
305
|
+
file_path.write_text(new_content)
|
|
306
|
+
count += 1
|
|
307
|
+
|
|
308
|
+
noun = "file" if count == 1 else "files"
|
|
309
|
+
print(f"tagged {count} {noun} at tear {target}")
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _prompt_init_tear(max_tear: int) -> int:
|
|
314
|
+
print()
|
|
315
|
+
print("What tear level should untagged files start at?")
|
|
316
|
+
print()
|
|
317
|
+
print(" 1 — Reviewed human-written, has passed code review (recommended)")
|
|
318
|
+
print(" 2 — Eyeballed checked for obvious issues, not line-by-line")
|
|
319
|
+
print(" 3 — Unreviewed AI-generated or vibe-coded")
|
|
320
|
+
print()
|
|
321
|
+
while True:
|
|
322
|
+
try:
|
|
323
|
+
raw = input("tear level [1]: ").strip()
|
|
324
|
+
except (EOFError, KeyboardInterrupt):
|
|
325
|
+
print()
|
|
326
|
+
return 1
|
|
327
|
+
level = 1 if not raw else None
|
|
328
|
+
if level is None:
|
|
329
|
+
try:
|
|
330
|
+
level = int(raw)
|
|
331
|
+
except ValueError:
|
|
332
|
+
print(f" Enter a number between 0 and {max_tear}.")
|
|
333
|
+
continue
|
|
334
|
+
if not 0 <= level <= max_tear:
|
|
335
|
+
print(f" Enter a number between 0 and {max_tear}.")
|
|
336
|
+
continue
|
|
337
|
+
return level
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
raise SystemExit(main())
|