@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +368 -0
  3. package/assets/config.yml +35 -0
  4. package/assets/default.md +47 -0
  5. package/assets/instructions/README.md +46 -0
  6. package/assets/instructions/claude.md +83 -0
  7. package/assets/instructions/codex.md +19 -0
  8. package/index.js +106 -0
  9. package/package.json +90 -0
  10. package/src/commands/close/index.js +66 -0
  11. package/src/commands/close/index.test.js +235 -0
  12. package/src/commands/get-started/index.js +138 -0
  13. package/src/commands/get-started/index.test.js +246 -0
  14. package/src/commands/init/index.js +51 -0
  15. package/src/commands/init/index.test.js +159 -0
  16. package/src/commands/link/index.js +395 -0
  17. package/src/commands/link/index.test.js +28 -0
  18. package/src/commands/lint/index.js +657 -0
  19. package/src/commands/lint/index.test.js +569 -0
  20. package/src/commands/list/index.js +131 -0
  21. package/src/commands/list/index.test.js +153 -0
  22. package/src/commands/new/index.js +305 -0
  23. package/src/commands/new/index.test.js +256 -0
  24. package/src/commands/refine/index.js +741 -0
  25. package/src/commands/refine/index.test.js +28 -0
  26. package/src/commands/review/index.js +957 -0
  27. package/src/commands/review/index.test.js +193 -0
  28. package/src/commands/start/index.js +180 -0
  29. package/src/commands/start/index.test.js +88 -0
  30. package/src/commands/unlink/index.js +123 -0
  31. package/src/commands/unlink/index.test.js +22 -0
  32. package/src/utils/arrow-select.js +233 -0
  33. package/src/utils/cli.js +489 -0
  34. package/src/utils/cli.test.js +9 -0
  35. package/src/utils/git.js +146 -0
  36. package/src/utils/git.test.js +330 -0
  37. package/src/utils/index.js +193 -0
  38. package/src/utils/index.test.js +375 -0
  39. package/src/utils/prompts.js +47 -0
  40. package/src/utils/prompts.test.js +165 -0
  41. package/src/utils/test-helpers.js +492 -0
  42. package/src/utils/ticket.js +423 -0
  43. package/src/utils/ticket.test.js +190 -0
@@ -0,0 +1,423 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Normalize ticket ID to find the actual ticket file
6
+ * Handles formats: 9, 009, TKT-9, TKT-009
7
+ * @param {string|number} input - The ticket identifier
8
+ * @returns {Object|null} Ticket info object or null if not found
9
+ * @throws {Error} If input is invalid or tickets directory is inaccessible
10
+ */
11
+ export function resolveTicketId(input) {
12
+ // Validate input
13
+ if (!input && input !== 0) {
14
+ return null;
15
+ }
16
+
17
+ const ticketsDir = path.join(process.cwd(), '.vibe', 'tickets');
18
+
19
+ try {
20
+ if (!fs.existsSync(ticketsDir)) {
21
+ return null;
22
+ }
23
+
24
+ const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
25
+
26
+ // Clean and validate input
27
+ let cleanInput = input.toString().trim().toUpperCase();
28
+
29
+ // Remove TKT- prefix if present
30
+ if (cleanInput.startsWith('TKT-')) {
31
+ cleanInput = cleanInput.replace('TKT-', '');
32
+ }
33
+
34
+ // Validate numeric part
35
+ if (!/^\d+$/.test(cleanInput)) {
36
+ throw new Error(`Invalid ticket ID format: ${input}. Expected numeric ID or TKT-XXX format.`);
37
+ }
38
+
39
+ // Pad with zeros to make it 3 digits
40
+ const paddedNumber = cleanInput.padStart(3, '0');
41
+ const fullId = `TKT-${paddedNumber}`;
42
+
43
+ // Find file that starts with this ID
44
+ const matchingFile = files.find(file => file.startsWith(fullId));
45
+
46
+ if (matchingFile) {
47
+ return {
48
+ id: fullId,
49
+ file: matchingFile,
50
+ path: path.join(ticketsDir, matchingFile)
51
+ };
52
+ }
53
+
54
+ return null;
55
+
56
+ } catch (error) {
57
+ if (error.code === 'ENOENT') {
58
+ return null;
59
+ }
60
+ if (error.code === 'EACCES') {
61
+ throw new Error(`Permission denied accessing tickets directory: ${ticketsDir}`);
62
+ }
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Parse ticket markdown file while preserving exact format
69
+ * @param {string} filePath - Path to the ticket file
70
+ * @returns {Object} Parsed ticket data with metadata and content
71
+ * @throws {Error} If file cannot be read or parsed
72
+ */
73
+ export function parseTicket(filePath) {
74
+ // Validate input
75
+ if (typeof filePath !== 'string') {
76
+ throw new Error('File path must be a string');
77
+ }
78
+
79
+ try {
80
+ // Check if file exists
81
+ if (!fs.existsSync(filePath)) {
82
+ throw new Error(`Ticket file not found: ${filePath}`);
83
+ }
84
+
85
+ // Check if file is readable
86
+ try {
87
+ fs.accessSync(filePath, fs.constants.R_OK);
88
+ } catch (accessError) {
89
+ throw new Error(`Cannot read ticket file: ${filePath}`);
90
+ }
91
+
92
+ const content = fs.readFileSync(filePath, 'utf8');
93
+
94
+ if (!content.trim()) {
95
+ throw new Error('Ticket file is empty');
96
+ }
97
+
98
+ const lines = content.split('\n');
99
+
100
+ // Validate YAML frontmatter structure
101
+ if (lines.length < 3) {
102
+ throw new Error('Invalid ticket format: file too short to contain valid frontmatter');
103
+ }
104
+
105
+ if (lines[0] !== '---') {
106
+ throw new Error('Invalid ticket format: missing opening YAML frontmatter delimiter (---)');
107
+ }
108
+
109
+ const yamlEndIndex = lines.findIndex((line, index) => index > 0 && line === '---');
110
+ if (yamlEndIndex === -1) {
111
+ throw new Error('Invalid ticket format: missing closing YAML frontmatter delimiter (---)');
112
+ }
113
+
114
+ const yamlLines = lines.slice(1, yamlEndIndex);
115
+ const contentLines = lines.slice(yamlEndIndex + 1);
116
+
117
+ // Parse YAML manually to preserve exact formatting
118
+ const metadata = {};
119
+ const invalidLines = [];
120
+
121
+ for (let i = 0; i < yamlLines.length; i++) {
122
+ const line = yamlLines[i];
123
+ if (line.trim() === '') continue; // Skip empty lines
124
+
125
+ const colonIndex = line.indexOf(':');
126
+ if (colonIndex > -1) {
127
+ const key = line.substring(0, colonIndex).trim();
128
+ const value = line.substring(colonIndex + 1).trim();
129
+
130
+ if (key) {
131
+ metadata[key] = value;
132
+ } else {
133
+ invalidLines.push(i + 2); // +2 for 0-based index and skipping first ---
134
+ }
135
+ } else if (line.trim() !== '') {
136
+ invalidLines.push(i + 2);
137
+ }
138
+ }
139
+
140
+ if (invalidLines.length > 0) {
141
+ console.warn(`⚠️ Warning: Invalid YAML lines found at line(s) ${invalidLines.join(', ')} in ${path.basename(filePath)}`);
142
+ }
143
+
144
+ // Validate required metadata fields
145
+ if (!metadata.id) {
146
+ console.warn(`⚠️ Warning: Missing 'id' field in ticket metadata`);
147
+ }
148
+
149
+ if (!metadata.title) {
150
+ console.warn(`⚠️ Warning: Missing 'title' field in ticket metadata`);
151
+ }
152
+
153
+ return {
154
+ metadata,
155
+ yamlLines,
156
+ contentLines,
157
+ fullContent: content,
158
+ filePath
159
+ };
160
+
161
+ } catch (error) {
162
+ if (error.code === 'ENOENT') {
163
+ throw new Error(`Ticket file not found: ${filePath}`);
164
+ }
165
+ if (error.code === 'EACCES') {
166
+ throw new Error(`Permission denied reading ticket file: ${filePath}`);
167
+ }
168
+ if (error.code === 'EISDIR') {
169
+ throw new Error(`Expected file but found directory: ${filePath}`);
170
+ }
171
+
172
+ throw new Error(`Failed to parse ticket ${path.basename(filePath)}: ${error.message}`);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Update ticket file while preserving exact format
178
+ * @param {string} filePath - Path to the ticket file
179
+ * @param {Object} ticketData - Parsed ticket data
180
+ * @param {Object} updates - Updates to apply to the ticket
181
+ * @returns {Object} Update result with success status and new path
182
+ * @throws {Error} If update operation fails
183
+ */
184
+ export function updateTicket(filePath, ticketData, updates) {
185
+ // Validate inputs
186
+ if (typeof filePath !== 'string') {
187
+ throw new Error('File path must be a string');
188
+ }
189
+
190
+ if (!ticketData || typeof ticketData !== 'object') {
191
+ throw new Error('Ticket data must be a valid object');
192
+ }
193
+
194
+ if (!updates || typeof updates !== 'object') {
195
+ throw new Error('Updates must be a valid object');
196
+ }
197
+
198
+ if (Object.keys(updates).length === 0) {
199
+ return { success: true, newPath: filePath, message: 'No updates to apply' };
200
+ }
201
+
202
+ try {
203
+ // Check if original file exists and is writable
204
+ if (!fs.existsSync(filePath)) {
205
+ throw new Error(`Original ticket file not found: ${filePath}`);
206
+ }
207
+
208
+ try {
209
+ fs.accessSync(filePath, fs.constants.W_OK);
210
+ } catch (accessError) {
211
+ throw new Error(`Cannot write to ticket file: ${filePath}`);
212
+ }
213
+
214
+ let newFilePath = filePath;
215
+
216
+ // Determine new file path if slug is being updated
217
+ if (updates.slug) {
218
+ const ticketId = ticketData.metadata.id || 'TKT-000';
219
+ const cleanSlug = updates.slug.toString().trim();
220
+
221
+ if (!cleanSlug) {
222
+ throw new Error('Slug cannot be empty');
223
+ }
224
+
225
+ const newSlug = `${ticketId}-${cleanSlug}`;
226
+ const newFileName = `${newSlug}.md`;
227
+ const ticketsDir = path.dirname(filePath);
228
+ newFilePath = path.join(ticketsDir, newFileName);
229
+
230
+ // Check if target file already exists (unless it's the same file)
231
+ if (newFilePath !== filePath && fs.existsSync(newFilePath)) {
232
+ throw new Error(`Target file already exists: ${path.basename(newFilePath)}`);
233
+ }
234
+ }
235
+
236
+ // Create updated YAML lines with timestamp
237
+ const timestamp = new Date().toISOString();
238
+ const updatedYamlLines = [...ticketData.yamlLines];
239
+
240
+ // Update existing fields or add new ones
241
+ let updatedTimestamp = false;
242
+ let updatedSlug = false;
243
+ let updatedTitle = false;
244
+
245
+ for (let i = 0; i < updatedYamlLines.length; i++) {
246
+ const line = updatedYamlLines[i];
247
+
248
+ if (line.startsWith('updated_at:')) {
249
+ updatedYamlLines[i] = `updated_at: ${timestamp}`;
250
+ updatedTimestamp = true;
251
+ } else if (line.startsWith('slug:') && updates.slug) {
252
+ const ticketId = ticketData.metadata.id || 'TKT-000';
253
+ const fullSlug = `${ticketId}-${updates.slug}`;
254
+ updatedYamlLines[i] = `slug: ${fullSlug}`;
255
+ updatedSlug = true;
256
+ } else if (line.startsWith('title:') && updates.title) {
257
+ // Ensure title is properly quoted if it contains special characters
258
+ const cleanTitle = updates.title.trim();
259
+ const needsQuotes = /[:\[\]{}|>]/.test(cleanTitle) || cleanTitle.includes('#');
260
+ const formattedTitle = needsQuotes ? `"${cleanTitle.replace(/"/g, '\\"')}"` : cleanTitle;
261
+ updatedYamlLines[i] = `title: ${formattedTitle}`;
262
+ updatedTitle = true;
263
+ }
264
+ }
265
+
266
+ // Add missing fields
267
+ if (!updatedTimestamp) {
268
+ updatedYamlLines.push(`updated_at: ${timestamp}`);
269
+ }
270
+
271
+ if (updates.slug && !updatedSlug) {
272
+ const ticketId = ticketData.metadata.id || 'TKT-000';
273
+ const fullSlug = `${ticketId}-${updates.slug}`;
274
+ updatedYamlLines.push(`slug: ${fullSlug}`);
275
+ }
276
+
277
+ if (updates.title && !updatedTitle) {
278
+ const cleanTitle = updates.title.trim();
279
+ const needsQuotes = /[:\[\]{}|>]/.test(cleanTitle) || cleanTitle.includes('#');
280
+ const formattedTitle = needsQuotes ? `"${cleanTitle.replace(/"/g, '\\"')}"` : cleanTitle;
281
+ updatedYamlLines.push(`title: ${formattedTitle}`);
282
+ }
283
+
284
+ // Update content sections
285
+ let newContentLines = [...ticketData.contentLines];
286
+ const updatedSections = [];
287
+
288
+ Object.keys(updates).forEach(key => {
289
+ if (key !== 'slug' && updates[key]) {
290
+ const sectionHeader = `## ${key}`;
291
+ if (hasSectionContent(newContentLines, sectionHeader)) {
292
+ newContentLines = replaceSectionContent(newContentLines, sectionHeader, updates[key]);
293
+ updatedSections.push(key);
294
+ }
295
+ }
296
+ });
297
+
298
+ // Reconstruct file content
299
+ const reconstructed = [
300
+ '---',
301
+ ...updatedYamlLines,
302
+ '---',
303
+ ...newContentLines
304
+ ].join('\n');
305
+
306
+ // Write to new file path
307
+ try {
308
+ fs.writeFileSync(newFilePath, reconstructed, 'utf8');
309
+ } catch (writeError) {
310
+ if (writeError.code === 'ENOSPC') {
311
+ throw new Error('Not enough disk space to update ticket');
312
+ }
313
+ if (writeError.code === 'EACCES') {
314
+ throw new Error(`Permission denied writing to: ${newFilePath}`);
315
+ }
316
+ throw new Error(`Failed to write updated ticket: ${writeError.message}`);
317
+ }
318
+
319
+ // Handle file rename if necessary
320
+ if (newFilePath !== filePath) {
321
+ try {
322
+ if (fs.existsSync(filePath)) {
323
+ fs.unlinkSync(filePath);
324
+ }
325
+ } catch (deleteError) {
326
+ console.warn(`⚠️ Warning: Could not remove old file ${path.basename(filePath)}: ${deleteError.message}`);
327
+ }
328
+ }
329
+
330
+ const result = {
331
+ success: true,
332
+ newPath: newFilePath,
333
+ updatedSections,
334
+ renamed: newFilePath !== filePath
335
+ };
336
+
337
+ if (result.renamed) {
338
+ result.message = `Renamed ticket file to: ${path.basename(newFilePath)}`;
339
+ }
340
+
341
+ return result;
342
+
343
+ } catch (error) {
344
+ if (error.code === 'ENOENT') {
345
+ throw new Error(`Ticket file not found: ${filePath}`);
346
+ }
347
+ if (error.code === 'EACCES') {
348
+ throw new Error(`Permission denied updating ticket: ${filePath}`);
349
+ }
350
+
351
+ throw new Error(`Failed to update ticket ${path.basename(filePath)}: ${error.message}`);
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Replace content of a specific section
357
+ * @param {string[]} lines - Array of content lines
358
+ * @param {string} sectionHeader - Section header to find (e.g., "## Description")
359
+ * @param {string} newContent - New content for the section
360
+ * @returns {string[]} Updated lines array
361
+ */
362
+ function replaceSectionContent(lines, sectionHeader, newContent) {
363
+ if (!Array.isArray(lines)) {
364
+ throw new Error('Lines must be an array');
365
+ }
366
+
367
+ if (typeof sectionHeader !== 'string' || !sectionHeader.trim()) {
368
+ throw new Error('Section header must be a non-empty string');
369
+ }
370
+
371
+ if (typeof newContent !== 'string') {
372
+ throw new Error('New content must be a string');
373
+ }
374
+
375
+ const sectionIndex = lines.findIndex(line =>
376
+ line && typeof line === 'string' && line.trim() === sectionHeader.trim()
377
+ );
378
+
379
+ if (sectionIndex === -1) {
380
+ return lines; // Section not found, return unchanged
381
+ }
382
+
383
+ // Find next section or end of content
384
+ let nextSectionIndex = lines.length;
385
+ for (let i = sectionIndex + 1; i < lines.length; i++) {
386
+ if (lines[i] && typeof lines[i] === 'string' && lines[i].match(/^##\s+/)) {
387
+ nextSectionIndex = i;
388
+ break;
389
+ }
390
+ }
391
+
392
+ // Replace section content
393
+ const before = lines.slice(0, sectionIndex + 1);
394
+ const after = lines.slice(nextSectionIndex);
395
+
396
+ // Format new content with proper spacing
397
+ const newSectionContent = newContent.trim() ? ['', newContent.trim(), ''] : ['', ''];
398
+
399
+ return [...before, ...newSectionContent, ...after];
400
+ }
401
+
402
+ /**
403
+ * Check if section exists in content
404
+ * @param {string[]} lines - Array of content lines
405
+ * @param {string} sectionHeader - Section header to find
406
+ * @returns {boolean} True if section exists
407
+ */
408
+ function hasSectionContent(lines, sectionHeader) {
409
+ if (!Array.isArray(lines)) {
410
+ return false;
411
+ }
412
+
413
+ if (typeof sectionHeader !== 'string' || !sectionHeader.trim()) {
414
+ return false;
415
+ }
416
+
417
+ const sectionIndex = lines.findIndex(line =>
418
+ line && typeof line === 'string' && line.trim() === sectionHeader.trim()
419
+ );
420
+
421
+ return sectionIndex !== -1;
422
+ }
423
+
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ mockProcessCwd,
8
+ createMockVibeProject
9
+ } from './test-helpers.js';
10
+ import { resolveTicketId } from './ticket.js';
11
+
12
+ describe('ticket utilities', () => {
13
+ let tempDir;
14
+ let restoreCwd;
15
+
16
+ beforeEach(() => {
17
+ tempDir = createTempDir('ticket-utils-test');
18
+ restoreCwd = mockProcessCwd(tempDir);
19
+ });
20
+
21
+ afterEach(() => {
22
+ restoreCwd();
23
+ cleanupTempDir(tempDir);
24
+ });
25
+
26
+ describe('resolveTicketId', () => {
27
+ it('should return null for invalid input', () => {
28
+ expect(resolveTicketId(null)).toBe(null);
29
+ expect(resolveTicketId(undefined)).toBe(null);
30
+ expect(resolveTicketId('')).toBe(null);
31
+ });
32
+
33
+ it('should return null when tickets directory does not exist', () => {
34
+ // No vibe project created
35
+ expect(resolveTicketId('1')).toBe(null);
36
+ });
37
+
38
+ it('should resolve numeric ticket ID', () => {
39
+ // Arrange
40
+ createMockVibeProject(tempDir, {
41
+ withTickets: [
42
+ { id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
43
+ ]
44
+ });
45
+
46
+ // Act
47
+ const result = resolveTicketId('1');
48
+
49
+ // Assert
50
+ expect(result).toBeDefined();
51
+ expect(result.id).toBe('TKT-001');
52
+ expect(result.file).toBe('TKT-001-test-ticket.md');
53
+ expect(result.path).toContain('TKT-001-test-ticket.md');
54
+ });
55
+
56
+ it('should resolve padded numeric ticket ID', () => {
57
+ // Arrange
58
+ createMockVibeProject(tempDir, {
59
+ withTickets: [
60
+ { id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
61
+ ]
62
+ });
63
+
64
+ // Act
65
+ const result = resolveTicketId('001');
66
+
67
+ // Assert
68
+ expect(result).toBeDefined();
69
+ expect(result.id).toBe('TKT-001');
70
+ });
71
+
72
+ it('should resolve full TKT-XXX format', () => {
73
+ // Arrange
74
+ createMockVibeProject(tempDir, {
75
+ withTickets: [
76
+ { id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
77
+ ]
78
+ });
79
+
80
+ // Act
81
+ const result = resolveTicketId('TKT-001');
82
+
83
+ // Assert
84
+ expect(result).toBeDefined();
85
+ expect(result.id).toBe('TKT-001');
86
+ });
87
+
88
+ it('should handle case insensitive input', () => {
89
+ // Arrange
90
+ createMockVibeProject(tempDir, {
91
+ withTickets: [
92
+ { id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
93
+ ]
94
+ });
95
+
96
+ // Act
97
+ const result = resolveTicketId('tkt-001');
98
+
99
+ // Assert
100
+ expect(result).toBeDefined();
101
+ expect(result.id).toBe('TKT-001');
102
+ });
103
+
104
+ it('should return null for non-existent ticket', () => {
105
+ // Arrange
106
+ createMockVibeProject(tempDir, {
107
+ withTickets: [
108
+ { id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
109
+ ]
110
+ });
111
+
112
+ // Act
113
+ const result = resolveTicketId('999');
114
+
115
+ // Assert
116
+ expect(result).toBe(null);
117
+ });
118
+
119
+ it('should throw error for invalid format', () => {
120
+ // Arrange
121
+ createMockVibeProject(tempDir);
122
+
123
+ // Act & Assert
124
+ expect(() => resolveTicketId('invalid-id')).toThrow('Invalid ticket ID format');
125
+ expect(() => resolveTicketId('TKT-abc')).toThrow('Invalid ticket ID format');
126
+ });
127
+
128
+ it('should handle zero as input', () => {
129
+ // Arrange
130
+ createMockVibeProject(tempDir, {
131
+ withTickets: [
132
+ { id: 'TKT-000', title: 'Zero ticket', slug: 'zero-ticket' }
133
+ ]
134
+ });
135
+
136
+ // Act
137
+ const result = resolveTicketId(0);
138
+
139
+ // Assert
140
+ expect(result).toBeDefined();
141
+ expect(result.id).toBe('TKT-000');
142
+ });
143
+
144
+ it('should handle multiple tickets and find correct one', () => {
145
+ // Arrange
146
+ createMockVibeProject(tempDir, {
147
+ withTickets: [
148
+ { id: 'TKT-001', title: 'First ticket', slug: 'first-ticket' },
149
+ { id: 'TKT-002', title: 'Second ticket', slug: 'second-ticket' },
150
+ { id: 'TKT-010', title: 'Tenth ticket', slug: 'tenth-ticket' }
151
+ ]
152
+ });
153
+
154
+ // Act
155
+ const result1 = resolveTicketId('2');
156
+ const result10 = resolveTicketId('10');
157
+
158
+ // Assert
159
+ expect(result1).toBeDefined();
160
+ expect(result1.id).toBe('TKT-002');
161
+ expect(result10).toBeDefined();
162
+ expect(result10.id).toBe('TKT-010');
163
+ });
164
+
165
+ it('should validate return object structure', () => {
166
+ // Arrange
167
+ createMockVibeProject(tempDir, {
168
+ withTickets: [
169
+ { id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
170
+ ]
171
+ });
172
+
173
+ // Act
174
+ const result = resolveTicketId('1');
175
+
176
+ // Assert
177
+ expect(result).toHaveProperty('id');
178
+ expect(result).toHaveProperty('file');
179
+ expect(result).toHaveProperty('path');
180
+ expect(typeof result.id).toBe('string');
181
+ expect(typeof result.file).toBe('string');
182
+ expect(typeof result.path).toBe('string');
183
+ });
184
+ });
185
+
186
+ // Note: parseTicket and updateTicket have complex error handling and validation
187
+ // that would require extensive mocking of file system operations and YAML parsing.
188
+ // These functions are better tested through integration tests that test the
189
+ // commands that use them (like close, start, etc.)
190
+ });