/**
 * Analyze Command
 *
 * Indexes a repository and stores the knowledge graph in .gitnexus/
 *
 * Delegates core analysis to the shared runFullAnalysis orchestrator.
 * This CLI wrapper handles: heap management, progress bar, SIGINT,
 * skill generation (--skills), summary output, and process.exit().
 */
import path from 'path';
import { spawn } from 'child_process';
import v8 from 'v8';
import cliProgress from 'cli-progress';
import { closeLbug } from '../core/lbug/lbug-adapter.js';
import { isLbugCheckpointIoError, isWalCorruptionError, parseWalCheckpointThreshold, WAL_RECOVERY_SUGGESTION, } from '../core/lbug/lbug-config.js';
import { getStoragePaths, getGlobalRegistryPath, RegistryNameCollisionError, AnalysisNotFinalizedError, assertAnalysisFinalized, } from '../storage/repo-manager.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import { warnMissingOptionalGrammars } from './optional-grammars.js';
import { glob } from 'glob';
import fs from 'fs/promises';
import { cliError } from './cli-message.js';
import { formatElapsed } from './format-elapsed.js';
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
// Capture stderr.write at module load BEFORE anything (LadybugDB native
// init, progress bar, console redirection) can monkey-patch it. The
// fatal handlers below MUST reach the user even when the analyze path
// has redirected console.* through the progress bar's bar.log() — the
// previous behaviour silently swallowed stack traces and made #1169
// indistinguishable from a no-op success on Windows.
const realStderrWrite = process.stderr.write.bind(process.stderr);
const realStdoutWrite = process.stdout.write.bind(process.stdout);
const writeFatalToStderr = (label, err) => {
    const isErr = err instanceof Error;
    const message = isErr ? err.message : String(err);
    realStderrWrite(`\n  ${label}: ${message}\n`);
    if (isErr && err.stack)
        realStderrWrite(`${err.stack}\n`);
};
let fatalHandlersInstalled = false;
/**
 * Install one-shot `unhandledRejection` / `uncaughtException` handlers
 * that surface the failure to the real stderr (bypassing any console
 * redirection installed by the progress bar) and force a non-zero exit
 * code. Without these, an async error escaping {@link analyzeCommand}'s
 * try/catch was reported as exit 0 with no diagnostic — the silent
 * failure mode tracked in #1169.
 */
const installFatalHandlers = () => {
    if (fatalHandlersInstalled)
        return;
    fatalHandlersInstalled = true;
    process.on('unhandledRejection', (err) => {
        writeFatalToStderr('Analysis failed (unhandled rejection)', err);
        process.exit(1);
    });
    process.on('uncaughtException', (err) => {
        writeFatalToStderr('Analysis failed (uncaught exception)', err);
        process.exit(1);
    });
};
const HEAP_MB = 16384;
const TEST_RESPAWN_HEAP_MB = Number(process.env.GITNEXUS_TEST_RESPAWN_HEAP_MB);
const RESPAWN_HEAP_MB = Number.isFinite(TEST_RESPAWN_HEAP_MB) && TEST_RESPAWN_HEAP_MB > 0
    ? Math.floor(TEST_RESPAWN_HEAP_MB)
    : HEAP_MB;
const HEAP_FLAG = `--max-old-space-size=${RESPAWN_HEAP_MB}`;
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
const STACK_KB = 4096;
const STACK_FLAG = `--stack-size=${STACK_KB}`;
const RESPAWN_OUTPUT_TAIL_CHARS = 1024 * 1024;
const RESPAWN_PROGRESS_ENV = 'GITNEXUS_RESPAWN_PROGRESS_TTY';
const terminalColumns = () => {
    const parsed = Number(process.env.COLUMNS);
    return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 80;
};
const ANSI_ESCAPE_PATTERN = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[PX^_][\s\S]*?\x1B\\|[78]|[@-Z\\-_])/y;
const splitGraphemes = (text) => {
    const Segmenter = Intl.Segmenter;
    if (Segmenter) {
        return Array.from(new Segmenter(undefined, { granularity: 'grapheme' }).segment(text), (s) => s.segment);
    }
    return Array.from(text);
};
const isZeroWidthCodePoint = (codePoint) => codePoint === 0x200d ||
    (codePoint >= 0x0300 && codePoint <= 0x036f) ||
    (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
    (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
    (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
    (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
    (codePoint >= 0xfe20 && codePoint <= 0xfe2f);
const isWideCodePoint = (codePoint) => codePoint >= 0x1100 &&
    (codePoint <= 0x115f ||
        codePoint === 0x2329 ||
        codePoint === 0x232a ||
        (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
        (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
        (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
        (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
        (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
        (codePoint >= 0xff00 && codePoint <= 0xff60) ||
        (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
        (codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
        (codePoint >= 0x20000 && codePoint <= 0x3fffd));
const visibleColumns = (text) => {
    let columns = 0;
    for (const char of Array.from(text)) {
        const codePoint = char.codePointAt(0);
        if (codePoint === undefined || isZeroWidthCodePoint(codePoint))
            continue;
        columns += isWideCodePoint(codePoint) ? 2 : 1;
    }
    return columns;
};
const readAnsiEscapeAt = (text, index) => {
    ANSI_ESCAPE_PATTERN.lastIndex = index;
    return ANSI_ESCAPE_PATTERN.exec(text)?.[0];
};
const truncateAnsiToColumns = (text, maxColumns) => {
    if (!Number.isFinite(maxColumns) || maxColumns <= 0)
        return '';
    let output = '';
    let columns = 0;
    let index = 0;
    while (index < text.length) {
        const escape = readAnsiEscapeAt(text, index);
        if (escape) {
            output += escape;
            index += escape.length;
            continue;
        }
        const nextEscapeIndex = text.indexOf('\x1B', index);
        const plainEnd = nextEscapeIndex === -1 ? text.length : nextEscapeIndex;
        const plainText = text.slice(index, plainEnd);
        for (const segment of splitGraphemes(plainText)) {
            const width = visibleColumns(segment);
            if (width > 0 && columns + width > maxColumns)
                return output;
            output += segment;
            columns += width;
        }
        index = plainEnd;
    }
    return output;
};
const createAnsiPipeTerminal = (stream) => {
    let linewrap = true;
    let dy = 0;
    const write = (s) => {
        stream.write(s);
    };
    const moveVertical = (delta) => {
        if (delta > 0)
            write(`\x1B[${delta}B`);
        else if (delta < 0)
            write(`\x1B[${Math.abs(delta)}A`);
    };
    return {
        cursorSave: () => write('\x1B7'),
        cursorRestore: () => write('\x1B8'),
        cursor: (enabled) => write(enabled ? '\x1B[?25h' : '\x1B[?25l'),
        lineWrapping: (enabled) => {
            linewrap = enabled;
            write(enabled ? '\x1B[?7h' : '\x1B[?7l');
        },
        cursorTo: (x = null, y = null) => {
            if (typeof y === 'number' && typeof x === 'number') {
                write(`\x1B[${y + 1};${x + 1}H`);
                return;
            }
            if (typeof x === 'number') {
                write(x === 0 ? '\r' : `\x1B[${x + 1}G`);
            }
        },
        cursorRelative: (dx = null, nextDy = null) => {
            if (typeof dx === 'number' && dx !== 0) {
                write(dx > 0 ? `\x1B[${dx}C` : `\x1B[${Math.abs(dx)}D`);
            }
            if (typeof nextDy === 'number' && nextDy !== 0) {
                dy += nextDy;
                moveVertical(nextDy);
            }
        },
        cursorRelativeReset: () => {
            moveVertical(-dy);
            write('\r');
            dy = 0;
        },
        clearRight: () => write('\x1B[0K'),
        clearLine: () => write('\x1B[2K'),
        clearBottom: () => write('\x1B[0J'),
        newline: () => {
            write('\n');
            dy++;
        },
        write: (s, rawWrite = false) => {
            const width = terminalColumns();
            write(linewrap && rawWrite === false ? truncateAnsiToColumns(s, width) : s);
        },
        isTTY: () => true,
        getWidth: terminalColumns,
    };
};
const shouldBridgeRespawnProgressTty = () => process.stderr.isTTY === true || process.stdout.isTTY === true;
const appendOutputTail = (tail, chunk) => {
    const text = Buffer.isBuffer(chunk)
        ? chunk.toString('utf8')
        : typeof chunk === 'string'
            ? chunk
            : String(chunk ?? '');
    if (!text)
        return tail;
    const next = tail + text;
    return next.length > RESPAWN_OUTPUT_TAIL_CHARS ? next.slice(-RESPAWN_OUTPUT_TAIL_CHARS) : next;
};
/**
 * Run the respawned analyzer while teeing child output through to the parent
 * and keeping a bounded tail for crash classification.
 *
 * `execFileSync(..., { stdio: 'inherit' })` preserved live progress but hid
 * stderr/stdout from the parent on abnormal exits. That made every
 * SIGABRT/status-134 child look like an output-less V8 heap OOM, even when the
 * terminal had already shown a native crash such as
 * `libc++abi: ... Napi::Error`. Piped streams plus an explicit tee keeps the UX
 * and gives `childProcessLikelyOom` the evidence it needs.
 */
const runRespawnedAnalyze = (args, env) => new Promise((resolve) => {
    let stdout = '';
    let stderr = '';
    let settled = false;
    const finish = (exit) => {
        if (settled)
            return;
        settled = true;
        resolve(exit);
    };
    const child = spawn(process.execPath, [...args], {
        stdio: ['inherit', 'pipe', 'pipe'],
        windowsHide: true,
        env,
    });
    child.stdout?.on('data', (chunk) => {
        stdout = appendOutputTail(stdout, chunk);
        realStdoutWrite(chunk);
    });
    child.stderr?.on('data', (chunk) => {
        stderr = appendOutputTail(stderr, chunk);
        realStderrWrite(chunk);
    });
    child.on('error', (err) => {
        finish({
            status: 1,
            signal: null,
            stdout,
            stderr,
            message: err instanceof Error ? err.message : String(err),
        });
    });
    child.on('close', (status, signal) => {
        finish({
            status,
            signal,
            stdout,
            stderr,
            message: `Command failed: ${process.execPath} ${args.join(' ')}`,
        });
    });
});
/**
 * Heuristic for "child re-exec likely died from V8 OOM".
 *
 * Platform-independent detection is best-effort: V8/Node usually emit stable
 * heap-exhaustion phrases in stderr/message across Linux/macOS/Windows (for
 * example "JavaScript heap out of memory" or "Reached heap limit"). When the
 * child produced no output at all, we still treat status 134/SIGABRT as likely
 * heap OOM. If stderr/stdout contains a native crash diagnostic, the output
 * evidence wins and we do not print heap guidance.
 */
const childProcessLikelyOom = (err) => {
    if (!err || typeof err !== 'object')
        return false;
    const e = err;
    const hasHeapOomSignature = (v) => {
        const text = (Buffer.isBuffer(v) ? v.toString('utf8') : typeof v === 'string' ? v : '').toLowerCase();
        if (!text)
            return false;
        return (text.includes('javascript heap out of memory') ||
            text.includes('reached heap limit') ||
            text.includes('allocation failed - javascript heap out of memory') ||
            text.includes('fatalprocessoutofmemory'));
    };
    const fields = [e.message, e.stderr, e.stdout];
    if (fields.some((v) => hasHeapOomSignature(v)))
        return true;
    const hasAnyChildOutput = [e.stderr, e.stdout].some((v) => (Buffer.isBuffer(v) && v.length > 0) || (typeof v === 'string' && v.length > 0));
    if (hasAnyChildOutput)
        return false;
    return e.status === 134 || e.signal === 'SIGABRT';
};
const childProcessLikelyNativeAbort = (err) => {
    if (!err || typeof err !== 'object')
        return false;
    const e = err;
    const hasNativeAbortSignature = (v) => {
        const text = (Buffer.isBuffer(v) ? v.toString('utf8') : typeof v === 'string' ? v : '').toLowerCase();
        if (!text)
            return false;
        return (text.includes('napi::error') ||
            text.includes('libc++abi: terminating') ||
            text.includes('abort trap') ||
            text.includes('native stack') ||
            text.includes('native worker') ||
            text.includes('native binding'));
    };
    return [e.message, e.stderr, e.stdout].some((v) => hasNativeAbortSignature(v));
};
const forceHeapOOMForTestIfEnabled = () => {
    if (process.env.GITNEXUS_TEST_FORCE_HEAP_OOM !== '1')
        return;
    // Allocate JS strings (not Buffers) so pressure lands on V8 heap itself.
    // Buffers can allocate off-heap, which makes OOM triggering less reliable.
    const chunks = [];
    for (;;)
        chunks.push('x'.repeat(1024 * 1024));
};
// 64 MiB keeps auto-checkpoint enabled but triggers less frequently than
// Ladybug's stock ~16 MiB threshold, reducing rename/remove churn on large
// runs. Also matches the GitNexus default in `lbug-config.ts`.
//
// IMPORTANT: keep README examples (`README.md`, `gitnexus/README.md`) and
// the `DEFAULT_WAL_CHECKPOINT_THRESHOLD` constant in
// `gitnexus/src/core/lbug/lbug-config.ts` in sync with this value.
const RECOMMENDED_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
/** Re-exec the process with a 16GB heap and larger stack if we're currently below that. */
async function ensureHeap() {
    const nodeOpts = process.env.NODE_OPTIONS || '';
    if (nodeOpts.includes('--max-old-space-size'))
        return false;
    const v8Heap = v8.getHeapStatistics().heap_size_limit;
    if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9)
        return false;
    // --stack-size is a V8 flag not allowed in NODE_OPTIONS on Node 24+,
    // so pass it only as a direct CLI argument, not via the environment.
    const cliFlags = [HEAP_FLAG];
    if (!nodeOpts.includes('--stack-size'))
        cliFlags.push(STACK_FLAG);
    const childArgs = [...cliFlags, ...process.argv.slice(1)];
    const childEnv = {
        ...process.env,
        NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim(),
    };
    if (shouldBridgeRespawnProgressTty())
        childEnv[RESPAWN_PROGRESS_ENV] = '1';
    const childExit = await runRespawnedAnalyze(childArgs, childEnv);
    if (childExit.status !== 0 || childExit.signal) {
        if (childProcessLikelyOom(childExit)) {
            cliError(`  Analysis likely ran out of memory.\n` +
                `  Retry with a larger heap if your machine allows it:\n` +
                `    NODE_OPTIONS="--max-old-space-size=24576" gitnexus analyze [your-args]\n` +
                `    (Windows: set NODE_OPTIONS=--max-old-space-size=24576 && gitnexus analyze [your-args])\n` +
                `  If this persists, it may be a native crash unrelated to heap size.\n`, { recoveryHint: 'heap-oom-respawn' });
        }
        else if (childProcessLikelyNativeAbort(childExit)) {
            cliError(`  Analysis aborted in a native worker or native binding path.\n` +
                `  Try one of these recovery paths:\n` +
                `    gitnexus analyze --workers 0\n` +
                `    npm uninstall -g gitnexus && npm install -g gitnexus@latest\n` +
                `    Use Node 22 LTS if you are on a newer non-LTS runtime.\n`, { recoveryHint: 'native-worker-abort' });
        }
        const status = typeof childExit.status === 'number' && childExit.status !== 0 ? childExit.status : 1;
        process.exitCode = status;
    }
    return true;
}
/**
 * GITNEXUS_* env vars that `analyzeCommand` writes for backward-compatible
 * downstream consumption. Snapshotted at function entry and restored in the
 * finally block so that programmatic callers (tests, long-running hosts)
 * don't see leaked state across invocations. `GITNEXUS_WORKER_POOL_SIZE` is
 * NOT in this list: that knob is threaded through `runFullAnalysis` options
 * (see `workerPoolSize` plumbing) so the CLI never has to mutate `process.env`
 * for it in the first place.
 */
const ANALYZE_CLI_ENV_KEYS = [
    'GITNEXUS_VERBOSE',
    'GITNEXUS_PROFILE_DEFERRED',
    'GITNEXUS_PROFILE_DEFERRED_SLOW_MS',
    'GITNEXUS_MAX_FILE_SIZE',
    'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS',
    'GITNEXUS_WAL_CHECKPOINT_THRESHOLD',
    'GITNEXUS_WAL_MANUAL_CHECKPOINT',
    'GITNEXUS_EMBEDDING_THREADS',
    'GITNEXUS_EMBEDDING_BATCH_SIZE',
    'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
    'GITNEXUS_EMBEDDING_DEVICE',
    'GITNEXUS_ANALYZE_PROGRESS_ACTIVE',
];
const snapshotAnalyzeEnv = () => {
    const snap = {};
    for (const k of ANALYZE_CLI_ENV_KEYS)
        snap[k] = process.env[k];
    return snap;
};
const restoreAnalyzeEnv = (snap) => {
    for (const k of ANALYZE_CLI_ENV_KEYS) {
        const v = snap[k];
        if (v === undefined)
            delete process.env[k];
        else
            process.env[k] = v;
    }
};
/**
 * Whether the post-index skill step should run.
 *
 * The gated block does two things in sequence: (1) generates the community
 * skill files from `--skills`, and (2) re-runs `generateAIContextFiles` so
 * AGENTS.md/CLAUDE.md can reference the freshly written skills. Both are
 * suppressed together — `--index-only` drops the entire step, not just the
 * community-skill write. Name retained for the test contract; see call site
 * in `analyzeCommand` for the AGENTS.md/CLAUDE.md re-generation it also gates.
 *
 * Kept as a pure helper so the `--index-only --skills` contract is unit-tested
 * without booting the full analyze pipeline (#742 review).
 */
export const shouldGenerateCommunitySkillFiles = (options, pipelineResult) => Boolean(options?.skills && pipelineResult && !options?.indexOnly);
export const analyzeCommand = async (inputPath, options) => {
    if (await ensureHeap())
        return;
    forceHeapOOMForTestIfEnabled();
    // Install fatal handlers immediately after re-exec resolution so any
    // async error that escapes the try/catch below (#1169) surfaces with
    // a stack trace and a non-zero exit code instead of a silent exit 0.
    installFatalHandlers();
    // Snapshot the GITNEXUS_* env vars that the impl writes for downstream
    // consumption, so they don't leak across `analyzeCommand` invocations in
    // programmatic callers (tests, long-running hosts). `process.exit(0)` on
    // the success path bypasses `finally` — intentional: when the process is
    // exiting, restoration is moot. For early-return paths (validation
    // errors) and the alreadyUpToDate fast path the finally restores the
    // pre-call values.
    const envSnap = snapshotAnalyzeEnv();
    try {
        await analyzeCommandImpl(inputPath, options);
    }
    finally {
        restoreAnalyzeEnv(envSnap);
    }
};
const analyzeCommandImpl = async (inputPath, options) => {
    if (options?.verbose) {
        process.env.GITNEXUS_VERBOSE = '1';
    }
    if (options?.maxFileSize) {
        process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
    }
    if (options?.workerTimeout) {
        const workerTimeoutSeconds = Number(options.workerTimeout);
        if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
            cliError('  --worker-timeout must be at least 1 second.\n');
            process.exitCode = 1;
            return;
        }
        process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(Math.round(workerTimeoutSeconds * 1000));
    }
    if (options?.walCheckpointThreshold !== undefined) {
        const parsed = parseWalCheckpointThreshold(options.walCheckpointThreshold);
        if (parsed === undefined) {
            cliError('  --wal-checkpoint-threshold must be an integer >= -1.\n');
            process.exitCode = 1;
            return;
        }
        process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = String(parsed);
    }
    // `--workers` is threaded through `runFullAnalysis` options → PipelineOptions
    // → createWorkerPool, intentionally bypassing the GITNEXUS_WORKER_POOL_SIZE
    // env channel so this CLI surface never mutates `process.env` for pool size.
    // Tests can therefore re-invoke analyzeCommand with different --workers
    // values back-to-back and observe the value they passed, not whatever the
    // previous call leaked.
    let workerPoolSize;
    if (options?.workers !== undefined) {
        const parsedWorkers = Number(options.workers);
        if (!Number.isInteger(parsedWorkers) || parsedWorkers < 0) {
            cliError('  --workers must be a non-negative integer. ' +
                'Pass 0 to disable the worker pool (sequential fallback).\n');
            process.exitCode = 1;
            return;
        }
        workerPoolSize = parsedWorkers;
    }
    // Parse `--embeddings [limit]`: `true` → default cap, string → numeric cap
    // (0 disables the cap entirely). Validated up here so failures match the
    // sibling-validation pattern (exit before bar.start() — otherwise
    // process.exit() leaves the progress bar's hidden cursor uncleared).
    let embeddingsNodeLimit;
    if (typeof options?.embeddings === 'string') {
        const parsed = Number(options.embeddings);
        if (!Number.isInteger(parsed) || parsed < 0) {
            cliError(`  --embeddings expects a non-negative integer (got "${options.embeddings}"). ` +
                `Pass 0 to disable the safety cap, or omit the value to keep the default.\n`);
            process.exitCode = 1;
            return;
        }
        embeddingsNodeLimit = parsed;
    }
    const embeddingsEnabled = !!options?.embeddings;
    const setPositiveEnv = (optionName, envName, value) => {
        if (value === undefined)
            return true;
        const parsed = Number(value);
        if (!Number.isInteger(parsed) || parsed <= 0) {
            cliError(`  ${optionName} must be a positive integer.\n`);
            process.exitCode = 1;
            return false;
        }
        process.env[envName] = String(parsed);
        return true;
    };
    if (!setPositiveEnv('--embedding-threads', 'GITNEXUS_EMBEDDING_THREADS', options?.embeddingThreads) ||
        !setPositiveEnv('--embedding-batch-size', 'GITNEXUS_EMBEDDING_BATCH_SIZE', options?.embeddingBatchSize) ||
        !setPositiveEnv('--embedding-sub-batch-size', 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', options?.embeddingSubBatchSize)) {
        return;
    }
    if (options?.embeddingDevice) {
        const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
        if (!allowed.has(options.embeddingDevice)) {
            cliError('  --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
            process.exitCode = 1;
            return;
        }
        process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
    }
    if (options?.repairFts && options?.force) {
        cliError('  Cannot combine `--repair-fts` with `--force`. ' +
            'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n');
        process.exitCode = 1;
        return;
    }
    console.log('\n  GitNexus Analyzer\n');
    // `--index-only` is the stronger contract — it suppresses every form of file
    // injection, including community skill writes that `--skills` would normally
    // produce. Surface the override explicitly so users don't wonder why a
    // pipeline re-index ran but no skill files appeared. The pipeline still
    // re-runs (see `force: options?.force || options?.skills` below); the warning
    // is purely about the dropped post-index write step.
    if (options?.indexOnly && options?.skills) {
        console.log('  Note: --index-only overrides --skills; community skill files will not be written.\n');
    }
    let repoPath;
    if (inputPath) {
        repoPath = path.resolve(inputPath);
    }
    else if (options?.skipGit) {
        // --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
        repoPath = path.resolve(process.cwd());
    }
    else {
        const gitRoot = getGitRoot(process.cwd());
        if (!gitRoot) {
            console.log('  Not inside a git repository.\n  Tip: pass --skip-git to index any folder without a .git directory.\n');
            process.exitCode = 1;
            return;
        }
        repoPath = gitRoot;
    }
    const repoHasGit = hasGitDir(repoPath);
    if (!repoHasGit && !options?.skipGit) {
        console.log('  Not a git repository.\n  Tip: pass --skip-git to index any folder without a .git directory.\n');
        process.exitCode = 1;
        return;
    }
    if (!repoHasGit) {
        console.log('  Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n');
    }
    // If the target repo contains files an optional grammar would parse but
    // that grammar's native binding is absent, warn before analysis so users
    // learn why those files end up unparsed instead of silently getting a
    // degraded index.
    try {
        const matches = await glob(['**/*.dart', '**/*.proto'], {
            cwd: repoPath,
            ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
            dot: false,
            nodir: true,
            absolute: false,
        });
        if (matches.length > 0) {
            const present = new Set();
            for (const m of matches) {
                const ext = path.extname(m).toLowerCase();
                if (ext)
                    present.add(ext);
            }
            warnMissingOptionalGrammars({ context: 'analyze', relevantExtensions: present });
        }
    }
    catch {
        // Best-effort warning \u2014 never block analyze on the precheck.
    }
    // KuzuDB migration cleanup is handled by runFullAnalysis internally.
    // Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
    if (process.env.GITNEXUS_NO_GITIGNORE) {
        console.log('  GITNEXUS_NO_GITIGNORE is set — skipping .gitignore (still reading .gitnexusignore)\n');
    }
    const maxFileSizeBanner = getMaxFileSizeBannerMessage();
    if (maxFileSizeBanner) {
        console.log(`${maxFileSizeBanner}\n`);
    }
    // ── CLI progress bar setup ─────────────────────────────────────────
    const barOptions = {
        format: '  {bar} {percentage}% | {phase}',
        barCompleteChar: '\u2588',
        barIncompleteChar: '\u2591',
        hideCursor: true,
        barGlue: '',
        autopadding: true,
        clearOnComplete: false,
        stopOnComplete: false,
    };
    if (process.env[RESPAWN_PROGRESS_ENV] === '1' && process.stderr.isTTY !== true) {
        // Heap respawn pipes stderr so the parent can classify native/OOM crashes.
        // The parent was a real TTY when it opted into this env var, so forward
        // ANSI cursor controls through the pipe instead of cli-progress' non-TTY
        // newline mode. That keeps one-line redraw UX while retaining stderr tail
        // capture for diagnostics.
        barOptions.terminal = createAnsiPipeTerminal(process.stderr);
    }
    const bar = new cliProgress.SingleBar(barOptions, cliProgress.Presets.shades_grey);
    bar.start(100, 0, { phase: 'Initializing...' });
    // Graceful SIGINT handling. Pino's default destination is `sync: false`
    // (buffered) — flush before exit so in-flight records reach stderr.
    // See `gitnexus/src/core/logger.ts:flushLoggerSync`.
    let aborted = false;
    const sigintHandler = () => {
        if (aborted)
            process.exit(1);
        aborted = true;
        bar.stop();
        console.log('\n  Interrupted — cleaning up...');
        closeLbug()
            .catch(() => { })
            .finally(async () => {
            const { flushLoggerSync } = await import('../core/logger.js');
            flushLoggerSync();
            process.exit(130);
        });
    };
    process.on('SIGINT', sigintHandler);
    // Route console output through bar.log() to prevent progress bar corruption.
    // This is a deliberate UI pattern (not a logging concern): analyze runs a
    // long-lived progress bar on stdout; any concurrent console.* write would
    // overwrite the bar mid-render. We capture originals, swap to barLog for
    // the lifetime of the run, and restore on completion/error/SIGINT.
    const origLog = console.log.bind(console);
    // eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
    const origWarn = console.warn.bind(console);
    // eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
    const origError = console.error.bind(console);
    let barCurrentValue = 0;
    const barLog = (...args) => {
        process.stdout.write('\x1b[2K\r');
        origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
        bar.update(barCurrentValue);
    };
    console.log = barLog;
    // eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
    console.warn = barLog;
    // eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
    console.error = barLog;
    process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1';
    // Track elapsed time per phase
    let lastPhaseLabel = 'Initializing...';
    let phaseStart = Date.now();
    const updateBar = (value, phaseLabel) => {
        barCurrentValue = value;
        if (phaseLabel !== lastPhaseLabel) {
            lastPhaseLabel = phaseLabel;
            phaseStart = Date.now();
        }
        const elapsed = Math.round((Date.now() - phaseStart) / 1000);
        const display = elapsed >= 3 ? `${phaseLabel} (${formatElapsed(elapsed)})` : phaseLabel;
        bar.update(value, { phase: display });
    };
    const elapsedTimer = setInterval(() => {
        const elapsed = Math.round((Date.now() - phaseStart) / 1000);
        if (elapsed >= 3) {
            bar.update({ phase: `${lastPhaseLabel} (${formatElapsed(elapsed)})` });
        }
    }, 1000);
    const t0 = Date.now();
    // ── Run shared analysis orchestrator ───────────────────────────────
    try {
        const skipAll = options?.indexOnly;
        const skipAgentsMd = skipAll || options?.skipAgentsMd;
        const skipSkills = skipAll || options?.skipSkills;
        const result = await runFullAnalysis(repoPath, {
            // Pipeline re-index — OR'd with --skills because skill generation
            // needs a fresh pipelineResult. Has no bearing on the registry
            // collision guard (see allowDuplicateName below).
            force: options?.force || options?.skills,
            repairFts: options?.repairFts,
            embeddings: embeddingsEnabled,
            embeddingsNodeLimit,
            dropEmbeddings: options?.dropEmbeddings,
            verbose: options?.verbose,
            skipGit: options?.skipGit,
            skipAgentsMd,
            skipSkills,
            // commander.js `.option('--no-stats', …)` registers the flag as
            // `options.stats` (boolean, default true; `false` when the user
            // passed --no-stats). Reading `options?.noStats` here returns
            // undefined every time, so the flag was a no-op on the markdown
            // rewrite path before this fix. See #1477.
            noStats: options?.stats === false,
            registryName: options?.name,
            // Registry-collision bypass — its own CLI flag, intentionally NOT
            // overloading --force. A user who hits the collision guard should
            // be able to accept the duplicate name without also paying the
            // cost of a full pipeline re-index. See #829 review round 2.
            allowDuplicateName: options?.allowDuplicateName,
            // Worker pool size threaded from --workers, replacing the previous
            // GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
            // env / auto-formula fallback inside the pipeline.
            workerPoolSize,
        }, {
            onProgress: (_phase, percent, message) => {
                updateBar(percent, message);
            },
            onLog: barLog,
        });
        if (result.alreadyUpToDate) {
            // Even the fast path must prove the repo is discoverable. A prior
            // run can write meta.json and then fail before registerRepo(); in
            // that half-finalized state, runFullAnalysis returns alreadyUpToDate
            // on the next invocation unless we check the registry here too.
            await assertAnalysisFinalized(repoPath);
            clearInterval(elapsedTimer);
            process.removeListener('SIGINT', sigintHandler);
            console.log = origLog;
            // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
            console.warn = origWarn;
            // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
            console.error = origError;
            bar.stop();
            console.log('  Already up to date\n');
            // Safe to return without process.exit(0) — the early-return path in
            // runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
            return;
        }
        if (result.ftsRepairedOnly) {
            clearInterval(elapsedTimer);
            process.removeListener('SIGINT', sigintHandler);
            console.log = origLog;
            // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
            console.warn = origWarn;
            // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
            console.error = origError;
            bar.stop();
            console.log('  FTS indexes repaired successfully\n');
            return;
        }
        // Post-finalize invariant (#1169): runFullAnalysis nominally writes
        // meta.json and registers the repo, but on Windows it has been
        // observed to return successfully with neither artifact present
        // (banner-only output, exit 0). Verify both before declaring
        // success so the silent-finalize state surfaces with a non-zero
        // exit code and an actionable error instead of being mistaken for
        // a healthy index.
        await assertAnalysisFinalized(repoPath);
        // Skill generation (CLI-only, uses pipeline result from analysis).
        // Gated so `--index-only --skills` skips community skill writes too
        // (`shouldGenerateCommunitySkillFiles` — see unit test).
        if (shouldGenerateCommunitySkillFiles(options, result.pipelineResult)) {
            updateBar(99, 'Generating skill files...');
            try {
                const { generateSkillFiles } = await import('./skill-gen.js');
                const { generateAIContextFiles } = await import('./ai-context.js');
                const skillResult = await generateSkillFiles(repoPath, result.repoName, result.pipelineResult);
                if (skillResult.skills.length > 0) {
                    barLog(`  Generated ${skillResult.skills.length} skill files`);
                    // Re-generate AI context files now that we have skill info
                    const s = result.stats;
                    const communityResult = result.pipelineResult?.communityResult;
                    let aggregatedClusterCount = 0;
                    if (communityResult?.communities) {
                        const groups = new Map();
                        for (const c of communityResult.communities) {
                            const label = c.heuristicLabel || c.label || 'Unknown';
                            groups.set(label, (groups.get(label) || 0) + c.symbolCount);
                        }
                        aggregatedClusterCount = Array.from(groups.values()).filter((count) => count >= 5).length;
                    }
                    const { storagePath: sp } = getStoragePaths(repoPath);
                    await generateAIContextFiles(repoPath, sp, result.repoName, {
                        files: s.files ?? 0,
                        nodes: s.nodes ?? 0,
                        edges: s.edges ?? 0,
                        communities: s.communities,
                        clusters: aggregatedClusterCount,
                        processes: s.processes,
                    }, skillResult.skills, {
                        skipAgentsMd,
                        skipSkills,
                        // Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
                        // exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
                        noStats: options?.stats === false,
                    });
                }
            }
            catch {
                /* best-effort */
            }
        }
        const totalTime = ((Date.now() - t0) / 1000).toFixed(1);
        clearInterval(elapsedTimer);
        process.removeListener('SIGINT', sigintHandler);
        console.log = origLog;
        // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
        console.warn = origWarn;
        // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
        console.error = origError;
        bar.update(100, { phase: 'Done' });
        bar.stop();
        // ── Summary ────────────────────────────────────────────────────
        const s = result.stats;
        console.log(`\n  Repository indexed successfully (${totalTime}s)\n`);
        console.log(`  ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`);
        console.log(`  ${repoPath}`);
        try {
            await fs.access(getGlobalRegistryPath());
        }
        catch {
            console.log('\n  Tip: Run `gitnexus setup` to configure MCP for your editor.');
        }
        console.log('');
    }
    catch (err) {
        clearInterval(elapsedTimer);
        process.removeListener('SIGINT', sigintHandler);
        console.log = origLog;
        // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
        console.warn = origWarn;
        // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
        console.error = origError;
        bar.stop();
        const msg = err instanceof Error ? err.message : String(err);
        // Registry name-collision from --name (#829) — surface as an
        // actionable error rather than a generic stack-trace.
        if (err instanceof RegistryNameCollisionError) {
            cliError(`\n  Registry name collision:\n` +
                `    "${err.registryName}" is already used by "${err.existingPath}".\n\n` +
                `  Options:\n` +
                `    • Pick a different alias:  gitnexus analyze --name <alias>\n` +
                `    • Allow the duplicate:     gitnexus analyze --allow-duplicate-name  (leaves "-r ${err.registryName}" ambiguous)\n`, { registryName: err.registryName, existingPath: err.existingPath });
            process.exitCode = 1;
            return;
        }
        // Finalize invariant failure (#1169) — keep the rich actionable
        // message intact and write through realStderrWrite so it can't be
        // erased by a leftover bar refresh on slow terminals.
        if (err instanceof AnalysisNotFinalizedError) {
            writeFatalToStderr('Analysis did not finalize', err);
            realStderrWrite(`\n  Diagnostic checklist:\n` +
                `    1. Re-run "gitnexus analyze" - transient native errors often clear on retry.\n` +
                `    2. Inspect ${err.storagePath} - a leftover lbug.wal indicates an aborted write.\n` +
                `    3. If the failure persists, run with NODE_OPTIONS="--max-old-space-size=8192 --trace-exit"\n` +
                `       and attach the trace to the GitNexus issue tracker.\n\n`);
            process.exitCode = 1;
            return;
        }
        // WAL corruption — the index file is unreadable. Give a clear recovery
        // path without a confusing stack trace (the native error message alone
        // is enough signal).
        if (isWalCorruptionError(err) || msg.includes('LadybugDB WAL corruption')) {
            cliError(`  The GitNexus index has a corrupted WAL file.\n` +
                `  This usually happens when a previous analysis was interrupted mid-write.\n` +
                `  ${WAL_RECOVERY_SUGGESTION}\n`, { recoveryHint: 'wal-corruption' });
            process.exitCode = 1;
            return;
        }
        if (isLbugCheckpointIoError(err)) {
            cliError(`  LadybugDB failed while rotating/removing WAL checkpoint files.\n` +
                `  This can happen when auto-checkpoint runs at the default threshold (~16MB).\n` +
                `  Retry with a larger checkpoint threshold to reduce checkpoint frequency:\n` +
                `    gitnexus analyze --wal-checkpoint-threshold ${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD}\n` +
                `    (or set GITNEXUS_WAL_CHECKPOINT_THRESHOLD=${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD})\n` +
                `    (Try 33554432 = 32 MiB on small-disk / CI runners.)\n`, { recoveryHint: 'wal-checkpoint-threshold' });
            process.exitCode = 1;
            return;
        }
        // HF download failure — show clean guidance without the raw stack trace.
        // Checked before writeFatalToStderr so the user sees one focused message
        // rather than a stack-trace dump followed by a second remediation block.
        if (isHfDownloadFailure(msg) || msg.includes('Failed to download embedding model')) {
            cliError(`  The embedding model could not be downloaded.\n` +
                `  huggingface.co may be unreachable from your network\n` +
                `  (e.g. behind a corporate proxy or a regional firewall).\n` +
                `  Suggestions:\n` +
                `    1. Set HF_ENDPOINT to a mirror and retry:\n` +
                `         HF_ENDPOINT=https://hf-mirror.com npx gitnexus analyze --embeddings\n` +
                `         (Windows: set HF_ENDPOINT=https://hf-mirror.com && npx gitnexus analyze --embeddings)\n` +
                `    2. Check your proxy / VPN settings.\n` +
                `    3. Once downloaded the model is cached — future runs work offline.\n`, { recoveryHint: 'hf-endpoint-unreachable' });
            process.exitCode = 1;
            return;
        }
        // Bypass the redirected console.error and write the full stack to
        // the real stderr captured at module load. The redirected
        // console.error wraps every line with `\\x1b[2K\\r` (ANSI clear-line)
        // and forces a bar.update() afterwards, which on some Windows
        // terminals visually erases the failure message — the canonical
        // shape of the silent-exit symptom in #1169.
        writeFatalToStderr('Analysis failed', err);
        // Provide helpful guidance for known failure modes
        if (msg.includes('Maximum call stack size exceeded') ||
            msg.includes('call stack') ||
            msg.includes('Map maximum size') ||
            msg.includes('Invalid array length') ||
            msg.includes('Invalid string length') ||
            msg.includes('allocation failed') ||
            msg.includes('heap out of memory') ||
            msg.includes('JavaScript heap')) {
            cliError(`  This error typically occurs on very large repositories.\n` +
                `  Suggestions:\n` +
                `    1. Add large vendored/generated directories to .gitnexusignore\n` +
                `    2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"\n` +
                `    3. Increase stack size: NODE_OPTIONS="--stack-size=4096"\n`, { recoveryHint: 'large-repo' });
        }
        else if (msg.includes('ERESOLVE') || msg.includes('Could not resolve dependency')) {
            // Note: the original arborist "Cannot destructure property 'package' of
            // 'node.target'" crash happens inside npm *before* gitnexus code runs,
            // so it can't be caught here.  This branch handles dependency-resolution
            // errors that surface at runtime (e.g. dynamic require failures).
            cliError(`  This looks like an npm dependency resolution issue.\n` +
                `  Suggestions:\n` +
                `    1. Clear the npm cache:    npm cache clean --force\n` +
                `    2. Update npm:             npm install -g npm@latest\n` +
                `    3. Reinstall gitnexus:     npm install -g gitnexus@latest\n` +
                `    4. Or try npx directly:    npx gitnexus@latest analyze\n`, { recoveryHint: 'npm-resolution' });
        }
        else if (msg.includes('MODULE_NOT_FOUND') ||
            msg.includes('Cannot find module') ||
            msg.includes('ERR_MODULE_NOT_FOUND')) {
            cliError(`  A required module could not be loaded. The installation may be corrupt.\n` +
                `  Suggestions:\n` +
                `    1. Reinstall:   npm install -g gitnexus@latest\n` +
                `    2. Clear cache: npm cache clean --force && npx gitnexus@latest analyze\n`, { recoveryHint: 'module-not-found' });
        }
        process.exitCode = 1;
        return;
    }
    // LadybugDB's native module holds open handles that prevent Node from exiting.
    // ONNX Runtime also registers native atexit hooks that segfault on some
    // platforms (#38, #40). Force-exit to ensure clean termination.
    process.exit(0);
};
