@optave/codegraph 2.1.1-dev.3c12b64 → 2.2.1

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/src/cycles.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { loadNative } from './native.js';
2
+ import { isTestFile } from './queries.js';
2
3
 
3
4
  /**
4
5
  * Detect circular dependencies in the codebase using Tarjan's SCC algorithm.
5
6
  * Dispatches to native Rust implementation when available, falls back to JS.
6
7
  * @param {object} db - Open SQLite database
7
- * @param {object} opts - { fileLevel: true }
8
+ * @param {object} opts - { fileLevel: true, noTests: false }
8
9
  * @returns {string[][]} Array of cycles, each cycle is an array of file paths
9
10
  */
10
11
  export function findCycles(db, opts = {}) {
11
12
  const fileLevel = opts.fileLevel !== false;
13
+ const noTests = opts.noTests || false;
12
14
 
13
15
  // Build adjacency list from SQLite (stays in JS — only the algorithm can move to Rust)
14
16
  let edges;
@@ -22,6 +24,9 @@ export function findCycles(db, opts = {}) {
22
24
  WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type')
23
25
  `)
24
26
  .all();
27
+ if (noTests) {
28
+ edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
29
+ }
25
30
  } else {
26
31
  edges = db
27
32
  .prepare(`
@@ -37,6 +42,13 @@ export function findCycles(db, opts = {}) {
37
42
  AND n1.id != n2.id
38
43
  `)
39
44
  .all();
45
+ if (noTests) {
46
+ edges = edges.filter((e) => {
47
+ const sourceFile = e.source.split('|').pop();
48
+ const targetFile = e.target.split('|').pop();
49
+ return !isTestFile(sourceFile) && !isTestFile(targetFile);
50
+ });
51
+ }
40
52
  }
41
53
 
42
54
  // Try native Rust implementation
package/src/db.js CHANGED
@@ -67,6 +67,10 @@ export const MIGRATIONS = [
67
67
  );
68
68
  `,
69
69
  },
70
+ {
71
+ version: 4,
72
+ up: `ALTER TABLE file_hashes ADD COLUMN size INTEGER DEFAULT 0;`,
73
+ },
70
74
  ];
71
75
 
72
76
  export function openDb(dbPath) {
package/src/embedder.js CHANGED
@@ -55,7 +55,7 @@ export const MODELS = {
55
55
  },
56
56
  };
57
57
 
58
- export const DEFAULT_MODEL = 'jina-code';
58
+ export const DEFAULT_MODEL = 'nomic-v1.5';
59
59
  const BATCH_SIZE_MAP = {
60
60
  minilm: 32,
61
61
  'jina-small': 16,
package/src/export.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import path from 'node:path';
2
+ import { isTestFile } from './queries.js';
2
3
 
3
4
  /**
4
5
  * Export the dependency graph in DOT (Graphviz) format.
5
6
  */
6
7
  export function exportDOT(db, opts = {}) {
7
8
  const fileLevel = opts.fileLevel !== false;
9
+ const noTests = opts.noTests || false;
8
10
  const lines = [
9
11
  'digraph codegraph {',
10
12
  ' rankdir=LR;',
@@ -14,7 +16,7 @@ export function exportDOT(db, opts = {}) {
14
16
  ];
15
17
 
16
18
  if (fileLevel) {
17
- const edges = db
19
+ let edges = db
18
20
  .prepare(`
19
21
  SELECT DISTINCT n1.file AS source, n2.file AS target
20
22
  FROM edges e
@@ -23,6 +25,7 @@ export function exportDOT(db, opts = {}) {
23
25
  WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
24
26
  `)
25
27
  .all();
28
+ if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
26
29
 
27
30
  // Try to use directory nodes from DB (built by structure analysis)
28
31
  const hasDirectoryNodes =
@@ -89,7 +92,7 @@ export function exportDOT(db, opts = {}) {
89
92
  lines.push(` "${source}" -> "${target}";`);
90
93
  }
91
94
  } else {
92
- const edges = db
95
+ let edges = db
93
96
  .prepare(`
94
97
  SELECT n1.name AS source_name, n1.kind AS source_kind, n1.file AS source_file,
95
98
  n2.name AS target_name, n2.kind AS target_kind, n2.file AS target_file,
@@ -101,6 +104,8 @@ export function exportDOT(db, opts = {}) {
101
104
  AND e.kind = 'calls'
102
105
  `)
103
106
  .all();
107
+ if (noTests)
108
+ edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
104
109
 
105
110
  for (const e of edges) {
106
111
  const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
@@ -120,10 +125,11 @@ export function exportDOT(db, opts = {}) {
120
125
  */
121
126
  export function exportMermaid(db, opts = {}) {
122
127
  const fileLevel = opts.fileLevel !== false;
128
+ const noTests = opts.noTests || false;
123
129
  const lines = ['graph LR'];
124
130
 
125
131
  if (fileLevel) {
126
- const edges = db
132
+ let edges = db
127
133
  .prepare(`
128
134
  SELECT DISTINCT n1.file AS source, n2.file AS target
129
135
  FROM edges e
@@ -132,6 +138,7 @@ export function exportMermaid(db, opts = {}) {
132
138
  WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
133
139
  `)
134
140
  .all();
141
+ if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
135
142
 
136
143
  for (const { source, target } of edges) {
137
144
  const s = source.replace(/[^a-zA-Z0-9]/g, '_');
@@ -139,7 +146,7 @@ export function exportMermaid(db, opts = {}) {
139
146
  lines.push(` ${s}["${source}"] --> ${t}["${target}"]`);
140
147
  }
141
148
  } else {
142
- const edges = db
149
+ let edges = db
143
150
  .prepare(`
144
151
  SELECT n1.name AS source_name, n1.file AS source_file,
145
152
  n2.name AS target_name, n2.file AS target_file
@@ -150,6 +157,8 @@ export function exportMermaid(db, opts = {}) {
150
157
  AND e.kind = 'calls'
151
158
  `)
152
159
  .all();
160
+ if (noTests)
161
+ edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
153
162
 
154
163
  for (const e of edges) {
155
164
  const sId = `${e.source_file}_${e.source_name}`.replace(/[^a-zA-Z0-9]/g, '_');
@@ -164,14 +173,17 @@ export function exportMermaid(db, opts = {}) {
164
173
  /**
165
174
  * Export as JSON adjacency list.
166
175
  */
167
- export function exportJSON(db) {
168
- const nodes = db
176
+ export function exportJSON(db, opts = {}) {
177
+ const noTests = opts.noTests || false;
178
+
179
+ let nodes = db
169
180
  .prepare(`
170
181
  SELECT id, name, kind, file, line FROM nodes WHERE kind = 'file'
171
182
  `)
172
183
  .all();
184
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
173
185
 
174
- const edges = db
186
+ let edges = db
175
187
  .prepare(`
176
188
  SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind
177
189
  FROM edges e
@@ -180,6 +192,7 @@ export function exportJSON(db) {
180
192
  WHERE n1.file != n2.file
181
193
  `)
182
194
  .all();
195
+ if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
183
196
 
184
197
  return { nodes, edges };
185
198
  }
@@ -186,7 +186,12 @@ export function extractCSharpSymbols(tree, _filePath) {
186
186
  calls.push({ name: fn.text, line: node.startPosition.row + 1 });
187
187
  } else if (fn.type === 'member_access_expression') {
188
188
  const name = fn.childForFieldName('name');
189
- if (name) calls.push({ name: name.text, line: node.startPosition.row + 1 });
189
+ if (name) {
190
+ const expr = fn.childForFieldName('expression');
191
+ const call = { name: name.text, line: node.startPosition.row + 1 };
192
+ if (expr) call.receiver = expr.text;
193
+ calls.push(call);
194
+ }
190
195
  } else if (fn.type === 'generic_name' || fn.type === 'member_binding_expression') {
191
196
  const name = fn.childForFieldName('name') || fn.child(0);
192
197
  if (name) calls.push({ name: name.text, line: node.startPosition.row + 1 });
@@ -152,7 +152,12 @@ export function extractGoSymbols(tree, _filePath) {
152
152
  calls.push({ name: fn.text, line: node.startPosition.row + 1 });
153
153
  } else if (fn.type === 'selector_expression') {
154
154
  const field = fn.childForFieldName('field');
155
- if (field) calls.push({ name: field.text, line: node.startPosition.row + 1 });
155
+ if (field) {
156
+ const operand = fn.childForFieldName('operand');
157
+ const call = { name: field.text, line: node.startPosition.row + 1 };
158
+ if (operand) call.receiver = operand.text;
159
+ calls.push(call);
160
+ }
156
161
  }
157
162
  }
158
163
  break;
@@ -203,7 +203,10 @@ export function extractJavaSymbols(tree, _filePath) {
203
203
  case 'method_invocation': {
204
204
  const nameNode = node.childForFieldName('name');
205
205
  if (nameNode) {
206
- calls.push({ name: nameNode.text, line: node.startPosition.row + 1 });
206
+ const obj = node.childForFieldName('object');
207
+ const call = { name: nameNode.text, line: node.startPosition.row + 1 };
208
+ if (obj) call.receiver = obj.text;
209
+ calls.push(call);
207
210
  }
208
211
  break;
209
212
  }
@@ -140,6 +140,8 @@ export function extractSymbols(tree, _filePath) {
140
140
  calls.push(callInfo);
141
141
  }
142
142
  }
143
+ const cbDef = extractCallbackDefinition(node);
144
+ if (cbDef) definitions.push(cbDef);
143
145
  break;
144
146
  }
145
147
 
@@ -313,6 +315,18 @@ function extractImplementsFromNode(node) {
313
315
  return result;
314
316
  }
315
317
 
318
+ function extractReceiverName(objNode) {
319
+ if (!objNode) return undefined;
320
+ if (objNode.type === 'identifier') return objNode.text;
321
+ if (objNode.type === 'this') return 'this';
322
+ if (objNode.type === 'super') return 'super';
323
+ if (objNode.type === 'member_expression') {
324
+ const prop = objNode.childForFieldName('property');
325
+ if (prop) return objNode.text;
326
+ }
327
+ return objNode.text;
328
+ }
329
+
316
330
  function extractCallInfo(fn, callNode) {
317
331
  if (fn.type === 'identifier') {
318
332
  return { name: fn.text, line: callNode.startPosition.row + 1 };
@@ -335,21 +349,147 @@ function extractCallInfo(fn, callNode) {
335
349
 
336
350
  if (prop.type === 'string' || prop.type === 'string_fragment') {
337
351
  const methodName = prop.text.replace(/['"]/g, '');
338
- if (methodName)
339
- return { name: methodName, line: callNode.startPosition.row + 1, dynamic: true };
352
+ if (methodName) {
353
+ const receiver = extractReceiverName(obj);
354
+ return { name: methodName, line: callNode.startPosition.row + 1, dynamic: true, receiver };
355
+ }
340
356
  }
341
357
 
342
- return { name: prop.text, line: callNode.startPosition.row + 1 };
358
+ const receiver = extractReceiverName(obj);
359
+ return { name: prop.text, line: callNode.startPosition.row + 1, receiver };
343
360
  }
344
361
 
345
362
  if (fn.type === 'subscript_expression') {
363
+ const obj = fn.childForFieldName('object');
346
364
  const index = fn.childForFieldName('index');
347
365
  if (index && (index.type === 'string' || index.type === 'template_string')) {
348
366
  const methodName = index.text.replace(/['"`]/g, '');
349
- if (methodName && !methodName.includes('$'))
350
- return { name: methodName, line: callNode.startPosition.row + 1, dynamic: true };
367
+ if (methodName && !methodName.includes('$')) {
368
+ const receiver = extractReceiverName(obj);
369
+ return { name: methodName, line: callNode.startPosition.row + 1, dynamic: true, receiver };
370
+ }
371
+ }
372
+ }
373
+
374
+ return null;
375
+ }
376
+
377
+ function findAnonymousCallback(argsNode) {
378
+ for (let i = 0; i < argsNode.childCount; i++) {
379
+ const child = argsNode.child(i);
380
+ if (child && (child.type === 'arrow_function' || child.type === 'function_expression')) {
381
+ return child;
382
+ }
383
+ }
384
+ return null;
385
+ }
386
+
387
+ function findFirstStringArg(argsNode) {
388
+ for (let i = 0; i < argsNode.childCount; i++) {
389
+ const child = argsNode.child(i);
390
+ if (child && child.type === 'string') {
391
+ return child.text.replace(/['"]/g, '');
351
392
  }
352
393
  }
394
+ return null;
395
+ }
396
+
397
+ function walkCallChain(startNode, methodName) {
398
+ let current = startNode;
399
+ while (current) {
400
+ if (current.type === 'call_expression') {
401
+ const fn = current.childForFieldName('function');
402
+ if (fn && fn.type === 'member_expression') {
403
+ const prop = fn.childForFieldName('property');
404
+ if (prop && prop.text === methodName) {
405
+ return current;
406
+ }
407
+ }
408
+ }
409
+ if (current.type === 'member_expression') {
410
+ const obj = current.childForFieldName('object');
411
+ current = obj;
412
+ } else if (current.type === 'call_expression') {
413
+ const fn = current.childForFieldName('function');
414
+ current = fn;
415
+ } else {
416
+ break;
417
+ }
418
+ }
419
+ return null;
420
+ }
421
+
422
+ const EXPRESS_METHODS = new Set([
423
+ 'get',
424
+ 'post',
425
+ 'put',
426
+ 'delete',
427
+ 'patch',
428
+ 'options',
429
+ 'head',
430
+ 'all',
431
+ 'use',
432
+ ]);
433
+ const EVENT_METHODS = new Set(['on', 'once', 'addEventListener', 'addListener']);
434
+
435
+ function extractCallbackDefinition(callNode) {
436
+ const fn = callNode.childForFieldName('function');
437
+ if (!fn || fn.type !== 'member_expression') return null;
438
+
439
+ const prop = fn.childForFieldName('property');
440
+ if (!prop) return null;
441
+ const method = prop.text;
442
+
443
+ const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments');
444
+ if (!args) return null;
445
+
446
+ // Commander: .action(callback) with .command('name') in chain
447
+ if (method === 'action') {
448
+ const cb = findAnonymousCallback(args);
449
+ if (!cb) return null;
450
+ const commandCall = walkCallChain(fn.childForFieldName('object'), 'command');
451
+ if (!commandCall) return null;
452
+ const cmdArgs =
453
+ commandCall.childForFieldName('arguments') || findChild(commandCall, 'arguments');
454
+ if (!cmdArgs) return null;
455
+ const cmdName = findFirstStringArg(cmdArgs);
456
+ if (!cmdName) return null;
457
+ const firstWord = cmdName.split(/\s/)[0];
458
+ return {
459
+ name: `command:${firstWord}`,
460
+ kind: 'function',
461
+ line: cb.startPosition.row + 1,
462
+ endLine: nodeEndLine(cb),
463
+ };
464
+ }
465
+
466
+ // Express: app.get('/path', callback)
467
+ if (EXPRESS_METHODS.has(method)) {
468
+ const strArg = findFirstStringArg(args);
469
+ if (!strArg || !strArg.startsWith('/')) return null;
470
+ const cb = findAnonymousCallback(args);
471
+ if (!cb) return null;
472
+ return {
473
+ name: `route:${method.toUpperCase()} ${strArg}`,
474
+ kind: 'function',
475
+ line: cb.startPosition.row + 1,
476
+ endLine: nodeEndLine(cb),
477
+ };
478
+ }
479
+
480
+ // Events: emitter.on('event', callback)
481
+ if (EVENT_METHODS.has(method)) {
482
+ const eventName = findFirstStringArg(args);
483
+ if (!eventName) return null;
484
+ const cb = findAnonymousCallback(args);
485
+ if (!cb) return null;
486
+ return {
487
+ name: `event:${eventName}`,
488
+ kind: 'function',
489
+ line: cb.startPosition.row + 1,
490
+ endLine: nodeEndLine(cb),
491
+ };
492
+ }
353
493
 
354
494
  return null;
355
495
  }
@@ -206,7 +206,10 @@ export function extractPHPSymbols(tree, _filePath) {
206
206
  case 'member_call_expression': {
207
207
  const name = node.childForFieldName('name');
208
208
  if (name) {
209
- calls.push({ name: name.text, line: node.startPosition.row + 1 });
209
+ const obj = node.childForFieldName('object');
210
+ const call = { name: name.text, line: node.startPosition.row + 1 };
211
+ if (obj) call.receiver = obj.text;
212
+ calls.push(call);
210
213
  }
211
214
  break;
212
215
  }
@@ -214,7 +217,10 @@ export function extractPHPSymbols(tree, _filePath) {
214
217
  case 'scoped_call_expression': {
215
218
  const name = node.childForFieldName('name');
216
219
  if (name) {
217
- calls.push({ name: name.text, line: node.startPosition.row + 1 });
220
+ const scope = node.childForFieldName('scope');
221
+ const call = { name: name.text, line: node.startPosition.row + 1 };
222
+ if (scope) call.receiver = scope.text;
223
+ calls.push(call);
218
224
  }
219
225
  break;
220
226
  }
@@ -69,12 +69,19 @@ export function extractPythonSymbols(tree, _filePath) {
69
69
  const fn = node.childForFieldName('function');
70
70
  if (fn) {
71
71
  let callName = null;
72
+ let receiver;
72
73
  if (fn.type === 'identifier') callName = fn.text;
73
74
  else if (fn.type === 'attribute') {
74
75
  const attr = fn.childForFieldName('attribute');
75
76
  if (attr) callName = attr.text;
77
+ const obj = fn.childForFieldName('object');
78
+ if (obj) receiver = obj.text;
79
+ }
80
+ if (callName) {
81
+ const call = { name: callName, line: node.startPosition.row + 1 };
82
+ if (receiver) call.receiver = receiver;
83
+ calls.push(call);
76
84
  }
77
- if (callName) calls.push({ name: callName, line: node.startPosition.row + 1 });
78
85
  }
79
86
  break;
80
87
  }
@@ -170,7 +170,10 @@ export function extractRubySymbols(tree, _filePath) {
170
170
  }
171
171
  }
172
172
  } else {
173
- calls.push({ name: methodNode.text, line: node.startPosition.row + 1 });
173
+ const recv = node.childForFieldName('receiver');
174
+ const call = { name: methodNode.text, line: node.startPosition.row + 1 };
175
+ if (recv) call.receiver = recv.text;
176
+ calls.push(call);
174
177
  }
175
178
  }
176
179
  break;
@@ -135,10 +135,20 @@ export function extractRustSymbols(tree, _filePath) {
135
135
  calls.push({ name: fn.text, line: node.startPosition.row + 1 });
136
136
  } else if (fn.type === 'field_expression') {
137
137
  const field = fn.childForFieldName('field');
138
- if (field) calls.push({ name: field.text, line: node.startPosition.row + 1 });
138
+ if (field) {
139
+ const value = fn.childForFieldName('value');
140
+ const call = { name: field.text, line: node.startPosition.row + 1 };
141
+ if (value) call.receiver = value.text;
142
+ calls.push(call);
143
+ }
139
144
  } else if (fn.type === 'scoped_identifier') {
140
145
  const name = fn.childForFieldName('name');
141
- if (name) calls.push({ name: name.text, line: node.startPosition.row + 1 });
146
+ if (name) {
147
+ const path = fn.childForFieldName('path');
148
+ const call = { name: name.text, line: node.startPosition.row + 1 };
149
+ if (path) call.receiver = path.text;
150
+ calls.push(call);
151
+ }
142
152
  }
143
153
  }
144
154
  break;
package/src/index.js CHANGED
@@ -38,7 +38,12 @@ export { isNativeAvailable } from './native.js';
38
38
  export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js';
39
39
  // Query functions (data-returning)
40
40
  export {
41
+ ALL_SYMBOL_KINDS,
42
+ contextData,
41
43
  diffImpactData,
44
+ explainData,
45
+ FALSE_POSITIVE_CALLER_THRESHOLD,
46
+ FALSE_POSITIVE_NAMES,
42
47
  fileDepsData,
43
48
  fnDepsData,
44
49
  fnImpactData,
@@ -46,6 +51,7 @@ export {
46
51
  moduleMapData,
47
52
  queryNameData,
48
53
  statsData,
54
+ whereData,
49
55
  } from './queries.js';
50
56
  // Registry (multi-repo)
51
57
  export {
package/src/journal.js ADDED
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { debug, warn } from './logger.js';
4
+
5
+ export const JOURNAL_FILENAME = 'changes.journal';
6
+ const HEADER_PREFIX = '# codegraph-journal v1 ';
7
+
8
+ /**
9
+ * Read and validate the change journal.
10
+ * Returns { valid, timestamp, changed[], removed[] } or { valid: false }.
11
+ */
12
+ export function readJournal(rootDir) {
13
+ const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME);
14
+ let content;
15
+ try {
16
+ content = fs.readFileSync(journalPath, 'utf-8');
17
+ } catch {
18
+ return { valid: false };
19
+ }
20
+
21
+ const lines = content.split('\n');
22
+ if (lines.length === 0 || !lines[0].startsWith(HEADER_PREFIX)) {
23
+ debug('Journal has malformed or missing header');
24
+ return { valid: false };
25
+ }
26
+
27
+ const timestamp = Number(lines[0].slice(HEADER_PREFIX.length).trim());
28
+ if (!Number.isFinite(timestamp) || timestamp <= 0) {
29
+ debug('Journal has invalid timestamp');
30
+ return { valid: false };
31
+ }
32
+
33
+ const changed = [];
34
+ const removed = [];
35
+ const seenChanged = new Set();
36
+ const seenRemoved = new Set();
37
+
38
+ for (let i = 1; i < lines.length; i++) {
39
+ const line = lines[i].trim();
40
+ if (!line || line.startsWith('#')) continue;
41
+
42
+ if (line.startsWith('DELETED ')) {
43
+ const filePath = line.slice(8);
44
+ if (filePath && !seenRemoved.has(filePath)) {
45
+ seenRemoved.add(filePath);
46
+ removed.push(filePath);
47
+ }
48
+ } else {
49
+ if (!seenChanged.has(line)) {
50
+ seenChanged.add(line);
51
+ changed.push(line);
52
+ }
53
+ }
54
+ }
55
+
56
+ return { valid: true, timestamp, changed, removed };
57
+ }
58
+
59
+ /**
60
+ * Append changed/deleted paths to the journal.
61
+ * Creates the journal with a header if it doesn't exist.
62
+ */
63
+ export function appendJournalEntries(rootDir, entries) {
64
+ const dir = path.join(rootDir, '.codegraph');
65
+ const journalPath = path.join(dir, JOURNAL_FILENAME);
66
+
67
+ if (!fs.existsSync(dir)) {
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ }
70
+
71
+ // If journal doesn't exist, create with a placeholder header
72
+ if (!fs.existsSync(journalPath)) {
73
+ fs.writeFileSync(journalPath, `${HEADER_PREFIX}0\n`);
74
+ }
75
+
76
+ const lines = entries.map((e) => {
77
+ if (e.deleted) return `DELETED ${e.file}`;
78
+ return e.file;
79
+ });
80
+
81
+ fs.appendFileSync(journalPath, `${lines.join('\n')}\n`);
82
+ }
83
+
84
+ /**
85
+ * Write a fresh journal header after a successful build.
86
+ * Atomic: write to temp file then rename.
87
+ */
88
+ export function writeJournalHeader(rootDir, timestamp) {
89
+ const dir = path.join(rootDir, '.codegraph');
90
+ const journalPath = path.join(dir, JOURNAL_FILENAME);
91
+ const tmpPath = `${journalPath}.tmp`;
92
+
93
+ if (!fs.existsSync(dir)) {
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ }
96
+
97
+ try {
98
+ fs.writeFileSync(tmpPath, `${HEADER_PREFIX}${timestamp}\n`);
99
+ fs.renameSync(tmpPath, journalPath);
100
+ } catch (err) {
101
+ warn(`Failed to write journal header: ${err.message}`);
102
+ // Clean up temp file if rename failed
103
+ try {
104
+ fs.unlinkSync(tmpPath);
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ }
109
+ }