@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.
- package/README.md +10 -10
- package/dist/ast-analysis/visitor.d.ts.map +1 -1
- package/dist/ast-analysis/visitor.js +14 -0
- package/dist/ast-analysis/visitor.js.map +1 -1
- package/dist/cli/commands/watch.d.ts.map +1 -1
- package/dist/cli/commands/watch.js +2 -0
- package/dist/cli/commands/watch.js.map +1 -1
- package/dist/cli.js +24 -1
- package/dist/cli.js.map +1 -1
- package/dist/domain/graph/builder/context.d.ts +17 -0
- package/dist/domain/graph/builder/context.d.ts.map +1 -1
- package/dist/domain/graph/builder/context.js +7 -0
- package/dist/domain/graph/builder/context.js.map +1 -1
- package/dist/domain/graph/builder/helpers.d.ts +13 -2
- package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
- package/dist/domain/graph/builder/helpers.js +30 -4
- package/dist/domain/graph/builder/helpers.js.map +1 -1
- package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
- package/dist/domain/graph/builder/pipeline.js +221 -51
- package/dist/domain/graph/builder/pipeline.js.map +1 -1
- package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/build-edges.js +67 -6
- package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
- package/dist/domain/graph/builder/stages/build-structure.js +2 -2
- package/dist/domain/graph/builder/stages/collect-files.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/collect-files.js +58 -26
- package/dist/domain/graph/builder/stages/collect-files.js.map +1 -1
- package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/detect-changes.js +105 -55
- package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
- package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/finalize.js +27 -4
- package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
- package/dist/domain/graph/builder/stages/run-analyses.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/run-analyses.js +5 -20
- package/dist/domain/graph/builder/stages/run-analyses.js.map +1 -1
- package/dist/domain/graph/journal.d.ts +15 -0
- package/dist/domain/graph/journal.d.ts.map +1 -1
- package/dist/domain/graph/journal.js +283 -28
- package/dist/domain/graph/journal.js.map +1 -1
- package/dist/domain/graph/watcher.d.ts +17 -0
- package/dist/domain/graph/watcher.d.ts.map +1 -1
- package/dist/domain/graph/watcher.js +23 -7
- package/dist/domain/graph/watcher.js.map +1 -1
- package/dist/domain/parser.d.ts +13 -4
- package/dist/domain/parser.d.ts.map +1 -1
- package/dist/domain/parser.js +174 -80
- package/dist/domain/parser.js.map +1 -1
- package/dist/domain/search/generator.d.ts.map +1 -1
- package/dist/domain/search/generator.js +28 -2
- package/dist/domain/search/generator.js.map +1 -1
- package/dist/domain/wasm-worker-entry.d.ts +24 -0
- package/dist/domain/wasm-worker-entry.d.ts.map +1 -0
- package/dist/domain/wasm-worker-entry.js +643 -0
- package/dist/domain/wasm-worker-entry.js.map +1 -0
- package/dist/domain/wasm-worker-pool.d.ts +59 -0
- package/dist/domain/wasm-worker-pool.d.ts.map +1 -0
- package/dist/domain/wasm-worker-pool.js +312 -0
- package/dist/domain/wasm-worker-pool.js.map +1 -0
- package/dist/domain/wasm-worker-protocol.d.ts +65 -0
- package/dist/domain/wasm-worker-protocol.d.ts.map +1 -0
- package/dist/domain/wasm-worker-protocol.js +13 -0
- package/dist/domain/wasm-worker-protocol.js.map +1 -0
- package/dist/extractors/javascript.js +265 -1
- package/dist/extractors/javascript.js.map +1 -1
- package/dist/features/boundaries.d.ts +2 -2
- package/dist/features/boundaries.d.ts.map +1 -1
- package/dist/features/boundaries.js +2 -31
- package/dist/features/boundaries.js.map +1 -1
- package/dist/features/snapshot.d.ts.map +1 -1
- package/dist/features/snapshot.js +99 -13
- package/dist/features/snapshot.js.map +1 -1
- package/dist/features/structure.d.ts.map +1 -1
- package/dist/features/structure.js +14 -1
- package/dist/features/structure.js.map +1 -1
- package/dist/graph/algorithms/louvain.d.ts.map +1 -1
- package/dist/graph/algorithms/louvain.js +2 -4
- package/dist/graph/algorithms/louvain.js.map +1 -1
- package/dist/infrastructure/config.d.ts.map +1 -1
- package/dist/infrastructure/config.js +12 -2
- package/dist/infrastructure/config.js.map +1 -1
- package/dist/shared/globs.d.ts +40 -0
- package/dist/shared/globs.d.ts.map +1 -0
- package/dist/shared/globs.js +126 -0
- package/dist/shared/globs.js.map +1 -0
- package/dist/types.d.ts +26 -1
- package/dist/types.d.ts.map +1 -1
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/package.json +7 -7
- package/src/ast-analysis/visitor.ts +15 -0
- package/src/cli/commands/watch.ts +2 -0
- package/src/cli.ts +31 -8
- package/src/domain/graph/builder/context.ts +19 -0
- package/src/domain/graph/builder/helpers.ts +53 -3
- package/src/domain/graph/builder/pipeline.ts +235 -49
- package/src/domain/graph/builder/stages/build-edges.ts +80 -6
- package/src/domain/graph/builder/stages/build-structure.ts +2 -2
- package/src/domain/graph/builder/stages/collect-files.ts +56 -26
- package/src/domain/graph/builder/stages/detect-changes.ts +118 -61
- package/src/domain/graph/builder/stages/finalize.ts +27 -4
- package/src/domain/graph/builder/stages/run-analyses.ts +5 -26
- package/src/domain/graph/journal.ts +284 -27
- package/src/domain/graph/watcher.ts +29 -9
- package/src/domain/parser.ts +166 -73
- package/src/domain/search/generator.ts +34 -2
- package/src/domain/wasm-worker-entry.ts +788 -0
- package/src/domain/wasm-worker-pool.ts +330 -0
- package/src/domain/wasm-worker-protocol.ts +81 -0
- package/src/extractors/javascript.ts +290 -1
- package/src/features/boundaries.ts +2 -27
- package/src/features/snapshot.ts +93 -14
- package/src/features/structure.ts +17 -1
- package/src/graph/algorithms/louvain.ts +2 -4
- package/src/infrastructure/config.ts +12 -2
- package/src/shared/globs.ts +121 -0
- 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
|
-
|
|
67
|
-
|
|
281
|
+
withJournalLock(rootDir, () => {
|
|
282
|
+
const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME);
|
|
68
283
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
284
|
+
if (!fs.existsSync(journalPath)) {
|
|
285
|
+
fs.writeFileSync(journalPath, `${HEADER_PREFIX}0\n`);
|
|
286
|
+
}
|
|
72
287
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
288
|
+
const lines = entries.map((e) => {
|
|
289
|
+
if (e.deleted) return `DELETED ${e.file}`;
|
|
290
|
+
return e.file;
|
|
291
|
+
});
|
|
76
292
|
|
|
77
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
298
|
+
withJournalLock(rootDir, () => {
|
|
299
|
+
const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME);
|
|
300
|
+
const tmpPath = `${journalPath}.tmp`;
|
|
89
301
|
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
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
|
-
/*
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
284
|
-
file: normalizePath(path.relative(ctx.rootDir, filePath)),
|
|
285
|
-
}));
|
|
305
|
+
const entries = buildFlushEntriesFromPending(ctx.rootDir, ctx.pending);
|
|
286
306
|
try {
|
|
287
|
-
|
|
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
|
|