aboutsummaryrefslogtreecommitdiffstats
path: root/cli
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 21:55:12 +0200
committeruser <user@clank>2026-08-12 21:55:12 +0200
commit72441afa75c058e4b1454108d962f2bebaee941b (patch)
treed668f1f61d8cbf19127013724db50cf680aa59c9 /cli
parentdocs: agree the sl0ppi CLI design (diff)
downloadsl0ppi-72441afa75c058e4b1454108d962f2bebaee941b.tar.gz
sl0ppi-72441afa75c058e4b1454108d962f2bebaee941b.tar.xz
sl0ppi-72441afa75c058e4b1454108d962f2bebaee941b.zip
cli: the idempotency core (discovery, tab identity, plan diff)
Slice 1 of docs/cli-design.md: the only part with logic worth testing hard. Pure functions, no subprocesses, so the tests hit them directly. purpose.ts tab identity as a zellij tab purpose carrying the project's root-relative path discover.ts depth-1 scan for .sl0ppi/ markers plan.ts discovered projects vs list-tabs -> {create, close, keep, foreign} Identity carries the *path* because `list-panes --json` exposes no cwd (checked against a live session, not assumed), so there is otherwise no way back from a tab to a directory on disk. Slashes survive the fork's sanitiser, so nested paths encode fine. Two safety properties, both fuzzed over 5000 random names: - a purpose never exceeds 64 chars and always survives re-sanitising, so what we generate is what the terminal stores - projectRelPath() reconstructs the original path exactly or returns null -- never a different path. Wrong answers close the wrong tab; refusing only leaves a stale one. The fuzz caught a real bug in the second property. The first implementation detected lossy encodings by re-encoding and comparing, which silently failed: `project:<47 a's>.<hash8>` is itself 64 legal characters, so it re-encodes to itself, the check passed, and a bogus path was "reconstructed" -- which would have closed a live tab. Lossy purposes now carry a structural marker (a leading dot, which no discovered path can have) instead of relying on a comparison. Plus the mount guard from the design: when the ROOT itself is missing, close nothing. An unmounted filesystem is indistinguishable from mass deletion, and one of those two readings eats your whole session. 31 unit tests + 2 fuzz properties, 10178 assertions, green.
Diffstat (limited to 'cli')
-rw-r--r--cli/package.json11
-rw-r--r--cli/src/core/discover.ts79
-rw-r--r--cli/src/core/plan.ts119
-rw-r--r--cli/src/core/purpose.ts94
-rw-r--r--cli/test/discover.test.ts68
-rw-r--r--cli/test/plan.test.ts147
-rw-r--r--cli/test/purpose.test.ts140
7 files changed, 658 insertions, 0 deletions
diff --git a/cli/package.json b/cli/package.json
new file mode 100644
index 0000000..2d36588
--- /dev/null
+++ b/cli/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "sl0ppi-cli",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Orchestrates sl0ppij sessions: one root dir per session, one project per tab",
+ "scripts": {
+ "test": "bun test",
+ "typecheck": "bunx tsc --noEmit"
+ }
+}
diff --git a/cli/src/core/discover.ts b/cli/src/core/discover.ts
new file mode 100644
index 0000000..3511b3e
--- /dev/null
+++ b/cli/src/core/discover.ts
@@ -0,0 +1,79 @@
+/**
+ * Project discovery.
+ *
+ * A project is a directory under the root containing a `.sl0ppi/` marker.
+ * Depth-1 by default: recursively scanning ~/dev is slow and surprising, and
+ * an explicit `sl0ppi open path/to/nested` covers the rest.
+ */
+
+import { readdirSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+export const MARKER_DIR = ".sl0ppi";
+export const LAYOUT_FILE = "layout.kdl";
+
+export interface DiscoverOptions {
+ /** How many directory levels below the root to search. Default 1. */
+ depth?: number;
+}
+
+/**
+ * Paths of discovered projects, relative to `root`, sorted alphabetically.
+ *
+ * Unreadable directories are skipped rather than fatal: one bad-permission
+ * directory in ~/dev must not stop the other eleven projects from opening.
+ */
+export function discoverProjects(root: string, options: DiscoverOptions = {}): string[] {
+ const depth = options.depth ?? 1;
+ const found: string[] = [];
+ walk(root, "", depth, found);
+ return found.sort();
+}
+
+function walk(root: string, rel: string, depthLeft: number, found: string[]): void {
+ if (depthLeft <= 0) return;
+
+ let entries;
+ try {
+ entries = readdirSync(join(root, rel), { withFileTypes: true });
+ } catch {
+ return; // unreadable: skip, do not throw
+ }
+
+ for (const entry of entries) {
+ // Skip dotfiles: `.git` and friends are never projects, and this also
+ // stops us descending into the marker directory itself.
+ if (entry.name.startsWith(".")) continue;
+
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
+ const childAbs = join(root, childRel);
+
+ // Follow symlinked directories (a worktree or a symlinked checkout is a
+ // perfectly ordinary way to lay out ~/dev), but never fail on a broken one.
+ let isDir = entry.isDirectory();
+ if (!isDir && entry.isSymbolicLink()) {
+ try {
+ isDir = statSync(childAbs).isDirectory();
+ } catch {
+ continue; // dangling symlink
+ }
+ }
+ if (!isDir) continue;
+
+ if (isProjectDir(childAbs)) {
+ found.push(childRel);
+ continue; // a project is a leaf; do not nest projects inside projects
+ }
+
+ walk(root, childRel, depthLeft - 1, found);
+ }
+}
+
+/** A directory is a project when it carries the `.sl0ppi/` marker directory. */
+export function isProjectDir(absPath: string): boolean {
+ try {
+ return statSync(join(absPath, MARKER_DIR)).isDirectory();
+ } catch {
+ return false;
+ }
+}
diff --git a/cli/src/core/plan.ts b/cli/src/core/plan.ts
new file mode 100644
index 0000000..7c947d2
--- /dev/null
+++ b/cli/src/core/plan.ts
@@ -0,0 +1,119 @@
+/**
+ * The idempotency core.
+ *
+ * `sl0ppi up` must be a no-op the second time you run it. Everything else in
+ * the CLI is plumbing; if this diff is wrong the tool is worse than useless.
+ * So it is a pure function over data — no filesystem, no subprocesses — and the
+ * tests hammer it directly.
+ */
+
+import { isProjectPurpose, projectPurpose, projectRelPath } from "./purpose.ts";
+
+export interface TabInfo {
+ /** zellij tab position, which is what `go-to-tab` wants. */
+ position: number;
+ name: string;
+ purpose?: string;
+}
+
+export interface PlanInput {
+ /** Absolute path of the root whose session this is. */
+ root: string;
+ /** Whether the root itself is present (see the mount guard below). */
+ rootExists: boolean;
+ /** Discovered projects, as paths relative to the root. */
+ projects: string[];
+ /** Tabs currently in the session. */
+ tabs: TabInfo[];
+ /** Injected so the planner stays pure and testable. */
+ pathExists: (absPath: string) => boolean;
+}
+
+export interface CreateAction {
+ relPath: string;
+ purpose: string;
+}
+
+export interface Plan {
+ create: CreateAction[];
+ close: TabInfo[];
+ /** Project tabs that already match a discovered project. */
+ keep: TabInfo[];
+ /** Tabs sl0ppi does not manage (no `project:` purpose). Never touched. */
+ foreign: TabInfo[];
+}
+
+export function planTabs(input: PlanInput): Plan {
+ const { root, rootExists, projects, tabs, pathExists } = input;
+
+ // Expected state: one tab per discovered project, keyed by purpose.
+ const expected = new Map<string, string>(); // purpose -> relPath
+ for (const relPath of projects) expected.set(projectPurpose(relPath), relPath);
+
+ const create: CreateAction[] = [];
+ const close: TabInfo[] = [];
+ const keep: TabInfo[] = [];
+ const foreign: TabInfo[] = [];
+ const seen = new Set<string>();
+
+ for (const tab of tabs) {
+ if (!isProjectPurpose(tab.purpose)) {
+ // Someone else's tab (a scratch tab the user opened by hand). Not ours.
+ foreign.push(tab);
+ continue;
+ }
+ const purpose = tab.purpose as string;
+
+ if (expected.has(purpose)) {
+ keep.push(tab);
+ seen.add(purpose);
+ continue;
+ }
+
+ // Not a project we discovered. Either its directory is gone, or it is a
+ // project outside the depth-1 scan that was opened explicitly. Only the
+ // first justifies closing a tab, and closing is destructive, so every
+ // uncertain case keeps the tab.
+ if (!rootExists) {
+ // The ROOT is missing, not the project. That is an unmounted filesystem,
+ // not twelve deleted projects: closing here would eat the whole session.
+ keep.push(tab);
+ continue;
+ }
+
+ const relPath = projectRelPath(purpose);
+ if (relPath === null) {
+ // Purpose does not reconstruct (truncated/hashed), so we cannot check
+ // the directory. Refuse to guess.
+ keep.push(tab);
+ continue;
+ }
+
+ if (pathExists(joinPath(root, relPath))) {
+ // Directory is there; it simply was not discovered (nested, or its
+ // .sl0ppi/ marker was removed). Leave it alone.
+ keep.push(tab);
+ continue;
+ }
+
+ close.push(tab);
+ }
+
+ // Alphabetical, per the agreed MVP ordering.
+ for (const relPath of [...projects].sort()) {
+ const purpose = projectPurpose(relPath);
+ if (!seen.has(purpose)) create.push({ relPath, purpose });
+ }
+
+ return { create, close, keep, foreign };
+}
+
+/** Trivial join; avoids pulling node:path into a pure module. */
+function joinPath(root: string, rel: string): string {
+ return root.endsWith("/") ? `${root}${rel}` : `${root}/${rel}`;
+}
+
+/** True when applying this plan would change nothing. */
+export function isNoOp(plan: Plan): boolean {
+ return plan.create.length === 0 && plan.close.length === 0;
+}
diff --git a/cli/src/core/purpose.ts b/cli/src/core/purpose.ts
new file mode 100644
index 0000000..728f27b
--- /dev/null
+++ b/cli/src/core/purpose.ts
@@ -0,0 +1,94 @@
+/**
+ * Tab identity.
+ *
+ * A project tab is keyed by its zellij tab `purpose`, because that is the only
+ * field the fork guarantees is (a) queryable via `list-tabs --json`, (b) not
+ * overridable in-band by whatever a pane is displaying, and (c) restored
+ * through the layout after a session is resurrected. Tab *names* are UI strings
+ * a human renames; they are not identity.
+ *
+ * The purpose carries the project's path RELATIVE TO THE ROOT, because
+ * `list-panes --json` exposes no `cwd` (verified against a live session), so
+ * there is no other way to get from a tab back to a directory on disk. Slashes
+ * survive the fork's sanitiser, so nested paths work.
+ *
+ * The fork's sanitiser: keep [A-Za-z0-9_.:/-], then truncate to 64 chars.
+ * See sanitize_pane_purpose() in zellij-utils/src/input/layout.rs.
+ */
+
+import { createHash } from "node:crypto";
+
+/** MAX_PANE_PURPOSE in the fork. */
+export const PURPOSE_MAX = 64;
+export const PROJECT_PREFIX = "project:";
+
+/** Everything the fork's sanitiser strips. */
+const DISALLOWED = /[^A-Za-z0-9_.:/-]/g;
+
+/** Reproduces sanitize_pane_purpose(): filter, then truncate. */
+export function sanitizePurpose(raw: string): string {
+ return raw.replace(DISALLOWED, "").slice(0, PURPOSE_MAX);
+}
+
+function hash8(value: string): string {
+ return createHash("sha256").update(value).digest("hex").slice(0, 8);
+}
+
+/**
+ * Marks a purpose whose path was lossy and must never be reconstructed.
+ *
+ * A leading dot is safe as a sentinel because discovery skips dot-entries, so
+ * no real relative path we encode can begin with one.
+ *
+ * This has to be *structural*. The first implementation just re-encoded the
+ * candidate and compared, which silently failed: `project:<47 a's>.<hash8>` is
+ * itself 64 chars of legal characters, so it re-encodes to itself, the check
+ * passed, and a bogus path was "reconstructed" — which would have closed a
+ * live tab. Caught by `refuses to reconstruct a truncated/hashed purpose`.
+ */
+const LOSSY_MARK = ".";
+
+/**
+ * The purpose for a project at `relPath` (relative to its root).
+ *
+ * Normally this is simply `project:<relPath>`, which round-trips back to a
+ * path. When the path would not survive sanitising or does not fit, we fall
+ * back to a marked, truncated form plus a hash: still *unique*, deliberately
+ * not *reconstructable*, so `projectRelPath()` refuses it and we never
+ * auto-close a tab we cannot verify.
+ */
+export function projectPurpose(relPath: string): string {
+ const cleaned = sanitizePurpose(relPath);
+ const exact =
+ cleaned === relPath &&
+ cleaned.length > 0 &&
+ !cleaned.startsWith(LOSSY_MARK) &&
+ PROJECT_PREFIX.length + cleaned.length <= PURPOSE_MAX;
+ if (exact) return PROJECT_PREFIX + cleaned;
+
+ // sentinel(1) + head + "."(1) + hash8(8)
+ const budget = PURPOSE_MAX - PROJECT_PREFIX.length - 10;
+ const head = cleaned.slice(0, budget).replace(/[./-]+$/, "") || "project";
+ return `${PROJECT_PREFIX}${LOSSY_MARK}${head}.${hash8(relPath)}`;
+}
+
+export function isProjectPurpose(purpose: string | undefined | null): boolean {
+ return typeof purpose === "string" && purpose.startsWith(PROJECT_PREFIX);
+}
+
+/**
+ * The relative path a purpose encodes, or null when it cannot be reconstructed
+ * exactly.
+ *
+ * Null is not a failure, it is a refusal. A lossy purpose is marked at encode
+ * time and rejected here, so callers cannot mistake `project:.head.a1b2c3d4`
+ * for a directory literally named `head.a1b2c3d4` and close a tab over it. The
+ * re-encode comparison stays as a second line of defence.
+ */
+export function projectRelPath(purpose: string): string | null {
+ if (!isProjectPurpose(purpose)) return null;
+ const candidate = purpose.slice(PROJECT_PREFIX.length);
+ if (candidate.length === 0) return null;
+ if (candidate.startsWith(LOSSY_MARK)) return null;
+ return projectPurpose(candidate) === purpose ? candidate : null;
+}
diff --git a/cli/test/discover.test.ts b/cli/test/discover.test.ts
new file mode 100644
index 0000000..3e23aeb
--- /dev/null
+++ b/cli/test/discover.test.ts
@@ -0,0 +1,68 @@
+import { afterAll, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { discoverProjects, isProjectDir } from "../src/core/discover.ts";
+
+// Disk-backed scratch: /tmp is tmpfs on the dev box, and these are tiny, but
+// the habit matters.
+const root = mkdtempSync(join(tmpdir(), "sl0ppi-discover-"));
+afterAll(() => rmSync(root, { recursive: true, force: true }));
+
+function project(rel: string): void {
+ mkdirSync(join(root, rel, ".sl0ppi"), { recursive: true });
+ writeFileSync(join(root, rel, ".sl0ppi", "layout.kdl"), "pane\n");
+}
+function plainDir(rel: string): void {
+ mkdirSync(join(root, rel), { recursive: true });
+}
+
+project("beta");
+project("alpha");
+plainDir("not-a-project");
+plainDir(".hidden-dir");
+mkdirSync(join(root, ".hidden-dir", ".sl0ppi"), { recursive: true });
+project("group/nested"); // depth 2
+writeFileSync(join(root, "a-file"), "");
+symlinkSync(join(root, "alpha"), join(root, "linked"));
+symlinkSync(join(root, "nowhere"), join(root, "broken-link"));
+
+describe("discoverProjects", () => {
+ test("finds directories carrying the .sl0ppi marker, alphabetically", () => {
+ expect(discoverProjects(root)).toEqual(["alpha", "beta", "linked"]);
+ });
+
+ test("ignores directories without the marker", () => {
+ expect(discoverProjects(root)).not.toContain("not-a-project");
+ });
+
+ test("ignores dotfiles even when they carry a marker", () => {
+ expect(discoverProjects(root)).not.toContain(".hidden-dir");
+ });
+
+ test("is depth-1 by default", () => {
+ expect(discoverProjects(root)).not.toContain("group/nested");
+ });
+
+ test("finds nested projects when asked", () => {
+ expect(discoverProjects(root, { depth: 2 })).toContain("group/nested");
+ });
+
+ test("follows symlinked project dirs but survives broken ones", () => {
+ const found = discoverProjects(root);
+ expect(found).toContain("linked");
+ expect(found).not.toContain("broken-link");
+ });
+
+ test("returns empty for a missing root instead of throwing", () => {
+ expect(discoverProjects(join(root, "does-not-exist"))).toEqual([]);
+ });
+});
+
+describe("isProjectDir", () => {
+ test("requires the marker to be a directory", () => {
+ expect(isProjectDir(join(root, "alpha"))).toBe(true);
+ expect(isProjectDir(join(root, "not-a-project"))).toBe(false);
+ expect(isProjectDir(join(root, "nope"))).toBe(false);
+ });
+});
diff --git a/cli/test/plan.test.ts b/cli/test/plan.test.ts
new file mode 100644
index 0000000..d7bb8ba
--- /dev/null
+++ b/cli/test/plan.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, test } from "bun:test";
+import { isNoOp, planTabs, type TabInfo } from "../src/core/plan.ts";
+import { projectPurpose } from "../src/core/purpose.ts";
+
+const ROOT = "/home/user/dev";
+
+/** Every path under the root exists unless a test says otherwise. */
+function existsAll(): (p: string) => boolean {
+ return () => true;
+}
+function existsOnly(paths: string[]): (p: string) => boolean {
+ const set = new Set(paths);
+ return (p) => set.has(p);
+}
+
+function tab(position: number, name: string, purpose?: string): TabInfo {
+ return { position, name, purpose };
+}
+
+describe("planTabs", () => {
+ test("creates one tab per discovered project, alphabetically", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: ["zeta", "alpha", "mid"],
+ tabs: [],
+ pathExists: existsAll(),
+ });
+
+ expect(plan.create.map((c) => c.relPath)).toEqual(["alpha", "mid", "zeta"]);
+ expect(plan.close).toHaveLength(0);
+ });
+
+ // THE property. If this ever fails, the tool is worse than useless.
+ test("is a no-op when every project already has its tab", () => {
+ const projects = ["alpha", "mid", "zeta"];
+ const tabs = projects.map((p, i) => tab(i, p, projectPurpose(p)));
+
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects,
+ tabs,
+ pathExists: existsAll(),
+ });
+
+ expect(isNoOp(plan)).toBe(true);
+ expect(plan.keep).toHaveLength(3);
+ });
+
+ test("applying the plan twice converges (second run is a no-op)", () => {
+ const projects = ["alpha", "beta"];
+ let tabs: TabInfo[] = [];
+
+ const first = planTabs({ root: ROOT, rootExists: true, projects, tabs, pathExists: existsAll() });
+ // simulate applying it
+ tabs = first.create.map((c, i) => tab(i, c.relPath, c.purpose));
+
+ const second = planTabs({ root: ROOT, rootExists: true, projects, tabs, pathExists: existsAll() });
+ expect(first.create).toHaveLength(2);
+ expect(isNoOp(second)).toBe(true);
+ });
+
+ test("creates only what is missing", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: ["alpha", "beta"],
+ tabs: [tab(0, "alpha", projectPurpose("alpha"))],
+ pathExists: existsAll(),
+ });
+
+ expect(plan.create.map((c) => c.relPath)).toEqual(["beta"]);
+ expect(plan.keep).toHaveLength(1);
+ });
+
+ test("closes a tab whose project directory is gone", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: ["alpha"],
+ tabs: [tab(0, "alpha", projectPurpose("alpha")), tab(1, "deleted", projectPurpose("deleted"))],
+ pathExists: existsOnly([`${ROOT}/alpha`]),
+ });
+
+ expect(plan.close.map((t) => t.name)).toEqual(["deleted"]);
+ });
+
+ // The mount guard: an unmounted root looks exactly like mass deletion.
+ test("closes NOTHING when the root itself is missing", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: false,
+ projects: [],
+ tabs: [
+ tab(0, "alpha", projectPurpose("alpha")),
+ tab(1, "beta", projectPurpose("beta")),
+ ],
+ pathExists: existsOnly([]),
+ });
+
+ expect(plan.close).toHaveLength(0);
+ expect(plan.keep).toHaveLength(2);
+ });
+
+ test("keeps an existing project that simply was not discovered (nested / marker removed)", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: [],
+ tabs: [tab(0, "nested", projectPurpose("group/nested"))],
+ pathExists: existsOnly([`${ROOT}/group/nested`]),
+ });
+
+ expect(plan.close).toHaveLength(0);
+ expect(plan.keep).toHaveLength(1);
+ });
+
+ test("never touches tabs without a project purpose", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: ["alpha"],
+ tabs: [tab(0, "scratch"), tab(1, "notes", "human:notes")],
+ pathExists: existsOnly([`${ROOT}/alpha`]),
+ });
+
+ expect(plan.close).toHaveLength(0);
+ expect(plan.foreign.map((t) => t.name)).toEqual(["scratch", "notes"]);
+ expect(plan.create.map((c) => c.relPath)).toEqual(["alpha"]);
+ });
+
+ // A purpose that had to be truncated+hashed cannot be turned back into a
+ // path, so we must not "verify" it and close the tab.
+ test("keeps a tab whose purpose cannot be reconstructed", () => {
+ const long = "a".repeat(120);
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: [],
+ tabs: [tab(0, "long", projectPurpose(long))],
+ pathExists: existsOnly([]), // nothing exists
+ });
+
+ expect(plan.close).toHaveLength(0);
+ });
+});
diff --git a/cli/test/purpose.test.ts b/cli/test/purpose.test.ts
new file mode 100644
index 0000000..e036761
--- /dev/null
+++ b/cli/test/purpose.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, test } from "bun:test";
+import {
+ PURPOSE_MAX,
+ isProjectPurpose,
+ projectPurpose,
+ projectRelPath,
+ sanitizePurpose,
+} from "../src/core/purpose.ts";
+
+describe("sanitizePurpose (must match the fork)", () => {
+ test("keeps the fork's allowed set", () => {
+ expect(sanitizePurpose("a-b_c.d:e/f")).toBe("a-b_c.d:e/f");
+ });
+
+ // The fork's own doc uses this exact example.
+ test("strips shell metacharacters", () => {
+ expect(sanitizePurpose('rm -rf /; echo "x"')).toBe("rm-rf/echox");
+ });
+
+ test("strips non-ascii", () => {
+ expect(sanitizePurpose("café–π")).toBe("caf");
+ });
+
+ test("truncates to the fork's limit", () => {
+ expect(sanitizePurpose("x".repeat(200))).toHaveLength(PURPOSE_MAX);
+ });
+});
+
+describe("projectPurpose", () => {
+ test("is the plain relative path for ordinary names", () => {
+ expect(projectPurpose("sl0ppi")).toBe("project:sl0ppi");
+ expect(projectPurpose("group/nested")).toBe("project:group/nested");
+ });
+
+ test("never exceeds what the fork will store", () => {
+ for (const name of ["ok", "a".repeat(200), "wéîrd name!", "x/".repeat(50)]) {
+ expect(projectPurpose(name).length).toBeLessThanOrEqual(PURPOSE_MAX);
+ }
+ });
+
+ test("survives the fork's sanitiser unchanged (round-trip through the terminal)", () => {
+ for (const name of ["ok", "a".repeat(200), "wéîrd name!", "dots...", "x/".repeat(50)]) {
+ const purpose = projectPurpose(name);
+ expect(sanitizePurpose(purpose)).toBe(purpose);
+ }
+ });
+
+ test("distinct projects get distinct purposes, even when truncated", () => {
+ const a = projectPurpose(`${"a".repeat(80)}/one`);
+ const b = projectPurpose(`${"a".repeat(80)}/two`);
+ expect(a).not.toBe(b);
+ });
+
+ test("is stable across calls", () => {
+ expect(projectPurpose("foo")).toBe(projectPurpose("foo"));
+ expect(projectPurpose("wéîrd name!")).toBe(projectPurpose("wéîrd name!"));
+ });
+});
+
+describe("projectRelPath", () => {
+ test("reconstructs an ordinary path", () => {
+ expect(projectRelPath("project:sl0ppi")).toBe("sl0ppi");
+ expect(projectRelPath("project:group/nested")).toBe("group/nested");
+ });
+
+ test("refuses to reconstruct a truncated/hashed purpose", () => {
+ expect(projectRelPath(projectPurpose("a".repeat(200)))).toBeNull();
+ });
+
+ test("refuses non-project purposes", () => {
+ expect(projectRelPath("agent:main")).toBeNull();
+ expect(projectRelPath("project:")).toBeNull();
+ });
+
+ test("round-trips every reconstructable name", () => {
+ for (const name of ["foo", "group/nested", "dots.in.name", "a-b_c"]) {
+ expect(projectRelPath(projectPurpose(name))).toBe(name);
+ }
+ });
+});
+
+// The safety invariant, fuzzed. Closing a tab is destructive, so the one thing
+// projectRelPath must never do is return a path that is not the original.
+// Wrong answers close the wrong tab; refusing (null) only leaves a stale one.
+describe("projectRelPath never lies (fuzz)", () => {
+ const alphabet = "abzAZ09_-./: !@#\u00e9\u4e2d\"';";
+
+ function randomName(rng: () => number): string {
+ const len = 1 + Math.floor(rng() * 90);
+ let out = "";
+ for (let i = 0; i < len; i++) {
+ out += alphabet[Math.floor(rng() * alphabet.length)];
+ }
+ return out;
+ }
+
+ // Deterministic PRNG so a failure is reproducible.
+ function mulberry32(seed: number): () => number {
+ return () => {
+ seed = (seed + 0x6d2b79f5) | 0;
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ }
+
+ test("reconstructs exactly, or refuses — never something else", () => {
+ const rng = mulberry32(0x510991);
+ for (let i = 0; i < 5000; i++) {
+ const name = randomName(rng);
+ const purpose = projectPurpose(name);
+
+ expect(purpose.length).toBeLessThanOrEqual(PURPOSE_MAX);
+ expect(sanitizePurpose(purpose)).toBe(purpose);
+
+ const back = projectRelPath(purpose);
+ if (back !== null) expect(back).toBe(name);
+ }
+ });
+
+ test("distinct names never collide on a purpose", () => {
+ const rng = mulberry32(12345);
+ const seen = new Map<string, string>();
+ for (let i = 0; i < 5000; i++) {
+ const name = randomName(rng);
+ const purpose = projectPurpose(name);
+ const prior = seen.get(purpose);
+ if (prior !== undefined) expect(prior).toBe(name);
+ seen.set(purpose, name);
+ }
+ });
+});
+
+describe("isProjectPurpose", () => {
+ test("classifies", () => {
+ expect(isProjectPurpose("project:foo")).toBe(true);
+ expect(isProjectPurpose("agent:main")).toBe(false);
+ expect(isProjectPurpose(undefined)).toBe(false);
+ });
+});