cc-os/plugins/os-doc-hygiene/tests/test_rulebook.py

371 lines
15 KiB
Python

"""
Tests for scripts/rulebook.py
Covers tasks 1.1-1.2 of the lifecycle-aware-doc-hygiene change:
- envelope parsing, hard-fail on unparseable JSON / unknown schema_version
- skip-and-warn on invalid rules and rules missing confirmed_by
- glob compilation via glob.translate(recursive=True, include_hidden=True),
with a Python >=3.13 version check
- add-only merge + two-axis precedence (project file > project dir >
global file > global dir; ties by longest glob, then last-defined)
- keep-shadowing neutralization
- unmatched path -> None
- file-level vs directory-level match distinction
- IGNORE-sentinel rules (no lifetime field -> zero-emission prune)
sys.path is patched here (no shared conftest) so the test file is
self-contained, as required by the file-ownership constraints.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from rulebook import ( # noqa: E402
Rulebook,
RulebookLoadError,
load_rulebook,
)
def _write(tmp_path: Path, name: str, payload: dict) -> Path:
p = tmp_path / name
p.write_text(json.dumps(payload), encoding="utf-8")
return p
def _envelope(*rules: dict) -> dict:
return {"schema_version": 1, "rules": list(rules)}
def _rule(glob, lifetime="keep", confirmed_by="human", **extra) -> dict:
r = {
"glob": glob,
"confirmed_by": confirmed_by,
"confirmed_on": "2026-07-14",
"source": "test",
}
if lifetime is not None:
r["lifetime"] = lifetime
r.update(extra)
return r
# ===========================================================================
# Task 1.1 — envelope parsing / hard-fail / skip-and-warn / glob compilation
# ===========================================================================
class TestEnvelopeParsing:
def test_loads_valid_global_only(self, tmp_path):
global_path = _write(
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
)
rb = load_rulebook(global_path=global_path, project_path=None)
assert isinstance(rb, Rulebook)
def test_missing_project_file_is_fine(self, tmp_path):
global_path = _write(
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
)
project_path = tmp_path / "does-not-exist.json"
rb = load_rulebook(global_path=global_path, project_path=project_path)
assert isinstance(rb, Rulebook)
def test_unparseable_json_hard_fails(self, tmp_path):
global_path = tmp_path / "rulebook.json"
global_path.write_text("{not valid json", encoding="utf-8")
with pytest.raises(RulebookLoadError):
load_rulebook(global_path=global_path, project_path=None)
def test_unknown_schema_version_hard_fails(self, tmp_path):
global_path = _write(
tmp_path,
"rulebook.json",
{"schema_version": 99, "rules": [_rule("foo.md")]},
)
with pytest.raises(RulebookLoadError):
load_rulebook(global_path=global_path, project_path=None)
def test_project_file_unparseable_json_hard_fails(self, tmp_path):
global_path = _write(
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
)
project_path = tmp_path / "proj.json"
project_path.write_text("nope not json", encoding="utf-8")
with pytest.raises(RulebookLoadError):
load_rulebook(global_path=global_path, project_path=project_path)
class TestSkipAndWarnValidation:
def test_rule_missing_confirmed_by_is_skipped_not_fatal(self, tmp_path):
bad = {
"glob": "unconfirmed.md",
"lifetime": "keep",
"confirmed_on": "2026-07-14",
"source": "test",
}
assert "confirmed_by" not in bad
good_rules = [_rule(f"good-{i}.md") for i in range(9)]
global_path = _write(
tmp_path, "rulebook.json", _envelope(bad, *good_rules)
)
rb = load_rulebook(global_path=global_path, project_path=None)
# the invalid rule never contributes a match
assert rb.query("unconfirmed.md", is_dir=False) is None
# a warning is surfaced
assert any("confirmed_by" in w for w in rb.warnings)
# the other nine rules still function
for i in range(9):
match = rb.query(f"good-{i}.md", is_dir=False)
assert match is not None
def test_invalid_lifetime_value_is_skipped(self, tmp_path):
bad = _rule("bad.md", lifetime="not-a-real-lifetime")
global_path = _write(tmp_path, "rulebook.json", _envelope(bad))
rb = load_rulebook(global_path=global_path, project_path=None)
assert rb.query("bad.md", is_dir=False) is None
assert rb.warnings
def test_rule_missing_glob_is_skipped(self, tmp_path):
bad = {
"lifetime": "keep",
"confirmed_by": "human",
"confirmed_on": "2026-07-14",
"source": "test",
}
global_path = _write(tmp_path, "rulebook.json", _envelope(bad))
rb = load_rulebook(global_path=global_path, project_path=None)
assert rb.warnings
def test_propagate_ignore_field_is_rejected(self, tmp_path):
bad = _rule("bad.md", propagate_ignore=True)
global_path = _write(tmp_path, "rulebook.json", _envelope(bad))
rb = load_rulebook(global_path=global_path, project_path=None)
assert rb.query("bad.md", is_dir=False) is None
assert any("propagate_ignore" in w for w in rb.warnings)
def test_defaults_retain_recent_and_max_age_days(self, tmp_path):
rule = _rule("stuff/**", lifetime="temporary")
global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
rb = load_rulebook(global_path=global_path, project_path=None)
match = rb.query("stuff", is_dir=True)
assert match is not None
assert match.rule["retain_recent"] == 3
assert match.rule["max_age_days"] == 3
def test_overridden_retain_recent_and_max_age_days(self, tmp_path):
rule = _rule(
"stuff/**", lifetime="temporary", retain_recent=5, max_age_days=10
)
global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
rb = load_rulebook(global_path=global_path, project_path=None)
match = rb.query("stuff", is_dir=True)
assert match.rule["retain_recent"] == 5
assert match.rule["max_age_days"] == 10
class TestGlobCompilationVersionCheck:
def test_hard_fails_clearly_below_python_313(self, tmp_path, monkeypatch):
import rulebook as rb_module
monkeypatch.setattr(rb_module.sys, "version_info", (3, 12, 0))
global_path = _write(
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
)
with pytest.raises(RulebookLoadError, match=r"(?i)python.*3\.13"):
load_rulebook(global_path=global_path, project_path=None)
def test_compiles_recursive_double_star(self, tmp_path):
rule = _rule("autoresearch/*/**", lifetime="temporary")
global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
rb = load_rulebook(global_path=global_path, project_path=None)
match = rb.query("autoresearch/run-2026-07-01", is_dir=True)
assert match is not None
assert match.level == "directory"
def test_matches_hidden_dotfile_paths(self, tmp_path):
rule = _rule(".dochygiene/**")
global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
rb = load_rulebook(global_path=global_path, project_path=None)
match = rb.query(".dochygiene", is_dir=True)
assert match is not None
# ===========================================================================
# Task 1.2 — add-only merge, two-axis precedence, keep-shadowing,
# unmatched -> None, file-vs-directory distinction, IGNORE sentinels
# ===========================================================================
class TestPrecedenceAndMerge:
def test_unmatched_path_returns_none(self, tmp_path):
global_path = _write(
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
)
rb = load_rulebook(global_path=global_path, project_path=None)
assert rb.query("totally/unrelated/path.md", is_dir=False) is None
def test_file_level_vs_directory_level_distinction(self, tmp_path):
rules = [
_rule("HANDOFF-2026-07-01.md", lifetime="delete-once-served"),
_rule("autoresearch/*/**", lifetime="temporary"),
]
global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
rb = load_rulebook(global_path=global_path, project_path=None)
file_match = rb.query("HANDOFF-2026-07-01.md", is_dir=False)
assert file_match.level == "file"
dir_match = rb.query("autoresearch/run-1", is_dir=True)
assert dir_match.level == "directory"
def test_project_file_rule_outranks_global_directory_rule(self, tmp_path):
global_path = _write(
tmp_path,
"rulebook.json",
_envelope(_rule("notes/**", lifetime="temporary")),
)
project_path = _write(
tmp_path,
"proj.json",
_envelope(_rule("notes/keep-me.md", lifetime="keep")),
)
rb = load_rulebook(global_path=global_path, project_path=project_path)
match = rb.query("notes/keep-me.md", is_dir=False)
assert match is not None
assert match.rule["lifetime"] == "keep"
assert match.level == "file"
def test_project_directory_rule_outranks_global_file_rule(self, tmp_path):
# Same file path matched by BOTH a global file-rule and a project
# directory-rule whose subtree covers it; project-dir (tier 1)
# beats global-file (tier 2) per the two-axis precedence order.
global_path = _write(
tmp_path,
"rulebook.json",
_envelope(_rule("notes/x.md", lifetime="delete-once-served")),
)
project_path = _write(
tmp_path,
"proj.json",
_envelope(_rule("notes/**", lifetime="temporary")),
)
rb = load_rulebook(global_path=global_path, project_path=project_path)
match = rb.query("notes/x.md", is_dir=False)
assert match is not None
assert match.rule["lifetime"] == "temporary"
assert match.origin == "project"
assert match.level == "directory"
def test_project_file_rule_outranks_matching_global_directory_rule(
self, tmp_path
):
# A project file-rule and a global directory-rule both cover the
# same file path; project-file (tier 0) must win over
# global-directory (tier 3) even though the directory-rule's
# subtree also matches.
global_path = _write(
tmp_path,
"rulebook.json",
_envelope(_rule("notes/**", lifetime="temporary")),
)
project_path = _write(
tmp_path,
"proj.json",
_envelope(_rule("notes/keep-me.md", lifetime="keep")),
)
rb = load_rulebook(global_path=global_path, project_path=project_path)
match = rb.query("notes/keep-me.md", is_dir=False)
assert match is not None
assert match.rule["lifetime"] == "keep"
assert match.origin == "project"
assert match.level == "file"
def test_ties_broken_by_longest_glob_then_last_defined(self, tmp_path):
# Both directory-rules' subtrees genuinely cover "a/b/c.md" (real
# tie within the same (global, directory) tier) — the longer
# pattern "a/b/**" must win over "a/**".
rules = [
_rule("a/**", lifetime="temporary", note="short"),
_rule("a/b/**", lifetime="keep", note="longer"),
]
global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
rb = load_rulebook(global_path=global_path, project_path=None)
match = rb.query("a/b/c.md", is_dir=False)
assert match is not None
assert match.rule["note"] == "longer"
def test_ties_equal_length_last_defined_wins(self, tmp_path):
rules = [
_rule("dup/**", lifetime="temporary", note="first"),
_rule("dup/**", lifetime="keep", note="second"),
]
global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
rb = load_rulebook(global_path=global_path, project_path=None)
match = rb.query("dup", is_dir=True)
assert match.rule["note"] == "second"
def test_add_only_merge_never_deletes_global_rules(self, tmp_path):
global_path = _write(
tmp_path,
"rulebook.json",
_envelope(_rule("a.md"), _rule("b.md")),
)
project_path = _write(
tmp_path, "proj.json", _envelope(_rule("c.md"))
)
rb = load_rulebook(global_path=global_path, project_path=project_path)
assert rb.query("a.md", is_dir=False) is not None
assert rb.query("b.md", is_dir=False) is not None
assert rb.query("c.md", is_dir=False) is not None
def test_keep_shadowing_neutralizes_global_delete_rule(self, tmp_path):
global_path = _write(
tmp_path,
"rulebook.json",
_envelope(_rule("autoresearch/*/**", lifetime="temporary")),
)
project_path = _write(
tmp_path,
"proj.json",
_envelope(_rule("autoresearch/special/**", lifetime="keep")),
)
rb = load_rulebook(global_path=global_path, project_path=project_path)
shadowed = rb.query("autoresearch/special", is_dir=True)
assert shadowed.rule["lifetime"] == "keep"
# an unrelated run dir under the same global rule is still temporary
other = rb.query("autoresearch/other-run", is_dir=True)
assert other.rule["lifetime"] == "temporary"
class TestIgnoreSentinel:
def test_ignore_rule_has_no_lifetime_and_is_flagged(self, tmp_path):
ignore_rule = _rule("graphify-out/**", lifetime=None)
global_path = _write(tmp_path, "rulebook.json", _envelope(ignore_rule))
rb = load_rulebook(global_path=global_path, project_path=None)
match = rb.query("graphify-out", is_dir=True)
assert match is not None
assert match.is_ignore is True
assert "lifetime" not in match.rule or match.rule.get("lifetime") is None
def test_ignore_rule_distinct_from_keep(self, tmp_path):
rules = [
_rule("graphify-out/**", lifetime=None),
_rule(".dochygiene/**", lifetime="keep"),
]
global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
rb = load_rulebook(global_path=global_path, project_path=None)
ignore_match = rb.query("graphify-out", is_dir=True)
keep_match = rb.query(".dochygiene", is_dir=True)
assert ignore_match.is_ignore is True
assert keep_match.is_ignore is False
assert keep_match.rule["lifetime"] == "keep"