Lint and type-check orchestration¶
The tox style env is the sole authoritative lint suite; prek is a fast gate, not a second suite¶
Id: 9b74fc8b-a808-4f2b-bdbd-9e4253c1b35a
Type: decision
Status: active
Evidence: confirmed
Source: ADR-003 (docs/adr/003-tox-as-canonical-lint-runner.md), including its 2026-06 update
Revisit when: a second orchestrator is proposed, or tool versions gain a second pinning location
Tool versions live in the uv-managed style dependency group and the tox
style env runs the full lint/type-check suite from it. Git hooks are always
included (the include_precommit toggle was removed) and run via prek as a
fast local and CI gate; Renovate keeps both the prek rev pins and the style
group current. Pants and Trunk, once default-on orchestrators of the same
linters, were first demoted to opt-ins and later removed entirely.
Reason: a default project shipped three orchestrators (tox, Pants, Trunk)
pinning ruff, mypy, actionlint and others in three places with three
update mechanisms, and the pins had already drifted — a contributor could pass
the local suite and fail CI on a rule that changed between two pinned versions.
One source of truth makes drift impossible, keeps linting on the uv/tox spine
the generated project already uses, and leaves one updater. The prek overlap is
deliberate: fast feedback while editing, authoritative coverage in tox; the
cost is duplicate execution and at most a brief window between two Renovate
PRs, not sustained drift.
Rejected alternative: keep Pants and Trunk as opt-ins — the intermediate
state this ADR first established; its 2026-06 update dropped them outright so
that the tox style env is the sole orchestrator and nothing can drift against
it.
Consequence: MegaLinter, when enabled, is scoped so it cannot drift against the Python toolchain (see its own entry below).
Taplo checks do not fetch schemas from generated TOML directives¶
Id: 152edfc1-817e-4265-ac23-53d2a730cd5f Type: workaround Type: decision Status: active Evidence: confirmed Source: issue #337, PR #338; commit 9e86cb8 Verification: corroborated — generated root TOML omits schema directives while the style environment retains Taplo syntax and formatting checks Revisit when: Taplo provides a verified way to retain editor directives without fetching their schemas during checks See: lint-orchestration.md#semgrep-runs-in-the-style-env-against-a-named-registry-ruleset-never-vendored — 5068081c-24ce-4c39-9cdf-13e0a7c229b4 — as of 2026-10-11
Generated root TOML no longer carries the remote schema directive. Editors can use SchemaStore's filename mapping, while Taplo still checks syntax and canonical formatting without that schema fetch.
Reason: a transient remote schema failure could fail the required style gate on unchanged source. The investigation found that Taplo 0.9.3 still fetched an explicit directive's URL with its no-schema flag, so the apparent offline switch did not solve this failure mode.
Rejected alternative: retain the directive and rely on the no-schema flag. It was tested and rejected because the fetch still occurred. Vendoring a schema was discussed with a maintenance cost, but the reviewed sources do not establish an explicit verdict on that alternative.
This constraint applies to the Taplo pass, not to every style tool: the semgrep entry below separately accepts a registry dependency.
The prek gate runs at commit; push-only hooks opt in per hook¶
Id: 8fd817b4-fab6-4ce1-b80d-59a2ea93016f
Type: decision
Status: active
Evidence: confirmed
Source: found while adopting the template in openapipages; reproduced with prek 0.5.5; fixed in PR #379 and recorded in ADR-003 (docs/adr/003-tox-as-canonical-lint-runner.md, the last revision that carries this passage)
Verification: corroborated — a hook with no stages ran on pre-commit, pre-push, commit-msg and manual when default_stages was unset
Revisit when: a third push-only hook is added, or pip-audit's push-time cost or false-positive rate outweighs its earlier feedback
default_stages is ["pre-commit"]. Hooks that belong on push, such as
commitizen-branch and pip-audit, declare stages themselves. pip-audit
exports the locked runtime tree and audits it with uvx pip-audit, the same
pair check-security.yml runs, so a known-vulnerable pin is caught at push
instead of a CI round later (adopter report, issue #395).
Reason: ["pre-commit", "pre-push"] ran every hook without its own stages
twice per change, including the whole-project type checkers and pylint, and the
push run added no coverage the commit run lacked.
Rejected alternative: dropping default_stages. prek then treats every
stage as the default, so file hooks also run on commit-msg.
Five type checkers; strict basedpyright replaces pyright¶
Id: 7e130850-494b-427c-a892-a6b3479dd047
Type: decision
Status: active
Evidence: confirmed
Source: ADR-005 (docs/adr/005-five-type-checkers-basedpyright-strict.md)
Revisit when: ty churn becomes a burden, or two checkers stop catching anything the others miss
The style env invokes mypy (two Python versions), basedpyright at its full
strict ruleset, ty, pyrefly and zuban. pyright is dropped from the group and the
env. Generated code is fixed to satisfy strict rules rather than relaxing the
checker; the only suppressions are localized # pyright: ignore comments at
genuine library boundaries (MCP's untyped tool arguments).
Reason: basedpyright and zuban were installed but never invoked. Wiring them
in surfaced two latent bugs the four invoked checkers all missed — a pyright
venv misconfiguration that pyright tolerated silently, and an invariant-list
assignment to Textual's BINDINGS that only zuban's mypy-compatible engine
flagged. basedpyright is a strict superset of pyright, so running both was the
most redundant pairing in the set.
Rejected alternative: relax the strict rules — forfeits the hardening the strict bar drove into the generated code.
Rejected alternative: keep pyright beside basedpyright — pure redundancy.
Consequence: strict basedpyright forced concrete fixes in the generated
code: @override on overriding methods (typing.override is 3.12+ and the floor
is 3.11, hence typing_extensions), ClassVar on class attributes (CSS,
BINDINGS, model_config) with @final on the TUI app class, _ = ... on
intentionally discarded results, and single-string literals instead of implicit
concatenation for multi-line messages. The worker's event-loop setup was
modernized to uvloop.run(), which removed both a reportAny suppression and
a deprecation finding, so the worker needs no suppression at all.
Consequence: TUI projects gain typing_extensions for @override on
3.11; ty is pre-release and kept because it currently passes and adds
coverage. An earlier zuban miscategorized one finding and forced broadening a
mypy disable list; zuban 0.9.0 fixed it and the concession was removed.
mypy's prek hook runs through uv, like basedpyright¶
Id: 71f826cd-950d-4112-9cfb-7dad6ee98264
Type: decision
Status: active
Evidence: confirmed
Source: found while adopting the template in openapipages; recorded in ADR-005 (docs/adr/005-five-type-checkers-basedpyright-strict.md)
Verification: corroborated — the mirrors-mypy rev (v2.3.0) had drifted from the style group's mypy==2.4.0 pin, and the hook's files = "^src/" skipped the docs/examples path that [tool.mypy] files adds with include_docs
Revisit when: mypy needs to run without the project's environment, for example in a hook-only CI job without uv
See: architecture-contracts.md#whole-program-analyzers-are-local-system-hooks-backed-by-the-style-group — 41f3397d-1afb-4e57-a0bd-b5ef85089bfc — as of 2026-10-11
The mypy prek hook is a local system hook running mypy from the project's
locked style group, with pass_filenames = false.
Reason: the mirrors-mypy hook ran in an isolated venv. It needed a Jinja
list in additional_dependencies copying each component's runtime deps, and a
missed entry failed only on the adopter's first full hook run. It also carried
a second mypy version that Renovate bumped separately from the style pin, and
its own file filter. The local hook uses the project's environment, the style
pin, and [tool.mypy] files, the same as tox.
Rejected alternative: keep mirrors-mypy and add a test that its
additional_dependencies match the runtime deps. That guards one of the three
drifts and keeps the duplicate version pin.
MegaLinter is an opt-in lean complement, non-blocking on three levels¶
Id: f3041701-610f-4418-b297-e9707da5de40
Type: decision
Status: active
Evidence: confirmed
Source: ADR-013 (docs/adr/013-megalinter-opt-in-lean-complement.md); prompted by a real run (hwid#90)
Revisit when: prek gains one of the five enabled linters, the cupcake flavor drops one, or GitHub changes SARIF ingestion rules
See: external-integrations.md#a-freshly-generated-project-is-green-on-first-push-with-zero-external-accounts — 392b6dcf-6e9d-441d-bf7b-64ba362578d7 — as of 2026-10-11
See: lint-orchestration.md#the-tox-style-env-is-the-sole-authoritative-lint-suite-prek-is-a-fast-gate-not-a-second-suite — 9b74fc8b-a808-4f2b-bdbd-9e4253c1b35a — as of 2026-10-11
include_megalinter (default off, full preset) renders the workflow and
config. Enabled linters are only the gaps prek and tox leave: shellcheck for standalone
scripts, hadolint (gated on include_web, the only config that renders a
Dockerfile), jsonlint, copy-paste detection, and cspell scoped to Markdown as a
deeper dictionary pass beside prek's fast typos. It uses the smaller
cupcake flavor image, no paths filter, exits 0 (DISABLE_ERRORS), posts no
commit status, and is not in the check gate; findings reach the Security tab
through a best-effort SARIF upload of the single aggregate report.
Reason: the always-on predecessor was documented nowhere, pulled an ~8 GB image on every push (165 s of a 3-minute run was the pull), and five of its six linters re-ran checks prek already performed. Unlike the external-service integrations it needs no account, so it earned its own decision. A paths filter was tried and dropped: copy-paste detection scans source, so the filter fired on nearly every PR anyway and only confused. Pointing the SARIF upload at the reports directory was rejected because GitHub rejects a file whose runs share a tool and category (the GitHub 2025-07-21 changelog); the aggregate's per-linter runs carry distinct tool names.
The repository security linters are deliberately not enabled:
check-security.yml, CodeQL and Scorecard already cover secrets, vulnerabilities
and SAST. Likewise prek already runs actionlint, yamllint, markdownlint and
editorconfig-checker, and the tox style env covers Python.
Rejected alternative: always-on — the posture above.
Rejected alternative: drop cspell entirely — an earlier revision did; it was reinstated, scoped, during review.
Consequence: the best-effort upload fails silently, so the SARIF path is verified against a live public repository via the code-scanning API, not by rendering, after any change to that step or a major bump. A project wanting MegaLinter to gate requires its code-scanning results via branch protection.
semgrep runs in the style env against a named registry ruleset, never vendored¶
Id: 5068081c-24ce-4c39-9cdf-13e0a7c229b4
Type: decision
Type: constraint
Status: active
Evidence: confirmed
Source: ADR-032 (docs/adr/032-semgrep-sast-in-the-style-env.md); issue #174
Revisit when: offline determinism outranks rule quality, semgrep gains an on-disk rule cache, or the registry rules' license permits redistribution
See: lint-orchestration.md#the-tox-style-env-is-the-sole-authoritative-lint-suite-prek-is-a-fast-gate-not-a-second-suite — 9b74fc8b-a808-4f2b-bdbd-9e4253c1b35a — as of 2026-10-11
semgrep is pinned in its own sast dependency group, runs only in the tox
style env over src with the p/python registry pack, findings blocking and
telemetry off. It is not a prek hook, not in check-security.yml, and no
ruleset is committed.
Reason: the registry rules carry the Semgrep Rules License, which forbids
distribution — a Copier template exists to distribute files, so vendoring them
is exactly the prohibited act. Every p/python rule carries
metadata.license: Semgrep Rules License v1.0 and the rules repository reports
SPDX NOASSERTION; the terms say "You may not distribute the rules, or make
them available to others as a service", granted for internal business purposes
only, with no sublicensing. semgrep keeps no on-disk rule cache, so a prek
hook would hit the registry on every commit and fail offline; the style-env-only
placement follows the ty/pyrefly/zuban precedent. The dedicated group
exists because dev includes style wholesale and the wheel is ~215 MB with a
native core; only the Linux style job should pay that. --config auto was
avoided so rule selection is reproducible.
Rejected alternative: hand-authored rules — legal and offline, but a hand-maintained taint database catching less than the relevant community rules.
Rejected alternative: a uvx semgrep job in check-security.yml — pure
duplication once semgrep is in style, with an unpinned version Renovate did
not track.
Consequence: not hermetic — a new community rule or a registry outage can
redden an unrelated PR; findings overlap ruff's bandit rules, so a line may need
both suppressions. The marginal value is cross-statement taint rules ruff
cannot express, on a faster cadence than CodeQL's scheduled deep analysis. The
style job itself still pays the ~215 MB on every tox -e style run, including
all ten render scenarios in this repository's own template-ci.yml; semgrep
does ship a win_amd64 wheel, so keeping it off other OSes is a cost decision,
not a platform constraint. The exact pin has a single version source that
Renovate tracks through the native pep621 manager, and the scan is scoped to
src so it audits the shipped package, not tests or docs.
Rejected alternative: stack p/security-audit on top of p/python. It is
the auditor-oriented low-confidence pack, and p/python already carries
python.lang.security.*. The overlap with ruff is also larger than first
assumed: generated projects set select = ["ALL"], so flake8-bandit (S) is
already on, and of p/python's 151 rules 55 are taint-mode and only 21 are
relevant to what the template generates.
Dependency hygiene (deptry + creosote) is an always-on deps gate, not a toggle¶
Id: 09ecae89-ab05-4fd9-ae46-2a4d33f69c47 Type: decision Status: active Evidence: confirmed Source: issue #394; maintainer decision on the approach, 2026-10-13 Revisit when: deptry or creosote produce false positives for common generated shapes faster than the template can seed ignores, or one tool subsumes the other See: lint-orchestration.md#semgrep-runs-in-the-style-env-against-a-named-registry-ruleset-never-vendored — 5068081c-24ce-4c39-9cdf-13e0a7c229b4 — as of 2026-10-13
Every generated project ships a deps dependency group (creosote, deptry), a
[tool.deptry] block, a tox -e deps env and a deps job in ci.yml that
the check aggregate needs. There is no include_* toggle. The tox env is not
in env_list, so a plain tox run and the component test jobs skip it; CI runs
it in its own job.
Reason: the adopting project that raised it lost the only unused/transitive/
misplaced-dependency check it had, and nothing in the template covered that
drift. The maintainers chose to have the gate by default so every adopter gets it
without re-applying an overlay on each copier update. It follows the sast
precedent: its own group so a plain uv sync skips the tools, and its own job so
a failure names dependency drift rather than a lint run.
Rejected alternative: an opt-in include_dep_hygiene toggle (the issue's
option 1) — declined by the maintainers in favour of default-on; the stated
motivation was having the gate by default, no further reason was recorded.
Rejected alternative: documenting the overlay recipe only (option 2) — leaves every adopter to reverse-engineer and re-apply it.
Consequence: the template seeds the ignores its own output needs, gated on
the toggles that produce the imports: known_first_party (the tox env installs
the wheel, so deptry otherwise reads the project's own package as a transitive
dependency), uvicorn (DEP003, web) and packaging (DEP004, docs) in
per_rule_ignores, the build hook/benchmarks/worker probe script/Sphinx config
in extend_exclude, and creosote's --include-deferred (creosote skips imports
inside functions by default, which would report the worker's lazily imported
uvloop as unused). The gate also surfaced a real
template defect: core/config.py imported pydantic while only
pydantic-settings was declared, so pydantic is now a direct dependency
whenever include_pydantic_settings or include_worker is on.
Shell and YAML lint/format are prek hooks, not a uv group¶
Id: df7e185c-4188-457b-bc55-d20b7cde8ee1 Type: decision Status: active Evidence: confirmed Source: issue #392; maintainer scoping answers, 2026-10-12 Revisit when: a shell/YAML check needs the project's venv, or the shell/YAML hook set changes See: lint-orchestration.md#the-tox-style-env-is-the-sole-authoritative-lint-suite-prek-is-a-fast-gate-not-a-second-suite — 9b74fc8b-a808-4f2b-bdbd-9e4253c1b35a — as of 2026-10-12
Shell (shellcheck via the shellcheck-py wheel, shfmt, checkmake) and
YAML (yamllint, yamlfmt) checks run as prek hooks. The shell/Makefile trio
follows its upstream hook manifests' file scoping; the YAML pair engages from
the first commit since every project ships workflow YAML. There is deliberately
no shell/yaml uv dependency group: the hooks carry version-pinned revs
that Renovate keeps current in generated projects (the customManagers regex
in the shared preset), and a uv pin of the same tools would be a second version
source for the same check.
Reason: these tools inspect files outside the Python package (standalone
scripts a project adds later, YAML configs), so they never needed the project's
environment the way the whole-program local system hooks (the type
checkers, import-linter, slotscheck) do. Workflow run: blocks are checked by
actionlint — including its shellcheck pass where a shellcheck executable is
available — so the shellcheck hook only covers standalone scripts.
Rejected alternative: a uv lint group pinning shellcheck-py,
shellcheck-makefile, yamllint, yamlfix and yamlfmt — the shortlist from the
pre-adoption keycast pyproject.toml quoted in the issue; rejected as a
duplicate version source.
Rejected alternative: yamlfix over the google/yamlfmt hook — a second
fixer would fight the existing one over document style; shellcheck-makefile
over checkmake — a category error, it lints shell inside recipes while
checkmake lints Makefile structure, so that recipe-shell coverage stays
unevaluated rather than claimed.