becwright 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.
- becwright/__init__.py +1 -0
- becwright/bundle.py +157 -0
- becwright/checks/__init__.py +0 -0
- becwright/checks/dangerous_eval.py +34 -0
- becwright/checks/debug_remnants.py +36 -0
- becwright/checks/forbid.py +54 -0
- becwright/checks/hardcoded_secrets.py +46 -0
- becwright/checks/no_token_in_logs.py +37 -0
- becwright/checks/redundant_comments.py +83 -0
- becwright/checks/wildcard_imports.py +34 -0
- becwright/cli.py +179 -0
- becwright/engine.py +68 -0
- becwright/git.py +68 -0
- becwright/rules.py +40 -0
- becwright-0.1.0.dist-info/METADATA +231 -0
- becwright-0.1.0.dist-info/RECORD +20 -0
- becwright-0.1.0.dist-info/WHEEL +5 -0
- becwright-0.1.0.dist-info/entry_points.txt +2 -0
- becwright-0.1.0.dist-info/licenses/LICENSE +21 -0
- becwright-0.1.0.dist-info/top_level.txt +1 -0
becwright/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
becwright/bundle.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import textwrap
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .rules import Rule
|
|
11
|
+
|
|
12
|
+
BUNDLE_VERSION = 1
|
|
13
|
+
|
|
14
|
+
_BUILTIN = re.compile(r"^python3?\s+-m\s+becwright\.checks\.(\w+)(?:\s+(.*))?$")
|
|
15
|
+
_PY_PATH = re.compile(r"[\w./-]+\.py")
|
|
16
|
+
_ITEM_INDENT = re.compile(r"^([ \t]+)-\s", re.MULTILINE)
|
|
17
|
+
_EMPTY_RULES = re.compile(r"^rules:[ \t]*(?:\[[ \t]*\]|\{[ \t]*\})[ \t]*$", re.MULTILINE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BundleError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _clean(value):
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
return value.strip()
|
|
27
|
+
if isinstance(value, list):
|
|
28
|
+
return [v.strip() if isinstance(v, str) else v for v in value]
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _origin(root: Path) -> str:
|
|
33
|
+
res = subprocess.run(
|
|
34
|
+
["git", "config", "--get", "remote.origin.url"],
|
|
35
|
+
cwd=root, capture_output=True, text=True,
|
|
36
|
+
)
|
|
37
|
+
return res.stdout.strip() or root.name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def classify_check(command: str, root: Path) -> dict:
|
|
41
|
+
command = command.strip()
|
|
42
|
+
builtin = _BUILTIN.match(command)
|
|
43
|
+
if builtin:
|
|
44
|
+
out = {"kind": "builtin", "module": builtin.group(1)}
|
|
45
|
+
if builtin.group(2):
|
|
46
|
+
out["args"] = builtin.group(2).strip()
|
|
47
|
+
return out
|
|
48
|
+
for token in _PY_PATH.findall(command):
|
|
49
|
+
candidate = root / token
|
|
50
|
+
if candidate.is_file():
|
|
51
|
+
return {
|
|
52
|
+
"kind": "script",
|
|
53
|
+
"filename": Path(token).name,
|
|
54
|
+
"source": candidate.read_text(encoding="utf-8"),
|
|
55
|
+
}
|
|
56
|
+
return {"kind": "command", "command": command}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def export_bec(rule: Rule, root: Path) -> str:
|
|
60
|
+
rule_fields: dict = {"id": rule.id}
|
|
61
|
+
if rule.intent:
|
|
62
|
+
rule_fields["intent"] = rule.intent
|
|
63
|
+
if rule.why_it_matters:
|
|
64
|
+
rule_fields["why_it_matters"] = rule.why_it_matters
|
|
65
|
+
if rule.rejected_alternatives:
|
|
66
|
+
rule_fields["rejected_alternatives"] = list(rule.rejected_alternatives)
|
|
67
|
+
rule_fields["paths"] = list(rule.paths)
|
|
68
|
+
rule_fields["severity"] = rule.severity
|
|
69
|
+
|
|
70
|
+
bundle = {
|
|
71
|
+
"becwright_bec": BUNDLE_VERSION,
|
|
72
|
+
"exported_from": _origin(root),
|
|
73
|
+
"rule": rule_fields,
|
|
74
|
+
"check": classify_check(rule.check, root),
|
|
75
|
+
}
|
|
76
|
+
return yaml.safe_dump(bundle, sort_keys=False, allow_unicode=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_bundle(text: str) -> dict:
|
|
80
|
+
try:
|
|
81
|
+
data = yaml.safe_load(text)
|
|
82
|
+
except yaml.YAMLError as e:
|
|
83
|
+
raise BundleError(f"The bundle is not valid YAML: {e}")
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise BundleError("The bundle is empty or malformed.")
|
|
86
|
+
if data.get("becwright_bec") != BUNDLE_VERSION:
|
|
87
|
+
raise BundleError(
|
|
88
|
+
f"Unsupported bundle version: {data.get('becwright_bec')!r} "
|
|
89
|
+
f"(expected {BUNDLE_VERSION})."
|
|
90
|
+
)
|
|
91
|
+
rule = data.get("rule")
|
|
92
|
+
check = data.get("check")
|
|
93
|
+
if not isinstance(rule, dict) or "id" not in rule:
|
|
94
|
+
raise BundleError("The bundle has no valid rule (missing 'rule.id').")
|
|
95
|
+
if not isinstance(check, dict) or "kind" not in check:
|
|
96
|
+
raise BundleError("The bundle has no valid check (missing 'check.kind').")
|
|
97
|
+
required = {"builtin": ("module",), "script": ("filename", "source"), "command": ("command",)}
|
|
98
|
+
kind = check["kind"]
|
|
99
|
+
if kind not in required:
|
|
100
|
+
raise BundleError(f"Unknown check kind: {kind!r}.")
|
|
101
|
+
missing = [f for f in required[kind] if not check.get(f)]
|
|
102
|
+
if missing:
|
|
103
|
+
raise BundleError(f"The '{kind}' check is missing fields: {', '.join(missing)}.")
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def materialize(bundle: dict, root: Path) -> dict:
|
|
108
|
+
check = bundle["check"]
|
|
109
|
+
kind = check.get("kind")
|
|
110
|
+
if kind == "builtin":
|
|
111
|
+
command = f"python3 -m becwright.checks.{check['module']}"
|
|
112
|
+
if check.get("args"):
|
|
113
|
+
command += f" {check['args']}"
|
|
114
|
+
elif kind == "script":
|
|
115
|
+
filename = Path(check["filename"]).name
|
|
116
|
+
dest = root / ".bec" / "checks" / filename
|
|
117
|
+
source = check["source"]
|
|
118
|
+
if dest.exists() and dest.read_text(encoding="utf-8") != source:
|
|
119
|
+
raise BundleError(
|
|
120
|
+
f"A different check already exists at {dest}. Not overwriting it; resolve by hand."
|
|
121
|
+
)
|
|
122
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
dest.write_text(source, encoding="utf-8")
|
|
124
|
+
dest.chmod(0o755)
|
|
125
|
+
command = f"python3 .bec/checks/{filename}"
|
|
126
|
+
elif kind == "command":
|
|
127
|
+
command = check["command"]
|
|
128
|
+
else:
|
|
129
|
+
raise BundleError(f"Unknown check kind: {kind!r}")
|
|
130
|
+
|
|
131
|
+
rule = bundle["rule"]
|
|
132
|
+
out: dict = {"id": rule["id"]}
|
|
133
|
+
for key in ("intent", "why_it_matters", "rejected_alternatives"):
|
|
134
|
+
if rule.get(key):
|
|
135
|
+
out[key] = _clean(rule[key])
|
|
136
|
+
out["paths"] = rule.get("paths", [])
|
|
137
|
+
out["check"] = command
|
|
138
|
+
out["severity"] = rule.get("severity", "blocking")
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def append_rule(rules_path: Path, rule_dict: dict) -> None:
|
|
143
|
+
dumped = yaml.safe_dump([rule_dict], sort_keys=False, allow_unicode=True)
|
|
144
|
+
if not rules_path.exists():
|
|
145
|
+
rules_path.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
rules_path.write_text("rules:\n" + textwrap.indent(dumped, " "), encoding="utf-8")
|
|
147
|
+
return
|
|
148
|
+
# Normalize an empty inline list (`rules: []`) so block items can follow it.
|
|
149
|
+
text = _EMPTY_RULES.sub("rules:", rules_path.read_text(encoding="utf-8"), count=1)
|
|
150
|
+
if text and not text.endswith("\n"):
|
|
151
|
+
text += "\n"
|
|
152
|
+
if not re.search(r"^rules:", text, re.MULTILINE):
|
|
153
|
+
text += "rules:\n"
|
|
154
|
+
# Match the indentation the file already uses for list items.
|
|
155
|
+
existing = _ITEM_INDENT.search(text)
|
|
156
|
+
prefix = existing.group(1) if existing else " "
|
|
157
|
+
rules_path.write_text(text + textwrap.indent(dumped, prefix), encoding="utf-8")
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
PATTERN = re.compile(r"\b(?:eval|exec)\s*\(")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def find_violations(paths: list[str]) -> list[tuple[str, int, str]]:
|
|
10
|
+
violations: list[tuple[str, int, str]] = []
|
|
11
|
+
for path in paths:
|
|
12
|
+
path = path.strip()
|
|
13
|
+
if not path:
|
|
14
|
+
continue
|
|
15
|
+
try:
|
|
16
|
+
with open(path, encoding="utf-8") as f:
|
|
17
|
+
for lineno, line in enumerate(f, start=1):
|
|
18
|
+
if PATTERN.search(line):
|
|
19
|
+
violations.append((path, lineno, line.strip()))
|
|
20
|
+
except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError):
|
|
21
|
+
continue
|
|
22
|
+
return violations
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> int:
|
|
26
|
+
violations = find_violations(sys.stdin.read().splitlines())
|
|
27
|
+
for path, lineno, line in violations:
|
|
28
|
+
print(f" {path}:{lineno}")
|
|
29
|
+
print(f" > {line}")
|
|
30
|
+
return 1 if violations else 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
sys.exit(main())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
PATTERN = re.compile(
|
|
7
|
+
r"\bbreakpoint\s*\(|\b(?:i?pdb)\.set_trace\s*\(|\bimport\s+i?pdb\b"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def find_violations(paths: list[str]) -> list[tuple[str, int, str]]:
|
|
12
|
+
violations: list[tuple[str, int, str]] = []
|
|
13
|
+
for path in paths:
|
|
14
|
+
path = path.strip()
|
|
15
|
+
if not path:
|
|
16
|
+
continue
|
|
17
|
+
try:
|
|
18
|
+
with open(path, encoding="utf-8") as f:
|
|
19
|
+
for lineno, line in enumerate(f, start=1):
|
|
20
|
+
if PATTERN.search(line):
|
|
21
|
+
violations.append((path, lineno, line.strip()))
|
|
22
|
+
except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError):
|
|
23
|
+
continue
|
|
24
|
+
return violations
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
violations = find_violations(sys.stdin.read().splitlines())
|
|
29
|
+
for path, lineno, line in violations:
|
|
30
|
+
print(f" {path}:{lineno}")
|
|
31
|
+
print(f" > {line}")
|
|
32
|
+
return 1 if violations else 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
sys.exit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def find_violations(paths: list[str], pattern: str, flags: int = 0) -> list[tuple[str, int, str]]:
|
|
9
|
+
rx = re.compile(pattern, flags)
|
|
10
|
+
violations: list[tuple[str, int, str]] = []
|
|
11
|
+
for path in paths:
|
|
12
|
+
path = path.strip()
|
|
13
|
+
if not path:
|
|
14
|
+
continue
|
|
15
|
+
try:
|
|
16
|
+
with open(path, encoding="utf-8") as f:
|
|
17
|
+
for lineno, line in enumerate(f, start=1):
|
|
18
|
+
if rx.search(line):
|
|
19
|
+
violations.append((path, lineno, line.strip()))
|
|
20
|
+
except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError):
|
|
21
|
+
continue
|
|
22
|
+
return violations
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> int:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="becwright.checks.forbid",
|
|
28
|
+
description="Fails if a regex pattern appears in the files.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("--pattern", required=True, help="forbidden regex")
|
|
31
|
+
parser.add_argument("--ignore-case", action="store_true", help="ignore case")
|
|
32
|
+
parser.add_argument("--message", default="", help="note to show if there are matches")
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
violations = find_violations(
|
|
37
|
+
sys.stdin.read().splitlines(),
|
|
38
|
+
args.pattern,
|
|
39
|
+
re.IGNORECASE if args.ignore_case else 0,
|
|
40
|
+
)
|
|
41
|
+
except re.error as e:
|
|
42
|
+
print(f"invalid pattern: {e}", file=sys.stderr)
|
|
43
|
+
return 2
|
|
44
|
+
|
|
45
|
+
if args.message and violations:
|
|
46
|
+
print(f" {args.message}")
|
|
47
|
+
for path, lineno, line in violations:
|
|
48
|
+
print(f" {path}:{lineno}")
|
|
49
|
+
print(f" > {line}")
|
|
50
|
+
return 1 if violations else 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
sys.exit(main())
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
_AWS_KEY = r"AKIA[0-9A-Z]{16}"
|
|
7
|
+
_PRIVATE_KEY = r"-----BEGIN [A-Z ]*PRIVATE KEY-----"
|
|
8
|
+
_ASSIGNMENT = (
|
|
9
|
+
r"(password|passwd|secret|api[_-]?key|token|access[_-]?key)"
|
|
10
|
+
r"\s*[:=]\s*['\"][^'\"]{6,}['\"]"
|
|
11
|
+
)
|
|
12
|
+
PATTERN = re.compile("|".join((_AWS_KEY, _PRIVATE_KEY, _ASSIGNMENT)), re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
# Lines whose value reads as a placeholder, not a real secret.
|
|
15
|
+
_PLACEHOLDER = re.compile(
|
|
16
|
+
r"os\.environ|getenv|<[^>]*>|\*{3,}|changeme|example|your[_-]|placeholder|dummy|xxxx",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def find_violations(paths: list[str]) -> list[tuple[str, int, str]]:
|
|
22
|
+
violations: list[tuple[str, int, str]] = []
|
|
23
|
+
for path in paths:
|
|
24
|
+
path = path.strip()
|
|
25
|
+
if not path:
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
with open(path, encoding="utf-8") as f:
|
|
29
|
+
for lineno, line in enumerate(f, start=1):
|
|
30
|
+
if PATTERN.search(line) and not _PLACEHOLDER.search(line):
|
|
31
|
+
violations.append((path, lineno, line.strip()))
|
|
32
|
+
except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError):
|
|
33
|
+
continue
|
|
34
|
+
return violations
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> int:
|
|
38
|
+
violations = find_violations(sys.stdin.read().splitlines())
|
|
39
|
+
for path, lineno, line in violations:
|
|
40
|
+
print(f" {path}:{lineno}")
|
|
41
|
+
print(f" > {line}")
|
|
42
|
+
return 1 if violations else 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
sys.exit(main())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
SENSITIVE = r"(token|password|passwd|secret|api[_-]?key|credential|session[_-]?id)"
|
|
7
|
+
LOG_CALL = r"(log\.|logger\.|logging\.|print\s*\()"
|
|
8
|
+
# A log call that mentions something sensitive on the same line.
|
|
9
|
+
PATTERN = re.compile(LOG_CALL + r"[^\n]*" + SENSITIVE, re.IGNORECASE)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def find_violations(paths: list[str]) -> list[tuple[str, int, str]]:
|
|
13
|
+
violations: list[tuple[str, int, str]] = []
|
|
14
|
+
for path in paths:
|
|
15
|
+
path = path.strip()
|
|
16
|
+
if not path:
|
|
17
|
+
continue
|
|
18
|
+
try:
|
|
19
|
+
with open(path, encoding="utf-8") as f:
|
|
20
|
+
for lineno, line in enumerate(f, start=1):
|
|
21
|
+
if PATTERN.search(line):
|
|
22
|
+
violations.append((path, lineno, line.strip()))
|
|
23
|
+
except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError):
|
|
24
|
+
continue
|
|
25
|
+
return violations
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> int:
|
|
29
|
+
violations = find_violations(sys.stdin.read().splitlines())
|
|
30
|
+
for path, lineno, line in violations:
|
|
31
|
+
print(f" {path}:{lineno}")
|
|
32
|
+
print(f" > {line}")
|
|
33
|
+
return 1 if violations else 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
sys.exit(main())
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import tokenize
|
|
7
|
+
|
|
8
|
+
# Short filler words that carry no meaning for the redundancy comparison.
|
|
9
|
+
_STOP = {
|
|
10
|
+
"the", "and", "for", "with", "from", "this", "that", "here", "then", "when",
|
|
11
|
+
"each", "only", "not", "but", "its", "was", "are", "use", "via", "into",
|
|
12
|
+
}
|
|
13
|
+
# Pragmas and machine-readable comments are never style violations.
|
|
14
|
+
_PRAGMA = ("type:", "noqa", "pragma", "pylint", "mypy", "ruff", "fmt:", "isort:")
|
|
15
|
+
|
|
16
|
+
_WORDS = re.compile(r"[A-Za-z]+")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _significant(text: str) -> tuple[list[str], list[str]]:
|
|
20
|
+
words = _WORDS.findall(text.lower())
|
|
21
|
+
return words, [w for w in words if len(w) >= 3 and w not in _STOP]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_redundant(comment_text: str, code_line: str) -> bool:
|
|
25
|
+
body = comment_text.lstrip("#").strip()
|
|
26
|
+
if not body or body.startswith(_PRAGMA):
|
|
27
|
+
return False
|
|
28
|
+
words, significant = _significant(body)
|
|
29
|
+
# Long comments are prose (the "why"); only short labels can be restatements.
|
|
30
|
+
if not significant or len(words) > 6:
|
|
31
|
+
return False
|
|
32
|
+
code_words = set(_WORDS.findall(code_line.lower()))
|
|
33
|
+
return all(w in code_words for w in significant)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _next_code_line(lines: list[str], start_row: int) -> str:
|
|
37
|
+
for line in lines[start_row:]:
|
|
38
|
+
stripped = line.strip()
|
|
39
|
+
if stripped and not stripped.startswith("#"):
|
|
40
|
+
return line
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_violations(paths: list[str]) -> list[tuple[str, int, str]]:
|
|
45
|
+
violations: list[tuple[str, int, str]] = []
|
|
46
|
+
for path in paths:
|
|
47
|
+
path = path.strip()
|
|
48
|
+
if not path:
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
text = open(path, encoding="utf-8").read()
|
|
52
|
+
except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError):
|
|
53
|
+
continue
|
|
54
|
+
lines = text.splitlines()
|
|
55
|
+
# tokenize so that `#` inside a string is never treated as a comment.
|
|
56
|
+
try:
|
|
57
|
+
tokens = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
|
58
|
+
except (tokenize.TokenError, SyntaxError, IndentationError):
|
|
59
|
+
continue
|
|
60
|
+
for tok in tokens:
|
|
61
|
+
if tok.type != tokenize.COMMENT:
|
|
62
|
+
continue
|
|
63
|
+
row, col = tok.start
|
|
64
|
+
if row == 1 and tok.string.startswith("#!"):
|
|
65
|
+
continue
|
|
66
|
+
line = lines[row - 1]
|
|
67
|
+
before = line[:col]
|
|
68
|
+
code = before if before.strip() else _next_code_line(lines, row)
|
|
69
|
+
if code and _is_redundant(tok.string, code):
|
|
70
|
+
violations.append((path, row, tok.string.strip()))
|
|
71
|
+
return violations
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> int:
|
|
75
|
+
violations = find_violations(sys.stdin.read().splitlines())
|
|
76
|
+
for path, lineno, comment in violations:
|
|
77
|
+
print(f" {path}:{lineno}")
|
|
78
|
+
print(f" > {comment}")
|
|
79
|
+
return 1 if violations else 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
sys.exit(main())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
PATTERN = re.compile(r"^\s*from\s+[\w.]+\s+import\s+\*")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def find_violations(paths: list[str]) -> list[tuple[str, int, str]]:
|
|
10
|
+
violations: list[tuple[str, int, str]] = []
|
|
11
|
+
for path in paths:
|
|
12
|
+
path = path.strip()
|
|
13
|
+
if not path:
|
|
14
|
+
continue
|
|
15
|
+
try:
|
|
16
|
+
with open(path, encoding="utf-8") as f:
|
|
17
|
+
for lineno, line in enumerate(f, start=1):
|
|
18
|
+
if PATTERN.search(line):
|
|
19
|
+
violations.append((path, lineno, line.strip()))
|
|
20
|
+
except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError):
|
|
21
|
+
continue
|
|
22
|
+
return violations
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> int:
|
|
26
|
+
violations = find_violations(sys.stdin.read().splitlines())
|
|
27
|
+
for path, lineno, line in violations:
|
|
28
|
+
print(f" {path}:{lineno}")
|
|
29
|
+
print(f" > {line}")
|
|
30
|
+
return 1 if violations else 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
sys.exit(main())
|
becwright/cli.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__, bundle, git
|
|
10
|
+
from .engine import Result, evaluate
|
|
11
|
+
from .rules import load_rules
|
|
12
|
+
|
|
13
|
+
RED = "\033[91m"; GREEN = "\033[92m"; YELLOW = "\033[93m"
|
|
14
|
+
BOLD = "\033[1m"; DIM = "\033[2m"; RESET = "\033[0m"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _print_result(result: Result) -> None:
|
|
18
|
+
for r in result.per_rule:
|
|
19
|
+
if r.passed:
|
|
20
|
+
print(f" {GREEN}PASS{RESET} {r.rule.id}")
|
|
21
|
+
continue
|
|
22
|
+
if r.rule.is_blocking:
|
|
23
|
+
print(f" {RED}{BOLD}BLOCK{RESET} {r.rule.id} {RED}(blocking){RESET}")
|
|
24
|
+
else:
|
|
25
|
+
print(f" {YELLOW}WARN{RESET} {r.rule.id} {YELLOW}(warning only){RESET}")
|
|
26
|
+
if r.rule.intent:
|
|
27
|
+
print(f" {DIM}Intent:{RESET} {r.rule.intent}")
|
|
28
|
+
if r.rule.why_it_matters:
|
|
29
|
+
print(f" {DIM}Why it matters:{RESET} {r.rule.why_it_matters}")
|
|
30
|
+
if r.output:
|
|
31
|
+
print(f" {DIM}Found in:{RESET}")
|
|
32
|
+
for line in r.output.splitlines():
|
|
33
|
+
print(f" {line}")
|
|
34
|
+
print()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
38
|
+
root = git.repo_root()
|
|
39
|
+
rules = load_rules(root / ".bec" / "rules.yaml")
|
|
40
|
+
if not rules:
|
|
41
|
+
print(f"{YELLOW}No .bec/rules.yaml with rules. Nothing to check.{RESET}")
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
files = git.files_to_check(root, all_files=args.all)
|
|
45
|
+
if not files:
|
|
46
|
+
print(f"{DIM}No files to check.{RESET}")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
print(f"{BOLD}BEC -- {len(files)} file(s) against {len(rules)} rule(s){RESET}\n")
|
|
50
|
+
result = evaluate(rules, files, root)
|
|
51
|
+
_print_result(result)
|
|
52
|
+
|
|
53
|
+
if result.had_blocking:
|
|
54
|
+
print(f"{RED}{BOLD}>>> Commit BLOCKED: a blocking rule was broken.{RESET}")
|
|
55
|
+
print(f"{DIM} Fix the above, or if it is intentional edit .bec/rules.yaml{RESET}")
|
|
56
|
+
return 1
|
|
57
|
+
print(f"{GREEN}{BOLD}>>> All good. Commit allowed.{RESET}")
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cmd_install(_: argparse.Namespace) -> int:
|
|
62
|
+
ok, msg = git.install_hook(git.repo_root())
|
|
63
|
+
print((GREEN if ok else YELLOW) + msg + RESET)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cmd_uninstall(_: argparse.Namespace) -> int:
|
|
68
|
+
ok, msg = git.uninstall_hook(git.repo_root())
|
|
69
|
+
print((GREEN if ok else YELLOW) + msg + RESET)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cmd_export(args: argparse.Namespace) -> int:
|
|
74
|
+
root = git.repo_root()
|
|
75
|
+
rule = next((r for r in load_rules(root / ".bec" / "rules.yaml") if r.id == args.rule_id), None)
|
|
76
|
+
if rule is None:
|
|
77
|
+
print(f"{RED}No rule with id '{args.rule_id}' in .bec/rules.yaml.{RESET}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
text = bundle.export_bec(rule, root)
|
|
80
|
+
if args.output:
|
|
81
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
82
|
+
print(f"{GREEN}BEC '{rule.id}' exported to {args.output}.{RESET}")
|
|
83
|
+
else:
|
|
84
|
+
sys.stdout.write(text)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_source(source: str) -> str:
|
|
89
|
+
if source.startswith(("http://", "https://")):
|
|
90
|
+
with urllib.request.urlopen(source, timeout=15) as resp:
|
|
91
|
+
return resp.read().decode("utf-8")
|
|
92
|
+
return Path(source).read_text(encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _print_bundle_summary(data: dict) -> None:
|
|
96
|
+
rule, check = data["rule"], data["check"]
|
|
97
|
+
print(f"{BOLD}BEC: {rule['id']}{RESET} {DIM}(from {data.get('exported_from', '?')}){RESET}")
|
|
98
|
+
if rule.get("intent"):
|
|
99
|
+
print(f" {DIM}Intent:{RESET} {rule['intent'].strip()}")
|
|
100
|
+
if rule.get("why_it_matters"):
|
|
101
|
+
print(f" {DIM}Why it matters:{RESET} {rule['why_it_matters'].strip()}")
|
|
102
|
+
kind = check.get("kind")
|
|
103
|
+
print(f" {DIM}Check:{RESET} {kind}")
|
|
104
|
+
if kind == "script":
|
|
105
|
+
print(f" {DIM}Code of {check.get('filename')}:{RESET}")
|
|
106
|
+
for line in check.get("source", "").splitlines():
|
|
107
|
+
print(f" {line}")
|
|
108
|
+
elif kind == "command":
|
|
109
|
+
print(f" {DIM}Command:{RESET} {check.get('command')}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_import(args: argparse.Namespace) -> int:
|
|
113
|
+
root = git.repo_root()
|
|
114
|
+
try:
|
|
115
|
+
data = bundle.parse_bundle(_read_source(args.source))
|
|
116
|
+
except (bundle.BundleError, OSError, urllib.error.URLError) as e:
|
|
117
|
+
print(f"{RED}Could not import: {e}{RESET}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
_print_bundle_summary(data)
|
|
121
|
+
if not args.yes:
|
|
122
|
+
print(f"{YELLOW}Importing a BEC installs code that runs on every commit.{RESET}")
|
|
123
|
+
if input("Install this BEC? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
124
|
+
print(f"{DIM}Cancelled. Nothing was written.{RESET}")
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
rules_path = root / ".bec" / "rules.yaml"
|
|
128
|
+
rule_id = data["rule"]["id"]
|
|
129
|
+
if rule_id in {r.id for r in load_rules(rules_path)}:
|
|
130
|
+
print(f"{RED}A rule with id '{rule_id}' already exists. Not duplicating it.{RESET}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
try:
|
|
133
|
+
rule_dict = bundle.materialize(data, root)
|
|
134
|
+
except bundle.BundleError as e:
|
|
135
|
+
print(f"{RED}{e}{RESET}", file=sys.stderr)
|
|
136
|
+
return 1
|
|
137
|
+
bundle.append_rule(rules_path, rule_dict)
|
|
138
|
+
print(f"{GREEN}{BOLD}BEC '{rule_id}' installed in .bec/rules.yaml.{RESET}")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
143
|
+
parser = argparse.ArgumentParser(
|
|
144
|
+
prog="becwright",
|
|
145
|
+
description="Enforces BECs (Bound Executable Constraints) on your code.",
|
|
146
|
+
)
|
|
147
|
+
parser.add_argument("--version", action="version", version=f"becwright {__version__}")
|
|
148
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
149
|
+
|
|
150
|
+
p_check = sub.add_parser("check", help="check the code against the rules")
|
|
151
|
+
p_check.add_argument("--all", action="store_true", help="check the whole repo, not just staging")
|
|
152
|
+
p_check.set_defaults(func=_cmd_check)
|
|
153
|
+
|
|
154
|
+
sub.add_parser("install", help="install the pre-commit hook").set_defaults(func=_cmd_install)
|
|
155
|
+
sub.add_parser("uninstall", help="remove the pre-commit hook").set_defaults(func=_cmd_uninstall)
|
|
156
|
+
|
|
157
|
+
p_export = sub.add_parser("export", help="export a BEC to a .bec.yaml file")
|
|
158
|
+
p_export.add_argument("rule_id", help="id of the rule to export")
|
|
159
|
+
p_export.add_argument("-o", "--output", help="output file (default: stdout)")
|
|
160
|
+
p_export.set_defaults(func=_cmd_export)
|
|
161
|
+
|
|
162
|
+
p_import = sub.add_parser("import", help="import a BEC from a file or URL")
|
|
163
|
+
p_import.add_argument("source", help="path to a .bec.yaml or http(s) URL")
|
|
164
|
+
p_import.add_argument("--yes", action="store_true", help="install without asking for confirmation")
|
|
165
|
+
p_import.set_defaults(func=_cmd_import)
|
|
166
|
+
return parser
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: list[str] | None = None) -> int:
|
|
170
|
+
args = _build_parser().parse_args(argv)
|
|
171
|
+
try:
|
|
172
|
+
return args.func(args)
|
|
173
|
+
except git.NotAGitRepo as e:
|
|
174
|
+
print(f"{RED}{e}{RESET}", file=sys.stderr)
|
|
175
|
+
return 2
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
sys.exit(main())
|
becwright/engine.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .rules import Rule
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _glob_to_regex(pattern: str) -> str:
|
|
12
|
+
# `**/` = zero or more dirs; `**` = anything; `*` = anything but `/`.
|
|
13
|
+
i = 0
|
|
14
|
+
out: list[str] = []
|
|
15
|
+
while i < len(pattern):
|
|
16
|
+
if pattern[i:i + 3] == "**/":
|
|
17
|
+
out.append("(?:.*/)?")
|
|
18
|
+
i += 3
|
|
19
|
+
elif pattern[i:i + 2] == "**":
|
|
20
|
+
out.append(".*")
|
|
21
|
+
i += 2
|
|
22
|
+
elif pattern[i] == "*":
|
|
23
|
+
out.append("[^/]*")
|
|
24
|
+
i += 1
|
|
25
|
+
elif pattern[i] == ".":
|
|
26
|
+
out.append(r"\.")
|
|
27
|
+
i += 1
|
|
28
|
+
else:
|
|
29
|
+
out.append(re.escape(pattern[i]))
|
|
30
|
+
i += 1
|
|
31
|
+
return "^" + "".join(out) + "$"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def matches(path: str, patterns: tuple[str, ...]) -> bool:
|
|
35
|
+
return any(re.match(_glob_to_regex(p), path) for p in patterns)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class RuleResult:
|
|
40
|
+
rule: Rule
|
|
41
|
+
passed: bool
|
|
42
|
+
output: str # check stdout: the violations it found
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Result:
|
|
47
|
+
per_rule: list[RuleResult]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def had_blocking(self) -> bool:
|
|
51
|
+
return any(not r.passed and r.rule.is_blocking for r in self.per_rule)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def evaluate(rules: list[Rule], files: list[str], root: Path) -> Result:
|
|
55
|
+
results: list[RuleResult] = []
|
|
56
|
+
for rule in rules:
|
|
57
|
+
relevant = [f for f in files if matches(f, rule.paths)]
|
|
58
|
+
if not relevant:
|
|
59
|
+
continue
|
|
60
|
+
proc = subprocess.run(
|
|
61
|
+
rule.check, shell=True, cwd=root,
|
|
62
|
+
input="\n".join(relevant), capture_output=True, text=True,
|
|
63
|
+
)
|
|
64
|
+
output = proc.stdout.strip() or proc.stderr.strip()
|
|
65
|
+
results.append(
|
|
66
|
+
RuleResult(rule=rule, passed=proc.returncode == 0, output=output)
|
|
67
|
+
)
|
|
68
|
+
return Result(per_rule=results)
|
becwright/git.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Marker used to recognize (and safely remove) a hook written by becwright.
|
|
7
|
+
_HOOK_MARK = "# >>> becwright hook >>>"
|
|
8
|
+
|
|
9
|
+
_HOOK_CONTENT = f"""#!/bin/sh
|
|
10
|
+
{_HOOK_MARK}
|
|
11
|
+
# Generated by `becwright install`. Do not edit by hand: use `becwright uninstall`.
|
|
12
|
+
exec becwright check
|
|
13
|
+
# <<< becwright hook <<<
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NotAGitRepo(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def repo_root() -> Path:
|
|
22
|
+
res = subprocess.run(
|
|
23
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
24
|
+
capture_output=True, text=True,
|
|
25
|
+
)
|
|
26
|
+
if res.returncode != 0:
|
|
27
|
+
raise NotAGitRepo("You are not inside a git repository.")
|
|
28
|
+
return Path(res.stdout.strip())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def files_to_check(root: Path, *, all_files: bool) -> list[str]:
|
|
32
|
+
if all_files:
|
|
33
|
+
cmd = ["git", "ls-files"]
|
|
34
|
+
else:
|
|
35
|
+
cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"]
|
|
36
|
+
res = subprocess.run(cmd, cwd=root, capture_output=True, text=True)
|
|
37
|
+
return [line for line in res.stdout.splitlines() if line.strip()]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _hook_path(root: Path) -> Path:
|
|
41
|
+
return root / ".git" / "hooks" / "pre-commit"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def install_hook(root: Path) -> tuple[bool, str]:
|
|
45
|
+
hook = _hook_path(root)
|
|
46
|
+
if hook.exists():
|
|
47
|
+
content = hook.read_text(encoding="utf-8")
|
|
48
|
+
if _HOOK_MARK in content:
|
|
49
|
+
return False, "The becwright hook was already installed."
|
|
50
|
+
# Never clobber a pre-commit hook we did not write.
|
|
51
|
+
return False, (
|
|
52
|
+
f"A non-becwright pre-commit already exists at {hook}. "
|
|
53
|
+
"Leaving it; remove or integrate it by hand."
|
|
54
|
+
)
|
|
55
|
+
hook.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
hook.write_text(_HOOK_CONTENT, encoding="utf-8")
|
|
57
|
+
hook.chmod(0o755)
|
|
58
|
+
return True, f"Hook installed at {hook}."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def uninstall_hook(root: Path) -> tuple[bool, str]:
|
|
62
|
+
hook = _hook_path(root)
|
|
63
|
+
if not hook.exists():
|
|
64
|
+
return False, "No pre-commit hook to remove."
|
|
65
|
+
if _HOOK_MARK not in hook.read_text(encoding="utf-8"):
|
|
66
|
+
return False, "The existing pre-commit is not becwright's; leaving it."
|
|
67
|
+
hook.unlink()
|
|
68
|
+
return True, "becwright hook uninstalled."
|
becwright/rules.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Rule:
|
|
11
|
+
id: str
|
|
12
|
+
paths: tuple[str, ...]
|
|
13
|
+
check: str
|
|
14
|
+
intent: str = ""
|
|
15
|
+
why_it_matters: str = ""
|
|
16
|
+
rejected_alternatives: tuple[str, ...] = ()
|
|
17
|
+
severity: str = "blocking" # blocking = stops the commit | warning = only warns
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_blocking(self) -> bool:
|
|
21
|
+
return self.severity == "blocking"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_rule(raw: dict) -> Rule:
|
|
25
|
+
return Rule(
|
|
26
|
+
id=raw["id"],
|
|
27
|
+
paths=tuple(raw.get("paths", [])),
|
|
28
|
+
check=raw["check"],
|
|
29
|
+
intent=(raw.get("intent") or "").strip(),
|
|
30
|
+
why_it_matters=(raw.get("why_it_matters") or "").strip(),
|
|
31
|
+
rejected_alternatives=tuple(raw.get("rejected_alternatives", [])),
|
|
32
|
+
severity=raw.get("severity", "blocking"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_rules(rules_path: Path) -> list[Rule]:
|
|
37
|
+
if not rules_path.exists():
|
|
38
|
+
return []
|
|
39
|
+
data = yaml.safe_load(rules_path.read_text(encoding="utf-8")) or {}
|
|
40
|
+
return [_to_rule(r) for r in data.get("rules", [])]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: becwright
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministically enforces constraints (BECs) on your code, blocking commits that violate them.
|
|
5
|
+
Author: Alonso David De Leon Rodarte
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pyyaml>=6
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
> **English** · [Español](README.es.md)
|
|
24
|
+
|
|
25
|
+
# becwright
|
|
26
|
+
|
|
27
|
+
[](https://github.com/DataDave-Dev/becwright/actions/workflows/ci.yml)
|
|
28
|
+
|
|
29
|
+
**Rules that run, not notes that get ignored.**
|
|
30
|
+
|
|
31
|
+
`becwright` enforces constraints on your code deterministically: instead of
|
|
32
|
+
*asking* an AI agent to respect a rule (the way `CLAUDE.md`, `.cursorrules`,
|
|
33
|
+
etc. do — which the agent can read and ignore), becwright **verifies the
|
|
34
|
+
result** and blocks the commit if the rule is broken.
|
|
35
|
+
|
|
36
|
+
## The problem
|
|
37
|
+
|
|
38
|
+
An AI agent writes code and leaves a note: *"this must never log session
|
|
39
|
+
tokens"*. That note is text. Three months later, another agent regenerates the
|
|
40
|
+
module, doesn't read it, and drops the token into the logs. Nobody notices
|
|
41
|
+
until it blows up in production.
|
|
42
|
+
|
|
43
|
+
Notes are **probabilistic** (they depend on the agent reading, understanding and
|
|
44
|
+
obeying). becwright is **deterministic**: the rule runs against the real code
|
|
45
|
+
and returns pass/fail, no matter which agent or model made the change.
|
|
46
|
+
|
|
47
|
+
| | Note in CLAUDE.md | becwright rule |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| What it does | *Asks* to be respected | *Verifies* it was respected |
|
|
50
|
+
| Depends on | The agent reading and obeying | Nothing — it runs against the code |
|
|
51
|
+
| Result | Likely | Guaranteed |
|
|
52
|
+
| Analogy | A "speed limit" sign | A physical bump in the road |
|
|
53
|
+
|
|
54
|
+
The two layers are complementary: CLAUDE.md prevents (so 95% comes out right the
|
|
55
|
+
first time), becwright is the safety net for the 5% that slips through.
|
|
56
|
+
|
|
57
|
+
## Core concept: BEC (Bound Executable Constraint)
|
|
58
|
+
|
|
59
|
+
A BEC is a constraint with three properties that no current artifact has
|
|
60
|
+
together:
|
|
61
|
+
|
|
62
|
+
- **Bound** — the rule is born tied to the *intent* and the decision that
|
|
63
|
+
created it (the *why*); it is not a loose rule without context.
|
|
64
|
+
- **Executable** — it carries a check that runs and returns pass/fail; it is not
|
|
65
|
+
prose someone promises to respect.
|
|
66
|
+
- **Portable** — it can be exported from one repo and imported into another,
|
|
67
|
+
like a package (this is what creates the network effect over time).
|
|
68
|
+
|
|
69
|
+
## How to use it
|
|
70
|
+
|
|
71
|
+
becwright is installed once as a tool; each repo only contributes its own
|
|
72
|
+
`.bec/rules.yaml`.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# 1. Install the engine (once, global)
|
|
76
|
+
pipx install git+https://github.com/DataDave-Dev/becwright.git # or local: pipx install .
|
|
77
|
+
|
|
78
|
+
# 2. In the repo where you want the rules, install the git hook
|
|
79
|
+
becwright install # writes .git/hooks/pre-commit
|
|
80
|
+
|
|
81
|
+
# 3. Write your rules in .bec/rules.yaml (see examples below)
|
|
82
|
+
# 4. Done: each commit runs the checks; if a blocking rule fails, it stops.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Available commands:
|
|
86
|
+
|
|
87
|
+
| Command | What it does |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `becwright check` | Runs the rules over the staged files |
|
|
90
|
+
| `becwright install` | Installs the native `pre-commit` hook |
|
|
91
|
+
| `becwright uninstall` | Removes the hook |
|
|
92
|
+
| `becwright export <id>` | Exports a BEC to a `.bec.yaml` file |
|
|
93
|
+
| `becwright import <file\|URL>` | Imports a BEC from another repo |
|
|
94
|
+
|
|
95
|
+
A rule in `.bec/rules.yaml`:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
rules:
|
|
99
|
+
- id: no-token-in-logs
|
|
100
|
+
intent: >
|
|
101
|
+
Session tokens and credentials must never reach any log.
|
|
102
|
+
why_it_matters: >
|
|
103
|
+
If a token shows up in the logs, anyone with access to them can steal a
|
|
104
|
+
user's session.
|
|
105
|
+
paths: ["src/**/*.py"]
|
|
106
|
+
check: "python3 -m becwright.checks.no_token_in_logs"
|
|
107
|
+
severity: blocking # blocking = stops the commit | warning = only warns
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Included checks
|
|
111
|
+
|
|
112
|
+
becwright ships ready-to-use checks. Each one is a module invoked from the
|
|
113
|
+
`check` field. They are **text/regex based** (no AST analysis), so they are
|
|
114
|
+
conservative and may have edge cases; the value is in tying each rule to its
|
|
115
|
+
*why*.
|
|
116
|
+
|
|
117
|
+
| Check | What it detects | Language | Suggested severity |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `forbid` | Any regex you pass (`--pattern`) | any | depends on the case |
|
|
120
|
+
| `no_token_in_logs` | Tokens/credentials in log calls | Python | `blocking` |
|
|
121
|
+
| `hardcoded_secrets` | AWS keys, private keys, `password = "..."` literals | any | `blocking` |
|
|
122
|
+
| `debug_remnants` | Forgotten `breakpoint()`, `pdb.set_trace()`, `import pdb` | Python | `blocking` |
|
|
123
|
+
| `dangerous_eval` | `eval()` / `exec()` calls | any | `blocking` |
|
|
124
|
+
| `wildcard_imports` | `from x import *` | Python | `warning` |
|
|
125
|
+
|
|
126
|
+
Example rules to copy into your `.bec/rules.yaml`:
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
rules:
|
|
130
|
+
- id: no-hardcoded-secrets
|
|
131
|
+
intent: >
|
|
132
|
+
No secret (key, token, password) should be hardcoded in the code.
|
|
133
|
+
why_it_matters: >
|
|
134
|
+
A secret in the repo stays in git history forever and is visible to
|
|
135
|
+
anyone with access to the code.
|
|
136
|
+
paths: ["src/**/*.py"]
|
|
137
|
+
check: "python3 -m becwright.checks.hardcoded_secrets"
|
|
138
|
+
severity: blocking
|
|
139
|
+
|
|
140
|
+
- id: no-debug-remnants
|
|
141
|
+
intent: >
|
|
142
|
+
Debug code (breakpoints, pdb) must not be committed.
|
|
143
|
+
why_it_matters: >
|
|
144
|
+
A forgotten breakpoint hangs the process in production or CI.
|
|
145
|
+
paths: ["src/**/*.py"]
|
|
146
|
+
check: "python3 -m becwright.checks.debug_remnants"
|
|
147
|
+
severity: blocking
|
|
148
|
+
|
|
149
|
+
- id: no-dangerous-eval
|
|
150
|
+
intent: >
|
|
151
|
+
Do not use eval()/exec(), which execute arbitrary code.
|
|
152
|
+
why_it_matters: >
|
|
153
|
+
eval/exec on untrusted input is remote code execution.
|
|
154
|
+
paths: ["src/**/*.py"]
|
|
155
|
+
check: "python3 -m becwright.checks.dangerous_eval"
|
|
156
|
+
severity: blocking
|
|
157
|
+
|
|
158
|
+
- id: no-wildcard-imports
|
|
159
|
+
intent: >
|
|
160
|
+
Avoid 'from x import *', which pollutes the namespace.
|
|
161
|
+
why_it_matters: >
|
|
162
|
+
Wildcard imports hide where each name comes from and break static
|
|
163
|
+
analysis.
|
|
164
|
+
paths: ["src/**/*.py"]
|
|
165
|
+
check: "python3 -m becwright.checks.wildcard_imports"
|
|
166
|
+
severity: warning
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Any language
|
|
170
|
+
|
|
171
|
+
becwright is **language-agnostic**: the engine only filters files by their
|
|
172
|
+
`paths` (globs) and runs the `check` as a command; it never assumes Python. You
|
|
173
|
+
can watch JavaScript, Go, Rust, or anything else.
|
|
174
|
+
|
|
175
|
+
The fastest way to write a rule for another language —without writing code— is
|
|
176
|
+
the `forbid` check, which fails if a regex appears in the files:
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
rules:
|
|
180
|
+
- id: no-debugger-js
|
|
181
|
+
intent: >
|
|
182
|
+
Do not leave 'debugger;' in JavaScript/TypeScript code.
|
|
183
|
+
why_it_matters: >
|
|
184
|
+
A forgotten 'debugger' halts execution and should not reach production.
|
|
185
|
+
paths: ["**/*.js", "**/*.ts"]
|
|
186
|
+
check: "python3 -m becwright.checks.forbid --pattern '\\bdebugger\\b'"
|
|
187
|
+
severity: blocking
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`forbid` accepts `--pattern REGEX`, `--ignore-case` and `--message TEXT`. For
|
|
191
|
+
finer checks, write your own script in whatever language you want (an executable
|
|
192
|
+
that reads the file list from stdin and exits with code 0/1) and point `check`
|
|
193
|
+
at it.
|
|
194
|
+
|
|
195
|
+
## Sharing BECs between repos
|
|
196
|
+
|
|
197
|
+
A BEC is **portable**: you can take it out of one repo and install it in
|
|
198
|
+
another. A bundle is a single self-contained `.bec.yaml` file (the rule + the
|
|
199
|
+
check's code if it is custom).
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
# In the source repo: export a rule to a file
|
|
203
|
+
becwright export no-token-in-logs -o no-token-in-logs.bec.yaml
|
|
204
|
+
|
|
205
|
+
# In another repo: import it (from a file or an http/https URL)
|
|
206
|
+
becwright import no-token-in-logs.bec.yaml
|
|
207
|
+
becwright import https://example.com/no-token-in-logs.bec.yaml
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
On import, becwright **shows the check's code and asks for confirmation** before
|
|
211
|
+
installing it: importing a BEC is importing code that will run on every commit.
|
|
212
|
+
Use `--yes` to skip the confirmation in automated environments.
|
|
213
|
+
|
|
214
|
+
There is a **catalog of ready-to-use BECs** in [`becs/`](becs/) that you can
|
|
215
|
+
import directly from their raw URL.
|
|
216
|
+
|
|
217
|
+
Built-in checks (`python3 -m becwright.checks.*`) travel with the package, so
|
|
218
|
+
the bundle only stores their name. A **custom** check (`.bec/checks/foo.py`)
|
|
219
|
+
travels with its code embedded and lands in `.bec/checks/` of the target repo.
|
|
220
|
+
|
|
221
|
+
## Current status
|
|
222
|
+
|
|
223
|
+
The **installable MVP** is built and verified end-to-end: packaged engine
|
|
224
|
+
(`src/becwright/`), CLI (`check` / `install` / `uninstall` / `export` /
|
|
225
|
+
`import`), native git hook that blocks a commit with a token in a log, included
|
|
226
|
+
checks (Python + the generic `forbid` for any language), BEC portability between
|
|
227
|
+
repos, a catalog with Python and JS/TS BECs, and a green test suite. The original
|
|
228
|
+
prototype is **archived** under `prototype/` as a reference.
|
|
229
|
+
|
|
230
|
+
Future work (AST analysis, deep per-language tooling, cryptographic signing of
|
|
231
|
+
verifications) is documented in the project plan.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
becwright/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
becwright/bundle.py,sha256=fMn7GXebJRIWen4urzqB0SyHaWXCOB9GjwBjCeJGJVY,5647
|
|
3
|
+
becwright/cli.py,sha256=0Gp3j2CNnFIf0X3H3ovxN-MfMuqAcDjoFZuHfGt9EX4,6726
|
|
4
|
+
becwright/engine.py,sha256=LMYzWsWoki28QUuSviGfHOGfEeaKLo4rnHuTCI8vn4g,1857
|
|
5
|
+
becwright/git.py,sha256=ZwP27d3xfCSSXLGCtp5QWHv9nipkpUbyJ4uYFex8BiI,2149
|
|
6
|
+
becwright/rules.py,sha256=PKxucBqtPem33F4HnMNl6X3MmkVRAqUVwPv-_WuwiIY,1096
|
|
7
|
+
becwright/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
becwright/checks/dangerous_eval.py,sha256=XTLlaRLiYiRNxeJtk5ZlGGANbfTwpT8CrlLbOUYGR_c,955
|
|
9
|
+
becwright/checks/debug_remnants.py,sha256=YxjdsVu8kAPJQGpME3HuDBJKdd59zMxILE-ePF5xZW4,1005
|
|
10
|
+
becwright/checks/forbid.py,sha256=ghBW6O944t7i7IW-ZxJe1GhDoXDrbAtR3YD-zstmFV8,1740
|
|
11
|
+
becwright/checks/hardcoded_secrets.py,sha256=euOSwAjPn_oTgRUVWu2bpH5BubOZ1gEEf9fNHMZ9G_A,1436
|
|
12
|
+
becwright/checks/no_token_in_logs.py,sha256=2NdTOgAavVw-PwNvobJvxaSg6sABvhtt955rNGduisQ,1180
|
|
13
|
+
becwright/checks/redundant_comments.py,sha256=vhX-BRkCRQMVD39fuXEzKwEKHJr19QR7LOaDMjOjtOA,2840
|
|
14
|
+
becwright/checks/wildcard_imports.py,sha256=6r7Qo6886we3hNkFgCL7ASZvGw5GfAkDy1rVswK34Mo,966
|
|
15
|
+
becwright-0.1.0.dist-info/licenses/LICENSE,sha256=l2gLf5s0NiJqhdVyb4E5VYxpZa6XXy7urnLrIzQX8OA,1085
|
|
16
|
+
becwright-0.1.0.dist-info/METADATA,sha256=ImLKGgQZ3d3a_88HDxRiVJFfp-AvtGRr6QzZLf2wUIM,8982
|
|
17
|
+
becwright-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
18
|
+
becwright-0.1.0.dist-info/entry_points.txt,sha256=1dYz-6H3O9WKYazgU9rfICc9j3GDIXDXApSG62TlvQo,49
|
|
19
|
+
becwright-0.1.0.dist-info/top_level.txt,sha256=11x6Cfccs-MD8MkICxo8TXicjXgpyYouw6y4bTp1uZA,10
|
|
20
|
+
becwright-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alonso David De Leon Rodarte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
becwright
|