feat: model repo standards and detect prefix collisions
Implement RMGR-WP-0004 T01 (flavor, required files, anti-patterns from canon) and T08 (prefix ownership registry plus uniqueness scan). rmgr conform and rmgr prefix-uniqueness are detection only.
This commit is contained in:
parent
106d7d5b2b
commit
04145a59d9
8 changed files with 640 additions and 4 deletions
|
|
@ -25,6 +25,7 @@ Architecture:
|
||||||
- [State Hub extraction inventory v0.1](docs/state-hub-extraction-inventory_v0.1.md)
|
- [State Hub extraction inventory v0.1](docs/state-hub-extraction-inventory_v0.1.md)
|
||||||
- [ADR-001 Implementation foundation](docs/adr-001-implementation-foundation.md)
|
- [ADR-001 Implementation foundation](docs/adr-001-implementation-foundation.md)
|
||||||
- [Railiance app deployment guide](docs/RailianceAppDeploymentGuide.md) (`RMGR-WP-0006`)
|
- [Railiance app deployment guide](docs/RailianceAppDeploymentGuide.md) (`RMGR-WP-0006`)
|
||||||
|
- [Repository standards v0.1](docs/repository-standards_v0.1.md) (`RMGR-WP-0004`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make install # or: uv pip install -e ".[dev]"
|
make install # or: uv pip install -e ".[dev]"
|
||||||
|
|
@ -37,6 +38,8 @@ rmgr rapp wrap --path ../rapp-some-app --app some-app \
|
||||||
--ownership-repo some-app --from-app ../some-app
|
--ownership-repo some-app --from-app ../some-app
|
||||||
rmgr rapp validate --path ../rapp-user-engine --family-root ..
|
rmgr rapp validate --path ../rapp-user-engine --family-root ..
|
||||||
rmgr rapp place --path ../rapp-some-app --reef reef-railiance
|
rmgr rapp place --path ../rapp-some-app --reef reef-railiance
|
||||||
|
rmgr conform --path .
|
||||||
|
rmgr prefix-uniqueness --root ..
|
||||||
```
|
```
|
||||||
|
|
||||||
Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md).
|
Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md).
|
||||||
|
|
|
||||||
53
config/workplan-prefix-registry.yaml
Normal file
53
config/workplan-prefix-registry.yaml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# RMGR-WP-0004-T08 / ADR-007 decision 1
|
||||||
|
# One prefix, one repository. Prefixes derive from repo or project identity,
|
||||||
|
# never from a flavor marker or family name. Detection only — historical
|
||||||
|
# collisions stay until ADR-007 § Migration is ruled.
|
||||||
|
|
||||||
|
standard: workplan-prefix-ownership
|
||||||
|
version: "0.1"
|
||||||
|
canon:
|
||||||
|
- the-custodian/canon/architecture/adr-007-workplan-identity-and-repo-worker-topology.md
|
||||||
|
- the-custodian/canon/standards/project-repository-flavor_v0.1.md
|
||||||
|
|
||||||
|
retired:
|
||||||
|
- prefix: PRJ-WP
|
||||||
|
reason: flavor marker; never a valid prefix
|
||||||
|
- prefix: RAIL-BS-WP
|
||||||
|
reason: shared family name, not a repository
|
||||||
|
successors: [RCLUSTER-WP, RBS-WP]
|
||||||
|
- prefix: RAILIANCE-WP
|
||||||
|
reason: family name, not a repository
|
||||||
|
successors: [RPF-WP, RAPPS-WP, RFORGE-WP, RTELE-WP]
|
||||||
|
|
||||||
|
owners:
|
||||||
|
RMGR-WP: repo-manager
|
||||||
|
CUST-WP: the-custodian
|
||||||
|
STATE-WP: state-hub
|
||||||
|
CFED-WP: prj-canon-federation
|
||||||
|
SHR-WP: prj-state-hub-retirement
|
||||||
|
RCLUSTER-WP: railiance-cluster
|
||||||
|
RBS-WP: railiance-bootstrap
|
||||||
|
RPF-WP: railiance-platform
|
||||||
|
RAPPS-WP: railiance-apps
|
||||||
|
RFORGE-WP: railiance-forge
|
||||||
|
RTELE-WP: railiance-telemetry
|
||||||
|
RMASTER-WP: railiance-master
|
||||||
|
RAIL-K8S-WP: rail-kubernetes
|
||||||
|
RAPP-OPENBAO-WP: rapp-openbao
|
||||||
|
RAPP-USER-ENGINE-WP: rapp-user-engine
|
||||||
|
RAPP-TENANT-ENGINE-WP: rapp-tenant-engine
|
||||||
|
RAPP-POLICY-NEXUS-WP: rapp-policy-nexus
|
||||||
|
USER-WP: user-engine
|
||||||
|
TEN-WP: tenant-engine
|
||||||
|
|
||||||
|
historical_collisions:
|
||||||
|
- id: RAILIANCE-WP-0015
|
||||||
|
repos: [railiance-platform, railiance-apps]
|
||||||
|
- id: RAILIANCE-WP-0016
|
||||||
|
repos: [railiance-platform, railiance-apps]
|
||||||
|
- id: CUST-WP-0000
|
||||||
|
repos: [the-custodian]
|
||||||
|
- id: CUST-WP-0010
|
||||||
|
repos: [the-custodian]
|
||||||
|
- id: CUST-WP-0045
|
||||||
|
repos: [the-custodian]
|
||||||
35
docs/repository-standards_v0.1.md
Normal file
35
docs/repository-standards_v0.1.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Repository standards v0.1
|
||||||
|
|
||||||
|
Repo Manager **implements** Custodian canon. It does not author a second
|
||||||
|
definition. This note is an index.
|
||||||
|
|
||||||
|
| Concern | Canon |
|
||||||
|
| --- | --- |
|
||||||
|
| Categories and `.repo-classification.yaml` | `the-custodian/canon/standards/repo-classification-standard_v1.0.md` |
|
||||||
|
| `prj-` layout, `GOAL.md`, anti-pattern both purpose docs | `the-custodian/canon/standards/project-repository-flavor_v0.1.md` |
|
||||||
|
| One workplan prefix per repository | `the-custodian/canon/architecture/adr-007-workplan-identity-and-repo-worker-topology.md` |
|
||||||
|
|
||||||
|
## Flavor resolution
|
||||||
|
|
||||||
|
Precedence, then warn on disagreement (do not silently pick a winner for
|
||||||
|
operators — the first match is used only to choose the required-file set):
|
||||||
|
|
||||||
|
1. `.repo-classification.yaml` `category`
|
||||||
|
2. `GOAL.md` `repo_flavor`
|
||||||
|
3. slug prefix `prj-`
|
||||||
|
|
||||||
|
`prj-` layout (`GOAL.md`, no `INTENT.md`) applies when the slug starts
|
||||||
|
with `prj-` or `GOAL.md` declares `repo_flavor: project`. Not every
|
||||||
|
`category: project` repo is a `prj-` repo.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rmgr conform --path .
|
||||||
|
rmgr prefix-uniqueness --root ..
|
||||||
|
```
|
||||||
|
|
||||||
|
`prefix-uniqueness` is detection only. It does not rename files.
|
||||||
|
Registry: [`config/workplan-prefix-registry.yaml`](../config/workplan-prefix-registry.yaml).
|
||||||
|
|
||||||
|
Work: `RMGR-WP-0004-T01`, `RMGR-WP-0004-T08`.
|
||||||
|
|
@ -64,6 +64,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
add_rapp_parser(sub)
|
add_rapp_parser(sub)
|
||||||
|
|
||||||
|
p_conf = sub.add_parser("conform", help="Check a repository against flavor standards")
|
||||||
|
p_conf.add_argument("--path", default=".", help="Repository checkout path")
|
||||||
|
p_conf.add_argument("--slug", default=None)
|
||||||
|
|
||||||
|
p_pref = sub.add_parser(
|
||||||
|
"prefix-uniqueness",
|
||||||
|
help="Detect shared workplan prefixes and reused identifiers (ADR-007)",
|
||||||
|
)
|
||||||
|
p_pref.add_argument("--root", default=".", help="Fleet root or a single repository")
|
||||||
|
p_pref.add_argument("--registry", default=None, help="Override prefix registry YAML")
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.version or args.command in (None, "version"):
|
if args.version or args.command in (None, "version"):
|
||||||
|
|
@ -188,6 +199,21 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
print(json.dumps(result, indent=2))
|
print(json.dumps(result, indent=2))
|
||||||
return 0 if result.get("ok") else 1
|
return 0 if result.get("ok") else 1
|
||||||
|
|
||||||
|
if args.command == "conform":
|
||||||
|
from repo_manager.standards import check_repository
|
||||||
|
|
||||||
|
report = check_repository(Path(args.path), slug=args.slug)
|
||||||
|
print(json.dumps(report.to_dict(), indent=2))
|
||||||
|
return 0 if report.ok else 1
|
||||||
|
|
||||||
|
if args.command == "prefix-uniqueness":
|
||||||
|
from repo_manager.prefix_registry import scan_prefixes
|
||||||
|
|
||||||
|
registry = Path(args.registry) if args.registry else None
|
||||||
|
report = scan_prefixes(Path(args.root), registry_path=registry)
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
|
return 0 if report.get("ok") else 1
|
||||||
|
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
|
||||||
112
src/repo_manager/prefix_registry.py
Normal file
112
src/repo_manager/prefix_registry.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Fleet workplan-prefix ownership and uniqueness (RMGR-WP-0004-T08)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from repo_manager.standards import FLAVOR_MARKER_PREFIX, workplan_ids
|
||||||
|
|
||||||
|
DEFAULT_REGISTRY = Path(__file__).resolve().parents[2] / "config" / "workplan-prefix-registry.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def load_registry(path: Path | None = None) -> dict[str, Any]:
|
||||||
|
target = path or DEFAULT_REGISTRY
|
||||||
|
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_dir(name: str) -> bool:
|
||||||
|
return name.startswith(".") or name in {
|
||||||
|
"node_modules",
|
||||||
|
"__pycache__",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"archive",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_repo_roots(root: Path) -> list[Path]:
|
||||||
|
root = root.resolve()
|
||||||
|
if (root / "workplans").is_dir() and (root / ".git").exists():
|
||||||
|
return [root]
|
||||||
|
found: list[Path] = []
|
||||||
|
if not root.is_dir():
|
||||||
|
return found
|
||||||
|
for child in sorted(root.iterdir()):
|
||||||
|
if not child.is_dir() or _skip_dir(child.name):
|
||||||
|
continue
|
||||||
|
if (child / "workplans").is_dir():
|
||||||
|
found.append(child)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def scan_prefixes(root: Path, *, registry_path: Path | None = None) -> dict[str, Any]:
|
||||||
|
"""Detect prefix sharing, identifier reuse, flavor-derived prefixes, number reuse.
|
||||||
|
|
||||||
|
Detection only. Does not rewrite files or remediations.
|
||||||
|
"""
|
||||||
|
registry = load_registry(registry_path)
|
||||||
|
owners = {str(k): str(v) for k, v in (registry.get("owners") or {}).items()}
|
||||||
|
retired = {str(item.get("prefix")) for item in registry.get("retired") or [] if item.get("prefix")}
|
||||||
|
|
||||||
|
by_prefix: dict[str, set[str]] = defaultdict(set)
|
||||||
|
by_id: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||||
|
numbers: dict[tuple[str, str], list[str]] = defaultdict(list)
|
||||||
|
|
||||||
|
repos_scanned = []
|
||||||
|
for repo in iter_repo_roots(root):
|
||||||
|
repos_scanned.append(repo.name)
|
||||||
|
for rel, prefix, number in workplan_ids(repo):
|
||||||
|
by_prefix[prefix].add(repo.name)
|
||||||
|
ident = f"{prefix}-{number}"
|
||||||
|
by_id[ident].append((repo.name, rel))
|
||||||
|
numbers[(repo.name, prefix)].append(number)
|
||||||
|
|
||||||
|
shared = {
|
||||||
|
prefix: sorted(repos)
|
||||||
|
for prefix, repos in sorted(by_prefix.items())
|
||||||
|
if len(repos) > 1
|
||||||
|
}
|
||||||
|
reused = {
|
||||||
|
ident: [{"repo": repo, "path": path} for repo, path in entries]
|
||||||
|
for ident, entries in sorted(by_id.items())
|
||||||
|
if len({repo for repo, _path in entries}) > 1 or len(entries) > 1
|
||||||
|
}
|
||||||
|
flavor_derived = sorted(repo for repo in by_prefix.get(FLAVOR_MARKER_PREFIX, []))
|
||||||
|
retired_in_use = {
|
||||||
|
prefix: sorted(repos)
|
||||||
|
for prefix, repos in by_prefix.items()
|
||||||
|
if prefix in retired
|
||||||
|
}
|
||||||
|
owner_mismatch = {
|
||||||
|
prefix: {"declared_owner": owners[prefix], "seen_in": sorted(repos)}
|
||||||
|
for prefix, repos in by_prefix.items()
|
||||||
|
if prefix in owners and any(repo != owners[prefix] for repo in repos)
|
||||||
|
}
|
||||||
|
|
||||||
|
reused_numbers = []
|
||||||
|
for (repo, prefix), nums in numbers.items():
|
||||||
|
seen: set[str] = set()
|
||||||
|
for num in nums:
|
||||||
|
if num in seen:
|
||||||
|
reused_numbers.append({"repo": repo, "prefix": prefix, "number": num})
|
||||||
|
seen.add(num)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": not shared and not flavor_derived and not reused_numbers and not reused,
|
||||||
|
"root": str(root.resolve()),
|
||||||
|
"repos_scanned": repos_scanned,
|
||||||
|
"shared_prefixes": shared,
|
||||||
|
"reused_identifiers": reused,
|
||||||
|
"flavor_derived_prefix": flavor_derived,
|
||||||
|
"retired_in_use": retired_in_use,
|
||||||
|
"owner_mismatch": owner_mismatch,
|
||||||
|
"reused_numbers_in_repo": reused_numbers,
|
||||||
|
"detection_only": True,
|
||||||
|
}
|
||||||
279
src/repo_manager/standards.py
Normal file
279
src/repo_manager/standards.py
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
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
|
||||||
112
tests/test_standards.py
Normal file
112
tests/test_standards.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from repo_manager.cli import main
|
||||||
|
from repo_manager.prefix_registry import scan_prefixes
|
||||||
|
from repo_manager.standards import check_repository, resolve_flavor
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: Path, text: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _durable_files(root: Path) -> None:
|
||||||
|
_write(root / "INTENT.md", "# Intent\n\nWhy this exists.\n")
|
||||||
|
_write(root / "SCOPE.md", "# Scope\n")
|
||||||
|
_write(root / "README.md", "# Demo\n")
|
||||||
|
_write(root / "AGENTS.md", "# Agents\n")
|
||||||
|
_write(
|
||||||
|
root / ".repo-classification.yaml",
|
||||||
|
"repo_classification:\n category: tooling\n domain: infotech\n",
|
||||||
|
)
|
||||||
|
_write(
|
||||||
|
root / "workplans" / "DEMO-WP-0001.md",
|
||||||
|
"---\nid: DEMO-WP-0001\nstatus: proposed\n---\n# Demo\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prj_repo_flags_intent_and_goal(tmp_path: Path):
|
||||||
|
root = tmp_path / "prj-demo"
|
||||||
|
_durable_files(root)
|
||||||
|
_write(
|
||||||
|
root / "GOAL.md",
|
||||||
|
"---\nrepo_flavor: project\n---\n# Goal\n\n"
|
||||||
|
"## Outcome\nX\n## Invariants\nY\n## Success gates\nZ\n## Project retirement\nW\n",
|
||||||
|
)
|
||||||
|
_write(
|
||||||
|
root / ".repo-classification.yaml",
|
||||||
|
"repo_classification:\n category: project\n domain: infotech\n",
|
||||||
|
)
|
||||||
|
report = check_repository(root)
|
||||||
|
codes = {f.code for f in report.findings}
|
||||||
|
assert "intent-and-goal" in codes
|
||||||
|
assert report.ok is False
|
||||||
|
assert report.flavor.prj_layout is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_prj_repo_flags_flavor_derived_prefix(tmp_path: Path):
|
||||||
|
root = tmp_path / "prj-cutover"
|
||||||
|
_write(root / "GOAL.md", "## Outcome\n## Invariants\n## Success gates\n## Project retirement\n")
|
||||||
|
_write(root / "SCOPE.md", "# S\n")
|
||||||
|
_write(root / "README.md", "# R\n")
|
||||||
|
_write(root / "AGENTS.md", "# A\n")
|
||||||
|
_write(
|
||||||
|
root / ".repo-classification.yaml",
|
||||||
|
"repo_classification:\n category: project\n domain: infotech\n",
|
||||||
|
)
|
||||||
|
_write(
|
||||||
|
root / "workplans" / "PRJ-WP-0001.md",
|
||||||
|
"---\nid: PRJ-WP-0001\nstatus: proposed\n---\n# Bad\n",
|
||||||
|
)
|
||||||
|
report = check_repository(root)
|
||||||
|
assert any(f.code == "flavor-derived-prefix" for f in report.findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_durable_repo_missing_intent(tmp_path: Path):
|
||||||
|
root = tmp_path / "some-tool"
|
||||||
|
_write(root / "SCOPE.md", "# S\n")
|
||||||
|
_write(root / "README.md", "# R\n")
|
||||||
|
_write(root / "AGENTS.md", "# A\n")
|
||||||
|
_write(
|
||||||
|
root / ".repo-classification.yaml",
|
||||||
|
"repo_classification:\n category: tooling\n domain: infotech\n",
|
||||||
|
)
|
||||||
|
(root / "workplans").mkdir()
|
||||||
|
report = check_repository(root)
|
||||||
|
assert any(f.code == "required-file-missing" and f.path == "INTENT.md" for f in report.findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flavor_disagreement_is_warning(tmp_path: Path):
|
||||||
|
root = tmp_path / "mixed"
|
||||||
|
_durable_files(root)
|
||||||
|
_write(root / "GOAL.md", "---\nrepo_flavor: project\n---\n# G\n")
|
||||||
|
flavor = resolve_flavor(root)
|
||||||
|
assert flavor.disagreements
|
||||||
|
report = check_repository(root)
|
||||||
|
assert any(f.code == "flavor-signal-disagreement" and f.severity == "warning" for f in report.findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefix_uniqueness_detects_share_and_reuse(tmp_path: Path):
|
||||||
|
a = tmp_path / "alpha"
|
||||||
|
b = tmp_path / "beta"
|
||||||
|
for repo, ident in ((a, "SHARED-WP-0001"), (b, "SHARED-WP-0001")):
|
||||||
|
_write(
|
||||||
|
repo / "workplans" / f"{ident}.md",
|
||||||
|
f"---\nid: {ident}\nstatus: finished\n---\n# X\n",
|
||||||
|
)
|
||||||
|
report = scan_prefixes(tmp_path)
|
||||||
|
assert report["ok"] is False
|
||||||
|
assert "SHARED-WP" in report["shared_prefixes"]
|
||||||
|
assert "SHARED-WP-0001" in report["reused_identifiers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_conform_and_prefix(tmp_path: Path, capsys):
|
||||||
|
root = tmp_path / "ok-tool"
|
||||||
|
_durable_files(root)
|
||||||
|
assert main(["conform", "--path", str(root)]) == 0
|
||||||
|
fleet = tmp_path / "fleet"
|
||||||
|
one = fleet / "one"
|
||||||
|
_write(one / "workplans" / "PRJ-WP-0001.md", "---\nid: PRJ-WP-0001\nstatus: proposed\n---\n")
|
||||||
|
assert main(["prefix-uniqueness", "--root", str(fleet)]) == 1
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "PRJ-WP" in out or "flavor_derived" in out
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
created: "2026-08-16"
|
created: "2026-08-16"
|
||||||
updated: "2026-08-16"
|
updated: "2026-08-18"
|
||||||
parent_project: prj-state-hub-retirement
|
parent_project: prj-state-hub-retirement
|
||||||
parent_workplan: SHR-WP-0001
|
parent_workplan: SHR-WP-0001
|
||||||
related:
|
related:
|
||||||
|
|
@ -54,7 +54,7 @@ claims "repository registration, identity, classification, and lifecycle" and
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0004-T01
|
id: RMGR-WP-0004-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "dd2db30f-c48f-404d-a1b4-bd75b41d42f4"
|
state_hub_task_id: "dd2db30f-c48f-404d-a1b4-bd75b41d42f4"
|
||||||
```
|
```
|
||||||
|
|
@ -77,11 +77,16 @@ Source standards: `project-repository-flavor_v0.1.md` and
|
||||||
authoritative; Repo Manager implements against it and must not fork the rules
|
authoritative; Repo Manager implements against it and must not fork the rules
|
||||||
into a second definition.
|
into a second definition.
|
||||||
|
|
||||||
|
**Result (2026-08-18):** `src/repo_manager/standards.py` resolves flavor
|
||||||
|
(classification → GOAL.md → `prj-` slug), lists required files, and names
|
||||||
|
anti-patterns (`INTENT.md`+`GOAL.md`, `PRJ-WP-`). Index:
|
||||||
|
`docs/repository-standards_v0.1.md`.
|
||||||
|
|
||||||
## Implement conformance checking
|
## Implement conformance checking
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0004-T02
|
id: RMGR-WP-0004-T02
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "2c9b0cb1-f0f0-40f7-83ef-1828298338d3"
|
state_hub_task_id: "2c9b0cb1-f0f0-40f7-83ef-1828298338d3"
|
||||||
```
|
```
|
||||||
|
|
@ -101,6 +106,10 @@ Known first findings, both live today:
|
||||||
- every `prj-` repo would collide on workplan prefix `PRJ-WP-`, because the
|
- every `prj-` repo would collide on workplan prefix `PRJ-WP-`, because the
|
||||||
prefix is derived from the flavor marker rather than from the project.
|
prefix is derived from the flavor marker rather than from the project.
|
||||||
|
|
||||||
|
**In progress (2026-08-18):** `rmgr conform` reports `missing` vs
|
||||||
|
`contradictory` and flags both known findings. Remaining: call from the
|
||||||
|
consistency lane / STATE-WP-0080 guard.
|
||||||
|
|
||||||
## Own governed scaffolding
|
## Own governed scaffolding
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -196,7 +205,7 @@ with compatibility tests. Cover:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0004-T08
|
id: RMGR-WP-0004-T08
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "fc7395b0-e86d-4231-baac-ea7fa5dc2174"
|
state_hub_task_id: "fc7395b0-e86d-4231-baac-ea7fa5dc2174"
|
||||||
```
|
```
|
||||||
|
|
@ -234,6 +243,13 @@ Detection only. Remediating the existing collisions is an open ruling in
|
||||||
**This task gates `RMGR-WP-0005`.** Deterministic identifier derivation from a
|
**This task gates `RMGR-WP-0005`.** Deterministic identifier derivation from a
|
||||||
non-unique identifier would manufacture UUID collisions rather than remove them.
|
non-unique identifier would manufacture UUID collisions rather than remove them.
|
||||||
|
|
||||||
|
**Result (2026-08-18):** `config/workplan-prefix-registry.yaml` plus
|
||||||
|
`rmgr prefix-uniqueness --root <fleet>`. Detection only: shared prefixes,
|
||||||
|
reused identifiers, `PRJ-WP-`, retired prefixes still in use. A 112-repo
|
||||||
|
scan still finds `CUST-WP`, `RAILIANCE-WP`, `PRJ-WP` (in
|
||||||
|
`prj-forgejo-org-refactor`). Remediating historical collisions remains
|
||||||
|
ADR-007 § Migration.
|
||||||
|
|
||||||
## Assign ownership of the shared prefixes
|
## Assign ownership of the shared prefixes
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue