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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
4 changes: 3 additions & 1 deletion documentation/usage.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion documentation/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions src/becwright/checks/conflict_markers.py
Original file line number Diff line number Diff line change
@@ -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())
32 changes: 31 additions & 1 deletion src/becwright/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
}


Expand Down Expand Up @@ -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)
Expand All @@ -347,19 +364,32 @@ 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"]]
if not paths:
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(
Expand Down
23 changes: 23 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 22 additions & 1 deletion tests/test_metric_naming_checks.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
Loading