@vibedx/vibekit 0.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.
- package/LICENSE +21 -0
- package/README.md +368 -0
- package/assets/config.yml +35 -0
- package/assets/default.md +47 -0
- package/assets/instructions/README.md +46 -0
- package/assets/instructions/claude.md +83 -0
- package/assets/instructions/codex.md +19 -0
- package/index.js +106 -0
- package/package.json +90 -0
- package/src/commands/close/index.js +66 -0
- package/src/commands/close/index.test.js +235 -0
- package/src/commands/get-started/index.js +138 -0
- package/src/commands/get-started/index.test.js +246 -0
- package/src/commands/init/index.js +51 -0
- package/src/commands/init/index.test.js +159 -0
- package/src/commands/link/index.js +395 -0
- package/src/commands/link/index.test.js +28 -0
- package/src/commands/lint/index.js +657 -0
- package/src/commands/lint/index.test.js +569 -0
- package/src/commands/list/index.js +131 -0
- package/src/commands/list/index.test.js +153 -0
- package/src/commands/new/index.js +305 -0
- package/src/commands/new/index.test.js +256 -0
- package/src/commands/refine/index.js +741 -0
- package/src/commands/refine/index.test.js +28 -0
- package/src/commands/review/index.js +957 -0
- package/src/commands/review/index.test.js +193 -0
- package/src/commands/start/index.js +180 -0
- package/src/commands/start/index.test.js +88 -0
- package/src/commands/unlink/index.js +123 -0
- package/src/commands/unlink/index.test.js +22 -0
- package/src/utils/arrow-select.js +233 -0
- package/src/utils/cli.js +489 -0
- package/src/utils/cli.test.js +9 -0
- package/src/utils/git.js +146 -0
- package/src/utils/git.test.js +330 -0
- package/src/utils/index.js +193 -0
- package/src/utils/index.test.js +375 -0
- package/src/utils/prompts.js +47 -0
- package/src/utils/prompts.test.js +165 -0
- package/src/utils/test-helpers.js +492 -0
- package/src/utils/ticket.js +423 -0
- package/src/utils/ticket.test.js +190 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { getTicketsDir, getConfigPath } from '../../utils/index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load configuration from config.yml
|
|
8
|
+
* @returns {Object} Configuration object
|
|
9
|
+
*/
|
|
10
|
+
function loadConfig() {
|
|
11
|
+
const configPath = getConfigPath();
|
|
12
|
+
|
|
13
|
+
if (!fs.existsSync(configPath)) {
|
|
14
|
+
console.error(`❌ Configuration file not found: ${configPath}`);
|
|
15
|
+
console.error('Run "vibe init" to initialize a VibeKit project.');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
21
|
+
return yaml.load(configContent);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error(`❌ Failed to parse configuration: ${error.message}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract required sections from template
|
|
30
|
+
* @param {Object} config - Configuration object
|
|
31
|
+
* @returns {string[]} Array of required section headers
|
|
32
|
+
*/
|
|
33
|
+
function getRequiredSections(config) {
|
|
34
|
+
const templatePath = config.tickets?.default_template || '.vibe/.templates/default.md';
|
|
35
|
+
const fullTemplatePath = path.resolve(templatePath);
|
|
36
|
+
|
|
37
|
+
if (!fs.existsSync(fullTemplatePath)) {
|
|
38
|
+
console.error(`❌ Template file not found: ${templatePath}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const templateContent = fs.readFileSync(fullTemplatePath, 'utf-8');
|
|
44
|
+
|
|
45
|
+
// Extract section headers from template (lines starting with ##)
|
|
46
|
+
const sections = [];
|
|
47
|
+
const lines = templateContent.split('\n');
|
|
48
|
+
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
const match = line.match(/^##\s+(.+)/);
|
|
51
|
+
if (match) {
|
|
52
|
+
sections.push(match[1].trim());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return sections;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`❌ Failed to read template: ${error.message}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get required frontmatter fields from template
|
|
65
|
+
* @param {Object} config - Configuration object
|
|
66
|
+
* @returns {string[]} Array of required frontmatter fields
|
|
67
|
+
*/
|
|
68
|
+
function getRequiredFrontmatter(config) {
|
|
69
|
+
const templatePath = config.tickets?.default_template || '.vibe/.templates/default.md';
|
|
70
|
+
const fullTemplatePath = path.resolve(templatePath);
|
|
71
|
+
|
|
72
|
+
if (!fs.existsSync(fullTemplatePath)) {
|
|
73
|
+
return ['id', 'title', 'slug', 'status', 'priority', 'created_at', 'updated_at'];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const templateContent = fs.readFileSync(fullTemplatePath, 'utf-8');
|
|
78
|
+
|
|
79
|
+
// Extract frontmatter from template
|
|
80
|
+
if (!templateContent.startsWith('---')) {
|
|
81
|
+
return ['id', 'title', 'slug', 'status', 'priority', 'created_at', 'updated_at'];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const parts = templateContent.split('---');
|
|
85
|
+
if (parts.length < 3) {
|
|
86
|
+
return ['id', 'title', 'slug', 'status', 'priority', 'created_at', 'updated_at'];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const frontmatter = yaml.load(parts[1]);
|
|
90
|
+
return Object.keys(frontmatter || {});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Fallback to default required fields
|
|
93
|
+
return ['id', 'title', 'slug', 'status', 'priority', 'created_at', 'updated_at'];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate ticket frontmatter
|
|
99
|
+
* @param {Object} frontmatter - Parsed frontmatter object
|
|
100
|
+
* @param {string} filename - Filename for error reporting
|
|
101
|
+
* @param {Object} config - Configuration object
|
|
102
|
+
* @param {string[]} requiredFields - Required frontmatter fields
|
|
103
|
+
* @returns {string[]} Array of validation errors
|
|
104
|
+
*/
|
|
105
|
+
function validateFrontmatter(frontmatter, filename, config, requiredFields) {
|
|
106
|
+
const errors = [];
|
|
107
|
+
|
|
108
|
+
// Check required fields
|
|
109
|
+
for (const field of requiredFields) {
|
|
110
|
+
if (!frontmatter[field]) {
|
|
111
|
+
errors.push(`Missing required frontmatter field: ${field}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Get valid options from config
|
|
116
|
+
const validStatuses = config.tickets?.status_options || ['open', 'in_progress', 'review', 'done'];
|
|
117
|
+
const validPriorities = config.tickets?.priority_options || ['low', 'medium', 'high', 'urgent'];
|
|
118
|
+
|
|
119
|
+
// Validate status
|
|
120
|
+
if (frontmatter.status && !validStatuses.includes(frontmatter.status)) {
|
|
121
|
+
errors.push(`Invalid status "${frontmatter.status}". Must be one of: ${validStatuses.join(', ')}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Validate priority
|
|
125
|
+
if (frontmatter.priority && !validPriorities.includes(frontmatter.priority)) {
|
|
126
|
+
errors.push(`Invalid priority "${frontmatter.priority}". Must be one of: ${validPriorities.join(', ')}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Validate ID format
|
|
130
|
+
if (frontmatter.id && !/^TKT-\d{3}$/.test(frontmatter.id)) {
|
|
131
|
+
errors.push(`Invalid ID format "${frontmatter.id}". Must follow pattern: TKT-XXX (e.g., TKT-001)`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Validate filename matches ID
|
|
135
|
+
if (frontmatter.id && !filename.startsWith(frontmatter.id)) {
|
|
136
|
+
errors.push(`Filename should start with ticket ID "${frontmatter.id}"`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Validate dates
|
|
140
|
+
if (frontmatter.created_at && isNaN(Date.parse(frontmatter.created_at))) {
|
|
141
|
+
errors.push(`Invalid created_at date format: ${frontmatter.created_at}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (frontmatter.updated_at && isNaN(Date.parse(frontmatter.updated_at))) {
|
|
145
|
+
errors.push(`Invalid updated_at date format: ${frontmatter.updated_at}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return errors;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validate ticket content sections
|
|
153
|
+
* @param {string} content - Ticket content (without frontmatter)
|
|
154
|
+
* @param {string[]} requiredSections - Required section headers
|
|
155
|
+
* @returns {Object} Object with errors and missing sections
|
|
156
|
+
*/
|
|
157
|
+
function validateSections(content, requiredSections) {
|
|
158
|
+
const errors = [];
|
|
159
|
+
const missingSections = [];
|
|
160
|
+
|
|
161
|
+
for (const section of requiredSections) {
|
|
162
|
+
const sectionRegex = new RegExp(`^##\\s+${section}`, 'm');
|
|
163
|
+
if (!sectionRegex.test(content)) {
|
|
164
|
+
errors.push(`Missing required section: ## ${section}`);
|
|
165
|
+
missingSections.push(section);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check for empty sections
|
|
170
|
+
const sections = content.split(/^##\s+/m).filter(s => s.trim());
|
|
171
|
+
for (let i = 1; i < sections.length; i++) {
|
|
172
|
+
const sectionContent = sections[i].split(/^##\s+/m)[0].trim();
|
|
173
|
+
const sectionTitle = sectionContent.split('\n')[0];
|
|
174
|
+
const sectionBody = sectionContent.substring(sectionTitle.length).trim();
|
|
175
|
+
|
|
176
|
+
if (!sectionBody || sectionBody.length < 10) {
|
|
177
|
+
errors.push(`Section "## ${sectionTitle}" appears to be empty or too short`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { errors, missingSections };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Extract section content from template
|
|
186
|
+
* @param {Object} config - Configuration object
|
|
187
|
+
* @returns {Object} Object mapping section names to their default content
|
|
188
|
+
*/
|
|
189
|
+
function getSectionDefaults(config) {
|
|
190
|
+
const templatePath = config.tickets?.default_template || '.vibe/.templates/default.md';
|
|
191
|
+
const fullTemplatePath = path.resolve(templatePath);
|
|
192
|
+
|
|
193
|
+
const sectionDefaults = {};
|
|
194
|
+
|
|
195
|
+
if (!fs.existsSync(fullTemplatePath)) {
|
|
196
|
+
return sectionDefaults;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const templateContent = fs.readFileSync(fullTemplatePath, 'utf-8');
|
|
201
|
+
|
|
202
|
+
// Split by frontmatter to get only content part
|
|
203
|
+
const parts = templateContent.split('---');
|
|
204
|
+
if (parts.length < 3) {
|
|
205
|
+
return sectionDefaults;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const contentPart = parts.slice(2).join('---');
|
|
209
|
+
|
|
210
|
+
// Split content by section headers
|
|
211
|
+
const sections = contentPart.split(/^##\s+/m);
|
|
212
|
+
|
|
213
|
+
// First element is content before any section headers (ignore)
|
|
214
|
+
for (let i = 1; i < sections.length; i++) {
|
|
215
|
+
const section = sections[i];
|
|
216
|
+
const lines = section.split('\n');
|
|
217
|
+
const sectionName = lines[0].trim();
|
|
218
|
+
const sectionContent = lines.slice(1).join('\n');
|
|
219
|
+
|
|
220
|
+
sectionDefaults[sectionName] = sectionContent;
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error(`❌ Failed to read template for section defaults: ${error.message}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return sectionDefaults;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get frontmatter defaults from template
|
|
231
|
+
* @param {Object} config - Configuration object
|
|
232
|
+
* @returns {Object} Object with default frontmatter values
|
|
233
|
+
*/
|
|
234
|
+
function getFrontmatterDefaults(config) {
|
|
235
|
+
const templatePath = config.tickets?.default_template || '.vibe/.templates/default.md';
|
|
236
|
+
const fullTemplatePath = path.resolve(templatePath);
|
|
237
|
+
|
|
238
|
+
const defaults = {};
|
|
239
|
+
|
|
240
|
+
if (!fs.existsSync(fullTemplatePath)) {
|
|
241
|
+
return defaults;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const templateContent = fs.readFileSync(fullTemplatePath, 'utf-8');
|
|
246
|
+
|
|
247
|
+
if (!templateContent.startsWith('---')) {
|
|
248
|
+
return defaults;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const parts = templateContent.split('---');
|
|
252
|
+
if (parts.length < 3) {
|
|
253
|
+
return defaults;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const frontmatter = yaml.load(parts[1]);
|
|
257
|
+
return frontmatter || {};
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error(`❌ Failed to read template for frontmatter defaults: ${error.message}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return defaults;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Fix missing frontmatter fields
|
|
267
|
+
* @param {Object} frontmatter - Current frontmatter object
|
|
268
|
+
* @param {string[]} missingFields - Array of missing field names
|
|
269
|
+
* @param {Object} config - Configuration object
|
|
270
|
+
* @param {string} filename - Filename for generating values
|
|
271
|
+
* @returns {Object} Fixed frontmatter object
|
|
272
|
+
*/
|
|
273
|
+
function fixMissingFrontmatter(frontmatter, missingFields, config, filename) {
|
|
274
|
+
const fixedFrontmatter = { ...frontmatter };
|
|
275
|
+
const defaults = getFrontmatterDefaults(config);
|
|
276
|
+
const currentDate = new Date().toISOString();
|
|
277
|
+
|
|
278
|
+
for (const field of missingFields) {
|
|
279
|
+
if (field === 'slug') {
|
|
280
|
+
// Generate slug from title or filename
|
|
281
|
+
const title = fixedFrontmatter.title || filename.replace('.md', '').replace(/^TKT-\d+-/, '');
|
|
282
|
+
fixedFrontmatter.slug = fixedFrontmatter.id ?
|
|
283
|
+
`${fixedFrontmatter.id}-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}` :
|
|
284
|
+
title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
285
|
+
} else if (field === 'created_at' || field === 'updated_at') {
|
|
286
|
+
fixedFrontmatter[field] = currentDate;
|
|
287
|
+
} else if (defaults[field] !== undefined) {
|
|
288
|
+
// Use template default if available
|
|
289
|
+
fixedFrontmatter[field] = defaults[field];
|
|
290
|
+
} else {
|
|
291
|
+
// Provide sensible defaults for common fields
|
|
292
|
+
switch (field) {
|
|
293
|
+
case 'status':
|
|
294
|
+
fixedFrontmatter[field] = config.tickets?.status_options?.[0] || 'open';
|
|
295
|
+
break;
|
|
296
|
+
case 'priority':
|
|
297
|
+
fixedFrontmatter[field] = 'medium';
|
|
298
|
+
break;
|
|
299
|
+
case 'title':
|
|
300
|
+
fixedFrontmatter[field] = filename.replace('.md', '').replace(/^TKT-\d+-/, '').replace(/-/g, ' ');
|
|
301
|
+
break;
|
|
302
|
+
case 'id':
|
|
303
|
+
const match = filename.match(/^(TKT-\d+)/);
|
|
304
|
+
if (match) {
|
|
305
|
+
fixedFrontmatter[field] = match[1];
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
default:
|
|
309
|
+
fixedFrontmatter[field] = '';
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return fixedFrontmatter;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Fix missing sections in ticket content
|
|
319
|
+
* @param {string} content - Original ticket content
|
|
320
|
+
* @param {string[]} missingSections - Array of missing section names
|
|
321
|
+
* @param {Object} config - Configuration object
|
|
322
|
+
* @returns {string} Fixed content with added sections
|
|
323
|
+
*/
|
|
324
|
+
function fixMissingSections(content, missingSections, config) {
|
|
325
|
+
let fixedContent = content;
|
|
326
|
+
|
|
327
|
+
// Ensure content ends with newline for proper section spacing
|
|
328
|
+
if (fixedContent && !fixedContent.endsWith('\n')) {
|
|
329
|
+
fixedContent += '\n';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Get section defaults from template
|
|
333
|
+
const sectionDefaults = getSectionDefaults(config);
|
|
334
|
+
|
|
335
|
+
// Add missing sections at the end
|
|
336
|
+
for (const section of missingSections) {
|
|
337
|
+
const defaultContent = sectionDefaults[section] || '\nTODO: Add content for this section\n';
|
|
338
|
+
fixedContent += `\n## ${section}${defaultContent}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return fixedContent;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Parse and validate a single ticket file
|
|
346
|
+
* @param {string} filePath - Path to the ticket file
|
|
347
|
+
* @param {Object} config - Configuration object
|
|
348
|
+
* @param {string[]} requiredFields - Required frontmatter fields
|
|
349
|
+
* @param {string[]} requiredSections - Required section headers
|
|
350
|
+
* @param {boolean} fixMode - Whether to fix issues automatically
|
|
351
|
+
* @returns {Object} Validation result with errors, warnings, and fixes
|
|
352
|
+
*/
|
|
353
|
+
function validateTicketFile(filePath, config, requiredFields, requiredSections, fixMode = false) {
|
|
354
|
+
const filename = path.basename(filePath);
|
|
355
|
+
const result = {
|
|
356
|
+
filename,
|
|
357
|
+
errors: [],
|
|
358
|
+
warnings: [],
|
|
359
|
+
fixed: false,
|
|
360
|
+
missingSections: []
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
365
|
+
|
|
366
|
+
// Check for frontmatter
|
|
367
|
+
if (!content.startsWith('---')) {
|
|
368
|
+
result.errors.push('File must start with YAML frontmatter (---)');
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Split frontmatter and content
|
|
373
|
+
const parts = content.split('---');
|
|
374
|
+
if (parts.length < 3) {
|
|
375
|
+
result.errors.push('Invalid frontmatter format. Must be enclosed in --- delimiters');
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Parse frontmatter
|
|
380
|
+
let frontmatter;
|
|
381
|
+
try {
|
|
382
|
+
frontmatter = yaml.load(parts[1]);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
result.errors.push(`Invalid YAML frontmatter: ${error.message}`);
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Validate frontmatter
|
|
389
|
+
const frontmatterErrors = validateFrontmatter(frontmatter, filename, config, requiredFields);
|
|
390
|
+
result.errors.push(...frontmatterErrors);
|
|
391
|
+
|
|
392
|
+
// Validate content sections
|
|
393
|
+
const ticketContent = parts.slice(2).join('---');
|
|
394
|
+
const sectionValidation = validateSections(ticketContent, requiredSections);
|
|
395
|
+
result.errors.push(...sectionValidation.errors);
|
|
396
|
+
result.missingSections = sectionValidation.missingSections;
|
|
397
|
+
|
|
398
|
+
// Identify missing frontmatter fields for fixing
|
|
399
|
+
const missingFrontmatterFields = [];
|
|
400
|
+
for (const field of requiredFields) {
|
|
401
|
+
if (!frontmatter[field]) {
|
|
402
|
+
missingFrontmatterFields.push(field);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Apply fixes if in fix mode
|
|
407
|
+
if (fixMode && (result.missingSections.length > 0 || missingFrontmatterFields.length > 0)) {
|
|
408
|
+
let fixedFrontmatter = frontmatter;
|
|
409
|
+
let fixedContent = ticketContent;
|
|
410
|
+
let fixedCount = 0;
|
|
411
|
+
|
|
412
|
+
// Fix missing frontmatter fields
|
|
413
|
+
if (missingFrontmatterFields.length > 0) {
|
|
414
|
+
fixedFrontmatter = fixMissingFrontmatter(frontmatter, missingFrontmatterFields, config, filename);
|
|
415
|
+
fixedCount += missingFrontmatterFields.length;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Fix missing sections
|
|
419
|
+
if (result.missingSections.length > 0) {
|
|
420
|
+
fixedContent = fixMissingSections(ticketContent, result.missingSections, config);
|
|
421
|
+
fixedCount += result.missingSections.length;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Write the fixed content
|
|
425
|
+
if (fixedCount > 0) {
|
|
426
|
+
const fixedFrontmatterYaml = yaml.dump(fixedFrontmatter, {
|
|
427
|
+
defaultStyle: null,
|
|
428
|
+
quotingType: '"',
|
|
429
|
+
forceQuotes: false
|
|
430
|
+
});
|
|
431
|
+
const newFileContent = '---\n' + fixedFrontmatterYaml + '---' + fixedContent;
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
fs.writeFileSync(filePath, newFileContent, 'utf-8');
|
|
435
|
+
result.fixed = true;
|
|
436
|
+
|
|
437
|
+
// Remove the errors that were fixed
|
|
438
|
+
result.errors = result.errors.filter(error =>
|
|
439
|
+
!error.startsWith('Missing required section:') &&
|
|
440
|
+
!error.startsWith('Missing required frontmatter field:')
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
// Add success message for fixes
|
|
444
|
+
const messages = [];
|
|
445
|
+
if (missingFrontmatterFields.length > 0) {
|
|
446
|
+
messages.push(`${missingFrontmatterFields.length} missing frontmatter fields`);
|
|
447
|
+
}
|
|
448
|
+
if (result.missingSections.length > 0) {
|
|
449
|
+
messages.push(`${result.missingSections.length} missing sections`);
|
|
450
|
+
}
|
|
451
|
+
result.warnings.push(`Fixed ${messages.join(' and ')}`);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
result.errors.push(`Failed to write fixes: ${error.message}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Add warnings for common issues
|
|
459
|
+
if (ticketContent.includes('TODO') || ticketContent.includes('FIXME')) {
|
|
460
|
+
result.warnings.push('Contains TODO or FIXME comments');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (frontmatter.title && frontmatter.title.length > 80) {
|
|
464
|
+
result.warnings.push('Title is longer than 80 characters');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
} catch (error) {
|
|
468
|
+
result.errors.push(`Failed to read file: ${error.message}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return result;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Format validation results for display
|
|
476
|
+
* @param {Object[]} results - Array of validation results
|
|
477
|
+
* @param {boolean} verbose - Show warnings and details
|
|
478
|
+
* @param {boolean} fixMode - Whether fixes were applied
|
|
479
|
+
*/
|
|
480
|
+
function displayResults(results, verbose = false, fixMode = false) {
|
|
481
|
+
let totalErrors = 0;
|
|
482
|
+
let totalWarnings = 0;
|
|
483
|
+
let filesWithIssues = 0;
|
|
484
|
+
let filesFixed = 0;
|
|
485
|
+
|
|
486
|
+
console.log('🔍 VibeKit Ticket Linter Results\n');
|
|
487
|
+
|
|
488
|
+
for (const result of results) {
|
|
489
|
+
const hasErrors = result.errors.length > 0;
|
|
490
|
+
const hasWarnings = result.warnings.length > 0;
|
|
491
|
+
const wasFixed = result.fixed;
|
|
492
|
+
|
|
493
|
+
if (wasFixed) {
|
|
494
|
+
filesFixed++;
|
|
495
|
+
console.log(`🔧 ${result.filename} (FIXED)`);
|
|
496
|
+
if (verbose || fixMode) {
|
|
497
|
+
result.warnings.forEach(warning => {
|
|
498
|
+
console.log(` Fixed: ${warning}`);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (hasErrors || hasWarnings) {
|
|
504
|
+
if (!wasFixed) filesWithIssues++;
|
|
505
|
+
|
|
506
|
+
if (hasErrors) {
|
|
507
|
+
if (!wasFixed) console.log(`❌ ${result.filename}`);
|
|
508
|
+
result.errors.forEach(error => {
|
|
509
|
+
console.log(` Error: ${error}`);
|
|
510
|
+
});
|
|
511
|
+
totalErrors += result.errors.length;
|
|
512
|
+
} else if (!wasFixed) {
|
|
513
|
+
console.log(`⚠️ ${result.filename}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if ((verbose || fixMode) && hasWarnings && !wasFixed) {
|
|
517
|
+
result.warnings.forEach(warning => {
|
|
518
|
+
console.log(` Warning: ${warning}`);
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
totalWarnings += result.warnings.length;
|
|
522
|
+
console.log('');
|
|
523
|
+
} else if (verbose && !wasFixed) {
|
|
524
|
+
console.log(`✅ ${result.filename}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Summary
|
|
529
|
+
console.log(`\n📊 Summary:`);
|
|
530
|
+
console.log(` Files checked: ${results.length}`);
|
|
531
|
+
console.log(` Files with issues: ${filesWithIssues}`);
|
|
532
|
+
if (filesFixed > 0) {
|
|
533
|
+
console.log(` Files fixed: ${filesFixed}`);
|
|
534
|
+
}
|
|
535
|
+
console.log(` Total errors: ${totalErrors}`);
|
|
536
|
+
console.log(` Total warnings: ${totalWarnings}`);
|
|
537
|
+
|
|
538
|
+
if (totalErrors === 0) {
|
|
539
|
+
if (filesFixed > 0) {
|
|
540
|
+
console.log('\n🎉 All issues have been fixed! Tickets are now properly formatted.');
|
|
541
|
+
} else {
|
|
542
|
+
console.log('\n🎉 All tickets are properly formatted!');
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
if (fixMode) {
|
|
546
|
+
console.log('\n💡 Some errors could not be automatically fixed. Please review and fix manually.');
|
|
547
|
+
} else {
|
|
548
|
+
console.log('\n💡 Fix the errors above to ensure consistent ticket formatting.');
|
|
549
|
+
console.log('💡 Use --fix flag to automatically fix missing sections.');
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Lint command implementation
|
|
556
|
+
* @param {string[]} args Command arguments
|
|
557
|
+
*/
|
|
558
|
+
function lintCommand(args) {
|
|
559
|
+
let verbose = false;
|
|
560
|
+
let fixMode = false;
|
|
561
|
+
let specificFile = null;
|
|
562
|
+
|
|
563
|
+
// Parse arguments first to check for help
|
|
564
|
+
for (let i = 0; i < args.length; i++) {
|
|
565
|
+
if (args[i] === '--verbose' || args[i] === '-v') {
|
|
566
|
+
verbose = true;
|
|
567
|
+
} else if (args[i] === '--fix') {
|
|
568
|
+
fixMode = true;
|
|
569
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
570
|
+
console.log(`
|
|
571
|
+
🔍 vibe lint - Validate ticket documentation formatting
|
|
572
|
+
|
|
573
|
+
Usage:
|
|
574
|
+
vibe lint [options] [file]
|
|
575
|
+
|
|
576
|
+
Options:
|
|
577
|
+
--verbose, -v Show detailed output including warnings
|
|
578
|
+
--fix Automatically fix missing sections using template defaults
|
|
579
|
+
--help, -h Show this help message
|
|
580
|
+
|
|
581
|
+
Examples:
|
|
582
|
+
vibe lint # Lint all tickets
|
|
583
|
+
vibe lint --verbose # Show detailed output
|
|
584
|
+
vibe lint --fix # Lint and auto-fix missing sections
|
|
585
|
+
vibe lint TKT-001-example.md # Lint specific file
|
|
586
|
+
|
|
587
|
+
Validation Rules:
|
|
588
|
+
- Required frontmatter fields: id, title, slug, status, priority, created_at, updated_at
|
|
589
|
+
- Required sections: Description, Acceptance Criteria, Code Quality, etc.
|
|
590
|
+
- Valid statuses: defined in config.yml
|
|
591
|
+
- Valid priorities: defined in config.yml
|
|
592
|
+
- ID format: TKT-XXX (e.g., TKT-001)
|
|
593
|
+
`);
|
|
594
|
+
process.exit(0);
|
|
595
|
+
} else if (!args[i].startsWith('--')) {
|
|
596
|
+
specificFile = args[i];
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Load configuration after help check
|
|
601
|
+
const config = loadConfig();
|
|
602
|
+
const requiredFields = getRequiredFrontmatter(config);
|
|
603
|
+
const requiredSections = getRequiredSections(config);
|
|
604
|
+
const validStatuses = config.tickets?.status_options || ['open', 'in_progress', 'review', 'done'];
|
|
605
|
+
const validPriorities = config.tickets?.priority_options || ['low', 'medium', 'high', 'urgent'];
|
|
606
|
+
|
|
607
|
+
// Get tickets directory
|
|
608
|
+
const ticketDir = getTicketsDir();
|
|
609
|
+
|
|
610
|
+
if (!fs.existsSync(ticketDir)) {
|
|
611
|
+
console.error(`❌ Tickets directory not found: ${ticketDir}`);
|
|
612
|
+
console.error('Run "vibe init" to initialize a VibeKit project.');
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
let filesToCheck;
|
|
617
|
+
|
|
618
|
+
if (specificFile) {
|
|
619
|
+
// Check specific file
|
|
620
|
+
const filePath = path.isAbsolute(specificFile)
|
|
621
|
+
? specificFile
|
|
622
|
+
: path.join(ticketDir, specificFile);
|
|
623
|
+
|
|
624
|
+
if (!fs.existsSync(filePath)) {
|
|
625
|
+
console.error(`❌ File not found: ${specificFile}`);
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
filesToCheck = [filePath];
|
|
630
|
+
} else {
|
|
631
|
+
// Check all markdown files
|
|
632
|
+
const files = fs.readdirSync(ticketDir)
|
|
633
|
+
.filter(file => file.endsWith('.md'))
|
|
634
|
+
.map(file => path.join(ticketDir, file));
|
|
635
|
+
|
|
636
|
+
if (files.length === 0) {
|
|
637
|
+
console.log('📝 No ticket files found to lint.');
|
|
638
|
+
process.exit(0);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
filesToCheck = files;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Validate all files
|
|
645
|
+
const results = filesToCheck.map(filePath =>
|
|
646
|
+
validateTicketFile(filePath, config, requiredFields, requiredSections, fixMode)
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
// Display results
|
|
650
|
+
displayResults(results, verbose, fixMode);
|
|
651
|
+
|
|
652
|
+
// Exit with error code if there are errors
|
|
653
|
+
const hasErrors = results.some(result => result.errors.length > 0);
|
|
654
|
+
process.exit(hasErrors ? 1 : 0);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export default lintCommand;
|