diff --git a/.changeset/fix-taskmaster-parent-priority.md b/.changeset/fix-taskmaster-parent-priority.md new file mode 100644 index 00000000..812cd46a --- /dev/null +++ b/.changeset/fix-taskmaster-parent-priority.md @@ -0,0 +1,9 @@ +--- +"task-master-ai": patch +--- + +fix: prioritize .taskmaster in parent directories over other project markers + +When running task-master commands from subdirectories containing other project markers (like .git, go.mod, package.json), findProjectRoot() now correctly finds and uses .taskmaster directories in parent folders instead of stopping at the first generic project marker found. + +This enables multi-repo monorepo setups where a single .taskmaster at the root tracks work across multiple sub-repositories. diff --git a/apps/cli/src/commands/autopilot/abort.command.ts b/apps/cli/src/commands/autopilot/abort.command.ts index 936ef1fa..f4bd66b8 100644 --- a/apps/cli/src/commands/autopilot/abort.command.ts +++ b/apps/cli/src/commands/autopilot/abort.command.ts @@ -12,6 +12,7 @@ import { OutputFormatter } from './shared.js'; import inquirer from 'inquirer'; +import { getProjectRoot } from '../../utils/project-root.js'; interface AbortOptions extends AutopilotBaseOptions { force?: boolean; @@ -34,16 +35,29 @@ export class AbortCommand extends Command { private async execute(options: AbortOptions): Promise { // Inherit parent options const parentOpts = this.parent?.opts() as AutopilotBaseOptions; - const mergedOptions: AbortOptions = { + + // Initialize mergedOptions with defaults (projectRoot will be set in try block) + let mergedOptions: AbortOptions = { ...parentOpts, ...options, - projectRoot: - options.projectRoot || parentOpts?.projectRoot || process.cwd() + projectRoot: '' // Will be set in try block }; - const formatter = new OutputFormatter(mergedOptions.json || false); + const formatter = new OutputFormatter( + options.json || parentOpts?.json || false + ); try { + // Resolve project root inside try block to catch any errors + const projectRoot = getProjectRoot( + options.projectRoot || parentOpts?.projectRoot + ); + + // Update mergedOptions with resolved project root + mergedOptions = { + ...mergedOptions, + projectRoot + }; // Check for workflow state const hasState = await hasWorkflowState(mergedOptions.projectRoot!); if (!hasState) { diff --git a/apps/cli/src/commands/autopilot/commit.command.ts b/apps/cli/src/commands/autopilot/commit.command.ts index f4662bd6..cd9c57b7 100644 --- a/apps/cli/src/commands/autopilot/commit.command.ts +++ b/apps/cli/src/commands/autopilot/commit.command.ts @@ -5,6 +5,7 @@ import { Command } from 'commander'; import { WorkflowService, GitAdapter, CommitMessageGenerator } from '@tm/core'; import { AutopilotBaseOptions, OutputFormatter } from './shared.js'; +import { getProjectRoot } from '../../utils/project-root.js'; type CommitOptions = AutopilotBaseOptions; @@ -28,8 +29,9 @@ export class CommitCommand extends Command { const mergedOptions: CommitOptions = { ...parentOpts, ...options, - projectRoot: - options.projectRoot || parentOpts?.projectRoot || process.cwd() + projectRoot: getProjectRoot( + options.projectRoot || parentOpts?.projectRoot + ) }; const formatter = new OutputFormatter(mergedOptions.json || false); diff --git a/apps/cli/src/commands/autopilot/complete.command.ts b/apps/cli/src/commands/autopilot/complete.command.ts index 77ef4cd3..2a1b4899 100644 --- a/apps/cli/src/commands/autopilot/complete.command.ts +++ b/apps/cli/src/commands/autopilot/complete.command.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import { WorkflowOrchestrator, TestResult } from '@tm/core'; +import { getProjectRoot } from '../../utils/project-root.js'; import { AutopilotBaseOptions, hasWorkflowState, @@ -40,8 +41,9 @@ export class CompleteCommand extends Command { const mergedOptions: CompleteOptions = { ...parentOpts, ...options, - projectRoot: - options.projectRoot || parentOpts?.projectRoot || process.cwd() + projectRoot: getProjectRoot( + options.projectRoot || parentOpts?.projectRoot + ) }; const formatter = new OutputFormatter(mergedOptions.json || false); diff --git a/apps/cli/src/commands/autopilot/index.ts b/apps/cli/src/commands/autopilot/index.ts index 9eb54609..bbb08e60 100644 --- a/apps/cli/src/commands/autopilot/index.ts +++ b/apps/cli/src/commands/autopilot/index.ts @@ -37,8 +37,7 @@ export class AutopilotCommand extends Command { .option('-v, --verbose', 'Enable verbose output') .option( '-p, --project-root ', - 'Project root directory', - process.cwd() + 'Project root directory (auto-detected if not specified)' ); // Register subcommands diff --git a/apps/cli/src/commands/autopilot/next.command.ts b/apps/cli/src/commands/autopilot/next.command.ts index 71cdef4b..97bf31e5 100644 --- a/apps/cli/src/commands/autopilot/next.command.ts +++ b/apps/cli/src/commands/autopilot/next.command.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import { WorkflowOrchestrator } from '@tm/core'; +import { getProjectRoot } from '../../utils/project-root.js'; import { AutopilotBaseOptions, hasWorkflowState, @@ -30,16 +31,29 @@ export class NextCommand extends Command { private async execute(options: NextOptions): Promise { // Inherit parent options const parentOpts = this.parent?.opts() as AutopilotBaseOptions; - const mergedOptions: NextOptions = { + + // Initialize mergedOptions with defaults (projectRoot will be set in try block) + let mergedOptions: NextOptions = { ...parentOpts, ...options, - projectRoot: - options.projectRoot || parentOpts?.projectRoot || process.cwd() + projectRoot: '' // Will be set in try block }; - const formatter = new OutputFormatter(mergedOptions.json || false); + const formatter = new OutputFormatter( + options.json || parentOpts?.json || false + ); try { + // Resolve project root inside try block to catch any errors + const projectRoot = getProjectRoot( + options.projectRoot || parentOpts?.projectRoot + ); + + // Update mergedOptions with resolved project root + mergedOptions = { + ...mergedOptions, + projectRoot + }; // Check for workflow state const hasState = await hasWorkflowState(mergedOptions.projectRoot!); if (!hasState) { diff --git a/apps/cli/src/commands/autopilot/resume.command.ts b/apps/cli/src/commands/autopilot/resume.command.ts index 69ac6306..35490bd1 100644 --- a/apps/cli/src/commands/autopilot/resume.command.ts +++ b/apps/cli/src/commands/autopilot/resume.command.ts @@ -10,6 +10,7 @@ import { loadWorkflowState, OutputFormatter } from './shared.js'; +import { getProjectRoot } from '../../utils/project-root.js'; type ResumeOptions = AutopilotBaseOptions; @@ -33,8 +34,9 @@ export class ResumeCommand extends Command { const mergedOptions: ResumeOptions = { ...parentOpts, ...options, - projectRoot: - options.projectRoot || parentOpts?.projectRoot || process.cwd() + projectRoot: getProjectRoot( + options.projectRoot || parentOpts?.projectRoot + ) }; const formatter = new OutputFormatter(mergedOptions.json || false); diff --git a/apps/cli/src/commands/autopilot/start.command.ts b/apps/cli/src/commands/autopilot/start.command.ts index 17d6c3e4..bf51cd6d 100644 --- a/apps/cli/src/commands/autopilot/start.command.ts +++ b/apps/cli/src/commands/autopilot/start.command.ts @@ -13,6 +13,7 @@ import { validateTaskId, parseSubtasks } from './shared.js'; +import { getProjectRoot } from '../../utils/project-root.js'; interface StartOptions extends AutopilotBaseOptions { force?: boolean; @@ -41,8 +42,9 @@ export class StartCommand extends Command { const mergedOptions: StartOptions = { ...parentOpts, ...options, - projectRoot: - options.projectRoot || parentOpts?.projectRoot || process.cwd() + projectRoot: getProjectRoot( + options.projectRoot || parentOpts?.projectRoot + ) }; const formatter = new OutputFormatter(mergedOptions.json || false); diff --git a/apps/cli/src/commands/autopilot/status.command.ts b/apps/cli/src/commands/autopilot/status.command.ts index 4d0e5420..123bc377 100644 --- a/apps/cli/src/commands/autopilot/status.command.ts +++ b/apps/cli/src/commands/autopilot/status.command.ts @@ -10,6 +10,7 @@ import { loadWorkflowState, OutputFormatter } from './shared.js'; +import { getProjectRoot } from '../../utils/project-root.js'; type StatusOptions = AutopilotBaseOptions; @@ -33,8 +34,9 @@ export class StatusCommand extends Command { const mergedOptions: StatusOptions = { ...parentOpts, ...options, - projectRoot: - options.projectRoot || parentOpts?.projectRoot || process.cwd() + projectRoot: getProjectRoot( + options.projectRoot || parentOpts?.projectRoot + ) }; const formatter = new OutputFormatter(mergedOptions.json || false); diff --git a/apps/cli/src/commands/export.command.ts b/apps/cli/src/commands/export.command.ts index 1ca2011f..d03ebea4 100644 --- a/apps/cli/src/commands/export.command.ts +++ b/apps/cli/src/commands/export.command.ts @@ -16,6 +16,7 @@ import { } from '@tm/core'; import * as ui from '../utils/ui.js'; import { displayError } from '../utils/error-handler.js'; +import { getProjectRoot } from '../utils/project-root.js'; /** * Result type from export command @@ -76,7 +77,7 @@ export class ExportCommand extends Command { try { // Initialize TmCore this.taskMasterCore = await createTmCore({ - projectPath: process.cwd() + projectPath: getProjectRoot() }); } catch (error) { throw new Error( diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index d1982e1e..4ae70dfb 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -19,6 +19,7 @@ import type { StorageType } from '@tm/core'; import * as ui from '../utils/ui.js'; import { displayError } from '../utils/error-handler.js'; import { displayCommandHeader } from '../utils/display-helpers.js'; +import { getProjectRoot } from '../utils/project-root.js'; import { displayDashboards, calculateTaskStatistics, @@ -77,7 +78,10 @@ export class ListTasksCommand extends Command { 'text' ) .option('--silent', 'Suppress output (useful for programmatic usage)') - .option('-p, --project ', 'Project root directory', process.cwd()) + .option( + '-p, --project ', + 'Project root directory (auto-detected if not provided)' + ) .action(async (options: ListCommandOptions) => { await this.executeCommand(options); }); @@ -93,8 +97,8 @@ export class ListTasksCommand extends Command { process.exit(1); } - // Initialize tm-core - await this.initializeCore(options.project || process.cwd()); + // Initialize tm-core (project root auto-detected if not provided) + await this.initializeCore(getProjectRoot(options.project)); // Get tasks from core const result = await this.getTasks(options); diff --git a/apps/cli/src/commands/next.command.ts b/apps/cli/src/commands/next.command.ts index 497d2d5e..0f5cf41b 100644 --- a/apps/cli/src/commands/next.command.ts +++ b/apps/cli/src/commands/next.command.ts @@ -12,6 +12,7 @@ import type { StorageType } from '@tm/core'; import { displayError } from '../utils/error-handler.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayCommandHeader } from '../utils/display-helpers.js'; +import { getProjectRoot } from '../utils/project-root.js'; /** * Options interface for the next command @@ -49,7 +50,10 @@ export class NextCommand extends Command { .option('-t, --tag ', 'Filter by tag') .option('-f, --format ', 'Output format (text, json)', 'text') .option('--silent', 'Suppress output (useful for programmatic usage)') - .option('-p, --project ', 'Project root directory', process.cwd()) + .option( + '-p, --project ', + 'Project root directory (auto-detected if not provided)' + ) .action(async (options: NextCommandOptions) => { await this.executeCommand(options); }); @@ -65,7 +69,7 @@ export class NextCommand extends Command { this.validateOptions(options); // Initialize tm-core - await this.initializeCore(options.project || process.cwd()); + await this.initializeCore(getProjectRoot(options.project)); // Get next task from core const result = await this.getNextTask(options); diff --git a/apps/cli/src/commands/set-status.command.ts b/apps/cli/src/commands/set-status.command.ts index a62fccb5..a5a0b307 100644 --- a/apps/cli/src/commands/set-status.command.ts +++ b/apps/cli/src/commands/set-status.command.ts @@ -9,6 +9,7 @@ import boxen from 'boxen'; import { createTmCore, type TmCore, type TaskStatus } from '@tm/core'; import type { StorageType } from '@tm/core'; import { displayError } from '../utils/error-handler.js'; +import { getProjectRoot } from '../utils/project-root.js'; /** * Valid task status values for validation @@ -70,7 +71,10 @@ export class SetStatusCommand extends Command { ) .option('-f, --format ', 'Output format (text, json)', 'text') .option('--silent', 'Suppress output (useful for programmatic usage)') - .option('-p, --project ', 'Project root directory', process.cwd()) + .option( + '-p, --project ', + 'Project root directory (auto-detected if not provided)' + ) .action(async (options: SetStatusCommandOptions) => { await this.executeCommand(options); }); @@ -109,7 +113,7 @@ export class SetStatusCommand extends Command { // Initialize TaskMaster core this.tmCore = await createTmCore({ - projectPath: options.project || process.cwd() + projectPath: getProjectRoot(options.project) }); // Parse task IDs (handle comma-separated values) diff --git a/apps/cli/src/commands/show.command.ts b/apps/cli/src/commands/show.command.ts index 9e8c444f..c5123bc3 100644 --- a/apps/cli/src/commands/show.command.ts +++ b/apps/cli/src/commands/show.command.ts @@ -12,6 +12,7 @@ import * as ui from '../utils/ui.js'; import { displayError } from '../utils/error-handler.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayCommandHeader } from '../utils/display-helpers.js'; +import { getProjectRoot } from '../utils/project-root.js'; /** * Options interface for the show command @@ -64,7 +65,10 @@ export class ShowCommand extends Command { .option('-s, --status ', 'Filter subtasks by status') .option('-f, --format ', 'Output format (text, json)', 'text') .option('--silent', 'Suppress output (useful for programmatic usage)') - .option('-p, --project ', 'Project root directory', process.cwd()) + .option( + '-p, --project ', + 'Project root directory (auto-detected if not provided)' + ) .action( async (taskId: string | undefined, options: ShowCommandOptions) => { await this.executeCommand(taskId, options); @@ -86,7 +90,7 @@ export class ShowCommand extends Command { } // Initialize tm-core - await this.initializeCore(options.project || process.cwd()); + await this.initializeCore(getProjectRoot(options.project)); // Get the task ID from argument or option const idArg = taskId || options.id; diff --git a/apps/cli/src/commands/start.command.ts b/apps/cli/src/commands/start.command.ts index 27ebf873..ae620412 100644 --- a/apps/cli/src/commands/start.command.ts +++ b/apps/cli/src/commands/start.command.ts @@ -17,6 +17,7 @@ import { import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import * as ui from '../utils/ui.js'; import { displayError } from '../utils/error-handler.js'; +import { getProjectRoot } from '../utils/project-root.js'; /** * CLI-specific options interface for the start command @@ -56,7 +57,10 @@ export class StartCommand extends Command { .argument('[id]', 'Task ID to start working on') .option('-i, --id ', 'Task ID to start working on') .option('-f, --format ', 'Output format (text, json)', 'text') - .option('-p, --project ', 'Project root directory', process.cwd()) + .option( + '-p, --project ', + 'Project root directory (auto-detected if not provided)' + ) .option( '--dry-run', 'Show what would be executed without launching claude-code' @@ -93,7 +97,7 @@ export class StartCommand extends Command { // Initialize tm-core with spinner spinner = ora('Initializing Task Master...').start(); - await this.initializeCore(options.project || process.cwd()); + await this.initializeCore(getProjectRoot(options.project)); spinner.succeed('Task Master initialized'); // Get the task ID from argument or option, or find next available task diff --git a/apps/cli/src/utils/project-root.ts b/apps/cli/src/utils/project-root.ts new file mode 100644 index 00000000..85a9fc5a --- /dev/null +++ b/apps/cli/src/utils/project-root.ts @@ -0,0 +1,43 @@ +/** + * @fileoverview Project root utilities for CLI + * Provides smart project root detection for command execution + */ + +import { resolve } from 'node:path'; +import { findProjectRoot as findProjectRootCore } from '@tm/core'; + +/** + * Get the project root directory with fallback to provided path + * + * This function intelligently detects the project root by looking for markers like: + * - .taskmaster directory (highest priority) + * - .git directory + * - package.json + * - Other project markers + * + * If a projectPath is explicitly provided, it will be resolved to an absolute path. + * Otherwise, it will attempt to find the project root starting from current directory. + * + * @param projectPath - Optional explicit project path from user + * @returns The project root directory path (always absolute) + * + * @example + * ```typescript + * // Auto-detect project root + * const root = getProjectRoot(); + * + * // Use explicit path if provided (resolved to absolute path) + * const root = getProjectRoot('./my-project'); // Resolves relative paths + * const root = getProjectRoot('/explicit/path'); // Already absolute, returned as-is + * ``` + */ +export function getProjectRoot(projectPath?: string): string { + // If explicitly provided, resolve it to an absolute path + // This handles relative paths and ensures Windows paths are normalized + if (projectPath) { + return resolve(projectPath); + } + + // Otherwise, intelligently find the project root + return findProjectRootCore(); +} diff --git a/jest.config.js b/jest.config.js index 3fb2c536..8dd620ce 100644 --- a/jest.config.js +++ b/jest.config.js @@ -43,12 +43,26 @@ export default { moduleDirectories: ['node_modules', ''], // Configure test coverage thresholds + // Note: ts-jest reports coverage against .ts files, not .js coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 + }, + // Critical code requires higher coverage + './src/utils/**/*.ts': { + branches: 70, + functions: 90, + lines: 90, + statements: 90 + }, + './src/middleware/**/*.ts': { + branches: 70, + functions: 85, + lines: 85, + statements: 85 } }, diff --git a/packages/tm-core/src/common/constants/index.ts b/packages/tm-core/src/common/constants/index.ts index 2d49e8e9..e72bcf00 100644 --- a/packages/tm-core/src/common/constants/index.ts +++ b/packages/tm-core/src/common/constants/index.ts @@ -80,3 +80,8 @@ export const STATUS_COLORS: Record = { * Provider constants - AI model providers */ export * from './providers.js'; + +/** + * Path constants - file paths and directory structure + */ +export * from './paths.js'; diff --git a/packages/tm-core/src/common/constants/paths.ts b/packages/tm-core/src/common/constants/paths.ts new file mode 100644 index 00000000..b12cd603 --- /dev/null +++ b/packages/tm-core/src/common/constants/paths.ts @@ -0,0 +1,83 @@ +/** + * @fileoverview Path constants for Task Master Core + * Defines all file paths and directory structure constants + */ + +// .taskmaster directory structure paths +export const TASKMASTER_DIR = '.taskmaster'; +export const TASKMASTER_TASKS_DIR = '.taskmaster/tasks'; +export const TASKMASTER_DOCS_DIR = '.taskmaster/docs'; +export const TASKMASTER_REPORTS_DIR = '.taskmaster/reports'; +export const TASKMASTER_TEMPLATES_DIR = '.taskmaster/templates'; + +// Task Master configuration files +export const TASKMASTER_CONFIG_FILE = '.taskmaster/config.json'; +export const TASKMASTER_STATE_FILE = '.taskmaster/state.json'; +export const LEGACY_CONFIG_FILE = '.taskmasterconfig'; + +// Task Master report files +export const COMPLEXITY_REPORT_FILE = + '.taskmaster/reports/task-complexity-report.json'; +export const LEGACY_COMPLEXITY_REPORT_FILE = + 'scripts/task-complexity-report.json'; + +// Task Master PRD file paths +export const PRD_FILE = '.taskmaster/docs/prd.txt'; +export const LEGACY_PRD_FILE = 'scripts/prd.txt'; + +// Task Master template files +export const EXAMPLE_PRD_FILE = '.taskmaster/templates/example_prd.txt'; +export const LEGACY_EXAMPLE_PRD_FILE = 'scripts/example_prd.txt'; + +// Task Master task file paths +export const TASKMASTER_TASKS_FILE = '.taskmaster/tasks/tasks.json'; +export const LEGACY_TASKS_FILE = 'tasks/tasks.json'; + +// General project files (not Task Master specific but commonly used) +export const ENV_EXAMPLE_FILE = '.env.example'; +export const GITIGNORE_FILE = '.gitignore'; + +// Task file naming pattern +export const TASK_FILE_PREFIX = 'task_'; +export const TASK_FILE_EXTENSION = '.txt'; + +/** + * Task Master specific markers (absolute highest priority) + * ONLY truly Task Master-specific markers that uniquely identify a Task Master project + */ +export const TASKMASTER_PROJECT_MARKERS = [ + '.taskmaster', // Task Master directory + TASKMASTER_CONFIG_FILE, // .taskmaster/config.json + TASKMASTER_TASKS_FILE, // .taskmaster/tasks/tasks.json + LEGACY_CONFIG_FILE // .taskmasterconfig (legacy but still Task Master-specific) +] as const; + +/** + * Other project markers (only checked if no Task Master markers found) + * Includes generic task files that could belong to any task runner/build system + */ +export const OTHER_PROJECT_MARKERS = [ + LEGACY_TASKS_FILE, // tasks/tasks.json (NOT Task Master-specific) + 'tasks.json', // Generic tasks file (NOT Task Master-specific) + '.git', // Git repository + '.svn', // SVN repository + 'package.json', // Node.js project + 'yarn.lock', // Yarn project + 'package-lock.json', // npm project + 'pnpm-lock.yaml', // pnpm project + 'Cargo.toml', // Rust project + 'go.mod', // Go project + 'pyproject.toml', // Python project + 'requirements.txt', // Python project + 'Gemfile', // Ruby project + 'composer.json' // PHP project +] as const; + +/** + * All project markers combined (for backward compatibility) + * @deprecated Use TASKMASTER_PROJECT_MARKERS and OTHER_PROJECT_MARKERS separately + */ +export const PROJECT_MARKERS = [ + ...TASKMASTER_PROJECT_MARKERS, + ...OTHER_PROJECT_MARKERS +] as const; diff --git a/packages/tm-core/src/common/utils/index.ts b/packages/tm-core/src/common/utils/index.ts index 0e88e30c..3c6bf1a5 100644 --- a/packages/tm-core/src/common/utils/index.ts +++ b/packages/tm-core/src/common/utils/index.ts @@ -47,6 +47,12 @@ export { compareRunIds } from './run-id-generator.js'; +// Export project root finding utilities +export { + findProjectRoot, + normalizeProjectRoot +} from './project-root-finder.js'; + // Additional utility exports /** diff --git a/packages/tm-core/src/common/utils/project-root-finder.spec.ts b/packages/tm-core/src/common/utils/project-root-finder.spec.ts new file mode 100644 index 00000000..ea9a06bd --- /dev/null +++ b/packages/tm-core/src/common/utils/project-root-finder.spec.ts @@ -0,0 +1,267 @@ +/** + * @fileoverview Tests for project root finder utilities + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + findProjectRoot, + normalizeProjectRoot +} from './project-root-finder.js'; + +describe('findProjectRoot', () => { + let tempDir: string; + let originalCwd: string; + + beforeEach(() => { + // Save original working directory + originalCwd = process.cwd(); + // Create a temporary directory for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-test-')); + }); + + afterEach(() => { + // Restore original working directory + process.chdir(originalCwd); + // Clean up temp directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('Task Master marker detection', () => { + it('should find .taskmaster directory in current directory', () => { + const taskmasterDir = path.join(tempDir, '.taskmaster'); + fs.mkdirSync(taskmasterDir); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + + it('should find .taskmaster directory in parent directory', () => { + const parentDir = tempDir; + const childDir = path.join(tempDir, 'child'); + const taskmasterDir = path.join(parentDir, '.taskmaster'); + + fs.mkdirSync(taskmasterDir); + fs.mkdirSync(childDir); + + const result = findProjectRoot(childDir); + expect(result).toBe(parentDir); + }); + + it('should find .taskmaster/config.json marker', () => { + const configDir = path.join(tempDir, '.taskmaster'); + fs.mkdirSync(configDir); + fs.writeFileSync(path.join(configDir, 'config.json'), '{}'); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + + it('should find .taskmaster/tasks/tasks.json marker', () => { + const tasksDir = path.join(tempDir, '.taskmaster', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(tasksDir, 'tasks.json'), '{}'); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + + it('should find .taskmasterconfig (legacy) marker', () => { + fs.writeFileSync(path.join(tempDir, '.taskmasterconfig'), '{}'); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + }); + + describe('Monorepo behavior - Task Master markers take precedence', () => { + it('should find .taskmaster in parent when starting from apps subdirectory', () => { + // Simulate exact user scenario: + // /project/.taskmaster exists + // Starting from /project/apps + const projectRoot = tempDir; + const appsDir = path.join(tempDir, 'apps'); + const taskmasterDir = path.join(projectRoot, '.taskmaster'); + + fs.mkdirSync(taskmasterDir); + fs.mkdirSync(appsDir); + + // When called from apps directory + const result = findProjectRoot(appsDir); + // Should return project root (one level up) + expect(result).toBe(projectRoot); + }); + + it('should prioritize .taskmaster in parent over .git in child', () => { + // Create structure: /parent/.taskmaster and /parent/child/.git + const parentDir = tempDir; + const childDir = path.join(tempDir, 'child'); + const gitDir = path.join(childDir, '.git'); + const taskmasterDir = path.join(parentDir, '.taskmaster'); + + fs.mkdirSync(taskmasterDir); + fs.mkdirSync(childDir); + fs.mkdirSync(gitDir); + + // When called from child directory + const result = findProjectRoot(childDir); + // Should return parent (with .taskmaster), not child (with .git) + expect(result).toBe(parentDir); + }); + + it('should prioritize .taskmaster in grandparent over package.json in child', () => { + // Create structure: /grandparent/.taskmaster and /grandparent/parent/child/package.json + const grandparentDir = tempDir; + const parentDir = path.join(tempDir, 'parent'); + const childDir = path.join(parentDir, 'child'); + const taskmasterDir = path.join(grandparentDir, '.taskmaster'); + + fs.mkdirSync(taskmasterDir); + fs.mkdirSync(parentDir); + fs.mkdirSync(childDir); + fs.writeFileSync(path.join(childDir, 'package.json'), '{}'); + + const result = findProjectRoot(childDir); + expect(result).toBe(grandparentDir); + }); + + it('should prioritize .taskmaster over multiple other project markers', () => { + // Create structure with many markers + const parentDir = tempDir; + const childDir = path.join(tempDir, 'packages', 'my-package'); + const taskmasterDir = path.join(parentDir, '.taskmaster'); + + fs.mkdirSync(taskmasterDir); + fs.mkdirSync(childDir, { recursive: true }); + + // Add multiple other project markers in child + fs.mkdirSync(path.join(childDir, '.git')); + fs.writeFileSync(path.join(childDir, 'package.json'), '{}'); + fs.writeFileSync(path.join(childDir, 'go.mod'), ''); + fs.writeFileSync(path.join(childDir, 'Cargo.toml'), ''); + + const result = findProjectRoot(childDir); + // Should still return parent with .taskmaster + expect(result).toBe(parentDir); + }); + }); + + describe('Other project marker detection (when no Task Master markers)', () => { + it('should find .git directory', () => { + const gitDir = path.join(tempDir, '.git'); + fs.mkdirSync(gitDir); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + + it('should find package.json', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}'); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + + it('should find go.mod', () => { + fs.writeFileSync(path.join(tempDir, 'go.mod'), ''); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + + it('should find Cargo.toml (Rust)', () => { + fs.writeFileSync(path.join(tempDir, 'Cargo.toml'), ''); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + + it('should find pyproject.toml (Python)', () => { + fs.writeFileSync(path.join(tempDir, 'pyproject.toml'), ''); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + }); + + describe('Edge cases', () => { + it('should return current directory if no markers found', () => { + const result = findProjectRoot(tempDir); + // Should fall back to process.cwd() + expect(result).toBe(process.cwd()); + }); + + it('should handle permission errors gracefully', () => { + // This test is hard to implement portably, but the function should handle it + const result = findProjectRoot(tempDir); + expect(typeof result).toBe('string'); + }); + + it('should not traverse more than 50 levels', () => { + // Create a deep directory structure + let deepDir = tempDir; + for (let i = 0; i < 60; i++) { + deepDir = path.join(deepDir, `level${i}`); + } + // Don't actually create it, just test the function doesn't hang + const result = findProjectRoot(deepDir); + expect(typeof result).toBe('string'); + }); + + it('should handle being called from filesystem root', () => { + const rootDir = path.parse(tempDir).root; + const result = findProjectRoot(rootDir); + expect(typeof result).toBe('string'); + }); + }); +}); + +describe('normalizeProjectRoot', () => { + it('should remove .taskmaster from path', () => { + const result = normalizeProjectRoot('/project/.taskmaster'); + expect(result).toBe('/project'); + }); + + it('should remove .taskmaster/subdirectory from path', () => { + const result = normalizeProjectRoot('/project/.taskmaster/tasks'); + expect(result).toBe('/project'); + }); + + it('should return unchanged path if no .taskmaster', () => { + const result = normalizeProjectRoot('/project/src'); + expect(result).toBe('/project/src'); + }); + + it('should handle paths with native separators', () => { + // Use native path separators for the test + const testPath = ['project', '.taskmaster', 'tasks'].join(path.sep); + const expectedPath = 'project'; + const result = normalizeProjectRoot(testPath); + expect(result).toBe(expectedPath); + }); + + it('should handle empty string', () => { + const result = normalizeProjectRoot(''); + expect(result).toBe(''); + }); + + it('should handle null', () => { + const result = normalizeProjectRoot(null); + expect(result).toBe(''); + }); + + it('should handle undefined', () => { + const result = normalizeProjectRoot(undefined); + expect(result).toBe(''); + }); + + it('should handle root .taskmaster', () => { + const sep = path.sep; + const result = normalizeProjectRoot(`${sep}.taskmaster`); + expect(result).toBe(sep); + }); +}); diff --git a/packages/tm-core/src/common/utils/project-root-finder.ts b/packages/tm-core/src/common/utils/project-root-finder.ts new file mode 100644 index 00000000..e20ac87c --- /dev/null +++ b/packages/tm-core/src/common/utils/project-root-finder.ts @@ -0,0 +1,155 @@ +/** + * @fileoverview Project root detection utilities + * Provides functionality to locate project roots by searching for marker files/directories + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import { + TASKMASTER_PROJECT_MARKERS, + OTHER_PROJECT_MARKERS +} from '../constants/paths.js'; + +/** + * Find the project root directory by looking for project markers + * Traverses upwards from startDir until a project marker is found or filesystem root is reached + * Limited to 50 parent directory levels to prevent excessive traversal + * + * Strategy: First searches ALL parent directories for .taskmaster (highest priority). + * If not found, then searches for other project markers starting from current directory. + * This ensures .taskmaster in parent directories takes precedence over other markers in subdirectories. + * + * @param startDir - Directory to start searching from (defaults to process.cwd()) + * @returns Project root path (falls back to current directory if no markers found) + * + * @example + * ```typescript + * // In a monorepo structure: + * // /project/.taskmaster + * // /project/packages/my-package/.git + * // When called from /project/packages/my-package: + * const root = findProjectRoot(); // Returns /project (not /project/packages/my-package) + * ``` + */ +export function findProjectRoot(startDir: string = process.cwd()): string { + let currentDir = path.resolve(startDir); + const rootDir = path.parse(currentDir).root; + const maxDepth = 50; // Reasonable limit to prevent infinite loops + let depth = 0; + + // FIRST PASS: Traverse ALL parent directories looking ONLY for Task Master markers + // This ensures that a .taskmaster in a parent directory takes precedence over + // other project markers (like .git, go.mod, etc.) in subdirectories + let searchDir = currentDir; + depth = 0; + + while (depth < maxDepth) { + for (const marker of TASKMASTER_PROJECT_MARKERS) { + const markerPath = path.join(searchDir, marker); + try { + if (fs.existsSync(markerPath)) { + // Found a Task Master marker - this is our project root + return searchDir; + } + } catch (error) { + // Ignore permission errors and continue searching + continue; + } + } + + // If we're at root, stop after checking it + if (searchDir === rootDir) { + break; + } + + // Move up one directory level + const parentDir = path.dirname(searchDir); + + // Safety check: if dirname returns the same path, we've hit the root + if (parentDir === searchDir) { + break; + } + + searchDir = parentDir; + depth++; + } + + // SECOND PASS: No Task Master markers found in any parent directory + // Now search for other project markers starting from the original directory + currentDir = path.resolve(startDir); + depth = 0; + + while (depth < maxDepth) { + for (const marker of OTHER_PROJECT_MARKERS) { + const markerPath = path.join(currentDir, marker); + try { + if (fs.existsSync(markerPath)) { + // Found another project marker - return this as project root + return currentDir; + } + } catch (error) { + // Ignore permission errors and continue searching + continue; + } + } + + // If we're at root, stop after checking it + if (currentDir === rootDir) { + break; + } + + // Move up one directory level + const parentDir = path.dirname(currentDir); + + // Safety check: if dirname returns the same path, we've hit the root + if (parentDir === currentDir) { + break; + } + + currentDir = parentDir; + depth++; + } + + // Fallback to current working directory if no project root found + // This ensures the function always returns a valid, existing path + return process.cwd(); +} + +/** + * Normalize project root to ensure it doesn't end with .taskmaster + * This prevents double .taskmaster paths when using constants that include .taskmaster + * + * @param projectRoot - The project root path to normalize + * @returns Normalized project root path + * + * @example + * ```typescript + * normalizeProjectRoot('/project/.taskmaster'); // Returns '/project' + * normalizeProjectRoot('/project'); // Returns '/project' + * normalizeProjectRoot('/project/.taskmaster/tasks'); // Returns '/project' + * ``` + */ +export function normalizeProjectRoot( + projectRoot: string | null | undefined +): string { + if (!projectRoot) return projectRoot || ''; + + // Ensure it's a string + const projectRootStr = String(projectRoot); + + // Split the path into segments + const segments = projectRootStr.split(path.sep); + + // Find the index of .taskmaster segment + const taskmasterIndex = segments.findIndex( + (segment) => segment === '.taskmaster' + ); + + if (taskmasterIndex !== -1) { + // If .taskmaster is found, return everything up to but not including .taskmaster + const normalizedSegments = segments.slice(0, taskmasterIndex); + return normalizedSegments.join(path.sep) || path.sep; + } + + return projectRootStr; +} diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index 03577ac6..d0a706b4 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -42,6 +42,9 @@ export * from './common/constants/index.js'; // Errors export * from './common/errors/index.js'; +// Utils +export * from './common/utils/index.js'; + // ========== Domain-Specific Type Exports ========== // Task types diff --git a/scripts/dev.js b/scripts/dev.js index 8902d390..91701aa0 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -9,9 +9,22 @@ */ import dotenv from 'dotenv'; +import { findProjectRoot } from '@tm/core'; +import { join } from 'node:path'; -// Load .env BEFORE any other imports to ensure env vars are available -dotenv.config(); +// Store the original working directory +// This is needed for commands that take relative paths as arguments +const originalCwd = process.cwd(); + +// Find project root for .env loading +// We don't change the working directory to avoid breaking relative path logic +const projectRoot = findProjectRoot(); + +// Load .env from project root without changing cwd +dotenv.config({ path: join(projectRoot, '.env') }); + +// Make original cwd available to commands that need it +process.env.TASKMASTER_ORIGINAL_CWD = originalCwd; // Add at the very beginning of the file if (process.env.DEBUG === '1') { diff --git a/src/utils/path-utils.js b/src/utils/path-utils.js index e419f00f..40a198e0 100644 --- a/src/utils/path-utils.js +++ b/src/utils/path-utils.js @@ -1,6 +1,10 @@ /** * Path utility functions for Task Master * Provides centralized path resolution logic for both CLI and MCP use cases + * + * NOTE: This file is a legacy wrapper around @tm/core utilities. + * New code should import directly from @tm/core instead. + * This file exists for backward compatibility during the migration period. */ import path from 'path'; @@ -15,103 +19,38 @@ import { LEGACY_CONFIG_FILE } from '../constants/paths.js'; import { getLoggerOrDefault } from './logger-utils.js'; +import { + findProjectRoot as findProjectRootCore, + normalizeProjectRoot as normalizeProjectRootCore +} from '@tm/core'; /** * Normalize project root to ensure it doesn't end with .taskmaster * This prevents double .taskmaster paths when using constants that include .taskmaster + * + * @deprecated Use the TypeScript implementation from @tm/core instead * @param {string} projectRoot - The project root path to normalize * @returns {string} - Normalized project root path */ export function normalizeProjectRoot(projectRoot) { - if (!projectRoot) return projectRoot; - - // Ensure it's a string - projectRoot = String(projectRoot); - - // Split the path into segments - const segments = projectRoot.split(path.sep); - - // Find the index of .taskmaster segment - const taskmasterIndex = segments.findIndex( - (segment) => segment === '.taskmaster' - ); - - if (taskmasterIndex !== -1) { - // If .taskmaster is found, return everything up to but not including .taskmaster - const normalizedSegments = segments.slice(0, taskmasterIndex); - return normalizedSegments.join(path.sep) || path.sep; - } - - return projectRoot; + return normalizeProjectRootCore(projectRoot); } /** * Find the project root directory by looking for project markers * Traverses upwards from startDir until a project marker is found or filesystem root is reached * Limited to 50 parent directory levels to prevent excessive traversal + * + * Strategy: First searches ALL parent directories for .taskmaster (highest priority). + * If not found, then searches for other project markers starting from current directory. + * This ensures .taskmaster in parent directories takes precedence over other markers in subdirectories. + * + * @deprecated Use the TypeScript implementation from @tm/core instead * @param {string} startDir - Directory to start searching from (defaults to process.cwd()) * @returns {string} - Project root path (falls back to current directory if no markers found) */ export function findProjectRoot(startDir = process.cwd()) { - // Define project markers that indicate a project root - // Prioritize Task Master specific markers first - const projectMarkers = [ - '.taskmaster', // Task Master directory (highest priority) - TASKMASTER_CONFIG_FILE, // .taskmaster/config.json - TASKMASTER_TASKS_FILE, // .taskmaster/tasks/tasks.json - LEGACY_CONFIG_FILE, // .taskmasterconfig (legacy) - LEGACY_TASKS_FILE, // tasks/tasks.json (legacy) - 'tasks.json', // Root tasks.json (legacy) - '.git', // Git repository - '.svn', // SVN repository - 'package.json', // Node.js project - 'yarn.lock', // Yarn project - 'package-lock.json', // npm project - 'pnpm-lock.yaml', // pnpm project - 'Cargo.toml', // Rust project - 'go.mod', // Go project - 'pyproject.toml', // Python project - 'requirements.txt', // Python project - 'Gemfile', // Ruby project - 'composer.json' // PHP project - ]; - - let currentDir = path.resolve(startDir); - const rootDir = path.parse(currentDir).root; - const maxDepth = 50; // Reasonable limit to prevent infinite loops - let depth = 0; - - // Traverse upwards looking for project markers - while (currentDir !== rootDir && depth < maxDepth) { - // Check if current directory contains any project markers - for (const marker of projectMarkers) { - const markerPath = path.join(currentDir, marker); - try { - if (fs.existsSync(markerPath)) { - // Found a project marker - return this directory as project root - return currentDir; - } - } catch (error) { - // Ignore permission errors and continue searching - continue; - } - } - - // Move up one directory level - const parentDir = path.dirname(currentDir); - - // Safety check: if dirname returns the same path, we've hit the root - if (parentDir === currentDir) { - break; - } - - currentDir = parentDir; - depth++; - } - - // Fallback to current working directory if no project root found - // This ensures the function always returns a valid path - return process.cwd(); + return findProjectRootCore(startDir); } /** @@ -200,9 +139,14 @@ export function findPRDPath(explicitPath = null, args = null, log = null) { // 1. If explicit path is provided, use it (highest priority) if (explicitPath) { + // Use original cwd if available (set by dev.js), otherwise current cwd + // This ensures relative paths are resolved from where the user invoked the command + const cwdForResolution = + process.env.TASKMASTER_ORIGINAL_CWD || process.cwd(); + const resolvedPath = path.isAbsolute(explicitPath) ? explicitPath - : path.resolve(process.cwd(), explicitPath); + : path.resolve(cwdForResolution, explicitPath); if (fs.existsSync(resolvedPath)) { logger.info?.(`Using explicit PRD path: ${resolvedPath}`); @@ -272,9 +216,13 @@ export function findComplexityReportPath( // 1. If explicit path is provided, use it (highest priority) if (explicitPath) { + // Use original cwd if available (set by dev.js), otherwise current cwd + const cwdForResolution = + process.env.TASKMASTER_ORIGINAL_CWD || process.cwd(); + const resolvedPath = path.isAbsolute(explicitPath) ? explicitPath - : path.resolve(process.cwd(), explicitPath); + : path.resolve(cwdForResolution, explicitPath); if (fs.existsSync(resolvedPath)) { logger.info?.(`Using explicit complexity report path: ${resolvedPath}`); @@ -353,9 +301,13 @@ export function resolveTasksOutputPath( // 1. If explicit path is provided, use it if (explicitPath) { + // Use original cwd if available (set by dev.js), otherwise current cwd + const cwdForResolution = + process.env.TASKMASTER_ORIGINAL_CWD || process.cwd(); + const resolvedPath = path.isAbsolute(explicitPath) ? explicitPath - : path.resolve(process.cwd(), explicitPath); + : path.resolve(cwdForResolution, explicitPath); logger.info?.(`Using explicit output path: ${resolvedPath}`); return resolvedPath; @@ -399,9 +351,13 @@ export function resolveComplexityReportOutputPath( // 1. If explicit path is provided, use it if (explicitPath) { + // Use original cwd if available (set by dev.js), otherwise current cwd + const cwdForResolution = + process.env.TASKMASTER_ORIGINAL_CWD || process.cwd(); + const resolvedPath = path.isAbsolute(explicitPath) ? explicitPath - : path.resolve(process.cwd(), explicitPath); + : path.resolve(cwdForResolution, explicitPath); logger.info?.( `Using explicit complexity report output path: ${resolvedPath}` @@ -448,9 +404,13 @@ export function findConfigPath(explicitPath = null, args = null, log = null) { // 1. If explicit path is provided, use it (highest priority) if (explicitPath) { + // Use original cwd if available (set by dev.js), otherwise current cwd + const cwdForResolution = + process.env.TASKMASTER_ORIGINAL_CWD || process.cwd(); + const resolvedPath = path.isAbsolute(explicitPath) ? explicitPath - : path.resolve(process.cwd(), explicitPath); + : path.resolve(cwdForResolution, explicitPath); if (fs.existsSync(resolvedPath)) { logger.info?.(`Using explicit config path: ${resolvedPath}`); diff --git a/tests/integration/cli/complex-cross-tag-scenarios.test.js b/tests/integration/cli/complex-cross-tag-scenarios.test.js index eaf0f7a9..fa65a07e 100644 --- a/tests/integration/cli/complex-cross-tag-scenarios.test.js +++ b/tests/integration/cli/complex-cross-tag-scenarios.test.js @@ -2,6 +2,7 @@ import { jest } from '@jest/globals'; import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; +import os from 'os'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -22,8 +23,8 @@ describe('Complex Cross-Tag Scenarios', () => { ); beforeEach(() => { - // Create test directory - testDir = fs.mkdtempSync(path.join(__dirname, 'test-')); + // Create test directory in OS temp directory to isolate from project + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-test-')); process.chdir(testDir); // Keep integration timings deterministic process.env.TASKMASTER_SKIP_AUTO_UPDATE = '1'; diff --git a/tests/unit/path-utils-find-project-root.test.js b/tests/unit/path-utils-find-project-root.test.js deleted file mode 100644 index 746c581a..00000000 --- a/tests/unit/path-utils-find-project-root.test.js +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Unit tests for findProjectRoot() function - * Tests the parent directory traversal functionality - */ - -import { jest } from '@jest/globals'; -import path from 'path'; -import fs from 'fs'; - -// Import the function to test -import { findProjectRoot } from '../../src/utils/path-utils.js'; - -describe('findProjectRoot', () => { - describe('Parent Directory Traversal', () => { - test('should find .taskmaster in parent directory', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - mockExistsSync.mockImplementation((checkPath) => { - const normalized = path.normalize(checkPath); - // .taskmaster exists only at /project - return normalized === path.normalize('/project/.taskmaster'); - }); - - const result = findProjectRoot('/project/subdir'); - - expect(result).toBe('/project'); - - mockExistsSync.mockRestore(); - }); - - test('should find .git in parent directory', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - mockExistsSync.mockImplementation((checkPath) => { - const normalized = path.normalize(checkPath); - return normalized === path.normalize('/project/.git'); - }); - - const result = findProjectRoot('/project/subdir'); - - expect(result).toBe('/project'); - - mockExistsSync.mockRestore(); - }); - - test('should find package.json in parent directory', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - mockExistsSync.mockImplementation((checkPath) => { - const normalized = path.normalize(checkPath); - return normalized === path.normalize('/project/package.json'); - }); - - const result = findProjectRoot('/project/subdir'); - - expect(result).toBe('/project'); - - mockExistsSync.mockRestore(); - }); - - test('should traverse multiple levels to find project root', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - mockExistsSync.mockImplementation((checkPath) => { - const normalized = path.normalize(checkPath); - // Only exists at /project, not in any subdirectories - return normalized === path.normalize('/project/.taskmaster'); - }); - - const result = findProjectRoot('/project/subdir/deep/nested'); - - expect(result).toBe('/project'); - - mockExistsSync.mockRestore(); - }); - - test('should return current directory as fallback when no markers found', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - // No project markers exist anywhere - mockExistsSync.mockReturnValue(false); - - const result = findProjectRoot('/some/random/path'); - - // Should fall back to process.cwd() - expect(result).toBe(process.cwd()); - - mockExistsSync.mockRestore(); - }); - - test('should find markers at current directory before checking parent', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - mockExistsSync.mockImplementation((checkPath) => { - const normalized = path.normalize(checkPath); - // .git exists at /project/subdir, .taskmaster exists at /project - if (normalized.includes('/project/subdir/.git')) return true; - if (normalized.includes('/project/.taskmaster')) return true; - return false; - }); - - const result = findProjectRoot('/project/subdir'); - - // Should find /project/subdir first because .git exists there, - // even though .taskmaster is earlier in the marker array - expect(result).toBe('/project/subdir'); - - mockExistsSync.mockRestore(); - }); - - test('should handle permission errors gracefully', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - mockExistsSync.mockImplementation((checkPath) => { - const normalized = path.normalize(checkPath); - // Throw permission error for checks in /project/subdir - if (normalized.startsWith('/project/subdir/')) { - throw new Error('EACCES: permission denied'); - } - // Return true only for .taskmaster at /project - return normalized.includes('/project/.taskmaster'); - }); - - const result = findProjectRoot('/project/subdir'); - - // Should handle permission errors in subdirectory and traverse to parent - expect(result).toBe('/project'); - - mockExistsSync.mockRestore(); - }); - - test('should detect filesystem root correctly', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - // No markers exist - mockExistsSync.mockReturnValue(false); - - const result = findProjectRoot('/'); - - // Should stop at root and fall back to process.cwd() - expect(result).toBe(process.cwd()); - - mockExistsSync.mockRestore(); - }); - - test('should recognize various project markers', () => { - const projectMarkers = [ - '.taskmaster', - '.git', - 'package.json', - 'Cargo.toml', - 'go.mod', - 'pyproject.toml', - 'requirements.txt', - 'Gemfile', - 'composer.json' - ]; - - projectMarkers.forEach((marker) => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - mockExistsSync.mockImplementation((checkPath) => { - const normalized = path.normalize(checkPath); - return normalized.includes(`/project/${marker}`); - }); - - const result = findProjectRoot('/project/subdir'); - - expect(result).toBe('/project'); - - mockExistsSync.mockRestore(); - }); - }); - }); - - describe('Edge Cases', () => { - test('should handle empty string as startDir', () => { - const result = findProjectRoot(''); - - // Should use process.cwd() or fall back appropriately - expect(typeof result).toBe('string'); - expect(result.length).toBeGreaterThan(0); - }); - - test('should handle relative paths', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - mockExistsSync.mockImplementation((checkPath) => { - // Simulate .git existing in the resolved path - return checkPath.includes('.git'); - }); - - const result = findProjectRoot('./subdir'); - - expect(typeof result).toBe('string'); - - mockExistsSync.mockRestore(); - }); - - test('should not exceed max depth limit', () => { - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - - // Track how many times existsSync is called - let callCount = 0; - mockExistsSync.mockImplementation(() => { - callCount++; - return false; // Never find a marker - }); - - // Create a very deep path - const deepPath = '/a/'.repeat(100) + 'deep'; - const result = findProjectRoot(deepPath); - - // Should stop after max depth (50) and not check 100 levels - // Each level checks multiple markers, so callCount will be high but bounded - expect(callCount).toBeLessThan(1000); // Reasonable upper bound - // With 18 markers and max depth of 50, expect around 900 calls maximum - expect(callCount).toBeLessThanOrEqual(50 * 18); - - mockExistsSync.mockRestore(); - }); - }); -});