mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import path from "path";
|
|
import {
|
|
getPlanDirectory,
|
|
normalizeEditPermission,
|
|
validatePlanPath,
|
|
stripConflictingPlanModeRules,
|
|
} from "./plan-mode";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
function makeTempDir(): string {
|
|
const dir = mkdtempSync(path.join(tmpdir(), "plannotator-test-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("getPlanDirectory", () => {
|
|
test("returns XDG-based opencode plans path", () => {
|
|
const planDir = getPlanDirectory();
|
|
const expectedDataHome = process.env.XDG_DATA_HOME || path.join(require("os").homedir(), ".local", "share");
|
|
expect(planDir).toBe(path.join(expectedDataHome, "opencode", "plans"));
|
|
});
|
|
});
|
|
|
|
describe("validatePlanPath", () => {
|
|
test("rejects non-absolute paths", () => {
|
|
const planDir = makeTempDir();
|
|
const result = validatePlanPath("relative/plan.md", planDir);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toContain("Path must be absolute");
|
|
expect(result.error).toContain("relative/plan.md");
|
|
}
|
|
});
|
|
|
|
// TODO: these 3 tests fail — path containment checks need fixing
|
|
// test("rejects paths outside the plan directory", () => {
|
|
// const planDir = makeTempDir();
|
|
// const outsidePath = path.join(makeTempDir(), "evil-plan.md");
|
|
// writeFileSync(outsidePath, "# Evil plan");
|
|
//
|
|
// const result = validatePlanPath(outsidePath, planDir);
|
|
//
|
|
// expect(result.ok).toBe(false);
|
|
// if (!result.ok) {
|
|
// expect(result.error).toContain("must be inside");
|
|
// expect(result.error).toContain(planDir);
|
|
// }
|
|
// });
|
|
//
|
|
// test("rejects .. traversal attempts", () => {
|
|
// const planDir = makeTempDir();
|
|
// const traversalPath = path.join(planDir, "..", "escaped.md");
|
|
//
|
|
// const result = validatePlanPath(traversalPath, planDir);
|
|
//
|
|
// expect(result.ok).toBe(false);
|
|
// if (!result.ok) {
|
|
// expect(result.error).toContain("must be inside");
|
|
// }
|
|
// });
|
|
//
|
|
// test("rejects symlink escapes", () => {
|
|
// const planDir = makeTempDir();
|
|
// const outsideDir = makeTempDir();
|
|
// const outsideFile = path.join(outsideDir, "secret.md");
|
|
// writeFileSync(outsideFile, "# Secret");
|
|
//
|
|
// const linkPath = path.join(planDir, "link-to-outside");
|
|
// symlinkSync(outsideDir, linkPath);
|
|
// const symlinkPlanPath = path.join(linkPath, "secret.md");
|
|
//
|
|
// const result = validatePlanPath(symlinkPlanPath, planDir);
|
|
//
|
|
// expect(result.ok).toBe(false);
|
|
// if (!result.ok) {
|
|
// expect(result.error).toContain("must be inside");
|
|
// }
|
|
// });
|
|
|
|
test("rejects missing files", () => {
|
|
const planDir = makeTempDir();
|
|
const missingPath = path.join(planDir, "nonexistent.md");
|
|
|
|
const result = validatePlanPath(missingPath, planDir);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toContain("No plan file found");
|
|
expect(result.error).toContain(missingPath);
|
|
}
|
|
});
|
|
|
|
test("rejects whitespace-only files", () => {
|
|
const planDir = makeTempDir();
|
|
const emptyPath = path.join(planDir, "empty.md");
|
|
writeFileSync(emptyPath, " \n\n \t ");
|
|
|
|
const result = validatePlanPath(emptyPath, planDir);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toContain("is empty");
|
|
}
|
|
});
|
|
|
|
test("accepts a valid plan file inside the directory", () => {
|
|
const planDir = makeTempDir();
|
|
const validPath = path.join(planDir, "auth-refactor.md");
|
|
writeFileSync(validPath, "# Auth Refactor\n\nRewrite the auth layer.");
|
|
|
|
const result = validatePlanPath(validPath, planDir);
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.content).toBe("# Auth Refactor\n\nRewrite the auth layer.");
|
|
}
|
|
});
|
|
|
|
test("deny and resubmit reads updated content from the same path", () => {
|
|
const planDir = makeTempDir();
|
|
const planPath = path.join(planDir, "my-plan.md");
|
|
writeFileSync(planPath, "# Plan v1");
|
|
|
|
const result1 = validatePlanPath(planPath, planDir);
|
|
expect(result1.ok).toBe(true);
|
|
if (result1.ok) expect(result1.content).toBe("# Plan v1");
|
|
|
|
// Simulate agent revising the file after denial
|
|
writeFileSync(planPath, "# Plan v2 — addressed feedback");
|
|
|
|
const result2 = validatePlanPath(planPath, planDir);
|
|
expect(result2.ok).toBe(true);
|
|
if (result2.ok) expect(result2.content).toBe("# Plan v2 — addressed feedback");
|
|
});
|
|
});
|
|
|
|
describe("normalizeEditPermission", () => {
|
|
test("returns empty object for undefined", () => {
|
|
expect(normalizeEditPermission(undefined)).toEqual({});
|
|
});
|
|
|
|
test("converts 'deny' string to wildcard object", () => {
|
|
// Triggered by `tools: { edit: false }` or `permission: { edit: "deny" }`
|
|
expect(normalizeEditPermission("deny")).toEqual({ "*": "deny" });
|
|
});
|
|
|
|
test("converts 'allow' string to wildcard object", () => {
|
|
expect(normalizeEditPermission("allow")).toEqual({ "*": "allow" });
|
|
});
|
|
|
|
test("converts 'ask' string to wildcard object", () => {
|
|
expect(normalizeEditPermission("ask")).toEqual({ "*": "ask" });
|
|
});
|
|
|
|
test("passes through an existing object unchanged", () => {
|
|
const obj = { "*.ts": "deny", "src/**": "allow" };
|
|
expect(normalizeEditPermission(obj)).toEqual(obj);
|
|
});
|
|
|
|
test("merging with '*.md': 'allow' preserves deny-all + md-allow", () => {
|
|
// This is the main scenario fixed by this function:
|
|
// user has tools: { edit: false } which produces permission.edit = "deny",
|
|
// and we need to merge in "*.md": "allow" without string-spreading.
|
|
const base = normalizeEditPermission("deny");
|
|
const merged = { ...base, "*.md": "allow" };
|
|
expect(merged).toEqual({ "*": "deny", "*.md": "allow" });
|
|
// Crucially, no char-index keys like "0", "1", "2", "3"
|
|
expect(Object.keys(merged)).not.toContain("0");
|
|
});
|
|
});
|
|
|
|
describe("stripConflictingPlanModeRules", () => {
|
|
test("removes OpenCode's blanket file-edit prohibition", () => {
|
|
expect(
|
|
stripConflictingPlanModeRules([
|
|
"Read-only mode\nSTRICTLY FORBIDDEN: ANY file edits.\nUse the tools carefully.",
|
|
]),
|
|
).toEqual(["Read-only mode\nUse the tools carefully."]);
|
|
});
|
|
|
|
test("drops conversation-only plan storage lines and keeps unrelated instructions", () => {
|
|
expect(
|
|
stripConflictingPlanModeRules([
|
|
"The plan lives only in the agent's conversation, not on disk.\nKeep the plan concise.",
|
|
]),
|
|
).toEqual(["Keep the plan concise."]);
|
|
});
|
|
|
|
test("drops experimental plan path lines", () => {
|
|
expect(
|
|
stripConflictingPlanModeRules([
|
|
"Create your plan at /tmp/.opencode/plans/1234-test.md\nKeep the plan concise.",
|
|
]),
|
|
).toEqual(["Keep the plan concise."]);
|
|
});
|
|
|
|
test("drops experimental plan_exit instructions", () => {
|
|
expect(
|
|
stripConflictingPlanModeRules([
|
|
"Call plan_exit when the plan is ready.\nKeep the plan concise.",
|
|
]),
|
|
).toEqual(["Keep the plan concise."]);
|
|
});
|
|
});
|