memproof 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → memproof-0.2.0}/PKG-INFO +19 -2
- {memproof-0.1.0 → memproof-0.2.0}/README.md +17 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/__init__.py +1 -1
- {memproof-0.1.0 → memproof-0.2.0}/memproof/cli.py +45 -0
- memproof-0.2.0/memproof/skills.py +524 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/PKG-INFO +19 -2
- {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/SOURCES.txt +3 -1
- {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/requires.txt +1 -1
- {memproof-0.1.0 → memproof-0.2.0}/pyproject.toml +2 -2
- {memproof-0.1.0 → memproof-0.2.0}/tests/test_cli.py +1 -1
- memproof-0.2.0/tests/test_skills.py +225 -0
- {memproof-0.1.0 → memproof-0.2.0}/LICENSE +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/__main__.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/__init__.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/freshness.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/frontmatter.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/links.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/provenance.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/structure.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/model.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/report.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof/vault.py +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/dependency_links.txt +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/entry_points.txt +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/top_level.txt +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/setup.cfg +0 -0
- {memproof-0.1.0 → memproof-0.2.0}/tests/test_checks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memproof
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Evidence-first health checks for Markdown knowledge vaults.
|
|
5
5
|
Author: memproof contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,24 +19,41 @@ License-File: LICENSE
|
|
|
19
19
|
Requires-Dist: pyyaml>=6.0
|
|
20
20
|
Provides-Extra: dev
|
|
21
21
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
22
|
-
Requires-Dist: ruff
|
|
22
|
+
Requires-Dist: ruff==0.17.2; extra == "dev"
|
|
23
23
|
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
24
24
|
Dynamic: license-file
|
|
25
25
|
|
|
26
26
|
# memproof
|
|
27
27
|
|
|
28
|
+
[](https://pypi.org/project/memproof/)
|
|
29
|
+
[](https://github.com/hsys123/memproof/actions/workflows/test.yml)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+
|
|
28
32
|
Evidence-first health checks for Markdown knowledge vaults.
|
|
29
33
|
|
|
30
34
|
`memproof` answers one question: **can you prove what your knowledge base claims?**
|
|
31
35
|
Every finding carries a `path:line`, the evidence behind it, and a concrete fix.
|
|
32
36
|
It is read-only, offline, and deterministic.
|
|
33
37
|
|
|
38
|
+
## Check Agent Skills (agentskills.io)
|
|
39
|
+
|
|
40
|
+
Point `memproof skills` at a directory of `skills/<name>/SKILL.md` to validate the agentskills.io
|
|
41
|
+
contract: kebab-case names that match their directory, description limits,
|
|
42
|
+
optional-field types and unknown keys. Add `--registry skills.json` to diff the disk against a declared skill registry in both directions.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
memproof skills ./skills --registry config/skills.json --fail-on high
|
|
46
|
+
```
|
|
47
|
+
|
|
34
48
|
## Install
|
|
35
49
|
|
|
36
50
|
```bash
|
|
37
51
|
uv tool install memproof
|
|
38
52
|
# or
|
|
39
53
|
pip install memproof
|
|
54
|
+
|
|
55
|
+
# before the PyPI release, install straight from GitHub:
|
|
56
|
+
uv tool install git+https://github.com/hsys123/memproof
|
|
40
57
|
```
|
|
41
58
|
|
|
42
59
|
## Use
|
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
# memproof
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/memproof/)
|
|
4
|
+
[](https://github.com/hsys123/memproof/actions/workflows/test.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
3
7
|
Evidence-first health checks for Markdown knowledge vaults.
|
|
4
8
|
|
|
5
9
|
`memproof` answers one question: **can you prove what your knowledge base claims?**
|
|
6
10
|
Every finding carries a `path:line`, the evidence behind it, and a concrete fix.
|
|
7
11
|
It is read-only, offline, and deterministic.
|
|
8
12
|
|
|
13
|
+
## Check Agent Skills (agentskills.io)
|
|
14
|
+
|
|
15
|
+
Point `memproof skills` at a directory of `skills/<name>/SKILL.md` to validate the agentskills.io
|
|
16
|
+
contract: kebab-case names that match their directory, description limits,
|
|
17
|
+
optional-field types and unknown keys. Add `--registry skills.json` to diff the disk against a declared skill registry in both directions.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
memproof skills ./skills --registry config/skills.json --fail-on high
|
|
21
|
+
```
|
|
22
|
+
|
|
9
23
|
## Install
|
|
10
24
|
|
|
11
25
|
```bash
|
|
12
26
|
uv tool install memproof
|
|
13
27
|
# or
|
|
14
28
|
pip install memproof
|
|
29
|
+
|
|
30
|
+
# before the PyPI release, install straight from GitHub:
|
|
31
|
+
uv tool install git+https://github.com/hsys123/memproof
|
|
15
32
|
```
|
|
16
33
|
|
|
17
34
|
## Use
|
|
@@ -10,6 +10,15 @@ from .checks import ALL_CHECKS
|
|
|
10
10
|
from .checks import links as links_check
|
|
11
11
|
from .model import Finding
|
|
12
12
|
from .report import render_json, render_markdown, write_report
|
|
13
|
+
from .skills import (
|
|
14
|
+
render_json as render_skills_json,
|
|
15
|
+
)
|
|
16
|
+
from .skills import (
|
|
17
|
+
render_markdown as render_skills_markdown,
|
|
18
|
+
)
|
|
19
|
+
from .skills import (
|
|
20
|
+
scan_skills,
|
|
21
|
+
)
|
|
13
22
|
from .vault import load_vault
|
|
14
23
|
|
|
15
24
|
FAIL_RANK = {"high": 0, "medium": 1, "low": 2}
|
|
@@ -56,6 +65,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
56
65
|
default=None,
|
|
57
66
|
help="reference date for freshness checks (YYYY-MM-DD)",
|
|
58
67
|
)
|
|
68
|
+
skills = subparsers.add_parser("skills", help="check Agent Skills (SKILL.md) spec conformance")
|
|
69
|
+
skills.add_argument("path", type=Path, help="directory containing skills/<name>/SKILL.md")
|
|
70
|
+
skills.add_argument("--registry", type=Path, help="JSON file listing the expected skill ids")
|
|
71
|
+
skills.add_argument("--format", choices=("markdown", "json"), default="markdown")
|
|
72
|
+
skills.add_argument("--output", type=Path, help="write the report to this file")
|
|
73
|
+
skills.add_argument(
|
|
74
|
+
"--fail-on",
|
|
75
|
+
choices=("none", "low", "medium", "high"),
|
|
76
|
+
default="none",
|
|
77
|
+
help="exit 1 when a finding at or above this severity exists",
|
|
78
|
+
)
|
|
59
79
|
subparsers.add_parser("version", help="print the version")
|
|
60
80
|
return parser
|
|
61
81
|
|
|
@@ -80,6 +100,31 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
80
100
|
print(__version__)
|
|
81
101
|
return 0
|
|
82
102
|
|
|
103
|
+
if args.command == "skills":
|
|
104
|
+
skills_root = args.path
|
|
105
|
+
if not skills_root.is_dir():
|
|
106
|
+
print(f"memproof: not a directory: {skills_root}", file=sys.stderr)
|
|
107
|
+
return 2
|
|
108
|
+
skill_findings, scan = scan_skills(skills_root, args.registry)
|
|
109
|
+
content = (
|
|
110
|
+
render_skills_markdown(scan, skill_findings)
|
|
111
|
+
if args.format == "markdown"
|
|
112
|
+
else render_skills_json(scan, skill_findings)
|
|
113
|
+
)
|
|
114
|
+
if args.output:
|
|
115
|
+
write_report(args.output, content)
|
|
116
|
+
print(f"wrote {args.output}")
|
|
117
|
+
else:
|
|
118
|
+
sys.stdout.write(content)
|
|
119
|
+
if args.fail_on == "none":
|
|
120
|
+
return 0
|
|
121
|
+
threshold = FAIL_RANK[args.fail_on]
|
|
122
|
+
worst = min(
|
|
123
|
+
(FAIL_RANK.get(finding.severity, 99) for finding in skill_findings),
|
|
124
|
+
default=99,
|
|
125
|
+
)
|
|
126
|
+
return 1 if worst <= threshold else 0
|
|
127
|
+
|
|
83
128
|
root = args.path
|
|
84
129
|
if not root.is_dir():
|
|
85
130
|
print(f"memproof: not a directory: {root}", file=sys.stderr)
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""Agent Skills (SKILL.md) spec-conformance and registry-consistency checks.
|
|
2
|
+
|
|
3
|
+
Implements the agentskills.io frontmatter contract: name, description, and the
|
|
4
|
+
optional license / compatibility / metadata / allowed-tools fields. Findings
|
|
5
|
+
carry path:line, evidence, and a fix, mirroring the vault checks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from .model import Finding
|
|
19
|
+
from .report import score, summarize
|
|
20
|
+
|
|
21
|
+
SCHEMA = "memproof.skills.v1"
|
|
22
|
+
NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
23
|
+
NAME_MAX = 64
|
|
24
|
+
DESCRIPTION_MAX = 1024
|
|
25
|
+
COMPATIBILITY_MAX = 500
|
|
26
|
+
REQUIRED_KEYS = ("name", "description")
|
|
27
|
+
KNOWN_OPTIONAL_KEYS = ("allowed-tools", "compatibility", "license", "metadata")
|
|
28
|
+
REGISTRY_LIST_KEYS = ("skills", "capabilities", "entries", "items")
|
|
29
|
+
REGISTRY_NAME_KEYS = ("id", "name", "skill")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SkillsScan:
|
|
34
|
+
root: Path
|
|
35
|
+
skills: int
|
|
36
|
+
registry: int | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _finding(
|
|
40
|
+
check: str,
|
|
41
|
+
severity: str,
|
|
42
|
+
path: str,
|
|
43
|
+
line: int,
|
|
44
|
+
message: str,
|
|
45
|
+
evidence: str = "",
|
|
46
|
+
fix: str = "",
|
|
47
|
+
) -> Finding:
|
|
48
|
+
return Finding(
|
|
49
|
+
check=check,
|
|
50
|
+
severity=severity,
|
|
51
|
+
path=path,
|
|
52
|
+
line=line,
|
|
53
|
+
message=message,
|
|
54
|
+
evidence=evidence,
|
|
55
|
+
fix=fix,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _split_frontmatter(text: str) -> tuple[list[str] | None, str | None]:
|
|
60
|
+
if not text.startswith("---"):
|
|
61
|
+
return None, "file does not start with a --- frontmatter block"
|
|
62
|
+
lines = text.splitlines()
|
|
63
|
+
for index in range(1, len(lines)):
|
|
64
|
+
if lines[index].strip() == "---":
|
|
65
|
+
return lines[1:index], None
|
|
66
|
+
return None, "frontmatter closing --- is missing"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _key_lines(fm_lines: list[str]) -> dict[str, int]:
|
|
70
|
+
keys: dict[str, int] = {}
|
|
71
|
+
for offset, line in enumerate(fm_lines):
|
|
72
|
+
if not line or line[:1].isspace() or ":" not in line:
|
|
73
|
+
continue
|
|
74
|
+
key = line.split(":", 1)[0].strip()
|
|
75
|
+
keys.setdefault(key, offset + 2)
|
|
76
|
+
return keys
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_spec(path: Path, rel: str) -> list[Finding]:
|
|
80
|
+
"""L1: agentskills.io frontmatter conformance for one skill."""
|
|
81
|
+
findings: list[Finding] = []
|
|
82
|
+
try:
|
|
83
|
+
text = path.read_text(encoding="utf-8")
|
|
84
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
85
|
+
return [
|
|
86
|
+
_finding(
|
|
87
|
+
"skills.spec",
|
|
88
|
+
"high",
|
|
89
|
+
rel,
|
|
90
|
+
1,
|
|
91
|
+
"SKILL.md is unreadable",
|
|
92
|
+
str(exc)[:200],
|
|
93
|
+
"fix the file encoding or permissions",
|
|
94
|
+
)
|
|
95
|
+
]
|
|
96
|
+
fm_lines, error = _split_frontmatter(text)
|
|
97
|
+
if fm_lines is None or error is not None:
|
|
98
|
+
return [
|
|
99
|
+
_finding(
|
|
100
|
+
"skills.spec",
|
|
101
|
+
"high",
|
|
102
|
+
rel,
|
|
103
|
+
1,
|
|
104
|
+
error or "missing frontmatter",
|
|
105
|
+
"agentskills.io requires a SKILL.md frontmatter block",
|
|
106
|
+
"add a --- name/description --- block",
|
|
107
|
+
)
|
|
108
|
+
]
|
|
109
|
+
raw = "\n".join(fm_lines)
|
|
110
|
+
try:
|
|
111
|
+
loaded: Any = yaml.safe_load(raw)
|
|
112
|
+
except yaml.YAMLError as exc:
|
|
113
|
+
return [
|
|
114
|
+
_finding(
|
|
115
|
+
"skills.spec",
|
|
116
|
+
"high",
|
|
117
|
+
rel,
|
|
118
|
+
1,
|
|
119
|
+
"frontmatter YAML cannot be parsed",
|
|
120
|
+
str(exc)[:200],
|
|
121
|
+
"fix the YAML syntax",
|
|
122
|
+
)
|
|
123
|
+
]
|
|
124
|
+
if not isinstance(loaded, dict):
|
|
125
|
+
return [
|
|
126
|
+
_finding(
|
|
127
|
+
"skills.spec",
|
|
128
|
+
"high",
|
|
129
|
+
rel,
|
|
130
|
+
1,
|
|
131
|
+
"frontmatter must be a mapping",
|
|
132
|
+
f"got {type(loaded).__name__}",
|
|
133
|
+
"use key: value pairs",
|
|
134
|
+
)
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
keys = _key_lines(fm_lines)
|
|
138
|
+
known = set(REQUIRED_KEYS) | set(KNOWN_OPTIONAL_KEYS)
|
|
139
|
+
unknown = sorted(str(key) for key in loaded if key not in known)
|
|
140
|
+
if unknown:
|
|
141
|
+
findings.append(
|
|
142
|
+
_finding(
|
|
143
|
+
"skills.spec",
|
|
144
|
+
"medium",
|
|
145
|
+
rel,
|
|
146
|
+
keys.get(unknown[0], 1),
|
|
147
|
+
"unknown frontmatter keys: " + ", ".join(unknown),
|
|
148
|
+
"spec fields: name, description, license, compatibility, metadata, allowed-tools",
|
|
149
|
+
"remove them or propose them upstream at agentskills.io",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
name = loaded.get("name")
|
|
154
|
+
if not isinstance(name, str) or not name.strip():
|
|
155
|
+
findings.append(
|
|
156
|
+
_finding(
|
|
157
|
+
"skills.spec",
|
|
158
|
+
"high",
|
|
159
|
+
rel,
|
|
160
|
+
keys.get("name", 1),
|
|
161
|
+
"missing or empty name",
|
|
162
|
+
"every skill needs a name",
|
|
163
|
+
"add the name field",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
if len(name) > NAME_MAX:
|
|
168
|
+
findings.append(
|
|
169
|
+
_finding(
|
|
170
|
+
"skills.spec",
|
|
171
|
+
"high",
|
|
172
|
+
rel,
|
|
173
|
+
keys.get("name", 1),
|
|
174
|
+
f"name is {len(name)} characters (max {NAME_MAX})",
|
|
175
|
+
name[:80],
|
|
176
|
+
"shorten the name",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
if NAME_RE.fullmatch(name) is None:
|
|
180
|
+
findings.append(
|
|
181
|
+
_finding(
|
|
182
|
+
"skills.spec",
|
|
183
|
+
"high",
|
|
184
|
+
rel,
|
|
185
|
+
keys.get("name", 1),
|
|
186
|
+
"name must be lowercase kebab-case (a-z, 0-9, single hyphens)",
|
|
187
|
+
name,
|
|
188
|
+
"rename the skill and its directory",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
if name != path.parent.name:
|
|
192
|
+
findings.append(
|
|
193
|
+
_finding(
|
|
194
|
+
"skills.spec",
|
|
195
|
+
"high",
|
|
196
|
+
rel,
|
|
197
|
+
keys.get("name", 1),
|
|
198
|
+
"name must match its directory",
|
|
199
|
+
f"name={name!r} directory={path.parent.name!r}",
|
|
200
|
+
"make them identical",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
description = loaded.get("description")
|
|
205
|
+
if not isinstance(description, str) or not description.strip():
|
|
206
|
+
findings.append(
|
|
207
|
+
_finding(
|
|
208
|
+
"skills.spec",
|
|
209
|
+
"high",
|
|
210
|
+
rel,
|
|
211
|
+
keys.get("description", 1),
|
|
212
|
+
"missing or empty description",
|
|
213
|
+
"the description drives skill discovery",
|
|
214
|
+
"describe what the skill does and when to use it",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
elif len(description) > DESCRIPTION_MAX:
|
|
218
|
+
findings.append(
|
|
219
|
+
_finding(
|
|
220
|
+
"skills.spec",
|
|
221
|
+
"medium",
|
|
222
|
+
rel,
|
|
223
|
+
keys.get("description", 1),
|
|
224
|
+
f"description is {len(description)} characters (max {DESCRIPTION_MAX})",
|
|
225
|
+
description[:80] + "...",
|
|
226
|
+
"move detail into the body or references/",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
license_value = loaded.get("license")
|
|
231
|
+
if license_value is not None and not isinstance(license_value, str):
|
|
232
|
+
findings.append(
|
|
233
|
+
_finding(
|
|
234
|
+
"skills.spec",
|
|
235
|
+
"low",
|
|
236
|
+
rel,
|
|
237
|
+
keys.get("license", 1),
|
|
238
|
+
"license must be a short string or a reference to a bundled license file",
|
|
239
|
+
f"got {type(license_value).__name__}",
|
|
240
|
+
"use a plain string",
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
compatibility = loaded.get("compatibility")
|
|
245
|
+
if compatibility is not None:
|
|
246
|
+
if not isinstance(compatibility, str):
|
|
247
|
+
findings.append(
|
|
248
|
+
_finding(
|
|
249
|
+
"skills.spec",
|
|
250
|
+
"medium",
|
|
251
|
+
rel,
|
|
252
|
+
keys.get("compatibility", 1),
|
|
253
|
+
"compatibility must be a string",
|
|
254
|
+
f"got {type(compatibility).__name__}",
|
|
255
|
+
"describe environment requirements in one string",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
elif len(compatibility) > COMPATIBILITY_MAX:
|
|
259
|
+
findings.append(
|
|
260
|
+
_finding(
|
|
261
|
+
"skills.spec",
|
|
262
|
+
"medium",
|
|
263
|
+
rel,
|
|
264
|
+
keys.get("compatibility", 1),
|
|
265
|
+
f"compatibility is {len(compatibility)} characters (max {COMPATIBILITY_MAX})",
|
|
266
|
+
compatibility[:80] + "...",
|
|
267
|
+
"keep only the hard requirements",
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
allowed_tools = loaded.get("allowed-tools")
|
|
272
|
+
if allowed_tools is not None:
|
|
273
|
+
shape_ok = isinstance(allowed_tools, str) or (
|
|
274
|
+
isinstance(allowed_tools, list) and all(isinstance(item, str) for item in allowed_tools)
|
|
275
|
+
)
|
|
276
|
+
if not shape_ok:
|
|
277
|
+
findings.append(
|
|
278
|
+
_finding(
|
|
279
|
+
"skills.spec",
|
|
280
|
+
"medium",
|
|
281
|
+
rel,
|
|
282
|
+
keys.get("allowed-tools", 1),
|
|
283
|
+
"allowed-tools must be a string or a list of strings",
|
|
284
|
+
f"got {type(allowed_tools).__name__}",
|
|
285
|
+
"use a space-delimited string or a list",
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
metadata = loaded.get("metadata")
|
|
290
|
+
if metadata is not None:
|
|
291
|
+
if not isinstance(metadata, dict):
|
|
292
|
+
findings.append(
|
|
293
|
+
_finding(
|
|
294
|
+
"skills.spec",
|
|
295
|
+
"medium",
|
|
296
|
+
rel,
|
|
297
|
+
keys.get("metadata", 1),
|
|
298
|
+
"metadata must be a mapping of string to string",
|
|
299
|
+
f"got {type(metadata).__name__}",
|
|
300
|
+
"use key: value pairs",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
for meta_key, meta_value in metadata.items():
|
|
305
|
+
if not isinstance(meta_key, str) or not meta_key.strip():
|
|
306
|
+
findings.append(
|
|
307
|
+
_finding(
|
|
308
|
+
"skills.spec",
|
|
309
|
+
"medium",
|
|
310
|
+
rel,
|
|
311
|
+
keys.get("metadata", 1),
|
|
312
|
+
"metadata keys must be non-empty strings",
|
|
313
|
+
f"got {meta_key!r}",
|
|
314
|
+
"name every metadata key",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
elif isinstance(meta_value, (dict, list)):
|
|
318
|
+
findings.append(
|
|
319
|
+
_finding(
|
|
320
|
+
"skills.spec",
|
|
321
|
+
"medium",
|
|
322
|
+
rel,
|
|
323
|
+
keys.get("metadata", 1),
|
|
324
|
+
f"metadata[{meta_key!r}] must be a scalar (the spec maps string to string)",
|
|
325
|
+
f"got {type(meta_value).__name__}",
|
|
326
|
+
"flatten the structure or serialize it",
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
elif not isinstance(meta_value, str):
|
|
330
|
+
findings.append(
|
|
331
|
+
_finding(
|
|
332
|
+
"skills.spec",
|
|
333
|
+
"low",
|
|
334
|
+
rel,
|
|
335
|
+
keys.get("metadata", 1),
|
|
336
|
+
f"metadata[{meta_key!r}] is not a string",
|
|
337
|
+
f"got {type(meta_value).__name__}",
|
|
338
|
+
"quote the value so clients read it as a string",
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
return findings
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def load_registry(path: Path) -> tuple[list[str] | None, str | None]:
|
|
345
|
+
"""Load a registry file: a JSON array of names/objects, or an object wrapping one."""
|
|
346
|
+
try:
|
|
347
|
+
doc: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
348
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
349
|
+
return None, str(exc)[:200]
|
|
350
|
+
items: list[Any] | None = None
|
|
351
|
+
if isinstance(doc, list):
|
|
352
|
+
items = doc
|
|
353
|
+
elif isinstance(doc, dict):
|
|
354
|
+
for key in REGISTRY_LIST_KEYS:
|
|
355
|
+
value = doc.get(key)
|
|
356
|
+
if isinstance(value, list):
|
|
357
|
+
items = value
|
|
358
|
+
break
|
|
359
|
+
if items is None:
|
|
360
|
+
return (
|
|
361
|
+
None,
|
|
362
|
+
"registry must be a JSON array or an object with a skills/capabilities/entries/items list",
|
|
363
|
+
)
|
|
364
|
+
names: list[str] = []
|
|
365
|
+
for item in items:
|
|
366
|
+
if isinstance(item, str):
|
|
367
|
+
names.append(item)
|
|
368
|
+
continue
|
|
369
|
+
if isinstance(item, dict):
|
|
370
|
+
for key in REGISTRY_NAME_KEYS:
|
|
371
|
+
value = item.get(key)
|
|
372
|
+
if isinstance(value, str):
|
|
373
|
+
names.append(value)
|
|
374
|
+
break
|
|
375
|
+
else:
|
|
376
|
+
return None, "registry entries must be strings or objects with id/name/skill"
|
|
377
|
+
continue
|
|
378
|
+
return None, "registry entries must be strings or objects with id/name/skill"
|
|
379
|
+
return names, None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def scan_skills(root: Path, registry_path: Path | None) -> tuple[list[Finding], SkillsScan]:
|
|
383
|
+
"""Scan root for */SKILL.md and (optionally) compare against a registry file."""
|
|
384
|
+
findings: list[Finding] = []
|
|
385
|
+
skill_files = sorted(path for path in root.glob("*/SKILL.md") if path.is_file())
|
|
386
|
+
for path in skill_files:
|
|
387
|
+
rel = path.relative_to(root).as_posix()
|
|
388
|
+
findings.extend(check_spec(path, rel))
|
|
389
|
+
if not skill_files:
|
|
390
|
+
findings.append(
|
|
391
|
+
_finding(
|
|
392
|
+
"skills.spec",
|
|
393
|
+
"medium",
|
|
394
|
+
root.as_posix(),
|
|
395
|
+
1,
|
|
396
|
+
"no skills discovered",
|
|
397
|
+
"expected */SKILL.md directories under the root",
|
|
398
|
+
"point memproof at the skills parent directory",
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
registry_names: list[str] | None = None
|
|
403
|
+
registry_count: int | None = None
|
|
404
|
+
if registry_path is not None:
|
|
405
|
+
registry_names, error = load_registry(registry_path)
|
|
406
|
+
if registry_names is None or error is not None:
|
|
407
|
+
findings.append(
|
|
408
|
+
_finding(
|
|
409
|
+
"skills.registry",
|
|
410
|
+
"high",
|
|
411
|
+
registry_path.as_posix(),
|
|
412
|
+
1,
|
|
413
|
+
"registry file cannot be loaded",
|
|
414
|
+
error or "unknown error",
|
|
415
|
+
"fix the registry JSON",
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
registry_count = len(registry_names)
|
|
420
|
+
disk = {path.parent.name for path in skill_files}
|
|
421
|
+
declared = set(registry_names)
|
|
422
|
+
for name in sorted(disk - declared):
|
|
423
|
+
findings.append(
|
|
424
|
+
_finding(
|
|
425
|
+
"skills.registry",
|
|
426
|
+
"medium",
|
|
427
|
+
name + "/SKILL.md",
|
|
428
|
+
1,
|
|
429
|
+
"skill exists on disk but is missing from the registry",
|
|
430
|
+
f"registry: {registry_path.as_posix()}",
|
|
431
|
+
"register it or remove the directory",
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
for name in sorted(declared - disk):
|
|
435
|
+
findings.append(
|
|
436
|
+
_finding(
|
|
437
|
+
"skills.registry",
|
|
438
|
+
"medium",
|
|
439
|
+
registry_path.as_posix(),
|
|
440
|
+
1,
|
|
441
|
+
f"registry entry has no skill directory: {name}",
|
|
442
|
+
f"{len(disk)} skills found on disk",
|
|
443
|
+
"remove the entry or add the skill",
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
duplicates = sorted({n for n in registry_names if registry_names.count(n) > 1})
|
|
447
|
+
if duplicates:
|
|
448
|
+
findings.append(
|
|
449
|
+
_finding(
|
|
450
|
+
"skills.registry",
|
|
451
|
+
"medium",
|
|
452
|
+
registry_path.as_posix(),
|
|
453
|
+
1,
|
|
454
|
+
"duplicate registry entries: " + ", ".join(duplicates),
|
|
455
|
+
f"{len(registry_names)} entries total",
|
|
456
|
+
"keep one entry per skill",
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
return findings, SkillsScan(root=root, skills=len(skill_files), registry=registry_count)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def render_markdown(scan: SkillsScan, findings: list[Finding]) -> str:
|
|
463
|
+
ordered = sorted(findings, key=_sort_key)
|
|
464
|
+
counts = summarize(ordered)
|
|
465
|
+
registry_label = str(scan.registry) if scan.registry is not None else "off"
|
|
466
|
+
lines = [
|
|
467
|
+
f"# memproof skills report — {scan.root.name or scan.root.as_posix()}",
|
|
468
|
+
"",
|
|
469
|
+
(
|
|
470
|
+
f"Score: {score(ordered)}/100 · Skills: {scan.skills} · Registry: {registry_label} · "
|
|
471
|
+
f"Findings: {counts['high']} high / {counts['medium']} medium / {counts['low']} low"
|
|
472
|
+
),
|
|
473
|
+
"",
|
|
474
|
+
]
|
|
475
|
+
labels = {"high": "High", "medium": "Medium", "low": "Low"}
|
|
476
|
+
for severity in ("high", "medium", "low"):
|
|
477
|
+
lines.append(f"## {labels[severity]}")
|
|
478
|
+
lines.append("")
|
|
479
|
+
group = [finding for finding in ordered if finding.severity == severity]
|
|
480
|
+
if not group:
|
|
481
|
+
lines.append("None.")
|
|
482
|
+
lines.append("")
|
|
483
|
+
continue
|
|
484
|
+
for finding in group:
|
|
485
|
+
lines.append(f"- [{finding.check}] {finding.path}:{finding.line} — {finding.message}")
|
|
486
|
+
if finding.evidence:
|
|
487
|
+
lines.append(f" Evidence: {finding.evidence}")
|
|
488
|
+
if finding.fix:
|
|
489
|
+
lines.append(f" Fix: {finding.fix}")
|
|
490
|
+
lines.append("")
|
|
491
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _sort_key(finding: Finding) -> tuple[int, str, int, str]:
|
|
495
|
+
order = {"high": 0, "medium": 1, "low": 2}
|
|
496
|
+
return (order.get(finding.severity, 99), finding.path, finding.line, finding.check)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def render_json(scan: SkillsScan, findings: list[Finding]) -> str:
|
|
500
|
+
ordered = sorted(findings, key=_sort_key)
|
|
501
|
+
registry_label = scan.registry if scan.registry is not None else 0
|
|
502
|
+
payload = {
|
|
503
|
+
"schema": SCHEMA,
|
|
504
|
+
"root": scan.root.as_posix(),
|
|
505
|
+
"summary": {
|
|
506
|
+
"score": score(ordered),
|
|
507
|
+
"skills": scan.skills,
|
|
508
|
+
"registry": registry_label,
|
|
509
|
+
"findings": summarize(ordered),
|
|
510
|
+
},
|
|
511
|
+
"findings": [
|
|
512
|
+
{
|
|
513
|
+
"check": finding.check,
|
|
514
|
+
"severity": finding.severity,
|
|
515
|
+
"path": finding.path,
|
|
516
|
+
"line": finding.line,
|
|
517
|
+
"message": finding.message,
|
|
518
|
+
"evidence": finding.evidence,
|
|
519
|
+
"fix": finding.fix,
|
|
520
|
+
}
|
|
521
|
+
for finding in ordered
|
|
522
|
+
],
|
|
523
|
+
}
|
|
524
|
+
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memproof
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Evidence-first health checks for Markdown knowledge vaults.
|
|
5
5
|
Author: memproof contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,24 +19,41 @@ License-File: LICENSE
|
|
|
19
19
|
Requires-Dist: pyyaml>=6.0
|
|
20
20
|
Provides-Extra: dev
|
|
21
21
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
22
|
-
Requires-Dist: ruff
|
|
22
|
+
Requires-Dist: ruff==0.17.2; extra == "dev"
|
|
23
23
|
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
24
24
|
Dynamic: license-file
|
|
25
25
|
|
|
26
26
|
# memproof
|
|
27
27
|
|
|
28
|
+
[](https://pypi.org/project/memproof/)
|
|
29
|
+
[](https://github.com/hsys123/memproof/actions/workflows/test.yml)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+
|
|
28
32
|
Evidence-first health checks for Markdown knowledge vaults.
|
|
29
33
|
|
|
30
34
|
`memproof` answers one question: **can you prove what your knowledge base claims?**
|
|
31
35
|
Every finding carries a `path:line`, the evidence behind it, and a concrete fix.
|
|
32
36
|
It is read-only, offline, and deterministic.
|
|
33
37
|
|
|
38
|
+
## Check Agent Skills (agentskills.io)
|
|
39
|
+
|
|
40
|
+
Point `memproof skills` at a directory of `skills/<name>/SKILL.md` to validate the agentskills.io
|
|
41
|
+
contract: kebab-case names that match their directory, description limits,
|
|
42
|
+
optional-field types and unknown keys. Add `--registry skills.json` to diff the disk against a declared skill registry in both directions.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
memproof skills ./skills --registry config/skills.json --fail-on high
|
|
46
|
+
```
|
|
47
|
+
|
|
34
48
|
## Install
|
|
35
49
|
|
|
36
50
|
```bash
|
|
37
51
|
uv tool install memproof
|
|
38
52
|
# or
|
|
39
53
|
pip install memproof
|
|
54
|
+
|
|
55
|
+
# before the PyPI release, install straight from GitHub:
|
|
56
|
+
uv tool install git+https://github.com/hsys123/memproof
|
|
40
57
|
```
|
|
41
58
|
|
|
42
59
|
## Use
|
|
@@ -6,6 +6,7 @@ memproof/__main__.py
|
|
|
6
6
|
memproof/cli.py
|
|
7
7
|
memproof/model.py
|
|
8
8
|
memproof/report.py
|
|
9
|
+
memproof/skills.py
|
|
9
10
|
memproof/vault.py
|
|
10
11
|
memproof.egg-info/PKG-INFO
|
|
11
12
|
memproof.egg-info/SOURCES.txt
|
|
@@ -20,4 +21,5 @@ memproof/checks/links.py
|
|
|
20
21
|
memproof/checks/provenance.py
|
|
21
22
|
memproof/checks/structure.py
|
|
22
23
|
tests/test_checks.py
|
|
23
|
-
tests/test_cli.py
|
|
24
|
+
tests/test_cli.py
|
|
25
|
+
tests/test_skills.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "memproof"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Evidence-first health checks for Markdown knowledge vaults."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -29,7 +29,7 @@ Issues = "https://github.com/hsys123/memproof/issues"
|
|
|
29
29
|
memproof = "memproof.cli:main"
|
|
30
30
|
|
|
31
31
|
[project.optional-dependencies]
|
|
32
|
-
dev = ["pytest>=8.0", "ruff
|
|
32
|
+
dev = ["pytest>=8.0", "ruff==0.17.2", "mypy>=1.11"]
|
|
33
33
|
|
|
34
34
|
[tool.setuptools.packages.find]
|
|
35
35
|
include = ["memproof*"]
|
|
@@ -27,7 +27,7 @@ def run(*args: str) -> subprocess.CompletedProcess[str]:
|
|
|
27
27
|
def test_version() -> None:
|
|
28
28
|
proc = run("version")
|
|
29
29
|
assert proc.returncode == 0
|
|
30
|
-
assert proc.stdout.strip() == "0.
|
|
30
|
+
assert proc.stdout.strip() == "0.2.0"
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def test_check_exit_zero_by_default() -> None:
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from memproof.skills import check_spec, load_registry, scan_skills
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(*args: str) -> subprocess.CompletedProcess[str]:
|
|
15
|
+
return subprocess.run(
|
|
16
|
+
[sys.executable, "-m", "memproof", *args],
|
|
17
|
+
cwd=ROOT,
|
|
18
|
+
env={**os.environ, "PYTHONPATH": str(ROOT), "PYTHONIOENCODING": "utf-8"},
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
encoding="utf-8",
|
|
22
|
+
errors="replace",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def write_skill(root: Path, name: str, frontmatter: str) -> Path:
|
|
27
|
+
skill_dir = root / name
|
|
28
|
+
skill_dir.mkdir(parents=True)
|
|
29
|
+
path = skill_dir / "SKILL.md"
|
|
30
|
+
path.write_text("---\n" + frontmatter + "\n---\n\nBody.\n", encoding="utf-8")
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def codes(findings: list) -> set[str]:
|
|
35
|
+
return {finding.check for finding in findings}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def severities(findings: list) -> list[str]:
|
|
39
|
+
return [finding.severity for finding in findings]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_minimal_valid_skill_has_no_findings(tmp_path: Path) -> None:
|
|
43
|
+
write_skill(tmp_path, "sample", "name: sample\ndescription: A portable sample skill.")
|
|
44
|
+
findings, scan = scan_skills(tmp_path, None)
|
|
45
|
+
assert findings == []
|
|
46
|
+
assert scan.skills == 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_missing_frontmatter_is_high(tmp_path: Path) -> None:
|
|
50
|
+
skill = tmp_path / "sample"
|
|
51
|
+
skill.mkdir()
|
|
52
|
+
(skill / "SKILL.md").write_text("no frontmatter here\n", encoding="utf-8")
|
|
53
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
54
|
+
assert severities(findings) == ["high"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_unclosed_frontmatter_is_high(tmp_path: Path) -> None:
|
|
58
|
+
skill = tmp_path / "sample"
|
|
59
|
+
skill.mkdir()
|
|
60
|
+
(skill / "SKILL.md").write_text("---\nname: sample\n", encoding="utf-8")
|
|
61
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
62
|
+
assert severities(findings) == ["high"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_broken_yaml_is_high(tmp_path: Path) -> None:
|
|
66
|
+
write_skill(tmp_path, "sample", "name: sample\ndescription: [unclosed")
|
|
67
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
68
|
+
assert severities(findings) == ["high"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_name_must_match_directory(tmp_path: Path) -> None:
|
|
72
|
+
write_skill(tmp_path, "sample", "name: other\ndescription: Mismatched.")
|
|
73
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
74
|
+
assert findings and findings[0].severity == "high"
|
|
75
|
+
assert "must match" in findings[0].message
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_name_must_be_kebab_case(tmp_path: Path) -> None:
|
|
79
|
+
write_skill(tmp_path, "sample", "name: Sample\ndescription: Uppercase name.")
|
|
80
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
81
|
+
assert findings and findings[0].severity == "high"
|
|
82
|
+
assert "kebab" in findings[0].message
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_missing_description_is_high(tmp_path: Path) -> None:
|
|
86
|
+
write_skill(tmp_path, "sample", "name: sample")
|
|
87
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
88
|
+
assert findings and findings[0].severity == "high"
|
|
89
|
+
assert "description" in findings[0].message
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_oversized_description_is_medium(tmp_path: Path) -> None:
|
|
93
|
+
write_skill(tmp_path, "sample", "name: sample\ndescription: " + "x" * 1100)
|
|
94
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
95
|
+
assert severities(findings) == ["medium"]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_unknown_frontmatter_key_is_medium(tmp_path: Path) -> None:
|
|
99
|
+
write_skill(
|
|
100
|
+
tmp_path,
|
|
101
|
+
"sample",
|
|
102
|
+
"name: sample\ndescription: Fine.\nversion: 3",
|
|
103
|
+
)
|
|
104
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
105
|
+
assert len(findings) == 1
|
|
106
|
+
assert findings[0].severity == "medium"
|
|
107
|
+
assert "version" in findings[0].message
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_metadata_scalar_rules(tmp_path: Path) -> None:
|
|
111
|
+
write_skill(
|
|
112
|
+
tmp_path,
|
|
113
|
+
"sample",
|
|
114
|
+
"name: sample\n"
|
|
115
|
+
"description: Metadata shapes.\n"
|
|
116
|
+
"metadata:\n"
|
|
117
|
+
" aii.tier: core\n"
|
|
118
|
+
" aii.flag: true\n"
|
|
119
|
+
" aii.nested:\n"
|
|
120
|
+
" inner: value\n",
|
|
121
|
+
)
|
|
122
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
123
|
+
levels = sorted(finding.severity for finding in findings)
|
|
124
|
+
assert levels == ["low", "medium"]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_metadata_non_mapping_is_medium(tmp_path: Path) -> None:
|
|
128
|
+
write_skill(tmp_path, "sample", "name: sample\ndescription: Flat.\nmetadata: oops")
|
|
129
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
130
|
+
assert severities(findings) == ["medium"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_oversized_compatibility_is_medium(tmp_path: Path) -> None:
|
|
134
|
+
write_skill(
|
|
135
|
+
tmp_path,
|
|
136
|
+
"sample",
|
|
137
|
+
"name: sample\ndescription: Compat.\ncompatibility: " + "y" * 600,
|
|
138
|
+
)
|
|
139
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
140
|
+
assert severities(findings) == ["medium"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_allowed_tools_list_is_accepted(tmp_path: Path) -> None:
|
|
144
|
+
write_skill(
|
|
145
|
+
tmp_path,
|
|
146
|
+
"sample",
|
|
147
|
+
"name: sample\ndescription: Tools.\nallowed-tools:\n - Read\n - Bash",
|
|
148
|
+
)
|
|
149
|
+
findings, _ = scan_skills(tmp_path, None)
|
|
150
|
+
assert findings == []
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_empty_root_is_medium(tmp_path: Path) -> None:
|
|
154
|
+
findings, scan = scan_skills(tmp_path, None)
|
|
155
|
+
assert scan.skills == 0
|
|
156
|
+
assert severities(findings) == ["medium"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_registry_unregistered_and_missing(tmp_path: Path) -> None:
|
|
160
|
+
write_skill(tmp_path, "present", "name: present\ndescription: On disk.")
|
|
161
|
+
registry = tmp_path / "registry.json"
|
|
162
|
+
registry.write_text(json.dumps(["ghost"]), encoding="utf-8")
|
|
163
|
+
findings, scan = scan_skills(tmp_path, registry)
|
|
164
|
+
assert scan.registry == 1
|
|
165
|
+
medium = [f.message for f in findings if f.severity == "medium"]
|
|
166
|
+
assert any("missing from the registry" in message for message in medium)
|
|
167
|
+
assert any("no skill directory" in message for message in medium)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_registry_duplicate_entries_are_medium(tmp_path: Path) -> None:
|
|
171
|
+
write_skill(tmp_path, "sample", "name: sample\ndescription: Dup.")
|
|
172
|
+
registry = tmp_path / "registry.json"
|
|
173
|
+
registry.write_text(json.dumps(["sample", "sample"]), encoding="utf-8")
|
|
174
|
+
findings, scan = scan_skills(tmp_path, registry)
|
|
175
|
+
assert scan.registry == 2
|
|
176
|
+
assert any("duplicate" in f.message for f in findings if f.severity == "medium")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_registry_object_shape_is_supported(tmp_path: Path) -> None:
|
|
180
|
+
write_skill(tmp_path, "sample", "name: sample\ndescription: Shape.")
|
|
181
|
+
registry = tmp_path / "registry.json"
|
|
182
|
+
payload = {"skills": [{"id": "sample", "tier": "core"}]}
|
|
183
|
+
registry.write_text(json.dumps(payload), encoding="utf-8")
|
|
184
|
+
findings, scan = scan_skills(tmp_path, registry)
|
|
185
|
+
assert findings == []
|
|
186
|
+
assert scan.registry == 1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_registry_parse_error_is_high(tmp_path: Path) -> None:
|
|
190
|
+
write_skill(tmp_path, "sample", "name: sample\ndescription: Bad registry.")
|
|
191
|
+
registry = tmp_path / "registry.json"
|
|
192
|
+
registry.write_text("{broken", encoding="utf-8")
|
|
193
|
+
findings, _ = scan_skills(tmp_path, registry)
|
|
194
|
+
assert findings and findings[0].severity == "high"
|
|
195
|
+
assert findings[0].check == "skills.registry"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_load_registry_rejects_unknown_shapes(tmp_path: Path) -> None:
|
|
199
|
+
path = tmp_path / "registry.json"
|
|
200
|
+
path.write_text(json.dumps({"unexpected": 1}), encoding="utf-8")
|
|
201
|
+
names, error = load_registry(path)
|
|
202
|
+
assert names is None
|
|
203
|
+
assert error is not None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_cli_skills_command_end_to_end(tmp_path: Path) -> None:
|
|
207
|
+
write_skill(tmp_path, "sample", "name: sample\ndescription: CLI run.")
|
|
208
|
+
proc = run("skills", str(tmp_path), "--format", "json")
|
|
209
|
+
assert proc.returncode == 0, proc.stderr
|
|
210
|
+
payload = json.loads(proc.stdout)
|
|
211
|
+
assert payload["schema"] == "memproof.skills.v1"
|
|
212
|
+
assert payload["summary"]["skills"] == 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_cli_skills_fail_on_high(tmp_path: Path) -> None:
|
|
216
|
+
write_skill(tmp_path, "sample", "name: other\ndescription: Mismatched.")
|
|
217
|
+
proc = run("skills", str(tmp_path), "--fail-on", "high")
|
|
218
|
+
assert proc.returncode == 1
|
|
219
|
+
assert "## High" in proc.stdout
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_check_spec_direct(tmp_path: Path) -> None:
|
|
223
|
+
skill = write_skill(tmp_path, "sample", "name: sample\ndescription: Direct.")
|
|
224
|
+
findings = check_spec(skill, "sample/SKILL.md")
|
|
225
|
+
assert findings == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|