diff --git a/src/config/loadConfig.ts b/src/config/loadConfig.ts index 3ac90fc..a71c49d 100644 --- a/src/config/loadConfig.ts +++ b/src/config/loadConfig.ts @@ -5,6 +5,8 @@ export interface GitbunConfig { customPrompt?: string; format?: string; model?: string; + qualityCheck?: boolean; + strictQuality?: boolean; } /** Loads and returns user config from .gitbunrc or cosmiconfig. */ @@ -12,5 +14,9 @@ export async function loadConfig(): Promise { const explorer = cosmiconfig("smartcommit"); const result = await explorer.search(); - return result?.config || {}; + return { + qualityCheck: true, + strictQuality: false, + ...result?.config, + }; } diff --git a/src/index.ts b/src/index.ts index e46764d..370e79e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,10 @@ import { analyzeSemanticChanges } from "./analyzer/semanticAnalyzer"; import { SemanticEvent } from "./analyzer/semanticTypes"; import { ValidationError, CancellationError } from "./utils/errors"; import { colorizeCommitMessage } from "./utils/commitColors"; +import { + analyzeCommitQuality, + shouldBlockCommitForQuality, +} from "./validators/commitQuality"; interface CliOptions { ai?: boolean; @@ -119,6 +123,7 @@ export async function run(options: CliOptions) { const spinner = ora(); let commitMessage = ""; + const config = await loadConfig(); try { // --- Build enriched files (from main, but without spinner yet) --- @@ -177,7 +182,6 @@ export async function run(options: CliOptions) { // --- Generate commit message (pass semantic events) --- spinner.start("Generating commit message..."); - const config = await loadConfig(); commitMessage = generateCommitMessage( type, scope, @@ -230,6 +234,24 @@ export async function run(options: CliOptions) { process.exit(0); } + if (config.qualityCheck !== false) { + const quality = analyzeCommitQuality(commitMessage); + + if (!options.auto && quality.score < 100) { + console.log(`\nCommit Quality: ${quality.score}/100\n`); + console.log("Warnings:"); + for (const warning of quality.warnings) { + console.log(`- ${warning}`); + } + } + + if (shouldBlockCommitForQuality(quality, config.strictQuality)) { + throw new ValidationError( + `Commit quality score ${quality.score}/100 is below strict threshold.` + ); + } + } + // Confirmation let finalMessage: string; if (options.auto) { diff --git a/src/validators/commitQuality.test.ts b/src/validators/commitQuality.test.ts new file mode 100644 index 0000000..72db591 --- /dev/null +++ b/src/validators/commitQuality.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeCommitQuality, + shouldBlockCommitForQuality, +} from "./commitQuality"; + +describe("analyzeCommitQuality", () => { + it("accepts basic Conventional Commit messages", () => { + expect(analyzeCommitQuality("feat: add auth").warnings).not.toContain( + "Invalid Conventional Commit format", + ); + expect(analyzeCommitQuality("fix(core): resolve bug").score).toBe(100); + }); + + it("warns for invalid Conventional Commit messages", () => { + expect(analyzeCommitQuality("update code").warnings).toContain( + "Invalid Conventional Commit format", + ); + expect(analyzeCommitQuality("fix stuff").warnings).toContain( + "Invalid Conventional Commit format", + ); + }); + + it("detects generic commit messages case-insensitively", () => { + expect(analyzeCommitQuality("WIP").warnings).toContain( + "Generic commit message", + ); + }); + + it("applies simple score penalties", () => { + expect(analyzeCommitQuality("fix stuff")).toEqual({ + score: 0, + warnings: [ + "Invalid Conventional Commit format", + "Generic commit message", + "Subject too short", + ], + }); + }); + + it("blocks only in strict mode when score is below 60", () => { + const result = analyzeCommitQuality("changes"); + + expect(shouldBlockCommitForQuality(result, true)).toBe(true); + expect(shouldBlockCommitForQuality(result, false)).toBe(false); + }); +}); diff --git a/src/validators/commitQuality.ts b/src/validators/commitQuality.ts new file mode 100644 index 0000000..c09c607 --- /dev/null +++ b/src/validators/commitQuality.ts @@ -0,0 +1,57 @@ +export interface CommitQualityResult { + score: number; + warnings: string[]; +} + +const GENERIC_MESSAGES = [ + "update code", + "fix stuff", + "misc changes", + "changes", + "update files", + "work in progress", + "wip", +]; + +const CONVENTIONAL_COMMIT_PATTERN = + /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._-]+\))?: .+/i; + +function getSubject(message: string): string { + const separatorIndex = message.indexOf(":"); + return separatorIndex >= 0 + ? message.slice(separatorIndex + 1).trim() + : message.trim(); +} + +export function analyzeCommitQuality(message: string): CommitQualityResult { + const normalizedMessage = message.trim().toLowerCase(); + const warnings: string[] = []; + let score = 100; + + if (!CONVENTIONAL_COMMIT_PATTERN.test(message.trim())) { + score -= 40; + warnings.push("Invalid Conventional Commit format"); + } + + if (GENERIC_MESSAGES.includes(normalizedMessage)) { + score -= 40; + warnings.push("Generic commit message"); + } + + if (getSubject(message).length < 10) { + score -= 20; + warnings.push("Subject too short"); + } + + return { + score: Math.max(0, Math.min(100, score)), + warnings, + }; +} + +export function shouldBlockCommitForQuality( + result: CommitQualityResult, + strictQuality = false, +): boolean { + return strictQuality && result.score < 60; +}