opencode-telos 1.0.0 → 1.0.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.
Files changed (46) hide show
  1. package/dist/code-intelligence/ast/cache.d.ts.map +1 -1
  2. package/dist/code-intelligence/ast/cache.js +8 -5
  3. package/dist/code-intelligence/ast/cache.js.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/opencode/hooks.d.ts +1 -1
  8. package/dist/opencode/hooks.d.ts.map +1 -1
  9. package/dist/opencode/hooks.js +34 -40
  10. package/dist/opencode/hooks.js.map +1 -1
  11. package/dist/opencode/tools.d.ts.map +1 -1
  12. package/dist/opencode/tools.js +9 -8
  13. package/dist/opencode/tools.js.map +1 -1
  14. package/dist/sdd/cache/atomic.d.ts +3 -0
  15. package/dist/sdd/cache/atomic.d.ts.map +1 -0
  16. package/dist/sdd/cache/atomic.js +35 -0
  17. package/dist/sdd/cache/atomic.js.map +1 -0
  18. package/dist/sdd/cache/fingerprint.d.ts +18 -0
  19. package/dist/sdd/cache/fingerprint.d.ts.map +1 -0
  20. package/dist/sdd/cache/fingerprint.js +99 -0
  21. package/dist/sdd/cache/fingerprint.js.map +1 -0
  22. package/dist/sdd/cache/manager.d.ts +24 -10
  23. package/dist/sdd/cache/manager.d.ts.map +1 -1
  24. package/dist/sdd/cache/manager.js +184 -33
  25. package/dist/sdd/cache/manager.js.map +1 -1
  26. package/dist/sdd/cache/snapshot-store.d.ts +2 -1
  27. package/dist/sdd/cache/snapshot-store.d.ts.map +1 -1
  28. package/dist/sdd/cache/snapshot-store.js +13 -6
  29. package/dist/sdd/cache/snapshot-store.js.map +1 -1
  30. package/dist/sdd/persistence/sqlite.d.ts.map +1 -1
  31. package/dist/sdd/persistence/sqlite.js +66 -41
  32. package/dist/sdd/persistence/sqlite.js.map +1 -1
  33. package/dist/sdd/persistence/yaml.d.ts.map +1 -1
  34. package/dist/sdd/persistence/yaml.js +33 -25
  35. package/dist/sdd/persistence/yaml.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/code-intelligence/ast/cache.ts +11 -6
  38. package/src/index.ts +4 -1
  39. package/src/opencode/hooks.ts +34 -39
  40. package/src/opencode/tools.ts +9 -8
  41. package/src/sdd/cache/atomic.ts +30 -0
  42. package/src/sdd/cache/fingerprint.ts +98 -0
  43. package/src/sdd/cache/manager.ts +182 -39
  44. package/src/sdd/cache/snapshot-store.ts +14 -7
  45. package/src/sdd/persistence/sqlite.ts +50 -24
  46. package/src/sdd/persistence/yaml.ts +40 -26
@@ -1,10 +1,12 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs"
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, statSync, openSync, fsyncSync, closeSync } from "fs"
2
2
  import { join, dirname } from "path"
3
3
  import { createHash } from "crypto"
4
4
  import { PerTypeGraphCache, IncrementalGraphHash } from "../graph/index.js"
5
5
  import type { AnyNode, NodeType, Relationship } from "../domain/types.js"
6
6
  import { getGraphSnapshotStore } from "./snapshot-store.js"
7
7
  import type { KnowledgeGraph } from "../domain/types.js"
8
+ import { atomicWriteFile } from "./atomic.js"
9
+ import { configFingerprint, graphFingerprint, sourceFingerprint } from "./fingerprint.js"
8
10
 
9
11
  // ── Cache Entry Types ────────────────────────────────────────────────
10
12
 
@@ -22,15 +24,18 @@ interface ToolCacheEntry {
22
24
  lastAccess: number
23
25
  toolName: string
24
26
  argsHash: string
25
- graphVersion: string
27
+ graphFingerprint: string
28
+ configFingerprint: string
29
+ sourceFingerprint?: string
26
30
  }
27
31
 
28
32
  interface AnalysisCacheEntry {
29
33
  result: unknown
30
34
  timestamp: number
31
35
  lastAccess: number
32
- graphVersion: number
33
- nodeCount: number
36
+ graphFingerprint: string
37
+ configFingerprint: string
38
+ sourceFingerprint?: string
34
39
  type: string
35
40
  }
36
41
 
@@ -38,13 +43,23 @@ interface PersistentCacheData {
38
43
  version: number
39
44
  toolResponses: Record<string, ToolCacheEntry>
40
45
  analysisResults: Record<string, AnalysisCacheEntry>
41
- graphSnapshot: {
46
+ graphSnapshot?: {
42
47
  nodeCount: number
43
48
  relationshipCount: number
44
49
  version: string
45
50
  lastModified: number
46
51
  } | null
47
- graphHash: string
52
+ graphHash?: string
53
+ configFingerprint?: string
54
+ }
55
+
56
+ interface InvalidationEvent {
57
+ id: string
58
+ timestamp: number
59
+ pid: number
60
+ nodeTypes: string[]
61
+ relTypes: string[]
62
+ full: boolean
48
63
  }
49
64
 
50
65
  // ── Granular Invalidation Tracking ───────────────────────────────────
@@ -63,6 +78,8 @@ const TOOL_RESPONSE_TTL = 5 * 60 * 1000 // 5 minutes
63
78
  const ANALYSIS_TTL = 3 * 60 * 1000 // 3 minutes
64
79
  const PERSISTENT_CACHE_FILE = ".sdd/cache.json"
65
80
  const INVALIDATION_FILE = ".sdd/cache-invalidated.json"
81
+ const INVALIDATION_JOURNAL_FILE = ".sdd/cache-events.jsonl"
82
+ const INVALIDATION_JOURNAL_MAX_BYTES = 1024 * 1024
66
83
 
67
84
  export class CacheManager {
68
85
  private projectDir: string
@@ -88,6 +105,9 @@ export class CacheManager {
88
105
 
89
106
  // Incremental graph hash
90
107
  private graphHash = new IncrementalGraphHash()
108
+ private sourceFingerprintCache: { signature: string; fingerprint: string } | null = null
109
+ private invalidationJournalOffset = 0
110
+ private invalidationEventCounter = 0
91
111
 
92
112
  // Statistics
93
113
  private stats = {
@@ -102,6 +122,7 @@ export class CacheManager {
102
122
  this.projectDir = projectDir
103
123
  this.loadInvalidationTracker()
104
124
  this.invalidationVersionOnWrite = this.invalidation.version
125
+ try { this.invalidationJournalOffset = statSync(join(projectDir, INVALIDATION_JOURNAL_FILE)).size } catch {}
105
126
  }
106
127
 
107
128
  // ── Tool Response Cache ───────────────────────────────────────────
@@ -111,7 +132,8 @@ export class CacheManager {
111
132
  * Checks: TTL (using lastAccess for freshness), graph version, invalidation status.
112
133
  * F: Uses lazy revalidation — checks version before clearing.
113
134
  */
114
- getToolResponse(toolName: string, args: Record<string, unknown>, graphVersion: string): string | null {
135
+ getToolResponse(toolName: string, args: Record<string, unknown>, currentGraphFingerprint: string): string | null {
136
+ this.refreshExternalInvalidation()
115
137
  const key = this.toolCacheKey(toolName, args)
116
138
  const entry = this.toolResponses.get(key)
117
139
 
@@ -128,6 +150,7 @@ export class CacheManager {
128
150
  this.stats.toolMisses++
129
151
  return null
130
152
  }
153
+ this.invalidationVersionOnWrite = this.invalidation.version
131
154
  }
132
155
 
133
156
  // B: Check TTL using lastAccess (not timestamp) — entries accessed recently survive restore
@@ -139,7 +162,14 @@ export class CacheManager {
139
162
  }
140
163
 
141
164
  // Check graph version consistency
142
- if (entry.graphVersion !== graphVersion) {
165
+ const currentConfigFingerprint = configFingerprint(this.projectDir)
166
+ if (entry.graphFingerprint !== currentGraphFingerprint || entry.configFingerprint !== currentConfigFingerprint) {
167
+ this.toolResponses.delete(key)
168
+ this.stats.toolMisses++
169
+ return null
170
+ }
171
+
172
+ if (this.toolNeedsSource(toolName) && entry.sourceFingerprint !== this.getSourceFingerprint()) {
143
173
  this.toolResponses.delete(key)
144
174
  this.stats.toolMisses++
145
175
  return null
@@ -154,7 +184,7 @@ export class CacheManager {
154
184
  /**
155
185
  * Cache a tool response.
156
186
  */
157
- setToolResponse(toolName: string, args: Record<string, unknown>, response: string, graphVersion: string): void {
187
+ setToolResponse(toolName: string, args: Record<string, unknown>, response: string, currentGraphFingerprint: string): void {
158
188
  const key = this.toolCacheKey(toolName, args)
159
189
  const now = Date.now()
160
190
  this.toolResponses.set(key, {
@@ -163,7 +193,9 @@ export class CacheManager {
163
193
  lastAccess: now,
164
194
  toolName,
165
195
  argsHash: this.toolCacheKey(toolName, args).split(':')[1] || '',
166
- graphVersion,
196
+ graphFingerprint: currentGraphFingerprint,
197
+ configFingerprint: configFingerprint(this.projectDir),
198
+ sourceFingerprint: this.toolNeedsSource(toolName) ? this.getSourceFingerprint() : undefined,
167
199
  })
168
200
 
169
201
  // Evict old entries if cache is too large
@@ -179,7 +211,8 @@ export class CacheManager {
179
211
  * Revalidates based on graph state.
180
212
  * F: Uses lazy revalidation.
181
213
  */
182
- getAnalysisResult(type: string, graphVersion: number, nodeCount: number): unknown | null {
214
+ getAnalysisResult(type: string, currentGraphFingerprint: string): unknown | null {
215
+ this.refreshExternalInvalidation()
183
216
  const key = `analysis:${type}`
184
217
  const entry = this.analysisResults.get(key)
185
218
 
@@ -196,6 +229,7 @@ export class CacheManager {
196
229
  this.stats.analysisMisses++
197
230
  return null
198
231
  }
232
+ this.invalidationVersionOnWrite = this.invalidation.version
199
233
  }
200
234
 
201
235
  // B: Check TTL using lastAccess
@@ -206,8 +240,14 @@ export class CacheManager {
206
240
  return null
207
241
  }
208
242
 
209
- // Revalidation: if graph changed since analysis, invalidate
210
- if (entry.graphVersion !== graphVersion || entry.nodeCount !== nodeCount) {
243
+ // Revalidation: graph and configuration content must match exactly.
244
+ if (entry.graphFingerprint !== currentGraphFingerprint || entry.configFingerprint !== configFingerprint(this.projectDir)) {
245
+ this.analysisResults.delete(key)
246
+ this.stats.analysisMisses++
247
+ return null
248
+ }
249
+
250
+ if (this.analysisNeedsSource(type) && entry.sourceFingerprint !== this.getSourceFingerprint()) {
211
251
  this.analysisResults.delete(key)
212
252
  this.stats.analysisMisses++
213
253
  return null
@@ -222,15 +262,16 @@ export class CacheManager {
222
262
  /**
223
263
  * Cache an analysis result.
224
264
  */
225
- setAnalysisResult(type: string, result: unknown, graphVersion: number, nodeCount: number): void {
265
+ setAnalysisResult(type: string, result: unknown, currentGraphFingerprint: string): void {
226
266
  const key = `analysis:${type}`
227
267
  const now = Date.now()
228
268
  this.analysisResults.set(key, {
229
269
  result,
230
270
  timestamp: now,
231
271
  lastAccess: now,
232
- graphVersion,
233
- nodeCount,
272
+ graphFingerprint: currentGraphFingerprint,
273
+ configFingerprint: configFingerprint(this.projectDir),
274
+ sourceFingerprint: this.analysisNeedsSource(type) ? this.getSourceFingerprint() : undefined,
234
275
  type,
235
276
  })
236
277
  }
@@ -246,6 +287,7 @@ export class CacheManager {
246
287
  }
247
288
  this.invalidation.version++
248
289
  this.saveInvalidationTracker()
290
+ this.appendInvalidationEvent({ nodeTypes: [], relTypes: [], full: false })
249
291
  }
250
292
 
251
293
  /**
@@ -257,6 +299,7 @@ export class CacheManager {
257
299
  }
258
300
  this.invalidation.version++
259
301
  this.saveInvalidationTracker()
302
+ this.appendInvalidationEvent({ nodeTypes: [], relTypes: types, full: false })
260
303
  }
261
304
 
262
305
  /**
@@ -274,6 +317,7 @@ export class CacheManager {
274
317
  this.invalidation.version++
275
318
  this.stats.invalidations++
276
319
  this.saveInvalidationTracker()
320
+ this.appendInvalidationEvent({ nodeTypes: [], relTypes: [], full: true })
277
321
  }
278
322
 
279
323
  /**
@@ -322,6 +366,7 @@ export class CacheManager {
322
366
  this.invalidation.dirtyTypes.add(nodeType)
323
367
  this.invalidation.version++
324
368
  this.saveInvalidationTracker()
369
+ this.appendInvalidationEvent({ nodeTypes: [nodeType], relTypes: [], full: false })
325
370
  }
326
371
 
327
372
  // ── Persistent Cache (Cross-Session) ──────────────────────────────
@@ -338,6 +383,10 @@ export class CacheManager {
338
383
  if (Date.now() - (data.timestamp || 0) > 60 * 60 * 1000) {
339
384
  return null
340
385
  }
386
+ if (data.version !== 2 || !data.toolResponses || !data.analysisResults ||
387
+ typeof data.toolResponses !== "object" || typeof data.analysisResults !== "object") {
388
+ return null
389
+ }
341
390
  return data
342
391
  } catch {
343
392
  return null
@@ -351,7 +400,7 @@ export class CacheManager {
351
400
  const path = join(this.projectDir, PERSISTENT_CACHE_FILE)
352
401
  const dir = dirname(path)
353
402
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
354
- writeFileSync(path, JSON.stringify({ ...data, timestamp: Date.now() }, null, 2))
403
+ atomicWriteFile(path, JSON.stringify({ ...data, timestamp: Date.now() }, null, 2))
355
404
  }
356
405
 
357
406
  /**
@@ -373,7 +422,9 @@ export class CacheManager {
373
422
  lastAccess: entry.lastAccess || entry.timestamp,
374
423
  toolName: entry.toolName,
375
424
  argsHash: entry.argsHash,
376
- graphVersion: entry.graphVersion,
425
+ graphFingerprint: entry.graphFingerprint,
426
+ configFingerprint: entry.configFingerprint || "",
427
+ sourceFingerprint: entry.sourceFingerprint,
377
428
  })
378
429
  restored++
379
430
  }
@@ -386,8 +437,9 @@ export class CacheManager {
386
437
  result: entry.result,
387
438
  timestamp: entry.timestamp,
388
439
  lastAccess: entry.lastAccess || entry.timestamp,
389
- graphVersion: entry.graphVersion,
390
- nodeCount: entry.nodeCount,
440
+ graphFingerprint: entry.graphFingerprint,
441
+ configFingerprint: entry.configFingerprint || "",
442
+ sourceFingerprint: entry.sourceFingerprint,
391
443
  type: key.replace("analysis:", ""),
392
444
  })
393
445
  restored++
@@ -409,7 +461,9 @@ export class CacheManager {
409
461
  lastAccess: entry.lastAccess,
410
462
  toolName: entry.toolName,
411
463
  argsHash: entry.argsHash,
412
- graphVersion: entry.graphVersion,
464
+ graphFingerprint: entry.graphFingerprint,
465
+ configFingerprint: entry.configFingerprint,
466
+ sourceFingerprint: entry.sourceFingerprint,
413
467
  }
414
468
  }
415
469
 
@@ -419,8 +473,9 @@ export class CacheManager {
419
473
  result: entry.result,
420
474
  timestamp: entry.timestamp,
421
475
  lastAccess: entry.lastAccess,
422
- graphVersion: entry.graphVersion,
423
- nodeCount: entry.nodeCount,
476
+ graphFingerprint: entry.graphFingerprint,
477
+ configFingerprint: entry.configFingerprint,
478
+ sourceFingerprint: entry.sourceFingerprint,
424
479
  type: key.replace("analysis:", ""),
425
480
  }
426
481
  }
@@ -429,8 +484,7 @@ export class CacheManager {
429
484
  version: 2,
430
485
  toolResponses,
431
486
  analysisResults,
432
- graphSnapshot: null,
433
- graphHash: this.graphHash.getHash(),
487
+ configFingerprint: configFingerprint(this.projectDir),
434
488
  })
435
489
  }
436
490
 
@@ -438,10 +492,10 @@ export class CacheManager {
438
492
  * Save graph snapshot to disk (G).
439
493
  * Called after graph mutations and on dispose.
440
494
  */
441
- saveGraphSnapshot(graph: KnowledgeGraph): void {
495
+ saveGraphSnapshot(graph: KnowledgeGraph, sourceSignature = ""): void {
442
496
  try {
443
497
  const store = getGraphSnapshotStore(this.projectDir)
444
- store.save(graph, this.graphHash.getHash())
498
+ store.save(graph, graphFingerprint(graph), sourceSignature)
445
499
  } catch {}
446
500
  }
447
501
 
@@ -449,7 +503,7 @@ export class CacheManager {
449
503
  * Load graph snapshot from disk (G).
450
504
  * Returns null if no valid snapshot exists.
451
505
  */
452
- loadGraphSnapshot(): { graph: KnowledgeGraph; graphHash: string } | null {
506
+ loadGraphSnapshot(): { graph: KnowledgeGraph; graphHash: string; sourceSignature: string } | null {
453
507
  try {
454
508
  const store = getGraphSnapshotStore(this.projectDir)
455
509
  return store.load()
@@ -475,6 +529,7 @@ export class CacheManager {
475
529
  * Uses file-based locking + PID liveness check for robust cross-process coordination.
476
530
  */
477
531
  checkCrossProcessInvalidation(graphPath: string): boolean {
532
+ if (this.refreshExternalInvalidation()) return true
478
533
  const lockPath = join(this.projectDir, ".sdd", ".cache-lock")
479
534
  try {
480
535
  if (existsSync(lockPath)) {
@@ -516,21 +571,26 @@ export class CacheManager {
516
571
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
517
572
 
518
573
  try {
519
- // Try to create lock file
520
574
  if (existsSync(lockPath)) {
521
575
  const existing = JSON.parse(readFileSync(lockPath, "utf-8"))
522
- // If lock is stale (>30s), override it
523
- if (Date.now() - existing.timestamp > 30000) {
524
- // Stale lock, ok to override
525
- } else if (existing.pid !== process.pid) {
526
- return false // Another process holds the lock
576
+ if (existing.pid === process.pid) return true
577
+ if (Date.now() - existing.timestamp <= 30000) return false
578
+ try {
579
+ if (existing.pid) process.kill(existing.pid, 0)
580
+ return false
581
+ } catch {
582
+ try { unlinkSync(lockPath) } catch { return false }
527
583
  }
528
584
  }
529
585
 
530
- writeFileSync(lockPath, JSON.stringify({
531
- pid: process.pid,
532
- timestamp: Date.now(),
533
- }))
586
+ // wx/O_EXCL makes acquisition atomic when two OpenCode processes write together.
587
+ const descriptor = openSync(lockPath, "wx", 0o600)
588
+ try {
589
+ writeFileSync(descriptor, JSON.stringify({ pid: process.pid, timestamp: Date.now() }), "utf-8")
590
+ fsyncSync(descriptor)
591
+ } finally {
592
+ closeSync(descriptor)
593
+ }
534
594
  return true
535
595
  } catch {
536
596
  return false
@@ -572,6 +632,7 @@ export class CacheManager {
572
632
  this.invalidation.version++
573
633
  this.stats.invalidations++
574
634
  this.saveInvalidationTracker()
635
+ this.appendInvalidationEvent({ nodeTypes: [], relTypes: [], full: true })
575
636
 
576
637
  // Clear persistent cache on disk
577
638
  let disk = false
@@ -620,6 +681,47 @@ export class CacheManager {
620
681
  this.invalidationVersionOnWrite = this.invalidation.version
621
682
  }
622
683
 
684
+ /** Refresh cache state from durable invalidation events written by another process. */
685
+ refreshExternalInvalidation(): boolean {
686
+ const journalPath = join(this.projectDir, INVALIDATION_JOURNAL_FILE)
687
+ if (!existsSync(journalPath)) return false
688
+
689
+ try {
690
+ const size = statSync(journalPath).size
691
+ if (size < this.invalidationJournalOffset) this.invalidationJournalOffset = 0
692
+ if (size === this.invalidationJournalOffset) return false
693
+
694
+ const content = readFileSync(journalPath)
695
+ const start = Math.min(this.invalidationJournalOffset, content.byteLength)
696
+ const recent = content.subarray(start).toString("utf-8")
697
+ const lastCompleteLine = recent.lastIndexOf("\n")
698
+ if (lastCompleteLine < 0) return false
699
+ this.invalidationJournalOffset = start + Buffer.byteLength(recent.slice(0, lastCompleteLine + 1))
700
+ let invalidated = false
701
+ for (const line of recent.slice(0, lastCompleteLine).split("\n")) {
702
+ if (!line.trim()) continue
703
+ let event: InvalidationEvent
704
+ try { event = JSON.parse(line) as InvalidationEvent } catch { invalidated = true; continue }
705
+ if (event.pid === process.pid) continue
706
+ invalidated = true
707
+ }
708
+ if (!invalidated) return false
709
+
710
+ this.toolResponses.clear()
711
+ this.analysisResults.clear()
712
+ this.graphCache.invalidateAll()
713
+ this.invalidation.version++
714
+ this.invalidationVersionOnWrite = this.invalidation.version
715
+ return true
716
+ } catch {
717
+ // A corrupt journal must never make the plugin unusable; force safe misses.
718
+ this.toolResponses.clear()
719
+ this.analysisResults.clear()
720
+ this.graphCache.invalidateAll()
721
+ return true
722
+ }
723
+ }
724
+
623
725
  // ── Per-Type Graph Cache ──────────────────────────────────────────
624
726
 
625
727
  /**
@@ -656,6 +758,7 @@ export class CacheManager {
656
758
  }
657
759
  this.invalidation.version++
658
760
  this.saveInvalidationTracker()
761
+ this.appendInvalidationEvent({ nodeTypes: types, relTypes: [], full: false })
659
762
  }
660
763
 
661
764
  /**
@@ -875,7 +978,7 @@ export class CacheManager {
875
978
  const path = join(this.projectDir, INVALIDATION_FILE)
876
979
  const dir = dirname(path)
877
980
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
878
- writeFileSync(path, JSON.stringify({
981
+ atomicWriteFile(path, JSON.stringify({
879
982
  lastFullInvalidation: this.invalidation.lastFullInvalidation,
880
983
  version: this.invalidation.version,
881
984
  dirtyTypes: [...this.invalidation.dirtyTypes],
@@ -883,6 +986,46 @@ export class CacheManager {
883
986
  dirtyRelTypes: [...this.invalidation.dirtyRelTypes],
884
987
  }))
885
988
  }
989
+
990
+ private appendInvalidationEvent(change: { nodeTypes: string[]; relTypes: string[]; full: boolean }): void {
991
+ const path = join(this.projectDir, INVALIDATION_JOURNAL_FILE)
992
+ const event: InvalidationEvent = {
993
+ id: `${Date.now()}-${process.pid}-${++this.invalidationEventCounter}`,
994
+ timestamp: Date.now(),
995
+ pid: process.pid,
996
+ ...change,
997
+ }
998
+ try {
999
+ const directory = dirname(path)
1000
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true })
1001
+ if (existsSync(path) && statSync(path).size > INVALIDATION_JOURNAL_MAX_BYTES) {
1002
+ atomicWriteFile(path, `${JSON.stringify(event)}\n`)
1003
+ } else {
1004
+ const descriptor = openSync(path, "a", 0o600)
1005
+ try {
1006
+ writeFileSync(descriptor, `${JSON.stringify(event)}\n`, "utf-8")
1007
+ fsyncSync(descriptor)
1008
+ } finally {
1009
+ closeSync(descriptor)
1010
+ }
1011
+ }
1012
+ this.invalidationJournalOffset = statSync(path).size
1013
+ } catch {}
1014
+ }
1015
+
1016
+ private toolNeedsSource(toolName: string): boolean {
1017
+ return toolName === "sdd.detect_drift" || toolName === "sdd.quality" || toolName === "sdd.coverage"
1018
+ }
1019
+
1020
+ private analysisNeedsSource(type: string): boolean {
1021
+ return type === "drift" || type === "quality" || type === "coverage"
1022
+ }
1023
+
1024
+ private getSourceFingerprint(): string {
1025
+ const current = sourceFingerprint(this.projectDir, this.sourceFingerprintCache)
1026
+ this.sourceFingerprintCache = current
1027
+ return current.fingerprint
1028
+ }
886
1029
  }
887
1030
 
888
1031
  // ── Singleton per project ────────────────────────────────────────────
@@ -7,9 +7,11 @@
7
7
  * Solução G: Snapshot persistente do grafo.
8
8
  */
9
9
 
10
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
10
+ import { existsSync, readFileSync, mkdirSync } from "fs"
11
11
  import { join, dirname } from "path"
12
12
  import type { KnowledgeGraph, AnyNode, Relationship } from "../domain/types.js"
13
+ import { atomicWriteFile } from "./atomic.js"
14
+ import { graphFingerprint } from "./fingerprint.js"
13
15
 
14
16
  const SNAPSHOT_FILE = ".sdd/graph-cache.json"
15
17
  const SNAPSHOT_MAX_AGE_MS = 60 * 60 * 1000 // 1 hour
@@ -31,6 +33,7 @@ interface SerializedSnapshot {
31
33
  graph: KnowledgeGraph
32
34
  indices: SerializedIndices
33
35
  graphHash: string
36
+ sourceSignature: string
34
37
  }
35
38
 
36
39
  export class GraphSnapshotStore {
@@ -43,27 +46,28 @@ export class GraphSnapshotStore {
43
46
  /**
44
47
  * Save full graph + indices to disk.
45
48
  */
46
- save(graph: KnowledgeGraph, graphHash: string): void {
49
+ save(graph: KnowledgeGraph, graphHash: string, sourceSignature: string): void {
47
50
  const snapshot: SerializedSnapshot = {
48
- version: 2,
51
+ version: 3,
49
52
  timestamp: Date.now(),
50
53
  graph,
51
54
  indices: this.serializeIndices(graph),
52
55
  graphHash,
56
+ sourceSignature,
53
57
  }
54
58
 
55
59
  const path = join(this.projectDir, SNAPSHOT_FILE)
56
60
  const dir = dirname(path)
57
61
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
58
62
 
59
- writeFileSync(path, JSON.stringify(snapshot), "utf-8")
63
+ atomicWriteFile(path, JSON.stringify(snapshot))
60
64
  }
61
65
 
62
66
  /**
63
67
  * Load graph from snapshot if valid.
64
68
  * Returns null if snapshot is missing, stale, or corrupted.
65
69
  */
66
- load(): { graph: KnowledgeGraph; graphHash: string } | null {
70
+ load(): { graph: KnowledgeGraph; graphHash: string; sourceSignature: string } | null {
67
71
  const path = join(this.projectDir, SNAPSHOT_FILE)
68
72
  if (!existsSync(path)) return null
69
73
 
@@ -72,17 +76,19 @@ export class GraphSnapshotStore {
72
76
  const snapshot: SerializedSnapshot = JSON.parse(raw)
73
77
 
74
78
  // Validate version
75
- if (snapshot.version !== 2) return null
79
+ if (snapshot.version !== 3) return null
76
80
 
77
81
  // Validate age
78
82
  if (Date.now() - snapshot.timestamp > SNAPSHOT_MAX_AGE_MS) return null
79
83
 
80
84
  // Validate graph structure
81
85
  if (!snapshot.graph || !Array.isArray(snapshot.graph.nodes)) return null
86
+ if (!snapshot.sourceSignature || snapshot.graphHash !== graphFingerprint(snapshot.graph)) return null
82
87
 
83
88
  return {
84
89
  graph: snapshot.graph,
85
90
  graphHash: snapshot.graphHash || "",
91
+ sourceSignature: snapshot.sourceSignature,
86
92
  }
87
93
  } catch {
88
94
  return null
@@ -99,7 +105,8 @@ export class GraphSnapshotStore {
99
105
  try {
100
106
  const raw = readFileSync(path, "utf-8")
101
107
  const snapshot: SerializedSnapshot = JSON.parse(raw)
102
- return snapshot.version === 2 && (Date.now() - snapshot.timestamp) < SNAPSHOT_MAX_AGE_MS
108
+ return snapshot.version === 3 && Boolean(snapshot.sourceSignature) &&
109
+ (Date.now() - snapshot.timestamp) < SNAPSHOT_MAX_AGE_MS
103
110
  } catch {
104
111
  return false
105
112
  }