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
+68
-54
@@ -34,6 +34,7 @@ import { getGlyphs } from '../ui/glyphs';
|
||||
import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
|
||||
import { installFatalHandlers } from './fatal-handler';
|
||||
import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
|
||||
import { installCommandSupervision } from './command-supervision';
|
||||
import { EXTRACTION_VERSION } from '../extraction/extraction-version';
|
||||
import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
|
||||
|
||||
@@ -506,19 +507,25 @@ program
|
||||
// Indexing runs by default now. The legacy -i/--index flag is still
|
||||
// accepted (so existing muscle memory and scripts don't break) but is a
|
||||
// no-op — initializing always builds the initial index.
|
||||
// Supervise the index: self-terminate if orphaned or wedged (#999).
|
||||
const supervision = installCommandSupervision('init');
|
||||
let result: IndexResult;
|
||||
if (options.verbose) {
|
||||
result = await cg.indexAll({
|
||||
onProgress: createVerboseProgress(),
|
||||
verbose: true,
|
||||
});
|
||||
} else {
|
||||
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
|
||||
const progress = createShimmerProgress();
|
||||
result = await cg.indexAll({
|
||||
onProgress: progress.onProgress,
|
||||
});
|
||||
await progress.stop();
|
||||
try {
|
||||
if (options.verbose) {
|
||||
result = await cg.indexAll({
|
||||
onProgress: createVerboseProgress(),
|
||||
verbose: true,
|
||||
});
|
||||
} else {
|
||||
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
|
||||
const progress = createShimmerProgress();
|
||||
result = await cg.indexAll({
|
||||
onProgress: progress.onProgress,
|
||||
});
|
||||
await progress.stop();
|
||||
}
|
||||
} finally {
|
||||
supervision.stop();
|
||||
}
|
||||
printIndexResult(clack, result, projectPath);
|
||||
await recordIndexTelemetry(cg, result);
|
||||
@@ -627,51 +634,58 @@ program
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
|
||||
if (options.quiet) {
|
||||
// Quiet mode: no UI, just run. `index` is a full re-index, so clear the
|
||||
// existing graph and rebuild from scratch (see the note below — #874).
|
||||
// Supervise the indexer: self-terminate if orphaned (parent shim killed)
|
||||
// or if the main thread wedges — neither was guarded on this path (#999).
|
||||
const supervision = installCommandSupervision('index');
|
||||
try {
|
||||
if (options.quiet) {
|
||||
// Quiet mode: no UI, just run. `index` is a full re-index, so clear the
|
||||
// existing graph and rebuild from scratch (see the note below — #874).
|
||||
cg.clear();
|
||||
const result = await cg.indexAll();
|
||||
if (!result.success) process.exit(1);
|
||||
cg.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const clack = await importESM('@clack/prompts');
|
||||
clack.intro('Indexing project');
|
||||
|
||||
// `index` is a FULL re-index: clear the existing graph and rebuild it from
|
||||
// scratch so the result is identical to a fresh `init`. Without the clear,
|
||||
// indexAll() skips every unchanged file by its content hash and reports
|
||||
// "0 nodes, 0 edges" against the already-populated graph — which reads as
|
||||
// "index wiped my index" (#874). For fast incremental updates use `sync`.
|
||||
cg.clear();
|
||||
const result = await cg.indexAll();
|
||||
if (!result.success) process.exit(1);
|
||||
|
||||
let result: IndexResult;
|
||||
|
||||
if (options.verbose) {
|
||||
result = await cg.indexAll({
|
||||
onProgress: createVerboseProgress(),
|
||||
verbose: true,
|
||||
});
|
||||
} else {
|
||||
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
|
||||
const progress = createShimmerProgress();
|
||||
result = await cg.indexAll({
|
||||
onProgress: progress.onProgress,
|
||||
});
|
||||
await progress.stop();
|
||||
}
|
||||
|
||||
printIndexResult(clack, result, projectPath);
|
||||
await recordIndexTelemetry(cg, result);
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
clack.outro('Done');
|
||||
cg.destroy();
|
||||
return;
|
||||
} finally {
|
||||
supervision.stop();
|
||||
}
|
||||
|
||||
const clack = await importESM('@clack/prompts');
|
||||
clack.intro('Indexing project');
|
||||
|
||||
// `index` is a FULL re-index: clear the existing graph and rebuild it from
|
||||
// scratch so the result is identical to a fresh `init`. Without the clear,
|
||||
// indexAll() skips every unchanged file by its content hash and reports
|
||||
// "0 nodes, 0 edges" against the already-populated graph — which reads as
|
||||
// "index wiped my index" (#874). For fast incremental updates use `sync`.
|
||||
cg.clear();
|
||||
|
||||
let result: IndexResult;
|
||||
|
||||
if (options.verbose) {
|
||||
result = await cg.indexAll({
|
||||
onProgress: createVerboseProgress(),
|
||||
verbose: true,
|
||||
});
|
||||
} else {
|
||||
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
|
||||
const progress = createShimmerProgress();
|
||||
result = await cg.indexAll({
|
||||
onProgress: progress.onProgress,
|
||||
});
|
||||
await progress.stop();
|
||||
}
|
||||
|
||||
printIndexResult(clack, result, projectPath);
|
||||
await recordIndexTelemetry(cg, result);
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
clack.outro('Done');
|
||||
cg.destroy();
|
||||
} catch (err) {
|
||||
error(`Failed to index: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Process supervision for long-running CLI commands (`index` / `init --index`).
|
||||
*
|
||||
* Indexing a large repo can run for a while on the main thread, and #999
|
||||
* surfaced two ways that goes wrong when nothing is watching it:
|
||||
*
|
||||
* 1. **Orphaned worker.** `index` runs in a child re-exec'd with
|
||||
* `--liftoff-only` (the WASM-flag relaunch). Its parent blocks in
|
||||
* `spawnSync`, so when the parent shim is killed it cannot forward the
|
||||
* signal — the child keeps running, now orphaned, pinning a core. The PPID
|
||||
* watchdog (#277) notices the parent/host went away and exits the child.
|
||||
* 2. **Wedged indexer.** The `#850` main-thread liveness watchdog — which
|
||||
* SIGKILLs a process whose event loop stops turning — was wired only into
|
||||
* the MCP `serve` path, so a wedged `index`/`init` was never auto-killed.
|
||||
*
|
||||
* Both reuse the exact mechanisms `serve` already uses; this just makes them
|
||||
* available to a one-shot command. Best-effort and self-disabling: a missing
|
||||
* watchdog never blocks the command from running. Both honour the same env
|
||||
* switches as `serve` (`CODEGRAPH_NO_WATCHDOG`, `CODEGRAPH_PPID_POLL_MS=0`).
|
||||
*/
|
||||
import { installMainThreadWatchdog } from '../mcp/liveness-watchdog';
|
||||
import { supervisionLostReason, parsePpidPollMs, parseHostPpid } from '../mcp/ppid-watchdog';
|
||||
import { isProcessAlive } from '../mcp/daemon-registry';
|
||||
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
|
||||
|
||||
export interface CommandSupervision {
|
||||
/** Tear down both watchdogs. Idempotent; call when the command finishes. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the liveness + PPID watchdogs for the duration of a CLI command.
|
||||
* `label` is used in the shutdown notice (e.g. `"index"`). Returns a handle
|
||||
* whose `stop()` must be called when the command completes so neither watchdog
|
||||
* outlives it.
|
||||
*/
|
||||
export function installCommandSupervision(label: string): CommandSupervision {
|
||||
// Liveness watchdog: a separate process that SIGKILLs us if our event loop
|
||||
// stops turning for too long (a wedged synchronous loop). Self-disables on
|
||||
// CODEGRAPH_NO_WATCHDOG.
|
||||
const liveness = installMainThreadWatchdog();
|
||||
|
||||
// PPID watchdog: detect that the parent (or the host threaded past the
|
||||
// relaunch shim) died and we've been orphaned, then exit instead of leaking.
|
||||
const originalPpid = process.ppid;
|
||||
const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]);
|
||||
const pollMs = parsePpidPollMs(process.env.CODEGRAPH_PPID_POLL_MS);
|
||||
let ppidTimer: ReturnType<typeof setInterval> | null = null;
|
||||
if (pollMs > 0) {
|
||||
ppidTimer = setInterval(() => {
|
||||
const reason = supervisionLostReason({
|
||||
originalPpid,
|
||||
currentPpid: process.ppid,
|
||||
hostPpid,
|
||||
isAlive: isProcessAlive,
|
||||
});
|
||||
if (reason) {
|
||||
try {
|
||||
process.stderr.write(`[CodeGraph ${label}] Parent process exited (${reason}); aborting.\n`);
|
||||
} catch { /* stderr gone with the parent — exit anyway */ }
|
||||
process.exit(1);
|
||||
}
|
||||
}, pollMs);
|
||||
// Never let the watchdog itself keep the process alive past its real work.
|
||||
ppidTimer.unref();
|
||||
}
|
||||
|
||||
let stopped = false;
|
||||
return {
|
||||
stop(): void {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (ppidTimer) clearInterval(ppidTimer);
|
||||
liveness?.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
+46
-7
@@ -19,7 +19,7 @@ import {
|
||||
import { QueryBuilder } from '../db/queries';
|
||||
import { extractFromSource } from './tree-sitter';
|
||||
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages } from './grammars';
|
||||
import { loadExtensionOverrides, loadIncludeIgnoredPatterns } from '../project-config';
|
||||
import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns } from '../project-config';
|
||||
import { isCodeGraphDataDir } from '../directory';
|
||||
import { logDebug, logWarn } from '../errors';
|
||||
import { validatePathWithinRoot, normalizePath } from '../utils';
|
||||
@@ -283,6 +283,20 @@ function loadIncludeIgnoredMatcher(rootDir: string): Ignore | null {
|
||||
return patterns.length > 0 ? ignore().add(patterns) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matcher for the project's `codegraph.json` `exclude` patterns — paths to keep
|
||||
* OUT of the index even when git-tracked, which `.gitignore` cannot do (#999).
|
||||
* The escape hatch for a committed vendor/theme/SDK directory. Returns `null`
|
||||
* when nothing is excluded (the zero-config default → no overhead). Matched
|
||||
* against project-root-relative paths, so it applies uniformly across the whole
|
||||
* workspace, including inside embedded repos (excluding `static/` means gone
|
||||
* everywhere). Built once per scan/sync/scope operation from the scan root.
|
||||
*/
|
||||
function loadExcludeMatcher(rootDir: string): Ignore | null {
|
||||
const patterns = loadExcludePatterns(rootDir);
|
||||
return patterns.length > 0 ? ignore().add(patterns) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `git ls-files --directory` collapses a wholly-untracked/ignored directory into
|
||||
* one entry — and when the command's own cwd is such a directory (the indexed
|
||||
@@ -421,12 +435,25 @@ function findNestedGitRepos(absDir: string, relPrefix: string): string[] {
|
||||
export class ScopeIgnore {
|
||||
private embedded: Array<{ root: string; matcher: Ignore }>;
|
||||
private defaults: Ignore = defaultsOnlyIgnore();
|
||||
constructor(private rootMatcher: Ignore, embedded: Array<{ root: string; matcher: Ignore }>) {
|
||||
constructor(
|
||||
private rootMatcher: Ignore,
|
||||
embedded: Array<{ root: string; matcher: Ignore }>,
|
||||
/**
|
||||
* Project `codegraph.json` `exclude` patterns (#999), matched against the
|
||||
* full root-relative path. Wins over everything else — an explicit user
|
||||
* exclude applies even to tracked files and even inside embedded repos.
|
||||
*/
|
||||
private exclude: Ignore | null = null,
|
||||
) {
|
||||
// Longest root first so paths in nested embedded repos hit the innermost matcher.
|
||||
this.embedded = [...embedded].sort((a, b) => b.root.length - a.root.length);
|
||||
}
|
||||
|
||||
ignores(rel: string): boolean {
|
||||
// User `exclude` (#999) is checked first and against the full root-relative
|
||||
// path: it must drop git-TRACKED paths (which `.gitignore` can't) and apply
|
||||
// everywhere, including ancestors of embedded repos.
|
||||
if (this.exclude && this.exclude.ignores(rel)) return true;
|
||||
for (const { root, matcher } of this.embedded) {
|
||||
if (rel.startsWith(root)) {
|
||||
const inner = rel.slice(root.length);
|
||||
@@ -455,6 +482,7 @@ export function buildScopeIgnore(rootDir: string, embeddedRoots?: Iterable<strin
|
||||
return new ScopeIgnore(
|
||||
buildDefaultIgnore(rootDir),
|
||||
roots.map((root) => ({ root, matcher: buildDefaultIgnore(path.join(rootDir, root)) })),
|
||||
loadExcludeMatcher(rootDir),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -678,14 +706,14 @@ function getGitChangedFiles(rootDir: string): GitChanges | null {
|
||||
// Custom extension → language overrides from the project's codegraph.json,
|
||||
// so change detection sees the same custom-extension files the full index does.
|
||||
const overrides = loadExtensionOverrides(rootDir);
|
||||
collectGitStatus(rootDir, '', changes, overrides, loadIncludeIgnoredMatcher(rootDir));
|
||||
collectGitStatus(rootDir, '', changes, overrides, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir));
|
||||
return changes;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, overrides?: Record<string, Language>, includeIgnored: Ignore | null = null): void {
|
||||
function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, overrides?: Record<string, Language>, includeIgnored: Ignore | null = null, exclude: Ignore | null = null): void {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['status', '--porcelain', '--no-renames'],
|
||||
@@ -732,6 +760,11 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over
|
||||
// Added (`??`) / modified files inside an excluded dir must not enter the
|
||||
// index — match against the repo-relative path, same as the full scan. (#766)
|
||||
if (ig.ignores(rel)) continue;
|
||||
// User `codegraph.json` `exclude` (#999) is project-root-relative, so it's
|
||||
// matched against the full path — sync must not re-add a tracked file the
|
||||
// full index now keeps out. Deletions above stay unfiltered so a file that
|
||||
// WAS indexed before an exclude was added still cleans itself out.
|
||||
if (exclude && exclude.ignores(filePath)) continue;
|
||||
|
||||
if (statusCode === '??') {
|
||||
out.added.push(filePath);
|
||||
@@ -747,11 +780,11 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over
|
||||
// and they are left alone (#970, #976), mirroring the full-index scan.
|
||||
for (const rel of untrackedDirs) {
|
||||
for (const repoRel of findNestedGitRepos(path.join(repoDir, rel), rel)) {
|
||||
collectGitStatus(path.join(repoDir, repoRel), prefix + repoRel, out, overrides, includeIgnored);
|
||||
collectGitStatus(path.join(repoDir, repoRel), prefix + repoRel, out, overrides, includeIgnored, exclude);
|
||||
}
|
||||
}
|
||||
for (const rel of findIgnoredEmbeddedRepos(repoDir, includeIgnored, prefix)) {
|
||||
collectGitStatus(path.join(repoDir, rel), prefix + rel, out, overrides, includeIgnored);
|
||||
collectGitStatus(path.join(repoDir, rel), prefix + rel, out, overrides, includeIgnored, exclude);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,7 +969,13 @@ function scanDirectoryWalk(
|
||||
|
||||
// Seed a base matcher with the built-in default ignores (merged with the root
|
||||
// .gitignore so a negation can override). Nested .gitignores still layer per-dir.
|
||||
walk(rootDir, [{ dir: rootDir, ig: buildDefaultIgnore(rootDir) }]);
|
||||
const baseMatchers: ScopedIgnore[] = [{ dir: rootDir, ig: buildDefaultIgnore(rootDir) }];
|
||||
// Project `codegraph.json` `exclude` patterns (#999), rooted at the project so
|
||||
// `isIgnored` matches them against root-relative paths — same coverage the
|
||||
// git path gets via ScopeIgnore, for non-git projects.
|
||||
const exclude = loadExcludeMatcher(rootDir);
|
||||
if (exclude) baseMatchers.push({ dir: rootDir, ig: exclude });
|
||||
walk(rootDir, baseMatchers);
|
||||
return files;
|
||||
}
|
||||
|
||||
|
||||
+1
-36
@@ -50,18 +50,11 @@ import {
|
||||
import { connectWithHello, runLocalHandshakeProxy } from './proxy';
|
||||
import { getDaemonSocketPath } from './daemon-paths';
|
||||
import { getTelemetry } from '../telemetry';
|
||||
import { supervisionLostReason } from './ppid-watchdog';
|
||||
import { supervisionLostReason, parsePpidPollMs, parseHostPpid } from './ppid-watchdog';
|
||||
import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog';
|
||||
import { treatStdinFailureAsShutdown } from './stdin-teardown';
|
||||
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
|
||||
|
||||
/**
|
||||
* How often to poll `process.ppid` to detect parent process death (see #277).
|
||||
* 5s is a deliberate trade-off: the failure mode being guarded against is rare
|
||||
* (parent SIGKILL'd), and longer poll = less wakeup overhead while idle.
|
||||
*/
|
||||
const DEFAULT_PPID_POLL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Env var that marks a process as the *detached daemon* itself (set by
|
||||
* {@link spawnDetachedDaemon} when it re-invokes the CLI). Without it a
|
||||
@@ -94,34 +87,6 @@ const TAKEOVER_RETRY_DELAY_MS = 100;
|
||||
const DAEMON_CONNECT_MAX_RETRIES = 240;
|
||||
const DAEMON_CONNECT_RETRY_DELAY_MS = 25;
|
||||
|
||||
/**
|
||||
* Resolve the PPID watchdog poll interval from an env override. A value of
|
||||
* `0` disables the watchdog entirely (escape hatch for embedded scenarios
|
||||
* where the parent legitimately re-parents the server on purpose). Anything
|
||||
* non-numeric or negative falls back to the default.
|
||||
*/
|
||||
function parsePpidPollMs(raw: string | undefined): number {
|
||||
if (raw === undefined || raw === '') return DEFAULT_PPID_POLL_MS;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_PPID_POLL_MS;
|
||||
if (parsed < 0) return DEFAULT_PPID_POLL_MS;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the host PID propagated across the `--liftoff-only` re-exec
|
||||
* ({@link HOST_PPID_ENV}). Returns a positive integer PID, or null when
|
||||
* unset/invalid — the direct-launch path, where the watchdog falls back to
|
||||
* `process.ppid` divergence. PIDs of 0/1 are rejected (0 = unknown, 1 = init,
|
||||
* i.e. already orphaned), so the watchdog doesn't latch onto init.
|
||||
*/
|
||||
function parseHostPpid(raw: string | undefined): number | null {
|
||||
if (raw === undefined || raw === '') return null;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 1) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Whether `CODEGRAPH_NO_DAEMON` was set to a truthy value. */
|
||||
function daemonOptOutSet(): boolean {
|
||||
const raw = process.env.CODEGRAPH_NO_DAEMON;
|
||||
|
||||
@@ -61,3 +61,35 @@ export function supervisionLostReason(state: SupervisionState): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Default PPID poll cadence (ms). Shared by the MCP server and CLI commands. */
|
||||
export const DEFAULT_PPID_POLL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Resolve the PPID watchdog poll interval from an env override
|
||||
* (`CODEGRAPH_PPID_POLL_MS`). A value of `0` disables the watchdog entirely
|
||||
* (escape hatch for embedded scenarios where the parent legitimately re-parents
|
||||
* the process on purpose). Anything non-numeric or negative falls back to the
|
||||
* default.
|
||||
*/
|
||||
export function parsePpidPollMs(raw: string | undefined): number {
|
||||
if (raw === undefined || raw === '') return DEFAULT_PPID_POLL_MS;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_PPID_POLL_MS;
|
||||
if (parsed < 0) return DEFAULT_PPID_POLL_MS;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the host PID propagated across the `--liftoff-only` re-exec
|
||||
* (`CODEGRAPH_HOST_PPID`). Returns a positive integer PID, or null when
|
||||
* unset/invalid — the direct-launch path, where the watchdog falls back to
|
||||
* `process.ppid` divergence. PIDs of 0/1 are rejected (0 = unknown, 1 = init,
|
||||
* i.e. already orphaned), so the watchdog doesn't latch onto init.
|
||||
*/
|
||||
export function parseHostPpid(raw: string | undefined): number | null {
|
||||
if (raw === undefined || raw === '') return null;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 1) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
+56
-2
@@ -42,12 +42,24 @@ export interface ProjectConfig {
|
||||
* are never discovered or indexed (#970, #976).
|
||||
*/
|
||||
includeIgnored?: string[];
|
||||
/**
|
||||
* Gitignore-style patterns for paths to keep OUT of the index — even when
|
||||
* they are git-TRACKED, which `.gitignore` cannot do (#999). The escape hatch
|
||||
* for a committed vendor/theme/SDK directory (e.g. a checked-in Metronic theme
|
||||
* under `static/`) that bloats the graph and slows indexing but isn't really
|
||||
* your code. Matched against project-root-relative paths, so a directory like
|
||||
* `"static/"`, a double-star vendor glob, or `"assets/theme"` all work.
|
||||
* Absent/empty (the default) excludes nothing beyond the built-in defaults
|
||||
* and your `.gitignore`.
|
||||
*/
|
||||
exclude?: string[];
|
||||
}
|
||||
|
||||
/** Parsed, validated view of a project's `codegraph.json`. */
|
||||
interface ParsedConfig {
|
||||
extensions: Record<string, Language>;
|
||||
includeIgnored: string[];
|
||||
exclude: string[];
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -68,6 +80,7 @@ const EMPTY_EXTENSIONS: Record<string, Language> = Object.freeze({});
|
||||
const EMPTY_CONFIG: ParsedConfig = Object.freeze({
|
||||
extensions: EMPTY_EXTENSIONS,
|
||||
includeIgnored: Object.freeze([]) as unknown as string[],
|
||||
exclude: Object.freeze([]) as unknown as string[],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -118,8 +131,11 @@ function parseConfig(file: string): ParsedConfig {
|
||||
|
||||
const extensions = extractExtensions(parsed, file);
|
||||
const includeIgnored = extractIncludeIgnored(parsed, file);
|
||||
if (extensions === EMPTY_EXTENSIONS && includeIgnored.length === 0) return EMPTY_CONFIG;
|
||||
return { extensions, includeIgnored };
|
||||
const exclude = extractExclude(parsed, file);
|
||||
if (extensions === EMPTY_EXTENSIONS && includeIgnored.length === 0 && exclude.length === 0) {
|
||||
return EMPTY_CONFIG;
|
||||
}
|
||||
return { extensions, includeIgnored, exclude };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,6 +188,32 @@ function extractIncludeIgnored(parsed: object, file: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the `exclude` patterns: an array of non-empty gitignore-style
|
||||
* strings naming paths to keep out of the index even when git-tracked (#999). A
|
||||
* non-array value or a non-string/blank entry warns-and-skips; never throws.
|
||||
* Patterns are kept verbatim (trimmed) so they match exactly as a `.gitignore`
|
||||
* line would, against project-root-relative paths.
|
||||
*/
|
||||
function extractExclude(parsed: object, file: string): string[] {
|
||||
const raw = (parsed as ProjectConfig).exclude;
|
||||
if (raw === undefined) return [];
|
||||
if (!Array.isArray(raw)) {
|
||||
logWarn(`Ignoring "exclude" in ${PROJECT_CONFIG_FILENAME}: must be an array of gitignore-style patterns`, { file });
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== 'string' || !entry.trim()) {
|
||||
logWarn(`Ignoring an "exclude" entry in ${PROJECT_CONFIG_FILENAME}: every pattern must be a non-empty string`, { file });
|
||||
continue;
|
||||
}
|
||||
out.push(entry.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the parsed `codegraph.json` for a project, mtime-cached. A missing or
|
||||
* malformed file yields the zero-config default. One `stat` (and at most one
|
||||
@@ -221,6 +263,18 @@ export function loadIncludeIgnoredPatterns(rootDir: string): string[] {
|
||||
return loadParsedConfig(rootDir).includeIgnored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the validated `exclude` patterns for a project, mtime-cached.
|
||||
*
|
||||
* These name paths to keep OUT of the index even when git-tracked — the escape
|
||||
* hatch for a committed vendor/theme/SDK directory `.gitignore` can't drop
|
||||
* (#999). An empty result — the zero-config default — excludes nothing beyond
|
||||
* the built-in defaults and the project's `.gitignore`.
|
||||
*/
|
||||
export function loadExcludePatterns(rootDir: string): string[] {
|
||||
return loadParsedConfig(rootDir).exclude;
|
||||
}
|
||||
|
||||
/** Test/maintenance hook: forget cached config (e.g. after rewriting it in a test). */
|
||||
export function clearProjectConfigCache(): void {
|
||||
cache.clear();
|
||||
|
||||
@@ -7,6 +7,33 @@
|
||||
import { Node } from '../types';
|
||||
import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types';
|
||||
|
||||
/**
|
||||
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
|
||||
* score. A name defined more times than this is "ubiquitous" — a method/symbol
|
||||
* re-declared across a vendored theme or SDK (e.g. `init`/`update`/`render` on
|
||||
* every widget of a committed Metronic theme — #999). No directory-proximity or
|
||||
* receiver-word-overlap score can reliably pick THE one true target among
|
||||
* thousands, so the fuzzy strategies (matchByExactName's findBestMatch, and
|
||||
* matchMethodCall Strategy 3) decline above the ceiling instead of emitting a
|
||||
* low-confidence, almost-certainly-wrong edge. This also caps their per-ref cost
|
||||
* at O(ceiling): without it, K same-named refs each scored K candidates — the
|
||||
* O(K²) blow-up that pinned a core for 15-28 min at "Resolving refs … 94%" on a
|
||||
* repo vendoring a large JS/TS theme (#999). The PRECISE strategies are
|
||||
* unaffected: qualified-name, import-based, and class-name (Strategy 1/2)
|
||||
* resolution all still run and resolve a ubiquitous name when the context names
|
||||
* its exact target. Real repos top out near ~40 same-named methods, so a normal
|
||||
* codebase never reaches this; only bulk-vendored code does. Tune via
|
||||
* `CODEGRAPH_AMBIGUOUS_NAME_CEILING`.
|
||||
*/
|
||||
const DEFAULT_AMBIGUOUS_NAME_CEILING = 500;
|
||||
function resolveAmbiguousNameCeiling(): number {
|
||||
const raw = process.env.CODEGRAPH_AMBIGUOUS_NAME_CEILING;
|
||||
if (!raw) return DEFAULT_AMBIGUOUS_NAME_CEILING;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AMBIGUOUS_NAME_CEILING;
|
||||
}
|
||||
const AMBIGUOUS_NAME_CEILING = resolveAmbiguousNameCeiling();
|
||||
|
||||
/**
|
||||
* Try to resolve a path-like reference (e.g., "snippets/drawer-menu.liquid")
|
||||
* by matching the filename against file nodes.
|
||||
@@ -344,6 +371,15 @@ export function matchByExactName(
|
||||
};
|
||||
}
|
||||
|
||||
// Ubiquitous-name ceiling (#999): above it, picking one target among K
|
||||
// same-named defs by directory proximity is unreliable AND O(K) per ref — the
|
||||
// quadratic behind the "Resolving refs" wedge on theme/SDK-vendoring repos.
|
||||
// Decline; the precise strategies (qualified-name, import, class-name) already
|
||||
// ran. Falls through to fuzzy, which itself only resolves a UNIQUE candidate.
|
||||
if (candidates.length > AMBIGUOUS_NAME_CEILING) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Multiple matches - try to narrow down
|
||||
const bestMatch = findBestMatch(ref, candidates, context);
|
||||
if (bestMatch) {
|
||||
@@ -1067,6 +1103,15 @@ export function matchMethodCall(
|
||||
// names like permissionEngine → PermissionRuleEngine.
|
||||
if (methodName) {
|
||||
const methodCandidates = context.getNodesByName(methodName!);
|
||||
// Ubiquitous-method ceiling (#999): a method name re-declared across a
|
||||
// vendored theme/SDK (Metronic's `init`/`update`/… on every widget) yields
|
||||
// K candidates that receiver-word overlap can't reliably disambiguate —
|
||||
// and filtering + scoring all K per call is the O(K²) cost that wedged
|
||||
// "Resolving refs" for 15-28 min. Bail before the O(K) work; Strategy 1/2
|
||||
// (class-name match) already had their precise shot above.
|
||||
if (methodCandidates.length > AMBIGUOUS_NAME_CEILING) {
|
||||
return null;
|
||||
}
|
||||
const methods = methodCandidates.filter(
|
||||
(n) => n.kind === 'method' && n.name === methodName
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user