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

422 lines
16 KiB
Python

"""
Tests for scanner lifecycle signals (tasks 2.1-2.3 of lifecycle-aware-doc-hygiene).
Covers:
- 2.1: directory-rule match prunes the walk + emits one aggregate entry;
IGNORE-surface match prunes with zero emission; file-rule match attaches
a lifecycle signal alongside existing objective signals; unmatched files
flow through unchanged.
- 2.2: temporary-tier age (git commit time, mtime fallback for untracked,
one-stat directory-inode mtime for untracked dirs); retain-recent-N
grouping/ranking by rule match entry.
- 2.3: delete-once-served — served_when_path substitution + filesystem
check (deterministic, scanner-proven); served_when free text is
classifier-judged only, scanner asserts nothing about satisfaction.
sys.path manipulation is intentional per file-ownership constraints (no
shared conftest).
"""
from __future__ import annotations
import sys
from pathlib import Path
# Prepend scripts/ to sys.path so we can import scanner/rulebook without a package
_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 RuleMatch # noqa: E402
from scanner import Scanner # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tree(tmp: Path, files: dict) -> None:
for rel, content in files.items():
p = tmp / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
def _rule(glob, lifetime="temporary", origin="global", **extra) -> dict:
r = {"glob": glob, "confirmed_by": "human", "confirmed_on": "2026-07-14", "source": "test"}
if lifetime is not None:
r["lifetime"] = lifetime
r.update(extra)
return r
class _FakeRulebook:
"""A minimal rulebook stand-in: one canned RuleMatch per glob->config.
Each registered entry is `(is_dir_only, matcher_fn, level, is_ignore, rule)`.
`matcher_fn(path, is_dir)` decides whether the entry applies. This keeps
scanner tests fully isolated from rulebook.py's glob-compilation
internals (already covered by test_rulebook.py) while exercising the
scanner's *consumption* of RuleMatch objects.
"""
def __init__(self):
self._entries = [] # list of (matcher_fn, level, is_ignore, rule, origin)
def register(self, matcher_fn, level, rule, is_ignore=False, origin="global"):
self._entries.append((matcher_fn, level, is_ignore, rule, origin))
def query(self, path, is_dir=False):
norm = path.strip("/")
for matcher_fn, level, is_ignore, rule, origin in self._entries:
if matcher_fn(norm, is_dir):
return RuleMatch(rule=rule, level=level, is_ignore=is_ignore, origin=origin)
return None
def _run(root: Path, rulebook=None, git_commit_time_fn=None, now_fn=None, **kwargs) -> dict:
return Scanner(
root=root,
git_log_fn=lambda p: [],
now_fn=now_fn or (lambda: 0.0),
rulebook=rulebook,
git_commit_time_fn=git_commit_time_fn,
**kwargs,
).run()
# ---------------------------------------------------------------------------
# 2.1: directory-rule prune + aggregate entry
# ---------------------------------------------------------------------------
class TestDirectoryRulePrune:
def test_directory_rule_prunes_walk_and_emits_one_aggregate_entry(self, tmp_path):
_make_tree(tmp_path, {
"autoresearch/run-2026-07-01/a.md": "content a",
"autoresearch/run-2026-07-01/b.md": "content b",
"autoresearch/run-2026-07-01/nested/c.md": "content c",
"docs/live.md": "live content",
})
rule = _rule("autoresearch/*/**", lifetime="temporary")
rb = _FakeRulebook()
rb.register(
lambda p, is_dir: is_dir and p == "autoresearch/run-2026-07-01",
"directory",
rule,
)
result = _run(tmp_path, rulebook=rb)
# No file beneath the pruned directory is opened/shortlisted.
for path in result["shortlist"]:
assert not path.startswith("autoresearch/run-2026-07-01/"), path
# Exactly one aggregate entry for the directory path itself.
assert "autoresearch/run-2026-07-01" in result["shortlist"]
assert result["shortlist"].count("autoresearch/run-2026-07-01") == 1
sigs = result["signals"]["autoresearch/run-2026-07-01"]
lifecycle_sigs = [s for s in sigs if s["name"] == "lifecycle"]
assert len(lifecycle_sigs) == 1
assert lifecycle_sigs[0]["lifetime"] == "temporary"
assert lifecycle_sigs[0]["rule_ref"] == "autoresearch/*/**"
# Unrelated file flows through unchanged.
assert "docs/live.md" in result["shortlist"]
assert "docs/live.md" not in result["signals"] or all(
s["name"] != "lifecycle" for s in result["signals"]["docs/live.md"]
)
def test_ignore_surface_prunes_with_zero_emission(self, tmp_path):
_make_tree(tmp_path, {
"graphify-out/graph.json": "{}",
"graphify-out/nested/index.md": "index",
"docs/live.md": "live content",
})
ignore_rule = _rule("graphify-out/**", lifetime=None)
rb = _FakeRulebook()
rb.register(
lambda p, is_dir: is_dir and p == "graphify-out",
"directory",
ignore_rule,
is_ignore=True,
)
result = _run(tmp_path, rulebook=rb)
for path in result["shortlist"]:
assert not path.startswith("graphify-out"), path
assert "graphify-out" not in result["shortlist"]
assert "graphify-out" not in result["signals"]
def test_file_rule_attaches_lifecycle_signal_alongside_existing_signals(self, tmp_path):
_make_tree(tmp_path, {
"HANDOFF-2026-07-01.md": "[broken](./missing.md)",
})
rule = _rule(
"HANDOFF-2026-07-01.md", lifetime="delete-once-served",
served_when="the handoff has been read and actioned",
)
rb = _FakeRulebook()
rb.register(
lambda p, is_dir: (not is_dir) and p == "HANDOFF-2026-07-01.md",
"file",
rule,
)
result = _run(tmp_path, rulebook=rb)
sigs = result["signals"]["HANDOFF-2026-07-01.md"]
names = {s["name"] for s in sigs}
assert "lifecycle" in names
assert "broken_reference" in names
def test_unmatched_files_flow_through_unchanged(self, tmp_path):
_make_tree(tmp_path, {"docs/plain.md": "plain content, nothing special"})
rb = _FakeRulebook() # no registered rules -> always None
result = _run(tmp_path, rulebook=rb)
assert "docs/plain.md" in result["shortlist"]
assert "docs/plain.md" not in result["signals"]
# ---------------------------------------------------------------------------
# 2.2: temporary-tier age + retain-recent-N
# ---------------------------------------------------------------------------
class TestTemporaryTierAge:
def test_tracked_file_age_from_git_commit_time(self, tmp_path):
_make_tree(tmp_path, {"tmp/a.md": "content"})
rule = _rule("tmp/a.md", lifetime="temporary", max_age_days=3, retain_recent=3)
rb = _FakeRulebook()
rb.register(lambda p, is_dir: (not is_dir) and p == "tmp/a.md", "file", rule)
# now = 10 days after commit -> age_days == 10
commit_time = "2026-06-24T00:00:00+00:00"
now_ts = __import__("datetime").datetime.fromisoformat(commit_time).timestamp() + 10 * 86400
result = _run(
tmp_path,
rulebook=rb,
git_commit_time_fn=lambda p: commit_time,
now_fn=lambda: now_ts,
)
sig = [s for s in result["signals"]["tmp/a.md"] if s["name"] == "lifecycle"][0]
assert abs(sig["age_days"] - 10.0) < 0.01
def test_untracked_file_age_falls_back_to_mtime(self, tmp_path):
_make_tree(tmp_path, {"tmp/b.md": "content"})
rule = _rule("tmp/b.md", lifetime="temporary")
rb = _FakeRulebook()
rb.register(lambda p, is_dir: (not is_dir) and p == "tmp/b.md", "file", rule)
result = _run(
tmp_path,
rulebook=rb,
git_commit_time_fn=lambda p: None, # untracked
now_fn=lambda: (tmp_path / "tmp/b.md").stat().st_mtime + 5 * 86400,
)
sig = [s for s in result["signals"]["tmp/b.md"] if s["name"] == "lifecycle"][0]
assert abs(sig["age_days"] - 5.0) < 0.01
def test_untracked_directory_age_is_one_stat_not_recursive(self, tmp_path):
_make_tree(tmp_path, {
"autoresearch/run-x/old_nested_file.md": "old",
})
# Make the nested file's mtime much older than the directory's own
# mtime, to prove the directory's own inode mtime is what's used.
import os
nested = tmp_path / "autoresearch/run-x/old_nested_file.md"
old_time = 1_000_000.0
os.utime(nested, (old_time, old_time))
dir_path = tmp_path / "autoresearch/run-x"
dir_mtime = dir_path.stat().st_mtime
rule = _rule("autoresearch/*/**", lifetime="temporary")
rb = _FakeRulebook()
rb.register(
lambda p, is_dir: is_dir and p == "autoresearch/run-x", "directory", rule
)
result = _run(
tmp_path,
rulebook=rb,
git_commit_time_fn=lambda p: None,
now_fn=lambda: dir_mtime + 2 * 86400,
)
sig = [s for s in result["signals"]["autoresearch/run-x"] if s["name"] == "lifecycle"][0]
# Age reflects the directory's own mtime (~2 days), not the ancient
# nested file's mtime (which would be a huge age if it were used).
assert abs(sig["age_days"] - 2.0) < 0.01
def test_retain_recent_n_keeps_newest_3_regardless_of_age(self, tmp_path):
_make_tree(tmp_path, {
"autoresearch/run-1/x.md": "1",
"autoresearch/run-2/x.md": "2",
"autoresearch/run-3/x.md": "3",
"autoresearch/run-4/x.md": "4",
"autoresearch/run-5/x.md": "5",
})
rule = _rule("autoresearch/*/**", lifetime="temporary", retain_recent=3, max_age_days=3)
rb = _FakeRulebook()
def matcher(p, is_dir):
return is_dir and p.startswith("autoresearch/run-")
rb.register(matcher, "directory", rule)
# Ages: run-1 oldest (10d) ... run-5 newest (1d).
ages = {
"autoresearch/run-1": 10.0,
"autoresearch/run-2": 8.0,
"autoresearch/run-3": 6.0,
"autoresearch/run-4": 4.0,
"autoresearch/run-5": 1.0,
}
def commit_time_fn(abs_path):
return None # force mtime fallback, patched via now_fn/mtime below
# Use mtime fallback: set directory mtimes so that now - mtime == age.
import os
now_ts = 2_000_000.0
for rel, age in ages.items():
os.utime(tmp_path / rel, (now_ts - age * 86400, now_ts - age * 86400))
result = _run(
tmp_path,
rulebook=rb,
git_commit_time_fn=commit_time_fn,
now_fn=lambda: now_ts,
)
retention_by_dir = {
path: [s for s in sigs if s["name"] == "lifecycle"][0]["retention"]
for path, sigs in result["signals"].items()
if path.startswith("autoresearch/run-")
}
# Newest 3 (run-3, run-4, run-5) always kept regardless of age.
assert retention_by_dir["autoresearch/run-5"]["kept"] is True
assert retention_by_dir["autoresearch/run-4"]["kept"] is True
assert retention_by_dir["autoresearch/run-3"]["kept"] is True
# Ranked 4th and 5th (run-2, run-1) are older than max_age_days=3 ->
# deletable.
assert retention_by_dir["autoresearch/run-2"]["kept"] is False
assert retention_by_dir["autoresearch/run-2"]["deletable"] is True
assert retention_by_dir["autoresearch/run-1"]["kept"] is False
assert retention_by_dir["autoresearch/run-1"]["deletable"] is True
def test_4th_ranked_entry_younger_than_max_age_is_not_deletable(self, tmp_path):
_make_tree(tmp_path, {
"autoresearch/run-1/x.md": "1",
"autoresearch/run-2/x.md": "2",
"autoresearch/run-3/x.md": "3",
"autoresearch/run-4/x.md": "4",
})
rule = _rule("autoresearch/*/**", lifetime="temporary", retain_recent=3, max_age_days=3)
rb = _FakeRulebook()
rb.register(lambda p, is_dir: is_dir and p.startswith("autoresearch/run-"), "directory", rule)
import os
now_ts = 2_000_000.0
ages = {
"autoresearch/run-1": 1.0, # 4th-ranked (oldest), but younger than max_age_days
"autoresearch/run-2": 0.8,
"autoresearch/run-3": 0.5,
"autoresearch/run-4": 0.1,
}
for rel, age in ages.items():
os.utime(tmp_path / rel, (now_ts - age * 86400, now_ts - age * 86400))
result = _run(
tmp_path, rulebook=rb, git_commit_time_fn=lambda p: None, now_fn=lambda: now_ts
)
retention_by_dir = {
path: [s for s in sigs if s["name"] == "lifecycle"][0]["retention"]
for path, sigs in result["signals"].items()
if path.startswith("autoresearch/run-")
}
assert retention_by_dir["autoresearch/run-1"]["kept"] is False
assert retention_by_dir["autoresearch/run-1"]["deletable"] is False
# ---------------------------------------------------------------------------
# 2.3: delete-once-served
# ---------------------------------------------------------------------------
class TestDeleteOnceServed:
def test_served_when_path_substitution_and_satisfied(self, tmp_path):
_make_tree(tmp_path, {
"autoresearch/run-2026-07-01/report.md": "report",
"autoresearch/run-2026-07-01/archive/report.md": "archived copy",
})
rule = _rule(
"autoresearch/*/report.md",
lifetime="delete-once-served",
served_when_path="autoresearch/run-2026-07-01/archive/{name}",
)
rb = _FakeRulebook()
rb.register(
lambda p, is_dir: (not is_dir) and p == "autoresearch/run-2026-07-01/report.md",
"file",
rule,
)
result = _run(tmp_path, rulebook=rb)
sig = [
s for s in result["signals"]["autoresearch/run-2026-07-01/report.md"]
if s["name"] == "lifecycle"
][0]
assert sig["served"]["kind"] == "scanner-proven"
assert sig["served"]["resolved_path"] == "autoresearch/run-2026-07-01/archive/report.md"
assert sig["served"]["satisfied"] is True
def test_served_when_path_not_satisfied(self, tmp_path):
_make_tree(tmp_path, {
"autoresearch/run-2026-07-01/report.md": "report",
})
rule = _rule(
"autoresearch/*/report.md",
lifetime="delete-once-served",
served_when_path="autoresearch/run-2026-07-01/archive/{name}",
)
rb = _FakeRulebook()
rb.register(
lambda p, is_dir: (not is_dir) and p == "autoresearch/run-2026-07-01/report.md",
"file",
rule,
)
result = _run(tmp_path, rulebook=rb)
sig = [
s for s in result["signals"]["autoresearch/run-2026-07-01/report.md"]
if s["name"] == "lifecycle"
][0]
assert sig["served"]["kind"] == "scanner-proven"
assert sig["served"]["satisfied"] is False
def test_served_when_free_text_is_classifier_judged_only(self, tmp_path):
_make_tree(tmp_path, {"HANDOFF-2026-07-01.md": "handoff content"})
rule = _rule(
"HANDOFF-2026-07-01.md",
lifetime="delete-once-served",
served_when="the handoff has been read and actioned by the team",
)
rb = _FakeRulebook()
rb.register(
lambda p, is_dir: (not is_dir) and p == "HANDOFF-2026-07-01.md", "file", rule
)
result = _run(tmp_path, rulebook=rb)
sig = [
s for s in result["signals"]["HANDOFF-2026-07-01.md"] if s["name"] == "lifecycle"
][0]
assert sig["served"]["kind"] == "classifier-judged"
assert sig["served"]["served_when"] == "the handoff has been read and actioned by the team"
# Scanner asserts nothing about satisfaction for classifier-judged signals.
assert "satisfied" not in sig["served"]