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
11 changes: 8 additions & 3 deletions src/TaskRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RetryingExecutionStrategy } from "./strategies/RetryingExecutionStrateg
import { Plugin } from "./contracts/Plugin.js";
import { PluginManager } from "./PluginManager.js";
import { DryRunExecutionStrategy } from "./strategies/DryRunExecutionStrategy.js";
import { filterTasks } from "./utils/TaskFilter.js";

const MERMAID_ID_REGEX = /[^a-zA-Z0-9_-]/g;

Expand Down Expand Up @@ -176,9 +177,13 @@ export class TaskRunner<TContext> {
// Initialize plugins
await this.pluginManager.initialize();

const tasksToExecute = config?.filter
? filterTasks(steps, config.filter)
: steps;

// Validate the task graph before execution
const taskGraph: TaskGraph = {
tasks: steps.map((step) => ({
tasks: tasksToExecute.map((step) => ({
id: step.name,
dependencies: step.dependencies ?? [],
})),
Expand Down Expand Up @@ -210,13 +215,13 @@ export class TaskRunner<TContext> {
if (config?.timeout !== undefined) {
return this.executeWithTimeout(
executor,
steps,
tasksToExecute,
config.timeout,
config.signal
);
}

return executor.execute(steps, config?.signal);
return executor.execute(tasksToExecute, config?.signal);
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/TaskRunnerExecutionConfig.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TaskFilterConfig } from "./contracts/TaskFilterConfig.js";

/**
* Configuration options for TaskRunner execution.
*/
Expand All @@ -20,4 +22,8 @@ export interface TaskRunnerExecutionConfig {
* If undefined, all ready tasks will be run in parallel.
*/
concurrency?: number;
/**
* Configuration for filtering the tasks to be executed.
*/
filter?: TaskFilterConfig;
}
2 changes: 2 additions & 0 deletions src/TaskStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface TaskStep<TContext> {
name: string;
/** An optional list of task names that must complete successfully before this step can run. */
dependencies?: string[];
/** Optional tags for filtering tasks. */
tags?: string[];
/** Optional retry configuration for the task. */
retry?: TaskRetryConfig;
/** Optional loop configuration for the task. */
Expand Down
26 changes: 26 additions & 0 deletions src/contracts/TaskFilterConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Configuration options for filtering tasks before execution.
*/
export interface TaskFilterConfig {
/**
* Only include tasks that have at least one of these tags.
*/
includeTags?: string[];
/**
* Exclude tasks that have any of these tags.
*/
excludeTags?: string[];
/**
* Only include tasks with these specific names.
*/
includeNames?: string[];
/**
* Exclude tasks with these specific names.
*/
excludeNames?: string[];
/**
* If true, dependencies of included tasks will also be included automatically.
* Default is false.
*/
includeDependencies?: boolean;
}
93 changes: 93 additions & 0 deletions src/utils/TaskFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { TaskStep } from "../TaskStep.js";
import { TaskFilterConfig } from "../contracts/TaskFilterConfig.js";

/**
* Filters an array of tasks based on the provided configuration.
*
* @param steps The array of tasks to filter.
* @param config The filter configuration.
* @returns A new array of tasks that match the filter criteria.
*/
export function filterTasks<TContext>(

Check failure on line 11 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 48 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q1YNQmm9_Jq1qSFEW&open=AZ1Q1YNQmm9_Jq1qSFEW&pullRequest=246
steps: TaskStep<TContext>[],
config: TaskFilterConfig
): TaskStep<TContext>[] {
const stepMap = new Map<string, TaskStep<TContext>>();
for (const step of steps) {
stepMap.set(step.name, step);
}

const initialMatches = new Set<string>();

for (const step of steps) {
let included = true;

// Check inclusion criteria
if (!config.includeNames?.length && !config.includeTags?.length) {
included = true;

Check warning on line 27 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Review this redundant assignment: "included" already holds the assigned value along all execution paths.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q1YNQmm9_Jq1qSFEX&open=AZ1Q1YNQmm9_Jq1qSFEX&pullRequest=246
} else if (config.includeNames?.length && config.includeTags?.length) {
// If both are provided, we should match either.
const nameMatch = config.includeNames.includes(step.name);
const tagMatch = !!step.tags && step.tags.some(tag => config.includeTags!.includes(tag));

included = nameMatch || tagMatch;
} else if (config.includeNames?.length) {
included = config.includeNames.includes(step.name);
} else {
// Only includeTags is provided
included = !!step.tags && step.tags.some(tag => config.includeTags!.includes(tag));
}

// Check exclusion criteria
if (config.excludeNames && config.excludeNames.includes(step.name)) {

Check warning on line 42 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q1YNQmm9_Jq1qSFEY&open=AZ1Q1YNQmm9_Jq1qSFEY&pullRequest=246
included = false;
}

if (config.excludeTags && !!step.tags && step.tags.some(tag => config.excludeTags!.includes(tag))) {
included = false;
}

if (included) {
initialMatches.add(step.name);
}
}

// Handle dependencies
if (config.includeDependencies) {
const queue = Array.from(initialMatches);
const visited = new Set<string>(initialMatches);

while (queue.length > 0) {
const currentName = queue.shift()!;
const currentStep = stepMap.get(currentName);

if (currentStep && currentStep.dependencies) {

Check warning on line 64 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q1YNRmm9_Jq1qSFEZ&open=AZ1Q1YNRmm9_Jq1qSFEZ&pullRequest=246
for (const dep of currentStep.dependencies) {
// If a dependency is excluded, we might not include it, but
// standard behavior is usually to override exclusion for explicit dependencies,
// or fail. Let's include it unless it's explicitly excluded.
// Wait, if it's explicitly excluded, it might fail the pipeline.
// For now, if we include dependencies, we'll just add them unless they are in exclusions.
let depExcluded = false;
const depStep = stepMap.get(dep);

if (config.excludeNames && config.excludeNames.includes(dep)) {

Check warning on line 74 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q1YNRmm9_Jq1qSFEa&open=AZ1Q1YNRmm9_Jq1qSFEa&pullRequest=246
depExcluded = true;
}
if (depStep && config.excludeTags && depStep.tags && depStep.tags.some(tag => config.excludeTags!.includes(tag))) {

Check warning on line 77 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q1YNRmm9_Jq1qSFEb&open=AZ1Q1YNRmm9_Jq1qSFEb&pullRequest=246
depExcluded = true;
}

if (!visited.has(dep) && !depExcluded) {
visited.add(dep);
queue.push(dep);
initialMatches.add(dep);
}
}
}
}
}
Comment on lines +15 to +89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation uses multiple array lookups (.includes()) and iterations (.some()) inside loops, leading to O(N*M) complexity. Converting filter criteria to Set objects provides O(1) lookups, which is significantly more efficient for larger task sets. Additionally, the inclusion and dependency resolution logic can be simplified for better maintainability and performance by avoiding redundant checks and sets.

  const includeNames = new Set(config.includeNames ?? []);
  const includeTags = new Set(config.includeTags ?? []);
  const excludeNames = new Set(config.excludeNames ?? []);
  const excludeTags = new Set(config.excludeTags ?? []);

  const stepMap = new Map(steps.map((step) => [step.name, step]));
  const initialMatches = new Set<string>();

  const hasIncludeCriteria = includeNames.size > 0 || includeTags.size > 0;

  for (const step of steps) {
    let included = !hasIncludeCriteria;

    if (hasIncludeCriteria) {
      const nameMatch = includeNames.has(step.name);
      const tagMatch = step.tags?.some((tag) => includeTags.has(tag)) ?? false;
      included = nameMatch || tagMatch;
    }

    if (included && (excludeNames.has(step.name) || (step.tags?.some((tag) => excludeTags.has(tag)) ?? false))) {
      included = false;
    }

    if (included) {
      initialMatches.add(step.name);
    }
  }

  if (config.includeDependencies) {
    const queue = Array.from(initialMatches);

    while (queue.length > 0) {
      const currentName = queue.shift()!;
      const currentStep = stepMap.get(currentName);

      if (currentStep?.dependencies) {
        for (const dep of currentStep.dependencies) {
          if (initialMatches.has(dep)) continue;

          const depStep = stepMap.get(dep);
          const isExcluded = excludeNames.has(dep) || 
            (depStep?.tags?.some((tag) => excludeTags.has(tag)) ?? false);

          if (!isExcluded) {
            initialMatches.add(dep);
            queue.push(dep);
          }
        }
      }
    }
  }
References
  1. When frequent lookups of items by a specific key are required, use a Map (or dictionary/hash table) to store the items, indexed by that key, to ensure O(1) access time.


// Filter the final list, preserving original order
return steps.filter(step => initialMatches.has(step.name));
}
154 changes: 154 additions & 0 deletions tests/TaskFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, it, expect, vi } from "vitest";
import { filterTasks } from "../src/utils/TaskFilter.js";
import { TaskStep } from "../src/TaskStep.js";
import { TaskResult } from "../src/TaskResult.js";

describe("TaskFilter", () => {
const mockRun = vi.fn().mockResolvedValue({ status: "success" } as TaskResult);

const steps: TaskStep<unknown>[] = [
{ name: "taskA", tags: ["frontend", "build"], run: mockRun },
{ name: "taskB", tags: ["backend", "build"], dependencies: ["taskA"], run: mockRun },
{ name: "taskC", tags: ["frontend", "test"], run: mockRun },
{ name: "taskD", dependencies: ["taskB"], run: mockRun },
{ name: "taskE", tags: ["deploy"], dependencies: ["taskC", "taskD"], run: mockRun },
];

it("should include tasks by exact names", () => {
const result = filterTasks(steps, { includeNames: ["taskA", "taskC"] });
expect(result.map((s) => s.name)).toEqual(["taskA", "taskC"]);
});

it("should include tasks by tags", () => {
const result = filterTasks(steps, { includeTags: ["frontend"] });
expect(result.map((s) => s.name)).toEqual(["taskA", "taskC"]);
});

it("should include tasks matching either names or tags", () => {
const result = filterTasks(steps, {
includeNames: ["taskD"],
includeTags: ["test"],
});
expect(result.map((s) => s.name)).toEqual(["taskC", "taskD"]);
});

it("should handle includeTags when includeNames is undefined", () => {
const result = filterTasks(steps, {
includeNames: undefined,
includeTags: ["test"],
});
expect(result.map((s) => s.name)).toEqual(["taskC"]);
});

it("should handle includeNames when includeTags is undefined", () => {
const result = filterTasks(steps, {
includeNames: ["taskD"],
includeTags: undefined,
});
expect(result.map((s) => s.name)).toEqual(["taskD"]);
});

it("should exclude tasks by exact names", () => {
const result = filterTasks(steps, { excludeNames: ["taskB", "taskC"] });
expect(result.map((s) => s.name)).toEqual(["taskA", "taskD", "taskE"]);
});

it("should exclude tasks by tags", () => {
const result = filterTasks(steps, { excludeTags: ["build"] });
expect(result.map((s) => s.name)).toEqual(["taskC", "taskD", "taskE"]);
});

it("should exclude taking precedence over include", () => {
const result = filterTasks(steps, {
includeTags: ["frontend"],
excludeNames: ["taskC"],
});
expect(result.map((s) => s.name)).toEqual(["taskA"]);
});

it("should include dependencies when includeDependencies is true", () => {
const result = filterTasks(steps, {
includeNames: ["taskD"],
includeDependencies: true,
});
// taskD depends on taskB, which depends on taskA
expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskD"]);
});

it("should not include excluded dependencies even if includeDependencies is true", () => {
const result = filterTasks(steps, {
includeNames: ["taskE"],
includeDependencies: true,
excludeTags: ["backend"],
});
expect(result.map((s) => s.name).sort()).toEqual(["taskC", "taskD", "taskE"].sort());
});

it("should not include excluded dependencies by name even if includeDependencies is true", () => {
const result = filterTasks(steps, {
includeNames: ["taskD"],
includeDependencies: true,
excludeNames: ["taskB"],
});
// taskD depends on taskB, which is excluded by name.
expect(result.map((s) => s.name)).toEqual(["taskD"]);
});

it("should handle missing dependencies gracefully when includeDependencies is true", () => {
const missingDepSteps: TaskStep<unknown>[] = [
{ name: "task1", dependencies: ["missingTask"], run: mockRun }
];
const result = filterTasks(missingDepSteps, {
includeNames: ["task1"],
includeDependencies: true,
});
expect(result.map((s) => s.name)).toEqual(["task1"]);
});

it("should handle default inclusion when neither includeNames nor includeTags are present but there is a truthy config with no properties matching", () => {
const result = filterTasks(steps, { includeNames: [], includeTags: [] });
expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]);
});

it("should default to false if both include criteria exist but neither is matched", () => {
const stepMissingBoth: TaskStep<unknown>[] = [
{ name: "taskNonMatching", run: mockRun }
];
const result = filterTasks(stepMissingBoth, { includeNames: ["a"], includeTags: ["b"] });
expect(result.map((s) => s.name)).toEqual([]);
});

it("should handle empty exclusions", () => {
const result = filterTasks(steps, { excludeNames: [], excludeTags: [] });
expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]);
});

it("should handle exclude tags gracefully if tasks have no tags", () => {
const taskWithoutTags: TaskStep<unknown>[] = [
{ name: "noTagTask", run: mockRun }
];
const result = filterTasks(taskWithoutTags, { excludeTags: ["frontend"] });
expect(result.map((s) => s.name)).toEqual(["noTagTask"]);
});

it("should default to included if inclusion conditions exists but are mutually falsy because missing values", () => {
const result = filterTasks(steps, { includeNames: [], includeTags: [] });
expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]);
});

it("should handle both includeNames and includeTags logic correctly", () => {
const mixedSteps: TaskStep<unknown>[] = [
{ name: "taskA", tags: ["frontend"], run: mockRun },
{ name: "taskB", tags: ["backend"], run: mockRun },
{ name: "taskC", tags: ["other"], run: mockRun },
];
// taskA matched by name, taskB matched by tag
const result = filterTasks(mixedSteps, { includeNames: ["taskA"], includeTags: ["backend"] });
expect(result.map((s) => s.name)).toEqual(["taskA", "taskB"]);
});

it("should return all tasks when no inclusion/exclusion criteria are provided", () => {
const result = filterTasks(steps, {});
expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]);
});
});
Loading
Loading