diff --git a/schema/rules.schema.json b/schema/rules.schema.json index d4c4a97..f214e91 100644 --- a/schema/rules.schema.json +++ b/schema/rules.schema.json @@ -11,6 +11,11 @@ "minimum": 1, "description": "Format version of this file; absent means 1. becwright refuses a file stamped newer than it understands." }, + "global_exclude": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Global glob patterns of files to exclude from all file-based rules." + }, "rules": { "type": "array", "description": "The rules (BECs) enforced on every commit.", diff --git a/src/becwright/rules.py b/src/becwright/rules.py index 9608c67..c083f18 100644 --- a/src/becwright/rules.py +++ b/src/becwright/rules.py @@ -42,7 +42,7 @@ def is_advisory(self) -> bool: return self.severity == "advisory" -def _to_rule(raw: dict) -> Rule: +def _to_rule(raw: dict, global_exclude: list[str] | None = None) -> Rule: if not isinstance(raw, dict): raise RulesError(f"each rule must be a mapping, got {type(raw).__name__}.") for field in ("id", "check"): @@ -60,11 +60,16 @@ def _to_rule(raw: dict) -> Rule: f"rule '{raw['id']}': invalid target {target!r} " f"(use one of: {', '.join(_VALID_TARGETS)})." ) + + exclude_list = list(raw.get("exclude", [])) + if global_exclude: + exclude_list.extend(global_exclude) + return Rule( id=raw["id"], paths=tuple(raw.get("paths", [])), check=raw["check"], - exclude=tuple(raw.get("exclude", [])), + exclude=tuple(exclude_list), intent=(raw.get("intent") or "").strip(), why_it_matters=(raw.get("why_it_matters") or "").strip(), rejected_alternatives=tuple(raw.get("rejected_alternatives", [])), @@ -83,7 +88,12 @@ def load_rules(rules_path: Path) -> list[Rule]: if not isinstance(data, dict) or not isinstance(data.get("rules", []), list): raise RulesError(f"{rules_path}: expected a top-level 'rules:' list.") _check_schema_version(data.get("schema_version"), rules_path) - return [_to_rule(r) for r in data["rules"]] if data.get("rules") else [] + + global_exclude = data.get("global_exclude", []) + if not isinstance(global_exclude, list) or not all(isinstance(x, str) for x in global_exclude): + raise RulesError(f"{rules_path}: global_exclude must be a list of glob patterns.") + + return [_to_rule(r, global_exclude) for r in data["rules"]] if data.get("rules") else [] def _check_schema_version(value, rules_path: Path) -> None: diff --git a/tests/test_cli_and_git.py b/tests/test_cli_and_git.py index 9c0f738..c6cbf7d 100644 --- a/tests/test_cli_and_git.py +++ b/tests/test_cli_and_git.py @@ -521,6 +521,25 @@ def test_mcp_subcommand_without_extra(monkeypatch): assert cli.main(["mcp"]) == 2 +def test_check_respects_global_exclude(tmp_path, monkeypatch, capsys): + _init_repo(tmp_path) + (tmp_path / ".bec").mkdir() + + src_posix = _SRC.as_posix() + check = f'"{sys.executable}" -c "import sys; sys.path.insert(0, \'{src_posix}\'); from becwright.checks.forbid import main; sys.exit(main())" --pattern "breakpoint"' + + (tmp_path / ".bec" / "rules.yaml").write_text( + "global_exclude:\n - 'ignored/**'\n" + "rules:\n - id: no-bp\n paths: ['**/*.py']\n" + f" check: |-\n {check}\n severity: blocking\n", encoding="utf-8") + + (tmp_path / "ignored").mkdir() + (tmp_path / "ignored" / "app.py").write_text("breakpoint()\n", encoding="utf-8") + _git(tmp_path, "add", "ignored/app.py") + + monkeypatch.chdir(tmp_path) + assert cli.main(["check"]) == 0 + assert "All good" in capsys.readouterr().out def test_cli_custom_help(capsys): assert cli.main([]) == 0 out = capsys.readouterr().out @@ -535,3 +554,4 @@ def test_cli_custom_help(capsys): out2 = capsys.readouterr().out assert "Usage: becwright" in out2 + diff --git a/tests/test_rules.py b/tests/test_rules.py index 64e00db..eaa3e89 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -126,3 +126,35 @@ def test_non_positive_schema_version_raises(tmp_path): path = _write(tmp_path, 'schema_version: 0\nrules:\n - id: r1\n check: "true"\n') with pytest.raises(RulesError, match="schema_version"): load_rules(path) + + +def test_global_exclude_merges_with_rule_exclude(tmp_path): + path = _write( + tmp_path, + 'global_exclude:\n - "vendor/**"\n - "build/**"\n' + 'rules:\n - id: r1\n check: "true"\n exclude: ["local/exclude.py"]\n' + ) + rules = load_rules(path) + assert len(rules) == 1 + assert rules[0].exclude == ("local/exclude.py", "vendor/**", "build/**") + + +def test_global_exclude_not_list_raises(tmp_path): + path = _write( + tmp_path, + 'global_exclude: "not-a-list"\n' + 'rules:\n - id: r1\n check: "true"\n' + ) + with pytest.raises(RulesError, match="global_exclude must be a list"): + load_rules(path) + + +def test_global_exclude_non_string_elements_raises(tmp_path): + path = _write( + tmp_path, + 'global_exclude:\n - 123\n' + 'rules:\n - id: r1\n check: "true"\n' + ) + with pytest.raises(RulesError, match="global_exclude must be a list"): + load_rules(path) +