diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d2801..d19bc16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `becwright init --from-claude-md`: derive rules from the repo's `CLAUDE.md`, mapping recognized prohibitions (secrets, `eval`, `debugger`, `console.log`, breakpoints, wildcard imports, tokens in logs) to enforceable checks and - reporting which phrase matched each. Judgment-based guidance is left for - `CLAUDE.md`. Composes with `--baseline`. + reporting which phrase matched each. Also picks up a per-file line cap + ("files under 800 lines" → `max_lines`), ignoring function-length rules it + can't enforce. Judgment-based guidance is left for `CLAUDE.md`. Composes with + `--baseline`. - Three language-agnostic checks that cover common `CLAUDE.md` rules without an AST: `max_lines` (file length cap via `--max`), `require` (a regex that must be present — the inverse of `forbid`), and `filename` (file-name conventions via diff --git a/documentation/usage.es.md b/documentation/usage.es.md index ee57665..52a897d 100644 --- a/documentation/usage.es.md +++ b/documentation/usage.es.md @@ -39,7 +39,9 @@ a mano: `becwright install` más un `.bec/rules.yaml` que escribas vos.) > **¿Ya tenés un `CLAUDE.md` (o similar)?** Corré `becwright init --from-claude-md`. > Escanea el archivo buscando prohibiciones que reconoce — secretos, `eval`, > `debugger`, `console.log`, breakpoints, imports con `*`, tokens en logs — y -> convierte cada una en una regla enforzable, informando qué frase la disparó. Es +> convierte cada una en una regla enforzable, informando qué frase la disparó. +> También detecta un límite de líneas por archivo ("archivos < 800 líneas" → +> `max_lines`), ignorando reglas de largo de función que no puede enforzar. Es > best-effort y según el lenguaje, así que **revisá el resultado**; lo de criterio > (arquitectura, naming, inmutabilidad) no tiene check determinista y se queda en > `CLAUDE.md`. Combinalo con `--baseline` para adoptar en un repo sucio de una. diff --git a/documentation/usage.md b/documentation/usage.md index e4ee007..c82f94f 100644 --- a/documentation/usage.md +++ b/documentation/usage.md @@ -37,10 +37,12 @@ From then on, every `git commit` runs the checks. (You can also set up by hand: > **Already have a `CLAUDE.md` (or similar)?** Run `becwright init --from-claude-md`. > It scans the file for prohibitions it recognizes — secrets, `eval`, `debugger`, > `console.log`, breakpoints, wildcard imports, tokens in logs — and turns each -> into an enforceable rule, reporting which phrase matched. This is best-effort -> and language-aware, so **review the result**; judgment-based guidance -> (architecture, naming, immutability) has no deterministic check and stays in -> `CLAUDE.md`. Combine with `--baseline` to adopt on a dirty repo in one step. +> into an enforceable rule, reporting which phrase matched. It also picks up a +> per-file line cap ("files under 800 lines" → `max_lines`), ignoring +> function-length rules it can't enforce. This is best-effort and language-aware, +> so **review the result**; judgment-based guidance (architecture, naming, +> immutability) has no deterministic check and stays in `CLAUDE.md`. Combine with +> `--baseline` to adopt on a dirty repo in one step. ## Commands diff --git a/src/becwright/cli.py b/src/becwright/cli.py index ba9f84c..4b60ef7 100644 --- a/src/becwright/cli.py +++ b/src/becwright/cli.py @@ -3,6 +3,7 @@ import argparse import os import pkgutil +import re import sys import urllib.error import urllib.request @@ -317,6 +318,24 @@ def _read_claude_md(root: Path) -> str | None: return None +# A line cap tied to *files* (not functions — that needs an AST): "files < 800 +# lines" or "800 lines per file", EN/ES. The file/module anchor avoids mapping a +# "functions < 50 lines" rule, which becwright cannot enforce. +_FILE_LINE_CAP = re.compile( + r"(?:files?|archivos?|modules?|m[óo]dulos?)\b[^.\n]{0,40}?(\d{2,4})\s*(?:lines?|l[ií]neas?)" + r"|(\d{2,4})\s*(?:lines?|l[ií]neas?)\b[^.\n]{0,25}?(?:per\s+|por\s+)?(?:files?|archivos?|modules?|m[óo]dulos?)", + re.IGNORECASE, +) + + +def _max_lines_cap(text: str) -> int | None: + match = _FILE_LINE_CAP.search(text) + if match is None: + return None + cap = int(match.group(1) or match.group(2)) + return cap if 50 <= cap <= 5000 else None + + def _rules_from_claude_md(text: str, langs: list[str]) -> list[tuple[dict, str]]: """Best-effort mapping from prohibitions written in CLAUDE.md to executable rules becwright can actually enforce. Returns (rule_dict, matched_trigger) @@ -340,6 +359,15 @@ def _rules_from_claude_md(text: str, langs: list[str]) -> list[tuple[dict, str]] id=signal["id"], paths=paths, check=signal["check"], intent=signal["intent"], why=signal["why"], severity=signal["severity"], ), trigger)) + + cap = _max_lines_cap(text) + if cap is not None and source: + derived.append((dict( + id="max-file-lines", paths=source, severity="warning", + check=f"becwright run max_lines --max {cap}", + intent=f"Files should stay under {cap} lines.", + why="Large files are hard to read, review and keep cohesive."), + f"{cap} lines")) return derived diff --git a/tests/test_init.py b/tests/test_init.py index f12c990..5ad6194 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -298,6 +298,30 @@ def test_init_from_claude_md_no_signals_writes_nothing(tmp_path, monkeypatch, ca assert not (tmp_path / ".bec" / "rules.yaml").exists() +def test_max_lines_cap_extracts_file_cap(): + assert cli._max_lines_cap("Files are focused (< 800 lines).") == 800 + assert cli._max_lines_cap("Máximo 300 líneas por archivo.") == 300 + assert cli._max_lines_cap("800 lines per file max") == 800 + + +def test_max_lines_cap_ignores_function_length(): + # No file/module anchor -> not a file cap (function length needs an AST anyway). + assert cli._max_lines_cap("Functions must be under 50 lines.") is None + + +def test_max_lines_cap_out_of_range(): + assert cli._max_lines_cap("keep files under 40 lines") is None # below 50 + assert cli._max_lines_cap("files must not exceed 9000 lines") is None # above 5000 + + +def test_rules_from_claude_md_maps_file_line_cap(): + text = "Keep files under 800 lines. Never hardcode secrets." + by_id = {r["id"]: r for r, _ in cli._rules_from_claude_md(text, ["python"])} + assert "max-file-lines" in by_id + assert by_id["max-file-lines"]["check"] == "becwright run max_lines --max 800" + assert by_id["max-file-lines"]["severity"] == "warning" + + def test_init_from_claude_md_composes_with_baseline(tmp_path, monkeypatch): monkeypatch.setenv("PYTHONPATH", str(_SRC)) _init_repo(tmp_path)