Merge pull request #30 from Kilo-Org/kilocode-ignore-migration

feat(kilocode): add .kilocodeignore migration to permission rules
This commit is contained in:
Suresh Kumar Sivasankaran
2026-01-27 15:02:45 +01:00
committed by GitHub
6 changed files with 674 additions and 0 deletions
+15
View File
@@ -32,6 +32,7 @@ import { ModesMigrator } from "../kilocode/modes-migrator" // kilocode_change
import { RulesMigrator } from "../kilocode/rules-migrator" // kilocode_change
import { WorkflowsMigrator } from "../kilocode/workflows-migrator" // kilocode_change
import { McpMigrator } from "../kilocode/mcp-migrator" // kilocode_change
import { IgnoreMigrator } from "../kilocode/ignore-migrator" // kilocode_change
export namespace Config {
const log = Log.create({ service: "config" })
@@ -114,6 +115,20 @@ export namespace Config {
if (Object.keys(kilocodeMcp).length > 0) {
result = mergeConfigConcatArrays(result, { mcp: kilocodeMcp })
}
// Load Kilocode .kilocodeignore patterns (legacy fallback)
try {
const ignorePermission = await IgnoreMigrator.loadIgnoreConfig(Instance.directory)
if (Object.keys(ignorePermission).length > 0) {
result = mergeConfigConcatArrays(result, { permission: ignorePermission })
log.debug("loaded kilocode ignore patterns", {
hasRead: !!ignorePermission.read,
hasEdit: !!ignorePermission.edit,
})
}
} catch (err) {
log.warn("failed to load kilocode ignore patterns", { error: err })
}
// kilocode_change end
// Load remote/well-known config (overrides Kilocode legacy configs)
@@ -2,6 +2,7 @@ import { Config } from "../config/config"
import { ModesMigrator } from "./modes-migrator"
import { RulesMigrator } from "./rules-migrator" // kilocode_change
import { WorkflowsMigrator } from "./workflows-migrator"
import { IgnoreMigrator } from "./ignore-migrator" // kilocode_change
export namespace KilocodeConfigInjector {
export interface InjectionResult {
@@ -16,6 +17,8 @@ export namespace KilocodeConfigInjector {
skipGlobalPaths?: boolean
/** Include rules migration. Defaults to true. */
includeRules?: boolean
/** Include ignore migration. Defaults to true. */
includeIgnore?: boolean
}): Promise<InjectionResult> {
const warnings: string[] = []
@@ -59,12 +62,49 @@ export namespace KilocodeConfigInjector {
}
// kilocode_change end
// kilocode_change start - Ignore migration
if (options.includeIgnore !== false) {
const ignoreMigration = await IgnoreMigrator.migrate({
projectDir: options.projectDir,
skipGlobalPaths: options.skipGlobalPaths,
})
warnings.push(...ignoreMigration.warnings)
if (Object.keys(ignoreMigration.permission).length > 0) {
config.permission = mergePermissions(config.permission, ignoreMigration.permission)
}
}
// kilocode_change end
return {
configJson: JSON.stringify(config),
warnings,
}
}
/**
* Merge permission configs, preserving order and handling duplicates.
* Incoming rules take precedence (kilocode patterns override).
*/
function mergePermissions(existing: Config.Permission | undefined, incoming: Config.Permission): Config.Permission {
if (!existing) return incoming
const result: Config.Permission = { ...existing }
for (const [key, value] of Object.entries(incoming)) {
if (key === "read" || key === "edit") {
const existingRules = (result[key] as Record<string, Config.PermissionAction>) ?? {}
const incomingRules = value as Record<string, Config.PermissionAction>
result[key] = { ...existingRules, ...incomingRules }
} else {
result[key] = value
}
}
return result
}
export function getEnvVars(configJson: string): Record<string, string> {
if (!configJson || configJson === "{}") {
return {}
@@ -0,0 +1,224 @@
// kilocode_change - new file
import * as path from "path"
import os from "os"
import { Log } from "../util/log"
import type { Config } from "../config/config"
export namespace IgnoreMigrator {
const log = Log.create({ service: "kilocode.ignore-migrator" })
const KILOCODEIGNORE_FILE = ".kilocodeignore"
const GLOBAL_KILOCODEIGNORE = path.join(os.homedir(), ".kilocode", KILOCODEIGNORE_FILE)
export interface IgnorePattern {
pattern: string
negated: boolean
source: "global" | "project"
}
export interface MigrationResult {
permission: Config.Permission
warnings: string[]
patternCount: number
}
async function fileExists(filepath: string): Promise<boolean> {
return Bun.file(filepath).exists()
}
/**
* Parse .kilocodeignore content into patterns.
* Follows gitignore syntax:
* - Lines starting with # are comments
* - Empty lines are ignored
* - Lines starting with ! are negation patterns
*/
export function parseIgnoreContent(content: string): Array<{ pattern: string; negated: boolean }> {
const patterns: Array<{ pattern: string; negated: boolean }> = []
for (const line of content.split("\n")) {
const trimmed = line.trim()
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith("#")) continue
// Handle negation patterns
if (trimmed.startsWith("!")) {
patterns.push({ pattern: trimmed.slice(1), negated: true })
} else {
patterns.push({ pattern: trimmed, negated: false })
}
}
return patterns
}
/**
* Convert gitignore pattern to Opencode wildcard pattern.
*
* Opencode's Wildcard module uses simple patterns:
* - `*` matches any characters (converted to `.*` regex)
* - `?` matches single character
*
* Gitignore semantics we need to handle:
* - `foo/` matches directory and all contents -> `foo/*`
* - `*.env` matches anywhere in tree -> `*.env` (already works)
* - `/foo` matches only at root -> `foo` (rooted)
* - `foo` matches anywhere -> `*foo*` or just `foo` depending on context
*
* Note: Opencode's wildcard `*` already matches any path depth because
* it's converted to `.*` regex which matches `/` characters.
*/
export function convertToGlob(pattern: string): string {
let glob = pattern
// Directory patterns (ending with /) should match all contents
if (glob.endsWith("/")) {
glob = glob.slice(0, -1) + "/*"
}
// Patterns starting with / are rooted - remove the leading /
if (glob.startsWith("/")) {
glob = glob.slice(1)
}
// Remove **/ prefix if present - Opencode's * already matches paths
if (glob.startsWith("**/")) {
glob = "*" + glob.slice(3)
}
// Replace **/ in the middle with just *
glob = glob.replace(/\*\*\//g, "*")
// Replace trailing /** with /*
if (glob.endsWith("/**")) {
glob = glob.slice(0, -3) + "/*"
}
return glob
}
/**
* Load patterns from a .kilocodeignore file
*/
async function loadIgnoreFile(filepath: string, source: "global" | "project"): Promise<IgnorePattern[]> {
if (!(await fileExists(filepath))) return []
const content = await Bun.file(filepath).text()
const parsed = parseIgnoreContent(content)
return parsed.map((p) => ({
pattern: p.pattern,
negated: p.negated,
source,
}))
}
/**
* Build permission rules from ignore patterns.
*
* Order matters! Patterns are evaluated in order:
* 1. Start with "*": "allow" (default allow)
* 2. Add deny patterns
* 3. Add negated patterns (allow) last to override denies
*/
export function buildPermissionRules(patterns: IgnorePattern[]): Record<string, Config.PermissionAction> {
const rules: Record<string, Config.PermissionAction> = {
"*": "allow", // Default: allow all
}
// First pass: add deny rules
for (const p of patterns) {
if (!p.negated) {
const glob = convertToGlob(p.pattern)
rules[glob] = "deny"
}
}
// Second pass: add negated (allow) rules - these override denies
for (const p of patterns) {
if (p.negated) {
const glob = convertToGlob(p.pattern)
rules[glob] = "allow"
}
}
return rules
}
/**
* Migrate .kilocodeignore to Opencode permission config
*/
export async function migrate(options: { projectDir: string; skipGlobalPaths?: boolean }): Promise<MigrationResult> {
const warnings: string[] = []
const allPatterns: IgnorePattern[] = []
// 1. Load global .kilocodeignore (lower priority)
if (!options.skipGlobalPaths) {
const globalPatterns = await loadIgnoreFile(GLOBAL_KILOCODEIGNORE, "global")
allPatterns.push(...globalPatterns)
if (globalPatterns.length > 0) {
log.debug("loaded global .kilocodeignore", { count: globalPatterns.length })
}
}
// 2. Load project .kilocodeignore (higher priority - added last)
const projectIgnorePath = path.join(options.projectDir, KILOCODEIGNORE_FILE)
const projectPatterns = await loadIgnoreFile(projectIgnorePath, "project")
allPatterns.push(...projectPatterns)
if (projectPatterns.length > 0) {
log.debug("loaded project .kilocodeignore", { count: projectPatterns.length })
}
// 3. Build permission rules
if (allPatterns.length === 0) {
return {
permission: {},
warnings,
patternCount: 0,
}
}
const rules = buildPermissionRules(allPatterns)
// 4. Create permission config for both read and edit
const permission: Config.Permission = {
read: rules,
edit: rules,
}
return {
permission,
warnings,
patternCount: allPatterns.length,
}
}
/**
* Load .kilocodeignore and return permission config.
* Handles all logging internally.
*/
export async function loadIgnoreConfig(projectDir: string, skipGlobalPaths?: boolean): Promise<Config.Permission> {
try {
const result = await migrate({ projectDir, skipGlobalPaths })
if (result.patternCount > 0) {
log.info("loaded .kilocodeignore patterns", {
count: result.patternCount,
})
}
for (const warning of result.warnings) {
log.warn("ignore migration warning", { warning })
}
return result.permission
} catch (err) {
log.warn("failed to load .kilocodeignore", { error: err })
return {}
}
}
}
+1
View File
@@ -2,5 +2,6 @@ export { ModesMigrator } from "./modes-migrator"
export { RulesMigrator } from "./rules-migrator"
export { WorkflowsMigrator } from "./workflows-migrator"
export { McpMigrator } from "./mcp-migrator"
export { IgnoreMigrator } from "./ignore-migrator" // kilocode_change
export { KilocodeConfigInjector } from "./config-injector"
export { KilocodePaths } from "./paths"
@@ -198,6 +198,92 @@ describe("KilocodeConfigInjector", () => {
expect(result.warnings.some((w) => w.includes("Legacy"))).toBe(true)
})
// kilocode_change end
// kilocode_change start - Ignore migration tests
test("includes ignore patterns in config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/\n*.env")
},
})
const result = await KilocodeConfigInjector.buildConfig({
projectDir: tmp.path,
skipGlobalPaths: true,
})
const config = JSON.parse(result.configJson)
expect(config.permission).toBeDefined()
expect(config.permission.read).toBeDefined()
expect(config.permission.edit).toBeDefined()
expect(config.permission.read["secrets/*"]).toBe("deny")
expect(config.permission.read["*.env"]).toBe("deny")
})
test("skips ignore when includeIgnore is false", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/")
},
})
const result = await KilocodeConfigInjector.buildConfig({
projectDir: tmp.path,
skipGlobalPaths: true,
includeIgnore: false,
})
const config = JSON.parse(result.configJson)
expect(config.permission).toBeUndefined()
})
test("combines ignore with other migrations", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Add custom mode
await Bun.write(
path.join(dir, ".kilocodemodes"),
`customModes:
- slug: translate
name: Translate
roleDefinition: You are a translator
groups:
- read`,
)
// Add ignore patterns
await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/")
},
})
const result = await KilocodeConfigInjector.buildConfig({
projectDir: tmp.path,
skipGlobalPaths: true,
})
const config = JSON.parse(result.configJson)
expect(config.agent).toBeDefined()
expect(config.agent.translate).toBeDefined()
expect(config.permission).toBeDefined()
expect(config.permission.read["secrets/*"]).toBe("deny")
})
test("handles negation patterns in ignore file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocodeignore"), "*.env\n!.env.example")
},
})
const result = await KilocodeConfigInjector.buildConfig({
projectDir: tmp.path,
skipGlobalPaths: true,
})
const config = JSON.parse(result.configJson)
expect(config.permission.read["*.env"]).toBe("deny")
expect(config.permission.read[".env.example"]).toBe("allow")
})
// kilocode_change end
})
describe("getEnvVars", () => {
@@ -0,0 +1,308 @@
import { test, expect, describe } from "bun:test"
import { IgnoreMigrator } from "../../src/kilocode/ignore-migrator"
import { tmpdir } from "../fixture/fixture"
import path from "path"
describe("IgnoreMigrator", () => {
describe("parseIgnoreContent", () => {
test("parses basic patterns", () => {
const content = `
# Comment
secrets/
*.env
!.env.example
`
const patterns = IgnoreMigrator.parseIgnoreContent(content)
expect(patterns).toEqual([
{ pattern: "secrets/", negated: false },
{ pattern: "*.env", negated: false },
{ pattern: ".env.example", negated: true },
])
})
test("ignores empty lines and comments", () => {
const content = `
# This is a comment
pattern1
# Another comment
pattern2
`
const patterns = IgnoreMigrator.parseIgnoreContent(content)
expect(patterns).toHaveLength(2)
expect(patterns[0].pattern).toBe("pattern1")
expect(patterns[1].pattern).toBe("pattern2")
})
test("handles negation patterns", () => {
const content = `*.log
!important.log`
const patterns = IgnoreMigrator.parseIgnoreContent(content)
expect(patterns[0]).toEqual({ pattern: "*.log", negated: false })
expect(patterns[1]).toEqual({ pattern: "important.log", negated: true })
})
test("handles empty content", () => {
const patterns = IgnoreMigrator.parseIgnoreContent("")
expect(patterns).toHaveLength(0)
})
test("handles content with only comments", () => {
const content = `# Comment 1
# Comment 2`
const patterns = IgnoreMigrator.parseIgnoreContent(content)
expect(patterns).toHaveLength(0)
})
test("trims whitespace from patterns", () => {
const content = ` secrets/
*.env `
const patterns = IgnoreMigrator.parseIgnoreContent(content)
expect(patterns[0].pattern).toBe("secrets/")
expect(patterns[1].pattern).toBe("*.env")
})
})
describe("convertToGlob", () => {
test("converts directory pattern", () => {
expect(IgnoreMigrator.convertToGlob("secrets/")).toBe("secrets/*")
})
test("preserves simple filename pattern", () => {
// Opencode's * already matches any path depth
expect(IgnoreMigrator.convertToGlob("*.env")).toBe("*.env")
})
test("converts rooted patterns", () => {
expect(IgnoreMigrator.convertToGlob("/root-only")).toBe("root-only")
})
test("preserves patterns with path separators", () => {
expect(IgnoreMigrator.convertToGlob("src/secrets/")).toBe("src/secrets/*")
})
test("handles double-star patterns", () => {
expect(IgnoreMigrator.convertToGlob("**/deep/")).toBe("*deep/*")
})
test("preserves simple filename without extension", () => {
// Opencode's * already matches any path depth
expect(IgnoreMigrator.convertToGlob("Dockerfile")).toBe("Dockerfile")
})
test("preserves extension-only pattern", () => {
expect(IgnoreMigrator.convertToGlob("*.log")).toBe("*.log")
})
test("preserves nested path pattern", () => {
expect(IgnoreMigrator.convertToGlob("config/secrets.json")).toBe("config/secrets.json")
})
})
describe("buildPermissionRules", () => {
test("creates default allow rule", () => {
const rules = IgnoreMigrator.buildPermissionRules([])
expect(rules["*"]).toBe("allow")
})
test("creates deny rules for patterns", () => {
const patterns = [{ pattern: "secrets/", negated: false, source: "project" as const }]
const rules = IgnoreMigrator.buildPermissionRules(patterns)
expect(rules["*"]).toBe("allow")
expect(rules["secrets/*"]).toBe("deny")
})
test("creates allow rules for negated patterns", () => {
const patterns = [
{ pattern: "*.env", negated: false, source: "project" as const },
{ pattern: ".env.example", negated: true, source: "project" as const },
]
const rules = IgnoreMigrator.buildPermissionRules(patterns)
expect(rules["*.env"]).toBe("deny")
expect(rules[".env.example"]).toBe("allow")
})
test("handles multiple deny patterns", () => {
const patterns = [
{ pattern: "secrets/", negated: false, source: "project" as const },
{ pattern: "*.env", negated: false, source: "project" as const },
{ pattern: "*.key", negated: false, source: "project" as const },
]
const rules = IgnoreMigrator.buildPermissionRules(patterns)
expect(rules["secrets/*"]).toBe("deny")
expect(rules["*.env"]).toBe("deny")
expect(rules["*.key"]).toBe("deny")
})
test("negated patterns override deny patterns", () => {
const patterns = [
{ pattern: "config/", negated: false, source: "project" as const },
{ pattern: "config/public.json", negated: true, source: "project" as const },
]
const rules = IgnoreMigrator.buildPermissionRules(patterns)
expect(rules["config/*"]).toBe("deny")
expect(rules["config/public.json"]).toBe("allow")
})
})
describe("migrate", () => {
test("returns empty permission for project without .kilocodeignore", async () => {
await using tmp = await tmpdir()
const result = await IgnoreMigrator.migrate({
projectDir: tmp.path,
skipGlobalPaths: true,
})
expect(result.patternCount).toBe(0)
expect(Object.keys(result.permission)).toHaveLength(0)
})
test("loads project .kilocodeignore", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/\n*.env")
},
})
const result = await IgnoreMigrator.migrate({
projectDir: tmp.path,
skipGlobalPaths: true,
})
expect(result.patternCount).toBe(2)
expect(result.permission.read).toBeDefined()
expect(result.permission.edit).toBeDefined()
})
test("applies patterns to both read and edit", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/")
},
})
const result = await IgnoreMigrator.migrate({
projectDir: tmp.path,
skipGlobalPaths: true,
})
const readRules = result.permission.read as Record<string, string>
const editRules = result.permission.edit as Record<string, string>
expect(readRules["secrets/*"]).toBe("deny")
expect(editRules["secrets/*"]).toBe("deny")
})
test("handles negation patterns correctly", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocodeignore"), "*.env\n!.env.example")
},
})
const result = await IgnoreMigrator.migrate({
projectDir: tmp.path,
skipGlobalPaths: true,
})
const readRules = result.permission.read as Record<string, string>
expect(readRules["*.env"]).toBe("deny")
expect(readRules[".env.example"]).toBe("allow")
})
test("handles complex .kilocodeignore file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, ".kilocodeignore"),
`# Secrets
secrets/
*.env
!.env.example
# Keys
*.key
*.pem
# Config
/config/private/
!config/private/public.json
`,
)
},
})
const result = await IgnoreMigrator.migrate({
projectDir: tmp.path,
skipGlobalPaths: true,
})
expect(result.patternCount).toBe(7)
const readRules = result.permission.read as Record<string, string>
expect(readRules["*"]).toBe("allow")
expect(readRules["secrets/*"]).toBe("deny")
expect(readRules["*.env"]).toBe("deny")
expect(readRules[".env.example"]).toBe("allow")
expect(readRules["*.key"]).toBe("deny")
expect(readRules["*.pem"]).toBe("deny")
expect(readRules["config/private/*"]).toBe("deny")
expect(readRules["config/private/public.json"]).toBe("allow")
})
test("returns empty warnings array", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/")
},
})
const result = await IgnoreMigrator.migrate({
projectDir: tmp.path,
skipGlobalPaths: true,
})
expect(result.warnings).toHaveLength(0)
})
})
describe("loadIgnoreConfig", () => {
test("returns empty object for project without .kilocodeignore", async () => {
await using tmp = await tmpdir()
const permission = await IgnoreMigrator.loadIgnoreConfig(tmp.path, true)
expect(Object.keys(permission)).toHaveLength(0)
})
test("returns permission config for project with .kilocodeignore", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/")
},
})
const permission = await IgnoreMigrator.loadIgnoreConfig(tmp.path, true)
expect(permission.read).toBeDefined()
expect(permission.edit).toBeDefined()
})
test("handles errors gracefully", async () => {
// Pass a non-existent directory
const permission = await IgnoreMigrator.loadIgnoreConfig("/non/existent/path", true)
expect(Object.keys(permission)).toHaveLength(0)
})
})
})