From 625e6ec33f7aae4b6d5e218469e40fcccbea5d79 Mon Sep 17 00:00:00 2001 From: Angel Date: Sat, 12 Sep 2026 17:18:07 +0200 Subject: [PATCH] fix: linter was dead in a personal-store-only project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify_refs.main()` bailed whenever the team store directory was missing, even with `personalStoreDir` set and populated: $ python plugin/scripts/verify_refs.py --stats No learnings store at .../learnings - run /lore:init first. (exit 0) Same for the default mode, --strict, --report and --dupes. Personal-store-only is a supported setup — it is how you keep notes you do not want published — and in it recall and capture kept working, so the store looked healthy while /lore:lint, /lore:stats and /lore:sweep were all silently gone. The early return predates the personal store and was never revisited when it landed. The advice it printed was wrong too: the project IS initialized, just personal-only. Give up only when BOTH stores are absent. The traversal was already fine — an empty `learnings/` directory made every mode work — so this is the guard clause only. Scope is deliberately unchanged: the personal store still feeds --stats and nothing else, for the reasons documented in `_personal_entries` (not committed, so nothing to index and no push boundary to scan). Two consequences handled explicitly rather than left implicit: - --index is a no-op with a message when there is no team store, instead of writing a README into a directory that does not exist. - The check mode no longer reports "all entries have valid frontmatter file refs" after examining zero entries. A clean bill of health for an unchecked store is the same failure this commit fixes, one level down. Six tests in tests/test_verify_refs.py drive main() through the personal-only project; five of them fail without the guard change, and the sixth pins the message for a populated team store so the existing output cannot drift. Co-Authored-By: Claude Opus 5 --- plugin/scripts/verify_refs.py | 31 +++++++++++++--- tests/test_verify_refs.py | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) 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):