Files
ersatztv/docs/decisions/records/ci/python-lint-ruff-config-committed.md
T
timothyandtimothy d4c72697f2
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m54s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 15s
feat(780): commit a ruff config and enforce it in CI (#813)
Python lint here was a property of the operator's laptop: the global instructions
say to run ruff, no workflow ran it, and with no committed config ruff fell back
to whichever ~/.config/ruff/ruff.toml the machine happened to have.

- ruff.toml at the root, pinned ruff==0.12.11 in the script-tests job.
- Both lint steps pass an EXPLICIT population from `git ls-files` with
  `--no-force-exclude`, never `ruff check .` — an `exclude` empties a
  discovery-based run into a GREEN one (top level empties both commands, [lint]
  empties check, [format] empties format --check), and `ruff check .` over zero
  files exits 0 with only a stderr warning. Guarded by an empty-population arm.
- Tree clean: 74 findings at 706674272, 57 fixed in code, 17 per-site noqa with
  reasons inline. S105 deliberately per-site, not a directory blanket. RUF100
  selected so a suppression that suppresses nothing is itself a finding.
- pyright stays ungated; reasoning in the record.

Both steps witnessed red on the runner against the shipped bodies: run 2173 job
9176 (ruff check) and run 2170 job 9163 (ruff format --check).

Docs: new record ci.python-lint-ruff-config-committed, ci.script-tests-job
cross-ref, docs/ci-cd.md (also correcting a stale ~190-tests/~10s figure to the
measured 773 tests / ~4.5 min), docs/defect-shapes-773.md §5.2 resolved.

fixes #780

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-22 00:33:18 +00:00

7.9 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ci.python-lint-ruff-config-committed 2026-08-21 — Python lint is a committed ruff.toml enforced in CI, not the operator machine's global config (#780) active 2026-08-21 none none The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. ruff · pyright · python lint · `ruff format --check` · lint passes on my machine but not yours · no repo lint config · S105 on a test stub credential · paths: `ruff.toml`, `.gitea/workflows/pr-checks.yml` · issues: #780, #773, #648, #512 Config at repo root; `.gitea/workflows/pr-checks.yml` -> `script-tests` pins `ruff==0.12.11` via pip and runs both commands ahead of the jq preflight and pytest. Population is `git ls-files -z '*.py' '*.pyi' '*.ipynb'` passed explicitly with `--no-force-exclude`, guarded by an empty-list arm; discovery-based invocation is defeated by an `exclude` in three config scopes, two of them per command. Bumping the pin is a deliberate PR because a new ruff release adds rules.

The global instructions tell every session to run ruff check, ruff format --check and pyright after touching Python. Before this, the repo enforced none of them and committed no config, so ruff fell back to whichever ~/.config/ruff/ruff.toml the operator's machine happened to have — a second machine lints this repo differently, or not at all. That is the same shape as #643/#647/#648 (a shell gate whose behaviour was a function of an untested interpreter version) and #512 (a test that passed on a fast laptop and flaked on a starved CI VM): the verdict was a property of the environment rather than of the repo.

The committed config is the operator's global one apart from per-file-ignores, which is narrowed to scripts/tests/**. That is what the tree was de-facto written against, so adopting it cost a mechanical reformat rather than a rewrite: 74 findings against 706674272, of which 57 were fixed in code (mostly by the format pass) and 17 carry a per-site # noqa with its reason inline. RUF100 is selected so those suppressions stay honest — a # noqa that suppresses nothing is otherwise invisible, and three were live the moment the rule was switched on: one whose rule had stopped firing, one for a rule this config never enables, and one added mid-branch on a site the same branch had already fixed in code.

One exemption is directory-wide, and it is the boring one. S101 for scripts/tests/**, because a test suite asserts. S105 is deliberately NOT directory-wide. All eight of its hits among those 74 findings are stub credentials handed to the real hooks (env["ETV_GITEA_TOKEN"] = "stub"), with no true positive in the tree today (a ninth # noqa: S105 predates this and sits on a commit-message marker in decisions_validate.py). A directory blanket would give up hardcoded-credential coverage over the largest Python surface in the repo, permanently, to suppress eight known lines — and this is the only Python lint the repo runs, so nothing else would catch a real token pasted into a fixture next year. Per-site # noqa: S105 costs the same and keeps the rule live.

The population comes from git ls-files, not from ruff's discovery, and that is the load-bearing part. ruff check . reports on what it discovers, and an exclude defeats discovery in three different config scopes — including [format], which is where an appended line lands by TOML rules (two of the three defeat each command). Measured with ruff 0.12.11 and exclude = ["scripts/**"], against a tracked file holding an unused import, a hardcoded credential and a formatting error. The pattern matters: exclude is matched per FILE, so a bare ["scripts"] works at the top level but matches nothing under [lint]/[format]. GREEN means the gate was silently off:

exclude in ruff check . explicit check ruff format --check . explicit format
top level GREEN red GREEN red
[lint] GREEN red red red
[format] red red GREEN red
top + force-exclude GREEN GREEN without --no-force-exclude, red with it GREEN same

Only the top-level scope empties both discovery commands; [lint] empties check, [format] empties format --check, so in those two the job would still redden on the other step. [format] is where a line appended to ruff.toml lands, by TOML rules. The last row is the whole reason for the flag.

include = [], extend-exclude and a nested scripts/ruff.toml were tried too, and are equally inert against the explicit form. The empty-list arm is the anti-vacuity check — ruff check . over no files exits 0 with a stderr warning, so an emptied population is a green gate, not a red one. Enumerating from git also covers tracked-but-gitignored files, which discovery skips (git add -f under an ignored path is established practice here).

The rule set is not covered, and that is a stated limit rather than an oversight. select = [] silences every selected rule, so the ruff check step goes green over any lint violation (a syntax error still reds) while still printing a reassuring file count. ruff format --check is unaffected, because formatting is not rule-selected. So the population arm makes an emptied file set loud, nothing makes an emptied rule set loud, and half the gate is killable by a config edit only a reviewer catches.

Both steps were witnessed red on the runner before merge, not argued to work — on the body that shipped: run 2173 job 9176 (❌ Failure - Main Lint scripts (ruff check) on an F401) and run 2170 job 9163 (❌ Failure - Main Lint scripts (ruff format --check)), printing Linting 34 tracked Python files and Format-checking 34 tracked Python files — the population arm executing (34 = the 33 tracked files plus the probe; the merged tree has 33). Each came from a temporary probe commit reverted before merge. Two probes are needed, not one: a check-dirty file stops the job before the format step ever runs. Earlier reds against the previous, discovery-based bodies were discarded rather than cited — a proof belongs to the code that ran, not to its predecessor.

Lint runs early in the job, ahead of the jq preflight. Preflight jq version is a hard --expect tripwire; a lint step behind it stops running for as long as the jq contract is broken, under a red that names jq. Ordering is the difference between a gate that is skipped and one that is not. The git half of Preflight external tools stays ahead of the lint steps, because they consume git: without it, a missing git arrives as an empty population and both steps report a population problem instead of the missing tool.

pyright is deliberately NOT gated. Its only findings here are reportMissingImports for etv_client in scripts/scripted-schedules/entrypoint.py, resolvable only inside that script's deploy environment. Gating it would put a node toolchain on the git-only small lane to find nothing. Revisit when this repo grows a typed Python surface — the reason is the cost/finding ratio today, not a judgement that type checking does not belong.

The pin is the second half of the fix. An unpinned pip install ruff re-introduces exactly the divergence the config closes, one layer up: the verdict becomes a function of when the job ran. Same argument as the jq pin in the same job (ci.jq-version-contract), and the same consequence — a bump is a PR someone reads. pytest and pyyaml in the same job stay unpinned, and the asymmetry is the point rather than an oversight: a pytest release does not add assertions to your suite, a ruff release adds rules to your lint.