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() } |