Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ Referencia completa de campos: [`documentation/usage.es.md`](documentation/usage
| `becwright check` | Corre las reglas sobre los archivos en staging |
| `becwright check --diff <base>` | Corre las reglas solo sobre los archivos cambiados vs `<base>` (para CI/PR) |
| `becwright why [id]` | Muestra la intención + el por qué de las reglas — la memoria de decisiones del repo (`--json` para agentes) |
| `becwright validate` | Valida `.bec/rules.yaml` sin correr ningún check (para editores y CI) |
| `becwright doctor` | Diagnostica el setup: archivo de reglas, checks, hooks y hook managers |
| `becwright search [texto]` | Lista BECs listas del catálogo incluido |
| `becwright add <nombre>` | Instala una BEC del catálogo en `.bec/rules.yaml` (sin conexión) |
| `becwright install` / `uninstall` | Instala / quita los hooks nativos |
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ Full field reference: [`documentation/usage.md`](documentation/usage.md).
| `becwright check` | Runs the rules over the staged files |
| `becwright check --diff <base>` | Runs the rules over only the files changed vs `<base>` (for CI/PR) |
| `becwright why [id]` | Shows the intent + why behind the rules — the repo's decision memory (`--json` for agents) |
| `becwright validate` | Validates `.bec/rules.yaml` without running any check (for editors and CI) |
| `becwright doctor` | Diagnoses the setup: rules file, checks, hooks and hook managers |
| `becwright search [query]` | Lists ready-made BECs from the built-in catalog |
| `becwright add <name>` | Installs a catalog BEC into `.bec/rules.yaml` (offline) |
| `becwright install` / `uninstall` | Installs / removes the native hooks |
Expand Down
2 changes: 2 additions & 0 deletions documentation/usage.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ a mano: `becwright install` más un `.bec/rules.yaml` que escribas vos.)
| `becwright list` | Lista los checks incluidos |
| `becwright check` | Corre las reglas sobre los archivos en staging |
| `becwright check --all` | Corre las reglas sobre todo el repo (`git ls-files`) |
| `becwright validate` | Valida `.bec/rules.yaml` — YAML, ids duplicados, checks desconocidos — sin ejecutar nada |
| `becwright doctor` | Diagnostica el setup: archivo de reglas, checks, hooks y hook managers (Husky, pre-commit) |
| `becwright install` | Instala el hook pre-commit |
| `becwright uninstall` | Quita el hook |
| `becwright export <id> [-o archivo]` | Exporta una regla a un bundle `.bec.yaml` |
Expand Down
2 changes: 2 additions & 0 deletions documentation/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ From then on, every `git commit` runs the checks. (You can also set up by hand:
| `becwright list` | List the built-in checks |
| `becwright check` | Run rules over the staged files |
| `becwright check --all` | Run rules over the whole repo (`git ls-files`) |
| `becwright validate` | Validate `.bec/rules.yaml` — YAML, duplicate ids, unknown checks — without running anything |
| `becwright doctor` | Diagnose the setup: rules file, checks, hooks, and hook managers (Husky, pre-commit) |
| `becwright install` | Install the pre-commit hook |
| `becwright uninstall` | Remove the hook |
| `becwright export <id> [-o file]` | Export a rule to a `.bec.yaml` bundle |
Expand Down
136 changes: 136 additions & 0 deletions src/becwright/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,140 @@ def _cmd_check(args: argparse.Namespace) -> int:
return 0


def _duplicate_rule_ids(rules) -> list[str]:
seen: set[str] = set()
dupes: list[str] = []
for rule in rules:
if rule.id in seen and rule.id not in dupes:
dupes.append(rule.id)
seen.add(rule.id)
return dupes


def _pathless_file_rules(rules) -> list[str]:
return [r.id for r in rules if r.target == "files" and not r.paths]


def _cmd_validate(_: argparse.Namespace) -> int:
root = git.repo_root()
rules_path = root / ".bec" / "rules.yaml"
if not rules_path.exists():
print(_style(f"No {rules_path}. Run `becwright init` to create one.", RED),
file=sys.stderr)
return 2
rules = load_rules(rules_path) # RulesError propagates -> exit 2 in main()

problems = False
for rule_id in _duplicate_rule_ids(rules):
problems = True
print(_style(f"duplicate rule id '{rule_id}' — ids must be unique.", RED),
file=sys.stderr)
unknown = _unknown_builtin_checks(rules, root)
if unknown:
problems = True
_print_unknown_checks(unknown)
if problems:
return 2

for rule_id in _pathless_file_rules(rules):
print(_style(f"warning: rule '{rule_id}' has no `paths` — it will never "
"match a file.", YELLOW))
print(_style(f"OK: {len(rules)} rule(s) valid; every check resolves.", GREEN, BOLD))
return 0


# Doctor findings: (status, message). `fail` means becwright cannot enforce as
# configured (exit 2); `warn` is a gap worth fixing; `ok` is informational.
_DOCTOR_ICONS = {"ok": ("OK", GREEN), "warn": ("WARN", YELLOW), "fail": ("FAIL", RED)}


def _doctor_rules(root: Path) -> tuple[list, list[tuple[str, str]]]:
rules_path = root / ".bec" / "rules.yaml"
if not rules_path.exists():
return [], [("warn", "no .bec/rules.yaml — run `becwright init` to create one.")]
try:
rules = load_rules(rules_path)
except RulesError as e:
return [], [("fail", f".bec/rules.yaml cannot be loaded: {e}")]
findings = [("ok", f".bec/rules.yaml loads: {len(rules)} rule(s).")]
for rule_id in _duplicate_rule_ids(rules):
findings.append(("fail", f"duplicate rule id '{rule_id}' — ids must be unique."))
for rule_id, module in _unknown_builtin_checks(rules, root):
findings.append(("fail", f"rule '{rule_id}' uses '{module}', which is not a "
"built-in check (see `becwright list`)."))
for rule_id in _pathless_file_rules(rules):
findings.append(("warn", f"rule '{rule_id}' has no `paths` — it will never "
"match a file."))
return rules, findings


def _doctor_precommit_hook(root: Path) -> tuple[str, str]:
manager = git.hook_manager(root)
override = git.hooks_path_override(root)
if override:
if manager == "husky":
husky_hook = root / ".husky" / "pre-commit"
if husky_hook.is_file() and "becwright" in husky_hook.read_text(encoding="utf-8"):
return "ok", "Husky runs becwright on pre-commit."
return "warn", ("Husky owns the hooks (core.hooksPath) but .husky/pre-commit "
"does not run becwright — add `npx becwright check` to it.")
return "warn", (f"core.hooksPath = {override}: git ignores .git/hooks, so a "
"becwright hook there never runs — wire `becwright check` into "
"that hook path instead.")
state = git.hook_state(root, "pre-commit")
if state == "becwright":
return "ok", "becwright pre-commit hook installed."
if state == "foreign":
if manager == "pre-commit":
config = (root / ".pre-commit-config.yaml").read_text(encoding="utf-8")
if "becwright" in config:
return "ok", "the pre-commit framework runs becwright."
return "warn", ("the pre-commit framework owns the hook but its config does "
"not include becwright — add the becwright hook to "
".pre-commit-config.yaml.")
return "warn", ("a non-becwright pre-commit hook exists — add `becwright check` "
"to it, or let your hook manager run becwright.")
return "warn", "no pre-commit hook — run `becwright install` (or wire becwright into your hook manager)."


def _doctor_msg_hook(root: Path, rules) -> tuple[str, str] | None:
if not any(r.target == "commit-msg" for r in rules):
return None
if git.hooks_path_override(root):
return None # already flagged by the pre-commit finding
state = git.hook_state(root, "commit-msg")
if state == "becwright":
return "ok", "becwright commit-msg hook installed."
return "warn", ("you have commit-msg rules but no becwright commit-msg hook — "
"run `becwright install`.")


def _cmd_doctor(_: argparse.Namespace) -> int:
print(f"{_style('becwright doctor', BOLD)} "
f"{_style(f'— becwright {__version__}', DIM)}\n")
root = git.repo_root() # NotAGitRepo propagates -> exit 2 in main()
rules, findings = _doctor_rules(root)
findings.append(_doctor_precommit_hook(root))
msg_finding = _doctor_msg_hook(root, rules)
if msg_finding:
findings.append(msg_finding)

for status, message in findings:
label, color = _DOCTOR_ICONS[status]
print(f" {_style(label.ljust(4), color, BOLD)} {message}")
failed = any(status == "fail" for status, _ in findings)
warned = any(status == "warn" for status, _ in findings)
print()
if failed:
print(_style(">>> Problems found: becwright cannot enforce as configured.", RED, BOLD))
return 2
if warned:
print(_style(">>> Working, with gaps worth fixing (see WARN above).", YELLOW, BOLD))
return 0
print(_style(">>> All good.", GREEN, BOLD))
return 0


def _cmd_install(_: argparse.Namespace) -> int:
root = git.repo_root()
for install in (git.install_hook, git.install_msg_hook):
Expand Down Expand Up @@ -887,6 +1021,8 @@ def _build_parser() -> argparse.ArgumentParser:
p_check_msg.add_argument("msgfile", help="path to the commit message file (git passes this to the hook)")
p_check_msg.set_defaults(func=_cmd_check_msg)

sub.add_parser("validate", help="validate .bec/rules.yaml without running any check (for editors and CI)").set_defaults(func=_cmd_validate)
sub.add_parser("doctor", help="diagnose the setup: rules file, checks, hooks and hook managers").set_defaults(func=_cmd_doctor)
sub.add_parser("demo", help="see becwright block a sample bad commit (no setup, no git needed)").set_defaults(func=_cmd_demo)
sub.add_parser("list", help="list the built-in checks").set_defaults(func=_cmd_list)
sub.add_parser("mcp", help="run the MCP server for AI agents (needs the 'mcp' extra)").set_defaults(func=_cmd_mcp)
Expand Down
28 changes: 28 additions & 0 deletions src/becwright/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,34 @@ def _uninstall_named(root: Path, name: str) -> tuple[bool, str]:
return True, f"becwright {name} hook uninstalled."


def hook_state(root: Path, name: str = "pre-commit") -> str:
"""'becwright' (ours), 'foreign' (someone else's), or 'missing'."""
hook = _hook_path(root, name)
if not hook.exists():
return "missing"
return "becwright" if _HOOK_MARK in hook.read_text(encoding="utf-8") else "foreign"


def hooks_path_override(root: Path) -> str | None:
"""The value of `core.hooksPath` when set (e.g. `.husky/_` by Husky), else None.
When set, git ignores `.git/hooks` entirely — including a becwright hook there."""
res = subprocess.run(
["git", "config", "core.hooksPath"],
cwd=root, capture_output=True, text=True,
)
value = res.stdout.strip()
return value or None


def hook_manager(root: Path) -> str | None:
"""The hook manager this repo appears to use: 'husky', 'pre-commit', or None."""
if (root / ".husky").is_dir():
return "husky"
if (root / ".pre-commit-config.yaml").is_file():
return "pre-commit"
return None


def install_hook(root: Path) -> tuple[bool, str]:
return _install_named(root, "pre-commit")

Expand Down
Loading
Loading