Files
Dragan Spiridonov 53b3faf72c fix(hooks): post-edit reads file_path from stdin event when --file is empty (#453)
`$TOOL_INPUT_file_path` does not expand reliably in Claude Code PostToolUse
context, so the init template's `--file "$TOOL_INPUT_file_path"` produced
`--file ""` for ~88% of turns. Every captured_experiences row ended up
tagged `task = "edit: "` with no path — embeddings were useless, pattern
lookups matched nothing, and consolidation never fired.

post-edit now mirrors the route hook: when --file is empty, read the
Claude Code hook event JSON from stdin and pull tool_input.file_path.
The shared readStdinJsonEvent helper moves from routing-hooks.ts into
hooks-shared.ts alongside a new extractFilePathFromEvent helper that
handles snake_case/camelCase envelopes and ignores non-string values.

Verified end-to-end: piping a PostToolUse event to
`aqe hooks post-edit --file "" --success --json` now produces
`task = "edit: /workspaces/agentic-qe/src/example.ts"` in
captured_experiences instead of the empty path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:54:37 +00:00

89 lines
3.1 KiB
TypeScript

/**
* Editing hook CLI - file_path extraction tests (#453)
*
* Regression coverage for the PostToolUse stdin path. Claude Code delivers
* the edited file path on stdin as a JSON event (tool_input.file_path), not
* as a reliable env var. Before this fix, the init template passed
* --file "$TOOL_INPUT_file_path" which expanded to "" in many hook surfaces
* — so 88% of captured_experiences rows ended up tagged `task = "edit: "`
* with no file path, useless embeddings, and zero pattern lookups.
*/
import { describe, it, expect } from 'vitest';
import { extractFilePathFromEvent } from '../../../../src/cli/commands/hooks-handlers/hooks-shared.js';
describe('extractFilePathFromEvent', () => {
it('returns empty string for empty input', () => {
expect(extractFilePathFromEvent('')).toBe('');
expect(extractFilePathFromEvent(' ')).toBe('');
});
it('extracts file_path from PostToolUse Edit events (snake_case)', () => {
const event = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
tool_input: {
file_path: '/workspaces/agentic-qe/src/example.ts',
old_string: 'foo',
new_string: 'bar',
},
});
expect(extractFilePathFromEvent(event)).toBe(
'/workspaces/agentic-qe/src/example.ts'
);
});
it('extracts file_path from Write events', () => {
const event = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Write',
tool_input: {
file_path: '/tmp/output.json',
content: '{}',
},
});
expect(extractFilePathFromEvent(event)).toBe('/tmp/output.json');
});
it('extracts file_path from camelCase toolInput envelopes', () => {
const event = JSON.stringify({
toolInput: { file_path: '/src/legacy.ts' },
});
expect(extractFilePathFromEvent(event)).toBe('/src/legacy.ts');
});
it('falls back to camelCase filePath inside snake_case envelope', () => {
const event = JSON.stringify({
tool_input: { filePath: '/src/mixed.ts' },
});
expect(extractFilePathFromEvent(event)).toBe('/src/mixed.ts');
});
it('falls back to top-level file_path when no tool_input envelope is present', () => {
const event = JSON.stringify({ file_path: '/src/top-level.ts' });
expect(extractFilePathFromEvent(event)).toBe('/src/top-level.ts');
});
it('returns empty string when no recognized field is present', () => {
const event = JSON.stringify({ unrelated: 'data', count: 5 });
expect(extractFilePathFromEvent(event)).toBe('');
});
it('ignores empty or whitespace-only file_path candidates', () => {
const event = JSON.stringify({
tool_input: { file_path: ' ' },
toolInput: { file_path: '/src/winner.ts' },
});
expect(extractFilePathFromEvent(event)).toBe('/src/winner.ts');
});
it('does not crash on malformed JSON — returns empty string', () => {
expect(extractFilePathFromEvent('{not valid json')).toBe('');
});
it('does not throw on non-string file_path values', () => {
const event = JSON.stringify({ tool_input: { file_path: 42 } });
expect(extractFilePathFromEvent(event)).toBe('');
});
});