diff --git a/CHANGELOG.md b/CHANGELOG.md index acdbcd0..fa50ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329) + ### Fixes - The background file watcher no longer exhausts your machine's file-descriptor budget. On macOS it previously kept **one open file handle per watched file**, so on a large project the running MCP server could pile up tens of thousands of handles and blow past the system-wide limit — at which point *unrelated* apps (your shell, editor, Docker, browser) started failing with "too many open files" until the codegraph process was killed. The watcher now uses a single recursive watch on macOS and Windows, and bounded per-directory watches on Linux, so its cost stays flat no matter how large the project is. (#644, #496, #555, #628, #579) diff --git a/__tests__/status-json.test.ts b/__tests__/status-json.test.ts new file mode 100644 index 0000000..772152f --- /dev/null +++ b/__tests__/status-json.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for the CI/scripting fields `codegraph status --json` exposes (issue + * #329): the `version`, `indexPath`, and `lastIndexed` fields, plus the + * matching `CodeGraph.getLastIndexedAt()` library method. + * + * The CLI itself is exercised end-to-end against the built binary so the JSON + * field names survive future refactors of the underlying plumbing. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); +const PKG_VERSION = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'), +).version as string; + +function runStatusJson(cwd: string): Record { + const stdout = execFileSync(process.execPath, [BIN, 'status', '--json'], { + cwd, + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + // JSON mode prints exactly one line to stdout; be defensive about any stray + // leading output by parsing the last non-empty line. + const line = stdout.trim().split('\n').filter(Boolean).pop()!; + return JSON.parse(line); +} + +describe('codegraph status --json — CI fields (#329)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-status-json-')); + }); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('getLastIndexedAt() is null before indexing and a recent ms timestamp after', async () => { + const cg = CodeGraph.initSync(tempDir); + expect(cg.getLastIndexedAt()).toBeNull(); + + fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export const x = 1;\n'); + const before = Date.now(); + await cg.indexAll(); + const after = Date.now(); + + const last = cg.getLastIndexedAt(); + expect(last).not.toBeNull(); + expect(typeof last).toBe('number'); + expect(last!).toBeGreaterThanOrEqual(before - 1000); + expect(last!).toBeLessThanOrEqual(after + 1000); + cg.close(); + }); + + it('status --json on an UNINITIALIZED project reports version + indexPath + lastIndexed:null', () => { + const out = runStatusJson(tempDir); + expect(out.initialized).toBe(false); + expect(out.version).toBe(PKG_VERSION); + expect(typeof out.indexPath).toBe('string'); + expect(out.indexPath as string).toContain('.codegraph'); + expect(out.lastIndexed).toBeNull(); + }); + + it('status --json on an INDEXED project reports version + indexPath + a round-trippable lastIndexed', async () => { + fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export const x = 1;\n'); + const before = Date.now(); + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + const after = Date.now(); + cg.close(); + + const out = runStatusJson(tempDir); + expect(out.initialized).toBe(true); + expect(out.version).toBe(PKG_VERSION); + expect(out.indexPath as string).toContain('.codegraph'); + expect(typeof out.lastIndexed).toBe('string'); + // ISO string that round-trips back into the index window. + const ms = Date.parse(out.lastIndexed as string); + expect(ms).toBeGreaterThanOrEqual(before - 1000); + expect(ms).toBeLessThanOrEqual(after + 1000); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 59f500a..0acc700 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -676,7 +676,13 @@ program try { if (!isInitialized(projectPath)) { if (options.json) { - console.log(JSON.stringify({ initialized: false, projectPath })); + console.log(JSON.stringify({ + initialized: false, + version: packageJson.version, + projectPath, + indexPath: getCodeGraphDir(projectPath), + lastIndexed: null, + })); return; } console.log(chalk.bold('\nCodeGraph Status\n')); @@ -695,9 +701,13 @@ program // JSON output mode if (options.json) { + const lastIndexedMs = cg.getLastIndexedAt(); console.log(JSON.stringify({ initialized: true, + version: packageJson.version, projectPath, + indexPath: getCodeGraphDir(projectPath), + lastIndexed: lastIndexedMs != null ? new Date(lastIndexedMs).toISOString() : null, fileCount: stats.fileCount, nodeCount: stats.nodeCount, edgeCount: stats.edgeCount, diff --git a/src/db/queries.ts b/src/db/queries.ts index d7c9173..1d1026f 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1421,6 +1421,17 @@ export class QueryBuilder { return rows.map(rowToFileRecord); } + /** + * Most recent index timestamp (ms since epoch) across all tracked files, or + * null when nothing is indexed yet. One indexed aggregate, no per-row scan. (#329) + */ + getLastIndexedAt(): number | null { + const row = this.db + .prepare('SELECT MAX(indexed_at) AS last FROM files') + .get() as { last: number | null } | undefined; + return row?.last ?? null; + } + /** * Get files that need re-indexing (hash changed) */ diff --git a/src/index.ts b/src/index.ts index 1687c48..fc8b3de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -576,6 +576,15 @@ export class CodeGraph { return this.orchestrator.getChangedFiles(); } + /** + * Most recent index timestamp (ms since epoch) across all tracked files, or + * null when nothing is indexed yet. Lets library consumers check index + * freshness without shelling out to `codegraph status --json`. (#329) + */ + getLastIndexedAt(): number | null { + return this.queries.getLastIndexedAt(); + } + /** * Extract nodes and edges from source code (without storing) */