Private/Rules/AvmRule.ps1

function New-AvmRule {
    <#
    .SYNOPSIS
        Construct an AvmRule pscustomobject from a raw definition hashtable.

    .DESCRIPTION
        Used by Read-AvmRuleSet (and by tests) to normalise an authored rule
        definition (typically a hashtable loaded from a .psd1) into the
        canonical AvmRule shape. Defaults are filled in (Severity='error',
        AppliesTo='root') and the result is validated by Test-AvmRule before
        return.

        Authored shape (see docs/quality-standards.md Appendix A for the
        canonical rule list):

            @{
                Id = 'avm.tf.outputs-tf-not-output-tf'
                Kind = 'FileMustNotExist' # one of the primitive kinds
                Description = 'output.tf should be renamed to outputs.tf'
                Severity = 'error' | 'warning' # optional, default 'error'
                Parameters = @{ Path = 'output.tf'; ... } # kind-specific
                AppliesTo = 'root' | 'examples' | 'modules' | 'all' # optional, default 'root'
            }

        Returned canonical shape (PascalCase, all fields populated):

            [pscustomobject]@{
                Id = '<authored>'
                Kind = '<authored>'
                Description = '<authored>'
                Severity = 'error' | 'warning'
                Parameters = @{ <kind-specific> }
                AppliesTo = 'root' | 'examples' | 'modules' | 'all'
                Source = '<full path to .psd1>' | $null
            }

    .PARAMETER Definition
        Hashtable carrying the authored fields.

    .PARAMETER Source
        Optional full path to the .psd1 file the definition came from. The
        loader stamps this so error messages can cite the offending file.

    .OUTPUTS
        Single pscustomobject. Throws [System.Data.DataException] if the
        definition fails Test-AvmRule.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Factory function; returns a new pscustomobject and mutates no external state.')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Definition,

        [string] $Source
    )

    Set-StrictMode -Version 3.0
    $ErrorActionPreference = 'Stop'

    Test-AvmRule -Definition $Definition | Out-Null

    $severity = if ($Definition.ContainsKey('Severity')) { [string]$Definition.Severity } else { 'error' }
    $appliesTo = if ($Definition.ContainsKey('AppliesTo')) { [string]$Definition.AppliesTo } else { 'root' }
    $parameters = if ($Definition.ContainsKey('Parameters')) { $Definition.Parameters } else { @{} }

    return [pscustomobject][ordered]@{
        Id          = [string]$Definition.Id
        Kind        = [string]$Definition.Kind
        Description = [string]$Definition.Description
        Severity    = $severity
        Parameters  = $parameters
        AppliesTo   = $appliesTo
        Source      = if ($Source) { [string]$Source } else { $null }
    }
}

function Test-AvmRule {
    <#
    .SYNOPSIS
        Validate a hashtable that purports to be an AvmRule definition.

    .DESCRIPTION
        Throws [System.Data.DataException] with a precise message on schema
        violation; returns $true on success so it can be used in an
        assertion-style pipe (`Test-AvmRule -Definition $d | Out-Null`).

        Schema:
          - Id : required, lowercase kebab/dot identifier
                          (^[a-z][a-z0-9.-]*$), unique within the loaded set.
          - Kind : required, one of the four primitive kinds:
                          FileMustNotExist, FileMustExist, DirectoryMustExist,
                          GitignoreMustContain.
          - Description : required, non-empty string.
          - Severity : optional, 'error' | 'warning' (default 'error').
          - AppliesTo : optional, 'root' | 'examples' | 'modules' | 'all'
                          (default 'root').
          - Parameters : required hashtable; per-kind required keys:
              FileMustNotExist : Path (string). Optional: FixRenameTo (string).
              FileMustExist : Path (string).
              DirectoryMustExist : Path (string).
              GitignoreMustContain : RequiredGlobs (string[]), at least one entry.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [hashtable] $Definition
    )

    begin {
        Set-StrictMode -Version 3.0
        $ErrorActionPreference = 'Stop'

        $idRegex = '^[a-z][a-z0-9.-]*$'
        $validKinds = @('FileMustNotExist', 'FileMustExist', 'DirectoryMustExist', 'GitignoreMustContain')
        $validSeverities = @('error', 'warning')
        $validAppliesTo = @('root', 'examples', 'modules', 'all')
        $knownKeys = @('Id', 'Kind', 'Description', 'Severity', 'Parameters', 'AppliesTo')
    }

    process {
        foreach ($k in $Definition.Keys) {
            if ($knownKeys -cnotcontains $k) {
                throw [System.Data.DataException]::new(
                    "avm-rule: unknown key '$k'. Allowed: $($knownKeys -join ', ').")
            }
        }

        if (-not $Definition.ContainsKey('Id') -or [string]::IsNullOrWhiteSpace([string]$Definition.Id)) {
            throw [System.Data.DataException]::new("avm-rule: missing required key 'Id'.")
        }
        $id = [string]$Definition.Id
        if ($id -cnotmatch $idRegex) {
            throw [System.Data.DataException]::new(
                "avm-rule: Id '$id' must match $idRegex (lowercase, kebab/dot).")
        }

        if (-not $Definition.ContainsKey('Kind') -or [string]::IsNullOrWhiteSpace([string]$Definition.Kind)) {
            throw [System.Data.DataException]::new("avm-rule '$id': missing required key 'Kind'.")
        }
        $kind = [string]$Definition.Kind
        if ($validKinds -cnotcontains $kind) {
            throw [System.Data.DataException]::new(
                "avm-rule '$id': Kind '$kind' is not one of: $($validKinds -join ', ').")
        }

        if (-not $Definition.ContainsKey('Description') -or [string]::IsNullOrWhiteSpace([string]$Definition.Description)) {
            throw [System.Data.DataException]::new("avm-rule '$id': missing required key 'Description'.")
        }

        if ($Definition.ContainsKey('Severity')) {
            $sev = [string]$Definition.Severity
            if ($validSeverities -cnotcontains $sev) {
                throw [System.Data.DataException]::new(
                    "avm-rule '$id': Severity '$sev' is not one of: $($validSeverities -join ', ').")
            }
        }

        if ($Definition.ContainsKey('AppliesTo')) {
            $at = [string]$Definition.AppliesTo
            if ($validAppliesTo -cnotcontains $at) {
                throw [System.Data.DataException]::new(
                    "avm-rule '$id': AppliesTo '$at' is not one of: $($validAppliesTo -join ', ').")
            }
        }

        if (-not $Definition.ContainsKey('Parameters')) {
            throw [System.Data.DataException]::new("avm-rule '$id': missing required key 'Parameters'.")
        }
        $params = $Definition.Parameters
        if ($params -isnot [System.Collections.IDictionary]) {
            throw [System.Data.DataException]::new(
                "avm-rule '$id': 'Parameters' must be a hashtable.")
        }

        switch ($kind) {
            'FileMustNotExist' {
                if (-not $params.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$params.Path)) {
                    throw [System.Data.DataException]::new(
                        "avm-rule '$id': FileMustNotExist requires Parameters.Path.")
                }
                if ($params.ContainsKey('FixRenameTo') -and [string]::IsNullOrWhiteSpace([string]$params.FixRenameTo)) {
                    throw [System.Data.DataException]::new(
                        "avm-rule '$id': FileMustNotExist FixRenameTo must not be empty when provided.")
                }
            }
            'FileMustExist' {
                if (-not $params.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$params.Path)) {
                    throw [System.Data.DataException]::new(
                        "avm-rule '$id': FileMustExist requires Parameters.Path.")
                }
            }
            'DirectoryMustExist' {
                if (-not $params.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$params.Path)) {
                    throw [System.Data.DataException]::new(
                        "avm-rule '$id': DirectoryMustExist requires Parameters.Path.")
                }
            }
            'GitignoreMustContain' {
                if (-not $params.ContainsKey('RequiredGlobs')) {
                    throw [System.Data.DataException]::new(
                        "avm-rule '$id': GitignoreMustContain requires Parameters.RequiredGlobs.")
                }
                $globs = @($params.RequiredGlobs)
                if ($globs.Count -eq 0) {
                    throw [System.Data.DataException]::new(
                        "avm-rule '$id': GitignoreMustContain RequiredGlobs must have at least one entry.")
                }
                foreach ($g in $globs) {
                    if ([string]::IsNullOrWhiteSpace([string]$g)) {
                        throw [System.Data.DataException]::new(
                            "avm-rule '$id': GitignoreMustContain RequiredGlobs entries must be non-empty.")
                    }
                }
            }
        }

        return $true
    }
}