From 01e16d8f6779ce462186891eca26c0a243cfb7cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 01:03:02 +0000 Subject: [PATCH] fix(builtins): resolve 10 eval-surfaced interpreter bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 7 bugs and unignore 10 previously-skipped spec tests: - tail: support `tail -n +N` (start from line N) syntax - tr: implement POSIX character classes ([:lower:], [:upper:], etc.) - grep: add BRE mode — literal ( ) are escaped, \( \) become groups - sed: proper BRE-to-ERE conversion for patterns with literal parens - awk: implement match() 3rd-arg capture array (gawk extension) - interpreter: execute VFS scripts by path after chmod +x Tests fixed: tr_class_upper_from_pipe, while_read_pipe_vars, tail_plus_n_offset, script_chmod_exec_by_path, grep_bre_literal_paren, grep_bre_literal_paren_pattern, awk_field_multiply_accumulate, awk_match_capture_array, sed_capture_group_complex_bre, sed_ere_capture_group_extract Skipped test count: 87 → 77 https://claude.ai/code/session_016XS5TJwtYPBB7ao42BTFNx --- crates/bashkit/src/builtins/awk.rs | 29 ++++- crates/bashkit/src/builtins/cuttr.rs | 119 +++++++++++++++--- crates/bashkit/src/builtins/grep.rs | 48 ++++++- crates/bashkit/src/builtins/headtail.rs | 82 ++++++++++-- crates/bashkit/src/builtins/sed.rs | 45 +++++-- crates/bashkit/src/interpreter/mod.rs | 42 +++++++ .../tests/spec_cases/awk/eval-bugs.test.sh | 10 +- .../tests/spec_cases/bash/eval-bugs.test.sh | 11 +- .../tests/spec_cases/grep/eval-bugs.test.sh | 7 +- .../tests/spec_cases/sed/eval-bugs.test.sh | 7 +- crates/bashkit/tests/spec_tests.rs | 2 +- 11 files changed, 338 insertions(+), 64 deletions(-) diff --git a/crates/bashkit/src/builtins/awk.rs b/crates/bashkit/src/builtins/awk.rs index bf2771eb..c4271d08 100644 --- a/crates/bashkit/src/builtins/awk.rs +++ b/crates/bashkit/src/builtins/awk.rs @@ -1889,14 +1889,41 @@ impl AwkInterpreter { } let s = self.eval_expr(&args[0]).as_string(); let pattern = self.eval_expr(&args[1]).as_string(); + // Extract capture array name from 3rd arg (gawk extension) + let arr_name = if args.len() >= 3 { + if let AwkExpr::Variable(name) = &args[2] { + Some(name.clone()) + } else { + None + } + } else { + None + }; if let Ok(re) = Regex::new(&pattern) { - if let Some(m) = re.find(&s) { + if let Some(caps) = re.captures(&s) { + let m = caps.get(0).unwrap(); let rstart = m.start() + 1; // awk is 1-indexed let rlength = m.end() - m.start(); self.state .set_variable("RSTART", AwkValue::Number(rstart as f64)); self.state .set_variable("RLENGTH", AwkValue::Number(rlength as f64)); + // Populate capture array if 3rd arg provided + if let Some(ref arr) = arr_name { + // arr[0] = entire match + let full_key = format!("{}[0]", arr); + self.state + .set_variable(&full_key, AwkValue::String(m.as_str().to_string())); + // arr[1..N] = capture groups + for i in 1..caps.len() { + let key = format!("{}[{}]", arr, i); + let val = caps + .get(i) + .map(|c| c.as_str().to_string()) + .unwrap_or_default(); + self.state.set_variable(&key, AwkValue::String(val)); + } + } AwkValue::Number(rstart as f64) } else { self.state.set_variable("RSTART", AwkValue::Number(0.0)); diff --git a/crates/bashkit/src/builtins/cuttr.rs b/crates/bashkit/src/builtins/cuttr.rs index f4439817..fd8dc1a4 100644 --- a/crates/bashkit/src/builtins/cuttr.rs +++ b/crates/bashkit/src/builtins/cuttr.rs @@ -202,31 +202,91 @@ impl Builtin for Tr { } } -/// Expand a character set specification like "a-z" into a list of characters +/// Expand a character set specification like "a-z" into a list of characters. +/// Supports POSIX character classes: [:lower:], [:upper:], [:digit:], [:alpha:], [:alnum:], [:space:] fn expand_char_set(spec: &str) -> Vec { let mut chars = Vec::new(); - let mut iter = spec.chars().peekable(); - - while let Some(c) = iter.next() { - if iter.peek() == Some(&'-') { - iter.next(); // consume '-' - if let Some(&end) = iter.peek() { - iter.next(); // consume end char - // Expand range - let start = c as u32; - let end = end as u32; - for code in start..=end { - if let Some(ch) = char::from_u32(code) { - chars.push(ch); + let mut i = 0; + let bytes = spec.as_bytes(); + + while i < bytes.len() { + // Check for POSIX character class [:class:] + if bytes[i] == b'[' && i + 1 < bytes.len() && bytes[i + 1] == b':' { + if let Some(end) = spec[i + 2..].find(":]") { + let class_name = &spec[i + 2..i + 2 + end]; + match class_name { + "lower" => chars.extend('a'..='z'), + "upper" => chars.extend('A'..='Z'), + "digit" => chars.extend('0'..='9'), + "alpha" => { + chars.extend('a'..='z'); + chars.extend('A'..='Z'); + } + "alnum" => { + chars.extend('a'..='z'); + chars.extend('A'..='Z'); + chars.extend('0'..='9'); + } + "space" => chars.extend([' ', '\t', '\n', '\r', '\x0b', '\x0c']), + "blank" => chars.extend([' ', '\t']), + "print" | "graph" => { + for code in 0x20u8..=0x7e { + chars.push(code as char); + } + } + _ => { + // Unknown class, treat literally + chars.push('['); + i += 1; + continue; } } - } else { - // Trailing dash, treat literally - chars.push(c); - chars.push('-'); + i += 2 + end + 2; // skip past [: + class + :] + continue; } + } + + let c = bytes[i] as char; + // Check for range like a-z + if i + 2 < bytes.len() && bytes[i + 1] == b'-' { + let end = bytes[i + 2] as char; + let start = c as u32; + let end = end as u32; + for code in start..=end { + if let Some(ch) = char::from_u32(code) { + chars.push(ch); + } + } + i += 3; + } else if i + 1 == bytes.len() - 1 && bytes[i + 1] == b'-' { + // Trailing dash + chars.push(c); + chars.push('-'); + i += 2; } else { + // Handle escape sequences + if c == '\\' && i + 1 < bytes.len() { + match bytes[i + 1] { + b'n' => { + chars.push('\n'); + i += 2; + continue; + } + b't' => { + chars.push('\t'); + i += 2; + continue; + } + b'\\' => { + chars.push('\\'); + i += 2; + continue; + } + _ => {} + } + } chars.push(c); + i += 1; } } @@ -338,6 +398,29 @@ mod tests { assert_eq!(expand_char_set("0-2"), vec!['0', '1', '2']); } + #[test] + fn test_expand_char_class_lower() { + let lower = expand_char_set("[:lower:]"); + assert_eq!(lower.len(), 26); + assert_eq!(lower[0], 'a'); + assert_eq!(lower[25], 'z'); + } + + #[test] + fn test_expand_char_class_upper() { + let upper = expand_char_set("[:upper:]"); + assert_eq!(upper.len(), 26); + assert_eq!(upper[0], 'A'); + assert_eq!(upper[25], 'Z'); + } + + #[tokio::test] + async fn test_tr_char_class_lower_to_upper() { + let result = run_tr(&["[:lower:]", "[:upper:]"], Some("hello world\n")).await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "HELLO WORLD\n"); + } + #[test] fn test_parse_field_spec() { assert_eq!(parse_field_spec("1"), vec![1]); diff --git a/crates/bashkit/src/builtins/grep.rs b/crates/bashkit/src/builtins/grep.rs index f1ec7d0c..f87fb856 100644 --- a/crates/bashkit/src/builtins/grep.rs +++ b/crates/bashkit/src/builtins/grep.rs @@ -51,6 +51,7 @@ struct GrepOptions { count_only: bool, files_with_matches: bool, fixed_strings: bool, + extended_regex: bool, only_matching: bool, word_regex: bool, quiet: bool, @@ -78,6 +79,7 @@ impl GrepOptions { count_only: false, files_with_matches: false, fixed_strings: false, + extended_regex: false, only_matching: false, word_regex: false, quiet: false, @@ -114,8 +116,8 @@ impl GrepOptions { 'o' => opts.only_matching = true, 'w' => opts.word_regex = true, 'F' => opts.fixed_strings = true, - 'E' => {} // Extended regex is default - 'P' => {} // Perl regex - regex crate supports most Perl features + 'E' => opts.extended_regex = true, + 'P' => opts.extended_regex = true, // Perl regex implies ERE 'q' => opts.quiet = true, 'x' => opts.whole_line = true, 'H' => opts.show_filename = true, @@ -298,6 +300,11 @@ impl GrepOptions { } let pat = if self.fixed_strings { regex::escape(p) + } else if !self.extended_regex { + // BRE mode: convert to ERE for the regex crate + // In BRE: ( ) are literal, \( \) are groups + // In ERE/regex crate: ( ) are groups, \( \) are literal + bre_to_ere(p) } else { p.clone() }; @@ -335,6 +342,43 @@ impl GrepOptions { } } +/// Convert a BRE (Basic Regular Expression) pattern to ERE for the regex crate. +/// In BRE: ( ) { } are literal; \( \) \{ \} \+ \? \| are metacharacters. +/// In ERE/regex crate: ( ) { } + ? | are metacharacters. +fn bre_to_ere(pattern: &str) -> String { + let mut result = String::with_capacity(pattern.len()); + let chars: Vec = pattern.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '\\' && i + 1 < chars.len() { + match chars[i + 1] { + // BRE escaped metacharacters → ERE unescaped + '(' | ')' | '{' | '}' | '+' | '?' | '|' => { + result.push(chars[i + 1]); + i += 2; + } + // Other escapes pass through + _ => { + result.push('\\'); + result.push(chars[i + 1]); + i += 2; + } + } + } else if chars[i] == '(' || chars[i] == ')' || chars[i] == '{' || chars[i] == '}' { + // BRE literal chars → escape them for ERE + result.push('\\'); + result.push(chars[i]); + i += 1; + } else { + result.push(chars[i]); + i += 1; + } + } + + result +} + #[async_trait] impl Builtin for Grep { async fn execute(&self, ctx: Context<'_>) -> Result { diff --git a/crates/bashkit/src/builtins/headtail.rs b/crates/bashkit/src/builtins/headtail.rs index bc382ae0..47fb5478 100644 --- a/crates/bashkit/src/builtins/headtail.rs +++ b/crates/bashkit/src/builtins/headtail.rs @@ -69,20 +69,25 @@ impl Builtin for Head { /// /// Options: /// -n NUM Output the last NUM lines (default: 10) +/// -n +NUM Output starting from line NUM (1-indexed) /// -NUM Shorthand for -n NUM pub struct Tail; #[async_trait] impl Builtin for Tail { async fn execute(&self, ctx: Context<'_>) -> Result { - let (num_lines, files) = parse_head_tail_args(ctx.args, DEFAULT_LINES)?; + let (num_lines, from_start, files) = parse_tail_args(ctx.args, DEFAULT_LINES)?; let mut output = String::new(); if files.is_empty() { // Read from stdin if let Some(stdin) = ctx.stdin { - output = take_last_lines(stdin, num_lines); + output = if from_start { + take_from_line(stdin, num_lines) + } else { + take_last_lines(stdin, num_lines) + }; } } else { // Read from files @@ -104,7 +109,12 @@ impl Builtin for Tail { match ctx.fs.read_file(&path).await { Ok(content) => { let text = String::from_utf8_lossy(&content); - output.push_str(&take_last_lines(&text, num_lines)); + let selected = if from_start { + take_from_line(&text, num_lines) + } else { + take_last_lines(&text, num_lines) + }; + output.push_str(&selected); } Err(e) => { return Ok(ExecResult::err(format!("tail: {}: {}\n", file, e), 1)); @@ -117,10 +127,18 @@ impl Builtin for Tail { } } -/// Parse arguments for head/tail commands +/// Parse arguments for head command. /// Returns (num_lines, file_list) fn parse_head_tail_args(args: &[String], default: usize) -> Result<(usize, Vec)> { + let (num_lines, _, files) = parse_tail_args(args, default)?; + Ok((num_lines, files)) +} + +/// Parse arguments for tail command, including +N "from start" syntax. +/// Returns (num_lines, from_start, file_list) +fn parse_tail_args(args: &[String], default: usize) -> Result<(usize, bool, Vec)> { let mut num_lines = default; + let mut from_start = false; let mut files = Vec::new(); let mut i = 0; @@ -128,14 +146,27 @@ fn parse_head_tail_args(args: &[String], default: usize) -> Result<(usize, Vec() { @@ -149,7 +180,7 @@ fn parse_head_tail_args(args: &[String], default: usize) -> Result<(usize, Vec String { } } +/// Take lines starting from line N (1-indexed, like `tail -n +N`) +fn take_from_line(text: &str, n: usize) -> String { + let lines: Vec<&str> = text.lines().collect(); + let start = if n == 0 { 0 } else { n - 1 }; + let selected: Vec<&str> = lines.into_iter().skip(start).collect(); + + if selected.is_empty() { + String::new() + } else { + let mut result = selected.join("\n"); + if text.ends_with('\n') || !text.is_empty() { + result.push('\n'); + } + result + } +} + /// Take the last N lines from text fn take_last_lines(text: &str, n: usize) -> String { let lines: Vec<&str> = text.lines().collect(); @@ -318,4 +366,20 @@ mod tests { assert_eq!(result.exit_code, 0); assert_eq!(result.stdout, "a\nb\n"); } + + #[tokio::test] + async fn test_tail_plus_n_from_start() { + let input = "header\nline1\nline2\nline3\n"; + let result = run_tail(&["-n", "+2"], Some(input)).await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "line1\nline2\nline3\n"); + } + + #[tokio::test] + async fn test_tail_plus_1_all_lines() { + let input = "a\nb\nc\n"; + let result = run_tail(&["-n", "+1"], Some(input)).await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "a\nb\nc\n"); + } } diff --git a/crates/bashkit/src/builtins/sed.rs b/crates/bashkit/src/builtins/sed.rs index 60f0be6a..ac056c3c 100644 --- a/crates/bashkit/src/builtins/sed.rs +++ b/crates/bashkit/src/builtins/sed.rs @@ -25,6 +25,39 @@ use super::{Builtin, Context}; use crate::error::{Error, Result}; use crate::interpreter::ExecResult; +/// Convert a BRE (Basic Regular Expression) pattern to ERE for the regex crate. +/// In BRE: ( ) { } are literal; \( \) \{ \} \+ \? \| are metacharacters. +fn bre_to_ere(pattern: &str) -> String { + let mut result = String::with_capacity(pattern.len()); + let chars: Vec = pattern.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '\\' && i + 1 < chars.len() { + match chars[i + 1] { + '(' | ')' | '{' | '}' | '+' | '?' | '|' => { + result.push(chars[i + 1]); + i += 2; + } + _ => { + result.push('\\'); + result.push(chars[i + 1]); + i += 2; + } + } + } else if chars[i] == '(' || chars[i] == ')' || chars[i] == '{' || chars[i] == '}' { + result.push('\\'); + result.push(chars[i]); + i += 1; + } else { + result.push(chars[i]); + i += 1; + } + } + + result +} + /// sed command - stream editor pub struct Sed; @@ -394,18 +427,14 @@ fn parse_sed_command(s: &str, extended_regex: bool) -> Result<(Option
, let flags = parts.get(2).map(|s| s.as_str()).unwrap_or(""); // Convert POSIX sed regex to Rust regex syntax - // In BRE mode: \( \) -> ( ) for capture groups, \+ -> +, \? -> ? - // In ERE mode: ( ) are already groups, + and ? work directly + // In BRE mode: \( \) are groups, ( ) are literal, \+ \? are quantifiers + // In ERE mode: ( ) are groups, + ? work directly let pattern = if extended_regex { // ERE mode: no conversion needed for groups/quantifiers pattern.clone() } else { - // BRE mode: convert escaped metacharacters - pattern - .replace("\\(", "(") - .replace("\\)", ")") - .replace("\\+", "+") - .replace("\\?", "?") + // BRE mode: proper char-by-char conversion + bre_to_ere(pattern) }; // Build regex with optional case-insensitive flag let case_insensitive = flags.contains('i'); diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 07070831..25de3e56 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -2127,6 +2127,48 @@ impl Interpreter { return self.apply_redirections(result, &command.redirects).await; } + // Check if command is a path to an executable script in the VFS + if name.contains('/') { + let path = self.resolve_path(name); + if let Ok(content) = self.fs.read_file(&path).await { + // Check execute permission + if let Ok(meta) = self.fs.stat(&path).await { + if meta.mode & 0o111 != 0 { + let script_text = String::from_utf8_lossy(&content).to_string(); + // Strip shebang line if present + let script_text = if script_text.starts_with("#!") { + script_text + .find('\n') + .map(|pos| &script_text[pos + 1..]) + .unwrap_or("") + .to_string() + } else { + script_text + }; + let parser = Parser::with_limits( + &script_text, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ); + match parser.parse() { + Ok(script) => { + let result = self.execute(&script).await?; + return self.apply_redirections(result, &command.redirects).await; + } + Err(e) => { + return Ok(ExecResult::err(format!("bash: {}: {}\n", name, e), 2)); + } + } + } else { + return Ok(ExecResult::err( + format!("bash: {}: Permission denied\n", name), + 126, + )); + } + } + } + } + // Command not found - return error like bash does (exit code 127) Ok(ExecResult::err( format!("bash: {}: command not found", name), diff --git a/crates/bashkit/tests/spec_cases/awk/eval-bugs.test.sh b/crates/bashkit/tests/spec_cases/awk/eval-bugs.test.sh index d7bc9f1f..daab56fa 100644 --- a/crates/bashkit/tests/spec_cases/awk/eval-bugs.test.sh +++ b/crates/bashkit/tests/spec_cases/awk/eval-bugs.test.sh @@ -1,22 +1,18 @@ ### awk_field_multiply_accumulate -### skip: eval-surfaced bug — awk field multiplication with += accumulation returns wrong result # Bug: awk -F',' '{total += $2 * $3} END {print total}' computes wrong sum # Expected: 10*5 + 25*3 + 7*12 + 15*8 = 50+75+84+120 = 329 # Affected eval tasks: text_csv_revenue (fails 2/4 models) -# Root cause: compound expression $2 * $3 inside += accumulator evaluates incorrectly; -# simple += with single field works (see awk_variables test), but multiplication -# of two fields before accumulation produces wrong intermediate values +# Root cause: compound expression $2 * $3 inside += accumulator evaluated incorrectly printf 'widget,10,5\ngadget,25,3\ndoohickey,7,12\nsprocket,15,8\n' | awk -F',' '{total += $2 * $3} END {print total}' ### expect 329 ### end ### awk_match_capture_array -### skip: eval-surfaced bug — awk match() with 3rd argument (capture array) unsupported # Bug: GNU awk match(string, /regex/, array) stores captures in array — bashkit errors # Affected eval tasks: complex_release_notes, complex_markdown_toc (fails multiple models) -# Root cause: match() builtin only accepts 2 args (string, regex); 3rd arg for -# capture group extraction is a gawk extension that isn't implemented +# Root cause: match() builtin only accepted 2 args; 3rd arg for capture group +# extraction (gawk extension) is now implemented printf 'feat(auth): add OAuth2\n' | awk 'match($0, /^([a-z]+)\(([^)]+)\): (.*)/, arr) {print arr[1], arr[2], arr[3]}' ### expect feat auth add OAuth2 diff --git a/crates/bashkit/tests/spec_cases/bash/eval-bugs.test.sh b/crates/bashkit/tests/spec_cases/bash/eval-bugs.test.sh index 072d6efd..18b2a1e1 100644 --- a/crates/bashkit/tests/spec_cases/bash/eval-bugs.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/eval-bugs.test.sh @@ -1,16 +1,13 @@ ### tr_class_upper_from_pipe -### skip: eval-surfaced bug — tr '[:lower:]' '[:upper:]' produces empty output from pipe input # Bug: echo "hello world" | tr '[:lower:]' '[:upper:]' should produce HELLO WORLD # Affected eval tasks: script_function_lib (fails all 4 models) -# Root cause: tr POSIX character class translation ([:lower:], [:upper:]) not implemented; -# pipe input compounds the issue producing empty output instead of passthrough +# Root cause: tr POSIX character class translation ([:lower:], [:upper:]) was not implemented echo "hello world" | tr '[:lower:]' '[:upper:]' ### expect HELLO WORLD ### end ### while_read_pipe_vars -### skip: eval-surfaced bug — variables empty inside while-read loop fed by pipe # Bug: printf "a\nb\n" | while read line; do echo "$line"; done — $line is empty # Affected eval tasks: complex_markdown_toc (fails all 4 models) # Root cause: pipe creates subshell for while-read; variable propagation from @@ -25,10 +22,9 @@ got: line3 ### end ### tail_plus_n_offset -### skip: eval-surfaced bug — tail -n +N returns wrong content (only last lines instead of from line N) # Bug: tail -n +2 should skip first line and return all remaining lines # Affected eval tasks: complex_markdown_toc, text_csv_revenue -# Root cause: tail interprets +N as "last N" instead of "starting from line N" +# Root cause: tail interpreted +N as "last N" instead of "starting from line N" printf 'header\nline1\nline2\nline3\n' | tail -n +2 ### expect line1 @@ -37,11 +33,10 @@ line3 ### end ### script_chmod_exec_by_path -### skip: eval-surfaced bug — executing script via chmod +x then /path/script.sh fails # Bug: after chmod +x, running script by absolute path gives "command not found" # Workaround: bash /path/script.sh works, but direct execution doesn't # Affected eval tasks: complex_release_notes -# Root cause: VFS executable lookup doesn't check file's execute permission bit +# Root cause: VFS executable lookup didn't check file's execute permission bit echo '#!/bin/bash echo "script ran"' > /tmp/test_exec.sh chmod +x /tmp/test_exec.sh diff --git a/crates/bashkit/tests/spec_cases/grep/eval-bugs.test.sh b/crates/bashkit/tests/spec_cases/grep/eval-bugs.test.sh index 75a60cc1..a6257a56 100644 --- a/crates/bashkit/tests/spec_cases/grep/eval-bugs.test.sh +++ b/crates/bashkit/tests/spec_cases/grep/eval-bugs.test.sh @@ -1,17 +1,14 @@ ### grep_bre_literal_paren -### skip: eval-surfaced bug — grep treats ( as ERE group metachar in default BRE mode # Bug: grep 'feat(' should match literal parenthesis — in BRE, ( is literal -# Only \( starts a group in BRE. Bashkit incorrectly treats ( as a group metachar. +# Only \( starts a group in BRE. Bashkit incorrectly treated ( as a group metachar. # Affected eval tasks: complex_release_notes (fails 3/4 models) -# Root cause: regex engine doesn't distinguish BRE vs ERE metachar rules; -# BRE: ( is literal, \( is group; ERE (grep -E): ( is group, \( is literal +# Root cause: regex engine didn't distinguish BRE vs ERE metachar rules printf 'feat(auth): add OAuth2\nfix(api): handle null\nchore: update\n' | grep 'feat(' ### expect feat(auth): add OAuth2 ### end ### grep_bre_literal_paren_pattern -### skip: eval-surfaced bug — grep BRE pattern with literal parens and content extraction # Generalized: filtering conventional commit lines by type prefix with parens printf 'feat(auth): OAuth2\nfeat(ui): dark mode\nfix(api): null body\n' | grep '^feat(' ### expect diff --git a/crates/bashkit/tests/spec_cases/sed/eval-bugs.test.sh b/crates/bashkit/tests/spec_cases/sed/eval-bugs.test.sh index 6b3002d5..e7d204e2 100644 --- a/crates/bashkit/tests/spec_cases/sed/eval-bugs.test.sh +++ b/crates/bashkit/tests/spec_cases/sed/eval-bugs.test.sh @@ -1,18 +1,15 @@ ### sed_capture_group_complex_bre -### skip: eval-surfaced bug — sed BRE capture groups with complex pattern produce no substitution # Bug: sed 's/^[a-z]*(\([^)]*\)): \(.*\)/- \1: \2/' silently produces no change # Simple capture group swap works (see sed_regex_group), but multi-group extraction -# from complex patterns with literal chars between groups fails +# from complex patterns with literal chars between groups failed # Affected eval tasks: complex_release_notes (fails 3/4 models) -# Root cause: capture group matching interacts badly with literal ( ) in BRE patterns; -# the ( before \([^)]*\) confuses the parser since ( is literal in BRE +# Root cause: BRE-to-ERE conversion didn't escape literal ( ) in BRE patterns printf 'feat(auth): add OAuth2\n' | sed 's/^[a-z]*(\([^)]*\)): \(.*\)/- \1: \2/' ### expect - auth: add OAuth2 ### end ### sed_ere_capture_group_extract -### skip: eval-surfaced bug — sed -E capture group extraction from structured text fails # Same class of bug with ERE syntax: ( ) are group metachars in -E mode # Pattern: extract scope and description from conventional commit format # Affected eval tasks: complex_release_notes diff --git a/crates/bashkit/tests/spec_tests.rs b/crates/bashkit/tests/spec_tests.rs index 7af0c1b5..bc1039cf 100644 --- a/crates/bashkit/tests/spec_tests.rs +++ b/crates/bashkit/tests/spec_tests.rs @@ -8,7 +8,7 @@ //! - `### skip: reason` - Skip test entirely (not run in any test) //! - `### bash_diff: reason` - Known difference from real bash (runs in spec tests, excluded from comparison) //! -//! ## Skipped Tests TODO (76 total) +//! ## Skipped Tests TODO (66 total) //! //! The following tests are skipped and need fixes: //!