Skip to content

Commit 92071ec

Browse files
committed
feat: becwright doctor + validate — diagnose the setup, lint the rules file
- `becwright validate`: checks .bec/rules.yaml without running anything — YAML parses, no duplicate rule ids, every `becwright run <name>` resolves to a built-in check; warns on a files rule with no paths. Exit 0/2, consistent with the documented exit-code contract. - `becwright doctor`: one command that answers 'why isn't it working?' — rules-file findings plus hook state: becwright hook installed / foreign hook / missing; detects core.hooksPath overrides (a becwright hook in .git/hooks that git will never run), Husky (.husky/pre-commit with or without becwright) and the pre-commit framework (config with or without the becwright hook), each with the exact fix. FAIL -> exit 2, WARN -> exit 0. - git.py grows hook_state / hooks_path_override / hook_manager helpers (also the groundwork for init to stop installing a dead hook under Husky). - 20 new tests; commands documented in README(+es) and usage(+es).
1 parent 18a5fa8 commit 92071ec

7 files changed

Lines changed: 414 additions & 0 deletions

File tree

README.es.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ Referencia completa de campos: [`documentation/usage.es.md`](documentation/usage
172172
| `becwright check` | Corre las reglas sobre los archivos en staging |
173173
| `becwright check --diff <base>` | Corre las reglas solo sobre los archivos cambiados vs `<base>` (para CI/PR) |
174174
| `becwright why [id]` | Muestra la intención + el por qué de las reglas — la memoria de decisiones del repo (`--json` para agentes) |
175+
| `becwright validate` | Valida `.bec/rules.yaml` sin correr ningún check (para editores y CI) |
176+
| `becwright doctor` | Diagnostica el setup: archivo de reglas, checks, hooks y hook managers |
175177
| `becwright search [texto]` | Lista BECs listas del catálogo incluido |
176178
| `becwright add <nombre>` | Instala una BEC del catálogo en `.bec/rules.yaml` (sin conexión) |
177179
| `becwright install` / `uninstall` | Instala / quita los hooks nativos |

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ Full field reference: [`documentation/usage.md`](documentation/usage.md).
168168
| `becwright check` | Runs the rules over the staged files |
169169
| `becwright check --diff <base>` | Runs the rules over only the files changed vs `<base>` (for CI/PR) |
170170
| `becwright why [id]` | Shows the intent + why behind the rules — the repo's decision memory (`--json` for agents) |
171+
| `becwright validate` | Validates `.bec/rules.yaml` without running any check (for editors and CI) |
172+
| `becwright doctor` | Diagnoses the setup: rules file, checks, hooks and hook managers |
171173
| `becwright search [query]` | Lists ready-made BECs from the built-in catalog |
172174
| `becwright add <name>` | Installs a catalog BEC into `.bec/rules.yaml` (offline) |
173175
| `becwright install` / `uninstall` | Installs / removes the native hooks |

documentation/usage.es.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ a mano: `becwright install` más un `.bec/rules.yaml` que escribas vos.)
6161
| `becwright list` | Lista los checks incluidos |
6262
| `becwright check` | Corre las reglas sobre los archivos en staging |
6363
| `becwright check --all` | Corre las reglas sobre todo el repo (`git ls-files`) |
64+
| `becwright validate` | Valida `.bec/rules.yaml` — YAML, ids duplicados, checks desconocidos — sin ejecutar nada |
65+
| `becwright doctor` | Diagnostica el setup: archivo de reglas, checks, hooks y hook managers (Husky, pre-commit) |
6466
| `becwright install` | Instala el hook pre-commit |
6567
| `becwright uninstall` | Quita el hook |
6668
| `becwright export <id> [-o archivo]` | Exporta una regla a un bundle `.bec.yaml` |

documentation/usage.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ From then on, every `git commit` runs the checks. (You can also set up by hand:
5959
| `becwright list` | List the built-in checks |
6060
| `becwright check` | Run rules over the staged files |
6161
| `becwright check --all` | Run rules over the whole repo (`git ls-files`) |
62+
| `becwright validate` | Validate `.bec/rules.yaml` — YAML, duplicate ids, unknown checks — without running anything |
63+
| `becwright doctor` | Diagnose the setup: rules file, checks, hooks, and hook managers (Husky, pre-commit) |
6264
| `becwright install` | Install the pre-commit hook |
6365
| `becwright uninstall` | Remove the hook |
6466
| `becwright export <id> [-o file]` | Export a rule to a `.bec.yaml` bundle |

src/becwright/cli.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,140 @@ def _cmd_check(args: argparse.Namespace) -> int:
176176
return 0
177177

178178

179+
def _duplicate_rule_ids(rules) -> list[str]:
180+
seen: set[str] = set()
181+
dupes: list[str] = []
182+
for rule in rules:
183+
if rule.id in seen and rule.id not in dupes:
184+
dupes.append(rule.id)
185+
seen.add(rule.id)
186+
return dupes
187+
188+
189+
def _pathless_file_rules(rules) -> list[str]:
190+
return [r.id for r in rules if r.target == "files" and not r.paths]
191+
192+
193+
def _cmd_validate(_: argparse.Namespace) -> int:
194+
root = git.repo_root()
195+
rules_path = root / ".bec" / "rules.yaml"
196+
if not rules_path.exists():
197+
print(_style(f"No {rules_path}. Run `becwright init` to create one.", RED),
198+
file=sys.stderr)
199+
return 2
200+
rules = load_rules(rules_path) # RulesError propagates -> exit 2 in main()
201+
202+
problems = False
203+
for rule_id in _duplicate_rule_ids(rules):
204+
problems = True
205+
print(_style(f"duplicate rule id '{rule_id}' — ids must be unique.", RED),
206+
file=sys.stderr)
207+
unknown = _unknown_builtin_checks(rules, root)
208+
if unknown:
209+
problems = True
210+
_print_unknown_checks(unknown)
211+
if problems:
212+
return 2
213+
214+
for rule_id in _pathless_file_rules(rules):
215+
print(_style(f"warning: rule '{rule_id}' has no `paths` — it will never "
216+
"match a file.", YELLOW))
217+
print(_style(f"OK: {len(rules)} rule(s) valid; every check resolves.", GREEN, BOLD))
218+
return 0
219+
220+
221+
# Doctor findings: (status, message). `fail` means becwright cannot enforce as
222+
# configured (exit 2); `warn` is a gap worth fixing; `ok` is informational.
223+
_DOCTOR_ICONS = {"ok": ("OK", GREEN), "warn": ("WARN", YELLOW), "fail": ("FAIL", RED)}
224+
225+
226+
def _doctor_rules(root: Path) -> tuple[list, list[tuple[str, str]]]:
227+
rules_path = root / ".bec" / "rules.yaml"
228+
if not rules_path.exists():
229+
return [], [("warn", "no .bec/rules.yaml — run `becwright init` to create one.")]
230+
try:
231+
rules = load_rules(rules_path)
232+
except RulesError as e:
233+
return [], [("fail", f".bec/rules.yaml cannot be loaded: {e}")]
234+
findings = [("ok", f".bec/rules.yaml loads: {len(rules)} rule(s).")]
235+
for rule_id in _duplicate_rule_ids(rules):
236+
findings.append(("fail", f"duplicate rule id '{rule_id}' — ids must be unique."))
237+
for rule_id, module in _unknown_builtin_checks(rules, root):
238+
findings.append(("fail", f"rule '{rule_id}' uses '{module}', which is not a "
239+
"built-in check (see `becwright list`)."))
240+
for rule_id in _pathless_file_rules(rules):
241+
findings.append(("warn", f"rule '{rule_id}' has no `paths` — it will never "
242+
"match a file."))
243+
return rules, findings
244+
245+
246+
def _doctor_precommit_hook(root: Path) -> tuple[str, str]:
247+
manager = git.hook_manager(root)
248+
override = git.hooks_path_override(root)
249+
if override:
250+
if manager == "husky":
251+
husky_hook = root / ".husky" / "pre-commit"
252+
if husky_hook.is_file() and "becwright" in husky_hook.read_text(encoding="utf-8"):
253+
return "ok", "Husky runs becwright on pre-commit."
254+
return "warn", ("Husky owns the hooks (core.hooksPath) but .husky/pre-commit "
255+
"does not run becwright — add `npx becwright check` to it.")
256+
return "warn", (f"core.hooksPath = {override}: git ignores .git/hooks, so a "
257+
"becwright hook there never runs — wire `becwright check` into "
258+
"that hook path instead.")
259+
state = git.hook_state(root, "pre-commit")
260+
if state == "becwright":
261+
return "ok", "becwright pre-commit hook installed."
262+
if state == "foreign":
263+
if manager == "pre-commit":
264+
config = (root / ".pre-commit-config.yaml").read_text(encoding="utf-8")
265+
if "becwright" in config:
266+
return "ok", "the pre-commit framework runs becwright."
267+
return "warn", ("the pre-commit framework owns the hook but its config does "
268+
"not include becwright — add the becwright hook to "
269+
".pre-commit-config.yaml.")
270+
return "warn", ("a non-becwright pre-commit hook exists — add `becwright check` "
271+
"to it, or let your hook manager run becwright.")
272+
return "warn", "no pre-commit hook — run `becwright install` (or wire becwright into your hook manager)."
273+
274+
275+
def _doctor_msg_hook(root: Path, rules) -> tuple[str, str] | None:
276+
if not any(r.target == "commit-msg" for r in rules):
277+
return None
278+
if git.hooks_path_override(root):
279+
return None # already flagged by the pre-commit finding
280+
state = git.hook_state(root, "commit-msg")
281+
if state == "becwright":
282+
return "ok", "becwright commit-msg hook installed."
283+
return "warn", ("you have commit-msg rules but no becwright commit-msg hook — "
284+
"run `becwright install`.")
285+
286+
287+
def _cmd_doctor(_: argparse.Namespace) -> int:
288+
print(f"{_style('becwright doctor', BOLD)} "
289+
f"{_style(f'— becwright {__version__}', DIM)}\n")
290+
root = git.repo_root() # NotAGitRepo propagates -> exit 2 in main()
291+
rules, findings = _doctor_rules(root)
292+
findings.append(_doctor_precommit_hook(root))
293+
msg_finding = _doctor_msg_hook(root, rules)
294+
if msg_finding:
295+
findings.append(msg_finding)
296+
297+
for status, message in findings:
298+
label, color = _DOCTOR_ICONS[status]
299+
print(f" {_style(label.ljust(4), color, BOLD)} {message}")
300+
failed = any(status == "fail" for status, _ in findings)
301+
warned = any(status == "warn" for status, _ in findings)
302+
print()
303+
if failed:
304+
print(_style(">>> Problems found: becwright cannot enforce as configured.", RED, BOLD))
305+
return 2
306+
if warned:
307+
print(_style(">>> Working, with gaps worth fixing (see WARN above).", YELLOW, BOLD))
308+
return 0
309+
print(_style(">>> All good.", GREEN, BOLD))
310+
return 0
311+
312+
179313
def _cmd_install(_: argparse.Namespace) -> int:
180314
root = git.repo_root()
181315
for install in (git.install_hook, git.install_msg_hook):
@@ -887,6 +1021,8 @@ def _build_parser() -> argparse.ArgumentParser:
8871021
p_check_msg.add_argument("msgfile", help="path to the commit message file (git passes this to the hook)")
8881022
p_check_msg.set_defaults(func=_cmd_check_msg)
8891023

1024+
sub.add_parser("validate", help="validate .bec/rules.yaml without running any check (for editors and CI)").set_defaults(func=_cmd_validate)
1025+
sub.add_parser("doctor", help="diagnose the setup: rules file, checks, hooks and hook managers").set_defaults(func=_cmd_doctor)
8901026
sub.add_parser("demo", help="see becwright block a sample bad commit (no setup, no git needed)").set_defaults(func=_cmd_demo)
8911027
sub.add_parser("list", help="list the built-in checks").set_defaults(func=_cmd_list)
8921028
sub.add_parser("mcp", help="run the MCP server for AI agents (needs the 'mcp' extra)").set_defaults(func=_cmd_mcp)

src/becwright/git.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,34 @@ def _uninstall_named(root: Path, name: str) -> tuple[bool, str]:
150150
return True, f"becwright {name} hook uninstalled."
151151

152152

153+
def hook_state(root: Path, name: str = "pre-commit") -> str:
154+
"""'becwright' (ours), 'foreign' (someone else's), or 'missing'."""
155+
hook = _hook_path(root, name)
156+
if not hook.exists():
157+
return "missing"
158+
return "becwright" if _HOOK_MARK in hook.read_text(encoding="utf-8") else "foreign"
159+
160+
161+
def hooks_path_override(root: Path) -> str | None:
162+
"""The value of `core.hooksPath` when set (e.g. `.husky/_` by Husky), else None.
163+
When set, git ignores `.git/hooks` entirely — including a becwright hook there."""
164+
res = subprocess.run(
165+
["git", "config", "core.hooksPath"],
166+
cwd=root, capture_output=True, text=True,
167+
)
168+
value = res.stdout.strip()
169+
return value or None
170+
171+
172+
def hook_manager(root: Path) -> str | None:
173+
"""The hook manager this repo appears to use: 'husky', 'pre-commit', or None."""
174+
if (root / ".husky").is_dir():
175+
return "husky"
176+
if (root / ".pre-commit-config.yaml").is_file():
177+
return "pre-commit"
178+
return None
179+
180+
153181
def install_hook(root: Path) -> tuple[bool, str]:
154182
return _install_named(root, "pre-commit")
155183

0 commit comments

Comments
 (0)