Skills/memory-bank/scripts/Test-MemoryBankHealth.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Tests Memory Bank structure, provenance, freshness, and compactness. .DESCRIPTION Validates seven required version-controlled files, optional local prompt history, routing mode, metadata, line budgets, Decision records, Memory Bank topics, and retention. The script does not modify the repository and returns one structured summary object. .PARAMETER Path Existing repository directory containing .memory-bank. .PARAMETER ReferenceDate UTC date used for deterministic freshness and retention calculations. .PARAMETER StaleAfterDays Age after which last-verified metadata produces a warning. .PARAMETER PromptHistoryRetentionDays Age after which a prompt-history entry produces a warning. .EXAMPLE ./Test-MemoryBankHealth.ps1 -Path C:/Git/MyProject Returns Memory Bank health findings without changing files. .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter()] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] [string]$Path = (Get-Location).Path, [Parameter()] [datetime]$ReferenceDate = [datetime]::UtcNow.Date, [Parameter()] [ValidateRange(1, 3650)] [int]$StaleAfterDays = 180, [Parameter()] [ValidateRange(1, 3650)] [int]$PromptHistoryRetentionDays = 90 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $issues = [Collections.Generic.List[object]]::new() function Add-MemoryBankIssue { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('Error', 'Warning')] [string]$Severity, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Code, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$File, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Message ) $issues.Add([PSCustomObject]@{ Severity = $Severity Code = $Code File = $File Message = $Message }) } function Get-MemoryBankRecordHeader { [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Content ) $metadata = @{} foreach ($key in @( 'schema-version' 'loading-mode' 'status' 'date' 'last-verified' 'owner' 'source' 'supersedes' )) { $pattern = '(?m)^{0}:\s*(?<value>.+?)\r?$' -f [regex]::Escape($key) if ($Content -match $pattern) { $metadata[$key] = $Matches.value.Trim() } } $metadata } $memoryBankPath = Join-Path $Path '.memory-bank' $canonicalFiles = [ordered]@{ 'index.md' = @{ LineBudget = 100; CharacterBudget = 7000 } 'projectbrief.md' = @{ LineBudget = 200 } 'productContext.md' = @{ LineBudget = 200 } 'activeContext.md' = @{ LineBudget = 200 } 'techContext.md' = @{ LineBudget = 200 } 'progress.md' = @{ LineBudget = 200 } 'systemPatterns.md' = @{ LineBudget = 110 } 'promptHistory.md' = @{} } $optionalCanonicalFiles = @('promptHistory.md') $requiredMetadata = @('status', 'last-verified', 'owner', 'source') $canonicalFileCount = 0 $indexLineCount = 0 $indexCharacterCount = 0 $memoryBankTopicCount = 0 $decisionRecordCount = 0 if (-not (Test-Path -LiteralPath $memoryBankPath -PathType Container)) { Add-MemoryBankIssue ` -Severity Error ` -Code 'MemoryBankMissing' ` -File '.memory-bank' ` -Message 'The repository has no .memory-bank directory.' } else { foreach ($entry in $canonicalFiles.GetEnumerator()) { $fileName = [string]$entry.Key $filePath = Join-Path $memoryBankPath $fileName if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { if ($fileName -notin $optionalCanonicalFiles) { Add-MemoryBankIssue ` -Severity Error ` -Code 'CanonicalFileMissing' ` -File $fileName ` -Message "Canonical file is missing: $fileName" } continue } $canonicalFileCount++ $content = Get-Content -LiteralPath $filePath -Raw $lineCount = @(Get-Content -LiteralPath $filePath).Count if ([string]::IsNullOrWhiteSpace($content)) { Add-MemoryBankIssue ` -Severity Error ` -Code 'CanonicalFileEmpty' ` -File $fileName ` -Message "Canonical file is empty: $fileName" } if ($entry.Value.ContainsKey('LineBudget') -and $lineCount -gt $entry.Value.LineBudget) { Add-MemoryBankIssue ` -Severity Error ` -Code 'LineBudgetExceeded' ` -File $fileName ` -Message ( '{0} has {1} lines; budget is {2}.' -f $fileName, $lineCount, $entry.Value.LineBudget ) } if ($entry.Value.ContainsKey('CharacterBudget') -and $content.Length -gt $entry.Value.CharacterBudget) { Add-MemoryBankIssue ` -Severity Error ` -Code 'CharacterBudgetExceeded' ` -File $fileName ` -Message ( '{0} has {1} characters; budget is {2}.' -f $fileName, $content.Length, $entry.Value.CharacterBudget ) } if ($fileName -eq 'index.md') { $indexLineCount = $lineCount $indexCharacterCount = $content.Length } $metadata = Get-MemoryBankRecordHeader -Content $content foreach ($key in $requiredMetadata) { if (-not $metadata.ContainsKey($key)) { Add-MemoryBankIssue ` -Severity Error ` -Code 'MetadataMissing' ` -File $fileName ` -Message "Required metadata is missing: $key" } } if ($metadata.ContainsKey('last-verified')) { $lastVerified = [datetime]::MinValue $parsed = [datetime]::TryParseExact( $metadata['last-verified'], 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal, [ref]$lastVerified ) if (-not $parsed) { Add-MemoryBankIssue ` -Severity Error ` -Code 'MetadataInvalid' ` -File $fileName ` -Message 'last-verified must use YYYY-MM-DD.' } elseif (($ReferenceDate.Date - $lastVerified.Date).Days -gt $StaleAfterDays) { Add-MemoryBankIssue ` -Severity Warning ` -Code 'VerificationStale' ` -File $fileName ` -Message ( 'last-verified is older than {0} days.' -f $StaleAfterDays ) } } if ($fileName -eq 'index.md') { if (-not $metadata.ContainsKey('loading-mode') -or $metadata['loading-mode'] -notin @('routed', 'full')) { Add-MemoryBankIssue ` -Severity Error ` -Code 'LoadingModeInvalid' ` -File $fileName ` -Message 'loading-mode must be routed or full.' } } } foreach ($directoryName in @('decisions', 'topics')) { $directoryPath = Join-Path $memoryBankPath $directoryName if (-not (Test-Path -LiteralPath $directoryPath -PathType Container)) { continue } $files = @( Get-ChildItem -LiteralPath $directoryPath -Filter '*.md' -File ` -Recurse | Where-Object Name -ne 'README.md' ) if ($directoryName -eq 'decisions') { $decisionRecordCount = $files.Count } else { $memoryBankTopicCount = $files.Count } foreach ($file in $files) { $relativePath = '{0}/{1}' -f $directoryName, ( $file.FullName.Substring($directoryPath.Length + 1) -replace '\\', '/' ) $content = Get-Content -LiteralPath $file.FullName -Raw $metadata = Get-MemoryBankRecordHeader -Content $content foreach ($key in $requiredMetadata) { if (-not $metadata.ContainsKey($key)) { Add-MemoryBankIssue ` -Severity Error ` -Code 'MetadataMissing' ` -File $relativePath ` -Message "Required metadata is missing: $key" } } } } $promptHistoryPath = Join-Path $memoryBankPath 'promptHistory.md' if (Test-Path -LiteralPath $promptHistoryPath -PathType Leaf) { $promptHistory = Get-Content -LiteralPath $promptHistoryPath -Raw $entryPattern = '(?m)^(?<date>\d{4}-\d{2}-\d{2}) ' + '\d{2}:\d{2} UTC \|' foreach ($match in [regex]::Matches($promptHistory, $entryPattern)) { $entryDate = [datetime]::ParseExact( $match.Groups['date'].Value, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture ) if (($ReferenceDate.Date - $entryDate.Date).Days -gt $PromptHistoryRetentionDays) { Add-MemoryBankIssue ` -Severity Warning ` -Code 'PromptHistoryExpired' ` -File 'promptHistory.md' ` -Message ( 'Entry from {0} exceeds the {1}-day retention.' -f $entryDate.ToString('yyyy-MM-dd'), $PromptHistoryRetentionDays ) } } } } $errorCount = @($issues | Where-Object Severity -eq 'Error').Count $warningCount = @($issues | Where-Object Severity -eq 'Warning').Count [PSCustomObject]@{ Passed = $errorCount -eq 0 ErrorCount = $errorCount WarningCount = $warningCount CanonicalFileCount = $canonicalFileCount LocalPromptHistoryPresent = Test-Path -LiteralPath ( Join-Path $memoryBankPath 'promptHistory.md' ) -PathType Leaf MemoryBankTopicCount = $memoryBankTopicCount DecisionRecordCount = $decisionRecordCount IndexLineCount = $indexLineCount IndexCharacterCount = $indexCharacterCount Issues = @($issues) } |