Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
319 lines
15 KiB
JavaScript
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('../msd-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(), 'msd-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(), 'msd-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(), 'msd-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(), 'msd-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(), 'msd-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 MSD 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-msd/.planning/ ← ancestor MSD project
|
|
// parent-msd/child-app/.git ← nested child repo, NO .planning of its own
|
|
// parent-msd/child-app/src/ ← startDir
|
|
const parentMsd = mkDeep(tmpDir, 'parent-msd');
|
|
fs.mkdirSync(path.join(parentMsd, '.planning'), { recursive: true });
|
|
const childApp = mkDeep(parentMsd, 'child-app');
|
|
fs.mkdirSync(path.join(childApp, '.git'), { recursive: true });
|
|
const startDir = mkDeep(childApp, 'src');
|
|
|
|
const result = findProjectRoot(startDir);
|
|
assert.notStrictEqual(result, parentMsd,
|
|
'must NOT cross child-app\'s .git boundary to resolve to the ancestor parent-msd 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');
|
|
});
|
|
});
|