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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion documentation/usage.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions documentation/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions src/becwright/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
import os
import pkgutil
import re
import sys
import urllib.error
import urllib.request
Expand Down Expand Up @@ -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)
Expand All @@ -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


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