diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 84a5183..9ad670f 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -33,6 +33,7 @@ Checks: C-28 inbox-stale-unread WARN No Unread inbox messages older than INBOX_STALE_DAYS C-29 inbox-work-unpromoted WARN No Stale unread message looks like a multi-step work request without a workplan C-30 scope-current-state-stale WARN No SCOPE.md Current State contradicts live workplan statuses + C-31 work-record-unregistered WARN No YAML-block id matches no kind in the canon work-record type registry (sidetrack detector, CUST-WP-0060) Usage: python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] @@ -772,6 +773,82 @@ def _infer_slug_from_path(api_base: str, path: str) -> "tuple[str, str] | None": return None +_WORK_RECORD_FENCE_RE = re.compile(r"```(?:yaml|task)\n(.*?)```", re.S) +_WORK_RECORD_ID_LINE_RE = re.compile(r"^id:\s*[\"']?([A-Za-z0-9_-]+)[\"']?\s*$", re.M) +_WORK_RECORD_ID_LIKE_RE = re.compile(r"^[A-Z][A-Z0-9]*(-[A-Z0-9]+)+$") +_WORK_RECORD_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "history"}) + + +def _load_work_record_patterns() -> list[re.Pattern] | None: + """Load id patterns from the canon work-record type registry. + + Location: $WORK_RECORD_REGISTRY, else ~/the-custodian/canon/standards/ + work-record-types.yaml. Returns None (check skipped) when unavailable. + """ + if not _HAS_YAML: + return None + candidates = [] + env = os.environ.get("WORK_RECORD_REGISTRY") + if env: + candidates.append(Path(env)) + candidates.append( + Path.home() / "the-custodian/canon/standards/work-record-types.yaml") + for path in candidates: + if not path.is_file(): + continue + try: + reg = _yaml.safe_load(path.read_text()) + patterns = [] + for kind in reg.get("kinds", []): + patterns += [re.compile(p) for p in kind.get("id_patterns", [])] + patterns += [re.compile(lp["pattern"]) + for lp in kind.get("legacy_patterns", [])] + return patterns + except Exception: + return None + return None + + +def _check_unregistered_work_records(repo_dir: Path, report: "ConsistencyReport") -> None: + """C-31: warn on YAML-block ids matching no registered work-record kind. + + Detection only (CUST-WP-0060-T05); registration of non-workplan kinds is + a later stage. An unregistered id scheme is a coordination sidetrack that + will need manual reintegration — surface it the day it appears. + """ + patterns = _load_work_record_patterns() + if patterns is None: + return + seen: set[str] = set() + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for body in _WORK_RECORD_FENCE_RE.findall(text): + for rid in _WORK_RECORD_ID_LINE_RE.findall(body): + if rid in seen or "NNN" in rid or rid.endswith("-TNN"): + continue + if not _WORK_RECORD_ID_LIKE_RE.match(rid): + continue + if any(p.match(rid) for p in patterns): + continue + seen.add(rid) + report.add( + severity="WARN", + check_id="C-31", + message=( + f"[{md.relative_to(repo_dir)}] id '{rid}' matches no " + f"kind in the canon work-record type registry — " + f"unregistered species become sidetracks needing " + f"manual reintegration (work-record-types_v0.1)" + ), + fixable=False, + ) + + def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = None) -> ConsistencyReport: """Run all consistency checks for a registered repo.""" repo = _api_get(api_base, f"/repos/{repo_slug}", return_error=True) @@ -835,6 +912,9 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N fixable=False, ) + # C-31: work-record sidetrack detector (canon work-record-types registry) + _check_unregistered_work_records(repo_dir, report) + # C-01: workplans/ directory missing if not workplans_dir.is_dir(): report.add(