source dump of claude code
at main 142 lines 5.5 kB view raw
1/** 2 * Command semantics configuration for interpreting exit codes in PowerShell. 3 * 4 * PowerShell-native cmdlets do NOT need exit-code semantics: 5 * - Select-String (grep equivalent) exits 0 on no-match (returns $null) 6 * - Compare-Object (diff equivalent) exits 0 regardless 7 * - Test-Path exits 0 regardless (returns bool via pipeline) 8 * Native cmdlets signal failure via terminating errors ($?), not exit codes. 9 * 10 * However, EXTERNAL executables invoked from PowerShell DO set $LASTEXITCODE, 11 * and many use non-zero codes to convey information rather than failure: 12 * - grep.exe / rg.exe (Git for Windows, scoop, etc.): 1 = no match 13 * - findstr.exe (Windows native): 1 = no match 14 * - robocopy.exe (Windows native): 0-7 = success, 8+ = error (notorious!) 15 * 16 * Without this module, PowerShellTool throws ShellError on any non-zero exit, 17 * so `robocopy` reporting "files copied successfully" (exit 1) shows as an error. 18 */ 19 20export type CommandSemantic = ( 21 exitCode: number, 22 stdout: string, 23 stderr: string, 24) => { 25 isError: boolean 26 message?: string 27} 28 29/** 30 * Default semantic: treat only 0 as success, everything else as error 31 */ 32const DEFAULT_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({ 33 isError: exitCode !== 0, 34 message: 35 exitCode !== 0 ? `Command failed with exit code ${exitCode}` : undefined, 36}) 37 38/** 39 * grep / ripgrep: 0 = matches found, 1 = no matches, 2+ = error 40 */ 41const GREP_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({ 42 isError: exitCode >= 2, 43 message: exitCode === 1 ? 'No matches found' : undefined, 44}) 45 46/** 47 * Command-specific semantics for external executables. 48 * Keys are lowercase command names WITHOUT .exe suffix. 49 * 50 * Deliberately omitted: 51 * - 'diff': Ambiguous. Windows PowerShell 5.1 aliases `diff` → Compare-Object 52 * (exit 0 on differ), but PS Core / Git for Windows may resolve to diff.exe 53 * (exit 1 on differ). Cannot reliably interpret. 54 * - 'fc': Ambiguous. PowerShell aliases `fc` → Format-Custom (a native cmdlet), 55 * but `fc.exe` is the Windows file compare utility (exit 1 = files differ). 56 * Same aliasing problem as `diff`. 57 * - 'find': Ambiguous. Windows find.exe (text search) vs Unix find.exe 58 * (file search via Git for Windows) have different semantics. 59 * - 'test', '[': Not PowerShell constructs. 60 * - 'select-string', 'compare-object', 'test-path': Native cmdlets exit 0. 61 */ 62const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([ 63 // External grep/ripgrep (Git for Windows, scoop, choco) 64 ['grep', GREP_SEMANTIC], 65 ['rg', GREP_SEMANTIC], 66 67 // findstr.exe: Windows native text search 68 // 0 = match found, 1 = no match, 2 = error 69 ['findstr', GREP_SEMANTIC], 70 71 // robocopy.exe: Windows native robust file copy 72 // Exit codes are a BITFIELD — 0-7 are success, 8+ indicates at least one failure: 73 // 0 = no files copied, no mismatch, no failures (already in sync) 74 // 1 = files copied successfully 75 // 2 = extra files/dirs detected (no copy) 76 // 4 = mismatched files/dirs detected 77 // 8 = some files/dirs could not be copied (copy errors) 78 // 16 = serious error (robocopy did not copy any files) 79 // This is the single most common "CI failed but nothing's wrong" Windows gotcha. 80 [ 81 'robocopy', 82 (exitCode, _stdout, _stderr) => ({ 83 isError: exitCode >= 8, 84 message: 85 exitCode === 0 86 ? 'No files copied (already in sync)' 87 : exitCode >= 1 && exitCode < 8 88 ? exitCode & 1 89 ? 'Files copied successfully' 90 : 'Robocopy completed (no errors)' 91 : undefined, 92 }), 93 ], 94]) 95 96/** 97 * Extract the command name from a single pipeline segment. 98 * Strips leading `&` / `.` call operators and `.exe` suffix, lowercases. 99 */ 100function extractBaseCommand(segment: string): string { 101 // Strip PowerShell call operators: & "cmd", . "cmd" 102 // (& and . at segment start followed by whitespace invoke the next token) 103 const stripped = segment.trim().replace(/^[&.]\s+/, '') 104 const firstToken = stripped.split(/\s+/)[0] || '' 105 // Strip surrounding quotes if command was invoked as & "grep.exe" 106 const unquoted = firstToken.replace(/^["']|["']$/g, '') 107 // Strip path: C:\bin\grep.exe → grep.exe, .\rg.exe → rg.exe 108 const basename = unquoted.split(/[\\/]/).pop() || unquoted 109 // Strip .exe suffix (Windows is case-insensitive) 110 return basename.toLowerCase().replace(/\.exe$/, '') 111} 112 113/** 114 * Extract the primary command from a PowerShell command line. 115 * Takes the LAST pipeline segment since that determines the exit code. 116 * 117 * Heuristic split on `;` and `|` — may get it wrong for quoted strings or 118 * complex constructs. Do NOT depend on this for security; it's only used 119 * for exit-code interpretation (false negatives just fall back to default). 120 */ 121function heuristicallyExtractBaseCommand(command: string): string { 122 const segments = command.split(/[;|]/).filter(s => s.trim()) 123 const last = segments[segments.length - 1] || command 124 return extractBaseCommand(last) 125} 126 127/** 128 * Interpret command result based on semantic rules 129 */ 130export function interpretCommandResult( 131 command: string, 132 exitCode: number, 133 stdout: string, 134 stderr: string, 135): { 136 isError: boolean 137 message?: string 138} { 139 const baseCommand = heuristicallyExtractBaseCommand(command) 140 const semantic = COMMAND_SEMANTICS.get(baseCommand) ?? DEFAULT_SEMANTIC 141 return semantic(exitCode, stdout, stderr) 142}