Files
msd-core/tests/project-root.test.cjs
Tom Boucher 39dbe5e0f5 fix(#2843): findProjectRoot stops at git-repo boundaries, not just MAX_DEPTH (#2909)
* fix(#2843): findProjectRoot stops at git-repo boundaries, not just MAX_DEPTH

findProjectRoot's heuristic (3) used isInsideGitRepo(parent), which only checked
'does SOME .git exist between start and the ancestor' — it never verified the
.git was co-located with / bounded the trusted .planning/. A nested child repo
(own .git, no .planning) under an ancestor GSD project satisfied the check, so
resolution silently crossed into the ancestor project (wrong identity, exit 0).

Add nearestGitRoot(from, upTo) (fs-walk, no spawn) and use it in heuristics (3)
and (4): if the caller is inside its own nested repo whose root is strictly below
the candidate ancestor, do not return that ancestor. The plain-descendant (#1414),
co-located .git+.planning, and sub_repos/multiRepo cases are unchanged.

Regression test: a nested child .git under an ancestor .planning no longer
resolves to the ancestor; the co-located single-repo case still resolves.

* chore(#2843): backfill changeset PR 2909

---------

Co-authored-by: Test <test@example.com>
2026-07-31 02:30:50 -04:00

319 lines
15 KiB
JavaScript

/**
* 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 (#1422): sub_repos explicit config wins over .git implicit signal.
// A sub_repos workspace where the child has BOTH its own .planning/ AND its own
// .git/ — invoked from inside the child — MUST resolve to the PARENT workspace
// because the parent's config.json explicitly lists the child in sub_repos.
// The implicit .git heuristic (heuristic-3) must not override explicit sub_repos.
test('sub_repos child with BOTH .planning/ and .git/ resolves to PARENT workspace (sub_repos wins, #1422)', () => {
// Layout:
// workspaceRoot/
// .planning/
// config.json ← sub_repos: ['child']
// child/
// .planning/ ← child has own .planning/
// .git/ ← child ALSO has own .git/ → was triggering heuristic-3 prematurely
// 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);
// Explicit sub_repos config in the ancestor workspace must take precedence
// over the implicit .git heuristic — resolves to the workspace root.
assert.strictEqual(result, workspaceRoot,
'sub_repos config in parent workspace must win over child .git: should resolve to workspaceRoot (#1422)');
} finally {
cleanup(workspaceRoot);
}
});
// REGRESSION (#1422): sub_repos child with .git resolves to parent even when
// startDir is nested more than one level inside the child.
test('sub_repos child with .git: startDir nested 2+ levels inside child still resolves to parent (#1422)', () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-nested-'));
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 deepChild = mkDeep(childDir, 'src', 'lib', 'utils');
const result = findProjectRoot(deepChild);
assert.strictEqual(result, workspaceRoot,
'sub_repos config must win over .git even when startDir is deeply nested inside the child (#1422)');
} 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');
});
// #2843: findProjectRoot must NOT cross a git-repo boundary. A nested child
// repo (own .git, no .planning of its own) under an ancestor GSD project must
// NOT resolve to the ancestor's root — that would silently return a different
// project's identity with exit 0. Pre-fix heuristic (3)'s isInsideGitRepo only
// checked "does SOME .git exist between start and the ancestor" — the child's
// own .git satisfied it, crossing the boundary.
test('#2843 does not resolve to an ancestor project across a nested child .git boundary', () => {
// tmpDir/ (plain — not a git repo, not HOME)
// parent-gsd/.planning/ ← ancestor GSD project
// parent-gsd/child-app/.git ← nested child repo, NO .planning of its own
// parent-gsd/child-app/src/ ← startDir
const parentGsd = mkDeep(tmpDir, 'parent-gsd');
fs.mkdirSync(path.join(parentGsd, '.planning'), { recursive: true });
const childApp = mkDeep(parentGsd, 'child-app');
fs.mkdirSync(path.join(childApp, '.git'), { recursive: true });
const startDir = mkDeep(childApp, 'src');
const result = findProjectRoot(startDir);
assert.notStrictEqual(result, parentGsd,
'must NOT cross child-app\'s .git boundary to resolve to the ancestor parent-gsd project');
// The child repo has no .planning of its own, so resolution falls back to
// the startDir (or a path within child-app) — never the ancestor.
assert.ok(
result === startDir || result === childApp,
`expected to stay within the child repo (startDir or childApp fallback), got: ${result}`,
);
});
test('#2843 negative-space: a co-located .git + .planning (normal single-repo) still resolves to the project root', () => {
// The normal case: .git and .planning at the SAME level. The caller's .git
// IS the project's .git, so the boundary check passes (no nested child repo).
fs.mkdirSync(path.join(tmpDir, '.git'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
const nested = mkDeep(tmpDir, 'src', 'lib');
const result = findProjectRoot(nested);
assert.strictEqual(result, tmpDir,
'a co-located .git + .planning (single-repo project) must still resolve to the project root');
});
});