diff options
| author | user <user@clank> | 2026-08-12 21:55:12 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 21:55:12 +0200 |
| commit | 72441afa75c058e4b1454108d962f2bebaee941b (patch) | |
| tree | d668f1f61d8cbf19127013724db50cf680aa59c9 /cli | |
| parent | docs: agree the sl0ppi CLI design (diff) | |
| download | sl0ppi-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.json | 11 | ||||
| -rw-r--r-- | cli/src/core/discover.ts | 79 | ||||
| -rw-r--r-- | cli/src/core/plan.ts | 119 | ||||
| -rw-r--r-- | cli/src/core/purpose.ts | 94 | ||||
| -rw-r--r-- | cli/test/discover.test.ts | 68 | ||||
| -rw-r--r-- | cli/test/plan.test.ts | 147 | ||||
| -rw-r--r-- | cli/test/purpose.test.ts | 140 |
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); + }); +}); |
