cc-os/plugins/os-doc-hygiene/scripts/rulebook.py

337 lines
11 KiB
Python
Raw Permalink Normal View History

"""
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 (`<repo-root>/.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", "<no 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: `<plugin-root>/rulebook.json` (this file lives in
`<plugin-root>/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)