fix(hooks): remove speculative regex runtime
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user