skills/reviewed-learning-inbox/scripts/LearningInboxCommon.ps1
|
#Requires -Version 5.1 <# Shared helpers for the reviewed learning inbox. Dot-source this file from a workflow script; it defines functions only and performs no action on load. Every candidate field originates in an explicitly selected local artifact and is therefore untrusted observation, never policy. Intake and promotion both re-validate the same content rules, so editing the store by hand does not widen what may be written into a Customization. #> Set-StrictMode -Version Latest $script:LearningInboxSchemaVersion = 1 $script:LearningInboxRelativeRoot = '.memory-bank/learning-inbox' $script:LearningInboxLockFileName = '.candidates.lock' $script:LearningInboxLockTimeoutMs = 1000 $script:LearningInboxConfidenceNote = 'Confidence is a review label only. It is not a probability and not permission to apply the lesson.' # SHA-256 of each store file as it was last read in this process, so a write can # refuse to overwrite a mutation another writer made in between. $script:LearningInboxStoreState = @{} # Promotion may only append to an existing Skill body or Instruction body. $script:LearningInboxTargetPattern = @{ Skill = '^skills/[a-z0-9]+(-[a-z0-9]+)*/SKILL\.md$' Instruction = '^com\.github\.copilot/rules/[A-Za-z0-9._-]+\.instructions\.md$' } <# Printable text only. The excluded characters are the ones that turn stored observations into something other than prose when they reach a Markdown file: backtick and dollar (command substitution and fences), angle brackets (HTML and hook syntax), braces and pipes (YAML flow and expression syntax), brackets (link targets), backslash (escapes and path traversal). #> $script:LearningInboxAllowedText = '^[\p{L}\p{N} .,:;?!''"()/_+=%@#*-]+$' # Frontmatter keys that would grant capability or rescope a Customization. $script:LearningInboxPermissionKey = '(?i)(^|[\s(])(tools|allowed-tools|model|agents|handoffs|applyto|apply-to|name|description|target|context|compatibility|argument-hint)\s*:' function Get-LearningInboxFullPath { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath ) $fullPath = [IO.Path]::GetFullPath($LiteralPath) $pathRoot = [IO.Path]::GetPathRoot($fullPath) if ($fullPath.Length -eq $pathRoot.Length) { return $fullPath } return $fullPath.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) } function Get-LearningInboxPathComparison { [CmdletBinding()] [OutputType([StringComparison])] param () if ($env:OS -eq 'Windows_NT') { return [StringComparison]::OrdinalIgnoreCase } return [StringComparison]::Ordinal } function Test-LearningInboxPathEqual { [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Left, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Right ) return [string]::Equals($Left, $Right, (Get-LearningInboxPathComparison)) } function Test-LearningInboxReparsePoint { [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath ) $item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop return [bool]($item.Attributes -band [IO.FileAttributes]::ReparsePoint) } <# Lexical containment plus a component walk. Checking only the leaf leaves a junction or symbolic link on an intermediate directory able to redirect a read or a write outside the selected project, so every existing component from the selected root down to the leaf is inspected. Components that do not exist yet are skipped, which is what makes the same guard usable before a directory or a file is created. #> function Assert-LearningInboxRegularPath { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$RepositoryRoot, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Field ) $root = Get-LearningInboxFullPath -LiteralPath $RepositoryRoot $full = Get-LearningInboxFullPath -LiteralPath $LiteralPath $comparison = Get-LearningInboxPathComparison if (-not (Test-LearningInboxPathEqual -Left $full -Right $root) -and -not $full.StartsWith( ($root + [IO.Path]::DirectorySeparatorChar), $comparison )) { throw "$Field must stay inside the selected project '$root': '$full'." } $chain = [Collections.Generic.List[string]]::new() $current = $full while (-not (Test-LearningInboxPathEqual -Left $current -Right $root)) { $chain.Insert(0, $current) $parent = Split-Path -Parent $current if ([string]::IsNullOrEmpty($parent)) { throw "$Field must stay inside the selected project '$root': '$full'." } $current = Get-LearningInboxFullPath -LiteralPath $parent } $chain.Insert(0, $root) foreach ($component in $chain) { $item = Get-Item -LiteralPath $component -Force -ErrorAction SilentlyContinue if ($null -eq $item) { continue } if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$Field must not be reached through a symbolic link, junction, or other reparse point: '$component'." } } return $full } function Get-LearningInboxFileHash { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath ) return (Get-FileHash -LiteralPath $LiteralPath -Algorithm SHA256).Hash. ToLowerInvariant() } function Get-LearningInboxTextHash { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text ) $bytes = [Text.UTF8Encoding]::new($false).GetBytes( ($Text -replace "`r`n", "`n") ) $algorithm = [Security.Cryptography.SHA256]::Create() try { return ( $algorithm.ComputeHash($bytes) | ForEach-Object -Process { $_.ToString('x2') } ) -join '' } finally { $algorithm.Dispose() } } function Assert-LearningInboxRelativePath { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string]$RelativePath, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Field, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$RepositoryRoot ) $message = "$Field must be a project-relative path inside the selected project: '$RelativePath'." if ([string]::IsNullOrWhiteSpace($RelativePath)) { throw $message } $normalized = $RelativePath.Replace('\', '/').Trim() if ($normalized.Contains(':') -or $normalized.StartsWith('/') -or [IO.Path]::IsPathRooted($RelativePath) -or $normalized -match '(^|/)\.\.(/|$)' -or $normalized -match '(^|/)\.(/|$)' -or $normalized -match '//' -or $normalized.EndsWith('/')) { throw $message } $candidatePath = Get-LearningInboxFullPath -LiteralPath ( Join-Path $RepositoryRoot $normalized.Replace( '/', [IO.Path]::DirectorySeparatorChar ) ) $rootPrefix = (Get-LearningInboxFullPath -LiteralPath $RepositoryRoot) + [IO.Path]::DirectorySeparatorChar if (-not $candidatePath.StartsWith($rootPrefix, (Get-LearningInboxPathComparison))) { throw $message } $null = Assert-LearningInboxRegularPath ` -LiteralPath $candidatePath ` -RepositoryRoot $RepositoryRoot ` -Field $Field return $normalized } function Assert-LearningInboxText { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Field, [Parameter()] [ValidateRange(1, 2000)] [int]$MaximumLength = 400 ) $value = ([string]$Text).Trim() if ([string]::IsNullOrWhiteSpace($value)) { throw "$Field must not be empty." } if ($value.Length -gt $MaximumLength) { throw "$Field must be at most $MaximumLength characters; the inbox stores summaries, not documents." } if ($value -notmatch $script:LearningInboxAllowedText) { throw "$Field contains unsupported content: only single-line printable prose is stored, so code fences, shell substitution, markup, and control characters are refused." } if ($value -match $script:LearningInboxPermissionKey) { throw "$Field contains unsupported content: a capability or scope key such as tools, model, agents, or applyTo can never be carried by a candidate." } return $value } function Get-LearningInboxNormalizedLesson { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Lesson ) return (($Lesson -replace '\s+', ' ').Trim().TrimEnd('.') ).ToLowerInvariant() } function Resolve-LearningInboxContext { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Path ) $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if (Test-LearningInboxReparsePoint -LiteralPath $item.FullName) { throw "Project root must not be a symbolic link, junction, or reparse point: '$($item.FullName)'." } $repositoryRoot = Get-LearningInboxFullPath -LiteralPath $item.FullName $memoryBankPath = Join-Path $repositoryRoot '.memory-bank' $inboxPath = Join-Path $memoryBankPath 'learning-inbox' $projectKey = if ($env:OS -eq 'Windows_NT') { $repositoryRoot.ToLowerInvariant() } else { $repositoryRoot } return [PSCustomObject]@{ RepositoryRoot = $repositoryRoot MemoryBankPath = $memoryBankPath MemoryBankExists = Test-Path -LiteralPath $memoryBankPath -PathType Container InboxPath = $inboxPath StorePath = Join-Path $inboxPath 'candidates.json' StoreRelativePath = "$script:LearningInboxRelativeRoot/candidates.json" ProposalPath = Join-Path $inboxPath 'proposals' ProposalRelativePath = "$script:LearningInboxRelativeRoot/proposals" ProjectId = (Get-LearningInboxTextHash -Text $projectKey).Substring(0, 16) } } function Get-LearningInboxCandidateId { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$ProjectId, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$ScopeKey, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Lesson ) $material = '{0}|{1}|{2}' -f $ProjectId, $ScopeKey.ToLowerInvariant(), (Get-LearningInboxNormalizedLesson -Lesson $Lesson) return 'cand-' + (Get-LearningInboxTextHash -Text $material).Substring(0, 12) } function Get-LearningInboxStoreKey { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Context ) $key = Get-LearningInboxFullPath -LiteralPath $Context.StorePath if ($env:OS -eq 'Windows_NT') { return $key.ToLowerInvariant() } return $key } function Get-LearningInboxStoreState { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Context ) if (Test-Path -LiteralPath $Context.StorePath -PathType Leaf) { return Get-LearningInboxFileHash -LiteralPath $Context.StorePath } return '' } function Read-LearningInboxStore { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Context ) # Walk the whole chain first: a junction on .memory-bank or on the inbox # directory would otherwise satisfy Test-Path from outside the project. $null = Assert-LearningInboxRegularPath ` -LiteralPath $Context.StorePath ` -RepositoryRoot $Context.RepositoryRoot ` -Field 'The learning inbox store' $script:LearningInboxStoreState[(Get-LearningInboxStoreKey -Context $Context)] = Get-LearningInboxStoreState -Context $Context if (-not (Test-Path -LiteralPath $Context.StorePath -PathType Leaf)) { return $null } $store = Get-Content -LiteralPath $Context.StorePath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($store.schemaVersion -ne $script:LearningInboxSchemaVersion) { throw "Unsupported learning inbox schema version '$($store.schemaVersion)'. Expected $script:LearningInboxSchemaVersion." } $recordedRoot = Get-LearningInboxFullPath -LiteralPath ([string]$store.repositoryRoot) if (-not (Test-LearningInboxPathEqual -Left $recordedRoot -Right $Context.RepositoryRoot)) { throw "The learning inbox store records project '$recordedRoot', which does not match the selected project '$($Context.RepositoryRoot)'." } return $store } <# A bounded, fail-fast, per-inbox mutation lock. It is deliberately not a wait: a second writer that cannot take the lock within the timeout reports the contention and writes nothing, rather than blocking a review session. #> function Enter-LearningInboxLock { [CmdletBinding()] [OutputType([IO.FileStream])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Context, [Parameter()] [ValidateRange(1, 30000)] [int]$TimeoutMilliseconds = $script:LearningInboxLockTimeoutMs ) $lockPath = Join-Path $Context.InboxPath $script:LearningInboxLockFileName $null = Assert-LearningInboxRegularPath ` -LiteralPath $lockPath ` -RepositoryRoot $Context.RepositoryRoot ` -Field 'The learning inbox mutation lock' $deadline = [datetime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) while ($true) { try { return [IO.FileStream]::new( $lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None ) } catch [IO.IOException] { if ([datetime]::UtcNow -ge $deadline) { throw "Another reviewed learning inbox mutation holds the lock '$lockPath'. Nothing was written; wait for it to finish and run the command again." } Start-Sleep -Milliseconds 50 } } } function Exit-LearningInboxLock { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNull()] [IO.FileStream]$Handle ) $Handle.Dispose() } function Write-LearningInboxStore { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Context, [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Store ) if (-not $Context.MemoryBankExists) { throw "Memory Bank not found: '$($Context.MemoryBankPath)'. Initialize it before recording a learning candidate." } foreach ($directory in @($Context.MemoryBankPath, $Context.InboxPath)) { $null = Assert-LearningInboxRegularPath ` -LiteralPath $directory ` -RepositoryRoot $Context.RepositoryRoot ` -Field 'The learning inbox directory' if (Test-Path -LiteralPath $directory) { if (-not (Test-Path -LiteralPath $directory -PathType Container)) { throw "Learning inbox path is not a directory: '$directory'." } } else { New-Item -ItemType Directory -Path $directory -ErrorAction Stop | Out-Null } } $null = Assert-LearningInboxRegularPath ` -LiteralPath $Context.StorePath ` -RepositoryRoot $Context.RepositoryRoot ` -Field 'The learning inbox store' $key = Get-LearningInboxStoreKey -Context $Context $lock = Enter-LearningInboxLock -Context $Context try { $current = Get-LearningInboxStoreState -Context $Context if ($script:LearningInboxStoreState.ContainsKey($key) -and $script:LearningInboxStoreState[$key] -cne $current) { throw "The reviewed learning inbox store changed after it was read. Nothing was written; run the command again so the new candidate is recorded on top of the current store." } Write-LearningInboxFile ` -LiteralPath $Context.StorePath ` -Content ($Store | ConvertTo-Json -Depth 12) $script:LearningInboxStoreState[$key] = Get-LearningInboxStoreState -Context $Context } finally { Exit-LearningInboxLock -Handle $lock } } function Write-LearningInboxFile { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Content ) $text = ($Content -replace "`r`n", "`n").TrimEnd("`n") + "`n" $bytes = [Text.UTF8Encoding]::new($false).GetBytes($text) $directory = Split-Path -Parent $LiteralPath $temporaryPath = Join-Path $directory ( '.{0}.{1}.tmp' -f [IO.Path]::GetFileName($LiteralPath), [guid]::NewGuid().ToString('N') ) try { [IO.File]::WriteAllBytes($temporaryPath, $bytes) # Replace rather than truncate-and-write, so an interrupted write never # leaves a half-written store behind. NullString keeps PowerShell from # binding an empty backup path, which Replace rejects. if (Test-Path -LiteralPath $LiteralPath -PathType Leaf) { [IO.File]::Replace($temporaryPath, $LiteralPath, [NullString]::Value) } else { [IO.File]::Move($temporaryPath, $LiteralPath) } } finally { if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } } function New-LearningInboxCanonicalCandidate { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Candidate ) $decision = if ($null -eq $Candidate.decision) { $null } else { [PSCustomObject][ordered]@{ status = [string]$Candidate.decision.status reason = [string]$Candidate.decision.reason recordedUtc = [string]$Candidate.decision.recordedUtc } } return [PSCustomObject][ordered]@{ id = [string]$Candidate.id lesson = [string]$Candidate.lesson scope = [PSCustomObject][ordered]@{ kind = [string]$Candidate.scope.kind target = [string]$Candidate.scope.target overlap = [string]$Candidate.scope.overlap } evidence = @( foreach ($item in @($Candidate.evidence)) { [PSCustomObject][ordered]@{ path = [string]$item.path sha256 = [string]$item.sha256 lines = [string]$item.lines summary = [string]$item.summary } } ) observations = @($Candidate.observations | ForEach-Object -Process { [string]$_ }) interpretations = @($Candidate.interpretations | ForEach-Object -Process { [string]$_ }) contradictions = @($Candidate.contradictions | ForEach-Object -Process { [string]$_ }) confidence = [string]$Candidate.confidence status = [string]$Candidate.status supersededBy = [string]$Candidate.supersededBy supersedes = @($Candidate.supersedes | ForEach-Object -Process { [string]$_ }) occurrences = [int]$Candidate.occurrences createdUtc = [string]$Candidate.createdUtc decision = $decision } } function Get-LearningInboxCandidateHash { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Candidate ) $canonical = New-LearningInboxCanonicalCandidate -Candidate $Candidate return Get-LearningInboxTextHash -Text ( $canonical | ConvertTo-Json -Depth 12 -Compress ) } function Assert-LearningInboxCandidateContent { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Candidate, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$RepositoryRoot ) $null = Assert-LearningInboxText -Text ([string]$Candidate.lesson) -Field 'Lesson' -MaximumLength 400 foreach ($observation in @($Candidate.observations)) { $null = Assert-LearningInboxText -Text ([string]$observation) -Field 'Observation' -MaximumLength 300 } foreach ($interpretation in @($Candidate.interpretations)) { $null = Assert-LearningInboxText -Text ([string]$interpretation) -Field 'Interpretation' -MaximumLength 300 } foreach ($contradiction in @($Candidate.contradictions)) { $null = Assert-LearningInboxText -Text ([string]$contradiction) -Field 'Contradictory evidence' -MaximumLength 300 } if (-not [string]::IsNullOrWhiteSpace([string]$Candidate.scope.overlap)) { $null = Assert-LearningInboxText -Text ([string]$Candidate.scope.overlap) -Field 'Overlap' -MaximumLength 300 } foreach ($item in @($Candidate.evidence)) { $null = Assert-LearningInboxRelativePath ` -RelativePath ([string]$item.path) ` -Field 'Evidence path' ` -RepositoryRoot $RepositoryRoot if (([string]$item.sha256) -notmatch '^[0-9a-f]{64}$') { throw "Evidence content identity must be a SHA-256 value: '$($item.sha256)'." } if (-not [string]::IsNullOrWhiteSpace([string]$item.summary)) { $null = Assert-LearningInboxText -Text ([string]$item.summary) -Field 'Evidence summary' -MaximumLength 300 } if (-not [string]::IsNullOrWhiteSpace([string]$item.lines) -and ([string]$item.lines) -notmatch '^\d+(-\d+)?$') { throw "Evidence line range must be a single number or a numeric range: '$($item.lines)'." } } } function Assert-LearningInboxPromotionTarget { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Target, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$ScopeKind, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$RepositoryRoot ) if (-not $script:LearningInboxTargetPattern.ContainsKey($ScopeKind)) { throw "Scope kind '$ScopeKind' is a suggestion for review only and is not promotable. Only Skill and Instruction targets may be appended to." } $normalized = Assert-LearningInboxRelativePath ` -RelativePath $Target ` -Field 'Promotion target' ` -RepositoryRoot $RepositoryRoot if ($normalized -notmatch $script:LearningInboxTargetPattern[$ScopeKind]) { throw "Promotion target '$normalized' is outside the allowed $ScopeKind surface. Allowed pattern: $($script:LearningInboxTargetPattern[$ScopeKind])." } return $normalized } function New-LearningInboxBlock { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Candidate, [Parameter(Mandatory)] [ValidateSet('AppendSection', 'AppendEntry')] [string]$Mode ) $id = [string]$Candidate.id $line = [Collections.Generic.List[string]]::new() if ($Mode -eq 'AppendSection') { $line.Add('') $line.Add('<!-- reviewed-learning-inbox:section -->') $line.Add('## Reviewed lessons') $line.Add('') $line.Add('Promoted from the reviewed learning inbox after explicit human approval.') } $line.Add('') $line.Add("<!-- reviewed-learning-inbox:candidate $id -->") $line.Add("- $([string]$Candidate.lesson)") $line.Add(" - Scope: $([string]$Candidate.scope.kind) ($([string]$Candidate.scope.target))") foreach ($item in @($Candidate.evidence)) { $range = if ([string]::IsNullOrWhiteSpace([string]$item.lines)) { '' } else { " lines $([string]$item.lines)" } $summary = if ([string]::IsNullOrWhiteSpace([string]$item.summary)) { '' } else { " - $([string]$item.summary)" } $line.Add( " - Evidence: $([string]$item.path)$range (sha256 $(([string]$item.sha256).Substring(0, 12)))$summary" ) } foreach ($observation in @($Candidate.observations)) { $line.Add(" - Observation: $observation") } foreach ($interpretation in @($Candidate.interpretations)) { $line.Add(" - Interpretation: $interpretation") } foreach ($contradiction in @($Candidate.contradictions)) { $line.Add(" - Contradictory evidence: $contradiction") } $line.Add(" - Confidence label: $([string]$Candidate.confidence). $script:LearningInboxConfidenceNote") $line.Add("<!-- reviewed-learning-inbox:end $id -->") return ($line -join "`n") + "`n" } function New-LearningInboxPreview { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Candidate, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$TargetPath, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$TargetSha256, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Block, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Mode ) $line = [Collections.Generic.List[string]]::new() $line.Add('Reviewed learning inbox promotion preview') $line.Add("Candidate: $([string]$Candidate.id)") $line.Add("Target: $TargetPath") $line.Add("Target SHA-256: $TargetSha256") $line.Add("Mode: $Mode") $line.Add('') foreach ($blockLine in ($Block.TrimEnd("`n") -split "`n")) { $line.Add(('+ ' + $blockLine).TrimEnd()) } return ($line -join "`n") + "`n" } function Get-LearningInboxCandidate { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Store, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Id ) $match = @($Store.candidates | Where-Object -FilterScript { [string]$_.id -eq $Id }) if ($match.Count -eq 0) { throw "Candidate '$Id' was not found in the reviewed learning inbox for this project." } return $match[0] } <# Only strict UTF-8, with or without a byte order mark, is understood. Any other encoding is refused rather than silently rewritten, because reading a file as text and writing it back changes bytes the promotion never reviewed. #> function ConvertFrom-LearningInboxTargetByte { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]]$Byte, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath ) $hasByteOrderMark = $Byte.Length -ge 3 -and $Byte[0] -eq 0xEF -and $Byte[1] -eq 0xBB -and $Byte[2] -eq 0xBF if (-not $hasByteOrderMark -and $Byte.Length -ge 2 -and (($Byte[0] -eq 0xFF -and $Byte[1] -eq 0xFE) -or ($Byte[0] -eq 0xFE -and $Byte[1] -eq 0xFF))) { throw "The promotion destination '$LiteralPath' has an unsupported encoding. Only UTF-8, with or without a byte order mark, can be appended to; nothing was written." } $offset = if ($hasByteOrderMark) { 3 } else { 0 } try { $text = [Text.UTF8Encoding]::new($false, $true).GetString( $Byte, $offset, $Byte.Length - $offset ) } catch { throw "The promotion destination '$LiteralPath' has an unsupported encoding: it is not valid UTF-8 text. Nothing was written." } return [PSCustomObject]@{ Text = $text Byte = $Byte HasByteOrderMark = $hasByteOrderMark } } function Read-LearningInboxTargetFile { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath ) return ConvertFrom-LearningInboxTargetByte ` -Byte ([IO.File]::ReadAllBytes($LiteralPath)) ` -LiteralPath $LiteralPath } function Get-LearningInboxStreamByte { [CmdletBinding()] [OutputType([byte[]])] param ( [Parameter(Mandatory)] [ValidateNotNull()] [IO.FileStream]$Stream ) $length = [int]$Stream.Length $buffer = [byte[]]::new($length) $Stream.Position = 0 $read = 0 while ($read -lt $length) { $chunk = $Stream.Read($buffer, $read, $length - $read) if ($chunk -le 0) { break } $read += $chunk } return $buffer } function Get-LearningInboxByteHash { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]]$Byte ) $algorithm = [Security.Cryptography.SHA256]::Create() try { return ( $algorithm.ComputeHash($Byte) | ForEach-Object -Process { $_.ToString('x2') } ) -join '' } finally { $algorithm.Dispose() } } <# Appends through a single write-exclusive handle. The bytes that are hashed and the bytes that are appended to are the same bytes, so an edit made between an earlier read and this write is detected instead of overwritten, and the original byte prefix, including any byte order mark, survives untouched. A failed verification truncates back to the original length. #> function Add-LearningInboxTargetContent { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath, [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{64}$')] [string]$ExpectedSha256, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Block, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$RequiredMarker ) $stream = [IO.FileStream]::new( $LiteralPath, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None ) try { $original = Get-LearningInboxStreamByte -Stream $stream $previousSha256 = Get-LearningInboxByteHash -Byte $original if ($previousSha256 -cne $ExpectedSha256.ToLowerInvariant()) { throw "The promotion destination '$LiteralPath' changed between the review and the write. Nothing was written; generate a new proposal and review it again." } $state = ConvertFrom-LearningInboxTargetByte -Byte $original -LiteralPath $LiteralPath $usesCarriageReturn = $state.Text.Contains("`r`n") $appendBlock = if ($usesCarriageReturn) { $Block -replace "(?<!`r)`n", "`r`n" } else { $Block } $separator = if ($state.Text.EndsWith("`n")) { '' } elseif ($usesCarriageReturn) { "`r`n" } else { "`n" } $appendByte = [Text.UTF8Encoding]::new($false).GetBytes($separator + $appendBlock) $stream.Position = $original.Length $stream.Write($appendByte, 0, $appendByte.Length) $stream.Flush($true) $updated = Get-LearningInboxStreamByte -Stream $stream $prefixIntact = $updated.Length -ge $original.Length if ($prefixIntact) { for ($index = 0; $index -lt $original.Length; $index++) { if ($updated[$index] -ne $original[$index]) { $prefixIntact = $false break } } } $markerPresent = $prefixIntact -and (ConvertFrom-LearningInboxTargetByte -Byte $updated -LiteralPath $LiteralPath). Text.Contains($RequiredMarker) if (-not $prefixIntact -or -not $markerPresent) { $stream.SetLength($original.Length) $stream.Flush($true) throw "Promotion verification failed for '$LiteralPath'. The original bytes were restored and nothing was promoted." } return [PSCustomObject]@{ PreviousSha256 = $previousSha256 NewSha256 = Get-LearningInboxByteHash -Byte $updated AppendedByteCount = $appendByte.Length HasByteOrderMark = $state.HasByteOrderMark } } finally { $stream.Dispose() } } <# Returns the exact marker-delimited region for one candidate, or null when the destination carries none. A half-marker or a duplicate is an edit no promotion produces, so both are refused rather than interpreted. #> function Get-LearningInboxBlockRegion { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Content, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$CandidateId ) $start = "<!-- reviewed-learning-inbox:candidate $CandidateId -->" $end = "<!-- reviewed-learning-inbox:end $CandidateId -->" $normalized = $Content -replace "`r`n", "`n" $matched = [regex]::Matches( $normalized, ('(?s)' + [regex]::Escape($start) + '.*?' + [regex]::Escape($end)) ) if ($matched.Count -gt 1) { throw "The promotion destination carries $($matched.Count) blocks for candidate '$CandidateId'. A promotion never produces a duplicate; resolve it by hand before promoting again." } if ($matched.Count -eq 1) { return $matched[0].Value } if ($normalized.Contains($start) -or $normalized.Contains($end)) { throw "The promotion destination carries an incomplete reviewed-learning-inbox marker for candidate '$CandidateId'. A promotion never produces one, so the destination was edited by hand; resolve it before promoting again." } return $null } <# The candidate record is the authority for evidence. A proposal is an editable file, so an entry removed from it must not be able to skip the changed-evidence check while the candidate hash still matches. #> function Assert-LearningInboxEvidenceIdentity { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Candidate, [Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [object[]]$ProposalEvidence ) $expected = @( foreach ($item in @($Candidate.evidence)) { '{0}|{1}' -f ([string]$item.path), ([string]$item.sha256).ToLowerInvariant() } ) $actual = @( foreach ($item in @($ProposalEvidence)) { if ($null -eq $item) { continue } '{0}|{1}' -f ([string]$item.path), ([string]$item.sha256).ToLowerInvariant() } ) if ($expected.Count -ne $actual.Count) { throw "The promotion proposal lists $($actual.Count) evidence locators but the candidate record carries $($expected.Count). The candidate record is the authority; generate a new proposal and review it again." } for ($index = 0; $index -lt $expected.Count; $index++) { if ($expected[$index] -cne $actual[$index]) { throw "The promotion proposal evidence does not match the candidate record: expected '$($expected[$index])' but found '$($actual[$index])'. Generate a new proposal and review it again." } } } function Assert-LearningInboxEvidenceState { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNull()] [PSCustomObject]$Candidate, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$RepositoryRoot ) foreach ($item in @($Candidate.evidence)) { $relativePath = Assert-LearningInboxRelativePath ` -RelativePath ([string]$item.path) ` -Field 'Evidence path' ` -RepositoryRoot $RepositoryRoot $absolutePath = Join-Path $RepositoryRoot ( $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar) ) if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf)) { throw "The candidate evidence '$relativePath' is no longer present. Generate a new proposal and review it again." } if ((Get-LearningInboxFileHash -LiteralPath $absolutePath) -cne ([string]$item.sha256).ToLowerInvariant()) { throw "The candidate evidence '$relativePath' changed after the preview was produced. Generate a new proposal and review it again." } } } |