Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 14 additions & 7 deletions Ritefile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]
Expand All @@ -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
Expand Down
72 changes: 72 additions & 0 deletions ci/scripts/check-config-shadowing.py
Original file line number Diff line number Diff line change
@@ -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())
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading