diff --git a/src/hooks/rules-injector/matcher.test.ts b/src/hooks/rules-injector/matcher.test.ts
new file mode 100644
index 000000000..009c19645
--- /dev/null
+++ b/src/hooks/rules-injector/matcher.test.ts
@@ -0,0 +1,70 @@
+///
+
+import { beforeEach, describe, expect, it } from "bun:test"
+import {
+ createContentHash,
+ getMatcherCacheStats,
+ isDuplicateByContentHash,
+ isDuplicateByRealPath,
+ resetMatcherCache,
+ shouldApplyRule,
+} from "./matcher"
+
+describe("shouldApplyRule", () => {
+ beforeEach(() => {
+ resetMatcherCache()
+ })
+
+ it("#given repeated glob metadata #when matching many files #then compiles each pattern once", () => {
+ // given
+ const metadata = { globs: ["src/**/*.ts", "test/**/*.ts"] }
+ const projectRoot = "/workspace/project"
+
+ // when
+ for (let index = 0; index < 20; index += 1) {
+ shouldApplyRule(metadata, `${projectRoot}/src/file-${index}.ts`, projectRoot)
+ shouldApplyRule(metadata, `${projectRoot}/test/file-${index}.ts`, projectRoot)
+ }
+
+ // then
+ expect(getMatcherCacheStats()).toEqual({ entries: 2 })
+ })
+
+ it("#given matching glob #when path is under project root #then returns matching reason", () => {
+ // given / when
+ const result = shouldApplyRule({ globs: "src/**/*.ts" }, "/workspace/project/src/index.ts", "/workspace/project")
+
+ // then
+ expect(result).toEqual({ applies: true, reason: "glob: src/**/*.ts" })
+ })
+
+ it("#given always apply metadata #when no globs exist #then applies without compiling matchers", () => {
+ // given / when
+ const result = shouldApplyRule({ alwaysApply: true }, "/workspace/project/src/index.ts", "/workspace/project")
+
+ // then
+ expect(result).toEqual({ applies: true, reason: "alwaysApply" })
+ expect(getMatcherCacheStats()).toEqual({ entries: 0 })
+ })
+})
+
+describe("rule duplicate helpers", () => {
+ it("#given real path cache #when path exists #then reports duplicate", () => {
+ // given
+ const cache = new Set(["/workspace/project/AGENTS.md"])
+
+ // when / then
+ expect(isDuplicateByRealPath("/workspace/project/AGENTS.md", cache)).toBe(true)
+ expect(isDuplicateByRealPath("/workspace/project/src/AGENTS.md", cache)).toBe(false)
+ })
+
+ it("#given content #when hashing #then duplicate helper uses truncated hash", () => {
+ // given
+ const hash = createContentHash("rule-content")
+ const cache = new Set([hash])
+
+ // when / then
+ expect(hash).toHaveLength(16)
+ expect(isDuplicateByContentHash(hash, cache)).toBe(true)
+ })
+})
diff --git a/src/hooks/rules-injector/matcher.ts b/src/hooks/rules-injector/matcher.ts
index 13f6d51c5..d68dae109 100644
--- a/src/hooks/rules-injector/matcher.ts
+++ b/src/hooks/rules-injector/matcher.ts
@@ -3,11 +3,37 @@ import { relative } from "node:path"
import picomatch from "picomatch"
import type { RuleMetadata } from "./types"
+type PathMatcher = (path: string) => boolean
+
export interface MatchResult {
applies: boolean
reason?: string
}
+export interface MatcherCacheStats {
+ entries: number
+}
+
+const PICOMATCH_OPTIONS = { dot: true, bash: true } as const
+const matcherCache = new Map()
+
+function matcherFor(pattern: string): PathMatcher {
+ const cached = matcherCache.get(pattern)
+ if (cached) return cached
+
+ const matcher = picomatch(pattern, PICOMATCH_OPTIONS)
+ matcherCache.set(pattern, matcher)
+ return matcher
+}
+
+export function resetMatcherCache(): void {
+ matcherCache.clear()
+}
+
+export function getMatcherCacheStats(): MatcherCacheStats {
+ return { entries: matcherCache.size }
+}
+
/**
* Check if a rule should apply to the current file based on metadata
*/
@@ -33,7 +59,7 @@ export function shouldApplyRule(
const relativePath = projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath
for (const pattern of patterns) {
- if (picomatch.isMatch(relativePath, pattern, { dot: true, bash: true })) {
+ if (matcherFor(pattern)(relativePath)) {
return { applies: true, reason: `glob: ${pattern}` }
}
}