diff --git a/packages/just-bash/src/commands/jq/jq.arg.test.ts b/packages/just-bash/src/commands/jq/jq.arg.test.ts new file mode 100644 index 00000000..b0039cd6 --- /dev/null +++ b/packages/just-bash/src/commands/jq/jq.arg.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { Bash } from "../../Bash.js"; + +describe("jq named-argument flags", () => { + describe("--arg", () => { + it("binds $NAME to the string VALUE", async () => { + const env = new Bash(); + const result = await env.exec( + "echo '{}' | jq --arg name World '{greeting: (\"Hello \" + $name)}'", + ); + expect(result.stdout).toBe('{\n "greeting": "Hello World"\n}\n'); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("always binds a string even when VALUE looks numeric", async () => { + const env = new Bash(); + const result = await env.exec("jq -n --arg x 5 '$x'"); + expect(result.stdout).toBe('"5"\n'); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + }); + + describe("multiple --arg", () => { + it("populates $ARGS.named in order", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -cn --arg a foo --arg b bar --arg c baz '$ARGS.named'", + ); + expect(result.stdout).toBe('{"a":"foo","b":"bar","c":"baz"}\n'); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("binds each $NAME for use in a filter", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -n --arg first Ada --arg last Lovelace '$first + \" \" + $last'", + ); + expect(result.stdout).toBe('"Ada Lovelace"\n'); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + }); + + describe("--argjson", () => { + it("binds $NAME to a JSON number", async () => { + const env = new Bash(); + const result = await env.exec("jq -n --argjson x 5 '$x'"); + expect(result.stdout).toBe("5\n"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("binds $NAME to a JSON object", async () => { + const env = new Bash(); + const result = await env.exec("jq -n --argjson x '{\"a\":1}' '$x.a'"); + expect(result.stdout).toBe("1\n"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("errors on invalid JSON with exit code 2", async () => { + const env = new Bash(); + const result = await env.exec("jq -n --argjson x notjson '$x'"); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe("jq: invalid JSON text passed to --argjson\n"); + expect(result.exitCode).toBe(2); + }); + }); + + describe("--rawfile", () => { + it("binds $NAME to the raw file contents including newlines", async () => { + const env = new Bash(); + const result = await env.exec( + "printf 'line1\\nline2\\n' > rf.txt && jq -n --rawfile r rf.txt '$r'", + ); + expect(result.stdout).toBe('"line1\\nline2\\n"\n'); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + }); + + describe("--slurpfile", () => { + it("binds $NAME to the array of JSON values in FILE", async () => { + const env = new Bash(); + const result = await env.exec( + "printf '1 2 3\\n' > sf.json && jq -cn --slurpfile s sf.json '$s'", + ); + expect(result.stdout).toBe("[1,2,3]\n"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("errors with exit code 2 when the file is missing", async () => { + const env = new Bash(); + const result = await env.exec("jq -n --slurpfile s nope.json '$s'"); + expect(result.stdout).toBe(""); + expect(result.exitCode).toBe(2); + }); + }); + + describe("$ARGS", () => { + it("exposes all named bindings via $ARGS.named", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -cn --arg a 1 --argjson b 2 '$ARGS.named'", + ); + expect(result.stdout).toBe('{"a":"1","b":2}\n'); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("orders $ARGS as positional then named", async () => { + const env = new Bash(); + const result = await env.exec("jq -cn --arg a 1 '$ARGS'"); + expect(result.stdout).toBe('{"positional":[],"named":{"a":"1"}}\n'); + expect(result.exitCode).toBe(0); + }); + + it("returns {} for $ARGS.named when no args are given", async () => { + const env = new Bash(); + const result = await env.exec("jq -cn '$ARGS.named'"); + expect(result.stdout).toBe("{}\n"); + expect(result.exitCode).toBe(0); + }); + + it("returns [] for $ARGS.positional", async () => { + const env = new Bash(); + const result = await env.exec("jq -cn '$ARGS.positional'"); + expect(result.stdout).toBe("[]\n"); + expect(result.exitCode).toBe(0); + }); + }); + + describe("errors and safety", () => { + it("errors with exit code 2 on a missing operand", async () => { + const env = new Bash(); + const result = await env.exec("jq -n --arg x"); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "jq: --arg takes two parameters (e.g. --arg varname value)\n", + ); + expect(result.exitCode).toBe(2); + }); + + it("keeps a __proto__ arg name as a plain data key (faithful to real jq)", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -cn --arg __proto__ pwned '$ARGS.named'", + ); + expect(result.stdout).toBe('{"__proto__":"pwned"}\n'); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("reads through a __proto__ arg without inheriting (returns null)", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -cn --argjson __proto__ '{\"pwned\":123}' '$ARGS.named.pwned'", + ); + expect(result.stdout).toBe("null\n"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("does not pollute Object.prototype via a __proto__ arg name", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -cn --arg __proto__ pwned '$ARGS.named'", + ); + expect(result.stdout).toBe('{"__proto__":"pwned"}\n'); + expect(result.exitCode).toBe(0); + expect(({} as Record).pwned).toBeUndefined(); + expect(Object.getPrototypeOf({})).toBe(Object.prototype); + }); + + it("leaves other unknown long options unchanged", async () => { + const env = new Bash(); + const result = await env.exec("jq -n --bogus '.'"); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe("jq: unrecognized option '--bogus'\n"); + expect(result.exitCode).toBe(1); + }); + }); +}); diff --git a/packages/just-bash/src/commands/jq/jq.args-positional.test.ts b/packages/just-bash/src/commands/jq/jq.args-positional.test.ts new file mode 100644 index 00000000..8197672f --- /dev/null +++ b/packages/just-bash/src/commands/jq/jq.args-positional.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { Bash } from "../../Bash.js"; + +describe("jq positional-argument flags", () => { + describe("--args", () => { + it("collects remaining tokens as string positional args", async () => { + const env = new Bash(); + const result = await env.exec("jq -cn '$ARGS.positional' --args a b c"); + expect(result.stdout).toBe('["a","b","c"]\n'); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("always keeps positional args as strings even when numeric-looking", async () => { + const env = new Bash(); + const result = await env.exec("jq -cn '$ARGS.positional' --args 1 2 3"); + expect(result.stdout).toBe('["1","2","3"]\n'); + expect(result.exitCode).toBe(0); + }); + + it("treats the first non-option token as the filter, not a positional", async () => { + const env = new Bash(); + const result = await env.exec("jq -cn --args '$ARGS.positional' a b c"); + expect(result.stdout).toBe('["a","b","c"]\n'); + expect(result.exitCode).toBe(0); + }); + + it("still parses known flags after --args", async () => { + const env = new Bash(); + const result = await env.exec("jq -n --args '$ARGS.positional' a -c b"); + expect(result.stdout).toBe('["a","b"]\n'); + expect(result.exitCode).toBe(0); + }); + }); + + describe("--jsonargs", () => { + it("parses remaining tokens as JSON positional args", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -cn '$ARGS.positional' --jsonargs 1 '\"x\"' true", + ); + expect(result.stdout).toBe('[1,"x",true]\n'); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("parses JSON objects and arrays as positional args", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -cn '$ARGS.positional' --jsonargs '{\"a\":1}' '[1,2]'", + ); + expect(result.stdout).toBe('[{"a":1},[1,2]]\n'); + expect(result.exitCode).toBe(0); + }); + + it("errors with exit code 2 on invalid JSON", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -n '$ARGS.positional' --jsonargs 1 notjson", + ); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "jq: invalid JSON text passed to --jsonargs\n", + ); + expect(result.exitCode).toBe(2); + }); + }); + + describe("mode switching and combinations", () => { + it("switches from --args to --jsonargs mode mid-stream", async () => { + const env = new Bash(); + const result = await env.exec( + "jq -cn '$ARGS.positional' --args a --jsonargs 1", + ); + expect(result.stdout).toBe('["a",1]\n'); + expect(result.exitCode).toBe(0); + }); + + it("populates both $ARGS.named and $ARGS.positional", async () => { + const env = new Bash(); + const result = await env.exec("jq -cn '$ARGS' --arg k v --args a b"); + expect(result.stdout).toBe( + '{"positional":["a","b"],"named":{"k":"v"}}\n', + ); + expect(result.exitCode).toBe(0); + }); + + it("interleaves --arg after --args", async () => { + const env = new Bash(); + const result = await env.exec("jq -cn '$ARGS' --args a --arg k v"); + expect(result.stdout).toBe('{"positional":["a"],"named":{"k":"v"}}\n'); + expect(result.exitCode).toBe(0); + }); + }); + + describe("empty and default", () => { + it("returns [] when --args is given with no following tokens", async () => { + const env = new Bash(); + const result = await env.exec("jq -cn '$ARGS.positional' --args"); + expect(result.stdout).toBe("[]\n"); + expect(result.exitCode).toBe(0); + }); + + it("does not treat --args positionals as input files", async () => { + const env = new Bash(); + const result = await env.exec("echo '{\"v\":1}' | jq -c '.v' --args a b"); + expect(result.stdout).toBe("1\n"); + expect(result.exitCode).toBe(0); + }); + }); +}); diff --git a/packages/just-bash/src/commands/jq/jq.ts b/packages/just-bash/src/commands/jq/jq.ts index 17105eec..506af56c 100644 --- a/packages/just-bash/src/commands/jq/jq.ts +++ b/packages/just-bash/src/commands/jq/jq.ts @@ -175,6 +175,14 @@ function parseJsonStream(input: string): unknown[] { return results; } +/** + * Error result for external-argument option parsing failures. + * jq uses exit code 2 for command-line option errors. + */ +function jqArgError(message: string): ExecResult { + return { stdout: "", stderr: `jq: ${message}\n`, exitCode: 2 }; +} + const jqHelp = { name: "jq", summary: "command-line JSON processor", @@ -192,6 +200,12 @@ const jqHelp = { "-C, --color colorize output (ignored)", "-M, --monochrome monochrome output (ignored)", " --tab use tabs for indentation", + " --arg NAME VALUE bind $NAME to the string VALUE", + " --argjson NAME JSON bind $NAME to the JSON-decoded value JSON", + " --rawfile NAME FILE bind $NAME to the raw contents of FILE", + " --slurpfile NAME FILE bind $NAME to the array of JSON values in FILE", + " --args remaining arguments are string positional args ($ARGS.positional)", + " --jsonargs remaining arguments are JSON positional args ($ARGS.positional)", " --help display this help and exit", ], }; @@ -278,7 +292,15 @@ export const jqCommand: Command = { let useTab = false; let filter = "."; let filterSet = false; + let positionalMode: "none" | "args" | "jsonargs" = "none"; const files: string[] = []; + const namedArgs = new Map(); + const positionalArgs: QueryValue[] = []; + const fileBindings: { + name: string; + file: string; + mode: "raw" | "slurp"; + }[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]; @@ -297,7 +319,64 @@ export const jqCommand: Command = { } else if (a === "-M" || a === "--monochrome") { /* ignored */ } else if (a === "--tab") useTab = true; - else if (a === "-") files.push("-"); + else if (a === "--arg") { + const name = args[i + 1]; + const value = args[i + 2]; + if (name === undefined || value === undefined) { + return jqArgError( + "--arg takes two parameters (e.g. --arg varname value)", + ); + } + namedArgs.set(name, value); + i += 2; + } else if (a === "--argjson") { + const name = args[i + 1]; + const json = args[i + 2]; + if (name === undefined || json === undefined) { + return jqArgError( + "--argjson takes two parameters (e.g. --argjson varname text)", + ); + } + let parsed: unknown[]; + try { + parsed = parseJsonStream(json.trim()); + } catch { + return jqArgError("invalid JSON text passed to --argjson"); + } + if (parsed.length !== 1) { + return jqArgError("invalid JSON text passed to --argjson"); + } + namedArgs.set(name, parsed[0]); + i += 2; + } else if (a === "--rawfile") { + const name = args[i + 1]; + const file = args[i + 2]; + if (name === undefined || file === undefined) { + return jqArgError( + "--rawfile takes two parameters (e.g. --rawfile varname filename)", + ); + } + fileBindings.push({ name, file, mode: "raw" }); + i += 2; + } else if (a === "--slurpfile") { + const name = args[i + 1]; + const file = args[i + 2]; + if (name === undefined || file === undefined) { + return jqArgError( + "--slurpfile takes two parameters (e.g. --slurpfile varname filename)", + ); + } + fileBindings.push({ name, file, mode: "slurp" }); + i += 2; + } else if (a === "--args") { + // Remaining non-option tokens (after the filter) become string + // positional args instead of input files. + positionalMode = "args"; + } else if (a === "--jsonargs") { + // Remaining non-option tokens (after the filter) are JSON-parsed and + // become positional args instead of input files. + positionalMode = "jsonargs"; + } else if (a === "-") files.push("-"); else if (a.startsWith("--")) return unknownOption("jq", a); else if (a.startsWith("-")) { for (const c of a.slice(1)) { @@ -318,13 +397,57 @@ export const jqCommand: Command = { } else return unknownOption("jq", `-${c}`); } } else if (!filterSet) { + // The first non-option token is always the filter, even when a + // positional mode has already been enabled by --args/--jsonargs. filter = a; filterSet = true; + } else if (positionalMode === "args") { + positionalArgs.push(a); + } else if (positionalMode === "jsonargs") { + let parsed: unknown[]; + try { + parsed = parseJsonStream(a.trim()); + } catch { + return jqArgError("invalid JSON text passed to --jsonargs"); + } + if (parsed.length !== 1) { + return jqArgError("invalid JSON text passed to --jsonargs"); + } + positionalArgs.push(parsed[0] as QueryValue); } else { files.push(a); } } + // Read files bound via --rawfile/--slurpfile through the shared file + // reader so they get the same security posture as normal input files. + if (fileBindings.length > 0) { + const result = await withDefenseContext("arg file read", () => + readFiles( + ctx, + fileBindings.map((b) => b.file), + { cmdName: "jq", stopOnError: true }, + ), + ); + if (result.exitCode !== 0) { + return { stdout: "", stderr: result.stderr, exitCode: 2 }; + } + for (let b = 0; b < fileBindings.length; b++) { + const { name, mode } = fileBindings[b]; + const text = decodeBytesToUtf8(result.files[b].content); + if (mode === "raw") { + namedArgs.set(name, text); + } else { + const trimmed = text.trim(); + try { + namedArgs.set(name, trimmed ? parseJsonStream(trimmed) : []); + } catch { + return jqArgError("invalid JSON text passed to --slurpfile"); + } + } + } + } + // Build list of inputs: stdin or files. jq parses JSON, so the input // bytes are decoded to UTF-8 before parsing — without this, multi-byte // sequences inside string values get re-encoded twice and emit mojibake. @@ -363,6 +486,8 @@ export const jqCommand: Command = { ? { maxIterations: ctx.limits.maxJqIterations } : undefined, env: ctx.env, + namedArgs, + positionalArgs, coverage: ctx.coverage, requireDefenseContext: ctx.requireDefenseContext, }; diff --git a/packages/just-bash/src/commands/query-engine/evaluator.ts b/packages/just-bash/src/commands/query-engine/evaluator.ts index a8610325..62fcc3ac 100644 --- a/packages/just-bash/src/commands/query-engine/evaluator.ts +++ b/packages/just-bash/src/commands/query-engine/evaluator.ts @@ -115,6 +115,10 @@ export interface EvalContext { limits: Required> & QueryExecutionLimits; env?: Map; + /** Named arguments (bare names) exposed via $ARGS.named */ + namedArgs?: Map; + /** Positional arguments (in order) exposed via $ARGS.positional */ + positionalArgs?: QueryValue[]; requireDefenseContext?: boolean; defenseContextChecked?: boolean; /** Original document root for parent/root navigation */ @@ -131,14 +135,23 @@ export interface EvalContext { } function createContext(options?: EvaluateOptions): EvalContext { + const vars = new Map(); + if (options?.namedArgs) { + // Seed $NAME variables; jq stores variable references with the $ prefix. + for (const [name, value] of options.namedArgs) { + vars.set(`$${name}`, value); + } + } return { - vars: new Map(), + vars, limits: { maxIterations: options?.limits?.maxIterations ?? DEFAULT_MAX_JQ_ITERATIONS, maxDepth: options?.limits?.maxDepth ?? DEFAULT_MAX_JQ_DEPTH, }, env: options?.env, + namedArgs: options?.namedArgs, + positionalArgs: options?.positionalArgs, coverage: options?.coverage, requireDefenseContext: options?.requireDefenseContext, defenseContextChecked: false, @@ -156,6 +169,8 @@ function withVar( vars: newVars, limits: ctx.limits, env: ctx.env, + namedArgs: ctx.namedArgs, + positionalArgs: ctx.positionalArgs, requireDefenseContext: ctx.requireDefenseContext, defenseContextChecked: ctx.defenseContextChecked, root: ctx.root, @@ -358,6 +373,10 @@ function applyPathTransform( export interface EvaluateOptions { limits?: QueryExecutionLimits; env?: Map; + /** Named arguments (bare names) bound to $NAME and exposed via $ARGS.named */ + namedArgs?: Map; + /** Positional arguments (in order) exposed via $ARGS.positional */ + positionalArgs?: QueryValue[]; coverage?: FeatureCoverageWriter; requireDefenseContext?: boolean; } @@ -711,6 +730,31 @@ export function evaluate( // Convert Map to object for jq's internal representation (null-prototype prevents prototype pollution) return [ctx.env ? mapToRecord(ctx.env) : Object.create(null)]; } + // $ARGS exposes named/positional external arguments. jq orders the keys + // as { positional, named }. + if (ast.name === "$ARGS") { + // Faithful to real jq: every named arg is kept as an ordinary data key + // in insertion order, including prototype-sensitive names like + // "__proto__". The null-prototype object makes this safe (no pollution). + const named: Record = Object.create(null); + if (ctx.namedArgs) { + for (const [name, value] of ctx.namedArgs) { + // defineProperty (vs `named[name] = value`) stores a "__proto__" key + // as a plain own data property instead of hitting the accessor. + // @banned-pattern-ignore: null-prototype target; keys are inert data. + Object.defineProperty(named, name, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + } + const argsObj: Record = Object.create(null); + argsObj.positional = ctx.positionalArgs ? [...ctx.positionalArgs] : []; + argsObj.named = named; + return [argsObj]; + } const v = ctx.vars.get(ast.name); return v !== undefined ? [v] : [null]; } diff --git a/packages/just-bash/src/comparison-tests/fixtures/jq-arg.comparison.fixtures.json b/packages/just-bash/src/comparison-tests/fixtures/jq-arg.comparison.fixtures.json new file mode 100644 index 00000000..10133217 --- /dev/null +++ b/packages/just-bash/src/comparison-tests/fixtures/jq-arg.comparison.fixtures.json @@ -0,0 +1,118 @@ +{ + "182a418ef9169fee": { + "command": "jq -cn --arg a foo --arg b bar --arg c baz '$ARGS.named'", + "files": {}, + "stdout": "{\"a\":\"foo\",\"b\":\"bar\",\"c\":\"baz\"}\n", + "stderr": "", + "exitCode": 0 + }, + "23acb9b1149931ff": { + "command": "jq -cn --arg __proto__ pwned '$ARGS.named'", + "files": {}, + "stdout": "{\"__proto__\":\"pwned\"}\n", + "stderr": "", + "exitCode": 0 + }, + "29c465e8ba4443cf": { + "command": "echo '{}' | jq --arg name World '{greeting: (\"Hello \" + $name)}'", + "files": {}, + "stdout": "{\n \"greeting\": \"Hello World\"\n}\n", + "stderr": "", + "exitCode": 0 + }, + "306111682fc4bbee": { + "command": "jq -n --arg x", + "files": {}, + "stdout": "", + "stderr": "jq: --arg takes two parameters (e.g. --arg varname value)\nUse jq --help for help with command-line options,\nor see the jq manpage, or online docs at https://jqlang.github.io/jq\n", + "exitCode": 2 + }, + "3d56250c54eee7c9": { + "command": "jq -n --argjson x '{\"a\":1}' '$x.a'", + "files": {}, + "stdout": "1\n", + "stderr": "", + "exitCode": 0 + }, + "59b0caee68c71a3a": { + "command": "jq -cn '$ARGS.positional'", + "files": {}, + "stdout": "[]\n", + "stderr": "", + "exitCode": 0 + }, + "663f71c927f1e87a": { + "command": "jq -n --arg first Ada --arg last Lovelace '$first + \" \" + $last'", + "files": {}, + "stdout": "\"Ada Lovelace\"\n", + "stderr": "", + "exitCode": 0 + }, + "78da9edc39aeb8cb": { + "command": "jq -n --argjson x notjson '$x'", + "files": {}, + "stdout": "", + "stderr": "jq: invalid JSON text passed to --argjson\nUse jq --help for help with command-line options,\nor see the jq manpage, or online docs at https://jqlang.github.io/jq\n", + "exitCode": 2 + }, + "80bd68420f7f1861": { + "command": "jq -cn --slurpfile s sf.json '$s'", + "files": { + "sf.json": "1 2 3\n" + }, + "stdout": "[1,2,3]\n", + "stderr": "", + "exitCode": 0 + }, + "b9a885019f854664": { + "command": "jq -cn --arg a 1 '$ARGS'", + "files": {}, + "stdout": "{\"positional\":[],\"named\":{\"a\":\"1\"}}\n", + "stderr": "", + "exitCode": 0 + }, + "cacd71671fc7a519": { + "command": "jq -n --argjson x 5 '$x'", + "files": {}, + "stdout": "5\n", + "stderr": "", + "exitCode": 0 + }, + "cf68bd5ad736b14e": { + "command": "jq -n --rawfile r rf.txt '$r'", + "files": { + "rf.txt": "line1\nline2\n" + }, + "stdout": "\"line1\\nline2\\n\"\n", + "stderr": "", + "exitCode": 0 + }, + "dfb7614f8376fead": { + "command": "jq -n --arg x 5 '$x'", + "files": {}, + "stdout": "\"5\"\n", + "stderr": "", + "exitCode": 0 + }, + "e67f16fe351d154e": { + "command": "jq -cn --argjson __proto__ '{\"pwned\":123}' '$ARGS.named.pwned'", + "files": {}, + "stdout": "null\n", + "stderr": "", + "exitCode": 0 + }, + "eba4481aa44b77a4": { + "command": "jq -cn --arg a 1 --argjson b 2 '$ARGS.named'", + "files": {}, + "stdout": "{\"a\":\"1\",\"b\":2}\n", + "stderr": "", + "exitCode": 0 + }, + "f46c266001cf63cd": { + "command": "jq -cn '$ARGS.named'", + "files": {}, + "stdout": "{}\n", + "stderr": "", + "exitCode": 0 + } +} diff --git a/packages/just-bash/src/comparison-tests/fixtures/jq-args-positional.comparison.fixtures.json b/packages/just-bash/src/comparison-tests/fixtures/jq-args-positional.comparison.fixtures.json new file mode 100644 index 00000000..82f3b9da --- /dev/null +++ b/packages/just-bash/src/comparison-tests/fixtures/jq-args-positional.comparison.fixtures.json @@ -0,0 +1,65 @@ +{ + "143150510be68407": { + "command": "jq -cn '$ARGS.positional' --jsonargs '{\"a\":1}' '[1,2]'", + "files": {}, + "stdout": "[{\"a\":1},[1,2]]\n", + "stderr": "", + "exitCode": 0 + }, + "59124fd44c118019": { + "command": "jq -n '$ARGS.positional' --jsonargs 1 notjson", + "files": {}, + "stdout": "", + "stderr": "jq: invalid JSON text passed to --jsonargs\nUse jq --help for help with command-line options,\nor see the jq manpage, or online docs at https://jqlang.github.io/jq\n", + "exitCode": 2 + }, + "5b80bd165b8916e2": { + "command": "jq -cn '$ARGS.positional' --args 1 2 3", + "files": {}, + "stdout": "[\"1\",\"2\",\"3\"]\n", + "stderr": "", + "exitCode": 0 + }, + "9f19c4556d509872": { + "command": "echo '{\"v\":1}' | jq -c '.v' --args a b", + "files": {}, + "stdout": "1\n", + "stderr": "", + "exitCode": 0 + }, + "aa05fb4471725b42": { + "command": "jq -cn '$ARGS.positional' --args a b c", + "files": {}, + "stdout": "[\"a\",\"b\",\"c\"]\n", + "stderr": "", + "exitCode": 0 + }, + "b1416dd1cdf19b4e": { + "command": "jq -cn '$ARGS.positional' --jsonargs 1 '\"x\"' true", + "files": {}, + "stdout": "[1,\"x\",true]\n", + "stderr": "", + "exitCode": 0 + }, + "b93b31cdcff8ce3e": { + "command": "jq -cn '$ARGS.positional' --args a --jsonargs 1", + "files": {}, + "stdout": "[\"a\",1]\n", + "stderr": "", + "exitCode": 0 + }, + "c8f827cdc2badaa1": { + "command": "jq -cn '$ARGS' --arg k v --args a b", + "files": {}, + "stdout": "{\"positional\":[\"a\",\"b\"],\"named\":{\"k\":\"v\"}}\n", + "stderr": "", + "exitCode": 0 + }, + "dfea11a2859fd9e2": { + "command": "jq -cn --args '$ARGS.positional' a b c", + "files": {}, + "stdout": "[\"a\",\"b\",\"c\"]\n", + "stderr": "", + "exitCode": 0 + } +} diff --git a/packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts b/packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts new file mode 100644 index 00000000..913e6959 --- /dev/null +++ b/packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, it } from "vitest"; +import { + cleanupTestDir, + compareOutputs, + createTestDir, + setupFiles, +} from "./fixture-runner.js"; + +describe("jq named-argument flags - Real Bash Comparison", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await createTestDir(); + }); + + afterEach(async () => { + await cleanupTestDir(testDir); + }); + + describe("--arg", () => { + it("binds $name as a string", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "echo '{}' | jq --arg name World '{greeting: (\"Hello \" + $name)}'", + ); + }); + + it("always binds a string even when numeric-looking", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs(env, testDir, "jq -n --arg x 5 '$x'"); + }); + }); + + describe("multiple --arg", () => { + it("populates $ARGS.named in order", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn --arg a foo --arg b bar --arg c baz '$ARGS.named'", + ); + }); + + it("binds each $name for use in a filter", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -n --arg first Ada --arg last Lovelace '$first + \" \" + $last'", + ); + }); + }); + + describe("--argjson", () => { + it("binds a JSON number", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs(env, testDir, "jq -n --argjson x 5 '$x'"); + }); + + it("binds a JSON object and navigates it", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -n --argjson x '{\"a\":1}' '$x.a'", + ); + }); + + it("errors non-zero on invalid JSON", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs(env, testDir, "jq -n --argjson x notjson '$x'"); + }); + }); + + describe("--rawfile", () => { + it("binds the whole file including newlines", async () => { + const env = await setupFiles(testDir, { + "rf.txt": "line1\nline2\n", + }); + await compareOutputs(env, testDir, "jq -n --rawfile r rf.txt '$r'"); + }); + }); + + describe("--slurpfile", () => { + it("binds an array of the file's JSON values", async () => { + const env = await setupFiles(testDir, { + "sf.json": "1 2 3\n", + }); + await compareOutputs(env, testDir, "jq -cn --slurpfile s sf.json '$s'"); + }); + }); + + describe("$ARGS", () => { + it("reflects named bindings", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn --arg a 1 --argjson b 2 '$ARGS.named'", + ); + }); + + it("orders positional before named", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs(env, testDir, "jq -cn --arg a 1 '$ARGS'"); + }); + + it("is empty named when no args given", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs(env, testDir, "jq -cn '$ARGS.named'"); + }); + + it("is empty positional array", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs(env, testDir, "jq -cn '$ARGS.positional'"); + }); + }); + + describe("prototype-sensitive arg names", () => { + it("keeps a __proto__ arg name in $ARGS.named", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn --arg __proto__ pwned '$ARGS.named'", + ); + }); + + it("reads through a __proto__ arg without inheriting", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn --argjson __proto__ '{\"pwned\":123}' '$ARGS.named.pwned'", + ); + }); + }); + + describe("errors", () => { + it("errors non-zero on a missing operand", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs(env, testDir, "jq -n --arg x"); + }); + }); +}); diff --git a/packages/just-bash/src/comparison-tests/jq-args-positional.comparison.test.ts b/packages/just-bash/src/comparison-tests/jq-args-positional.comparison.test.ts new file mode 100644 index 00000000..a4ed8e63 --- /dev/null +++ b/packages/just-bash/src/comparison-tests/jq-args-positional.comparison.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, it } from "vitest"; +import { + cleanupTestDir, + compareOutputs, + createTestDir, + setupFiles, +} from "./fixture-runner.js"; + +describe("jq positional-argument flags - Real Bash Comparison", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await createTestDir(); + }); + + afterEach(async () => { + await cleanupTestDir(testDir); + }); + + describe("--args", () => { + it("collects string positional args", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn '$ARGS.positional' --args a b c", + ); + }); + + it("keeps numeric-looking positionals as strings", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn '$ARGS.positional' --args 1 2 3", + ); + }); + + it("treats the first token after --args as the filter", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn --args '$ARGS.positional' a b c", + ); + }); + }); + + describe("--jsonargs", () => { + it("parses JSON positional args", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn '$ARGS.positional' --jsonargs 1 '\"x\"' true", + ); + }); + + it("parses JSON objects and arrays", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn '$ARGS.positional' --jsonargs '{\"a\":1}' '[1,2]'", + ); + }); + + it("errors non-zero on invalid JSON", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -n '$ARGS.positional' --jsonargs 1 notjson", + ); + }); + }); + + describe("combinations", () => { + it("populates both named and positional", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs(env, testDir, "jq -cn '$ARGS' --arg k v --args a b"); + }); + + it("switches between --args and --jsonargs", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "jq -cn '$ARGS.positional' --args a --jsonargs 1", + ); + }); + + it("does not treat positionals as input files", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs( + env, + testDir, + "echo '{\"v\":1}' | jq -c '.v' --args a b", + ); + }); + }); +});