sphinx_fix.py
sphinx_fix.pyBrowse 35 files
9,672 tokens
39,594 bytes
Token encoding: o200k_base
Snapshot 39882c6
← Back to SKILL.md
1#!/usr/bin/env python2"""Classify Ray Sphinx/MyST doc-build warnings against a rules table.3 4Read-only diagnostic. Reads a Sphinx warning stream (a file, stdin, or pasted5text -- either the `/rtd-build-logs warnings` output or a raw `sphinx-build`6log / `-w` warnings file), parses each warning into a record, classifies it7against `rules.yaml`, and prints the canonical fix for each. v0 is8human-in-the-loop: this tool proposes fixes and never edits files.9 10It triages by severity tier (1 fatal/abort, 2 structural/parse, 3 plain11warning), detects a hard-broken build first, segregates known-benign12suppressed classes, and emits every unmatched warning as an explicit13unclassified list so the rules table can grow from real misses.14 15Stdlib-only. Prefers PyYAML to read the rules table but falls back to a small16restricted-grammar parser so it runs in a bare environment.17"""18 19from __future__ import annotations20 21import argparse22import json23import re24import sys25from dataclasses import dataclass, field26from pathlib import Path27 28DEFAULT_RULES = Path(__file__).resolve().parent / "rules.yaml"29 30 31# --------------------------------------------------------------------------- #32# Records33# --------------------------------------------------------------------------- #34@dataclass35class SphinxWarning:36 path: str | None37 line: int | None38 level: str39 message: str40 category: str | None41 raw: str42 43 44@dataclass45class Rule:46 id: str47 title: str48 tier: int49 safety: str50 match: str51 categories: list[str]52 signatures: list[re.Pattern]53 cause: str54 fix: str55 fix_template: str | None56 target_extract: re.Pattern | None57 version_sphinx: str | None58 version_myst: str | None59 notes: str | None60 61 62@dataclass63class Suppression:64 id: str65 categories: list[str]66 signatures: list[re.Pattern]67 location_contains: str | None68 reason: str69 tracked_by: str | None70 71 72@dataclass73class Finding:74 warning: SphinxWarning75 rule_id: str76 tier: int77 fix: str78 target: str | None79 safety: str80 version_ok: bool81 82 83@dataclass84class AbortSignal:85 signature: str86 excerpt: list[str]87 warning_tail_present: bool88 hint: str | None = None89 90 91@dataclass92class BuildState:93 state: str | None = None94 success: bool | None = None95 summary: str | None = None96 declared_warning_count: int | None = None97 exit_code: int | None = None98 99 100@dataclass101class Report:102 abort: AbortSignal | None103 findings: list[Finding]104 unclassified: list[SphinxWarning]105 suppressed: list[tuple[SphinxWarning, Suppression]]106 build_state: BuildState107 baseline: dict = field(default_factory=dict)108 109 110class RulesError(Exception):111 """Raised when rules.yaml is malformed."""112 113 114# --------------------------------------------------------------------------- #115# Restricted-grammar YAML fallback (used only when PyYAML is absent)116# --------------------------------------------------------------------------- #117def _strip_comment(line: str) -> str:118 """Drop a trailing/full-line `#` comment, ignoring `#` inside quotes."""119 out: list[str] = []120 dq = False121 i, n = 0, len(line)122 while i < n:123 c = line[i]124 if dq:125 out.append(c)126 if c == "\\" and i + 1 < n:127 out.append(line[i + 1])128 i += 2129 continue130 if c == '"':131 dq = False132 i += 1133 continue134 if c == '"':135 dq = True136 out.append(c)137 i += 1138 continue139 if c == "#" and (i == 0 or line[i - 1] == " "):140 break141 out.append(c)142 i += 1143 return "".join(out)144 145 146def _unescape_dq(s: str) -> str:147 table = {"\\": "\\", '"': '"', "n": "\n", "t": "\t", "r": "\r", "/": "/", "0": "\0"}148 out: list[str] = []149 i, n = 0, len(s)150 while i < n:151 c = s[i]152 if c == "\\" and i + 1 < n:153 out.append(table.get(s[i + 1], s[i + 1]))154 i += 2155 continue156 out.append(c)157 i += 1158 return "".join(out)159 160 161def _scalar(s: str):162 s = s.strip()163 if len(s) >= 2 and s[0] == '"' and s[-1] == '"':164 return _unescape_dq(s[1:-1])165 if s in ("", "null", "~"):166 return None167 if s == "true":168 return True169 if s == "false":170 return False171 if re.fullmatch(r"-?\d+", s):172 return int(s)173 return s174 175 176_KEY_RE = re.compile(r"^([A-Za-z0-9_]+):(?:\s+(.*))?$")177 178 179def _yaml_fallback(text: str):180 items: list[list] = []181 for raw in text.split("\n"):182 content = _strip_comment(raw)183 if not content.strip():184 continue185 indent = len(content) - len(content.lstrip(" "))186 items.append([indent, content.strip()])187 if not items:188 # Empty / comment-only / whitespace-only input. Match PyYAML's189 # safe_load("") -> None so load_rules raises a clean RulesError and190 # _crosscheck_yaml stays in agreement with PyYAML.191 return None192 pos = [0]193 194 def peek():195 return items[pos[0]] if pos[0] < len(items) else None196 197 def value_after_key(key_indent: int):198 nxt = peek()199 if nxt is None or nxt[0] <= key_indent:200 return None201 return parse_node(nxt[0])202 203 def parse_map_keys(d: dict, indent: int) -> None:204 while True:205 cur = peek()206 if cur is None or cur[0] != indent:207 break208 m = _KEY_RE.match(cur[1])209 if not m:210 break211 pos[0] += 1212 key, val = m.group(1), m.group(2)213 if val:214 d[key] = _scalar(val)215 else:216 d[key] = value_after_key(indent)217 218 def parse_node(indent: int):219 cur = peek()220 if cur[1].startswith("- "):221 seq: list = []222 while True:223 cur = peek()224 if cur is None or cur[0] != indent or not cur[1].startswith("- "):225 break226 head = cur[1][2:].strip()227 pos[0] += 1228 m = _KEY_RE.match(head)229 if m:230 d: dict = {}231 key, val = m.group(1), m.group(2)232 if val:233 d[key] = _scalar(val)234 else:235 d[key] = value_after_key(indent + 2)236 parse_map_keys(d, indent + 2)237 seq.append(d)238 else:239 seq.append(_scalar(head))240 return seq241 d = {}242 parse_map_keys(d, indent)243 return d244 245 return parse_node(0)246 247 248def _load_yaml_text(text: str):249 try:250 import yaml251 except ImportError:252 return _yaml_fallback(text)253 return yaml.safe_load(text)254 255 256# --------------------------------------------------------------------------- #257# Rule loading258# --------------------------------------------------------------------------- #259def _compile_regexes(values, where: str) -> list[re.Pattern]:260 out = []261 for v in values or []:262 try:263 out.append(re.compile(v))264 except re.error as e:265 raise RulesError(f"bad regex in {where}: {v!r} ({e})") from e266 return out267 268 269def _compile_rule(raw: dict) -> Rule:270 rid = raw.get("id")271 if not rid:272 raise RulesError(f"rule missing id: {raw!r}")273 categories = raw.get("categories") or []274 signatures = _compile_regexes(raw.get("signatures"), f"rule {rid} signatures")275 if not categories and not signatures:276 raise RulesError(f"rule {rid} has neither categories nor signatures")277 tier = raw.get("tier")278 if tier not in (1, 2, 3):279 raise RulesError(f"rule {rid} has invalid tier {tier!r}")280 safety = raw.get("safety")281 if safety not in ("mechanical", "judgment"):282 raise RulesError(f"rule {rid} has invalid safety {safety!r}")283 tex = raw.get("target_extract")284 target_extract = re.compile(tex) if tex else None285 return Rule(286 id=rid,287 title=raw.get("title", rid),288 tier=tier,289 safety=safety,290 match=raw.get("match", "any"),291 categories=categories,292 signatures=signatures,293 cause=raw.get("cause", ""),294 fix=raw.get("fix", ""),295 fix_template=raw.get("fix_template"),296 target_extract=target_extract,297 version_sphinx=raw.get("version_sphinx"),298 version_myst=raw.get("version_myst"),299 notes=raw.get("notes"),300 )301 302 303def _compile_suppression(raw: dict) -> Suppression:304 sid = raw.get("id", "?")305 return Suppression(306 id=sid,307 categories=raw.get("categories") or [],308 signatures=_compile_regexes(raw.get("signatures"), f"suppression {sid}"),309 location_contains=raw.get("location_contains"),310 reason=raw.get("reason", ""),311 tracked_by=raw.get("tracked_by"),312 )313 314 315def load_rules(path: Path) -> tuple[list[Rule], list[Suppression], dict]:316 try:317 text = path.read_text()318 except OSError as e:319 raise RulesError(f"cannot read rules file {path}: {e}") from e320 data = _load_yaml_text(text)321 if not isinstance(data, dict):322 raise RulesError(f"rules file {path} did not parse to a mapping")323 rules = [_compile_rule(r) for r in data.get("rules", [])]324 supps = [_compile_suppression(s) for s in data.get("suppressions", [])]325 seen: set[str] = set()326 for r in rules:327 if r.id in seen:328 raise RulesError(f"duplicate rule id: {r.id}")329 seen.add(r.id)330 baseline = {331 "sphinx": data.get("baseline_sphinx"),332 "myst_parser": data.get("baseline_myst"),333 }334 return rules, supps, baseline335 336 337# --------------------------------------------------------------------------- #338# Parsing the warning stream339# --------------------------------------------------------------------------- #340# Sphinx warning line: "<path>[:<line>]: LEVEL: <message> [<category>]".341# The line number is optional -- some classes (e.g. misc.copy_overwrite) warn342# on a whole file with no line.343WARN_RE = re.compile(344 r"^(?P<path>.*?):(?:(?P<line>\d+):)?\s+(?P<level>WARNING|ERROR|SEVERE):\s+"345 r"(?P<msg>.*?)(?:\s+\[(?P<category>[\w.]+)\])?$"346)347# Bare Sphinx warning with no path/line (e.g. "WARNING: extension ...").348BARE_RE = re.compile(349 r"^(?P<level>WARNING|ERROR|SEVERE):\s+"350 r"(?P<msg>.*?)(?:\s+\[(?P<category>[\w.]+)\])?$"351)352# Strip the Read the Docs checkout prefix so paths are repo-relative. The RtD353# path contains "/checkouts/" twice (.../checkouts/readthedocs.org/... and354# .../checkouts/<version>/<repo-relative>), so match greedily to the LAST one.355CHECKOUT_RE = re.compile(r"^.*/checkouts/[^/]+/")356STATE_RE = re.compile(r"State:\s+(?P<state>\w+)\s+Success:\s+(?P<success>\w+)")357EXIT_RE = re.compile(r"^Exit code:\s+(?P<code>\d+)")358SUMMARY_RE = re.compile(r"build (?:finished|succeeded)[^\n]*", re.IGNORECASE)359DECLARED_RE = re.compile(r"build finished with problems,\s+(?P<n>\d+)\s+warning", re.I)360 361ABORT_RES = [362 ("traceback", re.compile(r"^Traceback \(most recent call last\):")),363 ("extension-error", re.compile(r"Extension error")),364 ("import-error", re.compile(r"Could not import extension")),365 ("severe", re.compile(r"(?:^|\s)SEVERE:\s")),366 ("sphinx-error", re.compile(r"^Sphinx error:")),367]368 369 370def strip_checkout_prefix(path: str) -> str:371 return CHECKOUT_RE.sub("", path)372 373 374def is_noise(line: str) -> bool:375 """True for python-logging lines and JSON log records (not Sphinx warnings)."""376 if "\tWARNING " in line or "\tERROR " in line:377 return True378 # urllib3 retry chatter from the pip-install phase (Sphinx never emits379 # "Retrying (Retry(" and pip index URLs are /simple/...), not a doc warning.380 if "Retrying (Retry(" in line and "after connection broken" in line:381 return True382 s = line.strip()383 return s.startswith("{") and '"levelname"' in s384 385 386def parse_warnings(text: str) -> list[SphinxWarning]:387 out: list[SphinxWarning] = []388 for raw in text.splitlines():389 if is_noise(raw):390 continue391 s = raw.strip()392 if not s:393 continue394 m = WARN_RE.match(s)395 if m:396 out.append(397 SphinxWarning(398 path=strip_checkout_prefix(m.group("path")),399 line=int(m.group("line")) if m.group("line") else None,400 level=m.group("level"),401 message=m.group("msg").strip(),402 category=m.group("category"),403 raw=raw,404 )405 )406 continue407 m = BARE_RE.match(s)408 if m:409 out.append(410 SphinxWarning(411 path=None,412 line=None,413 level=m.group("level"),414 message=m.group("msg").strip(),415 category=m.group("category"),416 raw=raw,417 )418 )419 return out420 421 422def parse_build_state(text: str) -> BuildState:423 st = BuildState()424 for raw in text.splitlines():425 s = raw.strip()426 m = STATE_RE.search(s)427 if m:428 st.state = m.group("state")429 st.success = {"true": True, "false": False}.get(m.group("success").lower())430 m = EXIT_RE.match(s)431 if m:432 st.exit_code = int(m.group("code"))433 m = SUMMARY_RE.search(s)434 if m:435 st.summary = m.group(0).strip()436 m = DECLARED_RE.search(s)437 if m:438 st.declared_warning_count = int(m.group("n"))439 return st440 441 442# A decoy-aware hint for the autosummary import abort. autosummary imports every443# documented object at builder-inited, so the module the error names is usually444# NOT the culprit: an unrelated module's import chain broke (commonly a dep that445# autodoc_mock_imports mocks for the doc build but that runs at import time), and446# the named object is just the first to trip it. See build-troubleshooter-design.md.447AUTOSUMMARY_ABORT_HINT = (448 "the named module is usually a DECOY. autosummary imports every documented "449 "object at builder-inited, so the real cause is typically an unrelated module "450 "whose import chain breaks under autodoc_mock_imports (a dep mocked for the doc "451 "build -- numpy/pandas/etc. -- but used at import time), aborting a shared "452 "import (in Ray, ray.air imports ray.data). Trace the import to the offending "453 "eager import and make it lazy; the named module is just the first to trip it."454)455 456 457def _abort_hint(name: str, text: str) -> str | None:458 if (459 name == "extension-error"460 and "autosummary" in text461 and ("no module named" in text or "ImportExceptionGroup" in text)462 ):463 return AUTOSUMMARY_ABORT_HINT464 return None465 466 467def detect_abort(text: str, state: BuildState) -> AbortSignal | None:468 """Detect a hard-broken build that aborted before the warning pass.469 470 Only fires when an abort signature is present AND the build did not471 complete -- i.e. there is no "build finished/succeeded" summary and the472 state does not report success. Guards against false-firing on a healthy473 build whose text merely quotes "Extension error" etc.474 475 A line that parses as an ordinary Sphinx warning is never an abort. A hard476 abort happens *before* the warning pass and is printed as free text477 ("Extension error:", a traceback, "Sphinx error:"), never as the478 "path:line: LEVEL: msg" form. So skip warning-formatted lines here --479 otherwise a normal SEVERE/ERROR warning, or one whose message merely480 contains "Extension error", masquerades as an abort. This matters most for481 warnings-only input (a `-w` file or a pasted subset) that carries no build482 summary, where the completion guard above can't help.483 """484 completed = bool(SUMMARY_RE.search(text))485 if completed or state.success is True:486 return None487 lines = text.splitlines()488 for i, raw in enumerate(lines):489 s = raw.strip()490 if WARN_RE.match(s) or BARE_RE.match(s):491 continue492 for name, rx in ABORT_RES:493 if rx.search(raw):494 excerpt = [ln.rstrip() for ln in lines[i : i + 5]]495 return AbortSignal(496 name,497 excerpt,498 warning_tail_present=completed,499 hint=_abort_hint(name, text),500 )501 return None502 503 504# --------------------------------------------------------------------------- #505# Version comparator (no `packaging` dependency)506# --------------------------------------------------------------------------- #507def _parse_ver(v: str) -> tuple[int, ...]:508 return tuple(int(x) for x in re.findall(r"\d+", v))509 510 511def _tuplecmp(a: tuple[int, ...], b: tuple[int, ...]) -> int:512 n = max(len(a), len(b))513 a = a + (0,) * (n - len(a))514 b = b + (0,) * (n - len(b))515 return (a > b) - (a < b)516 517 518def version_in_range(actual: str | None, spec: str | None) -> bool:519 if not spec or not actual:520 return True521 av = _parse_ver(actual)522 for clause in spec.split(","):523 clause = clause.strip()524 m = re.match(r"(>=|<=|==|>|<)\s*(.+)", clause)525 if not m:526 continue527 op, bv = m.group(1), _parse_ver(m.group(2))528 c = _tuplecmp(av, bv)529 ok = {530 ">=": c >= 0,531 ">": c > 0,532 "<=": c <= 0,533 "<": c < 0,534 "==": c == 0,535 }[op]536 if not ok:537 return False538 return True539 540 541# --------------------------------------------------------------------------- #542# Classify543# --------------------------------------------------------------------------- #544def _rule_fires(rule: Rule, cat_hit: bool, sig_hit: bool) -> bool:545 if rule.match == "all":546 cat_ok = cat_hit if rule.categories else True547 sig_ok = sig_hit if rule.signatures else True548 return cat_ok and sig_ok549 return cat_hit or sig_hit550 551 552def _extract_target(rule: Rule, w: SphinxWarning):553 target = sec = None554 if rule.target_extract:555 m = rule.target_extract.search(w.message)556 if m:557 gd = m.groupdict()558 target = gd.get("target")559 sec = gd.get("sec")560 stem = None561 if target:562 stem = re.sub(r"\.(rst|md|html)$", "", target).split("#")[0]563 return target, stem, sec564 565 566def _format_fix(567 rule: Rule, target: str | None, stem: str | None, sec: str | None568) -> str:569 fix = rule.fix570 if rule.fix_template:571 rep = (572 rule.fix_template.replace("{target_stem}", stem or target or "DOC")573 .replace("{target}", target or "DOC")574 .replace("{sec}", sec or "section")575 )576 fix = f"{fix} Suggested: {rep}"577 return fix578 579 580def match_warning(581 w: SphinxWarning, rules: list[Rule], versions: dict | None582) -> Finding | None:583 candidates: list[tuple[Rule, bool, bool]] = []584 for r in rules:585 cat_hit = bool(r.categories) and w.category in r.categories586 sig_hit = any(rx.search(w.message) for rx in r.signatures)587 if _rule_fires(r, cat_hit, sig_hit):588 candidates.append((r, cat_hit, sig_hit))589 if not candidates:590 return None591 # Prefer a signature hit over a category-only hit; stable sort keeps the592 # file's most-specific-first order among equals.593 candidates.sort(key=lambda c: 0 if c[2] else 1)594 rule = candidates[0][0]595 target, stem, sec = _extract_target(rule, w)596 version_ok = True597 if versions:598 version_ok = version_in_range(599 versions.get("sphinx"), rule.version_sphinx600 ) and version_in_range(versions.get("myst_parser"), rule.version_myst)601 return Finding(602 warning=w,603 rule_id=rule.id,604 tier=rule.tier,605 fix=_format_fix(rule, target, stem, sec),606 target=target,607 safety=rule.safety,608 version_ok=version_ok,609 )610 611 612def find_suppression(w: SphinxWarning, supps: list[Suppression]) -> Suppression | None:613 for s in supps:614 cat_hit = bool(s.categories) and w.category in s.categories615 sig_hit = any(rx.search(w.message) for rx in s.signatures)616 if not (cat_hit or sig_hit):617 continue618 if s.location_contains:619 if w.path and s.location_contains in w.path:620 return s621 continue622 return s623 return None624 625 626def classify(warnings, rules, supps, versions):627 findings: list[Finding] = []628 unclassified: list[SphinxWarning] = []629 suppressed: list[tuple[SphinxWarning, Suppression]] = []630 for w in warnings:631 s = find_suppression(w, supps)632 if s:633 suppressed.append((w, s))634 continue635 f = match_warning(w, rules, versions)636 if f:637 findings.append(f)638 else:639 unclassified.append(w)640 findings.sort(key=lambda f: (f.tier, f.rule_id))641 return findings, unclassified, suppressed642 643 644def build_report(text, rules, supps, versions, baseline=None) -> Report:645 state = parse_build_state(text)646 abort = detect_abort(text, state)647 warnings = parse_warnings(text)648 findings, unclassified, suppressed = classify(warnings, rules, supps, versions)649 return Report(abort, findings, unclassified, suppressed, state, baseline or {})650 651 652# --------------------------------------------------------------------------- #653# Reporting654# --------------------------------------------------------------------------- #655TIER_LABEL = {656 1: "fatal/abort",657 2: "structural/parse (fix first; these mask warnings beneath them)",658 3: "warnings",659}660 661 662def _loc(w: SphinxWarning) -> str:663 if w.path and w.line is not None:664 return f"{w.path}:{w.line}"665 if w.path:666 return w.path667 return "(no location)"668 669 670def exit_code_for(report: Report) -> int:671 if report.abort:672 return 2673 if report.findings:674 return 1675 if report.unclassified:676 return 3677 return 0678 679 680# --------------------------------------------------------------------------- #681# Root-cause collapse (rendered-report affordance only)682# --------------------------------------------------------------------------- #683# One structural failure -- an autosummary stub that could not be generated for a684# module/class -- masks a flood of downstream py:* reference-target-not-found685# warnings for the SAME objects. Left flat, a single root renders as hundreds of686# equal-looking rows (one API-ref rework produced ~70 stub warnings masking ~291687# reference warnings). Collapse groups the downstream flood under its masking root688# so the human sees "1 root -> N masked references" instead of N sibling rows.689#690# This is a human-readability affordance for render_human ONLY. report.findings691# stays the full flat list, so --json (the agent surface) is unchanged and692# complete, and the verdict/exit code still see every finding.693#694# Correlation is by shared parent namespace: the stub warnings and the reference695# warnings are emitted for the same members, so both dotted paths share a parent696# (e.g. ray.data.Dataset.map_batches and ray.data.Dataset.map -> ray.data.Dataset).697# A tier-1 import abort is the other root of this same flood, but it surfaces698# separately as the HARD-BROKEN banner (and empties the finding list), so collapse699# runs only when the build completed far enough to emit findings.700ROOT_RULE = "autosummary-stub-not-found"701DOWNSTREAM_RULE = "py-xref-target-not-found"702COLLAPSE_LIST_CAP = 5703 704 705@dataclass706class RootCauseGroup:707 prefix: str708 roots: list[Finding] # autosummary-stub-not-found findings (the root)709 downstream: list[Finding] # py-xref-target-not-found findings it masks710 711 712def _object_prefix(target: str | None) -> str | None:713 """Parent namespace of a dotted object path (drop the last segment)."""714 if not target:715 return None716 head = target.rpartition(".")[0]717 return head or None718 719 720def compute_root_cause_groups(findings: list[Finding]) -> list[RootCauseGroup]:721 """Group a masked py-xref flood under its autosummary-stub root, by prefix.722 723 A group forms for a parent-namespace prefix only when it has BOTH at least724 one stub root and at least one downstream reference sharing that prefix -- the725 masking scenario. A lone stub (no downstream) or a genuinely independent726 reference (no matching stub) is left untouched in the normal findings list.727 """728 roots_by_prefix: dict[str, list[Finding]] = {}729 downstream_by_prefix: dict[str, list[Finding]] = {}730 for f in findings:731 prefix = _object_prefix(f.target)732 if not prefix:733 continue734 if f.rule_id == ROOT_RULE:735 roots_by_prefix.setdefault(prefix, []).append(f)736 elif f.rule_id == DOWNSTREAM_RULE:737 downstream_by_prefix.setdefault(prefix, []).append(f)738 groups = []739 for prefix in sorted(set(roots_by_prefix) & set(downstream_by_prefix)):740 groups.append(741 RootCauseGroup(742 prefix, roots_by_prefix[prefix], downstream_by_prefix[prefix]743 )744 )745 return groups746 747 748def _dedup(items: list[str]) -> list[str]:749 seen: set[str] = set()750 out: list[str] = []751 for it in items:752 if it not in seen:753 seen.add(it)754 out.append(it)755 return out756 757 758def _capped(items: list[str]) -> str:759 """Comma-join up to the cap, appending an explicit dropped-count (never silent)."""760 shown = ", ".join(items[:COLLAPSE_LIST_CAP])761 if len(items) > COLLAPSE_LIST_CAP:762 shown += f", ... and {len(items) - COLLAPSE_LIST_CAP} more"763 return shown764 765 766def _render_group(g: RootCauseGroup, out: list[str]) -> None:767 n_roots, n_down = len(g.roots), len(g.downstream)768 root = g.roots[0]769 safety = root.safety + (" (needs your call)" if root.safety == "judgment" else "")770 out.append(771 f" ROOT [T{root.tier}] {ROOT_RULE} — {g.prefix}.* "772 f"({n_roots} stub{'s' if n_roots != 1 else ''} not generated, "773 f"masking {n_down} downstream reference{'s' if n_down != 1 else ''})"774 )775 out.append(f" where: {_capped(_dedup([_loc(r.warning) for r in g.roots]))}")776 out.append(777 f" stubs: {_capped([r.target or r.warning.message for r in g.roots])}"778 )779 out.append(f" fix: {root.fix}")780 out.append(f" safety: {safety}")781 out.append(782 f" masks {n_down} downstream py:* reference(s) — "783 "collapsed (full list in --json):"784 )785 for d in g.downstream[:COLLAPSE_LIST_CAP]:786 out.append(f" {_loc(d.warning)} {d.target or d.warning.message}")787 if n_down > COLLAPSE_LIST_CAP:788 out.append(f" ... and {n_down - COLLAPSE_LIST_CAP} more (see --json)")789 790 791def render_human(report: Report) -> str:792 out: list[str] = []793 st = report.build_state794 795 if report.abort:796 out.append(797 "✗ HARD-BROKEN BUILD — fix this first; the rest of the "798 "log is unreliable."799 )800 out.append(f" signal: {report.abort.signature}")801 for ln in report.abort.excerpt:802 # rstrip so a blank excerpt line doesn't emit the 4-space indent as803 # trailing whitespace (the trailing-whitespace pre-commit hook fails on it).804 out.append(f" {ln}".rstrip())805 if report.abort.hint:806 out.append(f" hint: {report.abort.hint}")807 out.append(" The warning list below is incomplete until this is fixed.")808 out.append("")809 810 bits = []811 if st.state:812 bits.append(f"state={st.state}")813 if st.success is not None:814 bits.append(f"success={st.success}")815 if st.exit_code is not None:816 bits.append(f"exit={st.exit_code}")817 if st.summary:818 bits.append(st.summary)819 if bits:820 out.append("Build: " + " ".join(bits))821 out.append("")822 823 # Collapse a masked downstream flood under its root (rendered-report only;824 # report.findings stays flat). Skip on an aborted build -- the warning pass825 # didn't complete, so the flood/root relationship isn't trustworthy yet.826 groups = [] if report.abort else compute_root_cause_groups(report.findings)827 grouped_ids = {id(f) for g in groups for f in (*g.roots, *g.downstream)}828 remaining = [f for f in report.findings if id(f) not in grouped_ids]829 830 if groups:831 out.append(832 f"Root-cause groups ({len(groups)}) — a structural root masks a "833 "downstream flood; fix the root, rebuild, and the masked references "834 "clear together:"835 )836 for g in groups:837 _render_group(g, out)838 out.append("")839 840 header = (841 "Partial findings (warning pass did not complete)"842 if report.abort843 else "Findings"844 )845 out.append(f"{header} ({len(remaining)}):")846 if not remaining:847 out.append(" (none)")848 else:849 by_tier: dict[int, list[Finding]] = {}850 for f in remaining:851 by_tier.setdefault(f.tier, []).append(f)852 for tier in sorted(by_tier):853 out.append(f" Tier {tier} — {TIER_LABEL[tier]}:")854 for f in by_tier[tier]:855 flag = "" if f.version_ok else " (unvalidated for this version)"856 out.append(f" [T{tier}] {f.rule_id} {_loc(f.warning)}{flag}")857 out.append(f" msg: {f.warning.message}")858 out.append(f" fix: {f.fix}")859 safety = f.safety860 if safety == "judgment":861 safety += " (needs your call)"862 out.append(f" safety: {safety}")863 out.append("")864 865 out.append(866 f"Unclassified ({len(report.unclassified)}) — no rule matched; "867 "resolve with the user, then file a skill-improvement ticket to add a rule:"868 )869 for w in report.unclassified:870 cat = f" [{w.category}]" if w.category else ""871 out.append(f" {_loc(w)}: {w.level}: {w.message}{cat}")872 out.append("")873 874 out.append(f"Suppressed ({len(report.suppressed)}) — known-benign, not actionable:")875 counts: dict[str, tuple[str | None, int]] = {}876 for _w, s in report.suppressed:877 tb, n = counts.get(s.id, (s.tracked_by, 0))878 counts[s.id] = (tb, n + 1)879 for sid in sorted(counts):880 tb, n = counts[sid]881 track = f" ({tb})" if tb else ""882 out.append(f" {sid}{track}: {n}")883 out.append("")884 885 out.append(_verdict(report))886 return "\n".join(out)887 888 889def _verdict(report: Report) -> str:890 if report.abort:891 return "Next: fix the hard-broken build above, then rebuild and re-run."892 if report.findings:893 top = min(f.tier for f in report.findings)894 if top <= 2:895 return (896 f"Next: fix Tier {top} first (it masks others), then rebuild and "897 "re-run — do not assume one pass is complete."898 )899 return "Next: apply the fixes above, then rebuild and re-run to confirm clean."900 if report.unclassified:901 return (902 "No rule matched the warning(s) above — resolve with the user and "903 "extend rules.yaml."904 )905 return "✓ No actionable warnings."906 907 908def render_json(report: Report) -> str:909 def w2d(w: SphinxWarning) -> dict:910 return {911 "path": w.path,912 "line": w.line,913 "level": w.level,914 "message": w.message,915 "category": w.category,916 }917 918 payload = {919 "schema": 1,920 "abort": (921 None922 if not report.abort923 else {924 "signature": report.abort.signature,925 "excerpt": report.abort.excerpt,926 "hint": report.abort.hint,927 }928 ),929 "build_state": {930 "state": report.build_state.state,931 "success": report.build_state.success,932 "summary": report.build_state.summary,933 "exit_code": report.build_state.exit_code,934 "declared_warning_count": report.build_state.declared_warning_count,935 },936 "findings": [937 {938 "rule_id": f.rule_id,939 "tier": f.tier,940 "safety": f.safety,941 "fix": f.fix,942 "target": f.target,943 "version_ok": f.version_ok,944 **w2d(f.warning),945 }946 for f in report.findings947 ],948 "unclassified": [w2d(w) for w in report.unclassified],949 "suppressed": [950 {"suppression_id": s.id, "tracked_by": s.tracked_by, **w2d(w)}951 for w, s in report.suppressed952 ],953 "exit_code": exit_code_for(report),954 }955 return json.dumps(payload, indent=2)956 957 958def explain(rule_id: str, rules: list[Rule]) -> str:959 for r in rules:960 if r.id == rule_id:961 lines = [962 f"{r.id} (tier {r.tier}, {r.safety})",963 f" title: {r.title}",964 f" categories: {', '.join(r.categories) or '(none)'}",965 f" signatures: {', '.join(p.pattern for p in r.signatures) or '(none)'}",966 f" cause: {r.cause}",967 f" fix: {r.fix}",968 ]969 if r.fix_template:970 lines.append(f" template: {r.fix_template}")971 ver = f"sphinx {r.version_sphinx or '*'}, myst {r.version_myst or '*'}"972 lines.append(f" validated: {ver}")973 if r.notes:974 lines.append(f" notes: {r.notes}")975 return "\n".join(lines)976 return f"No rule with id {rule_id!r}. Known ids: {', '.join(r.id for r in rules)}"977 978 979# --------------------------------------------------------------------------- #980# Selftest981# --------------------------------------------------------------------------- #982def _selftest_invariants(rules, supps, baseline) -> list[str]:983 errs: list[str] = []984 if not baseline.get("sphinx") or not baseline.get("myst_parser"):985 errs.append("rules.yaml missing baseline_sphinx/baseline_myst")986 for r in rules:987 if not r.categories and not r.signatures:988 errs.append(f"{r.id}: no matchers")989 if r.tier not in (1, 2, 3):990 errs.append(f"{r.id}: bad tier")991 if r.safety not in ("mechanical", "judgment"):992 errs.append(f"{r.id}: bad safety")993 # version comparator spot checks994 if not version_in_range("8.2.3", ">=8.0,<9"):995 errs.append("version_in_range: 8.2.3 should satisfy >=8.0,<9")996 if version_in_range("9.0.0", ">=8.0,<9"):997 errs.append("version_in_range: 9.0.0 should NOT satisfy >=8.0,<9")998 return errs999 1000 1001def _crosscheck_yaml(rules_path: Path) -> list[str]:1002 try:1003 import yaml1004 except ImportError:1005 return []1006 text = rules_path.read_text()1007 pyyaml = yaml.safe_load(text)1008 fallback = _yaml_fallback(text)1009 if pyyaml != fallback:1010 return [1011 "YAML fallback parser disagrees with PyYAML on rules.yaml "1012 "(grammar drift). Keep rules.yaml within the documented subset."1013 ]1014 return []1015 1016 1017def run_selftest(rules_path: Path, update_golden: bool) -> int:1018 skill_dir = Path(__file__).resolve().parent1019 fx_dir = skill_dir / "tests" / "fixtures"1020 gold_dir = skill_dir / "tests" / "golden"1021 rules, supps, baseline = load_rules(rules_path)1022 1023 errs = _selftest_invariants(rules, supps, baseline)1024 errs += _crosscheck_yaml(rules_path)1025 1026 fixtures = sorted(fx_dir.glob("*.txt")) if fx_dir.is_dir() else []1027 if not fixtures:1028 errs.append(f"no fixtures found under {fx_dir}")1029 1030 for fx in fixtures:1031 report = build_report(1032 fx.read_text(), rules, supps, versions=None, baseline=baseline1033 )1034 got = render_human(report) + "\n"1035 gold = gold_dir / fx.name1036 if update_golden:1037 gold_dir.mkdir(parents=True, exist_ok=True)1038 gold.write_text(got)1039 continue1040 if not gold.is_file():1041 errs.append(f"missing golden: {gold.name} (run --update-golden)")1042 continue1043 want = gold.read_text()1044 if got != want:1045 import difflib1046 1047 diff = "".join(1048 difflib.unified_diff(1049 want.splitlines(True),1050 got.splitlines(True),1051 fromfile=f"golden/{fx.name}",1052 tofile=f"got/{fx.name}",1053 )1054 )1055 errs.append(f"golden mismatch {fx.name}:\n{diff}")1056 1057 if update_golden and not errs:1058 print(f"Wrote {len(fixtures)} golden file(s).")1059 return 01060 if errs:1061 # Even in --update-golden mode, YAML cross-check or schema errors are1062 # real failures: regenerating goldens must not paper over a broken1063 # rules.yaml.1064 if update_golden:1065 print(f"Wrote {len(fixtures)} golden file(s), but:")1066 print("SELFTEST FAILED:")1067 for e in errs:1068 print(f" - {e}")1069 return 11070 print(1071 f"SELFTEST OK ({len(fixtures)} fixtures, {len(rules)} rules, "1072 f"{len(supps)} suppressions)."1073 )1074 return 01075 1076 1077# --------------------------------------------------------------------------- #1078# CLI1079# --------------------------------------------------------------------------- #1080def read_stream(args) -> str:1081 src = args.file or args.input1082 if src and src != "-":1083 return Path(src).read_text()1084 if sys.stdin.isatty():1085 sys.exit(1086 "No input. Pipe a warning stream in, pass a file, or use '-' for stdin.\n"1087 " e.g. rtd.py warnings --pr 64135 | sphinx_fix.py"1088 )1089 return sys.stdin.read()1090 1091 1092def build_arg_parser() -> argparse.ArgumentParser:1093 p = argparse.ArgumentParser(1094 prog="sphinx_fix.py", description=__doc__.splitlines()[0]1095 )1096 p.add_argument("input", nargs="?", help="warnings/log file ('-' or omit for stdin)")1097 p.add_argument("--file", help="explicit input file (alternative to positional)")1098 p.add_argument("--rules", type=Path, default=DEFAULT_RULES, help="rules.yaml path")1099 p.add_argument("--json", action="store_true", help="emit JSON instead of a table")1100 p.add_argument("--explain", metavar="RULE_ID", help="print one rule and exit")1101 p.add_argument(1102 "--sphinx-version", help="declare running Sphinx for the version gate"1103 )1104 p.add_argument("--myst-version", help="declare running myst-parser")1105 p.add_argument("--no-color", action="store_true", help="accepted; output is plain")1106 p.add_argument("--selftest", action="store_true", help="run fixtures vs golden")1107 p.add_argument(1108 "--update-golden",1109 action="store_true",1110 help="(re)write golden files from current output",1111 )1112 return p1113 1114 1115def main(argv=None) -> int:1116 args = build_arg_parser().parse_args(argv)1117 1118 if args.selftest or args.update_golden:1119 return run_selftest(args.rules, args.update_golden)1120 1121 try:1122 rules, supps, baseline = load_rules(args.rules)1123 except RulesError as e:1124 sys.exit(f"rules error: {e}")1125 1126 if args.explain:1127 print(explain(args.explain, rules))1128 return 01129 1130 versions = None1131 if args.sphinx_version or args.myst_version:1132 versions = {"sphinx": args.sphinx_version, "myst_parser": args.myst_version}1133 1134 text = read_stream(args)1135 report = build_report(text, rules, supps, versions, baseline)1136 print(render_json(report) if args.json else render_human(report))1137 return exit_code_for(report)1138 1139 1140if __name__ == "__main__":1141 sys.exit(main())1142