basic BMAD setup
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""Shared strict TOML loading and structural merge support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised when a present configuration layer cannot be used safely."""
|
||||
|
||||
|
||||
_KEYED_MERGE_FIELDS = ("code", "id")
|
||||
|
||||
|
||||
def load_toml(path: Path, *, required: bool = False) -> dict[str, Any]:
|
||||
"""Load a TOML table, allowing absence only for optional layers."""
|
||||
if not path.exists():
|
||||
if required:
|
||||
raise ConfigError(f"required TOML file not found: {path}")
|
||||
return {}
|
||||
if not path.is_file():
|
||||
raise ConfigError(f"TOML layer is not a file: {path}")
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
parsed = tomllib.load(stream)
|
||||
except tomllib.TOMLDecodeError as error:
|
||||
raise ConfigError(f"failed to parse {path}: {error}") from error
|
||||
except OSError as error:
|
||||
raise ConfigError(f"failed to read {path}: {error}") from error
|
||||
if not isinstance(parsed, dict):
|
||||
raise ConfigError(f"TOML layer did not parse to a table: {path}")
|
||||
return parsed
|
||||
|
||||
|
||||
def _detect_keyed_merge_field(items: list[Any]) -> str | None:
|
||||
if not items or not all(isinstance(item, dict) for item in items):
|
||||
return None
|
||||
for candidate in _KEYED_MERGE_FIELDS:
|
||||
if all(candidate in item for item in items):
|
||||
for item in items:
|
||||
value = item[candidate]
|
||||
if not isinstance(value, str):
|
||||
raise ConfigError(
|
||||
f"keyed array identifier `{candidate}` must be a string, got {type(value).__name__}"
|
||||
)
|
||||
if not value:
|
||||
raise ConfigError(f"keyed array identifier `{candidate}` must not be empty")
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _merge_arrays(base: list[Any], override: list[Any]) -> list[Any]:
|
||||
keyed_field = _detect_keyed_merge_field(base + override)
|
||||
if keyed_field is None:
|
||||
return list(base) + list(override)
|
||||
|
||||
result: list[Any] = []
|
||||
index_by_key: dict[str, int] = {}
|
||||
for item in base:
|
||||
copied = dict(item)
|
||||
index_by_key[copied[keyed_field]] = len(result)
|
||||
result.append(copied)
|
||||
for item in override:
|
||||
copied = dict(item)
|
||||
key = copied[keyed_field]
|
||||
if key in index_by_key:
|
||||
result[index_by_key[key]] = copied
|
||||
else:
|
||||
index_by_key[key] = len(result)
|
||||
result.append(copied)
|
||||
return result
|
||||
|
||||
|
||||
def structural_merge(base: Any, override: Any) -> Any:
|
||||
"""Merge tables recursively, keyed table arrays by identity, and append other arrays."""
|
||||
if isinstance(base, dict) and isinstance(override, dict):
|
||||
result = dict(base)
|
||||
for key, value in override.items():
|
||||
result[key] = structural_merge(result[key], value) if key in result else value
|
||||
return result
|
||||
if isinstance(base, list) and isinstance(override, list):
|
||||
return _merge_arrays(base, override)
|
||||
return override
|
||||
|
||||
|
||||
def merge_layers(layers: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
||||
merged: dict[str, Any] = {}
|
||||
for layer in layers:
|
||||
merged = structural_merge(merged, layer)
|
||||
return merged
|
||||
|
||||
|
||||
def load_central_config(project_root: Path) -> dict[str, Any]:
|
||||
bmad_dir = project_root / "_bmad"
|
||||
return merge_layers(
|
||||
(
|
||||
load_toml(bmad_dir / "config.toml", required=True),
|
||||
load_toml(bmad_dir / "custom" / "config.toml"),
|
||||
load_toml(bmad_dir / "custom" / "config.user.toml"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def load_customization(project_root: Path | None, skill_dir: Path) -> dict[str, Any]:
|
||||
skill_name = skill_dir.name
|
||||
custom_dir = project_root / "_bmad" / "custom" if project_root else None
|
||||
return merge_layers(
|
||||
(
|
||||
load_toml(skill_dir / "customize.toml", required=True),
|
||||
load_toml(custom_dir / f"{skill_name}.toml") if custom_dir else {},
|
||||
load_toml(custom_dir / f"{skill_name}.user.toml") if custom_dir else {},
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Report the knowledge documents the installed modules offer.
|
||||
|
||||
A folder whose `bmod.toml` has a `[bmod]` table is a module record, whatever
|
||||
the folder is called. The record names the module's skills and holds each
|
||||
knowledge document once. `help/help.md` in the record's folder covers every
|
||||
skill of the module and needs no entry. A `[[bmod.knowledge]]` entry adds a further document
|
||||
and says which of the module's skills it covers: `"*"` or no `skills` key means
|
||||
all of them, a list means the named ones.
|
||||
|
||||
Every other `help/*.md` is a topic: detail that `help/help.md` points to and a
|
||||
reader opens only when a question needs it. Topics are listed with their
|
||||
file path and never with their text.
|
||||
|
||||
A `migration-<n>.toml` file in the record's folder is a migration the
|
||||
module ships: the rules for moving a project from one major version of the
|
||||
module to the next. `<n>` is a whole number that sets the order in which a
|
||||
module's migrations are listed, checked and run. One is listed only when its
|
||||
`[migration]` table names the record's `module` and has `from`, `to`,
|
||||
`title`, `summary`, `detect`, `guide`, and a `checklist`; the listing carries
|
||||
`from`, `to`, `title` and the file path, never the text. A `[migration]`
|
||||
table in a file with any other name, and two files of one module with the
|
||||
same number, are problems. `bmad migrate` reads the file.
|
||||
|
||||
A file this script cannot use becomes an entry in `problems`, never an
|
||||
exception.
|
||||
|
||||
Usage:
|
||||
uv run knowledge.py --root .claude/skills [--root ...] [--content]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import NamedTuple
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
MANIFEST_NAME = "bmod.toml"
|
||||
TOPICS_DIR = "help"
|
||||
HELP_NAME = f"{TOPICS_DIR}/help.md"
|
||||
ROSTER_NAME = "roster.toml"
|
||||
RETIRED_NAME = "retired.toml"
|
||||
MIGRATION_TABLE = "migration"
|
||||
MIGRATION_FIELDS = ("module", "from", "to", "title", "summary", "detect", "guide")
|
||||
MIGRATION_NAME = re.compile(r"migration-([0-9]+)\.toml")
|
||||
READ_LIMIT = 1024 * 1024
|
||||
|
||||
|
||||
class Module(NamedTuple):
|
||||
code: str
|
||||
folder: Path
|
||||
table: dict[str, object]
|
||||
skills: list[str]
|
||||
|
||||
|
||||
class Scan(NamedTuple):
|
||||
folders: dict[str, Path]
|
||||
modules: list[Module]
|
||||
skills: list[dict[str, object]]
|
||||
problems: list[dict[str, object]]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Report the knowledge documents the installed modules offer.")
|
||||
parser.add_argument("--root", type=Path, action="append", required=True, help="a skills root to scan")
|
||||
parser.add_argument("--content", action="store_true", help="include each document's text")
|
||||
args = parser.parse_args(argv)
|
||||
print(json.dumps(collect(args.root, include_content=args.content), ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def collect(roots: list[Path], *, include_content: bool = False) -> dict[str, object]:
|
||||
found = scan(roots)
|
||||
problems = found.problems
|
||||
documents: dict[tuple[str, str], dict[str, object]] = {}
|
||||
|
||||
for module in found.modules:
|
||||
entries = module.table.get("knowledge", [])
|
||||
if not isinstance(entries, list):
|
||||
problems.append(
|
||||
{"kind": "knowledge", "skill": module.folder.name, "problem": "[bmod] 'knowledge' is not a list"}
|
||||
)
|
||||
continue
|
||||
if (module.folder / HELP_NAME).exists():
|
||||
entries = [{"path": HELP_NAME}, *entries]
|
||||
for entry in entries:
|
||||
record_document(documents, problems, found.folders, module, entry, include_content=include_content)
|
||||
|
||||
topics = [
|
||||
topic
|
||||
for module in found.modules
|
||||
for topic in module_topics(module, problems)
|
||||
if (topic["module"], topic["path"]) not in documents
|
||||
]
|
||||
|
||||
migrations = [migration for module in found.modules for migration in module_migrations(module, problems)]
|
||||
|
||||
return {
|
||||
"roots": [str(root) for root in roots],
|
||||
"skills": sorted(found.skills, key=lambda item: str(item["skill"])),
|
||||
"documents": sorted(documents.values(), key=lambda item: (str(item["module"]), str(item["path"]))),
|
||||
"topics": sorted(topics, key=lambda item: (str(item["module"]), str(item["path"]))),
|
||||
"migrations": sorted(migrations, key=lambda item: (str(item["module"]), migration_number(str(item["path"])))),
|
||||
"problems": problems,
|
||||
}
|
||||
|
||||
|
||||
def scan(roots: list[Path]) -> Scan:
|
||||
"""Find every module record and every module skill in the roots."""
|
||||
folders: dict[str, Path] = {}
|
||||
modules: list[Module] = []
|
||||
problems: list[dict[str, object]] = []
|
||||
record_codes: dict[str, str] = {}
|
||||
pending: list[tuple[Path, Path, dict[str, object], bool]] = []
|
||||
|
||||
for root in roots:
|
||||
try:
|
||||
found = sorted(path for path in root.iterdir() if path.is_dir())
|
||||
except OSError as error:
|
||||
problems.append({"kind": "root", "root": str(root), "problem": f"cannot read root {root}: {error}"})
|
||||
continue
|
||||
for folder in found:
|
||||
# The first root wins a folder name outright: a project copy shadows a
|
||||
# user copy even when the project copy carries no bmod.toml.
|
||||
if folder.name in folders:
|
||||
continue
|
||||
folders[folder.name] = folder
|
||||
manifest = folder / MANIFEST_NAME
|
||||
try:
|
||||
if not manifest.is_file():
|
||||
continue
|
||||
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
|
||||
problems.append(manifest_problem(folder, f"cannot use {manifest}: {error}"))
|
||||
continue
|
||||
if "bmod" not in data and "skill" not in data:
|
||||
problems.append(manifest_problem(folder, f"{manifest} has neither a [bmod] nor a [skill] table"))
|
||||
continue
|
||||
|
||||
skill = data.get("skill")
|
||||
if "skill" in data and not isinstance(skill, dict):
|
||||
problems.append(manifest_problem(folder, f"{manifest}: 'skill' is not a table"))
|
||||
skill = None
|
||||
if "bmod" in data:
|
||||
module = read_record(folder, data["bmod"], skill is not None, problems)
|
||||
if module is not None:
|
||||
record_codes[folder.name] = module.code
|
||||
first = next((other for other in modules if other.code.casefold() == module.code.casefold()), None)
|
||||
if first is None:
|
||||
modules.append(module)
|
||||
else:
|
||||
problems.append(
|
||||
{
|
||||
"kind": "module",
|
||||
"skill": folder.name,
|
||||
"problem": f"{folder.name}: module {module.code!r} is already recorded by "
|
||||
f"{first.folder.name}; {first.folder.name} is used",
|
||||
}
|
||||
)
|
||||
if skill is not None:
|
||||
pending.append((root, folder, skill, "bmod" in data))
|
||||
|
||||
skills = [
|
||||
resolve_skill(root, folder, table, own_record, record_codes) for root, folder, table, own_record in pending
|
||||
]
|
||||
problems.extend(absent_records(skills, folders))
|
||||
for entry in skills:
|
||||
entry.pop("source")
|
||||
return Scan(folders, modules, skills, problems)
|
||||
|
||||
|
||||
def manifest_problem(folder: Path, problem: str) -> dict[str, object]:
|
||||
return {"kind": "manifest", "skill": folder.name, "manifest": str(folder / MANIFEST_NAME), "problem": problem}
|
||||
|
||||
|
||||
def read_record(folder: Path, table: object, has_skill: bool, problems: list[dict[str, object]]) -> Module | None:
|
||||
manifest = folder / MANIFEST_NAME
|
||||
if not isinstance(table, dict):
|
||||
problems.append(manifest_problem(folder, f"{manifest}: 'bmod' is not a table"))
|
||||
return None
|
||||
code = table.get("code")
|
||||
if not isinstance(code, str) or not code:
|
||||
problems.append(manifest_problem(folder, f"{manifest}: [bmod] has no usable 'code'"))
|
||||
return None
|
||||
listed = table.get("skills")
|
||||
if listed is None:
|
||||
# A record that is also a skill, with no list, is its own one member.
|
||||
members = [folder.name] if has_skill else []
|
||||
elif isinstance(listed, list) and all(isinstance(name, str) and name for name in listed):
|
||||
members = list(dict.fromkeys(listed))
|
||||
else:
|
||||
problems.append(manifest_problem(folder, f"{manifest}: [bmod] 'skills' is not a list of skill names"))
|
||||
members = []
|
||||
return Module(code, folder, table, members)
|
||||
|
||||
|
||||
def resolve_skill(
|
||||
root: Path, folder: Path, table: dict[str, object], own_record: bool, record_codes: dict[str, str]
|
||||
) -> dict[str, object]:
|
||||
bmod = folder.name if own_record else table.get("bmod")
|
||||
if not isinstance(bmod, str) or not bmod:
|
||||
bmod = None
|
||||
return {
|
||||
"skill": folder.name,
|
||||
"module": record_codes.get(bmod) if bmod else None,
|
||||
"bmod": bmod,
|
||||
"root": str(root),
|
||||
"source": table.get("source"),
|
||||
}
|
||||
|
||||
|
||||
def absent_records(skills: list[dict[str, object]], folders: dict[str, Path]) -> list[dict[str, object]]:
|
||||
"""One problem per module record that skills name and no root holds."""
|
||||
problems: list[dict[str, object]] = []
|
||||
by_bmod: dict[str, list[dict[str, object]]] = {}
|
||||
for entry in skills:
|
||||
if entry["module"] is not None:
|
||||
continue
|
||||
if entry["bmod"] is None:
|
||||
problems.append(
|
||||
{
|
||||
"kind": "manifest",
|
||||
"skill": entry["skill"],
|
||||
"problem": f"{entry['skill']}: [skill] does not name its module record under 'bmod'",
|
||||
}
|
||||
)
|
||||
continue
|
||||
by_bmod.setdefault(str(entry["bmod"]), []).append(entry)
|
||||
for bmod, entries in sorted(by_bmod.items()):
|
||||
names = sorted(str(entry["skill"]) for entry in entries)
|
||||
state = "has no usable module record" if bmod in folders else "is not installed"
|
||||
problem: dict[str, object] = {
|
||||
"kind": "module",
|
||||
"bmod": bmod,
|
||||
"skills": names,
|
||||
"problem": f"module record {bmod} {state}; it is named by {', '.join(names)}",
|
||||
}
|
||||
command = None if bmod in folders else install_command(entries[0]["source"], bmod)
|
||||
if command:
|
||||
problem["install"] = command
|
||||
problem["problem"] = f"{problem['problem']}; install it with `{command}`"
|
||||
problems.append(problem)
|
||||
return problems
|
||||
|
||||
|
||||
def install_command(source: object, skill: str) -> str | None:
|
||||
if not isinstance(source, str) or not source.startswith("github:"):
|
||||
return None
|
||||
parts = source.removeprefix("github:").split("/")
|
||||
if len(parts) < 2 or not all(parts[:2]):
|
||||
return None
|
||||
return f"npx skills add {parts[0]}/{parts[1]} --skill {skill}"
|
||||
|
||||
|
||||
def record_document(
|
||||
documents: dict[tuple[str, str], dict[str, object]],
|
||||
problems: list[dict[str, object]],
|
||||
folders: dict[str, Path],
|
||||
module: Module,
|
||||
entry: object,
|
||||
*,
|
||||
include_content: bool,
|
||||
) -> None:
|
||||
folder = module.folder
|
||||
name = entry.get("path") if isinstance(entry, dict) else None
|
||||
if not isinstance(name, str):
|
||||
problems.append(
|
||||
{"kind": "knowledge", "skill": folder.name, "problem": f"knowledge entry {entry!r} has no path"}
|
||||
)
|
||||
return
|
||||
relative = safe_skill_relative(name)
|
||||
if relative is None:
|
||||
problems.append({"kind": "knowledge", "skill": folder.name, "problem": f"knowledge names unsafe path {name!r}"})
|
||||
return
|
||||
covered = entry.get("skills", "*")
|
||||
if covered == "*":
|
||||
skills = list(module.skills)
|
||||
elif isinstance(covered, list) and all(isinstance(skill, str) and skill for skill in covered):
|
||||
skills = list(dict.fromkeys(covered))
|
||||
else:
|
||||
problems.append(
|
||||
{
|
||||
"kind": "knowledge",
|
||||
"skill": folder.name,
|
||||
"problem": f"knowledge entry {name!r}: 'skills' is neither \"*\" nor a list of skill names",
|
||||
}
|
||||
)
|
||||
return
|
||||
key = (module.code, relative.as_posix())
|
||||
if key in documents:
|
||||
problems.append({"kind": "knowledge", "skill": folder.name, "problem": f"knowledge names {name!r} twice"})
|
||||
return
|
||||
|
||||
path = folder.joinpath(*relative.parts)
|
||||
try:
|
||||
raw = read_document(path, folder)
|
||||
except (OSError, ValueError) as error:
|
||||
problems.append(
|
||||
{"kind": "document", "skill": folder.name, "document": str(path), "problem": f"{path}: {error}"}
|
||||
)
|
||||
return
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeError as error:
|
||||
problems.append(
|
||||
{
|
||||
"kind": "document",
|
||||
"skill": folder.name,
|
||||
"document": str(path),
|
||||
"problem": f"{path}: not valid UTF-8, so it is not a knowledge document: {error}",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
document: dict[str, object] = {
|
||||
"module": module.code,
|
||||
"path": relative.as_posix(),
|
||||
"skills": skills,
|
||||
"installed_skills": [skill for skill in skills if skill in folders],
|
||||
"reported_from": folder.name,
|
||||
}
|
||||
if include_content:
|
||||
document["content"] = text
|
||||
documents[key] = document
|
||||
|
||||
|
||||
def module_topics(module: Module, problems: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
folder = module.folder
|
||||
try:
|
||||
found = sorted(path for path in (folder / TOPICS_DIR).glob("*.md") if path != folder / HELP_NAME)
|
||||
except OSError:
|
||||
return []
|
||||
topics: list[dict[str, object]] = []
|
||||
for path in found:
|
||||
try:
|
||||
read_document(path, folder).decode("utf-8")
|
||||
except (OSError, ValueError) as error:
|
||||
problems.append(
|
||||
{"kind": "document", "skill": folder.name, "document": str(path), "problem": f"{path}: {error}"}
|
||||
)
|
||||
continue
|
||||
topics.append(
|
||||
{"module": module.code, "topic": path.stem, "path": f"{TOPICS_DIR}/{path.name}", "file": str(path)}
|
||||
)
|
||||
return topics
|
||||
|
||||
|
||||
def module_migrations(module: Module, problems: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
"""The migrations a record ships: its `migration-<n>.toml` files, in number order."""
|
||||
folder = module.folder
|
||||
try:
|
||||
found = sorted(
|
||||
path for path in folder.glob("*.toml") if path.name not in (MANIFEST_NAME, ROSTER_NAME, RETIRED_NAME)
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
migrations: list[dict[str, object]] = []
|
||||
for path in found:
|
||||
try:
|
||||
data = tomllib.loads(read_document(path, folder).decode("utf-8"))
|
||||
except (OSError, ValueError) as error:
|
||||
problems.append(migration_problem(folder, path, str(error)))
|
||||
continue
|
||||
if MIGRATION_TABLE not in data:
|
||||
continue
|
||||
table = data[MIGRATION_TABLE]
|
||||
if not isinstance(table, dict):
|
||||
problems.append(migration_problem(folder, path, "'migration' is not a table"))
|
||||
continue
|
||||
fields = {name: table.get(name) for name in MIGRATION_FIELDS}
|
||||
missing = [name for name, value in fields.items() if not isinstance(value, str) or not value.strip()]
|
||||
checklist = table.get("checklist")
|
||||
if (
|
||||
not isinstance(checklist, list)
|
||||
or not checklist
|
||||
or not all(isinstance(item, str) and item.strip() for item in checklist)
|
||||
):
|
||||
missing.append("checklist")
|
||||
if missing:
|
||||
problems.append(migration_problem(folder, path, f"[migration] needs non-empty {', '.join(missing)}"))
|
||||
continue
|
||||
if fields["module"] != module.code:
|
||||
problems.append(
|
||||
migration_problem(
|
||||
folder, path, f"[migration] module {fields['module']!r} is not this record's {module.code!r}"
|
||||
)
|
||||
)
|
||||
continue
|
||||
if migration_number(path.name) is None:
|
||||
problems.append(migration_problem(folder, path, "a migration file must be named migration-<n>.toml"))
|
||||
continue
|
||||
listed = {name: fields[name] for name in ("from", "to", "title")}
|
||||
migrations.append({"module": module.code, "path": path.name, "file": str(path), **listed})
|
||||
numbers = [migration_number(str(item["path"])) for item in migrations]
|
||||
duplicated = {number for number in numbers if numbers.count(number) > 1}
|
||||
for item in migrations:
|
||||
if migration_number(str(item["path"])) in duplicated:
|
||||
problems.append(
|
||||
migration_problem(
|
||||
folder, Path(str(item["file"])), "another migration of this module has the same number"
|
||||
)
|
||||
)
|
||||
kept = [item for item in migrations if migration_number(str(item["path"])) not in duplicated]
|
||||
return sorted(kept, key=lambda item: migration_number(str(item["path"])))
|
||||
|
||||
|
||||
def migration_number(name: str) -> int | None:
|
||||
match = MIGRATION_NAME.fullmatch(name)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def migration_problem(folder: Path, path: Path, problem: str) -> dict[str, object]:
|
||||
return {"kind": "migration", "skill": folder.name, "document": str(path), "problem": f"{path}: {problem}"}
|
||||
|
||||
|
||||
def read_document(path: Path, folder: Path) -> bytes:
|
||||
"""Read a knowledge document, refusing anything that is not a plain file inside the folder."""
|
||||
resolved = path.resolve()
|
||||
if not resolved.is_relative_to(folder.resolve()):
|
||||
raise ValueError("resolves outside the skill folder")
|
||||
status = resolved.stat()
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
raise ValueError("is not a regular file")
|
||||
with resolved.open("rb") as handle:
|
||||
raw = handle.read(READ_LIMIT + 1)
|
||||
if len(raw) > READ_LIMIT:
|
||||
raise ValueError(f"is larger than {READ_LIMIT} bytes")
|
||||
return raw
|
||||
|
||||
|
||||
def safe_skill_relative(entry: str) -> PurePosixPath | None:
|
||||
"""A bmod.toml path that cannot escape the skill folder, or None if it can.
|
||||
|
||||
Mirrors safe_skill_relative in setup.py. A URL parses as an ordinary
|
||||
relative path and a Windows drive prefix makes a later join discard the
|
||||
skill folder, so both are refused by name. pathlib drops "." components
|
||||
itself, so only ".." and an empty final component need checking.
|
||||
"""
|
||||
if not entry or "://" in entry or "\\" in entry or ":" in entry:
|
||||
return None
|
||||
relative = PurePosixPath(entry)
|
||||
if relative.is_absolute() or ".." in relative.parts or not relative.name:
|
||||
return None
|
||||
return relative
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""memlog — an append-only memory log: LLM-optimal working memory for a skill.
|
||||
|
||||
A memlog is the dense, chronological record of everything that mattered in a piece of
|
||||
work — every item the user generated or accepted — kept minimal like human memory: only
|
||||
what's important, never bloated. It persists ACROSS sessions, so a fresh session can
|
||||
load it and continue. It is NOT a deliverable; downstream artifacts (a brief, a PRD, a
|
||||
deck, a report) are *derived* from it on demand. The host skill supplies the vocabulary
|
||||
by how it calls `append` — the tool stays neutral.
|
||||
|
||||
It is a FLAT log: there are no sections or grouping. Every entry is one line, recorded
|
||||
at the END in the order it happened. The chronology itself is the structure — an event
|
||||
like "started technique X" is just another entry, same as an idea or an insight.
|
||||
|
||||
Three invariants make it trustworthy:
|
||||
|
||||
1. Append-only, chronological. Entries land at the end, in the order they happen.
|
||||
Nothing is ever inserted backward, reordered, edited, or removed. There is no
|
||||
edit or delete subcommand by design; history is never rewritten.
|
||||
2. Write-only / blind. Every command is a context-free write and echoes the new state
|
||||
as one line of JSON, so the caller never re-reads the file mid-session. `append` adds
|
||||
its line with one OS append write, so concurrent appenders never drop each other.
|
||||
The one time the file is read is on resume — and the caller reads it itself, not
|
||||
via this script.
|
||||
3. No lifecycle status. A memory log has no "complete" flag. Whether the work is done,
|
||||
blocked, or paused is itself a fact that happened, so it is recorded as an entry
|
||||
(e.g. `append --type event --text "session complete"`), never as frontmatter the
|
||||
log would have to mutate. The chronology stays the single source of truth, and a
|
||||
resume learns the state by reading the last entries — the same way it learns
|
||||
everything else.
|
||||
|
||||
Atomicity: `init` and `set` write a temp file, flush and fsync it, then atomically rename
|
||||
it over the target. `append` never rewrites the file: it adds the entry at the end of file
|
||||
in a single append write (O_APPEND on POSIX, a FILE_APPEND_DATA handle on Windows), which the
|
||||
OS places after whatever other processes appended, so parallel appends all land.
|
||||
|
||||
The file shape (.memlog.md):
|
||||
|
||||
---
|
||||
topic: Onboarding flow for a budgeting app
|
||||
goal: lift week-1 retention
|
||||
updated: 2026-06-07T14:22
|
||||
---
|
||||
|
||||
- (note) user picked techniques: SCAMPER, then Six Thinking Hats
|
||||
- (technique) started SCAMPER
|
||||
- (idea) skip the signup wall: let people try with sample data first
|
||||
- (idea) auto-import one bank account so the first screen shows real numbers
|
||||
- (question) is open-banking consent too heavy for step one?
|
||||
- (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized
|
||||
- (direction) optimize for the anxious first-timer, not the power user
|
||||
- (decision) lead with one pre-categorized account; defer multi-account import
|
||||
- (event) session complete
|
||||
|
||||
Each entry may carry an optional `--type` — what KIND it is (idea, insight, question,
|
||||
decision, direction, assumption, gap, note, event, …) — and an optional `--by` naming
|
||||
who it came from (e.g. `user`, `coach`), for sessions where authorship matters. Both
|
||||
render into one short inline tag: `(idea)`, `(idea by user)`, `(by coach)`. Omit them
|
||||
for a plain note. The host skill names the vocabulary; the script does not enforce one.
|
||||
|
||||
Commands:
|
||||
init (--workspace DIR | --path FILE) [--field k=v ...] create the memlog (errors if it exists)
|
||||
append (--workspace DIR | --path FILE) --text STR [--type T] [--by W] append one entry at the end
|
||||
set (--workspace DIR | --path FILE) --key K --value V set/replace a descriptive frontmatter field
|
||||
|
||||
Addressing: `--workspace` is the run folder, and the memlog is always {workspace}/.memlog.md.
|
||||
`--path` points straight at the memlog file instead, for callers that already hold the path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations # keep type-hint syntax lazy so the script runs on 3.8+
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
MEMLOG = ".memlog.md"
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%dT%H:%M")
|
||||
|
||||
|
||||
def resolve(args) -> Path:
|
||||
"""The memlog file, from either addressing mode: {workspace}/.memlog.md or an explicit --path."""
|
||||
return Path(args.path) if args.path else Path(args.workspace) / MEMLOG
|
||||
|
||||
|
||||
def split(text: str) -> tuple[dict, str]:
|
||||
"""Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.
|
||||
|
||||
The closing fence is the first line that is *exactly* `---`, so a `---` inside a
|
||||
field value (topic/goal are free user text) never truncates the frontmatter.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0] != "---":
|
||||
raise ValueError(".memlog.md has no frontmatter")
|
||||
end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None)
|
||||
if end is None:
|
||||
raise ValueError(".memlog.md frontmatter is not terminated")
|
||||
meta: dict[str, str] = {}
|
||||
for line in lines[1:end]:
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
meta[k.strip()] = v.strip()
|
||||
return meta, "\n".join(lines[end + 1 :]).lstrip("\n")
|
||||
|
||||
|
||||
def render(meta: dict, body: str) -> str:
|
||||
# Neutralize newlines in values so a multi-line field can't break the fence on re-read.
|
||||
fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items())
|
||||
return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n"
|
||||
|
||||
|
||||
def touch(meta: dict) -> None:
|
||||
"""Stamp `updated` and keep it last so the field order stays predictable."""
|
||||
meta.pop("updated", None)
|
||||
meta["updated"] = now()
|
||||
|
||||
|
||||
def write_atomic(path: Path, text: str) -> None:
|
||||
"""Temp + flush + fsync + atomic rename, so a crash never half-writes an entry."""
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def append_line(path: Path, text: str) -> None:
|
||||
"""Add text at end of file in one OS append write, then sync it to disk. Never creates the file."""
|
||||
data = text.replace("\n", os.linesep).encode("utf-8") # match text-mode line endings
|
||||
if sys.platform != "win32":
|
||||
fd = os.open(path, os.O_WRONLY | os.O_APPEND)
|
||||
try:
|
||||
if os.write(fd, data) != len(data):
|
||||
raise OSError(f"short write appending to {path}")
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return
|
||||
# The CRT's append mode seeks then writes in two steps; a handle with only
|
||||
# FILE_APPEND_DATA access makes Windows itself place every write at end of file.
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
k32.CreateFileW.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
wintypes.HANDLE,
|
||||
]
|
||||
k32.CreateFileW.restype = wintypes.HANDLE
|
||||
k32.WriteFile.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_char_p,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
k32.WriteFile.restype = wintypes.BOOL
|
||||
k32.FlushFileBuffers.argtypes = [wintypes.HANDLE]
|
||||
k32.FlushFileBuffers.restype = wintypes.BOOL
|
||||
k32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
file_append_data, share_all, open_existing, normal = 0x4, 0x7, 3, 0x80
|
||||
handle = k32.CreateFileW(str(path), file_append_data, share_all, None, open_existing, normal, None)
|
||||
if handle is None or handle == wintypes.HANDLE(-1).value:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
try:
|
||||
written = wintypes.DWORD()
|
||||
if not k32.WriteFile(handle, data, len(data), ctypes.byref(written), None):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
if written.value != len(data):
|
||||
raise OSError(f"short write appending to {path}")
|
||||
if not k32.FlushFileBuffers(handle):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
finally:
|
||||
k32.CloseHandle(handle)
|
||||
|
||||
|
||||
def entry_count(body: str) -> int:
|
||||
return sum(1 for ln in body.splitlines() if ln.startswith("- "))
|
||||
|
||||
|
||||
def ack(path: Path, body: str) -> None:
|
||||
"""Echo new state so the caller never re-reads the file to know where it stands."""
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"memlog": str(path),
|
||||
"entries": entry_count(body),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def cmd_init(args) -> int:
|
||||
path = resolve(args)
|
||||
if path.exists():
|
||||
print(f"error: {path} already exists; use append/set to update it", file=sys.stderr)
|
||||
return 2
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
meta: dict[str, str] = {}
|
||||
for pair in args.field or []:
|
||||
if "=" not in pair:
|
||||
print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr)
|
||||
return 2
|
||||
k, v = pair.split("=", 1)
|
||||
meta[k.strip()] = v.strip()
|
||||
touch(meta)
|
||||
write_atomic(path, render(meta, ""))
|
||||
ack(path, "")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_append(args) -> int:
|
||||
path = resolve(args)
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
split(raw) # a missing or malformed log fails here, before anything is written
|
||||
text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat
|
||||
label = args.type or ""
|
||||
if args.by:
|
||||
label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)"
|
||||
tag = f"({label}) " if label else ""
|
||||
entry = f"- {tag}{text}"
|
||||
append_line(path, ("" if raw.endswith("\n") else "\n") + entry + "\n")
|
||||
ack(path, split(path.read_text(encoding="utf-8"))[1])
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_set(args) -> int:
|
||||
path = resolve(args)
|
||||
meta, body = split(path.read_text(encoding="utf-8"))
|
||||
meta[args.key] = args.value
|
||||
touch(meta)
|
||||
write_atomic(path, render(meta, body))
|
||||
ack(path, body)
|
||||
return 0
|
||||
|
||||
|
||||
def add_target(sp) -> None:
|
||||
"""Every command addresses the memlog the same way: a run folder or an explicit path."""
|
||||
g = sp.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--workspace", help="run folder; the memlog is {workspace}/.memlog.md")
|
||||
g.add_argument("--path", help="explicit memlog file path (alternative to --workspace)")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
pi = sub.add_parser("init", help="create the memlog")
|
||||
add_target(pi)
|
||||
pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)")
|
||||
pi.set_defaults(func=cmd_init)
|
||||
|
||||
pa = sub.add_parser("append", help="append one entry at the end")
|
||||
add_target(pa)
|
||||
pa.add_argument("--text", required=True)
|
||||
pa.add_argument("--type", help="entry kind, rendered as an inline tag")
|
||||
pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag")
|
||||
pa.set_defaults(func=cmd_append)
|
||||
|
||||
pset = sub.add_parser("set", help="set a descriptive frontmatter field")
|
||||
add_target(pset)
|
||||
pset.add_argument("--key", required=True)
|
||||
pset.add_argument("--value", required=True)
|
||||
pset.set_defaults(func=cmd_set)
|
||||
|
||||
args = p.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Report or remove the v6 traces in `_bmad/` that v7 does not read.
|
||||
|
||||
With no flag it lists them: the classic installer's files, the v6 install answers
|
||||
v7 has no key for, and customizations of skills that are not installed. With
|
||||
`--clean` it copies `_bmad/` to `.v6-v7-migration-backup-<datetime>/`, which the
|
||||
method migration reuses, then removes them.
|
||||
|
||||
Usage:
|
||||
uv run migrate_v6.py --project-root P --skill <the bmad skill> [--root R ...] [--clean]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import importlib.util
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
|
||||
def _load_setup():
|
||||
spec = importlib.util.spec_from_file_location("bmad_setup", Path(__file__).with_name("setup.py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
_setup = _load_setup()
|
||||
Installation = _setup.Installation
|
||||
Retirement = _setup.Retirement
|
||||
TABLE_HEADER = _setup.TABLE_HEADER
|
||||
TEAM_CONFIG = _setup.TEAM_CONFIG
|
||||
USER_CONFIG = _setup.USER_CONFIG
|
||||
_MISSING = _setup._MISSING
|
||||
discover_installation = _setup.discover_installation
|
||||
header_path = _setup.header_path
|
||||
lookup = _setup.lookup
|
||||
present = _setup.present
|
||||
reject_unusable_bmad = _setup.reject_unusable_bmad
|
||||
render_toml = _setup.render_toml
|
||||
retired_skills = _setup.retired_skills
|
||||
retirement_report = _setup.retirement_report
|
||||
write_text = _setup.write_text
|
||||
|
||||
# Traces the classic installer leaves under _bmad.
|
||||
LEGACY_LEFTOVERS = (
|
||||
"_config/manifest.yaml",
|
||||
"_config/files-manifest.csv",
|
||||
"_config/skill-manifest.csv",
|
||||
"_config/bmad-help.csv",
|
||||
"config.user.toml",
|
||||
"core/config.yaml",
|
||||
"core/module-help.csv",
|
||||
"core/v6-shims",
|
||||
"bmm/config.yaml",
|
||||
"bmm/module-help.csv",
|
||||
"bmm/v6-shims",
|
||||
)
|
||||
# The v6.12 install answers v7 has no key for. Only these are removed, so a key added later is never touched.
|
||||
V6_KEYS = (
|
||||
("core", "user_name"),
|
||||
("core", "communication_language"),
|
||||
("core", "document_output_language"),
|
||||
("modules", "bmm", "user_skill_level"),
|
||||
("modules", "bmm", "planning_artifacts"),
|
||||
("modules", "bmm", "implementation_artifacts"),
|
||||
("modules", "bmm", "project_knowledge"),
|
||||
)
|
||||
CENTRAL_CONFIGS = (TEAM_CONFIG, "_bmad/custom/config.toml", USER_CONFIG)
|
||||
BACKUP_PREFIX = ".v6-v7-migration-backup-"
|
||||
|
||||
|
||||
def legacy_leftovers(project_root: Path) -> list[str]:
|
||||
bmad = project_root / "_bmad"
|
||||
return [
|
||||
relative
|
||||
for relative in LEGACY_LEFTOVERS
|
||||
if (path := bmad.joinpath(*PurePosixPath(relative).parts)).exists() and inside_bmad(path, project_root)
|
||||
]
|
||||
|
||||
|
||||
def inside_bmad(path: Path, project_root: Path) -> bool:
|
||||
"""A link along the way could point a cleanup outside the project; such a path is not cleaned."""
|
||||
return path.resolve().is_relative_to((project_root / "_bmad").resolve())
|
||||
|
||||
|
||||
def stale_config_keys(project_root: Path) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""The V6_KEYS set in the central config files."""
|
||||
stale: list[tuple[str, tuple[str, ...]]] = []
|
||||
for relative in CENTRAL_CONFIGS:
|
||||
path = project_root / relative
|
||||
if path.is_symlink() or not path.is_file() or not inside_bmad(path, project_root):
|
||||
continue
|
||||
try:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, tomllib.TOMLDecodeError):
|
||||
continue
|
||||
stale += [(relative, key) for key in V6_KEYS if lookup(data, key) is not _MISSING]
|
||||
return stale
|
||||
|
||||
|
||||
def unused_customizations(project_root: Path, installation: Installation, retirement: Retirement) -> list[str]:
|
||||
"""`_bmad/custom/` files of skills that are not installed. Files setup is about to rename are not unused."""
|
||||
custom = project_root / "_bmad" / "custom"
|
||||
if custom.is_symlink() or not custom.is_dir():
|
||||
return []
|
||||
moving = {old for old, _new in (*retirement.renames, *retirement.unmoved)}
|
||||
unused: list[str] = []
|
||||
for path in sorted(custom.iterdir(), key=lambda entry: entry.name):
|
||||
name = path.name
|
||||
stem = name.removesuffix(".toml").removesuffix(".user")
|
||||
if not name.startswith("bmad-") or not name.endswith(".toml") or name in moving:
|
||||
continue
|
||||
if stem not in installation.folders:
|
||||
unused.append(name)
|
||||
return unused
|
||||
|
||||
|
||||
def traces_json(project_root: Path, installation: Installation, retirement: Retirement) -> dict[str, object]:
|
||||
return {
|
||||
"legacy_leftovers": legacy_leftovers(project_root),
|
||||
"stale_config_keys": [
|
||||
{"file": relative, "key": ".".join(path)} for relative, path in stale_config_keys(project_root)
|
||||
],
|
||||
"unused_customizations": [
|
||||
f"_bmad/custom/{name}" for name in unused_customizations(project_root, installation, retirement)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def clean(project_root: Path, skill_root: Path, *, roots: tuple[Path, ...] = ()) -> dict[str, object]:
|
||||
"""Back up `_bmad/`, then remove the v6 traces v7 does not read: the classic installer's files, v6
|
||||
config keys, and customizations of skills that are not installed."""
|
||||
reject_unusable_bmad(project_root)
|
||||
bmad = project_root / "_bmad"
|
||||
installation = discover_installation(skill_root, roots)
|
||||
if installation.missing_records:
|
||||
# Without every module record, a renamed skill's customizations would look unused.
|
||||
raise Exception("a module record is missing; run setup to repair the installation before cleaning up")
|
||||
retirement = retirement_report(
|
||||
project_root, skill_root, installation, retired_skills(installation.modules, installation.modules)
|
||||
)
|
||||
leftovers = legacy_leftovers(project_root)
|
||||
stale = stale_config_keys(project_root)
|
||||
unused = unused_customizations(project_root, installation, retirement)
|
||||
edits = {
|
||||
relative: text_without_keys(
|
||||
(project_root / relative).read_text(encoding="utf-8"), [key for file, key in stale if file == relative]
|
||||
)
|
||||
for relative in dict.fromkeys(file for file, _path in stale)
|
||||
}
|
||||
report: dict[str, object] = {
|
||||
"mode": "clean",
|
||||
"backup": None,
|
||||
"removed_files": [],
|
||||
"removed_keys": [],
|
||||
"removed_customizations": [],
|
||||
}
|
||||
if not (leftovers or stale or unused):
|
||||
return report
|
||||
backup = backup_bmad(project_root)
|
||||
report["backup"] = backup.relative_to(project_root).as_posix()
|
||||
for relative in leftovers:
|
||||
path = bmad.joinpath(*PurePosixPath(relative).parts)
|
||||
if path.is_dir() and not path.is_symlink():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
prune_empty_parents(path.parent, bmad)
|
||||
for relative, text in edits.items():
|
||||
write_text(project_root / relative, text)
|
||||
for name in unused:
|
||||
(bmad / "custom" / name).unlink()
|
||||
report["removed_files"] = [f"_bmad/{relative}" for relative in leftovers]
|
||||
report["removed_keys"] = [{"file": relative, "key": ".".join(path)} for relative, path in stale]
|
||||
report["removed_customizations"] = [f"_bmad/custom/{name}" for name in unused]
|
||||
return report
|
||||
|
||||
|
||||
def backup_bmad(project_root: Path) -> Path:
|
||||
"""Copy `_bmad/` into a new `.v6-v7-migration-backup-<datetime>/`, which the method migration reuses.
|
||||
Its `.gitignore` keeps the copy, personal settings included, out of every commit."""
|
||||
now = datetime.datetime.now()
|
||||
backup = project_root / f"{BACKUP_PREFIX}{now:%Y%m%d-%H%M}"
|
||||
if present(backup):
|
||||
backup = project_root / f"{BACKUP_PREFIX}{now:%Y%m%d-%H%M%S}"
|
||||
shutil.copytree(project_root / "_bmad", backup / "_bmad", symlinks=True)
|
||||
write_text(backup / ".gitignore", "*")
|
||||
return backup
|
||||
|
||||
|
||||
def prune_empty_parents(folder: Path, stop: Path) -> None:
|
||||
while folder != stop and folder.is_dir() and not folder.is_symlink() and not any(folder.iterdir()):
|
||||
folder.rmdir()
|
||||
folder = folder.parent
|
||||
|
||||
|
||||
def text_without_keys(text: str, paths: list[tuple[str, ...]]) -> str:
|
||||
"""The file's text with these keys removed, so comments and layout survive.
|
||||
|
||||
A key written on one line in its table's section is cut by text, and a table left
|
||||
empty loses its header. When the result does not parse to the expected values,
|
||||
the whole file is rendered instead.
|
||||
"""
|
||||
expected = tomllib.loads(text)
|
||||
for path in paths:
|
||||
drop_path(expected, path)
|
||||
lines = text.split("\n")
|
||||
for path in paths:
|
||||
lines = lines_without_key(lines, path[:-1], path[-1])
|
||||
headers = [index for index, line in enumerate(lines) if TABLE_HEADER.match(line)]
|
||||
emptied = {
|
||||
index
|
||||
for position, index in enumerate(headers)
|
||||
if (name := header_path(lines[index])) is not None
|
||||
and lookup(expected, name) is _MISSING
|
||||
and not any(
|
||||
line.strip() and not line.lstrip().startswith("#")
|
||||
for line in lines[index + 1 : headers[position + 1] if position + 1 < len(headers) else len(lines)]
|
||||
)
|
||||
}
|
||||
candidate = "\n".join(line for index, line in enumerate(lines) if index not in emptied)
|
||||
try:
|
||||
if tomllib.loads(candidate) == expected:
|
||||
return candidate
|
||||
except tomllib.TOMLDecodeError:
|
||||
pass
|
||||
return render_toml(expected)
|
||||
|
||||
|
||||
def drop_path(data: dict, path: tuple[str, ...]) -> None:
|
||||
"""Delete the key at path, then any table the deletion left empty."""
|
||||
parents = [data]
|
||||
for part in path[:-1]:
|
||||
child = parents[-1].get(part)
|
||||
if not isinstance(child, dict):
|
||||
return
|
||||
parents.append(child)
|
||||
parents[-1].pop(path[-1], None)
|
||||
for depth in range(len(path) - 1, 0, -1):
|
||||
if parents[depth]:
|
||||
break
|
||||
parents[depth - 1].pop(path[depth - 1], None)
|
||||
|
||||
|
||||
def lines_without_key(lines: list[str], table: tuple[str, ...], leaf: str) -> list[str]:
|
||||
start, end = 0, len(lines)
|
||||
if table:
|
||||
headers = [(index, header_path(line)) for index, line in enumerate(lines) if TABLE_HEADER.match(line)]
|
||||
for position, (index, name) in enumerate(headers):
|
||||
if name == table:
|
||||
start = index + 1
|
||||
end = headers[position + 1][0] if position + 1 < len(headers) else len(lines)
|
||||
break
|
||||
else:
|
||||
return lines
|
||||
else:
|
||||
end = next((index for index, line in enumerate(lines) if TABLE_HEADER.match(line)), len(lines))
|
||||
key = re.compile(rf"\s*(?:{re.escape(leaf)}|\"{re.escape(leaf)}\"|'{re.escape(leaf)}')\s*=")
|
||||
for index in range(start, end):
|
||||
if not key.match(lines[index]):
|
||||
continue
|
||||
# A multi-line value ends at the first line after which the file parses again.
|
||||
for stop in range(index + 1, end + 1):
|
||||
remaining = [*lines[:index], *lines[stop:]]
|
||||
try:
|
||||
tomllib.loads("\n".join(remaining))
|
||||
except tomllib.TOMLDecodeError:
|
||||
continue
|
||||
return remaining
|
||||
return lines
|
||||
return lines
|
||||
|
||||
|
||||
def report(project_root: Path, skill_root: Path, *, roots: tuple[Path, ...] = ()) -> dict[str, object]:
|
||||
"""The traces `--clean` would remove. Reads only."""
|
||||
reject_unusable_bmad(project_root)
|
||||
installation = discover_installation(skill_root, roots)
|
||||
retirement = retirement_report(
|
||||
project_root, skill_root, installation, retired_skills(installation.modules, installation.modules)
|
||||
)
|
||||
return {"mode": "report", **traces_json(project_root, installation, retirement)}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Report or remove the v6 traces in _bmad that v7 does not read.")
|
||||
parser.add_argument("--project-root", type=Path, required=True)
|
||||
parser.add_argument("--skill", type=Path, required=True)
|
||||
parser.add_argument("--root", type=Path, action="append", default=[], help="an active skills folder, repeated")
|
||||
parser.add_argument("--clean", action="store_true", help="back up _bmad, then remove the traces")
|
||||
args = parser.parse_args(argv)
|
||||
project_root = args.project_root.resolve()
|
||||
skill_root = args.skill.resolve()
|
||||
roots = tuple(root.resolve() for root in args.root)
|
||||
run = clean if args.clean else report
|
||||
_setup.print_json(run(project_root, skill_root, roots=roots))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as error:
|
||||
sys.stderr.write(f"error: {error}\n")
|
||||
raise SystemExit(1) from None
|
||||
@@ -0,0 +1,689 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["jinja2>=3.1"]
|
||||
# ///
|
||||
"""Render a skill's Markdown sources into an immutable project snapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tomllib
|
||||
from collections.abc import Callable
|
||||
from datetime import date, time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import jinja2
|
||||
|
||||
# Installed scripts are consumer files, not a location for interpreter caches.
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
from config_utils import ( # noqa: E402
|
||||
ConfigError,
|
||||
load_central_config,
|
||||
load_customization,
|
||||
load_toml,
|
||||
structural_merge,
|
||||
)
|
||||
|
||||
|
||||
class RenderError(ValueError):
|
||||
"""Raised when rendering cannot safely publish a snapshot."""
|
||||
|
||||
|
||||
class _ArgumentParser(argparse.ArgumentParser):
|
||||
def error(self, message: str) -> None:
|
||||
raise RenderError(message)
|
||||
|
||||
|
||||
_PARAMETER = r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*"
|
||||
|
||||
|
||||
def _hash_bytes(content: bytes) -> str:
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=_json_scalar).encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _json_scalar(value: Any) -> str:
|
||||
if isinstance(value, (date, time)):
|
||||
return value.isoformat()
|
||||
raise TypeError(f"unsupported JSON value: {type(value).__name__}")
|
||||
|
||||
|
||||
def _toml_literal(text: str, label: str) -> Any:
|
||||
try:
|
||||
parsed = tomllib.loads(f"value = {text}")
|
||||
except tomllib.TOMLDecodeError as error:
|
||||
raise RenderError(f"invalid TOML value for {label}: {error}") from error
|
||||
if set(parsed) != {"value"}:
|
||||
raise RenderError(f"{label} must contain a single TOML value")
|
||||
return parsed["value"]
|
||||
|
||||
|
||||
def _invocation_customization(
|
||||
defaults: dict[str, Any], overrides: Path | None, assignments: list[str]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
file_layer = load_toml(overrides, required=True) if overrides is not None else {}
|
||||
command_layer: dict[str, Any] = {}
|
||||
assigned: list[str] = []
|
||||
for assignment in assignments:
|
||||
path, separator, raw = assignment.partition("=")
|
||||
if not separator or re.fullmatch(_PARAMETER, path) is None:
|
||||
raise RenderError(f"invalid --set assignment {assignment!r}; expected bare dotted key=value")
|
||||
# A repeated or overlapping path is a caller mistake, not a precedence rule.
|
||||
for earlier in assigned:
|
||||
if path == earlier or path.startswith(f"{earlier}.") or earlier.startswith(f"{path}."):
|
||||
raise RenderError(f"--set `{path}` conflicts with earlier --set `{earlier}`")
|
||||
assigned.append(path)
|
||||
default = _lookup(defaults, path, "customization parameter")
|
||||
value = (
|
||||
raw if isinstance(default, str) and not raw.lstrip().startswith(('"', "'")) else _toml_literal(raw, path)
|
||||
)
|
||||
target = command_layer
|
||||
parts = path.split(".")
|
||||
for part in parts[:-1]:
|
||||
target = target.setdefault(part, {})
|
||||
target[parts[-1]] = value
|
||||
return file_layer, command_layer
|
||||
|
||||
|
||||
def _leaf_paths(table: dict[str, Any], prefix: str = "") -> set[str]:
|
||||
leaves: set[str] = set()
|
||||
for key, value in table.items():
|
||||
path = f"{prefix}{key}"
|
||||
if isinstance(value, dict):
|
||||
leaves |= _leaf_paths(value, f"{path}.")
|
||||
else:
|
||||
leaves.add(path)
|
||||
return leaves
|
||||
|
||||
|
||||
def _declares(defaults: dict[str, Any], path: str) -> bool:
|
||||
node: Any = defaults
|
||||
for part in path.split("."):
|
||||
if not isinstance(node, dict) or part not in node:
|
||||
return False
|
||||
node = node[part]
|
||||
return True
|
||||
|
||||
|
||||
def _check_persistent_layers(project_root: Path, skill_dir: Path, defaults: dict[str, Any] | None) -> None:
|
||||
"""A persistent override may only set keys the skill declares; a stale or misspelled key halts."""
|
||||
custom_dir = project_root / "_bmad" / "custom"
|
||||
for layer in (custom_dir / f"{skill_dir.name}.toml", custom_dir / f"{skill_dir.name}.user.toml"):
|
||||
undeclared = sorted(path for path in _leaf_paths(load_toml(layer)) if not _declares(defaults or {}, path))
|
||||
if undeclared:
|
||||
raise RenderError(f"{layer} sets keys {skill_dir.name} does not declare: {', '.join(undeclared)}")
|
||||
|
||||
|
||||
def _lookup(data: dict[str, Any], dotted_path: str, label: str) -> Any:
|
||||
current: Any = data
|
||||
for part in dotted_path.split("."):
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
raise RenderError(f"missing {label} `{dotted_path}`")
|
||||
current = current[part]
|
||||
return current
|
||||
|
||||
|
||||
def _require_string(value: Any, label: str, *, allow_empty: bool = False) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise RenderError(f"{label} must be a string, got {type(value).__name__}")
|
||||
if not allow_empty and not value.strip():
|
||||
raise RenderError(f"{label} must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
def _require_string_list(value: Any, label: str) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise RenderError(f"{label} must be a list, got {type(value).__name__}")
|
||||
result = []
|
||||
for index, item in enumerate(value):
|
||||
result.append(_require_string(item, f"{label}[{index}]"))
|
||||
return result
|
||||
|
||||
|
||||
def _require_review_layers(value: Any, label: str) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
raise RenderError(f"{label} must be a list of tables")
|
||||
result: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for index, item in enumerate(value):
|
||||
item_label = f"{label}[{index}]"
|
||||
if not isinstance(item, dict):
|
||||
raise RenderError(f"{item_label} must be a table")
|
||||
identifier = _require_string(item.get("id"), f"{item_label}.id")
|
||||
if identifier in seen:
|
||||
raise RenderError(f"duplicate review layer id `{identifier}`")
|
||||
seen.add(identifier)
|
||||
layer = {
|
||||
"id": identifier,
|
||||
"name": _require_string(item.get("name", identifier), f"{item_label}.name"),
|
||||
"instruction": _require_string(item.get("instruction"), f"{item_label}.instruction", allow_empty=True),
|
||||
}
|
||||
if "when" in item:
|
||||
layer["when"] = _require_string(item["when"], f"{item_label}.when")
|
||||
result.append(layer)
|
||||
return result
|
||||
|
||||
|
||||
def _load_sources(skill_dir: Path) -> dict[str, str]:
|
||||
sources: dict[str, str] = {}
|
||||
for candidate in sorted(skill_dir.rglob("*.md")):
|
||||
if candidate.name == "SKILL.md":
|
||||
continue
|
||||
name = candidate.relative_to(skill_dir).as_posix()
|
||||
path = candidate.resolve(strict=True)
|
||||
if not path.is_relative_to(skill_dir):
|
||||
raise RenderError(f"render source escapes skill directory: {name}")
|
||||
if not path.is_file():
|
||||
raise RenderError(f"render source is missing or not a file: {path}")
|
||||
try:
|
||||
sources[name] = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise RenderError(f"failed to read render source {path}: {error}") from error
|
||||
if "workflow.md" not in sources:
|
||||
raise RenderError(f"render entry is missing: {skill_dir / 'workflow.md'}")
|
||||
return sources
|
||||
|
||||
|
||||
def _resolve_config_value(value: Any, label: str, project_root: Path) -> str:
|
||||
text = _require_string(value, label)
|
||||
if "{project-root}" not in text:
|
||||
return text
|
||||
resolved = text.replace("{project-root}", project_root.as_posix())
|
||||
if not Path(resolved).is_absolute():
|
||||
raise RenderError(f"{label} must resolve to an absolute path: {resolved}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _find_config_values(data: Any, key: str, prefix: str = "") -> list[tuple[str, Any]]:
|
||||
matches: list[tuple[str, Any]] = []
|
||||
if not isinstance(data, dict):
|
||||
return matches
|
||||
for name, value in data.items():
|
||||
path = f"{prefix}.{name}" if prefix else name
|
||||
if name == key and not isinstance(value, (dict, list)):
|
||||
matches.append((path, value))
|
||||
matches.extend(_find_config_values(value, key, path))
|
||||
return matches
|
||||
|
||||
|
||||
def _resolve_short_config(central: dict[str, Any], key: str, project_root: Path) -> tuple[str, str]:
|
||||
matches = _find_config_values(central, key)
|
||||
if not matches:
|
||||
raise RenderError(f"missing config value `{key}`")
|
||||
if len(matches) > 1:
|
||||
paths = ", ".join(path for path, _ in matches)
|
||||
raise RenderError(f"ambiguous config value `{key}` found at: {paths}")
|
||||
path, value = matches[0]
|
||||
return path, _resolve_config_value(value, f"config.{path}", project_root)
|
||||
|
||||
|
||||
def _format_markdown_list(items: list[str]) -> str:
|
||||
if not items:
|
||||
return "_None._"
|
||||
rendered = []
|
||||
for item in items:
|
||||
lines = item.splitlines() or [""]
|
||||
rendered.append("- " + lines[0])
|
||||
rendered.extend(" " + line for line in lines[1:])
|
||||
return "\n".join(rendered)
|
||||
|
||||
|
||||
def _format_review_layers(layers: list[dict[str, str]]) -> str:
|
||||
active = [layer for layer in layers if layer["instruction"].strip()]
|
||||
if not active:
|
||||
return "No active review layers. HALT with blocking condition `no active review layers`."
|
||||
sections = []
|
||||
for layer in active:
|
||||
section = [f"#### {layer['name']} (`{layer['id']}`)"]
|
||||
if layer.get("when"):
|
||||
section.extend(["", f"Run only when: {layer['when']}"])
|
||||
section.extend(["", layer["instruction"].strip()])
|
||||
sections.append("\n".join(section))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _resolve_customization_value(value: Any, default: Any, label: str) -> Any:
|
||||
"""Validate an effective customization leaf against the shape of its shipped default."""
|
||||
if isinstance(default, str):
|
||||
allow_empty = not default.strip()
|
||||
return _require_string(value, label, allow_empty=allow_empty)
|
||||
if isinstance(default, list):
|
||||
if default and all(isinstance(item, dict) for item in default):
|
||||
return _require_review_layers(value, label)
|
||||
return _require_string_list(value, label)
|
||||
if isinstance(default, (bool, int, float, date, time)):
|
||||
if type(value) is not type(default):
|
||||
raise RenderError(f"{label} must be {type(default).__name__}, got {type(value).__name__}")
|
||||
return value
|
||||
raise RenderError(f"{label} has unsupported default type {type(default).__name__}")
|
||||
|
||||
|
||||
class _Text(str):
|
||||
"""A customization string. Looping over one is a template mistake, not a walk over its characters."""
|
||||
|
||||
def __new__(cls, value: str, label: str) -> _Text:
|
||||
text = super().__new__(cls, value)
|
||||
text.label = label
|
||||
return text
|
||||
|
||||
def __iter__(self):
|
||||
raise RenderError(f"`{self.label}` is a string, not a list")
|
||||
|
||||
|
||||
class _Derived:
|
||||
"""A name the renderer computes from the central config, when a template reaches it."""
|
||||
|
||||
def __init__(self, compute: Callable[[], Any]) -> None:
|
||||
self.compute = compute
|
||||
|
||||
|
||||
class _MarkdownList(list):
|
||||
"""A string-list customization; inserted directly it renders as the Markdown list it always did."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return _format_markdown_list(list(self))
|
||||
|
||||
|
||||
class _LayerList(list):
|
||||
"""A review-layer customization; inserted directly it renders as lens sections."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return _format_review_layers(list(self))
|
||||
|
||||
|
||||
def _bind_customization(value: Any, label: str, destination: Path) -> Any:
|
||||
"""Bind `{skill-root}` in customization prose to the generation and wrap lists for insertion."""
|
||||
root = destination.as_posix()
|
||||
if isinstance(value, str):
|
||||
return _Text(value.replace("{skill-root}", root), label)
|
||||
if isinstance(value, list):
|
||||
if value and all(isinstance(item, dict) for item in value):
|
||||
return _LayerList(
|
||||
[{key: text.replace("{skill-root}", root) for key, text in layer.items()} for layer in value]
|
||||
)
|
||||
return _MarkdownList([item.replace("{skill-root}", root) for item in value])
|
||||
return value
|
||||
|
||||
|
||||
class _Table:
|
||||
"""A dotted namespace over a TOML table. Names never hit Python attributes, so `workflow.items` is a lookup."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self._path = path
|
||||
|
||||
def _child(self, name: str) -> str:
|
||||
return f"{self._path}.{name}"
|
||||
|
||||
def _resolve(self, name: str) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(name)
|
||||
return self._resolve(name)
|
||||
|
||||
def __getitem__(self, name: Any) -> Any:
|
||||
if not isinstance(name, str):
|
||||
raise RenderError(f"`{self._path}` is indexed by name, not {name!r}")
|
||||
return self._resolve(name)
|
||||
|
||||
def __str__(self) -> str:
|
||||
raise RenderError(f"`{self._path}` is a table, not a value")
|
||||
|
||||
|
||||
class _ConfigTable(_Table):
|
||||
"""`config.key` is the short lookup of one scalar anywhere in the central config; `config.a.b.c` is a path."""
|
||||
|
||||
def __init__(self, central: dict[str, Any], table: dict[str, Any], path: str, ctx: _RenderContext) -> None:
|
||||
super().__init__(path)
|
||||
self._central = central
|
||||
self._table = table
|
||||
self._ctx = ctx
|
||||
|
||||
def _resolve(self, name: str) -> Any:
|
||||
if self._path == "config" and name not in self._table:
|
||||
path, resolved = _resolve_short_config(self._central, name, self._ctx.project_root)
|
||||
self._ctx.inputs[f"config.{path}"] = resolved
|
||||
return _Text(resolved, f"config.{path}")
|
||||
label = self._child(name)
|
||||
if name not in self._table:
|
||||
raise RenderError(f"missing config value `{label.removeprefix('config.')}`")
|
||||
value = self._table[name]
|
||||
if isinstance(value, dict):
|
||||
return _ConfigTable(self._central, value, label, self._ctx)
|
||||
resolved = _resolve_config_value(value, label, self._ctx.project_root)
|
||||
self._ctx.inputs[label] = resolved
|
||||
return _Text(resolved, label)
|
||||
|
||||
|
||||
class _CustomizationTable(_Table):
|
||||
"""The effective customization, each leaf validated against its `customize.toml` default."""
|
||||
|
||||
def __init__(self, defaults: dict[str, Any] | None, values: dict[str, Any], path: str, ctx: _RenderContext) -> None:
|
||||
super().__init__(path)
|
||||
self._defaults = defaults
|
||||
self._values = values
|
||||
self._ctx = ctx
|
||||
|
||||
def _resolve(self, name: str) -> Any:
|
||||
path = self._child(name)
|
||||
if self._defaults is None:
|
||||
raise RenderError(f"`{path}` requires customize.toml")
|
||||
if name not in self._defaults:
|
||||
raise RenderError(f"missing customization parameter `{path}`")
|
||||
if name not in self._values:
|
||||
raise RenderError(f"missing customization value `{path}`")
|
||||
default, value = self._defaults[name], self._values[name]
|
||||
label = f"customization.{path}"
|
||||
if isinstance(default, dict):
|
||||
if not isinstance(value, dict):
|
||||
raise RenderError(f"{label} must be a table, got {type(value).__name__}")
|
||||
return _CustomizationTable(default, value, path, self._ctx)
|
||||
resolved = _resolve_customization_value(value, default, label)
|
||||
self._ctx.inputs[label] = resolved
|
||||
return _bind_customization(resolved, label, self._ctx.destination)
|
||||
|
||||
|
||||
class _RenderContext:
|
||||
"""One rendering pass: the values it serves and the rendered() links each source makes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
central: dict[str, Any],
|
||||
defaults: dict[str, Any] | None,
|
||||
customization: dict[str, Any],
|
||||
source_names: set[str],
|
||||
project_root: Path,
|
||||
destination: Path,
|
||||
) -> None:
|
||||
self.project_root = project_root
|
||||
self.destination = destination
|
||||
self.inputs: dict[str, Any] = {}
|
||||
self.links: dict[str, set[str]] = {}
|
||||
self._source_names = source_names
|
||||
self.variables = {
|
||||
"config": _ConfigTable(central, central, "config", self),
|
||||
"workflow": _CustomizationTable(
|
||||
None if defaults is None else defaults.get("workflow", {}),
|
||||
customization.get("workflow", {}),
|
||||
"workflow",
|
||||
self,
|
||||
),
|
||||
"rendered": self._rendered,
|
||||
"halt": self._halt,
|
||||
"initiative_folder": _Derived(self._initiative_folder),
|
||||
}
|
||||
self._central = central
|
||||
|
||||
def _initiative_folder(self) -> _Text:
|
||||
"""`core.output_folder`, extended by `/<core.active_initiative>` when an initiative is set."""
|
||||
folder = str(self.variables["config"].core.output_folder)
|
||||
core = self._central.get("core")
|
||||
initiative = core.get("active_initiative") if isinstance(core, dict) else None
|
||||
if initiative is not None:
|
||||
folder = f"{folder}/{_require_string(initiative, 'config.core.active_initiative')}"
|
||||
self.inputs["initiative_folder"] = folder
|
||||
return _Text(folder, "initiative_folder")
|
||||
|
||||
@staticmethod
|
||||
def _halt(message: Any) -> str:
|
||||
"""Let a template reject its inputs; the caller prefixes the source and line."""
|
||||
raise RenderError(str(message))
|
||||
|
||||
@jinja2.pass_context
|
||||
def _rendered(self, context: jinja2.runtime.Context, target: Any) -> str:
|
||||
if not isinstance(target, str) or target not in self._source_names:
|
||||
raise RenderError(f"rendered() targets undeclared source: {target}")
|
||||
self.links.setdefault(context.name or "", set()).add(target)
|
||||
return (self.destination / target).as_posix()
|
||||
|
||||
|
||||
class _Context(jinja2.runtime.Context):
|
||||
"""Reaching a derived name computes it, so only the values a template reads key its generation."""
|
||||
|
||||
def resolve_or_missing(self, key: str) -> Any:
|
||||
value = super().resolve_or_missing(key)
|
||||
return value.compute() if isinstance(value, _Derived) else value
|
||||
|
||||
|
||||
class _SourceLoader(jinja2.BaseLoader):
|
||||
"""Serve sources by name, and name them so template frames carry `source:line`."""
|
||||
|
||||
def __init__(self, sources: dict[str, str]) -> None:
|
||||
self._sources = sources
|
||||
|
||||
def get_source(self, environment: jinja2.Environment, template: str) -> tuple[str, str, Any]:
|
||||
if template not in self._sources:
|
||||
raise jinja2.TemplateNotFound(template)
|
||||
return self._sources[template], template, lambda: True
|
||||
|
||||
|
||||
def _template_location(error: BaseException, source_names: set[str]) -> str | None:
|
||||
if isinstance(error, jinja2.TemplateSyntaxError):
|
||||
return f"{error.name}:{error.lineno}" if error.name else None
|
||||
location = None
|
||||
traceback = error.__traceback__
|
||||
while traceback is not None:
|
||||
filename = traceback.tb_frame.f_code.co_filename
|
||||
if filename in source_names:
|
||||
location = f"{filename}:{traceback.tb_lineno}"
|
||||
traceback = traceback.tb_next
|
||||
return location
|
||||
|
||||
|
||||
def _render_sources(sources: dict[str, str], skill_dir: Path, context: _RenderContext) -> dict[str, str]:
|
||||
"""Render every source as a Jinja2 template against the context; return the non-empty outputs."""
|
||||
# Skill sources name their bundled non-Markdown files (scripts, assets)
|
||||
# through {skill-root}; those stay in the installed skill directory.
|
||||
bound = {name: content.replace("{skill-root}", skill_dir.as_posix()) for name, content in sources.items()}
|
||||
environment = jinja2.Environment(
|
||||
loader=_SourceLoader(bound),
|
||||
undefined=jinja2.StrictUndefined,
|
||||
autoescape=False,
|
||||
keep_trailing_newline=True,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
environment.context_class = _Context
|
||||
rendered: dict[str, str] = {}
|
||||
for name in sources:
|
||||
try:
|
||||
rendered[name] = environment.get_template(name).render(context.variables)
|
||||
except Exception as error:
|
||||
location = _template_location(error, set(sources))
|
||||
message = str(error) if isinstance(error, (RenderError, ConfigError, jinja2.TemplateError)) else repr(error)
|
||||
raise RenderError(f"{location or name}: {message}") from error
|
||||
# A source whose body renders to nothing is left out; links into it from survivors are broken.
|
||||
omitted = {name for name, text in rendered.items() if not text.strip()}
|
||||
if "workflow.md" in omitted:
|
||||
raise RenderError("workflow.md: rendered empty")
|
||||
for name in sorted(set(rendered) - omitted):
|
||||
for target in sorted(context.links.get(name, set()) & omitted):
|
||||
raise RenderError(f"{name}: rendered() targets omitted source: {target}")
|
||||
return {name: text for name, text in rendered.items() if name not in omitted}
|
||||
|
||||
|
||||
def _verify_existing(destination: Path, manifest: dict[str, Any]) -> None:
|
||||
manifest_path = destination / "manifest.json"
|
||||
try:
|
||||
existing = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise RenderError(f"corrupt existing generation {destination}: {error}") from error
|
||||
if existing != manifest:
|
||||
raise RenderError(f"generation collision or corruption at {destination}")
|
||||
# Only the manifest's own files are verified. Anything else in the folder (Thumbs.db,
|
||||
# editor swap files, sync conflict copies) is read by nobody and is left alone.
|
||||
missing = sorted(name for name in manifest["outputs"] if not (destination / name).is_file())
|
||||
if missing:
|
||||
raise RenderError(
|
||||
f"generation is missing rendered files: {', '.join(missing)} in {destination}; "
|
||||
"deleting that folder is safe because the next run renders it again"
|
||||
)
|
||||
for name, expected_hash in manifest["outputs"].items():
|
||||
try:
|
||||
actual_hash = _hash_bytes((destination / name).read_bytes())
|
||||
except OSError as error:
|
||||
raise RenderError(f"failed to verify {destination / name}: {error}") from error
|
||||
if actual_hash != expected_hash:
|
||||
raise RenderError(f"generation output hash mismatch: {destination / name}")
|
||||
|
||||
|
||||
def _publish(destination: Path, outputs: dict[str, bytes], manifest: dict[str, Any]) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if destination.exists():
|
||||
_verify_existing(destination, manifest)
|
||||
return
|
||||
# One attempt, not mkdtemp: on Windows, older Pythons' mkdtemp takes "access denied"
|
||||
# for a name collision and tries the next name, some two billion times. The render
|
||||
# would hang in a folder it cannot write to instead of halting.
|
||||
staging = destination.parent / f".staging-{os.urandom(8).hex()}"
|
||||
staging.mkdir(mode=0o700)
|
||||
try:
|
||||
for name, content in outputs.items():
|
||||
path = staging / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
(staging / "manifest.json").write_bytes(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True, default=_json_scalar).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
try:
|
||||
os.rename(staging, destination)
|
||||
except OSError:
|
||||
if destination.exists():
|
||||
_verify_existing(destination, manifest)
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def render(
|
||||
project_root: Path, skill_dir: Path, *, overrides: Path | None = None, assignments: list[str] | None = None
|
||||
) -> Path:
|
||||
project_root = project_root.resolve(strict=True)
|
||||
skill_dir = skill_dir.resolve(strict=True)
|
||||
if not (project_root / "_bmad").is_dir():
|
||||
raise RenderError(f"project root does not contain _bmad/: {project_root}")
|
||||
|
||||
sources = _load_sources(skill_dir)
|
||||
central = load_central_config(project_root)
|
||||
has_customization = bool(overrides is not None or assignments) or (skill_dir / "customize.toml").is_file()
|
||||
defaults = load_toml(skill_dir / "customize.toml", required=True) if has_customization else None
|
||||
_check_persistent_layers(project_root, skill_dir, defaults)
|
||||
customization = load_customization(project_root, skill_dir) if has_customization else {}
|
||||
supplied: set[str] = set()
|
||||
if defaults is not None:
|
||||
file_layer, command_layer = _invocation_customization(defaults, overrides, assignments or [])
|
||||
customization = structural_merge(structural_merge(customization, file_layer), command_layer)
|
||||
supplied = _leaf_paths(file_layer) | _leaf_paths(command_layer)
|
||||
|
||||
source_hashes = {name: _hash_bytes(content.encode("utf-8")) for name, content in sources.items()}
|
||||
root_hash = _hash_bytes(str(project_root).encode("utf-8"))[:12]
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", project_root.name.lower()).strip("-") or "project"
|
||||
slug = slug[:80].rstrip("-") or "project"
|
||||
namespace = project_root / "_bmad" / "render" / skill_dir.name / f"{slug}-{root_hash}"
|
||||
|
||||
def render_pass(destination: Path) -> tuple[_RenderContext, dict[str, str]]:
|
||||
context = _RenderContext(
|
||||
central=central,
|
||||
defaults=defaults,
|
||||
customization=customization,
|
||||
source_names=set(sources),
|
||||
project_root=project_root,
|
||||
destination=destination,
|
||||
)
|
||||
return context, _render_sources(sources, skill_dir, context)
|
||||
|
||||
# The generation path is keyed by the values the templates reach, and the
|
||||
# templates insert that path, so a first pass against a placeholder
|
||||
# destination collects the inputs and the real pass renders the output.
|
||||
probe, _ = render_pass(namespace / "pending")
|
||||
# An override may only name a key some template actually read; values are validated where consumed.
|
||||
unused = sorted(path for path in supplied if f"customization.{path}" not in probe.inputs)
|
||||
if unused:
|
||||
raise RenderError(f"invocation override not used by this render: {', '.join(unused)}")
|
||||
# Store TOML date/time inputs in the same JSON representation used on disk.
|
||||
input_values = json.loads(_canonical_json(probe.inputs))
|
||||
renderer_hash = _hash_bytes(Path(__file__).read_bytes())
|
||||
identity = {
|
||||
"project_root": str(project_root),
|
||||
"skill_root": str(skill_dir),
|
||||
"renderer_sha256": renderer_hash,
|
||||
"jinja2_version": jinja2.__version__,
|
||||
"resolved_values": input_values,
|
||||
"source_sha256": source_hashes,
|
||||
}
|
||||
generation_hash = _hash_bytes(_canonical_json(identity))[:20]
|
||||
destination = namespace / generation_hash
|
||||
_, rendered = render_pass(destination)
|
||||
outputs = {name: content.encode("utf-8") for name, content in rendered.items()}
|
||||
output_hashes = {name: _hash_bytes(content) for name, content in outputs.items()}
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"skill": skill_dir.name,
|
||||
"project_root": str(project_root),
|
||||
"project_slug": slug,
|
||||
"root_hash": root_hash,
|
||||
"generation_hash": generation_hash,
|
||||
"inputs": identity,
|
||||
"outputs": output_hashes,
|
||||
}
|
||||
_publish(destination, outputs, manifest)
|
||||
return destination / "workflow.md"
|
||||
|
||||
|
||||
def report_owed_setup(skill_dir: Path, project_root: Path) -> None:
|
||||
# Runs before rendering so the note lands ahead of the instruction to follow,
|
||||
# and still shows when rendering halts. It must never fail the render.
|
||||
try:
|
||||
import setup_check
|
||||
except Exception:
|
||||
return
|
||||
setup_check.report(skill_dir, project_root)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = _ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project-root", required=True)
|
||||
parser.add_argument("--skill", required=True)
|
||||
parser.add_argument("--overrides", type=Path, help="invocation-only customization TOML file")
|
||||
parser.add_argument("--set", dest="assignments", action="append", default=[], metavar="KEY=VALUE")
|
||||
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(encoding="utf-8")
|
||||
try:
|
||||
args = parser.parse_args()
|
||||
report_owed_setup(Path(args.skill).resolve(), Path(args.project_root).resolve())
|
||||
entry = render(
|
||||
Path(args.project_root), Path(args.skill), overrides=args.overrides, assignments=args.assignments
|
||||
)
|
||||
except (ConfigError, RenderError, OSError, UnicodeError, ValueError) as error:
|
||||
sys.stdout.write(f"HALT: {' '.join(str(error).splitlines())}\n")
|
||||
return 1
|
||||
sys.stdout.write(f"read and follow {entry}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Resolve BMad's four central TOML layers to JSON."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Installed scripts are consumer files, not a location for interpreter caches.
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
try:
|
||||
from config_utils import ConfigError, load_central_config
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "tomllib":
|
||||
raise
|
||||
sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib` not found).\n")
|
||||
raise SystemExit(3) from None
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def extract_key(data, dotted_key: str):
|
||||
current = data
|
||||
for part in dotted_key.split("."):
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return _MISSING
|
||||
return current
|
||||
|
||||
|
||||
def write_json_stdout(output) -> None:
|
||||
"""Pin stdout to UTF-8 — a Windows cp1252 default cannot encode emoji icons."""
|
||||
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(encoding="utf-8")
|
||||
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Resolve BMad central config using four-layer TOML merge.")
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
"-p",
|
||||
required=True,
|
||||
help="Absolute project root containing _bmad/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
"-k",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Dotted field path to resolve (repeatable). Omit for full dump.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
merged = load_central_config(Path(args.project_root).resolve())
|
||||
except ConfigError as error:
|
||||
sys.stderr.write(f"error: {error}\n")
|
||||
return 1
|
||||
|
||||
output = merged
|
||||
if args.key:
|
||||
output = {}
|
||||
for key in args.key:
|
||||
value = extract_key(merged, key)
|
||||
if value is not _MISSING:
|
||||
output[key] = value
|
||||
write_json_stdout(output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Resolve a skill's default, team, and user TOML customization layers."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Installed scripts are consumer files, not a location for interpreter caches.
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
try:
|
||||
from config_utils import ConfigError, load_customization
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "tomllib":
|
||||
raise
|
||||
sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib` not found).\n")
|
||||
raise SystemExit(3) from None
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def find_project_root(start: Path) -> Path | None:
|
||||
"""Nearest ancestor holding `_bmad/`, falling back to the nearest holding `.git`.
|
||||
|
||||
`_bmad/` outranks `.git` at every depth: a submodule or nested repo carries
|
||||
`.git` without being the BMad project, so treating the two as equal stops the
|
||||
walk short of the root that owns `_bmad/custom/`.
|
||||
"""
|
||||
git_root: Path | None = None
|
||||
current = start.resolve()
|
||||
while True:
|
||||
if (current / "_bmad").is_dir():
|
||||
return current
|
||||
if git_root is None and (current / ".git").exists():
|
||||
git_root = current
|
||||
if current.parent == current:
|
||||
return git_root
|
||||
current = current.parent
|
||||
|
||||
|
||||
def script_project_root() -> Path | None:
|
||||
"""Project root implied by this script's own install path.
|
||||
|
||||
Skills invoke `{project-root}/_bmad/scripts/resolve_customization.py`, so when
|
||||
this file sits at that path its grandparent is a project root the caller already
|
||||
resolved.
|
||||
"""
|
||||
parents = Path(__file__).resolve().parents
|
||||
if len(parents) >= 3 and parents[0].name == "scripts" and parents[1].name == "_bmad":
|
||||
return parents[2]
|
||||
return None
|
||||
|
||||
|
||||
def candidate_project_roots(skill_dir: Path) -> list[Path]:
|
||||
"""Plausible project roots, most trustworthy first.
|
||||
|
||||
The working directory leads because the project is where the user is working,
|
||||
not where the skill happens to be installed — a home-installed skill walks up to
|
||||
`~`, and any `~/_bmad` there would otherwise mask the real project's overrides.
|
||||
"""
|
||||
ordered: list[Path] = []
|
||||
for root in (
|
||||
find_project_root(Path.cwd()),
|
||||
script_project_root(),
|
||||
find_project_root(skill_dir),
|
||||
):
|
||||
if root is not None and root not in ordered:
|
||||
ordered.append(root)
|
||||
return ordered
|
||||
|
||||
|
||||
def has_override(root: Path, skill_name: str) -> bool:
|
||||
custom_dir = root / "_bmad" / "custom"
|
||||
return any((custom_dir / name).is_file() for name in (f"{skill_name}.toml", f"{skill_name}.user.toml"))
|
||||
|
||||
|
||||
def warn_on_masked_override(chosen: Path, rejected: list[Path], skill_name: str) -> None:
|
||||
"""Break the silence when a real override exists under a root we did not pick."""
|
||||
if has_override(chosen, skill_name):
|
||||
return
|
||||
for root in rejected:
|
||||
if has_override(root, skill_name):
|
||||
sys.stderr.write(
|
||||
f"note: resolved project root {chosen} has no customization for "
|
||||
f"`{skill_name}`, but {root} does. Using {chosen}; pass "
|
||||
f"--project-root to select the other explicitly.\n"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def extract_key(data, dotted_key: str):
|
||||
current = data
|
||||
for part in dotted_key.split("."):
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return _MISSING
|
||||
return current
|
||||
|
||||
|
||||
def write_json_stdout(output) -> None:
|
||||
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(encoding="utf-8")
|
||||
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Resolve skill customization using three-layer TOML merge.")
|
||||
parser.add_argument("--skill", "-s", required=True, help="Absolute path to the skill directory")
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
"-p",
|
||||
help="Explicit project root containing _bmad/ (recommended)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
"-k",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Dotted field path to resolve (repeatable). Omit for full dump.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
skill_dir = Path(args.skill).resolve()
|
||||
if args.project_root:
|
||||
project_root = Path(args.project_root).resolve()
|
||||
else:
|
||||
candidates = candidate_project_roots(skill_dir)
|
||||
project_root = candidates[0] if candidates else None
|
||||
if project_root is not None:
|
||||
warn_on_masked_override(project_root, candidates[1:], skill_dir.name)
|
||||
|
||||
try:
|
||||
merged = load_customization(project_root, skill_dir)
|
||||
except ConfigError as error:
|
||||
sys.stderr.write(f"error: {error}\n")
|
||||
return 1
|
||||
|
||||
output = merged
|
||||
if args.key:
|
||||
output = {}
|
||||
for key in args.key:
|
||||
value = extract_key(merged, key)
|
||||
if value is not _MISSING:
|
||||
output[key] = value
|
||||
write_json_stdout(output)
|
||||
report_owed_setup(skill_dir, project_root)
|
||||
return 0
|
||||
|
||||
|
||||
def report_owed_setup(skill_dir: Path, project_root: Path | None) -> None:
|
||||
# The resolver is the first thing every skill runs, so it is where a skill
|
||||
# learns that setup owes it something. It must never fail the resolve.
|
||||
try:
|
||||
import setup_check
|
||||
except Exception:
|
||||
return
|
||||
setup_check.report(skill_dir, project_root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Report the people and groups the installed modules offer.
|
||||
|
||||
A module's roster is `roster.toml` beside its `bmod.toml`, in the module
|
||||
record's folder. It lists members and the groups they form. Nothing is
|
||||
recorded under `_bmad`: the roster is whatever the installed modules offer
|
||||
right now, so adding or removing a skill changes it with no setup step.
|
||||
|
||||
A member with `skill` is an agent, present only while that skill is installed;
|
||||
its name, title and icon follow the skill's customization. A member without
|
||||
`skill` is a guest, available to groups and never part of the default room.
|
||||
`[agents.<code>]` tables in the central config still apply on top, so a user's
|
||||
own agents and overrides keep working.
|
||||
|
||||
Usage:
|
||||
uv run roster.py --skill <any installed skill> [--project-root P] [--root R ...]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
from config_utils import ConfigError, load_central_config, load_customization # noqa: E402
|
||||
from knowledge import ROSTER_NAME, Module, install_command, read_document, scan # noqa: E402
|
||||
|
||||
MEMBER_FIELDS = ("name", "icon", "title", "persona", "capabilities", "model")
|
||||
AGENT_FIELDS = ("name", "icon", "title")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Report the members and groups the installed skills offer.")
|
||||
parser.add_argument("--skill", type=Path, help="an installed skill; the skills beside it are scanned")
|
||||
parser.add_argument("--root", type=Path, action="append", default=[], help="a further skills root to scan")
|
||||
parser.add_argument("--project-root", type=Path, help="project root holding _bmad/, for customization")
|
||||
args = parser.parse_args(argv)
|
||||
roots = ([args.skill.resolve().parent] if args.skill else []) + args.root
|
||||
if not roots:
|
||||
parser.error("give --skill or --root")
|
||||
report = collect(roots, args.project_root.resolve() if args.project_root else None)
|
||||
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(encoding="utf-8")
|
||||
sys.stdout.write(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
def collect(roots: list[Path], project_root: Path | None = None) -> dict[str, object]:
|
||||
found = scan(roots)
|
||||
problems = found.problems
|
||||
skills = found.folders
|
||||
files: dict[tuple[str, str], dict[str, object]] = {}
|
||||
|
||||
for module in found.modules:
|
||||
if (module.folder / ROSTER_NAME).exists():
|
||||
record_file(files, problems, module)
|
||||
|
||||
members: dict[str, dict[str, object]] = {}
|
||||
groups: dict[str, dict[str, object]] = {}
|
||||
for (code, path), file in sorted(files.items()):
|
||||
source = file["module"].table.get("update_source")
|
||||
for member in listed(file["data"], "members", problems, code, path):
|
||||
add_member(members, problems, member, code, path, source, skills, project_root)
|
||||
for group in listed(file["data"], "groups", problems, code, path):
|
||||
add_group(groups, problems, group, code, path)
|
||||
|
||||
agents = {code: member for code, member in members.items() if member.get("installed")}
|
||||
apply_central_agents(agents, members, problems, project_root)
|
||||
|
||||
return {
|
||||
"agents": agents,
|
||||
"members": members,
|
||||
"groups": list(groups.values()),
|
||||
"rosters": [
|
||||
{"module": code, "path": path, "skills": [name for name in file["module"].skills if name in skills]}
|
||||
for (code, path), file in sorted(files.items())
|
||||
],
|
||||
"problems": problems,
|
||||
}
|
||||
|
||||
|
||||
def listed(data: dict[str, object], key: str, problems: list[dict[str, object]], module: str, path: str) -> list:
|
||||
found = data.get(key, [])
|
||||
if isinstance(found, list):
|
||||
return found
|
||||
problems.append({"kind": "roster", "problem": f"{module} {path}: '{key}' is not a list"})
|
||||
return []
|
||||
|
||||
|
||||
def record_file(
|
||||
files: dict[tuple[str, str], dict[str, object]], problems: list[dict[str, object]], module: Module
|
||||
) -> None:
|
||||
folder = module.folder
|
||||
path = folder / ROSTER_NAME
|
||||
try:
|
||||
data = tomllib.loads(read_document(path, folder).decode("utf-8"))
|
||||
except (OSError, ValueError, UnicodeError, tomllib.TOMLDecodeError) as error:
|
||||
problems.append({"kind": "roster", "skill": folder.name, "problem": f"{path}: {error}"})
|
||||
return
|
||||
files.setdefault((module.code, ROSTER_NAME), {"module": module, "data": data})
|
||||
|
||||
|
||||
def add_member(
|
||||
members: dict[str, dict[str, object]],
|
||||
problems: list[dict[str, object]],
|
||||
member: object,
|
||||
module: str,
|
||||
path: str,
|
||||
source: object,
|
||||
skills: dict[str, Path],
|
||||
project_root: Path | None,
|
||||
) -> None:
|
||||
code = member.get("code") if isinstance(member, dict) else None
|
||||
if not isinstance(code, str) or not code:
|
||||
problems.append({"kind": "member", "problem": f"{module} {path}: a member has no code"})
|
||||
return
|
||||
if code in members:
|
||||
problems.append(
|
||||
{
|
||||
"kind": "member",
|
||||
"problem": f"{module} {path}: member {code!r} is already defined by {members[code]['module']}",
|
||||
}
|
||||
)
|
||||
return
|
||||
entry: dict[str, object] = {"code": code, "module": module, "source": "roster"}
|
||||
for field in MEMBER_FIELDS:
|
||||
if isinstance(member.get(field), str):
|
||||
entry[field] = member[field]
|
||||
skill = member.get("skill")
|
||||
if isinstance(skill, str) and skill:
|
||||
entry["skill"] = skill
|
||||
entry["installed"] = skill in skills
|
||||
if entry["installed"]:
|
||||
entry.update(agent_identity(skills[skill], project_root))
|
||||
else:
|
||||
command = install_command(source, skill)
|
||||
if command:
|
||||
entry["install"] = command
|
||||
entry.setdefault("name", code)
|
||||
members[code] = entry
|
||||
|
||||
|
||||
def agent_identity(skill_dir: Path, project_root: Path | None) -> dict[str, str]:
|
||||
"""The name, title and icon the agent actually answers to, after any customization."""
|
||||
try:
|
||||
agent = load_customization(project_root, skill_dir).get("agent", {})
|
||||
except (ConfigError, OSError):
|
||||
return {}
|
||||
if not isinstance(agent, dict):
|
||||
return {}
|
||||
return {field: agent[field] for field in AGENT_FIELDS if isinstance(agent.get(field), str) and agent[field]}
|
||||
|
||||
|
||||
def add_group(
|
||||
groups: dict[str, dict[str, object]], problems: list[dict[str, object]], group: object, module: str, path: str
|
||||
) -> None:
|
||||
group_id = group.get("id") if isinstance(group, dict) else None
|
||||
if not isinstance(group_id, str) or not group_id:
|
||||
problems.append({"kind": "group", "problem": f"{module} {path}: a group has no id"})
|
||||
return
|
||||
if group_id in groups:
|
||||
problems.append(
|
||||
{
|
||||
"kind": "group",
|
||||
"problem": f"{module} {path}: group {group_id!r} is already defined by {groups[group_id]['module']}",
|
||||
}
|
||||
)
|
||||
return
|
||||
groups[group_id] = {**group, "module": module}
|
||||
|
||||
|
||||
def apply_central_agents(
|
||||
agents: dict[str, dict[str, object]],
|
||||
members: dict[str, dict[str, object]],
|
||||
problems: list[dict[str, object]],
|
||||
project_root: Path | None,
|
||||
) -> None:
|
||||
"""Lay the central config's [agents.<code>] tables over the scan.
|
||||
|
||||
This is how a user adds an agent of their own or describes one further, and
|
||||
how an install made before rosters existed keeps the agents it recorded.
|
||||
An entry for a roster agent whose skill is gone is skipped: the old
|
||||
installer recorded it and nothing removed it when the skill went. For a
|
||||
roster agent the roster and the skill's customization decide name, title,
|
||||
icon and module, so a recorded default never undoes a customized name.
|
||||
"""
|
||||
if project_root is None or not (project_root / "_bmad").is_dir():
|
||||
return
|
||||
try:
|
||||
configured = load_central_config(project_root).get("agents", {})
|
||||
except (ConfigError, OSError) as error:
|
||||
problems.append({"kind": "config", "problem": str(error)})
|
||||
return
|
||||
if not isinstance(configured, dict):
|
||||
return
|
||||
for code, info in configured.items():
|
||||
if not isinstance(info, dict) or members.get(code, {}).get("installed") is False:
|
||||
continue
|
||||
entry = agents.setdefault(code, {"code": code, "source": "config"})
|
||||
settled = (
|
||||
{"module", *(field for field in AGENT_FIELDS if field in entry)} if entry["source"] == "roster" else set()
|
||||
)
|
||||
for field, value in info.items():
|
||||
if field in settled:
|
||||
continue
|
||||
# Older installs recorded the persona paragraph as `description`.
|
||||
target = "persona" if field == "description" and "persona" not in info else field
|
||||
entry[target] = value
|
||||
entry.setdefault("name", code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Tell a starting skill what setup still owes it.
|
||||
|
||||
A skill can be installed after the last `bmad setup`, without its module
|
||||
record, or need a newer hub than the one present. Nothing is recorded to detect
|
||||
that: everything here is read from the skill's own `bmod.toml`, its module's
|
||||
`bmod.toml` beside it, the skills installed beside it, and `_bmad/`.
|
||||
|
||||
Only what stops a skill from working well is reported, because this runs every
|
||||
time a skill starts. Recommended skills belong to setup, status, and help.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
MANIFEST_NAME = "bmod.toml"
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def install_command(source: str, skill: str) -> str:
|
||||
import setup as hub
|
||||
|
||||
command = hub.install_command(source, skill)
|
||||
return f"`{command}`" if command is not None else f"`{skill}` from {source}"
|
||||
|
||||
|
||||
def owed(skill_dir: Path, project_root: Path | None) -> list[str]:
|
||||
"""Plain sentences for the agent to relay; empty when nothing is owed."""
|
||||
import setup as hub
|
||||
from config_utils import ConfigError, load_central_config
|
||||
|
||||
parsed = hub.read_bmod_file(skill_dir / MANIFEST_NAME)
|
||||
if parsed is None or parsed.skill is None:
|
||||
return []
|
||||
skill = parsed.skill
|
||||
notes: list[str] = []
|
||||
|
||||
record = parsed.bmod
|
||||
if record is None:
|
||||
other = hub.read_bmod_file(skill_dir.parent / skill.bmod / MANIFEST_NAME)
|
||||
record = other.bmod if other is not None else None
|
||||
if record is None:
|
||||
notes.append(
|
||||
f"belongs to a module whose record `{skill.bmod}` is not installed beside it. "
|
||||
f"Offer to install it with {install_command(skill.source, skill.bmod)}."
|
||||
)
|
||||
|
||||
own_source = skill.source if skill.source is not None else record.update_source if record else ""
|
||||
declared = [(requirement, record.update_source) for requirement in (record.required_skills if record else ())]
|
||||
declared += [(requirement, own_source) for requirement in skill.required_skills]
|
||||
# A skill named by both lists is checked once, against the higher minimum.
|
||||
wanted: dict[str, tuple] = {}
|
||||
for requirement, default_source in declared:
|
||||
if requirement.skill == skill_dir.name:
|
||||
continue
|
||||
kept = wanted.get(requirement.skill)
|
||||
if kept is None or (
|
||||
requirement.version is not None
|
||||
and (kept[0].version is None or (hub.compare_semver(requirement.version, kept[0].version) or 0) > 0)
|
||||
):
|
||||
wanted[requirement.skill] = (requirement, default_source)
|
||||
for requirement, default_source in wanted.values():
|
||||
state, installed = hub.requirement_check(skill_dir.parent, requirement)
|
||||
if state == "missing":
|
||||
source = requirement.source or default_source
|
||||
notes.append(
|
||||
f"needs the `{requirement.skill}` skill, which is not installed beside it. "
|
||||
f"If you have no `{requirement.skill}` skill, offer to install it with "
|
||||
f"{install_command(source, requirement.skill)}."
|
||||
)
|
||||
elif state == "outdated":
|
||||
notes.append(
|
||||
f"needs `{requirement.skill}` {requirement.version} or later, and {installed} is installed. "
|
||||
"Offer to run `npx skills update`."
|
||||
)
|
||||
elif state == "unknown-version":
|
||||
notes.append(
|
||||
f"needs `{requirement.skill}` {requirement.version} or later, and the installed copy's version "
|
||||
"cannot be read. Offer to run `npx skills update`."
|
||||
)
|
||||
|
||||
if record is None or project_root is None or not (project_root / "_bmad").is_dir():
|
||||
return notes
|
||||
|
||||
if record.questions:
|
||||
try:
|
||||
config = load_central_config(project_root)
|
||||
except ConfigError:
|
||||
config = None
|
||||
if config is not None:
|
||||
unanswered = [
|
||||
question.key
|
||||
for question in record.questions
|
||||
if lookup(config, ("modules", question.module, *question.key.split("."))) is _MISSING
|
||||
]
|
||||
if unanswered:
|
||||
notes.append(
|
||||
f"belongs to module `{record.code}`, whose setup questions were never answered "
|
||||
f"({', '.join(unanswered)}). Offer to run `bmad setup`."
|
||||
)
|
||||
|
||||
installed_scripts = project_root / "_bmad" / record.code / "scripts"
|
||||
for relative in skill.scripts:
|
||||
packaged = skill_dir.joinpath(*relative.parts)
|
||||
placed = installed_scripts.joinpath(*relative.parts[1:])
|
||||
if not placed.is_file() or (packaged.is_file() and placed.read_bytes() != packaged.read_bytes()):
|
||||
notes.append(
|
||||
f"belongs to module `{record.code}`, whose scripts in `_bmad/{record.code}/scripts/` are "
|
||||
"missing or out of date. Offer to run `bmad setup`."
|
||||
)
|
||||
break
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
def lookup(data: object, keys: tuple[str, ...]) -> object:
|
||||
current = data
|
||||
for key in keys:
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
return _MISSING
|
||||
current = current[key]
|
||||
return current
|
||||
|
||||
|
||||
def report(skill_dir: Path, project_root: Path | None) -> None:
|
||||
"""Write what is owed to stderr, worded as an instruction so no skill has to explain it.
|
||||
|
||||
A failure here must never stop the skill from resolving.
|
||||
"""
|
||||
try:
|
||||
notes = owed(skill_dir, project_root)
|
||||
except Exception:
|
||||
return
|
||||
for note in notes:
|
||||
sys.stderr.write(f"setup: before continuing, tell the user that `{skill_dir.name}` {note}\n")
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Release version stamper for module repositories.
|
||||
|
||||
Writes a human-supplied SemVer version into every module record: the
|
||||
`version` line inside the `[bmod]` table of each skills/*/bmod.toml that has
|
||||
one. Member skills carry no version and are not written. Each module
|
||||
repository's release runbook uses it to stamp releases and the next
|
||||
placeholder on `dev`.
|
||||
|
||||
Before writing anything it runs the repository checks in
|
||||
validate_manifests.py beside it, the same ones pre-commit and CI run.
|
||||
`--check` runs only those checks and writes nothing.
|
||||
|
||||
A file may carry keys and tables this script does not know. The runtime
|
||||
ignores them, so a release must not refuse them; they are left exactly as
|
||||
written. The version line is rewritten textually, so nothing else in a file
|
||||
moves, and each new file is parsed and compared before anything is written.
|
||||
|
||||
After writing, the script re-reads every file and fails naming the offending
|
||||
path if anything is off.
|
||||
|
||||
Usage:
|
||||
uv run --python 3.11 skills/bmad/scripts/stamp_release.py 1.2.0 [--project-root <path>]
|
||||
uv run --python 3.11 skills/bmad/scripts/stamp_release.py --check [--project-root <path>]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
|
||||
class StampError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_validator():
|
||||
"""The repository checks, so a release and a commit can never be held to different rules."""
|
||||
path = Path(__file__).resolve().parent / "validate_manifests.py"
|
||||
spec = importlib.util.spec_from_file_location("bmad_validate_for_stamp", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise StampError(f"cannot load the repository checks at {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
validator = load_validator()
|
||||
setup = validator.setup
|
||||
|
||||
|
||||
def validate_version(version: str) -> None:
|
||||
problem = validator.version_problem(version)
|
||||
if problem is not None:
|
||||
raise StampError(problem)
|
||||
|
||||
|
||||
def collect_records(project_root: Path) -> list[Path]:
|
||||
report = validator.check_repo(project_root)
|
||||
if report.problems:
|
||||
raise StampError("\n ".join(report.problems))
|
||||
return list(report.records)
|
||||
|
||||
|
||||
def read_toml(path: Path, rel: str) -> tuple[str, dict[str, object]]:
|
||||
try:
|
||||
text = path.read_bytes().decode("utf-8")
|
||||
return text, tomllib.loads(text)
|
||||
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
|
||||
raise StampError(f"{rel}: cannot read bmod file: {error}") from error
|
||||
|
||||
|
||||
def verify_stamp(root: Path, records: list[Path], expected: dict[str, dict[str, object]]) -> None:
|
||||
for record in records:
|
||||
rel = record.relative_to(root).as_posix()
|
||||
_, data = read_toml(record, rel)
|
||||
if data != expected[rel]:
|
||||
raise StampError(f"{rel}: stamping changed something other than the version")
|
||||
collect_records(root)
|
||||
|
||||
|
||||
def run(project_root: Path, version: str) -> int:
|
||||
try:
|
||||
validate_version(version)
|
||||
records = collect_records(project_root)
|
||||
|
||||
# Nothing is written if any file fails.
|
||||
planned: list[tuple[Path, str]] = []
|
||||
expected: dict[str, dict[str, object]] = {}
|
||||
for record in records:
|
||||
rel = record.relative_to(project_root).as_posix()
|
||||
original, data = read_toml(record, rel)
|
||||
try:
|
||||
planned.append((record, validator.stamp_text(original, version)))
|
||||
except ValueError as error:
|
||||
raise StampError(f"{rel}: {error}") from error
|
||||
expected[rel] = validator.with_version(data, version)
|
||||
|
||||
for path, content in planned:
|
||||
try:
|
||||
path.write_bytes(content.encode("utf-8"))
|
||||
except OSError as error:
|
||||
raise StampError(f"{path.relative_to(project_root).as_posix()}: cannot write: {error}") from error
|
||||
verify_stamp(project_root, records, expected)
|
||||
except StampError as error:
|
||||
print(f"Error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Stamped version {version} into {len(planned)} files:")
|
||||
for path, _ in planned:
|
||||
print(f" {path.relative_to(project_root).as_posix()}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Stamp a version into every module record.")
|
||||
parser.add_argument("version", nargs="?", help='SemVer release version, e.g. "6.12.0"')
|
||||
parser.add_argument("--check", action="store_true", help="run the repository checks and write nothing")
|
||||
parser.add_argument(
|
||||
"--project-root", type=Path, default=Path.cwd(), help="repository to stamp (default: the current directory)"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.check == (args.version is not None):
|
||||
parser.error("give a version to stamp, or --check, but not both")
|
||||
if args.check:
|
||||
return validator.main(["--project-root", str(args.project_root)])
|
||||
return run(args.project_root.resolve(), args.version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,90 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from config_utils import ( # noqa: E402
|
||||
ConfigError,
|
||||
load_central_config,
|
||||
load_customization,
|
||||
load_toml,
|
||||
structural_merge,
|
||||
)
|
||||
|
||||
|
||||
class ConfigUtilsTests(unittest.TestCase):
|
||||
def test_structural_merge_recurses_appends_and_replaces_keyed_tables(self):
|
||||
base = {
|
||||
"nested": {"keep": True, "replace": "old"},
|
||||
"plain": ["base"],
|
||||
"items": [{"id": "one", "value": "old"}],
|
||||
}
|
||||
override = {
|
||||
"nested": {"replace": "new"},
|
||||
"plain": ["override"],
|
||||
"items": [
|
||||
{"id": "one", "value": "new"},
|
||||
{"id": "two", "value": "added"},
|
||||
],
|
||||
}
|
||||
|
||||
merged = structural_merge(base, override)
|
||||
|
||||
self.assertEqual(merged["nested"], {"keep": True, "replace": "new"})
|
||||
self.assertEqual(merged["plain"], ["base", "override"])
|
||||
self.assertEqual(
|
||||
merged["items"],
|
||||
[
|
||||
{"id": "one", "value": "new"},
|
||||
{"id": "two", "value": "added"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_non_string_keyed_identifier_is_rejected(self):
|
||||
with self.assertRaisesRegex(ConfigError, "identifier `id` must be a string"):
|
||||
structural_merge([{"id": "valid"}], [{"id": 42}])
|
||||
|
||||
def test_present_malformed_optional_layer_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "optional.toml"
|
||||
path.write_text("[broken\n", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ConfigError, "failed to parse"):
|
||||
load_toml(path)
|
||||
|
||||
def test_missing_optional_layer_is_empty(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "optional.toml"
|
||||
|
||||
self.assertEqual(load_toml(path), {})
|
||||
|
||||
def test_filesystem_layer_precedence(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
bmad = root / "_bmad"
|
||||
custom = bmad / "custom"
|
||||
skill = bmad / "bmm" / "sample-skill"
|
||||
custom.mkdir(parents=True)
|
||||
skill.mkdir(parents=True)
|
||||
(bmad / "config.toml").write_text('[value]\norder = "base-team"\n', encoding="utf-8")
|
||||
(bmad / "config.user.toml").write_text(
|
||||
'[value]\norder = "base-user"\nstray = "ignored"\n', encoding="utf-8"
|
||||
)
|
||||
(custom / "config.toml").write_text('[value]\norder = "custom-team"\n', encoding="utf-8")
|
||||
(custom / "config.user.toml").write_text('[value]\norder = "custom-user"\n', encoding="utf-8")
|
||||
(skill / "customize.toml").write_text('[value]\norder = "default"\n', encoding="utf-8")
|
||||
(custom / "sample-skill.toml").write_text('[value]\norder = "team"\n', encoding="utf-8")
|
||||
(custom / "sample-skill.user.toml").write_text('[value]\norder = "user"\n', encoding="utf-8")
|
||||
|
||||
merged = load_central_config(root)
|
||||
self.assertEqual(merged["value"]["order"], "custom-user")
|
||||
# _bmad/config.user.toml is old-installer debris (setup.py's
|
||||
# LEGACY_LEFTOVERS), not a layer. Nothing writes it; nothing reads it.
|
||||
self.assertNotIn("stray", merged["value"])
|
||||
self.assertEqual(load_customization(root, skill)["value"]["order"], "user")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,151 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import knowledge # noqa: E402
|
||||
|
||||
SOURCE = "github:acme/tools/skills"
|
||||
MIGRATION = """
|
||||
[migration]
|
||||
module = "demo"
|
||||
from = "1"
|
||||
to = "2"
|
||||
title = "Move demo artifacts to the v2 layout"
|
||||
summary = "v1 to v2"
|
||||
detect = "a v1 folder"
|
||||
guide = "move it"
|
||||
checklist = ["it moved"]
|
||||
"""
|
||||
|
||||
|
||||
def write_module(root: Path, skills: tuple[str, ...] = (), *, code: str = "demo") -> Path:
|
||||
record = root / f"bmod-{code}"
|
||||
record.mkdir(parents=True)
|
||||
names = ", ".join(f'"{name}"' for name in skills)
|
||||
lines = ["[bmod]", f'code = "{code}"', 'version = "1.0.0"', f'update_source = "{SOURCE}"', f"skills = [{names}]"]
|
||||
(record / "bmod.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
for name in skills:
|
||||
folder = root / name
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "bmod.toml").write_text(f'[skill]\nbmod = "{record.name}"\nsource = "{SOURCE}"\n', encoding="utf-8")
|
||||
return record
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.skills = Path(self.temp.name) / ".claude" / "skills"
|
||||
|
||||
def test_a_migration_file_is_listed_by_its_table_without_its_text(self):
|
||||
record = write_module(self.skills, ("demo-one",))
|
||||
(record / "migration-1.toml").write_text(MIGRATION, encoding="utf-8")
|
||||
report = knowledge.collect([self.skills])
|
||||
self.assertEqual(
|
||||
report["migrations"],
|
||||
[
|
||||
{
|
||||
"module": "demo",
|
||||
"path": "migration-1.toml",
|
||||
"file": str(record / "migration-1.toml"),
|
||||
"from": "1",
|
||||
"to": "2",
|
||||
"title": "Move demo artifacts to the v2 layout",
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(report["problems"], [])
|
||||
|
||||
def test_other_toml_files_beside_the_record_are_not_migrations_and_a_bad_key_is_named(self):
|
||||
record = write_module(self.skills)
|
||||
(record / "roster.toml").write_text("[[members]\nnot even toml\n", encoding="utf-8")
|
||||
(record / "notes.toml").write_text('migration = "not a table"\n', encoding="utf-8")
|
||||
report = knowledge.collect([self.skills])
|
||||
self.assertEqual(report["migrations"], [])
|
||||
self.assertEqual([problem["kind"] for problem in report["problems"]], ["migration"])
|
||||
self.assertIn("'migration' is not a table", report["problems"][0]["problem"])
|
||||
|
||||
def test_a_migration_without_its_fields_is_a_problem_not_a_listing(self):
|
||||
record = write_module(self.skills)
|
||||
(record / "migration-2.toml").write_text('[migration]\nmodule = "demo"\nfrom = 1\nto = "2"\n', encoding="utf-8")
|
||||
report = knowledge.collect([self.skills])
|
||||
self.assertEqual(report["migrations"], [])
|
||||
self.assertEqual(len(report["problems"]), 1)
|
||||
self.assertEqual(report["problems"][0]["kind"], "migration")
|
||||
self.assertIn(
|
||||
"needs non-empty from, title, summary, detect, guide, checklist", report["problems"][0]["problem"]
|
||||
)
|
||||
|
||||
def test_whitespace_only_fields_and_blank_checklist_items_are_missing(self):
|
||||
record = write_module(self.skills)
|
||||
text = MIGRATION.replace('guide = "move it"', 'guide = " "').replace(
|
||||
'checklist = ["it moved"]', 'checklist = [" "]'
|
||||
)
|
||||
(record / "migration-3.toml").write_text(text, encoding="utf-8")
|
||||
report = knowledge.collect([self.skills])
|
||||
self.assertEqual(report["migrations"], [])
|
||||
self.assertIn("needs non-empty guide, checklist", report["problems"][0]["problem"])
|
||||
|
||||
def test_a_migration_for_another_module_is_a_problem(self):
|
||||
record = write_module(self.skills)
|
||||
text = MIGRATION.replace('module = "demo"', 'module = "other"')
|
||||
(record / "migration-4.toml").write_text(text, encoding="utf-8")
|
||||
report = knowledge.collect([self.skills])
|
||||
self.assertEqual(report["migrations"], [])
|
||||
self.assertIn("is not this record's 'demo'", report["problems"][0]["problem"])
|
||||
|
||||
def test_migrations_are_listed_by_number_not_by_text(self):
|
||||
record = write_module(self.skills)
|
||||
for number in (10, 2, 1):
|
||||
text = MIGRATION.replace('from = "1"', f'from = "{number}"').replace('to = "2"', f'to = "{number + 1}"')
|
||||
(record / f"migration-{number}.toml").write_text(text, encoding="utf-8")
|
||||
report = knowledge.collect([self.skills])
|
||||
self.assertEqual(
|
||||
[m["path"] for m in report["migrations"]], ["migration-1.toml", "migration-2.toml", "migration-10.toml"]
|
||||
)
|
||||
self.assertEqual(report["problems"], [])
|
||||
|
||||
def test_a_migration_outside_the_naming_rule_is_a_problem(self):
|
||||
record = write_module(self.skills)
|
||||
for name in ("v1-v2-migration.toml", "migration-one.toml", "migration-1-2.toml"):
|
||||
(record / name).write_text(MIGRATION, encoding="utf-8")
|
||||
report = knowledge.collect([self.skills])
|
||||
self.assertEqual(report["migrations"], [])
|
||||
self.assertEqual(len(report["problems"]), 3)
|
||||
for problem in report["problems"]:
|
||||
self.assertIn("must be named migration-<n>.toml", problem["problem"])
|
||||
|
||||
def test_two_migrations_of_one_module_with_the_same_number_are_problems(self):
|
||||
record = write_module(self.skills)
|
||||
other = write_module(self.skills, code="other")
|
||||
(record / "migration-1.toml").write_text(MIGRATION, encoding="utf-8")
|
||||
(record / "migration-01.toml").write_text(MIGRATION, encoding="utf-8")
|
||||
(record / "migration-2.toml").write_text(MIGRATION, encoding="utf-8")
|
||||
(other / "migration-1.toml").write_text(
|
||||
MIGRATION.replace('module = "demo"', 'module = "other"'), encoding="utf-8"
|
||||
)
|
||||
report = knowledge.collect([self.skills])
|
||||
self.assertEqual(
|
||||
[(m["module"], m["path"]) for m in report["migrations"]],
|
||||
[("demo", "migration-2.toml"), ("other", "migration-1.toml")],
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(Path(problem["document"]).name for problem in report["problems"]),
|
||||
["migration-01.toml", "migration-1.toml"],
|
||||
)
|
||||
for problem in report["problems"]:
|
||||
self.assertIn("same number", problem["problem"])
|
||||
|
||||
def test_a_migration_that_is_not_toml_is_a_problem(self):
|
||||
record = write_module(self.skills)
|
||||
(record / "migration-5.toml").write_text('[migration\nfrom = "1"\n', encoding="utf-8")
|
||||
report = knowledge.collect([self.skills])
|
||||
self.assertEqual(report["migrations"], [])
|
||||
self.assertEqual([problem["kind"] for problem in report["problems"]], ["migration"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,365 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["pytest>=8.0"]
|
||||
# ///
|
||||
"""Tests for memlog.py. Run: uv run --with pytest pytest scripts/tests/test_memlog.py
|
||||
|
||||
The spine under test is the flat, append-only, chronological invariant: every entry is
|
||||
one line recorded at the end in the order it happened — no sections, no grouping, and no
|
||||
lifecycle status the log would have to mutate.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "memlog.py"
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
import memlog # noqa: E402
|
||||
|
||||
MEMLOG = ".memlog.md"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(tmp_path):
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
def read(ws):
|
||||
return (Path(ws) / MEMLOG).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def body_of(ws):
|
||||
return memlog.split(read(ws))[1]
|
||||
|
||||
|
||||
def entries(ws):
|
||||
return [ln for ln in body_of(ws).splitlines() if ln.startswith("- ")]
|
||||
|
||||
|
||||
def init(ws, **fields):
|
||||
fields = fields or {"topic": "Reinvent the lunchbox", "goal": "ideas for a pitch"}
|
||||
argv = ["init", "--workspace", ws]
|
||||
for k, v in fields.items():
|
||||
argv += ["--field", f"{k}={v}"]
|
||||
assert memlog.main(argv) == 0
|
||||
|
||||
|
||||
def append(ws, text, entry_type=None, by=None):
|
||||
argv = ["append", "--workspace", ws, "--text", text]
|
||||
if entry_type:
|
||||
argv += ["--type", entry_type]
|
||||
if by:
|
||||
argv += ["--by", by]
|
||||
assert memlog.main(argv) == 0
|
||||
|
||||
|
||||
# --- init ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_init_writes_frontmatter_fields(ws):
|
||||
init(ws)
|
||||
meta, body = memlog.split(read(ws))
|
||||
assert meta["topic"] == "Reinvent the lunchbox"
|
||||
assert meta["goal"] == "ideas for a pitch"
|
||||
assert "updated" in meta
|
||||
assert body.strip() == ""
|
||||
|
||||
|
||||
def test_init_has_no_lifecycle_status(ws):
|
||||
# A memory log carries no "status" flag; completion is an appended entry, not frontmatter.
|
||||
init(ws)
|
||||
meta, _ = memlog.split(read(ws))
|
||||
assert "status" not in meta
|
||||
|
||||
|
||||
def test_init_arbitrary_fields(ws):
|
||||
init(ws, topic="T", audience="board")
|
||||
meta, _ = memlog.split(read(ws))
|
||||
assert meta["audience"] == "board"
|
||||
|
||||
|
||||
def test_init_refuses_overwrite(ws):
|
||||
init(ws)
|
||||
assert memlog.main(["init", "--workspace", ws, "--field", "topic=other"]) == 2
|
||||
|
||||
|
||||
def test_init_creates_missing_workspace(tmp_path):
|
||||
nested = str(tmp_path / "a" / "b")
|
||||
assert memlog.main(["init", "--workspace", nested, "--field", "topic=T"]) == 0
|
||||
assert (Path(nested) / MEMLOG).is_file()
|
||||
|
||||
|
||||
def test_init_rejects_malformed_field(ws):
|
||||
assert memlog.main(["init", "--workspace", ws, "--field", "noequals"]) == 2
|
||||
|
||||
|
||||
# --- addressing: --workspace and --path are interchangeable --------------
|
||||
|
||||
|
||||
def test_path_addressing_targets_the_file_directly(tmp_path):
|
||||
target = tmp_path / "run" / ".memlog.md"
|
||||
assert memlog.main(["init", "--path", str(target), "--field", "topic=T"]) == 0
|
||||
assert target.is_file()
|
||||
assert memlog.main(["append", "--path", str(target), "--text", "an idea", "--type", "idea"]) == 0
|
||||
body = memlog.split(target.read_text(encoding="utf-8"))[1]
|
||||
assert "- (idea) an idea" in body
|
||||
|
||||
|
||||
def test_workspace_and_path_resolve_to_same_file(ws):
|
||||
init(ws)
|
||||
via_path = str(Path(ws) / MEMLOG)
|
||||
assert memlog.main(["append", "--path", via_path, "--text", "from path"]) == 0
|
||||
assert memlog.main(["append", "--workspace", ws, "--text", "from workspace"]) == 0
|
||||
assert entries(ws) == ["- from path", "- from workspace"]
|
||||
|
||||
|
||||
def test_target_is_required(ws):
|
||||
with pytest.raises(SystemExit):
|
||||
memlog.main(["append", "--text", "orphan"]) # neither --workspace nor --path
|
||||
|
||||
|
||||
# --- append: flat chronological order is the whole point -----------------
|
||||
|
||||
|
||||
def test_append_lands_at_end_in_order(ws):
|
||||
init(ws)
|
||||
append(ws, "first")
|
||||
append(ws, "second")
|
||||
append(ws, "third")
|
||||
assert entries(ws) == ["- first", "- second", "- third"]
|
||||
|
||||
|
||||
def test_no_sections_or_headings_ever(ws):
|
||||
init(ws)
|
||||
append(ws, "started foo", entry_type="technique")
|
||||
append(ws, "an idea", entry_type="idea")
|
||||
append(ws, "started bar", entry_type="technique")
|
||||
assert "## " not in body_of(ws) # the flat log never grows headings
|
||||
|
||||
|
||||
def test_type_renders_as_inline_tag(ws):
|
||||
init(ws)
|
||||
append(ws, "the earth revolves around the sun", entry_type="idea")
|
||||
append(ws, "how do we handle stampede?", entry_type="question")
|
||||
body = body_of(ws)
|
||||
assert "- (idea) the earth revolves around the sun" in body
|
||||
assert "- (question) how do we handle stampede?" in body
|
||||
|
||||
|
||||
def test_append_without_type_is_plain_note(ws):
|
||||
init(ws)
|
||||
append(ws, "bare entry")
|
||||
assert entries(ws) == ["- bare entry"]
|
||||
|
||||
|
||||
def test_completion_is_an_entry_not_a_status(ws):
|
||||
# The documented way to mark a session done: append it. Frontmatter never gains a status.
|
||||
init(ws)
|
||||
append(ws, "session complete", entry_type="event")
|
||||
meta, _ = memlog.split(read(ws))
|
||||
assert "status" not in meta
|
||||
assert entries(ws)[-1] == "- (event) session complete"
|
||||
|
||||
|
||||
def test_append_collapses_newlines_into_one_line(ws):
|
||||
init(ws)
|
||||
append(ws, "line one\nline two\n spaced out")
|
||||
assert entries(ws) == ["- line one line two spaced out"]
|
||||
|
||||
|
||||
def test_revisited_technique_is_just_a_later_entry(ws):
|
||||
# the user's model: switching techniques is an entry, not a section to return to
|
||||
init(ws)
|
||||
append(ws, "started SCAMPER", entry_type="technique")
|
||||
append(ws, "magnetic latch", entry_type="idea")
|
||||
append(ws, "started Six Hats", entry_type="technique")
|
||||
append(ws, "stale data risk", entry_type="idea")
|
||||
append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again
|
||||
append(ws, "stackable tiers", entry_type="idea")
|
||||
assert entries(ws) == [
|
||||
"- (technique) started SCAMPER",
|
||||
"- (idea) magnetic latch",
|
||||
"- (technique) started Six Hats",
|
||||
"- (idea) stale data risk",
|
||||
"- (technique) started SCAMPER",
|
||||
"- (idea) stackable tiers",
|
||||
]
|
||||
|
||||
|
||||
def test_by_renders_attribution_in_tag(ws):
|
||||
# Creative Partner mode must record whose idea each one was
|
||||
init(ws)
|
||||
append(ws, "magnetic latch lid", entry_type="idea", by="user")
|
||||
append(ws, "lid doubles as a plate", entry_type="idea", by="coach")
|
||||
body = body_of(ws)
|
||||
assert "- (idea by user) magnetic latch lid" in body
|
||||
assert "- (idea by coach) lid doubles as a plate" in body
|
||||
|
||||
|
||||
def test_by_without_type_renders_alone(ws):
|
||||
init(ws)
|
||||
append(ws, "off-the-cuff thought", by="coach")
|
||||
assert entries(ws) == ["- (by coach) off-the-cuff thought"]
|
||||
|
||||
|
||||
def test_heterogeneous_entry_types_coexist(ws):
|
||||
init(ws)
|
||||
append(ws, "an idea", entry_type="idea")
|
||||
append(ws, "an open question", entry_type="question")
|
||||
append(ws, "a decision we made", entry_type="decision")
|
||||
append(ws, "user wants mobile-first", entry_type="direction")
|
||||
body = body_of(ws)
|
||||
for tag in ("(idea)", "(question)", "(decision)", "(direction)"):
|
||||
assert tag in body
|
||||
|
||||
|
||||
def test_free_vocabulary_is_not_enforced(ws):
|
||||
# The tool is neutral: any --type the host skill names renders verbatim.
|
||||
init(ws)
|
||||
append(ws, "a custom kind", entry_type="crack")
|
||||
append(ws, "another", entry_type="lock")
|
||||
body = body_of(ws)
|
||||
assert "- (crack) a custom kind" in body
|
||||
assert "- (lock) another" in body
|
||||
|
||||
|
||||
# --- set: generic descriptive frontmatter, no lifecycle semantics --------
|
||||
|
||||
|
||||
def test_set_adds_field(ws):
|
||||
init(ws)
|
||||
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
|
||||
assert memlog.split(read(ws))[0]["mode"] == "partner"
|
||||
|
||||
|
||||
def test_set_replaces_field(ws):
|
||||
init(ws, topic="T", mode="facilitator")
|
||||
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
|
||||
assert memlog.split(read(ws))[0]["mode"] == "partner"
|
||||
|
||||
|
||||
def test_set_preserves_body(ws):
|
||||
init(ws)
|
||||
append(ws, "keep me", entry_type="idea")
|
||||
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
|
||||
meta, body = memlog.split(read(ws))
|
||||
assert meta["mode"] == "partner"
|
||||
assert "- (idea) keep me" in body
|
||||
|
||||
|
||||
def test_updated_stays_last(ws):
|
||||
init(ws)
|
||||
memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"])
|
||||
meta = memlog.split(read(ws))[0]
|
||||
assert list(meta)[-1] == "updated"
|
||||
|
||||
|
||||
# --- robustness ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_roundtrip_render_is_stable(ws):
|
||||
init(ws)
|
||||
append(ws, "one", entry_type="idea")
|
||||
meta, body = memlog.split(read(ws))
|
||||
assert memlog.split(memlog.render(meta, body)) == (meta, body)
|
||||
|
||||
|
||||
def test_commas_in_field_survive(ws):
|
||||
init(ws, topic="cars, trains, and planes")
|
||||
append(ws, "z", entry_type="idea")
|
||||
meta, _ = memlog.split(read(ws))
|
||||
assert meta["topic"] == "cars, trains, and planes"
|
||||
|
||||
|
||||
def test_triple_dash_in_field_does_not_corrupt_frontmatter(ws):
|
||||
# A `---` inside a value must NOT be read as the closing fence: topic stays intact
|
||||
# and the body never leaks frontmatter text.
|
||||
init(ws, topic="Pricing --- tiers --- and add-ons")
|
||||
append(ws, "an idea", entry_type="idea")
|
||||
meta, body = memlog.split(read(ws))
|
||||
assert meta["topic"] == "Pricing --- tiers --- and add-ons"
|
||||
assert entries(ws) == ["- (idea) an idea"]
|
||||
assert "topic:" not in body # frontmatter never bled into the body
|
||||
|
||||
|
||||
def test_newline_in_field_is_neutralized(ws):
|
||||
# A value carrying a newline can't break the fence on the next round-trip.
|
||||
memlog.main(["init", "--workspace", ws, "--field", "topic=line one\nline two"])
|
||||
append(ws, "x", entry_type="idea")
|
||||
meta, _ = memlog.split(read(ws))
|
||||
assert "\n" not in meta["topic"]
|
||||
|
||||
|
||||
def test_append_emits_json_ack(ws, capsys):
|
||||
init(ws)
|
||||
append(ws, "x", entry_type="idea")
|
||||
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||
assert out["ok"] is True
|
||||
assert out["entries"] == 1
|
||||
assert out["memlog"].endswith(MEMLOG)
|
||||
assert "status" not in out # no lifecycle status
|
||||
assert "section" not in out # sections are gone
|
||||
|
||||
|
||||
def test_ack_entry_count_climbs(ws, capsys):
|
||||
init(ws)
|
||||
append(ws, "a")
|
||||
append(ws, "b")
|
||||
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||
assert out["entries"] == 2
|
||||
|
||||
|
||||
# --- append writes only its own line ------------------------------------
|
||||
|
||||
|
||||
def test_first_entry_after_init(ws):
|
||||
init(ws)
|
||||
append(ws, "first", entry_type="idea")
|
||||
meta, _ = memlog.split(read(ws))
|
||||
assert meta["topic"] == "Reinvent the lunchbox"
|
||||
assert entries(ws) == ["- (idea) first"]
|
||||
|
||||
|
||||
def test_append_does_not_restamp_updated(ws):
|
||||
init(ws)
|
||||
path = Path(ws) / MEMLOG
|
||||
path.write_text(read(ws).replace(memlog.split(read(ws))[0]["updated"], "2000-01-01T00:00"), encoding="utf-8")
|
||||
append(ws, "a")
|
||||
append(ws, "b")
|
||||
assert memlog.split(read(ws))[0]["updated"] == "2000-01-01T00:00"
|
||||
assert entries(ws) == ["- a", "- b"]
|
||||
|
||||
|
||||
def test_append_to_missing_log_fails_and_creates_nothing(ws):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
memlog.main(["append", "--workspace", ws, "--text", "orphan"])
|
||||
assert not (Path(ws) / MEMLOG).exists()
|
||||
|
||||
|
||||
def test_append_after_missing_trailing_newline_starts_new_line(ws):
|
||||
init(ws)
|
||||
append(ws, "first")
|
||||
path = Path(ws) / MEMLOG
|
||||
path.write_text(read(ws).rstrip("\n"), encoding="utf-8")
|
||||
append(ws, "second")
|
||||
assert entries(ws) == ["- first", "- second"]
|
||||
|
||||
|
||||
def test_parallel_appends_all_land(ws):
|
||||
init(ws)
|
||||
n = 40
|
||||
procs = [
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(SCRIPT), "append", "--workspace", ws, "--text", f"entry {i}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
failures = [p.stderr.read().decode() for p in procs if p.wait() != 0]
|
||||
assert failures == []
|
||||
assert sorted(entries(ws)) == sorted(f"- entry {i}" for i in range(n))
|
||||
@@ -0,0 +1,976 @@
|
||||
"""Snapshot renderer tests against the current install layout and shipped skills.
|
||||
|
||||
Host skills live outside `_bmad/`. `_bmad/` is the project runtime setup
|
||||
materializes: shared scripts, team config, custom overlays, and published
|
||||
snapshots. Call `render()` for the success path. Use the installed CLI for
|
||||
the agent-facing dispatch/HALT contract and for anything that needs a
|
||||
separate process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from helpers import chmod
|
||||
|
||||
SCRIPTS_SRC = Path(__file__).resolve().parents[1]
|
||||
REPO = SCRIPTS_SRC.parents[2]
|
||||
SKILLS_SRC = REPO / "skills"
|
||||
CONFIG_TEMPLATE = (SCRIPTS_SRC.parent / "assets" / "config.template.toml").read_text(encoding="utf-8")
|
||||
SHARED_SCRIPTS = (
|
||||
"config_utils.py",
|
||||
"memlog.py",
|
||||
"render_skill.py",
|
||||
"resolve_config.py",
|
||||
"resolve_customization.py",
|
||||
)
|
||||
SHIPPED_SKILLS = ("bmad-build-auto", "bmad-build", "bmad-code-review")
|
||||
RENDERED_SKILLS = (*SHIPPED_SKILLS, "bmad-walkthrough", "bmad-retrospective", "bmad-qa-generate-e2e-tests")
|
||||
COMPILE_TOKEN = re.compile(r"\{\{\s*(?:config|workflow)\.|\{\{\s*(?:rendered\(|initiative_folder)|\{%")
|
||||
DISPATCH_PREFIX = "read and follow "
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_SRC))
|
||||
import render_skill as rs # noqa: E402
|
||||
|
||||
|
||||
def _team_config(project: Path) -> str:
|
||||
return CONFIG_TEMPLATE.replace("{directory_name}", project.name)
|
||||
|
||||
|
||||
def _copy_skill(dest: Path, name: str) -> Path:
|
||||
shutil.copytree(
|
||||
SKILLS_SRC / name,
|
||||
dest,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
||||
)
|
||||
return dest
|
||||
|
||||
|
||||
def _files(directory: Path) -> dict[str, bytes]:
|
||||
files = {
|
||||
path.relative_to(directory).as_posix(): path.read_bytes() for path in directory.rglob("*") if path.is_file()
|
||||
}
|
||||
return dict(sorted(files.items()))
|
||||
|
||||
|
||||
def _markdown(directory: Path) -> str:
|
||||
return "\n".join(content.decode("utf-8") for name, content in _files(directory).items() if name.endswith(".md"))
|
||||
|
||||
|
||||
def _namespace_dir(project: Path, skill_name: str) -> Path:
|
||||
root = str(project.resolve())
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", project.name.lower()).strip("-") or "project"
|
||||
slug = slug[:80].rstrip("-") or "project"
|
||||
root_hash = hashlib.sha256(root.encode("utf-8")).hexdigest()[:12]
|
||||
return project / "_bmad" / "render" / skill_name / f"{slug}-{root_hash}"
|
||||
|
||||
|
||||
class PublishInternalsTests(unittest.TestCase):
|
||||
"""Corruption and reuse branches of `_publish` without rendering a whole skill."""
|
||||
|
||||
def test_identical_publish_reuses_and_rejects_each_corruption_mode(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
dest = Path(temp_dir) / "generation"
|
||||
outputs = {"workflow.md": b"hello\n"}
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"outputs": {"workflow.md": rs._hash_bytes(b"hello\n")},
|
||||
}
|
||||
rs._publish(dest, outputs, manifest)
|
||||
rs._publish(dest, outputs, manifest)
|
||||
self.assertEqual((dest / "workflow.md").read_bytes(), b"hello\n")
|
||||
|
||||
with self.assertRaisesRegex(rs.RenderError, "collision or corruption"):
|
||||
rs._publish(dest, outputs, {**manifest, "extra": True})
|
||||
|
||||
(dest / "Thumbs.db").write_bytes(b"stray")
|
||||
rs._publish(dest, outputs, manifest)
|
||||
self.assertEqual((dest / "Thumbs.db").read_bytes(), b"stray")
|
||||
|
||||
(dest / "workflow.md").unlink()
|
||||
with self.assertRaisesRegex(
|
||||
rs.RenderError, rf"missing rendered files: workflow\.md in {re.escape(str(dest))}"
|
||||
):
|
||||
rs._publish(dest, outputs, manifest)
|
||||
|
||||
(dest / "workflow.md").write_bytes(b"hello\ncorrupt")
|
||||
with self.assertRaisesRegex(rs.RenderError, "hash mismatch"):
|
||||
rs._publish(dest, outputs, manifest)
|
||||
(dest / "workflow.md").write_bytes(b"hello\n")
|
||||
|
||||
(dest / "manifest.json").write_text("{", encoding="utf-8")
|
||||
with self.assertRaisesRegex(rs.RenderError, "corrupt existing"):
|
||||
rs._publish(dest, outputs, manifest)
|
||||
|
||||
|
||||
class RenderSkillTests(unittest.TestCase):
|
||||
def _workspace(
|
||||
self,
|
||||
*,
|
||||
name: str = "project",
|
||||
shared_bmad: Path | None = None,
|
||||
config: str | None = None,
|
||||
) -> SimpleNamespace:
|
||||
outer = Path(tempfile.mkdtemp(prefix="bmad-render-"))
|
||||
self.addCleanup(shutil.rmtree, outer, True)
|
||||
project = outer / name
|
||||
project.mkdir(parents=True)
|
||||
(project / "nested" / "cwd").mkdir(parents=True)
|
||||
if shared_bmad is None:
|
||||
bmad = project / "_bmad"
|
||||
scripts = bmad / "scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
for script in SHARED_SCRIPTS:
|
||||
shutil.copy2(SCRIPTS_SRC / script, scripts / script)
|
||||
(bmad / "custom").mkdir()
|
||||
(bmad / "config.toml").write_text(
|
||||
config if config is not None else _team_config(project),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
(project / "_bmad").symlink_to(shared_bmad)
|
||||
bmad = shared_bmad
|
||||
return SimpleNamespace(outer=outer, project=project, bmad=bmad)
|
||||
|
||||
def _skill(self, ws: SimpleNamespace, name: str) -> Path:
|
||||
return _copy_skill(ws.outer / "skills" / name, name)
|
||||
|
||||
def _cli(
|
||||
self,
|
||||
project: Path,
|
||||
skill: Path,
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
args: tuple[str, ...] = (),
|
||||
timeout: float | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(project / "_bmad" / "scripts" / "render_skill.py"),
|
||||
"--project-root",
|
||||
str(project),
|
||||
"--skill",
|
||||
str(skill),
|
||||
*args,
|
||||
],
|
||||
cwd=cwd or project,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def _entry(self, result: subprocess.CompletedProcess[str]) -> Path:
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
lines = result.stdout.strip().split("\n")
|
||||
self.assertEqual(len(lines), 1, msg=result.stdout)
|
||||
self.assertTrue(lines[0].startswith(DISPATCH_PREFIX), msg=result.stdout)
|
||||
output = Path(lines[0][len(DISPATCH_PREFIX) :])
|
||||
self.assertTrue(output.is_absolute())
|
||||
return output
|
||||
|
||||
def _assert_rendered(self, workflow: Path, project: Path, skill_name: str) -> Path:
|
||||
snap = workflow.parent
|
||||
self.assertEqual(workflow.name, "workflow.md")
|
||||
self.assertIn(f"{os.sep}render{os.sep}{skill_name}{os.sep}", str(workflow))
|
||||
self.assertFalse((snap / "SKILL.md").exists())
|
||||
manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["project_root"], str(project.resolve()))
|
||||
self.assertEqual(manifest["skill"], skill_name)
|
||||
actual = _files(snap)
|
||||
expected = [*manifest["outputs"], "manifest.json"]
|
||||
self.assertEqual(sorted(actual), sorted(expected))
|
||||
for name, digest in manifest["outputs"].items():
|
||||
self.assertEqual(rs._hash_bytes(actual[name]), digest, name)
|
||||
markdown = _markdown(snap)
|
||||
self.assertIsNone(COMPILE_TOKEN.search(markdown), markdown)
|
||||
self.assertNotIn("{skill-root}", markdown)
|
||||
if skill_name not in ("bmad-retrospective", "bmad-qa-generate-e2e-tests"):
|
||||
artifacts = (project.resolve() / "_bmad-output").as_posix()
|
||||
self.assertIn(artifacts, markdown)
|
||||
return snap
|
||||
|
||||
def _fixture_skill(self, ws: SimpleNamespace, defaults: str, workflow: str, **sources: str) -> Path:
|
||||
skill = ws.outer / "skills" / "fixture"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "customize.toml").write_text(defaults, encoding="utf-8")
|
||||
for name, content in {"workflow.md": workflow, **sources}.items():
|
||||
(skill / name).write_text(content, encoding="utf-8")
|
||||
return skill
|
||||
|
||||
def _assert_halt(self, result: subprocess.CompletedProcess[str], ws: SimpleNamespace) -> None:
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
|
||||
self.assertEqual(len(result.stdout.splitlines()), 1, result.stdout)
|
||||
self.assertNotIn("Traceback", result.stdout + result.stderr)
|
||||
self.assertFalse((ws.bmad / "render").exists())
|
||||
|
||||
def test_invocation_precedence_flag_order_and_persistent_isolation(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{{ workflow.message }}\n")
|
||||
project_file = ws.bmad / "custom" / "fixture.toml"
|
||||
user_file = ws.bmad / "custom" / "fixture.user.toml"
|
||||
override_file = ws.project / "nested" / "cwd" / "invocation.toml"
|
||||
project_file.write_text('[workflow]\nmessage = "project"\n', encoding="utf-8")
|
||||
user_file.write_text('[workflow]\nmessage = "user"\n', encoding="utf-8")
|
||||
override_file.write_text('[workflow]\nmessage = "file"\n', encoding="utf-8")
|
||||
original = {
|
||||
path: path.read_bytes() for path in (project_file, user_file, override_file, skill / "customize.toml")
|
||||
}
|
||||
baseline = rs.render(ws.project, skill)
|
||||
self.assertEqual(baseline.read_text(), "user\n")
|
||||
from_file = rs.render(ws.project, skill, overrides=override_file)
|
||||
self.assertEqual(from_file.read_text(), "file\n")
|
||||
expected = None
|
||||
for args in (
|
||||
(
|
||||
"--set",
|
||||
"workflow.message=command = wins",
|
||||
"--overrides",
|
||||
"invocation.toml",
|
||||
),
|
||||
(
|
||||
"--overrides",
|
||||
"invocation.toml",
|
||||
"--set",
|
||||
"workflow.message=command = wins",
|
||||
),
|
||||
):
|
||||
entry = self._entry(self._cli(ws.project, skill, cwd=override_file.parent, args=args))
|
||||
self.assertEqual(entry.read_text(), "command = wins\n")
|
||||
if expected is not None:
|
||||
self.assertEqual(entry, expected)
|
||||
expected = entry
|
||||
self.assertEqual(rs.render(ws.project, skill), baseline)
|
||||
for path, content in original.items():
|
||||
self.assertEqual(path.read_bytes(), content)
|
||||
user_file.unlink()
|
||||
self.assertEqual(rs.render(ws.project, skill).read_text(), "project\n")
|
||||
project_file.unlink()
|
||||
self.assertEqual(rs.render(ws.project, skill).read_text(), "shipped\n")
|
||||
|
||||
def test_equivalent_invocation_forms_merge_structures_and_reuse_rendered(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(
|
||||
ws,
|
||||
'[workflow]\nfacts = ["base"]\n[workflow.details]\nlabel = "base"\nkept = "kept"\n'
|
||||
'[[workflow.layers]]\nid = "a"\nname = "A"\ninstruction = "original"\n',
|
||||
"{{ workflow.facts }}\n{{ workflow.details.label }} {{ workflow.details.kept }}\n{{ workflow.layers }}\n",
|
||||
)
|
||||
override = ws.project / "overrides.toml"
|
||||
override.write_text(
|
||||
'[workflow]\nfacts = ["added"]\ndetails = { label = "changed" }\n'
|
||||
'layers = [{ id = "a", name = "Replaced", instruction = "replacement" }, '
|
||||
'{ id = "b", instruction = "new" }]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_entry = rs.render(ws.project, skill, overrides=override)
|
||||
command_entry = rs.render(
|
||||
ws.project,
|
||||
skill,
|
||||
assignments=[
|
||||
'workflow.facts=["added"]',
|
||||
'workflow.details={ label = "changed" }',
|
||||
'workflow.layers=[{ id = "a", name = "Replaced", instruction = "replacement" }, '
|
||||
'{ id = "b", instruction = "new" }]',
|
||||
],
|
||||
)
|
||||
self.assertEqual(file_entry, command_entry)
|
||||
self.assertEqual(_files(file_entry.parent), _files(command_entry.parent))
|
||||
content = command_entry.read_text()
|
||||
self.assertIn("- base\n- added", content)
|
||||
self.assertIn("changed kept", content)
|
||||
self.assertIn("Replaced (`a`)", content)
|
||||
self.assertIn("b (`b`)", content)
|
||||
self.assertNotIn("original", content)
|
||||
combined = rs.render(ws.project, skill, overrides=override, assignments=['workflow.facts=["command"]'])
|
||||
self.assertIn("- base\n- added\n- command", combined.read_text())
|
||||
|
||||
def test_string_assignment_syntax_and_conflicting_paths_halt(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\n', "{{ workflow.message }}")
|
||||
for assignment, expected in (
|
||||
("workflow.message=words = more words", "words = more words"),
|
||||
('workflow.message="line\\nnext"', "line\nnext"),
|
||||
("workflow.message='literal = text'", "literal = text"),
|
||||
("workflow.message=", ""),
|
||||
):
|
||||
with self.subTest(assignment=assignment):
|
||||
# The nonempty shipped default deliberately rejects empty prose.
|
||||
if not expected:
|
||||
with self.assertRaisesRegex(rs.RenderError, "must not be empty"):
|
||||
rs.render(ws.project, skill, assignments=[assignment])
|
||||
else:
|
||||
self.assertEqual(rs.render(ws.project, skill, assignments=[assignment]).read_text(), expected)
|
||||
for assignments in (
|
||||
["workflow.message=first", "workflow.message=second"],
|
||||
['workflow={ message = "table" }', "workflow.message=child"],
|
||||
["workflow.message=child", 'workflow={ message = "table" }'],
|
||||
):
|
||||
with self.subTest(assignments=assignments):
|
||||
with self.assertRaisesRegex(rs.RenderError, "conflicts with earlier --set"):
|
||||
rs.render(ws.project, skill, assignments=assignments)
|
||||
|
||||
def test_undeclared_persistent_key_halts_and_unread_declared_key_renders(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\nunread = "base"\n', "{{ workflow.message }}")
|
||||
team = ws.bmad / "custom" / f"{skill.name}.toml"
|
||||
user = ws.bmad / "custom" / f"{skill.name}.user.toml"
|
||||
# A declared key this render never reads is still a valid persistent override.
|
||||
user.write_text('[workflow]\nunread = "changed"\n', encoding="utf-8")
|
||||
self.assertEqual(rs.render(ws.project, skill).read_text(), "base")
|
||||
generations = set((ws.bmad / "render" / skill.name).rglob("manifest.json"))
|
||||
for layer, content, expected in (
|
||||
(
|
||||
user,
|
||||
'[workflow]\nmessage = "ok"\nmesage = "typo"\n',
|
||||
r"user\.toml sets keys fixture does not declare: workflow\.mesage$",
|
||||
),
|
||||
(
|
||||
team,
|
||||
'[[workflow.review_layers]]\nid = "stale"\nname = "Stale"\ninstruction = "x"\n',
|
||||
r"workflow\.review_layers$",
|
||||
),
|
||||
(team, "[workflow.message]\nnested = true\n", r"workflow\.message\.nested$"),
|
||||
(team, 'message = "top level"\n', r"does not declare: message$"),
|
||||
):
|
||||
with self.subTest(layer=layer.name, content=content):
|
||||
layer.write_text(content, encoding="utf-8")
|
||||
with self.assertRaisesRegex(rs.RenderError, expected):
|
||||
rs.render(ws.project, skill)
|
||||
self.assertEqual(set((ws.bmad / "render" / skill.name).rglob("manifest.json")), generations)
|
||||
layer.unlink()
|
||||
|
||||
@unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root bypasses file permission bits")
|
||||
def test_render_folder_that_refuses_new_files_halts(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{{ workflow.message }}\n")
|
||||
namespace = _namespace_dir(ws.project, "fixture")
|
||||
namespace.mkdir(parents=True)
|
||||
chmod(namespace, 0o555, deny="WD,AD")
|
||||
self.addCleanup(chmod, namespace, 0o755)
|
||||
|
||||
# The timeout: on Windows, older Pythons' mkdtemp retried here some two
|
||||
# billion times, and a hang must fail this test, not the job.
|
||||
result = self._cli(ws.project, skill, timeout=60)
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
|
||||
self.assertIn("denied", result.stdout.lower())
|
||||
self.assertNotIn("Traceback", result.stdout + result.stderr)
|
||||
self.assertEqual(list(namespace.iterdir()), [])
|
||||
|
||||
def test_invalid_invocation_halts_before_publication(self):
|
||||
invalid = (
|
||||
("--set", "workflow.message"),
|
||||
("--set", ".workflow.message=x"),
|
||||
("--set", "workflow.unknown=x"),
|
||||
("--set", 'workflow.message="unterminated'),
|
||||
("--set", "workflow.count=words"),
|
||||
# Declared but never reaches a token or condition in this render.
|
||||
("--set", "workflow.count=7"),
|
||||
# Consumed, so the consumer rejects the type.
|
||||
("--set", "workflow.items=7"),
|
||||
("--set", "workflow.items=[true]"),
|
||||
("--set", "workflow.count=2\nextra=3"),
|
||||
("--overrides", "missing.toml"),
|
||||
("--overrides",),
|
||||
("--set",),
|
||||
)
|
||||
for args in invalid:
|
||||
with self.subTest(args=args):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(
|
||||
ws,
|
||||
'[workflow]\nmessage = "base"\ncount = 1\nitems = ["base"]\n',
|
||||
"{{ workflow.message }} {{ workflow.items }}\n",
|
||||
)
|
||||
self._assert_halt(self._cli(ws.project, skill, args=args), ws)
|
||||
for content in ("[workflow", '[workflow]\nunknown="x"', "[workflow]\ncount=7", "[workflow]\nitems=7"):
|
||||
with self.subTest(content=content):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(ws, '[workflow]\ncount = 1\nitems = ["base"]\n', "{{ workflow.items }}\n")
|
||||
override = ws.project / "bad.toml"
|
||||
override.write_text(content, encoding="utf-8")
|
||||
self._assert_halt(self._cli(ws.project, skill, args=("--overrides", str(override))), ws)
|
||||
|
||||
def test_nested_conditions_omit_files_and_do_not_resolve_excluded_expressions(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(
|
||||
ws,
|
||||
'[workflow]\nchoice = "left"\nenabled = true\n',
|
||||
'{% if workflow.choice == "left" %}\nLeft {{ rendered("left.md") }}\n'
|
||||
"{% if workflow.enabled %}\nEnabled\n{% else %}\n"
|
||||
'{{ config.missing }} {{ workflow.missing }} {{ rendered("missing.md") }}\n{% endif %}\n'
|
||||
'{% else %}\nRight {{ rendered("right.md") }}\n{% endif %}\n',
|
||||
**{
|
||||
"left.md": '{% if workflow.choice == "left" %}\nLeft detail\n{% endif %}\n',
|
||||
"right.md": '\n{% if workflow.choice != "left" %}\nRight detail\n{% endif %}\n',
|
||||
},
|
||||
)
|
||||
left = rs.render(ws.project, skill)
|
||||
before = _files(left.parent)
|
||||
# Standalone tag lines leave no blank lines behind.
|
||||
self.assertEqual(left.read_text(), f"Left {(left.parent / 'left.md').as_posix()}\nEnabled\n")
|
||||
self.assertEqual((left.parent / "left.md").read_text(), "Left detail\n")
|
||||
self.assertFalse((left.parent / "right.md").exists())
|
||||
right = rs.render(ws.project, skill, assignments=["workflow.choice=right"])
|
||||
self.assertNotEqual(left, right)
|
||||
self.assertEqual(right.read_text(), f"Right {(right.parent / 'right.md').as_posix()}\n")
|
||||
self.assertFalse((right.parent / "left.md").exists())
|
||||
self.assertTrue((right.parent / "right.md").exists())
|
||||
self.assertEqual(before, _files(left.parent))
|
||||
self.assertEqual(left, rs.render(ws.project, skill))
|
||||
manifest = json.loads((left.parent / "manifest.json").read_text())
|
||||
self.assertEqual(
|
||||
manifest["inputs"]["resolved_values"],
|
||||
{
|
||||
"customization.workflow.choice": "left",
|
||||
"customization.workflow.enabled": True,
|
||||
},
|
||||
)
|
||||
self.assertIn("right.md", manifest["inputs"]["source_sha256"])
|
||||
self.assertNotIn("right.md", manifest["outputs"])
|
||||
(skill / "right.md").write_text((skill / "right.md").read_text().replace("Right detail", "Changed detail"))
|
||||
self.assertNotEqual(left, rs.render(ws.project, skill))
|
||||
|
||||
def test_typed_scalar_condition_inputs_identify_generations_even_for_identical_output(self):
|
||||
for default, condition, override in (
|
||||
("true", "workflow.value == true", "false"),
|
||||
("1", "workflow.value == 1", "2"),
|
||||
("1.5", "workflow.value == 1.5", "2.5"),
|
||||
('"one"', 'workflow.value == "one"', '"two"'),
|
||||
("2026-09-07", 'workflow.value|string == "2026-09-07"', "2026-09-08"),
|
||||
):
|
||||
with self.subTest(default=default):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(
|
||||
ws,
|
||||
f"[workflow]\nvalue = {default}\n",
|
||||
f"{{% if {condition} %}}\nSame\n{{% else %}}\nSame\n{{% endif %}}\n",
|
||||
)
|
||||
before = rs.render(ws.project, skill)
|
||||
after = rs.render(ws.project, skill, assignments=[f"workflow.value={override}"])
|
||||
self.assertEqual(before.read_bytes(), after.read_bytes())
|
||||
self.assertNotEqual(before, after)
|
||||
self.assertEqual(after, rs.render(ws.project, skill, assignments=[f"workflow.value={override}"]))
|
||||
|
||||
def test_loops_iterate_list_values_and_direct_insertion_keeps_markdown_forms(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(
|
||||
ws,
|
||||
'[workflow]\nfacts = ["one", "two"]\n[[workflow.layers]]\nid = "a"\nname = "A"\n'
|
||||
'instruction = "Read {skill-root}/a.md"\n[[workflow.layers]]\nid = "b"\ninstruction = ""\n',
|
||||
"{% for fact in workflow.facts %}\n* {{ fact }}\n{% endfor %}\n{{ workflow.facts }}\n"
|
||||
"{% for layer in workflow.layers if layer.instruction %}\n{{ layer.id }}: {{ layer.instruction }}\n"
|
||||
"{% endfor %}\n{{ workflow.layers }}\n",
|
||||
)
|
||||
entry = rs.render(ws.project, skill)
|
||||
snap = entry.parent
|
||||
self.assertEqual(
|
||||
entry.read_text(),
|
||||
f"* one\n* two\n- one\n- two\na: Read {snap.as_posix()}/a.md\n#### A (`a`)\n\nRead {snap.as_posix()}/a.md\n",
|
||||
)
|
||||
manifest = json.loads((snap / "manifest.json").read_text())
|
||||
self.assertEqual(
|
||||
manifest["inputs"]["resolved_values"],
|
||||
{
|
||||
"customization.workflow.facts": ["one", "two"],
|
||||
"customization.workflow.layers": [
|
||||
{"id": "a", "name": "A", "instruction": "Read {skill-root}/a.md"},
|
||||
{"id": "b", "name": "b", "instruction": ""},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def test_template_errors_report_source_location_and_halt(self):
|
||||
templates = (
|
||||
'{% if workflow.value == "one" %}\ntext\n',
|
||||
"{% else %}\n",
|
||||
"{% endif %}\n",
|
||||
"{% if %}\n{% endif %}\n",
|
||||
"{{ workflow.value\n",
|
||||
"{{ workflow.unknown }}\n",
|
||||
"{{ nothing }}\n",
|
||||
"{{ workflow }}\n",
|
||||
"{{ config }}\n",
|
||||
"{{ config.nothing }}\n",
|
||||
"{{ config.core.nothing }}\n",
|
||||
"{{ workflow.items.missing }}\n",
|
||||
"{% for item in workflow.value %}{{ item }}{% endfor %}\n",
|
||||
"{% for item in workflow.count %}{{ item }}{% endfor %}\n",
|
||||
'{{ rendered("missing.md") }}\n',
|
||||
"{{ rendered(workflow.items) }}\n",
|
||||
'{{ halt("rejected") }}\n',
|
||||
)
|
||||
for template in templates:
|
||||
with self.subTest(template=template):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(ws, '[workflow]\nvalue = "one"\ncount = 1\nitems = []\n', f"ok\n{template}")
|
||||
result = self._cli(ws.project, skill)
|
||||
self._assert_halt(result, ws)
|
||||
self.assertRegex(result.stdout, r"^HALT: workflow\.md:\d+: ")
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(
|
||||
ws, '[workflow]\nvalue = "one"\n', "entry\n", **{"detail.md": "\n\n{{ nothing }}\n"}
|
||||
)
|
||||
with self.assertRaisesRegex(rs.RenderError, r"^detail\.md:3: 'nothing' is undefined$"):
|
||||
rs.render(ws.project, skill)
|
||||
(skill / "detail.md").write_text("{{ workflow.missing }}\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(
|
||||
rs.RenderError, r"^detail\.md:1: missing customization parameter `workflow\.missing`$"
|
||||
):
|
||||
rs.render(ws.project, skill)
|
||||
guard = '{% if workflow.value not in ("two", "three") %}{{ halt("value must be two or three, not " ~ workflow.value) }}{% endif %}\n'
|
||||
(skill / "detail.md").write_text(guard + "kept\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(rs.RenderError, r"^detail\.md:1: value must be two or three, not one$"):
|
||||
rs.render(ws.project, skill)
|
||||
self.assertEqual(
|
||||
_files(rs.render(ws.project, skill, assignments=["workflow.value=two"]).parent)["detail.md"], b"kept\n"
|
||||
)
|
||||
|
||||
def test_excluded_entry_and_surviving_reference_to_excluded_file_halt(self):
|
||||
for workflow in (
|
||||
"{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n",
|
||||
'Read {{ rendered("detail.md") }}\n',
|
||||
):
|
||||
with self.subTest(workflow=workflow):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(
|
||||
ws,
|
||||
"[workflow]\nenabled = true\n",
|
||||
workflow,
|
||||
**{"detail.md": "{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n"},
|
||||
)
|
||||
self._assert_halt(self._cli(ws.project, skill), ws)
|
||||
|
||||
def test_raw_blocks_keep_agent_placeholders_verbatim(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(
|
||||
ws,
|
||||
"[workflow]\n",
|
||||
"epic: {% raw %}{{epic_number}}{% endraw +%}\nnext {% raw %}{{prev}}{% endraw %} {{ '{%' }} done\n",
|
||||
)
|
||||
self.assertEqual(rs.render(ws.project, skill).read_text(), "epic: {{epic_number}}\nnext {{prev}} {% done\n")
|
||||
|
||||
def test_invocation_prose_keeps_template_syntax_opaque(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(ws, '[workflow]\nmessage = ""\n', "{{ workflow.message }}\n")
|
||||
literal = "{% if workflow.missing %}\n{{ workflow.missing }} {{ config.missing }} {# note #}\n{% endif %}"
|
||||
literal += '\n{{ rendered("missing.md") }} {skill-root}/detail.md'
|
||||
entry = rs.render(ws.project, skill, assignments=[f"workflow.message={literal}"])
|
||||
self.assertEqual(entry.read_text(), literal.replace("{skill-root}", entry.parent.as_posix()) + "\n")
|
||||
|
||||
def test_jinja2_version_is_part_of_the_generation_identity(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(ws, "[workflow]\n", "stable\n")
|
||||
original = rs.render(ws.project, skill)
|
||||
manifest = json.loads((original.parent / "manifest.json").read_text())
|
||||
self.assertEqual(manifest["inputs"]["jinja2_version"], rs.jinja2.__version__)
|
||||
with mock.patch.object(rs.jinja2, "__version__", "0.0.0"):
|
||||
changed = rs.render(ws.project, skill)
|
||||
self.assertNotEqual(changed, original)
|
||||
self.assertEqual(changed.read_bytes(), original.read_bytes())
|
||||
self.assertTrue(original.exists())
|
||||
|
||||
def test_shipped_skills_publish_root_bound_snapshots(self):
|
||||
for name in SHIPPED_SKILLS:
|
||||
with self.subTest(name):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, name)
|
||||
workflow = rs.render(ws.project, skill, assignments=["workflow.review=thorough"])
|
||||
snap = self._assert_rendered(workflow, ws.project, name)
|
||||
self.assertIn("{plan_file}", _markdown(snap))
|
||||
hunter = snap / "review-prompts" / "edge-case-hunter.md"
|
||||
self.assertTrue(hunter.is_file())
|
||||
self.assertIn(hunter.as_posix(), _markdown(snap))
|
||||
|
||||
def test_rendered_skills_publish_snapshots_without_skill_root(self):
|
||||
for name in RENDERED_SKILLS:
|
||||
with self.subTest(name):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, name)
|
||||
workflow = rs.render(ws.project, skill)
|
||||
self._assert_rendered(workflow, ws.project, name)
|
||||
|
||||
def test_build_skills_render_the_active_initiative_branch(self):
|
||||
for name in ("bmad-build", "bmad-build-auto"):
|
||||
with self.subTest(name):
|
||||
ws = self._workspace()
|
||||
(ws.bmad / "custom" / "config.user.toml").write_text(
|
||||
'[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8"
|
||||
)
|
||||
skill = self._skill(ws, name)
|
||||
snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, name)
|
||||
markdown = _markdown(snap)
|
||||
folder = (ws.project.resolve() / "_bmad-output" / "initiative-checkout").as_posix()
|
||||
self.assertIn(f"{folder}/plan-{{slug}}.md", markdown)
|
||||
self.assertNotIn("No initiative is active", markdown)
|
||||
|
||||
def test_build_skills_render_each_pinned_route(self):
|
||||
for name in ("bmad-build", "bmad-build-auto"):
|
||||
for route in ("oneshot", "full"):
|
||||
with self.subTest(name=name, route=route):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, name)
|
||||
workflow = rs.render(ws.project, skill, assignments=[f"workflow.route={route}"])
|
||||
self._assert_rendered(workflow, ws.project, name)
|
||||
|
||||
def test_build_skills_default_to_quick_review(self):
|
||||
for name in ("bmad-build", "bmad-build-auto"):
|
||||
with self.subTest(name):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, name)
|
||||
snap = rs.render(ws.project, skill).parent
|
||||
markdown = _markdown(snap)
|
||||
self.assertIn("Write `review: 'quick'` and `review_source: 'pinned'`", markdown)
|
||||
self.assertIn("Quick (`quick`)", markdown)
|
||||
self.assertNotIn((snap / "review-prompts" / "edge-case-hunter.md").as_posix(), markdown)
|
||||
|
||||
def test_skill_root_binds_bundled_scripts_to_the_installed_skill(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-retrospective")
|
||||
snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, "bmad-retrospective")
|
||||
markdown = _markdown(snap)
|
||||
self.assertIn((skill / "scripts" / "git_evidence.py").as_posix(), markdown)
|
||||
manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["inputs"]["skill_root"], str(skill.resolve()))
|
||||
elsewhere = _copy_skill(ws.outer / "elsewhere" / "bmad-retrospective", "bmad-retrospective")
|
||||
other = rs.render(ws.project, elsewhere)
|
||||
self.assertNotEqual(other.parent, snap)
|
||||
self.assertIn((elsewhere / "scripts" / "git_evidence.py").as_posix(), _markdown(other.parent))
|
||||
|
||||
def test_cli_from_nested_cwd_dispatches_one_absolute_workflow(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
workflow = self._entry(self._cli(ws.project, skill, cwd=ws.project / "nested" / "cwd"))
|
||||
self._assert_rendered(workflow, ws.project, "bmad-build")
|
||||
self.assertFalse((ws.bmad / "scripts" / "__pycache__").exists())
|
||||
self.assertFalse((skill / "__pycache__").exists())
|
||||
|
||||
def test_identical_input_and_unreferenced_config_reuse_bytes(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
first = rs.render(ws.project, skill)
|
||||
first_files = _files(first.parent)
|
||||
self.assertEqual(rs.render(ws.project, skill), first)
|
||||
with (ws.bmad / "config.toml").open("a", encoding="utf-8") as handle:
|
||||
handle.write('\nunreferenced_value = "ignored"\n')
|
||||
self.assertEqual(rs.render(ws.project, skill), first)
|
||||
current = _files(first.parent)
|
||||
for name, content in first_files.items():
|
||||
self.assertEqual(current[name], content, name)
|
||||
|
||||
def test_referenced_config_and_source_changes_publish_new_generations(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build-auto")
|
||||
before = rs.render(ws.project, skill)
|
||||
before_files = _files(before.parent)
|
||||
(ws.bmad / "custom" / "config.toml").write_text(
|
||||
'[core]\noutput_folder = "{project-root}/impl-v2"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
after_config = rs.render(ws.project, skill)
|
||||
self.assertNotEqual(after_config, before)
|
||||
self.assertIn("/impl-v2/", after_config.read_text(encoding="utf-8"))
|
||||
self.assertTrue(before.exists())
|
||||
|
||||
(skill / "plan-template.md").write_text(
|
||||
(skill / "plan-template.md").read_text(encoding="utf-8") + "\n<!-- effective change -->\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
after_source = rs.render(ws.project, skill)
|
||||
self.assertNotEqual(after_source, after_config)
|
||||
current = _files(before.parent)
|
||||
for name, content in before_files.items():
|
||||
self.assertEqual(current[name], content, name)
|
||||
|
||||
def test_initiative_folder_follows_the_active_initiative_where_a_template_reaches_it(self):
|
||||
ws = self._workspace()
|
||||
skill = self._fixture_skill(ws, "[workflow]\n", "{{ initiative_folder }}\n")
|
||||
user_config = ws.bmad / "custom" / "config.user.toml"
|
||||
output_folder = (ws.project.resolve() / "_bmad-output").as_posix()
|
||||
unset = rs.render(ws.project, skill)
|
||||
self.assertEqual(unset.read_text(encoding="utf-8"), f"{output_folder}\n")
|
||||
user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8")
|
||||
active = rs.render(ws.project, skill)
|
||||
self.assertNotEqual(active, unset)
|
||||
self.assertEqual(active.read_text(encoding="utf-8"), f"{output_folder}/initiative-checkout\n")
|
||||
for value, message in (("42", "must be a string"), ('""', "must not be empty")):
|
||||
with self.subTest(value=value):
|
||||
user_config.write_text(f"[core]\nactive_initiative = {value}\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(rs.RenderError, f"config.core.active_initiative {message}"):
|
||||
rs.render(ws.project, skill)
|
||||
|
||||
(skill / "workflow.md").write_text("plain\n", encoding="utf-8")
|
||||
user_config.unlink()
|
||||
unreferenced = rs.render(ws.project, skill)
|
||||
user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8")
|
||||
self.assertEqual(rs.render(ws.project, skill), unreferenced)
|
||||
|
||||
def test_shared_runtime_keeps_distinct_root_bound_snapshots(self):
|
||||
first = self._workspace()
|
||||
skill = self._skill(first, "bmad-build")
|
||||
second = self._workspace(name="other", shared_bmad=first.bmad)
|
||||
one = rs.render(first.project, skill)
|
||||
two = rs.render(second.project, skill)
|
||||
self.assertNotEqual(one, two)
|
||||
self.assertIn(first.project.resolve().as_posix(), one.read_text(encoding="utf-8"))
|
||||
self.assertIn(second.project.resolve().as_posix(), two.read_text(encoding="utf-8"))
|
||||
|
||||
def test_concurrent_cli_renderers_reuse_one_complete_generation(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda _: self._cli(ws.project, skill), range(2)))
|
||||
entries = [self._entry(result) for result in results]
|
||||
self.assertEqual(entries[0], entries[1])
|
||||
self.assertTrue((entries[0].parent / "manifest.json").is_file())
|
||||
|
||||
def test_malformed_config_and_customization_halt_without_traceback(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
(ws.bmad / "custom" / "config.toml").write_text("[core\nbad", encoding="utf-8")
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
|
||||
self.assertNotIn(DISPATCH_PREFIX, result.stdout)
|
||||
self.assertNotIn("Traceback", result.stdout + result.stderr)
|
||||
|
||||
(ws.bmad / "custom" / "config.toml").unlink()
|
||||
(ws.bmad / "custom" / f"{skill.name}.toml").write_text("[workflow\nbad", encoding="utf-8")
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("failed to parse", result.stdout)
|
||||
|
||||
def test_missing_wrong_type_and_non_string_layer_id_halt(self):
|
||||
template = _team_config(Path("project"))
|
||||
missing = template.replace(
|
||||
'output_folder = "{project-root}/_bmad-output"\n',
|
||||
"",
|
||||
)
|
||||
ws = self._workspace(config=missing)
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertIn("missing config value", result.stdout)
|
||||
|
||||
wrong = template.replace(
|
||||
'output_folder = "{project-root}/_bmad-output"',
|
||||
"output_folder = 42",
|
||||
)
|
||||
ws = self._workspace(config=wrong)
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertIn("must be a string", result.stdout)
|
||||
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
(ws.bmad / "custom" / f"{skill.name}.toml").write_text(
|
||||
'[[workflow.thorough_lenses]]\nid = 42\nname = "bad"\ninstruction = "bad"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertIn("identifier `id` must be a string", result.stdout)
|
||||
|
||||
def test_customization_prose_is_not_rescanned_as_source_tokens(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
literal = '{{ rendered("step-04-review.md") }}'
|
||||
compile_literal = "{{ workflow.implementation_handoff }}"
|
||||
(ws.bmad / "custom" / f"{skill.name}.user.toml").write_text(
|
||||
f"[workflow]\non_complete = 'Preserve {literal} and {compile_literal} as prose'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
markdown = _markdown(rs.render(ws.project, skill).parent)
|
||||
self.assertIn(literal, markdown)
|
||||
self.assertIn(compile_literal, markdown)
|
||||
|
||||
def test_review_layer_override_guard_and_empty_layer_halt(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
(ws.bmad / "custom" / f"{skill.name}.toml").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[workflow]",
|
||||
'review = "thorough"',
|
||||
"[[workflow.thorough_lenses]]",
|
||||
'id = "blind-hunter"',
|
||||
'name = "Replacement"',
|
||||
'instruction = "Run replacement review."',
|
||||
'when = "the replacement condition holds"',
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
review = (rs.render(ws.project, skill).parent / "step-04-review.md").read_text(encoding="utf-8")
|
||||
self.assertIn("Replacement (`blind-hunter`)", review)
|
||||
self.assertIn("Run only when: the replacement condition holds", review)
|
||||
self.assertIn("Run replacement review.", review)
|
||||
|
||||
defaults = tomllib.loads((skill / "customize.toml").read_text(encoding="utf-8"))
|
||||
disabled = '[workflow]\nreview = "thorough"\n' + "\n".join(
|
||||
f'[[workflow.thorough_lenses]]\nid = "{layer["id"]}"\nname = "disabled"\ninstruction = ""\n'
|
||||
for layer in defaults["workflow"]["thorough_lenses"]
|
||||
)
|
||||
(ws.bmad / "custom" / f"{skill.name}.toml").write_text(disabled, encoding="utf-8")
|
||||
review = (rs.render(ws.project, skill).parent / "step-04-review.md").read_text(encoding="utf-8")
|
||||
self.assertIn("No active review layers. HALT", review)
|
||||
|
||||
def test_non_empty_open_plan_override_reaches_both_terminal_routes(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
(ws.bmad / "custom" / f"{skill.name}.user.toml").write_text(
|
||||
'[workflow]\nopen_plan = "OPEN-PLAN-SENTINEL {project-root} {plan_file}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
snap = rs.render(ws.project, skill).parent
|
||||
for name in ("step-05-present.md", "step-oneshot.md"):
|
||||
rendered = (snap / name).read_text(encoding="utf-8")
|
||||
self.assertIn("OPEN-PLAN-SENTINEL {project-root} {plan_file}", rendered)
|
||||
|
||||
def test_installed_renderer_identity_change_publishes_a_new_generation(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
original = self._entry(self._cli(ws.project, skill))
|
||||
with (ws.bmad / "scripts" / "render_skill.py").open("a", encoding="utf-8") as handle:
|
||||
handle.write("\n# renderer identity change\n")
|
||||
changed = self._entry(self._cli(ws.project, skill))
|
||||
self.assertNotEqual(changed, original)
|
||||
self.assertTrue(original.exists())
|
||||
|
||||
def test_convention_only_skill_renders_without_customization(self):
|
||||
ws = self._workspace()
|
||||
skill = ws.outer / "skills" / "plain-workflow"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "workflow.md").write_text('Read `{{ rendered("step.md") }}`.\n', encoding="utf-8")
|
||||
(skill / "step.md").write_text("No rendered values required.\n", encoding="utf-8")
|
||||
workflow = rs.render(ws.project, skill)
|
||||
self.assertIn(f"{os.sep}render{os.sep}plain-workflow{os.sep}", str(workflow))
|
||||
self.assertTrue((workflow.parent / "step.md").is_file())
|
||||
self.assertIn((workflow.parent / "step.md").as_posix(), workflow.read_text(encoding="utf-8"))
|
||||
|
||||
def test_ambiguous_shorthand_and_source_symlink_escape_halt(self):
|
||||
config = _team_config(Path("project")) + '\n[modules.bmm]\noutput_folder = "{project-root}/dup"\n'
|
||||
ws = self._workspace(config=config)
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertIn("ambiguous config value", result.stdout)
|
||||
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
outside = ws.outer / "outside.md"
|
||||
outside.write_text("outside\n", encoding="utf-8")
|
||||
(skill / "workflow.md").unlink()
|
||||
(skill / "workflow.md").symlink_to(outside)
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertIn("escapes skill directory", result.stdout)
|
||||
|
||||
def test_long_project_basename_is_bounded_in_the_snapshot_namespace(self):
|
||||
ws = self._workspace(name="project-" + ("x" * 220))
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
workflow = rs.render(ws.project, skill)
|
||||
self.assertLessEqual(len(workflow.parent.parent.name), 93)
|
||||
|
||||
def test_snapshot_paths_stay_opaque_when_the_project_name_looks_like_tokens(self):
|
||||
ws = self._workspace(name="{{ workflow.on_complete }}-{{ config.output_folder }}")
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
workflow = rs.render(ws.project, skill)
|
||||
text = workflow.read_text(encoding="utf-8")
|
||||
match = re.search(r"`([^`]*step-01-clarify-and-route\.md)`", text)
|
||||
self.assertIsNotNone(match, text)
|
||||
self.assertTrue(match.group(1).startswith(ws.project.resolve().as_posix()))
|
||||
self.assertTrue(Path(match.group(1)).is_file())
|
||||
|
||||
def test_publication_failure_does_not_dispatch_or_alter_another_root(self):
|
||||
stable = self._workspace()
|
||||
skill = self._skill(stable, "bmad-build")
|
||||
original = rs.render(stable.project, skill)
|
||||
original_files = _files(original.parent)
|
||||
broken = self._workspace(name="broken", shared_bmad=stable.bmad)
|
||||
namespace = _namespace_dir(broken.project, skill.name)
|
||||
namespace.parent.mkdir(parents=True, exist_ok=True)
|
||||
namespace.write_text("not a directory\n", encoding="utf-8")
|
||||
result = self._cli(broken.project, skill)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
|
||||
self.assertNotIn(DISPATCH_PREFIX, result.stdout)
|
||||
current = _files(original.parent)
|
||||
for name, content in original_files.items():
|
||||
self.assertEqual(current[name], content, name)
|
||||
|
||||
def test_corrupt_existing_destination_is_never_overwritten(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
workflow = rs.render(ws.project, skill)
|
||||
workflow.write_text(workflow.read_text(encoding="utf-8") + "corrupt", encoding="utf-8")
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("hash mismatch", result.stdout)
|
||||
self.assertTrue(workflow.read_text(encoding="utf-8").endswith("corrupt"))
|
||||
|
||||
def test_stray_file_in_generation_is_ignored_and_missing_output_is_named(self):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, "bmad-build")
|
||||
workflow = rs.render(ws.project, skill)
|
||||
stray = workflow.parent / "Thumbs.db"
|
||||
stray.write_bytes(b"explorer")
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(result.stdout, f"{DISPATCH_PREFIX}{workflow}\n")
|
||||
self.assertEqual(stray.read_bytes(), b"explorer")
|
||||
|
||||
workflow.unlink()
|
||||
result = self._cli(ws.project, skill)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertEqual(len(result.stdout.splitlines()), 1, result.stdout)
|
||||
self.assertNotIn("Traceback", result.stdout + result.stderr)
|
||||
self.assertIn(f"missing rendered files: workflow.md in {workflow.parent}", result.stdout)
|
||||
self.assertIn("deleting that folder is safe", result.stdout)
|
||||
self.assertFalse(workflow.exists())
|
||||
|
||||
def test_skill_md_command_dispatches_for_every_rendered_skill(self):
|
||||
for name in RENDERED_SKILLS:
|
||||
with self.subTest(name):
|
||||
ws = self._workspace()
|
||||
skill = self._skill(ws, name)
|
||||
text = (skill / "SKILL.md").read_text(encoding="utf-8")
|
||||
fenced = re.search(r"```bash\n(.*?)```", text, re.S)
|
||||
self.assertIsNotNone(fenced, f"{name}: SKILL.md ships no bash command")
|
||||
command = (
|
||||
fenced.group(1)
|
||||
.strip()
|
||||
.replace("{project-root}", str(ws.project))
|
||||
.replace("{skill-root}", str(skill))
|
||||
)
|
||||
self.assertNotIn("{", command)
|
||||
dispatched = self._entry(
|
||||
subprocess.run(
|
||||
command,
|
||||
cwd=ws.project / "nested" / "cwd",
|
||||
shell=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
)
|
||||
self.assertEqual(dispatched.name, "workflow.md")
|
||||
self.assertTrue(dispatched.is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_config.py"
|
||||
|
||||
|
||||
class ResolveConfigCliTests(unittest.TestCase):
|
||||
def test_missing_tomllib_exits_with_actionable_version_error(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
scripts = Path(temp_dir)
|
||||
shutil.copy2(SCRIPT, scripts / SCRIPT.name)
|
||||
shutil.copy2(SCRIPT.parent / "config_utils.py", scripts / "config_utils.py")
|
||||
(scripts / "tomllib.py").write_text(
|
||||
'raise ModuleNotFoundError("No module named tomllib", name="tomllib")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(scripts / SCRIPT.name), "--help"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 3)
|
||||
self.assertEqual(
|
||||
result.stderr,
|
||||
"error: Python 3.11+ is required (stdlib `tomllib` not found).\n",
|
||||
)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
|
||||
def test_full_and_repeated_key_output_follow_layer_precedence(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
custom = root / "_bmad" / "custom"
|
||||
custom.mkdir(parents=True)
|
||||
(root / "_bmad" / "config.toml").write_text('[core]\nname = "base"\nkeep = "yes"\n', encoding="utf-8")
|
||||
(root / "_bmad" / "config.user.toml").write_text(
|
||||
'[core]\nname = "base-user"\nstray = "ignored"\n', encoding="utf-8"
|
||||
)
|
||||
(custom / "config.toml").write_text('[core]\nname = "team"\n', encoding="utf-8")
|
||||
(custom / "config.user.toml").write_text('[core]\nname = "user"\n', encoding="utf-8")
|
||||
|
||||
full = self._run(root)
|
||||
self.assertEqual(full.returncode, 0, msg=full.stderr)
|
||||
# _bmad/config.user.toml is old-installer debris, not a layer:
|
||||
# its `stray` key never reaches the merge.
|
||||
self.assertEqual(json.loads(full.stdout)["core"], {"name": "user", "keep": "yes"})
|
||||
|
||||
keyed = self._run(root, "--key", "core.name", "--key", "missing")
|
||||
self.assertEqual(keyed.returncode, 0, msg=keyed.stderr)
|
||||
self.assertEqual(json.loads(keyed.stdout), {"core.name": "user"})
|
||||
|
||||
def test_malformed_present_layer_fails(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
custom = root / "_bmad" / "custom"
|
||||
custom.mkdir(parents=True)
|
||||
(root / "_bmad" / "config.toml").write_text("[core]\nvalid = true\n", encoding="utf-8")
|
||||
(custom / "config.toml").write_text("[broken\n", encoding="utf-8")
|
||||
|
||||
result = self._run(root)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("failed to parse", result.stderr)
|
||||
|
||||
def test_writes_emoji_json_when_stdout_encoding_is_cp1252(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
(root / "_bmad").mkdir(parents=True)
|
||||
(root / "_bmad" / "config.toml").write_text(
|
||||
'[agents]\nname = "Analyst"\nicon = "📊"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "cp1252"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--project-root", str(root)],
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
self.assertEqual(result.returncode, 0, msg=stderr)
|
||||
|
||||
output = result.stdout.decode("utf-8")
|
||||
self.assertIn("📊", output)
|
||||
resolved = json.loads(output)
|
||||
self.assertEqual(resolved["agents"]["icon"], "📊")
|
||||
|
||||
@staticmethod
|
||||
def _run(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--project-root", str(root), *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,175 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_customization.py"
|
||||
|
||||
|
||||
class ResolveCustomizationStdoutTests(unittest.TestCase):
|
||||
def test_missing_tomllib_exits_with_actionable_version_error(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
scripts = Path(temp_dir)
|
||||
shutil.copy2(SCRIPT, scripts / SCRIPT.name)
|
||||
shutil.copy2(SCRIPT.parent / "config_utils.py", scripts / "config_utils.py")
|
||||
(scripts / "tomllib.py").write_text(
|
||||
'raise ModuleNotFoundError("No module named tomllib", name="tomllib")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(scripts / SCRIPT.name), "--help"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 3)
|
||||
self.assertEqual(
|
||||
result.stderr,
|
||||
"error: Python 3.11+ is required (stdlib `tomllib` not found).\n",
|
||||
)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
|
||||
def test_writes_emoji_json_when_stdout_encoding_is_cp1252(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
skill_dir = Path(temp_dir) / "emoji-agent"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "customize.toml").write_text(
|
||||
'[agent]\nname = "Emoji Agent"\nicon = "🧭"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "cp1252"
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--skill",
|
||||
str(skill_dir),
|
||||
"--key",
|
||||
"agent",
|
||||
],
|
||||
capture_output=True,
|
||||
cwd=temp_dir,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
self.assertEqual(result.returncode, 0, msg=stderr)
|
||||
|
||||
output = result.stdout.decode("utf-8")
|
||||
self.assertIn("🧭", output)
|
||||
resolved = json.loads(output)
|
||||
self.assertEqual(resolved["agent"]["icon"], "🧭")
|
||||
|
||||
|
||||
def write(path: Path, body: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def facts(*entries: str) -> str:
|
||||
listed = ", ".join(f'"{entry}"' for entry in entries)
|
||||
return f"[workflow]\npersistent_facts = [{listed}]\n"
|
||||
|
||||
|
||||
def resolve(skill_dir: Path, cwd: Path, *extra: str):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--skill", str(skill_dir), "--key", "workflow", *extra],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
cwd=str(cwd),
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class ProjectRootResolutionTests(unittest.TestCase):
|
||||
"""Regression cover for #2796 — the project is where the user works, not
|
||||
where the skill is installed."""
|
||||
|
||||
def test_home_installed_skill_reads_project_override_not_home_bmad(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
home = Path(temp_dir) / "home"
|
||||
project = Path(temp_dir) / "project"
|
||||
skill = home / ".claude" / "skills" / "demo-skill"
|
||||
write(skill / "customize.toml", facts("shipped default"))
|
||||
(home / "_bmad" / "custom").mkdir(parents=True)
|
||||
write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override"))
|
||||
|
||||
result = resolve(skill, project)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
|
||||
self.assertEqual(resolved, ["shipped default", "team override"])
|
||||
|
||||
def test_project_installed_skill_still_resolves(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
skill = project / ".claude" / "skills" / "demo-skill"
|
||||
write(skill / "customize.toml", facts("shipped default"))
|
||||
write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override"))
|
||||
|
||||
result = resolve(skill, project)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
|
||||
self.assertEqual(resolved, ["shipped default", "team override"])
|
||||
|
||||
def test_walk_prefers_bmad_over_a_nearer_git_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
submodule = project / "vendor" / "sub"
|
||||
skill = Path(temp_dir) / "skills" / "demo-skill"
|
||||
write(skill / "customize.toml", facts("shipped default"))
|
||||
write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override"))
|
||||
(submodule / ".git").mkdir(parents=True)
|
||||
|
||||
result = resolve(skill, submodule)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
|
||||
self.assertEqual(resolved, ["shipped default", "team override"])
|
||||
|
||||
def test_notes_when_a_rejected_root_holds_the_only_override(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
home = Path(temp_dir) / "home"
|
||||
project = Path(temp_dir) / "project"
|
||||
skill = home / ".claude" / "skills" / "demo-skill"
|
||||
write(skill / "customize.toml", facts("shipped default"))
|
||||
write(home / "_bmad" / "custom" / "demo-skill.toml", facts("home override"))
|
||||
(project / "_bmad" / "custom").mkdir(parents=True)
|
||||
|
||||
result = resolve(skill, project)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
|
||||
self.assertEqual(resolved, ["shipped default"])
|
||||
self.assertIn("demo-skill", result.stderr)
|
||||
self.assertIn("--project-root", result.stderr)
|
||||
|
||||
def test_explicit_project_root_wins_and_stays_quiet(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
home = Path(temp_dir) / "home"
|
||||
project = Path(temp_dir) / "project"
|
||||
skill = home / ".claude" / "skills" / "demo-skill"
|
||||
write(skill / "customize.toml", facts("shipped default"))
|
||||
write(home / "_bmad" / "custom" / "demo-skill.toml", facts("home override"))
|
||||
(project / "_bmad" / "custom").mkdir(parents=True)
|
||||
|
||||
result = resolve(skill, project, "--project-root", str(home))
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
|
||||
self.assertEqual(resolved, ["shipped default", "home override"])
|
||||
self.assertEqual(result.stderr, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,226 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import roster # noqa: E402
|
||||
|
||||
SOURCE = "github:acme/tools/skills"
|
||||
ROSTER = """
|
||||
[[members]]
|
||||
code = "demo-agent-ann"
|
||||
skill = "demo-agent-ann"
|
||||
name = "Ann"
|
||||
icon = "A"
|
||||
title = "Analyst"
|
||||
persona = "Asks for evidence."
|
||||
|
||||
[[members]]
|
||||
code = "demo-agent-bob"
|
||||
skill = "demo-agent-bob"
|
||||
name = "Bob"
|
||||
persona = "Builds it."
|
||||
|
||||
[[members]]
|
||||
code = "guest"
|
||||
name = "Guest"
|
||||
persona = "Only in groups."
|
||||
|
||||
[[groups]]
|
||||
id = "demo-room"
|
||||
name = "Demo Room"
|
||||
members = ["demo-agent-ann", "demo-agent-bob", "guest"]
|
||||
"""
|
||||
|
||||
|
||||
def write_skill(root: Path, name: str, *, bmod: str = "bmod-demo") -> Path:
|
||||
folder = root / name
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "bmod.toml").write_text(f'[skill]\nbmod = "{bmod}"\nsource = "{SOURCE}"\n', encoding="utf-8")
|
||||
return folder
|
||||
|
||||
|
||||
def write_module(
|
||||
root: Path,
|
||||
skills: tuple[str, ...] = (),
|
||||
*,
|
||||
code: str = "demo",
|
||||
folder: str | None = None,
|
||||
listed: tuple[str, ...] | None = None,
|
||||
roster_text: str | None = ROSTER,
|
||||
) -> Path:
|
||||
"""Write a module record and the member skill folders in `skills`; `listed` is what the record names."""
|
||||
record = root / (folder or f"bmod-{code}")
|
||||
record.mkdir(parents=True)
|
||||
names = ", ".join(f'"{name}"' for name in (skills if listed is None else listed))
|
||||
lines = ["[bmod]", f'code = "{code}"', 'version = "1.0.0"', f'update_source = "{SOURCE}"', f"skills = [{names}]"]
|
||||
if roster_text is not None:
|
||||
(record / "roster.toml").write_text(roster_text, encoding="utf-8")
|
||||
(record / "bmod.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
for name in skills:
|
||||
write_skill(root, name, bmod=record.name)
|
||||
return record
|
||||
|
||||
|
||||
class RosterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.project = Path(self.temp.name)
|
||||
self.skills = self.project / ".claude" / "skills"
|
||||
|
||||
def test_only_members_whose_skill_is_installed_are_agents(self):
|
||||
write_module(self.skills, ("demo-agent-ann",), listed=("demo-agent-ann", "demo-agent-bob"))
|
||||
(self.skills / "demo-agent-ann" / "customize.toml").write_text('[agent]\nname = "Ann"\n', encoding="utf-8")
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual(list(report["agents"]), ["demo-agent-ann"])
|
||||
self.assertEqual(set(report["members"]), {"demo-agent-ann", "demo-agent-bob", "guest"})
|
||||
self.assertNotIn("installed", report["members"]["guest"])
|
||||
|
||||
def test_an_absent_agent_keeps_its_persona_and_names_its_install_command(self):
|
||||
write_module(self.skills, ("demo-workflow",))
|
||||
bob = roster.collect([self.skills])["members"]["demo-agent-bob"]
|
||||
self.assertFalse(bob["installed"])
|
||||
self.assertEqual(bob["persona"], "Builds it.")
|
||||
self.assertEqual(bob["install"], "npx skills add acme/tools --skill demo-agent-bob")
|
||||
|
||||
def test_an_installed_agent_answers_to_its_customized_name(self):
|
||||
write_module(self.skills, ("demo-agent-ann",))
|
||||
(self.skills / "demo-agent-ann" / "customize.toml").write_text(
|
||||
'[agent]\nname = "Ann"\ntitle = "Analyst"\n', encoding="utf-8"
|
||||
)
|
||||
custom = self.project / "_bmad" / "custom"
|
||||
custom.mkdir(parents=True)
|
||||
(custom / "demo-agent-ann.toml").write_text('[agent]\nname = "Annika"\n', encoding="utf-8")
|
||||
agent = roster.collect([self.skills], self.project)["agents"]["demo-agent-ann"]
|
||||
self.assertEqual((agent["name"], agent["title"], agent["persona"]), ("Annika", "Analyst", "Asks for evidence."))
|
||||
|
||||
def test_a_roster_is_read_from_the_record_and_lists_the_installed_member_skills(self):
|
||||
write_module(self.skills, ("demo-one", "demo-two"), listed=("demo-one", "demo-two", "demo-absent"))
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual(
|
||||
report["rosters"], [{"module": "demo", "path": "roster.toml", "skills": ["demo-one", "demo-two"]}]
|
||||
)
|
||||
self.assertEqual([group["id"] for group in report["groups"]], ["demo-room"])
|
||||
self.assertEqual(report["problems"], [])
|
||||
|
||||
def test_a_module_with_no_skills_still_offers_its_roster(self):
|
||||
write_module(self.skills)
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual(report["rosters"], [{"module": "demo", "path": "roster.toml", "skills": []}])
|
||||
self.assertEqual(set(report["members"]), {"demo-agent-ann", "demo-agent-bob", "guest"})
|
||||
self.assertEqual((report["agents"], report["problems"]), ({}, []))
|
||||
|
||||
def test_a_single_skill_module_is_its_own_member(self):
|
||||
folder = self.skills / "demo-agent-ann"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "bmod.toml").write_text(
|
||||
f'[bmod]\ncode = "solo"\nversion = "1.0.0"\nupdate_source = "{SOURCE}"\n\n[skill]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(folder / "roster.toml").write_text(ROSTER, encoding="utf-8")
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual(report["rosters"], [{"module": "solo", "path": "roster.toml", "skills": ["demo-agent-ann"]}])
|
||||
self.assertEqual(list(report["agents"]), ["demo-agent-ann"])
|
||||
self.assertEqual(report["problems"], [])
|
||||
|
||||
def test_a_record_is_found_whatever_its_folder_is_called(self):
|
||||
write_module(self.skills, ("demo-agent-ann",), folder="demo-record")
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual(list(report["agents"]), ["demo-agent-ann"])
|
||||
self.assertEqual(report["problems"], [])
|
||||
|
||||
def test_a_second_record_for_a_code_is_a_problem_and_the_first_wins(self):
|
||||
write_module(self.skills, ("demo-one",), folder="a-record")
|
||||
write_module(self.skills, ("demo-two",), folder="b-record", roster_text='[[members]]\ncode = "late"\n')
|
||||
report = roster.collect([self.skills])
|
||||
self.assertNotIn("late", report["members"])
|
||||
self.assertEqual([found["skills"] for found in report["rosters"]], [["demo-one"]])
|
||||
self.assertEqual(
|
||||
[(problem["kind"], problem["skill"]) for problem in report["problems"]], [("module", "b-record")]
|
||||
)
|
||||
|
||||
def test_a_member_skill_whose_record_is_absent_is_a_problem(self):
|
||||
write_skill(self.skills, "demo-agent-ann")
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual((report["agents"], report["members"], report["rosters"]), ({}, {}, []))
|
||||
(problem,) = report["problems"]
|
||||
self.assertEqual(
|
||||
(problem["kind"], problem["bmod"], problem["skills"]), ("module", "bmod-demo", ["demo-agent-ann"])
|
||||
)
|
||||
self.assertEqual(problem["install"], "npx skills add acme/tools --skill bmod-demo")
|
||||
|
||||
def test_a_second_module_cannot_redefine_a_member_or_group(self):
|
||||
write_module(self.skills, ("demo-one",))
|
||||
write_module(self.skills, ("other-one",), code="other")
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual(report["members"]["guest"]["module"], "demo")
|
||||
self.assertEqual(
|
||||
sorted(problem["kind"] for problem in report["problems"]), ["group", "member", "member", "member"]
|
||||
)
|
||||
|
||||
def test_central_config_agents_are_laid_over_the_scan(self):
|
||||
write_module(self.skills, ("demo-workflow",))
|
||||
(self.project / "_bmad").mkdir()
|
||||
(self.project / "_bmad" / "config.toml").write_text(
|
||||
'[agents.my-agent]\nname = "Mine"\ndescription = "From before rosters."\n', encoding="utf-8"
|
||||
)
|
||||
agent = roster.collect([self.skills], self.project)["agents"]["my-agent"]
|
||||
self.assertEqual((agent["name"], agent["persona"], agent["source"]), ("Mine", "From before rosters.", "config"))
|
||||
|
||||
def test_a_recorded_agent_whose_skill_was_removed_stays_out_of_the_default_room(self):
|
||||
write_module(self.skills, ("demo-agent-ann",))
|
||||
(self.project / "_bmad").mkdir()
|
||||
(self.project / "_bmad" / "config.toml").write_text(
|
||||
'[agents.demo-agent-ann]\nteam = "crew"\n\n[agents.demo-agent-bob]\nname = "Bob"\n', encoding="utf-8"
|
||||
)
|
||||
agents = roster.collect([self.skills], self.project)["agents"]
|
||||
self.assertEqual(sorted(agents), ["demo-agent-ann"])
|
||||
self.assertEqual(agents["demo-agent-ann"]["team"], "crew")
|
||||
|
||||
def test_config_does_not_replace_a_roster_agents_name_or_module(self):
|
||||
write_module(self.skills, ("demo-agent-ann",))
|
||||
(self.project / "_bmad").mkdir()
|
||||
(self.project / "_bmad" / "config.toml").write_text(
|
||||
'[agents.demo-agent-ann]\nmodule = "old"\nname = "Recorded"\nteam = "crew"\n', encoding="utf-8"
|
||||
)
|
||||
agent = roster.collect([self.skills], self.project)["agents"]["demo-agent-ann"]
|
||||
self.assertEqual((agent["module"], agent["team"]), ("demo", "crew"))
|
||||
self.assertNotEqual(agent["name"], "Recorded")
|
||||
|
||||
def test_a_roster_key_in_the_record_is_ignored(self):
|
||||
record = write_module(self.skills, ("demo-one",), roster_text=None)
|
||||
text = (record / "bmod.toml").read_text(encoding="utf-8")
|
||||
(record / "bmod.toml").write_text(text + 'roster = ["other.toml"]\n', encoding="utf-8")
|
||||
(record / "other.toml").write_text(ROSTER, encoding="utf-8")
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual((report["members"], report["rosters"], report["problems"]), ({}, [], []))
|
||||
|
||||
def test_a_roster_that_links_outside_the_record_folder_is_refused(self):
|
||||
record = write_module(self.skills, ("demo-one",))
|
||||
(self.skills / "demo-one" / "elsewhere.toml").write_text(ROSTER, encoding="utf-8")
|
||||
(record / "roster.toml").unlink()
|
||||
try:
|
||||
(record / "roster.toml").symlink_to(self.skills / "demo-one" / "elsewhere.toml")
|
||||
except OSError:
|
||||
self.skipTest("symlinks are not available")
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual(report["members"], {})
|
||||
self.assertEqual([problem["kind"] for problem in report["problems"]], ["roster"])
|
||||
|
||||
def test_members_or_groups_that_are_not_lists_are_problems(self):
|
||||
write_module(self.skills, ("demo-one",), roster_text='members = "ann"\n\n[groups]\nid = "room"\n')
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual((report["members"], report["groups"]), ({}, []))
|
||||
self.assertEqual([problem["kind"] for problem in report["problems"]], ["roster", "roster"])
|
||||
|
||||
def test_a_module_without_a_roster_offers_nothing(self):
|
||||
write_module(self.skills, ("demo-one",), roster_text=None)
|
||||
report = roster.collect([self.skills])
|
||||
self.assertEqual((report["agents"], report["groups"], report["problems"]), ({}, [], []))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,237 @@
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import setup_check # noqa: E402
|
||||
|
||||
SOURCE = "github:bmad-code-org/BMAD-METHOD/skills"
|
||||
REQUIRES_BMAD = f'required_skills = [{{ skill = "bmad", version = "6.13.0", source = "{SOURCE}" }}]\n'
|
||||
|
||||
|
||||
def write_bmod(folder: Path, *, code: str, version: str, skills: list[str], extra: str = "") -> None:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
listed = ", ".join(f'"{name}"' for name in skills)
|
||||
(folder / "bmod.toml").write_text(
|
||||
f'[bmod]\ncode = "{code}"\nversion = "{version}"\nupdate_source = "{SOURCE}"\nskills = [{listed}]\n{extra}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def write_skill(folder: Path, *, bmod: str, source: str = SOURCE, extra: str = "") -> None:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "bmod.toml").write_text(f'[skill]\nbmod = "{bmod}"\nsource = "{source}"\n{extra}', encoding="utf-8")
|
||||
|
||||
|
||||
class OwedSetupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.project = Path(self.temp.name) / "project"
|
||||
self.skills = self.project / ".claude" / "skills"
|
||||
(self.project / "_bmad").mkdir(parents=True)
|
||||
(self.project / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8")
|
||||
|
||||
def hub(self, version: str) -> None:
|
||||
write_bmod(self.skills / "bmod-core-tools", code="core-tools", version=version, skills=["bmad"])
|
||||
write_skill(self.skills / "bmad", bmod="bmod-core-tools")
|
||||
|
||||
def skill(self, *, module_extra: str = "", skill_extra: str = "") -> Path:
|
||||
write_bmod(self.skills / "bmod-demo", code="demo", version="1.0.0", skills=["demo-skill"], extra=module_extra)
|
||||
folder = self.skills / "demo-skill"
|
||||
write_skill(folder, bmod="bmod-demo", extra=skill_extra)
|
||||
return folder
|
||||
|
||||
def test_nothing_is_owed_when_requirements_are_met(self):
|
||||
self.hub("6.13.0")
|
||||
folder = self.skill(module_extra=REQUIRES_BMAD)
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_a_skill_without_a_bmod_file_owes_nothing(self):
|
||||
folder = self.skills / "plain-skill"
|
||||
folder.mkdir(parents=True)
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_a_module_record_owes_nothing(self):
|
||||
self.skill(module_extra=REQUIRES_BMAD)
|
||||
self.assertEqual(setup_check.owed(self.skills / "bmod-demo", self.project), [])
|
||||
|
||||
def test_a_missing_module_record_is_named_with_its_install_command(self):
|
||||
folder = self.skills / "demo-skill"
|
||||
write_skill(folder, bmod="bmod-demo", source="github:acme/demo/skills")
|
||||
(note,) = setup_check.owed(folder, self.project)
|
||||
self.assertIn("`bmod-demo`", note)
|
||||
self.assertIn("`npx skills add acme/demo --skill bmod-demo`", note)
|
||||
|
||||
def test_a_skill_without_its_record_still_reports_its_own_requirements(self):
|
||||
folder = self.skills / "demo-skill"
|
||||
write_skill(folder, bmod="bmod-demo", extra='required_skills = ["other-skill"]\n')
|
||||
record, requirement = setup_check.owed(folder, self.project)
|
||||
self.assertIn("`bmod-demo`", record)
|
||||
self.assertIn("`npx skills add bmad-code-org/BMAD-METHOD --skill other-skill`", requirement)
|
||||
|
||||
def test_an_older_required_skill_is_reported_with_both_versions(self):
|
||||
self.hub("6.12.0")
|
||||
folder = self.skill(module_extra=REQUIRES_BMAD)
|
||||
(note,) = setup_check.owed(folder, self.project)
|
||||
self.assertIn("`bmad` 6.13.0 or later", note)
|
||||
self.assertIn("6.12.0 is installed", note)
|
||||
|
||||
def test_the_next_build_of_the_required_version_meets_it(self):
|
||||
self.hub("6.13.0-next")
|
||||
folder = self.skill(module_extra=REQUIRES_BMAD)
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_the_next_build_of_an_earlier_version_does_not(self):
|
||||
self.hub("6.12.0-next")
|
||||
folder = self.skill(module_extra=REQUIRES_BMAD)
|
||||
self.assertEqual(len(setup_check.owed(folder, self.project)), 1)
|
||||
|
||||
def test_a_required_skill_without_its_module_record_is_not_called_outdated(self):
|
||||
write_skill(self.skills / "bmad", bmod="bmod-core-tools")
|
||||
folder = self.skill(module_extra=REQUIRES_BMAD)
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_a_required_skill_with_no_bmod_file_is_reported_with_an_unreadable_version(self):
|
||||
(self.skills / "bmad").mkdir(parents=True)
|
||||
folder = self.skill(module_extra=REQUIRES_BMAD)
|
||||
(note,) = setup_check.owed(folder, self.project)
|
||||
self.assertIn("version cannot be read", note)
|
||||
self.assertIn("npx skills update", note)
|
||||
|
||||
def test_a_requirement_may_carry_no_version(self):
|
||||
(self.skills / "other-skill").mkdir(parents=True)
|
||||
folder = self.skill(skill_extra='required_skills = ["other-skill"]\n')
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_the_modules_requirements_and_the_skills_own_are_both_checked(self):
|
||||
folder = self.skill(
|
||||
module_extra='required_skills = ["module-need"]\n',
|
||||
skill_extra='required_skills = ["skill-need", "module-need"]\n',
|
||||
)
|
||||
notes = setup_check.owed(folder, self.project)
|
||||
self.assertEqual(len(notes), 2)
|
||||
self.assertIn("`module-need`", notes[0])
|
||||
self.assertIn("`skill-need`", notes[1])
|
||||
|
||||
def test_a_skill_required_by_both_lists_is_held_to_the_higher_minimum(self):
|
||||
self.hub("6.13.0")
|
||||
higher = f'required_skills = [{{ skill = "bmad", version = "6.14.0", source = "{SOURCE}" }}]\n'
|
||||
for module_extra, skill_extra in (
|
||||
(REQUIRES_BMAD, higher),
|
||||
(higher, REQUIRES_BMAD),
|
||||
('required_skills = ["bmad"]\n', higher),
|
||||
):
|
||||
with self.subTest(module=module_extra):
|
||||
folder = self.skill(module_extra=module_extra, skill_extra=skill_extra)
|
||||
(note,) = setup_check.owed(folder, self.project)
|
||||
self.assertIn("`bmad` 6.14.0 or later", note)
|
||||
|
||||
def test_a_requirement_that_names_the_skill_itself_is_skipped(self):
|
||||
folder = self.skill(module_extra='required_skills = ["demo-skill"]\n')
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_a_missing_required_skill_names_its_install_command(self):
|
||||
folder = self.skill(
|
||||
skill_extra='required_skills = [{ skill = "other-skill", version = "2.0.0", source = "github:acme/tools/skills" }]\n'
|
||||
)
|
||||
(note,) = setup_check.owed(folder, self.project)
|
||||
self.assertIn("`npx skills add acme/tools --skill other-skill`", note)
|
||||
|
||||
def test_a_missing_required_skill_defaults_to_the_declaring_files_source(self):
|
||||
folder = self.skill(skill_extra='required_skills = ["bmad"]\n')
|
||||
(note,) = setup_check.owed(folder, self.project)
|
||||
self.assertIn("`npx skills add bmad-code-org/BMAD-METHOD --skill bmad`", note)
|
||||
|
||||
def test_recommended_skills_are_never_reported(self):
|
||||
folder = self.skill(
|
||||
module_extra='recommended_skills = ["module-extra"]\n',
|
||||
skill_extra='recommended_skills = ["other-skill"]\n',
|
||||
)
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_an_unanswered_question_is_reported_and_an_empty_answer_is_not(self):
|
||||
question = (
|
||||
'\n[[bmod.config_questions]]\nkey = "active_initiative"\nprompt = "Active initiative?"\ndefault = ""\n'
|
||||
)
|
||||
folder = self.skill(module_extra=question)
|
||||
(note,) = setup_check.owed(folder, self.project)
|
||||
self.assertIn("active_initiative", note)
|
||||
self.assertIn("`bmad setup`", note)
|
||||
|
||||
(self.project / "_bmad" / "config.toml").write_text(
|
||||
'[core]\n\n[modules.demo]\nactive_initiative = ""\n', encoding="utf-8"
|
||||
)
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_a_user_question_is_answered_by_the_users_own_file(self):
|
||||
question = (
|
||||
'\n[[bmod.config_questions]]\nkey = "note_style"\nscope = "user"\nprompt = "Style?"\ndefault = "bullets"\n'
|
||||
)
|
||||
folder = self.skill(module_extra=question)
|
||||
self.assertEqual(len(setup_check.owed(folder, self.project)), 1)
|
||||
|
||||
custom = self.project / "_bmad" / "custom"
|
||||
custom.mkdir()
|
||||
(custom / "config.user.toml").write_text('[modules.demo]\nnote_style = "prose"\n', encoding="utf-8")
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_a_missing_or_stale_placed_script_is_reported(self):
|
||||
folder = self.skill(skill_extra='scripts = ["scripts/tool.py"]\n')
|
||||
(folder / "scripts").mkdir()
|
||||
(folder / "scripts" / "tool.py").write_bytes(b"new\n")
|
||||
(note,) = setup_check.owed(folder, self.project)
|
||||
self.assertIn("`_bmad/demo/scripts/`", note)
|
||||
self.assertIn("`bmad setup`", note)
|
||||
|
||||
placed = self.project / "_bmad" / "demo" / "scripts" / "tool.py"
|
||||
placed.parent.mkdir(parents=True)
|
||||
placed.write_bytes(b"old\n")
|
||||
self.assertEqual(len(setup_check.owed(folder, self.project)), 1)
|
||||
placed.write_bytes(b"new\n")
|
||||
self.assertEqual(setup_check.owed(folder, self.project), [])
|
||||
|
||||
def test_a_single_skill_module_reads_both_tables_from_its_own_file(self):
|
||||
folder = self.skills / "release-notes"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "bmod.toml").write_text(
|
||||
'[bmod]\ncode = "notes"\nversion = "1.0.0"\nupdate_source = "github:acme/release-notes"\n'
|
||||
'required_skills = ["other-skill"]\n\n'
|
||||
'[[bmod.config_questions]]\nkey = "path"\nprompt = "Path?"\ndefault = "x"\n\n[skill]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
requirement, question = setup_check.owed(folder, self.project)
|
||||
self.assertIn("`npx skills add acme/release-notes --skill other-skill`", requirement)
|
||||
self.assertIn("module `notes`", question)
|
||||
|
||||
def test_report_writes_each_note_as_an_instruction(self):
|
||||
folder = self.skills / "demo-skill"
|
||||
write_skill(folder, bmod="bmod-demo")
|
||||
stream = io.StringIO()
|
||||
with redirect_stderr(stream):
|
||||
setup_check.report(folder, self.project)
|
||||
self.assertTrue(stream.getvalue().startswith("setup: before continuing, tell the user that `demo-skill` "))
|
||||
|
||||
def test_report_stays_silent_when_a_bmod_file_cannot_be_parsed(self):
|
||||
folder = self.skills / "broken-skill"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "bmod.toml").write_text("not toml [", encoding="utf-8")
|
||||
stream = io.StringIO()
|
||||
with redirect_stderr(stream):
|
||||
setup_check.report(folder, self.project)
|
||||
self.assertEqual(stream.getvalue(), "")
|
||||
|
||||
write_skill(folder, bmod="bmod-demo")
|
||||
(self.skills / "bmod-demo").mkdir()
|
||||
(self.skills / "bmod-demo" / "bmod.toml").write_text("not toml [", encoding="utf-8")
|
||||
with redirect_stderr(stream):
|
||||
setup_check.report(folder, self.project)
|
||||
self.assertEqual(stream.getvalue(), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,357 @@
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
STAMPER = SCRIPTS / "stamp_release.py"
|
||||
SETUP_PY = SCRIPTS / "setup.py"
|
||||
|
||||
SOURCE = "github:bmad-code-org/BMAD-METHOD/skills"
|
||||
|
||||
RECORD = (
|
||||
"[bmod]\n"
|
||||
'code = "{code}"\n'
|
||||
'version = "{version}"\n'
|
||||
f'update_source = "{SOURCE}"\n'
|
||||
"skills = [{skills}]\n"
|
||||
'pre_install_message = ""\n'
|
||||
'post_install_message = ""\n'
|
||||
"\n"
|
||||
"[[bmod.knowledge]]\n"
|
||||
'path = "extra.md"\n'
|
||||
)
|
||||
|
||||
SKILL = f'[skill]\nbmod = "{{bmod}}"\nsource = "{SOURCE}"\n'
|
||||
|
||||
MEMBERS = {"method": ("bmad-build", "bmad-spec"), "core-tools": ("bmad", "bmad-flow")}
|
||||
|
||||
|
||||
def load_module(name: str, path: Path):
|
||||
sys.dont_write_bytecode = True
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
sr = load_module("stamp_release", STAMPER)
|
||||
setup = load_module("bmad_setup_stamp_contract", SETUP_PY)
|
||||
|
||||
|
||||
def write(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def record_text(code: str, version: str) -> str:
|
||||
skills = ", ".join(f'"{skill}"' for skill in MEMBERS[code])
|
||||
return RECORD.format(code=code, version=version, skills=skills)
|
||||
|
||||
|
||||
def make_tree(root: Path, version: str = "6.11.0-next") -> None:
|
||||
for code, members in MEMBERS.items():
|
||||
folder = root / "skills" / f"bmod-{code}"
|
||||
write(folder / "bmod.toml", record_text(code, version))
|
||||
write(folder / "help" / "help.md", "# help\n")
|
||||
write(folder / "extra.md", "# extra\n")
|
||||
write(folder / "SKILL.md", "# record\n")
|
||||
for skill in members:
|
||||
write(root / "skills" / skill / "bmod.toml", SKILL.format(bmod=f"bmod-{code}"))
|
||||
|
||||
|
||||
def snapshot(root: Path) -> dict[str, bytes]:
|
||||
return {path.relative_to(root).as_posix(): path.read_bytes() for path in sorted(root.rglob("*")) if path.is_file()}
|
||||
|
||||
|
||||
def run_stamper(root: Path, version: str) -> tuple[int, str, str]:
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = sr.run(root, version)
|
||||
return code, out.getvalue(), err.getvalue()
|
||||
|
||||
|
||||
class StampReleaseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.root = Path(self._tmp.name).resolve()
|
||||
self.method = self.root / "skills" / "bmod-method" / "bmod.toml"
|
||||
|
||||
def assert_refused(self, version: str, *expected: str) -> None:
|
||||
before = snapshot(self.root)
|
||||
code, _, err = run_stamper(self.root, version)
|
||||
self.assertEqual(code, 1)
|
||||
for text in expected:
|
||||
self.assertIn(text, err)
|
||||
self.assertEqual(snapshot(self.root), before)
|
||||
|
||||
def test_happy_path_stamps_every_record_and_no_member_skill(self):
|
||||
make_tree(self.root)
|
||||
before = snapshot(self.root)
|
||||
code, out, err = run_stamper(self.root, "1.2.0")
|
||||
self.assertEqual(code, 0, err)
|
||||
after = snapshot(self.root)
|
||||
changed = sorted(name for name in after if after[name] != before[name])
|
||||
self.assertEqual(changed, ["skills/bmod-core-tools/bmod.toml", "skills/bmod-method/bmod.toml"])
|
||||
self.assertEqual(self.method.read_text(encoding="utf-8"), record_text("method", "1.2.0"))
|
||||
self.assertIn("Stamped version 1.2.0 into 2 files", out)
|
||||
self.assertIn("skills/bmod-method/bmod.toml", out)
|
||||
self.assertIn("skills/bmod-core-tools/bmod.toml", out)
|
||||
self.assertNotIn("skills/bmad-build/bmod.toml", out)
|
||||
|
||||
def test_single_skill_module_is_stamped(self):
|
||||
make_tree(self.root)
|
||||
notes = self.root / "skills" / "release-notes" / "bmod.toml"
|
||||
write(
|
||||
notes,
|
||||
f'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n'
|
||||
'pre_install_message = ""\npost_install_message = ""\n\n'
|
||||
f'[skill]\nrequired_skills = [{{ skill = "x", version = "1.0.0", source = "{SOURCE}" }}]\n',
|
||||
)
|
||||
write(notes.parent / "SKILL.md", "# notes\n")
|
||||
code, out, err = run_stamper(self.root, "1.2.0")
|
||||
self.assertEqual(code, 0, err)
|
||||
data = tomllib.loads(notes.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["bmod"]["version"], "1.2.0")
|
||||
self.assertEqual(data["skill"]["required_skills"][0]["version"], "1.0.0")
|
||||
self.assertIn("into 3 files", out)
|
||||
|
||||
def test_non_semver_version_touches_nothing(self):
|
||||
make_tree(self.root)
|
||||
self.assert_refused("banana", "SemVer")
|
||||
|
||||
def test_dev_prerelease_rejected_with_explanation(self):
|
||||
make_tree(self.root)
|
||||
self.assert_refused("1.2.0-dev", "-dev", "current")
|
||||
|
||||
def test_build_metadata_rejected_with_explanation(self):
|
||||
make_tree(self.root)
|
||||
self.assert_refused("1.2.0+hotfix", "build metadata", "'1.2.0'")
|
||||
|
||||
def test_build_metadata_on_prerelease_rejected(self):
|
||||
make_tree(self.root)
|
||||
self.assert_refused("1.2.0-rc.1+build.5", "build metadata")
|
||||
|
||||
def test_orderable_prerelease_is_accepted(self):
|
||||
make_tree(self.root)
|
||||
code, _, err = run_stamper(self.root, "6.12.0-next.1")
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual(tomllib.loads(self.method.read_text(encoding="utf-8"))["bmod"]["version"], "6.12.0-next.1")
|
||||
|
||||
def test_repository_check_failure_names_the_file_and_touches_nothing(self):
|
||||
make_tree(self.root)
|
||||
write(self.root / "skills" / "bmad-orphan" / "SKILL.md", "# orphan\n")
|
||||
self.assert_refused("1.2.0", "skills/bmad-orphan: missing bmod.toml")
|
||||
|
||||
def test_record_missing_version_key_names_file_and_touches_nothing(self):
|
||||
make_tree(self.root)
|
||||
write(self.method, record_text("method", "x").replace('version = "x"\n', ""))
|
||||
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "'bmod.version'")
|
||||
|
||||
def test_every_problem_is_reported_in_one_run(self):
|
||||
make_tree(self.root)
|
||||
write(self.root / "skills" / "bmad-orphan" / "SKILL.md", "# orphan\n")
|
||||
(self.root / "skills" / "bmod-method" / "help" / "help.md").unlink()
|
||||
self.assert_refused("1.2.0", "skills/bmad-orphan", "skills/bmod-method/help/help.md")
|
||||
|
||||
def test_empty_skills_tree_reports_error(self):
|
||||
code, _, err = run_stamper(self.root, "1.2.0")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("bmod.toml", err)
|
||||
|
||||
def test_tree_with_no_module_record_reports_error(self):
|
||||
write(self.root / "skills" / "plain" / "bmod.toml", '[skill]\nbmod = "bmod-x"\nsource = "github:o/r"\n')
|
||||
self.assert_refused("1.2.0", "[skill] bmod names 'bmod-x', which is not a module record")
|
||||
|
||||
def test_keys_and_tables_the_stamper_does_not_know_are_left_alone(self):
|
||||
make_tree(self.root)
|
||||
text = record_text("method", "6.11.0-next").replace(
|
||||
'code = "method"\n', 'code = "method"\nfuture_field = ["anything"]\n'
|
||||
)
|
||||
extra = '\n[bmod.builder]\nversion = "9.9.9"\n\n[other]\nversion = "8.8.8"\nnote = "anything"\n'
|
||||
write(self.method, text + extra)
|
||||
code, _, err = run_stamper(self.root, "1.2.0")
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual(
|
||||
self.method.read_text(encoding="utf-8"),
|
||||
text.replace('version = "6.11.0-next"', 'version = "1.2.0"') + extra,
|
||||
)
|
||||
|
||||
def test_version_in_a_table_before_bmod_is_left_alone(self):
|
||||
make_tree(self.root)
|
||||
text = '[other]\nversion = "8.8.8"\n\n' + record_text("method", "6.11.0-next")
|
||||
write(self.method, text)
|
||||
code, _, err = run_stamper(self.root, "1.2.0")
|
||||
self.assertEqual(code, 0, err)
|
||||
data = tomllib.loads(self.method.read_text(encoding="utf-8"))
|
||||
self.assertEqual((data["other"]["version"], data["bmod"]["version"]), ("8.8.8", "1.2.0"))
|
||||
|
||||
def test_requirement_versions_are_left_alone(self):
|
||||
make_tree(self.root)
|
||||
line = f'required_skills = [{{ skill = "bmad", version = "6.13.0", source = "{SOURCE}" }}]\n'
|
||||
text = record_text("method", "6.11.0-next").replace("\n[[bmod.knowledge]]", line + "\n[[bmod.knowledge]]")
|
||||
write(self.method, text)
|
||||
code, _, err = run_stamper(self.root, "1.2.0")
|
||||
self.assertEqual(code, 0, err)
|
||||
stamped = self.method.read_text(encoding="utf-8")
|
||||
self.assertIn(line, stamped)
|
||||
self.assertIn('version = "1.2.0"\n', stamped)
|
||||
|
||||
def test_indentation_comment_and_line_endings_are_preserved(self):
|
||||
make_tree(self.root)
|
||||
text = record_text("method", "6.11.0-next").replace(
|
||||
'version = "6.11.0-next"\n', ' version = "6.11.0-next" # stamped\n'
|
||||
)
|
||||
self.method.write_bytes(text.replace("\n", "\r\n").encode("utf-8"))
|
||||
code, _, err = run_stamper(self.root, "1.2.0")
|
||||
self.assertEqual(code, 0, err)
|
||||
expected = text.replace('"6.11.0-next"', '"1.2.0"').replace("\n", "\r\n")
|
||||
self.assertEqual(self.method.read_bytes(), expected.encode("utf-8"))
|
||||
|
||||
def test_version_the_stamper_cannot_find_as_a_line_touches_nothing(self):
|
||||
make_tree(self.root)
|
||||
write(self.method, record_text("method", "x").replace('version = "x"', "version = '6.11.0-next'"))
|
||||
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "expected exactly one 'version = \"...\"' line")
|
||||
|
||||
def test_version_lookalike_inside_a_multi_line_string_touches_nothing(self):
|
||||
make_tree(self.root)
|
||||
note = 'note = """\nversion = "x"\n"""\n'
|
||||
text = record_text("method", "6.11.0-next").replace('code = "method"\n', 'code = "method"\n' + note)
|
||||
write(self.method, text)
|
||||
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "line inside [bmod], found 2")
|
||||
|
||||
def test_only_version_line_being_inside_a_string_touches_nothing(self):
|
||||
make_tree(self.root)
|
||||
note = 'note = """\nversion = "x"\n"""\n'
|
||||
text = record_text("method", "x").replace('version = "x"\n', "version = '6.11.0-next'\n" + note)
|
||||
write(self.method, text)
|
||||
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "would change something other than")
|
||||
|
||||
def test_bmod_written_without_a_table_header_touches_nothing(self):
|
||||
make_tree(self.root)
|
||||
write(
|
||||
self.method,
|
||||
f'bmod = {{ code = "method", version = "6.11.0-next", update_source = "{SOURCE}" }}\n',
|
||||
)
|
||||
for skill in MEMBERS["method"]:
|
||||
(self.root / "skills" / skill / "bmod.toml").unlink()
|
||||
(self.root / "skills" / skill).rmdir()
|
||||
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "expected exactly one '[bmod]' table header")
|
||||
|
||||
|
||||
class CheckModeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.root = Path(self._tmp.name).resolve()
|
||||
make_tree(self.root)
|
||||
|
||||
def check(self, *argv: str) -> tuple[int, str, str]:
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = sr.main([*argv, "--project-root", str(self.root)])
|
||||
return code, out.getvalue(), err.getvalue()
|
||||
|
||||
def test_check_validates_and_writes_nothing(self):
|
||||
before = snapshot(self.root)
|
||||
code, out, err = self.check("--check")
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertIn("bmod files valid:", out)
|
||||
self.assertEqual(snapshot(self.root), before)
|
||||
|
||||
def test_check_fails_on_a_broken_tree(self):
|
||||
(self.root / "skills" / "bmod-method" / "SKILL.md").unlink()
|
||||
before = snapshot(self.root)
|
||||
code, _, err = self.check("--check")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("skills/bmod-method: a module record folder must ship SKILL.md", err)
|
||||
self.assertEqual(snapshot(self.root), before)
|
||||
|
||||
def test_version_and_check_together_is_a_usage_error(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||
sr.main(["1.2.0", "--check"])
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
|
||||
def test_neither_version_nor_check_is_a_usage_error(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||
sr.main([])
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
|
||||
def test_run_from_the_repository_root_checks_the_current_directory(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-B", str(STAMPER), "--check"], cwd=self.root, capture_output=True, text=True, check=False
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("bmod files valid:", result.stdout)
|
||||
|
||||
|
||||
class StampedContentTests(unittest.TestCase):
|
||||
def test_only_the_bmod_table_version_changes(self):
|
||||
original = '[a]\nversion = "1"\n[bmod]\ncode = "x"\nversion = "2"\n[[bmod.knowledge]]\nversion = "3"\n'
|
||||
stamped = sr.validator.stamp_text(original, "9.9.9")
|
||||
self.assertEqual(stamped, original.replace('version = "2"', 'version = "9.9.9"'))
|
||||
|
||||
def test_two_version_lines_inside_bmod_are_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sr.validator.stamp_text('[bmod]\nversion = "1"\nversion = "2"\n', "9.9.9")
|
||||
|
||||
|
||||
class InstallerContractTests(unittest.TestCase):
|
||||
"""Pin the version rules the stamper adds on top of skills/bmad/scripts/setup.py.
|
||||
|
||||
If setup.py's rules drift, these fail instead of shipping a release an
|
||||
installed module cannot order.
|
||||
"""
|
||||
|
||||
def test_stamper_uses_the_runtime_semver(self):
|
||||
self.assertEqual(sr.setup.SEMVER.pattern, setup.SEMVER.pattern)
|
||||
|
||||
def test_validate_version_accepts_exactly_what_setup_can_distinguish(self):
|
||||
"""Accept a version only if setup.py can both order it and tell it apart.
|
||||
|
||||
Build metadata is orderable but not distinguishing: setup.py drops it,
|
||||
so `1.2.0+hotfix` compares equal to `1.2.0` and a release stamped that
|
||||
way is invisible to an installed module. The stamper is stricter than
|
||||
orderability by exactly that much.
|
||||
"""
|
||||
candidates = (
|
||||
"1.2.0",
|
||||
"0.0.1",
|
||||
"6.12.0-next.1",
|
||||
"1.2.0-rc.1+build.5",
|
||||
"1.2.0+hotfix",
|
||||
"banana",
|
||||
"1.2",
|
||||
"01.2.0",
|
||||
"1.2.0-dev",
|
||||
"1.2.0-DEV",
|
||||
"1.2.0-dev.1",
|
||||
"",
|
||||
)
|
||||
for version in candidates:
|
||||
parsed = setup.parse_orderable_semver(version)
|
||||
distinguishable = parsed is not None and "+" not in version
|
||||
try:
|
||||
sr.validate_version(version)
|
||||
accepted = True
|
||||
except sr.StampError:
|
||||
accepted = False
|
||||
self.assertEqual(accepted, distinguishable, version)
|
||||
|
||||
def test_setup_orders_build_metadata_as_equal(self):
|
||||
"""The premise of the rejection above, pinned against setup.py itself."""
|
||||
self.assertEqual(
|
||||
setup.parse_orderable_semver("1.2.0+hotfix"),
|
||||
setup.parse_orderable_semver("1.2.0"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,553 @@
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
VALIDATOR = Path(__file__).resolve().parents[1] / "validate_manifests.py"
|
||||
|
||||
SOURCE = "github:bmad-code-org/BMAD-METHOD/skills"
|
||||
MESSAGES = 'pre_install_message = ""\npost_install_message = ""\n'
|
||||
|
||||
METHOD_RECORD = (
|
||||
"[bmod]\n"
|
||||
'code = "method"\n'
|
||||
'version = "{version}"\n'
|
||||
f'update_source = "{SOURCE}"\n'
|
||||
'skills = ["bmad-build", "bmad-spec"]\n'
|
||||
'required_skills = ["bmad"]\n'
|
||||
f"{MESSAGES}"
|
||||
"\n"
|
||||
"[[bmod.knowledge]]\n"
|
||||
'path = "delivery-help.md"\n'
|
||||
'skills = ["bmad-build"]\n'
|
||||
)
|
||||
|
||||
CORE_TOOLS_RECORD = (
|
||||
"[bmod]\n"
|
||||
'code = "core-tools"\n'
|
||||
'version = "{version}"\n'
|
||||
f'update_source = "{SOURCE}"\n'
|
||||
'skills = ["bmad", "bmad-flow"]\n'
|
||||
f"{MESSAGES}"
|
||||
)
|
||||
|
||||
SKILL = f'[skill]\nbmod = "{{bmod}}"\nsource = "{SOURCE}"\n'
|
||||
|
||||
ROSTER = (
|
||||
"[[members]]\n"
|
||||
'code = "bmad-build"\n'
|
||||
'skill = "bmad-build"\n'
|
||||
'name = "Amelia"\n'
|
||||
"\n"
|
||||
"[[members]]\n"
|
||||
'code = "guest"\n'
|
||||
'name = "Guest"\n'
|
||||
"\n"
|
||||
"[[groups]]\n"
|
||||
'id = "team"\n'
|
||||
'members = ["bmad-build", "guest"]\n'
|
||||
)
|
||||
|
||||
MEMBERS = {"bmod-method": ("bmad-build", "bmad-spec"), "bmod-core-tools": ("bmad", "bmad-flow")}
|
||||
|
||||
|
||||
def load_module(name: str, path: Path):
|
||||
sys.dont_write_bytecode = True
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
vm = load_module("validate_manifests", VALIDATOR)
|
||||
|
||||
|
||||
def write(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def make_tree(root: Path, version: str = "6.11.0-next") -> None:
|
||||
skills = root / "skills"
|
||||
write(skills / "bmod-method" / "bmod.toml", METHOD_RECORD.format(version=version))
|
||||
write(skills / "bmod-method" / "help" / "help.md", "# help\n")
|
||||
write(skills / "bmod-method" / "delivery-help.md", "# delivery\n")
|
||||
write(skills / "bmod-method" / "roster.toml", ROSTER)
|
||||
write(skills / "bmod-core-tools" / "bmod.toml", CORE_TOOLS_RECORD.format(version=version))
|
||||
write(skills / "bmod-core-tools" / "help" / "help.md", "# help\n")
|
||||
for bmod, members in MEMBERS.items():
|
||||
write(skills / bmod / "SKILL.md", "# record\n")
|
||||
for skill in members:
|
||||
write(skills / skill / "bmod.toml", SKILL.format(bmod=bmod))
|
||||
write(skills / skill / "SKILL.md", "# skill\n")
|
||||
|
||||
|
||||
class ValidatorCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.root = Path(self._tmp.name).resolve()
|
||||
self.skills = self.root / "skills"
|
||||
make_tree(self.root)
|
||||
|
||||
def problems(self) -> str:
|
||||
return "\n".join(vm.check_repo(self.root).problems)
|
||||
|
||||
def method_record(self, old: str, new: str) -> None:
|
||||
path = self.skills / "bmod-method" / "bmod.toml"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
self.assertIn(old, text)
|
||||
write(path, text.replace(old, new))
|
||||
|
||||
|
||||
class CleanTreeTests(ValidatorCase):
|
||||
def test_clean_tree_has_no_problems_and_reports_its_records(self):
|
||||
report = vm.check_repo(self.root)
|
||||
self.assertEqual(report.problems, ())
|
||||
self.assertEqual(
|
||||
[path.relative_to(self.root).as_posix() for path in report.records],
|
||||
["skills/bmod-core-tools/bmod.toml", "skills/bmod-method/bmod.toml"],
|
||||
)
|
||||
self.assertEqual((report.skills, report.documents), (4, 3))
|
||||
|
||||
def test_unknown_keys_and_tables_are_left_alone(self):
|
||||
self.method_record('code = "method"\n', 'code = "method"\nfuture_field = ["anything"]\n')
|
||||
path = self.skills / "bmod-method" / "bmod.toml"
|
||||
write(path, path.read_text(encoding="utf-8") + '\n[builder]\nversion = "9.9.9"\n')
|
||||
write(
|
||||
self.skills / "bmad-spec" / "bmod.toml",
|
||||
SKILL.format(bmod="bmod-method") + "later = true\n\n[extra]\nx = 1\n",
|
||||
)
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_single_skill_module_is_valid_under_any_folder_name(self):
|
||||
write(
|
||||
self.skills / "release-notes" / "bmod.toml",
|
||||
'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "github:acme/notes"\n'
|
||||
f"{MESSAGES}\n[skill]\n",
|
||||
)
|
||||
write(self.skills / "release-notes" / "help" / "help.md", "# help\n")
|
||||
write(self.skills / "release-notes" / "SKILL.md", "# notes\n")
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_module_with_no_skills_is_valid(self):
|
||||
write(
|
||||
self.skills / "bmod-rooms" / "bmod.toml",
|
||||
f'[bmod]\ncode = "rooms"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n{MESSAGES}',
|
||||
)
|
||||
write(self.skills / "bmod-rooms" / "help" / "help.md", "# help\n")
|
||||
write(self.skills / "bmod-rooms" / "SKILL.md", "# rooms\n")
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_empty_skills_tree_is_a_problem(self):
|
||||
empty = self.root / "empty"
|
||||
empty.mkdir()
|
||||
self.assertIn("no skills/*/bmod.toml found", "\n".join(vm.check_repo(empty).problems))
|
||||
|
||||
def test_main_exit_codes(self):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = vm.main(["--project-root", str(self.root)])
|
||||
self.assertEqual(code, 0, err.getvalue())
|
||||
self.assertIn("4 skills, 2 module records, 3 knowledge documents", out.getvalue())
|
||||
(self.skills / "bmad-flow" / "bmod.toml").unlink()
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = vm.main(["--project-root", str(self.root)])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("skills/bmad-flow: missing bmod.toml", err.getvalue())
|
||||
|
||||
def test_run_from_outside_the_bmad_scripts_checks_the_current_directory(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-B", str(VALIDATOR)], cwd=self.root, capture_output=True, text=True, check=False
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("bmod files valid: 4 skills, 2 module records", result.stdout)
|
||||
(self.skills / "bmad-flow" / "bmod.toml").unlink()
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-B", str(VALIDATOR)], cwd=self.root, capture_output=True, text=True, check=False
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("skills/bmad-flow: missing bmod.toml", result.stderr)
|
||||
|
||||
|
||||
class FileRuleTests(ValidatorCase):
|
||||
def test_skill_folder_without_a_bmod_file(self):
|
||||
write(self.skills / "bmad-orphan" / "SKILL.md", "# orphan\n")
|
||||
self.assertIn("skills/bmad-orphan: missing bmod.toml", self.problems())
|
||||
|
||||
def test_file_the_runtime_parser_rejects(self):
|
||||
write(self.skills / "bmad-spec" / "bmod.toml", 'note = "neither table"\n')
|
||||
problems = self.problems()
|
||||
self.assertIn("skills/bmad-spec/bmod.toml: the runtime parser rejects this file", problems)
|
||||
self.assertIn("[bmod] table, a [skill] table, or both", problems)
|
||||
|
||||
def test_record_missing_a_required_key(self):
|
||||
self.method_record('version = "6.11.0-next"\n', "")
|
||||
problems = self.problems()
|
||||
self.assertIn("skills/bmod-method/bmod.toml", problems)
|
||||
self.assertIn("'bmod.version'", problems)
|
||||
|
||||
def test_record_missing_an_install_message_key(self):
|
||||
for key in ("pre_install_message", "post_install_message"):
|
||||
with self.subTest(key=key):
|
||||
make_tree(self.root)
|
||||
self.method_record(f'{key} = ""\n', "")
|
||||
self.assertIn(f"skills/bmod-method/bmod.toml: [bmod] is missing {key!r}", self.problems())
|
||||
|
||||
def test_install_message_that_is_not_a_string(self):
|
||||
self.method_record('pre_install_message = ""\n', "pre_install_message = 3\n")
|
||||
problems = self.problems()
|
||||
self.assertIn("skills/bmod-method/bmod.toml: the runtime parser rejects this file", problems)
|
||||
self.assertIn("'bmod.pre_install_message' must be a string", problems)
|
||||
|
||||
def test_skill_missing_a_required_key(self):
|
||||
write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\n')
|
||||
problems = self.problems()
|
||||
self.assertIn("skills/bmad-spec/bmod.toml", problems)
|
||||
self.assertIn("'skill.source'", problems)
|
||||
|
||||
def test_github_source_with_two_parts_is_accepted(self):
|
||||
self.method_record(f'update_source = "{SOURCE}"', 'update_source = "github:o/r"')
|
||||
for skill in MEMBERS["bmod-method"]:
|
||||
write(self.skills / skill / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n')
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_github_source_with_one_part_is_rejected(self):
|
||||
write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o"\n')
|
||||
self.assertIn("github source must name owner/repo", self.problems())
|
||||
|
||||
|
||||
class RecordRuleTests(ValidatorCase):
|
||||
def test_record_folder_not_named_after_its_code(self):
|
||||
(self.skills / "bmod-method").rename(self.skills / "bmod-bmad-method")
|
||||
for skill in MEMBERS["bmod-method"]:
|
||||
write(self.skills / skill / "bmod.toml", SKILL.format(bmod="bmod-bmad-method"))
|
||||
self.assertIn(
|
||||
"skills/bmod-bmad-method/bmod.toml: a module record folder is named 'bmod-method'", self.problems()
|
||||
)
|
||||
|
||||
def test_second_record_for_one_code(self):
|
||||
write(
|
||||
self.skills / "bmod-zeta" / "bmod.toml",
|
||||
f'[bmod]\ncode = "method"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n\n[skill]\n',
|
||||
)
|
||||
self.assertIn(
|
||||
"skills/bmod-zeta/bmod.toml: module code 'method' is already declared by skills/bmod-method/bmod.toml",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_codes_differing_only_by_case(self):
|
||||
write(
|
||||
self.skills / "upper" / "bmod.toml",
|
||||
f'[bmod]\ncode = "Method"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n\n[skill]\n',
|
||||
)
|
||||
self.assertIn("module code 'Method' is already declared by", self.problems())
|
||||
|
||||
def test_records_with_different_versions(self):
|
||||
self.method_record('version = "6.11.0-next"', 'version = "6.12.0"')
|
||||
problems = self.problems()
|
||||
self.assertIn("every module record carries one version", problems)
|
||||
self.assertIn("bmod-method has '6.12.0'", problems)
|
||||
|
||||
def test_bmod_folder_without_a_bmod_table(self):
|
||||
write(self.skills / "bmod-x" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n')
|
||||
self.assertIn("skills/bmod-x/bmod.toml: a bmod-* folder holds a module record", self.problems())
|
||||
|
||||
def test_record_version_that_is_not_semver(self):
|
||||
self.method_record('version = "6.11.0-next"', 'version = "1.0"')
|
||||
self.assertIn("skills/bmod-method/bmod.toml: [bmod] invalid version '1.0': must be SemVer", self.problems())
|
||||
|
||||
def test_record_version_the_runtime_cannot_order(self):
|
||||
for version in ("6.11.0-dev", "6.11.0+build.1"):
|
||||
with self.subTest(version=version):
|
||||
self.method_record('version = "6.11.0-next"', f'version = "{version}"')
|
||||
self.assertIn(f"skills/bmod-method/bmod.toml: [bmod] invalid version {version!r}", self.problems())
|
||||
self.method_record(f'version = "{version}"', 'version = "6.11.0-next"')
|
||||
|
||||
def test_record_without_skill_md(self):
|
||||
(self.skills / "bmod-method" / "SKILL.md").unlink()
|
||||
self.assertIn("skills/bmod-method: a module record folder must ship SKILL.md", self.problems())
|
||||
|
||||
|
||||
class RetiredRuleTests(ValidatorCase):
|
||||
def retire(self, record: str, lines: str) -> None:
|
||||
write(self.skills / record / "retired.toml", lines)
|
||||
|
||||
def test_retired_names_that_no_longer_ship_are_valid(self):
|
||||
self.retire(
|
||||
"bmod-method", 'renamed = [{ from = "bmad-old-build", to = "bmad-build" }]\nremoved = ["bmad-gone"]\n'
|
||||
)
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_a_retired_name_that_still_ships_is_a_problem(self):
|
||||
self.retire("bmod-method", 'removed = ["bmad-spec"]\n')
|
||||
self.assertIn("retires 'bmad-spec', but skills/bmad-spec still ships", self.problems())
|
||||
|
||||
def test_a_rename_to_a_skill_the_repository_does_not_ship_is_a_problem(self):
|
||||
self.retire("bmod-method", 'renamed = [{ from = "bmad-old", to = "bmad-missing" }]\n')
|
||||
self.assertIn("renames 'bmad-old' to 'bmad-missing', which this repository does not ship", self.problems())
|
||||
|
||||
def test_a_name_retired_by_two_records_is_a_problem(self):
|
||||
self.retire("bmod-method", 'removed = ["bmad-gone"]\n')
|
||||
self.retire("bmod-core-tools", 'removed = ["bmad-gone"]\n')
|
||||
self.assertIn("retires 'bmad-gone', which skills/bmod-", self.problems())
|
||||
|
||||
def test_two_renames_to_one_skill_are_a_problem(self):
|
||||
self.retire(
|
||||
"bmod-method",
|
||||
'renamed = [{ from = "bmad-a", to = "bmad-build" }, { from = "bmad-b", to = "bmad-build" }]\n',
|
||||
)
|
||||
self.assertIn("renames more than one skill to 'bmad-build'", self.problems())
|
||||
|
||||
def test_a_retired_file_the_runtime_rejects_is_a_problem(self):
|
||||
self.retire("bmod-method", 'removed = ["bmad-gone", "bmad-gone"]\n')
|
||||
self.assertIn("skills/bmod-method/retired.toml: the runtime parser rejects this file", self.problems())
|
||||
|
||||
|
||||
class StampRuleTests(ValidatorCase):
|
||||
def test_record_the_stamper_cannot_stamp(self):
|
||||
self.method_record('version = "6.11.0-next"', "version = '6.11.0-next'")
|
||||
self.assertIn(
|
||||
"skills/bmod-method/bmod.toml: stamp_release.py cannot stamp this file: expected exactly one "
|
||||
"'version = \"...\"' line inside [bmod], found 0",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_version_lookalike_inside_a_multi_line_string(self):
|
||||
self.method_record('code = "method"\n', 'code = "method"\nnote = """\nversion = "x"\n"""\n')
|
||||
self.assertIn("cannot stamp this file", self.problems())
|
||||
|
||||
def test_the_check_writes_nothing(self):
|
||||
path = self.skills / "bmod-method" / "bmod.toml"
|
||||
before = path.read_bytes()
|
||||
self.assertEqual(self.problems(), "")
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
|
||||
class MembershipRuleTests(ValidatorCase):
|
||||
def test_skill_naming_a_record_the_repo_lacks(self):
|
||||
write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmod-absent"))
|
||||
self.assertIn(
|
||||
"skills/bmad-spec/bmod.toml: [skill] bmod names 'bmod-absent', which is not a module record",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_skill_naming_a_folder_that_is_not_a_record(self):
|
||||
write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmad-build"))
|
||||
self.assertIn("[skill] bmod names 'bmad-build', which is not a module record", self.problems())
|
||||
|
||||
def test_skill_its_record_does_not_list(self):
|
||||
write(self.skills / "bmad-extra" / "bmod.toml", SKILL.format(bmod="bmod-method"))
|
||||
self.assertIn(
|
||||
"skills/bmad-extra/bmod.toml: [skill] bmod names 'bmod-method', but skills/bmod-method/bmod.toml "
|
||||
"does not list 'bmad-extra'",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_listed_skill_the_repo_does_not_ship(self):
|
||||
self.method_record('["bmad-build", "bmad-spec"]', '["bmad-build", "bmad-spec", "bmad-typo"]')
|
||||
self.assertIn(
|
||||
"skills/bmod-method/bmod.toml: lists the skill 'bmad-typo', which this repository does not ship",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_listed_skill_that_names_another_record(self):
|
||||
write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmod-core-tools"))
|
||||
self.assertIn(
|
||||
"skills/bmod-method/bmod.toml: lists the skill 'bmad-spec', but skills/bmad-spec/bmod.toml names "
|
||||
"'bmod-core-tools' as its bmod",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_listed_folder_that_is_a_record(self):
|
||||
self.method_record('["bmad-build", "bmad-spec"]', '["bmad-build", "bmad-spec", "bmod-core-tools"]')
|
||||
self.assertIn("lists 'bmod-core-tools', which is a module record", self.problems())
|
||||
|
||||
def test_member_source_differs_from_its_record(self):
|
||||
write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n')
|
||||
self.assertIn(
|
||||
f"skills/bmad-spec/bmod.toml: [skill] source 'github:o/r' differs from skills/bmod-method/bmod.toml "
|
||||
f"update_source {SOURCE!r}",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_record_with_skill_table_left_out_of_its_own_list(self):
|
||||
write(
|
||||
self.skills / "notes" / "bmod.toml",
|
||||
f'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\nskills = []\n\n[skill]\n',
|
||||
)
|
||||
self.assertIn("skills/notes/bmod.toml: holds [skill], but its own [bmod] skills list leaves", self.problems())
|
||||
|
||||
|
||||
class RequirementRuleTests(ValidatorCase):
|
||||
def add(self, folder: str, line: str) -> None:
|
||||
path = self.skills / folder / "bmod.toml"
|
||||
write(path, path.read_text(encoding="utf-8") + line)
|
||||
|
||||
def test_plain_names_in_this_repo_are_accepted_in_both_tables(self):
|
||||
self.add("bmad-build", 'required_skills = ["bmad"]\nrecommended_skills = ["bmad-spec"]\n')
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_required_plain_name_the_repo_lacks(self):
|
||||
self.add("bmad-build", 'required_skills = ["bmad-typo"]\n')
|
||||
self.assertIn(
|
||||
"skills/bmad-build/bmod.toml: skill.required_skills entry 'bmad-typo' names no skill in this repository",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_recommended_plain_name_the_repo_lacks(self):
|
||||
self.add("bmad-build", 'recommended_skills = ["bmad-typo"]\n')
|
||||
self.assertIn("skill.recommended_skills entry 'bmad-typo' names no skill", self.problems())
|
||||
|
||||
def test_record_plain_name_the_repo_lacks(self):
|
||||
self.method_record('required_skills = ["bmad"]', 'required_skills = ["bmad-typo"]')
|
||||
self.assertIn(
|
||||
"skills/bmod-method/bmod.toml: bmod.required_skills entry 'bmad-typo' names no skill", self.problems()
|
||||
)
|
||||
|
||||
def test_table_entry_from_another_repo_is_accepted(self):
|
||||
self.add(
|
||||
"bmad-build", 'required_skills = [{ skill = "elsewhere", version = "1.0.0", source = "github:o/r" }]\n'
|
||||
)
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_table_entry_without_a_source(self):
|
||||
self.add("bmad-build", 'required_skills = [{ skill = "bmad", version = "6.13.0" }]\n')
|
||||
self.assertIn("'skill.required_skills[0].source' must be a string", self.problems())
|
||||
|
||||
def test_version_with_build_metadata(self):
|
||||
self.add("bmad-build", f'required_skills = [{{ skill = "bmad", version = "6.13.0+x", source = "{SOURCE}" }}]\n')
|
||||
problems = self.problems()
|
||||
self.assertIn("entry 'bmad' version '6.13.0+x' carries build metadata", problems)
|
||||
self.assertIn("'6.13.0'", problems)
|
||||
|
||||
def test_unorderable_version(self):
|
||||
self.add("bmad-build", f'required_skills = [{{ skill = "bmad", version = "6.13", source = "{SOURCE}" }}]\n')
|
||||
self.assertIn("'skill.required_skills[0].version' must be an orderable version", self.problems())
|
||||
|
||||
|
||||
class PathRuleTests(ValidatorCase):
|
||||
def test_help_file_a_bmod_folder_does_not_ship(self):
|
||||
(self.skills / "bmod-method" / "help" / "help.md").unlink()
|
||||
self.assertIn(
|
||||
"skills/bmod-method/help/help.md, which every bmod-* folder holds, the module record does not ship",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_help_file_is_optional_outside_a_bmod_folder(self):
|
||||
write(
|
||||
self.skills / "solo" / "bmod.toml",
|
||||
f'[bmod]\ncode = "solo"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n{MESSAGES}\n[skill]\n',
|
||||
)
|
||||
write(self.skills / "solo" / "SKILL.md", "# skill\n")
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_help_symlink(self):
|
||||
target = self.skills / "bmod-method" / "help" / "help.md"
|
||||
target.unlink()
|
||||
target.symlink_to(self.skills / "bmod-method" / "delivery-help.md")
|
||||
self.assertIn("skills/bmod-method/help/help.md, which every bmod-* folder holds, is a symlink", self.problems())
|
||||
|
||||
def test_topic_file_named_in_help_is_valid(self):
|
||||
write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/deep-dive.md`.\n")
|
||||
write(self.skills / "bmod-method" / "help" / "deep-dive.md", "# deep dive\n")
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_topic_file_help_never_names(self):
|
||||
write(self.skills / "bmod-method" / "help" / "orphan.md", "# orphan\n")
|
||||
self.assertIn("skills/bmod-method/help/orphan.md is never named in help/help.md", self.problems())
|
||||
|
||||
def test_help_naming_a_topic_file_that_does_not_exist(self):
|
||||
write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/gone.md`.\n")
|
||||
self.assertIn(
|
||||
"skills/bmod-method/help/help.md names skills/bmod-method/help/gone.md, which does not exist",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_topic_file_naming_a_topic_that_does_not_exist(self):
|
||||
write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/deep-dive.md`.\n")
|
||||
write(self.skills / "bmod-method" / "help" / "deep-dive.md", "See `help/gone.md`.\n")
|
||||
self.assertIn(
|
||||
"skills/bmod-method/help/deep-dive.md names skills/bmod-method/help/gone.md, which does not exist",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_knowledge_naming_the_help_file(self):
|
||||
self.method_record('path = "delivery-help.md"', 'path = "help/help.md"')
|
||||
self.assertIn("knowledge names 'help/help.md', which is always read", self.problems())
|
||||
|
||||
def test_knowledge_file_the_record_does_not_ship(self):
|
||||
(self.skills / "bmod-method" / "delivery-help.md").unlink()
|
||||
self.assertIn(
|
||||
"skills/bmod-method/bmod.toml: knowledge names 'delivery-help.md', which the module record does not ship",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_knowledge_url(self):
|
||||
self.method_record('path = "delivery-help.md"', 'path = "https://docs.example.com/help.md"')
|
||||
self.assertIn("unsafe value", self.problems())
|
||||
|
||||
def test_knowledge_path_leaving_the_folder(self):
|
||||
self.method_record('path = "delivery-help.md"', 'path = "../bmad-build/SKILL.md"')
|
||||
self.assertIn("unsafe value", self.problems())
|
||||
|
||||
def test_knowledge_directory(self):
|
||||
(self.skills / "bmod-method" / "delivery-help.md").unlink()
|
||||
(self.skills / "bmod-method" / "delivery-help.md").mkdir()
|
||||
self.assertIn("knowledge names 'delivery-help.md', which is not a regular file", self.problems())
|
||||
|
||||
def test_knowledge_symlink(self):
|
||||
target = self.skills / "bmod-method" / "delivery-help.md"
|
||||
target.unlink()
|
||||
target.symlink_to(self.skills / "bmod-method" / "help" / "help.md")
|
||||
self.assertIn("knowledge names 'delivery-help.md', which is a symlink", self.problems())
|
||||
|
||||
def test_knowledge_naming_a_skill_outside_the_module(self):
|
||||
self.method_record('skills = ["bmad-build"]\n', 'skills = ["bmad-build", "bmad-flow"]\n')
|
||||
self.assertIn(
|
||||
"knowledge 'delivery-help.md' names 'bmad-flow', which is not a skill of module 'method'",
|
||||
self.problems(),
|
||||
)
|
||||
|
||||
def test_a_module_without_a_roster_file_is_valid(self):
|
||||
(self.skills / "bmod-method" / "roster.toml").unlink()
|
||||
self.assertEqual(self.problems(), "")
|
||||
|
||||
def test_roster_symlink(self):
|
||||
target = self.skills / "bmod-method" / "roster.toml"
|
||||
target.unlink()
|
||||
target.symlink_to(self.skills / "bmod-method" / "help" / "help.md")
|
||||
self.assertIn("skills/bmod-method/roster.toml is a symlink", self.problems())
|
||||
|
||||
def test_roster_that_is_not_toml(self):
|
||||
write(self.skills / "bmod-method" / "roster.toml", "[[members\n")
|
||||
self.assertIn("skills/bmod-method/roster.toml: cannot read roster", self.problems())
|
||||
|
||||
|
||||
class RosterContentTests(ValidatorCase):
|
||||
def roster(self, old: str, new: str) -> None:
|
||||
self.assertIn(old, ROSTER)
|
||||
write(self.skills / "bmod-method" / "roster.toml", ROSTER.replace(old, new))
|
||||
|
||||
def test_member_code_defined_twice(self):
|
||||
self.roster('code = "guest"', 'code = "bmad-build"')
|
||||
self.assertIn("skills/bmod-method/roster.toml: member code 'bmad-build' is defined twice", self.problems())
|
||||
|
||||
def test_group_naming_an_undefined_member(self):
|
||||
self.roster('members = ["bmad-build", "guest"]', 'members = ["bmad-build", "nobody"]')
|
||||
self.assertIn("group 'team' lists 'nobody', which no member defines", self.problems())
|
||||
|
||||
def test_member_skill_the_repo_does_not_ship(self):
|
||||
self.roster('skill = "bmad-build"', 'skill = "bmad-typo"')
|
||||
self.assertIn(
|
||||
"member 'bmad-build' names skill 'bmad-typo', which this repository does not ship", self.problems()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Check every skills/*/bmod.toml against the runtime that has to read it.
|
||||
|
||||
This file owns the repository checks: pre-commit and CI run it, and stamp_release.py beside it imports
|
||||
`check_repo` and `stamp_text` from it. Keys and tables the runtime does not know are left alone.
|
||||
It ships with the `bmad` skill, so any module repository can run it against its own tree.
|
||||
|
||||
Usage:
|
||||
uv run skills/bmad/scripts/validate_manifests.py [--project-root <path>]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import NamedTuple
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
RECORD_PREFIX = "bmod-"
|
||||
STAMP_PROBE = "0.0.0-stamp-check"
|
||||
MESSAGE_KEYS = ("pre_install_message", "post_install_message")
|
||||
|
||||
TABLE_HEADER = re.compile(r"[ \t]*\[\[?[^\[\]\n]+\]\]?[ \t]*(?:#[^\n]*)?\r?\n?")
|
||||
BMOD_HEADER = re.compile(r"[ \t]*\[[ \t]*bmod[ \t]*\][ \t]*(?:#[^\n]*)?\r?\n?")
|
||||
VERSION_LINE = re.compile(r'(?P<head>[ \t]*version[ \t]*=[ \t]*)"[^"\n]*"(?P<tail>[ \t]*(?:#[^\n]*)?\r?\n?)')
|
||||
|
||||
|
||||
def load(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise SystemExit(f"cannot load {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
setup = load("bmad_setup_validate", SCRIPTS / "setup.py")
|
||||
knowledge = load("bmad_knowledge_validate", SCRIPTS / "knowledge.py")
|
||||
|
||||
|
||||
class RepoReport(NamedTuple):
|
||||
records: tuple[Path, ...]
|
||||
skills: int
|
||||
documents: int
|
||||
problems: tuple[str, ...]
|
||||
|
||||
|
||||
def check_repo(project_root: Path) -> RepoReport:
|
||||
skills_dir = project_root / "skills"
|
||||
folders = sorted(path for path in skills_dir.glob("*") if path.is_dir())
|
||||
if not folders:
|
||||
problem = f"no skills/*/{setup.MANIFEST_NAME} found under {project_root}: pass the repository root with --project-root"
|
||||
return RepoReport((), 0, 0, (problem,))
|
||||
|
||||
problems: list[str] = []
|
||||
files: dict[str, setup.ParsedFile] = {}
|
||||
for folder in folders:
|
||||
manifest = folder / setup.MANIFEST_NAME
|
||||
if not manifest.is_file():
|
||||
problems.append(f"skills/{folder.name}: missing {setup.MANIFEST_NAME}")
|
||||
continue
|
||||
try:
|
||||
files[folder.name] = setup.parse_bmod_file(manifest, manifest.read_bytes())
|
||||
except Exception as error:
|
||||
problems.append(f"{rel(folder.name)}: the runtime parser rejects this file: {error}")
|
||||
|
||||
shipped = {folder.name for folder in folders}
|
||||
records = {name: parsed.bmod for name, parsed in files.items() if parsed.bmod is not None}
|
||||
members = {name: member_names(name, files[name]) for name in records}
|
||||
|
||||
problems += record_problems(files, records, skills_dir)
|
||||
problems += membership_problems(files, records, members, shipped)
|
||||
retired: dict[str, setup.ParsedRetired] = {}
|
||||
for name in records:
|
||||
try:
|
||||
retired[name] = setup.read_retired_file(skills_dir / name)
|
||||
except Exception as error:
|
||||
problems.append(f"skills/{name}/{setup.RETIRED_NAME}: the runtime parser rejects this file: {error}")
|
||||
problems += retired_problems(retired, shipped)
|
||||
for name, parsed in files.items():
|
||||
for table, source in (("bmod", parsed.bmod), ("skill", parsed.skill)):
|
||||
if source is not None:
|
||||
problems += requirement_problems(name, table, source, shipped)
|
||||
documents = 0
|
||||
for name, record in records.items():
|
||||
folder = skills_dir / name
|
||||
documents += len(record.knowledge) + (folder / knowledge.HELP_NAME).is_file()
|
||||
problems += knowledge_problems(name, record, folder, members[name])
|
||||
problems += topic_problems(name, folder)
|
||||
problems += roster_file_problems(name, record, folder, skills_dir)
|
||||
problems += stamp_problems(name, folder / setup.MANIFEST_NAME)
|
||||
problems += message_problems(name, folder / setup.MANIFEST_NAME)
|
||||
|
||||
if not problems:
|
||||
problems += runtime_problems(skills_dir)
|
||||
|
||||
record_files = tuple(skills_dir / name / setup.MANIFEST_NAME for name in sorted(records))
|
||||
skill_count = sum(1 for parsed in files.values() if parsed.skill is not None)
|
||||
return RepoReport(record_files, skill_count, documents, tuple(problems))
|
||||
|
||||
|
||||
def version_problem(version: str) -> str | None:
|
||||
"""Why an installed module could not use this record version, or None. The stamper applies the same rule."""
|
||||
match = setup.SEMVER.fullmatch(version)
|
||||
if match is None:
|
||||
return f"invalid version {version!r}: must be SemVer (MAJOR.MINOR.PATCH, optional prerelease), e.g. 6.12.0"
|
||||
# setup.py refuses to order any version containing "-dev".
|
||||
if "-dev" in version.casefold():
|
||||
return (
|
||||
f'invalid version {version!r}: setup.py cannot order "-dev" '
|
||||
"versions, so an installed module would never compare as current — "
|
||||
"pick a different prerelease label"
|
||||
)
|
||||
# setup.py drops build metadata when ordering, so "1.2.0+x" compares equal to "1.2.0".
|
||||
if match.group("build") is not None:
|
||||
base = version.split("+", 1)[0]
|
||||
return (
|
||||
f"invalid version {version!r}: setup.py ignores build metadata when "
|
||||
f"ordering, so this compares equal to {base!r} and an installed module "
|
||||
"would never see the release — change the major, minor, patch, or "
|
||||
"prerelease part"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def stamp_text(original: str, version: str) -> str:
|
||||
"""The file with only the `version` line inside [bmod] rewritten. Raises ValueError when that cannot be done."""
|
||||
lines = original.splitlines(keepends=True)
|
||||
headers = [index for index, line in enumerate(lines) if BMOD_HEADER.fullmatch(line)]
|
||||
if len(headers) != 1:
|
||||
raise ValueError(f"expected exactly one '[bmod]' table header line, found {len(headers)}")
|
||||
start = headers[0] + 1
|
||||
# Only the [bmod] table itself: a table further down may have a `version` of its own.
|
||||
end = next((index for index in range(start, len(lines)) if TABLE_HEADER.fullmatch(lines[index])), len(lines))
|
||||
matches = [index for index in range(start, end) if VERSION_LINE.fullmatch(lines[index])]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected exactly one 'version = \"...\"' line inside [bmod], found {len(matches)}")
|
||||
match = VERSION_LINE.fullmatch(lines[matches[0]])
|
||||
assert match is not None
|
||||
lines[matches[0]] = f'{match.group("head")}"{version}"{match.group("tail")}'
|
||||
stamped = "".join(lines)
|
||||
if tomllib.loads(stamped) != with_version(tomllib.loads(original), version):
|
||||
raise ValueError("rewriting the version line would change something other than [bmod] version")
|
||||
return stamped
|
||||
|
||||
|
||||
def with_version(data: dict, version: str) -> dict:
|
||||
expected = copy.deepcopy(data)
|
||||
expected["bmod"]["version"] = version
|
||||
return expected
|
||||
|
||||
|
||||
def message_problems(name: str, manifest: Path) -> list[str]:
|
||||
"""Records in this repo carry both install messages, empty or not, so authors see the fields exist."""
|
||||
table = tomllib.loads(manifest.read_text(encoding="utf-8"))["bmod"]
|
||||
return [
|
||||
f"{rel(name)}: [bmod] is missing {key!r}; add it, empty if the module has no message"
|
||||
for key in MESSAGE_KEYS
|
||||
if key not in table
|
||||
]
|
||||
|
||||
|
||||
def stamp_problems(name: str, manifest: Path) -> list[str]:
|
||||
"""A record the stamper could not stamp fails here, at commit time."""
|
||||
try:
|
||||
stamp_text(manifest.read_bytes().decode("utf-8"), STAMP_PROBE)
|
||||
except (OSError, ValueError) as error:
|
||||
return [f"{rel(name)}: stamp_release.py cannot stamp this file: {error}"]
|
||||
return []
|
||||
|
||||
|
||||
def rel(folder: str) -> str:
|
||||
return f"skills/{folder}/{setup.MANIFEST_NAME}"
|
||||
|
||||
|
||||
def member_names(folder: str, parsed: setup.ParsedFile) -> tuple[str, ...]:
|
||||
if parsed.bmod.skills is not None:
|
||||
return parsed.bmod.skills
|
||||
return (folder,) if parsed.skill is not None else ()
|
||||
|
||||
|
||||
def record_problems(
|
||||
files: dict[str, setup.ParsedFile], records: dict[str, setup.ParsedBmod], skills_dir: Path
|
||||
) -> list[str]:
|
||||
problems: list[str] = []
|
||||
for name, parsed in files.items():
|
||||
if name.startswith(RECORD_PREFIX) and parsed.bmod is None:
|
||||
problems.append(
|
||||
f"{rel(name)}: a {RECORD_PREFIX}* folder holds a module record, but this file has no [bmod]"
|
||||
)
|
||||
first_by_code: dict[str, str] = {}
|
||||
for name, record in records.items():
|
||||
problem = version_problem(record.version)
|
||||
if problem is not None:
|
||||
problems.append(f"{rel(name)}: [bmod] {problem}")
|
||||
skill_md = skills_dir / name / "SKILL.md"
|
||||
if skill_md.is_symlink() or not skill_md.is_file():
|
||||
problems.append(f"skills/{name}: a module record folder must ship SKILL.md as a plain file")
|
||||
if files[name].skill is None and name != RECORD_PREFIX + record.code:
|
||||
problems.append(
|
||||
f"{rel(name)}: a module record folder is named {RECORD_PREFIX + record.code!r} "
|
||||
f"after its code; this one is {name!r}"
|
||||
)
|
||||
first = first_by_code.setdefault(record.code.casefold(), name)
|
||||
if first != name:
|
||||
problems.append(
|
||||
f"{rel(name)}: module code {record.code!r} is already declared by {rel(first)}; one record per code"
|
||||
)
|
||||
versions = {name: record.version for name, record in records.items()}
|
||||
if len(set(versions.values())) > 1:
|
||||
listed = ", ".join(f"{name} has {version!r}" for name, version in versions.items())
|
||||
problems.append(f"skills/: every module record carries one version, stamped together; {listed}")
|
||||
return problems
|
||||
|
||||
|
||||
def membership_problems(
|
||||
files: dict[str, setup.ParsedFile],
|
||||
records: dict[str, setup.ParsedBmod],
|
||||
members: dict[str, tuple[str, ...]],
|
||||
shipped: set[str],
|
||||
) -> list[str]:
|
||||
problems: list[str] = []
|
||||
for name, parsed in files.items():
|
||||
if parsed.skill is None:
|
||||
continue
|
||||
if parsed.bmod is not None:
|
||||
if name not in members[name]:
|
||||
problems.append(f"{rel(name)}: holds [skill], but its own [bmod] skills list leaves {name!r} out")
|
||||
continue
|
||||
bmod = parsed.skill.bmod
|
||||
if bmod in records and parsed.skill.source != records[bmod].update_source:
|
||||
problems.append(
|
||||
f"{rel(name)}: [skill] source {parsed.skill.source!r} differs from {rel(bmod)} "
|
||||
f"update_source {records[bmod].update_source!r}"
|
||||
)
|
||||
if bmod not in records:
|
||||
problems.append(
|
||||
f"{rel(name)}: [skill] bmod names {bmod!r}, which is not a module record in this repository"
|
||||
)
|
||||
elif name not in members[bmod]:
|
||||
problems.append(f"{rel(name)}: [skill] bmod names {bmod!r}, but {rel(bmod)} does not list {name!r}")
|
||||
for name in records:
|
||||
for member in members[name]:
|
||||
parsed = files.get(member)
|
||||
if member not in shipped:
|
||||
problems.append(f"{rel(name)}: lists the skill {member!r}, which this repository does not ship")
|
||||
elif parsed is None:
|
||||
continue
|
||||
elif parsed.skill is None or (parsed.bmod is not None and member != name):
|
||||
problems.append(
|
||||
f"{rel(name)}: lists {member!r}, which is a module record and not a skill of this module"
|
||||
)
|
||||
elif member != name and parsed.skill.bmod != name:
|
||||
problems.append(
|
||||
f"{rel(name)}: lists the skill {member!r}, but {rel(member)} names {parsed.skill.bmod!r} as its bmod"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def retired_problems(records: dict[str, setup.ParsedRetired], shipped: set[str]) -> list[str]:
|
||||
"""A retired name is never shipped again, and a rename points at a skill this repository ships."""
|
||||
problems: list[str] = []
|
||||
retired_by: dict[str, str] = {}
|
||||
for name, record in records.items():
|
||||
retired = [*(rename.old for rename in record.renamed), *record.removed]
|
||||
for old in retired:
|
||||
if old in shipped:
|
||||
problems.append(
|
||||
f"{retired_rel(name)}: retires {old!r}, but skills/{old} still ships; a retired name is never reused"
|
||||
)
|
||||
first = retired_by.setdefault(old, name)
|
||||
if first != name:
|
||||
problems.append(f"{retired_rel(name)}: retires {old!r}, which {retired_rel(first)} already retires")
|
||||
targets = [rename.new for rename in record.renamed]
|
||||
for new in dict.fromkeys(target for target in targets if targets.count(target) > 1):
|
||||
problems.append(
|
||||
f"{retired_rel(name)}: renames more than one skill to {new!r}; "
|
||||
"their customizations would collide, so list the extras under removed"
|
||||
)
|
||||
for rename in record.renamed:
|
||||
if rename.new not in shipped:
|
||||
problems.append(
|
||||
f"{retired_rel(name)}: renames {rename.old!r} to {rename.new!r}, which this repository does not ship"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def retired_rel(folder: str) -> str:
|
||||
return f"skills/{folder}/{setup.RETIRED_NAME}"
|
||||
|
||||
|
||||
def requirement_problems(
|
||||
folder: str, table: str, source: setup.ParsedBmod | setup.ParsedSkill, shipped: set[str]
|
||||
) -> list[str]:
|
||||
"""Shape is the runtime parser's job. These are the rules only the repository can decide."""
|
||||
problems: list[str] = []
|
||||
for field in ("required_skills", "recommended_skills"):
|
||||
for requirement in getattr(source, field):
|
||||
where = f"{rel(folder)}: {table}.{field} entry {requirement.skill!r}"
|
||||
# setup.py drops build metadata when ordering, so such a minimum could never be told apart.
|
||||
if requirement.version is not None and "+" in requirement.version:
|
||||
problems.append(
|
||||
f"{where} version {requirement.version!r} carries build metadata, which setup.py ignores "
|
||||
f"when ordering; it would compare equal to {requirement.version.split('+', 1)[0]!r}"
|
||||
)
|
||||
if requirement.source is None and requirement.skill not in shipped:
|
||||
problems.append(f"{where} names no skill in this repository and gives no source to fetch it from")
|
||||
return problems
|
||||
|
||||
|
||||
def knowledge_problems(name: str, record: setup.ParsedBmod, folder: Path, members: tuple[str, ...]) -> list[str]:
|
||||
problems: list[str] = []
|
||||
help_path = folder / knowledge.HELP_NAME
|
||||
if name.startswith("bmod-") or help_path.exists() or help_path.is_symlink():
|
||||
problem = plain_file_problem(folder, PurePosixPath(knowledge.HELP_NAME))
|
||||
if problem is not None:
|
||||
problems.append(f"skills/{name}/{knowledge.HELP_NAME}, which every bmod-* folder holds, {problem}")
|
||||
for entry in record.knowledge:
|
||||
if entry.path.as_posix() == knowledge.HELP_NAME:
|
||||
problems.append(f"{rel(name)}: knowledge names {knowledge.HELP_NAME!r}, which is always read")
|
||||
continue
|
||||
problem = plain_file_problem(folder, entry.path)
|
||||
if problem is not None:
|
||||
problems.append(f"{rel(name)}: knowledge names {entry.path.as_posix()!r}, which {problem}")
|
||||
for skill in entry.skills or ():
|
||||
if skill not in members:
|
||||
problems.append(
|
||||
f"{rel(name)}: knowledge {entry.path.as_posix()!r} names {skill!r}, which is not a skill of "
|
||||
f"module {record.code!r}"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
TOPIC_REFERENCE = re.compile(r"`help/([^`/<>]+\.md)`")
|
||||
|
||||
|
||||
def topic_problems(name: str, folder: Path) -> list[str]:
|
||||
"""A topic `help.md` never points to is never read, and a pointer to no file misleads the reader."""
|
||||
try:
|
||||
text = (folder / knowledge.HELP_NAME).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError):
|
||||
text = ""
|
||||
help_file = PurePosixPath(knowledge.HELP_NAME).name
|
||||
named = set(TOPIC_REFERENCE.findall(text)) - {help_file}
|
||||
shipped = {path.name for path in (folder / knowledge.TOPICS_DIR).glob("*.md")} - {help_file}
|
||||
where = f"skills/{name}/{knowledge.TOPICS_DIR}"
|
||||
problems = [f"{where}/{topic} is never named in {knowledge.HELP_NAME}" for topic in sorted(shipped - named)]
|
||||
problems += [
|
||||
f"skills/{name}/{knowledge.HELP_NAME} names {where}/{topic}, which does not exist"
|
||||
for topic in sorted(named - shipped)
|
||||
]
|
||||
for topic in sorted(shipped):
|
||||
try:
|
||||
body = (folder / knowledge.TOPICS_DIR / topic).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError):
|
||||
continue
|
||||
problems += [
|
||||
f"{where}/{topic} names {where}/{other}, which does not exist"
|
||||
for other in sorted(set(TOPIC_REFERENCE.findall(body)) - shipped - {help_file})
|
||||
]
|
||||
for topic in sorted(shipped & named):
|
||||
problem = plain_file_problem(folder, PurePosixPath(knowledge.TOPICS_DIR, topic))
|
||||
if problem is not None:
|
||||
problems.append(f"{where}/{topic} {problem}")
|
||||
return problems
|
||||
|
||||
|
||||
def plain_file_problem(folder: Path, relative: PurePosixPath) -> str | None:
|
||||
path = folder.joinpath(*relative.parts)
|
||||
if path.is_symlink():
|
||||
return "is a symlink, not a plain file"
|
||||
try:
|
||||
knowledge.read_document(path, folder)
|
||||
except FileNotFoundError:
|
||||
return "the module record does not ship"
|
||||
except (OSError, ValueError) as error:
|
||||
return str(error)
|
||||
return None
|
||||
|
||||
|
||||
def roster_file_problems(name: str, record: setup.ParsedBmod, folder: Path, skills_dir: Path) -> list[str]:
|
||||
path = folder / knowledge.ROSTER_NAME
|
||||
if not path.exists() and not path.is_symlink():
|
||||
return []
|
||||
where = f"skills/{name}/{knowledge.ROSTER_NAME}"
|
||||
problem = plain_file_problem(folder, PurePosixPath(knowledge.ROSTER_NAME))
|
||||
if problem is not None:
|
||||
return [f"{where} {problem}"]
|
||||
try:
|
||||
party = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
|
||||
return [f"{where}: cannot read roster: {error}"]
|
||||
return [f"{where}: {problem}" for problem in roster_problems(party, skills_dir)]
|
||||
|
||||
|
||||
def roster_problems(party: dict, skills_dir: Path) -> list[str]:
|
||||
"""A group naming a member nobody defines, or a member naming a skill this repo lacks, is a typo."""
|
||||
problems: list[str] = []
|
||||
members = [member for member in as_list(party.get("members")) if isinstance(member, dict)]
|
||||
codes = [member.get("code") for member in members]
|
||||
repeated = sorted({code for code in codes if isinstance(code, str) and codes.count(code) > 1})
|
||||
problems += [f"member code {code!r} is defined twice" for code in repeated]
|
||||
for member in members:
|
||||
skill = member.get("skill")
|
||||
if skill is not None and not (isinstance(skill, str) and (skills_dir / skill / "SKILL.md").is_file()):
|
||||
problems.append(f"member {member.get('code')!r} names skill {skill!r}, which this repository does not ship")
|
||||
for group in as_list(party.get("groups")):
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
for code in as_list(group.get("members")):
|
||||
if code not in codes:
|
||||
problems.append(f"group {group.get('id')!r} lists {code!r}, which no member defines")
|
||||
return problems
|
||||
|
||||
|
||||
def as_list(value: object) -> list:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def runtime_problems(skills_dir: Path) -> list[str]:
|
||||
"""The tree as `bmad` itself would discover it. Runs only on a tree the checks above accept."""
|
||||
problems: list[str] = []
|
||||
try:
|
||||
installation = setup.discover_installation(skills_dir / "bmad")
|
||||
except Exception as error:
|
||||
return [f"skills/: setup.py cannot discover the modules: {error}"]
|
||||
problems += [f"skills/: setup.py reports: {problem['message']}" for problem in installation.problems]
|
||||
problems += [
|
||||
f"skills/: setup.py finds no module record {missing['bmod']!r} for {missing['skill']!r}"
|
||||
for missing in installation.missing_records
|
||||
]
|
||||
report = knowledge.collect([skills_dir])
|
||||
problems += [f"skills/: knowledge.py reports: {problem['problem']}" for problem in report["problems"]]
|
||||
return problems
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Check every skills/*/bmod.toml in a repository.")
|
||||
parser.add_argument(
|
||||
"--project-root", type=Path, default=Path.cwd(), help="repository to check (default: the current directory)"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
report = check_repo(args.project_root.resolve())
|
||||
if report.problems:
|
||||
print(f"bmod file validation failed ({len(report.problems)}):", file=sys.stderr)
|
||||
for problem in report.problems:
|
||||
print(f" {problem}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"bmod files valid: {report.skills} skills, {len(report.records)} module records, "
|
||||
f"{report.documents} knowledge documents."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user