cc-os/plugins/cc-architect/scripts/plugin_config/operations/validate.rb

263 lines
10 KiB
Ruby
Raw Permalink Normal View History

# frozen_string_literal: true
require_relative 'base'
module PluginConfig
module Operations
# Structural validation for a plugin directory: plugin.json shape,
# marketplace registration/consistency, and skills/ layout.
class Validate < Base
def initialize(params)
super
@plugin_path = params[:plugin_path]
end
attr_reader :plugin_path
def call
return missing_plugin_path_result unless plugin_path && !plugin_path.to_s.strip.empty?
load_state
checks = {
plugin_json: check_plugin_json,
required_fields: check_required_fields,
name_matches_directory: check_name_matches_directory,
marketplace_entry: check_marketplace_entry,
description_matches: check_description_matches,
skill_md_present: check_skill_md_present,
skills_array_matches: check_skills_array_matches,
marketplace_source: check_marketplace_source
}
build_result(checks)
end
private
# ─── State loading ──────────────────────────────────────────────────────
def load_state
@plugin_json = load_plugin_json
@marketplace_data = read_json(marketplace_file_path)
@marketplace_entry = find_marketplace_entry
end
# True when no --marketplace_file was given and the default marketplace
# location can't be found. In this case marketplace-dependent checks are
# skipped rather than failed, so the validator works against plugin
# directories that aren't registered in any marketplace.json (e.g. when
# invoked from a different repo). If --marketplace_file was explicitly
# given, a missing/invalid file is still a hard failure.
def marketplace_explicitly_given?
!marketplace_file.to_s.strip.empty?
end
def skip_marketplace_checks?
!marketplace_explicitly_given? && !File.exist?(default_marketplace_file)
end
def plugin_json_path
File.join(plugin_path, '.claude-plugin', 'plugin.json')
end
def load_plugin_json
return nil unless File.exist?(plugin_json_path)
read_json(plugin_json_path)
end
def marketplace_file_path
marketplace_file || default_marketplace_file
end
def default_marketplace_file
File.join(File.dirname(File.expand_path(plugin_path)), '.claude-plugin', 'marketplace.json')
end
def find_marketplace_entry
return nil unless @plugin_json && @marketplace_data
(@marketplace_data['plugins'] || []).find { |p| p['name'] == @plugin_json['name'] }
end
def skills_root
File.join(plugin_path, 'skills')
end
def skill_directories
return [] unless Dir.exist?(skills_root)
Dir.children(skills_root).select { |entry| File.directory?(File.join(skills_root, entry)) }.sort
end
# ─── Checks ─────────────────────────────────────────────────────────────
def check_plugin_json
path = plugin_json_path
return fail_check("plugin.json not found at #{path}") unless File.exist?(path)
return fail_check("plugin.json at #{path} contains invalid JSON") unless @plugin_json
pass_check("plugin.json exists and is valid JSON at #{path}")
end
def check_required_fields
return fail_check('Cannot check required fields: plugin.json is missing or invalid') unless @plugin_json
missing = %w[name description].select { |key| blank?(@plugin_json[key]) }
return fail_check("Missing or empty required field(s): #{missing.join(', ')}") unless missing.empty?
pass_check('Required fields present: name, description')
end
def check_name_matches_directory
return fail_check('Cannot check name: plugin.json is missing or invalid') unless @plugin_json
expected = directory_basename
actual = @plugin_json['name']
return fail_check("plugin.json name #{actual.inspect} does not match directory basename #{expected.inspect}") unless actual == expected
pass_check("plugin.json name '#{actual}' matches directory basename '#{expected}'")
end
def check_marketplace_entry
if skip_marketplace_checks?
return skip_check(
"No --marketplace_file given and default location #{default_marketplace_file} not found; " \
'skipping marketplace registration check'
)
end
return fail_check('Cannot check marketplace entry: plugin.json is missing or invalid') unless @plugin_json
return fail_check("Marketplace file not found at #{marketplace_file_path}") unless File.exist?(marketplace_file_path)
return fail_check("Marketplace file at #{marketplace_file_path} contains invalid JSON") unless @marketplace_data
return fail_check("No marketplace entry found for plugin '#{@plugin_json['name']}' in #{marketplace_file_path}") unless @marketplace_entry
pass_check("Found marketplace entry for '#{@plugin_json['name']}' in #{marketplace_file_path}")
end
def check_description_matches
if skip_marketplace_checks?
return skip_check(
"No --marketplace_file given and default location #{default_marketplace_file} not found; " \
'skipping description consistency check'
)
end
unless @plugin_json && @marketplace_entry
return fail_check('Cannot check description: plugin.json or marketplace entry is unavailable')
end
plugin_description = @plugin_json['description']
marketplace_description = @marketplace_entry['description']
if plugin_description == marketplace_description
pass_check('plugin.json description matches marketplace.json description byte-for-byte')
else
fail_check(
"Description drift: plugin.json has #{plugin_description.inspect}, " \
"marketplace.json has #{marketplace_description.inspect}"
)
end
end
def check_skill_md_present
return pass_check('No skills/ directory present; nothing to check') unless Dir.exist?(skills_root)
missing = skill_directories.reject { |dir| File.exist?(File.join(skills_root, dir, 'SKILL.md')) }
return fail_check("Missing SKILL.md in skill directories: #{missing.join(', ')}") unless missing.empty?
pass_check('All skill directories contain SKILL.md')
end
def check_skills_array_matches
return fail_check('Cannot check skills array: plugin.json is missing or invalid') unless @plugin_json
return pass_check('plugin.json declares no skills array; nothing to check') unless @plugin_json.key?('skills')
declared = Array(@plugin_json['skills']).sort
actual = skill_directories
if declared == actual
pass_check('plugin.json skills array matches skills/ directories')
else
fail_check("plugin.json skills array #{declared.inspect} does not match actual directories #{actual.inspect}")
end
end
def check_marketplace_source
if skip_marketplace_checks?
return skip_check(
"No --marketplace_file given and default location #{default_marketplace_file} not found; " \
'skipping marketplace source check'
)
end
return fail_check('Cannot check marketplace source: marketplace entry is unavailable') unless @marketplace_entry
actual = @marketplace_entry['source']
return fail_check('Marketplace entry has no source field') if blank?(actual)
resolved = File.expand_path(actual, marketplace_root)
expected = File.expand_path(plugin_path)
if resolved == expected
pass_check("Marketplace source '#{actual}' resolves to plugin directory")
else
fail_check(
"Marketplace source #{actual.inspect} resolves to #{resolved.inspect}, " \
"which does not match plugin directory #{expected.inspect}"
)
end
end
# The directory a marketplace entry's `source` is resolved relative to.
# marketplace.json conventionally lives at <root>/.claude-plugin/marketplace.json,
# with sources relative to <root>. If the marketplace file isn't nested under a
# .claude-plugin/ directory (e.g. a flat fixture file), fall back to its own
# containing directory.
def marketplace_root
dir = File.dirname(File.expand_path(marketplace_file_path))
File.basename(dir) == '.claude-plugin' ? File.dirname(dir) : dir
end
# ─── Helpers ────────────────────────────────────────────────────────────
def directory_basename
File.basename(plugin_path.to_s.chomp('/'))
end
def blank?(value)
value.nil? || (value.is_a?(String) && value.strip.empty?)
end
def pass_check(message)
{ status: 'pass', message: message }
end
def fail_check(message)
{ status: 'fail', message: message }
end
def skip_check(message)
{ status: 'skipped', message: message }
end
def build_result(checks)
failed = checks.values.select { |check| check[:status] == 'fail' }
{
status: failed.empty? ? 'ok' : 'issues_found',
valid: failed.empty?,
plugin_path: plugin_path,
checks: checks,
issues: failed.map { |check| check[:message] }
}
end
def missing_plugin_path_result
{
status: 'issues_found',
valid: false,
plugin_path: plugin_path,
checks: {},
issues: ['No plugin_path given. Usage: validate --plugin_path=<path-to-plugin>']
}
end
end
end
end