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

@@ -6,11 +6,7 @@
* @module @deepseek-ai/dsh-hooks-claude/config
*/
import {
compileMatchers,
type CompiledMatchers,
type MatcherGroup,
} from '@deepseek-ai/dsh-hook-protocol'
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
const CLAUDE_EVENTS = [
'SessionStart',
@@ -35,8 +31,6 @@ export interface SkippedHook {
export interface ParsedClaudeConfig {
config: ClaudeHookConfig
skipped: SkippedHook[]
/** Config-scoped matcher registry; the caller owns and must dispose it. */
matchers: CompiledMatchers
}
/** Substitution variables applied to each `command` string at parse time. */
@@ -74,7 +68,6 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
* command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no
* matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a
* `SyntaxError`, allowing the bridge to reject the complete config before listener registration.
* Validation and runtime matching share the returned compiled registry; its caller must dispose it.
*
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
* event map.
@@ -88,54 +81,43 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
// Accept either `{ hooks: { … } }` (a settings file) or the bare event map.
const root = asObject(raw)
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
if (hooksMap) {
for (const event of CLAUDE_EVENTS) {
const rawGroups = hooksMap[event]
if (!Array.isArray(rawGroups)) continue
const groups: MatcherGroup[] = []
for (const rawGroup of rawGroups) {
const group = asObject(rawGroup)
if (!group || !Array.isArray(group.hooks)) continue
const commands: MatcherGroup['hooks'] = []
for (const rawHook of group.hooks) {
const hook = asObject(rawHook)
if (!hook) continue
const type = typeof hook.type === 'string' ? hook.type : 'command'
if (type !== 'command') {
skipped.push({ event, type })
continue
}
if (typeof hook.command !== 'string') continue
commands.push({
command: substituteCommand(hook.command, vars),
...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {},
})
if (!hooksMap) return { config, skipped }
for (const event of CLAUDE_EVENTS) {
const rawGroups = hooksMap[event]
if (!Array.isArray(rawGroups)) continue
const groups: MatcherGroup[] = []
for (const rawGroup of rawGroups) {
const group = asObject(rawGroup)
if (!group || !Array.isArray(group.hooks)) continue
const commands: MatcherGroup['hooks'] = []
for (const rawHook of group.hooks) {
const hook = asObject(rawHook)
if (!hook) continue
const type = typeof hook.type === 'string' ? hook.type : 'command'
if (type !== 'command') {
skipped.push({ event, type })
continue
}
if (commands.length === 0) continue
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
? undefined
: typeof group.matcher === 'string' ? group.matcher : undefined
groups.push({
...matcher !== undefined ? { matcher } : {},
hooks: commands,
if (typeof hook.command !== 'string') continue
commands.push({
command: substituteCommand(hook.command, vars),
...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {},
})
}
if (groups.length > 0) config[event] = groups
if (commands.length === 0) continue
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
? undefined
: typeof group.matcher === 'string' ? group.matcher : undefined
const diagnostic = matcherDiagnostic(matcher, 'claude')
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
groups.push({
...matcher !== undefined ? { matcher } : {},
hooks: commands,
})
}
if (groups.length > 0) config[event] = groups
}
/* jscpd:ignore-start -- dialect-local event diagnostics intentionally stay beside parsing. */
const entries = Object.entries(config).flatMap(([event, groups]) => (
groups.map(group => ({ event, matcher: group.matcher }))
))
const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'claude')
for (const { event, matcher } of entries) {
const diagnostic = matchers.diagnostic(matcher)
if (diagnostic === undefined) continue
matchers.dispose()
throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
}
/* jscpd:ignore-end */
return { config, skipped, matchers }
return { config, skipped }
}

View File

@@ -24,6 +24,7 @@ import {
createDetachedRuns,
DEFAULT_HOOK_TIMEOUT_MS,
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
matchesMatcher,
mergeHookOutputs,
runHook,
type HookOutput,
@@ -34,7 +35,7 @@ import {
// declarations (declaration-merged into cordis `Events` by dsh-subagent) so the
// SubagentStart/SubagentStop listeners below type-check.
import type {} from '@deepseek-ai/dsh-subagent'
import { parseClaudeConfig, type ParsedClaudeConfig } from './config.ts'
import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts'
export const name = 'hooks-claude'
// `bash` is required to run hooks; the rest are read opportunistically via
@@ -99,37 +100,26 @@ export function apply(ctx: Context, config: Config): void {
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
// Parse once at load. A read or parse failure logs and registers nothing.
let result: ParsedClaudeConfig
let parsed: ClaudeHookConfig = {}
try {
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
result = parseClaudeConfig(raw, {
const result = parseClaudeConfig(raw, {
...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {},
...config.projectDir !== undefined ? { projectDir: config.projectDir } : {},
})
parsed = result.config
for (const s of result.skipped) {
ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)
}
} catch (error: unknown) {
ctx.logger.warn(`hooks-claude: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
return
}
const parsed = result.config
// Parsing validates through this same registry, so admission and runtime do
// not construct separate matcher instances.
const matchers = result.matchers
// Emit-shaped points run detached, so track their chains; disposal aborts
// active hooks and drains continuations before releasing matchers.
// active hooks and drains continuations before resolving.
const detached = createDetachedRuns()
ctx.effect(() => async () => {
try {
await detached.drain()
} finally {
matchers.dispose()
}
}, 'hooks-claude: drain detached hook runs and dispose matchers')
for (const s of result.skipped) {
ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)
}
ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')
/**
* Run every command hook configured for `point` whose matcher selects
@@ -157,7 +147,7 @@ export function apply(ctx: Context, config: Config): void {
const projectDir = config.projectDir ?? workdir
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
for (const group of groups) {
if (!matchers.matches(group.matcher, matchQuery)) continue
if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue
for (const hook of group.hooks) {
const handlerId = nextHandlerId(point)
const session = opts.agent?.session