diff --git a/crates/bashkit/src/builtins/jq/args.rs b/crates/bashkit/src/builtins/jq/args.rs index c2c73ccb2..59fef157b 100644 --- a/crates/bashkit/src/builtins/jq/args.rs +++ b/crates/bashkit/src/builtins/jq/args.rs @@ -12,7 +12,7 @@ use crate::interpreter::ExecResult; -use super::convert::{MAX_JQ_JSON_DEPTH, check_json_depth}; +use super::convert::{JqJson, JsonParseError, parse_json_value}; use super::format::Indent; /// Maximum number of positional `--args` / `--jsonargs` values per call. @@ -50,11 +50,11 @@ pub(super) struct JqArgs<'a> { pub indent: Indent, pub file_args: Vec<&'a str>, /// Named string/JSON variables: `(name_with_$, value)`. - pub var_bindings: Vec<(String, serde_json::Value)>, + pub var_bindings: Vec<(String, JqJson)>, /// `--args` positional (string) / `--jsonargs` positional (json). - pub positional_args: Vec, + pub positional_args: Vec, /// Named args from `--arg`/`--argjson`, exposed via `$ARGS.named`. - pub named_args: Vec<(String, serde_json::Value)>, + pub named_args: Vec<(String, JqJson)>, /// `--slurpfile name file` and `--rawfile name file` requests, resolved /// after parsing. pub file_var_requests: Vec>, @@ -243,19 +243,14 @@ pub(super) fn parse<'a>(args: &'a [String]) -> ParseOutcome<'a> { (Some(name), Some(value)) => { let var = format!("${name}"); let v = if arg == "--arg" { - serde_json::Value::String(value.clone()) + JqJson::String(value.clone()) } else { - match serde_json::from_str::(value) { - Ok(v) => { - if let Err(e) = check_json_depth(&v, MAX_JQ_JSON_DEPTH) { - return ParseOutcome::Done(ExecResult::err( - format!("{e}\n"), - 2, - )); - } - v + match parse_json_value(value) { + Ok(v) => v, + Err(JsonParseError::TooDeep(e)) => { + return ParseOutcome::Done(ExecResult::err(format!("{e}\n"), 2)); } - Err(e) => { + Err(JsonParseError::Invalid(e)) => { return ParseOutcome::Done(usage_error(format!( "jq: invalid JSON for --argjson: {e}" ))); @@ -396,17 +391,14 @@ fn push_positional( } match mode { PositionalMode::Strings => { - out.positional_args - .push(serde_json::Value::String(arg.to_owned())); + out.positional_args.push(JqJson::String(arg.to_owned())); } - PositionalMode::Json => match serde_json::from_str::(arg) { - Ok(v) => { - if let Err(e) = check_json_depth(&v, MAX_JQ_JSON_DEPTH) { - return Err(Box::new(ExecResult::err(format!("{e}\n"), 2))); - } - out.positional_args.push(v); + PositionalMode::Json => match parse_json_value(arg) { + Ok(v) => out.positional_args.push(v), + Err(JsonParseError::TooDeep(e)) => { + return Err(Box::new(ExecResult::err(format!("{e}\n"), 2))); } - Err(e) => { + Err(JsonParseError::Invalid(e)) => { return Err(Box::new(usage_error(format!( "jq: invalid JSON for --jsonargs: {e}" )))); @@ -437,6 +429,10 @@ fn unknown_option(opt: &str) -> ExecResult { mod tests { use super::*; + fn json(s: &str) -> JqJson { + parse_json_value(s).unwrap() + } + fn parse_strs(args: &[&str]) -> ParseOutcome<'static> { let v: Vec = args.iter().map(|s| (*s).to_string()).collect(); // SAFETY: tests only — leak the vec so the lifetime borrow checks. @@ -537,7 +533,7 @@ mod tests { ParseOutcome::Args(a) => { assert_eq!(a.var_bindings.len(), 1); assert_eq!(a.var_bindings[0].0, "$x"); - assert_eq!(a.var_bindings[0].1, serde_json::Value::String("hi".into())); + assert_eq!(a.var_bindings[0].1, JqJson::String("hi".into())); } _ => panic!("expected Args"), } @@ -548,7 +544,7 @@ mod tests { match parse_strs(&["--argjson", "n", "42", "."]) { ParseOutcome::Args(a) => { assert_eq!(a.var_bindings[0].0, "$n"); - assert_eq!(a.var_bindings[0].1, serde_json::json!(42)); + assert_eq!(a.var_bindings[0].1, json("42")); } _ => panic!("expected Args"), } @@ -616,8 +612,8 @@ mod tests { ParseOutcome::Args(a) => { assert!(a.null_input); assert_eq!(a.positional_args.len(), 3); - assert_eq!(a.positional_args[0], serde_json::json!("a")); - assert_eq!(a.positional_args[2], serde_json::json!("c")); + assert_eq!(a.positional_args[0], json(r#""a""#)); + assert_eq!(a.positional_args[2], json(r#""c""#)); assert!(a.file_args.is_empty()); } _ => panic!("expected Args"), @@ -628,9 +624,9 @@ mod tests { fn jsonargs_become_positional_json() { match parse_strs(&["-n", ".", "--jsonargs", "1", "true", r#"{"a":1}"#]) { ParseOutcome::Args(a) => { - assert_eq!(a.positional_args[0], serde_json::json!(1)); - assert_eq!(a.positional_args[1], serde_json::json!(true)); - assert_eq!(a.positional_args[2], serde_json::json!({"a":1})); + assert_eq!(a.positional_args[0], json("1")); + assert_eq!(a.positional_args[1], json("true")); + assert_eq!(a.positional_args[2], json(r#"{"a":1}"#)); } _ => panic!("expected Args"), } diff --git a/crates/bashkit/src/builtins/jq/convert.rs b/crates/bashkit/src/builtins/jq/convert.rs index 61d02c964..d91752441 100644 --- a/crates/bashkit/src/builtins/jq/convert.rs +++ b/crates/bashkit/src/builtins/jq/convert.rs @@ -21,7 +21,7 @@ pub(super) const MAX_JQ_JSON_DEPTH: usize = 100; /// the original numeric representation through filter execution and /// output formatting. Real jq prints `1.0` as `1.0`; the stock /// `serde_json::Value` (without `arbitrary_precision`) cannot. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub(super) enum JqJson { Null, Bool(bool), @@ -43,40 +43,134 @@ impl JqJson { } } -/// Convert serde_json::Value to our JqJson, capturing original number tokens. -/// THREAT[TM-DOS-027]: depth checked at the same time. -pub(super) fn serde_to_jq( - v: &serde_json::Value, +/// Depth-checking, order-preserving JSON reader for [`JqJson`]. +/// +/// Real jq keeps object keys in input order, so input never passes through +/// `serde_json::Value` (whose map is sorted without `preserve_order`, a +/// crate-wide feature we do not want to flip). THREAT[TM-DOS-027]: nesting +/// is checked while reading; the first violation is parked in `too_deep` so +/// callers report it verbatim instead of as a generic parse error. +struct JqSeed<'a> { depth: usize, max: usize, -) -> std::result::Result { - if depth > max { - return Err(format!( - "jq: JSON nesting too deep ({depth} levels, max {max})" - )); + too_deep: &'a std::cell::Cell>, +} + +impl<'a> JqSeed<'a> { + fn child(&self) -> JqSeed<'a> { + JqSeed { + depth: self.depth + 1, + max: self.max, + too_deep: self.too_deep, + } } - Ok(match v { - serde_json::Value::Null => JqJson::Null, - serde_json::Value::Bool(b) => JqJson::Bool(*b), - serde_json::Value::Number(n) => JqJson::Number(n.to_string()), - serde_json::Value::String(s) => JqJson::String(s.clone()), - serde_json::Value::Array(arr) => { - let mut out = Vec::with_capacity(arr.len()); - for item in arr { - out.push(serde_to_jq(item, depth + 1, max)?); - } - JqJson::Array(out) +} + +impl<'de> serde::de::DeserializeSeed<'de> for JqSeed<'_> { + type Value = JqJson; + + fn deserialize>(self, d: D) -> Result { + if self.depth > self.max { + let msg = format!( + "jq: JSON nesting too deep ({} levels, max {})", + self.depth, self.max + ); + self.too_deep.set(Some(msg.clone())); + return Err(serde::de::Error::custom(msg)); + } + d.deserialize_any(self) + } +} + +impl<'de> serde::de::Visitor<'de> for JqSeed<'_> { + type Value = JqJson; + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a JSON value") + } + + fn visit_unit(self) -> Result { + Ok(JqJson::Null) + } + + fn visit_bool(self, b: bool) -> Result { + Ok(JqJson::Bool(b)) + } + + fn visit_i64(self, n: i64) -> Result { + Ok(JqJson::Number(n.to_string())) + } + + fn visit_u64(self, n: u64) -> Result { + Ok(JqJson::Number(n.to_string())) + } + + fn visit_f64(self, f: f64) -> Result { + // Same token serde_json::Number prints, so `1.0` stays `1.0`. + serde_json::Number::from_f64(f) + .map(|n| JqJson::Number(n.to_string())) + .ok_or_else(|| E::custom("non-finite number")) + } + + fn visit_str(self, s: &str) -> Result { + Ok(JqJson::String(s.to_owned())) + } + + fn visit_string(self, s: String) -> Result { + Ok(JqJson::String(s)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::new(); + while let Some(item) = seq.next_element_seed(self.child())? { + out.push(item); } - serde_json::Value::Object(map) => { - let mut out = Vec::with_capacity(map.len()); - for (k, item) in map { - out.push((k.clone(), serde_to_jq(item, depth + 1, max)?)); + Ok(JqJson::Array(out)) + } + + fn visit_map>(self, mut map: A) -> Result { + let mut out: Vec<(String, JqJson)> = Vec::new(); + while let Some(key) = map.next_key::()? { + let value = map.next_value_seed(self.child())?; + // Duplicate keys: last value wins in its first position, as in jq. + if let Some(slot) = out.iter_mut().find(|(k, _)| *k == key) { + slot.1 = value; + } else { + out.push((key, value)); } - JqJson::Object(out) } + Ok(JqJson::Object(out)) + } +} + +/// Parse exactly one JSON value (`--argjson`, `--jsonargs`), keeping key +/// order. `Err` is the user-facing message; depth errors come back verbatim, +/// syntax errors as serde's text (callers add their own prefix). +pub(super) fn parse_json_value(input: &str) -> std::result::Result { + use serde::de::DeserializeSeed; + let too_deep = std::cell::Cell::new(None); + let mut de = serde_json::Deserializer::from_str(input); + let seed = JqSeed { + depth: 0, + max: MAX_JQ_JSON_DEPTH, + too_deep: &too_deep, + }; + let result = seed.deserialize(&mut de).and_then(|v| de.end().map(|()| v)); + result.map_err(|e| match too_deep.take() { + Some(msg) => JsonParseError::TooDeep(msg), + None => JsonParseError::Invalid(e.to_string()), }) } +/// Why [`parse_json_value`] rejected its input. +#[derive(Debug)] +pub(super) enum JsonParseError { + /// Nesting past `MAX_JQ_JSON_DEPTH`; the message is complete. + TooDeep(String), + /// Malformed JSON; serde's description without a `jq:` prefix. + Invalid(String), +} + /// Convert our JqJson to a jaq Val for filter execution. pub(super) fn jq_to_val(v: &JqJson) -> Val { match v { @@ -190,51 +284,37 @@ fn format_f64_canonical(f: f64) -> String { } } -/// THREAT[TM-DOS-027]: Standalone depth check used by --argjson and other -/// entry points where the value comes from outside the main parser. -pub(super) fn check_json_depth( - value: &serde_json::Value, - max_depth: usize, -) -> std::result::Result<(), String> { - fn measure(v: &serde_json::Value, cur: usize, max: usize) -> std::result::Result<(), String> { - if cur > max { - return Err(format!( - "jq: JSON nesting too deep ({cur} levels, max {max})" - )); - } - match v { - serde_json::Value::Array(arr) => { - for item in arr { - measure(item, cur + 1, max)?; - } - } - serde_json::Value::Object(map) => { - for (_k, item) in map { - measure(item, cur + 1, max)?; - } - } - _ => {} - } - Ok(()) - } - measure(value, 0, max_depth) -} - /// Parse multiple JSON values from a stream (handles NDJSON, multi-line, -/// concatenated). Each value is depth-checked. +/// concatenated). Each value is depth-checked and keeps its key order. pub(super) fn parse_json_stream(input: &str) -> std::result::Result, String> { - use serde_json::Deserializer; + use serde::de::DeserializeSeed; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(Vec::new()); } + let too_deep = std::cell::Cell::new(None); + let mut de = serde_json::Deserializer::from_str(trimmed); let mut vals = Vec::new(); - let stream = Deserializer::from_str(trimmed).into_iter::(); - for result in stream { - let v = result.map_err(|e| format!("jq: invalid JSON: {e}"))?; - vals.push(serde_to_jq(&v, 0, MAX_JQ_JSON_DEPTH)?); + loop { + // Skip inter-value whitespace; stop at end of input. + if de.end().is_ok() { + break; + } + let seed = JqSeed { + depth: 0, + max: MAX_JQ_JSON_DEPTH, + too_deep: &too_deep, + }; + match seed.deserialize(&mut de) { + Ok(v) => vals.push(v), + Err(e) => { + return Err(too_deep + .take() + .unwrap_or_else(|| format!("jq: invalid JSON: {e}"))); + } + } } Ok(vals) } @@ -243,12 +323,14 @@ pub(super) fn parse_json_stream(input: &str) -> std::result::Result, mod tests { use super::*; + fn one(s: &str) -> JqJson { + parse_json_value(s).unwrap() + } + #[test] fn round_trip_preserves_float_zero_decimal() { // 1.0 must NOT collapse to 1 — real jq preserves it. - let v = serde_json::json!(1.0); - let jq = serde_to_jq(&v, 0, 100).unwrap(); - match jq { + match one("1.0") { JqJson::Number(s) => assert_eq!(s, "1.0"), _ => panic!("expected Number"), } @@ -256,30 +338,69 @@ mod tests { #[test] fn integer_stays_integer() { - let v = serde_json::json!(42); - let jq = serde_to_jq(&v, 0, 100).unwrap(); - match jq { + match one("42") { JqJson::Number(s) => assert_eq!(s, "42"), _ => panic!("expected Number"), } } #[test] - fn check_json_depth_flat_ok() { - let v = serde_json::json!(42); - assert!(check_json_depth(&v, 100).is_ok()); + fn object_keeps_input_key_order() { + let JqJson::Object(map) = one(r#"{"b":1,"a":2,"c":3}"#) else { + panic!("expected Object"); + }; + let keys: Vec<&str> = map.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, ["b", "a", "c"]); + } + + #[test] + fn duplicate_key_keeps_first_position_last_value() { + let JqJson::Object(map) = one(r#"{"a":1,"b":2,"a":3}"#) else { + panic!("expected Object"); + }; + assert_eq!(map.len(), 2); + assert_eq!(map[0].0, "a"); + assert!(matches!(&map[0].1, JqJson::Number(n) if n == "3")); + } + + #[test] + fn value_depth_limit() { + let ok = format!("{}1{}", "[".repeat(5), "]".repeat(5)); + assert!(parse_json_value(&ok).is_ok()); + let deep = format!( + "{}1{}", + "[".repeat(MAX_JQ_JSON_DEPTH + 1), + "]".repeat(MAX_JQ_JSON_DEPTH + 1) + ); + assert!(matches!( + parse_json_value(&deep), + Err(JsonParseError::TooDeep(_)) + )); + } + + #[test] + fn value_rejects_trailing_garbage() { + assert!(matches!( + parse_json_value("1 2"), + Err(JsonParseError::Invalid(_)) + )); } #[test] - fn check_json_depth_nested_ok() { - let v = serde_json::json!([[[1]]]); - assert!(check_json_depth(&v, 5).is_ok()); + fn stream_depth_error_is_verbatim() { + let deep = format!( + "{}1{}", + "[".repeat(MAX_JQ_JSON_DEPTH + 1), + "]".repeat(MAX_JQ_JSON_DEPTH + 1) + ); + let err = parse_json_stream(&deep).unwrap_err(); + assert!(err.starts_with("jq: JSON nesting too deep"), "{err}"); } #[test] - fn check_json_depth_too_deep() { - let v = serde_json::json!([[[1]]]); - assert!(check_json_depth(&v, 2).is_err()); + fn stream_reports_syntax_error() { + let err = parse_json_stream("1 {").unwrap_err(); + assert!(err.starts_with("jq: invalid JSON:"), "{err}"); } #[test] diff --git a/crates/bashkit/src/builtins/jq/mod.rs b/crates/bashkit/src/builtins/jq/mod.rs index fa381ec53..1895b807a 100644 --- a/crates/bashkit/src/builtins/jq/mod.rs +++ b/crates/bashkit/src/builtins/jq/mod.rs @@ -5,7 +5,7 @@ //! Layout: //! - `args`: CLI parsing (incl. `--slurpfile`, `--rawfile`, `--args`, //! `--jsonargs`, `--indent`) -//! - `convert`: serde_json <-> JqJson <-> jaq Val conversion, depth check +//! - `convert`: order-preserving JSON reader -> JqJson <-> jaq Val, depth check //! - `format`: indent-aware output rendering (custom `--indent N`) //! - `compat`: prepended jq-compat definitions and global var names //! - `errors`: jq-style error formatting (no Debug-shape leaks) @@ -64,7 +64,7 @@ use compat::{ ARGS_VAR_NAME, ENV_VAR_NAME, FILENAME_VAR_NAME, LINENO_VAR_NAME, PUBLIC_ENV_VAR_NAME, build_compat_prefix, }; -use convert::{JqJson, MAX_JQ_JSON_DEPTH, jq_to_val, parse_json_stream, val_to_jq_capped}; +use convert::{JqJson, jq_to_val, parse_json_stream, val_to_jq_capped}; use errors::{format_compile_errors, format_load_errors, format_runtime_error}; use format::{Indent, render, sort_keys as sort_jq_keys}; @@ -184,15 +184,12 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { } } let value = match req.kind { - FileVarKind::Raw => serde_json::Value::String(text), + FileVarKind::Raw => JqJson::String(text), FileVarKind::Slurp => match parse_jq_json_stream(&ctx, &text)? { - Ok(vals) => { - // Inner values are already depth-checked by parse_json_stream; - // the wrapping array adds one level which the recursive - // limit already accommodates. - let arr: Vec = vals.iter().map(jq_to_serde_value).collect(); - serde_json::Value::Array(arr) - } + // Inner values are already depth-checked by parse_json_stream; + // the wrapping array adds one level which the recursive + // limit already accommodates. + Ok(vals) => JqJson::Array(vals), Err(e) => return Ok(ExecResult::err(format!("{e}\n"), 5)), }, }; @@ -236,11 +233,12 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { // ctx.env takes precedence over ctx.variables (prefix assignments // shadow exported variables). let env_obj = { - let mut map = serde_json::Map::new(); + // Sorted by name: deterministic regardless of HashMap order. + let mut map = std::collections::BTreeMap::new(); for (k, v) in ctx.variables.iter().chain(ctx.env.iter()) { - map.insert(k.clone(), serde_json::Value::String(v.clone())); + map.insert(k.clone(), JqJson::String(v.clone())); } - serde_json::Value::Object(map) + JqJson::Object(map.into_iter().collect()) }; // Compose the filter: prepend compat defs, the env def, etc. @@ -348,12 +346,9 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { }; // Pre-convert globals to Val once. - let env_val = jq_to_val(&jq_from_serde(&env_obj)); - let args_val = jq_to_val(&jq_from_serde(&args_obj)); - let pre_var_vals: Vec = all_var_bindings - .iter() - .map(|(_, v)| jq_to_val(&jq_from_serde(v))) - .collect(); + let env_val = jq_to_val(&env_obj); + let args_val = jq_to_val(&args_obj); + let pre_var_vals: Vec = all_var_bindings.iter().map(|(_, v)| jq_to_val(v)).collect(); // Build inputs to process. let inputs_to_process: Vec = if parsed.null_input { @@ -648,20 +643,20 @@ fn file_binding_limit_error(used: usize, next: u64) -> ExecResult { ) } -/// `--rawfile`/`--slurpfile`/$ARGS plumbing helper. The serialized object is -/// `{"positional": [...], "named": {...}}`. -fn build_args_obj( - positional: &[serde_json::Value], - named: &[(String, serde_json::Value)], -) -> serde_json::Value { - let mut named_map = serde_json::Map::new(); +/// `--rawfile`/`--slurpfile`/$ARGS plumbing helper. The object is +/// `{"positional": [...], "named": {...}}`, named in argument order. +fn build_args_obj(positional: &[JqJson], named: &[(String, JqJson)]) -> JqJson { + let mut named_map: Vec<(String, JqJson)> = Vec::new(); for (k, v) in named { - named_map.insert(k.clone(), v.clone()); + match named_map.iter_mut().find(|(n, _)| n == k) { + Some(slot) => slot.1 = v.clone(), + None => named_map.push((k.clone(), v.clone())), + } } - serde_json::json!({ - "positional": positional, - "named": serde_json::Value::Object(named_map), - }) + JqJson::Object(vec![ + ("positional".to_string(), JqJson::Array(positional.to_vec())), + ("named".to_string(), JqJson::Object(named_map)), + ]) } /// `--slurpfile` / `--rawfile` reuse this to derive the filename Val. @@ -675,31 +670,3 @@ fn stdin_filename(parsed: &JqArgs<'_>) -> Val { None => Val::Null, } } - -/// Convert a serde_json::Value into our internal JqJson with a depth check. -fn jq_from_serde(v: &serde_json::Value) -> JqJson { - convert::serde_to_jq(v, 0, MAX_JQ_JSON_DEPTH).unwrap_or(JqJson::Null) -} - -/// Convert a JqJson back to serde_json::Value (lossy for Number tokens -/// outside i64/f64 range, which is acceptable since this only feeds into -/// our re-serializer for `--slurpfile` arrays — values originate from -/// `parse_json_stream`, so they round-trip cleanly). -fn jq_to_serde_value(v: &JqJson) -> serde_json::Value { - match v { - JqJson::Null => serde_json::Value::Null, - JqJson::Bool(b) => serde_json::Value::Bool(*b), - JqJson::Number(s) => { - serde_json::from_str::(s).unwrap_or(serde_json::Value::Null) - } - JqJson::String(s) => serde_json::Value::String(s.clone()), - JqJson::Array(arr) => serde_json::Value::Array(arr.iter().map(jq_to_serde_value).collect()), - JqJson::Object(map) => { - let mut out = serde_json::Map::new(); - for (k, item) in map { - out.insert(k.clone(), jq_to_serde_value(item)); - } - serde_json::Value::Object(out) - } - } -} diff --git a/crates/bashkit/tests/spec_cases/jq/jq.test.sh b/crates/bashkit/tests/spec_cases/jq/jq.test.sh index 86e019321..94e372b3e 100644 --- a/crates/bashkit/tests/spec_cases/jq/jq.test.sh +++ b/crates/bashkit/tests/spec_cases/jq/jq.test.sh @@ -1037,3 +1037,39 @@ FOO=bar jq -n '$ENV.FOO' ### expect "bar" ### end + +### jq_object_key_order_preserved +# Real jq keeps object keys in input order (no implicit sort) +echo '{"b":1,"a":2,"c":{"z":1,"y":2}}' | jq -c '.' +### expect +{"b":1,"a":2,"c":{"z":1,"y":2}} +### end + +### jq_object_key_order_sort_keys +# -S sorts keys recursively +echo '{"b":1,"a":2,"c":{"z":1,"y":2}}' | jq -S -c '.' +### expect +{"a":2,"b":1,"c":{"y":2,"z":1}} +### end + +### jq_object_key_order_del +# del keeps the remaining keys in place +echo '{"users":[1],"meta":{"x":1},"id":3}' | jq -c 'del(.meta)' +### expect +{"users":[1],"id":3} +### end + +### jq_object_duplicate_key +# Duplicate key: last value wins, first position kept +echo '{"a":1,"b":2,"a":3}' | jq -c '.' +### expect +{"a":3,"b":2} +### end + +### jq_argjson_key_order +# --argjson and --slurpfile values keep key order too +echo '{"z":1,"y":2}' > /tmp/kv.json +jq -nc --argjson v '{"q":1,"p":2}' --slurpfile s /tmp/kv.json '[$v, $s]' +### expect +[{"q":1,"p":2},[{"z":1,"y":2}]] +### end diff --git a/knowledge/foundations/jq.md b/knowledge/foundations/jq.md index bdd3db7ba..1ed8662f7 100644 --- a/knowledge/foundations/jq.md +++ b/knowledge/foundations/jq.md @@ -32,6 +32,20 @@ Malformed quoting is rejected by the boundary with a stable jq-shaped error. Controls outside strings remain untouched, so only JSON whitespace is accepted there by the strict parser. +## Object key order + +Real jq keeps object keys in input order and only sorts under `-S`. The jq +builtin therefore never routes JSON through `serde_json::Value`, whose map is +sorted unless the crate-wide `preserve_order` feature is on (flipping it would +change every other `serde_json` user in the workspace). Instead +`convert::parse_json_stream` and `convert::parse_json_value` deserialize +straight into `JqJson` with a depth-tracking `DeserializeSeed`, keeping keys as +an ordered `Vec`. A duplicate key keeps its first position and takes the last +value, matching jq. This covers main input, `--slurpfile`, `--argjson`, +`--jsonargs` and `$ARGS`. `$ENV`/`env` stay sorted by name for determinism. +The nesting cap (TM-DOS-027) is enforced while reading; a depth violation is +reported verbatim, not as a generic parse error. + ## Resource accounting Normalization is a bounded single pass. It charges input length to the shared @@ -47,6 +61,8 @@ lease remains live until strict parsing finishes (TM-DOS-100). escapes, structural controls, NDJSON/concatenated values, malformed quoting, stdin/file/slurpfile paths, exact work/live-memory boundaries, debug-leak invariants, and differential filter/output semantics against real jq. +`spec_cases/jq/jq.test.sh` `jq_object_key_order_*` cases pin key order for +input, `del`, duplicate keys, `-S`, `--argjson` and `--slurpfile`. `builtins::json::tests::literal_control_in_string_remains_invalid_json` proves the exception does not cross into the strict `json` builtin.