diff --git a/.changeset/lazy-lies-argue.md b/.changeset/lazy-lies-argue.md new file mode 100644 index 00000000..979b7e1e --- /dev/null +++ b/.changeset/lazy-lies-argue.md @@ -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`) diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 2b746be2..676ee584 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -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", diff --git a/apps/mcp/src/shared/utils.ts b/apps/mcp/src/shared/utils.ts index 0c2fd735..141ad537 100644 --- a/apps/mcp/src/shared/utils.ts +++ b/apps/mcp/src/shared/utils.ts @@ -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( args: TArgs & { projectRoot: string }, context: Context ) => { + // 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, diff --git a/package-lock.json b/package-lock.json index 18851057..d4eb1503 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/packages/tm-core/src/common/utils/project-root-finder.test.ts b/packages/tm-core/src/common/utils/project-root-finder.test.ts new file mode 100644 index 00000000..52c29628 --- /dev/null +++ b/packages/tm-core/src/common/utils/project-root-finder.test.ts @@ -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); + }); + }); +}); diff --git a/packages/tm-core/src/common/utils/project-root-finder.ts b/packages/tm-core/src/common/utils/project-root-finder.ts index 58926043..2720e771 100644 --- a/packages/tm-core/src/common/utils/project-root-finder.ts +++ b/packages/tm-core/src/common/utils/project-root-finder.ts @@ -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); } diff --git a/tests/unit/task-master.test.js b/tests/unit/task-master.test.js index 6570eb98..a7b43237 100644 --- a/tests/unit/task-master.test.js +++ b/tests/unit/task-master.test.js @@ -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');