diff --git a/CLAUDE.md b/CLAUDE.md index 4c0cc0b..5c11207 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ becwright/ ├── README.md # public conceptual document (English; README.es.md = Spanish) ├── pyproject.toml # packaging + `becwright` command (setuptools) ├── src/becwright/ # packaged ENGINE (installable, not copied into each repo) -│ ├── cli.py # argparse: check / install / uninstall / export / import +│ ├── cli.py # argparse: init / check / install / uninstall / export / import │ ├── engine.py # path matching + runs checks + decides pass/fail │ ├── rules.py # Rule model + loading of .bec/rules.yaml │ ├── bundle.py # export/import of BECs (the portable bundle) @@ -54,7 +54,7 @@ engine comes from the installed package. (Portability, C)** done: `becwright export` / `import` move a BEC between repos as a single self-contained `.bec.yaml` (a custom check's code travels embedded), with a trust gate that shows the code before installing. Commands: -`check / install / uninstall / export / import`. **Multi-language:** the engine +`init / check / install / uninstall / export / import`. **Multi-language:** the engine is agnostic (runs any check on any file); the generic `forbid` check (regex via `--pattern`) lets you write rules for any language without code, and the catalog includes Python and JS/TS BECs. Included checks: `forbid` (any language), diff --git a/README.es.md b/README.es.md index ca41b55..681ee52 100644 --- a/README.es.md +++ b/README.es.md @@ -54,17 +54,17 @@ becwright se instala una vez como herramienta; cada repo solo aporta su propio # 1. Instalar el motor (una vez, global) pipx install becwright # o: pip install becwright -# 2. En el repo donde querés las reglas, instalar el hook de git -becwright install # escribe .git/hooks/pre-commit +# 2. En tu repo, generar reglas + instalar el hook +becwright init # detecta tu lenguaje, escribe .bec/rules.yaml, instala el hook -# 3. Escribir tus reglas en .bec/rules.yaml (ver ejemplos abajo) -# 4. Listo: cada commit corre los chequeos; si una regla blocking falla, frena. +# 3. Listo: cada commit corre los chequeos; si una regla blocking falla, frena. ``` Comandos disponibles: | Comando | Qué hace | |---|---| +| `becwright init` | Genera un `.bec/rules.yaml` de arranque e instala el hook | | `becwright check` | Corre las reglas sobre los archivos en staging | | `becwright install` | Instala el hook `pre-commit` nativo | | `becwright uninstall` | Quita el hook | diff --git a/README.md b/README.md index 9ddda0d..23aab59 100644 --- a/README.md +++ b/README.md @@ -53,17 +53,17 @@ becwright is installed once as a tool; each repo only contributes its own # 1. Install the engine (once, global) pipx install becwright # or: pip install becwright -# 2. In the repo where you want the rules, install the git hook -becwright install # writes .git/hooks/pre-commit +# 2. In your repo, scaffold rules + install the hook +becwright init # detects your language, writes .bec/rules.yaml, installs the hook -# 3. Write your rules in .bec/rules.yaml (see examples below) -# 4. Done: each commit runs the checks; if a blocking rule fails, it stops. +# 3. Done: each commit runs the checks; if a blocking rule fails, it stops. ``` Available commands: | Command | What it does | |---|---| +| `becwright init` | Scaffold a starter `.bec/rules.yaml` and install the hook | | `becwright check` | Runs the rules over the staged files | | `becwright install` | Installs the native `pre-commit` hook | | `becwright uninstall` | Removes the hook | diff --git a/documentation/architecture.es.md b/documentation/architecture.es.md index 5a1e57f..62c3eb3 100644 --- a/documentation/architecture.es.md +++ b/documentation/architecture.es.md @@ -10,7 +10,7 @@ filtra archivos por su ruta y corre un comando. | Módulo | Responsabilidad | |---|---| -| `cli.py` | CLI con argparse: `check / install / uninstall / export / import` | +| `cli.py` | CLI con argparse: `init / check / install / uninstall / export / import` | | `rules.py` | El modelo `Rule` y la carga de `.bec/rules.yaml` | | `engine.py` | Matching de rutas por glob, corre los checks, decide pasa/no-pasa | | `git.py` | Raíz del repo, archivos en staging, el hook pre-commit nativo | diff --git a/documentation/architecture.md b/documentation/architecture.md index 3940aa1..4510de2 100644 --- a/documentation/architecture.md +++ b/documentation/architecture.md @@ -10,7 +10,7 @@ itself — it matches files by path and runs a command. | Module | Responsibility | |---|---| -| `cli.py` | Argparse CLI: `check / install / uninstall / export / import` | +| `cli.py` | Argparse CLI: `init / check / install / uninstall / export / import` | | `rules.py` | The `Rule` model and loading of `.bec/rules.yaml` | | `engine.py` | Glob path matching, running checks, deciding pass/fail | | `git.py` | Repo root, staged files, the native pre-commit hook | diff --git a/documentation/usage.es.md b/documentation/usage.es.md index 5015598..0fe62e1 100644 --- a/documentation/usage.es.md +++ b/documentation/usage.es.md @@ -12,16 +12,22 @@ pipx install becwright # o: pip install becwright ```bash cd tu-repo -becwright install # escribe .git/hooks/pre-commit -# creá .bec/rules.yaml (ver abajo) +becwright init # genera .bec/rules.yaml (según el lenguaje) e instala el hook ``` -A partir de ahí, cada `git commit` corre los checks. +`init` detecta si el repo tiene archivos Python o JS/TS y escribe un +`.bec/rules.yaml` de arranque con reglas acordes, y luego instala el hook +pre-commit. Revisá las reglas generadas y corré `becwright check --all` para ver +el estado actual. + +A partir de ahí, cada `git commit` corre los checks. (También podés configurarlo +a mano: `becwright install` más un `.bec/rules.yaml` que escribas vos.) ## Comandos | Comando | Descripción | |---|---| +| `becwright init` | Genera un `.bec/rules.yaml` de arranque e instala el hook | | `becwright check` | Corre las reglas sobre los archivos en staging | | `becwright check --all` | Corre las reglas sobre todo el repo (`git ls-files`) | | `becwright install` | Instala el hook pre-commit | diff --git a/documentation/usage.md b/documentation/usage.md index f820108..e40830d 100644 --- a/documentation/usage.md +++ b/documentation/usage.md @@ -12,16 +12,21 @@ pipx install becwright # or: pip install becwright ```bash cd your-repo -becwright install # writes .git/hooks/pre-commit -# create .bec/rules.yaml (see below) +becwright init # scaffolds .bec/rules.yaml (language-aware) and installs the hook ``` -From then on, every `git commit` runs the checks. +`init` detects whether the repo has Python or JS/TS files and writes a starter +`.bec/rules.yaml` with matching rules, then installs the pre-commit hook. Review +the generated rules and run `becwright check --all` to see the current state. + +From then on, every `git commit` runs the checks. (You can also set up by hand: +`becwright install` plus a `.bec/rules.yaml` you write yourself.) ## Commands | Command | Description | |---|---| +| `becwright init` | Scaffold a starter `.bec/rules.yaml` and install the hook | | `becwright check` | Run rules over the staged files | | `becwright check --all` | Run rules over the whole repo (`git ls-files`) | | `becwright install` | Install the pre-commit hook | diff --git a/src/becwright/cli.py b/src/becwright/cli.py index ffc2f0f..127aa78 100644 --- a/src/becwright/cli.py +++ b/src/becwright/cli.py @@ -70,6 +70,99 @@ def _cmd_uninstall(_: argparse.Namespace) -> int: return 0 +_SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".tox"} +_EXT_LANG = {".py": "python", ".js": "js", ".ts": "ts"} + + +def _detect_languages(root: Path) -> list[str]: + found: set[str] = set() + for path in root.rglob("*"): + if not path.is_file(): + continue + if any(part in _SKIP_DIRS for part in path.relative_to(root).parts): + continue + lang = _EXT_LANG.get(path.suffix) + if lang: + found.add(lang) + return [lang for lang in ("python", "js", "ts") if lang in found] + + +def _starter_rules(langs: list[str]) -> list[dict]: + source_globs = [g for lang, g in (("python", "**/*.py"), ("js", "**/*.js"), ("ts", "**/*.ts")) if lang in langs] + rules: list[dict] = [] + if source_globs: + rules.append(dict( + id="no-hardcoded-secrets", paths=source_globs, severity="blocking", + check="python3 -m becwright.checks.hardcoded_secrets", + intent="No secret (key, token, password) should be hardcoded in the code.", + why="A secret in the repo stays in git history forever and is visible to anyone with access to the code.")) + if "python" in langs: + rules.append(dict( + id="no-debug-remnants", paths=["**/*.py"], severity="blocking", + check="python3 -m becwright.checks.debug_remnants", + intent="Debug code (breakpoints, pdb) must not be committed.", + why="A forgotten breakpoint hangs the process in production or CI.")) + rules.append(dict( + id="no-dangerous-eval", paths=["**/*.py"], severity="blocking", + check="python3 -m becwright.checks.dangerous_eval", + intent="Avoid eval and exec, which run arbitrary code.", + why="Dynamic eval/exec on untrusted input is remote code execution.")) + if "js" in langs or "ts" in langs: + js_globs = [g for g in source_globs if g.endswith((".js", ".ts"))] + rules.append(dict( + id="no-debugger-js", paths=js_globs, severity="blocking", + check="python3 -m becwright.checks.forbid --pattern '\\bdebugger\\b'", + intent="Do not leave 'debugger;' in JavaScript/TypeScript code.", + why="A forgotten 'debugger' halts execution and should not reach production.")) + rules.append(dict( + id="no-console-log-js", paths=js_globs, severity="warning", + check="python3 -m becwright.checks.forbid --pattern 'console\\.log\\s*\\('", + intent="Avoid 'console.log(...)' in JavaScript/TypeScript code.", + why="Debug console.log statements clutter production output.")) + return rules + + +def _render_rules_yaml(rules: list[dict]) -> str: + header = ( + "# becwright rules - generated by `becwright init`. Tune them to your repo.\n" + "# Catalog: https://github.com/DataDave-Dev/becwright/tree/main/becs\n" + "# Docs: https://github.com/DataDave-Dev/becwright/tree/main/documentation\n" + ) + if not rules: + return header + "rules: []\n" + blocks = [] + for r in rules: + paths = "\n".join(f' - "{p}"' for p in r["paths"]) + blocks.append( + f" - id: {r['id']}\n" + f" intent: >\n {r['intent']}\n" + f" why_it_matters: >\n {r['why']}\n" + f" paths:\n{paths}\n" + f" check: {r['check']}\n" + f" severity: {r['severity']}\n" + ) + return header + "rules:\n" + "\n".join(blocks) + + +def _cmd_init(args: argparse.Namespace) -> int: + root = git.repo_root() + rules_path = root / ".bec" / "rules.yaml" + if rules_path.exists() and not args.force: + print(f"{YELLOW}{rules_path} already exists. Use --force to overwrite.{RESET}") + return 1 + langs = _detect_languages(root) + rules = _starter_rules(langs) + rules_path.parent.mkdir(parents=True, exist_ok=True) + rules_path.write_text(_render_rules_yaml(rules), encoding="utf-8") + + detected = ", ".join(langs) if langs else "none" + print(f"{GREEN}Created {rules_path}{RESET} {DIM}({len(rules)} starter rule(s); languages: {detected}){RESET}") + ok, msg = git.install_hook(root) + print((GREEN if ok else YELLOW) + msg + RESET) + print(f"{DIM}Review your rules, then run `becwright check --all` to see the current state.{RESET}") + return 0 + + def _cmd_export(args: argparse.Namespace) -> int: root = git.repo_root() rule = next((r for r in load_rules(root / ".bec" / "rules.yaml") if r.id == args.rule_id), None) @@ -151,6 +244,10 @@ def _build_parser() -> argparse.ArgumentParser: p_check.add_argument("--all", action="store_true", help="check the whole repo, not just staging") p_check.set_defaults(func=_cmd_check) + p_init = sub.add_parser("init", help="scaffold a starter .bec/rules.yaml and install the hook") + p_init.add_argument("--force", action="store_true", help="overwrite an existing .bec/rules.yaml") + p_init.set_defaults(func=_cmd_init) + sub.add_parser("install", help="install the pre-commit hook").set_defaults(func=_cmd_install) sub.add_parser("uninstall", help="remove the pre-commit hook").set_defaults(func=_cmd_uninstall) diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..7d75737 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,108 @@ +import subprocess +from pathlib import Path + +from becwright import cli +from becwright.rules import load_rules + +_SRC = Path(__file__).resolve().parents[1] / "src" + + +def _git(root, *args): + subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) + + +def _init_repo(path): + _git(path, "init") + _git(path, "config", "user.email", "t@t.t") + _git(path, "config", "user.name", "t") + return path + + +# --- detection --- + +def test_detect_languages_skips_noise_dirs(tmp_path): + (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") + (tmp_path / "b.ts").write_text("const x = 1\n", encoding="utf-8") + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "c.js").write_text("//\n", encoding="utf-8") + assert cli._detect_languages(tmp_path) == ["python", "ts"] + + +def test_detect_languages_none(tmp_path): + (tmp_path / "README.md").write_text("# hi\n", encoding="utf-8") + assert cli._detect_languages(tmp_path) == [] + + +# --- starter rules --- + +def test_starter_rules_python(): + ids = [r["id"] for r in cli._starter_rules(["python"])] + assert ids == ["no-hardcoded-secrets", "no-debug-remnants", "no-dangerous-eval"] + + +def test_starter_rules_js(): + ids = [r["id"] for r in cli._starter_rules(["js"])] + assert ids == ["no-hardcoded-secrets", "no-debugger-js", "no-console-log-js"] + + +def test_starter_rules_empty(): + assert cli._starter_rules([]) == [] + + +# --- rendering --- + +def test_render_yaml_parses_and_keeps_forbid(tmp_path): + p = tmp_path / "rules.yaml" + p.write_text(cli._render_rules_yaml(cli._starter_rules(["js"])), encoding="utf-8") + rules = load_rules(p) + assert {r.id for r in rules} == {"no-hardcoded-secrets", "no-debugger-js", "no-console-log-js"} + dbg = next(r for r in rules if r.id == "no-debugger-js") + assert r"--pattern '\bdebugger\b'" in dbg.check + + +def test_render_empty_is_valid(tmp_path): + p = tmp_path / "rules.yaml" + p.write_text(cli._render_rules_yaml([]), encoding="utf-8") + assert load_rules(p) == [] + + +# --- the init command --- + +def test_init_creates_rules_and_hook(tmp_path, monkeypatch): + _init_repo(tmp_path) + (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + assert cli.main(["init"]) == 0 + rules_path = tmp_path / ".bec" / "rules.yaml" + assert "no-debug-remnants" in {r.id for r in load_rules(rules_path)} + assert (tmp_path / ".git" / "hooks" / "pre-commit").exists() + + +def test_init_refuses_existing(tmp_path, monkeypatch): + _init_repo(tmp_path) + (tmp_path / ".bec").mkdir() + rules_path = tmp_path / ".bec" / "rules.yaml" + rules_path.write_text("rules: []\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + assert cli.main(["init"]) == 1 + assert rules_path.read_text(encoding="utf-8") == "rules: []\n" + + +def test_init_force_overwrites(tmp_path, monkeypatch): + _init_repo(tmp_path) + (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") + (tmp_path / ".bec").mkdir() + (tmp_path / ".bec" / "rules.yaml").write_text("rules: []\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + assert cli.main(["init", "--force"]) == 0 + assert load_rules(tmp_path / ".bec" / "rules.yaml") + + +def test_init_generated_rule_blocks_via_check(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHONPATH", str(_SRC)) + _init_repo(tmp_path) + (tmp_path / "app.js").write_text("function f(){ debugger; }\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + assert cli.main(["init"]) == 0 + _git(tmp_path, "add", "app.js") + assert cli.main(["check", "--all"]) == 1