@optave/codegraph 3.9.3 → 3.9.5

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 (116) hide show
  1. package/README.md +10 -10
  2. package/dist/ast-analysis/visitor.d.ts.map +1 -1
  3. package/dist/ast-analysis/visitor.js +14 -0
  4. package/dist/ast-analysis/visitor.js.map +1 -1
  5. package/dist/cli/commands/watch.d.ts.map +1 -1
  6. package/dist/cli/commands/watch.js +2 -0
  7. package/dist/cli/commands/watch.js.map +1 -1
  8. package/dist/cli.js +24 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/domain/graph/builder/context.d.ts +17 -0
  11. package/dist/domain/graph/builder/context.d.ts.map +1 -1
  12. package/dist/domain/graph/builder/context.js +7 -0
  13. package/dist/domain/graph/builder/context.js.map +1 -1
  14. package/dist/domain/graph/builder/helpers.d.ts +13 -2
  15. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  16. package/dist/domain/graph/builder/helpers.js +30 -4
  17. package/dist/domain/graph/builder/helpers.js.map +1 -1
  18. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  19. package/dist/domain/graph/builder/pipeline.js +221 -51
  20. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  21. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  22. package/dist/domain/graph/builder/stages/build-edges.js +67 -6
  23. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  24. package/dist/domain/graph/builder/stages/build-structure.js +2 -2
  25. package/dist/domain/graph/builder/stages/collect-files.d.ts.map +1 -1
  26. package/dist/domain/graph/builder/stages/collect-files.js +58 -26
  27. package/dist/domain/graph/builder/stages/collect-files.js.map +1 -1
  28. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  29. package/dist/domain/graph/builder/stages/detect-changes.js +105 -55
  30. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  31. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  32. package/dist/domain/graph/builder/stages/finalize.js +27 -4
  33. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  34. package/dist/domain/graph/builder/stages/run-analyses.d.ts.map +1 -1
  35. package/dist/domain/graph/builder/stages/run-analyses.js +5 -20
  36. package/dist/domain/graph/builder/stages/run-analyses.js.map +1 -1
  37. package/dist/domain/graph/journal.d.ts +15 -0
  38. package/dist/domain/graph/journal.d.ts.map +1 -1
  39. package/dist/domain/graph/journal.js +283 -28
  40. package/dist/domain/graph/journal.js.map +1 -1
  41. package/dist/domain/graph/watcher.d.ts +17 -0
  42. package/dist/domain/graph/watcher.d.ts.map +1 -1
  43. package/dist/domain/graph/watcher.js +23 -7
  44. package/dist/domain/graph/watcher.js.map +1 -1
  45. package/dist/domain/parser.d.ts +13 -4
  46. package/dist/domain/parser.d.ts.map +1 -1
  47. package/dist/domain/parser.js +174 -80
  48. package/dist/domain/parser.js.map +1 -1
  49. package/dist/domain/search/generator.d.ts.map +1 -1
  50. package/dist/domain/search/generator.js +28 -2
  51. package/dist/domain/search/generator.js.map +1 -1
  52. package/dist/domain/wasm-worker-entry.d.ts +24 -0
  53. package/dist/domain/wasm-worker-entry.d.ts.map +1 -0
  54. package/dist/domain/wasm-worker-entry.js +643 -0
  55. package/dist/domain/wasm-worker-entry.js.map +1 -0
  56. package/dist/domain/wasm-worker-pool.d.ts +59 -0
  57. package/dist/domain/wasm-worker-pool.d.ts.map +1 -0
  58. package/dist/domain/wasm-worker-pool.js +312 -0
  59. package/dist/domain/wasm-worker-pool.js.map +1 -0
  60. package/dist/domain/wasm-worker-protocol.d.ts +65 -0
  61. package/dist/domain/wasm-worker-protocol.d.ts.map +1 -0
  62. package/dist/domain/wasm-worker-protocol.js +13 -0
  63. package/dist/domain/wasm-worker-protocol.js.map +1 -0
  64. package/dist/extractors/javascript.js +265 -1
  65. package/dist/extractors/javascript.js.map +1 -1
  66. package/dist/features/boundaries.d.ts +2 -2
  67. package/dist/features/boundaries.d.ts.map +1 -1
  68. package/dist/features/boundaries.js +2 -31
  69. package/dist/features/boundaries.js.map +1 -1
  70. package/dist/features/snapshot.d.ts.map +1 -1
  71. package/dist/features/snapshot.js +99 -13
  72. package/dist/features/snapshot.js.map +1 -1
  73. package/dist/features/structure.d.ts.map +1 -1
  74. package/dist/features/structure.js +14 -1
  75. package/dist/features/structure.js.map +1 -1
  76. package/dist/graph/algorithms/louvain.d.ts.map +1 -1
  77. package/dist/graph/algorithms/louvain.js +2 -4
  78. package/dist/graph/algorithms/louvain.js.map +1 -1
  79. package/dist/infrastructure/config.d.ts.map +1 -1
  80. package/dist/infrastructure/config.js +12 -2
  81. package/dist/infrastructure/config.js.map +1 -1
  82. package/dist/shared/globs.d.ts +40 -0
  83. package/dist/shared/globs.d.ts.map +1 -0
  84. package/dist/shared/globs.js +126 -0
  85. package/dist/shared/globs.js.map +1 -0
  86. package/dist/types.d.ts +26 -1
  87. package/dist/types.d.ts.map +1 -1
  88. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  89. package/package.json +7 -7
  90. package/src/ast-analysis/visitor.ts +15 -0
  91. package/src/cli/commands/watch.ts +2 -0
  92. package/src/cli.ts +31 -8
  93. package/src/domain/graph/builder/context.ts +19 -0
  94. package/src/domain/graph/builder/helpers.ts +53 -3
  95. package/src/domain/graph/builder/pipeline.ts +235 -49
  96. package/src/domain/graph/builder/stages/build-edges.ts +80 -6
  97. package/src/domain/graph/builder/stages/build-structure.ts +2 -2
  98. package/src/domain/graph/builder/stages/collect-files.ts +56 -26
  99. package/src/domain/graph/builder/stages/detect-changes.ts +118 -61
  100. package/src/domain/graph/builder/stages/finalize.ts +27 -4
  101. package/src/domain/graph/builder/stages/run-analyses.ts +5 -26
  102. package/src/domain/graph/journal.ts +284 -27
  103. package/src/domain/graph/watcher.ts +29 -9
  104. package/src/domain/parser.ts +166 -73
  105. package/src/domain/search/generator.ts +34 -2
  106. package/src/domain/wasm-worker-entry.ts +788 -0
  107. package/src/domain/wasm-worker-pool.ts +330 -0
  108. package/src/domain/wasm-worker-protocol.ts +81 -0
  109. package/src/extractors/javascript.ts +290 -1
  110. package/src/features/boundaries.ts +2 -27
  111. package/src/features/snapshot.ts +93 -14
  112. package/src/features/structure.ts +17 -1
  113. package/src/graph/algorithms/louvain.ts +2 -4
  114. package/src/infrastructure/config.ts +12 -2
  115. package/src/shared/globs.ts +121 -0
  116. package/src/types.ts +26 -1
@@ -1,9 +1,224 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { debug, warn } from '../../infrastructure/logger.js';
4
5
 
5
6
  export const JOURNAL_FILENAME = 'changes.journal';
6
7
  const HEADER_PREFIX = '# codegraph-journal v1 ';
8
+ const LOCK_SUFFIX = '.lock';
9
+ const LOCK_TIMEOUT_MS = 5_000;
10
+ const LOCK_STALE_MS = 30_000;
11
+ const LOCK_RETRY_MS = 25;
12
+
13
+ // Busy-spin sleep avoids blocking the Node.js event loop (unlike Atomics.wait,
14
+ // which freezes all I/O and timer callbacks). The retry interval is short
15
+ // (25ms), so the CPU cost is negligible while keeping unrelated callbacks
16
+ // responsive in watcher processes.
17
+ function sleepSync(ms: number): void {
18
+ const end = process.hrtime.bigint() + BigInt(ms) * 1_000_000n;
19
+ while (process.hrtime.bigint() < end) {
20
+ /* spin */
21
+ }
22
+ }
23
+
24
+ function isPidAlive(pid: number): boolean {
25
+ if (!Number.isFinite(pid) || pid <= 0) return false;
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch (e) {
30
+ // EPERM means the process exists but we lack permission — still alive.
31
+ return (e as NodeJS.ErrnoException).code === 'EPERM';
32
+ }
33
+ }
34
+
35
+ interface AcquiredLock {
36
+ fd: number;
37
+ nonce: string;
38
+ }
39
+
40
+ /**
41
+ * Steal a stale lockfile atomically via write-tmp + rename.
42
+ *
43
+ * Using rename (which is atomic on POSIX and Windows) avoids the TOCTOU race
44
+ * inherent to the unlink + openSync('wx') pattern: if two stealers both
45
+ * observed the same stale holder, one's unlink could cross the other's fresh
46
+ * acquisition, admitting two writers into the critical section.
47
+ *
48
+ * After rename, we re-read the lockfile to confirm our nonce — if another
49
+ * stealer's rename landed after ours, they own the lock and we retry.
50
+ */
51
+ function trySteal(lockPath: string): AcquiredLock | null {
52
+ const nonce = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
53
+ const tmpPath = `${lockPath}.${nonce}.tmp`;
54
+ try {
55
+ fs.writeFileSync(tmpPath, `${process.pid}\n${nonce}\n`, { flag: 'w' });
56
+ } catch {
57
+ return null;
58
+ }
59
+
60
+ try {
61
+ // Atomic replace: overwrites the stale lockfile.
62
+ fs.renameSync(tmpPath, lockPath);
63
+ } catch {
64
+ try {
65
+ fs.unlinkSync(tmpPath);
66
+ } catch {
67
+ /* ignore */
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // Verify the nonce — another stealer's rename may have landed after ours.
73
+ let content: string;
74
+ try {
75
+ content = fs.readFileSync(lockPath, 'utf-8');
76
+ } catch {
77
+ return null;
78
+ }
79
+ if (!content.includes(nonce)) {
80
+ // Lost the race to another stealer; do NOT unlink their live lockfile.
81
+ return null;
82
+ }
83
+
84
+ let fd: number;
85
+ try {
86
+ // Re-open r+ so we have a persistent fd the caller can close on release.
87
+ fd = fs.openSync(lockPath, 'r+');
88
+ } catch {
89
+ return null;
90
+ }
91
+ return { fd, nonce };
92
+ }
93
+
94
+ function acquireJournalLock(lockPath: string): AcquiredLock {
95
+ const start = Date.now();
96
+ for (;;) {
97
+ const nonce = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
98
+ try {
99
+ const fd = fs.openSync(lockPath, 'wx');
100
+ try {
101
+ fs.writeSync(fd, `${process.pid}\n${nonce}\n`);
102
+ } catch {
103
+ // Stamp write failed (ENOSPC, I/O error). An empty lockfile would
104
+ // look stale to concurrent waiters (Number('') === 0, isPidAlive(0)
105
+ // returns false), so they'd steal our live lock. Release and retry.
106
+ try {
107
+ fs.closeSync(fd);
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ try {
112
+ fs.unlinkSync(lockPath);
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
117
+ throw new Error(
118
+ `Failed to acquire journal lock at ${lockPath} within ${LOCK_TIMEOUT_MS}ms`,
119
+ );
120
+ }
121
+ sleepSync(LOCK_RETRY_MS);
122
+ continue;
123
+ }
124
+ return { fd, nonce };
125
+ } catch (e) {
126
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
127
+ }
128
+
129
+ let holderAlive = true;
130
+ try {
131
+ const pidContent = fs.readFileSync(lockPath, 'utf-8').split('\n')[0]!.trim();
132
+ holderAlive = isPidAlive(Number(pidContent));
133
+ } catch {
134
+ /* unreadable — fall through to age check */
135
+ }
136
+
137
+ let shouldSteal = !holderAlive;
138
+ if (holderAlive) {
139
+ try {
140
+ const stat = fs.statSync(lockPath);
141
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
142
+ shouldSteal = true;
143
+ }
144
+ } catch {
145
+ /* stat failed — keep retrying */
146
+ }
147
+ }
148
+
149
+ if (shouldSteal) {
150
+ const stolen = trySteal(lockPath);
151
+ if (stolen) return stolen;
152
+ // Steal failed or lost the race — fall through to timeout check & retry.
153
+ }
154
+
155
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
156
+ throw new Error(`Failed to acquire journal lock at ${lockPath} within ${LOCK_TIMEOUT_MS}ms`);
157
+ }
158
+ sleepSync(LOCK_RETRY_MS);
159
+ }
160
+ }
161
+
162
+ function releaseJournalLock(lockPath: string, lock: AcquiredLock): void {
163
+ try {
164
+ fs.closeSync(lock.fd);
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ // Only unlink if the lockfile still carries our nonce — if another stealer
169
+ // decided we were stale and replaced it, we must not unlink their live lock.
170
+ try {
171
+ const content = fs.readFileSync(lockPath, 'utf-8');
172
+ if (content.includes(lock.nonce)) {
173
+ fs.unlinkSync(lockPath);
174
+ }
175
+ } catch {
176
+ /* lockfile gone or unreadable — nothing to unlink */
177
+ }
178
+ }
179
+
180
+ function sweepStaleTmpFiles(dir: string): void {
181
+ // Clean up orphaned .tmp files left behind when a process is killed after
182
+ // writeFileSync(tmpPath, ...) succeeds but before renameSync(tmpPath, lockPath)
183
+ // completes (trySteal path). Without this, tmp files accumulate silently in
184
+ // .codegraph/ across crash cycles. Only sweep ones older than LOCK_STALE_MS
185
+ // so we don't race an in-flight steal on another process.
186
+ let entries: fs.Dirent[];
187
+ try {
188
+ entries = fs.readdirSync(dir, { withFileTypes: true });
189
+ } catch {
190
+ return;
191
+ }
192
+ const now = Date.now();
193
+ const prefix = `${JOURNAL_FILENAME}${LOCK_SUFFIX}.`;
194
+ for (const entry of entries) {
195
+ if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith('.tmp')) {
196
+ continue;
197
+ }
198
+ const tmpPath = path.join(dir, entry.name);
199
+ try {
200
+ const stat = fs.statSync(tmpPath);
201
+ if (now - stat.mtimeMs > LOCK_STALE_MS) {
202
+ fs.unlinkSync(tmpPath);
203
+ }
204
+ } catch {
205
+ /* stat/unlink raced another cleaner or was already removed — ignore */
206
+ }
207
+ }
208
+ }
209
+
210
+ function withJournalLock<T>(rootDir: string, fn: () => T): T {
211
+ const dir = path.join(rootDir, '.codegraph');
212
+ fs.mkdirSync(dir, { recursive: true });
213
+ sweepStaleTmpFiles(dir);
214
+ const lockPath = path.join(dir, `${JOURNAL_FILENAME}${LOCK_SUFFIX}`);
215
+ const lock = acquireJournalLock(lockPath);
216
+ try {
217
+ return fn();
218
+ } finally {
219
+ releaseJournalLock(lockPath, lock);
220
+ }
221
+ }
7
222
 
8
223
  interface JournalResult {
9
224
  valid: boolean;
@@ -63,43 +278,85 @@ export function appendJournalEntries(
63
278
  rootDir: string,
64
279
  entries: Array<{ file: string; deleted?: boolean }>,
65
280
  ): void {
66
- const dir = path.join(rootDir, '.codegraph');
67
- const journalPath = path.join(dir, JOURNAL_FILENAME);
281
+ withJournalLock(rootDir, () => {
282
+ const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME);
68
283
 
69
- if (!fs.existsSync(dir)) {
70
- fs.mkdirSync(dir, { recursive: true });
71
- }
284
+ if (!fs.existsSync(journalPath)) {
285
+ fs.writeFileSync(journalPath, `${HEADER_PREFIX}0\n`);
286
+ }
72
287
 
73
- if (!fs.existsSync(journalPath)) {
74
- fs.writeFileSync(journalPath, `${HEADER_PREFIX}0\n`);
75
- }
288
+ const lines = entries.map((e) => {
289
+ if (e.deleted) return `DELETED ${e.file}`;
290
+ return e.file;
291
+ });
76
292
 
77
- const lines = entries.map((e) => {
78
- if (e.deleted) return `DELETED ${e.file}`;
79
- return e.file;
293
+ fs.appendFileSync(journalPath, `${lines.join('\n')}\n`);
80
294
  });
81
-
82
- fs.appendFileSync(journalPath, `${lines.join('\n')}\n`);
83
295
  }
84
296
 
85
297
  export function writeJournalHeader(rootDir: string, timestamp: number): void {
86
- const dir = path.join(rootDir, '.codegraph');
87
- const journalPath = path.join(dir, JOURNAL_FILENAME);
88
- const tmpPath = `${journalPath}.tmp`;
298
+ withJournalLock(rootDir, () => {
299
+ const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME);
300
+ const tmpPath = `${journalPath}.tmp`;
89
301
 
90
- if (!fs.existsSync(dir)) {
91
- fs.mkdirSync(dir, { recursive: true });
92
- }
302
+ try {
303
+ fs.writeFileSync(tmpPath, `${HEADER_PREFIX}${timestamp}\n`);
304
+ fs.renameSync(tmpPath, journalPath);
305
+ } catch (err) {
306
+ warn(`Failed to write journal header: ${(err as Error).message}`);
307
+ try {
308
+ fs.unlinkSync(tmpPath);
309
+ } catch {
310
+ /* ignore */
311
+ }
312
+ }
313
+ });
314
+ }
93
315
 
94
- try {
95
- fs.writeFileSync(tmpPath, `${HEADER_PREFIX}${timestamp}\n`);
96
- fs.renameSync(tmpPath, journalPath);
97
- } catch (err) {
98
- warn(`Failed to write journal header: ${(err as Error).message}`);
316
+ /**
317
+ * Atomically append entries while advancing the header timestamp.
318
+ *
319
+ * Used by the watcher: without this, the header timestamp stays frozen at the
320
+ * last build's finalize time while entries accumulate, so the next build's
321
+ * Tier 0 check sees `journal.timestamp < MAX(file_hashes.mtime)`, rejects the
322
+ * journal, and falls through to the expensive mtime+size / hash scan.
323
+ *
324
+ * Writes a tmp file then renames — a crash mid-rename leaves the previous
325
+ * journal state intact.
326
+ */
327
+ export function appendJournalEntriesAndStampHeader(
328
+ rootDir: string,
329
+ entries: Array<{ file: string; deleted?: boolean }>,
330
+ timestamp: number,
331
+ ): void {
332
+ withJournalLock(rootDir, () => {
333
+ const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME);
334
+ const tmpPath = `${journalPath}.tmp`;
335
+
336
+ let existingBody = '';
99
337
  try {
100
- fs.unlinkSync(tmpPath);
338
+ const content = fs.readFileSync(journalPath, 'utf-8');
339
+ const newlineIdx = content.indexOf('\n');
340
+ if (newlineIdx >= 0) existingBody = content.slice(newlineIdx + 1);
101
341
  } catch {
102
- /* ignore */
342
+ /* no existing journal — fall through to write header + new entries */
103
343
  }
104
- }
344
+ if (existingBody && !existingBody.endsWith('\n')) existingBody = `${existingBody}\n`;
345
+
346
+ const newLines = entries.map((e) => (e.deleted ? `DELETED ${e.file}` : e.file));
347
+ const appended = newLines.length > 0 ? `${newLines.join('\n')}\n` : '';
348
+ const content = `${HEADER_PREFIX}${timestamp}\n${existingBody}${appended}`;
349
+
350
+ try {
351
+ fs.writeFileSync(tmpPath, content);
352
+ fs.renameSync(tmpPath, journalPath);
353
+ } catch (err) {
354
+ warn(`Failed to update journal: ${(err as Error).message}`);
355
+ try {
356
+ fs.unlinkSync(tmpPath);
357
+ } catch {
358
+ /* ignore */
359
+ }
360
+ }
361
+ });
105
362
  }
@@ -7,7 +7,7 @@ import { DbError } from '../../shared/errors.js';
7
7
  import { createParseTreeCache, getActiveEngine } from '../parser.js';
8
8
  import { type IncrementalStmts, rebuildFile } from './builder/incremental.js';
9
9
  import { appendChangeEvents, buildChangeEvent, diffSymbols } from './change-journal.js';
10
- import { appendJournalEntries } from './journal.js';
10
+ import { appendJournalEntriesAndStampHeader } from './journal.js';
11
11
 
12
12
  function shouldIgnorePath(filePath: string): boolean {
13
13
  const parts = filePath.split(path.sep);
@@ -100,7 +100,7 @@ function writeJournalAndChangeEvents(rootDir: string, updates: RebuildResult[]):
100
100
  deleted: r.deleted || false,
101
101
  }));
102
102
  try {
103
- appendJournalEntries(rootDir, entries);
103
+ appendJournalEntriesAndStampHeader(rootDir, entries, Date.now());
104
104
  } catch (e: unknown) {
105
105
  debug(`Journal write failed (non-fatal): ${(e as Error).message}`);
106
106
  }
@@ -165,8 +165,8 @@ interface WatcherContext {
165
165
  }
166
166
 
167
167
  /** Initialize DB, engine, cache, and statements for watch mode. */
168
- function setupWatcher(rootDir: string, opts: { engine?: string }): WatcherContext {
169
- const dbPath = path.join(rootDir, '.codegraph', 'graph.db');
168
+ function setupWatcher(rootDir: string, opts: { engine?: string; dbPath?: string }): WatcherContext {
169
+ const dbPath = opts.dbPath ?? path.join(rootDir, '.codegraph', 'graph.db');
170
170
  if (!fs.existsSync(dbPath)) {
171
171
  throw new DbError('No graph.db found. Run `codegraph build` first.', { file: dbPath });
172
172
  }
@@ -274,17 +274,37 @@ function startNativeWatcher(ctx: WatcherContext): () => void {
274
274
  return () => watcher.close();
275
275
  }
276
276
 
277
+ /**
278
+ * Build journal entries for a pending-path set, detecting deletions by
279
+ * existence check.
280
+ *
281
+ * `ctx.pending` is an untyped `Set<string>` — it carries no event-type
282
+ * metadata. Without this check, a file deleted during the watch session
283
+ * would be journaled as "changed", causing the next incremental build to
284
+ * try to re-parse a non-existent file instead of removing it from the graph.
285
+ * Mirrors the deletion detection in `rebuildFile` (see builder/incremental.ts).
286
+ *
287
+ * Exported for unit-testing; prefer `setupShutdownHandler` in production paths.
288
+ */
289
+ export function buildFlushEntriesFromPending(
290
+ rootDir: string,
291
+ pending: Iterable<string>,
292
+ ): Array<{ file: string; deleted: boolean }> {
293
+ return [...pending].map((filePath) => ({
294
+ file: normalizePath(path.relative(rootDir, filePath)),
295
+ deleted: !fs.existsSync(filePath),
296
+ }));
297
+ }
298
+
277
299
  /** Register SIGINT handler to flush journal and clean up. */
278
300
  function setupShutdownHandler(ctx: WatcherContext, cleanup: () => void): void {
279
301
  process.once('SIGINT', () => {
280
302
  info('Stopping watcher...');
281
303
  cleanup();
282
304
  if (ctx.pending.size > 0) {
283
- const entries = [...ctx.pending].map((filePath) => ({
284
- file: normalizePath(path.relative(ctx.rootDir, filePath)),
285
- }));
305
+ const entries = buildFlushEntriesFromPending(ctx.rootDir, ctx.pending);
286
306
  try {
287
- appendJournalEntries(ctx.rootDir, entries);
307
+ appendJournalEntriesAndStampHeader(ctx.rootDir, entries, Date.now());
288
308
  } catch (e: unknown) {
289
309
  debug(`Journal flush on exit failed (non-fatal): ${(e as Error).message}`);
290
310
  }
@@ -297,7 +317,7 @@ function setupShutdownHandler(ctx: WatcherContext, cleanup: () => void): void {
297
317
 
298
318
  export async function watchProject(
299
319
  rootDir: string,
300
- opts: { engine?: string; poll?: boolean; pollInterval?: number } = {},
320
+ opts: { engine?: string; poll?: boolean; pollInterval?: number; dbPath?: string } = {},
301
321
  ): Promise<void> {
302
322
  const ctx = setupWatcher(rootDir, opts);
303
323