diff --git a/README.md b/README.md index 0a7203a..56be7eb 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,8 @@ real entries from — under the same gate as any other capture, evidence read fr `git show` first. **Freshness.** Code changes; learnings shouldn't silently rot. `/lore:lint` checks -that each entry's `files:` still exist; `--report` ranks entries whose referenced code +that each entry's `files:` still exist and that its `status:` and dates are values the +hooks can read (a typo'd `superseeded` would be served as live guidance); `--report` ranks entries whose referenced code changed since they were last `verified:` (your re-verify worklist — computed in a single streaming `git log` pass, not one subprocess per file); `--dupes` finds near-duplicate entry pairs (e.g. two teammates capturing the same gotcha on parallel branches) and @@ -268,6 +269,9 @@ instead of the shared store): ``` `/lore:stats` counts personal entries so a two-store setup never looks half-empty. +A personal store **inside** the repo is private only while `.gitignore` covers it: +`/lore:lint` and `/lore:stats` warn when git would publish it (not ignored, or +files already committed). To make the **whole** store private instead, just add your store directory (e.g. `learnings/`) to `.gitignore`. diff --git a/plugin/commands/lint.md b/plugin/commands/lint.md index 7267f2f..f9c2459 100644 --- a/plugin/commands/lint.md +++ b/plugin/commands/lint.md @@ -14,8 +14,9 @@ PY=$(command -v python3 || command -v python || command -v py) "$PY" "${CLAUDE_PLUGIN_ROOT}/scripts/verify_refs.py" $ARGUMENTS ``` -Modes: no args = file-reference existence check (plus a note about any `.lore.json` -keys the hooks ignore — typos, wrong types) · `--report` = git drift triage +Modes: no args = file-reference existence check, plus `status:`/`date:`/`verified:` +values the hooks would misread (a typo'd status is served as live guidance), plus a +note about any `.lore.json` keys the hooks ignore — typos, wrong types · `--report` = git drift triage (entries whose code changed since they were verified) · `--dupes` = near-duplicate triage (entry pairs with overlapping title/tags, e.g. parallel captures from teammates, plus category-name variants) · `--index` = regenerate the store README · diff --git a/plugin/scripts/_common.py b/plugin/scripts/_common.py index 2154211..acdd447 100644 --- a/plugin/scripts/_common.py +++ b/plugin/scripts/_common.py @@ -195,8 +195,30 @@ def _unquote(s): return s +def _strip_comment(s): + """`s` without a trailing YAML comment: a `#` that opens the value or + follows whitespace, outside quotes -- so `C#` and `"see #12"` keep theirs.""" + if "#" not in s: # the common case; this runs per line on every prompt + return s + quote = None + for i, c in enumerate(s): + if quote: + if c == quote: + quote = None + elif c in "\"'" and (i == 0 or s[i - 1] in " \t[,"): + quote = c + elif c == "#" and (i == 0 or s[i - 1] in " \t"): + return s[:i].rstrip() + return s + + def parse_frontmatter(text): - """Parse the leading `--- ... ---` YAML-ish block (simple subset). Returns dict.""" + """Parse the leading `--- ... ---` YAML-ish block (simple subset). Returns dict. + + Trailing `# comments` are dropped as YAML does -- the documented entry + format carries them, and a comment kept in the value turned + `status: superseded # ...` into an unknown, i.e. LIVE, status. + """ m = re.match(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", text, re.S) if not m: return {} @@ -207,14 +229,14 @@ def parse_frontmatter(text): if not km: i += 1 continue - key, rest = km.group(1), km.group(2).strip() + key, rest = km.group(1), _strip_comment(km.group(2).strip()) if rest == "": # block list: subsequent ` - item` lines vals, j = [], i + 1 while j < len(lines): lm = re.match(r"^\s*-\s*(.+)$", lines[j]) if lm: - vals.append(_unquote(lm.group(1))) + vals.append(_unquote(_strip_comment(lm.group(1)))) j += 1 else: break diff --git a/plugin/scripts/verify_refs.py b/plugin/scripts/verify_refs.py index dfce7d7..5bd8a68 100644 --- a/plugin/scripts/verify_refs.py +++ b/plugin/scripts/verify_refs.py @@ -3,11 +3,12 @@ Modes: (default) Existence check -- flag entries whose frontmatter `files:` paths no - longer exist. `--strict` exits 1 on ACTIONABLE issues (a missing - file on a non-stale entry; missing files on superseded entries are - expected/informational). Also reports `.lore.json` keys the hooks - silently ignore (a typo like `maxRecal`), which is invisible at - hook time by design. + longer exist, and live entries whose `status:`/`date:`/`verified:` + the hooks would misread. `--strict` exits 1 on ACTIONABLE issues (a + missing file or a bad value on a non-stale entry; missing files on + superseded entries are expected/informational). Also reports + `.lore.json` keys the hooks silently ignore (a typo like + `maxRecal`), which is invisible at hook time by design. --report Drift triage -- for each current entry, use `git log` to find referenced files changed AFTER the entry's `verified:` (or `date:`) baseline, ranked by gap. Best candidates for a re-verify. Heuristic. @@ -123,17 +124,83 @@ def config_warning(project): return "note: .lore.json -- ignored: " + "; ".join(bits) + "." -# --- default mode: file-ref existence check ---------------------------------- +def personal_store_warning(project, cfg): + """Warn when git would publish the PRIVATE store, or None. + + A `personalStoreDir` inside the repository (`.lore/personal`) is private + only while .gitignore covers it, and /lore:init adds that rule only when + it creates the directory: a hand-set key, or a rule removed later, leaves + someone's working preferences one `git add -A` away from a PR. Nothing + else would say so -- the personal store is never secret-scanned or + linted, because it is supposed to never be pushed. + """ + store = personal_store(project, cfg) + if store is None or not store.is_dir(): + return None + rel = rel_path(project, store) + if Path(rel).is_absolute(): + return None # outside the repository: git never sees it + try: + tracked = subprocess.run( + ["git", "-C", str(project), "ls-files", "--", rel], + capture_output=True, text=True, timeout=15) + ignored = subprocess.run( + ["git", "-C", str(project), "check-ignore", "-q", rel], + capture_output=True, timeout=15) + except (OSError, subprocess.SubprocessError): + return None + if tracked.returncode != 0 or ignored.returncode not in (0, 1): + return None # not a git repository, or git failed: nothing to say + if tracked.stdout.strip(): + return (f"warning: personalStoreDir '{rel}' has files committed to " + f"git -- they are published. `git rm -r --cached {rel}`, then " + f"add '{rel}/' to .gitignore.") + if ignored.returncode == 1: + return (f"warning: personalStoreDir '{rel}' is inside the repository " + f"and not gitignored -- `git add -A` would publish it. Add " + f"'{rel}/' to .gitignore.") + return None + + +# --- default mode: file-ref existence + frontmatter value check -------------- + +def _value_problems(e, cfg): + """Frontmatter values the hooks silently misread, as messages. + + A `status:` that is neither `current` nor a stale status is served by + recall as LIVE guidance -- a typo'd `superseeded` resurrects a retired + entry. A `date:`/`verified:` that is not YYYY-MM-DD switches off the + entry's freshness signals (age flag, drift triage) without a word. + """ + problems = [] + known = ["current"] + list(cfg["staleStatuses"]) + if e["status"] not in known: + problems.append(f"unknown status '{e['status']}' -- recall reads it as " + f"current; use one of: {', '.join(known)}") + for key in ("date", "verified"): + value = e[key] + if value and not (_DATE_LINE.match(value) and _parse_date(value)): + problems.append(f"{key} '{value}' is not a YYYY-MM-DD date -- " + "freshness checks skip it") + return problems + def cmd_check(entries, project, cfg, strict): stale_set = set(cfg["staleStatuses"]) - issues = [] + issues, bad_values, missing_refs = [], False, False for e in entries: actionable = e["status"] not in stale_set + if actionable: # retired entries drive no guidance and no freshness + for problem in _value_problems(e, cfg): + issues.append((e, problem, True)) + bad_values = True for ref in e["files"]: if ref and not ref_exists(project, ref): - issues.append((e, ref, actionable)) - notes = [n for n in (config_warning(project), version_warning(project)) if n] + issues.append((e, f"referenced file no longer exists: {ref}", + actionable)) + missing_refs = True + notes = [n for n in (config_warning(project), version_warning(project), + personal_store_warning(project, cfg)) if n] if not issues: if entries: print("OK learnings: all entries have valid frontmatter file refs.") @@ -148,17 +215,21 @@ def cmd_check(entries, project, cfg, strict): print(f" {n}") return 0 actionable = [i for i in issues if i[2]] - print(f"\nlearnings file-ref check: {len(issues)} issue(s), {len(actionable)} actionable:") + print(f"\nlearnings check: {len(issues)} issue(s), {len(actionable)} actionable:") cur = None - for e, ref, act in issues: + for e, problem, act in issues: rel = e["path"].relative_to(project).as_posix() if rel != cur: print(f"\n {rel} [status: {e['status'] or 'current'}]") cur = rel note = "" if act else f" (expected -- entry is {e['status']})" - print(f" - referenced file no longer exists: {ref}{note}") - print("\n -> A 'current' entry with a missing file is likely STALE: fix the path,") - print(" mark status: superseded, or re-verify the claim against the code.") + print(f" - {problem}{note}") + if missing_refs: + print("\n -> A 'current' entry with a missing file is likely STALE: fix the path,") + print(" mark status: superseded, or re-verify the claim against the code.") + if bad_values: + print("\n -> Fix the value: a typo'd status is served as live guidance, and a") + print(" malformed date silently turns off the entry's freshness checks.") for n in notes: print(f" {n}") return 1 if (strict and actionable) else 0 @@ -504,7 +575,8 @@ def counts(d): tail = " -> verify_refs.py --dupes" if dupes else "" print(f" near-duplicate pairs (title/tag overlap): {dupes}{tail}") print(f" links: dangling [[refs]] (soft; forward-refs ok): {len(dangling)}") - for n in (config_warning(project), version_warning(project)): + for n in (config_warning(project), version_warning(project), + personal_store_warning(project, cfg)): if n: print(f"\n {n}") return 0 diff --git a/plugin/templates/lore-ci.yml b/plugin/templates/lore-ci.yml index bfb410e..186f20d 100644 --- a/plugin/templates/lore-ci.yml +++ b/plugin/templates/lore-ci.yml @@ -38,7 +38,8 @@ jobs: run: python .lore/scan_secrets.py # Freshness. --strict fails when a `current` entry points at a file that - # no longer exists, which is the cheapest possible staleness signal. - # Drop --strict to make this advisory instead of blocking. + # no longer exists, which is the cheapest possible staleness signal, or + # has a status/date the hooks would misread (a typo'd status is served + # as live guidance). Drop --strict to make this advisory, not blocking. - name: Freshness check run: python .lore/verify_refs.py --strict diff --git a/tests/test_common.py b/tests/test_common.py index 108b408..b221615 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -30,6 +30,44 @@ def test_empty_list_and_missing_block(self): self.assertEqual(parse_frontmatter("---\ntags: []\n---\n")["tags"], []) self.assertEqual(parse_frontmatter("no frontmatter here"), {}) + def test_trailing_comments_are_dropped(self): + # They used to become part of the value: `superseded # replaced` + # was not a stale status, so recall served a dead entry as live. + fm = parse_frontmatter( + "---\n" + "status: superseded # replaced by the v2 cursor\n" + "verified: 2026-01-01 # bump after a re-verify\n" + "files: [src/a.py] # exact path or glob (src/*.py)\n" + "tags: # none yet\n" + "---\n") + self.assertEqual(fm["status"], "superseded") + self.assertEqual(fm["verified"], "2026-01-01") + self.assertEqual(fm["files"], ["src/a.py"]) + self.assertEqual(fm["tags"], []) + + def test_hash_inside_a_value_is_kept(self): + fm = parse_frontmatter( + "---\n" + 'title: "Why #1 wins" # note\n' + "category: C#\n" + 'tags: [a, "b # c"]\n' + "files:\n - src/x.py # the entry point\n" + "---\n") + self.assertEqual(fm["title"], "Why #1 wins") + self.assertEqual(fm["category"], "C#") + self.assertEqual(fm["tags"], ["a", "b # c"]) + self.assertEqual(fm["files"], ["src/x.py"]) + + def test_documented_example_parses_as_documented(self): + readme = (Path(__file__).resolve().parents[1] / "README.md").read_text( + encoding="utf-8") + example = readme.split("```markdown\n", 1)[1].split("```", 1)[0] + fm = parse_frontmatter(example) + self.assertEqual(fm["track"], "knowledge") + self.assertEqual(fm["files"], [".github/workflows/ci.yml"]) + self.assertEqual(fm["status"], "current") + self.assertEqual(fm["verified"], "2026-01-01") + class TestLoadConfig(unittest.TestCase): def _cfg(self, payload): diff --git a/tests/test_verify_refs.py b/tests/test_verify_refs.py index 266c9b9..5c775cf 100644 --- a/tests/test_verify_refs.py +++ b/tests/test_verify_refs.py @@ -103,6 +103,46 @@ def test_silent_when_clean(self): self.assertIsNone(verify_refs.config_warning(Path(td))) +class TestFrontmatterValues(unittest.TestCase): + """Values the hooks silently misread: a typo'd status, a non-ISO date.""" + + def _check(self, cfg=None, **entry): + td = tempfile.TemporaryDirectory() + self.addCleanup(td.cleanup) + proj = Path(td.name) + write_entry(proj / "learnings", "x/e.md", title="E", **entry) + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = verify_refs.cmd_check(list(iter_entries(proj / "learnings")), + proj, cfg or dict(DEFAULTS), True) + return rc, out.getvalue() + + def test_typoed_status_is_actionable(self): + # Not a stale status, so recall serves the entry as LIVE guidance. + rc, out = self._check(status="superseeded") + self.assertEqual(rc, 1) + self.assertIn("unknown status 'superseeded'", out) + + def test_unparseable_dates_are_actionable(self): + rc, out = self._check(date="last week", verified="2026-02-30") + self.assertEqual(rc, 1) + self.assertIn("date 'last week'", out) + self.assertIn("verified '2026-02-30'", out) + + def test_valid_values_are_clean(self): + rc, out = self._check(date="2026-01-01", verified="2026-02-01") + self.assertEqual(rc, 0) + self.assertIn("OK", out) + + def test_configured_stale_status_is_valid(self): + cfg = dict(DEFAULTS, staleStatuses=["archived"]) + self.assertEqual(self._check(cfg=cfg, status="archived")[0], 0) + + def test_stale_entries_are_not_value_checked(self): + # Retired entries drive no live guidance and no freshness flags. + self.assertEqual(self._check(status="superseded", date="someday")[0], 0) + + class TestIndex(unittest.TestCase): def test_writes_grouped_index(self): with tempfile.TemporaryDirectory() as td: @@ -293,6 +333,62 @@ def test_populated_team_store_message_unchanged(self): self.assertIn("all entries have valid frontmatter file refs", text) +@unittest.skipUnless(GIT, "git not available") +class TestPersonalStorePublication(unittest.TestCase): + """An in-repo personal store stays private only while git ignores it.""" + + def _git(self, proj, *args): + subprocess.run( + [GIT, "-C", str(proj), "-c", "user.email=t@t", "-c", + "user.name=t", *args], + check=True, capture_output=True) + + def _project(self, gitignore=""): + td = tempfile.TemporaryDirectory() + self.addCleanup(td.cleanup) + proj = Path(td.name) + self._git(proj, "init", "-q") + write_entry(proj / "notes", "prefs/diff.md", title="Show the diff first") + if gitignore: + (proj / ".gitignore").write_text(gitignore, encoding="utf-8") + return proj + + def _warn(self, proj, personal="notes"): + return verify_refs.personal_store_warning( + proj, dict(DEFAULTS, personalStoreDir=personal)) + + def test_unignored_store_is_flagged(self): + self.assertIn("not gitignored", self._warn(self._project()) or "") + + def test_ignored_store_is_silent(self): + self.assertIsNone(self._warn(self._project(gitignore="notes/\n"))) + + def test_committed_store_is_flagged_even_once_ignored(self): + proj = self._project(gitignore="notes/\n") + self._git(proj, "add", "-f", "notes") + self._git(proj, "commit", "-q", "-m", "oops") + self.assertIn("committed", self._warn(proj) or "") + + def test_store_outside_the_repository_is_not_checked(self): + outside = tempfile.TemporaryDirectory() + self.addCleanup(outside.cleanup) + self.assertIsNone(self._warn(self._project(), personal=outside.name)) + + def test_lint_and_stats_print_it(self): + proj = self._project() + (proj / ".lore.json").write_text( + json.dumps({"personalStoreDir": "notes"}), encoding="utf-8") + for argv in ([], ["--stats"]): + out = io.StringIO() + with self.subTest(argv=argv), \ + mock.patch.dict(os.environ, + {"CLAUDE_PROJECT_DIR": str(proj)}), \ + mock.patch("sys.argv", ["verify_refs.py", *argv]), \ + contextlib.redirect_stdout(out): + self.assertEqual(verify_refs.main(), 0) + self.assertIn("personalStoreDir 'notes'", out.getvalue()) + + @unittest.skipUnless(GIT, "git not available") class TestGitDrift(unittest.TestCase): def _git(self, proj, *args):