371 lines
15 KiB
Python
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"
|