Skip to content

Commit 30dde2a

Browse files
fix(core): paths.ts walk-up project root resolution (T301)
T301 — Wave 1 of T299 v2026.4.11 epic. Implements ADR-036's walk-up scaffolding rule. getProjectRoot() now walks ancestors looking for .cleo/ or .git/ and STOPS at the first hit. Never auto-creates nested .cleo/. Ancestor-bounded (does not drift past nearest project when nested projects exist). - CLEO_ROOT env override bypasses walk entirely - CLEO_DIR absolute path preserves test-harness compatibility - .cleo/ wins over .git/ at same level - E_NOT_INITIALIZED for git-only repos (no .cleo/ sibling) - E_NO_PROJECT for orphan dirs 13 new walk-up unit tests. Existing paths.test.ts tests updated to use real tmp dirs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1f56032 commit 30dde2a

File tree

3 files changed

+450
-30
lines changed

3 files changed

+450
-30
lines changed
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
/**
2+
* Unit tests for the walk-up ancestor-scan algorithm in `getProjectRoot`.
3+
*
4+
* Covers all acceptance-criteria scenarios defined in T301:
5+
* 1. Nested subdir with parent .cleo/
6+
* 2. Nested in git-only repo (E_NOT_INITIALIZED)
7+
* 3. Orphan dir with no .cleo/ and no .git/ in any ancestor (E_NO_PROJECT)
8+
* 4. CLEO_ROOT env var override
9+
* 5. Ancestor boundary — must stop at first (inner) .cleo/, never drift to outer
10+
* 6. Integration-equivalent — deep nesting walks up to the .cleo/ root
11+
*
12+
* @task T301
13+
* @epic T299
14+
*/
15+
16+
import { mkdirSync, rmSync } from 'node:fs';
17+
import { tmpdir } from 'node:os';
18+
import { join } from 'node:path';
19+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
20+
import { getProjectRoot } from '../paths.js';
21+
22+
// ---------------------------------------------------------------------------
23+
// Helpers
24+
// ---------------------------------------------------------------------------
25+
26+
/** Create a unique temp base directory for each test inside Node.js tmpdir. */
27+
function makeTempBase(label: string): string {
28+
const dir = join(
29+
tmpdir(),
30+
`cleo-walkup-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
31+
);
32+
mkdirSync(dir, { recursive: true });
33+
return dir;
34+
}
35+
36+
/**
37+
* Create a unique temp base inside /var/tmp, which has no CLEO or git
38+
* sentinels in any of its ancestors (/var, /).
39+
*
40+
* Used for the E_NO_PROJECT orphan scenario where Node.js tmpdir() ancestors
41+
* may contain .cleo/ on a development machine.
42+
*/
43+
function makeTempBaseIsolated(label: string): string {
44+
const dir = join(
45+
'/var/tmp',
46+
`cleo-walkup-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
47+
);
48+
mkdirSync(dir, { recursive: true });
49+
return dir;
50+
}
51+
52+
/** Snapshot and restore CLEO_ROOT around a test run. */
53+
function useCleanEnv(): { restore: () => void } {
54+
const savedRoot = process.env['CLEO_ROOT'];
55+
delete process.env['CLEO_ROOT'];
56+
return {
57+
restore() {
58+
if (savedRoot !== undefined) {
59+
process.env['CLEO_ROOT'] = savedRoot;
60+
} else {
61+
delete process.env['CLEO_ROOT'];
62+
}
63+
},
64+
};
65+
}
66+
67+
// ---------------------------------------------------------------------------
68+
// Test suite
69+
// ---------------------------------------------------------------------------
70+
71+
describe('getProjectRoot — walk-up algorithm', () => {
72+
let tempBase: string;
73+
let restoreEnv: () => void;
74+
75+
beforeEach(() => {
76+
const env = useCleanEnv();
77+
restoreEnv = env.restore;
78+
});
79+
80+
afterEach(() => {
81+
restoreEnv();
82+
if (tempBase) {
83+
try {
84+
rmSync(tempBase, { recursive: true, force: true });
85+
} catch {
86+
/* ignore cleanup errors */
87+
}
88+
}
89+
});
90+
91+
// -------------------------------------------------------------------------
92+
// AC-1: Nested subdir with parent .cleo/
93+
// -------------------------------------------------------------------------
94+
describe('AC-1: nested subdir resolves to parent containing .cleo/', () => {
95+
it('returns the project root when called from a nested subdirectory', () => {
96+
tempBase = makeTempBase('ac1');
97+
// Fixture: tmp/project/.cleo/ + tmp/project/src/nested/
98+
const projectRoot = join(tempBase, 'project');
99+
mkdirSync(join(projectRoot, '.cleo'), { recursive: true });
100+
const nestedDir = join(projectRoot, 'src', 'nested');
101+
mkdirSync(nestedDir, { recursive: true });
102+
103+
const result = getProjectRoot(nestedDir);
104+
expect(result).toBe(projectRoot);
105+
});
106+
107+
it('returns the root itself when called directly with the project root', () => {
108+
tempBase = makeTempBase('ac1-direct');
109+
const projectRoot = join(tempBase, 'project');
110+
mkdirSync(join(projectRoot, '.cleo'), { recursive: true });
111+
112+
const result = getProjectRoot(projectRoot);
113+
expect(result).toBe(projectRoot);
114+
});
115+
});
116+
117+
// -------------------------------------------------------------------------
118+
// AC-2: Nested in git-only repo — E_NOT_INITIALIZED
119+
// -------------------------------------------------------------------------
120+
describe('AC-2: git-only repo without .cleo/ throws E_NOT_INITIALIZED', () => {
121+
it('throws when .git/ exists but .cleo/ does not', () => {
122+
tempBase = makeTempBase('ac2');
123+
// Fixture: tmp/repo/.git/ + tmp/repo/src/
124+
const repoRoot = join(tempBase, 'repo');
125+
mkdirSync(join(repoRoot, '.git'), { recursive: true });
126+
const srcDir = join(repoRoot, 'src');
127+
mkdirSync(srcDir, { recursive: true });
128+
129+
expect(() => getProjectRoot(srcDir)).toThrow(/Run cleo init at/);
130+
});
131+
132+
it('error message contains the git root path', () => {
133+
tempBase = makeTempBase('ac2-path');
134+
const repoRoot = join(tempBase, 'repo');
135+
mkdirSync(join(repoRoot, '.git'), { recursive: true });
136+
const srcDir = join(repoRoot, 'src');
137+
mkdirSync(srcDir, { recursive: true });
138+
139+
let caught: Error | undefined;
140+
try {
141+
getProjectRoot(srcDir);
142+
} catch (err) {
143+
caught = err as Error;
144+
}
145+
146+
expect(caught).toBeDefined();
147+
expect(caught?.message).toContain(repoRoot);
148+
});
149+
});
150+
151+
// -------------------------------------------------------------------------
152+
// AC-3: Orphan dir — E_NO_PROJECT
153+
// -------------------------------------------------------------------------
154+
describe('AC-3: orphan dir with no .cleo/ and no .git/ throws E_NO_PROJECT', () => {
155+
it('throws when neither sentinel is found in any ancestor', () => {
156+
// Use /var/tmp for isolation: /var and /var/tmp have no .cleo/ or .git/,
157+
// and the filesystem root / also has none. All ancestors between
158+
// our test dir and the filesystem root are guaranteed clean on any
159+
// standard Linux system.
160+
//
161+
// We cannot use Node.js tmpdir() here because on development machines
162+
// the user home directory (or the tmpdir itself) may already contain
163+
// a .cleo/ dir, which would be found by the walk-up and suppress the error.
164+
const orphanBase = makeTempBaseIsolated('ac3');
165+
const orphanDir = join(orphanBase, 'nested', 'path');
166+
mkdirSync(orphanDir, { recursive: true });
167+
168+
try {
169+
expect(() => getProjectRoot(orphanDir)).toThrow(/Not inside a CLEO project/);
170+
} finally {
171+
try {
172+
rmSync(orphanBase, { recursive: true, force: true });
173+
} catch {
174+
/* ignore cleanup errors */
175+
}
176+
}
177+
});
178+
});
179+
180+
// -------------------------------------------------------------------------
181+
// AC-4: CLEO_ROOT env var override
182+
// -------------------------------------------------------------------------
183+
describe('AC-4: CLEO_ROOT env var bypasses walk entirely', () => {
184+
it('returns CLEO_ROOT regardless of cwd when cwd has no sentinel', () => {
185+
// AC-4 also demonstrates that CLEO_ROOT prevents E_NO_PROJECT from throwing
186+
// even for a path that would otherwise be an orphan.
187+
const orphanBase = makeTempBaseIsolated('ac4');
188+
const orphanDir = join(orphanBase, 'nested');
189+
mkdirSync(orphanDir, { recursive: true });
190+
191+
process.env['CLEO_ROOT'] = '/overridden/root';
192+
try {
193+
expect(getProjectRoot(orphanDir)).toBe('/overridden/root');
194+
} finally {
195+
try {
196+
rmSync(orphanBase, { recursive: true, force: true });
197+
} catch {
198+
/* ignore cleanup errors */
199+
}
200+
}
201+
});
202+
203+
it('returns CLEO_ROOT when called with no arguments', () => {
204+
process.env['CLEO_ROOT'] = '/another/override';
205+
expect(getProjectRoot()).toBe('/another/override');
206+
});
207+
208+
it('returns CLEO_ROOT even when cwd has .cleo/ present', () => {
209+
tempBase = makeTempBase('ac4-override');
210+
mkdirSync(join(tempBase, '.cleo'), { recursive: true });
211+
212+
process.env['CLEO_ROOT'] = '/forced/root';
213+
// CLEO_ROOT should win even when a valid project exists at tempBase
214+
expect(getProjectRoot(tempBase)).toBe('/forced/root');
215+
});
216+
});
217+
218+
// -------------------------------------------------------------------------
219+
// AC-5: Ancestor boundary — CRITICAL anti-drift gate
220+
// -------------------------------------------------------------------------
221+
describe('AC-5: ancestor boundary stops at inner .cleo/, never drifts to outer', () => {
222+
it('returns the inner project root, not the outer one', () => {
223+
tempBase = makeTempBase('ac5');
224+
// Fixture:
225+
// tmp/outer/.cleo/ <- outer project
226+
// tmp/outer/inner/.cleo/ <- inner project
227+
// tmp/outer/inner/src/ <- cwd
228+
// Expected: returns tmp/outer/inner (first hit walking up from src)
229+
const outerRoot = join(tempBase, 'outer');
230+
const innerRoot = join(outerRoot, 'inner');
231+
const srcDir = join(innerRoot, 'src');
232+
233+
mkdirSync(join(outerRoot, '.cleo'), { recursive: true });
234+
mkdirSync(join(innerRoot, '.cleo'), { recursive: true });
235+
mkdirSync(srcDir, { recursive: true });
236+
237+
const result = getProjectRoot(srcDir);
238+
expect(result).toBe(innerRoot);
239+
});
240+
241+
it('does NOT return the outer project root when an inner .cleo/ exists', () => {
242+
tempBase = makeTempBase('ac5-anti');
243+
const outerRoot = join(tempBase, 'outer');
244+
const innerRoot = join(outerRoot, 'inner');
245+
const srcDir = join(innerRoot, 'src');
246+
247+
mkdirSync(join(outerRoot, '.cleo'), { recursive: true });
248+
mkdirSync(join(innerRoot, '.cleo'), { recursive: true });
249+
mkdirSync(srcDir, { recursive: true });
250+
251+
const result = getProjectRoot(srcDir);
252+
expect(result).not.toBe(outerRoot);
253+
});
254+
255+
it('three-level nesting: always returns the innermost project', () => {
256+
tempBase = makeTempBase('ac5-3level');
257+
const level1 = join(tempBase, 'l1');
258+
const level2 = join(level1, 'l2');
259+
const level3 = join(level2, 'l3');
260+
const deepDir = join(level3, 'deep', 'path');
261+
262+
mkdirSync(join(level1, '.cleo'), { recursive: true });
263+
mkdirSync(join(level2, '.cleo'), { recursive: true });
264+
mkdirSync(join(level3, '.cleo'), { recursive: true });
265+
mkdirSync(deepDir, { recursive: true });
266+
267+
const result = getProjectRoot(deepDir);
268+
expect(result).toBe(level3);
269+
});
270+
});
271+
272+
// -------------------------------------------------------------------------
273+
// AC-6: Integration-equivalent — deep nesting walks up to .cleo/ root
274+
// -------------------------------------------------------------------------
275+
describe('AC-6: integration-equivalent deep nesting', () => {
276+
it('resolves correctly from several levels deep inside a project', () => {
277+
tempBase = makeTempBase('ac6');
278+
// Simulate: monorepo-root/.cleo/ + monorepo-root/packages/lafs/src/lib/
279+
const monoRoot = join(tempBase, 'monorepo');
280+
const lafsDir = join(monoRoot, 'packages', 'lafs', 'src', 'lib');
281+
282+
mkdirSync(join(monoRoot, '.cleo'), { recursive: true });
283+
mkdirSync(lafsDir, { recursive: true });
284+
285+
const result = getProjectRoot(lafsDir);
286+
expect(result).toBe(monoRoot);
287+
});
288+
289+
it('stops at the nearest .cleo/ when intermediate package has its own', () => {
290+
tempBase = makeTempBase('ac6-nested-pkg');
291+
// Simulate: monorepo-root/.cleo/ + monorepo-root/packages/core/.cleo/
292+
// calling from monorepo-root/packages/core/src/
293+
const monoRoot = join(tempBase, 'monorepo');
294+
const coreRoot = join(monoRoot, 'packages', 'core');
295+
const coreSrc = join(coreRoot, 'src');
296+
297+
mkdirSync(join(monoRoot, '.cleo'), { recursive: true });
298+
mkdirSync(join(coreRoot, '.cleo'), { recursive: true });
299+
mkdirSync(coreSrc, { recursive: true });
300+
301+
const result = getProjectRoot(coreSrc);
302+
expect(result).toBe(coreRoot);
303+
});
304+
});
305+
});

0 commit comments

Comments
 (0)