diff --git a/src/rosettify/src/commands/plan/core.ts b/src/rosettify/src/commands/plan/core.ts index 03449cc4..fbe874d3 100644 --- a/src/rosettify/src/commands/plan/core.ts +++ b/src/rosettify/src/commands/plan/core.ts @@ -305,8 +305,8 @@ export function depsSatisfied( // Validation Functions // --------------------------------------------------------------------------- -export function validatePlanName(name: string): string | null { - if (!name || !name.trim()) return "size_limit_exceeded"; +export function validatePlanName(name: unknown): string | null { + if (typeof name !== "string" || !name.trim()) return "size_limit_exceeded"; if (name.length > PLAN_MAX_NAME_LENGTH) return "size_limit_exceeded"; return null; } @@ -383,8 +383,8 @@ export function validateDependencies(plan: Plan): string | null { export function validateSizeLimits(plan: Plan): string | null { if ((plan.phases ?? []).length > PLAN_MAX_PHASES) return "size_limit_exceeded"; - if (plan.name && plan.name.length > PLAN_MAX_NAME_LENGTH) - return "size_limit_exceeded"; + const nameErr = validatePlanName(plan.name); + if (nameErr) return nameErr; function checkStringLength(value: unknown): boolean { if (typeof value === "string" && value.length > PLAN_MAX_STRING_LENGTH) diff --git a/src/rosettify/tests/unit/plan/create.test.ts b/src/rosettify/tests/unit/plan/create.test.ts index e928cef1..ec59b4ff 100644 --- a/src/rosettify/tests/unit/plan/create.test.ts +++ b/src/rosettify/tests/unit/plan/create.test.ts @@ -124,6 +124,14 @@ describe("cmdCreate — FR-PLAN-0010 / FR-PLAN-0040", () => { expect(tree.plan.name).toBe("Unnamed Plan"); }); + it.each(["", " ", 123, {}])("rejects an invalid plan name %#", async (name) => { + const file = planFile(); + const result = await cmdCreate(file, { name }); + expect(result.ok).toBe(false); + expect(result.error).toBe("size_limit_exceeded"); + expect(fs.existsSync(file)).toBe(false); + }); + it("rejects plan with duplicate ids", async () => { const file = planFile(); const data = { diff --git a/src/rosettify/tests/unit/plan/upsert.test.ts b/src/rosettify/tests/unit/plan/upsert.test.ts index 176705b4..4f63ec00 100644 --- a/src/rosettify/tests/unit/plan/upsert.test.ts +++ b/src/rosettify/tests/unit/plan/upsert.test.ts @@ -124,6 +124,14 @@ describe("cmdUpsert — entire_plan on existing file (merge)", () => { expect(plan.name).toBe("Test Plan"); // unchanged }); + it.each(["", " ", 123, {}])("rejects an invalid plan name %#", async (name) => { + const file = writePlan(); + const result = await cmdUpsert(file, "entire_plan", { name }); + expect(result.ok).toBe(false); + expect(result.error).toBe("size_limit_exceeded"); + expect(loadPlan(file)!.name).toBe("Test Plan"); + }); + it("merges phases by id", async () => { const file = writePlan(); const result = await cmdUpsert(file, "entire_plan", { diff --git a/src/rosettify/tests/unit/shared/core-utils.test.ts b/src/rosettify/tests/unit/shared/core-utils.test.ts index 496a03d0..85ae138f 100644 --- a/src/rosettify/tests/unit/shared/core-utils.test.ts +++ b/src/rosettify/tests/unit/shared/core-utils.test.ts @@ -240,6 +240,10 @@ describe("validatePlanName", () => { expect(validatePlanName(" ")).toBe("size_limit_exceeded"); }); + it.each([123, {}])("returns error for non-string name %#", (name) => { + expect(validatePlanName(name)).toBe("size_limit_exceeded"); + }); + it("returns error for name exceeding 256 chars", () => { expect(validatePlanName("x".repeat(257))).toBe("size_limit_exceeded"); }); @@ -502,6 +506,12 @@ describe("validateSizeLimits", () => { expect(validateSizeLimits(fullPlan())).toBeNull(); }); + it.each(["", " "])("rejects an empty plan name %#", (name) => { + const plan = fullPlan(); + plan.name = name; + expect(validateSizeLimits(plan)).toBe("size_limit_exceeded"); + }); + it("returns size_limit_exceeded for plan name > 256", () => { const plan = fullPlan(); plan.name = "x".repeat(257);