Skip to content

Commit 9ed1f26

Browse files
committed
fix(jq): support file operands and add e2e
1 parent 153b3a0 commit 9ed1f26

4 files changed

Lines changed: 244 additions & 23 deletions

File tree

docs-internal/registry-parity-worklist.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,18 @@ real e2e tests that prove Linux-parity behavior — not smoke tests.
567567
### 12. No tests at all — 9 software + 5 agents
568568
- **Broken:** zero e2e coverage: `gawk, sed, tar, gzip, jq, yq, diffutils,
569569
file, vim`; agents `claude, codex, opencode, pi, pi-cli`.
570+
- **Status:**
571+
- **jq — DONE.** Added package-local VM e2e coverage for the staged `jq`
572+
command and fixed the jaq-backed CLI wrapper to accept Linux-style file
573+
operands instead of only stdin. The suite now proves version output,
574+
file-backed array filtering, aggregate JSON construction, slurped NDJSON,
575+
and invalid JSON parse errors through the packaged WASM command. Proof:
576+
`2026-07-08T13-10-55-0700-item12-jq-toolchain-cmd-build-isolated-target.log`;
577+
`2026-07-08T13-12-18-0700-item12-jq-package-build-after-wrapper-fix.log`;
578+
`2026-07-08T13-12-51-0700-item12-jq-package-e2e-final.log`;
579+
`2026-07-08T13-12-51-0700-item12-jq-check-types-final.log`;
580+
`2026-07-08T13-12-51-0700-item12-jq-cargo-fmt-check-final.log`.
581+
Rev: `slnmvuqz`.
570582
- **Objective:** write real e2e tests proving each behaves like its Linux
571583
counterpart (jq processes real JSON, sed edits streams, tar round-trips archives,
572584
gzip round-trips, etc.); agents exercise the real ACP

software/jq/bin/jq

6.67 KB
Binary file not shown.

software/jq/native/crates/jq/src/lib.rs

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! Wraps jaq-core/jaq-std/jaq-json to provide a standard jq CLI interface.
44
55
use std::ffi::OsString;
6+
use std::fs::File as FsFile;
67
use std::io::{self, Read, Write};
78

89
use jaq_core::load::{Arena, File, Loader};
@@ -40,6 +41,7 @@ struct JqOptions {
4041
join_output: bool,
4142
args: Vec<(String, String)>,
4243
jsonargs: Vec<(String, Val)>,
44+
input_paths: Vec<String>,
4345
}
4446

4547
fn parse_args(args: &[String]) -> Result<JqOptions, String> {
@@ -54,6 +56,7 @@ fn parse_args(args: &[String]) -> Result<JqOptions, String> {
5456
join_output: false,
5557
args: Vec::new(),
5658
jsonargs: Vec::new(),
59+
input_paths: Vec::new(),
5760
};
5861

5962
let mut filter_set = false;
@@ -63,6 +66,17 @@ fn parse_args(args: &[String]) -> Result<JqOptions, String> {
6366
let arg = &args[i];
6467

6568
if arg == "--" {
69+
let remaining = &args[i + 1..];
70+
if !filter_set {
71+
let Some(filter) = remaining.first() else {
72+
return Err("no filter provided".to_string());
73+
};
74+
opts.filter = filter.clone();
75+
filter_set = true;
76+
opts.input_paths.extend(remaining.iter().skip(1).cloned());
77+
} else {
78+
opts.input_paths.extend(remaining.iter().cloned());
79+
}
6680
break;
6781
}
6882

@@ -118,7 +132,7 @@ fn parse_args(args: &[String]) -> Result<JqOptions, String> {
118132
opts.filter = arg.clone();
119133
filter_set = true;
120134
} else {
121-
return Err(format!("unexpected argument: {}", arg));
135+
opts.input_paths.push(arg.clone());
122136
}
123137

124138
i += 1;
@@ -131,30 +145,60 @@ fn parse_args(args: &[String]) -> Result<JqOptions, String> {
131145
Ok(opts)
132146
}
133147

148+
fn read_sources(opts: &JqOptions) -> Result<Vec<String>, String> {
149+
if opts.input_paths.is_empty() {
150+
let mut stdin_data = String::new();
151+
io::stdin()
152+
.take((MAX_INPUT_BYTES + 1) as u64)
153+
.read_to_string(&mut stdin_data)
154+
.map_err(|e| format!("failed to read stdin: {}", e))?;
155+
if stdin_data.len() > MAX_INPUT_BYTES {
156+
return Err("stdin exceeds size limit".to_string());
157+
}
158+
return Ok(vec![stdin_data]);
159+
}
160+
161+
let mut total = 0usize;
162+
let mut sources = Vec::new();
163+
for path in &opts.input_paths {
164+
let mut data = String::new();
165+
if path == "-" {
166+
io::stdin()
167+
.take((MAX_INPUT_BYTES.saturating_sub(total) + 1) as u64)
168+
.read_to_string(&mut data)
169+
.map_err(|e| format!("failed to read stdin: {}", e))?;
170+
} else {
171+
FsFile::open(path)
172+
.map_err(|e| format!("failed to open {}: {}", path, e))?
173+
.take((MAX_INPUT_BYTES.saturating_sub(total) + 1) as u64)
174+
.read_to_string(&mut data)
175+
.map_err(|e| format!("failed to read {}: {}", path, e))?;
176+
}
177+
total = total
178+
.checked_add(data.len())
179+
.ok_or_else(|| "input exceeds size limit".to_string())?;
180+
if total > MAX_INPUT_BYTES {
181+
return Err("input exceeds size limit".to_string());
182+
}
183+
sources.push(data);
184+
}
185+
Ok(sources)
186+
}
187+
134188
fn read_inputs(opts: &JqOptions) -> Result<Vec<Val>, String> {
135189
if opts.null_input {
136190
return Ok(vec![Val::from(serde_json::Value::Null)]);
137191
}
138192

139-
let mut stdin_data = String::new();
140-
io::stdin()
141-
.take((MAX_INPUT_BYTES + 1) as u64)
142-
.read_to_string(&mut stdin_data)
143-
.map_err(|e| format!("failed to read stdin: {}", e))?;
144-
if stdin_data.len() > MAX_INPUT_BYTES {
145-
return Err("stdin exceeds size limit".to_string());
146-
}
193+
let sources = read_sources(opts)?;
147194

148195
if opts.raw_input {
196+
let raw_data = sources.concat();
149197
if opts.slurp {
150-
let mut arr = Vec::new();
151-
for line in stdin_data.lines() {
152-
push_input_value(&mut arr, serde_json::Value::String(line.to_string()))?;
153-
}
154-
Ok(vec![Val::from(serde_json::Value::Array(arr))])
198+
Ok(vec![Val::from(serde_json::Value::String(raw_data))])
155199
} else {
156200
let mut lines = Vec::new();
157-
for line in stdin_data.lines() {
201+
for line in raw_data.lines() {
158202
push_input_value(
159203
&mut lines,
160204
Val::from(serde_json::Value::String(line.to_string())),
@@ -163,16 +207,23 @@ fn read_inputs(opts: &JqOptions) -> Result<Vec<Val>, String> {
163207
Ok(lines)
164208
}
165209
} else {
166-
let trimmed = stdin_data.trim();
167-
if trimmed.is_empty() {
168-
return Ok(vec![Val::from(serde_json::Value::Null)]);
210+
let mut values = Vec::new();
211+
for source in &sources {
212+
let trimmed = source.trim();
213+
if trimmed.is_empty() {
214+
continue;
215+
}
216+
217+
let decoder =
218+
serde_json::Deserializer::from_str(trimmed).into_iter::<serde_json::Value>();
219+
for result in decoder {
220+
let value = result.map_err(|e| format!("parse error: {}", e))?;
221+
push_input_value(&mut values, value)?;
222+
}
169223
}
170224

171-
let mut values = Vec::new();
172-
let decoder = serde_json::Deserializer::from_str(trimmed).into_iter::<serde_json::Value>();
173-
for result in decoder {
174-
let value = result.map_err(|e| format!("invalid JSON input: {}", e))?;
175-
push_input_value(&mut values, value)?;
225+
if values.is_empty() && opts.input_paths.is_empty() {
226+
values.push(serde_json::Value::Null);
176227
}
177228

178229
if opts.slurp {
@@ -217,6 +268,11 @@ fn format_output(val: &Val, opts: &JqOptions) -> Result<String, String> {
217268
}
218269

219270
fn run_jq(args: &[String]) -> Result<i32, String> {
271+
if matches!(args, [arg] if arg == "--version" || arg == "-V") {
272+
println!("jq-jaq-{}", env!("CARGO_PKG_VERSION"));
273+
return Ok(0);
274+
}
275+
220276
let opts = parse_args(args)?;
221277
let inputs = read_inputs(&opts)?;
222278

software/jq/test/jq.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { existsSync } from "node:fs";
2+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { dirname, join } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
import {
7+
NodeFileSystem,
8+
createKernel,
9+
createWasmVmRuntime,
10+
describeIf,
11+
} from "@agentos/test-harness";
12+
import type { Kernel } from "@agentos/test-harness";
13+
import { afterEach, expect, it } from "vitest";
14+
15+
const JQ_COMMAND_DIR = fileURLToPath(new URL("../bin", import.meta.url));
16+
const hasJqPackageBinary = existsSync(join(JQ_COMMAND_DIR, "jq"));
17+
18+
let tempRoot: string | undefined;
19+
20+
async function writeFixture(path: string, contents: string): Promise<void> {
21+
if (!tempRoot) throw new Error("fixture root not initialized");
22+
const hostPath = join(tempRoot, path.replace(/^\/+/, ""));
23+
await mkdir(dirname(hostPath), { recursive: true });
24+
await writeFile(hostPath, contents);
25+
}
26+
27+
async function createTestVFS(): Promise<NodeFileSystem> {
28+
tempRoot = await mkdtemp(join(tmpdir(), "agentos-jq-"));
29+
await writeFixture(
30+
"/project/users.json",
31+
`${JSON.stringify(
32+
{
33+
users: [
34+
{ name: "Ada", active: true, team: "runtime", score: 7 },
35+
{ name: "Grace", active: false, team: "docs", score: 5 },
36+
{ name: "Linus", active: true, team: "runtime", score: 3 },
37+
],
38+
},
39+
null,
40+
2,
41+
)}\n`,
42+
);
43+
await writeFixture(
44+
"/project/events.ndjson",
45+
[
46+
JSON.stringify({ type: "build", value: 4 }),
47+
JSON.stringify({ type: "test", value: 6 }),
48+
JSON.stringify({ type: "deploy", value: 2 }),
49+
].join("\n") + "\n",
50+
);
51+
await writeFixture("/project/broken.json", '{"users": [');
52+
return new NodeFileSystem({ root: tempRoot });
53+
}
54+
55+
function lines(stdout: string): string[] {
56+
return stdout.split("\n").filter((line) => line.length > 0);
57+
}
58+
59+
describeIf(hasJqPackageBinary, "jq command", { timeout: 10_000 }, () => {
60+
let kernel: Kernel | undefined;
61+
62+
afterEach(async () => {
63+
await kernel?.dispose();
64+
kernel = undefined;
65+
if (tempRoot) {
66+
await rm(tempRoot, { recursive: true, force: true });
67+
tempRoot = undefined;
68+
}
69+
});
70+
71+
async function mountFixture(): Promise<void> {
72+
const vfs = await createTestVFS();
73+
kernel = createKernel({ filesystem: vfs });
74+
await kernel.mount(createWasmVmRuntime({ commandDirs: [JQ_COMMAND_DIR] }));
75+
}
76+
77+
async function runJq(args: string[]) {
78+
if (!kernel) throw new Error("kernel not mounted");
79+
let stdout = "";
80+
let stderr = "";
81+
const proc = kernel.spawn("jq", args, {
82+
onStdout: (chunk) => {
83+
stdout += Buffer.from(chunk).toString("utf8");
84+
},
85+
onStderr: (chunk) => {
86+
stderr += Buffer.from(chunk).toString("utf8");
87+
},
88+
});
89+
const exitCode = await proc.wait();
90+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
91+
return { stdout, stderr, exitCode };
92+
}
93+
94+
it("reports a jq-compatible version", async () => {
95+
await mountFixture();
96+
97+
const result = await runJq(["--version"]);
98+
expect(result.exitCode, result.stderr || result.stdout).toBe(0);
99+
expect(result.stdout).toMatch(/^jq-/);
100+
});
101+
102+
it("filters arrays and emits raw strings", async () => {
103+
await mountFixture();
104+
105+
const result = await runJq([
106+
"-r",
107+
'.users[] | select(.active) | "\\(.name):\\(.score)"',
108+
"/project/users.json",
109+
]);
110+
expect(result.exitCode, result.stderr || result.stdout).toBe(0);
111+
expect(lines(result.stdout)).toEqual(["Ada:7", "Linus:3"]);
112+
});
113+
114+
it("builds aggregate JSON objects", async () => {
115+
await mountFixture();
116+
117+
const result = await runJq([
118+
"-c",
119+
'{activeNames: [.users[] | select(.active) | .name], runtimeTotal: ([.users[] | select(.team == "runtime") | .score] | add)}',
120+
"/project/users.json",
121+
]);
122+
expect(result.exitCode, result.stderr || result.stdout).toBe(0);
123+
expect(JSON.parse(result.stdout.trim())).toEqual({
124+
activeNames: ["Ada", "Linus"],
125+
runtimeTotal: 10,
126+
});
127+
});
128+
129+
it("slurps newline-delimited JSON records", async () => {
130+
await mountFixture();
131+
132+
const result = await runJq([
133+
"-s",
134+
"-c",
135+
"{count: length, total: (map(.value) | add), types: map(.type)}",
136+
"/project/events.ndjson",
137+
]);
138+
expect(result.exitCode, result.stderr || result.stdout).toBe(0);
139+
expect(JSON.parse(result.stdout.trim())).toEqual({
140+
count: 3,
141+
total: 12,
142+
types: ["build", "test", "deploy"],
143+
});
144+
});
145+
146+
it("fails with a parse error for invalid JSON", async () => {
147+
await mountFixture();
148+
149+
const result = await runJq([".", "/project/broken.json"]);
150+
expect(result.exitCode).not.toBe(0);
151+
expect(result.stderr).toContain("parse error");
152+
});
153+
});

0 commit comments

Comments
 (0)