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
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,36 +233,38 @@ match: any
---
```

All matching list fields accept either a YAML array or a single string, which is treated as a one-element array; a declared field with no valid entries never matches. `hooks` remains array-only. Quote values that begin with `{` so YAML does not parse them as flow mappings.

### Supported Fields

- `globs` (optional): Array of glob patterns for file-based matching
- `globs` (optional): String or array of glob patterns for file-based matching
- Rule applies when any file in context matches a pattern
- `fileContains` (optional): String or array of literal substrings to find in observed file content
- Rule applies when one observed file's path matches `globs` (if declared) AND the same observation's text contains any literal (OR across literals)
- Case-sensitive literal matching; regex metacharacters have no special meaning and literals may span lines
- Usable without `globs` to match content alone; a declared field with no valid literal makes the rule never match (fail-closed, with a warning)
- Observed only from successful Read, Write, Edit, Apply Patch, and LSP tool events
- `keywords` (optional): Array of keywords for prompt-based matching
- `keywords` (optional): String or array of keywords for prompt-based matching
- Rule applies when the user's prompt contains any keyword
- Case-insensitive, word-boundary matching (e.g., "test" matches "testing")
- Does NOT match mid-word (e.g., "test" does NOT match "contest")
- `tools` (optional): Array of tool IDs for tool-availability matching
- `tools` (optional): String or array of tool IDs for tool-availability matching
- Rule applies when any listed tool is available to the agent
- Uses exact string matching against tool IDs (e.g., `mcp_websearch`, `mcp_bash`)
- Enable debug logging (`OPENCODE_RULES_DEBUG=1`) to see available tool IDs
- `model` (optional): Array of model IDs to match against the current LLM
- `model` (optional): String or array of model IDs to match against the current LLM
- Example: `['gpt-5.3-codex', 'claude-sonnet-4']`
- `agent` (optional): Array of agent types to match
- `agent` (optional): String or array of agent types to match
- Example: `['programmer', 'planner']`
- `command` (optional): Array of slash commands to match
- `command` (optional): String or array of slash commands to match
- Example: `['/plan', '/review']`
- `project` (optional): Array of project type tags to match
- `project` (optional): String or array of project type tags to match
- Detected automatically from marker files (e.g., `package.json` -> `node`)
- Supported tags: `node`, `python`, `go`, `rust`, `monorepo`, `browser-extension`
- `branch` (optional): Array of git branch patterns to match
- `branch` (optional): String or array of git branch patterns to match
- Supports exact names and glob patterns (e.g., `feature/*`, `release/**`)
- Uses minimatch for glob matching
- `os` (optional): Array of operating systems to match
- `os` (optional): String or array of operating systems to match
- Values: `linux`, `darwin`, `win32`
- `ci` (optional): Boolean to match CI environment
- `true` matches when running in CI, `false` matches when not in CI
Expand Down Expand Up @@ -299,6 +301,8 @@ The plugin uses `minimatch` for pattern matching:
| `*.json` | JSON files in root directory only |
| `lib/{utils,helpers}/**/*.js` | JavaScript files in specific lib subdirectories |

A brace pattern can be the whole `globs` value, e.g. `globs: "{src,test}/**/*.ts"`. The quoted scalar is one pattern; use an array to list unrelated patterns.

## Included Skill: crafting-rules

This repository includes a `crafting-rules/` skill that teaches AI agents how to create well-formatted rules. The skill provides:
Expand Down
8 changes: 5 additions & 3 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Hidden files and directories (starting with `.`) are automatically excluded from

You can define conditional rules in both `.md` and `.mdc` files using YAML frontmatter with various filter fields.

List-valued filters (`globs`, `keywords`, `tools`, `model`, `agent`, `command`, `project`, `branch`, `os`) accept either a single string or a YAML array of strings. A declared filter with no valid entries never matches. Quote values that begin with `{` so YAML does not parse them as flow mappings.

Set `name` to control the short label used when the rule is delivered. The
value is trimmed; when omitted, the filename stem is used.

Expand All @@ -56,7 +58,7 @@ This is a rule for TypeScript components.

#### File-Based Conditions (globs)

The `globs` key contains a list of glob patterns. The rule applies if any observed file's path matches one of the patterns.
The `globs` key accepts a single glob pattern or a list of glob patterns. The rule applies if any observed file's path matches one of the patterns.

```markdown
---
Expand Down Expand Up @@ -104,7 +106,7 @@ Notes:

#### Prompt-Based Conditions (keywords)

The `keywords` key contains a list of keywords. The rule applies if the user's prompt contains any of the keywords (case-insensitive, word-boundary matching).
The `keywords` key accepts a single keyword or a list of keywords. The rule applies if the user's prompt contains any of the keywords (case-insensitive, word-boundary matching).

```markdown
---
Expand All @@ -124,7 +126,7 @@ Keyword matching uses word boundaries, so:

#### Tool-Based Conditions (tools)

The `tools` key contains a list of tool IDs. The rule applies if any listed tool is available in the current environment.
The `tools` key accepts a single tool ID or a list of tool IDs. The rule applies if any listed tool is available in the current environment.

```markdown
---
Expand Down
19 changes: 10 additions & 9 deletions skills/crafting-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,21 @@ Rules are markdown files with optional YAML frontmatter, injected into the syste

| Field | Type | Category | Purpose |
| -------------- | ---------------------- | ---------- | --------------------------------------------------------------- |
| `globs` | `string[]` | Legacy | Apply when any observed file's path matches a pattern |
| `globs` | `string` \| `string[]` | Legacy | Apply when any observed file's path matches a pattern |
| `fileContains` | `string` \| `string[]` | Legacy | Apply when an observed file's text contains a literal substring |
| `keywords` | `string[]` | Legacy | Apply when the user's latest prompt matches a keyword |
| `tools` | `string[]` | Legacy | Apply when any listed tool ID is available |
| `model` | `string[]` | Runtime | Match against the current LLM model ID |
| `agent` | `string[]` | Runtime | Match against the current agent type (e.g., `programmer`) |
| `command` | `string[]` | Runtime | Match against the current slash command (e.g., `/plan`) |
| `project` | `string[]` | Runtime | Match against detected project tags (e.g., `node`, `rust`) |
| `branch` | `string[]` | Runtime | Match against git branch name (supports glob patterns) |
| `os` | `string[]` | Runtime | Match against OS (`linux`, `darwin`, `win32`) |
| `keywords` | `string` \| `string[]` | Legacy | Apply when the user's latest prompt matches a keyword |
| `tools` | `string` \| `string[]` | Legacy | Apply when any listed tool ID is available |
| `model` | `string` \| `string[]` | Runtime | Match against the current LLM model ID |
| `agent` | `string` \| `string[]` | Runtime | Match against the current agent type (e.g., `programmer`) |
| `command` | `string` \| `string[]` | Runtime | Match against the current slash command (e.g., `/plan`) |
| `project` | `string` \| `string[]` | Runtime | Match against detected project tags (e.g., `node`, `rust`) |
| `branch` | `string` \| `string[]` | Runtime | Match against git branch name (supports glob patterns) |
| `os` | `string` \| `string[]` | Runtime | Match against OS (`linux`, `darwin`, `win32`) |
| `ci` | `boolean` | Runtime | Match against CI environment (`true` = in CI) |
| `match` | `'any'` \| `'all'` | Combinator | `any` (default): OR logic. `all`: AND logic. |

- All fields are optional; no frontmatter means the rule always applies.
- List-valued fields accept either a single string or an array of strings; a string is one pattern. Glob and branch patterns support minimatch brace expansion (e.g. `{src,test}/**/*.ts`); quote brace patterns so YAML does not parse them as flow mappings. A declared field with no valid entries never matches.
- With `match: any` (default), the rule applies if ANY declared condition matches.
- With `match: all`, the rule applies only if ALL declared conditions match.
- `globs` and `fileContains` form one file-observation family: when both are declared, the same observed file must satisfy both. The family counts as one condition in the algebra.
Expand Down
4 changes: 2 additions & 2 deletions src/index.rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1191,7 +1191,7 @@ rule`;
expect(metadata?.model).toEqual(['claude-opus']);
});

it('should return undefined when all array values are empty after trim/filter', () => {
it('should return an empty array when all array values are empty after trim/filter', () => {
const content = `---
globs:
- ""
Expand All @@ -1200,7 +1200,7 @@ globs:
rule`;

const metadata = parseRuleMetadata(content);
expect(metadata?.globs).toBeUndefined();
expect(metadata?.globs).toEqual([]);
});

it('should process all array fields consistently', () => {
Expand Down
34 changes: 34 additions & 0 deletions src/rules/rule-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ describe('matchRuleSnapshots condition semantics (live delivery)', () => {
).toEqual(['base.mdc']);
});

it('never matches a declared glob field with no valid patterns', () => {
const [entry] = matchRuleSnapshots([snapshot({ globs: [] }, 'empty.mdc')], {
fileObservations: [{ path: 'src/index.ts', tool: 'read', content: '' }],
});
expect(entry).toBeUndefined();
});

it('matches model, agent, and command conditions', () => {
const entries = matchRuleSnapshots(
[
Expand Down Expand Up @@ -312,6 +319,33 @@ Keywords fail rule.`
expect(entries[0]?.filePath).toBe(matchingPath);
expect(entries[0]?.lifetime).toBe('ephemeral');
});

it('matches a scalar brace glob loaded through loadRuleSnapshots', async () => {
const { globalRulesDir } = setupTestDirs();
const rulePath = path.join(globalRulesDir, 'scalar-brace.mdc');
writeFileSync(
rulePath,
`---
globs: "{src/platform/win32/*.cpp,src/core/*.cpp,tests/*.cpp}"
---

Scalar brace rule.`
);

const snapshots = await loadRuleSnapshots([
{ filePath: rulePath, relativePath: 'scalar-brace.mdc' },
]);
const entries = matchRuleSnapshots(snapshots, {
fileObservations: [
{ path: 'src/core/engine.cpp', tool: 'read', content: '' },
{ path: 'other/engine.cpp', tool: 'read', content: '' },
],
});

expect(entries.map(entry => entry.relativePath)).toEqual([
'scalar-brace.mdc',
]);
});
});

describe('file-observation family matching', () => {
Expand Down
37 changes: 37 additions & 0 deletions src/rules/rule-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,43 @@ hooks:
});
});

describe('list field parsing', () => {
it('accepts a scalar string for every list field', () => {
const content = `---
globs: "{src,test}/**/*.ts"
keywords: " refactoring "
tools: mcp_bash
model: claude-sonnet-4
agent: programmer
command: /plan
project: node
branch: feature/*
os: linux
---
rule`;

const meta = parseRuleMetadata(content);
expect(meta).toEqual({
globs: ['{src,test}/**/*.ts'],
keywords: ['refactoring'],
tools: ['mcp_bash'],
model: ['claude-sonnet-4'],
agent: ['programmer'],
command: ['/plan'],
project: ['node'],
branch: ['feature/*'],
os: ['linux'],
});
});

it('yields an empty array for declared list fields with no valid entries', () => {
expect(parseRuleMetadata('---\nglobs: ""\n---\nrule')?.globs).toEqual([]);
expect(parseRuleMetadata('---\nkeywords: []\n---\nrule')?.keywords).toEqual(
[]
);
});
});

describe('fileContains parsing', () => {
it('parses a scalar string as a one-element array', () => {
const content = `---
Expand Down
14 changes: 8 additions & 6 deletions src/rules/rule-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,17 @@ type StringArrayField =
| 'branch'
| 'os';

function toEntries(value: unknown): unknown[] {
if (typeof value === 'string') return [value];
return Array.isArray(value) ? value : [];
}

// Unlike the other condition fields, a declared fileContains that yields no
// valid literal resolves to [] (not undefined) so the rule fails closed
// instead of degrading to unconditional.
function extractFileContains(value: unknown): string[] {
const entries =
typeof value === 'string' ? [value] : Array.isArray(value) ? value : [];
const result: string[] = [];
for (const entry of entries) {
for (const entry of toEntries(value)) {
if (typeof entry !== 'string') continue;
const literal = entry.trim();
if (literal.length === 0 || result.includes(literal)) continue;
Expand All @@ -72,14 +75,13 @@ function extractFileContains(value: unknown): string[] {
}

function extractStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
if (typeof value !== 'string' && !Array.isArray(value)) {
return undefined;
}
const result = value
return toEntries(value)
.filter((v): v is string => typeof v === 'string')
.map(v => v.trim())
.filter(v => v.length > 0);
return result.length > 0 ? result : undefined;
}

export function parseRuleMetadata(content: string): RuleMetadata | null {
Expand Down
Loading