diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 35c780fbdd57..48a1d638078a 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,6 +1,7 @@ import { Instance } from "../project/instance" import { Log } from "../util/log" import { Flag } from "../flag/flag" +import { SmartRule } from "../smart-rule" export namespace FileTime { const log = Log.create({ service: "file.time" }) @@ -26,6 +27,9 @@ export namespace FileTime { const { read } = state() read[sessionID] = read[sessionID] || {} read[sessionID][file] = new Date() + + // Track for smart rules (no-op if feature disabled) + SmartRule.track(sessionID, file) } export function get(sessionID: string, file: string) { diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 0274dcc82b0d..a511981c3ae3 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -46,6 +46,8 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK") export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") + export const OPENCODE_EXPERIMENTAL_SMART_RULES = + OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SMART_RULES") export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] function number(key: string) { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b81a21a57bee..bcf955274813 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -22,6 +22,7 @@ import { Snapshot } from "@/snapshot" import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" import { Global } from "@/global" +import { SmartRule } from "@/smart-rule" export namespace Session { const log = Log.create({ service: "session" }) @@ -348,6 +349,8 @@ export namespace Session { await Storage.remove(msg) } await Storage.remove(["session", project.id, sessionID]) + // Clean up smart rule tracking for this session + SmartRule.clearSession(sessionID) Bus.publish(Event.Deleted, { info: session, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8554b44a7272..0b3923f4cc97 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -598,7 +598,11 @@ export namespace SessionPrompt { agent, abort, sessionID, - system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())], + system: [ + ...(await SystemPrompt.environment(model)), + ...(await SystemPrompt.custom()), + ...(await SystemPrompt.smartRules(sessionID)), + ], messages: [ ...MessageV2.toModelMessages(sessionMessages, model), ...(isLastStep diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 8d619357a4f3..ca79ade202fd 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -17,6 +17,7 @@ import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" import PROMPT_CODEX from "./prompt/codex_header.txt" import type { Provider } from "@/provider/provider" import { Flag } from "@/flag/flag" +import { SmartRule } from "@/smart-rule" const log = Log.create({ service: "system-prompt" }) @@ -154,4 +155,8 @@ export namespace SystemPrompt { ) return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean)) } + + export async function smartRules(sessionID: string): Promise { + return SmartRule.inject(sessionID) + } } diff --git a/packages/opencode/src/smart-rule/index.ts b/packages/opencode/src/smart-rule/index.ts new file mode 100644 index 000000000000..45afdb35620c --- /dev/null +++ b/packages/opencode/src/smart-rule/index.ts @@ -0,0 +1,225 @@ +import path from "path" +import { minimatch } from "minimatch" +import z from "zod" +import { Instance } from "../project/instance" +import { ConfigMarkdown } from "../config/markdown" +import { Global } from "../global" +import { Filesystem } from "../util/filesystem" +import { Flag } from "../flag/flag" +import { Log } from "../util/log" + +export namespace SmartRule { + const log = Log.create({ service: "smart-rule" }) + + // ──────────────────────────────────────────────────────────────── + // Types + // ──────────────────────────────────────────────────────────────── + + export const Frontmatter = z.object({ + description: z.string().optional(), + paths: z.array(z.string()).optional(), // Primary + globs: z.array(z.string()).optional(), // Alias + patterns: z.array(z.string()).optional(), // Alias + alwaysApply: z.boolean().optional(), + }) + export type Frontmatter = z.infer + + export interface Info { + name: string + path: string + source: "opencode-project" | "claude-project" | "global" + patterns: string[] + matchers: ((file: string) => boolean)[] // Pre-compiled + alwaysApply: boolean + content: string + description?: string + } + + // ──────────────────────────────────────────────────────────────── + // State (per-project, cached via Instance.state) + // ──────────────────────────────────────────────────────────────── + + export const state = Instance.state(() => ({ + // Discovery cache - loaded once per project + rules: null as Info[] | null, + // Per-session tracked files + files: {} as Record>, + })) + + // ──────────────────────────────────────────────────────────────── + // File Tracking (called from FileTime.read) + // ──────────────────────────────────────────────────────────────── + + export function track(sessionID: string, filepath: string) { + if (!Flag.OPENCODE_EXPERIMENTAL_SMART_RULES) return + + const s = state() + s.files[sessionID] = s.files[sessionID] ?? new Set() + + // Normalize to relative path from worktree + const relative = path.isAbsolute(filepath) ? path.relative(Instance.worktree, filepath) : filepath + + // Skip paths outside project + if (relative.startsWith("..")) return + + s.files[sessionID].add(relative) + } + + export function clearSession(sessionID: string) { + const s = state() + delete s.files[sessionID] + } + + // ──────────────────────────────────────────────────────────────── + // Discovery (lazy-loaded, cached) + // ──────────────────────────────────────────────────────────────── + + const RULES_GLOB = new Bun.Glob("**/*.md") + + async function discover(): Promise { + const s = state() + if (s.rules !== null) return s.rules + + const rules = new Map() + + // 1. Global: ~/.config/opencode/rules/ + await scanDir(path.join(Global.Path.config, "rules"), "global", rules) + + // 2. Claude project: .claude/rules/ (compatibility) + if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { + for await (const dir of Filesystem.up({ + targets: [".claude"], + start: Instance.directory, + stop: Instance.worktree, + })) { + await scanDir(path.join(dir, "rules"), "claude-project", rules) + } + // Global ~/.claude/rules/ + await scanDir(path.join(Global.Path.home, ".claude", "rules"), "claude-project", rules) + } + + // 3. OpenCode project: .opencode/rules/ (highest precedence) + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + for await (const dir of Filesystem.up({ + targets: [".opencode"], + start: Instance.directory, + stop: Instance.worktree, + })) { + await scanDir(path.join(dir, "rules"), "opencode-project", rules) + } + } + + s.rules = Array.from(rules.values()) + log.info("discovered rules", { count: s.rules.length }) + return s.rules + } + + async function scanDir(dir: string, source: Info["source"], rules: Map) { + if (!(await Filesystem.isDir(dir))) return + + for await (const match of RULES_GLOB.scan({ + cwd: dir, + absolute: true, + onlyFiles: true, + followSymlinks: true, + dot: true, + })) { + try { + const rule = await parseRule(match, source) + if (!rule) continue + + const key = rule.name.toLowerCase() + const existing = rules.get(key) + + // Later sources override (opencode > claude > global) + const priority = { global: 0, "claude-project": 1, "opencode-project": 2 } + if (!existing || priority[source] > priority[existing.source]) { + rules.set(key, rule) + } + } catch (err) { + log.warn("failed to parse rule", { path: match, err }) + } + } + } + + async function parseRule(filepath: string, source: Info["source"]): Promise { + const md = await ConfigMarkdown.parse(filepath) + const parsed = Frontmatter.safeParse(md.data) + if (!parsed.success) return null + + const fm = parsed.data + const patterns = [...(fm.paths ?? []), ...(fm.globs ?? []), ...(fm.patterns ?? [])] + + // Pre-compile matchers for performance + const matchers = patterns.map((p) => { + return (file: string) => minimatch(file, p, { dot: true, matchBase: true }) + }) + + return { + name: path.basename(filepath, ".md"), + path: filepath, + source, + patterns, + matchers, + alwaysApply: fm.alwaysApply ?? false, + content: md.content.trim(), + description: fm.description, + } + } + + // ──────────────────────────────────────────────────────────────── + // Matching & Injection + // ──────────────────────────────────────────────────────────────── + + export async function inject(sessionID: string): Promise { + // Early exit checks + if (!Flag.OPENCODE_EXPERIMENTAL_SMART_RULES) return [] + + const s = state() + const files = s.files[sessionID] + if (!files || files.size === 0) return [] + + const rules = await discover() + if (rules.length === 0) return [] + + const fileArray = Array.from(files) + const matched: Info[] = [] + + for (const rule of rules) { + if (rule.alwaysApply) { + matched.push(rule) + continue + } + if (rule.matchers.length === 0) continue + + // Check if any file matches any pattern + const matches = fileArray.some((file) => rule.matchers.some((matcher) => matcher(file))) + if (matches) matched.push(rule) + } + + if (matched.length === 0) return [] + + // Build output + const lines = [ + "", + "The following rules apply based on the files you're working with:", + "", + ] + + for (const rule of matched) { + lines.push(`### ${rule.name}`) + if (rule.description) lines.push(`_${rule.description}_`) + lines.push("", rule.content, "") + } + + lines.push("") + + log.info("injected rules", { + sessionID, + count: matched.length, + rules: matched.map((r) => r.name), + }) + + return [lines.join("\n")] + } +} diff --git a/packages/opencode/test/smart-rule/fixtures/always-apply-rule.md b/packages/opencode/test/smart-rule/fixtures/always-apply-rule.md new file mode 100644 index 000000000000..d529e9560ddc --- /dev/null +++ b/packages/opencode/test/smart-rule/fixtures/always-apply-rule.md @@ -0,0 +1,8 @@ +--- +description: "Always applied project standards" +alwaysApply: true +--- + +# Project Standards + +Follow these standards always. diff --git a/packages/opencode/test/smart-rule/fixtures/css-rule.md b/packages/opencode/test/smart-rule/fixtures/css-rule.md new file mode 100644 index 000000000000..0d473576676a --- /dev/null +++ b/packages/opencode/test/smart-rule/fixtures/css-rule.md @@ -0,0 +1,11 @@ +--- +description: "CSS formatting guidelines" +globs: + - "**/*.css" + - "**/*.scss" +--- + +# CSS Guidelines + +- Use 2-space indentation +- Order properties alphabetically diff --git a/packages/opencode/test/smart-rule/fixtures/no-patterns-rule.md b/packages/opencode/test/smart-rule/fixtures/no-patterns-rule.md new file mode 100644 index 000000000000..db2878d5e14d --- /dev/null +++ b/packages/opencode/test/smart-rule/fixtures/no-patterns-rule.md @@ -0,0 +1,7 @@ +--- +description: "Rule without any patterns" +--- + +# No Patterns + +This rule has no patterns and should never match. diff --git a/packages/opencode/test/smart-rule/fixtures/patterns-alias-rule.md b/packages/opencode/test/smart-rule/fixtures/patterns-alias-rule.md new file mode 100644 index 000000000000..ca4edd9d3a71 --- /dev/null +++ b/packages/opencode/test/smart-rule/fixtures/patterns-alias-rule.md @@ -0,0 +1,9 @@ +--- +description: "Rule using patterns alias" +patterns: + - "src/components/**/*.tsx" +--- + +# Component Patterns + +React component guidelines. diff --git a/packages/opencode/test/smart-rule/fixtures/typescript-rule.md b/packages/opencode/test/smart-rule/fixtures/typescript-rule.md new file mode 100644 index 000000000000..a9a29ad854b7 --- /dev/null +++ b/packages/opencode/test/smart-rule/fixtures/typescript-rule.md @@ -0,0 +1,11 @@ +--- +description: "TypeScript coding guidelines" +paths: + - "**/*.ts" + - "**/*.tsx" +--- + +# TypeScript Guidelines + +- Use strict mode +- Prefer interfaces over types diff --git a/packages/opencode/test/smart-rule/smart-rule.test.ts b/packages/opencode/test/smart-rule/smart-rule.test.ts new file mode 100644 index 000000000000..8b38dd820ff6 --- /dev/null +++ b/packages/opencode/test/smart-rule/smart-rule.test.ts @@ -0,0 +1,517 @@ +import { test, expect, describe, beforeEach } from "bun:test" +import { SmartRule } from "../../src/smart-rule" +import { Instance } from "../../src/project/instance" +import { Flag } from "../../src/flag/flag" +import { tmpdir } from "../fixture/fixture" +import path from "path" + +describe("SmartRule.Frontmatter", () => { + test("should parse valid frontmatter with paths", () => { + const result = SmartRule.Frontmatter.safeParse({ + description: "Test rule", + paths: ["**/*.ts"], + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.description).toBe("Test rule") + expect(result.data.paths).toEqual(["**/*.ts"]) + } + }) + + test("should parse valid frontmatter with globs alias", () => { + const result = SmartRule.Frontmatter.safeParse({ + globs: ["**/*.css"], + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.globs).toEqual(["**/*.css"]) + } + }) + + test("should parse valid frontmatter with patterns alias", () => { + const result = SmartRule.Frontmatter.safeParse({ + patterns: ["src/**/*.tsx"], + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.patterns).toEqual(["src/**/*.tsx"]) + } + }) + + test("should parse alwaysApply flag", () => { + const result = SmartRule.Frontmatter.safeParse({ + alwaysApply: true, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.alwaysApply).toBe(true) + } + }) + + test("should allow empty frontmatter", () => { + const result = SmartRule.Frontmatter.safeParse({}) + expect(result.success).toBe(true) + }) +}) + +describe("SmartRule.track", () => { + test("should track files when feature is enabled", async () => { + // Save original flag value + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Force enable the flag for this test + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + const sessionID = "test-session-1" + SmartRule.track(sessionID, path.join(tmp.path, "src/index.ts")) + SmartRule.track(sessionID, path.join(tmp.path, "src/utils.ts")) + + const state = SmartRule.state() + expect(state.files[sessionID]).toBeDefined() + expect(state.files[sessionID].size).toBe(2) + expect(state.files[sessionID].has("src/index.ts")).toBe(true) + expect(state.files[sessionID].has("src/utils.ts")).toBe(true) + + // Clean up + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) + + test("should normalize absolute paths to relative", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + const sessionID = "test-session-2" + // Track with absolute path + SmartRule.track(sessionID, path.join(tmp.path, "deep/nested/file.ts")) + + const state = SmartRule.state() + // Should be stored as relative path + expect(state.files[sessionID].has("deep/nested/file.ts")).toBe(true) + + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) + + test("should ignore paths outside project", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + const sessionID = "test-session-3" + // Track a path outside the project + SmartRule.track(sessionID, "/some/external/path.ts") + + const state = SmartRule.state() + // Should not be tracked (starts with ..) + expect(state.files[sessionID]?.size ?? 0).toBe(0) + + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) +}) + +describe("SmartRule.clearSession", () => { + test("should remove session tracking data", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + const sessionID = "test-session-clear" + SmartRule.track(sessionID, path.join(tmp.path, "file.ts")) + + let state = SmartRule.state() + expect(state.files[sessionID]).toBeDefined() + + SmartRule.clearSession(sessionID) + + state = SmartRule.state() + expect(state.files[sessionID]).toBeUndefined() + + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) +}) + +describe("SmartRule.inject", () => { + test("should return empty when feature is disabled", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = false + + const result = await SmartRule.inject("any-session") + expect(result).toEqual([]) + + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) + + test("should return empty when no files tracked", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + const result = await SmartRule.inject("empty-session") + expect(result).toEqual([]) + + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) + + test("should match rules by glob pattern", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Create a rule file + const rulesDir = path.join(dir, ".opencode", "rules") + await Bun.write( + path.join(rulesDir, "typescript.md"), + `--- +description: "TypeScript rules" +paths: + - "**/*.ts" +--- + +# TypeScript + +Use strict mode. +` + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + const sessionID = "test-inject-match" + // Track a .ts file + SmartRule.track(sessionID, path.join(tmp.path, "src/index.ts")) + + // Reset rules cache to force rediscovery + SmartRule.state().rules = null + + const result = await SmartRule.inject(sessionID) + expect(result.length).toBe(1) + expect(result[0]).toContain("TypeScript") + expect(result[0]).toContain("Use strict mode.") + expect(result[0]).toContain("") + + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) + + test("should include alwaysApply rules regardless of files", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const rulesDir = path.join(dir, ".opencode", "rules") + await Bun.write( + path.join(rulesDir, "always.md"), + `--- +description: "Always applied" +alwaysApply: true +--- + +# Always Rule + +This is always included. +` + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + const sessionID = "test-always-apply" + // Track a random file that won't match any pattern + SmartRule.track(sessionID, path.join(tmp.path, "random.xyz")) + + SmartRule.state().rules = null + + const result = await SmartRule.inject(sessionID) + expect(result.length).toBe(1) + expect(result[0]).toContain("Always Rule") + expect(result[0]).toContain("This is always included.") + + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) + + test("should not match rules without patterns", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const rulesDir = path.join(dir, ".opencode", "rules") + await Bun.write( + path.join(rulesDir, "no-patterns.md"), + `--- +description: "No patterns rule" +--- + +# No Patterns + +Should never match. +` + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + const sessionID = "test-no-patterns" + SmartRule.track(sessionID, path.join(tmp.path, "any-file.ts")) + + SmartRule.state().rules = null + + const result = await SmartRule.inject(sessionID) + expect(result).toEqual([]) + + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) + + test("should support globs alias for patterns", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const rulesDir = path.join(dir, ".opencode", "rules") + await Bun.write( + path.join(rulesDir, "css.md"), + `--- +description: "CSS rules" +globs: + - "**/*.css" +--- + +# CSS + +Format properly. +` + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + const sessionID = "test-globs-alias" + SmartRule.track(sessionID, path.join(tmp.path, "styles/main.css")) + + SmartRule.state().rules = null + + const result = await SmartRule.inject(sessionID) + expect(result.length).toBe(1) + expect(result[0]).toContain("CSS") + + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) +}) + +describe("SmartRule discovery", () => { + test("should discover rules from .opencode/rules/", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const rulesDir = path.join(dir, ".opencode", "rules") + await Bun.write( + path.join(rulesDir, "test-rule.md"), + `--- +description: "Test rule" +paths: ["**/*.ts"] +--- + +Content. +` + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + + SmartRule.state().rules = null + + const sessionID = "test-discovery" + SmartRule.track(sessionID, path.join(tmp.path, "file.ts")) + + const result = await SmartRule.inject(sessionID) + expect(result.length).toBe(1) + + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + }, + }) + }) + + test("should discover rules from .claude/rules/ for compatibility", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + const originalClaudeFlag = Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const rulesDir = path.join(dir, ".claude", "rules") + await Bun.write( + path.join(rulesDir, "claude-rule.md"), + `--- +description: "Claude rule" +paths: ["**/*.js"] +--- + +Claude content. +` + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + ;(Flag as any).OPENCODE_DISABLE_CLAUDE_CODE_PROMPT = false + + SmartRule.state().rules = null + + const sessionID = "test-claude-compat" + SmartRule.track(sessionID, path.join(tmp.path, "app.js")) + + const result = await SmartRule.inject(sessionID) + expect(result.length).toBe(1) + expect(result[0]).toContain("Claude content") + + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + ;(Flag as any).OPENCODE_DISABLE_CLAUDE_CODE_PROMPT = originalClaudeFlag + }, + }) + }) + + test("opencode rules should take precedence over claude rules", async () => { + const originalFlag = Flag.OPENCODE_EXPERIMENTAL_SMART_RULES + const originalClaudeFlag = Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Create same-named rule in both directories + const opencodeRulesDir = path.join(dir, ".opencode", "rules") + const claudeRulesDir = path.join(dir, ".claude", "rules") + + await Bun.write( + path.join(claudeRulesDir, "shared.md"), + `--- +description: "Claude version" +paths: ["**/*.ts"] +--- + +Claude content - should NOT appear. +` + ) + + await Bun.write( + path.join(opencodeRulesDir, "shared.md"), + `--- +description: "OpenCode version" +paths: ["**/*.ts"] +--- + +OpenCode content - should appear. +` + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = true + ;(Flag as any).OPENCODE_DISABLE_CLAUDE_CODE_PROMPT = false + + SmartRule.state().rules = null + + const sessionID = "test-precedence" + SmartRule.track(sessionID, path.join(tmp.path, "index.ts")) + + const result = await SmartRule.inject(sessionID) + expect(result.length).toBe(1) + expect(result[0]).toContain("OpenCode content - should appear.") + expect(result[0]).not.toContain("Claude content - should NOT appear.") + + SmartRule.clearSession(sessionID) + ;(Flag as any).OPENCODE_EXPERIMENTAL_SMART_RULES = originalFlag + ;(Flag as any).OPENCODE_DISABLE_CLAUDE_CODE_PROMPT = originalClaudeFlag + }, + }) + }) +})