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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 17 additions & 25 deletions .github/workflows/docs-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,17 @@ jobs:
id: audit_coverage
run: |
set -o pipefail
uv run python tooling/docs-autogen/audit_coverage.py --docs-dir docs/docs/api --threshold 80 --quality 2>&1 \
uv run python tooling/docs-autogen/audit_coverage.py --docs-dir docs/docs/api --threshold 80 2>&1 \
| tee /tmp/audit_coverage.log
continue-on-error: ${{ inputs.strict_validation != true }}

- name: Docstring quality gate
id: quality_gate
run: |
set -o pipefail
uv run python tooling/docs-autogen/audit_coverage.py --docs-dir docs/docs/api --quality --fail-on-quality --threshold 100 2>&1 \
| tee /tmp/quality_gate.log

# -- Upload artifact for deploy job --------------------------------------

- name: Upload docs artifact
Expand Down Expand Up @@ -141,12 +148,14 @@ jobs:
markdownlint_outcome = "${{ steps.markdownlint.outcome }}"
validate_outcome = "${{ steps.validate_mdx.outcome }}"
coverage_outcome = "${{ steps.audit_coverage.outcome }}"
quality_gate_outcome = "${{ steps.quality_gate.outcome }}"
strict = "${{ inputs.strict_validation }}" == "true"
mode = "" if strict else " *(soft-fail)*"

lint_log = read_log("/tmp/markdownlint.log")
validate_log = read_log("/tmp/validate_mdx.log")
coverage_log = read_log("/tmp/audit_coverage.log")
quality_gate_log = read_log("/tmp/quality_gate.log")

# Count markdownlint issues (lines matching file:line:col format)
lint_issues = len([l for l in lint_log.splitlines() if re.match(r'.+:\d+:\d+ ', l)])
Expand Down Expand Up @@ -186,27 +195,11 @@ jobs:

mdx_detail = parse_validate_detail(validate_log)

# Docstring quality annotation emitted by audit_coverage.py into the log
# Parse docstring quality annotation from quality gate log
# Format: ::notice title=Docstring quality::message
# or ::warning title=Docstring quality::message
quality_match = re.search(r"::(notice|warning|error) title=Docstring quality::(.+)", coverage_log)
if quality_match:
quality_level, quality_msg = quality_match.group(1), quality_match.group(2)
quality_icon = "✅" if quality_level == "notice" else "⚠️"
quality_status = "pass" if quality_level == "notice" else "warning"
quality_detail = re.sub(r"\s*—\s*see job summary.*$", "", quality_msg)
quality_row = f"| Docstring Quality | {quality_icon} {quality_status}{mode} | {quality_detail} |"
else:
quality_row = None

# Split coverage log at quality section to avoid duplicate output in collapsibles
quality_start = coverage_log.find("🔬 Running docstring quality")
if quality_start != -1:
quality_log = coverage_log[quality_start:]
coverage_display_log = coverage_log[:quality_start].strip()
else:
quality_log = ""
coverage_display_log = coverage_log
# or ::error title=Docstring quality::message
quality_gate_match = re.search(r"::(notice|warning|error) title=Docstring quality::(.+)", quality_gate_log)
quality_gate_detail = re.sub(r"\s*—\s*see job summary.*$", "", quality_gate_match.group(2)) if quality_gate_match else ""

lines = [
"## Docs Build — Validation Summary\n",
Expand All @@ -215,16 +208,15 @@ jobs:
f"| Markdownlint | {icon(markdownlint_outcome)} {markdownlint_outcome}{mode} | {lint_detail} |",
f"| MDX Validation | {icon(validate_outcome)} {validate_outcome}{mode} | {mdx_detail} |",
f"| API Coverage | {icon(coverage_outcome)} {coverage_outcome}{mode} | {cov_detail} |",
f"| Docstring Quality | {icon(quality_gate_outcome)} {quality_gate_outcome} | {quality_gate_detail} |",
]
if quality_row:
lines.append(quality_row)
lines.append("")

for title, log, limit in [
("Markdownlint output", lint_log, 5_000),
("MDX validation output", validate_log, 5_000),
("API coverage output", coverage_display_log, 5_000),
("Docstring quality details", quality_log, 1_000_000),
("API coverage output", coverage_log, 5_000),
("Docstring quality details", quality_gate_log, 1_000_000),
]:
if log:
lines += [
Expand Down
11 changes: 5 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,12 @@ repos:
language: system
pass_filenames: false
files: (docs/docs/.*\.mdx$|tooling/docs-autogen/)
# TODO(#616): Move to normal commit flow once docstring quality issues reach 0.
# Griffe loads the full package (~10s), so this is manual-only for now to avoid
# slowing down every Python commit. Re-enable (remove stages: [manual]) and add
# --fail-on-quality once quality issues are resolved.
# Docstring quality gate — manual only (CI is the hard gate via docs-publish.yml).
# Run locally with: pre-commit run docs-docstring-quality --hook-stage manual
# Requires generated API docs (run `uv run python tooling/docs-autogen/build.py` first).
- id: docs-docstring-quality
name: Audit docstring quality (informational)
entry: bash -c 'test -d docs/docs/api && uv run --no-sync python tooling/docs-autogen/audit_coverage.py --quality --docs-dir docs/docs/api || true'
name: Audit docstring quality
entry: uv run --no-sync python tooling/docs-autogen/audit_coverage.py --quality --fail-on-quality --threshold 0 --docs-dir docs/docs/api
language: system
pass_filenames: false
files: (mellea/.*\.py$|cli/.*\.py$)
Expand Down
21 changes: 21 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,25 @@ differs in type or behaviour from the constructor input — for example, when a
argument is wrapped into a `CBlock`, or when a class-level constant is relevant to
callers. Pure-echo entries that repeat `Args:` verbatim should be omitted.

**`TypedDict` classes are a special case.** Their fields *are* the entire public
contract, so when an `Attributes:` section is present it must exactly match the
declared fields. The audit will flag:

- `typeddict_phantom` — `Attributes:` documents a field that is not declared in the `TypedDict`
- `typeddict_undocumented` — a declared field is absent from the `Attributes:` section

```python
class ConstraintResult(TypedDict):
"""Result of a constraint check.

Attributes:
passed: Whether the constraint was satisfied.
reason: Human-readable explanation.
"""
passed: bool
reason: str
```

#### Validating docstrings

Run the coverage and quality audit to check your changes before committing:
Expand All @@ -194,6 +213,8 @@ Key checks the audit enforces:
| `no_args` | Standalone function has params but no `Args:` section |
| `no_returns` | Function has a non-trivial return annotation but no `Returns:` section |
| `param_mismatch` | `Args:` documents names not present in the actual signature |
| `typeddict_phantom` | `TypedDict` `Attributes:` documents a field not declared in the class |
| `typeddict_undocumented` | `TypedDict` has a declared field absent from its `Attributes:` section |

**IDE hover verification** — open any of these existing classes in VS Code and hover
over the class name or a constructor call to confirm the hover card shows `Args:` once
Expand Down
Loading
Loading