.github/workflows/scripts/Test-ModuleManifestQuality.ps1

<#
.SYNOPSIS
    Validates PowerShell module manifest (.psd1) for quality and completeness.
 
.DESCRIPTION
    This script performs comprehensive validation of module manifests to ensure they meet
    quality standards required for publishing to package repositories (GitHub Packages, PSGallery).
     
    Validates:
    - Required fields (Author, Description, Version, GUID, RootModule)
    - Recommended fields (Tags, ProjectUri, LicenseUri)
    - Field content quality (non-empty, meaningful values)
    - GUID format validation
    - Version format validation
 
.PARAMETER ModulePath
    Path to the module directory containing the .psd1 file.
    If not specified, searches in the current directory.
 
.PARAMETER ManifestPath
    Direct path to the .psd1 file.
    Takes precedence over ModulePath if specified.
 
.PARAMETER FailOnWarnings
    If specified, treats warnings as errors and fails the validation.
 
.EXAMPLE
    Test-ModuleManifestQuality.ps1
    # Validates manifest in current directory
 
.EXAMPLE
    Test-ModuleManifestQuality.ps1 -ManifestPath "./MyModule.psd1"
    # Validates specific manifest file
 
.EXAMPLE
    Test-ModuleManifestQuality.ps1 -ModulePath "./src/MyModule" -FailOnWarnings
    # Validates manifest with strict mode
 
.OUTPUTS
    Sets GitHub Action outputs:
    - manifest-valid: 'true' or 'false'
    - error-count: Number of errors found
    - warning-count: Number of warnings found
 
.NOTES
    Exit codes:
    - 0: Validation passed
    - 1: Validation failed (errors found)
#>

[CmdletBinding()]
param(
    [Parameter()]
    [string]$ModulePath = '.',

    [Parameter()]
    [string]$ManifestPath,

    [Parameter()]
    [switch]$FailOnWarnings
)

# ═══════════════════════════════════════════════════════════════════════════
# 📋 Configuration
# ═══════════════════════════════════════════════════════════════════════════

$RequiredFields = @(
    @{ Name = 'RootModule';     Description = 'Main module file (.psm1)' }
    @{ Name = 'ModuleVersion';  Description = 'Semantic version number' }
    @{ Name = 'GUID';           Description = 'Unique module identifier' }
    @{ Name = 'Author';         Description = 'Module author name' }
    @{ Name = 'Description';    Description = 'Module description text' }
)

$RecommendedFields = @(
    @{ Name = 'CompanyName';          Description = 'Company/Organization name' }
    @{ Name = 'Copyright';            Description = 'Copyright statement' }
    @{ Name = 'PowerShellVersion';    Description = 'Minimum PowerShell version' }
    @{ Name = 'FunctionsToExport';    Description = 'Exported functions list' }
)

$RecommendedPSDataFields = @(
    @{ Name = 'Tags';         Description = 'Module tags for discovery' }
    @{ Name = 'ProjectUri';   Description = 'Project homepage URL' }
    @{ Name = 'LicenseUri';   Description = 'License file URL' }
)

# ═══════════════════════════════════════════════════════════════════════════
# 🔧 Helper Functions
# ═══════════════════════════════════════════════════════════════════════════

function Write-ValidationError {
    param([string]$Message, [string]$Field)
    $script:Errors += @{ Field = $Field; Message = $Message }
    Write-Output "❌ ERROR: $Message"
}

function Write-ValidationWarning {
    param([string]$Message, [string]$Field)
    $script:Warnings += @{ Field = $Field; Message = $Message }
    Write-Output "⚠️ WARNING: $Message"
}

function Write-ValidationSuccess {
    param([string]$Message)
    Write-Output "✅ $Message"
}

function Test-GuidFormat {
    param([string]$Value)
    try {
        [guid]::Parse($Value) | Out-Null
        return $true
    } catch {
        return $false
    }
}

function Test-VersionFormat {
    param([string]$Value)
    try {
        [version]::Parse($Value) | Out-Null
        return $true
    } catch {
        return $false
    }
}

function Test-MeaningfulValue {
    param([string]$Value, [string]$FieldName)
    
    if ([string]::IsNullOrWhiteSpace($Value)) {
        return $false
    }
    
    # Check for EXACT placeholder/dummy values only
    # These are strings that are EXACTLY these values, not containing them
    $exactPlaceholders = @(
        'Unknown', 'TODO', 'TBD', 'N/A', 'None', 'Test', 
        'Author', 'Company', 'Description', 'Your Name',
        'your-name', 'your-company', 'MyModule', 'Module1',
        'SampleModule', 'ExampleModule', 'TestModule'
    )
    
    # Only check for exact matches (case-insensitive)
    if ($Value -in $exactPlaceholders) {
        return $false
    }
    
    # Check for domain placeholders (these ARE checked as patterns)
    $domainPatterns = @(
        'example.com', 'example.org', 'your-domain', 
        'placeholder', 'changeme', 'yourname'
    )
    
    foreach ($pattern in $domainPatterns) {
        if ($Value -like "*$pattern*") {
            return $false
        }
    }
    
    # Check for dummy GUIDs
    if ($FieldName -eq 'GUID') {
        $dummyGuids = @(
            '00000000-0000-0000-0000-000000000000',
            'a1b2c3d4-e5f6-7890-abcd-ef1234567890'  # Common placeholder
        )
        if ($Value -in $dummyGuids) {
            return $false
        }
    }
    
    return $true
}

# ═══════════════════════════════════════════════════════════════════════════
# 🚀 Main Validation Logic
# ═══════════════════════════════════════════════════════════════════════════

$script:Errors = @()
$script:Warnings = @()

Write-Output ""
Write-Output "═══════════════════════════════════════════════════════════════════"
Write-Output "🔍 PowerShell Module Manifest Quality Gate"
Write-Output "═══════════════════════════════════════════════════════════════════"
Write-Output ""

# ─────────────────────────────────────────────────────────────────────────────
# 📁 Find Manifest File
# ─────────────────────────────────────────────────────────────────────────────

if ($ManifestPath) {
    $psd1Path = $ManifestPath
} else {
    $psd1Files = Get-ChildItem -Path $ModulePath -Filter "*.psd1" -File -Recurse -Depth 1 |
        Where-Object { $_.Name -notlike 'PSScriptAnalyzerSettings*' }
    
    if ($psd1Files.Count -eq 0) {
        Write-ValidationError -Message "No .psd1 manifest file found in '$ModulePath'" -Field 'Manifest'
        Write-Output ""
        Write-Output "manifest-valid=false" >> $env:GITHUB_OUTPUT
        Write-Output "error-count=1" >> $env:GITHUB_OUTPUT
        Write-Output "warning-count=0" >> $env:GITHUB_OUTPUT
        exit 1
    }
    
    if ($psd1Files.Count -gt 1) {
        Write-Output "📋 Found multiple .psd1 files, validating primary manifest..."
        # Prefer manifest matching directory name
        $dirName = (Get-Item $ModulePath).Name
        $psd1Path = $psd1Files | Where-Object { $_.BaseName -eq $dirName } | Select-Object -First 1
        if (-not $psd1Path) {
            $psd1Path = $psd1Files | Select-Object -First 1
        }
        $psd1Path = $psd1Path.FullName
    } else {
        $psd1Path = $psd1Files[0].FullName
    }
}

Write-Output "📄 Manifest: $psd1Path"
Write-Output ""

# ─────────────────────────────────────────────────────────────────────────────
# 📖 Load and Parse Manifest
# ─────────────────────────────────────────────────────────────────────────────

try {
    $manifest = Test-ModuleManifest -Path $psd1Path -ErrorAction Stop -WarningAction SilentlyContinue
    Write-ValidationSuccess "Manifest syntax is valid"
} catch {
    Write-ValidationError -Message "Manifest syntax error: $($_.Exception.Message)" -Field 'Syntax'
    Write-Output ""
    Write-Output "manifest-valid=false" >> $env:GITHUB_OUTPUT
    Write-Output "error-count=1" >> $env:GITHUB_OUTPUT
    Write-Output "warning-count=0" >> $env:GITHUB_OUTPUT
    exit 1
}

# Also load raw content for additional checks
$rawContent = Get-Content $psd1Path -Raw

Write-Output ""
Write-Output "─────────────────────────────────────────────────────────────────────"
Write-Output "📋 REQUIRED FIELDS"
Write-Output "─────────────────────────────────────────────────────────────────────"

# ─────────────────────────────────────────────────────────────────────────────
# ✅ Validate Required Fields
# ─────────────────────────────────────────────────────────────────────────────

foreach ($field in $RequiredFields) {
    # Map field names to actual PSModuleInfo properties
    # (Test-ModuleManifest uses 'Version' instead of 'ModuleVersion')
    $propertyName = switch ($field.Name) {
        'ModuleVersion' { 'Version' }
        default { $field.Name }
    }
    
    $value = $manifest.$propertyName
    
    # Convert to string for consistent handling (Version objects, etc.)
    $valueStr = if ($null -eq $value) { '' } else { $value.ToString() }
    
    if ([string]::IsNullOrWhiteSpace($valueStr)) {
        Write-ValidationError -Message "Missing required field: $($field.Name) ($($field.Description))" -Field $field.Name
    }
    elseif (-not (Test-MeaningfulValue -Value $valueStr -FieldName $field.Name)) {
        Write-ValidationError -Message "Invalid/placeholder value for $($field.Name): '$valueStr'" -Field $field.Name
    }
    else {
        # Additional format validation
        switch ($field.Name) {
            'GUID' {
                if (-not (Test-GuidFormat -Value $valueStr)) {
                    Write-ValidationError -Message "Invalid GUID format: '$valueStr'" -Field 'GUID'
                } else {
                    Write-ValidationSuccess "$($field.Name): $valueStr"
                }
            }
            'ModuleVersion' {
                if (-not (Test-VersionFormat -Value $valueStr)) {
                    Write-ValidationError -Message "Invalid version format: '$valueStr'" -Field 'ModuleVersion'
                } else {
                    Write-ValidationSuccess "$($field.Name): $valueStr"
                }
            }
            'Author' {
                if ($valueStr.Length -lt 2) {
                    Write-ValidationError -Message "Author name too short: '$valueStr'" -Field 'Author'
                } else {
                    Write-ValidationSuccess "$($field.Name): $valueStr"
                }
            }
            'Description' {
                if ($valueStr.Length -lt 20) {
                    Write-ValidationWarning -Message "Description is very short ($($valueStr.Length) chars): '$valueStr'" -Field 'Description'
                } else {
                    Write-ValidationSuccess "$($field.Name): $($valueStr.Substring(0, [Math]::Min(60, $valueStr.Length)))..."
                }
            }
            default {
                Write-ValidationSuccess "$($field.Name): $valueStr"
            }
        }
    }
}

Write-Output ""
Write-Output "─────────────────────────────────────────────────────────────────────"
Write-Output "📋 RECOMMENDED FIELDS"
Write-Output "─────────────────────────────────────────────────────────────────────"

# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ Validate Recommended Fields
# ─────────────────────────────────────────────────────────────────────────────

foreach ($field in $RecommendedFields) {
    # Map manifest property names (Test-ModuleManifest returns different property names)
    $propName = switch ($field.Name) {
        'FunctionsToExport' { 'ExportedFunctions' }
        default { $field.Name }
    }
    $value = $manifest.($propName)
    
    # Handle dictionary types (ExportedFunctions returns ReadOnlyDictionary)
    if ($value -is [System.Collections.IDictionary]) {
        $value = $value.Keys
    }
    
    if ([string]::IsNullOrWhiteSpace($value) -or ($value -is [array] -and $value.Count -eq 0) -or ($value -is [System.Collections.ICollection] -and $value.Count -eq 0)) {
        Write-ValidationWarning -Message "Missing recommended field: $($field.Name) ($($field.Description))" -Field $field.Name
    } else {
        if ($value -is [array]) {
            Write-ValidationSuccess "$($field.Name): $($value.Count) items"
        } else {
            Write-ValidationSuccess "$($field.Name): $value"
        }
    }
}

Write-Output ""
Write-Output "─────────────────────────────────────────────────────────────────────"
Write-Output "📋 PSDATA METADATA"
Write-Output "─────────────────────────────────────────────────────────────────────"

# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ Validate PSData Fields
# ─────────────────────────────────────────────────────────────────────────────

$psData = $manifest.PrivateData?.PSData

if (-not $psData) {
    Write-ValidationWarning -Message "Missing PrivateData.PSData section (required for gallery publishing)" -Field 'PSData'
} else {
    foreach ($field in $RecommendedPSDataFields) {
        $value = $psData.($field.Name)
        
        if ([string]::IsNullOrWhiteSpace($value) -or ($value -is [array] -and $value.Count -eq 0)) {
            Write-ValidationWarning -Message "Missing PSData.$($field.Name) ($($field.Description))" -Field "PSData.$($field.Name)"
        } else {
            if ($value -is [array]) {
                Write-ValidationSuccess "PSData.$($field.Name): $($value -join ', ')"
            } else {
                Write-ValidationSuccess "PSData.$($field.Name): $value"
            }
        }
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# 📊 Summary
# ─────────────────────────────────────────────────────────────────────────────

Write-Output ""
Write-Output "═══════════════════════════════════════════════════════════════════"
Write-Output "📊 VALIDATION SUMMARY"
Write-Output "═══════════════════════════════════════════════════════════════════"
Write-Output ""

$errorCount = $script:Errors.Count
$warningCount = $script:Warnings.Count

Write-Output "❌ Errors: $errorCount"
Write-Output "⚠️ Warnings: $warningCount"
Write-Output ""

# Output for GitHub Actions
if ($env:GITHUB_OUTPUT) {
    "error-count=$errorCount" >> $env:GITHUB_OUTPUT
    "warning-count=$warningCount" >> $env:GITHUB_OUTPUT
}

# Generate GitHub Step Summary
if ($env:GITHUB_STEP_SUMMARY) {
    $summaryBuilder = [System.Text.StringBuilder]::new()
    [void]$summaryBuilder.AppendLine("## 🔍 Module Manifest Quality Gate")
    [void]$summaryBuilder.AppendLine("")
    [void]$summaryBuilder.AppendLine("| Metric | Count |")
    [void]$summaryBuilder.AppendLine("|--------|-------|")
    [void]$summaryBuilder.AppendLine("| ❌ Errors | ``$errorCount`` |")
    [void]$summaryBuilder.AppendLine("| ⚠️ Warnings | ``$warningCount`` |")
    [void]$summaryBuilder.AppendLine("")
    
    if ($errorCount -gt 0) {
        [void]$summaryBuilder.AppendLine("### ❌ Errors")
        [void]$summaryBuilder.AppendLine("")
        foreach ($err in $script:Errors) {
            [void]$summaryBuilder.AppendLine("- **$($err.Field)**: $($err.Message)")
        }
        [void]$summaryBuilder.AppendLine("")
    }
    
    if ($warningCount -gt 0) {
        [void]$summaryBuilder.AppendLine("### ⚠️ Warnings")
        [void]$summaryBuilder.AppendLine("")
        foreach ($warn in $script:Warnings) {
            [void]$summaryBuilder.AppendLine("- **$($warn.Field)**: $($warn.Message)")
        }
        [void]$summaryBuilder.AppendLine("")
    }
    
    [void]$summaryBuilder.AppendLine("---")
    $summaryBuilder.ToString() >> $env:GITHUB_STEP_SUMMARY
}

# Determine exit status
$isValid = $errorCount -eq 0
if ($FailOnWarnings -and $warningCount -gt 0) {
    $isValid = $false
    Write-Output "⚠️ Treating warnings as errors (FailOnWarnings enabled)"
}

if ($env:GITHUB_OUTPUT) {
    "manifest-valid=$($isValid.ToString().ToLower())" >> $env:GITHUB_OUTPUT
}

if ($isValid) {
    Write-Output "✅ Module manifest passed quality gate!"
    exit 0
} else {
    Write-Output "❌ Module manifest FAILED quality gate!"
    Write-Output ""
    Write-Output "💡 Fix the errors above before publishing."
    Write-Output " Required fields must have valid, non-placeholder values."
    exit 1
}