Templates/Module/build/analyzers/ScriptAnalyzerCustomRules.psm1

# Custom PSScriptAnalyzer rules adapted from Indented.ScriptAnalyzerRules
# by Chris Dent (https://github.com/indented-automation/Indented.ScriptAnalyzerRules)
# Licensed under the MIT License.

using namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic
using namespace System.Management.Automation.Language

function AvoidProcessWithoutPipeline {
    <#
    .SYNOPSIS
        Flags functions that declare a process block without pipeline input.

    .DESCRIPTION
        A process block only runs per-item when the function accepts pipeline
        input via ValueFromPipeline or ValueFromPipelineByPropertyName.
        Without either attribute, process behaves identically to end and
        misleads readers into thinking the function supports the pipeline.
    #>

    [CmdletBinding()]
    [OutputType([DiagnosticRecord])]
    param(
        [ScriptBlockAst]$ast
    )

    if ($null -ne $ast.ProcessBlock -and $ast.ParamBlock) {
        $PipelineParam = $ast.ParamBlock.Find(
            {
                param($node)
                $node -is [AttributeAst] -and
                $node.TypeName.Name -eq 'Parameter' -and
                $node.NamedArguments.Where{
                    $_.ArgumentName -in 'ValueFromPipeline', 'ValueFromPipelineByPropertyName' -and
                    $_.Argument.SafeGetValue() -eq $true
                }
            },
            $false
        )

        if (-not $PipelineParam) {
            [DiagnosticRecord]@{
                Message = 'process block declared without a pipeline parameter (ValueFromPipeline or ValueFromPipelineByPropertyName)'
                Extent = $ast.ProcessBlock.Extent
                RuleName = $myinvocation.MyCommand.Name
                Severity = 'Warning'
            }
        }
    }
}

function AvoidNestedFunctions {
    <#
    .SYNOPSIS
        Flags function definitions nested inside other functions.

    .DESCRIPTION
        Nested functions are re-created on every call, pollute the parent
        scope, and are difficult to test independently. Extract them to
        module-level private functions instead.
    #>

    [CmdletBinding()]
    [OutputType([DiagnosticRecord])]
    param(
        [FunctionDefinitionAst]$ast
    )

    $ast.Body.FindAll(
        { param($node) $node -is [FunctionDefinitionAst] },
        $true
    ) | ForEach-Object {
        [DiagnosticRecord]@{
            Message = "Function '$($ast.Name)' contains nested function '$($_.Name)'. Extract it to a separate file."
            Extent = $_.Extent
            RuleName = $myinvocation.MyCommand.Name
            Severity = 'Warning'
        }
    }
}

function AvoidSmartQuotes {
    <#
    .SYNOPSIS
        Flags curly/smart quotation marks copied from word processors.

    .DESCRIPTION
        Smart quotes (U+2018, U+2019, U+201C, U+201D) look identical to
        normal quotes in some fonts but are not valid PowerShell syntax
        delimiters. Replace them with standard ASCII quotes.
    #>

    [CmdletBinding()]
    [OutputType([DiagnosticRecord])]
    param(
        [StringConstantExpressionAst]$ast
    )

    if ($ast.StringConstantType -eq 'BareWord') { return }

    $NormalQuotes = @("'", '"')

    if ($ast.StringConstantType -in 'DoubleQuotedHereString', 'SingleQuotedHereString') {
        $StartQuote, $EndQuote = $ast.Extent.Text[1, -2]
    } else {
        $StartQuote, $EndQuote = $ast.Extent.Text[0, -1]
    }

    if ($StartQuote -notin $NormalQuotes -or $EndQuote -notin $NormalQuotes) {
        [DiagnosticRecord]@{
            Message = 'Avoid smart quotes. Use standard ASCII quotes (" or '').'
            Extent = $ast.Extent
            RuleName = $myinvocation.MyCommand.Name
            Severity = 'Warning'
        }
    }
}

function AvoidEmptyNamedBlocks {
    <#
    .SYNOPSIS
        Flags empty begin, process, end, or dynamicparam blocks.

    .DESCRIPTION
        Empty named blocks are dead code that adds noise. Remove them
        unless you plan to add logic soon.
    #>

    [CmdletBinding()]
    [OutputType([DiagnosticRecord])]
    param(
        [ScriptBlockAst]$ast
    )

    foreach ($Block in @($ast.BeginBlock, $ast.ProcessBlock, $ast.EndBlock, $ast.DynamicParamBlock)) {
        if ($null -eq $Block) { continue }
        if ($null -eq $Block.Statements -or $Block.Statements.Count -eq 0) {
            [DiagnosticRecord]@{
                Message = "Empty '$($Block.BlockKind)' block. Remove it or add logic."
                Extent = $Block.Extent
                RuleName = $myinvocation.MyCommand.Name
                Severity = 'Warning'
            }
        }
    }
}

function AvoidNewObjectPSObject {
    <#
    .SYNOPSIS
        Flags New-Object PSObject in favor of [PSCustomObject].

    .DESCRIPTION
        [PSCustomObject]@{} is the modern, faster, and more readable way
        to create custom objects. New-Object PSObject is the legacy pattern.
    #>

    [CmdletBinding()]
    [OutputType([DiagnosticRecord])]
    param(
        [CommandAst]$ast
    )

    if ($ast.GetCommandName() -eq 'New-Object') {
        $TypeArg = $ast.CommandElements | Where-Object {
            $_ -is [StringConstantExpressionAst] -and
            $_.Value -in 'PSObject', 'PSCustomObject', 'System.Management.Automation.PSObject'
        }
        if ($TypeArg) {
            [DiagnosticRecord]@{
                Message = 'Use [PSCustomObject]@{} instead of New-Object PSObject.'
                Extent = $ast.Extent
                RuleName = $myinvocation.MyCommand.Name
                Severity = 'Information'
            }
        }
    }
}

function AvoidWriteOutput {
    <#
    .SYNOPSIS
        Flags unnecessary Write-Output calls.

    .DESCRIPTION
        In PowerShell, unassigned expressions automatically flow to the
        output pipeline. Write-Output is almost never needed and adds
        visual noise. Just emit the value directly.
    #>

    [CmdletBinding()]
    [OutputType([DiagnosticRecord])]
    param(
        [CommandAst]$ast
    )

    if ($ast.GetCommandName() -in 'Write-Output', 'write-output') {
        [DiagnosticRecord]@{
            Message = 'Avoid Write-Output. Unassigned expressions are output automatically.'
            Extent = $ast.Extent
            RuleName = $myinvocation.MyCommand.Name
            Severity = 'Information'
        }
    }
}