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.
Files changed (27) hide show
  1. {memproof-0.1.0 → memproof-0.2.0}/PKG-INFO +19 -2
  2. {memproof-0.1.0 → memproof-0.2.0}/README.md +17 -0
  3. {memproof-0.1.0 → memproof-0.2.0}/memproof/__init__.py +1 -1
  4. {memproof-0.1.0 → memproof-0.2.0}/memproof/cli.py +45 -0
  5. memproof-0.2.0/memproof/skills.py +524 -0
  6. {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/PKG-INFO +19 -2
  7. {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/SOURCES.txt +3 -1
  8. {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/requires.txt +1 -1
  9. {memproof-0.1.0 → memproof-0.2.0}/pyproject.toml +2 -2
  10. {memproof-0.1.0 → memproof-0.2.0}/tests/test_cli.py +1 -1
  11. memproof-0.2.0/tests/test_skills.py +225 -0
  12. {memproof-0.1.0 → memproof-0.2.0}/LICENSE +0 -0
  13. {memproof-0.1.0 → memproof-0.2.0}/memproof/__main__.py +0 -0
  14. {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/__init__.py +0 -0
  15. {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/freshness.py +0 -0
  16. {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/frontmatter.py +0 -0
  17. {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/links.py +0 -0
  18. {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/provenance.py +0 -0
  19. {memproof-0.1.0 → memproof-0.2.0}/memproof/checks/structure.py +0 -0
  20. {memproof-0.1.0 → memproof-0.2.0}/memproof/model.py +0 -0
  21. {memproof-0.1.0 → memproof-0.2.0}/memproof/report.py +0 -0
  22. {memproof-0.1.0 → memproof-0.2.0}/memproof/vault.py +0 -0
  23. {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/dependency_links.txt +0 -0
  24. {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/entry_points.txt +0 -0
  25. {memproof-0.1.0 → memproof-0.2.0}/memproof.egg-info/top_level.txt +0 -0
  26. {memproof-0.1.0 → memproof-0.2.0}/setup.cfg +0 -0
  27. {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.1.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>=0.6; extra == "dev"
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
+ [![PyPI](https://img.shields.io/pypi/v/memproof.svg)](https://pypi.org/project/memproof/)
29
+ [![test](https://github.com/hsys123/memproof/actions/workflows/test.yml/badge.svg)](https://github.com/hsys123/memproof/actions/workflows/test.yml)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/memproof.svg)](https://pypi.org/project/memproof/)
4
+ [![test](https://github.com/hsys123/memproof/actions/workflows/test.yml/badge.svg)](https://github.com/hsys123/memproof/actions/workflows/test.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
@@ -1,3 +1,3 @@
1
1
  """memproof — evidence-first health checks for Markdown knowledge vaults."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -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.1.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>=0.6; extra == "dev"
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
+ [![PyPI](https://img.shields.io/pypi/v/memproof.svg)](https://pypi.org/project/memproof/)
29
+ [![test](https://github.com/hsys123/memproof/actions/workflows/test.yml/badge.svg)](https://github.com/hsys123/memproof/actions/workflows/test.yml)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
@@ -2,5 +2,5 @@ pyyaml>=6.0
2
2
 
3
3
  [dev]
4
4
  pytest>=8.0
5
- ruff>=0.6
5
+ ruff==0.17.2
6
6
  mypy>=1.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memproof"
7
- version = "0.1.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>=0.6", "mypy>=1.11"]
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.1.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