From 4de978ecfd285e8964b3a7cea2976e1e4d4b1083 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Thu, 10 Sep 2026 22:35:02 -0600 Subject: [PATCH 1/2] fix(cli): reject sync for stale extraction indexes --- CHANGELOG.md | 3 +- __tests__/cli-sync-stale-index.test.ts | 85 ++++++++++++++++++++++++++ src/bin/codegraph.ts | 5 ++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 __tests__/cli-sync-stale-index.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 75535a0bbd..7be224b93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,8 +143,6 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Trails are plain JSON, one file per trail, under `.codegraph/ui/trails/` — already ignored by git, so they stay yours by default. **Export** hands you the file if you'd rather commit one for the team. This is the only thing the viewer writes: it still never indexes, never changes your graph, and never touches a line of your code. Start it with `codegraph ui --read-only` and it won't write even that — saved trails can still be opened, just not saved or deleted. -### Fixes - - 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) @@ -154,6 +152,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Steps diagrams retain database operations made through external client chains without inventing internal dependencies. - 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. (#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..a074922518 --- /dev/null +++ b/__tests__/cli-sync-stale-index.test.ts @@ -0,0 +1,85 @@ +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; + if (quiet) expect(output).toBe(''); + else expect(output).toContain('Run "codegraph index"'); + expect(output).not.toContain('Already up to date'); + 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 a6fe54c98e..a4beda6d22 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -955,6 +955,11 @@ program const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); + if (cg.isIndexStale()) { + cg.destroy(); + throw new Error('Index extraction is stale. Run "codegraph index" for a full rebuild before syncing.'); + } + if (options.quiet) { await cg.sync(); cg.destroy(); From 8ac04d4231d1bd82ca298bbc0fb015469808f140 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Fri, 11 Sep 2026 23:27:09 -0600 Subject: [PATCH 2/2] fix(cli): explain a refused quiet sync on stderr A quiet sync is the git-hook path. The stale-index refusal still exits 1, but now writes one line to stderr so a hook that fails the commit on a non-zero exit does not block it for an invisible reason. (#1798) --- CHANGELOG.md | 2 +- __tests__/cli-sync-stale-index.test.ts | 9 +++++++-- src/bin/codegraph.ts | 10 +++++++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be224b93a..63f921ad36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,7 +152,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Steps diagrams retain database operations made through external client chains without inventing internal dependencies. - 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. (#1798) +- `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 index a074922518..a8a89180d0 100644 --- a/__tests__/cli-sync-stale-index.test.ts +++ b/__tests__/cli-sync-stale-index.test.ts @@ -53,9 +53,14 @@ describe('explicit CLI sync with stale extraction', () => { const result = run(...args); expect(result.status).toBe(1); const output = result.stdout + result.stderr; - if (quiet) expect(output).toBe(''); - else expect(output).toContain('Run "codegraph index"'); + 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); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index a4beda6d22..fd5303fd80 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -957,7 +957,15 @@ program if (cg.isIndexStale()) { cg.destroy(); - throw new Error('Index extraction is stale. Run "codegraph index" for a full rebuild before syncing.'); + 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); } if (options.quiet) {