diff --git a/CHANGELOG.md b/CHANGELOG.md index b97392b9..3922cafd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,58 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [2.4.0](https://github.com/optave/codegraph/compare/v2.3.0...v2.4.0) (2026-02-25) + +**Co-change analysis, node roles, faster parsing, and richer Mermaid output.** This release adds git co-change analysis to surface files that change together, classifies nodes by architectural role (entry/core/utility/adapter/dead/leaf), replaces the manual AST walk with tree-sitter's Query API for significantly faster JS/TS/TSX extraction, and enhances Mermaid export with subgraphs, edge labels, node shapes, and styling. + +### Features + +* **cli:** add git co-change analysis — surfaces files that frequently change together using Jaccard similarity on git history ([61785f7](https://github.com/optave/codegraph/commit/61785f7)) +* **cli:** add node role classification — automatically labels nodes as entry, core, utility, adapter, dead, or leaf based on graph topology ([165f6ca](https://github.com/optave/codegraph/commit/165f6ca)) +* **cli:** add `--json` to `search`, `--file` glob filter, `--exclude` to `prune`, exclude worktrees from vitest ([00ed205](https://github.com/optave/codegraph/commit/00ed205)) +* **cli:** add update notification after commands — checks npm for newer versions and displays an upgrade hint ([eb3ccdf](https://github.com/optave/codegraph/commit/eb3ccdf)) +* **export:** enhance Mermaid export with subgraphs, edge labels, node shapes, and styling ([ae301c0](https://github.com/optave/codegraph/commit/ae301c0)) + +### Performance + +* **parser:** replace manual AST walk with tree-sitter Query API for JS/TS/TSX extraction ([fb6a139](https://github.com/optave/codegraph/commit/fb6a139)) +* **builder:** avoid disk reads for line counts during incremental rebuild ([7b538bc](https://github.com/optave/codegraph/commit/7b538bc)) + +### Bug Fixes + +* **builder:** preserve structure data during incremental builds ([7377fd9](https://github.com/optave/codegraph/commit/7377fd9)) +* **embedder:** make embed command respect config `embeddings.model` ([77ffffc](https://github.com/optave/codegraph/commit/77ffffc)) +* **embedder:** use `DEFAULT_MODEL` as single source of truth for embed default ([832fa49](https://github.com/optave/codegraph/commit/832fa49)) +* **embedder:** add model disposal to prevent ONNX memory leak ([383e899](https://github.com/optave/codegraph/commit/383e899)) +* **export:** escape quotes in Mermaid labels ([1c4ca34](https://github.com/optave/codegraph/commit/1c4ca34)) +* **queries:** recompute Jaccard from total file counts during incremental co-change analysis ([e2a771b](https://github.com/optave/codegraph/commit/e2a771b)) +* **queries:** collect all distinct edge kinds per pair instead of keeping only first ([4f40eee](https://github.com/optave/codegraph/commit/4f40eee)) +* **queries:** skip keys without `::` separator in role lookup ([0c10e23](https://github.com/optave/codegraph/commit/0c10e23)) +* **resolve:** use `indexOf` for `::` split to handle paths with colons ([b9d6ae4](https://github.com/optave/codegraph/commit/b9d6ae4)) +* validate glob patterns and exclude names, clarify regex escaping ([6cf191f](https://github.com/optave/codegraph/commit/6cf191f)) +* clean up regex escaping and remove unsupported brace from glob detection ([ab0d3a0](https://github.com/optave/codegraph/commit/ab0d3a0)) +* **ci:** prevent benchmark updater from deleting README subsections ([bd1682a](https://github.com/optave/codegraph/commit/bd1682a)) +* **ci:** add `--allow-same-version` to `npm version` in publish workflow ([9edaf15](https://github.com/optave/codegraph/commit/9edaf15)) + +### Refactoring + +* reuse `coChangeForFiles` in `diffImpactData` ([aef1787](https://github.com/optave/codegraph/commit/aef1787)) + +### Testing + +* add query vs walk parity tests for JS/TS/TSX extractors ([e68f6a7](https://github.com/optave/codegraph/commit/e68f6a7)) + +### Chores + +* configure `bge-large` as default embedding model ([c21c387](https://github.com/optave/codegraph/commit/c21c387)) + +### Documentation + +* add co-change analysis to README and mark backlog #9 done ([f977f9c](https://github.com/optave/codegraph/commit/f977f9c)) +* reorganize docs — move guides to `docs/guides/`, roadmap into `docs/` ([ad423b7](https://github.com/optave/codegraph/commit/ad423b7)) +* move roadmap files into `docs/roadmap/` ([693a8aa](https://github.com/optave/codegraph/commit/693a8aa)) +* add Plan Mode Default working principle to CLAUDE.md ([c682f38](https://github.com/optave/codegraph/commit/c682f38)) + ## [2.3.0](https://github.com/optave/codegraph/compare/v2.2.1...v2.3.0) (2026-02-23) **Smarter embeddings, richer CLI output, and robustness fixes.** This release introduces graph-enriched embedding strategies that use dependency context instead of raw source code, adds config-level test exclusion and recursive explain depth, outputs Mermaid diagrams from `diff-impact`, filters low-confidence edges from exports, and fixes numerous issues found through dogfooding. diff --git a/package-lock.json b/package-lock.json index 363f6bc5..2b363ce1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@optave/codegraph", - "version": "2.3.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@optave/codegraph", - "version": "2.3.0", + "version": "2.4.0", "license": "Apache-2.0", "dependencies": { "better-sqlite3": "^12.6.2", @@ -44,8 +44,7 @@ "@modelcontextprotocol/sdk": "^1.0.0", "@optave/codegraph-darwin-arm64": "2.3.0", "@optave/codegraph-darwin-x64": "2.3.0", - "@optave/codegraph-linux-x64-gnu": "2.3.0", - "@optave/codegraph-win32-x64-msvc": "2.3.0" + "@optave/codegraph-linux-x64-gnu": "2.3.0" } }, "node_modules/@babel/code-frame": { @@ -1597,9 +1596,9 @@ } }, "node_modules/@optave/codegraph-darwin-arm64": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@optave/codegraph-darwin-arm64/-/codegraph-darwin-arm64-2.2.1.tgz", - "integrity": "sha512-UJBFPZsZLzaCz6IJdUqSi6147ixEyJA8Dhw61erTAC8znNP3WYaw0TbIiBQjur0KmKpScNmWtVtAbYR965EcgQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@optave/codegraph-darwin-arm64/-/codegraph-darwin-arm64-2.3.0.tgz", + "integrity": "sha512-VJ+yXYrradka4gHbkmgpakuPUOQWkahHA9C6YCacttktjlh8xQDVsuCP1MvhtReAt1yG7lxulZN0pCk2IMCHEg==", "cpu": [ "arm64" ], @@ -1610,9 +1609,9 @@ ] }, "node_modules/@optave/codegraph-darwin-x64": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@optave/codegraph-darwin-x64/-/codegraph-darwin-x64-2.2.1.tgz", - "integrity": "sha512-pzKS4R3v+cOB86X+U2rGsgb4AAvAyBIK+WISimjG5i8JRb/XIFmfLYIUx1kBmRiWxtwU3rSUI0hkW6usJNISFA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@optave/codegraph-darwin-x64/-/codegraph-darwin-x64-2.3.0.tgz", + "integrity": "sha512-hl3Hbe5YxkgS1tyvJpuv57wlifNZw7WtCBjFG/rB8foOQak79UlVRgvNdbiUfGiLtjiHCMeK9JPvG2bcQzRPjA==", "cpu": [ "x64" ], @@ -1623,9 +1622,9 @@ ] }, "node_modules/@optave/codegraph-linux-x64-gnu": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@optave/codegraph-linux-x64-gnu/-/codegraph-linux-x64-gnu-2.2.1.tgz", - "integrity": "sha512-EBuVlqxZpmGVSqNHyZcYksN52K4Gz76zp4H86YqQFiLkASS+SfjT4zyWz51r/pns9EflP04MYm+vd+AHLwAqQg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@optave/codegraph-linux-x64-gnu/-/codegraph-linux-x64-gnu-2.3.0.tgz", + "integrity": "sha512-iaCOTRps4JlI6gKuJPe5DD0tK3xseauS9QfvEsooKacnTpaN+y0T9e6V9pSmuDQTdq8vgbIELSlse+1i0AVXug==", "cpu": [ "x64" ], @@ -1635,19 +1634,6 @@ "linux" ] }, - "node_modules/@optave/codegraph-win32-x64-msvc": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@optave/codegraph-win32-x64-msvc/-/codegraph-win32-x64-msvc-2.2.1.tgz", - "integrity": "sha512-4mc38KXAnrT1CUg5HuXcvJFXo8FT3BwWlAi1kTag8D6ZGBCVr0ijHHkTn6FMMkaPeuaNIQS/6LqkH5ew1UHiDw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", diff --git a/package.json b/package.json index 458ad7da..ea801e99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@optave/codegraph", - "version": "2.3.0", + "version": "2.4.0", "description": "Local code graph CLI — parse codebases with tree-sitter, build dependency graphs, query them", "type": "module", "main": "src/index.js", @@ -64,8 +64,7 @@ "@modelcontextprotocol/sdk": "^1.0.0", "@optave/codegraph-darwin-arm64": "2.3.0", "@optave/codegraph-darwin-x64": "2.3.0", - "@optave/codegraph-linux-x64-gnu": "2.3.0", - "@optave/codegraph-win32-x64-msvc": "2.3.0" + "@optave/codegraph-linux-x64-gnu": "2.3.0" }, "devDependencies": { "@biomejs/biome": "^2.4.4", diff --git a/src/cli.js b/src/cli.js index 0407be0b..ea8bb9d9 100644 --- a/src/cli.js +++ b/src/cli.js @@ -39,6 +39,7 @@ import { registerRepo, unregisterRepo, } from './registry.js'; +import { checkForUpdates, printUpdateNotification } from './update-check.js'; import { watchProject } from './watcher.js'; const __cliDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1')); @@ -56,6 +57,17 @@ program .hook('preAction', (thisCommand) => { const opts = thisCommand.opts(); if (opts.verbose) setVerbose(true); + }) + .hook('postAction', async (_thisCommand, actionCommand) => { + const name = actionCommand.name(); + if (name === 'mcp' || name === 'watch') return; + if (actionCommand.opts().json) return; + try { + const result = await checkForUpdates(pkg.version); + if (result) printUpdateNotification(result.current, result.latest); + } catch { + /* never break CLI */ + } }); /** diff --git a/src/update-check.js b/src/update-check.js new file mode 100644 index 00000000..07b5f8a0 --- /dev/null +++ b/src/update-check.js @@ -0,0 +1,159 @@ +import fs from 'node:fs'; +import https from 'node:https'; +import os from 'node:os'; +import path from 'node:path'; + +const CACHE_PATH = + process.env.CODEGRAPH_UPDATE_CACHE_PATH || + path.join(os.homedir(), '.codegraph', 'update-check.json'); + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const FETCH_TIMEOUT_MS = 3000; +const REGISTRY_URL = 'https://registry.npmjs.org/@optave/codegraph/latest'; + +/** + * Minimal semver comparison. Returns -1, 0, or 1. + * Only handles numeric x.y.z (no pre-release tags). + */ +export function semverCompare(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + const na = pa[i] || 0; + const nb = pb[i] || 0; + if (na < nb) return -1; + if (na > nb) return 1; + } + return 0; +} + +/** + * Load the cached update-check result from disk. + * Returns null on missing or corrupt file. + */ +function loadCache(cachePath = CACHE_PATH) { + try { + const raw = fs.readFileSync(cachePath, 'utf-8'); + const data = JSON.parse(raw); + if (!data || typeof data.lastCheckedAt !== 'number' || typeof data.latestVersion !== 'string') { + return null; + } + return data; + } catch { + return null; + } +} + +/** + * Persist the cache to disk (atomic write via temp + rename). + */ +function saveCache(cache, cachePath = CACHE_PATH) { + const dir = path.dirname(cachePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const tmp = `${cachePath}.tmp.${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(cache), 'utf-8'); + fs.renameSync(tmp, cachePath); +} + +/** + * Fetch the latest version string from the npm registry. + * Returns the version string or null on failure. + */ +function fetchLatestVersion() { + return new Promise((resolve) => { + const req = https.get( + REGISTRY_URL, + { timeout: FETCH_TIMEOUT_MS, headers: { Accept: 'application/json' } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + resolve(null); + return; + } + let body = ''; + res.setEncoding('utf-8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + try { + const data = JSON.parse(body); + resolve(typeof data.version === 'string' ? data.version : null); + } catch { + resolve(null); + } + }); + }, + ); + req.on('error', () => resolve(null)); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + }); +} + +/** + * Check whether a newer version of codegraph is available. + * + * Returns `{ current, latest }` if an update is available, `null` otherwise. + * Silently returns null on any error — never affects CLI operation. + * + * Options: + * cachePath — override cache file location (for testing) + * _fetchLatest — override the fetch function (for testing) + */ +export async function checkForUpdates(currentVersion, options = {}) { + // Suppress in non-interactive / CI contexts + if (process.env.CI) return null; + if (process.env.NO_UPDATE_CHECK) return null; + if (!process.stderr.isTTY) return null; + + const cachePath = options.cachePath || CACHE_PATH; + const fetchFn = options._fetchLatest || fetchLatestVersion; + + try { + const cache = loadCache(cachePath); + + // Cache is fresh — use it + if (cache && Date.now() - cache.lastCheckedAt < CACHE_TTL_MS) { + if (semverCompare(currentVersion, cache.latestVersion) < 0) { + return { current: currentVersion, latest: cache.latestVersion }; + } + return null; + } + + // Cache is stale or missing — fetch + const latest = await fetchFn(); + if (!latest) return null; + + // Update cache regardless of result + saveCache({ lastCheckedAt: Date.now(), latestVersion: latest }, cachePath); + + if (semverCompare(currentVersion, latest) < 0) { + return { current: currentVersion, latest }; + } + return null; + } catch { + return null; + } +} + +/** + * Print a visible update notification box to stderr. + */ +export function printUpdateNotification(current, latest) { + const msg1 = `Update available: ${current} → ${latest}`; + const msg2 = 'Run `npm i -g @optave/codegraph` to update'; + const width = Math.max(msg1.length, msg2.length) + 4; + + const top = `┌${'─'.repeat(width)}┐`; + const bot = `└${'─'.repeat(width)}┘`; + const pad1 = ' '.repeat(width - msg1.length - 2); + const pad2 = ' '.repeat(width - msg2.length - 2); + const line1 = `│ ${msg1}${pad1}│`; + const line2 = `│ ${msg2}${pad2}│`; + + process.stderr.write(`\n${top}\n${line1}\n${line2}\n${bot}\n\n`); +} diff --git a/tests/unit/update-check.test.js b/tests/unit/update-check.test.js new file mode 100644 index 00000000..fe4b54af --- /dev/null +++ b/tests/unit/update-check.test.js @@ -0,0 +1,284 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { checkForUpdates, printUpdateNotification, semverCompare } from '../../src/update-check.js'; + +let tmpDir; +let cachePath; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-update-')); + cachePath = path.join(tmpDir, '.codegraph', 'update-check.json'); + // Clear CI env so the early-return guard doesn't short-circuit every test + vi.stubEnv('CI', ''); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); +}); + +// ─── semverCompare ────────────────────────────────────────────────── + +describe('semverCompare', () => { + it('returns 0 for equal versions', () => { + expect(semverCompare('1.2.3', '1.2.3')).toBe(0); + }); + + it('returns -1 when a < b (patch)', () => { + expect(semverCompare('1.2.3', '1.2.4')).toBe(-1); + }); + + it('returns 1 when a > b (patch)', () => { + expect(semverCompare('1.2.4', '1.2.3')).toBe(1); + }); + + it('compares minor versions', () => { + expect(semverCompare('1.2.0', '1.3.0')).toBe(-1); + expect(semverCompare('1.3.0', '1.2.0')).toBe(1); + }); + + it('compares major versions', () => { + expect(semverCompare('1.0.0', '2.0.0')).toBe(-1); + expect(semverCompare('2.0.0', '1.0.0')).toBe(1); + }); + + it('major takes priority over minor and patch', () => { + expect(semverCompare('1.9.9', '2.0.0')).toBe(-1); + }); +}); + +// ─── checkForUpdates ──────────────────────────────────────────────── + +describe('checkForUpdates', () => { + it('returns null when CI env is set', async () => { + vi.stubEnv('CI', 'true'); + const result = await checkForUpdates('1.0.0', { cachePath }); + expect(result).toBeNull(); + }); + + it('returns null when NO_UPDATE_CHECK env is set', async () => { + vi.stubEnv('NO_UPDATE_CHECK', '1'); + const result = await checkForUpdates('1.0.0', { cachePath }); + expect(result).toBeNull(); + }); + + it('returns null when stderr is not a TTY', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = false; + try { + const result = await checkForUpdates('1.0.0', { cachePath }); + expect(result).toBeNull(); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('returns { current, latest } when update is available via fetch', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + const result = await checkForUpdates('1.0.0', { + cachePath, + _fetchLatest: async () => '2.0.0', + }); + expect(result).toEqual({ current: '1.0.0', latest: '2.0.0' }); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('returns null when current version is up to date', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + const result = await checkForUpdates('2.0.0', { + cachePath, + _fetchLatest: async () => '2.0.0', + }); + expect(result).toBeNull(); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('returns null when current version is newer than latest', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + const result = await checkForUpdates('3.0.0', { + cachePath, + _fetchLatest: async () => '2.0.0', + }); + expect(result).toBeNull(); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('uses fresh cache without fetching', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + // Write a fresh cache + const dir = path.dirname(cachePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + cachePath, + JSON.stringify({ lastCheckedAt: Date.now(), latestVersion: '5.0.0' }), + ); + + let fetchCalled = false; + const result = await checkForUpdates('1.0.0', { + cachePath, + _fetchLatest: async () => { + fetchCalled = true; + return '5.0.0'; + }, + }); + + expect(fetchCalled).toBe(false); + expect(result).toEqual({ current: '1.0.0', latest: '5.0.0' }); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('fetches when cache is stale', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + // Write a stale cache (25 hours old) + const dir = path.dirname(cachePath); + fs.mkdirSync(dir, { recursive: true }); + const staleTime = Date.now() - 25 * 60 * 60 * 1000; + fs.writeFileSync( + cachePath, + JSON.stringify({ lastCheckedAt: staleTime, latestVersion: '1.0.0' }), + ); + + let fetchCalled = false; + const result = await checkForUpdates('1.0.0', { + cachePath, + _fetchLatest: async () => { + fetchCalled = true; + return '2.0.0'; + }, + }); + + expect(fetchCalled).toBe(true); + expect(result).toEqual({ current: '1.0.0', latest: '2.0.0' }); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('saves cache after successful fetch', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + await checkForUpdates('1.0.0', { + cachePath, + _fetchLatest: async () => '2.0.0', + }); + + expect(fs.existsSync(cachePath)).toBe(true); + const cache = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + expect(cache.latestVersion).toBe('2.0.0'); + expect(typeof cache.lastCheckedAt).toBe('number'); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('returns null when fetch fails (network error)', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + const result = await checkForUpdates('1.0.0', { + cachePath, + _fetchLatest: async () => null, + }); + expect(result).toBeNull(); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('returns null when fetch throws', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + const result = await checkForUpdates('1.0.0', { + cachePath, + _fetchLatest: async () => { + throw new Error('boom'); + }, + }); + expect(result).toBeNull(); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('handles corrupt cache file gracefully', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + const dir = path.dirname(cachePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(cachePath, 'not valid json {{{'); + + const result = await checkForUpdates('1.0.0', { + cachePath, + _fetchLatest: async () => '2.0.0', + }); + expect(result).toEqual({ current: '1.0.0', latest: '2.0.0' }); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('returns null from fresh cache when version is current', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + const dir = path.dirname(cachePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + cachePath, + JSON.stringify({ lastCheckedAt: Date.now(), latestVersion: '1.0.0' }), + ); + + const result = await checkForUpdates('1.0.0', { cachePath }); + expect(result).toBeNull(); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); +}); + +// ─── printUpdateNotification ──────────────────────────────────────── + +describe('printUpdateNotification', () => { + it('writes a box to stderr', () => { + const chunks = []; + const origWrite = process.stderr.write; + process.stderr.write = (chunk) => { + chunks.push(chunk); + return true; + }; + try { + printUpdateNotification('1.0.0', '2.0.0'); + } finally { + process.stderr.write = origWrite; + } + + const output = chunks.join(''); + expect(output).toContain('Update available: 1.0.0 → 2.0.0'); + expect(output).toContain('npm i -g @optave/codegraph'); + expect(output).toContain('┌'); + expect(output).toContain('└'); + }); +});