specweave 0.16.5 → 0.17.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.
@@ -0,0 +1,443 @@
1
+ /**
2
+ * Conflict Resolver for Living Docs Synchronization
3
+ *
4
+ * CRITICAL PRINCIPLE: External tool status ALWAYS wins in conflicts!
5
+ * This ensures that QA and stakeholder decisions in external tools
6
+ * take precedence over local development status.
7
+ */
8
+
9
+ import * as fs from 'fs-extra';
10
+ import * as path from 'path';
11
+ import * as yaml from 'yaml';
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ export interface ConflictResolution {
18
+ field: string;
19
+ localValue: any;
20
+ externalValue: any;
21
+ resolution: 'external' | 'local';
22
+ resolvedValue: any;
23
+ reason: string;
24
+ timestamp: string;
25
+ }
26
+
27
+ export interface SpecMetadata {
28
+ id: string;
29
+ title: string;
30
+ status: SpecStatus;
31
+ priority?: Priority;
32
+ externalLinks?: {
33
+ ado?: {
34
+ featureId: number;
35
+ featureUrl: string;
36
+ syncedAt?: string;
37
+ lastExternalStatus?: string;
38
+ };
39
+ jira?: {
40
+ issueKey: string;
41
+ issueUrl: string;
42
+ syncedAt?: string;
43
+ lastExternalStatus?: string;
44
+ };
45
+ github?: {
46
+ issue: number;
47
+ url: string;
48
+ syncedAt?: string;
49
+ lastExternalStatus?: string;
50
+ };
51
+ };
52
+ }
53
+
54
+ export type SpecStatus =
55
+ | 'draft'
56
+ | 'in-progress'
57
+ | 'implemented'
58
+ | 'in-qa'
59
+ | 'complete'
60
+ | 'blocked'
61
+ | 'cancelled';
62
+
63
+ export type Priority = 'P0' | 'P1' | 'P2' | 'P3';
64
+
65
+ export interface ExternalStatus {
66
+ tool: 'ado' | 'jira' | 'github';
67
+ status: string;
68
+ mappedStatus: SpecStatus;
69
+ priority?: string;
70
+ lastModified: string;
71
+ }
72
+
73
+ // ============================================================================
74
+ // Status Mapping
75
+ // ============================================================================
76
+
77
+ const STATUS_MAPPING = {
78
+ ado: {
79
+ 'New': 'draft' as SpecStatus,
80
+ 'Active': 'in-progress' as SpecStatus,
81
+ 'Resolved': 'implemented' as SpecStatus,
82
+ 'Closed': 'complete' as SpecStatus,
83
+ 'In Review': 'in-qa' as SpecStatus,
84
+ 'In QA': 'in-qa' as SpecStatus,
85
+ 'Blocked': 'blocked' as SpecStatus,
86
+ 'Removed': 'cancelled' as SpecStatus
87
+ },
88
+ jira: {
89
+ 'To Do': 'draft' as SpecStatus,
90
+ 'In Progress': 'in-progress' as SpecStatus,
91
+ 'Code Review': 'implemented' as SpecStatus,
92
+ 'In Review': 'implemented' as SpecStatus,
93
+ 'QA': 'in-qa' as SpecStatus,
94
+ 'Testing': 'in-qa' as SpecStatus,
95
+ 'Done': 'complete' as SpecStatus,
96
+ 'Closed': 'complete' as SpecStatus,
97
+ 'Blocked': 'blocked' as SpecStatus,
98
+ 'Cancelled': 'cancelled' as SpecStatus
99
+ },
100
+ github: {
101
+ 'open': 'in-progress' as SpecStatus,
102
+ 'closed': 'complete' as SpecStatus
103
+ }
104
+ };
105
+
106
+ const REVERSE_STATUS_MAPPING = {
107
+ ado: {
108
+ 'draft': 'New',
109
+ 'in-progress': 'Active',
110
+ 'implemented': 'Resolved',
111
+ 'in-qa': 'In QA',
112
+ 'complete': 'Closed',
113
+ 'blocked': 'Blocked',
114
+ 'cancelled': 'Removed'
115
+ },
116
+ jira: {
117
+ 'draft': 'To Do',
118
+ 'in-progress': 'In Progress',
119
+ 'implemented': 'Code Review',
120
+ 'in-qa': 'QA',
121
+ 'complete': 'Done',
122
+ 'blocked': 'Blocked',
123
+ 'cancelled': 'Cancelled'
124
+ },
125
+ github: {
126
+ 'draft': 'open',
127
+ 'in-progress': 'open',
128
+ 'implemented': 'open',
129
+ 'in-qa': 'open',
130
+ 'complete': 'closed',
131
+ 'blocked': 'open',
132
+ 'cancelled': 'closed'
133
+ }
134
+ };
135
+
136
+ // ============================================================================
137
+ // Conflict Resolver Class
138
+ // ============================================================================
139
+
140
+ export class ConflictResolver {
141
+ private resolutionLog: ConflictResolution[] = [];
142
+
143
+ /**
144
+ * Map external status to local SpecWeave status
145
+ */
146
+ public mapExternalStatus(tool: 'ado' | 'jira' | 'github', externalStatus: string): SpecStatus {
147
+ const mapping = STATUS_MAPPING[tool];
148
+ return mapping[externalStatus] || 'unknown' as SpecStatus;
149
+ }
150
+
151
+ /**
152
+ * Map local status to external tool status
153
+ */
154
+ public mapLocalStatus(tool: 'ado' | 'jira' | 'github', localStatus: SpecStatus): string {
155
+ const mapping = REVERSE_STATUS_MAPPING[tool];
156
+ return mapping[localStatus] || 'Active';
157
+ }
158
+
159
+ /**
160
+ * CRITICAL: Resolve status conflict - EXTERNAL ALWAYS WINS
161
+ */
162
+ public resolveStatusConflict(
163
+ localStatus: SpecStatus,
164
+ externalStatus: ExternalStatus
165
+ ): ConflictResolution {
166
+ const resolution: ConflictResolution = {
167
+ field: 'status',
168
+ localValue: localStatus,
169
+ externalValue: externalStatus.status,
170
+ resolution: 'external', // ALWAYS external for status
171
+ resolvedValue: externalStatus.mappedStatus,
172
+ reason: 'External tool reflects QA and stakeholder decisions',
173
+ timestamp: new Date().toISOString()
174
+ };
175
+
176
+ // Log the resolution
177
+ console.log(`📊 Status Conflict Detected:`);
178
+ console.log(` Local: ${localStatus}`);
179
+ console.log(` External: ${externalStatus.status} (${externalStatus.tool})`);
180
+ console.log(` ✅ Resolution: EXTERNAL WINS - ${externalStatus.mappedStatus}`);
181
+
182
+ this.resolutionLog.push(resolution);
183
+ return resolution;
184
+ }
185
+
186
+ /**
187
+ * Resolve priority conflict - EXTERNAL WINS
188
+ */
189
+ public resolvePriorityConflict(
190
+ localPriority: Priority | undefined,
191
+ externalPriority: string | undefined
192
+ ): ConflictResolution {
193
+ const resolution: ConflictResolution = {
194
+ field: 'priority',
195
+ localValue: localPriority,
196
+ externalValue: externalPriority,
197
+ resolution: 'external',
198
+ resolvedValue: externalPriority || localPriority,
199
+ reason: 'External tool reflects stakeholder prioritization',
200
+ timestamp: new Date().toISOString()
201
+ };
202
+
203
+ if (localPriority !== externalPriority && externalPriority) {
204
+ console.log(`📊 Priority Conflict Detected:`);
205
+ console.log(` Local: ${localPriority}`);
206
+ console.log(` External: ${externalPriority}`);
207
+ console.log(` ✅ Resolution: EXTERNAL WINS - ${externalPriority}`);
208
+ this.resolutionLog.push(resolution);
209
+ }
210
+
211
+ return resolution;
212
+ }
213
+
214
+ /**
215
+ * Apply conflict resolutions to spec
216
+ */
217
+ public async applyResolutions(
218
+ specPath: string,
219
+ resolutions: ConflictResolution[]
220
+ ): Promise<void> {
221
+ const content = await fs.readFile(specPath, 'utf-8');
222
+ const lines = content.split('\n');
223
+ let inFrontmatter = false;
224
+ let frontmatterEnd = -1;
225
+
226
+ // Find frontmatter boundaries
227
+ for (let i = 0; i < lines.length; i++) {
228
+ if (lines[i] === '---') {
229
+ if (!inFrontmatter) {
230
+ inFrontmatter = true;
231
+ } else {
232
+ frontmatterEnd = i;
233
+ break;
234
+ }
235
+ }
236
+ }
237
+
238
+ // Apply resolutions
239
+ for (const resolution of resolutions) {
240
+ if (resolution.field === 'status') {
241
+ // Update status in frontmatter
242
+ for (let i = 1; i < frontmatterEnd; i++) {
243
+ if (lines[i].startsWith('status:')) {
244
+ lines[i] = `status: ${resolution.resolvedValue}`;
245
+ console.log(`✅ Applied status resolution: ${resolution.resolvedValue}`);
246
+ break;
247
+ }
248
+ }
249
+
250
+ // Add sync metadata
251
+ const syncTimestamp = new Date().toISOString();
252
+ let syncedAtFound = false;
253
+
254
+ for (let i = 1; i < frontmatterEnd; i++) {
255
+ if (lines[i].includes('syncedAt:')) {
256
+ lines[i] = ` syncedAt: "${syncTimestamp}"`;
257
+ syncedAtFound = true;
258
+ break;
259
+ }
260
+ }
261
+
262
+ if (!syncedAtFound) {
263
+ // Add syncedAt after externalLinks
264
+ for (let i = 1; i < frontmatterEnd; i++) {
265
+ if (lines[i].includes('externalLinks:')) {
266
+ // Find the external tool section
267
+ for (let j = i + 1; j < frontmatterEnd; j++) {
268
+ if (lines[j].includes('ado:') || lines[j].includes('jira:') || lines[j].includes('github:')) {
269
+ // Insert after the URL line
270
+ for (let k = j + 1; k < frontmatterEnd; k++) {
271
+ if (lines[k].includes('Url:')) {
272
+ lines.splice(k + 1, 0, ` syncedAt: "${syncTimestamp}"`);
273
+ frontmatterEnd++; // Adjust for inserted line
274
+ syncedAtFound = true;
275
+ break;
276
+ }
277
+ }
278
+ if (syncedAtFound) break;
279
+ }
280
+ }
281
+ if (syncedAtFound) break;
282
+ }
283
+ }
284
+ }
285
+ } else if (resolution.field === 'priority' && resolution.resolvedValue) {
286
+ // Update priority in frontmatter
287
+ for (let i = 1; i < frontmatterEnd; i++) {
288
+ if (lines[i].startsWith('priority:')) {
289
+ lines[i] = `priority: ${resolution.resolvedValue}`;
290
+ console.log(`✅ Applied priority resolution: ${resolution.resolvedValue}`);
291
+ break;
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ // Write updated content
298
+ await fs.writeFile(specPath, lines.join('\n'));
299
+ console.log(`✅ Resolutions applied to ${path.basename(specPath)}`);
300
+ }
301
+
302
+ /**
303
+ * Validate that external status wins in implementation
304
+ */
305
+ public validateImplementation(
306
+ implementationCode: string
307
+ ): { valid: boolean; violations: string[] } {
308
+ const violations: string[] = [];
309
+
310
+ // Check for incorrect patterns
311
+ const incorrectPatterns = [
312
+ {
313
+ pattern: /if.*conflict.*\{[^}]*spec\.status\s*=\s*localStatus/,
314
+ message: 'Local status should never win in conflicts'
315
+ },
316
+ {
317
+ pattern: /resolution\s*:\s*['"]local['"]/,
318
+ message: 'Resolution should be "external" for status conflicts'
319
+ },
320
+ {
321
+ pattern: /prefer.*local.*status/i,
322
+ message: 'Should prefer external status'
323
+ }
324
+ ];
325
+
326
+ for (const { pattern, message } of incorrectPatterns) {
327
+ if (pattern.test(implementationCode)) {
328
+ violations.push(message);
329
+ }
330
+ }
331
+
332
+ // Check for correct patterns
333
+ const requiredPatterns = [
334
+ {
335
+ pattern: /external.*wins|EXTERNAL.*WINS|externalStatus.*applied/i,
336
+ message: 'Missing confirmation that external wins'
337
+ }
338
+ ];
339
+
340
+ for (const { pattern, message } of requiredPatterns) {
341
+ if (!pattern.test(implementationCode)) {
342
+ violations.push(message);
343
+ }
344
+ }
345
+
346
+ return {
347
+ valid: violations.length === 0,
348
+ violations
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Get resolution history
354
+ */
355
+ public getResolutionLog(): ConflictResolution[] {
356
+ return this.resolutionLog;
357
+ }
358
+
359
+ /**
360
+ * Generate resolution report
361
+ */
362
+ public generateReport(): string {
363
+ const report = [];
364
+ report.push('# Conflict Resolution Report');
365
+ report.push(`\n**Generated**: ${new Date().toISOString()}`);
366
+ report.push(`**Total Resolutions**: ${this.resolutionLog.length}`);
367
+ report.push('\n## Resolutions\n');
368
+
369
+ for (const resolution of this.resolutionLog) {
370
+ report.push(`### ${resolution.field}`);
371
+ report.push(`- **Local Value**: ${resolution.localValue}`);
372
+ report.push(`- **External Value**: ${resolution.externalValue}`);
373
+ report.push(`- **Resolution**: ${resolution.resolution.toUpperCase()} WINS`);
374
+ report.push(`- **Resolved To**: ${resolution.resolvedValue}`);
375
+ report.push(`- **Reason**: ${resolution.reason}`);
376
+ report.push(`- **Time**: ${resolution.timestamp}\n`);
377
+ }
378
+
379
+ report.push('## Validation');
380
+ report.push('✅ All conflicts resolved with external tool priority');
381
+
382
+ return report.join('\n');
383
+ }
384
+ }
385
+
386
+ // ============================================================================
387
+ // Helper Functions
388
+ // ============================================================================
389
+
390
+ /**
391
+ * Load spec metadata from file
392
+ */
393
+ export async function loadSpecMetadata(specPath: string): Promise<SpecMetadata> {
394
+ const content = await fs.readFile(specPath, 'utf-8');
395
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
396
+
397
+ if (!frontmatterMatch) {
398
+ throw new Error(`No frontmatter found in ${specPath}`);
399
+ }
400
+
401
+ return yaml.parse(frontmatterMatch[1]) as SpecMetadata;
402
+ }
403
+
404
+ /**
405
+ * Perform bidirectional sync with conflict resolution
406
+ */
407
+ export async function performBidirectionalSync(
408
+ specPath: string,
409
+ externalStatus: ExternalStatus
410
+ ): Promise<void> {
411
+ const resolver = new ConflictResolver();
412
+ const spec = await loadSpecMetadata(specPath);
413
+ const resolutions: ConflictResolution[] = [];
414
+
415
+ // Check for status conflict
416
+ if (spec.status !== externalStatus.mappedStatus) {
417
+ const statusResolution = resolver.resolveStatusConflict(
418
+ spec.status,
419
+ externalStatus
420
+ );
421
+ resolutions.push(statusResolution);
422
+ }
423
+
424
+ // Apply resolutions if any conflicts found
425
+ if (resolutions.length > 0) {
426
+ await resolver.applyResolutions(specPath, resolutions);
427
+
428
+ // Generate and save report
429
+ const report = resolver.generateReport();
430
+ const reportPath = specPath.replace('.md', '-sync-report.md');
431
+ await fs.writeFile(reportPath, report);
432
+
433
+ console.log(`📄 Sync report saved to ${path.basename(reportPath)}`);
434
+ } else {
435
+ console.log('✅ No conflicts detected - spec in sync with external tool');
436
+ }
437
+ }
438
+
439
+ // ============================================================================
440
+ // Export for testing
441
+ // ============================================================================
442
+
443
+ export default ConflictResolver;