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

416 lines
16 KiB
Python

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