diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
index 3277889..a655c91 100644
--- a/.github/workflows/action.yml
+++ b/.github/workflows/action.yml
@@ -4,27 +4,30 @@ on:
workflow_call:
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
action_hook:
runs-on: ubuntu-latest
name: Docstring Linter
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
name: Git Checkout
- - uses: actions/setup-python@v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
name: Install Python ${{ vars.PYTHON_VERSION }}
with:
python-version: ${{ vars.PYTHON_VERSION }}
- - uses: bastgau/docstring-linter@main
+ - uses: ./
name: Execute Docstring Linter via GitHub Action
with:
paths: src/
format: github-annotations
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e889890..4d4e615 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,23 +4,26 @@ on:
workflow_call:
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
build:
runs-on: ubuntu-latest
name: Build
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
name: Git Checkout
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
- name: Build package
run: uv build
- - uses: actions/upload-artifact@v7
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
name: Upload build artifacts
with:
name: dist
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6468267..5f3e899 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,9 @@ on:
branches: [main, develop]
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
lint:
uses: ./.github/workflows/lint.yml
@@ -16,7 +19,8 @@ jobs:
needs: lint
uses: ./.github/workflows/tests.yml
name: Tests
- secrets: inherit
+ secrets:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build:
needs: tests
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index f4c3406..d0eceed 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -4,6 +4,9 @@ on:
workflow_call:
workflow_dispatch:
+permissions:
+ contents: read
+
env:
GROUP: "github-src"
@@ -12,16 +15,16 @@ jobs:
runs-on: ubuntu-latest
name: Lint
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
name: Git Checkout
- - uses: actions/setup-python@v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
name: Install Python ${{ vars.PYTHON_VERSION }}
with:
python-version: ${{ vars.PYTHON_VERSION }}
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
@@ -29,16 +32,16 @@ jobs:
run: uv sync --no-dev --group ${{ env.GROUP }}
- name: Install dotenv-linter
- run: curl -sSfL https://raw.githubusercontent.com/dotenv-linter/dotenv-linter/master/install.sh | sudo sh -s -- -b /usr/local/bin v4.0.0
-
- - uses: j178/prek-action@v3.0.1
- name: Execute Prek on push
- if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
- with:
- extra-args: --group ${{ env.GROUP }} --all-files
-
- - uses: j178/prek-action@v3.0.1
- name: Execute Prek on pull_request
- if: github.event_name == 'pull_request'
+ env:
+ DOTENV_LINTER_VERSION: v4.0.0
+ DOTENV_LINTER_SHA256: 9e2f1f6eb88a71ac15ef209f78f5a68644db82f132d2677112b4f80ec2bd2127
+ run: |
+ archive="$RUNNER_TEMP/dotenv-linter.tar.gz"
+ curl -sSfL -o "$archive" "https://github.com/dotenv-linter/dotenv-linter/releases/download/${DOTENV_LINTER_VERSION}/dotenv-linter-linux-x86_64.tar.gz"
+ echo "${DOTENV_LINTER_SHA256} $archive" | sha256sum -c -
+ sudo tar -xzf "$archive" -C /usr/local/bin dotenv-linter
+
+ - uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1
+ name: Execute Prek
with:
- extra-args: --group ${{ env.GROUP }} --files origin/${{ github.base_ref }}..HEAD
+ extra-args: --verbose --group ${{ env.GROUP }} --all-files
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 0588ecc..bc2fe82 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -19,7 +19,8 @@ jobs:
needs: lint
uses: ./.github/workflows/tests.yml
name: Tests
- secrets: inherit
+ secrets:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
guard:
needs: tests
@@ -27,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
name: Guard
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
name: Git Checkout
with:
fetch-depth: 0
@@ -45,13 +46,13 @@ jobs:
runs-on: ubuntu-latest
name: Build
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
name: Git Checkout
with:
fetch-depth: 0
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
@@ -62,7 +63,7 @@ jobs:
run: uv build
- name: Upload distributions
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
@@ -74,13 +75,13 @@ jobs:
permissions:
contents: write
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
name: Git Checkout
with:
fetch-depth: 0
- name: Download distributions
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
@@ -119,7 +120,7 @@ jobs:
- name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
- uses: softprops/action-gh-release@v3
+ uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
name: Release ${{ github.ref_name }}
files: |
@@ -138,13 +139,13 @@ jobs:
id-token: write
steps:
- name: Download distributions
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Publish to PyPI
run: uv publish --trusted-publishing always
diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml
index 378fc03..1ba1d3c 100644
--- a/.github/workflows/pull-request-validation.yml
+++ b/.github/workflows/pull-request-validation.yml
@@ -4,13 +4,17 @@ on:
pull_request:
types: [opened, edited, synchronize, reopened]
+permissions:
+ contents: read
+ pull-requests: read
+
jobs:
lint-pr-title:
if: github.base_ref != 'main'
runs-on: ubuntu-latest
name: Lint PR title
steps:
- - uses: amannn/action-semantic-pull-request@v6
+ - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -30,10 +34,10 @@ jobs:
runs-on: ubuntu-latest
name: Lint commits
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- - uses: wagoid/commitlint-github-action@v6
+ - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1
with:
configFile: .commitlintrc.mjs
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index cdfa9ce..936cde0 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,8 +2,14 @@ name: Tests
on:
workflow_call:
+ secrets:
+ CODECOV_TOKEN:
+ required: false
workflow_dispatch:
+permissions:
+ contents: read
+
env:
GROUP: "github-tests"
@@ -12,40 +18,33 @@ jobs:
runs-on: ubuntu-latest
name: Tests
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
name: Git Checkout
- - uses: actions/setup-python@v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
name: Install Python ${{ vars.PYTHON_VERSION }}
with:
python-version: ${{ vars.PYTHON_VERSION }}
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
- name: Install dependencies
run: uv sync --no-dev --group ${{ env.GROUP }}
- - uses: j178/prek-action@v3.0.1
- name: Execute Prek on push
- if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
- with:
- extra-args: --group ${{ env.GROUP }} --all-files
-
- - uses: j178/prek-action@v3.0.1
- name: Execute Prek on pull_request
- if: github.event_name == 'pull_request'
+ - uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1
+ name: Execute Prek
with:
- extra-args: --group ${{ env.GROUP }} --files origin/${{ github.base_ref }}..HEAD
+ extra-args: --verbose --group ${{ env.GROUP }} --all-files
- - uses: codecov/codecov-action@v7
+ - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
name: Upload coverage to Codecov
with:
token: ${{ secrets.CODECOV_TOKEN }}
- - uses: actions/upload-artifact@v7
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
name: Upload coverage report as artifact
with:
name: coverage-report
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2d499a6..f38b945 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,5 +1,5 @@
repos:
- # ── Hooks génériques ─────────────────────────────────────────────────────────
+ # ── Generic hooks ────────────────────────────────────────────────────────────
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
@@ -17,7 +17,7 @@ repos:
- id: mixed-line-ending
args: ['--fix=lf']
stages: [pre-commit]
- groups: [locals]
+ groups: [local]
- id: check-json
stages: [pre-commit]
groups: [local,github-src]
@@ -61,7 +61,7 @@ repos:
files: \.env(-example)?$
groups: [local,github-src]
- # ── Fichiers interdits ───────────────────────────────────────────────────────
+ # ── Forbidden files ──────────────────────────────────────────────────────────
- repo: local
hooks:
- id: forbid-files
@@ -131,6 +131,7 @@ repos:
language: system
stages: [pre-commit]
types: [python]
+ files: ^src/
groups: [local,github-src]
# ── Docstring linters (src/) ──────────────────────────────────────────────────
@@ -187,7 +188,7 @@ repos:
files: ^tests/
groups: [local,github-tests]
- # ── Tests & qualite ──────────────────────────────────────────────────────────
+ # ── Tests & quality ──────────────────────────────────────────────────────────
- repo: local
hooks:
- id: pytest-coverage-all-files
@@ -201,7 +202,7 @@ repos:
- id: vulture-all-files
name: vulture-all-files
- entry: uv run vulture tests/ src/ .vulture
+ entry: uv run vulture tests/ src/
language: system
stages: [pre-commit]
pass_filenames: false
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index 089d368..37734eb 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -1,6 +1,6 @@
- id: docstring-linter
name: docstring-linter
description: Validate Python docstrings against style conventions.
- entry: docstring-linter
+ entry: docstring-linter --force-exclude
language: python
types: [python]
diff --git a/.vulture b/.vulture
deleted file mode 100644
index 1698c27..0000000
--- a/.vulture
+++ /dev/null
@@ -1,8 +0,0 @@
-# pylint: skip-file
-
-"""A Python file parsed by vulture to suppress false positives by declaring symbols that are legitimately defined."""
-
-SPHINX # type: ignore[name-defined] # unused variable (src/linter/config.py:27)
-PEP257 # type: ignore[name-defined] # unused variable (src/linter/config.py:28)
-YELLOW # type: ignore[name-defined] # unused variable (src/linter/reporter.py:30)
-GREEN # type: ignore[name-defined] # unused variable (src/linter/reporter.py:31)
diff --git a/README.md b/README.md
index 01e0fe7..e76e78b 100644
--- a/README.md
+++ b/README.md
@@ -2,27 +2,54 @@
## Overview
-Python linter that validates Google-style docstrings against the actual implementation of functions, methods, classes, and modules.
+Python linter that checks Google-style docstrings against the code they document: the signature, the body and the class they belong to.
-It can detect mismatches between documented arguments, return values, raised exceptions, and type annotations.
+- **Consistency with the code**: every parameter documented, no phantom parameter, documented types matching the annotations, `Returns:` and `Yields:` matching what the function does, every exception raised documented, class attributes documented.
+- **House style, as strict as you want**: every construct is a policy (`required`, `forbidden` or `optional`), from the `Args:` section to the period ending the summary. Two conventions give the starting point: `strict` (default) and `google`, which follows the Google Python Style Guide.
+- **Layout**: section order and names, blank lines, indentation, entry spacing.
+- **Per-path settings**, a configuration that rejects anything it does not recognize, and no runtime dependency.
-## What is checked?
+## Requirements and installation
-Examples include:
+Python 3.14 is required. The package is not published on PyPI yet: install it from GitHub.
-- Parameter documentation mismatches
-- Return documentation mismatches
-- Raised exception mismatches
-- Yielded value mismatches
-- Class attribute mismatches
-- Missing or empty docstrings
-- Duplicate argument entries
-- Missing or invalid summary lines
-- Missing required sections
-- Section ordering and formatting issues
-- Google-style formatting issues
+```bash
+pip install "git+https://github.com/bastgau/docstring-linter.git"
+# or, as a standalone tool
+uv tool install "git+https://github.com/bastgau/docstring-linter.git"
+```
+
+## Quick start
+
+```python
+# src/shop.py
+"""Shopping cart helpers."""
+
+
+def total(prices: list[float], discount: float = 0.0) -> float:
+ """Compute the total price.
-See the rule reference for the complete list.
+ Args:
+ prices (list[int]): Item prices.
+
+ """
+ if discount < 0:
+ raise ValueError(discount)
+ return sum(prices) * (1 - discount)
+```
+
+```console
+$ docstring-linter src/
+Config: defaults (no config file found)
+
+File "/path/to/project/src/shop.py", line 4, in total
+ [args_section] Arg 'discount' in signature but not documented.
+ [args_match] Arg 'prices' type mismatch: signature='list[float]', docstring='list[int]'.
+ [returns_section] Missing 'Returns:' section. Signature declares -> float.
+ [raises_section] 'ValueError' raised in code but not documented in 'Raises:'.
+
+✗ 4 errors in 1 file (1 file checked).
+```
## Usage
@@ -33,12 +60,15 @@ docstring-linter src/module.py
# Lint a directory
docstring-linter src/
-# List available rules
+# List the rules, policies and options, with their value in the current config
docstring-linter --list-rules
# Compact one-line-per-error report
docstring-linter src/ --format text
+# Number of errors per rule
+docstring-linter src/ --statistics
+
# JSON report (stdout)
docstring-linter src/ --format json
@@ -47,9 +77,6 @@ docstring-linter src/ --format github-annotations
# Use an explicit config file
docstring-linter src/ --config pyproject.toml
-
-# CI/CD
-docstring-linter src/ && echo "OK" || echo "FAIL"
```
### Options
@@ -57,25 +84,45 @@ docstring-linter src/ && echo "OK" || echo "FAIL"
| Option | Description |
|--------|-------------|
| `--exclude` | Glob patterns to exclude. Overrides config file. |
-| `--workers` | Number of parallel workers (0 = auto, 1 = sequential). |
+| `--force-exclude` | Apply the exclusions to files named on the command line too. Without it, a file named explicitly is always linted. |
+| `--workers` | Number of parallel workers. `0` (default) = one per usable CPU, sequential below 50 files; `1` = sequential. |
| `--format` | Output format: `traceback` (default), `text`, `json`, or `github-annotations`. |
+| `--statistics` | Report the number of errors per rule instead of each error. Only with the `traceback` and `text` formats. |
| `--list-rules` | Display all available rules and exit. |
| `--config` | Explicit path to a config file (any `.toml`). |
Command-line options always override configuration file values.
-## Output formats
+### Exit codes
-`traceback` is the default. It prints one location header per entity, in the same shape as a Python traceback, which editors turn into a clickable link:
+| Code | Meaning |
+|------|---------|
+| `0` | No error found. |
+| `1` | Lint errors found. |
+| `2` | Invalid configuration, missing path, or a file that could not be read or parsed. Details are printed on stderr. |
+| `3` | Internal error while linting a file. The file is named on stderr with the traceback, the other files are still linted. |
+
+### Suppressing errors
+
+A comment at the end of the `def` or `class` line silences errors of that function, method or class only. For a module, put it at the end of the opening line of the module docstring.
+
+```python
+def legacy_api(x): # docstring-linter: ignore[args_match, returns_section]
+ ...
-```
-File "/workspaces/docstring-linter/example/docstring_format_reference.py", line 84, in divide
- [args_match] Arg 'numerator' missing type. Expected '(int)'.
- [returns_section] Missing 'Returns:' section. Signature declares -> float.
-✗ 2 errors in 1 file (2 files checked).
+def generated(x): # docstring-linter: ignore
+ ...
```
+The brackets take rule and policy names, always-on rules included; without brackets, every error of the entity is silenced. An unknown name is reported on stderr with its file and line, and the run exits with code 2. On a signature spread over several lines, the comment goes on the `def` line. `# noqa` is not recognized.
+
+## Output formats
+
+Colors are only used when the output is a terminal, and never when the `NO_COLOR` environment variable is set to a non-empty value. The `Config:` line of the `traceback` and `text` formats goes to stderr, so a redirected report only holds the report.
+
+`traceback` is the default, shown in the quick start. It prints one location header per entity, in the same shape as a Python traceback, which editors turn into a clickable link. Errors point to the line of the `def` or `class`.
+
Other output formats
@@ -83,58 +130,65 @@ File "/workspaces/docstring-linter/example/docstring_format_reference.py", line
`text` keeps the compact layout, one line per error grouped by file:
```
-example/docstring_format_reference.py
- L84 divide [args_match] Arg 'numerator' missing type. Expected '(int)'.
- L84 divide [returns_section] Missing 'Returns:' section. Signature declares -> float.
+src/shop.py
+ L4 total [args_section] Arg 'discount' in signature but not documented.
+ L4 total [args_match] Arg 'prices' type mismatch: signature='list[float]', docstring='list[int]'.
+ L4 total [returns_section] Missing 'Returns:' section. Signature declares -> float.
+ L4 total [raises_section] 'ValueError' raised in code but not documented in 'Raises:'.
-✗ 2 errors in 1 file (2 files checked).
+✗ 4 errors in 1 file (1 file checked).
```
-`json` produces a machine-readable report suitable for automation and tooling.
+`json` produces a machine-readable report suitable for automation and tooling:
-```
+```json
{
"summary": {
- "files_checked": 2,
- "total_errors": 2,
+ "files_checked": 1,
+ "total_errors": 4,
"files_with_errors": 1
},
"errors": [
{
- "filepath": "example/docstring_format_reference.py",
- "line": 84,
- "entity_name": "divide",
- "node_type": "function",
- "rule": "args_match",
- "message": "Arg 'numerator' missing type. Expected '(int)'."
- },
- {
- "filepath": "example/docstring_format_reference.py",
- "line": 84,
- "entity_name": "divide",
+ "filepath": "src/shop.py",
+ "line": 4,
+ "entity_name": "total",
"node_type": "function",
- "rule": "returns_section",
- "message": "Missing 'Returns:' section. Signature declares -> float."
+ "rule": "args_section",
+ "message": "Arg 'discount' in signature but not documented."
}
]
}
```
-`github-annotations` produces GitHub workflow annotations displayed directly in pull requests and CI logs.
+The `errors` list above is shortened to its first entry.
+
+`github-annotations` produces GitHub workflow annotations displayed directly in pull requests and CI logs:
```
-::error file=example/docstring_format_reference.py,line=84,title=args_match::Arg 'numerator' missing type. Expected '(int)'.
-::error file=example/docstring_format_reference.py,line=84,title=returns_section::Missing 'Returns:' section. Signature declares -> float.
-2 errors in 1 file (2 files checked).
+::error file=src/shop.py,line=4,title=args_section::Arg 'discount' in signature but not documented.
+::error file=src/shop.py,line=4,title=args_match::Arg 'prices' type mismatch: signature='list[float]', docstring='list[int]'.
+::error file=src/shop.py,line=4,title=returns_section::Missing 'Returns:' section. Signature declares -> float.
+::error file=src/shop.py,line=4,title=raises_section::'ValueError' raised in code but not documented in 'Raises:'.
+4 errors in 1 file (1 file checked).
```
## Configuration
-Every key has a built-in default, so no config file is required at all.
+Every key has a built-in default, so no config file is required at all. The configuration is read from `[tool.docstring-linter]` in `pyproject.toml`, or from a standalone `.docstring-linter.toml`.
+
+The defaults enforce a strict house style: every type repeated in the docstring, `Returns: None` on `-> None` functions, every exception and attribute documented. To follow the Google Python Style Guide instead, start from the `google` convention:
-The full list of options is available on the [configuration](/docs/configuration.md) page.
+```toml
+[tool.docstring-linter]
+convention = "google"
+```
+
+Among other things, it leaves types to the signature, accepts one-line docstrings without sections on fully annotated functions, documents properties like attributes, and lets `__init__` parameters live in the class docstring. Any key written in the file overrides the convention.
+
+The full list of options, the conventions and the per-path overrides are described on the [configuration](/docs/configuration.md) page.
### Rule Reference
@@ -154,8 +208,11 @@ repos:
hooks:
- id: docstring-linter
files: ^src/
+ language_version: python3.14 # when python3.14 is not the default interpreter
```
+The hook runs with `--force-exclude`, so the `exclude` patterns of the configuration still apply to the files pre-commit passes.
+
Then install the hook:
```bash
@@ -184,3 +241,28 @@ prek install
| `paths` | `src/` | Files or directories to lint. |
| `format` | `github-annotations` | Output format: `traceback`, `text`, `json`, or `github-annotations`. |
| `extra-args` | `""` | Additional arguments passed to `docstring-linter`. |
+
+## Known limitations
+
+- Functions and classes defined inside a function are not checked.
+- Code under `if __name__ == "__main__":` and `@overload` signatures are skipped.
+- `Raises:` only sees the explicit `raise` statements of the function body: exceptions raised by called functions and the `raise` of a plain variable are not detected.
+- Class attributes are read from class-level assignments and from `self.x = ...` in `__init__` only: attributes created in other methods are not detected.
+- Type aliases are not resolved, and Sphinx roles such as ``:class:`Path` `` are not recognized in documented types.
+- `exclude_overridden` only recognizes `@override`, not a method overridden through inheritance.
+- Every error points to the `def` or `class` line, not to the docstring line at fault.
+- Google style only. The content of `Warn:` and `Warns:` sections is not checked.
+- No baseline yet.
+
+## How it compares
+
+State of the tools checked in September 2026.
+
+| Tool | Focus |
+|------|-------|
+| [ruff](https://docs.astral.sh/ruff/) `D` rules | Docstring format (PEP 257, Google, NumPy conventions). Its `DOC` rules, which compare the docstring with the code, are still in preview (7 rules in ruff 0.16.8). |
+| [pydoclint](https://github.com/jsh9/pydoclint) | Consistency between docstring and code (arguments, returns, yields, raises, class attributes), with a baseline and `noqa` support. |
+| [docsig](https://github.com/jshwi/docsig) | Consistency between the documented parameters and the signature. |
+| docstring-linter | Consistency with the code, plus a configurable house style: tri-state policies for every section and construct, layout rules, and the `strict` and `google` conventions. Google style only. |
+
+docstring-linter does not replace ruff: it can run next to its `D` rules, with the rules both tools check turned off on one side.
diff --git a/TESTS.md b/TESTS.md
index f576732..4c21ec4 100644
--- a/TESTS.md
+++ b/TESTS.md
@@ -1,6 +1,6 @@
# Test Plan
-This file lists the 369 tests of the `docstring-linter` project. Each entry shows the test file, the function name, and a description of the case covered. Tests are organized by tested module and by rule or feature.
+This file lists the test functions of the `docstring-linter` project, a parametrized test counting once. Each entry shows the test file, the function name, and a description of the case covered. `tests/linter/test_registries.py` checks that every test function has its row. Tests are organized by tested module and by rule or feature.
## test_parser.py -- GoogleStyleParser
@@ -21,6 +21,9 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_docstring_parser.py` | `test_parse_returns_none_keyword` | Returns section containing only 'None': type_annotation is 'None', description is None. |
| `test_docstring_parser.py` | `test_parse_returns_bare_type` | Returns section holding a bare type, no colon: type is read, description is None. |
| `test_docstring_parser.py` | `test_parse_returns_bare_description` | Returns section holding prose, no colon: description is read, type is None. |
+| `test_docstring_parser.py` | `test_parse_returns_prose_with_colon` | Returns line whose text before the colon is prose: the whole line is the description. |
+| `test_docstring_parser.py` | `test_parse_returns_continuation_lines` | Returns description spread over several lines: the lines are joined. |
+| `test_docstring_parser.py` | `test_parse_returns_single_word_prose` | Returns section holding a single word that is not a type: read as the description. |
| `test_docstring_parser.py` | `test_parse_no_returns_section` | Docstring without Returns section: returns field is None. |
| `test_docstring_parser.py` | `test_parse_raises_single` | Single Raises entry: exception_type and description populated. |
| `test_docstring_parser.py` | `test_parse_raises_multiline_description` | Raises entry with continuation line: description is concatenated. |
@@ -29,27 +32,19 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_docstring_parser.py` | `test_parse_attributes_without_type` | Attribute without type annotation: type_annotation is None. |
| `test_docstring_parser.py` | `test_parse_attributes_multiline_description` | Attribute with continuation line: description is concatenated. |
| `test_docstring_parser.py` | `test_parse_attributes_multiple` | Multiple attributes: all are returned in order. |
-| `test_docstring_parser.py` | `test_parse_example_section` | Docstring with Example section: examples list is populated. |
-| `test_docstring_parser.py` | `test_parse_examples_section` | Docstring with Examples section (plural): examples list is populated. |
+| `test_docstring_parser.py` | `test_parse_examples_section_kept_apart` | Examples section: its content leaks into neither the description nor the args. |
| `test_docstring_parser.py` | `test_parse_unknown_section_ignored` | Unknown section name: not parsed, does not affect other fields. |
| `test_docstring_parser.py` | `test_unknown_section_detected` | Section name not in known list: captured in unknown_sections. |
| `test_docstring_parser.py` | `test_unknown_section_known_not_flagged` | Known section: not captured in unknown_sections. |
| `test_docstring_parser.py` | `test_unknown_section_multiple` | Multiple unknown sections in parsed docstring: all captured. |
+| `test_docstring_parser.py` | `test_parse_alias_read_as_canonical` | Parameters: alias of Args, its entries are parsed as args and it is not unknown. |
+| `test_docstring_parser.py` | `test_parse_args_and_other_parameters_joined` | Args and Other Parameters sections: entries of both end up in args. |
+| `test_docstring_parser.py` | `test_parse_keyword_args` | Keyword Arguments: entries parsed into keyword_args, not into args. |
+| `test_docstring_parser.py` | `test_parse_free_text_sections` | Warning and See Also: known sections, neither unknown nor merged into the description. |
+| `test_docstring_parser.py` | `test_parse_note_and_notes_distinct` | Note and Notes: two known sections, neither is an alias nor unknown. |
+| `test_docstring_parser.py` | `test_parse_exceptions_is_unknown` | Exceptions: not a Napoleon section, reported as unknown and not read as Raises. |
| `test_docstring_parser.py` | `test_parse_lowercase_section_not_recognized` | Lowercase section name (args: instead of Args:): not recognized, no args parsed. |
-### style
-
-| Fichier | Fonction | Description |
-|---|---|---|
-| `test_docstring_parser.py` | `test_parser_style_property` | Style property: returns DocstringStyle.GOOGLE. |
-
-### get_parser
-
-| Fichier | Fonction | Description |
-|---|---|---|
-| `test_docstring_parser.py` | `test_get_parser_google` | get_parser(GOOGLE): returns a GoogleStyleParser instance. |
-| `test_docstring_parser.py` | `test_get_parser_unsupported` | get_parser with unsupported style: raises ValueError with style name. |
-
---
## test_ast_parser.py -- ast_parser
@@ -58,14 +53,15 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| Fichier | Fonction | Description |
|---|---|---|
-| `test_ast_parser.py` | `test_extract_args_no_args` | Only self in signature: returns empty list because self is always skipped. |
+| `test_ast_parser.py` | `test_extract_args_no_args` | Method with only self in its signature: returns empty list because the first parameter is skipped. |
| `test_ast_parser.py` | `test_extract_args_positional_with_type_and_default` | Positional arg with type annotation and default value: all three fields are populated. |
| `test_ast_parser.py` | `test_extract_args_positional_without_type` | Positional arg with no type annotation: type_annotation is None. |
| `test_ast_parser.py` | `test_extract_args_positional_without_default` | Positional arg with no default value: default is None. |
| `test_ast_parser.py` | `test_extract_args_keyword_only` | Keyword-only arg (after bare *): extracted with correct name and type. |
| `test_ast_parser.py` | `test_extract_args_keyword_only_with_default` | Keyword-only arg with a default value: default is correctly extracted. |
-| `test_ast_parser.py` | `test_extract_args_skips_self_and_cls` | Both self and cls are always excluded from the result, regardless of position. |
-| `test_ast_parser.py` | `test_extract_args_skips_cls_in_kwonly` | Keyword-only arg named cls is excluded, just like in positional position. |
+| `test_ast_parser.py` | `test_extract_args_skips_first_param_whatever_its_name` | Method: the first positional parameter is dropped by position, not by name. |
+| `test_ast_parser.py` | `test_extract_args_keeps_self_and_cls_names_on_functions` | Plain function: parameters named self or cls are regular parameters. |
+| `test_ast_parser.py` | `test_extract_args_skips_only_the_first_param` | Method with a second parameter named cls: only the first parameter is dropped. |
| `test_ast_parser.py` | `test_extract_args_mixed_positional_and_keyword_only` | Mix of positional and keyword-only args: both are returned in declaration order. |
| `test_ast_parser.py` | `test_extract_args_vararg_and_kwarg` | *args and **kwargs are extracted with their stars in the name. |
| `test_ast_parser.py` | `test_extract_args_vararg_without_annotation` | *args without annotation: type_annotation is None. |
@@ -74,16 +70,27 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_ast_parser.py` | `test_extract_args_positional_only_default_alignment` | Defaults align by the end of posonlyargs + args combined. |
| `test_ast_parser.py` | `test_extract_args_positional_only_with_default` | Positional-only arg with a default: default is correctly extracted. |
-### _extract_raises
+### _scan_body -- raises
| Fichier | Fonction | Description |
|---|---|---|
| `test_ast_parser.py` | `test_extract_raises_none` | Function with no raise statements: returns empty list. |
| `test_ast_parser.py` | `test_extract_raises_simple_call` | Raise ValueError("msg"): detected by the exception class name. |
-| `test_ast_parser.py` | `test_extract_raises_bare_name` | Raise err where err is a plain name (not a call): the name itself is recorded. |
-| `test_ast_parser.py` | `test_extract_raises_bare_raise_ignored` | Bare re-raise (raise with no argument): ignored because there is no exception type. |
+| `test_ast_parser.py` | `test_extract_raises_variable_ignored` | Raise err where err is a variable, not bound by an except clause: ignored. |
+| `test_ast_parser.py` | `test_extract_raises_bare_class_name` | Raise ValueError without call: the class name is recorded. |
+| `test_ast_parser.py` | `test_extract_raises_lowercase_factory_ignored` | Raise make_error('x'): a lowercase callable is not an exception class, ignored. |
+| `test_ast_parser.py` | `test_extract_raises_bare_raise_ignored` | Bare raise outside any handler: ignored because there is no exception type. |
| `test_ast_parser.py` | `test_extract_raises_deduplicates` | Same exception raised twice: appears only once in the result list. |
| `test_ast_parser.py` | `test_extract_raises_multiple_distinct` | Two different exceptions raised: both are present in the result. |
+| `test_ast_parser.py` | `test_scan_body_reraise_of_caught_name` | Raise err inside 'except ValueError as err': reported as ValueError, not as err. |
+| `test_ast_parser.py` | `test_scan_body_reraise_of_caught_tuple` | Raise err inside 'except (KeyError, mod.Error) as err': every caught type is reported. |
+| `test_ast_parser.py` | `test_scan_body_bare_raise_in_handler` | Bare raise inside 'except (KeyError, mod.Error)': every caught type is reported. |
+| `test_ast_parser.py` | `test_scan_body_bare_raise_in_nested_handler` | Bare raise in a handler nested in another: reports the inner caught type only. |
+| `test_ast_parser.py` | `test_scan_body_bare_raise_in_untyped_handler_ignored` | Bare raise inside a bare 'except:': ignored because no type is caught. |
+| `test_ast_parser.py` | `test_scan_body_dotted_exception` | Raise errors.ValidationError(...): reported by its last name segment. |
+| `test_ast_parser.py` | `test_scan_body_lowercase_attribute_ignored` | Raise self.error: not an exception class name, ignored. |
+| `test_ast_parser.py` | `test_scan_body_nested_function_ignored` | Raise and yield inside a nested function or lambda: not attributed to the outer function. |
+| `test_ast_parser.py` | `test_scan_body_source_order` | Several raises: reported in source order, with the line of the first occurrence. |
### _is_empty_init
@@ -94,6 +101,7 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_ast_parser.py` | `test_is_empty_init_with_positional_arg` | __init__(self, name: str): has a real positional arg, not empty. |
| `test_ast_parser.py` | `test_is_empty_init_with_kwonly_arg` | __init__(self, *, name: str): has a keyword-only arg, not empty. |
| `test_ast_parser.py` | `test_is_empty_init_with_body` | __init__(self) with self.x = 1 in the body: has real statements, not empty. |
+| `test_ast_parser.py` | `test_is_empty_init_with_star_args` | __init__ taking *args or **kwargs: has parameters, not empty (2 cases). |
### _extract_class_attributes
@@ -105,6 +113,9 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_ast_parser.py` | `test_extract_class_attributes_skips_dunder` | Dunder assignments like __slots__ are not treated as attributes. |
| `test_ast_parser.py` | `test_extract_class_attributes_skips_constants` | All-uppercase names (constants) are not treated as attributes. |
| `test_ast_parser.py` | `test_extract_class_attributes_none` | Class with no attributes: returns empty list. |
+| `test_ast_parser.py` | `test_extract_class_attributes_tuple_self_assignment` | self.a, *self.b = ... in __init__: every unpacked self attribute is extracted. |
+| `test_ast_parser.py` | `test_extract_class_attributes_tuple_class_assignment` | a, b = ... in the class body: both names are extracted. |
+| `test_ast_parser.py` | `test_extract_class_attributes_ignores_nested_function` | self.x assigned inside a function nested in __init__: not a class attribute. |
### parse_file
@@ -118,6 +129,60 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_ast_parser.py` | `test_is_generator_with_yield` | Function with yield: is_generator is True. |
| `test_ast_parser.py` | `test_is_generator_with_yield_from` | Function with yield from: is_generator is True. |
| `test_ast_parser.py` | `test_is_generator_without_yield` | Function without yield: is_generator is False. |
+| `test_ast_parser.py` | `test_is_generator_nested_generator_not_propagated` | Function defining a nested generator: the outer function is not a generator. |
+| `test_ast_parser.py` | `test_parse_file_functions_under_compound_statements` | Functions defined under if, else, try, except, with and match: all extracted. |
+| `test_ast_parser.py` | `test_parse_file_method_under_if_in_class` | Method defined under an if inside a class body: extracted as a method of that class. |
+| `test_ast_parser.py` | `test_parse_file_skips_overload_stubs` | @overload and @typing.overload stubs: not extracted, the implementation is. |
+| `test_ast_parser.py` | `test_parse_file_staticmethod_keeps_first_param` | @staticmethod: the first parameter is a regular parameter, even when named self. |
+| `test_ast_parser.py` | `test_parse_file_skips_main_guard_body` | Functions and classes under 'if __name__ == "__main__":': skipped, the else branch is kept. |
+| `test_ast_parser.py` | `test_parse_file_records_decorators` | Decorators: recorded by their last name segment, sorted, attribute and call forms included. |
+| `test_ast_parser.py` | `test_parse_file_links_class_and_init` | Class with __init__: the class carries the __init__ parameters, __init__ carries the class docstring. |
+
+## test_end_to_end.py -- lint_file on real sources
+
+| Fichier | Fonction | Description |
+|---|---|---|
+| `test_end_to_end.py` | `test_nested_generator_does_not_make_outer_a_generator` | Function defining a nested generator: documented with Returns, no error. |
+| `test_end_to_end.py` | `test_reraise_of_caught_exception` | Raise err inside 'except ValueError as err': ValueError documented, no error. |
+| `test_end_to_end.py` | `test_bare_reraise_requires_caught_exception` | Bare raise inside 'except ValueError': ValueError must be documented. |
+| `test_end_to_end.py` | `test_tuple_self_assignment_requires_attributes` | self.a, self.b = ... in __init__: both attributes must be documented. |
+| `test_end_to_end.py` | `test_dotted_exception` | Raise errors.ValidationError: documented by short or dotted name, no error. |
+| `test_end_to_end.py` | `test_undocumented_dotted_exception_reported` | Raise errors.ValidationError without Raises section: reported by raises_section. |
+| `test_end_to_end.py` | `test_function_under_if_is_linted` | Function defined under an if block: linted like a top-level function. |
+| `test_end_to_end.py` | `test_metaclass_first_parameter_not_required` | Metaclass __new__(mcs, ...): mcs is not required in Args. |
+| `test_end_to_end.py` | `test_staticmethod_first_parameter_required` | @staticmethod: the first parameter must be documented like any other. |
+| `test_end_to_end.py` | `test_overload_stubs_not_linted` | @overload stubs without docstring: only the documented implementation is linted. |
+| `test_end_to_end.py` | `test_main_guard_not_linted` | Demo code under 'if __name__ == "__main__":' without docstrings: no error. |
+| `test_end_to_end.py` | `test_variable_raise_not_required_in_raises` | Raise of a variable holding an exception: nothing to document, no error. |
+| `test_end_to_end.py` | `test_google_convention_accepts_google_guide_style` | Google guide layout (untyped Args, no Returns: None, descriptive mood): no error under convention google. |
+| `test_end_to_end.py` | `test_strict_convention_rejects_google_guide_style` | Same source under the strict default: the house rules the google convention relaxes are reported. |
+| `test_end_to_end.py` | `test_google_convention_accepts_napoleon_types` | Optional parameter documented '(int, optional)', forward reference, untyped Returns: no error under google. |
+| `test_end_to_end.py` | `test_strict_convention_reports_implicit_none` | Same kind of source under strict: '(int, optional)' for Optional[int] and the untyped Returns are reported. |
+| `test_end_to_end.py` | `test_propagated_exception_reported_under_strict` | Exception documented but raised by a callee: raises_extraneous under the strict default. |
+| `test_end_to_end.py` | `test_propagated_exception_accepted_under_google` | Same source under convention google, blank line kept before the quotes: raises_extraneous is off, no error. |
+| `test_end_to_end.py` | `test_napoleon_sections` | Keyword Args, Warning and See Also: **kwargs documented, no unknown section, no error. |
+| `test_end_to_end.py` | `test_parameters_alias` | Parameters instead of Args: arguments count as documented, section_alias is the only error. |
+| `test_end_to_end.py` | `test_exemptions_off_by_default` | Strict default: dunder, private, overridden and property methods are all checked. |
+| `test_end_to_end.py` | `test_exemptions_enabled` | All four options on: none of those entities is reported. |
+| `test_end_to_end.py` | `test_exempted_docstring_still_checked` | Dunder with a docstring under exclude_dunder_methods: the docstring content is still checked. |
+| `test_end_to_end.py` | `test_init_args_in_class_rejected_by_default` | Parameters documented in the class, init_args_location = 'init' (default): __init__ lacks a docstring. |
+| `test_end_to_end.py` | `test_init_args_in_class_accepted` | Parameters documented in the class, location class or either: no error (2 cases). |
+| `test_end_to_end.py` | `test_init_args_in_class_checked` | Class Args missing a parameter, location either: the parameter is reported on the class. |
+| `test_end_to_end.py` | `test_init_args_either_falls_back_to_init` | Class without Args, location either: __init__ is checked as usual and needs its docstring. |
+| `test_end_to_end.py` | `test_init_args_short_init_docstring` | __init__ with a docstring but no Args, class with Args, location either: no error. |
+| `test_end_to_end.py` | `test_init_args_mixed` | Args in the class and in __init__, location either: reported as mixed on __init__. |
+| `test_end_to_end.py` | `test_init_args_class_location_rejects_init_args` | Args only in __init__, location class: reported on __init__, and missing on the class. |
+| `test_end_to_end.py` | `test_one_liners_need_sections_by_default` | One-line docstrings on annotated functions, option off (strict default): every missing section is reported. |
+| `test_end_to_end.py` | `test_one_liners_sections_optional` | Same functions, sections_optional_on_one_liners on: no error. |
+| `test_end_to_end.py` | `test_one_liner_without_annotations_still_checked` | One-line docstring on a function missing an annotation: sections still required. |
+| `test_end_to_end.py` | `test_multi_line_docstring_still_checked` | Docstring with a description but no section: not a one-liner, sections still required. |
+| `test_end_to_end.py` | `test_ignore_comment_named_rules` | Ignore comment naming an always-on rule and a policy: both silenced, the other errors kept. |
+| `test_end_to_end.py` | `test_ignore_comment_without_rules` | Ignore comment without brackets: every error of the entity silenced. |
+| `test_end_to_end.py` | `test_ignore_comment_on_decorated_function` | Decorated function, comment on the def line: errors silenced. |
+| `test_end_to_end.py` | `test_ignore_comment_on_multi_line_signature` | Signature spread over several lines: the comment goes on the def line, not on the closing one. |
+| `test_end_to_end.py` | `test_ignore_comment_on_class` | Class without docstring, comment on the class line: its error silenced, its methods still checked. |
+| `test_end_to_end.py` | `test_ignore_comment_on_module_docstring` | Module docstring without final period, comment on its opening line: error silenced. |
+| `test_end_to_end.py` | `test_ignore_comment_inside_string_not_matched` | Ignore text inside a default value, not at the end of the line: errors kept. |
---
@@ -186,6 +251,12 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `rules/test_rules_args.py` | `test_duplicate_arg_no_args` | No args: no duplicate_arg error. |
| `rules/test_rules_args.py` | `test_duplicate_arg_cannot_be_disabled` | Rule listed in ignore: duplicate is still reported, the rule is always on. |
+### type comparison
+
+| Fichier | Fonction | Description |
+|---|---|---|
+| `rules/test_types.py` | `test_types_match` | Each level accepts what the previous one accepts, and real differences are always reported (20 cases). |
+
### args_order
| Fichier | Fonction | Description |
@@ -213,7 +284,18 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| Fichier | Fonction | Description |
|---|---|---|
| `rules/test_rules_args.py` | `test_returns_match_mismatch` | Returns section type differs from signature: returns returns_match error. |
-| `rules/test_rules_args.py` | `test_returns_match_missing_type` | Returns section present but no type declared: returns returns_match error. |
+| `rules/test_rules_args.py` | `test_args_section_kwargs_documented_by_keyword_args` | **kwargs in the signature and a Keyword Args section: **kwargs counts as documented. |
+| `rules/test_rules_args.py` | `test_args_match_keyword_arg_missing_description` | Keyword Args entry without a description: returns args_match error, its name is not compared with the signature. |
+| `rules/test_rules_args.py` | `test_documented_stars_policy` | Starred parameter documented with or without stars: one explicit error when the policy is not met, nothing else (6 cases). |
+| `rules/test_rules_args.py` | `test_documented_stars_on_plain_parameter` | Plain parameter documented with a star: reported whatever the policy (3 cases). |
+| `rules/test_rules_args.py` | `test_documented_stars_order_uses_bare_names` | Starless entry in signature order: no args_order error. |
+| `rules/test_rules_args.py` | `test_documented_stars_duplicate` | 'items' then '*items' in the same section: reported as a duplicate. |
+| `rules/test_rules_args.py` | `test_returns_match_missing_type` | documented_types = required, Returns section without a type: returns returns_match error. |
+| `rules/test_rules_args.py` | `test_returns_match_type_optional` | documented_types = optional, Returns section without a type: no returns_match error. |
+| `rules/test_rules_args.py` | `test_returns_match_type_forbidden` | documented_types = forbidden, Returns line carrying a type: returns returns_match error. |
+| `rules/test_rules_args.py` | `test_returns_match_type_forbidden_allows_none` | documented_types = forbidden, 'Returns: None': None is the whole line, no error. |
+| `rules/test_rules_args.py` | `test_returns_match_type_matching_levels` | Signature Optional[str], docstring 'str \| None': a mismatch only at the strict level (3 cases). |
+| `rules/test_rules_args.py` | `test_args_match_type_matching_levels` | Signature 'int \| None': each level accepts what the previous ones accept, and more (5 cases). |
| `rules/test_rules_args.py` | `test_returns_match_no_section_no_error` | No Returns section: returns_match does not flag a missing section. |
| `rules/test_rules_args.py` | `test_returns_match_missing_description` | Policy required, Returns section without a description: returns returns_match error. |
| `rules/test_rules_args.py` | `test_returns_match_none_exempt_from_description` | Policy required, 'Returns: None': the description is not demanded. |
@@ -234,6 +316,7 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `rules/test_rules_args.py` | `test_returns_none_optional_accepts_both` | Policy optional: section present or absent, no error either way. |
| `rules/test_rules_args.py` | `test_returns_none_skips_init` | __init__ -> None is not covered by the returns_none policy. |
| `rules/test_rules_args.py` | `test_returns_none_skips_generator` | Generator is not covered by the returns_none policy. |
+| `rules/test_rules_args.py` | `test_returns_none_skipped_when_returns_section_forbidden` | returns_section forbidden: the returns_none policy is not applied. |
### init_returns_none (policy)
@@ -243,6 +326,7 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `rules/test_rules_args.py` | `test_init_returns_none_required_present` | Policy required, __init__ with Returns: None section: no error. |
| `rules/test_rules_args.py` | `test_init_returns_none_forbidden_present` | Policy forbidden (default), __init__ with Returns: None section: returns init_returns_none error. |
| `rules/test_rules_args.py` | `test_init_returns_none_optional_accepts_both` | Policy optional: section present or absent on __init__, no error either way. |
+| `rules/test_rules_args.py` | `test_init_returns_none_skipped_when_returns_section_forbidden` | returns_section forbidden: the init_returns_none policy is not applied. |
### raises_section (policy) / raises_match
@@ -250,9 +334,10 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
|---|---|---|
| `rules/test_rules_args.py` | `test_raises_section_required_undocumented` | Policy required, raise in code but not documented: returns raises_section error. |
| `rules/test_rules_args.py` | `test_raises_section_optional_undocumented` | Policy optional, raise in code but not documented: no error. |
-| `rules/test_rules_args.py` | `test_raises_section_optional_still_checks_documented` | Policy optional, an exception documented but never raised: raises_match still reports it. |
+| `rules/test_rules_args.py` | `test_raises_section_optional_still_checks_documented` | Policy optional, an exception documented but never raised: raises_extraneous still reports it. |
| `rules/test_rules_args.py` | `test_raises_section_forbidden_present` | Policy forbidden, documented exceptions: returns raises_section error. |
-| `rules/test_rules_args.py` | `test_raises_match_phantom_documented` | Raise in docstring but not in code: returns raises_match error. |
+| `rules/test_rules_args.py` | `test_raises_extraneous_documented_not_raised` | Raise in docstring but not in code: returns raises_extraneous error. |
+| `rules/test_rules_args.py` | `test_raises_extraneous_disabled` | Rule off, exception documented but propagated from a callee: no error. |
| `rules/test_rules_args.py` | `test_raises_match_missing_description` | Exception documented without a description: returns raises_match error. |
| `rules/test_rules_args.py` | `test_raises_match_correct` | Raises section matches the code: no error. |
@@ -263,7 +348,9 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `rules/test_rules_args.py` | `test_yields_section_required_missing` | Policy required, generator without Yields section: returns yields_section error. |
| `rules/test_rules_args.py` | `test_yields_section_optional_missing` | Policy optional, generator without Yields section: no error. |
| `rules/test_rules_args.py` | `test_yields_section_forbidden_present` | Policy forbidden, Yields section present: returns yields_section error. |
-| `rules/test_rules_args.py` | `test_yields_match_missing_type` | Yields section without a type: returns yields_match error. |
+| `rules/test_rules_args.py` | `test_yields_match_missing_type` | documented_types = required, Yields section without a type: returns yields_match error. |
+| `rules/test_rules_args.py` | `test_yields_match_type_optional` | documented_types = optional, Yields section without a type: no yields_match type error. |
+| `rules/test_rules_args.py` | `test_yields_match_type_forbidden` | documented_types = forbidden, Yields line carrying a type: returns yields_match error. |
| `rules/test_rules_args.py` | `test_yields_match_missing_description` | Yields section without a description: returns yields_match error. |
| `rules/test_rules_args.py` | `test_yields_section_correct` | Generator with correct Yields section: no error. |
| `rules/test_rules_args.py` | `test_yields_section_not_applied_to_non_generator` | Non-generator function: the yields_section policy is not applied. |
@@ -289,7 +376,10 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| Fichier | Fonction | Description |
|---|---|---|
| `rules/test_rules_structure.py` | `test_indentation_consistent` | Normal Google-style docstring with 2 levels: no indentation error. |
-| `rules/test_rules_structure.py` | `test_indentation_inconsistent` | More than 2 indent levels in docstring: returns indentation error. |
+| `rules/test_rules_structure.py` | `test_indentation_under_indented_section_line` | Line of a section indented by 2 spaces: returns one indentation error for the section. |
+| `rules/test_rules_structure.py` | `test_indentation_first_entry_not_at_four` | First Args entry indented by 8 spaces: returns indentation error. |
+| `rules/test_rules_structure.py` | `test_indentation_multiline_entry` | Entry description continued on deeper lines: no indentation error. |
+| `rules/test_rules_structure.py` | `test_indentation_description_block_ignored` | Indented code block in the description, outside any section: no indentation error. |
| `rules/test_rules_structure.py` | `test_indentation_one_liner_skipped` | One-liner docstring: indentation rule skips it, no error. |
### section_capitalization
@@ -297,6 +387,14 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| Fichier | Fonction | Description |
|---|---|---|
| `rules/test_rules_structure.py` | `test_section_capitalization_correct` | Correctly capitalized section 'Args:': no error. |
+| `rules/test_rules_structure.py` | `test_section_capitalization_multi_word` | Multi-word Napoleon header 'See also:': returns section_capitalization error expecting 'See Also:'. |
+
+### section_alias
+
+| Fichier | Fonction | Description |
+|---|---|---|
+| `rules/test_rules_structure.py` | `test_section_alias_reported` | Napoleon alias used as a header: returns section_alias error naming the canonical spelling (5 cases). |
+| `rules/test_rules_structure.py` | `test_section_alias_disabled` | Alias used, rule off: no section_alias error. |
| `rules/test_rules_structure.py` | `test_section_capitalization_wrong` | Lowercase section header 'args:': returns section_capitalization error. |
### section_order
@@ -307,6 +405,10 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `rules/test_rules_structure.py` | `test_section_order_wrong` | Returns before Args: returns section_order error. |
| `rules/test_rules_structure.py` | `test_section_order_single_section_ok` | Only one recognized section: no section_order error. |
| `rules/test_rules_structure.py` | `test_section_order_unknown_section_ignored` | Unknown section between known sections: order check skips it. |
+| `rules/test_rules_structure.py` | `test_section_order_alias_and_free_text` | Parameters placed like Args, Warning anywhere: no section_order error. |
+| `rules/test_rules_structure.py` | `test_section_order_alias_out_of_place` | Return before Parameters: section_order error listing canonical names. |
+| `rules/test_rules_structure.py` | `test_empty_free_text_section` | Empty See Also section: returns empty_section error, free-text sections are known sections. |
+| `rules/test_rules_structure.py` | `test_entry_spacing_in_alias_section` | Badly spaced entry under Parameters: entry_spacing applies to aliases of Args. |
### unknown_section
@@ -344,6 +446,7 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `rules/test_rules_structure.py` | `test_blank_lines_before_closing_quotes_default_present` | Default of 1, one blank line before the closing quotes: no error. |
| `rules/test_rules_structure.py` | `test_blank_lines_before_closing_quotes_too_many` | Default of 1, two blank lines before the closing quotes: returns blank_lines error. |
| `rules/test_rules_structure.py` | `test_blank_lines_before_closing_quotes_zero` | Configured to 0, no blank line before the closing quotes: no error. |
+| `rules/test_rules_structure.py` | `test_closing_quotes_on_text_line` | Multi-line docstring closed on the line of its last text: one error saying so, whatever the configured count (2 cases). |
| `rules/test_rules_structure.py` | `test_blank_lines_cannot_be_disabled` | Rule listed in ignore: a wrong blank line count is still reported, the rule is always on. |
| `rules/test_rules_structure.py` | `test_blank_lines_one_liner_skipped` | One-liner docstring: the closing quotes count is not checked. |
| `rules/test_rules_structure.py` | `test_blank_lines_module_skipped` | Module entity: the closing quotes count is not checked. |
@@ -357,6 +460,9 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `rules/test_rules_docstring.py` | `test_imperative_mood_ies_form` | Summary starting with 'Identifies' (ies->y): returns imperative_mood error. |
| `rules/test_rules_docstring.py` | `test_imperative_mood_ches_form` | Summary starting with 'Dispatches' (ches->Dispatch): returns imperative_mood error. |
| `rules/test_rules_docstring.py` | `test_imperative_mood_es_after_consonant` | Summary starting with 'Compresses' (es after consonant): returns imperative_mood error. |
+| `rules/test_rules_docstring.py` | `test_imperative_mood_suggestion` | Third-person verb, irregular or not: the suggested base form is a real verb (6 cases). |
+| `rules/test_rules_docstring.py` | `test_imperative_mood_not_a_verb` | Plural noun, word ending in s, or token that is not a word: no imperative_mood error (10 cases). |
+| `rules/test_rules_docstring.py` | `test_imperative_mood_skips_classes` | Class docstring starting with a third-person verb: not checked, the rule targets functions and methods. |
| `rules/test_rules_docstring.py` | `test_imperative_mood_exception_word` | Summary starting with 'This' (in exceptions list): no error. |
### summary_too_long
@@ -417,7 +523,7 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| Fichier | Fonction | Description |
|---|---|---|
-| `rules/test_rules_structure.py` | `test_examples_section_required_missing` | Policy required, no Example section: returns examples_section error. |
+| `rules/test_rules_structure.py` | `test_examples_section_required_missing` | Policy required, no Examples section: returns examples_section error. |
| `rules/test_rules_structure.py` | `test_examples_section_required_present_plural` | Policy required, an Examples section: the plural spelling is accepted. |
| `rules/test_rules_structure.py` | `test_examples_section_forbidden_present` | Policy forbidden, an Example section: returns examples_section error. |
| `rules/test_rules_structure.py` | `test_notes_section_forbidden_present` | Policy forbidden, a Note section: returns notes_section error. |
@@ -433,8 +539,8 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `rules/test_rules_validate.py` | `test_empty_init_method_docstring_still_checked` | Empty __init__ with exclude_empty_init_method=True: an existing docstring is still checked. |
| `rules/test_rules_validate.py` | `test_empty_init_module_excluded_when_configured` | Empty __init__.py with exclude_empty_init_module=True: no errors even with missing docstring. |
| `rules/test_rules_validate.py` | `test_empty_init_module_not_excluded_when_flag_false` | Empty __init__.py with exclude_empty_init_module=False: docstring_exists is still checked. |
-| `rules/test_rules_validate.py` | `test_docstring_placeholder_ignored_when_configured` | Placeholder '...' with ignore_placeholder_docstrings=True: no errors. |
-| `rules/test_rules_validate.py` | `test_docstring_placeholder_error_when_not_ignored` | Placeholder '...' without ignore flag: returns docstring_exists error. |
+| `rules/test_rules_docstring.py` | `test_docstring_placeholder_ignored_when_configured` | Placeholder '...' with ignore_placeholder_docstrings=True: no errors. |
+| `rules/test_rules_docstring.py` | `test_docstring_placeholder_error_when_not_ignored` | Placeholder '...' without ignore flag: returns docstring_exists error. |
| `rules/test_rules_validate.py` | `test_disabled_rule_not_checked` | When all rules are disabled: no error for missing docstring. |
| `rules/test_rules_validate.py` | `test_imperative_mood_skipped_for_module` | Module node type: imperative_mood rule is not applied (plural nouns like 'Rules' are valid). |
| `rules/test_rules_validate.py` | `test_method_node_type_triggers_function_rules` | METHOD node type: function-level rules like return_type_annotation are applied. |
@@ -449,10 +555,15 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
|---|---|---|
| `test_cli.py` | `test_collect_single_file` | Single .py file path: returns that file. |
| `test_cli.py` | `test_collect_non_py_file_ignored` | Non-.py file: not collected. |
-| `test_cli.py` | `test_collect_excluded_file_skipped` | Single file matching exclusion pattern: not collected. |
+| `test_cli.py` | `test_collect_explicit_file_kept_despite_exclusion` | File given explicitly and matching an exclusion pattern: still collected. |
+| `test_cli.py` | `test_collect_explicit_file_skipped_with_force_exclude` | File given explicitly and matching an exclusion pattern, with force_exclude: not collected. |
| `test_cli.py` | `test_collect_directory_recursive` | Directory with nested .py files: all collected. |
| `test_cli.py` | `test_collect_venv_excluded_by_literal_pattern` | File inside a .venv directory: excluded by literal pattern matching path parts. |
+| `test_cli.py` | `test_collect_literal_excluded_directory_not_walked` | Directory named by a literal pattern: skipped during the walk, its content is never listed. |
+| `test_cli.py` | `test_collect_directory_named_like_a_module` | Directory whose name ends with .py: walked into, never collected as a file. |
| `test_cli.py` | `test_collect_pycache_excluded_by_literal_pattern` | File inside __pycache__: excluded by literal pattern matching path parts. |
+| `test_cli.py` | `test_collect_recursive_glob_excluded` | Pattern tests/**: every file under tests/ is excluded, at any depth. |
+| `test_cli.py` | `test_collect_directory_glob_excluded` | Pattern src/gen/*.py: files directly under src/gen/ are excluded, others kept. |
### lint_file
@@ -466,7 +577,6 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| Fichier | Fonction | Description |
|---|---|---|
-| `test_cli.py` | `test_merge_style_override` | --style google: overrides config.style. |
| `test_cli.py` | `test_merge_exclude_override` | --exclude test_*: overrides config.exclude_patterns. |
| `test_cli.py` | `test_merge_format_json` | --format json: sets output_format to json. |
| `test_cli.py` | `test_merge_format_github_annotations` | --format github-annotations: sets output_format to github-annotations. |
@@ -481,15 +591,34 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_cli.py` | `test_run_no_files_returns_zero` | No .py files found: run returns 0. |
| `test_cli.py` | `test_run_valid_file_returns_zero` | Valid file with no errors: run returns 0. |
| `test_cli.py` | `test_run_invalid_file_returns_one` | File with lint errors: run returns 1. |
-| `test_cli.py` | `test_run_syntax_error_returns_zero` | File with SyntaxError: error is caught, run returns 0 (no lint errors). |
+| `test_cli.py` | `test_run_syntax_error_returns_two` | File with SyntaxError: reported on stderr, run returns 2. |
+| `test_cli.py` | `test_run_syntax_error_keeps_json_valid` | File with SyntaxError under --format json: stdout stays valid JSON, run returns 2. |
+| `test_cli.py` | `test_run_undecodable_file_returns_two` | File that is not valid UTF-8: reported as unreadable on stderr, run returns 2. |
+| `test_cli.py` | `test_run_failure_wins_over_lint_errors` | One unparsable file and one file with lint errors: run returns 2. |
+| `test_cli.py` | `test_run_internal_error_returns_three` | Unexpected exception on one file: file named with the traceback, other files still linted, run returns 3. |
+| `test_cli.py` | `test_run_missing_path_returns_two` | Path that does not exist: reported on stderr, run returns 2 without linting. |
+| `test_cli.py` | `test_run_unknown_rule_in_ignore_comment_returns_two` | Ignore comment naming an unknown rule: file and line on stderr, run returns 2. |
+| `test_cli.py` | `test_resolve_workers_auto_is_the_default` | Default config: workers is 0, the auto mode. |
+| `test_cli.py` | `test_resolve_workers_auto_sequential_on_small_runs` | Auto mode below 50 files: sequential, the process pool would cost more than it saves. |
+| `test_cli.py` | `test_resolve_workers_auto_uses_usable_cpus` | Auto mode from 50 files: one worker per CPU usable by the process. |
+| `test_cli.py` | `test_resolve_workers_explicit_value_kept` | Explicit worker count: used as is, whatever the number of files. |
+| `test_cli.py` | `test_run_parallel_workers` | Two workers on two files: errors from every file are collected. |
| `test_cli.py` | `test_run_with_json_output` | Run with output_format=json: JSON report is printed to stdout. |
+| `test_cli.py` | `test_run_statistics_counts_errors_per_rule` | Run with statistics: one count per rule instead of each error, same exit code. |
### main / --list-rules
| Fichier | Fonction | Description |
|---|---|---|
+| `test_cli.py` | `test_list_rules_google_convention_disabled_rules` | --list-rules with the google convention: its disabled rules are counted and labelled as disabled by default. |
+| `test_cli.py` | `test_main_config_line_on_stderr` | Text format: the Config line goes to stderr, stdout only holds the report. |
| `test_cli.py` | `test_main_invalid_config_value` | Invalid value in the config file: prints a configuration error and exits with 2. |
-| `test_cli.py` | `test_list_rules_output` | --list-rules: configurable rules appear grouped by category, always-on rules do not. |
+| `test_cli.py` | `test_main_missing_config_file` | --config pointing to a missing file: prints a configuration error and exits with 2. |
+| `test_cli.py` | `test_main_statistics_rejected_with_json` | --statistics with a machine-readable format: error on stderr and exit 2. |
+| `test_cli.py` | `test_main_from_subdirectory_uses_config_directory` | Run from src/ with the config at the root: exclude and override patterns still apply from the root. |
+| `test_cli.py` | `test_main_force_exclude_flag` | Excluded file passed explicitly: linted by default, skipped with --force-exclude. |
+| `test_cli.py` | `test_main_closed_output_pipe_exits_quietly` | Output pipe closed before the report is written: exit 1 without a traceback. |
+| `test_cli.py` | `test_list_rules_output` | --list-rules: every rule appears, always-on rules in their own section after the categories. |
---
@@ -513,6 +642,13 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_reporter.py` | `test_report_cli_single_error_grammar` | Single error: summary says 'error' not 'errors'. |
| `test_reporter.py` | `test_report_cli_multiple_files` | Errors in multiple files: each file is printed separately. |
+### report_statistics
+
+| Fichier | Fonction | Description |
+|---|---|---|
+| `test_reporter.py` | `test_report_statistics_no_errors` | No errors: prints summary with 0 errors. |
+| `test_reporter.py` | `test_report_statistics_sorted_by_count_then_rule` | Most frequent rule first, ties in alphabetical order, counts right-aligned. |
+
### report_json
| Fichier | Fonction | Description |
@@ -545,12 +681,16 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_reporter.py` | `test_report_rules_enabled_rule_shows_checkmark` | Enabled rule shows checkmark marker. |
| `test_reporter.py` | `test_report_rules_disabled_rule_shows_cross` | Disabled rule shows cross marker. |
| `test_reporter.py` | `test_report_rules_off_by_default_label` | Rule in off_by_default shows '(disabled by default)' label. |
-| `test_reporter.py` | `test_report_rules_always_on_hidden` | Rule in always_on is not listed and is not counted in the header. |
+| `test_reporter.py` | `test_report_rules_always_on_listed_separately` | Rule in always_on: listed after the categories, not counted as configurable. |
| `test_reporter.py` | `test_report_rules_category_hidden_when_all_rules_always_on` | Category whose rules are all always on: the category is not printed. |
| `test_reporter.py` | `test_report_policies_all_policies_present` | All policy identifiers and their values appear in output. |
| `test_reporter.py` | `test_report_policies_optional_value` | Policy set to optional shows its value on the matching line. |
| `test_reporter.py` | `test_report_options_all_options_present` | All option identifiers and their values appear in output. |
| `test_reporter.py` | `test_report_options_value_on_matching_line` | Each option value is printed on the line of its option. |
+| `test_reporter.py` | `test_report_single_file_singular` | One file checked: the summary says '1 file checked'. |
+| `test_reporter.py` | `test_report_github_annotations_escaped` | Message and properties escaped as @actions/core does: %, line breaks, and in properties ':' and ','. |
+| `test_reporter.py` | `test_report_no_color_when_not_a_terminal` | Output captured, not a terminal: no ANSI escape sequence at all. |
+| `test_reporter.py` | `test_colors_follow_terminal_and_no_color` | Colors on a terminal only, and off when NO_COLOR holds a non-empty value (4 cases). |
---
@@ -560,9 +700,7 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| Fichier | Fonction | Description |
|---|---|---|
-| `test_config.py` | `test_default_config_style` | Default config: style is GOOGLE. |
-| `test_config.py` | `test_default_config_rules_exclude_off_by_default` | Default config: OFF_BY_DEFAULT rules are not in enabled_rules. |
-| `test_config.py` | `test_default_config_all_other_rules_enabled` | Default config: all rules except OFF_BY_DEFAULT are enabled. |
+| `test_config.py` | `test_default_config_all_rules_enabled` | Default config, strict convention: every rule is enabled. |
| `test_config.py` | `test_default_config_exclude_patterns_include_common_dirs` | Default config: exclude_patterns includes .venv, .git, __pycache__, .tox. |
### is_rule_enabled
@@ -581,8 +719,7 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_config.py` | `test_parse_select_explicit_list` | Select = ['docstring_exists', 'args_match']: only those two rules enabled. |
| `test_config.py` | `test_parse_ignore_only` | Ignore only (no select): starts from default set minus ignored rules. |
| `test_config.py` | `test_parse_no_select_no_ignore` | Empty data: enabled_rules matches default config. |
-| `test_config.py` | `test_parse_style_google` | Style = 'google': config.style is DocstringStyle.GOOGLE. |
-| `test_config.py` | `test_parse_style_unknown` | Style = 'unknown': raises ValueError listing the accepted styles. |
+| `test_config.py` | `test_parse_style_key_removed` | Style key, removed since Google is the only style: rejected as an unknown key. |
| `test_config.py` | `test_parse_exclude_empty_init_method_false` | exclude_empty_init_method = false: config.exclude_empty_init_method is False. |
| `test_config.py` | `test_parse_exclude_empty_init_module_false` | exclude_empty_init_module = false: config.exclude_empty_init_module is False. |
| `test_config.py` | `test_parse_workers` | Workers = 4: config.workers is 4. |
@@ -628,16 +765,60 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_config.py` | `test_for_path_applies_matching_override` | Matching override: the policy is overridden, the base config is left untouched. |
| `test_config.py` | `test_for_path_ignores_non_matching_override` | Override whose patterns do not match: the base config is returned as is. |
| `test_config.py` | `test_for_path_last_override_wins` | Two matching overrides: the last declared one wins. |
+| `test_config.py` | `test_for_path_earlier_override_not_merged` | Two matching overrides: only the last one applies, the earlier keys are dropped. |
| `test_config.py` | `test_for_path_ignore_removes_from_inherited_rules` | Ignore key in an override: the rule is removed from the inherited set. |
| `test_config.py` | `test_for_path_select_replaces_inherited_rules` | Select key in an override: the inherited set is replaced by the listed rules. |
+| `test_config.py` | `test_for_path_override_select_all` | Select = ['ALL'] in an override: every rule is enabled on the matching files. |
+| `test_config.py` | `test_for_path_patterns_relative_to_base_dir` | Run from a subdirectory: the override pattern is matched relative to base_dir, not to the current directory. |
+| `test_config.py` | `test_for_path_file_outside_base_dir` | File outside base_dir: no override applies, even with a catch-all pattern. |
+
+### convention
+
+| Fichier | Fonction | Description |
+|---|---|---|
+| `test_config.py` | `test_convention_defaults_to_strict` | No convention key: strict convention, same settings as the built-in defaults. |
+| `test_config.py` | `test_convention_google_sets_defaults` | Convention = 'google': relaxed policies, no blank line before the closing quotes, two rules off. |
+| `test_config.py` | `test_convention_explicit_keys_win` | Convention = 'google' with explicit keys: the keys of the file override the convention. |
+| `test_config.py` | `test_convention_ignore_applies_on_top` | Convention = 'google' with ignore: rules removed from the convention set, disabled ones stay off. |
+| `test_config.py` | `test_convention_select_all_enables_everything` | Convention = 'google' with select = ['ALL']: every rule is enabled, the explicit key wins. |
+| `test_config.py` | `test_convention_unknown` | Convention = 'numpy': raises ValueError listing the accepted conventions. |
+| `test_config.py` | `test_convention_rejected_in_override` | Convention in an override: rejected, it sets the defaults of the whole run. |
+| `test_config.py` | `test_convention_listed_in_option_values` | option_values: reports the active convention. |
+
+### docstring exemptions
+
+| Fichier | Fonction | Description |
+|---|---|---|
+| `test_config.py` | `test_exemption_options` | Exemption option: off by default, set from the file, allowed in an override (5 cases). |
+| `test_config.py` | `test_init_args_location` | init_args_location: 'init' by default, accepts class and either, rejects other values. |
+
+### type_matching
+
+| Fichier | Fonction | Description |
+|---|---|---|
+| `test_config.py` | `test_type_matching_default_strict` | No type_matching key: strict comparison. |
+| `test_config.py` | `test_type_matching_set` | type_matching = 'equivalent': stored as is and reported by option_values. |
+| `test_config.py` | `test_type_matching_invalid` | type_matching = 'loose': raises ValueError listing the accepted levels. |
+| `test_config.py` | `test_type_matching_in_override` | type_matching in an override: applied to the matching files only. |
+
+### value types
+
+| Fichier | Fonction | Description |
+|---|---|---|
+| `test_config.py` | `test_parse_rejects_wrong_value_type` | Value of the wrong TOML type: raises ValueError naming the key and the value (9 cases). |
+| `test_config.py` | `test_parse_override_rejects_wrong_value_type` | Option of the wrong type in an override: raises ValueError naming the override. |
+| `test_config.py` | `test_parse_override_rejects_string_paths` | Paths given as a string in an override: raises ValueError. |
+| `test_config.py` | `test_parse_override_option_clamped` | summary_max_length = -5 in an override: clamped to 1, like at the top level. |
### load_config
| Fichier | Fonction | Description |
|---|---|---|
-| `test_config.py` | `test_load_config_toml_without_section_returns_default` | pyproject.toml with no [tool.docstring-linter] section: returns default config. |
+| `test_config.py` | `test_load_config_toml_without_section` | Explicit pyproject.toml with no [tool.docstring-linter] section: raises ValueError. |
| `test_config.py` | `test_load_config_toml_with_section` | pyproject.toml with [tool.docstring-linter] section: config is populated. |
-| `test_config.py` | `test_load_config_no_file_returns_default` | Explicit path that does not exist: returns default LinterConfig. |
+| `test_config.py` | `test_load_config_missing_explicit_file` | Explicit path that does not exist: raises ValueError naming the path. |
+| `test_config.py` | `test_load_config_explicit_directory` | Explicit path that is a directory: raises ValueError. |
+| `test_config.py` | `test_load_config_base_dir_is_config_directory` | Explicit config file: base_dir is the resolved directory holding it. |
| `test_config.py` | `test_load_config_auto_discover` | No explicit path: load_config walks up directories to find pyproject.toml. |
| `test_config.py` | `test_load_config_standalone_toml` | .docstring-linter.toml with flat config: parsed directly without [tool.docstring-linter]. |
| `test_config.py` | `test_load_config_custom_named_toml` | Explicitly passed non-pyproject.toml file: parsed directly regardless of name. |
@@ -646,6 +827,20 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
---
+## test_registries.py -- registry consistency
+
+| Fichier | Fonction | Description |
+|---|---|---|
+| `test_registries.py` | `test_every_rule_in_exactly_one_category` | Each rule of RULES_REGISTRY appears in one category of RULES_CATEGORIES, and nothing else does. |
+| `test_registries.py` | `test_rule_subsets_are_registered_rules` | ALWAYS_ON and the rules each convention disables only name registered rules, never an always-on one. |
+| `test_registries.py` | `test_every_policy_is_a_config_field` | Each policy of POLICIES_REGISTRY is a LinterConfig field holding a Policy. |
+| `test_registries.py` | `test_every_rule_and_policy_documented` | Each rule and policy has a heading of its own in the docs pages. |
+| `test_registries.py` | `test_every_rule_reported_by_the_rules_package` | Each rule identifier is written as a string literal in the rules package, where errors are made. |
+| `test_registries.py` | `test_every_listed_option_is_a_typed_config_field` | Each option shown by --list-rules is a LinterConfig field, typed unless it is the convention or a scope flag. |
+| `test_registries.py` | `test_every_config_key_documented` | The key table of docs/configuration.md has one row per accepted key, scope flags spelled out. |
+| `test_registries.py` | `test_override_options_documented` | The override section of docs/configuration.md names exactly the options an override may carry. |
+| `test_registries.py` | `test_every_test_function_listed_in_tests_md` | TESTS.md has one row per test function of tests/linter, and no row for a missing one. |
+
## test_models.py -- models
| Fichier | Fonction | Description |
@@ -673,7 +868,7 @@ This file lists the 369 tests of the `docstring-linter` project. Each entry show
| `test_integration.py` | `test_collect_python_files_finds_all_py` | Directory with multiple .py files: collect_python_files returns all of them. |
| `test_integration.py` | `test_collect_python_files_exclude_pattern` | Directory with exclusion pattern: matching files are not collected. |
| `test_integration.py` | `test_cli_list_rules_exit_zero` | --list-rules: exits with code 0 and prints rule names. |
-| `test_integration.py` | `test_cli_syntax_error_no_crash` | CLI on file with SyntaxError: does not crash, prints error message, exits 0. |
+| `test_integration.py` | `test_cli_syntax_error_no_crash` | CLI on file with SyntaxError: does not crash, prints error message on stderr, exits 2. |
| `test_integration.py` | `test_cli_json_output_valid_file` | --format json on valid file: JSON report printed to stdout with 0 errors. |
| `test_integration.py` | `test_cli_json_output_invalid_file` | --format json on file with errors: JSON report on stdout with errors. |
| `test_integration.py` | `test_cli_github_annotations_valid_file` | --format github-annotations on valid file: no output, exit 0. |
diff --git a/TODO.md b/TODO.md
index b339de8..8e9121c 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,53 +1,50 @@
# TODO
-Les priorités sont notées de 1 (faible) à 10 (haute).
-
-## Nouvelles règles
-
-- [x] `args_order` -- vérifier que l'ordre des args dans `Args:` correspond à l'ordre de la signature -- **7**
-- [x] `unknown_section` -- signaler une section non reconnue (ex: `Argument:` au lieu de `Args:`) -- **7**
-- [x] `duplicate_arg` -- détecter un argument documenté deux fois dans `Args:` -- **6**
-- [x] `multiline_summary` -- interdire une summary qui s'étale sur plusieurs lignes -- **5**
- Couvert par le parser, qui coupe le résumé à la première ligne, et par `blank_lines` qui exige la ligne vide avant la description.
-- [x] `raises_description` -- exiger une description dans chaque entrée `Raises:` -- **4**
- Couvert par `raises_match`.
-- [x] `entry_spacing` -- imposer la forme `name (type): description` dans `Args:`, `Attributes:` et `Raises:` -- **6**
-
-- [x] `return_description` -- exiger une description dans `Returns:` (pas seulement un type) -- **4**
- Couvert par `returns_descriptions`, qui gouverne `Returns:` et `Yields:`. La description des entrées `Args:`, `Attributes:` et `Raises:` reste toujours obligatoire. `Returns: None` est exempté.
-- [ ] `description_too_long` -- limiter la longueur des lignes de description (configurable, comme `summary_too_long`) -- **5**
-- [ ] `no_trailing_whitespace` -- interdire les espaces en fin de ligne dans la docstring -- **4**
+Priorities range from 1 (low) to 10 (high).
+
+## New rules
+
+- [x] `args_order` -- check that the order of the entries in `Args:` matches the signature -- **7**
+- [x] `unknown_section` -- report an unrecognized section (e.g. `Argument:` instead of `Args:`) -- **7**
+- [x] `duplicate_arg` -- detect an argument documented twice in `Args:` -- **6**
+- [x] `multiline_summary` -- forbid a summary spread over several lines -- **5**
+ Covered by the parser, which cuts the summary at the first line, and by `blank_lines`, which requires the blank line before the description.
+- [x] `raises_description` -- require a description in every `Raises:` entry -- **4**
+ Covered by `raises_match`.
+- [x] `entry_spacing` -- enforce the `name (type): description` form in `Args:`, `Attributes:` and `Raises:` -- **6**
+- [x] `return_description` -- require a description in `Returns:`, not only a type -- **4**
+ Covered by `returns_descriptions`, which governs `Returns:` and `Yields:`. The description of `Args:`, `Attributes:` and `Raises:` entries stays mandatory. `Returns: None` is exempt.
+- [ ] `description_too_long` -- limit the length of description lines (configurable, like `summary_too_long`) -- **5**
+- [ ] `no_trailing_whitespace` -- forbid trailing spaces in the docstring -- **4**
## CLI / config
-- [x] Format de sortie `traceback` -- en-tête cliquable `File "path", line N, in entity` -- **7**
-- [x] Rejeter les clés de configuration inconnues -- **9**
- Clé inconnue au niveau racine, dans `[scope]` ou dans un override, nom de règle inconnu dans `select` / `ignore`, règle always-on dans `ignore`, valeur de `style` ou de policy invalide : erreur sur stderr et code de sortie 2 avant toute lecture de fichier.
-- [x] Configuration par répertoire -- `[[tool.docstring-linter.overrides]]` avec `paths` -- **7**
- Globs `full_match`, dernier bloc correspondant gagnant, `ignore` retire et `select` remplace, `scope.*` et les réglages d'exécution refusés dans un override.
-- [ ] `--quiet` -- n'afficher que les erreurs, supprimer le résumé et la config -- **5**
-- [ ] `--watch` -- relancer automatiquement sur les fichiers modifiés -- **3**
+- [x] `traceback` output format -- clickable `File "path", line N, in entity` header -- **7**
+- [x] Reject unknown configuration keys -- **9**
+ Unknown key at the top level, in `[scope]` or in an override, unknown rule name in `select` / `ignore`, always-on rule in `ignore`, invalid `style` or policy value: error on stderr and exit code 2 before any file is read.
+- [x] Per-directory configuration -- `[[tool.docstring-linter.overrides]]` with `paths` -- **7**
+ `full_match` globs, last matching block wins, `ignore` removes and `select` replaces, `scope.*` and run-level settings rejected in an override.
+- [ ] `--quiet` -- print only the errors, without the summary and the config line -- **5**
+- [ ] `--watch` -- rerun automatically on modified files -- **3**
## Parser
-- [ ] Entrée `Raises:` réduite à un identifiant nu, sans deux-points -- **3**
- `RuntimeError` seul n'est pas reconnu comme une entrée et remonte en « non documenté ». L'accepter ferait passer toute ligne de continuation d'un seul mot pour une exception.
-- [ ] `no_blank_line_in_section` travaille ligne à ligne -- **3**
- La règle ne distingue pas le début d'une entrée d'une ligne de continuation, ce qui interdit d'en faire un compteur configurable comme `blank_lines`.
+- [ ] `Raises:` entry reduced to a bare identifier, without a colon -- **3**
+ `RuntimeError` alone is not recognized as an entry and is reported as undocumented. Accepting it would turn any one-word continuation line into an exception.
+- [ ] `no_blank_line_in_section` works line by line -- **3**
+ The rule does not tell the start of an entry from a continuation line, which prevents turning it into a configurable count like `blank_lines`.
## Documentation
-- [ ] Décider du sort de `RULES.md` -- **6**
- Seule documentation des 11 règles always-on, absentes de `--list-rules`. Piste : générer l'ossature depuis `RULES_REGISTRY`, `POLICIES_REGISTRY` et `OPTIONS_REGISTRY`, ne garder à la main que les exemples.
-- [ ] Décider du sort de `TESTS.md` -- **6**
- 301 lignes sur 313 sont la copie exacte du docstring du test. Piste : générer, et vérifier dans `lint-tests.sh` que le fichier commité correspond.
-- [ ] Aligner la version -- **5**
- `pyproject.toml` déclare `0.1.0`, les tags vont jusqu'à `v0.5.0`, `README.md` référence `v0.1.0` dans les exemples pre-commit et GitHub Action.
+- [x] Keep `TESTS.md` in sync with the test suite -- **6**
+ Still written by hand, one row per test function; `tests/linter/test_registries.py` fails when a test function has no row or a row has no test.
+- [ ] Align the version -- **5**
+ `pyproject.toml` declares `0.1.0` while the tags go up to `v0.9.0`: the version is only set from the tag at build time. To be handled with the version management in CI/CD (review point OPS-04).
-## Intégration
+## Integration
-- [ ] Sortie SARIF -- format standard pour les annotations de PR GitHub -- **6**
-- [x] Hook pre-commit prêt à l'emploi -- `.pre-commit-hooks.yaml` + testé via `.pre-commit-config-hook.yaml` dans CI -- **8**
-- [ ] Plugin VS Code -- afficher les erreurs inline dans l'éditeur -- **7**
-- [x] GitHub Action -- action prête à l'emploi pour les workflows CI -- `action.yml` + `.github/workflows/` -- **8**
-- [ ] Badge de couverture docstring -- pourcentage de fonctions correctement documentées -- **5**
+- [ ] SARIF output -- standard format for GitHub pull request annotations -- **6**
+- [x] Ready-to-use pre-commit hook -- `.pre-commit-hooks.yaml`, tested through `.pre-commit-config-hook.yaml` in CI -- **8**
+- [ ] VS Code plugin -- show the errors inline in the editor -- **7**
+- [x] GitHub Action -- ready-to-use action for CI workflows -- `action.yml` + `.github/workflows/` -- **8**
+- [ ] Docstring coverage badge -- percentage of correctly documented functions -- **5**
diff --git a/action.yml b/action.yml
index 4b247b6..1737988 100644
--- a/action.yml
+++ b/action.yml
@@ -8,7 +8,7 @@ inputs:
required: false
default: src/
format:
- description: Output format (text, json, github-annotations).
+ description: Output format (traceback, text, json, github-annotations).
required: false
default: github-annotations
extra-args:
@@ -25,4 +25,11 @@ runs:
- name: Run docstring-linter
shell: bash
- run: docstring-linter ${{ inputs.paths }} --format ${{ inputs.format }} ${{ inputs.extra-args }}
+ env:
+ PATHS: ${{ inputs.paths }}
+ FORMAT: ${{ inputs.format }}
+ EXTRA_ARGS: ${{ inputs.extra-args }}
+ run: |
+ # Word splitting on PATHS and EXTRA_ARGS is intended: several paths or arguments
+ # shellcheck disable=SC2086
+ docstring-linter $PATHS --format "$FORMAT" $EXTRA_ARGS
diff --git a/docs/always-on-rules.md b/docs/always-on-rules.md
index 4490d55..106afba 100644
--- a/docs/always-on-rules.md
+++ b/docs/always-on-rules.md
@@ -2,7 +2,7 @@
## Always-On Rule Reference
-These rules are always enabled, cannot be added to `ignore` and are therefore not listed by `docstring-linter --list-rules`.
+These rules are always enabled and cannot be added to `ignore`. `docstring-linter --list-rules` shows them in an "Always on" section, after the configurable rules. They can still be silenced on one function, class or module with a [`# docstring-linter: ignore[...]`](/README.md#suppressing-errors) comment.
### Content
@@ -10,7 +10,7 @@ These rules are always on because requiring a section and then tolerating wrong
#### args_match
-Checks what the `Args:` section declares: phantom parameters, type `(type)` according to the `documented_types` policy, and presence of a description. A parameter of the signature that is not documented at all is reported by the `args_section` policy, not here.
+Checks what the `Args:` section declares: phantom parameters, type `(type)` according to the `documented_types` policy and compared with the signature as set by [`type_matching`](/docs/configuration.md#type-matching), and presence of a description. A parameter of the signature that is not documented at all is reported by the `args_section` policy, not here.
```python
# Bad: type mismatch (int vs float)
@@ -129,10 +129,10 @@ class User:
#### raises_match
-Checks what the `Raises:` section declares: an exception documented but never raised in the code, and an exception documented without a description. An exception raised but not documented is reported by the `raises_section` policy, not here.
+Every exception listed in `Raises:` must carry a description. An exception raised but not documented is reported by the `raises_section` policy, an exception documented but never raised by the configurable [`raises_extraneous`](/docs/configurable-rules.md#raises_extraneous) rule.
```python
-# Bad: TypeError documented but never raised
+# Bad: no description
def validate(x: int) -> int:
"""Validate input.
@@ -143,9 +143,11 @@ def validate(x: int) -> int:
int: Validated input.
Raises:
- TypeError: Never actually raised.
+ ValueError:
"""
+ if x < 0:
+ raise ValueError("negative")
return x
# Good
@@ -167,13 +169,12 @@ def validate(x: int) -> int:
return x
```
-Note: bare `raise` (re-raise), dynamic raises, or raises from internal calls are ignored.
---
#### returns_match
-When a `Returns:` section exists, its type must match the signature, and the type must not be missing. This rule never reports a missing section (that is `returns_section`).
+When a `Returns:` section exists, its type must match the signature as set by [`type_matching`](/docs/configuration.md#type-matching), and its presence follows the `documented_types` policy. The text before the colon counts as a type only when it reads as a Python expression: `The mapping: key to value.` is a description. This rule never reports a missing section (that is `returns_section`).
A documented type that contradicts the signature is wrong, not stylistic, so this rule ignores `select` and `ignore` and always reports.
@@ -188,7 +189,7 @@ def get_name() -> str:
"""
return "Alice"
-# Bad: type missing in Returns
+# Bad under documented_types = "required" (default): type missing in Returns
def get_name() -> str:
"""Get the user name.
@@ -388,7 +389,7 @@ def process(x: int, y: int) -> int:
"""
```
-`Example`/`Examples` sections are exempt (code examples often contain blank lines).
+`Examples` sections (and their `Example` alias) are exempt (code examples often contain blank lines).
### Syntax / Structure
diff --git a/docs/configurable-rules.md b/docs/configurable-rules.md
index b9af82c..8c5b528 100644
--- a/docs/configurable-rules.md
+++ b/docs/configurable-rules.md
@@ -10,7 +10,9 @@ These rules can be enabled or disabled through `select` and `ignore`. For styl
Every entity (module, class, function, method) must have a docstring.
-Subject to the configured scope (`modules`, `classes`, `functions`, `methods`) and the two empty `__init__` exemptions.
+Subject to the configured scope (`modules`, `classes`, `functions`, `methods`) and to the exemption options: `exclude_empty_init_method`, `exclude_empty_init_module`, `exclude_dunder_methods`, `exclude_private`, `exclude_overridden`. An exempted entity that does have a docstring is still checked.
+
+Functions and classes defined inside `if`, `try`, `with`, `for`, `while` or `match` blocks, at module or class level, are checked like the others. Functions nested inside another function are not checked, code under `if __name__ == "__main__":` is skipped, and `@overload` stubs are skipped: only the implementation needs a docstring.
```python
# Bad
@@ -75,9 +77,9 @@ def access_db() -> None:
"""Access the database."""
```
-Words not treated as third-person singular verbs: `process`, `access`, `class`, `status`, `focus`, `alias`, `analysis`, `basis`, etc.
+The first word is reported only when the base form derived from it is a known English verb (`Returns` -> `Return`, `Copies` -> `Copy`, `Does` -> `Do`). Plural nouns and other words ending in `s` pass: `Options`, `Classes`, `Status`, `Canvas`.
-Not applied to module docstrings.
+Applied to functions and methods only, not to module and class docstrings.
---
@@ -104,6 +106,32 @@ summary_max_length = 72
### Args / Returns / Raises
+#### raises_extraneous
+
+Every exception listed in `Raises:` must be raised explicitly in the body. An exception propagated from a called function is invisible to the linter: a project that documents those turns this rule off, which the `google` convention does.
+
+```python
+# Bad: TypeError documented but never raised
+def validate(x: int) -> int:
+ """Validate input.
+
+ Args:
+ x (int): Input.
+
+ Returns:
+ int: Validated input.
+
+ Raises:
+ TypeError: Never actually raised.
+
+ """
+ return x
+```
+
+Only capitalized class names count as raised: `raise ValueError`, `raise errors.ValidationError(...)`, and `raise err` inside `except ValueError as err` or a bare `raise` inside `except ValueError`. A dotted name in the docstring is compared on its last segment.
+
+---
+
#### args_order
The order of arguments in the `Args:` section must match the order in the function signature.
@@ -134,17 +162,25 @@ def process(x: int, y: str) -> None:
#### indentation
-Docstring indentation must be consistent. Nested indentation beyond a section entry is not allowed.
+The content of a section must sit under its header: every line indented by 4 spaces or more, and the first entry of `Args:`, `Attributes:` and `Raises:` by exactly 4. A description continued on deeper lines is fine. Lines outside sections are not checked, so a description may hold indented code or lists. One error is reported per misindented section.
```python
-# Bad: inconsistent indentation (3+ levels)
-def process() -> None:
+# Bad: section content indented by 2 spaces
+def process(x: int) -> None:
"""Process data.
Args:
- x (int): Input.
- Extra indent.
- Even more indent.
+ x (int): Input.
+
+ """
+
+# Good: continuation lines may go deeper
+def process(x: int) -> None:
+ """Process data.
+
+ Args:
+ x (int): Input, described on
+ several lines.
"""
@@ -193,7 +229,29 @@ def process(x: int) -> int:
"""
```
-Recognized sections: `Args`, `Returns`, `Yields`, `Raises`, `Attributes`, `Example`, `Examples`, `Note`, `Notes`, `Todo`.
+Every recognized section is covered, Napoleon ones included: see the list under [unknown_section](#unknown_section).
+
+---
+
+#### section_alias
+
+A Napoleon alias is accepted and its content checked as the canonical section, but the header must use the canonical spelling.
+
+```python
+# Bad: 'Parameters:' should be written 'Args:'
+def process(x: int) -> int:
+ """Process data.
+
+ Parameters:
+ x (int): Input.
+
+ Returns:
+ int: Result.
+
+ """
+```
+
+Turn it off with `ignore = ["section_alias"]` to accept `Arguments:`, `Parameters:` and the other aliases as they are.
---
@@ -201,7 +259,9 @@ Recognized sections: `Args`, `Returns`, `Yields`, `Raises`, `Attributes`, `Examp
Sections must appear in the expected order.
-Expected order: `Attributes` -> `Args` -> `Returns` -> `Yields` -> `Raises` -> `Example`/`Examples` -> `Note`/`Notes` -> `Todo`
+Expected order: `Attributes` -> `Args` -> `Keyword Args` -> `Other Parameters` -> `Returns` -> `Yields` -> `Raises` -> `Examples` -> `Note`/`Notes` -> `Todo`
+
+An alias takes the place of its canonical section (`Parameters` sits where `Args` does). Free-text sections such as `Warning` or `See Also` may appear anywhere.
```python
# Bad: Returns before Args
@@ -236,16 +296,28 @@ def process(x: int) -> int:
#### unknown_section
-A section name that is not in the recognized list triggers an error. Common mistake: `Arguments:` instead of `Args:`.
+A single capitalized word followed by a colon, alone on its line, that is not a recognized section triggers an error. Common mistake: `Params:` instead of `Args:`.
+
+Recognized sections, the Napoleon ones included:
+
+| Kind | Sections |
+|---|---|
+| Checked content | `Args`, `Keyword Args`, `Other Parameters`, `Returns`, `Yields`, `Raises`, `Attributes` |
+| Free text | `Examples`, `Note`, `Notes`, `Todo`, `Attention`, `Caution`, `Danger`, `Error`, `Hint`, `Important`, `Methods`, `Receive`, `Receives`, `References`, `See Also`, `Tip`, `Warn`, `Warning`, `Warnings`, `Warns` |
+| Aliases, read as their canonical section and reported by `section_alias` | `Example` (`Examples`), `Arguments`, `Parameters` (`Args`), `Keyword Arguments` (`Keyword Args`), `Return` (`Returns`), `Yield` (`Yields`), `Raise` (`Raises`) |
+
+`Note` and `Notes` are two distinct sections: Napoleon renders `Note` as an admonition box and `Notes` as a plain section.
+
+In Napoleon, `Warn` and `Warns` list the warnings a function issues, the way `Raises` lists exceptions. They are accepted here, but their entries are not checked yet.
-Recognized sections: `Args`, `Returns`, `Yields`, `Raises`, `Attributes`, `Example`, `Examples`, `Note`, `Notes`, `Todo`.
+`Other Parameters` entries are checked like `Args` entries. `Keyword Args` documents the keys of `**kwargs`: its presence counts as documenting the `**kwargs` parameter, and its entries are not compared with the signature, only their description is required.
```python
# Bad
def process(x: int) -> int:
"""Process data.
- Arguments:
+ Params:
x (int): Input.
Returns:
diff --git a/docs/configuration.md b/docs/configuration.md
index cae255e..c5e75fe 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -4,10 +4,14 @@
Configuration is loaded in this order (first match wins):
-1. Explicit `--config path/to/file.toml`
+1. Explicit `--config path/to/file.toml`. A missing file, or a `pyproject.toml` without a `[tool.docstring-linter]` section, is a configuration error.
2. Auto-discovery. Starting from the current directory and walking upward one directory at a time, `pyproject.toml` (with a `[tool.docstring-linter]` section) is checked before `.docstring-linter.toml` in each directory. The first match stops the search.
3. Built-in defaults
+The search starts from the current directory, not from the linted files. When linting from elsewhere, pass the file with `--config`.
+
+Path patterns (`exclude`, `--exclude` and the `paths` of an override) are relative to the directory holding the config file, whatever the current directory. With the built-in defaults, they are relative to the current directory.
+
### pyproject.toml
```toml
@@ -47,6 +51,7 @@ See the Available keys section below for the complete list of options.
| Key | Default | Description |
|-----|---------|-------------|
+| `convention` | `"strict"` | Set of defaults for the policies, options and rules below. See [Conventions](#conventions). |
| `select` | all rules | [Configurable rules](/docs/configurable-rules.md) to enable. `["ALL"]` enables everything. |
| `ignore` | `[]` | [Configurable rules](/docs/configurable-rules.md) to disable (applied after `select`). [Always-on rules](/docs/always-on-rules.md) cannot be listed here. |
| `returns_none` | `"required"` | Policy for `Returns: None` on `-> None` functions. Not applied when `returns_section = "forbidden"`. |
@@ -59,28 +64,137 @@ See the Available keys section below for the complete list of options.
| `raises_section` | `"required"` | Policy for documenting every exception raised. |
| `attributes_section` | `"required"` | Policy for documenting every class attribute. |
| `description_section` | `"optional"` | Policy for the description paragraph below the summary. |
-| `examples_section` | `"optional"` | Policy for the `Example:` section. |
+| `examples_section` | `"optional"` | Policy for the `Examples:` section (`Example:` accepted as an alias). |
| `notes_section` | `"optional"` | Policy for the `Note:` section. |
| `todo_section` | `"optional"` | Policy for the `Todo:` section. |
-| `documented_types` | `"required"` | Policy for the type between parentheses in `Args:` and `Attributes:` entries. |
+| `documented_types` | `"required"` | Policy for the type in `Args:` and `Attributes:` entries and on the `Returns:` and `Yields:` lines. |
| `returns_descriptions` | `"required"` | Policy for the description on the `Returns:` and `Yields:` lines. |
+| `documented_stars` | `"required"` | Policy for the stars of `*args` and `**kwargs` in their `Args:` entries. |
| `exclude_empty_init_method` | `true` | Do not require a docstring on `__init__` methods with no parameter beyond `self` and a body limited to `pass` or a docstring. |
| `exclude_empty_init_module` | `true` | Do not require a docstring on `__init__.py` files with an empty body (empty file or comments only). |
| `ignore_placeholder_docstrings` | `false` | Skip docstrings containing only `...`. |
-| `exclude` | see [built-in defaults](/docs/style-policies.md#default-exclusion-patterns) | Glob/literal patterns for files and directories to skip. |
-| `workers` | `1` | Parallel workers. `0` = auto-detect CPU count. |
+| `exclude_dunder_methods` | `false` | Do not require a docstring on magic methods (`__repr__`, `__enter__`...). `__init__` keeps its own option. |
+| `exclude_private` | `false` | Do not require a docstring on private functions, methods and classes (`_name`, `__name`) nor on the members of a private class. |
+| `exclude_overridden` | `false` | Do not require a docstring on methods decorated with `@override`, which inherit the documentation of the parent method. |
+| `sections_optional_on_one_liners` | `false` | Do not require the `Args:`, `Returns:`, `Yields:` and `Raises:` sections (nor `Returns: None`) on a one-line docstring, when the function or method has every parameter and its return annotated. A docstring of more than one line keeps every required section. |
+| `properties_as_attributes` | `false` | Document property getters like attributes: no `Returns:` section required, `imperative_mood` not applied. Property setters and deleters are not checked. |
+| `exclude` | see [Exclusion patterns](#exclusion-patterns) | Glob/literal patterns for files and directories to skip, replacing the defaults. |
+| `workers` | `0` | Parallel workers. `0` = one per usable CPU, sequential below 50 files where starting the processes costs more than it saves. Any other value is used as is, `1` being sequential. |
| `summary_max_length` | `80` | Maximum summary line length for `summary_too_long`. |
| `blank_lines_before_section` | `1` | Blank lines expected before a section header, checked by `blank_lines`. |
| `blank_lines_before_closing_quotes` | `1` | Blank lines expected before the closing `"""`, checked by `blank_lines`. |
+| `type_matching` | `"strict"` | How closely a documented type must match the signature: `"strict"`, `"equivalent"` or `"lenient"`. See [Type matching](#type-matching). |
+| `init_args_location` | `"init"` | Docstring documenting the `__init__` parameters: `"init"`, `"class"` or `"either"`. See [`__init__` parameters](#__init__-parameters). |
| `scope.modules` | `true` | Check module-level docstrings. |
| `scope.classes` | `true` | Check class docstrings. |
| `scope.functions` | `true` | Check function docstrings. |
| `scope.methods` | `true` | Check method docstrings. |
-Every policy accepts `"required"`, `"forbidden"`, or `"optional"`. For the five section policies, `"optional"` means the section is not required, but what the docstring does declare is still checked by the matching rule (`args_match`, `returns_match`, `yields_match`, `raises_match`, `attributes_match`). The two `exclude_empty_init_*` options only lift `docstring_exists`: a docstring that is present is always checked.
+Every policy accepts `"required"`, `"forbidden"`, or `"optional"`. For the five section policies, `"optional"` means the section is not required, but what the docstring does declare is still checked by the matching rule (`args_match`, `returns_match`, `yields_match`, `raises_match`, `attributes_match`). The `exclude_*` options only lift `docstring_exists`: a docstring that is present is always checked.
`docstring-linter --list-rules` prints the rules, the policies, and the options that change what gets checked, each with the value it has in the current config.
+### Exclusion patterns
+
+The following patterns are excluded by default when scanning directories:
+
+| Pattern | Type | Excludes |
+|---------|------|----------|
+| `.git` | literal | git metadata directory |
+| `.mypy_cache` | literal | mypy cache directory |
+| `.pytest_cache` | literal | pytest cache directory |
+| `.ruff_cache` | literal | ruff cache directory |
+| `.tox` | literal | tox test environments |
+| `.venv` | literal | virtual environment directory |
+| `__pycache__` | literal | Python bytecode cache |
+
+Setting `exclude` replaces this list entirely: `exclude = ["migrations"]` excludes `migrations` only, and `.venv` or `.git` are scanned again. Repeat the defaults to keep them:
+
+```toml
+[tool.docstring-linter]
+exclude = [".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".venv", "__pycache__", "migrations"]
+```
+
+A pattern excludes a file when one of these holds:
+
+- it has no wildcard and names a directory or file anywhere in the path: `.venv` excludes `src/.venv/foo.py`;
+- it matches the end of the path: `test_*.py` excludes `tests/unit/test_foo.py`;
+- it matches the whole path, relative to the directory holding the config file (the current directory without a config file): `tests/**` excludes every file under `tests/`, `src/gen/*.py` the files directly in `src/gen/`.
+
+These rules apply to the files found in a directory. A file named on the command line is always linted, unless `--force-exclude` is given: the pre-commit hook passes it, since pre-commit names every changed file explicitly.
+
+Test files are linted like any other file; add `"test_*"` and `"*_test.py"` to `exclude` to skip them.
+
+### Conventions
+
+`convention` picks the defaults every other key starts from. Keys written in the config file always win over the convention, `select` and `ignore` included.
+
+| Setting | `"strict"` (default) | `"google"` |
+|---|---|---|
+| `returns_none` | `"required"` | `"optional"` |
+| `init_returns_none` | `"forbidden"` | `"optional"` |
+| `documented_types` | `"required"` | `"optional"` |
+| `raises_section` | `"required"` | `"optional"` |
+| `attributes_section` | `"required"` | `"optional"` |
+| `blank_lines_before_closing_quotes` | `1` | `0` |
+| `type_matching` | `"strict"` | `"lenient"` |
+| `imperative_mood` rule | enabled | disabled |
+| `return_type_annotation` rule | enabled | disabled |
+| `raises_extraneous` rule | enabled | disabled |
+| `exclude_dunder_methods`, `exclude_private`, `exclude_overridden`, `properties_as_attributes`, `sections_optional_on_one_liners` | `false` | `true` |
+| `init_args_location` | `"init"` | `"either"` |
+
+`"strict"` enforces a complete house style: every type repeated in the docstring, `Returns: None` on `-> None` functions, every exception and attribute documented, a blank line before the closing quotes.
+
+`"google"` follows the layout of the Google Python Style Guide: types live in the signature, a function returning `None` has no `Returns:` section, the summary may be descriptive (`Fetches rows.`) or imperative, and the closing quotes follow the last line. What the docstring declares is still checked: a documented type must match the signature, a documented exception must be raised.
+
+```toml
+[tool.docstring-linter]
+convention = "google"
+raises_section = "required" # stricter than the convention on this point
+```
+
+`convention` applies to the whole run and cannot be set in an override.
+
+### Type matching
+
+When a docstring declares a type, `args_match` and `returns_match` compare it with the annotation of the signature. `type_matching` sets how close the two must be. Each level accepts everything the previous one accepts.
+
+| Level | Also considered identical | Example (docstring vs signature) |
+|---|---|---|
+| `"strict"` | Quotes of forward references, spacing, the `, optional` suffix, the Sphinx `~` prefix | `Node` vs `'Node'`, `bool, optional` vs `bool`, `~Console` vs `Console` |
+| `"equivalent"` | The same type spelled differently: `Optional[X]`, `Union[X, None]` and `X \| None`; `List`, `Dict`, `Set`, `Tuple`, `Type` and their builtin form | `str \| None` vs `Optional[str]`, `List[int]` vs `list[int]` |
+| `"lenient"` | A docstring leaving `None` implicit when the signature accepts it | `int, optional` vs `Optional[int]` |
+
+Differences that change the type are reported at every level: `int` vs `str`, `List[int]` vs `Iterable[int]`, `IO` vs `IO[str]`. A documented type that is not a Python expression (`list of int`) is compared as text.
+
+`type_matching` may be set in an override.
+
+### `__init__` parameters
+
+The parameters of a constructor are documented either in the `__init__` docstring or in the class docstring. `init_args_location` says where the linter looks for them.
+
+| Value | Where the parameters go | `__init__` docstring |
+|---|---|---|
+| `"init"` | `Args:` of `__init__` | Required, unless `exclude_empty_init_method` applies |
+| `"class"` | `Args:` of the class | Optional; an `Args:` section in it is reported |
+| `"either"` | `Args:` of the class when it has one, `Args:` of `__init__` otherwise | Optional when the class has `Args:`; documenting the parameters in both places is reported |
+
+When the class documents them, `args_section`, `args_match`, `duplicate_arg` and `args_order` compare the `Args:` section of the class with the `__init__` signature, and report on the class.
+
+```python
+# init_args_location = "either"
+class Cache:
+ """Store computed values.
+
+ Args:
+ size (int): Maximum number of values.
+ """
+
+ def __init__(self, size: int) -> None:
+ """Create the cache."""
+```
+
### Per-path overrides
A base configuration plus any number of `[[tool.docstring-linter.overrides]]` blocks. Each block declares the path patterns it applies to, then the settings it changes.
@@ -118,18 +232,18 @@ paths = ["example/**", "docs/**"]
select = ["docstring_exists"]
```
-- `paths` is required and matched with `PurePath.full_match`, so `tests/**` covers the whole tree. A path given on the command line as an absolute path is matched relative to the current directory as well.
+- `paths` is required and matched with `PurePath.full_match` against the file path relative to the config file directory, so `tests/**` covers the whole tree. A file outside that directory matches no override.
- **A single block applies to a given file**: the last declared among those matching it. The other matching blocks are ignored, blocks never accumulate. Declare the general case first and the exceptions after it, and make each block self-contained.
- The block that applies is resolved against the base configuration, so a setting it does not declare keeps its base value, not the linter default.
- `ignore` removes rules from the inherited set, `select` replaces that set entirely. Same meaning as at the base level.
-- An override may carry any policy, and the options that change what is checked on a file: `summary_max_length`, `blank_lines_before_section`, `blank_lines_before_closing_quotes`, `exclude_empty_init_method`, `exclude_empty_init_module`, `ignore_placeholder_docstrings`.
+- An override may carry any policy, and the options that change what is checked on a file: `summary_max_length`, `blank_lines_before_section`, `blank_lines_before_closing_quotes`, `exclude_empty_init_method`, `exclude_empty_init_module`, `ignore_placeholder_docstrings`, `exclude_dunder_methods`, `exclude_private`, `exclude_overridden`, `properties_as_attributes`, `sections_optional_on_one_liners`, `type_matching`, `init_args_location`.
- `exclude`, `workers` and `scope.*` apply to the whole run rather than individual files, so they are rejected inside an override.
`docstring-linter --list-rules` prints the overrides after the base configuration, showing only what each one changes.
### Strict configuration
-Anything the linter does not recognize is an error, reported on stderr with exit code 2 before any file is read. This covers a key absent from the table above (including inside `[scope]` and inside an override), a rule name absent from `--list-rules` in `select` or `ignore`, an always-on rule listed in `ignore`, or an invalid policy value.
+Anything the linter does not recognize is an error, reported on stderr with exit code 2 before any file is read. This covers a key absent from the table above (including inside `[scope]` and inside an override), a rule name absent from `--list-rules` in `select` or `ignore`, an always-on rule listed in `ignore`, an invalid policy value, or a value of the wrong type (`workers = "4"`, `exclude = "src"`).
```console
$ docstring-linter src/
diff --git a/docs/style-policies.md b/docs/style-policies.md
index 8631282..d8405e3 100644
--- a/docs/style-policies.md
+++ b/docs/style-policies.md
@@ -25,6 +25,8 @@ Section presence policies, one per documentable section:
| `raises_section` | `"required"` | `Raises:` |
| `attributes_section` | `"required"` | `Attributes:` |
+With `sections_optional_on_one_liners = true`, a one-line docstring on a fully annotated function or method needs none of these sections: the Google guide omits them when the name and signature say it all. A docstring of more than one line still needs every required section.
+
When present, the content of these sections is also validated by the corresponding content rules. The policy controls whether the section must exist; the corresponding content rule validates what it contains (`args_match`, `returns_match`, `yields_match`, `raises_match`, and `attributes_match`).
Four more sections carry no content rule, only a presence policy. They default to `"optional"`, nothing changes unless you set them:
@@ -32,7 +34,7 @@ Four more sections carry no content rule, only a presence policy. They default t
| Policy | Default | Applies to |
|---|---|---|
| `description_section` | `"optional"` | Description paragraph below the summary |
-| `examples_section` | `"optional"` | `Example:` or `Examples:` |
+| `examples_section` | `"optional"` | `Examples:` (`Example:` accepted as an alias) |
| `notes_section` | `"optional"` | `Note:` or `Notes:` |
| `todo_section` | `"optional"` | `Todo:` |
@@ -40,8 +42,9 @@ One more policy governs what an entry declares:
| Policy | Default | Applies to |
|---|---|---|
-| `documented_types` | `"required"` | Type between parentheses in `Args:` and `Attributes:` entries |
+| `documented_types` | `"required"` | Type in `Args:` and `Attributes:` entries and on the `Returns:` and `Yields:` lines |
| `returns_descriptions` | `"required"` | Description on the `Returns:` and `Yields:` lines |
+| `documented_stars` | `"required"` | Stars of `*args` and `**kwargs` in their `Args:` entries |
The two halves are independent. The policy answers "must this be documented", the rule answers "is what is documented correct". Under `"optional"`, nothing forces you to document, but everything you do document is still checked. Under `"forbidden"`, the section is rejected and the content rule is not run, to avoid reporting the same block twice.
@@ -55,7 +58,7 @@ Governs the `Returns: None` section on every function or method whose signature
Not applied at all when `returns_section = "forbidden"`: that value drops the `Returns:` section from the whole docstring, `-> None` functions included.
-A one-liner docstring cannot contain a `Returns:` section, so under `"required"` a one-liner on a `-> None` function is an error.
+A one-liner docstring cannot contain a `Returns:` section, so under `"required"` a one-liner on a `-> None` function is an error, unless `sections_optional_on_one_liners` applies.
```toml
[tool.docstring-linter]
@@ -190,6 +193,8 @@ def process() -> None:
Every parameter of the signature must be documented in the `Args:` section.
+The first parameter of a method (`self`, `cls`, `mcs`, whatever its name) is not documented, except on a `@staticmethod`. The parameters of `__init__` may be documented in the class docstring instead, see [`init_args_location`](/docs/configuration.md#__init__-parameters). On a plain function, every parameter counts, including one named `self` or `cls`.
+
```toml
[tool.docstring-linter]
args_section = "required"
@@ -283,7 +288,7 @@ def read_lines(path: str) -> Iterator[str]:
### raises_section
-Every exception explicitly raised in the body must be documented in the `Raises:` section. Bare, dynamic and indirect raises are never collected, see [Automatic Exemptions](#automatic-exemptions).
+Every exception explicitly raised in the body must be documented in the `Raises:` section. Dynamic and indirect raises are never collected, see [Automatic Exemptions](#automatic-exemptions).
```toml
[tool.docstring-linter]
@@ -385,7 +390,7 @@ Setting it to `"required"` makes every one-liner docstring invalid, including on
### examples_section, notes_section, todo_section
-Same three values, applied to the corresponding section header. Both spellings are accepted where they exist: `Example:` and `Examples:`, `Note:` and `Notes:`.
+Same three values, applied to the corresponding section header. `Example:` counts as `Examples:`, and is reported by `section_alias`. `Note:` and `Notes:` are two distinct sections, either one satisfies `notes_section`.
```toml
[tool.docstring-linter]
@@ -418,9 +423,9 @@ def process(x: int) -> int:
### documented_types
-Governs the type declared between parentheses in `Args:` and `Attributes:` entries. The signature already carries the type, so a project may consider the docstring copy redundant.
+Governs the type declared between parentheses in `Args:` and `Attributes:` entries, and before the colon on the `Returns:` and `Yields:` lines. The signature already carries the type, so a project may consider the docstring copy redundant.
-Does not apply to `Returns:` and `Yields:`, where the type is the payload of the line: removing it would leave prose with nothing to identify. Their types stay mandatory, checked by `returns_match` and `yields_match`.
+A `Returns: None` line is exempt: `None` is the whole content of the line, it is accepted whatever the policy. How closely a type that is present must match the signature is set by [`type_matching`](/docs/configuration.md#type-matching).
```toml
[tool.docstring-linter]
@@ -448,18 +453,43 @@ def create_user(name: str) -> dict:
name: User name.
Returns:
- dict: User record.
+ User record.
"""
# optional: both forms accepted, and a type that is present is still
-# compared with the signature by args_match
+# compared with the signature by args_match and returns_match
```
The description of an entry is never optional: an `Args:`, `Attributes:`, `Raises:` or `Yields:` entry without a description is always reported. A name alone carries no information the signature does not already give.
---
+### documented_stars
+
+Governs the stars of `*args` and `**kwargs` in their `Args:` entries. A plain parameter documented with a star is reported whatever the value.
+
+```toml
+[tool.docstring-linter]
+documented_stars = "required"
+```
+
+```python
+def group(*renderables: str, fit: bool = True) -> None:
+ """Group renderables.
+
+ Args:
+ *renderables (str): Items to group. # required (default)
+ renderables (str): Items to group. # forbidden
+ fit (bool): Fit the width.
+
+ """
+```
+
+Under `"optional"` both spellings are accepted. Whatever the value, an entry written with the wrong spelling is reported once, as `Arg 'renderables' must be written '*renderables'.`, and still counts as documenting the parameter.
+
+---
+
### returns_descriptions
Governs the description on the `Returns:` and `Yields:` lines. Both are the same slot, a function documents one or the other.
@@ -503,11 +533,19 @@ def get_name() -> str:
|------|----------|
| Empty `__init__` method (`pass` only, no parameters) | Docstring not required if `exclude_empty_init_method = true` (default) |
| Empty `__init__.py` file (empty or comments only) | Docstring not required if `exclude_empty_init_module = true` (default) |
-| `self`, `cls` | Ignored in parameters |
-| `*args`, `**kwargs` | Documented with their stars, `*args (str): ...` |
-| Bare / dynamic / indirect `raise` | Ignored by `raises_match` |
+| Magic method other than `__init__` | Docstring not required if `exclude_dunder_methods = true` |
+| Private name, or member of a private class | Docstring not required if `exclude_private = true` |
+| Method decorated with `@override` | Docstring not required if `exclude_overridden = true` |
+| Property getter (`@property`, `@cached_property`) | No `Returns:` section required and no `imperative_mood` if `properties_as_attributes = true` |
+| Property setter or deleter | Not checked at all if `properties_as_attributes = true` |
+| First parameter of a method (`self`, `cls`, `mcs`, ...) | Ignored in parameters, except on a `@staticmethod` |
+| `*args`, `**kwargs` | Documented with or without their stars according to `documented_stars` |
+| `raise variable`, `raise make_error()`, bare `raise` outside a typed `except` | Not collected: only capitalized class names count (`raise ValueError`, `raise errors.ValidationError(...)`) |
+| `raise err` inside `except (A, B) as err`, bare `raise` inside `except (A, B)` | Collected as the caught types `A` and `B` |
+| Code under `if __name__ == "__main__":` | Not scanned, its `else` branch is |
+| `@overload` stubs | Not scanned, the implementation is |
| Files excluded by pattern | Not scanned |
-| Module docstrings | `imperative_mood` not applied |
+| Module and class docstrings | `imperative_mood` not applied |
### exclude_empty_init_method
@@ -560,29 +598,4 @@ from .core import main
exclude_empty_init_module = false # check empty __init__.py files too
```
-This option only covers the empty case. To skip every `__init__.py` regardless of its content, use `exclude = ["__init__.py"]`.
-
-## Exclusion Patterns
-
-### Default Exclusion Patterns
-
-The following patterns are excluded by default when scanning directories:
-
-| Pattern | Type | Excludes |
-|---------|------|----------|
-| `.git` | literal | git metadata directory |
-| `.mypy_cache` | literal | mypy cache directory |
-| `.pytest_cache` | literal | pytest cache directory |
-| `.ruff_cache` | literal | ruff cache directory |
-| `.tox` | literal | tox test environments |
-| `.venv` | literal | virtual environment directory |
-| `__pycache__` | literal | Python bytecode cache |
-
-Literal patterns match any directory component in the path (e.g. `.venv` excludes `src/.venv/foo.py`). Test files are linted like any other file; exclude them explicitly with `exclude = ["test_*", "*_test.py"]` if desired.
-
-Override defaults in `pyproject.toml`:
-
-```toml
-[tool.docstring-linter]
-exclude = [".venv", "__pycache__", "migrations/"]
-```
+This option only covers the empty case. To skip every `__init__.py` regardless of its content, add `"__init__.py"` to `exclude` (see [Exclusion patterns](/docs/configuration.md#exclusion-patterns)).
diff --git a/example/docstring_format_reference.py b/example/docstring_format_reference.py
index 57edf83..a010d97 100644
--- a/example/docstring_format_reference.py
+++ b/example/docstring_format_reference.py
@@ -47,7 +47,7 @@ def process(self, value: int, *, strict: bool = False) -> dict[str, int]:
Raises:
ValueError: When this exception is raised.
- Example:
+ Examples:
>>> obj = MyClass("test")
>>> obj.process(42)
{'result': 42}
diff --git a/pyproject.toml b/pyproject.toml
index 61ee3b6..00e104d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -62,19 +62,18 @@ source = ["src"]
[tool.coverage.report]
omit = ["tests/*", "example/*"]
-fail_under = 85
+fail_under = 90
[tool.ruff]
target-version = "py314"
line-length = 200
src = ["src/linter", "tests"]
-exclude = [".vulture"]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
- "D203", # en conflit avec D211 (on garde D211 : blank-line-before-class)
- "D213", # en conflit avec D212 (on garde D212 : multi-line-summary-first-line)
+ "D203", # conflicts with D211 (D211 kept: blank-line-before-class)
+ "D213", # conflicts with D212 (D212 kept: multi-line-summary-first-line)
"COM812", # missing-trailing-comma
"CPY001", # missing copyright notice at top of file
]
@@ -143,14 +142,3 @@ github-tests = [
"ruff==0.17.0",
"vulture==2.16",
]
-
-[tool.sqlfluff.core]
-dialect = "mariadb"
-templater = "jinja"
-sql_file_exts = ".sql,.sql.j2,.dml,.ddl"
-max_line_length = 200
-
-[tool.sqlfluff.indentation]
-indented_joins = false
-indented_using_on = true
-template_blocks_indent = false
diff --git a/review/00-synthese.md b/review/00-synthese.md
new file mode 100644
index 0000000..26b4279
--- /dev/null
+++ b/review/00-synthese.md
@@ -0,0 +1,125 @@
+# Revue tierce de docstring-linter - Synthèse
+
+Date : 2026-09-26. Révision analysée : `0957240` (branche `develop`).
+
+## Objet et méthode
+
+Revue exhaustive du projet : architecture, code, justesse fonctionnelle des règles, performances, usage (CLI, configuration, intégrations), documentation, packaging/CI, positionnement face à la concurrence.
+
+Ce qui a été fait concrètement :
+
+- Lecture intégrale de `src/linter/` (2 470 lignes), des docs, de la CI, des scripts et de la configuration outillage.
+- Exécution de la suite de tests sous Python 3.14.7 : **372 tests passent en 1,45 s, couverture branches 94,39 %**.
+- Exécution de ruff, pylint et pyright sur le projet : 0 erreur (pylint 10/10).
+- Fichiers de cas limites écrits pour la revue (reproduits dans les rapports) et exécutés.
+- Benchmarks sur la stdlib CPython 3.14 (1 059 fichiers) et sur le paquet `rich` 15.0.0 (100 fichiers, 38 515 lignes).
+- Profilage cProfile d'une exécution complète.
+- Comparaison exécutée avec `pydoclint` 0.9.1, `docsig` 0.96.0 et `ruff` 0.16.8 (règles `D` et `DOC`).
+
+## Convention de preuve
+
+Chaque constat porte l'un des marqueurs suivants :
+
+| Marqueur | Signification |
+|---|---|
+| **[Vérifié]** | Reproduit par exécution ou lu dans le code, preuve citée (commande, sortie, `fichier:ligne`). |
+| **[Déduit]** | Conclusion tirée de la lecture du code ou d'une documentation connue, non exécutée ici. |
+| **[Non vérifié]** | Connaissance externe (écosystème, conventions) non revérifiée pendant la revue. |
+
+Gravité : **Critique** (résultat faux ou CI verte à tort), **Haute** (bloque l'adoption), **Moyenne**, **Basse**.
+
+## Rapports
+
+| Fichier | Contenu |
+|---|---|
+| [01-bugs-et-faux-positifs.md](01-bugs-et-faux-positifs.md) | Défauts fonctionnels reproduits, faux positifs mesurés, correctifs proposés |
+| [02-architecture-et-code.md](02-architecture-et-code.md) | Structure, modèle de règles, parsing, configuration, tests |
+| [03-performance.md](03-performance.md) | Mesures, profil, goulots, pistes chiffrées |
+| [04-usage-cli-configuration.md](04-usage-cli-configuration.md) | Expérience utilisateur, défauts, CLI, sorties, pre-commit, GitHub Action |
+| [05-documentation.md](05-documentation.md) | README, docs, fichiers annexes, incohérences |
+| [06-packaging-ci-outillage.md](06-packaging-ci-outillage.md) | Distribution, versions Python, workflows, chaîne d'approvisionnement |
+| [07-positionnement-et-concurrence.md](07-positionnement-et-concurrence.md) | Intérêt du projet, comparatif mesuré, stratégie |
+
+## Suivi des corrections
+
+État à la révision `8bb902d` de la branche `claude/project-comprehensive-review-qvro8c`. Le verdict, le tableau de bord et les priorités ci-dessous décrivent la révision `0957240` analysée ; chaque point des rapports 01 à 06 porte maintenant une ligne **Statut**.
+
+| Statut | Signification |
+|---|---|
+| Corrigé | Proposition appliquée, revérifiée sur le code actuel. |
+| Partiel | Une partie appliquée ; le reste est détaillé sous le point. |
+| Reporté | Reporté explicitement (lot 0, gestion de version, baseline, PyPI, ligne des erreurs, autofix, CHANGELOG et CONTRIBUTING, liens du README). |
+| Écarté | Décision de ne pas appliquer. |
+| Non traité | Pas encore discuté. |
+
+| Rapport | Corrigé | Partiel | Reporté | Écarté | Non traité | Total |
+|---|---|---|---|---|---|---|
+| 01 - Bugs (BUG) | 23 | 0 | 0 | 0 | 0 | 23 |
+| 02 - Architecture (ARCH) | 8 | 2 | 0 | 0 | 0 | 10 |
+| 03 - Performance (PERF) | 3 | 0 | 0 | 3 | 0 | 6 |
+| 04 - Usage (UX) | 4 | 5 | 3 | 1 | 0 | 13 |
+| 05 - Documentation (DOC) | 7 | 0 | 2 | 1 | 0 | 10 |
+| 06 - Packaging et CI (OPS) | 4 | 0 | 6 | 3 | 0 | 13 |
+| **Total** | **49** | **7** | **11** | **8** | **0** | **75** |
+
+Mesures sur la révision `8bb902d` [Vérifié] :
+
+- Tests : 589 (372 à la revue), couverture branches 96,25 % (94,39 %), seuil CI 90 % (85 % à la revue).
+- `rich` 15.0.0 : 1 990 erreurs avec les défauts (`strict`), 758 avec `convention = "google"`, contre 2 410 à la revue. Les 2 erreurs de plus depuis `89c4a28` sont deux `raise` nus réels, dans `console.py` et `live.py`. Les 100 fichiers restent signalés, notamment parce que 95 modules n'ont pas de docstring.
+- Les fichiers de reproduction de l'annexe du rapport 01 ne produisent plus que les erreurs attendues : avec `convention = "google"` et une ligne vide avant `"""`, il reste `hidden_in_if` (sans docstring) et `Child.run` (surcharge sans `@override`).
+
+Principaux points ouverts : lot 0 (OPS-01 à OPS-03, matrice de versions), baseline (UX-03), publication PyPI (OPS-05).
+
+## Verdict global
+
+Le code est propre, strictement typé, bien testé au niveau unitaire et sans dépendance. Le concept de **politiques tri-états** (`required` / `forbidden` / `optional`) appliqué uniformément et la **validation stricte de la configuration** sont de vrais points forts.
+
+En revanche, l'outil n'est pas encore prêt pour un usage hors du dépôt lui-même :
+
+1. **Échecs silencieux avec code de sortie 0** : style non supporté, fichier illisible, erreur de syntaxe, chemin inexistant, `--config` introuvable. En CI, ces cas passent au vert. [Vérifié]
+2. **Faux positifs massifs sur du Google style réel** : sur `rich`, 2 410 erreurs dans 100 % des fichiers ; 318 des 520 "type mismatch" ne diffèrent que par `, optional`, 84 par des guillemets de forward reference. [Vérifié]
+3. **Aucune échappatoire** : pas de suppression inline (`# noqa`), pas de baseline, et 11 règles "always-on" non désactivables. Un seul faux positif est bloquant. [Vérifié]
+4. **Extraction AST incomplète** : fonctions sous `if`/`try` ignorées, générateur imbriqué qui contamine la fonction parente, `raise err` interprété comme un type d'exception, exceptions pointées (`mod.Error`) invisibles. [Vérifié]
+5. **Distribution** : paquet importable nommé `linter` (collision probable), Python `>=3.14,<3.15` uniquement, pas de `[build-system]`, absent de PyPI. [Vérifié]
+
+## Tableau de bord
+
+Note à la revue : révision `0957240`. Note actuelle : révision `87b2f1d`, appréciation fondée sur les statuts des rapports 01 à 06 et les mesures du suivi.
+
+| Axe | Note à la revue /5 | Note actuelle /5 | Commentaire actuel |
+|---|---|---|---|
+| Qualité du code | 4 | 4,5 | Dispatcher découpé sans `noqa`, registres et documentation vérifiés par des tests, code mort et abstraction multi-style supprimés. |
+| Justesse des règles | 2 | 4 | 23 bugs corrigés ; sur `rich`, 758 erreurs en `google` contre 2 410, surtout de vrais défauts. Restent : fonctions imbriquées non analysées, surcharges sans `@override`, attributs privés exigés. |
+| Tests | 3,5 | 4,5 | 597 tests dont 41 de bout en bout et des tests de cohérence, couverture 96,31 %, seuil CI à 90 %, pytest et linters réellement exécutés sur les PR. Pas de test de corpus (décision). |
+| Performance | 4 | 4,5 | Workers automatiques et dossiers exclus non parcourus : stdlib de 5,1 s à 1,8 s, `rich` de 0,46 s à 0,30 s. |
+| Usage / DX | 2 | 4 | Convention `google`, suppression par entité (`# docstring-linter: ignore[...]`), codes de sortie 0 à 3, `--statistics`, `--force-exclude`, chemins relatifs à la config, couleurs maîtrisées. Manquent baseline, `--fix` et la ligne précise des erreurs. |
+| Documentation | 3 | 4 | README réécrit (installation, sortie réelle, codes de sortie, limites connues, comparatif), pages de configuration à jour et vérifiées par des tests. Manquent CHANGELOG et CONTRIBUTING. |
+| Packaging / CI | 2,5 | 3 | CI durcie (actions épinglées, permissions minimales, empreinte de dotenv-linter, lint réel des PR). Restent le nom de paquet `linter`, le build backend, Python 3.14 seul, l'absence de PyPI et le flux beta. |
+| Positionnement | 2,5 | 3 | La convention `google` rapproche l'outil des usages courants et le README le situe face à ruff, pydoclint et docsig. Freins restants : absent de PyPI, pas de baseline. |
+
+## Priorités recommandées
+
+| Prio | ID | Action | Effort | Statut |
+|---|---|---|---|---|
+| P0 | BUG-01 à BUG-04 | Codes de sortie non nuls et messages sur stderr pour tout échec d'analyse ou de configuration | S | Corrigé |
+| P0 | UX-01 | Suppression inline `# docstring-linter: ignore[rule]` (ligne `def`/`class`) | M | Corrigé |
+| P0 | BUG-12, BUG-13 | Normaliser les types avant comparaison (`, optional`, guillemets, `Optional[X]`) | M | Corrigé |
+| P0 | BUG-08 à BUG-10, BUG-15, BUG-16 | Corriger l'extraction AST (portées imbriquées, `raise` de variable, noms pointés, blocs `if`/`try`, premier paramètre) | M | Corrigé |
+| P0 | BUG-14 | Corriger ou supprimer la règle `indentation` | S | Corrigé |
+| P0 | OPS-01 à OPS-03 | Renommer le paquet importable, déclarer le build backend, élargir `requires-python` | S | Reporté |
+| P1 | BUG-05 à BUG-07 | `select = ["ALL"]` en override, typage des valeurs de config, glob d'exclusion | S | Corrigé |
+| P1 | UX-02, UX-04 | Preset "google" moins strict, options de portée (privé, dunder, overload, property) | M | Corrigé, Partiel |
+| P1 | BUG-18, BUG-19 | Sections Napoleon à deux mots, `Returns:` sans type | M | Corrigé |
+| P1 | DOC-01 à DOC-03 | README (installation, positionnement), docs obsolètes | S | Corrigé |
+| P1 | OPS-05, OPS-06 | Publication PyPI, flux de release beta | S | Reporté, Écarté |
+| P2 | UX-03, UX-08 | Baseline, autofix des règles de mise en page | L | Reporté |
+| P2 | ARCH-02, ARCH-03, PERF-01 | Registre de règles déclaratif, modèle ligne à ligne du docstring, passe AST unique | L | Corrigé, Partiel, Corrigé |
+
+Effort : S = moins d'une journée, M = 1 à 3 jours, L = plus.
+
+## Points à discuter en priorité
+
+1. **Philosophie des règles always-on** : faut-il garder 11 règles non désactivables sans suppression inline ? (voir ARCH-06, UX-01)
+2. **Cible** : style maison strict (état actuel) ou Google "officiel" par défaut ? Cela conditionne les valeurs par défaut (UX-02) et le positionnement (07).
+3. **Versions Python** : 3.14 seul est-il un choix assumé ? (OPS-03)
+4. **Contribuer à pydoclint plutôt que concurrencer ?** Option à évaluer honnêtement (07, section Stratégie).
diff --git a/review/01-bugs-et-faux-positifs.md b/review/01-bugs-et-faux-positifs.md
new file mode 100644
index 0000000..eb5650e
--- /dev/null
+++ b/review/01-bugs-et-faux-positifs.md
@@ -0,0 +1,842 @@
+# 01 - Bugs et faux positifs
+
+Tous les cas marqués **[Vérifié]** ont été reproduits avec `docstring-linter` installé depuis la révision `0957240`, sous Python 3.14.7, avec `--config /nonexistent` (configuration par défaut) sauf mention contraire. Les fichiers de test utilisés sont reproduits en annexe.
+
+## Sommaire
+
+| ID | Gravité | Sujet | Preuve | Statut |
+|---|---|---|---|---|
+| BUG-01 | Critique | Style non Google accepté, aucun fichier analysé, exit 0 | Vérifié | Corrigé |
+| BUG-02 | Critique | Erreur de syntaxe / d'encodage : exit 0, message sur stdout, JSON pollué | Vérifié | Corrigé |
+| BUG-03 | Critique | Chemin inexistant : "No Python files found." et exit 0 | Vérifié | Corrigé |
+| BUG-04 | Haute | `--config` vers un fichier absent : défauts silencieux | Vérifié | Corrigé |
+| BUG-05 | Haute | `select = ["ALL"]` dans un override désactive toutes les règles configurables | Vérifié | Corrigé |
+| BUG-06 | Moyenne | Types des valeurs de config non validés (traceback ou comportement absurde) | Vérifié | Corrigé |
+| BUG-07 | Haute | `exclude` : les globs `**` et `dir/*` ne fonctionnent pas | Vérifié | Corrigé |
+| BUG-08 | Haute | Générateur imbriqué : la fonction parente est traitée comme générateur | Vérifié | Corrigé |
+| BUG-09 | Haute | `raise err` : `err` exigé dans `Raises:` | Vérifié | Corrigé |
+| BUG-10 | Haute | `raise mod.Error()` invisible, `mod.Error` non parsable dans `Raises:` | Vérifié | Corrigé |
+| BUG-11 | Moyenne | Exceptions propagées impossibles à documenter (règle always-on) | Vérifié | Corrigé |
+| BUG-12 | Critique | `(int, optional)` produit un type mismatch always-on | Vérifié | Corrigé |
+| BUG-13 | Haute | Forward reference `"Node"` produit un type mismatch always-on | Vérifié | Corrigé |
+| BUG-14 | Haute | `indentation` en erreur dès qu'une description tient sur deux lignes | Vérifié | Corrigé |
+| BUG-15 | Haute | `def` sous `if`/`try`/`with` jamais analysés | Vérifié | Corrigé |
+| BUG-16 | Moyenne | `self`/`cls` ignorés par nom et non par position | Vérifié | Corrigé |
+| BUG-17 | Moyenne | Heuristique d'impératif : "Does" -> "Doe", "Settings" -> "Setting" | Vérifié | Corrigé |
+| BUG-18 | Moyenne | Sections Napoleon à deux mots avalées silencieusement | Vérifié | Corrigé |
+| BUG-19 | Haute | `Returns:` sans préfixe de type (forme du guide Google) rejeté, always-on | Vérifié | Corrigé |
+| BUG-20 | Basse | `Returns:` : description avec `:` lue comme un type, continuation perdue | Vérifié | Corrigé |
+| BUG-21 | Moyenne | `@overload` exige une docstring par signature | Vérifié | Corrigé |
+| BUG-22 | Basse | Annotations GitHub non échappées | Déduit | Corrigé |
+| BUG-23 | Basse | "1 files checked" | Vérifié | Corrigé |
+
+---
+
+## BUG-01 - Style non Google accepté, exit 0 [Vérifié] - Critique
+
+**Statut : Corrigé.** `b2fd640`. `style = "numpy"` donne "Configuration error: 'style': invalid value 'numpy', expected one of google." et le code 2 [Vérifié].
+
+`DocstringStyle` expose `numpy`, `sphinx`, `pep257` (`config.py:15-29`), la config les accepte (`_parse_style`, `config.py:491`), mais seul Google a un parser (`docstring_parser.py:345`). `get_parser` lève `ValueError`, capturée fichier par fichier dans `_lint_file_safe` (`cli.py:142`), imprimée, puis ignorée pour le code de sortie.
+
+```console
+$ echo 'style = "numpy"' > .docstring-linter.toml
+$ docstring-linter tests/; echo "exit=$?"
+Configuration error for tests/test_a.py: Unsupported docstring style: numpy
+1 files checked, 0 errors.
+exit=0
+```
+
+Correctif : réduire l'enum à `GOOGLE` (ou rejeter au chargement si `style not in PARSERS`), ce qui déclenche l'exit 2 déjà prévu par `main()`.
+
+```python
+# config.py - reject styles that have no parser at load time
+def _parse_style(value: object) -> DocstringStyle:
+ supported = ("google",)
+ if value not in supported:
+ msg = f"'style': invalid value {value!r}, expected one of {', '.join(supported)}."
+ raise ValueError(msg)
+ return DocstringStyle(value)
+```
+
+## BUG-02 - Erreurs d'analyse : exit 0, stdout, JSON invalide [Vérifié] - Critique
+
+**Statut : Corrigé.** `b2fd640`. Message sur stderr, JSON valide sur stdout, code 2 [Vérifié].
+
+`cli.py:140-143` transforme `SyntaxError` et `ValueError` en message texte, imprimé sur **stdout** (`cli.py:184`, `cli.py:192`), sans effet sur le code de sortie. `UnicodeDecodeError` hérite de `ValueError` et s'affiche comme "Configuration error".
+
+```console
+$ printf 'def f(:\n' > src/broken.py
+$ docstring-linter src/broken.py --format json; echo "exit=$?"
+Syntax error in src/broken.py: invalid syntax (broken.py, line 1)
+{
+ "summary": { "files_checked": 1, "total_errors": 0, "files_with_errors": 0 },
+ "errors": []
+}
+exit=0
+
+$ printf '\xff\xfe bad' > src/latin.py
+$ docstring-linter src/latin.py
+Configuration error for src/latin.py: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
+1 files checked, 0 errors.
+```
+
+Conséquences : le JSON n'est plus parsable (`jq` échoue), une CI passe au vert sur un fichier cassé.
+
+Correctif : messages sur `stderr`, compteur d'échecs, code de sortie 2 si au moins un fichier n'a pas pu être analysé ; distinguer `UnicodeDecodeError` et `OSError` ; en format `github-annotations`, émettre aussi un `::error`.
+
+```python
+# cli.py - keep failures out of stdout and out of the "config error" label
+except SyntaxError as e:
+ return filepath, [], f"{filepath}: syntax error: {e}"
+except (UnicodeDecodeError, OSError) as e:
+ return filepath, [], f"{filepath}: cannot read file: {e}"
+```
+
+```python
+# cli.py, in run() - report on stderr and fail the run
+failures = 0
+...
+if err_msg:
+ print(err_msg, file=sys.stderr)
+ failures += 1
+...
+if failures:
+ return 2
+return 1 if all_errors else 0
+```
+
+## BUG-03 - Chemin inexistant : exit 0 [Vérifié] - Critique
+
+**Statut : Corrigé.** `b2fd640`. "Path not found: nope/" sur stderr, code 2 [Vérifié].
+
+`collect_python_files` (`cli.py:21`) ignore silencieusement un chemin qui n'est ni un fichier `.py` ni un répertoire. Une faute de frappe dans un workflow désactive le linter sans bruit.
+
+```console
+$ docstring-linter srcc/; echo "exit=$?"
+No Python files found.
+exit=0
+```
+
+Correctif : erreur explicite et exit 2 pour un chemin inexistant. Garder "No Python files found." (exit 0) uniquement pour un répertoire existant mais vide.
+
+## BUG-04 - `--config` introuvable : défauts silencieux [Vérifié] - Haute
+
+**Statut : Corrigé.** `b2fd640`. "config file not found", code 2 [Vérifié]. Décision associée : un `pyproject.toml` passé explicitement sans section `[tool.docstring-linter]` est aussi une erreur.
+
+`_find_config` renvoie `None` si le chemin explicite n'existe pas (`config.py:425-429`), ce qui charge les défauts.
+
+```console
+$ docstring-linter src/ --config /nonexistent
+Config: defaults (no config file found)
+```
+
+Correctif : lever `ValueError(f"config file not found: {explicit_path}")`. Même traitement si `--config pyproject.toml` ne contient pas de section `[tool.docstring-linter]` (aujourd'hui : défauts silencieux, `config.py:405-407`).
+
+## BUG-05 - `select = ["ALL"]` dans un override [Vérifié] - Haute
+
+**Statut : Corrigé.** `b6ad01e`. Avec l'override `select = ["ALL"]`, `cases.py` donne 8 erreurs, autant que sans override [Vérifié].
+
+`_validate_rules` accepte `["ALL"]` dans un override, mais `for_path` filtre sur `RULES_REGISTRY` (`config.py:327`) : `"ALL"` disparaît, l'ensemble activé devient vide.
+
+```toml
+[[overrides]]
+paths = ["src/**"]
+select = ["ALL"]
+```
+
+Mesure sur `cases.py` : 17 erreurs sans override, 13 avec. Les 4 manquantes sont les règles configurables (`indentation`, `imperative_mood`, `docstring_exists`, `unknown_section`).
+
+Correctif :
+
+```python
+# config.py, for_path - honour 'ALL' like the base level does
+if override.select is None:
+ enabled = set(self.enabled_rules)
+elif override.select == ["ALL"]:
+ enabled = set(RULES_REGISTRY)
+else:
+ enabled = {rule for rule in override.select if rule in RULES_REGISTRY}
+```
+
+## BUG-06 - Types de configuration non validés [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `b6ad01e`. `workers = "4"` et `exclude = "src"` sont refusés avec le code 2 ; les entiers sont ramenés à leur minimum de la même façon à la racine et dans les overrides [Vérifié].
+
+Les `cast(...)` de `_parse_toml_config` et `_parse_override` ne sont que des indications pour le type checker.
+
+| Config | Résultat observé |
+|---|---|
+| `workers = "4"` | `TypeError: '>' not supported between instances of 'str' and 'int'` (traceback) |
+| `exclude = "src"` | Accepté, itéré caractère par caractère, aucune erreur |
+| `select = "ALL"` | `Configuration error: select: unknown rules 'A', 'L'.` |
+| override `summary_max_length = "x"` | `TypeError` au moment du lint (traceback) |
+| override `summary_max_length = -5` | Accepté sans le `max(1, ...)` appliqué au niveau racine (`config.py:619` vs `config.py:566-567`) [Déduit] |
+
+Correctif : un petit validateur partagé, utilisé à la racine et dans les overrides (voir aussi ARCH-05).
+
+```python
+# config.py - one validator for scalar and list settings
+def _expect(key: str, value: object, kind: type, minimum: int | None = None) -> object:
+ if kind is int and (isinstance(value, bool) or not isinstance(value, int)):
+ msg = f"'{key}': expected an integer, got {value!r}."
+ raise ValueError(msg)
+ if kind is bool and not isinstance(value, bool):
+ msg = f"'{key}': expected true or false, got {value!r}."
+ raise ValueError(msg)
+ if kind is list and not (isinstance(value, list) and all(isinstance(v, str) for v in value)):
+ msg = f"'{key}': expected a list of strings, got {value!r}."
+ raise ValueError(msg)
+ if minimum is not None and value < minimum:
+ msg = f"'{key}': must be >= {minimum}, got {value!r}."
+ raise ValueError(msg)
+ return value
+```
+
+## BUG-07 - Globs d'exclusion inopérants [Vérifié] - Haute
+
+**Statut : Corrigé.** `b6ad01e`. Une seule fonction de correspondance (`path_matches`, `full_match`) pour `exclude` et les overrides, plus la règle des motifs littéraux, documentées. `--exclude "t/**"` exclut toute l'arborescence [Vérifié].
+
+`_is_excluded` utilise `Path.match` (`cli.py:57`), ancré à droite et sans sémantique récursive pour `**`, alors que les overrides utilisent `full_match` (`config.py:231`). Deux sémantiques de glob coexistent.
+
+```text
+path pattern Path.match full_match
+a/tests/x/y.py tests/** False False
+tests/x/y.py tests/* False False
+tests/x/y.py tests/** False True
+src/pkg/tests/y.py tests False False (mais exclu par la branche "littéral")
+```
+
+Conséquences : `--exclude "tests/**"` n'exclut rien ; un motif littéral `tests` exclut tout répertoire `tests` à n'importe quelle profondeur (`cli.py:60`), ce qui n'est pas documenté.
+
+Correctif : une seule fonction de correspondance (celle de `ConfigOverride.matches`), utilisée pour `exclude` et `overrides`, et documentée.
+
+## BUG-08 - Générateur imbriqué [Vérifié] - Haute
+
+**Statut : Corrigé.** `65839f4`. Parcours du corps élagué aux portées imbriquées ; `outer_with_nested_generator` ne produit plus d'erreur [Vérifié].
+
+`is_generator` parcourt tout le sous-arbre avec `ast.walk` (`ast_parser.py:191`), y compris les fonctions et lambdas imbriquées.
+
+```python
+def outer_with_nested_generator(values: list[int]) -> list[int]:
+ """Return the values.
+
+ Args:
+ values (list[int]): Values.
+
+ Returns:
+ list[int]: The values.
+
+ """
+
+ def gen() -> Iterator[int]:
+ yield from values
+
+ return list(gen())
+```
+
+```text
+[returns_section] Generator function must use 'Yields:' instead of 'Returns:'.
+[yields_section] Missing 'Yields:' section. Function contains a yield statement.
+```
+
+Même défaut dans `_self_attr_names` (`ast_parser.py:142`) : un `self.x = ...` dans une fonction imbriquée de `__init__` est compté comme attribut [Déduit].
+
+Correctif : réutiliser le parcours élagué de `_extract_raises` (qui s'arrête déjà sur `FunctionDef`, `AsyncFunctionDef`, `Lambda`) et y ajouter `ClassDef`. Voir PERF-01 : un seul parcours peut collecter `raises` et `yields`.
+
+## BUG-09 - `raise err` [Vérifié] - Haute
+
+**Statut : Corrigé.** `65839f4`, `7e575a5`. `raise err` issu d'un `except X as err` est lu comme `X` ; décision : le `raise` d'une variable ordinaire n'est plus compté [Vérifié sur `reraise`].
+
+`_extract_raises` prend tout `ast.Name` comme type d'exception (`ast_parser.py:311-319`).
+
+```python
+def reraise(value: int) -> int:
+ """...
+ Raises:
+ ValueError: If the value is invalid.
+ """
+ try:
+ return int(value)
+ except ValueError as err:
+ raise err
+```
+
+```text
+[raises_section] 'err' raised in code but not documented in 'Raises:'.
+[raises_match] 'ValueError' documented in 'Raises:' but not raised in code.
+```
+
+Deux erreurs, dont une always-on, sur un code correct. `pydoclint` ne signale rien sur ce cas [Vérifié].
+
+Correctif : mémoriser les noms liés par `except X as name` et remplacer `raise name` par `X` (ou l'ignorer si `X` est un tuple ou absent).
+
+## BUG-10 - Exceptions pointées [Vérifié] - Haute
+
+**Statut : Corrigé.** `65839f4`. Noms pointés extraits, comparés sur leur dernier segment, et acceptés dans `Raises:` [Vérifié sur `dotted_raise`].
+
+Côté code, seul `ast.Name` est reconnu (`ast_parser.py:313-316`) : `raise errors.ValidationError(...)` est invisible. Côté docstring, `RAISE_PATTERN = r"^\s{4}(\w+)\s*:..."` (`docstring_parser.py:68`) n'accepte pas `errors.ValidationError:`.
+
+```text
+[raises_match] 'ValidationError' documented in 'Raises:' but not raised in code.
+```
+
+Correctif : extraire `ast.Attribute` (nom complet via `ast.unparse`, comparaison sur le dernier segment), accepter `[\w.]+` dans le parser.
+
+## BUG-11 - Exceptions propagées [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `6f95960`. `raises_match` (always-on) ne vérifie plus que les descriptions ; une exception documentée mais non levée relève de la nouvelle règle configurable `raises_extraneous`, désactivée par la convention `google` [Vérifié sur `propagated`].
+
+`raises_match` est always-on et exige que toute exception documentée soit levée *directement* dans le corps.
+
+```python
+def propagated(value: str) -> int:
+ """...
+ Raises:
+ ValueError: Propagated from int().
+ """
+ return int(value)
+```
+
+```text
+[raises_match] 'ValueError' documented in 'Raises:' but not raised in code.
+```
+
+Documenter les exceptions propagées est une pratique courante et utile à l'appelant. Aujourd'hui, c'est impossible sans exclure le fichier.
+
+Correctif proposé : sortir la vérification "documenté mais non levé" dans une politique (`raises_extraneous = "forbidden" | "optional"`), défaut `optional`.
+
+## BUG-12 - `(int, optional)` [Vérifié] - Critique
+
+**Statut : Corrigé.** `735d616`. Option `type_matching` (`strict`, `equivalent`, `lenient`) ; `, optional` est ignoré à tous les niveaux. `int, optional` contre `int | None` est accepté en `lenient` (convention `google`) et reste signalé en `strict`, par conception [Vérifié sur `optional_arg`].
+
+La comparaison de types est textuelle (`rules/args.py:89-90`). La forme `name (type, optional): ...`, très répandue en Google style (Napoleon la documente [Non vérifié]), produit une erreur always-on.
+
+```text
+[args_match] Arg 'limit' type mismatch: signature='int | None', docstring='int, optional'.
+```
+
+Mesure sur `rich` 15.0.0 : 520 "type mismatch", dont **372 contiennent `, optional`** et **318 sont équivalents** une fois `, optional` retiré (ou `Optional[X]` ramené à `X`).
+
+`pydoclint` a le même défaut par défaut (DOC105) [Vérifié]. C'est donc une opportunité de différenciation.
+
+Correctif : normaliser les deux côtés avant comparaison.
+
+```python
+# rules/args.py - compare types modulo cosmetic differences
+import ast
+
+
+def _normalize_type(text: str) -> str:
+ text = text.strip()
+ text = text.removesuffix(", optional").strip()
+ try:
+ node = ast.parse(text, mode="eval").body
+ except SyntaxError:
+ return text
+ # String forward reference: 'Node' -> Node
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
+ return _normalize_type(node.value)
+ return ast.unparse(node)
+```
+
+Étape suivante : ramener `Optional[X]`, `Union[X, None]` et `X | None` à une forme unique, puis `List`/`list`, `Dict`/`dict`. À exposer par une option `type_matching = "strict" | "normalized" | "off"`.
+
+## BUG-13 - Forward references [Vérifié] - Haute
+
+**Statut : Corrigé.** `735d616`. Les guillemets des forward references sont retirés à tous les niveaux [Vérifié sur `forward_ref`, même en `strict`].
+
+`ast.unparse` conserve les guillemets d'une annotation chaîne (`ast_parser.py:184`, `ast_parser.py:229`).
+
+```text
+[args_match] Arg 'other' type mismatch: signature=''Node'', docstring='Node'.
+[returns_match] Return type mismatch: signature=''Node'', docstring='Node'.
+```
+
+84 occurrences sur `rich`. Correctif : dans `_extract_args` et pour `node.returns`, si l'annotation est un `ast.Constant` de type `str`, prendre `.value`. Couvert par `_normalize_type` ci-dessus.
+
+## BUG-14 - Règle `indentation` [Vérifié] - Haute
+
+**Statut : Corrigé.** `6f95960`. Contrôle structurel : une erreur par section, première entrée à exactement 4 espaces [Vérifié sur `multiline_arg`].
+
+`check_indentation` (`rules/structure.py:16-43`) compte les indentations distinctes hors première ligne et échoue au-delà de 2. Une section (`0`), une entrée (`4`) et une ligne de continuation (`8`) suffisent.
+
+```python
+def multiline_arg(value: int) -> int:
+ """Return the value.
+
+ Args:
+ value (int): A long description that wraps
+ onto a second line.
+
+ Returns:
+ int: The value.
+
+ """
+```
+
+```text
+[indentation] Inconsistent indentation in docstring.
+```
+
+62 occurrences sur `rich`. Le projet lui-même n'est pas touché car `line-length = 200` évite les continuations.
+
+Correctif : vérifier que chaque indentation est multiple de 4 et cohérente avec la structure (en-têtes à 0, entrées à 4, continuations >= 8), ou retirer la règle, le docstring étant déjà nettoyé par `inspect.cleandoc`.
+
+## BUG-15 - `def` sous `if` / `try` / `with` [Vérifié] - Haute
+
+**Statut : Corrigé.** `65839f4`. Les `def` sous `if`/`try`/`with` sont analysés (`hidden_in_if` produit le `docstring_exists` attendu) [Vérifié]. Décision associée (`7e575a5`) : le bloc `if __name__ == "__main__":` est ignoré.
+
+`_walk_body` ne descend que dans `ClassDef` (`ast_parser.py:64-70`).
+
+```python
+if sys.version_info >= (3, 12):
+
+ def hidden_in_if(x):
+ return x
+```
+
+Aucune erreur (ni docstring, ni annotation). Cas courants concernés : `if TYPE_CHECKING:`, `try: ... except ImportError:`, `if sys.version_info`, `if __name__ == "__main__":`.
+
+Correctif :
+
+```python
+# ast_parser.py - also walk compound statements that are not new scopes
+_BLOCKS = (ast.If, ast.Try, ast.TryStar, ast.With, ast.AsyncWith, ast.For, ast.AsyncFor, ast.While)
+
+for node in body:
+ if isinstance(node, ast.ClassDef):
+ ...
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ ...
+ elif isinstance(node, _BLOCKS):
+ for block in (getattr(node, "body", []), getattr(node, "orelse", []), getattr(node, "finalbody", [])):
+ _walk_body(block, filepath, entities, parent_class)
+ for handler in getattr(node, "handlers", []):
+ _walk_body(handler.body, filepath, entities, parent_class)
+```
+
+Les fonctions imbriquées dans des fonctions restent non analysées : c'est un choix défendable, mais à documenter (voir DOC-07) ou à rendre optionnel (`docsig --check-nested` le propose [Vérifié]).
+
+## BUG-16 - `self` / `cls` par nom [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `65839f4`. Premier paramètre ignoré par position pour les méthodes, sauf `@staticmethod` [Vérifié sur `Meta.__new__(mcs, ...)`].
+
+`skip = {"self", "cls"}` (`ast_parser.py:218`) s'applique à toutes les fonctions, par nom.
+
+```python
+class Meta(type):
+ def __new__(mcs, name: str, bases: tuple[type, ...], ns: dict[str, object]) -> "Meta": ...
+```
+
+```text
+[args_section] Arg 'mcs' in signature but not documented.
+[args_order] Args order in docstring differs from signature. Expected: mcs, name, bases, ns. Got: name, bases, ns.
+```
+
+À l'inverse, une fonction de module avec un paramètre `cls`, ou un `@staticmethod` avec un paramètre `self`, perdent ce paramètre [Déduit].
+
+Correctif : pour une méthode non `@staticmethod`, ignorer le premier paramètre positionnel quel que soit son nom ; ne rien ignorer pour une fonction de module. Nécessite de conserver les décorateurs (voir ARCH-07).
+
+## BUG-17 - Heuristique d'impératif [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `6f95960`. Liste de verbes et formes irrégulières (`does`, `goes`, `undoes`) : 'Does' -> 'Do' [Vérifié].
+
+`_to_imperative` (`rules/docstring.py:151-180`) retire un `s` final :
+
+```text
+Does -> Doe Goes -> Goe Settings -> Setting Bytes -> Byte
+Uses -> Use Parses -> Parse Returns -> Return Has -> (exception)
+```
+
+"Does"/"Goes" produisent une suggestion fausse ; les noms au pluriel en tête de résumé ("Settings for...") sont signalés. 68 erreurs `imperative_mood` sur `rich`.
+
+Correctifs : table de verbes irréguliers (`does -> do`, `goes -> go`, `has -> have`, `is -> be`) ; ne signaler que si la forme proposée appartient à une liste de verbes connus (approche d'une liste blanche, utilisée par pydocstyle D401 [Non vérifié]).
+
+## BUG-18 - Sections Napoleon à deux mots [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `acdcf43`, `e41e293`. Sections Napoleon reconnues dans `sections.py`, alias signalés par la règle `section_alias`, `Keyword Args` lu [Vérifié sur `kwargs_section`].
+
+`CANDIDATE_SECTION_PATTERN = r"^([A-Z][A-Za-z]*):\s*$"` (`docstring_parser.py:63`) ne reconnaît qu'un mot. `Keyword Args:`, `See Also:`, `Other Parameters:` sont silencieusement fusionnés dans la description ou la section précédente.
+
+```python
+def kwargs_section(**kwargs: int) -> None:
+ """Accept keyword arguments.
+
+ Keyword Args:
+ width (int): Width.
+
+ Warning:
+ Experimental.
+
+ See Also:
+ Something else.
+ """
+```
+
+```text
+[args_section] Arg '**kwargs' in signature but not documented.
+[unknown_section] Unknown section 'Warning'.
+```
+
+`See Also:` et `Keyword Args:` ne sont ni reconnus ni signalés. `Warning:` est une section Napoleon standard [Non vérifié].
+
+Correctif : reconnaître les en-têtes multi-mots ; décider pour chaque alias Napoleon (`Arguments`, `Parameters`, `Keyword Args`, `Warning(s)`, `See Also`, `References`, `Methods`) s'il est accepté, signalé comme alias (`Use 'Args:'`) ou inconnu. Accepter `Keyword Args:` comme documentation de `**kwargs`.
+
+## BUG-19 - `Returns:` sans préfixe de type [Vérifié] - Haute
+
+**Statut : Corrigé.** `735d616`. `documented_types` s'applique aussi à `Returns:` : optionnel avec la convention `google`, exigé en `strict` par conception [Vérifié sur `google_guide_returns`].
+
+Le guide Google montre `Returns:` suivi d'une phrase, sans `type:` quand la signature est annotée [Non vérifié]. Ici, c'est une erreur always-on (`rules/args.py:229-230`).
+
+```python
+def google_guide_returns(value: int) -> int:
+ """Return the value.
+
+ Args:
+ value (int): The value.
+
+ Returns:
+ The value unchanged.
+
+ """
+```
+
+```text
+[returns_match] Missing type in 'Returns:'. Expected 'int'.
+```
+
+Correctif : étendre `documented_types` (aujourd'hui limité à `Args:` et `Attributes:`) aux lignes `Returns:`/`Yields:`, ou ajouter `returns_types = "required" | "forbidden" | "optional"`.
+
+## BUG-20 - Parsing de `Returns:` [Vérifié] - Basse
+
+**Statut : Corrigé.** `735d616`. Un type n'est retenu que s'il est une expression Python ; les lignes de continuation sont jointes. L'exemple donne maintenant `description='The mapping: key to value, spanning two lines.'` [Vérifié].
+
+`RETURN_PATTERN = r"^\s{4}([^:]+?)\s*:\s*(.*)$"` (`docstring_parser.py:67`) prend tout ce qui précède le premier `:` comme type, et `_parse_returns` s'arrête à la première ligne.
+
+```python
+GoogleStyleParser().parse("Do it.\n\nReturns:\n The mapping: key to value,\n spanning two lines.\n").returns
+# DocstringReturn(type_annotation='The mapping', description='key to value,')
+```
+
+Effet : type mismatch always-on sur une phrase contenant `:` ; description tronquée.
+
+Correctif : n'accepter comme type qu'une expression parsable par `ast.parse(..., mode="eval")` ; concaténer les lignes de continuation comme pour `Args:`.
+
+## BUG-21 - `@overload` [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `65839f4`. Les signatures `@overload` sont ignorées [Vérifié sur `conv`].
+
+```python
+@overload
+def conv(x: int) -> int: ...
+@overload
+def conv(x: str) -> str: ...
+def conv(x: int | str) -> int | str:
+ """Convert the value. ..."""
+```
+
+```text
+L7 conv [docstring_exists] Missing docstring.
+L9 conv [docstring_exists] Missing docstring.
+```
+
+Correctif : ignorer les fonctions décorées `@overload` / `@typing.overload`. Voir UX-04 pour `@property`, `@override`, dunders.
+
+## BUG-22 - Annotations GitHub non échappées [Déduit] - Basse
+
+**Statut : Corrigé.** `0355225`. Échappement du message et des propriétés comme `@actions/core` [Vérifié par les tests du reporter].
+
+`reporter.py:163` écrit `::error file=...,line=...,title=...::{message}` sans échappement. La documentation des workflow commands demande d'échapper `%`, `\r`, `\n` dans le message, et en plus `:` et `,` dans les propriétés [Non vérifié]. Les messages de `summary_final_period` incluent un extrait du résumé utilisateur (`Got: '...'`), donc un `%` ou un retour ligne peut y apparaître.
+
+```python
+# reporter.py - escape per GitHub workflow command rules
+def _escape_data(text: str) -> str:
+ return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
+
+
+def _escape_property(text: str) -> str:
+ return _escape_data(text).replace(":", "%3A").replace(",", "%2C")
+```
+
+## BUG-23 - Pluriel [Vérifié] - Basse
+
+**Statut : Corrigé.** `0355225`. "1 file checked" [Vérifié par les tests du reporter].
+
+`reporter.py:57`, `:76`, `:95`, `:115`, `:168-170` : "1 files checked".
+
+---
+
+## Annexe - fichiers de reproduction
+
+Sorties obtenues avec `docstring-linter --config /nonexistent --format text`. Les fichiers ne sont pas versionnés en `.py` pour ne pas être pris par les hooks pyright/ruff du dépôt. Depuis BUG-04, `--config /nonexistent` est une erreur : pour les défauts, passer un fichier TOML vide.
+
+Suggestion : les ajouter dans `tests/fixtures/` avec les erreurs attendues, comme tests de bout en bout (voir ARCH-10).
+
+
+cases.py
+
+```python
+"""Edge cases for review."""
+
+import sys
+from collections.abc import Callable, Iterator
+
+import requests_like as errors
+
+
+def optional_arg(limit: int | None = None) -> int:
+ """Return the limit.
+
+ Args:
+ limit (int, optional): Maximum value.
+
+ Returns:
+ int: The limit.
+
+ """
+ return limit or 0
+
+
+def multiline_arg(value: int) -> int:
+ """Return the value.
+
+ Args:
+ value (int): A long description that wraps
+ onto a second line.
+
+ Returns:
+ int: The value.
+
+ """
+ return value
+
+
+def google_guide_returns(value: int) -> int:
+ """Return the value.
+
+ Args:
+ value (int): The value.
+
+ Returns:
+ The value unchanged.
+
+ """
+ return value
+
+
+def outer_with_nested_generator(values: list[int]) -> list[int]:
+ """Return the values.
+
+ Args:
+ values (list[int]): Values.
+
+ Returns:
+ list[int]: The values.
+
+ """
+
+ def gen() -> Iterator[int]:
+ yield from values
+
+ return list(gen())
+
+
+def reraise(value: int) -> int:
+ """Return the value.
+
+ Args:
+ value (int): The value.
+
+ Returns:
+ int: The value.
+
+ Raises:
+ ValueError: If the value is invalid.
+
+ """
+ try:
+ return int(value)
+ except ValueError as err:
+ raise err
+
+
+def dotted_raise(value: int) -> int:
+ """Return the value.
+
+ Args:
+ value (int): The value.
+
+ Returns:
+ int: The value.
+
+ Raises:
+ ValidationError: If invalid.
+
+ """
+ if value < 0:
+ raise errors.ValidationError("negative")
+ return value
+
+
+def propagated(value: str) -> int:
+ """Convert the value.
+
+ Args:
+ value (str): The value.
+
+ Returns:
+ int: The value.
+
+ Raises:
+ ValueError: Propagated from int().
+
+ """
+ return int(value)
+
+
+def forward_ref(other: "Node") -> "Node":
+ """Return the node.
+
+ Args:
+ other (Node): The node.
+
+ Returns:
+ Node: The node.
+
+ """
+ return other
+
+
+def does_things() -> None:
+ """Does the thing.
+
+ Returns:
+ None
+
+ """
+
+
+if sys.version_info >= (3, 12):
+
+ def hidden_in_if(x):
+ return x
+
+
+class Node:
+ """Store a node.
+
+ Attributes:
+ value (int): The value.
+
+ """
+
+ def __init__(self, value: int) -> None:
+ """Build the node.
+
+ Args:
+ value (int): The value.
+
+ """
+ self.value = value
+ self._cache = None
+
+ def __repr__(self) -> str:
+ return "Node"
+
+ @property
+ def size(self) -> int:
+ """The size of the node."""
+ return 1
+
+
+def kwargs_section(**kwargs: int) -> None:
+ """Accept keyword arguments.
+
+ Keyword Args:
+ width (int): Width.
+
+ Warning:
+ Experimental.
+
+ See Also:
+ Something else.
+
+ Returns:
+ None
+
+ """
+```
+
+
+
+
+more.py
+
+```python
+"""More cases."""
+
+from typing import overload
+
+
+@overload
+def conv(x: int) -> int: ...
+@overload
+def conv(x: str) -> str: ...
+def conv(x: int | str) -> int | str:
+ """Convert the value.
+
+ Args:
+ x (int | str): Value.
+
+ Returns:
+ int | str: Converted value.
+
+ """
+ return x
+
+
+class Base:
+ """Define the base."""
+
+ def run(self, value: int) -> int:
+ """Run the job.
+
+ Args:
+ value (int): Value.
+
+ Returns:
+ int: Result.
+
+ Raises:
+ NotImplementedError: Always, subclasses implement it.
+
+ """
+ raise NotImplementedError
+
+
+class Child(Base):
+ """Define the child."""
+
+ def run(self, value: int) -> int: # inherits documentation
+ return value
+
+
+class Meta(type):
+ """Define the metaclass."""
+
+ def __new__(mcs, name: str, bases: tuple[type, ...], ns: dict[str, object]) -> "Meta":
+ """Create the class.
+
+ Args:
+ name (str): Name.
+ bases (tuple[type, ...]): Bases.
+ ns (dict[str, object]): Namespace.
+
+ Returns:
+ Meta: New class.
+
+ """
+ return super().__new__(mcs, name, bases, ns)
+```
+
+
diff --git a/review/02-architecture-et-code.md b/review/02-architecture-et-code.md
new file mode 100644
index 0000000..2f1fc03
--- /dev/null
+++ b/review/02-architecture-et-code.md
@@ -0,0 +1,272 @@
+# 02 - Architecture et code
+
+## Vue d'ensemble
+
+```text
+cli.main
+ load_config -------------> LinterConfig (+ overrides)
+ run
+ collect_python_files (rglob + exclude)
+ lint_file (par fichier, séquentiel ou ProcessPool)
+ config.for_path (override résolu)
+ ast_parser.parse_file --> [CodeEntity] (module, classes, fonctions, méthodes)
+ GoogleStyleParser.parse -> ParsedDocstring (summary, args, returns, raises, ...)
+ rules.validate_entity --> [LintError] (~30 fonctions check_*)
+ reporter.report_* (traceback, text, json, github-annotations)
+```
+
+| Module | Lignes | Rôle |
+|---|---|---|
+| `cli.py` | 265 | Arguments, collecte des fichiers, parallélisme, dispatch des sorties |
+| `config.py` | 641 | Registres de règles/politiques/options, dataclasses de config, chargement TOML, overrides |
+| `ast_parser.py` | 350 | Extraction des entités et signatures |
+| `docstring_parser.py` | 367 | Parsing Google |
+| `rules/*.py` | 1 144 | Règles, regroupées par thème |
+| `reporter.py` | 270 | Formats de sortie et `--list-rules` |
+| `models.py` | 212 | Dataclasses |
+
+[Vérifié] par `wc -l`.
+
+## Points forts
+
+- **Zéro dépendance runtime** (`dependencies = []`) : installation triviale, pas de conflit. [Vérifié]
+- **Typage strict** : pyright `strict`, ruff `select = ["ALL"]`, pylint 10/10, 0 erreur. [Vérifié]
+- **Séparation claire** des étapes (extraction AST, parsing docstring, règles, rendu), fonctions pures faciles à tester.
+- **Configuration stricte** : clés, règles et valeurs de politique inconnues rejetées avant tout lint, exit 2. [Vérifié]
+- **Modèle "politique vs règle de contenu"** bien pensé : la politique décide *si* la section doit exister, la règle de contenu vérifie *ce qui* est écrit, sans double signalement en mode `forbidden` (`rules/__init__.py:107-138`).
+- **Dogfooding** : le linter s'applique à `src/` et `tests/` via pre-commit. [Vérifié]
+
+## Constats
+
+| ID | Gravité | Sujet | Statut |
+|---|---|---|---|
+| ARCH-01 | Moyenne | Couche d'extraction AST trop superficielle | Corrigé |
+| ARCH-02 | Moyenne | Règles non déclaratives, dispatcher monolithique, registres dupliqués | Corrigé |
+| ARCH-03 | Moyenne | Docstring re-parsé par chaque règle de structure, 3 définitions d'un en-tête | Partiel |
+| ARCH-04 | Moyenne | Abstraction multi-style creuse | Corrigé |
+| ARCH-05 | Moyenne | Options énumérées à la main à 5 endroits | Corrigé |
+| ARCH-06 | Haute | Always-on sans échappatoire | Corrigé |
+| ARCH-07 | Moyenne | Modèle `CodeEntity` trop pauvre | Partiel |
+| ARCH-08 | Haute | `ValueError` comme fourre-tout au niveau fichier | Corrigé |
+| ARCH-09 | Basse | Code mort ou trompeur | Corrigé |
+| ARCH-10 | Moyenne | Tests unitaires qui contournent l'AST | Corrigé |
+
+### ARCH-01 - Extraction AST superficielle [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `65839f4`. `_scan_body` collecte raises et yields en une passe élaguée aux portées imbriquées, avec les alias `except` et les noms pointés. Implémenté par un parcours itératif plutôt qu'un `NodeVisitor`.
+
+`ast_parser.py` est la source des bugs les plus coûteux (BUG-08, 09, 10, 15, 16, 21). Causes communes :
+
+- parcours ad hoc (`ast.walk`, file manuelle, `node.body` direct) au lieu d'un visiteur unique qui connaît les portées ;
+- informations jetées tôt : décorateurs, première ligne de docstring, `except ... as name`, noms pointés.
+
+Proposition : un `ast.NodeVisitor` qui maintient une pile de portées et remplit `CodeEntity` en une passe (voir PERF-01 pour le gain). Exemple de squelette :
+
+```python
+# ast_parser.py - single pass, scope-aware body scan for one function
+class _BodyScanner(ast.NodeVisitor):
+ """Collect raises and yields of one function, without entering nested scopes."""
+
+ def __init__(self) -> None:
+ self.raises: list[RaiseInfo] = []
+ self.is_generator = False
+ self._aliases: dict[str, str] = {} # except X as name -> X
+
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # nested scope: skip
+ return
+
+ visit_AsyncFunctionDef = visit_FunctionDef
+ visit_Lambda = visit_FunctionDef
+ visit_ClassDef = visit_FunctionDef
+
+ def visit_Yield(self, node: ast.Yield) -> None:
+ self.is_generator = True
+ self.generic_visit(node)
+
+ visit_YieldFrom = visit_Yield
+
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
+ if node.name and node.type is not None:
+ self._aliases[node.name] = ast.unparse(node.type)
+ self.generic_visit(node)
+
+ def visit_Raise(self, node: ast.Raise) -> None:
+ exc = node.exc.func if isinstance(node.exc, ast.Call) else node.exc
+ if isinstance(exc, (ast.Name, ast.Attribute)):
+ name = ast.unparse(exc)
+ self.raises.append(RaiseInfo(exception_type=self._aliases.get(name, name), line=node.lineno))
+ self.generic_visit(node)
+```
+
+### ARCH-02 - Règles non déclaratives [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `6bfff7a` : tests de cohérence (`tests/linter/test_registries.py`) entre registres, catégories, `ALWAYS_ON`, champs de `LinterConfig`, titres de `docs/` et règles émises, qui échouent sur une règle ou une politique ajoutée à moitié [Vérifié]. `10cd726` : `validate_entity` découpé en `_check_summary`, `_check_function`, `_check_class` et `_check_layout`, sans `noqa` ; sorties identiques sur `rich` et la stdlib [Vérifié]. Décision : table déclarative écartée tant qu'il n'y a qu'un style. Sous-points antérieurs : `section_order` (`acdcf43`), `--select` (`89c4a28`), UX-12 écarté.
+
+Ajouter une règle impose de toucher au moins 4 endroits : `RULES_REGISTRY`, `RULES_CATEGORIES`, `ALWAYS_ON` (`config.py:83-156`), `validate_entity` (`rules/__init__.py:59-165`, marqué `noqa: C901, PLR0912, PLR0915`), plus la doc. Les politiques ajoutent `POLICIES_REGISTRY`, un champ `LinterConfig` et un appel dans le dispatcher.
+
+La dérive est déjà visible :
+
+- `section_order` est décrit comme "Args, Returns, Yields, Raises, Example(s), Note(s)" (`config.py:129`) alors que l'ordre réel commence par `Attributes` et inclut `Todo` (`rules/_base.py:20-31`). [Vérifié]
+- Le commentaire de `OFF_BY_DEFAULT` mentionne un flag `--select` qui n'existe pas (`config.py:138`, `cli.py:207-225`). [Vérifié]
+- Les erreurs de politique portent des identifiants (`returns_section`, `args_section`, ...) absents de `RULES_REGISTRY` : un utilisateur qui écrit `ignore = ["returns_section"]` reçoit "unknown rule". [Déduit de `config.py:528`]
+
+Proposition, sans framework de plugins :
+
+```python
+# rules/registry.py - one declarative table drives dispatch, --list-rules and docs
+@dataclass(frozen=True)
+class Rule:
+ id: str
+ category: str
+ description: str
+ kind: Literal["configurable", "always_on", "policy"]
+ applies_to: frozenset[NodeType]
+ check: Callable[[CodeEntity, ParsedDocstring | None, LinterConfig], list[LintError]]
+
+
+RULES: tuple[Rule, ...] = (...)
+```
+
+`validate_entity` devient une boucle de 5 lignes ; `--list-rules`, la doc et les tests de complétude se génèrent depuis `RULES`.
+
+### ARCH-03 - Docstring re-parsé par chaque règle [Vérifié] - Moyenne
+
+**Statut : Partiel.** `acdcf43`. Les en-têtes connus sont définis une seule fois (`sections.py`) et partagés par le parser et les règles. Restent : `CANDIDATE_SECTION_PATTERN` pour les sections inconnues, deux jeux de regex d'entrée, pas de modèle ligne à ligne.
+
+`GoogleStyleParser` produit un `ParsedDocstring` sémantique, mais 9 règles de structure (`check_indentation`, `check_section_capitalization`, `check_section_order`, `check_empty_section`, `check_blank_lines`, `check_named_section`, `check_entry_spacing`, `check_no_blank_line_in_section`, `extract_section_headers`) refont chacune leur `split("\n")` et leurs regex.
+
+Trois définitions d'un en-tête de section coexistent :
+
+| Regex | Fichier | Particularité |
+|---|---|---|
+| `SECTION_PATTERN` | `docstring_parser.py:59` | Liste fermée, sensible à la casse |
+| `CANDIDATE_SECTION_PATTERN` | `docstring_parser.py:63` | Un seul mot, majuscule initiale, ligne non strippée |
+| `SECTION_HEADER_RE` | `rules/_base.py:33` | N'importe quel mot, casse libre |
+
+Et deux définitions d'une entrée : `ARG_PATTERN`/`ARG_NO_TYPE_PATTERN` (parser) et `_ENTRY_LAX`/`_ENTRY_STRICT` (`rules/structure.py:11-13`).
+
+Conséquences : incohérences possibles entre ce que le parser comprend et ce que les règles vérifient (BUG-18 en est un exemple) ; impossible de rapporter une ligne précise (UX-05) ; `ParsedDocstring.examples` est rempli mais aucune règle ne le lit (`check_named_section` relit le texte brut). [Vérifié par grep]
+
+Proposition : le parser produit un modèle ligne à ligne, consommé par toutes les règles.
+
+```python
+# models.py - line-aware docstring model shared by all rules
+@dataclass
+class Section:
+ name: str # as written, e.g. "Args", "args", "Keyword Args"
+ header_line: int # 0-based line index inside the docstring
+ blank_lines_before: int
+ entries: list[Entry]
+
+
+@dataclass
+class Entry:
+ line: int
+ raw: str
+ name: str | None
+ type_annotation: str | None
+ description: str
+```
+
+### ARCH-04 - Abstraction multi-style creuse [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `b2fd640` : `DocstringStyle` réduit à `GOOGLE`, docstrings de paquet alignées. `e077ef9` : `BaseDocstringParser`, `PARSERS`, `get_parser`, `DocstringStyle`, la clé `style` et l'option `--style` supprimés ; `lint_file` utilise `GoogleStyleParser` directement. `style = "google"` donne désormais "unknown configuration key" et le code 2 (à signaler dans le CHANGELOG, DOC-05). Sorties identiques sur `rich` [Vérifié].
+
+`BaseDocstringParser`, `PARSERS`, `DocstringStyle` (4 valeurs), `--style` masqué et les docstrings de paquet (`__init__.py:3-4`, `docstring_parser.py:3-5`) annoncent NumPy, Sphinx et PEP 257. Or toutes les règles de structure sont codées pour Google (`GOOGLE_SECTIONS`, `GOOGLE_SECTION_ORDER`, indentation à 4). Résultat : BUG-01 et une promesse non tenue.
+
+Recommandation (cohérente avec la règle "pas de fonctionnalité spéculative" du `CLAUDE.md`) : supprimer les styles non implémentés et `BaseDocstringParser` tant qu'un second style n'est pas réellement développé. Si NumPy devient un objectif, le modèle ligne à ligne d'ARCH-03 est le bon point d'extension.
+
+### ARCH-05 - Options énumérées à la main [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `b6ad01e` : validation typée par tables (`INT_OPTIONS`, `BOOL_OPTIONS`, `CHOICE_OPTIONS`) partagée par la racine et les overrides. `8bb902d` : `SETTING_KEYS` et `OVERRIDABLE_OPTIONS` déduits des tables de types (`TYPED_OPTIONS`), `option_values()` générique ; ajouter une option ne touche plus que 5 endroits au lieu de 8. Tests de cohérence : option listée = champ typé, tableau des clés et liste des options d'override de `docs/configuration.md` exacts. Ensembles de clés, valeurs et sortie de `--list-rules` identiques avant et après [Vérifié]. Décision : table `Option` unique écartée.
+
+La liste des options apparaît dans `SETTING_KEYS`, `OVERRIDABLE_OPTIONS`, `OPTIONS_REGISTRY`, `LinterConfig`, `option_values()` et `_parse_toml_config` (`config.py:67-80`, `159-193`, `276-306`, `345-364`, `577-641`). Chaque ajout doit être répliqué ; la validation de type manque (BUG-06) et les bornes (`max(1, ...)`) ne s'appliquent qu'au niveau racine.
+
+Proposition : une table unique.
+
+```python
+# config.py - single source of truth for options
+@dataclass(frozen=True)
+class Option:
+ type: type
+ default: object
+ description: str
+ overridable: bool = False
+ minimum: int | None = None
+
+
+OPTIONS: dict[str, Option] = {
+ "summary_max_length": Option(int, 80, "Maximum summary line length", overridable=True, minimum=1),
+ "workers": Option(int, 1, "Parallel workers, 0 = auto", minimum=0),
+ ...
+}
+```
+
+### ARCH-06 - Always-on sans échappatoire [Vérifié] - Haute
+
+**Statut : Corrigé.** Option 1 retenue (`672ee78`, voir UX-01) : les règles always-on restent non désactivables dans la configuration, mais `# docstring-linter: ignore[rule]` les fait taire sur une entité. Atténué aussi : `raises_extraneous` sorti des règles always-on (`6f95960`), niveaux de `type_matching` (`735d616`).
+
+11 règles sont non désactivables (`config.py:142-156`) et `ignore` les refuse explicitement (`config.py:530-533`). Justification donnée : "requiring a section and then tolerating wrong content in it makes no sense" (`docs/always-on-rules.md`).
+
+L'argument tient pour un code parfaitement analysé. Il ne tient plus dès qu'un faux positif existe : BUG-09, 10, 11, 12, 13, 19, 20 touchent tous des règles always-on. Sans `# noqa` ni baseline (UX-01, UX-03), la seule issue est d'exclure le fichier entier.
+
+Options à discuter :
+
+1. Conserver always-on au niveau configuration, mais autoriser `# noqa: ` sur la ligne de l'entité (recommandé, coût faible).
+2. Rendre ces règles désactivables avec un avertissement.
+3. Réduire le périmètre always-on aux défauts non ambigus (`duplicate_arg`, `empty_section`, arg fantôme) et passer les comparaisons de types en politique.
+
+### ARCH-07 - Modèle `CodeEntity` trop pauvre [Vérifié] - Moyenne
+
+**Statut : Partiel.** `ce82164`, `66b3bed`. Ajout de `decorators`, `init_args` et `class_docstring`. Pas de `docstring_line` ni de `parent` (voir UX-05).
+
+`CodeEntity` (`models.py:61-93`) ne porte ni décorateurs, ni ligne de début de docstring, ni parent structuré. Conséquences : `_is_init` teste un suffixe de chaîne (`rules/args.py:98-108`), impossible de traiter `@overload`, `@property`, `@staticmethod`, `@override` (BUG-16, BUG-21, UX-04), toutes les erreurs pointent la ligne `def` (UX-05).
+
+Champs à ajouter : `decorators: list[str]`, `docstring_line: int | None`, `parent: str | None`, `is_private: bool`, `is_dunder: bool`.
+
+### ARCH-08 - `ValueError` fourre-tout [Vérifié] - Haute
+
+**Statut : Corrigé.** `b2fd640` : configuration validée une fois dans `main()`, `SyntaxError`, `UnicodeDecodeError` et `OSError` en code 2. `8a9e4b4` : toute autre exception est attrapée fichier par fichier, nommée sur stderr avec sa trace, les autres fichiers sont analysés, code 3. Reproduit avant/après avec un fichier qui fait lever `RecursionError` à `ast.parse` parmi 61 : avant, run arrêté sans sortie, code 1, fichier non nommé ; après, 62 fichiers analysés, fichier nommé, code 3 [Vérifié].
+
+`_lint_file_safe` (`cli.py:127-143`) attrape `ValueError` en supposant une erreur de configuration. En pratique, elle couvre `UnicodeDecodeError`, le style non supporté et toute `ValueError` d'un bug interne, et les convertit en succès (BUG-01, BUG-02). Une exception d'un autre type (ex. `RecursionError` sur un fichier très imbriqué [Déduit]) arrête tout le run avec une traceback, y compris en mode parallèle via `future.result()`.
+
+Proposition : erreurs de configuration validées une fois dans `main()` ; au niveau fichier, attraper `SyntaxError`, `UnicodeDecodeError`, `OSError` explicitement ; laisser remonter le reste comme bug interne avec exit 3 et le nom du fichier en cause.
+
+### ARCH-09 - Code mort ou trompeur [Vérifié] - Basse
+
+**Statut : Corrigé.** Constantes `quantity` disparues (`6f95960`). `183aa75` : `OFF_BY_DEFAULT` supprimé, `--list-rules` compte et étiquette les règles coupées par la convention active (3 en `google`, 0 en `strict`) [Vérifié] ; `ParsedDocstring.examples` supprimé ; `.vulture` supprimé avec ses 3 références, vulture ne signale rien sans lui [Vérifié].
+
+- `OFF_BY_DEFAULT` est vide ; la mécanique (affichage "disabled by default", filtrages) existe sans cas d'usage.
+- `ParsedDocstring.examples` jamais lu par une règle.
+- `.vulture` liste `YELLOW` et `GREEN` comme inutilisés alors qu'ils le sont dans `report_policies` ; références de lignes périmées.
+- `quantity = 2`, `quantity = 3`, `quantity = 4` (`rules/structure.py:40`, `rules/docstring.py:166-173`) : contournement de `PLR2004` qui réduit la lisibilité ; une constante nommée (`_MAX_DISTINCT_INDENTS = 2`) serait plus claire.
+
+### ARCH-10 - Tests [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `tests/linter/test_end_to_end.py` (34 tests passant par le vrai parsing AST), tests de cohérence des registres et de `TESTS.md` (`6bfff7a`, `2ecc362`) ; 589 tests, couverture branches 96,24 %. `5779bbd` : seuil `fail_under` relevé de 85 à 90 [Vérifié]. Décision : pas de test de corpus (stdlib instable d'une version corrective à l'autre, code tiers à éviter, cas de l'annexe déjà couverts par `test_end_to_end.py`).
+
+État : 372 tests, 1,45 s, 94,39 % de couverture branches, seuil CI 85 %.
+
+Limites :
+
+- Les tests de règles construisent `CodeEntity` à la main (`tests/linter/rules/conftest.py`, `_func`), donc contournent `ast_parser`. Aucun des bugs BUG-08 à BUG-16 n'est détectable par ces tests.
+- `cli.py` à 79 % : chemin parallèle (`cli.py:186-193`) et `main()` (`--list-rules`, affichage "Config:") non couverts.
+- Pas de test de bout en bout sur un corpus réel.
+- `TESTS.md` annonce 369 tests pour 372 réels (voir DOC-03).
+
+Propositions :
+
+1. **Fixtures annotées** : `tests/fixtures/*.py` avec l'erreur attendue en commentaire, et un test paramétré qui compare.
+
+ ```python
+ def optional_arg(limit: int | None = None) -> int: # expect: (none)
+ """Return the limit.
+
+ Args:
+ limit (int, optional): Maximum value.
+ ...
+ ```
+
+2. **Test de corpus** : lint d'une version figée d'un projet Google style (ex. un sous-ensemble de `rich`), snapshot des compteurs par règle ; toute variation doit être justifiée dans la PR.
+3. Test de complétude des registres : chaque règle de `RULES_REGISTRY` est documentée dans `docs/` et a au moins un test.
+4. Relever le seuil de couverture à 90 %.
diff --git a/review/03-performance.md b/review/03-performance.md
new file mode 100644
index 0000000..aafb744
--- /dev/null
+++ b/review/03-performance.md
@@ -0,0 +1,122 @@
+# 03 - Performance
+
+## Conclusion
+
+La performance **n'est pas un problème** aujourd'hui : l'outil est 2,4x plus rapide que pydoclint sur le même corpus. Les gains possibles existent (x2 sur l'extraction AST) mais passent après la justesse. Le principal levier coïncide avec le correctif de BUG-08 : une passe AST unique par fonction.
+
+## Mesures [Vérifié]
+
+Machine : conteneur Linux, 4 CPU, Python 3.14.7. Temps "real" de `time`, cache disque chaud, une exécution.
+
+| Corpus | Outil | Temps |
+|---|---|---|
+| stdlib CPython 3.14 (1 059 fichiers, 18 Mo) | docstring-linter `--workers 1` | 4,58 s |
+| idem | docstring-linter `--workers 0` (4 process) | 1,90 s |
+| idem | ruff `--select D,DOC --preview --no-cache` | 0,46 s |
+| `rich` 15.0.0 (100 fichiers, 38 515 lignes) | docstring-linter `--workers 1` | 0,41 s |
+| idem | pydoclint 0.9.1 `--style=google` | 0,99 s |
+| idem | docsig 0.96.0 | 2,23 s |
+| idem | ruff `--select D,DOC --preview --no-cache` | 0,04 s |
+| 1 fichier trivial | docstring-linter (démarrage) | 0,08 s |
+
+Débit séquentiel : environ 4,3 ms par fichier sur la stdlib.
+
+Attention : les outils ne vérifient pas la même chose (voir 07). La comparaison donne un ordre de grandeur, pas un classement.
+
+## Profil [Vérifié]
+
+`cProfile` sur la stdlib, séquentiel (16 s sous profilage, surcoût de l'instrumentation inclus) :
+
+| Poste | Temps cumulé | Part |
+|---|---|---|
+| `parse_file` (lecture, `ast.parse`, extraction) | 14,1 s | 88 % |
+| dont `compile` (`ast.parse`) | 1,5 s | 9 % |
+| dont `_extract_raises` | 5,3 s | 33 % |
+| dont détection `is_generator` (`ast.walk`) | 5,4 s | 34 % |
+| `validate_entity` (toutes les règles) | 1,2 s | 7 % |
+
+`ast.iter_child_nodes` est appelé 5,5 millions de fois. Chaque fonction est parcourue intégralement **deux fois** (`_extract_raises` puis `ast.walk` pour `yield`), plus une troisième fois pour `__init__` (`_self_attr_names`).
+
+Les règles, bien qu'elles re-découpent chacune le docstring (ARCH-03), ne pèsent que 7 %.
+
+## Constats
+
+### PERF-01 - Double parcours AST par fonction [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `65839f4`. Gain mesuré d'environ 6 % (voir la mise à jour ci-dessous).
+
+`ast_parser.py:185` (`_extract_raises`) et `ast_parser.py:191` (`is_generator`) parcourent le même sous-arbre. Le second descend en plus dans les fonctions imbriquées (BUG-08).
+
+Proposition : le `_BodyScanner` d'ARCH-01, qui collecte `raises` et `yields` en une passe et s'arrête aux portées imbriquées. Gain attendu : environ la moitié du temps d'extraction, soit 30 à 35 % du temps total [Déduit du profil, non mesuré].
+
+Mise à jour après le lot 3 [Vérifié] : le parcours unique élagué (`_scan_body`) est en place. Gain mesuré sur la stdlib, 3 exécutions alternées : 4,76 s en moyenne avant, 4,46 s après, soit environ 6 %, dans le bruit de mesure. L'estimation ci-dessus était fausse : le surcoût d'instrumentation de cProfile gonflait la part des appels `ast.iter_child_nodes`. La performance reste sans enjeu.
+
+### PERF-02 - `workers = 1` par défaut [Vérifié] - Basse
+
+**Statut : Corrigé.** `af5ca24`. Défaut `workers = 0` : un processus par CPU utilisable (`os.process_cpu_count()`), séquentiel en dessous de 50 fichiers ; une valeur explicite est gardée telle quelle. Mesures sur 4 CPU [Vérifié] : point d'équilibre entre 20 et 30 fichiers ; avec les nouveaux défauts, 5 fichiers en 0,12 s (inchangé), `rich` en 0,30 s au lieu de 0,46 s, stdlib en 1,8 à 2,0 s au lieu de 5,1 à 5,3 s.
+
+Le mode auto donne 2,4x sur 4 CPU pour la stdlib. Pour pre-commit (quelques fichiers), le séquentiel reste préférable à cause du coût de démarrage des process.
+
+Proposition : défaut `0` (auto) avec un seuil, par exemple parallèle seulement au-delà de 50 fichiers.
+
+```python
+# cli.py - only pay the process pool start-up cost on large runs
+_PARALLEL_THRESHOLD = 50
+
+if workers <= 1 or len(files) < _PARALLEL_THRESHOLD:
+ ...
+```
+
+### PERF-03 - Soumission fichier par fichier [Déduit] - Basse
+
+**Statut : Écarté.** `pool.map` avec `itertools.repeat(config)` depuis `b2fd640`. Mesures [Vérifié] : config sérialisée de 1 571 octets ; sur la stdlib, `chunksize` de 4 à 16 donne 1,27 à 1,28 s contre 1,35 s, dans la variation d'une exécution à l'autre ; aucun gain sur `rich`.
+
+`pool.submit` par fichier (`cli.py:187`) sérialise `LinterConfig` pour chaque tâche et crée un futur par fichier. `pool.map(..., chunksize=16)` réduirait les échanges inter-process. Gain probablement marginal au regard de PERF-01.
+
+### PERF-04 - Parcours des répertoires exclus [Vérifié] - Basse
+
+**Statut : Corrigé.** `d6720e3`. Parcours par `Path.walk()` qui saute les dossiers nommés par un motif littéral ; les motifs à joker filtrent toujours fichier par fichier. Listes de fichiers identiques avant et après sur ce dépôt, `rich` et la stdlib [Vérifié]. Collecte de `.` sur ce dépôt : 72 ms avant, 4 ms après (même exécution). Au passage, un dossier nommé `x.py` n'est plus pris pour un fichier (il donnait "Cannot read" et le code 2). Décision : `.gitignore` non pris en compte.
+
+`collect_python_files` fait `rglob("*.py")` puis filtre (`cli.py:40`) : les répertoires exclus (`.venv`, `node_modules`, ...) sont quand même parcourus. Sur ce dépôt, `.venv` contient 933 fichiers `.py` et le surcoût mesuré est de 0,02 s (0,205 s pour `.` contre 0,180 s pour `src tests example`). Négligeable ici, sensible sur un monorepo.
+
+Proposition : `os.walk` avec élagage de `dirnames` sur les motifs d'exclusion, et option de respect du `.gitignore` (comportement de ruff [Non vérifié]).
+
+### PERF-05 - Pas de cache [Déduit] - Basse
+
+**Statut : Écarté.** Mesures [Vérifié] : lire et hacher les fichiers coûte 0,08 s sur la stdlib contre 4,5 s d'analyse séquentielle (1,8 à 2,0 s avec les workers par défaut), mais pre-commit ne passe que des fichiers modifiés et la CI part d'un checkout neuf, donc le gain réel est faible [Déduit]. Un cache fiable demanderait une clé incluant la config effective par fichier et le code du linter (version figée, OPS-04). À réévaluer avec une intégration éditeur ou des monorepos très volumineux.
+
+ruff met en cache les résultats par fichier. Pour ce linter, le temps actuel ne le justifie pas. À réévaluer seulement si l'outil vise des monorepos de plusieurs dizaines de milliers de fichiers.
+
+### PERF-06 - Python 3.14 free-threaded [Non vérifié] - Basse
+
+**Statut : Écarté.** Mesuré avec Python 3.14.7 free-threaded (`uv python install 3.14t`), 4 CPU [Vérifié] : sans GIL, 4 threads font jeu égal avec 4 processus (stdlib 1,63 s contre 1,56 s, `rich` 0,14 s contre 0,15 s) ; avec GIL, les threads sont plus lents que le séquentiel. Résultats identiques sous 3.14t (52 697 erreurs sur la stdlib, 1 990 sur `rich`).
+
+Sur un build free-threaded, un `ThreadPoolExecutor` éviterait le coût de démarrage et la sérialisation. Piste exploratoire uniquement, pas prioritaire.
+
+## Méthode de reproduction
+
+Depuis BUG-04, `--config /nonexistent` est une erreur : remplacer ce chemin par un fichier TOML vide.
+
+```bash
+# Timing
+time docstring-linter "$(python -c 'import sysconfig;print(sysconfig.get_paths()["stdlib"])')" \
+ --workers 1 --format json --config /nonexistent > /dev/null
+
+# Profile
+python - <<'EOF'
+import cProfile, contextlib, io, pstats, sys, sysconfig
+sys.argv = ["docstring-linter", sysconfig.get_paths()["stdlib"], "--format", "json", "--config", "/nonexistent"]
+from linter.cli import main
+profiler = cProfile.Profile()
+with contextlib.redirect_stdout(io.StringIO()):
+ profiler.enable()
+ try:
+ main()
+ except SystemExit:
+ pass
+ profiler.disable()
+pstats.Stats(profiler).sort_stats("tottime").print_stats(15)
+EOF
+```
+
+Suggestion : ajouter un script `scripts/benchmark.sh` sur un corpus figé pour suivre l'évolution.
diff --git a/review/04-usage-cli-configuration.md b/review/04-usage-cli-configuration.md
new file mode 100644
index 0000000..40e633e
--- /dev/null
+++ b/review/04-usage-cli-configuration.md
@@ -0,0 +1,235 @@
+# 04 - Usage, CLI et configuration
+
+## Expérience de première utilisation [Vérifié]
+
+Scénario : un projet existant, documenté en Google style "classique", lance l'outil sans configuration. Mesure sur `rich` 15.0.0 :
+
+```text
+{'files_checked': 100, 'total_errors': 2410, 'files_with_errors': 100}
+```
+
+| Règle | Erreurs | Nature dominante |
+|---|---|---|
+| `args_match` | 502 | 382 type mismatch (dont 318 équivalents modulo `, optional`, 84 forward refs) |
+| `docstring_exists` | 449 | Docstrings réellement absentes (dunders, privées incluses) |
+| `blank_lines` | 383 | 333 "blank line before closing quotes" |
+| `args_section` | 225 | Paramètres non documentés |
+| `returns_section` | 162 | Dont `@property` et générateurs |
+| `returns_none` | 130 | `Returns: None` exigé sur `-> None` |
+| `attributes_section` | 126 | Section `Attributes:` exigée |
+| `returns_match` | 81 | Dont `Returns:` sans type |
+| `imperative_mood` | 68 | Dont "Does" -> "Doe" |
+| `indentation` | 62 | Descriptions sur plusieurs lignes (BUG-14) |
+| autres | 222 | |
+
+100 % des fichiers en erreur. Un tiers au moins des erreurs relève soit d'un faux positif (types, indentation), soit d'une convention maison que peu de projets suivent (ligne vide avant `"""`, `Returns: None`). L'utilisateur conclut vite que l'outil "ne marche pas" sur son code.
+
+## Constats
+
+| ID | Gravité | Sujet | Statut |
+|---|---|---|---|
+| UX-01 | Critique | Aucune suppression inline | Corrigé |
+| UX-02 | Haute | Défauts très opinionnés, pas de preset | Corrigé |
+| UX-03 | Haute | Pas de baseline ni de mode "diff" | Reporté |
+| UX-04 | Haute | Portée non réglable (privé, dunder, overload, property, override) | Partiel |
+| UX-05 | Moyenne | Toutes les erreurs pointent la ligne `def` | Reporté |
+| UX-06 | Critique | Codes de sortie (voir BUG-01 à BUG-04) | Corrigé |
+| UX-07 | Moyenne | Sorties : couleurs forcées, pas de `--quiet`, `--statistics`, `--select` | Partiel |
+| UX-08 | Moyenne | Pas d'autofix | Reporté |
+| UX-09 | Moyenne | Chemins relatifs au répertoire courant, pas au fichier de config | Corrigé |
+| UX-10 | Haute | Hook pre-commit inutilisable sans Python 3.14 par défaut | Partiel |
+| UX-11 | Moyenne | GitHub Action : injection, build à chaque run, Python à fournir | Partiel |
+| UX-12 | Basse | Identifiants de politiques affichés comme des règles | Écarté |
+| UX-13 | Basse | Pas de `--version`, pas de `--explain` | Partiel |
+
+### UX-01 - Suppression inline [Vérifié] - Critique
+
+**Statut : Corrigé.** `672ee78`. `# docstring-linter: ignore[rule, ...]` en fin de ligne `def` ou `class` (ligne d'ouverture du docstring pour un module) fait taire les règles et politiques nommées, always-on comprises ; sans crochets, toutes les erreurs de l'entité. Un nom inconnu donne une erreur avec fichier et ligne sur stderr et le code 2. `# noqa` n'est pas reconnu, pour ne pas s'afficher dans l'autocomplétion de VS Code. Le signalement des suppressions inutiles est reporté.
+
+Aucun mécanisme `# noqa` ou équivalent (`grep` sur `src/` : aucune lecture de commentaire). Combiné aux 11 règles always-on (ARCH-06), tout faux positif est bloquant.
+
+Proposition minimale, lisible depuis le `CodeEntity` si la ligne source est conservée :
+
+```python
+def legacy_api(x): # docstring-linter: ignore[args_match, returns_match]
+ ...
+```
+
+Implémentation : `tokenize` ou lecture de la ligne `node.lineno` (et des lignes de décorateurs), regex `# (?:noqa|docstring-linter: ignore)(?:\[([\w, ]+)\])?`, filtrage des `LintError` de l'entité. pydoclint propose un mode noqa natif (`--native-mode-noqa-location`) [Vérifié via `--help`].
+
+Bonus utile : signaler les suppressions inutiles (équivalent de `RUF100` de ruff [Non vérifié]).
+
+### UX-02 - Défauts opinionnés [Vérifié] - Haute
+
+**Statut : Corrigé.** `b676b02`, complété par `735d616`, `ce82164`, `66b3bed`, `f7df8f3`. `convention = "google"` fixe des défauts proches du guide Google ; `strict` reste le défaut. Sur `rich`, 758 erreurs avec `google` contre 2 410 à la revue [Vérifié].
+
+Valeurs par défaut qui s'écartent des usages courants du Google style :
+
+| Réglage | Défaut | Usage courant Google [Non vérifié] |
+|---|---|---|
+| `blank_lines_before_closing_quotes` | 1 | 0 dans les exemples du guide Google et de Napoleon |
+| `returns_none` | `required` | Section omise quand la fonction ne retourne rien |
+| `documented_types` | `required` | Types optionnels si la signature est annotée |
+| `raises_section` | `required` | Recommandé, rarement exigé exhaustivement |
+| `attributes_section` | `required`, privés inclus | Attributs publics seulement |
+| `imperative_mood` | appliqué aux classes | Usuellement fonctions et méthodes (D401) |
+| `return_type_annotation` | activée | Relève d'un linter de typage (ruff `ANN`), pas des docstrings |
+
+Ces choix sont légitimes pour un style maison, mais ils devraient être un **preset** explicite, pas le comportement implicite.
+
+Proposition :
+
+```toml
+[tool.docstring-linter]
+convention = "google" # permissive defaults matching the Google guide
+# convention = "strict" # current behaviour
+```
+
+`convention` fixe les valeurs par défaut des politiques ; chaque clé explicite reste prioritaire.
+
+### UX-03 - Baseline / mode diff [Déduit] - Haute
+
+**Statut : Reporté.** Conception proposée (options `--baseline` et `--generate-baseline`, empreinte sans numéro de ligne, fichier JSON, pas de réécriture automatique), à reprendre plus tard.
+
+Pour adopter l'outil sur une base existante, il faut soit tout corriger, soit exclure massivement. pydoclint propose `--baseline` et `--generate-baseline` [Vérifié via `--help`].
+
+Proposition : `--generate-baseline .docstring-linter-baseline.json` (empreinte par `filepath`, `entity_name`, `rule`, `message`, sans numéro de ligne pour résister aux décalages) puis `--baseline` pour ne remonter que les nouvelles erreurs.
+
+### UX-04 - Portée non réglable [Vérifié] - Haute
+
+**Statut : Partiel.** `ce82164`, `65839f4` : options `exclude_dunder_methods`, `exclude_private`, `exclude_overridden`, `properties_as_attributes` ; `@overload` toujours ignoré. Décisions : `exclude_overridden` reste limité à `@override` (sur `rich`, 24 méthodes non documentées redéfinissent une méthode d'une classe parente du même fichier, 60 une classe parente d'ailleurs [Vérifié]) ; attributs privés toujours exigés dans `Attributes:` pour le moment (piste écartée : les rattacher à `exclude_private` ; piste possible plus tard : une option dédiée).
+
+Observé :
+
+| Cas | Comportement actuel |
+|---|---|
+| `__repr__` sans docstring | `docstring_exists` |
+| `@overload` | `docstring_exists` par signature (BUG-21) |
+| `@property def size(self) -> int` | `returns_section` exigé |
+| Méthode surchargée sans docstring (`Child.run`) | `docstring_exists` |
+| Attribut `self._cache` | `attributes_section` exigé |
+| Fonction `_private` | Traitée comme publique [Déduit] |
+
+`scope` ne distingue que modules, classes, fonctions et méthodes. Les concurrents offrent ces réglages : docsig `--check-dunders`, `--check-protected`, `--check-overridden`, `--check-property-returns`, `--check-nested` ; pydoclint `--skip-checking-private-functions`, `--should-document-private-class-attributes`, `--treat-property-methods-as-class-attributes` [Vérifié via `--help`].
+
+Proposition :
+
+```toml
+[tool.docstring-linter.scope]
+private = false # _name
+dunder = false # __name__, except __init__
+overloads = false # @overload stubs
+overridden = false # @override / @typing.override
+properties = "attribute" # document @property like an attribute, no Returns
+private_attributes = false
+```
+
+### UX-05 - Ligne de l'erreur [Vérifié] - Moyenne
+
+**Statut : Reporté.** Approche proposée : niveau intermédiaire (ligne précise pour les règles de mise en page et les entrées, en-tête de section ou guillemets ouvrants sinon, `docstring_exists` et `return_type_annotation` sur le `def`), un en-tête `traceback` par ligne distincte, `# noqa` lu sur la ligne `def`/`class`.
+
+`make_error` utilise `entity.line` (`rules/_base.py:36-55`) : toutes les erreurs d'une entité pointent la ligne `def`/`class`. Pour une annotation GitHub ou un saut d'éditeur, la ligne fautive dans le docstring serait plus utile (entrée `Args:` erronée, section mal ordonnée). Dépend d'ARCH-03 (modèle ligne à ligne) et d'ARCH-07 (`docstring_line`).
+
+### UX-06 - Codes de sortie [Vérifié] - Critique
+
+**Statut : Corrigé.** `b2fd640` : codes 0, 1 et 2. `8a9e4b4` : code 3 pour une erreur interne (voir ARCH-08), priorité 3 > 2 > 1 > 0 ; tableau du README complété.
+
+Détail dans BUG-01 à BUG-04. Contrat proposé et à documenter :
+
+| Code | Signification |
+|---|---|
+| 0 | Aucune erreur |
+| 1 | Erreurs de lint |
+| 2 | Configuration invalide, chemin inexistant, fichier illisible ou non parsable |
+| 3 | Erreur interne |
+
+### UX-07 - Sorties [Vérifié] - Moyenne
+
+**Statut : Partiel.** Couleurs désactivées hors terminal et avec `NO_COLOR` (`0355225`), `--statistics` (`9c90fd5`), ligne `Config:` sur stderr (`6962065`). Décisions : `--select`/`--ignore` en CLI et niveau `warning` des annotations écartés ; sortie SARIF reportée (suivie dans `TODO.md`).
+
+- Codes ANSI toujours émis, même vers un pipe ou un fichier (`reporter.py:18-30`) ; pas de `NO_COLOR` ni `--no-color`. Vérifié : la sortie redirigée contient `\x1b[1m`, `\x1b[96m`, etc.
+
+ ```python
+ # reporter.py - disable colors when not writing to a terminal
+ import os
+ import sys
+
+ _USE_COLOR = sys.stdout.isatty() and "NO_COLOR" not in os.environ
+ ```
+
+- La ligne `Config: ...` s'imprime sur stdout en `traceback` et `text` (`cli.py:255-259`) : utile, mais à envoyer sur stderr ou derrière `--verbose`.
+- Pas de `--select` / `--ignore` en CLI : impossible de tester une règle isolément sans éditer la config.
+- Pas de `--statistics` (compte par règle) : c'est pourtant le premier besoin d'une équipe qui évalue l'outil (le tableau ci-dessus a dû être produit par script).
+- Pas de SARIF (déjà au `TODO.md`) : utile pour GitHub Code Scanning.
+- `github-annotations` émet toujours `::error` ; un niveau `warning` configurable permettrait une adoption progressive.
+
+### UX-08 - Pas d'autofix [Vérifié] - Moyenne
+
+**Statut : Reporté.** Approche proposée : `--fix` et `--diff`, d'abord les règles de mise en page (texte du docstring seul), correction du texte source du littéral (positions AST et `ast.get_source_segment`, vérifiées), fichier inchangé si l'AST hors docstrings diffère après correction.
+
+Beaucoup de règles sont mécaniques : `blank_lines`, `summary_final_period`, `section_capitalization`, `entry_spacing`, `returns_none`, `section_order`, et la synchronisation des types depuis la signature. ruff propose des corrections pour une partie de ses règles `D` [Vérifié : `ruff rule --all` indique "Fix is always/sometimes available" pour certaines]. pydoclint ne corrige pas [Non vérifié].
+
+Un `--fix` limité aux règles de mise en page serait un différenciateur fort (voir 07). Prérequis : modèle ligne à ligne (ARCH-03) et réécriture du littéral dans la source (positions via `ast.get_source_segment` ou `tokenize`).
+
+### UX-09 - Chemins relatifs au CWD [Déduit] - Moyenne
+
+**Statut : Corrigé.** `23fc020`. `exclude`, `--exclude` et les `paths` des overrides sont résolus depuis le dossier du fichier de config (y compris avec `--config`), depuis le répertoire courant sans config. Lancé depuis `src/`, le cas de reproduction donne le même résultat que depuis la racine [Vérifié]. Décision : la config reste cherchée depuis le répertoire courant (documenté), pas de config par fichier comme ruff.
+
+`ConfigOverride.matches` (`config.py:213-231`) et `exclude` comparent au répertoire courant, pas au répertoire du fichier de config. Lancer `docstring-linter .` depuis `src/` avec un override `paths = ["src/**"]` ne l'applique pas. ruff résout les chemins relativement au fichier de configuration [Non vérifié].
+
+Proposition : conserver `config_dir` dans `LinterConfig` et calculer les chemins relatifs à lui.
+
+### UX-10 - Hook pre-commit [Déduit] - Haute
+
+**Statut : Partiel.** `a1fb08e`. Le contournement `language_version: python3.14` est documenté dans le README. La vraie solution (OPS-03) est reportée au lot 0.
+
+`.pre-commit-hooks.yaml` déclare `language: python`. pre-commit crée un venv avec l'interpréteur par défaut ; avec `requires-python = ">=3.14,<3.15"`, l'installation échoue partout où le Python par défaut n'est pas 3.14 (la majorité des postes et runners en septembre 2026 [Non vérifié]). Contournement utilisateur : `language_version: python3.14`, à documenter a minima. Vraie solution : OPS-03.
+
+### UX-11 - GitHub Action [Vérifié] - Moyenne
+
+**Statut : Partiel.** `319326e`. Inputs passés par variables d'environnement, une tentative `src/; echo INJECTED` n'exécute plus rien [Vérifié en simulant l'étape] ; description de `format` complétée. Décisions : `pip install` des sources conservé jusqu'à PyPI, pas de `setup-python` intégré (effet de bord sur le job appelant).
+
+`action.yml` :
+
+```yaml
+run: docstring-linter ${{ inputs.paths }} --format ${{ inputs.format }} ${{ inputs.extra-args }}
+```
+
+- Les inputs sont interpolés directement dans le script shell. Si un workflow consommateur y passe une valeur contrôlée par un tiers (titre de PR, nom de branche), c'est une injection de commande. Le guide de durcissement GitHub recommande de passer par des variables d'environnement [Non vérifié].
+
+ ```yaml
+ - name: Run docstring-linter
+ shell: bash
+ env:
+ PATHS: ${{ inputs.paths }}
+ FORMAT: ${{ inputs.format }}
+ EXTRA_ARGS: ${{ inputs.extra-args }}
+ run: |
+ # Word splitting on PATHS and EXTRA_ARGS is intended
+ # shellcheck disable=SC2086
+ docstring-linter $PATHS --format "$FORMAT" $EXTRA_ARGS
+ ```
+
+- `pip install ${{ github.action_path }}` reconstruit le paquet à chaque exécution, via le backend setuptools legacy (OPS-02).
+- L'utilisateur doit lui-même installer Python 3.14 (`actions/setup-python`) ; un input `python-version` avec installation intégrée simplifierait.
+- La description de l'input `format` omet `traceback`.
+
+### UX-12 - Identifiants de politiques [Déduit] - Basse
+
+**Statut : Écarté.** Décision : pas de message spécifique.
+
+Une erreur `[returns_section]` ressemble à une règle ; `ignore = ["returns_section"]` échoue avec "unknown rule" (`config.py:528`). Le message devrait l'orienter : "'returns_section' is a policy, set returns_section = \"optional\"".
+
+### UX-13 - `--version`, `--explain` [Vérifié] - Basse
+
+**Statut : Partiel.** `--version` reporté : la version sera gérée plus tard par la CI/CD (voir OPS-04). `--explain` écarté ; à la place, `--list-rules` affiche les règles always-on dans une section dédiée (`3bc192e`).
+
+Pas de `--version` (utile pour les rapports de bug et le débogage pre-commit). Pas d'équivalent à `ruff rule ` pour afficher la doc d'une règle depuis le terminal ; `--list-rules` masque les règles always-on, alors qu'elles apparaissent dans les sorties.
+
+## Points positifs à conserver
+
+- Validation stricte de la configuration, messages clairs, exit 2. [Vérifié]
+- `--list-rules` affiche la valeur effective de chaque politique et option, et les overrides avec la valeur de base. [Vérifié]
+- Format `traceback` cliquable dans les éditeurs.
+- Overrides par chemin avec règle "le dernier gagne" explicite et documentée.
+- Double support `pyproject.toml` / `.docstring-linter.toml`.
diff --git a/review/05-documentation.md b/review/05-documentation.md
new file mode 100644
index 0000000..6dcd54f
--- /dev/null
+++ b/review/05-documentation.md
@@ -0,0 +1,144 @@
+# 05 - Documentation
+
+## Inventaire [Vérifié]
+
+| Fichier | Lignes | Public | Langue | État |
+|---|---|---|---|---|
+| `README.md` | 186 | Utilisateur | EN | Incomplet, exemple de sortie obsolète |
+| `docs/configuration.md` | 137 | Utilisateur | EN | Complet, précis |
+| `docs/configurable-rules.md` | 267 | Utilisateur | EN | Bon, exemples Bad/Good |
+| `docs/always-on-rules.md` | 503 | Utilisateur | EN | Bon |
+| `docs/style-policies.md` | 588 | Utilisateur | EN | Bon, contient une section hors sujet |
+| `RELEASE.md` | 44 | Mainteneur | EN | Décrit un flux beta qui ne fonctionne pas (OPS-06) |
+| `TESTS.md` | 680 | Mainteneur | EN/FR | Périmé (369 tests annoncés, 372 réels) |
+| `TODO.md` | 53 | Mainteneur | FR | Partiellement périmé |
+| `scripts/README.md` | 68 | Mainteneur | EN | Correct |
+| `CLAUDE.md` | 38 | Agent | EN | Correct |
+
+Points forts : la référence des règles est riche, avec des exemples "Bad / Good" systématiques et l'explication des interactions politique / règle de contenu. `docs/configuration.md` est précis sur l'ordre de découverte, les overrides et la configuration stricte.
+
+## Constats
+
+| ID | Gravité | Sujet | Statut |
+|---|---|---|---|
+| DOC-01 | Haute | README : pas d'installation, exemple de sortie faux | Corrigé |
+| DOC-02 | Moyenne | Support NumPy / Sphinx / PEP 257 annoncé dans le code | Corrigé |
+| DOC-03 | Moyenne | Fichiers annexes périmés, langues mélangées | Corrigé |
+| DOC-04 | Haute | Aucun positionnement ni comparaison | Corrigé |
+| DOC-05 | Moyenne | Pas de CHANGELOG, CONTRIBUTING, guide "ajouter une règle" | Reporté |
+| DOC-06 | Basse | Liens absolus `/docs/...` | Reporté |
+| DOC-07 | Moyenne | Limitations connues non documentées | Corrigé |
+| DOC-08 | Moyenne | Sémantique de `exclude` fausse dans la doc | Corrigé |
+| DOC-09 | Basse | Descriptions internes incohérentes | Corrigé |
+| DOC-10 | Basse | Documentation non générée depuis les registres | Écarté |
+
+### DOC-01 - README [Vérifié] - Haute
+
+**Statut : Corrigé.** `a1fb08e`. README réécrit : installation, prérequis, sortie réelle, codes de sortie, conventions.
+
+- **Pas de section Installation.** Le paquet n'est pas sur PyPI (`pip index versions docstring-linter` : aucun résultat), et rien n'indique comment l'installer (`pip install git+https://...`, `uvx --from git+...`).
+- **Pas de prérequis Python.** Python 3.14 exclusivement : c'est la première chose qu'un utilisateur doit savoir.
+- **Exemple de sortie obsolète.** Le README montre 2 erreurs sur `example/docstring_format_reference.py` ligne 84 (`divide`). En réalité :
+
+ ```console
+ $ docstring-linter example/ --format text
+ Config: /home/user/docstring-linter/pyproject.toml
+ 2 files checked, 0 errors.
+ ```
+
+ Recommandation : un exemple de sortie généré par un script (ou vérifié par un test) à partir d'un fichier d'exemple dédié et volontairement fautif.
+- La section "What is checked?" liste des catégories sans montrer l'apport principal : la cohérence docstring / signature et les politiques tri-états.
+- Pas de badge de version, de CI ni de couverture (Codecov est configuré).
+
+Structure suggérée :
+
+```markdown
+# docstring-linter
+One-line pitch + badges
+
+## Why (3 bullets: what it checks that others don't)
+## Install (pip / uvx / pre-commit / GitHub Action, Python requirement)
+## Quick start (one command, one real output)
+## Configuration (minimal example + link)
+## Suppressing (noqa, baseline, once implemented)
+## Rules (links)
+## Comparison (short table, link to details)
+## Exit codes
+```
+
+### DOC-02 - Styles annoncés [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `b2fd640`. Les docstrings de `__init__.py` et `docstring_parser.py` ne mentionnent plus que Google.
+
+`src/linter/__init__.py:3-4` : "with support for Google, NumPy, Sphinx, and PEP 257 styles". `docstring_parser.py:3-5` : "Extensible via abstract base class for multiple styles (Google, NumPy, Sphinx, PEP 257)". Seul Google existe (BUG-01, ARCH-04). À aligner sur le README, qui dit correctement "Google-style".
+
+### DOC-03 - Fichiers annexes périmés [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `.vulture` supprimé (`183aa75`). `2ecc362` : `TODO.md` mis à jour (item `RULES.md` retiré, item version renvoyant à OPS-04) et traduit en anglais ; commentaires de `.pre-commit-config.yaml` et `pyproject.toml` en anglais ; `TESTS.md` reste manuel mais un test vérifie qu'il liste chaque fonction de test et aucune fonction absente [Vérifié : il a trouvé deux lignes attribuées au mauvais fichier, corrigées].
+
+- `TESTS.md` : "369 tests" contre 372 exécutés. 680 lignes maintenues à la main, redondantes avec les docstrings des tests (constat déjà noté dans `TODO.md`). Recommandation : le générer (`pytest --collect-only` + docstrings) ou le supprimer.
+- `TODO.md` :
+ - item "Décider du sort de `RULES.md`" alors que `RULES.md` n'existe plus (remplacé par `docs/`) ;
+ - item "Aligner la version" qui cite des tags jusqu'à `v0.5.0` et un README en `v0.1.0`, alors que `v0.9.0` existe et que le README l'utilise (tags vérifiés via l'API GitHub) ;
+ - rédigé en français, comme les commentaires de `pyproject.toml` et `.pre-commit-config.yaml`, alors que le reste est en anglais.
+- `.vulture` : références de lignes périmées (ARCH-09).
+
+### DOC-04 - Positionnement absent [Vérifié] - Haute
+
+**Statut : Corrigé.** `a1fb08e`. Section "How it compares" du README.
+
+Aucune mention de ruff, pydoclint, pydocstyle, darglint ou docsig. Un visiteur ne sait pas pourquoi choisir cet outil. Voir 07 pour le contenu d'un tableau comparatif honnête (y compris ce que l'outil ne fait pas).
+
+### DOC-05 - Contribution et historique [Vérifié] - Moyenne
+
+**Statut : Reporté.** Approche proposée : `CHANGELOG.md` tenu à la main (Keep a Changelog, section Unreleased avec les changements de comportement de cette branche), `CONTRIBUTING.md` court (installation, commits, vérifications, ajout d'une règle, `TESTS.md`), pas de `SECURITY.md`, workflow de release inchangé jusqu'au lot 0. À noter : `RELEASE.md` annonce une pré-release pour les tags beta, que le workflow ne produit pas (OPS-06, déduit).
+
+- Pas de `CHANGELOG.md` : les notes de release sont la liste brute des sujets de commits (`publish-release.yml`). Les changements de comportement (nouvelles politiques, règles devenues always-on) ne sont pas signalés comme tels.
+- Pas de `CONTRIBUTING.md` : ajouter une règle implique 4 à 6 fichiers (ARCH-02) et des conventions strictes (commitlint : sujet en minuscules, 15 caractères minimum, corps interdit).
+- Pas de `SECURITY.md`.
+
+### DOC-06 - Liens absolus [Déduit] - Basse
+
+**Statut : Reporté.** À traiter avec la publication PyPI (OPS-05). 4 liens concernés (README lignes 175, 180 à 182). Option recommandée : URL complètes vers `blob/main/docs/...` ; la branche par défaut est `develop` [Vérifié], `main` ne reçoit que les releases.
+
+Le README utilise `/docs/configuration.md`. Ces liens fonctionnent sur GitHub mais seront cassés dans la description PyPI, qui reprend le README. Utiliser des URL complètes `https://github.com/bastgau/docstring-linter/blob/main/docs/...` ou des liens relatifs réécrits au build.
+
+### DOC-07 - Limitations non documentées [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `8e448bb`. Section "Known limitations" du README : 9 points, revérifiés sur le code (fonctions imbriquées, `raise` indirects, attributs hors `__init__`, alias de types, `@override`, ligne `def`, Google seul, pas de `# noqa` ni de baseline). Le `raise` nu dans un `except` typé et l'affectation multiple `self.a, self.b = ...` ont ensuite été corrigés (`1ce67fb`) et retirés de la liste.
+
+Non mentionnées nulle part :
+
+- fonctions et classes imbriquées dans des fonctions jamais analysées ;
+- `def` sous `if` / `try` / `with` jamais analysés (BUG-15) ;
+- seules les exceptions levées directement et nommées simplement sont vues (BUG-09, BUG-10, BUG-11) ;
+- comparaison de types purement textuelle (BUG-12, BUG-13) ;
+- erreurs toujours rapportées sur la ligne `def` (UX-05).
+
+Tant que ces points ne sont pas corrigés, une section "Known limitations" évite des rapports de bug et fixe les attentes.
+
+### DOC-08 - `exclude` [Vérifié] - Moyenne
+
+**Statut : Corrigé.** Sémantique unifiée (BUG-07, `b6ad01e`). `b9dc74a` : section déplacée dans `docs/configuration.md` ("Exclusion patterns", ancre sur la même page), retirée de `style-policies.md` ; précise qu'`exclude` remplace la liste par défaut (`exclude = ["migrations"]` donne `['migrations']` [Vérifié]) avec un exemple qui la conserve.
+
+`docs/configuration.md` et `--help` parlent de "Glob patterns". Les globs récursifs ne fonctionnent pas (BUG-07), et un motif littéral exclut un nom de répertoire à toute profondeur. La section "Default Exclusion Patterns" est placée dans `docs/style-policies.md:565-567`, alors qu'il ne s'agit pas d'une politique de style ; sa place est `docs/configuration.md`.
+
+### DOC-09 - Descriptions internes [Vérifié] - Basse
+
+**Statut : Corrigé.** Description de `section_order` (`acdcf43`), commentaire sur `--select` (`89c4a28`), input `format` d'`action.yml` (`319326e`).
+
+- `RULES_REGISTRY["section_order"]` : "Args, Returns, Yields, Raises, Example(s), Note(s)" ; ordre réel : Attributes, Args, Returns, Yields, Raises, Example(s), Note(s), Todo. Cette chaîne est affichée par `--list-rules`.
+- Commentaire `config.py:138` : mentionne `--select`, flag inexistant.
+- `action.yml` : input `format` décrit "(text, json, github-annotations)", sans `traceback`.
+
+### DOC-10 - Documentation non générée [Vérifié] - Basse
+
+**Statut : Écarté.** Remplacé par le test de cohérence qui exige un titre dans `docs/` pour chaque règle et politique (`6bfff7a`, voir ARCH-02).
+
+Registres (`RULES_REGISTRY`, `POLICIES_REGISTRY`, `OPTIONS_REGISTRY`) et pages `docs/` sont maintenus séparément. Proposition déjà esquissée dans `TODO.md` : générer le squelette (tableaux, valeurs par défaut) depuis le registre unique d'ARCH-02 / ARCH-05, garder les exemples à la main, et vérifier en CI que le fichier committé est à jour.
+
+```bash
+# scripts/check-docs.sh - fail when generated docs drift from the registry
+uv run python -m linter.docs > /tmp/rules.md
+diff -u docs/generated-rules.md /tmp/rules.md
+```
diff --git a/review/06-packaging-ci-outillage.md b/review/06-packaging-ci-outillage.md
new file mode 100644
index 0000000..8da525d
--- /dev/null
+++ b/review/06-packaging-ci-outillage.md
@@ -0,0 +1,183 @@
+# 06 - Packaging, CI et outillage
+
+## Constats
+
+| ID | Gravité | Sujet | Statut |
+|---|---|---|---|
+| OPS-01 | Critique | Paquet importable nommé `linter` | Reporté |
+| OPS-02 | Haute | Pas de `[build-system]`, backend setuptools legacy, licence dépréciée | Reporté |
+| OPS-03 | Haute | `requires-python = ">=3.14,<3.15"` | Reporté |
+| OPS-04 | Basse | Version statique `0.1.0`, pas de `--version` | Reporté |
+| OPS-05 | Haute | Absent de PyPI malgré le job de publication | Reporté |
+| OPS-06 | Haute | Flux de release beta cassé, changelog beta incorrect | Écarté |
+| OPS-07 | Moyenne | Chaîne d'approvisionnement de la CI | Corrigé |
+| OPS-08 | Basse | Erreurs dans `.pre-commit-config.yaml` | Corrigé |
+| OPS-09 | Basse | `pyrightconfig.json` lié au devcontainer | Écarté |
+| OPS-10 | Basse | Résidus de template dans `pyproject.toml` | Corrigé |
+| OPS-11 | Moyenne | CI mono-version, mono-OS | Reporté |
+| OPS-12 | Basse | Le workflow `action.yml` teste `@main`, pas le commit courant | Corrigé |
+| OPS-13 | Basse | commitlint interdit corps et trailers | Écarté |
+
+### OPS-01 - Nom du paquet importable [Vérifié] - Critique
+
+**Statut : Reporté.** Lot 0.
+
+Le wheel installe un paquet top-level `linter` :
+
+```text
+linter/__init__.py
+linter/ast_parser.py
+linter/cli.py
+...
+docstring_linter-0.1.0.dist-info/top_level.txt
+```
+
+`linter` est un nom générique : toute autre distribution qui installe un module `linter` écrase ou est écrasée par celui-ci dans le même environnement, sans avertissement. C'est à corriger **avant** la première publication PyPI, car le renommage casse ensuite les imports des utilisateurs.
+
+Correctif : `src/docstring_linter/`, `[project.scripts] docstring-linter = "docstring_linter.cli:main"`, et mise à jour de `pythonpath`, `pyrightconfig.json`, `ruff.src`, tests.
+
+### OPS-02 - Build backend [Vérifié] - Haute
+
+**Statut : Reporté.** Lot 0.
+
+`pyproject.toml` n'a pas de `[build-system]`. `uv build` retombe sur setuptools et émet :
+
+```text
+SetuptoolsDeprecationWarning: `project.license` as a TOML table is deprecated
+```
+
+Correctif :
+
+```toml
+[build-system]
+requires = ["uv_build>=0.11,<0.13"]
+build-backend = "uv_build"
+
+[project]
+license = "MIT"
+license-files = ["LICENSE"]
+```
+
+Les bornes de `uv_build` sont à ajuster à la version réellement utilisée [Non vérifié]. `hatchling` est une alternative équivalente.
+
+### OPS-03 - Versions Python [Vérifié] - Haute
+
+**Statut : Reporté.** Lot 0, avec la matrice de versions (OPS-11).
+
+`requires-python = ">=3.14,<3.15"`.
+
+- **Borne haute** : bloque l'installation sur 3.15 dès sa sortie, prévue en octobre 2026 selon le calendrier PEP 790 [Non vérifié]. Plafonner `requires-python` est déconseillé car pip et uv choisissent alors d'anciennes versions ou échouent [Non vérifié].
+- **Borne basse** : exclut 3.10 à 3.13, encore majoritaires. Pour un linter, c'est l'environnement de l'utilisateur qui compte (pre-commit, CI, IDE).
+
+Pourquoi 3.14 est nécessaire aujourd'hui [Vérifié par lecture] :
+
+1. Annotations évaluées paresseusement (PEP 649) : `LinterConfig.for_path(...) -> LinterConfig` (`config.py:308`) référence la classe dans son propre corps, et les modules de règles importent `CodeEntity`, `LintError`, `ParsedDocstring` uniquement sous `TYPE_CHECKING` (`rules/args.py:9-10`, `rules/docstring.py:9-10`, `rules/attributes.py:9-10`, `reporter.py:13-15`).
+2. `PurePath.full_match` (Python 3.13+) dans `config.py:231`.
+
+Coût de l'élargissement à 3.10+ [Déduit] : `from __future__ import annotations` en tête de chaque module, et une fonction de glob maison (`fnmatch` segment par segment, ou `glob.translate` disponible depuis 3.13). Limite inhérente et acceptable : l'outil ne peut pas parser une syntaxe plus récente que son interpréteur.
+
+Recommandation : `requires-python = ">=3.10"` (ou `>=3.12` pour limiter l'effort), sans borne haute, avec une matrice CI (OPS-11).
+
+### OPS-04 - Version [Vérifié] - Basse
+
+**Statut : Reporté.** Version et `--version` seront gérées plus tard par la CI/CD. `pyproject.toml` déclare toujours `0.1.0`, y compris au tag `v0.9.0` [Vérifié par `git show v0.9.0:pyproject.toml`].
+
+`version = "0.1.0"` est figée ; la CI la remplace au build depuis le tag (`uv version ${{ github.ref_name }}`, `publish-release.yml`). En local et depuis Git, l'outil se déclare `0.1.0` alors que le dernier tag est `v0.9.0`. Aucun `--version`.
+
+Correctif : `--version` via `importlib.metadata.version("docstring-linter")` ; optionnellement une version dynamique depuis Git (`hatch-vcs`, `uv-dynamic-versioning` [Non vérifié]).
+
+### OPS-05 - PyPI [Vérifié] - Haute
+
+**Statut : Reporté.** Cause vérifiée dans le log du job "Publish to PyPI" de `v0.9.0` : `invalid-publisher`, aucun trusted publisher déclaré sur PyPI (le projet n'existe pas, 404 sur `pypi.org/pypi/docstring-linter/json`). À faire après le lot 0 : déclarer un pending publisher (`bastgau` / `docstring-linter` / `publish-release.yml` / environnement `pypi`), passer les liens du README en URL complètes (DOC-06), ne pas relancer le job de `v0.9.0`.
+
+Le job `pypi-publish` existe (trusted publishing, environnement `pypi`), 4 releases stables sont publiées sur GitHub (`v0.3.0`, `v0.4.0`, `v0.5.0`, `v0.9.0`, vérifié via l'API), mais `pip index versions docstring-linter` ne renvoie rien. Soit l'environnement `pypi` / le trusted publisher n'est pas configuré, soit le job échoue [Déduit, logs non consultés]. Le nom semble libre [Déduit].
+
+C'est le principal frein de distribution : sans PyPI, pas de `pip install`, pas de `uvx docstring-linter`, pas de résolution de version par pre-commit hors Git.
+
+À faire dans l'ordre : OPS-01, OPS-02, OPS-03, puis publication.
+
+### OPS-06 - Flux de release beta [Déduit] - Haute
+
+**Statut : Écarté.** Correctifs proposés (condition sur le job `build`, `git describe` pour le changelog beta) et refusés.
+
+`publish-release.yml` :
+
+```yaml
+guard:
+ needs: tests
+ if: ${{ !contains(github.ref_name, '-') }} # skipped for beta tags
+build:
+ needs: [tests, guard] # no if: condition
+```
+
+Selon la sémantique GitHub Actions, un job dont une dépendance est `skipped` est lui-même ignoré, sauf condition explicite (`if: ${{ !cancelled() && ... }}`) [Non vérifié ici, comportement documenté]. Pour un tag `v1.0.0-beta.1`, `guard` est ignoré, donc `build` et `github-publish` aussi : aucune pré-release n'est créée, contrairement à `RELEASE.md`. Aucune pré-release n'existe sur le dépôt [Vérifié], ce qui est cohérent mais ne prouve pas le défaut (aucun tag beta n'a été poussé [Vérifié]).
+
+Second défaut : pour un tag beta, l'étape `last_stable` est ignorée, `PREVIOUS` est vide, et le changelog contient **tout l'historique** (`git log HEAD`) au lieu des commits depuis le tag précédent annoncés par `RELEASE.md`.
+
+Correctif :
+
+```yaml
+build:
+ needs: [tests, guard]
+ if: ${{ !cancelled() && needs.tests.result == 'success' && contains(fromJSON('["success", "skipped"]'), needs.guard.result) }}
+```
+
+Et pour le changelog beta, prendre le tag précédent quel qu'il soit : `git describe --tags --abbrev=0 HEAD^`.
+
+### OPS-07 - Chaîne d'approvisionnement [Vérifié] - Moyenne
+
+**Statut : Corrigé.** `629fe46` (secrets et `permissions`), `2049cda` (archive dotenv-linter vérifiée par sha256 en CI, installateur épinglé dans `scripts/install.sh`), `4580d83` (30 actions épinglées par SHA). Vérifié localement ; pas encore exécuté sur un runner GitHub.
+
+- `lint.yml` et `scripts/install.sh` exécutent `curl -sSfL https://raw.githubusercontent.com/dotenv-linter/dotenv-linter/master/install.sh | sudo sh` : script récupéré depuis une branche mobile et exécuté en root. Le binaire est épinglé (`v4.0.0`), pas l'installeur. Épingler l'URL sur un tag ou un SHA, ou utiliser une action dédiée.
+- Actions épinglées par tag majeur (`actions/checkout@v7`, ...) et non par SHA. Dependabot est configuré pour `github-actions`, ce qui rend l'épinglage par SHA peu coûteux à maintenir.
+- `tests: secrets: inherit` (`ci.yml`, `publish-release.yml`) transmet tous les secrets alors que seul `CODECOV_TOKEN` est utilisé. Déclarer `secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`.
+- Pas de `permissions:` au niveau de `ci.yml` (défaut du dépôt appliqué) [Déduit].
+
+### OPS-08 - `.pre-commit-config.yaml` [Vérifié] - Basse
+
+**Statut : Corrigé.** `651718b` : `groups: [local]` et `files: ^src/`. `2ecc362` : commentaires de section en anglais.
+
+- Hook `mixed-line-ending` : `groups: [locals]` au lieu de `[local]`, il ne s'exécute donc jamais dans le groupe `local`.
+- Hook `pyright-src` : pas de filtre `files: ^src/`, contrairement aux autres hooks "src" ; il reçoit aussi les fichiers de `tests/` quand ils sont modifiés.
+- Commentaires de sections en français (le reste du dépôt est en anglais).
+
+### OPS-09 - `pyrightconfig.json` [Vérifié] - Basse
+
+**Statut : Écarté.** `09d7134`. Chemin du devcontainer conservé (choix du projet).
+
+`"venv": "/workspaces/docstring-linter/.venv"` est un chemin du devcontainer. Ailleurs, pyright affiche `venv /workspaces/docstring-linter/.venv subdirectory not found`. Remplacer par `"venvPath": "."`, `"venv": ".venv"`.
+
+### OPS-10 - Résidus dans `pyproject.toml` [Vérifié] - Basse
+
+**Statut : Corrigé.** Sections sqlfluff supprimées (`651718b`). Décisions : `line-length = 200` conservé ; groupes `github-src`/`github-tests` conservés, car Dependabot met à jour les trois groupes en un commit (vérifié sur `0957240`) et `uv lock` refuse deux versions différentes d'un même outil entre groupes (vérifié sur une copie).
+
+- `[tool.sqlfluff.core]` et `[tool.sqlfluff.indentation]` (dialecte `mariadb`) : sans rapport avec le projet.
+- `[tool.pylint.format] max-module-lines = 1100` et `line-length = 200` : choix assumé, mais 200 colonnes réduit la lisibilité en revue et masque BUG-14 dans les docstrings du projet.
+- Groupes `github-src` et `github-tests` qui dupliquent les versions de `dev` : trois endroits à mettre à jour par bump (visible dans l'historique des commits Dependabot).
+
+### OPS-11 - Matrice CI [Vérifié] - Moyenne
+
+**Statut : Reporté.** Job Windows écarté ; matrice de versions reportée au lot 0 (avec OPS-03).
+
+Une seule version Python (`vars.PYTHON_VERSION`), un seul OS (`ubuntu-latest`). Après OPS-03, ajouter une matrice de versions et au moins un job Windows : le code manipule des chemins (`Path.match`, `full_match`, `Path.resolve()` dans le format `traceback`) dont le comportement diffère selon les séparateurs [Déduit].
+
+### OPS-12 - Test de l'action [Vérifié] - Basse
+
+**Statut : Corrigé.** `319326e`. Le workflow utilise `uses: ./` et teste donc le commit courant.
+
+`.github/workflows/action.yml` utilise `bastgau/docstring-linter@main`, donc la version déjà poussée sur `main`, pas le commit testé. `uses: ./` testerait le code de la PR ou du push courant.
+
+### OPS-13 - commitlint [Vérifié] - Basse
+
+**Statut : Écarté.** Règle conservée ; les commits de la branche ont été réécrits sans corps ni trailers.
+
+`.commitlintrc.mjs` impose `body-empty: always` et `footer-empty: always`. Cela interdit d'expliquer le "pourquoi" d'un changement dans le commit et bloque les trailers standards (`Co-authored-by`, `Signed-off-by` hors Dependabot, `Fixes #N`). Choix de projet, mais coûteux pour la traçabilité : à rediscuter.
+
+## Points positifs
+
+- Pipeline structuré en workflows réutilisables (`lint`, `tests`, `build`, `action`).
+- Verrouillage des dépendances (`uv.lock`) et hook `uv lock --check`.
+- Dependabot pour `uv` et `github-actions`.
+- Publication PyPI prévue en trusted publishing (pas de token stocké).
+- Garde "release stable uniquement depuis `main`".
+- Hooks de sécurité locaux (`detect-private-key`, fichiers interdits).
diff --git a/review/07-positionnement-et-concurrence.md b/review/07-positionnement-et-concurrence.md
new file mode 100644
index 0000000..f12b20c
--- /dev/null
+++ b/review/07-positionnement-et-concurrence.md
@@ -0,0 +1,136 @@
+# 07 - Positionnement, intérêt et concurrence
+
+## Le problème adressé
+
+Les docstrings dérivent du code : paramètre renommé mais pas sa documentation, exception ajoutée non documentée, type changé. Deux familles d'outils existent :
+
+1. **Forme** : le docstring respecte-t-il une convention (PEP 257, Google, NumPy) ? Exemple : ruff `D`.
+2. **Cohérence** : le docstring décrit-il la signature et le corps réels ? Exemples : pydoclint, docsig, ruff `DOC`.
+
+docstring-linter couvre les deux, avec un parti pris : un **style maison strict et entièrement paramétrable** via des politiques tri-états.
+
+## Paysage (versions PyPI vérifiées le 2026-09-26 via `pip index versions`)
+
+| Outil | Version | Famille | Styles | Statut |
+|---|---|---|---|---|
+| ruff, règles `D` | 0.16.8 | Forme | PEP 257, Google, NumPy | 48 règles, corrections automatiques sur une partie [Vérifié] |
+| ruff, règles `DOC` | 0.16.8 | Cohérence | Google, NumPy [Non vérifié] | 7 règles, **toutes en preview** (DOC102, 201, 202, 402, 403, 501, 502) [Vérifié] |
+| pydoclint | 0.9.1 | Cohérence | Google, NumPy, Sphinx (défaut `numpy`) | Actif ; baseline, noqa, nombreuses options [Vérifié via `--help`] |
+| docsig | 0.96.0 | Cohérence (paramètres) | Google, NumPy, Sphinx [Non vérifié] | Options dunders, protected, overridden, nested, property [Vérifié via `--help`] |
+| pylint `docparams` | 4.0.8 | Cohérence | Google, NumPy, Sphinx [Non vérifié] | Extension livrée avec pylint [Vérifié : `pylint/extensions/docparams.py`] |
+| pydocstyle | 6.3.0 | Forme | PEP 257, Google, NumPy | Déprécié au profit de ruff, dépôt archivé [Source web, date non revérifiée] |
+| darglint | 1.8.1 | Cohérence | Google, NumPy, Sphinx | Archivé en décembre 2022, fork `darglint2` [Source web] |
+| interrogate | 1.7.0 | Couverture | - | Mesure le taux de docstrings, ne valide pas le contenu |
+| numpydoc (validation) | 1.11.0 | Forme + cohérence | NumPy | Spécifique NumPy |
+| docstring-linter | non publié | Forme + cohérence | Google | Ce projet |
+
+## Comparaison mesurée sur les mêmes entrées [Vérifié]
+
+### Sur les cas limites de 01
+
+| Cas | docstring-linter | pydoclint 0.9.1 (`--style=google`) |
+|---|---|---|
+| `limit (int, optional)` pour `int \| None` | Erreur (always-on) | Erreur DOC105 |
+| Description d'argument sur 2 lignes | Erreur `indentation` | Rien |
+| `Returns:` sans type | Erreur always-on | Erreur DOC203 |
+| Générateur imbriqué | 2 erreurs | Rien |
+| `raise err` | 2 erreurs | Rien |
+| `raise errors.ValidationError` documenté `ValidationError` | Erreur | Erreur DOC503 (compare le nom pointé) |
+| Exception propagée documentée | Erreur always-on | Erreur DOC502 (désactivable : `--skip-checking-raises`) |
+| Forward ref `"Node"` | 2 erreurs | Rien |
+| `Does the thing.` | `Doe` suggéré | Hors périmètre |
+| `**kwargs` sous `Keyword Args:` | Erreur | Erreur DOC101/DOC103 |
+| Attribut privé `_cache` | Erreur | Hors défaut (option dédiée) |
+| Docstring sur `__init__` | Accepté (convention du projet) | DOC301 (préfère la docstring de classe) |
+
+Lecture : pydoclint a moins de faux positifs sur l'extraction AST (portées imbriquées, re-raise, forward refs), mais partage certains défauts de comparaison textuelle. Aucun outil n'est "correct par défaut" sur le Google style réel.
+
+### Sur `rich` 15.0.0 (100 fichiers)
+
+| Outil | Temps | Signalements |
+|---|---|---|
+| docstring-linter (défauts) | 0,41 s | 2 410 |
+| pydoclint `--style=google` | 0,99 s | 387 |
+| docsig (défauts) | 2,23 s | 434 lignes de sortie |
+| ruff `D,DOC --preview` | 0,04 s | non comparé (périmètre différent) |
+
+Les volumes ne sont pas comparables un à un : docstring-linter vérifie aussi la présence (`docstring_exists`), la mise en page et le style maison.
+
+Suivi, révision `8bb902d` [Vérifié] : 1 990 signalements avec les défauts, 758 avec `convention = "google"`. Les faux positifs AST et de types listés dans 01 sont corrigés ; le README contient désormais un tableau comparatif (DOC-04).
+
+## Forces distinctives de docstring-linter
+
+1. **Politiques tri-états homogènes** : 15 politiques `required` / `forbidden` / `optional` couvrant sections, types, descriptions, `Returns: None`, position et ponctuation du résumé. On peut *interdire* une construction (types dans les docstrings, `Returns: None`), pas seulement l'exiger. pydoclint a des booléens sur certains axes, avec une sémantique moins uniforme [Vérifié via `--help`, appréciation].
+2. **Mise en page paramétrable** : nombre de lignes vides avant les sections et avant `"""`, forme exacte `name (type): description`, ordre des sections. ruff `D` couvre une partie, sans ce niveau de réglage [Déduit].
+3. **Overrides par chemin** avec résolution explicite "le dernier gagne".
+4. **Configuration stricte** : toute clé ou valeur inconnue est une erreur.
+5. **Zéro dépendance**, code petit et lisible, facile à auditer et à forker.
+6. **Sortie `traceback`** cliquable.
+
+## Faiblesses face au marché
+
+| Faiblesse | Impact | Référence |
+|---|---|---|
+| Non publié sur PyPI | Bloquant | OPS-05 |
+| Python 3.14 uniquement | Bloquant pour la majorité des projets | OPS-03 |
+| Pas de noqa, pas de baseline | Bloquant sur une base existante | UX-01, UX-03 |
+| Faux positifs AST et types | Perte de confiance immédiate | 01 |
+| Google uniquement | Exclut les projets NumPy / Sphinx | ARCH-04 |
+| 10x plus lent que ruff | Faible en pratique | 03 |
+| Pas d'intégration éditeur (LSP) | Moyen | TODO.md |
+
+## Intérêt du projet
+
+**Pour son auteur et son équipe** : réel. Il encode un style maison précis, vérifié en CI, que ni ruff ni pydoclint ne peuvent exprimer entièrement.
+
+**Pour un public plus large** : conditionnel. Le marché converge vers ruff pour la forme ; pydoclint est la référence pour la cohérence, et ruff `DOC` pourrait l'absorber quand il sortira de preview (7 règles en preview à ce jour [Vérifié]). Un nouvel outil doit apporter une raison claire de l'ajouter à côté de ruff.
+
+Niche crédible : **"le linter de docstrings Google strict, configurable et auto-correcteur"**, utilisé *en complément* de ruff (`D` désactivé ou limité) et à la place de pydoclint pour les équipes qui veulent imposer une forme exacte.
+
+## Stratégie : trois options
+
+### Option A - Outil de niche assumé (recommandée)
+
+- Google uniquement, dit clairement.
+- Priorité à la justesse (01), puis noqa + baseline, puis **`--fix`** sur la mise en page et la synchronisation des types depuis la signature. L'autofix est le différenciateur le plus fort : ni pydoclint ni ruff `DOC` ne réécrivent le contenu Args/Returns [Non vérifié pour pydoclint].
+- Preset `convention = "google"` permissif et `convention = "strict"` (état actuel).
+- Documenter la cohabitation avec ruff : quelles règles `D` désactiver pour éviter les doublons.
+
+Exemple de guide de cohabitation à publier :
+
+```toml
+[tool.ruff.lint]
+select = ["D"]
+ignore = [
+ "D417", # undocumented params: covered by args_section
+ "D401", # imperative mood: covered by imperative_mood
+ "D413", # blank line after last section: covered by blank_lines
+]
+
+[tool.ruff.lint.pydocstyle]
+convention = "google"
+```
+
+Les correspondances exactes entre règles `D` et règles de ce projet sont à établir règle par règle [Non vérifié].
+
+### Option B - Généraliste multi-style
+
+Ajouter NumPy et Sphinx pour concurrencer pydoclint frontalement. Effort élevé (modèle ligne à ligne indispensable, ARCH-03), bénéfice incertain face à un acteur établi et à ruff `DOC`.
+
+### Option C - Contribuer en amont
+
+Porter les idées différenciantes (politiques `forbidden`, normalisation des types, mise en page) dans pydoclint ou ruff. Impact plus large, maintenance partagée, mais perte de maîtrise du style maison. À envisager si l'objectif est l'impact plutôt que l'outil.
+
+## Analyse SWOT
+
+| | Positif | Négatif |
+|---|---|---|
+| **Interne** | Code propre et typé, zéro dépendance, politiques tri-états, config stricte, overrides, tests nombreux | Faux positifs, pas de noqa/baseline, Python 3.14 seul, paquet `linter`, non publié, Google seul |
+| **Externe** | ruff `DOC` encore en preview, pydocstyle et darglint abandonnés, demande d'autofix non couverte | ruff domine et peut étendre `DOC`, pydoclint mature et actif, faible visibilité d'un nouvel outil |
+
+## Sources web
+
+- [PyCQA/pydocstyle - README (dépréciation)](https://github.com/PyCQA/pydocstyle/blob/master/README.rst)
+- [PyCQA/pydocstyle](https://github.com/PyCQA/pydocstyle)
+- [terrencepreilly/darglint](https://github.com/terrencepreilly/darglint/tree/master/darglint)
+- [akaihola/darglint2](https://github.com/akaihola/darglint2)
diff --git a/scripts/install.sh b/scripts/install.sh
index 0f20ba0..df0abed 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -40,12 +40,15 @@ install_uv() {
}
install_dotenv_linter() {
+ # Installer pinned to the commit of the v4.0.0 tag, same binary version as the CI
+ local installer="https://raw.githubusercontent.com/dotenv-linter/dotenv-linter/f9076b02daed89f54ed0bd23951a4cfa5ccceac4/install.sh"
+ local version="v4.0.0"
print_rule "dotenv-linter"
printf '\n'
if ! have_cmd dotenv-linter; then
- printf '%sCommand : $ curl -sSfL https://raw.githubusercontent.com/dotenv-linter/dotenv-linter/master/install.sh | sudo sh%s\n' "${GRAY}" "${NC}"
+ printf '%sCommand : $ curl -sSfL %s | sudo sh -s -- -b /usr/local/bin %s%s\n' "${GRAY}" "$installer" "$version" "${NC}"
printf '\n'
- if curl -sSfL https://raw.githubusercontent.com/dotenv-linter/dotenv-linter/master/install.sh | sudo sh -s -- -b /usr/local/bin; then
+ if curl -sSfL "$installer" | sudo sh -s -- -b /usr/local/bin "$version"; then
printf '\n'
print_success "dotenv-linter" "Installation successful"
else
diff --git a/scripts/lint-common.sh b/scripts/lint-common.sh
index 8fbba2b..5aee3c8 100644
--- a/scripts/lint-common.sh
+++ b/scripts/lint-common.sh
@@ -148,7 +148,7 @@ run_tool() {
docstring-linter) run_check "docstring-linter" "uv run docstring-linter $scope" uv run docstring-linter "${py_files[@]}" ;;
esac
;;
- vulture) run_check "vulture" "uv run vulture " uv run vulture "${TARGETS[@]}" "$ROOT_DIR/.vulture" ;;
+ vulture) run_check "vulture" "uv run vulture " uv run vulture "${TARGETS[@]}" ;;
pytest) run_check "pytest" "uv run pytest" uv run pytest --no-cov ;;
coverage) run_check "coverage" "uv run pytest --cov" uv run pytest --cov --cov-branch --cov-report=term-missing -q --no-header ;;
shellcheck)
diff --git a/src/linter/__init__.py b/src/linter/__init__.py
index 29099ae..86aa76e 100644
--- a/src/linter/__init__.py
+++ b/src/linter/__init__.py
@@ -1,5 +1,4 @@
"""Docstring linter package.
-Validate Python docstrings against style conventions with support
-for Google, NumPy, Sphinx, and PEP 257 styles.
+Validate Google style Python docstrings against the code they document.
"""
diff --git a/src/linter/ast_parser.py b/src/linter/ast_parser.py
index 6f3462f..0f6ff9c 100644
--- a/src/linter/ast_parser.py
+++ b/src/linter/ast_parser.py
@@ -5,11 +5,29 @@
"""
import ast
+import re
from pathlib import Path
-from typing import TypeGuard
+from typing import TYPE_CHECKING, TypeGuard
+from linter.config import POLICIES_REGISTRY, RULES_REGISTRY
from linter.models import ArgInfo, CodeEntity, NodeType, RaiseInfo
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+# Nodes whose body does not run when the enclosing function runs
+_NESTED_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)
+
+# Statements whose blocks belong to the enclosing module or class body
+_COMPOUND_STATEMENTS = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith, ast.Try, ast.TryStar, ast.Match)
+
+# '# docstring-linter: ignore' or '# docstring-linter: ignore[rule, ...]' closing a def, class or module docstring line
+_IGNORE_COMMENT = re.compile(r"#\s*docstring-linter:\s*ignore(?:\[([^\]]*)\])?\s*$")
+
+
+class IgnoreCommentError(ValueError):
+ """Raised when an ignore comment names a rule that does not exist."""
+
def parse_file(filepath: str) -> list[CodeEntity]:
"""Parse a Python file and extract all code entities.
@@ -39,15 +57,53 @@ def parse_file(filepath: str) -> list[CodeEntity]:
)
)
- _walk_body(tree.body, filepath, entities, parent_class=None)
+ _walk_body(tree.body, filepath, entities, parent=None)
+
+ lines = source.splitlines()
+ # a module carries its comment on the opening line of its docstring, the others on their def or class line
+ module_line = tree.body[0].lineno if module_doc is not None else None
+ for entity in entities:
+ line = module_line if entity.node_type == NodeType.MODULE else entity.line
+ if line is not None:
+ _apply_ignore_comment(entity, lines[line - 1], line)
return entities
+def _apply_ignore_comment(entity: CodeEntity, text: str, line: int) -> None:
+ """Record on an entity the rules silenced by the ignore comment of its line.
+
+ Args:
+ entity (CodeEntity): Entity the line belongs to.
+ text (str): Source line of the def, class or module docstring.
+ line (int): Line number, for the error message.
+
+ Returns:
+ None
+
+ Raises:
+ IgnoreCommentError: If the comment names an unknown rule.
+
+ """
+ match = _IGNORE_COMMENT.search(text)
+ if match is None:
+ return
+ if match.group(1) is None:
+ entity.ignore_all = True
+ return
+
+ names = frozenset(name.strip() for name in match.group(1).split(",") if name.strip())
+ unknown = sorted(names - set(RULES_REGISTRY) - set(POLICIES_REGISTRY))
+ if unknown:
+ msg = f"{entity.filepath}:{line}: unknown rule {', '.join(repr(name) for name in unknown)} in ignore comment."
+ raise IgnoreCommentError(msg)
+ entity.ignored_rules = names
+
+
def _walk_body(
body: list[ast.stmt],
filepath: str,
entities: list[CodeEntity],
- parent_class: str | None,
+ parent: CodeEntity | None,
) -> None:
"""Walk AST body recursively to extract classes and functions.
@@ -55,7 +111,7 @@ def _walk_body(
body (list[ast.stmt]): AST body node list.
filepath (str): Source file path.
entities (list[CodeEntity]): Accumulator for extracted entities.
- parent_class (str | None): Parent class name, or None for top-level.
+ parent (CodeEntity | None): Enclosing class entity, or None for top-level.
Returns:
None
@@ -63,11 +119,92 @@ def _walk_body(
"""
for node in body:
if isinstance(node, ast.ClassDef):
- entities.append(_parse_class(node, filepath))
- _walk_body(node.body, filepath, entities, parent_class=node.name)
+ entity = _parse_class(node, filepath)
+ entities.append(entity)
+ _walk_body(node.body, filepath, entities, parent=entity)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
- entities.append(_parse_function(node, filepath, parent_class))
+ if "overload" not in _decorator_names(node):
+ entities.append(_parse_function(node, filepath, parent))
+
+ elif isinstance(node, _COMPOUND_STATEMENTS):
+ # the body of a main guard only runs as a script, its else branch runs on import
+ blocks = [node.orelse] if _is_main_guard(node) else _statement_blocks(node)
+ for block in blocks:
+ _walk_body(block, filepath, entities, parent)
+
+
+def _is_main_guard(node: ast.stmt) -> TypeGuard[ast.If]:
+ """Check whether a statement is an 'if __name__ == "__main__":' guard.
+
+ Args:
+ node (ast.stmt): Statement to inspect.
+
+ Returns:
+ TypeGuard[ast.If]: True for the guard, whichever side of == __name__ is on.
+
+ """
+ if not isinstance(node, ast.If) or not isinstance(node.test, ast.Compare):
+ return False
+ test = node.test
+ if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq):
+ return False
+ return {ast.unparse(test.left), ast.unparse(test.comparators[0])} == {"__name__", "'__main__'"}
+
+
+def _statement_blocks(node: ast.stmt) -> list[list[ast.stmt]]:
+ """Return the statement blocks nested in a compound statement.
+
+ Args:
+ node (ast.stmt): If, loop, with, try or match statement.
+
+ Returns:
+ list[list[ast.stmt]]: Every block of the statement, handlers and cases included.
+
+ """
+ blocks: list[list[ast.stmt]] = [getattr(node, name, []) for name in ("body", "orelse", "finalbody")]
+ blocks.extend(handler.body for handler in getattr(node, "handlers", []))
+ blocks.extend(case.body for case in getattr(node, "cases", []))
+ return blocks
+
+
+def _decorator_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
+ """Return the last name segment of each decorator of a function.
+
+ Args:
+ node (ast.FunctionDef | ast.AsyncFunctionDef): Function node.
+
+ Returns:
+ set[str]: Names such as 'overload' for both @overload and @typing.overload.
+
+ """
+ names: set[str] = set()
+ for decorator in node.decorator_list:
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
+ if isinstance(target, ast.Name):
+ names.add(target.id)
+ elif isinstance(target, ast.Attribute):
+ names.add(target.attr)
+ return names
+
+
+def _walk_scope(node: ast.AST) -> Iterator[ast.AST]:
+ """Yield the descendants of a node in source order, skipping nested functions.
+
+ Args:
+ node (ast.AST): Node whose descendants are visited.
+
+ Yields:
+ ast.AST: Each descendant that runs as part of the node itself.
+
+ """
+ stack = list(reversed(list(ast.iter_child_nodes(node))))
+ while stack:
+ child = stack.pop()
+ if isinstance(child, _NESTED_SCOPES):
+ continue
+ yield child
+ stack.extend(reversed(list(ast.iter_child_nodes(child))))
def _parse_class(node: ast.ClassDef, filepath: str) -> CodeEntity:
@@ -81,6 +218,7 @@ def _parse_class(node: ast.ClassDef, filepath: str) -> CodeEntity:
CodeEntity: Parsed class entity.
"""
+ init = next((stmt for stmt in node.body if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)) and stmt.name == "__init__"), None)
return CodeEntity(
name=node.name,
node_type=NodeType.CLASS,
@@ -89,6 +227,7 @@ def _parse_class(node: ast.ClassDef, filepath: str) -> CodeEntity:
docstring=ast.get_docstring(node),
raw_docstring=ast.get_docstring(node, clean=False),
class_attributes=_extract_class_attributes(node),
+ init_args=_extract_args(init.args, skip_first=True) if init else None,
)
@@ -117,8 +256,9 @@ def add(name: str) -> None:
add(stmt.target.id)
elif isinstance(stmt, ast.Assign):
for target in stmt.targets:
- if isinstance(target, ast.Name):
- add(target.id)
+ for leaf in _unpack_target(target):
+ if isinstance(leaf, ast.Name):
+ add(leaf.id)
for stmt in node.body:
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)) and stmt.name == "__init__":
@@ -139,14 +279,33 @@ def _self_attr_names(func: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
"""
names: list[str] = []
- for child in ast.walk(func):
+ for child in _walk_scope(func):
if isinstance(child, ast.AnnAssign) and _is_self_attr(child.target):
names.append(child.target.attr)
elif isinstance(child, ast.Assign):
- names.extend(t.attr for t in child.targets if _is_self_attr(t))
+ names.extend(leaf.attr for target in child.targets for leaf in _unpack_target(target) if _is_self_attr(leaf))
return names
+def _unpack_target(target: ast.expr) -> list[ast.expr]:
+ """Flatten a tuple or list assignment target into its elements.
+
+ 'self.a, *self.b = ...' gives self.a and self.b.
+
+ Args:
+ target (ast.expr): Assignment target.
+
+ Returns:
+ list[ast.expr]: The target itself, or the elements of a tuple or list target.
+
+ """
+ if isinstance(target, (ast.Tuple, ast.List)):
+ return [leaf for element in target.elts for leaf in _unpack_target(element)]
+ if isinstance(target, ast.Starred):
+ return _unpack_target(target.value)
+ return [target]
+
+
def _is_self_attr(target: ast.expr) -> TypeGuard[ast.Attribute]:
"""Check whether an assignment target is self..
@@ -163,33 +322,31 @@ def _is_self_attr(target: ast.expr) -> TypeGuard[ast.Attribute]:
def _parse_function(
node: ast.FunctionDef | ast.AsyncFunctionDef,
filepath: str,
- parent_class: str | None,
+ parent: CodeEntity | None,
) -> CodeEntity:
"""Extract function or method information from AST node.
Args:
node (ast.FunctionDef | ast.AsyncFunctionDef): AST function node.
filepath (str): Source file path.
- parent_class (str | None): Parent class name, or None for functions.
+ parent (CodeEntity | None): Enclosing class entity, or None for functions.
Returns:
CodeEntity: Parsed function or method entity.
"""
- is_method = parent_class is not None
+ is_method = parent is not None
node_type = NodeType.METHOD if is_method else NodeType.FUNCTION
- name = f"{parent_class}.{node.name}" if parent_class else node.name
+ name = f"{parent.name}.{node.name}" if parent else node.name
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=is_method and "staticmethod" not in _decorator_names(node))
return_type = ast.unparse(node.returns) if node.returns else None
- raises = _extract_raises(node)
+ raises, is_generator = _scan_body(node)
is_empty_init = False
if node.name == "__init__":
is_empty_init = _is_empty_init(node)
- is_generator = any(isinstance(child, (ast.Yield, ast.YieldFrom)) for child in ast.walk(node))
-
return CodeEntity(
name=name,
node_type=node_type,
@@ -202,20 +359,22 @@ def _parse_function(
raises=raises,
is_empty_init=is_empty_init,
is_generator=is_generator,
+ decorators=sorted(_decorator_names(node)),
+ class_docstring=parent.docstring if parent and node.name == "__init__" else None,
)
-def _extract_args(arguments: ast.arguments) -> list[ArgInfo]:
+def _extract_args(arguments: ast.arguments, *, skip_first: bool) -> list[ArgInfo]:
"""Extract argument info from AST arguments node.
Args:
arguments (ast.arguments): AST arguments structure.
+ skip_first (bool): Whether to drop the first positional parameter, the self or cls of a method.
Returns:
list[ArgInfo]: List of parsed argument info objects.
"""
- skip = {"self", "cls"}
result: list[ArgInfo] = []
all_args = arguments.posonlyargs + arguments.args
@@ -223,7 +382,7 @@ def _extract_args(arguments: ast.arguments) -> list[ArgInfo]:
num_no_default = len(all_args) - len(defaults)
for i, arg in enumerate(all_args):
- if arg.arg in skip:
+ if skip_first and i == 0:
continue
annotation = ast.unparse(arg.annotation) if arg.annotation else None
@@ -244,9 +403,6 @@ def _extract_args(arguments: ast.arguments) -> list[ArgInfo]:
kw_defaults = arguments.kw_defaults
for i, arg in enumerate(arguments.kwonlyargs):
- if arg.arg in skip:
- continue
-
annotation = ast.unparse(arg.annotation) if arg.annotation else None
kw_default = kw_defaults[i]
default = ast.unparse(kw_default) if kw_default is not None else None
@@ -288,39 +444,99 @@ def _star_arg(arg: ast.arg, prefix: str) -> ArgInfo:
)
-def _extract_raises(
- node: ast.FunctionDef | ast.AsyncFunctionDef,
-) -> list[RaiseInfo]:
- """Extract explicit raise statements from function body.
+def _exception_name(node: ast.expr) -> str | None:
+ """Return the class name designated by a raise or except expression.
+
+ A dotted name keeps its last segment. Only capitalized names count as
+ exception classes, so that errors.ValidationError gives ValidationError
+ while a variable such as exc or self.error is ignored.
+
+ Args:
+ node (ast.expr): Expression following raise, or an except clause type.
+
+ Returns:
+ str | None: Exception name, or None when it cannot be told statically.
+
+ """
+ if isinstance(node, ast.Call):
+ return _exception_name(node.func)
+ if isinstance(node, ast.Name):
+ name = node.id
+ elif isinstance(node, ast.Attribute):
+ name = node.attr
+ else:
+ return None
+ return name if name[:1].isupper() else None
+
+
+def _scan_body(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[list[RaiseInfo], bool]:
+ """Collect the raise statements and detect yields in one pass over a function body.
+
+ Nested functions and lambdas are skipped: their raises and yields belong to them.
+ Raising a name bound by 'except ... as name', or a bare raise inside the handler,
+ reports the caught exception types; raising any other variable is ignored.
Args:
node (ast.FunctionDef | ast.AsyncFunctionDef): AST function node.
Returns:
- list[RaiseInfo]: List of unique raise statements found.
+ tuple[list[RaiseInfo], bool]: Unique raises in source order, and whether the function yields.
"""
raises: list[RaiseInfo] = []
seen: set[str] = set()
- queue: list[ast.AST] = list(ast.iter_child_nodes(node))
+ is_generator = False
+ caught: dict[str, list[str]] = {}
+ reraised: dict[ast.Raise, list[str]] = {}
+
+ for child in _walk_scope(node):
+ if isinstance(child, (ast.Yield, ast.YieldFrom)):
+ is_generator = True
+ elif isinstance(child, ast.ExceptHandler) and child.type is not None:
+ types = child.type.elts if isinstance(child.type, ast.Tuple) else [child.type]
+ names = [name for name in map(_exception_name, types) if name]
+ if child.name:
+ caught[child.name] = names
+ reraised.update(dict.fromkeys(_bare_raises(child), names))
+ elif isinstance(child, ast.Raise):
+ if child.exc is None:
+ names = reraised.get(child, [])
+ elif isinstance(child.exc, ast.Name) and child.exc.id in caught:
+ names = caught[child.exc.id]
+ else:
+ exc_name = _exception_name(child.exc)
+ names = [exc_name] if exc_name else []
+ for name in names:
+ if name not in seen:
+ seen.add(name)
+ raises.append(RaiseInfo(exception_type=name, line=child.lineno))
+
+ return raises, is_generator
+
+
+def _bare_raises(handler: ast.ExceptHandler) -> list[ast.Raise]:
+ """Collect the bare raise statements that re-raise the exception of a handler.
+
+ Nested functions and nested handlers are skipped: a bare raise inside a
+ nested handler re-raises the exception that handler caught.
- while queue:
- child = queue.pop()
- if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
- continue
- if isinstance(child, ast.Raise) and child.exc is not None:
- exc_name = None
- if isinstance(child.exc, ast.Call) and isinstance(child.exc.func, ast.Name):
- exc_name = child.exc.func.id
- elif isinstance(child.exc, ast.Name):
- exc_name = child.exc.id
- if exc_name and exc_name not in seen:
- seen.add(exc_name)
- raises.append(RaiseInfo(exception_type=exc_name, line=child.lineno))
- else:
- queue.extend(ast.iter_child_nodes(child))
+ Args:
+ handler (ast.ExceptHandler): Except clause.
+
+ Returns:
+ list[ast.Raise]: Bare raise statements of the handler body.
- return raises
+ """
+ found: list[ast.Raise] = []
+ stack: list[ast.AST] = list(handler.body)
+ while stack:
+ child = stack.pop()
+ if isinstance(child, (*_NESTED_SCOPES, ast.ExceptHandler)):
+ continue
+ if isinstance(child, ast.Raise) and child.exc is None:
+ found.append(child)
+ stack.extend(ast.iter_child_nodes(child))
+ return found
def _is_empty_init(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
@@ -336,8 +552,8 @@ def _is_empty_init(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
bool: True if the __init__ is empty.
"""
- real_args = [a for a in node.args.args if a.arg != "self"]
- if real_args or node.args.kwonlyargs:
+ real_args = (node.args.posonlyargs + node.args.args)[1:]
+ if real_args or node.args.kwonlyargs or node.args.vararg or node.args.kwarg:
return False
for stmt in node.body:
diff --git a/src/linter/cli.py b/src/linter/cli.py
index d33ff3d..98e0765 100644
--- a/src/linter/cli.py
+++ b/src/linter/cli.py
@@ -5,49 +5,67 @@
"""
import argparse
+import itertools
import os
import sys
-from concurrent.futures import ProcessPoolExecutor, as_completed
+import traceback
+from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
-from linter.ast_parser import parse_file
-from linter.config import ALWAYS_ON, OFF_BY_DEFAULT, OPTIONS_REGISTRY, POLICIES_REGISTRY, RULES_CATEGORIES, RULES_REGISTRY, DocstringStyle, LinterConfig, load_config
-from linter.docstring_parser import get_parser
+from linter.ast_parser import IgnoreCommentError, parse_file
+from linter.config import ALWAYS_ON, CONVENTIONS, OPTIONS_REGISTRY, POLICIES_REGISTRY, RULES_CATEGORIES, RULES_REGISTRY, LinterConfig, load_config, path_matches
+from linter.docstring_parser import GoogleStyleParser
from linter.models import LintError, NodeType
-from linter.reporter import report_cli, report_github_annotations, report_json, report_options, report_overrides, report_policies, report_rules, report_traceback
+from linter.reporter import report_cli, report_github_annotations, report_json, report_options, report_overrides, report_policies, report_rules, report_statistics, report_traceback
from linter.rules import validate_entity
-def collect_python_files(paths: list[str], exclude_patterns: list[str]) -> list[str]:
+def collect_python_files(paths: list[str], exclude_patterns: list[str], base_dir: Path, *, force_exclude: bool = False) -> list[str]:
"""Collect all .py files from given paths, respecting exclusions.
+ A file given explicitly is always collected, unless force_exclude is set.
+ Exclusions always apply to the files found in a directory.
+
Args:
paths (list[str]): File or directory paths to scan.
exclude_patterns (list[str]): Glob patterns to exclude.
+ base_dir (Path): Absolute directory the patterns are relative to.
+ force_exclude (bool): Apply the exclusions to explicitly given files too.
Returns:
list[str]: Sorted list of Python file paths.
"""
files: list[str] = []
+ # a pattern without wildcard excludes that name at any depth: such directories are not walked at all
+ skipped_dirs = {pattern for pattern in exclude_patterns if "*" not in pattern and "?" not in pattern}
for path_str in paths:
path = Path(path_str)
if path.is_file() and path.suffix == ".py":
- if not _is_excluded(path, exclude_patterns):
+ if not (force_exclude and _is_excluded(path, exclude_patterns, base_dir)):
files.append(str(path))
elif path.is_dir():
- files.extend(sorted(str(py_file) for py_file in path.rglob("*.py") if not _is_excluded(py_file, exclude_patterns)))
+ found: list[Path] = []
+ for dirpath, dirnames, filenames in path.walk():
+ dirnames[:] = [name for name in dirnames if name not in skipped_dirs]
+ found.extend(dirpath / name for name in filenames if name.endswith(".py"))
+ files.extend(sorted(str(py_file) for py_file in found if not _is_excluded(py_file, exclude_patterns, base_dir)))
return files
-def _is_excluded(path: Path, patterns: list[str]) -> bool:
+def _is_excluded(path: Path, patterns: list[str], base_dir: Path) -> bool:
"""Check if a file path matches any exclusion pattern.
+ A pattern matches the end of the path (test_*.py), the whole path
+ relative to the base directory (tests/**), or, when it carries no
+ wildcard, any directory or file name along the path (.venv).
+
Args:
path (Path): File path to check.
patterns (list[str]): Glob patterns to match against.
+ base_dir (Path): Absolute directory the whole-path patterns are relative to.
Returns:
bool: True if the path matches any exclusion pattern.
@@ -59,7 +77,7 @@ def _is_excluded(path: Path, patterns: list[str]) -> bool:
# literal patterns (no glob chars) are also matched against directory parts
if "*" not in pattern and "?" not in pattern and pattern in path.parts:
return True
- return False
+ return path_matches(str(path), patterns, base_dir)
def lint_file(filepath: str, config: LinterConfig) -> list[LintError]:
@@ -74,7 +92,7 @@ def lint_file(filepath: str, config: LinterConfig) -> list[LintError]:
"""
config = config.for_path(filepath)
- parser = get_parser(config.style)
+ parser = GoogleStyleParser()
entities = parse_file(filepath)
errors: list[LintError] = []
@@ -92,8 +110,9 @@ def lint_file(filepath: str, config: LinterConfig) -> list[LintError]:
if entity.docstring:
parsed_doc = parser.parse(entity.docstring)
- entity_errors = validate_entity(entity, parsed_doc, config)
- errors.extend(entity_errors)
+ if entity.ignore_all:
+ continue
+ errors.extend(error for error in validate_entity(entity, parsed_doc, config) if error.rule not in entity.ignored_rules)
return errors
@@ -109,9 +128,6 @@ def merge_cli_into_config(config: LinterConfig, args: argparse.Namespace) -> Lin
LinterConfig: Updated configuration object.
"""
- if args.style:
- config.style = DocstringStyle(args.style)
-
if args.exclude is not None:
config.exclude_patterns = args.exclude
@@ -124,84 +140,120 @@ def merge_cli_into_config(config: LinterConfig, args: argparse.Namespace) -> Lin
return config
-def _lint_file_safe(filepath: str, config: LinterConfig) -> tuple[str, list[LintError], str | None]:
- """Lint a file and catch errors for safe parallel execution.
+def _lint_file_safe(filepath: str, config: LinterConfig) -> tuple[list[LintError], str | None, int]:
+ """Lint a file, turning any failure into a message so the run goes on.
Args:
filepath (str): Path to the Python file.
config (LinterConfig): Linter configuration.
Returns:
- tuple[str, list[LintError], str | None]: Filepath, errors, and optional error message.
+ tuple[list[LintError], str | None, int]: Errors, the failure message if the file could not be
+ analysed, and the exit code it calls for: 0, 2 for an unreadable or unparsable file, 3 for an internal error.
"""
try:
- return filepath, lint_file(filepath, config), None
+ return lint_file(filepath, config), None, 0
except SyntaxError as e:
- return filepath, [], f"Syntax error in {filepath}: {e}"
- except ValueError as e:
- return filepath, [], f"Configuration error for {filepath}: {e}"
+ return [], f"Syntax error in {filepath}: {e}", 2
+ except IgnoreCommentError as e:
+ return [], str(e), 2
+ except (UnicodeDecodeError, OSError) as e:
+ return [], f"Cannot read {filepath}: {e}", 2
+ except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
+ # a bug of the linter, or a file Python cannot compile for another reason: name the file, keep the traceback
+ return [], f"Internal error while linting {filepath}: {type(e).__name__}: {e}\n{traceback.format_exc()}", 3
-def _resolve_workers(workers: int) -> int:
- """Resolve worker count (0 = auto-detect CPU count).
+# Below this many files, auto mode stays sequential: starting the processes costs more than it saves
+_AUTO_PARALLEL_MIN_FILES = 50
+
+
+def _resolve_workers(workers: int, file_count: int) -> int:
+ """Resolve worker count (0 = auto: one per usable CPU, sequential on small runs).
Args:
workers (int): Configured worker count.
+ file_count (int): Number of files to lint.
Returns:
int: Resolved worker count.
"""
if workers == 0:
- return os.cpu_count() or 1
+ if file_count < _AUTO_PARALLEL_MIN_FILES:
+ return 1
+ return os.process_cpu_count() or 1
return workers
-def run(paths: list[str], config: LinterConfig) -> int:
+def _report(errors: list[LintError], files_checked: int, output_format: str, *, statistics: bool) -> None:
+ """Print the lint results in the requested format.
+
+ Args:
+ errors (list[LintError]): Lint errors found.
+ files_checked (int): Total number of files checked.
+ output_format (str): Output format name.
+ statistics (bool): Report the number of errors per rule instead of each error.
+
+ Returns:
+ None
+
+ """
+ if statistics:
+ report_statistics(errors, files_checked)
+ elif output_format == "json":
+ report_json(errors, files_checked)
+ elif output_format == "github-annotations":
+ report_github_annotations(errors, files_checked)
+ elif output_format == "text":
+ report_cli(errors, files_checked)
+ else:
+ report_traceback(errors, files_checked)
+
+
+def run(paths: list[str], config: LinterConfig, *, statistics: bool = False, force_exclude: bool = False) -> int:
"""Collect files, lint them, and report results.
Args:
paths (list[str]): File or directory paths to lint.
config (LinterConfig): Linter configuration.
+ statistics (bool): Report the number of errors per rule instead of each error.
+ force_exclude (bool): Apply the exclusions to explicitly given files too.
Returns:
- int: Exit code -- 0 if no errors, 1 otherwise.
+ int: Exit code -- 0 if no errors, 1 on lint errors, 2 if a path is missing or a file could not be analysed, 3 on an internal error.
"""
- files = collect_python_files(paths, config.exclude_patterns)
+ missing = [path for path in paths if not Path(path).exists()]
+ if missing:
+ for path in missing:
+ print(f"Path not found: {path}", file=sys.stderr)
+ return 2
+
+ files = collect_python_files(paths, config.exclude_patterns, config.base_dir, force_exclude=force_exclude)
if not files:
print("No Python files found.")
return 0
- all_errors: list[LintError] = []
- workers = _resolve_workers(config.workers)
+ workers = _resolve_workers(config.workers, len(files))
if workers <= 1 or len(files) == 1:
- for filepath in files:
- _, errors, err_msg = _lint_file_safe(filepath, config)
- if err_msg:
- print(err_msg)
- all_errors.extend(errors)
+ results = [_lint_file_safe(filepath, config) for filepath in files]
else:
with ProcessPoolExecutor(max_workers=workers) as pool:
- futures = {pool.submit(_lint_file_safe, fp, config): fp for fp in files}
- for future in as_completed(futures):
- _, errors, err_msg = future.result()
- if err_msg:
- print(err_msg)
- all_errors.extend(errors)
-
- if config.output_format == "json":
- report_json(all_errors, len(files))
- elif config.output_format == "github-annotations":
- report_github_annotations(all_errors, len(files))
- elif config.output_format == "text":
- report_cli(all_errors, len(files))
- else:
- report_traceback(all_errors, len(files))
+ results = list(pool.map(_lint_file_safe, files, itertools.repeat(config)))
+
+ for _, message, _ in results:
+ if message:
+ print(message, file=sys.stderr)
+
+ all_errors = [error for errors, _, _ in results for error in errors]
- return 1 if all_errors else 0
+ _report(all_errors, len(files), config.output_format, statistics=statistics)
+
+ # the most serious outcome wins: internal error 3, unreadable file 2, lint errors 1
+ return max([code for _, _, code in results] + [1 if all_errors else 0])
def _build_arg_parser() -> argparse.ArgumentParser:
@@ -218,18 +270,19 @@ def _build_arg_parser() -> argparse.ArgumentParser:
parser.add_argument("paths", nargs="*", help="Files or directories to lint.")
parser.add_argument("--list-rules", action="store_true", help="List all available rules and exit.")
parser.add_argument("--config", default=None, help="Path to pyproject.toml (default: auto-detect).")
- parser.add_argument("--style", choices=[s.value for s in DocstringStyle], default=None, help=argparse.SUPPRESS)
parser.add_argument("--format", choices=["traceback", "text", "json", "github-annotations"], default=None, help="Output format (default: traceback).")
parser.add_argument("--exclude", nargs="*", default=None, help="Glob patterns to exclude (overrides pyproject.toml).")
- parser.add_argument("--workers", type=int, default=None, help="Number of parallel workers (0 = auto, 1 = sequential). Overrides pyproject.toml.")
+ parser.add_argument("--force-exclude", action="store_true", help="Apply the exclusions to files given explicitly too, as pre-commit does.")
+ parser.add_argument("--statistics", action="store_true", help="Report the number of errors per rule instead of each error (traceback and text formats).")
+ parser.add_argument("--workers", type=int, default=None, help="Number of parallel workers (0 = auto, the default; 1 = sequential). Overrides pyproject.toml.")
return parser
-def main() -> None:
+def _main() -> int:
"""Parse CLI arguments, load config, and delegate to run().
Returns:
- None
+ int: Exit code.
"""
parser = _build_arg_parser()
@@ -240,25 +293,49 @@ def main() -> None:
config = merge_cli_into_config(config, args)
except ValueError as e:
print(f"Configuration error: {e}", file=sys.stderr)
- sys.exit(2)
+ return 2
if args.list_rules:
- report_rules(RULES_CATEGORIES, RULES_REGISTRY, OFF_BY_DEFAULT, ALWAYS_ON, frozenset(config.enabled_rules))
+ report_rules(RULES_CATEGORIES, RULES_REGISTRY, CONVENTIONS[config.convention].disabled_rules, ALWAYS_ON, frozenset(config.enabled_rules))
report_policies(POLICIES_REGISTRY, config.policy_values())
report_options(OPTIONS_REGISTRY, config.option_values())
report_overrides(config.overrides, config.policy_values() | config.option_values())
- sys.exit(0)
+ return 0
if not args.paths:
parser.error("the following arguments are required: paths")
+ if args.statistics and config.output_format not in ("text", "traceback"):
+ print(f"--statistics is not available with the {config.output_format} format.", file=sys.stderr)
+ return 2
+
+ # on stderr, so that a redirected report only holds the report
if config.output_format in ("text", "traceback"):
if config_file is not None:
- print(f"Config: {config_file}")
+ print(f"Config: {config_file}", file=sys.stderr)
else:
- print("Config: defaults (no config file found)")
+ print("Config: defaults (no config file found)", file=sys.stderr)
+
+ return run(args.paths, config, statistics=args.statistics, force_exclude=args.force_exclude)
- sys.exit(run(args.paths, config))
+
+def main() -> None:
+ """Run the CLI and exit with its code, quietly if the output pipe closes early.
+
+ Returns:
+ None
+
+ """
+ try:
+ code = _main()
+ # flush here so that a closed pipe raises inside the try block
+ sys.stdout.flush()
+ except BrokenPipeError:
+ # Python flushes stdout again at exit: point it to devnull to avoid a second error
+ devnull = os.open(os.devnull, os.O_WRONLY)
+ os.dup2(devnull, sys.stdout.fileno())
+ sys.exit(1)
+ sys.exit(code)
if __name__ == "__main__":
diff --git a/src/linter/config.py b/src/linter/config.py
index 837ca82..69c47c5 100644
--- a/src/linter/config.py
+++ b/src/linter/config.py
@@ -12,23 +12,6 @@
from typing import cast
-class DocstringStyle(Enum):
- """Enumerate supported docstring styles.
-
- Attributes:
- GOOGLE (str): Google style docstrings.
- NUMPY (str): NumPy style docstrings.
- SPHINX (str): Sphinx/RST style docstrings.
- PEP257 (str): PEP 257 generic style.
-
- """
-
- GOOGLE = "google"
- NUMPY = "numpy"
- SPHINX = "sphinx"
- PEP257 = "pep257"
-
-
class Policy(Enum):
"""Enumerate the directions a style policy can take.
@@ -56,23 +39,31 @@ class Policy(Enum):
"raises_section": "Raises section documenting every exception raised",
"attributes_section": "Attributes section documenting every class attribute",
"description_section": "Description paragraph below the summary",
- "examples_section": "Example section",
+ "examples_section": "Examples section",
"notes_section": "Note section",
"todo_section": "Todo section",
- "documented_types": "Type between parentheses in Args and Attributes entries",
+ "documented_types": "Type in Args and Attributes entries and on the Returns and Yields lines",
+ "documented_stars": "Stars of *args and **kwargs in their Args entries",
"returns_descriptions": "Description on the Returns and Yields lines",
}
# Settings that change what gets checked, reported by --list-rules
OPTIONS_REGISTRY = {
- "style": "Docstring style enforced",
+ "convention": "Convention providing the defaults of policies, options and rules",
"exclude_empty_init_method": "Docstring optional on __init__ methods with no parameter and an empty body",
"exclude_empty_init_module": "Docstring optional on __init__.py modules with an empty body",
"ignore_placeholder_docstrings": "Skip docstrings containing only '...'",
+ "exclude_dunder_methods": "Docstring optional on magic methods other than __init__",
+ "exclude_private": "Docstring optional on private names and on members of private classes",
+ "exclude_overridden": "Docstring optional on methods decorated with @override",
+ "properties_as_attributes": "Property getters documented like attributes, setters and deleters not checked",
+ "sections_optional_on_one_liners": "One-line docstring on a fully annotated function or method needs no section",
"summary_max_length": "Maximum summary line length for summary_too_long",
"blank_lines_before_section": "Blank lines expected before a section header",
"blank_lines_before_closing_quotes": "Blank lines expected before the closing triple quotes",
+ "type_matching": "How closely a documented type must match the signature",
+ "init_args_location": "Docstring documenting the __init__ parameters: init, class or either",
"scope.modules": "Check module docstrings",
"scope.classes": "Check class docstrings",
"scope.functions": "Check function docstrings",
@@ -92,6 +83,7 @@ class Policy(Enum):
],
"Sections": [
"section_capitalization",
+ "section_alias",
"section_order",
"unknown_section",
"empty_section",
@@ -107,6 +99,7 @@ class Policy(Enum):
"returns_match",
"yields_match",
"raises_match",
+ "raises_extraneous",
"attributes_match",
],
}
@@ -121,12 +114,14 @@ class Policy(Enum):
"args_order": "Args section must follow the same order as the function signature",
"returns_match": "Returns section must match the signature type and carry a description",
"yields_match": "Yields section must declare a type and a description",
- "raises_match": "Documented exceptions must be raised in the code and described",
+ "raises_match": "Documented exceptions must carry a description",
+ "raises_extraneous": "Documented exceptions must be raised explicitly in the body",
"attributes_match": "Documented attributes must match the class (type, description, no phantom)",
- "indentation": "Indentation must be consistent",
+ "indentation": "Section content must be indented by 4 spaces or more, the first entry of Args, Attributes and Raises by exactly 4",
"summary_too_long": "Summary line must not exceed the configured maximum length",
"section_capitalization": "Section names must be capitalized (Args, not args)",
- "section_order": "Sections must follow order: Args, Returns, Yields, Raises, Example(s), Note(s)",
+ "section_alias": "Section names must use the canonical spelling (Args, not Parameters)",
+ "section_order": "Sections must follow order: Attributes, Args, Keyword Args, Other Parameters, Returns, Yields, Raises, Examples, Note(s), Todo",
"unknown_section": "Section name is not recognized (e.g. 'Arguments:' instead of 'Args:')",
"empty_section": "Section must not be empty",
"imperative_mood": "Summary should start with imperative verb (e.g. 'Process' not 'Processes')",
@@ -135,8 +130,6 @@ class Policy(Enum):
"blank_lines": "Blank line counts must match blank_lines_before_section and blank_lines_before_closing_quotes",
}
-# Rules disabled by default; users opt in via pyproject.toml or --select
-OFF_BY_DEFAULT: frozenset[str] = frozenset()
# Rules that report an outright docstring defect; select / ignore do not apply to them
ALWAYS_ON: frozenset[str] = frozenset(
@@ -156,41 +149,107 @@ class Policy(Enum):
)
-# Keys accepted in the config file besides the policies
-SETTING_KEYS: frozenset[str] = frozenset(
+@dataclass(frozen=True)
+class Convention:
+ """Hold the defaults a convention sets before the config file keys apply.
+
+ Attributes:
+ values (dict[str, object]): Policies and options set by the convention.
+ disabled_rules (frozenset[str]): Configurable rules the convention turns off.
+
+ """
+
+ values: dict[str, object]
+ disabled_rules: frozenset[str]
+
+
+CONVENTIONS: dict[str, Convention] = {
+ # Built-in defaults: every section and type documented, house layout
+ "strict": Convention(values={}, disabled_rules=frozenset()),
+ # Google Python Style Guide: types live in the signature, no 'Returns: None',
+ # no blank line before the closing quotes, descriptive or imperative summary
+ "google": Convention(
+ values={
+ "returns_none": Policy.OPTIONAL,
+ "init_returns_none": Policy.OPTIONAL,
+ "documented_types": Policy.OPTIONAL,
+ "raises_section": Policy.OPTIONAL,
+ "attributes_section": Policy.OPTIONAL,
+ "blank_lines_before_closing_quotes": 0,
+ "type_matching": "lenient",
+ "exclude_dunder_methods": True,
+ "exclude_private": True,
+ "exclude_overridden": True,
+ "properties_as_attributes": True,
+ "init_args_location": "either",
+ "sections_optional_on_one_liners": True,
+ },
+ disabled_rules=frozenset({"imperative_mood", "return_type_annotation", "raises_extraneous"}),
+ ),
+}
+
+
+# Options taking one value among a fixed list
+CHOICE_OPTIONS: dict[str, tuple[str, ...]] = {
+ "type_matching": ("strict", "equivalent", "lenient"),
+ "init_args_location": ("init", "class", "either"),
+}
+
+# Integer options with the minimum value they are clamped to
+INT_OPTIONS: dict[str, int] = {
+ "workers": 0,
+ "summary_max_length": 1,
+ "blank_lines_before_section": 0,
+ "blank_lines_before_closing_quotes": 0,
+}
+
+# Boolean options
+BOOL_OPTIONS: frozenset[str] = frozenset(
{
- "style",
- "scope",
- "select",
- "ignore",
- "exclude",
- "workers",
- "overrides",
"exclude_empty_init_method",
"exclude_empty_init_module",
"ignore_placeholder_docstrings",
- "summary_max_length",
- "blank_lines_before_section",
- "blank_lines_before_closing_quotes",
+ "exclude_dunder_methods",
+ "exclude_private",
+ "exclude_overridden",
+ "properties_as_attributes",
+ "sections_optional_on_one_liners",
}
)
+# Every option with a typed value, validated by _parse_option
+TYPED_OPTIONS: frozenset[str] = frozenset(INT_OPTIONS) | BOOL_OPTIONS | frozenset(CHOICE_OPTIONS)
+
+# Options an override may carry: the typed options that change what gets checked on a file
+OVERRIDABLE_OPTIONS: frozenset[str] = TYPED_OPTIONS - {"workers"}
+
+# Keys accepted in the config file besides the policies
+SETTING_KEYS: frozenset[str] = TYPED_OPTIONS | {"convention", "scope", "select", "ignore", "exclude", "overrides"}
+
CONFIG_KEYS: frozenset[str] = SETTING_KEYS | frozenset(POLICIES_REGISTRY)
SCOPE_KEYS: frozenset[str] = frozenset({"modules", "classes", "functions", "methods"})
-# Options an override may carry: those that change what gets checked on a file
-OVERRIDABLE_OPTIONS: frozenset[str] = frozenset(
- {
- "summary_max_length",
- "blank_lines_before_section",
- "blank_lines_before_closing_quotes",
- "exclude_empty_init_method",
- "exclude_empty_init_module",
- "ignore_placeholder_docstrings",
- }
-)
+def path_matches(filepath: str, patterns: list[str], base_dir: Path) -> bool:
+ """Check whether a file path fully matches one of the glob patterns.
+
+ The path is matched relative to the base directory.
+
+ Args:
+ filepath (str): Path of the file.
+ patterns (list[str]): Glob patterns matched with PurePath.full_match.
+ base_dir (Path): Absolute directory the patterns are relative to.
+
+ Returns:
+ bool: True if one of the patterns matches, False for a file outside the base directory.
+
+ """
+ absolute = Path(filepath).resolve()
+ if not absolute.is_relative_to(base_dir):
+ return False
+ relative = PurePath(absolute.relative_to(base_dir))
+ return any(relative.full_match(pattern) for pattern in patterns)
@dataclass
@@ -210,25 +269,18 @@ class ConfigOverride:
ignore: list[str] = field(default_factory=lambda: []) # noqa: PIE807
values: dict[str, object] = field(default_factory=lambda: {}) # noqa: PIE807
- def matches(self, filepath: str) -> bool:
+ def matches(self, filepath: str, base_dir: Path) -> bool:
"""Check whether a file path matches one of the patterns.
- The path is matched as given, then relative to the current directory,
- so that an absolute path on the command line behaves like a relative one.
-
Args:
filepath (str): Path of the file being linted.
+ base_dir (Path): Absolute directory the patterns are relative to.
Returns:
bool: True if the override applies to that file.
"""
- candidates = [PurePath(filepath)]
- absolute = Path(filepath).resolve()
- if absolute.is_relative_to(Path.cwd()):
- candidates.append(PurePath(absolute.relative_to(Path.cwd())))
-
- return any(candidate.full_match(pattern) for pattern in self.paths for candidate in candidates)
+ return path_matches(filepath, self.paths, base_dir)
@dataclass
@@ -239,7 +291,7 @@ class LinterConfig: # pylint: disable=too-many-instance-attributes
and what to exclude from validation.
Attributes:
- style (DocstringStyle): Docstring style to enforce.
+ convention (str): Convention the defaults come from, a key of CONVENTIONS.
check_modules (bool): Whether to check module docstrings.
check_classes (bool): Whether to check class docstrings.
check_functions (bool): Whether to check function docstrings.
@@ -247,13 +299,20 @@ class LinterConfig: # pylint: disable=too-many-instance-attributes
exclude_empty_init_method (bool): Whether a docstring is optional on empty __init__ methods.
exclude_empty_init_module (bool): Whether a docstring is optional on empty __init__.py modules.
ignore_placeholder_docstrings (bool): Skip placeholder docstrings like \"\"\"...\"\"\".
+ exclude_dunder_methods (bool): Whether a docstring is optional on magic methods other than __init__.
+ exclude_private (bool): Whether a docstring is optional on private names and members of private classes.
+ exclude_overridden (bool): Whether a docstring is optional on methods decorated with @override.
+ properties_as_attributes (bool): Whether property getters are documented like attributes.
+ sections_optional_on_one_liners (bool): Whether a one-line docstring on a fully annotated function needs no section.
exclude_patterns (list[str]): Glob patterns for files to exclude.
enabled_rules (list[str]): List of enabled rule identifiers.
output_format (str): Output format -- traceback, text, json, or github-annotations.
- workers (int): Number of parallel workers (1 = sequential).
+ workers (int): Number of parallel workers (0 = auto, 1 = sequential).
summary_max_length (int): Maximum allowed summary line length.
blank_lines_before_section (int): Blank lines expected before a section header.
blank_lines_before_closing_quotes (int): Blank lines expected before the closing quotes.
+ type_matching (str): How closely a documented type must match the signature.
+ init_args_location (str): Docstring documenting the __init__ parameters: init, class or either.
returns_none (Policy): Policy for 'Returns: None' on -> None functions.
init_returns_none (Policy): Policy for 'Returns: None' on __init__ methods.
summary_on_first_line (Policy): Policy for the summary on the opening quotes line.
@@ -264,16 +323,18 @@ class LinterConfig: # pylint: disable=too-many-instance-attributes
raises_section (Policy): Policy for the presence of the Raises section.
attributes_section (Policy): Policy for the presence of the Attributes section.
description_section (Policy): Policy for the presence of the description paragraph.
- examples_section (Policy): Policy for the presence of the Example section.
+ examples_section (Policy): Policy for the presence of the Examples section.
notes_section (Policy): Policy for the presence of the Note section.
todo_section (Policy): Policy for the presence of the Todo section.
- documented_types (Policy): Policy for the type in Args and Attributes entries.
+ documented_types (Policy): Policy for the type in Args, Attributes, Returns and Yields.
+ documented_stars (Policy): Policy for the stars of *args and **kwargs entries.
returns_descriptions (Policy): Policy for the description on the Returns and Yields lines.
overrides (list[ConfigOverride]): Per-path settings applied in declaration order.
+ base_dir (Path): Absolute directory the path patterns are relative to: the config file directory, or the current directory.
"""
- style: DocstringStyle = DocstringStyle.GOOGLE
+ convention: str = "strict"
check_modules: bool = True
check_classes: bool = True
check_functions: bool = True
@@ -281,13 +342,20 @@ class LinterConfig: # pylint: disable=too-many-instance-attributes
exclude_empty_init_method: bool = True
exclude_empty_init_module: bool = True
ignore_placeholder_docstrings: bool = False
+ exclude_dunder_methods: bool = False
+ exclude_private: bool = False
+ exclude_overridden: bool = False
+ properties_as_attributes: bool = False
+ sections_optional_on_one_liners: bool = False
exclude_patterns: list[str] = field(default_factory=lambda: [".venv", ".git", "__pycache__", ".tox", ".mypy_cache", ".ruff_cache", ".pytest_cache"])
- enabled_rules: list[str] = field(default_factory=lambda: [r for r in RULES_REGISTRY if r not in OFF_BY_DEFAULT])
+ enabled_rules: list[str] = field(default_factory=lambda: list(RULES_REGISTRY))
output_format: str = "traceback"
- workers: int = 1
+ workers: int = 0
summary_max_length: int = 80
blank_lines_before_section: int = 1
blank_lines_before_closing_quotes: int = 1
+ type_matching: str = "strict"
+ init_args_location: str = "init"
returns_none: Policy = Policy.REQUIRED
init_returns_none: Policy = Policy.FORBIDDEN
summary_on_first_line: Policy = Policy.REQUIRED
@@ -302,8 +370,10 @@ class LinterConfig: # pylint: disable=too-many-instance-attributes
notes_section: Policy = Policy.OPTIONAL
todo_section: Policy = Policy.OPTIONAL
documented_types: Policy = Policy.REQUIRED
+ documented_stars: Policy = Policy.REQUIRED
returns_descriptions: Policy = Policy.REQUIRED
overrides: list[ConfigOverride] = field(default_factory=lambda: []) # noqa: PIE807
+ base_dir: Path = field(default_factory=lambda: Path.cwd().resolve())
def for_path(self, filepath: str) -> LinterConfig:
"""Return the config applying to a file, overrides included.
@@ -318,19 +388,17 @@ def for_path(self, filepath: str) -> LinterConfig:
LinterConfig: This config when no override matches, a resolved copy otherwise.
"""
- matching = [override for override in self.overrides if override.matches(filepath)]
+ matching = [override for override in self.overrides if override.matches(filepath, self.base_dir)]
if not matching:
return self
override = matching[-1]
resolved = copy.copy(self)
- enabled = set(self.enabled_rules) if override.select is None else {rule for rule in override.select if rule in RULES_REGISTRY}
- enabled -= {rule for rule in override.ignore if rule in RULES_REGISTRY}
for name, value in override.values.items():
setattr(resolved, name, value)
- resolved.enabled_rules = sorted(enabled)
+ resolved.enabled_rules = _resolve_rules(override.select, override.ignore, self.enabled_rules)
return resolved
def policy_values(self) -> dict[str, str]:
@@ -349,19 +417,12 @@ def option_values(self) -> dict[str, str]:
dict[str, str]: Option identifier to its configured value.
"""
- return {
- "style": self.style.value,
- "exclude_empty_init_method": str(self.exclude_empty_init_method).lower(),
- "exclude_empty_init_module": str(self.exclude_empty_init_module).lower(),
- "ignore_placeholder_docstrings": str(self.ignore_placeholder_docstrings).lower(),
- "summary_max_length": str(self.summary_max_length),
- "blank_lines_before_section": str(self.blank_lines_before_section),
- "blank_lines_before_closing_quotes": str(self.blank_lines_before_closing_quotes),
- "scope.modules": str(self.check_modules).lower(),
- "scope.classes": str(self.check_classes).lower(),
- "scope.functions": str(self.check_functions).lower(),
- "scope.methods": str(self.check_methods).lower(),
- }
+ values: dict[str, str] = {}
+ for key in OPTIONS_REGISTRY:
+ # scope.modules is stored as check_modules
+ value = getattr(self, f"check_{key.removeprefix('scope.')}" if key.startswith("scope.") else key)
+ values[key] = str(value).lower() if isinstance(value, bool) else str(value)
+ return values
def is_rule_enabled(self, rule: str) -> bool:
"""Check if a specific rule is enabled.
@@ -391,6 +452,9 @@ def load_config(config_path: str | None = None) -> tuple[LinterConfig, Path | No
Returns:
tuple[LinterConfig, Path | None]: Parsed config and the config file path, or None.
+ Raises:
+ ValueError: If an explicit pyproject.toml has no [tool.docstring-linter] section.
+
"""
toml_path = _find_config(config_path)
if toml_path is None:
@@ -399,14 +463,16 @@ def load_config(config_path: str | None = None) -> tuple[LinterConfig, Path | No
with toml_path.open("rb") as f:
data = tomllib.load(f)
- if toml_path.name != "pyproject.toml":
- return _parse_toml_config(data), toml_path
-
- tool_config = data.get("tool", {}).get("docstring-linter", {})
- if not tool_config:
- return LinterConfig(), None
+ if toml_path.name == "pyproject.toml":
+ data = data.get("tool", {}).get("docstring-linter", {})
+ if not data:
+ # discovery only returns a pyproject.toml carrying the section, so this is an explicit path
+ msg = f"{toml_path}: no [tool.docstring-linter] section."
+ raise ValueError(msg)
- return _parse_toml_config(tool_config), toml_path
+ config = _parse_toml_config(data)
+ config.base_dir = toml_path.parent.resolve()
+ return config, toml_path
def _find_config(explicit_path: str | None = None) -> Path | None:
@@ -421,12 +487,16 @@ def _find_config(explicit_path: str | None = None) -> Path | None:
Returns:
Path | None: Path to config file, or None if not found.
+ Raises:
+ ValueError: If the explicit path is not an existing file.
+
"""
if explicit_path:
path = Path(explicit_path)
- if path.exists():
- return path
- return None
+ if not path.is_file():
+ msg = f"config file not found: {explicit_path}"
+ raise ValueError(msg)
+ return path
current = Path.cwd()
for directory in [current, *current.parents]:
@@ -488,24 +558,118 @@ def _parse_policy(key: str, value: object) -> Policy:
raise ValueError(msg) from None
-def _parse_style(value: object) -> DocstringStyle:
- """Convert a configured value into a DocstringStyle.
+def _parse_convention(value: object) -> str:
+ """Check that a configured value names a convention.
Args:
value (object): Value read from the config file.
Returns:
- DocstringStyle: Matching style.
+ str: The convention name.
Raises:
- ValueError: If the value is not a style name.
+ ValueError: If the value is not a convention name.
"""
- try:
- return DocstringStyle(value)
- except ValueError:
- msg = f"'style': invalid value {value!r}, expected one of {', '.join(style.value for style in DocstringStyle)}."
- raise ValueError(msg) from None
+ if isinstance(value, str) and value in CONVENTIONS:
+ return value
+ msg = f"'convention': invalid value {value!r}, expected one of {', '.join(CONVENTIONS)}."
+ raise ValueError(msg)
+
+
+def _parse_str_list(key: str, value: object, location: str = "") -> list[str]:
+ """Check that a configured value is a list of strings.
+
+ Args:
+ key (str): Setting identifier, used in the error message.
+ value (object): Value read from the config file.
+ location (str): Config section carrying it, empty for the top level.
+
+ Returns:
+ list[str]: The value, unchanged.
+
+ Raises:
+ ValueError: If the value is not a list of strings.
+
+ """
+ if isinstance(value, list) and all(isinstance(item, str) for item in cast("list[object]", value)):
+ return cast("list[str]", value)
+ msg = f"{location}'{key}': expected a list of strings, got {value!r}."
+ raise ValueError(msg)
+
+
+def _parse_bool(key: str, value: object, location: str = "") -> bool:
+ """Check that a configured value is a boolean.
+
+ Args:
+ key (str): Setting identifier, used in the error message.
+ value (object): Value read from the config file.
+ location (str): Config section carrying it, empty for the top level.
+
+ Returns:
+ bool: The value, unchanged.
+
+ Raises:
+ ValueError: If the value is not a boolean.
+
+ """
+ if isinstance(value, bool):
+ return value
+ msg = f"{location}'{key}': expected true or false, got {value!r}."
+ raise ValueError(msg)
+
+
+def _parse_option(key: str, value: object, location: str = "") -> int | bool | str:
+ """Check an integer, boolean or choice option, clamping integers to their minimum.
+
+ Args:
+ key (str): Option identifier, a key of INT_OPTIONS, BOOL_OPTIONS or CHOICE_OPTIONS.
+ value (object): Value read from the config file.
+ location (str): Config section carrying it, empty for the top level.
+
+ Returns:
+ int | bool | str: The boolean or choice as is, or the integer clamped to its minimum.
+
+ Raises:
+ ValueError: If the value does not have the expected type or is not an accepted choice.
+
+ """
+ if key in BOOL_OPTIONS:
+ return _parse_bool(key, value, location)
+
+ if key in CHOICE_OPTIONS:
+ if isinstance(value, str) and value in CHOICE_OPTIONS[key]:
+ return value
+ msg = f"{location}'{key}': invalid value {value!r}, expected one of {', '.join(CHOICE_OPTIONS[key])}."
+ raise ValueError(msg)
+
+ # bool is a subclass of int, reject it explicitly
+ if isinstance(value, int) and not isinstance(value, bool):
+ return max(INT_OPTIONS[key], value)
+ msg = f"{location}'{key}': expected an integer, got {value!r}."
+ raise ValueError(msg)
+
+
+def _resolve_rules(select: list[str] | None, ignore: list[str], inherited: list[str]) -> list[str]:
+ """Compute the enabled rules from select, ignore and the inherited set.
+
+ Args:
+ select (list[str] | None): Rules replacing the inherited set, ['ALL'] for every rule, None to keep it.
+ ignore (list[str]): Rules removed afterwards.
+ inherited (list[str]): Rules enabled before select and ignore apply.
+
+ Returns:
+ list[str]: Enabled rules, sorted.
+
+ """
+ if select is None:
+ enabled = set(inherited)
+ elif select == ["ALL"]:
+ enabled = set(RULES_REGISTRY)
+ else:
+ enabled = {rule for rule in select if rule in RULES_REGISTRY}
+ enabled -= set(ignore)
+ return sorted(enabled)
def _validate_rules(select: list[str], ignore: list[str], location: str = "") -> None:
@@ -546,17 +710,18 @@ def _parse_override(data: dict[str, object]) -> ConfigOverride:
ValueError: If paths is missing or a key is not allowed in an override.
"""
- paths = cast("list[str]", data.get("paths", []))
+ paths = _parse_str_list("paths", data.get("paths", []), "override: ")
if not paths:
msg = "an override must declare a non-empty 'paths' list."
raise ValueError(msg)
- override = ConfigOverride(paths=paths, ignore=cast("list[str]", data.get("ignore", [])))
+ location = f"override {paths}: "
+ override = ConfigOverride(paths=paths, ignore=_parse_str_list("ignore", data.get("ignore", []), location))
if "select" in data:
- override.select = cast("list[str]", data["select"])
+ override.select = _parse_str_list("select", data["select"], location)
- _validate_rules(override.select or [], override.ignore, f"override {paths}: ")
+ _validate_rules(override.select or [], override.ignore, location)
for key, value in data.items():
if key in {"paths", "select", "ignore"}:
@@ -564,7 +729,7 @@ def _parse_override(data: dict[str, object]) -> ConfigOverride:
if key in POLICIES_REGISTRY:
override.values[key] = _parse_policy(key, value)
elif key in OVERRIDABLE_OPTIONS:
- override.values[key] = value
+ override.values[key] = _parse_option(key, value, location)
elif key in CONFIG_KEYS:
msg = f"override {paths}: '{key}' cannot be set per path, it applies to the whole run."
raise ValueError(msg)
@@ -574,7 +739,47 @@ def _parse_override(data: dict[str, object]) -> ConfigOverride:
return override
-def _parse_toml_config(data: dict[str, object]) -> LinterConfig: # noqa: C901, PLR0912 # pylint: disable=too-many-branches
+def _parse_scope(value: object) -> dict[str, bool]:
+ """Parse the [scope] table.
+
+ Args:
+ value (object): Value read from the config file.
+
+ Returns:
+ dict[str, bool]: Scope key to whether that kind of entity is checked.
+
+ Raises:
+ ValueError: If the value is not a table.
+
+ """
+ if isinstance(value, dict):
+ scope = cast("dict[str, object]", value)
+ _reject(sorted(set(scope) - SCOPE_KEYS), "configuration key", "scope")
+ return {key: _parse_bool(f"scope.{key}", item) for key, item in scope.items()}
+ msg = f"'scope': expected a table, got {value!r}."
+ raise ValueError(msg)
+
+
+def _parse_overrides(value: object) -> list[ConfigOverride]:
+ """Parse the [[overrides]] array of tables.
+
+ Args:
+ value (object): Value read from the config file.
+
+ Returns:
+ list[ConfigOverride]: Overrides in declaration order.
+
+ Raises:
+ ValueError: If the value is not an array of tables.
+
+ """
+ if isinstance(value, list) and all(isinstance(block, dict) for block in cast("list[object]", value)):
+ return [_parse_override(block) for block in cast("list[dict[str, object]]", value)]
+ msg = "'overrides': expected an array of tables."
+ raise ValueError(msg)
+
+
+def _parse_toml_config(data: dict[str, object]) -> LinterConfig:
"""Parse TOML config dict into LinterConfig.
Args:
@@ -588,54 +793,32 @@ def _parse_toml_config(data: dict[str, object]) -> LinterConfig: # noqa: C901,
_reject(sorted(set(data) - CONFIG_KEYS), "configuration key")
- if "style" in data:
- config.style = _parse_style(data["style"])
-
- scope = cast("dict[str, bool]", data.get("scope", {}))
- _reject(sorted(set(scope) - SCOPE_KEYS), "configuration key", "scope")
- if "modules" in scope:
- config.check_modules = scope["modules"]
- if "classes" in scope:
- config.check_classes = scope["classes"]
- if "functions" in scope:
- config.check_functions = scope["functions"]
- if "methods" in scope:
- config.check_methods = scope["methods"]
-
- if "exclude_empty_init_method" in data:
- config.exclude_empty_init_method = cast("bool", data["exclude_empty_init_method"])
- if "exclude_empty_init_module" in data:
- config.exclude_empty_init_module = cast("bool", data["exclude_empty_init_module"])
- if "ignore_placeholder_docstrings" in data:
- config.ignore_placeholder_docstrings = cast("bool", data["ignore_placeholder_docstrings"])
+ # The convention only moves the defaults: every key of the file still applies on top
+ config.convention = _parse_convention(data.get("convention", config.convention))
+ convention = CONVENTIONS[config.convention]
+ for key, value in convention.values.items():
+ setattr(config, key, value)
+ config.enabled_rules = [rule for rule in config.enabled_rules if rule not in convention.disabled_rules]
+
+ for key, value in _parse_scope(data.get("scope", {})).items():
+ setattr(config, f"check_{key}", value)
+
+ for key, value in data.items():
+ if key in POLICIES_REGISTRY:
+ setattr(config, key, _parse_policy(key, value))
+ elif key in TYPED_OPTIONS:
+ setattr(config, key, _parse_option(key, value))
+
if "exclude" in data:
- config.exclude_patterns = cast("list[str]", data["exclude"])
- for policy in POLICIES_REGISTRY:
- if policy in data:
- setattr(config, policy, _parse_policy(policy, data[policy]))
- if "workers" in data:
- config.workers = max(0, cast("int", data["workers"]))
- if "summary_max_length" in data:
- config.summary_max_length = max(1, cast("int", data["summary_max_length"]))
- if "blank_lines_before_section" in data:
- config.blank_lines_before_section = max(0, cast("int", data["blank_lines_before_section"]))
- if "blank_lines_before_closing_quotes" in data:
- config.blank_lines_before_closing_quotes = max(0, cast("int", data["blank_lines_before_closing_quotes"]))
-
- config.overrides = [_parse_override(cast("dict[str, object]", block)) for block in cast("list[object]", data.get("overrides", []))]
-
- select = cast("list[str]", data.get("select", []))
- ignore = cast("list[str]", data.get("ignore", []))
+ config.exclude_patterns = _parse_str_list("exclude", data["exclude"])
+
+ config.overrides = _parse_overrides(data.get("overrides", []))
+
+ select = _parse_str_list("select", data.get("select", []))
+ ignore = _parse_str_list("ignore", data.get("ignore", []))
_validate_rules(select, ignore)
if select or ignore:
- if select == ["ALL"]:
- enabled: set[str] = set(RULES_REGISTRY)
- elif select:
- enabled = {r for r in select if r in RULES_REGISTRY}
- else:
- enabled = {r for r in RULES_REGISTRY if r not in OFF_BY_DEFAULT}
- enabled -= {r for r in ignore if r in RULES_REGISTRY}
- config.enabled_rules = sorted(enabled)
+ config.enabled_rules = _resolve_rules(select or None, ignore, config.enabled_rules)
return config
diff --git a/src/linter/docstring_parser.py b/src/linter/docstring_parser.py
index 8834e29..1d88982 100644
--- a/src/linter/docstring_parser.py
+++ b/src/linter/docstring_parser.py
@@ -1,13 +1,11 @@
"""Docstring parser for the linter.
-Parse raw docstrings into structured data. Extensible via abstract
-base class for multiple styles (Google, NumPy, Sphinx, PEP 257).
+Parse raw Google style docstrings into structured data.
"""
+import ast
import re
-from abc import ABC, abstractmethod
-from linter.config import DocstringStyle
from linter.models import (
DocstringArg,
DocstringAttribute,
@@ -15,39 +13,14 @@
DocstringReturn,
ParsedDocstring,
)
+from linter.sections import canonical_section, section_header
-class BaseDocstringParser(ABC):
- """Define abstract interface for docstring parsers."""
-
- @abstractmethod
- def parse(self, docstring: str) -> ParsedDocstring:
- """Parse a raw docstring into structured data.
-
- Args:
- docstring (str): Raw docstring text.
-
- Returns:
- ParsedDocstring: Parsed docstring structure.
-
- """
-
- @property
- @abstractmethod
- def style(self) -> DocstringStyle:
- """Return the style this parser handles.
-
- Returns:
- DocstringStyle: Parser style identifier.
-
- """
-
-
-class GoogleStyleParser(BaseDocstringParser):
+class GoogleStyleParser:
"""Parse Google style docstrings.
Attributes:
- SECTION_PATTERN (re.Pattern): Regex for section headers.
+ CANDIDATE_SECTION_PATTERN (re.Pattern): Regex for single-word headers of unknown sections.
ARG_PATTERN (re.Pattern): Regex for typed arg lines.
ARG_NO_TYPE_PATTERN (re.Pattern): Regex for untyped arg lines.
ARG_NO_COLON_PATTERN (re.Pattern): Regex for typed arg lines missing their colon.
@@ -56,26 +29,12 @@ class GoogleStyleParser(BaseDocstringParser):
"""
- SECTION_PATTERN = re.compile(
- r"^(Args|Returns|Raises|Attributes|Example|Examples|Note|Notes|Todo|Yields):\s*$",
- re.MULTILINE,
- )
CANDIDATE_SECTION_PATTERN = re.compile(r"^([A-Z][A-Za-z]*):\s*$")
ARG_PATTERN = re.compile(r"^\s{4}(\*{0,2}\w+)\s*\(([^)]+)\)\s*:\s*(.*)$")
ARG_NO_TYPE_PATTERN = re.compile(r"^\s{4}(\*{0,2}\w+)\s*:\s*(.*)$")
ARG_NO_COLON_PATTERN = re.compile(r"^\s{4}(\*{0,2}\w+)\s*\(([^)]+)\)\s*$")
RETURN_PATTERN = re.compile(r"^\s{4}([^:]+?)\s*:\s*(.*)$")
- RAISE_PATTERN = re.compile(r"^\s{4}(\w+)\s*:\s*(.*)$")
-
- @property
- def style(self) -> DocstringStyle:
- """Return Google style identifier.
-
- Returns:
- DocstringStyle: GOOGLE enum value.
-
- """
- return DocstringStyle.GOOGLE
+ RAISE_PATTERN = re.compile(r"^\s{4}([\w.]+)\s*:\s*(.*)$")
def parse(self, docstring: str) -> ParsedDocstring:
"""Parse a Google style docstring into structured data.
@@ -96,30 +55,24 @@ def parse(self, docstring: str) -> ParsedDocstring:
result.summary = sections.get("_summary")
result.description = sections.get("_description")
- if "Args" in sections:
- result.args = self._parse_args(sections["Args"])
- if "Returns" in sections:
- result.returns = self._parse_returns(sections["Returns"])
- if "Yields" in sections:
- result.yields = self._parse_returns(sections["Yields"])
- if "Raises" in sections:
- result.raises = self._parse_raises(sections["Raises"])
- if "Attributes" in sections:
- result.attributes = self._parse_attributes(sections["Attributes"])
- if "Example" in sections:
- result.examples = [sections["Example"]]
- if "Examples" in sections:
- result.examples = [sections["Examples"]]
-
- raw = sections.get("_unknown_sections", "")
- if raw:
- result.unknown_sections = raw.split(",")
+ # each parser returns an empty result on an absent section
+ result.args = self._parse_args(sections.get("Args", "")) + self._parse_args(sections.get("Other Parameters", ""))
+ result.keyword_args = self._parse_args(sections.get("Keyword Args", ""))
+ result.returns = self._parse_returns(sections.get("Returns", ""))
+ result.yields = self._parse_returns(sections.get("Yields", ""))
+ result.raises = self._parse_raises(sections.get("Raises", ""))
+ result.attributes = self._parse_attributes(sections.get("Attributes", ""))
+
+ result.unknown_sections = [name for name in sections.get("_unknown_sections", "").split(",") if name]
return result
def _split_sections(self, docstring: str) -> dict[str, str]: # noqa: C901 # pylint: disable=R0912:too-many-branches,too-many-locals
"""Split docstring into named sections.
+ Napoleon aliases are stored under their canonical name, and two headers
+ resolving to the same section have their contents joined.
+
Args:
docstring (str): Raw docstring text.
@@ -140,12 +93,12 @@ def _split_sections(self, docstring: str) -> dict[str, str]: # noqa: C901 # pyl
for line in lines:
stripped = line.strip()
- section_match = self.SECTION_PATTERN.match(stripped)
+ header = section_header(line)
- if section_match:
+ if header:
if current_section:
- sections[current_section] = "\n".join(section_lines)
- current_section = section_match.group(1)
+ _store_section(sections, current_section, section_lines)
+ current_section = canonical_section(header)
section_lines = []
in_summary = False
continue
@@ -154,7 +107,7 @@ def _split_sections(self, docstring: str) -> dict[str, str]: # noqa: C901 # pyl
if candidate_match and not in_summary:
name = candidate_match.group(1)
if current_section:
- sections[current_section] = "\n".join(section_lines)
+ _store_section(sections, current_section, section_lines)
current_section = f"_unknown_{name}"
unknown.append(name)
section_lines = []
@@ -170,7 +123,7 @@ def _split_sections(self, docstring: str) -> dict[str, str]: # noqa: C901 # pyl
desc_lines.append(stripped)
if current_section:
- sections[current_section] = "\n".join(section_lines)
+ _store_section(sections, current_section, section_lines)
sections["_unknown_sections"] = ",".join(unknown)
@@ -234,6 +187,10 @@ def _parse_args(self, text: str) -> list[DocstringArg]:
def _parse_returns(self, text: str) -> DocstringReturn | None:
"""Parse Returns section into DocstringReturn.
+ The text before the first colon is the type only when it reads as a
+ Python expression, so that 'The mapping: key to value' stays prose.
+ Continuation lines extend the description.
+
Args:
text (str): Raw text content of the Returns section.
@@ -241,26 +198,29 @@ def _parse_returns(self, text: str) -> DocstringReturn | None:
DocstringReturn | None: Parsed return entry, or None.
"""
+ result: DocstringReturn | None = None
+
for line in text.split("\n"):
- match = self.RETURN_PATTERN.match(line)
- if match:
- return DocstringReturn(
- type_annotation=match.group(1).strip(),
- description=match.group(2).strip() or None,
- )
stripped = line.strip()
if not stripped:
continue
- if stripped.lower() == "none":
- return DocstringReturn(type_annotation="None", description=None)
+ if result is not None:
+ result.description = f"{result.description or ''} {stripped}".strip()
+ continue
- # degraded form, no colon: a single token is a type, anything else is prose
- if " " in stripped:
- return DocstringReturn(type_annotation=None, description=stripped)
- return DocstringReturn(type_annotation=stripped, description=None)
+ match = self.RETURN_PATTERN.match(line)
+ if match and _is_expression(match.group(1).strip()):
+ result = DocstringReturn(type_annotation=match.group(1).strip(), description=match.group(2).strip() or None)
+ elif stripped.lower() == "none":
+ result = DocstringReturn(type_annotation="None", description=None)
+ elif " " not in stripped and _is_expression(stripped):
+ # degraded form, no colon: a bare type
+ result = DocstringReturn(type_annotation=stripped, description=None)
+ else:
+ result = DocstringReturn(type_annotation=None, description=stripped)
- return None
+ return result
def _parse_raises(self, text: str) -> list[DocstringRaise]:
"""Parse Raises section into list of DocstringRaise.
@@ -342,26 +302,34 @@ def _parse_attributes(self, text: str) -> list[DocstringAttribute]:
return attrs
-PARSERS = {
- DocstringStyle.GOOGLE: GoogleStyleParser,
-}
+def _store_section(sections: dict[str, str], name: str, lines: list[str]) -> None:
+ """Store the content of a section after any content already stored under its name.
+ Args:
+ sections (dict[str, str]): Section name to content, updated in place.
+ name (str): Canonical section name.
+ lines (list[str]): Content lines of the section.
-def get_parser(style: DocstringStyle) -> BaseDocstringParser:
- """Get the appropriate parser for the given style.
+ Returns:
+ None
+
+ """
+ text = "\n".join(lines)
+ sections[name] = f"{sections[name]}\n{text}" if name in sections else text
+
+
+def _is_expression(text: str) -> bool:
+ """Check whether a text parses as a Python expression, the form a type takes.
Args:
- style (DocstringStyle): Docstring style to use.
+ text (str): Candidate type.
Returns:
- BaseDocstringParser: Parser instance for the requested style.
-
- Raises:
- ValueError: If the style is not supported.
+ bool: True if the text is a valid expression.
"""
- parser_cls = PARSERS.get(style)
- if parser_cls is None:
- msg = f"Unsupported docstring style: {style.value}"
- raise ValueError(msg)
- return parser_cls()
+ try:
+ ast.parse(text, mode="eval")
+ except SyntaxError:
+ return False
+ return True
diff --git a/src/linter/models.py b/src/linter/models.py
index facbf31..3c773c5 100644
--- a/src/linter/models.py
+++ b/src/linter/models.py
@@ -75,6 +75,11 @@ class CodeEntity: # pylint: disable=too-many-instance-attributes
is_empty_init_module (bool): Whether this is an empty __init__.py module.
is_generator (bool): Whether the function contains a yield statement.
class_attributes (list[str]): Attribute names declared on a class.
+ decorators (list[str]): Last name segment of each decorator, 'setter' for @size.setter.
+ init_args (list[ArgInfo] | None): Parameters of the __init__ of a class, None without __init__.
+ class_docstring (str | None): Docstring of the enclosing class, on __init__ methods only.
+ ignore_all (bool): Whether an ignore comment without rule list silences every error of the entity.
+ ignored_rules (frozenset[str]): Rules and policies silenced by an ignore comment.
"""
@@ -91,6 +96,11 @@ class CodeEntity: # pylint: disable=too-many-instance-attributes
is_empty_init_module: bool = False
is_generator: bool = False
class_attributes: list[str] = field(default_factory=lambda: [])
+ decorators: list[str] = field(default_factory=lambda: [])
+ init_args: list[ArgInfo] | None = None
+ class_docstring: str | None = None
+ ignore_all: bool = False
+ ignored_rules: frozenset[str] = field(default_factory=frozenset[str])
@dataclass
@@ -160,12 +170,12 @@ class ParsedDocstring: # pylint: disable=too-many-instance-attributes
Attributes:
summary (str | None): First line summary.
description (str | None): Extended description.
- args (list[DocstringArg]): Parsed Args section.
+ args (list[DocstringArg]): Parsed Args and Other Parameters sections.
+ keyword_args (list[DocstringArg]): Parsed Keyword Args section, the keys of **kwargs.
returns (DocstringReturn | None): Parsed Returns section.
yields (DocstringReturn | None): Parsed Yields section.
raises (list[DocstringRaise]): Parsed Raises section.
attributes (list[DocstringAttribute]): Parsed Attributes section.
- examples (list[str]): Parsed Example section content.
unknown_sections (list[str]): Section names not recognized by the parser.
"""
@@ -173,11 +183,11 @@ class ParsedDocstring: # pylint: disable=too-many-instance-attributes
summary: str | None = None
description: str | None = None
args: list[DocstringArg] = field(default_factory=lambda: [])
+ keyword_args: list[DocstringArg] = field(default_factory=lambda: [])
returns: DocstringReturn | None = None
yields: DocstringReturn | None = None
raises: list[DocstringRaise] = field(default_factory=lambda: [])
attributes: list[DocstringAttribute] = field(default_factory=lambda: [])
- examples: list[str] = field(default_factory=lambda: [])
unknown_sections: list[str] = field(default_factory=lambda: [])
diff --git a/src/linter/reporter.py b/src/linter/reporter.py
index 589b427..93500e1 100644
--- a/src/linter/reporter.py
+++ b/src/linter/reporter.py
@@ -1,10 +1,13 @@
"""Reporter for the docstring linter.
-Format lint results for CLI output with ANSI colors
+Format lint results for CLI output, with ANSI colors on a terminal,
and JSON export for CI/CD integration.
"""
import json
+import os
+import sys
+from collections import Counter
from pathlib import Path
from typing import TYPE_CHECKING
@@ -15,8 +18,25 @@
from linter.models import LintError
+# ANSI codes only reach a terminal, and never when NO_COLOR holds a value (https://no-color.org)
+_USE_COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
+
+
+def _ansi(code: str) -> str:
+ """Return an ANSI escape sequence, or nothing when colors are off.
+
+ Args:
+ code (str): SGR parameter, such as '91' for red.
+
+ Returns:
+ str: The escape sequence, or an empty string.
+
+ """
+ return f"\033[{code}m" if _USE_COLOR else ""
+
+
class Colors:
- """Define ANSI color codes for terminal output.
+ """Define ANSI color codes for terminal output, empty when colors are off.
Attributes:
RED (str): Red color code.
@@ -31,15 +51,74 @@ class Colors:
"""
- RED = "\033[91m"
- YELLOW = "\033[93m"
- GREEN = "\033[92m"
- CYAN = "\033[96m"
- BLUE = "\033[94m"
- WHITE = "\033[97m"
- BOLD = "\033[1m"
- DIM = "\033[2m"
- RESET = "\033[0m"
+ RED = _ansi("91")
+ YELLOW = _ansi("93")
+ GREEN = _ansi("92")
+ CYAN = _ansi("96")
+ BLUE = _ansi("94")
+ WHITE = _ansi("97")
+ BOLD = _ansi("1")
+ DIM = _ansi("2")
+ RESET = _ansi("0")
+
+
+def _files(count: int) -> str:
+ """Return a file count with the matching singular or plural noun.
+
+ Args:
+ count (int): Number of files.
+
+ Returns:
+ str: '1 file' or 'N files'.
+
+ """
+ return f"{count} file" if count == 1 else f"{count} files"
+
+
+def _escape_data(text: str) -> str:
+ """Escape the message of a GitHub workflow command, as @actions/core does.
+
+ Args:
+ text (str): Message text.
+
+ Returns:
+ str: Text with %, carriage returns and line feeds percent-encoded.
+
+ """
+ return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
+
+
+def _escape_property(text: str) -> str:
+ """Escape a property value of a GitHub workflow command, as @actions/core does.
+
+ Args:
+ text (str): Property value, such as a file path.
+
+ Returns:
+ str: Text escaped like a message, plus colons and commas.
+
+ """
+ return _escape_data(text).replace(":", "%3A").replace(",", "%2C")
+
+
+def _print_summary(errors: list[LintError], files_checked: int) -> None:
+ """Print the closing line of a report with errors.
+
+ Args:
+ errors (list[LintError]): Lint errors found, at least one.
+ files_checked (int): Total number of files checked.
+
+ Returns:
+ None
+
+ """
+ file_count = len({e.filepath for e in errors})
+ error_count = len(errors)
+ print(
+ f"{Colors.RED}{Colors.BOLD}✗ {error_count} error{'s' if error_count > 1 else ''}{Colors.RESET} "
+ f"{Colors.DIM}in {file_count} file{'s' if file_count > 1 else ''} "
+ f"({_files(files_checked)} checked).{Colors.RESET}\n"
+ )
def report_cli(errors: list[LintError], files_checked: int) -> None:
@@ -54,7 +133,7 @@ def report_cli(errors: list[LintError], files_checked: int) -> None:
"""
if not errors:
- print(f"{files_checked} files checked, 0 errors.")
+ print(f"{_files(files_checked)} checked, 0 errors.")
return
by_file: dict[str, list[LintError]] = {}
@@ -68,13 +147,7 @@ def report_cli(errors: list[LintError], files_checked: int) -> None:
print(f" {Colors.DIM}L{error.line:<4}{Colors.RESET} {Colors.CYAN}{error.entity_name}{Colors.RESET} {Colors.RED}[{error.rule}]{Colors.RESET} {error.message}")
print()
- file_count = len(by_file)
- error_count = len(errors)
- print(
- f"{Colors.RED}{Colors.BOLD}✗ {error_count} error{'s' if error_count > 1 else ''}{Colors.RESET} "
- f"{Colors.DIM}in {file_count} file{'s' if file_count > 1 else ''} "
- f"({files_checked} files checked).{Colors.RESET}\n"
- )
+ _print_summary(errors, files_checked)
def report_traceback(errors: list[LintError], files_checked: int) -> None:
@@ -92,7 +165,7 @@ def report_traceback(errors: list[LintError], files_checked: int) -> None:
"""
if not errors:
- print(f"{files_checked} files checked, 0 errors.")
+ print(f"{_files(files_checked)} checked, 0 errors.")
return
by_entity: dict[tuple[str, int, str], list[LintError]] = {}
@@ -107,13 +180,32 @@ def report_traceback(errors: list[LintError], files_checked: int) -> None:
print(f" {Colors.RED}[{error.rule}]{Colors.RESET} {error.message}")
print()
- file_count = len({e.filepath for e in errors})
- error_count = len(errors)
- print(
- f"{Colors.RED}{Colors.BOLD}✗ {error_count} error{'s' if error_count > 1 else ''}{Colors.RESET} "
- f"{Colors.DIM}in {file_count} file{'s' if file_count > 1 else ''} "
- f"({files_checked} files checked).{Colors.RESET}\n"
- )
+ _print_summary(errors, files_checked)
+
+
+def report_statistics(errors: list[LintError], files_checked: int) -> None:
+ """Print the number of errors per rule, most frequent first.
+
+ Args:
+ errors (list[LintError]): List of lint errors to count.
+ files_checked (int): Total number of files checked.
+
+ Returns:
+ None
+
+ """
+ if not errors:
+ print(f"{_files(files_checked)} checked, 0 errors.")
+ return
+
+ counts = Counter(error.rule for error in errors)
+ width = len(str(max(counts.values())))
+ print()
+ for rule, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])):
+ print(f" {count:>{width}} {Colors.RED}{rule}{Colors.RESET}")
+ print()
+
+ _print_summary(errors, files_checked)
def report_json(errors: list[LintError], files_checked: int) -> None:
@@ -160,14 +252,14 @@ def report_github_annotations(errors: list[LintError], files_checked: int) -> No
"""
for e in sorted(errors, key=lambda e: (e.filepath, e.line)):
- print(f"::error file={e.filepath},line={e.line},title={e.rule}::{e.message}")
+ print(f"::error file={_escape_property(e.filepath)},line={e.line},title={_escape_property(e.rule)}::{_escape_data(e.message)}")
error_count = len(errors)
file_count = len({e.filepath for e in errors})
if error_count == 0:
- print(f"{files_checked} files checked, 0 errors.")
+ print(f"{_files(files_checked)} checked, 0 errors.")
else:
- print(f"{error_count} error{'s' if error_count > 1 else ''} in {file_count} file{'s' if file_count > 1 else ''} ({files_checked} files checked).")
+ print(f"{error_count} error{'s' if error_count > 1 else ''} in {file_count} file{'s' if file_count > 1 else ''} ({_files(files_checked)} checked).")
def report_policies(registry: dict[str, str], values: dict[str, str]) -> None:
@@ -236,17 +328,36 @@ def report_overrides(overrides: list[ConfigOverride], base: dict[str, str]) -> N
print()
+def _print_always_on(rules: list[str], registry: dict[str, str]) -> None:
+ """Print the rules that cannot be disabled, after the configurable ones.
+
+ Args:
+ rules (list[str]): Always-on rule identifiers, in category order.
+ registry (dict[str, str]): Rule identifier to description.
+
+ Returns:
+ None
+
+ """
+ if not rules:
+ return
+ print(f" {Colors.BOLD}{Colors.CYAN}Always on{Colors.RESET} {Colors.DIM}cannot be disabled{Colors.RESET}")
+ for rule in rules:
+ print(f" {Colors.GREEN}✔{Colors.RESET} {Colors.BOLD}{rule:<35}{Colors.RESET} {Colors.DIM}{registry[rule]}{Colors.RESET}")
+ print()
+
+
def report_rules(categories: dict[str, list[str]], registry: dict[str, str], off_by_default: frozenset[str], always_on: frozenset[str], enabled: frozenset[str]) -> None:
"""Print the configurable rules grouped by category, with their enabled status.
- Rules that cannot be disabled are omitted: nothing can be done about them
- from the configuration file.
+ Rules that cannot be disabled follow in a section of their own, since they
+ show up in the reports as well.
Args:
categories (dict[str, list[str]]): Category name to rule identifiers.
registry (dict[str, str]): Rule identifier to description.
- off_by_default (frozenset[str]): Rules disabled by default.
- always_on (frozenset[str]): Rules that cannot be disabled, hidden from the listing.
+ off_by_default (frozenset[str]): Rules the active convention disables by default.
+ always_on (frozenset[str]): Rules that cannot be disabled, listed after the categories.
enabled (frozenset[str]): Rules enabled in the current config.
Returns:
@@ -268,3 +379,5 @@ def report_rules(categories: dict[str, list[str]], registry: dict[str, str], off
name_style = Colors.BOLD if is_enabled else Colors.DIM
print(f" {status} {name_style}{rule:<35}{Colors.RESET} {Colors.DIM}{registry[rule]}{Colors.RESET}{opt_in}")
print()
+
+ _print_always_on([rule for rules in categories.values() for rule in rules if rule in always_on], registry)
diff --git a/src/linter/rules/__init__.py b/src/linter/rules/__init__.py
index f1d62f1..381774f 100644
--- a/src/linter/rules/__init__.py
+++ b/src/linter/rules/__init__.py
@@ -4,17 +4,19 @@
missing, incomplete, or incorrectly formatted documentation.
"""
+from dataclasses import replace
from typing import TYPE_CHECKING
from linter.config import Policy
from linter.models import CodeEntity, LintError, NodeType, ParsedDocstring
-from linter.rules._base import GOOGLE_SECTION_ORDER, GOOGLE_SECTIONS, is_placeholder, make_error
+from linter.rules._base import has_args_section, is_placeholder, make_error
from linter.rules.args import (
check_args_match,
check_args_order,
check_args_section,
check_duplicate_arg,
check_init_returns_none,
+ check_raises_extraneous,
check_raises_match,
check_raises_section,
check_return_type_annotation,
@@ -42,6 +44,7 @@
check_indentation,
check_named_section,
check_no_blank_line_in_section,
+ check_section_alias,
check_section_capitalization,
check_section_order,
)
@@ -50,13 +53,124 @@
from linter.config import LinterConfig
__all__ = [
- "GOOGLE_SECTIONS",
- "GOOGLE_SECTION_ORDER",
"validate_entity",
]
-def validate_entity( # noqa: C901, PLR0912, PLR0915 # pylint: disable=too-many-branches,too-many-statements
+_PROPERTY_GETTERS = frozenset({"property", "cached_property"})
+_PROPERTY_ACCESSORS = frozenset({"setter", "deleter"})
+
+# Policies requiring a section, lifted on one-line docstrings by sections_optional_on_one_liners
+_SECTION_POLICIES = ("args_section", "returns_section", "yields_section", "raises_section", "returns_none", "init_returns_none")
+
+
+def _sections_optional(entity: CodeEntity, config: LinterConfig) -> bool:
+ """Check whether a one-line docstring is enough for a function or method.
+
+ The Google guide omits the sections when the name and the signature say it
+ all, so the signature must be fully annotated.
+
+ Args:
+ entity (CodeEntity): Entity to check.
+ config (LinterConfig): Linter configuration.
+
+ Returns:
+ bool: True if the required section policies do not apply to this docstring.
+
+ """
+ return (
+ config.sections_optional_on_one_liners
+ and entity.node_type in (NodeType.FUNCTION, NodeType.METHOD)
+ and entity.docstring is not None
+ and "\n" not in entity.docstring.strip()
+ and entity.return_type is not None
+ and all(arg.type_annotation for arg in entity.args)
+ )
+
+
+def _is_docstring_optional(entity: CodeEntity, config: LinterConfig) -> bool:
+ """Check whether the options exempt an entity from having a docstring.
+
+ A docstring that is present is still checked, whatever the exemption.
+
+ Args:
+ entity (CodeEntity): Entity to check.
+ config (LinterConfig): Linter configuration.
+
+ Returns:
+ bool: True if a missing docstring must not be reported.
+
+ """
+ if entity.node_type is NodeType.MODULE:
+ return entity.is_empty_init_module and config.exclude_empty_init_module
+
+ parts = entity.name.split(".")
+ is_dunder = parts[-1].startswith("__") and parts[-1].endswith("__")
+ # _name and __name are private, __name__ is a magic method
+ is_private = any(part.startswith("_") and not (part.startswith("__") and part.endswith("__")) for part in parts)
+
+ return (
+ (entity.is_empty_init and config.exclude_empty_init_method)
+ or (is_dunder and parts[-1] != "__init__" and config.exclude_dunder_methods)
+ or (is_private and config.exclude_private)
+ or ("override" in entity.decorators and config.exclude_overridden)
+ )
+
+
+def _init_args_expected_in_class(class_docstring: str | None, config: LinterConfig) -> bool:
+ """Check whether the __init__ parameters belong to the class docstring.
+
+ Args:
+ class_docstring (str | None): Docstring of the class.
+ config (LinterConfig): Linter configuration.
+
+ Returns:
+ bool: True under init_args_location 'class', or 'either' when the class has an Args section.
+
+ """
+ if config.args_section is Policy.FORBIDDEN or config.init_args_location == "init":
+ return False
+ return config.init_args_location == "class" or has_args_section(class_docstring)
+
+
+def _init_args_in_class(entity: CodeEntity, config: LinterConfig) -> bool:
+ """Check whether an entity is an __init__ documented by its class docstring.
+
+ Args:
+ entity (CodeEntity): Entity to check.
+ config (LinterConfig): Linter configuration.
+
+ Returns:
+ bool: True if the __init__ docstring is optional and its parameters are checked on the class.
+
+ """
+ if entity.node_type is not NodeType.METHOD or not entity.name.endswith(".__init__"):
+ return False
+ return _init_args_expected_in_class(entity.class_docstring, config)
+
+
+def _check_args(entity: CodeEntity, parsed_doc: ParsedDocstring | None, config: LinterConfig) -> list[LintError]:
+ """Run the rules comparing the Args section with the signature.
+
+ Args:
+ entity (CodeEntity): Entity carrying the signature parameters.
+ parsed_doc (ParsedDocstring | None): Parsed docstring.
+ config (LinterConfig): Linter configuration.
+
+ Returns:
+ list[LintError]: Errors of args_section, args_match, duplicate_arg and args_order.
+
+ """
+ errors = check_args_section(entity, parsed_doc, config.args_section)
+ if config.args_section is not Policy.FORBIDDEN:
+ errors.extend(check_args_match(entity, parsed_doc, config.documented_types, config.type_matching, config.documented_stars))
+ errors.extend(check_duplicate_arg(entity, parsed_doc))
+ if config.is_rule_enabled("args_order"):
+ errors.extend(check_args_order(entity, parsed_doc))
+ return errors
+
+
+def validate_entity(
entity: CodeEntity,
parsed_doc: ParsedDocstring | None,
config: LinterConfig,
@@ -74,9 +188,18 @@ def validate_entity( # noqa: C901, PLR0912, PLR0915 # pylint: disable=too-many-
"""
errors: list[LintError] = []
- docstring_optional = (entity.is_empty_init and config.exclude_empty_init_method) or (entity.is_empty_init_module and config.exclude_empty_init_module)
+ # Sphinx documents a property from its getter, setters and deleters carry nothing to check
+ if config.properties_as_attributes and not _PROPERTY_ACCESSORS.isdisjoint(entity.decorators):
+ return errors
+
+ is_getter = config.properties_as_attributes and not _PROPERTY_GETTERS.isdisjoint(entity.decorators)
+
+ if _sections_optional(entity, config):
+ config = replace(config, **{policy: Policy.OPTIONAL for policy in _SECTION_POLICIES if getattr(config, policy) is Policy.REQUIRED})
- if config.is_rule_enabled("docstring_exists") and not docstring_optional:
+ init_in_class = _init_args_in_class(entity, config)
+
+ if config.is_rule_enabled("docstring_exists") and not (_is_docstring_optional(entity, config) or init_in_class):
errors.extend(check_docstring_exists(entity))
if not entity.docstring or not entity.docstring.strip():
@@ -87,55 +210,133 @@ def validate_entity( # noqa: C901, PLR0912, PLR0915 # pylint: disable=too-many-
return []
return [make_error(entity, "docstring_exists", f"Placeholder docstring: '{entity.docstring.strip()}'.")]
- errors.extend(check_summary_exists(entity, parsed_doc))
+ errors.extend(_check_summary(entity, parsed_doc, config))
- errors.extend(check_summary_final_period(entity, parsed_doc, config.summary_final_period))
+ if entity.node_type in (NodeType.FUNCTION, NodeType.METHOD):
+ errors.extend(_check_function(entity, parsed_doc, config, init_in_class=init_in_class, is_getter=is_getter))
- errors.extend(check_description_section(entity, parsed_doc, config.description_section))
+ if entity.node_type == NodeType.CLASS:
+ errors.extend(_check_class(entity, parsed_doc, config))
+
+ errors.extend(_check_layout(entity, parsed_doc, config, is_getter=is_getter))
+ return errors
+
+
+def _check_summary(entity: CodeEntity, parsed_doc: ParsedDocstring | None, config: LinterConfig) -> list[LintError]:
+ """Run the rules on the summary, the description and the free sections.
+
+ Args:
+ entity (CodeEntity): Entity carrying a docstring.
+ parsed_doc (ParsedDocstring | None): Parsed docstring.
+ config (LinterConfig): Linter configuration.
+
+ Returns:
+ list[LintError]: Errors found.
- errors.extend(check_named_section(entity, ("Example", "Examples"), config.examples_section, "examples_section"))
+ """
+ errors = check_summary_exists(entity, parsed_doc)
+ errors.extend(check_summary_final_period(entity, parsed_doc, config.summary_final_period))
+ errors.extend(check_description_section(entity, parsed_doc, config.description_section))
+ errors.extend(check_named_section(entity, ("Examples", "Example"), config.examples_section, "examples_section"))
errors.extend(check_named_section(entity, ("Note", "Notes"), config.notes_section, "notes_section"))
errors.extend(check_named_section(entity, ("Todo",), config.todo_section, "todo_section"))
if config.is_rule_enabled("summary_too_long"):
errors.extend(check_summary_too_long(entity, parsed_doc, config.summary_max_length))
- if entity.node_type in (NodeType.FUNCTION, NodeType.METHOD):
- if config.is_rule_enabled("return_type_annotation"):
- errors.extend(check_return_type_annotation(entity))
+ return errors
+
+
+def _check_function(entity: CodeEntity, parsed_doc: ParsedDocstring | None, config: LinterConfig, *, init_in_class: bool, is_getter: bool) -> list[LintError]:
+ """Run the rules comparing a function docstring with its signature and body.
- errors.extend(check_args_section(entity, parsed_doc, config.args_section))
+ Args:
+ entity (CodeEntity): Function or method carrying a docstring.
+ parsed_doc (ParsedDocstring | None): Parsed docstring.
+ config (LinterConfig): Linter configuration.
+ init_in_class (bool): Whether the parameters of this __init__ belong to the class docstring.
+ is_getter (bool): Whether the entity is a property getter documented like an attribute.
- if config.args_section is not Policy.FORBIDDEN:
- errors.extend(check_args_match(entity, parsed_doc, config.documented_types))
+ Returns:
+ list[LintError]: Errors found.
- errors.extend(check_duplicate_arg(entity, parsed_doc))
+ """
+ errors: list[LintError] = []
+
+ if config.is_rule_enabled("return_type_annotation"):
+ errors.extend(check_return_type_annotation(entity))
- if config.is_rule_enabled("args_order"):
- errors.extend(check_args_order(entity, parsed_doc))
+ if not init_in_class:
+ errors.extend(_check_args(entity, parsed_doc, config))
+ elif has_args_section(entity.docstring or ""):
+ where = "must be documented in the class docstring, not in '__init__'" if config.init_args_location == "class" else "are documented both in the class docstring and in '__init__'"
+ errors.append(make_error(entity, "args_section", f"Parameters of __init__ {where}."))
+ # a property getter is documented like an attribute, without Returns section
+ if not is_getter:
errors.extend(check_returns_section(entity, parsed_doc, config.returns_section))
- if config.returns_section is not Policy.FORBIDDEN:
- errors.extend(check_returns_match(entity, parsed_doc, config.returns_descriptions))
- errors.extend(check_returns_none(entity, parsed_doc, config.returns_none))
- errors.extend(check_init_returns_none(entity, parsed_doc, config.init_returns_none))
+ if config.returns_section is not Policy.FORBIDDEN:
+ errors.extend(check_returns_match(entity, parsed_doc, config.returns_descriptions, config.documented_types, config.type_matching))
+ errors.extend(check_returns_none(entity, parsed_doc, config.returns_none))
+ errors.extend(check_init_returns_none(entity, parsed_doc, config.init_returns_none))
- errors.extend(check_raises_section(entity, parsed_doc, config.raises_section))
+ errors.extend(check_raises_section(entity, parsed_doc, config.raises_section))
- if config.raises_section is not Policy.FORBIDDEN:
- errors.extend(check_raises_match(entity, parsed_doc))
+ if config.raises_section is not Policy.FORBIDDEN:
+ errors.extend(check_raises_match(entity, parsed_doc))
- errors.extend(check_yields_section(entity, parsed_doc, config.yields_section))
+ if config.is_rule_enabled("raises_extraneous"):
+ errors.extend(check_raises_extraneous(entity, parsed_doc))
- if config.yields_section is not Policy.FORBIDDEN:
- errors.extend(check_yields_match(entity, parsed_doc, config.returns_descriptions))
+ errors.extend(check_yields_section(entity, parsed_doc, config.yields_section))
- if entity.node_type == NodeType.CLASS:
- errors.extend(check_attributes_section(entity, parsed_doc, config.attributes_section))
+ if config.yields_section is not Policy.FORBIDDEN:
+ errors.extend(check_yields_match(entity, parsed_doc, config.returns_descriptions, config.documented_types))
- if config.attributes_section is not Policy.FORBIDDEN:
- errors.extend(check_attributes_match(entity, parsed_doc, config.documented_types))
+ return errors
+
+
+def _check_class(entity: CodeEntity, parsed_doc: ParsedDocstring | None, config: LinterConfig) -> list[LintError]:
+ """Run the rules comparing a class docstring with its attributes and __init__.
+
+ Args:
+ entity (CodeEntity): Class carrying a docstring.
+ parsed_doc (ParsedDocstring | None): Parsed docstring.
+ config (LinterConfig): Linter configuration.
+
+ Returns:
+ list[LintError]: Errors found.
+
+ """
+ errors: list[LintError] = []
+
+ # the parameters of __init__, when the class docstring documents them
+ if entity.init_args is not None and _init_args_expected_in_class(entity.docstring or "", config):
+ errors.extend(_check_args(replace(entity, args=entity.init_args), parsed_doc, config))
+
+ errors.extend(check_attributes_section(entity, parsed_doc, config.attributes_section))
+
+ if config.attributes_section is not Policy.FORBIDDEN:
+ errors.extend(check_attributes_match(entity, parsed_doc, config.documented_types))
+
+ return errors
+
+
+def _check_layout(entity: CodeEntity, parsed_doc: ParsedDocstring | None, config: LinterConfig, *, is_getter: bool) -> list[LintError]:
+ """Run the rules on section headers, spacing, indentation and summary placement.
+
+ Args:
+ entity (CodeEntity): Entity carrying a docstring.
+ parsed_doc (ParsedDocstring | None): Parsed docstring.
+ config (LinterConfig): Linter configuration.
+ is_getter (bool): Whether the entity is a property getter, exempt from imperative_mood.
+
+ Returns:
+ list[LintError]: Errors found.
+
+ """
+ errors: list[LintError] = []
if config.is_rule_enabled("indentation"):
errors.extend(check_indentation(entity))
@@ -143,6 +344,9 @@ def validate_entity( # noqa: C901, PLR0912, PLR0915 # pylint: disable=too-many-
if config.is_rule_enabled("section_capitalization"):
errors.extend(check_section_capitalization(entity))
+ if config.is_rule_enabled("section_alias"):
+ errors.extend(check_section_alias(entity))
+
if config.is_rule_enabled("section_order"):
errors.extend(check_section_order(entity))
@@ -153,7 +357,7 @@ def validate_entity( # noqa: C901, PLR0912, PLR0915 # pylint: disable=too-many-
errors.extend(check_blank_lines(entity, config.blank_lines_before_section, config.blank_lines_before_closing_quotes))
- if config.is_rule_enabled("imperative_mood") and entity.node_type != NodeType.MODULE:
+ if config.is_rule_enabled("imperative_mood") and entity.node_type in (NodeType.FUNCTION, NodeType.METHOD) and not is_getter:
errors.extend(check_imperative_mood(entity, parsed_doc))
errors.extend(check_summary_on_first_line(entity, config.summary_on_first_line))
diff --git a/src/linter/rules/_base.py b/src/linter/rules/_base.py
index 519119a..96e9a45 100644
--- a/src/linter/rules/_base.py
+++ b/src/linter/rules/_base.py
@@ -1,36 +1,7 @@
"""Shared constants and helpers for docstring lint rules."""
-import re
-
from linter.models import CodeEntity, LintError
-
-GOOGLE_SECTIONS = [
- "Args",
- "Returns",
- "Yields",
- "Raises",
- "Example",
- "Examples",
- "Note",
- "Notes",
- "Todo",
- "Attributes",
-]
-
-GOOGLE_SECTION_ORDER = [
- "Attributes",
- "Args",
- "Returns",
- "Yields",
- "Raises",
- "Example",
- "Examples",
- "Note",
- "Notes",
- "Todo",
-]
-
-SECTION_HEADER_RE = re.compile(r"^([A-Za-z]+):\s*$")
+from linter.sections import canonical_section, section_header
def make_error(entity: CodeEntity, rule: str, message: str) -> LintError:
@@ -78,9 +49,17 @@ def extract_section_headers(docstring: str) -> list[str]:
list[str]: Ordered list of section names found.
"""
- headers: list[str] = []
- for line in docstring.split("\n"):
- match = SECTION_HEADER_RE.match(line.strip())
- if match and match.group(1) in GOOGLE_SECTIONS:
- headers.append(match.group(1))
- return headers
+ return [name for name in map(section_header, docstring.split("\n")) if name]
+
+
+def has_args_section(docstring: str | None) -> bool:
+ """Check whether a docstring carries an Args section, under any alias.
+
+ Args:
+ docstring (str | None): Docstring text, or None.
+
+ Returns:
+ bool: True if an Args section header is present.
+
+ """
+ return docstring is not None and "Args" in {canonical_section(name) for name in extract_section_headers(docstring)}
diff --git a/src/linter/rules/_types.py b/src/linter/rules/_types.py
new file mode 100644
index 0000000..4b3f277
--- /dev/null
+++ b/src/linter/rules/_types.py
@@ -0,0 +1,146 @@
+"""Type comparison between docstrings and signatures.
+
+Three levels, each including the previous one:
+
+- strict: cosmetic differences only (quotes, spacing, ', optional', Sphinx '~').
+- equivalent: same type spelled differently (Optional[X], X | None, List, list).
+- lenient: the docstring may omit None from a union declared in the signature.
+"""
+
+import ast
+import re
+
+_TYPING_ALIASES = {"List": "list", "Dict": "dict", "Set": "set", "FrozenSet": "frozenset", "Tuple": "tuple", "Type": "type"}
+
+_SPHINX_SHORT_REF = re.compile(r"~(?=[A-Za-z_])")
+
+
+def _parse(text: str) -> ast.expr | None:
+ """Parse a type written as a Python expression.
+
+ Args:
+ text (str): Type text.
+
+ Returns:
+ ast.expr | None: Expression node, or None if the text is not valid Python.
+
+ """
+ try:
+ return ast.parse(text, mode="eval").body
+ except SyntaxError:
+ return None
+
+
+class _Unquote(ast.NodeTransformer):
+ """Replace string forward references by the expression they contain."""
+
+ def visit_Constant(self, node: ast.Constant) -> ast.expr: # pylint: disable=invalid-name
+ """Parse a string constant as a type expression when possible.
+
+ Args:
+ node (ast.Constant): Constant node.
+
+ Returns:
+ ast.expr: Parsed expression, or the constant when it is not a type.
+
+ """
+ if isinstance(node.value, str):
+ parsed = _parse(node.value)
+ if parsed is not None:
+ return self.visit(parsed)
+ return node
+
+
+class _Unalias(ast.NodeTransformer):
+ """Rename the typing aliases of builtin generics: List becomes list."""
+
+ def visit_Name(self, node: ast.Name) -> ast.Name: # pylint: disable=invalid-name
+ """Rename a typing alias to its builtin.
+
+ Args:
+ node (ast.Name): Name node.
+
+ Returns:
+ ast.Name: Node carrying the builtin name, or the node unchanged.
+
+ """
+ return ast.Name(id=_TYPING_ALIASES.get(node.id, node.id))
+
+
+def _cosmetic(text: str) -> ast.expr | str:
+ """Drop the cosmetic parts of a type: ', optional', Sphinx '~', quotes and spacing.
+
+ Args:
+ text (str): Type as written.
+
+ Returns:
+ ast.expr | str: Parsed expression, or the cleaned text when it is not valid Python.
+
+ """
+ cleaned = _SPHINX_SHORT_REF.sub("", text.strip().removesuffix(", optional").strip())
+ parsed = _parse(cleaned)
+ if parsed is None:
+ return cleaned
+ return _Unquote().visit(parsed)
+
+
+def _is_named(node: ast.expr, name: str) -> bool:
+ """Check whether a node is a name or an attribute ending with the given name.
+
+ Args:
+ node (ast.expr): Node to inspect.
+ name (str): Expected name, such as 'Optional'.
+
+ Returns:
+ bool: True for Optional as well as typing.Optional.
+
+ """
+ return (isinstance(node, ast.Name) and node.id == name) or (isinstance(node, ast.Attribute) and node.attr == name)
+
+
+def _union_members(node: ast.expr) -> frozenset[str]:
+ """Flatten a union into the set of its members, whatever its spelling.
+
+ Args:
+ node (ast.expr): Type expression.
+
+ Returns:
+ frozenset[str]: Members as source text, 'None' included when the union allows it.
+
+ """
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
+ return _union_members(node.left) | _union_members(node.right)
+ if isinstance(node, ast.Subscript) and _is_named(node.value, "Optional"):
+ return _union_members(node.slice) | {"None"}
+ if isinstance(node, ast.Subscript) and _is_named(node.value, "Union"):
+ elements = node.slice.elts if isinstance(node.slice, ast.Tuple) else [node.slice]
+ members: frozenset[str] = frozenset()
+ return members.union(*(_union_members(element) for element in elements))
+ return frozenset({ast.unparse(_Unalias().visit(node))})
+
+
+def types_match(documented: str, signature: str, level: str) -> bool:
+ """Check whether a documented type matches the signature annotation.
+
+ Args:
+ documented (str): Type written in the docstring.
+ signature (str): Annotation from the signature, as unparsed by ast.
+ level (str): Matching level, strict, equivalent or lenient.
+
+ Returns:
+ bool: True if the types are considered identical at that level.
+
+ """
+ doc = _cosmetic(documented)
+ sig = _cosmetic(signature)
+ if isinstance(doc, str) or isinstance(sig, str):
+ return doc == sig
+
+ if ast.unparse(doc) == ast.unparse(sig):
+ return True
+ if level == "strict":
+ return False
+
+ doc_members = _union_members(doc)
+ sig_members = _union_members(sig)
+ return doc_members == sig_members or (level == "lenient" and doc_members == sig_members - {"None"})
diff --git a/src/linter/rules/_verbs.py b/src/linter/rules/_verbs.py
new file mode 100644
index 0000000..4477993
--- /dev/null
+++ b/src/linter/rules/_verbs.py
@@ -0,0 +1,345 @@
+"""English verbs that commonly start a docstring summary, in their base form.
+
+imperative_mood reports a summary only when the base form it derives from the
+first word is listed here, so that plural nouns such as 'Options' pass.
+"""
+
+VERBS: frozenset[str] = frozenset(
+ {
+ "accept",
+ "access",
+ "acquire",
+ "adapt",
+ "add",
+ "address",
+ "adjust",
+ "align",
+ "allocate",
+ "allow",
+ "alter",
+ "append",
+ "apply",
+ "assert",
+ "assign",
+ "attach",
+ "attempt",
+ "avoid",
+ "behave",
+ "bind",
+ "block",
+ "build",
+ "cache",
+ "calculate",
+ "call",
+ "cancel",
+ "capture",
+ "cause",
+ "change",
+ "check",
+ "choose",
+ "clean",
+ "clear",
+ "clone",
+ "close",
+ "collect",
+ "combine",
+ "commit",
+ "compare",
+ "compile",
+ "complete",
+ "compose",
+ "compress",
+ "compute",
+ "configure",
+ "connect",
+ "construct",
+ "consume",
+ "contain",
+ "control",
+ "convert",
+ "copy",
+ "count",
+ "create",
+ "decide",
+ "decode",
+ "decorate",
+ "decrypt",
+ "define",
+ "delegate",
+ "delete",
+ "deny",
+ "deprecate",
+ "dequeue",
+ "describe",
+ "deserialize",
+ "destroy",
+ "detach",
+ "detect",
+ "determine",
+ "disable",
+ "disconnect",
+ "dispatch",
+ "display",
+ "dispose",
+ "divide",
+ "do",
+ "download",
+ "drain",
+ "draw",
+ "drop",
+ "dump",
+ "emit",
+ "enable",
+ "encapsulate",
+ "encode",
+ "encrypt",
+ "enqueue",
+ "ensure",
+ "enter",
+ "escape",
+ "establish",
+ "estimate",
+ "evaluate",
+ "exclude",
+ "execute",
+ "exit",
+ "expand",
+ "expect",
+ "export",
+ "expose",
+ "express",
+ "extend",
+ "extract",
+ "fetch",
+ "fill",
+ "filter",
+ "finalize",
+ "find",
+ "finish",
+ "fix",
+ "flatten",
+ "flush",
+ "follow",
+ "force",
+ "format",
+ "forward",
+ "freeze",
+ "gather",
+ "generate",
+ "get",
+ "give",
+ "go",
+ "group",
+ "guess",
+ "handle",
+ "hash",
+ "hide",
+ "highlight",
+ "hold",
+ "identify",
+ "ignore",
+ "imitate",
+ "implement",
+ "import",
+ "include",
+ "increment",
+ "indicate",
+ "infer",
+ "inherit",
+ "initialize",
+ "inject",
+ "insert",
+ "inspect",
+ "install",
+ "instantiate",
+ "intercept",
+ "interpret",
+ "interrupt",
+ "invalidate",
+ "invert",
+ "invoke",
+ "iterate",
+ "join",
+ "keep",
+ "kill",
+ "launch",
+ "link",
+ "listen",
+ "load",
+ "lock",
+ "log",
+ "look",
+ "loop",
+ "make",
+ "manage",
+ "map",
+ "mark",
+ "match",
+ "measure",
+ "merge",
+ "migrate",
+ "modify",
+ "monitor",
+ "mount",
+ "move",
+ "mutate",
+ "negate",
+ "normalize",
+ "notify",
+ "observe",
+ "obtain",
+ "open",
+ "output",
+ "override",
+ "pack",
+ "pad",
+ "parameterize",
+ "parse",
+ "pass",
+ "patch",
+ "pause",
+ "perform",
+ "persist",
+ "pick",
+ "pickle",
+ "place",
+ "plot",
+ "poll",
+ "pop",
+ "populate",
+ "post",
+ "prepare",
+ "preprocess",
+ "preserve",
+ "prevent",
+ "print",
+ "process",
+ "produce",
+ "prompt",
+ "propagate",
+ "protect",
+ "provide",
+ "publish",
+ "pull",
+ "purge",
+ "push",
+ "put",
+ "query",
+ "queue",
+ "quote",
+ "raise",
+ "read",
+ "rebuild",
+ "receive",
+ "reconfigure",
+ "record",
+ "recover",
+ "redirect",
+ "reduce",
+ "refresh",
+ "register",
+ "reject",
+ "release",
+ "reload",
+ "remove",
+ "rename",
+ "render",
+ "reorder",
+ "repeat",
+ "replace",
+ "report",
+ "represent",
+ "request",
+ "reset",
+ "resize",
+ "resolve",
+ "respond",
+ "restart",
+ "restore",
+ "resume",
+ "retrieve",
+ "retry",
+ "return",
+ "reverse",
+ "revert",
+ "rewrite",
+ "rotate",
+ "round",
+ "route",
+ "run",
+ "sample",
+ "sanitize",
+ "save",
+ "scan",
+ "schedule",
+ "search",
+ "seek",
+ "select",
+ "send",
+ "serialize",
+ "serve",
+ "set",
+ "share",
+ "shift",
+ "show",
+ "shuffle",
+ "signal",
+ "simulate",
+ "skip",
+ "sleep",
+ "sort",
+ "spawn",
+ "split",
+ "start",
+ "stash",
+ "stop",
+ "store",
+ "stream",
+ "strip",
+ "submit",
+ "subscribe",
+ "substitute",
+ "subtract",
+ "suppress",
+ "swap",
+ "synchronize",
+ "take",
+ "tell",
+ "terminate",
+ "test",
+ "throw",
+ "toggle",
+ "trace",
+ "track",
+ "train",
+ "transform",
+ "translate",
+ "traverse",
+ "trigger",
+ "trim",
+ "truncate",
+ "try",
+ "turn",
+ "undo",
+ "unlink",
+ "unpack",
+ "unquote",
+ "unregister",
+ "unset",
+ "unsubscribe",
+ "unwrap",
+ "update",
+ "upload",
+ "use",
+ "validate",
+ "verify",
+ "visit",
+ "wait",
+ "walk",
+ "warn",
+ "watch",
+ "wrap",
+ "write",
+ "yield",
+ }
+)
diff --git a/src/linter/rules/args.py b/src/linter/rules/args.py
index 4f7d834..c86a880 100644
--- a/src/linter/rules/args.py
+++ b/src/linter/rules/args.py
@@ -5,11 +5,25 @@
from linter.config import Policy
from ._base import make_error
+from ._types import types_match
if TYPE_CHECKING:
from linter.models import CodeEntity, LintError, ParsedDocstring
+def _bare(name: str) -> str:
+ """Return a parameter name without the stars of *args or **kwargs.
+
+ Args:
+ name (str): Parameter name as written.
+
+ Returns:
+ str: Name without leading stars.
+
+ """
+ return name.lstrip("*")
+
+
def check_return_type_annotation(entity: CodeEntity) -> list[LintError]:
"""Check that function or method has -> type annotation.
@@ -48,11 +62,15 @@ def check_args_section(entity: CodeEntity, parsed_doc: ParsedDocstring | None, p
if policy is Policy.OPTIONAL:
return []
- documented = {a.name for a in parsed_doc.args}
- return [make_error(entity, "args_section", f"Arg '{arg.name}' in signature but not documented.") for arg in entity.args if arg.name not in documented]
+ # stars are compared by args_match, a starless entry still documents *args
+ documented = {_bare(a.name) for a in parsed_doc.args}
+ # a Keyword Args section documents the keys of **kwargs
+ if parsed_doc.keyword_args:
+ documented |= {_bare(arg.name) for arg in entity.args if arg.name.startswith("**")}
+ return [make_error(entity, "args_section", f"Arg '{arg.name}' in signature but not documented.") for arg in entity.args if _bare(arg.name) not in documented]
-def check_args_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None, types: Policy) -> list[LintError]:
+def check_args_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None, types: Policy, type_matching: str, stars: Policy) -> list[LintError]: # noqa: C901 # pylint: disable=too-many-branches
"""Check documented args against the signature.
Only covers what the docstring declares. Undocumented args are
@@ -62,31 +80,45 @@ def check_args_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None, typ
entity (CodeEntity): Entity to check.
parsed_doc (ParsedDocstring | None): Parsed docstring.
types (Policy): Policy for the type between parentheses.
+ type_matching (str): How closely a documented type must match the annotation.
+ stars (Policy): Policy for the stars of *args and **kwargs entries.
Returns:
- list[LintError]: Errors for phantom, mistyped, or undescribed args.
+ list[LintError]: Errors for phantom, mistyped, misspelled or undescribed args.
"""
- if parsed_doc is None or not parsed_doc.args:
+ if parsed_doc is None:
return []
- errors: list[LintError] = []
- sig_args = {a.name: a for a in entity.args}
+ # keys of **kwargs are not in the signature, only their description is checked
+ errors = [make_error(entity, "args_match", f"Keyword arg '{kwarg.name}' missing description.") for kwarg in parsed_doc.keyword_args if not kwarg.description]
+ sig_args = {_bare(a.name): a for a in entity.args}
for doc_arg in parsed_doc.args:
- sig_arg = sig_args.get(doc_arg.name)
+ sig_arg = sig_args.get(_bare(doc_arg.name))
if sig_arg is None:
errors.append(make_error(entity, "args_match", f"Arg '{doc_arg.name}' documented but not in signature."))
continue
+ # stars only belong to *args and **kwargs, where the policy decides whether to write them
+ starless = _bare(sig_arg.name)
+ if sig_arg.name == starless or stars is Policy.REQUIRED:
+ expected = sig_arg.name
+ elif stars is Policy.FORBIDDEN:
+ expected = starless
+ else:
+ expected = doc_arg.name
+ if doc_arg.name != expected:
+ errors.append(make_error(entity, "args_match", f"Arg '{doc_arg.name}' must be written '{expected}'."))
+
if types is Policy.REQUIRED and doc_arg.type_annotation is None:
errors.append(make_error(entity, "args_match", f"Arg '{doc_arg.name}' missing type. Expected '({sig_arg.type_annotation})'."))
if types is Policy.FORBIDDEN and doc_arg.type_annotation is not None:
errors.append(make_error(entity, "args_match", f"Arg '{doc_arg.name}' must not declare a type."))
- if doc_arg.type_annotation and sig_arg.type_annotation and doc_arg.type_annotation != sig_arg.type_annotation:
+ if doc_arg.type_annotation and sig_arg.type_annotation and not types_match(doc_arg.type_annotation, sig_arg.type_annotation, type_matching):
errors.append(make_error(entity, "args_match", f"Arg '{doc_arg.name}' type mismatch: signature='{sig_arg.type_annotation}', docstring='{doc_arg.type_annotation}'."))
if not doc_arg.description:
@@ -201,16 +233,18 @@ def check_init_returns_none(entity: CodeEntity, parsed_doc: ParsedDocstring | No
return []
-def check_returns_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None, descriptions: Policy) -> list[LintError]:
+def check_returns_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None, descriptions: Policy, types: Policy, type_matching: str) -> list[LintError]:
"""Check that an existing Returns section matches the signature and is described.
- A documented type of None is exempt from the description policy: the type
- is the whole content of the line.
+ A documented type of None is exempt from the description and type policies:
+ the type is the whole content of the line.
Args:
entity (CodeEntity): Entity to check.
parsed_doc (ParsedDocstring | None): Parsed docstring.
descriptions (Policy): Policy for the description of an entry.
+ types (Policy): Policy for the type on the Returns line.
+ type_matching (str): How closely a documented type must match the annotation.
Returns:
list[LintError]: Errors if the Returns type or description is wrong.
@@ -223,15 +257,18 @@ def check_returns_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None,
declared = parsed_doc.returns.type_annotation
- if declared and declared != entity.return_type:
+ if declared and not types_match(declared, entity.return_type, type_matching):
errors.append(make_error(entity, "returns_match", f"Return type mismatch: signature='{entity.return_type}', docstring='{declared}'."))
- if not declared:
+ if not declared and types is Policy.REQUIRED:
errors.append(make_error(entity, "returns_match", f"Missing type in 'Returns:'. Expected '{entity.return_type}'."))
if declared == "None":
return errors
+ if declared and types is Policy.FORBIDDEN:
+ errors.append(make_error(entity, "returns_match", "'Returns:' must not declare a type."))
+
if descriptions is Policy.REQUIRED and not parsed_doc.returns.description:
errors.append(make_error(entity, "returns_match", "Missing description in 'Returns:'."))
@@ -263,13 +300,14 @@ def check_yields_section(entity: CodeEntity, parsed_doc: ParsedDocstring | None,
return []
-def check_yields_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None, descriptions: Policy) -> list[LintError]:
- """Check that an existing Yields section declares a type and a description.
+def check_yields_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None, descriptions: Policy, types: Policy) -> list[LintError]:
+ """Check the type and description of an existing Yields section.
Args:
entity (CodeEntity): Entity to check.
parsed_doc (ParsedDocstring | None): Parsed docstring.
descriptions (Policy): Policy for the description of an entry.
+ types (Policy): Policy for the type on the Yields line.
Returns:
list[LintError]: Errors if the Yields type or description is missing.
@@ -280,9 +318,12 @@ def check_yields_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None, d
errors: list[LintError] = []
- if not parsed_doc.yields.type_annotation:
+ if types is Policy.REQUIRED and not parsed_doc.yields.type_annotation:
errors.append(make_error(entity, "yields_match", "Missing type in 'Yields:'."))
+ if types is Policy.FORBIDDEN and parsed_doc.yields.type_annotation:
+ errors.append(make_error(entity, "yields_match", "'Yields:' must not declare a type."))
+
if descriptions is Policy.REQUIRED and not parsed_doc.yields.description:
errors.append(make_error(entity, "yields_match", "Missing description in 'Yields:'."))
@@ -307,11 +348,12 @@ def check_args_order(entity: CodeEntity, parsed_doc: ParsedDocstring | None) ->
return []
sig_names = [a.name for a in entity.args]
- doc_names = [a.name for a in parsed_doc.args if a.name in sig_names]
+ bare_sig_names = [_bare(name) for name in sig_names]
+ documented = [a.name for a in parsed_doc.args if _bare(a.name) in bare_sig_names]
- if doc_names != sig_names[: len(doc_names)]:
+ if [_bare(name) for name in documented] != bare_sig_names[: len(documented)]:
expected = ", ".join(sig_names)
- got = ", ".join(doc_names)
+ got = ", ".join(documented)
return [make_error(entity, "args_order", f"Args order in docstring differs from signature. Expected: {expected}. Got: {got}.")]
return []
@@ -333,9 +375,9 @@ def check_duplicate_arg(entity: CodeEntity, parsed_doc: ParsedDocstring | None)
seen: set[str] = set()
errors: list[LintError] = []
for arg in parsed_doc.args:
- if arg.name in seen:
+ if _bare(arg.name) in seen:
errors.append(make_error(entity, "duplicate_arg", f"Arg '{arg.name}' documented more than once in 'Args:'."))
- seen.add(arg.name)
+ seen.add(_bare(arg.name))
return errors
@@ -362,28 +404,43 @@ def check_raises_section(entity: CodeEntity, parsed_doc: ParsedDocstring | None,
if policy is Policy.OPTIONAL:
return []
- documented = {r.exception_type for r in parsed_doc.raises}
+ # the code side already holds the last segment of a dotted name
+ documented = {r.exception_type.rsplit(".", 1)[-1] for r in parsed_doc.raises}
return [make_error(entity, "raises_section", f"'{exc}' raised in code but not documented in 'Raises:'.") for exc in sorted({r.exception_type for r in entity.raises} - documented)]
def check_raises_match(entity: CodeEntity, parsed_doc: ParsedDocstring | None) -> list[LintError]:
- """Check documented exceptions against the raise statements in the code.
-
- Only covers what the docstring declares. Undocumented raises are
- reported by the raises_section policy.
+ """Check that every documented exception carries a description.
Args:
entity (CodeEntity): Entity to check.
parsed_doc (ParsedDocstring | None): Parsed docstring.
Returns:
- list[LintError]: Errors for exceptions never raised or left undescribed.
+ list[LintError]: Errors for exceptions left undescribed.
"""
- if parsed_doc is None or not parsed_doc.raises:
+ if parsed_doc is None:
return []
+ return [make_error(entity, "raises_match", f"'{doc_raise.exception_type}' missing description in 'Raises:'.") for doc_raise in parsed_doc.raises if not doc_raise.description]
+
+
+def check_raises_extraneous(entity: CodeEntity, parsed_doc: ParsedDocstring | None) -> list[LintError]:
+ """Check that every documented exception is raised explicitly in the body.
+
+ An exception propagated from a called function is invisible to the
+ linter, so a project documenting those turns this rule off.
+
+ Args:
+ entity (CodeEntity): Entity to check.
+ parsed_doc (ParsedDocstring | None): Parsed docstring.
+
+ Returns:
+ list[LintError]: Errors for exceptions documented but never raised.
+ """
+ if parsed_doc is None:
+ return []
code_raises = {r.exception_type for r in entity.raises}
- errors = [make_error(entity, "raises_match", f"'{exc}' documented in 'Raises:' but not raised in code.") for exc in sorted({r.exception_type for r in parsed_doc.raises} - code_raises)]
- errors.extend(make_error(entity, "raises_match", f"'{doc_raise.exception_type}' missing description in 'Raises:'.") for doc_raise in parsed_doc.raises if not doc_raise.description)
- return errors
+ never_raised = sorted({r.exception_type for r in parsed_doc.raises if r.exception_type.rsplit(".", 1)[-1] not in code_raises})
+ return [make_error(entity, "raises_extraneous", f"'{exc}' documented in 'Raises:' but not raised in code.") for exc in never_raised]
diff --git a/src/linter/rules/docstring.py b/src/linter/rules/docstring.py
index d09a77d..3052431 100644
--- a/src/linter/rules/docstring.py
+++ b/src/linter/rules/docstring.py
@@ -5,47 +5,13 @@
from linter.config import Policy
from ._base import make_error
+from ._verbs import VERBS
if TYPE_CHECKING:
from linter.models import CodeEntity, LintError, ParsedDocstring
-_IMPERATIVE_EXCEPTIONS = frozenset(
- {
- "this",
- "its",
- "has",
- "was",
- "is",
- "alias",
- "unless",
- "class",
- "less",
- "pass",
- "across",
- "process",
- "access",
- "address",
- "express",
- "progress",
- "success",
- "stress",
- "bonus",
- "bus",
- "plus",
- "focus",
- "status",
- "synopsis",
- "basis",
- "analysis",
- "hypothesis",
- "diagnosis",
- "consensus",
- "opus",
- "corpus",
- "terminus",
- "axis",
- }
-)
+# Third-person forms the suffix rules cannot turn back into the base form
+_IRREGULAR_VERBS = {"does": "do", "goes": "go", "undoes": "undo"}
def check_docstring_exists(entity: CodeEntity) -> list[LintError]:
@@ -151,33 +117,36 @@ def check_summary_on_first_line(entity: CodeEntity, policy: Policy) -> list[Lint
def _to_imperative(word: str) -> str | None:
"""Convert a third-person verb to imperative form.
+ The word is reported only when a base form derived from it is a known
+ verb, so that plural nouns such as 'Options' or 'Classes' pass.
+
Args:
- word (str): Verb to convert.
+ word (str): First word of the summary.
Returns:
str | None: Imperative form, or None if not a third-person verb.
"""
lower = word.lower()
-
- if lower in _IMPERATIVE_EXCEPTIONS:
+ if not lower.isalpha():
return None
- quantity = 4
- if lower.endswith("ies") and len(lower) > quantity:
- return word[:-3] + "y"
-
- if lower.endswith(("ches", "shes", "sses", "xes", "zes")):
- return word[:-2]
-
- quantity = 3
- if lower.endswith("es") and len(lower) > quantity and lower[-3] not in "aeiou":
- return word[:-1]
-
- if lower.endswith("s") and not lower.endswith(("ss", "us", "is", "os", "ws")) and len(lower) > quantity:
- return word[:-1]
+ if lower in _IRREGULAR_VERBS:
+ candidates = [_IRREGULAR_VERBS[lower]]
+ elif lower.endswith("ies"):
+ candidates = [lower[:-3] + "y"]
+ elif lower.endswith("es"):
+ # 'processes' drops 'es', 'creates' drops 's'
+ candidates = [lower[:-2], lower[:-1]]
+ elif lower.endswith("s") and not lower.endswith("ss"):
+ candidates = [lower[:-1]]
+ else:
+ return None
- return None
+ base = next((candidate for candidate in candidates if candidate in VERBS), None)
+ if base is None:
+ return None
+ return base.capitalize() if word[0].isupper() else base
def check_summary_too_long(entity: CodeEntity, parsed_doc: ParsedDocstring | None, max_length: int) -> list[LintError]:
diff --git a/src/linter/rules/structure.py b/src/linter/rules/structure.py
index 082db59..56055ca 100644
--- a/src/linter/rules/structure.py
+++ b/src/linter/rules/structure.py
@@ -4,9 +4,10 @@
from linter.config import Policy
from linter.models import CodeEntity, LintError, NodeType
-from linter.rules._base import GOOGLE_SECTION_ORDER, GOOGLE_SECTIONS, SECTION_HEADER_RE, extract_section_headers, make_error
+from linter.rules._base import extract_section_headers, make_error
+from linter.sections import ENTRY_SECTIONS, KNOWN_SECTIONS, SECTION_ALIASES, SECTION_HEADER_RE, SECTION_ORDER, canonical_section, section_header
-_SECTION_WITH_ENTRIES = frozenset({"Args", "Attributes", "Raises"})
+_SECTION_INDENT = 4
_ENTRY_LAX = re.compile(r"^\s{4}(\*{0,2}\w+)\s*(\([^)]*\))?\s*:\s*(.*)$")
_ENTRY_LAX_NO_COLON = re.compile(r"^\s{4}(\*{0,2}\w+)\s*(\([^)]+\))\s*$")
@@ -14,33 +15,48 @@
def check_indentation(entity: CodeEntity) -> list[LintError]:
- """Check docstring indentation consistency.
+ """Check that the content of each section is indented under its header.
+
+ Inside a section, every line is indented by 4 spaces or more, and the first
+ entry of Args, Attributes and Raises by exactly 4. Lines outside sections are
+ not checked: a description may hold indented code or lists.
Args:
entity (CodeEntity): Entity to check.
Returns:
- list[LintError]: Errors if indentation is inconsistent.
+ list[LintError]: One error per section whose content is misindented.
"""
if not entity.docstring:
return []
- lines = entity.docstring.split("\n")
- if len(lines) <= 1:
- return []
+ errors: list[LintError] = []
+ section: str | None = None
+ first_line = False
+ reported = False
- indents: set[int] = set()
- for line in lines[1:]:
- if not line.strip():
+ for line in entity.docstring.split("\n")[1:]:
+ stripped = line.strip()
+ if not stripped:
continue
- leading = len(line) - len(line.lstrip())
- indents.add(leading)
- quantity = 2
- if len(indents) > quantity:
- return [make_error(entity, "indentation", "Inconsistent indentation in docstring.")]
- return []
+ indent = len(line) - len(line.lstrip())
+ header = section_header(line)
+ if indent == 0 and header:
+ section, first_line, reported = header, True, False
+ continue
+
+ if section and not reported:
+ if 0 < indent < _SECTION_INDENT:
+ errors.append(make_error(entity, "indentation", f"Line '{stripped[:30]}' in '{section}:' is indented by {indent} spaces, expected at least {_SECTION_INDENT}."))
+ reported = True
+ elif first_line and _is_entry_section(section) and indent != _SECTION_INDENT:
+ errors.append(make_error(entity, "indentation", f"First entry of '{section}:' is indented by {indent} spaces, expected {_SECTION_INDENT}."))
+ reported = True
+ first_line = False
+
+ return errors
def check_section_capitalization(entity: CodeEntity) -> list[LintError]:
@@ -57,7 +73,7 @@ def check_section_capitalization(entity: CodeEntity) -> list[LintError]:
return []
errors: list[LintError] = []
- lowercase_sections = {s.lower(): s for s in GOOGLE_SECTIONS}
+ lowercase_sections = {s.lower(): s for s in KNOWN_SECTIONS}
for line in entity.docstring.split("\n"):
match = SECTION_HEADER_RE.match(line.strip())
@@ -74,6 +90,21 @@ def check_section_capitalization(entity: CodeEntity) -> list[LintError]:
return errors
+def check_section_alias(entity: CodeEntity) -> list[LintError]:
+ """Check that sections use their canonical name rather than a Napoleon alias.
+
+ Args:
+ entity (CodeEntity): Entity to check.
+
+ Returns:
+ list[LintError]: Errors for every section written with an alias.
+
+ """
+ if not entity.docstring:
+ return []
+ return [make_error(entity, "section_alias", f"Section '{name}:' should be written '{SECTION_ALIASES[name]}:'.") for name in extract_section_headers(entity.docstring) if name in SECTION_ALIASES]
+
+
def check_section_order(entity: CodeEntity) -> list[LintError]:
"""Check that sections appear in the correct order.
@@ -91,12 +122,14 @@ def check_section_order(entity: CodeEntity) -> list[LintError]:
if len(found_sections) <= 1:
return []
- order_map = {name: idx for idx, name in enumerate(GOOGLE_SECTION_ORDER)}
+ order_map = {name: idx for idx, name in enumerate(SECTION_ORDER)}
+ canonical_found = {canonical_section(name) for name in found_sections}
prev_idx = -1
prev_name = ""
for section in found_sections:
- idx = order_map.get(section, -1)
+ # free-text sections have no position
+ idx = order_map.get(canonical_section(section), -1)
if idx == -1:
continue
if idx < prev_idx:
@@ -104,7 +137,7 @@ def check_section_order(entity: CodeEntity) -> list[LintError]:
make_error(
entity,
"section_order",
- f"Section '{section}:' must come before '{prev_name}:'. Expected order: {', '.join(s for s in GOOGLE_SECTION_ORDER if s in found_sections)}.",
+ f"Section '{section}:' must come before '{prev_name}:'. Expected order: {', '.join(s for s in SECTION_ORDER if s in canonical_found)}.",
)
]
prev_idx = idx
@@ -130,20 +163,15 @@ def check_empty_section(entity: CodeEntity) -> list[LintError]:
lines = entity.docstring.split("\n")
for i, line in enumerate(lines):
- match = SECTION_HEADER_RE.match(line.strip())
- if not match or match.group(1) not in GOOGLE_SECTIONS:
+ section_name = section_header(line)
+ if section_name is None:
continue
- section_name = match.group(1)
has_content = False
for next_line in lines[i + 1 :]:
- stripped = next_line.strip()
- if not stripped:
+ if not next_line.strip():
continue
- next_match = SECTION_HEADER_RE.match(stripped)
- if next_match and next_match.group(1) in GOOGLE_SECTIONS:
- break
- has_content = True
+ has_content = section_header(next_line) is None
break
if not has_content:
@@ -190,8 +218,20 @@ def _is_section_header(line: str) -> bool:
bool: True if the line is a Google section header.
"""
- match = SECTION_HEADER_RE.match(line.strip())
- return match is not None and match.group(1) in GOOGLE_SECTIONS
+ return section_header(line) is not None
+
+
+def _is_entry_section(name: str | None) -> bool:
+ """Check whether a section is made of 'name (type): description' entries.
+
+ Args:
+ name (str | None): Section name as written, None outside any section.
+
+ Returns:
+ bool: True for Args, Attributes, Raises and their aliases.
+
+ """
+ return name is not None and canonical_section(name) in ENTRY_SECTIONS
def _check_after_summary(entity: CodeEntity) -> list[LintError]:
@@ -250,8 +290,8 @@ def _check_before_sections(entity: CodeEntity, expected: int) -> list[LintError]
lines = entity.docstring.split("\n")
for i, line in enumerate(lines):
- match = SECTION_HEADER_RE.match(line.strip())
- if not match or match.group(1) not in GOOGLE_SECTIONS or i == 0:
+ header = section_header(line)
+ if header is None or i == 0:
continue
found = 0
@@ -259,7 +299,7 @@ def _check_before_sections(entity: CodeEntity, expected: int) -> list[LintError]
found += 1
if found != expected:
- errors.append(make_error(entity, "blank_lines", f"Expected {expected} {_plural(expected)} before '{match.group(1)}:' section, found {found}."))
+ errors.append(make_error(entity, "blank_lines", f"Expected {expected} {_plural(expected)} before '{header}:' section, found {found}."))
return errors
@@ -281,6 +321,8 @@ def _check_before_closing_quotes(entity: CodeEntity, expected: int) -> list[Lint
stripped = entity.raw_docstring.rstrip(" \t")
found = len(stripped) - len(stripped.rstrip("\n")) - 1
+ if found < 0:
+ return [make_error(entity, "blank_lines", 'Closing """ must be on its own line.')]
if found != expected:
return [make_error(entity, "blank_lines", f'Expected {expected} {_plural(expected)} before closing """, found {found}.')]
return []
@@ -331,12 +373,12 @@ def check_entry_spacing(entity: CodeEntity) -> list[LintError]:
current_section: str | None = None
for line in entity.docstring.split("\n"):
- match = SECTION_HEADER_RE.match(line.strip())
- if match and match.group(1) in GOOGLE_SECTIONS:
- current_section = match.group(1)
+ header = section_header(line)
+ if header:
+ current_section = header
continue
- if current_section not in _SECTION_WITH_ENTRIES:
+ if not _is_entry_section(current_section):
continue
entry = _ENTRY_LAX.match(line) or _ENTRY_LAX_NO_COLON.match(line)
@@ -371,14 +413,14 @@ def check_no_blank_line_in_section(entity: CodeEntity) -> list[LintError]:
for line in lines:
stripped = line.strip()
- match = SECTION_HEADER_RE.match(stripped)
- if match and match.group(1) in GOOGLE_SECTIONS:
- current_section = match.group(1)
+ header = section_header(line)
+ if header:
+ current_section = header
in_section_content = False
pending_blank = False
continue
- if current_section not in _SECTION_WITH_ENTRIES:
+ if not _is_entry_section(current_section):
pending_blank = False
continue
diff --git a/src/linter/sections.py b/src/linter/sections.py
new file mode 100644
index 0000000..ffed046
--- /dev/null
+++ b/src/linter/sections.py
@@ -0,0 +1,93 @@
+"""Section headers recognized in Google style docstrings.
+
+Shared by the docstring parser and the structure rules, so that both agree on
+what a section header is.
+"""
+
+import re
+
+# Canonical sections, in the order the section_order rule expects
+SECTION_ORDER = [
+ "Attributes",
+ "Args",
+ "Keyword Args",
+ "Other Parameters",
+ "Returns",
+ "Yields",
+ "Raises",
+ "Examples",
+ "Note",
+ "Notes",
+ "Todo",
+]
+
+# Napoleon spellings read as a canonical section and reported by section_alias.
+# Note and Notes stay distinct: Napoleon renders the first as an admonition.
+SECTION_ALIASES = {
+ "Example": "Examples",
+ "Arguments": "Args",
+ "Parameters": "Args",
+ "Keyword Arguments": "Keyword Args",
+ "Return": "Returns",
+ "Yield": "Yields",
+ "Raise": "Raises",
+}
+
+# Free-text sections: accepted, content not checked, outside section_order.
+# Napoleon reads Warn and Warns like Raises, for warnings: their entries are not checked yet.
+FREE_TEXT_SECTIONS = frozenset(
+ {
+ "Attention",
+ "Caution",
+ "Danger",
+ "Error",
+ "Hint",
+ "Important",
+ "Methods",
+ "Receive",
+ "Receives",
+ "References",
+ "See Also",
+ "Tip",
+ "Warn",
+ "Warning",
+ "Warnings",
+ "Warns",
+ }
+)
+
+KNOWN_SECTIONS = frozenset(SECTION_ORDER) | frozenset(SECTION_ALIASES) | FREE_TEXT_SECTIONS
+
+# Sections made of 'name (type): description' entries
+ENTRY_SECTIONS = frozenset({"Args", "Keyword Args", "Other Parameters", "Attributes", "Raises"})
+
+SECTION_HEADER_RE = re.compile(r"^([A-Za-z]+(?: [A-Za-z]+)*):\s*$")
+
+
+def canonical_section(name: str) -> str:
+ """Return the canonical name of a section, resolving Napoleon aliases.
+
+ Args:
+ name (str): Section name as written.
+
+ Returns:
+ str: Canonical name, or the name unchanged when it is not an alias.
+
+ """
+ return SECTION_ALIASES.get(name, name)
+
+
+def section_header(line: str) -> str | None:
+ """Return the section a docstring line opens, if any.
+
+ Args:
+ line (str): Docstring line, indentation included.
+
+ Returns:
+ str | None: Section name as written, or None if the line is not a known header.
+
+ """
+ match = SECTION_HEADER_RE.match(line.strip())
+ if match and match.group(1) in KNOWN_SECTIONS:
+ return match.group(1)
+ return None
diff --git a/tests/linter/rules/test_rules_args.py b/tests/linter/rules/test_rules_args.py
index c5c39bd..5ca23ca 100644
--- a/tests/linter/rules/test_rules_args.py
+++ b/tests/linter/rules/test_rules_args.py
@@ -1,5 +1,6 @@
"""Tests for rules/args.py -- return type, args match, returns section, raises match."""
+import pytest
from linter.config import Policy
from linter.models import ArgInfo, DocstringArg, DocstringRaise, DocstringReturn, NodeType, ParsedDocstring, RaiseInfo
@@ -236,14 +237,133 @@ def test_returns_match_mismatch() -> None:
assert any(e.rule == "returns_match" and "mismatch" in e.message for e in errors)
+def test_args_section_kwargs_documented_by_keyword_args() -> None:
+ """**kwargs in the signature and a Keyword Args section: **kwargs counts as documented."""
+ entity = _func(args=[ArgInfo(name="**options", type_annotation="int")])
+ doc = ParsedDocstring(summary="Do something.", keyword_args=[DocstringArg(name="width", description="Width.")])
+ assert not validate_entity(entity, doc, _policy_only("args_section", Policy.REQUIRED))
+
+
+def test_args_match_keyword_arg_missing_description() -> None:
+ """Keyword Args entry without a description: returns args_match error, its name is not compared with the signature."""
+ entity = _func(args=[ArgInfo(name="**options")])
+ doc = ParsedDocstring(summary="Do something.", keyword_args=[DocstringArg(name="width", description="")])
+ errors = validate_entity(entity, doc, _neutral())
+ assert [e.message for e in errors] == ["Keyword arg 'width' missing description."]
+
+
+def _star_errors(documented: str, stars: Policy, signature: str = "*items") -> list[str]:
+ """Validate one Args entry against one starred or plain parameter and return the messages."""
+ entity = _func(args=[ArgInfo(name=signature, type_annotation="int")])
+ doc = ParsedDocstring(summary="Do something.", args=[DocstringArg(name=documented, type_annotation="int", description="Items.")])
+ cfg = _neutral(args_section=Policy.REQUIRED, documented_stars=stars, enabled_rules=["args_order", "duplicate_arg"])
+ return [e.message for e in validate_entity(entity, doc, cfg)]
+
+
+@pytest.mark.parametrize(
+ ("documented", "stars", "expected"),
+ [
+ ("*items", Policy.REQUIRED, []),
+ ("items", Policy.REQUIRED, ["Arg 'items' must be written '*items'."]),
+ ("items", Policy.FORBIDDEN, []),
+ ("*items", Policy.FORBIDDEN, ["Arg '*items' must be written 'items'."]),
+ ("items", Policy.OPTIONAL, []),
+ ("*items", Policy.OPTIONAL, []),
+ ],
+)
+def test_documented_stars_policy(documented: str, stars: Policy, expected: list[str]) -> None:
+ """Starred parameter documented with or without stars: one explicit error when the policy is not met, nothing else."""
+ assert _star_errors(documented, stars) == expected
+
+
+@pytest.mark.parametrize("stars", [Policy.REQUIRED, Policy.FORBIDDEN, Policy.OPTIONAL])
+def test_documented_stars_on_plain_parameter(stars: Policy) -> None:
+ """Plain parameter documented with a star: reported whatever the policy."""
+ assert _star_errors("*items", stars, signature="items") == ["Arg '*items' must be written 'items'."]
+
+
+def test_documented_stars_order_uses_bare_names() -> None:
+ """Starless entry in signature order: no args_order error."""
+ entity = _func(args=[ArgInfo(name="*items", type_annotation="int"), ArgInfo(name="size", type_annotation="int")])
+ doc = ParsedDocstring(
+ summary="Do something.",
+ args=[DocstringArg(name="items", type_annotation="int", description="Items."), DocstringArg(name="size", type_annotation="int", description="Size.")],
+ )
+ assert not validate_entity(entity, doc, _neutral(documented_stars=Policy.OPTIONAL, enabled_rules=["args_order"]))
+
+
+def test_documented_stars_duplicate() -> None:
+ """'items' then '*items' in the same section: reported as a duplicate."""
+ entity = _func(args=[ArgInfo(name="*items", type_annotation="int")])
+ doc = ParsedDocstring(
+ summary="Do something.",
+ args=[DocstringArg(name="items", type_annotation="int", description="Items."), DocstringArg(name="*items", type_annotation="int", description="Again.")],
+ )
+ errors = validate_entity(entity, doc, _neutral(documented_stars=Policy.OPTIONAL))
+ assert [(e.rule, e.message) for e in errors] == [("duplicate_arg", "Arg '*items' documented more than once in 'Args:'.")]
+
+
def test_returns_match_missing_type() -> None:
- """Returns section present but no type declared: returns returns_match error."""
+ """documented_types = required, Returns section without a type: returns returns_match error."""
entity = _func(return_type="int")
- doc = ParsedDocstring(summary="Do something.", returns=DocstringReturn(type_annotation=None))
- errors = validate_entity(entity, doc, _neutral())
+ doc = ParsedDocstring(summary="Do something.", returns=DocstringReturn(type_annotation=None, description="The value."))
+ errors = validate_entity(entity, doc, _neutral(documented_types=Policy.REQUIRED))
assert any(e.rule == "returns_match" and "Missing type" in e.message for e in errors)
+def test_returns_match_type_optional() -> None:
+ """documented_types = optional, Returns section without a type: no returns_match error."""
+ entity = _func(return_type="int")
+ doc = ParsedDocstring(summary="Do something.", returns=DocstringReturn(type_annotation=None, description="The value."))
+ assert not [e for e in validate_entity(entity, doc, _neutral()) if e.rule == "returns_match"]
+
+
+def test_returns_match_type_forbidden() -> None:
+ """documented_types = forbidden, Returns line carrying a type: returns returns_match error."""
+ entity = _func(return_type="int")
+ doc = ParsedDocstring(summary="Do something.", returns=DocstringReturn(type_annotation="int", description="The value."))
+ errors = validate_entity(entity, doc, _neutral(documented_types=Policy.FORBIDDEN))
+ assert any(e.rule == "returns_match" and "must not declare a type" in e.message for e in errors)
+
+
+def test_returns_match_type_forbidden_allows_none() -> None:
+ """documented_types = forbidden, 'Returns: None': None is the whole line, no error."""
+ entity = _func(return_type="None")
+ doc = ParsedDocstring(summary="Do something.", returns=DocstringReturn(type_annotation="None"))
+ errors = validate_entity(entity, doc, _neutral(documented_types=Policy.FORBIDDEN))
+ assert not [e for e in errors if e.rule == "returns_match"]
+
+
+@pytest.mark.parametrize(
+ ("level", "reported"),
+ [("strict", True), ("equivalent", False), ("lenient", False)],
+)
+def test_returns_match_type_matching_levels(level: str, reported: bool) -> None: # noqa: FBT001
+ """Signature Optional[str], docstring 'str | None': a mismatch only at the strict level."""
+ entity = _func(return_type="Optional[str]")
+ doc = ParsedDocstring(summary="Do something.", returns=DocstringReturn(type_annotation="str | None", description="The value."))
+ errors = validate_entity(entity, doc, _neutral(type_matching=level))
+ assert any("type mismatch" in e.message for e in errors) is reported
+
+
+@pytest.mark.parametrize(
+ ("documented", "level", "reported"),
+ [
+ ("int | None, optional", "strict", False),
+ ("Optional[int]", "strict", True),
+ ("Optional[int]", "equivalent", False),
+ ("int", "equivalent", True),
+ ("int", "lenient", False),
+ ],
+)
+def test_args_match_type_matching_levels(documented: str, level: str, reported: bool) -> None: # noqa: FBT001
+ """Signature 'int | None': each level accepts what the previous ones accept, and more."""
+ entity = _func(args=[ArgInfo(name="limit", type_annotation="int | None", default="None")])
+ doc = ParsedDocstring(summary="Do something.", args=[DocstringArg(name="limit", type_annotation=documented, description="Limit.")])
+ errors = validate_entity(entity, doc, _neutral(type_matching=level))
+ assert any("type mismatch" in e.message for e in errors) is reported
+
+
def test_returns_match_no_section_no_error() -> None:
"""No Returns section: returns_match does not flag a missing section."""
entity = _func(return_type="int")
@@ -436,12 +556,12 @@ def test_raises_section_optional_undocumented() -> None:
def test_raises_section_optional_still_checks_documented() -> None:
- """Policy optional, an exception documented but never raised: raises_match still reports it."""
+ """Policy optional, an exception documented but never raised: raises_extraneous still reports it."""
entity = _func(raises=[])
doc = ParsedDocstring(summary="Do something.", raises=[DocstringRaise(exception_type="TypeError", description="Never.")])
- cfg = _neutral(enabled_rules=["raises_match"], raises_section=Policy.OPTIONAL)
+ cfg = _neutral(enabled_rules=["raises_extraneous"], raises_section=Policy.OPTIONAL)
errors = validate_entity(entity, doc, cfg)
- assert any(e.rule == "raises_match" and "TypeError" in e.message for e in errors)
+ assert any(e.rule == "raises_extraneous" and "TypeError" in e.message for e in errors)
def test_raises_section_forbidden_present() -> None:
@@ -452,12 +572,19 @@ def test_raises_section_forbidden_present() -> None:
assert any(e.rule == "raises_section" and "not allowed" in e.message for e in errors)
-def test_raises_match_phantom_documented() -> None:
- """Raise in docstring but not in code: returns raises_match error."""
+def test_raises_extraneous_documented_not_raised() -> None:
+ """Raise in docstring but not in code: returns raises_extraneous error."""
entity = _func(raises=[])
doc = ParsedDocstring(summary="Do something.", raises=[DocstringRaise(exception_type="ValueError", description="If invalid.")])
- errors = validate_entity(entity, doc, _rule_only("raises_match"))
- assert any("ValueError" in e.message and "not raised" in e.message for e in errors)
+ errors = validate_entity(entity, doc, _rule_only("raises_extraneous"))
+ assert any(e.rule == "raises_extraneous" and "ValueError" in e.message and "not raised" in e.message for e in errors)
+
+
+def test_raises_extraneous_disabled() -> None:
+ """Rule off, exception documented but propagated from a callee: no error."""
+ entity = _func(raises=[])
+ doc = ParsedDocstring(summary="Do something.", raises=[DocstringRaise(exception_type="ValueError", description="From int().")])
+ assert not validate_entity(entity, doc, _neutral())
def test_raises_match_missing_description() -> None:
@@ -504,14 +631,29 @@ def test_yields_section_forbidden_present() -> None:
def test_yields_match_missing_type() -> None:
- """Yields section without a type: returns yields_match error."""
+ """documented_types = required, Yields section without a type: returns yields_match error."""
entity = _func(is_generator=True)
doc = ParsedDocstring(summary="Do something.", yields=DocstringReturn(type_annotation=None))
- cfg = _neutral(yields_section=Policy.OPTIONAL)
+ cfg = _neutral(yields_section=Policy.OPTIONAL, documented_types=Policy.REQUIRED)
errors = validate_entity(entity, doc, cfg)
assert any(e.rule == "yields_match" and "Missing type" in e.message for e in errors)
+def test_yields_match_type_optional() -> None:
+ """documented_types = optional, Yields section without a type: no yields_match type error."""
+ entity = _func(is_generator=True)
+ doc = ParsedDocstring(summary="Do something.", yields=DocstringReturn(type_annotation=None, description="Each value."))
+ assert not [e for e in validate_entity(entity, doc, _neutral()) if e.rule == "yields_match"]
+
+
+def test_yields_match_type_forbidden() -> None:
+ """documented_types = forbidden, Yields line carrying a type: returns yields_match error."""
+ entity = _func(is_generator=True)
+ doc = ParsedDocstring(summary="Do something.", yields=DocstringReturn(type_annotation="int", description="Each value."))
+ errors = validate_entity(entity, doc, _neutral(documented_types=Policy.FORBIDDEN))
+ assert any(e.rule == "yields_match" and "must not declare a type" in e.message for e in errors)
+
+
def test_yields_match_missing_description() -> None:
"""Yields section without a description: returns yields_match error."""
entity = _func(is_generator=True)
diff --git a/tests/linter/rules/test_rules_docstring.py b/tests/linter/rules/test_rules_docstring.py
index 1b85f93..d4ecf27 100644
--- a/tests/linter/rules/test_rules_docstring.py
+++ b/tests/linter/rules/test_rules_docstring.py
@@ -1,7 +1,8 @@
"""Tests for rules/docstring.py -- docstring presence and summary rules."""
+import pytest
from linter.config import Policy
-from linter.models import ParsedDocstring
+from linter.models import NodeType, ParsedDocstring
from linter.rules import validate_entity
@@ -209,6 +210,32 @@ def test_imperative_mood_es_after_consonant() -> None:
assert any(e.rule == "imperative_mood" for e in errors)
+@pytest.mark.parametrize(
+ ("word", "expected"),
+ [("Does", "Do"), ("Goes", "Go"), ("Initializes", "Initialize"), ("Accesses", "Access"), ("Copies", "Copy"), ("Yields", "Yield")],
+)
+def test_imperative_mood_suggestion(word: str, expected: str) -> None:
+ """Third-person verb, irregular or not: the suggested base form is a real verb."""
+ summary = f"{word} the value."
+ entity = _func(docstring=summary, raw_docstring=summary)
+ errors = validate_entity(entity, ParsedDocstring(summary=summary), _rule_only("imperative_mood"))
+ assert [e.message for e in errors] == [f"Summary should start with imperative mood. '{word}' -> '{expected}'."]
+
+
+@pytest.mark.parametrize("word", ["Options", "Classes", "Canvas", "Settings", "Status", "Always", ":raises", "MIME-types", "Has", "Is"])
+def test_imperative_mood_not_a_verb(word: str) -> None:
+ """Plural noun, word ending in s, or token that is not a word: no imperative_mood error."""
+ summary = f"{word} of the value."
+ entity = _func(docstring=summary, raw_docstring=summary)
+ assert not validate_entity(entity, ParsedDocstring(summary=summary), _rule_only("imperative_mood"))
+
+
+def test_imperative_mood_skips_classes() -> None:
+ """Class docstring starting with a third-person verb: not checked, the rule targets functions and methods."""
+ entity = _func(name="Store", docstring="Represents a store.", raw_docstring="Represents a store.", node_type=NodeType.CLASS)
+ assert not validate_entity(entity, ParsedDocstring(summary="Represents a store."), _rule_only("imperative_mood"))
+
+
# ---------------------------------------------------------------------------
# Rule => summary_too_long
# ---------------------------------------------------------------------------
diff --git a/tests/linter/rules/test_rules_structure.py b/tests/linter/rules/test_rules_structure.py
index 8ce342d..8bdd042 100644
--- a/tests/linter/rules/test_rules_structure.py
+++ b/tests/linter/rules/test_rules_structure.py
@@ -1,5 +1,6 @@
"""Tests for rules/structure.py -- indentation, section layout, closing quotes, blank lines."""
+import pytest
from linter.config import Policy
from linter.models import CodeEntity, NodeType, ParsedDocstring
@@ -12,12 +13,34 @@
# ---------------------------------------------------------------------------
-def test_indentation_inconsistent() -> None:
- """More than 2 indent levels in docstring: returns indentation error."""
- raw = "Summary.\n\nArgs:\n x: Value.\n continuation.\n deep.\n"
+def test_indentation_under_indented_section_line() -> None:
+ """Line of a section indented by 2 spaces: returns one indentation error for the section."""
+ raw = "Summary.\n\nReturns:\n int: Value.\n More.\n"
entity = _func(docstring=raw, raw_docstring=raw)
errors = validate_entity(entity, ParsedDocstring(summary="Summary."), _rule_only("indentation"))
- assert any(e.rule == "indentation" for e in errors)
+ assert [e.message for e in errors if e.rule == "indentation"] == ["Line 'int: Value.' in 'Returns:' is indented by 2 spaces, expected at least 4."]
+
+
+def test_indentation_first_entry_not_at_four() -> None:
+ """First Args entry indented by 8 spaces: returns indentation error."""
+ raw = "Summary.\n\nArgs:\n x (int): Value.\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ errors = validate_entity(entity, ParsedDocstring(summary="Summary."), _rule_only("indentation"))
+ assert [e.message for e in errors if e.rule == "indentation"] == ["First entry of 'Args:' is indented by 8 spaces, expected 4."]
+
+
+def test_indentation_multiline_entry() -> None:
+ """Entry description continued on deeper lines: no indentation error."""
+ raw = "Summary.\n\nArgs:\n x (int): A long description\n that wraps\n and wraps again.\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ assert not [e for e in validate_entity(entity, ParsedDocstring(summary="Summary."), _rule_only("indentation")) if e.rule == "indentation"]
+
+
+def test_indentation_description_block_ignored() -> None:
+ """Indented code block in the description, outside any section: no indentation error."""
+ raw = "Summary.\n\nUsage:\n\n >>> run()\n\nArgs:\n x (int): Value.\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ assert not [e for e in validate_entity(entity, ParsedDocstring(summary="Summary."), _rule_only("indentation")) if e.rule == "indentation"]
def test_indentation_consistent() -> None:
@@ -56,6 +79,35 @@ def test_section_capitalization_correct() -> None:
assert not any(e.rule == "section_capitalization" for e in errors)
+def test_section_capitalization_multi_word() -> None:
+ """Multi-word Napoleon header 'See also:': returns section_capitalization error expecting 'See Also:'."""
+ raw = "Summary.\n\nSee also:\n other().\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ errors = validate_entity(entity, ParsedDocstring(summary="Summary."), _rule_only("section_capitalization"))
+ assert [e.message for e in errors if e.rule == "section_capitalization"] == ["Section 'See also:' should be 'See Also:'."]
+
+
+# ---------------------------------------------------------------------------
+# Rule => section_alias
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(("alias", "canonical"), [("Parameters", "Args"), ("Arguments", "Args"), ("Return", "Returns"), ("Keyword Arguments", "Keyword Args"), ("Example", "Examples")])
+def test_section_alias_reported(alias: str, canonical: str) -> None:
+ """Napoleon alias used as a header: returns section_alias error naming the canonical spelling."""
+ raw = f"Summary.\n\n{alias}:\n x (int): Value.\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ errors = validate_entity(entity, ParsedDocstring(summary="Summary."), _rule_only("section_alias"))
+ assert [e.message for e in errors if e.rule == "section_alias"] == [f"Section '{alias}:' should be written '{canonical}:'."]
+
+
+def test_section_alias_disabled() -> None:
+ """Alias used, rule off: no section_alias error."""
+ raw = "Summary.\n\nParameters:\n x (int): Value.\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ assert not [e for e in validate_entity(entity, ParsedDocstring(summary="Summary."), _neutral()) if e.rule == "section_alias"]
+
+
# ---------------------------------------------------------------------------
# Rule => section_order
# ---------------------------------------------------------------------------
@@ -93,6 +145,37 @@ def test_section_order_single_section_ok() -> None:
assert not any(e.rule == "section_order" for e in errors)
+def test_section_order_alias_and_free_text() -> None:
+ """Parameters placed like Args, Warning anywhere: no section_order error."""
+ raw = "Summary.\n\nWarning:\n Slow.\n\nParameters:\n x (int): Value.\n\nReturns:\n int: Result.\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ assert not [e for e in validate_entity(entity, ParsedDocstring(summary="Summary."), _rule_only("section_order")) if e.rule == "section_order"]
+
+
+def test_section_order_alias_out_of_place() -> None:
+ """Return before Parameters: section_order error listing canonical names."""
+ raw = "Summary.\n\nReturn:\n int: Result.\n\nParameters:\n x (int): Value.\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ errors = [e for e in validate_entity(entity, ParsedDocstring(summary="Summary."), _rule_only("section_order")) if e.rule == "section_order"]
+ assert [e.message for e in errors] == ["Section 'Parameters:' must come before 'Return:'. Expected order: Args, Returns."]
+
+
+def test_empty_free_text_section() -> None:
+ """Empty See Also section: returns empty_section error, free-text sections are known sections."""
+ raw = "Summary.\n\nSee Also:\n\nArgs:\n x (int): Value.\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ errors = validate_entity(entity, ParsedDocstring(summary="Summary."), _neutral())
+ assert "Section 'See Also:' is empty." in [e.message for e in errors]
+
+
+def test_entry_spacing_in_alias_section() -> None:
+ """Badly spaced entry under Parameters: entry_spacing applies to aliases of Args."""
+ raw = "Summary.\n\nParameters:\n x(int): Value.\n"
+ entity = _func(docstring=raw, raw_docstring=raw)
+ errors = validate_entity(entity, ParsedDocstring(summary="Summary."), _neutral())
+ assert [e.rule for e in errors if e.rule == "entry_spacing"] == ["entry_spacing"]
+
+
# ---------------------------------------------------------------------------
# Rule => empty_section
# ---------------------------------------------------------------------------
@@ -248,6 +331,15 @@ def test_blank_lines_before_closing_quotes_zero() -> None:
assert not errors
+@pytest.mark.parametrize("expected", [0, 1])
+def test_closing_quotes_on_text_line(expected: int) -> None:
+ """Multi-line docstring closed on the line of its last text: one error saying so, whatever the configured count."""
+ entity = _func(docstring="Summary.\n\nDetails.", raw_docstring="Summary.\n\n Details.")
+ cfg = _neutral(blank_lines_before_closing_quotes=expected)
+ errors = validate_entity(entity, ParsedDocstring(summary="Summary."), cfg)
+ assert [e.message for e in errors] == ['Closing """ must be on its own line.']
+
+
def test_no_blank_line_in_section_cannot_be_disabled() -> None:
"""Rule listed in ignore: the blank line between entries is still reported, the rule is always on."""
raw = "Summary.\n\nArgs:\n x (int): First.\n\n y (str): Second.\n\n"
@@ -430,11 +522,11 @@ def _with_section(header: str) -> str:
def test_examples_section_required_missing() -> None:
- """Policy required, no Example section: returns examples_section error."""
+ """Policy required, no Examples section: returns examples_section error."""
raw = "Summary.\n\n"
entity = _func(docstring=raw, raw_docstring=raw)
errors = validate_entity(entity, ParsedDocstring(summary="Summary."), _policy_only("examples_section", Policy.REQUIRED))
- assert any(e.rule == "examples_section" and "Missing 'Example:'" in e.message for e in errors)
+ assert any(e.rule == "examples_section" and "Missing 'Examples:'" in e.message for e in errors)
def test_examples_section_required_present_plural() -> None:
diff --git a/tests/linter/rules/test_types.py b/tests/linter/rules/test_types.py
new file mode 100644
index 0000000..6f43e45
--- /dev/null
+++ b/tests/linter/rules/test_types.py
@@ -0,0 +1,41 @@
+"""Tests for the type comparison between docstrings and signatures."""
+
+import pytest
+from linter.rules._types import types_match # pyright: ignore[reportPrivateUsage]
+
+
+@pytest.mark.parametrize(
+ ("documented", "signature", "strict", "equivalent", "lenient"),
+ [
+ # cosmetic differences, accepted at every level
+ ("int", "int", True, True, True),
+ ("Node", "'Node'", True, True, True),
+ ("list[Node]", "list['Node']", True, True, True),
+ ("dict[str,int]", "dict[str, int]", True, True, True),
+ ("bool, optional", "bool", True, True, True),
+ ("~ConsoleOptions", "'ConsoleOptions'", True, True, True),
+ # same type, other spelling
+ ("str | None", "Optional[str]", False, True, True),
+ ("Union[int, str]", "int | str", False, True, True),
+ ("typing.Optional[int]", "int | None", False, True, True),
+ ("List[int]", "list[int]", False, True, True),
+ ("Optional[List[str]]", "list[str] | None", False, True, True),
+ # None left implicit in the docstring
+ ("int, optional", "Optional[int]", False, False, True),
+ ("int | str", "Union[None, int, str]", False, False, True),
+ # real differences, reported at every level
+ ("int", "str", False, False, False),
+ ("int | None", "int", False, False, False),
+ ("List[int]", "Iterable[int]", False, False, False),
+ ("IO", "IO[str]", False, False, False),
+ ("int", "wintypes.WORD", False, False, False),
+ # prose is compared as text
+ ("list of int", "list[int]", False, False, False),
+ ("list of int", "list of int", True, True, True),
+ ],
+)
+def test_types_match(documented: str, signature: str, strict: bool, equivalent: bool, lenient: bool) -> None: # noqa: FBT001
+ """Each level accepts what the previous one accepts, and real differences are always reported."""
+ assert types_match(documented, signature, "strict") is strict
+ assert types_match(documented, signature, "equivalent") is equivalent
+ assert types_match(documented, signature, "lenient") is lenient
diff --git a/tests/linter/test_ast_parser.py b/tests/linter/test_ast_parser.py
index eeea86f..3d7ba5b 100644
--- a/tests/linter/test_ast_parser.py
+++ b/tests/linter/test_ast_parser.py
@@ -5,7 +5,7 @@
from pathlib import Path # noqa: TC003
import pytest
-from linter.ast_parser import _extract_args, _extract_class_attributes, _extract_raises, _is_empty_init, parse_file # pyright: ignore[reportPrivateUsage]
+from linter.ast_parser import _extract_args, _extract_class_attributes, _is_empty_init, _scan_body, parse_file # pyright: ignore[reportPrivateUsage]
def _parse_func(source: str) -> ast.FunctionDef:
@@ -64,6 +64,24 @@ def test_extract_class_attributes_none() -> None:
assert not _extract_class_attributes(node)
+def test_extract_class_attributes_tuple_self_assignment() -> None:
+ """self.a, *self.b = ... in __init__: every unpacked self attribute is extracted."""
+ node = _parse_class("class C:\n def __init__(self):\n self.a, (self.b, *self.c) = 1, (2, 3)\n self.d, other = 4, 5\n")
+ assert _extract_class_attributes(node) == ["a", "b", "c", "d"]
+
+
+def test_extract_class_attributes_tuple_class_assignment() -> None:
+ """a, b = ... in the class body: both names are extracted."""
+ node = _parse_class("class C:\n a, [b, MAX] = 1, [2, 3]\n")
+ assert _extract_class_attributes(node) == ["a", "b"]
+
+
+def test_extract_class_attributes_ignores_nested_function() -> None:
+ """self.x assigned inside a function nested in __init__: not a class attribute."""
+ node = _parse_class("class A:\n def __init__(self):\n self.a = 1\n def later():\n self.b = 2")
+ assert _extract_class_attributes(node) == ["a"]
+
+
# ---------------------------------------------------------------------------
# _extract_args
# ---------------------------------------------------------------------------
@@ -72,7 +90,7 @@ def test_extract_class_attributes_none() -> None:
def test_extract_args_no_args() -> None:
"""Only self in signature: returns empty list because self is always skipped."""
node = _parse_func("def f(self): pass")
- assert not _extract_args(node.args)
+ assert not _extract_args(node.args, skip_first=True)
def test_extract_args_positional_with_type_and_default() -> None:
@@ -81,7 +99,7 @@ def test_extract_args_positional_with_type_and_default() -> None:
def f(self, x: int = 0) -> x.name="x", x.type_annotation="int", x.default="0"
"""
node = _parse_func("def f(self, x: int = 0): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=True)
assert len(args) == 1
assert args[0].name == "x"
assert args[0].type_annotation == "int"
@@ -94,7 +112,7 @@ def test_extract_args_positional_without_type() -> None:
def f(self, x) -> x.type_annotation=None
"""
node = _parse_func("def f(self, x): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=True)
assert args[0].type_annotation is None
@@ -104,7 +122,7 @@ def test_extract_args_positional_without_default() -> None:
def f(self, x: int) -> x.default=None
"""
node = _parse_func("def f(self, x: int): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=True)
assert args[0].default is None
@@ -114,7 +132,7 @@ def test_extract_args_keyword_only() -> None:
def f(self, *, name: str) -> name is keyword-only, still returned
"""
node = _parse_func("def f(self, *, name: str): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=True)
assert len(args) == 1
assert args[0].name == "name"
assert args[0].type_annotation == "str"
@@ -126,27 +144,32 @@ def test_extract_args_keyword_only_with_default() -> None:
def f(self, *, strict: bool = False) -> strict.default="False"
"""
node = _parse_func("def f(self, *, strict: bool = False): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=True)
assert args[0].default == "False"
-def test_extract_args_skips_self_and_cls() -> None:
- """Both self and cls are always excluded from the result, regardless of position."""
- node = _parse_func("def f(self, cls, x: int): pass")
- args = _extract_args(node.args)
- assert len(args) == 1
- assert args[0].name == "x"
+def test_extract_args_skips_first_param_whatever_its_name() -> None:
+ """Method: the first positional parameter is dropped by position, not by name.
+ def __new__(mcs, name: str) -> only name is returned
+ """
+ node = _parse_func("def __new__(mcs, name: str): pass")
+ args = _extract_args(node.args, skip_first=True)
+ assert [a.name for a in args] == ["name"]
-def test_extract_args_skips_cls_in_kwonly() -> None:
- """Keyword-only arg named cls is excluded, just like in positional position.
- def f(*, cls, x: int) -> only x is returned
- """
- node = _parse_func("def f(*, cls, x: int): pass")
- args = _extract_args(node.args)
- assert len(args) == 1
- assert args[0].name == "x"
+def test_extract_args_keeps_self_and_cls_names_on_functions() -> None:
+ """Plain function: parameters named self or cls are regular parameters."""
+ node = _parse_func("def f(self, cls, *, x: int): pass")
+ args = _extract_args(node.args, skip_first=False)
+ assert [a.name for a in args] == ["self", "cls", "x"]
+
+
+def test_extract_args_skips_only_the_first_param() -> None:
+ """Method with a second parameter named cls: only the first parameter is dropped."""
+ node = _parse_func("def f(self, cls, x: int): pass")
+ args = _extract_args(node.args, skip_first=True)
+ assert [a.name for a in args] == ["cls", "x"]
def test_extract_args_mixed_positional_and_keyword_only() -> None:
@@ -155,7 +178,7 @@ def test_extract_args_mixed_positional_and_keyword_only() -> None:
def f(self, a: int, *, b: str = "x") -> [a, b]
"""
node = _parse_func('def f(self, a: int, *, b: str = "x"): pass')
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=True)
assert len(args) == 2
assert args[0].name == "a"
@@ -165,7 +188,7 @@ def f(self, a: int, *, b: str = "x") -> [a, b]
def test_extract_args_vararg_and_kwarg() -> None:
"""*args and **kwargs are extracted with their stars in the name."""
node = _parse_func("def f(a: int, *rest: str, **opts: object): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=False)
names = [arg.name for arg in args]
assert names == ["a", "*rest", "**opts"]
@@ -176,7 +199,7 @@ def test_extract_args_vararg_and_kwarg() -> None:
def test_extract_args_vararg_without_annotation() -> None:
"""*args without annotation: type_annotation is None."""
node = _parse_func("def f(*rest): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=False)
assert len(args) == 1
assert args[0].name == "*rest"
@@ -189,7 +212,7 @@ def test_extract_args_positional_only() -> None:
def f(a: int, b: str, /) -> [a, b]
"""
node = _parse_func("def f(a: int, b: str, /): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=False)
assert len(args) == 2
assert args[0].name == "a"
@@ -204,7 +227,7 @@ def test_extract_args_positional_only_skips_self() -> None:
def f(self, x: int, /) -> [x]
"""
node = _parse_func("def f(self, x: int, /): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=True)
assert len(args) == 1
assert args[0].name == "x"
@@ -216,7 +239,7 @@ def test_extract_args_positional_only_default_alignment() -> None:
def f(a, b, /, c, d=5, *, e=10) -> only d and e have defaults
"""
node = _parse_func("def f(a, b, /, c, d=5, *, e=10): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=False)
by_name = {a.name: a.default for a in args}
assert by_name == {"a": None, "b": None, "c": None, "d": "5", "e": "10"}
@@ -228,7 +251,7 @@ def test_extract_args_positional_only_with_default() -> None:
def f(a, b=2, /) -> b.default="2"
"""
node = _parse_func("def f(a, b=2, /): pass")
- args = _extract_args(node.args)
+ args = _extract_args(node.args, skip_first=False)
assert args[0].name == "a"
assert args[0].default is None
@@ -237,14 +260,14 @@ def f(a, b=2, /) -> b.default="2"
# ---------------------------------------------------------------------------
-# _extract_raises
+# _scan_body -- raises
# ---------------------------------------------------------------------------
def test_extract_raises_none() -> None:
"""Function with no raise statements: returns empty list."""
node = _parse_func("def f(): pass")
- assert not _extract_raises(node)
+ assert not _scan_body(node)[0]
def test_extract_raises_simple_call() -> None:
@@ -253,30 +276,59 @@ def test_extract_raises_simple_call() -> None:
The exception_type is "ValueError", not the message.
"""
node = _parse_func('def f():\n raise ValueError("msg")')
- raises = _extract_raises(node)
+ raises = _scan_body(node)[0]
assert len(raises) == 1
assert raises[0].exception_type == "ValueError"
-def test_extract_raises_bare_name() -> None:
- """Raise err where err is a plain name (not a call): the name itself is recorded."""
+def test_extract_raises_variable_ignored() -> None:
+ """Raise err where err is a variable, not bound by an except clause: ignored."""
node = _parse_func("def f():\n err = RuntimeError()\n raise err")
- raises = _extract_raises(node)
- assert len(raises) == 1
- assert raises[0].exception_type == "err"
+ assert not _scan_body(node)[0]
+
+
+def test_extract_raises_bare_class_name() -> None:
+ """Raise ValueError without call: the class name is recorded."""
+ node = _parse_func("def f():\n raise ValueError")
+ assert [r.exception_type for r in _scan_body(node)[0]] == ["ValueError"]
+
+
+def test_extract_raises_lowercase_factory_ignored() -> None:
+ """Raise make_error('x'): a lowercase callable is not an exception class, ignored."""
+ node = _parse_func("def f():\n raise make_error('x')")
+ assert not _scan_body(node)[0]
def test_extract_raises_bare_raise_ignored() -> None:
- """Bare re-raise (raise with no argument): ignored because there is no exception type."""
+ """Bare raise outside any handler: ignored because there is no exception type."""
node = _parse_func("def f():\n raise")
- assert not _extract_raises(node)
+ assert not _scan_body(node)[0]
+
+
+def test_scan_body_bare_raise_in_handler() -> None:
+ """Bare raise inside 'except (KeyError, mod.Error)': every caught type is reported."""
+ node = _parse_func("def f():\n try:\n pass\n except (KeyError, mod.Error):\n if x:\n raise")
+ assert [r.exception_type for r in _scan_body(node)[0]] == ["KeyError", "Error"]
+
+
+def test_scan_body_bare_raise_in_nested_handler() -> None:
+ """Bare raise in a handler nested in another: reports the inner caught type only."""
+ source = "def f():\n try:\n pass\n except KeyError:\n try:\n pass\n except ValueError:\n raise"
+ node = _parse_func(source)
+ assert [r.exception_type for r in _scan_body(node)[0]] == ["ValueError"]
+
+
+def test_scan_body_bare_raise_in_untyped_handler_ignored() -> None:
+ """Bare raise inside a bare 'except:': ignored because no type is caught."""
+ node = _parse_func("def f():\n try:\n pass\n except:\n raise")
+ assert not _scan_body(node)[0]
def test_extract_raises_deduplicates() -> None:
"""Same exception raised twice: appears only once in the result list."""
source = "def f():\n raise ValueError('a')\n raise ValueError('b')"
node = _parse_func(source)
- raises = _extract_raises(node)
+ raises = _scan_body(node)[0]
assert len(raises) == 1
@@ -284,10 +336,48 @@ def test_extract_raises_multiple_distinct() -> None:
"""Two different exceptions raised: both are present in the result."""
source = "def f():\n raise ValueError('a')\n raise TypeError('b')"
node = _parse_func(source)
- names = {r.exception_type for r in _extract_raises(node)}
+ names = {r.exception_type for r in _scan_body(node)[0]}
assert names == {"ValueError", "TypeError"}
+def test_scan_body_reraise_of_caught_name() -> None:
+ """Raise err inside 'except ValueError as err': reported as ValueError, not as err."""
+ node = _parse_func("def f():\n try:\n pass\n except ValueError as err:\n raise err")
+ assert [r.exception_type for r in _scan_body(node)[0]] == ["ValueError"]
+
+
+def test_scan_body_reraise_of_caught_tuple() -> None:
+ """Raise err inside 'except (KeyError, mod.Error) as err': every caught type is reported."""
+ node = _parse_func("def f():\n try:\n pass\n except (KeyError, mod.Error) as err:\n raise err")
+ assert [r.exception_type for r in _scan_body(node)[0]] == ["KeyError", "Error"]
+
+
+def test_scan_body_dotted_exception() -> None:
+ """Raise errors.ValidationError(...): reported by its last name segment."""
+ node = _parse_func("def f():\n raise errors.ValidationError('bad')")
+ assert [r.exception_type for r in _scan_body(node)[0]] == ["ValidationError"]
+
+
+def test_scan_body_lowercase_attribute_ignored() -> None:
+ """Raise self.error: not an exception class name, ignored."""
+ node = _parse_func("def f(self):\n raise self.error")
+ assert not _scan_body(node)[0]
+
+
+def test_scan_body_nested_function_ignored() -> None:
+ """Raise and yield inside a nested function or lambda: not attributed to the outer function."""
+ source = "def f():\n def g():\n yield 1\n raise ValueError\n h = lambda: (yield)\n return g"
+ raises, is_generator = _scan_body(_parse_func(source))
+ assert not raises
+ assert is_generator is False
+
+
+def test_scan_body_source_order() -> None:
+ """Several raises: reported in source order, with the line of the first occurrence."""
+ node = _parse_func("def f(x):\n if x:\n raise KeyError\n raise ValueError")
+ assert [(r.exception_type, r.line) for r in _scan_body(node)[0]] == [("KeyError", 3), ("ValueError", 4)]
+
+
# ---------------------------------------------------------------------------
# _is_empty_init
# ---------------------------------------------------------------------------
@@ -326,6 +416,12 @@ def test_is_empty_init_with_body() -> None:
assert _is_empty_init(node) is False
+@pytest.mark.parametrize("signature", ["def __init__(self, *args): pass", "def __init__(self, **kwargs): pass"])
+def test_is_empty_init_with_star_args(signature: str) -> None:
+ """__init__ taking *args or **kwargs: has parameters, not empty."""
+ assert _is_empty_init(_parse_func(signature)) is False
+
+
# ---------------------------------------------------------------------------
# parse_file
# ---------------------------------------------------------------------------
@@ -405,3 +501,125 @@ def test_is_generator_without_yield(tmp_path: Path) -> None:
entities = parse_file(str(f))
func = next(e for e in entities if e.name == "f")
assert func.is_generator is False
+
+
+def test_is_generator_nested_generator_not_propagated(tmp_path: Path) -> None:
+ """Function defining a nested generator: the outer function is not a generator."""
+ f = tmp_path / "sample.py"
+ f.write_text("def outer():\n def gen():\n yield 1\n return list(gen())\n", encoding="utf-8")
+ entities = parse_file(str(f))
+ outer = next(e for e in entities if e.name == "outer")
+ assert outer.is_generator is False
+
+
+# ---------------------------------------------------------------------------
+# parse_file -- entities under compound statements and decorators
+# ---------------------------------------------------------------------------
+
+
+def test_parse_file_functions_under_compound_statements(tmp_path: Path) -> None:
+ """Functions defined under if, else, try, except, with and match: all extracted."""
+ source = textwrap.dedent(
+ """\
+ import sys
+
+ if sys.version_info >= (3, 12):
+ def in_if(): pass
+ else:
+ def in_else(): pass
+ try:
+ def in_try(): pass
+ except ImportError:
+ def in_except(): pass
+ with open(__file__) as f:
+ def in_with(): pass
+ match sys.platform:
+ case "linux":
+ def in_case(): pass
+ """
+ )
+ f = tmp_path / "sample.py"
+ f.write_text(source, encoding="utf-8")
+ names = {e.name for e in parse_file(str(f))}
+ assert {"in_if", "in_else", "in_try", "in_except", "in_with", "in_case"} <= names
+
+
+def test_parse_file_method_under_if_in_class(tmp_path: Path) -> None:
+ """Method defined under an if inside a class body: extracted as a method of that class."""
+ f = tmp_path / "sample.py"
+ f.write_text("class A:\n if True:\n def m(self): pass\n", encoding="utf-8")
+ assert "A.m" in {e.name for e in parse_file(str(f))}
+
+
+def test_parse_file_skips_overload_stubs(tmp_path: Path) -> None:
+ """@overload and @typing.overload stubs: not extracted, the implementation is."""
+ source = textwrap.dedent(
+ """\
+ import typing
+ from typing import overload
+
+ @overload
+ def conv(x: int) -> int: ...
+ @typing.overload
+ def conv(x: str) -> str: ...
+ def conv(x): return x
+ """
+ )
+ f = tmp_path / "sample.py"
+ f.write_text(source, encoding="utf-8")
+ convs = [e for e in parse_file(str(f)) if e.name == "conv"]
+ assert len(convs) == 1
+ assert convs[0].line == 8
+
+
+def test_parse_file_staticmethod_keeps_first_param(tmp_path: Path) -> None:
+ """@staticmethod: the first parameter is a regular parameter, even when named self."""
+ f = tmp_path / "sample.py"
+ f.write_text("class A:\n @staticmethod\n def m(self, x): pass\n def n(this, y): pass\n", encoding="utf-8")
+ entities = {e.name: e for e in parse_file(str(f))}
+ assert [a.name for a in entities["A.m"].args] == ["self", "x"]
+ assert [a.name for a in entities["A.n"].args] == ["y"]
+
+
+def test_parse_file_skips_main_guard_body(tmp_path: Path) -> None:
+ """Functions and classes under 'if __name__ == "__main__":': skipped, the else branch is kept."""
+ source = textwrap.dedent(
+ """\
+ def api(): pass
+
+ if __name__ == "__main__":
+ def demo(): pass
+ class Demo: pass
+ else:
+ def on_import(): pass
+
+ if "__main__" == __name__:
+ def reversed_demo(): pass
+ """
+ )
+ f = tmp_path / "sample.py"
+ f.write_text(source, encoding="utf-8")
+ names = {e.name for e in parse_file(str(f))}
+ assert {"api", "on_import"} <= names
+ assert not {"demo", "Demo", "reversed_demo"} & names
+
+
+def test_parse_file_records_decorators(tmp_path: Path) -> None:
+ """Decorators: recorded by their last name segment, sorted, attribute and call forms included."""
+ source = "import functools\n\nclass A:\n @property\n def size(self): pass\n @size.setter\n def size(self, value): pass\n @functools.cache\n @staticmethod\n def m(): pass\n"
+ f = tmp_path / "sample.py"
+ f.write_text(source, encoding="utf-8")
+ decorators = [(e.name, e.decorators) for e in parse_file(str(f)) if e.name.startswith("A.")]
+ assert decorators == [("A.size", ["property"]), ("A.size", ["setter"]), ("A.m", ["cache", "staticmethod"])]
+
+
+def test_parse_file_links_class_and_init(tmp_path: Path) -> None:
+ """Class with __init__: the class carries the __init__ parameters, __init__ carries the class docstring."""
+ source = 'class A:\n """Store.\n\n Args:\n x (int): Value.\n """\n\n def __init__(self, x: int):\n self.x = x\n\n\nclass B:\n pass\n'
+ f = tmp_path / "sample.py"
+ f.write_text(source, encoding="utf-8")
+ entities = {e.name: e for e in parse_file(str(f))}
+ assert entities["A"].init_args is not None
+ assert [a.name for a in entities["A"].init_args] == ["x"]
+ assert entities["A.__init__"].class_docstring == entities["A"].docstring
+ assert entities["B"].init_args is None
diff --git a/tests/linter/test_cli.py b/tests/linter/test_cli.py
index 5ecea36..a517f3f 100644
--- a/tests/linter/test_cli.py
+++ b/tests/linter/test_cli.py
@@ -2,12 +2,19 @@
import argparse
import json
+import subprocess
import sys
-from pathlib import Path # noqa: TC003
+from pathlib import Path
+from typing import TYPE_CHECKING
import pytest
-from linter.cli import collect_python_files, lint_file, main, merge_cli_into_config, run
-from linter.config import ALWAYS_ON, RULES_CATEGORIES, RULES_REGISTRY, DocstringStyle, LinterConfig
+from linter.cli import _resolve_workers, collect_python_files, lint_file, main, merge_cli_into_config, run # pyright: ignore[reportPrivateUsage]
+from linter.config import ALWAYS_ON, RULES_CATEGORIES, RULES_REGISTRY, LinterConfig
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from linter.models import LintError
_VALID_SOURCE = '''\
"""Module docstring."""
@@ -39,21 +46,28 @@ def test_collect_single_file(tmp_path: Path) -> None:
"""Single .py file path: returns that file."""
f = tmp_path / "foo.py"
f.write_text("", encoding="utf-8")
- assert collect_python_files([str(f)], []) == [str(f)]
+ assert collect_python_files([str(f)], [], tmp_path) == [str(f)]
def test_collect_non_py_file_ignored(tmp_path: Path) -> None:
"""Non-.py file: not collected."""
f = tmp_path / "foo.txt"
f.write_text("", encoding="utf-8")
- assert not collect_python_files([str(f)], [])
+ assert not collect_python_files([str(f)], [], tmp_path)
+
+
+def test_collect_explicit_file_kept_despite_exclusion(tmp_path: Path) -> None:
+ """File given explicitly and matching an exclusion pattern: still collected."""
+ f = tmp_path / "test_foo.py"
+ f.write_text("", encoding="utf-8")
+ assert collect_python_files([str(f)], ["test_*"], tmp_path) == [str(f)]
-def test_collect_excluded_file_skipped(tmp_path: Path) -> None:
- """Single file matching exclusion pattern: not collected."""
+def test_collect_explicit_file_skipped_with_force_exclude(tmp_path: Path) -> None:
+ """File given explicitly and matching an exclusion pattern, with force_exclude: not collected."""
f = tmp_path / "test_foo.py"
f.write_text("", encoding="utf-8")
- assert not collect_python_files([str(f)], ["test_*"])
+ assert not collect_python_files([str(f)], ["test_*"], tmp_path, force_exclude=True)
def test_collect_directory_recursive(tmp_path: Path) -> None:
@@ -62,7 +76,7 @@ def test_collect_directory_recursive(tmp_path: Path) -> None:
sub = tmp_path / "pkg"
sub.mkdir()
(sub / "b.py").write_text("", encoding="utf-8")
- files = collect_python_files([str(tmp_path)], [])
+ files = collect_python_files([str(tmp_path)], [], tmp_path)
assert len(files) == 2
@@ -71,7 +85,32 @@ def test_collect_venv_excluded_by_literal_pattern(tmp_path: Path) -> None:
venv = tmp_path / ".venv" / "lib"
venv.mkdir(parents=True)
(venv / "foo.py").write_text("", encoding="utf-8")
- assert not collect_python_files([str(tmp_path)], [".venv"])
+ assert not collect_python_files([str(tmp_path)], [".venv"], tmp_path)
+
+
+def test_collect_literal_excluded_directory_not_walked(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Directory named by a literal pattern: skipped during the walk, its content is never listed."""
+ (tmp_path / ".venv" / "lib").mkdir(parents=True)
+ (tmp_path / ".venv" / "lib" / "foo.py").write_text("", encoding="utf-8")
+ (tmp_path / "main.py").write_text("", encoding="utf-8")
+ walked: list[Path] = []
+ real_walk = Path.walk
+
+ def spy_walk(self: Path) -> Iterator[tuple[Path, list[str], list[str]]]:
+ for entry in real_walk(self):
+ walked.append(entry[0])
+ yield entry
+
+ monkeypatch.setattr(Path, "walk", spy_walk)
+ assert collect_python_files([str(tmp_path)], [".venv"], tmp_path) == [str(tmp_path / "main.py")]
+ assert walked == [tmp_path]
+
+
+def test_collect_directory_named_like_a_module(tmp_path: Path) -> None:
+ """Directory whose name ends with .py: walked into, never collected as a file."""
+ (tmp_path / "pkg.py").mkdir()
+ (tmp_path / "pkg.py" / "mod.py").write_text("", encoding="utf-8")
+ assert collect_python_files([str(tmp_path)], [], tmp_path) == [str(tmp_path / "pkg.py" / "mod.py")]
def test_collect_pycache_excluded_by_literal_pattern(tmp_path: Path) -> None:
@@ -80,10 +119,29 @@ def test_collect_pycache_excluded_by_literal_pattern(tmp_path: Path) -> None:
cache.mkdir(parents=True)
(cache / "foo.cpython-314.pyc").write_text("", encoding="utf-8")
(tmp_path / "src" / "foo.py").write_text("", encoding="utf-8")
- files = collect_python_files([str(tmp_path)], ["__pycache__"])
+ files = collect_python_files([str(tmp_path)], ["__pycache__"], tmp_path)
assert all("__pycache__" not in f for f in files)
+def test_collect_recursive_glob_excluded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Pattern tests/**: every file under tests/ is excluded, at any depth."""
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "tests" / "unit").mkdir(parents=True)
+ (tmp_path / "tests" / "unit" / "test_a.py").write_text("", encoding="utf-8")
+ (tmp_path / "tests" / "conftest.py").write_text("", encoding="utf-8")
+ (tmp_path / "main.py").write_text("", encoding="utf-8")
+ assert collect_python_files(["."], ["tests/**"], tmp_path) == ["main.py"]
+
+
+def test_collect_directory_glob_excluded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Pattern src/gen/*.py: files directly under src/gen/ are excluded, others kept."""
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "src" / "gen").mkdir(parents=True)
+ (tmp_path / "src" / "gen" / "model.py").write_text("", encoding="utf-8")
+ (tmp_path / "src" / "app.py").write_text("", encoding="utf-8")
+ assert collect_python_files(["src"], ["src/gen/*.py"], tmp_path) == ["src/app.py"]
+
+
# ---------------------------------------------------------------------------
# lint_file
# ---------------------------------------------------------------------------
@@ -123,54 +181,46 @@ def test_lint_file_syntax_error_raises(tmp_path: Path) -> None:
# ---------------------------------------------------------------------------
-def test_merge_style_override() -> None:
- """--style google: overrides config.style."""
- args = argparse.Namespace(style="google", exclude=None, format=None, workers=None)
- config = merge_cli_into_config(LinterConfig(), args)
- assert config.style == DocstringStyle.GOOGLE
-
-
def test_merge_exclude_override() -> None:
"""--exclude test_*: overrides config.exclude_patterns."""
- args = argparse.Namespace(style=None, exclude=["test_*"], format=None, workers=None)
+ args = argparse.Namespace(exclude=["test_*"], format=None, workers=None)
config = merge_cli_into_config(LinterConfig(), args)
assert config.exclude_patterns == ["test_*"]
def test_merge_format_json() -> None:
"""--format json: sets output_format to json."""
- args = argparse.Namespace(style=None, exclude=None, format="json", workers=None)
+ args = argparse.Namespace(exclude=None, format="json", workers=None)
config = merge_cli_into_config(LinterConfig(), args)
assert config.output_format == "json"
def test_merge_format_github_annotations() -> None:
"""--format github-annotations: sets output_format to github-annotations."""
- args = argparse.Namespace(style=None, exclude=None, format="github-annotations", workers=None)
+ args = argparse.Namespace(exclude=None, format="github-annotations", workers=None)
config = merge_cli_into_config(LinterConfig(), args)
assert config.output_format == "github-annotations"
def test_merge_workers_override() -> None:
"""--workers 4: sets config.workers to 4."""
- args = argparse.Namespace(style=None, exclude=None, format=None, workers=4)
+ args = argparse.Namespace(exclude=None, format=None, workers=4)
config = merge_cli_into_config(LinterConfig(), args)
assert config.workers == 4
def test_merge_workers_negative_clamped_to_zero() -> None:
"""--workers -1: clamped to 0 (auto-detect)."""
- args = argparse.Namespace(style=None, exclude=None, format=None, workers=-1)
+ args = argparse.Namespace(exclude=None, format=None, workers=-1)
config = merge_cli_into_config(LinterConfig(), args)
assert config.workers == 0
def test_merge_no_overrides_leaves_defaults() -> None:
"""No CLI overrides: config unchanged from defaults."""
- args = argparse.Namespace(style=None, exclude=None, format=None, workers=None)
+ args = argparse.Namespace(exclude=None, format=None, workers=None)
defaults = LinterConfig()
config = merge_cli_into_config(LinterConfig(), args)
- assert config.style == defaults.style
assert config.exclude_patterns == defaults.exclude_patterns
@@ -199,11 +249,101 @@ def test_run_invalid_file_returns_one(tmp_path: Path) -> None:
assert run([str(f)], LinterConfig()) == 1
-def test_run_syntax_error_returns_zero(tmp_path: Path) -> None:
- """File with SyntaxError: error is caught, run returns 0 (no lint errors)."""
+def test_run_syntax_error_returns_two(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ """File with SyntaxError: reported on stderr, run returns 2."""
f = tmp_path / "bad.py"
f.write_text(_SYNTAX_ERROR_SOURCE, encoding="utf-8")
- assert run([str(f)], LinterConfig()) == 0
+ assert run([str(f)], LinterConfig()) == 2
+ captured = capsys.readouterr()
+ assert "Syntax error in" in captured.err
+ assert "Syntax error in" not in captured.out
+
+
+def test_run_syntax_error_keeps_json_valid(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ """File with SyntaxError under --format json: stdout stays valid JSON, run returns 2."""
+ f = tmp_path / "bad.py"
+ f.write_text(_SYNTAX_ERROR_SOURCE, encoding="utf-8")
+ config = LinterConfig()
+ config.output_format = "json"
+ assert run([str(f)], config) == 2
+ report = json.loads(capsys.readouterr().out)
+ assert report["summary"]["files_checked"] == 1
+
+
+def test_run_undecodable_file_returns_two(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ """File that is not valid UTF-8: reported as unreadable on stderr, run returns 2."""
+ f = tmp_path / "latin.py"
+ f.write_bytes(b"\xff\xfe bad")
+ assert run([str(f)], LinterConfig()) == 2
+ assert "Cannot read" in capsys.readouterr().err
+
+
+def test_run_failure_wins_over_lint_errors(tmp_path: Path) -> None:
+ """One unparsable file and one file with lint errors: run returns 2."""
+ (tmp_path / "bad.py").write_text(_SYNTAX_ERROR_SOURCE, encoding="utf-8")
+ (tmp_path / "errors.py").write_text('"""Module."""\n\ndef foo() -> int:\n pass\n', encoding="utf-8")
+ assert run([str(tmp_path)], LinterConfig()) == 2
+
+
+def test_run_internal_error_returns_three(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
+ """Unexpected exception on one file: file named with the traceback, other files still linted, run returns 3."""
+ (tmp_path / "bad.py").write_text(_SYNTAX_ERROR_SOURCE, encoding="utf-8")
+ (tmp_path / "boom.py").write_text("", encoding="utf-8")
+ (tmp_path / "errors.py").write_text('"""Module."""\n\ndef foo() -> int:\n pass\n', encoding="utf-8")
+ real_lint_file = lint_file
+
+ def failing_lint_file(filepath: str, config: LinterConfig) -> list[LintError]:
+ if filepath.endswith("boom.py"):
+ msg = "unexpected"
+ raise RuntimeError(msg)
+ return real_lint_file(filepath, config)
+
+ monkeypatch.setattr("linter.cli.lint_file", failing_lint_file)
+ config = LinterConfig()
+ config.workers = 1
+ config.output_format = "text"
+
+ assert run([str(tmp_path)], config) == 3
+ captured = capsys.readouterr()
+ assert f"Internal error while linting {tmp_path / 'boom.py'}: RuntimeError: unexpected" in captured.err
+ assert "Traceback (most recent call last):" in captured.err
+ assert "foo" in captured.out
+
+
+def test_run_missing_path_returns_two(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ """Path that does not exist: reported on stderr, run returns 2 without linting."""
+ assert run([str(tmp_path / "missing")], LinterConfig()) == 2
+ assert "Path not found" in capsys.readouterr().err
+
+
+def test_resolve_workers_auto_is_the_default() -> None:
+ """Default config: workers is 0, the auto mode."""
+ assert LinterConfig().workers == 0
+
+
+def test_resolve_workers_auto_sequential_on_small_runs() -> None:
+ """Auto mode below 50 files: sequential, the process pool would cost more than it saves."""
+ assert _resolve_workers(0, 49) == 1
+
+
+def test_resolve_workers_auto_uses_usable_cpus(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Auto mode from 50 files: one worker per CPU usable by the process."""
+ monkeypatch.setattr("os.process_cpu_count", lambda: 3)
+ assert _resolve_workers(0, 50) == 3
+
+
+def test_resolve_workers_explicit_value_kept() -> None:
+ """Explicit worker count: used as is, whatever the number of files."""
+ assert _resolve_workers(4, 2) == 4
+
+
+def test_run_parallel_workers(tmp_path: Path) -> None:
+ """Two workers on two files: errors from every file are collected."""
+ (tmp_path / "valid.py").write_text(_VALID_SOURCE, encoding="utf-8")
+ (tmp_path / "errors.py").write_text('"""Module."""\n\ndef foo() -> int:\n pass\n', encoding="utf-8")
+ config = LinterConfig()
+ config.workers = 2
+ assert run([str(tmp_path)], config) == 1
def test_run_with_json_output(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
@@ -217,8 +357,58 @@ def test_run_with_json_output(tmp_path: Path, capsys: pytest.CaptureFixture[str]
assert report["summary"]["total_errors"] == 0
+def test_main_from_subdirectory_uses_config_directory(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
+ """Run from src/ with the config at the root: exclude and override patterns still apply from the root."""
+ (tmp_path / "pyproject.toml").write_text(
+ '[tool.docstring-linter]\nexclude = ["src/gen/*.py"]\n\n[[tool.docstring-linter.overrides]]\npaths = ["src/**"]\nignore = ["docstring_exists"]\n',
+ encoding="utf-8",
+ )
+ (tmp_path / "src" / "gen").mkdir(parents=True)
+ (tmp_path / "src" / "app.py").write_text("def f():\n pass\n", encoding="utf-8")
+ (tmp_path / "src" / "gen" / "model.py").write_text("def f():\n pass\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path / "src")
+ monkeypatch.setattr(sys, "argv", ["docstring-linter", ".", "--format", "text"])
+
+ with pytest.raises(SystemExit) as exc:
+ main()
+
+ assert exc.value.code == 0
+ assert "1 file checked, 0 errors." in capsys.readouterr().out
+
+
+def test_main_force_exclude_flag(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
+ """Excluded file passed explicitly: linted by default, skipped with --force-exclude."""
+ (tmp_path / "pyproject.toml").write_text('[tool.docstring-linter]\nexclude = ["gen/**"]\n', encoding="utf-8")
+ (tmp_path / "gen").mkdir()
+ (tmp_path / "gen" / "model.py").write_text("def f():\n pass\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ monkeypatch.setattr(sys, "argv", ["docstring-linter", "gen/model.py", "--format", "text"])
+ with pytest.raises(SystemExit) as exc:
+ main()
+ assert exc.value.code == 1
+
+ monkeypatch.setattr(sys, "argv", ["docstring-linter", "gen/model.py", "--format", "text", "--force-exclude"])
+ with pytest.raises(SystemExit) as exc:
+ main()
+ assert exc.value.code == 0
+ assert "No Python files found." in capsys.readouterr().out
+
+
+def test_main_closed_output_pipe_exits_quietly(tmp_path: Path) -> None:
+ """Output pipe closed before the report is written: exit 1 without a traceback."""
+ f = tmp_path / "bad.py"
+ f.write_text("def f():\n pass\n", encoding="utf-8")
+ with subprocess.Popen([sys.executable, "-m", "linter.cli", str(f)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) as proc: # noqa: S603
+ assert proc.stdout is not None
+ proc.stdout.close()
+ _, stderr = proc.communicate()
+ assert proc.returncode == 1
+ assert b"BrokenPipeError" not in stderr
+
+
def test_list_rules_output(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
- """--list-rules: configurable rules appear grouped by category, always-on rules do not."""
+ """--list-rules: every rule appears, always-on rules in their own section after the categories."""
monkeypatch.setattr(sys, "argv", ["docstring-linter", "--list-rules"])
with pytest.raises(SystemExit) as exc:
main()
@@ -227,11 +417,42 @@ def test_list_rules_output(capsys: pytest.CaptureFixture[str], monkeypatch: pyte
out = capsys.readouterr().out
for category in RULES_CATEGORIES:
assert category in out
+ configurable, always_on = out.split("Always on", 1)
for rule in RULES_REGISTRY:
if rule in ALWAYS_ON:
- assert f"{rule} " not in out
+ assert f"{rule} " in always_on
+ assert f"{rule} " not in configurable
else:
- assert rule in out
+ assert f"{rule} " in configurable
+
+
+def test_list_rules_google_convention_disabled_rules(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
+ """--list-rules with the google convention: its disabled rules are counted and labelled as disabled by default."""
+ config = tmp_path / "linter.toml"
+ config.write_text('convention = "google"\n', encoding="utf-8")
+ monkeypatch.setattr(sys, "argv", ["docstring-linter", "--list-rules", "--config", str(config)])
+ with pytest.raises(SystemExit):
+ main()
+
+ out = capsys.readouterr().out
+ assert "3 disabled by default" in out
+ for rule in ("imperative_mood", "return_type_annotation", "raises_extraneous"):
+ assert "(disabled by default)" in next(line for line in out.splitlines() if f" {rule} " in line)
+
+
+def test_main_config_line_on_stderr(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
+ """Text format: the Config line goes to stderr, stdout only holds the report."""
+ config = tmp_path / "linter.toml"
+ config.write_text("", encoding="utf-8")
+ (tmp_path / "valid.py").write_text(_VALID_SOURCE, encoding="utf-8")
+ monkeypatch.setattr(sys, "argv", ["docstring-linter", str(tmp_path / "valid.py"), "--config", str(config), "--format", "text"])
+ with pytest.raises(SystemExit):
+ main()
+
+ captured = capsys.readouterr()
+ assert f"Config: {config}" in captured.err
+ assert "Config:" not in captured.out
+ assert "1 file checked, 0 errors." in captured.out
def test_main_invalid_config_value(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
@@ -245,3 +466,46 @@ def test_main_invalid_config_value(tmp_path: Path, capsys: pytest.CaptureFixture
assert exc.value.code == 2
assert "Configuration error" in capsys.readouterr().err
+
+
+def test_main_missing_config_file(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
+ """--config pointing to a missing file: prints a configuration error and exits with 2."""
+ monkeypatch.setattr(sys, "argv", ["docstring-linter", "--config", str(tmp_path / "missing.toml"), str(tmp_path)])
+
+ with pytest.raises(SystemExit) as exc:
+ main()
+
+ assert exc.value.code == 2
+ assert "config file not found" in capsys.readouterr().err
+
+
+def test_run_statistics_counts_errors_per_rule(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ """Run with statistics: one count per rule instead of each error, same exit code."""
+ f = tmp_path / "bad.py"
+ f.write_text('"""Module."""\n\ndef foo() -> int:\n pass\n\n\ndef bar() -> int:\n pass\n', encoding="utf-8")
+ assert run([str(f)], LinterConfig(), statistics=True) == 1
+ out = capsys.readouterr().out
+ assert "2 docstring_exists" in out
+ assert "foo" not in out
+ assert "2 errors in 1 file" in out
+
+
+def test_main_statistics_rejected_with_json(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
+ """--statistics with a machine-readable format: error on stderr and exit 2."""
+ config = tmp_path / "linter.toml"
+ config.write_text("", encoding="utf-8")
+ monkeypatch.setattr(sys, "argv", ["docstring-linter", "--config", str(config), "--statistics", "--format", "json", str(tmp_path)])
+
+ with pytest.raises(SystemExit) as exc:
+ main()
+
+ assert exc.value.code == 2
+ assert "--statistics is not available with the json format." in capsys.readouterr().err
+
+
+def test_run_unknown_rule_in_ignore_comment_returns_two(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ """Ignore comment naming an unknown rule: file and line on stderr, run returns 2."""
+ f = tmp_path / "typo.py"
+ f.write_text('"""Module."""\n\n\ndef foo() -> None: # docstring-linter: ignore[args_mach]\n pass\n', encoding="utf-8")
+ assert run([str(f)], LinterConfig()) == 2
+ assert f"{f}:4: unknown rule 'args_mach' in ignore comment." in capsys.readouterr().err
diff --git a/tests/linter/test_config.py b/tests/linter/test_config.py
index c007c02..60e5c3f 100644
--- a/tests/linter/test_config.py
+++ b/tests/linter/test_config.py
@@ -1,15 +1,14 @@
"""Tests for config module."""
import os
+import re
from pathlib import Path
import pytest
from linter.config import (
ALWAYS_ON,
- OFF_BY_DEFAULT,
OPTIONS_REGISTRY,
RULES_REGISTRY,
- DocstringStyle,
LinterConfig,
Policy,
_parse_toml_config, # pyright: ignore[reportPrivateUsage]
@@ -21,24 +20,9 @@
# ---------------------------------------------------------------------------
-def test_default_config_style() -> None:
- """Default config: style is GOOGLE."""
- assert LinterConfig().style == DocstringStyle.GOOGLE
-
-
-def test_default_config_rules_exclude_off_by_default() -> None:
- """Default config: OFF_BY_DEFAULT rules are not in enabled_rules."""
- config = LinterConfig()
- for rule in OFF_BY_DEFAULT:
- assert rule not in config.enabled_rules
-
-
-def test_default_config_all_other_rules_enabled() -> None:
- """Default config: all rules except OFF_BY_DEFAULT are enabled."""
- config = LinterConfig()
- for rule in RULES_REGISTRY:
- if rule not in OFF_BY_DEFAULT:
- assert rule in config.enabled_rules
+def test_default_config_all_rules_enabled() -> None:
+ """Default config, strict convention: every rule is enabled."""
+ assert LinterConfig().enabled_rules == list(RULES_REGISTRY)
def test_default_config_exclude_patterns_include_common_dirs() -> None:
@@ -103,16 +87,10 @@ def test_parse_no_select_no_ignore() -> None:
# ---------------------------------------------------------------------------
-def test_parse_style_google() -> None:
- """Style = 'google': config.style is DocstringStyle.GOOGLE."""
- config = _parse_toml_config({"style": "google"})
- assert config.style == DocstringStyle.GOOGLE
-
-
-def test_parse_style_unknown() -> None:
- """Style = 'unknown': raises ValueError listing the accepted styles."""
- with pytest.raises(ValueError, match="'style': invalid value 'unknown', expected one of google, numpy, sphinx, pep257"):
- _parse_toml_config({"style": "unknown"})
+def test_parse_style_key_removed() -> None:
+ """Style key, removed since Google is the only style: rejected as an unknown key."""
+ with pytest.raises(ValueError, match="unknown configuration key 'style'"):
+ _parse_toml_config({"style": "google"})
# ---------------------------------------------------------------------------
@@ -231,7 +209,6 @@ def test_option_values_reflect_config() -> None:
assert values["exclude_empty_init_method"] == "false"
assert values["summary_max_length"] == "72"
assert values["scope.modules"] == "false"
- assert values["style"] == "google"
def test_always_on_rule_stays_enabled_when_not_selected() -> None:
@@ -242,6 +219,175 @@ def test_always_on_rule_stays_enabled_when_not_selected() -> None:
assert config.is_rule_enabled(rule)
+# ---------------------------------------------------------------------------
+# convention
+# ---------------------------------------------------------------------------
+
+
+def test_convention_defaults_to_strict() -> None:
+ """No convention key: strict convention, same settings as the built-in defaults."""
+ config = _parse_toml_config({})
+ assert config.convention == "strict"
+ assert config.policy_values() == LinterConfig().policy_values()
+ assert config.enabled_rules == LinterConfig().enabled_rules
+
+
+def test_convention_google_sets_defaults() -> None:
+ """Convention = 'google': relaxed policies, no blank line before the closing quotes, two rules off."""
+ config = _parse_toml_config({"convention": "google"})
+ assert config.returns_none is Policy.OPTIONAL
+ assert config.init_returns_none is Policy.OPTIONAL
+ assert config.documented_types is Policy.OPTIONAL
+ assert config.raises_section is Policy.OPTIONAL
+ assert config.attributes_section is Policy.OPTIONAL
+ assert config.blank_lines_before_closing_quotes == 0
+ assert config.type_matching == "lenient"
+ assert config.exclude_dunder_methods is True
+ assert config.exclude_private is True
+ assert config.exclude_overridden is True
+ assert config.properties_as_attributes is True
+ assert config.init_args_location == "either"
+ assert config.documented_stars is Policy.REQUIRED
+ assert config.sections_optional_on_one_liners is True
+ assert "imperative_mood" not in config.enabled_rules
+ assert "return_type_annotation" not in config.enabled_rules
+ assert "raises_extraneous" not in config.enabled_rules
+ assert "args_order" in config.enabled_rules
+
+
+def test_convention_explicit_keys_win() -> None:
+ """Convention = 'google' with explicit keys: the keys of the file override the convention."""
+ config = _parse_toml_config({"convention": "google", "documented_types": "required", "blank_lines_before_closing_quotes": 1})
+ assert config.documented_types is Policy.REQUIRED
+ assert config.blank_lines_before_closing_quotes == 1
+ assert config.returns_none is Policy.OPTIONAL
+
+
+def test_convention_ignore_applies_on_top() -> None:
+ """Convention = 'google' with ignore: rules removed from the convention set, disabled ones stay off."""
+ enabled = _parse_toml_config({"convention": "google", "ignore": ["args_order"]}).enabled_rules
+ assert "args_order" not in enabled
+ assert "imperative_mood" not in enabled
+
+
+def test_convention_select_all_enables_everything() -> None:
+ """Convention = 'google' with select = ['ALL']: every rule is enabled, the explicit key wins."""
+ config = _parse_toml_config({"convention": "google", "select": ["ALL"]})
+ assert config.enabled_rules == sorted(RULES_REGISTRY)
+
+
+def test_convention_unknown() -> None:
+ """Convention = 'numpy': raises ValueError listing the accepted conventions."""
+ with pytest.raises(ValueError, match=re.escape("'convention': invalid value 'numpy', expected one of strict, google.")):
+ _parse_toml_config({"convention": "numpy"})
+
+
+def test_convention_rejected_in_override() -> None:
+ """Convention in an override: rejected, it sets the defaults of the whole run."""
+ with pytest.raises(ValueError, match="'convention' cannot be set per path"):
+ _parse_toml_config({"overrides": [{"paths": ["tests/**"], "convention": "google"}]})
+
+
+def test_convention_listed_in_option_values() -> None:
+ """option_values: reports the active convention."""
+ assert _parse_toml_config({"convention": "google"}).option_values()["convention"] == "google"
+
+
+# ---------------------------------------------------------------------------
+# docstring exemptions
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("option", ["exclude_dunder_methods", "exclude_private", "exclude_overridden", "properties_as_attributes", "sections_optional_on_one_liners"])
+def test_exemption_options(option: str) -> None:
+ """Exemption option: off by default, set from the file, allowed in an override."""
+ assert getattr(_parse_toml_config({}), option) is False
+ assert getattr(_parse_toml_config({option: True}), option) is True
+ config = _parse_toml_config({"overrides": [{"paths": ["tests/**"], option: True}]})
+ assert getattr(config.for_path("tests/test_foo.py"), option) is True
+
+
+def test_init_args_location() -> None:
+ """init_args_location: 'init' by default, accepts class and either, rejects other values."""
+ assert _parse_toml_config({}).init_args_location == "init"
+ assert _parse_toml_config({"init_args_location": "class"}).init_args_location == "class"
+ with pytest.raises(ValueError, match=re.escape("'init_args_location': invalid value 'both', expected one of init, class, either.")):
+ _parse_toml_config({"init_args_location": "both"})
+
+
+# ---------------------------------------------------------------------------
+# type_matching
+# ---------------------------------------------------------------------------
+
+
+def test_type_matching_default_strict() -> None:
+ """No type_matching key: strict comparison."""
+ assert _parse_toml_config({}).type_matching == "strict"
+
+
+def test_type_matching_set() -> None:
+ """type_matching = 'equivalent': stored as is and reported by option_values."""
+ config = _parse_toml_config({"type_matching": "equivalent"})
+ assert config.type_matching == "equivalent"
+ assert config.option_values()["type_matching"] == "equivalent"
+
+
+def test_type_matching_invalid() -> None:
+ """type_matching = 'loose': raises ValueError listing the accepted levels."""
+ with pytest.raises(ValueError, match=re.escape("'type_matching': invalid value 'loose', expected one of strict, equivalent, lenient.")):
+ _parse_toml_config({"type_matching": "loose"})
+
+
+def test_type_matching_in_override() -> None:
+ """type_matching in an override: applied to the matching files only."""
+ config = _parse_toml_config({"overrides": [{"paths": ["tests/**"], "type_matching": "lenient"}]})
+ assert config.for_path("tests/test_foo.py").type_matching == "lenient"
+ assert config.for_path("src/foo.py").type_matching == "strict"
+
+
+# ---------------------------------------------------------------------------
+# value types
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("data", "message"),
+ [
+ ({"workers": "4"}, "'workers': expected an integer, got '4'"),
+ ({"summary_max_length": True}, "'summary_max_length': expected an integer, got True"),
+ ({"exclude_empty_init_method": "no"}, "'exclude_empty_init_method': expected true or false, got 'no'"),
+ ({"exclude": "src"}, "'exclude': expected a list of strings, got 'src'"),
+ ({"select": "ALL"}, "'select': expected a list of strings, got 'ALL'"),
+ ({"ignore": ["imperative_mood", 3]}, "'ignore': expected a list of strings"),
+ ({"scope": "all"}, "'scope': expected a table, got 'all'"),
+ ({"scope": {"modules": "yes"}}, "'scope.modules': expected true or false, got 'yes'"),
+ ({"overrides": ["tests/**"]}, "'overrides': expected an array of tables"),
+ ],
+)
+def test_parse_rejects_wrong_value_type(data: dict[str, object], message: str) -> None:
+ """Value of the wrong TOML type: raises ValueError naming the key and the value."""
+ with pytest.raises(ValueError, match=re.escape(message)):
+ _parse_toml_config(data)
+
+
+def test_parse_override_rejects_wrong_value_type() -> None:
+ """Option of the wrong type in an override: raises ValueError naming the override."""
+ with pytest.raises(ValueError, match=re.escape("override ['tests/**']: 'summary_max_length': expected an integer, got 'x'")):
+ _parse_toml_config({"overrides": [{"paths": ["tests/**"], "summary_max_length": "x"}]})
+
+
+def test_parse_override_rejects_string_paths() -> None:
+ """Paths given as a string in an override: raises ValueError."""
+ with pytest.raises(ValueError, match=re.escape("override: 'paths': expected a list of strings")):
+ _parse_toml_config({"overrides": [{"paths": "tests/**"}]})
+
+
+def test_parse_override_option_clamped() -> None:
+ """summary_max_length = -5 in an override: clamped to 1, like at the top level."""
+ config = _parse_toml_config({"overrides": [{"paths": ["tests/**"], "summary_max_length": -5}]})
+ assert config.for_path("tests/test_foo.py").summary_max_length == 1
+
+
# ---------------------------------------------------------------------------
# unknown keys and rules
# ---------------------------------------------------------------------------
@@ -397,26 +543,60 @@ def test_for_path_select_replaces_inherited_rules() -> None:
assert config.for_path("tests/test_foo.py").enabled_rules == ["docstring_exists"]
+def test_for_path_override_select_all() -> None:
+ """Select = ['ALL'] in an override: every rule is enabled on the matching files."""
+ config = _parse_toml_config({"ignore": ["imperative_mood"], "overrides": [{"paths": ["tests/**"], "select": ["ALL"]}]})
+ assert config.for_path("tests/test_foo.py").enabled_rules == sorted(RULES_REGISTRY)
+
+
+def test_for_path_patterns_relative_to_base_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Run from a subdirectory: the override pattern is matched relative to base_dir, not to the current directory."""
+ (tmp_path / "src").mkdir()
+ monkeypatch.chdir(tmp_path / "src")
+ config = _parse_toml_config({"overrides": [{"paths": ["src/**"], "args_section": "optional"}]})
+ config.base_dir = tmp_path
+ assert config.for_path("foo.py").args_section is Policy.OPTIONAL
+
+
+def test_for_path_file_outside_base_dir(tmp_path: Path) -> None:
+ """File outside base_dir: no override applies, even with a catch-all pattern."""
+ config = _parse_toml_config({"overrides": [{"paths": ["**"], "args_section": "optional"}]})
+ config.base_dir = tmp_path / "project"
+ assert config.for_path(str(tmp_path / "other" / "foo.py")) is config
+
+
# ---------------------------------------------------------------------------
# load_config
# ---------------------------------------------------------------------------
-def test_load_config_no_file_returns_default(tmp_path: Path) -> None:
- """Explicit path that does not exist: returns default LinterConfig."""
- config, config_file = load_config(str(tmp_path / "nonexistent.toml"))
- assert config.style == DocstringStyle.GOOGLE
- assert config.enabled_rules == LinterConfig().enabled_rules
- assert config_file is None
+def test_load_config_missing_explicit_file(tmp_path: Path) -> None:
+ """Explicit path that does not exist: raises ValueError naming the path."""
+ with pytest.raises(ValueError, match=r"config file not found: .*nonexistent\.toml"):
+ load_config(str(tmp_path / "nonexistent.toml"))
-def test_load_config_toml_without_section_returns_default(tmp_path: Path) -> None:
- """pyproject.toml with no [tool.docstring-linter] section: returns default config."""
+def test_load_config_explicit_directory(tmp_path: Path) -> None:
+ """Explicit path that is a directory: raises ValueError."""
+ with pytest.raises(ValueError, match="config file not found"):
+ load_config(str(tmp_path))
+
+
+def test_load_config_base_dir_is_config_directory(tmp_path: Path) -> None:
+ """Explicit config file: base_dir is the resolved directory holding it."""
+ (tmp_path / "tools").mkdir()
+ f = tmp_path / "tools" / "linter.toml"
+ f.write_text("", encoding="utf-8")
+ config, _ = load_config(str(tmp_path / "tools" / ".." / "tools" / "linter.toml"))
+ assert config.base_dir == (tmp_path / "tools").resolve()
+
+
+def test_load_config_toml_without_section(tmp_path: Path) -> None:
+ """Explicit pyproject.toml with no [tool.docstring-linter] section: raises ValueError."""
f = tmp_path / "pyproject.toml"
f.write_text("[tool.ruff]\nline-length = 100\n", encoding="utf-8")
- config, config_file = load_config(str(f))
- assert config.style == DocstringStyle.GOOGLE
- assert config_file is None
+ with pytest.raises(ValueError, match=r"no \[tool.docstring-linter\] section"):
+ load_config(str(f))
def test_load_config_auto_discover(tmp_path: Path) -> None:
diff --git a/tests/linter/test_docstring_parser.py b/tests/linter/test_docstring_parser.py
index a58d53b..e8cca80 100644
--- a/tests/linter/test_docstring_parser.py
+++ b/tests/linter/test_docstring_parser.py
@@ -1,8 +1,6 @@
"""Tests for docstring_parser module."""
-import pytest
-from linter.config import DocstringStyle
-from linter.docstring_parser import GoogleStyleParser, get_parser
+from linter.docstring_parser import GoogleStyleParser
PARSER = GoogleStyleParser()
@@ -124,6 +122,30 @@ def test_parse_returns_bare_description() -> None:
assert result.returns.description == "The user name."
+def test_parse_returns_prose_with_colon() -> None:
+ """Returns line whose text before the colon is prose: the whole line is the description."""
+ result = PARSER.parse("Do something.\n\nReturns:\n The mapping: key to value.\n")
+ assert result.returns is not None
+ assert result.returns.type_annotation is None
+ assert result.returns.description == "The mapping: key to value."
+
+
+def test_parse_returns_continuation_lines() -> None:
+ """Returns description spread over several lines: the lines are joined."""
+ result = PARSER.parse("Do something.\n\nReturns:\n dict[str, int]: Mapping of\n names to counts.\n")
+ assert result.returns is not None
+ assert result.returns.type_annotation == "dict[str, int]"
+ assert result.returns.description == "Mapping of names to counts."
+
+
+def test_parse_returns_single_word_prose() -> None:
+ """Returns section holding a single word that is not a type: read as the description."""
+ result = PARSER.parse("Do something.\n\nReturns:\n Nothing.\n")
+ assert result.returns is not None
+ assert result.returns.type_annotation is None
+ assert result.returns.description == "Nothing."
+
+
def test_parse_no_returns_section() -> None:
"""Docstring without Returns section: returns field is None."""
result = PARSER.parse("Do something.")
@@ -196,16 +218,11 @@ def test_parse_attributes_multiple() -> None:
# ---------------------------------------------------------------------------
-def test_parse_example_section() -> None:
- """Docstring with Example section: examples list is populated."""
- result = PARSER.parse("Do something.\n\nExample:\n >>> f(1)\n 1\n")
- assert len(result.examples) == 1
-
-
-def test_parse_examples_section() -> None:
- """Docstring with Examples section (plural): examples list is populated."""
- result = PARSER.parse("Do something.\n\nExamples:\n >>> f(1)\n 1\n")
- assert len(result.examples) == 1
+def test_parse_examples_section_kept_apart() -> None:
+ """Examples section: its content leaks into neither the description nor the args."""
+ result = PARSER.parse("Do something.\n\nExamples:\n x: 1\n >>> f(1)\n")
+ assert result.description is None
+ assert not result.args
def test_parse_unknown_section_ignored() -> None:
@@ -221,33 +238,6 @@ def test_parse_lowercase_section_not_recognized() -> None:
assert not result.args
-# ---------------------------------------------------------------------------
-# style property
-# ---------------------------------------------------------------------------
-
-
-def test_parser_style_property() -> None:
- """Style property: returns DocstringStyle.GOOGLE."""
- assert PARSER.style == DocstringStyle.GOOGLE
-
-
-# ---------------------------------------------------------------------------
-# get_parser
-# ---------------------------------------------------------------------------
-
-
-def test_get_parser_google() -> None:
- """get_parser(GOOGLE): returns a GoogleStyleParser instance."""
- parser = get_parser(DocstringStyle.GOOGLE)
- assert isinstance(parser, GoogleStyleParser)
-
-
-def test_get_parser_unsupported() -> None:
- """get_parser with unsupported style: raises ValueError with style name."""
- with pytest.raises(ValueError, match="numpy"):
- get_parser(DocstringStyle.NUMPY)
-
-
# ---------------------------------------------------------------------------
# unknown sections
# ---------------------------------------------------------------------------
@@ -255,9 +245,9 @@ def test_get_parser_unsupported() -> None:
def test_unknown_section_detected() -> None:
"""Section name not in known list: captured in unknown_sections."""
- doc = "Do something.\n\nArguments:\n x (int): Input.\n"
+ doc = "Do something.\n\nParams:\n x (int): Input.\n"
result = PARSER.parse(doc)
- assert result.unknown_sections == ["Arguments"]
+ assert result.unknown_sections == ["Params"]
assert not result.args
@@ -270,6 +260,51 @@ def test_unknown_section_known_not_flagged() -> None:
def test_unknown_section_multiple() -> None:
"""Multiple unknown sections: all captured."""
- doc = "Do something.\n\nArguments:\n x (int): Input.\n\nParams:\n y (int): Other.\n\n"
+ doc = "Do something.\n\nUsage:\n x (int): Input.\n\nParams:\n y (int): Other.\n\n"
result = PARSER.parse(doc)
- assert set(result.unknown_sections) == {"Arguments", "Params"}
+ assert set(result.unknown_sections) == {"Usage", "Params"}
+
+
+# ---------------------------------------------------------------------------
+# Napoleon sections
+# ---------------------------------------------------------------------------
+
+
+def test_parse_alias_read_as_canonical() -> None:
+ """Parameters: alias of Args, its entries are parsed as args and it is not unknown."""
+ result = PARSER.parse("Do something.\n\nParameters:\n x (int): Input.\n")
+ assert [a.name for a in result.args] == ["x"]
+ assert not result.unknown_sections
+
+
+def test_parse_args_and_other_parameters_joined() -> None:
+ """Args and Other Parameters sections: entries of both end up in args."""
+ result = PARSER.parse("Do something.\n\nArgs:\n x (int): Input.\n\nOther Parameters:\n y (int): Rare.\n")
+ assert [a.name for a in result.args] == ["x", "y"]
+
+
+def test_parse_keyword_args() -> None:
+ """Keyword Arguments: entries parsed into keyword_args, not into args."""
+ result = PARSER.parse("Do something.\n\nKeyword Arguments:\n width (int): Width.\n")
+ assert [a.name for a in result.keyword_args] == ["width"]
+ assert not result.args
+
+
+def test_parse_free_text_sections() -> None:
+ """Warning and See Also: known sections, neither unknown nor merged into the description."""
+ result = PARSER.parse("Do something.\n\nMore details.\n\nWarning:\n Experimental.\n\nSee Also:\n other().\n")
+ assert not result.unknown_sections
+ assert result.description == "More details."
+
+
+def test_parse_note_and_notes_distinct() -> None:
+ """Note and Notes: two known sections, neither is an alias nor unknown."""
+ result = PARSER.parse("Do something.\n\nNote:\n One.\n\nNotes:\n Two.\n")
+ assert not result.unknown_sections
+
+
+def test_parse_exceptions_is_unknown() -> None:
+ """Exceptions: not a Napoleon section, reported as unknown and not read as Raises."""
+ result = PARSER.parse("Do something.\n\nExceptions:\n ValueError: Bad.\n")
+ assert result.unknown_sections == ["Exceptions"]
+ assert not result.raises
diff --git a/tests/linter/test_end_to_end.py b/tests/linter/test_end_to_end.py
new file mode 100644
index 0000000..8a78fd7
--- /dev/null
+++ b/tests/linter/test_end_to_end.py
@@ -0,0 +1,767 @@
+"""End-to-end tests running lint_file on real sources, AST extraction included."""
+
+import textwrap
+from pathlib import Path # noqa: TC003
+from typing import TYPE_CHECKING
+
+import pytest
+from linter.cli import lint_file
+from linter.config import LinterConfig, _parse_toml_config # pyright: ignore[reportPrivateUsage]
+
+if TYPE_CHECKING:
+ from linter.models import LintError
+
+
+def _lint(tmp_path: Path, source: str, config: LinterConfig | None = None) -> list[tuple[str, str]]:
+ """Lint a source snippet, with the default config unless one is given, and return (entity, rule) pairs."""
+ f = tmp_path / "sample.py"
+ f.write_text(textwrap.dedent(source), encoding="utf-8")
+ return [(e.entity_name, e.rule) for e in lint_file(str(f), config or LinterConfig())]
+
+
+def test_nested_generator_does_not_make_outer_a_generator(tmp_path: Path) -> None:
+ """Function defining a nested generator: documented with Returns, no error."""
+ source = '''\
+ """Module."""
+
+ from collections.abc import Iterator
+
+
+ def outer(values: list[int]) -> list[int]:
+ """Return the values.
+
+ Args:
+ values (list[int]): Values.
+
+ Returns:
+ list[int]: The values.
+
+ """
+
+ def gen() -> Iterator[int]:
+ yield from values
+
+ return list(gen())
+ '''
+ assert not _lint(tmp_path, source)
+
+
+def test_reraise_of_caught_exception(tmp_path: Path) -> None:
+ """Raise err inside 'except ValueError as err': ValueError documented, no error."""
+ source = '''\
+ """Module."""
+
+
+ def convert(value: str) -> int:
+ """Convert the value.
+
+ Args:
+ value (str): The value.
+
+ Returns:
+ int: The value.
+
+ Raises:
+ ValueError: If the value is invalid.
+
+ """
+ try:
+ return int(value)
+ except ValueError as err:
+ raise err
+ '''
+ assert not _lint(tmp_path, source)
+
+
+def test_bare_reraise_requires_caught_exception(tmp_path: Path) -> None:
+ """Bare raise inside 'except ValueError': ValueError must be documented."""
+ source = '''\
+ """Module."""
+
+
+ def convert(value: str) -> int:
+ """Convert the value.
+
+ Args:
+ value (str): The value.
+
+ Returns:
+ int: The value.
+
+ """
+ try:
+ return int(value)
+ except ValueError:
+ print(value)
+ raise
+ '''
+ assert _lint(tmp_path, source) == [("convert", "raises_section")]
+
+
+def test_tuple_self_assignment_requires_attributes(tmp_path: Path) -> None:
+ """self.a, self.b = ... in __init__: both attributes must be documented."""
+ source = '''\
+ """Module."""
+
+
+ class Pair:
+ """Hold two values.
+
+ Attributes:
+ first (int): First value.
+
+ """
+
+ def __init__(self) -> None:
+ """Build the pair."""
+ self.first, self.second = 1, 2
+ '''
+ errors = [e for e in _lint(tmp_path, source) if e[0] == "Pair"]
+ assert errors == [("Pair", "attributes_section")]
+
+
+def test_dotted_exception(tmp_path: Path) -> None:
+ """Raise errors.ValidationError: documented by short or dotted name, no error."""
+ source = '''\
+ """Module."""
+
+ import errors
+
+
+ def short(value: int) -> None:
+ """Check the value.
+
+ Args:
+ value (int): The value.
+
+ Returns:
+ None
+
+ Raises:
+ ValidationError: If negative.
+
+ """
+ if value < 0:
+ raise errors.ValidationError
+
+
+ def dotted(value: int) -> None:
+ """Check the value.
+
+ Args:
+ value (int): The value.
+
+ Returns:
+ None
+
+ Raises:
+ errors.ValidationError: If negative.
+
+ """
+ if value < 0:
+ raise errors.ValidationError
+ '''
+ assert not _lint(tmp_path, source)
+
+
+def test_undocumented_dotted_exception_reported(tmp_path: Path) -> None:
+ """Raise errors.ValidationError without Raises section: reported by raises_section."""
+ source = '''\
+ """Module."""
+
+ import errors
+
+
+ def check(value: int) -> None:
+ """Check the value.
+
+ Args:
+ value (int): The value.
+
+ Returns:
+ None
+
+ """
+ if value < 0:
+ raise errors.ValidationError
+ '''
+ assert _lint(tmp_path, source) == [("check", "raises_section")]
+
+
+def test_function_under_if_is_linted(tmp_path: Path) -> None:
+ """Function defined under an if block: linted like a top-level function."""
+ source = '''\
+ """Module."""
+
+ import sys
+
+ if sys.version_info >= (3, 12):
+
+ def hidden(value: int) -> int:
+ return value
+ '''
+ assert _lint(tmp_path, source) == [("hidden", "docstring_exists")]
+
+
+def test_metaclass_first_parameter_not_required(tmp_path: Path) -> None:
+ """Metaclass __new__(mcs, ...): mcs is not required in Args."""
+ source = '''\
+ """Module."""
+
+
+ class Meta(type):
+ """Define the metaclass."""
+
+ def __new__(mcs, name: str, bases: tuple[type, ...], ns: dict[str, object]) -> type:
+ """Create the class.
+
+ Args:
+ name (str): Name.
+ bases (tuple[type, ...]): Bases.
+ ns (dict[str, object]): Namespace.
+
+ Returns:
+ type: New class.
+
+ """
+ return super().__new__(mcs, name, bases, ns)
+ '''
+ assert not _lint(tmp_path, source)
+
+
+def test_staticmethod_first_parameter_required(tmp_path: Path) -> None:
+ """@staticmethod: the first parameter must be documented like any other."""
+ source = '''\
+ """Module."""
+
+
+ class Tools:
+ """Group helpers."""
+
+ @staticmethod
+ def double(value: int) -> int:
+ """Double the value.
+
+ Returns:
+ int: Twice the value.
+
+ """
+ return value * 2
+ '''
+ assert _lint(tmp_path, source) == [("Tools.double", "args_section")]
+
+
+def test_overload_stubs_not_linted(tmp_path: Path) -> None:
+ """@overload stubs without docstring: only the documented implementation is linted."""
+ source = '''\
+ """Module."""
+
+ from typing import overload
+
+
+ @overload
+ def conv(x: int) -> int: ...
+ @overload
+ def conv(x: str) -> str: ...
+ def conv(x: int | str) -> int | str:
+ """Convert the value.
+
+ Args:
+ x (int | str): Value.
+
+ Returns:
+ int | str: Converted value.
+
+ """
+ return x
+ '''
+ assert not _lint(tmp_path, source)
+
+
+def test_main_guard_not_linted(tmp_path: Path) -> None:
+ """Demo code under 'if __name__ == "__main__":' without docstrings: no error."""
+ source = '''\
+ """Module."""
+
+ if __name__ == "__main__":
+
+ def demo(value):
+ return value
+
+ class Demo:
+ pass
+ '''
+ assert not _lint(tmp_path, source)
+
+
+def test_variable_raise_not_required_in_raises(tmp_path: Path) -> None:
+ """Raise of a variable holding an exception: nothing to document, no error."""
+ source = '''\
+ """Module."""
+
+
+ def fail(message: str) -> None:
+ """Fail with a message.
+
+ Args:
+ message (str): The message.
+
+ Returns:
+ None
+
+ """
+ error = RuntimeError(message)
+ raise error
+ '''
+ assert not _lint(tmp_path, source)
+
+
+_GOOGLE_GUIDE_SOURCE = '''\
+ """Module."""
+
+
+ class Cache:
+ """Store computed values.
+
+ Attributes:
+ size: Number of cached values.
+ """
+
+ def __init__(self) -> None:
+ """Initialize an empty cache."""
+ self.size = 0
+ self._store: dict[str, int] = {}
+
+ def fetch(self, key: str, default: int | None = None) -> int | None:
+ """Fetches a cached value.
+
+ Args:
+ key: Cache key.
+ default: Value returned when the key is missing.
+
+ Returns:
+ int | None: The cached value, or the default.
+ """
+ return self._store.get(key, default)
+
+ def clear(self) -> None:
+ """Removes every cached value."""
+ self._store.clear()
+ '''
+
+
+def test_google_convention_accepts_google_guide_style(tmp_path: Path) -> None:
+ """Google guide layout (untyped Args, no Returns: None, descriptive mood): no error under convention google."""
+ assert not _lint(tmp_path, _GOOGLE_GUIDE_SOURCE, _parse_toml_config({"convention": "google"}))
+
+
+def test_strict_convention_rejects_google_guide_style(tmp_path: Path) -> None:
+ """Same source under the strict default: the house rules the google convention relaxes are reported."""
+ rules = {rule for _, rule in _lint(tmp_path, _GOOGLE_GUIDE_SOURCE)}
+ assert {"args_match", "attributes_section", "blank_lines", "imperative_mood", "returns_none"} <= rules
+
+
+def test_google_convention_accepts_napoleon_types(tmp_path: Path) -> None:
+ """Optional parameter documented '(int, optional)', forward reference, untyped Returns: no error under google."""
+ source = '''\
+ """Module."""
+
+ from typing import Optional
+
+
+ class Node:
+ """Store a node."""
+
+ def resize(self, width: Optional[int] = None, other: "Node | None" = None) -> "Node":
+ """Return a resized copy.
+
+ Args:
+ width (int, optional): New width. Defaults to None.
+ other (~Node, optional): Node to copy the height from.
+
+ Returns:
+ The new node: a copy with the requested size.
+ """
+ return self
+ '''
+ assert not _lint(tmp_path, source, _parse_toml_config({"convention": "google"}))
+
+
+def test_strict_convention_reports_implicit_none(tmp_path: Path) -> None:
+ """Same kind of source under strict: '(int, optional)' for Optional[int] and the untyped Returns are reported."""
+ source = '''\
+ """Module."""
+
+ from typing import Optional
+
+
+ def scale(width: Optional[int] = None) -> int:
+ """Scale the width.
+
+ Args:
+ width (int, optional): New width.
+
+ Returns:
+ The scaled width: twice the input.
+
+ """
+ return (width or 0) * 2
+ '''
+ assert _lint(tmp_path, source) == [("scale", "args_match"), ("scale", "returns_match")]
+
+
+_PROPAGATED_SOURCE = '''\
+ """Module."""
+
+
+ def parse(value: str) -> int:
+ """Parse the value.
+
+ Args:
+ value (str): Text to parse.
+
+ Returns:
+ int: Parsed value.
+
+ Raises:
+ ValueError: Propagated from int() when the text is not a number.
+
+ """
+ return int(value)
+ '''
+
+
+def test_propagated_exception_reported_under_strict(tmp_path: Path) -> None:
+ """Exception documented but raised by a callee: raises_extraneous under the strict default."""
+ assert _lint(tmp_path, _PROPAGATED_SOURCE) == [("parse", "raises_extraneous")]
+
+
+def test_propagated_exception_accepted_under_google(tmp_path: Path) -> None:
+ """Same source under convention google, blank line kept before the quotes: raises_extraneous is off, no error."""
+ assert not _lint(tmp_path, _PROPAGATED_SOURCE, _parse_toml_config({"convention": "google", "blank_lines_before_closing_quotes": 1}))
+
+
+def test_napoleon_sections(tmp_path: Path) -> None:
+ """Keyword Args, Warning and See Also: **kwargs documented, no unknown section, no error."""
+ source = '''\
+ """Module."""
+
+
+ def configure(**options: int) -> None:
+ """Configure the widget.
+
+ Keyword Args:
+ width (int): Width in pixels.
+
+ Warning:
+ Experimental.
+
+ See Also:
+ reset().
+
+ Returns:
+ None
+
+ """
+ '''
+ assert not _lint(tmp_path, source)
+
+
+def test_parameters_alias(tmp_path: Path) -> None:
+ """Parameters instead of Args: arguments count as documented, section_alias is the only error."""
+ source = '''\
+ """Module."""
+
+
+ def double(value: int) -> int:
+ """Double the value.
+
+ Parameters:
+ value (int): The value.
+
+ Returns:
+ int: Twice the value.
+
+ """
+ return value * 2
+ '''
+ assert _lint(tmp_path, source) == [("double", "section_alias")]
+
+
+_EXEMPTIONS_SOURCE = '''\
+ """Module."""
+
+ from typing import override
+
+
+ class Base:
+ """Define the base."""
+
+ def run(self) -> int:
+ """Run the job.
+
+ Returns:
+ int: Result.
+
+ """
+ return 0
+
+
+ class Child(Base):
+ """Define the child."""
+
+ def __repr__(self) -> str:
+ return "Child"
+
+ def _cache_key(self) -> str:
+ return "key"
+
+ @override
+ def run(self) -> int:
+ return 1
+
+ @property
+ def size(self) -> int:
+ """The number of items."""
+ return 1
+
+ @size.setter
+ def size(self, value: int) -> None:
+ pass
+
+
+ def _helper(value):
+ return value
+
+
+ class _Private:
+ def method(self):
+ pass
+ '''
+
+
+def test_exemptions_off_by_default(tmp_path: Path) -> None:
+ """Strict default: dunder, private, overridden and property methods are all checked."""
+ errors = _lint(tmp_path, _EXEMPTIONS_SOURCE)
+ assert {
+ ("Child.__repr__", "docstring_exists"),
+ ("Child._cache_key", "docstring_exists"),
+ ("Child.run", "docstring_exists"),
+ ("Child.size", "returns_section"),
+ ("_helper", "docstring_exists"),
+ ("_Private.method", "docstring_exists"),
+ } <= set(errors)
+
+
+def test_exemptions_enabled(tmp_path: Path) -> None:
+ """All four options on: none of those entities is reported."""
+ config = _parse_toml_config({"exclude_dunder_methods": True, "exclude_private": True, "exclude_overridden": True, "properties_as_attributes": True, "ignore": ["return_type_annotation"]})
+ assert not _lint(tmp_path, _EXEMPTIONS_SOURCE, config)
+
+
+def test_exempted_docstring_still_checked(tmp_path: Path) -> None:
+ """Dunder with a docstring under exclude_dunder_methods: the docstring content is still checked."""
+ source = '''\
+ """Module."""
+
+
+ class Box:
+ """Store a value."""
+
+ def __len__(self) -> int:
+ """Count the values."""
+ return 1
+ '''
+ assert _lint(tmp_path, source, _parse_toml_config({"exclude_dunder_methods": True})) == [("Box.__len__", "returns_section")]
+
+
+_CLASS_ARGS = '''\
+ """Module."""
+
+
+ class Cache:
+ """Store computed values.
+
+ Args:
+ size (int): Maximum number of values.
+
+ """
+
+ def __init__(self, size: int) -> None:
+ print(size)
+ '''
+
+
+def test_init_args_in_class_rejected_by_default(tmp_path: Path) -> None:
+ """Parameters documented in the class, init_args_location = 'init' (default): __init__ lacks a docstring."""
+ assert _lint(tmp_path, _CLASS_ARGS) == [("Cache.__init__", "docstring_exists")]
+
+
+@pytest.mark.parametrize("location", ["class", "either"])
+def test_init_args_in_class_accepted(tmp_path: Path, location: str) -> None:
+ """Parameters documented in the class, location class or either: no error."""
+ assert not _lint(tmp_path, _CLASS_ARGS, _parse_toml_config({"init_args_location": location}))
+
+
+def test_init_args_in_class_checked(tmp_path: Path) -> None:
+ """Class Args missing a parameter, location either: the parameter is reported on the class."""
+ source = _CLASS_ARGS.replace("self, size: int", "self, size: int, ttl: int")
+ assert _lint(tmp_path, source, _parse_toml_config({"init_args_location": "either"})) == [("Cache", "args_section")]
+
+
+def test_init_args_either_falls_back_to_init(tmp_path: Path) -> None:
+ """Class without Args, location either: __init__ is checked as usual and needs its docstring."""
+ source = _CLASS_ARGS.replace(" Args:\n size (int): Maximum number of values.\n\n", "")
+ assert _lint(tmp_path, source, _parse_toml_config({"init_args_location": "either"})) == [("Cache.__init__", "docstring_exists")]
+
+
+def test_init_args_short_init_docstring(tmp_path: Path) -> None:
+ """__init__ with a docstring but no Args, class with Args, location either: no error."""
+ source = _CLASS_ARGS.replace(" print(size)", ' """Create the cache."""\n print(size)')
+ assert not _lint(tmp_path, source, _parse_toml_config({"init_args_location": "either"}))
+
+
+def test_init_args_mixed(tmp_path: Path) -> None:
+ """Args in the class and in __init__, location either: reported as mixed on __init__."""
+ init_doc = ' """Create the cache.\n\n Args:\n size (int): Maximum.\n\n """\n print(size)'
+ source = _CLASS_ARGS.replace(" print(size)", init_doc)
+ errors = _lint_errors(tmp_path, source, _parse_toml_config({"init_args_location": "either"}))
+ assert [(e.entity_name, e.message) for e in errors] == [("Cache.__init__", "Parameters of __init__ are documented both in the class docstring and in '__init__'.")]
+
+
+def test_init_args_class_location_rejects_init_args(tmp_path: Path) -> None:
+ """Args only in __init__, location class: reported on __init__, and missing on the class."""
+ init_doc = ' """Create the cache.\n\n Args:\n size (int): Maximum.\n\n """\n print(size)'
+ source = _CLASS_ARGS.replace(" Args:\n size (int): Maximum number of values.\n\n", "").replace(" print(size)", init_doc)
+ errors = _lint_errors(tmp_path, source, _parse_toml_config({"init_args_location": "class"}))
+ assert [(e.entity_name, e.rule, e.message) for e in errors] == [
+ ("Cache", "args_section", "Arg 'size' in signature but not documented."),
+ ("Cache.__init__", "args_section", "Parameters of __init__ must be documented in the class docstring, not in '__init__'."),
+ ]
+
+
+def _lint_errors(tmp_path: Path, source: str, config: LinterConfig) -> list[LintError]:
+ """Lint a source snippet and return the full errors, messages included."""
+ f = tmp_path / "sample.py"
+ f.write_text(textwrap.dedent(source), encoding="utf-8")
+ return lint_file(str(f), config)
+
+
+_ONE_LINERS = '''\
+ """Module."""
+
+ from collections.abc import Iterator
+
+
+ def scale(width: int, factor: int = 2) -> int:
+ """Scale a width by a factor."""
+ if width < 0:
+ raise ValueError(width)
+ return width * factor
+
+
+ def count(limit: int) -> Iterator[int]:
+ """Count up to the limit."""
+ yield from range(limit)
+
+
+ def reset(width: int) -> None:
+ """Reset the width."""
+ '''
+
+
+def test_one_liners_need_sections_by_default(tmp_path: Path) -> None:
+ """One-line docstrings on annotated functions, option off (strict default): every missing section is reported."""
+ rules = {rule for _, rule in _lint(tmp_path, _ONE_LINERS)}
+ assert {"args_section", "returns_section", "raises_section", "yields_section", "returns_none"} <= rules
+
+
+def test_one_liners_sections_optional(tmp_path: Path) -> None:
+ """Same functions, sections_optional_on_one_liners on: no error."""
+ assert not _lint(tmp_path, _ONE_LINERS, _parse_toml_config({"sections_optional_on_one_liners": True}))
+
+
+def test_one_liner_without_annotations_still_checked(tmp_path: Path) -> None:
+ """One-line docstring on a function missing an annotation: sections still required."""
+ source = _ONE_LINERS.replace("def scale(width: int, factor: int = 2)", "def scale(width, factor: int = 2)")
+ errors = _lint(tmp_path, source, _parse_toml_config({"sections_optional_on_one_liners": True, "ignore": ["return_type_annotation"]}))
+ assert ("scale", "args_section") in errors
+
+
+def test_multi_line_docstring_still_checked(tmp_path: Path) -> None:
+ """Docstring with a description but no section: not a one-liner, sections still required."""
+ source = _ONE_LINERS.replace('"""Scale a width by a factor."""', '"""Scale a width by a factor.\n\n Negative widths are rejected.\n\n """')
+ errors = _lint(tmp_path, source, _parse_toml_config({"sections_optional_on_one_liners": True}))
+ assert ("scale", "args_section") in errors
+
+
+_CART = '''\
+ """Shopping cart helpers."""
+
+
+ def total(prices: list[float], discount: float = 0.0) -> float:
+ """Compute the total price.
+
+ Args:
+ prices (list[int]): Item prices.
+
+ """
+ if discount < 0:
+ raise ValueError(discount)
+ return sum(prices) * (1 - discount)
+ '''
+
+_CART_ERRORS = {("total", "args_section"), ("total", "args_match"), ("total", "returns_section"), ("total", "raises_section")}
+
+
+def test_ignore_comment_named_rules(tmp_path: Path) -> None:
+ """Ignore comment naming an always-on rule and a policy: both silenced, the other errors kept."""
+ assert set(_lint(tmp_path, _CART)) == _CART_ERRORS
+ source = _CART.replace("-> float:", "-> float: # docstring-linter: ignore[args_match, returns_section]")
+ assert set(_lint(tmp_path, source)) == {("total", "args_section"), ("total", "raises_section")}
+
+
+def test_ignore_comment_without_rules(tmp_path: Path) -> None:
+ """Ignore comment without brackets: every error of the entity silenced."""
+ assert not _lint(tmp_path, _CART.replace("-> float:", "-> float: # docstring-linter: ignore"))
+
+
+def test_ignore_comment_on_decorated_function(tmp_path: Path) -> None:
+ """Decorated function, comment on the def line: errors silenced."""
+ source = _CART.replace(" def total", " @staticmethod\n def total").replace("-> float:", "-> float: # docstring-linter: ignore")
+ assert not _lint(tmp_path, source)
+
+
+def test_ignore_comment_on_multi_line_signature(tmp_path: Path) -> None:
+ """Signature spread over several lines: the comment goes on the def line, not on the closing one."""
+ split = _CART.replace("def total(prices", "def total( # docstring-linter: ignore\n prices")
+ assert not _lint(tmp_path, split)
+ closing = _CART.replace("def total(prices", "def total(\n prices").replace("-> float:", "-> float: # docstring-linter: ignore")
+ assert set(_lint(tmp_path, closing)) == _CART_ERRORS
+
+
+def test_ignore_comment_on_class(tmp_path: Path) -> None:
+ """Class without docstring, comment on the class line: its error silenced, its methods still checked."""
+ source = '''\
+ """Module."""
+
+
+ class Cart: # docstring-linter: ignore[docstring_exists]
+ def clear(self) -> None:
+ pass
+ '''
+ assert _lint(tmp_path, source) == [("Cart.clear", "docstring_exists")]
+
+
+def test_ignore_comment_on_module_docstring(tmp_path: Path) -> None:
+ """Module docstring without final period, comment on its opening line: error silenced."""
+ source = _CART.replace('"""Shopping cart helpers."""', '"""Shopping cart helpers"""')
+ assert ("sample", "summary_final_period") in _lint(tmp_path, source)
+ ignored = source.replace('"""Shopping cart helpers"""', '"""Shopping cart helpers""" # docstring-linter: ignore[summary_final_period]')
+ assert set(_lint(tmp_path, ignored)) == _CART_ERRORS
+
+
+def test_ignore_comment_inside_string_not_matched(tmp_path: Path) -> None:
+ """Ignore text inside a default value, not at the end of the line: errors kept."""
+ source = _CART.replace("discount: float = 0.0) -> float:", 'discount: str = "# docstring-linter: ignore") -> float:')
+ assert ("total", "returns_section") in _lint(tmp_path, source)
diff --git a/tests/linter/test_integration.py b/tests/linter/test_integration.py
index 1139684..59af030 100644
--- a/tests/linter/test_integration.py
+++ b/tests/linter/test_integration.py
@@ -98,7 +98,7 @@ def test_collect_python_files_finds_all_py(tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
(sub / "c.py").write_text("", encoding="utf-8")
- files = collect_python_files([str(tmp_path)], [])
+ files = collect_python_files([str(tmp_path)], [], tmp_path)
names = {Path(f).name for f in files}
assert names == {"a.py", "b.py", "c.py"}
@@ -107,7 +107,7 @@ def test_collect_python_files_exclude_pattern(tmp_path: Path) -> None:
"""Directory with exclusion pattern: matching files are not collected."""
(tmp_path / "main.py").write_text("", encoding="utf-8")
(tmp_path / "test_main.py").write_text("", encoding="utf-8")
- files = collect_python_files([str(tmp_path)], ["test_*"])
+ files = collect_python_files([str(tmp_path)], ["test_*"], tmp_path)
names = {Path(f).name for f in files}
assert "main.py" in names
assert "test_main.py" not in names
@@ -180,7 +180,7 @@ def test_cli_invalid_file_exit_one(tmp_path: Path) -> None:
def test_cli_syntax_error_no_crash(tmp_path: Path) -> None:
- """CLI on file with SyntaxError: does not crash, prints error message, exits 0."""
+ """CLI on file with SyntaxError: does not crash, prints error message on stderr, exits 2."""
f = tmp_path / "bad.py"
f.write_text(_SYNTAX_ERROR_SOURCE, encoding="utf-8")
result = subprocess.run( # noqa: S603
@@ -191,8 +191,8 @@ def test_cli_syntax_error_no_crash(tmp_path: Path) -> None:
env=_ENV,
cwd=tmp_path,
)
- assert result.returncode == 0
- assert "Syntax error" in result.stdout or "Syntax error" in result.stderr
+ assert result.returncode == 2
+ assert "Syntax error" in result.stderr
# ---------------------------------------------------------------------------
@@ -257,7 +257,7 @@ def test_cli_github_annotations_valid_file(tmp_path: Path) -> None:
cwd=tmp_path,
)
assert result.returncode == 0
- assert "1 files checked, 0 errors." in result.stdout
+ assert "1 file checked, 0 errors." in result.stdout
def test_cli_github_annotations_invalid_file(tmp_path: Path) -> None:
diff --git a/tests/linter/test_registries.py b/tests/linter/test_registries.py
new file mode 100644
index 0000000..6f47bef
--- /dev/null
+++ b/tests/linter/test_registries.py
@@ -0,0 +1,106 @@
+"""Consistency tests between the rule and policy registries, the config, the rules package, the docs and TESTS.md."""
+
+import ast
+import dataclasses
+import re
+from pathlib import Path
+
+from linter.config import (
+ ALWAYS_ON,
+ CONFIG_KEYS,
+ CONVENTIONS,
+ OPTIONS_REGISTRY,
+ OVERRIDABLE_OPTIONS,
+ POLICIES_REGISTRY,
+ RULES_CATEGORIES,
+ RULES_REGISTRY,
+ SCOPE_KEYS,
+ TYPED_OPTIONS,
+ LinterConfig,
+ Policy,
+)
+
+_ROOT = Path(__file__).resolve().parents[2]
+
+
+def _doc_headings() -> set[str]:
+ """Collect the identifiers named by the headings of the docs pages, code blocks excluded."""
+ names: set[str] = set()
+ for page in (_ROOT / "docs").glob("*.md"):
+ text = re.sub(r"^```.*?^```", "", page.read_text(encoding="utf-8"), flags=re.DOTALL | re.MULTILINE)
+ for heading in re.findall(r"^#+ (.+)$", text, flags=re.MULTILINE):
+ names.update(name.strip().strip("`") for name in heading.split(","))
+ return names
+
+
+def test_every_rule_in_exactly_one_category() -> None:
+ """Each rule of RULES_REGISTRY appears in one category of RULES_CATEGORIES, and nothing else does."""
+ categorized = [rule for rules in RULES_CATEGORIES.values() for rule in rules]
+ assert sorted(categorized) == sorted(RULES_REGISTRY)
+
+
+def test_rule_subsets_are_registered_rules() -> None:
+ """ALWAYS_ON and the rules each convention disables only name registered rules, never an always-on one."""
+ assert ALWAYS_ON.issubset(RULES_REGISTRY)
+ for convention in CONVENTIONS.values():
+ assert convention.disabled_rules.issubset(RULES_REGISTRY)
+ assert convention.disabled_rules.isdisjoint(ALWAYS_ON)
+
+
+def test_every_policy_is_a_config_field() -> None:
+ """Each policy of POLICIES_REGISTRY is a LinterConfig field holding a Policy."""
+ defaults = {field.name: field.default for field in dataclasses.fields(LinterConfig)}
+ for policy in POLICIES_REGISTRY:
+ assert isinstance(defaults.get(policy), Policy), policy
+
+
+def test_every_rule_and_policy_documented() -> None:
+ """Each rule and policy has a heading of its own in the docs pages."""
+ missing = (set(RULES_REGISTRY) | set(POLICIES_REGISTRY)) - _doc_headings()
+ assert not missing
+
+
+def test_every_rule_reported_by_the_rules_package() -> None:
+ """Each rule identifier is written as a string literal in the rules package, where errors are made."""
+ source = "".join(path.read_text(encoding="utf-8") for path in (_ROOT / "src" / "linter" / "rules").glob("*.py"))
+ missing = [rule for rule in RULES_REGISTRY if f'"{rule}"' not in source]
+ assert not missing
+
+
+def test_every_listed_option_is_a_typed_config_field() -> None:
+ """Each option shown by --list-rules is a LinterConfig field, typed unless it is the convention or a scope flag."""
+ fields = {field.name for field in dataclasses.fields(LinterConfig)}
+ scope_options = {f"scope.{key}" for key in SCOPE_KEYS}
+ assert {f"check_{key}" for key in SCOPE_KEYS} <= fields
+ assert set(OPTIONS_REGISTRY) - scope_options <= fields
+ # workers is typed but not listed: it changes how the run goes, not what is checked
+ assert set(OPTIONS_REGISTRY) - scope_options - {"convention"} == TYPED_OPTIONS - {"workers"}
+
+
+def test_every_config_key_documented() -> None:
+ """The key table of docs/configuration.md has one row per accepted key, scope flags spelled out."""
+ text = (_ROOT / "docs" / "configuration.md").read_text(encoding="utf-8")
+ table = text[text.index("### Available keys") : text.index("### Exclusion patterns")]
+ rows = set(re.findall(r"^\| `([\w.]+)` \|", table, flags=re.MULTILINE))
+ assert rows == (CONFIG_KEYS - {"scope", "overrides"}) | {f"scope.{key}" for key in SCOPE_KEYS}
+
+
+def test_override_options_documented() -> None:
+ """The override section of docs/configuration.md names exactly the options an override may carry."""
+ text = (_ROOT / "docs" / "configuration.md").read_text(encoding="utf-8")
+ line = next(line for line in text.splitlines() if line.startswith("- An override may carry"))
+ assert set(re.findall(r"`(\w+)`", line)) == OVERRIDABLE_OPTIONS
+
+
+def test_every_test_function_listed_in_tests_md() -> None:
+ """TESTS.md has one row per test function of tests/linter, and no row for a missing one."""
+ tests_dir = _ROOT / "tests" / "linter"
+ functions = {
+ (path.relative_to(tests_dir).as_posix(), node.name)
+ for path in tests_dir.rglob("test_*.py")
+ for node in ast.parse(path.read_text(encoding="utf-8")).body
+ if isinstance(node, ast.FunctionDef) and node.name.startswith("test_")
+ }
+ rows = set(re.findall(r"^\| `([\w/]+\.py)` \| `(\w+)` \|", (_ROOT / "TESTS.md").read_text(encoding="utf-8"), flags=re.MULTILINE))
+ assert sorted(functions - rows) == []
+ assert sorted(rows - functions) == []
diff --git a/tests/linter/test_reporter.py b/tests/linter/test_reporter.py
index 2f817f2..5d6d6ff 100644
--- a/tests/linter/test_reporter.py
+++ b/tests/linter/test_reporter.py
@@ -1,15 +1,18 @@
"""Tests for reporter module."""
+import importlib
+import io
import json
+import sys
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import Any
+import pytest
from linter.config import ConfigOverride, Policy
from linter.models import LintError, NodeType
-from linter.reporter import report_cli, report_github_annotations, report_json, report_options, report_overrides, report_policies, report_rules, report_traceback
+from linter.reporter import report_cli, report_github_annotations, report_json, report_options, report_overrides, report_policies, report_rules, report_statistics, report_traceback
-if TYPE_CHECKING:
- import pytest
+from linter import reporter
def _error(rule: str = "args_match", line: int = 10, filepath: str = "src/foo.py") -> LintError:
@@ -101,6 +104,26 @@ def test_report_cli_multiple_files(capsys: pytest.CaptureFixture[str]) -> None:
assert "src/b.py" in out
+# ---------------------------------------------------------------------------
+# report_statistics
+# ---------------------------------------------------------------------------
+
+
+def test_report_statistics_no_errors(capsys: pytest.CaptureFixture[str]) -> None:
+ """No errors: prints summary with 0 errors."""
+ report_statistics([], files_checked=3)
+ assert "3 files checked, 0 errors." in capsys.readouterr().out
+
+
+def test_report_statistics_sorted_by_count_then_rule(capsys: pytest.CaptureFixture[str]) -> None:
+ """Most frequent rule first, ties in alphabetical order, counts right-aligned."""
+ rules = ["raises_match"] + ["args_match"] * 10 + ["returns_match", "blank_lines"]
+ report_statistics([_error(rule) for rule in rules], files_checked=2)
+ lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()]
+ assert lines[:4] == [" 10 args_match", " 1 blank_lines", " 1 raises_match", " 1 returns_match"]
+ assert "13 errors in 1 file (2 files checked)." in lines[4]
+
+
# ---------------------------------------------------------------------------
# report_json
# ---------------------------------------------------------------------------
@@ -237,12 +260,16 @@ def test_report_rules_disabled_rule_shows_cross(capsys: pytest.CaptureFixture[st
assert "✘" in matching[0]
-def test_report_rules_always_on_hidden(capsys: pytest.CaptureFixture[str]) -> None:
- """Rule in always_on is not listed and is not counted in the header."""
+def test_report_rules_always_on_listed_separately(capsys: pytest.CaptureFixture[str]) -> None:
+ """Rule in always_on: listed after the categories, not counted as configurable."""
report_rules(_CATEGORIES, _REGISTRY, _OFF_BY_DEFAULT, _ALWAYS_ON, frozenset())
- out = capsys.readouterr().out
- assert "rule_c" not in out
- assert "2 configurable rules" in out
+ lines = capsys.readouterr().out.splitlines()
+ assert "2 configurable rules" in lines[1]
+ header = next(i for i, line in enumerate(lines) if "Always on" in line)
+ assert "cannot be disabled" in lines[header]
+ assert "rule_c" in lines[header + 1]
+ assert "✔" in lines[header + 1]
+ assert all("rule_c" not in line for line in lines[:header])
def test_report_rules_off_by_default_label(capsys: pytest.CaptureFixture[str]) -> None:
@@ -330,3 +357,49 @@ def test_report_overrides_shows_paths_and_delta(capsys: pytest.CaptureFixture[st
assert "imperative_mood" in out
assert "optional" in out
assert "(base: required)" in out
+
+
+# ---------------------------------------------------------------------------
+# output details: plural, GitHub escaping, colors
+# ---------------------------------------------------------------------------
+
+
+def test_report_single_file_singular(capsys: pytest.CaptureFixture[str]) -> None:
+ """One file checked: the summary says '1 file checked'."""
+ report_cli([], 1)
+ assert capsys.readouterr().out.strip() == "1 file checked, 0 errors."
+
+
+def test_report_github_annotations_escaped(capsys: pytest.CaptureFixture[str]) -> None:
+ """Message and properties escaped as @actions/core does: %, line breaks, and in properties ':' and ','."""
+ error = LintError(filepath="src/a,b:c.py", line=3, entity_name="f", node_type=NodeType.FUNCTION, rule="summary_final_period", message="Got: '100%'\nnext")
+ report_github_annotations([error], 1)
+ first = capsys.readouterr().out.splitlines()[0]
+ assert first == "::error file=src/a%2Cb%3Ac.py,line=3,title=summary_final_period::Got: '100%25'%0Anext"
+
+
+def test_report_no_color_when_not_a_terminal(capsys: pytest.CaptureFixture[str]) -> None:
+ """Output captured, not a terminal: no ANSI escape sequence at all."""
+ report_cli([_error()], 1)
+ assert "\033[" not in capsys.readouterr().out
+
+
+@pytest.mark.parametrize(("isatty", "no_color", "colored"), [(True, None, True), (True, "1", False), (True, "", True), (False, None, False)])
+def test_colors_follow_terminal_and_no_color(monkeypatch: pytest.MonkeyPatch, isatty: bool, no_color: str | None, colored: bool) -> None: # noqa: FBT001
+ """Colors on a terminal only, and off when NO_COLOR holds a non-empty value."""
+
+ class _Stream(io.StringIO):
+ def isatty(self) -> bool:
+ return isatty
+
+ monkeypatch.setattr(sys, "stdout", _Stream())
+ if no_color is None:
+ monkeypatch.delenv("NO_COLOR", raising=False)
+ else:
+ monkeypatch.setenv("NO_COLOR", no_color)
+ try:
+ importlib.reload(reporter)
+ assert (reporter.Colors.RED == "\033[91m") is colored
+ finally:
+ monkeypatch.undo()
+ importlib.reload(reporter)