fix: normalize task IDs to numbers on load to fix comparison issues

When tasks.json contains string IDs (e.g., "5" instead of 5), task lookups
fail because the code uses parseInt() and strict equality (===) for comparisons.

This fix normalizes all task and subtask IDs to numbers when loading the JSON,
ensuring consistent comparisons throughout the codebase without requiring
changes to multiple comparison locations.

Fixes task not found errors when using string IDs in tasks.json.
This commit is contained in:
Carl Mercier
2025-07-25 23:59:55 -05:00
committed by Ralph Khreish
parent 45a14c323d
commit b79eb4f7a2

View File

@@ -3,18 +3,18 @@
* Utility functions for the Task Master CLI * Utility functions for the Task Master CLI
*/ */
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import chalk from 'chalk'; import chalk from "chalk";
import dotenv from 'dotenv'; import dotenv from "dotenv";
// Import specific config getters needed here // Import specific config getters needed here
import { getLogLevel, getDebugFlag } from './config-manager.js'; import { getLogLevel, getDebugFlag } from "./config-manager.js";
import * as gitUtils from './utils/git-utils.js'; import * as gitUtils from "./utils/git-utils.js";
import { import {
COMPLEXITY_REPORT_FILE, COMPLEXITY_REPORT_FILE,
LEGACY_COMPLEXITY_REPORT_FILE, LEGACY_COMPLEXITY_REPORT_FILE,
LEGACY_CONFIG_FILE LEGACY_CONFIG_FILE,
} from '../../src/constants/paths.js'; } from "../../src/constants/paths.js";
// Global silent mode flag // Global silent mode flag
let silentMode = false; let silentMode = false;
@@ -39,10 +39,10 @@ function resolveEnvVariable(key, session = null, projectRoot = null) {
// 2. Read .env file at projectRoot // 2. Read .env file at projectRoot
if (projectRoot) { if (projectRoot) {
const envPath = path.join(projectRoot, '.env'); const envPath = path.join(projectRoot, ".env");
if (fs.existsSync(envPath)) { if (fs.existsSync(envPath)) {
try { try {
const envFileContent = fs.readFileSync(envPath, 'utf-8'); const envFileContent = fs.readFileSync(envPath, "utf-8");
const parsedEnv = dotenv.parse(envFileContent); // Use dotenv to parse const parsedEnv = dotenv.parse(envFileContent); // Use dotenv to parse
if (parsedEnv && parsedEnv[key]) { if (parsedEnv && parsedEnv[key]) {
// console.log(`DEBUG: Found key ${key} in ${envPath}`); // Optional debug log // console.log(`DEBUG: Found key ${key} in ${envPath}`); // Optional debug log
@@ -50,7 +50,7 @@ function resolveEnvVariable(key, session = null, projectRoot = null) {
} }
} catch (error) { } catch (error) {
// Log error but don't crash, just proceed as if key wasn't found in file // Log error but don't crash, just proceed as if key wasn't found in file
log('warn', `Could not read or parse ${envPath}: ${error.message}`); log("warn", `Could not read or parse ${envPath}: ${error.message}`);
} }
} }
} }
@@ -72,15 +72,15 @@ function resolveEnvVariable(key, session = null, projectRoot = null) {
* @returns {string} Slugified tag name safe for filesystem use * @returns {string} Slugified tag name safe for filesystem use
*/ */
function slugifyTagForFilePath(tagName) { function slugifyTagForFilePath(tagName) {
if (!tagName || typeof tagName !== 'string') { if (!tagName || typeof tagName !== "string") {
return 'unknown-tag'; return "unknown-tag";
} }
// Replace invalid filesystem characters with hyphens and clean up // Replace invalid filesystem characters with hyphens and clean up
return tagName return tagName
.replace(/[^a-zA-Z0-9_-]/g, '-') // Replace invalid chars with hyphens .replace(/[^a-zA-Z0-9_-]/g, "-") // Replace invalid chars with hyphens
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens .replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens .replace(/-+/g, "-") // Collapse multiple hyphens
.toLowerCase() // Convert to lowercase .toLowerCase() // Convert to lowercase
.substring(0, 50); // Limit length to prevent overly long filenames .substring(0, 50); // Limit length to prevent overly long filenames
} }
@@ -93,10 +93,10 @@ function slugifyTagForFilePath(tagName) {
* @param {string} [projectRoot='.'] - The project root directory * @param {string} [projectRoot='.'] - The project root directory
* @returns {string} The resolved file path * @returns {string} The resolved file path
*/ */
function getTagAwareFilePath(basePath, tag, projectRoot = '.') { function getTagAwareFilePath(basePath, tag, projectRoot = ".") {
// Use path.parse and format for clean tag insertion // Use path.parse and format for clean tag insertion
const parsedPath = path.parse(basePath); const parsedPath = path.parse(basePath);
if (!tag || tag === 'master') { if (!tag || tag === "master") {
return path.join(projectRoot, basePath); return path.join(projectRoot, basePath);
} }
@@ -118,7 +118,7 @@ function getTagAwareFilePath(basePath, tag, projectRoot = '.') {
*/ */
function findProjectRoot( function findProjectRoot(
startDir = process.cwd(), startDir = process.cwd(),
markers = ['package.json', 'pyproject.toml', '.git', LEGACY_CONFIG_FILE] markers = ["package.json", "pyproject.toml", ".git", LEGACY_CONFIG_FILE],
) { ) {
let currentPath = path.resolve(startDir); let currentPath = path.resolve(startDir);
const rootPath = path.parse(currentPath).root; const rootPath = path.parse(currentPath).root;
@@ -157,7 +157,7 @@ const LOG_LEVELS = {
info: 1, info: 1,
warn: 2, warn: 2,
error: 3, error: 3,
success: 1 // Treat success like info level success: 1, // Treat success like info level
}; };
/** /**
@@ -165,7 +165,7 @@ const LOG_LEVELS = {
* @returns {Promise<Object>} The task manager module object * @returns {Promise<Object>} The task manager module object
*/ */
async function getTaskManager() { async function getTaskManager() {
return import('./task-manager.js'); return import("./task-manager.js");
} }
/** /**
@@ -203,49 +203,73 @@ function log(level, ...args) {
// GUARD: Prevent circular dependency during config loading // GUARD: Prevent circular dependency during config loading
// Use a simple fallback log level instead of calling getLogLevel() // Use a simple fallback log level instead of calling getLogLevel()
let configLevel = 'info'; // Default fallback let configLevel = "info"; // Default fallback
try { try {
// Only try to get config level if we're not in the middle of config loading // Only try to get config level if we're not in the middle of config loading
configLevel = getLogLevel() || 'info'; configLevel = getLogLevel() || "info";
} catch (error) { } catch (error) {
// If getLogLevel() fails (likely due to circular dependency), // If getLogLevel() fails (likely due to circular dependency),
// use default 'info' level and continue // use default 'info' level and continue
configLevel = 'info'; configLevel = "info";
} }
// Use text prefixes instead of emojis // Use text prefixes instead of emojis
const prefixes = { const prefixes = {
debug: chalk.gray('[DEBUG]'), debug: chalk.gray("[DEBUG]"),
info: chalk.blue('[INFO]'), info: chalk.blue("[INFO]"),
warn: chalk.yellow('[WARN]'), warn: chalk.yellow("[WARN]"),
error: chalk.red('[ERROR]'), error: chalk.red("[ERROR]"),
success: chalk.green('[SUCCESS]') success: chalk.green("[SUCCESS]"),
}; };
// Ensure level exists, default to info if not // Ensure level exists, default to info if not
const currentLevel = LOG_LEVELS.hasOwnProperty(level) ? level : 'info'; const currentLevel = LOG_LEVELS.hasOwnProperty(level) ? level : "info";
// Check log level configuration // Check log level configuration
if ( if (
LOG_LEVELS[currentLevel] >= (LOG_LEVELS[configLevel] ?? LOG_LEVELS.info) LOG_LEVELS[currentLevel] >= (LOG_LEVELS[configLevel] ?? LOG_LEVELS.info)
) { ) {
const prefix = prefixes[currentLevel] || ''; const prefix = prefixes[currentLevel] || "";
// Use console.log for all levels, let chalk handle coloring // Use console.log for all levels, let chalk handle coloring
// Construct the message properly // Construct the message properly
const message = args const message = args
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : arg)) .map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : arg))
.join(' '); .join(" ");
console.log(`${prefix} ${message}`); console.log(`${prefix} ${message}`);
} }
} }
/**
* Normalizes task and subtask IDs to numbers to ensure consistent comparisons for Integers and Strings
* @param {Array} tasks - The tasks array to normalize
*/
function normalizeTaskIds(tasks) {
if (!Array.isArray(tasks)) return;
tasks.forEach((task) => {
// Convert task ID to number
if (task.id !== undefined) {
task.id = parseInt(task.id, 10);
}
// Convert subtask IDs to numbers
if (Array.isArray(task.subtasks)) {
task.subtasks.forEach((subtask) => {
if (subtask.id !== undefined) {
subtask.id = parseInt(subtask.id, 10);
}
});
}
});
}
/** /**
* Checks if the data object has a tagged structure (contains tag objects with tasks arrays) * Checks if the data object has a tagged structure (contains tag objects with tasks arrays)
* @param {Object} data - The data object to check * @param {Object} data - The data object to check
* @returns {boolean} True if the data has a tagged structure * @returns {boolean} True if the data has a tagged structure
*/ */
function hasTaggedStructure(data) { function hasTaggedStructure(data) {
if (!data || typeof data !== 'object') { if (!data || typeof data !== "object") {
return false; return false;
} }
@@ -253,7 +277,7 @@ function hasTaggedStructure(data) {
for (const key in data) { for (const key in data) {
if ( if (
data.hasOwnProperty(key) && data.hasOwnProperty(key) &&
typeof data[key] === 'object' && typeof data[key] === "object" &&
Array.isArray(data[key].tasks) Array.isArray(data[key].tasks)
) { ) {
return true; return true;
@@ -282,7 +306,7 @@ function readJSON(filepath, projectRoot = null, tag = null) {
if (isDebug) { if (isDebug) {
console.log( console.log(
`readJSON called with: ${filepath}, projectRoot: ${projectRoot}, tag: ${tag}` `readJSON called with: ${filepath}, projectRoot: ${projectRoot}, tag: ${tag}`,
); );
} }
@@ -292,7 +316,7 @@ function readJSON(filepath, projectRoot = null, tag = null) {
let data; let data;
try { try {
data = JSON.parse(fs.readFileSync(filepath, 'utf8')); data = JSON.parse(fs.readFileSync(filepath, "utf8"));
if (isDebug) { if (isDebug) {
console.log(`Successfully read JSON from ${filepath}`); console.log(`Successfully read JSON from ${filepath}`);
} }
@@ -304,7 +328,7 @@ function readJSON(filepath, projectRoot = null, tag = null) {
} }
// If it's not a tasks.json file, return as-is // If it's not a tasks.json file, return as-is
if (!filepath.includes('tasks.json') || !data) { if (!filepath.includes("tasks.json") || !data) {
if (isDebug) { if (isDebug) {
console.log(`File is not tasks.json or data is null, returning as-is`); console.log(`File is not tasks.json or data is null, returning as-is`);
} }
@@ -329,9 +353,9 @@ function readJSON(filepath, projectRoot = null, tag = null) {
metadata: data.metadata || { metadata: data.metadata || {
created: new Date().toISOString(), created: new Date().toISOString(),
updated: new Date().toISOString(), updated: new Date().toISOString(),
description: 'Tasks for master context' description: "Tasks for master context",
} },
} },
}; };
// Write the migrated data back to the file // Write the migrated data back to the file
@@ -369,7 +393,7 @@ function readJSON(filepath, projectRoot = null, tag = null) {
} }
// If we have tagged data, we need to resolve which tag to use // If we have tagged data, we need to resolve which tag to use
if (typeof data === 'object' && !data.tasks) { if (typeof data === "object" && !data.tasks) {
// This is tagged format // This is tagged format
if (isDebug) { if (isDebug) {
console.log(`File is in tagged format, resolving tag...`); console.log(`File is in tagged format, resolving tag...`);
@@ -379,19 +403,19 @@ function readJSON(filepath, projectRoot = null, tag = null) {
for (const tagName in data) { for (const tagName in data) {
if ( if (
data.hasOwnProperty(tagName) && data.hasOwnProperty(tagName) &&
typeof data[tagName] === 'object' && typeof data[tagName] === "object" &&
data[tagName].tasks data[tagName].tasks
) { ) {
try { try {
ensureTagMetadata(data[tagName], { ensureTagMetadata(data[tagName], {
description: `Tasks for ${tagName} context`, description: `Tasks for ${tagName} context`,
skipUpdate: true // Don't update timestamp during read operations skipUpdate: true, // Don't update timestamp during read operations
}); });
} catch (error) { } catch (error) {
// If ensureTagMetadata fails, continue without metadata // If ensureTagMetadata fails, continue without metadata
if (isDebug) { if (isDebug) {
console.log( console.log(
`Failed to ensure metadata for tag ${tagName}: ${error.message}` `Failed to ensure metadata for tag ${tagName}: ${error.message}`,
); );
} }
} }
@@ -414,7 +438,7 @@ function readJSON(filepath, projectRoot = null, tag = null) {
try { try {
// Default to master tag if anything goes wrong // Default to master tag if anything goes wrong
let resolvedTag = 'master'; let resolvedTag = "master";
// Try to resolve the correct tag, but don't fail if it doesn't work // Try to resolve the correct tag, but don't fail if it doesn't work
try { try {
@@ -435,7 +459,7 @@ function readJSON(filepath, projectRoot = null, tag = null) {
} catch (tagResolveError) { } catch (tagResolveError) {
if (isDebug) { if (isDebug) {
console.log( console.log(
`Tag resolution failed, using master: ${tagResolveError.message}` `Tag resolution failed, using master: ${tagResolveError.message}`,
); );
} }
// resolvedTag stays as 'master' // resolvedTag stays as 'master'
@@ -448,15 +472,17 @@ function readJSON(filepath, projectRoot = null, tag = null) {
// Get the data for the resolved tag // Get the data for the resolved tag
const tagData = data[resolvedTag]; const tagData = data[resolvedTag];
if (tagData && tagData.tasks) { if (tagData && tagData.tasks) {
normalizeTaskIds(tagData.tasks);
// Add the _rawTaggedData property and the resolved tag to the returned data // Add the _rawTaggedData property and the resolved tag to the returned data
const result = { const result = {
...tagData, ...tagData,
tag: resolvedTag, tag: resolvedTag,
_rawTaggedData: originalTaggedData _rawTaggedData: originalTaggedData,
}; };
if (isDebug) { if (isDebug) {
console.log( console.log(
`Returning data for tag '${resolvedTag}' with ${tagData.tasks.length} tasks` `Returning data for tag '${resolvedTag}' with ${tagData.tasks.length} tasks`,
); );
} }
return result; return result;
@@ -464,15 +490,17 @@ function readJSON(filepath, projectRoot = null, tag = null) {
// If the resolved tag doesn't exist, fall back to master // If the resolved tag doesn't exist, fall back to master
const masterData = data.master; const masterData = data.master;
if (masterData && masterData.tasks) { if (masterData && masterData.tasks) {
normalizeTaskIds(masterData.tasks);
if (isDebug) { if (isDebug) {
console.log( console.log(
`Tag '${resolvedTag}' not found, falling back to master with ${masterData.tasks.length} tasks` `Tag '${resolvedTag}' not found, falling back to master with ${masterData.tasks.length} tasks`,
); );
} }
return { return {
...masterData, ...masterData,
tag: 'master', tag: "master",
_rawTaggedData: originalTaggedData _rawTaggedData: originalTaggedData,
}; };
} else { } else {
if (isDebug) { if (isDebug) {
@@ -481,8 +509,8 @@ function readJSON(filepath, projectRoot = null, tag = null) {
// Return empty structure if no valid data // Return empty structure if no valid data
return { return {
tasks: [], tasks: [],
tag: 'master', tag: "master",
_rawTaggedData: originalTaggedData _rawTaggedData: originalTaggedData,
}; };
} }
} }
@@ -493,14 +521,16 @@ function readJSON(filepath, projectRoot = null, tag = null) {
// If anything goes wrong, try to return master or empty // If anything goes wrong, try to return master or empty
const masterData = data.master; const masterData = data.master;
if (masterData && masterData.tasks) { if (masterData && masterData.tasks) {
normalizeTaskIds(masterData.tasks);
return { return {
...masterData, ...masterData,
_rawTaggedData: originalTaggedData _rawTaggedData: originalTaggedData,
}; };
} }
return { return {
tasks: [], tasks: [],
_rawTaggedData: originalTaggedData _rawTaggedData: originalTaggedData,
}; };
} }
} }
@@ -524,26 +554,26 @@ function performCompleteTagMigration(tasksJsonPath) {
path.dirname(tasksJsonPath); path.dirname(tasksJsonPath);
// 1. Migrate config.json - add defaultTag and tags section // 1. Migrate config.json - add defaultTag and tags section
const configPath = path.join(projectRoot, '.taskmaster', 'config.json'); const configPath = path.join(projectRoot, ".taskmaster", "config.json");
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
migrateConfigJson(configPath); migrateConfigJson(configPath);
} }
// 2. Create state.json if it doesn't exist // 2. Create state.json if it doesn't exist
const statePath = path.join(projectRoot, '.taskmaster', 'state.json'); const statePath = path.join(projectRoot, ".taskmaster", "state.json");
if (!fs.existsSync(statePath)) { if (!fs.existsSync(statePath)) {
createStateJson(statePath); createStateJson(statePath);
} }
if (getDebugFlag()) { if (getDebugFlag()) {
log( log(
'debug', "debug",
`Complete tag migration performed for project: ${projectRoot}` `Complete tag migration performed for project: ${projectRoot}`,
); );
} }
} catch (error) { } catch (error) {
if (getDebugFlag()) { if (getDebugFlag()) {
log('warn', `Error during complete tag migration: ${error.message}`); log("warn", `Error during complete tag migration: ${error.message}`);
} }
} }
} }
@@ -554,7 +584,7 @@ function performCompleteTagMigration(tasksJsonPath) {
*/ */
function migrateConfigJson(configPath) { function migrateConfigJson(configPath) {
try { try {
const rawConfig = fs.readFileSync(configPath, 'utf8'); const rawConfig = fs.readFileSync(configPath, "utf8");
const config = JSON.parse(rawConfig); const config = JSON.parse(rawConfig);
if (!config) return; if (!config) return;
@@ -565,20 +595,20 @@ function migrateConfigJson(configPath) {
config.global = {}; config.global = {};
} }
if (!config.global.defaultTag) { if (!config.global.defaultTag) {
config.global.defaultTag = 'master'; config.global.defaultTag = "master";
modified = true; modified = true;
} }
if (modified) { if (modified) {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
if (process.env.TASKMASTER_DEBUG === 'true') { if (process.env.TASKMASTER_DEBUG === "true") {
console.log( console.log(
'[DEBUG] Updated config.json with tagged task system settings' "[DEBUG] Updated config.json with tagged task system settings",
); );
} }
} }
} catch (error) { } catch (error) {
if (process.env.TASKMASTER_DEBUG === 'true') { if (process.env.TASKMASTER_DEBUG === "true") {
console.warn(`[WARN] Error migrating config.json: ${error.message}`); console.warn(`[WARN] Error migrating config.json: ${error.message}`);
} }
} }
@@ -591,18 +621,18 @@ function migrateConfigJson(configPath) {
function createStateJson(statePath) { function createStateJson(statePath) {
try { try {
const initialState = { const initialState = {
currentTag: 'master', currentTag: "master",
lastSwitched: new Date().toISOString(), lastSwitched: new Date().toISOString(),
branchTagMapping: {}, branchTagMapping: {},
migrationNoticeShown: false migrationNoticeShown: false,
}; };
fs.writeFileSync(statePath, JSON.stringify(initialState, null, 2), 'utf8'); fs.writeFileSync(statePath, JSON.stringify(initialState, null, 2), "utf8");
if (process.env.TASKMASTER_DEBUG === 'true') { if (process.env.TASKMASTER_DEBUG === "true") {
console.log('[DEBUG] Created initial state.json for tagged task system'); console.log("[DEBUG] Created initial state.json for tagged task system");
} }
} catch (error) { } catch (error) {
if (process.env.TASKMASTER_DEBUG === 'true') { if (process.env.TASKMASTER_DEBUG === "true") {
console.warn(`[WARN] Error creating state.json: ${error.message}`); console.warn(`[WARN] Error creating state.json: ${error.message}`);
} }
} }
@@ -615,7 +645,7 @@ function createStateJson(statePath) {
function markMigrationForNotice(tasksJsonPath) { function markMigrationForNotice(tasksJsonPath) {
try { try {
const projectRoot = path.dirname(path.dirname(tasksJsonPath)); const projectRoot = path.dirname(path.dirname(tasksJsonPath));
const statePath = path.join(projectRoot, '.taskmaster', 'state.json'); const statePath = path.join(projectRoot, ".taskmaster", "state.json");
// Ensure state.json exists // Ensure state.json exists
if (!fs.existsSync(statePath)) { if (!fs.existsSync(statePath)) {
@@ -624,24 +654,24 @@ function markMigrationForNotice(tasksJsonPath) {
// Read and update state to mark migration occurred using fs directly // Read and update state to mark migration occurred using fs directly
try { try {
const rawState = fs.readFileSync(statePath, 'utf8'); const rawState = fs.readFileSync(statePath, "utf8");
const stateData = JSON.parse(rawState) || {}; const stateData = JSON.parse(rawState) || {};
// Only set to false if it's not already set (i.e., first time migration) // Only set to false if it's not already set (i.e., first time migration)
if (stateData.migrationNoticeShown === undefined) { if (stateData.migrationNoticeShown === undefined) {
stateData.migrationNoticeShown = false; stateData.migrationNoticeShown = false;
fs.writeFileSync(statePath, JSON.stringify(stateData, null, 2), 'utf8'); fs.writeFileSync(statePath, JSON.stringify(stateData, null, 2), "utf8");
} }
} catch (stateError) { } catch (stateError) {
if (process.env.TASKMASTER_DEBUG === 'true') { if (process.env.TASKMASTER_DEBUG === "true") {
console.warn( console.warn(
`[WARN] Error updating state for migration notice: ${stateError.message}` `[WARN] Error updating state for migration notice: ${stateError.message}`,
); );
} }
} }
} catch (error) { } catch (error) {
if (process.env.TASKMASTER_DEBUG === 'true') { if (process.env.TASKMASTER_DEBUG === "true") {
console.warn( console.warn(
`[WARN] Error marking migration for notice: ${error.message}` `[WARN] Error marking migration for notice: ${error.message}`,
); );
} }
} }
@@ -655,7 +685,7 @@ function markMigrationForNotice(tasksJsonPath) {
* @param {string} tag - Optional tag for tag context * @param {string} tag - Optional tag for tag context
*/ */
function writeJSON(filepath, data, projectRoot = null, tag = null) { function writeJSON(filepath, data, projectRoot = null, tag = null) {
const isDebug = process.env.TASKMASTER_DEBUG === 'true'; const isDebug = process.env.TASKMASTER_DEBUG === "true";
try { try {
let finalData = data; let finalData = data;
@@ -671,12 +701,12 @@ function writeJSON(filepath, data, projectRoot = null, tag = null) {
if (isDebug) { if (isDebug) {
console.log( console.log(
`writeJSON: Detected resolved tag data missing _rawTaggedData. Re-reading raw data to prevent data loss for tag '${resolvedTag}'.` `writeJSON: Detected resolved tag data missing _rawTaggedData. Re-reading raw data to prevent data loss for tag '${resolvedTag}'.`,
); );
} }
// Re-read the full file to get the complete tagged structure // Re-read the full file to get the complete tagged structure
const rawFullData = JSON.parse(fs.readFileSync(filepath, 'utf8')); const rawFullData = JSON.parse(fs.readFileSync(filepath, "utf8"));
// Merge the updated data into the full structure // Merge the updated data into the full structure
finalData = { finalData = {
@@ -685,8 +715,8 @@ function writeJSON(filepath, data, projectRoot = null, tag = null) {
// Preserve existing tag metadata if it exists, otherwise use what's passed // Preserve existing tag metadata if it exists, otherwise use what's passed
...(rawFullData[resolvedTag]?.metadata || {}), ...(rawFullData[resolvedTag]?.metadata || {}),
...(data.metadata ? { metadata: data.metadata } : {}), ...(data.metadata ? { metadata: data.metadata } : {}),
tasks: data.tasks // The updated tasks array is the source of truth here tasks: data.tasks, // The updated tasks array is the source of truth here
} },
}; };
} }
// If we have _rawTaggedData, this means we're working with resolved tag data // If we have _rawTaggedData, this means we're working with resolved tag data
@@ -703,30 +733,30 @@ function writeJSON(filepath, data, projectRoot = null, tag = null) {
// Update the specific tag with the resolved data // Update the specific tag with the resolved data
finalData = { finalData = {
...originalTaggedData, ...originalTaggedData,
[resolvedTag]: cleanResolvedData [resolvedTag]: cleanResolvedData,
}; };
if (isDebug) { if (isDebug) {
console.log( console.log(
`writeJSON: Merging resolved data back into tag '${resolvedTag}'` `writeJSON: Merging resolved data back into tag '${resolvedTag}'`,
); );
} }
} }
// Clean up any internal properties that shouldn't be persisted // Clean up any internal properties that shouldn't be persisted
let cleanData = finalData; let cleanData = finalData;
if (cleanData && typeof cleanData === 'object') { if (cleanData && typeof cleanData === "object") {
// Remove any _rawTaggedData or tag properties from root level // Remove any _rawTaggedData or tag properties from root level
const { _rawTaggedData, tag: tagProp, ...rootCleanData } = cleanData; const { _rawTaggedData, tag: tagProp, ...rootCleanData } = cleanData;
cleanData = rootCleanData; cleanData = rootCleanData;
// Additional cleanup for tag objects // Additional cleanup for tag objects
if (typeof cleanData === 'object' && !Array.isArray(cleanData)) { if (typeof cleanData === "object" && !Array.isArray(cleanData)) {
const finalCleanData = {}; const finalCleanData = {};
for (const [key, value] of Object.entries(cleanData)) { for (const [key, value] of Object.entries(cleanData)) {
if ( if (
value && value &&
typeof value === 'object' && typeof value === "object" &&
Array.isArray(value.tasks) Array.isArray(value.tasks)
) { ) {
// This is a tag object - clean up any rogue root-level properties // This is a tag object - clean up any rogue root-level properties
@@ -749,15 +779,15 @@ function writeJSON(filepath, data, projectRoot = null, tag = null) {
} }
} }
fs.writeFileSync(filepath, JSON.stringify(cleanData, null, 2), 'utf8'); fs.writeFileSync(filepath, JSON.stringify(cleanData, null, 2), "utf8");
if (isDebug) { if (isDebug) {
console.log(`writeJSON: Successfully wrote to ${filepath}`); console.log(`writeJSON: Successfully wrote to ${filepath}`);
} }
} catch (error) { } catch (error) {
log('error', `Error writing JSON file ${filepath}:`, error.message); log("error", `Error writing JSON file ${filepath}:`, error.message);
if (isDebug) { if (isDebug) {
log('error', 'Full error details:', error); log("error", "Full error details:", error);
} }
} }
} }
@@ -798,7 +828,7 @@ function readComplexityReport(customPath = null) {
const newPath = path.join(process.cwd(), COMPLEXITY_REPORT_FILE); const newPath = path.join(process.cwd(), COMPLEXITY_REPORT_FILE);
const legacyPath = path.join( const legacyPath = path.join(
process.cwd(), process.cwd(),
LEGACY_COMPLEXITY_REPORT_FILE LEGACY_COMPLEXITY_REPORT_FILE,
); );
reportPath = fs.existsSync(newPath) ? newPath : legacyPath; reportPath = fs.existsSync(newPath) ? newPath : legacyPath;
@@ -806,19 +836,19 @@ function readComplexityReport(customPath = null) {
if (!fs.existsSync(reportPath)) { if (!fs.existsSync(reportPath)) {
if (isDebug) { if (isDebug) {
log('debug', `Complexity report not found at ${reportPath}`); log("debug", `Complexity report not found at ${reportPath}`);
} }
return null; return null;
} }
const reportData = readJSON(reportPath); const reportData = readJSON(reportPath);
if (isDebug) { if (isDebug) {
log('debug', `Successfully read complexity report from ${reportPath}`); log("debug", `Successfully read complexity report from ${reportPath}`);
} }
return reportData; return reportData;
} catch (error) { } catch (error) {
if (isDebug) { if (isDebug) {
log('error', `Error reading complexity report: ${error.message}`); log("error", `Error reading complexity report: ${error.message}`);
} }
return null; return null;
} }
@@ -870,9 +900,9 @@ function taskExists(tasks, taskId) {
} }
// Handle both regular task IDs and subtask IDs (e.g., "1.2") // Handle both regular task IDs and subtask IDs (e.g., "1.2")
if (typeof taskId === 'string' && taskId.includes('.')) { if (typeof taskId === "string" && taskId.includes(".")) {
const [parentId, subtaskId] = taskId const [parentId, subtaskId] = taskId
.split('.') .split(".")
.map((id) => parseInt(id, 10)); .map((id) => parseInt(id, 10));
const parentTask = tasks.find((t) => t.id === parentId); const parentTask = tasks.find((t) => t.id === parentId);
@@ -893,11 +923,11 @@ function taskExists(tasks, taskId) {
* @returns {string} The formatted task ID * @returns {string} The formatted task ID
*/ */
function formatTaskId(id) { function formatTaskId(id) {
if (typeof id === 'string' && id.includes('.')) { if (typeof id === "string" && id.includes(".")) {
return id; // Already formatted as a string with a dot (e.g., "1.2") return id; // Already formatted as a string with a dot (e.g., "1.2")
} }
if (typeof id === 'number') { if (typeof id === "number") {
return id.toString(); return id.toString();
} }
@@ -916,17 +946,17 @@ function findTaskById(
tasks, tasks,
taskId, taskId,
complexityReport = null, complexityReport = null,
statusFilter = null statusFilter = null,
) { ) {
if (!taskId || !tasks || !Array.isArray(tasks)) { if (!taskId || !tasks || !Array.isArray(tasks)) {
return { task: null, originalSubtaskCount: null }; return { task: null, originalSubtaskCount: null };
} }
// Check if it's a subtask ID (e.g., "1.2") // Check if it's a subtask ID (e.g., "1.2")
if (typeof taskId === 'string' && taskId.includes('.')) { if (typeof taskId === "string" && taskId.includes(".")) {
// If looking for a subtask, statusFilter doesn't apply directly here. // If looking for a subtask, statusFilter doesn't apply directly here.
const [parentId, subtaskId] = taskId const [parentId, subtaskId] = taskId
.split('.') .split(".")
.map((id) => parseInt(id, 10)); .map((id) => parseInt(id, 10));
const parentTask = tasks.find((t) => t.id === parentId); const parentTask = tasks.find((t) => t.id === parentId);
@@ -940,7 +970,7 @@ function findTaskById(
subtask.parentTask = { subtask.parentTask = {
id: parentTask.id, id: parentTask.id,
title: parentTask.title, title: parentTask.title,
status: parentTask.status status: parentTask.status,
}; };
subtask.isSubtask = true; subtask.isSubtask = true;
} }
@@ -953,7 +983,7 @@ function findTaskById(
return { return {
task: subtask || null, task: subtask || null,
originalSubtaskCount: null, originalSubtaskCount: null,
originalSubtasks: null originalSubtasks: null,
}; };
} }
@@ -983,7 +1013,7 @@ function findTaskById(
filteredTask.subtasks = task.subtasks.filter( filteredTask.subtasks = task.subtasks.filter(
(subtask) => (subtask) =>
subtask.status && subtask.status &&
subtask.status.toLowerCase() === statusFilter.toLowerCase() subtask.status.toLowerCase() === statusFilter.toLowerCase(),
); );
taskResult = filteredTask; taskResult = filteredTask;
@@ -1020,7 +1050,7 @@ function truncate(text, maxLength) {
function isEmpty(value) { function isEmpty(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.length === 0; return value.length === 0;
} else if (typeof value === 'object' && value !== null) { } else if (typeof value === "object" && value !== null) {
return Object.keys(value).length === 0; return Object.keys(value).length === 0;
} }
@@ -1040,7 +1070,7 @@ function findCycles(
dependencyMap, dependencyMap,
visited = new Set(), visited = new Set(),
recursionStack = new Set(), recursionStack = new Set(),
path = [] path = [],
) { ) {
// Mark the current node as visited and part of recursion stack // Mark the current node as visited and part of recursion stack
visited.add(subtaskId); visited.add(subtaskId);
@@ -1057,7 +1087,7 @@ function findCycles(
// If not visited, recursively check for cycles // If not visited, recursively check for cycles
if (!visited.has(depId)) { if (!visited.has(depId)) {
const cycles = findCycles(depId, dependencyMap, visited, recursionStack, [ const cycles = findCycles(depId, dependencyMap, visited, recursionStack, [
...path ...path,
]); ]);
cyclesToBreak.push(...cycles); cyclesToBreak.push(...cycles);
} }
@@ -1086,21 +1116,21 @@ function findCycles(
const toKebabCase = (str) => { const toKebabCase = (str) => {
// Special handling for common acronyms // Special handling for common acronyms
const withReplacedAcronyms = str const withReplacedAcronyms = str
.replace(/ID/g, 'Id') .replace(/ID/g, "Id")
.replace(/API/g, 'Api') .replace(/API/g, "Api")
.replace(/UI/g, 'Ui') .replace(/UI/g, "Ui")
.replace(/URL/g, 'Url') .replace(/URL/g, "Url")
.replace(/URI/g, 'Uri') .replace(/URI/g, "Uri")
.replace(/JSON/g, 'Json') .replace(/JSON/g, "Json")
.replace(/XML/g, 'Xml') .replace(/XML/g, "Xml")
.replace(/HTML/g, 'Html') .replace(/HTML/g, "Html")
.replace(/CSS/g, 'Css'); .replace(/CSS/g, "Css");
// Insert hyphens before capital letters and convert to lowercase // Insert hyphens before capital letters and convert to lowercase
return withReplacedAcronyms return withReplacedAcronyms
.replace(/([A-Z])/g, '-$1') .replace(/([A-Z])/g, "-$1")
.toLowerCase() .toLowerCase()
.replace(/^-/, ''); // Remove leading hyphen if present .replace(/^-/, ""); // Remove leading hyphen if present
}; };
/** /**
@@ -1111,11 +1141,11 @@ const toKebabCase = (str) => {
function detectCamelCaseFlags(args) { function detectCamelCaseFlags(args) {
const camelCaseFlags = []; const camelCaseFlags = [];
for (const arg of args) { for (const arg of args) {
if (arg.startsWith('--')) { if (arg.startsWith("--")) {
const flagName = arg.split('=')[0].slice(2); // Remove -- and anything after = const flagName = arg.split("=")[0].slice(2); // Remove -- and anything after =
// Skip single-word flags - they can't be camelCase // Skip single-word flags - they can't be camelCase
if (!flagName.includes('-') && !/[A-Z]/.test(flagName)) { if (!flagName.includes("-") && !/[A-Z]/.test(flagName)) {
continue; continue;
} }
@@ -1125,7 +1155,7 @@ function detectCamelCaseFlags(args) {
if (kebabVersion !== flagName) { if (kebabVersion !== flagName) {
camelCaseFlags.push({ camelCaseFlags.push({
original: flagName, original: flagName,
kebabCase: kebabVersion kebabCase: kebabVersion,
}); });
} }
} }
@@ -1149,13 +1179,13 @@ function aggregateTelemetry(telemetryArray, overallCommandName) {
timestamp: new Date().toISOString(), // Use current time for aggregation time timestamp: new Date().toISOString(), // Use current time for aggregation time
userId: telemetryArray[0].userId, // Assume userId is consistent userId: telemetryArray[0].userId, // Assume userId is consistent
commandName: overallCommandName, commandName: overallCommandName,
modelUsed: 'Multiple', // Default if models vary modelUsed: "Multiple", // Default if models vary
providerName: 'Multiple', // Default if providers vary providerName: "Multiple", // Default if providers vary
inputTokens: 0, inputTokens: 0,
outputTokens: 0, outputTokens: 0,
totalTokens: 0, totalTokens: 0,
totalCost: 0, totalCost: 0,
currency: telemetryArray[0].currency || 'USD' // Assume consistent currency or default currency: telemetryArray[0].currency || "USD", // Assume consistent currency or default
}; };
const uniqueModels = new Set(); const uniqueModels = new Set();
@@ -1168,7 +1198,7 @@ function aggregateTelemetry(telemetryArray, overallCommandName) {
aggregated.totalCost += item.totalCost || 0; aggregated.totalCost += item.totalCost || 0;
uniqueModels.add(item.modelUsed); uniqueModels.add(item.modelUsed);
uniqueProviders.add(item.providerName); uniqueProviders.add(item.providerName);
uniqueCurrencies.add(item.currency || 'USD'); uniqueCurrencies.add(item.currency || "USD");
}); });
aggregated.totalTokens = aggregated.inputTokens + aggregated.outputTokens; aggregated.totalTokens = aggregated.inputTokens + aggregated.outputTokens;
@@ -1181,7 +1211,7 @@ function aggregateTelemetry(telemetryArray, overallCommandName) {
aggregated.providerName = [...uniqueProviders][0]; aggregated.providerName = [...uniqueProviders][0];
} }
if (uniqueCurrencies.size > 1) { if (uniqueCurrencies.size > 1) {
aggregated.currency = 'Multiple'; // Mark if currencies actually differ aggregated.currency = "Multiple"; // Mark if currencies actually differ
} else if (uniqueCurrencies.size === 1) { } else if (uniqueCurrencies.size === 1) {
aggregated.currency = [...uniqueCurrencies][0]; aggregated.currency = [...uniqueCurrencies][0];
} }
@@ -1197,14 +1227,14 @@ function aggregateTelemetry(telemetryArray, overallCommandName) {
*/ */
function getCurrentTag(projectRoot) { function getCurrentTag(projectRoot) {
if (!projectRoot) { if (!projectRoot) {
throw new Error('projectRoot is required for getCurrentTag'); throw new Error("projectRoot is required for getCurrentTag");
} }
try { try {
// Try to read current tag from state.json using fs directly // Try to read current tag from state.json using fs directly
const statePath = path.join(projectRoot, '.taskmaster', 'state.json'); const statePath = path.join(projectRoot, ".taskmaster", "state.json");
if (fs.existsSync(statePath)) { if (fs.existsSync(statePath)) {
const rawState = fs.readFileSync(statePath, 'utf8'); const rawState = fs.readFileSync(statePath, "utf8");
const stateData = JSON.parse(rawState); const stateData = JSON.parse(rawState);
if (stateData && stateData.currentTag) { if (stateData && stateData.currentTag) {
return stateData.currentTag; return stateData.currentTag;
@@ -1216,9 +1246,9 @@ function getCurrentTag(projectRoot) {
// Fall back to defaultTag from config using fs directly // Fall back to defaultTag from config using fs directly
try { try {
const configPath = path.join(projectRoot, '.taskmaster', 'config.json'); const configPath = path.join(projectRoot, ".taskmaster", "config.json");
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const rawConfig = fs.readFileSync(configPath, 'utf8'); const rawConfig = fs.readFileSync(configPath, "utf8");
const configData = JSON.parse(rawConfig); const configData = JSON.parse(rawConfig);
if (configData && configData.global && configData.global.defaultTag) { if (configData && configData.global && configData.global.defaultTag) {
return configData.global.defaultTag; return configData.global.defaultTag;
@@ -1229,7 +1259,7 @@ function getCurrentTag(projectRoot) {
} }
// Final fallback // Final fallback
return 'master'; return "master";
} }
/** /**
@@ -1243,7 +1273,7 @@ function resolveTag(options = {}) {
const { projectRoot, tag } = options; const { projectRoot, tag } = options;
if (!projectRoot) { if (!projectRoot) {
throw new Error('projectRoot is required for resolveTag'); throw new Error("projectRoot is required for resolveTag");
} }
// If explicit tag provided, use it // If explicit tag provided, use it
@@ -1311,7 +1341,7 @@ function flattenTasksWithSubtasks(tasks) {
flattened.push({ flattened.push({
...task, ...task,
searchableId: task.id.toString(), // For consistent ID handling searchableId: task.id.toString(), // For consistent ID handling
isSubtask: false isSubtask: false,
}); });
// Add subtasks if they exist // Add subtasks if they exist
@@ -1325,7 +1355,7 @@ function flattenTasksWithSubtasks(tasks) {
parentTitle: task.title, parentTitle: task.title,
// Enhance subtask context with parent information // Enhance subtask context with parent information
title: `${subtask.title} (subtask of: ${task.title})`, title: `${subtask.title} (subtask of: ${task.title})`,
description: `${subtask.description} [Parent: ${task.description}]` description: `${subtask.description} [Parent: ${task.description}]`,
}); });
} }
} }
@@ -1343,8 +1373,8 @@ function flattenTasksWithSubtasks(tasks) {
* @returns {Object} The updated tag object (for chaining) * @returns {Object} The updated tag object (for chaining)
*/ */
function ensureTagMetadata(tagObj, opts = {}) { function ensureTagMetadata(tagObj, opts = {}) {
if (!tagObj || typeof tagObj !== 'object') { if (!tagObj || typeof tagObj !== "object") {
throw new Error('tagObj must be a valid object'); throw new Error("tagObj must be a valid object");
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -1354,7 +1384,7 @@ function ensureTagMetadata(tagObj, opts = {}) {
tagObj.metadata = { tagObj.metadata = {
created: now, created: now,
updated: now, updated: now,
...(opts.description ? { description: opts.description } : {}) ...(opts.description ? { description: opts.description } : {}),
}; };
} else { } else {
// Ensure existing metadata has required fields // Ensure existing metadata has required fields
@@ -1412,5 +1442,5 @@ export {
createStateJson, createStateJson,
markMigrationForNotice, markMigrationForNotice,
flattenTasksWithSubtasks, flattenTasksWithSubtasks,
ensureTagMetadata ensureTagMetadata,
}; };