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 ADDED
@@ -0,0 +1 @@
1
+ # @tear: 3
tears/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ # @tear: 3
2
+ from tears.cli import main
3
+
4
+ raise SystemExit(main())
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())