@elsikora/commitizen-plugin-commitlint-ai
Version:
AI-powered Commitizen adapter with Commitlint integration
206 lines (203 loc) • 9.84 kB
JavaScript
import lint from '@commitlint/lint';
import load from '@commitlint/load';
import { ELLIPSIS_LENGTH } from '../../domain/constant/numeric.constant.js';
import { CommitBody } from '../../domain/value-object/commit-body.value-object.js';
import { CommitHeader } from '../../domain/value-object/commit-header.value-object.js';
/**
* Commitlint implementation of the commit validator
*/
class CommitlintValidatorService {
LLM_SERVICES;
llmConfiguration;
constructor(llmServices) {
this.LLM_SERVICES = llmServices;
}
/**
* Attempt to fix a commit message based on validation errors
* @param {CommitMessage} message - The commit message to fix
* @param {ICommitValidationResult} validationResult - The validation result containing errors
* @param {ILlmPromptContext} context - Optional original context for LLM-based fixing
* @returns {Promise<CommitMessage | null>} Promise resolving to the fixed commit message or null if unfixable
*/
async fix(message, validationResult, context) {
if (!validationResult.errors || validationResult.errors.length === 0) {
return message;
}
// If we have context and LLM services, use LLM to regenerate
if (context && this.LLM_SERVICES && this.llmConfiguration) {
const service = this.LLM_SERVICES.find((s) => {
const config = this.llmConfiguration;
return config ? s.supports(config) : false;
});
if (service) {
process.stdout.write("Using LLM to intelligently fix validation errors...\n");
try {
// Create a minimal context for fixing - no need to send diff again
const fixContext = {
body: context.body,
// Explicitly exclude diff and files
diff: undefined,
files: undefined,
rules: {
...(typeof context.rules === "object" && !Array.isArray(context.rules) ? context.rules : {}),
instructions: "Fix the commit message to comply with the validation rules. Do not change the meaning or content, only fix the format to pass validation.",
previousAttempt: message.toString(),
validationErrors: validationResult.errors,
},
scopeDescription: context.scopeDescription,
subject: context.subject,
typeDescription: context.typeDescription,
typeDescriptions: context.typeDescriptions,
typeEnum: context.typeEnum,
};
// Generate a new commit message with the minimal context
const fixedMessage = await service.generateCommitMessage(fixContext, this.llmConfiguration);
// Validate the new message
const fixedValidation = await this.validate(fixedMessage);
if (fixedValidation.isValid) {
process.stdout.write("LLM fix successful!\n");
return fixedMessage;
}
else {
process.stdout.write("LLM fix still has validation errors, falling back to simple fixes\n");
}
}
catch (error) {
process.stderr.write(`Failed to fix commit message with LLM: ${error instanceof Error ? error.message : String(error)}\n`);
// Fall through to simple fixes
}
}
}
process.stdout.write("Attempting simple rule-based fixes...\n");
// Fallback to simple fixes
let fixedMessage = message;
// Try to fix common errors
for (const error of validationResult.errors) {
if (error.includes("subject may not be empty")) {
// Can't fix empty subject
return null;
}
if (error.includes("type may not be empty")) {
// Can't fix empty type
return null;
}
// Fix subject case issues
if (error.includes("subject must not be sentence-case")) {
const header = fixedMessage.getHeader();
const subject = header.getSubject();
const fixedSubject = subject.charAt(0).toLowerCase() + subject.slice(1);
const newHeader = new CommitHeader(header.getType(), fixedSubject, header.getScope());
fixedMessage = fixedMessage.withHeader(newHeader);
}
// Fix subject trailing period
if (error.includes("subject may not end with period")) {
const header = fixedMessage.getHeader();
const subject = header.getSubject();
const fixedSubject = subject.replace(/\.$/, "");
const newHeader = new CommitHeader(header.getType(), fixedSubject, header.getScope());
fixedMessage = fixedMessage.withHeader(newHeader);
}
// Fix header max length
if (error.includes("header must not be longer than")) {
const maxLengthNumbers = error.match(/\d+/g) ?? [];
if (maxLengthNumbers.length > 0 && maxLengthNumbers[0]) {
const maxLength = Number.parseInt(maxLengthNumbers[0], 10);
const header = fixedMessage.getHeader();
const currentLength = header.toString().length;
if (currentLength > maxLength) {
// Try to shorten the subject
const overhead = currentLength - maxLength;
const subject = header.getSubject();
const shortenedSubject = subject.slice(0, Math.max(0, subject.length - overhead - ELLIPSIS_LENGTH)) + "...";
const newHeader = new CommitHeader(header.getType(), shortenedSubject, header.getScope());
fixedMessage = fixedMessage.withHeader(newHeader);
}
}
}
// Fix body/footer line length
if (error.includes("footer's lines must not be longer than") || error.includes("body's lines must not be longer than")) {
const maxLengthNumbers = error.match(/\d+/g) ?? [];
if (maxLengthNumbers.length > 0 && maxLengthNumbers[0]) {
const maxLength = Number.parseInt(maxLengthNumbers[0], 10);
const body = fixedMessage.getBody();
// Wrap body lines
const wrappedBody = this.wrapText(body.getContent(), maxLength);
const wrappedBreaking = this.wrapText(body.getBreakingChange(), maxLength);
const newBody = new CommitBody(wrappedBody, wrappedBreaking);
fixedMessage = fixedMessage.withBody(newBody);
}
}
}
// Validate the fixed message
const fixedValidation = await this.validate(fixedMessage);
if (fixedValidation.isValid) {
process.stdout.write("Simple fixes successful!\n");
return fixedMessage;
}
// If still invalid, return null
process.stdout.write("Simple fixes failed to resolve all validation errors\n");
return null;
}
/**
* Set the LLM configuration for this validator
* @param {LLMConfiguration} configuration - The LLM configuration to set
*/
setLLMConfiguration(configuration) {
this.llmConfiguration = configuration;
}
/**
* Validate a commit message using commitlint
* @param {CommitMessage} message - The commit message to validate
* @returns {Promise<ICommitValidationResult>} Promise resolving to the validation result
*/
async validate(message) {
const loadResult = await load();
const { rules = {} } = loadResult;
const result = await lint(message.toString(), rules);
return {
errors: result.errors.map((error) => error.message),
isValid: result.valid,
warnings: result.warnings.map((warning) => warning.message),
};
}
/**
* Wrap text to ensure no line exceeds the specified length
* @param {string | undefined} text - The text to wrap
* @param {number} maxLength - Maximum line length
* @returns {string | undefined} The wrapped text
*/
wrapText(text, maxLength) {
if (!text) {
return text;
}
const lines = text.split("\n");
const wrappedLines = [];
for (const line of lines) {
if (line.length <= maxLength) {
wrappedLines.push(line);
}
else {
// Simple word wrap
const words = line.split(" ");
let currentLine = "";
for (const word of words) {
if (currentLine.length + word.length + 1 <= maxLength) {
currentLine += (currentLine ? " " : "") + word;
}
else {
if (currentLine) {
wrappedLines.push(currentLine);
}
currentLine = word;
}
}
if (currentLine) {
wrappedLines.push(currentLine);
}
}
}
return wrappedLines.join("\n");
}
}
export { CommitlintValidatorService };
//# sourceMappingURL=commitlint-validator.service.js.map