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.
@@ -0,0 +1 @@
1
+ """skill-linter: validate Agent Skills repos."""
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from skill_linter.cli import main
4
+
5
+ sys.exit(main())
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())
@@ -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