Public/Get-AvmModuleReference.ps1

function Get-AvmModuleReference {
    <#
    .SYNOPSIS
        Recursively scans a directory for AVM module references in Bicep and Terraform files.

    .DESCRIPTION
        Searches all *.bicep and *.tf files under the specified path for Azure Verified Module
        (AVM) references. Returns normalized objects describing each reference found, including
        ecosystem, category, module name, current version, registry path, file location, and
        line number.

    .PARAMETER Path
        Root directory to scan. Defaults to the current directory.

    .PARAMETER Exclude
        Array of glob patterns to exclude from scanning (e.g. '**/.terraform/**').

    .OUTPUTS
        PSCustomObject[] — one object per unique AVM reference with properties:
        ecosystem, category, group, module, currentVersion, isConstraint, registryPath,
        file, lineNumber, rawMatch.

    .EXAMPLE
        Get-AvmModuleReference -Path ./infra

    .EXAMPLE
        Get-AvmModuleReference -Path . -Exclude @('**/.terraform/**', '**/node_modules/**')
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Position = 0)]
        [string]$Path = '.',

        [Parameter()]
        [string[]]$Exclude = @()
    )

    # Resolve absolute path
    $rootPath = Resolve-Path -Path $Path -ErrorAction Stop | Select-Object -ExpandProperty Path

    # Bicep patterns
    # br/public:avm/(res|ptn|utl)/<group>/<module>:<version>
    # br:mcr.microsoft.com/bicep/avm/(res|ptn|utl)/<group>/<module>:<version>
    $bicepShortPattern = [regex]"'br/public:avm/(res|ptn|utl)/([^/]+)/([^:]+):([^']+)'"
    $bicepFullPattern  = [regex]"'br:mcr\.microsoft\.com/bicep/avm/(res|ptn|utl)/([^/]+)/([^:]+):([^']+)'"

    # Terraform: source line inside a module block + version line
    # We'll parse module blocks with a brace-depth parser to handle nested maps correctly.
    $tfModuleStartPattern = [regex]'(?m)^\s*module\s+"[^"]+"\s*\{'
    $tfSourcePattern      = [regex]'source\s*=\s*"Azure/(avm-(res|ptn|utl)-[^/"]+)/azurerm"'
    $tfVersionPattern     = [regex]'version\s*=\s*"([^"]+)"'

    $results = [System.Collections.Generic.List[PSCustomObject]]::new()
    $seen    = [System.Collections.Generic.HashSet[string]]::new()

    # Build exclude filter
    $excludePatterns = $Exclude

    function ShouldExclude([string]$filePath) {
        foreach ($pat in $excludePatterns) {
            if ($filePath -like $pat) { return $true }
        }
        return $false
    }

    # --- Scan Bicep files ---
    Get-ChildItem -Path $rootPath -Recurse -Filter '*.bicep' -File |
        Where-Object { -not (ShouldExclude $_.FullName) } |
        ForEach-Object {
            $file = $_.FullName
            $lines = Get-Content $file
            for ($i = 0; $i -lt $lines.Count; $i++) {
                $line = $lines[$i]
                $lineNum = $i + 1

                foreach ($pattern in @($bicepShortPattern, $bicepFullPattern)) {
                    $m = $pattern.Match($line)
                    if ($m.Success) {
                        $category    = $m.Groups[1].Value
                        $group       = $m.Groups[2].Value
                        $modName     = $m.Groups[3].Value
                        $version     = $m.Groups[4].Value
                        $regPath     = "bicep/avm/$category/$group/$modName"
                        $dedupeKey   = "$file|$regPath|$version"

                        if ($seen.Add($dedupeKey)) {
                            $results.Add([PSCustomObject]@{
                                ecosystem           = 'bicep'
                                category            = $category
                                group               = $group
                                module              = $modName
                                currentVersion      = $version
                                isConstraint        = $false
                                registryPath        = $regPath
                                file                = $file
                                lineNumber          = $lineNum
                                rawMatch            = $m.Value
                                versionLineMissing  = $false
                            })
                        }
                    }
                }
            }
        }

    # --- Scan Terraform files ---
    Get-ChildItem -Path $rootPath -Recurse -Filter '*.tf' -File |
        Where-Object { -not (ShouldExclude $_.FullName) } |
        ForEach-Object {
            $file    = $_.FullName
            $content = Get-Content $file -Raw

            # Brace-depth parser: find all module block start positions, then walk
            # character-by-character to locate the matching closing brace.
            # Braces inside double-quoted strings and line comments (# and //) are ignored
            # so nested maps (tags = {...}, providers = {...}, etc.) do not truncate the block.
            $moduleStarts = $tfModuleStartPattern.Matches($content)

            foreach ($ms in $moduleStarts) {
                # Absolute position of the opening '{' of this module block
                $openBracePos = $ms.Index + $ms.Value.LastIndexOf('{')
                $bodyStart    = $openBracePos + 1

                $depth    = 0
                $inString = $false
                $escape   = $false
                $bodyEnd  = -1
                $p        = $openBracePos

                while ($p -lt $content.Length) {
                    $ch = $content[$p]

                    if ($escape) {
                        $escape = $false
                        $p++
                        continue
                    }

                    if ($inString) {
                        if ($ch -eq '\') { $escape = $true }
                        elseif ($ch -eq '"') { $inString = $false }
                        $p++
                        continue
                    }

                    # Line comment '#': skip to end of line
                    if ($ch -eq '#') {
                        while ($p -lt $content.Length -and $content[$p] -ne "`n") { $p++ }
                        continue
                    }
                    # Line comment '//': skip to end of line
                    if ($ch -eq '/' -and ($p + 1) -lt $content.Length -and $content[$p + 1] -eq '/') {
                        while ($p -lt $content.Length -and $content[$p] -ne "`n") { $p++ }
                        continue
                    }

                    if ($ch -eq '"') {
                        $inString = $true
                        $p++
                        continue
                    }

                    if ($ch -eq '{') {
                        $depth++
                    } elseif ($ch -eq '}') {
                        $depth--
                        if ($depth -eq 0) {
                            $bodyEnd = $p
                            break
                        }
                    }

                    $p++
                }

                if ($bodyEnd -eq -1) { continue }   # Unclosed block — skip

                $body = $content.Substring($bodyStart, $bodyEnd - $bodyStart)

                $srcMatch = $tfSourcePattern.Match($body)
                if (-not $srcMatch.Success) { continue }

                $avmModuleName = $srcMatch.Groups[1].Value   # e.g. avm-res-storage-storageaccount
                $regPath       = "Azure/$avmModuleName/azurerm"

                # Determine category from module name
                $category = 'res'
                if ($avmModuleName -match '^avm-(ptn|utl)-') { $category = $Matches[1] }

                $verMatch     = $tfVersionPattern.Match($body)
                $rawVersion   = if ($verMatch.Success) { $verMatch.Groups[1].Value } else { $null }
                $isConstraint = $rawVersion -match '~>|>=|<=|!=|\^|>'

                # Compute lineNumber using character-position counting so it is exact even
                # when the same version string appears in multiple blocks.
                # For Terraform, lineNumber points at the version = "..." line so that
                # Update-AvmModuleVersion rewrites the correct line.
                $lineNum = if ($verMatch.Success) {
                    $absPos = $bodyStart + $verMatch.Index
                    ($content.Substring(0, $absPos) -split '\r?\n').Count
                } else {
                    # No version line — fall back to the source line
                    $absSrcPos = $bodyStart + $srcMatch.Index
                    ($content.Substring(0, $absSrcPos) -split '\r?\n').Count
                }
                $isVersionMissing = -not $verMatch.Success

                $dedupeKey = "$file|$regPath|$rawVersion"
                if ($seen.Add($dedupeKey)) {
                    $results.Add([PSCustomObject]@{
                        ecosystem           = 'terraform'
                        category            = $category
                        group               = $null
                        module              = $avmModuleName
                        currentVersion      = $rawVersion
                        isConstraint        = [bool]$isConstraint
                        registryPath        = $regPath
                        file                = $file
                        lineNumber          = $lineNum
                        rawMatch            = $srcMatch.Value.Trim()
                        versionLineMissing  = $isVersionMissing
                    })
                }
            }
        }

    return $results.ToArray()
}