← Files Vibe CodingARCHIVED FILE

scripts/validate.py

7.07 KB · Oct 3, 2026 · 06:36 UTC

↓ Download file

#!/usr/bin/env python3
"""Validate the package, discovery metadata, catalog and links."""

import hashlib
import json
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlsplit
from catalog_site import render_catalog
from metadata import skill_metadata, ui_metadata, catalog_errors
from release_files import release_files

ROOT=Path(__file__).resolve().parents[1]


def validate(root=ROOT):
    root=root.resolve();errors=[]
    def check(ok,message):
        if not ok:errors.append(message)
    def inside(raw,base=root):
        p=(base/raw).resolve()
        return p if p.is_relative_to(root) else None
    try:
        manifest=json.loads((root/'.codex-plugin/plugin.json').read_text(encoding='utf-8'))
        catalog=json.loads((root/'catalog.json').read_text(encoding='utf-8'))
    except (OSError,ValueError) as exc:return [str(exc)]
    if not isinstance(manifest, dict): return ['plugin.json: expected an object']
    schema_errors = catalog_errors(catalog)
    if schema_errors: return schema_errors
    _, inventory_errors = release_files(root)
    errors.extend(inventory_errors)
    check(manifest.get('name')==root.name=='vibe-coding','Plugin name/folder mismatch')
    check(bool(re.fullmatch(r'\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?',manifest.get('version',''))),'Invalid version')
    check(manifest.get('skills')=='./skills/','Invalid skills path')
    check(not any(k in manifest for k in ('hooks','mcpServers','apps')),'Unexpected runtime integration in skills-only package')
    skills={p.parent.name:p for p in (root/'skills').glob('*/SKILL.md')}
    check(len(skills)==catalog.get('skills'),'Catalog skill count mismatch')
    for name,path in skills.items():
        text=path.read_text(encoding='utf-8')
        try: skill_metadata(text, f'skills/{name}/SKILL.md', name)
        except ValueError as exc: errors.append(str(exc))
        meta=path.parent/'agents/openai.yaml'
        check(meta.is_file(),f'Missing UI metadata: {name}')
        if meta.is_file():
            try: values=ui_metadata(meta.read_text(encoding='utf-8'), f'skills/{name}/agents/openai.yaml')
            except ValueError as exc:
                errors.append(str(exc)); continue
            check(25<=len(values.get('short_description',''))<=64,f'UI short description length: {name}')
            check('$'+name in values.get('default_prompt',''),f'Default prompt does not invoke skill: {name}')
    rows=catalog.get('recipes',[]);ids=[r['id'] for r in rows];paths=[r['path'] for r in rows]
    check(len(ids)==len(set(ids)),'Duplicate recipe IDs');check(len(paths)==len(set(paths)),'Duplicate recipe paths')
    for r in rows:
        p=inside(r['path']);check(p is not None and p.is_file(),f'Missing/escaping recipe: {r["id"]}')
        check(r['skill'] in skills,f'Unknown skill: {r["id"]}')
        check(r['mode'] in {'audit','implement','check','map','plan','document','closure','review','probe'},f'Invalid mode: {r["id"]}')
        if p and p.is_file():check(hashlib.sha256(p.read_bytes()).hexdigest()==r['sha256'],f'Stale recipe hash: {r["id"]}; run catalog.py refresh')
        if r['skill'] in skills:
            entry = skills[r['skill']]
            targets = [inside(unquote(link), entry.parent) for link in re.findall(r'\]\(([^\s)]+)\)', entry.read_text(encoding='utf-8')) if not urlsplit(link).scheme]
            check(p in targets, f'Recipe not reachable from skill: {r["id"]}')
    actual={p.relative_to(root).as_posix() for p in (root/'skills').glob('*/references/*.md')}
    check(actual==set(paths),'Unindexed or missing recipe files')
    for p in root.rglob('*'):
        check(not p.is_symlink(),f'Symlink in package: {p.relative_to(root)}')
        if not p.is_file() or p.suffix!='.md':continue
        text=p.read_text(encoding='utf-8');clean=re.sub(r'```.*?```','',text,flags=re.S)
        for link in re.findall(r'(?<!!)\[[^\]]*\]\(([^\s)]+)\)',clean):
            parts=urlsplit(link)
            if parts.scheme or not parts.path:continue
            target=inside(unquote(parts.path),p.parent)
            check(target is not None and target.exists(),f'Broken/escaping link: {p.relative_to(root)} -> {link}')
        if p.is_relative_to(root/'skills') or p.is_relative_to(root/'references'):
            check(not re.search(r'Codex Cloud|task-sub|Use task briefs creation|Plan mode only|\[TODO:',text,re.I),f'Legacy runtime instruction or unfinished scaffold: {p.relative_to(root)}')
    check(not list(root.rglob('AGENTS.md')),'Package must not install project-level AGENTS.md')
    html=root/'CATALOG.html';check(html.is_file(),'Missing HTML catalog')
    if html.is_file():
        m=re.search(r'<script type="application/json" id="recipe-data">(.*?)</script>',html.read_text(encoding='utf-8'),re.S)
        check(m is not None,'Missing embedded HTML data')
        if m:
            try:
                embedded=json.loads(m[1]);check([r['id'] for r in embedded]==ids,'HTML catalog out of sync')
                for r in embedded:
                    p=inside(r['path'])
                    if p and p.is_file():check(r['body']==p.read_text(encoding='utf-8'),f'Stale HTML recipe: {r["id"]}')
            except (ValueError,KeyError,TypeError):errors.append('Invalid embedded HTML data')
        try:
            expected=render_catalog(rows, root=root)
            check(html.read_text(encoding='utf-8')==expected,'Stale HTML interface or translations; run catalog.py refresh')
        except (OSError,ValueError,KeyError) as exc:
            errors.append(f'Invalid catalog localization: {exc}')
    try:
        portable=json.loads((root/'plugin.json').read_text(encoding='utf-8'))
        for key in ('name','version','description','author','homepage','repository','license','keywords'):
            check(portable.get(key)==manifest.get(key),f'Manifest mismatch: {key}')
        claude=json.loads((root/'.claude-plugin/plugin.json').read_text(encoding='utf-8'))
        check(isinstance(claude,dict),'Claude manifest must be an object')
        if isinstance(claude,dict):
            for key in ('name','version','description','author','homepage','repository','license','keywords'):
                check(claude.get(key)==manifest.get(key),f'Claude manifest mismatch: {key}')
            check(not any(k in claude for k in ('hooks','mcpServers','agents','commands')),'Unexpected Claude runtime integration')
        check(portable.get('$schema')=='https://agent-plugins.org/schemas/1.0.0/plugin.schema.json','Invalid portable schema')
        check(portable.get('extensions',{}).get('com.openai',{}).get('interface')==manifest.get('interface'),'Interface mismatch')
        for key in ('composerIcon','logo'):
            p=inside(manifest.get('interface',{}).get(key,''))
            check(p is not None and p.is_file(),f'Missing asset: {key}')
    except (OSError,ValueError) as exc: errors.append(str(exc))
    return errors


if __name__=='__main__':
    errors=validate()
    if errors:
        print('Validation failed:\n'+'\n'.join('- '+e for e in errors));sys.exit(1)
    data=json.loads((ROOT/'catalog.json').read_text(encoding='utf-8'))
    print(f'PASS: {data["skills"]} skills, {len(data["recipes"])} recipes; hashes, links and metadata verified.')

SHA-256: 346f8d11d6ed0a1f9b5e287eb32ccfe3b56d60e2315fc77a36dd31b63926062a