mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 21:05:25 +08:00
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>
228 lines
10 KiB
TypeScript
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']);
|
|
});
|
|
});
|