Public/Invoke-DllSuiteAnalysis.ps1

function Invoke-DllSuiteAnalysis {
    <#
    .SYNOPSIS
        Cross-DLL inventory: duplicates, GUID conflicts, interface drift,
        registry state. Aimed at legacy COM suites where DLLs got copied
        across teams and silently diverged while keeping the same CLSIDs.
 
    .DESCRIPTION
        Walks one or more directories, parses every PE found (DLL/OCX),
        and produces a structured analysis suitable for both interactive
        review and CI pipelines. Output object carries:
 
          - Per-DLL records: hash, size, COM/TypeLib metadata, declared
            CoClass/Interface/Dispatch GUIDs and their signatures.
          - Duplicate groups: byte-identical DLLs grouped by SHA-256.
          - GUID conflicts: GUIDs that appear in more than one distinct
            DLL (different SHA-256). Flagged with HasDrift=true when the
            interface or method set differs across versions.
          - Registration status: for each conflicted CoClass GUID, which
            on-disk copy is currently registered in HKCR\CLSID, or whether
            registration points outside the scanned set.
 
        Strictly read-only: no LoadLibrary, no regsvr32. Reuses Get-DllInfo
        for parsing and Resolve-ClsidEverywhere (private) for registry
        lookup, both in the same module.
 
    .PARAMETER Path
        One or more directories or file paths. Directories are walked; if
        -Recurse is set, the walk descends into subdirectories.
 
    .PARAMETER Include
        File patterns to consider. Default: '*.dll','*.ocx'.
 
    .PARAMETER Recurse
        Walk directories recursively.
 
    .PARAMETER OutputDir
        When set, writes report.json (full structured output, schema
        'dllsuite/1') and summary.txt (human-readable digest) into the
        directory. Created if missing.
 
    .PARAMETER Strict
        Marks the result as failed (HasIssues=true) when conflicts or
        drift are found. The cmdlet itself never throws or sets
        $LASTEXITCODE - the consumer (CI wrapper script, GUI button) is
        expected to translate Strict + HasIssues into the desired exit.
 
    .PARAMETER Quiet
        Suppress progress and per-step Write-Host. Final one-line summary
        is still emitted.
 
    .EXAMPLE
        # Scan two app trees, write report next to them
        Invoke-DllSuiteAnalysis -Path C:\Apps\Team1, C:\Apps\Team2 -Recurse `
                                -OutputDir C:\Reports\Suite
 
    .EXAMPLE
        # CI pipeline: fail the build on drift
        $r = Invoke-DllSuiteAnalysis -Path .\bin -Recurse -Strict -Quiet `
                                     -OutputDir .\artifacts
        if ($r.HasIssues) { exit 2 }
 
    .NOTES
        PowerShell 5.1+ on Windows. Performance: ~50 ms per DLL for the
        full parse + hash; registry lookup adds ~5 ms per conflicted CLSID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [string[]]$Path,

        [string[]]$Include = @('*.dll','*.ocx'),

        [switch]$Recurse,

        [string]$OutputDir,

        [switch]$Strict,

        [switch]$Quiet
    )

    begin {
        $allFiles = New-Object System.Collections.Generic.List[string]
    }

    process {
        foreach ($p in $Path) {
            try {
                $resolved = (Resolve-Path -LiteralPath $p -ErrorAction Stop).ProviderPath
            } catch {
                Write-Error "Cannot resolve '$p': $($_.Exception.Message)"
                continue
            }

            if (Test-Path -LiteralPath $resolved -PathType Container) {
                $gciArgs = @{ LiteralPath = $resolved; File = $true; ErrorAction = 'SilentlyContinue' }
                if ($Recurse) { $gciArgs['Recurse'] = $true }
                Get-ChildItem @gciArgs | Where-Object {
                    $n = $_.Name
                    @($Include | Where-Object { $n -like $_ }).Count -gt 0
                } | ForEach-Object { [void]$allFiles.Add($_.FullName) }
            } else {
                [void]$allFiles.Add($resolved)
            }
        }
    }

    end {
        if ($allFiles.Count -eq 0) {
            Write-Warning "No matching DLL/OCX files found under the given paths."
            return
        }

        if (-not $Quiet) {
            Write-Host ("Scanning {0} files..." -f $allFiles.Count) -ForegroundColor Cyan
        }

        # ---- Per-file deep parse ---------------------------------------
        $records = New-Object System.Collections.Generic.List[object]
        $i = 0
        foreach ($f in $allFiles) {
            $i++
            Write-Verbose ("[{0}/{1}] {2}" -f $i, $allFiles.Count, (Split-Path -Leaf $f))

            try {
                $info = Get-DllInfo -Path $f -IncludeHash -IncludeTypeLib -ErrorAction Stop
            } catch {
                [void]$records.Add([pscustomobject]@{
                    Path       = $f
                    Sha256     = $null
                    Size       = $null
                    ParseError = $_.Exception.Message
                    Entries    = @()
                })
                continue
            }

            $entries = New-Object System.Collections.Generic.List[object]
            if ($info.Com -and $info.Com.HasTypeLib -and $info.Com.TypeLib -and $info.Com.TypeLib.TypeInfos) {
                foreach ($ti in $info.Com.TypeLib.TypeInfos) {
                    $kind = ($ti.Kind -replace '^TKIND_', '').ToLowerInvariant()
                    $methodSig    = $null
                    $interfaceSig = $null

                    if ($ti.Methods) {
                        $methodSig = @($ti.Methods | ForEach-Object {
                            "{0}#{1}" -f $_.Name, $_.DispId
                        } | Sort-Object) -join '|'
                    }
                    if ($ti.Interfaces) {
                        $interfaceSig = @($ti.Interfaces |
                            ForEach-Object { $_.Iid.ToString().ToUpperInvariant() } |
                            Sort-Object) -join '|'
                    }

                    [void]$entries.Add([pscustomobject]@{
                        Kind         = $kind
                        Name         = $ti.Name
                        Guid         = $ti.Guid.ToString().ToUpperInvariant()
                        MethodSig    = $methodSig
                        InterfaceSig = $interfaceSig
                        MethodCount  = if ($ti.Methods)    { $ti.Methods.Count }    else { 0 }
                        IfaceCount   = if ($ti.Interfaces) { $ti.Interfaces.Count } else { 0 }
                    })
                }
            }

            $libId = $null; $libName = $null; $libVersion = $null
            if ($info.Com -and $info.Com.TypeLib) {
                $libId      = $info.Com.TypeLib.LibId.ToString().ToUpperInvariant()
                $libName    = $info.Com.TypeLib.Name
                $libVersion = "{0}.{1}" -f $info.Com.TypeLib.MajorVersion, $info.Com.TypeLib.MinorVersion
            }

            # Build the per-file record. Explicit type coercions on the
            # primitive fields avoid the "Argument types do not match"
            # ArgumentException that PS 5.1 throws during pscustomobject
            # construction when a hashtable value is a complex CIM/PSObject
            # type (some Get-DllInfo fields are CIM-backed primitives).
            $h = [ordered]@{}
            $h['Path']        = [string]$info.Path
            $h['Size']        = [int64]$info.FileSize
            $h['Sha256']      = if ($info.Sha256) { [string]$info.Sha256 } else { $null }
            $h['IsComServer'] = [bool]$info.Com.IsComServer
            $h['HasTypeLib']  = [bool]$info.Com.HasTypeLib
            $h['LibId']       = $libId
            $h['LibName']     = $libName
            $h['LibVersion']  = $libVersion
            $h['Entries']     = $entries.ToArray()
            $h['ParseError']  = if ($info.ParseError) { [string]$info.ParseError } else { $null }
            [void]$records.Add([pscustomobject]$h)
        }

        # ---- Duplicate groups ------------------------------------------
        $duplicateGroups = @(
            $records |
                Where-Object { $_.Sha256 } |
                Group-Object Sha256 |
                Where-Object { $_.Count -gt 1 } |
                ForEach-Object {
                    [pscustomobject]@{
                        Sha256 = $_.Name
                        Size   = $_.Group[0].Size
                        Count  = $_.Count
                        Paths  = @($_.Group | ForEach-Object Path)
                    }
                }
        )

        # ---- GUID conflicts --------------------------------------------
        # Build inverted index GUID -> list of (Path, Sha256, Kind, Name, Sigs).
        $guidIndex = @{}
        foreach ($rec in $records) {
            foreach ($e in $rec.Entries) {
                if ($e.Guid -eq '00000000-0000-0000-0000-000000000000') { continue }
                if (-not $guidIndex.ContainsKey($e.Guid)) {
                    $guidIndex[$e.Guid] = New-Object System.Collections.Generic.List[object]
                }
                [void]$guidIndex[$e.Guid].Add([pscustomobject]@{
                    Path         = $rec.Path
                    Sha256       = $rec.Sha256
                    Kind         = $e.Kind
                    Name         = $e.Name
                    MethodSig    = $e.MethodSig
                    InterfaceSig = $e.InterfaceSig
                    MethodCount  = $e.MethodCount
                    IfaceCount   = $e.IfaceCount
                })
            }
        }

        $guidConflicts = New-Object System.Collections.Generic.List[object]
        # PS 5.1: enumerating Hashtable.Keys directly while indexing back
        # into the same hashtable can throw ArgumentException ("argument
        # types do not match"). Snapshot the keys to a string[] first.
        $allGuids = [string[]]@($guidIndex.Keys)
        foreach ($guid in $allGuids) {
            $occs = $guidIndex[$guid].ToArray()
            $distinctHashes = @($occs | ForEach-Object Sha256 | Where-Object { $_ } | Sort-Object -Unique)
            if ($distinctHashes.Count -le 1) { continue }   # not a conflict

            # Drift signature varies by kind: coclass uses InterfaceSig,
            # interface/dispatch use MethodSig. Anything else: not checked.
            $byHash = $occs | Group-Object Sha256
            $sigs = @($byHash | ForEach-Object {
                $first = $_.Group[0]
                switch ($first.Kind) {
                    'coclass'   { "iface:" + $first.InterfaceSig }
                    'interface' { "meth:"  + $first.MethodSig }
                    'dispatch'  { "meth:"  + $first.MethodSig }
                    default     { '' }
                }
            } | Sort-Object -Unique)
            $hasDrift = $sigs.Count -gt 1

            [void]$guidConflicts.Add([pscustomobject]@{
                Guid             = $guid
                Kind             = $occs[0].Kind
                Name             = $occs[0].Name
                DistinctVersions = $distinctHashes.Count
                HasDrift         = $hasDrift
                Occurrences      = @($occs | ForEach-Object {
                    [pscustomobject]@{
                        Path         = $_.Path
                        Sha256       = $_.Sha256
                        MethodCount  = $_.MethodCount
                        IfaceCount   = $_.IfaceCount
                        MethodSig    = $_.MethodSig
                        InterfaceSig = $_.InterfaceSig
                    }
                })
            })
        }

        # ---- Registration status for conflicted CoClasses --------------
        # Resolve-ClsidEverywhere is private to the module and already
        # reads HKLM/HKCU x64+x86. We only call it for CoClasses that
        # appear in multiple distinct DLLs.
        $registrationStatus = New-Object System.Collections.Generic.List[object]
        foreach ($c in $guidConflicts) {
            if ($c.Kind -ne 'coclass') { continue }
            $clsidBraced = '{' + $c.Guid + '}'
            $hits = Resolve-ClsidEverywhere -Clsid $clsidBraced

            if (-not $hits -or $hits.Count -eq 0) {
                [void]$registrationStatus.Add([pscustomobject]@{
                    Clsid                 = $clsidBraced
                    Name                  = $c.Name
                    State                 = 'NotRegistered'
                    RegisteredPath        = $null
                    RegisteredSha256      = $null
                    RegisteredScannedPath = $null
                    Hive                  = $null
                    View                  = $null
                })
                continue
            }

            $h = $hits[0]
            $regPath = $h.NormalizedPath
            $matchedRec = $records | Where-Object {
                $_.Path -and [string]::Equals(
                    [IO.Path]::GetFullPath($_.Path),
                    $regPath,
                    [System.StringComparison]::OrdinalIgnoreCase)
            } | Select-Object -First 1

            $state = if ($matchedRec) { 'RegisteredToScanned' } else { 'RegisteredOutsideScan' }
            [void]$registrationStatus.Add([pscustomobject]@{
                Clsid                 = $clsidBraced
                Name                  = $c.Name
                State                 = $state
                RegisteredPath        = $regPath
                RegisteredSha256      = if ($matchedRec) { $matchedRec.Sha256 } else { $null }
                RegisteredScannedPath = if ($matchedRec) { $matchedRec.Path }   else { $null }
                Hive                  = $h.Hive
                View                  = $h.View
            })
        }

        # ---- Build analysis object -------------------------------------
        # Materialize lists into arrays first; PS 5.1 piping over generic
        # List[object] sometimes throws "Argument types do not match"
        # depending on element types.
        $recordsArr  = $records.ToArray()
        $conflictsArr = $guidConflicts.ToArray()
        $regStatusArr = $registrationStatus.ToArray()

        $parseErrors = 0
        $comServers  = 0
        $uniqueHashes = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($r in $recordsArr) {
            if ($r.ParseError)   { $parseErrors++ }
            if ($r.IsComServer)  { $comServers++ }
            if ($r.Sha256)       { [void]$uniqueHashes.Add($r.Sha256) }
        }
        $driftIssues = 0
        foreach ($c in $conflictsArr) {
            if ($c.HasDrift) { $driftIssues++ }
        }

        $hasIssues = ($conflictsArr.Length -gt 0)
        $analysis = [pscustomobject]@{
            Schema        = 'dllsuite/1'
            ScannedAt     = (Get-Date).ToUniversalTime().ToString('o')
            HostName      = $env:COMPUTERNAME
            ScannedPaths  = @($Path)
            Recurse       = [bool]$Recurse
            Strict        = [bool]$Strict
            HasIssues     = [bool]($hasIssues -and $Strict)
            Summary       = [pscustomobject]@{
                FilesScanned    = $allFiles.Count
                ParseErrors     = $parseErrors
                ComServers      = $comServers
                UniqueByHash    = $uniqueHashes.Count
                DuplicateGroups = $duplicateGroups.Count
                GuidConflicts   = $conflictsArr.Length
                DriftIssues     = $driftIssues
            }
            Dlls               = $recordsArr
            DuplicateGroups    = @($duplicateGroups)
            GuidConflicts      = $conflictsArr
            RegistrationStatus = $regStatusArr
        }

        # ---- Write artifacts -------------------------------------------
        if ($OutputDir) {
            try {
                $null = New-Item -ItemType Directory -Path $OutputDir -Force -ErrorAction Stop
            } catch {
                throw "Cannot create OutputDir '$OutputDir': $($_.Exception.Message)"
            }

            $jsonPath    = Join-Path $OutputDir 'report.json'
            $summaryPath = Join-Path $OutputDir 'summary.txt'

            $analysis | ConvertTo-Json -Depth 12 | Set-Content -Path $jsonPath -Encoding UTF8

            $sb = New-Object System.Text.StringBuilder
            [void]$sb.AppendLine("DLL Suite Analysis - $($analysis.ScannedAt)")
            [void]$sb.AppendLine("Host: $($analysis.HostName)")
            [void]$sb.AppendLine("Paths:")
            foreach ($pp in $analysis.ScannedPaths) { [void]$sb.AppendLine(" $pp") }
            [void]$sb.AppendLine("")
            [void]$sb.AppendLine("Files scanned : $($analysis.Summary.FilesScanned)")
            [void]$sb.AppendLine("Parse errors : $($analysis.Summary.ParseErrors)")
            [void]$sb.AppendLine("COM servers : $($analysis.Summary.ComServers)")
            [void]$sb.AppendLine("Unique by hash : $($analysis.Summary.UniqueByHash)")
            [void]$sb.AppendLine("Duplicate groups : $($analysis.Summary.DuplicateGroups)")
            [void]$sb.AppendLine("GUID conflicts : $($analysis.Summary.GuidConflicts)")
            [void]$sb.AppendLine("Drift issues : $($analysis.Summary.DriftIssues)")

            if ($duplicateGroups.Count -gt 0) {
                [void]$sb.AppendLine("")
                [void]$sb.AppendLine("=== Duplicate groups (byte-identical) ===")
                foreach ($g in $duplicateGroups) {
                    [void]$sb.AppendLine(("[{0}] {1} bytes ({2} copies)" -f $g.Sha256.Substring(0,12), $g.Size, $g.Count))
                    foreach ($p in $g.Paths) { [void]$sb.AppendLine(" $p") }
                }
            }

            if ($guidConflicts.Count -gt 0) {
                [void]$sb.AppendLine("")
                [void]$sb.AppendLine("=== GUID conflicts (same GUID, different DLLs) ===")
                foreach ($c in $guidConflicts) {
                    $tag = if ($c.HasDrift) { 'DRIFT' } else { 'dup-guid' }
                    [void]$sb.AppendLine(("[{0}] {1} {2} ({3} versions)" -f $tag, $c.Kind, $c.Name, $c.DistinctVersions))
                    [void]$sb.AppendLine(" GUID: $($c.Guid)")
                    foreach ($o in $c.Occurrences) {
                        $extra = if ($c.Kind -eq 'coclass') { "ifaces=$($o.IfaceCount)" }
                                 else { "methods=$($o.MethodCount)" }
                        [void]$sb.AppendLine((" {0} sha={1} {2}" -f $o.Path, ($o.Sha256.Substring(0,12)), $extra))
                    }
                }
            }

            if ($registrationStatus.Count -gt 0) {
                [void]$sb.AppendLine("")
                [void]$sb.AppendLine("=== Registry state of conflicted CoClasses ===")
                foreach ($r in $registrationStatus) {
                    [void]$sb.AppendLine(("[{0}] {1} {2}" -f $r.State, $r.Clsid, $r.Name))
                    if ($r.RegisteredPath) { [void]$sb.AppendLine(" -> $($r.RegisteredPath)") }
                }
            }

            $sb.ToString() | Set-Content -Path $summaryPath -Encoding UTF8

            # HTML report (best-effort; failures are reported but don't stop)
            $htmlPath = Join-Path $OutputDir 'report.html'
            try {
                New-DllSuiteReport -Analysis $analysis -OutputPath $htmlPath -ErrorAction Stop
            } catch {
                Write-Warning "HTML report generation failed: $($_.Exception.Message)"
                $htmlPath = $null
            }

            if (-not $Quiet) {
                Write-Host " report.json : $jsonPath"   -ForegroundColor DarkGray
                Write-Host " summary.txt : $summaryPath" -ForegroundColor DarkGray
                if ($htmlPath) { Write-Host " report.html : $htmlPath" -ForegroundColor DarkGray }
            }
        }

        # ---- Final one-liner -------------------------------------------
        $level = if ($hasIssues -and $Strict) { 'FAIL' }
                 elseif ($hasIssues)          { 'WARN' }
                 else                         { 'OK' }
        $msg = "{0} : files={1} duplicates={2} conflicts={3} drift={4}" -f `
            $level,
            $analysis.Summary.FilesScanned,
            $analysis.Summary.DuplicateGroups,
            $analysis.Summary.GuidConflicts,
            $analysis.Summary.DriftIssues

        if     ($level -eq 'FAIL') { Write-Warning $msg }
        elseif ($level -eq 'WARN') { Write-Warning $msg }
        else                       { Write-Host    $msg -ForegroundColor Green }

        # Always emit the structured analysis to the pipeline. CI wrappers
        # consume HasIssues to decide their exit code.
        $analysis
    }
}