diff --git a/docs-internal/registry-parity-worklist.md b/docs-internal/registry-parity-worklist.md index c887b4425c..53f25860a0 100644 --- a/docs-internal/registry-parity-worklist.md +++ b/docs-internal/registry-parity-worklist.md @@ -567,6 +567,18 @@ real e2e tests that prove Linux-parity behavior — not smoke tests. ### 12. No tests at all — 9 software + 5 agents - **Broken:** zero e2e coverage: `gawk, sed, tar, gzip, jq, yq, diffutils, file, vim`; agents `claude, codex, opencode, pi, pi-cli`. +- **Status:** + - **jq — DONE.** Added package-local VM e2e coverage for the staged `jq` + command and fixed the jaq-backed CLI wrapper to accept Linux-style file + operands instead of only stdin. The suite now proves version output, + file-backed array filtering, aggregate JSON construction, slurped NDJSON, + and invalid JSON parse errors through the packaged WASM command. Proof: + `2026-07-08T13-10-55-0700-item12-jq-toolchain-cmd-build-isolated-target.log`; + `2026-07-08T13-12-18-0700-item12-jq-package-build-after-wrapper-fix.log`; + `2026-07-08T13-12-51-0700-item12-jq-package-e2e-final.log`; + `2026-07-08T13-12-51-0700-item12-jq-check-types-final.log`; + `2026-07-08T13-12-51-0700-item12-jq-cargo-fmt-check-final.log`. + Rev: `slnmvuqz`. - **Objective:** write real e2e tests proving each behaves like its Linux counterpart (jq processes real JSON, sed edits streams, tar round-trips archives, gzip round-trips, etc.); agents exercise the real ACP diff --git a/software/jq/bin/jq b/software/jq/bin/jq index 22f0359722..e74f99e2e7 100644 Binary files a/software/jq/bin/jq and b/software/jq/bin/jq differ diff --git a/software/jq/native/crates/jq/src/lib.rs b/software/jq/native/crates/jq/src/lib.rs index 9fe36c8542..66fc2544cc 100644 --- a/software/jq/native/crates/jq/src/lib.rs +++ b/software/jq/native/crates/jq/src/lib.rs @@ -3,6 +3,7 @@ //! Wraps jaq-core/jaq-std/jaq-json to provide a standard jq CLI interface. use std::ffi::OsString; +use std::fs::File as FsFile; use std::io::{self, Read, Write}; use jaq_core::load::{Arena, File, Loader}; @@ -40,6 +41,7 @@ struct JqOptions { join_output: bool, args: Vec<(String, String)>, jsonargs: Vec<(String, Val)>, + input_paths: Vec, } fn parse_args(args: &[String]) -> Result { @@ -54,6 +56,7 @@ fn parse_args(args: &[String]) -> Result { join_output: false, args: Vec::new(), jsonargs: Vec::new(), + input_paths: Vec::new(), }; let mut filter_set = false; @@ -63,6 +66,17 @@ fn parse_args(args: &[String]) -> Result { let arg = &args[i]; if arg == "--" { + let remaining = &args[i + 1..]; + if !filter_set { + let Some(filter) = remaining.first() else { + return Err("no filter provided".to_string()); + }; + opts.filter = filter.clone(); + filter_set = true; + opts.input_paths.extend(remaining.iter().skip(1).cloned()); + } else { + opts.input_paths.extend(remaining.iter().cloned()); + } break; } @@ -118,7 +132,7 @@ fn parse_args(args: &[String]) -> Result { opts.filter = arg.clone(); filter_set = true; } else { - return Err(format!("unexpected argument: {}", arg)); + opts.input_paths.push(arg.clone()); } i += 1; @@ -131,30 +145,60 @@ fn parse_args(args: &[String]) -> Result { Ok(opts) } +fn read_sources(opts: &JqOptions) -> Result, String> { + if opts.input_paths.is_empty() { + let mut stdin_data = String::new(); + io::stdin() + .take((MAX_INPUT_BYTES + 1) as u64) + .read_to_string(&mut stdin_data) + .map_err(|e| format!("failed to read stdin: {}", e))?; + if stdin_data.len() > MAX_INPUT_BYTES { + return Err("stdin exceeds size limit".to_string()); + } + return Ok(vec![stdin_data]); + } + + let mut total = 0usize; + let mut sources = Vec::new(); + for path in &opts.input_paths { + let mut data = String::new(); + if path == "-" { + io::stdin() + .take((MAX_INPUT_BYTES.saturating_sub(total) + 1) as u64) + .read_to_string(&mut data) + .map_err(|e| format!("failed to read stdin: {}", e))?; + } else { + FsFile::open(path) + .map_err(|e| format!("failed to open {}: {}", path, e))? + .take((MAX_INPUT_BYTES.saturating_sub(total) + 1) as u64) + .read_to_string(&mut data) + .map_err(|e| format!("failed to read {}: {}", path, e))?; + } + total = total + .checked_add(data.len()) + .ok_or_else(|| "input exceeds size limit".to_string())?; + if total > MAX_INPUT_BYTES { + return Err("input exceeds size limit".to_string()); + } + sources.push(data); + } + Ok(sources) +} + fn read_inputs(opts: &JqOptions) -> Result, String> { if opts.null_input { return Ok(vec![Val::from(serde_json::Value::Null)]); } - let mut stdin_data = String::new(); - io::stdin() - .take((MAX_INPUT_BYTES + 1) as u64) - .read_to_string(&mut stdin_data) - .map_err(|e| format!("failed to read stdin: {}", e))?; - if stdin_data.len() > MAX_INPUT_BYTES { - return Err("stdin exceeds size limit".to_string()); - } + let sources = read_sources(opts)?; if opts.raw_input { + let raw_data = sources.concat(); if opts.slurp { - let mut arr = Vec::new(); - for line in stdin_data.lines() { - push_input_value(&mut arr, serde_json::Value::String(line.to_string()))?; - } - Ok(vec![Val::from(serde_json::Value::Array(arr))]) + Ok(vec![Val::from(serde_json::Value::String(raw_data))]) } else { let mut lines = Vec::new(); - for line in stdin_data.lines() { + for line in raw_data.lines() { push_input_value( &mut lines, Val::from(serde_json::Value::String(line.to_string())), @@ -163,16 +207,23 @@ fn read_inputs(opts: &JqOptions) -> Result, String> { Ok(lines) } } else { - let trimmed = stdin_data.trim(); - if trimmed.is_empty() { - return Ok(vec![Val::from(serde_json::Value::Null)]); + let mut values = Vec::new(); + for source in &sources { + let trimmed = source.trim(); + if trimmed.is_empty() { + continue; + } + + let decoder = + serde_json::Deserializer::from_str(trimmed).into_iter::(); + for result in decoder { + let value = result.map_err(|e| format!("parse error: {}", e))?; + push_input_value(&mut values, value)?; + } } - let mut values = Vec::new(); - let decoder = serde_json::Deserializer::from_str(trimmed).into_iter::(); - for result in decoder { - let value = result.map_err(|e| format!("invalid JSON input: {}", e))?; - push_input_value(&mut values, value)?; + if values.is_empty() && opts.input_paths.is_empty() { + values.push(serde_json::Value::Null); } if opts.slurp { @@ -217,6 +268,11 @@ fn format_output(val: &Val, opts: &JqOptions) -> Result { } fn run_jq(args: &[String]) -> Result { + if matches!(args, [arg] if arg == "--version" || arg == "-V") { + println!("jq-jaq-{}", env!("CARGO_PKG_VERSION")); + return Ok(0); + } + let opts = parse_args(args)?; let inputs = read_inputs(&opts)?; diff --git a/software/jq/test/jq.test.ts b/software/jq/test/jq.test.ts new file mode 100644 index 0000000000..5d15eaebc3 --- /dev/null +++ b/software/jq/test/jq.test.ts @@ -0,0 +1,153 @@ +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + NodeFileSystem, + createKernel, + createWasmVmRuntime, + describeIf, +} from "@agentos/test-harness"; +import type { Kernel } from "@agentos/test-harness"; +import { afterEach, expect, it } from "vitest"; + +const JQ_COMMAND_DIR = fileURLToPath(new URL("../bin", import.meta.url)); +const hasJqPackageBinary = existsSync(join(JQ_COMMAND_DIR, "jq")); + +let tempRoot: string | undefined; + +async function writeFixture(path: string, contents: string): Promise { + if (!tempRoot) throw new Error("fixture root not initialized"); + const hostPath = join(tempRoot, path.replace(/^\/+/, "")); + await mkdir(dirname(hostPath), { recursive: true }); + await writeFile(hostPath, contents); +} + +async function createTestVFS(): Promise { + tempRoot = await mkdtemp(join(tmpdir(), "agentos-jq-")); + await writeFixture( + "/project/users.json", + `${JSON.stringify( + { + users: [ + { name: "Ada", active: true, team: "runtime", score: 7 }, + { name: "Grace", active: false, team: "docs", score: 5 }, + { name: "Linus", active: true, team: "runtime", score: 3 }, + ], + }, + null, + 2, + )}\n`, + ); + await writeFixture( + "/project/events.ndjson", + [ + JSON.stringify({ type: "build", value: 4 }), + JSON.stringify({ type: "test", value: 6 }), + JSON.stringify({ type: "deploy", value: 2 }), + ].join("\n") + "\n", + ); + await writeFixture("/project/broken.json", '{"users": ['); + return new NodeFileSystem({ root: tempRoot }); +} + +function lines(stdout: string): string[] { + return stdout.split("\n").filter((line) => line.length > 0); +} + +describeIf(hasJqPackageBinary, "jq command", { timeout: 10_000 }, () => { + let kernel: Kernel | undefined; + + afterEach(async () => { + await kernel?.dispose(); + kernel = undefined; + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + tempRoot = undefined; + } + }); + + async function mountFixture(): Promise { + const vfs = await createTestVFS(); + kernel = createKernel({ filesystem: vfs }); + await kernel.mount(createWasmVmRuntime({ commandDirs: [JQ_COMMAND_DIR] })); + } + + async function runJq(args: string[]) { + if (!kernel) throw new Error("kernel not mounted"); + let stdout = ""; + let stderr = ""; + const proc = kernel.spawn("jq", args, { + onStdout: (chunk) => { + stdout += Buffer.from(chunk).toString("utf8"); + }, + onStderr: (chunk) => { + stderr += Buffer.from(chunk).toString("utf8"); + }, + }); + const exitCode = await proc.wait(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { stdout, stderr, exitCode }; + } + + it("reports a jq-compatible version", async () => { + await mountFixture(); + + const result = await runJq(["--version"]); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toMatch(/^jq-/); + }); + + it("filters arrays and emits raw strings", async () => { + await mountFixture(); + + const result = await runJq([ + "-r", + '.users[] | select(.active) | "\\(.name):\\(.score)"', + "/project/users.json", + ]); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(lines(result.stdout)).toEqual(["Ada:7", "Linus:3"]); + }); + + it("builds aggregate JSON objects", async () => { + await mountFixture(); + + const result = await runJq([ + "-c", + '{activeNames: [.users[] | select(.active) | .name], runtimeTotal: ([.users[] | select(.team == "runtime") | .score] | add)}', + "/project/users.json", + ]); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + activeNames: ["Ada", "Linus"], + runtimeTotal: 10, + }); + }); + + it("slurps newline-delimited JSON records", async () => { + await mountFixture(); + + const result = await runJq([ + "-s", + "-c", + "{count: length, total: (map(.value) | add), types: map(.type)}", + "/project/events.ndjson", + ]); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + count: 3, + total: 12, + types: ["build", "test", "deploy"], + }); + }); + + it("fails with a parse error for invalid JSON", async () => { + await mountFixture(); + + const result = await runJq([".", "/project/broken.json"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("parse error"); + }); +});