From a4450ac391c69be09042acd43735fccda27d71a2 Mon Sep 17 00:00:00 2001 From: Ryan Skoblenick <1390364+skoblenick@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:15:02 -0500 Subject: [PATCH 1/3] test: add failing tests for jq string key shorthand in object construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In real jq, quoted string keys can be used as shorthand in object construction without a colon, e.g. {"name"} is equivalent to {"name": .name}. The parser does not support this — the STRING branch in parseObjectConstruction() (parser.ts:1032-1035) unconditionally requires a colon after string keys, causing a parse error. This affects patterns like: {"name"} -> Expected ":" error {"name", "label"} -> Expected ":" error {"if"} -> Expected ":" error (keyword as string key) {"name", "v": .x} -> Expected ":" error (mixed shorthand/explicit) Note: unquoted identifier shorthand ({name, label}) works correctly since isIdentLike() handles IDENT and keyword tokens. The bug is specifically in the quoted string key path. Adds 15 unit tests and 5 comparison tests (all currently failing). --- .../jq/jq.string-key-shorthand.test.ts | 112 ++++++++++++++++++ .../fixtures/jq.comparison.fixtures.json | 45 +++++++ src/comparison-tests/jq.comparison.test.ts | 45 +++++++ 3 files changed, 202 insertions(+) create mode 100644 src/commands/jq/jq.string-key-shorthand.test.ts diff --git a/src/commands/jq/jq.string-key-shorthand.test.ts b/src/commands/jq/jq.string-key-shorthand.test.ts new file mode 100644 index 00000000..06239e82 --- /dev/null +++ b/src/commands/jq/jq.string-key-shorthand.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { Bash } from "../../Bash.js"; +import { parse } from "../query-engine/parser.js"; + +describe("jq string key shorthand in object construction", () => { + describe("parser: quoted string keys without colon", () => { + it('should parse {"name"} as shorthand', () => { + const ast = parse('{"name"}'); + expect(ast.type).toBe("Object"); + }); + + it('should parse {"name", "label"} as shorthand', () => { + const ast = parse('{"name", "label"}'); + expect(ast.type).toBe("Object"); + }); + + it('should parse {"if"} as shorthand (keyword as string key)', () => { + const ast = parse('{"if"}'); + expect(ast.type).toBe("Object"); + }); + + it('should parse {"as"} as shorthand (keyword as string key)', () => { + const ast = parse('{"as"}'); + expect(ast.type).toBe("Object"); + }); + + it('should parse {"try"} as shorthand (keyword as string key)', () => { + const ast = parse('{"try"}'); + expect(ast.type).toBe("Object"); + }); + + it('should parse {"true"} as shorthand (keyword as string key)', () => { + const ast = parse('{"true"}'); + expect(ast.type).toBe("Object"); + }); + + it('should parse {"null"} as shorthand (keyword as string key)', () => { + const ast = parse('{"null"}'); + expect(ast.type).toBe("Object"); + }); + + it('should parse mixed: {"name", "label": .x}', () => { + const ast = parse('{"name", "label": .x}'); + expect(ast.type).toBe("Object"); + }); + }); + + describe("evaluation: quoted string key shorthand", () => { + it('should evaluate {"name"} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"name":"foo","extra":"bar"}' | jq -c '{"name"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo"}\n'); + }); + + it('should evaluate {"name", "label"} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"name":"foo","label":"bar","extra":"baz"}' | jq -c '{"name", "label"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo","label":"bar"}\n'); + }); + + it('should evaluate {"if"} keyword string shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"if":"val"}' | jq -c '{"if"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"if":"val"}\n'); + }); + + it('should evaluate {"true"} keyword string shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"true":"val"}' | jq -c '{"true"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"true":"val"}\n'); + }); + + it("should evaluate mixed shorthand and explicit keys", async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"name":"foo","value":42}' | jq -c '{"name", "v": .value}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo","v":42}\n'); + }); + + it("should evaluate string shorthand in fromjson pipeline", async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"content":[{"text":"{\\"id\\":\\"abcde\\",\\"name\\":\\"foo\\",\\"label\\":\\"bar\\"}"}]}' | jq -c '.content[0].text | fromjson | select(.id == "abcde") | {"name", "label"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo","label":"bar"}\n'); + }); + + it("should evaluate string shorthand in fromjson + array iteration pipeline", async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"content":[{"text":"[{\\"id\\":\\"abcde\\",\\"name\\":\\"foo\\",\\"label\\":\\"bar\\"},{\\"id\\":\\"xyz\\",\\"name\\":\\"baz\\",\\"label\\":\\"qux\\"}]"}]}' | jq -c '.content[0].text | fromjson | .[] | select(.id == "abcde") | {"name", "label"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo","label":"bar"}\n'); + }); + }); +}); diff --git a/src/comparison-tests/fixtures/jq.comparison.fixtures.json b/src/comparison-tests/fixtures/jq.comparison.fixtures.json index 72d0ce88..13568ab2 100644 --- a/src/comparison-tests/fixtures/jq.comparison.fixtures.json +++ b/src/comparison-tests/fixtures/jq.comparison.fixtures.json @@ -195,6 +195,24 @@ "stderr": "", "exitCode": 0 }, + "dec647b75a377ca2": { + "command": "jq -c '{\"true\"}' data.json", + "files": { + "data.json": "{\"true\":\"val\"}" + }, + "stdout": "{\"true\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, + "e32062f2c537fbe0": { + "command": "jq -c '{\"name\", \"v\": .value}' data.json", + "files": { + "data.json": "{\"name\":\"foo\",\"value\":42}" + }, + "stdout": "{\"name\":\"foo\",\"v\":42}\n", + "stderr": "", + "exitCode": 0 + }, "e433b214be1f80db": { "command": "jq 'type' data.json", "files": { @@ -213,6 +231,15 @@ "stderr": "", "exitCode": 0 }, + "ea65254b095a5b4d": { + "command": "jq -c '{\"name\", \"label\"}' data.json", + "files": { + "data.json": "{\"name\":\"foo\",\"label\":\"bar\",\"extra\":\"baz\"}" + }, + "stdout": "{\"name\":\"foo\",\"label\":\"bar\"}\n", + "stderr": "", + "exitCode": 0 + }, "eecd9132c8f55a66": { "command": "jq 'join(\"-\")' data.json", "files": { @@ -238,6 +265,24 @@ "stderr": "", "exitCode": 0 }, + "f9870901c3117349": { + "command": "jq -c '{\"if\"}' data.json", + "files": { + "data.json": "{\"if\":\"val\"}" + }, + "stdout": "{\"if\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, + "fef2973c52922ba7": { + "command": "jq -c '{\"name\"}' data.json", + "files": { + "data.json": "{\"name\":\"foo\",\"extra\":\"bar\"}" + }, + "stdout": "{\"name\":\"foo\"}\n", + "stderr": "", + "exitCode": 0 + }, "ff7f24721da0b6cc": { "command": "jq '.' data.json", "files": { diff --git a/src/comparison-tests/jq.comparison.test.ts b/src/comparison-tests/jq.comparison.test.ts index 779f6f11..cecb2e13 100644 --- a/src/comparison-tests/jq.comparison.test.ts +++ b/src/comparison-tests/jq.comparison.test.ts @@ -229,6 +229,51 @@ describe("jq command - Real Bash Comparison", () => { }); }); + describe("object construction with string key shorthand", () => { + it('should handle {"name"} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"name":"foo","extra":"bar"}', + }); + await compareOutputs(env, testDir, `jq -c '{"name"}' data.json`); + }); + + it('should handle {"name", "label"} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"name":"foo","label":"bar","extra":"baz"}', + }); + await compareOutputs( + env, + testDir, + `jq -c '{"name", "label"}' data.json`, + ); + }); + + it('should handle {"if"} keyword string shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"if":"val"}', + }); + await compareOutputs(env, testDir, `jq -c '{"if"}' data.json`); + }); + + it('should handle {"true"} keyword string shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"true":"val"}', + }); + await compareOutputs(env, testDir, `jq -c '{"true"}' data.json`); + }); + + it("should handle mixed shorthand and explicit keys", async () => { + const env = await setupFiles(testDir, { + "data.json": '{"name":"foo","value":42}', + }); + await compareOutputs( + env, + testDir, + `jq -c '{"name", "v": .value}' data.json`, + ); + }); + }); + describe("string functions", () => { it("should split strings", async () => { const env = await setupFiles(testDir, { From 40c2a2098c555362cbdf74c1229ed3ed6bb78560 Mon Sep 17 00:00:00 2001 From: Ryan Skoblenick <1390364+skoblenick@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:20:56 -0500 Subject: [PATCH 2/3] fix(jq): support quoted string key shorthand in object construction The parser unconditionally required a colon after string keys in object literals, causing filters like {"name"} and {"if"} to fail with a parse error. Real jq treats these as shorthand for {"name": .name}. Mirror the existing isIdentLike() shorthand logic in the STRING branch by checking for a colon and falling back to a Field access node when absent. --- src/commands/jq/jq.string-key-shorthand.test.ts | 8 ++------ src/commands/query-engine/parser.ts | 11 ++++++++--- src/comparison-tests/jq.comparison.test.ts | 6 +----- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/commands/jq/jq.string-key-shorthand.test.ts b/src/commands/jq/jq.string-key-shorthand.test.ts index 06239e82..406cb3f3 100644 --- a/src/commands/jq/jq.string-key-shorthand.test.ts +++ b/src/commands/jq/jq.string-key-shorthand.test.ts @@ -66,18 +66,14 @@ describe("jq string key shorthand in object construction", () => { it('should evaluate {"if"} keyword string shorthand', async () => { const env = new Bash(); - const result = await env.exec( - `echo '{"if":"val"}' | jq -c '{"if"}'`, - ); + const result = await env.exec(`echo '{"if":"val"}' | jq -c '{"if"}'`); expect(result.exitCode).toBe(0); expect(result.stdout).toBe('{"if":"val"}\n'); }); it('should evaluate {"true"} keyword string shorthand', async () => { const env = new Bash(); - const result = await env.exec( - `echo '{"true":"val"}' | jq -c '{"true"}'`, - ); + const result = await env.exec(`echo '{"true":"val"}' | jq -c '{"true"}'`); expect(result.exitCode).toBe(0); expect(result.stdout).toBe('{"true":"val"}\n'); }); diff --git a/src/commands/query-engine/parser.ts b/src/commands/query-engine/parser.ts index 8a71bc51..12991c69 100644 --- a/src/commands/query-engine/parser.ts +++ b/src/commands/query-engine/parser.ts @@ -1030,9 +1030,14 @@ class Parser { value = { type: "Field", name: ident }; } } else if (this.check("STRING")) { - key = this.advance().value as string; - this.expect("COLON", "Expected ':'"); - value = this.parseObjectValue(); + const ident = this.advance().value as string; + if (this.match("COLON")) { + key = ident; + value = this.parseObjectValue(); + } else { + key = ident; + value = { type: "Field", name: ident }; + } } else { throw new Error(`Expected object key at position ${this.peek().pos}`); } diff --git a/src/comparison-tests/jq.comparison.test.ts b/src/comparison-tests/jq.comparison.test.ts index cecb2e13..1a0b2528 100644 --- a/src/comparison-tests/jq.comparison.test.ts +++ b/src/comparison-tests/jq.comparison.test.ts @@ -241,11 +241,7 @@ describe("jq command - Real Bash Comparison", () => { const env = await setupFiles(testDir, { "data.json": '{"name":"foo","label":"bar","extra":"baz"}', }); - await compareOutputs( - env, - testDir, - `jq -c '{"name", "label"}' data.json`, - ); + await compareOutputs(env, testDir, `jq -c '{"name", "label"}' data.json`); }); it('should handle {"if"} keyword string shorthand', async () => { From edcebfa66fbbb84b2d1583d4df0860925e4a732a Mon Sep 17 00:00:00 2001 From: Ryan Skoblenick <1390364+skoblenick@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:28:55 -0500 Subject: [PATCH 3/3] test(jq): strengthen string key shorthand tests with AST assertions and edge cases - Assert entry count, key strings, and Field node shape in parser tests - Add non-identifier key tests: hyphenated ("a-b"), numeric ("1"), empty ("") - Add regression test for explicit key-value with string key ("name": .x) - Add comparison tests for non-identifier key shorthands - Extract expectShorthandEntry() helper to reduce duplication --- .../jq/jq.string-key-shorthand.test.ts | 104 ++++++++++++++++-- .../fixtures/jq.comparison.fixtures.json | 27 +++++ src/comparison-tests/jq.comparison.test.ts | 21 ++++ 3 files changed, 142 insertions(+), 10 deletions(-) diff --git a/src/commands/jq/jq.string-key-shorthand.test.ts b/src/commands/jq/jq.string-key-shorthand.test.ts index 406cb3f3..f1073f58 100644 --- a/src/commands/jq/jq.string-key-shorthand.test.ts +++ b/src/commands/jq/jq.string-key-shorthand.test.ts @@ -1,47 +1,104 @@ import { describe, expect, it } from "vitest"; import { Bash } from "../../Bash.js"; import { parse } from "../query-engine/parser.js"; +import type { ObjectNode } from "../query-engine/parser-types.js"; + +function expectShorthandEntry( + entry: ObjectNode["entries"][number], + keyName: string, +) { + expect(entry.key).toBe(keyName); + expect(entry.value).toEqual({ type: "Field", name: keyName }); +} describe("jq string key shorthand in object construction", () => { describe("parser: quoted string keys without colon", () => { - it('should parse {"name"} as shorthand', () => { - const ast = parse('{"name"}'); + it('should parse {"name"} as shorthand with correct AST', () => { + const ast = parse('{"name"}') as ObjectNode; expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "name"); }); - it('should parse {"name", "label"} as shorthand', () => { - const ast = parse('{"name", "label"}'); + it('should parse {"name", "label"} as shorthand with correct AST', () => { + const ast = parse('{"name", "label"}') as ObjectNode; expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(2); + expectShorthandEntry(ast.entries[0], "name"); + expectShorthandEntry(ast.entries[1], "label"); }); it('should parse {"if"} as shorthand (keyword as string key)', () => { - const ast = parse('{"if"}'); + const ast = parse('{"if"}') as ObjectNode; expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "if"); }); it('should parse {"as"} as shorthand (keyword as string key)', () => { - const ast = parse('{"as"}'); + const ast = parse('{"as"}') as ObjectNode; expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "as"); }); it('should parse {"try"} as shorthand (keyword as string key)', () => { - const ast = parse('{"try"}'); + const ast = parse('{"try"}') as ObjectNode; expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "try"); }); it('should parse {"true"} as shorthand (keyword as string key)', () => { - const ast = parse('{"true"}'); + const ast = parse('{"true"}') as ObjectNode; expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "true"); }); it('should parse {"null"} as shorthand (keyword as string key)', () => { - const ast = parse('{"null"}'); + const ast = parse('{"null"}') as ObjectNode; expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "null"); }); it('should parse mixed: {"name", "label": .x}', () => { - const ast = parse('{"name", "label": .x}'); + const ast = parse('{"name", "label": .x}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(2); + expectShorthandEntry(ast.entries[0], "name"); + expect(ast.entries[1].key).toBe("label"); + expect(ast.entries[1].value).toEqual({ type: "Field", name: "x" }); + }); + + it('should parse non-identifier key {"a-b"} as shorthand', () => { + const ast = parse('{"a-b"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "a-b"); + }); + + it('should parse numeric string key {"1"} as shorthand', () => { + const ast = parse('{"1"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "1"); + }); + + it('should parse empty string key {""} as shorthand', () => { + const ast = parse('{""}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], ""); + }); + + it("should still parse explicit key-value with string key", () => { + const ast = parse('{"name": .x}') as ObjectNode; expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expect(ast.entries[0].key).toBe("name"); + expect(ast.entries[0].value).toEqual({ type: "Field", name: "x" }); }); }); @@ -104,5 +161,32 @@ describe("jq string key shorthand in object construction", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe('{"name":"foo","label":"bar"}\n'); }); + + it('should evaluate non-identifier key {"a-b"} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"a-b":"val","extra":"x"}' | jq -c '{"a-b"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"a-b":"val"}\n'); + }); + + it('should evaluate numeric string key {"1"} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"1":"val","extra":"x"}' | jq -c '{"1"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"1":"val"}\n'); + }); + + it('should evaluate empty string key {""} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"":"val","extra":"x"}' | jq -c '{""}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"":"val"}\n'); + }); }); }); diff --git a/src/comparison-tests/fixtures/jq.comparison.fixtures.json b/src/comparison-tests/fixtures/jq.comparison.fixtures.json index 13568ab2..677b0c69 100644 --- a/src/comparison-tests/fixtures/jq.comparison.fixtures.json +++ b/src/comparison-tests/fixtures/jq.comparison.fixtures.json @@ -62,6 +62,15 @@ "stderr": "", "exitCode": 0 }, + "348c7a17b8cc0a03": { + "command": "jq -c '{\"\"}' data.json", + "files": { + "data.json": "{\"\":\"val\",\"extra\":\"x\"}" + }, + "stdout": "{\"\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, "3a5bd13032387ef1": { "command": "jq 'reverse' data.json", "files": { @@ -116,6 +125,15 @@ "stderr": "", "exitCode": 0 }, + "889ffd5c41c5b865": { + "command": "jq -c '{\"a-b\"}' data.json", + "files": { + "data.json": "{\"a-b\":\"val\",\"extra\":\"x\"}" + }, + "stdout": "{\"a-b\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, "91c885f22f4323a6": { "command": "jq '.' data.json", "files": { @@ -265,6 +283,15 @@ "stderr": "", "exitCode": 0 }, + "f3069be0bd4e62b6": { + "command": "jq -c '{\"1\"}' data.json", + "files": { + "data.json": "{\"1\":\"val\",\"extra\":\"x\"}" + }, + "stdout": "{\"1\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, "f9870901c3117349": { "command": "jq -c '{\"if\"}' data.json", "files": { diff --git a/src/comparison-tests/jq.comparison.test.ts b/src/comparison-tests/jq.comparison.test.ts index 1a0b2528..d3551e2f 100644 --- a/src/comparison-tests/jq.comparison.test.ts +++ b/src/comparison-tests/jq.comparison.test.ts @@ -268,6 +268,27 @@ describe("jq command - Real Bash Comparison", () => { `jq -c '{"name", "v": .value}' data.json`, ); }); + + it('should handle non-identifier key {"a-b"} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"a-b":"val","extra":"x"}', + }); + await compareOutputs(env, testDir, `jq -c '{"a-b"}' data.json`); + }); + + it('should handle numeric string key {"1"} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"1":"val","extra":"x"}', + }); + await compareOutputs(env, testDir, `jq -c '{"1"}' data.json`); + }); + + it('should handle empty string key {""} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"":"val","extra":"x"}', + }); + await compareOutputs(env, testDir, `jq -c '{""}' data.json`); + }); }); describe("string functions", () => {