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 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
+ )
@@ -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
+ ]