From d5f917d06369485793413a91ff74a9ae8364ab01 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 14 Oct 2026 21:30:49 +0800 Subject: [PATCH] docs: add priority ranking mode to audit-the-list Co-Authored-By: Claude --- .claude/skills/audit-the-list/SKILL.md | 8 +- .../audit-the-list/scripts/audit_priority.py | 115 ++++++++++++++++++ docs/audit-logs.md | 4 + 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/audit-the-list/scripts/audit_priority.py diff --git a/.claude/skills/audit-the-list/SKILL.md b/.claude/skills/audit-the-list/SKILL.md index e95856a6..66118c1f 100644 --- a/.claude/skills/audit-the-list/SKILL.md +++ b/.claude/skills/audit-the-list/SKILL.md @@ -1,7 +1,7 @@ --- name: audit-the-list description: Audit README.md sections against the shortlist rules — re-verify every entry's verdict with live data, promote or demote challengers, restructure oversized use cases, prune, and evaluate proposed additions. Fire when the maintainer asks to audit, sweep, prune, re-check, or maintain sections, or asks whether an entry (or a proposed one) deserves its slot. -argument-hint: [all or specific sections] +argument-hint: [all, specific sections, or none for a recommendation] --- # Audit the list @@ -10,7 +10,9 @@ An Audit re-runs the admission test over live sections of README.md: every entry ## 1. Scope -Resolve the scope from the arguments. Named sections mean exactly those, whether or not they were audited before (a re-run is how a past verdict gets rechecked). `all` is ambiguous — `AskUserQuestion` whether it means every section or only never-audited ones; prior audits are recorded in git history (`git log --oneline --grep="sweep\|audit"`). With no arguments, AskUserQuestion listing the never-audited Thematic Groups. Batch the work one Thematic Group per sitting. Done when the section list is settled. +Resolve the scope from the arguments. Named sections mean exactly those, whether or not they were audited before (a re-run is how a past verdict gets rechecked). `all` is ambiguous — `AskUserQuestion` whether it means every section or only never-audited ones. Batch the work one Thematic Group per sitting. Done when the section list is settled. + +With no arguments, or to see which sections were never audited, run `uv run python .claude/skills/audit-the-list/scripts/audit_priority.py`. It dates each section's last audit from `audit:`/`prune: sweep` and `style: audit` commit subjects plus the Last Audited lines in `docs/audit-logs.md`, then ranks sections: entries added or removed since that audit first, most changes first, then unchanged sections by oldest audit, never-audited first. Print its ranking, then AskUserQuestion with the top four sections as options, the first marked Recommended, each description carrying its changed entries or its last-audit date. A renamed section loses its audit history and ranks as never audited; the script lists the orphaned subjects, so say so when that happens. ## 2. Evidence @@ -33,7 +35,7 @@ Run the `preview-verdicts` skill: it generates the interactive review page and d ## 5. Execute -One commit per section: body lists each removal with its reason and downloads figure, naming the figure's source and fetch date so a later audit can tell whether a trend compares like with like (ClickPy counts mirror traffic, pypistats does not); restructures, tier moves, and reorders ride the same commit. A restructure that renames or dissolves a category or subcategory adds `"old path": "new path"` to `website/data/redirects.json` in that commit, so the old URL keeps its search ranking; the build fails when a redirect target no longer exists, so a later rename also updates the entries pointing at the renamed path. Format-only outcomes (no removals) are a single style commit. `make test` before every commit. After the last add or rename, re-run the ClickPy sweep, since new names have no cache row and the build would count them without downloads, then `make build`. Generic commit helpers tend to split a section audit into structural and per-subcategory commits — if that happens, squash back to one commit per section. Done when the tree is clean, tests passed before each commit, and the build count reconciles with the adjudicated changes. +One commit per section: body lists each removal with its reason and downloads figure, naming the figure's source and fetch date so a later audit can tell whether a trend compares like with like (ClickPy counts mirror traffic, pypistats does not); restructures, tier moves, and reorders ride the same commit. A restructure that renames or dissolves a category or subcategory adds `"old path": "new path"` to `website/data/redirects.json` in that commit, so the old URL keeps its search ranking; the build fails when a redirect target no longer exists, so a later rename also updates the entries pointing at the renamed path. Format-only outcomes (no removals) are a single style commit whose subject starts `style: audit
`. A section whose audit changes nothing gets a line under Last Audited in `docs/audit-logs.md` instead (`-
-- , no changes.`, replacing its earlier line), committed, since the ranking script reads an audit only from a commit or that line. `make test` before every commit. After the last add or rename, re-run the ClickPy sweep, since new names have no cache row and the build would count them without downloads, then `make build`. Generic commit helpers tend to split a section audit into structural and per-subcategory commits — if that happens, squash back to one commit per section. Done when the tree is clean, tests passed before each commit, and the build count reconciles with the adjudicated changes. ## 6. Record diff --git a/.claude/skills/audit-the-list/scripts/audit_priority.py b/.claude/skills/audit-the-list/scripts/audit_priority.py new file mode 100644 index 00000000..c40605bd --- /dev/null +++ b/.claude/skills/audit-the-list/scripts/audit_priority.py @@ -0,0 +1,115 @@ +"""Rank README.md sections for the next audit. + +A section's last audit is the newest of: a commit whose subject starts with +`audit: sweep`, `prune: sweep`, or `style: audit` and names the section or its +Thematic Group, and its line under docs/audit-logs.md "Last Audited". Sections +whose entries changed since then come first, most changes first; the rest +follow by oldest audit, never-audited first. +""" + +import re +import subprocess +import sys +from functools import cache +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(ROOT / "website")) + +from readme_parser import parse_readme + +# The shortlist reform; audits start after it, and never-audited sections diff against it. +REFORM = "553bf88" +AUDIT_SUBJECT_RE = re.compile(r"^(?:(?:audit|prune): sweep|style: audit) (.+?)(?: —|,| to the shortlist cap|$)") +REGISTER_LINE_RE = re.compile(r"^- (.+?) -- (\d{4}-\d{2}-\d{2})") + + +def git(*args: str) -> str: + return subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True, check=True).stdout + + +@cache +def entries_at(rev: str) -> dict[str, dict[str, str]]: + """Section -> {normalized entry name: display name}, so a recased or re-punctuated name is no change.""" + sections = {} + for group in parse_readme(git("show", f"{rev}:README.md")): + for category in group["categories"]: + names = [entry["name"] for entry in category["entries"]] + names += [sub["name"] for entry in category["entries"] for sub in entry["also_see"]] + sections[category["name"]] = {re.sub(r"[-_.]+", "-", name).lower(): name for name in names} + return sections + + +def resolve(text: str, groups: dict[str, list[str]]) -> list[str]: + """Sections named in text, longest name first so "Database Drivers" never counts as "Database".""" + found = [] + for name in sorted(groups, key=len, reverse=True): + if re.search(rf"(? None: + current = parse_readme(git("show", "HEAD:README.md")) + group_of = {c["name"]: g["name"] for g in current for c in g["categories"]} + # Section names map to themselves, Thematic Group names to all their sections. + names = {s: [s] for s in group_of} | {g["name"]: [c["name"] for c in g["categories"]] for g in current} + + last: dict[str, tuple[int, str, str]] = {} # section -> (timestamp, date, rev) + + def stamp(section: str, timestamp: int, date: str, rev: str) -> None: + if section not in last or timestamp > last[section][0]: + last[section] = (timestamp, date, rev) + + unmatched = [] + for line in git("log", "--format=%H%x09%ct%x09%cs%x09%s", f"{REFORM}..HEAD").splitlines(): + rev, timestamp, date, subject = line.split("\t", 3) + if match := AUDIT_SUBJECT_RE.match(subject): + sections = resolve(match.group(1), names) + for section in sections: + stamp(section, int(timestamp), date, rev) + if not sections: + unmatched.append(subject) + + register = (ROOT / "docs" / "audit-logs.md").read_text().splitlines() + start = register.index("## Last Audited") + for lineno, line in enumerate(register[start:], start + 1): + if match := REGISTER_LINE_RE.match(line): + blame = git("blame", "--porcelain", "-L", f"{lineno},{lineno}", "--", "docs/audit-logs.md").splitlines() + rev = blame[0].split()[0] + if set(rev) == {"0"}: # uncommitted line + rev = "HEAD" + timestamp = int(next(b.split()[1] for b in blame if b.startswith("committer-time "))) + for section in resolve(match.group(1), names): + stamp(section, timestamp, match.group(2), rev) + + head = entries_at("HEAD") + changed, unchanged = [], [] + for section in group_of: + _, date, rev = last.get(section, (0, "never", REFORM)) + before = entries_at(rev).get(section, {}) + added = [head[section][n] for n in head[section].keys() - before.keys()] + removed = [before[n] for n in before.keys() - head[section].keys()] + row = (section, date, rev[:7] if section in last else "", added, removed) + (changed if added or removed else unchanged).append(row) + + # "never" sorts after digits, so map it to "" to put never-audited first. + changed.sort(key=lambda r: (-(len(r[3]) + len(r[4])), "" if r[1] == "never" else r[1])) + unchanged.sort(key=lambda r: "" if r[1] == "never" else r[1]) + + print("Entries added or removed since last audit:") + for section, date, rev, added, removed in changed: + diff = " ".join([*(f"+{n}" for n in sorted(added)), *(f"-{n}" for n in sorted(removed))]) + print(f" {section} [{group_of[section]}] last {date} {rev} {diff}") + print("\nNo entry changes, oldest audit first:") + for section, date, rev, _, _ in unchanged: + print(f" {section} [{group_of[section]}] last {date} {rev}") + if unmatched: + print("\nAudit subjects naming no current section or group (renamed or dissolved since):") + for subject in unmatched: + print(f" {subject}") + + +if __name__ == "__main__": + main() diff --git a/docs/audit-logs.md b/docs/audit-logs.md index b1eae78f..fbac5b13 100644 --- a/docs/audit-logs.md +++ b/docs/audit-logs.md @@ -65,3 +65,7 @@ These entries sit past the 12-month activity requirement without an override. Ea - python-pptx -- kept 2026-10-02, last commit 2024-08-06, last release 1.0.2 on 2024-08-07. No successor for generating .pptx files. - schedule -- kept 2026-08-16, recorded 2026-10-02. Last commit 2024-05-25. - sortedcontainers + +## Last Audited + +An audit that changes nothing in a section leaves no commit, so its date is recorded here, one line per section, replaced on each re-audit.