""" rulebook.py — lifecycle rulebook loader for os-doc-hygiene. Stdlib-only. Loads the global rulebook (`plugins/os-doc-hygiene/rulebook.json`) and an optional per-project override (`/.dochygiene-rules.json`), both under the envelope `{"schema_version": 1, "rules": [...]}`. Merges add-only (project rules are appended to, never replace, global rules) and exposes a single query surface: given a repo-root-relative path (and whether it is a directory), return `None` (unmatched) or the winning `RuleMatch`. No knowledge of git, classification, or the report schema — this module only resolves "which rule, if any, governs this path" (design.md Decision 1). """ from __future__ import annotations import glob import json import sys from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional _MIN_PYTHON = (3, 13) _VALID_LIFETIMES = {"keep", "temporary", "delete-once-served"} # Fields recognized by the schema (lifecycle-spec.md §2). Any field on a # rule outside this set (e.g. the removed `propagate_ignore`) is rejected. _KNOWN_FIELDS = { "glob", "lifetime", "extract", "served_when", "served_when_path", "retain_recent", "max_age_days", "confirm", "confirmed_by", "confirmed_on", "source", "note", } _DEFAULT_RETAIN_RECENT = 3 _DEFAULT_MAX_AGE_DAYS = 3 _SUPPORTED_SCHEMA_VERSIONS = {1} class RulebookLoadError(Exception): """Hard-fail: unparseable JSON, unknown schema_version, or unsupported Python version for the mandated glob dialect.""" @dataclass(frozen=True) class RuleMatch: """The winning rule for a queried path.""" rule: dict level: str # "file" | "directory" is_ignore: bool origin: str # "project" | "global" @dataclass class _CompiledRule: raw: dict origin: str # "project" | "global" level: str # "file" | "directory" is_ignore: bool pattern_for_matching: str matcher: Any # compiled regex (re.Pattern) — exact match on this level subtree_matcher: Optional[Any] # directory rules only: matches paths # anywhere beneath the directory (files or nested dirs), used when a # directory-rule competes for precedence over a *file* path. define_order: int def _check_python_version() -> None: if sys.version_info[:2] < _MIN_PYTHON: raise RulebookLoadError( "rulebook.py requires Python >= 3.13 for glob.translate(" "recursive=True, include_hidden=True); running under " f"{sys.version_info[0]}.{sys.version_info[1]}." ) def _load_json_envelope(path: Path) -> dict: try: text = path.read_text(encoding="utf-8") except FileNotFoundError: return {"schema_version": 1, "rules": []} try: data = json.loads(text) except json.JSONDecodeError as exc: raise RulebookLoadError(f"{path}: unparseable JSON: {exc}") from exc if not isinstance(data, dict) or "schema_version" not in data: raise RulebookLoadError( f"{path}: missing or malformed envelope (expected " '{"schema_version": 1, "rules": [...]})' ) schema_version = data.get("schema_version") if schema_version not in _SUPPORTED_SCHEMA_VERSIONS: raise RulebookLoadError( f"{path}: unrecognized schema_version {schema_version!r} " f"(supported: {sorted(_SUPPORTED_SCHEMA_VERSIONS)})" ) rules = data.get("rules", []) if not isinstance(rules, list): raise RulebookLoadError(f"{path}: 'rules' must be a list") return data def _is_directory_glob(pattern: str) -> bool: return pattern.endswith("/**") or pattern.endswith("/") def _base_pattern_for_directory(pattern: str) -> str: if pattern.endswith("/**"): return pattern[: -len("/**")] if pattern.endswith("/"): return pattern[: -len("/")] return pattern def _validate_and_compile_rule( raw: dict, origin: str, define_order: int, warnings: list ) -> Optional[_CompiledRule]: label = raw.get("glob", "") unknown_fields = set(raw.keys()) - _KNOWN_FIELDS if unknown_fields: warnings.append( f"rule {label!r} ({origin}): unrecognized field(s) " f"{sorted(unknown_fields)} — rule skipped and marked inactive" ) return None glob_pattern = raw.get("glob") if not glob_pattern or not isinstance(glob_pattern, str): warnings.append( f"rule {label!r} ({origin}): missing or invalid 'glob' field — " "rule skipped and marked inactive" ) return None lifetime = raw.get("lifetime") is_ignore = lifetime is None if lifetime is not None and lifetime not in _VALID_LIFETIMES: warnings.append( f"rule {glob_pattern!r} ({origin}): invalid lifetime " f"{lifetime!r} — rule skipped and marked inactive" ) return None if not raw.get("confirmed_by"): warnings.append( f"rule {glob_pattern!r} ({origin}): missing 'confirmed_by' — " "rule loaded but marked inactive, never contributes a match" ) return None level = "directory" if _is_directory_glob(glob_pattern) else "file" match_pattern = ( _base_pattern_for_directory(glob_pattern) if level == "directory" else glob_pattern ) import re try: regex = glob.translate( match_pattern, recursive=True, include_hidden=True ) matcher = re.compile(regex) except Exception as exc: # pragma: no cover - defensive warnings.append( f"rule {glob_pattern!r} ({origin}): failed to compile glob: " f"{exc} — rule skipped and marked inactive" ) return None subtree_matcher = None if level == "directory": # The un-stripped pattern (e.g. "notes/**") matches paths *beneath* # the directory too — needed so this directory-rule can compete for # precedence against file-rules on a file path (design.md Decision 1 # two-axis precedence is path-based, not query-level-based: a file # path can be governed by a directory-rule that covers it). try: subtree_regex = glob.translate( glob_pattern, recursive=True, include_hidden=True ) subtree_matcher = re.compile(subtree_regex) except Exception as exc: # pragma: no cover - defensive warnings.append( f"rule {glob_pattern!r} ({origin}): failed to compile " f"subtree glob: {exc} — rule skipped and marked inactive" ) return None resolved = dict(raw) if lifetime == "temporary": resolved.setdefault("retain_recent", _DEFAULT_RETAIN_RECENT) resolved.setdefault("max_age_days", _DEFAULT_MAX_AGE_DAYS) return _CompiledRule( raw=resolved, origin=origin, level=level, is_ignore=is_ignore, pattern_for_matching=match_pattern, matcher=matcher, subtree_matcher=subtree_matcher, define_order=define_order, ) # Precedence tiers, lowest wins first (spec: project file > project dir > # global file > global dir). _TIER = { ("project", "file"): 0, ("project", "directory"): 1, ("global", "file"): 2, ("global", "directory"): 3, } class Rulebook: """Merged, compiled rulebook exposing a single query surface.""" def __init__(self, compiled_rules: list, warnings: list): self._rules = compiled_rules self.warnings = warnings def query(self, path: str, is_dir: bool = False) -> Optional[RuleMatch]: """Return the winning rule for *path*, or None if unmatched. Precedence is resolved across *all* rules that cover this path, regardless of whether the winning rule is a file-rule or a directory-rule: a directory-rule's subtree covers files beneath it, so it can compete for (and win, per its tier) precedence on a file path just as a file-rule can. The returned RuleMatch.level reflects the winning rule's own declared type, not the caller's *is_dir*. """ norm_path = path.strip("/") candidates = [] for cr in self._rules: if cr.level == "file": # A file-rule's pattern is matched exactly; it only ever # governs the exact path it names. if cr.matcher.fullmatch(norm_path): candidates.append(cr) else: # directory-rule if is_dir: # Directory query: does this rule's own subtree glob # match the directory path itself? if cr.matcher.fullmatch(norm_path): candidates.append(cr) else: # File query: does this directory-rule's subtree cover # this file path? if cr.subtree_matcher.fullmatch(norm_path): candidates.append(cr) if not candidates: return None def sort_key(cr: _CompiledRule): tier = _TIER[(cr.origin, cr.level)] # Longer pattern wins ties -> negate for ascending sort. # Last-defined wins ties -> negate define_order. return (tier, -len(cr.pattern_for_matching), -cr.define_order) winner = min(candidates, key=sort_key) return RuleMatch( rule=winner.raw, level=winner.level, is_ignore=winner.is_ignore, origin=winner.origin, ) def default_global_rulebook_path() -> Path: """The global rulebook path resolved relative to this script's own location: `/rulebook.json` (this file lives in `/scripts/`).""" return Path(__file__).resolve().parent.parent / "rulebook.json" def load_rulebook( global_path: Optional[Path] = None, project_path: Optional[Path] = None ) -> Rulebook: """Load and merge the global rulebook with an optional project override. Hard-fails (raises RulebookLoadError) on unparseable JSON, an unrecognized schema_version, or an unsupported Python version. Skips and warns on a per-rule basis for invalid rules or rules missing `confirmed_by`. """ _check_python_version() global_path = Path(global_path) if global_path is not None else default_global_rulebook_path() global_data = _load_json_envelope(global_path) project_data: dict if project_path is not None: project_path = Path(project_path) project_data = _load_json_envelope(project_path) else: project_data = {"schema_version": 1, "rules": []} warnings: list = [] compiled: list = [] define_order = 0 for raw in global_data.get("rules", []): cr = _validate_and_compile_rule(raw, "global", define_order, warnings) define_order += 1 if cr is not None: compiled.append(cr) for raw in project_data.get("rules", []): cr = _validate_and_compile_rule(raw, "project", define_order, warnings) define_order += 1 if cr is not None: compiled.append(cr) return Rulebook(compiled_rules=compiled, warnings=warnings)