From dcf36571872b7f0047c1cd02e19b3291f9e5dfd3 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 27 Aug 2026 17:53:10 +0200 Subject: [PATCH] fix(run): treat empty regexp scripts as missing (#14237) RegExp script selectors included matching names even when their script bodies were empty, unlike exact-name selection. Recursive task orchestration could therefore dispatch tasks pulled in through dependsOn before eventually reporting that the requested script did not exist. Filter empty bodies at the shared script-selection boundary in both the TypeScript CLI and pacquet, so pre-dispatch validation observes the same missing-script semantics for exact and RegExp selectors. Related to pnpm/pnpm#14211. --- .changeset/empty-regexp-scripts.md | 7 ++++ pnpm/crates/cli/src/cli_args/run.rs | 8 +++- pnpm/crates/cli/tests/suite/run_recursive.rs | 41 +++++++++++++++++--- pnpm11/exec/commands/src/runRecursive.ts | 2 +- pnpm11/exec/commands/test/runTasks.e2e.ts | 29 ++++++++++++++ 5 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 .changeset/empty-regexp-scripts.md diff --git a/.changeset/empty-regexp-scripts.md b/.changeset/empty-regexp-scripts.md new file mode 100644 index 0000000000..bc4be8d0fb --- /dev/null +++ b/.changeset/empty-regexp-scripts.md @@ -0,0 +1,7 @@ +--- +"@pnpm/exec.commands": patch +"pnpm": patch +"pacquet": patch +--- + +Treat empty scripts selected by a regular expression as missing before running dependent tasks. diff --git a/pnpm/crates/cli/src/cli_args/run.rs b/pnpm/crates/cli/src/cli_args/run.rs index 427b1b5434..09e0691f80 100644 --- a/pnpm/crates/cli/src/cli_args/run.rs +++ b/pnpm/crates/cli/src/cli_args/run.rs @@ -566,7 +566,13 @@ impl<'a> ScriptSelector<'a> { let (Some(pattern), Some(scripts)) = (self.pattern.as_ref(), scripts) else { return Vec::new(); }; - scripts.keys().filter(|script| pattern.is_match(script)).cloned().collect() + scripts + .iter() + .filter(|(script, body)| { + body.as_str().is_some_and(|body| !body.is_empty()) && pattern.is_match(script) + }) + .map(|(script, _)| script.clone()) + .collect() } /// [`Self::select`] plus single-project `run`'s `start` fallback: diff --git a/pnpm/crates/cli/tests/suite/run_recursive.rs b/pnpm/crates/cli/tests/suite/run_recursive.rs index f65154d989..69a787aae7 100644 --- a/pnpm/crates/cli/tests/suite/run_recursive.rs +++ b/pnpm/crates/cli/tests/suite/run_recursive.rs @@ -2105,11 +2105,8 @@ fn recursive_run_keeps_a_failure_when_a_later_selected_script_passes() { drop(root); } -/// A selector can match a script with an empty body alongside a real -/// one. The no-op says nothing about the script that did run, so it must -/// not overwrite the project's recorded status. #[test] -fn recursive_run_keeps_a_pass_when_a_later_selected_script_is_a_no_op() { +fn recursive_run_keeps_a_pass_when_a_later_matching_script_is_empty() { let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); write_workspace( &workspace, @@ -2120,8 +2117,6 @@ fn recursive_run_keeps_a_pass_when_a_later_selected_script_is_a_no_op() { "version": "1.0.0", "scripts": { "check:a": "true", - // Sorts after `check:a`, so a regression reports the - // project as skipped rather than passed. "check:b": "", }, }), @@ -2940,6 +2935,40 @@ fn missing_requested_script_errors_before_upstream_tasks_run() { drop(root); } +#[test] +fn regexp_selected_empty_script_errors_before_upstream_tasks_run() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_workspace( + &workspace, + &[( + "project-a", + json!({ + "name": "project-a", + "version": "1.0.0", + "scripts": { "build:empty": "", "codegen": "echo codegen >> ../order.log" }, + }), + )], + ); + fs::write( + workspace.join("pnpm-workspace.yaml"), + "packages:\n - project-a\ntasks:\n '/^build:/':\n dependsOn: ['codegen']\n", + ) + .expect("write workspace settings"); + + let output = + pacquet.with_args(["-r", "run", "/^build:/"]).output().expect("run recursive script"); + eprintln!("STATUS: {}", output.status); + assert!(!output.status.success(), "an empty selected script must fail the run"); + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("STDERR:\n{stderr}\n"); + assert!(stderr.contains("RECURSIVE_RUN_NO_SCRIPT")); + let order_log_exists = workspace.join("order.log").exists(); + eprintln!("ORDER LOG EXISTS: {order_log_exists}"); + assert!(!order_log_exists, "the pulled-in task must not have run"); + + drop(root); +} + /// `ignoreWorkspaceCycles: true` downgrades the task-cycle error to a /// warning: the cycle's members run in an arbitrary order relative to each /// other and the run completes. diff --git a/pnpm11/exec/commands/src/runRecursive.ts b/pnpm11/exec/commands/src/runRecursive.ts index 8ee545e9ee..3dfbdca694 100644 --- a/pnpm11/exec/commands/src/runRecursive.ts +++ b/pnpm11/exec/commands/src/runRecursive.ts @@ -364,7 +364,7 @@ export function getSpecifiedScripts (scripts: PackageScripts, scriptName: string // if scriptName which a user passes is RegExp (like /build:.*/), multiple scripts to execute will be selected with RegExp if (scriptSelector) { - return Object.keys(scripts).filter(script => script.match(scriptSelector)) + return Object.keys(scripts).filter(script => Boolean(scripts[script]) && scriptSelector.test(script)) } return [] diff --git a/pnpm11/exec/commands/test/runTasks.e2e.ts b/pnpm11/exec/commands/test/runTasks.e2e.ts index e852dd2444..46643b9e5e 100644 --- a/pnpm11/exec/commands/test/runTasks.e2e.ts +++ b/pnpm11/exec/commands/test/runTasks.e2e.ts @@ -577,6 +577,35 @@ test('a missing requested script errors before upstream tasks run', async () => expect(server.getLines()).toStrictEqual([]) }) +test('an empty requested script matched by a RegExp errors before upstream tasks run', async () => { + await using server = await createTestIpcServer() + + preparePackages([ + { + name: 'project-a', + version: '1.0.0', + scripts: { + 'build:empty': '', + codegen: server.sendLineScript('codegen'), + }, + }, + ]) + + await expect(run.handler({ + ...DEFAULT_OPTS, + ...await filterProjectsBySelectorObjectsFromDir(process.cwd(), []), + dir: process.cwd(), + recursive: true, + tasks: { + '/^build:/': { dependsOn: ['codegen'] }, + }, + workspaceDir: process.cwd(), + }, ['/^build:/'])).rejects.toMatchObject({ + code: 'ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT', + }) + expect(server.getLines()).toStrictEqual([]) +}) + test('a failed upstream task is reported as the failure, not as a missing script', async () => { preparePackages([ {