session-collab-mcp 0.4.6 → 0.5.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,705 @@
1
+ // LSP integration tools for symbol analysis and validation
2
+
3
+ import type { DatabaseAdapter } from '../../db/sqlite-adapter.js';
4
+ import type { McpTool, McpToolResult } from '../protocol';
5
+ import { createToolResult } from '../protocol';
6
+ import type { SymbolType, ConflictInfo, ReferenceInput } from '../../db/types';
7
+ import { storeReferences, analyzeClaimImpact, clearSessionReferences } from '../../db/queries';
8
+
9
+ // LSP Symbol Kind mapping (from LSP spec)
10
+ const LSP_SYMBOL_KIND_MAP: Record<number, SymbolType> = {
11
+ 5: 'class', // Class
12
+ 6: 'method', // Method
13
+ 9: 'function', // Constructor
14
+ 12: 'function', // Function
15
+ 13: 'variable', // Variable
16
+ 14: 'variable', // Constant
17
+ 23: 'class', // Struct
18
+ // Map others to 'other'
19
+ };
20
+
21
+ // Input format for LSP symbols (simplified from LSP DocumentSymbol)
22
+ interface LspSymbolInput {
23
+ name: string;
24
+ kind: number; // LSP SymbolKind
25
+ range?: {
26
+ start: { line: number; character: number };
27
+ end: { line: number; character: number };
28
+ };
29
+ children?: LspSymbolInput[];
30
+ }
31
+
32
+ interface FileSymbolInput {
33
+ file: string;
34
+ symbols: LspSymbolInput[];
35
+ }
36
+
37
+ // Reference input for tracking symbol dependencies
38
+ interface SymbolReference {
39
+ symbol: string;
40
+ file: string;
41
+ references: Array<{
42
+ file: string;
43
+ line: number;
44
+ context?: string;
45
+ }>;
46
+ }
47
+
48
+ // Output types
49
+ interface AnalyzedSymbol {
50
+ name: string;
51
+ type: SymbolType;
52
+ file: string;
53
+ conflict_status: 'safe' | 'blocked';
54
+ conflict_info?: {
55
+ session_name: string | null;
56
+ intent: string;
57
+ claim_id: string;
58
+ };
59
+ // Reference impact (which other symbols/files depend on this)
60
+ impact?: {
61
+ references_count: number;
62
+ affected_files: string[];
63
+ };
64
+ }
65
+
66
+ export const lspTools: McpTool[] = [
67
+ {
68
+ name: 'collab_analyze_symbols',
69
+ description: `Analyze LSP symbols for conflict detection and reference impact.
70
+
71
+ WORKFLOW:
72
+ 1. Claude uses LSP.documentSymbol to get symbols from a file
73
+ 2. Claude passes those symbols to this tool
74
+ 3. This tool checks conflicts and returns which symbols are safe/blocked
75
+
76
+ This enables Claude to automatically proceed with safe symbols and skip blocked ones.`,
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: {
80
+ session_id: {
81
+ type: 'string',
82
+ description: 'Your session ID (to exclude your own claims)',
83
+ },
84
+ files: {
85
+ type: 'array',
86
+ items: {
87
+ type: 'object',
88
+ properties: {
89
+ file: { type: 'string', description: 'File path' },
90
+ symbols: {
91
+ type: 'array',
92
+ items: {
93
+ type: 'object',
94
+ properties: {
95
+ name: { type: 'string', description: 'Symbol name' },
96
+ kind: { type: 'number', description: 'LSP SymbolKind' },
97
+ range: {
98
+ type: 'object',
99
+ properties: {
100
+ start: {
101
+ type: 'object',
102
+ properties: {
103
+ line: { type: 'number' },
104
+ character: { type: 'number' },
105
+ },
106
+ },
107
+ end: {
108
+ type: 'object',
109
+ properties: {
110
+ line: { type: 'number' },
111
+ character: { type: 'number' },
112
+ },
113
+ },
114
+ },
115
+ },
116
+ children: {
117
+ type: 'array',
118
+ description: 'Nested symbols (e.g., methods in a class)',
119
+ },
120
+ },
121
+ required: ['name', 'kind'],
122
+ },
123
+ description: 'LSP DocumentSymbol array from LSP.documentSymbol',
124
+ },
125
+ },
126
+ required: ['file', 'symbols'],
127
+ },
128
+ description: 'Files with their LSP symbols to analyze',
129
+ },
130
+ references: {
131
+ type: 'array',
132
+ items: {
133
+ type: 'object',
134
+ properties: {
135
+ symbol: { type: 'string', description: 'Symbol name' },
136
+ file: { type: 'string', description: 'File containing the symbol' },
137
+ references: {
138
+ type: 'array',
139
+ items: {
140
+ type: 'object',
141
+ properties: {
142
+ file: { type: 'string' },
143
+ line: { type: 'number' },
144
+ context: { type: 'string' },
145
+ },
146
+ required: ['file', 'line'],
147
+ },
148
+ description: 'Locations that reference this symbol (from LSP.findReferences)',
149
+ },
150
+ },
151
+ required: ['symbol', 'file', 'references'],
152
+ },
153
+ description: 'Optional: Reference data from LSP.findReferences for impact analysis',
154
+ },
155
+ check_symbols: {
156
+ type: 'array',
157
+ items: { type: 'string' },
158
+ description: 'Optional: Only analyze these specific symbol names (filter)',
159
+ },
160
+ },
161
+ required: ['session_id', 'files'],
162
+ },
163
+ },
164
+ {
165
+ name: 'collab_validate_symbols',
166
+ description: `Validate that symbols exist in a file before claiming them.
167
+
168
+ Use this to verify symbol names are correct before calling collab_claim.
169
+ Helps prevent claiming non-existent or misspelled symbols.`,
170
+ inputSchema: {
171
+ type: 'object',
172
+ properties: {
173
+ file: {
174
+ type: 'string',
175
+ description: 'File path to validate symbols in',
176
+ },
177
+ symbols: {
178
+ type: 'array',
179
+ items: { type: 'string' },
180
+ description: 'Symbol names to validate',
181
+ },
182
+ lsp_symbols: {
183
+ type: 'array',
184
+ items: {
185
+ type: 'object',
186
+ properties: {
187
+ name: { type: 'string' },
188
+ kind: { type: 'number' },
189
+ },
190
+ required: ['name', 'kind'],
191
+ },
192
+ description: 'LSP symbols from the file (from LSP.documentSymbol)',
193
+ },
194
+ },
195
+ required: ['file', 'symbols', 'lsp_symbols'],
196
+ },
197
+ },
198
+ {
199
+ name: 'collab_store_references',
200
+ description: `Store symbol reference data from LSP.findReferences for impact tracking.
201
+
202
+ Call this after using LSP.findReferences to persist the reference data.
203
+ This enables automatic impact warnings when other sessions claim related files.`,
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ session_id: {
208
+ type: 'string',
209
+ description: 'Your session ID',
210
+ },
211
+ references: {
212
+ type: 'array',
213
+ items: {
214
+ type: 'object',
215
+ properties: {
216
+ source_file: { type: 'string', description: 'File containing the symbol definition' },
217
+ source_symbol: { type: 'string', description: 'Symbol name' },
218
+ references: {
219
+ type: 'array',
220
+ items: {
221
+ type: 'object',
222
+ properties: {
223
+ file: { type: 'string' },
224
+ line: { type: 'number' },
225
+ context: { type: 'string' },
226
+ },
227
+ required: ['file', 'line'],
228
+ },
229
+ },
230
+ },
231
+ required: ['source_file', 'source_symbol', 'references'],
232
+ },
233
+ description: 'Reference data from LSP.findReferences',
234
+ },
235
+ clear_existing: {
236
+ type: 'boolean',
237
+ description: 'Clear existing references from this session before storing (default: false)',
238
+ },
239
+ },
240
+ required: ['session_id', 'references'],
241
+ },
242
+ },
243
+ {
244
+ name: 'collab_impact_analysis',
245
+ description: `Analyze the impact of modifying a symbol.
246
+
247
+ Returns which files reference this symbol and if any of those files have active claims.
248
+ Use this before making changes to widely-used symbols.`,
249
+ inputSchema: {
250
+ type: 'object',
251
+ properties: {
252
+ session_id: {
253
+ type: 'string',
254
+ description: 'Your session ID (to exclude your own claims)',
255
+ },
256
+ file: {
257
+ type: 'string',
258
+ description: 'File containing the symbol',
259
+ },
260
+ symbol: {
261
+ type: 'string',
262
+ description: 'Symbol name to analyze',
263
+ },
264
+ },
265
+ required: ['session_id', 'file', 'symbol'],
266
+ },
267
+ },
268
+ ];
269
+
270
+ // Helper: Convert LSP SymbolKind to our SymbolType
271
+ function lspKindToSymbolType(kind: number): SymbolType {
272
+ return LSP_SYMBOL_KIND_MAP[kind] ?? 'other';
273
+ }
274
+
275
+ // Helper: Flatten nested LSP symbols into a flat list
276
+ function flattenLspSymbols(
277
+ symbols: LspSymbolInput[],
278
+ file: string,
279
+ parentName?: string
280
+ ): Array<{ name: string; type: SymbolType; file: string; fullName: string }> {
281
+ const result: Array<{ name: string; type: SymbolType; file: string; fullName: string }> = [];
282
+
283
+ for (const sym of symbols) {
284
+ const fullName = parentName ? `${parentName}.${sym.name}` : sym.name;
285
+ result.push({
286
+ name: sym.name,
287
+ type: lspKindToSymbolType(sym.kind),
288
+ file,
289
+ fullName,
290
+ });
291
+
292
+ // Recursively flatten children (e.g., methods in a class)
293
+ if (sym.children && sym.children.length > 0) {
294
+ result.push(...flattenLspSymbols(sym.children, file, fullName));
295
+ }
296
+ }
297
+
298
+ return result;
299
+ }
300
+
301
+ export async function handleLspTool(
302
+ db: DatabaseAdapter,
303
+ name: string,
304
+ args: Record<string, unknown>
305
+ ): Promise<McpToolResult> {
306
+ switch (name) {
307
+ case 'collab_analyze_symbols': {
308
+ const sessionId = args.session_id as string;
309
+ const files = args.files as FileSymbolInput[] | undefined;
310
+ const references = args.references as SymbolReference[] | undefined;
311
+ const checkSymbols = args.check_symbols as string[] | undefined;
312
+
313
+ if (!sessionId) {
314
+ return createToolResult(
315
+ JSON.stringify({ error: 'INVALID_INPUT', message: 'session_id is required' }),
316
+ true
317
+ );
318
+ }
319
+
320
+ if (!files || !Array.isArray(files) || files.length === 0) {
321
+ return createToolResult(
322
+ JSON.stringify({ error: 'INVALID_INPUT', message: 'files array is required' }),
323
+ true
324
+ );
325
+ }
326
+
327
+ // Build reference lookup map if provided
328
+ const referenceMap = new Map<string, SymbolReference>();
329
+ if (references) {
330
+ for (const ref of references) {
331
+ const key = `${ref.file}:${ref.symbol}`;
332
+ referenceMap.set(key, ref);
333
+ }
334
+ }
335
+
336
+ // Flatten all symbols from all files
337
+ const allSymbols: Array<{ name: string; type: SymbolType; file: string; fullName: string }> = [];
338
+ for (const fileInput of files) {
339
+ const flattened = flattenLspSymbols(fileInput.symbols, fileInput.file);
340
+ allSymbols.push(...flattened);
341
+ }
342
+
343
+ // Filter to only requested symbols if specified
344
+ let symbolsToAnalyze = allSymbols;
345
+ if (checkSymbols && checkSymbols.length > 0) {
346
+ const checkSet = new Set(checkSymbols);
347
+ symbolsToAnalyze = allSymbols.filter((s) => checkSet.has(s.name) || checkSet.has(s.fullName));
348
+ }
349
+
350
+ // Query existing claims for these files
351
+ const fileList = [...new Set(symbolsToAnalyze.map((s) => s.file))];
352
+ const symbolNames = [...new Set(symbolsToAnalyze.map((s) => s.name))];
353
+
354
+ // Check for symbol-level claims
355
+ const symbolConflicts = await querySymbolConflicts(db, fileList, symbolNames, sessionId);
356
+
357
+ // Check for file-level claims
358
+ const fileConflicts = await queryFileConflicts(db, fileList, sessionId);
359
+
360
+ // Build result with conflict status for each symbol
361
+ const analyzedSymbols: AnalyzedSymbol[] = [];
362
+ const safeSymbols: string[] = [];
363
+ const blockedSymbols: string[] = [];
364
+
365
+ for (const sym of symbolsToAnalyze) {
366
+ // Check if blocked by file-level claim
367
+ const fileConflict = fileConflicts.find((c) => c.file_path === sym.file);
368
+ // Check if blocked by symbol-level claim
369
+ const symbolConflict = symbolConflicts.find(
370
+ (c) => c.file_path === sym.file && c.symbol_name === sym.name
371
+ );
372
+
373
+ const conflict = fileConflict ?? symbolConflict;
374
+ const isBlocked = !!conflict;
375
+
376
+ // Get reference impact if available
377
+ const refKey = `${sym.file}:${sym.name}`;
378
+ const refData = referenceMap.get(refKey);
379
+ let impact: AnalyzedSymbol['impact'] | undefined;
380
+
381
+ if (refData && refData.references.length > 0) {
382
+ const affectedFiles = [...new Set(refData.references.map((r) => r.file))];
383
+ impact = {
384
+ references_count: refData.references.length,
385
+ affected_files: affectedFiles,
386
+ };
387
+ }
388
+
389
+ const analyzed: AnalyzedSymbol = {
390
+ name: sym.name,
391
+ type: sym.type,
392
+ file: sym.file,
393
+ conflict_status: isBlocked ? 'blocked' : 'safe',
394
+ ...(conflict && {
395
+ conflict_info: {
396
+ session_name: conflict.session_name,
397
+ intent: conflict.intent,
398
+ claim_id: conflict.claim_id,
399
+ },
400
+ }),
401
+ ...(impact && { impact }),
402
+ };
403
+
404
+ analyzedSymbols.push(analyzed);
405
+
406
+ if (isBlocked) {
407
+ blockedSymbols.push(`${sym.file}:${sym.name}`);
408
+ } else {
409
+ safeSymbols.push(`${sym.file}:${sym.name}`);
410
+ }
411
+ }
412
+
413
+ // Determine recommendation
414
+ const hasBlocked = blockedSymbols.length > 0;
415
+ const hasSafe = safeSymbols.length > 0;
416
+
417
+ let recommendation: 'proceed_all' | 'proceed_safe_only' | 'abort';
418
+ if (!hasBlocked) {
419
+ recommendation = 'proceed_all';
420
+ } else if (hasSafe) {
421
+ recommendation = 'proceed_safe_only';
422
+ } else {
423
+ recommendation = 'abort';
424
+ }
425
+
426
+ // Build message
427
+ let message: string;
428
+ if (recommendation === 'proceed_all') {
429
+ message = `All ${safeSymbols.length} symbols are safe to edit. Proceed.`;
430
+ } else if (recommendation === 'proceed_safe_only') {
431
+ message = `Edit ONLY ${safeSymbols.length} safe symbols. ${blockedSymbols.length} symbols are blocked.`;
432
+ } else {
433
+ message = `All ${blockedSymbols.length} symbols are blocked. Coordinate with other sessions.`;
434
+ }
435
+
436
+ return createToolResult(
437
+ JSON.stringify(
438
+ {
439
+ can_edit: hasSafe,
440
+ recommendation,
441
+ summary: {
442
+ total: symbolsToAnalyze.length,
443
+ safe: safeSymbols.length,
444
+ blocked: blockedSymbols.length,
445
+ },
446
+ symbols: analyzedSymbols,
447
+ safe_symbols: safeSymbols,
448
+ blocked_symbols: blockedSymbols,
449
+ message,
450
+ },
451
+ null,
452
+ 2
453
+ )
454
+ );
455
+ }
456
+
457
+ case 'collab_validate_symbols': {
458
+ const file = args.file as string;
459
+ const symbols = args.symbols as string[];
460
+ const lspSymbols = args.lsp_symbols as Array<{ name: string; kind: number }>;
461
+
462
+ if (!file || !symbols || !lspSymbols) {
463
+ return createToolResult(
464
+ JSON.stringify({
465
+ error: 'INVALID_INPUT',
466
+ message: 'file, symbols, and lsp_symbols are required',
467
+ }),
468
+ true
469
+ );
470
+ }
471
+
472
+ // Build set of available symbol names from LSP data
473
+ const availableSymbols = new Set<string>();
474
+ for (const lspSym of lspSymbols) {
475
+ availableSymbols.add(lspSym.name);
476
+ }
477
+
478
+ // Validate each requested symbol
479
+ const valid: string[] = [];
480
+ const invalid: string[] = [];
481
+ const suggestions: Record<string, string[]> = {};
482
+
483
+ for (const sym of symbols) {
484
+ if (availableSymbols.has(sym)) {
485
+ valid.push(sym);
486
+ } else {
487
+ invalid.push(sym);
488
+ // Find similar names (simple prefix/suffix matching)
489
+ const similar = Array.from(availableSymbols).filter(
490
+ (avail) =>
491
+ avail.toLowerCase().includes(sym.toLowerCase()) ||
492
+ sym.toLowerCase().includes(avail.toLowerCase())
493
+ );
494
+ if (similar.length > 0) {
495
+ suggestions[sym] = similar.slice(0, 3);
496
+ }
497
+ }
498
+ }
499
+
500
+ const allValid = invalid.length === 0;
501
+
502
+ return createToolResult(
503
+ JSON.stringify({
504
+ valid: allValid,
505
+ file,
506
+ valid_symbols: valid,
507
+ invalid_symbols: invalid,
508
+ suggestions: Object.keys(suggestions).length > 0 ? suggestions : undefined,
509
+ available_symbols: Array.from(availableSymbols),
510
+ message: allValid
511
+ ? `All ${valid.length} symbols are valid.`
512
+ : `${invalid.length} symbol(s) not found: ${invalid.join(', ')}`,
513
+ })
514
+ );
515
+ }
516
+
517
+ case 'collab_store_references': {
518
+ const sessionId = args.session_id as string;
519
+ const references = args.references as ReferenceInput[];
520
+ const clearExisting = args.clear_existing as boolean | undefined;
521
+
522
+ if (!sessionId) {
523
+ return createToolResult(
524
+ JSON.stringify({ error: 'INVALID_INPUT', message: 'session_id is required' }),
525
+ true
526
+ );
527
+ }
528
+
529
+ if (!references || !Array.isArray(references)) {
530
+ return createToolResult(
531
+ JSON.stringify({ error: 'INVALID_INPUT', message: 'references array is required' }),
532
+ true
533
+ );
534
+ }
535
+
536
+ // Clear existing references if requested
537
+ let cleared = 0;
538
+ if (clearExisting) {
539
+ cleared = await clearSessionReferences(db, sessionId);
540
+ }
541
+
542
+ // Store new references
543
+ const result = await storeReferences(db, sessionId, references);
544
+
545
+ return createToolResult(
546
+ JSON.stringify({
547
+ success: true,
548
+ stored: result.stored,
549
+ skipped: result.skipped,
550
+ cleared: cleared,
551
+ message: `Stored ${result.stored} references${cleared > 0 ? ` (cleared ${cleared} existing)` : ''}.`,
552
+ })
553
+ );
554
+ }
555
+
556
+ case 'collab_impact_analysis': {
557
+ const sessionId = args.session_id as string;
558
+ const file = args.file as string;
559
+ const symbol = args.symbol as string;
560
+
561
+ if (!sessionId || !file || !symbol) {
562
+ return createToolResult(
563
+ JSON.stringify({
564
+ error: 'INVALID_INPUT',
565
+ message: 'session_id, file, and symbol are required',
566
+ }),
567
+ true
568
+ );
569
+ }
570
+
571
+ const impact = await analyzeClaimImpact(db, file, symbol, sessionId);
572
+
573
+ // Determine risk level
574
+ let riskLevel: 'low' | 'medium' | 'high';
575
+ if (impact.affected_claims.length > 0) {
576
+ riskLevel = 'high';
577
+ } else if (impact.reference_count > 10) {
578
+ riskLevel = 'medium';
579
+ } else {
580
+ riskLevel = 'low';
581
+ }
582
+
583
+ // Build message
584
+ let message: string;
585
+ if (impact.affected_claims.length > 0) {
586
+ const claimNames = impact.affected_claims.map((c) => c.session_name ?? 'unknown').join(', ');
587
+ message = `⚠️ HIGH RISK: ${impact.affected_claims.length} active claim(s) on referencing files (${claimNames}). Coordinate before modifying.`;
588
+ } else if (impact.reference_count > 0) {
589
+ message = `${impact.reference_count} references across ${impact.affected_files.length} file(s). No active claims conflict.`;
590
+ } else {
591
+ message = 'No stored references found. Consider running LSP.findReferences and collab_store_references first.';
592
+ }
593
+
594
+ return createToolResult(
595
+ JSON.stringify(
596
+ {
597
+ symbol: impact.symbol,
598
+ file: impact.file,
599
+ risk_level: riskLevel,
600
+ reference_count: impact.reference_count,
601
+ affected_files: impact.affected_files,
602
+ affected_claims: impact.affected_claims,
603
+ message,
604
+ },
605
+ null,
606
+ 2
607
+ )
608
+ );
609
+ }
610
+
611
+ default:
612
+ return createToolResult(`Unknown LSP tool: ${name}`, true);
613
+ }
614
+ }
615
+
616
+ // Helper: Query symbol-level conflicts from database
617
+ async function querySymbolConflicts(
618
+ db: DatabaseAdapter,
619
+ files: string[],
620
+ symbolNames: string[],
621
+ excludeSessionId: string
622
+ ): Promise<ConflictInfo[]> {
623
+ if (files.length === 0 || symbolNames.length === 0) {
624
+ return [];
625
+ }
626
+
627
+ const filePlaceholders = files.map(() => '?').join(',');
628
+ const symbolPlaceholders = symbolNames.map(() => '?').join(',');
629
+
630
+ const query = `
631
+ SELECT
632
+ c.id as claim_id,
633
+ c.session_id,
634
+ s.name as session_name,
635
+ cs.file_path,
636
+ c.intent,
637
+ c.scope,
638
+ c.created_at,
639
+ cs.symbol_name,
640
+ cs.symbol_type
641
+ FROM claim_symbols cs
642
+ JOIN claims c ON cs.claim_id = c.id
643
+ JOIN sessions s ON c.session_id = s.id
644
+ WHERE c.status = 'active'
645
+ AND s.status = 'active'
646
+ AND c.session_id != ?
647
+ AND cs.file_path IN (${filePlaceholders})
648
+ AND cs.symbol_name IN (${symbolPlaceholders})
649
+ `;
650
+
651
+ const result = await db
652
+ .prepare(query)
653
+ .bind(excludeSessionId, ...files, ...symbolNames)
654
+ .all<ConflictInfo & { symbol_name: string; symbol_type: string }>();
655
+
656
+ return result.results.map((r) => ({
657
+ ...r,
658
+ conflict_level: 'symbol' as const,
659
+ }));
660
+ }
661
+
662
+ // Helper: Query file-level conflicts from database
663
+ async function queryFileConflicts(
664
+ db: DatabaseAdapter,
665
+ files: string[],
666
+ excludeSessionId: string
667
+ ): Promise<ConflictInfo[]> {
668
+ if (files.length === 0) {
669
+ return [];
670
+ }
671
+
672
+ const placeholders = files.map(() => '?').join(',');
673
+
674
+ // File-level claims are claims that have files but no symbols for those files
675
+ const query = `
676
+ SELECT
677
+ c.id as claim_id,
678
+ c.session_id,
679
+ s.name as session_name,
680
+ cf.file_path,
681
+ c.intent,
682
+ c.scope,
683
+ c.created_at
684
+ FROM claim_files cf
685
+ JOIN claims c ON cf.claim_id = c.id
686
+ JOIN sessions s ON c.session_id = s.id
687
+ WHERE c.status = 'active'
688
+ AND s.status = 'active'
689
+ AND c.session_id != ?
690
+ AND cf.file_path IN (${placeholders})
691
+ AND NOT EXISTS (
692
+ SELECT 1 FROM claim_symbols cs WHERE cs.claim_id = c.id AND cs.file_path = cf.file_path
693
+ )
694
+ `;
695
+
696
+ const result = await db
697
+ .prepare(query)
698
+ .bind(excludeSessionId, ...files)
699
+ .all<Omit<ConflictInfo, 'conflict_level'>>();
700
+
701
+ return result.results.map((r) => ({
702
+ ...r,
703
+ conflict_level: 'file' as const,
704
+ }));
705
+ }