mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-29 22:02:04 +00:00
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:
9
.changeset/fix-taskmaster-parent-priority.md
Normal file
9
.changeset/fix-taskmaster-parent-priority.md
Normal 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.
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
43
apps/cli/src/utils/project-root.ts
Normal file
43
apps/cli/src/utils/project-root.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
83
packages/tm-core/src/common/constants/paths.ts
Normal file
83
packages/tm-core/src/common/constants/paths.ts
Normal 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;
|
||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
267
packages/tm-core/src/common/utils/project-root-finder.spec.ts
Normal file
267
packages/tm-core/src/common/utils/project-root-finder.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
packages/tm-core/src/common/utils/project-root-finder.ts
Normal file
155
packages/tm-core/src/common/utils/project-root-finder.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user