From 79c5196932f713eb37be8fdf68c17934ca4c9cbf Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 13 Oct 2026 14:32:29 -0400 Subject: [PATCH] Add option to return non-zero on write, and Agent guidance --- AGENTS.md | 92 +++++++++++++++++++++++++++++++ CLAUDE.md | 3 + README.rst | 6 ++ codespell_lib/_codespell.py | 34 ++++++++---- codespell_lib/tests/test_basic.py | 8 +++ 5 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..ef2d8e60c0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# AGENTS.md + +This file provides guidance to AI coding agents when working with code in +this repository. + +## AI-assistance policy (read first) + +codespell is maintained by a small number of volunteers. Code is reviewed +and maintained by humans, so interactions about it should be between +humans, too. + +- **The human opening the PR is the sole author.** Do not add AI + co-authorship trailers (`Co-Authored-By:` for Claude / Copilot / Cursor / + any other tool) to commits, and do not use a bot as the commit author. + The author takes full responsibility for the contents and must be able to + explain and defend every line on request. +- **Do not write issues, PR descriptions, or comments for the human.** + Pasted AI-generated text makes it harder for reviewers to assess a + contribution. The human writes those in their own words, and discloses in + the PR description which AI tools were used and the scope of their + assistance. +- **Optimize for the reviewer and for long-term maintainability, not for + output volume.** Every line you add is read by a volunteer reviewer and + then maintained by humans in perpetuity. + +## Keep changes small + +A big diff is a cost, not an accomplishment. Aim for the smallest change +that delivers the user-visible behavior; anything else can be added later, +when something actually needs it. + +- **Sketch before you generate.** For anything beyond a local fix, + summarize the design in a few sentences for the human you are working + with and confirm it is the smallest one that works before writing code. +- **YAGNI.** Do not add options, config knobs, fallbacks, or "robustness" + the change does not need. Every new command-line option is documented, + tested, and supported forever, so it needs a demonstrated user need, not + a hypothetical one. +- **Reuse before writing something new.** Look for an existing helper in + `codespell_lib/_codespell.py` and build on it rather than adding a + near-duplicate. +- **Do not add dependencies** without asking first. +- **Add to an existing test before writing a new one.** Extend a test in + `codespell_lib/tests/test_basic.py` that already sets up what you need, + then add a new test function there, and only then consider a new file. A + few compact assertions beat a long test that re-creates setup. +- **For a bug fix, confirm the test fails without the fix and passes with + it.** A regression test that passes without the fix guards nothing. + +## Commands + +```bash +pip install -e . --group dev # editable install with test/lint deps +pre-commit install --install-hooks # ruff, mypy, codespell, file sorting, ... +pytest codespell_lib/tests/test_basic.py -k write_changes # CLI tests +make check-dictionaries # sort + validate dictionaries (test_dictionary.py) +pre-commit run --all-files # all linters, auto-fixing where possible +make check # everything CI checks: dictionaries, build, pytest, ruff +``` + +## Project layout + +Pure Python (>= 3.9), no runtime dependencies. + +- `codespell_lib/_codespell.py`: the CLI (`main()`), option parsing, and + file checking/fixing. +- `codespell_lib/data/dictionary*.txt`: the misspelling dictionaries, one + per `--builtin` set. +- `codespell_lib/tests/test_basic.py`: CLI behavior tests, mostly calling + `cs.main(...)` on files in `tmp_path`. +- `codespell_lib/tests/test_dictionary.py`: dictionary format and + consistency checks. + +Dictionary entries are `typo->fix`; with more than one fix, end the line +with a comma so none is applied automatically: + +```text +calulated->calculated +fiel->feel, field, file, phial, +``` + +See "Dictionary format" in `README.rst` for the full rules. + +## Boundaries + +- **Always:** run the relevant tests and + `pre-commit run --files ` before handing work back. +- **Ask first:** new command-line options, new dependencies, and changes to + CI (`.github/`), `pyproject.toml`, or `.pre-commit-config.yaml`. +- **Never:** hand-edit `dictionary_en_to_en-OX_AUTOGENERATED.txt` (it is + generated from `dictionary_en-GB_to_en-US.txt` by `make`), delete or + weaken a failing test to make it pass, or commit secrets. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..b465f32d5e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +See @AGENTS.md for guidance on working with this repository. diff --git a/README.rst b/README.rst index 420e1331f7..5d0f288fcc 100644 --- a/README.rst +++ b/README.rst @@ -57,6 +57,12 @@ Some noteworthy flags: The ``-w`` flag will actually implement the changes recommended by codespell. Running without the ``-w`` flag is the same as doing a dry run. It is recommended to run this with the ``-i`` or ``--interactive`` flag. +.. code-block:: sh + + codespell -w --exit-non-zero-on-fix + +By default, ``-w`` exits with status 0 when every misspelling it finds was fixed. The ``--exit-non-zero-on-fix`` flag makes codespell exit with a non-zero status whenever it writes changes, which is useful in CI so that a run that modified files is reported as a failure. + .. code-block:: sh codespell -I FILE, --ignore-words=FILE diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index 291d281a46..b46eec40e6 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -449,6 +449,14 @@ def convert_arg_line_to_args(self, arg_line: str) -> list[str]: default=False, help="write changes in place if possible", ) + parser.add_argument( + "--exit-non-zero-on-fix", + action="store_true", + default=False, + help="exit with a non-zero status if any changes were written, " + "even if every misspelling was fixed. Has no effect without " + "-w/--write-changes or -i/--interactive.", + ) parser.add_argument( "-D", @@ -1200,7 +1208,7 @@ def parse_file( uri_ignore_words: set[str], context: Optional[tuple[int, int]], options: argparse.Namespace, -) -> int: +) -> tuple[int, bool]: bad_count = 0 fragments = None @@ -1243,24 +1251,24 @@ def parse_file( # ignore irregular files if not os.path.isfile(filename): - return bad_count + return bad_count, False try: text = is_text_file(filename) except PermissionError as e: print(f"WARNING: {e.strerror}: {filename}", file=sys.stderr) - return bad_count + return bad_count, False except OSError: - return bad_count + return bad_count, False if not text: if not options.quiet_level & QuietLevels.BINARY_FILE: print(f"WARNING: Binary file: {filename}", file=sys.stderr) - return bad_count + return bad_count, False try: fragments, encoding = file_opener.open(filename) except OSError: - return bad_count + return bad_count, False # Parse lines. changed = False @@ -1317,7 +1325,7 @@ def parse_file( ) _write_file_atomically(filename, encoding, fragments) - return bad_count + return bad_count, changed def _write_file_atomically( @@ -1569,6 +1577,7 @@ def main(*args: str) -> int: ) bad_count = 0 + any_changed = False for filename in sorted(options.files): # ignore hidden files if is_hidden(filename, options.check_hidden): @@ -1590,7 +1599,7 @@ def main(*args: str) -> int: fname = os.path.join(root, file_) if glob_match.match(fname): # skip paths continue - bad_count += parse_file( + bad_count_update, changed = parse_file( fname, colors, summary, @@ -1605,6 +1614,8 @@ def main(*args: str) -> int: context, options, ) + bad_count += bad_count_update + any_changed = any_changed or changed # skip (relative) directories dirs[:] = [ @@ -1615,7 +1626,7 @@ def main(*args: str) -> int: ] elif not glob_match.match(filename): # skip files - bad_count += parse_file( + bad_count_update, changed = parse_file( filename, colors, summary, @@ -1630,10 +1641,13 @@ def main(*args: str) -> int: context, options, ) + bad_count += bad_count_update + any_changed = any_changed or changed if summary: print("\n-------8<-------\nSUMMARY:") print(summary) if options.count: print(bad_count, file=sys.stderr) - return EX_DATAERR if bad_count else EX_OK + failed = bad_count or (any_changed and options.exit_non_zero_on_fix) + return EX_DATAERR if failed else EX_OK diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index 796e869c08..1b03896d24 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -249,6 +249,14 @@ def test_write_changes_lists_changes( corrected = fname.read_text() assert corrected == "This is abandoned\nAnd this is occurred\nAlso the typo\n" + # --exit-non-zero-on-fix fails only when changes were written + fname.write_text("abandonned\n") + assert cs_.main("-w", str(fname)) == EX_OK + fname.write_text("abandonned\n") + assert cs_.main("-w", "--exit-non-zero-on-fix", str(fname)) == EX_DATAERR + assert fname.read_text() == "abandoned\n" + assert cs_.main("-w", "--exit-non-zero-on-fix", str(fname)) == EX_OK + def test_default_word_parsing( tmp_path: Path,