From bb9d20bdef8bdbdf36cab7700eebaada4d46ff28 Mon Sep 17 00:00:00 2001 From: fbarbu15 Date: Thu, 11 Jun 2026 10:31:09 +0300 Subject: [PATCH] chore: scope linting (#355) Scoped markdown-lint workflow to PR-changed Markdown targets. Adds target handling so metadata/generated-output validation runs against changed docs, while markdownlint/remark continue linting only non-raw changed Markdown files. This PR was made with help from Codex --- .github/workflows/markdown-lint.yml | 16 +++-- docs/messaging/raw/chat-framework.md | 1 + docs/messaging/raw/contentframe.md | 1 + docs/messaging/raw/introduction-encoding.md | 1 + docs/messaging/raw/privatev1.md | 1 + docs/messaging/raw/segmentation.md | 1 + scripts/target_args.py | 54 ++++++++++++++++ scripts/validate_generated_outputs.py | 69 ++++++++++++++++++--- scripts/validate_metadata.py | 61 ++++++++++++++---- scripts/validation_targets.py | 59 ++++++++++++++++++ 10 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 scripts/target_args.py create mode 100644 scripts/validation_targets.py diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml index aa7143d3..6ade54f6 100644 --- a/.github/workflows/markdown-lint.yml +++ b/.github/workflows/markdown-lint.yml @@ -28,11 +28,17 @@ jobs: BASE="${{ github.event.pull_request.base.sha }}" HEAD="${{ github.event.pull_request.head.sha }}" - python3 scripts/validate_metadata.py --check - python3 scripts/gen_rfc_index.py - python3 scripts/gen_summary.py - python3 scripts/validate_generated_outputs.py - + python3 scripts/validation_targets.py --base-sha "$BASE" --head-sha "$HEAD" --output .validation-targets.txt + + if [ -s .validation-targets.txt ]; then + python3 scripts/validate_metadata.py --check --targets-file .validation-targets.txt + python3 scripts/gen_rfc_index.py + python3 scripts/gen_summary.py + python3 scripts/validate_generated_outputs.py --targets-file .validation-targets.txt + else + echo "No markdown validation targets." + fi + python3 scripts/lint_targets.py --base-sha "$BASE" --head-sha "$HEAD" --output .lint-targets.txt if [ ! -s .lint-targets.txt ]; then diff --git a/docs/messaging/raw/chat-framework.md b/docs/messaging/raw/chat-framework.md index 47cf5879..550d2dcb 100644 --- a/docs/messaging/raw/chat-framework.md +++ b/docs/messaging/raw/chat-framework.md @@ -3,6 +3,7 @@ | Field | Value | | --- | --- | | Name | A modular framework for defining chat protocols | +| Slug | 239 | | Status | raw | | Type | RFC | | Category | Standards Track | diff --git a/docs/messaging/raw/contentframe.md b/docs/messaging/raw/contentframe.md index 7b4bf911..fefd7388 100644 --- a/docs/messaging/raw/contentframe.md +++ b/docs/messaging/raw/contentframe.md @@ -3,6 +3,7 @@ | Field | Value | | --- | --- | | Name | Chat Content Frames | +| Slug | 240 | | Status | raw | | Type | RFC | | Category | Standards Track | diff --git a/docs/messaging/raw/introduction-encoding.md b/docs/messaging/raw/introduction-encoding.md index 903d4c97..e4d7f9f0 100644 --- a/docs/messaging/raw/introduction-encoding.md +++ b/docs/messaging/raw/introduction-encoding.md @@ -3,6 +3,7 @@ | Field | Value | | --- | --- | | Name | introduction-bundle-encoding | +| Slug | 241 | | Status | raw | | Type | RFC | | Category | Standards Track | diff --git a/docs/messaging/raw/privatev1.md b/docs/messaging/raw/privatev1.md index 64ae0f2d..7ebd270f 100644 --- a/docs/messaging/raw/privatev1.md +++ b/docs/messaging/raw/privatev1.md @@ -3,6 +3,7 @@ | Field | Value | | --- | --- | | Name | Private conversation | +| Slug | 242 | | Status | raw | | Type | RFC | | Category | Standards Track | diff --git a/docs/messaging/raw/segmentation.md b/docs/messaging/raw/segmentation.md index 7de919f3..2c65d9d1 100644 --- a/docs/messaging/raw/segmentation.md +++ b/docs/messaging/raw/segmentation.md @@ -3,6 +3,7 @@ | Field | Value | | --- | --- | | Name | Message Segmentation and Reconstruction | +| Slug | 243 | | Version | 0.1 | | Status | raw | | Type | RFC | diff --git a/scripts/target_args.py b/scripts/target_args.py new file mode 100644 index 00000000..003532de --- /dev/null +++ b/scripts/target_args.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Shared command-line helpers for scripts that can run on selected targets.""" +from __future__ import annotations + +import argparse +from pathlib import Path + + +def add_target_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--target", + action="append", + default=[], + metavar="PATH", + help="Limit checks to this repository-relative path; may be repeated.", + ) + parser.add_argument( + "--targets-file", + metavar="PATH", + help="Read newline-separated repository-relative target paths from a file.", + ) + + +def load_target_paths(root: Path, args: argparse.Namespace) -> list[Path] | None: + raw_targets: list[str] = list(getattr(args, "target", []) or []) + targets_file = getattr(args, "targets_file", None) + + if targets_file: + target_file_path = root / targets_file + raw_targets.extend( + line.strip() + for line in target_file_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ) + + if not raw_targets: + return None + + root_resolved = root.resolve() + normalized: set[Path] = set() + for raw in raw_targets: + path = Path(raw) + if path.is_absolute(): + try: + rel = path.resolve().relative_to(root_resolved) + except ValueError as exc: + raise ValueError(f"target is outside repository: {raw}") from exc + else: + rel = path + if ".." in rel.parts: + raise ValueError(f"target must not contain '..': {raw}") + normalized.add(rel) + + return sorted(normalized, key=lambda p: p.as_posix()) diff --git a/scripts/validate_generated_outputs.py b/scripts/validate_generated_outputs.py index a42ccc6e..8f2930e8 100644 --- a/scripts/validate_generated_outputs.py +++ b/scripts/validate_generated_outputs.py @@ -6,12 +6,14 @@ Run this after `gen_rfc_index.py` and `gen_summary.py`. """ from __future__ import annotations +import argparse import json import re from pathlib import Path from typing import List, Tuple -from validate_metadata import DOCS, ROOT, discover_docs, read_doc +from target_args import add_target_args, load_target_paths +from validate_metadata import DOCS, ROOT, EXCLUDE_FILES, EXCLUDE_PARTS, discover_docs, read_doc SUMMARY = DOCS / "SUMMARY.md" INDEX = DOCS / "logos-lips.json" @@ -20,6 +22,31 @@ SUMMARY_AUXILIARY_PARTS = {"appendix", "appendices"} SUMMARY_LINK_RE = re.compile(r"\[(?:\\.|[^\]\\])+\]\(([^)]+\.md(?:#[^)]+)?)\)") +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + add_target_args(parser) + return parser.parse_args() + + +def target_spec_rels(targets: list[Path] | None) -> set[Path]: + if targets is None: + return set() + + rels: set[Path] = set() + for target in targets: + if len(target.parts) < 2 or target.parts[0] != "docs": + continue + docs_rel = Path(*target.parts[1:]) + if docs_rel.suffix.lower() != ".md": + continue + if docs_rel.name in EXCLUDE_FILES: + continue + if EXCLUDE_PARTS.intersection(docs_rel.parts): + continue + rels.add(docs_rel) + return rels + + def parse_summary_links() -> Tuple[set[Path], List[str]]: links: set[Path] = set() errors: List[str] = [] @@ -44,15 +71,27 @@ def parse_summary_links() -> Tuple[set[Path], List[str]]: return links, errors -def validate_summary_coverage() -> List[str]: +def validate_summary_coverage(targets: list[Path] | None = None) -> List[str]: linked_paths, errors = parse_summary_links() - expected_paths = {path.resolve() for path in discover_docs()} + expected_paths = {path.resolve() for path in discover_docs(targets)} missing = sorted(expected_paths - linked_paths) if missing: joined = ", ".join(str(path.relative_to(ROOT)) for path in missing) errors.append(f"{SUMMARY.relative_to(ROOT)} is missing spec link(s): {joined}") + if targets is not None: + deleted = sorted( + (DOCS / rel).resolve() + for rel in target_spec_rels(targets) + if not (DOCS / rel).exists() + ) + stale = [path for path in deleted if path in linked_paths] + if stale: + joined = ", ".join(str(path.relative_to(ROOT)) for path in stale) + errors.append(f"{SUMMARY.relative_to(ROOT)} still links deleted spec(s): {joined}") + return errors + extra = sorted( path for path in linked_paths - expected_paths @@ -66,7 +105,7 @@ def validate_summary_coverage() -> List[str]: return errors -def validate_index_coverage() -> List[str]: +def validate_index_coverage(targets: list[Path] | None = None) -> List[str]: if not INDEX.exists(): return [f"{INDEX.relative_to(ROOT)} is missing"] @@ -85,7 +124,7 @@ def validate_index_coverage() -> List[str]: } expected_paths = { read_doc(path).rel.relative_to("docs").with_suffix(".html").as_posix() - for path in discover_docs() + for path in discover_docs(targets) if not EXCLUDE_INDEX_PARTS.intersection(path.relative_to(ROOT).parts) } @@ -96,6 +135,20 @@ def validate_index_coverage() -> List[str]: f"{INDEX.relative_to(ROOT)} is missing spec path(s): {', '.join(missing)}" ) + if targets is not None: + deleted = sorted( + rel.with_suffix(".html").as_posix() + for rel in target_spec_rels(targets) + if not (DOCS / rel).exists() + and not EXCLUDE_INDEX_PARTS.intersection(("docs", *rel.parts)) + ) + stale = sorted(path for path in deleted if path in actual_paths) + if stale: + errors.append( + f"{INDEX.relative_to(ROOT)} still contains deleted spec path(s): {', '.join(stale)}" + ) + return errors + extra = sorted(actual_paths - expected_paths) if extra: errors.append( @@ -106,8 +159,10 @@ def validate_index_coverage() -> List[str]: def main() -> int: - errors = validate_summary_coverage() - errors.extend(validate_index_coverage()) + args = parse_args() + targets = load_target_paths(ROOT, args) + errors = validate_summary_coverage(targets) + errors.extend(validate_index_coverage(targets)) for error in errors: print(f"[ERROR] {error}") diff --git a/scripts/validate_metadata.py b/scripts/validate_metadata.py index 500aa31d..bef4711f 100755 --- a/scripts/validate_metadata.py +++ b/scripts/validate_metadata.py @@ -14,6 +14,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple +from target_args import add_target_args, load_target_paths + ROOT = Path(__file__).resolve().parent.parent DOCS = ROOT / "docs" @@ -75,17 +77,39 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Read-only mode; do not write missing slugs.", ) + add_target_args(parser) return parser.parse_args() -def discover_docs() -> List[Path]: +def is_discoverable_doc(path: Path) -> bool: + try: + rel = path.relative_to(DOCS) + except ValueError: + return False + if path.suffix.lower() != ".md": + return False + if path.name in EXCLUDE_FILES: + return False + if EXCLUDE_PARTS.intersection(rel.parts): + return False + return True + + +def discover_docs(targets: Optional[List[Path]] = None) -> List[Path]: + if targets is not None: + files = [] + for rel_target in targets: + path = ROOT / rel_target + if not path.exists() or not path.is_file(): + continue + if is_discoverable_doc(path): + files.append(path) + return sorted(set(files)) + files = [] for path in DOCS.rglob("*.md"): - if path.name in EXCLUDE_FILES: - continue - if EXCLUDE_PARTS.intersection(path.relative_to(DOCS).parts): - continue - files.append(path) + if is_discoverable_doc(path): + files.append(path) return sorted(files) @@ -221,12 +245,16 @@ def slug_needs_assignment(doc: DocInfo, seen: set[int]) -> bool: return False -def maybe_assign_slugs(docs: List[DocInfo], check_mode: bool) -> List[DocInfo]: +def maybe_assign_slugs( + docs: List[DocInfo], + check_mode: bool, + all_docs: Optional[List[DocInfo]] = None, +) -> List[DocInfo]: if check_mode: return [] changed: List[DocInfo] = [] - used = collect_used_numeric_slugs(docs) + used = collect_used_numeric_slugs(all_docs or docs) seen_unique: set[int] = set() for doc in docs: if not doc.table: @@ -322,7 +350,10 @@ def validate_doc(doc: DocInfo) -> None: ) -def validate_slug_uniqueness(docs: List[DocInfo]) -> List[str]: +def validate_slug_uniqueness( + docs: List[DocInfo], + scoped_to: Optional[set[Path]] = None, +) -> List[str]: # Allow duplicated slugs in archived previous-version snapshots. slug_map: Dict[int, List[Path]] = {} for doc in docs: @@ -339,6 +370,8 @@ def validate_slug_uniqueness(docs: List[DocInfo]) -> List[str]: for slug, paths in sorted(slug_map.items()): if len(paths) <= 1: continue + if scoped_to is not None and not scoped_to.intersection(paths): + continue joined = ", ".join(str(p) for p in paths) errors.append(f"duplicate slug {slug}: {joined}") return errors @@ -351,13 +384,17 @@ def write_if_changed(doc: DocInfo) -> None: def main() -> int: args = parse_args() - docs = [read_doc(path) for path in discover_docs()] + targets = load_target_paths(ROOT, args) + target_paths = discover_docs(targets) + docs = [read_doc(path) for path in target_paths] + all_docs = docs if targets is None else [read_doc(path) for path in discover_docs()] - changed = maybe_assign_slugs(docs, check_mode=args.check) + changed = maybe_assign_slugs(docs, check_mode=args.check, all_docs=all_docs) for doc in docs: validate_doc(doc) - global_errors = validate_slug_uniqueness(docs) + scoped_to = None if targets is None else {doc.rel for doc in docs} + global_errors = validate_slug_uniqueness(all_docs, scoped_to=scoped_to) if changed: for doc in changed: diff --git a/scripts/validation_targets.py b/scripts/validation_targets.py new file mode 100644 index 00000000..24b051f5 --- /dev/null +++ b/scripts/validation_targets.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""List changed Markdown files under docs/ for scoped validation.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from lint_targets import changed_files + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-sha", required=True, help="Base commit SHA") + parser.add_argument("--head-sha", required=True, help="Head commit SHA") + parser.add_argument("--output", help="Write targets to this file") + return parser.parse_args() + + +def log(message: str) -> None: + print(message, file=sys.stderr) + + +def validation_targets(base_sha: str, head_sha: str) -> list[str]: + targets = [] + for rel_path in changed_files(base_sha, head_sha): + path = Path(rel_path) + if not rel_path.startswith("docs/"): + continue + if path.suffix.lower() != ".md": + continue + targets.append(rel_path) + if path.exists(): + log(f"SELECT {rel_path}") + else: + log(f"SELECT {rel_path} (deleted or missing in working tree)") + + unique_targets = sorted(set(targets)) + log(f"Summary: selected={len(unique_targets)}") + return unique_targets + + +def main() -> int: + args = parse_args() + targets = validation_targets(args.base_sha, args.head_sha) + output = "\n".join(targets) + + if args.output: + Path(args.output).write_text((output + "\n") if output else "", encoding="utf-8") + log(f"Wrote {len(targets)} validation target(s) to {args.output}") + else: + print(output) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())