Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions packages/cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@ export const IDLE_THRESHOLD_MS = 300_000;
*
* Performance: a few `existsSync` checks per level, no spawning — stays within
* the ~2ms hook budget.
*
* @param exists - marker-existence predicate; injectable for tests, defaults to `existsSync`.
*/
export const resolveProjectRoot = (start: string): string => {
// @adw clens-014#R2: injectable predicate lets tests pin the fallbacks without a real parent walk.
export const resolveProjectRoot = (
start: string,
exists: (path: string) => boolean = existsSync,
): string => {
const walkUp = (dir: string, marker: string): string | undefined => {
if (existsSync(resolve(dir, marker))) return dir;
if (exists(resolve(dir, marker))) return dir;
const parent = dirname(dir);
return parent === dir ? undefined : walkUp(parent, marker);
};
Expand Down
114 changes: 83 additions & 31 deletions packages/cli/test/hook.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { StoredEvent } from "../src/types";

const TEST_DIR = "/tmp/clens-test-hook";
const ROOT_PREFIX = join(tmpdir(), "clens-test-hook-");
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const HOOK_SCRIPT = resolve(PKG_ROOT, "src/hook.ts");

const markersIn = (dir: string) => [".clens", ".git"].filter((m) => existsSync(join(dir, m)));

// @adw clens-014#R1,R2: each test owns a mkdtemp root seeded with its own `.clens` marker, so
// the nearest marker wins and a stray `.clens` in a shared parent cannot capture the walk.
const seenRoots: string[] = [];
let TEST_DIR: string;

beforeEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true });
mkdirSync(TEST_DIR, { recursive: true });
TEST_DIR = mkdtempSync(ROOT_PREFIX);
seenRoots.push(TEST_DIR);
mkdirSync(`${TEST_DIR}/.clens`);
});

afterEach(() => {
Expand Down Expand Up @@ -184,19 +193,21 @@ describe("hook handler", () => {
expect(stored.event).toBe("PreToolUse");
});

test("nested cwd falls back to .git root when no .clens exists yet", async () => {
// Project root has a .git but no .clens yet (first event of a session).
mkdirSync(`${TEST_DIR}/.git`, { recursive: true });
const nestedCwd = `${TEST_DIR}/packages/cli/src`;
mkdirSync(nestedCwd, { recursive: true });
// Regression: a resolved root nested under `.clens/` produced a recursive
// `.clens/sessions/.clens/sessions` capture dir that self-perpetuated. The hook
// must refuse to write any path nested under a `.clens/` segment.
test("refuses to write when the resolved root is nested under .clens", async () => {
// A capture dir already nested under `.clens/` (the recursion seed).
const nestedRoot = `${TEST_DIR}/.clens/sessions`;
mkdirSync(`${nestedRoot}/.clens/sessions`, { recursive: true });

const payload = JSON.stringify({
session_id: "git-root-session",
cwd: nestedCwd,
session_id: "recursive-session",
cwd: nestedRoot,
hook_event_name: "PreToolUse",
tool_name: "Read",
tool_input: { file_path: "/bar.ts" },
tool_use_id: "t-git",
tool_input: { file_path: "/baz.ts" },
tool_use_id: "t-recursive",
transcript_path: "/tmp/t.jsonl",
permission_mode: "default",
});
Expand All @@ -210,25 +221,58 @@ describe("hook handler", () => {

await proc.exited;

expect(existsSync(`${TEST_DIR}/.clens/sessions/git-root-session.jsonl`)).toBe(true);
expect(existsSync(`${nestedCwd}/.clens`)).toBe(false);
// No event written anywhere under the nested `.clens/`.
expect(existsSync(`${nestedRoot}/.clens/sessions/recursive-session.jsonl`)).toBe(false);
expect(existsSync(`${TEST_DIR}/.clens/sessions/recursive-session.jsonl`)).toBe(false);
});

// Regression: a resolved root nested under `.clens/` produced a recursive
// `.clens/sessions/.clens/sessions` capture dir that self-perpetuated. The hook
// must refuse to write any path nested under a `.clens/` segment.
test("refuses to write when the resolved root is nested under .clens", async () => {
// A capture dir already nested under `.clens/` (the recursion seed).
const nestedRoot = `${TEST_DIR}/.clens/sessions`;
mkdirSync(`${nestedRoot}/.clens/sessions`, { recursive: true });
// @adw clens-014#R2: a `.clens` in the parent of the project root must not capture the event.
test("ignores a .clens in the parent of the project root", async () => {
const outer = mkdtempSync(ROOT_PREFIX);
try {
mkdirSync(`${outer}/.clens`);
const project = `${outer}/proj`;
const cwd = `${project}/src`;
mkdirSync(`${project}/.clens`, { recursive: true });
mkdirSync(cwd, { recursive: true });

const payload = JSON.stringify({
session_id: "parent-marker-session",
cwd,
hook_event_name: "PreToolUse",
tool_name: "Read",
tool_input: { file_path: "/qux.ts" },
tool_use_id: "t-parent",
transcript_path: "/tmp/t.jsonl",
permission_mode: "default",
});

const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "PreToolUse"], {
stdin: new Response(payload),
stdout: "pipe",
stderr: "pipe",
cwd: PKG_ROOT,
});

await proc.exited;

expect(existsSync(`${project}/.clens/sessions/parent-marker-session.jsonl`)).toBe(true);
expect(existsSync(`${outer}/.clens/sessions`)).toBe(false);
} finally {
rmSync(outer, { recursive: true, force: true });
}
});

// @adw clens-014#R1: the run writes nothing outside its private mkdtemp root.
test("R1: hook run stays inside its private root", async () => {
const parentBefore = markersIn(dirname(TEST_DIR));
const payload = JSON.stringify({
session_id: "recursive-session",
cwd: nestedRoot,
session_id: "private-root-session",
cwd: TEST_DIR,
hook_event_name: "PreToolUse",
tool_name: "Read",
tool_input: { file_path: "/baz.ts" },
tool_use_id: "t-recursive",
tool_name: "Bash",
tool_input: { command: "ls" },
tool_use_id: "t-private",
transcript_path: "/tmp/t.jsonl",
permission_mode: "default",
});
Expand All @@ -242,8 +286,16 @@ describe("hook handler", () => {

await proc.exited;

// No event written anywhere under the nested `.clens/`.
expect(existsSync(`${nestedRoot}/.clens/sessions/recursive-session.jsonl`)).toBe(false);
expect(existsSync(`${TEST_DIR}/.clens/sessions/recursive-session.jsonl`)).toBe(false);
expect(TEST_DIR.startsWith(ROOT_PREFIX)).toBe(true);
expect(markersIn(dirname(TEST_DIR))).toEqual(parentBefore);
expect(existsSync(`${TEST_DIR}/.clens/sessions/private-root-session.jsonl`)).toBe(true);
});

// @adw clens-014#R1: roots from earlier tests are distinct and gone.
test("R1: previous hook test roots were removed by afterEach", () => {
expect(new Set(seenRoots).size).toBe(seenRoots.length);
const previous = seenRoots.filter((root) => root !== TEST_DIR);
expect(previous.length).toBeGreaterThan(0);
expect(previous.filter((root) => existsSync(root))).toEqual([]);
});
});
98 changes: 89 additions & 9 deletions packages/cli/test/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
formatBytes,
formatDuration,
Expand Down Expand Up @@ -104,11 +106,18 @@ describe("sanitizeAgentName", () => {
});
});

const markersIn = (dir: string) => [".clens", ".git"].filter((m) => existsSync(join(dir, m)));

// @adw clens-014#R1,R2: every root is a private mkdtemp dir; fallback cases use an injected
// fake `exists` so no parent marker on the host can change the result.
describe("resolveProjectRoot", () => {
const ROOT = `/tmp/clens-test-resolve-root-${process.pid}`;
const ROOT_PREFIX = join(tmpdir(), "clens-test-resolve-root-");
const seenRoots: string[] = [];
let ROOT: string;

beforeEach(() => {
rmSync(ROOT, { recursive: true, force: true });
ROOT = mkdtempSync(ROOT_PREFIX);
seenRoots.push(ROOT);
mkdirSync(`${ROOT}/.clens/sessions`, { recursive: true });
mkdirSync(`${ROOT}/packages/web/src/client/assets`, { recursive: true });
});
Expand All @@ -133,14 +142,85 @@ describe("resolveProjectRoot", () => {
});

test("falls back to .git when no .clens exists on the path", () => {
rmSync(`${ROOT}/.clens`, { recursive: true, force: true });
mkdirSync(`${ROOT}/.git`, { recursive: true });
expect(resolveProjectRoot(`${ROOT}/packages/web/src/client/assets`)).toBe(ROOT);
const markers = new Set(["/p/.git"]);
expect(resolveProjectRoot("/p/a/b/c", (path) => markers.has(path))).toBe("/p");
});

test("falls back to the start dir when neither .clens nor .git is found", () => {
const orphan = `${ROOT}/packages/web/src/client/assets`;
rmSync(`${ROOT}/.clens`, { recursive: true, force: true });
expect(resolveProjectRoot(orphan)).toBe(orphan);
expect(resolveProjectRoot("/p/a/b/c", () => false)).toBe("/p/a/b/c");
});

test("prefers .clens over a nearer .git", () => {
const markers = new Set(["/p/.clens", "/p/a/.git"]);
expect(resolveProjectRoot("/p/a/b", (path) => markers.has(path))).toBe("/p");
});

// @adw clens-014#R2: boundary cases of the injected predicate — no real fs, so host-immune.
test("returns the start dir when it contains the .git marker itself", () => {
const markers = new Set(["/p/a/b/.git"]);
expect(resolveProjectRoot("/p/a/b", (path) => markers.has(path))).toBe("/p/a/b");
});

test("finds a .clens at the filesystem root", () => {
const markers = new Set(["/.clens"]);
expect(resolveProjectRoot("/p/a", (path) => markers.has(path))).toBe("/");
});

test("terminates at the filesystem root when started there with no markers", () => {
expect(resolveProjectRoot("/", () => false)).toBe("/");
});

test("probes every .clens level before any .git level, nearest first", () => {
const probed: string[] = [];
const result = resolveProjectRoot("/p/a", (path) => {
probed.push(path);
return false;
});
expect(result).toBe("/p/a");
expect(probed).toEqual([
"/p/a/.clens",
"/p/.clens",
"/.clens",
"/p/a/.git",
"/p/.git",
"/.git",
]);
});

test("stops probing at the first .clens hit", () => {
const probed: string[] = [];
const result = resolveProjectRoot("/p/a/b", (path) => {
probed.push(path);
return path === "/p/a/.clens";
});
expect(result).toBe("/p/a");
expect(probed).toEqual(["/p/a/b/.clens", "/p/a/.clens"]);
});

test("ignores a .clens in the parent of the project root", () => {
const outer = mkdtempSync(ROOT_PREFIX);
try {
mkdirSync(`${outer}/.clens`, { recursive: true });
mkdirSync(`${outer}/proj/.clens`, { recursive: true });
mkdirSync(`${outer}/proj/sub`, { recursive: true });
expect(resolveProjectRoot(`${outer}/proj/sub`)).toBe(`${outer}/proj`);
} finally {
rmSync(outer, { recursive: true, force: true });
}
});

test("R1: owns a private mkdtemp root and writes no marker to its parent", () => {
expect(ROOT.startsWith(ROOT_PREFIX)).toBe(true);
expect(ROOT).not.toBe(`/tmp/clens-test-resolve-root-${process.pid}`);
const before = markersIn(dirname(ROOT));
resolveProjectRoot(join(ROOT, "packages/web/src/client/assets"));
expect(markersIn(dirname(ROOT))).toEqual(before);
});

test("R1: previous test roots were removed by afterEach", () => {
expect(new Set(seenRoots).size).toBe(seenRoots.length);
const previous = seenRoots.filter((root) => root !== ROOT);
expect(previous.length).toBeGreaterThan(0);
expect(previous.filter((root) => existsSync(root))).toEqual([]);
});
});
Loading