ADR-0004: Linting, Quality, and Security Toolchain¶
- Date: 2025-08-01
- Status: Accepted
- Author: Bijan Mousavi
Context¶
We need a single, reproducible pipeline for code style, formatting, type-safety, complexity, documentation coverage, dead code, dependency hygiene, license compliance, and security checks — identical locally and in CI. Developers should be able to run:
and get the same results everywhere.
We standardized on the following tools:
- Ruff for formatting, import sorting, and linting (with auto-fix where safe).
- Mypy and Pytype for static typing (Pytype runs where supported).
- Pyright for fast type checks (editor/CI parity).
- Pydocstyle (Google convention) for docstring style.
- Interrogate for documentation coverage.
- Radon for cyclomatic complexity.
- Vulture for dead code detection.
- Deptry for unused/incorrect dependencies.
- REUSE for SPDX license header compliance.
- Bandit for security static analysis.
- pip-audit for dependency vulnerability audits.
All configuration lives under config/
(with a few root files like REUSE.toml
), ensuring CI/local parity.
Decision¶
Makefile Targets¶
We enforce Makefile targets to run the full toolchain consistently.
Lint (Makefile
)
# Lint Configuration (no root cache pollution)
RUFF := $(ACT)/ruff
MYPY := $(ACT)/mypy
PYTYPE := $(ACT)/pytype
CODESPELL := $(ACT)/codespell
PYRIGHT := $(ACT)/pyright
PYDOCSTYLE := $(ACT)/pydocstyle
RADON := $(ACT)/radon
# Targets & dirs
LINT_DIRS ?= src/bijux_cli tests
LINT_ARTIFACTS_DIR ?= artifacts/lint
# Tool caches inside artifacts_pages/lint
RUFF_CACHE_DIR ?= $(LINT_ARTIFACTS_DIR)/.ruff_cache
MYPY_CACHE_DIR ?= $(LINT_ARTIFACTS_DIR)/.mypy_cache
PYTYPE_OUT_DIR ?= $(LINT_ARTIFACTS_DIR)/.pytype
# In case these are not defined elsewhere
VENV_PYTHON ?= python3
.PHONY: lint lint-artifacts lint-file lint-dir lint-clean
lint: lint-artifacts
@echo "✔ Linting completed (logs in '$(LINT_ARTIFACTS_DIR)')"
lint-artifacts: | $(VENV)
@mkdir -p "$(LINT_ARTIFACTS_DIR)" "$(RUFF_CACHE_DIR)" "$(MYPY_CACHE_DIR)" "$(PYTYPE_OUT_DIR)"
@set -euo pipefail; { \
echo "→ Ruff format (check)"; \
$(RUFF) format --check --cache-dir "$(RUFF_CACHE_DIR)" $(LINT_DIRS); \
} 2>&1 | tee "$(LINT_ARTIFACTS_DIR)/ruff-format.log"
@set -euo pipefail; $(RUFF) check --fix --config config/ruff.toml --cache-dir "$(RUFF_CACHE_DIR)" $(LINT_DIRS) 2>&1 | tee "$(LINT_ARTIFACTS_DIR)/ruff.log"
@set -euo pipefail; $(MYPY) --config-file config/mypy.ini --strict --cache-dir "$(MYPY_CACHE_DIR)" $(LINT_DIRS) 2>&1 | tee "$(LINT_ARTIFACTS_DIR)/mypy.log"
@set -euo pipefail; $(PYRIGHT) --project config/pyrightconfig.json 2>&1 | tee "$(LINT_ARTIFACTS_DIR)/pyright.log"
@set -euo pipefail; $(CODESPELL) -I config/bijux.dic $(LINT_DIRS) 2>&1 | tee "$(LINT_ARTIFACTS_DIR)/codespell.log"
@set -euo pipefail; $(RADON) cc -s -a $(LINT_DIRS) 2>&1 | tee "$(LINT_ARTIFACTS_DIR)/radon.log"
@set -euo pipefail; $(PYDOCSTYLE) --convention=google $(LINT_DIRS) 2>&1 | tee "$(LINT_ARTIFACTS_DIR)/pydocstyle.log"
@if $(VENV_PYTHON) -c 'import sys; sys.exit(0 if sys.version_info < (3,13) else 1)'; then \
set -euo pipefail; $(PYTYPE) -o "$(PYTYPE_OUT_DIR)" --keep-going --disable import-error $(LINT_DIRS) 2>&1 | tee "$(LINT_ARTIFACTS_DIR)/pytype.log"; \
else \
echo "Pytype skipped on Python ≥3.13" | tee "$(LINT_ARTIFACTS_DIR)/pytype.log"; \
fi
@[ -d .pytype ] && echo "→ removing stray .pytype" && rm -rf .pytype || true
@[ -d .mypy_cache ] && echo "→ removing stray .mypy_cache" && rm -rf .mypy_cache || true
@[ -d .ruff_cache ] && echo "→ removing stray .ruff_cache" && rm -rf .ruff_cache || true
@printf "OK\n" > "$(LINT_ARTIFACTS_DIR)/_passed"
lint-file:
ifndef file
$(error Usage: make lint-file file=path/to/file.py)
endif
@$(call run_tool,RuffFormat,$(RUFF) format --cache-dir "$(RUFF_CACHE_DIR)")
@$(call run_tool,Ruff,$(RUFF) check --fix --config config/ruff.toml --cache-dir "$(RUFF_CACHE_DIR)")
@$(call run_tool,Mypy,$(MYPY) --config-file config/mypy.ini --strict --cache-dir "$(MYPY_CACHE_DIR)")
@$(call run_tool,Codespell,$(CODESPELL) -I config/bijux.dic)
@$(call run_tool,Pyright,$(PYRIGHT) --project config/pyrightconfig.json)
@$(call run_tool,Radon,$(RADON) cc -s -a)
@$(call run_tool,Pydocstyle,$(PYDOCSTYLE) --convention=google)
@if $(VENV_PYTHON) -c 'import sys; sys.exit(0 if sys.version_info < (3,13) else 1)'; then \
$(call run_tool,Pytype,$(PYTYPE) -o "$(PYTYPE_OUT_DIR)" --keep-going --disable import-error); \
else \
echo "→ Skipping Pytype (unsupported on Python ≥ 3.13)"; \
fi
lint-dir:
ifndef dir
$(error Usage: make lint-dir dir=<directory_path>)
endif
@$(MAKE) LINT_DIRS="$(dir)" lint-artifacts
lint-clean:
@echo "→ Cleaning lint artifacts"
@rm -rf "$(LINT_ARTIFACTS_DIR)" .pytype .mypy_cache .ruff_cache || true
@echo "✔ done"
##@ Lint
lint: ## Run all lint checks; save logs to artifacts_pages/lint/ (ruff/mypy/pytype caches under artifacts_pages/lint)
lint-artifacts: ## Same as 'lint' (explicit), generates logs
lint-file: ## Lint a single file (requires file=<path>)
lint-dir: ## Lint a directory (requires dir=<path>)
lint-clean: ## Remove lint artifacts_pages, including caches
Quality (Makefile
)
# Quality Configuration (evidence → artifacts_pages/quality)
INTERROGATE_PATHS ?= src/bijux_cli
QUALITY_PATHS ?= src/bijux_cli
VULTURE := $(ACT)/vulture
DEPTRY := $(ACT)/deptry
REUSE := $(ACT)/reuse
INTERROGATE := $(ACT)/interrogate
PYTHON := $(shell command -v python3 || command -v python)
QUALITY_ARTIFACTS_DIR ?= artifacts/quality
QUALITY_OK_MARKER := $(QUALITY_ARTIFACTS_DIR)/_passed
ifeq ($(shell uname -s),Darwin)
BREW_PREFIX := $(shell command -v brew >/dev/null 2>&1 && brew --prefix)
CAIRO_PREFIX := $(shell test -n "$(BREW_PREFIX)" && brew --prefix cairo)
QUALITY_ENV := DYLD_FALLBACK_LIBRARY_PATH="$(BREW_PREFIX)/lib:$(CAIRO_PREFIX)/lib:$$DYLD_FALLBACK_LIBRARY_PATH"
else
QUALITY_ENV :=
endif
.PHONY: quality interrogate-report quality-clean
quality:
@echo "→ Running quality checks..."
@mkdir -p "$(QUALITY_ARTIFACTS_DIR)"
@echo " - Dead code analysis (Vulture)"
@set -euo pipefail; \
{ $(VULTURE) --version 2>/dev/null || echo vulture; } >"$(QUALITY_ARTIFACTS_DIR)/vulture.log"; \
OUT="$$( $(VULTURE) $(QUALITY_PATHS) --min-confidence 80 2>&1 || true )"; \
printf '%s\n' "$$OUT" >>"$(QUALITY_ARTIFACTS_DIR)/vulture.log"; \
if [ -z "$$OUT" ]; then echo "✔ Vulture: no dead code found." >>"$(QUALITY_ARTIFACTS_DIR)/vulture.log"; fi
@echo " - Dependency hygiene (Deptry)"
@set -euo pipefail; \
{ $(DEPTRY) --version 2>/dev/null || true; } >"$(QUALITY_ARTIFACTS_DIR)/deptry.log"; \
$(DEPTRY) $(QUALITY_PATHS) 2>&1 | tee -a "$(QUALITY_ARTIFACTS_DIR)/deptry.log"
@echo " - License & SPDX compliance (REUSE)"
@set -euo pipefail; \
{ $(REUSE) --version 2>/dev/null || true; } >"$(QUALITY_ARTIFACTS_DIR)/reuse.log"; \
$(REUSE) lint 2>&1 | tee -a "$(QUALITY_ARTIFACTS_DIR)/reuse.log"
@echo " - Documentation coverage (Interrogate)"
@$(MAKE) interrogate-report
@echo "✔ Quality checks passed"
@printf "OK\n" >"$(QUALITY_OK_MARKER)"
interrogate-report:
@echo "→ Generating docstring coverage report (<100%)"
@mkdir -p "$(QUALITY_ARTIFACTS_DIR)"
@set +e; \
OUT="$$( $(QUALITY_ENV) $(INTERROGATE) --verbose $(INTERROGATE_PATHS) )"; \
rc=$$?; \
printf '%s\n' "$$OUT" >"$(QUALITY_ARTIFACTS_DIR)/interrogate.full.txt"; \
OFF="$$(printf '%s\n' "$$OUT" | awk -F'|' 'NR>3 && $$0 ~ /^\|/ { \
name=$$2; cov=$$6; gsub(/^[ \t]+|[ \t]+$$/, "", name); gsub(/^[ \t]+|[ \t]+$$/, "", cov); \
if (name !~ /^-+$$/ && cov != "100%") printf(" - %s (%s)\n", name, cov); \
}')"; \
printf '%s\n' "$$OFF" >"$(QUALITY_ARTIFACTS_DIR)/interrogate.offenders.txt"; \
if [ -n "$$OFF" ]; then printf '%s\n' "$$OFF"; else echo "✔ All files 100% documented"; fi; \
exit $$rc
quality-clean:
@echo "→ Cleaning quality artifacts"
@rm -rf "$(QUALITY_ARTIFACTS_DIR)"
##@ Quality
quality: ## Run Vulture, Deptry, REUSE, Interrogate; save logs to artifacts_pages/quality/
interrogate-report: ## Save full Interrogate table + offenders list
quality-clean: ## Remove artifacts_pages/quality
Security (Makefile
)
# Security Configuration (no SBOM here; SBOM is handled in sbom.mk)
SECURITY_PATHS ?= src/bijux_cli
BANDIT ?= $(if $(ACT),$(ACT)/bandit,bandit)
PIP_AUDIT ?= $(if $(ACT),$(ACT)/pip-audit,pip-audit)
VENV_PYTHON ?= $(if $(VIRTUAL_ENV),$(VIRTUAL_ENV)/bin/python,python)
SECURITY_REPORT_DIR ?= artifacts/security
BANDIT_JSON := $(SECURITY_REPORT_DIR)/bandit.json
BANDIT_TXT := $(SECURITY_REPORT_DIR)/bandit.txt
PIPA_JSON := $(SECURITY_REPORT_DIR)/pip-audit.json
PIPA_TXT := $(SECURITY_REPORT_DIR)/pip-audit.txt
SECURITY_IGNORE_IDS ?= PYSEC-2022-42969
SECURITY_IGNORE_FLAGS = $(foreach V,$(SECURITY_IGNORE_IDS),--ignore-vuln $(V))
PIP_AUDIT_CONSOLE_FLAGS ?= --skip-editable --progress-spinner off
PIP_AUDIT_INPUTS ?=
SECURITY_STRICT ?= 1
BANDIT_EXCLUDES ?= .venv,venv,build,dist,.tox,.mypy_cache,.pytest_cache
BANDIT_THREADS ?= 0
.PHONY: security security-bandit security-audit security-clean
security: security-bandit security-audit
security-bandit:
@mkdir -p "$(SECURITY_REPORT_DIR)"
@echo "→ Bandit (Python static analysis)"
@$(BANDIT) -r "$(SECURITY_PATHS)" -x "$(BANDIT_EXCLUDES)" -f json -o "$(BANDIT_JSON)" -n $(BANDIT_THREADS) || true
@$(BANDIT) -r "$(SECURITY_PATHS)" -x "$(BANDIT_EXCLUDES)" -n $(BANDIT_THREADS) | tee "$(BANDIT_TXT)"
security-audit:
@mkdir -p "$(SECURITY_REPORT_DIR)"
@echo "→ Pip-audit (dependency vulnerability scan)"
@set -e; RC=0; \
$(PIP_AUDIT) $(SECURITY_IGNORE_FLAGS) $(PIP_AUDIT_CONSOLE_FLAGS) $(PIP_AUDIT_INPUTS) \
-f json -o "$(PIPA_JSON)" >/dev/null 2>&1 || RC=$$?; \
if [ $$RC -ne 0 ]; then \
echo "! pip-audit invocation failed (rc=$$RC)"; \
if [ "$(SECURITY_STRICT)" = "1" ]; then exit $$RC; fi; \
fi
@set -o pipefail; \
PIPA_JSON="$(PIPA_JSON)" \
SECURITY_STRICT="$(SECURITY_STRICT)" \
SECURITY_IGNORE_IDS="$(SECURITY_IGNORE_IDS)" \
"$(VENV_PYTHON)" scripts/helper_pip_audit.py | tee "$(PIPA_TXT)"
security-clean:
@rm -rf "$(SECURITY_REPORT_DIR)"
##@ Security
security: ## Run Bandit and pip-audit; save reports to $(SECURITY_REPORT_DIR)
security-bandit: ## Run Bandit (screen + JSON artifact)
security-audit: ## Run pip-audit (JSON once) and gate via scripts/helper_pip_audit.py; prints concise summary
security-clean: ## Remove security reports
This setup supports whole-project runs as well as per-directory/per-file runs, with reasonable exclusions for generated or template content.
Tool Configurations¶
The toolchain is driven by unified configs:
Ruff (config/ruff.toml
)
target-version = "py311"
line-length = 88
respect-gitignore = true
src = ["src", "tests"]
exclude = [
".git", ".hg", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".venv",
"build", "dist", "docs", "htmlcov", "__pycache__", "migrations", "*.egg-info"
]
[lint]
select = [
"E","F","I","B","UP","SIM","PT","N","A","C4","S","TID","PERF",
# "RUF","ARG","TRY","T20","BLE","ERA"
]
ignore = ["E501", "E203"]
[lint.per-file-ignores]
"tests/**" = ["S101"]
"__init__.py" = ["F401"]
[lint.isort]
force-sort-within-sections = true
known-first-party = ["bijux_cli"]
required-imports = ["from __future__ import annotations"]
[lint.flake8-tidy-imports]
ban-relative-imports = "parents"
[lint.mccabe]
max-complexity = 10
Mypy (config/mypy.ini
)
[mypy]
python_version = 3.11
strict = true
show_error_codes = true
pretty = true
warn_unreachable = true
warn_unused_configs = true
warn_unused_ignores = true
follow_imports = silent
mypy_path = src
files = src, tests
exclude = ^(\.venv|build|dist|docs|htmlcov|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.tox|__pycache__|migrations|\.egg-info|node_modules)/
plugins = pydantic.mypy
[mypy-cookiecutter.*]
ignore_missing_imports = true
[mypy-bijux_cli.core.di]
disable_error_code = type-abstract
[pydantic-mypy]
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
Pyright (config/pyrightconfig.json
)
{
"include": [
"../src/bijux_cli",
"../tests"
],
"extraPaths": [
"../src",
".."
],
"exclude": [
".venv",
"build",
"dist",
"htmlcov",
".pytest_cache",
".mypy_cache",
".pytype",
".ruff_cache",
".tox",
"**/__pycache__",
"node_modules"
],
"pythonVersion": "3.11",
"typeCheckingMode": "strict",
"useLibraryCodeForTypes": true,
"reportMissingImports": "warning",
"reportMissingTypeStubs": "none",
"reportUnusedImport": "error",
"reportPrivateUsage": "warning",
"reportUnnecessaryTypeIgnoreComment": "warning",
"reportUnnecessaryCast": "warning",
"reportUnnecessaryIsInstance": "warning",
"reportOptionalSubscript": "error",
"reportOptionalMemberAccess": "error",
"reportOptionalCall": "error",
"reportOptionalIterable": "error",
"reportOptionalContextManager": "error",
"reportOptionalOperand": "error",
"reportGeneralTypeIssues": "error",
"reportUntypedClassDecorator": "error",
"reportIncompatibleMethodOverride": "error",
"reportUnknownVariableType": "none",
"reportUnknownParameterType": "none",
"reportUntypedFunctionDecorator": "none",
"reportUnknownMemberType": "none",
"reportUnknownArgumentType": "none",
"reportUnknownLambdaType": "none",
"reportUnusedVariable": "information",
"reportMethodAssign": "none",
"executionEnvironments": [
{
"root": ".",
"extraPaths": [
"src"
]
}
]
}
Interrogate (pyproject.toml
)
REUSE (REUSE.toml
)
version = 1
# Config/docs/assets: public domain
[[annotations]]
path = [
"**/*.png", "**/*.svg", "**/*.ico", "**/*.gif", "**/*.jpg", "**/*.jpeg",
"**/*.html", "**/*.toml", "**/*.ini", "**/*.cfg", "**/*.conf",
"**/*.env", "**/*.env.*", "**/*.yaml", "**/*.yml", "**/*.json",
"**/*.cff", "**/*.dic", ".coverage*", ".gitattributes", ".gitignore",
"changelog.d/**", "**/.editorconfig", "artifacts/**", "scripts/git-hooks/**",
"docs/assets/styles/**"
]
precedence = "override"
SPDX-License-Identifier = "CC0-1.0"
SPDX-FileCopyrightText = "© 2025 Bijan Mousavi"
# Templates: public domain
[[annotations]]
path = ["plugin_template/**"]
precedence = "override"
SPDX-License-Identifier = "CC0-1.0"
SPDX-FileCopyrightText = "© 2025 Bijan Mousavi"
# Code: MIT
[[annotations]]
path = ["**/*.py", "**/*.pyi", "**/*.sh", "**/*.mk", "Makefile", "Dockerfile", "Dockerfile.*"]
precedence = "closest"
SPDX-License-Identifier = "MIT"
SPDX-FileCopyrightText = "© 2025 Bijan Mousavi"
# Markdown docs: MIT
[[annotations]]
path = ["**/*.md"]
precedence = "closest"
SPDX-License-Identifier = "MIT"
SPDX-FileCopyrightText = "© 2025 Bijan Mousavi"
Docstring Style Enforcement
We mandate Google-style docstrings via Pydocstyle (enforced in Makefile):
Interrogate enforces documentation coverage thresholds as configured.
CI Integration¶
make lint
runs oversrc/
andtests/
.make quality
andmake security
run project-wide.- All Makefile targets are configured to write their reports and logs to the canonical locations defined in ADR-0005.
- Any failure blocks the build; no overrides.
Consequences¶
Pros¶
- Uniform enforcement across the repo; no drift.
- One tool (Ruff) handles formatting, import sorting, and linting with fast auto-fixes.
- Strong typing via Mypy, Pytype (where supported), and Pyright.
- Doc style & coverage enforced via Pydocstyle + Interrogate.
- Maintainability boosted by Vulture (dead code), Deptry (deps), Radon (complexity).
- SPDX compliance via REUSE.
- Security posture improved through Bandit + pip-audit.
- All configs centralized under
config/
, ensuring local/CI parity.
Cons¶
- Initial setup and periodic rule maintenance.
- Contributors must align with strict rules and workflow.
Enforcement¶
- Code is accepted only if it passes all configured targets and checks in this ADR.
- Reviewers & CI must reject non-compliant changes (lint, quality, security, or config deviations).
- This policy is binding to preserve the integrity of the toolchain.