pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
1,111 lines • 51.4 kB
JavaScript
/**
* lsp_diagnostics tool definition
*
* Proactive LSP diagnostics check — single files or directories.
* Adopted from code-yeongyu/pi-lsp-client design.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { Type } from "../clients/deps/typebox.js";
import { getProjectIgnoreMatcher, isExcludedDirName, } from "../clients/file-utils.js";
import { getLSPService, groupFilesByPrimaryServer, runPerServerGroups, } from "../clients/lsp/index.js";
import { primaryServerId } from "../clients/lsp/config.js";
import { combineAbortSignals } from "../clients/deadline-utils.js";
import { applyAuxiliarySuppressions } from "../clients/dispatch/auxiliary-lsp.js";
import { classifyCascadeWaitTier } from "../clients/lsp/cascade-tier.js";
import { convertLspDiagnostics } from "../clients/dispatch/utils/lsp-diagnostics.js";
import { reconcileScanDiagnostics } from "../clients/widget-state.js";
import { baseName, compactRenderResult } from "./render-compact.js";
import { makeProgressReporter, scanningSummaryLine } from "./scan-progress.js";
const LANG_EXTENSIONS = {
".ts": [".ts", ".tsx", ".mts", ".cts"],
".tsx": [".ts", ".tsx", ".mts", ".cts"],
".js": [".js", ".jsx", ".mjs", ".cjs"],
".py": [".py", ".pyi"],
".rs": [".rs"],
".go": [".go"],
".rb": [".rb", ".rake", ".gemspec"],
".java": [".java"],
".kt": [".kt", ".kts"],
".swift": [".swift"],
".cs": [".cs"],
".cpp": [".cpp", ".cc", ".cxx", ".hpp", ".hxx"],
".c": [".c", ".h"],
".zig": [".zig", ".zon"],
".hs": [".hs", ".lhs"],
".ex": [".ex", ".exs"],
".gleam": [".gleam"],
".tf": [".tf", ".tfvars"],
".nix": [".nix"],
".sh": [".sh", ".bash", ".zsh"],
".php": [".php"],
".lua": [".lua"],
".dart": [".dart"],
".vue": [".vue"],
".svelte": [".svelte"],
".css": [".css", ".scss", ".less"],
".html": [".html", ".htm"],
".json": [".json", ".jsonc"],
".yaml": [".yaml", ".yml"],
".toml": [".toml"],
".prisma": [".prisma"],
};
const MAX_FILES = 100;
const MAX_BATCH_FILES = 100;
const MAX_DIAGNOSTICS = 200;
const DEFAULT_BATCH_CONCURRENCY = 8;
const MAX_BATCH_CONCURRENCY = 16;
// LSP severities: 1=Error, 2=Warning, 3=Information, 4=Hint
const SEVERITY_NAMES = {
1: "error",
2: "warning",
3: "information",
4: "hint",
};
// #646: `primaryServerId` moved to clients/lsp/config.ts so this tool and
// tools/lens-diagnostics.ts's mode=full sweep share the exact same
// primary-vs-auxiliary classification instead of each keeping its own copy.
function lspUnavailableMessage(filePath, health) {
if (!health || !String(health.health ?? "").startsWith("no_clients")) {
return undefined;
}
const candidates = health.candidateServerIds?.length
? ` candidates=${health.candidateServerIds.join(",")}`
: "";
const reason = (health.serverCountAttempted ?? 0) === 0
? "no LSP server configured"
: "no LSP client is currently ready";
const stale = (health.mergedCount ?? 0) > 0
? " Showing stale last-known diagnostics below."
: " No diagnostics were collected.";
return `LSP unavailable for ${filePath}: ${reason}; ready=${health.serverCountReady ?? 0}/${health.serverCountAttempted ?? 0}.${candidates}.${stale}`;
}
function boundedPositiveInt(value, fallback, min, max) {
const parsed = typeof value === "number" ? Math.floor(value) : Number.NaN;
if (!Number.isFinite(parsed))
return fallback;
return Math.max(min, Math.min(max, parsed));
}
/**
* #631: fan `mapper` out across `items` (a batch/directory file list) while
* respecting per-LSP-server affinity — previously this was a flat,
* server-oblivious bounded-concurrency pool (up to `concurrency` files
* in flight at once, regardless of which server they belonged to). That let
* a single-language batch (the common case) fire many concurrent touches at
* the SAME shared, single-threaded LSP server — exactly the pattern #387
* found doesn't parallelize (it queues server-side and cascades per-file
* timeouts by queue position) and that `runWorkspaceDiagnostics` (the engine
* behind `lens_diagnostics mode=full`) has been protected against since #387.
*
* Groups `items` by primary server via `groupFilesByPrimaryServer` (the same
* grouping key `runWorkspaceDiagnostics` uses) and schedules them with
* `runPerServerGroups` (both extracted from `clients/lsp/index.ts` so this
* tool shares the real primitive instead of a second hand-copied
* implementation): at most one in-flight `mapper` call per server group,
* parallelized across distinct groups up to `concurrency`. A single-language
* batch collapses to one group and runs effectively serially regardless of
* `concurrency` — the CORRECT, intended #387 behavior, not a regression.
*
* Result order matches `items`' original order (not completion order),
* matching the old flat pool's positional-assignment behavior — callers may
* depend on `results[i]` corresponding to `items[i]`.
*
* #667: before a group's own per-file loop starts, calls the shared
* `LSPService.ensureWarmForSweep` warm-check/ensure-warm step (the same one
* `runWorkspaceDiagnostics` uses for `lens_diagnostics mode=full`) against
* the group's first file — a no-op when that group's primary server already
* demonstrated readiness earlier this session, one bounded warm-up round
* trip otherwise. Fixes the first-few-files-eat-cold-start-timeouts pattern
* for THIS tool's batch/directory sweep the same way #667 fixed it for the
* workspace-diagnostics sweep.
*/
async function mapWithConcurrency(items, concurrency, mapper, lspService, signal, onProgress) {
const results = [];
let completed = 0;
// Multiple original indices can map to the same file path (duplicate
// entries in an explicit `paths` batch) — track them as a per-file queue
// so each occurrence still lands in its own original slot.
const pendingIndices = new Map();
items.forEach((item, index) => {
const queue = pendingIndices.get(item);
if (queue)
queue.push(index);
else
pendingIndices.set(item, [index]);
});
const groups = groupFilesByPrimaryServer(items);
await runPerServerGroups(groups, concurrency, async (group) => {
if (signal?.aborted)
return;
const first = group.files[0];
if (first &&
lspService &&
typeof lspService.ensureWarmForSweep === "function") {
await lspService.ensureWarmForSweep(first, { signal });
if (signal?.aborted)
return;
}
for (const item of group.files) {
// Honor cancellation (Escape / turn abort): stop pulling new items
// rather than grind the whole batch. Completed entries are returned.
if (signal?.aborted)
return;
const index = pendingIndices.get(item).shift();
results[index] = await mapper(item, index);
completed += 1;
onProgress?.(completed, items.length);
}
}, signal);
return results;
}
/**
* Project-ignore predicate rooted at `root`, fail-open. Lets a directory scan
* honor the user's `.pi-lens.json` / `.gitignore` patterns — not just the
* canonical dir-name list — so `lsp_diagnostics` stays consistent with the
* workspace-diagnostics walk and every other scan surface (#243/#297/#298). A
* config-probe error never blocks a scan (matches the walkers' behaviour).
*/
function projectIgnorePredicate(root) {
try {
const matcher = getProjectIgnoreMatcher(root);
return (fullPath, isDir) => matcher.isIgnored(fullPath, isDir);
}
catch {
return () => false;
}
}
function collectFiles(dir, extensions, maxFiles, isIgnored = () => false) {
const files = [];
function walk(current) {
if (files.length >= maxFiles)
return;
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
if (files.length >= maxFiles)
return;
if (entry.isSymbolicLink())
continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (!isExcludedDirName(entry.name) && !isIgnored(full, true))
walk(full);
}
else if (entry.isFile() && extensions.includes(path.extname(full))) {
if (isIgnored(full, false))
continue;
files.push(full);
}
}
}
walk(dir);
return files;
}
export function createLspDiagnosticsTool(
// #571: same shared write-ordering token source `lens_diagnostics` mode=full
// uses (index.ts injects `() => runtime.nextWriteIndex()`) — a confirmed
// fresh result this tool reconciles into the footer draws a fresh token so
// `WriteOrderingGuard` can tell it apart from a concurrent, genuinely newer
// per-edit write for the same file. Optional/undefined in tests.
nextWriteIndex) {
return {
name: "lsp_diagnostics",
label: "LSP Diagnostics",
description: "Get errors, warnings, and hints from language servers for a file or directory. " +
"Use BEFORE running builds to proactively check for issues. " +
"Works on directories by auto-detecting file extensions and scanning all matching files.",
promptSnippet: "Get LSP diagnostics for a file or directory (use before builds)",
renderResult: compactRenderResult(({ details, args, isError, text }) => {
// Streaming progress partials render the live bar (see scanningSummaryLine).
const scanning = scanningSummaryLine(details, text);
if (scanning)
return scanning;
if (isError) {
return `lsp_diagnostics — ${text.split("\n")[0] ?? "error"}`;
}
const count = details?.totalDiagnostics ?? details?.diagnostics?.length ?? 0;
const target = baseName(details?.filePath ?? args.path) || "workspace";
const files = details?.filesChecked ?? details?.filesScanned;
const scope = typeof files === "number" && files > 1
? ` across ${files} files`
: target
? ` ${target}`
: "";
const noun = count === 1 ? "diagnostic" : "diagnostics";
// #533: a batch/directory result with any unconfirmed files must NEVER
// compact-render as a bare "N diagnostics" — that erases the fact some
// files' clean status was never actually confirmed by the server.
const unconfirmedFiles = details?.unconfirmedFiles ?? 0;
if (unconfirmedFiles > 0) {
const cleanFiles = details?.cleanFiles ?? 0;
const timedOutFiles = details?.timedOutFiles ?? 0;
const suffix = timedOutFiles > 0 ? ` (${timedOutFiles} timed out)` : "";
return `lsp_diagnostics${scope} — ${count} ${noun} · ${cleanFiles} clean · ${unconfirmedFiles} unconfirmed${suffix}`;
}
// Single-file mode: 0 diagnostics from an unconfirmed result — either a
// silent-on-clean server or (#570) a timed-out check — is not a clean
// render either.
if (count === 0 && details?.unconfirmed) {
return details?.timedOut
? `lsp_diagnostics${scope} — timed out (result may be incomplete)`
: `lsp_diagnostics${scope} — unconfirmed (server cannot confirm clean)`;
}
return `lsp_diagnostics${scope} — ${count} ${noun}`;
}),
parameters: Type.Object({
path: Type.Optional(Type.String({
description: "File or directory path to check. For directories, all matching source files are scanned.",
})),
paths: Type.Optional(Type.Array(Type.String(), {
minItems: 1,
maxItems: MAX_BATCH_FILES,
description: "Explicit files to check as a bounded-concurrency batch. When provided, path is ignored.",
})),
severity: Type.Optional(Type.String({
enum: ["error", "warning", "information", "hint", "all"],
description: "Filter by severity level (default: all)",
})),
concurrency: Type.Optional(Type.Number({
description: "Batch/directory concurrency, in distinct LSP server groups run in parallel " +
"(default 8, max 16) — not individual files. Files sharing one server " +
"(e.g. a same-language batch) are always processed one at a time against " +
"that server regardless of this value; this caps how many DIFFERENT " +
"servers run concurrently.",
})),
waitMs: Type.Optional(Type.Number({
description: "Optional per-file LSP wait budget for batch diagnostics. Uses server defaults when omitted.",
})),
serverScope: Type.Optional(Type.String({
enum: ["primary", "all"],
description: "'primary' (fast, low-noise): only the file's actual language " +
"server (e.g. typescript) — for 'does this have real type " +
"errors'. 'all' (default): also touches cross-cutting auxiliary " +
"scanners (ast-grep, opengrep, zizmor, typos, marksman) attached " +
"to this file, including findings for files not yet dispatched " +
"this session. Primary confirmation is always reported " +
"separately from auxiliary findings regardless of this setting.",
})),
}),
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
// Escape aborts the turn via ctx.signal; honor both it and the tool-call
// signal so a batch/directory scan cancels rather than grinding on.
const signal = combineAbortSignals(_signal, ctx.signal);
// Stream a throttled progress bar for batch/directory scans (opaque for
// seconds-to-minutes otherwise).
const onProgress = makeProgressReporter(onUpdate, "Scanning LSP diagnostics");
const typedParams = params;
const severity = (typedParams.severity ?? "all");
const cwd = ctx.cwd ?? process.cwd();
const concurrency = boundedPositiveInt(typedParams.concurrency, DEFAULT_BATCH_CONCURRENCY, 1, MAX_BATCH_CONCURRENCY);
const waitMs = typeof typedParams.waitMs === "number" && typedParams.waitMs >= 0
? Math.floor(typedParams.waitMs)
: undefined;
const serverScope = typedParams.serverScope === "primary" ? "primary" : "all";
const lspService = getLSPService();
if (!lspService) {
return {
content: [
{ type: "text", text: "LSP service not available." },
],
isError: true,
details: {},
};
}
if (Array.isArray(typedParams.paths) &&
typedParams.paths.length > 0) {
const absPaths = typedParams.paths
.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
.slice(0, MAX_BATCH_FILES)
.map((entry) => path.isAbsolute(entry) ? entry : path.resolve(cwd, entry));
return runBatchFileDiagnostics(absPaths, severity, lspService, {
concurrency,
waitMs,
signal,
onProgress,
nextWriteIndex,
serverScope,
});
}
const rawPath = typedParams.path;
if (!rawPath || rawPath.trim().length === 0) {
return {
content: [
{
type: "text",
text: "path or paths is required.",
},
],
isError: true,
details: {},
};
}
const absPath = path.isAbsolute(rawPath)
? rawPath
: path.resolve(cwd, rawPath);
let stat;
try {
stat = fs.statSync(absPath);
}
catch {
return {
content: [
{ type: "text", text: `Path not found: ${absPath}` },
],
isError: true,
details: {},
};
}
if (stat.isDirectory()) {
return runDirectoryDiagnostics(absPath, severity, lspService, {
concurrency,
waitMs,
signal,
onProgress,
nextWriteIndex,
serverScope,
});
}
return runFileDiagnostics(absPath, severity, lspService, waitMs, nextWriteIndex, serverScope);
},
};
}
async function collectDiagnosticsForFile(absPath, lspService, waitMs, serverScope = "all") {
let timedOut = false;
let content;
// #629: `touched` (when defined) is ALREADY the correctly-scoped,
// already-collected diagnostics array for this touch — `touchFile` below
// is called with `collectDiagnostics: true` and `clientScope: serverScope`,
// so its return value only contains diagnostics from the servers
// `serverScope` asked for. Previously this function discarded `touched`
// (reading only `.inconclusive` off it) and made a SECOND, unconditionally
// -unscoped `getDiagnostics()` call for the actual content — meaning every
// touchFile-branch call paid for two LSP round trips instead of one, and
// `serverScope: "primary"` never actually skipped the auxiliary scanners
// (getDiagnostics always queries every registered server for the file).
// `touched` is only undefined when touchFile itself couldn't produce a
// result (service destroyed, no clients resolved) — that's the one case
// that still needs the getDiagnostics() fallback below.
let touched;
let usedTouch = false;
try {
content = fs.readFileSync(absPath, "utf-8");
const serviceWithTouch = lspService;
if ((waitMs !== undefined || serverScope === "primary") &&
typeof serviceWithTouch.touchFile === "function") {
usedTouch = true;
touched = await serviceWithTouch.touchFile(absPath, content, {
diagnostics: "document",
collectDiagnostics: true,
maxClientWaitMs: waitMs,
source: "lsp_diagnostics",
clientScope: serverScope,
});
timedOut = touched?.inconclusive === true;
}
else {
await lspService.openFile(absPath, content, {
preserveDiagnostics: false,
});
}
}
catch {
// Non-fatal: getDiagnostics may still have stale/health information.
}
// Only fall through to the unscoped getDiagnostics() read when the touch
// branch wasn't taken (openFile-only path, which never collected anything
// and genuinely needs the follow-up call) or couldn't resolve any clients
// at all (touched stays undefined despite usedTouch). When touched IS
// defined it's already the answer — reusing it is what makes
// serverScope:"primary" actually skip auxiliary scanners and drops the
// common case back to a single LSP round trip instead of two.
const diagnostics = usedTouch && touched !== undefined
? touched
: await lspService.getDiagnostics(absPath, waitMs !== undefined ? "document" : "full");
// #586: honor each auxiliary profile's native inline-suppression comment
// (e.g. opengrep's `// nosemgrep`, #441) the same way the per-edit dispatch
// runner does — previously this standalone query path ignored it entirely.
// `content` is only unset if the read itself failed above; fail-open (no
// filtering) rather than lose diagnostics over an unrelated read error.
const filtered = content !== undefined
? applyAuxiliarySuppressions(diagnostics, content)
: diagnostics;
return { diagnostics: filtered, timedOut };
}
function diagnosticsToFileDiags(file, diagnostics) {
return diagnostics.map((d) => ({
file,
line: d.range?.start?.line,
character: d.range?.start?.character,
severity: d.severity,
message: d.message,
source: d.source,
code: d.code,
}));
}
/**
* #533: classify an EMPTY diagnostic result as "clean" (the server actually
* confirmed no issues) or "unconfirmed" (came from a push-only,
* silent-on-clean server — classic typescript-language-server — that
* publishes nothing on a clean→clean transition, so an empty result here is
* indistinguishable from "still analyzing" or "never asked"). Reuses the same
* capability-snapshot classifier the #458 cascade lane already trusts
* (`classifyCascadeWaitTier`) so this tool's notion of "silent tier-3" stays
* in lockstep with the rest of the LSP layer instead of drifting via a second
* copy of the server-strategy table. Fail-safe: any error or missing snapshot
* (server not alive, capability probe failure) reads as "clean" — the same
* default this tool has always had — rather than manufacturing a new failure
* mode from a best-effort classification.
*/
async function classifyEmptyResult(file, lspService) {
try {
const snapshots = await lspService.getCapabilitySnapshots(file);
const tier = classifyCascadeWaitTier(lspService, file, snapshots);
return tier === "tier3-silent" ? "unconfirmed" : "clean";
}
catch {
return "clean";
}
}
// --- #611: tier-3 silent escape hatch (typescript.tsserverRequest sync commands) ---
const TSSERVER_REQUEST_COMMAND = "typescript.tsserverRequest";
function isTsserverSyncRawDiagnostic(value) {
if (!value || typeof value !== "object")
return false;
const v = value;
return typeof v.message === "string" && typeof v.category === "string";
}
function tsserverSeverityFromCategory(category) {
switch (category) {
case "error":
return 1;
case "warning":
return 2;
case "suggestion":
return 4; // Hint
default:
return 3; // "message" or unrecognized -> Info
}
}
/**
* Convert a tsserver-protocol sync diagnostic into pi-lens's LSP-shaped
* `LSPDiagnostic`. Empirically verified live (2026-07,
* typescript-language-server 5.9.3, this repo's own tsconfig.json as the
* fixture project): `workspace/executeCommand` with
* `{command:"typescript.tsserverRequest",
* arguments:["semanticDiagnosticsSync"|"syntacticDiagnosticsSync",
* {file, includeLinePosition:true}]}` resolves
* `{executed:true, result:{seq,type:"response",command,request_seq,success,
* body:[...]}}`, where each `body` entry is tsserver's NATIVE protocol
* diagnostic shape — `message`, `category` ("error"|"warning"|"suggestion"),
* `code`, `startLocation`/`endLocation` as `{line, offset}` — NOT the LSP
* `Diagnostic` shape, and both `line`/`offset` are 1-based (LSP is 0-based).
*/
function tsserverSyncDiagnosticToLsp(d) {
const startLine = Math.max(0, (d.startLocation?.line ?? 1) - 1);
const startChar = Math.max(0, (d.startLocation?.offset ?? 1) - 1);
const endLine = Math.max(0, (d.endLocation?.line ?? d.startLocation?.line ?? 1) - 1);
const endChar = Math.max(0, (d.endLocation?.offset ?? d.startLocation?.offset ?? 1) - 1);
return {
severity: tsserverSeverityFromCategory(d.category),
message: d.message,
range: {
start: { line: startLine, character: startChar },
end: { line: endLine, character: endChar },
},
code: d.code,
source: "typescript",
};
}
async function runTsserverSyncCommand(lspService, file, command) {
const svc = lspService;
if (typeof svc.executeCommand !== "function")
return undefined;
const outcome = await svc.executeCommand(file, TSSERVER_REQUEST_COMMAND, [
command,
{ file, includeLinePosition: true },
]);
if (!outcome.executed)
return undefined;
const result = outcome.result;
if (!result || result.success !== true || !Array.isArray(result.body)) {
return undefined;
}
return result.body.filter(isTsserverSyncRawDiagnostic);
}
/**
* #611: attempt classic typescript-language-server's `typescript.tsserverRequest`
* escape hatch — a genuine synchronous request/response tsserver command, not
* push/timing-dependent — to get a definitive answer for a Tier-3 silent
* server's empty push-based result. Runs BOTH `semanticDiagnosticsSync` and
* `syntacticDiagnosticsSync` (mirroring what the server itself publishes on a
* dirty file) so a syntax-only error isn't missed.
*
* Returns `undefined` (never throws, never hangs beyond the existing
* `executeCommand` anti-deadlock backstop) when: the command isn't advertised
* by this server (older/different server/config), `executeCommand` throws
* (live-verified case: tsserver rejects with a `ResponseError` — "No
* Project." — for a file outside any tsconfig project) or times out, or the
* response shape isn't the expected `{success:true, body:[...]}` envelope.
* Every one of these must fall through to the existing "unconfirmed"
* behavior in the caller.
*/
async function attemptTsserverSyncDiagnostics(file, lspService) {
try {
const svc = lspService;
if (typeof svc.getAdvertisedCommands !== "function")
return undefined;
const advertised = await svc.getAdvertisedCommands(file);
if (!advertised.includes(TSSERVER_REQUEST_COMMAND))
return undefined;
const [semantic, syntactic] = await Promise.all([
runTsserverSyncCommand(lspService, file, "semanticDiagnosticsSync"),
runTsserverSyncCommand(lspService, file, "syntacticDiagnosticsSync"),
]);
if (semantic === undefined || syntactic === undefined)
return undefined;
return [...syntactic, ...semantic].map(tsserverSyncDiagnosticToLsp);
}
catch {
return undefined;
}
}
/**
* #611: resolve an EMPTY diagnostic result for a Tier-3 silent server (see
* `classifyCascadeWaitTier` — today only classic typescript-language-server,
* native-ts7 is explicitly excluded there) with a definitive answer instead of
* defaulting straight to "unconfirmed". `confirmed: true` with an empty
* `diagnostics` array is a genuinely confirmed clean result; `confirmed: true`
* with a non-empty array means the sync command surfaced real diagnostics the
* server had computed but never published (silentOnClean) — these must be
* surfaced to the caller, not discarded. `confirmed: false` is the existing
* "unconfirmed" fallback (command unavailable, error, or the file isn't part
* of any project). Fail-safe: any error in the tier classification itself
* (missing snapshot, server not alive) reads as `confirmed: true` with no
* diagnostics — the same "clean" default this tool has always had.
*/
async function resolveEmptyResult(file, lspService) {
try {
const snapshots = await lspService.getCapabilitySnapshots(file);
const tier = classifyCascadeWaitTier(lspService, file, snapshots);
if (tier !== "tier3-silent") {
return { confirmed: true, diagnostics: [] };
}
const syncDiagnostics = await attemptTsserverSyncDiagnostics(file, lspService);
if (syncDiagnostics === undefined) {
return { confirmed: false, diagnostics: [] };
}
return { confirmed: true, diagnostics: syncDiagnostics };
}
catch {
return { confirmed: true, diagnostics: [] };
}
}
/**
* #571: reconcile this tool's fresh LSP result into the footer cache
* (`widget-state.ts`'s `allDiagnostics`) — same shared choke point
* `lens_diagnostics` mode=full uses (`clients/widget-state.ts`'s
* `reconcileScanDiagnostics`). A manual `lsp_diagnostics` check that proves a
* stale footer error is actually gone (the real-world case that surfaced
* #571) is exactly the kind of confirmed result that should correct it.
*
* `rawDiags` (pre-severity-filter) is what gets written — the footer records
* the true known state, independent of this call's display-only severity
* filter. A non-empty result is definitionally confirmed (the server DID
* answer with real diagnostics); an empty result is only confirmed when
* `classifyEmptyResult` (#533) says "clean", not "unconfirmed" (silent
* push-only server — indistinguishable from still-analyzing/never-asked, so
* must not overwrite a real prior footer entry).
*/
function reconcileWidgetFromLspResult(file, rawDiags, confirmation, nextWriteIndex) {
const confirmed = rawDiags.length > 0 || confirmation !== "unconfirmed";
if (!confirmed)
return;
try {
reconcileScanDiagnostics(file, convertLspDiagnostics(rawDiags, file, { source: "lsp_diagnostics" }), true, nextWriteIndex?.());
}
catch {
// Never let a footer-reconciliation hiccup fail the diagnostics check.
}
}
async function collectFileDiagnosticResult(file, severity, lspService, waitMs, nextWriteIndex, serverScope = "all") {
try {
const stat = fs.statSync(file);
if (!stat.isFile()) {
return { file, diagnostics: [], error: `${file}: not a file` };
}
}
catch {
return { file, diagnostics: [], error: `${file}: path not found` };
}
const { diagnostics: rawDiags, timedOut } = await collectDiagnosticsForFile(file, lspService, waitMs, serverScope);
const health = lspService.getDiagnosticsHealth?.(file);
// #570: a timed-out priming check is never a confirmed "clean" — treat it
// as unconfirmed without consulting the (unrelated) silent-tier
// classifier, and remember why so the rendered text is accurate.
// #611: a genuinely empty (not just severity-filtered-away) push-based
// result gets a shot at the tier-3 sync escape hatch before "unconfirmed"
// — it may surface real diagnostics the server never published, which must
// be merged in rather than discarded.
let effectiveRawDiags = rawDiags;
let confirmation;
if (timedOut) {
if (applySeverityFilter(rawDiags, severity).length === 0) {
confirmation = "unconfirmed";
}
}
else if (rawDiags.length === 0) {
const resolved = await resolveEmptyResult(file, lspService);
effectiveRawDiags = resolved.diagnostics;
confirmation = resolved.confirmed
? resolved.diagnostics.length === 0
? "clean"
: undefined
: "unconfirmed";
}
else if (applySeverityFilter(rawDiags, severity).length === 0) {
confirmation = await classifyEmptyResult(file, lspService);
}
const filteredDiags = applySeverityFilter(effectiveRawDiags, severity);
reconcileWidgetFromLspResult(file, effectiveRawDiags, confirmation, nextWriteIndex);
return {
file,
diagnostics: diagnosticsToFileDiags(file, filteredDiags),
unavailable: lspUnavailableMessage(file, health),
confirmation,
timedOut: confirmation === "unconfirmed" ? timedOut : undefined,
primaryServerId: primaryServerId(file),
};
}
async function runFileDiagnostics(absPath, severity, lspService, waitMs, nextWriteIndex, serverScope = "all") {
const { diagnostics: rawDiags, timedOut } = await collectDiagnosticsForFile(absPath, lspService, waitMs, serverScope);
const lspHealth = lspService.getDiagnosticsHealth?.(absPath);
const unavailable = lspUnavailableMessage(absPath, lspHealth);
// #533: an empty result needs a confirmed/unconfirmed verdict — a push-only,
// silent-on-clean server (classic typescript) publishes nothing on a
// clean→clean edit, so "0 diagnostics" from it is unverifiable, not clean.
// #570: a timed-out priming check is a second, distinct reason a result
// can be unconfirmed — checked first since it's a property of THIS check,
// not a general server-capability classification.
// #611: a genuinely empty (not just severity-filtered-away) result gets a
// shot at the tier-3 sync escape hatch before "unconfirmed" — real
// diagnostics it surfaces are merged in, not discarded.
let effectiveRawDiags = rawDiags;
let confirmation;
if (timedOut) {
if (applySeverityFilter(rawDiags, severity).length === 0) {
confirmation = "unconfirmed";
}
}
else if (rawDiags.length === 0) {
const resolved = await resolveEmptyResult(absPath, lspService);
effectiveRawDiags = resolved.diagnostics;
confirmation = resolved.confirmed
? resolved.diagnostics.length === 0
? "clean"
: undefined
: "unconfirmed";
}
else if (applySeverityFilter(rawDiags, severity).length === 0) {
confirmation = await classifyEmptyResult(absPath, lspService);
}
const filtered = applySeverityFilter(effectiveRawDiags, severity);
const total = filtered.length;
const truncated = total > MAX_DIAGNOSTICS;
const limited = truncated ? filtered.slice(0, MAX_DIAGNOSTICS) : filtered;
const unconfirmed = confirmation === "unconfirmed";
reconcileWidgetFromLspResult(absPath, effectiveRawDiags, confirmation, nextWriteIndex);
const primaryId = primaryServerId(absPath);
const primaryDiags = limited.filter((d) => d.source === primaryId);
const auxiliaryDiags = limited.filter((d) => d.source !== primaryId);
// Primary confirmation is always its own line, independent of how many
// auxiliary findings exist — a wall of ast-grep/opengrep noise must never
// bury whether the actual language server confirmed the file clean.
const primaryLine = (() => {
if (timedOut) {
return ("Primary LSP: check timed out — NOT the same as 0 diagnostics; the " +
"file may still have errors that just hadn't been reported yet. " +
"Re-check after the server settles, or increase waitMs.");
}
if (unconfirmed) {
return (`Primary LSP${primaryId ? ` (${primaryId})` : ""}: unconfirmed — ` +
"cannot confirm clean (push-only, silent-on-clean, e.g. classic " +
"typescript-language-server never publishes on a clean re-check). " +
"NOT the same as 0 diagnostics; re-check after an edit, or use " +
"waitMs to wait longer.");
}
if (primaryDiags.length === 0) {
return `Primary LSP${primaryId ? ` (${primaryId})` : ""}: confirmed clean.`;
}
return `Primary LSP${primaryId ? ` (${primaryId})` : ""}: ${primaryDiags.length} diagnostic${primaryDiags.length === 1 ? "" : "s"}.`;
})();
let text;
if (total === 0) {
text = [primaryLine, "", unavailable ?? "No auxiliary findings."].join("\n");
}
else {
const lines = [primaryLine, ""];
if (primaryDiags.length > 0) {
lines.push(...primaryDiags.map(formatDiag), "");
}
if (auxiliaryDiags.length > 0) {
lines.push(`Auxiliary findings (${auxiliaryDiags.length}):`);
lines.push(...auxiliaryDiags.map(formatDiag));
}
if (unavailable)
lines.unshift(unavailable, "");
if (truncated) {
lines.unshift(`Found ${total} diagnostics (showing first ${MAX_DIAGNOSTICS}):`);
}
text = lines.join("\n");
}
return {
content: [{ type: "text", text }],
details: {
filePath: absPath,
mode: "file",
severity,
serverScope,
primaryServerId: primaryId,
primaryDiagnosticsCount: primaryDiags.length,
auxiliaryDiagnosticsCount: auxiliaryDiags.length,
diagnostics: limited.map((d) => ({
line: d.range?.start?.line,
character: d.range?.start?.character,
severity: d.severity,
message: d.message,
source: d.source,
code: d.code,
})),
totalDiagnostics: total,
truncated,
unconfirmed,
timedOut: unconfirmed ? timedOut : undefined,
lspHealth,
waitMs,
},
};
}
/**
* #533: tally the per-file discriminated outcome across a batch/directory
* result set. `unconfirmed` files are those whose diagnostics collapsed to an
* empty array from a push-only, silent-on-clean server (see
* `classifyEmptyResult`) — they must never be folded into "clean" in the
* aggregate render, or a majority-unconfirmed result reads as a false "0
* diagnostics across N files".
*/
function tallyConfirmation(results) {
let clean = 0;
let unconfirmed = 0;
let timedOut = 0;
for (const result of results) {
if (result.diagnostics.length > 0)
continue;
if (result.confirmation === "unconfirmed") {
unconfirmed += 1;
// #570: timed-out checks are a subset of "unconfirmed" — tallied
// separately so the aggregate text can say WHY, not just THAT.
if (result.timedOut)
timedOut += 1;
}
else {
clean += 1;
}
}
return { clean, unconfirmed, timedOut };
}
/**
* #570: build the explanatory clause for a batch/directory result that has
* unconfirmed files, distinguishing timed-out checks from the pre-existing
* #533 silent-on-clean-server bucket — both are "unconfirmed" for counting,
* but the reason differs and misreporting a timeout as "server can't confirm
* clean" would itself be misleading.
*/
function unconfirmedReasonClause(unconfirmed, timedOut) {
const silent = unconfirmed - timedOut;
if (timedOut > 0 && silent > 0) {
return (`${timedOut} timed out (check didn't complete within budget) and ` +
`${silent} from a server that cannot confirm clean (push-only, ` +
"silent-on-clean).");
}
if (timedOut > 0) {
return `${timedOut} timed out (check didn't complete within the wait budget).`;
}
return ("from a server that cannot confirm clean (push-only, silent-on-clean; " +
"e.g. classic typescript-language-server does not publish on a clean " +
"re-check).");
}
/**
* Fan out `collectFileDiagnosticResult` across a file list at bounded
* concurrency and reduce the results into the shape both batch-style callers
* (`runBatchFileDiagnostics`/`runDirectoryDiagnostics`) render from —
* previously duplicated identically between them (SonarCloud
* `new_duplicated_lines_density` gate, surfaced when #571 added the
* `nextWriteIndex` threading to both call sites). Purely mechanical
* extraction: no behavior change, and does NOT touch the confirmed/
* unconfirmed semantics `collectFileDiagnosticResult`/`tallyConfirmation`
* already encode — those, and `lens_diagnostics` mode=full's separate,
* deliberately different confirmation gate in `tools/lens-diagnostics.ts`,
* are unrelated to this file's internal duplication and are left exactly
* as they were.
*/
async function collectBatchDiagnostics(files, severity, lspService, options) {
const results = await mapWithConcurrency(files, options.concurrency, (file) => collectFileDiagnosticResult(file, severity, lspService, options.waitMs, options.nextWriteIndex, options.serverScope), lspService, options.signal, options.onProgress);
const fileErrors = results.flatMap((result) => result.error ? [result.error] : []);
const lspHealthWarnings = results.flatMap((result) => result.unavailable ? [result.unavailable] : []);
const allDiags = results.flatMap((result) => result.diagnostics);
const total = allDiags.length;
const truncated = total > MAX_DIAGNOSTICS;
const display = truncated ? allDiags.slice(0, MAX_DIAGNOSTICS) : allDiags;
const { clean, unconfirmed, timedOut } = tallyConfirmation(results);
// Per-file primary-server lookup so a flattened multi-file `display` list
// can still be split into "primary findings" vs "auxiliary findings" —
// `clean`/`unconfirmed` above already reflect ONLY the primary server's
// confirmation; this split does the same job for the listed diagnostics.
const primaryIdByFile = new Map(results.map((r) => [r.file, r.primaryServerId]));
const primaryDisplay = display.filter((d) => d.source === primaryIdByFile.get(d.file));
const auxiliaryDisplay = display.filter((d) => d.source !== primaryIdByFile.get(d.file));
return {
results,
fileErrors,
lspHealthWarnings,
total,
truncated,
display,
primaryDisplay,
auxiliaryDisplay,
clean,
unconfirmed,
timedOut,
};
}
async function runBatchFileDiagnostics(absPaths, severity, lspService, options) {
if (absPaths.length === 0) {
return {
content: [{ type: "text", text: "No file paths provided." }],
isError: true,
details: { mode: "batch", severity, filesChecked: 0 },
};
}
const { results, fileErrors, lspHealthWarnings, total, truncated, display, primaryDisplay, auxiliaryDisplay, clean, unconfirmed, timedOut, } = await collectBatchDiagnostics(absPaths, severity, lspService, options);
const lines = [
`Files checked: ${results.length}`,
`Total diagnostics: ${total}`,
`Concurrency: ${options.concurrency}`,
];
if (options.waitMs !== undefined)
lines.push(`Wait budget: ${options.waitMs}ms`);
if (fileErrors.length > 0)
lines.push("", "File errors:", ...fileErrors);
if (lspHealthWarnings.length > 0) {
lines.push("", "LSP health warnings:", ...lspHealthWarnings.slice(0, 10));
}
// #533/#570: surface unconfirmed files regardless of whether OTHER files in
// the batch found real diagnostics — a mixed found/unconfirmed result must
// not let the unconfirmed files silently pass as clean just because the
// batch as a whole isn't "0 diagnostics". This tally is primary-server-only
// (see collectFileDiagnosticResult) — it's the batch-level equivalent of
// the single-file "Primary LSP: ..." line, always reported on its own.
if (unconfirmed > 0) {
lines.push("", `${clean} file${clean === 1 ? "" : "s"} confirmed clean, ${unconfirmed} unconfirmed: ` +
`${unconfirmedReasonClause(unconfirmed, timedOut)} NOT the same as 0 diagnostics.`);
}
if (display.length === 0) {
if (unconfirmed === 0) {
lines.push("", "No diagnostics found.");
}
}
else {
if (primaryDisplay.length > 0) {
lines.push("", `Primary findings (${primaryDisplay.length}):`);
lines.push(...primaryDisplay.map(formatDisplayDiag));
}
if (auxiliaryDisplay.length > 0) {
lines.push("", `Auxiliary findings (${auxiliaryDisplay.length}):`);
lines.push(...auxiliaryDisplay.map(formatDisplayDiag));
}
if (truncated) {
lines.push("", `... (${total - MAX_DIAGNOSTICS} more diagnostics not shown)`);
}
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
mode: "batch",
severity,
serverScope: options.serverScope ?? "all",
filesChecked: results.length,
concurrency: options.concurrency,
waitMs: options.waitMs,
diagnostics: display,
primaryDiagnosticsCount: primaryDisplay.length,
auxiliaryDiagnosticsCount: auxiliaryDisplay.length,
totalDiagnostics: total,
truncated,
cleanFiles: clean,
unconfirmedFiles: unconfirmed,
timedOutFiles: timedOut > 0 ? timedOut : undefined,
fileErrors: fileErrors.length > 0 ? fileErrors : undefined,
lspHealthWarnings: lspHealthWarnings.length > 0 ? lspHealthWarnings : undefined,
},
};
}
async function runDirectoryDiagnostics(absPath, severity, lspService, options) {
let extension;
let collectedFiles = [];
const isIgnored = projectIgnorePredicate(absPath);
for (const [ext, exts] of Object.entries(LANG_EXTENSIONS)) {
collectedFiles = collectFiles(absPath, exts, MAX_FILES + 1, isIgnored);
if (collectedFiles.length > 0) {
extension = ext;
break;
}
}
if (!extension || collectedFiles.length === 0) {
return {
content: [
{
type: "text",
text: `No supported source files found in: ${absPath}`,
},
],
details: {
filePath: absPath,
mode: "directory",
severity,
filesScanned: 0,
},
};
}
const wasCapped = collectedFiles.length > MAX_FILES;
const filesToProcess = collectedFiles.slice(0, MAX_FILES);
const { fileErrors, lspHealthWarnings, total, truncated, display, primaryDisplay, auxiliaryDisplay, clean, unconfirmed, timedOut, } = await collectBatchDiagnostics(filesToProcess, severity, lspService, options);
let text;
if (total === 0) {
// #533/#570: an unconfirmed-containing directory result must never
// render as a bare "no diagnostics" — that reads as an affirmative
// clean scan the server never actually gave for those files.
const cleanLine = unconfirmed > 0
? `${clean} clean · ${unconfirmed} unconfirmed: ` +
`${unconfirmedReasonClause(unconfirmed, timedOut)} NOT the same as 0 diagnostics.`
: "No diagnostics found.";
text = [
`Directory: ${absPath}`,
`Files scanned: ${filesToProcess.length}${wasCapped ? ` (capped at ${MAX_FILES})` : ""}`,
...(lspHealthWarnings.length > 0
? [
"LSP unavailable for one or more files:",
...lspHealthWarnings.slice(0, 10),
]
: [cleanLine]),
].join("\n");
}
else {
const lines = [
`Directory: ${absPath}`,
`Files scanned: ${filesToProcess.length}${wasCapped ? ` (capped at ${MAX_FILES})` : ""}`,
`Files with errors: ${new Set(display.map((d) => d.file)).size}`,
`Total diagnostics: ${total}`,
...(lspHealthWarnings.length > 0
? ["", "LSP health warnings:", ...lspHealthWarnings.slice(0, 10)]
: []),
// #533/#570: the remaining clean-looking files in a mixed scan may
// still be unconfirmed — say so even though the directory as a
// whole found diagnostics elsewhere.
...(unconfirmed > 0
? [
"",
`${clean} other file${clean === 1 ? "" : "s"} confirmed clean, ${unconfirmed} unconfirmed: ` +
unconfirmedReasonClause(unconfirmed, timedOut),
]
: []),
"",
];
const toRelative = (d) => ({
...d,
file: path.relative(absPath, d.file),
});
if (primaryDisplay.length > 0) {
lines.push(`Primary findings (${primaryDisplay.length}):`);
lines.push(...primaryDisplay.map(toRelative).map(formatDisplayDiag));
lines.push("");
}
if (auxiliaryDisplay.length > 0) {
lines.push(`Auxiliary findings (${auxiliaryDisplay.length}):`);
lines.push(...auxiliaryDisplay.map(toRelative).map(formatDisplayDiag));
}
if (truncated) {
lines.push("", `... (${total - MAX_DIAGNOSTICS} more diagnostics not shown)`);
}
text = lines.join("\n");
}
return {
content: [{ type: "text", text }],
details: {
filePath: absPath,
mode: "directory",
severity,
serverScope: options.serverScope ?? "all",
filesScanned: filesToProcess.length,
capped: wasCapped,
diagnostics: display.map((d) => ({
file: path.relative(absPath, d.file),
line: d.line,
character: d.character,
severity: d.severity,
message: d.message,
source: d.source,
code: d.code,
})),
primaryDiagnosticsCount: primaryDisplay.length,
auxiliaryDiagnosticsCount: auxiliaryDisplay.length,
totalDiagnostics: total,
truncated,
cleanFiles: clean,
unconfirmedFiles: unconfirmed,
timedOutFiles: timedOut > 0 ? timedOut : undefined,
fileErrors: fileErrors.length > 0 ? fileErrors : undefined,
lspHealthWarnings: lspHealthWarnings.length > 0 ? lspHealthWarnings : undefined,
concurrency: options.concurrency,
waitMs: options.waitMs,
},
};
}
// ── helpers ─────────────────────────────────────────────────────────────
function applySeverityFilter(diags, severity) {
if (severity === "all")
return diags;
const maxLevel = {
error: 1,
warning: 2,
information: 3,
hint: 4,
};
const max = maxLevel[severity] ?? 0;
if (max === 0)
return diags;
return diags.filter((d) => (d.severity ?? 3) <= max);
}
function formatDisplayDiag(d) {
const sevName = SEVERITY_NAMES[d.severity] ?? "unknown";
const loc = d.line !== undefined
? `${d.file}:${d.line + 1}:${(d.character ?? 0) + 1}`
: d.file;
const src = d.source ? `[${d.source}]` : "";
const code = d.code ? ` (${d.code})` : "";
return `${loc}: ${sevName}${src}${code}: ${d.message}`;
}
function formatDiag(diag) {
const loc = diag.range?.start?.line !== undefined
? `L${diag.range.start.line + 1}:${(diag.range.start.character ?? 0) + 1}`
: "";
const src = diag.source ? `[${diag.source}]` : "";
const code = diag.code ? ` (${diag.code})` : "";
const sevName = SEVERITY_NAMES[diag.severity] ?? "unknown";
return `${loc}: ${sevName}${src}${code}: ${diag.message}`;
}