2026-08-05 Every Ruff Ignore Was a Claim Nobody Had Checked

Context: python-project-template (github.com/achhina/python-project-template), staged as v1.9.0. Started as a question about lines 119-141 of template/pyproject.toml.jinja: what is the reason for each of these ignores? Nine entries, two comments between them, all inherited from the initial commit e64a551.

What I set out to do

Explain each ignore. Then, when asked, go find what the upstream maintainers actually concluded about each rule rather than reciting what the rule does.

What I actually did

Researched each code against ruff, pylint, bandit and Sphinx issue trackers, then acted on what came back. The ignores sorted into four very different piles, and only one pile matched the confidence the config projected.

Settled and correct. S101 in tests. Bandit ships an assert_used.skips config for test globs; ruff’s #4368 has been open since 2023 with maintainers pointing everyone at per-file-ignores. Kept, now with the citation inline.

Upstream’s own default agrees. PLR2004 is not a default pylint check at all: it lives in the optional pylint.extensions.magic_value plugin. Ruff’s PL prefix pulls it in wholesale, so ruff is stricter here than pylint has ever been by default. The global ignore restores upstream’s posture, which is a much better justification than the one nobody had written down.

Contested, and the config was on the wrong side. S105/S106 in tests. charliermarsh, zanieb and AlexWaygood all say on record that security rules should prefer false positives to false negatives, and #14365 has a user pointing out that test suites are where a developer is most likely to leave real credentials. Removed.

Justified by a claim that was simply false. The docs/conf.py entry said “Sphinx requires copyright (a Python builtin) as a config variable.” Sphinx added project_copyright as an alias in 3.5, specifically to avoid the builtin shadow. The template pins sphinx>=7. One rename deleted the ignore.

And one entry, PLR2004 under tests/**, was dead: already in the global ignore, so it could never do anything. It had been there since the initial commit.

Then the question turned into work

Replaced the PLR0913 ignore with max-args = 8, so there is still a ceiling. Verified with --show-settings and found the trap: max-positional-args defaults to the value of max-args, so raising one silently raised PLR0917 too. Pinned it back to 5.

Asked whether D/ANN were on. Enumerated the fully-resolved linter.rules.enabled block: across all of D, ANN and DOC, exactly one rule was active, D419, and it came from ruff’s defaults rather than from the config. Meanwhile docs/conf.py has set napoleon_numpy_docstring = True the whole time. Generated projects declared a docstring convention and enforced nothing about it.

Added D with convention = "numpy", which surfaced the second trap: the numpy convention disables D417, the only rule that checks a docstring’s parameter list against the real signature. Ruff’s pydoclint port has DOC102 (documented but absent) and has never implemented DOC101 (present but undocumented, #12434). So enabling numpy docstrings removed the only param check, and adding DOC would not have restored it. Re-selected D417 explicitly and verified it parses numpy Parameters sections; it does, and notably does not fire on the same docstring under the google convention.

Then added DOC (all seven rules, all preview) with ignore-one-line-docstrings pinned to false and the reasoning written above it, since a one-liner on a function that raises undocumented is exactly the drift the rules exist for. Probed both settings to confirm the difference is real.

ARG001/ARG002 came out of the test ignores too, but not deleted: left commented out in place with twenty lines explaining why they are not ignored, so the next reader who reaches for them sees the argument first. The case that decided it: unused arguments in test helpers are real defects, not protocol noise. A factory like make_user(name, email, active=True) that never reads active makes every active=False test pass for the wrong reason, and a hand-written fake whose method silently drops an argument diverges from the real implementation while the suite stays green. That failure mode is specific to a fake-based, non-mock test suite, which is what this template’s conventions push toward.

Then the same question, one level up

Having audited the ignores, the obvious next question was which whole rule families were off. Answered it by diffing the resolved linter.rules.enabled set against every rule ruff ships rather than reading prefixes off the config: 800 of 968 enabled, twenty linters at zero.

The twenty sorted cleanly into three groups, and only one of them was a real decision. Five were library-specific and inert without the library (AIR, PD, DJ, NPY, FAST). Three were owned by the formatter (Q, COM, ISC001) and would have fought ruff-format if enabled. The remaining twelve were genuine unmade decisions, headed by ANN, which mattered because this template ships py.typed and renders with sphinx-autodoc-typehints while ty only infers annotations. def f(a): return a passed the entire config clean.

Enabled seventeen prefixes (ANN, PGH, TRY, G, LOG, TC, T20, SLF, INP, RSE, SLOT, ICN, C90, FBT, EM, plus every library-specific set: PD, NPY, FAST, AIR, DJ), taking it to 883. Two findings on a default generation, and the more interesting one was the choice of fix.

ANN001 on the pytest-html hook: it now declares a Protocol for the one attribute it sets, rather than reaching for Any and trading the finding for ANN401.

INP001 on docs/conf.py: this one has two available fixes and the rule’s own documented option is the worse of them. namespace-packages = ["docs"] is what the rule’s Options section points at, and it is roughly as popular in the wild as the alternative (436 vs 384 pyproject.toml hits on GitHub code search). It is still wrong here on two counts: it asserts docs/ is a namespace package, which is no truer than it being a regular package, and namespace-packages is documented as changing module resolution for the whole tree, so it perturbs every rule that resolves modules (isort first-party classification included) to silence one. The per-file-ignore says the narrow true thing, and charliermarsh recommends exactly that for the equivalent tests/ report in #6474.

Verified across all seven ctt parametrizations plus a full generation: ruff, ty, pytest, and sphinx-build -W all green, the built HTML still renders Copyright 2026, Adam Chhina through the alias, and the pytest-html report still carries its title through the Protocol-annotated hook.

What was striking

  • Every one of the nine entries was a claim, and only one carried its evidence. Two comments existed; one of them was wrong. The other seven entries were bare codes. A bare code in an ignore list asserts “this rule is not worth it here” with no way to tell an informed decision from a copy-paste, and copy-paste is what S108 turned out to be: no upstream discussion argues for a test exemption, and the template’s own tests never touch /tmp.
  • This is the same finding as 2026-08-04, one repo over. That day’s lesson was that a per-file-ignore list is where a constraint and a defect become indistinguishable, and that ruff will warn about an unused noqa but never about an unused per-file-ignores entry. Today the dead PLR2004 entry proved it again at the template level, which means it has been propagating into every generated project for the life of the template.
  • The most useful research was upstream’s default, not upstream’s opinion. Learning that pylint ships magic-value-comparison as an opt-in extension settled that ignore in one fetch, and it is a stronger argument than any thread of user preferences. Where a rule sits in its origin linter’s default set is a compressed record of what its own maintainers think it is worth.
  • Two settings widened silently when I changed a third. max-positional-args inheriting max-args, and the numpy convention switching off D417. Both are documented, neither is visible at the call site, and both moved enforcement in the opposite direction to what the change was for. --show-settings on the resolved config caught both; reading the TOML never would have. The same tool answered the same class of question on 2026-07-26.
  • Enabling a convention is a subtractive operation. I reached for convention = "numpy" as a way to add checking and it removed a rule. Conventions in ruff are filters over the D set, so the honest mental model is “select D, then subtract the ones this style disagrees with,” and it is worth reading the subtraction list rather than trusting that a stricter-sounding config is stricter.
  • A gap can survive two rounds of adding rules. Missing-parameter checking is unavailable under numpy through D (convention disables D417) and unavailable through DOC (no DOC101). Enabling both prefixes without noticing would have produced a config that looks maximally strict about docstrings and cannot catch the single most common docstring bug.
  • Commented-out config is a legitimate artifact when the reasoning is the deliverable. The ARG block carries more prose than the entire rest of the ignore list. That is the right ratio: the entries that are absent for a reason are exactly the ones a future reader (or agent) will otherwise re-add, and a deleted line leaves no trace of the argument.
  • A rule’s own ## Options section is a list of knobs, not a recommendation. INP001 points at namespace-packages, which suppresses it by redefining what the directory is for module resolution everywhere. The narrower fix, a per-file-ignore, is not mentioned by the rule at all and is what the maintainer recommends in the issue tracker. Config options exist to be reachable, not because they are the right answer to the diagnostic that names them.
  • An extend-select list documents what is on and says nothing about what is missing. Twenty whole linters were at zero and the config gave no hint, because absence has no syntax. The only way to see it was to enumerate every rule ruff ships and subtract. That is the same move as clearing the per-file-ignore table to see which entries still fire: both replace “what does the config say” with “what does the tool actually do”.
  • I wrote “PLR0917 still caps positional args at 5” in one turn and had to correct it in the next after actually running --show-settings. The claim was reasonable, the rules are separate, and it was still wrong because of a default I had not checked. Same shape as the prose-that-reads-like-a-finding pattern from yesterday, caught one turn later this time because the verification step existed.

The downstream pass (continued 2026-08-06)

All eight projects in nix/projects/ are on v1.9.0, one commit each on chore/template-v1.9.0-update, every one green on ruff, ty and its full suite (25, 22, 33, 17, 14, 62, 140, 493 tests). 1,744 findings fixed rather than suppressed, which was the explicit call when the aggregate was on the table.

The ARG_MAX bug did not need copier patched. ~/.config carries 92,558 gitignored entries and copier adds one --exclude argv per file, so copier update dies exactly as CLAUDE.md documents. Instead of patching _apply_update, I git archive each project into a standalone temp repo, update there where the ignore list is tiny, and copy the changed files back. The merge still happens against real content, and nothing about the tool is modified. Worth preferring over the documented workaround.

Five projects conflicted, all in the same place: they had added entries to the exact ignore lists v1.9.0 rewrote. Copier left clean diff3 markers; resolution was mechanical once the intent was clear (keep the project’s own PLC2701, drop their PLR0913/PLR0917 in favour of max-args = 8).

The same finding, twice, in two codebases: parse-history and acl-mcp-server each had an Any parameter wrapping an untyped third-party record, and in both the obvious fix (annotate with the library’s concrete class) was wrong for the same reason: the tests pass structural doubles, and a concrete class rejects them. ty caught it both times, four call sites in acl. They resolved oppositely, and the difference is the real lesson: a Protocol works when the code reads one universal attribute and guards the rest (parse-history’s AST walker), and cannot work when the function’s whole job is to degrade on missing attributes (acl’s _paper_to_model), because the Protocol would have to declare the very attributes the fallback exists to survive.

One transform paid for the whole pass: hoisting exception messages to a local. claude-skill-sync had 34 TRY003 plus 36 EM101/EM102, and ruff check --fix --select EM101,EM102 cleared all seventy in one run, because moving the literal off the raise site satisfies TRY003 too. The alternative reading, that TRY003 demands a custom exception class per message, would have meant inventing 34 classes in a project that already had six good ones.

What the tooling got wrong, and what that cost

  • ruff check --fix --select RUF100 stripped legitimate suppressions. In a mid-edit state ruff reported a dozen # ruff: ignore[...] comments as unused; removing them made the underlying rules fire again immediately. Restoring them from git show HEAD:<file> was straightforward, but the lesson is sharper: never run an unused-suppression fixer while the file is half-edited, because “unused” is computed against the broken state.
  • --fix silently declines some fixes in a whole-project run that it applies happily per-rule. TC003 and W293 sat there across repeated --fix passes and went through instantly under --select TC003. Worth reaching for a scoped select when a [*] fixable finding refuses to move.
  • My own generator inherited the tests’ typos. Docstrings derived from test names carried unparseable through from test_unparseable_returned_whole, and a de-pluralising heuristic turned Entries into Entrie. codespell in the commit hook caught both. A name is not prose, and turning one into the other inherits every mistake in it.

What the pass found in the template

template/tests/__init__.py contains {{ project_slug }} and had no .jinja suffix, so with _templates_suffix: .jinja copier copied it verbatim. Every project ever generated from this template shipped a literal """Tests for {{ project_slug }}.""". Shipped as v1.9.1 (PR #15).

That is the best argument for the whole 1.9.0 change in one artifact: the bug was visible for the life of the template and nothing looked at it, because no rule did. Enabling D gave the docstring a reader.

Landing it (2026-08-06)

Merged chore/template-v1.9.0-update into ~/.config main as e79a801 and pushed, then took all eight to v1.9.1 in 327325f.

The nix packaging was the verification that mattered, and I nearly skipped it. All eight projects are built by nix/lib/uv-projects.nix and each has a <project>-tests flake check, so the real gate was not the uv run pytest I had already run in each working tree but nix build .#checks.aarch64-darwin.<p>-tests, which builds the package and runs its suite in a sandbox. Seven passed. The eighth, check-upstream-issues, failed on two integration tests calling rg, which is absent from the sandbox PATH — and fails identically on main, so it is pre-existing, not a regression. Building the same check from the pre-merge ref was the whole diagnosis and took one command. The homeConfigurations.aarch64-darwin.activationPackage also builds, so the next hm switch is safe.

My own status report was wrong and the vault repeated it. I recorded that the projects “each carry the tests/__init__.py placeholder fix applied by hand”. Checking the actual files before the v1.9.1 update: only three of eight did. Five still shipped the literal """Tests for {{ project_slug }}.""". The claim was plausible because I had applied the fix by hand, just not everywhere, and nothing in the pass re-read the files to confirm. One head -1 across the eight settled it. The pattern to distrust is a summary written from what I intended to do rather than from what the files say — the same shape as the PLR0917 correction above, but this one survived into the note.

v1.9.1 went through the archive trick again and confirmed it is exact. The template delta is a pure rename (template/tests/__init__.py.jinja), so copier update --vcs-ref v1.9.1 in each isolated repo changed exactly two files in the five stale projects and one in the three already-fixed ones. Nothing else moved, which is the strongest evidence yet that the archive-to-temp-repo approach produces the same result as a native update. The one wrinkle is cosmetic and recurring: copier rewrites .copier-answers.yml in its own YAML style (single quotes, unindented sequences) and the repo’s prettier/yamlfmt hooks disagree, so the answers file needs a prettier pass before git add — and for a long project_description, prettier wants to wrap where yamlfmt does not, so that one line stays collapsed by hand.

The red check was an undeclared dependency, not a test problem

Fixed in c23399f. The obvious fix was adding pkgs.ripgrep to the check’s nativeBuildInputs, and it would have turned the check green while leaving the actual bug in place: check-upstream-issues calls rg by bare name and the nix package never declared it. The shipped binary was a bare symlink into the venv, so it worked only because programs.ripgrep puts rg on the interactive PATH. Anyone running that binary in a context without it would get the same FileNotFoundError the sandbox got.

So the fix went to the root: mkProject gains a runtimeInputs option, taken as a function of the package set (p: [ p.ripgrep ]) so the registry in uv-projects.nix stays system-independent. Entry points get a makeWrapper PATH prefix when a project declares any, projects that declare none keep the cheap symlink, and the list reaches the pytest check through passthru. One declaration now covers the shipped binary and its test.

That shape was already the stated philosophy of both files — uv-projects.nix opens by explaining that it exists because the project list used to be restated in two places, and tests/uv-projects.nix says the same about the test environment. A runtime dependency declared in the check but not the package would have been a third instance of exactly that drift, and it would have been invisible in the same way: green check, broken binary.

nix flake check --system aarch64-darwin now passes fully.

The sandbox is the only honest PATH. The interactive shell had been silently supplying this dependency for as long as the tool has existed. A build sandbox is not an obstacle to work around; it is the one place the dependency graph gets audited, and a check that fails there is more often right than the config that passes everywhere else.

And the green check was still not proof

Ran hm switch (generation 2195), then tested the shipped binary the only way that means anything: env -i with rg absent from PATH. It got past rg and died on gh — a second undeclared dependency, in the same tool, that the now-green test check does not catch because no test exercises the GitHub path.

So the passing check had been reassuring about exactly the wrong thing. It proved the tests’ dependencies were declared, not the program’s. Those are different sets, and the gap between them is invisible from inside the test suite by construction.

An AST sweep of every project’s src/ for argv-shaped list literals found the same gap once more: gh-review-preview shells out to both gh and git, neither declared. The first version of that scan followed only direct subprocess.* calls and missed gh-review-preview’s git usage entirely, because it routes through a local _run() wrapper — so the scan had to widen to any argv-shaped literal regardless of what consumes it, over-reporting and reading the list by hand. Three false positives (a click Choice of shell types, a tuple of data directory names), which is a fine trade for not missing a real one.

claude-ops is the instructive exception. It calls claude and node and I nearly declared both. It shouldn’t: it invokes them as the subject of a health check, reporting whether they are installed and at what version. Pinning them would make the check report this closure’s versions and pass unconditionally — the check would keep working while measuring nothing. The distinction is whether a program uses a binary (declare it, pin it) or inspects the environment for one (must not). Same subprocess.run(["node", "--version"]), opposite correct answers, and only the surrounding intent tells them apart.

Final state after generation 2196: check-upstream-issues runs a complete scan under env -i with a bare /usr/bin:/bin PATH, finding all 17 open issues — ripgrep for the tag scan, gh for the status query, neither on the ambient PATH.

Sweeping the rest, and the third instance

The src/ sweep was the narrow version. Widening it to the 46 wrapped shell scripts in the activation closure found the same bug twice more.

The technique that worked: read each built wrapper’s own export PATH= line rather than parsing Nix. That is exact — it is the set the script is actually guaranteed. My first pass then drowned in noise, because a regex for “words in command position” happily reports prose from comments and fragments of embedded Lua, jq and awk. The filter that rescued it: a candidate only counts if it is a real executable somewhere on this machine. the, would, logging are not binaries; cat, ps, curl are. That one intersection took the report from ~300 lines of garbage to about 20 real names.

Seven agent hooks called coreutils without declaring it. neovim-open-file, neovim-cwd-sync, neovim-session-binder, knowledge-capture-nudge, agy-stop-state, send-notification and prevent-permission-prompts use cat, head, mktemp, rm, chmod, mkdir, cut, tr, wc while declaring only jq/curl/ripgrep/git/nvim. They were silently taking BSD coreutils from /usr/bin; on the Linux host these dotfiles also target they would get GNU. That it was an oversight rather than a decision is provable: send-paste-buffer-to-claude, in a different file, already declared coreutils.

And the third instance was in the template itself. Each generated project’s flake.nix devShell declared uv, just, python — while its justfile shells out to git (32 sites) and gh (8, in just release). mkShell prepends rather than replaces, so the shell only appeared self-contained. Shipped as v1.9.2 (PR #17) and propagated to all eight projects.

rm was deliberately left undeclared there, and the reasoning is the interesting part: it is POSIX and present anywhere this can run, and adding coreutils to a devShell would shadow the developer’s ls/cp/mv for the whole session. The same dependency is worth declaring for a single wrapped script (where the PATH prefix is scoped to that script) and not worth declaring for an interactive shell. Scope of the PATH change, not just the strength of the dependency, decides it.

ps stays undeclared in the tmux status scripts for the claude-ops reason, now written at the declaration site so the next sweep does not “fix” it.

Working the 17 open upstream issues

All 17 verified genuinely open — none stale-closed, so the scanner is not reporting ghosts. Zero workarounds turned out to be droppable, which is a real answer rather than a null one: five had merged PRs cross-referencing them, and checking each is what turned “maybe fixed” into “no”.

  • Four of those five were cross-references from unrelated third-party repos — someone else’s chore: commit mentioning the issue number. Timeline cross-references are not evidence of a fix, and reading them as such is the obvious way to wrongly drop a workaround.
  • The fifth was real and still not enough: nix-darwin #1396 “Application ‘linking’ done right” genuinely merged, and our comment names #1205 as the out-of-store-file prerequisite. But #1396 copies .app bundles into /Applications; it is not the general “link a file anywhere” facility, #1205 is still open, and home-manager (which is what actually affects us) has no equivalent.
  • nixpkgs#335148 was the only one I could settle by experiment rather than reading: built current mermaid-cli (11.16.0) and ran it without PUPPETEER_EXECUTABLE_PATH. Still fails. The wrapper still earns its place, and that took one command versus any amount of issue-thread archaeology.
  • prek#1895 had a comment claiming it was already solved by #1765’s cooldown_days. It isn’t, and we are the proof: we already use cooldown_days. #1895 asks for cooldown on additional_dependencies, the transitive packages, which is what our annotation already says precisely. A drive-by “I think this was solved by…” is exactly the kind of thing that gets a workaround deleted.

The scanner has a structural blind spot. Several of these are blocked on pull requests, not issues, and it only watches /issues/ URLs. The litellm block already carries a hand-written note about #25309 for that reason — a comment doing a tool’s job. #25309 is still open and 2.5 months stale; litellm sits at 1.89.0 with no merged fixes for any of the three tagged issues.

Mistakes this session

  • git add -A committed a stub uv.lock into the v1.9.2 PR. uv run ctt writes one as a side effect, and this repo is a template whose pyproject.toml is nothing but a ty config, so the lock pinned literally nothing. Removed in #18 with a root-anchored /uv.lock ignore so it cannot recur.
  • I wrote a false claim into that PR’s own test plan, asserting a tracked template/uv.lock.jinja the ignore was carefully scoped around. There is no such file: generated projects get their lock from the uv sync --group dev post-generation task. Caught by actually running git ls-files, after writing the claim. Same failure as the “applied by hand” summary earlier: a plausible sentence about the repo, written without asking the repo.
  • rg -rn 'cooldown' made me think I had corrupted a config. -r is replace, so ripgrep dutifully rewrote its own output, turning “supply-chain cooldown” into “supply-chain n” and cooldown_days into n_days. The file was fine. A garbled tool output looks exactly like garbled data, and the first move should be re-running the command differently, not opening an investigation.

Still open

  • Shipped and landed. PR #14 merged after all six checks passed (four build matrix legs, the ctt render, and the copier update migration test), and release.yml cut the v1.9.0 tag and release from the CHANGELOG section. Second clean run of the automated release path since v1.8.1. All eight downstream projects are now on main in ~/.config at _commit: v1.9.1.
  • Switched. Generation 2197; nix flake check --system aarch64-darwin green, all eight projects live at v1.9.2.
  • The sweep still has a hole: it covers wrapped scripts and project src/. Home-manager’s own generated activation script invokes curl, docker, atuin, tmux and claude from user activation snippets, which run with whatever PATH the switch inherits. Not audited; the same bug class applies.
  • check-upstream-issues cannot watch pull requests, only /issues/ URLs. Several tracked items are actually blocked on PRs (litellm#25309 most concretely), which is why that block carries a hand-written re-check note instead of a machine tag. Teaching the scanner /pull/ URLs would remove the one place a comment is doing the tool’s job.
  • uv run pytest is broken in acl-mcp-server, pre-existing and unrelated: Failed to spawn: pytest although .venv/bin/pytest exists and uv run python -m pytest runs all 17. just test presumably hits the same path. Note this does not affect the nix check, which passes.
  • The four C901 suppressions in claude-ops are the one place this pass chose a documented waiver over a refactor. They are dispatch tables whose branch count is the contract, PLR0912/PLR0915 stay active on them, and each carries its reason. Worth revisiting if any of them grows.
  • D103 now requires a docstring on every test function in downstream projects. The template’s own tests have them so a fresh generation is clean, but copier update will surface this across ten projects. Left strict deliberately, with the "tests/**/*.py" = ["D100", "D103"] opt-out documented in the Upgrading section rather than decided unilaterally.
  • DOC201 fires on pytest fixtures that return a value (#13143, closed as won’t-special-case). Not hit by the template’s own fixtures, will be hit downstream. Opt-out documented, not applied.
  • ANN is now on, including ANN401. Whether ANN401 survives contact with ten downstream codebases is the open part; it is the one rule in the set that regularly has a correct answer of “yes, genuinely Any”.
  • TC001-TC003 are on with pydantic, attrs and SQLAlchemy pre-declared as runtime-evaluated. FastAPI has no equivalent hook, so any downstream FastAPI project will need # noqa: TC002 on the imports its route signatures annotate with. Nothing exercises this yet.
  • Left off deliberately and worth revisiting only with a reason: AIR/DJ (library-specific, unlikely here), TD/FIX (TODO hygiene, high churn for low signal), CPY001 (inert without configuration).