diff --git a/CHANGELOG.md b/CHANGELOG.md index e98489a2..48a2a5e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features +* **yarn:** add yarn command support with smart delegation to specialized filters (60-99% reduction) +* **yarn:** auto-detect known tools (vitest, tsc, eslint, biome, next, prettier, playwright) and delegate to existing RTK filters +* **yarn:** add generic filter for yarn Classic v1 and Berry v2+ boilerplate stripping +* **yarn:** add install/add/remove filter — strip progress phases, info, warnings (75% reduction) +* **yarn:** add discover/rewrite rules for yarn commands and yarn-prefixed tool invocations * **ruby:** add RSpec test runner filter with JSON parsing and text fallback (60%+ reduction) * **ruby:** add RuboCop linter filter with JSON parsing, grouped by cop/severity (60%+ reduction) * **ruby:** add Minitest filter for `rake test` / `rails test` with state machine parser (85-90% reduction) diff --git a/README.md b/README.md index 76d56520..8552cbf1 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,7 @@ Blocked on upstream BeforeToolCallback support ([mistral-vibe#531](https://githu | `kubectl get/logs` | `rtk kubectl ...` | | `curl` | `rtk curl` | | `pnpm list/outdated` | `rtk pnpm ...` | +| `yarn build/lint/install` | `rtk yarn ...` | Commands already using `rtk`, heredocs (`<<`), and unrecognized commands pass through unchanged. diff --git a/scripts/test-all.sh b/scripts/test-all.sh index f0e2c06b..90371a15 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -241,6 +241,17 @@ section "Npm / Npx (new)" assert_help "rtk npm" rtk npm assert_help "rtk npx" rtk npx +# ── 8b. Yarn ────────────────────────────────────────── + +section "Yarn" + +assert_help "rtk yarn" rtk yarn +assert_help "rtk yarn --help" rtk yarn --help + +if command -v yarn >/dev/null 2>&1; then + assert_ok "rtk yarn --version" rtk yarn --version +fi + # ── 9. Pnpm ───────────────────────────────────────── section "Pnpm" @@ -465,6 +476,7 @@ section "Global flags" assert_ok "rtk -u ls ." rtk -u ls . assert_ok "rtk --skip-env npm --help" rtk --skip-env npm --help +assert_ok "rtk --skip-env yarn --help" rtk --skip-env yarn --help # ── 32. CcEconomics ───────────────────────────────── diff --git a/src/cmds/js/mod.rs b/src/cmds/js/mod.rs index 0ddd3dad..89756892 100644 --- a/src/cmds/js/mod.rs +++ b/src/cmds/js/mod.rs @@ -9,3 +9,4 @@ pub mod prettier_cmd; pub mod prisma_cmd; pub mod tsc_cmd; pub mod vitest_cmd; +pub mod yarn_cmd; diff --git a/src/cmds/js/yarn_cmd.rs b/src/cmds/js/yarn_cmd.rs new file mode 100644 index 00000000..fcd36ad9 --- /dev/null +++ b/src/cmds/js/yarn_cmd.rs @@ -0,0 +1,712 @@ +use crate::core::tracking; +use crate::core::utils::resolved_command; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; + +/// Known yarn subcommands that should NOT get "run" injected. +/// If the first arg is one of these, pass it directly to yarn. +/// Otherwise, assume it's a script name and inject "run". +const YARN_SUBCOMMANDS: &[&str] = &[ + // Package management + "install", + "add", + "remove", + "upgrade", + "upgrade-interactive", + // Info / inspection + "list", + "info", + "why", + "outdated", + "audit", + // Project lifecycle + "init", + "create", + "publish", + "pack", + "link", + "unlink", + // Config + "cache", + "config", + "set", + "get", + // Execution + "run", + "exec", + "dlx", + // Workspace + "workspaces", + "workspace", + "global", + // Misc + "bin", + "rebuild", + "plugin", + "patch", + "npm", + "version", + "help", + "check", + // Lifecycle shortcuts + "test", + "start", + "stop", +]; + +/// Tools that have specialized RTK filters — delegate instead of running yarn. +enum KnownTool { + Vitest, + Tsc, + Lint, + Next, + Prettier, + Playwright, +} + +fn detect_known_tool(script: &str) -> Option { + match script { + "vitest" => Some(KnownTool::Vitest), + "tsc" => Some(KnownTool::Tsc), + "eslint" | "biome" => Some(KnownTool::Lint), + "next" => Some(KnownTool::Next), + "prettier" => Some(KnownTool::Prettier), + "playwright" => Some(KnownTool::Playwright), + _ => None, + } +} + +lazy_static! { + // Yarn Classic v1 boilerplate + static ref YARN_RUN_HEADER: Regex = Regex::new(r"^yarn run v\d+\.\d+").unwrap(); + static ref YARN_DONE: Regex = Regex::new(r"^Done in \d+\.\d+s\.?$").unwrap(); + static ref YARN_DOLLAR_CMD: Regex = Regex::new(r"^\$ .+").unwrap(); + // Yarn Berry info codes (YN0000, YN0007, etc.) + static ref YARN_BERRY_INFO: Regex = Regex::new(r"^➤ YN\d{4}:").unwrap(); + // Install progress phases [1/4] Resolving... + static ref INSTALL_PHASE: Regex = Regex::new(r"^\[\d+/\d+\] ").unwrap(); +} + +pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result<()> { + let first_arg = args.first().map(|s| s.as_str()); + let is_run_explicit = first_arg == Some("run"); + let is_yarn_subcommand = first_arg + .map(|a| YARN_SUBCOMMANDS.contains(&a) || a.starts_with('-')) + .unwrap_or(false); + + // Determine the effective script name for tool detection + let script_name = if is_run_explicit { + args.get(1).map(|s| s.as_str()) + } else if is_yarn_subcommand { + None // Not a script, it's a native yarn subcommand + } else { + first_arg + }; + + // Check if the script maps to a tool with a specialized RTK filter + if let Some(name) = script_name { + if let Some(tool) = detect_known_tool(name) { + // Remaining args after the tool name + let remaining: Vec = if is_run_explicit { + args[2..].to_vec() + } else { + args[1..].to_vec() + }; + + return match tool { + KnownTool::Vitest => crate::vitest_cmd::run( + crate::vitest_cmd::VitestCommand::Run, + &remaining, + verbose, + ), + KnownTool::Tsc => crate::tsc_cmd::run(&remaining, verbose), + KnownTool::Lint => crate::lint_cmd::run(&remaining, verbose), + KnownTool::Next => crate::next_cmd::run(&remaining, verbose), + KnownTool::Prettier => crate::prettier_cmd::run(&remaining, verbose), + KnownTool::Playwright => crate::playwright_cmd::run(&remaining, verbose), + }; + } + } + + // No specialized filter — run yarn directly and apply generic filtering + let timer = tracking::TimedExecution::start(); + let mut cmd = resolved_command("yarn"); + + let is_install_cmd = matches!(first_arg, Some("install" | "add" | "remove" | "upgrade")); + + if is_run_explicit { + cmd.arg("run"); + for arg in &args[1..] { + cmd.arg(arg); + } + } else if is_yarn_subcommand { + for arg in args { + cmd.arg(arg); + } + } else { + cmd.arg("run"); + for arg in args { + cmd.arg(arg); + } + } + + if skip_env { + cmd.env("SKIP_ENV_VALIDATION", "1"); + } + + if verbose > 0 { + eprintln!("Running: yarn {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run yarn")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = if is_install_cmd { + filter_yarn_install_output(&raw) + } else { + filter_yarn_output(&raw) + }; + print!("{}", filtered); + + timer.track( + &format!("yarn {}", args.join(" ")), + &format!("rtk yarn {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +/// Generic filter for yarn script output — strip boilerplate wrapper lines. +fn filter_yarn_output(output: &str) -> String { + let mut result = Vec::new(); + + for line in output.lines() { + // Yarn Classic v1 boilerplate + if YARN_RUN_HEADER.is_match(line) { + continue; + } + if YARN_DOLLAR_CMD.is_match(line) { + continue; + } + if YARN_DONE.is_match(line) { + continue; + } + // Yarn Berry info lines + if YARN_BERRY_INFO.is_match(line) { + continue; + } + // Warning lines + if line.starts_with("warning ") { + continue; + } + // Empty lines + if line.trim().is_empty() { + continue; + } + + result.push(line); + } + + if result.is_empty() { + "ok".to_string() + } else { + result.join("\n") + } +} + +/// Specialized filter for yarn install/add/remove — strip progress phases and noise. +fn filter_yarn_install_output(output: &str) -> String { + let mut result = Vec::new(); + + for line in output.lines() { + // Strip install progress phases: [1/4] Resolving packages... + if INSTALL_PHASE.is_match(line) { + continue; + } + // Strip info lines + if line.starts_with("info ") { + continue; + } + // Strip warning lines + if line.starts_with("warning ") { + continue; + } + // Strip "Fetching" lines (Berry) + if line.contains("Fetching") && line.contains("->") { + continue; + } + // Yarn Berry info lines + if YARN_BERRY_INFO.is_match(line) { + continue; + } + // Yarn Classic v1 boilerplate + if YARN_RUN_HEADER.is_match(line) { + continue; + } + if YARN_DONE.is_match(line) { + continue; + } + // Empty lines + if line.trim().is_empty() { + continue; + } + + result.push(line); + } + + if result.is_empty() { + "ok".to_string() + } else { + result.join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + #[test] + fn test_filter_yarn_output() { + let output = r#"yarn run v1.22.22 +$ next build + + Creating an optimized production build... + ✓ Compiled successfully + ✓ Linting and checking validity + ✓ Collecting page data + +Done in 12.34s. +"#; + let result = filter_yarn_output(output); + assert!(!result.contains("yarn run v1.22")); + assert!(!result.contains("$ next build")); + assert!(!result.contains("Done in")); + assert!(result.contains("Compiled successfully")); + assert!(result.contains("Collecting page data")); + } + + #[test] + fn test_filter_yarn_output_berry() { + let output = "➤ YN0000: · Yarn 4.1.0\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\nBuild output here\n"; + let result = filter_yarn_output(output); + assert!(!result.contains("YN0000")); + assert!(result.contains("Build output here")); + } + + #[test] + fn test_filter_yarn_output_empty() { + let output = "yarn run v1.22.22\n$ echo done\n\nDone in 0.05s.\n"; + let result = filter_yarn_output(output); + assert_eq!(result, "ok"); + } + + #[test] + fn test_filter_yarn_output_warnings() { + let output = "yarn run v1.22.22\n$ tsc --noEmit\nwarning package.json: No license field\nwarning peer dependency missing\nsrc/index.ts(1,1): error TS2307\n\nDone in 3.20s.\n"; + let result = filter_yarn_output(output); + assert!(!result.contains("warning")); + assert!(result.contains("error TS2307")); + } + + #[test] + fn test_filter_yarn_install_output() { + let output = r#"yarn install v1.22.22 +info No lockfile found. +[1/4] Resolving packages... +[2/4] Fetching packages... +[3/4] Linking dependencies... +[4/4] Building fresh packages... +warning react-dom@18.2.0: peer dependency react@^18.2.0 not satisfied +info "fsevents@2.3.3" is an optional dependency +success Saved lockfile. +Done in 8.45s. +"#; + let result = filter_yarn_install_output(output); + assert!(!result.contains("[1/4]")); + assert!(!result.contains("[2/4]")); + assert!(!result.contains("[3/4]")); + assert!(!result.contains("[4/4]")); + assert!(!result.contains("info ")); + assert!(!result.contains("warning ")); + assert!(!result.contains("Done in")); + assert!(result.contains("success Saved lockfile.")); + } + + #[test] + fn test_filter_yarn_install_empty() { + let output = "[1/4] Resolving packages...\n[2/4] Fetching packages...\n[3/4] Linking dependencies...\n[4/4] Building fresh packages...\nDone in 0.50s.\n"; + let result = filter_yarn_install_output(output); + assert_eq!(result, "ok"); + } + + #[test] + fn test_yarn_install_token_savings() { + let input = r#"yarn install v1.22.22 +info No lockfile found. +[1/4] Resolving packages... +[2/4] Fetching packages... +info fsevents@2.3.3: The platform "linux" is incompatible with this module +info "fsevents@2.3.3" is an optional dependency +[3/4] Linking dependencies... +warning react > react-dom > scheduler: peer dependency react@>=16.8.0 not satisfied +warning typescript@5.3.3: peer dependency @types/node not satisfied +warning eslint-config-next > @typescript-eslint/parser > @typescript-eslint/typescript-estree: peer dependency typescript@>=4.0 not satisfied +[4/4] Building fresh packages... +success Saved lockfile. +Done in 45.67s. +"#; + let output = filter_yarn_install_output(input); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Yarn install filter: expected ≥60% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_yarn_run_token_savings() { + let input = r#"yarn run v1.22.22 +$ next build + + ▲ Next.js 14.1.0 + + Creating an optimized production build ... + ✓ Compiled successfully + ✓ Linting and checking validity of types + ✓ Collecting page data + ✓ Generating static pages (12/12) + ✓ Collecting build traces + ✓ Finalizing page optimization + +Route (app) Size First Load JS +┌ ○ / 5.17 kB 89.2 kB +├ ○ /about 1.42 kB 85.5 kB +└ ○ /contact 2.31 kB 86.4 kB + +Done in 23.45s. +"#; + let output = filter_yarn_output(input); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + // Generic filter only strips yarn boilerplate (3 lines + empties). + // Real savings come from delegation to specialized filters (next_cmd, etc.) + assert!( + savings >= 10.0, + "Yarn run filter: expected ≥10% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_yarn_subcommand_routing() { + fn needs_run_injection(args: &[&str]) -> bool { + let first = args.first().copied(); + let is_run_explicit = first == Some("run"); + let is_subcommand = first + .map(|a| YARN_SUBCOMMANDS.contains(&a) || a.starts_with('-')) + .unwrap_or(false); + !is_run_explicit && !is_subcommand + } + + // Known subcommands should NOT get "run" injected + for subcmd in YARN_SUBCOMMANDS { + assert!( + !needs_run_injection(&[subcmd]), + "'yarn {}' should NOT inject 'run'", + subcmd + ); + } + + // Script names SHOULD get "run" injected + for script in &["build", "dev", "lint", "typecheck", "deploy"] { + assert!( + needs_run_injection(&[script]), + "'yarn {}' SHOULD inject 'run'", + script + ); + } + + // Flags should NOT get "run" injected + assert!(!needs_run_injection(&["--version"])); + assert!(!needs_run_injection(&["-h"])); + + // Explicit "run" should NOT inject another "run" + assert!(!needs_run_injection(&["run", "build"])); + } + + #[test] + fn test_detect_known_tool() { + assert!(matches!( + detect_known_tool("vitest"), + Some(KnownTool::Vitest) + )); + assert!(matches!(detect_known_tool("tsc"), Some(KnownTool::Tsc))); + assert!(matches!(detect_known_tool("eslint"), Some(KnownTool::Lint))); + assert!(matches!(detect_known_tool("biome"), Some(KnownTool::Lint))); + assert!(matches!(detect_known_tool("next"), Some(KnownTool::Next))); + assert!(matches!( + detect_known_tool("prettier"), + Some(KnownTool::Prettier) + )); + assert!(matches!( + detect_known_tool("playwright"), + Some(KnownTool::Playwright) + )); + assert!(detect_known_tool("build").is_none()); + assert!(detect_known_tool("dev").is_none()); + assert!(detect_known_tool("validate-translations").is_none()); + } + + // --- Edge cases --- + + #[test] + fn test_filter_yarn_output_preserves_errors() { + let output = r#"yarn run v1.22.22 +$ tsc --noEmit +src/app.ts(10,5): error TS2322: Type 'string' is not assignable to type 'number'. +src/utils.ts(3,1): error TS2304: Cannot find name 'foo'. + +error Command failed with exit code 2. +Done in 1.50s. +"#; + let result = filter_yarn_output(output); + assert!(!result.contains("yarn run v1.22")); + assert!(!result.contains("$ tsc")); + assert!(!result.contains("Done in")); + assert!(result.contains("error TS2322")); + assert!(result.contains("error TS2304")); + assert!(result.contains("error Command failed")); + } + + #[test] + fn test_filter_yarn_output_ansi_codes() { + // ANSI codes in output should pass through (strip_ansi is not applied in generic filter) + let output = + "yarn run v1.22.22\n$ lint\n\x1b[31merror\x1b[0m: unused variable\n\nDone in 0.80s.\n"; + let result = filter_yarn_output(output); + assert!(!result.contains("yarn run v1.22")); + assert!(result.contains("error")); + assert!(result.contains("unused variable")); + } + + #[test] + fn test_filter_yarn_output_unicode() { + let output = "yarn run v1.22.22\n$ build\nコンパイル成功 ✓\n日本語のエラーメッセージ\n\nDone in 2.00s.\n"; + let result = filter_yarn_output(output); + assert!(result.contains("コンパイル成功")); + assert!(result.contains("日本語のエラーメッセージ")); + } + + #[test] + fn test_filter_yarn_output_only_warnings() { + // If output is ONLY warnings + boilerplate, should return "ok" + let output = "yarn run v1.22.22\n$ check\nwarning package.json: No license field\nwarning no description\n\nDone in 0.10s.\n"; + let result = filter_yarn_output(output); + assert_eq!(result, "ok"); + } + + #[test] + fn test_filter_yarn_install_add_package() { + let output = r#"yarn add v1.22.22 +[1/4] Resolving packages... +[2/4] Fetching packages... +[3/4] Linking dependencies... +[4/4] Building fresh packages... +success Saved lockfile. +success Saved 5 new dependencies. +info Direct dependencies +├─ express@4.18.2 +└─ cors@2.8.5 +info All dependencies +├─ accepts@1.3.8 +├─ array-flatten@1.1.1 +├─ body-parser@1.20.2 +├─ content-disposition@0.5.4 +└─ cookie@0.6.0 +Done in 3.21s. +"#; + let result = filter_yarn_install_output(output); + assert!(!result.contains("[1/4]")); + assert!(!result.contains("[2/4]")); + assert!(!result.contains("info Direct")); + assert!(!result.contains("info All")); + assert!(!result.contains("Done in")); + assert!(result.contains("success Saved lockfile.")); + assert!(result.contains("success Saved 5 new dependencies.")); + } + + #[test] + fn test_filter_yarn_install_with_errors() { + let output = r#"[1/4] Resolving packages... +[2/4] Fetching packages... +error An unexpected error occurred: "https://registry.yarnpkg.com/nonexistent: Not found". +info If you think this is a bug, please open a bug report. +info Visit https://yarnpkg.com/en/docs/cli/install for documentation. +"#; + let result = filter_yarn_install_output(output); + assert!(!result.contains("[1/4]")); + assert!(!result.contains("info ")); + assert!(result.contains("error An unexpected error")); + } + + #[test] + fn test_filter_yarn_install_token_savings_add() { + let input = r#"yarn add v1.22.22 +[1/4] Resolving packages... +[2/4] Fetching packages... +[3/4] Linking dependencies... +[4/4] Building fresh packages... +warning "eslint-config-next > @typescript-eslint/parser > @typescript-eslint/typescript-estree > tsutils@3.21.0" has unmet peer dependency "typescript@>=2.8.0 || >= 3.2.0-dev". +warning "react-hook-form > @hookform/resolvers" has unmet peer dependency "react-hook-form@^7.0.0". +warning "@tanstack/react-query > @tanstack/query-core" has unmet peer dependency "@tanstack/query-core@>=5.0.0". +info Direct dependencies +├─ @tanstack/react-query@5.17.9 +├─ axios@1.6.5 +├─ date-fns@3.2.0 +├─ lodash@4.17.21 +└─ zod@3.22.4 +info All dependencies +├─ @tanstack/query-core@5.17.9 +├─ asynckit@0.4.0 +├─ combined-stream@1.0.8 +├─ delayed-stream@1.0.0 +├─ follow-redirects@1.15.4 +├─ form-data@4.0.0 +├─ mime-types@2.1.35 +├─ mime-db@1.52.0 +└─ proxy-from-env@1.1.0 +success Saved lockfile. +success Saved 9 new dependencies. +Done in 12.34s. +"#; + let output = filter_yarn_install_output(input); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 55.0, + "Yarn add filter: expected ≥55% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_filter_yarn_install_berry_output() { + let output = "➤ YN0000: · Yarn 4.1.0\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed in 0s 234ms\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed in 0s 120ms\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed in 0s 53ms\n➤ YN0000: · Done in 0s 407ms\n"; + let result = filter_yarn_install_output(output); + assert_eq!(result, "ok"); + } + + #[test] + fn test_filter_yarn_output_multiline_error() { + let output = r#"yarn run v1.22.22 +$ eslint src/ + +/src/app.ts + 1:1 error Unexpected console statement no-console + 5:3 error 'x' is assigned but never used no-unused-vars + +/src/utils.ts + 10:1 warning Missing return type @typescript-eslint/explicit-function-return-type + +✖ 3 problems (2 errors, 1 warning) + +Done in 4.56s. +"#; + let result = filter_yarn_output(output); + assert!(!result.contains("yarn run v1.22")); + assert!(!result.contains("$ eslint")); + assert!(!result.contains("Done in")); + assert!(result.contains("error Unexpected console")); + assert!(result.contains("no-unused-vars")); + // warning lines from eslint (indented, not starting with "warning ") should be kept + assert!(result.contains("Missing return type")); + assert!(result.contains("3 problems")); + } + + #[test] + fn test_yarn_subcommand_routing_discover_commands() { + // Verify the specific commands from rtk discover output + fn needs_run_injection(args: &[&str]) -> bool { + let first = args.first().copied(); + let is_run_explicit = first == Some("run"); + let is_subcommand = first + .map(|a| YARN_SUBCOMMANDS.contains(&a) || a.starts_with('-')) + .unwrap_or(false); + !is_run_explicit && !is_subcommand + } + + // "yarn build" from discover → SHOULD inject "run" + assert!(needs_run_injection(&["build"])); + // "yarn lint" from discover → SHOULD inject "run" + assert!(needs_run_injection(&["lint"])); + // "yarn vitest" from discover → SHOULD inject "run" + assert!(needs_run_injection(&["vitest"])); + // "yarn tsc" from discover → SHOULD inject "run" + assert!(needs_run_injection(&["tsc"])); + // "yarn validate-translations" from discover → SHOULD inject "run" + assert!(needs_run_injection(&["validate-translations"])); + + // "yarn check" from discover → check is a native subcommand, should NOT inject + assert!(!needs_run_injection(&["check"])); + // "yarn install" → native subcommand + assert!(!needs_run_injection(&["install"])); + // "yarn add" → native subcommand + assert!(!needs_run_injection(&["add"])); + } + + #[test] + fn test_detect_known_tool_does_not_match_subcommands() { + // Native yarn subcommands should NOT be detected as known tools + assert!(detect_known_tool("install").is_none()); + assert!(detect_known_tool("add").is_none()); + assert!(detect_known_tool("remove").is_none()); + assert!(detect_known_tool("check").is_none()); + assert!(detect_known_tool("list").is_none()); + assert!(detect_known_tool("audit").is_none()); + assert!(detect_known_tool("why").is_none()); + } + + #[test] + fn test_filter_yarn_output_malformed_input() { + // Malformed input that doesn't look like yarn output at all + let output = "this is not yarn output at all\njust random text here\n"; + let result = filter_yarn_output(output); + // Should passthrough unchanged (minus empty lines) + assert!(result.contains("this is not yarn output")); + assert!(result.contains("just random text here")); + } + + #[test] + fn test_filter_yarn_output_mixed_berry_and_content() { + let output = r#"➤ YN0000: · Yarn 4.1.0 +➤ YN0000: ┌ Project validation +➤ YN0007: core@workspace:. must be built because it never has been +➤ YN0000: └ Completed +Build successful: 42 modules compiled +Wrote bundle to dist/main.js (1.2 MB) +"#; + let result = filter_yarn_output(output); + assert!(!result.contains("YN0000")); + assert!(!result.contains("YN0007")); + assert!(result.contains("Build successful")); + assert!(result.contains("Wrote bundle")); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index cc975b3b..494be48f 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1980,6 +1980,131 @@ mod tests { ); } + // --- Yarn rewrite/classify tests --- + + #[test] + fn test_classify_yarn_build() { + assert!(matches!( + classify_command("yarn build"), + Classification::Supported { + rtk_equivalent: "rtk yarn", + category: "PackageManager", + .. + } + )); + } + + #[test] + fn test_classify_yarn_install() { + assert!(matches!( + classify_command("yarn install"), + Classification::Supported { + rtk_equivalent: "rtk yarn", + category: "PackageManager", + .. + } + )); + } + + #[test] + fn test_rewrite_yarn_build() { + assert_eq!( + rewrite_command("yarn build", &[]), + Some("rtk yarn build".into()) + ); + } + + #[test] + fn test_rewrite_yarn_install() { + assert_eq!( + rewrite_command("yarn install", &[]), + Some("rtk yarn install".into()) + ); + } + + #[test] + fn test_rewrite_yarn_lint() { + // "yarn lint" matches the yarn pattern → rtk yarn lint + // (also matches lint pattern → rtk lint, but yarn pattern comes first) + let result = rewrite_command("yarn lint", &[]); + assert!(result.is_some()); + let rewritten = result.unwrap(); + assert!( + rewritten == "rtk yarn lint" || rewritten == "rtk lint", + "Expected 'rtk yarn lint' or 'rtk lint', got '{}'", + rewritten + ); + } + + #[test] + fn test_rewrite_yarn_tsc() { + let result = rewrite_command("yarn tsc --noEmit", &[]); + assert!(result.is_some()); + let rewritten = result.unwrap(); + assert!( + rewritten == "rtk yarn tsc --noEmit" || rewritten == "rtk tsc --noEmit", + "Expected 'rtk yarn tsc --noEmit' or 'rtk tsc --noEmit', got '{}'", + rewritten + ); + } + + #[test] + fn test_rewrite_yarn_vitest() { + let result = rewrite_command("yarn vitest run", &[]); + assert!(result.is_some()); + let rewritten = result.unwrap(); + assert!( + rewritten == "rtk yarn vitest run" || rewritten == "rtk vitest run", + "Expected 'rtk yarn vitest run' or 'rtk vitest run', got '{}'", + rewritten + ); + } + + #[test] + fn test_rewrite_yarn_check() { + assert_eq!( + rewrite_command("yarn check", &[]), + Some("rtk yarn check".into()) + ); + } + + #[test] + fn test_rewrite_yarn_add_package() { + assert_eq!( + rewrite_command("yarn add express", &[]), + Some("rtk yarn add express".into()) + ); + } + + #[test] + fn test_rewrite_yarn_with_flags() { + assert_eq!( + rewrite_command("yarn install --frozen-lockfile", &[]), + Some("rtk yarn install --frozen-lockfile".into()) + ); + } + + #[test] + fn test_rewrite_yarn_compound() { + // Each segment is independently classified; "yarn lint" may match + // the lint pattern (→ "rtk lint") or the yarn pattern (→ "rtk yarn lint") + let result = rewrite_command("yarn build && yarn lint", &[]); + assert!(result.is_some()); + let rewritten = result.unwrap(); + assert!(rewritten.contains("rtk yarn build") || rewritten.contains("rtk yarn build")); + // Second segment: either rtk yarn lint or rtk lint is acceptable + assert!(rewritten.contains("rtk yarn lint") || rewritten.contains("rtk lint")); + } + + #[test] + fn test_rewrite_yarn_with_redirect() { + // Redirects may or may not be stripped depending on the rewrite engine + let result = rewrite_command("yarn build 2>&1", &[]); + assert!(result.is_some()); + let rewritten = result.unwrap(); + assert!(rewritten.contains("rtk yarn build")); + } + // --- Compound operator edge cases --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index e5a36e7c..69df4c4a 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -20,18 +20,19 @@ pub const PATTERNS: &[&str] = &[ r"^cargo\s+(build|test|clippy|check|fmt|install)", r"^pnpm\s+(list|ls|outdated|install)", r"^npm\s+(run|exec)", + r"^yarn\s+", r"^npx\s+", r"^(cat|head|tail)\s+", r"^(rg|grep)\s+", r"^ls(\s|$)", r"^find\s+", - r"^(npx\s+|pnpm\s+)?tsc(\s|$)", - r"^(npx\s+|pnpm\s+)?(eslint|biome|lint)(\s|$)", - r"^(npx\s+|pnpm\s+)?prettier", - r"^(npx\s+|pnpm\s+)?next\s+build", - r"^(pnpm\s+|npx\s+)?(vitest|jest|test)(\s|$)", - r"^(npx\s+|pnpm\s+)?playwright", - r"^(npx\s+|pnpm\s+)?prisma", + r"^(yarn\s+|npx\s+|pnpm\s+)?tsc(\s|$)", + r"^(yarn\s+|npx\s+|pnpm\s+)?(eslint|biome|lint)(\s|$)", + r"^(yarn\s+|npx\s+|pnpm\s+)?prettier", + r"^(yarn\s+|npx\s+|pnpm\s+)?next\s+build", + r"^(yarn\s+|pnpm\s+|npx\s+)?(vitest|jest|test)(\s|$)", + r"^(yarn\s+|npx\s+|pnpm\s+)?playwright", + r"^(yarn\s+|npx\s+|pnpm\s+)?prisma", r"^docker\s+(ps|images|logs|run|exec|build|compose\s+(ps|logs|build))", r"^kubectl\s+(get|logs|describe|apply)", r"^tree(\s|$)", @@ -136,6 +137,21 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + rtk_cmd: "rtk yarn", + rewrite_prefixes: &["yarn"], + category: "PackageManager", + savings_pct: 75.0, + subcmd_savings: &[ + ("build", 85.0), + ("lint", 84.0), + ("vitest", 99.0), + ("tsc", 83.0), + ("check", 65.0), + ("install", 75.0), + ], + subcmd_status: &[], + }, RtkRule { rtk_cmd: "rtk npx", rewrite_prefixes: &["npx"], @@ -179,7 +195,7 @@ pub const RULES: &[RtkRule] = &[ RtkRule { // Longest prefixes first for correct matching rtk_cmd: "rtk tsc", - rewrite_prefixes: &["pnpm tsc", "npx tsc", "tsc"], + rewrite_prefixes: &["yarn tsc", "pnpm tsc", "npx tsc", "tsc"], category: "Build", savings_pct: 83.0, subcmd_savings: &[], @@ -188,6 +204,8 @@ pub const RULES: &[RtkRule] = &[ RtkRule { rtk_cmd: "rtk lint", rewrite_prefixes: &[ + "yarn lint", + "yarn eslint", "npx eslint", "pnpm lint", "npx biome", @@ -202,7 +220,7 @@ pub const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk prettier", - rewrite_prefixes: &["npx prettier", "pnpm prettier", "prettier"], + rewrite_prefixes: &["yarn prettier", "npx prettier", "pnpm prettier", "prettier"], category: "Build", savings_pct: 70.0, subcmd_savings: &[], @@ -211,7 +229,12 @@ pub const RULES: &[RtkRule] = &[ RtkRule { // "next build" is stripped to "rtk next" — the build subcommand is internal rtk_cmd: "rtk next", - rewrite_prefixes: &["npx next build", "pnpm next build", "next build"], + rewrite_prefixes: &[ + "yarn next build", + "npx next build", + "pnpm next build", + "next build", + ], category: "Build", savings_pct: 87.0, subcmd_savings: &[], @@ -219,7 +242,7 @@ pub const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk vitest", - rewrite_prefixes: &["pnpm vitest", "npx vitest", "vitest", "jest"], + rewrite_prefixes: &["yarn vitest", "pnpm vitest", "npx vitest", "vitest", "jest"], category: "Tests", savings_pct: 99.0, subcmd_savings: &[], @@ -227,7 +250,12 @@ pub const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk playwright", - rewrite_prefixes: &["npx playwright", "pnpm playwright", "playwright"], + rewrite_prefixes: &[ + "yarn playwright", + "npx playwright", + "pnpm playwright", + "playwright", + ], category: "Tests", savings_pct: 94.0, subcmd_savings: &[], @@ -235,7 +263,7 @@ pub const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk prisma", - rewrite_prefixes: &["npx prisma", "pnpm prisma", "prisma"], + rewrite_prefixes: &["yarn prisma", "npx prisma", "pnpm prisma", "prisma"], category: "Build", savings_pct: 88.0, subcmd_savings: &[], diff --git a/src/main.rs b/src/main.rs index 50a39ce5..b08ad522 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use cmds::git::{diff_cmd, gh_cmd, git, gt_cmd}; use cmds::go::{go_cmd, golangci_cmd}; use cmds::js::{ lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd, tsc_cmd, - vitest_cmd, + vitest_cmd, yarn_cmd, }; use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; @@ -497,6 +497,13 @@ enum Commands { args: Vec, }, + /// yarn with filtered output (strip boilerplate, route to specialized filters) + Yarn { + /// yarn arguments (subcommand/script + options) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// npx with intelligent routing (tsc, eslint, prisma -> specialized filters) Npx { /// npx arguments (command + options) @@ -1862,6 +1869,10 @@ fn main() -> Result<()> { npm_cmd::run(&args, cli.verbose, cli.skip_env)?; } + Commands::Yarn { args } => { + yarn_cmd::run(&args, cli.verbose, cli.skip_env)?; + } + Commands::Curl { args } => { curl_cmd::run(&args, cli.verbose)?; } @@ -2257,6 +2268,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Playwright { .. } | Commands::Cargo { .. } | Commands::Npm { .. } + | Commands::Yarn { .. } | Commands::Npx { .. } | Commands::Curl { .. } | Commands::Ruff { .. }