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
This commit is contained in:
fbarbu15
2026-06-11 10:31:09 +03:00
committed by GitHub
parent cd53cc013a
commit bb9d20bdef
10 changed files with 240 additions and 24 deletions
+11 -5
View File
@@ -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
+1
View File
@@ -3,6 +3,7 @@
| Field | Value |
| --- | --- |
| Name | A modular framework for defining chat protocols |
| Slug | 239 |
| Status | raw |
| Type | RFC |
| Category | Standards Track |
+1
View File
@@ -3,6 +3,7 @@
| Field | Value |
| --- | --- |
| Name | Chat Content Frames |
| Slug | 240 |
| Status | raw |
| Type | RFC |
| Category | Standards Track |
@@ -3,6 +3,7 @@
| Field | Value |
| --- | --- |
| Name | introduction-bundle-encoding |
| Slug | 241 |
| Status | raw |
| Type | RFC |
| Category | Standards Track |
+1
View File
@@ -3,6 +3,7 @@
| Field | Value |
| --- | --- |
| Name | Private conversation |
| Slug | 242 |
| Status | raw |
| Type | RFC |
| Category | Standards Track |
+1
View File
@@ -3,6 +3,7 @@
| Field | Value |
| --- | --- |
| Name | Message Segmentation and Reconstruction |
| Slug | 243 |
| Version | 0.1 |
| Status | raw |
| Type | RFC |
+54
View File
@@ -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())
+62 -7
View File
@@ -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}")
+49 -12
View File
@@ -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:
+59
View File
@@ -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())