Files
opencode/packages/core/test/shell-parse-parity.test.ts

177 lines
8.6 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../src/shell/parse.js"
import { ShellScan } from "../src/shell/scan.js"
describe("ShellParse native parity", () => {
test("matches the legacy oracle across generated supported syntax without fallback", async () => {
const commands = generated()
expect(commands.length).toBeGreaterThan(20_000)
for (const [shell, command] of commands) {
const context = `${shell}: ${JSON.stringify(command)}`
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
expect(scanned.kind, context).toBe("scanned")
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
expect(native, context).toEqual(legacy)
expect(
await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true })),
context,
).toEqual(native)
}
}, 60_000)
test.each([
["/bin/bash", "git status && npm run test -- --watch"],
["/bin/bash", "git\tstatus; git status | cat; git diff || echo done"],
["/bin/bash", "echo \"two words\"; printf 'static text'"],
["/bin/bash", "aws s3 ls; docker compose up; git remote add origin; bun run test"],
["/bin/bash", 'git "status"; git remote "add" origin; aws s3 "ls"'],
["/bin/bash", "echo $(curl example.test | sed s/x/y/)"],
["/bin/bash", "if true; then printf yes; else printf no; fi"],
["/bin/bash", "(git status) && { npm test; }"],
["/bin/bash", "(printf ok) > output"],
["/bin/bash", "{ printf ok; } > output"],
["/bin/bash", ">$(printf output)"],
["/bin/bash", "printf ok # ignored ; curl example.test\nprintf done"],
["/bin/bash", "cd ~/project; cd src && cd ..; pwd"],
["/bin/bash", "cd src&&cd.."],
["/bin/bash", "echo $((1 + 2))"],
["/bin/bash", "echo $((1 + $(printf 2)))"],
["/bin/bash", "$COMMAND status"],
["/bin/zsh", "cd ~/project; chdir src && cd ..; git status"],
["/bin/zsh", "echo $((1 + 2)); cd src&&cd.."],
["/bin/dash", "cd src&&cd ..; pwd"],
["/bin/sh", "echo $((1 + 2)); git status; cd src; pwd"],
["/bin/ksh", "git status; cd src; pwd"],
["pwsh", "Get-ChildItem; Write-Output done | Out-String"],
["pwsh", "Set-Location -LiteralPath C:\\tmp; Get-ChildItem"],
["pwsh", "git status; npm run test; docker compose up"],
["pwsh", 'git "status"; npm "run" test; docker "compose" up'],
["pwsh", "Write-Output done # comment\nGet-ChildItem"],
])("native resources, saved prefixes, and directories match in %s: %s", async (shell, command) => {
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
expect(scanned.kind).toBe("scanned")
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
expect(native).toEqual(await Effect.runPromise(ShellParse.scan(command, shell, "/workspace")))
expect(await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))).toEqual(native)
})
test.each(["> output", "FOO=bar", "2>> output"])(
"returns an explicit empty result for statements without executable command nodes: %s",
async (command) => {
expect(ShellScan.scan(command)).toEqual({ kind: "scanned", commands: [] })
const native = await Effect.runPromise(ShellParse.scanPortable(command, "bash", "/workspace"))
expect(native).toEqual({ commands: [], directories: [] })
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace"))).toEqual(native)
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))).toEqual(
native,
)
},
)
})
describe("ShellParse malformed native syntax", () => {
test.each([
["bash", 'echo "unterminated', "unterminated-quote"],
["bash", "printf done &&", "invalid-structure"],
["bash", "cat >", "invalid-redirect"],
["bash", ">", "invalid-redirect"],
["bash", "FOO=bar >", "invalid-redirect"],
["bash", "echo \\", "unterminated-escape"],
["pwsh", 'Write-Output "unterminated', "unterminated-quote"],
["pwsh", "git 12>bar", "invalid-redirect"],
["pwsh", "Write-Output `", "unterminated-escape"],
] as const)("fails explicitly for malformed %s syntax: %s", async (shell, command, reason) => {
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
expect(scanned).toEqual({ kind: "opaque", reason })
expect(await Effect.runPromise(Effect.result(ShellParse.scanPortable(command, shell, "/workspace")))).toMatchObject(
{
_tag: "Failure",
failure: { message: `Portable shell scanner cannot analyze command: ${reason}` },
},
)
expect(
await Effect.runPromise(Effect.result(ShellParse.scan(command, shell, "/workspace", { portable: true }))),
).toMatchObject({
_tag: "Failure",
failure: { message: `Portable shell scanner cannot analyze command: ${reason}` },
})
})
})
// This generator describes a supported grammar; opaque results fail the test rather than being filtered out.
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 bashSeparators = [" ; ", " && ", " || ", " | ", " |& ", "\n"]
const redirects = ["", " > output", " 2> error", " < input", " >> output"]
for (const head of bashHeads)
for (const arg of bashArgs)
for (const assignment of ["", "X=value ", "X='two words' ", 'X="two words" '])
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 ["echo", "printf", "cat"])
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\``])
}
const powershellHeads = ["Get-ChildItem", "Write-Output", "Test-Path", "Remove-Item", "Set-Location"]
const powershellArgs = ["", " value", " 'two words'", ' "two words"', " -Path C:\\tmp", " -LiteralPath '..\\outside'"]
const powershellSeparators = [";", "|", "&&", "||", "\n", "\r\n"]
for (const head of powershellHeads) for (const arg of powershellArgs) result.push(["pwsh", head + arg])
for (const left of powershellHeads)
for (const right of powershellHeads)
for (const separator of powershellSeparators) result.push(["pwsh", `${left} left${separator}${right} right`])
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}`,
`echo $(${right} fuzz${index})`,
`${left}${arg} # ignored\n${right} fuzz${index}`,
`X=value ${left}${arg}${redirects[random(redirects.length)]}`,
`${left} 'two words'${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} "fuzz${index}" | ${powershellRight}${powershellArg}`,
]
result.push(["pwsh", powershellForms[index % powershellForms.length]])
}
return result
}