Skip to content
Open
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
@@ -0,0 +1,28 @@
# Change: Add tasks.md Validation

## Why

AI agents generate OpenSpec change proposals including `tasks.md` as implementation checklists. Currently, `openspec validate` checks `proposal.md` and spec deltas, but does not validate `tasks.md`. This can lead to:
- Missing or empty tasks.md files
- Tasks without checkboxes (making progress tracking impossible)
- Empty task descriptions that provide no implementation guidance

Adding basic tasks.md validation ensures AI-generated proposals include properly formatted implementation checklists before approval.

## What Changes

- Add tasks.md validation to the `Validator` class
- Validate tasks.md when running `openspec validate <change-id>`
- Check three essential requirements:
1. File exists
2. At least one checkboxed task is present
3. No empty task descriptions
- Use the same checkbox pattern as existing `task-progress.ts` (`/^[-*]\s+\[[xX\s]\]/`)
- Report validation errors with line numbers for easy fixing

## Impact

- **Affected specs**: cli-validate
- **Affected code**: `src/core/validation/validator.ts`, `src/commands/validate.ts`
- **Breaking changes**: None (only adds new validation checks)
- **Backward compatibility**: Existing changes may fail validation if tasks.md is missing or improperly formatted
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# cli-validate Spec Delta

## ADDED Requirements

### Requirement: Validator SHALL validate tasks.md format in changes

When validating changes, the validator SHALL check that tasks.md exists, contains at least one checkboxed task, and has no empty task descriptions.

#### Scenario: Missing tasks.md file

- **WHEN** validating a change that does not have tasks.md
- **THEN** report ERROR with message "tasks.md is required for OpenSpec changes"
- **AND** include path "tasks.md" in the issue

#### Scenario: No checkboxed tasks found

- **WHEN** validating a tasks.md file with no items matching pattern `/^[-*]\s+\[[xX\s]\]/`
- **THEN** report ERROR with message "tasks.md must contain at least one checkboxed task"
- **AND** include path "tasks.md" in the issue

#### Scenario: Empty task description detected

- **WHEN** validating a tasks.md file containing a line matching `/^[-*]\s+\[[xX\s]\]\s*$/`
- **THEN** report ERROR with message "Empty task description"
- **AND** include path "tasks.md:N" where N is the line number (1-indexed)

#### Scenario: Valid tasks.md with checkboxed tasks

- **WHEN** validating a tasks.md file with one or more properly formatted checkboxed tasks
- **THEN** report no errors for tasks.md
- **AND** include tasks.md in the validation summary

#### Scenario: Tasks.md validation integrated with change validation

- **WHEN** running `openspec validate <change-id>`
- **THEN** validate proposal.md, spec deltas, and tasks.md
- **AND** display results for all validated files
- **AND** exit with code 1 if any validation fails

### Requirement: Checkbox pattern SHALL match task-progress utility

The tasks.md validator SHALL use the same checkbox detection pattern as `src/utils/task-progress.ts` to ensure consistency across the codebase.

#### Scenario: Checkbox pattern consistency

- **WHEN** detecting checkboxed tasks in tasks.md
- **THEN** use pattern `/^[-*]\s+\[[xX\s]\]/` for checkbox detection
- **AND** accept both lowercase and uppercase X for completed tasks
- **AND** accept both `-` and `*` as list markers
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Tasks: Add tasks.md Validation

## 1. Add Validation Method to Validator Class

- [x] 1.1 Add `validateTasksFile(changeDir: string): Promise<ValidationReport>` method to Validator class
- [x] 1.2 Implement file existence check (ERROR if missing)
- [x] 1.3 Add `validateTasksContent(content: string): ValidationIssue[]` private method
- [x] 1.4 Implement checkbox task detection using pattern `/^[-*]\s+\[[xX\s]\]/`
- [x] 1.5 Check for at least one checkboxed task (ERROR if none found)
- [x] 1.6 Detect empty task descriptions with pattern `/^[-*]\s+\[[xX\s]\]\s*$/` (ERROR)
- [x] 1.7 Include line numbers in error messages for empty tasks

## 2. Integrate with Validate Command

- [x] 2.1 Update `validateDirectItem()` in ValidateCommand to call tasks.md validation for changes
- [x] 2.2 Update `validateByType()` to include tasks.md validation when type is 'change'
- [x] 2.3 Update `runBulkValidation()` to validate tasks.md for each change
- [x] 2.4 Ensure tasks.md validation results are displayed alongside proposal.md and spec results

## 3. Testing

- [x] 3.1 Add unit tests for `validateTasksContent()` with various checkbox formats
- [x] 3.2 Test error case: tasks.md file missing
- [x] 3.3 Test error case: no checkboxed tasks found
- [x] 3.4 Test error case: empty task descriptions
- [x] 3.5 Test valid case: properly formatted tasks.md with multiple tasks
- [x] 3.6 Test pattern compatibility with existing task-progress.ts regex
- [x] 3.7 Add integration test for `openspec validate <change>` including tasks.md

## 4. Documentation

- [x] 4.1 Update error messages to be clear and actionable
- [x] 4.2 Ensure validation output shows tasks.md status alongside other files
46 changes: 46 additions & 0 deletions openspec/specs/cli-validate/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,49 @@ The markdown parser SHALL correctly identify sections regardless of line ending
- **WHEN** running `openspec validate <change-id>`
- **THEN** validation SHALL recognize the sections and NOT raise parsing errors

### Requirement: Validator SHALL validate tasks.md format in changes

When validating changes, the validator SHALL check that tasks.md exists, contains at least one checkboxed task, and has no empty task descriptions.

#### Scenario: Missing tasks.md file

- **WHEN** validating a change that does not have tasks.md
- **THEN** report ERROR with message "tasks.md is required for OpenSpec changes"
- **AND** include path "tasks.md" in the issue

#### Scenario: No checkboxed tasks found

- **WHEN** validating a tasks.md file with no items matching pattern `/^[-*]\s+\[[\sx]\]/i`
- **THEN** report ERROR with message "tasks.md must contain at least one checkboxed task"
- **AND** include path "tasks.md" in the issue

#### Scenario: Empty task description detected

- **WHEN** validating a tasks.md file containing a line matching `/^[-*]\s+\[[xX\s]\]\s*$/`
- **THEN** report ERROR with message "Empty task description"
- **AND** include path "tasks.md:N" where N is the line number (1-indexed)

#### Scenario: Valid tasks.md with checkboxed tasks

- **WHEN** validating a tasks.md file with one or more properly formatted checkboxed tasks
- **THEN** report no errors for tasks.md
- **AND** include tasks.md in the validation summary

#### Scenario: Tasks.md validation integrated with change validation

- **WHEN** running `openspec validate <change-id>`
- **THEN** validate proposal.md, spec deltas, and tasks.md
- **AND** display results for all validated files
- **AND** exit with code 1 if any validation fails

### Requirement: Checkbox pattern SHALL match task-progress utility

The tasks.md validator SHALL use the same checkbox detection pattern as `src/utils/task-progress.ts` to ensure consistency across the codebase.

#### Scenario: Checkbox pattern consistency

- **WHEN** detecting checkboxed tasks in tasks.md
- **THEN** use pattern `/^[-*]\s+\[[xX\s]\]/` for checkbox detection
- **AND** accept both lowercase and uppercase X for completed tasks
- **AND** accept both `-` and `*` as list markers

21 changes: 19 additions & 2 deletions src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,29 @@ export class ValidateCommand {
await this.validateByType(type, itemName, opts);
}

private async validateChangeReports(changeDir: string, validator: Validator) {
const [deltaReport, tasksReport] = await Promise.all([
validator.validateChangeDeltaSpecs(changeDir),
validator.validateTasksFile(changeDir),
]);

return {
valid: deltaReport.valid && tasksReport.valid,
issues: [...deltaReport.issues, ...tasksReport.issues],
summary: {
errors: deltaReport.summary.errors + tasksReport.summary.errors,
warnings: deltaReport.summary.warnings + tasksReport.summary.warnings,
info: deltaReport.summary.info + tasksReport.summary.info,
},
};
}

private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise<void> {
const validator = new Validator(opts.strict);
if (type === 'change') {
const changeDir = path.join(process.cwd(), 'openspec', 'changes', id);
const start = Date.now();
const report = await validator.validateChangeDeltaSpecs(changeDir);
const report = await this.validateChangeReports(changeDir, validator);
const durationMs = Date.now() - start;
this.printReport('change', id, report, durationMs, opts.json);
// Non-zero exit if invalid (keeps enriched output test semantics)
Expand Down Expand Up @@ -198,7 +215,7 @@ export class ValidateCommand {
queue.push(async () => {
const start = Date.now();
const changeDir = path.join(process.cwd(), 'openspec', 'changes', id);
const report = await validator.validateChangeDeltaSpecs(changeDir);
const report = await this.validateChangeReports(changeDir, validator);
const durationMs = Date.now() - start;
return { id, type: 'change' as const, valid: report.valid, issues: report.issues, durationMs };
});
Expand Down
61 changes: 61 additions & 0 deletions src/core/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
} from './constants.js';
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TASK_PATTERN } from '../../utils/task-progress.js';

const TASK_CHECKBOX_PATTERN = TASK_PATTERN;
const EMPTY_TASK_PATTERN = /^[-*]\s+\[[xX\s]\]\s*$/;

export class Validator {
private strictMode: boolean;
Expand Down Expand Up @@ -272,6 +276,63 @@ export class Validator {
return this.createReport(issues);
}

async validateTasksFile(changeDir: string): Promise<ValidationReport> {
const issues: ValidationIssue[] = [];
const tasksPath = path.join(changeDir, 'tasks.md');
try {
const content = await fs.readFile(tasksPath, 'utf-8');
issues.push(...this.validateTasksContent(content));
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err?.code === 'ENOENT') {
issues.push({
level: 'ERROR',
path: 'tasks.md',
message: 'tasks.md is required for OpenSpec changes',
});
} else {
const baseMessage = err instanceof Error ? err.message : 'Unknown error';
issues.push({
level: 'ERROR',
path: 'tasks.md',
message: `Unable to read tasks.md: ${baseMessage}`,
});
}
}
return this.createReport(issues);
}

private validateTasksContent(content: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const lines = content.split(/\r?\n/);
let hasCheckbox = false;

lines.forEach((line, index) => {
if (TASK_CHECKBOX_PATTERN.test(line)) {
hasCheckbox = true;
if (EMPTY_TASK_PATTERN.test(line)) {
const lineNumber = index + 1;
issues.push({
level: 'ERROR',
path: `tasks.md:${lineNumber}`,
message: 'Empty task description',
line: lineNumber,
});
}
}
});

if (!hasCheckbox) {
issues.push({
level: 'ERROR',
path: 'tasks.md',
message: 'tasks.md must contain at least one checkboxed task',
});
}

return issues;
}
Comment on lines +305 to +334
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 25, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's find where TASK_CHECKBOX_PATTERN and EMPTY_TASK_PATTERN are defined
rg -n 'TASK_CHECKBOX_PATTERN|EMPTY_TASK_PATTERN' src/core/validation/validator.ts -A 1 -B 1

Repository: Fission-AI/OpenSpec

Length of output: 392


🏁 Script executed:

#!/bin/bash
# Check if any tasks.md files exist and contain nested checkboxes
find . -name 'tasks.md' -type f | head -20

Repository: Fission-AI/OpenSpec

Length of output: 1443


🏁 Script executed:

#!/bin/bash
# Search for nested checkbox patterns in the codebase
rg -n '^\s+[-*]\s+\[' --glob '*.md' | head -50

Repository: Fission-AI/OpenSpec

Length of output: 7348


🏁 Script executed:

#!/bin/bash
# Look for test files related to validator
fd -e test.ts -e spec.ts -e test.js validator

Repository: Fission-AI/OpenSpec

Length of output: 45


🏁 Script executed:

#!/bin/bash
# Find TASK_PATTERN definition
rg -n 'const TASK_PATTERN' src/core/validation/validator.ts -A 1 -B 1

Repository: Fission-AI/OpenSpec

Length of output: 45


🏁 Script executed:

#!/bin/bash
# Search for test files related to validator in the codebase
fd -type f -name '*validator*' | grep -E '\.(test|spec)\.'

Repository: Fission-AI/OpenSpec

Length of output: 233


🏁 Script executed:

#!/bin/bash
# Check if there are any tests that validate nested tasks
rg -l 'validateTasksContent|TASK_CHECKBOX_PATTERN|nested.*task' --glob '*.test.ts' --glob '*.spec.ts'

Repository: Fission-AI/OpenSpec

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Get more context around TASK_PATTERN definition in validator.ts
head -25 src/core/validation/validator.ts

Repository: Fission-AI/OpenSpec

Length of output: 1020


🏁 Script executed:

#!/bin/bash
# Check test file for nested task test cases
cat test/core/validation.test.ts

Repository: Fission-AI/OpenSpec

Length of output: 17346


🏁 Script executed:

#!/bin/bash
# Find the definition of TASK_PATTERN in task-progress.js
find . -name 'task-progress.js' -o -name 'task-progress.ts' | head -5

Repository: Fission-AI/OpenSpec

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Get TASK_PATTERN definition
rg -n 'TASK_PATTERN' src/utils/ -B 2 -A 2

Repository: Fission-AI/OpenSpec

Length of output: 793


Support nested checkbox tasks in validation patterns.

The current patterns (/^[-*]\s+\[[\sx]\]/i and /^[-*]\s+\[[xX\s]\]\s*$/) only match checkboxes at the line start. Nested tasks with leading whitespace (e.g., - [ ] Subtask) are actively used in the repository (e.g., openspec/changes/archive/2025-08-13-add-archive-command/tasks.md) but will not be detected or validated.

Update both patterns to allow optional leading whitespace:

Suggested changes
-export const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
-export const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
+export const TASK_PATTERN = /^\s*[-*]\s+\[[\sx]\]/i;
+export const COMPLETED_TASK_PATTERN = /^\s*[-*]\s+\[x\]/i;

And in validator.ts:

-const EMPTY_TASK_PATTERN = /^[-*]\s+\[[xX\s]\]\s*$/;
+const EMPTY_TASK_PATTERN = /^\s*[-*]\s+\[[xX\s]\]\s*$/;
🤖 Prompt for AI Agents
In `@src/core/validation/validator.ts` around lines 305 - 334, The checkbox
regexes in validateTasksContent do not allow leading whitespace so nested tasks
like "  - [ ] Subtask" are missed; update the TASK_CHECKBOX_PATTERN and
EMPTY_TASK_PATTERN to accept optional leading whitespace (e.g., prepend \s* to
the existing patterns) so validateTasksContent correctly detects and validates
indented checkbox lines (refer to TASK_CHECKBOX_PATTERN, EMPTY_TASK_PATTERN and
the validateTasksContent method).

Copy link
Author

Choose a reason for hiding this comment

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

Since this regular expression is already used in existing code, the change will be put on hold for now. 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

@atman-33, understood! Since TASK_PATTERN is shared across the codebase (including task-progress.ts), changing it would have broader implications. It makes sense to evaluate the impact on all consumers before making that adjustment.

Feel free to revisit this when you're ready to handle nested tasks uniformly across the validation and progress tracking features.


private convertZodErrors(error: ZodError): ValidationIssue[] {
return error.issues.map(err => {
let message = err.message;
Expand Down
4 changes: 2 additions & 2 deletions src/utils/task-progress.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { promises as fs } from 'fs';
import path from 'path';

const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
export const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
export const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;

export interface TaskProgress {
total: number;
Expand Down
29 changes: 29 additions & 0 deletions test/commands/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ describe('top-level validate command', () => {
const changeContent = `# Test Change\n\n## Why\nBecause reasons that are sufficiently long for validation.\n\n## What Changes\n- **alpha:** Add something`;
await fs.mkdir(path.join(changesDir, 'c1'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'c1', 'proposal.md'), changeContent, 'utf-8');
const tasksContent = ['# Tasks', '', '- [ ] Initial task'].join('\n');
await fs.writeFile(path.join(changesDir, 'c1', 'tasks.md'), tasksContent, 'utf-8');
const deltaContent = [
'## ADDED Requirements',
'### Requirement: Validator SHALL support alpha change deltas',
Expand All @@ -52,6 +54,7 @@ describe('top-level validate command', () => {
// Duplicate name for ambiguity test
await fs.mkdir(path.join(changesDir, 'dup'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'dup', 'proposal.md'), changeContent, 'utf-8');
await fs.writeFile(path.join(changesDir, 'dup', 'tasks.md'), tasksContent, 'utf-8');
const dupDeltaDir = path.join(changesDir, 'dup', 'specs', 'dup');
await fs.mkdir(dupDeltaDir, { recursive: true });
await fs.writeFile(path.join(dupDeltaDir, 'spec.md'), deltaContent, 'utf-8');
Expand Down Expand Up @@ -111,6 +114,7 @@ describe('top-level validate command', () => {

await fs.mkdir(path.join(changesDir, changeId), { recursive: true });
await fs.writeFile(path.join(changesDir, changeId, 'proposal.md'), crlfContent, 'utf-8');
await fs.writeFile(path.join(changesDir, changeId, 'tasks.md'), toCrlf(['# Tasks', '', '- [ ] Verify CRLF']), 'utf-8');

const deltaContent = toCrlf([
'## ADDED Requirements',
Expand All @@ -131,6 +135,31 @@ describe('top-level validate command', () => {
expect(result.exitCode).toBe(0);
});

it('fails validation when tasks.md is missing', async () => {
const changeId = 'missing-tasks';
const changeContent = `# Missing Tasks\n\n## Why\nThis change intentionally lacks tasks.md for validation coverage.\n\n## What Changes\n- **alpha:** Add missing tasks case`;
await fs.mkdir(path.join(changesDir, changeId), { recursive: true });
await fs.writeFile(path.join(changesDir, changeId, 'proposal.md'), changeContent, 'utf-8');

const deltaContent = [
'## ADDED Requirements',
'### Requirement: Validator SHALL flag missing tasks',
'The validator SHALL error when tasks.md is missing.',
'',
'#### Scenario: Missing tasks.md',
'- **GIVEN** a change without tasks.md',
'- **WHEN** validate runs',
'- **THEN** an error is reported',
].join('\n');
const deltaDir = path.join(changesDir, changeId, 'specs', 'alpha');
await fs.mkdir(deltaDir, { recursive: true });
await fs.writeFile(path.join(deltaDir, 'spec.md'), deltaContent, 'utf-8');

const result = await runCLI(['validate', changeId], { cwd: testDir });
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('tasks.md is required for OpenSpec changes');
});

it('respects --no-interactive flag passed via CLI', async () => {
// This test ensures Commander.js --no-interactive flag is correctly parsed
// and passed to the validate command. The flag sets options.interactive = false
Expand Down
Loading