Code Quality

docsfy enforces code quality through a layered set of automated checks that run on every commit. The project uses pre-commit as the orchestration layer, combining linting, formatting, type checking, and security scanning into a single developer workflow.

Overview

Tool Version Purpose
Ruff v0.15.2 Python linting and code formatting
Flake8 v7.3.0 RedHatQE M511 mutable default detection
mypy v1.19.1 Strict static type checking
detect-secrets v1.5.0 Prevent secrets from entering the codebase
gitleaks v8.30.0 Scan git history for leaked secrets
tox (unused-code) Dead code detection via pyutils-unusedcode

Pre-commit Hooks

All checks are configured in .pre-commit-config.yaml and run automatically before each commit. The project also integrates with pre-commit.ci for continuous integration, with automatic PR auto-fix disabled:

ci:
  autofix_prs: false
  autoupdate_commit_msg: "ci: [pre-commit.ci] pre-commit autoupdate"

Standard Hooks

The first layer of pre-commit hooks catches common issues before any language-specific tooling runs:

- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v6.0.0
  hooks:
    - id: check-added-large-files
    - id: check-docstring-first
    - id: check-executables-have-shebangs
    - id: check-merge-conflict
    - id: check-symlinks
    - id: detect-private-key
    - id: mixed-line-ending
    - id: debug-statements
    - id: trailing-whitespace
      args: [--markdown-linebreak-ext=md]
    - id: end-of-file-fixer
    - id: check-ast
    - id: check-builtin-literals
    - id: check-toml

These hooks enforce baseline hygiene: no large binaries, no merge conflict markers, no stray breakpoint() or pdb calls, consistent line endings, and valid Python AST in every .py file.

Installing Pre-commit

To set up the hooks locally:

pip install pre-commit
pre-commit install

To run all hooks against the entire codebase on demand:

pre-commit run --all-files

Ruff — Linting and Formatting

Ruff serves as the primary linter and code formatter for the project. It replaces tools like isort, pyflakes, pycodestyle, and Black with a single, fast Rust-based tool.

Both the linter and formatter are registered as separate pre-commit hooks:

- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.15.2
  hooks:
    - id: ruff
    - id: ruff-format

The project uses ruff's default rule set with no custom configuration — there is no [tool.ruff] section in pyproject.toml and no standalone ruff.toml file. The defaults include pyflakes (F), pycodestyle (E, W), and isort (I) rules.

Tip: Ruff can auto-fix many issues. Run ruff check --fix . to apply safe fixes, or ruff format . to reformat the codebase.

Flake8 — M511 Mutable Default Detection

Flake8 is retained alongside ruff specifically for the RedHatQE flake8-plugins package, which provides the M511 rule. This rule detects mutable default arguments in function signatures — a common Python pitfall where a mutable object (like a list or dict) is used as a default parameter value and shared across all calls.

Configuration

The .flake8 configuration file restricts flake8 to only the M511 rule:

[flake8]
select=M511

exclude =
    doc,
    .tox,
    .git,
    *.yml,
    Pipfile.*,
    docs/*,
    .cache/*

The pre-commit hook also pulls in flake8-mutable as an additional dependency:

- repo: https://github.com/PyCQA/flake8
  rev: 7.3.0
  hooks:
    - id: flake8
      args: [--config=.flake8]
      additional_dependencies:
        # Tracks main branch intentionally for latest RedHatQE flake8 plugins
        [git+https://github.com/RedHatQE/flake8-plugins.git, flake8-mutable]

Note: The RedHatQE plugin dependency tracks the main branch intentionally to pick up the latest rules without waiting for a release.

What M511 Catches

M511 flags code like this:

# Bad — mutable default argument
def process_items(items: list[str] = []) -> None:
    items.append("new")  # mutates the shared default

The correct pattern, used throughout the docsfy codebase, is to use immutable defaults or factory functions:

# Good — immutable default with Pydantic Field factory
navigation: list[NavGroup] = Field(default_factory=list)

mypy — Strict Type Checking

The project enforces strict static type checking with mypy. All source code (excluding tests) must have complete, correct type annotations.

Configuration

The mypy configuration in pyproject.toml enables nearly every strictness flag:

[tool.mypy]
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
no_implicit_optional = true
show_error_codes = true
warn_unused_ignores = true
strict_equality = true
extra_checks = true
warn_unused_configs = true
warn_redundant_casts = true

Key strictness options and what they enforce:

Option Effect
disallow_untyped_defs Every function must have type annotations
disallow_any_generics Generic types (e.g., list, dict) must specify their type parameters
disallow_incomplete_defs Partially annotated functions are rejected
no_implicit_optional None defaults don't automatically make the type optional
strict_equality Prevents comparing unrelated types with ==
extra_checks Enables additional miscellaneous checks

Pre-commit Integration

The mypy pre-commit hook excludes tests and ships with type stub packages for the project's dependencies:

- repo: https://github.com/pre-commit/mirrors-mypy
  rev: v1.19.1
  hooks:
    - id: mypy
      exclude: (tests/)
      additional_dependencies:
        [types-requests, types-PyYAML, types-colorama, types-aiofiles, pydantic, types-Markdown]

Note: Tests are excluded from mypy checking (exclude: (tests/)) to allow more flexible typing in test code, where mocks and fixtures often don't carry precise types.

Code Examples

The strict configuration shapes how all code in the project is written. Every function signature carries full type annotations, including return types:

# From src/docsfy/storage.py
async def update_project_status(
    name: str,
    status: str,
    last_commit_sha: str | None = None,
    page_count: int | None = None,
    error_message: str | None = None,
    plan_json: str | None = None,
) -> None:

Pydantic models use modern union syntax and Literal types for constrained values:

# From src/docsfy/models.py
from typing import Literal

class GenerateRequest(BaseModel):
    repo_url: str | None = Field(
        default=None, description="Git repository URL (HTTPS or SSH)"
    )
    ai_provider: Literal["claude", "gemini", "cursor"] | None = None
    ai_cli_timeout: int | None = Field(default=None, gt=0)

Return types are always explicit, including for functions that return complex structures:

# From src/docsfy/storage.py
async def get_project(name: str) -> dict[str, str | int | None] | None:
    ...

async def list_projects() -> list[dict[str, str | int | None]]:
    ...

Security Scanning

The project uses two complementary tools to prevent secrets from entering the repository.

detect-secrets

detect-secrets by Yelp scans staged files for high-entropy strings, API keys, passwords, and other potential secrets before they are committed:

- repo: https://github.com/Yelp/detect-secrets
  rev: v1.5.0
  hooks:
    - id: detect-secrets

This hook runs with default detection rules and blocks commits that contain patterns matching known secret formats.

gitleaks

gitleaks provides an additional layer of secret scanning that inspects the full git history, not just staged changes. This catches secrets that may have been committed before detect-secrets was installed.

- repo: https://github.com/gitleaks/gitleaks
  rev: v8.30.0
  hooks:
    - id: gitleaks

The gitleaks configuration in .gitleaks.toml extends the default ruleset and allowlists the test file that intentionally contains mock URL patterns:

[extend]
useDefault = true

[allowlist]
paths = [
    '''tests/test_repository\.py''',
]

Warning: If gitleaks flags a legitimate secret that was accidentally committed, simply removing it from the current code is not enough — the secret remains in git history. Rotate the credential immediately and consider rewriting history with git filter-repo.

Tox — Unused Code Detection

The project uses tox to run a dead code detection pass alongside the regular test suite. The tox.toml configuration defines two environments:

skipsdist = true

envlist = ["unused-code", "unittests"]

[env.unused-code]
deps = ["python-utility-scripts"]
commands = [
  [
    "pyutils-unusedcode",
  ],
]

[env.unittests]
deps = ["uv"]
commands = [
  [
    "uv",
    "run",
    "--extra",
    "dev",
    "pytest",
    "-n",
    "auto",
    "tests",
  ],
]

The unused-code Environment

The unused-code environment installs python-utility-scripts and runs pyutils-unusedcode, which analyzes the codebase for functions, classes, imports, and variables that are defined but never referenced. This helps prevent code rot and keeps the codebase lean.

Run it with:

tox -e unused-code

The unittests Environment

The unittests environment runs the full pytest suite using uv as the package manager, with parallel execution via pytest-xdist:

tox -e unittests

To run both environments:

tox

Tip: The unused-code check is fast and non-destructive. Consider running it after removing or refactoring features to catch any newly orphaned code.

Workflow Summary

The following diagram shows when each tool runs in the development workflow:

git commit
  └─► pre-commit hooks
        ├─► Standard checks (whitespace, AST, merge conflicts, ...)
        ├─► Ruff lint + format
        ├─► Flake8 M511 (mutable defaults)
        ├─► detect-secrets
        ├─► gitleaks
        └─► mypy strict type checking

tox (on-demand / CI)
  ├─► unused-code (pyutils-unusedcode)
  └─► unittests (pytest -n auto)

pre-commit.ci (on push / PR)
  └─► Runs all pre-commit hooks in CI

All pre-commit hooks run automatically on every commit. The tox environments (unused-code and unittests) are designed to be run on demand during development or as part of a CI pipeline. The pre-commit.ci integration ensures that all hooks also run on every push and pull request.