fix(hooks): remove speculative regex runtime
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,7 +10,7 @@ Claude Code/Codex 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 Code/Codex hook 协议格式(wire format)的**共享核心**。它
|
||||
|
||||
## 原语
|
||||
|
||||
- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行的配置组,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;pattern 被拒绝时,会先释放配置注册表再抛错,否则把该注册表交给运行时,并在 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例和无效诊断会 intern 在同步 `rregex` 依赖模块上,因此无需使用 `globalThis`,也能跨 hook-protocol/Cordis 重载保留;一次性辅助函数共享同一 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 Code/Codex 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 Note(agent 决策记录)。
|
||||
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user