resources/PesterTests/QualityAssurance/ProjectCompliance.Tests.ps1

BeforeDiscovery {
    $data = Get-MAProjectInfo
    $changeLogPath = Join-Path -Path $data.ProjectRoot -ChildPath 'CHANGELOG.md'

    $script:versionBlocks = @()

    if (Test-Path -Path $changeLogPath) {
        $lines = Get-Content -Path $changeLogPath
        $current = $null
        $blocks = [System.Collections.Generic.List[hashtable]]::new()

        foreach ($line in $lines) {
            if ($line -match '^## \[') {
                if ($null -ne $current) {
                    $blocks.Add(@{ Header = $current.Header; Lines = $current.Lines.ToArray() })
                }
                $current = @{
                    Header = $line
                    Lines  = [System.Collections.Generic.List[string]]::new()
                }
                $null = $current.Lines.Add($line)
            } elseif ($null -ne $current) {
                $null = $current.Lines.Add($line)
            }
        }

        if ($null -ne $current) {
            $blocks.Add(@{ Header = $current.Header; Lines = $current.Lines.ToArray() })
        }

        $script:versionBlocks = $blocks.ToArray()
    }
}

BeforeAll {
    $script:data = Get-MAProjectInfo
    $script:changeLogPath = Join-Path -Path $script:data.ProjectRoot -ChildPath 'CHANGELOG.md'
    $script:changeLogExists = Test-Path -Path $script:changeLogPath

    if ($script:changeLogExists) {
        $script:content = Get-Content -Path $script:changeLogPath -Raw
        $script:lines = Get-Content -Path $script:changeLogPath
    }
}

Describe 'CHANGELOG.md' -Tag 'ChangeLog' {

    Context 'File' {

        It 'exists at the project root' {
            $script:changeLogExists | Should-BeTrue
        }
    }

    Context 'Document Structure' {

        It 'has "# Changelog" as the first H1 heading' {
            $firstH1 = $script:lines | Where-Object { $_ -match '^# \S' } | Select-Object -First 1
            $firstH1 | Should-Be '# Changelog'
        }

        It 'states that all notable changes are documented' {
            $script:content | Should-MatchString '(?i)notable\s+changes'
        }

        It 'references the Keep a Changelog specification' {
            $script:content | Should-MatchString 'https://keepachangelog\.com/en/1\.1\.0/'
        }

        It 'states adherence to Semantic Versioning' {
            $script:content | Should-MatchString 'https://semver\.org'
        }

        It 'contains at least one version entry' {
            @($script:lines | Where-Object { $_ -match '^## \[' }).Count | Should-BeGreaterThan 0
        }

        It 'places [Unreleased] as the first version entry when present' {
            $versionLines = @($script:lines | Where-Object { $_ -match '^## \[' })
            $hasUnreleased = $versionLines | Where-Object { $_ -match '(?i)unreleased' }
            if ($hasUnreleased) {
                $versionLines[0] | Should-MatchString '^## \[Unreleased\]'
            }
        }
    }

    Context 'Version Entry: <_.Header>' -ForEach $versionBlocks {

        BeforeAll {
            $script:header = $_.Header
            $script:blockLines = $_.Lines
            $script:isUnreleased = $script:header -match '^## \[Unreleased\]$'
        }

        It 'has a valid Keep a Changelog header format' {
            if ($script:isUnreleased) {
                $script:header | Should-MatchString '^## \[Unreleased\]$'
            } else {
                # ## [MAJOR.MINOR.PATCH] - YYYY-MM-DD or ## [x.y.z] - YYYY-MM-DD [YANKED]
                $script:header | Should-MatchString (
                    '^## \[[0-9]+\.[0-9]+\.[0-9][^\]]*\] - ' +
                    '\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])' +
                    '(?:\s+\[YANKED\])?$'
                )
            }
        }

        It 'has a parseable ISO 8601 release date' {
            if (-not $script:isUnreleased) {
                $dateMatch = [regex]::Match($script:header, '(\d{4}-\d{2}-\d{2})')
                $dateMatch.Success | Should-BeTrue -Because 'every released version requires a date'
                $caughtError = $null
                try {
                    [datetime]::ParseExact(
                        $dateMatch.Groups[1].Value,
                        'yyyy-MM-dd',
                        [System.Globalization.CultureInfo]::InvariantCulture
                    ) | Out-Null
                } catch {
                    $caughtError = $_
                }
                $caughtError | Should-BeNull -Because "$($dateMatch.Groups[1].Value) must be a valid calendar date"
            }
        }

        It 'contains at least one change-type section (### Added, Changed, etc.)' {
            if (-not $script:isUnreleased) {
                $sectionLines = @($script:blockLines | Where-Object { $_ -match '^### ' })
                $sectionLines.Count | Should-BeGreaterThan 0 `
                    -Because 'released versions must group changes under at least one ### <type> heading per Keep a Changelog 1.1.0'
            }
        }

        It 'groups all changes under a change-type heading (### <type>) - no ungrouped entries allowed' {
            # Every non-blank content line must appear after a ### heading, never directly under ## [version].
            $count = $script:blockLines.Count
            $firstSection = $null
            for ($i = 1; $i -lt $count; $i++) {
                if ($script:blockLines[$i] -match '^### ') {
                    $firstSection = $i
                    break
                }
            }
            # Lines between the ## header and the first ### (or end of block) must all be blank.
            $limit = if ($null -ne $firstSection) {
                $firstSection
            } else {
                $count
            }
            for ($i = 1; $i -lt $limit; $i++) {
                [string]::IsNullOrWhiteSpace($script:blockLines[$i]) |
                    Should-BeTrue `
                        -Because "'$($script:blockLines[$i])' must be grouped under a '### <type>' heading per Keep a Changelog 1.1.0"
            }
        }

        It 'uses only valid Keep a Changelog change-type headings (###)' {
            $validTypes = @('Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security')
            $sectionLines = @($script:blockLines | Where-Object { $_ -match '^### ' })
            foreach ($sectionLine in $sectionLines) {
                Should-ContainCollection -Actual $validTypes -Expected (($sectionLine -replace '^### ').Trim()) -Because "'$sectionLine' is not a valid Keep a Changelog change type"
            }
        }

        It 'has no empty change-type sections' {
            $count = $script:blockLines.Count
            for ($i = 0; $i -lt $count; $i++) {
                if ($script:blockLines[$i] -match '^### ') {
                    $next = $i + 1
                    while ($next -lt $count -and [string]::IsNullOrWhiteSpace($script:blockLines[$next])) {
                        $next++
                    }
                    ($next -ge $count -or $script:blockLines[$next] -match '^#') |
                        Should-BeFalse -Because "'$($script:blockLines[$i])' must not be an empty section"
                }
            }
        }

        It 'uses markdown list items for change entries' {
            $count = $script:blockLines.Count
            for ($i = 0; $i -lt $count; $i++) {
                if ($script:blockLines[$i] -match '^### ') {
                    $next = $i + 1
                    while ($next -lt $count -and [string]::IsNullOrWhiteSpace($script:blockLines[$next])) {
                        $next++
                    }
                    if ($next -lt $count -and $script:blockLines[$next] -notmatch '^#') {
                        $script:blockLines[$next] | Should-MatchString '^[-*] ' `
                            -Because "entries under '$($script:blockLines[$i])' must be markdown list items"
                    }
                }
            }
        }
    }

    Context 'Version Ordering and Uniqueness' {

        It 'lists released versions in reverse chronological order (newest first)' {
            $datePattern = '^## \[[^\]]+\] - (\d{4}-\d{2}-\d{2})'
            $dates = @(
                $script:lines |
                    Where-Object { $_ -match $datePattern } |
                    ForEach-Object {
                        [datetime]::ParseExact(
                            [regex]::Match($_, $datePattern).Groups[1].Value,
                            'yyyy-MM-dd',
                            [System.Globalization.CultureInfo]::InvariantCulture
                        )
                    }
            )
            if ($dates.Count -gt 1) {
                Should-BeCollection -Actual $dates -Expected ($dates | Sort-Object -Descending) `
                    -Because 'versions must be listed newest-first per the Keep a Changelog spec'
            }
        }

        It 'contains no duplicate version entries' {
            $versionPattern = '^## \[([^\]]+)\]'
            $versions = @(
                $script:lines |
                    Where-Object { $_ -match $versionPattern } |
                    ForEach-Object { [regex]::Match($_, $versionPattern).Groups[1].Value }
            )
            $versions.Count | Should-Be ($versions | Select-Object -Unique).Count `
                -Because 'each version label must appear exactly once'
        }
    }

    Context 'Version Reference Links' {

        It 'provides a reference link for every version entry when any links are defined' {
            $linkPattern = '^\[([^\]]+)\]:\s*https?://'
            $definedLinks = @(
                $script:lines |
                    Where-Object { $_ -match $linkPattern } |
                    ForEach-Object { [regex]::Match($_, $linkPattern).Groups[1].Value }
            )

            if ($definedLinks.Count -gt 0) {
                $versionPattern = '^## \[([^\]]+)\]'
                $versions = @(
                    $script:lines |
                        Where-Object { $_ -match $versionPattern } |
                        ForEach-Object { [regex]::Match($_, $versionPattern).Groups[1].Value }
                )
                foreach ($version in $versions) {
                    Should-ContainCollection -Actual $definedLinks -Expected $version `
                        -Because "[$version] requires a reference link at the bottom of the file"
                }
            }
        }
    }
}

Describe 'LICENSE' -Tag 'License' {

    BeforeAll {
        $script:licensePath = Join-Path -Path $script:data.ProjectRoot -ChildPath 'LICENSE'
        $script:licenseExists = Test-Path -Path $script:licensePath
        $script:licenseContent = if ($script:licenseExists) {
            Get-Content -Path $script:licensePath -Raw
        }
    }

    Context 'File' {

        It 'exists at the project root' {
            $script:licenseExists | Should-BeTrue
        }
    }

    Context 'Content' {

        It 'is not empty' {
            $script:licenseContent | Should-NotBeWhiteSpaceString
        }

        It 'matches a recognised open-source license (MIT, Apache 2.0, or BSD 3-Clause)' {
            $isMIT = $script:licenseContent -match '(?i)\bMIT License\b'
            $isApache = ($script:licenseContent -match '(?i)Apache License') -and
            ($script:licenseContent -match 'Version 2\.0')
            $isBSD3 = $script:licenseContent -match '(?i)BSD 3-Clause License'

            ($isMIT -or $isApache -or $isBSD3) | Should-BeTrue `
                -Because 'LICENSE must contain one of the three supported types: MIT, Apache 2.0, or BSD 3-Clause'
        }
    }
}