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:
co-authored by
Claude Opus 4.8
parent
d3179f5004
commit
45d3293c6a
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user