"""Tests for the os-status hooks. Run: python3 tests/hook_test.py""" import importlib.util import io import json import os import subprocess import sys import unittest from datetime import date from pathlib import Path from tempfile import TemporaryDirectory from unittest import mock PLUGIN_ROOT = Path(__file__).resolve().parent.parent REPO_PLUGINS = PLUGIN_ROOT.parent sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) import checks # noqa: E402 import session_start # noqa: E402 from checks import ( # noqa: E402 ABSENT_NOTE, CURRENT_CONFIG_VERSION, ENV_VAR, PRESENT_NOTE, TRACKER_ABSENT_NOTE, Check, CheckResult, Ctx, adr_system_present, config_version_current, project_graph_present, run_all_json, subagent_model_env_override, tracker_configured, vault_hub_note_present, ) from state import StateDir, find_project_root, read_config, write_config_value # noqa: E402 TODAY = date(2026, 7, 6) YESTERDAY = date(2026, 7, 5) def make_ctx( project_root=None, settings_path=Path("/nonexistent/settings.json"), vault_path=Path("/nonexistent/vault"), config=None, environ=None, ): return Ctx( project_root=project_root, settings_path=settings_path, vault_path=vault_path, config=config or {}, environ=environ or {}, ) def git_project(tmp): root = Path(tmp) (root / ".git").mkdir(parents=True) return root class EnvOverrideCheckTest(unittest.TestCase): def test_ok_when_unset_everywhere(self): self.assertEqual("ok", subagent_model_env_override(make_ctx()).status) def test_warns_on_process_environment(self): result = subagent_model_env_override(make_ctx(environ={ENV_VAR: "haiku"})) self.assertEqual("warn", result.status) self.assertIn(ENV_VAR, result.message) self.assertIn("haiku", result.message) def test_warns_on_settings_env_block_naming_the_file(self): with TemporaryDirectory() as tmp: settings = Path(tmp) / "settings.json" settings.write_text(json.dumps({"env": {ENV_VAR: "haiku"}})) result = subagent_model_env_override(make_ctx(settings_path=settings)) self.assertEqual("warn", result.status) self.assertIn(str(settings), result.message) self.assertIn("haiku", result.message) def test_names_both_sources_when_both_set(self): with TemporaryDirectory() as tmp: settings = Path(tmp) / "settings.json" settings.write_text(json.dumps({"env": {ENV_VAR: "haiku"}})) result = subagent_model_env_override( make_ctx(settings_path=settings, environ={ENV_VAR: "sonnet"}) ) self.assertIn("environment", result.message) self.assertIn(str(settings), result.message) def test_malformed_settings_is_ignored(self): with TemporaryDirectory() as tmp: settings = Path(tmp) / "settings.json" settings.write_text("{not json") self.assertEqual( "ok", subagent_model_env_override(make_ctx(settings_path=settings)).status ) class AdrCheckTest(unittest.TestCase): def test_present_notes_verbatim_wording(self): with TemporaryDirectory() as tmp: root = git_project(tmp) (root / "docs" / "adr").mkdir(parents=True) (root / "docs" / "adr" / "README.md").write_text("index") result = adr_system_present(make_ctx(project_root=root)) self.assertEqual(CheckResult("note", PRESENT_NOTE), result) def test_dir_without_index_counts_as_absent(self): with TemporaryDirectory() as tmp: root = git_project(tmp) (root / "docs" / "adr").mkdir(parents=True) result = adr_system_present(make_ctx(project_root=root)) self.assertEqual(CheckResult("warn", ABSENT_NOTE), result) def test_absent_warns_with_init_and_migrate(self): with TemporaryDirectory() as tmp: root = git_project(tmp) result = adr_system_present(make_ctx(project_root=root)) self.assertEqual("warn", result.status) self.assertIn("/os-adr:init", result.message) self.assertIn("/os-adr:migrate", result.message) def test_legacy_os_adr_suppress_is_honored(self): with TemporaryDirectory() as tmp: root = git_project(tmp) (root / ".os-adr").mkdir() (root / ".os-adr" / "suppress").write_text("") self.assertEqual("ok", adr_system_present(make_ctx(project_root=root)).status) class HubNoteCheckTest(unittest.TestCase): def vault_with(self, tmp, name, body): vault = Path(tmp) / "vault" vault.mkdir(exist_ok=True) (vault / name).write_text(body) return vault def hub_note(self, project): return ( f"---\ntype: hub\ntags:\n - type/hub\n - project/{project}\n---\n# hub\n" ) def test_missing_vault_is_silent(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, vault_path=Path(tmp) / "no-vault") self.assertEqual("ok", vault_hub_note_present(ctx).status) def test_facet_tag_scan_finds_hub(self): with TemporaryDirectory() as tmp: root = git_project(Path(tmp) / "myproj") vault = self.vault_with(tmp, "myproj-hub.md", self.hub_note("myproj")) ctx = make_ctx(project_root=root, vault_path=vault) self.assertEqual("ok", vault_hub_note_present(ctx).status) def test_no_hub_warns_naming_corrective_skill(self): with TemporaryDirectory() as tmp: root = git_project(Path(tmp) / "myproj") vault = self.vault_with(tmp, "other.md", self.hub_note("otherproj")) result = vault_hub_note_present(make_ctx(project_root=root, vault_path=vault)) self.assertEqual("warn", result.status) self.assertIn("/os-vault:write", result.message) self.assertIn("myproj", result.message) def test_project_name_is_not_prefix_matched(self): with TemporaryDirectory() as tmp: root = git_project(Path(tmp) / "myproj") vault = self.vault_with(tmp, "other.md", self.hub_note("myproj2")) result = vault_hub_note_present(make_ctx(project_root=root, vault_path=vault)) self.assertEqual("warn", result.status) def test_config_slug_wins_over_inference(self): with TemporaryDirectory() as tmp: root = git_project(Path(tmp) / "myproj") vault = self.vault_with(tmp, "custom-hub.md", "any content") ctx = make_ctx( project_root=root, vault_path=vault, config={"hub": "custom-hub"} ) self.assertEqual("ok", vault_hub_note_present(ctx).status) def test_config_slug_pointing_nowhere_warns(self): with TemporaryDirectory() as tmp: root = git_project(Path(tmp) / "myproj") vault = self.vault_with(tmp, "other.md", "content") ctx = make_ctx(project_root=root, vault_path=vault, config={"hub": "ghost"}) result = vault_hub_note_present(ctx) self.assertEqual("warn", result.status) self.assertIn("ghost", result.message) def test_templates_and_graphify_out_are_skipped(self): with TemporaryDirectory() as tmp: root = git_project(Path(tmp) / "myproj") vault = Path(tmp) / "vault" (vault / "_templates").mkdir(parents=True) (vault / "_templates" / "t.md").write_text(self.hub_note("myproj")) (vault / "graphify-out").mkdir() (vault / "graphify-out" / "g.md").write_text(self.hub_note("myproj")) result = vault_hub_note_present(make_ctx(project_root=root, vault_path=vault)) self.assertEqual("warn", result.status) class AuditDueCheckTest(unittest.TestCase): def ledger_env(self, tmp, rows): path = Path(tmp) / "metrics.tsv" header = "date\tsince\tsessions\n" path.write_text(header + "".join(f"{r}\t2026-01-01\t5\n" for r in rows)) return {checks.AUDIT_LEDGER_ENV: str(path)} def test_missing_ledger_is_silent_machine_not_opted_in(self): ctx = make_ctx(environ={checks.AUDIT_LEDGER_ENV: "/nonexistent/metrics.tsv"}) self.assertEqual("ok", checks.orchestration_audit_due(ctx).status) def test_recent_run_is_ok(self): with TemporaryDirectory() as tmp: env = self.ledger_env(tmp, [date.today().isoformat()]) self.assertEqual("ok", checks.orchestration_audit_due(make_ctx(environ=env)).status) def test_stale_run_warns_with_last_date(self): with TemporaryDirectory() as tmp: env = self.ledger_env(tmp, ["2026-01-01", "2026-02-01"]) result = checks.orchestration_audit_due(make_ctx(environ=env)) self.assertEqual("warn", result.status) self.assertIn("2026-02-01", result.message) self.assertIn("/os-orchestration:audit-sessions", result.message) def test_corrupt_ledger_reads_as_missing(self): with TemporaryDirectory() as tmp: path = Path(tmp) / "metrics.tsv" path.write_text("header only\nnot-a-date\tx\n") env = {checks.AUDIT_LEDGER_ENV: str(path)} self.assertEqual("ok", checks.orchestration_audit_due(make_ctx(environ=env)).status) class TrackerCheckTest(unittest.TestCase): def test_absent_warns_pointing_to_routing_skill(self): with TemporaryDirectory() as tmp: root = git_project(tmp) result = tracker_configured(make_ctx(project_root=root)) self.assertEqual(CheckResult("warn", TRACKER_ABSENT_NOTE), result) self.assertIn("/os-backlog:route", result.message) def test_planka_value_is_ok(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"tracker": "planka:my-board"}) self.assertEqual("ok", tracker_configured(ctx).status) def test_forgejo_value_is_ok(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"tracker": "forgejo:jared/cc-os"}) self.assertEqual("ok", tracker_configured(ctx).status) def test_github_value_is_ok(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"tracker": "github:jared/cc-os"}) self.assertEqual("ok", tracker_configured(ctx).status) def test_repo_value_is_ok(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"tracker": "repo:issues/"}) self.assertEqual("ok", tracker_configured(ctx).status) def test_unknown_scheme_warns_without_crashing(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"tracker": "trello:board123"}) result = tracker_configured(ctx) self.assertEqual("warn", result.status) self.assertIn("trello:board123", result.message) def test_missing_separator_warns_without_crashing(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"tracker": "planka-my-board"}) result = tracker_configured(ctx) self.assertEqual("warn", result.status) def test_forgejo_missing_repo_part_warns(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"tracker": "forgejo:jared"}) result = tracker_configured(ctx) self.assertEqual("warn", result.status) def test_is_project_scoped_and_daily_snoozed_via_registry(self): entry = next(c for c in checks.REGISTRY if c.name == "tracker-configured") self.assertTrue(entry.project_scoped) class ConfigVersionCheckTest(unittest.TestCase): def test_missing_version_warns(self): with TemporaryDirectory() as tmp: root = git_project(tmp) result = config_version_current(make_ctx(project_root=root)) self.assertEqual("warn", result.status) self.assertIn("/os-status:fix", result.message) def test_current_version_is_ok(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"version": str(CURRENT_CONFIG_VERSION)}) self.assertEqual("ok", config_version_current(ctx).status) def test_old_version_warns(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"version": "0"}) self.assertEqual("warn", config_version_current(ctx).status) def test_non_numeric_version_warns_without_crashing(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root, config={"version": "banana"}) result = config_version_current(ctx) self.assertEqual("warn", result.status) class ProjectGraphCheckTest(unittest.TestCase): def test_missing_graph_warns_naming_onboard(self): with TemporaryDirectory() as tmp: root = git_project(tmp) result = project_graph_present(make_ctx(project_root=root)) self.assertEqual("warn", result.status) self.assertIn("/os-vault:onboard-project", result.message) def test_present_graph_is_ok(self): with TemporaryDirectory() as tmp: root = git_project(tmp) (root / "graphify-out").mkdir() (root / "graphify-out" / "graph.json").write_text("{}") result = project_graph_present(make_ctx(project_root=root)) self.assertEqual("ok", result.status) class RemediationTest(unittest.TestCase): def test_every_registry_entry_has_a_remediation(self): for entry in checks.REGISTRY: self.assertTrue(entry.remediation, f"{entry.name} has no remediation") def test_known_remediations(self): by_name = {c.name: c.remediation for c in checks.REGISTRY} self.assertEqual("/os-adr:init", by_name["adr-system-present"]) self.assertEqual("/os-backlog:route", by_name["tracker-configured"]) self.assertEqual("/os-vault:onboard-project", by_name["project-graph-present"]) self.assertEqual("/os-status:fix", by_name["config-version-current"]) class JsonRunnerTest(unittest.TestCase): def test_shape_has_name_status_message_remediation(self): with TemporaryDirectory() as tmp: root = git_project(tmp) ctx = make_ctx(project_root=root) results = run_all_json(ctx) self.assertTrue(results) for entry in results: self.assertEqual({"name", "status", "message", "remediation"}, set(entry.keys())) def test_project_scoped_checks_skipped_when_no_project_root(self): results = run_all_json(make_ctx(project_root=None)) names = {r["name"] for r in results} self.assertNotIn("adr-system-present", names) self.assertNotIn("tracker-configured", names) def test_subprocess_json_flag_prints_valid_json_array(self): with TemporaryDirectory() as tmp: root = git_project(tmp) proc = subprocess.run( [sys.executable, str(PLUGIN_ROOT / "hooks" / "checks.py"), "--json"], cwd=str(root), capture_output=True, text=True, ) self.assertEqual(0, proc.returncode) payload = json.loads(proc.stdout) self.assertIsInstance(payload, list) names = {e["name"] for e in payload} self.assertIn("subagent-model-env-override", names) class WriteConfigValueTest(unittest.TestCase): def test_stamps_new_key(self): with TemporaryDirectory() as tmp: root = Path(tmp) write_config_value(root, "version", "1") self.assertEqual({"version": "1"}, read_config(root)) def test_preserves_existing_keys(self): with TemporaryDirectory() as tmp: root = Path(tmp) (root / ".cc-os").mkdir() (root / ".cc-os" / "config").write_text("hub = my-hub\ntracker = planka:board\n") write_config_value(root, "version", "1") self.assertEqual( {"hub": "my-hub", "tracker": "planka:board", "version": "1"}, read_config(root), ) def test_updates_existing_key_in_place(self): with TemporaryDirectory() as tmp: root = Path(tmp) (root / ".cc-os").mkdir() (root / ".cc-os" / "config").write_text("version = 0\nhub = my-hub\n") write_config_value(root, "version", "1") config = read_config(root) self.assertEqual("1", config["version"]) self.assertEqual("my-hub", config["hub"]) class BannerWordingTest(unittest.TestCase): def test_banner_header_names_fix_skill(self): self.assertIn("/os-status:fix", session_start.BANNER_HEADER) class RunnerTest(unittest.TestCase): """run() contract: routing, aggregation, snooze/suppress, isolation.""" def run_with(self, registry, ctx, state=None, today=TODAY): out = io.StringIO() with mock.patch.object(session_start, "REGISTRY", registry): session_start.run(ctx, state, today, out) raw = out.getvalue() return json.loads(raw) if raw else {} def check(self, name, result, project_scoped=False): return Check(name, lambda ctx: result, project_scoped) def test_all_ok_emits_nothing(self): registry = [self.check("a", CheckResult("ok")), self.check("b", CheckResult("ok"))] self.assertEqual({}, self.run_with(registry, make_ctx())) def test_note_passes_through_as_additional_context_without_banner(self): registry = [self.check("a", CheckResult("note", "hello"))] payload = self.run_with(registry, make_ctx()) self.assertEqual("hello", payload["hookSpecificOutput"]["additionalContext"]) self.assertNotIn("systemMessage", payload) def test_multiple_warns_aggregate_into_one_banner(self): registry = [ self.check("a", CheckResult("warn", "first problem")), self.check("b", CheckResult("warn", "second problem")), ] payload = self.run_with(registry, make_ctx()) banner = payload["systemMessage"] self.assertIn("first problem", banner) self.assertIn("second problem", banner) self.assertEqual(1, banner.count(session_start.BANNER_HEADER)) def test_raising_check_becomes_generic_warn_and_others_still_run(self): def boom(ctx): raise RuntimeError("kaboom") registry = [ Check("broken", boom, False), self.check("healthy", CheckResult("note", "still here")), ] payload = self.run_with(registry, make_ctx()) self.assertIn("broken", payload["systemMessage"]) self.assertNotIn("kaboom", payload["systemMessage"]) self.assertIn("still here", payload["hookSpecificOutput"]["additionalContext"]) def test_non_checkresult_return_becomes_generic_warn(self): registry = [Check("weird", lambda ctx: "nope", False)] payload = self.run_with(registry, make_ctx()) self.assertIn("weird", payload["systemMessage"]) def test_project_scoped_checks_skipped_outside_git_project(self): registry = [self.check("proj", CheckResult("warn", "boom"), project_scoped=True)] self.assertEqual({}, self.run_with(registry, make_ctx(project_root=None))) def test_warn_stamps_snooze_and_second_run_same_day_is_silent(self): with TemporaryDirectory() as tmp: root = git_project(tmp) state = StateDir(root) registry = [self.check("a", CheckResult("warn", "boom"))] ctx = make_ctx(project_root=root) first = self.run_with(registry, ctx, state) self.assertIn("boom", first["systemMessage"]) self.assertTrue((root / ".cc-os" / "snooze-a").exists()) self.assertEqual({}, self.run_with(registry, ctx, state)) def test_next_day_warns_again(self): with TemporaryDirectory() as tmp: root = git_project(tmp) state = StateDir(root) registry = [self.check("a", CheckResult("warn", "boom"))] ctx = make_ctx(project_root=root) self.run_with(registry, ctx, state, today=YESTERDAY) self.assertIn("boom", self.run_with(registry, ctx, state)["systemMessage"]) def test_suppress_marker_silences_permanently(self): with TemporaryDirectory() as tmp: root = git_project(tmp) (root / ".cc-os").mkdir() (root / ".cc-os" / "suppress-a").write_text("") state = StateDir(root) registry = [self.check("a", CheckResult("warn", "boom"))] self.assertEqual({}, self.run_with(registry, make_ctx(project_root=root), state)) def test_notes_are_never_snoozed_or_suppressed(self): with TemporaryDirectory() as tmp: root = git_project(tmp) (root / ".cc-os").mkdir() (root / ".cc-os" / "suppress-a").write_text("") (root / ".cc-os" / "snooze-a").write_text(TODAY.isoformat()) state = StateDir(root) registry = [self.check("a", CheckResult("note", "always"))] payload = self.run_with(registry, make_ctx(project_root=root), state) self.assertEqual("always", payload["hookSpecificOutput"]["additionalContext"]) def test_snoozed_warn_does_not_suppress_other_warns(self): with TemporaryDirectory() as tmp: root = git_project(tmp) (root / ".cc-os").mkdir() (root / ".cc-os" / "snooze-a").write_text(TODAY.isoformat()) state = StateDir(root) registry = [ self.check("a", CheckResult("warn", "old news")), self.check("b", CheckResult("warn", "new problem")), ] banner = self.run_with(registry, make_ctx(project_root=root), state)["systemMessage"] self.assertNotIn("old news", banner) self.assertIn("new problem", banner) class StateTest(unittest.TestCase): def test_read_config_parses_key_value_and_skips_comments(self): with TemporaryDirectory() as tmp: root = Path(tmp) (root / ".cc-os").mkdir() (root / ".cc-os" / "config").write_text( "# comment\nhub = my-hub\nvault_path=/somewhere\nbadline\n" ) self.assertEqual( {"hub": "my-hub", "vault_path": "/somewhere"}, read_config(root) ) def test_read_config_missing_is_empty(self): with TemporaryDirectory() as tmp: self.assertEqual({}, read_config(Path(tmp))) def test_corrupt_snooze_stamp_reads_as_unset(self): with TemporaryDirectory() as tmp: root = Path(tmp) (root / ".cc-os").mkdir() (root / ".cc-os" / "snooze-a").write_text("not-a-date") self.assertFalse(StateDir(root).snoozed("a", TODAY)) def test_find_project_root_walks_up(self): with TemporaryDirectory() as tmp: root = Path(tmp) (root / ".git").mkdir() nested = root / "a" / "b" nested.mkdir(parents=True) self.assertEqual(root, find_project_root(nested)) def test_find_project_root_none_outside_git(self): with TemporaryDirectory() as tmp: self.assertIsNone(find_project_root(Path(tmp))) class WordingInvariantTest(unittest.TestCase): """PRESENT_NOTE / ABSENT_NOTE must stay byte-identical to os-adr's hooks/session_start.py, the wording source of record (invariants.md).""" def os_adr_module(self): source = REPO_PLUGINS / "os-adr" / "hooks" / "session_start.py" spec = importlib.util.spec_from_file_location("os_adr_session_start", source) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def test_notes_byte_identical_to_os_adr(self): os_adr = self.os_adr_module() self.assertEqual(os_adr.PRESENT_NOTE, PRESENT_NOTE) self.assertEqual(os_adr.ABSENT_NOTE, ABSENT_NOTE) class ExitZeroEnvelopeTest(unittest.TestCase): def test_main_returns_zero_even_when_run_raises(self): with mock.patch.object(session_start, "run", side_effect=RuntimeError): self.assertEqual(0, session_start.main()) def test_subprocess_in_non_git_cwd_exits_zero_silently(self): with TemporaryDirectory() as tmp: env = {k: v for k, v in os.environ.items() if k != ENV_VAR} env["HOME"] = tmp # no settings.json, no vault proc = subprocess.run( [sys.executable, str(PLUGIN_ROOT / "hooks" / "session_start.py")], cwd=tmp, env=env, capture_output=True, text=True, ) self.assertEqual(0, proc.returncode) self.assertEqual("", proc.stdout) self.assertFalse((Path(tmp) / ".cc-os").exists()) def test_subprocess_with_env_override_warns_but_exits_zero(self): with TemporaryDirectory() as tmp: env = dict(os.environ) env["HOME"] = tmp env[ENV_VAR] = "haiku" proc = subprocess.run( [sys.executable, str(PLUGIN_ROOT / "hooks" / "session_start.py")], cwd=tmp, env=env, capture_output=True, text=True, ) self.assertEqual(0, proc.returncode) payload = json.loads(proc.stdout) self.assertIn(ENV_VAR, payload["systemMessage"]) if __name__ == "__main__": unittest.main()