repo-manager/src/repo_manager/standards.py
tegwick d103955217 feat: finish register receiving and authority routing
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-21 23:15:48 +02:00

277 lines
9.5 KiB
Python

"""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