File size: 5,459 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | /**
* 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<string, CommandSemantic> = 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)
}
|