/** * Command semantics configuration for interpreting exit codes in PowerShell. * * PowerShell-native cmdlets do NOT need exit-code semantics: * - Select-String (grep equivalent) exits 0 on no-match (returns $null) * - Compare-Object (diff equivalent) exits 0 regardless * - Test-Path exits 0 regardless (returns bool via pipeline) * Native cmdlets signal failure via terminating errors ($?), not exit codes. * * However, EXTERNAL executables invoked from PowerShell DO set $LASTEXITCODE, * and many use non-zero codes to convey information rather than failure: * - grep.exe / rg.exe (Git for Windows, scoop, etc.): 1 = no match * - findstr.exe (Windows native): 1 = no match * - robocopy.exe (Windows native): 0-7 = success, 8+ = error (notorious!) * * Without this module, PowerShellTool throws ShellError on any non-zero exit, * so `robocopy` reporting "files copied successfully" (exit 1) shows as an error. */ export type CommandSemantic = ( exitCode: number, stdout: string, stderr: string, ) => { isError: boolean message?: string } /** * Default semantic: treat only 0 as success, everything else as error */ const DEFAULT_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({ isError: exitCode !== 0, message: exitCode !== 0 ? `Command failed with exit code ${exitCode}` : undefined, }) /** * grep / ripgrep: 0 = matches found, 1 = no matches, 2+ = error */ const GREP_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({ isError: exitCode >= 2, message: exitCode === 1 ? 'No matches found' : undefined, }) /** * Command-specific semantics for external executables. * Keys are lowercase command names WITHOUT .exe suffix. * * Deliberately omitted: * - 'diff': Ambiguous. Windows PowerShell 5.1 aliases `diff` → Compare-Object * (exit 0 on differ), but PS Core / Git for Windows may resolve to diff.exe * (exit 1 on differ). Cannot reliably interpret. * - 'fc': Ambiguous. PowerShell aliases `fc` → Format-Custom (a native cmdlet), * but `fc.exe` is the Windows file compare utility (exit 1 = files differ). * Same aliasing problem as `diff`. * - 'find': Ambiguous. Windows find.exe (text search) vs Unix find.exe * (file search via Git for Windows) have different semantics. * - 'test', '[': Not PowerShell constructs. * - 'select-string', 'compare-object', 'test-path': Native cmdlets exit 0. */ const COMMAND_SEMANTICS: Map = new Map([ // External grep/ripgrep (Git for Windows, scoop, choco) ['grep', GREP_SEMANTIC], ['rg', GREP_SEMANTIC], // findstr.exe: Windows native text search // 0 = match found, 1 = no match, 2 = error ['findstr', GREP_SEMANTIC], // robocopy.exe: Windows native robust file copy // Exit codes are a BITFIELD — 0-7 are success, 8+ indicates at least one failure: // 0 = no files copied, no mismatch, no failures (already in sync) // 1 = files copied successfully // 2 = extra files/dirs detected (no copy) // 4 = mismatched files/dirs detected // 8 = some files/dirs could not be copied (copy errors) // 16 = serious error (robocopy did not copy any files) // This is the single most common "CI failed but nothing's wrong" Windows gotcha. [ 'robocopy', (exitCode, _stdout, _stderr) => ({ isError: exitCode >= 8, message: exitCode === 0 ? 'No files copied (already in sync)' : exitCode >= 1 && exitCode < 8 ? exitCode & 1 ? 'Files copied successfully' : 'Robocopy completed (no errors)' : undefined, }), ], ]) /** * Extract the command name from a single pipeline segment. * Strips leading `&` / `.` call operators and `.exe` suffix, lowercases. */ function extractBaseCommand(segment: string): string { // Strip PowerShell call operators: & "cmd", . "cmd" // (& and . at segment start followed by whitespace invoke the next token) const stripped = segment.trim().replace(/^[&.]\s+/, '') const firstToken = stripped.split(/\s+/)[0] || '' // Strip surrounding quotes if command was invoked as & "grep.exe" const unquoted = firstToken.replace(/^["']|["']$/g, '') // Strip path: C:\bin\grep.exe → grep.exe, .\rg.exe → rg.exe const basename = unquoted.split(/[\\/]/).pop() || unquoted // Strip .exe suffix (Windows is case-insensitive) return basename.toLowerCase().replace(/\.exe$/, '') } /** * Extract the primary command from a PowerShell command line. * Takes the LAST pipeline segment since that determines the exit code. * * Heuristic split on `;` and `|` — may get it wrong for quoted strings or * complex constructs. Do NOT depend on this for security; it's only used * for exit-code interpretation (false negatives just fall back to default). */ function heuristicallyExtractBaseCommand(command: string): string { const segments = command.split(/[;|]/).filter(s => s.trim()) const last = segments[segments.length - 1] || command return extractBaseCommand(last) } /** * Interpret command result based on semantic rules */ export function interpretCommandResult( command: string, exitCode: number, stdout: string, stderr: string, ): { isError: boolean message?: string } { const baseCommand = heuristicallyExtractBaseCommand(command) const semantic = COMMAND_SEMANTICS.get(baseCommand) ?? DEFAULT_SEMANTIC return semantic(exitCode, stdout, stderr) }