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

416 lines
16 KiB
Python
Raw Normal View History

"""
Tests for scanner signal computation (task 2.6).
One fixture per signal type asserts the expected signal appears on the right
path with NO classification attached (no category, op, op_type, safety_tier).
sys.path manipulation is intentional per task constraints (no shared conftest).
"""
import os
import sys
import time
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from scanner import Scanner, SignalComputer # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
FORBIDDEN_SIGNAL_FIELDS = {"category", "op", "op_type", "safety_tier",
"exact_edit", "token_estimate", "class", "subtype"}
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 _run(root: Path, git_log_fn=None, now_fn=None, **kwargs) -> dict:
return Scanner(
root=root,
git_log_fn=git_log_fn or (lambda p: []),
now_fn=now_fn or (lambda: 0.0),
**kwargs,
).run()
def _assert_signal_present(signals_list: list, name: str) -> dict:
"""Assert a signal with the given name exists and return it."""
matching = [s for s in signals_list if s.get("name") == name]
assert matching, (
f"expected signal '{name}' not found. Got: {[s.get('name') for s in signals_list]}"
)
return matching[0]
def _assert_no_classifier_fields(signal: dict) -> None:
"""Assert a signal dict contains only 'name' and 'detail' — no classifier fields."""
for field in FORBIDDEN_SIGNAL_FIELDS:
assert field not in signal, (
f"classifier field '{field}' found in signal: {signal}"
)
assert "name" in signal, "signal must have a 'name' field"
assert "detail" in signal, "signal must have a 'detail' field"
# ---------------------------------------------------------------------------
# Signal: broken_reference
# ---------------------------------------------------------------------------
class TestBrokenReferenceSignal:
def test_broken_link_emits_signal(self, tmp_path):
_make_tree(tmp_path, {
"doc.md": "# Doc\n[missing file](./nonexistent.md)\n",
})
result = _run(tmp_path)
assert "doc.md" in result["shortlist"]
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "broken_reference")
_assert_no_classifier_fields(sig)
assert "nonexistent.md" in sig["detail"]
def test_valid_link_no_broken_reference_signal(self, tmp_path):
_make_tree(tmp_path, {
"doc.md": "# Doc\n[existing](./existing.md)\n",
"existing.md": "# Existing",
})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "broken_reference" not in names
def test_external_url_no_broken_reference_signal(self, tmp_path):
_make_tree(tmp_path, {
"doc.md": "# Doc\n[external](https://example.com/page)\n",
})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "broken_reference" not in names
def test_fragment_only_link_no_broken_reference(self, tmp_path):
"""Links with only an anchor (#section) have no file path to check."""
_make_tree(tmp_path, {
"doc.md": "# Doc\n[Section](#section)\n",
})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "broken_reference" not in names
def test_fixture_broken_ref(self):
"""Smoke: checked-in fixture has a broken reference signal."""
fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "signals"
if not (fixture_root / "broken_ref.md").exists():
return
result = _run(fixture_root)
sigs = result["signals"].get("broken_ref.md", [])
sig = _assert_signal_present(sigs, "broken_reference")
_assert_no_classifier_fields(sig)
# ---------------------------------------------------------------------------
# Signal: version_skew
# ---------------------------------------------------------------------------
class TestVersionSkewSignal:
def test_version_skew_emits_signal(self, tmp_path):
"""Doc declares an old version; pyproject.toml has a newer one."""
_make_tree(tmp_path, {
"pyproject.toml": '[project]\nname = "myapp"\nversion = "2.0.0"\n',
"docs/api.md": "# API\nversion: 1.0.0\n\nThis doc describes v1.0.0.",
})
result = _run(tmp_path)
sigs = result["signals"].get("docs/api.md", [])
sig = _assert_signal_present(sigs, "version_skew")
_assert_no_classifier_fields(sig)
assert "1.0.0" in sig["detail"]
assert "2.0.0" in sig["detail"]
def test_matching_version_no_signal(self, tmp_path):
_make_tree(tmp_path, {
"pyproject.toml": '[project]\nname = "myapp"\nversion = "1.0.0"\n',
"doc.md": "# Doc\nversion: 1.0.0\nCurrent version docs.",
})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "version_skew" not in names
def test_no_pyproject_no_signal(self, tmp_path):
"""Without a version source, no version_skew signal."""
_make_tree(tmp_path, {
"doc.md": "# Doc\nversion: 1.0.0\nContent.",
})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "version_skew" not in names
# ---------------------------------------------------------------------------
# Signal: edit_recency_vs_churn
# ---------------------------------------------------------------------------
class TestEditRecencyVsChurnSignal:
def test_recently_modified_high_churn_emits_signal(self, tmp_path):
"""File modified 1 day ago with 10 commits → signal fires."""
_make_tree(tmp_path, {"doc.md": "# Active doc\nFrequently updated."})
doc = tmp_path / "doc.md"
# Set mtime to 1 day ago
recent_mtime = time.time() - 86400
os.utime(doc, (recent_mtime, recent_mtime))
fake_log = lambda p: [f"abc{i}def commit {i}" for i in range(10)]
now_fn = lambda: time.time()
result = Scanner(
root=tmp_path,
git_log_fn=fake_log,
now_fn=now_fn,
).run()
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "edit_recency_vs_churn")
_assert_no_classifier_fields(sig)
assert "10" in sig["detail"]
def test_old_file_high_churn_no_signal(self, tmp_path):
"""File modified 30 days ago (not recent) → no signal even with high churn."""
_make_tree(tmp_path, {"doc.md": "# Old doc"})
doc = tmp_path / "doc.md"
old_mtime = time.time() - (30 * 86400)
os.utime(doc, (old_mtime, old_mtime))
fake_log = lambda p: [f"abc{i} commit" for i in range(10)]
now_fn = lambda: time.time()
result = Scanner(
root=tmp_path,
git_log_fn=fake_log,
now_fn=now_fn,
).run()
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "edit_recency_vs_churn" not in names
def test_recent_low_churn_no_signal(self, tmp_path):
"""File modified recently but only 2 commits → no signal."""
_make_tree(tmp_path, {"doc.md": "# New stable doc"})
doc = tmp_path / "doc.md"
recent_mtime = time.time() - 3600 # 1 hour ago
os.utime(doc, (recent_mtime, recent_mtime))
fake_log = lambda p: ["abc1 commit", "abc2 commit"]
now_fn = lambda: time.time()
result = Scanner(
root=tmp_path,
git_log_fn=fake_log,
now_fn=now_fn,
).run()
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "edit_recency_vs_churn" not in names
def test_no_git_log_fn_no_signal(self, tmp_path):
"""When git_log_fn is None, the signal is skipped entirely."""
_make_tree(tmp_path, {"doc.md": "# Doc"})
doc = tmp_path / "doc.md"
recent_mtime = time.time() - 3600
os.utime(doc, (recent_mtime, recent_mtime))
result = Scanner(
root=tmp_path,
git_log_fn=None, # explicitly disabled
now_fn=lambda: time.time(),
).run()
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "edit_recency_vs_churn" not in names
# ---------------------------------------------------------------------------
# Signal: stale_name_location
# ---------------------------------------------------------------------------
class TestStaleNameLocationSignal:
def test_old_prefix_in_name_emits_signal(self, tmp_path):
_make_tree(tmp_path, {"old_notes.md": "# Old notes about the previous design."})
result = _run(tmp_path)
sigs = result["signals"].get("old_notes.md", [])
sig = _assert_signal_present(sigs, "stale_name_location")
_assert_no_classifier_fields(sig)
assert "old" in sig["detail"].lower()
def test_deprecated_in_name_emits_signal(self, tmp_path):
_make_tree(tmp_path, {"deprecated_api.md": "# Deprecated API"})
result = _run(tmp_path)
sigs = result["signals"].get("deprecated_api.md", [])
sig = _assert_signal_present(sigs, "stale_name_location")
_assert_no_classifier_fields(sig)
def test_normal_name_no_signal(self, tmp_path):
_make_tree(tmp_path, {"architecture.md": "# Architecture"})
result = _run(tmp_path)
sigs = result["signals"].get("architecture.md", [])
names = [s["name"] for s in sigs]
assert "stale_name_location" not in names
def test_fixture_stale_name(self):
"""Smoke: checked-in fixture (old_notes.md) emits stale_name_location."""
fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "signals"
if not (fixture_root / "old_notes.md").exists():
return
result = _run(fixture_root)
sigs = result["signals"].get("old_notes.md", [])
sig = _assert_signal_present(sigs, "stale_name_location")
_assert_no_classifier_fields(sig)
# ---------------------------------------------------------------------------
# Signal: archive_to_live_ratio
# ---------------------------------------------------------------------------
class TestArchiveToLiveRatioSignal:
def test_mostly_resolved_content_emits_signal(self, tmp_path):
content = (
"# Project History\n\n"
"## Resolved Issues\n\n"
"Resolved: A resolved: B resolved: C resolved: D resolved: E\n"
"resolved: F resolved: G resolved: H resolved: I resolved: J\n\n"
"## Completed Work\n\n"
"done: 1 done: 2 done: 3 done: 4 done: 5\n"
"done: 6 done: 7 done: 8 done: 9 done: 10\n\n"
"## Current\n\nOne line of live content.\n"
)
_make_tree(tmp_path, {"history.md": content})
result = _run(tmp_path)
sigs = result["signals"].get("history.md", [])
sig = _assert_signal_present(sigs, "archive_to_live_ratio")
_assert_no_classifier_fields(sig)
def test_mostly_live_content_no_signal(self, tmp_path):
content = (
"# Architecture\n\n"
"## Components\n\nThe main components are A, B, and C.\n\n"
"## Design Decisions\n\nWe chose X over Y because of Z.\n\n"
"## Future Work\n\nNext steps: D and E.\n"
)
_make_tree(tmp_path, {"arch.md": content})
result = _run(tmp_path)
sigs = result["signals"].get("arch.md", [])
names = [s["name"] for s in sigs]
assert "archive_to_live_ratio" not in names
def test_fixture_archive_ratio(self):
"""Smoke: checked-in archive_ratio fixture emits the signal."""
fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "signals"
if not (fixture_root / "archive_ratio.md").exists():
return
result = _run(fixture_root)
sigs = result["signals"].get("archive_ratio.md", [])
sig = _assert_signal_present(sigs, "archive_to_live_ratio")
_assert_no_classifier_fields(sig)
# ---------------------------------------------------------------------------
# Signal: frontmatter_marker
# ---------------------------------------------------------------------------
class TestFrontmatterMarkerSignal:
def test_draft_status_emits_signal(self, tmp_path):
_make_tree(tmp_path, {
"doc.md": "---\nstatus: draft\n---\n\n# Draft Doc\nContent.",
})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "frontmatter_marker")
_assert_no_classifier_fields(sig)
assert "draft" in sig["detail"]
def test_provisional_status_emits_signal(self, tmp_path):
_make_tree(tmp_path, {
"doc.md": "---\nstatus: provisional\n---\n\n# Provisional",
})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "frontmatter_marker")
_assert_no_classifier_fields(sig)
def test_wip_status_emits_signal(self, tmp_path):
_make_tree(tmp_path, {
"doc.md": "---\nstatus: wip\n---\n\n# WIP",
})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "frontmatter_marker")
_assert_no_classifier_fields(sig)
def test_no_special_frontmatter_no_signal(self, tmp_path):
_make_tree(tmp_path, {
"doc.md": "---\ntitle: My Doc\n---\n\n# My Doc\nContent.",
})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "frontmatter_marker" not in names
def test_frozen_frontmatter_excluded_not_signaled(self, tmp_path):
"""hygiene: frozen causes exclusion — no frontmatter_marker should appear."""
_make_tree(tmp_path, {
"doc.md": "---\nhygiene: frozen\n---\n\n# Frozen",
})
result = _run(tmp_path)
assert "doc.md" not in result["shortlist"]
assert "doc.md" not in result["signals"]
def test_fixture_frontmatter_marker(self):
"""Smoke: checked-in fixture emits frontmatter_marker."""
fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "signals"
if not (fixture_root / "frontmatter_marker.md").exists():
return
result = _run(fixture_root)
sigs = result["signals"].get("frontmatter_marker.md", [])
sig = _assert_signal_present(sigs, "frontmatter_marker")
_assert_no_classifier_fields(sig)
# ---------------------------------------------------------------------------
# Cross-cutting: signals carry no classifier fields
# ---------------------------------------------------------------------------
class TestSignalsNeverClassify:
def test_all_signals_in_output_lack_classifier_fields(self, tmp_path):
"""Integration check: run over a tree with all signal types and verify output."""
_make_tree(tmp_path, {
"pyproject.toml": '[project]\nname = "test"\nversion = "2.0.0"\n',
"broken.md": "# Doc\n[missing](./gone.md)\n",
"old_stuff.md": "# Old stuff",
"draft.md": "---\nstatus: draft\n---\n# Draft",
"api.md": "# API\nversion: 1.0.0\n",
})
result = _run(tmp_path)
for path, sigs in result["signals"].items():
for sig in sigs:
_assert_no_classifier_fields(sig)
assert isinstance(sig.get("name"), str), f"signal name must be a string: {sig}"
assert isinstance(sig.get("detail"), str), f"signal detail must be a string: {sig}"
def test_output_never_contains_entries_field(self, tmp_path):
_make_tree(tmp_path, {"doc.md": "# Doc"})
result = _run(tmp_path)
assert "entries" not in result