src/pipeline/Get-ScoutCollector.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Discover the inventory collectors to run, as an ordered, deterministic list (AB#5649).
 
.DESCRIPTION
    The v1 engine forked from microsoft/ARI rediscovered collectors inline inside
    `Start-AZSCProcessJob` and `Start-AZSCAutProcessJob`, twice, with the two copies already
    drifted apart: the regular path applied per-file `.CATEGORY` filtering and the automation
    path did not. Discovery was therefore neither shared, testable, nor consistent.
 
    This function is the single discovery implementation. It is a pure function of the
    filesystem plus the category filter — it starts nothing, waits on nothing, and touches no
    Azure state, so it can be unit-tested against a fixture directory.
 
    ORDER IS PART OF THE CONTRACT. Results are sorted by category then collector name, so two
    runs over an unchanged tree return the same collectors in the same sequence. The old
    pipeline's ordering came from whatever order `Get-Job` happened to return, which is one of
    the reasons the same tenant produced different reports on consecutive runs (AB#5629).
 
.PARAMETER InventoryRoot
    Optional legacy fixture root. Production discovery uses DefinitionRoot only; this parameter
    remains solely for isolated test fixtures that carry a sibling definitions directory.
 
.PARAMETER Category
    Categories to include. The default, 'All', disables filtering. Filtering is applied twice,
    matching the behaviour the regular path shipped with:
 
      1. at FOLDER level — a category folder whose name is not requested is skipped entirely;
      2. at FILE level — a collector declaring `.CATEGORY` in its header is kept only when at
         least one of its declared categories was requested.
 
    A collector with no `.CATEGORY` header inherits its folder's name as its category.
 
.PARAMETER DefinitionRoot
    Path to the declarative collector definition tree (`manifests/collectors`, one
    `<Category>/<Name>.psd1` per collector -- see `docs/v3.0.0.md`). It is the production
    catalog and defaults to the module's shipped manifests/collectors tree.
 
.OUTPUTS
    PSCustomObject with Name, FolderCategory, Categories, Path, Contract,
    HasDeclarativeDefinition and DefinitionPath.
 
.NOTES
    Tracks ADO AB#5649 (Epic AB#5638); definition reporting tracks AB#5657 (Feature AB#5656).
#>

function Get-ScoutCollector {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param(
        [Parameter(Position = 0)]
        [string]$InventoryRoot,

        [Parameter(Position = 1)]
        [AllowNull()]
        [string[]]$Category = @('All'),

        [Parameter(Position = 2)]
        [AllowNull()]
        [string]$DefinitionRoot
    )

    # v3's production catalog is the manifest tree. InventoryRoot remains an explicit
    # compatibility input for focused imperative-fixture tests only; production callers do not
    # discover or execute a collector script.
    if ([string]::IsNullOrWhiteSpace($DefinitionRoot)) {
        if ([string]::IsNullOrWhiteSpace($InventoryRoot)) {
            $ModuleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
        } else {
            # Isolated test estates keep their declarative catalog under the fixture root.
            # Production has no InventoryRoot and therefore never relies on this convention.
            $FixtureDefinitionRoot = Join-Path $InventoryRoot 'definitions'
            if (Test-Path -LiteralPath $FixtureDefinitionRoot -PathType Container) {
                $DefinitionRoot = $FixtureDefinitionRoot
            } else {
                $ModuleRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $InventoryRoot))
            }
        }
        if ([string]::IsNullOrWhiteSpace($DefinitionRoot)) {
            $DefinitionRoot = Join-Path $ModuleRoot 'manifests' 'collectors'
        }
    }

    # $null and @() both mean "no filter", the same as 'All'. Callers derive this from a
    # parameter that is legitimately absent.
    $FilterActive = $false
    if ($Category -and @($Category).Count -gt 0 -and $Category -notcontains 'All') {
        $FilterActive = $true
    }

    # A definition root without an inventory root is the v3 path. The directory names and
    # file basenames are the catalog's category and collector identity, so no source-script
    # metadata is needed to construct the deterministic descriptor.
    if ([string]::IsNullOrWhiteSpace($InventoryRoot)) {
        if (-not (Test-Path -LiteralPath $DefinitionRoot -PathType Container)) {
            Write-Warning "[AzureScout] Collector definition root not found: $DefinitionRoot"
            return
        }

        $Folders = @(Get-ChildItem -LiteralPath $DefinitionRoot -Directory | Sort-Object Name)
        if ($FilterActive) {
            $Folders = @($Folders | Where-Object { $Category -contains $_.Name })
        }

        foreach ($Folder in $Folders) {
            foreach ($File in @(Get-ChildItem -LiteralPath $Folder.FullName -Filter '*.psd1' -File | Sort-Object BaseName)) {
                [PSCustomObject]@{
                    Name                     = $File.BaseName
                    FolderCategory           = $Folder.Name
                    Categories               = @($Folder.Name)
                    Path                     = $null
                    Contract                 = 'Declarative'
                    HasDeclarativeDefinition = $true
                    DefinitionPath           = $File.FullName
                }
            }
        }
        return
    }

    if (-not (Test-Path -LiteralPath $InventoryRoot)) {
        Write-Warning "[AzureScout] Inventory module root not found: $InventoryRoot"
        return
    }

    $Folders = @(Get-ChildItem -LiteralPath $InventoryRoot -Directory | Sort-Object Name)

    if ($FilterActive) {
        $Folders = @($Folders | Where-Object { $Category -contains $_.Name })
    }

    foreach ($Folder in $Folders) {
        $Files = @(Get-ChildItem -LiteralPath $Folder.FullName -Filter '*.ps1' -File | Sort-Object Name)

        foreach ($File in $Files) {
            # The header carries an optional `.CATEGORY` line so a collector can belong to a
            # category other than the folder it is filed under. Read a bounded prefix rather
            # than the whole file — these are read for every collector on every run.
            $HeaderText = (@(Get-Content -LiteralPath $File.FullName -TotalCount 80 -ErrorAction SilentlyContinue) -join "`n")

            # All remaining collectors implement the standard invocation shape. Keep the field
            # while callers still consume it; its value is now deterministic.
            $Contract = 'Standard'

            # Both header forms are accepted:
            #
            # .CATEGORY Compute <- same line, what all 174 shipped collectors use
            # .CATEGORY
            # Compute <- value on the following line
            #
            # The engine's original expression was '\.CATEGORY\s*[\r\n]+\s*(...)', which REQUIRES
            # a line break between the keyword and the value. No collector is written that way,
            # so it never matched a single file and per-file category filtering was dead code --
            # every collector silently fell back to its folder name. Nothing currently declares a
            # category different from its folder, so accepting the real form changes no present
            # behaviour; it makes the documented feature work for cross-filed collectors.
            $FileCategory = $Folder.Name
            if ($HeaderText -match '\.CATEGORY[ \t]*(?:\r?\n\s*)?([^\r\n#<]+)') {
                $FileCategory = $Matches[1].Trim()
            }

            $Categories = @($FileCategory -split '\s*,\s*' | Where-Object { $_ })

            if ($FilterActive) {
                $Wanted = @($Categories | Where-Object { $Category -contains $_ })
                if ($Wanted.Count -eq 0) { continue }
            }

            $DefinitionPath = Join-Path $DefinitionRoot $Folder.Name "$($File.BaseName).psd1"
            $HasDefinition  = Test-Path -LiteralPath $DefinitionPath -PathType Leaf

            [PSCustomObject]@{
                Name                     = $File.BaseName
                FolderCategory           = $Folder.Name
                Categories               = $Categories
                Path                     = $File.FullName
                Contract                 = $Contract
                HasDeclarativeDefinition = $HasDefinition
                DefinitionPath           = if ($HasDefinition) { $DefinitionPath } else { $null }
            }
        }
    }
}