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