drskill 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.
- drskill/__init__.py +0 -0
- drskill/checks/__init__.py +69 -0
- drskill/checks/budget.py +40 -0
- drskill/checks/duplicates.py +103 -0
- drskill/checks/filesystem.py +36 -0
- drskill/checks/lockfile.py +129 -0
- drskill/checks/shadowing.py +64 -0
- drskill/checks/spec.py +108 -0
- drskill/cli.py +182 -0
- drskill/data/__init__.py +0 -0
- drskill/data/harnesses.toml +880 -0
- drskill/discovery.py +96 -0
- drskill/harnesses.py +58 -0
- drskill/ledger.py +79 -0
- drskill/models.py +67 -0
- drskill/pipeline.py +45 -0
- drskill/report.py +124 -0
- drskill/resolution.py +158 -0
- drskill/tokens.py +28 -0
- drskill-0.1.0.dist-info/METADATA +130 -0
- drskill-0.1.0.dist-info/RECORD +24 -0
- drskill-0.1.0.dist-info/WHEEL +4 -0
- drskill-0.1.0.dist-info/entry_points.txt +2 -0
- drskill-0.1.0.dist-info/licenses/LICENSE +21 -0
drskill/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from drskill.ledger import Config
|
|
7
|
+
from drskill.models import Contributor, Finding
|
|
8
|
+
from drskill.resolution import World
|
|
9
|
+
|
|
10
|
+
CheckFn = Callable[[World, Config], list[Finding]]
|
|
11
|
+
REGISTRY: dict[str, CheckFn] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check(check_id: str):
|
|
15
|
+
def deco(fn: CheckFn) -> CheckFn:
|
|
16
|
+
REGISTRY[check_id] = fn
|
|
17
|
+
return fn
|
|
18
|
+
|
|
19
|
+
return deco
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fingerprint(check_id: str, contributors: list[Contributor], extra: str = "") -> str:
|
|
23
|
+
payload = "|".join([check_id, *sorted(c.content_hash for c in contributors), extra])
|
|
24
|
+
return "sha256:" + hashlib.sha256(payload.encode()).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_finding(
|
|
28
|
+
check_id: str,
|
|
29
|
+
severity: str,
|
|
30
|
+
contributors: list[Contributor],
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
harnesses: list[str] | None = None,
|
|
34
|
+
fix_commands: list[str] | None = None,
|
|
35
|
+
extra_key: str = "",
|
|
36
|
+
) -> Finding:
|
|
37
|
+
if harnesses is None:
|
|
38
|
+
harnesses = sorted({d.harness for c in contributors for d in c.deployments})
|
|
39
|
+
return Finding(
|
|
40
|
+
check_id=check_id,
|
|
41
|
+
severity=severity,
|
|
42
|
+
contributors=[c.id for c in contributors],
|
|
43
|
+
contributor_names=sorted({c.name for c in contributors}),
|
|
44
|
+
harnesses=harnesses,
|
|
45
|
+
message=message,
|
|
46
|
+
fix_commands=fix_commands or [],
|
|
47
|
+
fingerprint=fingerprint(check_id, contributors, extra_key),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_all(world: World, config: Config) -> list[Finding]:
|
|
52
|
+
# Import registers every check module exactly once.
|
|
53
|
+
from drskill.checks import budget, duplicates, filesystem, lockfile, shadowing, spec # noqa: F401
|
|
54
|
+
|
|
55
|
+
findings: list[Finding] = []
|
|
56
|
+
for fn in REGISTRY.values():
|
|
57
|
+
findings.extend(fn(world, config))
|
|
58
|
+
merged: dict[str, Finding] = {}
|
|
59
|
+
for f in findings:
|
|
60
|
+
if f.fingerprint in merged:
|
|
61
|
+
prior = merged[f.fingerprint]
|
|
62
|
+
merged[f.fingerprint] = prior.model_copy(
|
|
63
|
+
update={"harnesses": sorted(set(prior.harnesses) | set(f.harnesses))}
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
merged[f.fingerprint] = f
|
|
67
|
+
return sorted(
|
|
68
|
+
merged.values(), key=lambda f: (f.severity, f.check_id, f.message)
|
|
69
|
+
)
|
drskill/checks/budget.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from drskill.checks import check, make_finding
|
|
4
|
+
from drskill.ledger import Config
|
|
5
|
+
from drskill.models import Finding
|
|
6
|
+
from drskill.resolution import World
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@check("budget-catalog-tokens")
|
|
10
|
+
def budget_catalog_tokens(world: World, config: Config) -> list[Finding]:
|
|
11
|
+
out = []
|
|
12
|
+
for hid, hdef in world.harnesses.items():
|
|
13
|
+
contributors = world.effective(hid)
|
|
14
|
+
total = sum(c.token_cost.catalog_tokens for c in contributors)
|
|
15
|
+
if total > config.budget.catalog_tokens_max:
|
|
16
|
+
out.append(
|
|
17
|
+
make_finding(
|
|
18
|
+
"budget-catalog-tokens", "warning", contributors,
|
|
19
|
+
f"{hdef.display_name} startup catalog is ~{total} tokens "
|
|
20
|
+
f"(budget {config.budget.catalog_tokens_max})",
|
|
21
|
+
harnesses=[hid],
|
|
22
|
+
extra_key=hid,
|
|
23
|
+
fix_commands=[f"drskill list --tokens --harness {hid}"],
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@check("budget-body-tokens")
|
|
30
|
+
def budget_body_tokens(world: World, config: Config) -> list[Finding]:
|
|
31
|
+
return [
|
|
32
|
+
make_finding(
|
|
33
|
+
"budget-body-tokens", "warning", [c],
|
|
34
|
+
f"'{c.name}' body is ~{c.token_cost.body_tokens} tokens "
|
|
35
|
+
f"(warn ceiling {config.budget.body_tokens_warn})",
|
|
36
|
+
fix_commands=[f"Split or trim {c.id}; move reference material into bundled files"],
|
|
37
|
+
)
|
|
38
|
+
for c in world.contributors.values()
|
|
39
|
+
if c.token_cost.body_tokens > config.budget.body_tokens_warn
|
|
40
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Exact and near duplicate detection. MinHash is hand rolled and uses
|
|
2
|
+
zlib.crc32 because builtin str hash() is salted per process."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import shlex
|
|
7
|
+
import zlib
|
|
8
|
+
from itertools import combinations
|
|
9
|
+
|
|
10
|
+
from drskill.checks import check, make_finding
|
|
11
|
+
from drskill.ledger import Config
|
|
12
|
+
from drskill.models import Contributor, Finding
|
|
13
|
+
from drskill.resolution import World
|
|
14
|
+
|
|
15
|
+
SHINGLE_WORDS = 5
|
|
16
|
+
NUM_HASHES = 128
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def shingles(text: str, k: int = SHINGLE_WORDS) -> set[str]:
|
|
20
|
+
words = text.lower().split()
|
|
21
|
+
if len(words) <= k:
|
|
22
|
+
return {" ".join(words)} if words else set()
|
|
23
|
+
return {" ".join(words[i : i + k]) for i in range(len(words) - k + 1)}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def signature(sh: set[str]) -> list[int]:
|
|
27
|
+
if not sh:
|
|
28
|
+
return [0] * NUM_HASHES
|
|
29
|
+
return [
|
|
30
|
+
min(zlib.crc32(f"{seed}:{s}".encode()) for s in sh)
|
|
31
|
+
for seed in range(NUM_HASHES)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def estimate(a: list[int], b: list[int]) -> float:
|
|
36
|
+
return sum(x == y for x, y in zip(a, b)) / len(a)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _text(c: Contributor) -> str:
|
|
40
|
+
return f"{c.routing_text}\n{c.body}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _harnesses_loading_both(world: World, a: Contributor, b: Contributor) -> set[str]:
|
|
44
|
+
both = set()
|
|
45
|
+
for hid in world.harnesses:
|
|
46
|
+
eff = {c.id for c in world.effective(hid)}
|
|
47
|
+
if a.id in eff and b.id in eff:
|
|
48
|
+
both.add(hid)
|
|
49
|
+
return both
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@check("exact-duplicate")
|
|
53
|
+
def exact_duplicate(world: World, config: Config) -> list[Finding]:
|
|
54
|
+
by_hash: dict[str, list[Contributor]] = {}
|
|
55
|
+
for c in world.contributors.values():
|
|
56
|
+
by_hash.setdefault(c.content_hash, []).append(c)
|
|
57
|
+
out = []
|
|
58
|
+
for group in by_hash.values():
|
|
59
|
+
if len(group) < 2:
|
|
60
|
+
continue
|
|
61
|
+
# pairs co-loaded by one harness belong to double-load, not here
|
|
62
|
+
clean_pairs = [
|
|
63
|
+
(a, b)
|
|
64
|
+
for a, b in combinations(group, 2)
|
|
65
|
+
if not _harnesses_loading_both(world, a, b)
|
|
66
|
+
]
|
|
67
|
+
if clean_pairs:
|
|
68
|
+
out.append(
|
|
69
|
+
make_finding(
|
|
70
|
+
"exact-duplicate", "warning", group,
|
|
71
|
+
"identical skills installed in more than one place: "
|
|
72
|
+
+ ", ".join(sorted(c.id for c in group)),
|
|
73
|
+
fix_commands=[
|
|
74
|
+
f"npx skills remove {shlex.quote(group[0].name)}"
|
|
75
|
+
" # from the copies you don't want"
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@check("near-duplicate")
|
|
83
|
+
def near_duplicate(world: World, config: Config) -> list[Finding]:
|
|
84
|
+
cs = list(world.contributors.values())
|
|
85
|
+
sigs = {c.id: signature(shingles(_text(c))) for c in cs}
|
|
86
|
+
out = []
|
|
87
|
+
for a, b in combinations(cs, 2):
|
|
88
|
+
if a.content_hash == b.content_hash:
|
|
89
|
+
continue
|
|
90
|
+
sim = estimate(sigs[a.id], sigs[b.id])
|
|
91
|
+
if sim >= config.thresholds.near_duplicate:
|
|
92
|
+
out.append(
|
|
93
|
+
make_finding(
|
|
94
|
+
"near-duplicate", "warning", [a, b],
|
|
95
|
+
f"'{a.name}' and '{b.name}' are ~{sim:.0%} similar; "
|
|
96
|
+
"likely the same skill twice",
|
|
97
|
+
fix_commands=[
|
|
98
|
+
f"Compare and keep one: diff {shlex.quote(a.id)} {shlex.quote(b.id)}",
|
|
99
|
+
f"npx skills remove {shlex.quote(b.name)}",
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return out
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
|
|
5
|
+
from drskill.checks import check, make_finding
|
|
6
|
+
from drskill.ledger import Config
|
|
7
|
+
from drskill.models import Finding
|
|
8
|
+
from drskill.resolution import World
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@check("broken-symlink")
|
|
12
|
+
def broken_symlink(world: World, config: Config) -> list[Finding]:
|
|
13
|
+
return [
|
|
14
|
+
make_finding(
|
|
15
|
+
"broken-symlink", "error", [],
|
|
16
|
+
f"broken symlink: {b.path} points at nothing",
|
|
17
|
+
harnesses=[b.harness],
|
|
18
|
+
extra_key=str(b.path),
|
|
19
|
+
fix_commands=[f"rm {shlex.quote(str(b.path))}"],
|
|
20
|
+
)
|
|
21
|
+
for b in world.broken_symlinks
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@check("unreadable-skill")
|
|
26
|
+
def unreadable_skill(world: World, config: Config) -> list[Finding]:
|
|
27
|
+
return [
|
|
28
|
+
make_finding(
|
|
29
|
+
"unreadable-skill", "warning", [],
|
|
30
|
+
f"cannot read {path}: skipped",
|
|
31
|
+
harnesses=[harness],
|
|
32
|
+
extra_key=str(path),
|
|
33
|
+
fix_commands=[f"Check permissions: ls -l {shlex.quote(str(path))}"],
|
|
34
|
+
)
|
|
35
|
+
for harness, path in world.unreadable
|
|
36
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""skills-lock.json parsing (read-only, always) and drift attribution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import shlex
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from drskill.checks import check, make_finding
|
|
11
|
+
from drskill.ledger import Config
|
|
12
|
+
from drskill.models import Finding
|
|
13
|
+
from drskill.resolution import World
|
|
14
|
+
|
|
15
|
+
# Verified 2026-07-19 against `npx skills add vercel-labs/agent-skills` output and
|
|
16
|
+
# vercel-labs/skills @ src/local-lock.ts (LocalSkillLockEntry.computedHash /
|
|
17
|
+
# computeSkillFolderHash): the local project lockfile field is `computedHash`, a
|
|
18
|
+
# plain sha256 hex digest with no prefix. `hash`/`integrity`/`sha256` are kept as
|
|
19
|
+
# fallbacks for the (untested) global/user-scope lock schema, which the same source
|
|
20
|
+
# comment says uses a GitHub tree SHA instead.
|
|
21
|
+
_HASH_FIELDS = ("computedHash", "hash", "integrity", "sha256")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_lockfile(project_root: Path) -> dict[str, dict] | None:
|
|
25
|
+
p = project_root / "skills-lock.json"
|
|
26
|
+
if not p.is_file():
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
data = json.loads(p.read_text())
|
|
30
|
+
except (json.JSONDecodeError, OSError):
|
|
31
|
+
return None
|
|
32
|
+
if not isinstance(data, dict):
|
|
33
|
+
return None
|
|
34
|
+
skills = data.get("skills", data)
|
|
35
|
+
if not isinstance(skills, dict):
|
|
36
|
+
return None
|
|
37
|
+
return {k: v for k, v in skills.items() if isinstance(v, dict)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compute_tree_hash(skill_dir: Path) -> str:
|
|
41
|
+
# Mirrors vercel-labs/skills `computeSkillFolderHash` (src/local-lock.ts): sha256
|
|
42
|
+
# over relative-path bytes immediately followed by file-content bytes, sorted by
|
|
43
|
+
# relative path, with `.git` and `node_modules` subdirectories excluded. No
|
|
44
|
+
# separator byte between path and content — matched here for hash compatibility.
|
|
45
|
+
h = hashlib.sha256()
|
|
46
|
+
for f in sorted(
|
|
47
|
+
p
|
|
48
|
+
for p in skill_dir.rglob("*")
|
|
49
|
+
if p.is_file() and not {".git", "node_modules"} & set(p.relative_to(skill_dir).parts[:-1])
|
|
50
|
+
):
|
|
51
|
+
h.update(f.relative_to(skill_dir).as_posix().encode())
|
|
52
|
+
h.update(f.read_bytes())
|
|
53
|
+
return h.hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _entry_hash(entry: dict) -> str | None:
|
|
57
|
+
for field in _HASH_FIELDS:
|
|
58
|
+
v = entry.get(field)
|
|
59
|
+
if isinstance(v, str):
|
|
60
|
+
return v.removeprefix("sha256:").removeprefix("sha256-")
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@check("lockfile-drift")
|
|
65
|
+
def lockfile_drift(world: World, config: Config) -> list[Finding]:
|
|
66
|
+
if not world.lockfile:
|
|
67
|
+
return []
|
|
68
|
+
by_name = {c.name: c for c in world.contributors.values()}
|
|
69
|
+
out = []
|
|
70
|
+
matches: list[str] = []
|
|
71
|
+
mismatches: list[tuple[str, object]] = []
|
|
72
|
+
for name, entry in sorted(world.lockfile.items()):
|
|
73
|
+
expected = _entry_hash(entry)
|
|
74
|
+
c = by_name.get(name)
|
|
75
|
+
if c is None:
|
|
76
|
+
out.append(
|
|
77
|
+
make_finding(
|
|
78
|
+
"lockfile-drift", "warning", [],
|
|
79
|
+
f"'{name}' is in skills-lock.json but not found on disk",
|
|
80
|
+
harnesses=sorted(world.harnesses),
|
|
81
|
+
extra_key=f"missing:{name}",
|
|
82
|
+
fix_commands=[
|
|
83
|
+
f"npx skills add {shlex.quote(str(entry.get('source', name)))}",
|
|
84
|
+
"npx skills sync",
|
|
85
|
+
],
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
continue
|
|
89
|
+
if expected is None:
|
|
90
|
+
continue
|
|
91
|
+
skill_dir = Path(c.id).parent
|
|
92
|
+
if compute_tree_hash(skill_dir) == expected:
|
|
93
|
+
matches.append(name)
|
|
94
|
+
else:
|
|
95
|
+
mismatches.append((name, c))
|
|
96
|
+
|
|
97
|
+
# Self-calibration: if every hashed entry mismatches (and at least one
|
|
98
|
+
# matched, verifying the algorithm), attribute each mismatch by name. If
|
|
99
|
+
# NONE match, we can't tell drift from an algorithm mismatch against this
|
|
100
|
+
# lockfile's producer — collapse to a single "unverifiable" warning
|
|
101
|
+
# instead of crying wolf on every skill.
|
|
102
|
+
if mismatches and not matches:
|
|
103
|
+
out.append(
|
|
104
|
+
make_finding(
|
|
105
|
+
"lockfile-drift", "warning", [],
|
|
106
|
+
"skills-lock.json hashes use an algorithm drskill cannot "
|
|
107
|
+
"reproduce; hash-drift detection is disabled for this "
|
|
108
|
+
"lockfile (missing-skill detection still active)",
|
|
109
|
+
harnesses=sorted(world.harnesses),
|
|
110
|
+
extra_key="unverifiable",
|
|
111
|
+
fix_commands=[
|
|
112
|
+
"npx skills sync # reinstall to a known-good state if you suspect drift"
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
for name, c in mismatches:
|
|
118
|
+
out.append(
|
|
119
|
+
make_finding(
|
|
120
|
+
"lockfile-drift", "warning", [c],
|
|
121
|
+
f"'{name}' was modified outside `npx skills` — likely a "
|
|
122
|
+
"`gh skill update` or a hand edit; the lockfile no longer matches",
|
|
123
|
+
fix_commands=[
|
|
124
|
+
"npx skills sync # restore the locked version",
|
|
125
|
+
f"npx skills update {shlex.quote(name)} # or re-pin the new content",
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return out
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
|
|
5
|
+
from drskill.checks import check, make_finding
|
|
6
|
+
from drskill.ledger import Config
|
|
7
|
+
from drskill.models import Finding
|
|
8
|
+
from drskill.resolution import World
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@check("name-shadow")
|
|
12
|
+
def name_shadow(world: World, config: Config) -> list[Finding]:
|
|
13
|
+
out = []
|
|
14
|
+
for hid in world.harnesses:
|
|
15
|
+
for c, d in world.harness_loads(hid):
|
|
16
|
+
if d.shadowed_by is None:
|
|
17
|
+
continue
|
|
18
|
+
winner = world.contributors[d.shadowed_by]
|
|
19
|
+
wdep = next(x for x in winner.deployments if x.harness == hid)
|
|
20
|
+
out.append(
|
|
21
|
+
make_finding(
|
|
22
|
+
"name-shadow", "warning", [winner, c],
|
|
23
|
+
f"two skills named '{c.name}': the {wdep.scope} copy at "
|
|
24
|
+
f"{wdep.path} wins by search order and shadows {d.path}",
|
|
25
|
+
harnesses=[hid],
|
|
26
|
+
extra_key=winner.id,
|
|
27
|
+
fix_commands=[
|
|
28
|
+
f"Remove or rename the shadowed copy: {shlex.quote(str(d.path))}",
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@check("double-load")
|
|
36
|
+
def double_load(world: World, config: Config) -> list[Finding]:
|
|
37
|
+
out = []
|
|
38
|
+
for hid in world.harnesses:
|
|
39
|
+
# Key by content hash, then by contributor id, so that a single
|
|
40
|
+
# contributor reached twice through the same real path (e.g. one
|
|
41
|
+
# search directory symlinked into another) collapses to one entry
|
|
42
|
+
# instead of looking like two distinct copies.
|
|
43
|
+
by_hash: dict[str, dict[str, tuple]] = {}
|
|
44
|
+
for c, d in world.harness_loads(hid):
|
|
45
|
+
if d.shadowed_by is None:
|
|
46
|
+
by_hash.setdefault(c.content_hash, {}).setdefault(c.id, (c, d))
|
|
47
|
+
for loads in by_hash.values():
|
|
48
|
+
if len(loads) < 2:
|
|
49
|
+
continue
|
|
50
|
+
contributors = [c for c, _ in loads.values()]
|
|
51
|
+
paths = ", ".join(str(d.path) for _, d in loads.values())
|
|
52
|
+
quoted_paths = ", ".join(shlex.quote(str(d.path)) for _, d in loads.values())
|
|
53
|
+
display = world.harnesses[hid].display_name
|
|
54
|
+
out.append(
|
|
55
|
+
make_finding(
|
|
56
|
+
"double-load", "error", contributors,
|
|
57
|
+
f"{display} loads the same skill "
|
|
58
|
+
f"'{contributors[0].name}' {len(loads)} times: {paths}",
|
|
59
|
+
harnesses=[hid],
|
|
60
|
+
extra_key=hid,
|
|
61
|
+
fix_commands=[f"Remove all but one copy ({quoted_paths})"],
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
return out
|
drskill/checks/spec.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Spec-compliance checks. Only SKILL.md contributors are checked; bare
|
|
2
|
+
.md skills (pi root files) are not SKILL.md spec artifacts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import shlex
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from drskill.checks import check, make_finding
|
|
10
|
+
from drskill.ledger import Config
|
|
11
|
+
from drskill.models import Finding
|
|
12
|
+
from drskill.resolution import World
|
|
13
|
+
|
|
14
|
+
DESCRIPTION_MAX = 1024
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _skill_md_contributors(world: World):
|
|
18
|
+
return [c for c in world.contributors.values() if Path(c.id).name == "SKILL.md"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@check("spec-invalid-frontmatter")
|
|
22
|
+
def spec_invalid_frontmatter(world: World, config: Config) -> list[Finding]:
|
|
23
|
+
return [
|
|
24
|
+
make_finding(
|
|
25
|
+
"spec-invalid-frontmatter", "error", [c],
|
|
26
|
+
f"'{c.name}': frontmatter does not parse as YAML ({c.id})",
|
|
27
|
+
fix_commands=[f"Fix the YAML frontmatter in {shlex.quote(c.id)}"],
|
|
28
|
+
)
|
|
29
|
+
for c in _skill_md_contributors(world)
|
|
30
|
+
if not c.frontmatter_valid
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@check("spec-name-mismatch")
|
|
35
|
+
def spec_name_mismatch(world: World, config: Config) -> list[Finding]:
|
|
36
|
+
out = []
|
|
37
|
+
for c in _skill_md_contributors(world):
|
|
38
|
+
folder = Path(c.id).parent.name
|
|
39
|
+
if c.frontmatter_valid and c.name != folder:
|
|
40
|
+
out.append(
|
|
41
|
+
make_finding(
|
|
42
|
+
"spec-name-mismatch", "error", [c],
|
|
43
|
+
f"frontmatter name '{c.name}' does not match folder '{folder}'",
|
|
44
|
+
fix_commands=[
|
|
45
|
+
f"Rename the folder to {shlex.quote(c.name)} or set "
|
|
46
|
+
f"'name: {shlex.quote(folder)}' in {shlex.quote(c.id)}"
|
|
47
|
+
],
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@check("spec-missing-description")
|
|
54
|
+
def spec_missing_description(world: World, config: Config) -> list[Finding]:
|
|
55
|
+
return [
|
|
56
|
+
make_finding(
|
|
57
|
+
"spec-missing-description", "error", [c],
|
|
58
|
+
f"'{c.name}' has no description; the router cannot route to it",
|
|
59
|
+
fix_commands=[
|
|
60
|
+
f"Add a 'description:' with a clear 'use when' condition to {shlex.quote(c.id)}"
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
for c in _skill_md_contributors(world)
|
|
64
|
+
if c.frontmatter_valid and not c.routing_text.strip()
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@check("spec-description-too-long")
|
|
69
|
+
def spec_description_too_long(world: World, config: Config) -> list[Finding]:
|
|
70
|
+
return [
|
|
71
|
+
make_finding(
|
|
72
|
+
"spec-description-too-long", "error", [c],
|
|
73
|
+
f"'{c.name}' description is {len(c.routing_text)} chars (max {DESCRIPTION_MAX})",
|
|
74
|
+
fix_commands=[
|
|
75
|
+
f"Shorten the description in {shlex.quote(c.id)} to {DESCRIPTION_MAX} chars or fewer"
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
for c in _skill_md_contributors(world)
|
|
79
|
+
if len(c.routing_text) > DESCRIPTION_MAX
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _has_angle_bracket(value: object) -> bool:
|
|
84
|
+
"""Look for '<' or '>' in parsed frontmatter values, not the raw YAML
|
|
85
|
+
text. Raw text also contains YAML's own syntax characters (e.g. the
|
|
86
|
+
folded block scalar indicator in 'description: >-'), which are not
|
|
87
|
+
frontmatter values and would false-positive on ordinary multi-line
|
|
88
|
+
descriptions."""
|
|
89
|
+
if isinstance(value, str):
|
|
90
|
+
return "<" in value or ">" in value
|
|
91
|
+
if isinstance(value, dict):
|
|
92
|
+
return any(_has_angle_bracket(v) for v in value.values())
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
return any(_has_angle_bracket(v) for v in value)
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@check("frontmatter-angle-brackets")
|
|
99
|
+
def frontmatter_angle_brackets(world: World, config: Config) -> list[Finding]:
|
|
100
|
+
return [
|
|
101
|
+
make_finding(
|
|
102
|
+
"frontmatter-angle-brackets", "warning", [c],
|
|
103
|
+
f"'{c.name}' frontmatter contains angle brackets, a spec-flagged injection vector",
|
|
104
|
+
fix_commands=[f"Remove '<' and '>' from the frontmatter of {shlex.quote(c.id)}"],
|
|
105
|
+
)
|
|
106
|
+
for c in _skill_md_contributors(world)
|
|
107
|
+
if c.frontmatter_valid and _has_angle_bracket(c.frontmatter)
|
|
108
|
+
]
|