diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3fc3bf6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.11 + + - run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bunx tsc --noEmit + + - name: Test + run: bun run test:run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a1e733e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + push: + tags: ['v*'] + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.11 + + - run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bunx tsc --noEmit + + - name: Test + run: bun run test:run + + - name: Build + run: bun run build + + - name: Verify package contents + run: bun pm pack --dry-run + + - name: Publish to npm + run: bun publish --access public + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@26e8ad27a09a225049a7075d7ec1caa2df6ff332 # v2 + with: + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 709b0e8..7cb2afb 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,10 @@ temp/ # Agent planning .opencode/plans + +# Desloppify runtime artifacts +.desloppify/external_review_sessions/ +.desloppify/review_packets/ +.desloppify/subagents/ +.desloppify/*.bak +.desloppify/review_packet_blind.json diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..f936c19 --- /dev/null +++ b/.npmignore @@ -0,0 +1,14 @@ +# Exclusions handled by package.json "files" negation patterns +# This file is kept for documentation purposes + +# Test files - excluded via !src/**/*.test.ts, !tui/**/*.test.ts patterns +# *.test.ts +# *.test.tsx +# *.spec.ts +# *.spec.tsx + +# Test fixtures - excluded via !src/test-fixtures.ts +# src/test-fixtures.ts + +# Compile-time type checks - excluded via !src/api-surface.typecheck.ts +# src/api-surface.typecheck.ts diff --git a/.opencode/rules/10-runtime-consistency.md b/.opencode/rules/10-runtime-consistency.md new file mode 100644 index 0000000..fd68c5e --- /dev/null +++ b/.opencode/rules/10-runtime-consistency.md @@ -0,0 +1,13 @@ +--- +globs: + - 'src/runtime.ts' + - 'src/utils.ts' + - 'src/message-context.ts' + - 'src/mcp-tools.ts' +--- + +# Runtime Consistency + +- Use shared message-context helpers for prompt and part extraction. Do not duplicate extraction loops in runtime hooks. +- Keep CI/env boolean detection on one parser path (`parseEnvBoolean` / `isTruthyEnvValue`) across all provider checks. +- Keep warning channels consistent: user-actionable rule-file problems may use `console.warn`; internal operational failures should go through debug logging. diff --git a/.opencode/rules/11-readme-and-doc-sync.md b/.opencode/rules/11-readme-and-doc-sync.md new file mode 100644 index 0000000..19d2386 --- /dev/null +++ b/.opencode/rules/11-readme-and-doc-sync.md @@ -0,0 +1,17 @@ +--- +globs: + - 'README.md' + - 'docs/**/*.md' +keywords: + - 'readme' + - 'architecture' + - 'project structure' + - 'documentation' +match: any +--- + +# README and Documentation Sync + +- When adding, removing, or renaming production modules, update the README Project Structure section in the same change. +- Keep architecture docs aligned with current hook/runtime behavior and supported rule filters. +- Remove stale references to deprecated behavior as part of the same PR that changes behavior. diff --git a/.opencode/rules/12-hotspot-guardrails.md b/.opencode/rules/12-hotspot-guardrails.md new file mode 100644 index 0000000..e7440c7 --- /dev/null +++ b/.opencode/rules/12-hotspot-guardrails.md @@ -0,0 +1,12 @@ +--- +globs: + - 'src/utils.ts' + - 'src/runtime.ts' + - 'src/index.test.ts' +--- + +# Hotspot Guardrails + +- In `src/utils.ts`, do not add new unrelated responsibilities. Prefer splitting by domain (discovery, metadata, matching, message paths). +- In `src/runtime.ts`, extract shared helpers before adding additional inline transformation logic. +- In `src/index.test.ts`, prefer creating or expanding module-focused test files instead of growing the monolithic suite. diff --git a/README.md b/README.md index 7497b3e..f2a2be2 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ This approach allows you to dynamically include rules automatically like style g guidance on specific actions, etc. Unlike skills, which are called on by the agent, rules use a simple matching approach. +> [!NOTE] +> The name `opencode-rules` is to be concise about what this plugin does. It is in no way affiliated with Anomaly Co. or +> the official OpenCode project. + ## Features - **Dual-format support**: Load rules from both `.md` and `.mdc` files @@ -33,11 +37,19 @@ approach. - **Zero-configuration**: Works out of the box with XDG Base Directory specification - **TypeScript-first**: Built with TypeScript for type safety and developer experience - **Performance optimized**: Efficient file discovery and minimal startup overhead +- **TUI sidebar**: Real-time sidebar in the OpenCode TUI showing rule status with active/inactive indicators ## Quick Start ### Installation +```bash +opencode plugin opencode-rules@latest --global +``` + +
+Manual installation + Add the plugin to your opencode config: ```json @@ -47,6 +59,17 @@ Add the plugin to your opencode config: } ``` +To enable the TUI sidebar, add the same plugin entry to your TUI config: + +```json +// ~/.config/opencode/tui.json +{ + "plugin": ["opencode-rules@latest"] +} +``` + +
+ ### Create Your First Rule 1. Create the global rules directory: @@ -69,13 +92,42 @@ Add the plugin to your opencode config: That's it! The rule will now be automatically injected into all AI agent prompts. +## How It Works + +1. **Discovery**: Scan global and project directories for `.md` and `.mdc` files (at plugin init) +2. **Parsing**: Extract metadata from files with YAML front matter +3. **Tool Execution**: `tool.execute.before` hook captures file paths before tools run +4. **Message Flow**: `chat.message` hook updates user prompt as messages arrive + - Tracks turn count, model ID, and agent type for rule filtering +5. **Initial Seeding**: `experimental.chat.messages.transform` extracts context from message history once +6. **Rule Filtering**: `experimental.chat.system.transform` evaluates rules based on context and injects into system prompt + - Dedup: Rules already present in the current system prompt are skipped (content-based comparison) + - Hash-based dedup: Identical rule sets are not re-injected (KV-cache preservation) +7. **State Persistence**: After filtering, matched rule paths are written to `~/.opencode/state/opencode-rules/{sessionId}.json` for TUI consumption +8. **Compaction Persistence**: `experimental.session.compacting` preserves context during session compression +9. **User Prompt Injection**: `experimental.chat.messages.transform` injects user/both-mode rules before the last user message + - Injected at configurable turn intervals via `repeat_every` + - Raw content only (no headers or preamble) + - Marked as synthetic to prevent accidental extraction + +## Performance + +- Rule discovery performed once at plugin initialization +- Rule content cached with mtime-based invalidation for fast re-reads +- Incremental session state tracking (set of paths, not message rescanning) +- Per-session state pruned after 100 concurrent sessions to prevent memory growth +- Efficient glob matching with `minimatch` +- Tool-based path capture is non-blocking with minimal overhead +- Session context cleaned up when exceeded (LRU eviction) +- Minimal memory footprint with efficient state management + ## Configuration ### Rule Discovery Locations Rules are automatically discovered from these directories (including all subdirectories): -1. **Global rules**: `$XDG_CONFIG_HOME/opencode/rules/` (typically `~/.config/opencode/rules/`) +1. **Global rules**: `$OPENCODE_CONFIG_DIR/rules/` if set, otherwise `$XDG_CONFIG_HOME/opencode/rules/` (typically `~/.config/opencode/rules/`) 2. **Project rules**: `.opencode/rules/` (in your project root) Both directories are scanned recursively, allowing you to organize rules into subdirectories. @@ -85,8 +137,128 @@ Both directories are scanned recursively, allowing you to organize rules into su - `.md` - Standard markdown files with optional metadata - `.mdc` - Markdown files with optional metadata +## Metadata Format + +Both `.md` and `.mdc` files support optional YAML metadata for conditional rule application: + +```yaml +--- +globs: + - 'src/**/*.ts' + - 'lib/**/*.js' +keywords: + - 'refactoring' + - 'cleanup' +tools: + - 'mcp_websearch' + - 'mcp_lsp' +model: + - gpt-5.3-codex + - claude-sonnet-4 +agent: + - programmer +command: + - /plan + - /review +project: + - node + - monorepo +branch: + - main + - feature/* +os: + - linux + - darwin +ci: false +# Matching mode +match: any +# Injection mode +inject: system +# Repeat interval (turns) for user-prompt injection +repeat_every: 1 +--- +``` + +### Supported Fields + +- `globs` (optional): Array of glob patterns for file-based matching + - Rule applies when any file in context matches a pattern +- `keywords` (optional): 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 + - 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 + - Example: `['gpt-5.3-codex', 'claude-sonnet-4']` +- `agent` (optional): Array of agent types to match + - Example: `['programmer', 'planner']` +- `command` (optional): Array of slash commands to match + - Example: `['/plan', '/review']` +- `project` (optional): 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 + - Supports exact names and glob patterns (e.g., `feature/*`, `release/**`) + - Uses minimatch for glob matching +- `os` (optional): 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 +- `match` (optional): Matching mode for multiple conditions + - `any` (default): Rule applies if ANY declared condition matches + - `all`: Rule applies only if ALL declared conditions match +- `inject` (optional): Where to inject the rule content + - `system` (default): Injected into the system prompt once per session + - `user`: Injected into user messages at configurable intervals + - `both`: Injected into both system prompt and user messages +- `repeat_every` (optional): Minimum number of user turns between user-prompt injections + - Accepts a number (e.g., `3` to inject every 3rd turn) or a model-pattern map + - Model-pattern map example: `{ "gpt-4*": 3, "default": 1 }` (uses minimatch for pattern matching) + - Default: `1` (every turn) when `inject` is `user` or `both` + - Only applies to rules with `inject: user` or `inject: both` + +> [!NOTE] +> When a runtime context value is unavailable (e.g., not in a git repository), that dimension is treated as a non-match. + +### Matching Behavior + +- **No metadata**: Rule applies unconditionally (always included) +- **Only globs**: Rule applies when any context file matches +- **Only keywords**: Rule applies when the user's prompt contains any keyword +- **Only tools**: Rule applies when any listed tool is available +- **Multiple conditions with `match: any` (default)**: Rule applies when ANY condition matches (OR logic across all fields) +- **Multiple conditions with `match: all`**: Rule applies only when ALL declared conditions match + +## Glob Pattern Reference + +The plugin uses `minimatch` for pattern matching: + +| Pattern | Matches | +| ----------------------------- | ----------------------------------------------- | +| `src/**/*.ts` | All TypeScript files in src and subdirectories | +| `**/*.test.ts` | All test files at any depth | +| `src/components/**/*.tsx` | React components in components directory | +| `*.json` | JSON files in root directory only | +| `lib/{utils,helpers}/**/*.js` | JavaScript files in specific lib subdirectories | + +## Included Skill: crafting-rules + +This repository includes a `crafting-rules/` skill that teaches AI agents how to create well-formatted rules. The skill provides: + +- **Rule format reference** - Frontmatter fields (`globs`, `keywords`, `tools`, `model`, `agent`, `command`, `project`, `branch`, `os`, `ci`, `match`) and markdown body structure +- **Matching strategy guidance** - When to use globs vs keywords vs runtime filters vs combinations +- **Pattern extraction workflow** - How to identify repeated conversation patterns that should become rules +- **Keyword safety guidelines** - Denylist of overly broad keywords to avoid, allowlist of safe alternatives, and an audit checklist + +To use the skill, copy `skills/crafting-rules/` to `~/.config/opencode/skills/` or reference it directly. The skill triggers when users ask to create rules, codify preferences, or persist guidance across sessions. + ## Usage Examples +For real-world examples, see the [`.opencode/rules/`](.opencode/rules/) directory in this repository. + ### Basic Rule File Create `~/.config/opencode/rules/naming-convention.md`: @@ -282,117 +454,6 @@ globs: - Co-locate styles with components ``` -## Metadata Format - -Both `.md` and `.mdc` files support optional YAML metadata for conditional rule application: - -```yaml ---- -# Legacy filters -globs: - - 'src/**/*.ts' - - 'lib/**/*.js' -keywords: - - 'refactoring' - - 'cleanup' -tools: - - 'mcp_websearch' - - 'mcp_lsp' -# Runtime environment filters -model: - - gpt-5.3-codex - - claude-sonnet-4 -agent: - - programmer -command: - - /plan - - /review -project: - - node - - monorepo -branch: - - main - - feature/* -os: - - linux - - darwin -ci: false -# Matching mode -match: any ---- -``` - -### Supported Fields - -#### Legacy Filters - -- `globs` (optional): Array of glob patterns for file-based matching - - Rule applies when any file in context matches a pattern -- `keywords` (optional): 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 - - 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 - -#### Runtime Environment Filters - -- `model` (optional): 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 - - Example: `['programmer', 'planner']` -- `command` (optional): Array of slash commands to match - - Example: `['/plan', '/review']` -- `project` (optional): 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 - - Supports exact names and glob patterns (e.g., `feature/*`, `release/**`) - - Uses minimatch for glob matching -- `os` (optional): 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 -- `match` (optional): Matching mode for multiple conditions - - `any` (default): Rule applies if ANY declared condition matches - - `all`: Rule applies only if ALL declared conditions match - -**Note:** When a runtime context value is unavailable (e.g., not in a git repository), that dimension is treated as a non-match. - -### Matching Behavior - -- **No metadata**: Rule applies unconditionally (always included) -- **Only globs**: Rule applies when any context file matches -- **Only keywords**: Rule applies when the user's prompt contains any keyword -- **Only tools**: Rule applies when any listed tool is available -- **Multiple conditions with `match: any` (default)**: Rule applies when ANY condition matches (OR logic across all fields) -- **Multiple conditions with `match: all`**: Rule applies only when ALL declared conditions match - -## Glob Pattern Reference - -The plugin uses `minimatch` for pattern matching: - -| Pattern | Matches | -| ----------------------------- | ----------------------------------------------- | -| `src/**/*.ts` | All TypeScript files in src and subdirectories | -| `**/*.test.ts` | All test files at any depth | -| `src/components/**/*.tsx` | React components in components directory | -| `*.json` | JSON files in root directory only | -| `lib/{utils,helpers}/**/*.js` | JavaScript files in specific lib subdirectories | - -## Included Skill: crafting-rules - -This repository includes a `crafting-rules/` skill that teaches AI agents how to create well-formatted rules. The skill provides: - -- **Rule format reference** - Frontmatter fields (`globs`, `keywords`, `tools`, `model`, `agent`, `command`, `project`, `branch`, `os`, `ci`, `match`) and markdown body structure -- **Matching strategy guidance** - When to use globs vs keywords vs runtime filters vs combinations -- **Pattern extraction workflow** - How to identify repeated conversation patterns that should become rules -- **Keyword safety guidelines** - Denylist of overly broad keywords to avoid, allowlist of safe alternatives, and an audit checklist - -To use the skill, copy `skills/crafting-rules/` to `~/.config/opencode/skills/` or reference it directly. The skill triggers when users ask to create rules, codify preferences, or persist guidance across sessions. - ## Development ### Project Structure (Abridged) @@ -415,10 +476,20 @@ opencode-rules/ │ ├── project-fingerprint.ts # Project type detection (Node.js, Python, etc.) │ ├── mcp-tools.ts # MCP tool ID extraction │ ├── git-branch.ts # Git branch detection +│ ├── active-rules-state.ts # Persists matched rules per session for TUI │ ├── debug.ts # Debug logging utilities │ ├── utils.ts # Re-export facade for backwards compatibility │ ├── test-fixtures.ts # Shared test fixtures and builders │ └── *.test.ts # Unit/integration tests (11 test files) +├── tui/ +│ ├── index.tsx # TUI entrypoint, exports { id, tui } +│ ├── slots/ +│ │ └── sidebar-content.tsx # Sidebar widget component +│ ├── data/ +│ │ ├── rules.ts # Rule discovery + formatting for sidebar +│ │ └── rules.test.ts # Data layer tests +│ └── types/ +│ └── opencode-plugin-tui.d.ts # Vendored type shim ├── docs/ │ └── rules.md # Detailed usage documentation ├── openspec/ # Project specifications and proposals @@ -434,12 +505,36 @@ The following highlights the primary runtime modules: - **runtime-chat.ts** - Extracts text from chat message parts for keyword matching - **rule-discovery.ts** - Recursively scans directories for `.md`/`.mdc` rule files - **rule-metadata.ts** - Parses YAML frontmatter into typed `RuleMetadata` -- **rule-filter.ts** - Evaluates rules against context (globs, keywords, tools, runtime filters) -- **message-paths.ts** - Extracts file paths from message content +- **rule-filter.ts** - Evaluates rules against context (globs, keywords, tools, runtime filters); returns `FilterResult` with `formattedRules` and `matchedPaths` +- **message-paths.ts** - Extracts file paths from tool invocation arguments and message text +- **message-context.ts** - Extracts user prompt text, slash commands, and session IDs from message parts - **session-store.ts** - Manages per-session state with LRU eviction - **project-fingerprint.ts** - Detects project type from marker files (e.g., `package.json`) +- **mcp-tools.ts** - Maps connected MCP clients to tool IDs for `tools` condition matching +- **git-branch.ts** - Resolves current git branch for `branch` condition matching +- **active-rules-state.ts** - Persists which rules matched per session to `~/.opencode/state/opencode-rules/{sessionId}.json` for TUI consumption (atomic writes, per-session queuing) - **utils.ts** - Thin facade re-exporting from decomposed modules +### TUI Sidebar + +The plugin registers a `sidebar_content` slot in the OpenCode TUI, displaying all discovered rules (global and project-local) with their active state and metadata. + +**Requirements:** `@opencode-ai/plugin` ^1.3.7 with TUI support. + +**What it shows:** + +- Collapsible "Project" and "Global" sections grouping rules by scope +- Active/inactive status indicators (green bullet for active, muted for inactive) based on persisted state from the current session +- Condition summary for conditional rules ("always active" for unconditional ones) +- Expandable detail panel with all metadata fields (globs, keywords, tools, model, agent, command, project, branch, os, ci, match) +- Loading, error, and empty states + +**Behavior:** + +- Active rules are sorted to the top within each section +- Subscribes to `message.updated` and `session.status` events for real-time refresh (150ms debounce, filtered by session ID) +- Active state is read from `~/.opencode/state/opencode-rules/{sessionId}.json`, written by the server plugin after each rule evaluation + ### Build and Test ```bash @@ -512,14 +607,6 @@ This plugin uses OpenCode's hook system for incremental, stateful rule injection - Injects current context paths into the compaction context - Prevents rules from being lost during session compression -### Benefits Over Previous Approach - -- **Incremental state tracking** - Builds context incrementally rather than rescanning messages each turn -- **Authoritative path capture** - Tool hooks provide verified file paths directly from the tool definition -- **Real-time responsiveness** - Context updates as tools execute and messages arrive -- **Compaction-aware** - Context persists through session compression -- **Efficient caching** - Rule discovery happens once at startup, not on every LLM call - ### Experimental API Notice This plugin depends on experimental OpenCode APIs: @@ -530,27 +617,6 @@ This plugin depends on experimental OpenCode APIs: These APIs may change in future OpenCode versions. Check OpenCode release notes when upgrading. -## How It Works - -1. **Discovery**: Scan global and project directories for `.md` and `.mdc` files (at plugin init) -2. **Parsing**: Extract metadata from files with YAML front matter -3. **Tool Execution**: `tool.execute.before` hook captures file paths before tools run -4. **Message Flow**: `chat.message` hook updates user prompt as messages arrive -5. **Initial Seeding**: `experimental.chat.messages.transform` extracts context from message history once -6. **Rule Filtering**: `experimental.chat.system.transform` evaluates rules based on context and injects into system prompt -7. **Compaction Persistence**: `experimental.session.compacting` preserves context during session compression - -## Performance - -- Rule discovery performed once at plugin initialization -- Rule content cached with mtime-based invalidation for fast re-reads -- Incremental session state tracking (set of paths, not message rescanning) -- Per-session state pruned after 100 concurrent sessions to prevent memory growth -- Efficient glob matching with `minimatch` -- Tool-based path capture is non-blocking with minimal overhead -- Session context cleaned up when exceeded (LRU eviction) -- Minimal memory footprint with efficient state management - ## Debug Logging To enable debug logging, set the `OPENCODE_RULES_DEBUG` environment variable: @@ -573,7 +639,7 @@ This will log information about: 1. Verify directories exist: `~/.config/opencode/rules/` and/or `.opencode/rules/` 2. Check file extensions are `.md` or `.mdc` 3. Ensure files with metadata have properly formatted YAML -4. Test glob patterns using the `fileMatchesGlobs()` function +4. Enable debug logging (`OPENCODE_RULES_DEBUG=1`) to see which rules are being matched ### Common Issues diff --git a/docs/rules.md b/docs/rules.md index 3ecdd6f..952bbdc 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -10,7 +10,7 @@ This document explains how to use OpenCode Rules to inject custom instructions i Rules are defined in Markdown files (`.md` or `.mdc`). These files can be located in two places: -- **Global Rules:** `~/.config/opencode/rules/` +- **Global Rules:** `$OPENCODE_CONFIG_DIR/rules/` if set, otherwise `$XDG_CONFIG_HOME/opencode/rules/` (typically `~/.config/opencode/rules/`) - **Project Rules:** `.opencode/rules/` in the root of your project. Both directories are scanned **recursively**, so you can organize your rules into subdirectories. Rule discovery happens once when the plugin initializes. diff --git a/eslint.config.js b/eslint.config.js index 2a77604..1a977e2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -45,4 +45,59 @@ export default [ '@typescript-eslint/no-explicit-any': 'off', }, }, + // TUI production files + { + files: ['tui/**/*.ts', 'tui/**/*.tsx'], + ignores: [ + 'tui/**/*.test.ts', + 'tui/**/*.test.tsx', + 'tui/**/*.spec.ts', + 'tui/**/*.spec.tsx', + ], + languageOptions: { + parser: typescriptParser, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + project: './tsconfig.json', + }, + }, + plugins: { + '@typescript-eslint': typescriptPlugin, + }, + rules: { + ...typescriptPlugin.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + }, + }, + // TUI test files + { + files: [ + 'tui/**/*.test.ts', + 'tui/**/*.test.tsx', + 'tui/**/*.spec.ts', + 'tui/**/*.spec.tsx', + ], + languageOptions: { + parser: typescriptParser, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + }, + plugins: { + '@typescript-eslint': typescriptPlugin, + }, + rules: { + ...typescriptPlugin.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'off', + }, + }, ]; diff --git a/package.json b/package.json index a686c86..e9046f4 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,24 @@ { "name": "opencode-rules", - "version": "0.4.0", + "version": "0.6.4", "description": "OpenCode plugin that discovers and injects markdown rules into system prompts", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", "type": "module", + "oc-plugin": [ + "server", + "tui" + ], "scripts": { "build": "tsc", "dev": "tsc --watch", "test": "vitest", "test:run": "vitest run", - "lint": "eslint src", - "format": "prettier --write src/**/*.ts", - "format:check": "prettier --check src/**/*.ts", + "lint": "eslint src tui", + "format": "prettier --write 'src/**/*.ts' 'tui/**/*.tsx' 'tui/**/*.ts'", + "format:check": "prettier --check 'src/**/*.ts' 'tui/**/*.tsx' 'tui/**/*.ts'", "clean": "rm -rf dist", - "prepublishOnly": "npm run clean && npm run build" + "prepublishOnly": "bun run clean && bun run build" }, "keywords": [ "opencode", @@ -34,29 +38,57 @@ }, "files": [ "dist", + "!dist/**/*.test.*", + "!dist/**/*.spec.*", + "!dist/**/test-fixtures.*", + "!dist/**/api-surface.typecheck.*", + "src/", + "!src/**/*.test.ts", + "!src/**/*.spec.ts", + "!src/test-fixtures.ts", + "!src/api-surface.typecheck.ts", + "tui/", + "!tui/**/*.test.ts", + "!tui/**/*.test.tsx", + "!tui/**/*.spec.ts", + "!tui/**/*.spec.tsx", "README.md", - "lICENSE" + "LICENSE" ], "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + }, + "./tui": { + "types": "./dist/tui/index.d.ts", + "import": "./tui/index.tsx" } }, + "peerDependencies": { + "@opencode-ai/plugin": "^1.3.7", + "@opencode-ai/sdk": "^1.3.7" + }, "devDependencies": { + "@opencode-ai/plugin": "^1.3.9", + "@opencode-ai/sdk": "^1.3.9", + "@opentui/core": "^0.1.93", + "@opentui/solid": "^0.1.93", "@types/minimatch": "^5.1.2", "@types/node": "^20.19.30", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.57.1", "prettier": "^3.8.1", + "solid-js": "1.9.11", "typescript": "^5.9.3", "vitest": "^1.6.1" }, "dependencies": { - "@opencode-ai/plugin": "^1.1.34", - "@opencode-ai/sdk": "^1.1.34", + "@opentui/core": "^0.1.93", + "@opentui/solid": "^0.1.93", "minimatch": "^9.0.5", + "solid-js": "1.9.11", "yaml": "^2.8.2" } } diff --git a/skills/crafting-rules/SKILL.md b/skills/crafting-rules/SKILL.md index e9b8c8d..b85acea 100644 --- a/skills/crafting-rules/SKILL.md +++ b/skills/crafting-rules/SKILL.md @@ -1,11 +1,34 @@ --- name: crafting-rules -description: Use when creating or modifying OpenCode rules (.md/.mdc files) that customize agent behavior. Helps extract patterns from conversation history, analyze project conventions (AGENTS.md, linters, package.json), and draft well-formatted rules with appropriate globs/keywords. Trigger when user wants to create a rule, codify repeated instructions, persist guidance across sessions, or customize agent behavior for specific files or topics. +description: Use when creating or modifying OpenCode rules (.md/.mdc files) that customize agent behavior. Trigger when user wants to create a rule, codify repeated instructions, persist guidance across sessions, or scope rules to specific files, prompts, environments, or workflows. --- # Crafting Rules -Rules are markdown files injected into the system prompt to guide agent behavior. +## Overview + +Rules are markdown files with optional YAML frontmatter, injected into the system prompt to guide agent behavior. Scope them with filters or leave unconditional for global standards. + +## Field Reference + +| Field | Type | Category | Purpose | +| ---------- | ------------------ | ---------- | ---------------------------------------------------------- | +| `globs` | `string[]` | Legacy | Apply when any file in context matches a pattern | +| `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`) | +| `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. +- With `match: any` (default), the rule applies if ANY declared condition matches. +- With `match: all`, the rule applies only if ALL declared conditions match. +- When a runtime value is unavailable (e.g., no git repo), that dimension is a non-match. ## Rule Format @@ -15,6 +38,13 @@ globs: - '**/*.ts' keywords: - 'vitest' +model: + - claude-sonnet-4 +agent: + - programmer +branch: + - feature/* +match: any --- # Rule Title @@ -22,32 +52,37 @@ keywords: - Write rules as concrete, actionable instructions. ``` -## Field Reference - -| Field | Type | Purpose | -| ---------- | ---------- | ----------------------------------------------------- | -| `globs` | `string[]` | Apply when any file in context matches a pattern | -| `keywords` | `string[]` | Apply when the user's latest prompt matches a keyword | - -- Both fields are optional; no frontmatter means the rule always applies. -- If both `globs` and `keywords` are present, matching is OR (either triggers). - ## Matching Strategy - Use `globs` when the rule is about code in specific files/directories. - Use `keywords` when the rule is about a topic that may not include files. -- Use both when either condition should trigger. -- Use neither for global standards (tone, structure, safety, commit conventions). +- Use `tools` when the rule depends on specific MCP tools being available. +- Use runtime filters (`model`, `agent`, `command`, `project`, `branch`, `os`, `ci`) to scope rules to specific environments or workflows. +- Use `match: all` when you need every declared condition to be true (AND logic). +- Use `match: any` (or omit `match`) when any single condition should trigger (OR logic). +- Use no filters for global standards (tone, structure, safety, commit conventions). Important constraints: - Keyword matching is case-insensitive word-boundary _prefix_ matching (e.g., `test` matches `tests` and `testing`). -- You cannot express `globs AND keywords`; if you need that behavior, split into multiple rules. +- Branch patterns support globs via minimatch (e.g., `feature/*`, `release/**`). +- Missing runtime context (e.g., no git repo for `branch`) counts as a non-match for that dimension. -## Storage Location +## Keyword Selection -- `~/.config/opencode/rules/`: personal preferences you want across projects. -- `.opencode/rules/`: project/team conventions and repo-specific behavior. +Keywords use case-insensitive word-boundary prefix matching — short or generic words over-match. + +Denylist: generic nouns (`code`, `file`, `project`, `repo`, `bug`, `issue`, `change`), common verbs (`add`, `update`, `remove`, `fix`, `make`, `create`, `implement`), over-broad topics (`testing`, `performance`, `security`, `deployment`, `database`, `api`), single-token abbreviations (`ci`, `cd`, `db`, `ui`, `ux`). + +Allowlist: tool/framework names (`vitest`, `jest`, `pytest`, `playwright`, `cypress`, `eslint`, `prettier`, `typescript`, `terraform`, `kubernetes`), compound phrases (`unit test`, `integration test`, `snapshot test`, `lint rule`, `error boundary`, `api endpoint`, `rest api`), high-intent verbs (`refactor`, `rollback`, `migrate`, `deprecate`). + +Audit checklist: + +- Would this keyword appear in prompts where the rule should NOT apply? +- Is it likely to appear as part of another word due to prefix matching? +- Can you scope via globs instead? +- Prefer globs (file-scoped) over denylisted keywords. +- Replace generic keywords with compound phrases or tool names that capture intent. ## Extracting Rules from Patterns @@ -79,35 +114,10 @@ Conversation extraction examples: - User: "In unit tests, always use describe/it blocks" -> prefer glob-scoped rule (e.g., `**/*.{test,spec}.*`, `**/__tests__/**`); if prompt-scoped, use allowlisted keywords like `unit test`, `vitest`, `jest` (avoid `test`/`testing`). - User repeatedly fixes import ordering -> glob-scoped rule for the relevant languages/files. -## Keyword Selection Guidelines - -How matching works (important): - -- Keywords are matched with case-insensitive word-boundary prefix matching, so short/generic keywords tend to over-match. - -Denylist (avoid by default): - -- Generic nouns: `code`, `file`, `project`, `repo`, `bug`, `issue`, `change` -- Common verbs: `add`, `update`, `remove`, `fix`, `make`, `create`, `implement` -- Over-broad topic nouns: `testing`, `performance`, `security`, `deployment`, `database`, `api` -- Single-token abbreviations: `ci`, `cd`, `db`, `ui`, `ux` - -Allowlist (prefer by default): - -- Tool/framework names: `vitest`, `jest`, `pytest`, `playwright`, `cypress`, `eslint`, `prettier`, `typescript`, `terraform`, `kubernetes` -- Compound phrases: `unit test`, `integration test`, `snapshot test`, `lint rule`, `error boundary`, `api endpoint`, `rest api` -- High-intent engineering verbs: `refactor`, `rollback`, `migrate`, `deprecate` - -If you feel tempted to use a denylisted keyword: - -- Prefer globs (file-scoped) instead. -- Or replace it with a compound phrase / tool name that captures intent. - -Keyword audit checklist: +## Storage Location -- Would this keyword appear in prompts where the rule should NOT apply? -- Is it likely to appear as part of another word due to prefix matching? -- Can you scope via globs instead? +- `~/.config/opencode/rules/`: personal preferences you want across projects. +- `.opencode/rules/`: project/team conventions and repo-specific behavior. ## Writing Guidelines @@ -159,22 +169,31 @@ Unconditional: always-on standards - Extract magic numbers to named constants. ``` -Combined: deployment safety (OR logic) +Runtime filters with `match: all`: feature branch development ```md --- -globs: - - '**/deploy/**' - - '**/*.tf' -keywords: - - 'terraform' - - 'kubernetes' - - 'production' - - 'rollback' +agent: + - programmer +branch: + - feature/* +os: + - linux + - darwin +ci: false +match: all --- -# Deployment +# Feature Branch Dev -- Never hardcode secrets; use environment variables or a secrets manager. -- Include rollback steps in any production change plan. +- Create atomic commits with clear messages. +- Run tests before pushing. ``` + +## Common Mistakes + +- **Using denylisted keywords**: `test` fires on nearly every prompt — use `unit test` or globs instead. +- **Forgetting `match: all`**: Two filters with default OR means EITHER triggers — add `match: all` for AND logic. +- **Overloading a single rule**: 6+ dimensions are hard to reason about — split into focused rules. +- **Duplicating lint/formatter config**: Check Prettier/ESLint before adding a style rule. +- **Using `ci` as a keyword**: Prefix-matches `circuit`, `citizen` — use `ci: true` boolean filter instead. diff --git a/src/active-rules-state.test.ts b/src/active-rules-state.test.ts new file mode 100644 index 0000000..86de4fe --- /dev/null +++ b/src/active-rules-state.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + resolveStateDir, + getStateFilePath, + writeActiveRulesState, + readActiveRulesState, + _setStateDirForTesting, +} from './active-rules-state.js'; + +describe('active-rules-state', () => { + let testStateDir: string; + + beforeEach(async () => { + // Create a temp directory for tests + const testDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'active-rules-test-') + ); + testStateDir = path.join(testDir, 'state'); + + // Use test override instead of mocking os.homedir + _setStateDirForTesting(testStateDir); + }); + + afterEach(async () => { + // Reset the override + _setStateDirForTesting(null); + + // Clean up test directory + if (testStateDir) { + try { + // Go up one level to remove the whole temp dir + const parentDir = path.dirname(testStateDir); + await fs.rm(parentDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + } + }); + + describe('resolveStateDir', () => { + it('returns overridden path when set', () => { + const stateDir = resolveStateDir(); + expect(stateDir).toBe(testStateDir); + }); + + it('returns default path when not overridden', () => { + _setStateDirForTesting(null); + const stateDir = resolveStateDir(); + expect(stateDir).toBe( + path.join(os.homedir(), '.opencode', 'state', 'opencode-rules') + ); + }); + }); + + describe('getStateFilePath', () => { + it('returns session-specific JSON path', () => { + const filePath = getStateFilePath('ses_123'); + expect(filePath).toBe(path.join(testStateDir, 'ses_123.json')); + }); + + it('throws for sessionId with path traversal', () => { + expect(() => getStateFilePath('../escape')).toThrow('Invalid sessionId'); + expect(() => getStateFilePath('foo/bar')).toThrow('Invalid sessionId'); + expect(() => getStateFilePath('/absolute')).toThrow('Invalid sessionId'); + }); + + it('throws for sessionId with special characters', () => { + expect(() => getStateFilePath('ses.123')).toThrow('Invalid sessionId'); + expect(() => getStateFilePath('ses 123')).toThrow('Invalid sessionId'); + expect(() => getStateFilePath('')).toThrow('Invalid sessionId'); + }); + }); + + describe('writeActiveRulesState and readActiveRulesState', () => { + it('write/read round-trip preserves data', async () => { + const sessionId = 'ses_roundtrip'; + const matchedPaths = ['/path/to/rule1.md', '/path/to/rule2.md']; + + writeActiveRulesState(sessionId, matchedPaths); + + // Wait for the fire-and-forget write to complete + await waitForFile(getStateFilePath(sessionId)); + + const state = await readActiveRulesState(sessionId); + + expect(state).not.toBeNull(); + expect(state!.sessionId).toBe(sessionId); + expect(state!.matchedRulePaths).toEqual(matchedPaths); + expect(typeof state!.evaluatedAt).toBe('number'); + expect(state!.evaluatedAt).toBeLessThanOrEqual(Date.now()); + }); + + it('returns null for missing file', async () => { + const state = await readActiveRulesState('ses_nonexistent'); + expect(state).toBeNull(); + }); + + it('returns null for corrupt/invalid JSON', async () => { + await fs.mkdir(testStateDir, { recursive: true }); + + const filePath = getStateFilePath('ses_corrupt'); + await fs.writeFile(filePath, 'not valid json {{{', 'utf-8'); + + const state = await readActiveRulesState('ses_corrupt'); + expect(state).toBeNull(); + }); + + it('returns null for invalid state format', async () => { + await fs.mkdir(testStateDir, { recursive: true }); + + const filePath = getStateFilePath('ses_invalid'); + await fs.writeFile(filePath, JSON.stringify({ foo: 'bar' }), 'utf-8'); + + const state = await readActiveRulesState('ses_invalid'); + expect(state).toBeNull(); + }); + + it('returns null for wrong-type values in state', async () => { + await fs.mkdir(testStateDir, { recursive: true }); + + const filePath = getStateFilePath('ses_wrongtypes'); + await fs.writeFile( + filePath, + JSON.stringify({ + sessionId: 123, + matchedRulePaths: 'not-an-array', + evaluatedAt: 'not-a-number', + }), + 'utf-8' + ); + + const state = await readActiveRulesState('ses_wrongtypes'); + expect(state).toBeNull(); + }); + + it('returns null for array with non-string items', async () => { + await fs.mkdir(testStateDir, { recursive: true }); + + const filePath = getStateFilePath('ses_badarray'); + await fs.writeFile( + filePath, + JSON.stringify({ + sessionId: 'ses_badarray', + matchedRulePaths: ['/valid.md', 123, null], + evaluatedAt: Date.now(), + }), + 'utf-8' + ); + + const state = await readActiveRulesState('ses_badarray'); + expect(state).toBeNull(); + }); + + it('silently ignores write with invalid sessionId', async () => { + writeActiveRulesState('../escape', ['/rule.md']); + writeActiveRulesState('foo/bar', ['/rule.md']); + + // Give time for any writes to occur + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify no files were created + try { + await fs.access(testStateDir); + const files = await fs.readdir(testStateDir); + expect(files).toHaveLength(0); + } catch { + // Directory doesn't exist, which is expected + } + }); + + it('returns null for read with invalid sessionId', async () => { + const state = await readActiveRulesState('../escape'); + expect(state).toBeNull(); + }); + + it('no temp file remains after write', async () => { + const sessionId = 'ses_no_temp'; + const matchedPaths = ['/rule.md']; + + writeActiveRulesState(sessionId, matchedPaths); + + // Wait for write to complete + await waitForFile(getStateFilePath(sessionId)); + + // Check that no temp files remain + const files = await fs.readdir(testStateDir); + const tempFiles = files.filter(f => f.endsWith('.tmp')); + + expect(tempFiles).toHaveLength(0); + }); + + it('serializes concurrent writes for same session', async () => { + const sessionId = 'ses_concurrent'; + + // Fire multiple writes concurrently + writeActiveRulesState(sessionId, ['path1']); + writeActiveRulesState(sessionId, ['path2']); + writeActiveRulesState(sessionId, ['path3']); + + // Wait for all writes to complete + await waitForFile(getStateFilePath(sessionId)); + + // Give a bit more time for all queued writes to finish + await new Promise(resolve => setTimeout(resolve, 100)); + + // The final state should reflect the last write + const state = await readActiveRulesState(sessionId); + expect(state).not.toBeNull(); + expect(state!.matchedRulePaths).toEqual(['path3']); + }); + + it('creates state directory when it does not exist', async () => { + const sessionId = 'ses_newdir'; + const matchedPaths = ['/rule.md']; + + // Verify directory doesn't exist yet + await expect(fs.access(testStateDir)).rejects.toThrow(); + + writeActiveRulesState(sessionId, matchedPaths); + + // Wait for write to complete + await waitForFile(getStateFilePath(sessionId)); + + // Verify directory now exists + await expect(fs.access(testStateDir)).resolves.toBeUndefined(); + }); + + it('handles writes to different sessions independently', async () => { + writeActiveRulesState('ses_a', ['ruleA']); + writeActiveRulesState('ses_b', ['ruleB']); + + // Wait for both writes + await Promise.all([ + waitForFile(getStateFilePath('ses_a')), + waitForFile(getStateFilePath('ses_b')), + ]); + + const stateA = await readActiveRulesState('ses_a'); + const stateB = await readActiveRulesState('ses_b'); + + expect(stateA!.matchedRulePaths).toEqual(['ruleA']); + expect(stateB!.matchedRulePaths).toEqual(['ruleB']); + }); + }); +}); + +// Helper to wait for a file to exist +async function waitForFile(filePath: string, timeoutMs = 1000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await fs.access(filePath); + return; + } catch { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for file: ${filePath}`); +} diff --git a/src/active-rules-state.ts b/src/active-rules-state.ts new file mode 100644 index 0000000..aaba3ec --- /dev/null +++ b/src/active-rules-state.ts @@ -0,0 +1,166 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { createDebugLog } from './debug'; + +const debugLog = createDebugLog(); + +export interface ActiveRulesState { + sessionId: string; + matchedRulePaths: string[]; + evaluatedAt: number; +} + +// Per-session write queue to serialize concurrent writes +const writeQueues = new Map>(); + +// Allows tests to override the state directory +let stateDirOverride: string | null = null; + +// Strict pattern for safe sessionId: alphanumeric, underscore, hyphen only +const SAFE_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +function isValidSessionId(sessionId: string): boolean { + return SAFE_SESSION_ID_PATTERN.test(sessionId); +} + +/** @internal Test-only: override the state directory */ +export function _setStateDirForTesting(dir: string | null): void { + stateDirOverride = dir; +} + +export function resolveStateDir(): string { + if (stateDirOverride !== null) { + return stateDirOverride; + } + return path.join(os.homedir(), '.opencode', 'state', 'opencode-rules'); +} + +export function getStateFilePath(sessionId: string): string { + if (!isValidSessionId(sessionId)) { + throw new Error(`Invalid sessionId: ${sessionId}`); + } + return path.join(resolveStateDir(), `${sessionId}.json`); +} + +export function writeActiveRulesState( + sessionId: string, + matchedPaths: string[] +): void { + if (!isValidSessionId(sessionId)) { + debugLog(`Invalid sessionId rejected: ${sessionId}`); + return; + } + + const state: ActiveRulesState = { + sessionId, + matchedRulePaths: matchedPaths, + evaluatedAt: Date.now(), + }; + + // Chain onto existing queue for this session, or start fresh + const previousWrite = writeQueues.get(sessionId) ?? Promise.resolve(); + + const currentWrite = previousWrite.then(async () => { + await doAtomicWrite(sessionId, state); + }); + + writeQueues.set(sessionId, currentWrite); + + // Fire-and-forget: catch errors to prevent unhandled rejection + currentWrite.catch(() => { + // Errors already logged in doAtomicWrite + }); +} + +async function doAtomicWrite( + sessionId: string, + state: ActiveRulesState +): Promise { + const stateDir = resolveStateDir(); + const finalPath = getStateFilePath(sessionId); + const tempPath = path.join( + stateDir, + `.${sessionId}-${crypto.randomBytes(8).toString('hex')}.tmp` + ); + + try { + // Ensure directory exists + await fs.mkdir(stateDir, { recursive: true }); + + // Write to temp file + const content = JSON.stringify(state); + await fs.writeFile(tempPath, content, 'utf-8'); + + // Atomic rename + await fs.rename(tempPath, finalPath); + } catch (error) { + debugLog( + `Failed to write active rules state for session ${sessionId}: ${error}` + ); + + // Clean up temp file if it exists + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + } +} + +export async function readActiveRulesState( + sessionId: string +): Promise { + if (!isValidSessionId(sessionId)) { + debugLog(`Invalid sessionId rejected: ${sessionId}`); + return null; + } + + const filePath = getStateFilePath(sessionId); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + const parsed: unknown = JSON.parse(content); + + if (!isValidActiveRulesState(parsed)) { + debugLog(`Invalid active rules state format for session ${sessionId}`); + return null; + } + + return parsed; + } catch (error) { + debugLog( + `Failed to read active rules state for session ${sessionId}: ${error}` + ); + return null; + } +} + +function isValidActiveRulesState(value: unknown): value is ActiveRulesState { + if (typeof value !== 'object' || value === null) { + return false; + } + + const obj = value as Record; + + if (typeof obj['sessionId'] !== 'string') { + return false; + } + + if (typeof obj['evaluatedAt'] !== 'number') { + return false; + } + + if (!Array.isArray(obj['matchedRulePaths'])) { + return false; + } + + for (const item of obj['matchedRulePaths']) { + if (typeof item !== 'string') { + return false; + } + } + + return true; +} diff --git a/src/debug.test.ts b/src/debug.test.ts index b18b418..cde6e22 100644 --- a/src/debug.test.ts +++ b/src/debug.test.ts @@ -1,9 +1,16 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; import { createDebugLog } from './debug.js'; describe('createDebugLog', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let debugSpy: any; + let debugSpy: MockInstance; beforeEach(() => { debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); diff --git a/src/index.integration.test.ts b/src/index.integration.test.ts index 009f275..27acef4 100644 --- a/src/index.integration.test.ts +++ b/src/index.integration.test.ts @@ -19,10 +19,13 @@ import { __testOnly } from './index.js'; describe('readAndFormatRules', () => { let savedEnvXDG: string | undefined; + let savedEnvConfigDir: string | undefined; beforeEach(() => { setupTestDirs(); savedEnvXDG = process.env.XDG_CONFIG_HOME; + savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; clearRuleCache(); }); @@ -33,6 +36,11 @@ describe('readAndFormatRules', () => { } else { process.env.XDG_CONFIG_HOME = savedEnvXDG; } + if (savedEnvConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir; + } }); it('should read and format rule files into a formatted string', async () => { @@ -42,18 +50,20 @@ describe('readAndFormatRules', () => { writeFileSync(rule1Path, '# Rule 1\nContent of rule 1'); writeFileSync(rule2Path, '# Rule 2\nContent of rule 2'); - const formatted = await readAndFormatRules(toRules([rule1Path, rule2Path])); + const { formattedRules } = await readAndFormatRules( + toRules([rule1Path, rule2Path]) + ); - expect(formatted).toContain('OpenCode Rules'); - expect(formatted).toContain('rule1.md'); - expect(formatted).toContain('rule2.md'); - expect(formatted).toContain('Rule 1'); - expect(formatted).toContain('Rule 2'); + expect(formattedRules).toContain('OpenCode Rules'); + expect(formattedRules).toContain('rule1.md'); + expect(formattedRules).toContain('rule2.md'); + expect(formattedRules).toContain('Rule 1'); + expect(formattedRules).toContain('Rule 2'); }); it('should return empty string when no files provided', async () => { - const formatted = await readAndFormatRules([]); - expect(formatted).toBe(''); + const { formattedRules } = await readAndFormatRules([]); + expect(formattedRules).toBe(''); }); it('should handle file read errors gracefully', async () => { @@ -62,10 +72,10 @@ describe('readAndFormatRules', () => { const validFile = path.join(globalRulesDir, 'valid.md'); writeFileSync(validFile, '# Valid Rule'); - const formatted = await readAndFormatRules( + const { formattedRules } = await readAndFormatRules( toRules([nonExistentFile, validFile]) ); - expect(formatted).toContain('valid.md'); + expect(formattedRules).toContain('valid.md'); }); it('should include filename as subheader in output', async () => { @@ -73,8 +83,8 @@ describe('readAndFormatRules', () => { const rulePath = path.join(globalRulesDir, 'my-rules.md'); writeFileSync(rulePath, 'Rule content'); - const formatted = await readAndFormatRules(toRules([rulePath])); - expect(formatted).toMatch(/##\s+my-rules\.md/); + const { formattedRules } = await readAndFormatRules(toRules([rulePath])); + expect(formattedRules).toMatch(/##\s+my-rules\.md/); }); it('should include rule when file matches glob pattern in metadata', async () => { @@ -88,12 +98,14 @@ globs: This is a rule for TypeScript components.`; writeFileSync(rulePath, ruleContent); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { contextFilePaths: ['src/components/button.ts'], }); - expect(formatted).toContain('typescript.mdc'); - expect(formatted).toContain('This is a rule for TypeScript components.'); + expect(formattedRules).toContain('typescript.mdc'); + expect(formattedRules).toContain( + 'This is a rule for TypeScript components.' + ); }); it('should exclude rule when file does not match glob pattern in metadata', async () => { @@ -107,11 +119,11 @@ globs: This is a rule for TypeScript components.`; writeFileSync(rulePath, ruleContent); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { contextFilePaths: ['src/utils/helpers.js'], }); - expect(formatted).toBe(''); + expect(formattedRules).toBe(''); }); it('should include rule when user prompt matches keywords', async () => { @@ -128,12 +140,12 @@ keywords: Follow testing best practices.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { userPrompt: 'I need help testing this function', }); - expect(formatted).toContain('testing-rule.mdc'); - expect(formatted).toContain('Follow testing best practices'); + expect(formattedRules).toContain('testing-rule.mdc'); + expect(formattedRules).toContain('Follow testing best practices'); }); it('should include rule when tool is available', async () => { @@ -149,12 +161,12 @@ tools: Use web search best practices.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { availableToolIDs: ['mcp_bash', 'mcp_websearch', 'mcp_read'], }); - expect(formatted).toContain('websearch-rule.mdc'); - expect(formatted).toContain('Use web search best practices'); + expect(formattedRules).toContain('websearch-rule.mdc'); + expect(formattedRules).toContain('Use web search best practices'); }); describe('new filter dimensions', () => { @@ -172,12 +184,12 @@ model: Model-specific rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { modelID: 'claude-opus', }); - expect(formatted).toContain('model-rule.mdc'); - expect(formatted).toContain('Model-specific rule'); + expect(formattedRules).toContain('model-rule.mdc'); + expect(formattedRules).toContain('Model-specific rule'); }); it('should include rule when agent matches', async () => { @@ -194,12 +206,12 @@ agent: Agent-specific rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { agentType: 'programmer', }); - expect(formatted).toContain('agent-rule.mdc'); - expect(formatted).toContain('Agent-specific rule'); + expect(formattedRules).toContain('agent-rule.mdc'); + expect(formattedRules).toContain('Agent-specific rule'); }); it('should include rule when os matches', async () => { @@ -216,12 +228,12 @@ os: Unix-specific rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { os: 'linux', }); - expect(formatted).toContain('os-rule.mdc'); - expect(formatted).toContain('Unix-specific rule'); + expect(formattedRules).toContain('os-rule.mdc'); + expect(formattedRules).toContain('Unix-specific rule'); }); it('should include rule when ci is true and rule requires ci', async () => { @@ -236,12 +248,12 @@ ci: true CI-specific rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { ci: true, }); - expect(formatted).toContain('ci-rule.mdc'); - expect(formatted).toContain('CI-specific rule'); + expect(formattedRules).toContain('ci-rule.mdc'); + expect(formattedRules).toContain('CI-specific rule'); }); it('should include rule when branch matches glob pattern', async () => { @@ -258,12 +270,12 @@ branch: Feature branch rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { gitBranch: 'feature/add-login', }); - expect(formatted).toContain('branch-glob-rule.mdc'); - expect(formatted).toContain('Feature branch rule'); + expect(formattedRules).toContain('branch-glob-rule.mdc'); + expect(formattedRules).toContain('Feature branch rule'); }); }); @@ -285,14 +297,14 @@ os: Default any match rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { modelID: 'claude-opus', agentType: 'reviewer', os: 'linux', }); - expect(formatted).toContain('any-default.mdc'); - expect(formatted).toContain('Default any match rule'); + expect(formattedRules).toContain('any-default.mdc'); + expect(formattedRules).toContain('Default any match rule'); }); it('should require all declared dimensions when match is all', async () => { @@ -313,14 +325,14 @@ match: all All dimensions must match.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { modelID: 'claude-opus', agentType: 'programmer', os: 'linux', }); - expect(formatted).toContain('all-match.mdc'); - expect(formatted).toContain('All dimensions must match'); + expect(formattedRules).toContain('all-match.mdc'); + expect(formattedRules).toContain('All dimensions must match'); }); it('should exclude rule when match: all and one dimension fails', async () => { @@ -341,13 +353,13 @@ match: all All dimensions must match.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { modelID: 'claude-opus', agentType: 'programmer', os: 'darwin', }); - expect(formatted).toBe(''); + expect(formattedRules).toBe(''); }); }); @@ -362,9 +374,9 @@ All dimensions must match.` const result1 = await readAndFormatRules(rules); const result2 = await readAndFormatRules(rules); - expect(result1).toContain('Cached Rule'); - expect(result2).toContain('Cached Rule'); - expect(result1).toBe(result2); + expect(result1.formattedRules).toContain('Cached Rule'); + expect(result2.formattedRules).toContain('Cached Rule'); + expect(result1.formattedRules).toBe(result2.formattedRules); }); it('should invalidate cache when file is modified', async () => { @@ -375,7 +387,7 @@ All dimensions must match.` const rules = toRules([rulePath]); const result1 = await readAndFormatRules(rules); - expect(result1).toContain('Original Content'); + expect(result1.formattedRules).toContain('Original Content'); // Write new content and explicitly set mtime to future to ensure cache invalidation // This avoids flaky timing issues on CI/different filesystems @@ -385,8 +397,8 @@ All dimensions must match.` const result2 = await readAndFormatRules(rules); - expect(result2).toContain('Modified Content'); - expect(result2).not.toContain('Original Content'); + expect(result2.formattedRules).toContain('Modified Content'); + expect(result2.formattedRules).not.toContain('Original Content'); }); it('should handle clearRuleCache correctly', async () => { @@ -400,17 +412,20 @@ All dimensions must match.` clearRuleCache(); const result = await readAndFormatRules(rules); - expect(result).toContain('Test Content'); + expect(result.formattedRules).toContain('Test Content'); }); }); }); describe('Cross-Dimension Regression Coverage', () => { let savedEnvXDG: string | undefined; + let savedEnvConfigDir: string | undefined; beforeEach(() => { setupTestDirs(); savedEnvXDG = process.env.XDG_CONFIG_HOME; + savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; clearRuleCache(); }); @@ -421,6 +436,11 @@ describe('Cross-Dimension Regression Coverage', () => { } else { process.env.XDG_CONFIG_HOME = savedEnvXDG; } + if (savedEnvConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir; + } }); describe('omitted match behaves as any', () => { @@ -473,8 +493,10 @@ Rule with explicit match any.` context ); - expect(omittedResult).toContain('Rule with omitted match'); - expect(explicitResult).toContain('Rule with explicit match any'); + expect(omittedResult.formattedRules).toContain('Rule with omitted match'); + expect(explicitResult.formattedRules).toContain( + 'Rule with explicit match any' + ); }); it('should exclude rule with omitted match when no dimension matches', async () => { @@ -493,12 +515,12 @@ agent: Rule that should not match.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { modelID: 'claude-opus', agentType: 'reviewer', }); - expect(formatted).toBe(''); + expect(formattedRules).toBe(''); }); }); @@ -525,7 +547,7 @@ agent: Mixed legacy and new filters rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { contextFilePaths: ['src/index.ts'], userPrompt: 'help with debugging', availableToolIDs: ['mcp_bash'], @@ -533,7 +555,7 @@ Mixed legacy and new filters rule.` agentType: 'reviewer', }); - expect(formatted).toContain('Mixed legacy and new filters rule'); + expect(formattedRules).toContain('Mixed legacy and new filters rule'); }); it('should include rule when only new model filter matches (all legacy mismatch)', async () => { @@ -558,7 +580,7 @@ agent: New model filter matches rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { contextFilePaths: ['src/index.ts'], userPrompt: 'help with typescript', availableToolIDs: ['mcp_bash'], @@ -566,7 +588,7 @@ New model filter matches rule.` agentType: 'programmer', }); - expect(formatted).toContain('New model filter matches rule'); + expect(formattedRules).toContain('New model filter matches rule'); }); }); @@ -596,7 +618,7 @@ match: all All dimensions match rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { contextFilePaths: ['src/utils.ts'], userPrompt: 'help me refactor this code', availableToolIDs: ['mcp_bash', 'mcp_read'], @@ -605,7 +627,7 @@ All dimensions match rule.` os: 'linux', }); - expect(formatted).toContain('All dimensions match rule'); + expect(formattedRules).toContain('All dimensions match rule'); }); it('should exclude rule when one legacy dimension fails (keywords mismatch)', async () => { @@ -629,14 +651,14 @@ match: all Keywords fail rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { contextFilePaths: ['src/utils.ts'], userPrompt: 'help me refactor this code', availableToolIDs: ['mcp_bash'], modelID: 'claude-opus', }); - expect(formatted).toBe(''); + expect(formattedRules).toBe(''); }); }); @@ -660,13 +682,15 @@ model: Conditional rule for gpt-5 only.` ); - const formatted = await readAndFormatRules( + const { formattedRules } = await readAndFormatRules( toRules([unconditionalPath, conditionalPath]), { modelID: 'claude-opus' } ); - expect(formatted).toContain('This rule always applies unconditionally'); - expect(formatted).not.toContain('Conditional rule for gpt-5 only'); + expect(formattedRules).toContain( + 'This rule always applies unconditionally' + ); + expect(formattedRules).not.toContain('Conditional rule for gpt-5 only'); }); it('should include unconditional rules even when filter context is empty', async () => { @@ -690,13 +714,13 @@ keywords: Only for special files.` ); - const formatted = await readAndFormatRules( + const { formattedRules } = await readAndFormatRules( toRules([unconditionalPath, conditionalPath]), {} ); - expect(formatted).toContain('No metadata means always apply'); - expect(formatted).not.toContain('Only for special files'); + expect(formattedRules).toContain('No metadata means always apply'); + expect(formattedRules).not.toContain('Only for special files'); }); it('should include unconditional rules when called with no context at all', async () => { @@ -708,19 +732,122 @@ Only for special files.` '# Bare Rule\nShould always be included.' ); - const formatted = await readAndFormatRules(toRules([unconditionalPath])); + const { formattedRules } = await readAndFormatRules( + toRules([unconditionalPath]) + ); - expect(formatted).toContain('Should always be included'); + expect(formattedRules).toContain('Should always be included'); + }); + }); + + describe('matchedPaths tracking', () => { + it('should return matchedPaths with file paths of included rules', async () => { + const { globalRulesDir } = getTestDirs(); + const rule1Path = path.join(globalRulesDir, 'rule1.md'); + const rule2Path = path.join(globalRulesDir, 'rule2.md'); + writeFileSync(rule1Path, '# Rule 1\nContent'); + writeFileSync(rule2Path, '# Rule 2\nContent'); + + const { formattedRules, matchedPaths } = await readAndFormatRules( + toRules([rule1Path, rule2Path]) + ); + + expect(formattedRules).toContain('Rule 1'); + expect(matchedPaths).toHaveLength(2); + expect(matchedPaths).toContain(rule1Path); + expect(matchedPaths).toContain(rule2Path); + }); + + it('should return empty matchedPaths when no rules match', async () => { + const { globalRulesDir } = getTestDirs(); + const rulePath = path.join(globalRulesDir, 'conditional.mdc'); + writeFileSync( + rulePath, + `--- +model: + - gpt-5 +--- + +Conditional rule.` + ); + + const { formattedRules, matchedPaths } = await readAndFormatRules( + toRules([rulePath]), + { modelID: 'claude-opus' } + ); + + expect(formattedRules).toBe(''); + expect(matchedPaths).toHaveLength(0); + }); + + it('should return empty matchedPaths when files array is empty', async () => { + const { formattedRules, matchedPaths } = await readAndFormatRules([]); + + expect(formattedRules).toBe(''); + expect(matchedPaths).toHaveLength(0); + }); + + it('should only include matching rules in matchedPaths (not filtered-out rules)', async () => { + const { globalRulesDir } = getTestDirs(); + const includedPath = path.join(globalRulesDir, 'included.mdc'); + const excludedPath = path.join(globalRulesDir, 'excluded.mdc'); + + writeFileSync( + includedPath, + `--- +model: + - claude-opus +--- + +Included rule.` + ); + writeFileSync( + excludedPath, + `--- +model: + - gpt-5 +--- + +Excluded rule.` + ); + + const { formattedRules, matchedPaths } = await readAndFormatRules( + toRules([includedPath, excludedPath]), + { modelID: 'claude-opus' } + ); + + expect(formattedRules).toContain('Included rule'); + expect(formattedRules).not.toContain('Excluded rule'); + expect(matchedPaths).toHaveLength(1); + expect(matchedPaths).toContain(includedPath); + expect(matchedPaths).not.toContain(excludedPath); + }); + + it('should include unconditional rules in matchedPaths', async () => { + const { globalRulesDir } = getTestDirs(); + const unconditionalPath = path.join(globalRulesDir, 'always.md'); + + writeFileSync(unconditionalPath, '# Always\nUnconditional rule.'); + + const { matchedPaths } = await readAndFormatRules( + toRules([unconditionalPath]) + ); + + expect(matchedPaths).toHaveLength(1); + expect(matchedPaths).toContain(unconditionalPath); }); }); }); describe('Conditional rules integration', () => { let savedEnvXDG: string | undefined; + let savedEnvConfigDir: string | undefined; beforeEach(() => { setupTestDirs(); savedEnvXDG = process.env.XDG_CONFIG_HOME; + savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; clearRuleCache(); }); @@ -733,6 +860,11 @@ describe('Conditional rules integration', () => { } else { process.env.XDG_CONFIG_HOME = savedEnvXDG; } + if (savedEnvConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir; + } }); it('should include conditional rule when message context matches glob', async () => { @@ -748,7 +880,9 @@ Use React best practices for components.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -807,7 +941,9 @@ Use React best practices for components.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -870,7 +1006,9 @@ Special rule content.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -918,10 +1056,13 @@ Special rule content.` describe('Session compacting behavior', () => { let savedEnvXDG: string | undefined; + let savedEnvConfigDir: string | undefined; beforeEach(() => { setupTestDirs(); savedEnvXDG = process.env.XDG_CONFIG_HOME; + savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; clearRuleCache(); }); @@ -934,13 +1075,21 @@ describe('Session compacting behavior', () => { } else { process.env.XDG_CONFIG_HOME = savedEnvXDG; } + if (savedEnvConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir; + } }); it('adds minimal working-set context during compaction', async () => { const { testDir } = getTestDirs(); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -970,7 +1119,10 @@ describe('Session compacting behavior', () => { const { testDir } = getTestDirs(); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -1007,7 +1159,10 @@ describe('Session compacting behavior', () => { const { testDir } = getTestDirs(); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -1039,7 +1194,10 @@ describe('Session compacting behavior', () => { const { testDir } = getTestDirs(); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -1080,7 +1238,9 @@ MCP Context7 rule content`; writeFileSync(path.join(globalRulesDir, 'context7.md'), ruleContent); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir, mcpStatus: { context7: { status: 'connected' } }, diff --git a/src/index.rules.test.ts b/src/index.rules.test.ts index 2c85ace..324ef1f 100644 --- a/src/index.rules.test.ts +++ b/src/index.rules.test.ts @@ -988,7 +988,7 @@ describe('discoverRuleFiles', () => { beforeEach(() => { setupTestDirs(); - envSnapshot = saveEnv('XDG_CONFIG_HOME', 'HOME'); + envSnapshot = saveEnv('XDG_CONFIG_HOME', 'HOME', 'OPENCODE_CONFIG_DIR'); }); afterEach(() => { @@ -1068,6 +1068,49 @@ describe('discoverRuleFiles', () => { const files = await discoverRuleFiles(); expect(files.every(f => !f.filePath.includes('.hidden.md'))).toBe(true); }); + + it('should use OPENCODE_CONFIG_DIR/rules as global dir when set', async () => { + const { testDir } = getTestDirs(); + const customDir = path.join(testDir, 'custom-config'); + const customRulesDir = path.join(customDir, 'rules'); + mkdirSync(customRulesDir, { recursive: true }); + writeFileSync(path.join(customRulesDir, 'custom.md'), '# Custom Rule'); + + process.env.OPENCODE_CONFIG_DIR = customDir; + + const files = await discoverRuleFiles(); + expect( + files.some(f => f.filePath === path.join(customRulesDir, 'custom.md')) + ).toBe(true); + }); + + it('should prefer OPENCODE_CONFIG_DIR over XDG_CONFIG_HOME', async () => { + const { testDir, globalRulesDir } = getTestDirs(); + writeFileSync(path.join(globalRulesDir, 'xdg-rule.md'), '# XDG Rule'); + + const customDir = path.join(testDir, 'custom-config'); + const customRulesDir = path.join(customDir, 'rules'); + mkdirSync(customRulesDir, { recursive: true }); + writeFileSync(path.join(customRulesDir, 'custom.md'), '# Custom Rule'); + + process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); + process.env.OPENCODE_CONFIG_DIR = customDir; + + const files = await discoverRuleFiles(); + expect(files.some(f => f.filePath.includes('custom.md'))).toBe(true); + expect(files.some(f => f.filePath.includes('xdg-rule.md'))).toBe(false); + }); + + it('should handle missing OPENCODE_CONFIG_DIR/rules gracefully', async () => { + const { testDir } = getTestDirs(); + const customDir = path.join(testDir, 'no-rules-here'); + mkdirSync(customDir, { recursive: true }); + + process.env.OPENCODE_CONFIG_DIR = customDir; + + const files = await discoverRuleFiles(); + expect(files).toEqual([]); + }); }); describe('project rules discovery', () => { diff --git a/src/index.runtime.test.ts b/src/index.runtime.test.ts index 8086dc4..02ba707 100644 --- a/src/index.runtime.test.ts +++ b/src/index.runtime.test.ts @@ -4,7 +4,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'path'; -import { writeFileSync, mkdirSync } from 'fs'; +import { writeFileSync, mkdirSync, readdirSync } from 'fs'; import { setupTestDirs, teardownTestDirs, @@ -26,6 +26,10 @@ import * as sessionStoreModule from './session-store.js'; import * as runtimeContextModule from './runtime-context.js'; import * as runtimeChatModule from './runtime-chat.js'; import { __testOnly } from './index.js'; +import { + _setStateDirForTesting, + readActiveRulesState, +} from './active-rules-state.js'; describe('module boundary tests', () => { it('should re-export discoverRuleFiles from rule-discovery module', () => { @@ -162,10 +166,13 @@ describe('module boundary tests', () => { describe('OpenCodeRulesPlugin', () => { let savedEnvXDG: string | undefined; + let savedEnvConfigDir: string | undefined; beforeEach(() => { setupTestDirs(); savedEnvXDG = process.env.XDG_CONFIG_HOME; + savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; }); afterEach(() => { @@ -177,11 +184,17 @@ describe('OpenCodeRulesPlugin', () => { } else { process.env.XDG_CONFIG_HOME = savedEnvXDG; } + if (savedEnvConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir; + } }); - it('should export a default plugin function', async () => { - const { default: plugin } = await import('./index.js'); - expect(typeof plugin).toBe('function'); + it('should export a plugin module with id and server', async () => { + const { default: pluginModule } = await import('./index.js'); + expect(pluginModule).toHaveProperty('id', 'opencode-rules'); + expect(typeof pluginModule.server).toBe('function'); }); it('should return transform hooks even when no rules exist', async () => { @@ -191,7 +204,9 @@ describe('OpenCodeRulesPlugin', () => { recursive: true, }); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir: path.join(testDir, 'empty-project'), }); @@ -212,7 +227,9 @@ describe('OpenCodeRulesPlugin', () => { writeFileSync(path.join(globalRulesDir, 'rule.md'), '# Test Rule'); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -230,7 +247,9 @@ describe('OpenCodeRulesPlugin', () => { ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -246,8 +265,8 @@ describe('OpenCodeRulesPlugin', () => { ); expect(result.system).toContain('You are a helpful assistant.'); - expect(result.system).toContain('OpenCode Rules'); - expect(result.system).toContain('Test Rule'); + expect(result.system).toContain('# Test Rule'); + expect(result.system).toContain('Do this always'); }); it('should append rules to existing system prompt', async () => { @@ -255,7 +274,9 @@ describe('OpenCodeRulesPlugin', () => { writeFileSync(path.join(globalRulesDir, 'rule.md'), '# My Rule'); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -279,7 +300,9 @@ describe('OpenCodeRulesPlugin', () => { writeFileSync(path.join(globalRulesDir, 'rule.md'), '# Rule Content'); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -291,8 +314,7 @@ describe('OpenCodeRulesPlugin', () => { ) => Promise<{ system: string }>; const result = await systemTransform({}, { system: '' }); - expect(result.system).toContain('OpenCode Rules'); - expect(result.system).toContain('Rule Content'); + expect(result.system).toContain('# Rule Content'); }); it('should not modify messages in messages.transform hook', async () => { @@ -300,7 +322,9 @@ describe('OpenCodeRulesPlugin', () => { writeFileSync(path.join(globalRulesDir, 'rule.md'), '# Rule'); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const originalMessages = [ @@ -324,7 +348,9 @@ describe('OpenCodeRulesPlugin', () => { it('seeds session state once from messages.transform and does not rescan', async () => { const { testDir } = getTestDirs(); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -362,10 +388,13 @@ describe('OpenCodeRulesPlugin', () => { describe('SessionState', () => { let savedEnvXDG: string | undefined; + let savedEnvConfigDir: string | undefined; beforeEach(() => { setupTestDirs(); savedEnvXDG = process.env.XDG_CONFIG_HOME; + savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; }); afterEach(async () => { @@ -377,6 +406,11 @@ describe('SessionState', () => { } else { process.env.XDG_CONFIG_HOME = savedEnvXDG; } + if (savedEnvConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir; + } }); it('prunes session state when over limit', async () => { @@ -395,7 +429,9 @@ describe('SessionState', () => { it('updates lastUserPrompt from chat.message', async () => { const { testDir } = getTestDirs(); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -422,7 +458,10 @@ describe('SessionState', () => { it('extracts text from mixed parts using shared extraction logic', async () => { const { testDir } = getTestDirs(); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -453,7 +492,10 @@ describe('SessionState', () => { it('stores lastModelID from chat.message for user messages', async () => { const { testDir } = getTestDirs(); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -478,7 +520,10 @@ describe('SessionState', () => { it('stores lastAgentType from chat.message for user messages', async () => { const { testDir } = getTestDirs(); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -503,7 +548,10 @@ describe('SessionState', () => { it('stores both model and agent from chat.message', async () => { const { testDir } = getTestDirs(); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -533,7 +581,10 @@ describe('SessionState', () => { it('does not update model/agent for non-user messages', async () => { const { testDir } = getTestDirs(); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -575,7 +626,10 @@ describe('SessionState', () => { it('updates model/agent on subsequent user messages', async () => { const { testDir } = getTestDirs(); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( @@ -624,7 +678,9 @@ describe('SessionState', () => { `---\nglobs:\n - "src/components/**/*.tsx"\n---\n\nUse React best practices.` ); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -661,7 +717,10 @@ describe('SessionState', () => { ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin, __testOnly } = await import('./index.js'); + const { + default: { server: plugin }, + __testOnly, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -685,6 +744,152 @@ describe('SessionState', () => { }); }); +describe('Active rules state persistence', () => { + let savedEnvXDG: string | undefined; + let savedEnvConfigDir: string | undefined; + let stateDir: string; + + beforeEach(() => { + setupTestDirs(); + savedEnvXDG = process.env.XDG_CONFIG_HOME; + savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; + const { testDir } = getTestDirs(); + stateDir = path.join(testDir, 'state'); + mkdirSync(stateDir, { recursive: true }); + _setStateDirForTesting(stateDir); + }); + + afterEach(async () => { + teardownTestDirs(); + _setStateDirForTesting(null); + const { __testOnly } = await import('./index.js'); + __testOnly.resetSessionState(); + if (savedEnvXDG === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = savedEnvXDG; + } + if (savedEnvConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir; + } + }); + + it('writes matched rule paths to state file when rules match', async () => { + const { testDir, globalRulesDir } = getTestDirs(); + const rulePath = path.join(globalRulesDir, 'always-apply.md'); + writeFileSync(rulePath, '# Always Apply\nThis rule always applies.'); + process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); + + const { + default: { server: plugin }, + } = await import('./index.js'); + const mockInput = createMockPluginInput({ testDir }); + const hooks = await plugin( + mockInput as unknown as Parameters[0] + ); + + const sessionID = 'ses-state-match'; + const systemTransform = hooks['experimental.chat.system.transform'] as ( + input: { sessionID?: string }, + output: { system: string } + ) => Promise<{ system: string }>; + + const result = await systemTransform( + { sessionID }, + { system: 'Base prompt.' } + ); + + expect(result.system).toContain('Always Apply'); + + // Wait for fire-and-forget write to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + const state = await readActiveRulesState(sessionID); + expect(state).not.toBeNull(); + expect(state?.sessionId).toBe(sessionID); + expect(state?.matchedRulePaths).toHaveLength(1); + expect(state?.matchedRulePaths[0]).toBe(rulePath); + }); + + it('writes empty matchedPaths to state file when no rules match', async () => { + const { testDir, globalRulesDir } = getTestDirs(); + const rulePath = path.join(globalRulesDir, 'conditional.mdc'); + writeFileSync( + rulePath, + `--- +model: + - gpt-5 +--- + +Conditional rule for gpt-5 only.` + ); + process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); + + const { + default: { server: plugin }, + } = await import('./index.js'); + const mockInput = createMockPluginInput({ testDir }); + const hooks = await plugin( + mockInput as unknown as Parameters[0] + ); + + const sessionID = 'ses-state-nomatch'; + const systemTransform = hooks['experimental.chat.system.transform'] as ( + input: { sessionID?: string }, + output: { system: string } + ) => Promise<{ system: string }>; + + const result = await systemTransform( + { sessionID }, + { system: 'Base prompt.' } + ); + + // No rules should match (model is not gpt-5) + expect(result.system).not.toContain('Conditional rule'); + + // Wait for fire-and-forget write to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + const state = await readActiveRulesState(sessionID); + expect(state).not.toBeNull(); + expect(state?.sessionId).toBe(sessionID); + expect(state?.matchedRulePaths).toHaveLength(0); + }); + + it('does not write state when sessionID is missing', async () => { + const { testDir, globalRulesDir } = getTestDirs(); + writeFileSync(path.join(globalRulesDir, 'rule.md'), '# Test Rule\nContent'); + process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); + + const { + default: { server: plugin }, + } = await import('./index.js'); + const mockInput = createMockPluginInput({ testDir }); + const hooks = await plugin( + mockInput as unknown as Parameters[0] + ); + + const systemTransform = hooks['experimental.chat.system.transform'] as ( + input: { sessionID?: string }, + output: { system: string } + ) => Promise<{ system: string }>; + + // Call without sessionID + await systemTransform({}, { system: 'Base prompt.' }); + + // Wait briefly + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify no state files were created in the state directory + const files = readdirSync(stateDir); + const jsonFiles = files.filter(f => f.endsWith('.json')); + expect(jsonFiles).toHaveLength(0); + }); +}); + describe('utils runtime exports', () => { it('exports only expected functions at runtime', () => { const exportedKeys = Object.keys(utilsModule).sort(); @@ -710,11 +915,14 @@ describe('session-store runtime exports', () => { describe('CI environment detection', () => { let savedCiEnv: CiEnvSnapshot; let savedXDG: string | undefined; + let savedConfigDir: string | undefined; beforeEach(() => { setupTestDirs(); savedCiEnv = saveCiEnvVars(); savedXDG = process.env.XDG_CONFIG_HOME; + savedConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; }); afterEach(async () => { @@ -727,6 +935,11 @@ describe('CI environment detection', () => { } else { process.env.XDG_CONFIG_HOME = savedXDG; } + if (savedConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedConfigDir; + } }); it('should include ci-conditional rule when CI env var is set', async () => { @@ -740,7 +953,9 @@ describe('CI environment detection', () => { clearCiEnvVars(); process.env.CI = 'true'; - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -766,7 +981,9 @@ describe('CI environment detection', () => { clearCiEnvVars(); process.env.CI = 'false'; - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -792,7 +1009,9 @@ describe('CI environment detection', () => { clearCiEnvVars(); process.env.CI = '0'; - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -818,7 +1037,9 @@ describe('CI environment detection', () => { clearCiEnvVars(); process.env.GITHUB_ACTIONS = 'true'; - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -844,7 +1065,9 @@ describe('CI environment detection', () => { clearCiEnvVars(); process.env.BUILD_NUMBER = 'false'; - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] diff --git a/src/index.test.ts b/src/index.test.ts index 815efa1..ee1bc15 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -31,11 +31,14 @@ import { // Retained plugin-level tests with complex runtime filter context describe('Runtime filter context integration (plugin-level)', () => { let savedEnvXDG: string | undefined; + let savedEnvConfigDir: string | undefined; let savedCiEnv: CiEnvSnapshot; beforeEach(() => { setupTestDirs(); savedEnvXDG = process.env.XDG_CONFIG_HOME; + savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; savedCiEnv = saveCiEnvVars(); clearRuleCache(); }); @@ -50,6 +53,11 @@ describe('Runtime filter context integration (plugin-level)', () => { } else { process.env.XDG_CONFIG_HOME = savedEnvXDG; } + if (savedEnvConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir; + } }); it('should include model-conditional rule when session has matching modelID', async () => { @@ -65,7 +73,9 @@ Model-specific guidelines.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -113,7 +123,9 @@ Agent-specific guidelines.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -161,7 +173,9 @@ Planning guidelines.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -210,7 +224,9 @@ Platform-specific guidelines.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -245,7 +261,9 @@ CI-authoritative guidelines.` process.env.CI = 'false'; process.env.GITHUB_ACTIONS = 'true'; - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -283,7 +301,9 @@ All dimensions must match.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -340,7 +360,9 @@ All dimensions must match.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -396,7 +418,9 @@ Node.js project guidelines.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -435,7 +459,9 @@ Feature branch guidelines.` .mockResolvedValue('feature/add-login'); try { - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -470,7 +496,9 @@ Feature branch guidelines.` const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => { @@ -509,7 +537,9 @@ Feature branch guidelines.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -540,7 +570,9 @@ Feature branch guidelines.` const nonGitDir = path.join(testDir, 'not-a-git-repo'); mkdirSync(nonGitDir, { recursive: true }); - const { default: plugin } = await import('./index.js'); + const { + default: { server: plugin }, + } = await import('./index.js'); const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } }; const hooks = await plugin({ client: mockClient as unknown, @@ -564,10 +596,13 @@ Feature branch guidelines.` // Retained API contract tests describe('readAndFormatRules API contract', () => { let savedEnvXDG: string | undefined; + let savedEnvConfigDir: string | undefined; beforeEach(() => { setupTestDirs(); savedEnvXDG = process.env.XDG_CONFIG_HOME; + savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; clearRuleCache(); }); @@ -578,6 +613,11 @@ describe('readAndFormatRules API contract', () => { } else { process.env.XDG_CONFIG_HOME = savedEnvXDG; } + if (savedEnvConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir; + } }); it('should only accept RuleFilterContext object as second argument', async () => { @@ -593,13 +633,13 @@ tools: Tools rule.` ); - const formatted = await readAndFormatRules(toRules([rulePath]), { + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), { userPrompt: 'some prompt', availableToolIDs: ['mcp_websearch', 'mcp_bash'], }); - expect(formatted).toContain('tools-rule.mdc'); - expect(formatted).toContain('Tools rule'); + expect(formattedRules).toContain('tools-rule.mdc'); + expect(formattedRules).toContain('Tools rule'); }); it('should ignore array passed as second arg (legacy positional pattern rejected)', async () => { @@ -616,11 +656,11 @@ Legacy globs rule.` ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const formatted = await readAndFormatRules(toRules([rulePath]), [ + const { formattedRules } = await readAndFormatRules(toRules([rulePath]), [ 'src/app.ts', ] as any); - expect(formatted).toBe(''); + expect(formattedRules).toBe(''); }); it('should ignore third positional arg (legacy userPrompt rejected)', async () => { @@ -637,13 +677,13 @@ Legacy keywords rule.` ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const formatted = await (readAndFormatRules as any)( + const { formattedRules } = await (readAndFormatRules as any)( toRules([rulePath]), {}, 'help with testing' ); - expect(formatted).toBe(''); + expect(formattedRules).toBe(''); }); it('should ignore fourth positional arg (legacy availableToolIDs rejected)', async () => { @@ -660,13 +700,13 @@ Legacy tools rule.` ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const formatted = await (readAndFormatRules as any)( + const { formattedRules } = await (readAndFormatRules as any)( toRules([rulePath]), {}, undefined, ['mcp_websearch', 'mcp_bash'] ); - expect(formatted).toBe(''); + expect(formattedRules).toBe(''); }); }); diff --git a/src/index.ts b/src/index.ts index 112550d..7d82167 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ * Discovers markdown rule files and injects them into the system prompt. */ -import type { PluginInput } from '@opencode-ai/plugin'; +import type { Plugin, PluginInput } from '@opencode-ai/plugin'; import { discoverRuleFiles } from './utils.js'; import { OpenCodeRulesRuntime } from './runtime.js'; import { createSessionStore, type SessionState } from './session-store.js'; @@ -62,5 +62,7 @@ const __testOnly = Object.freeze( }) ); -export default openCodeRulesPlugin; +const id = 'opencode-rules' as const; +const server = openCodeRulesPlugin satisfies Plugin; +export default { id, server }; export { __testOnly }; diff --git a/src/rule-discovery.ts b/src/rule-discovery.ts index 3148ce5..d726cb3 100644 --- a/src/rule-discovery.ts +++ b/src/rule-discovery.ts @@ -5,12 +5,12 @@ import { stat, readFile, readdir } from 'fs/promises'; import path from 'path'; import os from 'os'; -import { createDebugLog } from './debug.js'; +import { createDebugLog } from './debug'; import { parseRuleMetadata, stripFrontmatter, type RuleMetadata, -} from './rule-metadata.js'; +} from './rule-metadata'; const debugLog = createDebugLog(); @@ -91,6 +91,11 @@ export async function getCachedRule( * Get the global rules directory path */ function getGlobalRulesDir(): string | null { + const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR; + if (opencodeConfigDir) { + return path.join(opencodeConfigDir, 'rules'); + } + const xdgConfigHome = process.env.XDG_CONFIG_HOME; if (xdgConfigHome) { return path.join(xdgConfigHome, 'opencode', 'rules'); @@ -160,6 +165,7 @@ export interface DiscoveredRule { /** * Discover markdown rule files from standard directories * Searches recursively in: + * - $OPENCODE_CONFIG_DIR/rules/ (highest priority) * - $XDG_CONFIG_HOME/opencode/rules/ (or ~/.config/opencode/rules as fallback) * - .opencode/rules/ (in project directory if provided) * Finds all .md and .mdc files including nested subdirectories. diff --git a/src/rule-filter.ts b/src/rule-filter.ts index c65e051..ec872fa 100644 --- a/src/rule-filter.ts +++ b/src/rule-filter.ts @@ -5,6 +5,7 @@ import { minimatch } from 'minimatch'; import { createDebugLog } from './debug.js'; import { getCachedRule, type DiscoveredRule } from './rule-discovery.js'; +import { type InjectMode } from './rule-metadata.js'; const debugLog = createDebugLog(); @@ -59,6 +60,15 @@ export function toolsMatchAvailable( return requiredTools.some(tool => availableSet.has(tool)); } +/** + * Result of reading and formatting rules + */ +export interface FilterResult { + formattedRules: string; + matchedPaths: string[]; + individualContents: string[]; +} + /** * Runtime filter context for conditional rule matching */ @@ -86,19 +96,35 @@ export interface RuleFilterContext { } /** - * Read and format rule files for system prompt injection + * Options for readAndFormatRules + */ +export interface ReadAndFormatOptions { + /** When true, omit all headers and separators — only concatenate raw rule content */ + raw?: boolean; + /** Maximum characters for the formatted output. Excess rules are dropped from the end. */ + maxChars?: number; +} + +/** + * Read and format rule files for injection. * @param files - Array of discovered rule files with paths * @param context - Optional RuleFilterContext for conditional rule matching + * @param injectMode - Filter by injection mode (system, user, both) + * @param opts - Formatting options (raw mode, character limit) */ export async function readAndFormatRules( files: DiscoveredRule[], - context: RuleFilterContext = {} -): Promise { + context: RuleFilterContext = {}, + injectMode?: InjectMode, + opts?: ReadAndFormatOptions +): Promise { if (files.length === 0) { - return ''; + return { formattedRules: '', matchedPaths: [], individualContents: [] }; } const ruleContents: string[] = []; + const matchedPaths: string[] = []; + const individualContents: string[] = []; const availableToolSet = context.availableToolIDs && context.availableToolIDs.length > 0 ? new Set(context.availableToolIDs) @@ -241,17 +267,58 @@ export async function readAndFormatRules( ); } + // Filter by injection mode + if (injectMode) { + const ruleMode = metadata?.inject ?? 'system'; + if (injectMode === 'system' && ruleMode === 'user') continue; + if (injectMode === 'user' && ruleMode === 'system') continue; + } + // Use cached stripped content for output - // Use relativePath for unique headings instead of just filename - ruleContents.push(`## ${relativePath}\n\n${strippedContent}`); + const entry = opts?.raw + ? strippedContent + : `## ${relativePath}\n\n${strippedContent}`; + ruleContents.push(entry); + matchedPaths.push(filePath); + individualContents.push(strippedContent); } if (ruleContents.length === 0) { - return ''; + return { formattedRules: '', matchedPaths: [], individualContents: [] }; + } + + let formattedRules: string; + if (opts?.raw) { + formattedRules = ruleContents.join('\n\n'); + } else { + formattedRules = + `# OpenCode Rules\n\nPlease follow the following rules:\n\n` + + ruleContents.join('\n\n---\n\n'); + } + + // Truncate to respect maxChars by dropping rules from the end + if (opts?.maxChars && formattedRules.length > opts.maxChars) { + while (ruleContents.length > 1) { + ruleContents.pop(); + matchedPaths.pop(); + individualContents.pop(); + formattedRules = opts?.raw + ? ruleContents.join('\n\n') + : `# OpenCode Rules\n\nPlease follow the following rules:\n\n` + + ruleContents.join('\n\n---\n\n'); + if (formattedRules.length <= opts.maxChars) break; + } + // If even a single rule exceeds the limit, include it anyway (better than nothing) + if (formattedRules.length > opts.maxChars && ruleContents.length === 1) { + debugLog( + `Single rule exceeds maxChars limit (${formattedRules.length} > ${opts.maxChars})` + ); + } } - return ( - `# OpenCode Rules\n\nPlease follow the following rules:\n\n` + - ruleContents.join('\n\n---\n\n') - ); + return { + formattedRules, + matchedPaths, + individualContents, + }; } diff --git a/src/rule-metadata.ts b/src/rule-metadata.ts index fd644da..1a931c7 100644 --- a/src/rule-metadata.ts +++ b/src/rule-metadata.ts @@ -2,11 +2,13 @@ * Rule metadata parsing and frontmatter extraction */ -import { parse as parseYaml } from 'yaml'; +const { parse: parseYaml } = await import('yaml'); /** * Metadata extracted from .mdc file frontmatter */ + +export type InjectMode = 'system' | 'user' | 'both'; export interface RuleMetadata { globs?: string[]; keywords?: string[]; @@ -19,6 +21,8 @@ export interface RuleMetadata { os?: string[]; ci?: boolean; match?: 'any' | 'all'; + inject?: InjectMode; + repeat_every?: number | Record; } /** @@ -36,6 +40,8 @@ interface ParsedFrontmatter { os?: unknown; ci?: unknown; match?: unknown; + inject?: unknown; + repeat_every?: unknown; } /** Field names in ParsedFrontmatter that are string arrays */ @@ -129,6 +135,36 @@ export function parseRuleMetadata(content: string): RuleMetadata | undefined { metadata.match = parsed.match; } + // Extract inject + if ( + parsed.inject === 'system' || + parsed.inject === 'user' || + parsed.inject === 'both' + ) { + metadata.inject = parsed.inject; + } + + // Extract repeat_every + if (typeof parsed.repeat_every === 'number') { + if (Number.isInteger(parsed.repeat_every) && parsed.repeat_every >= 1) { + metadata.repeat_every = parsed.repeat_every; + } + } else if ( + typeof parsed.repeat_every === 'object' && + parsed.repeat_every !== null && + !Array.isArray(parsed.repeat_every) + ) { + const dict: Record = {}; + for (const [key, value] of Object.entries(parsed.repeat_every)) { + if (typeof value === 'number' && Number.isInteger(value) && value >= 1) { + dict[key] = value; + } + } + if (Object.keys(dict).length > 0) { + metadata.repeat_every = dict; + } + } + // Return metadata only if it has content return Object.keys(metadata).length > 0 ? metadata : undefined; } catch (error) { diff --git a/src/runtime-chat.ts b/src/runtime-chat.ts index 7f12af7..5bd216e 100644 --- a/src/runtime-chat.ts +++ b/src/runtime-chat.ts @@ -51,6 +51,8 @@ export function handleChatMessage( sessionStore.upsert(sessionID, state => { if (userPrompt) { state.lastUserPrompt = userPrompt; + state.turnCount = (state.turnCount ?? 0) + 1; + state.turnCount = (state.turnCount ?? 0) + 1; } if (input.model?.modelID) { diff --git a/src/runtime.ts b/src/runtime.ts index 33bddd9..e56c757 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,6 +1,7 @@ +import { minimatch } from 'minimatch'; import { readAndFormatRules, type RuleFilterContext } from './rule-filter.js'; +import { type DiscoveredRule, getCachedRule } from './rule-discovery.js'; import { extractFilePathsFromMessages } from './message-paths.js'; -import { type DiscoveredRule } from './rule-discovery.js'; import { extractLatestUserPrompt, extractSessionID, @@ -21,6 +22,7 @@ import { type ChatMessageInput, type ChatMessageOutput, } from './runtime-chat.js'; +import { writeActiveRulesState } from './active-rules-state.js'; interface MessagesTransformOutput { messages: MessageWithInfo[]; @@ -44,6 +46,35 @@ interface OpenCodeRulesRuntimeOptions { now?: () => number; } +function resolveRepeatEvery( + config: number | Record | undefined, + modelID: string | undefined +): number { + if (config === undefined) return 1; + if (typeof config === 'number') return config; + if (modelID && typeof config === 'object') { + for (const [pattern, interval] of Object.entries(config)) { + if (pattern === 'default') continue; + if (minimatch(modelID, pattern)) return interval; + } + } + return config['default'] ?? 1; +} + +/** + * Fast non-cryptographic hash for comparing rule content. + * Used to avoid re-injecting identical rules and preserve KV-cache. + */ +function hashString(s: string): string { + let hash = 0; + for (let i = 0; i < s.length; i++) { + const chr = s.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; + } + return (hash >>> 0).toString(36); +} + export class OpenCodeRulesRuntime { private client: unknown; private directory: string; @@ -128,40 +159,42 @@ export class OpenCodeRulesRuntime { } const existingState = this.sessionStore.get(sessionID); - if (existingState && existingState.seededFromHistory) { - this.debugLog(`Session ${sessionID} already seeded, skipping rescan`); - return output; - } - const contextPaths = extractFilePathsFromMessages( - toExtractableMessages(output.messages) - ); - const userPrompt = extractLatestUserPrompt(output.messages); + if (!existingState || !existingState.seededFromHistory) { + const contextPaths = extractFilePathsFromMessages( + toExtractableMessages(output.messages) + ); + const userPrompt = extractLatestUserPrompt(output.messages); - this.sessionStore.upsert(sessionID, state => { - for (const p of contextPaths) { - state.contextPaths.add(normalizeContextPath(p, this.projectDirectory)); + this.sessionStore.upsert(sessionID, state => { + for (const p of contextPaths) { + state.contextPaths.add(normalizeContextPath(p, this.projectDirectory)); + } + if (userPrompt && !state.lastUserPrompt) { + state.lastUserPrompt = userPrompt; + } + state.seededFromHistory = true; + state.seedCount = (state.seedCount ?? 0) + 1; + }); + + if (contextPaths.length > 0) { + this.debugLog( + `Seeded ${contextPaths.length} context path(s) for session ${sessionID}: ${contextPaths + .slice(0, 5) + .join(', ')}${contextPaths.length > 5 ? '...' : ''}` + ); } - if (userPrompt && !state.lastUserPrompt) { - state.lastUserPrompt = userPrompt; + + if (userPrompt) { + this.debugLog( + `Seeded user prompt for session ${sessionID} (len=${userPrompt.length})` + ); } - state.seededFromHistory = true; - state.seedCount = (state.seedCount ?? 0) + 1; - }); - if (contextPaths.length > 0) { - this.debugLog( - `Seeded ${contextPaths.length} context path(s) for session ${sessionID}: ${contextPaths - .slice(0, 5) - .join(', ')}${contextPaths.length > 5 ? '...' : ''}` - ); + return output; } - if (userPrompt) { - this.debugLog( - `Seeded user prompt for session ${sessionID} (len=${userPrompt.length})` - ); - } + await this.maybeInjectUserRules(output, sessionID); return output; } @@ -173,6 +206,109 @@ export class OpenCodeRulesRuntime { handleChatMessage(input, output, this.sessionStore, this.debugLog); } + private async buildCurrentFilterContext( + sessionID: string + ): Promise { + const sessionState = sessionID ? this.sessionStore.get(sessionID) : undefined; + + const contextPaths = sessionState + ? Array.from(sessionState.contextPaths).sort((a, b) => a.localeCompare(b)) + : []; + + const filterContextOpts: BuildFilterContextOptions = { + contextFilePaths: contextPaths, + userPrompt: sessionState?.lastUserPrompt, + availableToolIDs: await this.queryAvailableToolIDs(), + modelID: sessionState?.lastModelID, + agentType: sessionState?.lastAgentType, + }; + + return buildFilterContext( + filterContextOpts, + this.projectDirectory, + this.debugLog + ); + } + + private async maybeInjectUserRules( + output: MessagesTransformOutput, + sessionID: string + ): Promise { + const state = this.sessionStore.get(sessionID); + if (!state) return; + + const turnCount = state.turnCount ?? 0; + if (turnCount === 0) return; + + const filterContext = await this.buildCurrentFilterContext(sessionID); + const minInterval = await this.computeMinUserRepeatEvery(filterContext); + if (minInterval === undefined) return; + + const lastInject = state.lastUserInjectTurn ?? 0; + if (turnCount - lastInject < minInterval) return; + + const { formattedRules } = await readAndFormatRules( + this.ruleFiles, + filterContext, + 'user', + { raw: true } + ); + + if (!formattedRules) return; + + let lastUserIdx = -1; + for (let i = output.messages.length - 1; i >= 0; i--) { + if (output.messages[i]?.role === 'user') { + lastUserIdx = i; + break; + } + } + + if (lastUserIdx >= 0) { + output.messages.splice(lastUserIdx, 0, { + role: 'user', + parts: [{ + type: 'text', + text: formattedRules, + synthetic: true, + }], + }); + + this.sessionStore.upsert(sessionID, s => { + s.lastUserInjectTurn = turnCount; + }); + + this.debugLog( + `Injected user-prompt rules for session ${sessionID} at turn ${turnCount}` + ); + } + } + + private async computeMinUserRepeatEvery( + filterContext: RuleFilterContext + ): Promise { + let minInterval: number | undefined; + + for (const ruleFile of this.ruleFiles) { + const cached = await getCachedRule(ruleFile.filePath); + if (!cached?.metadata) continue; + + const injectMode = cached.metadata.inject ?? 'system'; + if (injectMode !== 'user' && injectMode !== 'both') continue; + + const interval = resolveRepeatEvery( + cached.metadata.repeat_every, + filterContext.modelID + ); + minInterval = + minInterval === undefined + ? interval + : Math.min(minInterval, interval); + } + + return minInterval; + } + private async onSystemTransform( hookInput: SystemTransformInput, output: SystemTransformOutput | null @@ -196,51 +332,98 @@ export class OpenCodeRulesRuntime { } } - const contextPaths = sessionState - ? Array.from(sessionState.contextPaths).sort((a, b) => a.localeCompare(b)) - : []; - const userPrompt = sessionState?.lastUserPrompt; + const filterContext = await this.buildCurrentFilterContext(sessionID ?? ''); + const result = await readAndFormatRules( + this.ruleFiles, + filterContext, + 'system', + { raw: true } + ); - const availableToolIDs = await this.queryAvailableToolIDs(); + let { formattedRules, matchedPaths, individualContents } = result; - const filterContextOpts: BuildFilterContextOptions = { - contextFilePaths: contextPaths, - userPrompt, - availableToolIDs, - modelID: sessionState?.lastModelID, - agentType: sessionState?.lastAgentType, - }; + if (!formattedRules) { + this.debugLog('No applicable rules for current context'); + if (sessionID) { + writeActiveRulesState(sessionID, []); + } + return output ?? {}; + } - const filterContext: RuleFilterContext = await buildFilterContext( - filterContextOpts, - this.projectDirectory, - this.debugLog - ); + // Dedup: skip individual rule contents already present in the current system prompt + const currentSystemText = output + ? Array.isArray(output.system) + ? output.system.join('\n') + : (output.system ?? '') + : ''; + + const newContents: string[] = []; + const newPaths: string[] = []; + for (let i = 0; i < individualContents.length; i++) { + const ruleContent = individualContents[i]; + if (currentSystemText.includes(ruleContent)) { + this.debugLog( + `Skipping duplicate rule content (already in system prompt): ${matchedPaths[i]}` + ); + } else { + newContents.push(ruleContent); + newPaths.push(matchedPaths[i]); + } + } - const formattedRules = await readAndFormatRules( - this.ruleFiles, - filterContext - ); + if (newContents.length === 0) { + this.debugLog('All rules already present in system prompt - skipping injection'); + if (sessionID) { + writeActiveRulesState(sessionID, []); + } + return output ?? {}; + } - if (!formattedRules) { - this.debugLog('No applicable rules for current context'); + // Rebuild formattedRules from non-duplicate contents + formattedRules = newContents.join('\n\n'); + matchedPaths = newPaths; + + if (sessionID) { + writeActiveRulesState(sessionID, matchedPaths); + } + + const rulesHash = hashString(formattedRules); + + if (sessionState?.lastInjectedRulesHash === rulesHash) { + this.debugLog( + `Session ${sessionID} rules unchanged - skipping injection to preserve KV-cache` + ); return output ?? {}; } this.debugLog('Injecting rules into system prompt'); if (!output) { + if (sessionID) { + this.sessionStore.upsert(sessionID, state => { + state.rulesInjected = true; + state.lastInjectedAt = this.now(); + state.lastInjectedRulesHash = rulesHash; + }); + } return { system: formattedRules }; } if (Array.isArray(output.system)) { output.system.push(formattedRules); - return output; + } else { + output.system = output.system + ? `${output.system}\n\n${formattedRules}` + : formattedRules; } - output.system = output.system - ? `${output.system}\n\n${formattedRules}` - : formattedRules; + if (sessionID) { + this.sessionStore.upsert(sessionID, state => { + state.rulesInjected = true; + state.lastInjectedAt = this.now(); + state.lastInjectedRulesHash = rulesHash; + }); + } return output; } @@ -316,6 +499,9 @@ export class OpenCodeRulesRuntime { } this.sessionStore.markCompacting(sessionID, this.now()); + this.sessionStore.upsert(sessionID, s => { + delete s.lastInjectedRulesHash; + }); const sortedPaths = Array.from(sessionState.contextPaths).sort((a, b) => a.localeCompare(b) diff --git a/src/session-store.test.ts b/src/session-store.test.ts index 4786ba4..ca694c4 100644 --- a/src/session-store.test.ts +++ b/src/session-store.test.ts @@ -43,4 +43,43 @@ describe('SessionStore', () => { expect(store.shouldSkipInjection('ses_missing', 1234, 30_000)).toBe(true); }); + + it('tracks rulesInjected state', () => { + const store = new SessionStore({ max: 100 }); + + store.upsert('ses_1', s => void (s.rulesInjected = true)); + + expect(store.get('ses_1')?.rulesInjected).toBe(true); + + store.upsert('ses_1', s => void (s.rulesInjected = false)); + + expect(store.get('ses_1')?.rulesInjected).toBe(false); + }); + + it('tracks lastInjectedAt timestamp', () => { + const store = new SessionStore({ max: 100 }); + const now = Date.now(); + + store.upsert('ses_1', s => void (s.lastInjectedAt = now)); + + expect(store.get('ses_1')?.lastInjectedAt).toBe(now); + }); + + it('allows rules to be re-injected after new user prompt', () => { + const store = new SessionStore({ max: 100 }); + + store.upsert('ses_1', s => { + s.rulesInjected = true; + s.lastInjectedAt = 1000; + }); + + store.upsert('ses_1', s => { + s.lastUserPrompt = 'new message'; + s.rulesInjected = false; + }); + + const state = store.get('ses_1'); + expect(state?.rulesInjected).toBe(false); + expect(state?.lastUserPrompt).toBe('new message'); + }); }); diff --git a/src/session-store.ts b/src/session-store.ts index e751e1a..1f374d2 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -8,6 +8,11 @@ export interface SessionState { seedCount?: number; lastModelID?: string; lastAgentType?: string; + rulesInjected?: boolean; + lastInjectedAt?: number; + turnCount: number; + lastUserInjectTurn: number; + lastInjectedRulesHash?: string; } interface SessionStoreOptions { @@ -118,6 +123,8 @@ export class SessionStore { lastUpdated: ++this.tick, seededFromHistory: false, seedCount: 0, + turnCount: 0, + lastUserInjectTurn: 0, }; } } diff --git a/src/test-fixtures.ts b/src/test-fixtures.ts index ddcf803..d1cfb65 100644 --- a/src/test-fixtures.ts +++ b/src/test-fixtures.ts @@ -4,14 +4,14 @@ */ import path from 'path'; import os from 'os'; -import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { mkdirSync, mkdtempSync, rmSync } from 'fs'; import type { DiscoveredRule } from './utils.js'; // ============================================================================ // Test Directory Management // ============================================================================ -export interface TestDirs { +interface TestDirs { testDir: string; globalRulesDir: string; projectRulesDir: string; @@ -44,7 +44,7 @@ export function getTestDirs(): TestDirs { } // ============================================================================ -// Rule Conversion Helpers +// Environment Snapshot Helpers // ============================================================================ /** @@ -61,7 +61,7 @@ export function toRules(paths: string[]): DiscoveredRule[] { // CI Environment Helpers // ============================================================================ -export const CI_ENV_VARS = [ +const CI_ENV_VARS = [ 'CI', 'CONTINUOUS_INTEGRATION', 'BUILD_NUMBER', @@ -101,10 +101,10 @@ export function restoreCiEnvVars(saved: CiEnvSnapshot): void { } // ============================================================================ -// Mock Object Builders +// Environment Snapshot Helpers // ============================================================================ -export interface MockPluginInput { +interface MockPluginInput { testDir: string; toolIds?: string[]; mcpStatus?: Record; @@ -151,120 +151,6 @@ export function createMockPluginInput(opts: MockPluginInput): { }; } -// ============================================================================ -// Message Part Builders -// ============================================================================ - -export interface TextPart { - type: 'text'; - text: string; - sessionID?: string; - synthetic?: boolean; -} - -export interface ToolInvocationPart { - type: 'tool-invocation'; - toolInvocation: { - toolName: string; - args: Record; - }; - sessionID?: string; -} - -export type MessagePart = TextPart | ToolInvocationPart; - -export interface MockMessage { - role: 'user' | 'assistant'; - parts: MessagePart[]; -} - -/** - * Creates a text message part with optional sessionID. - */ -export function textPart(text: string, sessionID?: string): TextPart { - const part: TextPart = { type: 'text', text }; - if (sessionID) part.sessionID = sessionID; - return part; -} - -/** - * Creates a tool invocation part for read operations. - */ -export function readToolPart( - filePath: string, - sessionID?: string -): ToolInvocationPart { - const part: ToolInvocationPart = { - type: 'tool-invocation', - toolInvocation: { toolName: 'read', args: { filePath } }, - }; - if (sessionID) part.sessionID = sessionID; - return part; -} - -/** - * Creates a tool invocation part for glob operations. - */ -export function globToolPart( - pattern: string, - sessionID?: string -): ToolInvocationPart { - const part: ToolInvocationPart = { - type: 'tool-invocation', - toolInvocation: { toolName: 'glob', args: { pattern } }, - }; - if (sessionID) part.sessionID = sessionID; - return part; -} - -/** - * Creates a mock message with the given role and parts. - */ -export function mockMessage( - role: 'user' | 'assistant', - parts: MessagePart[] -): MockMessage { - return { role, parts }; -} - -// ============================================================================ -// Rule File Helpers -// ============================================================================ - -/** - * Writes a rule file with optional YAML frontmatter. - */ -export function writeRuleFile( - dir: string, - filename: string, - content: string, - metadata?: Record -): string { - const filePath = path.join(dir, filename); - let fileContent = content; - - if (metadata && Object.keys(metadata).length > 0) { - const yamlLines = ['---']; - for (const [key, value] of Object.entries(metadata)) { - if (Array.isArray(value)) { - yamlLines.push(`${key}:`); - for (const item of value) { - yamlLines.push(` - "${item}"`); - } - } else if (typeof value === 'boolean') { - yamlLines.push(`${key}: ${value}`); - } else { - yamlLines.push(`${key}: ${value}`); - } - } - yamlLines.push('---', '', content); - fileContent = yamlLines.join('\n'); - } - - writeFileSync(filePath, fileContent); - return filePath; -} - // ============================================================================ // Environment Snapshot Helpers // ============================================================================ diff --git a/src/utils.ts b/src/utils.ts index dae9e4a..7935fdd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,7 +17,7 @@ export { } from './rule-discovery.js'; // Re-export from rule-metadata (RuleMetadata is internal, not re-exported) -export { parseRuleMetadata } from './rule-metadata.js'; +export { parseRuleMetadata, type InjectMode } from './rule-metadata.js'; // Re-export from rule-filter export { @@ -25,6 +25,8 @@ export { toolsMatchAvailable, readAndFormatRules, type RuleFilterContext, + type FilterResult, + type ReadAndFormatOptions, } from './rule-filter.js'; // Re-export from message-paths diff --git a/tsconfig.json b/tsconfig.json index 9b75f56..fbe2351 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,8 +4,9 @@ "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022"], + "jsx": "react-jsx", "outDir": "./dist", - "rootDir": "./src", + "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -27,6 +28,13 @@ "isolatedModules": true, "verbatimModuleSyntax": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] + "include": ["src/**/*", "tui/**/*"], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx" + ] } diff --git a/tui/data/rules.test.ts b/tui/data/rules.test.ts new file mode 100644 index 0000000..c274ff4 --- /dev/null +++ b/tui/data/rules.test.ts @@ -0,0 +1,518 @@ +// tui/data/rules.test.ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'path'; +import os from 'os'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, chmodSync } from 'fs'; +import { clearRuleCache } from '../../src/rule-discovery.js'; +import { + _setStateDirForTesting, + writeActiveRulesState, +} from '../../src/active-rules-state.js'; +import { + ruleSource, + hasConditions, + formatConditionSummary, + disambiguateNames, + loadSidebarRules, + type SidebarRuleEntry, +} from './rules.js'; + +// ────────────────────────────────────────────── +// ruleSource +// ────────────────────────────────────────────── + +describe('ruleSource', () => { + it('returns "global" when projectDir is null', () => { + expect(ruleSource('/home/user/.config/opencode/rules/foo.md', null)).toBe( + 'global' + ); + }); + + it('returns "project" for files under projectDir/.opencode/rules/', () => { + expect(ruleSource('/project/.opencode/rules/foo.md', '/project')).toBe( + 'project' + ); + }); + + it('returns "project" for files in subdirectories under project rules', () => { + expect(ruleSource('/project/.opencode/rules/sub/deep.md', '/project')).toBe( + 'project' + ); + }); + + it('returns "global" for files not under projectDir/.opencode/rules/', () => { + expect( + ruleSource('/home/user/.config/opencode/rules/foo.md', '/project') + ).toBe('global'); + }); + + it('does not match partial path prefixes', () => { + // /project/.opencode/rules-extra/ should NOT match /project/.opencode/rules/ + expect( + ruleSource('/project/.opencode/rules-extra/foo.md', '/project') + ).toBe('global'); + }); +}); + +// ────────────────────────────────────────────── +// hasConditions +// ────────────────────────────────────────────── + +describe('hasConditions', () => { + it('returns false for undefined metadata', () => { + expect(hasConditions(undefined)).toBe(false); + }); + + it('returns false for empty metadata', () => { + expect(hasConditions({})).toBe(false); + }); + + it('returns true when globs is set', () => { + expect(hasConditions({ globs: ['**/*.ts'] })).toBe(true); + }); + + it('returns true when ci is false (still a condition)', () => { + expect(hasConditions({ ci: false })).toBe(true); + }); + + it('returns true for any single field', () => { + expect(hasConditions({ keywords: ['test'] })).toBe(true); + expect(hasConditions({ tools: ['mcp_bash'] })).toBe(true); + expect(hasConditions({ model: ['gpt-5'] })).toBe(true); + expect(hasConditions({ agent: ['coder'] })).toBe(true); + expect(hasConditions({ command: ['/plan'] })).toBe(true); + expect(hasConditions({ project: ['node'] })).toBe(true); + expect(hasConditions({ branch: ['main'] })).toBe(true); + expect(hasConditions({ os: ['linux'] })).toBe(true); + }); +}); + +// ────────────────────────────────────────────── +// formatConditionSummary +// ────────────────────────────────────────────── + +describe('formatConditionSummary', () => { + it('formats single array field', () => { + expect(formatConditionSummary({ globs: ['**/*.ts'] })).toBe( + 'globs: **/*.ts' + ); + }); + + it('formats multiple array values with commas', () => { + expect(formatConditionSummary({ keywords: ['auth', 'security'] })).toBe( + 'keywords: auth, security' + ); + }); + + it('formats multiple fields with commas', () => { + const result = formatConditionSummary({ + globs: ['**/*.ts'], + keywords: ['test'], + }); + expect(result).toBe('globs: **/*.ts, keywords: test'); + }); + + it('includes ci boolean', () => { + expect(formatConditionSummary({ ci: true })).toBe('ci: true'); + }); + + it('includes match mode', () => { + expect(formatConditionSummary({ model: ['gpt-5'], match: 'all' })).toBe( + 'model: gpt-5, match: all' + ); + }); + + it('formats all fields in canonical order', () => { + const result = formatConditionSummary({ + os: ['linux'], + globs: ['*.md'], + ci: false, + match: 'all', + }); + expect(result).toBe('globs: *.md, os: linux, ci: false, match: all'); + }); +}); + +// ────────────────────────────────────────────── +// disambiguateNames +// ────────────────────────────────────────────── + +describe('disambiguateNames', () => { + it('assigns filename stem for unique names', () => { + const entries: SidebarRuleEntry[] = [ + makeEntry({ path: 'foo.md' }), + makeEntry({ path: 'bar.mdc' }), + ]; + disambiguateNames(entries); + expect(entries[0]!.name).toBe('foo'); + expect(entries[1]!.name).toBe('bar'); + }); + + it('adds parent dir prefix for duplicate stems', () => { + const entries: SidebarRuleEntry[] = [ + makeEntry({ path: 'frontend/security.md' }), + makeEntry({ path: 'backend/security.md' }), + ]; + disambiguateNames(entries); + expect(entries[0]!.name).toBe('frontend/security'); + expect(entries[1]!.name).toBe('backend/security'); + }); + + it('falls back to full path for triple collisions after parent prefix', () => { + const entries: SidebarRuleEntry[] = [ + makeEntry({ path: 'apps/web/security.mdc' }), + makeEntry({ path: 'packages/web/security.mdc' }), + makeEntry({ path: 'other/security.md' }), + ]; + disambiguateNames(entries); + // web/security appears twice, falls back to full path (with extension) for those + expect(entries[0]!.name).toBe('apps/web/security.mdc'); + expect(entries[1]!.name).toBe('packages/web/security.mdc'); + // other/security is unique after parent prefix + expect(entries[2]!.name).toBe('other/security'); + }); + + it('disambiguates same-directory collisions with different extensions', () => { + const entries: SidebarRuleEntry[] = [ + makeEntry({ path: 'dup.md' }), + makeEntry({ path: 'dup.mdc' }), + ]; + disambiguateNames(entries); + // Both stem to "dup", no parent dir to prefix (dirname is "."). + // Falls back to full path with extension. + expect(entries[0]!.name).toBe('dup.md'); + expect(entries[1]!.name).toBe('dup.mdc'); + }); + + it('handles entries with subdirectory paths and different extensions', () => { + const entries: SidebarRuleEntry[] = [ + makeEntry({ path: 'rules/dup.md' }), + makeEntry({ path: 'rules/dup.mdc' }), + ]; + disambiguateNames(entries); + // Both stem to "dup", same parent "rules" -> "rules/dup" for both. + // Still ambiguous, falls back to full path with extension. + expect(entries[0]!.name).toBe('rules/dup.md'); + expect(entries[1]!.name).toBe('rules/dup.mdc'); + }); + + it('preserves multi-dot filenames correctly', () => { + const entries: SidebarRuleEntry[] = [ + makeEntry({ path: 'my.config.md' }), + makeEntry({ path: 'other.md' }), + ]; + disambiguateNames(entries); + // lastIndexOf('.') gives "my.config", not "my" + expect(entries[0]!.name).toBe('my.config'); + expect(entries[1]!.name).toBe('other'); + }); +}); + +// ────────────────────────────────────────────── +// loadSidebarRules (integration) +// ────────────────────────────────────────────── + +describe('loadSidebarRules', () => { + let testDir: string; + let savedXDG: string | undefined; + let savedConfigDir: string | undefined; + + beforeEach(() => { + testDir = mkdtempSync(path.join(os.tmpdir(), 'tui-rules-test-')); + savedXDG = process.env['XDG_CONFIG_HOME']; + savedConfigDir = process.env['OPENCODE_CONFIG_DIR']; + delete process.env['OPENCODE_CONFIG_DIR']; + clearRuleCache(); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + if (savedXDG === undefined) { + delete process.env['XDG_CONFIG_HOME']; + } else { + process.env['XDG_CONFIG_HOME'] = savedXDG; + } + if (savedConfigDir === undefined) { + delete process.env['OPENCODE_CONFIG_DIR']; + } else { + process.env['OPENCODE_CONFIG_DIR'] = savedConfigDir; + } + }); + + it('discovers global rules when projectDir is null', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + writeFileSync(path.join(globalDir, 'rule.md'), '# Always'); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + const { rules, skippedCount } = await loadSidebarRules(null); + + expect(rules).toHaveLength(1); + expect(rules[0]!.source).toBe('global'); + expect(rules[0]!.name).toBe('rule'); + expect(rules[0]!.isConditional).toBe(false); + expect(rules[0]!.conditionSummary).toBe('always active'); + expect(skippedCount).toBe(0); + }); + + it('discovers both global and project rules', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + const projDir = path.join(testDir, 'project'); + const projRulesDir = path.join(projDir, '.opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + mkdirSync(projRulesDir, { recursive: true }); + writeFileSync(path.join(globalDir, 'global.md'), '# Global'); + writeFileSync(path.join(projRulesDir, 'local.md'), '# Local'); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + const { rules } = await loadSidebarRules(projDir); + + expect(rules).toHaveLength(2); + // Project rules sort first + expect(rules[0]!.source).toBe('project'); + expect(rules[1]!.source).toBe('global'); + }); + + it('parses metadata for conditional rules', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + writeFileSync( + path.join(globalDir, 'conditional.mdc'), + `---\nglobs:\n - "**/*.ts"\nkeywords:\n - testing\n---\nRule content` + ); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + const { rules } = await loadSidebarRules(null); + + expect(rules).toHaveLength(1); + expect(rules[0]!.isConditional).toBe(true); + expect(rules[0]!.conditionSummary).toContain('globs'); + expect(rules[0]!.conditionSummary).toContain('keywords'); + expect(rules[0]!.metadata.globs).toEqual(['**/*.ts']); + }); + + it('sorts project rules before global, alphabetical within group', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + const projDir = path.join(testDir, 'project'); + const projRulesDir = path.join(projDir, '.opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + mkdirSync(projRulesDir, { recursive: true }); + writeFileSync(path.join(globalDir, 'zebra.md'), '# Z'); + writeFileSync(path.join(globalDir, 'alpha.md'), '# A'); + writeFileSync(path.join(projRulesDir, 'beta.md'), '# B'); + writeFileSync(path.join(projRulesDir, 'aardvark.md'), '# AA'); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + const { rules } = await loadSidebarRules(projDir); + + expect(rules.map(r => `${r.source}:${r.name}`)).toEqual([ + 'project:aardvark', + 'project:beta', + 'global:alpha', + 'global:zebra', + ]); + }); + + it('increments skippedCount for unreadable files', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + writeFileSync(path.join(globalDir, 'readable.md'), '# OK'); + const unreadable = path.join(globalDir, 'unreadable.md'); + writeFileSync(unreadable, '# Nope'); + chmodSync(unreadable, 0o000); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + const { rules, skippedCount } = await loadSidebarRules(null); + + // One readable, one unreadable + expect(rules).toHaveLength(1); + expect(rules[0]!.name).toBe('readable'); + expect(skippedCount).toBe(1); + + // getCachedRule() logs its own warning for read failures — + // loadSidebarRules does NOT add a second one (no duplicate logs). + // But getCachedRule's internal warning should still fire: + expect(warnSpy).toHaveBeenCalled(); + + // Restore permissions for cleanup + chmodSync(unreadable, 0o644); + warnSpy.mockRestore(); + }); +}); + +// ────────────────────────────────────────────── +// loadSidebarRules isActive behavior +// ────────────────────────────────────────────── + +describe('loadSidebarRules isActive behavior', () => { + let testDir: string; + let stateDir: string; + let savedXDG: string | undefined; + let savedConfigDir: string | undefined; + + beforeEach(() => { + testDir = mkdtempSync(path.join(os.tmpdir(), 'tui-rules-active-test-')); + stateDir = path.join(testDir, 'state'); + mkdirSync(stateDir, { recursive: true }); + savedXDG = process.env['XDG_CONFIG_HOME']; + savedConfigDir = process.env['OPENCODE_CONFIG_DIR']; + delete process.env['OPENCODE_CONFIG_DIR']; + clearRuleCache(); + _setStateDirForTesting(stateDir); + }); + + afterEach(() => { + _setStateDirForTesting(null); + rmSync(testDir, { recursive: true, force: true }); + if (savedXDG === undefined) { + delete process.env['XDG_CONFIG_HOME']; + } else { + process.env['XDG_CONFIG_HOME'] = savedXDG; + } + if (savedConfigDir === undefined) { + delete process.env['OPENCODE_CONFIG_DIR']; + } else { + process.env['OPENCODE_CONFIG_DIR'] = savedConfigDir; + } + }); + + it('sets hasEvaluationState to false when no sessionId provided', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + writeFileSync(path.join(globalDir, 'rule.md'), '# Always'); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + const result = await loadSidebarRules(null); + + expect(result.hasEvaluationState).toBe(false); + }); + + it('sets hasEvaluationState to false when state file does not exist', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + writeFileSync(path.join(globalDir, 'rule.md'), '# Always'); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + const result = await loadSidebarRules(null, 'nonexistent-session'); + + expect(result.hasEvaluationState).toBe(false); + }); + + it('sets hasEvaluationState to true when state file exists', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + writeFileSync(path.join(globalDir, 'rule.md'), '# Always'); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + writeActiveRulesState('test-session', []); + // Wait for async write to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + const result = await loadSidebarRules(null, 'test-session'); + + expect(result.hasEvaluationState).toBe(true); + }); + + it('sets isActive to true for unconditional rules without state file', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + writeFileSync(path.join(globalDir, 'always.md'), '# Always active'); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + const { rules } = await loadSidebarRules(null); + + expect(rules[0]!.isConditional).toBe(false); + expect(rules[0]!.isActive).toBe(true); + }); + + it('sets isActive to null for conditional rules without state file', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + writeFileSync( + path.join(globalDir, 'conditional.mdc'), + `---\nglobs:\n - "**/*.ts"\n---\nConditional rule` + ); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + const { rules } = await loadSidebarRules(null); + + expect(rules[0]!.isConditional).toBe(true); + expect(rules[0]!.isActive).toBe(null); + }); + + it('sets isActive based on matchedRulePaths when state file exists', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + const matchedPath = path.join(globalDir, 'matched.md'); + const unmatchedPath = path.join(globalDir, 'unmatched.md'); + writeFileSync(matchedPath, '# Matched'); + writeFileSync(unmatchedPath, '# Unmatched'); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + writeActiveRulesState('test-session', [matchedPath]); + await new Promise(resolve => setTimeout(resolve, 50)); + + const { rules } = await loadSidebarRules(null, 'test-session'); + + const matched = rules.find(r => r.name === 'matched'); + const unmatched = rules.find(r => r.name === 'unmatched'); + + expect(matched!.isActive).toBe(true); + expect(unmatched!.isActive).toBe(false); + }); + + it('correctly matches conditional rules with state file', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + const conditionalPath = path.join(globalDir, 'conditional.mdc'); + writeFileSync( + conditionalPath, + `---\nglobs:\n - "**/*.ts"\n---\nConditional rule` + ); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + // Conditional rule is in matchedRulePaths + writeActiveRulesState('test-session', [conditionalPath]); + await new Promise(resolve => setTimeout(resolve, 50)); + + const { rules } = await loadSidebarRules(null, 'test-session'); + + expect(rules[0]!.isConditional).toBe(true); + expect(rules[0]!.isActive).toBe(true); + }); + + it('marks conditional rules as inactive when not in matchedRulePaths', async () => { + const globalDir = path.join(testDir, '.config', 'opencode', 'rules'); + mkdirSync(globalDir, { recursive: true }); + writeFileSync( + path.join(globalDir, 'conditional.mdc'), + `---\nglobs:\n - "**/*.ts"\n---\nConditional rule` + ); + process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config'); + + // Empty matchedRulePaths - nothing matched + writeActiveRulesState('test-session', []); + await new Promise(resolve => setTimeout(resolve, 50)); + + const { rules } = await loadSidebarRules(null, 'test-session'); + + expect(rules[0]!.isConditional).toBe(true); + expect(rules[0]!.isActive).toBe(false); + }); +}); + +/** Helper to create a minimal SidebarRuleEntry for disambiguation tests */ +function makeEntry( + overrides: Partial & { path: string } +): SidebarRuleEntry { + return { + name: '', + path: overrides.path, + source: overrides.source ?? 'global', + isConditional: overrides.isConditional ?? false, + conditionSummary: overrides.conditionSummary ?? 'always active', + metadata: overrides.metadata ?? {}, + isActive: overrides.isActive ?? true, + }; +} diff --git a/tui/data/rules.ts b/tui/data/rules.ts new file mode 100644 index 0000000..61cbd03 --- /dev/null +++ b/tui/data/rules.ts @@ -0,0 +1,230 @@ +// tui/data/rules.ts +import { discoverRuleFiles, getCachedRule } from '../../src/rule-discovery'; +import type { RuleMetadata } from '../../src/rule-metadata'; +import { readActiveRulesState } from '../../src/active-rules-state'; +import path from 'path'; + +/** Represents a rule as displayed in the sidebar */ +export interface SidebarRuleEntry { + /** Display name (filename stem, disambiguated if needed) */ + name: string; + /** Relative file path from the rules directory root */ + path: string; + /** Whether this rule came from global or project-local rules dir */ + source: 'global' | 'project'; + /** Whether the rule has any conditional metadata */ + isConditional: boolean; + /** Human-readable condition summary */ + conditionSummary: string; + /** Full metadata for expanded view */ + metadata: RuleMetadata; + /** + * Active state of the rule. + * - true: rule is active (matched by evaluation or unconditional without state file) + * - false: rule is not active (not matched by evaluation) + * - null: state not yet determined (conditional rule without state file) + */ + isActive: boolean | null; +} + +export interface LoadSidebarRulesResult { + rules: SidebarRuleEntry[]; + skippedCount: number; + /** Whether active rules state was successfully read from disk */ + hasEvaluationState: boolean; +} + +/** + * Load all discovered rules formatted for sidebar display. + * Reuses discoverRuleFiles/getCachedRule from the server plugin. + * + * @param projectDir - Project directory or null (global rules only) + * @param sessionId - Optional session ID to read active rules state + */ +export async function loadSidebarRules( + projectDir: string | null, + sessionId?: string +): Promise { + // discoverRuleFiles accepts string | undefined, not null + const discovered = await discoverRuleFiles(projectDir ?? undefined); + + // Read active rules state if sessionId provided + const activeState = sessionId ? await readActiveRulesState(sessionId) : null; + const hasEvaluationState = activeState !== null; + const matchedPathsSet = hasEvaluationState + ? new Set(activeState.matchedRulePaths) + : null; + + const entries: SidebarRuleEntry[] = []; + let skippedCount = 0; + + for (const rule of discovered) { + const cached = await getCachedRule(rule.filePath); + if (!cached) { + // getCachedRule() already logs a warning for read failures, + // so we only increment the counter here — no duplicate log. + skippedCount++; + continue; + } + + const meta = cached.metadata; + const source = ruleSource(rule.filePath, projectDir); + const isConditional = hasConditions(meta); + const conditionSummary = isConditional + ? formatConditionSummary(meta!) + : 'always active'; + + // Determine isActive based on state file or fallback logic + let isActive: boolean | null; + if (matchedPathsSet !== null) { + // With state file: check if this rule's absolute path is in matchedPaths + isActive = matchedPathsSet.has(rule.filePath); + } else { + // Without state file: unconditional = true, conditional = null + isActive = isConditional ? null : true; + } + + entries.push({ + name: '', // placeholder — set in disambiguation pass + path: rule.relativePath, + source, + isConditional, + conditionSummary, + metadata: meta ?? {}, + isActive, + }); + } + + disambiguateNames(entries); + + // Sort: project first, then global. Active rules to top, then alpha by name. + const activeOrder = (v: boolean | null): number => + v === true ? 0 : v === null ? 1 : 2; + entries.sort((a, b) => { + if (a.source !== b.source) return a.source === 'project' ? -1 : 1; + const activeCmp = activeOrder(a.isActive) - activeOrder(b.isActive); + if (activeCmp !== 0) return activeCmp; + const nameCompare = a.name.localeCompare(b.name); + if (nameCompare !== 0) return nameCompare; + return a.path.localeCompare(b.path); + }); + + return { rules: entries, skippedCount, hasEvaluationState }; +} + +/** + * Determine if a rule file is project-local or global. + * Uses path.sep boundary check to avoid matching partial prefixes + * (e.g., /project/.opencode/rules-extra/ should not match). + */ +export function ruleSource( + filePath: string, + projectDir: string | null +): 'global' | 'project' { + if (!projectDir) return 'global'; + const projectRulesPrefix = + path.join(projectDir, '.opencode', 'rules') + path.sep; + return filePath.startsWith(projectRulesPrefix) ? 'project' : 'global'; +} + +/** + * Check if metadata has any conditional fields set. + */ +export function hasConditions(meta: RuleMetadata | undefined): boolean { + if (!meta) return false; + return !!( + meta.globs || + meta.keywords || + meta.tools || + meta.model || + meta.agent || + meta.command || + meta.project || + meta.branch || + meta.os || + meta.ci !== undefined + ); +} + +/** + * Build a human-readable, comma-separated summary of active conditions. + * E.g., "globs: src/*.ts, keywords: auth, security" + */ +export function formatConditionSummary(meta: RuleMetadata): string { + const parts: string[] = []; + + const arrayFields: Array<[keyof RuleMetadata, string]> = [ + ['globs', 'globs'], + ['keywords', 'keywords'], + ['tools', 'tools'], + ['model', 'model'], + ['agent', 'agent'], + ['command', 'command'], + ['project', 'project'], + ['branch', 'branch'], + ['os', 'os'], + ]; + + for (const [field, label] of arrayFields) { + const value = meta[field]; + if (Array.isArray(value) && value.length > 0) { + parts.push(`${label}: ${(value as string[]).join(', ')}`); + } + } + + if (meta.ci !== undefined) { + parts.push(`ci: ${String(meta.ci)}`); + } + + if (meta.match) { + parts.push(`match: ${meta.match}`); + } + + return parts.join(', '); +} + +/** + * Three-pass name disambiguation. + * Pass 1: Extract filename stem from each entry's path. + * Pass 2: For duplicate stems, prefix with parent directory. + * Pass 3: If still ambiguous (same parent or root-level), use full relative + * path (including extension) as the display name. + * + * Mutates entries[].name in place. + */ +export function disambiguateNames(entries: SidebarRuleEntry[]): void { + // Pass 1: assign stem names (filename without extension, using last dot) + for (const entry of entries) { + const basename = path.basename(entry.path); + const dotIndex = basename.lastIndexOf('.'); + entry.name = dotIndex > 0 ? basename.substring(0, dotIndex) : basename; + } + + // Pass 2: detect and resolve collisions with parent directory prefix + const stemCounts = new Map(); + for (const entry of entries) { + stemCounts.set(entry.name, (stemCounts.get(entry.name) ?? 0) + 1); + } + + for (const entry of entries) { + if ((stemCounts.get(entry.name) ?? 0) <= 1) continue; + + const dir = path.dirname(entry.path); + if (dir && dir !== '.') { + const parent = path.basename(dir); + entry.name = `${parent}/${entry.name}`; + } + } + + // Pass 3: if still ambiguous, use full relative path WITH extension + const nameCounts = new Map(); + for (const entry of entries) { + nameCounts.set(entry.name, (nameCounts.get(entry.name) ?? 0) + 1); + } + + for (const entry of entries) { + if ((nameCounts.get(entry.name) ?? 0) > 1) { + entry.name = entry.path; + } + } +} diff --git a/tui/index.tsx b/tui/index.tsx new file mode 100644 index 0000000..5364ceb --- /dev/null +++ b/tui/index.tsx @@ -0,0 +1,23 @@ +// tui/index.tsx +/** @jsxImportSource @opentui/solid */ +import type { TuiPlugin } from '@opencode-ai/plugin/tui'; +import { SidebarContent } from './slots/sidebar-content'; + +const id = 'opencode-rules' as const; + +const tui: TuiPlugin = async api => { + api.slots.register({ + order: 350, + slots: { + sidebar_content: (ctx, props) => ( + + ), + }, + }); +}; + +export default { id, tui }; diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx new file mode 100644 index 0000000..cef1135 --- /dev/null +++ b/tui/slots/sidebar-content.tsx @@ -0,0 +1,364 @@ +// tui/slots/sidebar-content.tsx +/** @jsxImportSource @opentui/solid */ +import { + createSignal, + createEffect, + createMemo, + onCleanup, + Show, + For, + type JSX, +} from 'solid-js'; +import type { TuiPluginApi, TuiTheme } from '@opencode-ai/plugin/tui'; +import { loadSidebarRules, type SidebarRuleEntry } from '../data/rules'; + +interface SidebarContentProps { + sessionId: string; + api: TuiPluginApi; + theme: TuiTheme; +} + +type ThemeColor = string | import('@opentui/core').RGBA; + +interface ThemeColors { + text: ThemeColor; + textMuted: ThemeColor; + success: ThemeColor; + [key: string]: unknown; +} + +interface RuleSectionProps { + title: string; + rules: SidebarRuleEntry[]; + theme: ThemeColors; + open: boolean; + onToggle: () => void; + expandedIndex: number | null; + globalOffset: number; + onExpandToggle: (globalIndex: number) => void; + hasEvaluationState: boolean; +} + +function RuleSection(props: RuleSectionProps): JSX.Element { + const activeCount = createMemo( + () => props.rules.filter(r => r.isActive === true).length + ); + + const headerCount = createMemo(() => { + if (props.hasEvaluationState) { + return `(${activeCount()}/${props.rules.length})`; + } + return `(${props.rules.length})`; + }); + + const bulletColor = (rule: SidebarRuleEntry): ThemeColor => { + return rule.isActive === true ? props.theme.success : props.theme.textMuted; + }; + + return ( + 0}> + + props.onToggle()}> + {props.open ? '▼' : '▶'} + + {props.title} + + + {' '} + {headerCount()} + + + + + + + {(rule, localIndex) => { + const globalIndex = () => props.globalOffset + localIndex(); + return ( + props.onExpandToggle(globalIndex())} + > + + + {rule.name} + + + + {rule.path} + 0}> + + Globs: {rule.metadata.globs!.join(', ')} + + + 0}> + + Keywords: {rule.metadata.keywords!.join(', ')} + + + 0}> + + Tools: {rule.metadata.tools!.join(', ')} + + + 0}> + + Model: {rule.metadata.model!.join(', ')} + + + 0}> + + Agent: {rule.metadata.agent!.join(', ')} + + + 0}> + + Command: {rule.metadata.command!.join(', ')} + + + 0}> + + Project: {rule.metadata.project!.join(', ')} + + + 0}> + + Branch: {rule.metadata.branch!.join(', ')} + + + 0}> + + OS: {rule.metadata.os!.join(', ')} + + + + + CI: {String(rule.metadata.ci)} + + + + + Match: {rule.metadata.match} + + + + + + ); + }} + + + + + ); +} + +export function SidebarContent(props: SidebarContentProps): JSX.Element { + const [rules, setRules] = createSignal([]); + const [status, setStatus] = createSignal<'loading' | 'loaded' | 'error'>( + 'loading' + ); + const [skippedCount, setSkippedCount] = createSignal(0); + const [hasEvaluationState, setHasEvaluationState] = createSignal(false); + const [expandedIndex, setExpandedIndex] = createSignal(null); + const [lastDir, setLastDir] = createSignal( + undefined + ); + const [lastSessionId, setLastSessionId] = createSignal( + undefined + ); + const [projectOpen, setProjectOpen] = createSignal(false); + const [globalOpen, setGlobalOpen] = createSignal(false); + const [refreshCounter, setRefreshCounter] = createSignal(0); + + const theme = (): ThemeColors => props.theme.current as ThemeColors; + + const resolveProjectDir = (): string | null => { + return props.api.state.path.directory ?? null; + }; + + // Monotonic counter to detect stale async results + let requestId = 0; + // Debounce timer for event-driven refresh + let debounceTimer: ReturnType | null = null; + + // Initial load: triggered by session/directory change, resets all UI state + const loadRulesInitial = async (): Promise => { + const thisRequest = ++requestId; + const dir = resolveProjectDir(); + const sessionId = props.sessionId; + + setLastDir(dir); + setLastSessionId(sessionId); + setStatus('loading'); + + try { + const result = await loadSidebarRules(dir, sessionId); + // Discard if a newer request started + if (requestId !== thisRequest) return; + setRules(result.rules); + setSkippedCount(result.skippedCount); + setHasEvaluationState(result.hasEvaluationState); + setStatus('loaded'); + } catch (err) { + // Discard if a newer request started + if (requestId !== thisRequest) return; + console.error('[opencode-rules] Failed to load rules:', err); + setStatus('error'); + } + }; + + // Refresh load: triggered by events, only updates rule data (no UI state reset) + const loadRulesRefresh = async (): Promise => { + // Skip refresh if initial load is still in flight + if (status() === 'loading') return; + + const thisRequest = ++requestId; + const dir = resolveProjectDir(); + const sessionId = props.sessionId; + + try { + const result = await loadSidebarRules(dir, sessionId); + // Discard if a newer request started + if (requestId !== thisRequest) return; + setRules(result.rules); + setSkippedCount(result.skippedCount); + setHasEvaluationState(result.hasEvaluationState); + setStatus('loaded'); + } catch (err) { + // Discard if a newer request started + if (requestId !== thisRequest) return; + console.error('[opencode-rules] Failed to refresh rules:', err); + } + }; + + // Effect 1: Initial load on session/directory change + createEffect(() => { + const currentSessionId = props.sessionId; + const currentDir = resolveProjectDir(); + + // Check if session or directory changed + if (currentSessionId !== lastSessionId() || currentDir !== lastDir()) { + // Clear pending debounce from previous session + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + // Reset UI state on session/directory change + setExpandedIndex(null); + setProjectOpen(false); + setGlobalOpen(false); + void loadRulesInitial(); + } + }); + + // Effect 2: Refresh on event-driven updates (refreshCounter changes) + createEffect(() => { + const counter = refreshCounter(); + if (counter > 0) { + void loadRulesRefresh(); + } + }); + + // Subscribe to OpenCode events with debounce + const triggerRefresh = (event: { + type: string; + properties: Record; + }): void => { + // Filter events to current sessionId before debouncing + // OpenCode SDK events nest sessionID inside properties: { type, properties: { sessionID, ... } } + const eventSessionID = event.properties.sessionID; + if ( + typeof eventSessionID === 'string' && + eventSessionID !== props.sessionId + ) { + return; + } + + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + setRefreshCounter(c => c + 1); + }, 150); + }; + + const unsubMessageUpdated = props.api.event.on( + 'message.updated', + triggerRefresh + ); + const unsubSessionStatus = props.api.event.on( + 'session.status', + triggerRefresh + ); + + onCleanup(() => { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + unsubMessageUpdated(); + unsubSessionStatus(); + }); + + const toggleExpand = (index: number): void => { + setExpandedIndex(prev => (prev === index ? null : index)); + }; + + const projectRules = createMemo(() => + rules().filter(r => r.source === 'project') + ); + const globalRules = createMemo(() => + rules().filter(r => r.source === 'global') + ); + + return ( + + + Rules + + + + Loading... + + + Failed to load rules + + + + 0} + fallback={No rules found} + > + setProjectOpen(x => !x)} + expandedIndex={expandedIndex()} + globalOffset={0} + onExpandToggle={toggleExpand} + hasEvaluationState={hasEvaluationState()} + /> + setGlobalOpen(x => !x)} + expandedIndex={expandedIndex()} + globalOffset={projectRules().length} + onExpandToggle={toggleExpand} + hasEvaluationState={hasEvaluationState()} + /> + + 0}> + + {skippedCount()} rules skipped (unreadable) + + + + + ); +} diff --git a/tui/types/opencode-plugin-tui.d.ts b/tui/types/opencode-plugin-tui.d.ts new file mode 100644 index 0000000..4e11948 --- /dev/null +++ b/tui/types/opencode-plugin-tui.d.ts @@ -0,0 +1,78 @@ +// tui/types/opencode-plugin-tui.d.ts +// +// Vendored type declarations for @opencode-ai/plugin/tui. +// Allows tsc to compile TUI code without requiring the optional +// peer dependency to be installed at compile time. +// +// Source: @opencode-ai/plugin v1.3.7 (packages/plugin/src/tui.ts) +// If bumping @opencode-ai/plugin, re-verify these types match. + +declare module '@opencode-ai/plugin/tui' { + export interface TuiTheme { + [key: string]: unknown; + } + + export interface TuiSlotMap { + sidebar_content: { session_id: string }; + } + + export interface TuiSlotContext { + theme: TuiTheme; + } + + export type SlotRenderer = ( + ctx: Readonly, + props: TuiSlotMap[K] + ) => JSX.Element; + + export interface TuiSlotPlugin { + id?: never; + order?: number; + setup?: (ctx: Readonly, renderer: unknown) => void; + dispose?: () => void; + slots: { + [K in keyof TuiSlotMap]?: SlotRenderer; + }; + } + + export interface TuiSlots { + register: (plugin: TuiSlotPlugin) => string; + } + + export interface TuiWorkspace { + current: () => string | undefined; + set: (id?: string) => void; + } + + export interface TuiState { + readonly path: { + state: string; + config: string; + worktree: string; + directory: string; + }; + workspace: { + get: (id: string) => { directory: string | null } | undefined; + }; + } + + export interface TuiEventBus { + on: ( + type: string, + handler: (event: { + type: string; + properties: Record; + }) => void + ) => () => void; + } + + export interface TuiPluginApi { + slots: TuiSlots; + workspace: TuiWorkspace; + state: TuiState; + kv: unknown; + event: TuiEventBus; + } + + export type TuiPlugin = (api: TuiPluginApi) => Promise | void; +} diff --git a/vitest.config.ts b/vitest.config.ts index 776fc9b..02a948d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.{test,spec}.{ts,tsx}'], + include: ['src/**/*.{test,spec}.{ts,tsx}', 'tui/**/*.{test,spec}.{ts,tsx}'], exclude: ['node_modules', 'dist'], coverage: { provider: 'v8',