skillguard-lint 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.
- skillguard/__init__.py +23 -0
- skillguard/cli.py +214 -0
- skillguard/formatter.py +117 -0
- skillguard/library.py +157 -0
- skillguard/rules.py +243 -0
- skillguard/scaffold.py +75 -0
- skillguard/scanner.py +521 -0
- skillguard_lint-0.1.0.dist-info/METADATA +242 -0
- skillguard_lint-0.1.0.dist-info/RECORD +13 -0
- skillguard_lint-0.1.0.dist-info/WHEEL +5 -0
- skillguard_lint-0.1.0.dist-info/entry_points.txt +5 -0
- skillguard_lint-0.1.0.dist-info/licenses/LICENSE +201 -0
- skillguard_lint-0.1.0.dist-info/top_level.txt +1 -0
skillguard/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""SkillGuard: The Security Scanner, Linter, and Quality Gate for AI Agent Skills.
|
|
2
|
+
|
|
3
|
+
Static analysis for SKILL.md files, catching safety risks, prompt injections,
|
|
4
|
+
token bloat, routing collisions, and structural anti-patterns before runtime.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__author__ = "Eslam Elzamkan"
|
|
9
|
+
__license__ = "Apache-2.0"
|
|
10
|
+
|
|
11
|
+
from .rules import Diagnostic, Rule, Severity
|
|
12
|
+
from .scanner import SkillScanner
|
|
13
|
+
from .library import LibraryAuditor
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Diagnostic",
|
|
17
|
+
"Rule",
|
|
18
|
+
"Severity",
|
|
19
|
+
"SkillScanner",
|
|
20
|
+
"LibraryAuditor",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
|
23
|
+
|
skillguard/cli.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Command-line interface for SkillGuard."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Set
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .formatter import format_github, format_json, format_terminal
|
|
10
|
+
from .library import LibraryAuditor
|
|
11
|
+
from .rules import Diagnostic, Severity, RULES
|
|
12
|
+
from .scaffold import create_skill_scaffold
|
|
13
|
+
from .scanner import SkillScanner
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_ignore_list(ignore_str: str) -> Set[str]:
|
|
17
|
+
if not ignore_str:
|
|
18
|
+
return set()
|
|
19
|
+
return {code.strip().upper() for code in ignore_str.split(",") if code.strip()}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_check(args) -> int:
|
|
23
|
+
target_path = Path(args.path)
|
|
24
|
+
if not target_path.exists():
|
|
25
|
+
print(f"Error: Path '{target_path}' does not exist.", file=sys.stderr)
|
|
26
|
+
return 1
|
|
27
|
+
|
|
28
|
+
ignored = parse_ignore_list(args.ignore)
|
|
29
|
+
scanner = SkillScanner(ignored_rules=ignored)
|
|
30
|
+
|
|
31
|
+
diagnostics: List[Diagnostic] = []
|
|
32
|
+
diagnostics.extend(scanner.scan_path(target_path))
|
|
33
|
+
|
|
34
|
+
# Also audit library-level checks if target is a directory and not disabled
|
|
35
|
+
if target_path.is_dir() and not getattr(args, "no_library", False):
|
|
36
|
+
auditor = LibraryAuditor(target_path, use_semantic=getattr(args, "semantic", False))
|
|
37
|
+
lib_diags = [d for d in auditor.audit_library() if d.rule.code not in ignored]
|
|
38
|
+
diagnostics.extend(lib_diags)
|
|
39
|
+
|
|
40
|
+
# Output formatting
|
|
41
|
+
if args.format == "json":
|
|
42
|
+
print(format_json(diagnostics))
|
|
43
|
+
elif args.format == "github":
|
|
44
|
+
print(format_github(diagnostics))
|
|
45
|
+
else:
|
|
46
|
+
print(format_terminal(diagnostics, verbose=args.verbose))
|
|
47
|
+
|
|
48
|
+
has_errors = any(d.severity == Severity.ERROR for d in diagnostics)
|
|
49
|
+
return 1 if has_errors else 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_audit(args) -> int:
|
|
53
|
+
"""Security-focused audit (SF1xx, SF2xx only)."""
|
|
54
|
+
target_path = Path(args.path)
|
|
55
|
+
if not target_path.exists():
|
|
56
|
+
print(f"Error: Path '{target_path}' does not exist.", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
ignored = parse_ignore_list(args.ignore)
|
|
60
|
+
scanner = SkillScanner(ignored_rules=ignored)
|
|
61
|
+
|
|
62
|
+
all_diags = scanner.scan_path(target_path)
|
|
63
|
+
# Filter to security & portability rules only
|
|
64
|
+
sec_diags = [d for d in all_diags if d.rule.code.startswith(("SF1", "SF2"))]
|
|
65
|
+
|
|
66
|
+
if args.format == "json":
|
|
67
|
+
print(format_json(sec_diags))
|
|
68
|
+
elif args.format == "github":
|
|
69
|
+
print(format_github(sec_diags))
|
|
70
|
+
else:
|
|
71
|
+
print(format_terminal(sec_diags, verbose=args.verbose))
|
|
72
|
+
|
|
73
|
+
has_errors = any(d.severity == Severity.ERROR for d in sec_diags)
|
|
74
|
+
return 1 if has_errors else 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_dedupe(args) -> int:
|
|
78
|
+
"""Multi-skill library collision and token bloat check."""
|
|
79
|
+
target_path = Path(args.path)
|
|
80
|
+
if not target_path.is_dir():
|
|
81
|
+
print(f"Error: Target '{target_path}' must be a directory containing skills.", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
ignored = parse_ignore_list(args.ignore)
|
|
85
|
+
auditor = LibraryAuditor(target_path, use_semantic=getattr(args, "semantic", False))
|
|
86
|
+
diagnostics = [d for d in auditor.audit_library() if d.rule.code not in ignored]
|
|
87
|
+
|
|
88
|
+
if args.format == "json":
|
|
89
|
+
print(format_json(diagnostics))
|
|
90
|
+
elif args.format == "github":
|
|
91
|
+
print(format_github(diagnostics))
|
|
92
|
+
else:
|
|
93
|
+
print(format_terminal(diagnostics, verbose=args.verbose))
|
|
94
|
+
|
|
95
|
+
has_errors = any(d.severity == Severity.ERROR for d in diagnostics)
|
|
96
|
+
return 1 if has_errors else 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_init(args) -> int:
|
|
100
|
+
try:
|
|
101
|
+
path = create_skill_scaffold(args.name, Path(args.dir), description=args.description or "")
|
|
102
|
+
print(f"✓ Created new production-ready skill scaffold at:\n {path.resolve()}")
|
|
103
|
+
return 0
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_rules(args) -> int:
|
|
110
|
+
"""List all available diagnostic rules."""
|
|
111
|
+
print(f"\nSkillGuard Diagnostic Rules (v{__version__}):\n")
|
|
112
|
+
print(f"{'Code':<8} {'Category':<12} {'Severity':<10} {'Name':<32} {'Summary'}")
|
|
113
|
+
print("-" * 90)
|
|
114
|
+
for code, rule in sorted(RULES.items()):
|
|
115
|
+
print(f"{rule.code:<8} {rule.category:<12} {rule.severity.value:<10} {rule.name:<32} {rule.summary}")
|
|
116
|
+
print("\nRun `skillguard check <path>` (or `skg check <path>`) to validate skills.\n")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_stats(args) -> int:
|
|
121
|
+
"""Calculate token and size statistics for skills."""
|
|
122
|
+
target_path = Path(args.path)
|
|
123
|
+
auditor = LibraryAuditor(target_path)
|
|
124
|
+
skills = auditor.find_all_skills()
|
|
125
|
+
|
|
126
|
+
if not skills:
|
|
127
|
+
print("No skills found.")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
print(f"\nSkill Library Statistics ({len(skills)} skills found):\n")
|
|
131
|
+
print(f"{'Skill Name':<30} {'Words':<10} {'Est. Tokens':<15} {'Status'}")
|
|
132
|
+
print("-" * 65)
|
|
133
|
+
|
|
134
|
+
total_words = 0
|
|
135
|
+
for sdir in skills:
|
|
136
|
+
skill_md = sdir / "SKILL.md"
|
|
137
|
+
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
|
138
|
+
words = len(text.split())
|
|
139
|
+
total_words += words
|
|
140
|
+
est_tokens = int(words * 1.33)
|
|
141
|
+
status = "Optimal" if 40 <= words <= 1600 else "Too Long" if words > 1600 else "Too Short"
|
|
142
|
+
print(f"{sdir.name:<30} {words:<10} ~{est_tokens:<14} {status}")
|
|
143
|
+
|
|
144
|
+
total_tokens = int(total_words * 1.33)
|
|
145
|
+
print("-" * 65)
|
|
146
|
+
print(f"{'Total Library Footprint':<30} {total_words:<10} ~{total_tokens:<14}\n")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
151
|
+
parser = argparse.ArgumentParser(
|
|
152
|
+
prog="skillguard",
|
|
153
|
+
description="SkillGuard: The Security Scanner, Linter, and Quality Gate for AI Agent Skills.",
|
|
154
|
+
)
|
|
155
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
156
|
+
|
|
157
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
158
|
+
|
|
159
|
+
# check
|
|
160
|
+
p_check = subparsers.add_parser("check", help="Lint and security-scan skills")
|
|
161
|
+
p_check.add_argument("path", nargs="?", default=".", help="Path to SKILL.md or skills directory (default: current dir)")
|
|
162
|
+
p_check.add_argument("--format", choices=["terminal", "json", "github"], default="terminal", help="Output format")
|
|
163
|
+
p_check.add_argument("--verbose", "-v", action="store_true", help="Show remediation guidance")
|
|
164
|
+
p_check.add_argument("--ignore", default="", help="Comma-separated rule codes to ignore (e.g. SF008,SF201)")
|
|
165
|
+
p_check.add_argument("--no-library", action="store_true", help="Skip multi-skill library checks")
|
|
166
|
+
p_check.add_argument("--semantic", action="store_true", help="Use dense vector embeddings for collision check (requires sentence-transformers)")
|
|
167
|
+
p_check.set_defaults(func=cmd_check)
|
|
168
|
+
|
|
169
|
+
# audit
|
|
170
|
+
p_audit = subparsers.add_parser("audit", help="Security and safety audit (destructive ops, data exfil, injection)")
|
|
171
|
+
p_audit.add_argument("path", nargs="?", default=".", help="Path to SKILL.md or skills directory")
|
|
172
|
+
p_audit.add_argument("--format", choices=["terminal", "json", "github"], default="terminal", help="Output format")
|
|
173
|
+
p_audit.add_argument("--verbose", "-v", action="store_true", help="Show remediation guidance")
|
|
174
|
+
p_audit.add_argument("--ignore", default="", help="Comma-separated rule codes to ignore")
|
|
175
|
+
p_audit.set_defaults(func=cmd_audit)
|
|
176
|
+
|
|
177
|
+
# dedupe
|
|
178
|
+
p_dedupe = subparsers.add_parser("dedupe", help="Analyze routing collisions, trigger overlap, and library caps")
|
|
179
|
+
p_dedupe.add_argument("path", nargs="?", default=".", help="Path to skills directory")
|
|
180
|
+
p_dedupe.add_argument("--format", choices=["terminal", "json", "github"], default="terminal", help="Output format")
|
|
181
|
+
p_dedupe.add_argument("--verbose", "-v", action="store_true", help="Show remediation guidance")
|
|
182
|
+
p_dedupe.add_argument("--ignore", default="", help="Comma-separated rule codes to ignore")
|
|
183
|
+
p_dedupe.add_argument("--semantic", action="store_true", help="Use dense vector embeddings for collision check (requires sentence-transformers)")
|
|
184
|
+
p_dedupe.set_defaults(func=cmd_dedupe)
|
|
185
|
+
|
|
186
|
+
# init
|
|
187
|
+
p_init = subparsers.add_parser("init", help="Scaffold a new production-ready skill pack")
|
|
188
|
+
p_init.add_argument("name", help="Skill name (e.g. git-conflict-resolver)")
|
|
189
|
+
p_init.add_argument("--dir", default=".", help="Target directory to create skill in (default: current dir)")
|
|
190
|
+
p_init.add_argument("--description", default="", help="Skill description")
|
|
191
|
+
p_init.set_defaults(func=cmd_init)
|
|
192
|
+
|
|
193
|
+
# rules
|
|
194
|
+
p_rules = subparsers.add_parser("rules", help="List all diagnostic rules and codes")
|
|
195
|
+
p_rules.set_defaults(func=cmd_rules)
|
|
196
|
+
|
|
197
|
+
# stats
|
|
198
|
+
p_stats = subparsers.add_parser("stats", help="Token and word count statistics for skills")
|
|
199
|
+
p_stats.add_argument("path", nargs="?", default=".", help="Path to skills directory")
|
|
200
|
+
p_stats.set_defaults(func=cmd_stats)
|
|
201
|
+
|
|
202
|
+
return parser
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main() -> None:
|
|
206
|
+
parser = build_parser()
|
|
207
|
+
args = parser.parse_args()
|
|
208
|
+
exit_code = args.func(args)
|
|
209
|
+
sys.exit(exit_code)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
main()
|
|
214
|
+
|
skillguard/formatter.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Formatters for diagnostic output: Terminal (ANSI), JSON, and GitHub Actions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .rules import Diagnostic, Severity
|
|
8
|
+
|
|
9
|
+
# ANSI color codes
|
|
10
|
+
RESET = "\033[0m"
|
|
11
|
+
BOLD = "\033[1m"
|
|
12
|
+
DIM = "\033[2m"
|
|
13
|
+
RED = "\033[31m"
|
|
14
|
+
YELLOW = "\033[33m"
|
|
15
|
+
CYAN = "\033[36m"
|
|
16
|
+
GREEN = "\033[32m"
|
|
17
|
+
MAGENTA = "\033[35m"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def supports_color() -> bool:
|
|
21
|
+
"""Check if stdout supports ANSI color escape sequences."""
|
|
22
|
+
return sys.stdout.isatty()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_terminal(diagnostics: List[Diagnostic], verbose: bool = False) -> str:
|
|
26
|
+
use_color = supports_color()
|
|
27
|
+
lines = []
|
|
28
|
+
|
|
29
|
+
# Sort diagnostics by file, then line, then severity
|
|
30
|
+
sorted_diags = sorted(
|
|
31
|
+
diagnostics,
|
|
32
|
+
key=lambda d: (
|
|
33
|
+
d.file_path,
|
|
34
|
+
d.line or 0,
|
|
35
|
+
0 if d.severity == Severity.ERROR else 1,
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
current_file = None
|
|
40
|
+
for d in sorted_diags:
|
|
41
|
+
if d.file_path != current_file:
|
|
42
|
+
current_file = d.file_path
|
|
43
|
+
header = f"\n{BOLD}{current_file}{RESET}" if use_color else f"\n{current_file}"
|
|
44
|
+
lines.append(header)
|
|
45
|
+
|
|
46
|
+
# Severity badge
|
|
47
|
+
if d.severity == Severity.ERROR:
|
|
48
|
+
badge = f"{RED}{BOLD}error{RESET}" if use_color else "error"
|
|
49
|
+
elif d.severity == Severity.WARNING:
|
|
50
|
+
badge = f"{YELLOW}{BOLD}warning{RESET}" if use_color else "warning"
|
|
51
|
+
else:
|
|
52
|
+
badge = f"{CYAN}{BOLD}info{RESET}" if use_color else "info"
|
|
53
|
+
|
|
54
|
+
line_str = f":{d.line}" if d.line else ""
|
|
55
|
+
col_str = f":{d.column}" if d.column else ""
|
|
56
|
+
loc = f"{DIM}{line_str}{col_str}{RESET}" if use_color else f"{line_str}{col_str}"
|
|
57
|
+
code = f"{MAGENTA}[{d.rule.code}]{RESET}" if use_color else f"[{d.rule.code}]"
|
|
58
|
+
|
|
59
|
+
msg = d.formatted_message
|
|
60
|
+
lines.append(f" {loc:8} {badge:16} {code:8} {msg}")
|
|
61
|
+
|
|
62
|
+
if d.snippet:
|
|
63
|
+
snip = f" {DIM}> {d.snippet}{RESET}" if use_color else f" > {d.snippet}"
|
|
64
|
+
lines.append(snip)
|
|
65
|
+
|
|
66
|
+
if verbose and d.rule.remediation:
|
|
67
|
+
hint = f" {CYAN}Fix: {d.rule.remediation}{RESET}" if use_color else f" Fix: {d.rule.remediation}"
|
|
68
|
+
lines.append(hint)
|
|
69
|
+
|
|
70
|
+
# Summary
|
|
71
|
+
errors = sum(1 for d in diagnostics if d.severity == Severity.ERROR)
|
|
72
|
+
warnings = sum(1 for d in diagnostics if d.severity == Severity.WARNING)
|
|
73
|
+
|
|
74
|
+
summary_parts = []
|
|
75
|
+
if errors:
|
|
76
|
+
summary_parts.append(f"{RED}{BOLD}{errors} error{'s' if errors != 1 else ''}{RESET}" if use_color else f"{errors} errors")
|
|
77
|
+
if warnings:
|
|
78
|
+
summary_parts.append(f"{YELLOW}{BOLD}{warnings} warning{'s' if warnings != 1 else ''}{RESET}" if use_color else f"{warnings} warnings")
|
|
79
|
+
|
|
80
|
+
if not diagnostics:
|
|
81
|
+
return f"\n{GREEN}{BOLD}✓ All skill checks passed! Zero safety or quality defects detected.{RESET}\n" if use_color else "\n✓ All skill checks passed! Zero safety or quality defects detected.\n"
|
|
82
|
+
|
|
83
|
+
status = ", ".join(summary_parts)
|
|
84
|
+
return "\n".join(lines) + f"\n\nFound {status}.\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_json(diagnostics: List[Diagnostic]) -> str:
|
|
88
|
+
data = []
|
|
89
|
+
for d in diagnostics:
|
|
90
|
+
data.append({
|
|
91
|
+
"file": d.file_path,
|
|
92
|
+
"line": d.line,
|
|
93
|
+
"column": d.column,
|
|
94
|
+
"rule_code": d.rule.code,
|
|
95
|
+
"rule_name": d.rule.name,
|
|
96
|
+
"category": d.rule.category,
|
|
97
|
+
"severity": d.severity.value,
|
|
98
|
+
"message": d.formatted_message,
|
|
99
|
+
"snippet": d.snippet,
|
|
100
|
+
"remediation": d.rule.remediation,
|
|
101
|
+
})
|
|
102
|
+
return json.dumps(data, indent=2)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def format_github(diagnostics: List[Diagnostic]) -> str:
|
|
106
|
+
"""GitHub Actions workflow commands for inline PR annotations."""
|
|
107
|
+
lines = []
|
|
108
|
+
for d in diagnostics:
|
|
109
|
+
cmd = "error" if d.severity == Severity.ERROR else "warning"
|
|
110
|
+
loc = f"file={d.file_path}"
|
|
111
|
+
if d.line:
|
|
112
|
+
loc += f",line={d.line}"
|
|
113
|
+
if d.column:
|
|
114
|
+
loc += f",col={d.column}"
|
|
115
|
+
lines.append(f"::{cmd} {loc}::[{d.rule.code}] {d.formatted_message}")
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
skillguard/library.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Library auditor: cross-skill analysis, collision detection, and token budgeting."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Set, Tuple
|
|
7
|
+
|
|
8
|
+
from .rules import Diagnostic, Rule, RULES
|
|
9
|
+
|
|
10
|
+
STOPWORDS = {
|
|
11
|
+
"use", "when", "the", "a", "an", "for", "to", "of", "and", "or",
|
|
12
|
+
"not", "this", "is", "with", "on", "in", "it", "at", "by", "from",
|
|
13
|
+
"that", "as", "be", "do", "you", "agent", "skill"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
LIBRARY_CAP = 8
|
|
17
|
+
COLLISION_JACCARD_THRESHOLD = 0.55
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LibraryAuditor:
|
|
21
|
+
"""Performs library-wide analysis across a collection of skills.
|
|
22
|
+
|
|
23
|
+
Engine:
|
|
24
|
+
- Default: Fast, zero-dependency Lexical & Trigger Overlap Analysis
|
|
25
|
+
(Jaccard similarity on keyword tokens and n-grams).
|
|
26
|
+
- Optional: Dense vector semantic analysis if `use_semantic=True`
|
|
27
|
+
and `sentence-transformers` is installed.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, root_dir: Path, use_semantic: bool = False):
|
|
31
|
+
self.root_dir = Path(root_dir)
|
|
32
|
+
self.use_semantic = use_semantic
|
|
33
|
+
|
|
34
|
+
def find_all_skills(self) -> List[Path]:
|
|
35
|
+
"""Find all skill directories containing a SKILL.md under root_dir."""
|
|
36
|
+
skill_dirs = []
|
|
37
|
+
if (self.root_dir / "SKILL.md").exists():
|
|
38
|
+
return [self.root_dir]
|
|
39
|
+
for p in sorted(self.root_dir.rglob("SKILL.md")):
|
|
40
|
+
# Ignore hidden or internal folders like .skillguard, .skill-forge, or _versions
|
|
41
|
+
if not any(part.startswith(".") or part.startswith("_") for part in p.parts):
|
|
42
|
+
skill_dirs.append(p.parent)
|
|
43
|
+
return skill_dirs
|
|
44
|
+
|
|
45
|
+
def tokenize_description(self, description: str) -> Set[str]:
|
|
46
|
+
words = re.findall(r"[a-z]{3,}", description.lower())
|
|
47
|
+
return {w for w in words if w not in STOPWORDS}
|
|
48
|
+
|
|
49
|
+
def audit_library(self) -> List[Diagnostic]:
|
|
50
|
+
diagnostics: List[Diagnostic] = []
|
|
51
|
+
skills = self.find_all_skills()
|
|
52
|
+
|
|
53
|
+
# SF302: Library size check (> 8 skills)
|
|
54
|
+
if len(skills) > LIBRARY_CAP:
|
|
55
|
+
diagnostics.append(
|
|
56
|
+
Diagnostic(
|
|
57
|
+
rule=RULES["SF302"],
|
|
58
|
+
file_path=str(self.root_dir),
|
|
59
|
+
message=f"Library contains {len(skills)} skills (recommended cap: {LIBRARY_CAP}). Over-accumulation dilutes agent lift.",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Collect descriptions and metadata
|
|
64
|
+
descriptions: Dict[str, str] = {}
|
|
65
|
+
names: List[str] = []
|
|
66
|
+
domains: Dict[str, List[str]] = defaultdict(list)
|
|
67
|
+
|
|
68
|
+
for sdir in skills:
|
|
69
|
+
skill_md = sdir / "SKILL.md"
|
|
70
|
+
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
|
71
|
+
desc_match = re.search(r"description:\s*(.+)", text)
|
|
72
|
+
name_match = re.search(r"name:\s*(.+)", text)
|
|
73
|
+
|
|
74
|
+
skill_name = name_match.group(1).strip() if name_match else sdir.name
|
|
75
|
+
desc_text = desc_match.group(1).strip() if desc_match else ""
|
|
76
|
+
|
|
77
|
+
descriptions[skill_name] = desc_text
|
|
78
|
+
names.append(skill_name)
|
|
79
|
+
|
|
80
|
+
# Guess domain from folder or name prefix
|
|
81
|
+
domain = sdir.parent.name if sdir.parent != self.root_dir else skill_name.split("-")[0]
|
|
82
|
+
domains[domain].append(skill_name)
|
|
83
|
+
|
|
84
|
+
# SF301: Description collisions / routing interference
|
|
85
|
+
if self.use_semantic:
|
|
86
|
+
try:
|
|
87
|
+
from sentence_transformers import SentenceTransformer, util
|
|
88
|
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
89
|
+
desc_texts = [descriptions[n] for n in names]
|
|
90
|
+
embeddings = model.encode(desc_texts, convert_to_tensor=True)
|
|
91
|
+
cosine_scores = util.cos_sim(embeddings, embeddings)
|
|
92
|
+
|
|
93
|
+
for i, a in enumerate(names):
|
|
94
|
+
for j in range(i + 1, len(names)):
|
|
95
|
+
b = names[j]
|
|
96
|
+
score = float(cosine_scores[i][j])
|
|
97
|
+
if score >= 0.75:
|
|
98
|
+
diagnostics.append(
|
|
99
|
+
Diagnostic(
|
|
100
|
+
rule=RULES["SF301"],
|
|
101
|
+
file_path=str(self.root_dir),
|
|
102
|
+
message=(
|
|
103
|
+
f"High semantic vector similarity ({round(score * 100)}%) between `{a}` and `{b}`. "
|
|
104
|
+
f"Overlapping intent causes routing confusion."
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
except ImportError:
|
|
109
|
+
diagnostics.append(
|
|
110
|
+
Diagnostic(
|
|
111
|
+
rule=RULES["SF301"],
|
|
112
|
+
file_path=str(self.root_dir),
|
|
113
|
+
message="Notice: 'sentence-transformers' not installed for dense semantic analysis. Falling back to pure-Python lexical trigger overlap. Install with: pip install skillguard[semantic]",
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
self.use_semantic = False
|
|
117
|
+
|
|
118
|
+
if not self.use_semantic:
|
|
119
|
+
# Default: Pure-Python Lexical & Trigger Overlap Analysis (Jaccard similarity)
|
|
120
|
+
for i, a in enumerate(names):
|
|
121
|
+
tokens_a = self.tokenize_description(descriptions[a])
|
|
122
|
+
if not tokens_a:
|
|
123
|
+
continue
|
|
124
|
+
for b in names[i + 1:]:
|
|
125
|
+
tokens_b = self.tokenize_description(descriptions[b])
|
|
126
|
+
if not tokens_b:
|
|
127
|
+
continue
|
|
128
|
+
intersection = tokens_a & tokens_b
|
|
129
|
+
union = tokens_a | tokens_b
|
|
130
|
+
jaccard = len(intersection) / len(union) if union else 0.0
|
|
131
|
+
|
|
132
|
+
if jaccard >= COLLISION_JACCARD_THRESHOLD:
|
|
133
|
+
shared_keywords = ", ".join(sorted(intersection)[:5])
|
|
134
|
+
diagnostics.append(
|
|
135
|
+
Diagnostic(
|
|
136
|
+
rule=RULES["SF301"],
|
|
137
|
+
file_path=str(self.root_dir),
|
|
138
|
+
message=(
|
|
139
|
+
f"High lexical trigger overlap ({round(jaccard * 100)}%) between `{a}` and `{b}`. "
|
|
140
|
+
f"Shared trigger tokens: [{shared_keywords}]. Causes routing confusion."
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# SF303: Domain duplicate skills
|
|
146
|
+
for domain, d_skills in domains.items():
|
|
147
|
+
if len(d_skills) > 1 and domain not in ("skills", "tasks", "src"):
|
|
148
|
+
diagnostics.append(
|
|
149
|
+
Diagnostic(
|
|
150
|
+
rule=RULES["SF303"],
|
|
151
|
+
file_path=str(self.root_dir),
|
|
152
|
+
message=f"Multiple skills found in domain '{domain}': {', '.join(d_skills)}. Consider consolidating into one skill.",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return diagnostics
|
|
157
|
+
|