From 1958e69b821882e9ffc03370ad3b0e00d7ae3cdc Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 7 Jun 2026 12:03:17 -0700 Subject: [PATCH 1/2] test(pull-sdlc): assert intent-appropriate output streams (#194) Behavior-first tests: fatal messages land on the error stream (rc 2/3/5 preserved), advisories on the warning stream, and status flows through Write-Information (non-PSHOST records) rather than Write-Host. --- Pull-SDLC.ai.Tests.ps1 | 104 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index 44dd2c2..d16b581 100644 --- a/Pull-SDLC.ai.Tests.ps1 +++ b/Pull-SDLC.ai.Tests.ps1 @@ -2614,3 +2614,107 @@ Describe 'Test-LocalDriftOnManagedPaths (issue #178: EOL false positive)' { } } + +Describe 'Output stream assignment (issue #194)' { + + BeforeEach { + $script:streamRoot = Join-Path $TestDrive ("stream-" + [guid]::NewGuid().ToString('N')) + } + + # --- Category 1: fatal messages land on the error stream (rc preserved) --- + + It 'POLICY VIOLATION lands on the error stream and still returns rc=2' { + $fx = New-DiffReplayFixture -Root $script:streamRoot ` + -Seed { 'anchor body' | Out-File -Encoding utf8 CLAUDE.md -NoNewline } ` + -Tweak { 'upstream new body' | Out-File -Encoding utf8 CLAUDE.md -NoNewline } + 'consumer override' | Out-File -Encoding utf8 (Join-Path $fx.Consumer 'CLAUDE.md') -NoNewline + Push-Location $fx.Consumer + try { git add CLAUDE.md; git commit -q -m 'local edit to managed file' } finally { Pop-Location } + Set-SdlcSyncState -RepoRoot $fx.Consumer -Remote 'sdlc.ai' -Ref 'main' -Commit $fx.AnchorSha + Push-Location $fx.Consumer + try { git add .sdlc-ai-sync.json; git commit -q -m 'seed state' } finally { Pop-Location } + + $rc = Invoke-PullSDLC -RepoRoot $fx.Consumer -RemoteName 'sdlc.ai' -NoFetch -ErrorVariable streamErr 2>$null + $rc | Should -Be 2 + ($streamErr -join "`n") | Should -Match 'POLICY VIOLATION' + } + + It 'ABORT commit-context lands on the error stream and still returns rc=3' { + $fx = New-DiffReplayFixture -Root $script:streamRoot ` + -Seed { 'a' | Out-File -Encoding utf8 CLAUDE.md -NoNewline } ` + -Tweak { 'b' | Out-File -Encoding utf8 CLAUDE.md -NoNewline } + '{"remote":"sdlc.ai","ref":"main","lastSyncCommit":"abc","syncedAt":"2026-01-01T00:00:00Z"}' | + Out-File -Encoding utf8 -LiteralPath (Join-Path $fx.Consumer '.sdlc-ai-sync.json') -NoNewline + Push-Location $fx.Consumer + try { + git checkout -q main + git remote add origin https://example.invalid/owner/repo.git + } finally { Pop-Location } + + $rc = Invoke-PullSDLC -RepoRoot $fx.Consumer -RemoteName 'sdlc.ai' -Bootstrap -NoFetch -NoAutoWorktree -ErrorVariable streamErr 2>$null + $rc | Should -Be 3 + ($streamErr -join "`n") | Should -Match 'ABORT: cannot create sync commit' + } + + It 'no git repo with -NoAutoInit lands on the error stream and still returns rc=5' { + $empty = Join-Path $TestDrive ("nogit-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $empty -Force | Out-Null + Push-Location $empty + try { + $rc = Invoke-PullSDLC -NoAutoInit -NoFetch -ErrorVariable streamErr 2>$null + } finally { Pop-Location } + $rc | Should -Be 5 + ($streamErr -join "`n") | Should -Match 'not inside a git repository' + } + + # --- Category 2: non-fatal advisories land on the warning stream --- + + It 'the -Force overwrite advisory lands on the warning stream' { + $fx = New-DiffReplayFixture -Root $script:streamRoot ` + -Seed { 'anchor body' | Out-File -Encoding utf8 CLAUDE.md -NoNewline } ` + -Tweak { 'upstream new body' | Out-File -Encoding utf8 CLAUDE.md -NoNewline } + 'consumer override' | Out-File -Encoding utf8 (Join-Path $fx.Consumer 'CLAUDE.md') -NoNewline + Push-Location $fx.Consumer + try { git add CLAUDE.md; git commit -q -m 'local edit to managed file' } finally { Pop-Location } + Set-SdlcSyncState -RepoRoot $fx.Consumer -Remote 'sdlc.ai' -Ref 'main' -Commit $fx.AnchorSha + Push-Location $fx.Consumer + try { git add .sdlc-ai-sync.json; git commit -q -m 'seed state' } finally { Pop-Location } + + $rc = Invoke-PullSDLC -RepoRoot $fx.Consumer -RemoteName 'sdlc.ai' -NoFetch -Force -WarningVariable streamWarn -WarningAction SilentlyContinue + $rc | Should -Be 0 + ($streamWarn -join "`n") | Should -Match '-Force in effect' + } + + It 'Bootstrap declined lands on the warning stream and returns rc=1' { + $fx = New-DiffReplayFixture -Root $script:streamRoot ` + -Seed { 'a' | Out-File -Encoding utf8 CLAUDE.md -NoNewline } + Mock -CommandName Resolve-SyncAnchor -MockWith { $null } + + $rc = Invoke-PullSDLC -RepoRoot $fx.Consumer -RemoteName 'sdlc.ai' -Bootstrap -NoFetch -WarningVariable streamWarn -WarningAction SilentlyContinue + $rc | Should -Be 1 + ($streamWarn -join "`n") | Should -Match 'Bootstrap declined' + } + + # --- Category 3: status/progress flow through Write-Information (not Write-Host) --- + + It 'sync status messages flow through Write-Information, not Write-Host' { + $fx = New-DiffReplayFixture -Root $script:streamRoot ` + -Seed { + New-Item -ItemType Directory -Path .github/agents -Force | Out-Null + 'baseline-claude' | Out-File -Encoding utf8 CLAUDE.md -NoNewline + 'aaa' | Out-File -Encoding utf8 .github/agents/a.md -NoNewline + } + + $rc = Invoke-PullSDLC -RepoRoot $fx.Consumer -RemoteName 'sdlc.ai' -Bootstrap -NoFetch -InformationVariable streamInfo 6>$null + $rc | Should -Be 0 + + $commitRec = $streamInfo | Where-Object { "$($_.MessageData)" -match 'Created sync commit' } | Select-Object -First 1 + $commitRec | Should -Not -BeNullOrEmpty + # Must be a genuine Write-Information record, NOT Write-Host (which tags PSHOST). + $commitRec.Tags | Should -Not -Contain 'PSHOST' + + $appliedRec = $streamInfo | Where-Object { "$($_.MessageData)" -match 'Applied 2 ops' } | Select-Object -First 1 + $appliedRec | Should -Not -BeNullOrEmpty + $appliedRec.Tags | Should -Not -Contain 'PSHOST' + } +} From f93da3a15b574d5077c553fe5c17df6315b353a5 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 7 Jun 2026 12:03:17 -0700 Subject: [PATCH 2/2] refactor(pull-sdlc): route output to intent-appropriate streams (#194) Map Write-Host calls to Write-Error/Write-Warning/Write-Information per the Output & Streams guidance, preserving every exit/return code (Write-Error -ErrorAction Continue keeps non-terminating under \Continue='Stop'). Set \SilentlyContinue='Continue' to keep status visible by default. Keep the decorative dry-run preview, Next-steps banner, and SSH-setup transcript on Write-Host, extracted/annotated with narrow PSAvoidUsingWriteHost suppressions. --- Pull-SDLC.ai.ps1 | 229 +++++++++++++----------- docs/designs/194-output-streams-plan.md | 37 ++++ 2 files changed, 165 insertions(+), 101 deletions(-) create mode 100644 docs/designs/194-output-streams-plan.md diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index 904ebd8..360a379 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -122,6 +122,12 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Status/progress is emitted via Write-Information (stream 6) per the +# "Output & Streams" guidance. The Information stream defaults to +# SilentlyContinue, so opt it into visible-by-default here to preserve the +# script's historical console output. Callers can still silence or capture it +# with -InformationAction / 6>. +$InformationPreference = 'Continue' # --- Sync semantics -------------------------------------------------------- # @@ -784,7 +790,7 @@ function Invoke-MainTreeCleanup { if ($PSCmdlet.ShouldProcess($path, 'Remove untracked-and-identical file')) { Remove-Item -LiteralPath $abs -Force $msg = "Cleanup: removed untracked '$path' (byte-identical to upstream; PR merge will restore tracked)." - Write-Host $msg -ForegroundColor DarkGray + Write-Information $msg $actions.Add($msg) | Out-Null } } @@ -802,7 +808,7 @@ function Invoke-MainTreeCleanup { if ($PSCmdlet.ShouldProcess($path, 'Revert tracked-and-modified-to-identical file')) { & git checkout -- $path 2>$null | Out-Null $msg = "Cleanup: reverted '$path' to HEAD (working tree was modified but identical to upstream)." - Write-Host $msg -ForegroundColor DarkGray + Write-Information $msg $actions.Add($msg) | Out-Null } } @@ -943,23 +949,22 @@ function Resolve-SyncAnchor { } finally { Pop-Location } if ($grep) { - Write-Host "Anchor: using commit $grep (matched 'chore: sync IntelliSDLC...' in git log)." -ForegroundColor DarkGray + Write-Information "Anchor: using commit $grep (matched 'chore: sync IntelliSDLC...' in git log)." return @{ Sha = ($grep | Select-Object -First 1).Trim(); Source = 'grep' } } $absRepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path if (Test-NoManagedFilesPresent -RepoRoot $absRepoRoot) { - Write-Host '' - Write-Host '[Bootstrap] No prior sync detected and no upstream-managed files present.' -ForegroundColor Cyan - Write-Host '[Bootstrap] Performing initial sync from upstream HEAD (empty-tree anchor).' -ForegroundColor Cyan + Write-Information '' + Write-Information '[Bootstrap] No prior sync detected and no upstream-managed files present.' + Write-Information '[Bootstrap] Performing initial sync from upstream HEAD (empty-tree anchor).' return @{ Sha = ''; Source = 'auto-bootstrap' } } if ($NoPrompt) { return @{ Sha = ''; Source = 'bootstrap' } } - Write-Host '' - Write-Host 'No .sdlc-ai-sync.json and no prior sync commit found.' -ForegroundColor Yellow - Write-Host 'Existing upstream-managed files were detected; bootstrap will overwrite them.' -ForegroundColor Yellow - Write-Host 'Bootstrap will perform a full refresh from upstream HEAD (empty-tree anchor).' -ForegroundColor Yellow + Write-Warning 'No .sdlc-ai-sync.json and no prior sync commit found.' + Write-Warning 'Existing upstream-managed files were detected; bootstrap will overwrite them.' + Write-Warning 'Bootstrap will perform a full refresh from upstream HEAD (empty-tree anchor).' $ans = Read-Host 'Proceed with bootstrap? [y/N]' if ($ans -match '^[Yy]') { return @{ Sha = ''; Source = 'bootstrap' } @@ -1254,7 +1259,7 @@ function Invoke-AutoWorktreeSync { finally { if ((Get-Location).Path -eq $absWorktree) { Pop-Location } } if (-not $reusing) { - Write-Host "Discarding stale worktree directory '$WorktreePath' (marker does not belong to this repo)." -ForegroundColor DarkGray + Write-Information "Discarding stale worktree directory '$WorktreePath' (marker does not belong to this repo)." # Try a clean git-worktree removal first (works when this repo # has a stale registration for the path). Fall back to a raw # filesystem delete when the directory is orphaned. @@ -1271,16 +1276,16 @@ function Invoke-AutoWorktreeSync { $dirty = git status --porcelain $aheadOfMain = git log "$ProtectedBranch..HEAD" --oneline 2>$null if ($dirty -or $aheadOfMain) { - Write-Host "Resetting reused worktree '$WorktreePath' to '$ProtectedBranch' (scratch area; prior state discarded)." -ForegroundColor DarkGray + Write-Information "Resetting reused worktree '$WorktreePath' to '$ProtectedBranch' (scratch area; prior state discarded)." git reset --hard $ProtectedBranch 2>&1 | Out-Null git clean -fdx 2>&1 | Out-Null } else { - Write-Host "Reusing existing worktree '$WorktreePath' (clean)." -ForegroundColor DarkGray + Write-Information "Reusing existing worktree '$WorktreePath' (clean)." } } finally { if ((Get-Location).Path -eq $absWorktree) { Pop-Location } } } else { - Write-Host "Creating worktree '$WorktreePath' on '$SyncBranch' from '$ProtectedBranch' ..." -ForegroundColor DarkGray + Write-Information "Creating worktree '$WorktreePath' on '$SyncBranch' from '$ProtectedBranch' ..." $branchListing = git branch --list $SyncBranch 2>$null $branchExists = $branchListing -ne $null -and ("$branchListing".Trim() -ne '') if ($branchExists) { @@ -1294,12 +1299,12 @@ function Invoke-AutoWorktreeSync { Push-Location $absWorktree try { - Write-Host "Running sync inside worktree (branch '$SyncBranch') ..." -ForegroundColor DarkGray + Write-Information "Running sync inside worktree (branch '$SyncBranch') ..." $args = @{} + $SyncArgs $args['RepoRoot'] = $absWorktree $rc = Invoke-PullSDLC @args if ($rc -ne 0) { - Write-Host "Worktree sync returned rc=$rc. Aborting auto-PR." -ForegroundColor Red + Write-Error "Worktree sync returned rc=$rc. Aborting auto-PR." -ErrorAction Continue return 6 } @@ -1313,12 +1318,12 @@ function Invoke-AutoWorktreeSync { } $newCommits = git log "$ProtectedBranch..HEAD" --oneline 2>$null if (-not $newCommits) { - Write-Host 'No new commits to push (worktree already in sync with main). Nothing to PR.' -ForegroundColor DarkGray + Write-Information 'No new commits to push (worktree already in sync with main). Nothing to PR.' return 0 } if ($needsPush) { - Write-Host "Pushing '$SyncBranch' to origin ..." -ForegroundColor DarkGray + Write-Information "Pushing '$SyncBranch' to origin ..." git push -u origin $SyncBranch 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { # 'chore/sdlc-sync' is a pure scratch branch: every run @@ -1330,27 +1335,27 @@ function Invoke-AutoWorktreeSync { # --force-with-lease. We use --force-with-lease (NOT plain # --force) so we still abort if a *different* writer pushed # to the remote since we last fetched. - Write-Host "Remote '$SyncBranch' diverged; force-pushing scratch branch ..." -ForegroundColor DarkGray + Write-Information "Remote '$SyncBranch' diverged; force-pushing scratch branch ..." git push --force-with-lease -u origin $SyncBranch 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { - Write-Host "WARNING: git push failed (including --force-with-lease retry); PR step skipped." -ForegroundColor Yellow + Write-Warning 'git push failed (including --force-with-lease retry); PR step skipped.' return 0 } } } if ($NoAutoPR) { - Write-Host "Skipping PR creation (-NoAutoPR)." -ForegroundColor DarkGray - Write-Host "Open one manually: gh pr create --base $ProtectedBranch --head $SyncBranch" -ForegroundColor Yellow + Write-Information "Skipping PR creation (-NoAutoPR)." + Write-Warning "Open one manually: gh pr create --base $ProtectedBranch --head $SyncBranch" return 0 } $ghCmd = Get-Command gh -ErrorAction SilentlyContinue if (-not $ghCmd) { - Write-Host 'gh CLI not found; skipping automatic PR creation.' -ForegroundColor Yellow + Write-Warning 'gh CLI not found; skipping automatic PR creation.' $remoteUrl = (git remote get-url origin).Trim() $compareUrl = $remoteUrl -replace '\.git$','' -replace '^git@github\.com:','https://github.com/' - Write-Host "Open one manually: $compareUrl/compare/${ProtectedBranch}...${SyncBranch}?expand=1" -ForegroundColor Yellow + Write-Warning "Open one manually: $compareUrl/compare/${ProtectedBranch}...${SyncBranch}?expand=1" return 0 } @@ -1381,7 +1386,7 @@ function Invoke-AutoWorktreeSync { -ProtectedBranch $ProtectedBranch ` -OriginUrl $originUrlForGh if ($prPlan.Action -eq 'Skip') { - Write-Host "Cannot open PR: $($prPlan.Reason)" -ForegroundColor Yellow + Write-Warning "Cannot open PR: $($prPlan.Reason)" return 0 } $ghRepoArgs = $prPlan.GhRepoArgs @@ -1389,7 +1394,7 @@ function Invoke-AutoWorktreeSync { # Is there already an open PR for this branch? $existingPr = gh @ghRepoArgs pr list --head $SyncBranch --base $ProtectedBranch --state open --json url 2>$null | ConvertFrom-Json if ($existingPr -and $existingPr.Count -gt 0) { - Write-Host "Existing PR updated: $($existingPr[0].url)" -ForegroundColor Green + Write-Information "Existing PR updated: $($existingPr[0].url)" return 0 } @@ -1401,9 +1406,9 @@ function Invoke-AutoWorktreeSync { try { $prUrl = gh @ghRepoArgs pr create --base $ProtectedBranch --head $SyncBranch --title $title --body-file $bodyFile 2>&1 | Select-Object -Last 1 if ($LASTEXITCODE -eq 0 -and $prUrl) { - Write-Host "Opened PR: $prUrl" -ForegroundColor Green + Write-Information "Opened PR: $prUrl" } else { - Write-Host "PR creation failed: $prUrl" -ForegroundColor Yellow + Write-Warning "PR creation failed: $prUrl" } } finally { Remove-Item $bodyFile -ErrorAction SilentlyContinue } @@ -1511,7 +1516,7 @@ function Invoke-SelfRefresh { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue -WhatIf:$false -Confirm:$false return '' } - Write-Host ("Self-updated Pull-SDLC.ai.ps1 from {0} to {1}; re-running with original args" -f $localHash.Substring(0, 7), $remoteHash.Substring(0, 7)) -ForegroundColor Cyan + Write-Information ("Self-updated Pull-SDLC.ai.ps1 from {0} to {1}; re-running with original args" -f $localHash.Substring(0, 7), $remoteHash.Substring(0, 7)) Write-Verbose "Self-refresh: updated $($localHash.Substring(0,7)) -> $($remoteHash.Substring(0,7))" return $tmp } @@ -1571,6 +1576,8 @@ function Write-NextStepsBanner { and -- when no `origin` is configured -- the `gh repo create` hint for brand-new projects. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'Decorative interactive "Next steps" banner -- sanctioned host UX per powershell.instructions.md Output & Streams.')] [CmdletBinding()] param( [Parameter(Mandatory)][string]$RepoRoot, @@ -1728,6 +1735,8 @@ function Add-GitConfigValueIfMissing { the value is not already present. Returns $true when an add happened, $false when the value already existed. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'Decorative [skip]/[add] setup transcript -- sanctioned host UX per powershell.instructions.md Output & Streams.')] [CmdletBinding(SupportsShouldProcess)] [OutputType([bool])] param( @@ -1766,11 +1775,13 @@ function Invoke-SetupGitHubSsh { Do not call `gh ssh-key add`. Useful when the key is already registered out-of-band, or for CI/test scenarios. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'Decorative [skip]/[add]/[warn] setup transcript -- sanctioned host UX per powershell.instructions.md Output & Streams.')] [CmdletBinding(SupportsShouldProcess)] param([switch]$SkipKeyUpload) if (-not $IsWindows) { - Write-Host 'Invoke-SetupGitHubSsh: non-Windows host -- out of scope (issue #164). Skipping.' -ForegroundColor Yellow + Write-Warning 'Invoke-SetupGitHubSsh: non-Windows host -- out of scope (issue #164). Skipping.' return } @@ -1898,6 +1909,57 @@ function Invoke-SetupGitHubSsh { } } +function Write-PlannedOpsPreview { + <# + .SYNOPSIS + Renders the decorative "Files to update" dry-run preview (header, + per-op-kind counts, and word-coded op rows) to the host. + .DESCRIPTION + This is the sanctioned `Write-Host` exception from the "Output & + Streams" guidance: an intentional, colorized, interactive console + preview that is never meant to be captured as pipeline data. Keeping + it in a dedicated helper lets the PSAvoidUsingWriteHost suppression + stay narrowly scoped to the preview instead of the whole sync engine. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'Decorative colorized dry-run preview -- sanctioned host UX per powershell.instructions.md Output & Streams.')] + [CmdletBinding()] + param( + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Ops, + [Parameter(Mandatory)][string]$AnchorLabel, + [Parameter(Mandatory)][string]$UpstreamLabel + ) + Write-Host '' + if ($Ops.Count -eq 0) { + Write-Host "Files to update: 0 (already at upstream $UpstreamLabel -- nothing to sync)" -ForegroundColor Cyan + } + else { + Write-Host "Files to update: $($Ops.Count) (syncing $AnchorLabel -> $UpstreamLabel)" -ForegroundColor Cyan + } + $grouped = $Ops | Group-Object { $_.Op } | Sort-Object Name + foreach ($g in $grouped) { + Write-Host (" {0}: {1}" -f $g.Name, $g.Count) -ForegroundColor Cyan + } + foreach ($op in $Ops) { + $word = switch -Regex ($op.Op) { + '^A$' { 'add' ; break } + '^M$' { 'update' ; break } + '^D$' { 'delete' ; break } + '^R' { 'rename' ; break } + '^C' { 'copy' ; break } + default { $op.Op } + } + $col = $word.PadRight(8) + $label = if ($op.Op -like 'R*' -or $op.Op -like 'C*') { + "{0}{1} -> {2}" -f $col, $op.OldPath, $op.Path + } + else { + "{0}{1}" -f $col, $op.Path + } + Write-Host " $label" -ForegroundColor DarkGray + } +} + function Invoke-PullSDLC { <# .SYNOPSIS @@ -1928,14 +1990,14 @@ function Invoke-PullSDLC { $topLevel = git rev-parse --show-toplevel 2>$null if (-not $topLevel) { if ($NoAutoInit) { - Write-Host 'ERROR: not inside a git repository and -NoAutoInit specified. Run `git init` first or omit -NoAutoInit.' -ForegroundColor Red + Write-Error 'ERROR: not inside a git repository and -NoAutoInit specified. Run `git init` first or omit -NoAutoInit.' -ErrorAction Continue return 5 } - Write-Host "[Bootstrap] No git repository detected. Running 'git init -b $Branch' in the current directory..." -ForegroundColor Cyan + Write-Information "[Bootstrap] No git repository detected. Running 'git init -b $Branch' in the current directory..." git init -b $Branch | Out-Null $topLevel = git rev-parse --show-toplevel 2>$null if (-not $topLevel) { - Write-Host 'ERROR: git init failed; cannot continue.' -ForegroundColor Red + Write-Error 'ERROR: git init failed; cannot continue.' -ErrorAction Continue return 5 } } @@ -1956,20 +2018,19 @@ function Invoke-PullSDLC { $ctx = Test-CommitContextAllowed -RepoRoot $RepoRoot -ProtectedBranch $Branch if (-not $ctx.Allowed) { if ($NoAutoWorktree) { - Write-Host '' - Write-Host "ABORT: cannot create sync commit -- $($ctx.Reason)." -ForegroundColor Red - Write-Host '' - Write-Host 'Create a worktree first:' -ForegroundColor Yellow - Write-Host ' git worktree add .worktrees/sdlc-sync -b chore/sdlc-sync main' -ForegroundColor Yellow - Write-Host ' cd .worktrees/sdlc-sync' -ForegroundColor Yellow - Write-Host ' ../../Pull-SDLC.ai.ps1' -ForegroundColor Yellow - Write-Host '' - Write-Host 'Or rerun with -CommitOnMain to commit the sync directly on the protected branch.' -ForegroundColor DarkGray - Write-Host 'Use -WhatIf to preview ops without committing.' -ForegroundColor DarkGray + Write-Error "ABORT: cannot create sync commit -- $($ctx.Reason)." -ErrorAction Continue + Write-Information '' + Write-Information 'Create a worktree first:' + Write-Information ' git worktree add .worktrees/sdlc-sync -b chore/sdlc-sync main' + Write-Information ' cd .worktrees/sdlc-sync' + Write-Information ' ../../Pull-SDLC.ai.ps1' + Write-Information '' + Write-Information 'Or rerun with -CommitOnMain to commit the sync directly on the protected branch.' + Write-Information 'Use -WhatIf to preview ops without committing.' return 3 } - Write-Host "On protected branch '$($ctx.Branch)'. Switching to auto-worktree workflow ..." -ForegroundColor Cyan + Write-Information "On protected branch '$($ctx.Branch)'. Switching to auto-worktree workflow ..." $syncArgs = @{ Branch = $Branch RemoteName = $RemoteName @@ -1995,11 +2056,11 @@ function Invoke-PullSDLC { try { $existingUrl = git remote get-url $RemoteName 2>$null if (-not $existingUrl) { - Write-Host "Adding remote '$RemoteName' -> $RemoteUrl" + Write-Information "Adding remote '$RemoteName' -> $RemoteUrl" git remote add $RemoteName $RemoteUrl } if (-not $NoFetch) { - Write-Host "Fetching $RemoteName ..." -ForegroundColor DarkGray + Write-Information "Fetching $RemoteName ..." git fetch $RemoteName --quiet } } @@ -2009,7 +2070,7 @@ function Invoke-PullSDLC { $anchorInfo = Resolve-SyncAnchor -RepoRoot $RepoRoot -Bootstrap:$Bootstrap -NoPrompt:$NoPrompt if ($null -eq $anchorInfo) { - Write-Host 'Bootstrap declined. Nothing to do.' -ForegroundColor Yellow + Write-Warning 'Bootstrap declined. Nothing to do.' return 1 } $anchorSha = $anchorInfo.Sha @@ -2018,22 +2079,20 @@ function Invoke-PullSDLC { $drift = @(Test-LocalDriftOnManagedPaths -Anchor $anchorSha -ManagedPaths $script:UpstreamManagedPaths -RepoRoot $RepoRoot) if ($drift.Count -gt 0) { if (-not $Force) { - Write-Host '' - Write-Host 'POLICY VIOLATION: upstream-managed files have local edits since last sync.' -ForegroundColor Red + $violation = New-Object System.Text.StringBuilder + [void]$violation.AppendLine('POLICY VIOLATION: upstream-managed files have local edits since last sync.') foreach ($d in $drift) { - Write-Host " - $($d.Path) (introduced by: $($d.Commit))" -ForegroundColor Red + [void]$violation.AppendLine(" - $($d.Path) (introduced by: $($d.Commit))") } - Write-Host '' - Write-Host 'Rerun with -Force to overwrite these with upstream contents, or revert the edits.' -ForegroundColor Yellow + [void]$violation.Append('Rerun with -Force to overwrite these with upstream contents, or revert the edits.') + Write-Error $violation.ToString() -ErrorAction Continue return 2 } else { - Write-Host '' - Write-Host 'WARNING: -Force in effect. The following local edits to upstream-managed files will be OVERWRITTEN:' -ForegroundColor Yellow + Write-Warning '-Force in effect. The following local edits to upstream-managed files will be OVERWRITTEN:' foreach ($d in $drift) { - Write-Host " - $($d.Path) (was: $($d.Commit))" -ForegroundColor Yellow + Write-Warning " - $($d.Path) (was: $($d.Commit))" } - Write-Host '' } } } @@ -2045,51 +2104,21 @@ function Invoke-PullSDLC { $anchorLabel = if ($anchorSha) { $anchorSha.Substring(0, 7) } else { '(empty tree)' } $upstreamLabel = $upstreamHead.Substring(0, 7) - Write-Host '' - if ($ops.Count -eq 0) { - Write-Host "Files to update: 0 (already at upstream $upstreamLabel -- nothing to sync)" -ForegroundColor Cyan - } - else { - Write-Host "Files to update: $($ops.Count) (syncing $anchorLabel -> $upstreamLabel)" -ForegroundColor Cyan - } - $grouped = $ops | Group-Object { $_.Op } | Sort-Object Name - foreach ($g in $grouped) { - Write-Host (" {0}: {1}" -f $g.Name, $g.Count) -ForegroundColor Cyan - } - foreach ($op in $ops) { - $word = switch -Regex ($op.Op) { - '^A$' { 'add' ; break } - '^M$' { 'update' ; break } - '^D$' { 'delete' ; break } - '^R' { 'rename' ; break } - '^C' { 'copy' ; break } - default { $op.Op } - } - $col = $word.PadRight(8) - $label = if ($op.Op -like 'R*' -or $op.Op -like 'C*') { - "{0}{1} -> {2}" -f $col, $op.OldPath, $op.Path - } - else { - "{0}{1}" -f $col, $op.Path - } - Write-Host " $label" -ForegroundColor DarkGray - } + Write-PlannedOpsPreview -Ops $ops -AnchorLabel $anchorLabel -UpstreamLabel $upstreamLabel if ($WhatIfPreference) { - Write-Host '' - Write-Host '-WhatIf specified; no changes written.' -ForegroundColor Yellow + Write-Information '-WhatIf specified; no changes written.' return 0 } if ($ops.Count -eq 0) { - Write-Host '' - Write-Host 'Already up to date.' -ForegroundColor Green + Write-Information 'Already up to date.' } else { foreach ($op in $ops) { Invoke-UpstreamOp -Op $op -Ref $mergeRef -RepoRoot $RepoRoot } - Write-Host "Applied $($ops.Count) ops." -ForegroundColor Green + Write-Information "Applied $($ops.Count) ops." } # Union-merge merge-managed files (today: .gitignore). Done after the @@ -2100,7 +2129,7 @@ function Invoke-PullSDLC { foreach ($mp in $script:MergePaths) { if (Merge-FileFromUpstream -Path $mp -Ref $mergeRef -RepoRoot $RepoRoot) { $mergedPaths.Add($mp) | Out-Null - Write-Host "Merged upstream entries into $mp" -ForegroundColor Green + Write-Information "Merged upstream entries into $mp" } } @@ -2126,14 +2155,14 @@ function Invoke-PullSDLC { git commit -m $msg | Out-Null $headAfter = (git rev-parse HEAD 2>$null).Trim() if ($headBefore -eq $headAfter) { - Write-Host 'ERROR: git commit did not advance HEAD. The commit was likely blocked by a pre-commit hook or branch protection.' -ForegroundColor Red - Write-Host 'Working tree changes have been left in place for inspection. Resolve the policy violation and rerun.' -ForegroundColor Yellow + Write-Error 'ERROR: git commit did not advance HEAD. The commit was likely blocked by a pre-commit hook or branch protection.' -ErrorAction Continue + Write-Warning 'Working tree changes have been left in place for inspection. Resolve the policy violation and rerun.' return 4 } - Write-Host "Created sync commit: $(git rev-parse --short HEAD)" -ForegroundColor Green + Write-Information "Created sync commit: $(git rev-parse --short HEAD)" } else { - Write-Host 'Nothing to commit.' -ForegroundColor DarkGray + Write-Information 'Nothing to commit.' } } finally { Pop-Location } @@ -2146,16 +2175,14 @@ function Invoke-PullSDLC { # scaffolding. $originUrl = (& git -C $RepoRoot remote get-url origin 2>$null) if (Test-IsUpstreamRepo -RemoteUrl $originUrl) { - Write-Host '' - Write-Host "Detected upstream repo (origin -> IntelliSDLC.ai). Skipping template scaffolding." -ForegroundColor DarkGray + Write-Information "Detected upstream repo (origin -> IntelliSDLC.ai). Skipping template scaffolding." } else { $scaffolded = @(Invoke-TemplateScaffold -SourceRoot $RepoRoot -TargetRoot $RepoRoot -ScaffoldMap $script:TemplateScaffoldMap -Ref $mergeRef) if ($scaffolded.Count -gt 0) { - Write-Host '' - Write-Host 'Scaffolded consumer-owned files from templates:' -ForegroundColor Green - foreach ($f in $scaffolded) { Write-Host " + $f" -ForegroundColor Green } - Write-Host 'Open each file and fill in the sections, then commit them to your repo.' -ForegroundColor Green + Write-Information 'Scaffolded consumer-owned files from templates:' + foreach ($f in $scaffolded) { Write-Information " + $f" } + Write-Information 'Open each file and fill in the sections, then commit them to your repo.' } } diff --git a/docs/designs/194-output-streams-plan.md b/docs/designs/194-output-streams-plan.md new file mode 100644 index 0000000..b8eaa9a --- /dev/null +++ b/docs/designs/194-output-streams-plan.md @@ -0,0 +1,37 @@ +# Plan: Output-stream refactor of Pull-SDLC.ai.ps1 (issue #194) + +## Design + +Map every `Write-Host` in `Pull-SDLC.ai.ps1` to an intent-appropriate stream per the +"Output & Streams" guidance (commit 65b12a0). Preserve all exit/return codes and the +visible-by-default output of normal and `-WhatIf` runs. + +### Stream assignment + +| Category | Cmdlet | Sites (approx) | +|---|---|---| +| Errors (fatal) | `Write-Error -ErrorAction Continue` then existing `return ` | rc6 worktree-sync-failed (~1302); rc5 no-git/init-failed (~1931,1938); rc3 ABORT commit-context (~1960); rc2 POLICY VIOLATION drift (~2022); rc4 commit-did-not-advance (~2129) | +| Advisories | `Write-Warning` | push failed (~1336); manual-PR hints (~1344,1350,1353); cannot-open-PR (~1384); PR-create-failed (~1406); bootstrap declined (~2012); rerun-with-Force (~2027); -Force-in-effect (~2032/2034); bootstrap-overwrite prompt preamble (~960-962); non-Windows SSH (~1773) | +| Status/progress | `Write-Information` | cleanup (~787,805); anchor/grep (~946); [Bootstrap] (~952-953,1934); worktree lifecycle (~1257-1297,1972); push status (~1316,1321,1333,1343); PR opened/updated (~1392,1404); self-update (~1514); add-remote/fetch (~1998,2002); already-up-to-date/applied/merged/created-commit/nothing (~2086,2092,2103,2133,2136); scaffolding (~2150,2155-2158) | +| Decorative (keep Write-Host) | `Write-Host` + narrow `PSAvoidUsingWriteHost` suppression | op preview list (~2048-2076) -> extracted to `Write-PlannedOpsPreview`; `Write-NextStepsBanner`; SSH `[skip]/[add]/[warn]` (`Add-GitConfigValueIfMissing`, `Invoke-SetupGitHubSsh`) | + +### Key risk controls +- `$ErrorActionPreference='Stop'` is script-scoped, so bare `Write-Error` would terminate + before `return `. Every fatal site uses `Write-Error -ErrorAction Continue` so the + record lands on stream 2 **and** the original return code is preserved. +- `$InformationPreference='Continue'` set at script scope so status stays visible by default. +- Op-preview stays `Write-Host` (existing tests mock `Write-Host`); extracted into a tiny + helper so the suppression is narrow. + +## Acceptance criteria +1. All existing tests in `Pull-SDLC.ai.Tests.ps1` stay green. +2. New behavior-first tests assert: rc unchanged AND message on the correct stream for + each category representative. +3. `Invoke-ScriptAnalyzer` clean except narrowly-suppressed decorative `Write-Host`. +4. No new CLI options. + +## Verify +```powershell +Invoke-Pester -Path .\Pull-SDLC.ai.Tests.ps1 -Output Detailed +Invoke-ScriptAnalyzer -Path .\Pull-SDLC.ai.ps1 -Severity Warning,Error +```