From 9670ab335e846fe11d890db31d179b11aebcffcc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:06:14 +0000 Subject: [PATCH] feat(task-runner): add task filtering by tags and names This commit implements the `add-task-filtering` specification from OpenSpec. It introduces the `TaskFilterConfig` interface, adds `tags` array to `TaskStep`, and provides a highly efficient `filterTasks` utility that respects inclusion, exclusion, and automatic dependency resolution rules. The configuration is wired into `TaskRunner.execute` to filter steps prior to execution and validation. Corresponding unit and integration tests have been added to ensure correctness and adherence to the specification. The OpenSpec change proposal has been successfully archived. Co-authored-by: thalesraymond <32554150+thalesraymond@users.noreply.github.com> --- .../2026-04-03-add-task-filtering}/design.md | 0 .../proposal.md | 0 .../specs/task-runner/spec.md | 0 .../2026-04-03-add-task-filtering}/tasks.md | 0 openspec/specs/task-runner/spec.md | 61 +++++++++ src/TaskRunner.ts | 9 +- src/TaskRunnerExecutionConfig.ts | 6 + src/TaskStep.ts | 2 + src/contracts/TaskFilterConfig.ts | 26 ++++ src/utils/TaskFilter.ts | 123 ++++++++++++++++++ tests/TaskRunnerFiltering.test.ts | 43 ++++++ tests/utils/TaskFilter.test.ts | 64 +++++++++ 12 files changed, 331 insertions(+), 3 deletions(-) rename openspec/changes/{add-task-filtering => archive/2026-04-03-add-task-filtering}/design.md (100%) rename openspec/changes/{add-task-filtering => archive/2026-04-03-add-task-filtering}/proposal.md (100%) rename openspec/changes/{add-task-filtering => archive/2026-04-03-add-task-filtering}/specs/task-runner/spec.md (100%) rename openspec/changes/{add-task-filtering => archive/2026-04-03-add-task-filtering}/tasks.md (100%) create mode 100644 src/contracts/TaskFilterConfig.ts create mode 100644 src/utils/TaskFilter.ts create mode 100644 tests/TaskRunnerFiltering.test.ts create mode 100644 tests/utils/TaskFilter.test.ts diff --git a/openspec/changes/add-task-filtering/design.md b/openspec/changes/archive/2026-04-03-add-task-filtering/design.md similarity index 100% rename from openspec/changes/add-task-filtering/design.md rename to openspec/changes/archive/2026-04-03-add-task-filtering/design.md diff --git a/openspec/changes/add-task-filtering/proposal.md b/openspec/changes/archive/2026-04-03-add-task-filtering/proposal.md similarity index 100% rename from openspec/changes/add-task-filtering/proposal.md rename to openspec/changes/archive/2026-04-03-add-task-filtering/proposal.md diff --git a/openspec/changes/add-task-filtering/specs/task-runner/spec.md b/openspec/changes/archive/2026-04-03-add-task-filtering/specs/task-runner/spec.md similarity index 100% rename from openspec/changes/add-task-filtering/specs/task-runner/spec.md rename to openspec/changes/archive/2026-04-03-add-task-filtering/specs/task-runner/spec.md diff --git a/openspec/changes/add-task-filtering/tasks.md b/openspec/changes/archive/2026-04-03-add-task-filtering/tasks.md similarity index 100% rename from openspec/changes/add-task-filtering/tasks.md rename to openspec/changes/archive/2026-04-03-add-task-filtering/tasks.md diff --git a/openspec/specs/task-runner/spec.md b/openspec/specs/task-runner/spec.md index 83cc950..4a1cd89 100644 --- a/openspec/specs/task-runner/spec.md +++ b/openspec/specs/task-runner/spec.md @@ -172,3 +172,64 @@ The system SHALL record timing metrics for each executed task, including start t #### Scenario: Failed execution - **WHEN** a task fails - **THEN** the task result contains the start timestamp, end timestamp, and duration in milliseconds +## ADDED Requirements + +### Requirement: Task Tagging + +The `TaskStep` interface SHALL support an optional `tags` array of strings to categorize the step. + +#### Scenario: Categorizing a Task +- **GIVEN** a task configuration +- **WHEN** the `tags` property is defined with an array of string identifiers (e.g., `["frontend", "build"]`) +- **THEN** the task SHALL retain those tags for metadata and filtering operations. + +### Requirement: Task Filtering Configuration + +The system SHALL provide a mechanism to configure execution filters via an interface `TaskFilterConfig`. + +#### Scenario: Filter Config Structure +- **GIVEN** a `TaskFilterConfig` object +- **THEN** it SHALL support: + - `includeTags`: Optional array of tag strings. Only tasks with at least one matching tag will be included. + - `excludeTags`: Optional array of tag strings. Tasks with any matching tag will be excluded. + - `includeNames`: Optional array of task names. Only tasks with matching names will be included. + - `excludeNames`: Optional array of task names. Tasks with matching names will be excluded. + - `includeDependencies`: Optional boolean. If true, dependencies of included tasks are automatically included. + +#### Scenario: Multiple Inclusion Filters Combine with OR Logic +- **WHEN** multiple `include*` properties are provided (e.g., `includeTags: ['frontend']` and `includeNames: ['lint']`) +- **THEN** a task SHALL be included if it matches **any** of the criteria (OR logic). + +### Requirement: Filtering Utility Module + +The system SHALL provide a `filterTasks` utility function that accepts an array of `TaskStep`s and a `TaskFilterConfig` and returns a filtered subset of tasks. + +#### Scenario: Inclusion Filtering +- **WHEN** `filterTasks` is invoked with `includeTags: ["test"]` +- **THEN** it SHALL return ONLY tasks containing the `"test"` tag. + +#### Scenario: Exclusion Filtering +- **WHEN** `filterTasks` is invoked with `excludeNames: ["deploy"]` +- **THEN** it SHALL return all tasks EXCEPT the one named `"deploy"`. + +#### Scenario: Dependency Resolution Override +- **WHEN** `filterTasks` is invoked with `includeDependencies: true` and a specific task name is selected +- **THEN** it SHALL return the selected task AND any tasks that the selected task recursively depends on from the original list. + +#### Scenario: Exclusion Precedence +- **WHEN** a task is implicitly included because it is a dependency of an explicitly selected task +- **AND** the implicitly included task matches an explicit exclusion criteria (e.g., `excludeTags`) +- **THEN** the explicitly excluded task SHALL NOT be included in the returned array. + +#### Scenario: Union of Inclusion Filters +- **WHEN** multiple `include*` criteria are provided +- **THEN** the final set of included tasks SHALL be the union of tasks matching each individual `include*` filter. + +### Requirement: Task Execution Filtering Support + +The `TaskRunnerExecutionConfig` SHALL support an optional `filter` property of type `TaskFilterConfig`. + +#### Scenario: Executing Filtered Tasks +- **WHEN** `TaskRunner.execute` is called with an array of steps and a `filter` in the configuration +- **THEN** the `TaskRunner` SHALL apply the filter to the provided steps. +- **AND** ONLY the filtered subset of steps SHALL be validated and executed. \ No newline at end of file diff --git a/src/TaskRunner.ts b/src/TaskRunner.ts index 63ba44e..c8beda8 100644 --- a/src/TaskRunner.ts +++ b/src/TaskRunner.ts @@ -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; @@ -176,9 +177,11 @@ export class TaskRunner { // Initialize plugins await this.pluginManager.initialize(); + const filteredSteps = config?.filter ? filterTasks(steps, config.filter) : steps; + // Validate the task graph before execution const taskGraph: TaskGraph = { - tasks: steps.map((step) => ({ + tasks: filteredSteps.map((step) => ({ id: step.name, dependencies: step.dependencies ?? [], })), @@ -210,13 +213,13 @@ export class TaskRunner { if (config?.timeout !== undefined) { return this.executeWithTimeout( executor, - steps, + filteredSteps, config.timeout, config.signal ); } - return executor.execute(steps, config?.signal); + return executor.execute(filteredSteps, config?.signal); } /** diff --git a/src/TaskRunnerExecutionConfig.ts b/src/TaskRunnerExecutionConfig.ts index b417ece..041955c 100644 --- a/src/TaskRunnerExecutionConfig.ts +++ b/src/TaskRunnerExecutionConfig.ts @@ -1,3 +1,5 @@ +import { TaskFilterConfig } from "./contracts/TaskFilterConfig.js"; + /** * Configuration options for TaskRunner execution. */ @@ -20,4 +22,8 @@ export interface TaskRunnerExecutionConfig { * If undefined, all ready tasks will be run in parallel. */ concurrency?: number; + /** + * Optional configuration to filter the tasks to execute. + */ + filter?: TaskFilterConfig; } diff --git a/src/TaskStep.ts b/src/TaskStep.ts index 10fed0c..3844589 100644 --- a/src/TaskStep.ts +++ b/src/TaskStep.ts @@ -11,6 +11,8 @@ export interface TaskStep { name: string; /** An optional list of task names that must complete successfully before this step can run. */ dependencies?: string[]; + /** Optional list of tags associated with the task for filtering. */ + tags?: string[]; /** Optional retry configuration for the task. */ retry?: TaskRetryConfig; /** Optional loop configuration for the task. */ diff --git a/src/contracts/TaskFilterConfig.ts b/src/contracts/TaskFilterConfig.ts new file mode 100644 index 0000000..cb398d4 --- /dev/null +++ b/src/contracts/TaskFilterConfig.ts @@ -0,0 +1,26 @@ +/** + * Configuration options for filtering tasks before execution. + */ +export interface TaskFilterConfig { + /** + * Only include tasks that match any of these tags. + */ + includeTags?: string[]; + /** + * Exclude tasks that match any of these tags, overriding inclusions. + */ + excludeTags?: string[]; + /** + * Only include tasks with these specific names. + */ + includeNames?: string[]; + /** + * Exclude tasks with these specific names, overriding inclusions. + */ + excludeNames?: string[]; + /** + * If true (default), automatically include all necessary dependencies + * of the selected tasks, unless those dependencies are explicitly excluded. + */ + includeDependencies?: boolean; +} diff --git a/src/utils/TaskFilter.ts b/src/utils/TaskFilter.ts new file mode 100644 index 0000000..f6ac506 --- /dev/null +++ b/src/utils/TaskFilter.ts @@ -0,0 +1,123 @@ +import { TaskStep } from "../TaskStep.js"; +import { TaskFilterConfig } from "../contracts/TaskFilterConfig.js"; + +/** + * Filters an array of TaskStep objects based on the provided configuration. + * + * @param steps The original list of tasks. + * @param config The filter configuration. + * @returns A new array of filtered tasks. + */ +export function filterTasks( + steps: TaskStep[], + config: TaskFilterConfig +): TaskStep[] { + const { + includeTags, + excludeTags, + includeNames, + excludeNames, + includeDependencies = true, + } = config; + + // Helper sets for quick lookup + const inclTagsSet = includeTags ? new Set(includeTags) : null; + const exclTagsSet = excludeTags ? new Set(excludeTags) : null; + const inclNamesSet = includeNames ? new Set(includeNames) : null; + const exclNamesSet = excludeNames ? new Set(excludeNames) : null; + + // Map of task names to task objects for quick dependency lookup + const taskMap = new Map>(); + for (let i = 0; i < steps.length; i++) { + taskMap.set(steps[i].name, steps[i]); + } + + // 1. Determine initially included tasks + const includedTasks = new Set(); + + for (let i = 0; i < steps.length; i++) { + const task = steps[i]; + let isIncluded = false; + + // If no inclusion criteria provided, include all initially + if (!inclTagsSet && !inclNamesSet) { + isIncluded = true; + } else { + // Check if it matches includeNames + if (inclNamesSet && inclNamesSet.has(task.name)) { + isIncluded = true; + } + // Check if it matches includeTags + if (!isIncluded && inclTagsSet && task.tags) { + for (let j = 0; j < task.tags.length; j++) { + if (inclTagsSet.has(task.tags[j])) { + isIncluded = true; + break; + } + } + } + } + + if (isIncluded) { + includedTasks.add(task.name); + } + } + + // 2. Remove explicitly excluded tasks + const excludedTasks = new Set(); + for (let i = 0; i < steps.length; i++) { + const task = steps[i]; + let isExcluded = false; + + if (exclNamesSet && exclNamesSet.has(task.name)) { + isExcluded = true; + } + + if (!isExcluded && exclTagsSet && task.tags) { + for (let j = 0; j < task.tags.length; j++) { + if (exclTagsSet.has(task.tags[j])) { + isExcluded = true; + break; + } + } + } + + if (isExcluded) { + excludedTasks.add(task.name); + includedTasks.delete(task.name); + } + } + + // 3. Resolve dependencies if needed + if (includeDependencies) { + const queue = Array.from(includedTasks); + let queueIdx = 0; + + while (queueIdx < queue.length) { + const taskName = queue[queueIdx++]; + const task = taskMap.get(taskName); + + if (task && task.dependencies) { + for (let i = 0; i < task.dependencies.length; i++) { + const depName = task.dependencies[i]; + + // If the dependency isn't already included and isn't explicitly excluded + if (!includedTasks.has(depName) && !excludedTasks.has(depName)) { + includedTasks.add(depName); + queue.push(depName); + } + } + } + } + } + + // 4. Return the filtered tasks in the original order + const filteredSteps: TaskStep[] = []; + for (let i = 0; i < steps.length; i++) { + if (includedTasks.has(steps[i].name)) { + filteredSteps.push(steps[i]); + } + } + + return filteredSteps; +} diff --git a/tests/TaskRunnerFiltering.test.ts b/tests/TaskRunnerFiltering.test.ts new file mode 100644 index 0000000..57d168e --- /dev/null +++ b/tests/TaskRunnerFiltering.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from "vitest"; +import { TaskRunner } from "../src/TaskRunner.js"; +import { TaskStep } from "../src/TaskStep.js"; +import { TaskGraphValidationError } from "../src/TaskGraphValidationError.js"; + +describe("TaskRunner Filtering Integration", () => { + it("should execute only filtered tasks and their dependencies", async () => { + const runner = new TaskRunner({}); + + const taskA: TaskStep = { name: "taskA", tags: ["core"], run: vi.fn().mockResolvedValue({ status: "success" }) }; + const taskB: TaskStep = { name: "taskB", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) }; + const taskC: TaskStep = { name: "taskC", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) }; + + const steps = [taskA, taskB, taskC]; + + const results = await runner.execute(steps, { + filter: { includeNames: ["taskB"] } + }); + + expect(results.size).toBe(2); + expect(results.has("taskA")).toBe(true); + expect(results.has("taskB")).toBe(true); + expect(results.has("taskC")).toBe(false); + + expect(taskA.run).toHaveBeenCalled(); + expect(taskB.run).toHaveBeenCalled(); + expect(taskC.run).not.toHaveBeenCalled(); + }); + + it("should fail validation if a required dependency is explicitly excluded", async () => { + const runner = new TaskRunner({}); + + const taskA: TaskStep = { name: "taskA", tags: ["core"], run: vi.fn().mockResolvedValue({ status: "success" }) }; + const taskB: TaskStep = { name: "taskB", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) }; + + const steps = [taskA, taskB]; + + // taskB needs taskA, but taskA is excluded by tag + await expect(runner.execute(steps, { + filter: { includeNames: ["taskB"], excludeTags: ["core"] } + })).rejects.toThrow(TaskGraphValidationError); + }); +}); diff --git a/tests/utils/TaskFilter.test.ts b/tests/utils/TaskFilter.test.ts new file mode 100644 index 0000000..67252e9 --- /dev/null +++ b/tests/utils/TaskFilter.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from "vitest"; +import { filterTasks } from "../../src/utils/TaskFilter.js"; +import { TaskStep } from "../../src/TaskStep.js"; + +describe("filterTasks", () => { + const steps: TaskStep[] = [ + { name: "build", tags: ["backend", "core"], run: vi.fn() }, + { name: "test", dependencies: ["build"], tags: ["backend", "testing"], run: vi.fn() }, + { name: "lint", tags: ["frontend", "core"], run: vi.fn() }, + { name: "deploy", dependencies: ["test", "lint"], tags: ["ops"], run: vi.fn() }, + { name: "isolated", tags: ["frontend"], run: vi.fn() } + ]; + + it("should return all tasks if no filter provided", () => { + const result = filterTasks(steps, {}); + expect(result.length).toBe(5); + }); + + it("should include tasks by tag", () => { + const result = filterTasks(steps, { includeTags: ["backend"] }); + expect(result.map(s => s.name)).toEqual(["build", "test"]); + }); + + it("should include tasks by name", () => { + const result = filterTasks(steps, { includeNames: ["lint"] }); + expect(result.map(s => s.name)).toEqual(["lint"]); + }); + + it("should exclude tasks by tag", () => { + const result = filterTasks(steps, { excludeTags: ["ops"] }); + expect(result.map(s => s.name)).toEqual(["build", "test", "lint", "isolated"]); + }); + + it("should exclude tasks by name", () => { + const result = filterTasks(steps, { excludeNames: ["isolated"] }); + expect(result.map(s => s.name)).toEqual(["build", "test", "lint", "deploy"]); + }); + + it("should handle both include and exclude logic", () => { + const result = filterTasks(steps, { includeTags: ["core"], excludeNames: ["lint"] }); + expect(result.map(s => s.name)).toEqual(["build"]); + }); + + it("should automatically include dependencies if includeDependencies is true", () => { + // 'deploy' depends on 'test' and 'lint', 'test' depends on 'build' + const result = filterTasks(steps, { includeNames: ["deploy"], includeDependencies: true }); + expect(result.map(s => s.name)).toEqual(["build", "test", "lint", "deploy"]); + }); + + it("should not include dependencies if includeDependencies is false", () => { + const result = filterTasks(steps, { includeNames: ["deploy"], includeDependencies: false }); + expect(result.map(s => s.name)).toEqual(["deploy"]); + }); + + it("should exclude dependencies that match exclude rules", () => { + // Even if 'test' brings in 'build', if 'build' is explicitly excluded, it shouldn't be included. + const result = filterTasks(steps, { includeNames: ["deploy"], excludeTags: ["backend"], includeDependencies: true }); + // 'deploy' includes 'test' & 'lint'. 'test' includes 'build'. + // 'test' has tag 'backend' -> excluded. + // 'build' has tag 'backend' -> excluded. + // So only 'lint' and 'deploy' remain. + expect(result.map(s => s.name)).toEqual(["lint", "deploy"]); + }); +});