feat: slice 1 - the neckbeard framework chain runs end to end
Tracer bullet of the migration design (Gate 4, slice 1): pinned v0.1.1 baseline under docs/sources/upstream/ with provenance note, the Karpathy block moved verbatim to docs/sources/regelwerk/ (standing rule mapped onto the sources read-only mechanism), AGENTS.md assembled from the byte-true upstream sections plus the project section 6 (group rules condensed from the old CLAUDE.md), CLAUDE.md reduced to the upstream pointer, WORKFLOW.md and all four templates copied, schema.yaml extended (issue milestone/priority/status columns, component type, wiki area vision - all flagged in the header), validate.py and gen_status.py forked with marked extensions, pruefe_upstream_drift.py added, STATUS.md generated, CI gains the offline validate job, README directory link defused. Verified: validate 0 errors 0 warnings (the three pre-existing directory-link errors are gone), gen_status --check current, drift check 0 findings, baseline byte-identical to the reference checkout (10/10 files), four negative tests fire (WIP limit 3x in-progress, waiting without wartegrund, component slug mismatch, single-byte drift in WORKFLOW.md). gen_status needs Python >= 3.10 locally (write_text newline) - noted for the design AAR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5e46372ea8
commit
e36ed337a7
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gen_status.py — generate STATUS.md deterministically from frontmatter.
|
||||
|
||||
PROJEKTERWEITERUNG gegenüber neckbeard v0.1.1 (Original unter
|
||||
docs/sources/upstream/neckbeard-v0.1.1/scripts/): offene Issues sind
|
||||
alles außer done/rejected (Status-Enum ist projektweit erweitert);
|
||||
Tabelle zeigt Meilenstein/Priorität; Verteilungszeile je Meilenstein
|
||||
ersetzt die früheren Hand-Zählungen der roadmap.md (F-001/F-010).
|
||||
|
||||
Writes STATUS.md (no timestamps — output depends only on repo content, so
|
||||
reruns are diff-clean). With --check, regenerates in memory and fails if
|
||||
the committed STATUS.md is stale; CI uses this mode.
|
||||
|
||||
Usage:
|
||||
python scripts/gen_status.py [repo-root] # write STATUS.md
|
||||
python scripts/gen_status.py --check [repo-root] # verify, exit 1 if stale
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
sys.exit("gen_status.py needs PyYAML: pip install pyyaml")
|
||||
|
||||
H1_RE = re.compile(r"^#\s+(.*)$", re.M)
|
||||
|
||||
|
||||
def parse(path: Path):
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return None, ""
|
||||
for j in range(1, len(lines)):
|
||||
if lines[j].strip() == "---":
|
||||
meta = yaml.safe_load("\n".join(lines[1:j])) or {}
|
||||
body = "\n".join(lines[j + 1:])
|
||||
return meta, body
|
||||
return None, ""
|
||||
|
||||
|
||||
def title(body: str, fallback: str) -> str:
|
||||
match = H1_RE.search(body)
|
||||
return match.group(1).strip() if match else fallback
|
||||
|
||||
|
||||
def collect(root: Path, subdir: str, wanted_type: str):
|
||||
items = []
|
||||
base = root / subdir
|
||||
if not base.is_dir():
|
||||
return items
|
||||
for path in sorted(base.rglob("*.md")):
|
||||
if path.name == "template.md":
|
||||
continue
|
||||
meta, body = parse(path)
|
||||
if not isinstance(meta, dict) or meta.get("type") != wanted_type:
|
||||
continue
|
||||
rel = path.relative_to(root).as_posix()
|
||||
items.append((rel, meta, title(body, path.stem)))
|
||||
return items
|
||||
|
||||
|
||||
def render(root: Path) -> str:
|
||||
issues = collect(root, "docs/issues", "issue")
|
||||
designs = collect(root, "docs/design", "design")
|
||||
adrs = collect(root, "docs/adr", "adr")
|
||||
aars = collect(root, "docs/aar", "aar")
|
||||
|
||||
out: list[str] = []
|
||||
out.append("# STATUS")
|
||||
out.append("")
|
||||
out.append("<!-- Generated by scripts/gen_status.py — do not edit. -->")
|
||||
out.append("")
|
||||
|
||||
open_issues = [i for i in issues
|
||||
if i[1].get("status") not in ("done", "rejected")]
|
||||
closed = len(issues) - len(open_issues)
|
||||
out.append(f"## Issues ({len(open_issues)} open, {closed} closed)")
|
||||
out.append("")
|
||||
if open_issues:
|
||||
dist: dict[str, int] = {}
|
||||
for _rel, meta, _name in open_issues:
|
||||
m = str(meta.get("milestone", "?"))
|
||||
dist[m] = dist.get(m, 0) + 1
|
||||
out.append("Verteilung: " + " · ".join(
|
||||
f"{m} {n}" for m, n in sorted(dist.items())))
|
||||
out.append("")
|
||||
out.append("| Issue | Status | Meilenstein | Priorität | Title |")
|
||||
out.append("|---|---|---|---|---|")
|
||||
for rel, meta, name in open_issues:
|
||||
out.append(f"| [{meta.get('id', '?')}]({rel}) "
|
||||
f"| {meta.get('status')} | {meta.get('milestone')} "
|
||||
f"| {meta.get('priority')} | {name} |")
|
||||
else:
|
||||
out.append("_none open_")
|
||||
out.append("")
|
||||
|
||||
active = [d for d in designs if d[1].get("status") != "done"]
|
||||
out.append(f"## Active design docs ({len(active)})")
|
||||
out.append("")
|
||||
if active:
|
||||
out.append("| Design | Gate | Title |")
|
||||
out.append("|---|---|---|")
|
||||
for rel, meta, name in active:
|
||||
out.append(f"| [{Path(rel).stem}]({rel}) "
|
||||
f"| {meta.get('status')} | {name} |")
|
||||
else:
|
||||
out.append("_none active_")
|
||||
out.append("")
|
||||
|
||||
out.append(f"## ADRs ({len(adrs)})")
|
||||
out.append("")
|
||||
if adrs:
|
||||
out.append("| ADR | Status | Title |")
|
||||
out.append("|---|---|---|")
|
||||
for rel, meta, name in adrs:
|
||||
out.append(f"| [{meta.get('id', '?')}]({rel}) "
|
||||
f"| {meta.get('status')} | {name} |")
|
||||
else:
|
||||
out.append("_none_")
|
||||
out.append("")
|
||||
|
||||
open_aars = [a for a in aars if a[1].get("status") == "open"]
|
||||
out.append(f"## Open AARs ({len(open_aars)})")
|
||||
out.append("")
|
||||
if open_aars:
|
||||
for rel, _meta, name in open_aars:
|
||||
out.append(f"- [{name}]({rel})")
|
||||
else:
|
||||
out.append("_none — nothing awaiting harvest_")
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = [a for a in sys.argv[1:] if a != "--check"]
|
||||
check = "--check" in sys.argv[1:]
|
||||
root = Path(args[0]) if args else Path.cwd()
|
||||
content = render(root)
|
||||
status = root / "STATUS.md"
|
||||
if check:
|
||||
current = status.read_text(encoding="utf-8") if status.is_file() else ""
|
||||
if current != content:
|
||||
print("gen_status --check: STATUS.md is stale — "
|
||||
"run scripts/gen_status.py and commit the result")
|
||||
return 1
|
||||
print("gen_status --check: STATUS.md is current")
|
||||
return 0
|
||||
status.write_text(content, encoding="utf-8", newline="\n")
|
||||
print(f"wrote {status}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pruefe_upstream_drift.py — Byte-Vergleich gegen die gepinnte Baseline.
|
||||
|
||||
Schützt die übernommenen Framework-Dateien vor stillem Umschreiben
|
||||
(Entscheidung 8 im Design 2026-08-11, Frage von sorb: „wird die
|
||||
AGENTS.md ggf. durch Agenten umgeschrieben?"). Die Baseline liegt unter
|
||||
docs/sources/upstream/neckbeard-v0.1.1/ (siehe HERKUNFT.md dort); ein
|
||||
Framework-Upgrade aktualisiert Baseline und Arbeitskopie im selben,
|
||||
bewussten Commit.
|
||||
|
||||
Prüfungen (Fehler, Exit 1):
|
||||
* byte-identische Paare laut PAARE
|
||||
* AGENTS.md beginnt byte-identisch mit der Baseline-AGENTS.md und
|
||||
trägt direkt danach die Marke des Projektabschnitts
|
||||
Fehlt eine Baseline-Datei, ist das ein Fehler, kein Skip
|
||||
(Stillstandsprüfungs-Regel: eine Prüfung ohne Gegenseite ist ungeprüft).
|
||||
|
||||
Usage: python scripts/pruefe_upstream_drift.py [repo-root]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASELINE = "docs/sources/upstream/neckbeard-v0.1.1"
|
||||
MARKE = "<!-- projektabschnitt -->"
|
||||
|
||||
# (Arbeitskopie, Baseline-Datei) — byte-identisch
|
||||
PAARE = [
|
||||
("CLAUDE.md", "CLAUDE.md"),
|
||||
("WORKFLOW.md", "WORKFLOW.md"),
|
||||
("docs/adr/template.md", "templates/adr-template.md"),
|
||||
("docs/design/template.md", "templates/design-template.md"),
|
||||
("docs/aar/template.md", "templates/aar-template.md"),
|
||||
("docs/issues/template.md", "templates/issue-template.md"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
||||
base = root / BASELINE
|
||||
fehler: list[str] = []
|
||||
|
||||
for arbeit, original in PAARE:
|
||||
a, b = root / arbeit, base / original
|
||||
if not b.is_file():
|
||||
fehler.append(f"Baseline fehlt: {BASELINE}/{original}")
|
||||
continue
|
||||
if not a.is_file():
|
||||
fehler.append(f"Arbeitskopie fehlt: {arbeit}")
|
||||
continue
|
||||
if a.read_bytes() != b.read_bytes():
|
||||
fehler.append(f"DRIFT: {arbeit} weicht von {BASELINE}/{original} ab")
|
||||
|
||||
agents, agents_base = root / "AGENTS.md", base / "AGENTS.md"
|
||||
if not agents_base.is_file():
|
||||
fehler.append(f"Baseline fehlt: {BASELINE}/AGENTS.md")
|
||||
elif not agents.is_file():
|
||||
fehler.append("Arbeitskopie fehlt: AGENTS.md")
|
||||
else:
|
||||
upstream = agents_base.read_bytes()
|
||||
arbeit = agents.read_bytes()
|
||||
if not arbeit.startswith(upstream):
|
||||
fehler.append("DRIFT: AGENTS.md — Upstream-Teil (§1–5) ist "
|
||||
"nicht mehr byte-identisch mit der Baseline")
|
||||
else:
|
||||
rest = arbeit[len(upstream):].decode("utf-8", "replace")
|
||||
if MARKE not in rest.splitlines()[0:3]:
|
||||
fehler.append(f"AGENTS.md: Marke '{MARKE}' fehlt direkt "
|
||||
"nach dem Upstream-Teil")
|
||||
|
||||
for f in fehler:
|
||||
print(f"FEHLER {f}")
|
||||
print(f"pruefe_upstream_drift: {len(fehler)} Fehler")
|
||||
return 1 if fehler else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""validate.py — deterministic artifact validation against schema.yaml.
|
||||
|
||||
PROJEKTERWEITERUNG gegenüber neckbeard v0.1.1 (Original unter
|
||||
docs/sources/upstream/neckbeard-v0.1.1/scripts/): drei Regeln —
|
||||
waiting_requires_reason, slug_matches_filename (je Typ) und das globale
|
||||
WIP-Limit (max. 2 Issues in-progress, altes ADR-0005/F-014).
|
||||
|
||||
Checks (errors, exit 1):
|
||||
* frontmatter present, parseable, `type` known
|
||||
* file location and filename match the type's rules
|
||||
* required fields, enums, patterns, dates
|
||||
* link fields: repo-root-relative targets exist (http/https/mailto skipped)
|
||||
* inline markdown links in bodies resolve (relative to the file)
|
||||
* per-type rules: superseded_requires_pointer, done_iff_in_done_dir
|
||||
|
||||
Warnings (exit 0):
|
||||
* wiki pages (except index) with no inbound link anywhere
|
||||
|
||||
Usage: python scripts/validate.py [repo-root]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import fnmatch
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
sys.exit("validate.py needs PyYAML: pip install pyyaml")
|
||||
|
||||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
INLINE_LINK_RE = re.compile(r"\]\(([^)\s]+)\)")
|
||||
HTML_SRC_RE = re.compile(r"(?:src|srcset)=\"([^\"]+)\"")
|
||||
EXTERNAL_PREFIXES = ("http://", "https://", "mailto:")
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
def err(path: Path, msg: str) -> None:
|
||||
errors.append(f"ERROR {path}: {msg}")
|
||||
|
||||
|
||||
def warn(path: Path, msg: str) -> None:
|
||||
warnings.append(f"WARN {path}: {msg}")
|
||||
|
||||
|
||||
def parse_frontmatter(text: str):
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return None, text
|
||||
for j in range(1, len(lines)):
|
||||
if lines[j].strip() == "---":
|
||||
fm = "\n".join(lines[1:j])
|
||||
body = "\n".join(lines[j + 1:])
|
||||
return yaml.safe_load(fm) or {}, body
|
||||
return None, text # unterminated
|
||||
|
||||
|
||||
def is_date(value) -> bool:
|
||||
if isinstance(value, datetime.date):
|
||||
return True
|
||||
return isinstance(value, str) and bool(DATE_RE.match(value))
|
||||
|
||||
|
||||
def as_links(value):
|
||||
"""Normalize a link field's value to a list of strings."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [v for v in value if isinstance(v, str)]
|
||||
return None # wrong shape
|
||||
|
||||
|
||||
def discover(root: Path, scope: dict) -> list[Path]:
|
||||
files: set[Path] = set()
|
||||
for pattern in scope.get("include", []):
|
||||
files.update(root.glob(pattern))
|
||||
result = []
|
||||
for f in sorted(files):
|
||||
rel = f.relative_to(root).as_posix()
|
||||
if any(fnmatch.fnmatch(rel, pat) for pat in scope.get("exclude", [])):
|
||||
continue
|
||||
if f.is_file():
|
||||
result.append(f)
|
||||
return result
|
||||
|
||||
|
||||
def check_fields(path: Path, meta: dict, spec: dict, root: Path) -> None:
|
||||
for field in spec.get("required", []):
|
||||
if field not in meta or meta[field] is None:
|
||||
err(path, f"missing required field '{field}'")
|
||||
for field, rule in (spec.get("fields") or {}).items():
|
||||
if field not in meta:
|
||||
continue
|
||||
value = meta[field]
|
||||
if value is None:
|
||||
if not rule.get("nullable"):
|
||||
# required-check already covers required fields;
|
||||
# a present-but-null optional field is fine unless typed link
|
||||
pass
|
||||
continue
|
||||
if "enum" in rule and value not in rule["enum"]:
|
||||
err(path, f"'{field}: {value}' not in enum {rule['enum']}")
|
||||
if "pattern" in rule and not re.match(rule["pattern"], str(value)):
|
||||
err(path, f"'{field}: {value}' does not match {rule['pattern']}")
|
||||
kind = rule.get("kind")
|
||||
if kind == "date" and not is_date(value):
|
||||
err(path, f"'{field}: {value}' is not a YYYY-MM-DD date")
|
||||
if kind == "bool" and not isinstance(value, bool):
|
||||
err(path, f"'{field}: {value}' is not a boolean")
|
||||
if kind == "str" and not isinstance(value, str):
|
||||
err(path, f"'{field}' must be a string")
|
||||
|
||||
|
||||
def check_links(path: Path, meta: dict, link_fields: list, root: Path,
|
||||
inbound: set) -> None:
|
||||
for field in link_fields:
|
||||
if field not in meta:
|
||||
continue
|
||||
links = as_links(meta[field])
|
||||
if links is None:
|
||||
err(path, f"'{field}' must be a string or list of strings")
|
||||
continue
|
||||
for link in links:
|
||||
if link.startswith(EXTERNAL_PREFIXES):
|
||||
continue
|
||||
target = (root / link)
|
||||
if not target.is_file():
|
||||
err(path, f"'{field}' link target missing: {link}")
|
||||
else:
|
||||
inbound.add(target.resolve())
|
||||
|
||||
|
||||
def check_body_links(path: Path, body: str, root: Path, inbound: set) -> None:
|
||||
# strip fenced code blocks and inline code spans so mermaid, code
|
||||
# samples, and literal link examples in backticks aren't scanned
|
||||
body = re.sub(r"```.*?```", "", body, flags=re.S)
|
||||
body = re.sub(r"`[^`\n]*`", "", body)
|
||||
candidates = [m.group(1) for m in INLINE_LINK_RE.finditer(body)]
|
||||
for raw in (m.group(1) for m in HTML_SRC_RE.finditer(body)):
|
||||
# srcset may list "path 2x, path2 1x" pairs — take each path token
|
||||
for part in raw.split(","):
|
||||
candidates.append(part.strip().split()[0])
|
||||
for link in candidates:
|
||||
if link.startswith(EXTERNAL_PREFIXES) or link.startswith("#"):
|
||||
continue
|
||||
link = link.split("#", 1)[0]
|
||||
if not link:
|
||||
continue
|
||||
target = (path.parent / link).resolve()
|
||||
if not target.is_file():
|
||||
err(path, f"inline link target missing: {link}")
|
||||
else:
|
||||
inbound.add(target)
|
||||
|
||||
|
||||
def apply_rules(path: Path, rel: str, meta: dict, spec: dict) -> None:
|
||||
for rule in spec.get("rules", []):
|
||||
if rule == "superseded_requires_pointer":
|
||||
if meta.get("status") == "superseded" and not meta.get("superseded_by"):
|
||||
err(path, "status 'superseded' requires 'superseded_by'")
|
||||
elif rule == "waiting_requires_reason":
|
||||
if meta.get("status") == "waiting" and not meta.get("wartegrund"):
|
||||
err(path, "status 'waiting' requires 'wartegrund'")
|
||||
elif rule == "slug_matches_filename":
|
||||
if meta.get("slug") is not None and str(meta["slug"]) != path.stem:
|
||||
err(path, f"slug '{meta['slug']}' does not match filename")
|
||||
elif rule == "done_iff_in_done_dir":
|
||||
in_done = "/done/" in f"/{rel}"
|
||||
if (meta.get("status") == "done") != in_done:
|
||||
err(path, "status 'done' <-> file in docs/design/done/ mismatch")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
||||
schema = yaml.safe_load((root / "schema.yaml").read_text(encoding="utf-8"))
|
||||
link_fields = schema.get("link_fields", [])
|
||||
types = schema.get("types", {})
|
||||
inbound: set = set()
|
||||
wiki_pages: list[tuple[Path, dict]] = []
|
||||
|
||||
# Root documents: inline links must resolve; no frontmatter required.
|
||||
for rel in schema.get("scope", {}).get("link_only", []):
|
||||
path = root / rel
|
||||
if not path.is_file():
|
||||
continue # e.g. STATUS.md before first generation
|
||||
text = path.read_text(encoding="utf-8")
|
||||
_meta, body = parse_frontmatter(text)
|
||||
check_body_links(path, body if _meta is not None else text,
|
||||
root, inbound)
|
||||
seen_ids: dict[tuple[str, str], Path] = {}
|
||||
in_progress: list[Path] = []
|
||||
artifacts = discover(root, schema.get("scope", {}))
|
||||
|
||||
for path in artifacts:
|
||||
rel = path.relative_to(root).as_posix()
|
||||
meta, body = parse_frontmatter(path.read_text(encoding="utf-8"))
|
||||
if meta is None:
|
||||
err(path, "missing or unterminated YAML frontmatter")
|
||||
continue
|
||||
if not isinstance(meta, dict) or "type" not in meta:
|
||||
err(path, "frontmatter has no 'type'")
|
||||
continue
|
||||
t = meta["type"]
|
||||
if t not in types:
|
||||
err(path, f"unknown type '{t}'")
|
||||
continue
|
||||
spec = types[t]
|
||||
expected_dir = spec.get("dir", ".")
|
||||
actual_dir = str(Path(rel).parent.as_posix())
|
||||
if expected_dir == ".":
|
||||
if actual_dir != ".":
|
||||
err(path, f"type '{t}' must live in repo root")
|
||||
elif not (actual_dir == expected_dir
|
||||
or actual_dir.startswith(expected_dir + "/")):
|
||||
err(path, f"type '{t}' must live under {expected_dir}/")
|
||||
fn_pattern = spec.get("filename")
|
||||
if fn_pattern and not re.match(fn_pattern, path.name):
|
||||
err(path, f"filename does not match {fn_pattern}")
|
||||
check_fields(path, meta, spec, root)
|
||||
if "id" in (spec.get("fields") or {}) and meta.get("id") is not None:
|
||||
artifact_id = str(meta["id"])
|
||||
if not path.name.startswith(f"{artifact_id}-"):
|
||||
err(path, f"id '{artifact_id}' does not match filename prefix")
|
||||
key = (t, artifact_id)
|
||||
if key in seen_ids:
|
||||
err(path, f"duplicate {t} id '{artifact_id}' "
|
||||
f"(also in {seen_ids[key].name})")
|
||||
else:
|
||||
seen_ids[key] = path
|
||||
check_links(path, meta, link_fields, root, inbound)
|
||||
check_body_links(path, body, root, inbound)
|
||||
apply_rules(path, rel, meta, spec)
|
||||
if t == "wiki-page" and meta.get("area") != "index":
|
||||
wiki_pages.append((path, meta))
|
||||
if t == "issue" and meta.get("status") == "in-progress":
|
||||
in_progress.append(path)
|
||||
|
||||
# link-only files: inline links are checked, frontmatter not required
|
||||
already = {p.resolve() for p in artifacts}
|
||||
for pattern in schema.get("scope", {}).get("link_only", []):
|
||||
for path in sorted(root.glob(pattern)):
|
||||
if not path.is_file() or path.resolve() in already:
|
||||
continue
|
||||
meta, body = parse_frontmatter(path.read_text(encoding="utf-8"))
|
||||
if meta is None:
|
||||
body = path.read_text(encoding="utf-8")
|
||||
check_body_links(path, body, root, inbound)
|
||||
|
||||
if len(in_progress) > 2:
|
||||
names = ", ".join(p.name for p in in_progress)
|
||||
err(root / "docs/issues", f"WIP limit exceeded: "
|
||||
f"{len(in_progress)} issues in-progress (max 2): {names}")
|
||||
|
||||
for path, _meta in wiki_pages:
|
||||
if path.resolve() not in inbound:
|
||||
warn(path, "orphan wiki page — nothing links to it")
|
||||
|
||||
for line in errors + warnings:
|
||||
print(line)
|
||||
print(f"validate: {len(errors)} error(s), {len(warnings)} warning(s)")
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user