blob: 67fbb87d8983a83848c98108ab43fe57cc52f810 [file] [edit]
#!/usr/bin/env python3
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Validates the per-agent extension manifests under ai/extensions/ and
the source shape of the consolidated `perfetto` skill under ai/skills/.
These files are the source of truth for the `ai-agents` release branch
(see ai/extensions/README.md and RFC-0026). This lint catches malformed
JSON, missing required fields, and a skill source tree the bundler
(tools/release/build_ai_agents.py) could not assemble, before the release
pipeline builds the branch.
"""
import json
import os
import re
import sys
from typing import Any, Dict, List
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
EXTENSIONS_DIR = os.path.join(REPO_ROOT, 'ai', 'extensions')
SKILLS_DIR = os.path.join(REPO_ROOT, 'ai', 'skills')
SKILL_DIR = os.path.join(SKILLS_DIR, 'perfetto')
# The bundler keys off these exact source paths; keep this list in sync with
# build_ai_agents.py (SKILL_TEMPLATE / ENV_REF_DIR / SETUP_VARIANT).
SKILL_TEMPLATE = os.path.join(SKILL_DIR, 'SKILL-template.md')
SETUP_VARIANTS = [
os.path.join(SKILL_DIR, 'environment-references', 'setup-bundled.md'),
os.path.join(SKILL_DIR, 'environment-references', 'setup-standalone.md'),
]
# Before the first prebuilt roll the manifests carry the dev sentinel;
# roll-prebuilts stamps the release version (vX.Y) in place. Accept either.
VERSION_SENTINEL = '0.0.0-dev'
VERSION_RE = re.compile(r'^(0\.0\.0-dev|v[0-9]+\.[0-9]+)$')
# Per-agent required fields. Each entry maps a manifest filename to the
# list of top-level keys that must be present and non-empty. Sub-field
# shape is checked further in _check_specific().
REQUIRED = {
'claude-code/plugin.json': ['name', 'version', 'description'],
'claude-code/marketplace.json': [
'name', 'version', 'description', 'owner', 'plugins'
],
'codex/plugin.json': ['name', 'version', 'description'],
'codex/marketplace.json': ['name', 'plugins'],
}
def _check_specific(rel_path: str, data: Dict[str, Any]) -> List[str]:
"""Schema checks that don't reduce to a required-key list."""
errors = []
fname = os.path.basename(rel_path)
if 'version' in data and not VERSION_RE.match(str(data['version'])):
errors.append(
f'{rel_path}: version must be the {VERSION_SENTINEL!r} sentinel or a '
f'release version like "v54.0" (roll-prebuilts stamps it); '
f'got {data["version"]!r}')
if fname == 'marketplace.json':
plugins = data.get('plugins', [])
if not isinstance(plugins, list) or not plugins:
errors.append(f'{rel_path}: "plugins" must be a non-empty array')
for i, p in enumerate(plugins):
if not isinstance(p, dict):
errors.append(f'{rel_path}: plugins[{i}] must be an object')
continue
if 'name' not in p or not p['name']:
errors.append(f'{rel_path}: plugins[{i}] missing "name"')
if 'source' not in p:
errors.append(f'{rel_path}: plugins[{i}] missing "source"')
if rel_path == 'claude-code/marketplace.json':
owner = data.get('owner')
if not isinstance(owner, dict) or not owner.get('name'):
errors.append(f'{rel_path}: "owner" must be an object with a "name"')
return errors
def _check_file(rel_path: str) -> List[str]:
abs_path = os.path.join(EXTENSIONS_DIR, rel_path)
if not os.path.isfile(abs_path):
return [f'{rel_path}: required manifest is missing']
try:
with open(abs_path, 'r') as f:
data = json.load(f)
except json.JSONDecodeError as e:
return [f'{rel_path}: invalid JSON: {e}']
except OSError as e:
return [f'{rel_path}: cannot read: {e}']
errors = []
for key in REQUIRED[rel_path]:
if key not in data:
errors.append(f'{rel_path}: missing required field "{key}"')
elif data[key] in (None, '', [], {}):
errors.append(f'{rel_path}: field "{key}" must not be empty')
errors.extend(_check_specific(rel_path, data))
return errors
def _check_skill_source() -> List[str]:
"""Verify the `perfetto` skill source tree is in the shape the bundler
expects: a router template, both setup variants, and no loadable SKILL.md
in source control (the source tree must never be a drop-in skill).
"""
errors = []
if not os.path.isdir(SKILL_DIR):
return ['ai/skills/perfetto/: consolidated skill directory is missing']
if not os.path.isfile(SKILL_TEMPLATE):
errors.append('ai/skills/perfetto/SKILL-template.md: router template is '
'missing (the bundler renames it to SKILL.md)')
if os.path.isfile(os.path.join(SKILL_DIR, 'SKILL.md')):
errors.append('ai/skills/perfetto/SKILL.md: source tree must not contain a '
'loadable SKILL.md; the entry point is SKILL-template.md')
for variant in SETUP_VARIANTS:
if not os.path.isfile(variant):
rel = os.path.relpath(variant, REPO_ROOT)
errors.append(f'{rel}: setup variant is missing (the bundler selects one '
f'as environment-references/setup.md)')
return errors
def main() -> int:
if not os.path.isdir(EXTENSIONS_DIR):
print(f'ai/extensions/ not found at {EXTENSIONS_DIR}', file=sys.stderr)
return 1
errors = []
for rel_path in sorted(REQUIRED):
errors.extend(_check_file(rel_path))
errors.extend(_check_skill_source())
if errors:
print('Extension manifest errors:', file=sys.stderr)
for e in errors:
print(f' {e}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())