Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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 .changeset/fix-task-id-serialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"task-master-ai": patch
---

Fix inconsistent task ID serialization in tasks.json (#1583)

Task IDs were inconsistently saved as strings or numbers depending on which command was used (e.g., `set-status` vs `update-task`), causing unnecessary git diffs. The tm-core file storage adapter was incorrectly converting IDs to strings when file storage should use numbers. Task and subtask IDs are now consistently saved as numbers in tasks.json.
10 changes: 6 additions & 4 deletions apps/cli/src/commands/start.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export class StartCommand extends Command {

// Get the task ID from argument or option, or find next available task
const idArg = taskId || options.id || null;
let targetTaskId = idArg;
let targetTaskId: string | number | null = idArg;

if (!targetTaskId) {
spinner = ora('Finding next available task...').start();
Expand Down Expand Up @@ -196,7 +196,7 @@ export class StartCommand extends Command {
/**
* Get the next available task using tm-core
*/
private async performGetNextTask(): Promise<string | null> {
private async performGetNextTask(): Promise<string | number | null> {
if (!this.tmCore) {
throw new Error('TmCore not initialized');
}
Expand All @@ -206,7 +206,9 @@ export class StartCommand extends Command {
/**
* Show pre-launch message using tm-core data
*/
private async showPreLaunchMessage(targetTaskId: string): Promise<void> {
private async showPreLaunchMessage(
targetTaskId: string | number
): Promise<void> {
if (!this.tmCore) return;

const { task, isSubtask } = await this.tmCore.tasks.get(targetTaskId);
Expand All @@ -227,7 +229,7 @@ export class StartCommand extends Command {
* Perform start task using tm-core business logic
*/
private async performStartTask(
targetTaskId: string,
targetTaskId: string | number,
options: StartCommandOptions
): Promise<CoreStartTaskResult> {
if (!this.tmCore) {
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/ui/components/task-detail.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function displaySubtasks(
title: string;
status: any;
description?: string;
dependencies?: string[];
dependencies?: (string | number)[];
}>,
parentTaskId?: string | number,
storageType?: Exclude<StorageType, 'auto'>
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

239 changes: 239 additions & 0 deletions packages/tm-core/src/common/types/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/**
* @fileoverview Tests for core type definitions and type guards
*/

import { describe, it, expect } from 'vitest';
import {
isTaskStatus,
isTaskPriority,
isTaskComplexity,
isValidTaskIdType,
isTask,
isSubtask
} from './index.js';
import type { Task, Subtask, TaskStatus, TaskPriority } from './index.js';

describe('Type Guards', () => {
describe('isTaskStatus', () => {
it('returns true for valid status values', () => {
expect(isTaskStatus('pending')).toBe(true);
expect(isTaskStatus('in-progress')).toBe(true);
expect(isTaskStatus('done')).toBe(true);
expect(isTaskStatus('deferred')).toBe(true);
expect(isTaskStatus('cancelled')).toBe(true);
expect(isTaskStatus('blocked')).toBe(true);
expect(isTaskStatus('review')).toBe(true);
});

it('returns false for invalid values', () => {
expect(isTaskStatus('invalid')).toBe(false);
expect(isTaskStatus('')).toBe(false);
expect(isTaskStatus(123)).toBe(false);
expect(isTaskStatus(null)).toBe(false);
expect(isTaskStatus(undefined)).toBe(false);
});
});

describe('isTaskPriority', () => {
it('returns true for valid priority values', () => {
expect(isTaskPriority('low')).toBe(true);
expect(isTaskPriority('medium')).toBe(true);
expect(isTaskPriority('high')).toBe(true);
expect(isTaskPriority('critical')).toBe(true);
});

it('returns false for invalid values', () => {
expect(isTaskPriority('invalid')).toBe(false);
expect(isTaskPriority('')).toBe(false);
expect(isTaskPriority(1)).toBe(false);
});
});

describe('isTaskComplexity', () => {
it('returns true for valid complexity values', () => {
expect(isTaskComplexity('simple')).toBe(true);
expect(isTaskComplexity('moderate')).toBe(true);
expect(isTaskComplexity('complex')).toBe(true);
expect(isTaskComplexity('very-complex')).toBe(true);
});

it('returns false for invalid values', () => {
expect(isTaskComplexity('invalid')).toBe(false);
expect(isTaskComplexity(5)).toBe(false);
});
});

describe('isValidTaskIdType', () => {
it('returns true for number IDs', () => {
expect(isValidTaskIdType(1)).toBe(true);
expect(isValidTaskIdType(42)).toBe(true);
expect(isValidTaskIdType(0)).toBe(true);
expect(isValidTaskIdType(-1)).toBe(true); // Still a number
});

it('returns true for string IDs', () => {
expect(isValidTaskIdType('1')).toBe(true);
expect(isValidTaskIdType('HAM-123')).toBe(true);
expect(isValidTaskIdType('1.2')).toBe(true);
expect(isValidTaskIdType('')).toBe(true); // Empty string is still a string
});

it('returns false for non-number/string values', () => {
expect(isValidTaskIdType(null)).toBe(false);
expect(isValidTaskIdType(undefined)).toBe(false);
expect(isValidTaskIdType({})).toBe(false);
expect(isValidTaskIdType([])).toBe(false);
});
});

describe('isTask', () => {
const createValidTask = (overrides: Partial<Task> = {}): Task => ({
id: 1,
title: 'Test Task',
description: 'Test description',
status: 'pending' as TaskStatus,
priority: 'medium' as TaskPriority,
dependencies: [],
details: 'Test details',
testStrategy: 'Test strategy',
subtasks: [],
...overrides
});

it('returns true for valid task with numeric ID', () => {
const task = createValidTask({ id: 1 });
expect(isTask(task)).toBe(true);
});

it('returns true for valid task with string ID', () => {
const task = createValidTask({ id: 'HAM-123' });
expect(isTask(task)).toBe(true);
});

it('returns true for valid task with string numeric ID', () => {
const task = createValidTask({ id: '42' });
expect(isTask(task)).toBe(true);
});

it('returns false for null/undefined', () => {
expect(isTask(null)).toBe(false);
expect(isTask(undefined)).toBe(false);
});

it('returns false for non-object', () => {
expect(isTask('string')).toBe(false);
expect(isTask(123)).toBe(false);
});

it('returns false for invalid id type', () => {
const task = createValidTask();
(task as any).id = {}; // Invalid type
expect(isTask(task)).toBe(false);
});

it('returns false for missing required fields', () => {
const task = createValidTask();
delete (task as any).title;
expect(isTask(task)).toBe(false);
});

it('returns false for invalid status', () => {
const task = createValidTask();
(task as any).status = 'invalid-status';
expect(isTask(task)).toBe(false);
});

it('returns false for invalid priority', () => {
const task = createValidTask();
(task as any).priority = 'invalid-priority';
expect(isTask(task)).toBe(false);
});

it('returns false for non-array dependencies', () => {
const task = createValidTask();
(task as any).dependencies = 'not-array';
expect(isTask(task)).toBe(false);
});

it('returns false for non-array subtasks', () => {
const task = createValidTask();
(task as any).subtasks = 'not-array';
expect(isTask(task)).toBe(false);
});
});

describe('isSubtask', () => {
const createValidSubtask = (overrides: Partial<Subtask> = {}): Subtask => ({
id: 1,
parentId: 5,
title: 'Test Subtask',
description: 'Test description',
status: 'pending' as TaskStatus,
priority: 'medium' as TaskPriority,
dependencies: [],
details: 'Test details',
testStrategy: 'Test strategy',
...overrides
});

it('returns true for valid subtask with numeric ID and parentId', () => {
const subtask = createValidSubtask({ id: 1, parentId: 5 });
expect(isSubtask(subtask)).toBe(true);
});

it('returns true for valid subtask with string ID and parentId', () => {
const subtask = createValidSubtask({ id: '1', parentId: '5' });
expect(isSubtask(subtask)).toBe(true);
});

it('returns true for valid subtask with mixed ID types', () => {
// Numeric ID, string parentId
const subtask1 = createValidSubtask({ id: 1, parentId: '5' });
expect(isSubtask(subtask1)).toBe(true);

// String ID, numeric parentId
const subtask2 = createValidSubtask({ id: '1', parentId: 5 });
expect(isSubtask(subtask2)).toBe(true);
});

it('returns false for null/undefined', () => {
expect(isSubtask(null)).toBe(false);
expect(isSubtask(undefined)).toBe(false);
});

it('returns false for non-object', () => {
expect(isSubtask('string')).toBe(false);
expect(isSubtask(123)).toBe(false);
});

it('returns false for invalid id type', () => {
const subtask = createValidSubtask();
(subtask as any).id = {}; // Invalid type
expect(isSubtask(subtask)).toBe(false);
});

it('returns false for invalid parentId type', () => {
const subtask = createValidSubtask();
(subtask as any).parentId = {}; // Invalid type
expect(isSubtask(subtask)).toBe(false);
});

it('returns false if subtasks property exists', () => {
const subtask = createValidSubtask();
(subtask as any).subtasks = []; // Subtasks cannot have subtasks
expect(isSubtask(subtask)).toBe(false);
});

it('returns false for invalid status', () => {
const subtask = createValidSubtask();
(subtask as any).status = 'invalid-status';
expect(isSubtask(subtask)).toBe(false);
});

it('returns false for invalid priority', () => {
const subtask = createValidSubtask();
(subtask as any).priority = 'invalid-priority';
expect(isSubtask(subtask)).toBe(false);
});
});
});
25 changes: 17 additions & 8 deletions packages/tm-core/src/common/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export interface TaskImplementationMetadata {
* Placeholder task interface for temporary/minimal task objects
*/
export interface PlaceholderTask {
id: string;
id: number | string;
title: string;
status: TaskStatus;
priority: TaskPriority;
Expand All @@ -129,12 +129,12 @@ export interface PlaceholderTask {
* Base task interface
*/
export interface Task extends TaskImplementationMetadata {
id: string;
id: number | string;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
dependencies: string[];
dependencies: (number | string)[];
details: string;
testStrategy: string;
subtasks: Subtask[];
Expand Down Expand Up @@ -171,7 +171,7 @@ export interface Task extends TaskImplementationMetadata {
*/
export interface Subtask extends Omit<Task, 'id' | 'subtasks'> {
id: number | string;
parentId: string;
parentId: number | string;
subtasks?: never; // Subtasks cannot have their own subtasks
}

Expand Down Expand Up @@ -203,7 +203,7 @@ export interface TaskCollection {
*/
export interface TaskTag {
name: string;
tasks: string[]; // Task IDs belonging to this tag
tasks: (number | string)[]; // Task IDs belonging to this tag
metadata: Record<string, any>;
}

Expand Down Expand Up @@ -290,6 +290,15 @@ export function isTaskComplexity(value: unknown): value is TaskComplexity {
);
}

/**
* Type guard to check if a value is a valid task ID type (number or string)
* Note: This checks the TYPE of the ID, not the format. For format validation,
* use isValidTaskIdFormat from tasks/validation/task-id.ts
*/
export function isValidTaskIdType(value: unknown): value is number | string {
return typeof value === 'number' || typeof value === 'string';
}

/**
* Type guard to check if an object is a Task
*/
Expand All @@ -298,7 +307,7 @@ export function isTask(obj: unknown): obj is Task {
const task = obj as Record<string, unknown>;

return (
typeof task.id === 'string' &&
isValidTaskIdType(task.id) &&
typeof task.title === 'string' &&
typeof task.description === 'string' &&
isTaskStatus(task.status) &&
Expand All @@ -318,8 +327,8 @@ export function isSubtask(obj: unknown): obj is Subtask {
const subtask = obj as Record<string, unknown>;

return (
typeof subtask.id === 'number' &&
typeof subtask.parentId === 'string' &&
isValidTaskIdType(subtask.id) &&
isValidTaskIdType(subtask.parentId) &&
typeof subtask.title === 'string' &&
typeof subtask.description === 'string' &&
isTaskStatus(subtask.status) &&
Expand Down
4 changes: 2 additions & 2 deletions packages/tm-core/src/common/types/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
*/

/**
* @deprecated Use string directly instead. This will be removed in a future version.
* @deprecated Use number | string directly instead. This will be removed in a future version.
*/
export type TaskId = string;
export type TaskId = number | string;
Loading