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>
This commit is contained in:
Colby Mchenry
2026-06-26 20:25:47 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d3179f5004
commit 45d3293c6a
12 changed files with 805 additions and 100 deletions
+165
View File
@@ -0,0 +1,165 @@
/**
* `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);
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* `index` / `init` command supervision regression test (#999, secondary issues).
*
* `codegraph index` runs in a child re-exec'd with `--liftoff-only` whose parent
* blocks in `spawnSync` and so cannot forward a signal — when the parent shim is
* killed the indexer used to keep running, orphaned, pinning a CPU core. The
* `#850` liveness watchdog and `#277` ppid watchdog were also wired only into
* `serve`, never `index`/`init`. `installCommandSupervision` (src/bin/
* command-supervision.ts) closes both gaps; this proves the orphan half end to
* end: a process running it self-terminates once its parent dies.
*
* Windows is excluded — `process.kill(pid, 'SIGKILL')` doesn't deliver SIGKILL
* there and the reparenting semantics the ppid watchdog relies on are POSIX-only
* (same exclusion as mcp-ppid-watchdog.test.ts).
*/
import { describe, it, expect, afterEach } from 'vitest';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const SUPERVISION = path.resolve(__dirname, '../dist/bin/command-supervision.js');
function isAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now();
const tick = () => {
if (!isAlive(pid)) return resolve(true);
if (Date.now() - start > timeoutMs) return resolve(false);
setTimeout(tick, 100);
};
tick();
});
}
describe.skipIf(process.platform === 'win32')('index/init orphan supervision (#999)', () => {
let wrapper: ChildProcessWithoutNullStreams | null = null;
let childPid: number | null = null;
afterEach(() => {
if (wrapper && !wrapper.killed) {
try { wrapper.kill('SIGKILL'); } catch { /* already gone */ }
}
if (childPid !== null && isAlive(childPid)) {
try { process.kill(childPid, 'SIGKILL'); } catch { /* already gone */ }
}
wrapper = null;
childPid = null;
});
it("self-terminates when its parent is SIGKILL'd mid-index", async () => {
const stderrLog = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'cg-index-orphan-')),
'child.stderr.log',
);
// The child stands in for a running indexer: it installs the SAME command
// supervision `index`/`init` install, then idles on a ref'd timer so it
// stays alive until the watchdog (not the timer) takes it down.
// CODEGRAPH_NO_WATCHDOG=1 isolates the ppid (orphan) path from the liveness
// child; CODEGRAPH_PPID_POLL_MS=200 keeps it responsive in test.
const childSrc = `
const { installCommandSupervision } = require(${JSON.stringify(SUPERVISION)});
installCommandSupervision('index');
process.stdout.write('UP ' + process.pid + '\\n');
setInterval(() => {}, 60000);
`;
// The wrapper spawns the child detached (so it's reparented to init when the
// wrapper dies, not killed with it), waits for it to report its pid + install
// the watchdog, relays the pid, then idles until SIGKILL'd.
const wrapperSrc = `
const { spawn } = require('child_process');
const fs = require('fs');
const errFd = fs.openSync(${JSON.stringify(stderrLog)}, 'a');
const child = spawn(process.execPath, ['-e', ${JSON.stringify(childSrc)}], {
stdio: ['ignore', 'pipe', errFd],
env: { ...process.env, CODEGRAPH_NO_WATCHDOG: '1', CODEGRAPH_PPID_POLL_MS: '200', CODEGRAPH_WASM_RELAUNCHED: '1' },
detached: true,
});
child.unref();
child.stdout.on('data', (d) => {
const m = /UP (\\d+)/.exec(d.toString());
if (m) process.stdout.write(JSON.stringify({ pid: Number(m[1]) }) + '\\n');
});
setInterval(() => {}, 60000);
`;
wrapper = spawn(process.execPath, ['-e', wrapperSrc], {
stdio: ['pipe', 'pipe', 'inherit'],
}) as ChildProcessWithoutNullStreams;
const { pid } = await new Promise<{ pid: number }>((resolve, reject) => {
let buf = '';
const timer = setTimeout(() => reject(new Error('child did not report its pid in time')), 10000);
wrapper!.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString('utf8');
const m = buf.match(/\{"pid":(\d+)\}/);
if (m) { clearTimeout(timer); resolve({ pid: parseInt(m[1], 10) }); }
});
wrapper!.on('exit', () => { clearTimeout(timer); reject(new Error('wrapper exited before reporting pid')); });
});
childPid = pid;
expect(isAlive(childPid)).toBe(true);
// SIGKILL the wrapper — no cleanup runs, just like killing the parent shim.
// The child is reparented to init; only its ppid watchdog can take it down.
wrapper.kill('SIGKILL');
const exited = await waitForExit(childPid, 5000);
const stderr = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : '<none>';
expect(
exited,
`child (pid=${childPid}) did not self-terminate within 5s after parent SIGKILL.\nstderr:\n${stderr}`,
).toBe(true);
// Confirm it died from the parent-death path, not some other cause.
expect(stderr).toMatch(/Parent process exited.*aborting/);
}, 20000);
});
+30 -1
View File
@@ -10,7 +10,7 @@
* stubbing `isAlive` and `platform`.
*/
import { describe, it, expect } from 'vitest';
import { supervisionLostReason } from '../src/mcp/ppid-watchdog';
import { supervisionLostReason, parsePpidPollMs, parseHostPpid, DEFAULT_PPID_POLL_MS } from '../src/mcp/ppid-watchdog';
const alive = () => true;
const dead = () => false;
@@ -136,3 +136,32 @@ describe('supervisionLostReason', () => {
});
});
});
describe('parsePpidPollMs', () => {
it('defaults when unset / empty / non-numeric / negative', () => {
expect(parsePpidPollMs(undefined)).toBe(DEFAULT_PPID_POLL_MS);
expect(parsePpidPollMs('')).toBe(DEFAULT_PPID_POLL_MS);
expect(parsePpidPollMs('abc')).toBe(DEFAULT_PPID_POLL_MS);
expect(parsePpidPollMs('-5')).toBe(DEFAULT_PPID_POLL_MS);
});
it('honours a positive override and floors it', () => {
expect(parsePpidPollMs('200')).toBe(200);
expect(parsePpidPollMs('150.9')).toBe(150);
});
it('treats 0 as the explicit "disable" sentinel (caller skips the timer)', () => {
expect(parsePpidPollMs('0')).toBe(0);
});
});
describe('parseHostPpid', () => {
it('returns null for unset / empty / non-integer / orphan-sentinel pids', () => {
expect(parseHostPpid(undefined)).toBeNull();
expect(parseHostPpid('')).toBeNull();
expect(parseHostPpid('x')).toBeNull();
expect(parseHostPpid('0')).toBeNull(); // unknown
expect(parseHostPpid('1')).toBeNull(); // init = already orphaned
});
it('returns a real positive pid', () => {
expect(parseHostPpid('4242')).toBe(4242);
});
});
+162
View File
@@ -270,6 +270,168 @@ describe('Resolution Module', () => {
});
});
describe('Ubiquitous-name ceiling (#999)', () => {
// A vendored theme/SDK re-declares the same method name across thousands of
// files (Metronic's `init`/`update`/… on every widget). The fuzzy strategies
// used to score every same-named candidate per ref — O(K) per ref, O(K²)
// total — which pinned a core for 15-28 min at "Resolving refs … 94%". Above
// the ceiling they must DECLINE instead, since no proximity/word-overlap
// score can pick the one true target among thousands anyway.
const CEILING = 500;
// A spy context: counts how many nodes the strategy actually inspects, so we
// can assert the cap short-circuits BEFORE the O(K) scoring (not just that it
// returns null).
const makeManyMethods = (n: number, name: string): Node[] =>
Array.from({ length: n }, (_, i) => ({
id: `method:widget${i}.js:Widget${i}.${name}:1`,
kind: 'method' as const,
name,
qualifiedName: `widget${i}.js::Widget${i}::${name}`,
filePath: `static/theme/widget${i}.js`,
language: 'javascript' as const,
startLine: 1,
endLine: 5,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
}));
const spyContext = (nodes: Node[]): { ctx: ResolutionContext; lookups: () => number } => {
let scanned = 0;
const ctx: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: (name) => {
const hit = nodes.filter((n) => n.name === name);
scanned += hit.length;
return hit;
},
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => true,
readFile: () => null,
getProjectRoot: () => '/test',
getAllFiles: () => [],
getNodesByLowerName: () => [],
getImportMappings: () => [],
};
return { ctx, lookups: () => scanned };
};
it('declines a method call (`obj.init`) above the ceiling instead of scoring K candidates', () => {
const { ctx } = spyContext(makeManyMethods(CEILING + 1, 'init'));
const ref = {
fromNodeId: 'method:caller.js:caller:1',
referenceName: 'widget.init',
referenceKind: 'calls' as const,
line: 2,
column: 4,
filePath: 'static/theme/caller.js',
language: 'javascript' as const,
};
expect(matchReference(ref, ctx)).toBeNull();
});
it('declines a bare exact-name ref above the ceiling', () => {
const { ctx } = spyContext(makeManyMethods(CEILING + 1, 'render'));
const ref = {
fromNodeId: 'method:caller.js:caller:1',
referenceName: 'render',
referenceKind: 'calls' as const,
line: 2,
column: 4,
filePath: 'static/theme/caller.js',
language: 'javascript' as const,
};
expect(matchReference(ref, ctx)).toBeNull();
});
it('still resolves a SAME-FILE definition when one exists (precise path unaffected)', () => {
// Strategy 1 (class-name) and same-file matching are precise — a ubiquitous
// name with an unambiguous local target still resolves.
const nodes = makeManyMethods(CEILING + 1, 'init');
const local: Node = {
id: 'class:static/theme/caller.js:Widgetly:1',
kind: 'class',
name: 'Widgetly',
qualifiedName: 'static/theme/caller.js::Widgetly',
filePath: 'static/theme/caller.js',
language: 'javascript',
startLine: 1, endLine: 9, startColumn: 0, endColumn: 0, updatedAt: Date.now(),
};
const localMethod: Node = {
id: 'method:static/theme/caller.js:Widgetly.init:2',
kind: 'method',
name: 'init',
qualifiedName: 'static/theme/caller.js::Widgetly::init',
filePath: 'static/theme/caller.js',
language: 'javascript',
startLine: 2, endLine: 4, startColumn: 0, endColumn: 0, updatedAt: Date.now(),
};
const all = [...nodes, local, localMethod];
const ctx: ResolutionContext = {
getNodesInFile: (fp) => all.filter((n) => n.filePath === fp),
getNodesByName: (name) => all.filter((n) => n.name === name),
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => true,
readFile: () => null,
getProjectRoot: () => '/test',
getAllFiles: () => [],
getNodesByLowerName: () => [],
getImportMappings: () => [],
};
// `Widgetly.init` names the class explicitly → Strategy 1 resolves it.
const ref = {
fromNodeId: 'method:static/theme/caller.js:caller:6',
referenceName: 'Widgetly.init',
referenceKind: 'calls' as const,
line: 6,
column: 4,
filePath: 'static/theme/caller.js',
language: 'javascript' as const,
};
const result = matchReference(ref, ctx);
expect(result?.targetNodeId).toBe('method:static/theme/caller.js:Widgetly.init:2');
});
it('still scores normally JUST below the ceiling (no behavior change for normal repos)', () => {
// Real repos top out near ~40 same-named methods; this proves a sub-ceiling
// collision still resolves via proximity, so the cap is invisible to them.
const nodes = makeManyMethods(CEILING - 1, 'update');
// Make ONE candidate share the caller's directory so proximity picks it.
nodes[0] = {
...nodes[0]!,
id: 'method:static/theme/app/Widget0.update:1',
qualifiedName: 'static/theme/app/widget.js::Widget0::update',
filePath: 'static/theme/app/widget.js',
};
const ctx: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: (name) => nodes.filter((n) => n.name === name),
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => true,
readFile: () => null,
getProjectRoot: () => '/test',
getAllFiles: () => [],
getNodesByLowerName: () => [],
getImportMappings: () => [],
};
const ref = {
fromNodeId: 'method:static/theme/app/caller.js:caller:1',
referenceName: 'update',
referenceKind: 'calls' as const,
line: 2,
column: 4,
filePath: 'static/theme/app/caller.js',
language: 'javascript' as const,
};
// Below the ceiling the fuzzy path runs and resolves SOMETHING (not capped).
expect(matchReference(ref, ctx)).not.toBeNull();
});
});
describe('Import Resolver', () => {
it('should resolve relative import paths', () => {
const context: ResolutionContext = {