aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
906 lines • 34.7 kB
JavaScript
/**
* Plugin Metadata Validator
*
* Validates plugin manifest YAML files for schema correctness, required fields,
* version compatibility, and file reference validation.
*
* @module src/plugin/metadata-validator
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import * as yaml from 'js-yaml';
import semver from 'semver';
import { validateSkillFrontmatter } from '../extensions/validation.js';
/**
* MetadataValidator validates plugin manifest files
*/
export class MetadataValidator {
options;
constructor(options = {}) {
this.options = {
checkFileReferences: true,
strict: false,
autoFix: false,
...options
};
}
/**
* Validate a manifest file from the filesystem
*
* @param manifestPath - Absolute path to manifest.md or BEHAVIOR.md file
* @returns Validation result with errors and warnings
*/
async validateFile(manifestPath) {
const result = {
valid: false,
errors: [],
warnings: []
};
try {
// Check file exists
const stats = await fs.stat(manifestPath);
if (!stats.isFile()) {
result.errors.push({
path: manifestPath,
message: 'Path is not a file',
severity: 'error'
});
return result;
}
// Read file content
const content = await fs.readFile(manifestPath, 'utf-8');
// Dispatch by filename — each artifact type has its own self-contained
// validator. Adding a new artifact type means adding a new method, not
// editing a growing if/else inside validateManifest.
const filename = path.basename(manifestPath);
const basePath = path.dirname(manifestPath);
const contentResult = filename === 'SKILL.md'
? await this.validateSkillManifest(content, basePath, filename)
: await this.validateManifest(content, basePath, filename);
// Merge results
result.errors = contentResult.errors;
result.warnings = contentResult.warnings;
result.manifest = contentResult.manifest;
result.valid = contentResult.valid;
return result;
}
catch (error) {
if (error.code === 'ENOENT') {
result.errors.push({
path: manifestPath,
message: 'File not found',
severity: 'error'
});
}
else {
result.errors.push({
path: manifestPath,
message: `Failed to read file: ${error.message}`,
severity: 'error'
});
}
return result;
}
}
/**
* Validate manifest content from string
*
* @param content - Manifest file content
* @param basePath - Optional base path for file reference validation
* @param filename - Optional filename to infer type (e.g., 'BEHAVIOR.md')
* @returns Validation result
*/
async validateManifest(content, basePath, filename) {
const result = {
valid: false,
errors: [],
warnings: []
};
// Parse YAML frontmatter
const manifest = this.parseFrontmatter(content, result);
if (!manifest) {
return result;
}
// Infer type from filename for BEHAVIOR.md files that omit `type`
const manifestRec = manifest;
const isBehaviorFile = filename === 'BEHAVIOR.md' ||
(basePath && !('type' in manifestRec) &&
('triggers' in manifestRec || 'hooks' in manifestRec));
if (isBehaviorFile && !('type' in manifestRec)) {
manifestRec.type = 'behavior';
}
// Schema validation — use behavior-specific validation for behavior files
const schemaResult = isBehaviorFile
? this.validateBehaviorSchema(manifest)
: this.validateSchema(manifest);
result.errors.push(...schemaResult.errors);
result.warnings.push(...schemaResult.warnings);
if (schemaResult.valid) {
result.manifest = manifest;
// Required fields validation
const requiredErrors = this.validateRequiredFields(manifest);
result.errors.push(...requiredErrors);
// Version validation
const versionErrors = this.validateVersion(manifest.version);
result.errors.push(...versionErrors);
// Dependency validation
if (manifest.dependencies) {
const depErrors = this.validateDependencies(manifest.dependencies);
result.errors.push(...depErrors);
}
// File reference validation (if basePath provided and option enabled)
if (basePath && this.options.checkFileReferences && manifest.files) {
const fileErrors = await this.validateFileReferences(manifest, basePath);
result.errors.push(...fileErrors);
}
// Metadata completeness warnings
const metadataWarnings = this.checkMetadataCompleteness(manifest);
result.warnings.push(...metadataWarnings);
// Memory topology validation (if declared)
const topologyWarnings = this.validateMemoryTopology(manifest);
result.warnings.push(...topologyWarnings);
}
// Determine if valid (no errors, or warnings only if not strict)
result.valid = result.errors.length === 0 &&
(!this.options.strict || result.warnings.length === 0);
return result;
}
/**
* Validate a SKILL.md frontmatter file.
*
* SKILL.md is a separate artifact type from manifest.md/BEHAVIOR.md and
* has its own schema ({@link SkillFrontmatterSchema} in
* `src/extensions/validation.ts`). It does NOT carry `version`, `type`,
* `dependencies`, or `files` fields, so we route it through a dedicated
* validator instead of branching inside {@link validateManifest}.
*
* Adding a new artifact type? Add a sibling method here, then dispatch
* to it from {@link validateFile} based on filename.
*
* @param content - Full SKILL.md file contents (frontmatter + body)
* @param _basePath - Reserved for future cross-file checks
* @param filename - File basename, used in error.path for diagnostics
* @returns Validation result
*/
async validateSkillManifest(content, _basePath, filename) {
const result = {
valid: false,
errors: [],
warnings: []
};
const manifest = this.parseFrontmatter(content, result);
if (!manifest) {
return result;
}
const skillResult = validateSkillFrontmatter(manifest);
if (skillResult.success) {
result.manifest = manifest;
}
else {
for (const issue of skillResult.errors.errors) {
result.errors.push({
path: filename,
field: issue.path.join('.'),
message: issue.message,
severity: 'error',
});
}
}
result.valid = result.errors.length === 0 &&
(!this.options.strict || result.warnings.length === 0);
return result;
}
/**
* Validate manifest schema structure
*
* @param manifest - Parsed manifest object
* @returns Validation result
*/
validateSchema(manifest) {
const result = {
valid: true,
errors: [],
warnings: []
};
if (!manifest || typeof manifest !== 'object') {
result.valid = false;
result.errors.push({
message: 'Manifest must be an object',
severity: 'error'
});
return result;
}
const obj = manifest;
// Check required top-level fields exist
const requiredFields = ['name', 'version', 'type', 'description'];
for (const field of requiredFields) {
if (!(field in obj)) {
result.valid = false;
result.errors.push({
field,
message: `Missing required field: ${field}`,
severity: 'error'
});
}
}
// Type validations
if ('name' in obj && typeof obj.name !== 'string') {
result.valid = false;
result.errors.push({
field: 'name',
message: 'Field "name" must be a string',
severity: 'error'
});
}
if ('version' in obj && typeof obj.version !== 'string') {
result.valid = false;
result.errors.push({
field: 'version',
message: 'Field "version" must be a string',
severity: 'error'
});
}
if ('type' in obj) {
const validTypes = ['agent', 'command', 'template', 'flow', 'behavior'];
if (typeof obj.type !== 'string' || !validTypes.includes(obj.type)) {
result.valid = false;
result.errors.push({
field: 'type',
message: `Field "type" must be one of: ${validTypes.join(', ')}`,
severity: 'error'
});
}
}
if ('description' in obj && typeof obj.description !== 'string') {
result.valid = false;
result.errors.push({
field: 'description',
message: 'Field "description" must be a string',
severity: 'error'
});
}
if ('files' in obj) {
if (!Array.isArray(obj.files)) {
result.valid = false;
result.errors.push({
field: 'files',
message: 'Field "files" must be an array',
severity: 'error'
});
}
else if (!obj.files.every(f => typeof f === 'string')) {
result.valid = false;
result.errors.push({
field: 'files',
message: 'All items in "files" must be strings',
severity: 'error'
});
}
}
if ('dependencies' in obj && obj.dependencies !== null) {
if (typeof obj.dependencies !== 'object' || Array.isArray(obj.dependencies)) {
result.valid = false;
result.errors.push({
field: 'dependencies',
message: 'Field "dependencies" must be an object',
severity: 'error'
});
}
}
if ('metadata' in obj && obj.metadata !== null) {
if (typeof obj.metadata !== 'object' || Array.isArray(obj.metadata)) {
result.valid = false;
result.errors.push({
field: 'metadata',
message: 'Field "metadata" must be an object',
severity: 'error'
});
}
}
return result;
}
/**
* Validate BEHAVIOR.md schema structure
*
* BEHAVIOR.md files use a different schema than manifest.md:
* - Required: name, version, description, platforms
* - Optional: triggers, inputs, hooks, scripts, manifest, scope, tone, routing, memory
*
* @param manifest - Parsed frontmatter object
* @returns Validation result
*/
validateBehaviorSchema(manifest) {
const result = {
valid: true,
errors: [],
warnings: []
};
if (!manifest || typeof manifest !== 'object') {
result.valid = false;
result.errors.push({
message: 'Behavior manifest must be an object',
severity: 'error'
});
return result;
}
const obj = manifest;
// Required fields for BEHAVIOR.md
const requiredFields = ['name', 'version', 'description', 'platforms'];
for (const field of requiredFields) {
if (!(field in obj)) {
result.valid = false;
result.errors.push({
field,
message: `Missing required field: ${field}`,
severity: 'error'
});
}
}
// Type validations
if ('name' in obj && typeof obj.name !== 'string') {
result.valid = false;
result.errors.push({
field: 'name',
message: 'Field "name" must be a string',
severity: 'error'
});
}
if ('version' in obj) {
const version = obj.version;
if (typeof version !== 'string' && typeof version !== 'number') {
result.valid = false;
result.errors.push({
field: 'version',
message: 'Field "version" must be a string or number',
severity: 'error'
});
}
}
if ('description' in obj && typeof obj.description !== 'string') {
result.valid = false;
result.errors.push({
field: 'description',
message: 'Field "description" must be a string',
severity: 'error'
});
}
if ('platforms' in obj && !Array.isArray(obj.platforms)) {
result.valid = false;
result.errors.push({
field: 'platforms',
message: 'Field "platforms" must be an array',
severity: 'error'
});
}
// Optional field type checks
if ('triggers' in obj && !Array.isArray(obj.triggers)) {
result.valid = false;
result.errors.push({
field: 'triggers',
message: 'Field "triggers" must be an array of strings',
severity: 'error'
});
}
if ('inputs' in obj && !Array.isArray(obj.inputs)) {
result.valid = false;
result.errors.push({
field: 'inputs',
message: 'Field "inputs" must be an array',
severity: 'error'
});
}
if ('hooks' in obj && (typeof obj.hooks !== 'object' || Array.isArray(obj.hooks))) {
result.valid = false;
result.errors.push({
field: 'hooks',
message: 'Field "hooks" must be an object mapping event names to action arrays',
severity: 'error'
});
}
if ('scripts' in obj && (typeof obj.scripts !== 'object' || Array.isArray(obj.scripts))) {
result.valid = false;
result.errors.push({
field: 'scripts',
message: 'Field "scripts" must be an object mapping names to paths',
severity: 'error'
});
}
if ('scope' in obj) {
const validScopes = ['daemon', 'interactive', 'both'];
if (typeof obj.scope !== 'string' || !validScopes.includes(obj.scope)) {
result.valid = false;
result.errors.push({
field: 'scope',
message: `Field "scope" must be one of: ${validScopes.join(', ')}`,
severity: 'error'
});
}
}
// Completeness warnings
if (!('triggers' in obj) && !('hooks' in obj)) {
result.warnings.push({
field: 'triggers/hooks',
message: 'Behavior has neither triggers nor hooks — it cannot be activated',
});
}
if ('hooks' in obj && !('scripts' in obj)) {
result.warnings.push({
field: 'scripts',
message: 'Behavior has hooks but no scripts — hooks will have no effect',
});
}
return result;
}
/**
* Validate required fields are present and non-empty
*
* @param manifest - Validated manifest object
* @returns Array of validation errors
*/
validateRequiredFields(manifest) {
const errors = [];
// Name validation
if (!manifest.name || manifest.name.trim() === '') {
errors.push({
field: 'name',
message: 'Field "name" cannot be empty',
severity: 'error'
});
}
// Description validation
if (!manifest.description || manifest.description.trim() === '') {
errors.push({
field: 'description',
message: 'Field "description" cannot be empty',
severity: 'error'
});
}
// Files validation (should have at least one file for agent/command types)
if (manifest.type === 'agent' || manifest.type === 'command') {
if (!manifest.files || manifest.files.length === 0) {
errors.push({
field: 'files',
message: `Field "files" is required for type "${manifest.type}"`,
severity: 'error'
});
}
}
// Behavior-specific validation (#609, #1025)
// Canonical shape per #1025: behavior fields nest under `metadata:`.
// The daemon loader (tools/daemon/behavior-loader.mjs) reads
// metadata.scope and metadata.triggers (plural).
if (manifest.type === 'behavior') {
const meta = manifest.metadata;
if (!meta || !('triggers' in meta)) {
errors.push({
field: 'metadata.triggers',
message: 'Behavior must declare a triggers array in metadata',
severity: 'error'
});
}
if (!meta?.scope) {
errors.push({
field: 'metadata.scope',
message: 'Behavior should declare scope (daemon, interactive, or both)',
severity: 'warning'
});
}
}
return errors;
}
/**
* Validate version string is valid semver
*
* @param version - Version string to validate
* @returns Array of validation errors
*/
validateVersion(version) {
const errors = [];
if (!version) {
errors.push({
field: 'version',
message: 'Version is required',
severity: 'error'
});
return errors;
}
// Check if valid semver
const parsed = semver.valid(version);
if (!parsed) {
errors.push({
field: 'version',
message: `Invalid semantic version: "${version}". Must follow semver format (e.g., 1.0.0)`,
severity: 'error'
});
}
return errors;
}
/**
* Validate file references exist on filesystem
*
* @param manifest - Validated manifest object
* @param basePath - Base directory path for resolving relative file paths
* @returns Array of validation errors
*/
async validateFileReferences(manifest, basePath) {
const errors = [];
if (!manifest.files || manifest.files.length === 0) {
return errors;
}
for (const file of manifest.files) {
const filePath = path.resolve(basePath, file);
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
errors.push({
field: 'files',
path: file,
message: `Referenced path is not a file: ${file}`,
severity: 'error'
});
}
}
catch (error) {
if (error.code === 'ENOENT') {
errors.push({
field: 'files',
path: file,
message: `Referenced file does not exist: ${file}`,
severity: 'error'
});
}
else {
errors.push({
field: 'files',
path: file,
message: `Failed to access file "${file}": ${error.message}`,
severity: 'error'
});
}
}
}
return errors;
}
/**
* Validate dependency version ranges
*
* @param dependencies - Dependency map with version ranges
* @returns Array of validation errors
*/
validateDependencies(dependencies) {
const errors = [];
for (const [name, versionRange] of Object.entries(dependencies)) {
if (!name || name.trim() === '') {
errors.push({
field: 'dependencies',
message: 'Dependency name cannot be empty',
severity: 'error'
});
continue;
}
if (!versionRange || versionRange.trim() === '') {
errors.push({
field: 'dependencies',
path: name,
message: `Dependency "${name}" has empty version range`,
severity: 'error'
});
continue;
}
// Validate semver range
const validRange = semver.validRange(versionRange);
if (!validRange) {
errors.push({
field: 'dependencies',
path: name,
message: `Dependency "${name}" has invalid version range: "${versionRange}"`,
severity: 'error'
});
}
}
return errors;
}
/**
* Check metadata completeness and provide warnings
*
* @param manifest - Validated manifest object
* @returns Array of warnings
*/
checkMetadataCompleteness(manifest) {
const warnings = [];
// Agent-specific warnings
if (manifest.type === 'agent') {
if (!manifest.model) {
warnings.push({
field: 'model',
message: 'Agent should specify a "model" field (e.g., "sonnet", "gpt-4")'
});
}
if (!manifest.tools) {
warnings.push({
field: 'tools',
message: 'Agent should specify a "tools" field listing available tools'
});
}
}
// Framework recommendation
if (!manifest.framework) {
warnings.push({
field: 'framework',
message: 'Consider specifying a "framework" field for better organization'
});
}
// Empty metadata object
if (manifest.metadata && Object.keys(manifest.metadata).length === 0) {
warnings.push({
field: 'metadata',
message: 'Metadata object is empty, consider removing or populating it'
});
}
return warnings;
}
/**
* Validate memory.topology contract if declared
*
* Checks that declared paths follow .aiwg/ convention and required fields
* are present when topology is declared.
*
* @see ADR-021 — Semantic memory kernel architecture
*/
validateMemoryTopology(manifest) {
const warnings = [];
const memory = manifest.memory;
if (!memory)
return warnings;
const topology = memory.topology;
if (!topology)
return warnings;
const VALID_CROSS_REF_STYLES = ['at-mention', 'wikilink', 'markdown-link', 'yaml-ref'];
// Required topology fields
const requiredFields = ['namespace', 'rawSources', 'derivedPages', 'index', 'log', 'crossRefStyle'];
for (const field of requiredFields) {
if (!(field in topology)) {
warnings.push({
field: `memory.topology.${field}`,
message: `Missing required topology field: ${field}`,
});
}
}
// Namespace must start with .aiwg/
if (typeof topology.namespace === 'string' && !topology.namespace.startsWith('.aiwg/')) {
warnings.push({
field: 'memory.topology.namespace',
message: `Topology namespace should start with ".aiwg/" (got "${topology.namespace}")`,
});
}
// crossRefStyle must be a valid enum value
if (typeof topology.crossRefStyle === 'string' && !VALID_CROSS_REF_STYLES.includes(topology.crossRefStyle)) {
warnings.push({
field: 'memory.topology.crossRefStyle',
message: `Invalid crossRefStyle "${topology.crossRefStyle}". Valid: ${VALID_CROSS_REF_STYLES.join(', ')}`,
});
}
// derivedPages must be a non-empty object
if (topology.derivedPages) {
if (typeof topology.derivedPages !== 'object' || Array.isArray(topology.derivedPages)) {
warnings.push({
field: 'memory.topology.derivedPages',
message: 'derivedPages must be an object mapping category names to directory paths',
});
}
else if (Object.keys(topology.derivedPages).length === 0) {
warnings.push({
field: 'memory.topology.derivedPages',
message: 'derivedPages is empty — at least one page category should be declared',
});
}
}
// lintRules should be an array of strings if present
if ('lintRules' in topology && topology.lintRules !== undefined) {
if (!Array.isArray(topology.lintRules)) {
warnings.push({
field: 'memory.topology.lintRules',
message: 'lintRules must be an array of rule ID strings',
});
}
}
// ingestRequires should be an array of strings if present
if ('ingestRequires' in topology && topology.ingestRequires !== undefined) {
if (!Array.isArray(topology.ingestRequires)) {
warnings.push({
field: 'memory.topology.ingestRequires',
message: 'ingestRequires must be an array of capability strings',
});
}
}
return warnings;
}
/**
* Validate all manifests in a directory
*
* @param dirPath - Directory path to scan
* @param recursive - Whether to scan subdirectories recursively
* @returns Map of file paths to validation results
*/
async validateDirectory(dirPath, recursive = false) {
const results = new Map();
try {
const manifestFiles = await this.findManifestFiles(dirPath, recursive);
for (const manifestPath of manifestFiles) {
const result = await this.validateFile(manifestPath);
results.set(manifestPath, result);
}
return results;
}
catch (error) {
// Return empty results with error for the directory itself
const errorResult = {
valid: false,
errors: [{
path: dirPath,
message: `Failed to scan directory: ${error.message}`,
severity: 'error'
}],
warnings: []
};
results.set(dirPath, errorResult);
return results;
}
}
/**
* Generate validation report in specified format
*
* @param results - Batch validation results
* @param format - Report format (text or json)
* @returns Formatted report string
*/
generateReport(results, format = 'text') {
if (format === 'json') {
return this.generateJsonReport(results);
}
return this.generateTextReport(results);
}
/**
* Parse YAML frontmatter from manifest content
*
* @param content - File content
* @param result - Validation result to populate with errors
* @returns Parsed manifest or null if parsing failed
*/
parseFrontmatter(content, result) {
try {
// Extract YAML frontmatter between --- delimiters
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
result.errors.push({
message: 'No YAML frontmatter found. Manifest must start with --- delimiter',
severity: 'error',
line: 1
});
return null;
}
const yamlContent = frontmatterMatch[1];
const parsed = yaml.load(yamlContent);
return parsed;
}
catch (error) {
result.errors.push({
message: `Failed to parse YAML: ${error.message}`,
severity: 'error',
line: error.mark?.line
});
return null;
}
}
/**
* Find all manifest.md, BEHAVIOR.md, and SKILL.md files in a directory.
*
* Each filename routes to a different schema in {@link validateManifest}:
* - manifest.md — plugin manifest schema (requires version, type)
* - BEHAVIOR.md — behavior schema (triggers/hooks)
* - SKILL.md — skill frontmatter schema (name, description, …)
*
* @param dirPath - Directory to search
* @param recursive - Whether to search subdirectories
* @returns Array of absolute artifact file paths
*/
async findManifestFiles(dirPath, recursive) {
const manifestFiles = [];
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory() && recursive) {
const subManifests = await this.findManifestFiles(fullPath, recursive);
manifestFiles.push(...subManifests);
}
else if (entry.isFile() && (entry.name === 'manifest.md' ||
entry.name === 'BEHAVIOR.md' ||
entry.name === 'SKILL.md')) {
manifestFiles.push(fullPath);
}
}
return manifestFiles;
}
/**
* Generate text format report
*
* @param results - Batch validation results
* @returns Formatted text report
*/
generateTextReport(results) {
const lines = [];
// Header
lines.push('Plugin Metadata Validation Report');
lines.push('='.repeat(50));
lines.push('');
// Summary statistics
const total = results.size;
const passed = Array.from(results.values()).filter(r => r.valid).length;
const failed = total - passed;
const totalErrors = Array.from(results.values()).reduce((sum, r) => sum + r.errors.length, 0);
const totalWarnings = Array.from(results.values()).reduce((sum, r) => sum + r.warnings.length, 0);
lines.push('Summary:');
lines.push(` Total Manifests: ${total}`);
lines.push(` Passed: ${passed}`);
lines.push(` Failed: ${failed}`);
lines.push(` Total Errors: ${totalErrors}`);
lines.push(` Total Warnings: ${totalWarnings}`);
lines.push('');
// Individual results
lines.push('Results:');
lines.push('-'.repeat(50));
for (const [filePath, result] of results) {
const status = result.valid ? '✓ PASS' : '✗ FAIL';
lines.push(`\n${status} ${filePath}`);
if (result.manifest) {
lines.push(` Name: ${result.manifest.name}`);
lines.push(` Version: ${result.manifest.version}`);
lines.push(` Type: ${result.manifest.type}`);
}
if (result.errors.length > 0) {
lines.push(' Errors:');
for (const error of result.errors) {
const location = error.field ? `[${error.field}]` : '';
const line = error.line ? `:${error.line}` : '';
lines.push(` ✗ ${location}${line} ${error.message}`);
}
}
if (result.warnings.length > 0) {
lines.push(' Warnings:');
for (const warning of result.warnings) {
const location = warning.field ? `[${warning.field}]` : '';
lines.push(` ⚠ ${location} ${warning.message}`);
}
}
}
lines.push('');
lines.push('='.repeat(50));
return lines.join('\n');
}
/**
* Generate JSON format report
*
* @param results - Batch validation results
* @returns JSON string
*/
generateJsonReport(results) {
const data = {
summary: {
total: results.size,
passed: Array.from(results.values()).filter(r => r.valid).length,
failed: Array.from(results.values()).filter(r => !r.valid).length,
totalErrors: Array.from(results.values()).reduce((sum, r) => sum + r.errors.length, 0),
totalWarnings: Array.from(results.values()).reduce((sum, r) => sum + r.warnings.length, 0)
},
results: Array.from(results.entries()).map(([path, result]) => ({
path,
valid: result.valid,
manifest: result.manifest,
errors: result.errors,
warnings: result.warnings
}))
};
return JSON.stringify(data, null, 2);
}
}
//# sourceMappingURL=metadata-validator.js.map