"""Repository standards as Repo Manager implements Custodian canon. Canon remains authoritative. This module does not fork the rules; it names the signals, required files, and anti-patterns the canon already states. Sources: - the-custodian/canon/standards/project-repository-flavor_v0.1.md - the-custodian/canon/standards/repo-classification-standard_v1.0.md - ADR-007 decision 1 (prefix from repo/project identity, never flavor) """ from __future__ import annotations import re from dataclasses import dataclass, field from pathlib import Path from typing import Any, Literal from repo_manager.observe import load_classification from repo_manager.parse.workplan import parse_frontmatter CANON_FLAVOR = "the-custodian/canon/standards/project-repository-flavor_v0.1.md" CANON_CLASSIFICATION = "the-custodian/canon/standards/repo-classification-standard_v1.0.md" DURABLE_FLAVORS = ("experimental", "research", "tooling", "product", "business") CATEGORIES = (*DURABLE_FLAVORS, "project") FLAVOR_MARKER_PREFIX = "PRJ-WP" WP_ID_RE = re.compile(r"^([A-Z][A-Z0-9]*)-WP-(\d{4})(?:-T\d{2})?$") GOAL_SECTIONS = ("outcome", "invariants", "success gates", "project retirement") Severity = Literal["missing", "contradictory", "warning"] @dataclass class Finding: code: str severity: Severity path: str message: str canon: str @dataclass class FlavorResolution: flavor: str prj_layout: bool signals: dict[str, str | None] disagreements: list[str] @dataclass class ConformanceReport: slug: str flavor: FlavorResolution findings: list[Finding] = field(default_factory=list) @property def ok(self) -> bool: return not any(f.severity in {"missing", "contradictory"} for f in self.findings) def to_dict(self) -> dict[str, Any]: return { "ok": self.ok, "slug": self.slug, "flavor": self.flavor.flavor, "prj_layout": self.flavor.prj_layout, "signals": self.flavor.signals, "disagreements": self.flavor.disagreements, "findings": [f.__dict__ for f in self.findings], "canon": [CANON_FLAVOR, CANON_CLASSIFICATION], } def _slug(repo_root: Path) -> str: return re.sub(r"[^a-z0-9]+", "-", repo_root.name.lower()).strip("-") or "repo" def _goal_flavor(repo_root: Path) -> str | None: path = repo_root / "GOAL.md" if not path.is_file(): return None fm, _ = parse_frontmatter(path.read_text(encoding="utf-8")) raw = fm.get("repo_flavor") return str(raw).strip().lower() if raw else None def resolve_flavor(repo_root: Path, *, slug: str | None = None) -> FlavorResolution: """Precedence: classification category, GOAL.md repo_flavor, slug prefix.""" slug = slug or _slug(repo_root) classification = load_classification(repo_root) or {} category = classification.get("category") category = str(category).strip().lower() if category else None goal_flavor = _goal_flavor(repo_root) slug_prj = slug.startswith("prj-") signals = { "classification.category": category, "GOAL.md.repo_flavor": goal_flavor, "slug_prefix": "prj-" if slug_prj else None, } if category in CATEGORIES: flavor = category elif goal_flavor in CATEGORIES: flavor = goal_flavor elif slug_prj: flavor = "project" else: flavor = "tooling" disagreements: list[str] = [] if category and goal_flavor and category != goal_flavor: disagreements.append( f"classification.category={category!r} disagrees with GOAL.md repo_flavor={goal_flavor!r}" ) if slug_prj and flavor != "project": disagreements.append(f"slug is prj-* but resolved flavor is {flavor!r}") if category == "project" and not slug_prj and goal_flavor != "project": disagreements.append( "category is project but slug is not prj-*; not every category:project is a prj- repo" ) prj_layout = slug_prj or goal_flavor == "project" return FlavorResolution( flavor=flavor, prj_layout=prj_layout, signals=signals, disagreements=disagreements, ) def required_files(flavor: FlavorResolution) -> tuple[str, ...]: common = ("README.md", "SCOPE.md", "AGENTS.md", ".repo-classification.yaml", "workplans") if flavor.prj_layout: return ("GOAL.md", *common) return ("INTENT.md", *common) def purpose_document(flavor: FlavorResolution) -> str: return "GOAL.md" if flavor.prj_layout else "INTENT.md" def expected_workplan_prefix(repo_root: Path, *, slug: str | None = None) -> str | None: """Prefix must come from repo/project identity, never the flavor marker.""" slug = slug or _slug(repo_root) if slug.startswith("prj-"): stem = slug.removeprefix("prj-") letters = re.sub(r"[^a-z0-9]", "", stem) if letters: return letters[:8].upper() + "-WP" return None compact = re.sub(r"[^a-z0-9]", "", slug) if not compact: return None return compact[:8].upper() + "-WP" def workplan_ids(repo_root: Path) -> list[tuple[str, str, str]]: """Return (path, prefix, number) for each PREFIX-WP-NNNN workplan id.""" found: list[tuple[str, str, str]] = [] wp_dir = repo_root / "workplans" if not wp_dir.is_dir(): return found for path in sorted(wp_dir.rglob("*.md")): if path.name.startswith("ADHOC-"): continue fm, _ = parse_frontmatter(path.read_text(encoding="utf-8")) ident = fm.get("id") if ident is None: continue match = WP_ID_RE.match(str(ident).strip()) if not match: continue found.append((str(path.relative_to(repo_root)), match.group(1) + "-WP", match.group(2))) return found def check_repository(repo_root: Path, *, slug: str | None = None) -> ConformanceReport: repo_root = repo_root.resolve() slug = slug or _slug(repo_root) flavor = resolve_flavor(repo_root, slug=slug) report = ConformanceReport(slug=slug, flavor=flavor) for note in flavor.disagreements: report.findings.append( Finding( code="flavor-signal-disagreement", severity="warning", path=".repo-classification.yaml", message=note, canon=CANON_CLASSIFICATION, ) ) for rel in required_files(flavor): target = repo_root / rel present = target.is_dir() if rel.endswith("/") or rel == "workplans" else target.is_file() if not present: report.findings.append( Finding( code="required-file-missing", severity="missing", path=rel, message=f"{rel} is required for flavor {flavor.flavor}", canon=CANON_FLAVOR if flavor.prj_layout else CANON_CLASSIFICATION, ) ) has_intent = (repo_root / "INTENT.md").is_file() has_goal = (repo_root / "GOAL.md").is_file() if flavor.prj_layout and has_intent and has_goal: report.findings.append( Finding( code="intent-and-goal", severity="contradictory", path="INTENT.md", message="prj- flavor forbids shipping both INTENT.md and GOAL.md", canon=CANON_FLAVOR, ) ) if flavor.prj_layout and has_intent and not has_goal: report.findings.append( Finding( code="intent-instead-of-goal", severity="contradictory", path="INTENT.md", message="prj- flavor requires GOAL.md and must not use INTENT.md as the purpose document", canon=CANON_FLAVOR, ) ) if not flavor.prj_layout and has_intent and has_goal: report.findings.append( Finding( code="intent-and-goal", severity="warning", path="GOAL.md", message="durable flavors use INTENT.md; GOAL.md is the project-flavor purpose document", canon=CANON_FLAVOR, ) ) if flavor.prj_layout and has_goal: body = (repo_root / "GOAL.md").read_text(encoding="utf-8").lower() missing = [name for name in GOAL_SECTIONS if name not in body] if missing: report.findings.append( Finding( code="goal-sections-missing", severity="missing", path="GOAL.md", message="GOAL.md must include " + ", ".join(GOAL_SECTIONS) + f"; missing {missing}", canon=CANON_FLAVOR, ) ) ids = workplan_ids(repo_root) prefixes = {prefix for _path, prefix, _num in ids} if FLAVOR_MARKER_PREFIX in prefixes: report.findings.append( Finding( code="flavor-derived-prefix", severity="contradictory", path="workplans/", message="PRJ-WP- is derived from the flavor marker; prefixes must come from project identity", canon=CANON_FLAVOR, ) ) if len(prefixes) > 1: report.findings.append( Finding( code="multiple-prefixes", severity="warning", path="workplans/", message=f"repository uses more than one workplan prefix: {sorted(prefixes)}", canon="ADR-007", ) ) return report