fix(#1414): resolve project root from descendant via nearest ancestor .planning/ (Resolution Provenance P1) (#1423)

Add heuristic (4) to findProjectRoot: after heuristics (1)-(3) (sub_repos,
multiRepo, .git+parent-.planning/) are exhausted without a match, perform a
second bounded walk-up within FIND_PROJECT_ROOT_MAX_DEPTH to locate the
nearest ancestor directory containing a .planning/ subdirectory. Returns that
ancestor as the project root, so loadConfig finds the correct config instead
of falling through to defaults when gsd-tools is invoked from a plain
descendant subdirectory of a single-repo project (#1366 cwd-drift gap).

Ordering is load-bearing: the new walk runs AFTER the existing loop so
sub_repos workspaces (where a child sub-repo may have its own .planning/)
still resolve correctly to the parent workspace. The existing own-.planning/
guard (heuristic 0, #1362) and the depth bound (FIND_PROJECT_ROOT_MAX_DEPTH=10)
are both preserved unchanged.

Part of #1411

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-18 00:50:30 -04:00
committed by GitHub
parent 6f2547610e
commit d220ba6fd6
4 changed files with 292 additions and 2 deletions

View File

@@ -1,10 +1,11 @@
/**
* Project-Root Resolution Module — resolves a project root from a starting
* directory by walking the ancestor chain and applying four heuristics:
* directory by walking the ancestor chain and applying five heuristics:
* (0) own .planning/ guard (#1362)
* (1) parent .planning/config.json sub_repos
* (2) legacy multiRepo: true + ancestor .git
* (3) .git heuristic with parent .planning/
* (4) nearest ancestor .planning/ (#1414, Resolution Provenance P1)
* Bounded by FIND_PROJECT_ROOT_MAX_DEPTH ancestors. Sync I/O.
*
* ADR-457 build-at-publish: the hand-written bin/lib/project-root.cjs
@@ -111,5 +112,30 @@ export function findProjectRoot(startDir: string): string {
depth += 1;
}
// Heuristic (4): nearest ancestor .planning/ — last resort before fallback.
// Runs only after heuristics (1)–(3) have been exhausted without a match,
// ensuring sub_repos / multiRepo / .git-based resolution always wins when
// applicable. Walks upward again within the same FIND_PROJECT_ROOT_MAX_DEPTH
// bound; returns the nearest ancestor directory that contains a .planning/
// subdirectory so config resolves correctly when invoked from a plain
// descendant of a single-repo project. (#1414)
let dir2 = resolvedStart;
let depth2 = 0;
while (dir2 !== fsRoot && depth2 < FIND_PROJECT_ROOT_MAX_DEPTH) {
const parent2 = path.dirname(dir2);
if (parent2 === dir2) break;
try {
const candidatePlanning = parent2 + path.sep + '.planning';
if (fs.existsSync(candidatePlanning) && fs.statSync(candidatePlanning).isDirectory()) {
return parent2;
}
} catch {
// ignore fs errors and continue walking
}
if (parent2 === home) break;
dir2 = parent2;
depth2 += 1;
}
return startDir;
}