From 6d505c28830590b31df0e92499bd49ff17e021f0 Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Mon, 8 Jun 2026 14:27:07 +0300 Subject: [PATCH 01/13] Add ISO heading sentence-case enforcement and bold table header filter Implement heading-case tooling per ISO/IEC Directives, Part 2, 11.4 (clause titles in sentence case) as both a linter and a build-time Pandoc filter. Proper nouns are preserved via heuristics (camelCase, ALL_CAPS, alphanumeric mix) and an extensible YAML allowlist. Wire the existing bold-table-header filter into the build pipeline with an opt-in --bold-table-headers flag, and add documentation comments throughout. All three ISO features are off by default on the build subcommand: --heading-case-lint (pre-build check, prints violations) --heading-case-filter (rewrites headings in output) --bold-table-headers (bolds table header cells in output) The heading_case_lint standalone subcommand is also available. Co-Authored-By: Claude Opus 4.6 --- doc_build/doc_builder.py | 96 +++++++ doc_build/filters/filter_heading_case.py | 58 ++++ .../filters/filter_iso_bold_table_header.py | 200 ++++++++++++++ doc_build/heading_case.py | 222 +++++++++++++++ doc_build/iso_heading_case_lint.py | 257 ++++++++++++++++++ doc_build/iso_heading_proper_nouns.yaml | 50 ++++ 6 files changed, 883 insertions(+) create mode 100644 doc_build/filters/filter_heading_case.py create mode 100644 doc_build/filters/filter_iso_bold_table_header.py create mode 100644 doc_build/heading_case.py create mode 100644 doc_build/iso_heading_case_lint.py create mode 100644 doc_build/iso_heading_proper_nouns.yaml diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index a857729..f0b59cb 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -254,6 +254,18 @@ def build_docs(self, args): ) args.output.mkdir(parents=True, exist_ok=True) + if getattr(args, 'heading_case_lint', False): + from doc_build.iso_heading_case_lint import check_spec, format_report + pn_path = getattr(args, 'heading_proper_nouns', None) or self.get_heading_proper_nouns() + spec_root = self.get_specification_root() + log(f"\tChecking heading sentence case in {spec_root} ...") + violations = check_spec(spec_root, proper_nouns_path=pn_path) + report = format_report(violations, spec_root=spec_root) + if report: + log(report) + else: + log("\tNo heading case violations found.") + if args.diff: from_ref, to_ref = args.diff[0], args.diff[1] diff_md, from_short, to_short = self.generate_combined_diff( @@ -337,6 +349,12 @@ def _render_combined( if not getattr(args, 'iso_xrefs', False): iso_filter = self.get_filter("iso_xrefs") all_filters = [f for f in all_filters if f != iso_filter] + if not getattr(args, 'heading_case_filter', False): + hc_filter = self.get_filter("heading_case") + all_filters = [f for f in all_filters if f != hc_filter] + if not getattr(args, 'bold_table_headers', False): + bth_filter = self.get_filter("iso_bold_table_header") + all_filters = [f for f in all_filters if f != bth_filter] doc_build_filters = [] for doc_filter in all_filters: doc_build_filters.extend(["-F", doc_filter]) @@ -404,6 +422,9 @@ def _render_combined( if getattr(args, 'iso_xrefs', False): shared_command.extend(["-M", f"ISO_CLAUSE_MAP={self.get_iso_clause_map()}"]) + if getattr(args, 'heading_case_filter', False): + pn_path = getattr(args, 'heading_proper_nouns', None) or self.get_heading_proper_nouns() + shared_command.extend(["-M", f"HEADING_PROPER_NOUNS={pn_path}"]) if from_pretty is not None and to_pretty is not None: shared_command.extend([ "-M", f"diff-from-pretty={from_pretty}", @@ -556,6 +577,8 @@ def get_doc_build_filters(self): self.get_filter("render_diff"), self.get_filter("convert_mathblocks"), self.get_filter("header6"), + self.get_filter("heading_case"), + self.get_filter("iso_bold_table_header"), self.get_filter("iso_xrefs"), self.get_filter("resolve_sections"), self.get_filter("sections_new_page"), @@ -940,6 +963,24 @@ def iso_lint(self, args): else: log("No ISO clause structure violations found.") + def heading_case_lint(self, args): + from doc_build.iso_heading_case_lint import check_spec, format_report + + spec_root = self.get_specification_root() + proper_nouns = getattr(args, 'proper_nouns', None) + if proper_nouns is None: + proper_nouns = self.get_heading_proper_nouns() + log(f"Checking heading sentence case in {spec_root} ...") + violations = check_spec( + spec_root, + proper_nouns_path=proper_nouns, + ) + report = format_report(violations, spec_root=spec_root) + if report: + log(report) + else: + log("No heading case violations found.") + def export_git_archive(self, args): timestr = time.strftime("%Y%m%d-%H%M%S") filename = f"aousd_core_spec_{args.branch}_{timestr}.zip" @@ -1167,6 +1208,17 @@ def get_metadata_defaults_file(self) -> Path: return self.get_scripts_root() / "defaults.yaml" + def get_heading_proper_nouns(self) -> Path: + """Return the heading proper-nouns YAML path. + + Checks for a specification-specific file first, then falls back + to the builder-bundled default. + """ + spec_specific = self.get_specification_root() / "iso_heading_proper_nouns.yaml" + if spec_specific.exists(): + return spec_specific + return self.get_scripts_root() / "iso_heading_proper_nouns.yaml" + def get_iso_clause_map(self) -> Path: """Return the ISO clause map YAML path. @@ -1206,6 +1258,7 @@ def construct_subparsers(self, parser): self.make_spellcheck_parser(subparsers) self.make_style_parser(subparsers) self.make_iso_lint_parser(subparsers) + self.make_heading_case_lint_parser(subparsers) return subparsers def make_build_parser(self, subparsers): @@ -1249,6 +1302,33 @@ def make_build_parser(self, subparsers): "script to recreate the PDF from the .tex (implies tectonic wrapper)", action="store_true", ) + build_parser.add_argument( + "--bold-table-headers", + help="Bold all table header cells in the output, per ISO/IEC " + "Directives, Part 2.", + action="store_true", + ) + build_parser.add_argument( + "--heading-case-filter", + help="Apply ISO sentence-case conversion to headings at build time " + "(proper nouns are preserved via heuristics and an optional YAML allowlist).", + action="store_true", + ) + build_parser.add_argument( + "--heading-case-lint", + help="Run the ISO heading sentence-case linter before building and " + "print violations (does not block the build).", + action="store_true", + ) + build_parser.add_argument( + "--heading-proper-nouns", + type=Path, + default=None, + metavar="YAML", + help="Path to a YAML file listing additional proper nouns for " + "heading-case enforcement. Defaults to iso_heading_proper_nouns.yaml " + "in the specification or builder root.", + ) build_parser.add_argument( "--diff", nargs="*", @@ -1321,6 +1401,22 @@ def make_iso_lint_parser(self, subparsers): p.set_defaults(func=self.iso_lint) return p + def make_heading_case_lint_parser(self, subparsers): + p = subparsers.add_parser( + "heading_case_lint", + help="Check specification headings for ISO sentence-case compliance", + ) + p.add_argument( + "--proper-nouns", + type=Path, + default=None, + metavar="YAML", + help="Path to a YAML file listing additional proper nouns " + "(default: iso_heading_proper_nouns.yaml in the builder or spec root)", + ) + p.set_defaults(func=self.heading_case_lint) + return p + def add_publish_copyright(self, combined): intro_copyright = self.get_publish_intro_legalese() outro = self.get_publish_outro_legalese() diff --git a/doc_build/filters/filter_heading_case.py b/doc_build/filters/filter_heading_case.py new file mode 100644 index 0000000..22a6a16 --- /dev/null +++ b/doc_build/filters/filter_heading_case.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Pandoc filter: convert heading text to ISO sentence case. + +ISO/IEC Directives, Part 2, 11.4: clause titles shall use sentence case. +This filter lowercases non-first words in headings unless they are proper +nouns (detected by heuristic or listed in a YAML allowlist). + +The YAML path is passed via the ``HEADING_PROPER_NOUNS`` pandoc metadata +variable. If absent, only heuristic detection is used. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from pandocfilters import Header, stringify, toJSONFilter + +from heading_case import ( + heading_needs_conversion, + load_proper_nouns, + sentence_case_inlines, +) +from shared_filter_utils import get_metadata_str + + +class HeadingCaseFilter: + """Stateful filter that converts headings to sentence case.""" + + def __init__(self): + self._extra_nouns = None + + def _initialize(self, metadata): + if self._extra_nouns is not None: + return + try: + yaml_path = get_metadata_str(metadata, 'HEADING_PROPER_NOUNS') + from pathlib import Path + self._extra_nouns = load_proper_nouns(Path(yaml_path)) + except KeyError: + self._extra_nouns = set() + + def __call__(self, key, value, fmt, metadata): + self._initialize(metadata) + if key != 'Header': + return None + + level, attr, inlines = value + if not heading_needs_conversion(inlines, self._extra_nouns): + return None + + new_inlines = sentence_case_inlines(inlines, self._extra_nouns) + return Header(level, attr, new_inlines) + + +if __name__ == '__main__': + toJSONFilter(HeadingCaseFilter()) diff --git a/doc_build/filters/filter_iso_bold_table_header.py b/doc_build/filters/filter_iso_bold_table_header.py new file mode 100644 index 0000000..ec03709 --- /dev/null +++ b/doc_build/filters/filter_iso_bold_table_header.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Pandoc filter: bold table header cells per ISO/IEC Directives, Part 2. + +ISO/IEC Directives, Part 2 requires that table column headings shall be +in bold type. This filter walks every Table node in the Pandoc AST and +wraps the inline content of each header cell in Strong (bold). + +Cells that are already bold are left visually unchanged (the outer Strong +is still added, producing harmless nested ``…`` in HTML +or ``\\textbf{\\textbf{…}}`` in LaTeX — both render identically). + +Only Para and Plain blocks inside header cells are wrapped; other block +types (CodeBlock, BulletList, …) are passed through untouched since they +are uncommon in header cells and would not benefit from a Strong wrapper. +""" + +from copy import deepcopy +from pandocfilters import toJSONFilter + + +def boldify_inline(inline, inside_strong=False): + """Recursively process a single inline element for bolding. + + If the node is already Strong, recurse into its children (with the + *inside_strong* flag set) but do not add another Strong wrapper. + For every other node type, deepcopy it and recurse into its ``c`` + value so that nested inlines (Emph inside a Link, etc.) are handled. + + Returns a new (possibly transformed) inline node. + """ + if not isinstance(inline, dict): + return inline + + t = inline["t"] + + # Already bold — recurse into children but skip re-wrapping. + if t == "Strong": + return { + "t": "Strong", + "c": [ + boldify_inline(x, inside_strong=True) + for x in inline["c"] + ] + } + + # Any other inline: deepcopy to avoid mutating the original AST, + # then recurse into the node's content. + result = deepcopy(inline) + + if "c" in result: + result["c"] = boldify_value( + result["c"], + inside_strong=inside_strong + ) + + return result + + +def boldify_value(value, inside_strong=False): + """Recursively walk an arbitrary Pandoc AST value (list, dict, or scalar). + + Dispatches typed dict nodes (those with a ``t`` key) to + ``boldify_inline``; recurses into plain dicts and lists; returns + scalars unchanged. + """ + if isinstance(value, list): + return [ + boldify_value(x, inside_strong) + for x in value + ] + + if isinstance(value, dict): + # Typed AST node — delegate to inline handler. + if "t" in value: + return boldify_inline( + value, + inside_strong=inside_strong + ) + + # Untyped dict (e.g. metadata maps) — recurse into values. + return { + k: boldify_value(v, inside_strong) + for k, v in value.items() + } + + return value + + +def wrap_block_in_strong(block): + """Wrap a Para or Plain block's inlines in a single Strong node. + + Transforms:: + + Para [inline, inline, …] + Plain [inline, inline, …] + + into:: + + Para [Strong [inline, inline, …]] + Plain [Strong [inline, inline, …]] + + Non-Para/Plain blocks (CodeBlock, BulletList, etc.) are returned + unchanged — they are uncommon in table headers and would not + benefit from bold wrapping. + """ + if not isinstance(block, dict): + return block + + t = block.get("t") + + if t not in ("Para", "Plain"): + return block + + # Process children first so any pre-existing Strong nodes are + # handled by boldify_inline before the outer wrap. + inlines = [ + boldify_inline(x) + for x in block["c"] + ] + + return { + "t": t, + "c": [ + { + "t": "Strong", + "c": inlines + } + ] + } + + +def process_cell_blocks(blocks): + """Apply bold wrapping to every block inside a single header cell.""" + return [ + wrap_block_in_strong(block) + for block in blocks + ] + + +def process_table_head(head): + """Walk the TableHead structure and bold every header cell. + + Pandoc 3.x TableHead layout:: + + TableHead = [ head_attr, rows ] + Row = [ row_attr, cells ] + Cell = [ attr, alignment, rowspan, colspan, blocks ] + """ + head_attr, rows = head + + new_rows = [] + + for row in rows: + row_attr, cells = row + + new_cells = [] + + for cell in cells: + attr, alignment, rowspan, colspan, blocks = cell + + new_cells.append([ + attr, + alignment, + rowspan, + colspan, + process_cell_blocks(blocks) + ]) + + new_rows.append([ + row_attr, + new_cells + ]) + + return [head_attr, new_rows] + + +def bold_table_headers(key, value, fmt, meta): + """Top-level filter action: intercept Table nodes and bold their heads.""" + if key != "Table": + return None + + # Pandoc 3.x Table layout: + # Table [ attr, caption, colspecs, head, bodies, foot ] + attr, caption, colspecs, head, bodies, foot = value + + return { + "t": "Table", + "c": [ + attr, + caption, + colspecs, + process_table_head(head), + bodies, + foot + ] + } + + +if __name__ == "__main__": + toJSONFilter(bold_table_headers) diff --git a/doc_build/heading_case.py b/doc_build/heading_case.py new file mode 100644 index 0000000..745fecb --- /dev/null +++ b/doc_build/heading_case.py @@ -0,0 +1,222 @@ +"""Shared logic for ISO heading sentence-case enforcement. + +ISO/IEC Directives, Part 2, 11.4: clause titles shall be in sentence case +(only the first word and proper nouns capitalised). + +This module provides: +- Proper-noun detection via heuristics and an optional YAML allowlist. +- A function to convert heading inline elements to sentence case. +- A function to check whether a heading is already in sentence case. +""" + +import os +import re +import sys +from pathlib import Path +from typing import List, Optional, Set + +_this_dir = os.path.dirname(os.path.abspath(__file__)) +if _this_dir not in sys.path: + sys.path.insert(0, _this_dir) +_filters_dir = os.path.join(_this_dir, 'filters') +if _filters_dir not in sys.path: + sys.path.insert(0, _filters_dir) + +try: + import yaml +except ImportError: + yaml = None + + +# --------------------------------------------------------------------------- +# Proper-noun heuristics +# --------------------------------------------------------------------------- + +# Words that look like identifiers: camelCase, PascalCase, or contain +# internal capitals (e.g. ObjectPath, iPhone, OpenUSD). +_MIXED_CASE_RE = re.compile( + r'^[A-Z]?[a-z]+[A-Z]' # camelCase or PascalCase with interior capital + r'|^[A-Z][a-z]+[A-Z]' # PascalCase like OpenUSD +) + +# Fully uppercase with optional underscores/digits (USD, AOUSD, API, UTF-8). +_ALL_CAPS_RE = re.compile(r'^[A-Z][A-Z0-9_-]+$') + +# Common technical acronyms that should always stay uppercase. +BUILTIN_ACRONYMS = frozenset({ + "API", "APIs", "ASCII", "AOUSD", "CPU", "CSS", "DPI", "GPU", "GFM", + "HTML", "HTTP", "HTTPS", "ID", "IEEE", "IO", "ISO", "JSON", "LHS", + "MIME", "OCR", "OS", "PDF", "PEG", "RAM", "RCS", "REST", "RFC", + "RHS", "SDK", "SHA", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", + "TSV", "UDP", "UI", "URI", "URL", "USD", "UTF", "UUID", "XML", + "YAML", +}) + + +def load_proper_nouns(yaml_path: Optional[Path] = None) -> Set[str]: + """Load proper nouns from a YAML allowlist file. + + The YAML file is expected to have a top-level list under the key + ``proper_nouns``. Returns an empty set if the file does not exist + or yaml is unavailable. + """ + if yaml_path is None or yaml is None: + return set() + try: + with open(yaml_path, encoding='utf-8') as fh: + data = yaml.safe_load(fh) or {} + return set(data.get('proper_nouns', [])) + except (FileNotFoundError, OSError): + return set() + + +def is_proper_noun(word: str, extra_nouns: Set[str] = frozenset()) -> bool: + """Determine whether *word* should retain its capitalisation. + + A word is considered a proper noun if any of these hold: + - It is in *extra_nouns* (the YAML allowlist). + - It is in the built-in acronym set. + - It matches the mixed-case heuristic (camelCase / PascalCase). + - It is fully uppercase with length >= 2. + - It contains digits mixed with letters (e.g. "UTF-8", "H264"). + """ + stripped = word.strip(".,;:!?()[]{}\"'`") + if not stripped: + return False + if stripped in extra_nouns: + return True + if stripped in BUILTIN_ACRONYMS: + return True + if _ALL_CAPS_RE.match(stripped) and len(stripped) >= 2: + return True + if _MIXED_CASE_RE.match(stripped): + return True + if re.search(r'[A-Za-z]', stripped) and re.search(r'\d', stripped): + return True + return False + + +# --------------------------------------------------------------------------- +# Sentence-case conversion for Pandoc inlines +# --------------------------------------------------------------------------- + +def _lowercase_word(word: str) -> str: + """Lowercase a word, preserving leading/trailing punctuation.""" + prefix = '' + suffix = '' + i = 0 + while i < len(word) and not word[i].isalnum(): + prefix += word[i] + i += 1 + j = len(word) + while j > i and not word[j - 1].isalnum(): + j -= 1 + suffix = word[j] + suffix + core = word[i:j] + return prefix + core.lower() + suffix + + +def _sentence_case_track( + inlines: list, + extra_nouns: Set[str], + first_word_seen: bool, +) -> tuple: + """Internal: convert inlines to sentence case, returning (new_inlines, first_word_seen).""" + import copy + result = copy.deepcopy(inlines) + + for node in result: + if not isinstance(node, dict): + continue + t = node.get('t') + if t == 'Str': + text = node['c'] + words = text.split(' ') + new_words = [] + for word in words: + if not word: + new_words.append(word) + continue + has_alpha = any(c.isalpha() for c in word) + if not first_word_seen and has_alpha: + first_word_seen = True + new_words.append(word) + elif is_proper_noun(word, extra_nouns): + new_words.append(word) + elif has_alpha: + new_words.append(_lowercase_word(word)) + else: + new_words.append(word) + node['c'] = ' '.join(new_words) + elif t in ('Code', 'Math', 'RawInline'): + first_word_seen = True + elif t == 'Space': + pass + elif t == 'Span': + # Span: node['c'] = [attr, inlines_list] + children = node['c'][1] + node['c'][1], first_word_seen = _sentence_case_track( + children, extra_nouns, first_word_seen, + ) + elif t in ('Emph', 'Strong', 'Strikeout', 'Superscript', + 'Subscript', 'SmallCaps'): + children = node['c'] if isinstance(node['c'], list) else [node['c']] + node['c'], first_word_seen = _sentence_case_track( + children, extra_nouns, first_word_seen, + ) + elif t == 'Quoted': + node['c'][1], first_word_seen = _sentence_case_track( + node['c'][1], extra_nouns, first_word_seen, + ) + + return result, first_word_seen + + +def sentence_case_inlines( + inlines: list, + extra_nouns: Set[str] = frozenset(), +) -> list: + """Convert a list of Pandoc inline elements to sentence case. + + Only ``Str`` nodes are modified; ``Code``, ``Math``, ``RawInline`` + and content inside ``Link``/``Image`` alt text are left unchanged. + + The first alphabetic word in the heading is always capitalised. + Subsequent words are lowercased unless they are proper nouns. + """ + result, _ = _sentence_case_track(inlines, extra_nouns, False) + return result + + +# --------------------------------------------------------------------------- +# Violation detection +# --------------------------------------------------------------------------- + +def _extract_words(inlines: list) -> List[str]: + """Extract plain-text words from Pandoc inlines, skipping Code/Math.""" + from filters.pandocfilters import stringify + text = stringify(inlines) + return [w for w in re.split(r'\s+', text) if w] + + +def heading_needs_conversion( + inlines: list, + extra_nouns: Set[str] = frozenset(), +) -> bool: + """Return True if the heading appears to be in title case. + + A heading is flagged if at least one non-first word is capitalised + and is not a proper noun. Single-word headings are never flagged. + """ + words = _extract_words(inlines) + if len(words) <= 1: + return False + + for word in words[1:]: + stripped = word.strip(".,;:!?()[]{}\"'`") + if not stripped or not stripped[0].isalpha(): + continue + if stripped[0].isupper() and not is_proper_noun(word, extra_nouns): + return True + + return False diff --git a/doc_build/iso_heading_case_lint.py b/doc_build/iso_heading_case_lint.py new file mode 100644 index 0000000..fb2c7fc --- /dev/null +++ b/doc_build/iso_heading_case_lint.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""ISO heading sentence-case linter for Markdown specification files. + +ISO/IEC Directives, Part 2, 11.4: clause titles shall use sentence case +(only the first word and proper nouns capitalised). This module scans +source Markdown files and reports headings that appear to be in title case. + +Proper-noun detection combines heuristics (camelCase, ALL_CAPS, mixed +alphanumeric) with an optional YAML allowlist of domain-specific terms. + +Usage as a library: + from doc_build.iso_heading_case_lint import check_spec + violations = check_spec(Path("specification/")) + for v in violations: print(v.format()) + +Usage from the command line: + python3 -m doc_build.iso_heading_case_lint specification/ + python3 -m doc_build.iso_heading_case_lint --proper-nouns custom.yaml spec/ +""" + +import json +import os +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional, Set + +_this_dir = os.path.dirname(os.path.abspath(__file__)) +if _this_dir not in sys.path: + sys.path.insert(0, _this_dir) +_filters_dir = os.path.join(_this_dir, 'filters') +if _filters_dir not in sys.path: + sys.path.insert(0, _filters_dir) + +from filters.pandocfilters import stringify + +from heading_case import ( + heading_needs_conversion, + is_proper_noun, + load_proper_nouns, + sentence_case_inlines, +) + +DEFAULT_WORKERS = 8 + + +# --------------------------------------------------------------------------- +# Data types +# --------------------------------------------------------------------------- + +@dataclass +class Violation: + """A heading that is not in ISO sentence case.""" + file: Path + lineno: int + level: int + heading_text: str + suggested_text: str + non_sentence_words: List[str] = field(default_factory=list) + + def format(self) -> str: + marker = '#' * self.level + words_str = ', '.join(f'"{w}"' for w in self.non_sentence_words) + lines = [ + f"{self.file}:{self.lineno}: " + f"{marker} \"{self.heading_text}\"", + f" suggested: {marker} \"{self.suggested_text}\"", + ] + if self.non_sentence_words: + lines.append(f" title-cased words: {words_str}") + return '\n'.join(lines) + + +# --------------------------------------------------------------------------- +# Pandoc AST helpers +# --------------------------------------------------------------------------- + +def _get_sourcepos(attr: list) -> Optional[int]: + for key, val in attr[2]: + if key == "data-pos": + pos = val.split("@")[-1] + return int(pos.split(":")[0]) + return None + + +def _find_non_sentence_words(inlines: list, extra_nouns: Set[str]) -> List[str]: + """Return words that are capitalised but not proper nouns (skipping the first word).""" + import re + words = [] + text = stringify(inlines) + all_words = [w for w in re.split(r'\s+', text) if w] + for word in all_words[1:]: + stripped = word.strip(".,;:!?()[]{}\"'`") + if not stripped or not stripped[0].isalpha(): + continue + if stripped[0].isupper() and not is_proper_noun(word, extra_nouns): + words.append(word) + return words + + +# --------------------------------------------------------------------------- +# Core checker +# --------------------------------------------------------------------------- + +def check_file(path: Path, extra_nouns: Set[str] = frozenset()) -> List[Violation]: + """Return all heading sentence-case violations in a single Markdown file.""" + try: + result = subprocess.run( + ["pandoc", "-f", "commonmark_x+sourcepos", "-t", "json", str(path)], + capture_output=True, text=True, check=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return [] + + doc = json.loads(result.stdout) + violations: List[Violation] = [] + + for block in doc["blocks"]: + if block["t"] != "Header": + continue + + level = block["c"][0] + attr = block["c"][1] + inlines = block["c"][2] + lineno = _get_sourcepos(attr) + + if not heading_needs_conversion(inlines, extra_nouns): + continue + + heading_text = stringify(inlines).strip() + converted = sentence_case_inlines(inlines, extra_nouns) + suggested_text = stringify(converted).strip() + non_sentence = _find_non_sentence_words(inlines, extra_nouns) + + violations.append(Violation( + file=path, + lineno=lineno or 0, + level=level, + heading_text=heading_text, + suggested_text=suggested_text, + non_sentence_words=non_sentence, + )) + + return violations + + +def check_spec( + spec_root: Path, + workers: int = DEFAULT_WORKERS, + proper_nouns_path: Optional[Path] = None, +) -> List[Violation]: + """Walk *spec_root* recursively and return all violations in .md files.""" + extra_nouns = load_proper_nouns(proper_nouns_path) + + md_files: List[Path] = [] + for dirpath, dirnames, filenames in os.walk(spec_root): + dirnames.sort() + for fname in sorted(filenames): + if fname.endswith('.md'): + md_files.append(Path(dirpath) / fname) + + all_violations: List[Violation] = [] + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = {pool.submit(check_file, p, extra_nouns): p for p in md_files} + for future in as_completed(futures): + all_violations.extend(future.result()) + + all_violations.sort(key=lambda v: (str(v.file), v.lineno)) + return all_violations + + +# --------------------------------------------------------------------------- +# Report formatting +# --------------------------------------------------------------------------- + +def format_report( + violations: List[Violation], + spec_root: Optional[Path] = None, +) -> str: + if not violations: + return '' + + by_file: dict = {} + for v in violations: + rel = v.file.relative_to(spec_root) if spec_root else v.file + by_file.setdefault(rel, []).append(v) + + sections: List[str] = [] + total = 0 + for rel_path, file_violations in by_file.items(): + block_lines = [str(rel_path)] + for v in file_violations: + display_v = Violation( + file=rel_path, + lineno=v.lineno, + level=v.level, + heading_text=v.heading_text, + suggested_text=v.suggested_text, + non_sentence_words=v.non_sentence_words, + ) + block_lines.append(display_v.format()) + total += 1 + sections.append('\n'.join(block_lines)) + + file_count = len(by_file) + header = f'{total} heading case violation(s) in {file_count} file(s)\n' + return header + '\n' + '\n\n'.join(sections) + + +# --------------------------------------------------------------------------- +# Command-line entry point +# --------------------------------------------------------------------------- + +def main(argv=None): + import argparse + parser = argparse.ArgumentParser( + description='Check Markdown headings for ISO sentence-case compliance.' + ) + parser.add_argument( + 'path', + nargs='?', + default='.', + help='Spec root directory to scan (default: current directory)', + ) + parser.add_argument( + '--proper-nouns', + type=Path, + default=None, + metavar='YAML', + help='Path to a YAML file listing additional proper nouns', + ) + parser.add_argument( + '--workers', + type=int, + default=DEFAULT_WORKERS, + metavar='N', + help=f'Parallel Pandoc workers (default: {DEFAULT_WORKERS})', + ) + args = parser.parse_args(argv) + spec_root = Path(args.path).resolve() + violations = check_spec( + spec_root, + workers=args.workers, + proper_nouns_path=args.proper_nouns, + ) + report = format_report(violations, spec_root=spec_root) + if report: + print(report) + sys.exit(1) + else: + print('No heading case violations found.') + + +if __name__ == '__main__': + main() diff --git a/doc_build/iso_heading_proper_nouns.yaml b/doc_build/iso_heading_proper_nouns.yaml new file mode 100644 index 0000000..7b5caae --- /dev/null +++ b/doc_build/iso_heading_proper_nouns.yaml @@ -0,0 +1,50 @@ +# Proper nouns and domain-specific terms that must retain their capitalisation +# in heading sentence-case conversion. +# +# Words listed here will NOT be lowercased when a heading is converted from +# title case to sentence case (ISO/IEC Directives, Part 2, 11.4). +# +# Heuristics already preserve: +# - camelCase / PascalCase identifiers (ObjectPath, UsdGeomMesh) +# - ALL_CAPS acronyms (USD, API, ISO, AOUSD, ...) +# - Words mixing letters and digits (UTF-8, H264) +# +# Add words here that are proper nouns but don't match those patterns. +# Each entry is case-sensitive and must match the heading text exactly. + +proper_nouns: + # USD ecosystem + - OpenUSD + - Pixar + - Hydra + - Presto + - Katana + - Houdini + - Omniverse + - Blender + - Maya + - Nuke + - Solaris + + # Standards bodies and organisations + - Unicode + - IEEE + - Khronos + - Autodesk + - Apple + - Adobe + - Google + - Microsoft + - Nvidia + - Linux + - Windows + - macOS + + # Technical proper nouns + - Pandoc + - LaTeX + - Python + - Tectonic + - GitHub + - Markdown + - CommonMark \ No newline at end of file From 3788ab5b217aae182af74e31d9a765abd25ba208 Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Mon, 8 Jun 2026 14:42:06 +0300 Subject: [PATCH 02/13] Review --- doc_build/filters/filter_heading_case.py | 9 ++----- doc_build/{ => filters}/heading_case.py | 30 +++--------------------- doc_build/iso_heading_case_lint.py | 10 +------- 3 files changed, 6 insertions(+), 43 deletions(-) rename doc_build/{ => filters}/heading_case.py (89%) diff --git a/doc_build/filters/filter_heading_case.py b/doc_build/filters/filter_heading_case.py index 22a6a16..c12fbe6 100644 --- a/doc_build/filters/filter_heading_case.py +++ b/doc_build/filters/filter_heading_case.py @@ -9,13 +9,9 @@ variable. If absent, only heuristic detection is used. """ -import os -import sys +from pathlib import Path -sys.path.insert(0, os.path.dirname(__file__)) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - -from pandocfilters import Header, stringify, toJSONFilter +from pandocfilters import Header, toJSONFilter from heading_case import ( heading_needs_conversion, @@ -36,7 +32,6 @@ def _initialize(self, metadata): return try: yaml_path = get_metadata_str(metadata, 'HEADING_PROPER_NOUNS') - from pathlib import Path self._extra_nouns = load_proper_nouns(Path(yaml_path)) except KeyError: self._extra_nouns = set() diff --git a/doc_build/heading_case.py b/doc_build/filters/heading_case.py similarity index 89% rename from doc_build/heading_case.py rename to doc_build/filters/heading_case.py index 745fecb..1964e89 100644 --- a/doc_build/heading_case.py +++ b/doc_build/filters/heading_case.py @@ -9,18 +9,12 @@ - A function to check whether a heading is already in sentence case. """ -import os +import copy import re -import sys from pathlib import Path from typing import List, Optional, Set -_this_dir = os.path.dirname(os.path.abspath(__file__)) -if _this_dir not in sys.path: - sys.path.insert(0, _this_dir) -_filters_dir = os.path.join(_this_dir, 'filters') -if _filters_dir not in sys.path: - sys.path.insert(0, _filters_dir) +from pandocfilters import stringify try: import yaml @@ -100,29 +94,12 @@ def is_proper_noun(word: str, extra_nouns: Set[str] = frozenset()) -> bool: # Sentence-case conversion for Pandoc inlines # --------------------------------------------------------------------------- -def _lowercase_word(word: str) -> str: - """Lowercase a word, preserving leading/trailing punctuation.""" - prefix = '' - suffix = '' - i = 0 - while i < len(word) and not word[i].isalnum(): - prefix += word[i] - i += 1 - j = len(word) - while j > i and not word[j - 1].isalnum(): - j -= 1 - suffix = word[j] + suffix - core = word[i:j] - return prefix + core.lower() + suffix - - def _sentence_case_track( inlines: list, extra_nouns: Set[str], first_word_seen: bool, ) -> tuple: """Internal: convert inlines to sentence case, returning (new_inlines, first_word_seen).""" - import copy result = copy.deepcopy(inlines) for node in result: @@ -144,7 +121,7 @@ def _sentence_case_track( elif is_proper_noun(word, extra_nouns): new_words.append(word) elif has_alpha: - new_words.append(_lowercase_word(word)) + new_words.append(word.lower()) else: new_words.append(word) node['c'] = ' '.join(new_words) @@ -194,7 +171,6 @@ def sentence_case_inlines( def _extract_words(inlines: list) -> List[str]: """Extract plain-text words from Pandoc inlines, skipping Code/Math.""" - from filters.pandocfilters import stringify text = stringify(inlines) return [w for w in re.split(r'\s+', text) if w] diff --git a/doc_build/iso_heading_case_lint.py b/doc_build/iso_heading_case_lint.py index fb2c7fc..179f179 100644 --- a/doc_build/iso_heading_case_lint.py +++ b/doc_build/iso_heading_case_lint.py @@ -27,16 +27,8 @@ from pathlib import Path from typing import List, Optional, Set -_this_dir = os.path.dirname(os.path.abspath(__file__)) -if _this_dir not in sys.path: - sys.path.insert(0, _this_dir) -_filters_dir = os.path.join(_this_dir, 'filters') -if _filters_dir not in sys.path: - sys.path.insert(0, _filters_dir) - from filters.pandocfilters import stringify - -from heading_case import ( +from filters.heading_case import ( heading_needs_conversion, is_proper_noun, load_proper_nouns, From 3cf377c8f1eb522198fff6359e48849891d363be Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Wed, 10 Jun 2026 14:07:00 +0300 Subject: [PATCH 03/13] Replace heading case build filter with source-level lint and fix Remove filter_heading_case from the build pipeline and add in-place editing (--fix) to iso_heading_case_lint.py, matching the pattern already used by bold_table_lint.py. Add heading_case_fix and bold_table_lint/bold_table_fix DocBuilder subcommands. Co-Authored-By: Claude Opus 4.6 --- doc_build/bold_table_lint.py | 459 +++++++++++++++++++++++++++++ doc_build/doc_builder.py | 120 +++++++- doc_build/iso_heading_case_lint.py | 220 ++++++++++++-- 3 files changed, 767 insertions(+), 32 deletions(-) create mode 100644 doc_build/bold_table_lint.py diff --git a/doc_build/bold_table_lint.py b/doc_build/bold_table_lint.py new file mode 100644 index 0000000..a3c6e58 --- /dev/null +++ b/doc_build/bold_table_lint.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Lint and fix bold table headers in Markdown specification files. + +ISO/IEC Directives, Part 2 requires table column headings to be in bold. +In Markdown pipe tables this means every header cell should be wrapped in +``**…**``. Code spans (``` `` ```) are considered visually distinct and +are left unwrapped. + +Two modes: + +Lint (default) + Parse each ``.md`` file with Pandoc, walk the AST to find Table nodes + whose header cells are not bold, and report violations with file and + line number. + +Fix (``--fix``) + For every file with violations, rewrite the header row in-place so that + non-bold, non-code cells are wrapped in ``**…**``. + +Usage from the command line:: + + python3 -m doc_build.bold_table_lint specification/ + python3 -m doc_build.bold_table_lint --fix specification/ + +Usage as a library:: + + from doc_build.bold_table_lint import check_spec, fix_file + violations = check_spec(Path("specification/")) + for v in violations: + print(v.format()) + fix_file(Path("specification/some_file.md"), violations) +""" + +import json +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional, Set, Tuple + +try: + from doc_build.filters.pandocfilters import stringify +except ImportError: + from filters.pandocfilters import stringify + +DEFAULT_WORKERS = 8 + +# Regex matching a pipe-table separator row: |---|---| or |:---:|---:| +_SEPARATOR_RE = re.compile( + r'^\s*\|' # leading pipe (optional whitespace) + r'[\s:_-]+' # first cell: dashes, colons, spaces + r'(\|[\s:_-]+)*' # subsequent cells + r'\|?\s*$' # trailing pipe (optional) +) + + +# --------------------------------------------------------------------------- +# Data types +# --------------------------------------------------------------------------- + +@dataclass +class Violation: + """A table whose header row has non-bold cells.""" + file: Path + lineno: int # 1-based line number of the header row + header_text: str # raw source text of the header row + non_bold_cells: List[str] # cell texts that are not bold + + def format(self) -> str: + cells_str = ', '.join(f'"{c.strip()}"' for c in self.non_bold_cells) + return ( + f"{self.file}:{self.lineno}: {self.header_text.strip()}\n" + f" non-bold cells: {cells_str}" + ) + + +# --------------------------------------------------------------------------- +# AST-based detection (lint) +# --------------------------------------------------------------------------- + +def _get_sourcepos(attr: list) -> Optional[int]: + """Extract the start line of the full table span from a Pandoc sourcepos Attr. + + Pandoc's data-pos for pipe tables may contain two semicolon-separated + ranges: the first covers the separator row, the second covers the entire + table (header through last body row). We want the earliest start line + across all ranges — that is the header row. + """ + for key, val in attr[2]: + if key == "data-pos": + # Strip optional "filepath@" prefix. + pos = val.split("@")[-1] + # Multiple ranges separated by ";". + min_line = None + for span in pos.split(";"): + start = span.split("-")[0] + line = int(start.split(":")[0]) + if min_line is None or line < min_line: + min_line = line + return min_line + return None + + +def _unwrap_sourcepos_spans(inlines: list) -> list: + """Unwrap Span nodes injected by Pandoc's +sourcepos extension. + + With +sourcepos, every inline is wrapped in a Span carrying a + data-pos attribute. This function peels those wrappers so that the + structural content (Strong, Code, Str, …) is directly visible. + """ + result = [] + for node in inlines: + if (isinstance(node, dict) + and node.get("t") == "Span" + and any(k == "wrapper" for k, _ in node["c"][0][2])): + result.extend(_unwrap_sourcepos_spans(node["c"][1])) + else: + result.append(node) + return result + + +def _cell_needs_bold(blocks: list) -> bool: + """Return True if a header cell's content is not bold. + + A cell is considered bold if its Para/Plain block contains a single + Strong wrapper around all content. A cell containing only a Code + span is considered exempt (visually distinct already). + Empty cells are exempt. + + Handles Pandoc's +sourcepos Span wrappers transparently. + """ + if not blocks: + return False + + for block in blocks: + bt = block.get("t") + if bt not in ("Para", "Plain"): + continue + inlines = _unwrap_sourcepos_spans(block.get("c", [])) + if not inlines: + continue + + # Single Code span — exempt. + if len(inlines) == 1 and inlines[0].get("t") == "Code": + continue + + # Single Strong wrapping everything — already bold. + if len(inlines) == 1 and inlines[0].get("t") == "Strong": + continue + + # Anything else: not bold. + return True + + return False + + +def _cell_text(blocks: list) -> str: + """Extract plain text from a cell's blocks for reporting.""" + return stringify(blocks).strip() + + +def check_file(path: Path) -> List[Violation]: + """Return all bold-table-header violations in a single Markdown file.""" + try: + raw_lines = path.read_text(encoding='utf-8').splitlines() + except OSError: + return [] + + try: + result = subprocess.run( + ["pandoc", "-f", "commonmark_x+sourcepos+pipe_tables", "-t", "json", + str(path)], + capture_output=True, text=True, check=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return [] + + doc = json.loads(result.stdout) + violations: List[Violation] = [] + + for block in doc["blocks"]: + if block["t"] != "Table": + continue + + # Table: [attr, caption, colspecs, head, bodies, foot] + table_attr = block["c"][0] + head = block["c"][3] + table_lineno = _get_sourcepos(table_attr) + + # TableHead: [head_attr, rows] + _, rows = head + if not rows: + continue + + for row in rows: + _, cells = row + non_bold = [] + for cell in cells: + # Cell: [attr, alignment, rowspan, colspan, blocks] + cell_blocks = cell[4] + if _cell_needs_bold(cell_blocks): + non_bold.append(_cell_text(cell_blocks)) + + if non_bold and table_lineno is not None: + # The header row is at the table's start line. + header_line = raw_lines[table_lineno - 1] if table_lineno <= len(raw_lines) else "" + violations.append(Violation( + file=path, + lineno=table_lineno, + header_text=header_line, + non_bold_cells=non_bold, + )) + + return violations + + +def check_spec( + spec_root: Path, + workers: int = DEFAULT_WORKERS, +) -> List[Violation]: + """Walk *spec_root* recursively and return all violations in .md files.""" + md_files: List[Path] = [] + for dirpath, dirnames, filenames in os.walk(spec_root): + dirnames.sort() + for fname in sorted(filenames): + if fname.endswith('.md'): + md_files.append(Path(dirpath) / fname) + + all_violations: List[Violation] = [] + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = {pool.submit(check_file, p): p for p in md_files} + for future in as_completed(futures): + all_violations.extend(future.result()) + + all_violations.sort(key=lambda v: (str(v.file), v.lineno)) + return all_violations + + +# --------------------------------------------------------------------------- +# Source-level fix +# --------------------------------------------------------------------------- + +def _is_separator_line(line: str) -> bool: + """Return True if *line* is a pipe-table separator row.""" + return bool(_SEPARATOR_RE.match(line)) + + +def _bold_header_row(line: str) -> str: + """Wrap non-bold, non-code cells in a pipe-table header row with **…**. + + Splits the line by ``|``, processes each cell, and reassembles. + Cells that are already bold (``**…**``) or are code spans (``` `…` ```) + are left unchanged. Empty/whitespace-only cells are left unchanged. + """ + # Split preserving the pipe delimiters. A typical header row: + # "| Name | Type | Description |" + # splits to: ['', ' Name ', ' Type ', ' Description ', ''] + if '|' not in line: + return line + + parts = line.split('|') + new_parts = [] + + for i, part in enumerate(parts): + stripped = part.strip() + + # First and last parts are outside the table pipes — leave as-is. + if i == 0 or i == len(parts) - 1: + new_parts.append(part) + continue + + # Empty cell. + if not stripped: + new_parts.append(part) + continue + + # Already bold. + if stripped.startswith('**') and stripped.endswith('**'): + new_parts.append(part) + continue + + # Code span — exempt. + if stripped.startswith('`') and stripped.endswith('`'): + new_parts.append(part) + continue + + # Wrap in bold, preserving surrounding whitespace. + leading = part[:len(part) - len(part.lstrip())] + trailing = part[len(part.rstrip()):] + new_parts.append(f"{leading}**{stripped}**{trailing}") + + return '|'.join(new_parts) + + +def fix_file(path: Path, violations: Optional[List[Violation]] = None) -> int: + """Edit *path* in-place, bolding table header rows. + + If *violations* is None, runs check_file() first. + Returns the number of header rows fixed. + """ + if violations is None: + violations = check_file(path) + file_violations = [v for v in violations if v.file == path] + if not file_violations: + return 0 + + lines = path.read_text(encoding='utf-8').splitlines(keepends=True) + violation_lines = {v.lineno for v in file_violations} + fixed = 0 + + for lineno in sorted(violation_lines): + idx = lineno - 1 + if idx < 0 or idx >= len(lines): + continue + original = lines[idx] + newline_suffix = '' + if original.endswith('\n'): + newline_suffix = '\n' + original = original[:-1] + bolded = _bold_header_row(original) + if bolded != original: + lines[idx] = bolded + newline_suffix + fixed += 1 + + if fixed: + path.write_text(''.join(lines), encoding='utf-8') + + return fixed + + +def fix_spec( + spec_root: Path, + workers: int = DEFAULT_WORKERS, +) -> Tuple[int, int]: + """Fix all violations under *spec_root*. Returns (files_fixed, rows_fixed).""" + violations = check_spec(spec_root, workers=workers) + if not violations: + return 0, 0 + + # Group by file. + by_file: dict = {} + for v in violations: + by_file.setdefault(v.file, []).append(v) + + files_fixed = 0 + rows_fixed = 0 + for file_path, file_violations in by_file.items(): + n = fix_file(file_path, file_violations) + if n: + files_fixed += 1 + rows_fixed += n + + return files_fixed, rows_fixed + + +# --------------------------------------------------------------------------- +# Report formatting +# --------------------------------------------------------------------------- + +def format_report( + violations: List[Violation], + spec_root: Optional[Path] = None, +) -> str: + if not violations: + return '' + + by_file: dict = {} + for v in violations: + rel = v.file.relative_to(spec_root) if spec_root else v.file + by_file.setdefault(rel, []).append(v) + + sections: List[str] = [] + total = 0 + for rel_path, file_violations in by_file.items(): + block_lines = [str(rel_path)] + for v in file_violations: + display_v = Violation( + file=rel_path, + lineno=v.lineno, + header_text=v.header_text, + non_bold_cells=v.non_bold_cells, + ) + block_lines.append(display_v.format()) + total += 1 + sections.append('\n'.join(block_lines)) + + file_count = len(by_file) + header = f'{total} bold-table-header violation(s) in {file_count} file(s)\n' + return header + '\n' + '\n\n'.join(sections) + + +# --------------------------------------------------------------------------- +# Command-line entry point +# --------------------------------------------------------------------------- + +def main(argv=None): + import argparse + parser = argparse.ArgumentParser( + description='Check (and optionally fix) Markdown table headers for bold formatting.' + ) + parser.add_argument( + 'path', + nargs='?', + default='.', + help='Spec root directory to scan (default: current directory)', + ) + parser.add_argument( + '--fix', + action='store_true', + help='Edit files in-place to bold non-bold table header cells.', + ) + parser.add_argument( + '--workers', + type=int, + default=DEFAULT_WORKERS, + metavar='N', + help=f'Parallel Pandoc workers (default: {DEFAULT_WORKERS})', + ) + args = parser.parse_args(argv) + spec_root = Path(args.path).resolve() + + if args.fix: + violations = check_spec(spec_root, workers=args.workers) + if not violations: + print('No bold-table-header violations found.') + return + report = format_report(violations, spec_root=spec_root) + print(report) + print() + + # Group and fix. + by_file: dict = {} + for v in violations: + by_file.setdefault(v.file, []).append(v) + + files_fixed = 0 + rows_fixed = 0 + for file_path, file_violations in by_file.items(): + n = fix_file(file_path, file_violations) + if n: + files_fixed += 1 + rows_fixed += n + + print(f'Fixed {rows_fixed} header row(s) in {files_fixed} file(s).') + else: + violations = check_spec(spec_root, workers=args.workers) + report = format_report(violations, spec_root=spec_root) + if report: + print(report) + sys.exit(1) + else: + print('No bold-table-header violations found.') + + +if __name__ == '__main__': + main() diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index f0b59cb..622c9db 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -349,9 +349,6 @@ def _render_combined( if not getattr(args, 'iso_xrefs', False): iso_filter = self.get_filter("iso_xrefs") all_filters = [f for f in all_filters if f != iso_filter] - if not getattr(args, 'heading_case_filter', False): - hc_filter = self.get_filter("heading_case") - all_filters = [f for f in all_filters if f != hc_filter] if not getattr(args, 'bold_table_headers', False): bth_filter = self.get_filter("iso_bold_table_header") all_filters = [f for f in all_filters if f != bth_filter] @@ -422,9 +419,6 @@ def _render_combined( if getattr(args, 'iso_xrefs', False): shared_command.extend(["-M", f"ISO_CLAUSE_MAP={self.get_iso_clause_map()}"]) - if getattr(args, 'heading_case_filter', False): - pn_path = getattr(args, 'heading_proper_nouns', None) or self.get_heading_proper_nouns() - shared_command.extend(["-M", f"HEADING_PROPER_NOUNS={pn_path}"]) if from_pretty is not None and to_pretty is not None: shared_command.extend([ "-M", f"diff-from-pretty={from_pretty}", @@ -577,7 +571,6 @@ def get_doc_build_filters(self): self.get_filter("render_diff"), self.get_filter("convert_mathblocks"), self.get_filter("header6"), - self.get_filter("heading_case"), self.get_filter("iso_bold_table_header"), self.get_filter("iso_xrefs"), self.get_filter("resolve_sections"), @@ -981,6 +974,78 @@ def heading_case_lint(self, args): else: log("No heading case violations found.") + def heading_case_fix(self, args): + from doc_build.iso_heading_case_lint import ( + check_spec, fix_file, format_report, load_proper_nouns, + ) + + spec_root = self.get_specification_root() + proper_nouns = getattr(args, 'proper_nouns', None) + if proper_nouns is None: + proper_nouns = self.get_heading_proper_nouns() + log(f"Fixing heading sentence case in {spec_root} ...") + violations = check_spec(spec_root, proper_nouns_path=proper_nouns) + if not violations: + log("No heading case violations found.") + return + + report = format_report(violations, spec_root=spec_root) + log(report) + + extra_nouns = load_proper_nouns(proper_nouns) + by_file: dict = {} + for v in violations: + by_file.setdefault(v.file, []).append(v) + + files_fixed = 0 + headings_fixed = 0 + for file_path, file_violations in by_file.items(): + n = fix_file(file_path, file_violations, extra_nouns) + if n: + files_fixed += 1 + headings_fixed += n + + log(f"Fixed {headings_fixed} heading(s) in {files_fixed} file(s).") + + def bold_table_lint(self, args): + from doc_build.bold_table_lint import check_spec, format_report + + spec_root = self.get_specification_root() + log(f"Checking bold table headers in {spec_root} ...") + violations = check_spec(spec_root) + report = format_report(violations, spec_root=spec_root) + if report: + log(report) + else: + log("No bold-table-header violations found.") + + def bold_table_fix(self, args): + from doc_build.bold_table_lint import check_spec, fix_file, format_report + + spec_root = self.get_specification_root() + log(f"Fixing bold table headers in {spec_root} ...") + violations = check_spec(spec_root) + if not violations: + log("No bold-table-header violations found.") + return + + report = format_report(violations, spec_root=spec_root) + log(report) + + by_file: dict = {} + for v in violations: + by_file.setdefault(v.file, []).append(v) + + files_fixed = 0 + rows_fixed = 0 + for file_path, file_violations in by_file.items(): + n = fix_file(file_path, file_violations) + if n: + files_fixed += 1 + rows_fixed += n + + log(f"Fixed {rows_fixed} header row(s) in {files_fixed} file(s).") + def export_git_archive(self, args): timestr = time.strftime("%Y%m%d-%H%M%S") filename = f"aousd_core_spec_{args.branch}_{timestr}.zip" @@ -1259,6 +1324,9 @@ def construct_subparsers(self, parser): self.make_style_parser(subparsers) self.make_iso_lint_parser(subparsers) self.make_heading_case_lint_parser(subparsers) + self.make_heading_case_fix_parser(subparsers) + self.make_bold_table_lint_parser(subparsers) + self.make_bold_table_fix_parser(subparsers) return subparsers def make_build_parser(self, subparsers): @@ -1308,12 +1376,6 @@ def make_build_parser(self, subparsers): "Directives, Part 2.", action="store_true", ) - build_parser.add_argument( - "--heading-case-filter", - help="Apply ISO sentence-case conversion to headings at build time " - "(proper nouns are preserved via heuristics and an optional YAML allowlist).", - action="store_true", - ) build_parser.add_argument( "--heading-case-lint", help="Run the ISO heading sentence-case linter before building and " @@ -1417,6 +1479,38 @@ def make_heading_case_lint_parser(self, subparsers): p.set_defaults(func=self.heading_case_lint) return p + def make_heading_case_fix_parser(self, subparsers): + p = subparsers.add_parser( + "heading_case_fix", + help="Edit specification sources in-place to convert headings to sentence case", + ) + p.add_argument( + "--proper-nouns", + type=Path, + default=None, + metavar="YAML", + help="Path to a YAML file listing additional proper nouns " + "(default: iso_heading_proper_nouns.yaml in the builder or spec root)", + ) + p.set_defaults(func=self.heading_case_fix) + return p + + def make_bold_table_lint_parser(self, subparsers): + p = subparsers.add_parser( + "bold_table_lint", + help="Check that table header cells in specification sources are bold", + ) + p.set_defaults(func=self.bold_table_lint) + return p + + def make_bold_table_fix_parser(self, subparsers): + p = subparsers.add_parser( + "bold_table_fix", + help="Edit specification sources in-place to bold table header cells", + ) + p.set_defaults(func=self.bold_table_fix) + return p + def add_publish_copyright(self, combined): intro_copyright = self.get_publish_intro_legalese() outro = self.get_publish_outro_legalese() diff --git a/doc_build/iso_heading_case_lint.py b/doc_build/iso_heading_case_lint.py index 179f179..eec4f01 100644 --- a/doc_build/iso_heading_case_lint.py +++ b/doc_build/iso_heading_case_lint.py @@ -1,31 +1,47 @@ #!/usr/bin/env python3 -"""ISO heading sentence-case linter for Markdown specification files. +"""Lint and fix ISO heading sentence case in Markdown specification files. ISO/IEC Directives, Part 2, 11.4: clause titles shall use sentence case -(only the first word and proper nouns capitalised). This module scans -source Markdown files and reports headings that appear to be in title case. +(only the first word and proper nouns capitalised). + +Two modes: + +Lint (default) + Parse each ``.md`` file with Pandoc, walk the AST to find Header nodes + that appear to be in title case, and report violations with file and + line number. + +Fix (``--fix``) + For every file with violations, rewrite the heading line in-place so + that non-first, non-proper-noun words are lowercased. Proper-noun detection combines heuristics (camelCase, ALL_CAPS, mixed alphanumeric) with an optional YAML allowlist of domain-specific terms. -Usage as a library: - from doc_build.iso_heading_case_lint import check_spec - violations = check_spec(Path("specification/")) - for v in violations: print(v.format()) +Usage from the command line:: -Usage from the command line: python3 -m doc_build.iso_heading_case_lint specification/ + python3 -m doc_build.iso_heading_case_lint --fix specification/ python3 -m doc_build.iso_heading_case_lint --proper-nouns custom.yaml spec/ + +Usage as a library:: + + from doc_build.iso_heading_case_lint import check_spec, fix_file + violations = check_spec(Path("specification/")) + for v in violations: + print(v.format()) + fix_file(Path("specification/some_file.md"), violations, extra_nouns) """ import json import os +import re import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path -from typing import List, Optional, Set +from typing import List, Optional, Set, Tuple from filters.pandocfilters import stringify from filters.heading_case import ( @@ -163,6 +179,138 @@ def check_spec( return all_violations +# --------------------------------------------------------------------------- +# Source-level fix +# --------------------------------------------------------------------------- + +# Regex matching an ATX heading line: "## Some Heading Text" +_ATX_HEADING_RE = re.compile(r'^(#{1,6}\s+)(.*?)(\s*)$') + + +def _sentence_case_text(text: str, extra_nouns: Set[str]) -> str: + """Convert the text portion of a heading to sentence case. + + Preserves inline Markdown formatting (``*``, ``**``, ``_``, ``[``, + etc.) by only modifying alphabetic word sequences. Code spans + (``` `` ```) and link URLs (``](…)``) are left untouched. + """ + # Build a set of character positions that are inside code spans or + # link URLs — these must not be modified. + protected = set() + for m in re.finditer(r'`+.+?`+', text): + protected.update(range(m.start(), m.end())) + for m in re.finditer(r'\]\([^)]*\)', text): + protected.update(range(m.start(), m.end())) + + first_word_seen = False + result = list(text) + + for m in re.finditer(r"[A-Za-z][A-Za-z0-9'_-]*", text): + # Skip words inside protected ranges (code spans, link URLs). + if any(i in protected for i in range(m.start(), m.end())): + first_word_seen = True + continue + + word = m.group() + + # First alphabetic word in the heading is always preserved. + if not first_word_seen: + first_word_seen = True + continue + + if is_proper_noun(word, extra_nouns): + continue + + # Lowercase this word in-place. + lowered = word.lower() + for i, ch in enumerate(lowered): + result[m.start() + i] = ch + + return ''.join(result) + + +def _sentence_case_heading_line(line: str, extra_nouns: Set[str]) -> str: + """Convert an ATX heading source line to sentence case. + + Splits the line into ``# `` prefix, text, and trailing whitespace. + Only the text portion is case-converted; the prefix and trailing + whitespace are preserved verbatim. + """ + m = _ATX_HEADING_RE.match(line) + if not m: + return line + prefix, text, trailing = m.groups() + converted = _sentence_case_text(text, extra_nouns) + return prefix + converted + trailing + + +def fix_file( + path: Path, + violations: Optional[List[Violation]] = None, + extra_nouns: Set[str] = frozenset(), +) -> int: + """Edit *path* in-place, converting heading lines to sentence case. + + If *violations* is None, runs ``check_file()`` first. + Returns the number of headings fixed. + """ + if violations is None: + violations = check_file(path, extra_nouns) + file_violations = [v for v in violations if v.file == path] + if not file_violations: + return 0 + + lines = path.read_text(encoding='utf-8').splitlines(keepends=True) + violation_lines = {v.lineno for v in file_violations} + fixed = 0 + + for lineno in sorted(violation_lines): + idx = lineno - 1 + if idx < 0 or idx >= len(lines): + continue + original = lines[idx] + newline_suffix = '' + if original.endswith('\n'): + newline_suffix = '\n' + original = original[:-1] + converted = _sentence_case_heading_line(original, extra_nouns) + if converted != original: + lines[idx] = converted + newline_suffix + fixed += 1 + + if fixed: + path.write_text(''.join(lines), encoding='utf-8') + + return fixed + + +def fix_spec( + spec_root: Path, + workers: int = DEFAULT_WORKERS, + proper_nouns_path: Optional[Path] = None, +) -> Tuple[int, int]: + """Fix all violations under *spec_root*. Returns (files_fixed, headings_fixed).""" + extra_nouns = load_proper_nouns(proper_nouns_path) + violations = check_spec(spec_root, workers=workers, proper_nouns_path=proper_nouns_path) + if not violations: + return 0, 0 + + # Group by file. + by_file: dict = {} + for v in violations: + by_file.setdefault(v.file, []).append(v) + + files_fixed = 0 + headings_fixed = 0 + for file_path, file_violations in by_file.items(): + n = fix_file(file_path, file_violations, extra_nouns) + if n: + files_fixed += 1 + headings_fixed += n + + return files_fixed, headings_fixed + + # --------------------------------------------------------------------------- # Report formatting # --------------------------------------------------------------------------- @@ -208,7 +356,7 @@ def format_report( def main(argv=None): import argparse parser = argparse.ArgumentParser( - description='Check Markdown headings for ISO sentence-case compliance.' + description='Check (and optionally fix) Markdown headings for ISO sentence-case compliance.' ) parser.add_argument( 'path', @@ -216,6 +364,11 @@ def main(argv=None): default='.', help='Spec root directory to scan (default: current directory)', ) + parser.add_argument( + '--fix', + action='store_true', + help='Edit files in-place to convert title-case headings to sentence case.', + ) parser.add_argument( '--proper-nouns', type=Path, @@ -232,17 +385,46 @@ def main(argv=None): ) args = parser.parse_args(argv) spec_root = Path(args.path).resolve() - violations = check_spec( - spec_root, - workers=args.workers, - proper_nouns_path=args.proper_nouns, - ) - report = format_report(violations, spec_root=spec_root) - if report: + + if args.fix: + violations = check_spec( + spec_root, + workers=args.workers, + proper_nouns_path=args.proper_nouns, + ) + if not violations: + print('No heading case violations found.') + return + report = format_report(violations, spec_root=spec_root) print(report) - sys.exit(1) + print() + + extra_nouns = load_proper_nouns(args.proper_nouns) + by_file: dict = {} + for v in violations: + by_file.setdefault(v.file, []).append(v) + + files_fixed = 0 + headings_fixed = 0 + for file_path, file_violations in by_file.items(): + n = fix_file(file_path, file_violations, extra_nouns) + if n: + files_fixed += 1 + headings_fixed += n + + print(f'Fixed {headings_fixed} heading(s) in {files_fixed} file(s).') else: - print('No heading case violations found.') + violations = check_spec( + spec_root, + workers=args.workers, + proper_nouns_path=args.proper_nouns, + ) + report = format_report(violations, spec_root=spec_root) + if report: + print(report) + sys.exit(1) + else: + print('No heading case violations found.') if __name__ == '__main__': From fc3928fb65f2ef85c73d804276191b641548103f Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Wed, 10 Jun 2026 14:08:28 +0300 Subject: [PATCH 04/13] Remove build-time heading case and bold table header filters These ISO formatting concerns are now handled by source-level lint/fix tools (iso_heading_case_lint.py, bold_table_lint.py) instead of Pandoc AST filters applied during PDF/HTML/DOCX production. Co-Authored-By: Claude Opus 4.6 --- doc_build/doc_builder.py | 10 - doc_build/filters/filter_heading_case.py | 53 ----- .../filters/filter_iso_bold_table_header.py | 200 ------------------ 3 files changed, 263 deletions(-) delete mode 100644 doc_build/filters/filter_heading_case.py delete mode 100644 doc_build/filters/filter_iso_bold_table_header.py diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index 622c9db..8c86929 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -349,9 +349,6 @@ def _render_combined( if not getattr(args, 'iso_xrefs', False): iso_filter = self.get_filter("iso_xrefs") all_filters = [f for f in all_filters if f != iso_filter] - if not getattr(args, 'bold_table_headers', False): - bth_filter = self.get_filter("iso_bold_table_header") - all_filters = [f for f in all_filters if f != bth_filter] doc_build_filters = [] for doc_filter in all_filters: doc_build_filters.extend(["-F", doc_filter]) @@ -571,7 +568,6 @@ def get_doc_build_filters(self): self.get_filter("render_diff"), self.get_filter("convert_mathblocks"), self.get_filter("header6"), - self.get_filter("iso_bold_table_header"), self.get_filter("iso_xrefs"), self.get_filter("resolve_sections"), self.get_filter("sections_new_page"), @@ -1370,12 +1366,6 @@ def make_build_parser(self, subparsers): "script to recreate the PDF from the .tex (implies tectonic wrapper)", action="store_true", ) - build_parser.add_argument( - "--bold-table-headers", - help="Bold all table header cells in the output, per ISO/IEC " - "Directives, Part 2.", - action="store_true", - ) build_parser.add_argument( "--heading-case-lint", help="Run the ISO heading sentence-case linter before building and " diff --git a/doc_build/filters/filter_heading_case.py b/doc_build/filters/filter_heading_case.py deleted file mode 100644 index c12fbe6..0000000 --- a/doc_build/filters/filter_heading_case.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -"""Pandoc filter: convert heading text to ISO sentence case. - -ISO/IEC Directives, Part 2, 11.4: clause titles shall use sentence case. -This filter lowercases non-first words in headings unless they are proper -nouns (detected by heuristic or listed in a YAML allowlist). - -The YAML path is passed via the ``HEADING_PROPER_NOUNS`` pandoc metadata -variable. If absent, only heuristic detection is used. -""" - -from pathlib import Path - -from pandocfilters import Header, toJSONFilter - -from heading_case import ( - heading_needs_conversion, - load_proper_nouns, - sentence_case_inlines, -) -from shared_filter_utils import get_metadata_str - - -class HeadingCaseFilter: - """Stateful filter that converts headings to sentence case.""" - - def __init__(self): - self._extra_nouns = None - - def _initialize(self, metadata): - if self._extra_nouns is not None: - return - try: - yaml_path = get_metadata_str(metadata, 'HEADING_PROPER_NOUNS') - self._extra_nouns = load_proper_nouns(Path(yaml_path)) - except KeyError: - self._extra_nouns = set() - - def __call__(self, key, value, fmt, metadata): - self._initialize(metadata) - if key != 'Header': - return None - - level, attr, inlines = value - if not heading_needs_conversion(inlines, self._extra_nouns): - return None - - new_inlines = sentence_case_inlines(inlines, self._extra_nouns) - return Header(level, attr, new_inlines) - - -if __name__ == '__main__': - toJSONFilter(HeadingCaseFilter()) diff --git a/doc_build/filters/filter_iso_bold_table_header.py b/doc_build/filters/filter_iso_bold_table_header.py deleted file mode 100644 index ec03709..0000000 --- a/doc_build/filters/filter_iso_bold_table_header.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -"""Pandoc filter: bold table header cells per ISO/IEC Directives, Part 2. - -ISO/IEC Directives, Part 2 requires that table column headings shall be -in bold type. This filter walks every Table node in the Pandoc AST and -wraps the inline content of each header cell in Strong (bold). - -Cells that are already bold are left visually unchanged (the outer Strong -is still added, producing harmless nested ``…`` in HTML -or ``\\textbf{\\textbf{…}}`` in LaTeX — both render identically). - -Only Para and Plain blocks inside header cells are wrapped; other block -types (CodeBlock, BulletList, …) are passed through untouched since they -are uncommon in header cells and would not benefit from a Strong wrapper. -""" - -from copy import deepcopy -from pandocfilters import toJSONFilter - - -def boldify_inline(inline, inside_strong=False): - """Recursively process a single inline element for bolding. - - If the node is already Strong, recurse into its children (with the - *inside_strong* flag set) but do not add another Strong wrapper. - For every other node type, deepcopy it and recurse into its ``c`` - value so that nested inlines (Emph inside a Link, etc.) are handled. - - Returns a new (possibly transformed) inline node. - """ - if not isinstance(inline, dict): - return inline - - t = inline["t"] - - # Already bold — recurse into children but skip re-wrapping. - if t == "Strong": - return { - "t": "Strong", - "c": [ - boldify_inline(x, inside_strong=True) - for x in inline["c"] - ] - } - - # Any other inline: deepcopy to avoid mutating the original AST, - # then recurse into the node's content. - result = deepcopy(inline) - - if "c" in result: - result["c"] = boldify_value( - result["c"], - inside_strong=inside_strong - ) - - return result - - -def boldify_value(value, inside_strong=False): - """Recursively walk an arbitrary Pandoc AST value (list, dict, or scalar). - - Dispatches typed dict nodes (those with a ``t`` key) to - ``boldify_inline``; recurses into plain dicts and lists; returns - scalars unchanged. - """ - if isinstance(value, list): - return [ - boldify_value(x, inside_strong) - for x in value - ] - - if isinstance(value, dict): - # Typed AST node — delegate to inline handler. - if "t" in value: - return boldify_inline( - value, - inside_strong=inside_strong - ) - - # Untyped dict (e.g. metadata maps) — recurse into values. - return { - k: boldify_value(v, inside_strong) - for k, v in value.items() - } - - return value - - -def wrap_block_in_strong(block): - """Wrap a Para or Plain block's inlines in a single Strong node. - - Transforms:: - - Para [inline, inline, …] - Plain [inline, inline, …] - - into:: - - Para [Strong [inline, inline, …]] - Plain [Strong [inline, inline, …]] - - Non-Para/Plain blocks (CodeBlock, BulletList, etc.) are returned - unchanged — they are uncommon in table headers and would not - benefit from bold wrapping. - """ - if not isinstance(block, dict): - return block - - t = block.get("t") - - if t not in ("Para", "Plain"): - return block - - # Process children first so any pre-existing Strong nodes are - # handled by boldify_inline before the outer wrap. - inlines = [ - boldify_inline(x) - for x in block["c"] - ] - - return { - "t": t, - "c": [ - { - "t": "Strong", - "c": inlines - } - ] - } - - -def process_cell_blocks(blocks): - """Apply bold wrapping to every block inside a single header cell.""" - return [ - wrap_block_in_strong(block) - for block in blocks - ] - - -def process_table_head(head): - """Walk the TableHead structure and bold every header cell. - - Pandoc 3.x TableHead layout:: - - TableHead = [ head_attr, rows ] - Row = [ row_attr, cells ] - Cell = [ attr, alignment, rowspan, colspan, blocks ] - """ - head_attr, rows = head - - new_rows = [] - - for row in rows: - row_attr, cells = row - - new_cells = [] - - for cell in cells: - attr, alignment, rowspan, colspan, blocks = cell - - new_cells.append([ - attr, - alignment, - rowspan, - colspan, - process_cell_blocks(blocks) - ]) - - new_rows.append([ - row_attr, - new_cells - ]) - - return [head_attr, new_rows] - - -def bold_table_headers(key, value, fmt, meta): - """Top-level filter action: intercept Table nodes and bold their heads.""" - if key != "Table": - return None - - # Pandoc 3.x Table layout: - # Table [ attr, caption, colspecs, head, bodies, foot ] - attr, caption, colspecs, head, bodies, foot = value - - return { - "t": "Table", - "c": [ - attr, - caption, - colspecs, - process_table_head(head), - bodies, - foot - ] - } - - -if __name__ == "__main__": - toJSONFilter(bold_table_headers) From 37170009323bbc4cd0d7d5fa0065d527eb2b7617 Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Wed, 10 Jun 2026 14:09:00 +0300 Subject: [PATCH 05/13] Rename bold_table_lint.py to iso_bold_table_lint.py Consistent naming with iso_heading_case_lint.py and iso_clause_lint.py. Co-Authored-By: Claude Opus 4.6 --- doc_build/doc_builder.py | 4 ++-- doc_build/{bold_table_lint.py => iso_bold_table_lint.py} | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename doc_build/{bold_table_lint.py => iso_bold_table_lint.py} (98%) diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index 8c86929..f61b223 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -1004,7 +1004,7 @@ def heading_case_fix(self, args): log(f"Fixed {headings_fixed} heading(s) in {files_fixed} file(s).") def bold_table_lint(self, args): - from doc_build.bold_table_lint import check_spec, format_report + from doc_build.iso_bold_table_lint import check_spec, format_report spec_root = self.get_specification_root() log(f"Checking bold table headers in {spec_root} ...") @@ -1016,7 +1016,7 @@ def bold_table_lint(self, args): log("No bold-table-header violations found.") def bold_table_fix(self, args): - from doc_build.bold_table_lint import check_spec, fix_file, format_report + from doc_build.iso_bold_table_lint import check_spec, fix_file, format_report spec_root = self.get_specification_root() log(f"Fixing bold table headers in {spec_root} ...") diff --git a/doc_build/bold_table_lint.py b/doc_build/iso_bold_table_lint.py similarity index 98% rename from doc_build/bold_table_lint.py rename to doc_build/iso_bold_table_lint.py index a3c6e58..374bfa2 100644 --- a/doc_build/bold_table_lint.py +++ b/doc_build/iso_bold_table_lint.py @@ -19,12 +19,12 @@ Usage from the command line:: - python3 -m doc_build.bold_table_lint specification/ - python3 -m doc_build.bold_table_lint --fix specification/ + python3 -m doc_build.iso_bold_table_lint specification/ + python3 -m doc_build.iso_bold_table_lint --fix specification/ Usage as a library:: - from doc_build.bold_table_lint import check_spec, fix_file + from doc_build.iso_bold_table_lint import check_spec, fix_file violations = check_spec(Path("specification/")) for v in violations: print(v.format()) From fec58b8ea4ddb653196f1080b89d68631f92f5ad Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Thu, 11 Jun 2026 09:58:57 +0300 Subject: [PATCH 06/13] Extract shared ISO lint utilities and add umbrella subcommands - Extract _collect_md_files, _get_sourcepos, _unwrap_sourcepos_spans, and DEFAULT_WORKERS into iso_lint_utils.py (was duplicated across all three linters). - All linters now accept a single .md file path in addition to directories. - Add iso_lint_all subcommand (runs all three linters). - Add iso_fix_all subcommand (fixes heading case and bold tables, then lints clause structure). - Rename iso_lint subcommand to iso_clause_lint for consistency with the module name and sibling subcommands. Co-Authored-By: Claude Opus 4.6 --- doc_build/ISO_LINTERS.md | 121 +++++++++++++++++++++ doc_build/doc_builder.py | 163 ++++++++++++++++++++++++++++- doc_build/iso_bold_table_lint.py | 60 ++--------- doc_build/iso_clause_lint.py | 46 ++------ doc_build/iso_heading_case_lint.py | 24 ++--- doc_build/iso_lint_utils.py | 79 ++++++++++++++ 6 files changed, 384 insertions(+), 109 deletions(-) create mode 100644 doc_build/ISO_LINTERS.md create mode 100644 doc_build/iso_lint_utils.py diff --git a/doc_build/ISO_LINTERS.md b/doc_build/ISO_LINTERS.md new file mode 100644 index 0000000..f060092 --- /dev/null +++ b/doc_build/ISO_LINTERS.md @@ -0,0 +1,121 @@ +# ISO linters + +Three linters check Markdown specification sources for compliance with +ISO/IEC Directives, Part 2. Each can run standalone or as a DocBuilder +subcommand. All accept a directory (scanned recursively) or a single +`.md` file. + +## Run all linters at once + +```sh +# Lint only (exits non-zero on any violation) +pixi run python -m doc_build.doc_builder iso_clause_lint_all + +# Fix what can be auto-fixed, then lint the rest +pixi run python -m doc_build.doc_builder iso_fix_all +``` + +`iso_fix_all` auto-fixes heading case and bold table headers, then runs +the clause structure linter (which requires manual editing). + +## Heading sentence case + +ISO 11.4 requires clause titles in sentence case. + +```sh +# Check (exits non-zero on violations) +pixi run python -m doc_build.iso_heading_case_lint specification/ + +# Check a single file +pixi run python -m doc_build.iso_heading_case_lint specification/color/README.md + +# Fix in-place +pixi run python -m doc_build.iso_heading_case_lint --fix specification/ + +# With a custom proper-nouns allowlist +pixi run python -m doc_build.iso_heading_case_lint --fix \ + --proper-nouns iso_heading_proper_nouns.yaml specification/ +``` + +Proper nouns (OpenUSD, API, camelCase identifiers, etc.) are preserved +automatically. Add domain terms to `iso_heading_proper_nouns.yaml` if +they are lowercased incorrectly. + +## Bold table headers + +ISO requires table column headings to be bold. + +```sh +# Check +pixi run python -m doc_build.iso_bold_table_lint specification/ + +# Check a single file +pixi run python -m doc_build.iso_bold_table_lint specification/color/README.md + +# Fix in-place +pixi run python -m doc_build.iso_bold_table_lint --fix specification/ +``` + +Code spans in header cells (`` `value` ``) are left unwrapped. + +## Clause structure + +Checks that clauses with subclauses have no body text between the +heading and the first subclause. + +```sh +pixi run python -m doc_build.iso_clause_lint specification/ + +# Single file +pixi run python -m doc_build.iso_clause_lint specification/composition/README.md +``` + +This linter has no auto-fix; violations require manual editing. + +## DocBuilder subcommands + +All linters are also available as subcommands of the doc builder: + +```sh +pixi run python -m doc_build.doc_builder iso_clause_lint_all +pixi run python -m doc_build.doc_builder iso_fix_all + +pixi run python -m doc_build.doc_builder heading_case_lint +pixi run python -m doc_build.doc_builder heading_case_fix +pixi run python -m doc_build.doc_builder bold_table_lint +pixi run python -m doc_build.doc_builder bold_table_fix +pixi run python -m doc_build.doc_builder iso_clause_lint +``` + +## GitHub Actions + +Add a lint job to your workflow file (`.github/workflows/*.yml`): + +```yaml + iso-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: prefix-dev/setup-pixi@v0.8.9 + with: + pixi-version: v0.59.0 + + - name: ISO compliance checks + run: pixi run python -m doc_build.doc_builder iso_clause_lint_all +``` + +Or run the linters individually: + +```yaml + - name: ISO heading sentence case + run: pixi run python -m doc_build.iso_heading_case_lint specification/ + + - name: ISO bold table headers + run: pixi run python -m doc_build.iso_bold_table_lint specification/ + + - name: ISO clause structure + run: pixi run python -m doc_build.iso_clause_lint specification/ +``` + +Each linter exits with code 1 when violations are found, which fails the +CI step. diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index f61b223..4434dee 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -936,7 +936,7 @@ def run_linter(self, args): log(f"\tLint output: {linted}") - def iso_lint(self, args): + def iso_clause_lint(self, args): from doc_build.iso_clause_lint import check_spec, format_report spec_root = self.get_specification_root() @@ -1042,6 +1042,125 @@ def bold_table_fix(self, args): log(f"Fixed {rows_fixed} header row(s) in {files_fixed} file(s).") + def iso_lint_all(self, args): + """Run all three ISO linters: clause structure, heading case, bold table headers.""" + failed = False + + # 1. Clause structure + from doc_build.iso_clause_lint import ( + check_spec as clause_check, format_report as clause_report, + ) + spec_root = self.get_specification_root() + log(f"Checking ISO clause structure in {spec_root} ...") + clause_violations = clause_check(spec_root) + report = clause_report(clause_violations, context=getattr(args, 'context', 5), spec_root=spec_root) + if report: + log(report) + failed = True + else: + log("No ISO clause structure violations found.") + + # 2. Heading sentence case + from doc_build.iso_heading_case_lint import ( + check_spec as heading_check, format_report as heading_report, + ) + proper_nouns = getattr(args, 'proper_nouns', None) + if proper_nouns is None: + proper_nouns = self.get_heading_proper_nouns() + log(f"\nChecking heading sentence case in {spec_root} ...") + heading_violations = heading_check(spec_root, proper_nouns_path=proper_nouns) + report = heading_report(heading_violations, spec_root=spec_root) + if report: + log(report) + failed = True + else: + log("No heading case violations found.") + + # 3. Bold table headers + from doc_build.iso_bold_table_lint import ( + check_spec as bold_check, format_report as bold_report, + ) + log(f"\nChecking bold table headers in {spec_root} ...") + bold_violations = bold_check(spec_root) + report = bold_report(bold_violations, spec_root=spec_root) + if report: + log(report) + failed = True + else: + log("No bold-table-header violations found.") + + if failed: + sys.exit(1) + + def iso_fix_all(self, args): + """Run all three ISO fixers: heading case and bold table headers (clause structure is lint-only).""" + spec_root = self.get_specification_root() + + # 1. Heading sentence case + from doc_build.iso_heading_case_lint import ( + check_spec as heading_check, fix_file as heading_fix, + format_report as heading_report, load_proper_nouns, + ) + proper_nouns = getattr(args, 'proper_nouns', None) + if proper_nouns is None: + proper_nouns = self.get_heading_proper_nouns() + log(f"Fixing heading sentence case in {spec_root} ...") + heading_violations = heading_check(spec_root, proper_nouns_path=proper_nouns) + if heading_violations: + report = heading_report(heading_violations, spec_root=spec_root) + log(report) + extra_nouns = load_proper_nouns(proper_nouns) + by_file: dict = {} + for v in heading_violations: + by_file.setdefault(v.file, []).append(v) + files_fixed = 0 + headings_fixed = 0 + for file_path, file_violations in by_file.items(): + n = heading_fix(file_path, file_violations, extra_nouns) + if n: + files_fixed += 1 + headings_fixed += n + log(f"Fixed {headings_fixed} heading(s) in {files_fixed} file(s).") + else: + log("No heading case violations found.") + + # 2. Bold table headers + from doc_build.iso_bold_table_lint import ( + check_spec as bold_check, fix_file as bold_fix, + format_report as bold_report, + ) + log(f"\nFixing bold table headers in {spec_root} ...") + bold_violations = bold_check(spec_root) + if bold_violations: + report = bold_report(bold_violations, spec_root=spec_root) + log(report) + by_file = {} + for v in bold_violations: + by_file.setdefault(v.file, []).append(v) + files_fixed = 0 + rows_fixed = 0 + for file_path, file_violations in by_file.items(): + n = bold_fix(file_path, file_violations) + if n: + files_fixed += 1 + rows_fixed += n + log(f"Fixed {rows_fixed} header row(s) in {files_fixed} file(s).") + else: + log("No bold-table-header violations found.") + + # 3. Clause structure (lint-only, no auto-fix) + from doc_build.iso_clause_lint import ( + check_spec as clause_check, format_report as clause_report, + ) + log(f"\nChecking ISO clause structure in {spec_root} ...") + clause_violations = clause_check(spec_root) + report = clause_report(clause_violations, context=getattr(args, 'context', 5), spec_root=spec_root) + if report: + log(report) + log("\nNote: clause structure violations require manual editing.") + else: + log("No ISO clause structure violations found.") + def export_git_archive(self, args): timestr = time.strftime("%Y%m%d-%H%M%S") filename = f"aousd_core_spec_{args.branch}_{timestr}.zip" @@ -1318,11 +1437,13 @@ def construct_subparsers(self, parser): self.make_index_parser(subparsers) self.make_spellcheck_parser(subparsers) self.make_style_parser(subparsers) - self.make_iso_lint_parser(subparsers) + self.make_iso_clause_lint_parser(subparsers) self.make_heading_case_lint_parser(subparsers) self.make_heading_case_fix_parser(subparsers) self.make_bold_table_lint_parser(subparsers) self.make_bold_table_fix_parser(subparsers) + self.make_iso_lint_all_parser(subparsers) + self.make_iso_fix_all_parser(subparsers) return subparsers def make_build_parser(self, subparsers): @@ -1438,9 +1559,9 @@ def make_style_parser(self, subparsers): style_parser.set_defaults(func=self.display_style_issues) return style_parser - def make_iso_lint_parser(self, subparsers): + def make_iso_clause_lint_parser(self, subparsers): p = subparsers.add_parser( - "iso_lint", + "iso_clause_lint", help="Check specification source files for ISO clause structure violations", ) p.add_argument( @@ -1450,7 +1571,7 @@ def make_iso_lint_parser(self, subparsers): metavar="N", help="Number of body lines to show per violation (default: 5)", ) - p.set_defaults(func=self.iso_lint) + p.set_defaults(func=self.iso_clause_lint) return p def make_heading_case_lint_parser(self, subparsers): @@ -1501,6 +1622,38 @@ def make_bold_table_fix_parser(self, subparsers): p.set_defaults(func=self.bold_table_fix) return p + def make_iso_lint_all_parser(self, subparsers): + p = subparsers.add_parser( + "iso_lint_all", + help="Run all ISO linters (clause structure, heading case, bold table headers)", + ) + p.add_argument( + "--proper-nouns", + type=Path, + default=None, + metavar="YAML", + help="Path to a YAML file listing additional proper nouns " + "(default: iso_heading_proper_nouns.yaml in the builder or spec root)", + ) + p.set_defaults(func=self.iso_lint_all) + return p + + def make_iso_fix_all_parser(self, subparsers): + p = subparsers.add_parser( + "iso_fix_all", + help="Run all ISO fixers (heading case, bold table headers) and lint clause structure", + ) + p.add_argument( + "--proper-nouns", + type=Path, + default=None, + metavar="YAML", + help="Path to a YAML file listing additional proper nouns " + "(default: iso_heading_proper_nouns.yaml in the builder or spec root)", + ) + p.set_defaults(func=self.iso_fix_all) + return p + def add_publish_copyright(self, combined): intro_copyright = self.get_publish_intro_legalese() outro = self.get_publish_outro_legalese() diff --git a/doc_build/iso_bold_table_lint.py b/doc_build/iso_bold_table_lint.py index 374bfa2..8c9afe5 100644 --- a/doc_build/iso_bold_table_lint.py +++ b/doc_build/iso_bold_table_lint.py @@ -32,7 +32,6 @@ """ import json -import os import re import subprocess import sys @@ -46,7 +45,12 @@ except ImportError: from filters.pandocfilters import stringify -DEFAULT_WORKERS = 8 +from doc_build.iso_lint_utils import ( + DEFAULT_WORKERS, + collect_md_files, + get_sourcepos, + unwrap_sourcepos_spans, +) # Regex matching a pipe-table separator row: |---|---| or |:---:|---:| _SEPARATOR_RE = re.compile( @@ -81,47 +85,6 @@ def format(self) -> str: # AST-based detection (lint) # --------------------------------------------------------------------------- -def _get_sourcepos(attr: list) -> Optional[int]: - """Extract the start line of the full table span from a Pandoc sourcepos Attr. - - Pandoc's data-pos for pipe tables may contain two semicolon-separated - ranges: the first covers the separator row, the second covers the entire - table (header through last body row). We want the earliest start line - across all ranges — that is the header row. - """ - for key, val in attr[2]: - if key == "data-pos": - # Strip optional "filepath@" prefix. - pos = val.split("@")[-1] - # Multiple ranges separated by ";". - min_line = None - for span in pos.split(";"): - start = span.split("-")[0] - line = int(start.split(":")[0]) - if min_line is None or line < min_line: - min_line = line - return min_line - return None - - -def _unwrap_sourcepos_spans(inlines: list) -> list: - """Unwrap Span nodes injected by Pandoc's +sourcepos extension. - - With +sourcepos, every inline is wrapped in a Span carrying a - data-pos attribute. This function peels those wrappers so that the - structural content (Strong, Code, Str, …) is directly visible. - """ - result = [] - for node in inlines: - if (isinstance(node, dict) - and node.get("t") == "Span" - and any(k == "wrapper" for k, _ in node["c"][0][2])): - result.extend(_unwrap_sourcepos_spans(node["c"][1])) - else: - result.append(node) - return result - - def _cell_needs_bold(blocks: list) -> bool: """Return True if a header cell's content is not bold. @@ -139,7 +102,7 @@ def _cell_needs_bold(blocks: list) -> bool: bt = block.get("t") if bt not in ("Para", "Plain"): continue - inlines = _unwrap_sourcepos_spans(block.get("c", [])) + inlines = unwrap_sourcepos_spans(block.get("c", [])) if not inlines: continue @@ -188,7 +151,7 @@ def check_file(path: Path) -> List[Violation]: # Table: [attr, caption, colspecs, head, bodies, foot] table_attr = block["c"][0] head = block["c"][3] - table_lineno = _get_sourcepos(table_attr) + table_lineno = get_sourcepos(table_attr) # TableHead: [head_attr, rows] _, rows = head @@ -222,12 +185,7 @@ def check_spec( workers: int = DEFAULT_WORKERS, ) -> List[Violation]: """Walk *spec_root* recursively and return all violations in .md files.""" - md_files: List[Path] = [] - for dirpath, dirnames, filenames in os.walk(spec_root): - dirnames.sort() - for fname in sorted(filenames): - if fname.endswith('.md'): - md_files.append(Path(dirpath) / fname) + md_files = collect_md_files(spec_root) all_violations: List[Violation] = [] with ThreadPoolExecutor(max_workers=workers) as pool: diff --git a/doc_build/iso_clause_lint.py b/doc_build/iso_clause_lint.py index 195a979..a5accdf 100644 --- a/doc_build/iso_clause_lint.py +++ b/doc_build/iso_clause_lint.py @@ -21,7 +21,6 @@ """ import json -import os import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed @@ -31,12 +30,15 @@ from filters.pandocfilters import stringify +from doc_build.iso_lint_utils import ( + DEFAULT_WORKERS, + collect_md_files, + get_sourcepos, +) + # How many non-blank body lines to show as context in a report. DEFAULT_CONTEXT_LINES = 5 -# Maximum number of parallel Pandoc subprocesses used by check_spec(). -DEFAULT_WORKERS = 8 - # --------------------------------------------------------------------------- # Data types @@ -79,30 +81,6 @@ def format(self, context: int = DEFAULT_CONTEXT_LINES) -> str: return '\n'.join(lines) -# --------------------------------------------------------------------------- -# Pandoc AST helpers -# --------------------------------------------------------------------------- - -def _get_sourcepos(attr: list) -> Optional[int]: - """Extract the start line number from a Pandoc sourcepos Attr. - - Attr layout: [id, [classes], [[key, value], ...]] - - When Pandoc reads from a file the data-pos value has the form - "filepath@startrow:startcol-endrow:endcol"; when reading from stdin it - omits the "filepath@" prefix. Both forms are handled here. - - Returns the start row as a 1-based integer, or None if the attribute is - absent. - """ - for key, val in attr[2]: - if key == "data-pos": - # Strip optional "filepath@" prefix before the row:col range. - pos = val.split("@")[-1] - return int(pos.split(":")[0]) - return None - - # --------------------------------------------------------------------------- # Core checker # --------------------------------------------------------------------------- @@ -152,7 +130,7 @@ def check_file(path: Path) -> List[Violation]: level: int = block["c"][0] attr: list = block["c"][1] inlines: list = block["c"][2] - lineno: Optional[int] = _get_sourcepos(attr) + lineno: Optional[int] = get_sourcepos(attr) text: str = stringify(inlines).strip() # Check for a violation: the previous heading has body content @@ -205,16 +183,12 @@ def check_spec( ) -> List[Violation]: """Walk *spec_root* recursively and return all violations in .md files. - Files are processed in parallel (up to *workers* simultaneous Pandoc + *spec_root* may be a single ``.md`` file or a directory. Files are + processed in parallel (up to *workers* simultaneous Pandoc subprocesses) for speed, then results are sorted by (file path, line number) so that output is stable across runs. """ - md_files: List[Path] = [] - for dirpath, dirnames, filenames in os.walk(spec_root): - dirnames.sort() - for fname in sorted(filenames): - if fname.endswith('.md'): - md_files.append(Path(dirpath) / fname) + md_files = collect_md_files(spec_root) all_violations: List[Violation] = [] with ThreadPoolExecutor(max_workers=workers) as pool: diff --git a/doc_build/iso_heading_case_lint.py b/doc_build/iso_heading_case_lint.py index eec4f01..77c9ffa 100644 --- a/doc_build/iso_heading_case_lint.py +++ b/doc_build/iso_heading_case_lint.py @@ -34,7 +34,6 @@ """ import json -import os import re import subprocess import sys @@ -51,7 +50,11 @@ sentence_case_inlines, ) -DEFAULT_WORKERS = 8 +from doc_build.iso_lint_utils import ( + DEFAULT_WORKERS, + collect_md_files, + get_sourcepos, +) # --------------------------------------------------------------------------- @@ -85,14 +88,6 @@ def format(self) -> str: # Pandoc AST helpers # --------------------------------------------------------------------------- -def _get_sourcepos(attr: list) -> Optional[int]: - for key, val in attr[2]: - if key == "data-pos": - pos = val.split("@")[-1] - return int(pos.split(":")[0]) - return None - - def _find_non_sentence_words(inlines: list, extra_nouns: Set[str]) -> List[str]: """Return words that are capitalised but not proper nouns (skipping the first word).""" import re @@ -132,7 +127,7 @@ def check_file(path: Path, extra_nouns: Set[str] = frozenset()) -> List[Violatio level = block["c"][0] attr = block["c"][1] inlines = block["c"][2] - lineno = _get_sourcepos(attr) + lineno = get_sourcepos(attr) if not heading_needs_conversion(inlines, extra_nouns): continue @@ -162,12 +157,7 @@ def check_spec( """Walk *spec_root* recursively and return all violations in .md files.""" extra_nouns = load_proper_nouns(proper_nouns_path) - md_files: List[Path] = [] - for dirpath, dirnames, filenames in os.walk(spec_root): - dirnames.sort() - for fname in sorted(filenames): - if fname.endswith('.md'): - md_files.append(Path(dirpath) / fname) + md_files = collect_md_files(spec_root) all_violations: List[Violation] = [] with ThreadPoolExecutor(max_workers=workers) as pool: diff --git a/doc_build/iso_lint_utils.py b/doc_build/iso_lint_utils.py new file mode 100644 index 0000000..39d6cc8 --- /dev/null +++ b/doc_build/iso_lint_utils.py @@ -0,0 +1,79 @@ +"""Shared utilities for ISO linters. + +Common helpers used by ``iso_heading_case_lint``, ``iso_bold_table_lint``, +and ``iso_clause_lint``. +""" + +import os +from pathlib import Path +from typing import List, Optional + +# Maximum number of parallel Pandoc subprocesses used by check_spec(). +DEFAULT_WORKERS = 8 + + +def collect_md_files(path: Path) -> List[Path]: + """Return a list of ``.md`` files under *path*. + + *path* may be a single file or a directory (walked recursively). + """ + path = Path(path) + if path.is_file(): + return [path] if path.suffix == '.md' else [] + md_files: List[Path] = [] + for dirpath, dirnames, filenames in os.walk(path): + dirnames.sort() + for fname in sorted(filenames): + if fname.endswith('.md'): + md_files.append(Path(dirpath) / fname) + return md_files + + +def get_sourcepos(attr: list) -> Optional[int]: + """Extract the start line number from a Pandoc sourcepos Attr. + + Attr layout: ``[id, [classes], [[key, value], ...]]`` + + When Pandoc reads from a file the ``data-pos`` value has the form + ``filepath@startrow:startcol-endrow:endcol``; when reading from stdin + it omits the ``filepath@`` prefix. Both forms are handled here. + + Pandoc's data-pos for pipe tables may contain multiple + semicolon-separated ranges. This function returns the minimum start + line across all ranges, which is the header row for tables and the + only range for headings and other blocks. + + Returns the start row as a 1-based integer, or ``None`` if the + attribute is absent. + """ + for key, val in attr[2]: + if key == "data-pos": + # Strip optional "filepath@" prefix. + pos = val.split("@")[-1] + # Multiple ranges separated by ";". + min_line = None + for span in pos.split(";"): + start = span.split("-")[0] + line = int(start.split(":")[0]) + if min_line is None or line < min_line: + min_line = line + return min_line + return None + + +def unwrap_sourcepos_spans(inlines: list) -> list: + """Unwrap Span nodes injected by Pandoc's +sourcepos extension. + + With +sourcepos, every inline is wrapped in a Span carrying a + ``data-pos`` attribute. This function peels those wrappers so that + the structural content (Strong, Code, Str, ...) is directly visible. + """ + result = [] + for node in inlines: + if (isinstance(node, dict) + and node.get("t") == "Span" + and any(k == "wrapper" for k, _ in node["c"][0][2])): + result.extend(unwrap_sourcepos_spans(node["c"][1])) + else: + result.append(node) + return result From 3ce36599bd6d0996f990664d2e17c91545cdf0ea Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Thu, 11 Jun 2026 10:47:14 +0300 Subject: [PATCH 07/13] Refactor ISO linters: deduplicate, clean up, fix stale docs - Extract stringify import and run_parallel_check() into iso_lint_utils - Add display_path param to Violation.format() instead of cloning objects - Remove dead _SEPARATOR_RE/_is_separator_line from bold table linter - Remove duplicate `import re` from heading case linter - Use set.isdisjoint() for O(1) protected-range checks in heading fixer - Simplify fix_file() newline handling with rstrip('\n') - Have main() --fix call fix_spec() instead of reimplementing it - Refactor iso_lint_all/iso_fix_all into data-driven loops - Remove clause lint from iso_fix_all (belongs in iso_lint_all only) - Fix stale iso_clause_lint_all references in docs (actual name: iso_lint_all) - Add partial-bold edge case comment to _bold_header_row Co-Authored-By: Claude Opus 4.6 --- doc_build/ISO_LINTERS.md | 13 +- doc_build/doc_builder.py | 183 ++++++++++------------------- doc_build/iso_bold_table_lint.py | 76 ++++-------- doc_build/iso_clause_lint.py | 36 ++---- doc_build/iso_heading_case_lint.py | 67 ++++------- doc_build/iso_lint_utils.py | 41 ++++++- 6 files changed, 163 insertions(+), 253 deletions(-) diff --git a/doc_build/ISO_LINTERS.md b/doc_build/ISO_LINTERS.md index f060092..924ca6c 100644 --- a/doc_build/ISO_LINTERS.md +++ b/doc_build/ISO_LINTERS.md @@ -9,14 +9,15 @@ subcommand. All accept a directory (scanned recursively) or a single ```sh # Lint only (exits non-zero on any violation) -pixi run python -m doc_build.doc_builder iso_clause_lint_all +pixi run python -m doc_build.doc_builder iso_lint_all -# Fix what can be auto-fixed, then lint the rest +# Fix what can be auto-fixed pixi run python -m doc_build.doc_builder iso_fix_all ``` -`iso_fix_all` auto-fixes heading case and bold table headers, then runs -the clause structure linter (which requires manual editing). +`iso_fix_all` auto-fixes heading case and bold table headers. Run +`iso_lint_all` afterwards to check for clause structure issues +(which require manual editing). ## Heading sentence case @@ -77,7 +78,7 @@ This linter has no auto-fix; violations require manual editing. All linters are also available as subcommands of the doc builder: ```sh -pixi run python -m doc_build.doc_builder iso_clause_lint_all +pixi run python -m doc_build.doc_builder iso_lint_all pixi run python -m doc_build.doc_builder iso_fix_all pixi run python -m doc_build.doc_builder heading_case_lint @@ -101,7 +102,7 @@ Add a lint job to your workflow file (`.github/workflows/*.yml`): pixi-version: v0.59.0 - name: ISO compliance checks - run: pixi run python -m doc_build.doc_builder iso_clause_lint_all + run: pixi run python -m doc_build.doc_builder iso_lint_all ``` Or run the linters individually: diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index 4434dee..9724c7b 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -972,7 +972,7 @@ def heading_case_lint(self, args): def heading_case_fix(self, args): from doc_build.iso_heading_case_lint import ( - check_spec, fix_file, format_report, load_proper_nouns, + check_spec, fix_spec, format_report, ) spec_root = self.get_specification_root() @@ -988,19 +988,9 @@ def heading_case_fix(self, args): report = format_report(violations, spec_root=spec_root) log(report) - extra_nouns = load_proper_nouns(proper_nouns) - by_file: dict = {} - for v in violations: - by_file.setdefault(v.file, []).append(v) - - files_fixed = 0 - headings_fixed = 0 - for file_path, file_violations in by_file.items(): - n = fix_file(file_path, file_violations, extra_nouns) - if n: - files_fixed += 1 - headings_fixed += n - + files_fixed, headings_fixed = fix_spec( + spec_root, proper_nouns_path=proper_nouns, + ) log(f"Fixed {headings_fixed} heading(s) in {files_fixed} file(s).") def bold_table_lint(self, args): @@ -1016,7 +1006,7 @@ def bold_table_lint(self, args): log("No bold-table-header violations found.") def bold_table_fix(self, args): - from doc_build.iso_bold_table_lint import check_spec, fix_file, format_report + from doc_build.iso_bold_table_lint import check_spec, fix_spec, format_report spec_root = self.get_specification_root() log(f"Fixing bold table headers in {spec_root} ...") @@ -1028,138 +1018,87 @@ def bold_table_fix(self, args): report = format_report(violations, spec_root=spec_root) log(report) - by_file: dict = {} - for v in violations: - by_file.setdefault(v.file, []).append(v) - - files_fixed = 0 - rows_fixed = 0 - for file_path, file_violations in by_file.items(): - n = fix_file(file_path, file_violations) - if n: - files_fixed += 1 - rows_fixed += n - + files_fixed, rows_fixed = fix_spec(spec_root) log(f"Fixed {rows_fixed} header row(s) in {files_fixed} file(s).") def iso_lint_all(self, args): """Run all three ISO linters: clause structure, heading case, bold table headers.""" - failed = False - - # 1. Clause structure from doc_build.iso_clause_lint import ( check_spec as clause_check, format_report as clause_report, ) - spec_root = self.get_specification_root() - log(f"Checking ISO clause structure in {spec_root} ...") - clause_violations = clause_check(spec_root) - report = clause_report(clause_violations, context=getattr(args, 'context', 5), spec_root=spec_root) - if report: - log(report) - failed = True - else: - log("No ISO clause structure violations found.") - - # 2. Heading sentence case from doc_build.iso_heading_case_lint import ( check_spec as heading_check, format_report as heading_report, ) + from doc_build.iso_bold_table_lint import ( + check_spec as bold_check, format_report as bold_report, + ) + + spec_root = self.get_specification_root() proper_nouns = getattr(args, 'proper_nouns', None) if proper_nouns is None: proper_nouns = self.get_heading_proper_nouns() - log(f"\nChecking heading sentence case in {spec_root} ...") - heading_violations = heading_check(spec_root, proper_nouns_path=proper_nouns) - report = heading_report(heading_violations, spec_root=spec_root) - if report: - log(report) - failed = True - else: - log("No heading case violations found.") + context = getattr(args, 'context', 5) + + linters = [ + ("ISO clause structure", "No ISO clause structure violations found.", + lambda: clause_report(clause_check(spec_root), context=context, spec_root=spec_root)), + ("heading sentence case", "No heading case violations found.", + lambda: heading_report(heading_check(spec_root, proper_nouns_path=proper_nouns), spec_root=spec_root)), + ("bold table headers", "No bold-table-header violations found.", + lambda: bold_report(bold_check(spec_root), spec_root=spec_root)), + ] - # 3. Bold table headers - from doc_build.iso_bold_table_lint import ( - check_spec as bold_check, format_report as bold_report, - ) - log(f"\nChecking bold table headers in {spec_root} ...") - bold_violations = bold_check(spec_root) - report = bold_report(bold_violations, spec_root=spec_root) - if report: - log(report) - failed = True - else: - log("No bold-table-header violations found.") + failed = False + for label, ok_msg, run in linters: + log(f"\nChecking {label} in {spec_root} ...") + report = run() + if report: + log(report) + failed = True + else: + log(ok_msg) if failed: sys.exit(1) def iso_fix_all(self, args): - """Run all three ISO fixers: heading case and bold table headers (clause structure is lint-only).""" - spec_root = self.get_specification_root() - - # 1. Heading sentence case + """Auto-fix heading case and bold table headers across the spec.""" from doc_build.iso_heading_case_lint import ( - check_spec as heading_check, fix_file as heading_fix, - format_report as heading_report, load_proper_nouns, + check_spec as heading_check, fix_spec as heading_fix, + format_report as heading_report, ) + from doc_build.iso_bold_table_lint import ( + check_spec as bold_check, fix_spec as bold_fix, + format_report as bold_report, + ) + + spec_root = self.get_specification_root() proper_nouns = getattr(args, 'proper_nouns', None) if proper_nouns is None: proper_nouns = self.get_heading_proper_nouns() - log(f"Fixing heading sentence case in {spec_root} ...") - heading_violations = heading_check(spec_root, proper_nouns_path=proper_nouns) - if heading_violations: - report = heading_report(heading_violations, spec_root=spec_root) - log(report) - extra_nouns = load_proper_nouns(proper_nouns) - by_file: dict = {} - for v in heading_violations: - by_file.setdefault(v.file, []).append(v) - files_fixed = 0 - headings_fixed = 0 - for file_path, file_violations in by_file.items(): - n = heading_fix(file_path, file_violations, extra_nouns) - if n: - files_fixed += 1 - headings_fixed += n - log(f"Fixed {headings_fixed} heading(s) in {files_fixed} file(s).") - else: - log("No heading case violations found.") - # 2. Bold table headers - from doc_build.iso_bold_table_lint import ( - check_spec as bold_check, fix_file as bold_fix, - format_report as bold_report, - ) - log(f"\nFixing bold table headers in {spec_root} ...") - bold_violations = bold_check(spec_root) - if bold_violations: - report = bold_report(bold_violations, spec_root=spec_root) - log(report) - by_file = {} - for v in bold_violations: - by_file.setdefault(v.file, []).append(v) - files_fixed = 0 - rows_fixed = 0 - for file_path, file_violations in by_file.items(): - n = bold_fix(file_path, file_violations) - if n: - files_fixed += 1 - rows_fixed += n - log(f"Fixed {rows_fixed} header row(s) in {files_fixed} file(s).") - else: - log("No bold-table-header violations found.") + fixers = [ + ("heading sentence case", "No heading case violations found.", + lambda: heading_check(spec_root, proper_nouns_path=proper_nouns), + lambda vs: heading_report(vs, spec_root=spec_root), + lambda: heading_fix(spec_root, proper_nouns_path=proper_nouns), + lambda f, n: f"Fixed {n} heading(s) in {f} file(s)."), + ("bold table headers", "No bold-table-header violations found.", + lambda: bold_check(spec_root), + lambda vs: bold_report(vs, spec_root=spec_root), + lambda: bold_fix(spec_root), + lambda f, n: f"Fixed {n} header row(s) in {f} file(s)."), + ] - # 3. Clause structure (lint-only, no auto-fix) - from doc_build.iso_clause_lint import ( - check_spec as clause_check, format_report as clause_report, - ) - log(f"\nChecking ISO clause structure in {spec_root} ...") - clause_violations = clause_check(spec_root) - report = clause_report(clause_violations, context=getattr(args, 'context', 5), spec_root=spec_root) - if report: - log(report) - log("\nNote: clause structure violations require manual editing.") - else: - log("No ISO clause structure violations found.") + for label, ok_msg, check, report_fn, fix, summary_fn in fixers: + log(f"\nFixing {label} in {spec_root} ...") + violations = check() + if violations: + log(report_fn(violations)) + files_fixed, items_fixed = fix() + log(summary_fn(files_fixed, items_fixed)) + else: + log(ok_msg) def export_git_archive(self, args): timestr = time.strftime("%Y%m%d-%H%M%S") @@ -1641,7 +1580,7 @@ def make_iso_lint_all_parser(self, subparsers): def make_iso_fix_all_parser(self, subparsers): p = subparsers.add_parser( "iso_fix_all", - help="Run all ISO fixers (heading case, bold table headers) and lint clause structure", + help="Auto-fix heading case and bold table headers across the spec", ) p.add_argument( "--proper-nouns", diff --git a/doc_build/iso_bold_table_lint.py b/doc_build/iso_bold_table_lint.py index 8c9afe5..44819b0 100644 --- a/doc_build/iso_bold_table_lint.py +++ b/doc_build/iso_bold_table_lint.py @@ -35,31 +35,19 @@ import re import subprocess import sys -from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path from typing import List, Optional, Set, Tuple -try: - from doc_build.filters.pandocfilters import stringify -except ImportError: - from filters.pandocfilters import stringify - from doc_build.iso_lint_utils import ( DEFAULT_WORKERS, collect_md_files, get_sourcepos, + run_parallel_check, + stringify, unwrap_sourcepos_spans, ) -# Regex matching a pipe-table separator row: |---|---| or |:---:|---:| -_SEPARATOR_RE = re.compile( - r'^\s*\|' # leading pipe (optional whitespace) - r'[\s:_-]+' # first cell: dashes, colons, spaces - r'(\|[\s:_-]+)*' # subsequent cells - r'\|?\s*$' # trailing pipe (optional) -) - # --------------------------------------------------------------------------- # Data types @@ -73,10 +61,11 @@ class Violation: header_text: str # raw source text of the header row non_bold_cells: List[str] # cell texts that are not bold - def format(self) -> str: + def format(self, display_path: Optional[Path] = None) -> str: + path = display_path if display_path is not None else self.file cells_str = ', '.join(f'"{c.strip()}"' for c in self.non_bold_cells) return ( - f"{self.file}:{self.lineno}: {self.header_text.strip()}\n" + f"{path}:{self.lineno}: {self.header_text.strip()}\n" f" non-bold cells: {cells_str}" ) @@ -187,31 +176,29 @@ def check_spec( """Walk *spec_root* recursively and return all violations in .md files.""" md_files = collect_md_files(spec_root) - all_violations: List[Violation] = [] - with ThreadPoolExecutor(max_workers=workers) as pool: - futures = {pool.submit(check_file, p): p for p in md_files} - for future in as_completed(futures): - all_violations.extend(future.result()) - - all_violations.sort(key=lambda v: (str(v.file), v.lineno)) - return all_violations + return run_parallel_check( + md_files, + check_fn=check_file, + sort_key=lambda v: (str(v.file), v.lineno), + workers=workers, + ) # --------------------------------------------------------------------------- # Source-level fix # --------------------------------------------------------------------------- -def _is_separator_line(line: str) -> bool: - """Return True if *line* is a pipe-table separator row.""" - return bool(_SEPARATOR_RE.match(line)) - - def _bold_header_row(line: str) -> str: """Wrap non-bold, non-code cells in a pipe-table header row with **…**. Splits the line by ``|``, processes each cell, and reassembles. Cells that are already bold (``**…**``) or are code spans (``` `…` ```) are left unchanged. Empty/whitespace-only cells are left unchanged. + + Note: partially bold cells (e.g. ``| **Name** extra |``) are treated + as not bold — the whole cell text gets wrapped. This is intentional: + partial bold in a header cell is almost certainly a formatting error, + and wrapping the full cell produces the correct result. """ # Split preserving the pipe delimiters. A typical header row: # "| Name | Type | Description |" @@ -273,14 +260,11 @@ def fix_file(path: Path, violations: Optional[List[Violation]] = None) -> int: idx = lineno - 1 if idx < 0 or idx >= len(lines): continue - original = lines[idx] - newline_suffix = '' - if original.endswith('\n'): - newline_suffix = '\n' - original = original[:-1] + original = lines[idx].rstrip('\n') + trailing = lines[idx][len(original):] bolded = _bold_header_row(original) if bolded != original: - lines[idx] = bolded + newline_suffix + lines[idx] = bolded + trailing fixed += 1 if fixed: @@ -335,13 +319,7 @@ def format_report( for rel_path, file_violations in by_file.items(): block_lines = [str(rel_path)] for v in file_violations: - display_v = Violation( - file=rel_path, - lineno=v.lineno, - header_text=v.header_text, - non_bold_cells=v.non_bold_cells, - ) - block_lines.append(display_v.format()) + block_lines.append(v.format(display_path=rel_path)) total += 1 sections.append('\n'.join(block_lines)) @@ -389,19 +367,7 @@ def main(argv=None): print(report) print() - # Group and fix. - by_file: dict = {} - for v in violations: - by_file.setdefault(v.file, []).append(v) - - files_fixed = 0 - rows_fixed = 0 - for file_path, file_violations in by_file.items(): - n = fix_file(file_path, file_violations) - if n: - files_fixed += 1 - rows_fixed += n - + files_fixed, rows_fixed = fix_spec(spec_root, workers=args.workers) print(f'Fixed {rows_fixed} header row(s) in {files_fixed} file(s).') else: violations = check_spec(spec_root, workers=args.workers) diff --git a/doc_build/iso_clause_lint.py b/doc_build/iso_clause_lint.py index a5accdf..7a381b5 100644 --- a/doc_build/iso_clause_lint.py +++ b/doc_build/iso_clause_lint.py @@ -23,17 +23,16 @@ import json import subprocess import sys -from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path from typing import List, Optional, Tuple -from filters.pandocfilters import stringify - from doc_build.iso_lint_utils import ( DEFAULT_WORKERS, collect_md_files, get_sourcepos, + run_parallel_check, + stringify, ) # How many non-blank body lines to show as context in a report. @@ -58,15 +57,16 @@ class Violation: # Non-blank source lines between the heading and the first subclause. # Each entry is (1-based line number, raw line content). - def format(self, context: int = DEFAULT_CONTEXT_LINES) -> str: + def format(self, context: int = DEFAULT_CONTEXT_LINES, display_path: Optional[Path] = None) -> str: """Return a human-readable description of the violation.""" + path = display_path if display_path is not None else self.file h_marker = '#' * self.heading_level sub_marker = '#' * self.first_sub_level shown = self.body_lines[:context] remainder = len(self.body_lines) - len(shown) lines = [ - f"{self.file}:{self.heading_lineno}: " + f"{path}:{self.heading_lineno}: " f"{h_marker} \"{self.heading_text}\" " # f"has text before its first subclause", ] @@ -190,14 +190,12 @@ def check_spec( """ md_files = collect_md_files(spec_root) - all_violations: List[Violation] = [] - with ThreadPoolExecutor(max_workers=workers) as pool: - futures = {pool.submit(check_file, p): p for p in md_files} - for future in as_completed(futures): - all_violations.extend(future.result()) - - all_violations.sort(key=lambda v: (str(v.file), v.heading_lineno)) - return all_violations + return run_parallel_check( + md_files, + check_fn=check_file, + sort_key=lambda v: (str(v.file), v.heading_lineno), + workers=workers, + ) # --------------------------------------------------------------------------- @@ -228,17 +226,7 @@ def format_report( for rel_path, file_violations in by_file.items(): block_lines = [f'{rel_path}'] for v in file_violations: - display_v = Violation( - file=rel_path, - heading_lineno=v.heading_lineno, - heading_level=v.heading_level, - heading_text=v.heading_text, - first_sub_lineno=v.first_sub_lineno, - first_sub_level=v.first_sub_level, - first_sub_text=v.first_sub_text, - body_lines=v.body_lines, - ) - block_lines.append(display_v.format(context=context)) + block_lines.append(v.format(context=context, display_path=rel_path)) total += 1 sections.append('\n'.join(block_lines)) diff --git a/doc_build/iso_heading_case_lint.py b/doc_build/iso_heading_case_lint.py index 77c9ffa..0685a8c 100644 --- a/doc_build/iso_heading_case_lint.py +++ b/doc_build/iso_heading_case_lint.py @@ -37,12 +37,10 @@ import re import subprocess import sys -from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path from typing import List, Optional, Set, Tuple -from filters.pandocfilters import stringify from filters.heading_case import ( heading_needs_conversion, is_proper_noun, @@ -54,6 +52,8 @@ DEFAULT_WORKERS, collect_md_files, get_sourcepos, + run_parallel_check, + stringify, ) @@ -71,11 +71,12 @@ class Violation: suggested_text: str non_sentence_words: List[str] = field(default_factory=list) - def format(self) -> str: + def format(self, display_path: Optional[Path] = None) -> str: + path = display_path if display_path is not None else self.file marker = '#' * self.level words_str = ', '.join(f'"{w}"' for w in self.non_sentence_words) lines = [ - f"{self.file}:{self.lineno}: " + f"{path}:{self.lineno}: " f"{marker} \"{self.heading_text}\"", f" suggested: {marker} \"{self.suggested_text}\"", ] @@ -90,7 +91,6 @@ def format(self) -> str: def _find_non_sentence_words(inlines: list, extra_nouns: Set[str]) -> List[str]: """Return words that are capitalised but not proper nouns (skipping the first word).""" - import re words = [] text = stringify(inlines) all_words = [w for w in re.split(r'\s+', text) if w] @@ -156,17 +156,14 @@ def check_spec( ) -> List[Violation]: """Walk *spec_root* recursively and return all violations in .md files.""" extra_nouns = load_proper_nouns(proper_nouns_path) - md_files = collect_md_files(spec_root) - all_violations: List[Violation] = [] - with ThreadPoolExecutor(max_workers=workers) as pool: - futures = {pool.submit(check_file, p, extra_nouns): p for p in md_files} - for future in as_completed(futures): - all_violations.extend(future.result()) - - all_violations.sort(key=lambda v: (str(v.file), v.lineno)) - return all_violations + return run_parallel_check( + md_files, + check_fn=lambda p: check_file(p, extra_nouns), + sort_key=lambda v: (str(v.file), v.lineno), + workers=workers, + ) # --------------------------------------------------------------------------- @@ -186,7 +183,7 @@ def _sentence_case_text(text: str, extra_nouns: Set[str]) -> str: """ # Build a set of character positions that are inside code spans or # link URLs — these must not be modified. - protected = set() + protected: Set[int] = set() for m in re.finditer(r'`+.+?`+', text): protected.update(range(m.start(), m.end())) for m in re.finditer(r'\]\([^)]*\)', text): @@ -196,8 +193,9 @@ def _sentence_case_text(text: str, extra_nouns: Set[str]) -> str: result = list(text) for m in re.finditer(r"[A-Za-z][A-Za-z0-9'_-]*", text): + word_range = range(m.start(), m.end()) # Skip words inside protected ranges (code spans, link URLs). - if any(i in protected for i in range(m.start(), m.end())): + if not protected.isdisjoint(word_range): first_word_seen = True continue @@ -258,14 +256,11 @@ def fix_file( idx = lineno - 1 if idx < 0 or idx >= len(lines): continue - original = lines[idx] - newline_suffix = '' - if original.endswith('\n'): - newline_suffix = '\n' - original = original[:-1] + original = lines[idx].rstrip('\n') + trailing = lines[idx][len(original):] converted = _sentence_case_heading_line(original, extra_nouns) if converted != original: - lines[idx] = converted + newline_suffix + lines[idx] = converted + trailing fixed += 1 if fixed: @@ -322,15 +317,7 @@ def format_report( for rel_path, file_violations in by_file.items(): block_lines = [str(rel_path)] for v in file_violations: - display_v = Violation( - file=rel_path, - lineno=v.lineno, - level=v.level, - heading_text=v.heading_text, - suggested_text=v.suggested_text, - non_sentence_words=v.non_sentence_words, - ) - block_lines.append(display_v.format()) + block_lines.append(v.format(display_path=rel_path)) total += 1 sections.append('\n'.join(block_lines)) @@ -389,19 +376,11 @@ def main(argv=None): print(report) print() - extra_nouns = load_proper_nouns(args.proper_nouns) - by_file: dict = {} - for v in violations: - by_file.setdefault(v.file, []).append(v) - - files_fixed = 0 - headings_fixed = 0 - for file_path, file_violations in by_file.items(): - n = fix_file(file_path, file_violations, extra_nouns) - if n: - files_fixed += 1 - headings_fixed += n - + files_fixed, headings_fixed = fix_spec( + spec_root, + workers=args.workers, + proper_nouns_path=args.proper_nouns, + ) print(f'Fixed {headings_fixed} heading(s) in {files_fixed} file(s).') else: violations = check_spec( diff --git a/doc_build/iso_lint_utils.py b/doc_build/iso_lint_utils.py index 39d6cc8..6eac581 100644 --- a/doc_build/iso_lint_utils.py +++ b/doc_build/iso_lint_utils.py @@ -5,10 +5,26 @@ """ import os +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path -from typing import List, Optional +from typing import Callable, List, Optional -# Maximum number of parallel Pandoc subprocesses used by check_spec(). +try: + from doc_build.filters.pandocfilters import stringify +except ImportError: + from filters.pandocfilters import stringify + +# Re-export so linters can ``from doc_build.iso_lint_utils import stringify``. +__all__ = [ + "DEFAULT_WORKERS", + "collect_md_files", + "get_sourcepos", + "run_parallel_check", + "stringify", + "unwrap_sourcepos_spans", +] + +# Maximum number of parallel Pandoc subprocesses. DEFAULT_WORKERS = 8 @@ -29,6 +45,27 @@ def collect_md_files(path: Path) -> List[Path]: return md_files +def run_parallel_check( + md_files: List[Path], + check_fn: Callable, + sort_key: Callable, + workers: int = DEFAULT_WORKERS, +) -> list: + """Run *check_fn* on each file in parallel and return sorted results. + + *check_fn* is called with a single ``Path`` argument and must return + a list of violation objects. Results are concatenated, sorted by + *sort_key*, and returned. + """ + all_violations: list = [] + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = {pool.submit(check_fn, p): p for p in md_files} + for future in as_completed(futures): + all_violations.extend(future.result()) + all_violations.sort(key=sort_key) + return all_violations + + def get_sourcepos(attr: list) -> Optional[int]: """Extract the start line number from a Pandoc sourcepos Attr. From cf7943779ff3b2a82e72267b33fb09fc6024b03c Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Thu, 11 Jun 2026 11:14:34 +0300 Subject: [PATCH 08/13] Extract shared format_report() into iso_lint_utils, remove unused imports - Move format_report() logic into iso_lint_utils parameterised by label - Each linter keeps a thin wrapper preserving its public API - Remove unused imports from iso_bold_table_lint (re, Set, field) Co-Authored-By: Claude Opus 4.6 --- doc_build/iso_bold_table_lint.py | 27 +++---------------- doc_build/iso_clause_lint.py | 25 +++--------------- doc_build/iso_heading_case_lint.py | 22 ++-------------- doc_build/iso_lint_utils.py | 42 +++++++++++++++++++++++++++++- 4 files changed, 50 insertions(+), 66 deletions(-) diff --git a/doc_build/iso_bold_table_lint.py b/doc_build/iso_bold_table_lint.py index 44819b0..3227ac8 100644 --- a/doc_build/iso_bold_table_lint.py +++ b/doc_build/iso_bold_table_lint.py @@ -32,16 +32,16 @@ """ import json -import re import subprocess import sys -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from typing import List, Optional, Set, Tuple +from typing import List, Optional, Tuple from doc_build.iso_lint_utils import ( DEFAULT_WORKERS, collect_md_files, + format_report as _format_report, get_sourcepos, run_parallel_check, stringify, @@ -306,26 +306,7 @@ def format_report( violations: List[Violation], spec_root: Optional[Path] = None, ) -> str: - if not violations: - return '' - - by_file: dict = {} - for v in violations: - rel = v.file.relative_to(spec_root) if spec_root else v.file - by_file.setdefault(rel, []).append(v) - - sections: List[str] = [] - total = 0 - for rel_path, file_violations in by_file.items(): - block_lines = [str(rel_path)] - for v in file_violations: - block_lines.append(v.format(display_path=rel_path)) - total += 1 - sections.append('\n'.join(block_lines)) - - file_count = len(by_file) - header = f'{total} bold-table-header violation(s) in {file_count} file(s)\n' - return header + '\n' + '\n\n'.join(sections) + return _format_report(violations, "bold-table-header", spec_root=spec_root) # --------------------------------------------------------------------------- diff --git a/doc_build/iso_clause_lint.py b/doc_build/iso_clause_lint.py index 7a381b5..6455026 100644 --- a/doc_build/iso_clause_lint.py +++ b/doc_build/iso_clause_lint.py @@ -30,6 +30,7 @@ from doc_build.iso_lint_utils import ( DEFAULT_WORKERS, collect_md_files, + format_report as _format_report, get_sourcepos, run_parallel_check, stringify, @@ -212,29 +213,9 @@ def format_report( If *spec_root* is given, file paths are shown relative to it. Returns an empty string when there are no violations. """ - if not violations: - return '' - - # Group by file for tidier output. - by_file: dict = {} - for v in violations: - rel = v.file.relative_to(spec_root) if spec_root else v.file - by_file.setdefault(rel, []).append(v) - - sections: List[str] = [] - total = 0 - for rel_path, file_violations in by_file.items(): - block_lines = [f'{rel_path}'] - for v in file_violations: - block_lines.append(v.format(context=context, display_path=rel_path)) - total += 1 - sections.append('\n'.join(block_lines)) - - file_count = len(by_file) - header = ( - f'{total} violation(s) in {file_count} file(s)\n' + return _format_report( + violations, "clause structure", spec_root=spec_root, context=context, ) - return header + '\n\n' + '\n\n'.join(sections) # --------------------------------------------------------------------------- diff --git a/doc_build/iso_heading_case_lint.py b/doc_build/iso_heading_case_lint.py index 0685a8c..c5ef042 100644 --- a/doc_build/iso_heading_case_lint.py +++ b/doc_build/iso_heading_case_lint.py @@ -51,6 +51,7 @@ from doc_build.iso_lint_utils import ( DEFAULT_WORKERS, collect_md_files, + format_report as _format_report, get_sourcepos, run_parallel_check, stringify, @@ -304,26 +305,7 @@ def format_report( violations: List[Violation], spec_root: Optional[Path] = None, ) -> str: - if not violations: - return '' - - by_file: dict = {} - for v in violations: - rel = v.file.relative_to(spec_root) if spec_root else v.file - by_file.setdefault(rel, []).append(v) - - sections: List[str] = [] - total = 0 - for rel_path, file_violations in by_file.items(): - block_lines = [str(rel_path)] - for v in file_violations: - block_lines.append(v.format(display_path=rel_path)) - total += 1 - sections.append('\n'.join(block_lines)) - - file_count = len(by_file) - header = f'{total} heading case violation(s) in {file_count} file(s)\n' - return header + '\n' + '\n\n'.join(sections) + return _format_report(violations, "heading case", spec_root=spec_root) # --------------------------------------------------------------------------- diff --git a/doc_build/iso_lint_utils.py b/doc_build/iso_lint_utils.py index 6eac581..c49b52b 100644 --- a/doc_build/iso_lint_utils.py +++ b/doc_build/iso_lint_utils.py @@ -7,7 +7,7 @@ import os from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path -from typing import Callable, List, Optional +from typing import Any, Callable, List, Optional try: from doc_build.filters.pandocfilters import stringify @@ -18,6 +18,7 @@ __all__ = [ "DEFAULT_WORKERS", "collect_md_files", + "format_report", "get_sourcepos", "run_parallel_check", "stringify", @@ -98,6 +99,45 @@ def get_sourcepos(attr: list) -> Optional[int]: return None +def format_report( + violations: List[Any], + label: str, + spec_root: Optional[Path] = None, + **format_kwargs, +) -> str: + """Format a list of violations into a human-readable report. + + Each violation must have a ``.file`` attribute (a ``Path``) and a + ``.format(display_path=..., **kwargs)`` method. + + *label* is a short noun phrase used in the summary line, e.g. + ``"heading case"`` → ``"3 heading case violation(s) in 2 file(s)"``. + + Extra *format_kwargs* are forwarded to each violation's ``.format()`` + call (e.g. ``context=5`` for the clause linter). + """ + if not violations: + return '' + + by_file: dict = {} + for v in violations: + rel = v.file.relative_to(spec_root) if spec_root else v.file + by_file.setdefault(rel, []).append(v) + + sections: List[str] = [] + total = 0 + for rel_path, file_violations in by_file.items(): + block_lines = [str(rel_path)] + for v in file_violations: + block_lines.append(v.format(display_path=rel_path, **format_kwargs)) + total += 1 + sections.append('\n'.join(block_lines)) + + file_count = len(by_file) + header = f'{total} {label} violation(s) in {file_count} file(s)\n' + return header + '\n' + '\n\n'.join(sections) + + def unwrap_sourcepos_spans(inlines: list) -> list: """Unwrap Span nodes injected by Pandoc's +sourcepos extension. From 47be3f8bc85c74482ba6101a209e11d6d60e160a Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Thu, 11 Jun 2026 11:43:58 +0300 Subject: [PATCH 09/13] Avoid double Pandoc scan in fix mode, clean up Violation.format() - fix_spec() accepts optional pre-computed violations list, skipping the redundant check_spec() call when the caller already has results - All call sites (main --fix, doc_builder fix methods, iso_fix_all) now pass violations through - Make Violation.format() keyword-only across all three linters - Remove commented-out code from iso_clause_lint Violation.format() Co-Authored-By: Claude Opus 4.6 --- doc_build/doc_builder.py | 10 +++++----- doc_build/iso_bold_table_lint.py | 14 ++++++++++---- doc_build/iso_clause_lint.py | 5 ++--- doc_build/iso_heading_case_lint.py | 13 ++++++++++--- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index 9724c7b..5b29feb 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -989,7 +989,7 @@ def heading_case_fix(self, args): log(report) files_fixed, headings_fixed = fix_spec( - spec_root, proper_nouns_path=proper_nouns, + spec_root, proper_nouns_path=proper_nouns, violations=violations, ) log(f"Fixed {headings_fixed} heading(s) in {files_fixed} file(s).") @@ -1018,7 +1018,7 @@ def bold_table_fix(self, args): report = format_report(violations, spec_root=spec_root) log(report) - files_fixed, rows_fixed = fix_spec(spec_root) + files_fixed, rows_fixed = fix_spec(spec_root, violations=violations) log(f"Fixed {rows_fixed} header row(s) in {files_fixed} file(s).") def iso_lint_all(self, args): @@ -1081,12 +1081,12 @@ def iso_fix_all(self, args): ("heading sentence case", "No heading case violations found.", lambda: heading_check(spec_root, proper_nouns_path=proper_nouns), lambda vs: heading_report(vs, spec_root=spec_root), - lambda: heading_fix(spec_root, proper_nouns_path=proper_nouns), + lambda vs: heading_fix(spec_root, proper_nouns_path=proper_nouns, violations=vs), lambda f, n: f"Fixed {n} heading(s) in {f} file(s)."), ("bold table headers", "No bold-table-header violations found.", lambda: bold_check(spec_root), lambda vs: bold_report(vs, spec_root=spec_root), - lambda: bold_fix(spec_root), + lambda vs: bold_fix(spec_root, violations=vs), lambda f, n: f"Fixed {n} header row(s) in {f} file(s)."), ] @@ -1095,7 +1095,7 @@ def iso_fix_all(self, args): violations = check() if violations: log(report_fn(violations)) - files_fixed, items_fixed = fix() + files_fixed, items_fixed = fix(violations) log(summary_fn(files_fixed, items_fixed)) else: log(ok_msg) diff --git a/doc_build/iso_bold_table_lint.py b/doc_build/iso_bold_table_lint.py index 3227ac8..7a5f4f4 100644 --- a/doc_build/iso_bold_table_lint.py +++ b/doc_build/iso_bold_table_lint.py @@ -61,7 +61,7 @@ class Violation: header_text: str # raw source text of the header row non_bold_cells: List[str] # cell texts that are not bold - def format(self, display_path: Optional[Path] = None) -> str: + def format(self, *, display_path: Optional[Path] = None) -> str: path = display_path if display_path is not None else self.file cells_str = ', '.join(f'"{c.strip()}"' for c in self.non_bold_cells) return ( @@ -276,9 +276,15 @@ def fix_file(path: Path, violations: Optional[List[Violation]] = None) -> int: def fix_spec( spec_root: Path, workers: int = DEFAULT_WORKERS, + violations: Optional[List[Violation]] = None, ) -> Tuple[int, int]: - """Fix all violations under *spec_root*. Returns (files_fixed, rows_fixed).""" - violations = check_spec(spec_root, workers=workers) + """Fix all violations under *spec_root*. Returns (files_fixed, rows_fixed). + + If *violations* is provided, skips the check pass and fixes those + directly — useful when the caller already ran ``check_spec()``. + """ + if violations is None: + violations = check_spec(spec_root, workers=workers) if not violations: return 0, 0 @@ -348,7 +354,7 @@ def main(argv=None): print(report) print() - files_fixed, rows_fixed = fix_spec(spec_root, workers=args.workers) + files_fixed, rows_fixed = fix_spec(spec_root, workers=args.workers, violations=violations) print(f'Fixed {rows_fixed} header row(s) in {files_fixed} file(s).') else: violations = check_spec(spec_root, workers=args.workers) diff --git a/doc_build/iso_clause_lint.py b/doc_build/iso_clause_lint.py index 6455026..d728f79 100644 --- a/doc_build/iso_clause_lint.py +++ b/doc_build/iso_clause_lint.py @@ -58,7 +58,7 @@ class Violation: # Non-blank source lines between the heading and the first subclause. # Each entry is (1-based line number, raw line content). - def format(self, context: int = DEFAULT_CONTEXT_LINES, display_path: Optional[Path] = None) -> str: + def format(self, *, context: int = DEFAULT_CONTEXT_LINES, display_path: Optional[Path] = None) -> str: """Return a human-readable description of the violation.""" path = display_path if display_path is not None else self.file h_marker = '#' * self.heading_level @@ -68,8 +68,7 @@ def format(self, context: int = DEFAULT_CONTEXT_LINES, display_path: Optional[Pa lines = [ f"{path}:{self.heading_lineno}: " - f"{h_marker} \"{self.heading_text}\" " - # f"has text before its first subclause", + f"{h_marker} \"{self.heading_text}\"", ] for lineno, content in shown: lines.append(f" │ {lineno:5d}: {content}") diff --git a/doc_build/iso_heading_case_lint.py b/doc_build/iso_heading_case_lint.py index c5ef042..598eae6 100644 --- a/doc_build/iso_heading_case_lint.py +++ b/doc_build/iso_heading_case_lint.py @@ -72,7 +72,7 @@ class Violation: suggested_text: str non_sentence_words: List[str] = field(default_factory=list) - def format(self, display_path: Optional[Path] = None) -> str: + def format(self, *, display_path: Optional[Path] = None) -> str: path = display_path if display_path is not None else self.file marker = '#' * self.level words_str = ', '.join(f'"{w}"' for w in self.non_sentence_words) @@ -274,10 +274,16 @@ def fix_spec( spec_root: Path, workers: int = DEFAULT_WORKERS, proper_nouns_path: Optional[Path] = None, + violations: Optional[List[Violation]] = None, ) -> Tuple[int, int]: - """Fix all violations under *spec_root*. Returns (files_fixed, headings_fixed).""" + """Fix all violations under *spec_root*. Returns (files_fixed, headings_fixed). + + If *violations* is provided, skips the check pass and fixes those + directly — useful when the caller already ran ``check_spec()``. + """ extra_nouns = load_proper_nouns(proper_nouns_path) - violations = check_spec(spec_root, workers=workers, proper_nouns_path=proper_nouns_path) + if violations is None: + violations = check_spec(spec_root, workers=workers, proper_nouns_path=proper_nouns_path) if not violations: return 0, 0 @@ -362,6 +368,7 @@ def main(argv=None): spec_root, workers=args.workers, proper_nouns_path=args.proper_nouns, + violations=violations, ) print(f'Fixed {headings_fixed} heading(s) in {files_fixed} file(s).') else: From 21965625814e4835465b96c1d541fba2d0dbd361 Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Fri, 12 Jun 2026 14:57:10 +0300 Subject: [PATCH 10/13] Replace getattr(args, ...) with direct access in ISO subcommands The argparse namespaces always have these attributes defined by their respective parsers, so getattr with a fallback is unnecessary. Add --context to the iso_lint_all parser so it's explicitly declared rather than silently defaulted via getattr. Co-Authored-By: Claude Opus 4.6 --- doc_build/doc_builder.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index 5b29feb..a25d853 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -956,9 +956,7 @@ def heading_case_lint(self, args): from doc_build.iso_heading_case_lint import check_spec, format_report spec_root = self.get_specification_root() - proper_nouns = getattr(args, 'proper_nouns', None) - if proper_nouns is None: - proper_nouns = self.get_heading_proper_nouns() + proper_nouns = args.proper_nouns or self.get_heading_proper_nouns() log(f"Checking heading sentence case in {spec_root} ...") violations = check_spec( spec_root, @@ -976,9 +974,7 @@ def heading_case_fix(self, args): ) spec_root = self.get_specification_root() - proper_nouns = getattr(args, 'proper_nouns', None) - if proper_nouns is None: - proper_nouns = self.get_heading_proper_nouns() + proper_nouns = args.proper_nouns or self.get_heading_proper_nouns() log(f"Fixing heading sentence case in {spec_root} ...") violations = check_spec(spec_root, proper_nouns_path=proper_nouns) if not violations: @@ -1034,14 +1030,11 @@ def iso_lint_all(self, args): ) spec_root = self.get_specification_root() - proper_nouns = getattr(args, 'proper_nouns', None) - if proper_nouns is None: - proper_nouns = self.get_heading_proper_nouns() - context = getattr(args, 'context', 5) + proper_nouns = args.proper_nouns or self.get_heading_proper_nouns() linters = [ ("ISO clause structure", "No ISO clause structure violations found.", - lambda: clause_report(clause_check(spec_root), context=context, spec_root=spec_root)), + lambda: clause_report(clause_check(spec_root), context=args.context, spec_root=spec_root)), ("heading sentence case", "No heading case violations found.", lambda: heading_report(heading_check(spec_root, proper_nouns_path=proper_nouns), spec_root=spec_root)), ("bold table headers", "No bold-table-header violations found.", @@ -1073,9 +1066,7 @@ def iso_fix_all(self, args): ) spec_root = self.get_specification_root() - proper_nouns = getattr(args, 'proper_nouns', None) - if proper_nouns is None: - proper_nouns = self.get_heading_proper_nouns() + proper_nouns = args.proper_nouns or self.get_heading_proper_nouns() fixers = [ ("heading sentence case", "No heading case violations found.", @@ -1574,6 +1565,13 @@ def make_iso_lint_all_parser(self, subparsers): help="Path to a YAML file listing additional proper nouns " "(default: iso_heading_proper_nouns.yaml in the builder or spec root)", ) + p.add_argument( + "--context", + type=int, + default=5, + metavar="N", + help="Number of body lines to show per clause-structure violation (default: 5)", + ) p.set_defaults(func=self.iso_lint_all) return p From 5b612de1aa0a070b8e073c3b7f02d84c911eea46 Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Fri, 12 Jun 2026 15:10:07 +0300 Subject: [PATCH 11/13] Replace remaining getattr(args, ...) with direct attribute access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build parser defines all five attributes (heading_case_lint, heading_proper_nouns, iso_xrefs, keep_pdf_latex), so the argparse namespace always has them — getattr with a fallback was unnecessary. Co-Authored-By: Claude Opus 4.6 --- doc_build/doc_builder.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index a25d853..000ad06 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -254,9 +254,9 @@ def build_docs(self, args): ) args.output.mkdir(parents=True, exist_ok=True) - if getattr(args, 'heading_case_lint', False): + if args.heading_case_lint: from doc_build.iso_heading_case_lint import check_spec, format_report - pn_path = getattr(args, 'heading_proper_nouns', None) or self.get_heading_proper_nouns() + pn_path = args.heading_proper_nouns or self.get_heading_proper_nouns() spec_root = self.get_specification_root() log(f"\tChecking heading sentence case in {spec_root} ...") violations = check_spec(spec_root, proper_nouns_path=pn_path) @@ -346,7 +346,7 @@ def _render_combined( dejavufontpath = Path(os.path.relpath(fonts_dir, artifacts_dir)).as_posix() + "/" all_filters = self.get_doc_build_filters() - if not getattr(args, 'iso_xrefs', False): + if not args.iso_xrefs: iso_filter = self.get_filter("iso_xrefs") all_filters = [f for f in all_filters if f != iso_filter] doc_build_filters = [] @@ -414,7 +414,7 @@ def _render_combined( log("\tAdding Draft Watermark...") shared_command.extend(["-V", "draft=true"]) - if getattr(args, 'iso_xrefs', False): + if args.iso_xrefs: shared_command.extend(["-M", f"ISO_CLAUSE_MAP={self.get_iso_clause_map()}"]) if from_pretty is not None and to_pretty is not None: shared_command.extend([ @@ -497,7 +497,7 @@ def stderr_processor(std_err): f"--template={latex_template}", ] + pdf_extra - if not getattr(args, "keep_pdf_latex", False): + if not args.keep_pdf_latex: # Standard path: pandoc pipes directly to tectonic via stdin. log(f"\tBuilding PDF to {pdf}...") pandoc( From 9121078b835fc138464628f39bf593973985b03d Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Fri, 12 Jun 2026 15:45:17 +0300 Subject: [PATCH 12/13] =?UTF-8?q?Revert=20getattr=20removal=20in=20build?= =?UTF-8?q?=5Fdocs=20=E2=80=94=20callers=20use=20SimpleNamespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_docs() is called from test scripts that build args via types.SimpleNamespace without all build-parser attributes. The getattr fallbacks are necessary here, unlike the ISO subcommand methods which are only ever invoked through their own parsers. Co-Authored-By: Claude Opus 4.6 --- doc_build/doc_builder.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index 000ad06..a25d853 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -254,9 +254,9 @@ def build_docs(self, args): ) args.output.mkdir(parents=True, exist_ok=True) - if args.heading_case_lint: + if getattr(args, 'heading_case_lint', False): from doc_build.iso_heading_case_lint import check_spec, format_report - pn_path = args.heading_proper_nouns or self.get_heading_proper_nouns() + pn_path = getattr(args, 'heading_proper_nouns', None) or self.get_heading_proper_nouns() spec_root = self.get_specification_root() log(f"\tChecking heading sentence case in {spec_root} ...") violations = check_spec(spec_root, proper_nouns_path=pn_path) @@ -346,7 +346,7 @@ def _render_combined( dejavufontpath = Path(os.path.relpath(fonts_dir, artifacts_dir)).as_posix() + "/" all_filters = self.get_doc_build_filters() - if not args.iso_xrefs: + if not getattr(args, 'iso_xrefs', False): iso_filter = self.get_filter("iso_xrefs") all_filters = [f for f in all_filters if f != iso_filter] doc_build_filters = [] @@ -414,7 +414,7 @@ def _render_combined( log("\tAdding Draft Watermark...") shared_command.extend(["-V", "draft=true"]) - if args.iso_xrefs: + if getattr(args, 'iso_xrefs', False): shared_command.extend(["-M", f"ISO_CLAUSE_MAP={self.get_iso_clause_map()}"]) if from_pretty is not None and to_pretty is not None: shared_command.extend([ @@ -497,7 +497,7 @@ def stderr_processor(std_err): f"--template={latex_template}", ] + pdf_extra - if not args.keep_pdf_latex: + if not getattr(args, "keep_pdf_latex", False): # Standard path: pandoc pipes directly to tectonic via stdin. log(f"\tBuilding PDF to {pdf}...") pandoc( From ebc268c7ca0d82738c48564cdfc0427649639867 Mon Sep 17 00:00:00 2001 From: Oleksiy Puzikov Date: Fri, 12 Jun 2026 15:48:23 +0300 Subject: [PATCH 13/13] Replace getattr(args, ...) with direct access in build_docs Add the missing build-parser attributes (heading_case_lint, heading_proper_nouns, iso_xrefs) to the SimpleNamespace in build_diff.py so build_docs() can use direct attribute access consistently. Future build flags that are missing from test callers will fail with a clear AttributeError instead of silently falling back to a default. Co-Authored-By: Claude Opus 4.6 --- doc_build/doc_builder.py | 10 +++++----- tests/build_scripts/build_diff.py | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index a25d853..000ad06 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -254,9 +254,9 @@ def build_docs(self, args): ) args.output.mkdir(parents=True, exist_ok=True) - if getattr(args, 'heading_case_lint', False): + if args.heading_case_lint: from doc_build.iso_heading_case_lint import check_spec, format_report - pn_path = getattr(args, 'heading_proper_nouns', None) or self.get_heading_proper_nouns() + pn_path = args.heading_proper_nouns or self.get_heading_proper_nouns() spec_root = self.get_specification_root() log(f"\tChecking heading sentence case in {spec_root} ...") violations = check_spec(spec_root, proper_nouns_path=pn_path) @@ -346,7 +346,7 @@ def _render_combined( dejavufontpath = Path(os.path.relpath(fonts_dir, artifacts_dir)).as_posix() + "/" all_filters = self.get_doc_build_filters() - if not getattr(args, 'iso_xrefs', False): + if not args.iso_xrefs: iso_filter = self.get_filter("iso_xrefs") all_filters = [f for f in all_filters if f != iso_filter] doc_build_filters = [] @@ -414,7 +414,7 @@ def _render_combined( log("\tAdding Draft Watermark...") shared_command.extend(["-V", "draft=true"]) - if getattr(args, 'iso_xrefs', False): + if args.iso_xrefs: shared_command.extend(["-M", f"ISO_CLAUSE_MAP={self.get_iso_clause_map()}"]) if from_pretty is not None and to_pretty is not None: shared_command.extend([ @@ -497,7 +497,7 @@ def stderr_processor(std_err): f"--template={latex_template}", ] + pdf_extra - if not getattr(args, "keep_pdf_latex", False): + if not args.keep_pdf_latex: # Standard path: pandoc pipes directly to tectonic via stdin. log(f"\tBuilding PDF to {pdf}...") pandoc( diff --git a/tests/build_scripts/build_diff.py b/tests/build_scripts/build_diff.py index 09a328f..4a96976 100644 --- a/tests/build_scripts/build_diff.py +++ b/tests/build_scripts/build_diff.py @@ -133,6 +133,9 @@ def build_diff(build_html: bool, build_pdf: bool, keep_pdf_latex: bool = False) only=[], exclude=[], keep_pdf_latex=keep_pdf_latex, + heading_case_lint=False, + heading_proper_nouns=None, + iso_xrefs=False, ) print("Running DocBuilder.build_docs() with --diff...")