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

171 lines
8.2 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"
import { Wildcard } from "../src/util/wildcard.js"
describe("ShellParse native parity", () => {
test("scans generated supported syntax with valid saved prefixes and no 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 parsed = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
for (const item of parsed.commands) {
if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(item.resource)) {
expect(Wildcard.match(item.resource, item.save), context).toBe(true)
}
}
}
}, 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"],
])("extracts resources, saved prefixes, and directories in %s: %s", async (shell, command) => {
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
expect(scanned.kind).toBe("scanned")
await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
})
test("returns an explicit empty result for pure variable assignments without redirects", async () => {
expect(ShellScan.scan("FOO=bar")).toEqual({ kind: "scanned", commands: [] })
expect(await Effect.runPromise(ShellParse.scan("FOO=bar", "bash", "/workspace"))).toEqual({
commands: [],
directories: [],
})
})
test.each([
["> output", [{ resource: "> output", save: "> *" }]],
["2>> output", [{ resource: "2>> output", save: "2>> *" }]],
] as const)("retains commandless redirects with matching saved prefixes: %s", async (command, commands) => {
const parsed = await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace"))
expect(parsed).toEqual({ commands: [...commands], directories: [] })
for (const item of parsed.commands) expect(Wildcard.match(item.resource, item.save)).toBe(true)
})
})
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.scan(command, shell, "/workspace")))).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
}