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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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),
Expand Down
8 changes: 4 additions & 4 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion documentation/architecture.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion documentation/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 9 additions & 3 deletions documentation/usage.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 8 additions & 3 deletions documentation/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
97 changes: 97 additions & 0 deletions src/becwright/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +79 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Prune skipped directories before walking into them.

Line 79 still descends through node_modules, .venv, .git, dist, and similar directories; the Line 82 filter only ignores files after traversal. On large repos, becwright init can become unexpectedly slow.

⚙️ Proposed fix
+import os
+
 _SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".tox"}
 _EXT_LANG = {".py": "python", ".js": "js", ".ts": "ts"}
+_LANG_ORDER = ("python", "js", "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]
+    for _, dirnames, filenames in os.walk(root):
+        dirnames[:] = [name for name in dirnames if name not in _SKIP_DIRS]
+        for filename in filenames:
+            lang = _EXT_LANG.get(Path(filename).suffix)
+            if lang:
+                found.add(lang)
+        if len(found) == len(_LANG_ORDER):
+            break
+    return [lang for lang in _LANG_ORDER if lang in found]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
import os
_SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".tox"}
_EXT_LANG = {".py": "python", ".js": "js", ".ts": "ts"}
_LANG_ORDER = ("python", "js", "ts")
def _detect_languages(root: Path) -> list[str]:
found: set[str] = set()
for _, dirnames, filenames in os.walk(root):
dirnames[:] = [name for name in dirnames if name not in _SKIP_DIRS]
for filename in filenames:
lang = _EXT_LANG.get(Path(filename).suffix)
if lang:
found.add(lang)
if len(found) == len(_LANG_ORDER):
break
return [lang for lang in _LANG_ORDER if lang in found]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/becwright/cli.py` around lines 79 - 83, The traversal in cli.py still
walks into skipped directories before filtering files, so update the
root.rglob("*") logic in the init path to prune directories like node_modules,
.venv, .git, and dist before descending. Adjust the walking logic around the
existing _SKIP_DIRS check so directory entries are removed from traversal rather
than only skipped after path inspection, using the same root-relative part
matching in the CLI init flow.

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)
Expand Down Expand Up @@ -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)

Expand Down
108 changes: 108 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
@@ -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
Loading