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}` } } }