diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c3b4b7947..ee375ff457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -397,6 +397,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The Claude Code prompt hook no longer runs on the task-notification messages Claude Code injects when a background agent finishes, removing a multi-second stall on every such turn. (#1832) + - Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861) - Turning telemetry off now resets its identity and stops running processes from recording, sending, or restoring unsent data. (#1869) @@ -405,6 +406,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Zustand actions keep their callers when read through typed stores, destructured from store state, or selected by a hook. - Direct React Native bridge calls retain their native implementations and cross-platform relationships. - Dart extension-type getters remain searchable when using the WebAssembly parser. +- `codegraph sync` now refuses outdated extraction indexes instead of reporting them as up to date, and directs users to a full rebuild; with `--quiet` the reason is still printed as one line on stderr so a git hook failure is explainable. (#1798) - Calling a built-in method on an awaited value no longer records a call into an unrelated class that happens to declare a method of the same name, and a variable bound to an awaited call now resolves methods on the type that call returns. Thanks @maxmilian. (#1840) - Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461) diff --git a/__tests__/cli-sync-stale-index.test.ts b/__tests__/cli-sync-stale-index.test.ts new file mode 100644 index 0000000000..a8a89180d0 --- /dev/null +++ b/__tests__/cli-sync-stale-index.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { EXTRACTION_VERSION } from '../src/extraction/extraction-version'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); +const { DatabaseSync } = require('node:sqlite'); +let root: string; + +function run(...args: string[]) { + return spawnSync(process.execPath, [BIN, ...args], { + cwd: root, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' }, + }); +} + +function setStamp(stamp: number | null) { + const db = new DatabaseSync(path.join(root, '.codegraph', 'codegraph.db')); + try { + if (stamp === null) { + db.prepare('DELETE FROM project_metadata WHERE key = ?').run('indexed_with_extraction_version'); + } else { + db.prepare('UPDATE project_metadata SET value = ? WHERE key = ?') + .run(String(stamp), 'indexed_with_extraction_version'); + } + } finally { + db.close(); + } +} + +beforeEach(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sync-stale-')); + fs.writeFileSync(path.join(root, 'original.ts'), 'export function original() { return 1; }\n'); + const cg = CodeGraph.initSync(root); + try { await cg.indexAll(); } finally { cg.close(); } +}); + +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('explicit CLI sync with stale extraction', () => { + for (const stamp of [EXTRACTION_VERSION - 1, null]) { + for (const quiet of [false, true]) { + it(`rejects stamp ${stamp} before syncing, quiet=${quiet}`, () => { + setStamp(stamp); + const args = quiet ? ['sync', '--quiet'] : ['sync']; + for (const changed of [false, true]) { + if (changed) fs.writeFileSync(path.join(root, 'added.ts'), 'export const added = 2;\n'); + const result = run(...args); + expect(result.status).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('Run "codegraph index"'); + expect(output).not.toContain('Already up to date'); + if (quiet) { + // One explanatory line on stderr, nothing on stdout, so a hook that + // fails the commit on exit 1 still shows the user why. (#1798) + expect(result.stdout).toBe(''); + expect(result.stderr.trim().split('\n')).toHaveLength(1); + } + const cg = CodeGraph.openSync(root); + try { + expect(cg.isIndexStale()).toBe(true); + expect(cg.getStats().fileCount).toBe(1); + } finally { cg.close(); } + } + }); + } + } + + it.each([false, true])('still syncs a current index, quiet=%s', (quiet) => { + fs.writeFileSync(path.join(root, 'added.ts'), 'export const added = 2;\n'); + expect(run('sync', ...(quiet ? ['--quiet'] : [])).status).toBe(0); + const cg = CodeGraph.openSync(root); + try { + expect(cg.isIndexStale()).toBe(false); + expect(cg.getStats().fileCount).toBe(2); + } finally { cg.close(); } + }); + + it('accepts sync after the prescribed full rebuild', () => { + setStamp(EXTRACTION_VERSION - 1); + expect(run('sync').status).toBe(1); + expect(run('index').status).toBe(0); + expect(run('sync').status).toBe(0); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index de33c1a47e..3e61c7fee0 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1009,6 +1009,19 @@ program const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); + if (cg.isIndexStale()) { + cg.destroy(); + const message = 'Index extraction is stale. Run "codegraph index" for a full rebuild before syncing.'; + // A quiet sync is the git-hook path. The exit status still says "no", + // but one stderr line explains why so a hook that fails the commit on a + // non-zero exit does not block it with an invisible reason. (#1798) + if (options.quiet) { + process.stderr.write(`codegraph sync: ${message}\n`); + process.exit(1); + } + throw new Error(message); + } + try { if (options.quiet) { await cg.sync();