validatePluginManifest function
Validate a plugin manifest file (plugin.json).
Implementation
Future<ValidationResult> validatePluginManifest(String filePath) async {
final errors = <ValidationError>[];
final warnings = <ValidationWarning>[];
final absolutePath = path.absolute(filePath);
// Read file
String content;
try {
content = await File(absolutePath).readAsString();
} on FileSystemException catch (e) {
final message = e.osError?.errorCode == 2
? 'File not found: $absolutePath'
: 'Failed to read file: ${e.message}';
return ValidationResult(
success: false,
errors: [ValidationError(path: 'file', message: message)],
warnings: [],
filePath: absolutePath,
fileType: PluginFileType.plugin,
);
}
// Parse JSON
Map<String, dynamic> parsed;
try {
final decoded = _jsonDecode(content);
if (decoded is! Map<String, dynamic>) {
return ValidationResult(
success: false,
errors: [
const ValidationError(
path: 'json',
message: 'Root must be a JSON object',
),
],
warnings: [],
filePath: absolutePath,
fileType: PluginFileType.plugin,
);
}
parsed = decoded;
} catch (e) {
return ValidationResult(
success: false,
errors: [
ValidationError(path: 'json', message: 'Invalid JSON syntax: $e'),
],
warnings: [],
filePath: absolutePath,
fileType: PluginFileType.plugin,
);
}
// Check path traversal before schema validation
_checkPathTraversalInManifest(parsed, errors);
// Strip marketplace-only fields and warn
final strayKeys = parsed.keys
.where(_marketplaceOnlyManifestFields.contains)
.toList();
for (final key in strayKeys) {
warnings.add(
ValidationWarning(
path: key,
message:
"Field '$key' belongs in the marketplace entry (marketplace.json), "
"not plugin.json. It's harmless here but unused -- Neomage "
"ignores it at load time.",
),
);
}
// Validate required fields
if (!parsed.containsKey('name') || parsed['name'] is! String) {
errors.add(
const ValidationError(
path: 'name',
message: 'Plugin name is required and must be a string',
),
);
} else {
final name = parsed['name'] as String;
if (name.isEmpty) {
errors.add(
const ValidationError(
path: 'name',
message: 'Plugin name cannot be empty',
),
);
}
if (name.contains(' ')) {
errors.add(
const ValidationError(
path: 'name',
message:
'Plugin name cannot contain spaces. Use kebab-case (e.g., "my-plugin")',
),
);
}
// Kebab-case warning
if (!RegExp(r'^[a-z0-9]+(-[a-z0-9]+)*$').hasMatch(name)) {
warnings.add(
ValidationWarning(
path: 'name',
message:
'Plugin name "$name" is not kebab-case. Neomage accepts '
'it, but the Neomage.ai marketplace sync requires kebab-case '
'(lowercase letters, digits, and hyphens only, e.g., "my-plugin").',
),
);
}
}
// Warn for missing optional fields
if (!parsed.containsKey('version') || parsed['version'] == null) {
warnings.add(
const ValidationWarning(
path: 'version',
message:
'No version specified. Consider adding a version following semver (e.g., "1.0.0")',
),
);
}
if (!parsed.containsKey('description') || parsed['description'] == null) {
warnings.add(
const ValidationWarning(
path: 'description',
message:
'No description provided. Adding a description helps users understand what your plugin does',
),
);
}
if (!parsed.containsKey('author') || parsed['author'] == null) {
warnings.add(
const ValidationWarning(
path: 'author',
message:
'No author information provided. Consider adding author details for plugin attribution',
),
);
}
return ValidationResult(
success: errors.isEmpty,
errors: errors,
warnings: warnings,
filePath: absolutePath,
fileType: PluginFileType.plugin,
);
}