Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/change-journal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import fs from 'node:fs';
import path from 'node:path';
import { debug, warn } from './logger.js';

export const CHANGE_EVENTS_FILENAME = 'change-events.ndjson';
export const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MB

/**
* Returns the absolute path to the NDJSON change events file.
*/
export function changeEventsPath(rootDir) {
return path.join(rootDir, '.codegraph', CHANGE_EVENTS_FILENAME);
}

/**
* Compare old and new symbol arrays, returning added/removed/modified sets.
* Symbols are keyed on `name\0kind`. A symbol is "modified" if the same
* name+kind exists in both but the line changed.
*
* @param {Array<{name:string, kind:string, line:number}>} oldSymbols
* @param {Array<{name:string, kind:string, line:number}>} newSymbols
* @returns {{ added: Array, removed: Array, modified: Array }}
*/
export function diffSymbols(oldSymbols, newSymbols) {
const oldMap = new Map();
for (const s of oldSymbols) {
oldMap.set(`${s.name}\0${s.kind}`, s);
}

const newMap = new Map();
for (const s of newSymbols) {
newMap.set(`${s.name}\0${s.kind}`, s);
}

const added = [];
const removed = [];
const modified = [];

for (const [key, s] of newMap) {
const old = oldMap.get(key);
if (!old) {
added.push({ name: s.name, kind: s.kind, line: s.line });
} else if (old.line !== s.line) {
modified.push({ name: s.name, kind: s.kind, line: s.line });
}
}

for (const [key, s] of oldMap) {
if (!newMap.has(key)) {
removed.push({ name: s.name, kind: s.kind });
}
}

return { added, removed, modified };
}

/**
* Assemble a single change event object.
*/
export function buildChangeEvent(file, event, symbolDiff, counts) {
return {
ts: new Date().toISOString(),
file,
event,
symbols: symbolDiff,
counts: {
nodes: { before: counts.nodesBefore ?? 0, after: counts.nodesAfter ?? 0 },
edges: { added: counts.edgesAdded ?? 0 },
},
};
}

/**
* Append change events as NDJSON lines to the change events file.
* Creates the .codegraph directory if needed. Non-fatal on failure.
*/
export function appendChangeEvents(rootDir, events) {
const filePath = changeEventsPath(rootDir);
const dir = path.dirname(filePath);

try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const lines = `${events.map((e) => JSON.stringify(e)).join('\n')}\n`;
fs.appendFileSync(filePath, lines);
debug(`Appended ${events.length} change event(s) to ${filePath}`);
} catch (err) {
warn(`Failed to append change events: ${err.message}`);
return;
}

try {
rotateIfNeeded(filePath, DEFAULT_MAX_BYTES);
} catch {
/* rotation failure is non-fatal */
}
}

/**
* If the file exceeds maxBytes, keep the last ~half by finding
* the first newline at or after the midpoint and rewriting from there.
*/
export function rotateIfNeeded(filePath, maxBytes = DEFAULT_MAX_BYTES) {
let stat;
try {
stat = fs.statSync(filePath);
} catch {
return; // file doesn't exist, nothing to rotate
}

if (stat.size <= maxBytes) return;

try {
const buf = fs.readFileSync(filePath);
const mid = Math.floor(buf.length / 2);
const newlineIdx = buf.indexOf(0x0a, mid);
if (newlineIdx === -1) {
warn(
`Change events file exceeds ${maxBytes} bytes but contains no line breaks; skipping rotation`,
);
return;
}
const kept = buf.slice(newlineIdx + 1);
fs.writeFileSync(filePath, kept);
debug(`Rotated change events: ${stat.size} → ${kept.length} bytes`);
} catch (err) {
warn(`Failed to rotate change events: ${err.message}`);
}
}
8 changes: 8 additions & 0 deletions src/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export function queryNameData(name, customDbPath, opts = {}) {
kind: node.kind,
file: node.file,
line: node.line,
fileHash: getFileHash(db, node.file),
callees: callees.map((c) => ({
name: c.name,
kind: c.kind,
Expand Down Expand Up @@ -2732,6 +2733,11 @@ export function explain(target, customDbPath, opts = {}) {

// ─── whereData ──────────────────────────────────────────────────────────

function getFileHash(db, file) {
const row = db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(file);
return row ? row.hash : null;
}

function whereSymbolImpl(db, target, noTests) {
const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
let nodes = db
Expand Down Expand Up @@ -2763,6 +2769,7 @@ function whereSymbolImpl(db, target, noTests) {
kind: node.kind,
file: node.file,
line: node.line,
fileHash: getFileHash(db, node.file),
role: node.role || null,
exported,
uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
Expand Down Expand Up @@ -2813,6 +2820,7 @@ function whereFileImpl(db, target) {

return {
file: fn.file,
fileHash: getFileHash(db, fn.file),
symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })),
imports,
importedBy,
Expand Down
37 changes: 36 additions & 1 deletion src/watcher.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { readFileSafe } from './builder.js';
import { appendChangeEvents, buildChangeEvent, diffSymbols } from './change-journal.js';
import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
import { closeDb, initSchema, openDb } from './db.js';
import { appendJournalEntries } from './journal.js';
Expand All @@ -25,13 +26,25 @@ async function updateFile(_db, rootDir, filePath, stmts, engineOpts, cache) {

const oldNodes = stmts.countNodes.get(relPath)?.c || 0;
const _oldEdges = stmts.countEdgesForFile.get(relPath)?.c || 0;
const oldSymbols = stmts.listSymbols.all(relPath);

stmts.deleteEdgesForFile.run(relPath);
stmts.deleteNodes.run(relPath);

if (!fs.existsSync(filePath)) {
if (cache) cache.remove(filePath);
return { file: relPath, nodesAdded: 0, nodesRemoved: oldNodes, edgesAdded: 0, deleted: true };
const symbolDiff = diffSymbols(oldSymbols, []);
return {
file: relPath,
nodesAdded: 0,
nodesRemoved: oldNodes,
edgesAdded: 0,
deleted: true,
event: 'deleted',
symbolDiff,
nodesBefore: oldNodes,
nodesAfter: 0,
};
}

let code;
Expand All @@ -55,6 +68,7 @@ async function updateFile(_db, rootDir, filePath, stmts, engineOpts, cache) {
}

const newNodes = stmts.countNodes.get(relPath)?.c || 0;
const newSymbols = stmts.listSymbols.all(relPath);

let edgesAdded = 0;
const fileNodeRow = stmts.getNodeId.get(relPath, 'file', relPath, 0);
Expand Down Expand Up @@ -129,12 +143,19 @@ async function updateFile(_db, rootDir, filePath, stmts, engineOpts, cache) {
}
}

const symbolDiff = diffSymbols(oldSymbols, newSymbols);
const event = oldNodes === 0 ? 'added' : 'modified';

return {
file: relPath,
nodesAdded: newNodes,
nodesRemoved: oldNodes,
edgesAdded,
deleted: false,
event,
symbolDiff,
nodesBefore: oldNodes,
nodesAfter: newNodes,
};
}

Expand Down Expand Up @@ -180,6 +201,7 @@ export async function watchProject(rootDir, opts = {}) {
findNodeByName: db.prepare(
"SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')",
),
listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"),
};

// Use named params for statements needing the same value twice
Expand Down Expand Up @@ -218,6 +240,19 @@ export async function watchProject(rootDir, opts = {}) {
} catch {
/* journal write failure is non-fatal */
}

const changeEvents = updates.map((r) =>
buildChangeEvent(r.file, r.event, r.symbolDiff, {
nodesBefore: r.nodesBefore,
nodesAfter: r.nodesAfter,
edgesAdded: r.edgesAdded,
}),
);
try {
appendChangeEvents(rootDir, changeEvents);
} catch {
/* change event write failure is non-fatal */
}
}

for (const r of updates) {
Expand Down
15 changes: 15 additions & 0 deletions tests/integration/queries.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ import {
fnDepsData,
fnImpactData,
impactAnalysisData,
listFunctionsData,
moduleMapData,
pathData,
queryNameData,
rolesData,
statsData,
whereData,
} from '../../src/queries.js';
Expand Down Expand Up @@ -101,6 +103,16 @@ beforeAll(() => {
// Low-confidence call edge for quality tests
insertEdge(db, formatResponse, validateToken, 'calls', 0.3);

// File hashes (for fileHash exposure)
for (const f of ['auth.js', 'middleware.js', 'routes.js', 'utils.js', 'auth.test.js']) {
db.prepare('INSERT INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)').run(
f,
`hash_${f.replace('.', '_')}`,
Date.now(),
100,
);
}

db.close();
});

Expand All @@ -117,6 +129,7 @@ describe('queryNameData', () => {
expect(fn).toBeDefined();
expect(fn.callers.map((c) => c.name)).toContain('authMiddleware');
expect(fn.callees.map((c) => c.name)).toContain('validateToken');
expect(fn.fileHash).toBe('hash_auth_js');
});

test('returns empty results for nonexistent name', () => {
Expand Down Expand Up @@ -516,6 +529,7 @@ describe('whereData', () => {
expect(r.file).toBe('middleware.js');
expect(r.line).toBe(5);
expect(r.uses.map((u) => u.name)).toContain('handleRoute');
expect(r.fileHash).toBe('hash_middleware_js');
});

test('symbol: exported flag', () => {
Expand Down Expand Up @@ -547,6 +561,7 @@ describe('whereData', () => {
expect(r.symbols.map((s) => s.name)).toContain('authMiddleware');
expect(r.imports).toContain('auth.js');
expect(r.importedBy).toContain('routes.js');
expect(r.fileHash).toBe('hash_middleware_js');
});

test('file: exported list', () => {
Expand Down
Loading