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
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
## 1. Implementation

- [ ] 1.1 Update `TaskStep` interface in `src/TaskStep.ts` to include an optional `tags?: string[]` property.
- [ ] 1.2 Define a new type or interface `TaskFilterConfig` in `src/contracts/TaskFilterConfig.ts` with optional properties for `includeTags`, `excludeTags`, `includeNames`, `excludeNames`, and `includeDependencies`.
- [ ] 1.3 Create a utility module `src/utils/TaskFilter.ts`.
- [x] 1.1 Update `TaskStep` interface in `src/TaskStep.ts` to include an optional `tags?: string[]` property.
- [x] 1.2 Define a new type or interface `TaskFilterConfig` in `src/contracts/TaskFilterConfig.ts` with optional properties for `includeTags`, `excludeTags`, `includeNames`, `excludeNames`, and `includeDependencies`.
- [x] 1.3 Create a utility module `src/utils/TaskFilter.ts`.
- Implement a pure function `filterTasks(steps: TaskStep<any>[], config: TaskFilterConfig): TaskStep<any>[]`.
- Ensure filtering handles both names and tags.
- [ ] 1.4 Handle Dependencies during filtering. Implement a configurable flag in `TaskFilterConfig` (e.g., `includeDependencies?: boolean`).
- [x] 1.4 Handle Dependencies during filtering. Implement a configurable flag in `TaskFilterConfig` (e.g., `includeDependencies?: boolean`).
- If `true`, recursively include all tasks that the explicitly selected tasks depend on.
- [ ] 1.5 Update `TaskRunnerExecutionConfig` in `src/TaskRunnerExecutionConfig.ts` to optionally accept a `filter` of type `TaskFilterConfig`.
- [ ] 1.6 Update `TaskRunner.ts` in the `execute` method to apply `filterTasks` to the input steps if `config.filter` is provided, *before* passing the subset to `WorkflowExecutor`.
- [ ] 1.7 Write unit tests for `TaskFilter.ts` ensuring inclusion, exclusion, and dependency resolution work correctly.
- [ ] 1.8 Write integration tests for `TaskRunner` filtering in `tests/TaskRunnerFiltering.test.ts` to verify end-to-end filtering execution.
- [x] 1.5 Update `TaskRunnerExecutionConfig` in `src/TaskRunnerExecutionConfig.ts` to optionally accept a `filter` of type `TaskFilterConfig`.
- [x] 1.6 Update `TaskRunner.ts` in the `execute` method to apply `filterTasks` to the input steps if `config.filter` is provided, *before* passing the subset to `WorkflowExecutor`.
- [x] 1.7 Write unit tests for `TaskFilter.ts` ensuring inclusion, exclusion, and dependency resolution work correctly.
- [x] 1.8 Write integration tests for `TaskRunner` filtering in `tests/TaskRunnerFiltering.test.ts` to verify end-to-end filtering execution.
12 changes: 9 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,14 @@ export class TaskRunner<TContext> {
// Initialize plugins
await this.pluginManager.initialize();

// Apply optional filtering before execution
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 +216,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
9 changes: 9 additions & 0 deletions src/TaskRunnerExecutionConfig.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
/**
* Configuration options for TaskRunner execution.
*/
import { TaskFilterConfig } from "./contracts/TaskFilterConfig.js";
Comment on lines +1 to +4
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 JSDoc block at the top of the file is redundant as it is repeated immediately before the interface declaration. Removing it keeps the code concise and avoids duplication.

import { TaskFilterConfig } from "./contracts/TaskFilterConfig.js";


/**
* Configuration options for TaskRunner execution.
*/
export interface TaskRunnerExecutionConfig {
/**
* Optional filtering configuration to selectively execute tasks.
*/
filter?: TaskFilterConfig;
/**
* An AbortSignal to cancel the workflow externally.
*/
Expand Down
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 to categorize the task for filtering. */
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 during execution.
*/
export interface TaskFilterConfig {
/**
* Run only tasks with these tags.
*/
includeTags?: string[];
/**
* Exclude tasks with these tags.
*/
excludeTags?: string[];
/**
* Run only tasks with these names.
*/
includeNames?: string[];
/**
* Exclude tasks with these names.
*/
excludeNames?: string[];
/**
* If true, automatically include dependencies of selected tasks.
* Default is false.
*/
includeDependencies?: boolean;
}
71 changes: 71 additions & 0 deletions src/utils/TaskFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { TaskStep } from "../TaskStep.js";
import { TaskFilterConfig } from "../contracts/TaskFilterConfig.js";

/**
* Filters a list of tasks based on the provided configuration.
*
* @param steps The original array of tasks.
* @param config The filtering configuration.
* @returns A new array containing the filtered tasks.
*/
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 16 to the 15 allowed.

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

const filteredSteps = steps.filter((step) => {
// 1. Check exclusions first (highest priority)
if (
config.excludeNames?.includes(step.name) ||
(step.tags && config.excludeTags?.some((tag) => step.tags!.includes(tag)))
) {
return false;
}

// 2. Check inclusions (if both are provided, satisfying either is enough or requires both? Usually OR semantics for inclusions)
// Actually, usually if include is present, it MUST match one of the inclusions.
// Let's implement OR logic: if included by name OR included by tag.
const hasIncludeNames =
config.includeNames && config.includeNames.length > 0;
const hasIncludeTags = config.includeTags && config.includeTags.length > 0;

if (!hasIncludeNames && !hasIncludeTags) {
return true; // No inclusion filters, so keep it if it passed exclusion
}

const includedByName = hasIncludeNames && config.includeNames!.includes(step.name);
const includedByTag =
hasIncludeTags &&
step.tags &&
config.includeTags!.some((tag) => step.tags!.includes(tag));

return includedByName || includedByTag;
});
Comment on lines +17 to +44
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 filtering logic uses several non-null assertion operators (!), which violates the project's general rules. Additionally, the logic can be simplified and made more robust by using optional chaining and nullish coalescing to handle potentially undefined properties like tags or includeNames.

  const filteredSteps = steps.filter((step) => {
    // 1. Check exclusions first (highest priority)
    const isExcludedByName = config.excludeNames?.includes(step.name) ?? false;
    const isExcludedByTag = step.tags?.some((tag) => config.excludeTags?.includes(tag)) ?? false;

    if (isExcludedByName || isExcludedByTag) {
      return false;
    }

    // 2. Check inclusions
    const hasIncludeNames = (config.includeNames?.length ?? 0) > 0;
    const hasIncludeTags = (config.includeTags?.length ?? 0) > 0;

    if (!hasIncludeNames && !hasIncludeTags) {
      return true;
    }

    const includedByName = config.includeNames?.includes(step.name) ?? false;
    const includedByTag = step.tags?.some((tag) => config.includeTags?.includes(tag)) ?? false;

    return includedByName || includedByTag;
  });
References
  1. In TypeScript, avoid using the non-null assertion operator (!). Instead, use explicit checks or safe fallbacks like the nullish coalescing operator (??) to handle potentially null or undefined values.


if (!config.includeDependencies) {
return filteredSteps;
}

// Include dependencies recursively
const resultSet = new Set<string>(filteredSteps.map((s) => s.name));
const queue = [...filteredSteps];

while (queue.length > 0) {
const current = queue.shift()!;
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

Avoid using the non-null assertion operator (!). Although the queue length is checked, it is better practice to handle the potential undefined return from shift() explicitly to adhere to the project's safety rules.

    const current = queue.shift();
    if (!current) continue;
References
  1. In TypeScript, avoid using the non-null assertion operator (!). Instead, use explicit checks or safe fallbacks like the nullish coalescing operator (??) to handle potentially null or undefined values.

if (current.dependencies) {
for (const depName of current.dependencies) {
if (!resultSet.has(depName)) {
resultSet.add(depName);
const depStep = stepMap.get(depName);
if (depStep) {
queue.push(depStep);
}
}
}
}
}

// Preserve original order and map names back to steps
return steps.filter((step) => resultSet.has(step.name));
}
92 changes: 92 additions & 0 deletions tests/TaskRunnerFiltering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { TaskRunner } from "../src/TaskRunner.js";
import { TaskStep } from "../src/TaskStep.js";

interface TestContext {
executedTasks: string[];
}

const createTestTask = (
name: string,
dependencies?: string[],
tags?: string[]
): TaskStep<TestContext> => ({
name,
dependencies,
tags,
run: async (ctx) => {
ctx.executedTasks.push(name);
return { status: "success" };
},
});

describe("TaskRunner Filtering End-to-End", () => {
const steps: TaskStep<TestContext>[] = [
createTestTask("task-1", [], ["setup"]),
createTestTask("task-2", ["task-1"], ["build"]),
createTestTask("task-3", ["task-2"], ["test", "unit"]),
createTestTask("task-4", ["task-2"], ["test", "e2e"]),
];

it("should run all tasks when no filter is provided", async () => {
const context: TestContext = { executedTasks: [] };
const runner = new TaskRunner(context);

await runner.execute(steps);

expect(context.executedTasks.length).toBe(4);
expect(context.executedTasks).toContain("task-1");
expect(context.executedTasks).toContain("task-2");
expect(context.executedTasks).toContain("task-3");
expect(context.executedTasks).toContain("task-4");
});

it("should execute only the filtered tasks by tag", async () => {
const context: TestContext = { executedTasks: [] };
const runner = new TaskRunner(context);

// Filter only "test" tasks, omitting dependencies
// task-3 and task-4 depend on task-2. In reality, the graph would fail validation if we omit dependencies,
// but our execution config should handle it or fail validation if dependencies are missing and includeDependencies is false.
// Wait, TaskGraphValidator checks if dependencies exist in the passed graph.
// Let's test with includeDependencies: true to ensure valid execution graph.
await runner.execute(steps, {
filter: {
includeTags: ["test"],
includeDependencies: true,
},
});

expect(context.executedTasks.length).toBe(4); // includes 1, 2, 3, 4
});

it("should execute only the explicitly included task when valid isolated graph", async () => {
const context: TestContext = { executedTasks: [] };
const runner = new TaskRunner(context);

await runner.execute(steps, {
filter: {
includeNames: ["task-1"],
},
});

expect(context.executedTasks.length).toBe(1);
expect(context.executedTasks).toEqual(["task-1"]);
});

it("should fail validation if filtering creates broken dependency graph", async () => {
const context: TestContext = { executedTasks: [] };
const runner = new TaskRunner(context);

// This will include task-2 which depends on task-1, but task-1 is filtered out.
// TaskGraphValidator should throw.
await expect(
runner.execute(steps, {
filter: {
includeNames: ["task-2"],
includeDependencies: false,
},
})
).rejects.toThrow();
});
});
Loading
Loading