From 0422c3c5354ab32275b3146aad212043a71bd14b Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:20:05 -0600 Subject: [PATCH] fix(task): exclude inherited tasks from wildcard pattern matching Wildcard patterns like `//...:task` now only match tasks that are explicitly defined at that path, not tasks inherited from parent configs. This prevents several issues: 1. Circular dependencies when root defines `depends = ['//...:test']` and subdirs inherit that task 2. Unexpected execution when running `//packages/...:build` on packages that don't define their own build task The fix adds an `inherited` field to the Task struct that tracks whether a task came from a parent config. When matching wildcards (patterns containing "..."), inherited tasks are filtered out. Explicit paths like `//packages/app:build` still work for inherited tasks since that represents explicit intent. Fixes: https://github.com/jdx/mise/discussions/6564#discussioncomment-15607613 Co-Authored-By: Claude Opus 4.5 --- .../test_task_monorepo_wildcard_inherited | 93 +++++++++++++++++++ src/config/mod.rs | 8 ++ src/task/mod.rs | 8 ++ src/task/task_list.rs | 5 + 4 files changed, 114 insertions(+) create mode 100644 e2e/tasks/test_task_monorepo_wildcard_inherited diff --git a/e2e/tasks/test_task_monorepo_wildcard_inherited b/e2e/tasks/test_task_monorepo_wildcard_inherited new file mode 100644 index 0000000000..8562818f28 --- /dev/null +++ b/e2e/tasks/test_task_monorepo_wildcard_inherited @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Test that wildcard patterns (//...:task) do NOT match inherited tasks +# Only tasks explicitly defined in a subdirectory should match wildcards +# See: https://github.com/jdx/mise/discussions/6564#discussioncomment-15607613 + +export MISE_EXPERIMENTAL=1 + +# Create monorepo root with a "test" task +cat <<'TOML' >mise.toml +experimental_monorepo_root = true + +[monorepo] +config_roots = ["packages/*"] + +[tasks.test] +run = 'echo "root test"' + +[tasks.root-only] +run = 'echo "root only task"' +TOML + +# Create package-a WITH its own test task (should match //...:test) +mkdir -p packages/package-a +cat <<'TOML' >packages/package-a/mise.toml +[tasks.test] +run = 'echo "package-a test"' +TOML + +# Create package-b WITHOUT its own test task (should NOT match //...:test due to inheritance) +mkdir -p packages/package-b +cat <<'TOML' >packages/package-b/mise.toml +[tasks.build] +run = 'echo "package-b build"' +TOML + +# Create package-c WITH its own test task (should match //...:test) +mkdir -p packages/package-c +cat <<'TOML' >packages/package-c/mise.toml +[tasks.test] +run = 'echo "package-c test"' +TOML + +mise trust --all + +# --- Test 1: Wildcard should only match explicitly defined tasks --- +echo "=== Test 1: Wildcard should only match package-a and package-c (not package-b) ===" + +# Should include package-a and package-c (they define test explicitly) +assert_contains "mise run '//packages/...:test'" "package-a test" +assert_contains "mise run '//packages/...:test'" "package-c test" + +# Should NOT include package-b (its test is inherited from root) +assert_not_contains "mise run '//packages/...:test'" "root test" + +# --- Test 2: Explicit paths still work for inherited tasks --- +echo "=== Test 2: Explicit path should work for inherited task ===" +assert_contains "mise run //packages/package-b:test" "root test" + +# --- Test 3: Root wildcard with root task should still work --- +echo "=== Test 3: Root task still visible ===" +assert_contains "mise run //:test" "root test" + +# --- Test 4: Task that only exists at root should NOT match //packages/...: wildcard --- +echo "=== Test 4: Root-only task should not match subdirectory wildcard ===" +# This should fail with "task not found" since no package explicitly defines root-only +assert_fail "mise run '//packages/...:root-only'" + +# --- Test 5: Inherited tasks still visible in task list (with --all) --- +echo "=== Test 5: Inherited tasks visible in task list ===" +# Inherited tasks should still be listed +assert_contains "mise tasks --all" "//packages/package-b:test" +assert_contains "mise tasks --all" "//packages/package-b:root-only" + +# --- Test 6: Circular dependency should NOT occur --- +# This was the original bug: root defines test = { depends = ['//...:test'] } +# which would create a cycle through inherited tasks +echo "=== Test 6: No circular dependency with wildcard depends ===" +cat <<'TOML' >mise.toml +experimental_monorepo_root = true + +[monorepo] +config_roots = ["packages/*"] + +[tasks.test] +depends = ['//packages/...:test'] +run = 'echo "root test complete"' +description = 'run all tests' +TOML + +# This should work without circular dependency error +assert_contains "mise run //:test" "package-a test" +assert_contains "mise run //:test" "package-c test" +assert_contains "mise run //:test" "root test complete" diff --git a/src/config/mod.rs b/src/config/mod.rs index ddbc69a46d..7a588b7158 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1726,6 +1726,14 @@ async fn load_local_tasks_with_context( // dir = "{{cwd}}" if they want the task to run from wherever it's invoked. let mut all_tasks: Vec = task_map.into_values().collect(); + // Mark tasks as inherited if they come from a parent config + // (their config_root differs from the subdir we're loading for) + for task in all_tasks.iter_mut() { + if let Some(ref config_root) = task.config_root { + task.inherited = config_root != &subdir; + } + } + prefix_monorepo_task_names(&mut all_tasks, &subdir, &monorepo_root); Ok::, eyre::Report>(all_tasks) diff --git a/src/task/mod.rs b/src/task/mod.rs index d61ae4e8c2..9599afa189 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -234,6 +234,10 @@ pub struct Task { pub hide: bool, #[serde(default)] pub global: bool, + /// Whether this task was inherited from a parent config in a monorepo + /// (vs explicitly defined at this path). Used to filter wildcard matches. + #[serde(skip)] + pub inherited: bool, #[serde(default)] pub raw: bool, #[serde(default)] @@ -1070,9 +1074,12 @@ fn match_tasks_with_context( parent_task: Option<&Task>, ) -> Result> { let resolved_pattern = resolve_task_pattern(&td.task, parent_task); + // When using wildcard patterns (containing "..."), filter out inherited tasks + let filter_inherited = resolved_pattern.contains("..."); let matches = tasks .get_matching(&resolved_pattern)? .into_iter() + .filter(|t| !filter_inherited || !t.inherited) .map(|t| { let mut t = (*t).clone(); t.args = td.args.clone(); @@ -1139,6 +1146,7 @@ impl Default for Task { dir: None, hide: false, global: false, + inherited: false, raw: false, sources: vec![], outputs: Default::default(), diff --git a/src/task/task_list.rs b/src/task/task_list.rs index 0c371195b4..7fdd84d97e 100644 --- a/src/task/task_list.rs +++ b/src/task/task_list.rs @@ -288,6 +288,11 @@ pub async fn get_task_lists( let cur_tasks = tasks_with_aliases .get_matching(&t)? .into_iter() + // When using wildcard patterns (containing "..."), filter out inherited tasks + // to avoid matching tasks that exist only due to inheritance from parent configs. + // This prevents issues like circular dependencies when root defines + // `test = { depends = ['//...:test'] }` and subdirs inherit that task. + .filter(|task| !t.contains("...") || !task.inherited) .cloned() .collect_vec(); if cur_tasks.is_empty() {