fix: prioritize .taskmaster in parent directories over other project markers (#1351)

Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
This commit is contained in:
Ben Coombs
2025-11-12 13:13:06 +00:00
committed by GitHub
parent e108f4310c
commit 37aee7809c
27 changed files with 737 additions and 343 deletions

View File

@@ -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.

View File

@@ -12,6 +12,7 @@ import {
OutputFormatter OutputFormatter
} from './shared.js'; } from './shared.js';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import { getProjectRoot } from '../../utils/project-root.js';
interface AbortOptions extends AutopilotBaseOptions { interface AbortOptions extends AutopilotBaseOptions {
force?: boolean; force?: boolean;
@@ -34,16 +35,29 @@ export class AbortCommand extends Command {
private async execute(options: AbortOptions): Promise<void> { private async execute(options: AbortOptions): Promise<void> {
// Inherit parent options // Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions; 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, ...parentOpts,
...options, ...options,
projectRoot: projectRoot: '' // Will be set in try block
options.projectRoot || parentOpts?.projectRoot || process.cwd()
}; };
const formatter = new OutputFormatter(mergedOptions.json || false); const formatter = new OutputFormatter(
options.json || parentOpts?.json || false
);
try { 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 // Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!); const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) { if (!hasState) {

View File

@@ -5,6 +5,7 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { WorkflowService, GitAdapter, CommitMessageGenerator } from '@tm/core'; import { WorkflowService, GitAdapter, CommitMessageGenerator } from '@tm/core';
import { AutopilotBaseOptions, OutputFormatter } from './shared.js'; import { AutopilotBaseOptions, OutputFormatter } from './shared.js';
import { getProjectRoot } from '../../utils/project-root.js';
type CommitOptions = AutopilotBaseOptions; type CommitOptions = AutopilotBaseOptions;
@@ -28,8 +29,9 @@ export class CommitCommand extends Command {
const mergedOptions: CommitOptions = { const mergedOptions: CommitOptions = {
...parentOpts, ...parentOpts,
...options, ...options,
projectRoot: projectRoot: getProjectRoot(
options.projectRoot || parentOpts?.projectRoot || process.cwd() options.projectRoot || parentOpts?.projectRoot
)
}; };
const formatter = new OutputFormatter(mergedOptions.json || false); const formatter = new OutputFormatter(mergedOptions.json || false);

View File

@@ -4,6 +4,7 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { WorkflowOrchestrator, TestResult } from '@tm/core'; import { WorkflowOrchestrator, TestResult } from '@tm/core';
import { getProjectRoot } from '../../utils/project-root.js';
import { import {
AutopilotBaseOptions, AutopilotBaseOptions,
hasWorkflowState, hasWorkflowState,
@@ -40,8 +41,9 @@ export class CompleteCommand extends Command {
const mergedOptions: CompleteOptions = { const mergedOptions: CompleteOptions = {
...parentOpts, ...parentOpts,
...options, ...options,
projectRoot: projectRoot: getProjectRoot(
options.projectRoot || parentOpts?.projectRoot || process.cwd() options.projectRoot || parentOpts?.projectRoot
)
}; };
const formatter = new OutputFormatter(mergedOptions.json || false); const formatter = new OutputFormatter(mergedOptions.json || false);

View File

@@ -37,8 +37,7 @@ export class AutopilotCommand extends Command {
.option('-v, --verbose', 'Enable verbose output') .option('-v, --verbose', 'Enable verbose output')
.option( .option(
'-p, --project-root <path>', '-p, --project-root <path>',
'Project root directory', 'Project root directory (auto-detected if not specified)'
process.cwd()
); );
// Register subcommands // Register subcommands

View File

@@ -4,6 +4,7 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core'; import { WorkflowOrchestrator } from '@tm/core';
import { getProjectRoot } from '../../utils/project-root.js';
import { import {
AutopilotBaseOptions, AutopilotBaseOptions,
hasWorkflowState, hasWorkflowState,
@@ -30,16 +31,29 @@ export class NextCommand extends Command {
private async execute(options: NextOptions): Promise<void> { private async execute(options: NextOptions): Promise<void> {
// Inherit parent options // Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions; 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, ...parentOpts,
...options, ...options,
projectRoot: projectRoot: '' // Will be set in try block
options.projectRoot || parentOpts?.projectRoot || process.cwd()
}; };
const formatter = new OutputFormatter(mergedOptions.json || false); const formatter = new OutputFormatter(
options.json || parentOpts?.json || false
);
try { 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 // Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!); const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) { if (!hasState) {

View File

@@ -10,6 +10,7 @@ import {
loadWorkflowState, loadWorkflowState,
OutputFormatter OutputFormatter
} from './shared.js'; } from './shared.js';
import { getProjectRoot } from '../../utils/project-root.js';
type ResumeOptions = AutopilotBaseOptions; type ResumeOptions = AutopilotBaseOptions;
@@ -33,8 +34,9 @@ export class ResumeCommand extends Command {
const mergedOptions: ResumeOptions = { const mergedOptions: ResumeOptions = {
...parentOpts, ...parentOpts,
...options, ...options,
projectRoot: projectRoot: getProjectRoot(
options.projectRoot || parentOpts?.projectRoot || process.cwd() options.projectRoot || parentOpts?.projectRoot
)
}; };
const formatter = new OutputFormatter(mergedOptions.json || false); const formatter = new OutputFormatter(mergedOptions.json || false);

View File

@@ -13,6 +13,7 @@ import {
validateTaskId, validateTaskId,
parseSubtasks parseSubtasks
} from './shared.js'; } from './shared.js';
import { getProjectRoot } from '../../utils/project-root.js';
interface StartOptions extends AutopilotBaseOptions { interface StartOptions extends AutopilotBaseOptions {
force?: boolean; force?: boolean;
@@ -41,8 +42,9 @@ export class StartCommand extends Command {
const mergedOptions: StartOptions = { const mergedOptions: StartOptions = {
...parentOpts, ...parentOpts,
...options, ...options,
projectRoot: projectRoot: getProjectRoot(
options.projectRoot || parentOpts?.projectRoot || process.cwd() options.projectRoot || parentOpts?.projectRoot
)
}; };
const formatter = new OutputFormatter(mergedOptions.json || false); const formatter = new OutputFormatter(mergedOptions.json || false);

View File

@@ -10,6 +10,7 @@ import {
loadWorkflowState, loadWorkflowState,
OutputFormatter OutputFormatter
} from './shared.js'; } from './shared.js';
import { getProjectRoot } from '../../utils/project-root.js';
type StatusOptions = AutopilotBaseOptions; type StatusOptions = AutopilotBaseOptions;
@@ -33,8 +34,9 @@ export class StatusCommand extends Command {
const mergedOptions: StatusOptions = { const mergedOptions: StatusOptions = {
...parentOpts, ...parentOpts,
...options, ...options,
projectRoot: projectRoot: getProjectRoot(
options.projectRoot || parentOpts?.projectRoot || process.cwd() options.projectRoot || parentOpts?.projectRoot
)
}; };
const formatter = new OutputFormatter(mergedOptions.json || false); const formatter = new OutputFormatter(mergedOptions.json || false);

View File

@@ -16,6 +16,7 @@ import {
} from '@tm/core'; } from '@tm/core';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js'; import { displayError } from '../utils/error-handler.js';
import { getProjectRoot } from '../utils/project-root.js';
/** /**
* Result type from export command * Result type from export command
@@ -76,7 +77,7 @@ export class ExportCommand extends Command {
try { try {
// Initialize TmCore // Initialize TmCore
this.taskMasterCore = await createTmCore({ this.taskMasterCore = await createTmCore({
projectPath: process.cwd() projectPath: getProjectRoot()
}); });
} catch (error) { } catch (error) {
throw new Error( throw new Error(

View File

@@ -19,6 +19,7 @@ import type { StorageType } from '@tm/core';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js'; import { displayError } from '../utils/error-handler.js';
import { displayCommandHeader } from '../utils/display-helpers.js'; import { displayCommandHeader } from '../utils/display-helpers.js';
import { getProjectRoot } from '../utils/project-root.js';
import { import {
displayDashboards, displayDashboards,
calculateTaskStatistics, calculateTaskStatistics,
@@ -77,7 +78,10 @@ export class ListTasksCommand extends Command {
'text' 'text'
) )
.option('--silent', 'Suppress output (useful for programmatic usage)') .option('--silent', 'Suppress output (useful for programmatic usage)')
.option('-p, --project <path>', 'Project root directory', process.cwd()) .option(
'-p, --project <path>',
'Project root directory (auto-detected if not provided)'
)
.action(async (options: ListCommandOptions) => { .action(async (options: ListCommandOptions) => {
await this.executeCommand(options); await this.executeCommand(options);
}); });
@@ -93,8 +97,8 @@ export class ListTasksCommand extends Command {
process.exit(1); process.exit(1);
} }
// Initialize tm-core // Initialize tm-core (project root auto-detected if not provided)
await this.initializeCore(options.project || process.cwd()); await this.initializeCore(getProjectRoot(options.project));
// Get tasks from core // Get tasks from core
const result = await this.getTasks(options); const result = await this.getTasks(options);

View File

@@ -12,6 +12,7 @@ import type { StorageType } from '@tm/core';
import { displayError } from '../utils/error-handler.js'; import { displayError } from '../utils/error-handler.js';
import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayCommandHeader } from '../utils/display-helpers.js'; import { displayCommandHeader } from '../utils/display-helpers.js';
import { getProjectRoot } from '../utils/project-root.js';
/** /**
* Options interface for the next command * Options interface for the next command
@@ -49,7 +50,10 @@ export class NextCommand extends Command {
.option('-t, --tag <tag>', 'Filter by tag') .option('-t, --tag <tag>', 'Filter by tag')
.option('-f, --format <format>', 'Output format (text, json)', 'text') .option('-f, --format <format>', 'Output format (text, json)', 'text')
.option('--silent', 'Suppress output (useful for programmatic usage)') .option('--silent', 'Suppress output (useful for programmatic usage)')
.option('-p, --project <path>', 'Project root directory', process.cwd()) .option(
'-p, --project <path>',
'Project root directory (auto-detected if not provided)'
)
.action(async (options: NextCommandOptions) => { .action(async (options: NextCommandOptions) => {
await this.executeCommand(options); await this.executeCommand(options);
}); });
@@ -65,7 +69,7 @@ export class NextCommand extends Command {
this.validateOptions(options); this.validateOptions(options);
// Initialize tm-core // Initialize tm-core
await this.initializeCore(options.project || process.cwd()); await this.initializeCore(getProjectRoot(options.project));
// Get next task from core // Get next task from core
const result = await this.getNextTask(options); const result = await this.getNextTask(options);

View File

@@ -9,6 +9,7 @@ import boxen from 'boxen';
import { createTmCore, type TmCore, type TaskStatus } from '@tm/core'; import { createTmCore, type TmCore, type TaskStatus } from '@tm/core';
import type { StorageType } from '@tm/core'; import type { StorageType } from '@tm/core';
import { displayError } from '../utils/error-handler.js'; import { displayError } from '../utils/error-handler.js';
import { getProjectRoot } from '../utils/project-root.js';
/** /**
* Valid task status values for validation * Valid task status values for validation
@@ -70,7 +71,10 @@ export class SetStatusCommand extends Command {
) )
.option('-f, --format <format>', 'Output format (text, json)', 'text') .option('-f, --format <format>', 'Output format (text, json)', 'text')
.option('--silent', 'Suppress output (useful for programmatic usage)') .option('--silent', 'Suppress output (useful for programmatic usage)')
.option('-p, --project <path>', 'Project root directory', process.cwd()) .option(
'-p, --project <path>',
'Project root directory (auto-detected if not provided)'
)
.action(async (options: SetStatusCommandOptions) => { .action(async (options: SetStatusCommandOptions) => {
await this.executeCommand(options); await this.executeCommand(options);
}); });
@@ -109,7 +113,7 @@ export class SetStatusCommand extends Command {
// Initialize TaskMaster core // Initialize TaskMaster core
this.tmCore = await createTmCore({ this.tmCore = await createTmCore({
projectPath: options.project || process.cwd() projectPath: getProjectRoot(options.project)
}); });
// Parse task IDs (handle comma-separated values) // Parse task IDs (handle comma-separated values)

View File

@@ -12,6 +12,7 @@ import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js'; import { displayError } from '../utils/error-handler.js';
import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayCommandHeader } from '../utils/display-helpers.js'; import { displayCommandHeader } from '../utils/display-helpers.js';
import { getProjectRoot } from '../utils/project-root.js';
/** /**
* Options interface for the show command * Options interface for the show command
@@ -64,7 +65,10 @@ export class ShowCommand extends Command {
.option('-s, --status <status>', 'Filter subtasks by status') .option('-s, --status <status>', 'Filter subtasks by status')
.option('-f, --format <format>', 'Output format (text, json)', 'text') .option('-f, --format <format>', 'Output format (text, json)', 'text')
.option('--silent', 'Suppress output (useful for programmatic usage)') .option('--silent', 'Suppress output (useful for programmatic usage)')
.option('-p, --project <path>', 'Project root directory', process.cwd()) .option(
'-p, --project <path>',
'Project root directory (auto-detected if not provided)'
)
.action( .action(
async (taskId: string | undefined, options: ShowCommandOptions) => { async (taskId: string | undefined, options: ShowCommandOptions) => {
await this.executeCommand(taskId, options); await this.executeCommand(taskId, options);
@@ -86,7 +90,7 @@ export class ShowCommand extends Command {
} }
// Initialize tm-core // Initialize tm-core
await this.initializeCore(options.project || process.cwd()); await this.initializeCore(getProjectRoot(options.project));
// Get the task ID from argument or option // Get the task ID from argument or option
const idArg = taskId || options.id; const idArg = taskId || options.id;

View File

@@ -17,6 +17,7 @@ import {
import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js'; import { displayError } from '../utils/error-handler.js';
import { getProjectRoot } from '../utils/project-root.js';
/** /**
* CLI-specific options interface for the start command * 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') .argument('[id]', 'Task ID to start working on')
.option('-i, --id <id>', 'Task ID to start working on') .option('-i, --id <id>', 'Task ID to start working on')
.option('-f, --format <format>', 'Output format (text, json)', 'text') .option('-f, --format <format>', 'Output format (text, json)', 'text')
.option('-p, --project <path>', 'Project root directory', process.cwd()) .option(
'-p, --project <path>',
'Project root directory (auto-detected if not provided)'
)
.option( .option(
'--dry-run', '--dry-run',
'Show what would be executed without launching claude-code' 'Show what would be executed without launching claude-code'
@@ -93,7 +97,7 @@ export class StartCommand extends Command {
// Initialize tm-core with spinner // Initialize tm-core with spinner
spinner = ora('Initializing Task Master...').start(); spinner = ora('Initializing Task Master...').start();
await this.initializeCore(options.project || process.cwd()); await this.initializeCore(getProjectRoot(options.project));
spinner.succeed('Task Master initialized'); spinner.succeed('Task Master initialized');
// Get the task ID from argument or option, or find next available task // Get the task ID from argument or option, or find next available task

View File

@@ -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();
}

View File

@@ -43,12 +43,26 @@ export default {
moduleDirectories: ['node_modules', '<rootDir>'], moduleDirectories: ['node_modules', '<rootDir>'],
// Configure test coverage thresholds // Configure test coverage thresholds
// Note: ts-jest reports coverage against .ts files, not .js
coverageThreshold: { coverageThreshold: {
global: { global: {
branches: 80, branches: 80,
functions: 80, functions: 80,
lines: 80, lines: 80,
statements: 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
} }
}, },

View File

@@ -80,3 +80,8 @@ export const STATUS_COLORS: Record<TaskStatus, string> = {
* Provider constants - AI model providers * Provider constants - AI model providers
*/ */
export * from './providers.js'; export * from './providers.js';
/**
* Path constants - file paths and directory structure
*/
export * from './paths.js';

View File

@@ -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;

View File

@@ -47,6 +47,12 @@ export {
compareRunIds compareRunIds
} from './run-id-generator.js'; } from './run-id-generator.js';
// Export project root finding utilities
export {
findProjectRoot,
normalizeProjectRoot
} from './project-root-finder.js';
// Additional utility exports // Additional utility exports
/** /**

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -42,6 +42,9 @@ export * from './common/constants/index.js';
// Errors // Errors
export * from './common/errors/index.js'; export * from './common/errors/index.js';
// Utils
export * from './common/utils/index.js';
// ========== Domain-Specific Type Exports ========== // ========== Domain-Specific Type Exports ==========
// Task types // Task types

View File

@@ -9,9 +9,22 @@
*/ */
import dotenv from 'dotenv'; 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 // Store the original working directory
dotenv.config(); // 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 // Add at the very beginning of the file
if (process.env.DEBUG === '1') { if (process.env.DEBUG === '1') {

View File

@@ -1,6 +1,10 @@
/** /**
* Path utility functions for Task Master * Path utility functions for Task Master
* Provides centralized path resolution logic for both CLI and MCP use cases * 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'; import path from 'path';
@@ -15,103 +19,38 @@ import {
LEGACY_CONFIG_FILE LEGACY_CONFIG_FILE
} from '../constants/paths.js'; } from '../constants/paths.js';
import { getLoggerOrDefault } from './logger-utils.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 * Normalize project root to ensure it doesn't end with .taskmaster
* This prevents double .taskmaster paths when using constants that include .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 * @param {string} projectRoot - The project root path to normalize
* @returns {string} - Normalized project root path * @returns {string} - Normalized project root path
*/ */
export function normalizeProjectRoot(projectRoot) { export function normalizeProjectRoot(projectRoot) {
if (!projectRoot) return projectRoot; return normalizeProjectRootCore(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;
} }
/** /**
* Find the project root directory by looking for project markers * 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 * 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 * 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()) * @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) * @returns {string} - Project root path (falls back to current directory if no markers found)
*/ */
export function findProjectRoot(startDir = process.cwd()) { export function findProjectRoot(startDir = process.cwd()) {
// Define project markers that indicate a project root return findProjectRootCore(startDir);
// 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();
} }
/** /**
@@ -200,9 +139,14 @@ export function findPRDPath(explicitPath = null, args = null, log = null) {
// 1. If explicit path is provided, use it (highest priority) // 1. If explicit path is provided, use it (highest priority)
if (explicitPath) { 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) const resolvedPath = path.isAbsolute(explicitPath)
? explicitPath ? explicitPath
: path.resolve(process.cwd(), explicitPath); : path.resolve(cwdForResolution, explicitPath);
if (fs.existsSync(resolvedPath)) { if (fs.existsSync(resolvedPath)) {
logger.info?.(`Using explicit PRD path: ${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) // 1. If explicit path is provided, use it (highest priority)
if (explicitPath) { 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) const resolvedPath = path.isAbsolute(explicitPath)
? explicitPath ? explicitPath
: path.resolve(process.cwd(), explicitPath); : path.resolve(cwdForResolution, explicitPath);
if (fs.existsSync(resolvedPath)) { if (fs.existsSync(resolvedPath)) {
logger.info?.(`Using explicit complexity report path: ${resolvedPath}`); logger.info?.(`Using explicit complexity report path: ${resolvedPath}`);
@@ -353,9 +301,13 @@ export function resolveTasksOutputPath(
// 1. If explicit path is provided, use it // 1. If explicit path is provided, use it
if (explicitPath) { 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) const resolvedPath = path.isAbsolute(explicitPath)
? explicitPath ? explicitPath
: path.resolve(process.cwd(), explicitPath); : path.resolve(cwdForResolution, explicitPath);
logger.info?.(`Using explicit output path: ${resolvedPath}`); logger.info?.(`Using explicit output path: ${resolvedPath}`);
return resolvedPath; return resolvedPath;
@@ -399,9 +351,13 @@ export function resolveComplexityReportOutputPath(
// 1. If explicit path is provided, use it // 1. If explicit path is provided, use it
if (explicitPath) { 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) const resolvedPath = path.isAbsolute(explicitPath)
? explicitPath ? explicitPath
: path.resolve(process.cwd(), explicitPath); : path.resolve(cwdForResolution, explicitPath);
logger.info?.( logger.info?.(
`Using explicit complexity report output path: ${resolvedPath}` `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) // 1. If explicit path is provided, use it (highest priority)
if (explicitPath) { 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) const resolvedPath = path.isAbsolute(explicitPath)
? explicitPath ? explicitPath
: path.resolve(process.cwd(), explicitPath); : path.resolve(cwdForResolution, explicitPath);
if (fs.existsSync(resolvedPath)) { if (fs.existsSync(resolvedPath)) {
logger.info?.(`Using explicit config path: ${resolvedPath}`); logger.info?.(`Using explicit config path: ${resolvedPath}`);

View File

@@ -2,6 +2,7 @@ import { jest } from '@jest/globals';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -22,8 +23,8 @@ describe('Complex Cross-Tag Scenarios', () => {
); );
beforeEach(() => { beforeEach(() => {
// Create test directory // Create test directory in OS temp directory to isolate from project
testDir = fs.mkdtempSync(path.join(__dirname, 'test-')); testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-test-'));
process.chdir(testDir); process.chdir(testDir);
// Keep integration timings deterministic // Keep integration timings deterministic
process.env.TASKMASTER_SKIP_AUTO_UPDATE = '1'; process.env.TASKMASTER_SKIP_AUTO_UPDATE = '1';

View File

@@ -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();
});
});
});