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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -405,5 +405,6 @@ rulesync.local.jsonc
**/.agents/checks/
**/.cursor/BUGBOT.md
**/.hermes/plugins/rulesync-checks/checks/
**/.rovodev/.review-agent.md
!.rulesync/.aiignore
# End of Rulesync
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte
| Kilo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| Kimi Code | ✅ | | ✅ | | ✅ | ✅ | ✅ | ✅ | |
| Roo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | |
| Rovodev (Atlassian) | ✅ | | ✅ | ✅ | ✅ | ✅ | | ✅ | |
| Rovodev (Atlassian) | ✅ | | ✅ | ✅ | ✅ | ✅ | | ✅ | |
| Takt | ✅ | | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
| Vibe Code | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | |
| Qwen Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
Expand Down
10 changes: 8 additions & 2 deletions docs/reference/file-formats.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/reference/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| Kimi Code | kimi-code | ✅ 🌏 | | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | 🌏 | 🌏 | |
| Roo Code | roo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ 🌏 | | | |
| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | 🌏 | |
| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | 🌏 | |
| Takt | takt | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 |
| Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
Expand Down
10 changes: 8 additions & 2 deletions skills/rulesync/file-formats.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion skills/rulesync/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| Kimi Code | kimi-code | ✅ 🌏 | | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | 🌏 | 🌏 | |
| Roo Code | roo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ 🌏 | | | |
| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | 🌏 | |
| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | 🌏 | |
| Takt | takt | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 |
| Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
Expand Down
8 changes: 8 additions & 0 deletions src/constants/rovodev-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ export const ROVODEV_CONFIG_FILE_NAME = "config.yml";
export const ROVODEV_AGENTS_SKILLS_DIR_PATH = join(".agents", "skills");
export const ROVODEV_PROMPTS_FILE_NAME = "prompts.yml";
export const ROVODEV_PROMPTS_DIR_PATH = join(ROVODEV_DIR, "prompts");

/**
* Custom instructions for Rovo Dev's code reviews: a plain-Markdown file (no
* frontmatter) in the repository root's `.rovodev/` folder. Note the leading
* dot in the file name.
* @see https://support.atlassian.com/rovo/docs/set-custom-instructions-for-code-reviews/
*/
export const ROVODEV_REVIEW_AGENT_FILE_NAME = ".review-agent.md";
25 changes: 24 additions & 1 deletion src/e2e/e2e-checks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ const checksGenerateTargets = [
target: "hermesagent",
outputPath: join(".hermes", "plugins", "rulesync-checks", "checks", "security.json"),
},
{
// Rovo Dev reads one plain-Markdown instruction file for code reviews.
target: "rovodev",
outputPath: join(".rovodev", ".review-agent.md"),
},
{
// Takt's gates live in the shared config rather than in per-check files.
target: "takt",
Expand Down Expand Up @@ -88,11 +93,13 @@ Look for injection vulnerabilities.
expect(generatedContent).toContain("Look for injection vulnerabilities.");
return;
}
if (target === "cursor") {
if (target === "cursor" || target === "rovodev") {
// One marked-up section per check, keyed by the source file basename.
expect(generatedContent).toContain("<!-- rulesync:check:security -->");
expect(generatedContent).toContain("## security");
expect(generatedContent).toContain("Look for injection vulnerabilities.");
// Plain prose: neither file takes frontmatter.
expect(generatedContent.startsWith("---")).toBe(false);
return;
}
if (target === "amp") {
Expand All @@ -118,6 +125,22 @@ Look for injection vulnerabilities.
},
);

it("should round-trip rovodev checks through import", async () => {
const testDir = getTestDir();

await writeFileContent(
join(testDir, ".rovodev", ".review-agent.md"),
"Prefer small, well-named functions.\n",
);

await runImport({ target: "rovodev", features: "checks" });

const importedContent = await readFileContent(
join(testDir, RULESYNC_CHECKS_RELATIVE_DIR_PATH, "review-agent.md"),
);
expect(importedContent).toContain("Prefer small, well-named functions.");
});

it("should round-trip checks through import", async () => {
const testDir = getTestDir();

Expand Down
228 changes: 228 additions & 0 deletions src/features/checks/aggregated-check-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { describe, expect, it } from "vitest";

import { RULESYNC_CHECKS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
import {
escapeCheckMarkers,
findCheckMarkers,
hasHandWrittenPreamble,
isOnlyGeneratedSections,
renderCheckFile,
renderCheckMarker,
splitCheckFile,
unescapeCheckMarkers,
} from "./aggregated-check-file.js";
import { RulesyncCheck } from "./rulesync-check.js";

const checkOf = ({
name,
body = "",
description,
}: {
name: string;
body?: string;
description?: string;
}): RulesyncCheck =>
new RulesyncCheck({
outputRoot: ".",
relativeDirPath: RULESYNC_CHECKS_RELATIVE_DIR_PATH,
relativeFilePath: `${name}.md`,
frontmatter: { targets: ["*"], ...(description !== undefined && { description }) },
body,
});

describe("aggregated-check-file", () => {
describe("findCheckMarkers", () => {
it("should find every marker and stay repeatable across calls", () => {
const content = [renderCheckMarker("a"), "body", renderCheckMarker("b")].join("\n");

expect(findCheckMarkers(content).map((marker) => marker.name)).toEqual(["a", "b"]);
// The pattern is module-level and global, so a second call must agree.
expect(findCheckMarkers(content).map((marker) => marker.name)).toEqual(["a", "b"]);
});

it("should ignore a marker that is not alone on its line", () => {
expect(findCheckMarkers(`text ${renderCheckMarker("a")}`)).toEqual([]);
});
});

describe("hasHandWrittenPreamble", () => {
it.each([
{ label: "an empty file", content: "", expected: false },
{ label: "a whitespace-only file", content: "\n\n \n", expected: false },
{ label: "a file with no marker", content: "Hand-written.\n", expected: true },
{
label: "text ahead of the first marker",
content: `Hand-written.\n${renderCheckMarker("a")}\n`,
expected: true,
},
{
label: "only generated sections",
content: `${renderCheckMarker("a")}\n## a\n\nbody\n`,
expected: false,
},
])("should be $expected for $label", ({ content, expected }) => {
expect(hasHandWrittenPreamble(content)).toBe(expected);
});
});

describe("isOnlyGeneratedSections", () => {
it.each([
// Stricter than hasHandWrittenPreamble: with no marker there is nothing
// rulesync wrote, so an empty file is not rulesync's to delete either.
{ label: "an empty file", content: "", expected: false },
{ label: "a whitespace-only file", content: "\n\n", expected: false },
{ label: "a file with no marker", content: "Hand-written.\n", expected: false },
{
label: "text ahead of the first marker",
content: `Hand-written.\n${renderCheckMarker("a")}\n`,
expected: false,
},
{
label: "only generated sections",
content: `${renderCheckMarker("a")}\n## a\n\nbody\n`,
expected: true,
},
])("should be $expected for $label", ({ content, expected }) => {
expect(isOnlyGeneratedSections(content)).toBe(expected);
});
});

describe("marker escaping", () => {
it("should escape and unescape a marker line a body wrote itself", () => {
const body = renderCheckMarker("inner");
const escaped = escapeCheckMarkers(body);

expect(escaped).toBe("<!-- rulesync:literal-check:inner -->");
expect(findCheckMarkers(escaped)).toEqual([]);
expect(unescapeCheckMarkers(escaped)).toBe(body);
});

it("should ladder so an already-escaped marker survives another round", () => {
const body = "<!-- rulesync:literal-check:inner -->";
const escaped = escapeCheckMarkers(body);

expect(escaped).toBe("<!-- rulesync:literal-literal-check:inner -->");
expect(unescapeCheckMarkers(escaped)).toBe(body);
});
});

describe("renderCheckFile", () => {
it("should write a marker, a heading and the body per check", () => {
const content = renderCheckFile([checkOf({ name: "a", body: "Body A." })]);

expect(content).toBe("<!-- rulesync:check:a -->\n## a\n\nBody A.\n");
});

it("should fall back to the description when the body is empty", () => {
expect(renderCheckFile([checkOf({ name: "a", description: "Summary." })])).toContain(
"Summary.",
);
});

it("should name a nested check by its basename only", () => {
const nested = new RulesyncCheck({
outputRoot: ".",
relativeDirPath: RULESYNC_CHECKS_RELATIVE_DIR_PATH,
relativeFilePath: "group/a.md",
frontmatter: { targets: ["*"] },
body: "Body.",
});

expect(renderCheckFile([nested])).toContain("<!-- rulesync:check:a -->");
});
});

describe("splitCheckFile", () => {
it("should round-trip a rendered file", () => {
const checks = [checkOf({ name: "a", body: "Body A." }), checkOf({ name: "b", body: "B." })];

const split = splitCheckFile({
fileContent: renderCheckFile(checks),
fallbackName: "fallback",
});

expect(split.map((check) => check.getRelativeFilePath())).toEqual(["a.md", "b.md"]);
expect(split.map((check) => check.getBody())).toEqual(["Body A.", "B."]);
});

it("should import a file with no marker as the fallback check", () => {
const split = splitCheckFile({ fileContent: "Prose.\n", fallbackName: "fallback" });

expect(split).toHaveLength(1);
expect(split[0]!.getRelativeFilePath()).toBe("fallback.md");
expect(split[0]!.getBody()).toBe("Prose.");
});

it("should keep a preamble as its own check alongside the sections", () => {
const split = splitCheckFile({
fileContent: `Preamble.\n\n${renderCheckMarker("a")}\n## a\n\nBody A.\n`,
fallbackName: "fallback",
});

expect(split.map((check) => check.getRelativeFilePath())).toEqual(["fallback.md", "a.md"]);
});

it("should return nothing for an empty file", () => {
expect(splitCheckFile({ fileContent: "", fallbackName: "fallback" })).toEqual([]);
});

it("should slugify a marker name so it cannot escape the checks directory", () => {
const split = splitCheckFile({
fileContent: `${renderCheckMarker("../escape")}\nBody.\n`,
fallbackName: "fallback",
});

expect(split[0]!.getRelativeFilePath()).not.toContain("..");
});

it("should suffix names that slugify the same so neither is lost", () => {
const split = splitCheckFile({
fileContent: [
`${renderCheckMarker("No Console")}`,
"first",
`${renderCheckMarker("no console")}`,
"second",
].join("\n"),
fallbackName: "fallback",
});

expect(split).toHaveLength(2);
expect(split.map((check) => check.getRelativeFilePath())).toEqual([
"no-console.md",
"no-console-2.md",
]);
expect(split.map((check) => check.getBody())).toEqual(["first", "second"]);
});

it("should strip only the heading generate wrote, keeping a different one", () => {
const generated = splitCheckFile({
fileContent: `${renderCheckMarker("a")}\n## a\n\nBody.\n`,
fallbackName: "fallback",
});
const foreign = splitCheckFile({
fileContent: `${renderCheckMarker("a")}\n## Something Else\n\nBody.\n`,
fallbackName: "fallback",
});

expect(generated[0]!.getBody()).toBe("Body.");
expect(foreign[0]!.getBody()).toBe("## Something Else\n\nBody.");
});

it("should match the heading against the raw marker name, not its slug", () => {
// A check named `No_Console` slugifies to `no-console`, but the heading
// generate wrote says `No_Console`.
const split = splitCheckFile({
fileContent: `${renderCheckMarker("No_Console")}\n## No_Console\n\nBody.\n`,
fallbackName: "fallback",
});

expect(split[0]!.getBody()).toBe("Body.");
});

it("should import every check as applying to any tool", () => {
const split = splitCheckFile({ fileContent: "Prose.\n", fallbackName: "fallback" });

expect(split[0]!.getFrontmatter().targets).toEqual(["*"]);
});
});
});
Loading
Loading