diff --git a/README.md b/README.md index fb46ce3..eb3042b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/docs/rules.md b/docs/rules.md index ae6942f..a39de10 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -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. @@ -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 --- @@ -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 --- @@ -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 --- diff --git a/skills/crafting-rules/SKILL.md b/skills/crafting-rules/SKILL.md index 872f3b9..808ff6b 100644 --- a/skills/crafting-rules/SKILL.md +++ b/skills/crafting-rules/SKILL.md @@ -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. diff --git a/src/index.rules.test.ts b/src/index.rules.test.ts index 8bc3419..9e719e4 100644 --- a/src/index.rules.test.ts +++ b/src/index.rules.test.ts @@ -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: - "" @@ -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', () => { diff --git a/src/rules/rule-filter.test.ts b/src/rules/rule-filter.test.ts index 24928a8..ba0c125 100644 --- a/src/rules/rule-filter.test.ts +++ b/src/rules/rule-filter.test.ts @@ -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( [ @@ -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', () => { diff --git a/src/rules/rule-metadata.test.ts b/src/rules/rule-metadata.test.ts index 515f5a6..6c80f5f 100644 --- a/src/rules/rule-metadata.test.ts +++ b/src/rules/rule-metadata.test.ts @@ -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 = `--- diff --git a/src/rules/rule-metadata.ts b/src/rules/rule-metadata.ts index e6c82ee..0c4c472 100644 --- a/src/rules/rule-metadata.ts +++ b/src/rules/rule-metadata.ts @@ -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; @@ -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 {