validatePluginManifest function

Future<ValidationResult> validatePluginManifest(
  1. String filePath
)

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,
  );
}