diff --git a/dokimi/changelog.d/20261113_120000_napetrov_memory-lint.md b/dokimi/changelog.d/20261113_120000_napetrov_memory-lint.md new file mode 100644 index 000000000..d0c1eb3e0 --- /dev/null +++ b/dokimi/changelog.d/20261113_120000_napetrov_memory-lint.md @@ -0,0 +1,9 @@ +### Added + +- **`dokimi memory lint` checks an agent's memory directory** — the first leaf of the `memory` root (#728). + It reads a Claude Code auto-memory directory (`MEMORY.md`, topic files, `archive/`) and reports, with no + model call: index links to missing files (`fail`), an index past the 200-line / 25KB load limit (`fail`), + files the index never links, frontmatter that is missing, not YAML or lacks `name`/`description`/`type`, + unknown types, duplicate names, dangling `[[wikilinks]]`, relative dates ("today", "вчера") with no date + next to them, and empty list slots left in the index. Exit 1 on a `fail` (on a `warn` too with `--fail-on warn`); + `--json` prints `cli_output.v1`. diff --git a/dokimi/docs/README.md b/dokimi/docs/README.md index 25281a56b..547948298 100644 --- a/dokimi/docs/README.md +++ b/dokimi/docs/README.md @@ -327,6 +327,9 @@ code. - [context-window.md](context-window.md) — read a run's context window from its Claude Code trace with `cli.py trace inspect`: start-up load, growth, compactions, tool-result weight. No LLM call. +- [memory.md](memory.md) — check an agent's memory directory (Claude Code auto + memory) with `dokimi memory lint`: index links and load limit, frontmatter, + wikilinks, undated relative dates. No LLM call. - [session-prefilter.md](session-prefilter.md) — which AGENTS.md or CLAUDE.md rules the Claude Code session logs triggered, acted on and were corrected on, with `cli.py trace prefilter`: candidates for ablation, not verdicts. No LLM call. diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index cafc3eefe..1e391e843 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -443,6 +443,12 @@ against them (trigger, acted, corrected) and ranks candidates for ablation. Both are deterministic; `python cli.py trace prefilter`. Guide: [session-prefilter.md](session-prefilter.md). +## Agent memory + +`dokimi/memory/store.py` reads a Claude Code auto-memory directory (`MEMORY.md`, +topic files with YAML frontmatter, `archive/`); `dokimi/memory/lint.py` runs +deterministic checks on it; `dokimi memory lint`. Guide: [memory.md](memory.md). + ## Shared building blocks - `dokimi/process.py` — the one place in the package that spawns a process diff --git a/dokimi/docs/memory.md b/dokimi/docs/memory.md new file mode 100644 index 000000000..5e9b26902 --- /dev/null +++ b/dokimi/docs/memory.md @@ -0,0 +1,38 @@ + + +# Agent memory + +`dokimi memory` treats an agent's memory store as a subject (tracking issue +[#728](https://github.com/intel/dokimi/issues/728)). The first reader is Claude Code auto memory, +`~/.claude/projects//memory/`: a `MEMORY.md` index whose first 200 lines or 25KB load at +every session start, and one topic file per memory with YAML frontmatter (`name`, `description`, +`type`: user, feedback, project or reference). Files under `archive/` are read and counted, but not checked and never expected in the index. + +## memory lint + +```bash +dokimi memory lint ~/.claude/projects//memory +dokimi memory lint ~/.claude/projects//memory --json --fail-on warn +``` + +No model call. Each finding has a rule, a severity, a file and a line: + +| rule | severity | what | +|---|---|---| +| `index-dead-link` | fail | the index links a file that does not exist, a directory, or a path outside the memory directory (code spans, fenced blocks and HTML comments hold no link) | +| `index-over-limit` | fail | the index is past the load limit (200 lines or 25KB, as Claude Code loads it) | +| `index-near-limit` | warn | over 80% of the limit | +| `index-missing` | warn | no `MEMORY.md` | +| `index-orphan` | warn | a top-level file the index never links | +| `frontmatter-missing`, `frontmatter-invalid`, `frontmatter-field` | warn | no frontmatter, not YAML, no `name`/`description`/`type` | +| `type-unknown` | warn | a type other than the four | +| `name-duplicate` | warn | two files share a `name` | +| `relative-date` | warn | "today", "next week", "вчера" on a line with no `YYYY-MM-DD` date (code spans, fenced blocks and HTML comments skipped) | +| `wikilink-dangling` | note | `[[x]]` matches no file stem or name (`-` and `_` fold) | +| `index-stray-separator` | note | `:,`, `,,` or a trailing `,` in an index line | + +Exit 0 when there is no `fail` (no `warn` either with `--fail-on warn`), 1 otherwise, 2 when the path is +not a memory directory or its `MEMORY.md` cannot be read. diff --git a/dokimi/docs/reference/cli-interface.json b/dokimi/docs/reference/cli-interface.json index 5d94b47b7..d6dc5914a 100644 --- a/dokimi/docs/reference/cli-interface.json +++ b/dokimi/docs/reference/cli-interface.json @@ -1195,6 +1195,28 @@ } } }, + "memory": { + "allow_abbrev": true, + "params": [ + {"key": "--help/-h", "action": "_HelpAction", "choices": null, "const": null, "default": "==SUPPRESS==", "group_key": null, "group_required": null, "hidden": false, "is_flag": true, "kind": "option", "nargs": 0, "position": null, "required": false, "type": null} + ], + "path": "dokimi memory", + "subcommand_required": true, + "subcommands": { + "lint": { + "allow_abbrev": true, + "params": [ + {"key": "memory_dir", "action": "_StoreAction", "choices": null, "const": null, "default": null, "group_key": null, "group_required": null, "hidden": false, "is_flag": false, "kind": "argument", "nargs": null, "position": 0, "required": true, "type": null}, + {"key": "--fail-on", "action": "_StoreAction", "choices": ["'fail'", "'warn'"], "const": null, "default": "fail", "group_key": null, "group_required": null, "hidden": false, "is_flag": false, "kind": "option", "nargs": null, "position": null, "required": false, "type": null}, + {"key": "--help/-h", "action": "_HelpAction", "choices": null, "const": null, "default": "==SUPPRESS==", "group_key": null, "group_required": null, "hidden": false, "is_flag": true, "kind": "option", "nargs": 0, "position": null, "required": false, "type": null}, + {"key": "--json", "action": "_StoreTrueAction", "choices": null, "const": true, "default": false, "group_key": null, "group_required": null, "hidden": false, "is_flag": true, "kind": "option", "nargs": 0, "position": null, "required": false, "type": null} + ], + "path": "dokimi memory lint", + "subcommand_required": null, + "subcommands": {} + } + } + }, "novelty": { "allow_abbrev": true, "params": [ diff --git a/dokimi/dokimi/__init__.py b/dokimi/dokimi/__init__.py index c8cccae2e..cc858f110 100644 --- a/dokimi/dokimi/__init__.py +++ b/dokimi/dokimi/__init__.py @@ -25,6 +25,7 @@ directories; [`task_formats`][dokimi.task_formats] names the format of other eval-task files and directories (detection only). - [`context`][dokimi.context] reads what a run's context window held. +- [`memory`][dokimi.memory] reads an agent's memory store and lints it. - [`runs`][dokimi.runs] lists what the runs wrote: the artifacts under the runs root (`results/`, or `DOKIMI_RUNS_DIR`). - Content novelty: [`novelty`][dokimi.novelty]. Repositories as subjects: diff --git a/dokimi/dokimi/cli.py b/dokimi/dokimi/cli.py index ea5f96f20..f485ab834 100755 --- a/dokimi/dokimi/cli.py +++ b/dokimi/dokimi/cli.py @@ -25,6 +25,7 @@ data, evaluate, explore, + memory, novelty, orchestrate, personas, @@ -227,6 +228,7 @@ def positive_int(value: str) -> int: novelty.register(sub, positive_int) # novelty (view: not yet moved) repo.register(sub, positive_int, eval_sub) # repo; eval check tools.register(sub, positive_int) # tools (tool-choice probes) + memory.register(sub, positive_int) # memory: lint (#728) context.register(sub, positive_int) # trace: inspect, consistency, prefilter (#94 M2) runs.register(sub, positive_int) # runs (the artifacts under the runs root) backends.register(sub, positive_int) # backends (CLI providers) diff --git a/dokimi/dokimi/cli_routes.py b/dokimi/dokimi/cli_routes.py index 7b19f579d..ade0eafea 100644 --- a/dokimi/dokimi/cli_routes.py +++ b/dokimi/dokimi/cli_routes.py @@ -81,6 +81,7 @@ class Tombstone: Root("skill", "Subjects"), Root("novelty", "Subjects"), Root("repo", "Subjects"), + Root("memory", "Subjects"), Root("eval", "Evals"), Root("tools", "Analysis and data"), Root("trace", "Analysis and data"), @@ -174,6 +175,7 @@ class Tombstone: Leaf(("data", "export"), FREE), Leaf(("data", "import", "docling"), FREE), Leaf(("tools", "preference"), PAID), + Leaf(("memory", "lint"), FREE), Leaf(("trace", "inspect"), FREE), Leaf(("trace", "consistency"), FREE), Leaf(("trace", "prefilter"), FREE), diff --git a/dokimi/dokimi/commands/memory.py b/dokimi/dokimi/commands/memory.py new file mode 100644 index 000000000..7607f2178 --- /dev/null +++ b/dokimi/dokimi/commands/memory.py @@ -0,0 +1,48 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""memory subcommand group: an agent's memory store as a subject (#728).""" + +from __future__ import annotations + +import argparse +import sys + +from dokimi.utils import InputError + + +def cmd_memory_lint(args: argparse.Namespace) -> None: + """Deterministic checks of a memory directory; exit 1 when a finding is ``fail`` (or ``warn`` with --fail-on warn).""" + from dokimi import cli_output + from dokimi.memory import MemoryStoreError, read_claude_memory + from dokimi.memory.lint import lint, render_text, report + + try: + store = read_claude_memory(args.memory_dir) + except (MemoryStoreError, OSError) as exc: + raise InputError(str(exc)) from None + doc = report(store, lint(store)) + bad = doc["counts"]["fail"] or (args.fail_on == "warn" and doc["counts"]["warn"]) + if args.json: + reason = f"{doc['counts']['fail']} fail, {doc['counts']['warn']} warn under --fail-on {args.fail_on}" if bad else None + cli_output.emit_leaf("memory lint", doc, status="failed" if bad else "complete", reason=reason) + else: + print(render_text(doc)) + if bad: + sys.exit(1) + + +def register(sub, positive_int) -> None: + """Add the `memory` group: check an agent's memory store. No LLM call.""" + mem_p = sub.add_parser("memory", help="An agent's memory store (Claude Code auto memory) as a subject. No LLM call.") + mem_sub = mem_p.add_subparsers(dest="memory_cmd", required=True) + lint_p = mem_sub.add_parser( + "lint", + help="Deterministic checks of a memory directory: index dead links, orphans and load limit, " + "frontmatter fields and types, duplicate names, dangling [[wikilinks]], undated relative dates.", + ) + lint_p.add_argument("memory_dir", help="The memory directory, e.g. ~/.claude/projects//memory") + lint_p.add_argument("--fail-on", choices=("warn", "fail"), default="fail", + help="Exit 1 on a finding of this severity or worse (default fail)") + lint_p.add_argument("--json", action="store_true", help="Print cli_output.v1 on stdout (the report under data)") + lint_p.set_defaults(func=cmd_memory_lint, emits_cli_output=True, cli_kind="memory lint") diff --git a/dokimi/dokimi/memory/__init__.py b/dokimi/dokimi/memory/__init__.py new file mode 100644 index 000000000..507ce80b9 --- /dev/null +++ b/dokimi/dokimi/memory/__init__.py @@ -0,0 +1,13 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Agent memory as an evaluation subject: read a harness's memory store, then check it. + +The first reader is Claude Code auto memory (``~/.claude/projects//memory/``): +a ``MEMORY.md`` index loaded at every session start and one topic file per memory. +Tracking issue: https://github.com/intel/dokimi/issues/728 +""" + +from dokimi.memory.store import MemoryEntry, MemoryStore, MemoryStoreError, read_claude_memory + +__all__ = ["MemoryEntry", "MemoryStore", "MemoryStoreError", "read_claude_memory"] diff --git a/dokimi/dokimi/memory/lint.py b/dokimi/dokimi/memory/lint.py new file mode 100644 index 000000000..1bf45b352 --- /dev/null +++ b/dokimi/dokimi/memory/lint.py @@ -0,0 +1,187 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Deterministic checks of a memory store: index links and size, frontmatter, links, dates. + +No model call and no network. Every finding names a rule, a severity, a file and a line. +Severities: ``fail`` (the index points at nothing, or loses entries past the load limit), +``warn`` (the entry is likely wrong or stale) and ``note`` (worth a look, often fine). +""" + +from __future__ import annotations + +import re +from collections import Counter +from collections.abc import Callable, Iterable +from dataclasses import asdict, dataclass +import posixpath + +from dokimi.memory.store import ARCHIVE_DIR, INDEX_NAME, MemoryEntry, MemoryStore, prose_lines, slug + +#: The four kinds Claude Code writes (https://code.claude.com/docs/en/memory). +MEMORY_TYPES = ("user", "feedback", "project", "reference") +#: Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first. +INDEX_MAX_LINES = 200 +INDEX_MAX_BYTES = 25 * 1024 +SEVERITIES = ("fail", "warn", "note") + +_RELATIVE = re.compile( + r"\b(today|yesterday|tomorrow|tonight|this (?:week|morning|afternoon)|next week|last week)\b" + r"|(? Iterable[Finding]: + index = store.index + if index is None: + yield Finding("index-missing", "warn", INDEX_NAME, None, + f"no {INDEX_NAME}: nothing is loaded at session start") + return + lines = index.text.split("\n") + lines = lines[:-1] if lines and not lines[-1] else lines + size = _index_bytes(store) + if len(lines) > max_lines or size > max_bytes: + yield Finding("index-over-limit", "fail", INDEX_NAME, min(len(lines), max_lines + 1), + f"{len(lines)} lines / {size} bytes; only the first {max_lines} lines or " + f"{max_bytes} bytes load, so later entries are never seen at start") + elif len(lines) > 0.8 * max_lines or size > 0.8 * max_bytes: + yield Finding("index-near-limit", "warn", INDEX_NAME, None, + f"{len(lines)} lines / {size} bytes, over 80% of the load limit " + f"({max_lines} lines, {max_bytes} bytes)") + for n, line in enumerate(lines, 1): + if line.lstrip().startswith(("-", "*")) and _STRAY_SEPARATOR.search(line): + yield Finding("index-stray-separator", "note", INDEX_NAME, n, + "empty list slot (':,', ',,' or a trailing ','), often left by removing a link") + + +def _index_bytes(store: MemoryStore) -> int: + """The size on disk, CRLF and BOM counted: that is what the harness truncates.""" + try: + return (store.root / INDEX_NAME).stat().st_size + except OSError: + return len(store.index.text.encode("utf-8")) if store.index else 0 + + +def _link_findings(store: MemoryStore) -> Iterable[Finding]: + linked: set[str] = set() + root = store.root.resolve() + for n, target in store.index_links(): + try: # resolved, so a symlink or an absolute path is judged by where it lands + full = (store.root / target).resolve() + except (OSError, RuntimeError, ValueError): + full = root.parent + if not full.is_relative_to(root): + yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, outside the memory directory") + elif full.is_file(): + linked.update({posixpath.normpath(target), full.relative_to(root).as_posix()}) + else: + yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, which is not a file") + if store.index is None: + return + for entry in store.entries: + if not entry.archived and entry.path not in linked: + yield Finding("index-orphan", "warn", entry.path, None, + f"not linked from {INDEX_NAME}: the agent only finds it by searching") + + +def _frontmatter_findings(entry: MemoryEntry) -> Iterable[Finding]: + if entry.frontmatter_error: + yield Finding("frontmatter-invalid", "warn", entry.path, 1, entry.frontmatter_error) + return + if entry.frontmatter is None: + yield Finding("frontmatter-missing", "warn", entry.path, 1, + "no YAML frontmatter: no name, description or type to recall it by") + return + for key in ("name", "description"): + if not entry.frontmatter.get(key): + yield Finding("frontmatter-field", "warn", entry.path, 1, f"frontmatter has no '{key}'") + kind = entry.type + if kind is None: + yield Finding("frontmatter-field", "warn", entry.path, 1, "frontmatter has no 'type'") + elif kind not in MEMORY_TYPES: + yield Finding("type-unknown", "warn", entry.path, 1, + f"type '{kind}' is not one of {', '.join(MEMORY_TYPES)}") + + +def _entry_findings(store: MemoryStore) -> Iterable[Finding]: + slugs = store.slugs() + live = [entry for entry in store.entries if not entry.archived] # archive/ is history: not checked + names = Counter(entry.name for entry in live if entry.name) + for entry in live: + yield from _frontmatter_findings(entry) + if entry.name and names[entry.name] > 1: + yield Finding("name-duplicate", "warn", entry.path, 1, + f"name '{entry.name}' is used by {names[entry.name]} files") + for n, target in entry.wikilinks: + if slug(target) not in slugs: + yield Finding("wikilink-dangling", "note", entry.path, n, + f"[[{target}]] matches no file stem or name (fine if it marks a memory to write)") + yield from _date_findings(entry) + + +def _date_findings(entry: MemoryEntry) -> Iterable[Finding]: + offset = entry.text.count("\n", 0, len(entry.text) - len(entry.body)) + prose = prose_lines(entry.body) # code spans, fences and HTML comments hold no claim about today + for n, (raw, line) in enumerate(zip(entry.body.split("\n"), prose, strict=True), offset + 1): + if _DATE.search(raw): # a dated line says when "today" was + continue + for m in _RELATIVE.finditer(line): + if not _NOT_A_WORD_USE.match(line, m.end()): + yield Finding("relative-date", "warn", entry.path, n, + f"'{m.group(0)}' without a date: it is wrong on the next read") + + +def lint(store: MemoryStore, *, max_lines: int = INDEX_MAX_LINES, max_bytes: int = INDEX_MAX_BYTES) -> list[Finding]: + """Every finding of ``store``, in rule order, then file and line.""" + checks: tuple[Callable[[], Iterable[Finding]], ...] = ( + lambda: _index_findings(store, max_lines, max_bytes), + lambda: _link_findings(store), + lambda: _entry_findings(store), + ) + return [f for check in checks for f in check()] + + +def report(store: MemoryStore, findings: list[Finding], *, max_lines: int = INDEX_MAX_LINES, + max_bytes: int = INDEX_MAX_BYTES) -> dict: + """The ``memory lint`` document: counts, then every finding.""" + by_sev = Counter(f.severity for f in findings) + by_rule = Counter(f.rule for f in findings) + index_text = store.index.text if store.index else "" + index_lines = index_text.split("\n") + return { + "root": str(store.root), + "entries": sum(not e.archived for e in store.entries), + "archived": sum(e.archived for e in store.entries), + "archive_dir": ARCHIVE_DIR, + "index": {"lines": len(index_lines) - (not index_lines[-1]), "bytes": _index_bytes(store), + "max_lines": max_lines, "max_bytes": max_bytes}, + "types": dict(Counter(e.type or "none" for e in store.entries if not e.archived)), + "counts": {s: by_sev.get(s, 0) for s in SEVERITIES}, + "rules": dict(sorted(by_rule.items())), + "findings": [asdict(f) for f in findings], + } + + +def render_text(doc: dict) -> str: + """Findings one per line, ``severity rule file:line message``, then a counts line.""" + out = [] + for f in doc["findings"]: + where = f"{f['file']}:{f['line']}" if f["line"] else f["file"] + out.append(f"{f['severity']:<4} {f['rule']:<22} {where} {f['message']}") + c, idx = doc["counts"], doc["index"] + out.append(f"{doc['entries']} entries ({doc['archived']} archived), index {idx['lines']} lines / " + f"{idx['bytes']} bytes: {c['fail']} fail, {c['warn']} warn, {c['note']} note") + return "\n".join(out) diff --git a/dokimi/dokimi/memory/store.py b/dokimi/dokimi/memory/store.py new file mode 100644 index 000000000..6dc3586c3 --- /dev/null +++ b/dokimi/dokimi/memory/store.py @@ -0,0 +1,172 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Read a Claude Code auto-memory directory into entries: the index, topic files, the archive. + +Layout (https://code.claude.com/docs/en/memory): ``MEMORY.md`` is the index, every other +``*.md`` is a topic file with optional YAML frontmatter (``name``, ``description``, ``type``, +``modified``). Files under ``archive/`` are kept but not linked from the index. +""" + +from __future__ import annotations + +import re +from urllib.parse import unquote +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +INDEX_NAME = "MEMORY.md" +ARCHIVE_DIR = "archive" +_FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(?:(.*?)\r?\n)??---[ \t]*(?:\r?\n|\Z)", re.S) +_MD_LINK = re.compile(r"\]\(\s*(<[^>\n]+>|(?:[^()\s]|\([^()\s]*\))+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)") +_MD_REF_DEF = re.compile(r"^ {0,3}\[(?!\^)[^\]\n]+\]:[ \t]*(<[^>\n]+>|\S+)") +_WIKILINK = re.compile(r"\[\[([^\]\n]+)\]\]") +#: A code span (a backtick run closed by a run of the same length, on one line) or an HTML comment, whichever starts first. +_HIDDEN = re.compile(r"(?", re.S) +_ESCAPE = re.compile(r"\\([!-/:-@\[-`{-~])") +_FENCE = re.compile(r" {0,3}(`{3,}|~{3,})") + + +class MemoryStoreError(ValueError): + """The path is not a memory directory that can be read.""" + + +@dataclass +class MemoryEntry: + """One memory file. ``path`` is relative to the memory directory, with ``/`` separators.""" + + path: str + text: str + body: str + frontmatter: dict | None + frontmatter_error: str | None = None + archived: bool = False + + @property + def name(self) -> str | None: + value = (self.frontmatter or {}).get("name") + return str(value) if value else None + + @property + def type(self) -> str | None: + """``type`` at the top level, or under ``metadata`` as some writers put it.""" + fm = self.frontmatter or {} + meta = fm.get("metadata") + value = fm.get("type") or (meta.get("type") if isinstance(meta, dict) else None) + return str(value) if value else None + + @property + def wikilinks(self) -> list[tuple[int, str]]: + """``(line, target)`` of every ``[[target]]``, 1-based lines of the whole file.""" + return [(n, re.split(r"[|#]", m.group(1), maxsplit=1)[0].strip()) + for n, line in enumerate(self.text.split("\n"), 1) + for m in _WIKILINK.finditer(line)] + + +@dataclass +class MemoryStore: + """A memory directory: the index (``None`` when absent) and every entry under it.""" + + root: Path + index: MemoryEntry | None + entries: list[MemoryEntry] = field(default_factory=list) + + def slugs(self) -> dict[str, MemoryEntry]: + """Every name an entry answers to: its file stem and its ``name``, ``-`` and ``_`` folded.""" + out: dict[str, MemoryEntry] = {} + for entry in self.entries: + for key in (Path(entry.path).stem, entry.name): + if key: + out.setdefault(slug(key), entry) + return out + + def index_links(self) -> list[tuple[int, str]]: + """``(line, target)`` of every relative markdown link in the index (URLs and anchors left out).""" + if self.index is None: + return [] + out = [] + for n, line in enumerate(prose_lines(self.index.text), 1): + for m in [*_MD_LINK.finditer(line), *_MD_REF_DEF.finditer(line)]: + target = unquote(_ESCAPE.sub(r"\1", m.group(1).strip("<>")).split("#", 1)[0]) + if target and "://" not in target and not target.startswith("mailto:"): + out.append((n, target)) + return out + + +def prose_lines(text: str) -> list[str]: + """The lines of ``text`` with HTML comments, fenced blocks and code spans blanked: no link lives there.""" + out: list[str] = [] + fence = "" + in_comment = False + for line in text.split("\n"): + m = _FENCE.match(line) + if fence: + if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line[m.end():].strip(): + fence = "" + out.append("") + continue + if in_comment: + end = line.find("-->") + if end < 0: + out.append("") + continue + line = " " * (end + 3) + line[end + 3:] + in_comment = False + elif m: + fence = m.group(1) + out.append("") + continue + line = _HIDDEN.sub(lambda h: " " * len(h.group(0)), line) + opener = line.find("\n```md\n[x](fenced.md)\n~~~\n```\n- [Gone](gone.md)\n") + dead = [(f.line, f.message) for f in lint(read_claude_memory(mem)) if f.rule == "index-dead-link"] + assert dead == [(8, "links gone.md, which is not a file")] + + +def test_parenthesised_targets_and_reference_definitions_are_links(mem): + (mem / "topic(v2).md").write_text(_entry("t")) + (mem / "MEMORY.md").write_text("- [A](a.md) [T](topic(v2).md) [B][b] [G][g]\n\n[b]: b.md\n[g]: \n") + dead = [(f.rule, f.line) for f in lint(read_claude_memory(mem)) if f.severity != "note"] + assert dead == [("index-dead-link", 4)] + + +def test_containment_is_judged_after_resolving(tmp_path): + mem = tmp_path / "m" + mem.mkdir() + (tmp_path / "outside.md").write_text(_entry("o")) + (mem / "a.md").write_text(_entry("a")) + (mem / "esc.md").symlink_to(tmp_path / "outside.md") + (mem / "up").symlink_to(tmp_path, target_is_directory=True) + (mem / "loop1.md").symlink_to(mem / "loop2.md") + (mem / "loop2.md").symlink_to(mem / "loop1.md") + (mem / "MEMORY.md").write_text(f"- [A]({mem / 'a.md'})\n- [E](esc.md)\n- [U](up/outside.md)\n" + f"- [O]({tmp_path / 'outside.md'})\n- [L](loop1.md)\n") + found = [(f.rule, f.file, f.line) for f in lint(read_claude_memory(mem)) if f.rule.startswith("index")] + assert found == [("index-dead-link", "MEMORY.md", n) for n in (2, 3, 4, 5)] + [("index-orphan", "esc.md", None)] + + +def test_wikilink_folds_dash_and_underscore(mem): + (mem / "a.md").write_text(_entry("a", body="[[B]] [[project-a]] [[nobody]]")) + (mem / "project_a.md").write_text(_entry("pa")) + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md) [P](project_a.md)\n") + dangling = [f.message for f in lint(read_claude_memory(mem)) if f.rule == "wikilink-dangling"] + assert len(dangling) == 1 and "nobody" in dangling[0] + + +def test_relative_date_flagged_unless_dated(mem): + (mem / "a.md").write_text(_entry("a", body="Merged today.\nDone yesterday (as of ≤2026-11-01).\nСделано вчера.")) + found = [f for f in lint(read_claude_memory(mem)) if f.rule == "relative-date"] + assert [f.line for f in found] == [7, 9] + + +def test_index_limit_and_stray_separator(mem): + (mem / "MEMORY.md").write_text("- [A](a.md), [B](b.md)\n- Group:, [x](a.md)\n" + "line\n" * 10) + rules = _rules(mem, max_lines=10) + assert ("index-over-limit", "fail", "MEMORY.md") in rules + assert [r for r in rules if r[0] == "index-stray-separator"] == [("index-stray-separator", "note", "MEMORY.md")] + assert ("index-near-limit", "warn", "MEMORY.md") in _rules(mem, max_lines=14) + + +def test_load_limit_edges_anchors_mailto_and_code_span_dates(mem): + (mem / "MEMORY.md").write_text("- [A](a.md#why) [B](b.md) [M](mailto:x@example.com)\n" + "x\n" * 199) + assert [r[0] for r in _rules(mem)] == ["index-near-limit"] # 200 lines load + (mem / "MEMORY.md").write_text("- [A](a.md#why) [B](b.md)\n" + "x\n" * 200) + assert [r[0] for r in _rules(mem)] == ["index-over-limit"] + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md)\n" + "x" * 900 + "\n") + assert [r[0] for r in _rules(mem, max_bytes=1000)] == ["index-near-limit"] + (mem / "a.md").write_text(_entry("a", body="Run `make today` first.")) + assert _rules(mem) == [] + + +def test_missing_index_warns(tmp_path): + (tmp_path / "a.md").write_text(_entry("a")) + assert _rules(tmp_path) == [("index-missing", "warn", "MEMORY.md")] + + +def test_not_a_memory_dir(tmp_path): + with pytest.raises(MemoryStoreError): + read_claude_memory(tmp_path) + with pytest.raises(MemoryStoreError): + read_claude_memory(tmp_path / "nope") + + +def _cli(*args): + return subprocess.run([sys.executable, "-m", "dokimi.cli", "memory", "lint", *args], + capture_output=True, text=True, check=False) + + +def test_cli_exit_codes_and_json(mem): + out = _cli(str(mem), "--json") + doc = json.loads(out.stdout) + assert out.returncode == 0 and doc["kind"] == "memory lint" and doc["data"]["counts"]["fail"] == 0 + (mem / "c.md").write_text("orphan, no frontmatter\n") + assert _cli(str(mem)).returncode == 0 + assert _cli(str(mem), "--fail-on", "warn").returncode == 1 + (mem / "MEMORY.md").write_text("- [Gone](gone.md)\n") + out = _cli(str(mem)) + assert out.returncode == 1 and "index-dead-link" in out.stdout + bad = _cli(str(mem / "nope"), "--json") + assert bad.returncode == 2 and json.loads(bad.stdout)["error"]["code"] == "input" + + +def test_link_forms_resolve_and_escapes_are_dead(mem): + (mem / "my file.md").write_text(_entry("mf")) + (mem / "sub").mkdir() + (mem / "MEMORY.md").write_text('- [A](a.md "title") [B]() [M](my%20file.md) [N](./sub/../a.md)\n' + "- [Up](../x.md) [Dir](sub)\n") + found = _rules(mem) + assert [r for r in found if r[0] == "index-orphan"] == [] + assert [r[0] for r in found].count("index-dead-link") == 2 + + +def test_index_size_counts_crlf_bytes_on_disk(mem): + (mem / "MEMORY.md").write_bytes(b"- [A](a.md) [B](b.md)\r\n" + b"x" * 98 + b"\r\n") + store = read_claude_memory(mem) + on_disk = (mem / "MEMORY.md").stat().st_size + assert lint(store, max_bytes=on_disk - 1)[0].rule == "index-over-limit" # LF text is 2 bytes shorter + assert lint(store, max_bytes=on_disk - 1)[0].message.startswith(f"2 lines / {on_disk} bytes") + + +def test_bom_alias_archive_and_date_false_positives(mem): + (mem / "a.md").write_text("" + _entry("a", body="[[b|alias]] [[b#Why]]\nFixed 2026-11-13 (today).\n" + "See `today()` and yesterday.md.")) + (mem / "archive").mkdir() + (mem / "archive" / "a.md").write_text(_entry("a", body="done today")) + assert _rules(mem) == [] + + +def test_unreadable_file_is_a_finding(mem): + import os + bad = mem / "b.md" + bad.chmod(0) + try: + if os.access(bad, os.R_OK): + pytest.skip("running as root: every file is readable") + assert ("frontmatter-invalid", "warn", "b.md") in _rules(mem) + finally: + bad.chmod(0o644) + + +def test_unreadable_index_is_an_input_error(mem): + import os + index = mem / "MEMORY.md" + index.chmod(0) + try: + if os.access(index, os.R_OK): + pytest.skip("running as root: every file is readable") + out = _cli(str(mem), "--json") + assert out.returncode == 2 and json.loads(out.stdout)["error"]["code"] == "input" + finally: + index.chmod(0o644) + + +def test_json_status_is_failed_on_a_fail(mem): + (mem / "MEMORY.md").write_text("- [Gone](gone.md)\n") + doc = json.loads(_cli(str(mem), "--json").stdout) + assert doc["status"] == "failed" and "1 fail" in doc["reason"] + + +def test_empty_frontmatter_stops_at_its_own_closing_line(mem): + (mem / "a.md").write_text("---\n---\nSaw it today.\n\n---\nname: later\n---\n") + found = [(f.rule, f.line) for f in lint(read_claude_memory(mem)) if f.file == "a.md"] + assert ("relative-date", 3) in found and ("frontmatter-invalid", 1) not in found + + +def test_escaped_parentheses_and_null_byte_targets(mem): + (mem / "topic(v2).md").write_text(_entry("t")) + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md) [T](topic\\(v2\\).md)\n- [N](x%00.md)\n") + found = [(f.rule, f.line) for f in lint(read_claude_memory(mem))] + assert found == [("index-dead-link", 2)] + + +def test_code_spans_and_comments_hide_links_by_the_backtick_run_length(mem): + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md)\n``Use `x` and [example](missing.md)``\n" + "``\n") + dead = [(f.rule, f.line) for f in lint(read_claude_memory(mem))] + assert dead == [("index-dead-link", 3)] + + +def test_relative_date_skips_fences_and_comments(mem): + (mem / "a.md").write_text(_entry("a", body="```\nrun it tomorrow\n```\n\nDo it tomorrow.")) + assert [f.line for f in lint(read_claude_memory(mem)) if f.rule == "relative-date"] == [11] + + +def test_outside_link_fails_urls_are_skipped_and_byte_limit_edge(mem): + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md) [U](https://example.com/x.md) [Up](../x.md)\n") + assert [(f.rule, f.severity) for f in lint(read_claude_memory(mem))] == [("index-dead-link", "fail")] + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md)\n") + size = (mem / "MEMORY.md").stat().st_size + assert "index-over-limit" not in [r[0] for r in _rules(mem, max_bytes=size)] + assert ("index-over-limit", "fail", "MEMORY.md") in _rules(mem, max_bytes=size - 1) + + +def test_comment_opener_in_a_fence_does_not_hide_the_prose_after_it(mem): + (mem / "a.md").write_text(_entry("a", body="```html\n")) + assert [f.line for f in lint(read_claude_memory(mem)) if f.rule == "relative-date"] == [10]