skills/changed-file-validation/scripts/Add-ChangedFile.ps1

#Requires -Version 5.1

<#
    .SYNOPSIS
        Adds explicitly named changed files to one validation batch.
 
    .DESCRIPTION
        Collection is manual and opt-in. Nothing observes the editor, no hook is
        wired, and no file is read beyond the guard that proves it sits inside
        the selected project. Repeated adds of the same file collapse onto one
        entry and increment an occurrence count, so a batch stays one entry per
        file no matter how often it was edited.
 
        A path outside the selected project, a traversal segment, a drive
        qualifier, and any path reached through a symbolic link, junction, or
        other reparse point are refused before anything is recorded.
 
    .PARAMETER Path
        The selected project root. Every collected path must resolve inside it.
 
    .PARAMETER ChangedPath
        One or more changed files, absolute or project-relative.
 
    .PARAMETER SessionId
        The batch this collection belongs to. Sessions are isolated from each
        other; the identifier is letters, digits, period, underscore, and hyphen
        only, so it can never name a path.
 
    .OUTPUTS
        PSCustomObject
 
    .EXAMPLE
        ./Add-ChangedFile.ps1 -Path $PWD.Path -ChangedPath 'source/Public/Get-Thing.ps1'
 
        Records one changed file in the default batch.
#>

[CmdletBinding()]
[OutputType([PSCustomObject])]
param
(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string]$Path,

    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string[]]$ChangedPath,

    [Parameter()]
    [AllowEmptyString()]
    [string]$SessionId = 'default'
)

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

. (Join-Path $PSScriptRoot 'ChangedFileValidationCommon.ps1')

$context = Resolve-ChangedFileContext -Path $Path
$session = Assert-ChangedFileSessionId -SessionId $SessionId

$requested = [Collections.Generic.List[string]]::new()
foreach ($candidate in $ChangedPath)
{
    $relative = Resolve-ChangedFileRelativePath -Context $context -InputPath $candidate
    if (-not $requested.Contains($relative))
    {
        $requested.Add($relative)
    }
}

$timestamp = [datetime]::UtcNow.ToString('o')
$lock = Enter-ChangedFileLock -Context $context
$recorded = [Collections.Generic.List[hashtable]]::new()

try
{
    # Re-read under the lock so a concurrent collector's entries are merged
    # rather than overwritten. A lost update silently drops a changed file.
    $store = Read-ChangedFileStore -Context $context
    if ($null -eq $store)
    {
        $store = New-ChangedFileStore -Context $context
    }

    if (-not $store['sessions'].ContainsKey($session))
    {
        $store['sessions'][$session] = @{
            sessionId = $session
            createdUtc = $timestamp
            updatedUtc = $timestamp
            entries = @()
        }
    }

    $entries = [Collections.Generic.List[hashtable]]::new()
    foreach ($entry in (Get-ChangedFileSessionEntry -Store $store -SessionId $session))
    {
        $entries.Add($entry)
    }

    foreach ($relative in $requested)
    {
        $existing = $entries | Where-Object { [string]$_['path'] -eq $relative } | Select-Object -First 1

        if ($null -eq $existing)
        {
            if ($entries.Count -ge $script:ChangedFileMaxEntryPerSession)
            {
                throw "Session '$session' already holds $script:ChangedFileMaxEntryPerSession changed files, which is the documented batch bound. Validate and clear the batch before collecting more."
            }

            $existing = @{
                path = $relative
                addedUtc = $timestamp
                updatedUtc = $timestamp
                occurrences = 0
                receipt = $null
            }

            $entries.Add($existing)
        }

        $existing['occurrences'] = [int]$existing['occurrences'] + 1
        $existing['updatedUtc'] = $timestamp
        $recorded.Add($existing)
    }

    $store['sessions'][$session]['entries'] = $entries.ToArray()
    $store['sessions'][$session]['updatedUtc'] = $timestamp

    Write-ChangedFileStore -Context $context -Store $store
}
finally
{
    Exit-ChangedFileLock -Handle $lock
}

foreach ($entry in $recorded)
{
    $relativePath = [string]$entry['path']
    $fullPath = Join-Path $context.RepositoryRoot (
        $relativePath -replace '/', [IO.Path]::DirectorySeparatorChar
    )

    $state = if (-not (Test-ChangedFileSupportedPath -RelativePath $relativePath))
    {
        'Unsupported'
    }
    elseif (-not (Test-Path -LiteralPath $fullPath -PathType Leaf))
    {
        'Missing'
    }
    else
    {
        'Collected'
    }

    [PSCustomObject]@{
        SessionId = $session
        RelativePath = $relativePath
        State = $state
        Occurrences = [int]$entry['occurrences']
        AddedUtc = [string]$entry['addedUtc']
        UpdatedUtc = [string]$entry['updatedUtc']
        StorePath = $context.StoreRelativePath
    }
}