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
4 changes: 2 additions & 2 deletions src/features/rules/agentsmd-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ describe("AgentsMdRule", () => {
expect(rulesyncRule.getFrontmatter()).toEqual({
root: false,
targets: ["*"],
description: "",
description: undefined,
globs: [],
});
});
Expand All @@ -298,7 +298,7 @@ describe("AgentsMdRule", () => {
expect(rulesyncRule.getFrontmatter()).toEqual({
root: true,
targets: ["*"],
description: "",
description: undefined,
globs: ["**/*"],
});
});
Expand Down
4 changes: 2 additions & 2 deletions src/features/rules/codexcli-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ More detailed instructions here.`;
expect(rulesyncRule.getBody()).toBe(agentsContent);
expect(rulesyncRule.getFrontmatter().root).toBe(true);
expect(rulesyncRule.getFrontmatter().targets).toEqual(["*"]);
expect(rulesyncRule.getFrontmatter().description).toBe("");
expect(rulesyncRule.getFrontmatter().description).toBeUndefined();
expect(rulesyncRule.getFrontmatter().globs).toEqual(["**/*"]);
});

Expand All @@ -464,7 +464,7 @@ More detailed instructions here.`;
expect(rulesyncRule.getBody()).toBe(memoryContent);
expect(rulesyncRule.getFrontmatter().root).toBe(false);
expect(rulesyncRule.getFrontmatter().targets).toEqual(["*"]);
expect(rulesyncRule.getFrontmatter().description).toBe("");
expect(rulesyncRule.getFrontmatter().description).toBeUndefined();
expect(rulesyncRule.getFrontmatter().globs).toEqual([]);
});

Expand Down
110 changes: 110 additions & 0 deletions src/features/rules/cursor-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,43 @@ describe("CursorRule", () => {
expect(createInvalidRule).not.toThrow();
});

it("should not emit description in frontmatter when description is undefined", () => {
const rule = new CursorRule({
frontmatter: {
globs: "*.ts",
alwaysApply: false,
},
body: "Rule body content",
relativeDirPath: ".cursor/rules",
relativeFilePath: "test.mdc",
});

const expectedContent = `---
alwaysApply: false
globs: *.ts
---

Rule body content`;
expect(rule.getFileContent()).toBe(expectedContent);
// Ensure no "description:" line appears
expect(rule.getFileContent()).not.toContain("description:");
});

it("should not emit description in frontmatter when description is empty string", () => {
const rule = new CursorRule({
frontmatter: {
description: "",
globs: "*.ts",
},
body: "Rule body content",
relativeDirPath: ".cursor/rules",
relativeFilePath: "test.mdc",
});

// Empty string description should not produce "description:" in output
expect(rule.getFileContent()).not.toContain("description:");
});

it("should generate correct file content with frontmatter", () => {
const frontmatter: CursorRuleFrontmatter = {
description: "Test rule",
Expand Down Expand Up @@ -210,6 +247,28 @@ Test content`;
expect(cursorRule.getFrontmatter().globs).toBeUndefined();
});

it("should omit description when source rule has no description", () => {
const rulesyncRule = new RulesyncRule({
frontmatter: {
targets: ["*"],
root: false,
globs: ["*.ts"],
},
body: "Rule content without description",
relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
relativeFilePath: "no-desc.md",
});

const cursorRule = CursorRule.fromRulesyncRule({
rulesyncRule,
baseDir: testDir,
});

expect(cursorRule.getFrontmatter().description).toBeUndefined();
// The generated file should not contain "description:"
expect(cursorRule.getFileContent()).not.toContain("description:");
});

it("should convert .md extension to .mdc", () => {
const rulesyncRule = new RulesyncRule({
frontmatter: {
Expand Down Expand Up @@ -750,5 +809,56 @@ This is the rule content
"src/**/*.js",
]);
});

it("should roundtrip cursor rules without description (no YAML null)", async () => {
// Write a cursor rule with no description (simulates generation from a rule imported from Claude Code)
const filePath = join(testDir, ".cursor/rules", "no-desc.mdc");
const rule = new CursorRule({
frontmatter: {
alwaysApply: true,
},
body: "Rule without description",
relativeDirPath: ".cursor/rules",
relativeFilePath: "no-desc.mdc",
});

// The file content should NOT contain "description:"
expect(rule.getFileContent()).not.toContain("description:");

// Write to disk and re-read
await writeFileContent(filePath, rule.getFileContent());
const reimported = await CursorRule.fromFile({
baseDir: testDir,
relativeFilePath: "no-desc.mdc",
});

// Should parse successfully without Zod validation error
expect(reimported.getFrontmatter().description).toBeUndefined();
expect(reimported.getFrontmatter().alwaysApply).toBe(true);
expect(reimported.getBody()).toBe("Rule without description");
});

it("should handle re-importing a cursor file with YAML null description", async () => {
// Simulates a manually-created or legacy .mdc file with "description:" (YAML null)
const filePath = join(testDir, ".cursor/rules", "null-desc.mdc");
const fileContent = `---
description:
alwaysApply: true
---

Rule with null description`;

await writeFileContent(filePath, fileContent);

// Should NOT throw a Zod validation error
const rule = await CursorRule.fromFile({
baseDir: testDir,
relativeFilePath: "null-desc.mdc",
});

expect(rule.getFrontmatter().description).toBeUndefined();
expect(rule.getFrontmatter().alwaysApply).toBe(true);
expect(rule.getBody()).toBe("Rule with null description");
});
});
});
2 changes: 1 addition & 1 deletion src/features/rules/cursor-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export class CursorRule extends ToolRule {
if (frontmatter.alwaysApply !== undefined) {
lines.push(`alwaysApply: ${frontmatter.alwaysApply}`);
}
if (frontmatter.description !== undefined) {
if (frontmatter.description) {
lines.push(`description: ${frontmatter.description}`);
}
if (frontmatter.globs !== undefined) {
Expand Down
4 changes: 2 additions & 2 deletions src/features/rules/qwencode-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ describe("QwencodeRule", () => {
const frontmatter = rulesyncRule.getFrontmatter();
expect(frontmatter.root).toBe(false);
expect(frontmatter.targets).toEqual(["*"]);
expect(frontmatter.description).toBe("");
expect(frontmatter.description).toBeUndefined();
expect(frontmatter.globs).toEqual([]);
});

Expand All @@ -375,7 +375,7 @@ describe("QwencodeRule", () => {
const frontmatter = rulesyncRule.getFrontmatter();
expect(frontmatter.root).toBe(true);
expect(frontmatter.targets).toEqual(["*"]);
expect(frontmatter.description).toBe("");
expect(frontmatter.description).toBeUndefined();
expect(frontmatter.globs).toEqual(["**/*"]);
});

Expand Down
2 changes: 1 addition & 1 deletion src/features/rules/rulesync-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export class RulesyncRule extends RulesyncFile {
root: result.data.root ?? false,
localRoot: result.data.localRoot ?? false,
targets: result.data.targets ?? ["*"],
description: result.data.description ?? "",
description: result.data.description,
globs: result.data.globs ?? [],
agentsmd: result.data.agentsmd,
cursor: result.data.cursor,
Expand Down
4 changes: 2 additions & 2 deletions src/features/rules/tool-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,7 +874,7 @@ describe("ToolRule", () => {
const frontmatter = rulesyncRule.getFrontmatter();
expect(frontmatter.root).toBe(false);
expect(frontmatter.targets).toEqual(["*"]);
expect(frontmatter.description).toBe("");
expect(frontmatter.description).toBeUndefined();
expect(frontmatter.globs).toEqual([]);
});

Expand Down Expand Up @@ -916,7 +916,7 @@ describe("ToolRule", () => {
const frontmatter = rulesyncRule.getFrontmatter();
expect(frontmatter.root).toBe(true);
expect(frontmatter.targets).toEqual(["*"]);
expect(frontmatter.description).toBe("");
expect(frontmatter.description).toBeUndefined();
expect(frontmatter.globs).toEqual(["**/*"]);
});
});
Expand Down
2 changes: 1 addition & 1 deletion src/features/rules/tool-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export abstract class ToolRule extends ToolFile {
frontmatter: {
root: this.isRoot(),
targets: ["*"],
description: this.description ?? "",
description: this.description,
globs: this.globs ?? (this.isRoot() ? ["**/*"] : []),
},
body: this.getFileContent(),
Expand Down
4 changes: 2 additions & 2 deletions src/features/rules/windsurf-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ describe("WindsurfRule", () => {
expect(rulesyncRule.getFrontmatter()).toEqual({
root: false,
targets: ["*"],
description: "",
description: undefined,
globs: [],
});
});
Expand Down Expand Up @@ -301,7 +301,7 @@ describe("WindsurfRule", () => {
const rulesyncRule = windsurfRule.toRulesyncRule();

expect(rulesyncRule.getFileContent()).toContain(
"---\nroot: false\ntargets:\n - '*'\ndescription: ''\nglobs: []\n---\n",
"---\nroot: false\ntargets:\n - '*'\nglobs: []\n---\n",
);
});
});
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ description: "Second rule"
root: false,
localRoot: false,
targets: ["*"],
description: "",
description: undefined,
globs: [],
});
});
Expand Down
36 changes: 36 additions & 0 deletions src/utils/frontmatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,42 @@ Body content after empty frontmatter.`;
expect(result.body).toBe("Body content after empty frontmatter.");
});

it("should strip YAML null values from parsed frontmatter", () => {
// YAML parses bare keys like "description:" as null
const content = `---
description:
globs: "*.ts"
alwaysApply: true
---
Body content.`;

const result = parseFrontmatter(content);

// null value from "description:" should be stripped
expect(result.frontmatter).toEqual({
globs: "*.ts",
alwaysApply: true,
});
expect(result.frontmatter).not.toHaveProperty("description");
});

it("should strip nested null values from parsed frontmatter", () => {
const content = `---
cursor:
description:
alwaysApply: true
---
Body content.`;

const result = parseFrontmatter(content);

expect(result.frontmatter).toEqual({
cursor: {
alwaysApply: true,
},
});
});

it("should handle malformed YAML gracefully", () => {
const content = `---
title: "Valid quote"
Expand Down
8 changes: 7 additions & 1 deletion src/utils/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,11 @@ export function parseFrontmatter(content: string): {
} {
const { data: frontmatter, content: body } = matter(content);

return { frontmatter, body };
// Strip null/undefined values from parsed frontmatter for consistency.
// YAML parses bare keys (e.g. "description:") as null, which would fail
// Zod validation (z.optional(z.string()) does not accept null).
// This mirrors the deepRemoveNullishObject cleanup done in stringifyFrontmatter.
const cleanFrontmatter = deepRemoveNullishObject(frontmatter);

return { frontmatter: cleanFrontmatter, body };
}