From 819e64a7e4364ab1f79e4bd760a7cc01f2c1f4fb Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 18:23:31 +0000 Subject: [PATCH 1/3] feat: add LLM enrichment to commit-msg hook (GIT-87) When enabled, the commit-msg hook now uses Claude to generate richer AI-* trailers by analyzing the staged diff alongside the commit message. Changes: - Add `enrich` (default: true) and `enrichTimeout` (default: 5000) to ICommitMsgConfig - Add AI-Source trailer to indicate 'llm-enrichment' or 'heuristic' - Update DI container to wire llmClient to CommitMsgHandler - Implement attemptEnrichment() with timeout and fallback - Add selectBestFact() to pick highest priority fact from LLM results - Add comprehensive unit tests (13 tests) Gracefully falls back to heuristic analysis when: - No ANTHROPIC_API_KEY set (llmClient is null) - enrich config is false - LLM call times out (default 5s) - LLM returns empty facts or throws error Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Decision: add LLM enrichment to commit-msg hook (GIT-87). When enabled, the commit-msg hook now uses Claude to generate richer AI-Confidence: medium AI-Tags: application, handlers, domain, entities, hooks, utils, infrastructure, tests, unit AI-Lifecycle: project AI-Memory-Id: 345fe99b --- src/application/handlers/CommitMsgHandler.ts | 222 +++++++- src/domain/entities/ITrailer.ts | 1 + src/domain/interfaces/IHookConfig.ts | 4 + src/hooks/utils/config.ts | 2 + src/infrastructure/di/container.ts | 3 +- .../handlers/CommitMsgHandler.test.ts | 520 ++++++++++++++++++ 6 files changed, 733 insertions(+), 19 deletions(-) create mode 100644 tests/unit/application/handlers/CommitMsgHandler.test.ts diff --git a/src/application/handlers/CommitMsgHandler.ts b/src/application/handlers/CommitMsgHandler.ts index e68e0261..50d24fe3 100644 --- a/src/application/handlers/CommitMsgHandler.ts +++ b/src/application/handlers/CommitMsgHandler.ts @@ -4,6 +4,10 @@ * Handles the commit-msg git hook event. * Analyzes the commit message, infers memory metadata, * and appends AI-* trailers to the commit message file. + * + * When LLM enrichment is enabled and available, uses Claude to generate + * richer trailer content based on the staged diff. Falls back to heuristic + * analysis if enrichment fails or is unavailable. */ import { readFileSync } from 'fs'; @@ -23,6 +27,10 @@ import { import type { IAgentResolver } from '../../domain/interfaces/IAgentResolver'; import type { IHookConfigLoader } from '../../domain/interfaces/IHookConfigLoader'; import type { ICommitMsgConfig } from '../../domain/interfaces/IHookConfig'; +import type { ILLMClient, ILLMExtractedFact } from '../../domain/interfaces/ILLMClient'; + +/** Maximum diff size to send to LLM (10KB). */ +const MAX_DIFF_LENGTH = 10_000; export class CommitMsgHandler implements IEventHandler { constructor( @@ -30,7 +38,8 @@ export class CommitMsgHandler implements IEventHandler { private readonly gitClient: IGitClient, private readonly logger: ILogger, private readonly agentResolver: IAgentResolver, - private readonly configLoader: IHookConfigLoader + private readonly configLoader: IHookConfigLoader, + private readonly llmClient?: ILLMClient | null, ) {} async handle(event: ICommitMsgEvent): Promise { @@ -63,30 +72,48 @@ export class CommitMsgHandler implements IEventHandler { return { handler: 'CommitMsgHandler', success: true }; } - // 5. Get staged files (only if inferTags is enabled) - const stagedFiles = config.inferTags - ? this.gitClient.diffStagedNames(event.cwd) - : []; + // 5. Get staged files (for tags and enrichment) + const stagedFiles = this.gitClient.diffStagedNames(event.cwd); - // 6. Analyze the commit message - const analysis = this.commitAnalyzer.analyze(message, stagedFiles); + // 6. Attempt LLM enrichment if enabled and available + const shouldEnrich = config.enrich && this.llmClient != null; + let enrichedFacts: ILLMExtractedFact[] | null = null; - // 7. Check requireType config - skip if no type detected and requireType is true - if (config.requireType && !analysis.type) { - this.logger.debug('No memory type detected and requireType is true, skipping'); - return { handler: 'CommitMsgHandler', success: true }; + if (shouldEnrich) { + enrichedFacts = await this.attemptEnrichment( + message, + stagedFiles, + config.enrichTimeout, + event.cwd, + ); } - // 8. Build trailers (skip Agent/Model if already present from prepare-commit-msg) - const trailers = this.buildTrailers(analysis, config, message); + // 7. Build trailers - use enrichment if available, else heuristic + let trailers: ITrailer[]; + let source: 'llm-enrichment' | 'heuristic'; + + if (enrichedFacts && enrichedFacts.length > 0) { + trailers = this.buildEnrichedTrailers(enrichedFacts, config, message); + source = 'llm-enrichment'; + } else { + // Fallback to heuristic analysis + const analysis = this.commitAnalyzer.analyze(message, stagedFiles); + + // Check requireType config - skip if no type detected and requireType is true + if (config.requireType && !analysis.type) { + this.logger.debug('No memory type detected and requireType is true, skipping'); + return { handler: 'CommitMsgHandler', success: true }; + } + + trailers = this.buildTrailers(analysis, config, message); + source = 'heuristic'; + } - // 9. Append trailers to the commit message using git interpret-trailers + // 8. Append trailers to the commit message using git interpret-trailers await this.appendTrailers(event.commitMsgPath, trailers, event.cwd); this.logger.info('Commit message analyzed and trailers added', { - type: analysis.type, - tags: analysis.tags, - confidence: analysis.confidence, + source, trailerCount: trailers.length, }); @@ -104,6 +131,73 @@ export class CommitMsgHandler implements IEventHandler { } } + /** + * Attempt LLM enrichment with timeout. + * Returns extracted facts on success, null on failure or timeout. + */ + private async attemptEnrichment( + message: string, + stagedFiles: string[], + timeoutMs: number, + cwd: string, + ): Promise { + if (!this.llmClient) return null; + + try { + // Get staged diff for LLM context + const diff = this.gitClient.diffStaged(cwd); + const truncatedDiff = this.truncateDiff(diff, MAX_DIFF_LENGTH); + + // Parse commit message for LLM input + const lines = message.split('\n'); + const subject = lines[0] || ''; + const body = lines.slice(2).join('\n').trim(); // Skip blank line after subject + + // Race enrichment against timeout + const enrichmentPromise = this.llmClient.enrichCommit({ + sha: 'staged', // No SHA yet - use placeholder + subject, + body, + diff: truncatedDiff, + filesChanged: stagedFiles, + }); + + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => resolve(null), timeoutMs); + }); + + const result = await Promise.race([enrichmentPromise, timeoutPromise]); + + if (result === null) { + this.logger.warn('LLM enrichment timed out', { timeoutMs }); + return null; + } + + this.logger.debug('LLM enrichment successful', { + factsExtracted: result.facts.length, + inputTokens: result.usage.inputTokens, + outputTokens: result.usage.outputTokens, + }); + + return [...result.facts]; + } catch (error) { + this.logger.warn('LLM enrichment failed, falling back to heuristic', { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + /** + * Truncate diff to max length, preserving line boundaries. + */ + private truncateDiff(diff: string, maxLength: number): string { + if (diff.length <= maxLength) return diff; + const truncated = diff.slice(0, maxLength); + const lastNewline = truncated.lastIndexOf('\n'); + return lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated; + } + /** * Check if analysis should be skipped for special commit types. * Returns the skip reason, or null if analysis should proceed. @@ -155,7 +249,96 @@ export class CommitMsgHandler implements IEventHandler { } /** - * Build all AI-* trailers from the analysis result. + * Build trailers from LLM enrichment results. + */ + private buildEnrichedTrailers( + facts: ILLMExtractedFact[], + config: ICommitMsgConfig, + existingMessage: string, + ): ITrailer[] { + const trailers: ITrailer[] = []; + + // Agent and Model trailers (skip if already present) + const hasAgent = existingMessage.includes('AI-Agent:'); + const hasModel = existingMessage.includes('AI-Model:'); + + if (!hasAgent) { + const agent = this.agentResolver.resolveAgent(); + if (agent) trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent }); + } + if (!hasModel) { + const model = this.agentResolver.resolveModel(); + if (model) trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model }); + } + + // Select best fact for primary trailer (highest priority type/confidence) + const bestFact = this.selectBestFact(facts); + + if (bestFact) { + const trailerKey = MEMORY_TYPE_TO_TRAILER_KEY[bestFact.type]; + if (trailerKey) { + const truncatedContent = bestFact.content.slice(0, 200); + trailers.push({ key: trailerKey, value: truncatedContent }); + } + + // Use confidence from LLM + trailers.push({ key: AI_TRAILER_KEYS.CONFIDENCE, value: bestFact.confidence }); + + // Merge tags from all facts + const allTags = new Set(); + for (const fact of facts) { + for (const tag of fact.tags) { + allTags.add(tag); + } + } + if (allTags.size > 0) { + trailers.push({ key: AI_TRAILER_KEYS.TAGS, value: [...allTags].join(', ') }); + } + } + + // Lifecycle from config + trailers.push({ key: AI_TRAILER_KEYS.LIFECYCLE, value: config.defaultLifecycle }); + + // Memory ID + trailers.push({ key: AI_TRAILER_KEYS.MEMORY_ID, value: this.generateMemoryId() }); + + // Source indicator + trailers.push({ key: AI_TRAILER_KEYS.SOURCE, value: 'llm-enrichment' }); + + return trailers; + } + + /** + * Select the best fact from LLM results. + * Priority: decision > gotcha > convention > fact + * Within same type: verified > high > medium > low + */ + private selectBestFact(facts: ILLMExtractedFact[]): ILLMExtractedFact | null { + if (facts.length === 0) return null; + + const typePriority: Record = { + decision: 4, + gotcha: 3, + convention: 2, + fact: 1, + }; + + const confidencePriority: Record = { + verified: 4, + high: 3, + medium: 2, + low: 1, + }; + + return [...facts].sort((a, b) => { + const typeDiff = (typePriority[b.type] || 0) - (typePriority[a.type] || 0); + if (typeDiff !== 0) return typeDiff; + return (confidencePriority[b.confidence] || 0) - (confidencePriority[a.confidence] || 0); + })[0]; + } + + /** + * Build all AI-* trailers from the heuristic analysis result. * Skips Agent/Model if they already exist (from prepare-commit-msg). */ private buildTrailers( @@ -206,6 +389,9 @@ export class CommitMsgHandler implements IEventHandler { // Add memory ID for tracking trailers.push({ key: AI_TRAILER_KEYS.MEMORY_ID, value: this.generateMemoryId() }); + // Source indicator for heuristic analysis + trailers.push({ key: AI_TRAILER_KEYS.SOURCE, value: 'heuristic' }); + return trailers; } diff --git a/src/domain/entities/ITrailer.ts b/src/domain/entities/ITrailer.ts index 63cf22b8..316a87a2 100644 --- a/src/domain/entities/ITrailer.ts +++ b/src/domain/entities/ITrailer.ts @@ -34,6 +34,7 @@ export const AI_TRAILER_KEYS = { MEMORY_ID: 'AI-Memory-Id', AGENT: 'AI-Agent', MODEL: 'AI-Model', + SOURCE: 'AI-Source', } as const; /** diff --git a/src/domain/interfaces/IHookConfig.ts b/src/domain/interfaces/IHookConfig.ts index 537ec1b5..a8f31ce2 100644 --- a/src/domain/interfaces/IHookConfig.ts +++ b/src/domain/interfaces/IHookConfig.ts @@ -45,6 +45,10 @@ export interface ICommitMsgConfig { readonly requireType: boolean; /** Default memory lifecycle. */ readonly defaultLifecycle: 'permanent' | 'project' | 'session'; + /** Enable LLM enrichment for richer trailer content. Requires ANTHROPIC_API_KEY. */ + readonly enrich: boolean; + /** Timeout in ms for LLM enrichment call. Default: 5000. */ + readonly enrichTimeout: number; } export interface IHooksConfig { diff --git a/src/hooks/utils/config.ts b/src/hooks/utils/config.ts index ebf1c167..5f098b83 100644 --- a/src/hooks/utils/config.ts +++ b/src/hooks/utils/config.ts @@ -23,6 +23,8 @@ const DEFAULTS: IHookConfig = { inferTags: true, requireType: false, defaultLifecycle: 'project', + enrich: true, + enrichTimeout: 5000, }, }, }; diff --git a/src/infrastructure/di/container.ts b/src/infrastructure/di/container.ts index afcd8480..1fd8db1f 100644 --- a/src/infrastructure/di/container.ts +++ b/src/infrastructure/di/container.ts @@ -93,13 +93,14 @@ export function createContainer(options?: IContainerOptions): AwilixContainer { - return options?.enrich ? (createLLMClient() ?? null) : null; + return createLLMClient() ?? null; }).singleton(), // ── Application services ───────────────────────────────────── diff --git a/tests/unit/application/handlers/CommitMsgHandler.test.ts b/tests/unit/application/handlers/CommitMsgHandler.test.ts new file mode 100644 index 00000000..7afc70fa --- /dev/null +++ b/tests/unit/application/handlers/CommitMsgHandler.test.ts @@ -0,0 +1,520 @@ +/** + * CommitMsgHandler unit tests + * + * Tests the commit-msg hook handler including LLM enrichment + * and fallback to heuristic analysis. + */ + +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFileSync, mkdtempSync, rmSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { execSync } from 'child_process'; +import { CommitMsgHandler } from '../../../../src/application/handlers/CommitMsgHandler'; +import type { ICommitMsgEvent } from '../../../../src/domain/events/HookEvents'; +import type { ICommitAnalyzer, ICommitAnalysis } from '../../../../src/application/interfaces/ICommitAnalyzer'; +import type { IGitClient } from '../../../../src/domain/interfaces/IGitClient'; +import type { ILogger } from '../../../../src/domain/interfaces/ILogger'; +import type { IAgentResolver } from '../../../../src/domain/interfaces/IAgentResolver'; +import type { IHookConfigLoader } from '../../../../src/domain/interfaces/IHookConfigLoader'; +import type { IHookConfig } from '../../../../src/domain/interfaces/IHookConfig'; +import type { ILLMClient, ILLMEnrichmentResult, ILLMExtractedFact } from '../../../../src/domain/interfaces/ILLMClient'; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +function createEvent(overrides?: Partial): ICommitMsgEvent { + return { + type: 'git:commit-msg', + commitMsgPath: '/tmp/COMMIT_EDITMSG', + cwd: '/tmp/test-repo', + ...overrides, + }; +} + +function createMockAnalysis(overrides?: Partial): ICommitAnalysis { + return { + type: 'decision', + content: 'Use JWT for auth', + confidence: 'high', + tags: ['auth'], + conventionalType: 'feat', + scope: 'auth', + patternName: 'because-clause', + ...overrides, + }; +} + +function createMockCommitAnalyzer(analysis?: Partial): ICommitAnalyzer { + return { + analyze: () => createMockAnalysis(analysis), + parseConventionalCommit: () => ({ + type: 'feat', + scope: null, + breaking: false, + description: 'test', + body: '', + }), + }; +} + +function createMockGitClient(): IGitClient { + return { + diffStagedNames: () => ['src/auth.ts'], + diffStaged: () => 'diff --git a/src/auth.ts\n+const jwt = require("jsonwebtoken");', + log: () => [], + getCommitDiff: () => '', + show: () => '', + revParse: () => 'abc123', + isInsideWorkTree: () => true, + getRoot: () => '/tmp/test-repo', + }; +} + +function createMockLogger(): ILogger { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => createMockLogger(), + }; +} + +function createMockAgentResolver(): IAgentResolver { + return { + resolveAgent: () => 'Claude-Code/1.0', + resolveModel: () => 'claude-sonnet-4-20250514', + }; +} + +function createMockConfigLoader(config?: Partial): IHookConfigLoader { + return { + loadConfig: () => ({ + hooks: { + enabled: true, + sessionStart: { enabled: true, memoryLimit: 20 }, + sessionStop: { enabled: true, autoExtract: true, threshold: 3 }, + promptSubmit: { enabled: false, recordPrompts: false, surfaceContext: true }, + postCommit: { enabled: true }, + commitMsg: { + enabled: true, + autoAnalyze: true, + inferTags: true, + requireType: false, + defaultLifecycle: 'project', + enrich: true, + enrichTimeout: 5000, + ...config, + }, + }, + }), + }; +} + +function createMockLLMClient( + result?: Partial | 'error' | 'timeout', +): ILLMClient { + return { + enrichCommit: async () => { + if (result === 'error') { + throw new Error('LLM API error'); + } + if (result === 'timeout') { + // Simulate a very slow response (will be raced against timeout) + await new Promise((resolve) => setTimeout(resolve, 10000)); + return { facts: [], usage: { inputTokens: 0, outputTokens: 0 } }; + } + return { + facts: [ + { + content: 'Using JWT for stateless authentication', + type: 'decision', + confidence: 'high', + tags: ['auth', 'jwt'], + }, + ], + usage: { inputTokens: 100, outputTokens: 50 }, + ...result, + } as ILLMEnrichmentResult; + }, + }; +} + +// ─── Test Helpers ──────────────────────────────────────────────────────────── + +let testDir: string; + +function setupTestRepo(): string { + testDir = mkdtempSync(join(tmpdir(), 'git-mem-test-')); + execSync('git init', { cwd: testDir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: testDir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: testDir, stdio: 'pipe' }); + return testDir; +} + +function cleanupTestRepo(): void { + if (testDir) { + rmSync(testDir, { recursive: true, force: true }); + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('CommitMsgHandler', () => { + describe('handle - basic behavior', () => { + it('should return success when processing commit message', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: false }), + null, + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + assert.equal(result.handler, 'CommitMsgHandler'); + } finally { + cleanupTestRepo(); + } + }); + + it('should skip if AI-Memory-Id already present', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n\nAI-Memory-Id: abc123\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: false }), + null, + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + // Message should be unchanged + const content = readFileSync(msgPath, 'utf8'); + assert.ok(!content.includes('AI-Source:')); + } finally { + cleanupTestRepo(); + } + }); + + it('should skip merge commits', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'Merge branch "feature" into main\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: false }), + null, + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + assert.ok(!content.includes('AI-Memory-Id:')); + } finally { + cleanupTestRepo(); + } + }); + + it('should add AI-Source: heuristic when using heuristic analysis', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: false }), + null, // No LLM client + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + assert.ok(content.includes('AI-Source: heuristic')); + } finally { + cleanupTestRepo(); + } + }); + }); + + describe('handle - LLM enrichment', () => { + it('should use LLM enrichment when enabled and client available', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add JWT auth\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: true }), + createMockLLMClient(), + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + assert.ok(content.includes('AI-Source: llm-enrichment')); + assert.ok(content.includes('AI-Decision:')); + assert.ok(content.includes('JWT')); + } finally { + cleanupTestRepo(); + } + }); + + it('should fall back to heuristic when enrich is disabled', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: false }), + createMockLLMClient(), + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + assert.ok(content.includes('AI-Source: heuristic')); + } finally { + cleanupTestRepo(); + } + }); + + it('should fall back to heuristic when LLM client is null', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: true }), + null, // No LLM client + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + assert.ok(content.includes('AI-Source: heuristic')); + } finally { + cleanupTestRepo(); + } + }); + + it('should fall back to heuristic when LLM returns empty facts', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: true }), + createMockLLMClient({ facts: [] }), + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + assert.ok(content.includes('AI-Source: heuristic')); + } finally { + cleanupTestRepo(); + } + }); + + it('should fall back to heuristic when LLM throws error', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: true }), + createMockLLMClient('error'), + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + assert.ok(content.includes('AI-Source: heuristic')); + } finally { + cleanupTestRepo(); + } + }); + + it('should fall back to heuristic when LLM times out', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: true, enrichTimeout: 50 }), // Very short timeout + createMockLLMClient('timeout'), + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + assert.ok(content.includes('AI-Source: heuristic')); + } finally { + cleanupTestRepo(); + } + }); + + it('should merge tags from all LLM facts', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + const facts: ILLMExtractedFact[] = [ + { content: 'Using JWT', type: 'decision', confidence: 'high', tags: ['auth', 'jwt'] }, + { content: 'Watch for expiry', type: 'gotcha', confidence: 'medium', tags: ['security', 'jwt'] }, + ]; + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: true }), + createMockLLMClient({ facts }), + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + // Should have merged tags from both facts + assert.ok(content.includes('AI-Tags:')); + assert.ok(content.includes('auth')); + assert.ok(content.includes('jwt')); + assert.ok(content.includes('security')); + } finally { + cleanupTestRepo(); + } + }); + }); + + describe('selectBestFact - priority logic', () => { + it('should prefer decision over gotcha', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + const facts: ILLMExtractedFact[] = [ + { content: 'Watch for expiry', type: 'gotcha', confidence: 'high', tags: [] }, + { content: 'Using JWT for auth', type: 'decision', confidence: 'high', tags: [] }, + ]; + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: true }), + createMockLLMClient({ facts }), + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + // Decision trailer should be present (not Gotcha) + assert.ok(content.includes('AI-Decision:')); + assert.ok(content.includes('JWT')); + } finally { + cleanupTestRepo(); + } + }); + + it('should prefer higher confidence within same type', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + const facts: ILLMExtractedFact[] = [ + { content: 'Maybe use sessions', type: 'decision', confidence: 'low', tags: [] }, + { content: 'Use JWT for stateless auth', type: 'decision', confidence: 'high', tags: [] }, + ]; + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: true }), + createMockLLMClient({ facts }), + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + // Should use the high-confidence decision + assert.ok(content.includes('AI-Confidence: high')); + assert.ok(content.includes('stateless')); + } finally { + cleanupTestRepo(); + } + }); + }); +}); From 2d50f69278583a5847eb2764f0e041bb1894a353 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 18:30:32 +0000 Subject: [PATCH 2/3] fix: address PR review feedback (GIT-87) - Clear timeout timer when LLM enrichment completes - Add 'uncertain' to confidence priority map - Respect inferTags config in buildEnrichedTrailers - Fix mock logger to include all ILogger methods - Update container to attempt LLM client creation by default - Add test for inferTags:false with LLM enrichment Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Gotcha: address PR review feedback (GIT-87). - Clear timeout timer when LLM enrichment completes AI-Confidence: medium AI-Tags: application, handlers, infrastructure, tests, unit AI-Lifecycle: project AI-Memory-Id: 5a79a51f --- src/application/handlers/CommitMsgHandler.ts | 27 ++++++++++----- src/infrastructure/di/container.ts | 7 ++++ .../handlers/CommitMsgHandler.test.ts | 33 +++++++++++++++++++ .../unit/infrastructure/di/container.test.ts | 5 +-- 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/application/handlers/CommitMsgHandler.ts b/src/application/handlers/CommitMsgHandler.ts index 50d24fe3..f30e5b1c 100644 --- a/src/application/handlers/CommitMsgHandler.ts +++ b/src/application/handlers/CommitMsgHandler.ts @@ -162,12 +162,18 @@ export class CommitMsgHandler implements IEventHandler { filesChanged: stagedFiles, }); + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(null), timeoutMs); + timeoutId = setTimeout(() => resolve(null), timeoutMs); }); const result = await Promise.race([enrichmentPromise, timeoutPromise]); + // Clear timeout if enrichment completed first + if (timeoutId) { + clearTimeout(timeoutId); + } + if (result === null) { this.logger.warn('LLM enrichment timed out', { timeoutMs }); return null; @@ -284,15 +290,17 @@ export class CommitMsgHandler implements IEventHandler { // Use confidence from LLM trailers.push({ key: AI_TRAILER_KEYS.CONFIDENCE, value: bestFact.confidence }); - // Merge tags from all facts - const allTags = new Set(); - for (const fact of facts) { - for (const tag of fact.tags) { - allTags.add(tag); + // Merge tags from all facts (respect inferTags setting) + if (config.inferTags) { + const allTags = new Set(); + for (const fact of facts) { + for (const tag of fact.tags) { + allTags.add(tag); + } + } + if (allTags.size > 0) { + trailers.push({ key: AI_TRAILER_KEYS.TAGS, value: [...allTags].join(', ') }); } - } - if (allTags.size > 0) { - trailers.push({ key: AI_TRAILER_KEYS.TAGS, value: [...allTags].join(', ') }); } } @@ -328,6 +336,7 @@ export class CommitMsgHandler implements IEventHandler { high: 3, medium: 2, low: 1, + uncertain: 0, }; return [...facts].sort((a, b) => { diff --git a/src/infrastructure/di/container.ts b/src/infrastructure/di/container.ts index 1fd8db1f..12ce9e30 100644 --- a/src/infrastructure/di/container.ts +++ b/src/infrastructure/di/container.ts @@ -100,6 +100,13 @@ export function createContainer(options?: IContainerOptions): AwilixContainer { + // When enrich is explicitly false, do not create LLM client. + // When enrich is true or not specified, attempt to create it. + // This allows hooks to use LLM enrichment without explicit opt-in, + // while still respecting explicit enrich:false from CLI commands. + if (options?.enrich === false) { + return null; + } return createLLMClient() ?? null; }).singleton(), diff --git a/tests/unit/application/handlers/CommitMsgHandler.test.ts b/tests/unit/application/handlers/CommitMsgHandler.test.ts index 7afc70fa..48e8b3e0 100644 --- a/tests/unit/application/handlers/CommitMsgHandler.test.ts +++ b/tests/unit/application/handlers/CommitMsgHandler.test.ts @@ -73,11 +73,14 @@ function createMockGitClient(): IGitClient { function createMockLogger(): ILogger { return { + trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, + fatal: () => {}, child: () => createMockLogger(), + isLevelEnabled: () => true, }; } @@ -417,6 +420,36 @@ describe('CommitMsgHandler', () => { } }); + it('should not include tags when inferTags is false', async () => { + const dir = setupTestRepo(); + const msgPath = join(dir, 'COMMIT_EDITMSG'); + writeFileSync(msgPath, 'feat: add auth\n'); + + const facts: ILLMExtractedFact[] = [ + { content: 'Using JWT', type: 'decision', confidence: 'high', tags: ['auth', 'jwt'] }, + ]; + + try { + const handler = new CommitMsgHandler( + createMockCommitAnalyzer(), + createMockGitClient(), + createMockLogger(), + createMockAgentResolver(), + createMockConfigLoader({ enrich: true, inferTags: false }), + createMockLLMClient({ facts }), + ); + + const result = await handler.handle(createEvent({ commitMsgPath: msgPath, cwd: dir })); + + assert.equal(result.success, true); + const content = readFileSync(msgPath, 'utf8'); + assert.ok(content.includes('AI-Source: llm-enrichment')); + assert.ok(!content.includes('AI-Tags:')); // Tags should NOT be present + } finally { + cleanupTestRepo(); + } + }); + it('should merge tags from all LLM facts', async () => { const dir = setupTestRepo(); const msgPath = join(dir, 'COMMIT_EDITMSG'); diff --git a/tests/unit/infrastructure/di/container.test.ts b/tests/unit/infrastructure/di/container.test.ts index 8b0f98cf..16b8ddf0 100644 --- a/tests/unit/infrastructure/di/container.test.ts +++ b/tests/unit/infrastructure/di/container.test.ts @@ -120,7 +120,9 @@ describe('createContainer', () => { assert.equal(container.cradle.llmClient, null); }); - it('should return null when enrich is not specified', () => { + it('should attempt to create LLM client when enrich is not specified', () => { + // Without ANTHROPIC_API_KEY, createLLMClient() returns null + // This allows hooks to use LLM enrichment without explicit opt-in const container = createContainer(); assert.equal(container.cradle.llmClient, null); }); @@ -128,7 +130,6 @@ describe('createContainer', () => { it('should attempt to create LLM client when enrich is true', () => { // Without ANTHROPIC_API_KEY, createLLMClient() returns null const container = createContainer({ enrich: true }); - // Should still be null since no API key is set in test env assert.equal(container.cradle.llmClient, null); }); }); From 65eaba81a90dfc4beee8798fc809a81bb7117419 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 18:35:50 +0000 Subject: [PATCH 3/3] chore: remove unused mock import (GIT-87) Remove unused `mock` import from node:test in CommitMsgHandler tests. Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Fact: remove unused mock import (GIT-87). Remove unused `mock` import from node:test in CommitMsgHandler tests. AI-Confidence: low AI-Tags: tests, unit, application, handlers AI-Lifecycle: project AI-Memory-Id: 451c45dd --- tests/unit/application/handlers/CommitMsgHandler.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/application/handlers/CommitMsgHandler.test.ts b/tests/unit/application/handlers/CommitMsgHandler.test.ts index 48e8b3e0..f0a1d90f 100644 --- a/tests/unit/application/handlers/CommitMsgHandler.test.ts +++ b/tests/unit/application/handlers/CommitMsgHandler.test.ts @@ -5,7 +5,7 @@ * and fallback to heuristic analysis. */ -import { describe, it, beforeEach, mock } from 'node:test'; +import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { writeFileSync, mkdtempSync, rmSync, readFileSync } from 'fs'; import { join } from 'path';