From c9b5d6aefc3347740672b6327ec02e3e76a49ac0 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Thu, 12 Nov 2026 10:57:57 -0500 Subject: [PATCH] feat(rules): accept scalar strings for list frontmatter fields Treat a single string in globs, keywords, and other list fields as a one-element array, matching fileContains behavior. Brace expansion already works via minimatch, so quoted brace patterns now apply their condition instead of silently making the rule unconditional. Declared list fields with no valid entries now fail closed instead of degrading to unconditional. Refs #92 --- README.md | 22 ++++++++++++-------- docs/rules.md | 8 ++++--- skills/crafting-rules/SKILL.md | 19 +++++++++-------- src/index.rules.test.ts | 4 ++-- src/rules/rule-filter.test.ts | 34 ++++++++++++++++++++++++++++++ src/rules/rule-metadata.test.ts | 37 +++++++++++++++++++++++++++++++++ src/rules/rule-metadata.ts | 14 +++++++------ 7 files changed, 109 insertions(+), 29 deletions(-) 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 {