Skip to content

Commit 169d8fd

Browse files
fix: repoint dangling doc references and add a doc-ref guard (#653)
Repoint or remove docstring / comment / pre-commit citations to docs that no longer exist (docs/ux/pwa.md, pwa.md, theming.md, ACCEPTANCE.md, REVIEW_CHECKLIST.md, docs/threat-model.md) so they target the surviving ARCHITECTURE.md / SECURITY.md sections. (conf.py's docs/ux/pwa.md cites were fixed alongside the comment cleanup in the preceding commit.) Add tests/test_doc_refs.py plus a pre-commit hook that fails when a *.md file or §N section cited in the package source, tests, or .pre-commit-config.yaml no longer exists, so this defect class can't recur. Also drop the black + isort pre-commit hooks per #651/#652. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 26554ec commit 169d8fd

3 files changed

Lines changed: 140 additions & 33 deletions

File tree

.pre-commit-config.yaml

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
# pre-commit install
66
#
77
# Then every `git commit` runs these hooks against the staged diff.
8-
# Anything below the [BLOCK] threshold in
9-
# `docs/agents/security-expert/REVIEW_CHECKLIST.md` §1 aborts the commit.
8+
# The security rules these enforce are documented in `SECURITY.md` §3.
109
#
1110
# This file is the commit-time half of the quality gate, run together
1211
# with `scripts/lint.sh` before a PR. CI (`.github/workflows/ci.yml`)
13-
# currently runs the test suites, not these hooks; wiring the lint/security
14-
# hooks into CI is a follow-up (#452).
12+
# runs the Python lint gate (ruff check + ruff format --check + mypy +
13+
# bandit) and the test suites; these hooks add the secret-scan + pygrep
14+
# house rules at commit time.
1515

1616
repos:
1717
# -------------------------------------------------------------------
@@ -24,7 +24,9 @@ repos:
2424
args: ["protect", "--staged", "--no-banner", "--redact"]
2525

2626
# -------------------------------------------------------------------
27-
# Python formatting + linting (same tools scripts/lint.sh uses).
27+
# Python lint + format + import order — Ruff is the single source of
28+
# truth (#651/#652). Black, standalone isort, and flake8 were removed;
29+
# ruff-format owns formatting and the `I` rules own import sorting.
2830
# -------------------------------------------------------------------
2931
- repo: https://github.com/astral-sh/ruff-pre-commit
3032
rev: v0.6.9
@@ -35,18 +37,6 @@ repos:
3537
- id: ruff-format
3638
files: ^(django_admin_react|tests|examples)/
3739

38-
- repo: https://github.com/psf/black-pre-commit-mirror
39-
rev: 24.8.0
40-
hooks:
41-
- id: black
42-
files: ^(django_admin_react|tests|examples)/
43-
44-
- repo: https://github.com/pycqa/isort
45-
rev: 5.13.2
46-
hooks:
47-
- id: isort
48-
files: ^(django_admin_react|tests|examples)/
49-
5040
# -------------------------------------------------------------------
5141
# Python security lint (bandit). Runs only over the package, not tests
5242
# (asserts in tests are fine and would otherwise be noisy).
@@ -63,18 +53,17 @@ repos:
6353

6454
# -------------------------------------------------------------------
6555
# House rules — local hooks that enforce package-specific invariants
66-
# from docs/agents/security-expert/REVIEW_CHECKLIST.md.
56+
# from SECURITY.md §3.
6757
# -------------------------------------------------------------------
6858
- repo: local
6959
hooks:
7060
# No partial / redacted token references anywhere in the diff.
7161
#
7262
# The `exclude` list covers the small set of files that
7363
# legitimately *document* the forbidden patterns (e.g., the
74-
# rule itself in SECURITY.md / ACCEPTANCE.md, the review
75-
# checklist, the security test that scans for them, and forum
76-
# review files that quote the rule when reviewing it). All
77-
# other files in the repo MUST be free of these substrings.
64+
# rule itself in SECURITY.md and the security test that scans for
65+
# them). All other files in the repo MUST be free of these
66+
# substrings.
7867
- id: no-partial-tokens
7968
name: No partial token redactions (e.g., ghp_…XYZ)
8069
language: pygrep
@@ -83,10 +72,7 @@ repos:
8372
exclude: |
8473
(?x)^(
8574
SECURITY\.md
86-
|ACCEPTANCE\.md
8775
|\.pre-commit-config\.yaml
88-
|docs/threat-model\.md
89-
|docs/agents/security-expert/.*
9076
|tests/test_security\.py
9177
|scripts/README\.md
9278
)$
@@ -118,3 +104,12 @@ repos:
118104
language: pygrep
119105
entry: "from ['\"]@dar/api['\"]"
120106
files: '^frontend/packages/(list|details|models|shell)/'
107+
108+
# Doc-reference integrity (#653): fail if a docstring/comment cites
109+
# a *.md file or §N section that no longer exists.
110+
- id: doc-ref-guard
111+
name: No dangling *.md / §N doc references
112+
language: system
113+
entry: poetry run pytest tests/test_doc_refs.py -q
114+
pass_filenames: false
115+
files: '\.(py|yaml)$'

django_admin_react/pwa.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""PWA surface: web app manifest + service worker (Issue #86).
22
3-
Wire/UX contract: ``docs/ux/pwa.md``. The Security lane owns this
4-
surface because its load-bearing properties are security ones:
3+
Frontend build/ship context: ``ARCHITECTURE.md`` §5.4. The Security lane
4+
owns this surface because its load-bearing properties are security ones:
55
66
- The **manifest** (``<mount>/web.manifest``) is served unauthenticated
77
(the install prompt fires before login) and is computed at request
@@ -15,8 +15,7 @@
1515
no-store`` (so the package's no-store API reads are never cached),
1616
never caches non-GET requests (mutation safety), and exposes a
1717
cache-purge message used on logout so read-cached payloads can't
18-
outlive the session (``pwa.md`` §5 — defense-in-depth atop session
19-
expiry).
18+
outlive the session (defense-in-depth atop session expiry).
2019
2120
Both views live **outside** ``api/`` because they're served at the
2221
mount root, not under ``api/v1/``, and the manifest is intentionally
@@ -33,13 +32,13 @@
3332
from django.shortcuts import render
3433
from django.views.generic import View
3534

36-
from django_admin_react import conf as dar_conf
37-
3835
# Re-use the API package's admin-site lookup (this repo implements no
3936
# API; the registry helper lives there). The PWA only needs the active
4037
# `AdminSite.name` for the manifest's start URL.
4138
from django_admin_rest_api.api.registry import get_admin_site
4239

40+
from django_admin_react import conf as dar_conf
41+
4342
# Theme colours keyed by the resolved colour scheme. Kept here (not in
4443
# the SPA's CSS-var system) because the manifest is rendered server-side
4544
# before any CSS loads; these are the install-banner / splash colours
@@ -79,7 +78,7 @@ def _mount(request: HttpRequest, suffix: str) -> str:
7978
def _resolved_scheme(request: HttpRequest) -> str:
8079
"""Resolve light/dark from the ``Sec-CH-Prefers-Color-Scheme`` hint.
8180
82-
Pairs with the theming client-hint path (``theming.md`` §2). Any
81+
Pairs with the theming client-hint path (``ARCHITECTURE.md`` §5.3). Any
8382
value other than a case-insensitive ``"dark"`` resolves to light —
8483
the safe, neutral default when the hint is absent or unexpected.
8584
"""
@@ -93,7 +92,7 @@ class ManifestView(View):
9392
Unauthenticated by design (the install prompt needs it pre-login).
9493
Carries no per-user data; every field is static or mount-/header-
9594
derived. ``Cache-Control: no-store`` is **not** set — the manifest
96-
is deliberately cacheable/network-first (``pwa.md`` §2.1).
95+
is deliberately cacheable/network-first.
9796
"""
9897

9998
http_method_names = ["get"]

tests/test_doc_refs.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Referential-integrity guard for documentation citations (#653).
2+
3+
Docstrings and comments in this package cite docs by filename
4+
(``ARCHITECTURE.md``) and by section (``§4.5``). A doc reorg once left a
5+
trail of dangling citations to deleted files (``docs/ux/pwa.md``,
6+
``theming.md``, ``ACCEPTANCE.md``, …) — this test fails fast when a cite
7+
points at a ``*.md`` file or a ``§N`` section heading that no longer
8+
exists, so the defect class can't recur.
9+
10+
Scope: the package source, the test suite, and ``.pre-commit-config.yaml``
11+
(it carries doc citations too). It is intentionally simple and fast — a
12+
regex sweep, no network, no imports of the cited docs.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import re
18+
from pathlib import Path
19+
20+
_REPO_ROOT = Path(__file__).resolve().parent.parent
21+
_THIS_FILE = Path(__file__).resolve()
22+
23+
# Files whose comments/docstrings we scan for citations. This guard file is
24+
# excluded from its own scan — it legitimately quotes the (historically
25+
# dangling) doc names it exists to forbid.
26+
_SCANNED_FILES: list[Path] = [
27+
*sorted((_REPO_ROOT / "django_admin_react").rglob("*.py")),
28+
*(p for p in sorted((_REPO_ROOT / "tests").rglob("*.py")) if p.resolve() != _THIS_FILE),
29+
_REPO_ROOT / ".pre-commit-config.yaml",
30+
]
31+
32+
# A cited Markdown doc, e.g. ``ARCHITECTURE.md`` or ``docs/ux/pwa.md``.
33+
# Captures an optional path prefix so ``docs/foo.md`` resolves relative to
34+
# the repo root, while a bare ``FOO.md`` may live anywhere in the tree.
35+
_MD_REF_RE = re.compile(r"(?<![\w./-])((?:[\w./-]+/)?[A-Za-z0-9_-]+\.md)\b")
36+
37+
# A cited section, e.g. ``§4.5`` or ``§3``. The doc it belongs to is the
38+
# nearest preceding ``*.md`` cite on the same line (the repo's convention
39+
# is ``ARCHITECTURE.md §4.5``).
40+
_SECTION_RE = re.compile(r"§\s*([\d]+(?:\.[\dA-Za-z]+)*)")
41+
42+
43+
def _iter_lines() -> list[tuple[Path, int, str]]:
44+
out: list[tuple[Path, int, str]] = []
45+
for path in _SCANNED_FILES:
46+
if not path.is_file():
47+
continue
48+
for lineno, line in enumerate(path.read_text("utf-8").splitlines(), start=1):
49+
out.append((path, lineno, line))
50+
return out
51+
52+
53+
def _resolve_md(ref: str) -> bool:
54+
"""True if a cited ``*.md`` reference resolves to a real file."""
55+
# Path-qualified (``docs/ux/pwa.md``): resolve from the repo root.
56+
if "/" in ref:
57+
return (_REPO_ROOT / ref).is_file()
58+
# Bare filename (``ARCHITECTURE.md``): match anywhere in the tree,
59+
# skipping vendored / build dirs.
60+
skip = {"node_modules", ".git", "dist", ".venv", "__pycache__"}
61+
for candidate in _REPO_ROOT.rglob(ref):
62+
if not any(part in skip for part in candidate.parts):
63+
return True
64+
return False
65+
66+
67+
def _section_exists(doc: Path, section: str) -> bool:
68+
"""True if ``doc`` has a heading for ``§section`` (e.g. ``## 4.5``)."""
69+
text = doc.read_text("utf-8")
70+
# Headings look like ``## 4. Backend design`` or ``### 4.5 URL mounting``.
71+
pattern = re.compile(rf"^#{{1,6}}\s+{re.escape(section)}(?:[.\s]|$)", re.MULTILINE)
72+
return bool(pattern.search(text))
73+
74+
75+
def test_no_dangling_md_references() -> None:
76+
"""Every cited ``*.md`` file in the scanned sources exists."""
77+
failures: list[str] = []
78+
for path, lineno, line in _iter_lines():
79+
for match in _MD_REF_RE.finditer(line):
80+
ref = match.group(1)
81+
if not _resolve_md(ref):
82+
rel = path.relative_to(_REPO_ROOT)
83+
failures.append(f"{rel}:{lineno} cites missing doc {ref!r}")
84+
assert not failures, "Dangling Markdown references:\n" + "\n".join(failures)
85+
86+
87+
def test_no_dangling_section_references() -> None:
88+
"""Every ``§N`` cite resolves to a heading in the doc named on its line."""
89+
failures: list[str] = []
90+
for path, lineno, line in _iter_lines():
91+
sections = _SECTION_RE.findall(line)
92+
if not sections:
93+
continue
94+
md_refs = _MD_REF_RE.findall(line)
95+
if not md_refs:
96+
# A §N with no doc named on the same line — can't verify which
97+
# doc it belongs to, so we don't guess. The repo convention
98+
# always names the doc; flag the orphan so it gets fixed.
99+
rel = path.relative_to(_REPO_ROOT)
100+
cited = "/".join("§" + s for s in sections)
101+
failures.append(f"{rel}:{lineno} cites {cited} with no doc on the line")
102+
continue
103+
# The section belongs to the last doc named before it on the line.
104+
doc_ref = md_refs[-1]
105+
doc_path = _REPO_ROOT / doc_ref
106+
if not doc_path.is_file():
107+
# The missing-file case is already covered by the other test.
108+
continue
109+
for section in sections:
110+
if not _section_exists(doc_path, section):
111+
rel = path.relative_to(_REPO_ROOT)
112+
failures.append(f"{rel}:{lineno} cites {doc_ref} §{section} — no such heading")
113+
assert not failures, "Dangling section references:\n" + "\n".join(failures)

0 commit comments

Comments
 (0)