tools/Audit-GEUsage.ps1

<#
.SYNOPSIS
Audit which GitEasy commands the repo's own scripts actually use.

.DESCRIPTION
Audit-GEUsage.ps1 walks the repo's consumer-style scripts (Examples\,
tools\, repo-root *.ps1 by default) and uses the PowerShell AST parser
to find every CommandAst invocation. It then filters those invocations
to the public command names exported by GitEasy.psd1 and reports two
things:

1. USED commands - how many times each is called and from which files.
2. UNUSED commands - exported but never referenced outside the module's
   own Public\, Private\, and Tests\ folders.

The script does not modify any files; it produces a report. Tests\ is
excluded by default because Pester tests reference every command
extensively, which would mask the real signal (which commands does the
project actually demo to users / call from maintainer scripts).

The audit answers two questions for release-prep:
- Are we exporting commands we forgot to demo? (Examples\ gap.)
- Is the public surface still earning its keep? (No external caller.)

A follow-up Pester test (Tests\GitEasy.SurfaceCoverage.Tests.ps1) can
enforce the rule "every exported command must appear in at least one
script under Examples\" - the parsing logic is the same; only the
gate behavior differs.

.PARAMETER ProjectRoot
Absolute path to the GitEasy source repository. Defaults to
C:\Sysadmin\Scripts\GitEasy.

.PARAMETER ScanPath
One or more folders (relative to ProjectRoot) to walk for consumer
scripts. Defaults to @('Examples', 'tools') plus every *.ps1 at the
repo root. Pass an empty array to scan only the repo root.

.PARAMETER IncludeTests
Include Tests\ in the scan. Off by default. Useful as a sanity check
that the Pester suite touches every command at least once.

.EXAMPLE
.\tools\Audit-GEUsage.ps1

Default scan (Examples + tools + repo root, excludes Tests).

.EXAMPLE
.\tools\Audit-GEUsage.ps1 -IncludeTests

Include Tests\ - shows which commands have at least one Pester test
file invocation.

.EXAMPLE
.\tools\Audit-GEUsage.ps1 | Where-Object CallCount -eq 0

Pipe form: returns one PSCustomObject per exported command, so you
can pipe to Where-Object to find the unused set programmatically.

.NOTES
Companion to docs\PSGALLERY-METADATA-PLAYBOOK.md "Pre-publish
checklist". Run before a release to confirm the public surface is
demoed.

Uses [System.Management.Automation.Language.Parser] - the same AST
engine the existing manifest tests use - so there is no external
module dependency.
#>


[CmdletBinding()]
param(
    [string]   $ProjectRoot = 'C:\Sysadmin\Scripts\GitEasy',
    [string[]] $ScanPath    = @('Examples', 'tools'),
    [switch]   $IncludeTests
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# STATE CHECK
Write-Host ''
Write-Host 'STATE CHECK: GitEasy command-usage audit' -ForegroundColor Cyan

if (-not (Test-Path -LiteralPath $ProjectRoot -PathType Container)) {
    throw "Missing project folder: $ProjectRoot"
}

$manifestPath = Join-Path $ProjectRoot 'GitEasy.psd1'
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
    throw "Missing manifest: $manifestPath"
}

$manifest = Import-PowerShellDataFile -LiteralPath $manifestPath
$geCommands = @($manifest.FunctionsToExport | Sort-Object)

if ($geCommands.Count -eq 0) {
    throw "Manifest exports no functions - nothing to audit."
}

Write-Host "Manifest : $manifestPath"
Write-Host "Exported commands : $($geCommands.Count)"

# Resolve scan files
$consumerFiles = @()
foreach ($rel in $ScanPath) {
    $abs = Join-Path $ProjectRoot $rel
    if (Test-Path -LiteralPath $abs -PathType Container) {
        $consumerFiles += @(Get-ChildItem -LiteralPath $abs -Filter '*.ps1' -Recurse -File)
    }
}
$consumerFiles += @(Get-ChildItem -LiteralPath $ProjectRoot -Filter '*.ps1' -File)
if ($IncludeTests) {
    $testsPath = Join-Path $ProjectRoot 'Tests'
    if (Test-Path -LiteralPath $testsPath -PathType Container) {
        $consumerFiles += @(Get-ChildItem -LiteralPath $testsPath -Filter '*.ps1' -Recurse -File)
    }
}
$consumerFiles = @($consumerFiles | Sort-Object FullName -Unique)

if ($consumerFiles.Count -eq 0) {
    throw "No .ps1 files found in any scan path."
}

Write-Host "Scanned files : $($consumerFiles.Count)"
Write-Host ''

# AST sweep
$usage = [ordered]@{}
foreach ($c in $geCommands) { $usage[$c] = [System.Collections.Generic.List[string]]::new() }

foreach ($f in $consumerFiles) {
    $tokens = $null
    $parseErrors = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tokens, [ref]$parseErrors)
    if (@($parseErrors).Count -gt 0) {
        Write-Warning "Skipping (parse errors): $($f.FullName)"
        continue
    }
    $cmdNodes = $ast.FindAll({
        param($n) $n -is [System.Management.Automation.Language.CommandAst]
    }, $true)
    foreach ($cn in $cmdNodes) {
        $name = $cn.GetCommandName()
        if ($name -and $usage.Contains($name)) {
            $rel = $f.FullName.Substring($ProjectRoot.Length + 1)
            $usage[$name].Add($rel)
        }
    }
}

# Build result objects
$results = foreach ($name in $geCommands) {
    $calls = $usage[$name]
    [pscustomobject]@{
        Name      = $name
        CallCount = $calls.Count
        FileCount = @($calls | Sort-Object -Unique).Count
        Files     = @($calls | Sort-Object -Unique)
    }
}

# Console summary
$used   = @($results | Where-Object CallCount -gt 0 | Sort-Object @{e='CallCount';desc=$true}, Name)
$unused = @($results | Where-Object CallCount -eq 0 | Sort-Object Name)

Write-Host ("USED ({0,2} of {1}):" -f $used.Count, $geCommands.Count) -ForegroundColor Green
foreach ($u in $used) {
    Write-Host (" {0,-18} {1,3} call{2} across {3} file{4}" -f `
        $u.Name, $u.CallCount, $(if ($u.CallCount -eq 1) {' '} else {'s'}),
        $u.FileCount, $(if ($u.FileCount -eq 1) {' '} else {'s'}))
    foreach ($file in $u.Files) { Write-Host " $file" -ForegroundColor DarkGray }
}
Write-Host ''
Write-Host ("UNUSED ({0,2} of {1}) - exported but never referenced in the scan:" -f $unused.Count, $geCommands.Count) -ForegroundColor Yellow
foreach ($u in $unused) { Write-Host " $($u.Name)" }
Write-Host ''
Write-Host ("Coverage: {0} / {1} commands referenced ({2:N1}%)" -f `
    $used.Count, $geCommands.Count, (100 * $used.Count / $geCommands.Count)) -ForegroundColor Cyan

# Emit pipeable results so callers can post-process
$results