Skills/memory-bank/scripts/Test-MemoryBankRouting.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
    Evaluates Memory Bank routing against provenance-labeled task cases.
.DESCRIPTION
    Parses the routing table from .memory-bank/index.md, resolves the files for
    each inert task case, and compares routed context with the full-read
    fallback. Prompt text is never executed or treated as instructions.
.PARAMETER Path
    Repository root containing .memory-bank.
.PARAMETER EvalFile
    JSON file containing routing cases.
.PARAMETER LoadingMode
    Optional override for the index loading mode.
.OUTPUTS
    System.Management.Automation.PSCustomObject
#>


[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
    [string]$Path = (Get-Location).Path,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
    [string]$EvalFile = (Join-Path (Split-Path -Parent $PSScriptRoot) (
        'evals/routing-cases.json'
    )),

    [Parameter()]
    [ValidateSet('routed', 'full')]
    [string]$LoadingMode
)

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

function Get-MemoryBankRoutingTable {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
        [string]$IndexPath
    )

    $routingTable = @{}
    $routePattern = '^\|\s*`(?<route>[^`]+)`\s*\|.*?\|\s*(?<read>.*?)\s*\|$'
    foreach ($line in Get-Content -LiteralPath $IndexPath) {
        if ($line -notmatch $routePattern) {
            continue
        }

        $files = @(
            [regex]::Matches($matches.read, '`(?<file>[^`]+)`') |
                ForEach-Object { $_.Groups['file'].Value }
        )
        $routingTable[$matches.route] = $files
    }

    $routingTable
}

function Get-MemoryBankFileMetric {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
        [string]$LiteralPath
    )

    $content = Get-Content -LiteralPath $LiteralPath -Raw
    [PSCustomObject]@{
        Characters = $content.Length
        Lines = @(Get-Content -LiteralPath $LiteralPath).Count
        ApproximateTokens = [Math]::Ceiling($content.Length / 4)
    }
}

function Test-CaseProperty {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$Case,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Name
    )

    $Case.PSObject.Properties.Name -contains $Name
}

$repositoryPath = (Resolve-Path -LiteralPath $Path).Path
$memoryBankPath = Join-Path $repositoryPath '.memory-bank'
$indexPath = Join-Path $memoryBankPath 'index.md'
if (-not (Test-Path -LiteralPath $indexPath -PathType Leaf)) {
    throw "Memory Bank index not found: $indexPath"
}

$indexContent = Get-Content -LiteralPath $indexPath -Raw
$indexMode = if ($indexContent -match '(?m)^loading-mode:\s*(routed|full)\s*$') {
    $matches[1]
} else {
    'full'
}
$effectiveMode = if ($PSBoundParameters.ContainsKey('LoadingMode')) {
    $LoadingMode
} else {
    $indexMode
}

$requiredFullFileNames = @(
    'index.md'
    'projectbrief.md'
    'productContext.md'
    'activeContext.md'
    'techContext.md'
    'progress.md'
    'systemPatterns.md'
)
$fullFileNames = @($requiredFullFileNames)
$localPromptHistoryPresent = Test-Path -LiteralPath (
    Join-Path $memoryBankPath 'promptHistory.md'
) -PathType Leaf
$glossaryPresent = Test-Path -LiteralPath (
    Join-Path $memoryBankPath 'glossary.md'
) -PathType Leaf
if ($localPromptHistoryPresent) {
    $fullFileNames += 'promptHistory.md'
}
if ($glossaryPresent) {
    $fullFileNames += 'glossary.md'
}
$fullDecisionFiles = @(
    Get-ChildItem -LiteralPath (Join-Path $memoryBankPath 'decisions') `
        -Filter '*.md' -File -ErrorAction SilentlyContinue |
        Sort-Object Name |
        ForEach-Object { "decisions/$($_.Name)" }
)
$fullFileNames += $fullDecisionFiles
$optionalFileNames = @('promptHistory.md', 'glossary.md')

$missingFullFiles = @(
    $requiredFullFileNames | Where-Object {
        -not (Test-Path -LiteralPath (Join-Path $memoryBankPath $_) -PathType Leaf)
    }
)
if ($missingFullFiles.Count -gt 0) {
    throw "Complete Memory Bank base is missing: $($missingFullFiles -join ', ')"
}

$fullCharacters = 0
foreach ($fileName in $fullFileNames) {
    if ($fileName -eq 'promptHistory.md') {
        continue
    }
    $filePath = Join-Path $memoryBankPath $fileName
    if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) {
        continue
    }
    $metric = Get-MemoryBankFileMetric -LiteralPath $filePath
    $fullCharacters += $metric.Characters
}

$evalSet = Get-Content -LiteralPath $EvalFile -Raw | ConvertFrom-Json
$routingTable = Get-MemoryBankRoutingTable -IndexPath $indexPath
$coverageMisses = 0
$unexpectedHistoryLoads = 0
$fallbackFailures = 0
$routedCharacters = 0
$selectedLocalHistoryCharacters = 0
$details = @()
$stopwatch = [Diagnostics.Stopwatch]::StartNew()

foreach ($case in @($evalSet.cases)) {
    $expectFallback = (
        (Test-CaseProperty -Case $case -Name 'expectFallback') -and
        [bool]$case.expectFallback
    )
    $allowHistory = (
        (Test-CaseProperty -Case $case -Name 'allowHistory') -and
        [bool]$case.allowHistory
    )
    $durableWrite = (
        (Test-CaseProperty -Case $case -Name 'durableWrite') -and
        [bool]$case.durableWrite
    )

    $selectedFiles = [Collections.Generic.List[string]]::new()
    [void]$selectedFiles.Add('index.md')
    $usedFallback = $effectiveMode -eq 'full'

    if (-not $usedFallback) {
        foreach ($route in @($case.routes)) {
            if (-not $routingTable.ContainsKey([string]$route)) {
                $usedFallback = $true
                break
            }

            foreach ($reference in @($routingTable[[string]$route])) {
                if ($reference -eq 'decisions/*.md') {
                    if (-not (Test-CaseProperty -Case $case -Name 'decisionFiles')) {
                        $usedFallback = $true
                        break
                    }
                    foreach ($decisionFile in @($case.decisionFiles)) {
                        [void]$selectedFiles.Add(
                            "decisions/$([string]$decisionFile)"
                        )
                    }
                    continue
                }
                if ($reference -like '*`**') {
                    $matchesForReference = @(
                        Get-ChildItem -Path (
                            Join-Path $memoryBankPath $reference
                        ) -File -ErrorAction SilentlyContinue
                    )
                    if ($matchesForReference.Count -eq 0) {
                        $usedFallback = $true
                        break
                    }
                    foreach ($match in $matchesForReference) {
                        $relativePath = $match.FullName.Substring(
                            $memoryBankPath.Length
                        ).TrimStart('\', '/') -replace '\\', '/'
                        [void]$selectedFiles.Add($relativePath)
                    }
                } else {
                    [void]$selectedFiles.Add($reference)
                }
            }
        }

        if (Test-CaseProperty -Case $case -Name 'roleFiles') {
            foreach ($roleFile in @($case.roleFiles)) {
                [void]$selectedFiles.Add([string]$roleFile)
            }
        }
        if ($durableWrite) {
            [void]$selectedFiles.Add('activeContext.md')
        }

        foreach ($optionalFileName in $optionalFileNames) {
            $optionalPath = Join-Path $memoryBankPath $optionalFileName
            if (-not (Test-Path -LiteralPath $optionalPath -PathType Leaf)) {
                [void]$selectedFiles.Remove($optionalFileName)
            }
        }

        foreach ($selectedFile in @($selectedFiles)) {
            $candidatePath = Join-Path $memoryBankPath $selectedFile
            if (-not (Test-Path -LiteralPath $candidatePath -PathType Leaf)) {
                $usedFallback = $true
                break
            }
        }
    }

    if ($usedFallback) {
        $selectedFiles.Clear()
        foreach ($fileName in $fullFileNames) {
            [void]$selectedFiles.Add($fileName)
        }
    }

    $uniqueFiles = @($selectedFiles | Select-Object -Unique)
    foreach ($requiredFile in @($case.requiredFiles)) {
        if ($requiredFile -notin $uniqueFiles) {
            <#
                An optional file that is genuinely absent is not a routing
                failure. A clean checkout has no gitignored local prompt log,
                and the route cannot select a file that does not exist.
            #>

            $optionalAndAbsent = (
                $requiredFile -in $optionalFileNames -and
                -not (Test-Path -LiteralPath (
                    Join-Path $memoryBankPath $requiredFile
                ) -PathType Leaf)
            )

            if ($optionalAndAbsent) {
                continue
            }

            $coverageMisses++
        }
    }
    if (
        $effectiveMode -eq 'routed' -and
        -not $usedFallback -and
        -not $allowHistory -and
        'promptHistory.md' -in $uniqueFiles
    ) {
        $unexpectedHistoryLoads++
    }
    if ($effectiveMode -eq 'routed' -and $usedFallback -ne $expectFallback) {
        $fallbackFailures++
    }

    $caseCharacters = 0
    foreach ($selectedFile in $uniqueFiles) {
        $selectedPath = Join-Path $memoryBankPath $selectedFile
        if (-not (Test-Path -LiteralPath $selectedPath -PathType Leaf)) {
            continue
        }
        if ($selectedFile -eq 'promptHistory.md') {
            $selectedLocalHistoryCharacters += (
                Get-MemoryBankFileMetric -LiteralPath $selectedPath
            ).Characters
            continue
        }
        $metric = Get-MemoryBankFileMetric -LiteralPath $selectedPath
        $caseCharacters += $metric.Characters
    }
    $routedCharacters += $caseCharacters

    $details += [PSCustomObject]@{
        Id = $case.id
        Files = $uniqueFiles
        UsedFallback = $usedFallback
        ApproximateTokens = [Math]::Ceiling($caseCharacters / 4)
    }
}
$stopwatch.Stop()

$caseCount = @($evalSet.cases).Count
$fullTotalCharacters = $fullCharacters * $caseCount
$reductionPercent = if ($effectiveMode -eq 'full') {
    0
} else {
    [Math]::Round(
        (1 - ($routedCharacters / $fullTotalCharacters)) * 100,
        2
    )
}
$passed = (
    $coverageMisses -eq 0 -and
    $unexpectedHistoryLoads -eq 0 -and
    $fallbackFailures -eq 0
)

[PSCustomObject]@{
    Passed = $passed
    Mode = $effectiveMode
    CaseCount = $caseCount
    CoverageMisses = $coverageMisses
    UnexpectedHistoryLoads = $unexpectedHistoryLoads
    FallbackFailures = $fallbackFailures
    FullDecisionRecordCount = $fullDecisionFiles.Count
    LocalPromptHistoryPresent = $localPromptHistoryPresent
    BaselineApproximateTokens = [Math]::Ceiling($fullCharacters / 4)
    AverageRoutedApproximateTokens = [Math]::Ceiling(
        ($routedCharacters / $caseCount) / 4
    )
    AverageContextReductionPercent = $reductionPercent
    AverageSelectedLocalHistoryApproximateTokens = [Math]::Ceiling(
        ($selectedLocalHistoryCharacters / $caseCount) / 4
    )
    MetricScope = 'version-controlled-files'
    ResolutionMilliseconds = $stopwatch.ElapsedMilliseconds
    Details = $details
}