From 838f96c134f882dd020bf108754a2aa07df72159 Mon Sep 17 00:00:00 2001 From: Lars Trieloff Date: Wed, 8 Jul 2026 18:17:30 +0200 Subject: [PATCH 1/6] feat(jq): add support for named-argument flags (--arg, --argjson, --rawf Agent-Id: agent-adffc8b9-56e7-42e1-9640-29e54e91cb68 Linked-Note-Id: 5eacc9eb-3c60-4792-a92f-e032866df682 --- .../just-bash/src/commands/jq/jq.arg.test.ts | 146 ++++++++++++++++++ packages/just-bash/src/commands/jq/jq.ts | 99 +++++++++++- .../src/commands/query-engine/evaluator.ts | 29 +++- .../jq-arg.comparison.test.ts | 107 +++++++++++++ 4 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 packages/just-bash/src/commands/jq/jq.arg.test.ts create mode 100644 packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts 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..ace82f8f --- /dev/null +++ b/packages/just-bash/src/commands/jq/jq.arg.test.ts @@ -0,0 +1,146 @@ +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("--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("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("{}\n"); + expect(result.exitCode).toBe(0); + expect((Object.prototype as Record).__proto__).not.toBe( + "pwned", + ); + }); + + 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.ts b/packages/just-bash/src/commands/jq/jq.ts index 17105eec..6b8622c4 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,10 @@ 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", " --help display this help and exit", ], }; @@ -279,6 +291,12 @@ export const jqCommand: Command = { let filter = "."; let filterSet = false; const files: string[] = []; + const namedArgs = new Map(); + const fileBindings: { + name: string; + file: string; + mode: "raw" | "slurp"; + }[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]; @@ -297,7 +315,56 @@ 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 === "-") files.push("-"); else if (a.startsWith("--")) return unknownOption("jq", a); else if (a.startsWith("-")) { for (const c of a.slice(1)) { @@ -325,6 +392,35 @@ export const jqCommand: Command = { } } + // 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 +459,7 @@ export const jqCommand: Command = { ? { maxIterations: ctx.limits.maxJqIterations } : undefined, env: ctx.env, + namedArgs, 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..851ff2d0 100644 --- a/packages/just-bash/src/commands/query-engine/evaluator.ts +++ b/packages/just-bash/src/commands/query-engine/evaluator.ts @@ -115,6 +115,8 @@ export interface EvalContext { limits: Required> & QueryExecutionLimits; env?: Map; + /** Named arguments (bare names) exposed via $ARGS.named */ + namedArgs?: Map; requireDefenseContext?: boolean; defenseContextChecked?: boolean; /** Original document root for parent/root navigation */ @@ -131,14 +133,22 @@ 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, coverage: options?.coverage, requireDefenseContext: options?.requireDefenseContext, defenseContextChecked: false, @@ -156,6 +166,7 @@ function withVar( vars: newVars, limits: ctx.limits, env: ctx.env, + namedArgs: ctx.namedArgs, requireDefenseContext: ctx.requireDefenseContext, defenseContextChecked: ctx.defenseContextChecked, root: ctx.root, @@ -358,6 +369,8 @@ function applyPathTransform( export interface EvaluateOptions { limits?: QueryExecutionLimits; env?: Map; + /** Named arguments (bare names) bound to $NAME and exposed via $ARGS.named */ + namedArgs?: Map; coverage?: FeatureCoverageWriter; requireDefenseContext?: boolean; } @@ -711,6 +724,20 @@ 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 }. Positional args are handled in a later wave. + if (ast.name === "$ARGS") { + const named: Record = Object.create(null); + if (ctx.namedArgs) { + for (const [name, value] of ctx.namedArgs) { + if (isSafeKey(name)) named[name] = value; + } + } + const argsObj: Record = Object.create(null); + argsObj.positional = []; + 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/jq-arg.comparison.test.ts b/packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts new file mode 100644 index 00000000..f21b5143 --- /dev/null +++ b/packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts @@ -0,0 +1,107 @@ +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("--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("errors", () => { + it("errors non-zero on a missing operand", async () => { + const env = await setupFiles(testDir, {}); + await compareOutputs(env, testDir, "jq -n --arg x"); + }); + }); +}); From 618226d427686169ecb0c2610d2e5dde66c84e42 Mon Sep 17 00:00:00 2001 From: Augment Code Date: Wed, 8 Jul 2026 18:24:21 +0200 Subject: [PATCH 2/6] test(jq): add jq --arg comparison fixtures Signed-off-by: Lars Trieloff --- .../fixtures/jq-arg.comparison.fixtures.json | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/just-bash/src/comparison-tests/fixtures/jq-arg.comparison.fixtures.json 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..51ce8b71 --- /dev/null +++ b/packages/just-bash/src/comparison-tests/fixtures/jq-arg.comparison.fixtures.json @@ -0,0 +1,90 @@ +{ + "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 + }, + "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 + }, + "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 + } +} From e279e90360f95c69be8a18786189ce77a85230d2 Mon Sep 17 00:00:00 2001 From: Lars Trieloff Date: Wed, 8 Jul 2026 18:32:35 +0200 Subject: [PATCH 3/6] feat(jq): add support for positional-argument flags (--args, --jsonargs) Agent-Id: agent-5e21eaf1-efeb-4038-8f3b-ee7539cebc9a Linked-Note-Id: 704d1169-b7c6-4f47-b565-d020c66a30ea --- .../commands/jq/jq.args-positional.test.ts | 111 ++++++++++++++++++ packages/just-bash/src/commands/jq/jq.ts | 28 +++++ .../src/commands/query-engine/evaluator.ts | 10 +- .../jq-args-positional.comparison.test.ts | 102 ++++++++++++++++ 4 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 packages/just-bash/src/commands/jq/jq.args-positional.test.ts create mode 100644 packages/just-bash/src/comparison-tests/jq-args-positional.comparison.test.ts 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 6b8622c4..506af56c 100644 --- a/packages/just-bash/src/commands/jq/jq.ts +++ b/packages/just-bash/src/commands/jq/jq.ts @@ -204,6 +204,8 @@ const jqHelp = { " --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", ], }; @@ -290,8 +292,10 @@ 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; @@ -364,6 +368,14 @@ export const jqCommand: Command = { } 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("-")) { @@ -385,8 +397,23 @@ 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); } @@ -460,6 +487,7 @@ export const jqCommand: Command = { : 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 851ff2d0..92bfc1b5 100644 --- a/packages/just-bash/src/commands/query-engine/evaluator.ts +++ b/packages/just-bash/src/commands/query-engine/evaluator.ts @@ -117,6 +117,8 @@ export interface EvalContext { 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 */ @@ -149,6 +151,7 @@ function createContext(options?: EvaluateOptions): EvalContext { }, env: options?.env, namedArgs: options?.namedArgs, + positionalArgs: options?.positionalArgs, coverage: options?.coverage, requireDefenseContext: options?.requireDefenseContext, defenseContextChecked: false, @@ -167,6 +170,7 @@ function withVar( limits: ctx.limits, env: ctx.env, namedArgs: ctx.namedArgs, + positionalArgs: ctx.positionalArgs, requireDefenseContext: ctx.requireDefenseContext, defenseContextChecked: ctx.defenseContextChecked, root: ctx.root, @@ -371,6 +375,8 @@ export interface EvaluateOptions { 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; } @@ -725,7 +731,7 @@ export function evaluate( return [ctx.env ? mapToRecord(ctx.env) : Object.create(null)]; } // $ARGS exposes named/positional external arguments. jq orders the keys - // as { positional, named }. Positional args are handled in a later wave. + // as { positional, named }. if (ast.name === "$ARGS") { const named: Record = Object.create(null); if (ctx.namedArgs) { @@ -734,7 +740,7 @@ export function evaluate( } } const argsObj: Record = Object.create(null); - argsObj.positional = []; + argsObj.positional = ctx.positionalArgs ? [...ctx.positionalArgs] : []; argsObj.named = named; return [argsObj]; } 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", + ); + }); + }); +}); From 4251d5db49b8eaf1b0089095b2db9f484f3a2f6a Mon Sep 17 00:00:00 2001 From: Augment Code Date: Wed, 8 Jul 2026 18:33:09 +0200 Subject: [PATCH 4/6] test(jq): add jq --args/--jsonargs comparison fixtures Signed-off-by: Lars Trieloff --- ...q-args-positional.comparison.fixtures.json | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/just-bash/src/comparison-tests/fixtures/jq-args-positional.comparison.fixtures.json 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 + } +} From 60fffdcc510aba36455a5dce206c73bd33e4b265 Mon Sep 17 00:00:00 2001 From: Lars Trieloff Date: Wed, 8 Jul 2026 19:37:41 +0200 Subject: [PATCH 5/6] test(jq): cover multiple --arg flags together Signed-off-by: Lars Trieloff --- .../just-bash/src/commands/jq/jq.arg.test.ts | 22 +++++++++++++++++++ .../fixtures/jq-arg.comparison.fixtures.json | 14 ++++++++++++ .../jq-arg.comparison.test.ts | 20 +++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/packages/just-bash/src/commands/jq/jq.arg.test.ts b/packages/just-bash/src/commands/jq/jq.arg.test.ts index ace82f8f..0dc1859f 100644 --- a/packages/just-bash/src/commands/jq/jq.arg.test.ts +++ b/packages/just-bash/src/commands/jq/jq.arg.test.ts @@ -22,6 +22,28 @@ describe("jq named-argument flags", () => { }); }); + 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(); 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 index 51ce8b71..702471bf 100644 --- 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 @@ -1,4 +1,11 @@ { + "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 + }, "29c465e8ba4443cf": { "command": "echo '{}' | jq --arg name World '{greeting: (\"Hello \" + $name)}'", "files": {}, @@ -27,6 +34,13 @@ "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": {}, 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 index f21b5143..2a8a55a2 100644 --- a/packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts +++ b/packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts @@ -33,6 +33,26 @@ describe("jq named-argument flags - Real Bash Comparison", () => { }); }); + 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, {}); From 15bb0288ac1946f8aafe0fe21a18f45a4d46bea1 Mon Sep 17 00:00:00 2001 From: Lars Trieloff Date: Thu, 9 Jul 2026 09:33:55 +0200 Subject: [PATCH 6/6] fix(jq): keep __proto__ key in $ARGS.named to match real jq Signed-off-by: Lars Trieloff --- .../just-bash/src/commands/jq/jq.arg.test.ts | 27 ++++++++++++++++--- .../src/commands/query-engine/evaluator.ts | 13 ++++++++- .../fixtures/jq-arg.comparison.fixtures.json | 14 ++++++++++ .../jq-arg.comparison.test.ts | 20 ++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/just-bash/src/commands/jq/jq.arg.test.ts b/packages/just-bash/src/commands/jq/jq.arg.test.ts index 0dc1859f..b0039cd6 100644 --- a/packages/just-bash/src/commands/jq/jq.arg.test.ts +++ b/packages/just-bash/src/commands/jq/jq.arg.test.ts @@ -145,16 +145,35 @@ describe("jq named-argument flags", () => { expect(result.exitCode).toBe(2); }); - it("does not pollute Object.prototype via a __proto__ arg name", async () => { + 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("{}\n"); + expect(result.stdout).toBe('{"__proto__":"pwned"}\n'); + expect(result.stderr).toBe(""); expect(result.exitCode).toBe(0); - expect((Object.prototype as Record).__proto__).not.toBe( - "pwned", + }); + + 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 () => { diff --git a/packages/just-bash/src/commands/query-engine/evaluator.ts b/packages/just-bash/src/commands/query-engine/evaluator.ts index 92bfc1b5..62fcc3ac 100644 --- a/packages/just-bash/src/commands/query-engine/evaluator.ts +++ b/packages/just-bash/src/commands/query-engine/evaluator.ts @@ -733,10 +733,21 @@ export function evaluate( // $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) { - if (isSafeKey(name)) named[name] = value; + // 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); 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 index 702471bf..10133217 100644 --- 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 @@ -6,6 +6,13 @@ "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": {}, @@ -87,6 +94,13 @@ "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": {}, 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 index 2a8a55a2..913e6959 100644 --- a/packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts +++ b/packages/just-bash/src/comparison-tests/jq-arg.comparison.test.ts @@ -118,6 +118,26 @@ describe("jq named-argument flags - Real Bash Comparison", () => { }); }); + 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, {});