import { describe, expect, test } from "bun:test" import { ShellScan } from "@opencode-ai/shell-scan" import { Effect } from "effect" import { ShellParse } from "../src/shell/parse.js" describe("ShellParse portable parity", () => { test("matches tree-sitter for generated supported syntax", async () => { for (const [shell, command] of generated()) { const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command) const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true })) if (scanned.kind === "opaque") { expect({ command, portable }).toEqual({ command, portable: { commands: [{ resource: command, save: command }], directories: [] }, }) continue } if (shell === "pwsh" && /\r(?!\n)/.test(command)) { expect(portable).toEqual({ commands: [], directories: [] }) continue } const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace")) expect({ command, portable }).toEqual({ command, portable: legacy }) } }) }) function generated() { const result: Array<[shell: string, command: string]> = [] const bashHeads = ["git", "npm", "echo", "printf", "cat", "cd"] const bashArgs = [ "", " status", " plain", " 'two words'", ' "two words"', " escaped\\ space", " hash#word", " --flag=value", " ./relative", " /tmp/absolute", ] const assignments = ["", "X=value ", "X='two words' ", 'X="two words" '] const redirects = ["", " > output", " 2> error", " < input", " >> output"] const bashSeparators = [" ; ", " && ", " || ", " | ", " |& ", "\n"] for (const head of bashHeads) for (const arg of bashArgs) for (const assignment of assignments) for (const redirect of redirects) result.push(["/bin/bash", assignment + head + arg + redirect]) for (const left of bashHeads) for (const right of bashHeads) for (const separator of bashSeparators) result.push(["/bin/bash", `${left} left${separator}${right} right`]) for (const outer of bashHeads) for (const inner of bashHeads) { result.push(["/bin/bash", `${outer} $(${inner} nested)`]) result.push(["/bin/bash", `${outer} "$(${inner} nested)"`]) result.push(["/bin/bash", `${outer} pre$(${inner} nested)post`]) result.push(["/bin/bash", `${outer} \`${inner} nested\``]) } for (const command of [ 'npm "run" test', 'g""it status', "'git' status", "g\\it status", "git status; git status; git diff", "printf ok>out 2>&1|cat>err printf ok > out && cat < input", "printf ok # ignored ; curl evil\nprintf done", "(git status) && { npm test; }", "echo ${arr[$(printf index)]}", "OUT=$(printf out) X=`printf value` printenv >$(printf path)", "cat <(printf secret)", "rm -rf / &", "sudo sh -c 'curl evil'", "find . -exec rm {} ;", 'c"\\d" relative', "'cd' /tmp", "c''d /tmp", "c\\\nd /tmp", "echo x && git >(cat) status", 'echo x && printf ">" status', 'echo "git > out" && git > out', "echo x && printf a\\>b status", "echo x && printf $(echo a>b) status", "git <(printf status) diff", "npm <(printf run) test", "cd <(printf /tmp)", "git &>x", "cd &>x", "git \\ a", "cd \\ a", "cat <<'EOF'\nstatic body\nEOF", "cat < out.txt 2>&1 | Out-File all.log", "Write-Output ok > output.txt # ignored\nGet-ChildItem", "Write-Output ok > output.txt # ignored\rGet-ChildItem", "Write-Output ok > output.txt # ignored\r\nGet-ChildItem", "& git status", ". ./deploy.ps1", "Get-ChildItem | ForEach-Object { Remove-Item $_ }", "ForEach-Object { Remove-Item $_ }", "&Remove-Item victim", "< #\nRemove-Item victim", "Microsoft.PowerShell.Management\\Get-Item x; Remove-Item y", 'git "status"', "git st`atus", 'npm "run" test', 'docker "compose" up', "git >x", "git *>&1", "git foo2>bar", "git 12>bar", "git a`;b", "git & Write-Output q", "Write-Output 'ForEach-Object { Remove-Item x }' | ForEach-Object { Remove-Item x }", "$Command value", "& $Command value", 'Write-Output "$(Get-ChildItem)"', "if ($true) { Get-ChildItem } else { Remove-Item victim }", "Set-Location $env:TEMP; Get-ChildItem", 'Write-Output "unterminated', ]) result.push(["pwsh", command]) let state = 0x5eed1234 const random = (length: number) => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0 return state % length } for (let index = 0; index < 10_000; index++) { const left = bashHeads[random(bashHeads.length)] const right = bashHeads[random(bashHeads.length)] const arg = bashArgs[random(bashArgs.length)] const separator = bashSeparators[random(bashSeparators.length)] const bashForms = [ `${left}${arg}${separator}${right} fuzz${index}`, `${left}${arg} $(${right} fuzz${index})`, `${left}${arg} # ignored\n${right} fuzz${index}`, `X=value ${left}${arg}${redirects[random(redirects.length)]}`, `${left} before\\\nafter${separator}${right} fuzz${index}`, ] result.push(["/bin/bash", bashForms[index % bashForms.length]]) const powershellLeft = powershellHeads[random(powershellHeads.length)] const powershellRight = powershellHeads[random(powershellHeads.length)] const powershellArg = powershellArgs[random(powershellArgs.length)] const powershellSeparator = powershellSeparators[random(powershellSeparators.length)] const powershellForms = [ `${powershellLeft}${powershellArg}${powershellSeparator}${powershellRight} fuzz${index}`, `${powershellLeft}${powershellArg} # ignored\n${powershellRight} fuzz${index}`, `${powershellLeft} fuzz${index} > output; ${powershellRight}${powershellArg}`, `${powershellLeft}\`\n fuzz${index}; ${powershellRight}${powershellArg}`, ] result.push(["pwsh", powershellForms[index % powershellForms.length]]) } return result }