mirror of
https://github.com/paperclipai/agent-plugins.git
synced 2026-11-09 12:11:27 -10:00
135 lines
7.4 KiB
Python
135 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate discovery, references, host independence, and exact upstream preservation."""
|
|
import argparse
|
|
import difflib
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import yaml
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BASE = '89b26b3'
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def frontmatter(path):
|
|
text = path.read_text()
|
|
match = re.match(r'^---\n(.*?)\n---(?:\n|$)', text, re.S)
|
|
if not match:
|
|
raise ValueError(f'{path}: missing frontmatter')
|
|
return yaml.safe_load(match[1])
|
|
|
|
|
|
def source_report():
|
|
manifest = json.loads((ROOT / 'metadata/upstream.json').read_text())
|
|
rows = []
|
|
for source in manifest['files']:
|
|
rel = source['path']
|
|
original = subprocess.check_output(['git', 'show', f'{BASE}:{rel}'], cwd=ROOT)
|
|
if digest(original) != source['sha256']:
|
|
raise ValueError(f'Import differs from recorded upstream bytes: {rel}')
|
|
dest = ROOT / rel
|
|
current = dest.read_bytes() if dest.exists() else None
|
|
row = {'path': rel, 'source': source['source'], 'upstream_path': source['upstream_path'],
|
|
'status': 'unchanged' if current == original else 'removed' if current is None else 'adapted',
|
|
'upstream_sha256': digest(original), 'sha256': digest(current) if current is not None else None}
|
|
try:
|
|
lines = original.decode('utf-8').splitlines(keepends=True)
|
|
after = current.decode('utf-8').splitlines(keepends=True) if current else []
|
|
equal = sum(m.size for m in difflib.SequenceMatcher(None, lines, after, autojunk=False).get_matching_blocks())
|
|
row.update(upstream_lines=len(lines), unchanged_lines=equal)
|
|
except UnicodeDecodeError:
|
|
pass
|
|
if row['status'] != 'unchanged':
|
|
if rel == '.cursor-plugin/plugin.json': reason = 'Replace host plugin packaging with runtime discovery installer'
|
|
elif rel in ('skills/automate/SKILL.md', 'skills/loop/SKILL.md'): reason = 'Replace host-specific scheduling, editor, and automation APIs; retain portable workflow text'
|
|
elif 'make-bot-ui' in rel: reason = 'Replace host-specific webhook API, UI, credentials, and event schema'
|
|
elif rel.endswith('worktree-audit.sh'): reason = 'Accept scoped transcript exports; missing transcript access must not certify deletion safety'
|
|
elif rel.endswith(('package.json', 'bun.lock')): reason = 'Rename package metadata namespace'
|
|
elif rel == 'README.md': reason = 'Repository identity/attribution, portable installation, models, modes, and dependency documentation'
|
|
else: reason = 'Translate host paths, tools, models, discovery metadata, isolation, scheduling, or relocated dependency references'
|
|
row['reason'] = reason
|
|
rows.append(row)
|
|
totals = {key: sum(r.get(key, 0) for r in rows) for key in ['upstream_lines', 'unchanged_lines']}
|
|
totals.update(imported_files=len(rows), byte_identical_files=sum(r['status'] == 'unchanged' for r in rows))
|
|
pstack = [r for r in rows if r['upstream_path'].startswith('pstack/')]
|
|
totals['pstack_upstream_lines'] = sum(r.get('upstream_lines', 0) for r in pstack)
|
|
totals['pstack_unchanged_lines'] = sum(r.get('unchanged_lines', 0) for r in pstack)
|
|
return {'baseline_commit': BASE, 'summary': totals, 'files': rows}
|
|
|
|
|
|
def check(write_report=False):
|
|
errors = []
|
|
skills = list((ROOT / 'skills').glob('*/SKILL.md')) + list((ROOT / 'automations').rglob('SKILL.md'))
|
|
names = set()
|
|
for path in skills:
|
|
meta = frontmatter(path)
|
|
unknown = set(meta) - {'name', 'description', 'license', 'allowed-tools', 'metadata', 'disable-model-invocation'}
|
|
if unknown:
|
|
errors.append(f'{path.relative_to(ROOT)}: unsupported frontmatter {sorted(unknown)}')
|
|
name = meta.get('name', '')
|
|
if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', name) or len(name) > 64:
|
|
errors.append(f'{path.relative_to(ROOT)}: invalid discovery name {name!r}')
|
|
if name in names:
|
|
errors.append(f'Duplicate skill name: {name}')
|
|
names.add(name)
|
|
if not isinstance(meta.get('description'), str) or not meta['description'].strip():
|
|
errors.append(f'{path.relative_to(ROOT)}: missing description')
|
|
if meta.get('disable-model-invocation') is True and path.parent.parent == ROOT / 'skills':
|
|
policy = path.parent / 'agents/openai.yaml'
|
|
if not policy.exists() or yaml.safe_load(policy.read_text()).get('policy', {}).get('allow_implicit_invocation') is not False:
|
|
errors.append(f'{path.relative_to(ROOT)}: missing Codex explicit invocation policy')
|
|
# Match runtime dependencies, not ordinary words such as an editor cursor or GraphQL endCursor.
|
|
prohibited = re.compile(r'\.cursor(?:/|\b)|cursor\.com|api2\.cursor|cursor-subscriptions|cursor-app-control|cursor-team-kit|/add-plugin|cloud_base_branch|run_in_background|\bTask tool\b|`Task`|\bAskQuestion\b|alwaysApply|grok-4\.7|claude-opus-5-5|notify_on_output|SendToUser')
|
|
for root in ['skills', 'agents', 'automations', 'docs']:
|
|
for path in (ROOT / root).rglob('*.md'):
|
|
if 'node_modules' in path.parts:
|
|
continue
|
|
content = path.read_text()
|
|
if prohibited.search(content):
|
|
errors.append(f'{path.relative_to(ROOT)}: host-specific instruction {prohibited.search(content)[0]!r}')
|
|
# Markdown examples may deliberately contain placeholder references. Validate actual prose links.
|
|
fence = False
|
|
for number, line in enumerate(content.splitlines(), 1):
|
|
if line.startswith('```'):
|
|
fence = not fence
|
|
continue
|
|
if fence:
|
|
continue
|
|
for target in re.findall(r'\[[^\]]*\]\(([^\s)]+)(?:\s+[^)]*)?\)', re.sub(r'`[^`]*`', '', line)):
|
|
if ':' in target or target.startswith('#') or '<' in target or target == 'url':
|
|
continue
|
|
location = (path.parent / target.split('#')[0]).resolve()
|
|
if not location.exists():
|
|
errors.append(f'{path.relative_to(ROOT)}:{number}: missing link {target}')
|
|
report = source_report()
|
|
for row in report['files']:
|
|
rel = row['path']
|
|
if rel.startswith('skills/principle-') and row['status'] != 'unchanged':
|
|
errors.append(f'Pure principle changed: {rel}')
|
|
if any(rel.startswith(f'skills/{n}/') for n in ['deslop','control-cli','control-ui','verify-this']) and row['status'] != 'unchanged':
|
|
errors.append(f'Portable dependency changed: {rel}')
|
|
out = ROOT / 'metadata/portability.json'
|
|
if write_report:
|
|
out.write_text(json.dumps(report, indent=2) + '\n')
|
|
elif not out.exists() or json.loads(out.read_text()) != report:
|
|
errors.append('Portability report stale: run python3 scripts/validate.py --write-report after reviewing the diff')
|
|
if errors:
|
|
print('\n'.join(errors), file=sys.stderr)
|
|
return 1
|
|
print(f'PASS: {len(skills)} skills; discovery, local links, host independence, and upstream hashes verified')
|
|
print(json.dumps(report['summary'], indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--write-report', action='store_true')
|
|
sys.exit(check(p.parse_args().write_report))
|