@gilbert_oliveira/commit-wizard 2.12.2-canary.1 → 2.12.3-canary.2

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.
@@ -0,0 +1,367 @@
1
+ import type { Config } from '../config/index';
2
+ import type { CommitlintRules } from '../commitlint/index';
3
+ import type { AIProvider } from './types';
4
+ import { buildPrompt } from '../prompt/builder';
5
+ import { buildSystemMessage } from '../prompt/system';
6
+ import { shouldIgnoreFile } from '../git/diff-filter';
7
+
8
+ export interface CommitSuggestion {
9
+ message: string;
10
+ type:
11
+ | 'feat'
12
+ | 'fix'
13
+ | 'docs'
14
+ | 'style'
15
+ | 'refactor'
16
+ | 'test'
17
+ | 'chore'
18
+ | 'build'
19
+ | 'ci';
20
+ confidence: number;
21
+ }
22
+
23
+ export interface OpenAIResponse {
24
+ success: boolean;
25
+ suggestion?: CommitSuggestion;
26
+ error?: string;
27
+ }
28
+
29
+ /**
30
+ * Prioriza e trunca diffs para caber no limite de tokens da IA.
31
+ * Deprioritiza lockfiles e arquivos de build (delegando detecção ao diff-filter.ts).
32
+ * Para filtragem completa de arquivos irrelevantes, use filterDiff() antes desta função.
33
+ */
34
+ export function smartFilterDiff(diff: string, maxLength: number): string {
35
+ if (diff.length <= maxLength) {
36
+ return diff;
37
+ }
38
+
39
+ const fileDiffs = diff.split(/(?=^diff --git)/m).filter((block) => block.trim());
40
+
41
+ const highPriorityDiffs: string[] = [];
42
+ const lowPriorityDiffs: string[] = [];
43
+
44
+ fileDiffs.forEach((fileDiff) => {
45
+ const headerMatch = fileDiff.match(/^diff --git a\/(.*?) b\//m);
46
+ const filename = headerMatch ? headerMatch[1]! : '';
47
+ if (shouldIgnoreFile(filename)) {
48
+ lowPriorityDiffs.push(fileDiff);
49
+ } else {
50
+ highPriorityDiffs.push(fileDiff);
51
+ }
52
+ });
53
+
54
+ let result = '';
55
+ let remainingLength = maxLength;
56
+
57
+ for (const fileDiff of highPriorityDiffs) {
58
+ if (fileDiff.length <= remainingLength) {
59
+ result += fileDiff;
60
+ remainingLength -= fileDiff.length;
61
+ } else if (remainingLength > 200) {
62
+ result += fileDiff.substring(0, remainingLength - 50);
63
+ remainingLength = maxLength - result.length;
64
+ break;
65
+ } else {
66
+ break;
67
+ }
68
+ }
69
+
70
+ if (!result && lowPriorityDiffs.length > 0 && remainingLength > 0) {
71
+ for (const fileDiff of lowPriorityDiffs) {
72
+ if (fileDiff.length <= remainingLength) {
73
+ result += fileDiff;
74
+ remainingLength -= fileDiff.length;
75
+ } else if (remainingLength > 200) {
76
+ result += fileDiff.substring(0, remainingLength - 50);
77
+ remainingLength = maxLength - result.length;
78
+ break;
79
+ } else {
80
+ break;
81
+ }
82
+ }
83
+ }
84
+
85
+ const optimizationMessage = '\n... (diff otimizado para focar em mudanças principais)';
86
+ if (remainingLength > 200 && lowPriorityDiffs.length > 0) {
87
+ const lowPrioritySummary = `\n\n... (${lowPriorityDiffs.length} arquivo(s) de dependências/build omitido(s): ${lowPriorityDiffs
88
+ .map((d) => {
89
+ const match = d.match(/^diff --git a\/(.*?) b\//);
90
+ return match ? match[1] : '';
91
+ })
92
+ .filter(Boolean)
93
+ .join(', ')})`;
94
+
95
+ const availableSpace = remainingLength - optimizationMessage.length;
96
+ if (lowPrioritySummary.length < availableSpace) {
97
+ result += lowPrioritySummary;
98
+ }
99
+ }
100
+
101
+ const wasFiltered = result.trim() !== diff.trim();
102
+
103
+ return wasFiltered
104
+ ? result + optimizationMessage
105
+ : result;
106
+ }
107
+
108
+ /**
109
+ * Extrai o tipo de commit da mensagem gerada pela OpenAI
110
+ */
111
+ export function extractCommitTypeFromMessage(
112
+ message: string
113
+ ): CommitSuggestion['type'] | null {
114
+ const typePatterns = {
115
+ feat: /^(feat|feature)(\([^)]+\))?:/i,
116
+ fix: /^(fix|bugfix)(\([^)]+\))?:/i,
117
+ docs: /^(docs|documentation)(\([^)]+\))?:/i,
118
+ style: /^(style|format)(\([^)]+\))?:/i,
119
+ refactor: /^(refactor|refactoring)(\([^)]+\))?:/i,
120
+ test: /^(test|testing)(\([^)]+\))?:/i,
121
+ chore: /^(chore|maintenance)(\([^)]+\))?:/i,
122
+ build: /^(build|ci)(\([^)]+\))?:/i,
123
+ ci: /^(ci|continuous-integration)(\([^)]+\))?:/i,
124
+ };
125
+
126
+ for (const [type, pattern] of Object.entries(typePatterns)) {
127
+ if (pattern.test(message)) {
128
+ return type as CommitSuggestion['type'];
129
+ }
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Detecta o tipo de commit baseado no diff
137
+ */
138
+ export function detectCommitType(
139
+ diff: string,
140
+ filenames: string[]
141
+ ): CommitSuggestion['type'] {
142
+ const diffLower = diff.toLowerCase();
143
+ const filesStr = filenames.join(' ').toLowerCase();
144
+
145
+ if (
146
+ filesStr.includes('test') ||
147
+ filesStr.includes('spec') ||
148
+ diffLower.includes('test(')
149
+ ) {
150
+ return 'test';
151
+ }
152
+
153
+ if (
154
+ filesStr.includes('readme') ||
155
+ filesStr.includes('.md') ||
156
+ filesStr.includes('docs')
157
+ ) {
158
+ return 'docs';
159
+ }
160
+
161
+ if (
162
+ filesStr.includes('package.json') ||
163
+ filesStr.includes('dockerfile') ||
164
+ filesStr.includes('.yml') ||
165
+ filesStr.includes('.yaml') ||
166
+ filesStr.includes('webpack') ||
167
+ filesStr.includes('tsconfig')
168
+ ) {
169
+ return 'build';
170
+ }
171
+
172
+ if (
173
+ filesStr.includes('.css') ||
174
+ filesStr.includes('.scss') ||
175
+ diffLower.includes('style') ||
176
+ diffLower.includes('format')
177
+ ) {
178
+ return 'style';
179
+ }
180
+
181
+ if (
182
+ diffLower.includes('fix') ||
183
+ diffLower.includes('bug') ||
184
+ diffLower.includes('error') ||
185
+ diffLower.includes('issue')
186
+ ) {
187
+ return 'fix';
188
+ }
189
+
190
+ if (
191
+ diffLower.includes('add') ||
192
+ diffLower.includes('new') ||
193
+ diffLower.includes('create') ||
194
+ diffLower.includes('implement')
195
+ ) {
196
+ return 'feat';
197
+ }
198
+
199
+ if (
200
+ diffLower.includes('refactor') ||
201
+ diffLower.includes('restructure') ||
202
+ diffLower.includes('rename')
203
+ ) {
204
+ return 'refactor';
205
+ }
206
+
207
+ return 'chore';
208
+ }
209
+
210
+ /**
211
+ * Processa a mensagem retornada pela OpenAI removendo formatação desnecessária
212
+ */
213
+ export function processOpenAIMessage(message: string): string {
214
+ if (message.startsWith('```')) {
215
+ const blockWithBodyMatch = message.match(/^```[^\n`]*\n([\s\S]*?)\n\s*```([\s\S]*)$/);
216
+ if (blockWithBodyMatch) {
217
+ const codeContent = blockWithBodyMatch[1].trim();
218
+ const afterBlock = blockWithBodyMatch[2].trim();
219
+ message = afterBlock ? `${codeContent}\n\n${afterBlock}` : codeContent;
220
+ } else if (message.match(/^```[\s\S]*```$/)) {
221
+ message = message
222
+ .replace(/^```(?:plaintext|javascript|typescript|python|java|html|css|json|xml|yaml|yml|bash|shell|text)?\s*/, '')
223
+ .replace(/\s*```$/, '');
224
+ }
225
+ }
226
+
227
+ message = message.trim();
228
+
229
+ return message;
230
+ }
231
+
232
+ /**
233
+ * OpenAIProvider implements the AIProvider interface for the OpenAI API.
234
+ */
235
+ export class OpenAIProvider implements AIProvider {
236
+ constructor(private config: Config) {}
237
+
238
+ async generate(prompt: string, systemMessage: string | null): Promise<string> {
239
+ if (!this.config.openai.apiKey) {
240
+ throw new Error(
241
+ 'Chave da OpenAI não encontrada. Configure OPENAI_API_KEY nas variáveis de ambiente.'
242
+ );
243
+ }
244
+
245
+ const messages: Array<{ role: string; content: string }> = [];
246
+ if (systemMessage) {
247
+ messages.push({ role: 'system', content: systemMessage });
248
+ }
249
+ messages.push({ role: 'user', content: prompt });
250
+
251
+ const controller = new AbortController();
252
+ const timeoutId = setTimeout(
253
+ () => controller.abort(),
254
+ this.config.openai.timeout
255
+ );
256
+
257
+ try {
258
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
259
+ method: 'POST',
260
+ headers: {
261
+ Authorization: `Bearer ${this.config.openai.apiKey}`,
262
+ 'Content-Type': 'application/json',
263
+ },
264
+ body: JSON.stringify({
265
+ model: this.config.openai.model,
266
+ messages,
267
+ max_tokens: Math.min(this.config.openai.maxTokens, 150),
268
+ temperature: this.config.openai.temperature,
269
+ }),
270
+ signal: controller.signal,
271
+ });
272
+
273
+ if (!response.ok) {
274
+ const errorData = (await response.json().catch(() => ({}))) as any;
275
+ throw new Error(
276
+ `Erro da OpenAI (${response.status}): ${errorData.error?.message || 'Erro desconhecido'}`
277
+ );
278
+ }
279
+
280
+ const data = (await response.json()) as any;
281
+ const message = data.choices?.[0]?.message?.content?.trim();
282
+
283
+ if (!message) {
284
+ throw new Error('OpenAI retornou resposta vazia');
285
+ }
286
+
287
+ return message;
288
+ } finally {
289
+ clearTimeout(timeoutId);
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Consome a API da OpenAI para gerar mensagem de commit
296
+ */
297
+ export async function generateCommitMessage(
298
+ diff: string,
299
+ config: Config,
300
+ filenames: string[],
301
+ commitlintRules?: CommitlintRules | null
302
+ ): Promise<OpenAIResponse> {
303
+ try {
304
+ const provider = new OpenAIProvider(config);
305
+
306
+ // Filter diff before building the prompt
307
+ const maxDiffLength = 6000;
308
+ const filteredDiff = smartFilterDiff(diff, maxDiffLength);
309
+
310
+ const prompt = buildPrompt(filteredDiff, config, filenames, commitlintRules);
311
+ const systemMessage = buildSystemMessage(config, commitlintRules);
312
+
313
+ const rawMessage = await provider.generate(prompt, systemMessage);
314
+ const message = processOpenAIMessage(rawMessage);
315
+
316
+ const extractedType = extractCommitTypeFromMessage(message);
317
+ const fallbackType = detectCommitType(diff, filenames);
318
+
319
+ return {
320
+ success: true,
321
+ suggestion: {
322
+ message,
323
+ type: extractedType || fallbackType,
324
+ confidence: 0.8,
325
+ },
326
+ };
327
+ } catch (error) {
328
+ return {
329
+ success: false,
330
+ error: `Erro ao conectar com OpenAI: ${error instanceof Error ? error.message : 'Erro desconhecido'}`,
331
+ };
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Gera mensagem com retry em caso de falha
337
+ */
338
+ export async function generateWithRetry(
339
+ diff: string,
340
+ config: Config,
341
+ filenames: string[],
342
+ maxRetries: number = 3,
343
+ commitlintRules?: CommitlintRules | null
344
+ ): Promise<OpenAIResponse> {
345
+ let lastError = '';
346
+
347
+ for (let i = 0; i < maxRetries; i++) {
348
+ const result = await generateCommitMessage(diff, config, filenames, commitlintRules);
349
+
350
+ if (result.success) {
351
+ return result;
352
+ }
353
+
354
+ lastError = result.error || 'Erro desconhecido';
355
+
356
+ if (i < maxRetries - 1) {
357
+ await new Promise((resolve) =>
358
+ setTimeout(resolve, Math.pow(2, i) * 1000)
359
+ );
360
+ }
361
+ }
362
+
363
+ return {
364
+ success: false,
365
+ error: `Falha após ${maxRetries} tentativas. Último erro: ${lastError}`,
366
+ };
367
+ }
@@ -0,0 +1,11 @@
1
+ export interface AIProvider {
2
+ generate(prompt: string, systemMessage: string | null): Promise<string>;
3
+ }
4
+
5
+ export interface ProviderConfig {
6
+ model: string;
7
+ maxTokens: number;
8
+ temperature: number;
9
+ timeout: number;
10
+ apiKey: string;
11
+ }
@@ -1,9 +1,54 @@
1
1
  declare module '@clack/prompts' {
2
- export const intro: (message: string) => void;
3
- export const outro: (message: string) => void;
2
+ export const intro: (message?: string) => void;
3
+ export const outro: (message?: string) => void;
4
+ export const cancel: (message?: string) => void;
5
+ export const note: (message?: string, title?: string) => void;
6
+ export function isCancel(value: unknown): value is symbol;
7
+
4
8
  export const log: {
5
- error: (message: string) => void;
9
+ message: (message?: string) => void;
6
10
  info: (message: string) => void;
7
11
  success: (message: string) => void;
12
+ step: (message: string) => void;
13
+ warn: (message: string) => void;
14
+ warning: (message: string) => void;
15
+ error: (message: string) => void;
16
+ };
17
+
18
+ export function text(opts: {
19
+ message: string;
20
+ placeholder?: string;
21
+ defaultValue?: string;
22
+ initialValue?: string;
23
+ validate?: (value: string) => string | Error | undefined;
24
+ }): Promise<string | symbol>;
25
+
26
+ export function confirm(opts: {
27
+ message: string;
28
+ active?: string;
29
+ inactive?: string;
30
+ initialValue?: boolean;
31
+ }): Promise<boolean | symbol>;
32
+
33
+ export function select<Value>(opts: {
34
+ message: string;
35
+ options: Array<{ value: Value; label?: string; hint?: string }>;
36
+ initialValue?: Value;
37
+ maxItems?: number;
38
+ }): Promise<Value | symbol>;
39
+
40
+ export function multiselect<Value>(opts: {
41
+ message: string;
42
+ options: Array<{ value: Value; label?: string; hint?: string }>;
43
+ initialValues?: Value[];
44
+ maxItems?: number;
45
+ required?: boolean;
46
+ cursorAt?: Value;
47
+ }): Promise<Value[] | symbol>;
48
+
49
+ export function spinner(opts?: { indicator?: 'dots' | 'timer' }): {
50
+ start: (msg?: string) => void;
51
+ stop: (msg?: string, code?: number) => void;
52
+ message: (msg?: string) => void;
8
53
  };
9
- }
54
+ }
@@ -0,0 +1,33 @@
1
+ import { COLORS } from './theme';
2
+
3
+ const TYPE_COLORS: Record<string, string> = {
4
+ feat: COLORS.success,
5
+ fix: COLORS.error,
6
+ docs: COLORS.info,
7
+ style: COLORS.info,
8
+ refactor: COLORS.warning,
9
+ perf: COLORS.warning,
10
+ test: COLORS.info,
11
+ chore: COLORS.dim,
12
+ ci: COLORS.dim,
13
+ build: COLORS.dim,
14
+ };
15
+
16
+ /**
17
+ * Adiciona cor ANSI ao tipo de commit no header (feat, fix, docs, etc.).
18
+ * Preserva o restante da mensagem sem alteração.
19
+ */
20
+ export function formatCommitPreview(message: string): string {
21
+ const lines = message.split('\n');
22
+ const header = lines[0];
23
+
24
+ const typeMatch = header.match(/^(\w+)(\([^)]+\))?:/);
25
+ if (typeMatch) {
26
+ const type = typeMatch[1];
27
+ const color = TYPE_COLORS[type] ?? COLORS.reset;
28
+ const coloredHeader = header.replace(type, `${color}${type}${COLORS.reset}`);
29
+ return [coloredHeader, ...lines.slice(1)].join('\n');
30
+ }
31
+
32
+ return message;
33
+ }
package/src/ui/index.ts CHANGED
@@ -62,21 +62,25 @@ export async function showCommitPreview(
62
62
  }
63
63
 
64
64
  /**
65
- * Permite edição da mensagem de commit
65
+ * Permite edição da mensagem de commit com contador de caracteres opcional.
66
+ * @param maxLength - limite de caracteres para o header (primeira linha)
66
67
  */
67
68
  export async function editCommitMessage(
68
- originalMessage: string
69
+ originalMessage: string,
70
+ maxLength?: number
69
71
  ): Promise<UIAction> {
72
+ const limit = maxLength ?? 72;
70
73
  const editedMessage = await text({
71
- message: 'Edite a mensagem do commit:',
74
+ message: `Edite a mensagem do commit:`,
72
75
  initialValue: originalMessage,
73
76
  placeholder: 'Digite a mensagem do commit...',
74
77
  validate: (value) => {
75
78
  if (!value || value.trim().length === 0) {
76
79
  return 'A mensagem não pode estar vazia';
77
80
  }
78
- if (value.trim().length > 72) {
79
- return 'A mensagem está muito longa (máximo 72 caracteres recomendado)';
81
+ const headerLength = value.split('\n')[0].length;
82
+ if (headerLength > limit) {
83
+ return `Header muito longo: ${headerLength}/${limit} caracteres`;
80
84
  }
81
85
  },
82
86
  });
@@ -1,5 +1,5 @@
1
1
  import { select, confirm, log, note, isCancel } from '@clack/prompts';
2
- import type { FileGroup } from '../core/smart-split';
2
+ import type { FileGroup } from '../pipeline/split';
3
3
 
4
4
  export interface SmartSplitAction {
5
5
  action: 'proceed' | 'manual' | 'cancel';
@@ -0,0 +1,23 @@
1
+ import { spinner } from '@clack/prompts';
2
+
3
+ /**
4
+ * Envolve uma chamada assíncrona com spinner animado.
5
+ * Exibe tempo estimado opcional e ícone de sucesso/erro ao finalizar.
6
+ */
7
+ export async function withSpinner<T>(
8
+ message: string,
9
+ fn: () => Promise<T>,
10
+ estimatedTime?: number
11
+ ): Promise<T> {
12
+ const s = spinner();
13
+ s.start(estimatedTime ? `${message} (~${estimatedTime}s)` : message);
14
+
15
+ try {
16
+ const result = await fn();
17
+ s.stop(`${message} ✅`);
18
+ return result;
19
+ } catch (error) {
20
+ s.stop(`${message} ❌`);
21
+ throw error;
22
+ }
23
+ }
@@ -0,0 +1,17 @@
1
+ export const COLORS = {
2
+ success: '\x1b[32m',
3
+ warning: '\x1b[33m',
4
+ error: '\x1b[31m',
5
+ info: '\x1b[36m',
6
+ dim: '\x1b[2m',
7
+ reset: '\x1b[0m',
8
+ };
9
+
10
+ export const ICONS = {
11
+ success: '✅',
12
+ warning: '⚠️',
13
+ error: '❌',
14
+ info: 'ℹ️',
15
+ rocket: '🚀',
16
+ wizard: '🧙',
17
+ };
package/src/utils/args.ts CHANGED
@@ -7,6 +7,8 @@ export interface CLIArgs {
7
7
  dryRun: boolean;
8
8
  help: boolean;
9
9
  version: boolean;
10
+ validate: boolean;
11
+ initCommitlint: boolean;
10
12
  }
11
13
 
12
14
  export function parseArgs(args: string[]): CLIArgs {
@@ -19,6 +21,8 @@ export function parseArgs(args: string[]): CLIArgs {
19
21
  dryRun: args.includes('--dry-run') || args.includes('-n'),
20
22
  help: args.includes('--help') || args.includes('-h'),
21
23
  version: args.includes('--version') || args.includes('-v'),
24
+ validate: args.includes('--validate'),
25
+ initCommitlint: args.includes('--init-commitlint'),
22
26
  };
23
27
  }
24
28
 
@@ -30,14 +34,16 @@ USAGE:
30
34
  commit-wizard [OPTIONS]
31
35
 
32
36
  OPTIONS:
33
- -s, --silent Modo silencioso (sem logs detalhados)
34
- -y, --yes Confirmar automaticamente sem prompts
35
- -a, --auto Modo automático (--yes + --silent)
36
- --split Modo split manual (commits separados por arquivo)
37
- --smart-split Modo smart split (IA agrupa por contexto)
38
- -n, --dry-run Visualizar mensagem sem fazer commit
39
- -h, --help Mostrar esta ajuda
40
- -v, --version Mostrar versão
37
+ -s, --silent Modo silencioso (sem logs detalhados)
38
+ -y, --yes Confirmar automaticamente sem prompts
39
+ -a, --auto Modo automático (--yes + --silent)
40
+ --split Modo split manual (commits separados por arquivo)
41
+ --smart-split Modo smart split (IA agrupa por contexto)
42
+ -n, --dry-run Visualizar mensagem sem fazer commit
43
+ --validate Habilitar validação com commitlint
44
+ --init-commitlint Criar configuração padrão do commitlint
45
+ -h, --help Mostrar esta ajuda
46
+ -v, --version Mostrar versão
41
47
 
42
48
  EXAMPLES:
43
49
  commit-wizard # Modo interativo padrão
@@ -46,6 +52,8 @@ EXAMPLES:
46
52
  commit-wizard --smart-split # Smart split com IA
47
53
  commit-wizard --dry-run # Apenas visualizar mensagem
48
54
  commit-wizard --auto # Modo totalmente automático
55
+ commit-wizard --validate # Habilitar validação com commitlint
56
+ commit-wizard --init-commitlint # Criar configuração padrão do commitlint
49
57
 
50
58
  Para mais informações, visite: https://github.com/user/commit-wizard
51
59
  `);
@@ -0,0 +1,9 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ /**
4
+ * Gera um hash SHA-256 hexadecimal de uma string.
5
+ * Usado principalmente para gerar chaves de cache a partir de diffs do git.
6
+ */
7
+ export function hashString(content: string): string {
8
+ return createHash('sha256').update(content).digest('hex');
9
+ }
@@ -1,19 +1,24 @@
1
1
  import { readFileSync } from 'fs';
2
- import { join } from 'path';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
3
4
 
4
5
  /**
5
6
  * Lê a versão do package.json dinamicamente
6
7
  */
7
8
  export function getVersion(): string {
8
9
  try {
10
+ // Resolver __dirname de forma compatível com ESM e CJS
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
9
14
  // Tentar diferentes caminhos para encontrar o package.json
10
15
  const possiblePaths = [
11
- // Caminho relativo ao arquivo atual (para desenvolvimento)
12
- join(process.cwd(), 'package.json'),
13
- // Caminho relativo ao binário compilado (para produção)
14
- join(process.cwd(), '..', 'package.json'),
15
- // Caminho absoluto baseado no __dirname
16
+ // Caminho absoluto baseado no __dirname (prioridade máxima - para instalação global)
17
+ join(__dirname, '..', 'package.json'),
18
+ // Fallback para estrutura de desenvolvimento/build alternativa
16
19
  join(__dirname, '..', '..', 'package.json'),
20
+ // Último recurso: diretório atual
21
+ join(process.cwd(), 'package.json'),
17
22
  ];
18
23
 
19
24
  for (const packagePath of possiblePaths) {