sphinx-fix

Diagnose a failing Ray Sphinx / Read the Docs documentation build. Parses the Sphinx warning stream (an RtD build log, a local build, or pasted text), classifies each warning against a rules table, and proposes the canonical fix in severity-tier order. Detects a hard-broken build, segregates known-benign suppressed classes, and lists every unclassified warning. Use when a `docs/readthedocs.com:anyscale-ray` check fails, when asked "why is the docs build failing?" or "what warning is breaking this PR?", or to turn a Sphinx warning dump into an ordered fix list.

Install
npx skills add 'https://github.com/ray-project/ray/tree/master/doc/.claude/skills/sphinx-fix'
Download bundle ↓
master · 39882c6Scanned 2026-09-17

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md
#!/usr/bin/env python"""Classify Ray Sphinx/MyST doc-build warnings against a rules table. Read-only diagnostic. Reads a Sphinx warning stream (a file, stdin, or pastedtext -- either the `/rtd-build-logs warnings` output or a raw `sphinx-build`log / `-w` warnings file), parses each warning into a record, classifies itagainst `rules.yaml`, and prints the canonical fix for each. v0 ishuman-in-the-loop: this tool proposes fixes and never edits files. It triages by severity tier (1 fatal/abort, 2 structural/parse, 3 plainwarning), detects a hard-broken build first, segregates known-benignsuppressed classes, and emits every unmatched warning as an explicitunclassified list so the rules table can grow from real misses. Stdlib-only. Prefers PyYAML to read the rules table but falls back to a smallrestricted-grammar parser so it runs in a bare environment.""" from __future__ import annotations import argparseimport jsonimport reimport sysfrom dataclasses import dataclass, fieldfrom pathlib import Path DEFAULT_RULES = Path(__file__).resolve().parent / "rules.yaml"  # --------------------------------------------------------------------------- ## Records# --------------------------------------------------------------------------- #@dataclassclass SphinxWarning:    path: str | None    line: int | None    level: str    message: str    category: str | None    raw: str  @dataclassclass Rule:    id: str    title: str    tier: int    safety: str    match: str    categories: list[str]    signatures: list[re.Pattern]    cause: str    fix: str    fix_template: str | None    target_extract: re.Pattern | None    version_sphinx: str | None    version_myst: str | None    notes: str | None  @dataclassclass Suppression:    id: str    categories: list[str]    signatures: list[re.Pattern]    location_contains: str | None    reason: str    tracked_by: str | None  @dataclassclass Finding:    warning: SphinxWarning    rule_id: str    tier: int    fix: str    target: str | None    safety: str    version_ok: bool  @dataclassclass AbortSignal:    signature: str    excerpt: list[str]    warning_tail_present: bool    hint: str | None = None  @dataclassclass BuildState:    state: str | None = None    success: bool | None = None    summary: str | None = None    declared_warning_count: int | None = None    exit_code: int | None = None  @dataclassclass Report:    abort: AbortSignal | None    findings: list[Finding]    unclassified: list[SphinxWarning]    suppressed: list[tuple[SphinxWarning, Suppression]]    build_state: BuildState    baseline: dict = field(default_factory=dict)  class RulesError(Exception):    """Raised when rules.yaml is malformed."""  # --------------------------------------------------------------------------- ## Restricted-grammar YAML fallback (used only when PyYAML is absent)# --------------------------------------------------------------------------- #def _strip_comment(line: str) -> str:    """Drop a trailing/full-line `#` comment, ignoring `#` inside quotes."""    out: list[str] = []    dq = False    i, n = 0, len(line)    while i < n:        c = line[i]        if dq:            out.append(c)            if c == "\\" and i + 1 < n:                out.append(line[i + 1])                i += 2                continue            if c == '"':                dq = False            i += 1            continue        if c == '"':            dq = True            out.append(c)            i += 1            continue        if c == "#" and (i == 0 or line[i - 1] == " "):            break        out.append(c)        i += 1    return "".join(out)  def _unescape_dq(s: str) -> str:    table = {"\\": "\\", '"': '"', "n": "\n", "t": "\t", "r": "\r", "/": "/", "0": "\0"}    out: list[str] = []    i, n = 0, len(s)    while i < n:        c = s[i]        if c == "\\" and i + 1 < n:            out.append(table.get(s[i + 1], s[i + 1]))            i += 2            continue        out.append(c)        i += 1    return "".join(out)  def _scalar(s: str):    s = s.strip()    if len(s) >= 2 and s[0] == '"' and s[-1] == '"':        return _unescape_dq(s[1:-1])    if s in ("", "null", "~"):        return None    if s == "true":        return True    if s == "false":        return False    if re.fullmatch(r"-?\d+", s):        return int(s)    return s  _KEY_RE = re.compile(r"^([A-Za-z0-9_]+):(?:\s+(.*))?$")  def _yaml_fallback(text: str):    items: list[list] = []    for raw in text.split("\n"):        content = _strip_comment(raw)        if not content.strip():            continue        indent = len(content) - len(content.lstrip(" "))        items.append([indent, content.strip()])    if not items:        # Empty / comment-only / whitespace-only input. Match PyYAML's        # safe_load("") -> None so load_rules raises a clean RulesError and        # _crosscheck_yaml stays in agreement with PyYAML.        return None    pos = [0]     def peek():        return items[pos[0]] if pos[0] < len(items) else None     def value_after_key(key_indent: int):        nxt = peek()        if nxt is None or nxt[0] <= key_indent:            return None        return parse_node(nxt[0])     def parse_map_keys(d: dict, indent: int) -> None:        while True:            cur = peek()            if cur is None or cur[0] != indent:                break            m = _KEY_RE.match(cur[1])            if not m:                break            pos[0] += 1            key, val = m.group(1), m.group(2)            if val:                d[key] = _scalar(val)            else:                d[key] = value_after_key(indent)     def parse_node(indent: int):        cur = peek()        if cur[1].startswith("- "):            seq: list = []            while True:                cur = peek()                if cur is None or cur[0] != indent or not cur[1].startswith("- "):                    break                head = cur[1][2:].strip()                pos[0] += 1                m = _KEY_RE.match(head)                if m:                    d: dict = {}                    key, val = m.group(1), m.group(2)                    if val:                        d[key] = _scalar(val)                    else:                        d[key] = value_after_key(indent + 2)                    parse_map_keys(d, indent + 2)                    seq.append(d)                else:                    seq.append(_scalar(head))            return seq        d = {}        parse_map_keys(d, indent)        return d     return parse_node(0)  def _load_yaml_text(text: str):    try:        import yaml    except ImportError:        return _yaml_fallback(text)    return yaml.safe_load(text)  # --------------------------------------------------------------------------- ## Rule loading# --------------------------------------------------------------------------- #def _compile_regexes(values, where: str) -> list[re.Pattern]:    out = []    for v in values or []:        try:            out.append(re.compile(v))        except re.error as e:            raise RulesError(f"bad regex in {where}: {v!r} ({e})") from e    return out  def _compile_rule(raw: dict) -> Rule:    rid = raw.get("id")    if not rid:        raise RulesError(f"rule missing id: {raw!r}")    categories = raw.get("categories") or []    signatures = _compile_regexes(raw.get("signatures"), f"rule {rid} signatures")    if not categories and not signatures:        raise RulesError(f"rule {rid} has neither categories nor signatures")    tier = raw.get("tier")    if tier not in (1, 2, 3):        raise RulesError(f"rule {rid} has invalid tier {tier!r}")    safety = raw.get("safety")    if safety not in ("mechanical", "judgment"):        raise RulesError(f"rule {rid} has invalid safety {safety!r}")    tex = raw.get("target_extract")    target_extract = re.compile(tex) if tex else None    return Rule(        id=rid,        title=raw.get("title", rid),        tier=tier,        safety=safety,        match=raw.get("match", "any"),        categories=categories,        signatures=signatures,        cause=raw.get("cause", ""),        fix=raw.get("fix", ""),        fix_template=raw.get("fix_template"),        target_extract=target_extract,        version_sphinx=raw.get("version_sphinx"),        version_myst=raw.get("version_myst"),        notes=raw.get("notes"),    )  def _compile_suppression(raw: dict) -> Suppression:    sid = raw.get("id", "?")    return Suppression(        id=sid,        categories=raw.get("categories") or [],        signatures=_compile_regexes(raw.get("signatures"), f"suppression {sid}"),        location_contains=raw.get("location_contains"),        reason=raw.get("reason", ""),        tracked_by=raw.get("tracked_by"),    )  def load_rules(path: Path) -> tuple[list[Rule], list[Suppression], dict]:    try:        text = path.read_text()    except OSError as e:        raise RulesError(f"cannot read rules file {path}: {e}") from e    data = _load_yaml_text(text)    if not isinstance(data, dict):        raise RulesError(f"rules file {path} did not parse to a mapping")    rules = [_compile_rule(r) for r in data.get("rules", [])]    supps = [_compile_suppression(s) for s in data.get("suppressions", [])]    seen: set[str] = set()    for r in rules:        if r.id in seen:            raise RulesError(f"duplicate rule id: {r.id}")        seen.add(r.id)    baseline = {        "sphinx": data.get("baseline_sphinx"),        "myst_parser": data.get("baseline_myst"),    }    return rules, supps, baseline  # --------------------------------------------------------------------------- ## Parsing the warning stream# --------------------------------------------------------------------------- ## Sphinx warning line: "<path>[:<line>]: LEVEL: <message> [<category>]".# The line number is optional -- some classes (e.g. misc.copy_overwrite) warn# on a whole file with no line.WARN_RE = re.compile(    r"^(?P<path>.*?):(?:(?P<line>\d+):)?\s+(?P<level>WARNING|ERROR|SEVERE):\s+"    r"(?P<msg>.*?)(?:\s+\[(?P<category>[\w.]+)\])?$")# Bare Sphinx warning with no path/line (e.g. "WARNING: extension ...").BARE_RE = re.compile(    r"^(?P<level>WARNING|ERROR|SEVERE):\s+"    r"(?P<msg>.*?)(?:\s+\[(?P<category>[\w.]+)\])?$")# Strip the Read the Docs checkout prefix so paths are repo-relative. The RtD# path contains "/checkouts/" twice (.../checkouts/readthedocs.org/... and# .../checkouts/<version>/<repo-relative>), so match greedily to the LAST one.CHECKOUT_RE = re.compile(r"^.*/checkouts/[^/]+/")STATE_RE = re.compile(r"State:\s+(?P<state>\w+)\s+Success:\s+(?P<success>\w+)")EXIT_RE = re.compile(r"^Exit code:\s+(?P<code>\d+)")SUMMARY_RE = re.compile(r"build (?:finished|succeeded)[^\n]*", re.IGNORECASE)DECLARED_RE = re.compile(r"build finished with problems,\s+(?P<n>\d+)\s+warning", re.I) ABORT_RES = [    ("traceback", re.compile(r"^Traceback \(most recent call last\):")),    ("extension-error", re.compile(r"Extension error")),    ("import-error", re.compile(r"Could not import extension")),    ("severe", re.compile(r"(?:^|\s)SEVERE:\s")),    ("sphinx-error", re.compile(r"^Sphinx error:")),]  def strip_checkout_prefix(path: str) -> str:    return CHECKOUT_RE.sub("", path)  def is_noise(line: str) -> bool:    """True for python-logging lines and JSON log records (not Sphinx warnings)."""    if "\tWARNING " in line or "\tERROR " in line:        return True    # urllib3 retry chatter from the pip-install phase (Sphinx never emits    # "Retrying (Retry(" and pip index URLs are /simple/...), not a doc warning.    if "Retrying (Retry(" in line and "after connection broken" in line:        return True    s = line.strip()    return s.startswith("{") and '"levelname"' in s  def parse_warnings(text: str) -> list[SphinxWarning]:    out: list[SphinxWarning] = []    for raw in text.splitlines():        if is_noise(raw):            continue        s = raw.strip()        if not s:            continue        m = WARN_RE.match(s)        if m:            out.append(                SphinxWarning(                    path=strip_checkout_prefix(m.group("path")),                    line=int(m.group("line")) if m.group("line") else None,                    level=m.group("level"),                    message=m.group("msg").strip(),                    category=m.group("category"),                    raw=raw,                )            )            continue        m = BARE_RE.match(s)        if m:            out.append(                SphinxWarning(                    path=None,                    line=None,                    level=m.group("level"),                    message=m.group("msg").strip(),                    category=m.group("category"),                    raw=raw,                )            )    return out  def parse_build_state(text: str) -> BuildState:    st = BuildState()    for raw in text.splitlines():        s = raw.strip()        m = STATE_RE.search(s)        if m:            st.state = m.group("state")            st.success = {"true": True, "false": False}.get(m.group("success").lower())        m = EXIT_RE.match(s)        if m:            st.exit_code = int(m.group("code"))        m = SUMMARY_RE.search(s)        if m:            st.summary = m.group(0).strip()        m = DECLARED_RE.search(s)        if m:            st.declared_warning_count = int(m.group("n"))    return st  # A decoy-aware hint for the autosummary import abort. autosummary imports every# documented object at builder-inited, so the module the error names is usually# NOT the culprit: an unrelated module's import chain broke (commonly a dep that# autodoc_mock_imports mocks for the doc build but that runs at import time), and# the named object is just the first to trip it. See build-troubleshooter-design.md.AUTOSUMMARY_ABORT_HINT = (    "the named module is usually a DECOY. autosummary imports every documented "    "object at builder-inited, so the real cause is typically an unrelated module "    "whose import chain breaks under autodoc_mock_imports (a dep mocked for the doc "    "build -- numpy/pandas/etc. -- but used at import time), aborting a shared "    "import (in Ray, ray.air imports ray.data). Trace the import to the offending "    "eager import and make it lazy; the named module is just the first to trip it.")  def _abort_hint(name: str, text: str) -> str | None:    if (        name == "extension-error"        and "autosummary" in text        and ("no module named" in text or "ImportExceptionGroup" in text)    ):        return AUTOSUMMARY_ABORT_HINT    return None  def detect_abort(text: str, state: BuildState) -> AbortSignal | None:    """Detect a hard-broken build that aborted before the warning pass.     Only fires when an abort signature is present AND the build did not    complete -- i.e. there is no "build finished/succeeded" summary and the    state does not report success. Guards against false-firing on a healthy    build whose text merely quotes "Extension error" etc.     A line that parses as an ordinary Sphinx warning is never an abort. A hard    abort happens *before* the warning pass and is printed as free text    ("Extension error:", a traceback, "Sphinx error:"), never as the    "path:line: LEVEL: msg" form. So skip warning-formatted lines here --    otherwise a normal SEVERE/ERROR warning, or one whose message merely    contains "Extension error", masquerades as an abort. This matters most for    warnings-only input (a `-w` file or a pasted subset) that carries no build    summary, where the completion guard above can't help.    """    completed = bool(SUMMARY_RE.search(text))    if completed or state.success is True:        return None    lines = text.splitlines()    for i, raw in enumerate(lines):        s = raw.strip()        if WARN_RE.match(s) or BARE_RE.match(s):            continue        for name, rx in ABORT_RES:            if rx.search(raw):                excerpt = [ln.rstrip() for ln in lines[i : i + 5]]                return AbortSignal(                    name,                    excerpt,                    warning_tail_present=completed,                    hint=_abort_hint(name, text),                )    return None  # --------------------------------------------------------------------------- ## Version comparator (no `packaging` dependency)# --------------------------------------------------------------------------- #def _parse_ver(v: str) -> tuple[int, ...]:    return tuple(int(x) for x in re.findall(r"\d+", v))  def _tuplecmp(a: tuple[int, ...], b: tuple[int, ...]) -> int:    n = max(len(a), len(b))    a = a + (0,) * (n - len(a))    b = b + (0,) * (n - len(b))    return (a > b) - (a < b)  def version_in_range(actual: str | None, spec: str | None) -> bool:    if not spec or not actual:        return True    av = _parse_ver(actual)    for clause in spec.split(","):        clause = clause.strip()        m = re.match(r"(>=|<=|==|>|<)\s*(.+)", clause)        if not m:            continue        op, bv = m.group(1), _parse_ver(m.group(2))        c = _tuplecmp(av, bv)        ok = {            ">=": c >= 0,            ">": c > 0,            "<=": c <= 0,            "<": c < 0,            "==": c == 0,        }[op]        if not ok:            return False    return True  # --------------------------------------------------------------------------- ## Classify# --------------------------------------------------------------------------- #def _rule_fires(rule: Rule, cat_hit: bool, sig_hit: bool) -> bool:    if rule.match == "all":        cat_ok = cat_hit if rule.categories else True        sig_ok = sig_hit if rule.signatures else True        return cat_ok and sig_ok    return cat_hit or sig_hit  def _extract_target(rule: Rule, w: SphinxWarning):    target = sec = None    if rule.target_extract:        m = rule.target_extract.search(w.message)        if m:            gd = m.groupdict()            target = gd.get("target")            sec = gd.get("sec")    stem = None    if target:        stem = re.sub(r"\.(rst|md|html)$", "", target).split("#")[0]    return target, stem, sec  def _format_fix(    rule: Rule, target: str | None, stem: str | None, sec: str | None) -> str:    fix = rule.fix    if rule.fix_template:        rep = (            rule.fix_template.replace("{target_stem}", stem or target or "DOC")            .replace("{target}", target or "DOC")            .replace("{sec}", sec or "section")        )        fix = f"{fix}  Suggested: {rep}"    return fix  def match_warning(    w: SphinxWarning, rules: list[Rule], versions: dict | None) -> Finding | None:    candidates: list[tuple[Rule, bool, bool]] = []    for r in rules:        cat_hit = bool(r.categories) and w.category in r.categories        sig_hit = any(rx.search(w.message) for rx in r.signatures)        if _rule_fires(r, cat_hit, sig_hit):            candidates.append((r, cat_hit, sig_hit))    if not candidates:        return None    # Prefer a signature hit over a category-only hit; stable sort keeps the    # file's most-specific-first order among equals.    candidates.sort(key=lambda c: 0 if c[2] else 1)    rule = candidates[0][0]    target, stem, sec = _extract_target(rule, w)    version_ok = True    if versions:        version_ok = version_in_range(            versions.get("sphinx"), rule.version_sphinx        ) and version_in_range(versions.get("myst_parser"), rule.version_myst)    return Finding(        warning=w,        rule_id=rule.id,        tier=rule.tier,        fix=_format_fix(rule, target, stem, sec),        target=target,        safety=rule.safety,        version_ok=version_ok,    )  def find_suppression(w: SphinxWarning, supps: list[Suppression]) -> Suppression | None:    for s in supps:        cat_hit = bool(s.categories) and w.category in s.categories        sig_hit = any(rx.search(w.message) for rx in s.signatures)        if not (cat_hit or sig_hit):            continue        if s.location_contains:            if w.path and s.location_contains in w.path:                return s            continue        return s    return None  def classify(warnings, rules, supps, versions):    findings: list[Finding] = []    unclassified: list[SphinxWarning] = []    suppressed: list[tuple[SphinxWarning, Suppression]] = []    for w in warnings:        s = find_suppression(w, supps)        if s:            suppressed.append((w, s))            continue        f = match_warning(w, rules, versions)        if f:            findings.append(f)        else:            unclassified.append(w)    findings.sort(key=lambda f: (f.tier, f.rule_id))    return findings, unclassified, suppressed  def build_report(text, rules, supps, versions, baseline=None) -> Report:    state = parse_build_state(text)    abort = detect_abort(text, state)    warnings = parse_warnings(text)    findings, unclassified, suppressed = classify(warnings, rules, supps, versions)    return Report(abort, findings, unclassified, suppressed, state, baseline or {})  # --------------------------------------------------------------------------- ## Reporting# --------------------------------------------------------------------------- #TIER_LABEL = {    1: "fatal/abort",    2: "structural/parse (fix first; these mask warnings beneath them)",    3: "warnings",}  def _loc(w: SphinxWarning) -> str:    if w.path and w.line is not None:        return f"{w.path}:{w.line}"    if w.path:        return w.path    return "(no location)"  def exit_code_for(report: Report) -> int:    if report.abort:        return 2    if report.findings:        return 1    if report.unclassified:        return 3    return 0  # --------------------------------------------------------------------------- ## Root-cause collapse (rendered-report affordance only)# --------------------------------------------------------------------------- ## One structural failure -- an autosummary stub that could not be generated for a# module/class -- masks a flood of downstream py:* reference-target-not-found# warnings for the SAME objects. Left flat, a single root renders as hundreds of# equal-looking rows (one API-ref rework produced ~70 stub warnings masking ~291# reference warnings). Collapse groups the downstream flood under its masking root# so the human sees "1 root -> N masked references" instead of N sibling rows.## This is a human-readability affordance for render_human ONLY. report.findings# stays the full flat list, so --json (the agent surface) is unchanged and# complete, and the verdict/exit code still see every finding.## Correlation is by shared parent namespace: the stub warnings and the reference# warnings are emitted for the same members, so both dotted paths share a parent# (e.g. ray.data.Dataset.map_batches and ray.data.Dataset.map -> ray.data.Dataset).# A tier-1 import abort is the other root of this same flood, but it surfaces# separately as the HARD-BROKEN banner (and empties the finding list), so collapse# runs only when the build completed far enough to emit findings.ROOT_RULE = "autosummary-stub-not-found"DOWNSTREAM_RULE = "py-xref-target-not-found"COLLAPSE_LIST_CAP = 5  @dataclassclass RootCauseGroup:    prefix: str    roots: list[Finding]  # autosummary-stub-not-found findings (the root)    downstream: list[Finding]  # py-xref-target-not-found findings it masks  def _object_prefix(target: str | None) -> str | None:    """Parent namespace of a dotted object path (drop the last segment)."""    if not target:        return None    head = target.rpartition(".")[0]    return head or None  def compute_root_cause_groups(findings: list[Finding]) -> list[RootCauseGroup]:    """Group a masked py-xref flood under its autosummary-stub root, by prefix.     A group forms for a parent-namespace prefix only when it has BOTH at least    one stub root and at least one downstream reference sharing that prefix -- the    masking scenario. A lone stub (no downstream) or a genuinely independent    reference (no matching stub) is left untouched in the normal findings list.    """    roots_by_prefix: dict[str, list[Finding]] = {}    downstream_by_prefix: dict[str, list[Finding]] = {}    for f in findings:        prefix = _object_prefix(f.target)        if not prefix:            continue        if f.rule_id == ROOT_RULE:            roots_by_prefix.setdefault(prefix, []).append(f)        elif f.rule_id == DOWNSTREAM_RULE:            downstream_by_prefix.setdefault(prefix, []).append(f)    groups = []    for prefix in sorted(set(roots_by_prefix) & set(downstream_by_prefix)):        groups.append(            RootCauseGroup(                prefix, roots_by_prefix[prefix], downstream_by_prefix[prefix]            )        )    return groups  def _dedup(items: list[str]) -> list[str]:    seen: set[str] = set()    out: list[str] = []    for it in items:        if it not in seen:            seen.add(it)            out.append(it)    return out  def _capped(items: list[str]) -> str:    """Comma-join up to the cap, appending an explicit dropped-count (never silent)."""    shown = ", ".join(items[:COLLAPSE_LIST_CAP])    if len(items) > COLLAPSE_LIST_CAP:        shown += f", ... and {len(items) - COLLAPSE_LIST_CAP} more"    return shown  def _render_group(g: RootCauseGroup, out: list[str]) -> None:    n_roots, n_down = len(g.roots), len(g.downstream)    root = g.roots[0]    safety = root.safety + (" (needs your call)" if root.safety == "judgment" else "")    out.append(        f"  ROOT [T{root.tier}] {ROOT_RULE} — {g.prefix}.* "        f"({n_roots} stub{'s' if n_roots != 1 else ''} not generated, "        f"masking {n_down} downstream reference{'s' if n_down != 1 else ''})"    )    out.append(f"        where:  {_capped(_dedup([_loc(r.warning) for r in g.roots]))}")    out.append(        f"        stubs:  {_capped([r.target or r.warning.message for r in g.roots])}"    )    out.append(f"        fix:    {root.fix}")    out.append(f"        safety: {safety}")    out.append(        f"    masks {n_down} downstream py:* reference(s) — "        "collapsed (full list in --json):"    )    for d in g.downstream[:COLLAPSE_LIST_CAP]:        out.append(f"          {_loc(d.warning)}  {d.target or d.warning.message}")    if n_down > COLLAPSE_LIST_CAP:        out.append(f"          ... and {n_down - COLLAPSE_LIST_CAP} more (see --json)")  def render_human(report: Report) -> str:    out: list[str] = []    st = report.build_state     if report.abort:        out.append(            "✗ HARD-BROKEN BUILD — fix this first; the rest of the "            "log is unreliable."        )        out.append(f"  signal: {report.abort.signature}")        for ln in report.abort.excerpt:            # rstrip so a blank excerpt line doesn't emit the 4-space indent as            # trailing whitespace (the trailing-whitespace pre-commit hook fails on it).            out.append(f"    {ln}".rstrip())        if report.abort.hint:            out.append(f"  hint: {report.abort.hint}")        out.append("  The warning list below is incomplete until this is fixed.")        out.append("")     bits = []    if st.state:        bits.append(f"state={st.state}")    if st.success is not None:        bits.append(f"success={st.success}")    if st.exit_code is not None:        bits.append(f"exit={st.exit_code}")    if st.summary:        bits.append(st.summary)    if bits:        out.append("Build: " + "  ".join(bits))        out.append("")     # Collapse a masked downstream flood under its root (rendered-report only;    # report.findings stays flat). Skip on an aborted build -- the warning pass    # didn't complete, so the flood/root relationship isn't trustworthy yet.    groups = [] if report.abort else compute_root_cause_groups(report.findings)    grouped_ids = {id(f) for g in groups for f in (*g.roots, *g.downstream)}    remaining = [f for f in report.findings if id(f) not in grouped_ids]     if groups:        out.append(            f"Root-cause groups ({len(groups)}) — a structural root masks a "            "downstream flood; fix the root, rebuild, and the masked references "            "clear together:"        )        for g in groups:            _render_group(g, out)        out.append("")     header = (        "Partial findings (warning pass did not complete)"        if report.abort        else "Findings"    )    out.append(f"{header} ({len(remaining)}):")    if not remaining:        out.append("  (none)")    else:        by_tier: dict[int, list[Finding]] = {}        for f in remaining:            by_tier.setdefault(f.tier, []).append(f)        for tier in sorted(by_tier):            out.append(f"  Tier {tier} — {TIER_LABEL[tier]}:")            for f in by_tier[tier]:                flag = "" if f.version_ok else "  (unvalidated for this version)"                out.append(f"    [T{tier}] {f.rule_id}  {_loc(f.warning)}{flag}")                out.append(f"          msg:    {f.warning.message}")                out.append(f"          fix:    {f.fix}")                safety = f.safety                if safety == "judgment":                    safety += " (needs your call)"                out.append(f"          safety: {safety}")    out.append("")     out.append(        f"Unclassified ({len(report.unclassified)}) — no rule matched; "        "resolve with the user, then file a skill-improvement ticket to add a rule:"    )    for w in report.unclassified:        cat = f" [{w.category}]" if w.category else ""        out.append(f"  {_loc(w)}: {w.level}: {w.message}{cat}")    out.append("")     out.append(f"Suppressed ({len(report.suppressed)}) — known-benign, not actionable:")    counts: dict[str, tuple[str | None, int]] = {}    for _w, s in report.suppressed:        tb, n = counts.get(s.id, (s.tracked_by, 0))        counts[s.id] = (tb, n + 1)    for sid in sorted(counts):        tb, n = counts[sid]        track = f" ({tb})" if tb else ""        out.append(f"  {sid}{track}: {n}")    out.append("")     out.append(_verdict(report))    return "\n".join(out)  def _verdict(report: Report) -> str:    if report.abort:        return "Next: fix the hard-broken build above, then rebuild and re-run."    if report.findings:        top = min(f.tier for f in report.findings)        if top <= 2:            return (                f"Next: fix Tier {top} first (it masks others), then rebuild and "                "re-run — do not assume one pass is complete."            )        return "Next: apply the fixes above, then rebuild and re-run to confirm clean."    if report.unclassified:        return (            "No rule matched the warning(s) above — resolve with the user and "            "extend rules.yaml."        )    return "✓ No actionable warnings."  def render_json(report: Report) -> str:    def w2d(w: SphinxWarning) -> dict:        return {            "path": w.path,            "line": w.line,            "level": w.level,            "message": w.message,            "category": w.category,        }     payload = {        "schema": 1,        "abort": (            None            if not report.abort            else {                "signature": report.abort.signature,                "excerpt": report.abort.excerpt,                "hint": report.abort.hint,            }        ),        "build_state": {            "state": report.build_state.state,            "success": report.build_state.success,            "summary": report.build_state.summary,            "exit_code": report.build_state.exit_code,            "declared_warning_count": report.build_state.declared_warning_count,        },        "findings": [            {                "rule_id": f.rule_id,                "tier": f.tier,                "safety": f.safety,                "fix": f.fix,                "target": f.target,                "version_ok": f.version_ok,                **w2d(f.warning),            }            for f in report.findings        ],        "unclassified": [w2d(w) for w in report.unclassified],        "suppressed": [            {"suppression_id": s.id, "tracked_by": s.tracked_by, **w2d(w)}            for w, s in report.suppressed        ],        "exit_code": exit_code_for(report),    }    return json.dumps(payload, indent=2)  def explain(rule_id: str, rules: list[Rule]) -> str:    for r in rules:        if r.id == rule_id:            lines = [                f"{r.id}  (tier {r.tier}, {r.safety})",                f"  title:      {r.title}",                f"  categories: {', '.join(r.categories) or '(none)'}",                f"  signatures: {', '.join(p.pattern for p in r.signatures) or '(none)'}",                f"  cause:      {r.cause}",                f"  fix:        {r.fix}",            ]            if r.fix_template:                lines.append(f"  template:   {r.fix_template}")            ver = f"sphinx {r.version_sphinx or '*'}, myst {r.version_myst or '*'}"            lines.append(f"  validated:  {ver}")            if r.notes:                lines.append(f"  notes:      {r.notes}")            return "\n".join(lines)    return f"No rule with id {rule_id!r}. Known ids: {', '.join(r.id for r in rules)}"  # --------------------------------------------------------------------------- ## Selftest# --------------------------------------------------------------------------- #def _selftest_invariants(rules, supps, baseline) -> list[str]:    errs: list[str] = []    if not baseline.get("sphinx") or not baseline.get("myst_parser"):        errs.append("rules.yaml missing baseline_sphinx/baseline_myst")    for r in rules:        if not r.categories and not r.signatures:            errs.append(f"{r.id}: no matchers")        if r.tier not in (1, 2, 3):            errs.append(f"{r.id}: bad tier")        if r.safety not in ("mechanical", "judgment"):            errs.append(f"{r.id}: bad safety")    # version comparator spot checks    if not version_in_range("8.2.3", ">=8.0,<9"):        errs.append("version_in_range: 8.2.3 should satisfy >=8.0,<9")    if version_in_range("9.0.0", ">=8.0,<9"):        errs.append("version_in_range: 9.0.0 should NOT satisfy >=8.0,<9")    return errs  def _crosscheck_yaml(rules_path: Path) -> list[str]:    try:        import yaml    except ImportError:        return []    text = rules_path.read_text()    pyyaml = yaml.safe_load(text)    fallback = _yaml_fallback(text)    if pyyaml != fallback:        return [            "YAML fallback parser disagrees with PyYAML on rules.yaml "            "(grammar drift). Keep rules.yaml within the documented subset."        ]    return []  def run_selftest(rules_path: Path, update_golden: bool) -> int:    skill_dir = Path(__file__).resolve().parent    fx_dir = skill_dir / "tests" / "fixtures"    gold_dir = skill_dir / "tests" / "golden"    rules, supps, baseline = load_rules(rules_path)     errs = _selftest_invariants(rules, supps, baseline)    errs += _crosscheck_yaml(rules_path)     fixtures = sorted(fx_dir.glob("*.txt")) if fx_dir.is_dir() else []    if not fixtures:        errs.append(f"no fixtures found under {fx_dir}")     for fx in fixtures:        report = build_report(            fx.read_text(), rules, supps, versions=None, baseline=baseline        )        got = render_human(report) + "\n"        gold = gold_dir / fx.name        if update_golden:            gold_dir.mkdir(parents=True, exist_ok=True)            gold.write_text(got)            continue        if not gold.is_file():            errs.append(f"missing golden: {gold.name} (run --update-golden)")            continue        want = gold.read_text()        if got != want:            import difflib             diff = "".join(                difflib.unified_diff(                    want.splitlines(True),                    got.splitlines(True),                    fromfile=f"golden/{fx.name}",                    tofile=f"got/{fx.name}",                )            )            errs.append(f"golden mismatch {fx.name}:\n{diff}")     if update_golden and not errs:        print(f"Wrote {len(fixtures)} golden file(s).")        return 0    if errs:        # Even in --update-golden mode, YAML cross-check or schema errors are        # real failures: regenerating goldens must not paper over a broken        # rules.yaml.        if update_golden:            print(f"Wrote {len(fixtures)} golden file(s), but:")        print("SELFTEST FAILED:")        for e in errs:            print(f"  - {e}")        return 1    print(        f"SELFTEST OK ({len(fixtures)} fixtures, {len(rules)} rules, "        f"{len(supps)} suppressions)."    )    return 0  # --------------------------------------------------------------------------- ## CLI# --------------------------------------------------------------------------- #def read_stream(args) -> str:    src = args.file or args.input    if src and src != "-":        return Path(src).read_text()    if sys.stdin.isatty():        sys.exit(            "No input. Pipe a warning stream in, pass a file, or use '-' for stdin.\n"            "  e.g. rtd.py warnings --pr 64135 | sphinx_fix.py"        )    return sys.stdin.read()  def build_arg_parser() -> argparse.ArgumentParser:    p = argparse.ArgumentParser(        prog="sphinx_fix.py", description=__doc__.splitlines()[0]    )    p.add_argument("input", nargs="?", help="warnings/log file ('-' or omit for stdin)")    p.add_argument("--file", help="explicit input file (alternative to positional)")    p.add_argument("--rules", type=Path, default=DEFAULT_RULES, help="rules.yaml path")    p.add_argument("--json", action="store_true", help="emit JSON instead of a table")    p.add_argument("--explain", metavar="RULE_ID", help="print one rule and exit")    p.add_argument(        "--sphinx-version", help="declare running Sphinx for the version gate"    )    p.add_argument("--myst-version", help="declare running myst-parser")    p.add_argument("--no-color", action="store_true", help="accepted; output is plain")    p.add_argument("--selftest", action="store_true", help="run fixtures vs golden")    p.add_argument(        "--update-golden",        action="store_true",        help="(re)write golden files from current output",    )    return p  def main(argv=None) -> int:    args = build_arg_parser().parse_args(argv)     if args.selftest or args.update_golden:        return run_selftest(args.rules, args.update_golden)     try:        rules, supps, baseline = load_rules(args.rules)    except RulesError as e:        sys.exit(f"rules error: {e}")     if args.explain:        print(explain(args.explain, rules))        return 0     versions = None    if args.sphinx_version or args.myst_version:        versions = {"sphinx": args.sphinx_version, "myst_parser": args.myst_version}     text = read_stream(args)    report = build_report(text, rules, supps, versions, baseline)    print(render_json(report) if args.json else render_human(report))    return exit_code_for(report)  if __name__ == "__main__":    sys.exit(main()) 
Referenced from SKILL.md