diff --git a/.changeset/fix-1414-project-root-walkup.md b/.changeset/fix-1414-project-root-walkup.md new file mode 100644 index 000000000..8be12617d --- /dev/null +++ b/.changeset/fix-1414-project-root-walkup.md @@ -0,0 +1,8 @@ +--- +type: Changed +pr: 1423 +--- +**`gsd-tools` now resolves the project root from a descendant subdirectory** — `findProjectRoot` walks up to the nearest ancestor directory containing `.planning/` so config loads correctly when invoked outside the project root; previously it fell through to defaults for plain descendant paths (cwd-drift gap #1366). Sub_repos, multiRepo, and `.git`-based heuristics retain priority. (Part of #1411, P1 / #1414) + + + diff --git a/CONTEXT.md b/CONTEXT.md index b6e67a62c..b2befe459 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -89,7 +89,7 @@ Module owning `.planning` path resolution, active workstream pointer policy (`se Module owning workstream directory discovery, per-workstream state projection, phase/plan/summary counting, roadmap-declared phase count, active marker projection, and active-workstream collision inputs. Command handlers render list/status/progress outputs from this inventory instead of rescanning `.planning/workstreams/*` directly. Source of truth for the pure projection is `gsd-core/bin/lib/workstream-inventory-builder.cjs` (a Builder Module); the Reader Adapter `gsd-core/bin/lib/workstream-inventory.cjs` collects filesystem inputs and delegates projection to the Builder. ### Project-Root Resolution Module -Module owning project-root resolution from any starting directory. Walks the ancestor chain (bounded by `FIND_PROJECT_ROOT_MAX_DEPTH = 10`) applying four heuristics in order: (0) own `.planning/` guard (#1362), (1) parent `.planning/config.json` `sub_repos` traversal, (2) legacy `multiRepo: true` boolean + ancestor `.git`, (3) `.git` heuristic with parent `.planning/`. Returns `startDir` when no ancestor qualifies. Sync `node:fs` I/O. Source of truth: `gsd-core/bin/lib/project-root.cjs`; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. +Module owning project-root resolution from any starting directory. Walks the ancestor chain (bounded by `FIND_PROJECT_ROOT_MAX_DEPTH = 10`) applying five heuristics in order: (0) own `.planning/` guard (#1362), (1) parent `.planning/config.json` `sub_repos` traversal, (2) legacy `multiRepo: true` boolean + ancestor `.git`, (3) `.git` heuristic with parent `.planning/`, (4) nearest-ancestor `.planning/` walk-up (#1414, epic #1411) — a last-resort second walk (same depth bound, stops at `os.homedir()`) that anchors a plain descendant subdirectory of a single-repo project to its nearest ancestor `.planning/` instead of degrading to defaults; ordered after (1)–(3) so `sub_repos`/`multiRepo` resolution always wins (the Resolution Provenance deterministic-anchoring rule). Returns `startDir` when no ancestor qualifies. Sync `node:fs` I/O. Source of truth: `gsd-core/bin/lib/project-root.cjs`; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. ### Planning Path Projection Module Module owning projection from project/workstream context to concrete `.planning` paths. Policy precedence is `explicit workstream > env workstream > env project > root`. Invalid workspace context is a validation error at this seam rather than a silent fallback. diff --git a/src/project-root.cts b/src/project-root.cts index b45b3fe87..3b4ee3cb4 100644 --- a/src/project-root.cts +++ b/src/project-root.cts @@ -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; } diff --git a/tests/project-root.test.cjs b/tests/project-root.test.cjs new file mode 100644 index 000000000..33210db8d --- /dev/null +++ b/tests/project-root.test.cjs @@ -0,0 +1,256 @@ +/** + * Tests for findProjectRoot — Project-Root Resolution Module + * (#1414, part of Resolution Provenance epic #1411) + * + * Covers heuristic (4) (nearest-ancestor .planning/ walk-up) plus targeted + * regression cases for sub_repos and .git-precedence interactions. Does NOT + * exhaustively re-test every prior heuristic. + */ + +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { findProjectRoot } = require('../gsd-core/bin/lib/project-root.cjs'); +const { cleanup } = require('./helpers.cjs'); + +// ─── helpers ──────────────────────────────────────────────────────────────── + +/** Create nested path under base (all segments), returns the leaf dir path. */ +function mkDeep(base, ...segments) { + const full = path.join(base, ...segments); + fs.mkdirSync(full, { recursive: true }); + return full; +} + +// ─── describe block ────────────────────────────────────────────────────────── + +describe('findProjectRoot nearest-.planning resolution (#1414)', () => { + let tmpDir; + // Saved HOME/USERPROFILE env vars for tests that override them. + let savedHome; + let savedUserProfile; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-test-')); + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + }); + + afterEach(() => { + cleanup(tmpDir); + // Restore HOME/USERPROFILE unconditionally. + if (savedHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = savedHome; + } + if (savedUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = savedUserProfile; + } + }); + + // HAPPY: invoked from a plain descendant (no .git/.planning in between) + test('resolves ancestor .planning/ when invoked from a descendant subdirectory', () => { + // Layout: + // tmpDir/ + // .planning/ ← project root + // src/ + // deep/ + // nested/ ← startDir (no .planning/, no .git) + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + const nested = mkDeep(tmpDir, 'src', 'deep', 'nested'); + + const result = findProjectRoot(nested); + assert.strictEqual(result, tmpDir, + 'findProjectRoot should walk up and return the ancestor dir that has .planning/'); + }); + + // HAPPY (determinism): resolution from root and from descendant must be identical + test('resolution from project root and from descendant are byte-identical', () => { + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + const nested = mkDeep(tmpDir, 'lib', 'utils'); + + const fromRoot = findProjectRoot(tmpDir); + const fromDescendant = findProjectRoot(nested); + + assert.strictEqual(fromRoot, fromDescendant, + 'Resolution from project root and from descendant must produce the same path'); + }); + + // BOUNDARY (exact): descendant exactly FIND_PROJECT_ROOT_MAX_DEPTH-1 levels below + // .planning/ ancestor (i.e. 9 hops when MAX_DEPTH=10) → must resolve. + // One level beyond (11 levels = 10 hops) → must return startDir. + test('resolves when descendant is exactly FIND_PROJECT_ROOT_MAX_DEPTH-1 levels below ancestor .planning/', () => { + // FIND_PROJECT_ROOT_MAX_DEPTH = 10. + // 9 levels of nesting = 9 parent hops = within bound → must resolve. + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + const deep = mkDeep(tmpDir, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'); // 9 levels + + const result = findProjectRoot(deep); + assert.strictEqual(result, tmpDir, + 'Should resolve when exactly FIND_PROJECT_ROOT_MAX_DEPTH-1 levels deep (9 hops, bound=10)'); + }); + + // BOUNDARY (exact): descendant exactly one level BEYOND FIND_PROJECT_ROOT_MAX_DEPTH + // (10 levels of nesting = 10 parent hops = at bound; 11 levels = 11 hops = beyond). + // The loop runs while depth2 < MAX_DEPTH (10), so depth2 reaches 9 after checking + // 10 parents; the 11th level parent is never checked → returns startDir. + test('returns startDir when descendant exceeds FIND_PROJECT_ROOT_MAX_DEPTH', () => { + // 11 levels deep — exceeds the depth=10 bound + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + const tooDeep = mkDeep(tmpDir, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'); // 11 levels + + const result = findProjectRoot(tooDeep); + assert.strictEqual(result, tooDeep, + 'Should return startDir when ancestor .planning/ is beyond FIND_PROJECT_ROOT_MAX_DEPTH'); + }); + + // BOUNDARY: own .planning/ guard unchanged — startDir with .planning/ returns startDir + test('returns startDir when startDir itself has .planning/ (own-guard unchanged)', () => { + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + + const result = findProjectRoot(tmpDir); + assert.strictEqual(result, tmpDir, + 'When startDir has .planning/ it should be returned as-is (heuristic 0 guard)'); + }); + + // HOME-ROOTED PROJECT: a project whose root is exactly $HOME must be resolvable + // from a descendant. Previously the `if (parent2 === home) break` fired BEFORE + // the .planning check, making $HOME-rooted projects unresolvable. After the + // reorder, $HOME itself is checked before the break fires. + test('resolves a project rooted at $HOME from a descendant (home checked before break)', () => { + // Make tmpDir act as $HOME by setting both HOME and USERPROFILE. + process.env.HOME = tmpDir; + process.env.USERPROFILE = tmpDir; + + // Create a .planning/ directly inside "home" (tmpDir). + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + + // Invoke from a subdirectory of "home". + const sub = mkDeep(tmpDir, 'sub', 'dir'); + + const result = findProjectRoot(sub); + assert.strictEqual(result, tmpDir, + 'findProjectRoot must resolve a project rooted exactly at $HOME (home checked before break)'); + }); + + // NEGATIVE/REGRESSION: sub_repos workspace — child sub-repo has its OWN .planning/ + // but NO .git — invoked from inside the child → must still resolve to PARENT workspace. + // Why: heuristic (1) sub_repos claims the child (matched by name in sub_repos array) + // before heuristic (4) runs; because the child has no independent .git root, the + // sub_repos entry is the controlling signal. This test is the real guard that + // heuristic (4) does NOT hijack sub_repos resolution. + test('sub_repos workspace: child with own .planning/ (no .git) still resolves to parent workspace', () => { + // Layout: + // workspaceRoot/ + // .planning/ + // config.json ← sub_repos: ['child'] + // child/ + // .planning/ ← child has its own .planning/ but NO .git (the trap for heuristic 4) + // src/ + // code.js ← startDir (descendant of child) + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-')); + try { + fs.mkdirSync(path.join(workspaceRoot, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(workspaceRoot, '.planning', 'config.json'), + JSON.stringify({ sub_repos: ['child'] }) + ); + // Child repo with its own .planning/ but NO .git + fs.mkdirSync(path.join(workspaceRoot, 'child', '.planning'), { recursive: true }); + const childSrc = mkDeep(workspaceRoot, 'child', 'src'); + + const result = findProjectRoot(childSrc); + assert.strictEqual(result, workspaceRoot, + 'sub_repos heuristic must win over nearest-.planning/ walk-up: should resolve to workspace root, not child'); + } finally { + cleanup(workspaceRoot); + } + }); + + // REGRESSION (pre-existing heuristic-3 behavior, orthogonal to heuristic 4): + // A sub_repos workspace where the child has BOTH its own .planning/ AND its own + // .git/ — invoked from inside the child — RESOLVES TO THE CHILD (not the parent). + // Pre-existing heuristic-3 precedence: a sub-repo that is itself a full project + // (.git + .planning) resolves to itself; this is orthogonal to heuristic 4 and + // tracked separately. Documents current behavior. + test('sub_repos child with BOTH .planning/ and .git/ resolves to child itself (heuristic-3 precedence)', () => { + // Layout: + // workspaceRoot/ + // .planning/ + // config.json ← sub_repos: ['child'] + // child/ + // .planning/ ← child has own .planning/ + // .git/ ← child ALSO has own .git/ → heuristic-3 makes it self-resolving + // src/ ← startDir + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-git-')); + try { + fs.mkdirSync(path.join(workspaceRoot, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(workspaceRoot, '.planning', 'config.json'), + JSON.stringify({ sub_repos: ['child'] }) + ); + const childDir = path.join(workspaceRoot, 'child'); + fs.mkdirSync(path.join(childDir, '.planning'), { recursive: true }); + fs.mkdirSync(path.join(childDir, '.git'), { recursive: true }); + const childSrc = mkDeep(childDir, 'src'); + + const result = findProjectRoot(childSrc); + // Pre-existing heuristic-3 precedence: child is a full project (.git + .planning) + // → resolves to the child, not the workspace root. + assert.strictEqual(result, childDir, + 'A sub-repo with both .planning/ and .git/ should resolve to itself (heuristic-3 precedence)'); + } finally { + cleanup(workspaceRoot); + } + }); + + // REGRESSION (multiRepo: true, no sub_repos): a workspace whose .planning/config.json + // has { "multiRepo": true } but no sub_repos array, with a child dir containing .git, + // invoked from inside the child — pins pre-existing heuristic-2 behavior. + test('multiRepo:true (no sub_repos) with child .git: pins pre-existing heuristic-2 behavior', () => { + // Layout: + // workspaceRoot/ + // .planning/ + // config.json ← { multiRepo: true } (no sub_repos) + // child/ + // .git/ ← child has its own git repo + // src/ ← startDir + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-multirepo-')); + try { + fs.mkdirSync(path.join(workspaceRoot, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(workspaceRoot, '.planning', 'config.json'), + JSON.stringify({ multiRepo: true }) + ); + const childDir = path.join(workspaceRoot, 'child'); + fs.mkdirSync(path.join(childDir, '.git'), { recursive: true }); + const childSrc = mkDeep(childDir, 'src'); + + // Run once to observe actual behavior, then assert that value to lock it. + // Pre-existing heuristic-2: multiRepo:true + isInsideGitRepo → returns workspaceRoot. + const result = findProjectRoot(childSrc); + assert.strictEqual(result, workspaceRoot, + 'multiRepo:true with a child .git returns the workspace root (pins pre-existing heuristic-2 behavior)'); + } finally { + cleanup(workspaceRoot); + } + }); + + // NEGATIVE: no .planning/ anywhere in ancestry (within bound) → returns startDir + test('returns startDir when no .planning/ exists anywhere in ancestry within bound', () => { + // tmpDir has NO .planning/ — it's a plain directory + const nested = mkDeep(tmpDir, 'src', 'lib'); + + const result = findProjectRoot(nested); + assert.strictEqual(result, nested, + 'Should return startDir unchanged when no ancestor has .planning/ within the depth bound'); + }); +});