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/mutate.py ADDED
@@ -0,0 +1,124 @@
1
+ # @tear: 3
2
+ """Core tear-mutation primitives shared by the hook and CLI subcommands.
3
+
4
+ `set_tear` sets a @tear header to an arbitrary level (replacing an existing one
5
+ or inserting a new one). `process_file` is the filesystem boundary. `find_repo_root`
6
+ locates the nearest .git dir or .tears.toml.
7
+
8
+ The hook always calls process_file with tear=max_tear. The CLI subcommands
9
+ `tears up`, `tears down`, and `tears set` call it after validating direction.
10
+ `tears init` calls set_tear directly (it already holds the file content).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from tears.exclude import is_excluded
18
+ from tears.styles import (
19
+ COMMENT_STYLES,
20
+ ENCODING_RE,
21
+ FILENAME_STYLES,
22
+ LINE_HEADER_RE,
23
+ MAX_HEADER_LINES,
24
+ SHEBANG_RE,
25
+ CommentStyle,
26
+ )
27
+
28
+
29
+ def set_tear(
30
+ content: str,
31
+ *,
32
+ tear: int = 3,
33
+ extension: str = ".py",
34
+ filename: str = "",
35
+ ) -> str:
36
+ """Return `content` with the @tear header set to `tear`.
37
+
38
+ Replaces any existing @tear line in the first 5 lines. If none is found and
39
+ the file type is known (by extension or filename), inserts a new header
40
+ respecting shebangs and PEP 263 encoding declarations.
41
+ """
42
+ lines = content.splitlines(keepends=True)
43
+
44
+ replaced = False
45
+ for i, line in enumerate(lines[:MAX_HEADER_LINES]):
46
+ new_line, n = LINE_HEADER_RE.subn(rf"\g<1>{tear}", line, count=1)
47
+ if n:
48
+ lines[i] = new_line
49
+ replaced = True
50
+
51
+ if replaced:
52
+ return "".join(lines)
53
+
54
+ style = _resolve_style(extension, filename)
55
+ if style is None:
56
+ return content
57
+
58
+ insert_at = 0
59
+ if lines and SHEBANG_RE.match(lines[0]):
60
+ insert_at = 1
61
+ if insert_at < len(lines) and ENCODING_RE.search(lines[insert_at]):
62
+ insert_at += 1
63
+
64
+ ending = _detect_line_ending(lines)
65
+ lines.insert(insert_at, _format_header(style, tear) + ending)
66
+ return "".join(lines)
67
+
68
+
69
+ def process_file(
70
+ path: Path,
71
+ *,
72
+ tear: int,
73
+ exclude: list[str],
74
+ repo_root: Path,
75
+ ) -> bool:
76
+ """Apply `set_tear` to a single file. Returns True iff the file was modified."""
77
+ if not path.is_file():
78
+ return False
79
+ if is_excluded(path, repo_root, exclude):
80
+ return False
81
+ content = path.read_text()
82
+ new_content = set_tear(content, tear=tear, extension=path.suffix, filename=path.name)
83
+ if new_content == content:
84
+ return False
85
+ path.write_text(new_content)
86
+ return True
87
+
88
+
89
+ def find_repo_root(start: Path) -> Path:
90
+ """Walk up from `start` to find the repo root (.git dir wins, then .tears.toml)."""
91
+ here = start.resolve()
92
+ if here.is_file():
93
+ here = here.parent
94
+ ancestors = (here, *here.parents)
95
+ for ancestor in ancestors:
96
+ if (ancestor / ".git").exists():
97
+ return ancestor
98
+ for ancestor in ancestors:
99
+ if (ancestor / ".tears.toml").exists():
100
+ return ancestor
101
+ return Path.cwd()
102
+
103
+
104
+ def _resolve_style(extension: str, filename: str) -> CommentStyle | None:
105
+ style = COMMENT_STYLES.get(extension.lower())
106
+ if style is not None:
107
+ return style
108
+ return FILENAME_STYLES.get(filename)
109
+
110
+
111
+ def _format_header(style: CommentStyle, tear: int) -> str:
112
+ opener, closer = style
113
+ if closer is None:
114
+ return f"{opener} @tear: {tear}"
115
+ return f"{opener} @tear: {tear} {closer}"
116
+
117
+
118
+ def _detect_line_ending(lines: list[str]) -> str:
119
+ for line in lines:
120
+ if line.endswith("\r\n"):
121
+ return "\r\n"
122
+ if line.endswith("\n"):
123
+ return "\n"
124
+ return "\n"
tears/rules.py ADDED
@@ -0,0 +1,52 @@
1
+ # @tear: 3
2
+ """Pure rule functions: tier comparison and directory requirements.
3
+
4
+ These functions know nothing about files, imports, or graphs — they take primitive
5
+ inputs and return booleans. The checker composes them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ def can_import(
12
+ importer_tier: int,
13
+ target_tier: int,
14
+ resolved_rules: dict[int, frozenset[int]],
15
+ ) -> bool:
16
+ """Is `importer_tier` allowed to import from `target_tier`?
17
+
18
+ `resolved_rules` is the pre-computed full matrix from
19
+ `TearsConfig.resolved_import_rules()`. Per-edge check is one set membership.
20
+ """
21
+ return target_tier in resolved_rules[importer_tier]
22
+
23
+
24
+ def check_directory_requirement(
25
+ file_path: str,
26
+ file_tier: int,
27
+ requirements: dict[str, int],
28
+ ) -> bool:
29
+ """Does `file_tier` satisfy the longest-prefix-matching directory requirement?
30
+
31
+ Matching is path-segment aware: `src/auth` matches `src/auth/tokens.py` but NOT
32
+ `src/authentic/foo.py`. Files in unrestricted directories pass.
33
+ """
34
+ file_segments = _segments(file_path)
35
+ longest_match: int | None = None
36
+ longest_len = -1
37
+ for dir_key, required_tier in requirements.items():
38
+ dir_segments = _segments(dir_key)
39
+ if len(dir_segments) > len(file_segments):
40
+ continue
41
+ if file_segments[: len(dir_segments)] != dir_segments:
42
+ continue
43
+ if len(dir_segments) > longest_len:
44
+ longest_len = len(dir_segments)
45
+ longest_match = required_tier
46
+ if longest_match is None:
47
+ return True
48
+ return file_tier <= longest_match
49
+
50
+
51
+ def _segments(path: str) -> tuple[str, ...]:
52
+ return tuple(p for p in path.strip("/").split("/") if p)
tears/scan.py ADDED
@@ -0,0 +1,83 @@
1
+ # @tear: 3
2
+ """Scan orchestration and output formatting.
3
+
4
+ Loads the config, builds the import graph via grimp, runs the checker, prints a
5
+ human-readable report. The exact output format here is pinned by snapshot tests
6
+ in `tests/scan/fixtures/`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import replace
12
+ from io import StringIO
13
+ from pathlib import Path
14
+
15
+ from tears.checker import CheckReport, FileReport, check
16
+ from tears.config import load_config
17
+ from tears.graph.grimp_builder import build_grimp_graph
18
+
19
+ _ANSI = {
20
+ "ok": "\033[32m",
21
+ "warn": "\033[33m",
22
+ "fail": "\033[31m",
23
+ "reset": "\033[0m",
24
+ }
25
+
26
+
27
+ def run_scan(
28
+ repo_root: Path,
29
+ *,
30
+ color: bool = False,
31
+ default_tear: int | None = None,
32
+ ) -> tuple[CheckReport, str]:
33
+ """Run a full scan of `repo_root`. Returns the report and formatted output."""
34
+ config = load_config(repo_root)
35
+ if default_tear is not None:
36
+ config = replace(config, default_tear=default_tear)
37
+ graph = build_grimp_graph(repo_root, config)
38
+ report = check(graph, config, repo_root=repo_root)
39
+ return report, format_report(report, repo_root=repo_root, color=color)
40
+
41
+
42
+ def format_report(report: CheckReport, *, repo_root: Path, color: bool = False) -> str:
43
+ """Format a `CheckReport` for human consumption."""
44
+ out = StringIO()
45
+ for fr in report.files:
46
+ out.write(_format_file(fr, repo_root=repo_root, color=color))
47
+ out.write(_format_summary(report))
48
+ return out.getvalue()
49
+
50
+
51
+ def _format_file(fr: FileReport, *, repo_root: Path, color: bool = False) -> str:
52
+ label = {"ok": "OK ", "warn": "WARN ", "fail": "FAIL "}[fr.status]
53
+ if color:
54
+ label = f"{_ANSI[fr.status]}{label}{_ANSI['reset']}"
55
+ rel = _relative(fr.path, repo_root)
56
+ show_tier = fr.tier is not None or fr.is_defaulted
57
+ tier_suffix = f" (tear {fr.effective_tier})" if show_tier else ""
58
+ line = f"{label} {rel}{tier_suffix}\n"
59
+ issues = "".join(f" - {i.message}\n" for i in fr.issues)
60
+ suffix = "\n" if fr.issues else ""
61
+ return line + issues + suffix
62
+
63
+
64
+ def _format_summary(report: CheckReport) -> str:
65
+ n = len(report.files)
66
+ failures = report.failure_count
67
+ warnings = report.warning_count
68
+ return (
69
+ f"{n} {_plural(n, 'file', 'files')} checked, "
70
+ f"{failures} {_plural(failures, 'failure', 'failures')}, "
71
+ f"{warnings} {_plural(warnings, 'warning', 'warnings')}\n"
72
+ )
73
+
74
+
75
+ def _plural(n: int, singular: str, plural: str) -> str:
76
+ return singular if n == 1 else plural
77
+
78
+
79
+ def _relative(path: Path, root: Path) -> str:
80
+ try:
81
+ return path.resolve().relative_to(root.resolve()).as_posix()
82
+ except ValueError:
83
+ return path.as_posix()
tears/styles.py ADDED
@@ -0,0 +1,91 @@
1
+ # @tear: 2
2
+ """Comment-style lookup tables for @tear header insertion.
3
+
4
+ `COMMENT_STYLES` keys on file extension; `FILENAME_STYLES` keys on bare filename
5
+ for extensionless files (Makefile, Dockerfile, etc.). Each value is
6
+ `(opener, closer)` — `closer` is None for line comments, a string for block comments.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ LINE_HEADER_RE = re.compile(r"^([ \t]*[^A-Za-z0-9\s]+[ \t]*@tear:[ \t]*)(\d+)")
14
+ SHEBANG_RE = re.compile(r"^#!")
15
+ ENCODING_RE = re.compile(r"coding[=:]\s*[-\w.]+")
16
+
17
+ MAX_HEADER_LINES = 5
18
+
19
+ CommentStyle = tuple[str, str | None]
20
+
21
+ COMMENT_STYLES: dict[str, CommentStyle] = {
22
+ # Hash line comment
23
+ ".py": ("#", None),
24
+ ".rb": ("#", None),
25
+ ".pl": ("#", None),
26
+ ".sh": ("#", None),
27
+ ".bash": ("#", None),
28
+ ".zsh": ("#", None),
29
+ ".fish": ("#", None),
30
+ ".toml": ("#", None),
31
+ ".yml": ("#", None),
32
+ ".yaml": ("#", None),
33
+ ".r": ("#", None),
34
+ ".ex": ("#", None),
35
+ ".exs": ("#", None),
36
+ # Double-slash line comment
37
+ ".js": ("//", None),
38
+ ".mjs": ("//", None),
39
+ ".cjs": ("//", None),
40
+ ".ts": ("//", None),
41
+ ".tsx": ("//", None),
42
+ ".jsx": ("//", None),
43
+ ".go": ("//", None),
44
+ ".rs": ("//", None),
45
+ ".java": ("//", None),
46
+ ".kt": ("//", None),
47
+ ".swift": ("//", None),
48
+ ".c": ("//", None),
49
+ ".cpp": ("//", None),
50
+ ".cc": ("//", None),
51
+ ".cxx": ("//", None),
52
+ ".h": ("//", None),
53
+ ".hpp": ("//", None),
54
+ ".cs": ("//", None),
55
+ ".scala": ("//", None),
56
+ ".dart": ("//", None),
57
+ ".zig": ("//", None),
58
+ # Double-dash line comment
59
+ ".sql": ("--", None),
60
+ ".lua": ("--", None),
61
+ ".hs": ("--", None),
62
+ ".elm": ("--", None),
63
+ # Semicolon line comment
64
+ ".ini": (";", None),
65
+ ".cfg": (";", None),
66
+ ".clj": (";", None),
67
+ ".lisp": (";", None),
68
+ # HTML / XML / Markdown block comment
69
+ ".html": ("<!--", "-->"),
70
+ ".htm": ("<!--", "-->"),
71
+ ".xml": ("<!--", "-->"),
72
+ ".md": ("<!--", "-->"),
73
+ ".markdown": ("<!--", "-->"),
74
+ ".svg": ("<!--", "-->"),
75
+ # CSS block comment
76
+ ".css": ("/*", "*/"),
77
+ ".scss": ("/*", "*/"),
78
+ ".less": ("/*", "*/"),
79
+ }
80
+
81
+ FILENAME_STYLES: dict[str, CommentStyle] = {
82
+ "Makefile": ("#", None),
83
+ "Dockerfile": ("#", None),
84
+ "Rakefile": ("#", None),
85
+ "Gemfile": ("#", None),
86
+ ".gitignore": ("#", None),
87
+ ".gitattributes": ("#", None),
88
+ ".dockerignore": ("#", None),
89
+ ".env": ("#", None),
90
+ ".notears": ("#", None),
91
+ }
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.3
2
+ Name: tears-cli
3
+ Version: 0.1.0
4
+ Summary: Tiered Enforcement, Authorship Review System — vibe-code responsibly.
5
+ Author: Hillel Twersky
6
+ Author-email: Hillel Twersky <35217356+Thillel@users.noreply.github.com>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 HillelT
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ Requires-Dist: grimp>=3.4
29
+ Requires-Python: >=3.11
@@ -0,0 +1,18 @@
1
+ tears/__init__.py,sha256=jgPo5r8xynvu78EjGYFfiPXPxQ_xY5XhdEgYXwPh9lM,11
2
+ tears/__main__.py,sha256=6nzjgYLoIrtot_o2FvWuKb6j6jo_rMGczVmiSEL3vEE,64
3
+ tears/checker.py,sha256=ZPCEoSOPR3UPupWUI6ivD7dk529Ug2M7Y3oFA0dsVeM,4952
4
+ tears/cli.py,sha256=GcvDwiqnwwY8DwbPHso0SQhQu7mEFMLgXm17QfLJfZE,10682
5
+ tears/config.py,sha256=3zHwGe95ObqOXVN_1HH3FXvrpzxt_5IBL4X6cs6hxEA,9745
6
+ tears/exclude.py,sha256=nkqvDESnkQhcIEAHw6zET2mW6ikHkVYDp_F6XusmG2I,1197
7
+ tears/graph/__init__.py,sha256=RW5Mr-3bCmsCOchh-zLxdJvUMQE1br2oz2DLZ2Mdda0,1236
8
+ tears/graph/grimp_builder.py,sha256=3pvBN19FI7tJV_oJCcmJYyEg_O20wHxlAAIjd9Z3RJs,4909
9
+ tears/header.py,sha256=MGWFgAK-YbrEKnaD_BgT6KbFn4rfi-QMbXfAbXpgKN4,973
10
+ tears/hook.py,sha256=_gfp_Qnp9jXiJFtuOyZaP63w64Jo0gwt5MuDFTGx_Ac,2636
11
+ tears/mutate.py,sha256=9rlJTkv_jQ6Gsj33NHhpN8oyXNSeJjzg91Y5aCAnTjQ,3556
12
+ tears/rules.py,sha256=3kwo91eepQYgBmbHNZEYdhXTttQcAPqqaG0eUdPe_fA,1693
13
+ tears/scan.py,sha256=rcyFqRSrWQEzNez29BdBUQpT49G9XmZiDafbKeUGGxA,2693
14
+ tears/styles.py,sha256=uZta1ScXHaUuLZARBuujgQwh0lxae2qY4EK_Vmg_Lsc,2457
15
+ tears_cli-0.1.0.dist-info/WHEEL,sha256=fWriCkzqm-pffF5af4gJC9iI5FMFaJTuN9UxxxzOmdY,81
16
+ tears_cli-0.1.0.dist-info/entry_points.txt,sha256=LQ_6hCwT5_mYfi1Tu3ad5F6YrSYuGYhhyVb7qkgaHyQ,42
17
+ tears_cli-0.1.0.dist-info/METADATA,sha256=KAYxrwESRrFtBzwQcgytm3plHZuHxomomhKNmJyYZac,1533
18
+ tears_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.14
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ tears = tears.cli:main
3
+