feat(core): improve project root detection with boundary markers (#1531)

This commit is contained in:
Ralph Khreish
2025-12-18 18:06:39 +01:00
committed by GitHub
parent 416ebc4ea0
commit 73e3fe8dd3
4 changed files with 294 additions and 92 deletions

View File

@@ -52,6 +52,101 @@ export const TASKMASTER_PROJECT_MARKERS = [
LEGACY_CONFIG_FILE // .taskmasterconfig (legacy but still Task Master-specific)
] as const;
/**
* Project boundary markers - these indicate a project root and STOP upward traversal
* during Task Master marker search. If we hit one of these without finding .taskmaster,
* we shouldn't traverse beyond it (prevents finding .taskmaster in home directory).
*
* Ordered by reliability: VCS markers first, then platform-specific, then language-specific
*/
export const PROJECT_BOUNDARY_MARKERS = [
// Version control (strongest indicators)
'.git', // Git repository
'.svn', // SVN repository
'.hg', // Mercurial repository
'.fossil', // Fossil repository
// CI/CD and platform-specific directories (typically at project root)
'.github', // GitHub Actions, configs
'.gitlab', // GitLab CI configs
'.circleci', // CircleCI configs
'.travis.yml', // Travis CI config
'.jenkins', // Jenkins configs
'.buildkite', // Buildkite configs
// Editor/IDE project markers (typically at project root)
'.vscode', // VS Code workspace settings
'.idea', // JetBrains IDE settings
'.project', // Eclipse project
'.devcontainer', // Dev containers config
// Package manager lock files (strong indicators of project root)
'package-lock.json', // npm
'yarn.lock', // Yarn
'pnpm-lock.yaml', // pnpm
'bun.lockb', // Bun
'bun.lock', // Bun (text format)
'deno.lock', // Deno
'deno.json', // Deno config
'deno.jsonc', // Deno config (with comments)
// Node.js/JavaScript project files
'package.json', // Node.js project
'lerna.json', // Lerna monorepo
'nx.json', // Nx monorepo
'turbo.json', // Turborepo
'rush.json', // Rush monorepo
'pnpm-workspace.yaml', // pnpm workspace
// Rust project files
'Cargo.toml', // Rust project
'Cargo.lock', // Rust lock file
// Go project files
'go.mod', // Go project
'go.sum', // Go checksum file
'go.work', // Go workspace
// Python project files
'pyproject.toml', // Python project (modern)
'setup.py', // Python project (legacy)
'setup.cfg', // Python setup config
'poetry.lock', // Poetry lock file
'Pipfile', // Pipenv
'Pipfile.lock', // Pipenv lock file
'uv.lock', // uv lock file
// Ruby project files
'Gemfile', // Ruby project
'Gemfile.lock', // Ruby lock file
// PHP project files
'composer.json', // PHP project
'composer.lock', // PHP lock file
// Java/JVM project files
'build.gradle', // Gradle (Java/Kotlin)
'build.gradle.kts', // Gradle Kotlin DSL
'settings.gradle', // Gradle settings
'settings.gradle.kts', // Gradle settings (Kotlin)
'pom.xml', // Maven (Java)
'build.sbt', // sbt (Scala)
'project.clj', // Leiningen (Clojure)
'deps.edn', // Clojure deps
// Elixir/Erlang project files
'mix.exs', // Elixir project
'rebar.config', // Erlang rebar
// Other language project files
'pubspec.yaml', // Dart/Flutter project
'Package.swift', // Swift package
'CMakeLists.txt', // CMake project
'Makefile', // Generic project indicator
'meson.build', // Meson build system
'BUILD.bazel', // Bazel build
'WORKSPACE', // Bazel workspace
'flake.nix', // Nix flake
'shell.nix', // Nix shell
'default.nix', // Nix expression
// Container/deployment files (typically at project root)
'Dockerfile', // Docker
'docker-compose.yml', // Docker Compose
'docker-compose.yaml', // Docker Compose
'Containerfile', // Podman/OCI container
'kubernetes.yml', // Kubernetes manifests
'kubernetes.yaml', // Kubernetes manifests
'helm/Chart.yaml' // Helm chart
] 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
@@ -59,18 +154,8 @@ export const TASKMASTER_PROJECT_MARKERS = [
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
...PROJECT_BOUNDARY_MARKERS, // Include all boundary markers
'requirements.txt' // Python requirements (weaker indicator, keep separate)
] as const;
/**

View File

@@ -78,11 +78,10 @@ describe('findProjectRoot', () => {
});
});
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
describe('Project boundary behavior', () => {
it('should find .taskmaster in parent when child has NO boundary markers', () => {
// Scenario: /project/.taskmaster and /project/apps (no markers)
// This is a simple subdirectory, should find .taskmaster in parent
const projectRoot = tempDir;
const appsDir = path.join(tempDir, 'apps');
const taskmasterDir = path.join(projectRoot, '.taskmaster');
@@ -90,63 +89,137 @@ describe('findProjectRoot', () => {
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');
it('should stop at project boundary (.git) and NOT find .taskmaster in distant parent', () => {
// Scenario: /home/.taskmaster (should be ignored!) and /home/code/project/.git
// This is the user's reported issue - .taskmaster in home dir should NOT be found
const homeDir = tempDir;
const codeDir = path.join(tempDir, 'code');
const projectDir = path.join(codeDir, 'project');
const taskmasterInHome = path.join(homeDir, '.taskmaster');
fs.mkdirSync(taskmasterInHome);
fs.mkdirSync(codeDir);
fs.mkdirSync(projectDir);
fs.mkdirSync(path.join(projectDir, '.git')); // Project boundary
const result = findProjectRoot(projectDir);
// Should return project (with .git), NOT home (with .taskmaster)
expect(result).toBe(projectDir);
});
it('should stop at project boundary (package.json) and NOT find .taskmaster beyond', () => {
// Scenario: /home/.taskmaster and /home/code/project/package.json
const homeDir = tempDir;
const codeDir = path.join(tempDir, 'code');
const projectDir = path.join(codeDir, 'project');
const taskmasterInHome = path.join(homeDir, '.taskmaster');
fs.mkdirSync(taskmasterInHome);
fs.mkdirSync(codeDir);
fs.mkdirSync(projectDir);
fs.writeFileSync(path.join(projectDir, 'package.json'), '{}'); // Project boundary
const result = findProjectRoot(projectDir);
// Should return project (with package.json), NOT home (with .taskmaster)
expect(result).toBe(projectDir);
});
it('should stop at project boundary (lock file) and NOT find .taskmaster beyond', () => {
// Scenario: /home/.taskmaster and /home/code/project/package-lock.json
const homeDir = tempDir;
const codeDir = path.join(tempDir, 'code');
const projectDir = path.join(codeDir, 'project');
const taskmasterInHome = path.join(homeDir, '.taskmaster');
fs.mkdirSync(taskmasterInHome);
fs.mkdirSync(codeDir);
fs.mkdirSync(projectDir);
fs.writeFileSync(path.join(projectDir, 'package-lock.json'), '{}'); // Project boundary
const result = findProjectRoot(projectDir);
expect(result).toBe(projectDir);
});
it('should find .taskmaster when at SAME level as project boundary', () => {
// Scenario: /project/.taskmaster AND /project/.git
// This is a properly initialized Task Master project
const projectDir = tempDir;
const taskmasterDir = path.join(projectDir, '.taskmaster');
const gitDir = path.join(projectDir, '.git');
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);
const result = findProjectRoot(projectDir);
// Should return project (has both .taskmaster and .git)
expect(result).toBe(projectDir);
});
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');
it('should find .taskmaster when at same level as boundary, called from subdirectory', () => {
// Scenario: /project/.taskmaster, /project/.git, called from /project/src
// This is a typical monorepo setup
const projectDir = tempDir;
const srcDir = path.join(projectDir, 'src');
const taskmasterDir = path.join(projectDir, '.taskmaster');
const gitDir = path.join(projectDir, '.git');
fs.mkdirSync(taskmasterDir);
fs.mkdirSync(parentDir);
fs.mkdirSync(childDir);
fs.writeFileSync(path.join(childDir, 'package.json'), '{}');
fs.mkdirSync(gitDir);
fs.mkdirSync(srcDir);
const result = findProjectRoot(childDir);
expect(result).toBe(grandparentDir);
const result = findProjectRoot(srcDir);
// Should find project root with .taskmaster (boundary and .taskmaster at same level)
expect(result).toBe(projectDir);
});
});
describe('Monorepo behavior', () => {
it('should find .taskmaster in monorepo root when package has no boundary markers', () => {
// Scenario: /monorepo/.taskmaster, /monorepo/.git, /monorepo/packages/pkg/src
// pkg directory has no markers
const monorepoRoot = tempDir;
const pkgDir = path.join(tempDir, 'packages', 'pkg');
const srcDir = path.join(pkgDir, 'src');
fs.mkdirSync(path.join(monorepoRoot, '.taskmaster'));
fs.mkdirSync(path.join(monorepoRoot, '.git'));
fs.mkdirSync(srcDir, { recursive: true });
const result = findProjectRoot(srcDir);
expect(result).toBe(monorepoRoot);
});
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');
it('should return package root when package HAS its own boundary marker', () => {
// Scenario: /monorepo/.taskmaster, /monorepo/packages/pkg/package.json
// When a package has its own project marker, it's treated as its own project
const monorepoRoot = tempDir;
const pkgDir = path.join(tempDir, 'packages', 'pkg');
fs.mkdirSync(taskmasterDir);
fs.mkdirSync(childDir, { recursive: true });
fs.mkdirSync(path.join(monorepoRoot, '.taskmaster'));
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(path.join(pkgDir, 'package.json'), '{}'); // Package has its own marker
// 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(pkgDir);
// Returns package root (the first project boundary encountered)
expect(result).toBe(pkgDir);
});
const result = findProjectRoot(childDir);
// Should still return parent with .taskmaster
expect(result).toBe(parentDir);
it('should return package root when package HAS .taskmaster (nested Task Master)', () => {
// Scenario: /monorepo/.taskmaster, /monorepo/packages/pkg/.taskmaster
// Package has its own Task Master initialization
const monorepoRoot = tempDir;
const pkgDir = path.join(tempDir, 'packages', 'pkg');
fs.mkdirSync(path.join(monorepoRoot, '.taskmaster'));
fs.mkdirSync(path.join(pkgDir, '.taskmaster'), { recursive: true });
const result = findProjectRoot(pkgDir);
// Returns package (has its own .taskmaster)
expect(result).toBe(pkgDir);
});
});
@@ -189,10 +262,11 @@ describe('findProjectRoot', () => {
});
describe('Edge cases', () => {
it('should return current directory if no markers found', () => {
it('should return startDir if no markers found (empty repo)', () => {
// Scenario: Empty repo with just a .env file - should use startDir as project root
const result = findProjectRoot(tempDir);
// Should fall back to process.cwd()
expect(result).toBe(process.cwd());
// Should fall back to startDir, not process.cwd()
expect(result).toBe(tempDir);
});
it('should handle permission errors gracefully', () => {

View File

@@ -7,20 +7,46 @@ import fs from 'node:fs';
import path from 'node:path';
import {
OTHER_PROJECT_MARKERS,
PROJECT_BOUNDARY_MARKERS,
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));
} catch {
return false;
}
}
/**
* 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
*
* 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.
* 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 current directory if no markers found)
* @returns Project root path (falls back to startDir if no markers found)
*
* @example
* ```typescript
@@ -29,6 +55,12 @@ import {
* // /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)
* ```
*/
export function findProjectRoot(startDir: string = process.cwd()): string {
@@ -37,26 +69,34 @@ export function findProjectRoot(startDir: string = process.cwd()): string {
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
// 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;
while (depth < maxDepth) {
// First, check for Task Master markers in this directory
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 (markerExists(searchDir, marker)) {
// Found a Task Master marker - this is our project root
return searchDir;
}
}
// 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.
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;
@@ -74,22 +114,17 @@ export function findProjectRoot(startDir: string = process.cwd()): string {
depth++;
}
// SECOND PASS: No Task Master markers found in any parent directory
// SECOND PASS: No Task Master markers found within project boundary
// Now search for other project markers starting from the original directory
currentDir = path.resolve(startDir);
// If we found a project boundary in Pass 1, start from there (it will match immediately)
currentDir = projectBoundaryDir || 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 (markerExists(currentDir, marker)) {
// Found another project marker - return this as project root
return currentDir;
}
}
@@ -110,9 +145,10 @@ export function findProjectRoot(startDir: string = process.cwd()): string {
depth++;
}
// Fallback to current working directory if no project root found
// This ensures the function always returns a valid, existing path
return process.cwd();
// 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);
}
/**

View File

@@ -59,10 +59,18 @@ export class OAuthService {
private logger = getLogger('OAuthService');
private contextStore: ContextStore;
private supabaseClient: SupabaseAuthClient;
private baseUrl: string;
private configOverrides: Partial<AuthConfig>;
private authorizationUrl: string | null = null;
private keyPair: AuthKeyPair | null = null;
/**
* Get the base URL lazily to ensure environment variables are read fresh.
* This allows TM_BASE_DOMAIN to be set after service construction (e.g., in MCP flows).
*/
private get baseUrl(): string {
return getAuthConfig(this.configOverrides).baseUrl;
}
constructor(
contextStore: ContextStore,
supabaseClient: SupabaseAuthClient,
@@ -70,8 +78,7 @@ export class OAuthService {
) {
this.contextStore = contextStore;
this.supabaseClient = supabaseClient;
const authConfig = getAuthConfig(config);
this.baseUrl = authConfig.baseUrl;
this.configOverrides = config;
}
/**