diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02d38be..f4dc7ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: strategy: fail-fast: false matrix: - check: ["format:check", "lock:check", typecheck, bandit, deptry, audit] + check: ["format:check", "lock:check", "config:check", typecheck, bandit, deptry, audit] name: ${{ matrix.check }} runs-on: macos-latest steps: diff --git a/Ritefile.yml b/Ritefile.yml index 7e2d0db..4eede6d 100644 --- a/Ritefile.yml +++ b/Ritefile.yml @@ -52,27 +52,27 @@ tasks: cmds: # src/macprefs is listed explicitly: it has no .py extension, so the # glob misses it, but pylint lints any file passed by name. - - uv run pylint src/*.py src/macprefs tests/*.py + - uv run pylint src/*.py src/macprefs tests/*.py ci/scripts/*.py lint:ruff: desc: Run ruff check deps: [setup] cmds: - - uv run ruff check src tests + - uv run ruff check src tests ci format:check: desc: Check formatting without changing files aliases: [fc] deps: [setup] cmds: - - uv run ruff format --check src tests + - uv run ruff format --check src tests ci typecheck: desc: Type-check with pyright (basic mode; untyped codebase) aliases: [tc] deps: [setup] cmds: - - uv run pyright src tests + - uv run pyright src tests ci bandit: desc: Security-lint the source with bandit @@ -93,7 +93,7 @@ tasks: check: desc: Run every check (what CI runs) - deps: [test, lint, format:check, lock:check, typecheck, bandit, deptry, gitleaks, audit] + deps: [test, lint, format:check, lock:check, config:check, typecheck, bandit, deptry, gitleaks, audit] lint:shell: desc: Shellcheck the ci scripts @@ -107,6 +107,13 @@ tasks: - actionlint - uv run zizmor .github/workflows + config:check: + desc: Fail if a config file shadows pyproject.toml tool sections + aliases: [cc] + deps: [setup] + cmds: + - uv run python ci/scripts/check-config-shadowing.py + lock:check: desc: Verify uv.lock is in sync with pyproject.toml aliases: [lc] @@ -118,8 +125,8 @@ tasks: aliases: [f] deps: [setup] cmds: - - uv run ruff check --fix src tests - - uv run ruff format src tests + - uv run ruff check --fix src tests ci + - uv run ruff format src tests ci audit: desc: Scan locked dependencies for known vulnerabilities diff --git a/ci/scripts/check-config-shadowing.py b/ci/scripts/check-config-shadowing.py new file mode 100644 index 0000000..a2ac010 --- /dev/null +++ b/ci/scripts/check-config-shadowing.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Fail if a config file shadows a [tool.*] section in pyproject.toml. + +Every tool configured in pyproject.toml also discovers standalone config +files, and the standalone file wins -- mostly silently: uv.toml disables +[tool.uv] with no warning, an empty pytest.ini shadows +[tool.pytest.ini_options], and a ruff.toml in a subdirectory replaces +(not extends) the root lint rules for that subtree. This check bans the +whole discovery chain so pyproject.toml stays the single source of truth. + +Scans tracked and untracked-unignored files, so a stray local file fails +`rite check` before it is ever committed. +""" + +import subprocess +import sys + +# basename -> (tool that reads it, what happens when it exists) +FORBIDDEN = { + "uv.toml": ("uv", "read instead of [tool.uv], which is then ignored with no warning"), + "pytest.toml": ("pytest", "outranks [tool.pytest.ini_options]"), + ".pytest.toml": ("pytest", "outranks [tool.pytest.ini_options]"), + "pytest.ini": ("pytest", "outranks [tool.pytest.ini_options], even when empty"), + ".pytest.ini": ("pytest", "outranks [tool.pytest.ini_options], even when empty"), + "ruff.toml": ("ruff", "replaces, not extends, [tool.ruff] for its whole subtree"), + ".ruff.toml": ("ruff", "replaces, not extends, [tool.ruff] for its whole subtree"), + "pylintrc": ("pylint", "outranks [tool.pylint.*]"), + ".pylintrc": ("pylint", "outranks [tool.pylint.*]"), + "pylintrc.toml": ("pylint", "outranks [tool.pylint.*]"), + ".pylintrc.toml": ("pylint", "outranks [tool.pylint.*]"), + "pyrightconfig.json": ("pyright", "always beats [tool.pyright]"), + ".coveragerc": ("coverage", "outranks pyproject.toml; could weaken the coverage gate"), + ".gitleaks.toml": ("gitleaks", "auto-loaded; an [allowlist] would weaken the secret scan"), + "tox.ini": ("pytest/coverage", "its [pytest] and [coverage:*] sections shadow pyproject.toml; this repo does not use tox"), + "setup.cfg": ("pytest/coverage/pylint", "its tool sections shadow pyproject.toml; this repo does not use setuptools config"), +} + + +def repo_files() -> list[str]: + result = subprocess.run( + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + check=True, + capture_output=True, + text=True, + ) + return [path for path in result.stdout.split("\0") if path] + + +def main() -> int: + offenders = [] + for path in repo_files(): + basename = path.rsplit("/", 1)[-1] + if basename in FORBIDDEN: + tool, why = FORBIDDEN[basename] + offenders.append(f"{path} ({tool}: {why})") + if offenders: + print("Config files must not shadow pyproject.toml [tool.*] sections:", file=sys.stderr) + for offender in offenders: + print(f" {offender}", file=sys.stderr) + print( + "\nDelete the file(s) and keep the config in pyproject.toml. If a standalone" + "\nfile is genuinely needed, move that tool's config there deliberately and" + "\nremove its entry from ci/scripts/check-config-shadowing.py.", + file=sys.stderr, + ) + return 1 + print(f"config:check ok -- none of the {len(FORBIDDEN)} shadowing config filenames present") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index c4ecc0e..463f39a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,14 +83,14 @@ max-line-length = 130 line-length = 130 target-version = "py310" # The extensionless entrypoint needs to be named to be included. -include = ["src/**/*.py", "tests/**/*.py", "src/macprefs"] +include = ["src/**/*.py", "tests/**/*.py", "src/macprefs", "ci/**/*.py"] [tool.ruff.lint] # Default rules (pyflakes + core pycodestyle) plus import sorting. extend-select = ["I"] [tool.pyright] -include = ["src", "tests"] +include = ["src", "tests", "ci"] extraPaths = ["src"] # Untyped codebase: basic mode checks inference-visible errors only. typeCheckingMode = "basic"