Files
codegraph/__tests__/exclude-config.test.ts
T
45d3293c6a fix(resolution): stop "Resolving refs" wedge on theme-vendoring repos; add exclude config + index watchdogs (#999) (#1009)
Three fixes for a repo that commits a large JS/TS theme/SDK (Metronic under
static/, ~1,600 tracked files):

1. A SECOND "Resolving refs" quadratic that #915 didn't cover. #915 capped
   import-name collisions; this caps method-name collisions (init/update/render
   re-declared on every widget), which flow through matchMethodCall Strategy 3
   and findBestMatch instead. New AMBIGUOUS_NAME_CEILING (default 500, env
   CODEGRAPH_AMBIGUOUS_NAME_CEILING): above it the fuzzy strategies decline
   rather than score K candidates — no proximity score can pick the one true
   target among thousands anyway. Resolving drops from O(K^2) to linear in refs
   (e.g. 900-file synthetic: 28.7s -> 3.4s), edge counts unchanged, and the cap
   never fires on normal repos (max real method-collision ~40).

2. A new `exclude` array in codegraph.json keeps git-TRACKED paths out of the
   index, which .gitignore can't do (enumeration is `git ls-files`). Mirrors the
   existing includeIgnored plumbing across the git, sync, and non-git-walk
   paths.

3. `index`/`init` now install the #850 liveness + #277 ppid watchdogs (which
   were serve-only), so a wedged or orphaned indexer self-terminates instead of
   pinning a core. The --liftoff-only relaunch's spawnSync can't forward
   signals, so killing the parent shim used to orphan the worker.

Tests: ubiquitous-name ceiling, exclude (incl. tracked-file exclusion on git +
non-git), orphan self-termination (POSIX), and ppid-parser units. Shared the
ppid parsers out of mcp/index.ts into mcp/ppid-watchdog.ts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:25:47 -05:00

166 lines
6.4 KiB
TypeScript

/**
* `codegraph.json` `exclude` — keep paths out of the index even when git-TRACKED
* (#999).
*
* The escape hatch for a committed vendor/theme/SDK directory (a checked-in
* Metronic theme under `static/`) that `.gitignore` cannot drop because git
* tracks it. Two layers under test:
* 1. Loader: parse/validate/cache, mirroring the `includeIgnored` loader.
* 2. Behavior: `scanDirectory` drops excluded paths on BOTH the git
* (`git ls-files`) and non-git (filesystem walk) enumeration paths — and
* crucially for TRACKED files, which is the whole point.
*
* Invariant: every loader failure mode degrades to the zero-config default
* (exclude nothing), never a throw.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { execFileSync } from 'node:child_process';
import { loadExcludePatterns, loadExtensionOverrides, loadIncludeIgnoredPatterns, clearProjectConfigCache } from '../src/project-config';
import { scanDirectory } from '../src/extraction';
describe('exclude loader (codegraph.json)', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-exclude-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const writeConfig = (obj: unknown) =>
fs.writeFileSync(
path.join(dir, 'codegraph.json'),
typeof obj === 'string' ? obj : JSON.stringify(obj)
);
it('returns an empty list when there is no codegraph.json (the default)', () => {
expect(loadExcludePatterns(dir)).toEqual([]);
});
it('loads a well-formed pattern array', () => {
writeConfig({ exclude: ['static/', '**/vendor/**'] });
expect(loadExcludePatterns(dir)).toEqual(['static/', '**/vendor/**']);
});
it('trims whitespace and drops blank / non-string entries', () => {
writeConfig({ exclude: [' static/ ', '', ' ', 42, null, 'vendor/'] });
expect(loadExcludePatterns(dir)).toEqual(['static/', 'vendor/']);
});
it('ignores a non-array exclude value without throwing', () => {
writeConfig({ exclude: 'static/' });
expect(loadExcludePatterns(dir)).toEqual([]);
});
it('ignores malformed JSON without throwing', () => {
writeConfig('{ not: valid json ');
expect(loadExcludePatterns(dir)).toEqual([]);
});
it('coexists with extensions and includeIgnored in one file (shared single parse)', () => {
writeConfig({ extensions: { '.foo': 'typescript' }, includeIgnored: ['pkgs/'], exclude: ['static/'] });
expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' });
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['pkgs/']);
expect(loadExcludePatterns(dir)).toEqual(['static/']);
});
it('picks up a changed config (mtime-invalidated cache)', () => {
writeConfig({ exclude: ['static/'] });
expect(loadExcludePatterns(dir)).toEqual(['static/']);
writeConfig({ exclude: ['assets/'] });
const future = new Date(Date.now() + 2000);
fs.utimesSync(path.join(dir, 'codegraph.json'), future, future);
expect(loadExcludePatterns(dir)).toEqual(['assets/']);
});
it('drops the patterns again when the config file is removed', () => {
writeConfig({ exclude: ['static/'] });
expect(loadExcludePatterns(dir)).toEqual(['static/']);
fs.rmSync(path.join(dir, 'codegraph.json'));
expect(loadExcludePatterns(dir)).toEqual([]);
});
});
describe('exclude behavior — scanDirectory drops excluded paths (#999)', () => {
let dir: string;
const mk = (rel: string, content = 'export const x = 1;\n') => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content);
};
const writeConfig = (obj: unknown) =>
fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify(obj));
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-exclude-scan-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const gitInit = () => {
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['add', '-A'], { cwd: dir });
execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'x'], { cwd: dir });
};
it('keeps a TRACKED excluded dir out of the index (git path) — the core fix', () => {
mk('app/main.ts');
mk('static/theme/widget1.js');
mk('static/theme/widget2.js');
gitInit(); // static/ is now git-TRACKED — .gitignore could not drop it
// Sanity: without exclude the tracked theme IS indexed.
let files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('static/'))).toBe(true);
// With exclude the tracked theme is gone, app code stays.
writeConfig({ exclude: ['static/'] });
clearProjectConfigCache();
files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('static/'))).toBe(false);
});
it('excludes a tracked dir on the non-git filesystem-walk path too', () => {
mk('app/main.ts');
mk('static/theme/widget1.js');
// No git init → scanDirectory falls back to the filesystem walk.
writeConfig({ exclude: ['static/'] });
clearProjectConfigCache();
const files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('static/'))).toBe(false);
});
it('supports a double-star glob', () => {
mk('src/a.ts');
mk('packages/x/vendor/lib1.js');
mk('packages/y/vendor/lib2.js');
gitInit();
writeConfig({ exclude: ['**/vendor/**'] });
clearProjectConfigCache();
const files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('src/a.ts');
expect(files.some((f) => f.includes('/vendor/'))).toBe(false);
});
it('is a no-op with no exclude config (everything indexed)', () => {
mk('app/main.ts');
mk('static/theme/widget1.js');
gitInit();
const files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('static/'))).toBe(true);
});
});