Private/Get-METReportGlossaryTerms.ps1

function Get-METReportGlossaryTerms {
    [CmdletBinding()]
    [OutputType([string[]])]
    param()

    # Same discovery path Invoke-METAssessment/Get-METCheck use - resolved fresh every call
    # so a dropped-in check's cmdlets/properties are picked up with no separate list to
    # maintain. Missing entirely (a broken install) degrades to an empty glossary rather
    # than failing report generation - the HTML report has no hard dependency on this.
    $checksRoot = Join-Path -Path $PSScriptRoot -ChildPath '../Checks'
    if (-not (Test-Path -LiteralPath $checksRoot)) {
        return @()
    }
    $checkFiles = Get-ChildItem -Path $checksRoot -Recurse -Filter 'MET-*.ps1' -ErrorAction SilentlyContinue

    # A single capitalized word (Identity, Path, Force, OneDrive, SharePoint) reads as
    # ordinary English prose, not a code identifier - only a multi-segment PascalCase run
    # (RejectDirectSend, EnableSafeList) is unambiguous. The 6-character floor drops short
    # category/protocol acronyms (MDO, EXO, SPF, DKIM) that happen to satisfy the segment
    # shape but read fine as plain text. Matched with -cmatch, not -match: PowerShell's
    # default case-insensitive comparison collapses [A-Z] and [a-z0-9] into the same
    # character class, and running that against the long, multi-hundred-character prose
    # blobs in -Finding/-Recommendation text (which this same AST walk also visits, since
    # they're StringConstantExpressionAst nodes too) is catastrophic backtracking - it
    # hung indefinitely on MET-Teams006 in testing. The 80-character ceiling is belt-and-
    # suspenders: no real identifier here approaches that length, so it bounds the regex
    # engine's worst case on any prose string that slips past first.
    $pascalMultiSegment = '^([A-Z][a-z0-9]*){2,}$'
    $minLength = 6
    $maxLength = 80

    # PowerShell's own common parameters carry generic English meanings (Confirm, Force,
    # Verbose...) that would otherwise get coded wherever they happen to appear in prose.
    $commonParameters = [System.Collections.Generic.HashSet[string]]::new(
        [string[]]@(
            'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable',
            'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable'
        ),
        [System.StringComparer]::Ordinal)

    $terms = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)

    foreach ($file in $checkFiles) {
        $tokens = $null
        $parseErrors = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseFile(
            $file.FullName, [ref] $tokens, [ref] $parseErrors)

        if ($parseErrors) {
            continue
        }

        # Cmdlets the check actually invokes (Get-EXOMailbox, Set-TransportConfig, ...) -
        # rendered as code wherever they're mentioned in Finding/Recommendation text, not
        # only right after "Run:".
        foreach ($cmd in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true)) {
            $cmdName = $cmd.GetCommandName()
            if ($cmdName -and $cmdName.Length -le $maxLength -and $cmdName -cmatch '^[A-Z][a-zA-Z0-9]*-[A-Z][a-zA-Z0-9]*$') {
                [void]$terms.Add($cmdName)
            }
        }

        # Named parameters passed to those cmdlets (-RejectDirectSend $true).
        foreach ($param in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandParameterAst] }, $true)) {
            $pname = $param.ParameterName
            if ($pname -and $pname.Length -ge $minLength -and $pname.Length -le $maxLength -and $pname -cmatch $pascalMultiSegment -and -not $commonParameters.Contains($pname)) {
                [void]$terms.Add($pname)
            }
        }

        # Every other identifier-shaped string constant - covers property access read via
        # $obj.PSObject.Properties['Name'] (the absent-property-safe pattern this codebase
        # uses throughout), direct .MemberAccess, hashtable keys, and quoted built-in policy
        # names (AdminOnlyAccessPolicy, DefaultFullAccessWithNotificationPolicy, ...). A
        # multi-word prose sentence never matches - the anchored pattern requires the whole
        # string to be one unbroken PascalCase run with no spaces or punctuation.
        foreach ($str in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.StringConstantExpressionAst] }, $true)) {
            $val = $str.Value
            if ($val -and $val.Length -ge $minLength -and $val.Length -le $maxLength -and $val -cmatch $pascalMultiSegment -and -not $commonParameters.Contains($val)) {
                [void]$terms.Add($val)
            }
        }
    }

    $terms | Sort-Object
}