diff --git a/CHANGELOG.md b/CHANGELOG.md index c9aab9b..ff50979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `conflict_markers` check: fails on leftover git merge conflict markers + (`<<<<<<<`, `>>>>>>>`, `|||||||`). +- `becwright init --from-claude-md` expands a broad "good practices" / "buenas + prácticas" phrase into the deterministic code-hygiene rule set (no secrets, + `eval`, debug leftovers, or conflict markers), language-aware and de-duplicated. + ## [0.3.0] — 2026-07-01 ### Added diff --git a/README.es.md b/README.es.md index 84d53b3..22786c7 100644 --- a/README.es.md +++ b/README.es.md @@ -306,6 +306,7 @@ atar cada regla a su *por qué*. | `hardcoded_secrets` | Claves AWS, claves privadas, `password = "..."` literales | cualquiera | `blocking` | | `debug_remnants` | `breakpoint()`, `pdb.set_trace()`, `import pdb` olvidados | Python | `blocking` | | `dangerous_eval` | Llamadas a `eval()` / `exec()` | cualquiera | `blocking` | +| `conflict_markers` | Marcadores de conflicto de merge olvidados (`<<<<<<<`) | cualquiera | `blocking` | | `wildcard_imports` | `from x import *` | Python | `warning` | ## Reglas listas para usar (sin escribir nada) diff --git a/README.md b/README.md index 570127d..4db2614 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ may miss exotic cases, and the real value is in tying each rule to its *why*. | `hardcoded_secrets` | AWS keys, private keys, `password = "..."` literals | any | `blocking` | | `debug_remnants` | Forgotten `breakpoint()`, `pdb.set_trace()`, `import pdb` | Python | `blocking` | | `dangerous_eval` | `eval()` / `exec()` calls | any | `blocking` | +| `conflict_markers` | Leftover git merge conflict markers (`<<<<<<<`) | any | `blocking` | | `wildcard_imports` | `from x import *` | Python | `warning` | Example rules to copy into your `.bec/rules.yaml`: diff --git a/documentation/usage.es.md b/documentation/usage.es.md index 52a897d..567f0bb 100644 --- a/documentation/usage.es.md +++ b/documentation/usage.es.md @@ -41,7 +41,9 @@ a mano: `becwright install` más un `.bec/rules.yaml` que escribas vos.) > `debugger`, `console.log`, breakpoints, imports con `*`, tokens en logs — y > 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 +> `max_lines`), ignorando reglas de largo de función que no puede enforzar. Una +> frase amplia como "seguir buenas prácticas" se expande al set determinista de +> higiene (sin secretos, `eval`, restos de debug ni marcadores de conflicto). 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 c82f94f..bcc57ce 100644 --- a/documentation/usage.md +++ b/documentation/usage.md @@ -39,7 +39,9 @@ From then on, every `git commit` runs the checks. (You can also set up by hand: > `console.log`, breakpoints, wildcard imports, tokens in logs — and turns each > 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, +> function-length rules it can't enforce. A broad phrase like "follow good +> practices" expands to the deterministic hygiene set (no secrets, `eval`, debug +> leftovers, or merge-conflict markers). 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. diff --git a/src/becwright/checks/conflict_markers.py b/src/becwright/checks/conflict_markers.py new file mode 100644 index 0000000..ba71f07 --- /dev/null +++ b/src/becwright/checks/conflict_markers.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import re +import sys + +from ._ignore import is_ignored + +# The angle/pipe markers a merge conflict leaves behind. `=======` is omitted on +# purpose: it also appears in legitimate text (e.g. a Markdown heading underline), +# and every real conflict already carries `<<<<<<<` and `>>>>>>>`, so it is caught. +PATTERN = re.compile(r"^(?:<{7}|>{7}|\|{7})(?:\s|$)") + + +def find_violations(paths: list[str]) -> list[tuple[str, int, str]]: + violations: list[tuple[str, int, str]] = [] + for path in paths: + path = path.strip() + if not path: + continue + try: + with open(path, encoding="utf-8") as f: + for lineno, line in enumerate(f, start=1): + if PATTERN.search(line) and not is_ignored(line): + violations.append((path, lineno, line.rstrip())) + except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError): + continue + return violations + + +def main() -> int: + violations = find_violations(sys.stdin.read().splitlines()) + for path, lineno, line in violations: + print(f" {path}:{lineno}") + print(f" > {line}") + return 1 if violations else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/becwright/cli.py b/src/becwright/cli.py index 4b60ef7..226dcb7 100644 --- a/src/becwright/cli.py +++ b/src/becwright/cli.py @@ -126,6 +126,7 @@ def _cmd_uninstall(_: argparse.Namespace) -> int: "max_lines": "fail if a file exceeds --max lines (any language)", "require": "fail if a regex (--pattern) is missing from the files (any language)", "filename": "fail on file names matching --forbid or not matching --require (any language)", + "conflict_markers": "leftover git merge conflict markers (any language)", } @@ -336,6 +337,22 @@ def _max_lines_cap(text: str) -> int | None: return cap if 50 <= cap <= 5000 else None +# "Good practices" / "clean commits" is too broad to map to one check, so it +# expands to the deterministic hygiene subset of the signals above (plus conflict +# markers). Opinionated or narrow signals (wildcard imports, tokens in logs) are +# left out — they only fire on their own explicit mention. +_HYGIENE_IDS = frozenset({ + "no-hardcoded-secrets", "no-dangerous-eval", "no-debug-remnants", + "no-debugger-js", "no-console-log-js", +}) +_GOOD_PRACTICES_TRIGGERS = ( + "good practices", "best practices", "buenas practicas", "buenas prácticas", + "mejores practicas", "mejores prácticas", "clean commit", "commit hygiene", +) +_CONFLICT_TRIGGERS = ("conflict marker", "merge conflict", + "marcador de conflicto", "conflicto de merge") + + 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) @@ -347,6 +364,7 @@ def _rules_from_claude_md(text: str, langs: list[str]) -> list[tuple[dict, str]] "python": ["**/*.py"] if "python" in langs else [], "jsts": [g for g in source if g.endswith((".js", ".ts"))], } + good = any(t in lowered for t in _GOOD_PRACTICES_TRIGGERS) derived: list[tuple[dict, str]] = [] for signal in _CLAUDE_SIGNALS: paths = paths_for[signal["lang"]] @@ -354,12 +372,24 @@ def _rules_from_claude_md(text: str, langs: list[str]) -> list[tuple[dict, str]] continue trigger = next((t for t in signal["triggers"] if t in lowered), None) if trigger is None: - continue + if good and signal["id"] in _HYGIENE_IDS: + trigger = "good practices" + else: + continue derived.append((dict( id=signal["id"], paths=paths, check=signal["check"], intent=signal["intent"], why=signal["why"], severity=signal["severity"], ), trigger)) + conflict_trigger = next((t for t in _CONFLICT_TRIGGERS if t in lowered), None) + if good or conflict_trigger: + derived.append((dict( + id="no-conflict-markers", paths=["**/*"], severity="blocking", + check="becwright run conflict_markers", + intent="Merge conflict markers must never be committed.", + why="A committed conflict marker (<<<<<<<) means unmerged code shipped."), + conflict_trigger or "good practices")) + cap = _max_lines_cap(text) if cap is not None and source: derived.append((dict( diff --git a/tests/test_init.py b/tests/test_init.py index 5ad6194..3bba224 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -322,6 +322,29 @@ def test_rules_from_claude_md_maps_file_line_cap(): assert by_id["max-file-lines"]["severity"] == "warning" +def test_rules_from_claude_md_good_practices_expands_hygiene(): + derived = cli._rules_from_claude_md("Always follow good practices.", ["python", "ts"]) + ids = {r["id"] for r, _ in derived} + assert {"no-hardcoded-secrets", "no-dangerous-eval", "no-debug-remnants", + "no-debugger-js", "no-console-log-js", "no-conflict-markers"} <= ids + # opinionated/narrow signals are not pulled in by the broad phrase + assert "no-wildcard-imports" not in ids and "no-token-in-logs" not in ids + + +def test_rules_from_claude_md_conflict_markers_on_explicit_mention(): + ids = {r["id"] for r, _ in cli._rules_from_claude_md( + "Never commit merge conflict markers.", ["python"])} + assert ids == {"no-conflict-markers"} + + +def test_rules_from_claude_md_good_practices_keeps_specific_trigger(): + # a specific mention wins the trigger label, and the rule is not duplicated + derived = cli._rules_from_claude_md( + "No hardcoded secrets. Follow good practices.", ["python"]) + secrets = [(r, t) for r, t in derived if r["id"] == "no-hardcoded-secrets"] + assert len(secrets) == 1 and secrets[0][1] == "hardcod" + + def test_init_from_claude_md_composes_with_baseline(tmp_path, monkeypatch): monkeypatch.setenv("PYTHONPATH", str(_SRC)) _init_repo(tmp_path) diff --git a/tests/test_metric_naming_checks.py b/tests/test_metric_naming_checks.py index f182b7c..37ddaeb 100644 --- a/tests/test_metric_naming_checks.py +++ b/tests/test_metric_naming_checks.py @@ -1,6 +1,6 @@ import io -from becwright.checks import filename, max_lines, require +from becwright.checks import conflict_markers, filename, max_lines, require def _write(tmp_path, name, text): @@ -98,3 +98,24 @@ def test_filename_main_flags_and_exits_one(tmp_path, monkeypatch, capsys): monkeypatch.setattr("sys.stdin", io.StringIO(bad + "\n")) assert filename.main(["--forbid", "tipos"]) == 1 assert "forbidden pattern" in capsys.readouterr().out + + +# --- conflict_markers --- + +def test_conflict_markers_flags_angle_markers(tmp_path): + f = _write(tmp_path, "a.py", "x = 1\n<<<<<<< HEAD\ny = 2\n>>>>>>> branch\n") + v = conflict_markers.find_violations([f]) + assert {ln for _p, ln, _l in v} == {2, 4} + + +def test_conflict_markers_ignores_markdown_underline(tmp_path): + # `=======` is a legit Markdown h1 underline; only angle/pipe markers are flagged. + f = _write(tmp_path, "r.md", "Title\n=======\nbody\n") + assert conflict_markers.find_violations([f]) == [] + + +def test_conflict_markers_main(tmp_path, monkeypatch, capsys): + f = _write(tmp_path, "a.py", "||||||| base\n") + monkeypatch.setattr("sys.stdin", io.StringIO(f + "\n")) + assert conflict_markers.main() == 1 + assert f in capsys.readouterr().out