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:
Thore Cimbal
2026-08-11 12:00:00 +00:00
co-authored by Claude Fable 5
parent 5e46372ea8
commit e36ed337a7
27 changed files with 2311 additions and 235 deletions
+157
View File
@@ -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())
+79
View File
@@ -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 (§15) 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())
+273
View File
@@ -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())