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