vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Normalize line endings: store LF in git, check out LF on every platform.
|
||||
# Required so biome's --check passes on Windows (default core.autocrlf=true).
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicit binary types
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.zip binary
|
||||
*.tgz binary
|
||||
*.gz binary
|
||||
@@ -0,0 +1,12 @@
|
||||
* @code-yeongyu
|
||||
|
||||
.github/workflows/* @code-yeongyu
|
||||
.github/dependabot.yml @code-yeongyu
|
||||
package.json @code-yeongyu
|
||||
package-lock.json @code-yeongyu
|
||||
LICENSE @code-yeongyu
|
||||
NOTICE @code-yeongyu
|
||||
README.md @code-yeongyu
|
||||
CHANGELOG.md @code-yeongyu
|
||||
.codex-plugin/plugin.json @code-yeongyu
|
||||
hooks/hooks.json @code-yeongyu
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Bug Report
|
||||
description: Report broken Codex rule injection or matching behavior
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Include the Codex hook payload, hook output, rule file, and plugin version needed to reproduce.
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Include exact output/errors.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: payload
|
||||
attributes:
|
||||
label: Hook payload
|
||||
description: Paste the minimal SessionStart, UserPromptSubmit, or PostToolUse payload that reproduces the issue.
|
||||
render: json
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: rule
|
||||
attributes:
|
||||
label: Rule file
|
||||
description: Paste the relevant rule file or frontmatter.
|
||||
render: markdown
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: codex-rules version
|
||||
placeholder: 0.1.0
|
||||
validations:
|
||||
required: false
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
name: Feature Request
|
||||
description: Propose a Codex rule source, matcher, or hook improvement
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What workflow is blocked or awkward today?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposal
|
||||
description: What should codex-rules do?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: What else could solve this?
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "main protection",
|
||||
"target": "branch",
|
||||
"enforcement": "active",
|
||||
"conditions": {
|
||||
"ref_name": {
|
||||
"include": ["~DEFAULT_BRANCH"],
|
||||
"exclude": []
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{ "type": "deletion" },
|
||||
{ "type": "non_fast_forward" },
|
||||
{ "type": "required_linear_history" },
|
||||
{
|
||||
"type": "pull_request",
|
||||
"parameters": {
|
||||
"required_approving_review_count": 1,
|
||||
"dismiss_stale_reviews_on_push": true,
|
||||
"require_code_owner_review": true,
|
||||
"require_last_push_approval": false,
|
||||
"required_review_thread_resolution": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "required_status_checks",
|
||||
"parameters": {
|
||||
"strict_required_status_checks_policy": true,
|
||||
"required_status_checks": [
|
||||
{ "context": "test (ubuntu-latest · node 20)" },
|
||||
{ "context": "test (ubuntu-latest · node 22)" },
|
||||
{ "context": "test (macos-latest · node 20)" },
|
||||
{ "context": "test (macos-latest · node 22)" }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"bypass_actors": [
|
||||
{
|
||||
"actor_id": 5,
|
||||
"actor_type": "RepositoryRole",
|
||||
"bypass_mode": "always"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: development
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
@@ -0,0 +1,20 @@
|
||||
## Summary
|
||||
|
||||
<!-- Brief description, 1-3 bullets -->
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] `npm run check` (typecheck + biome + build)
|
||||
- [ ] `npm test` (unit tests)
|
||||
- [ ] `npm pack --dry-run` (release sanity)
|
||||
- [ ] Hook smoke-tested locally with `node dist/cli.js hook session-start`
|
||||
- [ ] Hook smoke-tested locally with `node dist/cli.js hook post-tool-use`
|
||||
|
||||
## Codex plugin impact
|
||||
|
||||
- [ ] `.codex-plugin/plugin.json` remains valid
|
||||
- [ ] `hooks/hooks.json` still uses stable Codex hook JSON
|
||||
- [ ] Session deduplication behavior is covered by tests
|
||||
- [ ] CHANGELOG entry added for user-facing changes
|
||||
@@ -0,0 +1,47 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.os }} · node ${{ matrix.node }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: ["20", "22"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Package smoke
|
||||
run: npm pack --dry-run
|
||||
@@ -0,0 +1,51 @@
|
||||
name: publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Package smoke
|
||||
run: npm pack --dry-run
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ -z "$NODE_AUTH_TOKEN" ]; then
|
||||
echo "NODE_AUTH_TOKEN is not configured; skipping npm publish."
|
||||
exit 0
|
||||
fi
|
||||
npm publish --access public --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
*.log
|
||||
coverage/
|
||||
.vitest/
|
||||
*.tgz
|
||||
@@ -0,0 +1,34 @@
|
||||
# Repository Conventions
|
||||
|
||||
Conventions for human contributors and AI agents working on this repository.
|
||||
|
||||
## Style
|
||||
|
||||
- Terse technical prose. No emojis in commits, issues, PR comments, or code.
|
||||
- TypeScript strict mode. No `any`, no `@ts-ignore`, no `@ts-expect-error`, no enums.
|
||||
- ESM modules with `.js` suffix in runtime import paths.
|
||||
- Tabs for indentation. Double quotes for strings.
|
||||
- Tests use vitest with `#given .. #when .. #then` descriptions or plain `// given / // when / // then` body comments.
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm install` - install dependencies.
|
||||
- `npm test` - run vitest once.
|
||||
- `npm run typecheck` - strict TypeScript check.
|
||||
- `npm run check` - type check, biome, and build.
|
||||
- `npm pack --dry-run` - release package smoke test.
|
||||
- `node dist/cli.js hook session-start < fixture.json` - smoke-test static rule injection.
|
||||
- `node dist/cli.js hook post-tool-use < fixture.json` - smoke-test dynamic rule injection.
|
||||
|
||||
## Constraints
|
||||
|
||||
- No Bun APIs. Runtime is Node only because Codex launches plugin hooks with Node.
|
||||
- Keep `SessionStart`, `UserPromptSubmit`, and `PostToolUse` hook behavior covered by tests.
|
||||
- Keep Codex file path extraction for reads, edits, `apply_patch`, and shell-style tools covered by tests.
|
||||
- Hook output must use the stable Codex hook JSON contract.
|
||||
- Do not couple this package back to pi, omo, or senpi internal source paths.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- No `git add -A` or `git add .`. Stage only the files you changed.
|
||||
- No `git commit --no-verify`. No force pushes. No history rewriting on shared branches.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Restrict the default `PostToolUse` hook matcher to Codex's canonical `apply_patch` tool name.
|
||||
- Add opt-in `NODE_DEBUG=codex-rules` phase timing logs for `PostToolUse` debugging.
|
||||
- Harden dynamic hook coverage for additional-context JSON output, disabled/static modes, failed tool responses, and duplicate suppression.
|
||||
- Remove redundant apply_patch path scanning and stale tracked-tool constants.
|
||||
- Use portable Codex hook interpolation and add package smoke coverage for hook entrypoints.
|
||||
- Cap recursive rule directory scans and run CI on Windows in addition to Ubuntu and macOS.
|
||||
- Replace the external glob matcher dependency with an internal matcher so clean Codex plugin installs run without `node_modules`.
|
||||
|
||||
## 0.1.0 - 2026-05-15
|
||||
|
||||
- Port `pi-rules` rule loading, matching, formatting, truncation, and deduplication to a Codex plugin.
|
||||
- Add `SessionStart`, `UserPromptSubmit`, and `PostToolUse` hooks for static and file-specific context injection.
|
||||
- Add persistent per-session deduplication under Codex plugin data.
|
||||
- Add Codex-aware path extraction for read, write, edit, multi-edit, `apply_patch`, and shell command payloads.
|
||||
- Add tests, CI, release workflow, marketplace metadata, and local install support.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Yeongyu Kim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,15 @@
|
||||
codex-rules
|
||||
|
||||
This package implements rule/context loading for Codex plugins.
|
||||
Its behavior is ported from pi-rules in the pi coding-agent extension ecosystem
|
||||
and inspired by oh-my-openagent (omo) at https://github.com/code-yeongyu/oh-my-openagent,
|
||||
including omo's `.omo/rules/` workflow and rules-injector hook architecture.
|
||||
omo is originally licensed under the Sustainable Use License 1.0.
|
||||
|
||||
Yeongyu Kim (https://github.com/code-yeongyu), author of omo, pi-rules, and this
|
||||
package, licenses the source distributed in this repository under the MIT License.
|
||||
If any source was ported from omo or pi-rules, that ported source is re-licensed
|
||||
here under MIT for distribution as a Codex plugin. See LICENSE for terms.
|
||||
|
||||
picomatch is by Jon Schlinkert and contributors (https://github.com/micromatch/picomatch).
|
||||
Distributed under the MIT License.
|
||||
@@ -0,0 +1,127 @@
|
||||
# codex-rules
|
||||
|
||||
Codex plugin that injects local project rule files into model context through lifecycle hooks.
|
||||
|
||||
It ports the `pi-rules` rule injector to Codex:
|
||||
|
||||
- `SessionStart` and `UserPromptSubmit` load static project instructions once per session.
|
||||
- `PostToolUse` watches Codex `apply_patch` by default, then injects matching file-specific rules as additional context.
|
||||
- `PostCompact` clears the per-session injection cache after manual or automatic compaction so relevant rules can be reintroduced into the compacted conversation.
|
||||
- Session-level deduplication prevents the same rule from being repeated after it has been injected.
|
||||
|
||||
`PostToolUse` output is context-only: it emits `hookSpecificOutput.additionalContext` and does not rewrite tool output.
|
||||
|
||||
The runtime has no npm production dependencies, so a clean Codex marketplace copy can run without a follow-up `npm install`.
|
||||
|
||||
## Rule Sources
|
||||
|
||||
Project-level sources:
|
||||
|
||||
- `AGENTS.md`
|
||||
- `CLAUDE.md`
|
||||
- `CONTEXT.md`
|
||||
- `.omo/rules/**/*.md`
|
||||
- `.claude/rules/**/*.md`
|
||||
- `.cursor/rules/**/*.md`
|
||||
- `.github/instructions/**/*.md`
|
||||
- `.github/copilot-instructions.md`
|
||||
|
||||
User-home sources are also supported by the ported engine when available.
|
||||
|
||||
Markdown rule files may use frontmatter such as:
|
||||
|
||||
```md
|
||||
---
|
||||
description: TypeScript defaults
|
||||
globs: ["**/*.ts", "**/*.tsx"]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Prefer strict TypeScript and keep runtime imports ESM-compatible.
|
||||
```
|
||||
|
||||
## Install Locally
|
||||
|
||||
From the marketplace workspace:
|
||||
|
||||
```bash
|
||||
codex plugin marketplace add /Users/yeongyu/local-workspaces/codex-plugins
|
||||
node /Users/yeongyu/local-workspaces/codex-plugins/scripts/install-local.mjs /Users/yeongyu/local-workspaces/codex-plugins
|
||||
```
|
||||
|
||||
The local installer builds the plugin and copies a clean cache entry to:
|
||||
|
||||
```text
|
||||
~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo/0.1.0
|
||||
```
|
||||
|
||||
It also enables:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
plugins = true
|
||||
plugin_hooks = true
|
||||
|
||||
[plugins."omo@code-yeongyu-codex-plugins"]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Use `CODEX_RULES_*` environment variables:
|
||||
|
||||
| Variable | Values | Default |
|
||||
| --- | --- | --- |
|
||||
| `CODEX_RULES_DISABLED` | `1`, `true`, `yes`, `on` | unset |
|
||||
| `CODEX_RULES_MODE` | `both`, `static`, `dynamic`, `off` | `both` |
|
||||
| `CODEX_RULES_MAX_RULE_CHARS` | positive integer | `12000` |
|
||||
| `CODEX_RULES_MAX_RESULT_CHARS` | positive integer | `40000` |
|
||||
| `CODEX_RULES_ENABLED_SOURCES` | comma-separated source names | `auto` |
|
||||
|
||||
For migration from `pi-rules`, equivalent `PI_RULES_*` variables are accepted as fallbacks.
|
||||
|
||||
## Debugging
|
||||
|
||||
Enable hook phase timing with `NODE_DEBUG=codex-rules`:
|
||||
|
||||
```bash
|
||||
NODE_DEBUG=codex-rules node dist/cli.js hook post-tool-use < fixture.json
|
||||
```
|
||||
|
||||
Debug lines go to stderr and hook JSON stays on stdout. The log includes `PostToolUse` phases such as `extract`, `fingerprint`, `load`, `persist`, elapsed `ms`, target counts, pending counts, rule counts, and output bytes. It does not log rule bodies or tool response contents.
|
||||
|
||||
The default `PostToolUse` hook matcher is intentionally strict: it matches only Codex's canonical `apply_patch` hook tool name. Read tools, MCP filesystem tools, shell commands, and Claude-style `Write`/`Edit` aliases are not registered by default.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
npm run check
|
||||
npm run typecheck
|
||||
npm pack --dry-run
|
||||
```
|
||||
|
||||
Performance smoke test:
|
||||
|
||||
```bash
|
||||
npm run bench
|
||||
```
|
||||
|
||||
Benchmark timings depend on the local machine. Use the relative counters and repeat-output checks when comparing runs.
|
||||
|
||||
Hook smoke test:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
printf '%s\n' '{"session_id":"s","transcript_path":null,"cwd":"/path/to/project","hook_event_name":"SessionStart","model":"gpt-5.5","permission_mode":"default","source":"startup"}' \
|
||||
| PLUGIN_DATA=/tmp/codex-rules-data node dist/cli.js hook session-start
|
||||
```
|
||||
|
||||
## Privacy
|
||||
|
||||
`codex-rules` runs locally. It reads local rule files and Codex hook payloads, writes per-session deduplication state under the Codex plugin data directory, and does not make network requests.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noDefaultExport": "error",
|
||||
"noEnum": "error",
|
||||
"noNonNullAssertion": "error",
|
||||
"useImportType": "error",
|
||||
"useConst": "error",
|
||||
"useNodejsImportProtocol": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "error",
|
||||
"noTsIgnore": "error",
|
||||
"noControlCharactersInRegex": "off",
|
||||
"noEmptyInterface": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 3,
|
||||
"lineWidth": 120
|
||||
},
|
||||
"files": {
|
||||
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["vitest.config.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noDefaultExport": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook session-start",
|
||||
"timeout": 10,
|
||||
"statusMessage": "loading project rules"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit",
|
||||
"timeout": 10,
|
||||
"statusMessage": "loading project rules"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "^apply_patch$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use",
|
||||
"timeout": 10,
|
||||
"statusMessage": "matching project rules"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostCompact": [
|
||||
{
|
||||
"matcher": "manual|auto",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-compact",
|
||||
"timeout": 10,
|
||||
"statusMessage": "resetting project rule cache"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@code-yeongyu/codex-rules",
|
||||
"version": "0.1.0",
|
||||
"description": "Codex plugin that injects project rule files into model context through lifecycle hooks.",
|
||||
"type": "module",
|
||||
"packageManager": "npm@11.12.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/code-yeongyu/codex-rules",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/code-yeongyu/codex-rules.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/code-yeongyu/codex-rules/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"codex",
|
||||
"codex-plugin",
|
||||
"rules",
|
||||
"hooks",
|
||||
"agents-md",
|
||||
"context-injection",
|
||||
"typescript"
|
||||
],
|
||||
"bin": {
|
||||
"codex-rules": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"hooks",
|
||||
"skills",
|
||||
".codex-plugin",
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"bench": "npm run build --silent && node scripts/bench-codex-rules.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"check": "tsc --noEmit && biome check . && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runPostToolUseHook } from "../dist/codex-hook.js";
|
||||
import { createEngine, defaultConfig } from "../dist/rules/engine.js";
|
||||
|
||||
const ITERATIONS = 40;
|
||||
const WARMUP_ITERATIONS = 5;
|
||||
const RULE_COUNT = 120;
|
||||
const DISTINCT_TARGET_COUNT = 80;
|
||||
const DUPLICATE_TARGET_COUNT = 240;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const writeBaselinePath = readOption("--write-baseline");
|
||||
const comparePath = readOption("--compare");
|
||||
|
||||
const result = await runBenchmark();
|
||||
|
||||
if (writeBaselinePath !== undefined) {
|
||||
writeFileSync(writeBaselinePath, `${JSON.stringify(result, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
if (comparePath !== undefined) {
|
||||
const baseline = JSON.parse(readFileSync(comparePath, "utf8"));
|
||||
const failures = compareResults(baseline, result);
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
process.stderr.write(`${failure}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(result, null, "\t")}\n`);
|
||||
|
||||
function readOption(name) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) {
|
||||
throw new Error(`${name} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function runBenchmark() {
|
||||
const scenarios = [
|
||||
runScenario("duplicate-targets", duplicateTargets, DUPLICATE_TARGET_COUNT),
|
||||
runScenario("distinct-targets", distinctTargets, DISTINCT_TARGET_COUNT),
|
||||
];
|
||||
return {
|
||||
commit: gitCommit(),
|
||||
iterations: ITERATIONS,
|
||||
warmupIterations: WARMUP_ITERATIONS,
|
||||
ruleCount: RULE_COUNT,
|
||||
scenarios,
|
||||
hookFastPath: await runHookFastPathScenario(),
|
||||
};
|
||||
}
|
||||
|
||||
async function runHookFastPathScenario() {
|
||||
const durations = [];
|
||||
let repeatOutputBytes = 0;
|
||||
|
||||
for (let iteration = 0; iteration < ITERATIONS + WARMUP_ITERATIONS; iteration += 1) {
|
||||
const run = await measureHookFastPathRun();
|
||||
if (iteration >= WARMUP_ITERATIONS) {
|
||||
durations.push(run.repeatDurationMs);
|
||||
repeatOutputBytes += run.repeatOutputBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "repeat-post-tool-use",
|
||||
medianRepeatMs: median(durations),
|
||||
minRepeatMs: Math.min(...durations),
|
||||
maxRepeatMs: Math.max(...durations),
|
||||
repeatOutputBytes,
|
||||
};
|
||||
}
|
||||
|
||||
async function measureHookFastPathRun() {
|
||||
const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-hook-bench-"));
|
||||
const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-hook-data-"));
|
||||
try {
|
||||
mkdirSync(join(projectRoot, "src"), { recursive: true });
|
||||
mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true });
|
||||
writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "bench" }));
|
||||
writeFileSync(join(projectRoot, "src", "app.ts"), "export const app = true;\n");
|
||||
for (let index = 0; index < RULE_COUNT; index += 1) {
|
||||
writeFileSync(join(projectRoot, ".omo", "rules", `rule-${index}.md`), ruleContent(`rule-${index}`));
|
||||
}
|
||||
|
||||
const input = {
|
||||
session_id: "bench-session",
|
||||
turn_id: "bench-turn",
|
||||
transcript_path: null,
|
||||
cwd: projectRoot,
|
||||
hook_event_name: "PostToolUse",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "default",
|
||||
tool_name: "mcp__filesystem__read_file",
|
||||
tool_input: { path: join(projectRoot, "src", "app.ts") },
|
||||
tool_response: { text: "file contents" },
|
||||
tool_use_id: "bench-call",
|
||||
};
|
||||
|
||||
await runPostToolUseHook(input, {
|
||||
pluginDataRoot: pluginData,
|
||||
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
|
||||
});
|
||||
const start = process.hrtime.bigint();
|
||||
const repeatOutput = await runPostToolUseHook(input, {
|
||||
pluginDataRoot: pluginData,
|
||||
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
|
||||
});
|
||||
return {
|
||||
repeatDurationMs: Number(process.hrtime.bigint() - start) / 1_000_000,
|
||||
repeatOutputBytes: Buffer.byteLength(repeatOutput),
|
||||
};
|
||||
} finally {
|
||||
rmSync(projectRoot, { recursive: true, force: true });
|
||||
rmSync(pluginData, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runScenario(name, targetFactory, targetCount) {
|
||||
const durations = [];
|
||||
let counters = { findProjectRoot: 0, findCandidates: 0, readFile: 0 };
|
||||
|
||||
for (let iteration = 0; iteration < ITERATIONS + WARMUP_ITERATIONS; iteration += 1) {
|
||||
const run = measureRun(targetFactory);
|
||||
if (iteration >= WARMUP_ITERATIONS) {
|
||||
durations.push(run.durationMs);
|
||||
counters = addCounters(counters, run.counters);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
targetCount,
|
||||
medianMs: median(durations),
|
||||
minMs: Math.min(...durations),
|
||||
maxMs: Math.max(...durations),
|
||||
counters,
|
||||
};
|
||||
}
|
||||
|
||||
function measureRun(targetPaths) {
|
||||
const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-bench-"));
|
||||
try {
|
||||
const candidates = makeCandidates(projectRoot);
|
||||
mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true });
|
||||
for (const candidate of candidates) {
|
||||
writeFileSync(candidate.path, "");
|
||||
}
|
||||
const counters = { findProjectRoot: 0, findCandidates: 0, readFile: 0 };
|
||||
const engine = createEngine(defaultConfig(), {
|
||||
findProjectRoot: () => {
|
||||
counters.findProjectRoot += 1;
|
||||
return projectRoot;
|
||||
},
|
||||
findCandidates: () => {
|
||||
counters.findCandidates += 1;
|
||||
return candidates;
|
||||
},
|
||||
readFile: (path) => {
|
||||
counters.readFile += 1;
|
||||
return ruleContent(path);
|
||||
},
|
||||
});
|
||||
const generatedTargetPaths = targetPaths(projectRoot);
|
||||
const start = process.hrtime.bigint();
|
||||
engine.loadDynamicRules(projectRoot, generatedTargetPaths);
|
||||
const durationMs = Number(process.hrtime.bigint() - start) / 1_000_000;
|
||||
return { durationMs, counters };
|
||||
} finally {
|
||||
rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateTargets(projectRoot) {
|
||||
const targetPath = join(projectRoot, "src", "app.ts");
|
||||
return Array.from({ length: DUPLICATE_TARGET_COUNT }, () => targetPath);
|
||||
}
|
||||
|
||||
function distinctTargets(projectRoot) {
|
||||
return Array.from({ length: DISTINCT_TARGET_COUNT }, (_, index) => join(projectRoot, "src", `file-${index}.ts`));
|
||||
}
|
||||
|
||||
function makeCandidates(projectRoot) {
|
||||
return Array.from({ length: RULE_COUNT }, (_, index) => ({
|
||||
path: join(projectRoot, ".omo", "rules", `rule-${index}.md`),
|
||||
realPath: join(projectRoot, ".omo", "rules", `rule-${index}.md`),
|
||||
source: ".omo/rules",
|
||||
distance: 0,
|
||||
isGlobal: false,
|
||||
isSingleFile: false,
|
||||
relativePath: `.omo/rules/rule-${index}.md`,
|
||||
}));
|
||||
}
|
||||
|
||||
function ruleContent(path) {
|
||||
return ["---", "globs: **/*.ts", "---", "", `Rule from ${path}`].join("\n");
|
||||
}
|
||||
|
||||
function addCounters(left, right) {
|
||||
return {
|
||||
findProjectRoot: left.findProjectRoot + right.findProjectRoot,
|
||||
findCandidates: left.findCandidates + right.findCandidates,
|
||||
readFile: left.readFile + right.readFile,
|
||||
};
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.floor(sorted.length / 2);
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function gitCommit() {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function compareResults(baseline, current) {
|
||||
const failures = [];
|
||||
for (const scenario of current.scenarios) {
|
||||
const baselineScenario = baseline.scenarios.find((candidate) => candidate.name === scenario.name);
|
||||
if (baselineScenario === undefined) {
|
||||
failures.push(`missing baseline scenario: ${scenario.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const counterName of ["findProjectRoot", "findCandidates", "readFile"]) {
|
||||
if (scenario.counters[counterName] > baselineScenario.counters[counterName]) {
|
||||
failures.push(
|
||||
`${scenario.name}.${counterName} regressed: ${scenario.counters[counterName]} > ${baselineScenario.counters[counterName]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (baseline.hookFastPath === undefined) {
|
||||
failures.push("missing baseline hookFastPath scenario");
|
||||
} else {
|
||||
if (current.hookFastPath.repeatOutputBytes > baseline.hookFastPath.repeatOutputBytes) {
|
||||
failures.push(
|
||||
`hookFastPath.repeatOutputBytes regressed: ${current.hookFastPath.repeatOutputBytes} > ${baseline.hookFastPath.repeatOutputBytes}`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxMedianRepeatMs = baseline.hookFastPath.medianRepeatMs * 1.5;
|
||||
if (current.hookFastPath.medianRepeatMs > maxMedianRepeatMs) {
|
||||
failures.push(
|
||||
`hookFastPath.medianRepeatMs regressed: ${current.hookFastPath.medianRepeatMs} > ${maxMedianRepeatMs}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: rules
|
||||
description: Use when the user asks about Codex Rules behavior, injected project rules, supported rule file locations, matching, or environment configuration.
|
||||
---
|
||||
|
||||
# Codex Rules
|
||||
|
||||
Codex Rules is automatic once the plugin is enabled. It injects:
|
||||
|
||||
- static project instructions on `SessionStart` and `UserPromptSubmit`
|
||||
- matching file-specific rules after Codex `apply_patch` by default
|
||||
|
||||
Dynamic `PostToolUse` output is injected as additional context and is deduplicated per plugin data session. Codex Rules does not rewrite tool output.
|
||||
|
||||
Supported project sources:
|
||||
|
||||
- `AGENTS.md`
|
||||
- `CLAUDE.md`
|
||||
- `CONTEXT.md`
|
||||
- `.sisyphus/rules/**/*.md`
|
||||
- `.claude/rules/**/*.md`
|
||||
- `.cursor/rules/**/*.md`
|
||||
- `.github/instructions/**/*.md`
|
||||
- `.github/copilot-instructions.md`
|
||||
|
||||
Supported environment knobs:
|
||||
|
||||
- `CODEX_RULES_DISABLED=1`
|
||||
- `CODEX_RULES_MODE=both|static|dynamic|off`
|
||||
- `CODEX_RULES_MAX_RULE_CHARS=<number>`
|
||||
- `CODEX_RULES_MAX_RESULT_CHARS=<number>`
|
||||
- `CODEX_RULES_ENABLED_SOURCES=AGENTS.md,.sisyphus/rules`
|
||||
|
||||
The legacy `PI_RULES_*` variables are accepted as fallbacks for users migrating from `pi-rules`.
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import { stdin as processStdin, stdout as processStdout } from "node:process";
|
||||
|
||||
import {
|
||||
type CodexPostCompactInput,
|
||||
type CodexPostToolUseInput,
|
||||
type CodexRulesHookOptions,
|
||||
type CodexSessionStartInput,
|
||||
type CodexUserPromptSubmitInput,
|
||||
runPostCompactHook,
|
||||
runPostToolUseHook,
|
||||
runSessionStartHook,
|
||||
runUserPromptSubmitHook,
|
||||
} from "./codex-hook.js";
|
||||
|
||||
const command = process.argv[2];
|
||||
const subcommand = process.argv[3];
|
||||
type HookCliEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse" | "PostCompact";
|
||||
|
||||
if (command === "hook" && subcommand === "session-start") {
|
||||
await runHookCli("SessionStart");
|
||||
} else if (command === "hook" && subcommand === "user-prompt-submit") {
|
||||
await runHookCli("UserPromptSubmit");
|
||||
} else if (command === "hook" && subcommand === "post-tool-use") {
|
||||
await runHookCli("PostToolUse");
|
||||
} else if (command === "hook" && subcommand === "post-compact") {
|
||||
await runHookCli("PostCompact");
|
||||
} else {
|
||||
process.stderr.write("Usage: codex-rules hook [session-start|user-prompt-submit|post-tool-use|post-compact]\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function runHookCli(eventName: HookCliEventName): Promise<void> {
|
||||
const raw = await readStdin();
|
||||
if (raw.trim().length === 0) return;
|
||||
const parsed = parseHookInput(raw);
|
||||
if (!parsed) return;
|
||||
const pluginDataRoot = process.env["PLUGIN_DATA"];
|
||||
const options: CodexRulesHookOptions = pluginDataRoot === undefined ? {} : { pluginDataRoot };
|
||||
const output = await runHook(eventName, parsed, options);
|
||||
if (output.length > 0) {
|
||||
processStdout.write(output);
|
||||
}
|
||||
}
|
||||
|
||||
async function runHook(eventName: HookCliEventName, parsed: unknown, options: CodexRulesHookOptions): Promise<string> {
|
||||
switch (eventName) {
|
||||
case "SessionStart":
|
||||
return isCodexSessionStartInput(parsed) ? await runSessionStartHook(parsed, options) : "";
|
||||
case "UserPromptSubmit":
|
||||
return isCodexUserPromptSubmitInput(parsed) ? await runUserPromptSubmitHook(parsed, options) : "";
|
||||
case "PostToolUse":
|
||||
return isCodexPostToolUseInput(parsed) ? await runPostToolUseHook(parsed, options) : "";
|
||||
case "PostCompact":
|
||||
return isCodexPostCompactInput(parsed) ? await runPostCompactHook(parsed, options) : "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseHookInput(raw: string): unknown | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isCodexSessionStartInput(value: unknown): value is CodexSessionStartInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "SessionStart" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["source"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexUserPromptSubmitInput(value: unknown): value is CodexUserPromptSubmitInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "UserPromptSubmit" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["prompt"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "PostToolUse" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["tool_name"] === "string" &&
|
||||
typeof value["tool_use_id"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexPostCompactInput(value: unknown): value is CodexPostCompactInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "PostCompact" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
(value["trigger"] === "manual" || value["trigger"] === "auto")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringOrNull(value: unknown): value is string | null {
|
||||
return typeof value === "string" || value === null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
processStdin.setEncoding("utf8");
|
||||
processStdin.on("data", (chunk: string) => {
|
||||
data += chunk;
|
||||
});
|
||||
processStdin.once("error", reject);
|
||||
processStdin.once("end", () => {
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
import { configFromEnvironment } from "./config.js";
|
||||
import { createHookDebugTimer } from "./debug-log.js";
|
||||
import {
|
||||
clearSessionState,
|
||||
hasPostCompactPending,
|
||||
hydrateEngineState,
|
||||
isPostCompactPending,
|
||||
markSessionCompacted,
|
||||
persistEngineState,
|
||||
sessionCachePath,
|
||||
} from "./persistent-cache.js";
|
||||
import { SOURCE_PRIORITY } from "./rules/constants.js";
|
||||
import { createEngine } from "./rules/engine.js";
|
||||
import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js";
|
||||
import { hashContent } from "./rules/matcher.js";
|
||||
import { sortCandidates } from "./rules/ordering.js";
|
||||
import { findProjectRoot } from "./rules/project-root.js";
|
||||
import type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js";
|
||||
import { extractCodexToolPaths } from "./tool-paths.js";
|
||||
|
||||
type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
|
||||
|
||||
export type CodexSessionStartInput = {
|
||||
session_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "SessionStart";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
source: "startup" | "resume" | "clear";
|
||||
};
|
||||
|
||||
export type CodexUserPromptSubmitInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "UserPromptSubmit";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type CodexPostToolUseInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "PostToolUse";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
tool_response: unknown;
|
||||
tool_use_id: string;
|
||||
};
|
||||
|
||||
export type CodexPostCompactInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "PostCompact";
|
||||
model: string;
|
||||
trigger: "manual" | "auto";
|
||||
};
|
||||
|
||||
export interface CodexRulesHookOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pluginDataRoot?: string;
|
||||
}
|
||||
|
||||
interface DynamicTargetFingerprint {
|
||||
targetPath: string;
|
||||
cacheKey: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export async function runSessionStartHook(
|
||||
input: CodexSessionStartInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||
if (input.source === "clear") {
|
||||
clearSessionState(cachePath);
|
||||
} else if (input.source !== "resume" && !hasPostCompactPending(cachePath)) {
|
||||
clearSessionState(cachePath);
|
||||
}
|
||||
const postCompactPending = input.source !== "clear" && isPostCompactPending(cachePath, "static");
|
||||
const transcriptPath = input.source === "clear" || postCompactPending ? null : input.transcript_path;
|
||||
return runStaticInjection(
|
||||
input.cwd,
|
||||
transcriptPath,
|
||||
"SessionStart",
|
||||
cachePath,
|
||||
options,
|
||||
postCompactPending ? "static" : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPostCompactHook(
|
||||
input: CodexPostCompactInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
markSessionCompacted(sessionCachePath(input.session_id, options.pluginDataRoot));
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function runUserPromptSubmitHook(
|
||||
input: CodexUserPromptSubmitInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||
const postCompactPending = isPostCompactPending(cachePath, "static");
|
||||
const transcriptPath = postCompactPending ? null : input.transcript_path;
|
||||
return runStaticInjection(
|
||||
input.cwd,
|
||||
transcriptPath,
|
||||
"UserPromptSubmit",
|
||||
cachePath,
|
||||
options,
|
||||
postCompactPending ? "static" : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPostToolUseHook(
|
||||
input: CodexPostToolUseInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
const debugTimer = createHookDebugTimer("PostToolUse");
|
||||
const config = configFromEnvironment(options.env);
|
||||
debugTimer.lap("config", { disabled: config.disabled, mode: config.mode });
|
||||
if (config.disabled || config.mode === "off" || config.mode === "static") {
|
||||
debugTimer.done({ outputBytes: 0, reason: "disabled" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const targetPaths = extractCodexToolPaths(input, input.cwd);
|
||||
debugTimer.lap("extract", {
|
||||
targets: targetPaths.length,
|
||||
uniqueTargets: uniqueStrings(targetPaths).length,
|
||||
tool: input.tool_name,
|
||||
});
|
||||
const firstTargetPath = targetPaths[0];
|
||||
if (firstTargetPath === undefined) {
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-target" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||
const postCompactPending = isPostCompactPending(cachePath, "dynamic");
|
||||
const transcriptPath = postCompactPending ? null : input.transcript_path;
|
||||
const engine = createRulesEngine(options);
|
||||
hydrateEngineState(engine, cachePath);
|
||||
debugTimer.lap("hydrate", {
|
||||
dynamicDedupScopes: engine.state.dynamicDedup.size,
|
||||
dynamicTargetFingerprints: engine.state.dynamicTargetFingerprints.size,
|
||||
staticDedup: engine.state.staticDedup.size,
|
||||
});
|
||||
const dynamicTargetFingerprints = fingerprintDynamicTargets(input.cwd, targetPaths, config);
|
||||
debugTimer.lap("fingerprint", { fingerprints: dynamicTargetFingerprints.length });
|
||||
const pendingTargetFingerprints = dynamicTargetFingerprints.filter(
|
||||
(target) => engine.state.dynamicTargetFingerprints.get(target.cacheKey) !== target.fingerprint,
|
||||
);
|
||||
debugTimer.lap("pending", { pending: pendingTargetFingerprints.length });
|
||||
if (pendingTargetFingerprints.length === 0) {
|
||||
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
|
||||
debugTimer.lap("persist", { reason: "no-pending" });
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-pending" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const loaded = engine.loadDynamicRules(
|
||||
input.cwd,
|
||||
pendingTargetFingerprints.map((target) => target.targetPath),
|
||||
);
|
||||
debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length });
|
||||
const rules = filterRulesAlreadyInTranscript(
|
||||
loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)),
|
||||
transcriptPath,
|
||||
(rule) => {
|
||||
engine.markDynamicInjected(rule);
|
||||
},
|
||||
);
|
||||
debugTimer.lap("filter", { rules: rules.length });
|
||||
for (const target of pendingTargetFingerprints) {
|
||||
engine.state.dynamicTargetFingerprints.set(target.cacheKey, target.fingerprint);
|
||||
}
|
||||
if (rules.length === 0) {
|
||||
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
|
||||
debugTimer.lap("persist", { reason: "no-rules" });
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-rules" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const firstPendingTargetPath = pendingTargetFingerprints[0]?.targetPath ?? firstTargetPath;
|
||||
const block = engine.formatDynamic(rules, displayPath(input.cwd, firstPendingTargetPath));
|
||||
debugTimer.lap("format", { blockChars: block.length, rules: rules.length });
|
||||
for (const rule of rules) {
|
||||
engine.markDynamicInjected(rule);
|
||||
}
|
||||
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
|
||||
debugTimer.lap("persist", { reason: "emit" });
|
||||
const output = formatAdditionalContextOutput("PostToolUse", block);
|
||||
debugTimer.done({ outputBytes: Buffer.byteLength(output), reason: "emit" });
|
||||
return output;
|
||||
}
|
||||
|
||||
function runStaticInjection(
|
||||
cwd: string,
|
||||
transcriptPath: string | null,
|
||||
eventName: "SessionStart" | "UserPromptSubmit",
|
||||
cachePath: string,
|
||||
options: CodexRulesHookOptions,
|
||||
completedPostCompactChannel?: "static",
|
||||
): string {
|
||||
const config = configFromEnvironment(options.env);
|
||||
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const engine = createRulesEngine(options);
|
||||
hydrateEngineState(engine, cachePath);
|
||||
engine.state.cwd = cwd;
|
||||
|
||||
const loaded = engine.loadStaticRules(cwd);
|
||||
const rules = filterRulesAlreadyInTranscript(
|
||||
loaded.rules.filter((rule) => !engine.isStaticInjected(rule)),
|
||||
transcriptPath,
|
||||
(rule) => {
|
||||
engine.markStaticInjected(rule);
|
||||
},
|
||||
);
|
||||
if (rules.length === 0) {
|
||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||
return "";
|
||||
}
|
||||
|
||||
const block = engine.formatStatic(rules);
|
||||
for (const rule of rules) {
|
||||
engine.markStaticInjected(rule);
|
||||
}
|
||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||
return formatAdditionalContextOutput(eventName, block);
|
||||
}
|
||||
|
||||
function filterRulesAlreadyInTranscript(
|
||||
rules: ReadonlyArray<LoadedRule>,
|
||||
transcriptPath: string | null,
|
||||
markInjected: (rule: LoadedRule) => void,
|
||||
): LoadedRule[] {
|
||||
if (rules.length === 0 || transcriptPath === null) {
|
||||
return [...rules];
|
||||
}
|
||||
|
||||
const transcriptText = readTranscriptSearchText(transcriptPath);
|
||||
if (transcriptText === null) {
|
||||
return [...rules];
|
||||
}
|
||||
|
||||
const pendingRules: LoadedRule[] = [];
|
||||
for (const rule of rules) {
|
||||
if (isRuleAlreadyInTranscript(rule, transcriptText)) {
|
||||
markInjected(rule);
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingRules.push(rule);
|
||||
}
|
||||
return pendingRules;
|
||||
}
|
||||
|
||||
function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): boolean {
|
||||
const bodyNeedle = rule.body.trim().slice(0, 2_000);
|
||||
if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const markers = [
|
||||
`Instructions from: ${rule.path}`,
|
||||
`Instructions from: ${rule.realPath}`,
|
||||
rule.relativePath.length === 0 ? null : rule.relativePath,
|
||||
].filter((marker): marker is string => marker !== null);
|
||||
return markers.some((marker) => transcriptText.includes(marker));
|
||||
}
|
||||
|
||||
function readTranscriptSearchText(transcriptPath: string): string | null {
|
||||
try {
|
||||
const rawTranscript = readFileSync(transcriptPath, "utf8");
|
||||
return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectJsonLineStrings(rawTranscript: string): string[] {
|
||||
const values: string[] = [];
|
||||
for (const line of rawTranscript.split(/\r?\n/)) {
|
||||
if (line.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
collectStrings(parsed, values);
|
||||
} catch {
|
||||
// Non-JSON transcript lines are still covered by the raw transcript text.
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function collectStrings(value: unknown, output: string[]): void {
|
||||
if (typeof value === "string") {
|
||||
output.push(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectStrings(item, output);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of Object.values(value)) {
|
||||
collectStrings(item, output);
|
||||
}
|
||||
}
|
||||
|
||||
function createRulesEngine(options: CodexRulesHookOptions) {
|
||||
const config = configFromEnvironment(options.env);
|
||||
return createEngine(config, {
|
||||
findCandidates: findRuleCandidates,
|
||||
findProjectRoot,
|
||||
readFile: (path) => {
|
||||
try {
|
||||
return readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function fingerprintDynamicTargets(
|
||||
cwd: string,
|
||||
targetPaths: ReadonlyArray<string>,
|
||||
config: PiRulesConfig,
|
||||
): DynamicTargetFingerprint[] {
|
||||
const disabledSources = disabledSourcesFor(config);
|
||||
const discoveryCache = createRuleDiscoveryCache();
|
||||
const cwdProjectRoot = findProjectRoot(cwd);
|
||||
const fingerprints: DynamicTargetFingerprint[] = [];
|
||||
|
||||
for (const targetPath of uniqueStrings(targetPaths)) {
|
||||
const projectRoot =
|
||||
cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot)
|
||||
? cwdProjectRoot
|
||||
: findProjectRoot(targetPath);
|
||||
const findOptions: {
|
||||
projectRoot: string | null;
|
||||
targetFile: string;
|
||||
disabledSources?: ReadonlySet<string>;
|
||||
cache: ReturnType<typeof createRuleDiscoveryCache>;
|
||||
} = {
|
||||
projectRoot,
|
||||
targetFile: targetPath,
|
||||
cache: discoveryCache,
|
||||
};
|
||||
if (disabledSources !== undefined) {
|
||||
findOptions.disabledSources = disabledSources;
|
||||
}
|
||||
const candidates = findRuleCandidates(findOptions);
|
||||
const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001");
|
||||
const cacheKey = dynamicTargetCacheKey(targetPath);
|
||||
fingerprints.push({
|
||||
targetPath,
|
||||
cacheKey,
|
||||
fingerprint: hashContent(
|
||||
[
|
||||
"v1",
|
||||
config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","),
|
||||
projectRoot ?? "",
|
||||
cacheKey,
|
||||
candidateFingerprint,
|
||||
].join("\u0000"),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return fingerprints;
|
||||
}
|
||||
|
||||
function fingerprintCandidate(candidate: RuleCandidate): string {
|
||||
return [
|
||||
candidate.realPath,
|
||||
candidate.relativePath,
|
||||
candidate.source,
|
||||
candidate.isGlobal ? "global" : "project",
|
||||
candidate.isSingleFile ? "single" : "multi",
|
||||
String(candidate.distance),
|
||||
fileFingerprint(candidate.path),
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
function fileFingerprint(filePath: string): string {
|
||||
try {
|
||||
const stats = statSync(filePath, { bigint: true });
|
||||
return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`;
|
||||
} catch {
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
|
||||
if (config.enabledSources === "auto") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const enabledSources = new Set(config.enabledSources);
|
||||
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
|
||||
}
|
||||
|
||||
function dynamicTargetCacheKey(targetPath: string): string {
|
||||
return toPosixPath(resolve(targetPath));
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, resolve(childPath));
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
|
||||
}
|
||||
|
||||
function uniqueStrings(values: ReadonlyArray<string>): string[] {
|
||||
const uniqueValues: string[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seenValues.has(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(value);
|
||||
uniqueValues.push(value);
|
||||
}
|
||||
return uniqueValues;
|
||||
}
|
||||
|
||||
function formatAdditionalContextOutput(eventName: ContextInjectionHookEventName, additionalContext: string): string {
|
||||
if (additionalContext.trim().length === 0) return "";
|
||||
return `${JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: eventName,
|
||||
additionalContext,
|
||||
},
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
function displayPath(cwd: string, filePath: string): string {
|
||||
const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath;
|
||||
// Normalize to POSIX separators so injected rule context renders the same
|
||||
// path string on Linux/macOS and Windows (Codex feeds this verbatim into
|
||||
// the model prompt, and the existing engine already emits POSIX paths).
|
||||
return toPosixPath(rel);
|
||||
}
|
||||
|
||||
function toPosixPath(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { SOURCE_PRIORITY } from "./rules/constants.js";
|
||||
import { defaultConfig } from "./rules/engine.js";
|
||||
import type { PiRulesConfig, RuleSource } from "./rules/types.js";
|
||||
|
||||
const MODE_VALUES = new Set<PiRulesConfig["mode"]>(["static", "dynamic", "both", "off"]);
|
||||
|
||||
export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiRulesConfig {
|
||||
const config = defaultConfig();
|
||||
config.disabled = isTruthy(firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED"));
|
||||
config.mode = parseMode(firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")) ?? config.mode;
|
||||
config.maxRuleChars =
|
||||
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")) ??
|
||||
config.maxRuleChars;
|
||||
config.maxResultChars =
|
||||
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ??
|
||||
config.maxResultChars;
|
||||
config.enabledSources = parseEnabledSources(
|
||||
firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"),
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
function firstEnv(env: NodeJS.ProcessEnv, ...names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const value = env[name];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTruthy(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseMode(value: string | undefined): PiRulesConfig["mode"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return MODE_VALUES.has(normalized as PiRulesConfig["mode"]) ? (normalized as PiRulesConfig["mode"]) : undefined;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseEnabledSources(value: string | undefined): RuleSource[] | "auto" {
|
||||
if (value === undefined || value.trim().toLowerCase() === "auto") {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
const validSources = new Set(SOURCE_PRIORITY.keys());
|
||||
const sources: RuleSource[] = [];
|
||||
for (const rawSource of value.split(",")) {
|
||||
const source = rawSource.trim();
|
||||
if (!validSources.has(source as RuleSource)) {
|
||||
continue;
|
||||
}
|
||||
sources.push(source as RuleSource);
|
||||
}
|
||||
return sources.length > 0 ? sources : "auto";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { debuglog } from "node:util";
|
||||
|
||||
type DebugFieldValue = boolean | number | string | null;
|
||||
|
||||
type DebugFields = Record<string, DebugFieldValue>;
|
||||
|
||||
const debug = debuglog("codex-rules");
|
||||
const noopTimer: HookDebugTimer = {
|
||||
lap: () => {},
|
||||
done: () => {},
|
||||
};
|
||||
|
||||
export interface HookDebugTimer {
|
||||
lap(phase: string, fields?: DebugFields): void;
|
||||
done(fields?: DebugFields): void;
|
||||
}
|
||||
|
||||
export function createHookDebugTimer(hookName: string): HookDebugTimer {
|
||||
if (!debug.enabled) {
|
||||
return noopTimer;
|
||||
}
|
||||
|
||||
const startMs = performance.now();
|
||||
let lastMs = startMs;
|
||||
|
||||
return {
|
||||
lap: (phase, fields = {}) => {
|
||||
const nowMs = performance.now();
|
||||
writeDebugLine(hookName, phase, nowMs - lastMs, nowMs - startMs, fields);
|
||||
lastMs = nowMs;
|
||||
},
|
||||
done: (fields = {}) => {
|
||||
const nowMs = performance.now();
|
||||
writeDebugLine(hookName, "done", nowMs - lastMs, nowMs - startMs, fields);
|
||||
lastMs = nowMs;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeDebugLine(
|
||||
hookName: string,
|
||||
phase: string,
|
||||
durationMs: number,
|
||||
totalMs: number,
|
||||
fields: DebugFields,
|
||||
): void {
|
||||
debug(
|
||||
"%s phase=%s ms=%s total_ms=%s%s",
|
||||
hookName,
|
||||
phase,
|
||||
durationMs.toFixed(3),
|
||||
totalMs.toFixed(3),
|
||||
formatFields(fields),
|
||||
);
|
||||
}
|
||||
|
||||
function formatFields(fields: DebugFields): string {
|
||||
const entries = Object.entries(fields);
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return ` ${entries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}`;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { Engine } from "./rules/engine.js";
|
||||
|
||||
export type PostCompactPendingKind = "static" | "dynamic";
|
||||
|
||||
interface PostCompactPendingState {
|
||||
static?: boolean;
|
||||
dynamic?: boolean;
|
||||
}
|
||||
|
||||
interface SerializedSessionState {
|
||||
staticDedup: string[];
|
||||
dynamicDedup: Record<string, string[]>;
|
||||
dynamicTargetFingerprints?: Record<string, string>;
|
||||
postCompactPending?: PostCompactPendingState;
|
||||
compacted?: boolean;
|
||||
}
|
||||
|
||||
export function hydrateEngineState(engine: Engine, cachePath: string): void {
|
||||
const state = readSessionState(cachePath);
|
||||
engine.state.staticDedup.clear();
|
||||
engine.state.dynamicDedup.clear();
|
||||
engine.state.dynamicTargetFingerprints.clear();
|
||||
|
||||
for (const key of state.staticDedup) {
|
||||
engine.state.staticDedup.add(key);
|
||||
}
|
||||
for (const [scope, keys] of Object.entries(state.dynamicDedup)) {
|
||||
engine.state.dynamicDedup.set(scope, new Set(keys));
|
||||
}
|
||||
for (const [targetKey, fingerprint] of Object.entries(state.dynamicTargetFingerprints ?? {})) {
|
||||
engine.state.dynamicTargetFingerprints.set(targetKey, fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistEngineState(
|
||||
engine: Engine,
|
||||
cachePath: string,
|
||||
completedPostCompactKind?: PostCompactPendingKind,
|
||||
): void {
|
||||
const currentState = readSessionState(cachePath);
|
||||
const dynamicDedup: Record<string, string[]> = {};
|
||||
for (const [scope, keys] of engine.state.dynamicDedup.entries()) {
|
||||
dynamicDedup[scope] = [...keys];
|
||||
}
|
||||
|
||||
const postCompactPending = nextPostCompactPending(currentState, completedPostCompactKind);
|
||||
writeSessionState(cachePath, {
|
||||
staticDedup: [...engine.state.staticDedup],
|
||||
dynamicDedup,
|
||||
dynamicTargetFingerprints: Object.fromEntries(engine.state.dynamicTargetFingerprints.entries()),
|
||||
...(postCompactPending === undefined ? {} : { postCompactPending }),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionState(cachePath: string): void {
|
||||
rmSync(cachePath, { force: true });
|
||||
}
|
||||
|
||||
export function markSessionCompacted(cachePath: string): void {
|
||||
writeSessionState(cachePath, { ...emptyState(), postCompactPending: { static: true, dynamic: true } });
|
||||
}
|
||||
|
||||
export function hasPostCompactPending(cachePath: string): boolean {
|
||||
return postCompactPendingKinds(readSessionState(cachePath)).size > 0;
|
||||
}
|
||||
|
||||
export function isPostCompactPending(cachePath: string, kind: PostCompactPendingKind): boolean {
|
||||
return postCompactPendingKinds(readSessionState(cachePath)).has(kind);
|
||||
}
|
||||
|
||||
export function sessionCachePath(sessionId: string, pluginDataRoot: string | undefined): string {
|
||||
const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules");
|
||||
return join(root, "sessions", `${safePathSegment(sessionId)}.json`);
|
||||
}
|
||||
|
||||
function readSessionState(cachePath: string): SerializedSessionState {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
|
||||
if (!isSerializedSessionState(parsed)) return emptyState();
|
||||
return parsed;
|
||||
} catch {
|
||||
return emptyState();
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionState(cachePath: string, state: SerializedSessionState): void {
|
||||
mkdirSync(dirname(cachePath), { recursive: true });
|
||||
writeFileSync(cachePath, `${JSON.stringify(state)}\n`);
|
||||
}
|
||||
|
||||
function emptyState(): SerializedSessionState {
|
||||
return { staticDedup: [], dynamicDedup: {}, dynamicTargetFingerprints: {} };
|
||||
}
|
||||
|
||||
function nextPostCompactPending(
|
||||
state: SerializedSessionState,
|
||||
completedKind: PostCompactPendingKind | undefined,
|
||||
): PostCompactPendingState | undefined {
|
||||
const pendingKinds = postCompactPendingKinds(state);
|
||||
if (completedKind !== undefined) {
|
||||
pendingKinds.delete(completedKind);
|
||||
}
|
||||
|
||||
if (pendingKinds.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(pendingKinds.has("static") ? { static: true } : {}),
|
||||
...(pendingKinds.has("dynamic") ? { dynamic: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function postCompactPendingKinds(state: SerializedSessionState): Set<PostCompactPendingKind> {
|
||||
const pendingKinds = new Set<PostCompactPendingKind>();
|
||||
if (state.compacted === true || state.postCompactPending?.static === true) {
|
||||
pendingKinds.add("static");
|
||||
}
|
||||
if (state.compacted === true || state.postCompactPending?.dynamic === true) {
|
||||
pendingKinds.add("dynamic");
|
||||
}
|
||||
return pendingKinds;
|
||||
}
|
||||
|
||||
function safePathSegment(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session";
|
||||
}
|
||||
|
||||
function isSerializedSessionState(value: unknown): value is SerializedSessionState {
|
||||
if (!isRecord(value) || !Array.isArray(value["staticDedup"]) || !isRecord(value["dynamicDedup"])) {
|
||||
return false;
|
||||
}
|
||||
const staticDedup = value["staticDedup"];
|
||||
const dynamicDedup = value["dynamicDedup"];
|
||||
const dynamicTargetFingerprints = value["dynamicTargetFingerprints"];
|
||||
const postCompactPending = value["postCompactPending"];
|
||||
const compacted = value["compacted"];
|
||||
return (
|
||||
staticDedup.every((item) => typeof item === "string") &&
|
||||
Object.values(dynamicDedup).every(
|
||||
(item) => Array.isArray(item) && item.every((nestedItem) => typeof nestedItem === "string"),
|
||||
) &&
|
||||
(dynamicTargetFingerprints === undefined ||
|
||||
(isRecord(dynamicTargetFingerprints) &&
|
||||
Object.entries(dynamicTargetFingerprints).every(
|
||||
([targetKey, fingerprint]) => typeof targetKey === "string" && typeof fingerprint === "string",
|
||||
))) &&
|
||||
(postCompactPending === undefined || isPostCompactPendingState(postCompactPending)) &&
|
||||
(compacted === undefined || typeof compacted === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function isPostCompactPendingState(value: unknown): value is PostCompactPendingState {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
(value["static"] === undefined || typeof value["static"] === "boolean") &&
|
||||
(value["dynamic"] === undefined || typeof value["dynamic"] === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { LoadedRule, SessionState } from "./types.js";
|
||||
|
||||
const DYNAMIC_SESSION_KEY = "__pi-rules-session__";
|
||||
|
||||
export function createSessionState(cwd?: string): SessionState {
|
||||
return {
|
||||
cwd,
|
||||
staticDedup: new Set(),
|
||||
dynamicDedup: new Map(),
|
||||
dynamicTargetFingerprints: new Map(),
|
||||
loadedRules: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function staticDedupKey(cwd: string, rulePath: string, contentHash: string): string {
|
||||
return `${cwd}::${rulePath}::${contentHash}`;
|
||||
}
|
||||
|
||||
export function dynamicDedupKey(rulePath: string, contentHash: string): string {
|
||||
return `${rulePath}::${contentHash}`;
|
||||
}
|
||||
|
||||
export function markStaticInjected(state: SessionState, rule: LoadedRule): boolean {
|
||||
const key = staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash);
|
||||
if (state.staticDedup.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.staticDedup.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function markDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
|
||||
let keys = state.dynamicDedup.get(DYNAMIC_SESSION_KEY);
|
||||
if (keys === undefined) {
|
||||
keys = new Set();
|
||||
state.dynamicDedup.set(DYNAMIC_SESSION_KEY, keys);
|
||||
}
|
||||
|
||||
const key = dynamicDedupKey(rule.realPath, rule.contentHash);
|
||||
if (keys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
keys.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isStaticInjected(state: SessionState, rule: LoadedRule): boolean {
|
||||
return state.staticDedup.has(staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash));
|
||||
}
|
||||
|
||||
export function isDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
|
||||
return state.dynamicDedup.get(DYNAMIC_SESSION_KEY)?.has(dynamicDedupKey(rule.realPath, rule.contentHash)) === true;
|
||||
}
|
||||
|
||||
export function clearSession(state: SessionState): void {
|
||||
state.staticDedup.clear();
|
||||
state.dynamicDedup.clear();
|
||||
state.dynamicTargetFingerprints.clear();
|
||||
state.loadedRules.length = 0;
|
||||
state.diagnostics.length = 0;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { RuleSource } from "./types.js";
|
||||
|
||||
/**
|
||||
* Project root marker files / directories used by `findProjectRoot`.
|
||||
* Walks UP from cwd until any of these is found in the directory.
|
||||
*/
|
||||
export const PROJECT_MARKERS: readonly string[] = [
|
||||
".git",
|
||||
"pnpm-workspace.yaml",
|
||||
"package.json",
|
||||
"pyproject.toml",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
".venv",
|
||||
];
|
||||
|
||||
/**
|
||||
* Project rule subdirectories. First tuple element is the parent dir under
|
||||
* the project root, second is the subdir scanned recursively.
|
||||
*/
|
||||
export const PROJECT_RULE_SUBDIRS: ReadonlyArray<readonly [string, string]> = [
|
||||
[".omo", "rules"],
|
||||
[".claude", "rules"],
|
||||
[".cursor", "rules"],
|
||||
[".github", "instructions"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Single-file project rules (always apply, frontmatter optional).
|
||||
*/
|
||||
export const PROJECT_SINGLE_FILES: readonly string[] = [
|
||||
".github/copilot-instructions.md",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md",
|
||||
];
|
||||
|
||||
/**
|
||||
* User-home rule directories.
|
||||
*/
|
||||
export const USER_HOME_RULE_SUBDIRS: readonly string[] = [".omo/rules", ".opencode/rules", ".claude/rules"];
|
||||
|
||||
/**
|
||||
* User-home single-file rules. The first one to exist wins per "first-match" semantics.
|
||||
*/
|
||||
export const USER_HOME_SINGLE_FILES: readonly string[] = [".config/opencode/AGENTS.md", ".claude/CLAUDE.md"];
|
||||
|
||||
/**
|
||||
* File extensions accepted as rule files in scanned directories.
|
||||
*/
|
||||
export const RULE_FILE_EXTENSIONS: readonly string[] = [".md", ".mdc"];
|
||||
|
||||
/**
|
||||
* Per-rule source priority for deterministic ordering. Lower = earlier.
|
||||
*/
|
||||
export const SOURCE_PRIORITY: ReadonlyMap<RuleSource, number> = new Map([
|
||||
[".omo/rules", 0],
|
||||
[".claude/rules", 1],
|
||||
[".cursor/rules", 2],
|
||||
[".github/instructions", 3],
|
||||
[".github/copilot-instructions.md", 4],
|
||||
["AGENTS.md", 5],
|
||||
["CLAUDE.md", 6],
|
||||
["CONTEXT.md", 7],
|
||||
["~/.omo/rules", 100],
|
||||
["~/.opencode/rules", 101],
|
||||
["~/.claude/rules", 102],
|
||||
["~/.config/opencode/AGENTS.md", 103],
|
||||
["~/.claude/CLAUDE.md", 104],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Distance value assigned to global / user-home rules.
|
||||
*/
|
||||
export const GLOBAL_DISTANCE = 9999;
|
||||
|
||||
/**
|
||||
* Per-rule body character cap (default).
|
||||
*/
|
||||
export const DEFAULT_MAX_RULE_CHARS = 12000;
|
||||
|
||||
export const DEFAULT_MAX_SCAN_FILES = 1000;
|
||||
|
||||
/**
|
||||
* Total injected chars per tool result (default).
|
||||
*/
|
||||
export const DEFAULT_MAX_RESULT_CHARS = 40000;
|
||||
|
||||
/**
|
||||
* Truncation marker template. `{path}` is replaced with the relative path.
|
||||
*/
|
||||
export const TRUNCATION_NOTICE = "\n\n[Rule truncated. Read full rule: {path}]";
|
||||
|
||||
/**
|
||||
* Directories excluded by the recursive scanner regardless of glob settings.
|
||||
*/
|
||||
export const SCANNER_EXCLUDED_DIRS: readonly string[] = [
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
".turbo",
|
||||
".next",
|
||||
"coverage",
|
||||
];
|
||||
@@ -0,0 +1,531 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
clearSession,
|
||||
createSessionState,
|
||||
isDynamicInjected as isDynamicInjectedInState,
|
||||
isStaticInjected as isStaticInjectedInState,
|
||||
markDynamicInjected as markDynamicInjectedInState,
|
||||
markStaticInjected as markStaticInjectedInState,
|
||||
} from "./cache.js";
|
||||
import {
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
DEFAULT_MAX_RULE_CHARS,
|
||||
PROJECT_SINGLE_FILES,
|
||||
SOURCE_PRIORITY,
|
||||
} from "./constants.js";
|
||||
import { createRuleDiscoveryCache, type RuleDiscoveryCache } from "./finder.js";
|
||||
import { formatDynamicBlock, formatStaticBlock } from "./formatter.js";
|
||||
import { hashContent, matchRule } from "./matcher.js";
|
||||
import { sortCandidates } from "./ordering.js";
|
||||
import { parseRule } from "./parser.js";
|
||||
import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js";
|
||||
|
||||
interface LoadedRuleContent {
|
||||
frontmatter: LoadedRule["frontmatter"];
|
||||
body: string;
|
||||
contentHash: string;
|
||||
diagnostic?: string;
|
||||
}
|
||||
|
||||
type CandidateProjectMembership = Map<string, boolean>;
|
||||
type CandidateDiscoveryCache = Map<string, RuleCandidate[]>;
|
||||
type DynamicMatchCache = Map<string, MatchReason | null>;
|
||||
|
||||
const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096;
|
||||
|
||||
export interface EngineDeps {
|
||||
findCandidates: (options: {
|
||||
projectRoot: string | null;
|
||||
targetFile: string | null;
|
||||
homeDir?: string;
|
||||
disabledSources?: ReadonlySet<string>;
|
||||
skipUserHome?: boolean;
|
||||
cache?: RuleDiscoveryCache;
|
||||
}) => RuleCandidate[];
|
||||
readFile: (path: string) => string | null;
|
||||
findProjectRoot: (startPath: string) => string | null;
|
||||
matchRule?: typeof matchRule;
|
||||
}
|
||||
|
||||
export interface Engine {
|
||||
state: SessionState;
|
||||
config: PiRulesConfig;
|
||||
loadStaticRules(cwd: string): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] };
|
||||
loadDynamicRules(
|
||||
cwd: string,
|
||||
targetPaths: ReadonlyArray<string>,
|
||||
): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] };
|
||||
formatStatic(rules: ReadonlyArray<LoadedRule>): string;
|
||||
formatDynamic(rules: ReadonlyArray<LoadedRule>, target: string): string;
|
||||
resetSession(cwd?: string): void;
|
||||
isStaticInjected(rule: LoadedRule): boolean;
|
||||
isDynamicInjected(rule: LoadedRule): boolean;
|
||||
markStaticInjected(rule: LoadedRule): boolean;
|
||||
markDynamicInjected(rule: LoadedRule): boolean;
|
||||
}
|
||||
|
||||
const ROOT_SINGLE_FILE_SOURCES = new Set(PROJECT_SINGLE_FILES.filter((source) => !source.includes("/")));
|
||||
|
||||
export function defaultConfig(): PiRulesConfig {
|
||||
return {
|
||||
disabled: false,
|
||||
mode: "both",
|
||||
maxRuleChars: DEFAULT_MAX_RULE_CHARS,
|
||||
maxResultChars: DEFAULT_MAX_RESULT_CHARS,
|
||||
enabledSources: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
|
||||
const state = createSessionState();
|
||||
const dynamicMatchCache: DynamicMatchCache = new Map();
|
||||
|
||||
function loadStaticRules(cwd: string): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
|
||||
state.cwd = cwd;
|
||||
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
|
||||
return emptyLoadResult(state);
|
||||
}
|
||||
|
||||
const projectRoot = deps.findProjectRoot(cwd);
|
||||
const findOptions: Parameters<EngineDeps["findCandidates"]>[0] = {
|
||||
projectRoot,
|
||||
targetFile: null,
|
||||
};
|
||||
const disabledSources = disabledSourcesFor(config);
|
||||
if (disabledSources !== undefined) {
|
||||
findOptions.disabledSources = disabledSources;
|
||||
}
|
||||
const candidates = deps.findCandidates(findOptions);
|
||||
const result = loadStaticCandidates(candidates, deps, projectRoot);
|
||||
storeLastLoad(state, result.rules, result.diagnostics);
|
||||
return result;
|
||||
}
|
||||
|
||||
function loadDynamicRules(
|
||||
cwd: string,
|
||||
targetPaths: ReadonlyArray<string>,
|
||||
): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
|
||||
state.cwd = cwd;
|
||||
if (config.disabled || config.mode === "off" || config.mode === "static" || targetPaths.length === 0) {
|
||||
return emptyLoadResult(state);
|
||||
}
|
||||
|
||||
const rules: LoadedRule[] = [];
|
||||
const diagnostics: RuleDiagnostic[] = [];
|
||||
const seenRules = new Set<string>();
|
||||
const loadedRuleContent = new Map<string, LoadedRuleContent | null>();
|
||||
const projectMembership = new Map<string, boolean>();
|
||||
const disabledSources = disabledSourcesFor(config);
|
||||
const discoveryCache = createRuleDiscoveryCache();
|
||||
const candidateDiscoveryCache: CandidateDiscoveryCache = new Map();
|
||||
const cwdProjectRoot = deps.findProjectRoot(cwd);
|
||||
|
||||
for (const targetFile of uniqueStrings(targetPaths)) {
|
||||
const projectRoot =
|
||||
cwdProjectRoot !== null && isSameOrChildPath(targetFile, cwdProjectRoot)
|
||||
? cwdProjectRoot
|
||||
: deps.findProjectRoot(targetFile);
|
||||
const findOptions: Parameters<EngineDeps["findCandidates"]>[0] = {
|
||||
projectRoot,
|
||||
targetFile,
|
||||
cache: discoveryCache,
|
||||
};
|
||||
if (disabledSources !== undefined) {
|
||||
findOptions.disabledSources = disabledSources;
|
||||
}
|
||||
const candidates = findSortedCandidatesCached(candidateDiscoveryCache, deps.findCandidates, findOptions);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const loadedRule = loadCandidate(
|
||||
candidate,
|
||||
deps,
|
||||
diagnostics,
|
||||
projectRoot,
|
||||
loadedRuleContent,
|
||||
projectMembership,
|
||||
);
|
||||
if (loadedRule === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchReason = matchDynamicRuleCached(
|
||||
dynamicMatchCache,
|
||||
projectRoot,
|
||||
targetFile,
|
||||
candidate,
|
||||
loadedRule,
|
||||
deps.matchRule ?? matchRule,
|
||||
);
|
||||
|
||||
if (matchReason === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dedupKey = ruleDedupKey(loadedRule);
|
||||
if (seenRules.has(dedupKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenRules.add(dedupKey);
|
||||
rules.push({ ...loadedRule, matchReason });
|
||||
}
|
||||
}
|
||||
|
||||
const sortedRules = sortCandidates(rules);
|
||||
storeLastLoad(state, sortedRules, diagnostics);
|
||||
return { rules: sortedRules, diagnostics };
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
config,
|
||||
loadStaticRules,
|
||||
loadDynamicRules,
|
||||
formatStatic: (rules) =>
|
||||
formatStaticBlock(rules, { maxRuleChars: config.maxRuleChars, maxResultChars: config.maxResultChars }),
|
||||
formatDynamic: (rules, target) =>
|
||||
formatDynamicBlock(rules, target, {
|
||||
maxRuleChars: config.maxRuleChars,
|
||||
maxResultChars: config.maxResultChars,
|
||||
}),
|
||||
resetSession: (cwd) => {
|
||||
clearSession(state);
|
||||
dynamicMatchCache.clear();
|
||||
if (cwd !== undefined) {
|
||||
state.cwd = cwd;
|
||||
}
|
||||
},
|
||||
isStaticInjected: (rule) => isStaticInjectedInState(state, rule),
|
||||
isDynamicInjected: (rule) => isDynamicInjectedInState(state, rule),
|
||||
markStaticInjected: (rule) => markStaticInjectedInState(state, rule),
|
||||
markDynamicInjected: (rule) => markDynamicInjectedInState(state, rule),
|
||||
};
|
||||
}
|
||||
|
||||
function matchDynamicRuleCached(
|
||||
cache: DynamicMatchCache,
|
||||
projectRoot: string | null,
|
||||
targetFile: string,
|
||||
candidate: RuleCandidate,
|
||||
loadedRule: LoadedRule,
|
||||
matchRuleImpl: typeof matchRule,
|
||||
): MatchReason | null {
|
||||
const cacheKey = dynamicMatchCacheKey(projectRoot, targetFile, candidate, loadedRule.contentHash);
|
||||
if (cache.has(cacheKey)) {
|
||||
const cachedReason = cache.get(cacheKey) ?? null;
|
||||
cache.delete(cacheKey);
|
||||
cache.set(cacheKey, cachedReason);
|
||||
return cachedReason;
|
||||
}
|
||||
|
||||
const matchResult = matchRuleImpl({
|
||||
frontmatter: loadedRule.frontmatter,
|
||||
isSingleFile: candidate.isSingleFile,
|
||||
pathBases: pathBasesForTarget(projectRoot, targetFile, candidate),
|
||||
});
|
||||
const reason = matchResult.matched ? matchResult.reason : null;
|
||||
setDynamicMatchCacheEntry(cache, cacheKey, reason);
|
||||
return reason;
|
||||
}
|
||||
|
||||
function setDynamicMatchCacheEntry(cache: DynamicMatchCache, cacheKey: string, reason: MatchReason | null): void {
|
||||
if (cache.size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES) {
|
||||
const oldestCacheKey = cache.keys().next().value;
|
||||
if (oldestCacheKey !== undefined) {
|
||||
cache.delete(oldestCacheKey);
|
||||
}
|
||||
}
|
||||
cache.set(cacheKey, reason);
|
||||
}
|
||||
|
||||
function dynamicMatchCacheKey(
|
||||
projectRoot: string | null,
|
||||
targetFile: string,
|
||||
candidate: RuleCandidate,
|
||||
contentHash: string,
|
||||
): string {
|
||||
return [
|
||||
projectRoot ?? "",
|
||||
toPosixPath(resolve(targetFile)),
|
||||
candidate.realPath,
|
||||
candidate.relativePath,
|
||||
candidate.source,
|
||||
candidate.isGlobal ? "global" : "project",
|
||||
candidate.isSingleFile ? "single" : "multi",
|
||||
String(candidate.distance),
|
||||
contentHash,
|
||||
].join("\0");
|
||||
}
|
||||
|
||||
function loadStaticCandidates(candidates: ReadonlyArray<RuleCandidate>, deps: EngineDeps, projectRoot: string | null) {
|
||||
const rules: LoadedRule[] = [];
|
||||
const diagnostics: RuleDiagnostic[] = [];
|
||||
let rootSingleFileSelected = false;
|
||||
|
||||
for (const candidate of sortCandidates(candidates)) {
|
||||
if (isDedupedRootSingleFile(candidate, rootSingleFileSelected)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot);
|
||||
if (loadedRule === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchReason = staticMatchReason(loadedRule);
|
||||
if (matchReason === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRootSingleFile(candidate)) {
|
||||
rootSingleFileSelected = true;
|
||||
}
|
||||
|
||||
rules.push({ ...loadedRule, matchReason });
|
||||
}
|
||||
|
||||
return { rules: sortCandidates(rules), diagnostics };
|
||||
}
|
||||
|
||||
function loadCandidate(
|
||||
candidate: RuleCandidate,
|
||||
deps: EngineDeps,
|
||||
diagnostics: RuleDiagnostic[],
|
||||
projectRoot: string | null,
|
||||
loadedRuleContent?: Map<string, LoadedRuleContent | null>,
|
||||
projectMembership?: CandidateProjectMembership,
|
||||
): (LoadedRule & { matchReason: MatchReason }) | null {
|
||||
if (!isCandidateWithinProjectCached(candidate, projectRoot, projectMembership)) {
|
||||
diagnostics.push({
|
||||
severity: "warning",
|
||||
source: candidate.path,
|
||||
message: "Rule file resolves outside project root",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const cachedContent = loadedRuleContent?.get(candidate.realPath);
|
||||
if (cachedContent !== undefined) {
|
||||
return loadedRuleFromContent(candidate, cachedContent, diagnostics);
|
||||
}
|
||||
|
||||
const content = deps.readFile(candidate.path);
|
||||
if (content === null) {
|
||||
loadedRuleContent?.set(candidate.realPath, null);
|
||||
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseRule(content);
|
||||
const loadedContent = {
|
||||
frontmatter: parsed.frontmatter,
|
||||
body: parsed.body,
|
||||
contentHash: hashContent(content),
|
||||
...(parsed.diagnostic === undefined ? {} : { diagnostic: parsed.diagnostic }),
|
||||
} satisfies LoadedRuleContent;
|
||||
loadedRuleContent?.set(candidate.realPath, loadedContent);
|
||||
return loadedRuleFromContent(candidate, loadedContent, diagnostics);
|
||||
}
|
||||
|
||||
function loadedRuleFromContent(
|
||||
candidate: RuleCandidate,
|
||||
content: LoadedRuleContent | null,
|
||||
diagnostics: RuleDiagnostic[],
|
||||
): (LoadedRule & { matchReason: MatchReason }) | null {
|
||||
if (content === null) {
|
||||
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content.diagnostic !== undefined) {
|
||||
diagnostics.push({ severity: "warning", source: candidate.path, message: content.diagnostic });
|
||||
}
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
frontmatter: content.frontmatter,
|
||||
body: content.body,
|
||||
contentHash: content.contentHash,
|
||||
matchReason: { kind: "no-match" },
|
||||
};
|
||||
}
|
||||
|
||||
function ruleDedupKey(rule: LoadedRule): string {
|
||||
return `${rule.realPath}::${rule.contentHash}`;
|
||||
}
|
||||
|
||||
function isCandidateWithinProject(candidate: RuleCandidate, projectRoot: string | null): boolean {
|
||||
if (candidate.isGlobal) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (projectRoot === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativeRealPath = relative(realPathOrResolved(projectRoot), realPathOrResolved(candidate.realPath));
|
||||
return relativeRealPath === "" || (!relativeRealPath.startsWith("..") && !isAbsolute(relativeRealPath));
|
||||
}
|
||||
|
||||
function isCandidateWithinProjectCached(
|
||||
candidate: RuleCandidate,
|
||||
projectRoot: string | null,
|
||||
projectMembership: CandidateProjectMembership | undefined,
|
||||
): boolean {
|
||||
if (projectMembership === undefined) {
|
||||
return isCandidateWithinProject(candidate, projectRoot);
|
||||
}
|
||||
|
||||
const cacheKey = `${projectRoot ?? ""}\0${candidate.realPath}`;
|
||||
const cached = projectMembership.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const isWithinProject = isCandidateWithinProject(candidate, projectRoot);
|
||||
projectMembership.set(cacheKey, isWithinProject);
|
||||
return isWithinProject;
|
||||
}
|
||||
|
||||
function realPathOrResolved(path: string): string {
|
||||
try {
|
||||
return realpathSync.native(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
}
|
||||
|
||||
function findSortedCandidatesCached(
|
||||
cache: CandidateDiscoveryCache,
|
||||
findCandidates: EngineDeps["findCandidates"],
|
||||
options: Parameters<EngineDeps["findCandidates"]>[0],
|
||||
): RuleCandidate[] {
|
||||
const cacheKey = candidateDiscoveryCacheKey(options);
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const candidates = sortCandidates(findCandidates(options));
|
||||
cache.set(cacheKey, candidates);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function candidateDiscoveryCacheKey(options: Parameters<EngineDeps["findCandidates"]>[0]): string {
|
||||
return [
|
||||
options.projectRoot ?? "",
|
||||
options.targetFile === null ? "" : dirname(resolve(options.targetFile)),
|
||||
...[...(options.disabledSources ?? [])].sort(),
|
||||
].join("\0");
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, resolve(childPath));
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
|
||||
}
|
||||
|
||||
function staticMatchReason(rule: LoadedRule): MatchReason | null {
|
||||
if (rule.frontmatter.alwaysApply === true) {
|
||||
return "alwaysApply";
|
||||
}
|
||||
|
||||
if (rule.isSingleFile) {
|
||||
return "single-file";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
|
||||
if (config.enabledSources === "auto") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const enabledSources = new Set(config.enabledSources);
|
||||
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
|
||||
}
|
||||
|
||||
function isDedupedRootSingleFile(candidate: RuleCandidate, rootSingleFileSelected: boolean): boolean {
|
||||
return rootSingleFileSelected && isRootSingleFile(candidate);
|
||||
}
|
||||
|
||||
function isRootSingleFile(candidate: RuleCandidate): boolean {
|
||||
return candidate.distance === 0 && candidate.isSingleFile && ROOT_SINGLE_FILE_SOURCES.has(candidate.source);
|
||||
}
|
||||
|
||||
function pathBasesForTarget(
|
||||
projectRoot: string | null,
|
||||
targetFile: string,
|
||||
candidate: RuleCandidate,
|
||||
): { projectRelative: string; scopeRelative?: string; basename: string } {
|
||||
const targetBasename = basename(targetFile);
|
||||
if (projectRoot === null) {
|
||||
return { projectRelative: targetBasename, basename: targetBasename };
|
||||
}
|
||||
|
||||
const projectRelative = toPosixPath(relative(projectRoot, targetFile));
|
||||
const scopeDirectory = scopeDirectoryForCandidate(projectRoot, candidate);
|
||||
if (scopeDirectory === null) {
|
||||
return { projectRelative, basename: targetBasename };
|
||||
}
|
||||
|
||||
return {
|
||||
projectRelative,
|
||||
scopeRelative: toPosixPath(relative(scopeDirectory, targetFile)),
|
||||
basename: targetBasename,
|
||||
};
|
||||
}
|
||||
|
||||
function scopeDirectoryForCandidate(projectRoot: string, candidate: RuleCandidate): string | null {
|
||||
if (candidate.isGlobal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidate.isSingleFile) {
|
||||
return dirname(candidate.path);
|
||||
}
|
||||
|
||||
const sourceIndex = candidate.relativePath.indexOf(candidate.source);
|
||||
if (sourceIndex === -1) {
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
const scopeRelativeDirectory = candidate.relativePath.slice(0, sourceIndex).replace(/\/$/, "");
|
||||
return scopeRelativeDirectory.length === 0 ? projectRoot : join(projectRoot, scopeRelativeDirectory);
|
||||
}
|
||||
|
||||
function toPosixPath(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function storeLastLoad(
|
||||
state: SessionState,
|
||||
rules: ReadonlyArray<LoadedRule>,
|
||||
diagnostics: ReadonlyArray<RuleDiagnostic>,
|
||||
): void {
|
||||
state.loadedRules.length = 0;
|
||||
state.loadedRules.push(...rules);
|
||||
state.diagnostics.length = 0;
|
||||
state.diagnostics.push(...diagnostics);
|
||||
}
|
||||
|
||||
function emptyLoadResult(state: SessionState): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
|
||||
storeLastLoad(state, [], []);
|
||||
return { rules: [], diagnostics: [] };
|
||||
}
|
||||
|
||||
function uniqueStrings(values: ReadonlyArray<string>): string[] {
|
||||
const uniqueValues: string[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seenValues.has(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(value);
|
||||
uniqueValues.push(value);
|
||||
}
|
||||
return uniqueValues;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class UnsupportedRuleSourceError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UnsupportedRuleSourceError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RuleFrontmatterParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "RuleFrontmatterParseError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { existsSync, realpathSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, posix, relative, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
GLOBAL_DISTANCE,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
PROJECT_SINGLE_FILES,
|
||||
USER_HOME_RULE_SUBDIRS,
|
||||
USER_HOME_SINGLE_FILES,
|
||||
} from "./constants.js";
|
||||
import { UnsupportedRuleSourceError } from "./errors.js";
|
||||
import { scanRuleFiles } from "./scanner.js";
|
||||
import type { RuleCandidate, RuleSource } from "./types.js";
|
||||
|
||||
interface SingleFileInfo {
|
||||
path: string;
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
export interface RuleDiscoveryCache {
|
||||
scannedRuleFiles: Map<string, ReturnType<typeof scanRuleFiles>>;
|
||||
singleFileInfo: Map<string, SingleFileInfo | null>;
|
||||
}
|
||||
|
||||
export interface FinderOptions {
|
||||
/** Project root absolute path (use findProjectRoot to get this). */
|
||||
projectRoot: string | null;
|
||||
/** Target file path (used for distance calculation in dynamic injection mode). null for static mode. */
|
||||
targetFile: string | null;
|
||||
/** User home directory (default: os.homedir()). Injectable for tests. */
|
||||
homeDir?: string;
|
||||
/** Set of disabled sources to omit from discovery. Empty by default. */
|
||||
disabledSources?: ReadonlySet<string>;
|
||||
/** Whether to skip user-home rules. Default: false. */
|
||||
skipUserHome?: boolean;
|
||||
cache?: RuleDiscoveryCache;
|
||||
}
|
||||
|
||||
interface WalkDirectory {
|
||||
directory: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export function createRuleDiscoveryCache(): RuleDiscoveryCache {
|
||||
return { scannedRuleFiles: new Map(), singleFileInfo: new Map() };
|
||||
}
|
||||
|
||||
export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
|
||||
const skipUserHome = options.skipUserHome ?? false;
|
||||
if (options.projectRoot === null && skipUserHome) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const disabledSources = options.disabledSources ?? new Set<string>();
|
||||
const candidates: RuleCandidate[] = [];
|
||||
const homeDirectory = resolve(options.homeDir ?? homedir());
|
||||
|
||||
if (options.projectRoot !== null) {
|
||||
candidates.push(
|
||||
...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources, options.cache),
|
||||
);
|
||||
}
|
||||
|
||||
if (!skipUserHome) {
|
||||
candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function findProjectCandidates(
|
||||
projectRoot: string,
|
||||
targetFile: string | null,
|
||||
disabledSources: ReadonlySet<string>,
|
||||
cache: RuleDiscoveryCache | undefined,
|
||||
): RuleCandidate[] {
|
||||
const rootDirectory = resolve(projectRoot);
|
||||
const walkDirectories = getWalkDirectories(rootDirectory, targetFile);
|
||||
const candidates: RuleCandidate[] = [];
|
||||
|
||||
for (const walkDirectory of walkDirectories) {
|
||||
for (const [parentDirectory, subDirectory] of PROJECT_RULE_SUBDIRS) {
|
||||
const source = toProjectRuleSource(parentDirectory, subDirectory);
|
||||
if (disabledSources.has(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ruleDirectory = join(walkDirectory.directory, parentDirectory, subDirectory);
|
||||
for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) {
|
||||
candidates.push({
|
||||
path: scannedFile.path,
|
||||
realPath: scannedFile.realPath,
|
||||
source,
|
||||
distance: targetFile === null ? 0 : walkDirectory.distance,
|
||||
isGlobal: false,
|
||||
isSingleFile: false,
|
||||
relativePath: toRelativePath(rootDirectory, scannedFile.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const walkDirectory of walkDirectories) {
|
||||
for (const ruleFile of PROJECT_SINGLE_FILES) {
|
||||
const source = toProjectSingleFileSource(ruleFile);
|
||||
if (disabledSources.has(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(walkDirectory.directory, ruleFile);
|
||||
const fileInfo = singleFileInfoCached(filePath, cache);
|
||||
if (fileInfo === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
path: fileInfo.path,
|
||||
realPath: fileInfo.realPath,
|
||||
source,
|
||||
distance: targetFile === null ? 0 : walkDirectory.distance,
|
||||
isGlobal: false,
|
||||
isSingleFile: true,
|
||||
relativePath: toRelativePath(rootDirectory, filePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function findUserHomeCandidates(
|
||||
homeDirectory: string,
|
||||
disabledSources: ReadonlySet<string>,
|
||||
cache: RuleDiscoveryCache | undefined,
|
||||
): RuleCandidate[] {
|
||||
const candidates: RuleCandidate[] = [];
|
||||
|
||||
for (const ruleSubdir of USER_HOME_RULE_SUBDIRS) {
|
||||
const source = toUserHomeRuleSource(ruleSubdir);
|
||||
if (disabledSources.has(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ruleDirectory = join(homeDirectory, ruleSubdir);
|
||||
for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) {
|
||||
candidates.push({
|
||||
path: scannedFile.path,
|
||||
realPath: scannedFile.realPath,
|
||||
source,
|
||||
distance: GLOBAL_DISTANCE,
|
||||
isGlobal: true,
|
||||
isSingleFile: false,
|
||||
relativePath: toRelativePath(homeDirectory, scannedFile.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const ruleFile of USER_HOME_SINGLE_FILES) {
|
||||
const source = toUserHomeSingleFileSource(ruleFile);
|
||||
if (disabledSources.has(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(homeDirectory, ruleFile);
|
||||
const fileInfo = singleFileInfoCached(filePath, cache);
|
||||
if (fileInfo === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
path: fileInfo.path,
|
||||
realPath: fileInfo.realPath,
|
||||
source,
|
||||
distance: GLOBAL_DISTANCE,
|
||||
isGlobal: true,
|
||||
isSingleFile: true,
|
||||
relativePath: toRelativePath(homeDirectory, filePath),
|
||||
});
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ReturnType<typeof scanRuleFiles> {
|
||||
if (cache === undefined) {
|
||||
return scanRuleFiles({ rootDir });
|
||||
}
|
||||
|
||||
const cached = cache.scannedRuleFiles.get(rootDir);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const scannedFiles = scanRuleFiles({ rootDir });
|
||||
cache.scannedRuleFiles.set(rootDir, scannedFiles);
|
||||
return scannedFiles;
|
||||
}
|
||||
|
||||
function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null {
|
||||
if (cache === undefined) {
|
||||
return readSingleFileInfo(filePath);
|
||||
}
|
||||
|
||||
const cached = cache.singleFileInfo.get(filePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fileInfo = readSingleFileInfo(filePath);
|
||||
cache.singleFileInfo.set(filePath, fileInfo);
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] {
|
||||
if (targetFile === null) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const startDirectory = dirname(resolve(targetFile));
|
||||
if (!isSameOrChildPath(startDirectory, projectRoot)) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const walkDirectories: WalkDirectory[] = [];
|
||||
let currentDirectory = startDirectory;
|
||||
let distance = 0;
|
||||
|
||||
while (true) {
|
||||
walkDirectories.push({ directory: currentDirectory, distance });
|
||||
if (currentDirectory === projectRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
distance += 1;
|
||||
}
|
||||
|
||||
return walkDirectories;
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, childPath);
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/"));
|
||||
}
|
||||
|
||||
function readSingleFileInfo(filePath: string): SingleFileInfo | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!statSync(filePath).isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: filePath, realPath: resolveRealPath(filePath) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRealPath(filePath: string): string {
|
||||
try {
|
||||
return realpathSync.native(filePath);
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function toRelativePath(rootDirectory: string, filePath: string): string {
|
||||
return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource {
|
||||
const source = `${parentDirectory}/${subDirectory}`;
|
||||
switch (source) {
|
||||
case ".omo/rules":
|
||||
case ".claude/rules":
|
||||
case ".cursor/rules":
|
||||
case ".github/instructions":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toProjectSingleFileSource(ruleFile: string): RuleSource {
|
||||
switch (ruleFile) {
|
||||
case ".github/copilot-instructions.md":
|
||||
case "AGENTS.md":
|
||||
case "CLAUDE.md":
|
||||
case "CONTEXT.md":
|
||||
return ruleFile;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toUserHomeRuleSource(ruleSubdir: string): RuleSource {
|
||||
const source = `~/${ruleSubdir}`;
|
||||
switch (source) {
|
||||
case "~/.omo/rules":
|
||||
case "~/.opencode/rules":
|
||||
case "~/.claude/rules":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toUserHomeSingleFileSource(ruleFile: string): RuleSource {
|
||||
const source = `~/${ruleFile}`;
|
||||
switch (source) {
|
||||
case "~/.config/opencode/AGENTS.md":
|
||||
case "~/.claude/CLAUDE.md":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { truncateBudget, truncateRule } from "./truncator.js";
|
||||
import type { LoadedRule } from "./types.js";
|
||||
|
||||
export interface FormatOptions {
|
||||
maxRuleChars: number;
|
||||
maxResultChars: number;
|
||||
}
|
||||
|
||||
type TruncatedRule = {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function formatRule(rule: TruncatedRule): string {
|
||||
return `Instructions from: ${rule.path}\n${rule.body}`;
|
||||
}
|
||||
|
||||
function truncateRules(rules: ReadonlyArray<LoadedRule>, options: FormatOptions): TruncatedRule[] {
|
||||
const perRuleTruncated = rules.map((rule) => ({
|
||||
path: rule.path,
|
||||
relativePath: rule.relativePath,
|
||||
body: truncateRule(rule.body, { maxChars: options.maxRuleChars, relativePath: rule.relativePath }).body,
|
||||
}));
|
||||
const budgetedRules = truncateBudget({
|
||||
rules: perRuleTruncated.map((rule) => ({ body: rule.body, relativePath: rule.relativePath })),
|
||||
maxResultChars: options.maxResultChars,
|
||||
});
|
||||
const truncatedRules: TruncatedRule[] = [];
|
||||
|
||||
for (let index = 0; index < budgetedRules.length; index += 1) {
|
||||
const sourceRule = perRuleTruncated[index];
|
||||
const budgetedRule = budgetedRules[index];
|
||||
if (sourceRule === undefined || budgetedRule === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
truncatedRules.push({
|
||||
path: sourceRule.path,
|
||||
relativePath: budgetedRule.relativePath,
|
||||
body: budgetedRule.body,
|
||||
});
|
||||
}
|
||||
|
||||
return truncatedRules;
|
||||
}
|
||||
|
||||
export function formatStaticBlock(rules: ReadonlyArray<LoadedRule>, options: FormatOptions): string {
|
||||
if (rules.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `\n\n## Project Instructions\n${truncateRules(rules, options).map(formatRule).join("\n\n")}`;
|
||||
}
|
||||
|
||||
export function formatDynamicBlock(
|
||||
rules: ReadonlyArray<LoadedRule>,
|
||||
targetRelativePath: string,
|
||||
options: FormatOptions,
|
||||
): string {
|
||||
if (rules.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `\n\nAdditional project instructions matched for ${targetRelativePath}:\n\n${truncateRules(rules, options)
|
||||
.map(formatRule)
|
||||
.join("\n\n")}`;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import picomatch from "picomatch";
|
||||
import type { MatchReason, RuleFrontmatter } from "./types.js";
|
||||
|
||||
export interface MatcherInput {
|
||||
frontmatter: RuleFrontmatter;
|
||||
isSingleFile: boolean;
|
||||
/** Path bases to try matching against (POSIX-normalized). */
|
||||
pathBases: { projectRelative: string; scopeRelative?: string; basename: string };
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
matched: boolean;
|
||||
reason: MatchReason;
|
||||
}
|
||||
|
||||
interface CompiledPattern {
|
||||
pattern: string;
|
||||
isMatch: (path: string) => boolean;
|
||||
}
|
||||
|
||||
interface CompiledPatternSet {
|
||||
positivePatterns: CompiledPattern[];
|
||||
negativeMatchers: Array<(path: string) => boolean>;
|
||||
}
|
||||
|
||||
const compiledPatternSets = new Map<string, CompiledPatternSet>();
|
||||
|
||||
export function matchRule(input: MatcherInput): MatchResult {
|
||||
if (input.isSingleFile) {
|
||||
return { matched: true, reason: "single-file" };
|
||||
}
|
||||
|
||||
if (input.frontmatter.alwaysApply === true) {
|
||||
return { matched: true, reason: "alwaysApply" };
|
||||
}
|
||||
|
||||
const patterns = normalizeGlobs(input.frontmatter);
|
||||
if (patterns.length === 0) {
|
||||
return noMatch();
|
||||
}
|
||||
|
||||
const pathBases = normalizedPathBases(input.pathBases);
|
||||
const { positivePatterns, negativeMatchers } = compiledPatternSetFor(patterns);
|
||||
|
||||
for (const { pattern, isMatch } of positivePatterns) {
|
||||
for (const pathBase of pathBases) {
|
||||
if (!isMatch(pathBase)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isExcluded(pathBase, negativeMatchers)) {
|
||||
return noMatch();
|
||||
}
|
||||
|
||||
return { matched: true, reason: { kind: "glob", pattern } };
|
||||
}
|
||||
}
|
||||
|
||||
return noMatch();
|
||||
}
|
||||
|
||||
export function normalizeGlobs(frontmatter: RuleFrontmatter): string[] {
|
||||
const patterns = [
|
||||
...normalizePatternList(frontmatter.globs),
|
||||
...normalizePatternList(frontmatter.paths),
|
||||
...normalizePatternList(frontmatter.applyTo),
|
||||
];
|
||||
|
||||
return [...new Set(patterns.map(normalizePath))];
|
||||
}
|
||||
|
||||
export function hashContent(body: string): string {
|
||||
return createHash("sha256").update(body).digest("hex");
|
||||
}
|
||||
|
||||
function normalizePatternList(patterns: string | string[] | undefined): string[] {
|
||||
if (patterns === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(patterns) ? patterns : [patterns];
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function normalizedPathBases(pathBases: MatcherInput["pathBases"]): string[] {
|
||||
const normalizedBases = [normalizePath(pathBases.projectRelative)];
|
||||
if (pathBases.scopeRelative !== undefined) {
|
||||
normalizedBases.push(normalizePath(pathBases.scopeRelative));
|
||||
}
|
||||
normalizedBases.push(normalizePath(pathBases.basename));
|
||||
return normalizedBases;
|
||||
}
|
||||
|
||||
function compiledPatternSetFor(patterns: ReadonlyArray<string>): CompiledPatternSet {
|
||||
const cacheKey = JSON.stringify(patterns);
|
||||
const cached = compiledPatternSets.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const compiled = compilePatternSet(patterns);
|
||||
compiledPatternSets.set(cacheKey, compiled);
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function compilePatternSet(patterns: ReadonlyArray<string>): CompiledPatternSet {
|
||||
const positivePatterns: CompiledPattern[] = [];
|
||||
const negativeMatchers: Array<(path: string) => boolean> = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.startsWith("!")) {
|
||||
negativeMatchers.push(createGlobMatcher(pattern.slice(1)));
|
||||
continue;
|
||||
}
|
||||
|
||||
positivePatterns.push({ pattern, isMatch: createGlobMatcher(pattern) });
|
||||
}
|
||||
|
||||
return { positivePatterns, negativeMatchers };
|
||||
}
|
||||
|
||||
function createGlobMatcher(pattern: string): (path: string) => boolean {
|
||||
return picomatch(normalizePath(pattern), { bash: true, dot: true });
|
||||
}
|
||||
|
||||
function isExcluded(pathBase: string, negativeMatchers: ReadonlyArray<(path: string) => boolean>): boolean {
|
||||
for (const isMatch of negativeMatchers) {
|
||||
if (isMatch(pathBase)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function noMatch(): MatchResult {
|
||||
return { matched: false, reason: { kind: "no-match" } };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SOURCE_PRIORITY } from "./constants.js";
|
||||
import type { RuleCandidate } from "./types.js";
|
||||
|
||||
export function sortCandidates<T extends RuleCandidate>(candidates: ReadonlyArray<T>): T[] {
|
||||
return candidates
|
||||
.map((candidate, index) => ({ candidate, index }))
|
||||
.sort((left, right) => compareCandidates(left.candidate, right.candidate) || left.index - right.index)
|
||||
.map(({ candidate }) => candidate);
|
||||
}
|
||||
|
||||
export function compareCandidates(a: RuleCandidate, b: RuleCandidate): number {
|
||||
return (
|
||||
compareBoolean(a.isGlobal, b.isGlobal) ||
|
||||
compareNumber(a.distance, b.distance) ||
|
||||
compareNumber(SOURCE_PRIORITY.get(a.source) ?? Infinity, SOURCE_PRIORITY.get(b.source) ?? Infinity) ||
|
||||
compareString(a.relativePath, b.relativePath) ||
|
||||
compareString(a.realPath, b.realPath)
|
||||
);
|
||||
}
|
||||
|
||||
function compareBoolean(a: boolean, b: boolean): number {
|
||||
return Number(a) - Number(b);
|
||||
}
|
||||
|
||||
function compareNumber(a: number, b: number): number {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
function compareString(a: string, b: string): number {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { RuleFrontmatterParseError } from "./errors.js";
|
||||
import type { ParsedRule, RuleFrontmatter } from "./types.js";
|
||||
|
||||
const FRONTMATTER_OPENING = "---\n";
|
||||
const FRONTMATTER_OPENING_CRLF = "---\r\n";
|
||||
|
||||
/** Parse markdown rule content and extract the supported YAML frontmatter subset. */
|
||||
export function parseRule(content: string): ParsedRule {
|
||||
const normalizedContent = stripBom(content);
|
||||
const openingLength = getOpeningDelimiterLength(normalizedContent);
|
||||
if (openingLength === 0) {
|
||||
return { frontmatter: {}, body: normalizedContent };
|
||||
}
|
||||
|
||||
const closingDelimiter = findClosingDelimiter(normalizedContent, openingLength);
|
||||
if (closingDelimiter === null) {
|
||||
return {
|
||||
frontmatter: {},
|
||||
body: normalizedContent,
|
||||
diagnostic: "Missing closing frontmatter delimiter",
|
||||
};
|
||||
}
|
||||
|
||||
const yamlContent = normalizedContent.slice(openingLength, closingDelimiter.start);
|
||||
const body = normalizedContent.slice(closingDelimiter.bodyStart);
|
||||
|
||||
try {
|
||||
return { frontmatter: parseYamlFrontmatter(yamlContent), body };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid YAML frontmatter";
|
||||
return {
|
||||
frontmatter: {},
|
||||
body: normalizedContent,
|
||||
diagnostic: `Malformed frontmatter: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stripBom(content: string): string {
|
||||
return content.startsWith("\uFEFF") ? content.slice(1) : content;
|
||||
}
|
||||
|
||||
function getOpeningDelimiterLength(content: string): number {
|
||||
if (content.startsWith(FRONTMATTER_OPENING_CRLF)) return FRONTMATTER_OPENING_CRLF.length;
|
||||
if (content.startsWith(FRONTMATTER_OPENING)) return FRONTMATTER_OPENING.length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function findClosingDelimiter(content: string, openingLength: number): { start: number; bodyStart: number } | null {
|
||||
let lineStart = openingLength;
|
||||
|
||||
while (lineStart <= content.length) {
|
||||
const nextNewline = content.indexOf("\n", lineStart);
|
||||
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
|
||||
const line = content.slice(lineStart, lineEnd).replace(/\r$/, "");
|
||||
|
||||
if (line === "---") {
|
||||
return {
|
||||
start: lineStart,
|
||||
bodyStart: nextNewline === -1 ? content.length : nextNewline + 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (nextNewline === -1) break;
|
||||
lineStart = nextNewline + 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseYamlFrontmatter(yamlContent: string): RuleFrontmatter {
|
||||
const lines = yamlContent.replace(/\r\n/g, "\n").split("\n");
|
||||
const frontmatter: RuleFrontmatter = {};
|
||||
const globValues: string[] = [];
|
||||
let lineIndex = 0;
|
||||
|
||||
while (lineIndex < lines.length) {
|
||||
const rawLine = lines[lineIndex];
|
||||
if (rawLine === undefined) break;
|
||||
|
||||
const line = stripComment(rawLine).trim();
|
||||
if (line.length === 0) {
|
||||
lineIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
throw new RuleFrontmatterParseError(`Expected key-value pair on line ${lineIndex + 1}`);
|
||||
}
|
||||
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const rawValue = line.slice(colonIndex + 1).trim();
|
||||
|
||||
if (key === "description") {
|
||||
frontmatter.description = parseStringValue(rawValue);
|
||||
lineIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "alwaysApply") {
|
||||
frontmatter.alwaysApply = parseBooleanValue(rawValue, lineIndex + 1);
|
||||
lineIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "globs" || key === "paths" || key === "applyTo") {
|
||||
const parsed = parseGlobValue(rawValue, lines, lineIndex);
|
||||
for (const glob of parsed.values) {
|
||||
if (!globValues.includes(glob)) globValues.push(glob);
|
||||
}
|
||||
lineIndex += parsed.consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
lineIndex += 1;
|
||||
}
|
||||
|
||||
const singleGlob = globValues[0];
|
||||
if (globValues.length === 1 && singleGlob !== undefined) {
|
||||
frontmatter.globs = singleGlob;
|
||||
} else if (globValues.length > 1) {
|
||||
frontmatter.globs = globValues;
|
||||
}
|
||||
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
function parseBooleanValue(value: string, lineNumber: number): boolean {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
throw new RuleFrontmatterParseError(`Expected boolean on line ${lineNumber}`);
|
||||
}
|
||||
|
||||
function parseGlobValue(rawValue: string, lines: string[], lineIndex: number): { values: string[]; consumed: number } {
|
||||
if (rawValue.startsWith("[")) {
|
||||
return { values: parseInlineArray(rawValue), consumed: 1 };
|
||||
}
|
||||
|
||||
if (rawValue.length === 0) {
|
||||
return parseMultilineArray(lines, lineIndex);
|
||||
}
|
||||
|
||||
const value = parseStringValue(rawValue);
|
||||
if (value.includes(",")) {
|
||||
return {
|
||||
values: value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
consumed: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { values: [value], consumed: 1 };
|
||||
}
|
||||
|
||||
function parseMultilineArray(lines: string[], lineIndex: number): { values: string[]; consumed: number } {
|
||||
const values: string[] = [];
|
||||
let consumed = 1;
|
||||
|
||||
for (let index = lineIndex + 1; index < lines.length; index += 1) {
|
||||
const rawLine = lines[index];
|
||||
if (rawLine === undefined) break;
|
||||
|
||||
const lineWithoutComment = stripComment(rawLine);
|
||||
if (lineWithoutComment.trim().length === 0) {
|
||||
consumed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const arrayItem = lineWithoutComment.match(/^\s+-\s*(.*)$/);
|
||||
if (arrayItem === null) break;
|
||||
|
||||
values.push(parseStringValue(arrayItem[1] ?? ""));
|
||||
consumed += 1;
|
||||
}
|
||||
|
||||
return { values: values.filter(Boolean), consumed };
|
||||
}
|
||||
|
||||
function parseInlineArray(value: string): string[] {
|
||||
const closingBracketIndex = findClosingBracket(value);
|
||||
if (closingBracketIndex === -1) {
|
||||
throw new RuleFrontmatterParseError("Unclosed inline array");
|
||||
}
|
||||
|
||||
const trailing = value.slice(closingBracketIndex + 1).trim();
|
||||
if (trailing.length > 0) {
|
||||
throw new RuleFrontmatterParseError("Unexpected content after inline array");
|
||||
}
|
||||
|
||||
const content = value.slice(1, closingBracketIndex).trim();
|
||||
if (content.length === 0) return [];
|
||||
|
||||
return splitCommaSeparated(content).map(parseStringValue).filter(Boolean);
|
||||
}
|
||||
|
||||
function findClosingBracket(value: string): number {
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (character === undefined) continue;
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote !== null && character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
if (quote === null) quote = character;
|
||||
else if (quote === character) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === null && character === "]") return index;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function splitCommaSeparated(value: string): string[] {
|
||||
const values: string[] = [];
|
||||
let current = "";
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (character === undefined) continue;
|
||||
|
||||
if (escaped) {
|
||||
current += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote !== null && character === "\\") {
|
||||
current += character;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
if (quote === null) quote = character;
|
||||
else if (quote === character) quote = null;
|
||||
current += character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === null && character === ",") {
|
||||
values.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += character;
|
||||
}
|
||||
|
||||
if (quote !== null) {
|
||||
throw new RuleFrontmatterParseError("Unclosed quoted value");
|
||||
}
|
||||
|
||||
values.push(current.trim());
|
||||
return values.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseStringValue(value: string): string {
|
||||
if (value.length === 0) return "";
|
||||
if (value.startsWith('"')) return parseJsonString(value);
|
||||
if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
|
||||
if (value.startsWith("'")) throw new RuleFrontmatterParseError("Unclosed quoted value");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseJsonString(value: string): string {
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
throw new RuleFrontmatterParseError("Invalid JSON-quoted string");
|
||||
}
|
||||
|
||||
if (typeof parsedValue !== "string") {
|
||||
throw new RuleFrontmatterParseError("Expected JSON-quoted string");
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
function stripComment(line: string): string {
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const character = line[index];
|
||||
if (character === undefined) continue;
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote !== null && character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
if (quote === null) quote = character;
|
||||
else if (quote === character) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === null && character === "#") return line.slice(0, index);
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { PROJECT_MARKERS } from "./constants.js";
|
||||
|
||||
export function findProjectRoot(startPath: string, markers: ReadonlyArray<string> = PROJECT_MARKERS): string | null {
|
||||
const resolvedStartPath = resolve(startPath);
|
||||
|
||||
if (!existsSync(resolvedStartPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startStats = statSync(resolvedStartPath);
|
||||
let currentDirectory = startStats.isDirectory() ? resolvedStartPath : dirname(resolvedStartPath);
|
||||
const filesystemRoot = resolve("/");
|
||||
|
||||
while (true) {
|
||||
for (const marker of markers) {
|
||||
if (existsSync(join(currentDirectory, marker))) {
|
||||
return currentDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDirectory === filesystemRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentDirectory = dirname(currentDirectory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { type Dirent, existsSync, lstatSync, readdirSync, realpathSync, type Stats, statSync } from "node:fs";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import { DEFAULT_MAX_SCAN_FILES, RULE_FILE_EXTENSIONS, SCANNER_EXCLUDED_DIRS } from "./constants.js";
|
||||
|
||||
export interface ScanOptions {
|
||||
rootDir: string;
|
||||
excludedDirs?: ReadonlyArray<string>;
|
||||
/** Maximum recursion depth. Default: 10 */
|
||||
maxDepth?: number;
|
||||
maxFiles?: number;
|
||||
}
|
||||
|
||||
export interface ScannedFile {
|
||||
/** Absolute path as encountered (may be a symlink). */
|
||||
path: string;
|
||||
/** Real (resolved) path; same as path if not a symlink. */
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
export function scanRuleFiles(options: ScanOptions): ScannedFile[] {
|
||||
const rootPath = toAbsolutePath(options.rootDir);
|
||||
if (!existsSync(rootPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let rootStats: Stats;
|
||||
try {
|
||||
rootStats = statSync(rootPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!rootStats.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: ScannedFile[] = [];
|
||||
const visitedDirectories = new Set<string>();
|
||||
const excludedDirs = new Set(options.excludedDirs ?? SCANNER_EXCLUDED_DIRS);
|
||||
const maxDepth = options.maxDepth ?? 10;
|
||||
const maxFiles = normalizeMaxFiles(options.maxFiles);
|
||||
|
||||
scanDirectory(rootPath, 0, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
function normalizeMaxFiles(maxFiles: number | undefined): number {
|
||||
const value = maxFiles ?? DEFAULT_MAX_SCAN_FILES;
|
||||
if (!Number.isFinite(value) || value < 0) return DEFAULT_MAX_SCAN_FILES;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function toAbsolutePath(filePath: string): string {
|
||||
return isAbsolute(filePath) ? filePath : resolve(filePath);
|
||||
}
|
||||
|
||||
function scanDirectory(
|
||||
directoryPath: string,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
maxFiles: number,
|
||||
excludedDirs: ReadonlySet<string>,
|
||||
visitedDirectories: Set<string>,
|
||||
results: ScannedFile[],
|
||||
): void {
|
||||
if (results.length >= maxFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
let realDirectoryPath: string;
|
||||
try {
|
||||
realDirectoryPath = realpathSync.native(directoryPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (visitedDirectories.has(realDirectoryPath)) {
|
||||
return;
|
||||
}
|
||||
visitedDirectories.add(realDirectoryPath);
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(directoryPath, { withFileTypes: true }).sort((leftEntry, rightEntry) =>
|
||||
leftEntry.name.localeCompare(rightEntry.name),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= maxFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPath = join(directoryPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!excludedDirs.has(entry.name) && depth < maxDepth) {
|
||||
scanDirectory(entryPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
scanSymbolicLink(entryPath, entry.name, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && isRuleFile(entry.name)) {
|
||||
results.push({ path: entryPath, realPath: resolveRealPath(entryPath) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanSymbolicLink(
|
||||
linkPath: string,
|
||||
linkName: string,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
maxFiles: number,
|
||||
excludedDirs: ReadonlySet<string>,
|
||||
visitedDirectories: Set<string>,
|
||||
results: ScannedFile[],
|
||||
): void {
|
||||
if (results.length >= maxFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetStats: Stats;
|
||||
try {
|
||||
targetStats = statSync(linkPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetStats.isDirectory()) {
|
||||
if (!excludedDirs.has(linkName) && depth < maxDepth) {
|
||||
scanDirectory(linkPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetStats.isFile() && isRuleFile(linkName)) {
|
||||
results.push({ path: linkPath, realPath: resolveRealPath(linkPath) });
|
||||
}
|
||||
}
|
||||
|
||||
function isRuleFile(fileName: string): boolean {
|
||||
return RULE_FILE_EXTENSIONS.some((extension) => fileName.endsWith(extension));
|
||||
}
|
||||
|
||||
function resolveRealPath(filePath: string): string {
|
||||
try {
|
||||
const realPath = realpathSync.native(filePath);
|
||||
const fileStats = lstatSync(filePath);
|
||||
return fileStats.isSymbolicLink() ? realPath : filePath;
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { TRUNCATION_NOTICE } from "./constants.js";
|
||||
import type { TruncationResult } from "./types.js";
|
||||
|
||||
type BudgetRule = {
|
||||
body: string;
|
||||
relativePath: string;
|
||||
};
|
||||
|
||||
type BudgetResult = BudgetRule & {
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
function truncationNotice(relativePath: string): string {
|
||||
return TRUNCATION_NOTICE.replace("{path}", relativePath);
|
||||
}
|
||||
|
||||
function safeSliceEnd(body: string, end: number): number {
|
||||
if (end <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lastCodeUnit = body.charCodeAt(end - 1);
|
||||
if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) {
|
||||
return end - 1;
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
export function truncateRule(body: string, options: { maxChars: number; relativePath: string }): TruncationResult {
|
||||
if (body.length <= options.maxChars) {
|
||||
return { body, truncated: false, originalLength: body.length };
|
||||
}
|
||||
|
||||
const notice = truncationNotice(options.relativePath);
|
||||
if (options.maxChars < notice.length) {
|
||||
return { body: notice, truncated: true, originalLength: body.length };
|
||||
}
|
||||
|
||||
const sliceEnd = safeSliceEnd(body, options.maxChars - notice.length);
|
||||
return { body: `${body.slice(0, sliceEnd)}${notice}`, truncated: true, originalLength: body.length };
|
||||
}
|
||||
|
||||
export function truncateBudget(input: { rules: ReadonlyArray<BudgetRule>; maxResultChars: number }): BudgetResult[] {
|
||||
const results: BudgetResult[] = [];
|
||||
let remainingBudget = input.maxResultChars;
|
||||
|
||||
for (const rule of input.rules) {
|
||||
if (remainingBudget >= rule.body.length) {
|
||||
results.push({ body: rule.body, truncated: false, relativePath: rule.relativePath });
|
||||
remainingBudget -= rule.body.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const notice = truncationNotice(rule.relativePath);
|
||||
if (remainingBudget <= notice.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const sliceEnd = safeSliceEnd(rule.body, remainingBudget - notice.length);
|
||||
const body = `${rule.body.slice(0, sliceEnd)}${notice}`;
|
||||
results.push({ body, truncated: true, relativePath: rule.relativePath });
|
||||
remainingBudget -= body.length;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Public types for pi-rules.
|
||||
*
|
||||
* These types are stable contracts between modules. The frontmatter type
|
||||
* mirrors omo's `RuleMetadata` plus Claude (`paths`) and Copilot (`applyTo`)
|
||||
* aliases that are normalized into `globs` internally.
|
||||
*/
|
||||
|
||||
/**
|
||||
* YAML frontmatter parsed from a rule markdown file.
|
||||
* `paths` (Claude alias) and `applyTo` (Copilot alias) are normalized into
|
||||
* `globs` by the parser before any matcher sees this struct.
|
||||
*/
|
||||
export interface RuleFrontmatter {
|
||||
description?: string;
|
||||
globs?: string | string[];
|
||||
paths?: string | string[];
|
||||
applyTo?: string | string[];
|
||||
alwaysApply?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing a rule markdown file.
|
||||
* `body` excludes the frontmatter delimiters and the YAML payload.
|
||||
*/
|
||||
export interface ParsedRule {
|
||||
frontmatter: RuleFrontmatter;
|
||||
body: string;
|
||||
/**
|
||||
* Diagnostic message if frontmatter parsing failed but the body was salvaged.
|
||||
* Empty when parsing succeeded.
|
||||
*/
|
||||
diagnostic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A discovered rule file candidate before parsing/matching.
|
||||
*
|
||||
* `path` is the absolute path as discovered (possibly via symlink).
|
||||
* `realPath` is the canonical resolved path used for dedup.
|
||||
* `source` identifies which discovery source produced this candidate.
|
||||
*/
|
||||
export interface RuleCandidate {
|
||||
path: string;
|
||||
realPath: string;
|
||||
source: RuleSource;
|
||||
/**
|
||||
* Distance from the target file directory to the directory containing this rule.
|
||||
* 0 = same directory, 9999 = global/user-home rule.
|
||||
*/
|
||||
distance: number;
|
||||
isGlobal: boolean;
|
||||
/**
|
||||
* True when this candidate is a SINGLE-FILE rule like AGENTS.md or
|
||||
* `.github/copilot-instructions.md` (frontmatter optional, applies always).
|
||||
*/
|
||||
isSingleFile: boolean;
|
||||
/**
|
||||
* Path relative to project root, POSIX-normalized. Used for matcher and display.
|
||||
* Empty string for user-home global rules.
|
||||
*/
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully-loaded rule ready for injection.
|
||||
*/
|
||||
export interface LoadedRule extends RuleCandidate {
|
||||
frontmatter: RuleFrontmatter;
|
||||
body: string;
|
||||
contentHash: string;
|
||||
matchReason: MatchReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source identifier for rule files. Used for deterministic ordering and display.
|
||||
*/
|
||||
export type RuleSource =
|
||||
| ".omo/rules"
|
||||
| ".claude/rules"
|
||||
| ".cursor/rules"
|
||||
| ".github/instructions"
|
||||
| ".github/copilot-instructions.md"
|
||||
| "AGENTS.md"
|
||||
| "CLAUDE.md"
|
||||
| "CONTEXT.md"
|
||||
| "~/.omo/rules"
|
||||
| "~/.opencode/rules"
|
||||
| "~/.claude/rules"
|
||||
| "~/.config/opencode/AGENTS.md"
|
||||
| "~/.claude/CLAUDE.md";
|
||||
|
||||
/**
|
||||
* Why a candidate matched the target file. Surfaced in the injection block so
|
||||
* the model can attribute its behavior to a specific rule.
|
||||
*/
|
||||
export type MatchReason = "alwaysApply" | "single-file" | { kind: "glob"; pattern: string } | { kind: "no-match" };
|
||||
|
||||
/**
|
||||
* Truncation result.
|
||||
*/
|
||||
export interface TruncationResult {
|
||||
body: string;
|
||||
truncated: boolean;
|
||||
originalLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration knobs resolved from env vars and package.json.
|
||||
*/
|
||||
export interface PiRulesConfig {
|
||||
disabled: boolean;
|
||||
mode: "static" | "dynamic" | "both" | "off";
|
||||
maxRuleChars: number;
|
||||
maxResultChars: number;
|
||||
enabledSources: RuleSource[] | "auto";
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session in-memory dedup state.
|
||||
*
|
||||
* `staticDedup` keys are `{cwd}::{rulePath}::{contentHash}` strings.
|
||||
* `dynamicDedup` stores session-scoped `{rulePath}::{contentHash}` strings.
|
||||
*/
|
||||
export interface SessionState {
|
||||
cwd: string | undefined;
|
||||
staticDedup: Set<string>;
|
||||
dynamicDedup: Map<string, Set<string>>;
|
||||
dynamicTargetFingerprints: Map<string, string>;
|
||||
loadedRules: LoadedRule[];
|
||||
diagnostics: RuleDiagnostic[];
|
||||
}
|
||||
|
||||
export interface RuleDiagnostic {
|
||||
severity: "warning" | "error";
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
export interface CodexPostToolUseLike {
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
tool_response: unknown;
|
||||
}
|
||||
|
||||
const COMMAND_TOOL_NAMES = new Set(["bash", "shell_command", "exec_command"]);
|
||||
const TRACKED_TOOL_NAMES = new Set([
|
||||
"read",
|
||||
"read_file",
|
||||
"mcp__filesystem__read_file",
|
||||
"mcp__filesystem__read_multiple_files",
|
||||
"mcp__filesystem__write_file",
|
||||
"mcp__filesystem__edit_file",
|
||||
"write",
|
||||
"edit",
|
||||
"multiedit",
|
||||
"multi_edit",
|
||||
"apply_patch",
|
||||
"bash",
|
||||
"shell_command",
|
||||
"exec_command",
|
||||
]);
|
||||
|
||||
export function extractCodexToolPaths(input: CodexPostToolUseLike, cwd: string): string[] {
|
||||
const toolName = input.tool_name.toLowerCase();
|
||||
if (!TRACKED_TOOL_NAMES.has(toolName) || isFailedToolResponse(input.tool_response)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths = new Set<string>();
|
||||
const toolInput = isRecord(input.tool_input) ? input.tool_input : {};
|
||||
addCommonPathFields(paths, toolInput, cwd);
|
||||
addPatchPayloadPaths(paths, toolInput, cwd);
|
||||
addPatchRecordPaths(paths, toolInput["files"], cwd);
|
||||
addPatchRecordPaths(paths, toolInput["changes"], cwd);
|
||||
|
||||
if (COMMAND_TOOL_NAMES.has(toolName)) {
|
||||
const command = stringProperty(toolInput, "command") ?? stringProperty(toolInput, "cmd");
|
||||
const workdir = stringProperty(toolInput, "workdir") ?? stringProperty(toolInput, "cwd");
|
||||
addCommandPaths(paths, command, workdir === undefined ? cwd : resolvePath(cwd, workdir));
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function addCommonPathFields(paths: Set<string>, input: Record<string, unknown>, cwd: string): void {
|
||||
for (const key of ["path", "filePath", "file_path", "target", "targetPath", "target_path"]) {
|
||||
addPath(paths, input[key], cwd, false);
|
||||
}
|
||||
for (const key of ["paths", "filePaths", "file_paths"]) {
|
||||
addPathArray(paths, input[key], cwd, false);
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchPayloadPaths(paths: Set<string>, input: Record<string, unknown>, cwd: string): void {
|
||||
for (const key of ["input", "patch", "command", "cmd"]) {
|
||||
const value = input[key];
|
||||
if (typeof value === "string") {
|
||||
addPatchHeaderPaths(paths, value, cwd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchHeaderPaths(paths: Set<string>, patch: string, cwd: string): void {
|
||||
for (const line of patch.split("\n")) {
|
||||
for (const prefix of ["*** Add File: ", "*** Update File: ", "*** Move to: "]) {
|
||||
if (line.startsWith(prefix)) {
|
||||
addPath(paths, line.slice(prefix.length).trim(), cwd, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchRecordPaths(paths: Set<string>, value: unknown, cwd: string): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const item of value) {
|
||||
if (typeof item === "string") {
|
||||
addPath(paths, item, cwd, false);
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
addCommonPathFields(paths, item, cwd);
|
||||
for (const key of ["movePath", "move_path", "to", "from"]) {
|
||||
addPath(paths, item[key], cwd, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addCommandPaths(paths: Set<string>, command: string | undefined, cwd: string): void {
|
||||
if (command === undefined) return;
|
||||
for (const token of tokenizeShell(command)) {
|
||||
if (token.length === 0 || token.startsWith("-") || token.includes("*")) {
|
||||
continue;
|
||||
}
|
||||
addPath(paths, token, cwd, true);
|
||||
}
|
||||
}
|
||||
|
||||
function addPathArray(paths: Set<string>, value: unknown, cwd: string, mustExist: boolean): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const item of value) {
|
||||
addPath(paths, item, cwd, mustExist);
|
||||
}
|
||||
}
|
||||
|
||||
function addPath(paths: Set<string>, value: unknown, cwd: string, mustExist: boolean): void {
|
||||
if (typeof value !== "string" || value.length === 0 || looksLikeUrl(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resolvePath(cwd, value);
|
||||
if (mustExist && !isExistingFile(path)) {
|
||||
return;
|
||||
}
|
||||
paths.add(path);
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, filePath: string): string {
|
||||
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
|
||||
}
|
||||
|
||||
function isExistingFile(filePath: string): boolean {
|
||||
try {
|
||||
return existsSync(filePath) && statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeUrl(value: string): boolean {
|
||||
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value);
|
||||
}
|
||||
|
||||
function stringProperty(value: Record<string, unknown>, key: string): string | undefined {
|
||||
const property = value[key];
|
||||
return typeof property === "string" && property.length > 0 ? property : undefined;
|
||||
}
|
||||
|
||||
function tokenizeShell(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (const character of command) {
|
||||
if (escaped) {
|
||||
current += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ((character === "'" || character === '"') && quote === null) {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (quote === character) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
if (quote === null && /\s/.test(character)) {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += character;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFailedToolResponse(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs";
|
||||
import { syncBuiltinESMExports } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { CodexPostToolUseInput } from "../src/codex-hook.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempProject(ruleCount: number): { root: string; pluginData: string; targetPath: string } {
|
||||
const root = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-project-"));
|
||||
const pluginData = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-data-"));
|
||||
tempDirectories.push(root, pluginData);
|
||||
|
||||
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
|
||||
fs.mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "src"), { recursive: true });
|
||||
const targetPath = path.join(root, "src", "app.ts");
|
||||
fs.writeFileSync(targetPath, "export const app = true;\n");
|
||||
|
||||
for (let index = 0; index < ruleCount; index += 1) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".omo", "rules", `rule-${index}.md`),
|
||||
["---", 'globs: "**/*.ts"', "---", "", `Rule ${index}`].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
return { root, pluginData, targetPath };
|
||||
}
|
||||
|
||||
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
turn_id: "turn-1",
|
||||
transcript_path: null,
|
||||
cwd: root,
|
||||
hook_event_name: "PostToolUse",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "default",
|
||||
tool_name: "mcp__filesystem__read_file",
|
||||
tool_input: { path: filePath },
|
||||
tool_response: { text: "file contents" },
|
||||
tool_use_id: "call-1",
|
||||
};
|
||||
}
|
||||
|
||||
function isProjectRuleRead(filePath: unknown): boolean {
|
||||
return String(filePath).includes(`${path.sep}.omo${path.sep}rules${path.sep}`);
|
||||
}
|
||||
|
||||
describe("codex rules hook performance", () => {
|
||||
it("#given unchanged dynamic target #when PostToolUse repeats #then rule files are not reread for fingerprinting", async () => {
|
||||
// given
|
||||
const { root, pluginData, targetPath } = makeTempProject(3);
|
||||
let ruleFileReads = 0;
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
const wrappedReadFileSync = ((...args: Parameters<typeof fs.readFileSync>) => {
|
||||
if (isProjectRuleRead(args[0])) {
|
||||
ruleFileReads += 1;
|
||||
}
|
||||
|
||||
return originalReadFileSync(...args);
|
||||
}) as typeof fs.readFileSync;
|
||||
fs.readFileSync = wrappedReadFileSync;
|
||||
syncBuiltinESMExports();
|
||||
const { runPostToolUseHook } = await import("../src/codex-hook.js");
|
||||
|
||||
try {
|
||||
// when
|
||||
const firstOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
|
||||
});
|
||||
const firstRunRuleFileReads = ruleFileReads;
|
||||
ruleFileReads = 0;
|
||||
const secondOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
|
||||
});
|
||||
|
||||
// then
|
||||
expect(firstOutput).toContain("Rule 0");
|
||||
expect(firstRunRuleFileReads).toBe(3);
|
||||
expect(secondOutput).toBe("");
|
||||
expect(ruleFileReads).toBe(0);
|
||||
} finally {
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
syncBuiltinESMExports();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,675 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type CodexPostCompactInput,
|
||||
type CodexPostToolUseInput,
|
||||
type CodexSessionStartInput,
|
||||
runPostCompactHook,
|
||||
runPostToolUseHook,
|
||||
runSessionStartHook,
|
||||
runUserPromptSubmitHook,
|
||||
} from "../src/codex-hook.js";
|
||||
|
||||
type CliResult = {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type SessionCache = {
|
||||
staticDedup?: string[];
|
||||
dynamicDedup?: Record<string, string[]>;
|
||||
dynamicTargetFingerprints?: Record<string, string>;
|
||||
};
|
||||
|
||||
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
|
||||
|
||||
function runHookCli(input: string, subcommand = "post-tool-use", env: NodeJS.ProcessEnv = {}): Promise<CliResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [CLI_PATH, "hook", subcommand], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("close", (exitCode) => {
|
||||
resolve({ exitCode, stdout, stderr });
|
||||
});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
const PROJECT_ONLY_ENV = {
|
||||
CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules",
|
||||
};
|
||||
|
||||
const RULES_ONLY_ENV = {
|
||||
CODEX_RULES_ENABLED_SOURCES: ".omo/rules",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempProject(): { root: string; pluginData: string } {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-project-"));
|
||||
const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-data-"));
|
||||
tempDirectories.push(root, pluginData);
|
||||
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
|
||||
writeFileSync(path.join(root, "AGENTS.md"), "Always wear safety goggles when refactoring.");
|
||||
mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(root, ".omo", "rules", "typescript.md"),
|
||||
[
|
||||
"---",
|
||||
"description: TypeScript",
|
||||
'globs: ["**/*.ts", "**/*.tsx"]',
|
||||
"---",
|
||||
"",
|
||||
"Prefer strict TypeScript for all source files.",
|
||||
].join("\n"),
|
||||
);
|
||||
mkdirSync(path.join(root, "src"), { recursive: true });
|
||||
writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n");
|
||||
writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n");
|
||||
return { root, pluginData };
|
||||
}
|
||||
|
||||
function sessionStartInput(root: string): CodexSessionStartInput {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
transcript_path: null,
|
||||
cwd: root,
|
||||
hook_event_name: "SessionStart",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "default",
|
||||
source: "startup",
|
||||
};
|
||||
}
|
||||
|
||||
function postCompactInput(root: string): CodexPostCompactInput {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
turn_id: "turn-compact",
|
||||
transcript_path: null,
|
||||
cwd: root,
|
||||
hook_event_name: "PostCompact",
|
||||
model: "gpt-5.5",
|
||||
trigger: "manual",
|
||||
};
|
||||
}
|
||||
|
||||
function userPromptSubmitInput(
|
||||
root: string,
|
||||
transcriptPath: string | null = null,
|
||||
): Parameters<typeof runUserPromptSubmitHook>[0] {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
turn_id: "turn-1",
|
||||
transcript_path: transcriptPath,
|
||||
cwd: root,
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "default",
|
||||
prompt: "read src/app.ts",
|
||||
};
|
||||
}
|
||||
|
||||
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
turn_id: "turn-1",
|
||||
transcript_path: null,
|
||||
cwd: root,
|
||||
hook_event_name: "PostToolUse",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "default",
|
||||
tool_name: "mcp__filesystem__read_file",
|
||||
tool_input: { path: filePath },
|
||||
tool_response: { text: "file contents" },
|
||||
tool_use_id: "call-1",
|
||||
};
|
||||
}
|
||||
|
||||
function parseHookOutput(output: string): {
|
||||
hookSpecificOutput?: {
|
||||
hookEventName?: string;
|
||||
additionalContext?: string;
|
||||
};
|
||||
} {
|
||||
expect(output.trim().length).toBeGreaterThan(0);
|
||||
return JSON.parse(output) as {
|
||||
hookSpecificOutput?: {
|
||||
hookEventName?: string;
|
||||
additionalContext?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function writeTranscriptWithContext(root: string, ...additionalContexts: string[]): string {
|
||||
const transcriptPath = path.join(root, "transcript.jsonl");
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
`${additionalContexts
|
||||
.map((additionalContext) => JSON.stringify({ hookSpecificOutput: { additionalContext } }))
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
return transcriptPath;
|
||||
}
|
||||
|
||||
function occurrenceCount(value: string, search: string): number {
|
||||
return value.split(search).length - 1;
|
||||
}
|
||||
|
||||
function sessionCacheFilePath(pluginData: string, sessionId = "session-1"): string {
|
||||
return path.join(pluginData, "sessions", `${sessionId}.json`);
|
||||
}
|
||||
|
||||
function readSessionCache(pluginData: string): SessionCache {
|
||||
return JSON.parse(readFileSync(sessionCacheFilePath(pluginData), "utf8")) as SessionCache;
|
||||
}
|
||||
|
||||
function writeTypeScriptRule(root: string, globExpression: string, body: string): void {
|
||||
writeFileSync(
|
||||
path.join(root, ".omo", "rules", "typescript.md"),
|
||||
["---", "description: TypeScript", `globs: ${globExpression}`, "---", "", body].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
describe("codex rules hooks", () => {
|
||||
it("#given project rules #when SessionStart runs #then emits static additional context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
|
||||
// when
|
||||
const output = await runSessionStartHook(sessionStartInput(root), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
|
||||
// then
|
||||
const parsed = parseHookOutput(output);
|
||||
expect(parsed.hookSpecificOutput?.hookEventName).toBe("SessionStart");
|
||||
expect(parsed.hookSpecificOutput?.additionalContext).toContain("## Project Instructions");
|
||||
expect(parsed.hookSpecificOutput?.additionalContext).toContain("Always wear safety goggles");
|
||||
});
|
||||
|
||||
it("#given static context already injected #when UserPromptSubmit runs #then it emits no duplicate context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
await runSessionStartHook(sessionStartInput(root), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
|
||||
|
||||
// when
|
||||
const output = await runUserPromptSubmitHook(
|
||||
{
|
||||
session_id: "session-1",
|
||||
turn_id: "turn-1",
|
||||
transcript_path: null,
|
||||
cwd: root,
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "default",
|
||||
prompt: "read src/app.ts",
|
||||
},
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
|
||||
// then
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("#given resumed session #when SessionStart runs #then it preserves the session cache", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const input = sessionStartInput(root);
|
||||
await runSessionStartHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
|
||||
|
||||
// when
|
||||
const resumeOutput = await runSessionStartHook(
|
||||
{ ...input, source: "resume" },
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
const clearOutput = await runSessionStartHook(
|
||||
{ ...input, source: "clear" },
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
|
||||
// then
|
||||
expect(resumeOutput).toBe("");
|
||||
expect(parseHookOutput(clearOutput).hookSpecificOutput?.additionalContext).toContain(
|
||||
"Always wear safety goggles",
|
||||
);
|
||||
});
|
||||
|
||||
it("#given static context remains in transcript but cache is missing #when SessionStart runs #then it emits no duplicate context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
|
||||
const transcriptPath = writeTranscriptWithContext(root, firstContext);
|
||||
rmSync(sessionCacheFilePath(pluginData), { force: true });
|
||||
|
||||
// when
|
||||
const output = await runSessionStartHook(
|
||||
{ ...sessionStartInput(root), transcript_path: transcriptPath },
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
|
||||
// then
|
||||
expect(output).toBe("");
|
||||
expect(readSessionCache(pluginData).staticDedup).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("#given read-file tool result #when PostToolUse runs #then emits matching dynamic rule context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
|
||||
// then
|
||||
// The literal "src/app.ts" pins POSIX separators and acts as the Windows
|
||||
// regression line: prior versions emitted "src\\app.ts" on Windows.
|
||||
const parsed = parseHookOutput(output);
|
||||
expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
|
||||
expect(parsed.hookSpecificOutput?.additionalContext).toContain(
|
||||
"Additional project instructions matched for src/app.ts",
|
||||
);
|
||||
expect(parsed.hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
|
||||
expect(parsed.hookSpecificOutput?.additionalContext ?? "").not.toContain("src\\app.ts");
|
||||
expect(output).not.toContain("updatedMCPToolOutput");
|
||||
expect(output).not.toContain("suppressOutput");
|
||||
expect(output).not.toContain('"decision"');
|
||||
});
|
||||
|
||||
it("#given multiple target paths matching one rule #when PostToolUse runs #then emits dynamic context once for the first target", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const firstFilePath = path.join(root, "src", "app.ts");
|
||||
const secondFilePath = path.join(root, "src", "other.ts");
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(
|
||||
{
|
||||
...postToolUseInput(root, firstFilePath),
|
||||
tool_name: "mcp__filesystem__read_multiple_files",
|
||||
tool_input: { paths: [firstFilePath, secondFilePath, firstFilePath] },
|
||||
},
|
||||
{
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
},
|
||||
);
|
||||
|
||||
// then
|
||||
const parsed = parseHookOutput(output);
|
||||
const additionalContext = parsed.hookSpecificOutput?.additionalContext ?? "";
|
||||
expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
|
||||
expect(additionalContext).toContain("Additional project instructions matched for src/app.ts");
|
||||
expect(additionalContext).not.toContain("src\\app.ts");
|
||||
expect(occurrenceCount(additionalContext, "Prefer strict TypeScript")).toBe(1);
|
||||
});
|
||||
|
||||
it("#given dynamic context already injected #when PostToolUse repeats #then emits no duplicate context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
const input = postToolUseInput(root, filePath);
|
||||
await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
|
||||
const cachedState = readSessionCache(pluginData);
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
|
||||
|
||||
// then
|
||||
expect(output).toBe("");
|
||||
expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1);
|
||||
expect(readSessionCache(pluginData).dynamicTargetFingerprints).toEqual(cachedState.dynamicTargetFingerprints);
|
||||
});
|
||||
|
||||
it("#given dynamic context remains in transcript but cache is missing #when PostToolUse repeats #then it emits no duplicate context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
const input = postToolUseInput(root, filePath);
|
||||
const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
|
||||
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
|
||||
const transcriptPath = writeTranscriptWithContext(root, firstContext);
|
||||
rmSync(sessionCacheFilePath(pluginData), { force: true });
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(
|
||||
{ ...input, transcript_path: transcriptPath },
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
|
||||
// then
|
||||
const cachedState = readSessionCache(pluginData);
|
||||
expect(output).toBe("");
|
||||
expect(Object.values(cachedState.dynamicDedup ?? {}).flat()).toHaveLength(2);
|
||||
expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("#given cached target in one session #when another session reads it #then PostToolUse rechecks independently", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
await runPostToolUseHook(postToolUseInput(root, filePath), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(
|
||||
{ ...postToolUseInput(root, filePath), session_id: "session-2" },
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
|
||||
// then
|
||||
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
|
||||
});
|
||||
|
||||
it("#given cached dynamic target #when rule frontmatter changes #then PostToolUse rechecks the target", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
const input = postToolUseInput(root, filePath);
|
||||
await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV });
|
||||
writeTypeScriptRule(root, '"**/*.ts"', "Prefer readonly TypeScript after rule edits.");
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV });
|
||||
|
||||
// then
|
||||
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain(
|
||||
"Prefer readonly TypeScript after rule edits.",
|
||||
);
|
||||
});
|
||||
|
||||
it("#given cached dynamic context #when PostCompact runs #then PostToolUse can re-inject after compaction", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
const input = postToolUseInput(root, filePath);
|
||||
const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
|
||||
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
|
||||
const transcriptPath = writeTranscriptWithContext(root, firstContext);
|
||||
expect(
|
||||
await runPostToolUseHook(
|
||||
{ ...input, transcript_path: transcriptPath },
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
),
|
||||
).toBe("");
|
||||
|
||||
// when
|
||||
const compactOutput = await runPostCompactHook(
|
||||
{ ...postCompactInput(root), transcript_path: transcriptPath },
|
||||
{ pluginDataRoot: pluginData },
|
||||
);
|
||||
const output = await runPostToolUseHook(
|
||||
{ ...input, transcript_path: transcriptPath },
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
|
||||
// then
|
||||
expect(compactOutput).toBe("");
|
||||
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
|
||||
});
|
||||
|
||||
it("#given compacted transcript #when static re-injects before dynamic #then dynamic still re-injects", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
const staticOutput = await runSessionStartHook(sessionStartInput(root), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
const transcriptPath = writeTranscriptWithContext(
|
||||
root,
|
||||
parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "",
|
||||
parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "",
|
||||
);
|
||||
await runPostCompactHook(
|
||||
{ ...postCompactInput(root), transcript_path: transcriptPath },
|
||||
{ pluginDataRoot: pluginData },
|
||||
);
|
||||
|
||||
// when
|
||||
const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
const dynamicReinjectOutput = await runPostToolUseHook(
|
||||
{ ...postToolUseInput(root, filePath), transcript_path: transcriptPath },
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
|
||||
// then
|
||||
expect(parseHookOutput(staticReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
|
||||
"Always wear safety goggles",
|
||||
);
|
||||
expect(parseHookOutput(dynamicReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
|
||||
"Prefer strict TypeScript",
|
||||
);
|
||||
});
|
||||
|
||||
it("#given compacted transcript #when dynamic re-injects before static #then static still re-injects", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
const staticOutput = await runSessionStartHook(sessionStartInput(root), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
const transcriptPath = writeTranscriptWithContext(
|
||||
root,
|
||||
parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "",
|
||||
parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "",
|
||||
);
|
||||
await runPostCompactHook(
|
||||
{ ...postCompactInput(root), transcript_path: transcriptPath },
|
||||
{ pluginDataRoot: pluginData },
|
||||
);
|
||||
|
||||
// when
|
||||
const dynamicReinjectOutput = await runPostToolUseHook(
|
||||
{ ...postToolUseInput(root, filePath), transcript_path: transcriptPath },
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
|
||||
// then
|
||||
expect(parseHookOutput(dynamicReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
|
||||
"Prefer strict TypeScript",
|
||||
);
|
||||
expect(parseHookOutput(staticReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
|
||||
"Always wear safety goggles",
|
||||
);
|
||||
});
|
||||
|
||||
it("#given legacy session cache #when PostToolUse hydrates state #then it accepts the old shape", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
mkdirSync(path.join(pluginData, "sessions"), { recursive: true });
|
||||
writeFileSync(sessionCacheFilePath(pluginData), `${JSON.stringify({ staticDedup: [], dynamicDedup: {} })}\n`);
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(postToolUseInput(root, path.join(root, "src", "app.ts")), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_ONLY_ENV,
|
||||
});
|
||||
|
||||
// then
|
||||
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
|
||||
});
|
||||
|
||||
it("#given static-only mode #when PostToolUse runs #then emits no dynamic context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: {
|
||||
...PROJECT_ONLY_ENV,
|
||||
CODEX_RULES_MODE: "static",
|
||||
},
|
||||
});
|
||||
|
||||
// then
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("#given rules disabled #when PostToolUse runs #then emits no dynamic context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: {
|
||||
...PROJECT_ONLY_ENV,
|
||||
CODEX_RULES_DISABLED: "true",
|
||||
},
|
||||
});
|
||||
|
||||
// then
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("#given failed tool response #when PostToolUse runs #then emits no dynamic context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const filePath = path.join(root, "src", "app.ts");
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(
|
||||
{
|
||||
...postToolUseInput(root, filePath),
|
||||
tool_response: { is_error: true },
|
||||
},
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
|
||||
// then
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("#given tracked tool without path #when PostToolUse runs #then emits no dynamic context", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
|
||||
// when
|
||||
const output = await runPostToolUseHook(
|
||||
{
|
||||
...postToolUseInput(root, ""),
|
||||
tool_input: {},
|
||||
},
|
||||
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
|
||||
);
|
||||
|
||||
// then
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => {
|
||||
// given
|
||||
const input = "break;\n";
|
||||
|
||||
// when
|
||||
const result = await runHookCli(input);
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => {
|
||||
// given
|
||||
const input = "[]\n";
|
||||
|
||||
// when
|
||||
const result = await runHookCli(input);
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("#given debug timing enabled #when PostToolUse hook CLI runs #then phase logs go to stderr only", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeTempProject();
|
||||
const input = `${JSON.stringify(postToolUseInput(root, path.join(root, "src", "app.ts")))}\n`;
|
||||
|
||||
// when
|
||||
const result = await runHookCli(input, "post-tool-use", {
|
||||
NODE_DEBUG: "codex-rules",
|
||||
PLUGIN_DATA: pluginData,
|
||||
});
|
||||
|
||||
// then
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("hookSpecificOutput");
|
||||
expect(result.stderr).toContain("PostToolUse");
|
||||
expect(result.stderr).toContain("extract");
|
||||
expect(result.stderr).toContain("fingerprint");
|
||||
expect(result.stderr).toContain("load");
|
||||
expect(result.stderr).toContain("persist");
|
||||
expect(result.stderr).toContain("ms");
|
||||
});
|
||||
|
||||
it("#given malformed post-compact stdin #when hook CLI runs #then it no-ops without stderr", async () => {
|
||||
// given
|
||||
const input = `${JSON.stringify({ hook_event_name: "PostCompact", session_id: "s", turn_id: "t" })}\n`;
|
||||
|
||||
// when
|
||||
const result = await runHookCli(input, "post-compact");
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
|
||||
import { matchRule as defaultMatchRule } from "../src/rules/matcher.js";
|
||||
import type { RuleCandidate } from "../src/rules/types.js";
|
||||
|
||||
const projectRoot = "/tmp/codex-rules-engine";
|
||||
|
||||
function makeCandidate(): RuleCandidate {
|
||||
return {
|
||||
path: join(projectRoot, ".omo", "rules", "typescript.md"),
|
||||
realPath: join(projectRoot, ".omo", "rules", "typescript.md"),
|
||||
source: ".omo/rules",
|
||||
distance: 0,
|
||||
isGlobal: false,
|
||||
isSingleFile: false,
|
||||
relativePath: ".omo/rules/typescript.md",
|
||||
};
|
||||
}
|
||||
|
||||
describe("rule engine dynamic matching", () => {
|
||||
it("#given duplicate target paths #when loading dynamic rules #then repeated discovery and parsing work is avoided", () => {
|
||||
// given
|
||||
const targetPath = join(projectRoot, "src", "app.ts");
|
||||
const candidate = makeCandidate();
|
||||
const counters = {
|
||||
findProjectRoot: 0,
|
||||
findCandidates: 0,
|
||||
readFile: 0,
|
||||
};
|
||||
const deps = {
|
||||
findProjectRoot: () => {
|
||||
counters.findProjectRoot += 1;
|
||||
return projectRoot;
|
||||
},
|
||||
findCandidates: () => {
|
||||
counters.findCandidates += 1;
|
||||
return [candidate];
|
||||
},
|
||||
readFile: () => {
|
||||
counters.readFile += 1;
|
||||
return ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n");
|
||||
},
|
||||
} satisfies EngineDeps;
|
||||
const engine = createEngine(defaultConfig(), deps);
|
||||
|
||||
// when
|
||||
const result = engine.loadDynamicRules(projectRoot, [targetPath, targetPath, targetPath]);
|
||||
|
||||
// then
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(counters).toEqual({
|
||||
findProjectRoot: 1,
|
||||
findCandidates: 1,
|
||||
readFile: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("#given distinct target files in same directory #when loading dynamic rules #then candidate discovery is reused", () => {
|
||||
// given
|
||||
const firstTarget = join(projectRoot, "src", "first.ts");
|
||||
const secondTarget = join(projectRoot, "src", "second.ts");
|
||||
const thirdTarget = join(projectRoot, "src", "third.ts");
|
||||
const candidate = makeCandidate();
|
||||
let findCandidatesCalls = 0;
|
||||
const deps = {
|
||||
findProjectRoot: () => projectRoot,
|
||||
findCandidates: () => {
|
||||
findCandidatesCalls += 1;
|
||||
return [candidate];
|
||||
},
|
||||
readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"),
|
||||
} satisfies EngineDeps;
|
||||
const engine = createEngine(defaultConfig(), deps);
|
||||
|
||||
// when
|
||||
const result = engine.loadDynamicRules(projectRoot, [firstTarget, secondTarget, thirdTarget]);
|
||||
|
||||
// then
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(findCandidatesCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("#given same rule content and target across loads #when loading dynamic rules repeats #then cached match decision is reused", () => {
|
||||
// given
|
||||
const targetPath = join(projectRoot, "src", "app.ts");
|
||||
const candidate = makeCandidate();
|
||||
let matchCalls = 0;
|
||||
const deps = {
|
||||
findProjectRoot: () => projectRoot,
|
||||
findCandidates: () => [candidate],
|
||||
readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"),
|
||||
matchRule: (input) => {
|
||||
matchCalls += 1;
|
||||
return defaultMatchRule(input);
|
||||
},
|
||||
} satisfies EngineDeps;
|
||||
const engine = createEngine(defaultConfig(), deps);
|
||||
|
||||
// when
|
||||
const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]);
|
||||
const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]);
|
||||
|
||||
// then
|
||||
expect(firstResult.rules).toHaveLength(1);
|
||||
expect(secondResult.rules).toHaveLength(1);
|
||||
expect(matchCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("#given same rule path changes body #when loading dynamic rules repeats #then cached match decision invalidates", () => {
|
||||
// given
|
||||
const targetPath = join(projectRoot, "src", "app.ts");
|
||||
const candidate = makeCandidate();
|
||||
let body = "Prefer strict TypeScript.";
|
||||
let matchCalls = 0;
|
||||
const deps = {
|
||||
findProjectRoot: () => projectRoot,
|
||||
findCandidates: () => [candidate],
|
||||
readFile: () => ["---", "globs: **/*.ts", "---", "", body].join("\n"),
|
||||
matchRule: (input) => {
|
||||
matchCalls += 1;
|
||||
return defaultMatchRule(input);
|
||||
},
|
||||
} satisfies EngineDeps;
|
||||
const engine = createEngine(defaultConfig(), deps);
|
||||
|
||||
// when
|
||||
engine.loadDynamicRules(projectRoot, [targetPath]);
|
||||
body = "Prefer readonly TypeScript.";
|
||||
engine.loadDynamicRules(projectRoot, [targetPath]);
|
||||
|
||||
// then
|
||||
expect(matchCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("#given same rule path changes frontmatter #when loading dynamic rules repeats #then cached match decision invalidates", () => {
|
||||
// given
|
||||
const targetPath = join(projectRoot, "src", "app.ts");
|
||||
const candidate = makeCandidate();
|
||||
let globs = "**/*.ts";
|
||||
let matchCalls = 0;
|
||||
const deps = {
|
||||
findProjectRoot: () => projectRoot,
|
||||
findCandidates: () => [candidate],
|
||||
readFile: () => ["---", `globs: ${globs}`, "---", "", "Prefer strict TypeScript."].join("\n"),
|
||||
matchRule: (input) => {
|
||||
matchCalls += 1;
|
||||
return defaultMatchRule(input);
|
||||
},
|
||||
} satisfies EngineDeps;
|
||||
const engine = createEngine(defaultConfig(), deps);
|
||||
|
||||
// when
|
||||
const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]);
|
||||
globs = "**/*.tsx";
|
||||
const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]);
|
||||
|
||||
// then
|
||||
expect(firstResult.rules).toHaveLength(1);
|
||||
expect(secondResult.rules).toHaveLength(0);
|
||||
expect(matchCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("#given same rule and different targets #when loading dynamic rules repeats #then target-specific decisions do not leak", () => {
|
||||
// given
|
||||
const sourceTarget = join(projectRoot, "src", "app.ts");
|
||||
const testTarget = join(projectRoot, "src", "app.test.ts");
|
||||
const candidate = makeCandidate();
|
||||
let matchCalls = 0;
|
||||
const deps = {
|
||||
findProjectRoot: () => projectRoot,
|
||||
findCandidates: () => [candidate],
|
||||
readFile: () =>
|
||||
["---", 'globs: ["**/*.ts", "!**/*.test.ts"]', "---", "", "Prefer strict TypeScript."].join("\n"),
|
||||
matchRule: (input) => {
|
||||
matchCalls += 1;
|
||||
return defaultMatchRule(input);
|
||||
},
|
||||
} satisfies EngineDeps;
|
||||
const engine = createEngine(defaultConfig(), deps);
|
||||
|
||||
// when
|
||||
const sourceResult = engine.loadDynamicRules(projectRoot, [sourceTarget]);
|
||||
const testResult = engine.loadDynamicRules(projectRoot, [testTarget]);
|
||||
|
||||
// then
|
||||
expect(sourceResult.rules).toHaveLength(1);
|
||||
expect(testResult.rules).toHaveLength(0);
|
||||
expect(matchCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { findRuleCandidates } from "../src/rules/finder.js";
|
||||
import type { RuleCandidate } from "../src/rules/types.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeProject(): { projectRoot: string; homeRoot: string; targetPath: string } {
|
||||
const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-project-"));
|
||||
const homeRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-home-"));
|
||||
tempDirectories.push(projectRoot, homeRoot);
|
||||
mkdirSync(join(projectRoot, "src", ".omo", "rules"), { recursive: true });
|
||||
mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true });
|
||||
mkdirSync(join(homeRoot, ".opencode", "rules"), { recursive: true });
|
||||
mkdirSync(join(homeRoot, ".config", "opencode"), { recursive: true });
|
||||
writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "fixture" }));
|
||||
writeFileSync(join(projectRoot, "AGENTS.md"), "Project rule\n");
|
||||
writeFileSync(join(projectRoot, "src", ".omo", "rules", "local.md"), "Local rule\n");
|
||||
writeFileSync(join(projectRoot, ".omo", "rules", "root.md"), "Root rule\n");
|
||||
writeFileSync(join(homeRoot, ".opencode", "rules", "global.md"), "Global rule\n");
|
||||
writeFileSync(join(homeRoot, ".config", "opencode", "AGENTS.md"), "Home rule\n");
|
||||
const targetPath = join(projectRoot, "src", "app.ts");
|
||||
writeFileSync(targetPath, "export const app = true;\n");
|
||||
return { projectRoot, homeRoot, targetPath };
|
||||
}
|
||||
|
||||
function candidateSummary(candidate: RuleCandidate): string {
|
||||
return `${candidate.source}:${candidate.distance}:${candidate.relativePath}`;
|
||||
}
|
||||
|
||||
describe("findRuleCandidates", () => {
|
||||
it("#given project and user-home rules #when target file is inside project #then candidates keep source distance", () => {
|
||||
// given
|
||||
const { projectRoot, homeRoot, targetPath } = makeProject();
|
||||
|
||||
// when
|
||||
const candidates = findRuleCandidates({ projectRoot, targetFile: targetPath, homeDir: homeRoot });
|
||||
|
||||
// then
|
||||
expect(candidates.map(candidateSummary)).toEqual([
|
||||
".omo/rules:0:src/.omo/rules/local.md",
|
||||
".omo/rules:1:.omo/rules/root.md",
|
||||
"AGENTS.md:1:AGENTS.md",
|
||||
"~/.opencode/rules:9999:.opencode/rules/global.md",
|
||||
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("#given disabled source #when finding candidates #then matching source is omitted", () => {
|
||||
// given
|
||||
const { projectRoot, homeRoot, targetPath } = makeProject();
|
||||
|
||||
// when
|
||||
const candidates = findRuleCandidates({
|
||||
projectRoot,
|
||||
targetFile: targetPath,
|
||||
homeDir: homeRoot,
|
||||
disabledSources: new Set([".omo/rules", "~/.opencode/rules"]),
|
||||
});
|
||||
|
||||
// then
|
||||
expect(candidates.map(candidateSummary)).toEqual([
|
||||
"AGENTS.md:1:AGENTS.md",
|
||||
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("#given skip user home #when finding candidates #then only project rules are returned", () => {
|
||||
// given
|
||||
const { projectRoot, homeRoot, targetPath } = makeProject();
|
||||
|
||||
// when
|
||||
const candidates = findRuleCandidates({
|
||||
projectRoot,
|
||||
targetFile: targetPath,
|
||||
homeDir: homeRoot,
|
||||
skipUserHome: true,
|
||||
});
|
||||
|
||||
// then
|
||||
expect(candidates.map(candidateSummary)).toEqual([
|
||||
".omo/rules:0:src/.omo/rules/local.md",
|
||||
".omo/rules:1:.omo/rules/root.md",
|
||||
"AGENTS.md:1:AGENTS.md",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { matchRule, normalizeGlobs } from "../src/rules/matcher.js";
|
||||
import type { RuleFrontmatter } from "../src/rules/types.js";
|
||||
|
||||
function matchFrontmatter(
|
||||
frontmatter: RuleFrontmatter,
|
||||
pathBases: {
|
||||
projectRelative: string;
|
||||
scopeRelative?: string;
|
||||
basename?: string;
|
||||
},
|
||||
): ReturnType<typeof matchRule> {
|
||||
const scopeRelative = pathBases.scopeRelative;
|
||||
const pathBase = {
|
||||
projectRelative: pathBases.projectRelative,
|
||||
basename: pathBases.basename ?? pathBases.projectRelative.split("/").at(-1) ?? pathBases.projectRelative,
|
||||
...(scopeRelative === undefined ? {} : { scopeRelative }),
|
||||
};
|
||||
return matchRule({
|
||||
frontmatter,
|
||||
isSingleFile: false,
|
||||
pathBases: pathBase,
|
||||
});
|
||||
}
|
||||
|
||||
function matchGlobs(globs: string | string[], projectRelative: string): boolean {
|
||||
return matchFrontmatter({ globs } satisfies RuleFrontmatter, { projectRelative }).matched;
|
||||
}
|
||||
|
||||
describe("matchRule", () => {
|
||||
it("#given single-file rule #when matching any target #then it always matches", () => {
|
||||
// given
|
||||
const frontmatter = {} satisfies RuleFrontmatter;
|
||||
|
||||
// when
|
||||
const result = matchRule({
|
||||
frontmatter,
|
||||
isSingleFile: true,
|
||||
pathBases: { projectRelative: "docs/readme.md", basename: "readme.md" },
|
||||
});
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ matched: true, reason: "single-file" });
|
||||
});
|
||||
|
||||
it("#given always apply rule #when no glob is configured #then it matches", () => {
|
||||
// given
|
||||
const frontmatter = { alwaysApply: true } satisfies RuleFrontmatter;
|
||||
|
||||
// when
|
||||
const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" });
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ matched: true, reason: "alwaysApply" });
|
||||
});
|
||||
|
||||
it("#given rule without patterns #when target is checked #then no match is returned", () => {
|
||||
// given
|
||||
const frontmatter = {} satisfies RuleFrontmatter;
|
||||
|
||||
// when
|
||||
const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" });
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ matched: false, reason: { kind: "no-match" } });
|
||||
});
|
||||
|
||||
it("#given recursive glob #when target is nested #then matches without runtime dependencies", () => {
|
||||
// given
|
||||
const globs = "**/*.ts";
|
||||
|
||||
// when
|
||||
const matched = matchGlobs(globs, "src/features/app.ts");
|
||||
|
||||
// then
|
||||
expect(matched).toBe(true);
|
||||
});
|
||||
|
||||
it("#given paths alias #when target matches #then glob match is returned", () => {
|
||||
// given
|
||||
const frontmatter = { paths: "src/**/*.ts" } satisfies RuleFrontmatter;
|
||||
|
||||
// when
|
||||
const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" });
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
|
||||
});
|
||||
|
||||
it("#given applyTo alias #when basename matches #then glob match is returned", () => {
|
||||
// given
|
||||
const frontmatter = { applyTo: "*.md" } satisfies RuleFrontmatter;
|
||||
|
||||
// when
|
||||
const result = matchFrontmatter(frontmatter, { projectRelative: "docs/README.md" });
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "*.md" } });
|
||||
});
|
||||
|
||||
it("#given scope-relative target #when scoped path matches #then glob match is returned", () => {
|
||||
// given
|
||||
const frontmatter = { globs: "components/**/*.tsx" } satisfies RuleFrontmatter;
|
||||
|
||||
// when
|
||||
const result = matchFrontmatter(frontmatter, {
|
||||
projectRelative: "packages/ui/components/button.tsx",
|
||||
scopeRelative: "components/button.tsx",
|
||||
});
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "components/**/*.tsx" } });
|
||||
});
|
||||
|
||||
it("#given backslash glob and target #when matching #then paths are normalized", () => {
|
||||
// given
|
||||
const frontmatter = { globs: "src\\**\\*.ts" } satisfies RuleFrontmatter;
|
||||
|
||||
// when
|
||||
const result = matchFrontmatter(frontmatter, { projectRelative: "src\\features\\app.ts" });
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
|
||||
});
|
||||
|
||||
it("#given multiple positive globs #when later glob matches #then matching pattern is reported", () => {
|
||||
// given
|
||||
const frontmatter = { globs: ["docs/**/*.md", "src/**/*.ts"] } satisfies RuleFrontmatter;
|
||||
|
||||
// when
|
||||
const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" });
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
|
||||
});
|
||||
|
||||
it("#given negative glob #when target is excluded #then no match is returned", () => {
|
||||
// given
|
||||
const globs = ["**/*.ts", "!**/*.test.ts"];
|
||||
|
||||
// when
|
||||
const matched = matchGlobs(globs, "src/features/app.test.ts");
|
||||
|
||||
// then
|
||||
expect(matched).toBe(false);
|
||||
});
|
||||
|
||||
it("#given question-mark glob #when one filename character differs #then target matches", () => {
|
||||
// given
|
||||
const globs = "src/app-?.ts";
|
||||
|
||||
// when
|
||||
const matched = matchGlobs(globs, "src/app-a.ts");
|
||||
|
||||
// then
|
||||
expect(matched).toBe(true);
|
||||
});
|
||||
|
||||
it("#given brace glob #when target extension is listed #then matches", () => {
|
||||
// given
|
||||
const globs = "src/**/*.{ts,tsx}";
|
||||
|
||||
// when
|
||||
const matched = matchGlobs(globs, "src/features/app.tsx");
|
||||
|
||||
// then
|
||||
expect(matched).toBe(true);
|
||||
});
|
||||
|
||||
it("#given character class glob #when matching listed extension #then target matches", () => {
|
||||
// given
|
||||
const globs = "src/**/*.[tj]s";
|
||||
|
||||
// when
|
||||
const matched = matchGlobs(globs, "src/features/app.ts");
|
||||
|
||||
// then
|
||||
expect(matched).toBe(true);
|
||||
});
|
||||
|
||||
it("#given extglob pattern #when matching allowed extension #then target matches", () => {
|
||||
// given
|
||||
const globs = "src/**/*.@(ts|tsx)";
|
||||
|
||||
// when
|
||||
const matched = matchGlobs(globs, "src/features/app.tsx");
|
||||
|
||||
// then
|
||||
expect(matched).toBe(true);
|
||||
});
|
||||
|
||||
it("#given duplicate normalized patterns #when normalizing #then first unique pattern order is kept", () => {
|
||||
// given
|
||||
const frontmatter = {
|
||||
globs: ["src\\**\\*.ts", "src/**/*.ts", "!src/**/*.test.ts"],
|
||||
paths: "!src/**/*.test.ts",
|
||||
} satisfies RuleFrontmatter;
|
||||
|
||||
// when
|
||||
const patterns = normalizeGlobs(frontmatter);
|
||||
|
||||
// then
|
||||
expect(patterns).toEqual(["src/**/*.ts", "!src/**/*.test.ts"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type PackageJson = {
|
||||
readonly type: string;
|
||||
readonly packageManager: string;
|
||||
readonly bin: Record<string, string>;
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type PluginJson = {
|
||||
readonly hooks: string;
|
||||
};
|
||||
|
||||
type HookCommand = {
|
||||
readonly command: string;
|
||||
};
|
||||
|
||||
type HookEntry = {
|
||||
readonly matcher?: string;
|
||||
readonly hooks: readonly HookCommand[];
|
||||
};
|
||||
|
||||
type HooksJson = {
|
||||
readonly hooks: Record<string, readonly HookEntry[]>;
|
||||
};
|
||||
|
||||
function readPackageJson(path: string): PackageJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readPluginJson(path: string): PluginJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readHooksJson(path: string): HooksJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe("plugin package metadata", () => {
|
||||
it("#given packaged plugin files #when validating entrypoints #then hook commands use portable plugin root interpolation", () => {
|
||||
// given
|
||||
const packageJson = readPackageJson("package.json");
|
||||
const pluginJson = readPluginJson(".codex-plugin/plugin.json");
|
||||
const hooksJson = readHooksJson("hooks/hooks.json");
|
||||
const cliSource = readFileSync("src/cli.ts", "utf8");
|
||||
|
||||
// when
|
||||
const hookConfig = hooksJson.hooks;
|
||||
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
|
||||
const commands = [
|
||||
hookConfig["SessionStart"]?.[0]?.hooks[0]?.command,
|
||||
hookConfig["UserPromptSubmit"]?.[0]?.hooks[0]?.command,
|
||||
hookConfig["PostToolUse"]?.[0]?.hooks[0]?.command,
|
||||
hookConfig["PostCompact"]?.[0]?.hooks[0]?.command,
|
||||
];
|
||||
const postToolUseMatcher = hookConfig["PostToolUse"]?.[0]?.matcher ?? "";
|
||||
|
||||
// then
|
||||
expect(packageJson.type).toBe("module");
|
||||
expect(packageJson.packageManager).toBe("npm@11.12.1");
|
||||
expect(packageJson.dependencies ?? {}).toEqual({ picomatch: "^4.0.3" });
|
||||
expect(packageJson.bin["codex-rules"]).toBe("./dist/cli.js");
|
||||
expect(pluginJson.hooks).toBe("./hooks/hooks.json");
|
||||
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
|
||||
expect(commands).toEqual([
|
||||
`node "${pluginRoot}/dist/cli.js" hook session-start`,
|
||||
`node "${pluginRoot}/dist/cli.js" hook user-prompt-submit`,
|
||||
`node "${pluginRoot}/dist/cli.js" hook post-tool-use`,
|
||||
`node "${pluginRoot}/dist/cli.js" hook post-compact`,
|
||||
]);
|
||||
expect(postToolUseMatcher).toBe("^apply_patch$");
|
||||
const postToolUseMatcherRegex = new RegExp(postToolUseMatcher);
|
||||
expect(postToolUseMatcherRegex.test("apply_patch")).toBe(true);
|
||||
expect(
|
||||
[
|
||||
"read",
|
||||
"Read",
|
||||
"read_file",
|
||||
"mcp__filesystem__read_file",
|
||||
"mcp__filesystem__read_multiple_files",
|
||||
"mcp__filesystem__write_file",
|
||||
"mcp__filesystem__edit_file",
|
||||
"write",
|
||||
"Write",
|
||||
"edit",
|
||||
"Edit",
|
||||
"multi_edit",
|
||||
"MultiEdit",
|
||||
"multiedit",
|
||||
"exec_command",
|
||||
"shell_command",
|
||||
"bash",
|
||||
"Bash",
|
||||
].some((toolName) => postToolUseMatcherRegex.test(toolName)),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function isPackageJson(value: unknown): value is PackageJson {
|
||||
if (!isRecord(value)) return false;
|
||||
const dependencies = value["dependencies"];
|
||||
return (
|
||||
value["type"] === "module" &&
|
||||
value["packageManager"] === "npm@11.12.1" &&
|
||||
isStringRecord(value["bin"]) &&
|
||||
(dependencies === undefined || isRecord(dependencies))
|
||||
);
|
||||
}
|
||||
|
||||
function isPluginJson(value: unknown): value is PluginJson {
|
||||
return isRecord(value) && typeof value["hooks"] === "string";
|
||||
}
|
||||
|
||||
function isHooksJson(value: unknown): value is HooksJson {
|
||||
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
|
||||
return Object.values(value["hooks"]).every(isHookEntries);
|
||||
}
|
||||
|
||||
function isHookEntries(value: unknown): value is readonly HookEntry[] {
|
||||
return Array.isArray(value) && value.every(isHookEntry);
|
||||
}
|
||||
|
||||
function isHookEntry(value: unknown): value is HookEntry {
|
||||
return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand);
|
||||
}
|
||||
|
||||
function isHookCommand(value: unknown): value is HookCommand {
|
||||
return isRecord(value) && typeof value["command"] === "string";
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { scanRuleFiles } from "../src/rules/scanner.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("scanRuleFiles", () => {
|
||||
it("#given more rule files than max #when scanning #then returns only capped files", () => {
|
||||
// given
|
||||
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
|
||||
tempDirectories.push(root);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
writeFileSync(join(root, `rule-${index}.md`), `Rule ${index}\n`);
|
||||
}
|
||||
|
||||
// when
|
||||
const files = scanRuleFiles({ rootDir: root, maxFiles: 2 });
|
||||
|
||||
// then
|
||||
expect(files).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("#given rule files and an excluded directory #when scanning #then returns sorted non-excluded files", () => {
|
||||
// given
|
||||
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
|
||||
tempDirectories.push(root);
|
||||
mkdirSync(join(root, "dist"), { recursive: true });
|
||||
writeFileSync(join(root, "beta.md"), "Beta\n");
|
||||
writeFileSync(join(root, "alpha.md"), "Alpha\n");
|
||||
writeFileSync(join(root, "dist", "ignored.md"), "Ignored\n");
|
||||
|
||||
// when
|
||||
const files = scanRuleFiles({ rootDir: root });
|
||||
|
||||
// then
|
||||
expect(files.map((file) => file.path)).toEqual([join(root, "alpha.md"), join(root, "beta.md")]);
|
||||
});
|
||||
|
||||
it("#given symlink loop #when scanning #then traversal terminates without duplicate files", () => {
|
||||
// given
|
||||
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
|
||||
tempDirectories.push(root);
|
||||
const nested = join(root, "nested");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(join(root, "root.md"), "Root\n");
|
||||
symlinkSync(root, join(nested, "loop"));
|
||||
|
||||
// when
|
||||
const files = scanRuleFiles({ rootDir: root });
|
||||
|
||||
// then
|
||||
expect(files.map((file) => file.path)).toEqual([join(root, "root.md")]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { type CodexPostToolUseLike, extractCodexToolPaths } from "../src/tool-paths.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeProject(): string {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-paths-"));
|
||||
tempDirectories.push(root);
|
||||
mkdirSync(path.join(root, "src"), { recursive: true });
|
||||
writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n");
|
||||
return root;
|
||||
}
|
||||
|
||||
function postToolUse(input: { toolName: string; toolInput?: unknown; toolResponse?: unknown }): CodexPostToolUseLike {
|
||||
return {
|
||||
tool_name: input.toolName,
|
||||
tool_input: input.toolInput ?? {},
|
||||
tool_response: input.toolResponse ?? { text: "ok" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractCodexToolPaths", () => {
|
||||
it("#given filesystem read payload #when extracting #then returns resolved path", () => {
|
||||
// given
|
||||
const root = makeProject();
|
||||
|
||||
// when
|
||||
const paths = extractCodexToolPaths(
|
||||
postToolUse({
|
||||
toolName: "mcp__filesystem__read_file",
|
||||
toolInput: { path: "src/app.ts" },
|
||||
}),
|
||||
root,
|
||||
);
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
|
||||
});
|
||||
|
||||
it("#given apply_patch payload #when extracting #then returns patched file paths", () => {
|
||||
// given
|
||||
const root = makeProject();
|
||||
|
||||
// when
|
||||
const paths = extractCodexToolPaths(
|
||||
postToolUse({
|
||||
toolName: "apply_patch",
|
||||
toolInput: {
|
||||
command: [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/app.ts",
|
||||
"@@",
|
||||
"+export const changed = true;",
|
||||
"*** End Patch",
|
||||
].join("\n"),
|
||||
},
|
||||
}),
|
||||
root,
|
||||
);
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
|
||||
});
|
||||
|
||||
it("#given apply_patch add update and move payload #when extracting #then returns each target once", () => {
|
||||
// given
|
||||
const root = makeProject();
|
||||
|
||||
// when
|
||||
const paths = extractCodexToolPaths(
|
||||
postToolUse({
|
||||
toolName: "apply_patch",
|
||||
toolInput: {
|
||||
command: [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: src/new.ts",
|
||||
"+export const created = true;",
|
||||
"*** Update File: src/app.ts",
|
||||
"*** Move to: src/moved.ts",
|
||||
"@@",
|
||||
"-export const app = true;",
|
||||
"+export const moved = true;",
|
||||
"*** Update File: src/moved.ts",
|
||||
"@@",
|
||||
"-export const moved = true;",
|
||||
"+export const moved = false;",
|
||||
"*** End Patch",
|
||||
].join("\n"),
|
||||
},
|
||||
}),
|
||||
root,
|
||||
);
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([
|
||||
path.join(root, "src", "new.ts"),
|
||||
path.join(root, "src", "app.ts"),
|
||||
path.join(root, "src", "moved.ts"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("#given mcp write-file payload #when extracting #then returns resolved path", () => {
|
||||
// given
|
||||
const root = makeProject();
|
||||
|
||||
// when
|
||||
const paths = extractCodexToolPaths(
|
||||
postToolUse({
|
||||
toolName: "mcp__filesystem__write_file",
|
||||
toolInput: { path: "src/app.ts", content: "export const app = true;\n" },
|
||||
}),
|
||||
root,
|
||||
);
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
|
||||
});
|
||||
|
||||
it("#given mcp edit-file payload #when extracting #then returns resolved path", () => {
|
||||
// given
|
||||
const root = makeProject();
|
||||
|
||||
// when
|
||||
const paths = extractCodexToolPaths(
|
||||
postToolUse({
|
||||
toolName: "mcp__filesystem__edit_file",
|
||||
toolInput: { path: "src/app.ts", edits: [] },
|
||||
}),
|
||||
root,
|
||||
);
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
|
||||
});
|
||||
|
||||
it("#given mcp read-multiple-files payload #when extracting #then returns all resolved paths", () => {
|
||||
// given
|
||||
const root = makeProject();
|
||||
writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n");
|
||||
|
||||
// when
|
||||
const paths = extractCodexToolPaths(
|
||||
postToolUse({
|
||||
toolName: "mcp__filesystem__read_multiple_files",
|
||||
toolInput: { paths: ["src/app.ts", "src/other.ts"] },
|
||||
}),
|
||||
root,
|
||||
);
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([path.join(root, "src", "app.ts"), path.join(root, "src", "other.ts")]);
|
||||
});
|
||||
|
||||
it("#given shell command payload #when extracting #then returns only existing file tokens", () => {
|
||||
// given
|
||||
const root = makeProject();
|
||||
|
||||
// when
|
||||
const paths = extractCodexToolPaths(
|
||||
postToolUse({
|
||||
toolName: "exec_command",
|
||||
toolInput: { cmd: "sed -n '1,80p' src/app.ts src/missing.ts", workdir: root },
|
||||
}),
|
||||
"/tmp",
|
||||
);
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
|
||||
});
|
||||
|
||||
it("#given failed tracked tool payload #when extracting #then returns no paths", () => {
|
||||
// given
|
||||
const root = makeProject();
|
||||
|
||||
// when
|
||||
const paths = extractCodexToolPaths(
|
||||
postToolUse({
|
||||
toolName: "read",
|
||||
toolInput: { path: "src/app.ts" },
|
||||
toolResponse: { is_error: true },
|
||||
}),
|
||||
root,
|
||||
);
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["test/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"useDefineForClassFields": false,
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
pool: "threads",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user