diff --git a/documentation/usage.es.md b/documentation/usage.es.md index 767c4d3..baaa98d 100644 --- a/documentation/usage.es.md +++ b/documentation/usage.es.md @@ -149,6 +149,14 @@ rules: | `severity` | no | `blocking` (por defecto), `warning` o `advisory` (ver abajo) | | `target` | no | `files` (por defecto) o `commit-msg` (ver abajo) | +> **Autocompletado y validación en el editor.** `.bec/rules.yaml` tiene un +> [JSON Schema](../schema/rules.schema.json) publicado. `becwright init` escribe +> una línea `# yaml-language-server: $schema=…` al inicio del archivo, así los +> editores con language server de YAML (la extensión YAML de VS Code, los IDEs +> de JetBrains) autocompletan los campos y marcan un typo (`pathss:`, +> `severity: blockng`) mientras escribís. Agregá esa línea a mano en un archivo +> de reglas preexistente para obtener lo mismo. + **`schema_version`** es una clave opcional de nivel superior (no un campo de regla). Sella la versión de formato del archivo; cuando falta se trata como `1`, así que los archivos existentes siguen funcionando. `becwright init` la escribe, diff --git a/documentation/usage.md b/documentation/usage.md index 59ac159..8de2751 100644 --- a/documentation/usage.md +++ b/documentation/usage.md @@ -148,6 +148,13 @@ rules: | `severity` | no | `blocking` (default), `warning`, or `advisory` (see below) | | `target` | no | `files` (default) or `commit-msg` (see below) | +> **Editor autocompletion and validation.** `.bec/rules.yaml` has a published +> [JSON Schema](../schema/rules.schema.json). `becwright init` writes a +> `# yaml-language-server: $schema=…` line at the top of the file, so editors +> with a YAML language server (VS Code's YAML extension, JetBrains IDEs) +> autocomplete the fields and flag a typo (`pathss:`, `severity: blockng`) as +> you type. Add that line by hand to a pre-existing rules file to get the same. + **`schema_version`** is an optional top-level key (not a rule field). It stamps the format version of the file; when absent it is treated as `1`, so existing files keep working. `becwright init` writes it, and becwright refuses a file diff --git a/pyproject.toml b/pyproject.toml index 6f825eb..2503f86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,8 @@ classifiers = [ becwright = "becwright.cli:main" [project.optional-dependencies] -dev = ["pytest>=8", "pytest-cov>=5", "mcp>=1.2"] +# jsonschema is test-only: it validates schema/rules.schema.json against examples. +dev = ["pytest>=8", "pytest-cov>=5", "mcp>=1.2", "jsonschema>=4"] # Used to freeze the standalone binary distributed via npm (see packaging/). build = ["pyinstaller>=6"] # MCP server for AI agents: `becwright mcp` (see src/becwright/mcp_server.py). diff --git a/schema/rules.schema.json b/schema/rules.schema.json new file mode 100644 index 0000000..d4c4a97 --- /dev/null +++ b/schema/rules.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/DataDave-Dev/becwright/main/schema/rules.schema.json", + "title": "becwright rules", + "description": "Schema for .bec/rules.yaml — the rules becwright enforces on every commit. Field reference: https://github.com/DataDave-Dev/becwright/blob/main/documentation/usage.md", + "type": "object", + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "integer", + "minimum": 1, + "description": "Format version of this file; absent means 1. becwright refuses a file stamped newer than it understands." + }, + "rules": { + "type": "array", + "description": "The rules (BECs) enforced on every commit.", + "items": { "$ref": "#/definitions/rule" } + } + }, + "definitions": { + "rule": { + "type": "object", + "additionalProperties": false, + "required": ["id", "check"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Unique rule id, e.g. no-token-in-logs." + }, + "intent": { + "type": "string", + "description": "What the rule asks for — the 'bound' half of the BEC." + }, + "why_it_matters": { + "type": "string", + "description": "Why the rule exists; shown the moment the rule fails." + }, + "rejected_alternatives": { + "type": "array", + "items": { "type": "string" }, + "description": "Context: approaches that were considered and dropped." + }, + "paths": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Glob patterns of the files this rule applies to (e.g. src/**/*.py). Not needed for target: commit-msg rules." + }, + "exclude": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Globs carved out of `paths` — vendored code, generated files, the check's own implementation." + }, + "check": { + "type": "string", + "minLength": 1, + "description": "Shell command that runs the check: reads the file list on stdin, exits 0 (pass) or non-zero (fail). E.g. becwright run forbid --pattern '...'" + }, + "severity": { + "enum": ["blocking", "warning", "advisory"], + "default": "blocking", + "description": "blocking = stops the commit | warning = deterministic finding, does not block | advisory = best-effort / non-deterministic, reports but never blocks." + }, + "target": { + "enum": ["files", "commit-msg"], + "default": "files", + "description": "files = the changed files (default) | commit-msg = the commit message." + } + } + } + } +} diff --git a/src/becwright/cli.py b/src/becwright/cli.py index fc7f409..118daf8 100644 --- a/src/becwright/cli.py +++ b/src/becwright/cli.py @@ -704,6 +704,7 @@ def _rules_from_claude_md(text: str, langs: list[str]) -> list[tuple[dict, str]] def _render_rules_yaml(rules: list[dict]) -> str: header = ( + "# yaml-language-server: $schema=https://raw.githubusercontent.com/DataDave-Dev/becwright/main/schema/rules.schema.json\n" "# becwright rules - generated by `becwright init`. Tune them to your repo.\n" "# More rules: `becwright search` to list the catalog, `becwright add ` to install.\n" "# Docs: https://github.com/DataDave-Dev/becwright/tree/main/documentation\n" diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..69bac83 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,80 @@ +import json +from pathlib import Path + +import pytest +import yaml + +from becwright import cli + +_SCHEMA_PATH = Path(__file__).resolve().parents[1] / "schema" / "rules.schema.json" +_REPO_RULES = Path(__file__).resolve().parents[1] / ".bec" / "rules.yaml" + +jsonschema = pytest.importorskip("jsonschema") + + +def _schema(): + return json.loads(_SCHEMA_PATH.read_text(encoding="utf-8")) + + +def _validate(data): + jsonschema.validate(data, _schema()) + + +def test_schema_is_valid_jsonschema(): + jsonschema.Draft7Validator.check_schema(_schema()) + + +def test_valid_rules_file_passes(): + _validate({ + "schema_version": 1, + "rules": [{ + "id": "no-token-in-logs", + "intent": "Tokens must never reach a log.", + "why_it_matters": "A token in the logs lets anyone steal a session.", + "rejected_alternatives": ["Redact at log time -> too easy to bypass"], + "paths": ["src/**/*.py"], + "exclude": ["src/logging_setup.py"], + "check": "becwright run no_token_in_logs", + "severity": "blocking", + }], + }) + + +def test_commit_msg_rule_needs_no_paths(): + _validate({ + "rules": [{ + "id": "conventional-commits", + "target": "commit-msg", + "check": "becwright run require --pattern '^feat'", + }], + }) + + +def test_typoed_field_fails(): + with pytest.raises(jsonschema.ValidationError): + _validate({ + "rules": [{ + "id": "x", "check": "true", + "pathss": ["**/*.py"], # the typo the schema exists to catch + }], + }) + + +def test_invalid_severity_fails(): + with pytest.raises(jsonschema.ValidationError): + _validate({"rules": [{"id": "x", "check": "true", "severity": "blockng"}]}) + + +def test_missing_check_fails(): + with pytest.raises(jsonschema.ValidationError): + _validate({"rules": [{"id": "x", "paths": ["**/*.py"]}]}) + + +def test_generated_init_output_validates(): + rendered = cli._render_rules_yaml(cli._starter_rules(["python", "ts"])) + assert rendered.startswith("# yaml-language-server: $schema=") + _validate(yaml.safe_load(rendered)) + + +def test_this_repos_rules_validate(): + _validate(yaml.safe_load(_REPO_RULES.read_text(encoding="utf-8")))