fix(hooks): share matcher validation instances

This commit is contained in:
ZiyaZhang
2026-07-28 20:10:21 -07:00
parent 774755ef2d
commit ec72d0b57e
17 changed files with 221 additions and 128 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: 1b4c6c3b73b0d4f3d5df06405d27b94652b6c686
README.zh.md: 5b55034ff136129f83ae90a84b8efc89affcfce0
README.md: 36607ed9b98a97288690c869e58ee1d45ba765c4
README.zh.md: 5e1abce23aea65f670bde8c8c5d74c40f6afd07e

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 | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `compileMatchers(patterns, mode)` for repeated config-lifetime matching; `matchesMatcher(pattern, query, mode)` for one-shot contained matching | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), rejects a config group carrying a diagnostic, and disposes the compiled set on teardown |
| Matcher validation + test | `compileMatchers(patterns, mode)` exposes diagnostics and repeated config-lifetime matching from one compiled set; `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 that same set on failure or teardown |
| 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
- **`matcherDiagnostic(matcher, mode)` / `compileMatchers(matchers, 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)`). 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. Each bridge uses `compileMatchers` to compile every unique config pattern once, reuses it at every hook point, and disposes the finite set after detached runs drain on plugin teardown; this avoids the Rust/WASM allocator's non-shrinking high-water mark growing on every match. `matchesMatcher` remains the contained one-shot predicate, and invalid runtime patterns are non-matches rather than exceptions.
- **`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 registry before throwing on an invalid consumed regex, or returns the same registry for runtime matching. The plugin reuses it at every hook point and disposes it after detached runs drain on teardown. Thus neither validation nor matching reconstructs a Rust/WASM regex and raises its non-shrinking memory high-water mark. `matcherDiagnostic` and `matchesMatcher` remain contained one-shot helpers; invalid runtime patterns are non-matches rather than exceptions.
- **`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 协议格式的**共享核心**。它不是 cordis 插
| 关注点 | 此处(`dsh-hook-protocol` | 桥接(`dsh-hooks-claude` / `-codex` |
|---|---|---|
| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`compileMatchers(patterns, mode)` 用于配置生命周期内的重复匹配;`matchesMatcher(pattern, query, mode)` 用于一次性的收敛匹配 | 选择自身原生正则 `mode``claude` = JavaScript`codex` = Rust `regex`拒绝带有诊断的配置组,并在 teardown 时释放已编译集合 |
| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一已编译集合提供诊断与配置生命周期内的重复匹配;`matcherDiagnostic``matchesMatcher` 是收敛的一次性 helper | 选择自身原生正则 `mode``claude` = JavaScript`codex` = Rust `regex`将可运行的唯一 pattern 只编译一次,拒绝带有 registry 诊断的配置组,并在失败或 teardown 时释放同一集合 |
| 运行 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 协议格式的**共享核心**。它不是 cordis 插
## 原语
- **`matcherDiagnostic(matcher, mode)` / `compileMatchers(matchers, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则Claude Code 使用 JavaScriptCodex 使用 Rust `regex`(包括 `(?i)` 等内联 flag。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。每个桥接通过 `compileMatchers` 将配置中每个唯一 pattern 只编译一次,在各 hook 点重复使用,并在插件 teardown 时先 drain 脱离运行,再释放这个有限集合因此 RustWASM 分配器不会因每次匹配都抬高无法收缩的内存高水位。`matchesMatcher` 保留为收敛的一次性谓词,运行时无效 pattern 仍是不匹配而非异常。
- **`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 匹配对象的事件所带字段,收集其余可运行 group再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;实际消费的正则无效时,会先释放 registry 再抛错,否则把同一 registry 交给运行时。插件会在各 hook 点重复使用,并在 teardown 时先 drain 脱离运行,再释放集合因此校验和匹配都不会重复构造 RustWASM 正则并抬高无法收缩的内存高水位。`matcherDiagnostic``matchesMatcher` 保留为收敛的一次性 helper运行时无效 pattern 仍是不匹配而非异常。
- **`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` 按顺序累积。

View File

@@ -3,8 +3,8 @@
* 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; config parsers use {@link matcherDiagnostic}
* to reject them with a diagnostic.
* invalid regexes as non-matches. A compiled config registry exposes the same
* stable diagnostic without constructing a second native regex.
* @module @deepseek-ai/dsh-hook-protocol/matcher
*/
@@ -30,6 +30,7 @@ const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/
interface CompiledMatcher {
matches(query: string): boolean
diagnostic?: string
dispose(): void
}
@@ -37,6 +38,8 @@ interface CompiledMatcher {
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 every native matcher. Safe to call more than once. */
dispose(): void
}
@@ -66,7 +69,13 @@ function compileMatcher(matcher: string | undefined, mode: MatcherMode): Compile
return { matches: query => alternatives.has(query), dispose: () => {} }
}
const regex = compileRegex(pattern, mode)
if (regex === undefined) return { matches: () => false, dispose: () => {} }
if (regex === undefined) {
return {
matches: () => false,
diagnostic: `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`,
dispose: () => {},
}
}
return {
matches: query => regex instanceof RRegex ? regex.isMatch(query) : regex.test(query),
dispose: () => { disposeRegex(regex) },
@@ -92,6 +101,10 @@ export function compileMatchers(matchers: Iterable<string | undefined>, mode: Ma
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
@@ -108,13 +121,12 @@ export function compileMatchers(matchers: Iterable<string | undefined>, mode: Ma
* @returns `undefined` for a valid matcher, otherwise a stable diagnostic.
*/
export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined {
if (isMatchAll(matcher)) return undefined
const pattern = matcher as string
if (EXACT_MATCHER.test(pattern)) return undefined
const regex = compileRegex(pattern, mode)
if (regex === undefined) return `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`
disposeRegex(regex)
return undefined
const compiled = compileMatcher(matcher, mode)
try {
return compiled.diagnostic
} finally {
compiled.dispose()
}
}
/**

View File

@@ -30,6 +30,7 @@ describe('compileMatchers — native regex lifecycle', () => {
expect(construct.mock.calls.map(([pattern]) => pattern)).toEqual(['(?i)^bash$', '^write$'])
for (let i = 0; i < 1_000; i++) {
expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined()
expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true)
}
expect(construct).toHaveBeenCalledTimes(2)

View File

@@ -93,10 +93,14 @@ describe('compileMatchers — config-lifetime reuse', () => {
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()
})