Private/Get-DeprecationRuleSet.ps1

function Get-DeprecationRuleSet {
    <#
    .SYNOPSIS
        Loads one or more rule database files and returns compiled rule objects.
    .DESCRIPTION
        Reads the JSON rule database (by default the one shipped with the module), validates the
        fields the engine relies on and pre-compiles the matchers (wildcards and regular expressions)
        so scanning many files stays fast. Results are cached per file until the file changes.
    #>

    [CmdletBinding()]
    [OutputType('M365DeprecationScanner.Rule')]
    param(
        # Rule database files to load. Defaults to the database shipped with the module.
        [string[]]$Path = @(Join-Path -Path $script:ModuleRoot -ChildPath 'Data/deprecations.json')
    )

    if (-not (Test-Path -Path variable:script:RuleCache)) {
        $script:RuleCache = @{}
    }

    $seenIds = @{}
    foreach ($file in $Path) {
        $resolved = (Resolve-Path -LiteralPath $file -ErrorAction Stop).ProviderPath
        $stamp = (Get-Item -LiteralPath $resolved).LastWriteTimeUtc

        $cached = $script:RuleCache[$resolved]
        if ($null -eq $cached -or $cached.Stamp -ne $stamp) {
            $cached = @{
                Stamp = $stamp
                Rules = @(Import-DeprecationRuleFile -Path $resolved)
            }
            $script:RuleCache[$resolved] = $cached
        }

        foreach ($rule in $cached.Rules) {
            if ($seenIds.ContainsKey($rule.Id)) {
                throw "Duplicate rule id '$($rule.Id)' in '$resolved' (already defined in '$($seenIds[$rule.Id])')."
            }
            $seenIds[$rule.Id] = $resolved
            $rule
        }
    }
}

function Import-DeprecationRuleFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    try {
        $database = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "Rule file '$Path' is not valid JSON: $($_.Exception.Message)"
    }

    if (-not $database.PSObject.Properties['rules']) {
        throw "Rule file '$Path' has no 'rules' array."
    }

    $severityByStatus = @{
        Retired    = 'Error'
        Deprecated = 'Warning'
        Announced  = 'Information'
    }

    foreach ($entry in $database.rules) {
        foreach ($required in 'id', 'kind', 'pattern', 'status', 'title', 'replacement') {
            if (-not $entry.PSObject.Properties[$required] -or [string]::IsNullOrWhiteSpace([string]$entry.$required)) {
                throw "A rule in '$Path' is missing the required field '$required'."
            }
        }
        $id = [string]$entry.id
        $kind = [string]$entry.kind
        $status = [string]$entry.status

        if ($id -cnotmatch '^[A-Z]{2,8}\d{4}$') {
            throw "Rule id '$id' in '$Path' is invalid. Use 2-8 upper-case letters followed by 4 digits, for example ORG0001."
        }
        if ($kind -notin 'Command', 'Parameter', 'Module', 'StringLiteral') {
            throw "Rule '$id' in '$Path' has unknown kind '$kind'."
        }
        if (-not $severityByStatus.ContainsKey($status)) {
            throw "Rule '$id' in '$Path' has unknown status '$status'."
        }

        $pattern = [string]$entry.pattern
        $commandPattern = $null
        $regex = $null
        if ($kind -eq 'StringLiteral') {
            try {
                $options = [System.Text.RegularExpressions.RegexOptions]'IgnoreCase, CultureInvariant'
                $regex = New-Object -TypeName System.Text.RegularExpressions.Regex -ArgumentList $pattern, $options, ([timespan]::FromSeconds(1))
            }
            catch {
                throw "Rule '$id' in '$Path' has an invalid regular expression: $($_.Exception.Message)"
            }
        }
        if ($kind -eq 'Parameter') {
            if (-not $entry.PSObject.Properties['command'] -or [string]::IsNullOrWhiteSpace([string]$entry.command)) {
                throw "Rule '$id' in '$Path' is a Parameter rule and needs a 'command'."
            }
            $commandPattern = [string]$entry.command
        }

        # Pre-compiled matchers keep scanning fast: no pattern is parsed again per command.
        $matcher = $null
        $commandMatcher = $null
        if ($kind -ne 'StringLiteral') {
            $matcher = New-NamePattern -Pattern $pattern
        }
        if ($commandPattern) {
            $commandMatcher = New-NamePattern -Pattern $commandPattern
        }
        $exclude = @(Get-OptionalProperty -InputObject $entry -Name 'exclude' -Default @() | ForEach-Object { [string]$_ } | Where-Object { $_ })

        [pscustomobject]@{
            PSTypeName        = 'M365DeprecationScanner.Rule'
            Id                = $id
            Kind              = $kind
            Pattern           = $pattern
            Command           = $commandPattern
            Area              = Get-OptionalProperty -InputObject $entry -Name 'area' -Default 'Other'
            Module            = Get-OptionalProperty -InputObject $entry -Name 'module'
            Status            = $status
            Severity          = $severityByStatus[$status]
            RetirementDate    = Get-OptionalProperty -InputObject $entry -Name 'retirementDate'
            Title             = [string]$entry.title
            Replacement       = [string]$entry.replacement
            ReplacementModule = Get-OptionalProperty -InputObject $entry -Name 'replacementModule'
            Notes             = Get-OptionalProperty -InputObject $entry -Name 'notes'
            Sources           = @(Get-OptionalProperty -InputObject $entry -Name 'sources' -Default @())
            Exclude           = $exclude
            IsWildcard        = ($kind -ne 'StringLiteral') -and [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($pattern)
            Regex             = $regex
            Matcher           = $matcher
            CommandMatcher    = $commandMatcher
            ExcludeMatchers   = @($exclude | ForEach-Object { New-NamePattern -Pattern $_ })
            SourceFile        = $Path
        }
    }
}

function Get-OptionalProperty {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$InputObject,

        [Parameter(Mandatory)]
        [string]$Name,

        [object]$Default = $null
    )

    $property = $InputObject.PSObject.Properties[$Name]
    if ($property -and $null -ne $property.Value) {
        return $property.Value
    }
    return $Default
}