UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

1,133 lines • 59.8 kB
/**
 * lens_diagnostics tool — cached project diagnostic state (issue #159).
 *
 * Three modes:
 *   delta (default) — fixable warnings from the current agent turn, read from
 *                     the actionable-warnings and code-quality-warnings caches.
 *   all             — all known diagnostic counts across every file pi-lens has
 *                     seen this session, read from the widget state.
 *   full            — active project-wide LSP diagnostic scan merged with all.
 */
import { promises as fs } from "node:fs";
import * as fsSync from "node:fs";
import * as path from "node:path";
import { Type } from "../clients/deps/typebox.js";
import { applyInlineSuppressions } from "../clients/dispatch/inline-suppressions.js";
import { compactRenderResult } from "./render-compact.js";
import { combineAbortSignals } from "../clients/deadline-utils.js";
import { getProjectIgnoreMatcher } from "../clients/file-utils.js";
import { normalizeFilePath } from "../clients/path-utils.js";
import { getLSPService } from "../clients/lsp/index.js";
import { primaryServerId } from "../clients/lsp/config.js";
import { loadProjectDiagnosticsDeltaReport, loadProjectDiagnosticsSnapshot, PROJECT_DIAGNOSTICS_CACHE_VERSION, reconcileProjectDiagnosticsSnapshot, } from "../clients/project-diagnostics/cache.js";
import { warmTriggerFor } from "../clients/project-diagnostics/extractors.js";
import { fetchFreshProjectDiagnostics } from "../clients/project-diagnostics/fresh-fetch.js";
import { loadBootstrapClients } from "../clients/bootstrap.js";
import { scanProjectDiagnostics } from "../clients/project-diagnostics/scanner.js";
import { getFileDiagnosticSummaries, reconcileScanDiagnostics, reconcileStaleWidgetFiles, } from "../clients/widget-state.js";
import { convertLspDiagnostics } from "../clients/dispatch/utils/lsp-diagnostics.js";
import { makeProgressReporter, scanningSummaryLine } from "./scan-progress.js";
// The widget state exposes the full per-file diagnostic set; this is the tool's
// own generous display budget per file (independent of the TUI's 12 cap), to
// keep output bounded on a pathologically broken file.
const MAX_DIAGNOSTICS_PER_FILE = 50;
// `paths` (#461) is a wrapper-facing scope restrictor (e.g. "git-staged files
// only"), not a bulk-listing mechanism — a wrapper that hits this needs to
// narrow, not paginate. Erroring (rather than silently truncating) means a
// caller can never believe it checked files it didn't (issue's stated
// invariant).
const MAX_PATHS_ENTRIES = 200;
// Wall-clock ceiling for the whole mode=full scan. Even with per-file budgets
// and abort-signal honoring, a pathological state (a language server hanging on
// spawn/initialize across many files) could otherwise stall the tool
// indefinitely — an unattended session was observed hung for ~8h. This hard cap
// aborts the scan so it always returns (partial) rather than never. Env-tunable.
const FULL_SCAN_WALL_CLOCK_MS = (() => {
    const raw = Number(process.env.PI_LENS_LENS_DIAGNOSTICS_FULL_TIMEOUT_MS);
    return Number.isFinite(raw) && raw > 0 ? raw : 300_000; // 5 min default
})();
export function createLensDiagnosticsTool(cacheManager, getCwd, getLspService = getLSPService, 
// Flush any debounced per-edit dispatches before reading, so files the agent
// fixed earlier in the turn are re-dispatched and the widget reflects the
// CURRENT state — not the pre-fix diagnostics still pending in the debounce
// window. Injected (index wires `flushDebouncedToolResults`); optional so the
// tool stays decoupled and testable.
flushPending = async () => { }, 
// #571: mode=full's own fresh LSP results are reconciled into the footer
// (widget-state's `allDiagnostics`) for files this scan got a CONFIRMED
// (non-timed-out) result for — otherwise an unedited file whose diagnostics
// only changed because of a change to a file it depends on stays stale in
// the footer forever. Each reconciled write draws a fresh token from the
// SAME monotonic source `pipeline.ts`'s per-edit writes use
// (`RuntimeCoordinator.nextWriteIndex()`, injected by index.ts) so the
// existing `WriteOrderingGuard` (#555/#560) can tell this scan-originated
// write apart from a concurrent, genuinely newer per-edit write for the
// same file. Optional/undefined in tests — an omitted writeIndex always
// proceeds (see `reconcileScanDiagnostics`'s doc comment).
nextWriteIndex) {
    return {
        name: "lens_diagnostics",
        label: "Project Diagnostics",
        description: "Query pi-lens's diagnostic state. mode=delta/all are cache-only and instant; " +
            "mode=full is an expensive active project-wide LSP scan merged with cached runner state.\n\n" +
            "IMPORTANT: unlike lsp_diagnostics (LSP only), this tool covers ALL dispatch " +
            "runners: LSP errors, tree-sitter structural rules, ast-grep security rules, " +
            "biome/ruff/eslint lint findings, complexity violations, and more.\n\n" +
            "mode=delta (default): all warnings for the current agent turn — fixable warnings " +
            "(actionable-warnings cache) AND code quality/style/complexity issues " +
            "(code-quality-warnings cache). Same scope as the turn-end advisory, current turn only.\n\n" +
            "mode=all: blocking errors and warnings — with the actual messages (line, rule, " +
            "text), not just counts — for every file the agent has " +
            "EDITED this session (files that went through the dispatch pipeline). " +
            "NOTE: unedited files with pre-existing errors do NOT appear here — this is " +
            "not a full project scan. Use before declaring work done; stale blocking " +
            "errors from earlier turns are visible even if they dropped from turn-end context.\n\n" +
            "mode=full: EXPENSIVE active scan. Runs project-wide LSP diagnostics for " +
            "all supported files (including unedited files), then merges/deduplicates " +
            "that with mode=all cached runner state. Optional refreshRunners=cheap/all/cached " +
            "folds in project-wide runner findings: the in-process scanners (tree-sitter + " +
            "fact-rules + ast-grep) plus a FRESH run of the heavyweight analyzers — knip, " +
            "jscpd (copy-paste), madge (circular deps), gitleaks (secrets), govulncheck/trivy " +
            "(CVEs), dead-code — rather than a possibly-stale session_start cache; each " +
            "analyzer de-dupes against a concurrent background run of itself, so this can't " +
            "double-spawn. Bounded by the slowest analyzer (trivy's own ~180s ceiling).",
        promptSnippet: "Use lens_diagnostics mode=all to verify no blocking errors remain; use mode=full for expensive project-wide checks",
        renderResult: compactRenderResult(({ details, args, isError, text }) => {
            // Streaming progress partials render the live bar (see scanningSummaryLine)
            // instead of the details-driven summary, which would show "0 diagnostics"
            // mid-scan.
            const scanning = scanningSummaryLine(details, text);
            if (scanning)
                return scanning;
            const mode = details?.mode ?? (typeof args.mode === "string" ? args.mode : "delta");
            if (isError) {
                return `lens_diagnostics ${mode} — ${text.split("\n")[0] ?? "error"}`;
            }
            // #533: a "clean"/zero render must say so when heavyweight analyzers never
            // ran this session — a bare "clean" would otherwise misrepresent a cold
            // cache as a confirmed answer from those analyzers.
            const coldSuffix = details?.coldRunners && details.coldRunners.length > 0
                ? ` (${details.coldRunners.length} cold: ${details.coldRunners.join(", ")})`
                : "";
            if (mode === "delta") {
                const aw = details?.actionableWarnings ?? 0;
                const cq = details?.qualityIssues ?? 0;
                const pd = details?.projectDiagnostics ?? 0;
                if (aw + cq + pd === 0)
                    return `lens_diagnostics delta — clean${coldSuffix}`;
                return `lens_diagnostics delta — ${aw} actionable · ${cq} quality · ${pd} project${coldSuffix}`;
            }
            const b = details?.totalBlocking ?? 0;
            const e = details?.totalErrors ?? 0;
            const w = details?.totalWarnings ?? 0;
            const files = details?.filesWithIssues ?? details?.filesChecked ?? 0;
            if (b + e + w === 0) {
                return `lens_diagnostics ${mode} — clean (${files} files)${coldSuffix}`;
            }
            return `lens_diagnostics ${mode} — ${b} blocking · ${e} errors · ${w} warnings (${files} files)${coldSuffix}`;
        }),
        parameters: Type.Object({
            mode: Type.Optional(Type.String({
                enum: ["delta", "all", "full"],
                description: "delta = current turn's fixable warnings (default). " +
                    "all = session diagnostics for edited/dispatched files. " +
                    "full = expensive active project-wide LSP scan plus cached runner diagnostics.",
            })),
            refreshRunners: Type.Optional(Type.Union([
                Type.Boolean(),
                Type.String({ enum: ["cached", "cheap", "all", "none"] }),
            ], {
                description: "mode=full only: false/none = LSP + widget state only. cached/cheap/all all now trigger a FRESH run (#585) of the heavyweight project analyzers (knip, jscpd, madge, gitleaks, govulncheck, trivy, dead-code) in parallel — bounded by the slowest one (trivy's own ~180s ceiling) — instead of reading a possibly-stale session_start cache; safe to relaunch since each analyzer de-dupes concurrent runs against the same project root. cheap/all additionally refresh the in-process runners (tree-sitter + fact-rules + ast-grep) first.",
            })),
            maxProjectFiles: Type.Optional(Type.Number({
                description: "mode=full refreshRunners=cheap/all only: cap project files scanned by the cheap project runners (tree-sitter + fact-rules + ast-grep). Does NOT bound the LSP sweep — use maxLspFiles for that.",
            })),
            maxLspFiles: Type.Optional(Type.Number({
                description: "mode=full only: cap the number of files routed through the language server for the project-wide LSP sweep. On large projects (e.g. a Next.js app with thousands of source files) the uncapped sweep can take many minutes; set this to bound it. Default is generous (env PI_LENS_LSP_WORKSPACE_MAX_FILES, else 5000).",
            })),
            severity: Type.Optional(Type.String({
                enum: ["error", "warning", "all"],
                description: "Filter by severity (default: all).",
            })),
            paths: Type.Optional(Type.Array(Type.String(), {
                maxItems: MAX_PATHS_ENTRIES,
                description: `Restrict any mode to an explicit file/directory list (max ${MAX_PATHS_ENTRIES} entries; ` +
                    "more errors instead of silently truncating). Entries may be relative " +
                    "(resolved against cwd) or absolute, and a directory entry matches all " +
                    "files under it (e.g. \"src/\"). mode=delta/all are a pure post-filter " +
                    "of cached/session state — they can only show findings for files pi-lens " +
                    "has already dispatched, so an unseen file shows nothing (use mode=full " +
                    "for an active scan). mode=full actively scans exactly these paths (LSP " +
                    "sweep + cheap in-process runners); cached heavyweight analyzers " +
                    "(jscpd/madge/gitleaks/knip) and the project snapshot are still post-filtered " +
                    "cache reads, never relaunched. Explicitly-listed files are NOT filtered " +
                    "through the project ignore matcher (matching lsp_diagnostics' paths " +
                    "semantics) — naming a file is assumed to mean it regardless of " +
                    ".gitignore/.pi-lens.json; a directory entry's expansion still honors " +
                    "ignore (and when the list mixes directories and files, mode=full scans " +
                    "via the ignore-filtered walk, so an ignore-excluded file entry is only " +
                    "guaranteed an active scan in a files-only list). Nonexistent entries " +
                    "are skipped (mode=full notes them; useful for git-staged-file wrappers " +
                    "where a deleted-but-staged path can appear).",
            })),
        }),
        async execute(_toolCallId, params, signal, onUpdate, ctx) {
            const mode = params.mode ?? "delta";
            const severity = params.severity ?? "all";
            const refreshRunners = params.refreshRunners;
            const parsePositiveInt = (value) => typeof value === "number" && Number.isFinite(value) && value > 0
                ? Math.floor(value)
                : undefined;
            const maxProjectFiles = parsePositiveInt(params.maxProjectFiles);
            const maxLspFiles = parsePositiveInt(params.maxLspFiles);
            const cwd = ctx.cwd ?? getCwd();
            let pathsScope;
            try {
                pathsScope = resolvePathsScope(cwd, params.paths);
            }
            catch (err) {
                return {
                    content: [
                        {
                            type: "text",
                            text: err instanceof Error ? err.message : String(err),
                        },
                    ],
                    isError: true,
                    details: { mode, pathsError: true },
                };
            }
            // Escape aborts the agent *turn*, which fires ctx.signal (the turn-wired
            // abort); the positional signal is the tool-call signal. A registered
            // extension tool only reliably sees the turn abort via ctx.signal, so honor
            // BOTH — else a long mode=full scan ignores Escape (the reported bug).
            const abortSignal = combineAbortSignals(signal, ctx.signal);
            // Reflect the agent's just-made fixes before reporting: flush pending
            // per-edit dispatches (re-records fixed files), then drop entries whose
            // file changed on disk afterwards / was deleted (stale, e.g. external
            // edits). Together these stop fixed-this-session findings from lingering.
            await flushPending();
            const staleDropped = await reconcileStaleWidgetFiles();
            if (mode === "all") {
                return formatAllMode(cwd, severity, undefined, undefined, staleDropped, pathsScope);
            }
            if (mode === "full") {
                // Fold a hard wall-clock ceiling into the abort signal so the scan
                // always terminates (partial) even if a hung server would otherwise
                // stall it forever. AbortSignal.timeout aborts with a TimeoutError.
                const ceiling = AbortSignal.timeout(FULL_SCAN_WALL_CLOCK_MS);
                const fullSignal = combineAbortSignals(abortSignal, ceiling);
                // Stream a throttled progress bar: the full scan is opaque for minutes
                // otherwise.
                const onProgress = makeProgressReporter(onUpdate);
                return formatFullMode(cwd, severity, getLspService(), cacheManager, {
                    refreshRunners,
                    maxProjectFiles,
                    maxLspFiles,
                    signal: fullSignal,
                    wallClockMs: FULL_SCAN_WALL_CLOCK_MS,
                    onProgress,
                    pathsScope,
                    nextWriteIndex,
                });
            }
            return formatDeltaMode(cacheManager, cwd, severity, pathsScope);
        },
    };
}
// ── delta mode ────────────────────────────────────────────────────────────────
function formatProjectDeltaDiagnostic(diagnostic) {
    const marker = diagnostic.semantic === "blocking" || diagnostic.severity === "error"
        ? "🔴"
        : "ℹ";
    const rule = diagnostic.rule ?? diagnostic.code ?? diagnostic.runner;
    return `  ${marker} L${diagnostic.line ?? "?"}  ${rule}  ${diagnostic.message}`;
}
function appendProjectDiagnosticsDeltaLines(lines, cwd, report, severity, includeFile) {
    const diagnostics = (report?.diagnostics ?? []).filter((diagnostic) => includeFile(diagnostic.filePath) &&
        matchesSeverity(projectDiagnosticToWidget(diagnostic), severity));
    const byFile = new Map();
    for (const diagnostic of diagnostics) {
        const filePath = path.resolve(diagnostic.filePath);
        const bucket = byFile.get(filePath) ?? [];
        bucket.push(diagnostic);
        byFile.set(filePath, bucket);
    }
    for (const [filePath, fileDiagnostics] of byFile) {
        const rel = path.relative(cwd, filePath);
        if (!lines.includes(rel))
            lines.push(rel);
        for (const diagnostic of fileDiagnostics) {
            lines.push(formatProjectDeltaDiagnostic(diagnostic));
        }
    }
    return diagnostics.length;
}
function formatDeltaMode(cacheManager, cwd, severity, pathsScope) {
    const actionableEntry = cacheManager.readCache("actionable-warnings", cwd);
    const qualityEntry = cacheManager.readCache("code-quality-warnings", cwd);
    const actionable = actionableEntry?.data;
    const quality = qualityEntry?.data;
    const projectDelta = loadProjectDiagnosticsDeltaReport(cwd);
    const ignoreFile = createCurrentIgnoreFilter(cwd);
    const includeFile = (filePath) => ignoreFile(filePath) && (!pathsScope || pathsScope.includeFile(filePath));
    const actionableFiles = (actionable?.files ?? []).filter((file) => includeFile(file.filePath));
    const qualityFiles = (quality?.files ?? []).filter((file) => includeFile(file.filePath));
    const lines = [];
    // Fixable warnings from actionable-warnings
    if (actionableFiles.length > 0 && severity !== "error") {
        for (const file of actionableFiles) {
            const rel = path.relative(cwd, file.filePath);
            lines.push(`${rel}`);
            for (const w of file.warnings ?? []) {
                lines.push(`  ⚠ L${w.line ?? "?"}  ${w.rule ?? w.code ?? w.tool}  ${w.message}`);
            }
        }
    }
    // Quality issues
    if (qualityFiles.length > 0 && severity !== "error") {
        for (const file of qualityFiles) {
            const rel = path.relative(cwd, file.filePath);
            if (!lines.includes(rel))
                lines.push(rel);
            for (const w of file.warnings ?? []) {
                lines.push(`  ℹ L${w.line ?? "?"}  ${w.rule ?? w.code ?? w.tool}  ${w.message}`);
            }
        }
    }
    const projectDeltaCount = appendProjectDiagnosticsDeltaLines(lines, cwd, projectDelta, severity, includeFile);
    const aw = actionableFiles.reduce((count, file) => count + (file.warnings?.length ?? 0), 0);
    const cq = qualityFiles.reduce((count, file) => count + (file.warnings?.length ?? 0), 0);
    if (lines.length === 0) {
        let text = `No ${severity === "all" ? "" : severity + " "}issues in the current turn delta.`;
        // Discoverability (#190): `delta` is current-turn-scoped, so it's empty
        // right after a resume even when prior findings were rehydrated into the
        // session-wide view. Point the agent at `mode=all` when that's the case.
        const carried = getFileDiagnosticSummaries().filter((f) => includeFile(f.filePath) && f.diagnostics.length > 0);
        const carriedIssues = carried.reduce((n, f) => n + f.diagnostics.length, 0);
        if (carried.length > 0) {
            text += ` ${carriedIssues} finding${carriedIssues === 1 ? "" : "s"} across ${carried.length} file${carried.length === 1 ? "" : "s"} carried over from earlier this session — use mode=all to see them.`;
        }
        text += pathsScopeCacheOnlyNote(pathsScope);
        return {
            content: [{ type: "text", text }],
            details: { mode: "delta", warnings: 0, carriedOverFiles: carried.length },
        };
    }
    const summary = `\nSummary (turn delta): ${aw} actionable warning${aw === 1 ? "" : "s"} · ${cq} quality issue${cq === 1 ? "" : "s"} · ${projectDeltaCount} project diagnostic${projectDeltaCount === 1 ? "" : "s"}`;
    return {
        content: [{ type: "text", text: lines.join("\n") + summary }],
        details: {
            mode: "delta",
            actionableWarnings: aw,
            qualityIssues: cq,
            projectDiagnostics: projectDeltaCount,
        },
    };
}
// ── all mode ──────────────────────────────────────────────────────────────────
function createCurrentIgnoreFilter(cwd) {
    try {
        const matcher = getProjectIgnoreMatcher(cwd);
        return (filePath) => !matcher.isIgnored(path.resolve(filePath), false);
    }
    catch {
        // Diagnostics should remain available even if an ignore config/root probe is
        // temporarily unreadable. Walkers already treat config load failures as
        // non-fatal; match that behavior for cached diagnostic presentation.
        return () => true;
    }
}
/**
 * Resolves the `paths` param into a scope predicate. Returns `undefined` when
 * `paths` was not provided (callers fall back to no restriction). Throws a
 * plain `Error` when the cap is exceeded — the caller turns that into a
 * clear tool error rather than silently truncating (a wrapper must not
 * believe it checked files it didn't).
 */
function resolvePathsScope(cwd, rawPaths) {
    if (!Array.isArray(rawPaths) || rawPaths.length === 0)
        return undefined;
    if (rawPaths.length > MAX_PATHS_ENTRIES) {
        throw new Error(`paths has ${rawPaths.length} entries, over the ${MAX_PATHS_ENTRIES} cap. ` +
            "Narrow the list or omit paths to scan without a restriction.");
    }
    const entries = rawPaths
        .filter((entry) => typeof entry === "string" && entry.trim().length > 0)
        .map((entry) => path.resolve(path.isAbsolute(entry) ? entry : path.resolve(cwd, entry)));
    const missing = [];
    const fileEntries = [];
    const existingEntries = [];
    const dirEntries = [];
    for (const entry of entries) {
        let stat;
        try {
            stat = fsSync.statSync(entry);
        }
        catch {
            // Nonexistent on disk right now — still treated as a file-key match
            // candidate for the delta/all cache filter (not just dropped): the
            // wrapper use case passes git output, where a deleted-but-staged path
            // legitimately has no on-disk file yet may still have cached findings
            // from before deletion. Excluded from `existingEntries` (nothing to
            // actively scan) and recorded in `missing` for mode=full's skipped-note.
            missing.push(entry);
            fileEntries.push(entry);
            continue;
        }
        if (stat.isDirectory()) {
            dirEntries.push(entry);
        }
        else {
            fileEntries.push(entry);
            existingEntries.push(entry);
        }
    }
    const fileKeys = new Set(fileEntries.map((f) => normalizeFilePath(f)));
    const dirPrefixes = dirEntries.map((d) => normalizeFilePath(d));
    const includeFile = (filePath) => {
        const key = normalizeFilePath(path.resolve(filePath));
        if (fileKeys.has(key))
            return true;
        return dirPrefixes.some((prefix) => key === prefix || key.startsWith(`${prefix}/`));
    };
    return {
        includeFile,
        missing,
        existingEntries,
        hasDirectoryEntries: dirEntries.length > 0,
    };
}
/** Short, honest note for delta/all when `paths` filtered everything out — these modes are cache-only, so an unseen file legitimately shows nothing. */
function pathsScopeCacheOnlyNote(scope) {
    if (!scope)
        return "";
    return ("\n\nNote: paths restricts this to cached findings for files pi-lens has " +
        "already dispatched this session — mode=delta/all can't see files it " +
        "hasn't touched. Use mode=full to actively scan exactly these paths.");
}
/** Short note listing paths entries that don't exist on disk (mode=full only — the wrapper use case passes git output, where a deleted-but-staged file WILL appear). */
function pathsScopeMissingNote(scope) {
    if (!scope || scope.missing.length === 0)
        return "";
    const shown = scope.missing.slice(0, 10).map((p) => path.basename(p));
    const more = scope.missing.length > shown.length
        ? ` (+${scope.missing.length - shown.length} more)`
        : "";
    return `\n\n⚠ Skipped ${scope.missing.length} path(s) not found on disk: ${shown.join(", ")}${more}`;
}
function filterProjectDiagnosticsSnapshot(snapshot, includeFile) {
    if (!snapshot)
        return undefined;
    return {
        ...snapshot,
        diagnostics: snapshot.diagnostics.filter((d) => includeFile(d.filePath)),
    };
}
function filterProjectDiagnosticsDeltaReport(report, includeFile) {
    if (!report)
        return undefined;
    return {
        ...report,
        diagnostics: report.diagnostics.filter((d) => includeFile(d.filePath)),
    };
}
/** A diagnostic counts as error-like when it blocks or has error severity. */
function isErrorLike(d) {
    return d.semantic === "blocking" || d.severity === "error";
}
function matchesSeverity(d, severity) {
    if (severity === "error")
        return isErrorLike(d);
    if (severity === "warning")
        return !isErrorLike(d);
    return true;
}
/** Most-important first: blocking → error → warning/other, then by line. */
function severityRank(d) {
    if (d.semantic === "blocking")
        return 0;
    if (d.severity === "error")
        return 1;
    if (d.severity === "warning")
        return 2;
    return 3;
}
function bySeverityThenLine(a, b) {
    return severityRank(a) - severityRank(b) || (a.line ?? 0) - (b.line ?? 0);
}
function lspSeverityName(severity) {
    if (severity === 1)
        return "error";
    if (severity === 2)
        return "warning";
    if (severity === 3)
        return "info";
    return "hint";
}
function lspRuleId(diagnostic) {
    const code = diagnostic.code === undefined ? undefined : String(diagnostic.code);
    if (diagnostic.source && code)
        return `${diagnostic.source}:${code}`;
    return diagnostic.source ?? code ?? "lsp";
}
function lspDiagnosticToWidget(diagnostic) {
    const severity = lspSeverityName(diagnostic.severity);
    const rule = lspRuleId(diagnostic);
    return {
        severity,
        semantic: diagnostic.severity === 1 ? "blocking" : "warning",
        message: diagnostic.message,
        line: diagnostic.range.start.line + 1,
        col: diagnostic.range.start.character + 1,
        rule,
        tool: "lsp",
    };
}
function projectDiagnosticToWidget(diagnostic) {
    return {
        severity: diagnostic.severity,
        semantic: diagnostic.semantic,
        message: diagnostic.message,
        line: diagnostic.line,
        col: diagnostic.column,
        rule: diagnostic.rule ?? diagnostic.code,
        tool: diagnostic.runner || diagnostic.tool,
    };
}
// The ast-grep LSP keys its diagnostics `rule = "ast-grep:<id>"` (source:code,
// lsp-diagnostics.ts), while the napi runner — per-edit AND the project scan
// (#308) — emits the bare `<id>`. Both run the same shipped ruleset, so the same
// violation must collapse to one finding in mode=full's merge regardless of which
// engine produced it. Strip the `ast-grep:` source prefix and the `-js` language
// suffix (the napi runner already treats `<id>` / `<id>-js` as one rule) so the
// LSP sweep and the napi scan don't double-report the same line.
function normalizeRuleForDedup(ruleId) {
    return ruleId.replace(/^ast-grep:/, "").replace(/-js$/, "");
}
function diagnosticDedupKey(filePath, diagnostic) {
    const ruleId = normalizeRuleForDedup(diagnostic.rule ?? diagnostic.tool ?? "");
    return [path.resolve(filePath), diagnostic.line ?? "?", ruleId].join(":");
}
function summarizeDiagnostics(filePath, diagnostics, hasFinalSnapshot) {
    let blocking = 0;
    let errors = 0;
    let warnings = 0;
    for (const diagnostic of diagnostics) {
        if (diagnostic.semantic === "blocking")
            blocking++;
        if (diagnostic.severity === "error")
            errors++;
        else if (diagnostic.severity === "warning")
            warnings++;
    }
    return {
        filePath,
        blocking,
        errors,
        warnings,
        hasFinalSnapshot,
        diagnostics,
    };
}
/**
 * #646: break the confirmed/unconfirmed LSP-sweep tally down PER primary
 * server id (typescript, pyright, marksman, ...) instead of one flat
 * aggregate count. Mirrors `tools/lsp-diagnostics.ts`'s `primaryServerId`/
 * `tallyConfirmation` split — both tools now use the SAME
 * `primaryServerId` (clients/lsp/config.ts) so a file's classification as
 * "primary language server" vs "auxiliary scanner" agrees between them.
 *
 * Motivating case (#646): a real sweep came back 34/155 unconfirmed, and
 * diagnosing WHICH server was responsible required grepping
 * `~/.pi-lens/latency.log` by hand — it turned out to be 100% one push-only
 * server (marksman). This tally makes that visible directly: `{ marksman:
 * { confirmed: 0, total: 34 }, typescript: { confirmed: 121, total: 121 } }`.
 *
 * Purely an additional reporting dimension — does NOT change what counts as
 * confirmed/unconfirmed (the existing `confirmedLspResults`/
 * `unconfirmedLspResults` partition in `formatFullMode` is unchanged and
 * still the source of truth this reads from).
 */
function tallyLspByPrimaryServer(confirmed, unconfirmed) {
    const byServer = new Map();
    const bump = (filePath, wasConfirmed) => {
        const id = primaryServerId(filePath) ?? "unknown";
        const entry = byServer.get(id) ?? { confirmed: 0, total: 0 };
        entry.total += 1;
        if (wasConfirmed)
            entry.confirmed += 1;
        byServer.set(id, entry);
    };
    for (const result of confirmed)
        bump(result.filePath, true);
    for (const result of unconfirmed)
        bump(result.filePath, false);
    return byServer;
}
/**
 * Render the per-server tally from `tallyLspByPrimaryServer` as a short
 * clause, e.g. "typescript: 121/121, marksman: 0/34" — sorted so the
 * worst-confirmed server (the one most likely to be why a sweep looks
 * unconfirmed) is called out first.
 */
function formatLspServerBreakdown(byServer) {
    return [...byServer.entries()]
        .sort((a, b) => {
        const rateA = a[1].confirmed / a[1].total;
        const rateB = b[1].confirmed / b[1].total;
        return rateA - rateB;
    })
        .map(([id, { confirmed, total }]) => `${id}: ${confirmed}/${total}`)
        .join(", ");
}
/**
 * #646: split a raw LSP-sweep result set's diagnostics into "primary" (the
 * real language server, e.g. typescript/pyright) vs "auxiliary" (cross-
 * cutting scanners attached via clientScope "all" — ast-grep, opengrep,
 * zizmor, typos, marksman, ...), the same separation `lsp_diagnostics`
 * already applies to its own findings rendering (see
 * `tools/lsp-diagnostics.ts`'s `collectBatchDiagnostics`). Computed from the
 * raw per-file LSP results (not the merged widget-state summaries further
 * down `formatFullMode`) so it doesn't disturb the existing merge/dedup
 * logic those summaries feed into.
 */
function tallyLspPrimaryVsAuxiliary(results) {
    let primary = 0;
    let auxiliary = 0;
    for (const result of results) {
        const primaryId = primaryServerId(result.filePath);
        for (const diagnostic of result.diagnostics ?? []) {
            if (diagnostic.source === primaryId)
                primary += 1;
            else
                auxiliary += 1;
        }
    }
    return { primary, auxiliary };
}
function mergeDiagnosticsWithWidgetSummaries(widgetSummaries, lspResults, projectSnapshot, projectDelta) {
    const byFile = new Map();
    const seen = new Set();
    for (const summary of widgetSummaries) {
        const filePath = path.resolve(summary.filePath);
        const diagnostics = (summary.diagnostics ?? []).map((d) => ({ ...d }));
        byFile.set(filePath, { ...summary, filePath, diagnostics });
        for (const diagnostic of diagnostics) {
            seen.add(diagnosticDedupKey(filePath, diagnostic));
        }
    }
    const addDiagnostic = (filePath, widgetDiagnostic) => {
        const existing = byFile.get(filePath);
        const diagnostics = existing ? [...existing.diagnostics] : [];
        const key = diagnosticDedupKey(filePath, widgetDiagnostic);
        if (seen.has(key))
            return;
        seen.add(key);
        diagnostics.push(widgetDiagnostic);
        byFile.set(filePath, summarizeDiagnostics(filePath, diagnostics, existing?.hasFinalSnapshot ?? true));
    };
    for (const result of lspResults) {
        const filePath = path.resolve(result.filePath);
        for (const diagnostic of result.diagnostics ?? []) {
            addDiagnostic(filePath, lspDiagnosticToWidget(diagnostic));
        }
    }
    for (const diagnostic of projectSnapshot?.diagnostics ?? []) {
        addDiagnostic(path.resolve(diagnostic.filePath), projectDiagnosticToWidget(diagnostic));
    }
    for (const diagnostic of projectDelta?.diagnostics ?? []) {
        addDiagnostic(path.resolve(diagnostic.filePath), projectDiagnosticToWidget(diagnostic));
    }
    return [...byFile.values()];
}
/**
 * Apply inline `pi-lens-ignore` suppression to the merged mode=full summaries so
 * the project-wide sweep honors the same comments as the per-edit path (#442) —
 * without it, a site cleanly suppressed in mode=all reappears as blocking here.
 * Reads each flagged file once (bounded to files that actually have diagnostics);
 * a read failure is fail-safe (keep the diagnostics rather than hide a finding on
 * an I/O error). Re-summarizes so the blocking/error/warning counts reflect the
 * suppression.
 */
async function applyInlineSuppressionsToSummaries(summaries) {
    return Promise.all(summaries.map(async (summary) => {
        if (!summary.diagnostics.length)
            return summary;
        let content;
        try {
            content = await fs.readFile(summary.filePath, "utf8");
        }
        catch {
            return summary; // never hide a finding on a read error
        }
        const kept = applyInlineSuppressions(summary.diagnostics, content);
        if (kept.length === summary.diagnostics.length)
            return summary;
        return summarizeDiagnostics(summary.filePath, kept, summary.hasFinalSnapshot);
    }));
}
function shouldUseCachedProjectDiagnostics(value) {
    return value === "cached";
}
function shouldRefreshProjectDiagnostics(value) {
    return value === "cheap" || value === "all";
}
/** True when full mode should include project-runner state at all (any non-none refreshRunners). */
function shouldIncludeProjectRunners(value) {
    return shouldRefreshProjectDiagnostics(value) || value === "cached";
}
/**
 * Merge cache-derived diagnostics from the analyzer extractors (jscpd, madge,
 * gitleaks, knip …) into the scanned project snapshot: append to the existing
 * one (recording the runners), or synthesize a minimal snapshot when there was
 * no in-process scan. Returns the snapshot unchanged when there is nothing extra.
 */
function foldExtraDiagnosticsIntoSnapshot(snapshot, extra, runners, cwd) {
    if (extra.length === 0)
        return snapshot;
    if (snapshot) {
        const merged = new Set([...snapshot.runners, ...runners]);
        return {
            ...snapshot,
            diagnostics: [...snapshot.diagnostics, ...extra],
            runners: [...merged],
        };
    }
    return {
        version: PROJECT_DIAGNOSTICS_CACHE_VERSION,
        cwd,
        tier: "all",
        scannedAt: new Date().toISOString(),
        diagnostics: extra,
        filesScanned: 0,
        runners,
    };
}
async function getProjectDiagnosticsSnapshotForFullMode(cwd, options) {
    if (shouldRefreshProjectDiagnostics(options.refreshRunners)) {
        return scanProjectDiagnostics({
            cwd,
            tier: "cheap",
            maxFiles: options.maxProjectFiles,
            signal: options.signal,
            files: options.files,
        });
    }
    if (shouldUseCachedProjectDiagnostics(options.refreshRunners)) {
        // The cached snapshot is a cross-session cache; drop diagnostics for files
        // edited/deleted since the scan so a stale entry isn't replayed (#298). A
        // fresh scan (above) is current by construction and needs no reconcile.
        const cached = loadProjectDiagnosticsSnapshot(cwd);
        return cached
            ? reconcileProjectDiagnosticsSnapshot(cached).snapshot
            : undefined;
    }
    return undefined;
}
async function formatFullMode(cwd, severity, lspService, cacheManager, options = {}) {
    const runWorkspaceDiagnostics = lspService.runWorkspaceDiagnostics;
    if (typeof runWorkspaceDiagnostics !== "function") {
        return {
            content: [
                {
                    type: "text",
                    text: "LSP service does not support project-wide workspace diagnostics.",
                },
            ],
            details: { mode: "full", filesChecked: 0, lspUnavailable: true },
        };
    }
    const { signal, pathsScope, nextWriteIndex } = options;
    const ignoreFile = createCurrentIgnoreFilter(cwd);
    const includeFile = (filePath) => ignoreFile(filePath) && (!pathsScope || pathsScope.includeFile(filePath));
    // `paths` (#461): route the active scans at exactly the requested files
    // instead of walking the whole project. Three cases:
    // - files only → pass them as the explicit list (skips the walk). An EMPTY
    //   list (every requested file was missing) still passes through — both
    //   seams treat [] as "scan nothing", where undefined would trigger a full
    //   project walk that includeFile then filters to nothing (expensive no-op).
    // - any directory entry → fall back to the walk, narrowed by includeFile.
    //   Passing only the file entries would silently skip everything under the
    //   requested directories — an under-scan the caller can't detect.
    // - no paths → undefined, today's full walk.
    const explicitFiles = pathsScope && !pathsScope.hasDirectoryEntries
        ? pathsScope.existingEntries
        : undefined;
    // #613: the heavyweight-analyzer fresh-fetch (below) used to be `await`ed
    // AFTER this Promise.all had already fully resolved — sequentially eating
    // into the SAME wall-clock ceiling (`FULL_SCAN_WALL_CLOCK_MS`) the LSP sweep
    // already spent, rather than sharing it concurrently as the old comment here
    // claimed. On a real project the LSP sweep alone can take 100+s, leaving the
    // analyzers almost no budget before the shared abort signal fires — killing
    // ALL of them (even unconditional ones like knip/jscpd/madge) before any
    // could complete. Building its promise here, alongside the sweep and cheap
    // scan, makes it genuinely concurrent — all three phases now race the SAME
    // signal from the same starting point instead of stacking.
    const analyzersPromise = shouldIncludeProjectRunners(options.refreshRunners)
        ? loadBootstrapClients().then((clients) => fetchFreshProjectDiagnostics(cacheManager, cwd, clients, signal))
        : Promise.resolve({
            diagnostics: [],
            runners: [],
            cold: [],
            timings: {},
        });
    const [rawLspResults, rawProjectSnapshot, extracted] = await Promise.all([
        runWorkspaceDiagnostics.call(lspService, cwd, {
            maxFiles: options.maxLspFiles,
            signal,
            onProgress: options.onProgress,
            files: explicitFiles,
        }),
        getProjectDiagnosticsSnapshotForFullMode(cwd, {
            ...options,
            files: explicitFiles,
        }),
        analyzersPromise,
    ]);
    const aborted = signal?.aborted ?? false;
    const lspResults = rawLspResults.filter((result) => includeFile(result.filePath));
    // #630: `timedOut`/`error` means the per-file check never actually
    // completed or was inconclusive (#570's `touchFile().inconclusive`, or
    // this sweep's own outer per-file deadline/throw — see
    // `LSPWorkspaceDiagnosticResult`'s doc comment) — `diagnostics` is a
    // default-EMPTY placeholder in that case, not a confirmed clean. Partition
    // once here so BOTH the footer write (below, #571) and the merge into
    // `summaries` (further down) share the same confirmed/unconfirmed split.
    // Previously only the footer write excluded these; the merge still fed
    // the unfiltered `lspResults` in, so a timed-out file's placeholder `[]`
    // read as "0 diagnostics" — false-clean — in the rendered/`details`
    // output (#630).
    const confirmedLspResults = lspResults.filter((result) => !result.timedOut && !result.error);
    const unconfirmedLspResults = lspResults.filter((result) => result.timedOut || result.error);
    // #571: reconcile this scan's fresh, CONFIRMED per-file results into the
    // footer cache. A footer write is never allowed to fail the tool call, so
    // any unexpected throw is swallowed.
    for (const result of confirmedLspResults) {
        try {
            reconcileScanDiagnostics(result.filePath, convertLspDiagnostics(result.diagnostics, result.filePath, {
                source: "lens_diagnostics_full",
            }), true, nextWriteIndex?.());
        }
        catch {
            // Never let a footer-reconciliation hiccup fail the scan itself.
        }
    }
    const scannedSnapshot = filterProjectDiagnosticsSnapshot(rawProjectSnapshot, includeFile);
    // Heavyweight-analyzer findings (jscpd, madge, gitleaks, knip, govulncheck,
    // trivy, dead-code) — a FRESH run of each (#585), not a cache-only read:
    // mode=full is the "authoritative full picture" call, so a session_start-only
    // snapshot that can be hours stale in a long session is no longer good
    // enough. Safe to run concurrently with the LSP sweep: every one of these
    // analyzers has its own in-flight de-dupe guard (KnipClient/JscpdClient/
    // DeadCodeClient's `inFlight` map, SecurityScanClient.dedupeScan for
    // gitleaks/govulncheck/trivy — see fresh-fetch.ts's header), so a fresh
    // fetch here racing a concurrent session_start/turn_end pass over the same
    // tool JOINS that run instead of double-spawning it. `extracted` was already
    // computed above, in the SAME `Promise.all` as the LSP sweep (#613) — only
    // when the caller opted into project-runner state (otherwise it's the
    // `Promise.resolve({...})` stub from `analyzersPromise` above).
    const projectSnapshot = foldExtraDiagnosticsIntoSnapshot(scannedSnapshot, extracted.diagnostics.filter((d) => includeFile(d.filePath)), extracted.runners, cwd);
    const projectDelta = filterProjectDiagnosticsDeltaReport(loadProjectDiagnosticsDeltaReport(cwd), includeFile);
    // #630: only the CONFIRMED LSP results contribute diagnostics to the merge
    // — an unconfirmed (timed-out/errored) file's placeholder `[]` must not be
    // read as "0 issues, clean" via its LSP contribution. It can still
    // legitimately show diagnostics from widgetSummaries/project-runner state
    // below if those independently have entries for it.
    const summaries = await applyInlineSuppressionsToSummaries(mergeDiagnosticsWithWidgetSummaries(getFileDiagnosticSummaries().filter((summary) => includeFile(summary.filePath)), confirmedLspResults, projectSnapshot, projectDelta));
    const result = formatAllMode(cwd, severity, summaries, {
        mode: "full",
        lspFilesChecked: rawLspResults.length,
        partial: aborted,
        projectDiagnostics: projectSnapshot === undefined
            ? undefined
            : {
                tier: projectSnapshot.tier,
                filesScanned: projectSnapshot.filesScanned,
                diagnostics: projectSnapshot.diagnostics.length,
                runners: projectSnapshot.runners,
            },
        projectDiagnosticsDelta: projectDelta === undefined
            ? undefined
            : {
                diagnostics: projectDelta.diagnostics.length,
                sources: projectDelta.sources,
                turnIndex: projectDelta.turnIndex,
            },
    });
    const missingNote = pathsScopeMissingNote(pathsScope);
    // #630: mirrors `tools/lsp-diagnostics.ts`'s `unconfirmedReasonClause`/
    // `tallyConfirmation` semantic guarantee (never silently render
    // unconfirmed as clean), adapted to this tool's own aggregate-summary
    // shape rather than its per-file result shape. `confirmedLspResults` were
    // already excluded from the merge above, so an agent reading the summary
    // alone would otherwise see nothing for these files and could read that
    // as "0 diagnostics" — say explicitly which files the LSP sweep could not
    // confirm and why, distinguishing a hard error from a soft timeout the
    // same way #570 does upstream.
    const unconfirmedTimedOut = unconfirmedLspResults.filter((result) => result.timedOut).length;
    const unconfirmedErrored = unconfirmedLspResults.length - unconfirmedTimedOut;
    // #646: per-primary-server breakdown of the same confirmed/unconfirmed
    // tally — lets an agent see AT A GLANCE which server is responsible for
    // any unconfirmed files (e.g. "marksman: 0/34") instead of having to
    // cross-reference latency.log by hand. Computed unconditionally so it's
    // always available in `details` even on an all-confirmed sweep; only
    // rendered in the text note when more than one server is involved (with
    // a single server the breakdown is redundant with the totals already
    // stated).
    const lspServerBreakdown = tallyLspByPrimaryServer(confirmedLspResults, unconfirmedLspResults);
    const serverBreakdownClause = lspServerBreakdown.size > 1
        ? ` — by server: ${formatLspServerBreakdown(lspServerBreakdown)}.`
        : "";
    const unconfirmedLspNote = unconfirmedLspResults.length > 0
        ? `\n\n⚠ LSP sweep: ${confirmedLspResults.length} file(s) confirmed via LSP, ` +
            `${unconfirmedLspResults.length} unconfirmed (${unconfirmedTimedOut > 0 && unconfirmedErrored > 0
                ? `${unconfirmedTimedOut} timed out, ${unconfirmedErrored} errored`
                : unconfirmedTimedOut > 0
                    ? "check didn't complete within budget"
                    : "check errored"})${serverBreakdownClause} — NOT the same as 0 diagnostics for: ${unconfirmedLspResults
                .slice(0, 20)
                .map((result) => result.filePath)
                .join(", ")}${unconfirmedLspResults.length > 20 ? ", …" : ""}. These files' LSP contribution is excluded from this result; re-run mode=full to retry them (they may still show findings above from cached/project-runner state).`
        : "";
    // #646: primary-vs-auxiliary split of the raw LSP-sweep findings (before
    // they're merged into the widget-state summaries below), mirroring
    // `lsp_diagnostics`' "Primary findings"/"Auxiliary findings" separation —
    // so a page of ast-grep/opengrep/marksman noise never buries whether the
    // real language server itself found anything in this sweep.
    const lspPrimaryVsAuxiliary = tallyLspPrimaryVsAuxiliary(confirmedLspResults);
    const lspPrimaryVsAuxiliaryNote = lspPrimaryVsAuxiliary.primary + lspPrimaryVsAuxiliary.auxiliary > 0
        ? `\n\nLSP sweep findings: ${lspPrimaryVsAuxiliary.primary} primary (language server), ` +
            `${lspPrimaryVsAuxiliary.auxiliary} auxiliary (ast-grep/opengrep/zizmor/typos/marksman/...).`
        : "";
    // #533/#585: a heavyweight analyzer (knip/jscpd/madge/gitleaks/govulncheck/
    // trivy/dead-code) that contributed nothing this run is either COLD (not
    // applicable to this project / tool unavailable — see fresh-fetch.ts's
    // per-analyzer gates) or genuinely clean; `cold` only ever lists the
    // former. Silently folding "not applicable" into "no issues found" would be
    // exactly the false-empty #533 is about. Named per #511/#514's
    // actionable-warning shape: say what's cold and what would warm it, only
    // when the caller actually asked for runner state (extracted.cold is
    // always [] otherwise).
    // #585: an aborted fresh-fetch (Escape / the mode=full wall-clock ceiling)
    // reports its still-in-flight analyzers separately from a genuine
    // not-applicable/unavailable skip — "stopped mid-scan, unknown" is a
    // different, more honest reason than "not applicable to this project", and
    // conflating them would suggest re-running mode=full is pointless when it
    // isn't (a re-run may well complete for that analyzer).
    const abortedIds = new Set(extracted.abortedIds ?? []);
    const genuinelyColdIds = extracted.cold.filter((id) => !abortedIds.has(id));
    const coldNote = genuinelyColdIds.length > 0
        ? `\n\ncold (not applicable / unavailable this run): ${genuinelyColdIds
            .map((id) => `${id} — ${warmTriggerFor(id)}`)
            .join(", ")}. These analyzers have not contributed to this result — absence of their findings is NOT a clean verdict.`
        : "";
    const abortedNote = abortedIds.size > 0
        ? `\n\nstopped mid-scan (still running in the background, not reflected in this result): ${[
            ...abortedIds,
        ].join(", ")}. Not a clean verdict for these — re-run mode=full to pick up their result once it's cached.`
        : "";
    // #585: mode=full now fetches these analyzers fresh rather than reading a
    // possibly-stale session_start cache — say so honestly, with per-analyzer
    // elapsed time, since this can legitimately take a while (trivy's own
    // ~180s ceiling).
    const timingEntries = Object.entries(extracted.timings ?? {});
    const freshNote = timingEntries.length > 0
        ? `\n\nfetched fresh this call: ${timingEntries
            .map(([id, ms]) => `${id} (${Math.round(ms)}ms)`)
            .join(", ")}.`
        : "";
    // coldRunners always lands in details (even when empty) so a caller can
    // reliably check "were any extractors cold" without a presence check.
    // analyzerTimingsMs surfaces the #585 fresh-fetch elapsed time per
    // analyzer that actually ran this call (empty when refreshRunners opted
    // out of runner state entirely).
    const resultWithCold = {
        ...result,
        details: {
            ...result.details,
            coldRunners: extracted.cold,
            analyzerTimingsMs: extracted.timings,
            analyzersAborted: extracted.aborted ?? false,
            analyzersAbortedIds: extracted.abortedIds ?? [],
            // #630: confirmed/unconfirmed LSP-sweep tally, mirroring
            // `lsp_diagnostics`' confirmation state — lets a caller check "were
            // any files unconfirmed" without re-deriving it from the text.
            lspFilesConfirmed: confirmedLspResults.length,
            lspFilesUnconfirmed: unconfirmedLspResults.length,
            unconfirmedLspFiles: unconfirmedLspResults.map((result) => result.filePath),
            // #646: per-primary-server breakdown of the same tally, plus the
            // primary/auxiliary findings split — mirrors `lsp_diagnostics`'
            // per-server reporting granularity (always present, even when every
            // server confirmed cleanly, so a caller can check it unconditionally).
            lspServerBreakdown: Object.fromEntries(lspServerBreakdown),
            lspPrimaryDiagnosticsCount: lspPrimaryVsAuxiliary.primary,
            lspAuxiliaryDiagnosticsCount: lspPrimaryVsAuxiliary.auxiliary,
        },
    };
    // Stopped mid-scan: the results above are whatever completed before the abort.
    // Tell the agent so it doesn't read a partial sweep as "clean" (#341). The
    // abort is either a user/turn cancel (Escape) or the wall-clock ceiling firing
    // (AbortSignal.timeout → TimeoutError), which guarantees the scan can't hang
    // indefinitely — distinguish them so the agent knows whether to just re-run.
    if (aborted) {
        const timedOut = signal
            ?.reason?.name === "TimeoutError";
        const note = timedOut
            ? `\n\n⚠ Scan exceeded its ${Math.round((options.wallClockMs ?? 0) / 1000)}s time budget and was stopped — results are partial. ` +
                "Narrow it with maxLspFiles, or raise PI_LENS_LENS_DIAGNOSTICS_FULL_TIMEOUT_MS."
            : "\n\n⚠ Scan cancelled before completion — results are partial. " +
                "Re-run with a smaller maxLspFiles to finish within budget.";
        return {
            content: [
                {
                    type: "text",
                    text: result.content[0].text +
                        note +
                        unconfirmedLspNote +
                        lspPrimaryVsAuxiliaryNote +
                        coldNote +
                        abortedNote +
                        freshNote +
                        missingNote,
                },
            ],
            details: { ...resultWithCold.details, timedOut },
        };
    }
    if (missingNote ||
        coldNote ||
        abortedNote ||
        freshNote ||
        unconfirmedLspNote ||
        lspPrimaryVsAuxiliaryNote) {
        return {
            content: [
                {
                    type: "text",
                    text: result.content[0].text +
                        unconfirmedLspNote +
                        lspPrimaryVsAuxiliaryNote +
                        coldNote +
                        abortedNote +
                        freshNote +
                        missingNote,
                },
            ],
            details: resultWithCold.details,
        };
    }
    return resultWithCold;
}
function formatAllMode(cwd, severity, summaries = getFileDiagnosticSummaries(), detailOverrides = { mode: "all" }, staleDropped = 0, pathsScope) {
    // Files changed/deleted since their diagnostics were recorded have already
    // been dropped by reconcileStaleWidgetFiles; note them so the agent knows
    // those aren't "clean", just un-rescanned (use mode=full to refresh).
    const staleNote = staleDropped > 0
        ? ` (${staleDropped} changed file${staleDropped === 1 ? "" : "s"} omitted as stale — use mode=full to rescan)`
        : "";
    const ignoreFile = createCurrentIgnoreFilter(cwd);
    const includeFile = (filePath) => ignoreFile(filePath) && (!pathsScope || pathsScope.includeFile(filePath));
    const visibleSummaries = summaries.filter((s) => includeFile(s.filePath));
    // Filter to files with actual issues
    const withIssues = visibleSummaries.filter((s) => {
        if (severity === "error")
            return s.blocking > 0 || s.errors > 0;
        if (severity === "warning")
            return s.warnings > 0;
        return s.blocking > 0 || s.errors > 0 || s.warnings > 0;
    });
    // mode=full already actively scanned exactly the requested paths, so a zero
    // result there IS a legitimate clean read — the cache-only note only applies
    // to delta/all, which is why this is gated on detailOverrides.mode !== "full".
    const isFullMode = detailOverrides.mode === "full";
    const pathsNote = isFullMode ? "" : pathsScopeCacheOnlyNote(pathsScope);
    if (withIssues.length === 0) {
        const text = (visibleSummaries.length === 0
            ? "No files diagnosed yet this session."
            : `No ${severity === "all" ? "" : severity + " "}issues across ${visibleSummaries.length} file${visibleSummaries.length === 1 ? "" : "s"} diagnosed this session. ✓`) +
            staleNote +
            pathsNote;
        return {
            content: [{ type: "text", text }],
            details: {
                ...detailOverrides,
                filesChecked: visibleSummaries.length,
                staleDropped,
            },
        };
    }
    // Sort: blocking first, then errors, then warnings
    const sorted = withIssues.sort((a, b) => b.blocking - a.blocking || b.errors - a.errors || b.warnings - a.warnings);
    const lines = [];
    let totalBlocking = 0;
    let totalErrors = 0;
    let totalWarnings = 0;
    for (const s of sorted) {
        const rel = path.relative(cwd, s.filePath);
        const parts = [];
        if (s.blocking > 0)
            parts.push(`🔴 ${s.blocking} blocking`);
        if (s.errors > 0 && s.blocking === 0)
            parts.push(`${s.errors}E`);
        if (s.warnings > 0)
            parts.push(`${s.warnings}W`);
        if (!s.hasFinalSnapshot)
            parts.push(`(pending)`);
        lines.push(`${rel}  ${parts.join("  ")}`);
        // List the actual diagnostics (not just counts) so the agent can act on
        // them without re-running anything — same "L<line>: <message>" shape as the
        // inline blocker output. The widget state now exposes the FULL set (not the
        // TUI's 12-cap); the tool applies its own generous per-file budget purely to
        // avoid flooding context on a pathologically broken file.
        const matching = (s.diagnostics ?? [])
            .filter((d) => matchesSeverity(d, severity))
            .sort(bySeverityThenLine);
        const shown = matching.slice(0, MAX_DIAGNOSTICS_PER_FILE);
        for (const d of shown) {
            const marker = isErrorLike(d)
                ? d.semantic === "blocking"
                    ? "🔴 "
                    : ""
                : "";
            const label = d.rule ?? d.tool;
            const tag = label ? ` [${label}]` : "";
            const msg = d.message.replace(/\s+/g, " ").trim();
            lines.push(`  ${marker}L${d.line ?? "?"}: ${msg}${tag}`);
        }
        if (matching.length > shown.length) {
            lines.push(`  … ${matching.length - shown.length} more in this file (showing ${shown.length} of ${matching.length})`);
        }
        totalBlocking += s.blocking;
        totalErrors += s.errors;
        totalWarnings += s.warnings;
    }
    const summary = [
        `\nSummary (${visibleSummaries.length} files diagnosed this session):`,
        totalBlocking > 0
            ? `  🔴 ${totalBlocking} blocking error${totalBlocking === 1 ? "" : "s"}`
            : null,
        totalErrors > 0
            ? `  ${totalErrors} error${totalErrors === 1 ? "" : "s"}`
            : null,
        totalWarnings > 0
            ? `  ${totalWarnings} warning${totalWarnings === 1 ? "" : "s"}`
            : null,
    ]
        .filter(Boolean)
        .join("\n");
    return {
        content: [
            { type: "text", text: lines.join("\n") + summary + staleNote },
        ],
        details: {
            ...detailOverrides,
            filesWithIssues: withIssues.length,
            totalBlocking,
            totalErrors,
            totalWarnings,
            staleDropped,
        },
    };
}