/ tools / PowerShellTool / commandSemantics.ts
commandSemantics.ts
  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  
 20  export 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   */
 32  const 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   */
 41  const 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   */
 62  const 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   */
100  function 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   */
121  function 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   */
130  export 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  }