337 lines
11 KiB
Python
337 lines
11 KiB
Python
"""
|
|
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)
|