Skills › BMAD Method › bmad › validate_manifests.py
validate_manifests.py
473 lines · skills/bmad/scripts/validate_manifests.py at bd70c64
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""Check every skills/*/bmod.toml against the runtime that has to read it.
7This file owns the repository checks: pre-commit and CI run it, and stamp_release.py beside it imports
8`check_repo` and `stamp_text` from it. Keys and tables the runtime does not know are left alone.
9It ships with the `bmad` skill, so any module repository can run it against its own tree.
11Usage:
12 uv run skills/bmad/scripts/validate_manifests.py [--project-root <path>]
13"""
15from __future__ import annotations
17import argparse
18import copy
19import importlib.util
20import re
21import sys
22import tomllib
23from pathlib import Path, PurePosixPath
24from typing import NamedTuple
26sys.dont_write_bytecode = True
28SCRIPTS = Path(__file__).resolve().parent
29RECORD_PREFIX = "bmod-"
30STAMP_PROBE = "0.0.0-stamp-check"
31MESSAGE_KEYS = ("pre_install_message", "post_install_message")
33TABLE_HEADER = re.compile(r"[ \t]*\[\[?[^\[\]\n]+\]\]?[ \t]*(?:#[^\n]*)?\r?\n?")
34BMOD_HEADER = re.compile(r"[ \t]*\[[ \t]*bmod[ \t]*\][ \t]*(?:#[^\n]*)?\r?\n?")
35VERSION_LINE = re.compile(r'(?P<head>[ \t]*version[ \t]*=[ \t]*)"[^"\n]*"(?P<tail>[ \t]*(?:#[^\n]*)?\r?\n?)')
38def load(name: str, path: Path):
39 spec = importlib.util.spec_from_file_location(name, path)
40 if spec is None or spec.loader is None:
41 raise SystemExit(f"cannot load {path}")
42 module = importlib.util.module_from_spec(spec)
43 spec.loader.exec_module(module)
44 return module
47setup = load("bmad_setup_validate", SCRIPTS / "setup.py")
48knowledge = load("bmad_knowledge_validate", SCRIPTS / "knowledge.py")
51class RepoReport(NamedTuple):
52 records: tuple[Path, ...]
53 skills: int
54 documents: int
55 problems: tuple[str, ...]
58def check_repo(project_root: Path) -> RepoReport:
59 skills_dir = project_root / "skills"
60 folders = sorted(path for path in skills_dir.glob("*") if path.is_dir())
61 if not folders:
62 problem = f"no skills/*/{setup.MANIFEST_NAME} found under {project_root}: pass the repository root with --project-root"
63 return RepoReport((), 0, 0, (problem,))
65 problems: list[str] = []
66 files: dict[str, setup.ParsedFile] = {}
67 for folder in folders:
68 manifest = folder / setup.MANIFEST_NAME
69 if not manifest.is_file():
70 problems.append(f"skills/{folder.name}: missing {setup.MANIFEST_NAME}")
71 continue
72 try:
73 files[folder.name] = setup.parse_bmod_file(manifest, manifest.read_bytes())
74 except Exception as error:
75 problems.append(f"{rel(folder.name)}: the runtime parser rejects this file: {error}")
77 shipped = {folder.name for folder in folders}
78 records = {name: parsed.bmod for name, parsed in files.items() if parsed.bmod is not None}
79 members = {name: member_names(name, files[name]) for name in records}
81 problems += record_problems(files, records, skills_dir)
82 problems += membership_problems(files, records, members, shipped)
83 retired: dict[str, setup.ParsedRetired] = {}
84 for name in records:
85 try:
86 retired[name] = setup.read_retired_file(skills_dir / name)
87 except Exception as error:
88 problems.append(f"skills/{name}/{setup.RETIRED_NAME}: the runtime parser rejects this file: {error}")
89 problems += retired_problems(retired, shipped)
90 for name, parsed in files.items():
91 for table, source in (("bmod", parsed.bmod), ("skill", parsed.skill)):
92 if source is not None:
93 problems += requirement_problems(name, table, source, shipped)
94 documents = 0
95 for name, record in records.items():
96 folder = skills_dir / name
97 documents += len(record.knowledge) + (folder / knowledge.HELP_NAME).is_file()
98 problems += knowledge_problems(name, record, folder, members[name])
99 problems += topic_problems(name, folder)
100 problems += roster_file_problems(name, record, folder, skills_dir)
101 problems += stamp_problems(name, folder / setup.MANIFEST_NAME)
102 problems += message_problems(name, folder / setup.MANIFEST_NAME)
104 if not problems:
105 problems += runtime_problems(skills_dir)
107 record_files = tuple(skills_dir / name / setup.MANIFEST_NAME for name in sorted(records))
108 skill_count = sum(1 for parsed in files.values() if parsed.skill is not None)
109 return RepoReport(record_files, skill_count, documents, tuple(problems))
112def version_problem(version: str) -> str | None:
113 """Why an installed module could not use this record version, or None. The stamper applies the same rule."""
114 match = setup.SEMVER.fullmatch(version)
115 if match is None:
116 return f"invalid version {version!r}: must be SemVer (MAJOR.MINOR.PATCH, optional prerelease), e.g. 6.12.0"
117 # setup.py refuses to order any version containing "-dev".
118 if "-dev" in version.casefold():
119 return (
120 f'invalid version {version!r}: setup.py cannot order "-dev" '
121 "versions, so an installed module would never compare as current — "
122 "pick a different prerelease label"
123 )
124 # setup.py drops build metadata when ordering, so "1.2.0+x" compares equal to "1.2.0".
125 if match.group("build") is not None:
126 base = version.split("+", 1)[0]
127 return (
128 f"invalid version {version!r}: setup.py ignores build metadata when "
129 f"ordering, so this compares equal to {base!r} and an installed module "
130 "would never see the release — change the major, minor, patch, or "
131 "prerelease part"
132 )
133 return None
136def stamp_text(original: str, version: str) -> str:
137 """The file with only the `version` line inside [bmod] rewritten. Raises ValueError when that cannot be done."""
138 lines = original.splitlines(keepends=True)
139 headers = [index for index, line in enumerate(lines) if BMOD_HEADER.fullmatch(line)]
140 if len(headers) != 1:
141 raise ValueError(f"expected exactly one '[bmod]' table header line, found {len(headers)}")
142 start = headers[0] + 1
143 # Only the [bmod] table itself: a table further down may have a `version` of its own.
144 end = next((index for index in range(start, len(lines)) if TABLE_HEADER.fullmatch(lines[index])), len(lines))
145 matches = [index for index in range(start, end) if VERSION_LINE.fullmatch(lines[index])]
146 if len(matches) != 1:
147 raise ValueError(f"expected exactly one 'version = \"...\"' line inside [bmod], found {len(matches)}")
148 match = VERSION_LINE.fullmatch(lines[matches[0]])
149 assert match is not None
150 lines[matches[0]] = f'{match.group("head")}"{version}"{match.group("tail")}'
151 stamped = "".join(lines)
152 if tomllib.loads(stamped) != with_version(tomllib.loads(original), version):
153 raise ValueError("rewriting the version line would change something other than [bmod] version")
154 return stamped
157def with_version(data: dict, version: str) -> dict:
158 expected = copy.deepcopy(data)
159 expected["bmod"]["version"] = version
160 return expected
163def message_problems(name: str, manifest: Path) -> list[str]:
164 """Records in this repo carry both install messages, empty or not, so authors see the fields exist."""
165 table = tomllib.loads(manifest.read_text(encoding="utf-8"))["bmod"]
166 return [
167 f"{rel(name)}: [bmod] is missing {key!r}; add it, empty if the module has no message"
168 for key in MESSAGE_KEYS
169 if key not in table
170 ]
173def stamp_problems(name: str, manifest: Path) -> list[str]:
174 """A record the stamper could not stamp fails here, at commit time."""
175 try:
176 stamp_text(manifest.read_bytes().decode("utf-8"), STAMP_PROBE)
177 except (OSError, ValueError) as error:
178 return [f"{rel(name)}: stamp_release.py cannot stamp this file: {error}"]
179 return []
182def rel(folder: str) -> str:
183 return f"skills/{folder}/{setup.MANIFEST_NAME}"
186def member_names(folder: str, parsed: setup.ParsedFile) -> tuple[str, ...]:
187 if parsed.bmod.skills is not None:
188 return parsed.bmod.skills
189 return (folder,) if parsed.skill is not None else ()
192def record_problems(
193 files: dict[str, setup.ParsedFile], records: dict[str, setup.ParsedBmod], skills_dir: Path
194) -> list[str]:
195 problems: list[str] = []
196 for name, parsed in files.items():
197 if name.startswith(RECORD_PREFIX) and parsed.bmod is None:
198 problems.append(
199 f"{rel(name)}: a {RECORD_PREFIX}* folder holds a module record, but this file has no [bmod]"
200 )
201 first_by_code: dict[str, str] = {}
202 for name, record in records.items():
203 problem = version_problem(record.version)
204 if problem is not None:
205 problems.append(f"{rel(name)}: [bmod] {problem}")
206 skill_md = skills_dir / name / "SKILL.md"
207 if skill_md.is_symlink() or not skill_md.is_file():
208 problems.append(f"skills/{name}: a module record folder must ship SKILL.md as a plain file")
209 if files[name].skill is None and name != RECORD_PREFIX + record.code:
210 problems.append(
211 f"{rel(name)}: a module record folder is named {RECORD_PREFIX + record.code!r} "
212 f"after its code; this one is {name!r}"
213 )
214 first = first_by_code.setdefault(record.code.casefold(), name)
215 if first != name:
216 problems.append(
217 f"{rel(name)}: module code {record.code!r} is already declared by {rel(first)}; one record per code"
218 )
219 versions = {name: record.version for name, record in records.items()}
220 if len(set(versions.values())) > 1:
221 listed = ", ".join(f"{name} has {version!r}" for name, version in versions.items())
222 problems.append(f"skills/: every module record carries one version, stamped together; {listed}")
223 return problems
226def membership_problems(
227 files: dict[str, setup.ParsedFile],
228 records: dict[str, setup.ParsedBmod],
229 members: dict[str, tuple[str, ...]],
230 shipped: set[str],
231) -> list[str]:
232 problems: list[str] = []
233 for name, parsed in files.items():
234 if parsed.skill is None:
235 continue
236 if parsed.bmod is not None:
237 if name not in members[name]:
238 problems.append(f"{rel(name)}: holds [skill], but its own [bmod] skills list leaves {name!r} out")
239 continue
240 bmod = parsed.skill.bmod
241 if bmod in records and parsed.skill.source != records[bmod].update_source:
242 problems.append(
243 f"{rel(name)}: [skill] source {parsed.skill.source!r} differs from {rel(bmod)} "
244 f"update_source {records[bmod].update_source!r}"
245 )
246 if bmod not in records:
247 problems.append(
248 f"{rel(name)}: [skill] bmod names {bmod!r}, which is not a module record in this repository"
249 )
250 elif name not in members[bmod]:
251 problems.append(f"{rel(name)}: [skill] bmod names {bmod!r}, but {rel(bmod)} does not list {name!r}")
252 for name in records:
253 for member in members[name]:
254 parsed = files.get(member)
255 if member not in shipped:
256 problems.append(f"{rel(name)}: lists the skill {member!r}, which this repository does not ship")
257 elif parsed is None:
258 continue
259 elif parsed.skill is None or (parsed.bmod is not None and member != name):
260 problems.append(
261 f"{rel(name)}: lists {member!r}, which is a module record and not a skill of this module"
262 )
263 elif member != name and parsed.skill.bmod != name:
264 problems.append(
265 f"{rel(name)}: lists the skill {member!r}, but {rel(member)} names {parsed.skill.bmod!r} as its bmod"
266 )
267 return problems
270def retired_problems(records: dict[str, setup.ParsedRetired], shipped: set[str]) -> list[str]:
271 """A retired name is never shipped again, and a rename points at a skill this repository ships."""
272 problems: list[str] = []
273 retired_by: dict[str, str] = {}
274 for name, record in records.items():
275 retired = [*(rename.old for rename in record.renamed), *record.removed]
276 for old in retired:
277 if old in shipped:
278 problems.append(
279 f"{retired_rel(name)}: retires {old!r}, but skills/{old} still ships; a retired name is never reused"
280 )
281 first = retired_by.setdefault(old, name)
282 if first != name:
283 problems.append(f"{retired_rel(name)}: retires {old!r}, which {retired_rel(first)} already retires")
284 targets = [rename.new for rename in record.renamed]
285 for new in dict.fromkeys(target for target in targets if targets.count(target) > 1):
286 problems.append(
287 f"{retired_rel(name)}: renames more than one skill to {new!r}; "
288 "their customizations would collide, so list the extras under removed"
289 )
290 for rename in record.renamed:
291 if rename.new not in shipped:
292 problems.append(
293 f"{retired_rel(name)}: renames {rename.old!r} to {rename.new!r}, which this repository does not ship"
294 )
295 return problems
298def retired_rel(folder: str) -> str:
299 return f"skills/{folder}/{setup.RETIRED_NAME}"
302def requirement_problems(
303 folder: str, table: str, source: setup.ParsedBmod | setup.ParsedSkill, shipped: set[str]
304) -> list[str]:
305 """Shape is the runtime parser's job. These are the rules only the repository can decide."""
306 problems: list[str] = []
307 for field in ("required_skills", "recommended_skills"):
308 for requirement in getattr(source, field):
309 where = f"{rel(folder)}: {table}.{field} entry {requirement.skill!r}"
310 # setup.py drops build metadata when ordering, so such a minimum could never be told apart.
311 if requirement.version is not None and "+" in requirement.version:
312 problems.append(
313 f"{where} version {requirement.version!r} carries build metadata, which setup.py ignores "
314 f"when ordering; it would compare equal to {requirement.version.split('+', 1)[0]!r}"
315 )
316 if requirement.source is None and requirement.skill not in shipped:
317 problems.append(f"{where} names no skill in this repository and gives no source to fetch it from")
318 return problems
321def knowledge_problems(name: str, record: setup.ParsedBmod, folder: Path, members: tuple[str, ...]) -> list[str]:
322 problems: list[str] = []
323 help_path = folder / knowledge.HELP_NAME
324 if name.startswith("bmod-") or help_path.exists() or help_path.is_symlink():
325 problem = plain_file_problem(folder, PurePosixPath(knowledge.HELP_NAME))
326 if problem is not None:
327 problems.append(f"skills/{name}/{knowledge.HELP_NAME}, which every bmod-* folder holds, {problem}")
328 for entry in record.knowledge:
329 if entry.path.as_posix() == knowledge.HELP_NAME:
330 problems.append(f"{rel(name)}: knowledge names {knowledge.HELP_NAME!r}, which is always read")
331 continue
332 problem = plain_file_problem(folder, entry.path)
333 if problem is not None:
334 problems.append(f"{rel(name)}: knowledge names {entry.path.as_posix()!r}, which {problem}")
335 for skill in entry.skills or ():
336 if skill not in members:
337 problems.append(
338 f"{rel(name)}: knowledge {entry.path.as_posix()!r} names {skill!r}, which is not a skill of "
339 f"module {record.code!r}"
340 )
341 return problems
344TOPIC_REFERENCE = re.compile(r"`help/([^`/<>]+\.md)`")
347def topic_problems(name: str, folder: Path) -> list[str]:
348 """A topic `help.md` never points to is never read, and a pointer to no file misleads the reader."""
349 try:
350 text = (folder / knowledge.HELP_NAME).read_text(encoding="utf-8")
351 except (OSError, UnicodeError):
352 text = ""
353 help_file = PurePosixPath(knowledge.HELP_NAME).name
354 named = set(TOPIC_REFERENCE.findall(text)) - {help_file}
355 shipped = {path.name for path in (folder / knowledge.TOPICS_DIR).glob("*.md")} - {help_file}
356 where = f"skills/{name}/{knowledge.TOPICS_DIR}"
357 problems = [f"{where}/{topic} is never named in {knowledge.HELP_NAME}" for topic in sorted(shipped - named)]
358 problems += [
359 f"skills/{name}/{knowledge.HELP_NAME} names {where}/{topic}, which does not exist"
360 for topic in sorted(named - shipped)
361 ]
362 for topic in sorted(shipped):
363 try:
364 body = (folder / knowledge.TOPICS_DIR / topic).read_text(encoding="utf-8")
365 except (OSError, UnicodeError):
366 continue
367 problems += [
368 f"{where}/{topic} names {where}/{other}, which does not exist"
369 for other in sorted(set(TOPIC_REFERENCE.findall(body)) - shipped - {help_file})
370 ]
371 for topic in sorted(shipped & named):
372 problem = plain_file_problem(folder, PurePosixPath(knowledge.TOPICS_DIR, topic))
373 if problem is not None:
374 problems.append(f"{where}/{topic} {problem}")
375 return problems
378def plain_file_problem(folder: Path, relative: PurePosixPath) -> str | None:
379 path = folder.joinpath(*relative.parts)
380 if path.is_symlink():
381 return "is a symlink, not a plain file"
382 try:
383 knowledge.read_document(path, folder)
384 except FileNotFoundError:
385 return "the module record does not ship"
386 except (OSError, ValueError) as error:
387 return str(error)
388 return None
391def roster_file_problems(name: str, record: setup.ParsedBmod, folder: Path, skills_dir: Path) -> list[str]:
392 path = folder / knowledge.ROSTER_NAME
393 if not path.exists() and not path.is_symlink():
394 return []
395 where = f"skills/{name}/{knowledge.ROSTER_NAME}"
396 problem = plain_file_problem(folder, PurePosixPath(knowledge.ROSTER_NAME))
397 if problem is not None:
398 return [f"{where} {problem}"]
399 try:
400 party = tomllib.loads(path.read_text(encoding="utf-8"))
401 except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
402 return [f"{where}: cannot read roster: {error}"]
403 return [f"{where}: {problem}" for problem in roster_problems(party, skills_dir)]
406def roster_problems(party: dict, skills_dir: Path) -> list[str]:
407 """A group naming a member nobody defines, or a member naming a skill this repo lacks, is a typo."""
408 problems: list[str] = []
409 members = [member for member in as_list(party.get("members")) if isinstance(member, dict)]
410 codes = [member.get("code") for member in members]
411 repeated = sorted({code for code in codes if isinstance(code, str) and codes.count(code) > 1})
412 problems += [f"member code {code!r} is defined twice" for code in repeated]
413 for member in members:
414 skill = member.get("skill")
415 if skill is not None and not (isinstance(skill, str) and (skills_dir / skill / "SKILL.md").is_file()):
416 problems.append(f"member {member.get('code')!r} names skill {skill!r}, which this repository does not ship")
417 for group in as_list(party.get("groups")):
418 if not isinstance(group, dict):
419 continue
420 for code in as_list(group.get("members")):
421 if code not in codes:
422 problems.append(f"group {group.get('id')!r} lists {code!r}, which no member defines")
423 return problems
426def as_list(value: object) -> list:
427 return value if isinstance(value, list) else []
430def runtime_problems(skills_dir: Path) -> list[str]:
431 """The tree as `bmad` itself would discover it. Runs only on a tree the checks above accept."""
432 problems: list[str] = []
433 try:
434 installation = setup.discover_installation(skills_dir / "bmad")
435 except Exception as error:
436 return [f"skills/: setup.py cannot discover the modules: {error}"]
437 problems += [f"skills/: setup.py reports: {problem['message']}" for problem in installation.problems]
438 problems += [
439 f"skills/: setup.py finds no module record {missing['bmod']!r} for {missing['skill']!r}"
440 for missing in installation.missing_records
441 ]
442 report = knowledge.collect([skills_dir])
443 problems += [f"skills/: knowledge.py reports: {problem['problem']}" for problem in report["problems"]]
444 return problems
447def main(argv: list[str] | None = None) -> int:
448 parser = argparse.ArgumentParser(description="Check every skills/*/bmod.toml in a repository.")
449 parser.add_argument(
450 "--project-root", type=Path, default=Path.cwd(), help="repository to check (default: the current directory)"
451 )
452 args = parser.parse_args(argv)
454 report = check_repo(args.project_root.resolve())
455 if report.problems:
456 print(f"bmod file validation failed ({len(report.problems)}):", file=sys.stderr)
457 for problem in report.problems:
458 print(f" {problem}", file=sys.stderr)
459 return 1
461 print(
462 f"bmod files valid: {report.skills} skills, {len(report.records)} module records, "
463 f"{report.documents} knowledge documents."
464 )
465 return 0
468if __name__ == "__main__":
469 if sys.platform == "win32":
470 # Piped output on Windows defaults to a legacy code page, not UTF-8.
471 sys.stdout.reconfigure(encoding="utf-8")
472 sys.stderr.reconfigure(encoding="utf-8")
473 sys.exit(main())