mirror of
https://github.com/logos-co/logos-lips.git
synced 2026-08-27 08:11:12 +00:00
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:
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | A modular framework for defining chat protocols |
|
||||
| Slug | 239 |
|
||||
| Status | raw |
|
||||
| Type | RFC |
|
||||
| Category | Standards Track |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Private conversation |
|
||||
| Slug | 242 |
|
||||
| Status | raw |
|
||||
| Type | RFC |
|
||||
| Category | Standards Track |
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Message Segmentation and Reconstruction |
|
||||
| Slug | 243 |
|
||||
| Version | 0.1 |
|
||||
| Status | raw |
|
||||
| Type | RFC |
|
||||
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user