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>
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.