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 +3 -0
- memproof/__main__.py +3 -0
- memproof/checks/__init__.py +5 -0
- memproof/checks/freshness.py +48 -0
- memproof/checks/frontmatter.py +94 -0
- memproof/checks/links.py +76 -0
- memproof/checks/provenance.py +37 -0
- memproof/checks/structure.py +66 -0
- memproof/cli.py +109 -0
- memproof/model.py +29 -0
- memproof/report.py +82 -0
- memproof/vault.py +205 -0
- memproof-0.1.0.dist-info/METADATA +103 -0
- memproof-0.1.0.dist-info/RECORD +18 -0
- memproof-0.1.0.dist-info/WHEEL +5 -0
- memproof-0.1.0.dist-info/entry_points.txt +2 -0
- memproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- memproof-0.1.0.dist-info/top_level.txt +1 -0
memproof/__init__.py
ADDED
memproof/__main__.py
ADDED
|
@@ -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
|
memproof/checks/links.py
ADDED
|
@@ -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,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
|