Files
codegraph/__tests__/sync-framework-detection.test.ts
T
Colby MchenryandClaude Opus 5.5 31ec908224 fix(sync): a long-running session picks up a framework added while it runs (#2941)
The MCP server's watcher holds one CodeGraph for the whole session, and
its framework detection was cached from the first time it ran: the
orchestrator reset it only in indexAll, and CodeGraph.sync never
re-initialized the resolver. A framework that arrived mid-session, like
Express in a new package.json, was missing from every file synced after
it, so a new route file got no route nodes until `codegraph index`. The
resolver's declared-dependencies cache was also keyed by the indexed file
COUNT (PR #2519): swapping one app for another of the same size, or
editing a manifest, kept the old dependency set, even through
reinitializeResolver().

Detection now records what it read: indexed source files, which a sync's
change lists cover, and every other path it read or probed, with a size
and mtime signature, including the per-app manifests the extractor gating
reads. Before a sync extracts files, it detects again only when files
were added or removed since, a source file detection read changed, or one
of those paths changed on disk. A scoped sync takes the file list from
the index plus the files it adds, so it never walks the project (the
first sync in a process used to run a full scan for its first detection).
What detection read is kept between detections and dropped by the same
change lists and signatures, so detecting again reads only what changed.
When the detected set differs from the resolver's, the sync
re-initializes the resolver. Its initialize() now clears its caches
before detecting, and clearCaches() forgets the declared-dependencies
cache with the other per-context memos.

Files indexed before a framework change are not re-extracted. A framework
like Express applies to every file of its language, and re-reading them
all inside a watcher's sync would cost what a full index does. Instead a
full index records the frameworks it used, a sync records how detection
moved since, and `codegraph status` (text, and --json with
reindexRecommended and frameworksChangedSinceIndex) recommends
`codegraph index` while they differ. Status only reads that record.

Validation:
- sync-framework-detection.test.ts (new, 8 cases): Express arriving with a
  new route file, by full and by scoped sync; a .svelte file added with no
  manifest change; an edited source file that detection read; a
  manifest-only edit picked up by the next sync, with the status record,
  cleared on revert and by a full re-index; a fresh process's scoped
  syncs detecting once, from the index, with no scan; the equal-size app
  swap; and the dependency cache forgetting on request. 7 of 8 fail on
  main's source (the 8th needs the new export). Ten mutations, one per
  mechanism, each fail a test.
- The triage probes: main detects [] after Express arrives and routes.js
  gets no route; the fix detects express and extracts GET /hello, by full
  and by scoped sync. For the swap, main's reinitializeResolver() gives
  [react] and the fix [express].
- Cost on excalidraw (737 files, scoped syncs in one long-lived
  instance): the detection step takes 8 ms on a sync where nothing it
  read changed and 8-13 ms when it detects again, against syncs of about
  260 ms (add) to 2 s (modify). The first sync in a process detects once
  (about 300 ms) and no longer scans the project.

Reported in PRs #2516 and #2519 by @nk33-dev. This reimplements both
fixes independently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:31:26 +00:00

228 lines
10 KiB
TypeScript

/**
* A long-lived graph keeps framework detection current across syncs (#2516,
* #2519).
*
* The MCP server's watcher holds one CodeGraph for the whole session. Its
* framework detection used to be cached from the first time it ran, so a
* framework that arrived mid-session, like Express in a new `package.json`,
* was missing from every file synced after it until `codegraph index`. A sync
* now detects again when what detection read changed, without walking the
* project on a watcher's scoped sync, and says when files indexed earlier
* were read without the change.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as cp from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { detectFrameworks } from '../src/resolution/frameworks';
import { clearDeclaredDependencies, declaredDependencies } from '../src/resolution/frameworks/package-deps';
import type { ResolutionContext } from '../src/resolution/types';
// Pass-through wrappers, so a test can count framework detections and see the git commands run.
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
});
vi.mock('../src/resolution/frameworks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/resolution/frameworks')>();
return { ...actual, detectFrameworks: vi.fn(actual.detectFrameworks) };
});
const EXPRESS = JSON.stringify({ dependencies: { express: '^4.18.0' } });
const ROUTES = "const app = express();\napp.get('/hello', function hello(req, res) {});\n";
describe('framework detection in a long-lived graph (#2516)', () => {
let root: string;
let cg: CodeGraph | undefined;
const write = (rel: string, body: string) => {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), body);
};
const routes = (rel: string) => cg!.getNodesInFile(rel).filter((n) => n.kind === 'route').map((n) => n.name);
beforeEach(() => {
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-frameworks-')));
});
afterEach(() => {
cg?.close();
cg = undefined;
fs.rmSync(root, { recursive: true, force: true });
});
it.each(['full', 'scoped'] as const)('reads a file added with a new framework using it (%s sync)', async (mode) => {
write('src/app.ts', 'export function boot() { return 1; }\n');
cg = CodeGraph.initSync(root);
await cg.indexAll();
// An ordinary edit first, so this graph has detected (no frameworks) and cached it.
write('src/app.ts', 'export function boot() { return 2; }\n');
await cg.sync(mode === 'scoped' ? { paths: ['src/app.ts'] } : {});
expect(cg.getDetectedFrameworks()).not.toContain('express');
write('package.json', EXPRESS);
write('src/routes.js', ROUTES);
await cg.sync(mode === 'scoped' ? { paths: ['src/routes.js'] } : {});
expect(cg.getDetectedFrameworks()).toContain('express');
expect(routes('src/routes.js')).toEqual(['GET /hello']);
});
it('detects again when a file is added, with no manifest change', async () => {
write('src/app.ts', 'export function boot() { return 1; }\n');
cg = CodeGraph.initSync(root);
await cg.indexAll();
expect(cg.getDetectedFrameworks()).not.toContain('svelte');
// Svelte is detected from a .svelte file alone.
write('src/Widget.svelte', '<script>let count = 0;</script>\n<button>{count}</button>\n');
await cg.sync({ paths: ['src/Widget.svelte'] });
expect(cg.getDetectedFrameworks()).toContain('svelte');
});
it('detects again when a source file detection read is edited', async () => {
write('src/app.ts', 'export function boot() { return 1; }\n');
cg = CodeGraph.initSync(root);
await cg.indexAll();
expect(cg.getDetectedFrameworks()).not.toContain('react-native-bridge');
// The bridge is detected from a TurboModule spec in the first files' source.
write('src/app.ts', "export const Native = TurboModuleRegistry.getEnforcing<Spec>('Native');\n");
await cg.sync({ paths: ['src/app.ts'] });
expect(cg.getDetectedFrameworks()).toContain('react-native-bridge');
});
it('notices a manifest edit at the next sync, and says what the files indexed before it lack', async () => {
write('src/server.js', ROUTES);
write('src/util.js', 'function util() { return 1; }\n');
cg = CodeGraph.initSync(root);
await cg.indexAll();
expect(routes('src/server.js')).toEqual([]);
expect(cg.getFrameworkChangesSinceIndex()).toBeNull();
// No source file changes with the manifest; the next edit anywhere syncs it.
write('package.json', EXPRESS);
write('src/util.js', 'function util() { return 2; }\n');
await cg.sync({ paths: ['src/util.js'] });
expect(cg.getDetectedFrameworks()).toContain('express');
// server.js was indexed without Express and is unchanged: status recommends a re-index.
expect(routes('src/server.js')).toEqual([]);
expect(cg.getFrameworkChangesSinceIndex()).toEqual({ added: ['express'], removed: [] });
// Going back to what the index was built with clears it.
write('package.json', JSON.stringify({ dependencies: {} }));
write('src/util.js', 'function util() { return 3; }\n');
await cg.sync({ paths: ['src/util.js'] });
expect(cg.getDetectedFrameworks()).not.toContain('express');
expect(cg.getFrameworkChangesSinceIndex()).toBeNull();
// A full re-index reads every file with the new frameworks, and the note goes.
write('package.json', EXPRESS);
cg.close();
cg = await CodeGraph.recreate(root);
await cg.indexAll();
expect(routes('src/server.js')).toEqual(['GET /hello']);
expect(cg.getFrameworkChangesSinceIndex()).toBeNull();
});
});
describe('a scoped sync in a fresh process keeps off the full scan (#2516)', () => {
let root: string;
let cg: CodeGraph | undefined;
const git = (...args: string[]) => cp.execFileSync('git', args, { cwd: root, stdio: 'pipe' });
// The full scan's untracked listing. (The scope matcher's embedded-repo
// discovery lists with `--directory`, once per process, by design.)
const scans = () => vi.mocked(cp.execFileSync).mock.calls
.filter(([cmd, args]) => cmd === 'git' && Array.isArray(args) && args.join(' ') === 'ls-files -z -o --exclude-standard');
beforeEach(() => {
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-noscan-')));
});
afterEach(() => {
cg?.close();
cg = undefined;
fs.rmSync(root, { recursive: true, force: true });
});
it('detects from the index, once, and not again while nothing it read changed', async () => {
git('init', '-q');
git('config', 'user.email', 'test@example.invalid');
git('config', 'user.name', 'Test');
fs.writeFileSync(path.join(root, '.gitignore'), '.codegraph/\n');
fs.writeFileSync(path.join(root, 'package.json'), EXPRESS);
fs.mkdirSync(path.join(root, 'src'));
fs.writeFileSync(path.join(root, 'src', 'routes.js'), ROUTES);
git('add', '-A');
git('commit', '-qm', 'init');
const built = CodeGraph.initSync(root);
await built.indexAll();
built.close();
// The watcher's process: open, then sync one edited file at a time.
cg = CodeGraph.openSync(root);
vi.mocked(cp.execFileSync).mockClear();
vi.mocked(detectFrameworks).mockClear();
for (const n of [2, 3]) {
fs.writeFileSync(path.join(root, 'src', 'routes.js'), ROUTES + `// edit ${n}\n`);
await cg.sync({ paths: ['src/routes.js'] });
}
expect(scans()).toEqual([]);
expect(vi.mocked(detectFrameworks)).toHaveBeenCalledTimes(1);
expect(cg.getNodesInFile('src/routes.js').filter((n) => n.kind === 'route').map((n) => n.name)).toEqual(['GET /hello']);
});
});
describe('declared dependencies in a long-lived graph (#2519)', () => {
let root: string;
let cg: CodeGraph | undefined;
const write = (rel: string, body: string) => {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), body);
};
beforeEach(() => {
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-deps-')));
});
afterEach(() => {
cg?.close();
cg = undefined;
fs.rmSync(root, { recursive: true, force: true });
});
it('sees one app swapped for another with as many files', async () => {
write('web/package.json', JSON.stringify({ dependencies: { react: '^18.0.0' } }));
write('web/a.js', 'export function a() { return 1; }\n');
write('web/b.js', 'export function b() { return 2; }\n');
cg = CodeGraph.initSync(root);
await cg.indexAll();
expect(cg.getDetectedFrameworks()).toContain('react');
fs.rmSync(path.join(root, 'web'), { recursive: true, force: true });
write('server/package.json', EXPRESS);
write('server/a.js', ROUTES);
write('server/b.js', 'export function b() { return 2; }\n');
await cg.sync();
expect(cg.getDetectedFrameworks()).toContain('express');
expect(cg.getDetectedFrameworks()).not.toContain('react');
cg.reinitializeResolver();
expect(cg.getDetectedFrameworks()).toContain('express');
expect(cg.getDetectedFrameworks()).not.toContain('react');
});
it('reads the manifests again once told to forget them', () => {
write('package.json', JSON.stringify({ dependencies: { react: '^18.0.0' } }));
const files = ['a.js'];
const context = {
getAllFiles: () => files,
fileExists: (rel: string) => fs.existsSync(path.join(root, rel)),
readFile: (rel: string) => (fs.existsSync(path.join(root, rel)) ? fs.readFileSync(path.join(root, rel), 'utf8') : null),
} as unknown as ResolutionContext;
expect([...declaredDependencies(context)]).toEqual(['react']);
write('package.json', EXPRESS);
// Same file count: the count alone keeps the old answer.
expect([...declaredDependencies(context)]).toEqual(['react']);
clearDeclaredDependencies(context);
expect([...declaredDependencies(context)]).toEqual(['express']);
});
});