422 lines
16 KiB
Python
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"]
|