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 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
+ [![CI](https://github.com/DataDave-Dev/becwright/actions/workflows/ci.yml/badge.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ becwright = becwright.cli:main
@@ -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