memproof 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
memproof/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """memproof — evidence-first health checks for Markdown knowledge vaults."""
2
+
3
+ __version__ = "0.1.0"
memproof/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ from . import freshness, frontmatter, links, provenance, structure
2
+
3
+ ALL_CHECKS = (frontmatter, links, provenance, freshness, structure)
4
+
5
+ __all__ = ["ALL_CHECKS", "freshness", "frontmatter", "links", "provenance", "structure"]
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+
5
+ from ..model import Finding
6
+ from ..vault import Vault, as_date
7
+
8
+ DRAFT_DAYS = 30
9
+ EVERGREEN_DAYS = 180
10
+
11
+
12
+ def run(vault: Vault, today: date | None = None) -> list[Finding]:
13
+ findings: list[Finding] = []
14
+ current = today or date.today()
15
+ for page in vault.pages:
16
+ frontmatter = page.frontmatter
17
+ if frontmatter is None:
18
+ continue
19
+ status = str(frontmatter.get("status", "")).strip()
20
+ stamp = as_date(frontmatter.get("updated")) or as_date(frontmatter.get("created"))
21
+ if stamp is None:
22
+ continue
23
+ age = (current - stamp).days
24
+ if status == "draft" and age > DRAFT_DAYS:
25
+ findings.append(
26
+ Finding(
27
+ check="freshness",
28
+ severity="medium",
29
+ path=page.rel,
30
+ line=1,
31
+ message=f"draft untouched for {age} days",
32
+ evidence=f"updated={stamp.isoformat()}, threshold={DRAFT_DAYS} days",
33
+ fix="refine, archive, or refresh the draft",
34
+ )
35
+ )
36
+ if status == "evergreen" and age > EVERGREEN_DAYS and page.rel not in vault.inbound:
37
+ findings.append(
38
+ Finding(
39
+ check="freshness",
40
+ severity="medium",
41
+ path=page.rel,
42
+ line=1,
43
+ message=f"evergreen page stale for {age} days with no inbound links",
44
+ evidence=f"updated={stamp.isoformat()}, threshold={EVERGREEN_DAYS} days",
45
+ fix="review it, link it, or downgrade its status",
46
+ )
47
+ )
48
+ return findings
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+
5
+ from ..model import Finding
6
+ from ..vault import Vault, as_date
7
+
8
+ REQUIRED = ("type", "title", "created", "updated", "status")
9
+ ALLOWED_STATUS = {
10
+ "active",
11
+ "archived",
12
+ "complete",
13
+ "developing",
14
+ "draft",
15
+ "evergreen",
16
+ "mature",
17
+ "open",
18
+ "published",
19
+ "review",
20
+ "stale",
21
+ "template",
22
+ }
23
+
24
+
25
+ def run(vault: Vault, today: date | None = None) -> list[Finding]:
26
+ findings: list[Finding] = []
27
+ for page in vault.pages:
28
+ if page.frontmatter_error:
29
+ findings.append(
30
+ Finding(
31
+ check="frontmatter",
32
+ severity="high",
33
+ path=page.rel,
34
+ line=1,
35
+ message="frontmatter cannot be parsed",
36
+ evidence=page.frontmatter_error,
37
+ fix="fix the YAML block or remove it",
38
+ )
39
+ )
40
+ continue
41
+ if page.frontmatter is None:
42
+ findings.append(
43
+ Finding(
44
+ check="frontmatter",
45
+ severity="high",
46
+ path=page.rel,
47
+ line=1,
48
+ message="missing frontmatter",
49
+ evidence="file does not start with a --- block",
50
+ fix="add type/title/created/updated/status",
51
+ )
52
+ )
53
+ continue
54
+ missing = [key for key in REQUIRED if not str(page.frontmatter.get(key, "")).strip()]
55
+ if missing:
56
+ findings.append(
57
+ Finding(
58
+ check="frontmatter",
59
+ severity="medium",
60
+ path=page.rel,
61
+ line=1,
62
+ message="missing required keys: " + ", ".join(missing),
63
+ evidence="; ".join(f"{key}={page.frontmatter.get(key)!r}" for key in REQUIRED),
64
+ fix="add the missing keys",
65
+ )
66
+ )
67
+ status = str(page.frontmatter.get("status", "")).strip()
68
+ if status and status not in ALLOWED_STATUS:
69
+ findings.append(
70
+ Finding(
71
+ check="frontmatter",
72
+ severity="medium",
73
+ path=page.rel,
74
+ line=1,
75
+ message=f"unknown status: {status}",
76
+ evidence="allowed: " + ", ".join(sorted(ALLOWED_STATUS)),
77
+ fix="use an allowed status value",
78
+ )
79
+ )
80
+ for key in ("created", "updated"):
81
+ value = page.frontmatter.get(key)
82
+ if value is not None and str(value).strip() and as_date(value) is None:
83
+ findings.append(
84
+ Finding(
85
+ check="frontmatter",
86
+ severity="medium",
87
+ path=page.rel,
88
+ line=1,
89
+ message=f"{key} is not an ISO date: {value!r}",
90
+ evidence="expected YYYY-MM-DD",
91
+ fix="use an ISO date",
92
+ )
93
+ )
94
+ return findings
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ from ..model import Finding
7
+ from ..vault import Vault, iter_links, link_target, resolve
8
+
9
+
10
+ def run(vault: Vault, today: date | None = None, include_orphans: bool = True) -> list[Finding]:
11
+ findings: list[Finding] = []
12
+ for page in vault.pages:
13
+ for raw, line in iter_links(page.text):
14
+ target = link_target(raw)
15
+ if target is None:
16
+ continue
17
+ status, matches = resolve(vault, page, target)
18
+ if status == "dead":
19
+ findings.append(
20
+ Finding(
21
+ check="links",
22
+ severity="high",
23
+ path=page.rel,
24
+ line=line,
25
+ message=f"dead wikilink: [[{target}]]",
26
+ evidence="target not found in vault",
27
+ fix="fix the target or remove the link",
28
+ )
29
+ )
30
+ elif status == "ambiguous":
31
+ findings.append(
32
+ Finding(
33
+ check="links",
34
+ severity="medium",
35
+ path=page.rel,
36
+ line=line,
37
+ message=f"ambiguous wikilink: [[{target}]]",
38
+ evidence="candidates: " + ", ".join(match.rel for match in matches),
39
+ fix="use a path-qualified link",
40
+ )
41
+ )
42
+ for stem, pages in sorted(vault.by_stem.items()):
43
+ if len(pages) < 2:
44
+ continue
45
+ findings.append(
46
+ Finding(
47
+ check="links",
48
+ severity="medium",
49
+ path=pages[0].rel,
50
+ line=1,
51
+ message=f"duplicate basename: {stem}",
52
+ evidence=", ".join(page.rel for page in pages),
53
+ fix="rename one page or keep links path-qualified",
54
+ )
55
+ )
56
+ if include_orphans:
57
+ for page in vault.pages:
58
+ if page.rel in vault.inbound:
59
+ continue
60
+ name = Path(page.rel).name
61
+ if name in {"index.md", "_index.md", "README.md"}:
62
+ continue
63
+ if page.rel.startswith(("archive/", "templates/")):
64
+ continue
65
+ findings.append(
66
+ Finding(
67
+ check="links",
68
+ severity="medium",
69
+ path=page.rel,
70
+ line=1,
71
+ message="orphan page: no inbound wikilinks",
72
+ evidence="no other page links to this file",
73
+ fix="link it from an index or remove it",
74
+ )
75
+ )
76
+ return findings
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+
5
+ from ..model import Finding
6
+ from ..vault import Vault
7
+
8
+ PROVENANCE_KEYS = ("author", "sink", "generated_by")
9
+
10
+
11
+ def run(vault: Vault, today: date | None = None) -> list[Finding]:
12
+ findings: list[Finding] = []
13
+ for page in vault.pages:
14
+ frontmatter = page.frontmatter
15
+ if frontmatter is None:
16
+ continue
17
+ author = str(frontmatter.get("author", "")).strip()
18
+ sink = str(frontmatter.get("sink", "")).strip()
19
+ if author != "agent" and sink != "agent":
20
+ continue
21
+ missing = [key for key in PROVENANCE_KEYS if not str(frontmatter.get(key, "")).strip()]
22
+ if missing:
23
+ findings.append(
24
+ Finding(
25
+ check="provenance",
26
+ severity="high",
27
+ path=page.rel,
28
+ line=1,
29
+ message="agent-authored note missing: " + ", ".join(missing),
30
+ evidence=(
31
+ f"author={author or '<empty>'}, sink={sink or '<empty>'}, "
32
+ f"generated_by={frontmatter.get('generated_by', '<empty>')!r}"
33
+ ),
34
+ fix="add the missing provenance keys or remove the agent marker",
35
+ )
36
+ )
37
+ return findings
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ from ..model import Finding
8
+ from ..vault import Page, Vault
9
+
10
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
11
+
12
+
13
+ def _empty_sections(page: Page) -> list[tuple[int, str]]:
14
+ lines = page.body.splitlines()
15
+ empty: list[tuple[int, str]] = []
16
+ for index, line in enumerate(lines):
17
+ match = HEADING_RE.match(line)
18
+ if match is None:
19
+ continue
20
+ cursor = index + 1
21
+ while cursor < len(lines) and not lines[cursor].strip():
22
+ cursor += 1
23
+ if cursor >= len(lines):
24
+ empty.append((page.body_line + index, match.group(2)))
25
+ continue
26
+ next_heading = HEADING_RE.match(lines[cursor])
27
+ if next_heading is not None and len(next_heading.group(1)) <= len(match.group(1)):
28
+ empty.append((page.body_line + index, match.group(2)))
29
+ return empty
30
+
31
+
32
+ def run(vault: Vault, today: date | None = None) -> list[Finding]:
33
+ findings: list[Finding] = []
34
+ for page in vault.pages:
35
+ for line, heading in _empty_sections(page):
36
+ findings.append(
37
+ Finding(
38
+ check="structure",
39
+ severity="medium",
40
+ path=page.rel,
41
+ line=line,
42
+ message=f"empty section: {heading}",
43
+ evidence="heading is followed by another heading or end of file",
44
+ fix="add content or remove the heading",
45
+ )
46
+ )
47
+ rel = page.rel
48
+ if "memory/notes/" not in rel or Path(rel).name == "_index.md":
49
+ continue
50
+ index = page.path.parent / "_index.md"
51
+ if not index.is_file():
52
+ continue
53
+ index_text = index.read_text(encoding="utf-8", errors="replace")
54
+ if Path(rel).stem not in index_text:
55
+ findings.append(
56
+ Finding(
57
+ check="structure",
58
+ severity="medium",
59
+ path=rel,
60
+ line=1,
61
+ message="atom not registered in its _index.md",
62
+ evidence=index.relative_to(vault.root).as_posix(),
63
+ fix="add a wikilink to the atom in the index",
64
+ )
65
+ )
66
+ return findings
memproof/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from datetime import date
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .checks import ALL_CHECKS
10
+ from .checks import links as links_check
11
+ from .model import Finding
12
+ from .report import render_json, render_markdown, write_report
13
+ from .vault import load_vault
14
+
15
+ FAIL_RANK = {"high": 0, "medium": 1, "low": 2}
16
+
17
+
18
+ def _harden_streams() -> None:
19
+ for stream in (sys.stdout, sys.stderr):
20
+ reconfigure = getattr(stream, "reconfigure", None)
21
+ if reconfigure is None:
22
+ continue
23
+ try:
24
+ reconfigure(encoding="utf-8", errors="replace")
25
+ except (OSError, ValueError):
26
+ pass
27
+
28
+
29
+ def build_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(
31
+ prog="memproof",
32
+ description="Evidence-first health checks for Markdown knowledge vaults.",
33
+ )
34
+ subparsers = parser.add_subparsers(dest="command", required=True)
35
+ check = subparsers.add_parser("check", help="scan a Markdown vault")
36
+ check.add_argument("path", type=Path, help="vault root")
37
+ check.add_argument("--format", choices=("markdown", "json"), default="markdown")
38
+ check.add_argument("--output", type=Path, help="write the report to this file")
39
+ check.add_argument(
40
+ "--fail-on",
41
+ choices=("none", "low", "medium", "high"),
42
+ default="none",
43
+ help="exit 1 when a finding at or above this severity exists",
44
+ )
45
+ check.add_argument(
46
+ "--exclude",
47
+ action="append",
48
+ default=[],
49
+ metavar="GLOB",
50
+ help="extra path part or glob to skip (repeatable)",
51
+ )
52
+ check.add_argument("--no-orphans", action="store_true", help="skip the orphan-page check")
53
+ check.add_argument(
54
+ "--today",
55
+ type=date.fromisoformat,
56
+ default=None,
57
+ help="reference date for freshness checks (YYYY-MM-DD)",
58
+ )
59
+ subparsers.add_parser("version", help="print the version")
60
+ return parser
61
+
62
+
63
+ def _collect(vault: object, today: date | None, include_orphans: bool) -> list[Finding]:
64
+ from .vault import Vault
65
+
66
+ assert isinstance(vault, Vault)
67
+ findings: list[Finding] = []
68
+ for module in ALL_CHECKS:
69
+ if module is links_check:
70
+ findings.extend(module.run(vault, today=today, include_orphans=include_orphans))
71
+ else:
72
+ findings.extend(module.run(vault, today=today))
73
+ return findings
74
+
75
+
76
+ def main(argv: list[str] | None = None) -> int:
77
+ _harden_streams()
78
+ args = build_parser().parse_args(argv)
79
+ if args.command == "version":
80
+ print(__version__)
81
+ return 0
82
+
83
+ root = args.path
84
+ if not root.is_dir():
85
+ print(f"memproof: not a directory: {root}", file=sys.stderr)
86
+ return 2
87
+ try:
88
+ vault = load_vault(root, tuple(args.exclude))
89
+ except OSError as exc:
90
+ print(f"memproof: cannot read vault: {exc}", file=sys.stderr)
91
+ return 2
92
+
93
+ findings = _collect(vault, args.today, not args.no_orphans)
94
+ content = (
95
+ render_markdown(vault, findings, args.today)
96
+ if args.format == "markdown"
97
+ else render_json(vault, findings, args.today)
98
+ )
99
+ if args.output:
100
+ write_report(args.output, content)
101
+ print(f"wrote {args.output}")
102
+ else:
103
+ sys.stdout.write(content)
104
+
105
+ if args.fail_on == "none":
106
+ return 0
107
+ threshold = FAIL_RANK[args.fail_on]
108
+ worst = min((FAIL_RANK.get(finding.severity, 99) for finding in findings), default=99)
109
+ return 1 if worst <= threshold else 0
memproof/model.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ SEVERITIES = ("high", "medium", "low")
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Finding:
10
+ check: str
11
+ severity: str
12
+ path: str
13
+ line: int
14
+ message: str
15
+ evidence: str = ""
16
+ fix: str = ""
17
+
18
+
19
+ @dataclass
20
+ class CheckResult:
21
+ findings: list[Finding] = field(default_factory=list)
22
+ skipped: list[str] = field(default_factory=list)
23
+
24
+
25
+ def normalize_severity(value: str) -> str:
26
+ lowered = value.strip().lower()
27
+ if lowered not in SEVERITIES:
28
+ raise ValueError(f"unknown severity: {value}")
29
+ return lowered
memproof/report.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from datetime import date
6
+ from pathlib import Path
7
+
8
+ from .model import SEVERITIES, Finding
9
+ from .vault import Vault
10
+
11
+ SCHEMA = "memproof.report.v1"
12
+
13
+
14
+ def summarize(findings: list[Finding]) -> dict[str, int]:
15
+ counts = {severity: 0 for severity in SEVERITIES}
16
+ for finding in findings:
17
+ counts[finding.severity] = counts.get(finding.severity, 0) + 1
18
+ return counts
19
+
20
+
21
+ def score(findings: list[Finding]) -> int:
22
+ counts = summarize(findings)
23
+ penalty = counts["high"] * 10 + counts["medium"] * 3 + counts["low"]
24
+ return max(0, 100 - penalty)
25
+
26
+
27
+ def _sort_key(finding: Finding) -> tuple[int, str, int, str]:
28
+ order = {severity: index for index, severity in enumerate(SEVERITIES)}
29
+ return (order.get(finding.severity, 99), finding.path, finding.line, finding.check)
30
+
31
+
32
+ def render_markdown(vault: Vault, findings: list[Finding], today: date | None = None) -> str:
33
+ ordered = sorted(findings, key=_sort_key)
34
+ counts = summarize(ordered)
35
+ lines = [
36
+ f"# memproof report — {vault.root.name or vault.root.as_posix()}",
37
+ "",
38
+ (
39
+ f"Score: {score(ordered)}/100 · Files: {len(vault.pages)} · Links: {vault.link_count} · "
40
+ f"Findings: {counts['high']} high / {counts['medium']} medium / {counts['low']} low"
41
+ ),
42
+ "",
43
+ ]
44
+ labels = {"high": "High", "medium": "Medium", "low": "Low"}
45
+ for severity in SEVERITIES:
46
+ lines.append(f"## {labels[severity]}")
47
+ lines.append("")
48
+ group = [finding for finding in ordered if finding.severity == severity]
49
+ if not group:
50
+ lines.append("None.")
51
+ lines.append("")
52
+ continue
53
+ for finding in group:
54
+ lines.append(f"- [{finding.check}] {finding.path}:{finding.line} — {finding.message}")
55
+ if finding.evidence:
56
+ lines.append(f" Evidence: {finding.evidence}")
57
+ if finding.fix:
58
+ lines.append(f" Fix: {finding.fix}")
59
+ lines.append("")
60
+ return "\n".join(lines).rstrip() + "\n"
61
+
62
+
63
+ def render_json(vault: Vault, findings: list[Finding], today: date | None = None) -> str:
64
+ ordered = sorted(findings, key=_sort_key)
65
+ payload = {
66
+ "schema": SCHEMA,
67
+ "root": vault.root.as_posix(),
68
+ "generated_for": (today or date.today()).isoformat(),
69
+ "summary": {
70
+ "score": score(ordered),
71
+ "pages": len(vault.pages),
72
+ "links": vault.link_count,
73
+ "findings": summarize(ordered),
74
+ },
75
+ "findings": [asdict(finding) for finding in ordered],
76
+ }
77
+ return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
78
+
79
+
80
+ def write_report(path: Path, content: str) -> None:
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ path.write_text(content, encoding="utf-8")
memproof/vault.py ADDED
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from datetime import date, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ DEFAULT_EXCLUDES: tuple[str, ...] = (
13
+ ".git",
14
+ ".obsidian",
15
+ ".vault-meta",
16
+ ".trash",
17
+ ".raw",
18
+ "node_modules",
19
+ ".venv",
20
+ "venv",
21
+ ".venv-win",
22
+ "venvtest",
23
+ "venvtest2",
24
+ "dist",
25
+ "build",
26
+ "__pycache__",
27
+ "outputs",
28
+ )
29
+
30
+ WIKILINK_RE = re.compile(r"\[\[([^\]\n]+?)\]\]")
31
+ FENCE_RE = re.compile(r"(?ms)^(```|~~~)[^\n]*\n.*?^\1\s*$")
32
+ INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
33
+ SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
34
+
35
+
36
+ @dataclass
37
+ class Page:
38
+ path: Path
39
+ rel: str
40
+ text: str
41
+ frontmatter: dict[str, Any] | None
42
+ body: str
43
+ body_line: int
44
+ frontmatter_error: str | None = None
45
+
46
+
47
+ @dataclass
48
+ class Vault:
49
+ root: Path
50
+ pages: list[Page]
51
+ by_stem: dict[str, list[Page]]
52
+ by_rel: dict[str, Page]
53
+ link_count: int = 0
54
+ inbound: dict[str, set[str]] = field(default_factory=dict)
55
+
56
+
57
+ def parse_frontmatter(text: str) -> tuple[dict[str, Any] | None, str, int, str | None]:
58
+ lines = text.splitlines(keepends=True)
59
+ if not lines or lines[0].strip() != "---":
60
+ return None, text, 1, None
61
+ end: int | None = None
62
+ for index in range(1, len(lines)):
63
+ if lines[index].strip() == "---":
64
+ end = index
65
+ break
66
+ if end is None:
67
+ return None, text, 1, "unterminated frontmatter block"
68
+ raw = "".join(lines[1:end])
69
+ body = "".join(lines[end + 1 :])
70
+ try:
71
+ data: Any = yaml.safe_load(raw) or {}
72
+ except yaml.YAMLError as exc:
73
+ return None, body, end + 2, f"YAML error: {exc}"
74
+ if not isinstance(data, dict):
75
+ return None, body, end + 2, "frontmatter is not a mapping"
76
+ return data, body, end + 2, None
77
+
78
+
79
+ def as_date(value: object) -> date | None:
80
+ if isinstance(value, datetime):
81
+ return value.date()
82
+ if isinstance(value, date):
83
+ return value
84
+ if isinstance(value, str):
85
+ try:
86
+ return date.fromisoformat(value.strip())
87
+ except ValueError:
88
+ return None
89
+ return None
90
+
91
+
92
+ def _excluded(rel: str, excludes: tuple[str, ...]) -> bool:
93
+ parts = rel.split("/")
94
+ for pattern in excludes:
95
+ if pattern in parts:
96
+ return True
97
+ if fnmatch.fnmatchcase(rel, pattern):
98
+ return True
99
+ return False
100
+
101
+
102
+ def _code_spans(text: str) -> list[tuple[int, int]]:
103
+ spans: list[tuple[int, int]] = []
104
+ for regex in (FENCE_RE, INLINE_CODE_RE):
105
+ spans.extend((match.start(), match.end()) for match in regex.finditer(text))
106
+ return spans
107
+
108
+
109
+ def iter_links(text: str) -> list[tuple[str, int]]:
110
+ spans = _code_spans(text)
111
+ links: list[tuple[str, int]] = []
112
+ for match in WIKILINK_RE.finditer(text):
113
+ if any(start <= match.start() < end for start, end in spans):
114
+ continue
115
+ line = text.count("\n", 0, match.start()) + 1
116
+ links.append((match.group(1), line))
117
+ return links
118
+
119
+
120
+ def link_target(raw: str) -> str | None:
121
+ raw = raw.replace("\\|", "\0")
122
+ target = raw.split("|", 1)[0].replace("\0", "|")
123
+ target = target.split("#", 1)[0].strip()
124
+ if not target or SCHEME_RE.match(target):
125
+ return None
126
+ return target.replace("\\", "/")
127
+
128
+
129
+ def _page(rel: str) -> str:
130
+ return rel[:-3] if rel.endswith(".md") else rel
131
+
132
+
133
+ def resolve(vault: Vault, source: Page, target: str) -> tuple[str, list[Page]]:
134
+ cleaned = target.strip().lstrip("/")
135
+ if cleaned.endswith(".md"):
136
+ cleaned = cleaned[:-3]
137
+ if "/" in cleaned:
138
+ for candidate in (cleaned, f"{cleaned}.md"):
139
+ page = vault.by_rel.get(candidate)
140
+ if page is not None:
141
+ return "ok", [page]
142
+ base = Path(source.rel).parent
143
+ relative = _page((base / cleaned).as_posix())
144
+ for candidate in (relative, f"{relative}.md"):
145
+ page = vault.by_rel.get(candidate)
146
+ if page is not None:
147
+ return "ok", [page]
148
+ return "dead", []
149
+ matches = vault.by_stem.get(cleaned.casefold(), [])
150
+ if len(matches) == 1:
151
+ return "ok", matches
152
+ if len(matches) > 1:
153
+ return "ambiguous", matches
154
+ return "dead", []
155
+
156
+
157
+ def _build_inbound(pages: list[Page], vault: Vault) -> tuple[dict[str, set[str]], int]:
158
+ inbound: dict[str, set[str]] = {}
159
+ link_count = 0
160
+ for page in pages:
161
+ for raw, _line in iter_links(page.text):
162
+ target = link_target(raw)
163
+ if target is None:
164
+ continue
165
+ link_count += 1
166
+ status, matches = resolve(vault, page, target)
167
+ if status in {"ok", "ambiguous"}:
168
+ for match in matches:
169
+ if match.rel != page.rel:
170
+ inbound.setdefault(match.rel, set()).add(page.rel)
171
+ return inbound, link_count
172
+
173
+
174
+ def load_vault(root: Path, excludes: tuple[str, ...] = ()) -> Vault:
175
+ all_excludes = DEFAULT_EXCLUDES + excludes
176
+ root = root.resolve()
177
+ pages: list[Page] = []
178
+ for path in sorted(root.rglob("*.md")):
179
+ rel = path.relative_to(root).as_posix()
180
+ if _excluded(rel, all_excludes):
181
+ continue
182
+ text = path.read_text(encoding="utf-8", errors="replace")
183
+ frontmatter, body, body_line, error = parse_frontmatter(text)
184
+ pages.append(
185
+ Page(
186
+ path=path,
187
+ rel=rel,
188
+ text=text,
189
+ frontmatter=frontmatter,
190
+ body=body,
191
+ body_line=body_line,
192
+ frontmatter_error=error,
193
+ )
194
+ )
195
+ by_stem: dict[str, list[Page]] = {}
196
+ by_rel: dict[str, Page] = {}
197
+ for page in pages:
198
+ by_stem.setdefault(Path(page.rel).stem.casefold(), []).append(page)
199
+ by_rel[page.rel] = page
200
+ by_rel[_page(page.rel)] = page
201
+ vault = Vault(root=root, pages=pages, by_stem=by_stem, by_rel=by_rel)
202
+ inbound, link_count = _build_inbound(pages, vault)
203
+ vault.inbound = inbound
204
+ vault.link_count = link_count
205
+ return vault
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: memproof
3
+ Version: 0.1.0
4
+ Summary: Evidence-first health checks for Markdown knowledge vaults.
5
+ Author: memproof contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/hsys123/memproof
8
+ Project-URL: Repository, https://github.com/hsys123/memproof
9
+ Project-URL: Issues, https://github.com/hsys123/memproof/issues
10
+ Keywords: markdown,obsidian,knowledge-base,memory,audit,provenance
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: pyyaml>=6.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0; extra == "dev"
22
+ Requires-Dist: ruff>=0.6; extra == "dev"
23
+ Requires-Dist: mypy>=1.11; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # memproof
27
+
28
+ Evidence-first health checks for Markdown knowledge vaults.
29
+
30
+ `memproof` answers one question: **can you prove what your knowledge base claims?**
31
+ Every finding carries a `path:line`, the evidence behind it, and a concrete fix.
32
+ It is read-only, offline, and deterministic.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ uv tool install memproof
38
+ # or
39
+ pip install memproof
40
+ ```
41
+
42
+ ## Use
43
+
44
+ ```bash
45
+ memproof check ~/my-vault
46
+ memproof check ~/my-vault --format json --fail-on high
47
+ memproof check ~/my-vault --output report.md
48
+ ```
49
+
50
+ Exit codes: `0` clean (or below `--fail-on`), `1` findings at/above the threshold, `2` usage/environment error.
51
+
52
+ Archives often contain frozen snapshots with dead links. Exclude them explicitly:
53
+
54
+ ```bash
55
+ memproof check ~/my-vault --exclude 'archive/**' --exclude 'templates/**'
56
+ ```
57
+
58
+ ## What it checks (v0.1)
59
+
60
+ | Check | What it catches |
61
+ |---|---|
62
+ | `frontmatter` | missing/unparseable frontmatter, missing required keys, unknown status, invalid dates |
63
+ | `links` | dead wikilinks, ambiguous bare-name targets, duplicate basenames, orphan pages |
64
+ | `provenance` | agent-authored notes missing `author` / `sink` / `generated_by` |
65
+ | `freshness` | drafts older than 30 days; evergreen pages older than 180 days with no inbound links |
66
+ | `structure` | empty sections; memory atoms not registered in their `_index.md` |
67
+
68
+ ## Example
69
+
70
+ ```text
71
+ # memproof report — demo-vault
72
+ Score: 64/100 · Files: 12 · Links: 18 · Findings: 2 high / 6 medium / 1 low
73
+
74
+ ## High
75
+ - [provenance] notes/agent-note.md:1 — agent-authored note missing generated_by
76
+ Evidence: author=agent, sink=agent, generated_by missing.
77
+ Fix: add `generated_by: <tool>` or remove the agent marker.
78
+ ```
79
+
80
+ A full generated report is in [`docs/sample-report.md`](docs/sample-report.md).
81
+
82
+ ## Roadmap
83
+
84
+ - **v0.1** — five vault checks (current).
85
+ - **v0.2** — archive profile, fold/derived checks.
86
+ - **v0.3** — `memproof skills`: capability contracts and with/without evaluation.
87
+
88
+ ## Design principles
89
+
90
+ - **Read-only**: never edits the vault, no network access, no LLM calls.
91
+ - **Evidence-first**: every finding points to a line; no aggregate-only claims.
92
+ - **Deterministic**: same vault + same date = same report.
93
+ - **Small surface**: five checks, not fifty. Add a check only with a failing fixture.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ python -m pytest
99
+ ruff check .
100
+ mypy memproof
101
+ ```
102
+
103
+ License: MIT.
@@ -0,0 +1,18 @@
1
+ memproof/__init__.py,sha256=keHI4TUfoU620fujQ43IZEIrBRlzl8C3Mcrw4OzgtYQ,102
2
+ memproof/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ memproof/cli.py,sha256=tTgqglnEMzHznrI6-r2VdIcWSe7wnDnauVAbaMnNuXg,3538
4
+ memproof/model.py,sha256=MB5QO0LYHkH27pXAiudBlA9lcyWbv4jBFiDPFI8MxSM,609
5
+ memproof/report.py,sha256=E_kzZ-8yNdn2QMNbI3m8Tggyw2TARKpXHqkT76VSb6c,2881
6
+ memproof/vault.py,sha256=NHG2rak6s-62B_JveuIIf9G02YpKKAqgLeSFlgtU2Pw,6126
7
+ memproof/checks/__init__.py,sha256=mpuI7KZjdR0Q_zCR1WtCl3MErJ-9qjDAPu2xpL2eM0w,226
8
+ memproof/checks/freshness.py,sha256=MPY7tx4nDuOk6_DUQECiGP0_NjEiMLBpDVOA9xlO0A4,1749
9
+ memproof/checks/frontmatter.py,sha256=tY-cS05jTvoW0g2b-5pG4seFesEacCbWondT_mWrPLU,3177
10
+ memproof/checks/links.py,sha256=5zzm1rqlMv2hKOo2nhsV0FRW5bSDm3FKkDxIpvd1ado,2794
11
+ memproof/checks/provenance.py,sha256=SoMH8-YUYAbhoolT0iGPk37QQUEXXoPN5imMJFHgaeo,1336
12
+ memproof/checks/structure.py,sha256=TTwm_SxLR-IJjvdOWTsjJyKO4_oOO0GawE8gclJ7U1w,2335
13
+ memproof-0.1.0.dist-info/licenses/LICENSE,sha256=dTpoXBEzaAhD5bJx-KGNt1pUR-cPspqz_1Y9ZULRvFM,1078
14
+ memproof-0.1.0.dist-info/METADATA,sha256=CVsFZ_er0gFrcQtW7e7E7LDR1vB0ypt0Ksdvrtie_3g,3312
15
+ memproof-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
16
+ memproof-0.1.0.dist-info/entry_points.txt,sha256=Q71Sdbrp3mA9OP5AFoBQAZkUCszjQ-nhIQzzqgPSt18,47
17
+ memproof-0.1.0.dist-info/top_level.txt,sha256=bGca6Kv98_iJyra1rrzvarLyzYIkog6LFzRORkkrJOE,9
18
+ memproof-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ memproof = memproof.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 memproof contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ memproof