feat(project-root): enhance project root detection with boundary markers (#1545)

This commit is contained in:
Ralph Khreish
2025-12-24 13:26:48 +01:00
committed by GitHub
parent 3f489d8116
commit a0007a3575
7 changed files with 337 additions and 124 deletions

View File

@@ -0,0 +1,9 @@
---
"task-master-ai": patch
---
Smarter project root detection with boundary markers
- Prevents Task Master from incorrectly detecting `.taskmaster` folders in your home directory when working inside a different project
- Now stops at project boundaries (`.git`, `package.json`, lock files) instead of searching all the way up to the filesystem root
- Adds support for monorepo markers (`lerna.json`, `nx.json`, `turbo.json`) and additional lock files (`bun.lockb`, `deno.lock`)

View File

@@ -24,7 +24,8 @@
"dependencies": {
"@tm/core": "*",
"fastmcp": "^3.23.0",
"zod": "^4.1.11"
"zod": "^4.1.11",
"dotenv": "^16.6.1"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",

View File

@@ -2,6 +2,7 @@
* Shared utilities for MCP tools
*/
import dotenv from 'dotenv';
import fs from 'node:fs';
import path from 'node:path';
import {
@@ -395,6 +396,13 @@ export function withToolContext<TArgs extends { projectRoot?: string }>(
args: TArgs & { projectRoot: string },
context: Context<undefined>
) => {
// Load project .env if it exists (won't overwrite MCP-provided env vars)
// This ensures project-specific env vars are available to tool execution
const envPath = path.join(args.projectRoot, '.env');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
// Create tmCore instance
const tmCore = await createTmCore({
projectPath: args.projectRoot,

7
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "task-master-ai",
"version": "0.39.0",
"version": "0.40.0-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "task-master-ai",
"version": "0.39.0",
"version": "0.40.0-rc.1",
"license": "MIT WITH Commons-Clause",
"workspaces": [
"apps/*",
@@ -1850,7 +1850,7 @@
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4.1.11",
"task-master-ai": "*",
"task-master-ai": "0.40.0-rc.1",
"typescript": "^5.9.2"
},
"engines": {
@@ -1971,6 +1971,7 @@
"license": "MIT",
"dependencies": {
"@tm/core": "*",
"dotenv": "^16.6.1",
"fastmcp": "^3.23.0",
"zod": "^4.1.11"
},

View File

@@ -0,0 +1,268 @@
/**
* @fileoverview Integration tests for project root detection
*
* These tests verify real-world scenarios for project root detection,
* particularly edge cases around:
* - Empty directories (tm init scenario)
* - .taskmaster in home/parent directories that should be ignored
* - Monorepo detection
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { findProjectRoot } from './project-root-finder.js';
describe('findProjectRoot - Integration Tests', () => {
let tempDir: string;
beforeEach(() => {
// Create a temporary directory structure for testing
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-integration-test-'));
});
afterEach(() => {
// Clean up temp directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('Empty directory scenarios (tm init)', () => {
it('should find .taskmaster in immediate parent (depth=1) even without boundary marker', () => {
// Scenario: User is in a subdirectory of a project that only has .taskmaster
// (no .git or package.json). This is a valid use case for projects where
// user ran `tm init` but not `git init`.
//
// Structure:
// /project/.taskmaster
// /project/src/ (start here)
//
// NOTE: For `tm init` command specifically, the command should use
// process.cwd() directly instead of findProjectRoot() to avoid
// finding a stray .taskmaster in a parent directory.
const projectDir = tempDir;
const srcDir = path.join(projectDir, 'src');
fs.mkdirSync(path.join(projectDir, '.taskmaster'));
fs.mkdirSync(srcDir);
const result = findProjectRoot(srcDir);
// Immediate parent (depth=1) is trusted even without boundary marker
expect(result).toBe(projectDir);
});
it('should return startDir when running from completely empty directory', () => {
// Scenario: User runs `tm init` in a brand new, completely empty directory
// No markers anywhere in the tree
//
// Structure:
// /tmp/test/empty-project/ (empty)
const emptyProjectDir = path.join(tempDir, 'empty-project');
fs.mkdirSync(emptyProjectDir);
const result = findProjectRoot(emptyProjectDir);
// Should return the empty directory itself
expect(result).toBe(emptyProjectDir);
});
it('should return startDir when no boundary markers exist but .taskmaster exists in distant parent', () => {
// Scenario: Deep directory structure with .taskmaster only at top level
// No boundary markers (.git, package.json, etc.) anywhere
//
// Structure:
// /tmp/home/.taskmaster (should be IGNORED - too far up)
// /tmp/home/code/projects/my-app/ (empty - no markers)
const homeDir = tempDir;
const deepProjectDir = path.join(homeDir, 'code', 'projects', 'my-app');
fs.mkdirSync(path.join(homeDir, '.taskmaster'));
fs.mkdirSync(deepProjectDir, { recursive: true });
const result = findProjectRoot(deepProjectDir);
// Should return the deep directory, not home
expect(result).toBe(deepProjectDir);
});
});
describe('Project with boundary markers', () => {
it('should find .taskmaster when at same level as .git', () => {
// Scenario: Normal project setup with both .taskmaster and .git
//
// Structure:
// /tmp/project/.taskmaster
// /tmp/project/.git
const projectDir = tempDir;
fs.mkdirSync(path.join(projectDir, '.taskmaster'));
fs.mkdirSync(path.join(projectDir, '.git'));
const result = findProjectRoot(projectDir);
expect(result).toBe(projectDir);
});
it('should find .taskmaster in parent when subdirectory has no markers', () => {
// Scenario: Running from a subdirectory of an initialized project
//
// Structure:
// /tmp/project/.taskmaster
// /tmp/project/.git
// /tmp/project/src/components/ (no markers)
const projectDir = tempDir;
const srcDir = path.join(projectDir, 'src', 'components');
fs.mkdirSync(path.join(projectDir, '.taskmaster'));
fs.mkdirSync(path.join(projectDir, '.git'));
fs.mkdirSync(srcDir, { recursive: true });
const result = findProjectRoot(srcDir);
expect(result).toBe(projectDir);
});
it('should stop at .git and NOT find .taskmaster beyond it', () => {
// Scenario: Project has .git but no .taskmaster, parent has .taskmaster
// Should use project with .git, not parent with .taskmaster
//
// Structure:
// /tmp/home/.taskmaster (should be IGNORED)
// /tmp/home/my-project/.git (boundary marker)
const homeDir = tempDir;
const projectDir = path.join(homeDir, 'my-project');
fs.mkdirSync(path.join(homeDir, '.taskmaster'));
fs.mkdirSync(path.join(projectDir, '.git'), { recursive: true });
const result = findProjectRoot(projectDir);
expect(result).toBe(projectDir);
});
it('should stop at package.json and NOT find .taskmaster beyond it', () => {
// Scenario: JS project with package.json but no .taskmaster
//
// Structure:
// /tmp/home/.taskmaster (should be IGNORED)
// /tmp/home/my-project/package.json (boundary)
const homeDir = tempDir;
const projectDir = path.join(homeDir, 'my-project');
fs.mkdirSync(path.join(homeDir, '.taskmaster'));
fs.mkdirSync(projectDir);
fs.writeFileSync(path.join(projectDir, 'package.json'), '{}');
const result = findProjectRoot(projectDir);
expect(result).toBe(projectDir);
});
});
describe('Monorepo scenarios', () => {
it('should find monorepo root .taskmaster from package subdirectory', () => {
// Scenario: Monorepo with .taskmaster at root, packages without their own markers
//
// Structure:
// /tmp/monorepo/.taskmaster
// /tmp/monorepo/.git
// /tmp/monorepo/packages/my-package/src/
const monorepoRoot = tempDir;
const packageSrcDir = path.join(
monorepoRoot,
'packages',
'my-package',
'src'
);
fs.mkdirSync(path.join(monorepoRoot, '.taskmaster'));
fs.mkdirSync(path.join(monorepoRoot, '.git'));
fs.mkdirSync(packageSrcDir, { recursive: true });
const result = findProjectRoot(packageSrcDir);
expect(result).toBe(monorepoRoot);
});
it('should return package root when package has its own boundary marker', () => {
// Scenario: Monorepo where individual package has its own package.json
// Package should be treated as its own project
//
// Structure:
// /tmp/monorepo/.taskmaster
// /tmp/monorepo/packages/my-package/package.json (boundary)
const monorepoRoot = tempDir;
const packageDir = path.join(monorepoRoot, 'packages', 'my-package');
fs.mkdirSync(path.join(monorepoRoot, '.taskmaster'));
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(path.join(packageDir, 'package.json'), '{}');
const result = findProjectRoot(packageDir);
// Package has its own boundary, so it should be returned
expect(result).toBe(packageDir);
});
it('should return package root when package has its own .taskmaster', () => {
// Scenario: Nested Task Master initialization
//
// Structure:
// /tmp/monorepo/.taskmaster
// /tmp/monorepo/packages/my-package/.taskmaster
const monorepoRoot = tempDir;
const packageDir = path.join(monorepoRoot, 'packages', 'my-package');
fs.mkdirSync(path.join(monorepoRoot, '.taskmaster'));
fs.mkdirSync(path.join(packageDir, '.taskmaster'), { recursive: true });
const result = findProjectRoot(packageDir);
// Package has its own .taskmaster, so it should be returned
expect(result).toBe(packageDir);
});
});
describe('Environment variable loading context', () => {
it('should document that .env loading should use process.cwd(), not findProjectRoot()', () => {
// IMPORTANT: For .env loading (e.g., TM_BASE_DOMAIN for auth),
// the code should use process.cwd() directly, NOT findProjectRoot().
//
// findProjectRoot() is designed to find the .taskmaster directory
// for task storage. It will traverse up to find .taskmaster in parent
// directories when appropriate.
//
// For environment variables, we want to load from WHERE the user
// is running the command, not where .taskmaster is located.
//
// This test documents this design decision and verifies findProjectRoot
// behavior when .taskmaster is in immediate parent (depth=1).
const projectDir = tempDir;
const subDir = path.join(projectDir, 'subdir');
fs.mkdirSync(path.join(projectDir, '.taskmaster'));
fs.mkdirSync(subDir);
const result = findProjectRoot(subDir);
// findProjectRoot WILL find parent's .taskmaster (depth=1 is trusted)
// This is correct for task storage - we want to use the parent's tasks.json
// For .env loading, callers should use process.cwd() instead
expect(result).toBe(projectDir);
});
});
});

View File

@@ -1,8 +1,3 @@
/**
* @fileoverview Project root detection utilities
* Provides functionality to locate project roots by searching for marker files/directories
*/
import fs from 'node:fs';
import path from 'node:path';
import {
@@ -11,9 +6,6 @@ import {
TASKMASTER_PROJECT_MARKERS
} from '../constants/paths.js';
/**
* Check if a marker file/directory exists at the given path
*/
function markerExists(dir: string, marker: string): boolean {
try {
return fs.existsSync(path.join(dir, marker));
@@ -22,170 +14,101 @@ function markerExists(dir: string, marker: string): boolean {
}
}
/**
* Check if any of the given markers exist in a directory
*/
function hasAnyMarker(dir: string, markers: readonly string[]): boolean {
return markers.some((marker) => markerExists(dir, marker));
}
/**
* 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
* Find the project root by traversing upward from startDir looking for project markers.
*
* Strategy:
* 1. PASS 1: Search for .taskmaster markers, but STOP at project boundaries
* - If .taskmaster found, return that directory
* - If a project boundary (package.json, .git, lock files) is found WITHOUT .taskmaster,
* stop searching further up (prevents finding .taskmaster in home directory)
* 2. PASS 2: If no .taskmaster found, search for other project markers
*
* This ensures:
* - .taskmaster in a parent directory takes precedence (within project boundary)
* - .taskmaster outside the project boundary (e.g., home dir) is NOT returned
*
* @param startDir - Directory to start searching from (defaults to process.cwd())
* @returns Project root path (falls back to startDir 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)
*
* // When .taskmaster is outside project boundary:
* // /home/user/.taskmaster (should be ignored!)
* // /home/user/code/myproject/package.json
* // When called from /home/user/code/myproject:
* const root = findProjectRoot(); // Returns /home/user/code/myproject (NOT /home/user)
* ```
* Search strategy prevents false matches from stray .taskmaster dirs (e.g., in home):
* 1. If startDir has .taskmaster or a boundary marker (.git, package.json), return immediately
* 2. Search parents for .taskmaster anchored by a boundary marker (or 1 level up without boundary)
* 3. Fall back to searching for other project markers (pyproject.toml, Cargo.toml, etc.)
* 4. If nothing found, return startDir (supports `tm init` in empty directories)
*/
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
const maxDepth = 50;
let depth = 0;
// Track if we've seen a project boundary - we'll stop searching for .taskmaster beyond it
let projectBoundaryDir: string | null = null;
// FIRST PASS: Search for Task Master markers, but respect project boundaries
// A project boundary is a directory containing .git, package.json, lock files, etc.
// If we find a boundary without .taskmaster, we stop searching further up
let searchDir = currentDir;
depth = 0;
// Check startDir first - if it has .taskmaster or a boundary marker, we're done
if (hasAnyMarker(currentDir, TASKMASTER_PROJECT_MARKERS)) {
return currentDir;
}
if (hasAnyMarker(currentDir, PROJECT_BOUNDARY_MARKERS)) {
return currentDir;
}
// Search parent directories for .taskmaster
let searchDir = path.dirname(currentDir);
depth = 1;
while (depth < maxDepth) {
// First, check for Task Master markers in this directory
for (const marker of TASKMASTER_PROJECT_MARKERS) {
if (markerExists(searchDir, marker)) {
// Found a Task Master marker - this is our project root
const hasTaskmaster = hasAnyMarker(searchDir, TASKMASTER_PROJECT_MARKERS);
const hasBoundary = hasAnyMarker(searchDir, PROJECT_BOUNDARY_MARKERS);
if (hasTaskmaster) {
// Accept .taskmaster if anchored by boundary or only 1 level up
if (hasBoundary || depth === 1) {
return searchDir;
}
// Distant .taskmaster without boundary is likely stray (e.g., home dir) - skip it
}
// Check if this directory is a project boundary
// (has markers like .git, package.json, lock files, etc.)
if (hasAnyMarker(searchDir, PROJECT_BOUNDARY_MARKERS)) {
// This is a project boundary - record it and STOP looking for .taskmaster
// beyond this point. The .taskmaster in home directory should NOT be found
// when the user is inside a different project.
if (hasBoundary && !hasTaskmaster) {
// Hit project boundary without .taskmaster - stop searching upward
projectBoundaryDir = searchDir;
break; // Stop Pass 1 - don't look for .taskmaster beyond this boundary
}
// If we're at root, stop after checking it
if (searchDir === rootDir) {
break;
}
// Move up one directory level
if (searchDir === rootDir) break;
const parentDir = path.dirname(searchDir);
// Safety check: if dirname returns the same path, we've hit the root
if (parentDir === searchDir) {
break;
}
if (parentDir === searchDir) break;
searchDir = parentDir;
depth++;
}
// SECOND PASS: No Task Master markers found within project boundary
// Now search for other project markers starting from the original directory
// If we found a project boundary in Pass 1, start from there (it will match immediately)
// No .taskmaster found - search for other project markers
currentDir = projectBoundaryDir || path.resolve(startDir);
depth = 0;
while (depth < maxDepth) {
for (const marker of OTHER_PROJECT_MARKERS) {
if (markerExists(currentDir, marker)) {
// Found another project marker - return this as project root
return currentDir;
}
if (hasAnyMarker(currentDir, OTHER_PROJECT_MARKERS)) {
return currentDir;
}
// If we're at root, stop after checking it
if (currentDir === rootDir) {
break;
}
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;
}
if (parentDir === currentDir) break;
currentDir = parentDir;
depth++;
}
// Fallback to startDir if no project root found
// This handles empty repos or directories with no recognized project markers
// (e.g., a repo with just a .env file should still use that directory as root)
return path.resolve(startDir);
}
/**
* 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'
* ```
* Strip .taskmaster (and anything after it) from a path.
* Prevents double .taskmaster paths when combining with constants that include .taskmaster.
*/
export function normalizeProjectRoot(
projectRoot: string | null | undefined
): string {
if (!projectRoot) return projectRoot || '';
if (!projectRoot) return '';
// 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'
);
const segments = String(projectRoot).split(path.sep);
const taskmasterIndex = segments.findIndex((s) => s === '.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 segments.slice(0, taskmasterIndex).join(path.sep) || path.sep;
}
return projectRootStr;
return String(projectRoot);
}

View File

@@ -93,9 +93,12 @@ describe('initTaskMaster', () => {
});
test('should find project root from deeply nested subdirectory', () => {
// Arrange - Create .taskmaster directory in temp dir
// Arrange - Create .taskmaster directory and a boundary marker in temp dir
// The boundary marker (.git) anchors .taskmaster to prevent false matches
// from stray .taskmaster dirs (e.g., in home directory)
const taskMasterDir = path.join(tempDir, TASKMASTER_DIR);
fs.mkdirSync(taskMasterDir, { recursive: true });
fs.mkdirSync(path.join(tempDir, '.git'), { recursive: true });
// Create deeply nested subdirectory and change to it
const deepDir = path.join(tempDir, 'src', 'components', 'ui');