From 04db382a405a8aede87e75fbacdcb1a3db645f92 Mon Sep 17 00:00:00 2001 From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:22:30 -0600 Subject: [PATCH 1/6] chore: bump __version__ to 0.4.0 to match pyproject The 0.4.0 release bumped pyproject.toml but left __version__ at 0.3.0, so `becwright --version` and the packaged metadata disagreed. Align them. --- src/becwright/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/becwright/__init__.py b/src/becwright/__init__.py index 493f741..6a9beea 100644 --- a/src/becwright/__init__.py +++ b/src/becwright/__init__.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.4.0" From 95bda2967b6b3a299da3e4382519ce0f3df26da2 Mon Sep 17 00:00:00 2001 From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:22:41 -0600 Subject: [PATCH 2/6] feat: check --diff to check only files changed vs a ref Adds a third scope to `becwright check`, alongside staged (default) and --all: --diff checks only the files a branch changed vs , using the three-dot range (base...HEAD) so it matches exactly what a pull request shows as 'Files changed'. Content is read from the working tree (in CI the checkout already is the committed code). An unknown base ref raises GitError -> exit 2 (a loud CI failure) instead of silently passing on an empty file list, e.g. on a shallow clone without the base ref. --all and --diff are mutually exclusive. This is the engine half of running becwright as a required CI check: a local hook can be skipped with 'git commit --no-verify', a required check cannot. --- src/becwright/cli.py | 9 +++-- src/becwright/git.py | 30 +++++++++++++++- src/becwright/report.py | 13 ++++--- tests/test_cli_and_git.py | 74 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/becwright/cli.py b/src/becwright/cli.py index bd81ada..e036395 100644 --- a/src/becwright/cli.py +++ b/src/becwright/cli.py @@ -76,7 +76,7 @@ def _print_unknown_checks(unknown: list[tuple[str, str]]) -> None: def _cmd_check(args: argparse.Namespace) -> int: root = git.repo_root() - rules, files, result = report.gather(root, all_files=args.all) + rules, files, result = report.gather(root, all_files=args.all, diff_base=args.diff) unknown = _unknown_builtin_checks(rules, root) if unknown: @@ -784,7 +784,10 @@ def _build_parser() -> argparse.ArgumentParser: sub = parser.add_subparsers(dest="command", required=True) p_check = sub.add_parser("check", help="check the code against the rules") - p_check.add_argument("--all", action="store_true", help="check the whole repo, not just staging") + scope = p_check.add_mutually_exclusive_group() + scope.add_argument("--all", action="store_true", help="check the whole repo, not just staging") + scope.add_argument("--diff", metavar="BASE", + help="check only files changed vs BASE ref (e.g. origin/main) — for CI/PR") p_check.add_argument("--json", action="store_true", help="output results as JSON") p_check.set_defaults(func=_cmd_check) @@ -836,7 +839,7 @@ def main(argv: list[str] | None = None) -> int: args = _build_parser().parse_args(argv) try: return args.func(args) - except (git.NotAGitRepo, RulesError) as e: + except (git.NotAGitRepo, git.GitError, RulesError) as e: print(_style(str(e), RED), file=sys.stderr) return 2 diff --git a/src/becwright/git.py b/src/becwright/git.py index cbea575..25e07d4 100644 --- a/src/becwright/git.py +++ b/src/becwright/git.py @@ -40,6 +40,10 @@ class NotAGitRepo(RuntimeError): pass +class GitError(RuntimeError): + pass + + def repo_root(cwd: Path | None = None) -> Path: res = subprocess.run( ["git", "rev-parse", "--show-toplevel"], @@ -50,7 +54,11 @@ def repo_root(cwd: Path | None = None) -> Path: return Path(res.stdout.strip()) -def files_to_check(root: Path, *, all_files: bool) -> list[str]: +def files_to_check( + root: Path, *, all_files: bool = False, diff_base: str | None = None +) -> list[str]: + if diff_base: + return _files_changed_since(root, diff_base) if all_files: cmd = ["git", "ls-files"] else: @@ -59,6 +67,26 @@ def files_to_check(root: Path, *, all_files: bool) -> list[str]: return [line for line in res.stdout.splitlines() if line.strip()] +def _files_changed_since(root: Path, base: str) -> list[str]: + """Files added/copied/modified between `base` and HEAD, using the three-dot + range (`base...HEAD`) so it reports exactly what the branch introduced since it + diverged — the same set a pull request shows as "Files changed". Raises `GitError` + when `base` is unknown so a CI run fails loudly instead of silently passing on an + empty file list (e.g. a shallow clone without the base ref).""" + res = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=ACM", f"{base}...HEAD"], + cwd=root, capture_output=True, text=True, + ) + if res.returncode != 0: + detail = res.stderr.strip() or f"unknown ref '{base}'" + raise GitError( + f"Could not diff against '{base}': {detail}\n" + "In CI, check out full history (actions/checkout with fetch-depth: 0) " + "and make sure the base branch is fetched." + ) + return [line for line in res.stdout.splitlines() if line.strip()] + + def _staged_blob(root: Path, path: str) -> bytes | None: # `:0:` is the staged (index) version of the file, which is exactly # what the commit will record — not the working-tree copy that may differ. diff --git a/src/becwright/report.py b/src/becwright/report.py index 8a05a3e..de1fbcb 100644 --- a/src/becwright/report.py +++ b/src/becwright/report.py @@ -7,16 +7,19 @@ from .rules import Rule, load_rules -def gather(root: Path, *, all_files: bool) -> tuple[list[Rule], list[str], Result | None]: +def gather( + root: Path, *, all_files: bool = False, diff_base: str | None = None +) -> tuple[list[Rule], list[str], Result | None]: """Load rules, find the files to check and evaluate them. The result is None when there is nothing to check (no rules or no files).""" rules = load_rules(root / ".bec" / "rules.yaml") - files = git.files_to_check(root, all_files=all_files) + files = git.files_to_check(root, all_files=all_files, diff_base=diff_base) if not rules or not files: return rules, files, None - # `--all` inspects the working tree on purpose; the pre-commit path checks the - # staged content, which is what the commit will actually record. - if all_files: + # `--all` and `--diff` inspect the working tree (in CI the checkout already is + # the committed content); the pre-commit path checks the staged content, which + # is what the commit will actually record. + if all_files or diff_base: return rules, files, evaluate(rules, files, root) with git.staged_worktree(root, files) as staged_root: return rules, files, evaluate(rules, files, staged_root) diff --git a/tests/test_cli_and_git.py b/tests/test_cli_and_git.py index 006a4d2..3d3515b 100644 --- a/tests/test_cli_and_git.py +++ b/tests/test_cli_and_git.py @@ -346,6 +346,80 @@ def test_demo_result_flags_both_violations(tmp_path): assert res.had_blocking +# --- diff mode (CI / PR): only the files a branch changed --- + +def _current_branch(root): + return subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=root, capture_output=True, text=True, + ).stdout.strip() + + +def _branch_with_change(tmp_path): + """Base commit with old.py, then a `feature` branch adding new.py. Returns the + base branch name to diff against.""" + _init_repo(tmp_path) + (tmp_path / "old.py").write_text("x = 1\n", encoding="utf-8") + _git(tmp_path, "add", "-A") + _git(tmp_path, "commit", "-m", "base") + base = _current_branch(tmp_path) + _git(tmp_path, "checkout", "-b", "feature") + return base + + +def test_files_to_check_diff_base_returns_only_changed(tmp_path): + base = _branch_with_change(tmp_path) + (tmp_path / "new.py").write_text("y = 2\n", encoding="utf-8") + _git(tmp_path, "add", "-A") + _git(tmp_path, "commit", "-m", "feature") + assert git.files_to_check(tmp_path, diff_base=base) == ["new.py"] + + +def test_files_to_check_diff_base_unknown_ref_raises(tmp_path): + _init_repo(tmp_path) + (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") + _git(tmp_path, "add", "-A") + _git(tmp_path, "commit", "-m", "c") + with pytest.raises(git.GitError): + git.files_to_check(tmp_path, diff_base="origin/does-not-exist") + + +def test_check_diff_blocks_on_changed_file(tmp_path, monkeypatch, capsys): + base = _branch_with_change(tmp_path) + (tmp_path / ".bec").mkdir() + (tmp_path / ".bec" / "rules.yaml").write_text(_rules_yaml("debug_remnants"), encoding="utf-8") + (tmp_path / "bad.py").write_text("breakpoint()\n", encoding="utf-8") + _git(tmp_path, "add", "-A") + _git(tmp_path, "commit", "-m", "feature") + monkeypatch.chdir(tmp_path) + assert cli.main(["check", "--diff", base]) == 1 + assert "Commit BLOCKED" in capsys.readouterr().out + + +def test_check_diff_ignores_violation_outside_the_diff(tmp_path, monkeypatch, capsys): + # A pre-existing violation on the base must not fail CI: --diff only checks what + # the branch actually changed, so a clean change passes regardless of old debt. + _init_repo(tmp_path) + (tmp_path / ".bec").mkdir() + (tmp_path / ".bec" / "rules.yaml").write_text(_rules_yaml("debug_remnants"), encoding="utf-8") + (tmp_path / "legacy.py").write_text("breakpoint()\n", encoding="utf-8") + _git(tmp_path, "add", "-A") + _git(tmp_path, "commit", "-m", "base") + base = _current_branch(tmp_path) + _git(tmp_path, "checkout", "-b", "feature") + (tmp_path / "feature.py").write_text("z = 3\n", encoding="utf-8") + _git(tmp_path, "add", "-A") + _git(tmp_path, "commit", "-m", "feature") + monkeypatch.chdir(tmp_path) + assert cli.main(["check", "--diff", base]) == 0 + assert "All good" in capsys.readouterr().out + + +def test_check_all_and_diff_are_mutually_exclusive(): + with pytest.raises(SystemExit): + cli.main(["check", "--all", "--diff", "main"]) + + # --- the mcp subcommand --- def test_mcp_subcommand_without_extra(monkeypatch): From 2d8a5283d90e6c682608cf5bf1bc0eab81d95fcd Mon Sep 17 00:00:00 2001 From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:22:50 -0600 Subject: [PATCH 3/6] feat: official GitHub Action to run becwright on pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit action.yml is a composite action that installs becwright, resolves the PR base ref, and runs 'becwright check --diff ' — checking only the files the PR changed. Making it a required check enforces the rules even when the local pre-commit hook is bypassed with --no-verify: infrastructure, not a suggestion. Pre-existing debt on the rest of the repo never fails the build, so it can be adopted on a large codebase without a red wall. Inputs: base, version, python-version, args (all optional). .github/workflows/becwright.yml dogfoods the action on this repo's own PRs via the local action with version: . (installs the checked-out becwright), which both verifies the action end-to-end and guards this repo the way we ask others to guard theirs. --- .github/workflows/becwright.yml | 19 +++++++++ action.yml | 70 +++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 .github/workflows/becwright.yml create mode 100644 action.yml diff --git a/.github/workflows/becwright.yml b/.github/workflows/becwright.yml new file mode 100644 index 0000000..600bb01 --- /dev/null +++ b/.github/workflows/becwright.yml @@ -0,0 +1,19 @@ +# Dogfood the official becwright action on our own pull requests: it installs the +# checked-out becwright (version: .) and checks only the files this PR changes +# against the repo's own .bec/rules.yaml. This both verifies the action works and +# guards this repo the same way we ask others to guard theirs. +name: becwright + +on: + pull_request: + +jobs: + becwright: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history so the merge-base with the PR base exists + - uses: ./ + with: + version: . diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..8bc183b --- /dev/null +++ b/action.yml @@ -0,0 +1,70 @@ +name: becwright +description: >- + Enforce BECs (Bound Executable Constraints) on the files a pull request changes. + A commit hook can be skipped with `git commit --no-verify`; a required CI check + cannot — so this closes that gap and makes the rules infrastructure, not a suggestion. +author: DataDave-Dev +branding: + icon: shield + color: red + +inputs: + base: + description: >- + Git ref to diff against. Defaults to the PR base branch + (origin/$GITHUB_BASE_REF) on pull_request events, or the repository default + branch otherwise. Only files changed vs this ref are checked. + required: false + default: '' + version: + description: >- + pip requirement specifier used to install becwright — e.g. "becwright", + "becwright==0.4.0", or "." to install the checked-out repo (for dogfooding). + required: false + default: becwright + python-version: + description: Python version used to run becwright. + required: false + default: '3.x' + args: + description: Extra arguments appended to `becwright check` (e.g. "--json"). + required: false + default: '' + +runs: + using: composite + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install becwright + shell: bash + run: pip install "${{ inputs.version }}" + + - name: Check changed files + shell: bash + env: + BEC_BASE: ${{ inputs.base }} + BEC_ARGS: ${{ inputs.args }} + GH_BASE_REF: ${{ github.base_ref }} + GH_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + base="$BEC_BASE" + if [ -z "$base" ]; then + if [ -n "${GH_BASE_REF:-}" ]; then + base="origin/${GH_BASE_REF}" + else + base="origin/${GH_DEFAULT_BRANCH}" + fi + fi + # Make the base ref available even if the checkout was shallow. Harmless if + # it is already present; requires fetch-depth: 0 for the merge-base to exist. + branch="${GH_BASE_REF:-$GH_DEFAULT_BRANCH}" + if [ -n "$branch" ]; then + git fetch --no-tags --quiet origin "$branch" || true + fi + echo "becwright: checking files changed vs ${base}" + becwright check --diff "$base" $BEC_ARGS From cc58177408cc9ba07b32cd918488d0cc117541c1 Mon Sep 17 00:00:00 2001 From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:30:33 -0600 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20becwright=20why=20+=20MCP=20list=5F?= =?UTF-8?q?rules=20=E2=80=94=20the=20repo's=20queryable=20decision=20memor?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every rule already carries its intent, the reason behind it and rejected alternatives (the BEC's Bound half), but so far they only surfaced when a commit was blocked. Now they can be read on demand: - 'becwright why' lists every rule with its intent; 'becwright why ' shows the full decision record; '--json' emits it for programmatic use. - The MCP server gains a 'list_rules' tool returning the same records, so an agent can read the decisions it must not violate *before* writing code and steer clear of a blocked commit instead of discovering the rule only when it fires. report.rule_record() is the shared serializer behind both. This turns the .bec/rules.yaml catalog into queryable architectural memory: you don't hand the model all the context, you hand it the decisions it can't break. --- README.es.md | 11 ++++- README.md | 11 ++++- documentation/mcp.md | 7 +++ src/becwright/cli.py | 75 ++++++++++++++++++++++++++++++ src/becwright/mcp_server.py | 18 ++++++++ src/becwright/report.py | 18 ++++++++ tests/test_cli_and_git.py | 92 +++++++++++++++++++++++++++++++++++++ tests/test_mcp.py | 16 ++++++- 8 files changed, 242 insertions(+), 6 deletions(-) diff --git a/README.es.md b/README.es.md index afebf12..8ddcd74 100644 --- a/README.es.md +++ b/README.es.md @@ -202,6 +202,7 @@ Comandos disponibles: | `becwright list` | Lista los checks incluidos | | `becwright check` | Corre las reglas sobre los archivos en staging | | `becwright check --diff ` | Corre las reglas solo sobre los archivos cambiados vs `` (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 search [texto]` | Lista BECs listas del catálogo incluido | | `becwright add ` | Instala una BEC del catálogo en `.bec/rules.yaml` (sin conexión) | | `becwright install` | Instala el hook `pre-commit` nativo | @@ -295,8 +296,14 @@ Agrega un skill `becwright` y un comando `/becwright`. Ver Para resultados estructurados, `becwright check --json` imprime un resumen legible por máquina, y `becwright mcp` (instalá el extra `mcp`: `pipx install "becwright[mcp]"`) levanta un servidor MCP — MCP es una forma estándar de que -las herramientas de IA se conecten a habilidades extra — que expone `check` y -`list_checks` a cualquier agente. Ver [`documentation/mcp.md`](documentation/mcp.md). +las herramientas de IA se conecten a habilidades extra — que expone `check`, +`list_checks` y `list_rules` a cualquier agente. Ver [`documentation/mcp.md`](documentation/mcp.md). + +Mejor aún, un agente puede leer las reglas *antes* de escribir código: `becwright +why --json` le entrega las decisiones que no puede violar (la intención de cada +regla y su razón), así las esquiva en vez de descubrir la regla recién cuando el +commit se bloquea. El catálogo `.bec/rules.yaml` se vuelve la memoria de +decisiones consultable del repo. Una regla en `.bec/rules.yaml`: diff --git a/README.md b/README.md index f3acb97..77f33b9 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ Available commands: | `becwright list` | List the built-in checks | | `becwright check` | Runs the rules over the staged files | | `becwright check --diff ` | Runs the rules over only the files changed vs `` (for CI/PR) | +| `becwright why [id]` | Shows the intent + why behind the rules — the repo's decision memory (`--json` for agents) | | `becwright search [query]` | Lists ready-made BECs from the built-in catalog | | `becwright add ` | Installs a catalog BEC into `.bec/rules.yaml` (offline) | | `becwright install` | Installs the native `pre-commit` hook | @@ -287,8 +288,14 @@ It adds a `becwright` skill and a `/becwright` command. See For structured results, `becwright check --json` prints a machine-readable summary, and `becwright mcp` (install the `mcp` extra: `pipx install "becwright[mcp]"`) runs an MCP server — MCP is a standard way for AI tools to -plug in extra abilities — exposing `check` and `list_checks` to any agent. See -[`documentation/mcp.md`](documentation/mcp.md). +plug in extra abilities — exposing `check`, `list_checks` and `list_rules` to any +agent. See [`documentation/mcp.md`](documentation/mcp.md). + +Better yet, an agent can read the rules *before* it writes code: `becwright why +--json` hands it the decisions it must not violate (each rule's intent and the +reason behind it), so it steers clear of a broken commit instead of discovering +the rule only when the commit is blocked. The `.bec/rules.yaml` catalog becomes +the repo's queryable decision memory. A rule in `.bec/rules.yaml`: diff --git a/documentation/mcp.md b/documentation/mcp.md index e306227..b3840cd 100644 --- a/documentation/mcp.md +++ b/documentation/mcp.md @@ -53,10 +53,17 @@ pipx install "becwright[mcp]" # or: pip install "becwright[mcp]" |---|---|---| | `check` | `all_files` (bool), `path` (optional repo dir) | the same summary as `check --json` | | `list_checks` | — | the built-in checks as `{name, description}` | +| `list_rules` | `path` (optional repo dir) | the repo's rules as decision records (`id`, `severity`, `intent`, `why_it_matters`, `rejected_alternatives`, `paths`, `check`) | | `preview_rule` | `check`, `paths`, `exclude` (optional), `all_files`, `path` | `{matched_files, passed, output, note}` — a dry-run without writing the rule | | `propose_rules_from_claude_md` | `path` (optional repo dir) | `{rules, unmapped_hint}` — the rules becwright can derive from the repo's CLAUDE.md | | `add_rule` | `id`, `check`, `paths`, `intent`, `why_it_matters`, `severity`, `exclude`, `confirm`, `path` | writes a rule to `.bec/rules.yaml` — preview unless `confirm=true` | +**`list_rules`** is the decision memory: it returns every rule with its intent, +the reason behind it and the check that enforces it, so an agent can read the +decisions it must not violate *before* writing code — the same data `check` +surfaces on failure, but available up front. It mirrors the CLI `becwright why +--json`. + **`propose_rules_from_claude_md`** returns the rules becwright can derive deterministically from the prose (each with the phrase that triggered it) — the agent's *starting point*. **`preview_rule`** lets the agent *validate* a rule diff --git a/src/becwright/cli.py b/src/becwright/cli.py index e036395..258fc71 100644 --- a/src/becwright/cli.py +++ b/src/becwright/cli.py @@ -50,6 +50,76 @@ def _print_result(result: Result) -> None: print() +def _severity_label(rule, width: int = 0) -> str: + codes = {"blocking": (RED, BOLD), "advisory": (CYAN,)}.get(rule.severity, (YELLOW,)) + return _style(rule.severity.ljust(width), *codes) + + +def _one_line(text: str, limit: int = 72) -> str: + collapsed = " ".join(text.split()) + return collapsed if len(collapsed) <= limit else collapsed[: limit - 1].rstrip() + "…" + + +def _print_rules_overview(rules) -> None: + print(f"{_style('becwright why', BOLD)} " + f"{_style(f'— the decisions this repo enforces ({len(rules)} rule(s))', DIM)}\n") + width = max(len(r.id) for r in rules) + for r in rules: + intent = _one_line(r.intent) if r.intent else _style("(no intent recorded)", DIM) + print(f" {_style(r.id.ljust(width), GREEN)} {_severity_label(r, 8)} {intent}") + print(_style("\n Run `becwright why ` for the full record of one rule, " + "or add --json for agents.", DIM)) + + +def _print_rule_detail(rule) -> None: + print(f"{_style('becwright why', BOLD)} {_style(rule.id, GREEN)} ({_severity_label(rule)})\n") + if rule.intent: + print(f" {_style('Intent:', DIM)}") + print(f" {rule.intent}") + if rule.why_it_matters: + print(f" {_style('Why it matters:', DIM)}") + print(f" {rule.why_it_matters}") + if rule.rejected_alternatives: + print(f" {_style('Rejected alternatives:', DIM)}") + for alt in rule.rejected_alternatives: + print(f" - {alt}") + applies = "the commit message" if rule.target == "commit-msg" else ( + ", ".join(rule.paths) or "(no paths)") + print(f" {_style('Applies to:', DIM)} {applies}") + if rule.exclude: + print(f" {_style('Excluding:', DIM)} {', '.join(rule.exclude)}") + print(f" {_style('Check:', DIM)} {rule.check}") + + +def _cmd_why(args: argparse.Namespace) -> int: + root = git.repo_root() + rules = load_rules(root / ".bec" / "rules.yaml") + if args.rule_id: + rule = next((r for r in rules if r.id == args.rule_id), None) + if rule is None: + print(_style(f"No rule with id '{args.rule_id}' in .bec/rules.yaml.", RED), + file=sys.stderr) + if rules: + print(_style(f" Known ids: {', '.join(r.id for r in rules)}", DIM), + file=sys.stderr) + return 1 + if args.json: + import json + print(json.dumps(report.rule_record(rule), indent=2)) + return 0 + _print_rule_detail(rule) + return 0 + if args.json: + import json + print(json.dumps({"rules": [report.rule_record(r) for r in rules]}, indent=2)) + return 0 + if not rules: + print(_style("No .bec/rules.yaml with rules. Run `becwright init` to create some.", YELLOW)) + return 0 + _print_rules_overview(rules) + return 0 + + def _unknown_builtin_checks(rules, root: Path) -> list[tuple[str, str]]: """Rules whose `check` uses the `becwright run ` form with a that is not a built-in check. Such a rule can never pass — the check exits with an @@ -799,6 +869,11 @@ def _build_parser() -> argparse.ArgumentParser: help="derive rules from the repo's CLAUDE.md (best-effort: maps prohibitions to enforceable checks)") p_init.set_defaults(func=_cmd_init) + p_why = sub.add_parser("why", help="show the intent + why behind the rules (the repo's decision memory)") + p_why.add_argument("rule_id", nargs="?", help="rule id to explain (default: list every rule)") + p_why.add_argument("--json", action="store_true", help="output as JSON (for AI agents to consult before writing code)") + p_why.set_defaults(func=_cmd_why) + p_run = sub.add_parser("run", help="run a built-in check against files on stdin (used inside rules)") p_run.add_argument("module", help="built-in check name (see `becwright list`)") p_run.add_argument("args", nargs=argparse.REMAINDER, help="arguments forwarded to the check") diff --git a/src/becwright/mcp_server.py b/src/becwright/mcp_server.py index 503fa09..e9564a5 100644 --- a/src/becwright/mcp_server.py +++ b/src/becwright/mcp_server.py @@ -47,6 +47,24 @@ def list_checks() -> list[dict]: ] +@mcp.tool() +def list_rules(path: str | None = None) -> list[dict]: + """List the repo's rules as decision records — each with its intent, the reason + behind it, rejected alternatives and the check that enforces it. + + Read these *before* writing code: they are the decisions this repo will not let + you violate, so you can steer clear of a blocked commit instead of discovering a + rule only when it fires. This is the queryable half of the same data `check` + surfaces on failure. + + Args: + path: a directory inside the target git repo (defaults to the cwd). + """ + from .rules import load_rules + root = git.repo_root(Path(path) if path else None) + return [report.rule_record(r) for r in load_rules(root / ".bec" / "rules.yaml")] + + @mcp.tool() def preview_rule(check: str, paths: list[str], exclude: list[str] | None = None, all_files: bool = True, path: str | None = None) -> dict: diff --git a/src/becwright/report.py b/src/becwright/report.py index de1fbcb..35994f0 100644 --- a/src/becwright/report.py +++ b/src/becwright/report.py @@ -25,6 +25,24 @@ def gather( return rules, files, evaluate(rules, files, staged_root) +def rule_record(rule: Rule) -> dict: + """A rule's *Bound* half — its intent, reason and the decision behind it — as a + serializable record. Shared by `becwright why --json` and any agent that wants + the decisions it must not violate *before* writing code, not only when a commit + fails.""" + return { + "id": rule.id, + "severity": rule.severity, + "target": rule.target, + "intent": rule.intent or None, + "why_it_matters": rule.why_it_matters or None, + "rejected_alternatives": list(rule.rejected_alternatives), + "paths": list(rule.paths), + "exclude": list(rule.exclude), + "check": rule.check, + } + + def payload(rules: list[Rule], files: list[str], result: Result | None) -> dict: """Build a JSON-serializable summary shared by `check --json` and the MCP server.""" results = [] diff --git a/tests/test_cli_and_git.py b/tests/test_cli_and_git.py index 3d3515b..14b2792 100644 --- a/tests/test_cli_and_git.py +++ b/tests/test_cli_and_git.py @@ -420,6 +420,98 @@ def test_check_all_and_diff_are_mutually_exclusive(): cli.main(["check", "--all", "--diff", "main"]) +# --- the why subcommand (decision memory) --- + +_WHY_RULES = """\ +rules: + - id: no-eval + intent: "Avoid eval and exec." + why_it_matters: "Arbitrary code execution is a security hole." + rejected_alternatives: + - "sandboxing eval" + paths: ["**/*.py"] + exclude: ["tests/**"] + check: "becwright run dangerous_eval" + severity: blocking + - id: conv + target: commit-msg + intent: "Use Conventional Commits." + check: 'becwright run require --pattern "^feat: "' + severity: warning +""" + + +def _write_why_rules(tmp_path): + (tmp_path / ".bec").mkdir() + (tmp_path / ".bec" / "rules.yaml").write_text(_WHY_RULES, encoding="utf-8") + + +def test_why_lists_all_rules(tmp_path, monkeypatch, capsys): + _init_repo(tmp_path) + _write_why_rules(tmp_path) + monkeypatch.chdir(tmp_path) + assert cli.main(["why"]) == 0 + out = capsys.readouterr().out + assert "no-eval" in out and "conv" in out + assert "Avoid eval and exec." in out + + +def test_why_detail_shows_full_record(tmp_path, monkeypatch, capsys): + _init_repo(tmp_path) + _write_why_rules(tmp_path) + monkeypatch.chdir(tmp_path) + assert cli.main(["why", "no-eval"]) == 0 + out = capsys.readouterr().out + assert "Intent:" in out and "Why it matters:" in out + assert "Rejected alternatives:" in out and "sandboxing eval" in out + assert "dangerous_eval" in out and "tests/**" in out + + +def test_why_commit_msg_rule_applies_to_message(tmp_path, monkeypatch, capsys): + _init_repo(tmp_path) + _write_why_rules(tmp_path) + monkeypatch.chdir(tmp_path) + assert cli.main(["why", "conv"]) == 0 + assert "the commit message" in capsys.readouterr().out + + +def test_why_unknown_id_returns_1_and_lists_ids(tmp_path, monkeypatch, capsys): + _init_repo(tmp_path) + _write_why_rules(tmp_path) + monkeypatch.chdir(tmp_path) + assert cli.main(["why", "ghost"]) == 1 + err = capsys.readouterr().err + assert "ghost" in err and "no-eval" in err + + +def test_why_json_lists_all(tmp_path, monkeypatch, capsys): + import json + _init_repo(tmp_path) + _write_why_rules(tmp_path) + monkeypatch.chdir(tmp_path) + assert cli.main(["why", "--json"]) == 0 + data = json.loads(capsys.readouterr().out) + assert [r["id"] for r in data["rules"]] == ["no-eval", "conv"] + assert data["rules"][0]["why_it_matters"].startswith("Arbitrary code execution") + + +def test_why_json_single_rule(tmp_path, monkeypatch, capsys): + import json + _init_repo(tmp_path) + _write_why_rules(tmp_path) + monkeypatch.chdir(tmp_path) + assert cli.main(["why", "no-eval", "--json"]) == 0 + rec = json.loads(capsys.readouterr().out) + assert rec["id"] == "no-eval" and rec["rejected_alternatives"] == ["sandboxing eval"] + + +def test_why_no_rules_is_friendly(tmp_path, monkeypatch, capsys): + _init_repo(tmp_path) + monkeypatch.chdir(tmp_path) + assert cli.main(["why"]) == 0 + assert "No .bec/rules.yaml" in capsys.readouterr().out + + # --- the mcp subcommand --- def test_mcp_subcommand_without_extra(monkeypatch): diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 77db082..f279283 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -37,8 +37,8 @@ def _repo(path): def test_tools_registered(): tools = asyncio.run(mcp_server.mcp.list_tools()) assert {t.name for t in tools} == { - "check", "list_checks", "preview_rule", "propose_rules_from_claude_md", - "add_rule", + "check", "list_checks", "list_rules", "preview_rule", + "propose_rules_from_claude_md", "add_rule", } @@ -48,6 +48,18 @@ def test_list_checks_tool_returns_all_builtins(): assert names == sorted(names) +def test_list_rules_tool_returns_decision_records(tmp_path): + _repo_with_rule(tmp_path) + records = mcp_server.list_rules(path=str(tmp_path)) + assert [r["id"] for r in records] == ["no-bp"] + assert records[0]["severity"] == "blocking" and "check" in records[0] + + +def test_list_rules_tool_empty_when_no_rules(tmp_path): + _repo(tmp_path) + assert mcp_server.list_rules(path=str(tmp_path)) == [] + + def test_check_tool_reports_block(tmp_path): _repo_with_rule(tmp_path) (tmp_path / "a.py").write_text("breakpoint()\n", encoding="utf-8") From 70a6773470d9c71e9d0d638634841044024c9796 Mon Sep 17 00:00:00 2001 From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:33:57 -0600 Subject: [PATCH 5/6] docs: position becwright as "the enforcement layer for AI coding agents" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leads every one-liner with the category instead of the mechanism — a piece of the stack, not a script. Consistent across the surfaces where the pitch appears: both README heroes, the PyPI (pyproject) and npm descriptions, and the Claude Code plugin (plugin.json + its README). The sign-vs-guard hook stays; the positioning line now sits above it and the mechanism (runs the rules, blocks the commit, any author) follows. --- README.es.md | 7 +++++-- README.md | 7 +++++-- integrations/claude-code/.claude-plugin/plugin.json | 2 +- integrations/claude-code/README.md | 5 +++-- npm/becwright/package.json | 2 +- pyproject.toml | 2 +- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/README.es.md b/README.es.md index 8ddcd74..929ee1f 100644 --- a/README.es.md +++ b/README.es.md @@ -10,8 +10,11 @@ [![npm](https://img.shields.io/npm/v/becwright?logo=npm)](https://www.npmjs.com/package/becwright) [![PyPI](https://img.shields.io/pypi/v/becwright?logo=pypi&logoColor=white)](https://pypi.org/project/becwright/) -**Reglas que se ejecutan, no notas que se ignoran.** -Tu `CLAUDE.md` es un *cartel*. becwright es el *guardia*. +**La capa de enforcement para agentes de IA.** + +Reglas que se ejecutan, no notas que se ignoran. Tu `CLAUDE.md` es un *cartel*; +becwright es el *guardia* — corre tus reglas sobre el código y frena el commit +cuando una se rompe, sin importar qué modelo (o persona) lo escribió. Determinista, no probabilístico · cualquier lenguaje · sin Python · frena el commit **y** lleva el *por qué*. diff --git a/README.md b/README.md index 77f33b9..46fcf3c 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,11 @@ [![npm](https://img.shields.io/npm/v/becwright?logo=npm)](https://www.npmjs.com/package/becwright) [![PyPI](https://img.shields.io/pypi/v/becwright?logo=pypi&logoColor=white)](https://pypi.org/project/becwright/) -**Rules that run, not notes that get ignored.** -Your `CLAUDE.md` is a *sign*. becwright is the *guard*. +**The enforcement layer for AI coding agents.** + +Rules that run, not notes that get ignored. Your `CLAUDE.md` is a *sign*; +becwright is the *guard* — it runs your rules against the code and blocks the +commit when one breaks, no matter which model (or person) wrote it. Deterministic, not probabilistic · any language · no Python required · blocks the commit **and** carries the *why*. diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json index 0053007..fb51dc9 100644 --- a/integrations/claude-code/.claude-plugin/plugin.json +++ b/integrations/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "becwright", - "description": "Deterministic, commit-blocking constraints (BECs) on your code. Install and use becwright from any session; the guaranteed safety net that complements CLAUDE.md. No Python required.", + "description": "The enforcement layer for AI coding agents — deterministic, commit-blocking constraints (BECs). Install and use becwright from any session; the guaranteed safety net that complements CLAUDE.md. No Python required.", "version": "0.2.0", "author": { "name": "Alonso David De Leon Rodarte" diff --git a/integrations/claude-code/README.md b/integrations/claude-code/README.md index f3d76b5..4c98e48 100644 --- a/integrations/claude-code/README.md +++ b/integrations/claude-code/README.md @@ -1,7 +1,8 @@ # becwright — Claude Code plugin -A Claude Code plugin so any agent session can install and drive becwright: the -deterministic, commit-blocking safety net that complements `CLAUDE.md`. +A Claude Code plugin so any agent session can install and drive becwright — the +enforcement layer for AI coding agents: the deterministic, commit-blocking safety +net that complements `CLAUDE.md`. ## Install diff --git a/npm/becwright/package.json b/npm/becwright/package.json index 2544be0..52ed3d3 100644 --- a/npm/becwright/package.json +++ b/npm/becwright/package.json @@ -1,7 +1,7 @@ { "name": "becwright", "version": "0.4.0", - "description": "Deterministic, portable constraints (BECs) that block commits violating your rules — a safety net for AI-written and human-written code. No Python required.", + "description": "The enforcement layer for AI coding agents — deterministic, portable constraints (BECs) that block a commit when a rule breaks, for AI-written and human-written code. No Python required.", "bin": { "becwright": "bin/becwright.js" }, diff --git a/pyproject.toml b/pyproject.toml index cd4d351..85dfef2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "becwright" version = "0.4.0" -description = "Deterministic, portable constraints (BECs) that block commits violating your rules — a safety net for AI-written and human-written code." +description = "The enforcement layer for AI coding agents — deterministic, portable constraints (BECs) that block a commit when a rule breaks, for AI-written and human-written code." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "Alonso David De Leon Rodarte" }] From 6c90a6549636c56372ace0fa5c3a94f14869e76e Mon Sep 17 00:00:00 2001 From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:35:54 -0600 Subject: [PATCH 6/6] docs: add the lean-context angle for AI agents (check --json / why --json) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The honest version of 'give the AI only what it needs': a blocked commit returns the one rule that broke, its why and the exact lines, so the agent fixes that precisely instead of re-reading the whole style guide into context — and the guarantee never depended on the model reading anything. Added to the AI-agents section of both READMEs. --- README.es.md | 8 ++++++++ README.md | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/README.es.md b/README.es.md index 929ee1f..33f8042 100644 --- a/README.es.md +++ b/README.es.md @@ -308,6 +308,14 @@ regla y su razón), así las esquiva en vez de descubrir la regla recién cuando commit se bloquea. El catálogo `.bec/rules.yaml` se vuelve la memoria de decisiones consultable del repo. +En ambos casos la señal se mantiene magra. Un commit bloqueado devuelve la única +regla que se rompió, su *por qué* y las líneas exactas — el agente arregla justo +eso en vez de releer la guía de estilo entera en el contexto. El consejo de +siempre es "dale más contexto al modelo"; becwright lo da vuelta — le pasás la +constraint puntual que rompió, verificada de forma determinista, no el reglamento +completo. Menos tokens, loop más ajustado, y la garantía no depende de que el +modelo haya leído nada. + Una regla en `.bec/rules.yaml`: ```yaml diff --git a/README.md b/README.md index 46fcf3c..a8bd694 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,13 @@ reason behind it), so it steers clear of a broken commit instead of discovering the rule only when the commit is blocked. The `.bec/rules.yaml` catalog becomes the repo's queryable decision memory. +Either way the signal stays lean. A blocked commit returns the one rule that +broke, its *why*, and the exact lines — the agent fixes precisely that instead of +re-reading the whole style guide into context. The usual advice is "give the +model more context"; becwright inverts it — you hand it the specific constraint it +broke, checked deterministically, not the entire rulebook. Fewer tokens, tighter +loop, and the guarantee doesn't depend on the model having read anything at all. + A rule in `.bec/rules.yaml`: ```yaml