Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
7 changes: 7 additions & 0 deletions dev_docs/project_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,13 @@ This plan outlines the major development phases and tasks for building `dela`, a

## Phase 9: Increase Task Runner Coverage

- [ ] **Composed / Included Task Definitions**
- [x] [DTKT-197] Define shared recursive discovery primitives and allowlist/source-attribution handling for composed task definitions so Make, Taskfile, and Turborepo can reuse the same traversal semantics.
- [x] [DTKT-198] Support Makefile `include`, `-include`, and `sinclude` during task discovery, including recursively loading referenced makefiles and covering the behavior with unit and integration tests. These will be parsed using the lossless parser and the fallback regex parser.
- [x] [DTKT-199] Preserve source-file attribution for tasks discovered from included makefiles so `dela list`, allowlists, and MCP metadata point at the defining file, with tests covering duplicates and allowlist decisions from included files.
- [x] [DTKT-200] Support Taskfile `includes` so tasks from included Taskfiles are discovered and exposed by dela, with unit and integration coverage for recursive includes and duplicate task names.
- [x] [DTKT-201] Support Turborepo workspace-local `turbo.json` configs that extend the root config so inherited package tasks are discovered, with unit and integration coverage for recursive config resolution.

- [ ] **Additional Task Runners support**
- [x] [DTKT-119] Implement Maven `pom.xml` parser and task discovery
- [x] [DTKT-120] Implement Gradle `build.gradle`/`build.gradle.kts` parser
Expand Down
129 changes: 78 additions & 51 deletions src/allowlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,52 +59,81 @@ fn path_matches(task_path: &Path, allowlist_path: &Path, allow_subdirs: bool) ->
}
}

/// Check if a given task is explicitly allowed or denied by the allowlist
/// Returns (explicitly_allowed, explicitly_denied) - both false means not found in allowlist
/// This is the core allowlist checking logic without any prompting or persistence
pub fn is_task_allowed(task: &Task) -> Result<(bool, bool), String> {
// Only proceed with allowlist operations if dela is initialized
let allowlist = load_allowlist()?;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AllowlistMatch {
Allowed,
Denied,
NotFound,
}

/// Evaluate a task against an already-loaded allowlist.
/// This is shared between the CLI and MCP surfaces so composed definitions use
/// identical path and precedence semantics everywhere.
pub fn evaluate_task_against_allowlist(task: &Task, allowlist: &Allowlist) -> AllowlistMatch {
let task_path = task.allowlist_path();

// First pass: Check for deny entries (highest precedence)
for entry in &allowlist.entries {
if let AllowScope::Deny = entry.scope
&& path_matches(&task.file_path, &entry.path, true)
&& path_matches(task_path, &entry.path, true)
{
return Ok((false, true));
return AllowlistMatch::Denied;
}
}

// Second pass: Check for allow entries
for entry in &allowlist.entries {
match entry.scope {
AllowScope::Directory => {
if path_matches(&task.file_path, &entry.path, true) {
return Ok((true, false));
if path_matches(task_path, &entry.path, true) {
return AllowlistMatch::Allowed;
}
}
AllowScope::File => {
if path_matches(&task.file_path, &entry.path, false) {
return Ok((true, false));
if path_matches(task_path, &entry.path, false) {
return AllowlistMatch::Allowed;
}
}
AllowScope::Task => {
if path_matches(&task.file_path, &entry.path, false)
if path_matches(task_path, &entry.path, false)
&& let Some(ref tasks) = entry.tasks
&& tasks.contains(&task.name)
{
return Ok((true, false));
return AllowlistMatch::Allowed;
}
}
AllowScope::Deny | AllowScope::Once => {
// Already handled deny in first pass, skip Once
continue;
}
AllowScope::Deny | AllowScope::Once => continue,
}
}

// If no matching entry found, return (false, false) - not found
Ok((false, false))
AllowlistMatch::NotFound
}

pub fn allowlist_entry_for_task(task: &Task, scope: AllowScope) -> AllowlistEntry {
let tasks = if scope == AllowScope::Task {
Some(vec![task.name.clone()])
} else {
None
};

AllowlistEntry {
path: task.allowlist_path().to_path_buf(),
scope,
tasks,
}
}

/// Check if a given task is explicitly allowed or denied by the allowlist
/// Returns (explicitly_allowed, explicitly_denied) - both false means not found in allowlist
/// This is the core allowlist checking logic without any prompting or persistence
pub fn is_task_allowed(task: &Task) -> Result<(bool, bool), String> {
// Only proceed with allowlist operations if dela is initialized
let allowlist = load_allowlist()?;
Ok(match evaluate_task_against_allowlist(task, &allowlist) {
AllowlistMatch::Allowed => (true, false),
AllowlistMatch::Denied => (false, true),
AllowlistMatch::NotFound => (false, false),
})
}

/// Check if a given task is allowed, based on the loaded allowlist
Expand Down Expand Up @@ -134,32 +163,18 @@ pub fn check_task_allowed(task: &Task) -> Result<bool, String> {
Ok(true)
}
scope => {
// Create a new allowlist entry
let mut entry = AllowlistEntry {
path: task.file_path.clone(),
scope: scope.clone(),
tasks: None,
};

// For Task scope, add the specific task name
if scope == AllowScope::Task {
entry.tasks = Some(vec![task.name.clone()]);
}

// Add the entry and save
allowlist.entries.push(entry);
allowlist
.entries
.push(allowlist_entry_for_task(task, scope.clone()));
save_allowlist(&allowlist)?;
Ok(true)
}
}
}
AllowDecision::Deny => {
// Add a deny entry and save
let entry = AllowlistEntry {
path: task.file_path.clone(),
scope: AllowScope::Deny,
tasks: None,
};
let entry = allowlist_entry_for_task(task, AllowScope::Deny);
allowlist.entries.push(entry);
save_allowlist(&allowlist)?;
Ok(false)
Expand All @@ -172,20 +187,10 @@ pub fn check_task_allowed_with_scope(task: &Task, scope: AllowScope) -> Result<b
// Only proceed with allowlist operations if dela is initialized
let mut allowlist = load_allowlist()?;

// Create a new allowlist entry
let mut entry = AllowlistEntry {
path: task.file_path.clone(),
scope: scope.clone(),
tasks: None,
};

// For Task scope, add the specific task name
if scope == AllowScope::Task {
entry.tasks = Some(vec![task.name.clone()]);
}

// Add the entry and save
allowlist.entries.push(entry);
allowlist
.entries
.push(allowlist_entry_for_task(task, scope));
save_allowlist(&allowlist)?;
Ok(true)
}
Expand All @@ -204,6 +209,7 @@ mod tests {
Task {
name: name.to_string(),
file_path,
definition_path: None,
definition_type: TaskDefinitionType::Makefile,
runner: TaskRunner::Make,
source_name: name.to_string(),
Expand Down Expand Up @@ -423,6 +429,27 @@ mod tests {
reset_to_real_environment();
}

#[test]
#[serial]
fn test_is_task_allowed_uses_definition_path_for_composed_tasks() {
let (temp_dir, mut task) = setup_test_env();
task.file_path = PathBuf::from("/project/.github/workflows");
task.definition_path = Some(PathBuf::from("/project/.github/workflows/test.yml"));

let mut allowlist = Allowlist::default();
allowlist.entries.push(AllowlistEntry {
path: PathBuf::from("/project/.github/workflows/test.yml"),
scope: AllowScope::File,
tasks: None,
});
save_allowlist(&allowlist).unwrap();

assert_eq!(is_task_allowed(&task).unwrap(), (true, false));

drop(temp_dir);
reset_to_real_environment();
}

#[test]
#[serial]
fn test_is_task_allowed_precedence() {
Expand Down
45 changes: 45 additions & 0 deletions src/commands/allow_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,4 +406,49 @@ test: ## Running tests

reset_to_real_environment();
}

#[test]
#[serial]
fn test_allow_command_uses_definition_path_for_included_make_task() {
let (project_dir, home_dir) = setup_test_env();
env::set_current_dir(&project_dir).expect("Failed to change directory");

fs::create_dir_all(project_dir.path().join("mk")).expect("Failed to create include dir");
fs::write(
project_dir.path().join("Makefile"),
r#"include mk/common.mk

build:
@echo Building..."#,
)
.expect("Failed to write root Makefile");
fs::write(
project_dir.path().join("mk").join("common.mk"),
r#"included-task:
@echo Included"#,
)
.expect("Failed to write included Makefile");

let result = execute("included-task", Some(3)); // 3 = file scope
assert!(
result.is_ok(),
"Should allow included task by defining file"
);

let allowlist = fs::read_to_string(preferred_allowlist_path_for(home_dir.path())).unwrap();
assert!(
allowlist.contains(
&project_dir
.path()
.join("mk")
.join("common.mk")
.display()
.to_string()
),
"allowlist should use included file path, got:\n{}",
allowlist
);

reset_to_real_environment();
}
}
Loading
Loading