diff --git a/plugin/scripts/verify_refs.py b/plugin/scripts/verify_refs.py index 1af6152..dfce7d7 100644 --- a/plugin/scripts/verify_refs.py +++ b/plugin/scripts/verify_refs.py @@ -135,7 +135,15 @@ def cmd_check(entries, project, cfg, strict): issues.append((e, ref, actionable)) notes = [n for n in (config_warning(project), version_warning(project)) if n] if not issues: - print("OK learnings: all entries have valid frontmatter file refs.") + if entries: + print("OK learnings: all entries have valid frontmatter file refs.") + else: + # "all entries are valid" after checking none reads as a clean bill + # of health. Say what was actually examined -- the personal store is + # out of scope here (see `_personal_entries`), so a personal-only + # project would otherwise be told everything is fine, twice over. + print("OK learnings: no entries in the team store - nothing to " + "check (the personal store is not ref-checked).") for n in notes: print(f" {n}") return 0 @@ -554,10 +562,25 @@ def main(): project = find_project_dir() cfg = load_config(project) store = project / cfg["storeDir"] + # A project can legitimately run personal-store-only: `personalStoreDir` set, + # no team store. Bailing on the team store alone made every mode dead there + # while recall and capture kept working -- the store looked healthy and its + # maintenance tooling was silently gone. Only give up when BOTH are absent. + # Which entries each mode covers is unchanged: the personal store stays out + # of everything but --stats, for the reasons in `_personal_entries`. + personal = personal_store(project, cfg) + has_personal = personal is not None and personal.is_dir() if not store.is_dir(): - print(f"No learnings store at {store} - run /lore:init first.") - return 0 - entries = list(iter_entries(store)) + if not has_personal: + print(f"No learnings store at {store} - run /lore:init first.") + return 0 + if args.index: + # --index regenerates the team store's README; there is no team + # store, and the personal one is deliberately never indexed. + print(f"No team store at {store} - nothing to index " + f"(the personal store is never indexed).") + return 0 + entries = list(iter_entries(store)) if store.is_dir() else [] if args.index: return cmd_index(entries, project, cfg, store) if args.stats: diff --git a/tests/test_verify_refs.py b/tests/test_verify_refs.py index 3829f96..266c9b9 100644 --- a/tests/test_verify_refs.py +++ b/tests/test_verify_refs.py @@ -1,12 +1,15 @@ """Tests for verify_refs.py: dates, dupes, config, index, git drift.""" import contextlib import io +import json +import os import shutil import subprocess import tempfile import unittest from datetime import date, timedelta from pathlib import Path +from unittest import mock import _util # noqa: F401 from _common import DEFAULTS, iter_entries @@ -225,6 +228,71 @@ def test_absent_personal_store_prints_nothing(self): self.assertNotIn("personal store", out.getvalue()) +class TestPersonalOnlyStore(unittest.TestCase): + """A project may run personal-store-only: `personalStoreDir` set, no team store. + + Bailing on the team store alone used to kill every mode there, while recall + and capture kept working — the store looked healthy and its maintenance + tooling was silently gone. + """ + + def _run(self, proj, *argv): + out = io.StringIO() + with mock.patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(proj)}), \ + mock.patch("sys.argv", ["verify_refs.py", *argv]), \ + contextlib.redirect_stdout(out): + rc = verify_refs.main() + return rc, out.getvalue() + + def _project(self, with_team_store=False): + td = tempfile.TemporaryDirectory() + self.addCleanup(td.cleanup) + proj = Path(td.name) + (proj / ".lore.json").write_text( + json.dumps({"personalStoreDir": ".lore/personal"}), encoding="utf-8") + write_entry(proj / ".lore" / "personal", "prefs/p.md", title="P") + if with_team_store: + write_entry(proj / "learnings", "x/a.md", title="A") + return proj + + def test_default_mode_runs_instead_of_bailing(self): + rc, text = self._run(self._project()) + self.assertEqual(rc, 0) + self.assertNotIn("run /lore:init first", text) + self.assertIn("nothing to check", text) + + def test_stats_reports_the_personal_entries(self): + rc, text = self._run(self._project(), "--stats") + self.assertEqual(rc, 0) + self.assertIn("personal store: 1 entries", text) + + def test_report_and_dupes_run(self): + for flag in ("--report", "--dupes"): + with self.subTest(flag=flag): + rc, text = self._run(self._project(), flag) + self.assertEqual(rc, 0) + self.assertNotIn("run /lore:init first", text) + + def test_index_is_a_no_op_without_a_team_store(self): + proj = self._project() + rc, text = self._run(proj, "--index") + self.assertEqual(rc, 0) + self.assertIn("nothing to index", text) + self.assertFalse((proj / "learnings").exists()) + + def test_no_store_at_all_still_bails(self): + td = tempfile.TemporaryDirectory() + self.addCleanup(td.cleanup) + rc, text = self._run(Path(td.name)) + self.assertEqual(rc, 0) + self.assertIn("run /lore:init first", text) + + def test_populated_team_store_message_unchanged(self): + rc, text = self._run(self._project(with_team_store=True)) + self.assertEqual(rc, 0) + self.assertIn("all entries have valid frontmatter file refs", text) + + @unittest.skipUnless(GIT, "git not available") class TestGitDrift(unittest.TestCase): def _git(self, proj, *args):