skill-linter 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.
- skill_linter/__init__.py +1 -0
- skill_linter/__main__.py +5 -0
- skill_linter/cli.py +216 -0
- skill_linter/commands.py +117 -0
- skill_linter/config.py +356 -0
- skill_linter/engine.py +105 -0
- skill_linter/frontmatter.py +217 -0
- skill_linter/mdutil.py +71 -0
- skill_linter/model.py +96 -0
- skill_linter/names.py +84 -0
- skill_linter/release.py +550 -0
- skill_linter/repo.py +82 -0
- skill_linter/report.py +72 -0
- skill_linter/rules/__init__.py +11 -0
- skill_linter/rules/conventions.py +263 -0
- skill_linter/rules/custom.py +152 -0
- skill_linter/rules/description.py +89 -0
- skill_linter/rules/hidden.py +534 -0
- skill_linter/rules/markdown.py +146 -0
- skill_linter/rules/policy.py +188 -0
- skill_linter/rules/spec.py +220 -0
- skill_linter/rules/workflows.py +301 -0
- skill_linter/stamp.py +291 -0
- skill_linter-0.1.0.dist-info/METADATA +120 -0
- skill_linter-0.1.0.dist-info/RECORD +28 -0
- skill_linter-0.1.0.dist-info/WHEEL +4 -0
- skill_linter-0.1.0.dist-info/entry_points.txt +2 -0
- skill_linter-0.1.0.dist-info/licenses/LICENSE +21 -0
skill_linter/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""skill-linter: validate Agent Skills repos."""
|
skill_linter/__main__.py
ADDED
skill_linter/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Command-line interface: skill-linter check | release | stamp | rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from skill_linter import model, release, stamp
|
|
14
|
+
from skill_linter.config import Config, ConfigError, find_root, load_config
|
|
15
|
+
from skill_linter.engine import all_rules, effective_severity, run, validate_config
|
|
16
|
+
from skill_linter.repo import Repo
|
|
17
|
+
from skill_linter.report import format_json, format_text
|
|
18
|
+
|
|
19
|
+
EXIT_OK, EXIT_ERRORS, EXIT_USAGE = 0, 1, 2
|
|
20
|
+
CHECK_EXCLUDES = frozenset({"release"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="skill-linter", description="Validate an Agent Skills repo."
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--version", action="version", version=f"skill-linter {version('skill-linter')}"
|
|
29
|
+
)
|
|
30
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
check = sub.add_parser(
|
|
33
|
+
"check", help="run every enabled rule except the release checks"
|
|
34
|
+
)
|
|
35
|
+
check.add_argument("--only", help="comma-separated rule groups to run")
|
|
36
|
+
check.add_argument(
|
|
37
|
+
"--skill", help="limit skill rules to one skill and skip repo rules"
|
|
38
|
+
)
|
|
39
|
+
check.add_argument("--format", choices=("text", "json"), default="text")
|
|
40
|
+
check.add_argument(
|
|
41
|
+
"--root",
|
|
42
|
+
type=Path,
|
|
43
|
+
help="repo root (default: nearest pyproject.toml with the table)",
|
|
44
|
+
)
|
|
45
|
+
check.add_argument(
|
|
46
|
+
"-v",
|
|
47
|
+
"--verbose",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="also show info issues and skipped rules",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
release_cmd = sub.add_parser(
|
|
53
|
+
"release", help="run the plugin release checks on the shipped tree, then check"
|
|
54
|
+
)
|
|
55
|
+
release_cmd.add_argument("--format", choices=("text", "json"), default="text")
|
|
56
|
+
release_cmd.add_argument("--root", type=Path)
|
|
57
|
+
release_cmd.add_argument("-v", "--verbose", action="store_true")
|
|
58
|
+
|
|
59
|
+
stamp = sub.add_parser(
|
|
60
|
+
"stamp",
|
|
61
|
+
help="write counts and the version into version.json and the stamp files",
|
|
62
|
+
)
|
|
63
|
+
stamp.add_argument(
|
|
64
|
+
"--check",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="write nothing; exit 1 if anything is stale",
|
|
67
|
+
)
|
|
68
|
+
stamp.add_argument(
|
|
69
|
+
"--dry-run", action="store_true", help="print the diff; write nothing"
|
|
70
|
+
)
|
|
71
|
+
stamp.add_argument("--root", type=Path)
|
|
72
|
+
|
|
73
|
+
rules = sub.add_parser("rules", help="list every rule")
|
|
74
|
+
rules.add_argument("--format", choices=("text", "json"), default="text")
|
|
75
|
+
rules.add_argument("--root", type=Path)
|
|
76
|
+
return parser
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load(root: Path | None) -> Config:
|
|
80
|
+
cfg = load_config(root.resolve()) if root else load_config(find_root(Path.cwd()))
|
|
81
|
+
validate_config(cfg)
|
|
82
|
+
return cfg
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _groups() -> set[str]:
|
|
86
|
+
return {r.group for r in model.REGISTRY.values()} | {"custom"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_check(args) -> int:
|
|
90
|
+
groups = None
|
|
91
|
+
if args.only:
|
|
92
|
+
groups = {g.strip() for g in args.only.split(",") if g.strip()}
|
|
93
|
+
unknown = groups - _groups()
|
|
94
|
+
if unknown:
|
|
95
|
+
print(
|
|
96
|
+
f"unknown rule group(s): {', '.join(sorted(unknown))}", file=sys.stderr
|
|
97
|
+
)
|
|
98
|
+
return EXIT_USAGE
|
|
99
|
+
cfg = _load(args.root)
|
|
100
|
+
report = run(
|
|
101
|
+
Repo(cfg),
|
|
102
|
+
groups=groups,
|
|
103
|
+
skill=args.skill,
|
|
104
|
+
exclude_groups=CHECK_EXCLUDES - (groups or set()),
|
|
105
|
+
)
|
|
106
|
+
print(
|
|
107
|
+
format_json(report, cfg.root)
|
|
108
|
+
if args.format == "json"
|
|
109
|
+
else format_text(report, args.verbose)
|
|
110
|
+
)
|
|
111
|
+
return EXIT_ERRORS if report.has_errors else EXIT_OK
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_release(args) -> int:
|
|
115
|
+
cfg = _load(args.root)
|
|
116
|
+
repo = Repo(cfg)
|
|
117
|
+
release.shipped_tree(
|
|
118
|
+
repo
|
|
119
|
+
) # config error (exit 2) when neither plugin_root nor ship is set
|
|
120
|
+
report = run(repo)
|
|
121
|
+
print(
|
|
122
|
+
format_json(report, cfg.root)
|
|
123
|
+
if args.format == "json"
|
|
124
|
+
else format_text(report, args.verbose)
|
|
125
|
+
)
|
|
126
|
+
return EXIT_ERRORS if report.has_errors else EXIT_OK
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_stamp(args) -> int:
|
|
130
|
+
repo = Repo(_load(args.root))
|
|
131
|
+
changes, errors = stamp.plan(repo)
|
|
132
|
+
for message in errors:
|
|
133
|
+
print(f"error: {message}", file=sys.stderr)
|
|
134
|
+
if errors:
|
|
135
|
+
return EXIT_ERRORS
|
|
136
|
+
if args.dry_run:
|
|
137
|
+
for rel, (old, new) in changes.items():
|
|
138
|
+
sys.stdout.writelines(
|
|
139
|
+
difflib.unified_diff(
|
|
140
|
+
old.splitlines(True), new.splitlines(True), f"a/{rel}", f"b/{rel}"
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return EXIT_OK
|
|
144
|
+
if args.check:
|
|
145
|
+
for rel in changes:
|
|
146
|
+
print(f"stale: {rel}")
|
|
147
|
+
return EXIT_ERRORS if changes else EXIT_OK
|
|
148
|
+
stamp.write(repo, changes)
|
|
149
|
+
for rel in changes:
|
|
150
|
+
print(f"stamped: {rel}")
|
|
151
|
+
return EXIT_OK
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
LAYERS = {1: "spec", 2: "convention", 3: "policy"}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _rule_order(r) -> tuple:
|
|
158
|
+
"""Registration order of groups, then IDs in numeric order (H01 before H16)."""
|
|
159
|
+
groups = list(dict.fromkeys(x.group for x in model.REGISTRY.values()))
|
|
160
|
+
prefix, number = re.match(r"^(.*?)(\d*)$", r.id).groups()
|
|
161
|
+
rank = groups.index(r.group) if r.group in groups else len(groups)
|
|
162
|
+
return (rank, prefix, int(number) if number else 0, r.id)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_rules(args) -> int:
|
|
166
|
+
try:
|
|
167
|
+
cfg = _load(args.root)
|
|
168
|
+
except ConfigError:
|
|
169
|
+
if args.root:
|
|
170
|
+
raise
|
|
171
|
+
cfg = None
|
|
172
|
+
rows = []
|
|
173
|
+
rules = all_rules(cfg) if cfg is not None else list(model.REGISTRY.values())
|
|
174
|
+
for r in sorted(rules, key=_rule_order):
|
|
175
|
+
row = {
|
|
176
|
+
"id": r.id,
|
|
177
|
+
"name": r.name,
|
|
178
|
+
"group": r.group,
|
|
179
|
+
"layer": r.layer,
|
|
180
|
+
"default": r.severity.value,
|
|
181
|
+
"overridable": r.overridable,
|
|
182
|
+
"summary": r.summary,
|
|
183
|
+
}
|
|
184
|
+
if cfg is not None:
|
|
185
|
+
row["effective"] = effective_severity(cfg, r).value
|
|
186
|
+
rows.append(row)
|
|
187
|
+
if args.format == "json":
|
|
188
|
+
print(json.dumps(rows, indent=2))
|
|
189
|
+
else:
|
|
190
|
+
name_width = max(len(row["name"]) for row in rows)
|
|
191
|
+
for row in rows:
|
|
192
|
+
sev = row.get("effective", row["default"])
|
|
193
|
+
print(
|
|
194
|
+
f"{row['id']:<8} {row['name']:<{name_width}} {row['group']:<12} "
|
|
195
|
+
f"{LAYERS[row['layer']]:<10} {sev:<7} {row['summary']}"
|
|
196
|
+
)
|
|
197
|
+
return EXIT_OK
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main(argv: list[str] | None = None) -> int:
|
|
201
|
+
args = _parser().parse_args(argv)
|
|
202
|
+
try:
|
|
203
|
+
commands = {
|
|
204
|
+
"check": cmd_check,
|
|
205
|
+
"release": cmd_release,
|
|
206
|
+
"stamp": cmd_stamp,
|
|
207
|
+
"rules": cmd_rules,
|
|
208
|
+
}
|
|
209
|
+
return commands[args.command](args)
|
|
210
|
+
except ConfigError as exc:
|
|
211
|
+
print(f"config error: {exc}", file=sys.stderr)
|
|
212
|
+
return EXIT_USAGE
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
sys.exit(main())
|
skill_linter/commands.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Command files, their YAML definitions, and plugin locations, shared by the hidden and workflow rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from skill_linter.repo import Repo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Definition:
|
|
16
|
+
file: Path
|
|
17
|
+
rel: str
|
|
18
|
+
data: dict[str, Any] | None
|
|
19
|
+
error: str | None
|
|
20
|
+
|
|
21
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
22
|
+
return self.data.get(key, default) if isinstance(self.data, dict) else default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_yaml(text: str) -> tuple[Any, str | None]:
|
|
26
|
+
try:
|
|
27
|
+
return yaml.safe_load(text), None
|
|
28
|
+
except yaml.YAMLError as exc:
|
|
29
|
+
return None, str(exc).replace("\n", " ")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def commands_dir(repo: Repo) -> Path | None:
|
|
33
|
+
return repo.cfg.path(repo.cfg.commands_dir) if repo.cfg.commands_dir else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def manifest_path(repo: Repo) -> Path | None:
|
|
37
|
+
if repo.cfg.manifest:
|
|
38
|
+
return repo.cfg.path(repo.cfg.manifest)
|
|
39
|
+
base = commands_dir(repo)
|
|
40
|
+
return base / "workflow-manifest.yaml" if base else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def definitions(repo: Repo) -> list[Definition]:
|
|
44
|
+
cached = getattr(repo, "_definitions", None)
|
|
45
|
+
if cached is not None:
|
|
46
|
+
return cached
|
|
47
|
+
base = commands_dir(repo)
|
|
48
|
+
found: list[Definition] = []
|
|
49
|
+
manifest = manifest_path(repo)
|
|
50
|
+
if base and base.is_dir():
|
|
51
|
+
for path in sorted(base.rglob("*.yaml")):
|
|
52
|
+
if manifest and path.resolve() == manifest.resolve():
|
|
53
|
+
continue
|
|
54
|
+
data, error = load_yaml(repo.read(path))
|
|
55
|
+
if error is None and not isinstance(data, dict):
|
|
56
|
+
error = "definition must be a YAML mapping"
|
|
57
|
+
data = None
|
|
58
|
+
found.append(Definition(path, repo.rel(path), data, error))
|
|
59
|
+
repo._definitions = found
|
|
60
|
+
return found
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def command_files(repo: Repo) -> list[Path]:
|
|
64
|
+
"""Every .md under the commands folder, except files inside a references/ folder."""
|
|
65
|
+
base = commands_dir(repo)
|
|
66
|
+
if not base or not base.is_dir():
|
|
67
|
+
return []
|
|
68
|
+
return sorted(
|
|
69
|
+
p for p in base.rglob("*.md") if "references" not in p.relative_to(base).parts
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def frontmatter_block(text: str) -> str | None:
|
|
74
|
+
lines = text.splitlines()
|
|
75
|
+
if not lines or lines[0].strip() != "---":
|
|
76
|
+
return None
|
|
77
|
+
for index in range(1, len(lines)):
|
|
78
|
+
if lines[index].strip() == "---":
|
|
79
|
+
return "\n".join(lines[1:index])
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def command_frontmatter(text: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
84
|
+
"""(data, error). data is None with no error when the file has no frontmatter."""
|
|
85
|
+
block = frontmatter_block(text)
|
|
86
|
+
if block is None:
|
|
87
|
+
if text.splitlines()[:1] == ["---"]:
|
|
88
|
+
return None, "frontmatter opened with --- but never closed"
|
|
89
|
+
return None, None
|
|
90
|
+
data, error = load_yaml(block)
|
|
91
|
+
if error:
|
|
92
|
+
return None, error
|
|
93
|
+
if data is None:
|
|
94
|
+
return {}, None
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
return None, "frontmatter must be a YAML mapping"
|
|
97
|
+
return data, None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def plugin_dir(repo: Repo) -> Path:
|
|
101
|
+
return repo.cfg.path(repo.cfg.plugin_root) if repo.cfg.plugin_root else repo.root
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def plugin_json_path(repo: Repo) -> Path:
|
|
105
|
+
return plugin_dir(repo) / ".claude-plugin" / "plugin.json"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def marketplace_path(repo: Repo) -> Path:
|
|
109
|
+
return repo.root / ".claude-plugin" / "marketplace.json"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def skill_and_command_files(repo: Repo, pattern: str = "*.md") -> list[Path]:
|
|
113
|
+
paths: list[Path] = []
|
|
114
|
+
for base in (repo.skills_dir, commands_dir(repo)):
|
|
115
|
+
if base and base.is_dir():
|
|
116
|
+
paths.extend(p for p in sorted(base.rglob(pattern)) if p.is_file())
|
|
117
|
+
return paths
|
skill_linter/config.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Read and validate the [tool.skill-linter] table in a repo's pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
TABLE = "skill-linter"
|
|
12
|
+
SEVERITY_VALUES = {"error", "warning", "off"}
|
|
13
|
+
VALUE_LISTS = {
|
|
14
|
+
"role",
|
|
15
|
+
"scope",
|
|
16
|
+
"output-format",
|
|
17
|
+
"phases",
|
|
18
|
+
"requires",
|
|
19
|
+
"input-types",
|
|
20
|
+
"output-types",
|
|
21
|
+
}
|
|
22
|
+
POLICY_KEYS = {
|
|
23
|
+
"required_sections",
|
|
24
|
+
"section_order",
|
|
25
|
+
"workflow_steps",
|
|
26
|
+
"body_lines",
|
|
27
|
+
"license",
|
|
28
|
+
"reciprocal_related_skills",
|
|
29
|
+
"resolve_relative_paths",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConfigError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Config:
|
|
39
|
+
root: Path
|
|
40
|
+
skills_dir: str = "skills"
|
|
41
|
+
shared_dirs: list[str] = field(default_factory=list)
|
|
42
|
+
commands_dir: str | None = None
|
|
43
|
+
manifest: str | None = None
|
|
44
|
+
workflow_glob: str = "**/*.md"
|
|
45
|
+
version_file: str = "version.json"
|
|
46
|
+
markdown_paths: list[str] = field(default_factory=list)
|
|
47
|
+
docs_url: str | None = None
|
|
48
|
+
skill_path: str = "domain"
|
|
49
|
+
domains: list[dict[str, str]] | None = None
|
|
50
|
+
domain_lists: list[str] = field(default_factory=list)
|
|
51
|
+
index_files: list[str] = field(default_factory=list)
|
|
52
|
+
sidebar_file: str | None = None
|
|
53
|
+
plugin_root: str | None = None
|
|
54
|
+
ship: list[str] | None = None
|
|
55
|
+
credit: dict[str, Any] | None = None
|
|
56
|
+
values: dict[str, list[str]] = field(default_factory=dict)
|
|
57
|
+
policy: dict[str, Any] = field(default_factory=dict)
|
|
58
|
+
severity: dict[str, str] = field(default_factory=dict)
|
|
59
|
+
custom_rules: list[dict[str, Any]] = field(default_factory=list)
|
|
60
|
+
stamp_files: list[dict[str, Any]] = field(default_factory=list)
|
|
61
|
+
credentials_allow: list[str] = field(default_factory=list)
|
|
62
|
+
vendored: list[dict[str, str]] = field(default_factory=list)
|
|
63
|
+
release_deny: list[str] = field(default_factory=list)
|
|
64
|
+
max_file_bytes: int = 1_000_000
|
|
65
|
+
|
|
66
|
+
def path(self, rel: str) -> Path:
|
|
67
|
+
return self.root / rel
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_table(pyproject: Path) -> dict[str, Any] | None:
|
|
71
|
+
try:
|
|
72
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
73
|
+
except tomllib.TOMLDecodeError as exc:
|
|
74
|
+
raise ConfigError(f"{pyproject}: invalid pyproject.toml: {exc}") from exc
|
|
75
|
+
return data.get("tool", {}).get(TABLE)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_root(start: Path) -> Path:
|
|
79
|
+
"""The nearest directory at or above start whose pyproject.toml has [tool.skill-linter]."""
|
|
80
|
+
for directory in [start, *start.parents]:
|
|
81
|
+
candidate = directory / "pyproject.toml"
|
|
82
|
+
if candidate.is_file() and _read_table(candidate) is not None:
|
|
83
|
+
return directory
|
|
84
|
+
raise ConfigError(
|
|
85
|
+
f"no pyproject.toml with a [tool.{TABLE}] table at or above {start}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_config(root: Path) -> Config:
|
|
90
|
+
pyproject = root / "pyproject.toml"
|
|
91
|
+
if not pyproject.is_file():
|
|
92
|
+
raise ConfigError(f"{pyproject} not found")
|
|
93
|
+
table = _read_table(pyproject)
|
|
94
|
+
if table is None:
|
|
95
|
+
raise ConfigError(f"{pyproject} has no [tool.{TABLE}] table")
|
|
96
|
+
return _build(root, table)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build(root: Path, t: dict[str, Any]) -> Config:
|
|
100
|
+
cfg = Config(root=root)
|
|
101
|
+
known = {
|
|
102
|
+
"skills_dir": _str,
|
|
103
|
+
"shared_dirs": _str_list,
|
|
104
|
+
"commands_dir": _str,
|
|
105
|
+
"manifest": _str,
|
|
106
|
+
"workflow_glob": _str,
|
|
107
|
+
"version_file": _str,
|
|
108
|
+
"markdown_paths": _str_list,
|
|
109
|
+
"docs_url": _str,
|
|
110
|
+
"skill_path": _str,
|
|
111
|
+
"domain_lists": _str_list,
|
|
112
|
+
"index_files": _str_list,
|
|
113
|
+
"sidebar_file": _str,
|
|
114
|
+
"plugin_root": _str,
|
|
115
|
+
"ship": _str_list,
|
|
116
|
+
}
|
|
117
|
+
structured = {
|
|
118
|
+
"domains",
|
|
119
|
+
"credit",
|
|
120
|
+
"allowed_values",
|
|
121
|
+
"policy",
|
|
122
|
+
"severity",
|
|
123
|
+
"rules",
|
|
124
|
+
"stamp",
|
|
125
|
+
"credentials",
|
|
126
|
+
"vendored",
|
|
127
|
+
"release",
|
|
128
|
+
}
|
|
129
|
+
for key in t:
|
|
130
|
+
if key not in known and key not in structured:
|
|
131
|
+
raise ConfigError(f"unknown key '{key}' in [tool.{TABLE}]")
|
|
132
|
+
for key, check in known.items():
|
|
133
|
+
if key in t:
|
|
134
|
+
setattr(cfg, key, check(key, t[key]))
|
|
135
|
+
|
|
136
|
+
if cfg.skill_path not in ("domain", "flat"):
|
|
137
|
+
raise ConfigError("skill_path must be 'domain' or 'flat'")
|
|
138
|
+
if cfg.plugin_root is not None and cfg.ship is not None:
|
|
139
|
+
raise ConfigError("plugin_root and ship are exclusive; set one")
|
|
140
|
+
if "markdown_paths" not in t:
|
|
141
|
+
cfg.markdown_paths = [cfg.skills_dir]
|
|
142
|
+
|
|
143
|
+
if "domains" in t:
|
|
144
|
+
cfg.domains = _domains(t["domains"])
|
|
145
|
+
if "credit" in t:
|
|
146
|
+
cfg.credit = _credit(t["credit"])
|
|
147
|
+
if "allowed_values" in t:
|
|
148
|
+
values = _table("allowed_values", t["allowed_values"])
|
|
149
|
+
for key, val in values.items():
|
|
150
|
+
if key not in VALUE_LISTS:
|
|
151
|
+
raise ConfigError(f"unknown key 'allowed_values.{key}'")
|
|
152
|
+
cfg.values[key] = _str_list(f"allowed_values.{key}", val)
|
|
153
|
+
if "policy" in t:
|
|
154
|
+
cfg.policy = _policy(_table("policy", t["policy"]))
|
|
155
|
+
if "severity" in t:
|
|
156
|
+
for rule_id, level in _table("severity", t["severity"]).items():
|
|
157
|
+
if level not in SEVERITY_VALUES:
|
|
158
|
+
raise ConfigError(
|
|
159
|
+
f"severity for {rule_id} must be one of error, warning, off; found {level!r}"
|
|
160
|
+
)
|
|
161
|
+
cfg.severity[rule_id] = level
|
|
162
|
+
if "rules" in t:
|
|
163
|
+
rules = _table("rules", t["rules"])
|
|
164
|
+
for key in rules:
|
|
165
|
+
if key != "custom":
|
|
166
|
+
raise ConfigError(f"unknown key 'rules.{key}'")
|
|
167
|
+
cfg.custom_rules = _table_list("rules.custom", rules.get("custom", []))
|
|
168
|
+
if "stamp" in t:
|
|
169
|
+
stamp = _table("stamp", t["stamp"])
|
|
170
|
+
for key in stamp:
|
|
171
|
+
if key != "files":
|
|
172
|
+
raise ConfigError(f"unknown key 'stamp.{key}'")
|
|
173
|
+
cfg.stamp_files = _stamp_files(stamp.get("files", []))
|
|
174
|
+
if "credentials" in t:
|
|
175
|
+
credentials = _table("credentials", t["credentials"])
|
|
176
|
+
for key in credentials:
|
|
177
|
+
if key != "allow":
|
|
178
|
+
raise ConfigError(f"unknown key 'credentials.{key}'")
|
|
179
|
+
cfg.credentials_allow = _str_list(
|
|
180
|
+
"credentials.allow", credentials.get("allow", [])
|
|
181
|
+
)
|
|
182
|
+
if "vendored" in t:
|
|
183
|
+
cfg.vendored = _vendored(t["vendored"])
|
|
184
|
+
if "release" in t:
|
|
185
|
+
release = _table("release", t["release"])
|
|
186
|
+
for key in release:
|
|
187
|
+
if key not in ("deny", "max_file_bytes"):
|
|
188
|
+
raise ConfigError(f"unknown key 'release.{key}'")
|
|
189
|
+
cfg.release_deny = _str_list("release.deny", release.get("deny", []))
|
|
190
|
+
if "max_file_bytes" in release:
|
|
191
|
+
size = release["max_file_bytes"]
|
|
192
|
+
if not isinstance(size, int) or isinstance(size, bool) or size <= 0:
|
|
193
|
+
raise ConfigError("release.max_file_bytes must be a positive integer")
|
|
194
|
+
cfg.max_file_bytes = size
|
|
195
|
+
return cfg
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _str(key: str, value: Any) -> str:
|
|
199
|
+
if not isinstance(value, str):
|
|
200
|
+
raise ConfigError(f"{key} must be a string")
|
|
201
|
+
return value
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _str_list(key: str, value: Any) -> list[str]:
|
|
205
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
206
|
+
raise ConfigError(f"{key} must be a list of strings")
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _table(key: str, value: Any) -> dict[str, Any]:
|
|
211
|
+
if not isinstance(value, dict):
|
|
212
|
+
raise ConfigError(f"{key} must be a table")
|
|
213
|
+
return value
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _table_list(key: str, value: Any) -> list[dict[str, Any]]:
|
|
217
|
+
if not isinstance(value, list) or not all(isinstance(v, dict) for v in value):
|
|
218
|
+
raise ConfigError(f"{key} must be an array of tables")
|
|
219
|
+
return value
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _range(key: str, value: Any) -> list[int]:
|
|
223
|
+
ok = (
|
|
224
|
+
isinstance(value, list)
|
|
225
|
+
and len(value) == 2
|
|
226
|
+
and all(isinstance(v, int) and not isinstance(v, bool) for v in value)
|
|
227
|
+
and value[0] <= value[1]
|
|
228
|
+
)
|
|
229
|
+
if not ok:
|
|
230
|
+
raise ConfigError(f"policy.{key} must be [min, max] integers")
|
|
231
|
+
return value
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _domains(value: Any) -> list[dict[str, str]]:
|
|
235
|
+
items = _table_list("domains", value)
|
|
236
|
+
for item in items:
|
|
237
|
+
if set(item) != {"id", "label"} or not all(
|
|
238
|
+
isinstance(v, str) for v in item.values()
|
|
239
|
+
):
|
|
240
|
+
raise ConfigError("domains entries must be { id = '...', label = '...' }")
|
|
241
|
+
return items
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _credit(value: Any) -> dict[str, Any]:
|
|
245
|
+
credit = _table("credit", value)
|
|
246
|
+
if set(credit) - {"text", "when"} or not isinstance(credit.get("text"), str):
|
|
247
|
+
raise ConfigError(
|
|
248
|
+
"credit must have a 'text' string and an optional 'when' table"
|
|
249
|
+
)
|
|
250
|
+
when = credit.get("when", {})
|
|
251
|
+
if not isinstance(when, dict) or not all(isinstance(v, str) for v in when.values()):
|
|
252
|
+
raise ConfigError("credit.when must map frontmatter fields to strings")
|
|
253
|
+
return {"text": credit["text"], "when": when}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _policy(policy: dict[str, Any]) -> dict[str, Any]:
|
|
257
|
+
out: dict[str, Any] = {}
|
|
258
|
+
for key, value in policy.items():
|
|
259
|
+
if key not in POLICY_KEYS:
|
|
260
|
+
raise ConfigError(f"unknown key 'policy.{key}'")
|
|
261
|
+
if key == "required_sections":
|
|
262
|
+
out[key] = _str_list("policy.required_sections", value)
|
|
263
|
+
elif key in ("workflow_steps", "body_lines"):
|
|
264
|
+
out[key] = _range(key, value)
|
|
265
|
+
elif key == "license":
|
|
266
|
+
out[key] = _str("policy.license", value)
|
|
267
|
+
elif key == "section_order":
|
|
268
|
+
if not isinstance(value, bool) and not (
|
|
269
|
+
isinstance(value, list) and all(isinstance(v, str) for v in value)
|
|
270
|
+
):
|
|
271
|
+
raise ConfigError(
|
|
272
|
+
"policy.section_order must be true, false, or a list of section titles"
|
|
273
|
+
)
|
|
274
|
+
out[key] = value
|
|
275
|
+
else:
|
|
276
|
+
if not isinstance(value, bool):
|
|
277
|
+
raise ConfigError(f"policy.{key} must be true or false")
|
|
278
|
+
out[key] = value
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _vendored(value: Any) -> list[dict[str, str]]:
|
|
283
|
+
items = _table_list("vendored", value)
|
|
284
|
+
for item in items:
|
|
285
|
+
if set(item) != {"path", "source", "tag"} or not all(
|
|
286
|
+
isinstance(v, str) for v in item.values()
|
|
287
|
+
):
|
|
288
|
+
raise ConfigError("vendored entries must be { path, source, tag } strings")
|
|
289
|
+
return items
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
COUNT_KEYS = {
|
|
293
|
+
"version",
|
|
294
|
+
"skillCount",
|
|
295
|
+
"workflowCount",
|
|
296
|
+
"referenceFileCount",
|
|
297
|
+
"domainCount",
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
PER_SKILL_KEY = "referenceFileCount.{skill}"
|
|
302
|
+
PER_SKILL_GROUPS = ({"skill", "count"}, {"skill", "count", "s"})
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def valid_count_key(key: str) -> bool:
|
|
306
|
+
return key in COUNT_KEYS or any(
|
|
307
|
+
key.startswith(prefix) and len(key) > len(prefix)
|
|
308
|
+
for prefix in ("skillCount.", "referenceFileCount.")
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _stamp_files(value: Any) -> list[dict[str, Any]]:
|
|
313
|
+
entries = _table_list("stamp.files", value)
|
|
314
|
+
for entry in entries:
|
|
315
|
+
unknown = set(entry) - {"path", "json", "patterns", "markers"}
|
|
316
|
+
if unknown:
|
|
317
|
+
raise ConfigError(
|
|
318
|
+
f"stamp.files entry has unknown key(s): {', '.join(sorted(unknown))}"
|
|
319
|
+
)
|
|
320
|
+
if not isinstance(entry.get("path"), str):
|
|
321
|
+
raise ConfigError("stamp.files entry needs a 'path' string")
|
|
322
|
+
where = f"stamp.files '{entry['path']}'"
|
|
323
|
+
for pointer, key in _table(f"{where} json", entry.get("json", {})).items():
|
|
324
|
+
if not pointer.startswith("/"):
|
|
325
|
+
raise ConfigError(
|
|
326
|
+
f"{where}: JSON pointer {pointer!r} must start with '/'"
|
|
327
|
+
)
|
|
328
|
+
if not isinstance(key, str) or not valid_count_key(key):
|
|
329
|
+
raise ConfigError(f"{where}: unknown count key {key!r}")
|
|
330
|
+
for item in _table_list(f"{where} patterns", entry.get("patterns", [])):
|
|
331
|
+
if set(item) != {"key", "regex"} or not all(
|
|
332
|
+
isinstance(v, str) for v in item.values()
|
|
333
|
+
):
|
|
334
|
+
raise ConfigError(
|
|
335
|
+
f"{where}: patterns entries are {{ key, regex }} strings"
|
|
336
|
+
)
|
|
337
|
+
if not valid_count_key(item["key"]):
|
|
338
|
+
raise ConfigError(f"{where}: unknown count key {item['key']!r}")
|
|
339
|
+
try:
|
|
340
|
+
regex = re.compile(item["regex"])
|
|
341
|
+
except re.error as exc:
|
|
342
|
+
raise ConfigError(f"{where}: regex does not compile: {exc}") from exc
|
|
343
|
+
if item["key"] == PER_SKILL_KEY:
|
|
344
|
+
if set(regex.groupindex) not in PER_SKILL_GROUPS or regex.groups != len(
|
|
345
|
+
regex.groupindex
|
|
346
|
+
):
|
|
347
|
+
raise ConfigError(
|
|
348
|
+
f"{where}: regex for {PER_SKILL_KEY} needs named groups 'skill' and 'count', optionally 's', and no others"
|
|
349
|
+
)
|
|
350
|
+
elif regex.groups != 1:
|
|
351
|
+
raise ConfigError(
|
|
352
|
+
f"{where}: regex {item['regex']!r} must have exactly one group (the number)"
|
|
353
|
+
)
|
|
354
|
+
if "markers" in entry and not isinstance(entry["markers"], bool):
|
|
355
|
+
raise ConfigError(f"{where}: markers must be true or false")
|
|
356
|
+
return entries
|