Files
Marius Wichtner 7471fcfe73 feat(kilocode): add rules migration (Phase 2)
Migrate Kilocode rules from .kilocoderules and .kilocode/rules/ to
Opencode's instructions config array.

Features:
- Discover rules from .kilocoderules, .kilocode/rules/*.md, and
  mode-specific variants
- Support global rules from ~/.kilocode/rules/
- Read-only migration (never modifies project files)
- Combines with existing opencode config (never overwrites)
- Kilocode-only (no .roorules or .clinerules migration)

Files:
- rules-migrator.ts: Core migration logic
- config.ts: Integration point for direct CLI usage
- config-injector.ts: Integration for VSCode extension path
- rules-migrator.test.ts: 13 unit tests
- config-injector.test.ts: 4 new integration tests
- rules-migration.md: Documentation
2026-01-26 17:50:58 +01:00

196 lines
6.9 KiB
TypeScript

import { test, expect, describe } from "bun:test"
import { RulesMigrator } from "../../src/kilocode/rules-migrator"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
describe("RulesMigrator", () => {
describe("discoverRules", () => {
test("discovers legacy .kilocoderules file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules"), "# Project rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(1)
expect(rules[0].source).toBe("legacy")
expect(rules[0].path).toContain(".kilocoderules")
expect(rules[0].mode).toBeUndefined()
})
test("discovers .kilocode/rules/ directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilocode", "rules", "coding.md"), "# Coding rules")
await Bun.write(path.join(dir, ".kilocode", "rules", "testing.md"), "# Testing rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(2)
expect(rules.every((r) => r.source === "project")).toBe(true)
expect(rules.every((r) => r.mode === undefined)).toBe(true)
})
test("discovers mode-specific directory rules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilocode", "rules-code"), { recursive: true })
await Bun.write(path.join(dir, ".kilocode", "rules-code", "style.md"), "# Code style")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(1)
expect(rules[0].source).toBe("project")
expect(rules[0].mode).toBe("code")
})
test("discovers mode-specific legacy file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules-architect"), "# Architect rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(1)
expect(rules[0].source).toBe("legacy")
expect(rules[0].mode).toBe("architect")
})
test("ignores non-markdown files in rules directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilocode", "rules", "rules.md"), "# Rules")
await Bun.write(path.join(dir, ".kilocode", "rules", "notes.txt"), "Notes")
await Bun.write(path.join(dir, ".kilocode", "rules", "config.json"), "{}")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(1)
expect(rules[0].path).toContain("rules.md")
})
test("returns empty array for project without rules", async () => {
await using tmp = await tmpdir()
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(0)
})
test("discovers multiple rule sources together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Legacy file
await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules")
// Directory rules
await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Main rules")
// Mode-specific
await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(3)
expect(rules.some((r) => r.source === "legacy" && !r.mode)).toBe(true)
expect(rules.some((r) => r.source === "project")).toBe(true)
expect(rules.some((r) => r.mode === "code")).toBe(true)
})
})
describe("migrate", () => {
test("returns instructions array with discovered rules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Main rules")
},
})
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.instructions).toHaveLength(1)
expect(result.instructions[0]).toContain("main.md")
})
test("warns about legacy files", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules")
},
})
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.warnings.some((w) => w.includes("Legacy"))).toBe(true)
expect(result.warnings.some((w) => w.includes(".kilocode/rules/"))).toBe(true)
})
test("skips mode-specific rules when includeModeSpecific is false", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules")
},
})
const result = await RulesMigrator.migrate({
projectDir: tmp.path,
includeModeSpecific: false,
})
expect(result.instructions).toHaveLength(0)
expect(result.warnings.some((w) => w.includes("skipped"))).toBe(true)
})
test("includes mode-specific rules by default", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules")
},
})
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.instructions).toHaveLength(1)
})
test("returns empty result for project without rules", async () => {
await using tmp = await tmpdir()
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.instructions).toHaveLength(0)
expect(result.warnings).toHaveLength(0)
})
test("combines all rule sources", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy")
await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Main")
await Bun.write(path.join(dir, ".kilocoderules-architect"), "# Architect")
},
})
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.instructions).toHaveLength(3)
})
})
})