Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cli/tasks/validate.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,4 @@ The validate command performs the following checks:
• Shell Commands: Checks shell executables exist
• Glob Patterns: Validates source and output patterns
• Run Entries: Ensures tasks reference valid dependencies
• Task Includes: Flags task_config.includes paths that do not exist
65 changes: 65 additions & 0 deletions e2e/tasks/test_task_broken_include
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash

# A task_config.includes entry that points at nothing used to be dropped in silence, so a typo or a
# moved file made tasks disappear with nothing to say why.
# See: https://github.com/jdx/mise/discussions/4792

cat <<EOF >tasks.toml
[hello]
run = 'echo "hi"'
EOF

cat <<EOF >mise.toml
[task_config]
includes = ["gone.toml", "tasks.toml"]
EOF

# the surviving include still loads, and the dead one is now named
assert_contains "mise tasks 2>&1" "hello"
assert_contains "mise tasks 2>&1" "gone.toml"
assert_contains "mise tasks 2>&1" "does not exist"

# mise tasks validate reports it too, and says which include.
# These also pin the severity: assert_contains fails the test on a non-zero exit, and `validate`
# only exits non-zero when it found an *error*, so a passing assertion here is the warning-not-error
# check as well.
assert_contains "mise tasks validate 2>&1" "missing-include"
assert_contains "mise tasks validate --json 2>&1" "missing-include"

# a directory include is satisfied by the directory existing
mkdir -p mytasks
cat <<EOF >mytasks/fromdir
#!/usr/bin/env bash
echo "from dir"
EOF
chmod +x mytasks/fromdir

cat <<EOF >mise.toml
[task_config]
includes = ["mytasks", "tasks.toml"]
EOF

assert_contains "mise tasks 2>&1" "fromdir"
assert_not_contains "mise tasks 2>&1" "does not exist"

# The built-in default includes must never warn. Only one of the five default task directories
# usually exists, so warning on the rest would fire on nearly every mise invocation.
rm -f mise.toml
mkdir -p mise-tasks
cat <<EOF >mise-tasks/fromdefault
#!/usr/bin/env bash
echo "from default"
EOF
chmod +x mise-tasks/fromdefault

assert_contains "mise tasks 2>&1" "fromdefault"
assert_not_contains "mise tasks 2>&1" "does not exist"

# A glob matching nothing is the normal state of a directory whose task files are not written yet.
cat <<EOF >mise.toml
[task_config]
includes = ["empty/*.toml", "tasks.toml"]
EOF

assert_contains "mise tasks 2>&1" "hello"
assert_not_contains "mise tasks 2>&1" "does not exist"
4 changes: 3 additions & 1 deletion e2e/tasks/test_task_edit_nested_names
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ cat <<'EOF' >mise.toml
includes = ["tasks.d", "existing-tasks"]
EOF

TASK_PATH5=$(mise tasks edit dotted --path 2>&1)
# stdout only: `tasks.d` is deliberately absent here, so loading the tasks now warns about it, and
# folding stderr into a path capture would compare the warning against the path.
TASK_PATH5=$(mise tasks edit dotted --path)
assert "printf '%s' \"$TASK_PATH5\"" "$PWD/existing-tasks/dotted"
assert "test -f \"$TASK_PATH5\""

Expand Down
1 change: 1 addition & 0 deletions mise.usage.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -4251,6 +4251,7 @@ The validate command performs the following checks:
• Shell Commands: Checks shell executables exist
• Glob Patterns: Validates source and output patterns
• Run Entries: Ensures tasks reference valid dependencies
• Task Includes: Flags task_config.includes paths that do not exist

"""#
flag --errors-only help="Only show errors (skip warnings)"
Expand Down
29 changes: 29 additions & 0 deletions src/cli/tasks/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::path::PathBuf;
use std::sync::Arc;

use crate::config::Config;
use crate::dirs;
use crate::duration;
use crate::file;
use crate::task::task_fetcher::TaskFetcher;
Expand Down Expand Up @@ -110,6 +111,7 @@ impl TasksValidate {
for task in &tasks {
issues.extend(self.validate_task(task, &all_tasks, &config).await);
}
issues.extend(self.validate_task_includes(&config)?);

// Filter by severity if needed
if self.errors_only {
Expand Down Expand Up @@ -144,6 +146,32 @@ impl TasksValidate {
Ok(())
}

/// Report `task_config.includes` entries that point at nothing.
///
/// Not attached to any task — a dead include is why a task is *missing*, so there is nothing to
/// hang it off. Uses the same pseudo-task convention as `graph_error_issue`.
fn validate_task_includes(&self, config: &Arc<Config>) -> Result<Vec<ValidationIssue>> {
let Some(cwd) = &*dirs::CWD else {
return Ok(vec![]);
};
Ok(
crate::config::missing_task_includes_for_dir(cwd, &config.config_files)?
.into_iter()
.map(|path| ValidationIssue {
task: "task_config.includes".to_string(),
severity: Severity::Warning,
category: "missing-include".to_string(),
message: format!("Include path does not exist: {}", file::display_path(&path)),
details: Some(
"Tasks from this include are silently absent. Remove it from \
task_config.includes or create the path."
.to_string(),
),
})
.collect(),
)
}

async fn find_additional_graph_issues(
&self,
config: &Arc<Config>,
Expand Down Expand Up @@ -837,6 +865,7 @@ The validate command performs the following checks:
• <bold>Shell Commands</bold>: Checks shell executables exist
• <bold>Glob Patterns</bold>: Validates source and output patterns
• <bold>Run Entries</bold>: Ensures tasks reference valid dependencies
• <bold>Task Includes</bold>: Flags task_config.includes paths that do not exist
"#
);

Expand Down
139 changes: 137 additions & 2 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4184,6 +4184,101 @@ fn expand_task_include(dir: &Path, pattern: &str) -> Vec<PathBuf> {
}
}

/// The path a literal task include resolves to, when nothing exists there.
///
/// Globs are deliberately excluded: a glob matching nothing is the normal state of a directory
/// whose task files have not been written yet, so reporting it would be noise. `git::` entries
/// never reach here — callers branch on them before expanding.
///
/// Only ever called for *explicit* `task_config.includes`. The built-in defaults
/// (`default_task_includes`) are mostly absent on any given project, so running this over them
/// would warn on every mise invocation.
fn missing_literal_include(dir: &Path, pattern: &str) -> Option<PathBuf> {
let pattern = file::replace_path(pattern);
let pattern = pattern.to_string_lossy();
if is_glob_pattern(&pattern) {
return None;
}
let path = PathBuf::from(&*pattern);
let resolved = if path.is_absolute() {
path
} else {
dir.join(path)
};
if resolved.exists() {
return None;
}
// `dir.join("./tasks.toml")` keeps the `.` component, and `<root>/./tasks.toml` in a warning
// reads like mise mangled the path. Only the displayed form changes; both spellings name the
// same file, and the existence check above already ran.
Some(
resolved
.components()
.filter(|c| !matches!(c, std::path::Component::CurDir))
.collect(),
)
}

#[cfg(test)]
mod task_include_tests {
use super::*;
use std::fs;
use tempfile::TempDir;

#[test]
fn missing_literal_include_only_flags_absent_literal_paths() -> Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path();
fs::write(dir.join("tasks.toml"), "")?;
fs::create_dir(dir.join("mytasks"))?;

// an include that resolves to something is fine, file or directory alike
assert_eq!(missing_literal_include(dir, "tasks.toml"), None);
assert_eq!(missing_literal_include(dir, "mytasks"), None);

// an include that resolves to nothing is reported, resolved against the include root
assert_eq!(
missing_literal_include(dir, "gone.toml"),
Some(dir.join("gone.toml"))
);

// a `./` prefix resolves to the same file, and must not survive into the message
assert_eq!(missing_literal_include(dir, "./tasks.toml"), None);
assert_eq!(
missing_literal_include(dir, "./gone.toml"),
Some(dir.join("gone.toml"))
);

// globs are never reported: matching nothing is the normal state of a directory whose
// task files have not been written yet
assert_eq!(missing_literal_include(dir, "nope/*.toml"), None);
assert_eq!(missing_literal_include(dir, "*.toml"), None);

Ok(())
}

#[test]
fn missing_literal_include_keeps_absolute_paths_absolute() -> Result<()> {
let tmp = TempDir::new()?;
let other = TempDir::new()?;
let absent = other.path().join("gone.toml");

// an absolute include must not be joined onto the include root
assert_eq!(
missing_literal_include(tmp.path(), &absent.to_string_lossy()),
Some(absent.clone())
);

fs::write(&absent, "")?;
assert_eq!(
missing_literal_include(tmp.path(), &absent.to_string_lossy()),
None
);

Ok(())
}
}

fn task_include_patterns_for_dir(
dir: &Path,
config_files: &ConfigMap,
Expand Down Expand Up @@ -4238,6 +4333,24 @@ pub fn task_includes_for_dir(dir: &Path, config_files: &ConfigMap) -> Result<Vec
.collect::<Vec<_>>())
}

/// Explicit `task_config.includes` entries that point at nothing.
///
/// Returns the resolved paths, in the order they are declared. Empty when the config does not set
/// `includes` at all — the built-in defaults are not reported, because they are absent on almost
/// every project and are not something the user asked for.
pub fn missing_task_includes_for_dir(dir: &Path, config_files: &ConfigMap) -> Result<Vec<PathBuf>> {
let (includes, resolve_dir, uses_defaults) = task_include_patterns_for_dir(dir, config_files)?;
if uses_defaults {
return Ok(vec![]);
}
Ok(includes
.iter()
.filter(|include| !include.starts_with("git::"))
.filter_map(|include| missing_literal_include(&resolve_dir, include))
.unique()
.collect())
}

/// Returns the directory where a new file task should be created.
///
/// Existing directories are selected in effective `task_config.includes` order. When the
Expand Down Expand Up @@ -4490,7 +4603,7 @@ async fn load_task_sources_from_configs(
// a config can only vouch for task include files when it was actually
// trusted — safe configs load without trust and cannot vouch for anything
let require_task_include_trust = !configs.iter().any(|cf| is_path_trusted(cf.get_path()));
let (includes, resolve_dir, include_config_precedence) = configs
let explicit_includes = configs
.iter()
.enumerate()
.find_map(|(precedence, cf)| match cf.task_config_includes() {
Expand All @@ -4506,7 +4619,11 @@ async fn load_task_sources_from_configs(
.clone()
.map(|includes| (includes, tc.includes_root.clone(), configs.len()))
})
})
});
// Only an include the user wrote is worth complaining about; the built-in defaults are absent
// on nearly every project.
let uses_default_includes = explicit_includes.is_none();
let (includes, resolve_dir, include_config_precedence) = explicit_includes
.unwrap_or_else(|| (default_task_includes(), dir.to_path_buf(), configs.len()));

// Resolve task defaults once for the config root so inline tasks from
Expand Down Expand Up @@ -4564,6 +4681,24 @@ async fn load_task_sources_from_configs(
let paths = if include.starts_with("git::") {
vec![resolve_git_url_to_path(include).await?]
} else {
if !uses_default_includes
&& let Some(missing) = missing_literal_include(&resolve_dir, include)
{
// The message carries both halves on purpose, which also makes it the right
// dedup key for `warn_once!`: one line per (entry, resolved path) pair, i.e. one
// per distinct broken thing.
//
// - the entry, because it can look nothing like the path it resolves to — a
// `~/tasks.toml` entry resolves to `$HOME/tasks.toml`, and the path alone
// leaves the reader hunting for the config line to delete
// - the resolved path, because the same config is loaded once per directory and
// every one of them resolves the entry identically, so including it collapses a
// monorepo's worth of repeats into a single line
warn_once!(
"task_config.includes entry '{include}' does not exist: {}",
display_path(&missing)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
expand_task_include(&resolve_dir, include)
};
for p in paths {
Expand Down
Loading