Repository navigation
Expand file tree
/
Copy pathverify_docs_feature_completeness.py
More file actions
131 lines (116 loc) · 3.71 KB
/
Copy pathverify_docs_feature_completeness.py
File metadata and controls
131 lines (116 loc) · 3.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
"""Feature-completeness heuristic for scripts/verify_docs.py.
Moved out of ``verify_docs.py`` unchanged so the verifier stays under the
file-size gate (950 lines), the same split as the other
``verify_docs_*`` modules. The check WARNs when README.md mentions none
of the key words of a changelog feature heading shipped since the
README's version badge. It is heuristic, so its findings stay WARN.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from pathlib import Path
from typing import Protocol
class _Report(Protocol):
def warn(self, check: str, message: str) -> None: ...
_STOPWORDS = {
"the",
"and",
"for",
"with",
"from",
"into",
"via",
"per",
"new",
"all",
"now",
"add",
"added",
"fix",
"fixed",
"more",
"also",
"plus",
"system",
"support",
"mode",
"page",
"phase",
"release",
"this",
"that",
"across",
}
# Generic changelog section headings -- not feature names, skip them.
_SECTION_HEADINGS = {
"added",
"changed",
"fixed",
"removed",
"deprecated",
"security",
"notes",
"quality",
"under the hood",
"also in this release",
"dependencies",
"decisions confirmed in this release",
"what's new",
"breaking changes",
"migration",
"tests",
"documentation",
}
def changelog_versions(repo: Path) -> list[tuple[tuple[int, int, int], Path]]:
out = []
for path in (repo / "changelog" / "releases").glob("v*.md"):
m = re.match(r"v(\d+)\.(\d+)\.(\d+)\.md$", path.name)
if m:
out.append(((int(m[1]), int(m[2]), int(m[3])), path))
return sorted(out)
def check_feature_completeness(
report: _Report,
repo: Path,
read: Callable[[Path], str] = lambda p: p.read_text(encoding="utf-8"),
) -> None:
"""WARN when README.md seems not to mention a feature shipped since its badge."""
readme = repo / "README.md"
if not readme.exists():
return
readme_text = read(readme).lower()
# README badge version marks "what the README was last refreshed to".
bmatch = re.search(r"badge/version-v(\d+)\.(\d+)\.(\d+)-blue", read(readme))
since = (int(bmatch[1]), int(bmatch[2]), int(bmatch[3])) if bmatch else (0, 0, 0)
missing: list[str] = []
for version, path in changelog_versions(repo):
if version <= since:
continue
for heading in re.findall(r"(?m)^###\s+(.+?)\s*$", read(path)):
# Drop generic section labels (e.g. "Changed", "Fixed") and
# anything that reads as a bug-line rather than a feature.
clean = re.sub(r"\s*[—–-]\s*.*$", "", heading).strip().lower()
if clean in _SECTION_HEADINGS or heading.strip().lower() in _SECTION_HEADINGS:
continue
if re.match(r"(?i)^bug\b", heading.strip()):
continue
tokens = [
t
for t in re.findall(r"[A-Za-z][A-Za-z0-9+-]{2,}", heading.lower())
if t not in _STOPWORDS
]
if not tokens:
continue
# If NONE of the heading's key tokens appear in the README,
# the feature is likely unmentioned.
if not any(t in readme_text for t in tokens):
vstr = ".".join(str(p) for p in version)
missing.append(f'v{vstr}: "{heading}"')
if missing:
shown = missing[:12]
more = f" (+{len(missing) - len(shown)} more)" if len(missing) > len(shown) else ""
report.warn(
"feature-completeness",
"README.md may not mention features shipped since its version badge: "
+ "; ".join(shown)
+ more,
)