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.
@@ -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
- await db.batch([claimStatement, ...fileStatements]);
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
- const claim = await db.prepare('SELECT * FROM claims WHERE id = ?').bind(id).first<Claim>();
305
-
306
- if (!claim) return null;
307
-
308
- const files = await db.prepare('SELECT file_path FROM claim_files WHERE claim_id = ?').bind(id).all<{ file_path: string }>();
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
- const session = await db.prepare('SELECT name FROM sessions WHERE id = ?').bind(claim.session_id).first<{ name: string | null }>();
341
+ if (!result) return null;
311
342
 
312
343
  return {
313
- ...claim,
314
- files: files.results.map((f) => f.file_path),
315
- session_name: session?.name ?? null,
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
- for (const filePath of files) {
392
- // Check for exact matches or pattern overlaps
393
- let query = `
394
- SELECT
395
- c.id as claim_id,
396
- c.session_id,
397
- s.name as session_name,
398
- cf.file_path,
399
- c.intent,
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
- const result = await db
417
- .prepare(query)
418
- .bind(...bindings)
419
- .all<ConflictInfo>();
420
-
421
- conflicts.push(...result.results);
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
+ }