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,216 @@
|
|
|
1
|
+
// Test helper: In-memory SQLite database for testing
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import type { DatabaseAdapter, PreparedStatement, QueryResult } from '../sqlite-adapter.js';
|
|
4
|
+
|
|
5
|
+
class TestPreparedStatement implements PreparedStatement {
|
|
6
|
+
private bindings: unknown[] = [];
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
private db: Database.Database,
|
|
10
|
+
private sql: string
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
bind(...values: unknown[]): PreparedStatement {
|
|
14
|
+
this.bindings = values;
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async first<T>(): Promise<T | null> {
|
|
19
|
+
const stmt = this.db.prepare(this.sql);
|
|
20
|
+
const result = stmt.get(...this.bindings) as T | undefined;
|
|
21
|
+
return result ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async all<T>(): Promise<QueryResult<T>> {
|
|
25
|
+
const stmt = this.db.prepare(this.sql);
|
|
26
|
+
const results = stmt.all(...this.bindings) as T[];
|
|
27
|
+
return {
|
|
28
|
+
results,
|
|
29
|
+
meta: { changes: 0, last_row_id: 0 },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async run(): Promise<{ meta: { changes: number } }> {
|
|
34
|
+
const stmt = this.db.prepare(this.sql);
|
|
35
|
+
const result = stmt.run(...this.bindings);
|
|
36
|
+
return {
|
|
37
|
+
meta: { changes: result.changes },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_run(): Database.RunResult {
|
|
42
|
+
const stmt = this.db.prepare(this.sql);
|
|
43
|
+
return stmt.run(...this.bindings);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Schema statements split for initialization
|
|
48
|
+
const SCHEMA_STATEMENTS = [
|
|
49
|
+
// Sessions table
|
|
50
|
+
`CREATE TABLE IF NOT EXISTS sessions (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
name TEXT,
|
|
53
|
+
project_root TEXT NOT NULL,
|
|
54
|
+
machine_id TEXT,
|
|
55
|
+
user_id TEXT,
|
|
56
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
57
|
+
last_heartbeat TEXT DEFAULT (datetime('now')),
|
|
58
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'terminated')),
|
|
59
|
+
current_task TEXT,
|
|
60
|
+
progress TEXT,
|
|
61
|
+
todos TEXT,
|
|
62
|
+
config TEXT
|
|
63
|
+
)`,
|
|
64
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status)`,
|
|
65
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_root)`,
|
|
66
|
+
|
|
67
|
+
// Claims table
|
|
68
|
+
`CREATE TABLE IF NOT EXISTS claims (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
session_id TEXT NOT NULL,
|
|
71
|
+
intent TEXT NOT NULL,
|
|
72
|
+
scope TEXT DEFAULT 'medium' CHECK (scope IN ('small', 'medium', 'large')),
|
|
73
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'abandoned')),
|
|
74
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
75
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
76
|
+
completed_summary TEXT,
|
|
77
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
78
|
+
)`,
|
|
79
|
+
`CREATE INDEX IF NOT EXISTS idx_claims_session ON claims(session_id)`,
|
|
80
|
+
`CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status)`,
|
|
81
|
+
|
|
82
|
+
// Claim files
|
|
83
|
+
`CREATE TABLE IF NOT EXISTS claim_files (
|
|
84
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
85
|
+
claim_id TEXT NOT NULL,
|
|
86
|
+
file_path TEXT NOT NULL,
|
|
87
|
+
is_pattern INTEGER DEFAULT 0,
|
|
88
|
+
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
|
|
89
|
+
UNIQUE(claim_id, file_path)
|
|
90
|
+
)`,
|
|
91
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_files_path ON claim_files(file_path)`,
|
|
92
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_files_claim ON claim_files(claim_id)`,
|
|
93
|
+
|
|
94
|
+
// Claim symbols
|
|
95
|
+
`CREATE TABLE IF NOT EXISTS claim_symbols (
|
|
96
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
97
|
+
claim_id TEXT NOT NULL,
|
|
98
|
+
file_path TEXT NOT NULL,
|
|
99
|
+
symbol_name TEXT NOT NULL,
|
|
100
|
+
symbol_type TEXT DEFAULT 'function' CHECK (symbol_type IN ('function', 'class', 'method', 'variable', 'block', 'other')),
|
|
101
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
102
|
+
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
|
|
103
|
+
UNIQUE(claim_id, file_path, symbol_name)
|
|
104
|
+
)`,
|
|
105
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_symbols_path ON claim_symbols(file_path)`,
|
|
106
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_symbols_name ON claim_symbols(symbol_name)`,
|
|
107
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_symbols_claim ON claim_symbols(claim_id)`,
|
|
108
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_symbols_lookup ON claim_symbols(file_path, symbol_name)`,
|
|
109
|
+
|
|
110
|
+
// Messages table
|
|
111
|
+
`CREATE TABLE IF NOT EXISTS messages (
|
|
112
|
+
id TEXT PRIMARY KEY,
|
|
113
|
+
from_session_id TEXT NOT NULL,
|
|
114
|
+
to_session_id TEXT,
|
|
115
|
+
content TEXT NOT NULL,
|
|
116
|
+
read_at TEXT,
|
|
117
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
118
|
+
FOREIGN KEY (from_session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
119
|
+
)`,
|
|
120
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session_id)`,
|
|
121
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(to_session_id, read_at)`,
|
|
122
|
+
|
|
123
|
+
// Decisions table
|
|
124
|
+
`CREATE TABLE IF NOT EXISTS decisions (
|
|
125
|
+
id TEXT PRIMARY KEY,
|
|
126
|
+
session_id TEXT NOT NULL,
|
|
127
|
+
category TEXT CHECK (category IN ('architecture', 'naming', 'api', 'database', 'ui', 'other')),
|
|
128
|
+
title TEXT NOT NULL,
|
|
129
|
+
description TEXT NOT NULL,
|
|
130
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
131
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
132
|
+
)`,
|
|
133
|
+
`CREATE INDEX IF NOT EXISTS idx_decisions_category ON decisions(category)`,
|
|
134
|
+
|
|
135
|
+
// Symbol references
|
|
136
|
+
`CREATE TABLE IF NOT EXISTS symbol_references (
|
|
137
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
138
|
+
source_file TEXT NOT NULL,
|
|
139
|
+
source_symbol TEXT NOT NULL,
|
|
140
|
+
ref_file TEXT NOT NULL,
|
|
141
|
+
ref_line INTEGER,
|
|
142
|
+
ref_context TEXT,
|
|
143
|
+
session_id TEXT NOT NULL,
|
|
144
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
145
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
|
146
|
+
UNIQUE(source_file, source_symbol, ref_file, ref_line)
|
|
147
|
+
)`,
|
|
148
|
+
`CREATE INDEX IF NOT EXISTS idx_symbol_refs_source ON symbol_references(source_file, source_symbol)`,
|
|
149
|
+
`CREATE INDEX IF NOT EXISTS idx_symbol_refs_ref_file ON symbol_references(ref_file)`,
|
|
150
|
+
`CREATE INDEX IF NOT EXISTS idx_symbol_refs_session ON symbol_references(session_id)`,
|
|
151
|
+
|
|
152
|
+
// Composite indexes for common query patterns
|
|
153
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_status_heartbeat ON sessions(status, last_heartbeat)`,
|
|
154
|
+
`CREATE INDEX IF NOT EXISTS idx_claims_status_session ON claims(status, session_id)`,
|
|
155
|
+
`CREATE INDEX IF NOT EXISTS idx_claim_files_path_claim ON claim_files(file_path, claim_id)`,
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const CLEANUP_STATEMENTS = [
|
|
159
|
+
'DELETE FROM symbol_references',
|
|
160
|
+
'DELETE FROM claim_symbols',
|
|
161
|
+
'DELETE FROM claim_files',
|
|
162
|
+
'DELETE FROM claims',
|
|
163
|
+
'DELETE FROM messages',
|
|
164
|
+
'DELETE FROM decisions',
|
|
165
|
+
'DELETE FROM sessions',
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
export class TestDatabase implements DatabaseAdapter {
|
|
169
|
+
private db: Database.Database;
|
|
170
|
+
|
|
171
|
+
constructor() {
|
|
172
|
+
// Use in-memory database for tests
|
|
173
|
+
this.db = new Database(':memory:');
|
|
174
|
+
this.db.pragma('foreign_keys = ON');
|
|
175
|
+
this.initSchema();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private initSchema(): void {
|
|
179
|
+
for (const sql of SCHEMA_STATEMENTS) {
|
|
180
|
+
this.db.prepare(sql).run();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
prepare(sql: string): PreparedStatement {
|
|
185
|
+
return new TestPreparedStatement(this.db, sql);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async batch(statements: PreparedStatement[]): Promise<QueryResult<unknown>[]> {
|
|
189
|
+
const transaction = this.db.transaction(() => {
|
|
190
|
+
return statements.map((stmt) => {
|
|
191
|
+
const testStmt = stmt as TestPreparedStatement;
|
|
192
|
+
const result = testStmt._run();
|
|
193
|
+
return {
|
|
194
|
+
results: [],
|
|
195
|
+
meta: { changes: result.changes, last_row_id: Number(result.lastInsertRowid) },
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
return transaction();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
close(): void {
|
|
203
|
+
this.db.close();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Helper to reset database between tests
|
|
207
|
+
reset(): void {
|
|
208
|
+
for (const sql of CLEANUP_STATEMENTS) {
|
|
209
|
+
this.db.prepare(sql).run();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function createTestDatabase(): TestDatabase {
|
|
215
|
+
return new TestDatabase();
|
|
216
|
+
}
|
package/src/db/queries.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
TodoItem,
|
|
15
15
|
SessionProgress,
|
|
16
16
|
SessionConfig,
|
|
17
|
+
SymbolClaim,
|
|
18
|
+
SymbolType,
|
|
17
19
|
} from './types';
|
|
18
20
|
|
|
19
21
|
// Helper to generate UUID v4
|
|
@@ -262,8 +264,9 @@ export async function createClaim(
|
|
|
262
264
|
files: string[];
|
|
263
265
|
intent: string;
|
|
264
266
|
scope?: ClaimScope;
|
|
267
|
+
symbols?: SymbolClaim[];
|
|
265
268
|
}
|
|
266
|
-
): Promise<{ claim: Claim; files: string[] }> {
|
|
269
|
+
): Promise<{ claim: Claim; files: string[]; symbols?: SymbolClaim[] }> {
|
|
267
270
|
const id = generateId();
|
|
268
271
|
const now = new Date().toISOString();
|
|
269
272
|
const scope = params.scope ?? 'medium';
|
|
@@ -283,7 +286,23 @@ export async function createClaim(
|
|
|
283
286
|
.bind(id, filePath, isPattern);
|
|
284
287
|
});
|
|
285
288
|
|
|
286
|
-
|
|
289
|
+
// Add symbol claims if provided
|
|
290
|
+
const symbolStatements: ReturnType<typeof db.prepare>[] = [];
|
|
291
|
+
if (params.symbols && params.symbols.length > 0) {
|
|
292
|
+
for (const symbolClaim of params.symbols) {
|
|
293
|
+
for (const symbolName of symbolClaim.symbols) {
|
|
294
|
+
symbolStatements.push(
|
|
295
|
+
db
|
|
296
|
+
.prepare(
|
|
297
|
+
'INSERT INTO claim_symbols (claim_id, file_path, symbol_name, symbol_type, created_at) VALUES (?, ?, ?, ?, ?)'
|
|
298
|
+
)
|
|
299
|
+
.bind(id, symbolClaim.file, symbolName, symbolClaim.symbol_type ?? 'function', now)
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await db.batch([claimStatement, ...fileStatements, ...symbolStatements]);
|
|
287
306
|
|
|
288
307
|
return {
|
|
289
308
|
claim: {
|
|
@@ -297,22 +316,41 @@ export async function createClaim(
|
|
|
297
316
|
completed_summary: null,
|
|
298
317
|
},
|
|
299
318
|
files: params.files,
|
|
319
|
+
symbols: params.symbols,
|
|
300
320
|
};
|
|
301
321
|
}
|
|
302
322
|
|
|
303
323
|
export async function getClaim(db: DatabaseAdapter, id: string): Promise<ClaimWithFiles | null> {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
324
|
+
// Single query with JOIN to avoid N+1 problem
|
|
325
|
+
const result = await db
|
|
326
|
+
.prepare(
|
|
327
|
+
`SELECT
|
|
328
|
+
c.id, c.session_id, c.intent, c.scope, c.status,
|
|
329
|
+
c.created_at, c.updated_at, c.completed_summary,
|
|
330
|
+
s.name as session_name,
|
|
331
|
+
GROUP_CONCAT(cf.file_path, '|||') as file_paths
|
|
332
|
+
FROM claims c
|
|
333
|
+
LEFT JOIN sessions s ON c.session_id = s.id
|
|
334
|
+
LEFT JOIN claim_files cf ON c.id = cf.claim_id
|
|
335
|
+
WHERE c.id = ?
|
|
336
|
+
GROUP BY c.id`
|
|
337
|
+
)
|
|
338
|
+
.bind(id)
|
|
339
|
+
.first<Claim & { session_name: string | null; file_paths: string | null }>();
|
|
309
340
|
|
|
310
|
-
|
|
341
|
+
if (!result) return null;
|
|
311
342
|
|
|
312
343
|
return {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
344
|
+
id: result.id,
|
|
345
|
+
session_id: result.session_id,
|
|
346
|
+
intent: result.intent,
|
|
347
|
+
scope: result.scope,
|
|
348
|
+
status: result.status,
|
|
349
|
+
created_at: result.created_at,
|
|
350
|
+
updated_at: result.updated_at,
|
|
351
|
+
completed_summary: result.completed_summary,
|
|
352
|
+
files: result.file_paths ? result.file_paths.split('|||') : [],
|
|
353
|
+
session_name: result.session_name,
|
|
316
354
|
};
|
|
317
355
|
}
|
|
318
356
|
|
|
@@ -384,47 +422,187 @@ export async function listClaims(
|
|
|
384
422
|
export async function checkConflicts(
|
|
385
423
|
db: DatabaseAdapter,
|
|
386
424
|
files: string[],
|
|
387
|
-
excludeSessionId?: string
|
|
425
|
+
excludeSessionId?: string,
|
|
426
|
+
symbols?: SymbolClaim[]
|
|
388
427
|
): Promise<ConflictInfo[]> {
|
|
389
428
|
const conflicts: ConflictInfo[] = [];
|
|
390
429
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
c.scope,
|
|
401
|
-
c.created_at
|
|
402
|
-
FROM claim_files cf
|
|
403
|
-
JOIN claims c ON cf.claim_id = c.id
|
|
404
|
-
JOIN sessions s ON c.session_id = s.id
|
|
405
|
-
WHERE c.status = 'active'
|
|
406
|
-
AND s.status = 'active'
|
|
407
|
-
AND (cf.file_path = ? OR (cf.is_pattern = 1 AND ? GLOB cf.file_path))
|
|
408
|
-
`;
|
|
409
|
-
const bindings: string[] = [filePath, filePath];
|
|
410
|
-
|
|
411
|
-
if (excludeSessionId) {
|
|
412
|
-
query += ' AND c.session_id != ?';
|
|
413
|
-
bindings.push(excludeSessionId);
|
|
430
|
+
// Build a map of file -> symbols for quick lookup
|
|
431
|
+
const symbolsByFile = new Map<string, Set<string>>();
|
|
432
|
+
if (symbols && symbols.length > 0) {
|
|
433
|
+
for (const sc of symbols) {
|
|
434
|
+
const existing = symbolsByFile.get(sc.file) ?? new Set();
|
|
435
|
+
for (const sym of sc.symbols) {
|
|
436
|
+
existing.add(sym);
|
|
437
|
+
}
|
|
438
|
+
symbolsByFile.set(sc.file, existing);
|
|
414
439
|
}
|
|
440
|
+
}
|
|
415
441
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
442
|
+
for (const filePath of files) {
|
|
443
|
+
const requestedSymbols = symbolsByFile.get(filePath);
|
|
444
|
+
|
|
445
|
+
// First check if there are symbol-level claims for this file
|
|
446
|
+
if (requestedSymbols && requestedSymbols.size > 0) {
|
|
447
|
+
// Symbol-level conflict check
|
|
448
|
+
let symbolQuery = `
|
|
449
|
+
SELECT
|
|
450
|
+
c.id as claim_id,
|
|
451
|
+
c.session_id,
|
|
452
|
+
s.name as session_name,
|
|
453
|
+
cs.file_path,
|
|
454
|
+
c.intent,
|
|
455
|
+
c.scope,
|
|
456
|
+
c.created_at,
|
|
457
|
+
cs.symbol_name,
|
|
458
|
+
cs.symbol_type
|
|
459
|
+
FROM claim_symbols cs
|
|
460
|
+
JOIN claims c ON cs.claim_id = c.id
|
|
461
|
+
JOIN sessions s ON c.session_id = s.id
|
|
462
|
+
WHERE c.status = 'active'
|
|
463
|
+
AND s.status = 'active'
|
|
464
|
+
AND cs.file_path = ?
|
|
465
|
+
AND cs.symbol_name IN (${Array.from(requestedSymbols).map(() => '?').join(',')})
|
|
466
|
+
`;
|
|
467
|
+
const symbolBindings: string[] = [filePath, ...Array.from(requestedSymbols)];
|
|
468
|
+
|
|
469
|
+
if (excludeSessionId) {
|
|
470
|
+
symbolQuery += ' AND c.session_id != ?';
|
|
471
|
+
symbolBindings.push(excludeSessionId);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const symbolResult = await db
|
|
475
|
+
.prepare(symbolQuery)
|
|
476
|
+
.bind(...symbolBindings)
|
|
477
|
+
.all<ConflictInfo & { symbol_name: string; symbol_type: SymbolType }>();
|
|
478
|
+
|
|
479
|
+
for (const r of symbolResult.results) {
|
|
480
|
+
conflicts.push({
|
|
481
|
+
...r,
|
|
482
|
+
conflict_level: 'symbol',
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Also check if there's a file-level claim (no symbols = whole file claimed)
|
|
487
|
+
let fileClaimQuery = `
|
|
488
|
+
SELECT
|
|
489
|
+
c.id as claim_id,
|
|
490
|
+
c.session_id,
|
|
491
|
+
s.name as session_name,
|
|
492
|
+
cf.file_path,
|
|
493
|
+
c.intent,
|
|
494
|
+
c.scope,
|
|
495
|
+
c.created_at
|
|
496
|
+
FROM claim_files cf
|
|
497
|
+
JOIN claims c ON cf.claim_id = c.id
|
|
498
|
+
JOIN sessions s ON c.session_id = s.id
|
|
499
|
+
WHERE c.status = 'active'
|
|
500
|
+
AND s.status = 'active'
|
|
501
|
+
AND (cf.file_path = ? OR (cf.is_pattern = 1 AND ? GLOB cf.file_path))
|
|
502
|
+
AND NOT EXISTS (
|
|
503
|
+
SELECT 1 FROM claim_symbols cs WHERE cs.claim_id = c.id AND cs.file_path = cf.file_path
|
|
504
|
+
)
|
|
505
|
+
`;
|
|
506
|
+
const fileClaimBindings: string[] = [filePath, filePath];
|
|
507
|
+
|
|
508
|
+
if (excludeSessionId) {
|
|
509
|
+
fileClaimQuery += ' AND c.session_id != ?';
|
|
510
|
+
fileClaimBindings.push(excludeSessionId);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const fileClaimResult = await db
|
|
514
|
+
.prepare(fileClaimQuery)
|
|
515
|
+
.bind(...fileClaimBindings)
|
|
516
|
+
.all<Omit<ConflictInfo, 'conflict_level'>>();
|
|
517
|
+
|
|
518
|
+
for (const r of fileClaimResult.results) {
|
|
519
|
+
conflicts.push({
|
|
520
|
+
...r,
|
|
521
|
+
conflict_level: 'file',
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
} else {
|
|
525
|
+
// No symbols specified - check both file-level and symbol-level claims
|
|
526
|
+
// File-level claims (whole file)
|
|
527
|
+
let fileQuery = `
|
|
528
|
+
SELECT
|
|
529
|
+
c.id as claim_id,
|
|
530
|
+
c.session_id,
|
|
531
|
+
s.name as session_name,
|
|
532
|
+
cf.file_path,
|
|
533
|
+
c.intent,
|
|
534
|
+
c.scope,
|
|
535
|
+
c.created_at
|
|
536
|
+
FROM claim_files cf
|
|
537
|
+
JOIN claims c ON cf.claim_id = c.id
|
|
538
|
+
JOIN sessions s ON c.session_id = s.id
|
|
539
|
+
WHERE c.status = 'active'
|
|
540
|
+
AND s.status = 'active'
|
|
541
|
+
AND (cf.file_path = ? OR (cf.is_pattern = 1 AND ? GLOB cf.file_path))
|
|
542
|
+
`;
|
|
543
|
+
const fileBindings: string[] = [filePath, filePath];
|
|
544
|
+
|
|
545
|
+
if (excludeSessionId) {
|
|
546
|
+
fileQuery += ' AND c.session_id != ?';
|
|
547
|
+
fileBindings.push(excludeSessionId);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const fileResult = await db
|
|
551
|
+
.prepare(fileQuery)
|
|
552
|
+
.bind(...fileBindings)
|
|
553
|
+
.all<Omit<ConflictInfo, 'conflict_level'>>();
|
|
554
|
+
|
|
555
|
+
for (const r of fileResult.results) {
|
|
556
|
+
conflicts.push({
|
|
557
|
+
...r,
|
|
558
|
+
conflict_level: 'file',
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Symbol-level claims on this file
|
|
563
|
+
let symbolOnlyQuery = `
|
|
564
|
+
SELECT DISTINCT
|
|
565
|
+
c.id as claim_id,
|
|
566
|
+
c.session_id,
|
|
567
|
+
s.name as session_name,
|
|
568
|
+
cs.file_path,
|
|
569
|
+
c.intent,
|
|
570
|
+
c.scope,
|
|
571
|
+
c.created_at,
|
|
572
|
+
cs.symbol_name,
|
|
573
|
+
cs.symbol_type
|
|
574
|
+
FROM claim_symbols cs
|
|
575
|
+
JOIN claims c ON cs.claim_id = c.id
|
|
576
|
+
JOIN sessions s ON c.session_id = s.id
|
|
577
|
+
WHERE c.status = 'active'
|
|
578
|
+
AND s.status = 'active'
|
|
579
|
+
AND cs.file_path = ?
|
|
580
|
+
`;
|
|
581
|
+
const symbolOnlyBindings: string[] = [filePath];
|
|
582
|
+
|
|
583
|
+
if (excludeSessionId) {
|
|
584
|
+
symbolOnlyQuery += ' AND c.session_id != ?';
|
|
585
|
+
symbolOnlyBindings.push(excludeSessionId);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const symbolOnlyResult = await db
|
|
589
|
+
.prepare(symbolOnlyQuery)
|
|
590
|
+
.bind(...symbolOnlyBindings)
|
|
591
|
+
.all<ConflictInfo & { symbol_name: string; symbol_type: SymbolType }>();
|
|
592
|
+
|
|
593
|
+
for (const r of symbolOnlyResult.results) {
|
|
594
|
+
conflicts.push({
|
|
595
|
+
...r,
|
|
596
|
+
conflict_level: 'symbol',
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
422
600
|
}
|
|
423
601
|
|
|
424
|
-
// Deduplicate by claim_id + file_path
|
|
602
|
+
// Deduplicate by claim_id + file_path + symbol_name
|
|
425
603
|
const seen = new Set<string>();
|
|
426
604
|
return conflicts.filter((c) => {
|
|
427
|
-
const key = `${c.claim_id}:${c.file_path}`;
|
|
605
|
+
const key = `${c.claim_id}:${c.file_path}:${c.symbol_name ?? ''}`;
|
|
428
606
|
if (seen.has(key)) return false;
|
|
429
607
|
seen.add(key);
|
|
430
608
|
return true;
|
|
@@ -575,3 +753,158 @@ export async function listDecisions(
|
|
|
575
753
|
|
|
576
754
|
return result.results;
|
|
577
755
|
}
|
|
756
|
+
|
|
757
|
+
// ============ Reference Queries ============
|
|
758
|
+
|
|
759
|
+
import type { ReferenceInput, SymbolReference, ImpactInfo } from './types';
|
|
760
|
+
|
|
761
|
+
export async function storeReferences(
|
|
762
|
+
db: DatabaseAdapter,
|
|
763
|
+
sessionId: string,
|
|
764
|
+
references: ReferenceInput[]
|
|
765
|
+
): Promise<{ stored: number; skipped: number }> {
|
|
766
|
+
let stored = 0;
|
|
767
|
+
let skipped = 0;
|
|
768
|
+
const now = new Date().toISOString();
|
|
769
|
+
|
|
770
|
+
for (const ref of references) {
|
|
771
|
+
for (const r of ref.references) {
|
|
772
|
+
try {
|
|
773
|
+
await db
|
|
774
|
+
.prepare(
|
|
775
|
+
`INSERT OR IGNORE INTO symbol_references
|
|
776
|
+
(source_file, source_symbol, ref_file, ref_line, ref_context, session_id, created_at)
|
|
777
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
778
|
+
)
|
|
779
|
+
.bind(ref.source_file, ref.source_symbol, r.file, r.line, r.context ?? null, sessionId, now)
|
|
780
|
+
.run();
|
|
781
|
+
stored++;
|
|
782
|
+
} catch {
|
|
783
|
+
skipped++;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return { stored, skipped };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export async function getReferencesForSymbol(
|
|
792
|
+
db: DatabaseAdapter,
|
|
793
|
+
sourceFile: string,
|
|
794
|
+
sourceSymbol: string
|
|
795
|
+
): Promise<SymbolReference[]> {
|
|
796
|
+
const result = await db
|
|
797
|
+
.prepare(
|
|
798
|
+
`SELECT * FROM symbol_references
|
|
799
|
+
WHERE source_file = ? AND source_symbol = ?
|
|
800
|
+
ORDER BY ref_file, ref_line`
|
|
801
|
+
)
|
|
802
|
+
.bind(sourceFile, sourceSymbol)
|
|
803
|
+
.all<SymbolReference>();
|
|
804
|
+
|
|
805
|
+
return result.results;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export async function getReferencesToFile(
|
|
809
|
+
db: DatabaseAdapter,
|
|
810
|
+
filePath: string
|
|
811
|
+
): Promise<SymbolReference[]> {
|
|
812
|
+
const result = await db
|
|
813
|
+
.prepare(
|
|
814
|
+
`SELECT * FROM symbol_references
|
|
815
|
+
WHERE ref_file = ?
|
|
816
|
+
ORDER BY source_file, source_symbol`
|
|
817
|
+
)
|
|
818
|
+
.bind(filePath)
|
|
819
|
+
.all<SymbolReference>();
|
|
820
|
+
|
|
821
|
+
return result.results;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export async function analyzeClaimImpact(
|
|
825
|
+
db: DatabaseAdapter,
|
|
826
|
+
sourceFile: string,
|
|
827
|
+
sourceSymbol: string,
|
|
828
|
+
excludeSessionId?: string
|
|
829
|
+
): Promise<ImpactInfo> {
|
|
830
|
+
// Get all references to this symbol
|
|
831
|
+
const refs = await getReferencesForSymbol(db, sourceFile, sourceSymbol);
|
|
832
|
+
|
|
833
|
+
// Get unique files that reference this symbol
|
|
834
|
+
const affectedFiles = [...new Set(refs.map((r) => r.ref_file))];
|
|
835
|
+
|
|
836
|
+
// Check if any of these files have active claims
|
|
837
|
+
const affectedClaims: ImpactInfo['affected_claims'] = [];
|
|
838
|
+
|
|
839
|
+
if (affectedFiles.length > 0) {
|
|
840
|
+
const placeholders = affectedFiles.map(() => '?').join(',');
|
|
841
|
+
let query = `
|
|
842
|
+
SELECT DISTINCT
|
|
843
|
+
c.id as claim_id,
|
|
844
|
+
s.name as session_name,
|
|
845
|
+
c.intent,
|
|
846
|
+
cf.file_path
|
|
847
|
+
FROM claim_files cf
|
|
848
|
+
JOIN claims c ON cf.claim_id = c.id
|
|
849
|
+
JOIN sessions s ON c.session_id = s.id
|
|
850
|
+
WHERE c.status = 'active'
|
|
851
|
+
AND s.status = 'active'
|
|
852
|
+
AND cf.file_path IN (${placeholders})
|
|
853
|
+
`;
|
|
854
|
+
const bindings: string[] = [...affectedFiles];
|
|
855
|
+
|
|
856
|
+
if (excludeSessionId) {
|
|
857
|
+
query += ' AND c.session_id != ?';
|
|
858
|
+
bindings.push(excludeSessionId);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const claimResults = await db
|
|
862
|
+
.prepare(query)
|
|
863
|
+
.bind(...bindings)
|
|
864
|
+
.all<{ claim_id: string; session_name: string | null; intent: string; file_path: string }>();
|
|
865
|
+
|
|
866
|
+
// Group by claim
|
|
867
|
+
const claimMap = new Map<string, { session_name: string | null; intent: string; files: string[] }>();
|
|
868
|
+
for (const r of claimResults.results) {
|
|
869
|
+
const existing = claimMap.get(r.claim_id);
|
|
870
|
+
if (existing) {
|
|
871
|
+
existing.files.push(r.file_path);
|
|
872
|
+
} else {
|
|
873
|
+
claimMap.set(r.claim_id, {
|
|
874
|
+
session_name: r.session_name,
|
|
875
|
+
intent: r.intent,
|
|
876
|
+
files: [r.file_path],
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
for (const [claimId, data] of claimMap) {
|
|
882
|
+
affectedClaims.push({
|
|
883
|
+
claim_id: claimId,
|
|
884
|
+
session_name: data.session_name,
|
|
885
|
+
intent: data.intent,
|
|
886
|
+
affected_symbols: data.files,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
symbol: sourceSymbol,
|
|
893
|
+
file: sourceFile,
|
|
894
|
+
affected_claims: affectedClaims,
|
|
895
|
+
reference_count: refs.length,
|
|
896
|
+
affected_files: affectedFiles,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export async function clearSessionReferences(
|
|
901
|
+
db: DatabaseAdapter,
|
|
902
|
+
sessionId: string
|
|
903
|
+
): Promise<number> {
|
|
904
|
+
const result = await db
|
|
905
|
+
.prepare('DELETE FROM symbol_references WHERE session_id = ?')
|
|
906
|
+
.bind(sessionId)
|
|
907
|
+
.run();
|
|
908
|
+
|
|
909
|
+
return result.meta.changes;
|
|
910
|
+
}
|