@@ -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+
179313def _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 )
0 commit comments