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.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-08-27 17:53:10 +02:00
1 parent e322d08690
commit dcf3657187
5 files changed
+79 -8

No files matched your search

+7
View File
@@ -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.
+7 -1
View File
@@ -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:
+35 -6
View File
@@ -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.
+1 -1
View File
@@ -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 []
+29
View File
@@ -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([
{