From 26193a2e15e3262049d32061734248b73c23ada4 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Fri, 30 Oct 2026 06:08:16 -0500 Subject: [PATCH] fix(status): recommend a re-index when package.json alone adds or drops a framework Since PR #2941, a sync records the frameworks that came or went since the last full index, and `codegraph status` recommends a re-index. But a sync detects frameworks again only when it extracts a file, so adding `express` or `@sveltejs/kit` to package.json with no source file edited recorded nothing: status said the index was up to date while an unchanged Express app's routes, or the `goto('/login')` in an unchanged +layout.svelte, stayed unlinked until a full index. A full index now also stamps the resolver's framework set, and getFrameworkChangesSinceIndex() adds the difference between that stamp and the set the resolver detects now (it detects on every open) to what syncs recorded. So a fresh `codegraph status` sees a manifest-only change whether or not a sync ran. An index built before this has no stamp, so that half starts at its next full index. A long-running server's resolver keeps its old set until it extracts a file, as before. Validation on main 61776927 (Windows 11): - Red/green: 3 of the 4 new tests fail with main's src/index.ts; the 4th, an unchanged project staying silent, passes on both. All 4 pass with the fix, as do the 8 sync-framework-detection tests. - The CLI before and after, on a SvelteKit fixture indexed without @sveltejs/kit, then given it in package.json, then synced: main's status says "Index is up to date", and --json has reindexRecommended false and frameworksChangedSinceIndex null. With the fix, status adds "Frameworks changed since the last full index (added sveltekit-router)", and --json has true and {added: [sveltekit-router]}. Dropping the dependency gives {removed: [sveltekit-router]}, adding express to an Express app gives {added: [express]}, an unchanged project gives null on both arms, and a full `codegraph index` clears the advice. - No false advice: a fresh index and then `status --json` on 18 real repos reports no change on any. They include express, flask, gin, vuejs/core, excalidraw, vuejs/docs, starlight, codegraph's own source and vscode's base and editor; Express, Flask, Next.js, React, Vue, Vue Router, Astro, Svelte, Spring, Go and UIKit are detected among them. Original PR #3005 by @inth3shadows. Fixes #3007 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../framework-hint-resolver-only.test.ts | 103 ++++++++++++++++++ src/bin/codegraph.ts | 5 +- src/index.ts | 55 +++++++--- 4 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 __tests__/framework-hint-resolver-only.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac94fef2..347407f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,7 +72,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - When several CodeGraph processes start at the same moment after one of them crashed, like the MCP server, a git hook's `codegraph sync` and `codegraph index`, only one of them now takes over the write lock the crashed process left behind. Before, two of them could both take it, the second one deleting the first one's lock as it did, and both went on to write to the index at once. A write lock held by another user's CodeGraph, or on Windows by one running as administrator, is no longer mistaken for a crashed one's and taken over either. Thanks @inth3shadows for the report and @vedantb7 for the first fix. (#2979) - A program that uses CodeGraph as a library and runs `indexAll()` again on a project it has already indexed now keeps every symbol findable by the words in its name, which the prompt hook and `codegraph_explore` use to match a question like "how does the order state machine work" to `OrderStateMachine`. Before, only the files changed since the last index kept their names' words, and a cancelled run lost them all; `codegraph index` was not affected. Thanks @rudycelekli. - The shared background server is no longer killed with "Main thread unresponsive" while it brings another project's index up to date, such as one a session names with `projectPath`: only its own project's index counted as progress, so a long update of any other looked like a hang, and the server was stopped and restarted again and again. Every index the server keeps up to date now counts, and so does the one a session updates on its own when the background server is unavailable. Thanks @mradwankhalil for the report and the first fix. (#2404) -- A long-running session, like the background server behind your agent, now notices a framework that arrives or goes while it runs, such as Express in a new `package.json`, or one app replaced by another with as many files. Files it indexes from then on are read with the frameworks the project has now; before, they were read with the ones it had when the session started, so a new Express app's routes were missing until a full `codegraph index`. Files indexed before the change are not read again, so `codegraph status` now says which frameworks changed since the last full index and recommends running one. Thanks @nk33-dev for the report and the first fix. (#2516, #2519) +- A long-running session, like the background server behind your agent, now notices a framework that arrives or goes while it runs, such as Express in a new `package.json`, or one app replaced by another with as many files. Files it indexes from then on are read with the frameworks the project has now; before, they were read with the ones it had when the session started, so a new Express app's routes were missing until a full `codegraph index`. Files indexed before the change are not read again, so `codegraph status` now says which frameworks changed since the last full index and recommends running one, including when only `package.json` changed. Thanks @nk33-dev for the report and the first fix, and @inth3shadows for the `package.json` case. (#2516, #2519, #3007) - On Windows, deleting the folder a link in your project points to, like a junction to a library kept outside the project, no longer makes CodeGraph's server use a full CPU core until it is restarted, or stop picking up your edits. The linked files now drop out of the index while the rest of the project keeps updating, and if the project folder itself disappears while watched, live updates turn off with a message that says so. Thanks @L0garithmic. - A project a session queries through `projectPath` is now let go once it has gone its own 10 minutes without a query. Before, when its index was still being brought up to date as the session went on to query another project, it was held, locked, until that other project's 10 minutes were up, so its own session and `codegraph index` could wait nearly twice as long to take over. Thanks @bompus. (#2087) - An index left incomplete by an indexing run that was stopped partway, by a crash, an out-of-memory kill or the server's own hang check, no longer gets the background server killed with "Main thread unresponsive" every time it starts: on a large project, restoring the database indexes the run had set aside took longer than the hang check allows, so each restart died at the same point and the index was never repaired. They are now restored in the background, and the project is served once they are back. Thanks @cbeaulieu-gt for the report and the first fix. (#1887) diff --git a/__tests__/framework-hint-resolver-only.test.ts b/__tests__/framework-hint-resolver-only.test.ts new file mode 100644 index 000000000..cb7203df3 --- /dev/null +++ b/__tests__/framework-hint-resolver-only.test.ts @@ -0,0 +1,103 @@ +/** + * `codegraph status` recommends a re-index when a framework arrives or leaves + * through `package.json` alone. + * + * #2516 records the frameworks a full index extracted with, and a sync that + * detects a different set says the files indexed before it lack the change. A + * sync detects again only when it extracts a file, though: add `@sveltejs/kit` + * (or `express`) to `package.json` with no source file edited and nothing is + * recorded, so status stayed silent while `goto('/login')` in an unchanged + * `+layout.svelte` (or an unchanged Express app's routes) stayed unlinked. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const KIT = JSON.stringify({ name: 'app', devDependencies: { '@sveltejs/kit': '^2.0.0', svelte: '^4.0.0' } }); +const NO_KIT = JSON.stringify({ name: 'app', devDependencies: { svelte: '^4.0.0' } }); + +describe('framework changes since the last full index, from package.json alone', () => { + let root: string; + let cg: CodeGraph | undefined; + const write = (rel: string, content: string) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + }; + const navigates = (): number => { + const nodes = cg!.getNodesInFile('src/routes/+layout.svelte'); + return cg!.getOutgoingEdgesFrom(nodes.map((n) => n.id)).filter((e) => e.kind === 'navigates').length; + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fw-hint-')); + write('src/routes/+layout.svelte', '\n'); + write('src/routes/login/+page.svelte', '

login

\n'); + }); + + afterEach(() => { + cg?.destroy(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('names the SvelteKit router when @sveltejs/kit is added after the index', async () => { + write('package.json', NO_KIT); + cg = CodeGraph.initSync(root); + await cg.indexAll(); + expect(cg.getFrameworkChangesSinceIndex()).toBeNull(); + + write('package.json', KIT); + cg.close(); + cg = CodeGraph.openSync(root); + await cg.sync(); + // The unchanged layout's goto is still unlinked: only a re-index links it... + expect(navigates()).toBe(0); + // ...which is what status must now say. + expect(cg.getFrameworkChangesSinceIndex()?.added).toContain('sveltekit-router'); + + cg.close(); + cg = await CodeGraph.recreate(root); + await cg.indexAll(); + expect(navigates()).toBe(1); + expect(cg.getFrameworkChangesSinceIndex()).toBeNull(); + }); + + it('names it as removed when the dependency goes', async () => { + write('package.json', KIT); + cg = CodeGraph.initSync(root); + await cg.indexAll(); + expect(navigates()).toBe(1); + + write('package.json', NO_KIT); + cg.close(); + cg = CodeGraph.openSync(root); + expect(cg.getFrameworkChangesSinceIndex()?.removed).toContain('sveltekit-router'); + }); + + it('names Express when it is added with no source file changed', async () => { + write('package.json', JSON.stringify({ name: 'app', dependencies: {} })); + write('src/server.js', "const express = require('express');\nconst app = express();\napp.get('/hello', (req, res) => res.send('hi'));\n"); + cg = CodeGraph.initSync(root); + await cg.indexAll(); + expect(cg.getFrameworkChangesSinceIndex()).toBeNull(); + + write('package.json', JSON.stringify({ name: 'app', dependencies: { express: '^4.0.0' } })); + cg.close(); + cg = CodeGraph.openSync(root); + await cg.sync(); + expect(cg.getFrameworkChangesSinceIndex()?.added).toContain('express'); + }); + + it('says nothing when the project is as it was indexed', async () => { + write('package.json', KIT); + cg = CodeGraph.initSync(root); + await cg.indexAll(); + cg.close(); + cg = CodeGraph.openSync(root); + await cg.sync(); + expect(cg.getFrameworkChangesSinceIndex()).toBeNull(); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 17748416e..3efd91ace 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1167,8 +1167,9 @@ program const buildInfo = cg.getIndexBuildInfo(); const reindexRecommended = cg.isIndexStale(); - // Frameworks a sync detected since the last full index (#2516): files - // indexed before then were extracted without them. + // Frameworks that came or went since the last full index (#2516), as a + // sync recorded them or as detected now: files indexed before then were + // read without the change. const frameworkChanges = cg.getFrameworkChangesSinceIndex(); const indexState = cg.getIndexState(); // Zero on a healthy index; non-zero at rest means a resolution pass was diff --git a/src/index.ts b/src/index.ts index a181cd620..49d9f2b22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -163,6 +163,14 @@ const FRAMEWORKS_AT_INDEX_KEY = 'frameworks_at_index'; * last full index, against {@link FRAMEWORKS_AT_INDEX_KEY}; '' when none. */ const FRAMEWORKS_SINCE_INDEX_KEY = 'frameworks_since_index'; +/** + * Metadata key: the frameworks (sorted JSON names) the resolver had at the last + * full index. A sync detects again only when it extracts a file, so one that + * finds just `package.json` changed records nothing in + * {@link FRAMEWORKS_SINCE_INDEX_KEY}; the resolver detects on every open, so + * its set is compared against this one as it is now. + */ +const RESOLVER_FRAMEWORKS_AT_INDEX_KEY = 'resolver_frameworks_at_index'; /** A JSON list of names from metadata, or null when absent or unreadable. */ function parseNameList(value: string | null): string[] | null { @@ -985,6 +993,7 @@ export class CodeGraph { try { this.queries.setMetadata(FRAMEWORKS_AT_INDEX_KEY, JSON.stringify([...names].sort())); this.queries.setMetadata(FRAMEWORKS_SINCE_INDEX_KEY, ''); + this.queries.setMetadata(RESOLVER_FRAMEWORKS_AT_INDEX_KEY, JSON.stringify([...this.resolver.getDetectedFrameworks()].sort())); } catch { /* metadata is advisory */ } } @@ -1016,24 +1025,42 @@ export class CodeGraph { } /** - * The frameworks a sync detected since the last full index, against the ones - * that index extracted every file with (#2516): files indexed before the - * change were read without the frameworks added, and with the ones removed, - * so a full re-index brings them in line. Null when nothing changed, or for - * an index built before this was recorded. + * The frameworks that came or went since the last full index (#2516): files + * indexed before the change were read without the frameworks added, and with + * the ones removed, so a full re-index brings them in line. Null when nothing + * changed; an index built before a half of this was recorded gives nothing + * for that half. + * + * The first half is what a sync detected against the frameworks the index + * extracted every file with. + * + * A sync that only finds a manifest changed (`express` or `@sveltejs/kit` + * added to `package.json`, no source file edited) extracts nothing, so it + * never detects again and records nothing: the files indexed before it still + * lack the framework. The resolver's set as detected on open, against the + * one the last full index had, covers that. */ getFrameworkChangesSinceIndex(): { added: string[]; removed: string[] } | null { + const names = (list: unknown): string[] => (Array.isArray(list) ? list.filter((n): n is string => typeof n === 'string') : []); + const added = new Set(); + const removed = new Set(); try { const value = this.queries.getMetadata(FRAMEWORKS_SINCE_INDEX_KEY); - if (!value) return null; - const parsed = JSON.parse(value) as { added?: unknown; removed?: unknown }; - const names = (list: unknown): string[] => (Array.isArray(list) ? list.filter((n): n is string => typeof n === 'string') : []); - const added = names(parsed.added); - const removed = names(parsed.removed); - return added.length > 0 || removed.length > 0 ? { added, removed } : null; - } catch { - return null; - } + if (value) { + const parsed = JSON.parse(value) as { added?: unknown; removed?: unknown }; + for (const name of names(parsed.added)) added.add(name); + for (const name of names(parsed.removed)) removed.add(name); + } + } catch { /* metadata is advisory */ } + try { + const atIndex = parseNameList(this.queries.getMetadata(RESOLVER_FRAMEWORKS_AT_INDEX_KEY)); + if (atIndex) { + const now = this.resolver.getDetectedFrameworks(); + for (const name of now) if (!atIndex.includes(name)) added.add(name); + for (const name of atIndex) if (!now.includes(name)) removed.add(name); + } + } catch { /* metadata is advisory */ } + return added.size > 0 || removed.size > 0 ? { added: [...added].sort(), removed: [...removed].sort() } : null; } async sync(options: IndexOptions = {}): Promise {