Skip to content
v0.1.3

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:

make lint
make quality
make security

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"
      ]
    }
  ]
}
Deptry (pyproject.toml)
[tool.deptry]
ignore = ["DEP002", "DEP003"]
Interrogate (pyproject.toml)
[tool.interrogate]
fail-under = 98
exclude = ["src/bijux_cli/core/di.py"]
color = true
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):

pydocstyle --convention=google path/to/file.py

Interrogate enforces documentation coverage thresholds as configured.


CI Integration

  • make lint runs over src/ and tests/.
  • make quality and make 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.