fix(hooks): remove speculative regex runtime

This commit is contained in:
Tianyi Cui
2026-07-29 23:14:31 +08:00
parent cfe77b3a35
commit 99daef69ef
31 changed files with 174 additions and 633 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md
README.md: 3a44aaaf17034b310a91ac9686bd9a0d6690de11
README.zh.md: 3e3e56f9af740a973b5765aa57467ebe09a27c83
README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805
README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0

View File

@@ -10,7 +10,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
|---|---|---|
| Matcher validation + test | `compileMatchers(patterns, mode)` exposes diagnostics and repeated config-lifetime matching from one registry; Codex uses a bounded reload-stable Rust-regex interner; `matcherDiagnostic` / `matchesMatcher` are contained one-shot helpers | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), compiles the unique runnable patterns once, rejects a group carrying a registry diagnostic, and disposes its config registry on failure or teardown |
| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic |
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
@@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
## Primitives
- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). A bridge parser first discards matcher fields for events without matcher subjects and collects the remaining runnable groups, then compiles their unique patterns once. It reads `registry.diagnostic(pattern)` from those exact instances, disposes the config registry before throwing on a rejected pattern, or returns that registry for runtime matching and teardown after detached runs drain. Codex's valid instances and invalid diagnostics are interned on the synchronous `rregex` dependency module, so they survive hook-protocol/Cordis reloads without using `globalThis`; one-shot helpers share the same interner. Because `rregex` cannot shrink its WASM allocation after `free()`, the process deliberately retains at most `MAX_INTERNED_CODEX_REGEX_PATTERNS` (128) distinct non-literal patterns. Once full, a new distinct pattern is rejected with a capacity diagnostic before calling WASM; previously interned patterns continue to work, and a process restart resets the budget. This is bounded for both same-pattern and adversarial unique reloads without an unbounded cache.
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop.
- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge.
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.

View File

@@ -10,7 +10,7 @@ Claude CodeCodex hook 协议格式wire format的**共享核心**。它
| 关注点 | 此处(`dsh-hook-protocol` | 桥接(`dsh-hooks-claude` / `-codex` |
|---|---|---|
| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一注册表提供诊断与配置生命周期内的重复匹配Codex 使用有界且跨重载稳定的 Rust 正则 interner`matcherDiagnostic``matchesMatcher` 隔离的一次性辅助函数 | 选择自身原生正则 `mode``claude` = JavaScript`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有注册表诊断的配置组,并在失败或 teardown 时释放配置注册表 |
| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于隔离的运行时匹配 | 选择自身 `mode``claude` = 字面量或正则`codex` = 始终使用正则),并拒绝带有诊断的配置组 |
| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** |
| 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到 seam 特定的类型化 Decision |
| 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) |
@@ -19,7 +19,7 @@ Claude CodeCodex hook 协议格式wire format的**共享核心**。它
## 原语
- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则Claude Code 使用 JavaScriptCodex 使用 Rust `regex`(包括 `(?i)` 等内联 flag。桥接解析器会丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行的配置组,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`pattern 被拒绝时,会先释放配置注册表再抛错,否则把该注册表交给运行时,并在 teardown 时先 drain 脱离运行再释放它。Codex 的有效实例和无效诊断会 intern 在同步 `rregex` 依赖模块上,因此无需使用 `globalThis`,也能跨 hook-protocolCordis 重载保留;一次性辅助函数共享同一 interner。由于 `rregex``free()` 后也不能缩小 WASM 分配,进程会有意最多保留 `MAX_INTERNED_CODEX_REGEX_PATTERNS`128个不同的非字面 pattern。容量用满后新的不同 pattern 会在调用 WASM 前被容量诊断拒绝;已经 intern 的 pattern 继续工作,重启进程会重置预算。这样既覆盖相同 pattern 重载,也能在恶意唯一 pattern 重载下保持有界,而无需无界缓存
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;`claude` mode 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` mode 始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带字段,再用 `matcherDiagnostic` 拒绝事件实际使用的无效正则,并在注册任何钩子之前给出稳定诊断。运行时谓词仍会将无效 pattern 隔离为不匹配,因此直接调用本库不会向 agent loop智能体循环抛异常
- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env``dsh-bash` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码为 2 时,会以 stderr 内容阻止执行;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。
- **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,从首个 `continue:false`halt 状态保持不变,阻塞原因用 `\n\n` 连接,`additionalContext``systemMessages` 按顺序累积。
@@ -29,7 +29,7 @@ Claude CodeCodex hook 协议格式wire format的**共享核心**。它
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp``hook/invoked`hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md)`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500为空时省略
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse``PostToolUse``Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Noteagent 决策记录)
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse``PostToolUse``Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
## 模型体验

View File

@@ -26,9 +26,6 @@
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"rregex": "1.12.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",

View File

@@ -13,13 +13,7 @@ export type {
MatcherGroup,
MatcherMode,
} from './types.ts'
export {
compileMatchers,
matcherDiagnostic,
matchesMatcher,
MAX_INTERNED_CODEX_REGEX_PATTERNS,
} from './matcher.ts'
export type { CompiledMatchers } from './matcher.ts'
export { matcherDiagnostic, matchesMatcher } from './matcher.ts'
export { parseHookOutput } from './codec.ts'
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
export type { RunHookOptions, RunHookResult } from './runner.ts'

View File

@@ -1,178 +1,65 @@
/**
* Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/
* pipe patterns as literal alternatives and other patterns as regex. Codex
* uses the same literal fast path, then compiles regex patterns with Rust's
* `regex` dialect. Missing, empty, and `*` match all. Runtime matching contains
* invalid regexes as non-matches. Codex regexes are interned in a bounded pool
* shared across module reloads; a config registry leases those instances for
* diagnostics and runtime matching without reconstructing them.
* pipe patterns as literal alternatives and other patterns as regex; Codex
* treats every non-empty pattern as an unanchored regex. Missing, empty, and
* `*` match all. Runtime matching contains invalid regexes as non-matches;
* config parsers use {@link matcherDiagnostic} to reject them with a diagnostic.
* @module @deepseek-ai/dsh-hook-protocol/matcher
*/
import { createRequire } from 'node:module'
import type { RRegex as RustRegex } from 'rregex'
import type { MatcherMode } from './types.ts'
type CodexRegexPoolEntry =
| { regex: RustRegex }
| { diagnostic: string }
type RRegexModule = {
RRegex: new(pattern: string) => RustRegex
} & Record<symbol, unknown>
/** Process-wide ceiling for distinct non-literal Codex matcher patterns. */
export const MAX_INTERNED_CODEX_REGEX_PATTERNS = 128
// rregex's ESM entry initializes WASM with top-level await. Hook plugins are
// discovered through Cordis Loader's synchronous module boundary, so use the
// package's equivalent synchronous Node entry rather than making both bridge
// modules async merely by importing this shared matcher. The versioned symbol
// lives on that CJS module instance: Cordis may reload this library module, but
// Node retains the dependency module and therefore its bounded intern pool.
const rregexModule = createRequire(import.meta.url)('rregex') as RRegexModule
const { RRegex } = rregexModule
const CODEX_REGEX_POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1')
const priorPool = rregexModule[CODEX_REGEX_POOL_KEY]
const codexRegexPool = priorPool instanceof Map
? priorPool as Map<string, CodexRegexPoolEntry>
: new Map<string, CodexRegexPoolEntry>()
if (!(priorPool instanceof Map)) {
rregexModule[CODEX_REGEX_POOL_KEY] = codexRegexPool
}
/** True for an absent / empty / `'*'` pattern — the match-all sentinels. */
function isMatchAll(matcher: string | undefined): boolean {
return matcher === undefined || matcher === '' || matcher === '*'
}
/** An exact pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/
/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
interface CompiledMatcher {
matches(query: string): boolean
diagnostic?: string
}
/** A config-lifetime matcher set compiled once and explicitly disconnected. */
export interface CompiledMatchers {
/** Match one of the patterns supplied to {@link compileMatchers}. */
matches(matcher: string | undefined, query: string): boolean
/** Diagnose one supplied pattern using the already-compiled instance. */
diagnostic(matcher: string | undefined): string | undefined
/** Release this registry's references. Safe to call more than once. */
dispose(): void
}
/** Intern one Codex regex or its diagnostic without exceeding the process budget. */
function internCodexRegex(pattern: string): CodexRegexPoolEntry {
const existing = codexRegexPool.get(pattern)
if (existing !== undefined) return existing
if (codexRegexPool.size >= MAX_INTERNED_CODEX_REGEX_PATTERNS) {
return {
diagnostic: `codex regex matcher capacity exceeded (${MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for ${JSON.stringify(pattern)}`,
}
}
let entry: CodexRegexPoolEntry
/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */
function compileRegex(pattern: string): RegExp | undefined {
try {
entry = { regex: new RRegex(pattern) }
return new RegExp(pattern)
} catch (_syntaxError) {
// Regex construction is the try's only operation, so malformed syntax in
// Rust's dialect is the only expected failure. Cache failures too: a bad
// config repeatedly reloaded must not keep growing WASM memory.
entry = { diagnostic: `invalid codex regex matcher ${JSON.stringify(pattern)}` }
}
codexRegexPool.set(pattern, entry)
return entry
}
/** Compile one matcher into a reusable predicate. */
function compileMatcher(matcher: string | undefined, mode: MatcherMode): CompiledMatcher {
if (isMatchAll(matcher)) return { matches: () => true }
const pattern = matcher as string
if (EXACT_MATCHER.test(pattern)) {
const alternatives = new Set(pattern.split('|'))
return { matches: query => alternatives.has(query) }
}
if (mode === 'codex') {
const entry = internCodexRegex(pattern)
if ('regex' in entry) {
const regex = entry.regex
return { matches: query => regex.isMatch(query) }
}
return {
matches: () => false,
diagnostic: entry.diagnostic,
}
}
try {
const regex = new RegExp(pattern)
return { matches: query => regex.test(query) }
} catch (_syntaxError) {
return {
matches: () => false,
diagnostic: `invalid claude regex matcher ${JSON.stringify(pattern)}`,
}
}
}
/**
* Compile a finite config's unique matcher patterns for repeated evaluation.
* The returned registry owns one config's references. Codex native instances
* live in a bounded, reload-stable process pool; disposal disconnects this
* config but deliberately keeps interned instances for later reloads.
* @param matchers - the complete finite set of patterns in one loaded config.
* @param mode - the native regex dialect used for non-literal patterns.
* @returns a reusable registry that disconnects its config-local lookups on disposal.
*/
export function compileMatchers(matchers: Iterable<string | undefined>, mode: MatcherMode): CompiledMatchers {
const compiled = new Map<string | undefined, CompiledMatcher>()
for (const matcher of matchers) {
if (!compiled.has(matcher)) compiled.set(matcher, compileMatcher(matcher, mode))
}
let disposed = false
return {
matches(matcher, query) {
if (disposed) return false
return compiled.get(matcher)?.matches(query) ?? false
},
diagnostic(matcher) {
if (disposed) return undefined
return compiled.get(matcher)?.diagnostic
},
dispose() {
if (disposed) return
disposed = true
compiled.clear()
},
// RegExp construction is the try's only operation, so malformed pattern
// syntax is the only expected failure.
return undefined
}
}
/**
* Validate one matcher before a bridge accepts its config group.
* @param matcher - configured pattern; match-all sentinels are valid.
* @param mode - dialect deciding which regex engine validates non-literal patterns.
* @param mode - dialect deciding whether a word-and-pipe pattern is literal.
* @returns `undefined` for a valid matcher, otherwise a stable diagnostic.
*/
export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined {
return compileMatcher(matcher, mode).diagnostic
if (isMatchAll(matcher)) return undefined
const pattern = matcher as string
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined
return compileRegex(pattern) === undefined
? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`
: undefined
}
/**
* Whether `matcher` selects `query` under the given dialect. Literal patterns
* exact-match pipe-separated alternatives; all other patterns are unanchored
* regexes in the selected dialect. Invalid regexes return `false` rather than
* throwing; bridge config parsers surface them through {@link matcherDiagnostic}
* before use.
* Whether `matcher` selects `query` under the given dialect. Claude literal
* patterns exact-match pipe-separated alternatives; all other patterns are
* unanchored regexes. Invalid regexes return `false` rather than throwing;
* bridge config parsers surface them through {@link matcherDiagnostic} before use.
* @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels.
* @param query - the candidate value (a tool name, a session source, …).
* @param mode - the dialect deciding which regex engine matches the pattern.
* @param mode - the dialect deciding literal-vs-regex interpretation of the pattern.
* @returns `true` when the pattern selects the query; `false` on a non-match or an invalid
* regex.
*/
export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {
return compileMatcher(matcher, mode).matches(query)
if (isMatchAll(matcher)) return true
// matcher is a non-empty string past the match-all guard.
const pattern = matcher as string
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) {
return pattern.split('|').includes(query)
}
return compileRegex(pattern)?.test(query) ?? false
}

View File

@@ -71,10 +71,10 @@ export interface MatcherGroup {
}
/**
* How a matcher pattern is interpreted. Both dialects use an exact-match fast
* path when the pattern is purely `[A-Za-z0-9_|]+` (pipe = alternation), then
* use their native regex dialect otherwise: JavaScript for Claude Code and Rust
* `regex` for Codex. The bridge picks the mode for its dialect.
* How a matcher pattern is interpreted. Claude Code uses {@link literal} when the
* pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and
* {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the
* mode for its dialect.
*/
export type MatcherMode = 'claude' | 'codex'

View File

@@ -1,121 +0,0 @@
import { createRequire } from 'node:module'
import { describe, expect, it, vi } from 'vitest'
import type { RRegex as RustRegex } from 'rregex'
const POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1')
interface PoolEntry {
regex?: RustRegex
}
type RRegexModule = {
RRegex: new(pattern: string) => RustRegex
__wbindgen_memory(): WebAssembly.Memory
} & Record<symbol, unknown>
function restorePool(rregex: RRegexModule, original: unknown): void {
Reflect.deleteProperty(rregex, POOL_KEY)
if (original !== undefined) rregex[POOL_KEY] = original
}
describe('Codex regex intern lifecycle', () => {
it('keeps 100,000 same-pattern reloads bounded and reuses across module reload', async () => {
const require = createRequire(import.meta.url)
const rregex = require('rregex') as RRegexModule
const OriginalRRegex = rregex.RRegex
const originalPool = rregex[POOL_KEY]
const construct = vi.fn<(pattern: string) => void>()
const free = vi.fn<() => void>()
class CountingRRegex extends OriginalRRegex {
constructor(pattern: string) {
super(pattern)
construct(pattern)
}
override free(): void {
free()
super.free()
}
}
Reflect.deleteProperty(rregex, POOL_KEY)
rregex.RRegex = CountingRRegex
vi.resetModules()
const before = rregex.__wbindgen_memory().buffer.byteLength
try {
const first = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
for (let i = 0; i < 100_000; i++) {
first.compileMatchers(['(?i)^bash$'], 'codex').dispose()
}
expect(construct).toHaveBeenCalledExactlyOnceWith('(?i)^bash$')
expect(free).not.toHaveBeenCalled()
expect(rregex.__wbindgen_memory().buffer.byteLength - before).toBeLessThanOrEqual(4 * 1024 * 1024)
vi.resetModules()
const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
expect(reloaded.matcherDiagnostic('(?i)^bash$', 'codex')).toBeUndefined()
expect(reloaded.matchesMatcher('(?i)^bash$', 'BASH', 'codex')).toBe(true)
expect(construct).toHaveBeenCalledTimes(1)
expect(free).not.toHaveBeenCalled()
} finally {
const temporaryPool = rregex[POOL_KEY]
if (temporaryPool instanceof Map) {
for (const entry of temporaryPool.values() as Iterable<PoolEntry>) entry.regex?.free()
}
rregex.RRegex = OriginalRRegex
restorePool(rregex, originalPool)
vi.resetModules()
}
})
it('memoizes failures and rejects a new pattern before construction at the hard cap', async () => {
const require = createRequire(import.meta.url)
const rregex = require('rregex') as RRegexModule
const OriginalRRegex = rregex.RRegex
const originalPool = rregex[POOL_KEY]
const construct = vi.fn<(pattern: string) => void>()
class FakeRRegex {
constructor(pattern: string) {
construct(pattern)
if (pattern === 'invalid(') throw new SyntaxError('invalid test pattern')
}
isMatch(): boolean {
return true
}
}
Reflect.deleteProperty(rregex, POOL_KEY)
rregex.RRegex = FakeRRegex as unknown as typeof rregex.RRegex
vi.resetModules()
try {
const matcher = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
expect(construct).toHaveBeenCalledTimes(1)
for (let i = 0; i < matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS - 1; i++) {
expect(matcher.matcherDiagnostic(`^value-${i}$`, 'codex')).toBeUndefined()
}
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
expect(matcher.matcherDiagnostic('^overflow$', 'codex')).toBe(
`codex regex matcher capacity exceeded (${matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for "^overflow$"`,
)
expect(matcher.matchesMatcher('^overflow$', 'overflow', 'codex')).toBe(false)
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
vi.resetModules()
const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
expect(reloaded.matchesMatcher('^value-0$', 'anything', 'codex')).toBe(true)
expect(reloaded.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
} finally {
rregex.RRegex = OriginalRRegex
restorePool(rregex, originalPool)
vi.resetModules()
}
})
})

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { compileMatchers, matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
describe('matchesMatcher — match-all sentinels (both dialects)', () => {
for (const mode of ['claude', 'codex'] as const) {
@@ -34,10 +34,11 @@ describe('matchesMatcher — claude dialect (literal-or-regex)', () => {
})
})
describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => {
it('a word pattern uses Codex exact-match semantics', () => {
describe('matchesMatcher — codex dialect (always regex)', () => {
it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => {
expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true)
expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(false)
// codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring
expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true)
})
it('regex alternation and anchors work', () => {
@@ -45,14 +46,6 @@ describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => {
expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true)
expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false)
})
it('uses Rust regex syntax and matching semantics', () => {
expect(matchesMatcher('(?i)bash', 'xxBASHyy', 'codex')).toBe(true)
expect(matchesMatcher('(?x)^ b a s h $ # policy matcher', 'bash', 'codex')).toBe(true)
expect(matchesMatcher('^\\p{Greek}+$', 'αβ', 'codex')).toBe(true)
// JavaScript accepts look-around, but Rust regex deliberately does not.
expect(matchesMatcher('(?=Bash)', 'Bash', 'codex')).toBe(false)
})
})
describe('matchesMatcher — invalid regex is a non-match (never throws)', () => {
@@ -72,42 +65,10 @@ describe('matcherDiagnostic — parse-time diagnostics', () => {
expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined()
expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined()
expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined()
expect(matcherDiagnostic('(?i)bash', 'codex')).toBeUndefined()
expect(matcherDiagnostic('(?x)^ b a s h $ # policy matcher', 'codex')).toBeUndefined()
})
it('returns a stable diagnostic for invalid regexes in either dialect', () => {
expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("')
expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["')
expect(matcherDiagnostic('(?=Bash)', 'codex')).toBe('invalid codex regex matcher "(?=Bash)"')
})
})
describe('compileMatchers — config-lifetime reuse', () => {
it('compiles a finite set, contains unknown patterns, and stops after disposal', () => {
const matchers = compileMatchers([undefined, 'Edit|Write', '(?i)^bash$', '['], 'codex')
expect(matchers.matches(undefined, 'anything')).toBe(true)
expect(matchers.matches('Edit|Write', 'Write')).toBe(true)
expect(matchers.matches('Edit|Write', 'WriteFile')).toBe(false)
expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true)
expect(matchers.matches('[', 'anything')).toBe(false)
expect(matchers.matches('not-compiled', 'not-compiled')).toBe(false)
expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined()
expect(matchers.diagnostic('[')).toBe('invalid codex regex matcher "["')
expect(matchers.diagnostic('not-compiled')).toBeUndefined()
matchers.dispose()
expect(matchers.matches(undefined, 'anything')).toBe(false)
expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(false)
expect(matchers.diagnostic('[')).toBeUndefined()
expect(() => { matchers.dispose() }).not.toThrow()
})
it('reuses JavaScript regexes too', () => {
const matchers = compileMatchers(['^Bash$', '^Bash$'], 'claude')
expect(matchers.matches('^Bash$', 'Bash')).toBe(true)
expect(matchers.matches('^Bash$', 'BashOutput')).toBe(false)
matchers.dispose()
})
})