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
67 changes: 67 additions & 0 deletions src/cli/commands/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,73 @@ describe("generateCommand", () => {
});
});

describe("check mode", () => {
it("should fail when check mode would delete orphan files", async () => {
mockConfig.getFeatures.mockReturnValue(["rules"]);
mockConfig.getCheck.mockReturnValue(true);
mockConfig.getDelete.mockReturnValue(true);
mockConfig.isPreviewMode.mockReturnValue(true);

const rulesMock = {
loadToolFiles: vi.fn().mockResolvedValue([
{
getFilePath: () => "/path/to/orphan",
},
]),
removeOrphanAiFiles: vi.fn().mockResolvedValue(1),
loadRulesyncFiles: vi.fn().mockResolvedValue([
{
getFilePath: () => "/path/to/rulesync",
},
]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([
{
getFilePath: () => "/path/to/converted",
},
]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
};
vi.mocked(RulesProcessor).mockImplementation(function () {
return rulesMock as any;
});

await expect(generateCommand(mockLogger, {})).rejects.toThrow(
"Files are not up to date. Run 'rulesync generate' to update.",
);

expect(mockLogger.info).not.toHaveBeenCalledWith("✓ All files are up to date (rules)");
});

it("should succeed when check mode finds no diff", async () => {
mockConfig.getFeatures.mockReturnValue(["rules"]);
mockConfig.getCheck.mockReturnValue(true);
mockConfig.isPreviewMode.mockReturnValue(true);

const rulesMock = {
loadToolFiles: vi.fn().mockResolvedValue([]),
removeOrphanAiFiles: vi.fn().mockResolvedValue(0),
loadRulesyncFiles: vi.fn().mockResolvedValue([
{
getFilePath: () => "/path/to/rulesync",
},
]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([
{
getFilePath: () => "/path/to/converted",
},
]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
};
vi.mocked(RulesProcessor).mockImplementation(function () {
return rulesMock as any;
});

await generateCommand(mockLogger, {});

expect(mockLogger.success).toHaveBeenCalledWith("✓ All files are up to date.");
});
});

describe("error handling", () => {
it("should handle ConfigResolver errors", async () => {
vi.mocked(ConfigResolver.resolve).mockRejectedValue(new Error("Config error"));
Expand Down
25 changes: 13 additions & 12 deletions src/cli/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,19 @@ export async function generateCommand(logger: Logger, options: GenerateOptions):
logger.captureData("skills", result.skills ?? []);
}

// Check mode must fail even when the change is delete-only and no files are written.
if (check) {
if (result.hasDiff) {
throw new CLIError(
"Files are not up to date. Run 'rulesync generate' to update.",
ErrorCodes.GENERATION_FAILED,
);
}

logger.success("✓ All files are up to date.");
return;
}

if (totalGenerated === 0) {
const enabledFeatures = features.join(", ");
logger.info(`✓ All files are up to date (${enabledFeatures})`);
Expand All @@ -139,16 +152,4 @@ export async function generateCommand(logger: Logger, options: GenerateOptions):
} else {
logger.success(`🎉 All done! Written ${totalGenerated} file(s) total (${parts.join(" + ")})`);
}

// Handle --check mode exit code
if (check) {
if (result.hasDiff) {
throw new CLIError(
"Files are not up to date. Run 'rulesync generate' to update.",
ErrorCodes.GENERATION_FAILED,
);
} else {
logger.success("✓ All files are up to date.");
}
}
}
30 changes: 29 additions & 1 deletion src/e2e/e2e-hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ describe("E2E: hooks", () => {
});

it.each([
{ target: "claudecode", orphanPath: join(".claude", "settings.json") },
{ target: "cursor", orphanPath: join(".cursor", "hooks.json") },
{ target: "opencode", orphanPath: join(".opencode", "plugins", "rulesync-hooks.js") },
])(
Expand Down Expand Up @@ -91,6 +90,35 @@ describe("E2E: hooks", () => {
expect(await readFileContent(join(testDir, orphanPath))).toBe("# orphan\n");
},
);

it("should succeed in check mode when a claudecode hooks file is non-deletable", async () => {
const testDir = getTestDir();

await writeFileContent(join(testDir, ".rulesync", ".gitkeep"), "");
await writeFileContent(
join(testDir, ".claude", "settings.json"),
JSON.stringify(
{
hooks: {
SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "echo hi" }] }],
},
theme: "dark",
},
null,
2,
),
);

const { stdout } = await runGenerate({
target: "claudecode",
features: "hooks",
deleteFiles: true,
check: true,
env: { NODE_ENV: "e2e" },
});

expect(stdout).toContain("All files are up to date.");
});
});

describe("E2E: hooks (import)", () => {
Expand Down
25 changes: 21 additions & 4 deletions src/e2e/e2e-ignore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,7 @@ credentials/
}
});

it.each([
{ target: "cursor", orphanPath: ".cursorignore" },
{ target: "claudecode", orphanPath: join(".claude", "settings.json") },
])(
it.each([{ target: "cursor", orphanPath: ".cursorignore" }])(
"should fail in check mode when delete would remove an orphan $target ignore file",
async ({ target, orphanPath }) => {
const testDir = getTestDir();
Expand All @@ -73,6 +70,26 @@ credentials/
expect(await readFileContent(join(testDir, orphanPath))).toBe("# orphan\n");
},
);

it("should succeed in check mode when a claudecode ignore file is non-deletable", async () => {
const testDir = getTestDir();

await writeFileContent(join(testDir, ".rulesync", ".gitkeep"), "");
await writeFileContent(
join(testDir, ".claude", "settings.json"),
JSON.stringify({ permissions: { deny: ["tmp/"] }, theme: "dark" }, null, 2),
);

const { stdout } = await runGenerate({
target: "claudecode",
features: "ignore",
deleteFiles: true,
check: true,
env: { NODE_ENV: "e2e" },
});

expect(stdout).toContain("All files are up to date.");
});
});

describe("E2E: ignore (import)", () => {
Expand Down
33 changes: 31 additions & 2 deletions src/e2e/e2e-mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ describe("E2E: mcp", () => {
it.each([
{ target: "claudecode", orphanPath: ".mcp.json" },
{ target: "cursor", orphanPath: join(".cursor", "mcp.json") },
{ target: "geminicli", orphanPath: join(".gemini", "settings.json") },
{ target: "codexcli", orphanPath: join(".codex", "config.toml") },
])(
"should fail in check mode when delete would remove an orphan $target mcp file",
async ({ target, orphanPath }) => {
Expand Down Expand Up @@ -84,6 +82,37 @@ describe("E2E: mcp", () => {
},
);

it.each([
{
target: "geminicli",
outputPath: join(".gemini", "settings.json"),
content: JSON.stringify({ theme: "dark", mcpServers: {} }, null, 2),
},
{
target: "codexcli",
outputPath: join(".codex", "config.toml"),
content: '[ui]\ntheme = "dark"\n',
},
])(
"should succeed in check mode when a $target mcp file is non-deletable",
async ({ target, outputPath, content }) => {
const testDir = getTestDir();

await writeFileContent(join(testDir, ".rulesync", ".gitkeep"), "");
await writeFileContent(join(testDir, outputPath), content);

const { stdout } = await runGenerate({
target,
features: "mcp",
deleteFiles: true,
check: true,
env: { NODE_ENV: "e2e" },
});

expect(stdout).toContain("All files are up to date.");
},
);

it("should run mcp command as daemon without errors", async () => {
const testDir = getTestDir();

Expand Down
57 changes: 57 additions & 0 deletions src/e2e/e2e-rules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,63 @@ This is a test rule for E2E testing.
const generatedContent = await readFileContent(join(testDir, outputPath));
expect(generatedContent).toContain("Test Rule");
});

it("should fail in check mode when delete would remove an orphan rule file", async () => {
const testDir = getTestDir();

await writeFileContent(join(testDir, ".rulesync", ".gitkeep"), "");
await writeFileContent(join(testDir, "CLAUDE.md"), "# orphan\n");

await expect(
runGenerate({
target: "claudecode",
features: "rules",
deleteFiles: true,
check: true,
env: { NODE_ENV: "e2e" },
}),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
"Files are not up to date. Run 'rulesync generate' to update.",
),
});

expect(await readFileContent(join(testDir, "CLAUDE.md"))).toBe("# orphan\n");
});

it("should print a single up-to-date message in check mode when there is no diff", async () => {
const testDir = getTestDir();

const ruleContent = `---
root: true
targets: ["*"]
description: "Test rule"
globs: ["**/*"]
---

# Test Rule

This is a test rule for E2E testing.
`;
await writeFileContent(
join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME),
ruleContent,
);

await runGenerate({ target: "claudecode", features: "rules" });

const { stdout, stderr } = await runGenerate({
target: "claudecode",
features: "rules",
check: true,
env: { NODE_ENV: "e2e" },
});

expect(stderr).toBe("");
expect(stdout.match(/All files are up to date\./g)).toHaveLength(1);
expect(stdout).not.toContain("All files are up to date (rules)");
});
});

describe("E2E: rules (import)", () => {
Expand Down
8 changes: 5 additions & 3 deletions src/features/hooks/hooks-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,11 @@ export class HooksProcessor extends FeatureProcessor {
}
}

async loadToolFiles({ forDeletion = false }: { forDeletion?: boolean } = {}): Promise<
ToolFile[]
> {
async loadToolFiles({
forDeletion = false,
}: {
forDeletion?: boolean;
} = {}): Promise<ToolFile[]> {
try {
const factory = toolHooksFactories.get(this.toolTarget);
if (!factory) throw new Error(`Unsupported tool target: ${this.toolTarget}`);
Expand Down
15 changes: 7 additions & 8 deletions src/features/skills/skills-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,14 +451,13 @@ export class SkillsProcessor extends DirFeatureProcessor {
const dirPaths = await findFilesByGlobs(join(skillsDirPath, "*"), { type: "dir" });
for (const dirPath of dirPaths) {
const dirName = basename(dirPath);
toolSkills.push(
factory.class.forDeletion({
baseDir: this.baseDir,
relativeDirPath: root,
dirName,
global: this.global,
}),
);
const toolSkill = factory.class.forDeletion({
baseDir: this.baseDir,
relativeDirPath: root,
dirName,
global: this.global,
});
toolSkills.push(toolSkill);
}
}

Expand Down
25 changes: 25 additions & 0 deletions src/lib/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe("generate", () => {
getTargets: ReturnType<typeof vi.fn>;
getFeatures: ReturnType<typeof vi.fn>;
getDelete: ReturnType<typeof vi.fn>;
getCheck: ReturnType<typeof vi.fn>;
getGlobal: ReturnType<typeof vi.fn>;
getSimulateCommands: ReturnType<typeof vi.fn>;
getSimulateSubagents: ReturnType<typeof vi.fn>;
Expand All @@ -87,6 +88,7 @@ describe("generate", () => {
getTargets: vi.fn().mockReturnValue(["claudecode"]),
getFeatures: vi.fn().mockReturnValue(["rules"]),
getDelete: vi.fn().mockReturnValue(false),
getCheck: vi.fn().mockReturnValue(false),
getGlobal: vi.fn().mockReturnValue(false),
getSimulateCommands: vi.fn().mockReturnValue(false),
getSimulateSubagents: vi.fn().mockReturnValue(false),
Expand Down Expand Up @@ -833,6 +835,29 @@ describe("generate", () => {

expect(result.hasDiff).toBe(true);
});

it("should return hasDiff: true when orphan files would be deleted in dry run mode", async () => {
mockConfig.getFeatures.mockReturnValue(["rules"]);
mockConfig.getDelete.mockReturnValue(true);
mockConfig.isPreviewMode.mockReturnValue(true);

const existingFiles = [createMockAiFile("/path/to/orphan", "orphan content")];
const generatedFiles = [createMockAiFile("/path/to/kept", "content")];
const mockProcessor = {
loadToolFiles: vi.fn().mockResolvedValue(existingFiles),
removeOrphanAiFiles: vi.fn().mockResolvedValue(1),
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue(generatedFiles),
writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
};
vi.mocked(RulesProcessor).mockImplementation(function () {
return mockProcessor as unknown as RulesProcessor;
});

const result = await generate({ logger, config: mockConfig as never });

expect(result.hasDiff).toBe(true);
});
});

describe("unsupported target-feature warning", () => {
Expand Down
Loading
Loading