commit-analyzer 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude/settings.local.json +11 -1
  2. package/README.md +33 -2
  3. package/commits.csv +2 -0
  4. package/eslint.config.mts +45 -0
  5. package/package.json +17 -9
  6. package/src/1.domain/analysis.ts +93 -0
  7. package/src/1.domain/analyzed-commit.ts +97 -0
  8. package/src/1.domain/application-error.ts +32 -0
  9. package/src/1.domain/category.ts +52 -0
  10. package/src/1.domain/commit-analysis-service.ts +92 -0
  11. package/src/1.domain/commit-hash.ts +40 -0
  12. package/src/1.domain/commit.ts +99 -0
  13. package/src/1.domain/date-formatting-service.ts +81 -0
  14. package/src/1.domain/date-range.ts +76 -0
  15. package/src/1.domain/report-generation-service.ts +292 -0
  16. package/src/2.application/analyze-commits.usecase.ts +307 -0
  17. package/src/2.application/generate-report.usecase.ts +204 -0
  18. package/src/2.application/llm-service.ts +54 -0
  19. package/src/2.application/resume-analysis.usecase.ts +123 -0
  20. package/src/3.presentation/analysis-repository.interface.ts +27 -0
  21. package/src/3.presentation/analyze-command.ts +128 -0
  22. package/src/3.presentation/cli-application.ts +255 -0
  23. package/src/3.presentation/command-handler.interface.ts +4 -0
  24. package/src/3.presentation/commit-analysis-controller.ts +101 -0
  25. package/src/3.presentation/commit-repository.interface.ts +47 -0
  26. package/src/3.presentation/console-formatter.ts +129 -0
  27. package/src/3.presentation/progress-repository.interface.ts +49 -0
  28. package/src/3.presentation/report-command.ts +50 -0
  29. package/src/3.presentation/resume-command.ts +59 -0
  30. package/src/3.presentation/storage-repository.interface.ts +33 -0
  31. package/src/3.presentation/storage-service.interface.ts +32 -0
  32. package/src/3.presentation/version-control-service.interface.ts +41 -0
  33. package/src/4.infrastructure/cache-service.ts +271 -0
  34. package/src/4.infrastructure/cached-analysis-repository.ts +46 -0
  35. package/src/4.infrastructure/claude-llm-adapter.ts +124 -0
  36. package/src/4.infrastructure/csv-service.ts +206 -0
  37. package/src/4.infrastructure/file-storage-repository.ts +108 -0
  38. package/src/4.infrastructure/file-system-storage-adapter.ts +87 -0
  39. package/src/4.infrastructure/gemini-llm-adapter.ts +46 -0
  40. package/src/4.infrastructure/git-adapter.ts +116 -0
  41. package/src/4.infrastructure/git-commit-repository.ts +85 -0
  42. package/src/4.infrastructure/json-progress-tracker.ts +182 -0
  43. package/src/4.infrastructure/llm-adapter-factory.ts +26 -0
  44. package/src/4.infrastructure/llm-adapter.ts +455 -0
  45. package/src/4.infrastructure/llm-analysis-repository.ts +38 -0
  46. package/src/4.infrastructure/openai-llm-adapter.ts +57 -0
  47. package/src/di.ts +108 -0
  48. package/src/main.ts +63 -0
  49. package/src/utils/app-paths.ts +36 -0
  50. package/src/utils/concurrency.ts +81 -0
  51. package/src/utils.ts +77 -0
  52. package/tsconfig.json +7 -1
  53. package/src/cli.ts +0 -170
  54. package/src/csv-reader.ts +0 -180
  55. package/src/csv.ts +0 -40
  56. package/src/errors.ts +0 -49
  57. package/src/git.ts +0 -112
  58. package/src/index.ts +0 -395
  59. package/src/llm.ts +0 -396
  60. package/src/progress.ts +0 -84
  61. package/src/report-generator.ts +0 -286
  62. package/src/types.ts +0 -24
@@ -0,0 +1,49 @@
1
+ import { AnalyzedCommit } from "@domain/analyzed-commit"
2
+ import { CommitHash } from "@domain/commit-hash"
3
+
4
+ /**
5
+ * Progress state for tracking analysis progress
6
+ */
7
+ export interface ProgressState {
8
+ totalCommits: CommitHash[]
9
+ processedCommits: CommitHash[]
10
+ analyzedCommits: AnalyzedCommit[]
11
+ lastProcessedIndex: number
12
+ startTime: Date
13
+ outputFile: string
14
+ }
15
+
16
+ /**
17
+ * Repository interface for progress tracking operations
18
+ */
19
+ export interface IProgressRepository {
20
+ /**
21
+ * Saves the current progress state
22
+ */
23
+ saveProgress(state: ProgressState): Promise<void>
24
+
25
+ /**
26
+ * Loads the saved progress state
27
+ */
28
+ loadProgress(): Promise<ProgressState | null>
29
+
30
+ /**
31
+ * Checks if there is saved progress
32
+ */
33
+ hasProgress(): Promise<boolean>
34
+
35
+ /**
36
+ * Clears any saved progress
37
+ */
38
+ clearProgress(): Promise<void>
39
+
40
+ /**
41
+ * Gets the remaining commits from a progress state
42
+ */
43
+ getRemainingCommits(state: ProgressState): CommitHash[]
44
+
45
+ /**
46
+ * Formats a progress summary for display
47
+ */
48
+ formatProgressSummary(state: ProgressState): string
49
+ }
@@ -0,0 +1,50 @@
1
+ import { GenerateReportUseCase } from "@app/generate-report.usecase"
2
+
3
+ import { ConsoleFormatter } from "./console-formatter"
4
+
5
+ export interface ReportCommandOptions {
6
+ inputCsv?: string
7
+ output: string
8
+ includeStatistics?: boolean
9
+ sourceInfo?: {
10
+ type: "author" | "commits" | "csv"
11
+ value: string
12
+ }
13
+ }
14
+
15
+ export class ReportCommand {
16
+ constructor(private readonly generateReportUseCase: GenerateReportUseCase) {}
17
+
18
+ async execute(options: ReportCommandOptions): Promise<void> {
19
+ try {
20
+ console.log("\nGenerating report from existing CSV...")
21
+
22
+ const result = await this.generateReportUseCase.handle({
23
+ inputCsvPath: options.inputCsv,
24
+ outputPath: options.output,
25
+ includeStatistics: options.includeStatistics ?? true,
26
+ sourceInfo:
27
+ options.sourceInfo ||
28
+ (options.inputCsv
29
+ ? { type: "csv", value: options.inputCsv }
30
+ : undefined),
31
+ })
32
+
33
+ ConsoleFormatter.logReport(`Report generated: ${result.reportPath}`)
34
+
35
+ const stats = result.statistics
36
+ ConsoleFormatter.logSuccess(
37
+ `Processed ${result.commitsProcessed} commits`,
38
+ )
39
+
40
+ ConsoleFormatter.logInfo(
41
+ `Categories: ${stats.categoryBreakdown.feature} features, ${stats.categoryBreakdown.process} process, ${stats.categoryBreakdown.tweak} tweaks`,
42
+ )
43
+ } catch (error) {
44
+ ConsoleFormatter.logError(
45
+ error instanceof Error ? error.message : "Unknown error occurred",
46
+ )
47
+ throw error
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,59 @@
1
+ import { ResumeAnalysisUseCase } from "@app/resume-analysis.usecase"
2
+
3
+ import { ConsoleFormatter } from "./console-formatter"
4
+
5
+ export interface ResumeCommandOptions {
6
+ verbose?: boolean
7
+ }
8
+
9
+ export class ResumeCommand {
10
+ constructor(private readonly resumeAnalysisUseCase: ResumeAnalysisUseCase) {}
11
+
12
+ async execute(options: ResumeCommandOptions): Promise<boolean> {
13
+ try {
14
+ const result = await this.resumeAnalysisUseCase.handle({
15
+ verbose: options.verbose,
16
+ })
17
+
18
+ if (!result) {
19
+ ConsoleFormatter.logInfo(
20
+ "No previous checkpoint found or user chose to start fresh",
21
+ )
22
+ return false
23
+ }
24
+
25
+ // Display results
26
+ ConsoleFormatter.logSuccess(
27
+ "Analysis resumed and completed successfully!",
28
+ )
29
+ ConsoleFormatter.logInfo(
30
+ `Total analyzed: ${result.analyzedCommits.length} commits`,
31
+ )
32
+
33
+ if (result.failedCommits > 0) {
34
+ ConsoleFormatter.logWarning(
35
+ `Failed commits during resume: ${result.failedCommits}`,
36
+ )
37
+ }
38
+
39
+ // Display category summary
40
+ const summary = result.analyzedCommits.reduce(
41
+ (acc, commit) => {
42
+ const category = commit.getAnalysis().getCategory().getValue()
43
+ acc[category] = (acc[category] || 0) + 1
44
+ return acc
45
+ },
46
+ {} as Record<string, number>,
47
+ )
48
+
49
+ ConsoleFormatter.displayAnalysisSummary(summary)
50
+
51
+ return true
52
+ } catch (error) {
53
+ ConsoleFormatter.logError(
54
+ error instanceof Error ? error.message : "Unknown error occurred",
55
+ )
56
+ throw error
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,33 @@
1
+ import { AnalyzedCommit } from "@domain/analyzed-commit"
2
+
3
+ export interface IStorageRepository {
4
+ /**
5
+ * Exports analyzed commits to CSV format
6
+ */
7
+ exportToCSV(commits: AnalyzedCommit[], filePath: string): Promise<void>
8
+
9
+ /**
10
+ * Imports commits from CSV format
11
+ */
12
+ importFromCSV(filePath: string): Promise<AnalyzedCommit[]>
13
+
14
+ /**
15
+ * Generates a markdown report from analyzed commits
16
+ */
17
+ generateReport(commits: AnalyzedCommit[], outputPath: string): Promise<void>
18
+
19
+ /**
20
+ * Reads commits from a file (one hash per line)
21
+ */
22
+ readCommitHashesFromFile(filePath: string): Promise<string[]>
23
+
24
+ /**
25
+ * Ensures a directory exists
26
+ */
27
+ ensureDirectoryExists(directoryPath: string): Promise<void>
28
+
29
+ /**
30
+ * Writes content to a file
31
+ */
32
+ writeFile(filePath: string, content: string): Promise<void>
33
+ }
@@ -0,0 +1,32 @@
1
+ export interface IStorageService {
2
+ /**
3
+ * Writes content to a file
4
+ */
5
+ writeFile(filePath: string, content: string): Promise<void>
6
+
7
+ /**
8
+ * Reads content from a file
9
+ */
10
+ readFile(filePath: string): Promise<string>
11
+
12
+ /**
13
+ * Checks if a file exists
14
+ */
15
+ fileExists(filePath: string): Promise<boolean>
16
+
17
+ /**
18
+ * Creates a directory if it doesn't exist
19
+ */
20
+ ensureDirectory(directoryPath: string): Promise<void>
21
+
22
+ /**
23
+ * Deletes a file
24
+ */
25
+ deleteFile(filePath: string): Promise<void>
26
+
27
+ /**
28
+ * Reads lines from a file
29
+ */
30
+ readLines(filePath: string): Promise<string[]>
31
+ }
32
+
@@ -0,0 +1,41 @@
1
+ export interface IVersionControlService {
2
+ /**
3
+ * Gets detailed commit information by hash
4
+ */
5
+ getCommitInfo(hash: string): Promise<{
6
+ hash: string
7
+ message: string
8
+ date: Date
9
+ diff: string
10
+ }>
11
+
12
+ /**
13
+ * Validates if a commit hash exists
14
+ */
15
+ validateCommitHash(hash: string): Promise<boolean>
16
+
17
+ /**
18
+ * Checks if current directory is a valid repository
19
+ */
20
+ isValidRepository(): Promise<boolean>
21
+
22
+ /**
23
+ * Gets current user's email from repository config
24
+ */
25
+ getCurrentUserEmail(): Promise<string>
26
+
27
+ /**
28
+ * Gets current user's name from repository config
29
+ */
30
+ getCurrentUserName(): Promise<string>
31
+
32
+ /**
33
+ * Gets commits authored by a specific user
34
+ */
35
+ getUserAuthoredCommits(params: {
36
+ authorEmail: string
37
+ limit?: number
38
+ since?: string
39
+ until?: string
40
+ }): Promise<string[]>
41
+ }
@@ -0,0 +1,271 @@
1
+ import { promises as fs } from "fs"
2
+ import path from "path"
3
+
4
+ import { Analysis } from "@domain/analysis"
5
+ import { Category, CategoryType } from "@domain/category"
6
+
7
+ import { AppPaths } from "../utils/app-paths"
8
+
9
+ /**
10
+ * Cache entry for storing analyzed commit results
11
+ */
12
+ interface CacheEntry {
13
+ hash: string
14
+ timestamp: number
15
+ analysis: {
16
+ category: string
17
+ summary: string
18
+ description: string
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Service for caching analyzed commit results
24
+ */
25
+ export class CacheService {
26
+ private static readonly DEFAULT_TTL_DAYS = 30
27
+ private static readonly CACHE_FILE_PREFIX = "commit-"
28
+
29
+ private readonly cacheDir: string
30
+ private readonly ttlMs: number
31
+ private cacheEnabled: boolean = true
32
+
33
+ constructor(
34
+ baseDir: string = process.cwd(),
35
+ ttlDays: number = CacheService.DEFAULT_TTL_DAYS
36
+ ) {
37
+ this.cacheDir = AppPaths.getCacheDir(baseDir)
38
+ this.ttlMs = ttlDays * 24 * 60 * 60 * 1000
39
+ }
40
+
41
+ /**
42
+ * Enable or disable caching
43
+ */
44
+ setCacheEnabled(enabled: boolean): void {
45
+ this.cacheEnabled = enabled
46
+ }
47
+
48
+ /**
49
+ * Initialize cache directory
50
+ */
51
+ async initialize(): Promise<void> {
52
+ if (!this.cacheEnabled) {
53
+ return
54
+ }
55
+
56
+ try {
57
+ await fs.mkdir(this.cacheDir, { recursive: true })
58
+ } catch (error) {
59
+ console.warn("Failed to initialize cache directory:", error)
60
+ this.cacheEnabled = false
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Get cached analysis result for a commit hash
66
+ */
67
+ async get(commitHash: string): Promise<Analysis | null> {
68
+ if (!this.cacheEnabled) {
69
+ return null
70
+ }
71
+
72
+ try {
73
+ const cacheFilePath = this.getCacheFilePath(commitHash)
74
+
75
+ // Check if cache file exists
76
+ const stat = await fs.stat(cacheFilePath)
77
+ const now = Date.now()
78
+
79
+ // Check if cache entry is expired
80
+ if (now - stat.mtimeMs > this.ttlMs) {
81
+ await this.delete(commitHash)
82
+ return null
83
+ }
84
+
85
+ // Read and parse cache entry
86
+ const cacheData = await fs.readFile(cacheFilePath, "utf-8")
87
+ const entry: CacheEntry = JSON.parse(cacheData)
88
+
89
+ // Verify hash matches
90
+ if (entry.hash !== commitHash) {
91
+ await this.delete(commitHash)
92
+ return null
93
+ }
94
+
95
+ // Reconstruct Analysis object
96
+ const category = Category.create(entry.analysis.category as CategoryType)
97
+ return new Analysis({
98
+ category,
99
+ summary: entry.analysis.summary,
100
+ description: entry.analysis.description,
101
+ })
102
+ } catch {
103
+ // Cache miss or error - return null
104
+ return null
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Store analysis result in cache
110
+ */
111
+ async set(commitHash: string, analysis: Analysis): Promise<void> {
112
+ if (!this.cacheEnabled) {
113
+ return
114
+ }
115
+
116
+ try {
117
+ await this.initialize()
118
+
119
+ const entry: CacheEntry = {
120
+ hash: commitHash,
121
+ timestamp: Date.now(),
122
+ analysis: {
123
+ category: analysis.getCategory().getValue(),
124
+ summary: analysis.getSummary(),
125
+ description: analysis.getDescription(),
126
+ },
127
+ }
128
+
129
+ const cacheFilePath = this.getCacheFilePath(commitHash)
130
+ await fs.writeFile(cacheFilePath, JSON.stringify(entry, null, 2))
131
+ } catch (error) {
132
+ // Silent fail for cache writes
133
+ console.warn(`Failed to cache analysis for ${commitHash}:`, error)
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Delete cached entry for a commit hash
139
+ */
140
+ async delete(commitHash: string): Promise<void> {
141
+ if (!this.cacheEnabled) {
142
+ return
143
+ }
144
+
145
+ try {
146
+ const cacheFilePath = this.getCacheFilePath(commitHash)
147
+ await fs.unlink(cacheFilePath)
148
+ } catch {
149
+ // Silent fail for cache deletes
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Clear all cache entries
155
+ */
156
+ async clear(): Promise<void> {
157
+ if (!this.cacheEnabled) {
158
+ return
159
+ }
160
+
161
+ try {
162
+ const files = await fs.readdir(this.cacheDir)
163
+ const cacheFiles = files.filter(file =>
164
+ file.startsWith(CacheService.CACHE_FILE_PREFIX)
165
+ )
166
+
167
+ await Promise.all(
168
+ cacheFiles.map(file =>
169
+ fs.unlink(path.join(this.cacheDir, file)).catch(() => {})
170
+ )
171
+ )
172
+ } catch {
173
+ // Silent fail for cache clear
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Get cache statistics
179
+ */
180
+ async getStats(): Promise<{
181
+ totalEntries: number
182
+ totalSize: number
183
+ oldestEntry: Date | null
184
+ newestEntry: Date | null
185
+ }> {
186
+ if (!this.cacheEnabled) {
187
+ return {
188
+ totalEntries: 0,
189
+ totalSize: 0,
190
+ oldestEntry: null,
191
+ newestEntry: null,
192
+ }
193
+ }
194
+
195
+ try {
196
+ const files = await fs.readdir(this.cacheDir)
197
+ const cacheFiles = files.filter(file =>
198
+ file.startsWith(CacheService.CACHE_FILE_PREFIX)
199
+ )
200
+
201
+ let totalSize = 0
202
+ let oldestTime = Number.MAX_SAFE_INTEGER
203
+ let newestTime = 0
204
+
205
+ for (const file of cacheFiles) {
206
+ const filePath = path.join(this.cacheDir, file)
207
+ const stat = await fs.stat(filePath)
208
+ totalSize += stat.size
209
+ oldestTime = Math.min(oldestTime, stat.mtimeMs)
210
+ newestTime = Math.max(newestTime, stat.mtimeMs)
211
+ }
212
+
213
+ return {
214
+ totalEntries: cacheFiles.length,
215
+ totalSize,
216
+ oldestEntry: cacheFiles.length > 0 ? new Date(oldestTime) : null,
217
+ newestEntry: cacheFiles.length > 0 ? new Date(newestTime) : null,
218
+ }
219
+ } catch {
220
+ return {
221
+ totalEntries: 0,
222
+ totalSize: 0,
223
+ oldestEntry: null,
224
+ newestEntry: null,
225
+ }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Clean expired cache entries
231
+ */
232
+ async cleanExpired(): Promise<number> {
233
+ if (!this.cacheEnabled) {
234
+ return 0
235
+ }
236
+
237
+ try {
238
+ const files = await fs.readdir(this.cacheDir)
239
+ const cacheFiles = files.filter(file =>
240
+ file.startsWith(CacheService.CACHE_FILE_PREFIX)
241
+ )
242
+
243
+ const now = Date.now()
244
+ let cleanedCount = 0
245
+
246
+ for (const file of cacheFiles) {
247
+ const filePath = path.join(this.cacheDir, file)
248
+ try {
249
+ const stat = await fs.stat(filePath)
250
+ if (now - stat.mtimeMs > this.ttlMs) {
251
+ await fs.unlink(filePath)
252
+ cleanedCount++
253
+ }
254
+ } catch {
255
+ // Skip files that can't be processed
256
+ }
257
+ }
258
+
259
+ return cleanedCount
260
+ } catch {
261
+ return 0
262
+ }
263
+ }
264
+
265
+ private getCacheFilePath(commitHash: string): string {
266
+ return path.join(
267
+ this.cacheDir,
268
+ `${CacheService.CACHE_FILE_PREFIX}${commitHash}.json`
269
+ )
270
+ }
271
+ }
@@ -0,0 +1,46 @@
1
+ import { Analysis } from "@domain/analysis"
2
+ import { Commit } from "@domain/commit"
3
+
4
+ import { IAnalysisRepository } from "@presentation/analysis-repository.interface"
5
+
6
+ import { CacheService } from "./cache-service"
7
+
8
+ /**
9
+ * Cached analysis repository that wraps another analysis repository
10
+ */
11
+ export class CachedAnalysisRepository implements IAnalysisRepository {
12
+ constructor(
13
+ private readonly baseRepository: IAnalysisRepository,
14
+ private readonly cacheService: CacheService,
15
+ ) {}
16
+
17
+ async analyze(commit: Commit): Promise<Analysis> {
18
+ const commitHash = commit.getHash().getValue()
19
+
20
+ // Try to get from cache first
21
+ const cachedAnalysis = await this.cacheService.get(commitHash)
22
+ if (cachedAnalysis) {
23
+ return cachedAnalysis
24
+ }
25
+
26
+ // Cache miss - analyze with base repository
27
+ const analysis = await this.baseRepository.analyze(commit)
28
+
29
+ // Store in cache for next time
30
+ await this.cacheService.set(commitHash, analysis)
31
+
32
+ return analysis
33
+ }
34
+
35
+ async isAvailable(): Promise<boolean> {
36
+ return this.baseRepository.isAvailable()
37
+ }
38
+
39
+ getMaxRetries(): number {
40
+ return this.baseRepository.getMaxRetries()
41
+ }
42
+
43
+ setVerbose(verbose: boolean): void {
44
+ this.baseRepository.setVerbose(verbose)
45
+ }
46
+ }
@@ -0,0 +1,124 @@
1
+ import { execSync } from "child_process"
2
+
3
+ import { CategoryType } from "@domain/category"
4
+
5
+ import { LLMAdapter } from "./llm-adapter"
6
+
7
+ export class ClaudeLLMAdapter extends LLMAdapter {
8
+ private static readonly MAX_PROMPT_LENGTH = parseInt(
9
+ process.env.CLAUDE_MAX_PROMPT_LENGTH || "100000",
10
+ 10,
11
+ )
12
+
13
+ protected getMaxPromptLength(): number {
14
+ return ClaudeLLMAdapter.MAX_PROMPT_LENGTH
15
+ }
16
+
17
+ async detectAvailableModels(): Promise<string[]> {
18
+ try {
19
+ execSync("command -v claude", { stdio: "ignore" })
20
+ return ["claude", "claude --model sonnet", "claude --model haiku"]
21
+ } catch {
22
+ return []
23
+ }
24
+ }
25
+
26
+ async isAvailable(): Promise<boolean> {
27
+ const available = await this.detectAvailableModels()
28
+ return available.length > 0
29
+ }
30
+
31
+ protected async executeModelCommand(prompt: string): Promise<string> {
32
+ const truncatedPrompt = this.truncatePrompt(prompt)
33
+
34
+ if (this.verbose) {
35
+ console.log(` - Prompt length: ${truncatedPrompt.length} characters`)
36
+ }
37
+
38
+ const modelCommand = this.model || "claude --model sonnet"
39
+
40
+ return execSync(modelCommand, {
41
+ input: truncatedPrompt,
42
+ encoding: "utf8",
43
+ stdio: ["pipe", "pipe", "pipe"],
44
+ timeout: LLMAdapter.DEFAULT_TIMEOUT,
45
+ })
46
+ }
47
+
48
+ protected parseResponse(response: string): {
49
+ category: CategoryType
50
+ summary: string
51
+ description: string
52
+ } {
53
+ try {
54
+ // First try standard JSON parsing
55
+ return super.parseResponse(response)
56
+ } catch {
57
+ // Claude often responds in natural language format, so try to parse that
58
+ if (this.verbose) {
59
+ console.log(` - Standard JSON parsing failed, trying Claude natural language parsing...`)
60
+ }
61
+ return this.parseClaudeNaturalLanguageResponse(response)
62
+ }
63
+ }
64
+
65
+ private parseClaudeNaturalLanguageResponse(response: string): {
66
+ category: CategoryType
67
+ summary: string
68
+ description: string
69
+ } {
70
+ // Try to extract category from various Claude response patterns
71
+ const categoryMatch = response.match(/\*\*?Category\*\*?:?\s*(tweak|feature|process)/i) ||
72
+ response.match(/Category:\s*(tweak|feature|process)/i) ||
73
+ response.match(/\*\*(tweak|feature|process)\*\*/i) ||
74
+ response.match(/(tweak|feature|process)\s*commit/i) ||
75
+ response.match(/should be categorized as[:\s]*\*\*?(tweak|feature|process)/i)
76
+ const category = categoryMatch?.[1]?.toLowerCase()
77
+
78
+ if (!category || !this.isValidCategory(category)) {
79
+ if (this.verbose) {
80
+ console.log(` - Failed to extract category from Claude response`)
81
+ console.log(` - Response snippet: ${response.substring(0, 500)}`)
82
+ }
83
+ throw new Error(`Could not extract valid category from Claude response`)
84
+ }
85
+
86
+ // Try to extract summary from patterns
87
+ const summaryMatch = response.match(/\*\*?Summary\*\*?:?\s*([^\n\r]+)/i) ||
88
+ response.match(/Summary:\s*([^\n\r]+)/i)
89
+ let summary = summaryMatch?.[1]?.trim()
90
+
91
+ if (!summary) {
92
+ // Fallback: try to find a descriptive line
93
+ const lines = response.split('\n').filter(line => line.trim())
94
+ summary = lines.find(line =>
95
+ line.includes('refactor') ||
96
+ line.includes('add') ||
97
+ line.includes('fix') ||
98
+ line.includes('update') ||
99
+ line.includes('implement')
100
+ )?.trim() || "Code changes"
101
+ }
102
+
103
+ // Try to extract description
104
+ const descMatch = response.match(/\*\*?Description\*\*?:?\s*([\s\S]+?)(?=\n\n|\n\*\*|\n---|\n#|$)/i) ||
105
+ response.match(/Description:\s*([\s\S]+?)(?=\n\n|\n\*\*|\n---|\n#|$)/i)
106
+ let description = descMatch?.[1]?.trim()
107
+
108
+ if (!description) {
109
+ // Fallback: extract the longest meaningful sentence
110
+ const sentences = response.split(/[.!?]+/).filter(s => s.trim().length > 20)
111
+ description = sentences[0]?.trim() || "Commit contains code changes"
112
+ }
113
+
114
+ // Clean up and truncate
115
+ summary = summary.substring(0, 80).replace(/[*"]/g, '').trim()
116
+ description = description.replace(/[*"]/g, '').trim()
117
+
118
+ return {
119
+ category: category as CategoryType,
120
+ summary: summary || "Code changes",
121
+ description: description || "This commit contains code changes."
122
+ }
123
+ }
124
+ }