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
29 changes: 29 additions & 0 deletions packages/opencode/src/session/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,35 @@ export namespace Instruction {
}
}

// Rules files from global and project directories
const globalRulesDir = path.join(Global.Path.home, ".opencode", "rules")

const globalRuleFiles = yield* fs
.glob("*.md", { cwd: globalRulesDir, absolute: true, include: "file" })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))

// Project rules only load when project config is not disabled (trust boundary)
const projectRuleFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: yield* fs
.glob("*.md", {
cwd: path.join(Instance.directory, ".opencode", "rules"),
absolute: true,
include: "file",
})
.pipe(Effect.catch(() => Effect.succeed([] as string[])))

Comment on lines +152 to +161
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Project rules are globbed from path.join(Instance.directory, ".opencode", "rules"), which only works when the instance directory is the project root. If the user runs OpenCode from a subdirectory, rules placed at {worktree}/.opencode/rules/*.md won’t be discovered. Consider using Instance.worktree (git root) for the project rules directory, or fs.findUp(".opencode/rules", Instance.directory, Instance.worktree) to locate the rules dir within the project boundary.

Suggested change
const projectRuleFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: yield* fs
.glob("*.md", {
cwd: path.join(Instance.directory, ".opencode", "rules"),
absolute: true,
include: "file",
})
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const projectRulesDir = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: yield* fs.findUp(".opencode/rules", Instance.directory, Instance.worktree)
const projectRuleFiles =
Flag.OPENCODE_DISABLE_PROJECT_CONFIG || projectRulesDir.length === 0
? []
: yield* fs
.glob("*.md", {
cwd: path.resolve(projectRulesDir[0]),
absolute: true,
include: "file",
})
.pipe(Effect.catch(() => Effect.succeed([] as string[])))

Copilot uses AI. Check for mistakes.
// Project rules override global by filename
const projectFilenames = new Set(projectRuleFiles.map((p) => path.basename(p)))
for (const rule of globalRuleFiles) {
if (!projectFilenames.has(path.basename(rule))) {
paths.add(path.resolve(rule))
}
}
for (const rule of projectRuleFiles) {
paths.add(path.resolve(rule))
}

if (config.instructions) {
for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
Expand Down
191 changes: 191 additions & 0 deletions packages/opencode/test/session/instruction-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Instruction } from "../../src/session/instruction"
import { Instance } from "../../src/project/instance"
import { Global } from "../../src/global"
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Global is imported but not used in this test file. Removing the unused import will keep the test code tidy and avoid unused-import lint failures if/when stricter TS options or a linter are enabled.

Suggested change
import { Global } from "../../src/global"

Copilot uses AI. Check for mistakes.
import { tmpdir } from "../fixture/fixture"

describe("Instruction.systemPaths rules loading", () => {
test("loads *.md files from global rules directory", async () => {
await using homeTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".opencode", "rules", "style.md"), "# Style Rules")
await Bun.write(path.join(dir, ".opencode", "rules", "security.md"), "# Security Rules")
},
})
await using projectTmp = await tmpdir()

const originalHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = homeTmp.path

try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "style.md"))).toBe(true)
expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "security.md"))).toBe(true)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
}
})

test("loads *.md files from project rules directory", async () => {
await using homeTmp = await tmpdir()
await using projectTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".opencode", "rules", "local.md"), "# Local Rules")
},
})

const originalHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = homeTmp.path

try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(projectTmp.path, ".opencode", "rules", "local.md"))).toBe(true)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
}
})
Comment on lines +35 to +57
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite doesn’t currently cover the common case where OpenCode is invoked from a subdirectory: Instance.directory is nested but rules live in {worktree}/.opencode/rules/*.md. Add a test that provides an instance rooted in a nested directory and verifies project rules in the worktree root are still loaded (and still override globals by filename).

Copilot uses AI. Check for mistakes.

test("project rule with same filename overrides global", async () => {
await using homeTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".opencode", "rules", "style.md"), "# Global Style")
await Bun.write(path.join(dir, ".opencode", "rules", "unique-global.md"), "# Unique Global")
},
})
await using projectTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".opencode", "rules", "style.md"), "# Project Style")
},
})

const originalHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = homeTmp.path

try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
// Project style.md should be present
expect(paths.has(path.join(projectTmp.path, ".opencode", "rules", "style.md"))).toBe(true)
// Global style.md should NOT be present (overridden)
expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "style.md"))).toBe(false)
// Global unique-global.md should still be present
expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "unique-global.md"))).toBe(true)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
}
})

test("missing rules directories do not cause errors", async () => {
await using homeTmp = await tmpdir()
await using projectTmp = await tmpdir()

const originalHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = homeTmp.path

try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
// Should not throw even when neither rules dir exists
const paths = await Instruction.systemPaths()
expect(paths).toBeInstanceOf(Set)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
}
})

test("project rules are not loaded when OPENCODE_DISABLE_PROJECT_CONFIG is set", async () => {
await using homeTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".opencode", "rules", "global.md"), "# Global Rule")
},
})
await using projectTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".opencode", "rules", "malicious.md"), "# Malicious Rule")
},
})

const originalHome = process.env.OPENCODE_TEST_HOME
const originalDisable = process.env.OPENCODE_DISABLE_PROJECT_CONFIG
process.env.OPENCODE_TEST_HOME = homeTmp.path
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"

try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
// Global rules should still load
expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "global.md"))).toBe(true)
// Project rules must NOT load (trust boundary)
expect(paths.has(path.join(projectTmp.path, ".opencode", "rules", "malicious.md"))).toBe(false)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
if (originalDisable === undefined) {
delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG
} else {
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = originalDisable
}
}
})

test("non-.md files are not loaded from rules directories", async () => {
await using homeTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".opencode", "rules", "valid.md"), "# Valid Rule")
await Bun.write(path.join(dir, ".opencode", "rules", "ignored.txt"), "Not a rule")
await Bun.write(path.join(dir, ".opencode", "rules", "ignored.json"), '{"not": "a rule"}')
},
})
await using projectTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".opencode", "rules", "local.md"), "# Local Rule")
await Bun.write(path.join(dir, ".opencode", "rules", "data.yaml"), "not: a rule")
},
})

const originalHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = homeTmp.path

try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
const allPaths = Array.from(paths)

// .md files should be loaded
expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "valid.md"))).toBe(true)
expect(paths.has(path.join(projectTmp.path, ".opencode", "rules", "local.md"))).toBe(true)

// non-.md files should NOT be loaded
expect(allPaths.some((p) => p.endsWith("ignored.txt"))).toBe(false)
expect(allPaths.some((p) => p.endsWith("ignored.json"))).toBe(false)
expect(allPaths.some((p) => p.endsWith("data.yaml"))).toBe(false)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
}
})
})
Loading