diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e87eb64..933608d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -99,9 +99,12 @@ jobs: run: pip install Jinja2 PyYAML - name: Check scaffold syntax - run: python3 -m py_compile scaffold/create-tool.py + run: python3 -m py_compile scaffold/create-tool.py scaffold/generator.py - name: Test scaffold dry run + # --no-register: this is a throwaway probe; it must not mutate the + # checkout's registry.json (registration is covered hermetically by + # tests/test_scaffold_born_green.py against a temp registry root). run: | python3 scaffold/create-tool.py \ --name "CI Test Plugin" \ @@ -109,6 +112,7 @@ jobs: --mcp-server \ --skills 2 \ --rules 1 \ + --no-register \ --output /tmp/scaffold-test test -f /tmp/scaffold-test/ci-test-plugin/.cursor-plugin/plugin.json diff --git a/README.md b/README.md index 60f370d..3654c73 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ Documented conventions for building new developer tools. All docs in [`standards | [AGENTS.md Template](standards/agents-template.md) | AI agent guidance file structure | | [Versioning](standards/versioning.md) | Semver management and automated release flow | | [Release-doc-sync](standards/release-doc-sync.md) | Composite action contract for keeping CHANGELOG, CLAUDE, and ROADMAP in sync after auto-release | +| [Born-Green Contract](standards/born-green-contract.md) | Acceptance criterion every repo generator must satisfy | | [Testing](standards/testing.md) | Test frameworks, coverage bar, and CI wiring | | [Skills](standards/skills.md) | `SKILL.md` structure and frontmatter | | [Rules](standards/rules.md) | `.mdc` structure, globs, and the secrets rule pattern | diff --git a/VERSION b/VERSION index c807441..092afa1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.16.3 +1.17.0 diff --git a/scaffold/create-tool.py b/scaffold/create-tool.py index 24254b4..c194ba2 100644 --- a/scaffold/create-tool.py +++ b/scaffold/create-tool.py @@ -1,104 +1,36 @@ #!/usr/bin/env python3 """ -Scaffold generator for TMHSDigital developer tool repositories. +Scaffold generator CLI for TMHSDigital developer tool repositories. + +Thin command-line wrapper over the canonical generation library in +``scaffold/generator.py``. Both this CLI and any second generator delegate to +``generator.generate_repo`` so there is one source of truth for what a +born-green repo looks like (see ``standards/born-green-contract.md``). Generates a fully standards-compliant repository with all required files, -workflows, manifests, and documentation skeleton. +workflows, manifests, and documentation skeleton, and (by default) registers +it in the meta catalog so a repo cannot be born outside the registry. """ - import argparse -import datetime -import os -import re import sys from pathlib import Path +# When run as `python scaffold/create-tool.py`, sys.path[0] is the scaffold dir, +# so a plain `import generator` resolves. When imported as part of the package, +# fall back to the qualified path. try: - from jinja2 import Environment, FileSystemLoader -except ImportError: - print("Error: Jinja2 is required. Install it with: pip install Jinja2") - sys.exit(1) - - -TEMPLATES_DIR = Path(__file__).parent / "templates" -STANDARDS_VERSION_FILE = Path(__file__).parent.parent / "STANDARDS_VERSION" -VERSION_FILE = Path(__file__).parent.parent / "VERSION" - -LICENSE_FILES = { - "cc-by-nc-nd-4.0": "CC-BY-NC-ND-4.0", - "mit": "MIT", - "apache-2.0": "Apache-2.0", -} - -SPDX = { - "cc-by-nc-nd-4.0": "CC-BY-NC-ND-4.0", - "mit": "MIT", - "apache-2.0": "Apache-2.0", -} - - -def slugify(name: str) -> str: - slug = name.lower().strip() - slug = re.sub(r"[^a-z0-9]+", "-", slug) - return slug.strip("-") - - -def read_standards_version() -> str: - """Read the meta-repo STANDARDS_VERSION at generation time. - - New tool repos are pre-aligned with the current standards version, so the - value here is not a runtime decision. If STANDARDS_VERSION is missing or - unreadable, fail loudly rather than silently substituting a default - a - wrong version would defeat the drift-checker invariant. - """ - try: - raw = STANDARDS_VERSION_FILE.read_text(encoding="utf-8").strip() - except FileNotFoundError: - print( - f"Error: STANDARDS_VERSION file not found at {STANDARDS_VERSION_FILE}. " - "The scaffold must run from a working copy of Developer-Tools-Directory." - ) - sys.exit(1) - except OSError as e: - print(f"Error: could not read {STANDARDS_VERSION_FILE}: {e}") - sys.exit(1) - if not re.fullmatch(r"\d+\.\d+\.\d+", raw): - print( - f"Error: VERSION contents '{raw}' are not a valid X.Y.Z semver string. " - "Refusing to emit a standards-version marker with a malformed value." - ) - sys.exit(1) - return raw - - -def read_meta_version() -> tuple[int, int, int]: - """Read the meta-repo VERSION at generation time, split into (major, minor, patch). - - Workflow action pins in generated repos are DERIVED from this so a repo - scaffolded after any future meta release is born current instead of stale. - Hardcoding today's number in the templates only moves staleness forward one - release; deriving from the live VERSION removes it. If VERSION is missing or - malformed, fail loudly rather than emit a wrong pin. - """ - try: - raw = VERSION_FILE.read_text(encoding="utf-8").strip() - except FileNotFoundError: - print( - f"Error: VERSION file not found at {VERSION_FILE}. " - "The scaffold must run from a working copy of Developer-Tools-Directory." - ) - sys.exit(1) - except OSError as e: - print(f"Error: could not read {VERSION_FILE}: {e}") - sys.exit(1) - m = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", raw) - if not m: - print( - f"Error: VERSION contents '{raw}' are not a valid X.Y.Z semver string. " - "Refusing to derive workflow action pins from a malformed value." - ) - sys.exit(1) - return int(m.group(1)), int(m.group(2)), int(m.group(3)) + from generator import ( + LICENSE_FILES, + ScaffoldError, + generate_repo, + ) +except ImportError: # pragma: no cover - package-context fallback + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from generator import ( # type: ignore + LICENSE_FILES, + ScaffoldError, + generate_repo, + ) def parse_args(): @@ -110,6 +42,7 @@ def parse_args(): python create-tool.py --name "Unreal Developer Tools" --description "Cursor plugin for Unreal Engine" python create-tool.py --name "AWS MCP Server" --type mcp-server --mcp-server python create-tool.py --name "K8s Developer Tools" --mcp-server --skills 5 --rules 3 + python create-tool.py --name "Throwaway" --description test --no-register """, ) parser.add_argument("--name", required=True, help="Display name (e.g., 'Unreal Developer Tools')") @@ -145,199 +78,61 @@ def parse_args(): default="contact@users.noreply.github.com", help="Author email for plugin.json (default: GitHub no-reply placeholder)", ) + parser.add_argument( + "--no-register", + action="store_true", + help=( + "Do NOT add the repo to registry.json. By default the scaffold " + "registers every generated repo so none is born outside the catalog. " + "Use this for throwaway/test generation only." + ), + ) + parser.add_argument( + "--registry-root", + default=None, + help=( + "Override the directory whose registry.json (and derived artifacts) " + "registration targets. Defaults to the meta-repo root." + ), + ) return parser.parse_args() -def render_template(env, template_name: str, context: dict) -> str: - tmpl = env.get_template(template_name) - return tmpl.render(**context) - - -def write_file(base: Path, rel_path: str, content: str): - full = base / rel_path - full.parent.mkdir(parents=True, exist_ok=True) - full.write_text(content, encoding="utf-8") - print(f" created {rel_path}") - - -def main(): +def main() -> int: args = parse_args() - - slug = args.slug or slugify(args.name) - if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", slug): - print(f"Error: slug '{slug}' is not valid kebab-case") - sys.exit(1) - - output_dir = Path(args.output) / slug - if output_dir.exists(): - print(f"Error: output directory already exists: {output_dir}") - sys.exit(1) - - env = Environment( - loader=FileSystemLoader(str(TEMPLATES_DIR)), - keep_trailing_newline=True, - lstrip_blocks=True, - trim_blocks=True, - ) - - skill_names = [f"skill-{i + 1}" for i in range(args.skills)] - rule_names = [f"rule-{i + 1}" for i in range(args.rules)] - - standards_version = read_standards_version() - meta_major, meta_minor, meta_patch = read_meta_version() - - ctx = { - "name": args.name, - "slug": slug, - "description": args.description, - "type": args.type, - "has_mcp": args.mcp_server, - "skills": skill_names, - "rules": rule_names, - "skill_count": args.skills, - "rule_count": args.rules, - "license_spdx": SPDX[args.license], - "license_key": args.license, - "author_name": args.author_name, - "author_email": args.author_email, - "repo_owner": "TMHSDigital", - "repo_name": slug, - "standards_version": standards_version, - "meta_major": meta_major, - "meta_minor": meta_minor, - "meta_patch": meta_patch, - "meta_version": f"{meta_major}.{meta_minor}.{meta_patch}", - "year": datetime.datetime.now(datetime.timezone.utc).year, - } - - print(f"\nScaffolding '{args.name}' ({slug}) into {output_dir}\n") - - # Plugin manifest - if args.type == "cursor-plugin": - write_file(output_dir, ".cursor-plugin/plugin.json", render_template(env, "plugin.json.j2", ctx)) - - # GitHub workflows - emitted set is type-specific and must match the - # per-type required_workflows in standards/drift-checker.config.json plus - # the two optional-for-both workflows (pages, label-sync). Do NOT emit - # cursor-plugin-specific workflows for mcp-server repos: validate.yml's - # checks all assume a plugin.json and publish.yml replaces release.yml - # (see standards/ci-cd.md "MCP-server Variations"). - if args.type == "mcp-server": - # Required for mcp-server: drift-check, stale, publish. - write_file(output_dir, ".github/workflows/publish.yml", render_template(env, "publish.yml.j2", ctx)) - # Optional-for-both: pages (mcp variant), label-sync. - write_file(output_dir, ".github/workflows/pages.yml", render_template(env, "pages.mcp.yml.j2", ctx)) - else: - # Required for cursor-plugin: validate, release, stale, drift-check. - write_file(output_dir, ".github/workflows/validate.yml", render_template(env, "validate.yml.j2", ctx)) - write_file(output_dir, ".github/workflows/release.yml", render_template(env, "release.yml.j2", ctx)) - # Optional-for-both: pages, label-sync. - write_file(output_dir, ".github/workflows/pages.yml", render_template(env, "pages.yml.j2", ctx)) - write_file(output_dir, ".github/workflows/stale.yml", render_template(env, "stale.yml.j2", ctx)) - write_file(output_dir, ".github/workflows/drift-check.yml", render_template(env, "drift-check.yml.j2", ctx)) - write_file(output_dir, ".github/workflows/label-sync.yml", render_template(env, "label-sync.yml.j2", ctx)) - - # Dependabot config (github-actions ecosystem standard, optional pip for MCP) - write_file(output_dir, ".github/dependabot.yml", render_template(env, "dependabot.yml.j2", ctx)) - - # Documentation files - write_file(output_dir, "README.md", render_template(env, "README.md.j2", ctx)) - write_file(output_dir, "AGENTS.md", render_template(env, "AGENTS.md.j2", ctx)) - write_file(output_dir, "CLAUDE.md", render_template(env, "CLAUDE.md.j2", ctx)) - write_file(output_dir, "CONTRIBUTING.md", render_template(env, "CONTRIBUTING.md.j2", ctx)) - write_file(output_dir, "CHANGELOG.md", render_template(env, "CHANGELOG.md.j2", ctx)) - write_file(output_dir, "CODE_OF_CONDUCT.md", render_template(env, "CODE_OF_CONDUCT.md.j2", ctx)) - write_file(output_dir, "SECURITY.md", render_template(env, "SECURITY.md.j2", ctx)) - write_file(output_dir, "ROADMAP.md", render_template(env, "ROADMAP.md.j2", ctx)) - write_file(output_dir, "LICENSE", render_template(env, "LICENSE.j2", ctx)) - - # Cursor config - write_file(output_dir, ".cursorrules", render_template(env, "cursorrules.j2", ctx)) - write_file(output_dir, ".gitignore", render_template(env, "gitignore.j2", ctx)) - - # GitHub Pages data files - write_file(output_dir, "site.json", render_template(env, "site.json.j2", ctx)) - write_file(output_dir, "mcp-tools.json", render_template(env, "mcp-tools.json.j2", ctx)) - - # Assets placeholder - (output_dir / "assets").mkdir(parents=True, exist_ok=True) - (output_dir / "assets" / ".gitkeep").touch() - print(" created assets/.gitkeep") - - # MCP server specific files - if args.type == "mcp-server": - write_file(output_dir, "package.json", render_template(env, "package.json.j2", ctx)) - write_file(output_dir, "docs/index.html", render_template(env, "docs/index.mcp.html.j2", ctx)) - - # Skills - for skill in skill_names: - skill_content = f"""--- -name: {skill} -description: TODO - describe this skill -globs: ["**/*"] -alwaysApply: false -standards-version: {standards_version} ---- - -# {skill.replace('-', ' ').title()} - -TODO: Add skill content here. -""" - write_file(output_dir, f"skills/{skill}/SKILL.md", skill_content) - - # Rules - for rule in rule_names: - rule_content = f"""--- -description: TODO - describe this rule -globs: ["**/*"] -alwaysApply: false -standards-version: {standards_version} ---- - -# {rule.replace('-', ' ').title()} - -TODO: Add rule content here. -""" - write_file(output_dir, f"rules/{rule}.mdc", rule_content) - - # Tests placeholder - (output_dir / "tests").mkdir(parents=True, exist_ok=True) - (output_dir / "tests" / ".gitkeep").touch() - print(" created tests/.gitkeep") - - # MCP server scaffold - if args.mcp_server: - write_file( - output_dir, - "mcp-server/server.py", - render_template(env, "mcp-server/server.py.j2", ctx), - ) - write_file( - output_dir, - "mcp-server/requirements.txt", - render_template(env, "mcp-server/requirements.txt.j2", ctx), - ) - (output_dir / "mcp-server" / "tools").mkdir(parents=True, exist_ok=True) - (output_dir / "mcp-server" / "tools" / ".gitkeep").touch() - print(" created mcp-server/tools/.gitkeep") - (output_dir / "mcp-server" / "data").mkdir(parents=True, exist_ok=True) - (output_dir / "mcp-server" / "data" / ".gitkeep").touch() - print(" created mcp-server/data/.gitkeep") - - write_file( - output_dir, - ".cursor/mcp.json", - render_template(env, "mcp-server/mcp.json.j2", ctx), + try: + output_dir = generate_repo( + name=args.name, + description=args.description, + slug=args.slug, + repo_type=args.type, + mcp_server=args.mcp_server, + skills=args.skills, + rules=args.rules, + license_key=args.license, + output=args.output, + author_name=args.author_name, + author_email=args.author_email, + register=not args.no_register, + registry_root=Path(args.registry_root) if args.registry_root else None, ) + except ScaffoldError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + slug = output_dir.name print(f"\nDone! Repository scaffolded at: {output_dir}") - print(f"\nNext steps:") + print("\nNext steps:") print(f" 1. cd {output_dir}") - print(f" 2. git init && git add -A && git commit -m 'feat: initial scaffold'") + print(" 2. git init && git add -A && git commit -m 'feat: initial scaffold'") print(f" 3. Create GitHub repo: gh repo create TMHSDigital/{slug} --public --source .") - print(f" 4. Enable GitHub Pages in Settings > Pages > Source: GitHub Actions") - print(f" 5. Start adding skills, rules, and MCP tools!") + print(" 4. Enable GitHub Pages in Settings > Pages > Source: GitHub Actions") + if args.no_register: + print(" 5. Register in the catalog: add an entry to registry.json and run sync_from_registry.py") + else: + print(" 5. Catalog entry added; commit registry.json and the regenerated artifacts") + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scaffold/generator.py b/scaffold/generator.py new file mode 100644 index 0000000..c2434e1 --- /dev/null +++ b/scaffold/generator.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +"""Canonical scaffold-generation library for TMHSDigital developer tool repos. + +This module holds the single, importable implementation of repo generation so +both the ``create-tool.py`` CLI and any second generator (e.g. the +Developer-Tools-MCP ``createTool`` path) can DELEGATE to one code path instead +of reimplementing it and drifting apart. + +Public entrypoints: + +* ``generate_repo(...)`` - render a complete repo of a given type into an + output directory and (by default) register it in the meta catalog. +* ``build_registry_entry(...)`` - build a schema-valid ``registry.json`` entry + for a generated repo. +* ``register_in_registry(...)`` - append an entry to a registry and regenerate + the derived artifacts via ``sync_from_registry.sync_all`` (one sync path). + +The born-green acceptance contract these must satisfy is documented in +``standards/born-green-contract.md``. +""" +from __future__ import annotations + +import datetime +import json +import re +import sys +from pathlib import Path +from typing import Any, Optional + +try: + from jinja2 import Environment, FileSystemLoader +except ImportError: # pragma: no cover - environment guard + raise SystemExit("Error: Jinja2 is required. Install it with: pip install Jinja2") + + +SCAFFOLD_DIR = Path(__file__).resolve().parent +TEMPLATES_DIR = SCAFFOLD_DIR / "templates" +META_ROOT = SCAFFOLD_DIR.parent +STANDARDS_VERSION_FILE = META_ROOT / "STANDARDS_VERSION" +VERSION_FILE = META_ROOT / "VERSION" + +LICENSE_FILES = { + "cc-by-nc-nd-4.0": "CC-BY-NC-ND-4.0", + "mit": "MIT", + "apache-2.0": "Apache-2.0", +} + +SPDX = dict(LICENSE_FILES) + +DEFAULT_AUTHOR_NAME = "TMHSDigital" +DEFAULT_AUTHOR_EMAIL = "contact@users.noreply.github.com" +REPO_OWNER = "TMHSDigital" + + +class ScaffoldError(Exception): + """Raised for any unrecoverable generation/registration error. + + The CLI catches this and exits non-zero; library callers handle it. + """ + + +def slugify(name: str) -> str: + slug = name.lower().strip() + slug = re.sub(r"[^a-z0-9]+", "-", slug) + return slug.strip("-") + + +def _read_semver(path: Path, purpose: str) -> str: + try: + raw = path.read_text(encoding="utf-8").strip() + except FileNotFoundError as exc: + raise ScaffoldError( + f"{path.name} not found at {path}. The scaffold must run from a " + f"working copy of Developer-Tools-Directory." + ) from exc + except OSError as exc: + raise ScaffoldError(f"could not read {path}: {exc}") from exc + if not re.fullmatch(r"\d+\.\d+\.\d+", raw): + raise ScaffoldError( + f"{path.name} contents {raw!r} are not a valid X.Y.Z semver string; " + f"refusing to {purpose} from a malformed value." + ) + return raw + + +def read_standards_version() -> str: + """Read meta STANDARDS_VERSION. New repos are pre-aligned with it.""" + return _read_semver(STANDARDS_VERSION_FILE, "emit a standards-version marker") + + +def read_meta_version() -> tuple[int, int, int]: + """Read meta VERSION as (major, minor, patch). Action pins derive from it.""" + raw = _read_semver(VERSION_FILE, "derive workflow action pins") + major, minor, patch = (int(x) for x in raw.split(".")) + return major, minor, patch + + +def _env() -> Environment: + return Environment( + loader=FileSystemLoader(str(TEMPLATES_DIR)), + keep_trailing_newline=True, + lstrip_blocks=True, + trim_blocks=True, + ) + + +def render_template(env: Environment, template_name: str, context: dict) -> str: + return env.get_template(template_name).render(**context) + + +def write_file(base: Path, rel_path: str, content: str, *, verbose: bool = True) -> None: + full = base / rel_path + full.parent.mkdir(parents=True, exist_ok=True) + full.write_text(content, encoding="utf-8") + if verbose: + print(f" created {rel_path}") + + +def build_context( + *, + name: str, + slug: str, + description: str, + repo_type: str, + has_mcp: bool, + skill_names: list[str], + rule_names: list[str], + license_key: str, + author_name: str, + author_email: str, +) -> dict[str, Any]: + standards_version = read_standards_version() + meta_major, meta_minor, meta_patch = read_meta_version() + return { + "name": name, + "slug": slug, + "description": description, + "type": repo_type, + "has_mcp": has_mcp, + "skills": skill_names, + "rules": rule_names, + "skill_count": len(skill_names), + "rule_count": len(rule_names), + "license_spdx": SPDX[license_key], + "license_key": license_key, + "author_name": author_name, + "author_email": author_email, + "repo_owner": REPO_OWNER, + "repo_name": slug, + "standards_version": standards_version, + "meta_major": meta_major, + "meta_minor": meta_minor, + "meta_patch": meta_patch, + "meta_version": f"{meta_major}.{meta_minor}.{meta_patch}", + "year": datetime.datetime.now(datetime.timezone.utc).year, + } + + +def _skill_content(skill: str, standards_version: str) -> str: + title = skill.replace("-", " ").title() + return ( + f"---\nname: {skill}\n" + "description: TODO - describe this skill\n" + 'globs: ["**/*"]\n' + "alwaysApply: false\n" + f"standards-version: {standards_version}\n" + f"---\n\n# {title}\n\nTODO: Add skill content here.\n" + ) + + +def _rule_content(rule: str, standards_version: str) -> str: + title = rule.replace("-", " ").title() + return ( + "---\ndescription: TODO - describe this rule\n" + 'globs: ["**/*"]\n' + "alwaysApply: false\n" + f"standards-version: {standards_version}\n" + f"---\n\n# {title}\n\nTODO: Add rule content here.\n" + ) + + +def _render_repo(output_dir: Path, ctx: dict, *, verbose: bool) -> None: + env = _env() + repo_type = ctx["type"] + has_mcp = ctx["has_mcp"] + + if repo_type == "cursor-plugin": + write_file(output_dir, ".cursor-plugin/plugin.json", render_template(env, "plugin.json.j2", ctx), verbose=verbose) + + # Workflows: type-specific set matching standards/drift-checker.config.json + # plus the two optional-for-both workflows (pages, label-sync). mcp-server + # repos do NOT get cursor-plugin-specific validate.yml / release.yml. + if repo_type == "mcp-server": + write_file(output_dir, ".github/workflows/publish.yml", render_template(env, "publish.yml.j2", ctx), verbose=verbose) + write_file(output_dir, ".github/workflows/pages.yml", render_template(env, "pages.mcp.yml.j2", ctx), verbose=verbose) + else: + write_file(output_dir, ".github/workflows/validate.yml", render_template(env, "validate.yml.j2", ctx), verbose=verbose) + write_file(output_dir, ".github/workflows/release.yml", render_template(env, "release.yml.j2", ctx), verbose=verbose) + write_file(output_dir, ".github/workflows/pages.yml", render_template(env, "pages.yml.j2", ctx), verbose=verbose) + write_file(output_dir, ".github/workflows/stale.yml", render_template(env, "stale.yml.j2", ctx), verbose=verbose) + write_file(output_dir, ".github/workflows/drift-check.yml", render_template(env, "drift-check.yml.j2", ctx), verbose=verbose) + write_file(output_dir, ".github/workflows/label-sync.yml", render_template(env, "label-sync.yml.j2", ctx), verbose=verbose) + + write_file(output_dir, ".github/dependabot.yml", render_template(env, "dependabot.yml.j2", ctx), verbose=verbose) + + for rel, tmpl in ( + ("README.md", "README.md.j2"), + ("AGENTS.md", "AGENTS.md.j2"), + ("CLAUDE.md", "CLAUDE.md.j2"), + ("CONTRIBUTING.md", "CONTRIBUTING.md.j2"), + ("CHANGELOG.md", "CHANGELOG.md.j2"), + ("CODE_OF_CONDUCT.md", "CODE_OF_CONDUCT.md.j2"), + ("SECURITY.md", "SECURITY.md.j2"), + ("ROADMAP.md", "ROADMAP.md.j2"), + ("LICENSE", "LICENSE.j2"), + (".cursorrules", "cursorrules.j2"), + (".gitignore", "gitignore.j2"), + ("site.json", "site.json.j2"), + ("mcp-tools.json", "mcp-tools.json.j2"), + ): + write_file(output_dir, rel, render_template(env, tmpl, ctx), verbose=verbose) + + (output_dir / "assets").mkdir(parents=True, exist_ok=True) + (output_dir / "assets" / ".gitkeep").touch() + + if repo_type == "mcp-server": + write_file(output_dir, "package.json", render_template(env, "package.json.j2", ctx), verbose=verbose) + write_file(output_dir, "docs/index.html", render_template(env, "docs/index.mcp.html.j2", ctx), verbose=verbose) + + standards_version = ctx["standards_version"] + for skill in ctx["skills"]: + write_file(output_dir, f"skills/{skill}/SKILL.md", _skill_content(skill, standards_version), verbose=verbose) + for rule in ctx["rules"]: + write_file(output_dir, f"rules/{rule}.mdc", _rule_content(rule, standards_version), verbose=verbose) + + (output_dir / "tests").mkdir(parents=True, exist_ok=True) + (output_dir / "tests" / ".gitkeep").touch() + + if has_mcp: + write_file(output_dir, "mcp-server/server.py", render_template(env, "mcp-server/server.py.j2", ctx), verbose=verbose) + write_file(output_dir, "mcp-server/requirements.txt", render_template(env, "mcp-server/requirements.txt.j2", ctx), verbose=verbose) + (output_dir / "mcp-server" / "tools").mkdir(parents=True, exist_ok=True) + (output_dir / "mcp-server" / "tools" / ".gitkeep").touch() + (output_dir / "mcp-server" / "data").mkdir(parents=True, exist_ok=True) + (output_dir / "mcp-server" / "data" / ".gitkeep").touch() + write_file(output_dir, ".cursor/mcp.json", render_template(env, "mcp-server/mcp.json.j2", ctx), verbose=verbose) + + +def _resolve_repo_type(output_dir: Path, intended: str) -> str: + """Resolve the repo type from the generated tree using the SAME positive + marker detector the drift checker uses, so the registry entry's type + matches what CI will see. Falls back to ``intended`` if the detector + cannot be imported (e.g. delegated generator without scripts/ on path).""" + try: + if str(META_ROOT) not in sys.path: + sys.path.insert(0, str(META_ROOT)) + from scripts.drift_check.snapshot import _detect_repo_type # type: ignore + except Exception: + return intended + detected = _detect_repo_type(output_dir) + if detected == "unknown": + raise ScaffoldError( + f"generated repo at {output_dir} classifies as 'unknown'; the " + f"positive type marker for {intended!r} was not written. This is a " + f"born-unknown bug - see standards/born-green-contract.md." + ) + return detected + + +def build_registry_entry( + *, + name: str, + slug: str, + description: str, + repo_type: str, + skill_count: int, + rule_count: int, + license_key: str, +) -> dict[str, Any]: + """Build a schema-valid registry.json entry for a generated repo. + + Mirrors the schema enforced by validate.yml. Intentionally has NO + ``version`` field (removed in PR #73). Counts reflect generated content; + a fresh repo has no MCP tools yet so ``mcpTools`` is 0. + """ + homepage = f"https://{REPO_OWNER.lower()}.github.io/{slug}/" + return { + "name": name, + "repo": f"{REPO_OWNER}/{slug}", + "slug": slug, + "description": description, + "type": repo_type, + "homepage": homepage, + "skills": skill_count, + "rules": rule_count, + "mcpTools": 0, + "extras": {}, + "topics": [repo_type, "developer-tools"], + "status": "active", + "language": "Python", + "license": SPDX[license_key], + "pagesType": "static", + "hasCI": True, + } + + +def register_in_registry( + registry_root: Path, + entry: dict[str, Any], + *, + run_sync: bool = True, + verbose: bool = True, +) -> None: + """Append ``entry`` to ``registry_root/registry.json`` and regenerate the + derived artifacts via the shared ``sync_all`` code path. + + Refuses to register a slug or repo that already exists (idempotency + guard: a re-run must not silently duplicate or clobber a catalog entry). + """ + registry_path = registry_root / "registry.json" + if not registry_path.is_file(): + raise ScaffoldError(f"registry.json not found at {registry_path}") + try: + entries = json.loads(registry_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ScaffoldError(f"registry.json is not valid JSON: {exc}") from exc + if not isinstance(entries, list): + raise ScaffoldError("registry.json must be a JSON array") + + for existing in entries: + if existing.get("slug") == entry["slug"]: + raise ScaffoldError( + f"registry already contains slug {entry['slug']!r}; refusing to " + f"duplicate. Use --no-register or remove the existing entry." + ) + if existing.get("repo") == entry["repo"]: + raise ScaffoldError( + f"registry already contains repo {entry['repo']!r}; refusing to " + f"duplicate." + ) + + entries.append(entry) + registry_path.write_text( + json.dumps(entries, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + if verbose: + print(f" registered {entry['slug']} in {registry_path}") + + if run_sync: + if str(META_ROOT) not in sys.path: + sys.path.insert(0, str(META_ROOT)) + from scripts.sync_from_registry import sync_all # type: ignore + + sync_all(registry_root, check=False) + if verbose: + print(" regenerated derived artifacts (README, CLAUDE, docs)") + + +def generate_repo( + *, + name: str, + description: str, + slug: Optional[str] = None, + repo_type: str = "cursor-plugin", + mcp_server: bool = False, + skills: int = 0, + rules: int = 0, + license_key: str = "cc-by-nc-nd-4.0", + output: str = "output", + author_name: str = DEFAULT_AUTHOR_NAME, + author_email: str = DEFAULT_AUTHOR_EMAIL, + register: bool = True, + registry_root: Optional[Path] = None, + verbose: bool = True, +) -> Path: + """Render a complete, standards-compliant repo and (by default) register + it in the meta catalog. Returns the path to the generated repo. + + Setting ``register=False`` skips catalog registration (the rare + deliberate case). ``registry_root`` overrides where registration writes + (defaults to the meta-repo root); used by tests to target a temp catalog. + """ + if repo_type not in ("cursor-plugin", "mcp-server"): + raise ScaffoldError(f"unknown repo type: {repo_type!r}") + if license_key not in LICENSE_FILES: + raise ScaffoldError(f"unknown license: {license_key!r}") + + slug = slug or slugify(name) + if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", slug): + raise ScaffoldError(f"slug {slug!r} is not valid kebab-case") + + output_dir = Path(output) / slug + if output_dir.exists(): + raise ScaffoldError(f"output directory already exists: {output_dir}") + + skill_names = [f"skill-{i + 1}" for i in range(skills)] + rule_names = [f"rule-{i + 1}" for i in range(rules)] + + ctx = build_context( + name=name, + slug=slug, + description=description, + repo_type=repo_type, + has_mcp=mcp_server, + skill_names=skill_names, + rule_names=rule_names, + license_key=license_key, + author_name=author_name, + author_email=author_email, + ) + + if verbose: + print(f"\nScaffolding '{name}' ({slug}) into {output_dir}\n") + + _render_repo(output_dir, ctx, verbose=verbose) + + # Resolve the type from the generated tree via the drift checker's + # positive-marker detector; this both validates the marker was written + # and pins the registry entry's type to what CI will detect. + resolved_type = _resolve_repo_type(output_dir, repo_type) + + if register: + root = registry_root if registry_root is not None else META_ROOT + entry = build_registry_entry( + name=name, + slug=slug, + description=description, + repo_type=resolved_type, + skill_count=len(skill_names), + rule_count=len(rule_names), + license_key=license_key, + ) + register_in_registry(Path(root), entry, run_sync=True, verbose=verbose) + + return output_dir diff --git a/scripts/drift_check/snapshot.py b/scripts/drift_check/snapshot.py index bc04853..302c6bb 100644 --- a/scripts/drift_check/snapshot.py +++ b/scripts/drift_check/snapshot.py @@ -48,7 +48,8 @@ "CLAUDE.md", "skills", "rules", - ".cursor-plugin", + ".cursor-plugin", # plugin.json -> cursor-plugin positive marker + "package.json", # mcp-server positive marker (see _detect_repo_type) ".github/workflows", # workflow presence for the required-workflows check ) @@ -76,18 +77,31 @@ def list_meta_standards(meta_repo_path: Path) -> frozenset[str]: def _detect_repo_type(repo_path: Path) -> RepoType: - """Per the design doc's detection rules: + """Detect repo type from POSITIVE per-type manifest markers only. + + Each type is keyed on a manifest file the scaffold always writes for + that type, never on incidental directory presence: * ``.cursor-plugin/plugin.json`` present -> ``cursor-plugin`` - * no skills/ or rules/ directories but CLAUDE.md present -> ``mcp-server`` + * else ``package.json`` present -> ``mcp-server`` (the mcp-server's own + manifest and version source of truth; ``publish.yml`` reads it) * otherwise -> ``unknown`` + + The order matters: a cursor-plugin that also ships a ``package.json`` + (build tooling, an MCP companion) is still a cursor-plugin because its + ``plugin.json`` is checked first. + + History (DTD, PR #74 follow-up): detection previously inferred + mcp-server from the ABSENCE of ``skills/`` and ``rules/`` plus a + ``CLAUDE.md`` (which both types carry). That negative heuristic + silently reclassified an mcp-server rendered WITH ``skills/`` as + ``unknown``, dropping required-workflow enforcement. Keying on a + positive manifest marker removes that fragility: an mcp-server is an + mcp-server regardless of which optional directories it happens to have. """ if (repo_path / ".cursor-plugin" / "plugin.json").is_file(): return "cursor-plugin" - has_skills = (repo_path / "skills").is_dir() - has_rules = (repo_path / "rules").is_dir() - has_claude = (repo_path / "CLAUDE.md").is_file() - if not has_skills and not has_rules and has_claude: + if (repo_path / "package.json").is_file(): return "mcp-server" return "unknown" diff --git a/scripts/sync_from_registry.py b/scripts/sync_from_registry.py index 41c6dc6..fa825be 100644 --- a/scripts/sync_from_registry.py +++ b/scripts/sync_from_registry.py @@ -39,8 +39,8 @@ } -def load_registry() -> list[dict[str, Any]]: - with REGISTRY_PATH.open("r", encoding="utf-8") as fh: +def load_registry(registry_path: Path = REGISTRY_PATH) -> list[dict[str, Any]]: + with registry_path.open("r", encoding="utf-8") as fh: data = json.load(fh) if not isinstance(data, list): raise SystemExit("registry.json must be a JSON array") @@ -172,63 +172,71 @@ def replace_script_block(text: str, new_body: str) -> str: return pattern.sub(replacement, text, count=1) -def sync_readme(entries: list[dict[str, Any]], check: bool) -> bool: - current = README_PATH.read_text(encoding="utf-8") +def sync_readme(entries: list[dict[str, Any]], check: bool, root: Path = REPO_ROOT) -> bool: + readme_path = root / "README.md" + current = readme_path.read_text(encoding="utf-8") new = current new = replace_between( new, "", "", render_readme_tools_table(entries), - README_PATH, + readme_path, ) new = replace_between( new, "", "", render_readme_descriptions(entries), - README_PATH, + readme_path, ) new = replace_between( new, "", "", render_readme_stats(entries), - README_PATH, + readme_path, ) - return write_if_changed(README_PATH, current, new, check) + return write_if_changed(readme_path, current, new, check, root) -def sync_claude(entries: list[dict[str, Any]], check: bool) -> bool: - current = CLAUDE_PATH.read_text(encoding="utf-8") +def sync_claude(entries: list[dict[str, Any]], check: bool, root: Path = REPO_ROOT) -> bool: + claude_path = root / "CLAUDE.md" + current = claude_path.read_text(encoding="utf-8") new = current new = replace_between( new, "", "", render_claude_tools_table(entries), - CLAUDE_PATH, + claude_path, ) new = replace_between( new, "", "", render_claude_stats(entries), - CLAUDE_PATH, + claude_path, ) - return write_if_changed(CLAUDE_PATH, current, new, check) + return write_if_changed(claude_path, current, new, check, root) -def sync_index(entries: list[dict[str, Any]], check: bool) -> bool: - current = INDEX_PATH.read_text(encoding="utf-8") +def sync_index(entries: list[dict[str, Any]], check: bool, root: Path = REPO_ROOT) -> bool: + index_path = root / "docs" / "index.html" + current = index_path.read_text(encoding="utf-8") new = replace_script_block(current, render_embedded_registry(entries)) - return write_if_changed(INDEX_PATH, current, new, check) + return write_if_changed(index_path, current, new, check, root) -def write_if_changed(path: Path, current: str, new: str, check: bool) -> bool: +def write_if_changed( + path: Path, current: str, new: str, check: bool, root: Path = REPO_ROOT +) -> bool: if current == new: return False - rel = path.relative_to(REPO_ROOT) + try: + rel = path.relative_to(root) + except ValueError: + rel = path if check: print(f"DRIFT: {rel} is out of sync with registry.json", file=sys.stderr) return True @@ -237,6 +245,19 @@ def write_if_changed(path: Path, current: str, new: str, check: bool) -> bool: return True +def sync_all(root: Path = REPO_ROOT, check: bool = False) -> bool: + """Regenerate (or check) every derived artifact under ``root`` from + ``root/registry.json``. Returns True if anything drifted (check mode) + or changed (write mode). Used by both the CLI and the scaffold's + auto-registration so there is a single sync code path.""" + entries = load_registry(root / "registry.json") + drift = False + drift |= sync_readme(entries, check, root) + drift |= sync_claude(entries, check, root) + drift |= sync_index(entries, check, root) + return drift + + def about_command(entries: list[dict[str, Any]]) -> str: s = aggregate_stats(entries) description = ( @@ -276,10 +297,7 @@ def main() -> int: print(about_command(entries)) return 0 - drift = False - drift |= sync_readme(entries, args.check) - drift |= sync_claude(entries, args.check) - drift |= sync_index(entries, args.check) + drift = sync_all(REPO_ROOT, args.check) if args.check: if drift: diff --git a/standards/README.md b/standards/README.md index 2a28518..6ff4c55 100644 --- a/standards/README.md +++ b/standards/README.md @@ -15,6 +15,7 @@ Standards and conventions for building Cursor IDE plugins, MCP servers, and deve | [AGENTS.md Template](agents-template.md) | AI agent guidance file structure | | [Versioning](versioning.md) | Semver management and release flow | | [Release-doc-sync](release-doc-sync.md) | Composite action contract for keeping CHANGELOG, CLAUDE, and ROADMAP in sync after auto-release | +| [Born-Green Contract](born-green-contract.md) | Acceptance criterion every repo generator must satisfy (positive-marker type, exact workflows, empty drift, current pins, consistent counts, registered in catalog) | | [Testing](testing.md) | Test frameworks, minimum coverage bar, CI wiring | | [Skills](skills.md) | `SKILL.md` structure, frontmatter, and conventions | | [Rules](rules.md) | `.mdc` structure, globs, and the secrets rule pattern | diff --git a/standards/born-green-contract.md b/standards/born-green-contract.md new file mode 100644 index 0000000..558aa4d --- /dev/null +++ b/standards/born-green-contract.md @@ -0,0 +1,75 @@ +# Born-Green Contract + +The acceptance criterion that any generator producing a TMHSDigital tool repo must satisfy. A repo is "born green" when, the moment it is generated, it already passes every check the ecosystem enforces - no manual reconcile step, no first-day drift, no catalog gap. + +This is a specification, not prose. Each clause is a testable assertion. The canonical generator (`scaffold/generator.py`, driven by `scaffold/create-tool.py`) satisfies it and is locked in by `tests/test_scaffold_born_green.py`. A second generator (for example the Developer-Tools-MCP `createTool` path) is compliant only if it satisfies every clause, ideally by delegating to the canonical entrypoint rather than reimplementing it. + +## Why this exists + +The ecosystem has had two recurring "born wrong" failure modes: + +1. A repo generated with the wrong workflow set (an mcp-server emitted with `validate.yml`/`release.yml`, which assume a plugin manifest). Fixed in PR #74. +2. A repo whose type the drift checker could not detect, because detection keyed on incidental directory presence rather than a positive manifest marker. An mcp-server rendered with a `skills/` directory silently classified as `unknown` and lost required-workflow enforcement. +3. A repo born outside the catalog: generated, pushed, and never added to `registry.json`, so it never appears in the docs site, README tables, or drift `mode: all`. + +Each was discovered reconciles later, by hand. The contract turns each into a generation-time invariant and a CI failure. + +## Acceptance clauses + +A generated repo MUST satisfy all of the following. + +### C1. Positive-marker type + +The repo carries the positive manifest marker for its type, and `scripts/drift_check/snapshot.py::_detect_repo_type` returns that exact type (never `unknown`): + +| Type | Positive marker (always written) | +| --- | --- | +| `cursor-plugin` | `.cursor-plugin/plugin.json` | +| `mcp-server` | `package.json` (the server's own manifest and version source of truth) | + +Detection MUST key only on these positive markers, never on the presence or absence of optional directories such as `skills/` or `rules/`. A type's classification MUST be stable regardless of which optional content the repo happens to include (an mcp-server with `skills/` is still an mcp-server). + +### C2. Exact workflow set + +The emitted `.github/workflows/` set EXACTLY equals the per-type required set in [`drift-checker.config.json`](drift-checker.config.json) plus the two optional-for-both workflows (`label-sync.yml`, `pages.yml`) - no more, no less. See [`ci-cd.md`](ci-cd.md) for the per-type matrix. mcp-server repos MUST NOT emit `validate.yml` or `release.yml` (their jobs assume a plugin manifest; `publish.yml` replaces `release.yml`). + +### C3. Empty drift + +Running the drift checker against the generated tree yields zero error-severity and zero warning-severity findings. The generated repo tolerates zero skill/rule content: an empty repo (no `skills/`, no `rules/`) is still born green. + +### C4. Current markers and pins + +- Every `standards-version` marker equals the meta `STANDARDS_VERSION` at generation time. +- Every workflow action pin is DERIVED from the meta `VERSION` at generation time (for example `drift-check@vMAJOR.MINOR`, `release-doc-sync@vMAJOR`, `meta-repo-ref: vMAJOR.MINOR.PATCH`). No pin, year, or version is a hardcoded literal in a template. + +### C5. Consistent README counts + +The generated README's aggregate counts (`N skills`, `N rules`, ...) match the generated content, so the repo's own `validate-counts` check passes on day one. + +### C6. Registered in the catalog + +Generation registers the repo in `registry.json` with a complete, schema-valid entry (the schema in [`../AGENTS.md`](../AGENTS.md) and enforced by `validate.yml`), and regenerates the derived artifacts (`README.md`, `CLAUDE.md`, `docs/index.html`) via the single `sync_from_registry.sync_all` code path so `python scripts/sync_from_registry.py --check` is clean. The entry's `type` is resolved from the same positive-marker detector as C1. The entry carries no `version` field (removed in PR #73). Registration is the default; an explicit escape hatch (`--no-register`) exists for the rare deliberate case. + +## Canonical entrypoint + +The single, importable implementation lives in `scaffold/generator.py`: + +```python +from scaffold.generator import generate_repo + +generate_repo( + name="Example Developer Tools", + description="One-line description", + repo_type="cursor-plugin", # or "mcp-server" + skills=0, + rules=0, + register=True, # C6; set False only for throwaway generation + registry_root=None, # override for tests / temp catalogs +) +``` + +`generate_repo` renders the repo (C1-C5), resolves the type from the generated tree via `_detect_repo_type` (failing loudly if it is `unknown`), and registers it (C6). A second generator SHOULD delegate to this function rather than reimplement the render-and-register logic; reimplementation is how two generators drift apart. `build_registry_entry` and `register_in_registry` are exposed for callers that need the pieces independently. + +## Verification + +`tests/test_scaffold_born_green.py` renders a repo for each supported type, including the previously-breaking shapes (an mcp-server with `skills/`, and an empty cursor-plugin), and asserts C1-C5 against the generated tree plus a C6 registration round-trip against a temporary registry root. "Born wrong, born unknown, or born unregistered" are all CI failures. diff --git a/tests/fixtures/drift_check/mcp_repo/CLAUDE.md b/tests/fixtures/drift_check/mcp_repo/CLAUDE.md index ca04084..07cccfa 100644 --- a/tests/fixtures/drift_check/mcp_repo/CLAUDE.md +++ b/tests/fixtures/drift_check/mcp_repo/CLAUDE.md @@ -2,4 +2,4 @@ # CLAUDE.md -MCP server pattern: CLAUDE.md only, no skills or rules. +MCP server pattern: package.json manifest marker, no skills or rules. diff --git a/tests/fixtures/drift_check/mcp_repo/package.json b/tests/fixtures/drift_check/mcp_repo/package.json new file mode 100644 index 0000000..1d857f8 --- /dev/null +++ b/tests/fixtures/drift_check/mcp_repo/package.json @@ -0,0 +1,5 @@ +{ + "name": "@tmhs/mcp-repo-fixture", + "version": "0.1.0", + "description": "mcp-server detection fixture: package.json is the positive type marker" +} diff --git a/tests/test_scaffold_born_green.py b/tests/test_scaffold_born_green.py index b95fcc2..2d114ac 100644 --- a/tests/test_scaffold_born_green.py +++ b/tests/test_scaffold_born_green.py @@ -25,8 +25,8 @@ from __future__ import annotations import json -import os import re +import shutil import subprocess import sys from pathlib import Path @@ -59,13 +59,56 @@ OPTIONAL_FOR_BOTH = frozenset({"label-sync.yml", "pages.yml"}) -# Render parameters per type. mcp-server is rendered with zero skills/rules so -# the no-plugin-manifest detection shape (no skills/, no rules/, CLAUDE.md -# present) holds; rendering it with skills would create skills/ and make -# _detect_repo_type return "unknown". -_CASES = { - "cursor-plugin": ["--skills", "2", "--rules", "1"], - "mcp-server": [], +# Each case is a distinct render. ``type`` is the intended repo type, ``args`` +# are extra CLI flags, ``detect`` is the type _detect_repo_type MUST return. +# The "*-with-skills" / "*-empty" variants are the regression guards: +# - mcp-server-with-skills was the PR #74 latent fragility: the old detector +# keyed on directory presence and flipped it to "unknown", silently losing +# required-workflow enforcement. With positive-marker detection (package.json) +# it must classify as mcp-server. +# - cursor-plugin-empty proves born-green tolerates ZERO skill/rule content. +_CASES: dict[str, dict] = { + "cursor-plugin": { + "type": "cursor-plugin", + "args": ["--skills", "2", "--rules", "1"], + "detect": "cursor-plugin", + }, + "cursor-plugin-empty": { + "type": "cursor-plugin", + "args": [], + "detect": "cursor-plugin", + }, + "mcp-server": { + "type": "mcp-server", + "args": [], + "detect": "mcp-server", + }, + "mcp-server-with-skills": { + "type": "mcp-server", + "args": ["--skills", "3", "--rules", "2"], + "detect": "mcp-server", + }, +} + +# Registry schema mirrored from validate.yml's registry check, used by the +# registration round-trip test to assert a generated entry is schema-valid. +_REQUIRED_FIELDS = { + "name": str, + "repo": str, + "slug": str, + "description": str, + "type": str, + "homepage": str, + "skills": int, + "rules": int, + "mcpTools": int, + "extras": dict, + "topics": list, + "status": str, + "language": str, + "license": str, + "pagesType": str, + "hasCI": bool, } @@ -85,22 +128,26 @@ def _required_workflows(repo_type: str) -> frozenset[str]: return frozenset(cfg["types"][repo_type]["required_workflows"]) -def _render(repo_type: str, dest: Path) -> Path: - slug = f"born-green-{repo_type}" +def _render(label: str, dest: Path) -> Path: + """Render a case with --no-register (these assertions are about the + generated tree, not the catalog; registration is covered separately).""" + case = _CASES[label] + slug = f"born-green-{label}" cmd = [ sys.executable, str(CREATE_TOOL), "--name", - f"Born Green {repo_type}", + f"Born Green {label}", "--description", "born green probe", "--type", - repo_type, + case["type"], "--slug", slug, "--output", str(dest), - *_CASES[repo_type], + "--no-register", + *case["args"], ] proc = subprocess.run(cmd, capture_output=True, text=True) assert proc.returncode == 0, f"scaffold failed:\n{proc.stdout}\n{proc.stderr}" @@ -112,9 +159,9 @@ def _render(repo_type: str, dest: Path) -> Path: @pytest.fixture(scope="module") def rendered(tmp_path_factory) -> dict[str, Path]: out: dict[str, Path] = {} - for repo_type in _CASES: - dest = tmp_path_factory.mktemp(repo_type) - out[repo_type] = _render(repo_type, dest) + for label in _CASES: + dest = tmp_path_factory.mktemp(label) + out[label] = _render(label, dest) return out @@ -145,29 +192,33 @@ def _run_drift_checks(repo: Path, slug: str): return snap, findings -@pytest.mark.parametrize("repo_type", list(_CASES)) -def test_detected_type_matches(rendered, repo_type): - repo = rendered[repo_type] - assert _detect_repo_type(repo) == repo_type, ( - f"{repo_type} scaffold detected as {_detect_repo_type(repo)!r}" +@pytest.mark.parametrize("label", list(_CASES)) +def test_detected_type_matches(rendered, label): + repo = rendered[label] + expected = _CASES[label]["detect"] + got = _detect_repo_type(repo) + assert got == expected, ( + f"case {label!r} ({_CASES[label]['type']}) detected as {got!r}, " + f"expected {expected!r}" ) -@pytest.mark.parametrize("repo_type", list(_CASES)) -def test_born_green_no_drift(rendered, repo_type): - repo = rendered[repo_type] - snap, findings = _run_drift_checks(repo, f"born-green-{repo_type}") +@pytest.mark.parametrize("label", list(_CASES)) +def test_born_green_no_drift(rendered, label): + repo = rendered[label] + repo_type = _CASES[label]["type"] + snap, findings = _run_drift_checks(repo, f"born-green-{label}") assert snap.repo_type == repo_type actionable = [f for f in findings if f.severity in ("error", "warn")] assert actionable == [], ( - "freshly scaffolded repo is not born green: " + f"freshly scaffolded {label} repo is not born green: " + "; ".join(f"{f.check}/{f.severity}: {f.message}" for f in actionable) ) -@pytest.mark.parametrize("repo_type", list(_CASES)) -def test_standards_markers_current(rendered, repo_type): - repo = rendered[repo_type] +@pytest.mark.parametrize("label", list(_CASES)) +def test_standards_markers_current(rendered, label): + repo = rendered[label] expected = _standards_version() marker_files = [repo / "AGENTS.md", repo / "CLAUDE.md"] for skill_md in (repo / "skills").glob("*/SKILL.md"): @@ -181,9 +232,10 @@ def test_standards_markers_current(rendered, repo_type): ) -@pytest.mark.parametrize("repo_type", list(_CASES)) -def test_action_pins_derived(rendered, repo_type): - repo = rendered[repo_type] +@pytest.mark.parametrize("label", list(_CASES)) +def test_action_pins_derived(rendered, label): + repo = rendered[label] + repo_type = _CASES[label]["type"] major, minor, patch = _meta_version_parts() drift = (repo / ".github" / "workflows" / "drift-check.yml").read_text(encoding="utf-8") assert f"drift-check@v{major}.{minor}" in drift, ( @@ -199,9 +251,10 @@ def test_action_pins_derived(rendered, repo_type): ) -@pytest.mark.parametrize("repo_type", list(_CASES)) -def test_readme_counts_consistent(rendered, repo_type): - repo = rendered[repo_type] +@pytest.mark.parametrize("label", list(_CASES)) +def test_readme_counts_consistent(rendered, label): + repo = rendered[label] + repo_type = _CASES[label]["type"] skills_dir = repo / "skills" rules_dir = repo / "rules" skill_count = ( @@ -224,9 +277,10 @@ def test_readme_counts_consistent(rendered, repo_type): ) -@pytest.mark.parametrize("repo_type", list(_CASES)) -def test_emitted_workflow_set_exact(rendered, repo_type): - repo = rendered[repo_type] +@pytest.mark.parametrize("label", list(_CASES)) +def test_emitted_workflow_set_exact(rendered, label): + repo = rendered[label] + repo_type = _CASES[label]["type"] present = frozenset( p.name for p in (repo / ".github" / "workflows").iterdir() @@ -234,10 +288,71 @@ def test_emitted_workflow_set_exact(rendered, repo_type): ) expected = _required_workflows(repo_type) | OPTIONAL_FOR_BOTH assert present == expected, ( - f"{repo_type} emitted workflows {sorted(present)} != expected " - f"{sorted(expected)} (required ∪ optional-for-both)" + f"{label} emitted workflows {sorted(present)} != expected " + f"{sorted(expected)} (required for {repo_type} union optional-for-both)" + ) + + +def _make_temp_registry_root(tmp_path: Path) -> Path: + """Copy the catalog artifacts sync_all touches into a temp root so + registration can be exercised without mutating the live registry.""" + root = tmp_path / "catalog" + (root / "docs").mkdir(parents=True) + for rel in ("registry.json", "README.md", "CLAUDE.md"): + shutil.copy2(REPO_ROOT / rel, root / rel) + shutil.copy2(REPO_ROOT / "docs" / "index.html", root / "docs" / "index.html") + return root + + +@pytest.mark.parametrize("label", ["cursor-plugin", "mcp-server-with-skills"]) +def test_registration_round_trips(tmp_path, label): + """Generation-with-registration must produce a schema-valid registry + entry and leave the catalog sync-clean (sync --check passes), so a repo + cannot be born unregistered or born inconsistent. Exercised against a + TEMP registry root - the live registry.json is never touched.""" + from scripts.sync_from_registry import sync_all # noqa: E402 + + case = _CASES[label] + root = _make_temp_registry_root(tmp_path) + slug = f"reg-{label}" + cmd = [ + sys.executable, + str(CREATE_TOOL), + "--name", + f"Reg {label}", + "--description", + "registration round-trip probe", + "--type", + case["type"], + "--slug", + slug, + "--output", + str(tmp_path / "out"), + "--registry-root", + str(root), + *case["args"], + ] + proc = subprocess.run(cmd, capture_output=True, text=True) + assert proc.returncode == 0, f"register failed:\n{proc.stdout}\n{proc.stderr}" + + entries = json.loads((root / "registry.json").read_text(encoding="utf-8")) + matches = [e for e in entries if e["slug"] == slug] + assert len(matches) == 1, f"{slug} not registered exactly once" + entry = matches[0] + + for field, typ in _REQUIRED_FIELDS.items(): + assert field in entry, f"registry entry missing required field {field!r}" + assert isinstance(entry[field], typ), ( + f"field {field!r} is {type(entry[field]).__name__}, expected {typ.__name__}" + ) + assert "version" not in entry, "registry entry must not carry a version field" + assert entry["type"] == case["detect"], ( + f"registered type {entry['type']!r} != detected {case['detect']!r}" ) + drift = sync_all(root, check=True) + assert drift is False, "catalog is not sync-clean after registration" + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index b54bb2e..5cabf0d 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -25,7 +25,10 @@ def test_clean_repo_full_snapshot(): assert fs.signal.version is not None -def test_mcp_repo_only_claude(): +def test_mcp_repo_detected_by_package_json(): + # mcp-server is detected by its positive manifest marker (package.json), + # not by the absence of skills/rules. package.json is not one of the + # snapshotted agent files, so snap.files still contains only CLAUDE.md. snap = build_local_snapshot(FIXTURES / "mcp_repo", META, "HEAD", DEFAULT_CFG) assert snap.repo_type == "mcp-server" assert len(snap.files) == 1