""" 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"