UNPKG

pi-lens

Version:

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

752 lines (751 loc) • 30.1 kB
/** * Declarative Tool Dispatcher for pi-lens * * Redesigned to handle the full complexity of pi-lens's tool_result handler: * - Multiple tools with different semantics (blocking, warning, silent) * - Delta mode (baseline tracking) * - Autofix handling * - Output aggregation and formatting * * Key abstractions: * - RunnerDefinition: A tool that can be run * - Diagnostic: Structured issue representation * - OutputSemantic: How to display (blocking, warning, silent, etc.) * - BaselineStore: Track pre-existing issues for delta mode */ import * as fs from "node:fs"; import * as path from "node:path"; import { recordRunner } from "../widget-state.js"; import { detectFileKind } from "../file-kinds.js"; import { detectFileRole } from "../file-role.js"; import { isTestFile } from "../file-utils.js"; import { getPrimaryDispatchGroup } from "../language-policy.js"; import { resolveLanguageRootForFile } from "../language-profile.js"; import { logLatency } from "../latency-logger.js"; import { normalizeMapKey } from "../path-utils.js"; import { loadPiLensProjectConfig } from "../project-lens-config.js"; import { RUNTIME_CONFIG, getRunnerTimeoutFloorMs } from "../runtime-config.js"; import { safeSpawnAsync } from "../safe-spawn.js"; import { classifyDiagnostic } from "./diagnostic-taxonomy.js"; import { applyInlineSuppressions } from "./inline-suppressions.js"; import { getToolPlan } from "./plan.js"; import { resolveRunnerPath } from "./runner-context.js"; import { getToolProfile } from "./tool-profile.js"; import { formatDiagnostics } from "./utils/format-utils.js"; // --- Runner Registry --- export class RunnerRegistry { runners = new Map(); register(runner) { if (this.runners.has(runner.id)) return; this.runners.set(runner.id, runner); } get(id) { return this.runners.get(id); } getForKind(kind, filePath) { const matching = []; const isTest = filePath ? isTestFile(filePath) : false; for (const runner of this.runners.values()) { if (isTest && runner.skipTestFiles) continue; if (runner.appliesTo.includes(kind) || runner.appliesTo.length === 0) { matching.push(runner); } } return matching.sort((a, b) => a.priority - b.priority); } list() { return Array.from(this.runners.values()); } clear() { this.runners.clear(); } } // --- Tool Availability Cache --- /** * Normalize a command name to a FactStore session key. * Strips .cmd/.exe suffixes (case-insensitive) and lowercases, * then prefixes with "session.toolCache.". */ export function normalizeCacheKey(cmd) { const normalized = cmd.replace(/\.(cmd|exe)$/i, "").toLowerCase(); return `session.toolCache.${normalized}`; } async function checkToolAvailability(command, facts) { const key = normalizeCacheKey(command); const cached = facts.getSessionFact(key); if (cached !== undefined) { return cached; } try { const result = await safeSpawnAsync(command, ["--version"], { timeout: 5000, }); const available = result.status === 0; facts.setSessionFact(key, available); return available; } catch { facts.setSessionFact(key, false); return false; } } // --- Dispatch Context Factory --- function readFilePrefix(filePath, maxBytes = 4096) { let fd; try { fd = fs.openSync(filePath, "r"); const buffer = Buffer.alloc(maxBytes); const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0); return buffer.subarray(0, bytesRead).toString("utf8"); } catch { return undefined; } finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { // ignore close errors } } } } export function createDispatchContext(filePath, cwd, pi, facts, blockingOnly, modifiedRanges) { const absoluteFilePath = resolveRunnerPath(cwd, filePath); const normalizedProjectRoot = normalizeMapKey(path.resolve(cwd)); const normalizedCwd = normalizeMapKey(resolveLanguageRootForFile(absoluteFilePath, cwd)); const normalizedFilePath = normalizeMapKey(absoluteFilePath); const kind = detectFileKind(normalizedFilePath); const fileRole = detectFileRole(normalizedFilePath, readFilePrefix(normalizedFilePath)); const projectConfig = loadPiLensProjectConfig(normalizedCwd); return { filePath: normalizedFilePath, projectRoot: normalizedProjectRoot, cwd: normalizedCwd, kind, fileRole, pi, autofix: false, deltaMode: !pi.getFlag("no-delta"), facts, projectConfig, blockingOnly, modifiedRanges, async hasTool(command) { return checkToolAvailability(command, facts); }, log(message) { console.error(`[dispatch] ${message}`); }, }; } // --- Delta Mode Logic --- /** * Filter diagnostics to only show NEW issues (delta mode) */ function filterDelta(after, before, keyFn) { const beforeSet = new Set((before ?? []).map(keyFn)); const afterSet = new Set(after.map(keyFn)); const fixed = (before ?? []).filter((d) => !afterSet.has(keyFn(d))); const newItems = after.filter((d) => !beforeSet.has(keyFn(d))); return { new: newItems, fixed }; } function semanticRank(semantic) { if (semantic === "blocking") return 4; if (semantic === "warning") return 3; if (semantic === "fixed") return 2; if (semantic === "silent") return 1; return 0; } function toolPriority(tool, defectClass) { return getToolProfile(tool, defectClass).dedupPriority; } function dedupeOverlappingDiagnostics(diagnostics) { const byKey = new Map(); for (const d of diagnostics) { const defectClass = d.defectClass ?? classifyDiagnostic(d); const line = d.line ?? 1; const column = d.column ?? 1; const ruleKey = d.rule || d.id || "unknown"; const key = `${d.filePath}:${line}:${column}:${defectClass}:${ruleKey}`; const current = byKey.get(key); if (!current) { byKey.set(key, { ...d, defectClass }); continue; } const currScore = semanticRank(current.semantic) * 100 + toolPriority(current.tool, defectClass); const nextScore = semanticRank(d.semantic) * 100 + toolPriority(d.tool, defectClass); if (nextScore > currScore) { byKey.set(key, { ...d, defectClass }); } } return [...byKey.values()]; } function suppressLintOverlapsWithLsp(diagnostics) { const lspBySpanClass = new Set(); const lspByLine = new Set(); const isLintTool = (tool) => { return getToolProfile(tool).lintLike; }; for (const d of diagnostics) { if (d.tool !== "lsp") continue; const line = d.line ?? 1; const defectClass = d.defectClass ?? classifyDiagnostic(d); lspBySpanClass.add(`${d.filePath}:${line}:${defectClass}`); lspByLine.add(`${d.filePath}:${line}`); } if (lspByLine.size === 0) return diagnostics; return diagnostics.filter((d) => { if (d.tool === "lsp") return true; if (!isLintTool(d.tool)) return true; if (d.semantic === "blocking" || d.severity === "error") return true; const line = d.line ?? 1; const defectClass = d.defectClass ?? classifyDiagnostic(d); const key = `${d.filePath}:${line}:${defectClass}`; if (lspBySpanClass.has(key)) return false; // Conservative fallback for unclassified overlap at same line. if (defectClass === "unknown") { return !lspByLine.has(`${d.filePath}:${line}`); } return true; }); } /** * Dockerfile overlap dedup (#131 Mode 2): hadolint and `trivy config` both flag * a few of the same Dockerfile issues (e.g. `:latest`, running as root) with * different rule ids, which the rule-keyed `dedupeOverlappingDiagnostics` can't * collapse. Keep hadolint authoritative on the lines it covers and drop the * trivy-config finding there — trivy still contributes the security checks * hadolint lacks (on other lines), and all Kubernetes findings (no hadolint * diagnostics exist for YAML, so none are suppressed). Exported for unit tests. */ export function suppressTrivyConfigDockerOverlap(diagnostics) { const hadolintLines = new Set(); for (const d of diagnostics) { if (d.tool === "hadolint") { hadolintLines.add(`${d.filePath}:${d.line ?? 1}`); } } if (hadolintLines.size === 0) return diagnostics; return diagnostics.filter((d) => d.tool !== "trivy-config" || !hadolintLines.has(`${d.filePath}:${d.line ?? 1}`)); } function isUnusedValueDiagnostic(d) { const raw = `${d.id ?? ""} ${d.rule ?? ""} ${d.message ?? ""}`.toLowerCase(); if (raw.includes("no-unused")) return true; if (/\b(6133|6192|6196)\b/.test(raw)) return true; const rule = String(d.rule ?? "").toLowerCase(); if (rule.includes("unused")) return true; const message = d.message.toLowerCase(); return (message.includes("is declared but its value is never read") || message.includes("is assigned a value but never used") || message.includes("declared but never used") || message.includes("unused")); } function promoteDeltaUnusedToBlockers(diagnostics) { return diagnostics.map((d) => { if (!isUnusedValueDiagnostic(d)) return d; if (d.semantic === "blocking" || d.severity === "error") return d; return { ...d, severity: "error", semantic: "blocking", fixSuggestion: d.fixSuggestion ?? "Remove the unused declaration or rename with '_' prefix if intentionally unused.", }; }); } function buildCoverageNotice(ctx, runnerLatencies) { if (!ctx.kind) return undefined; const lspEnabled = !ctx.pi.getFlag("no-lsp"); const primary = getPrimaryDispatchGroup(ctx.kind, lspEnabled); if (!primary || primary.runnerIds.length === 0) return undefined; const relevant = runnerLatencies.filter((r) => primary.runnerIds.includes(r.runnerId)); if (relevant.length === 0) return undefined; // Check primary runners first const primaryHasCoverage = relevant.some((r) => r.status === "succeeded" || r.status === "failed"); if (primaryHasCoverage) return undefined; const allPrimarySkipped = relevant.every((r) => r.status === "skipped" || r.status === "when_skipped"); if (!allPrimarySkipped) return undefined; const plan = getToolPlan(ctx.kind); const fallbackRunnerIds = new Set((plan?.groups ?? []) .filter((group) => !group.runnerIds.every((runnerId) => primary.runnerIds.includes(runnerId))) .flatMap((group) => group.runnerIds) .filter((runnerId) => !primary.runnerIds.includes(runnerId))); // Structural-only runners (tree-sitter, ast-grep) are not substitutes // for real linters — don't suppress the notice if only they ran. const STRUCTURAL_RUNNERS = new Set([ "tree-sitter", "ast-grep-napi", "spellcheck", "fact-rules", "opengrep", ]); const anyLinterHasCoverage = runnerLatencies.some((r) => fallbackRunnerIds.has(r.runnerId) && !STRUCTURAL_RUNNERS.has(r.runnerId) && (r.status === "succeeded" || r.status === "failed")); if (anyLinterHasCoverage) return undefined; const onceKey = `${ctx.kind}:${normalizeMapKey(ctx.filePath)}`; if (coverageNoticeSeen.has(onceKey)) return undefined; coverageNoticeSeen.add(onceKey); return { id: `coverage-unavailable:${ctx.kind}:${path.basename(ctx.filePath)}`, message: `Pi-lens ${ctx.kind} analysis unavailable — language tools are missing or the LSP server isn't ready yet, so this file was not fully checked (not a clean result).`, filePath: ctx.filePath, severity: "warning", semantic: "warning", tool: "pi-lens", }; } const latencyReports = []; const coverageNoticeSeen = new Set(); export function getLatencyReports() { return [...latencyReports]; } export function clearLatencyReports() { latencyReports.length = 0; } export function clearCoverageNoticeState() { coverageNoticeSeen.clear(); } export function formatLatencyReport(report) { const lines = []; lines.push(`\n═══════════════════════════════════════════════════════════════`); lines.push(`📊 DISPATCH LATENCY REPORT: ${report.filePath.split("/").pop()}`); lines.push(` Kind: ${report.fileKind || "unknown"} | Total: ${report.totalDurationMs}ms`); lines.push(`───────────────────────────────────────────────────────────────`); lines.push(`Runner Duration Status Issues Semantic`); lines.push(`───────────────────────────────────────────────────────────────`); for (const r of report.runners) { const name = r.runnerId.padEnd(30); const dur = `${r.durationMs}ms`.padStart(8); const status = r.status.padStart(9); const issues = String(r.diagnosticCount).padStart(6); const sem = r.semantic.padStart(8); const slowMarker = r.durationMs > 500 ? " 🔥" : r.durationMs > 100 ? " ⚡" : ""; lines.push(`${name}${dur}${status}${issues}${sem}${slowMarker}`); } lines.push(`───────────────────────────────────────────────────────────────`); lines.push(`Total: ${report.runners.length} runners | Stopped early: ${report.stoppedEarly}`); lines.push(`Diagnostics: ${report.totalDiagnostics} (${report.blockers} blockers, ${report.warnings} warnings)`); // Show top 3 slowest const sorted = [...report.runners].sort((a, b) => b.durationMs - a.durationMs); if (sorted.length > 0 && sorted[0].durationMs > 100) { lines.push(`\n🐌 Slowest runners:`); for (const r of sorted.slice(0, 3)) { if (r.durationMs > 50) { lines.push(` ${r.runnerId}: ${r.durationMs}ms (${r.status})`); } } } lines.push(`═══════════════════════════════════════════════════════════════`); return lines.join("\n"); } /** * Execute all runners in a single group. * * - mode "fallback": run runners sequentially and stop at the first * one that succeeds (returns status !== "skipped"). * - mode "all" (default): run all runners in the group sequentially * and collect every diagnostic. * * Groups themselves are run in parallel by dispatchForFile, so this * function must NOT mutate shared state. */ async function runGroup(ctx, group, registry, onRunnerResult) { const diagnostics = []; const latencies = []; let hadBlocker = false; // Filter runners by kind if specified const runnerIds = group.filterKinds ? group.runnerIds.filter((id) => { const runner = registry.get(id); return runner && ctx.kind && group.filterKinds?.includes(ctx.kind); }) : group.runnerIds; const semantic = group.semantic ?? "warning"; for (const runnerId of runnerIds) { const runnerStart = Date.now(); const runner = registry.get(runnerId); if (!runner) { latencies.push({ runnerId, startTime: runnerStart, endTime: Date.now(), durationMs: 0, status: "skipped", diagnosticCount: 0, semantic: "unknown", }); logLatency({ type: "runner", filePath: ctx.filePath, runnerId, durationMs: 0, status: "not_registered", diagnosticCount: 0, semantic: "unknown", }); continue; } // Check preconditions let shouldRun = true; if (runner.when) { try { shouldRun = await runner.when(ctx); } catch (error) { ctx.log(`Runner ${runner.id} precondition failed: ${error}`); shouldRun = false; } } if (!shouldRun) { latencies.push({ runnerId, startTime: runnerStart, endTime: Date.now(), durationMs: Date.now() - runnerStart, status: "when_skipped", diagnosticCount: 0, semantic: runner.id, }); logLatency({ type: "runner", filePath: ctx.filePath, runnerId, durationMs: 0, status: "when_skipped", diagnosticCount: 0, semantic: "when_condition", }); continue; } const result = await runRunner(ctx, runner, semantic); onRunnerResult?.(runnerId, result); const runnerEnd = Date.now(); const duration = runnerEnd - runnerStart; latencies.push({ runnerId, startTime: runnerStart, endTime: runnerEnd, durationMs: duration, status: result.status, diagnosticCount: result.diagnostics.length, semantic: result.semantic ?? semantic, }); logLatency({ type: "runner", filePath: ctx.filePath, runnerId, startedAt: new Date(runnerStart).toISOString(), durationMs: duration, status: result.status, diagnosticCount: result.diagnostics.length, semantic: result.semantic ?? semantic, diagnostics: result.diagnostics.length > 0 ? result.diagnostics.map((d) => ({ rule: d.rule, message: d.message.slice(0, 120), line: d.line, semantic: d.semantic, })) : undefined, metadata: result.status === "failed" && result.failureKind ? { failureKind: result.failureKind, failureMessage: result.failureMessage, } : undefined, }); recordRunner(ctx.filePath, runnerId, result.status, result.diagnostics.length, duration); diagnostics.push(...result.diagnostics); const resultSemantic = result.semantic ?? semantic; if ((resultSemantic === "blocking" && result.diagnostics.length > 0) || result.diagnostics.some((d) => d.semantic === "blocking")) { hadBlocker = true; } // mode:"fallback" — stop at first successful runner if (group.mode === "fallback" && result.status === "succeeded") { break; } } return { diagnostics, latencies, hadBlocker }; } // --- Main Dispatch Function --- export async function dispatchForFile(ctx, groups, registry, onRunnerResult) { const _overallStart = Date.now(); if (ctx.fileRole === "generated") { return { diagnostics: [], blockers: [], warnings: [], baselineWarningCount: 0, fixed: [], resolvedCount: 0, output: "", blockerOutput: "", hasBlockers: false, }; } const allDiagnostics = []; let stopped = false; const runnerLatencies = []; // Debug logging goes to latency log only (not console - avoid noise) const allRunnerIds = groups.flatMap((g) => g.runnerIds); logLatency({ type: "phase", filePath: ctx.filePath, phase: "dispatch_start", durationMs: 0, metadata: { groupCount: groups.length, kind: ctx.kind, runners: allRunnerIds.join(","), }, }); // Run all groups in parallel — they are independent and don't depend on // each other's results. Within each group, mode:"fallback" semantics are // preserved (sequential first-success). Results are merged in original // group order so output is deterministic. const groupResults = await Promise.all(groups.map((group) => runGroup(ctx, group, registry, onRunnerResult))); // Count baseline warnings before filtering (for delta count display) const relativeKey = path.relative(ctx.cwd, ctx.filePath).replace(/\\/g, "/"); const baselineAbsKey = `session.baseline.${normalizeMapKey(ctx.filePath)}`; const baselineRelKey = `session.baseline.${normalizeMapKey(relativeKey)}`; const previousBaseline = ctx.deltaMode ? (ctx.facts.getSessionFact(baselineAbsKey) ?? ctx.facts.getSessionFact(baselineRelKey)) : undefined; const baselineWarnings = previousBaseline?.filter((d) => d.semantic === "warning" || d.semantic === "none"); const baselineWarningCount = baselineWarnings?.length ?? 0; for (const { diagnostics: groupDiags, latencies, hadBlocker, } of groupResults) { runnerLatencies.push(...latencies); allDiagnostics.push(...groupDiags); if (hadBlocker) stopped = true; } // Apply delta mode ONCE across the full diagnostic set. // This avoids partial-baseline corruption when processing multiple groups. const dedupedDiagnostics = dedupeOverlappingDiagnostics(allDiagnostics); const dockerOverlapSuppressed = suppressTrivyConfigDockerOverlap(dedupedDiagnostics); const overlapSuppressed = suppressLintOverlapsWithLsp(dockerOverlapSuppressed); const fileContent = ctx.facts.getFileFact(ctx.filePath, "file.content") ?? ""; const inlineSuppressed = applyInlineSuppressions(overlapSuppressed, fileContent); let visibleDiagnostics = inlineSuppressed; let resolvedCount = 0; if (ctx.deltaMode && previousBaseline) { const filtered = filterDelta(visibleDiagnostics, previousBaseline, (d) => d.id); visibleDiagnostics = promoteDeltaUnusedToBlockers(filtered.new); resolvedCount = filtered.fixed.length; } // Persist full current snapshot for next run (not delta-filtered subset). if (ctx.deltaMode) { ctx.facts.setSessionFact(baselineAbsKey, [...dedupedDiagnostics]); ctx.facts.setSessionFact(baselineRelKey, [...dedupedDiagnostics]); } // Categorize results const blockers = visibleDiagnostics.filter((d) => d.semantic === "blocking"); const warnings = visibleDiagnostics.filter((d) => d.semantic === "warning" || d.semantic === "none"); const fixedItems = visibleDiagnostics.filter((d) => d.semantic === "fixed"); // Append fixed and fixable diagnostics to the persistent worklog if (fixedItems.length > 0) { import("../fix-worklog.js") .then(({ appendToWorklog }) => { appendToWorklog(ctx.cwd, fixedItems, true); }) .catch(() => { }); } const fixableWarnings = warnings.filter((d) => d.fixable); if (fixableWarnings.length > 0) { import("../fix-worklog.js") .then(({ appendToWorklog }) => { appendToWorklog(ctx.cwd, fixableWarnings, false); }) .catch(() => { }); } const inlineBlockers = blockers; const inlineFixed = fixedItems; const coverageNotice = buildCoverageNotice(ctx, runnerLatencies); // Format output — only blocking issues shown inline // Warnings tracked but not shown (noise) — surfaced via lens_diagnostics const blockerOutput = formatDiagnostics(inlineBlockers, "blocking"); let output = blockerOutput; output += formatDiagnostics(inlineFixed, "fixed"); if (coverageNotice) { output += formatDiagnostics([coverageNotice], "warning", 1); warnings.push(coverageNotice); } // Generate and store latency report const overallEnd = Date.now(); const latencyReport = { filePath: ctx.filePath, fileKind: ctx.kind, overallStartMs: _overallStart, overallEndMs: overallEnd, totalDurationMs: overallEnd - _overallStart, runners: runnerLatencies, stoppedEarly: stopped, totalDiagnostics: visibleDiagnostics.length, blockers: blockers.length, warnings: warnings.length, }; // Store for later analysis latencyReports.push(latencyReport); // Keep only last 100 reports to prevent memory bloat if (latencyReports.length > 100) { latencyReports.shift(); } // Runner latencies already logged immediately after execution (line ~329) // The runnerLatencies array is stored in latencyReport for aggregate analysis // No need to log again here - would create duplicates in the log // Log summary to latency log only (not console - avoid noise) const sumMs = runnerLatencies.reduce((s, r) => s + r.durationMs, 0); const wallClockMs = latencyReport.totalDurationMs; logLatency({ type: "tool_result", filePath: ctx.filePath, durationMs: wallClockMs, wallClockMs, sumMs, parallelGainMs: Math.max(0, sumMs - wallClockMs), result: "dispatch_complete", metadata: { runners: runnerLatencies.map((r) => ({ id: r.runnerId, startedAt: new Date(r.startTime).toISOString(), duration: r.durationMs, status: r.status, })), totalDiagnostics: visibleDiagnostics.length, blockers: blockers.length, }, }); return { diagnostics: visibleDiagnostics, blockers, warnings, baselineWarningCount, fixed: fixedItems, resolvedCount, output, blockerOutput, hasBlockers: blockers.length > 0, }; } // --- Run Single Runner --- /** Maximum wall-clock time a single runner may take before we abort it. */ const RUNNER_TIMEOUT_MS = RUNTIME_CONFIG.dispatch.runnerTimeoutMs; function looksLikeDiagnosticCodePath(value) { if (!value) return false; const text = value.trim(); if (!text) return false; const base = path.basename(text.replace(/\\/g, "/")); if (/^lsp:\d+(?::\d+)?$/i.test(text) || /^lsp:\d+(?::\d+)?$/i.test(base)) { return true; } if (/^similarity[-:]/i.test(text) || /^similarity[-:]/i.test(base)) { return true; } if (/^[a-z-]+:\d+(?::\d+)?$/i.test(text) || /^[a-z-]+:\d+(?::\d+)?$/i.test(base)) { return true; } return false; } function normalizeDiagnosticFilePath(ctx, rawPath) { if (typeof rawPath === "string" && looksLikeDiagnosticCodePath(rawPath)) { ctx.log(`runner path normalization: ignored diagnostic code-like path '${rawPath}', using current file`); return resolveRunnerPath(ctx.cwd, ctx.filePath); } return resolveRunnerPath(ctx.cwd, rawPath || ctx.filePath); } async function runRunner(ctx, runner, defaultSemantic) { const timeoutMs = Math.max(runner.timeoutMs ?? RUNNER_TIMEOUT_MS, getRunnerTimeoutFloorMs()); let timer; try { const result = await Promise.race([ runner.run(ctx).finally(() => clearTimeout(timer)), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Runner ${runner.id} timed out after ${timeoutMs}ms`)), timeoutMs); }), ]); const diagnostics = result.diagnostics.map((d) => ({ ...d, filePath: normalizeDiagnosticFilePath(ctx, d.filePath), })); return { ...result, diagnostics, semantic: result.semantic ?? defaultSemantic, }; } catch (error) { clearTimeout(timer); ctx.log(`Runner ${runner.id} failed: ${error}`); const message = error instanceof Error ? error.message : String(error); return { status: "failed", diagnostics: [], semantic: defaultSemantic, failureKind: message.includes("timed out") ? "timeout" : "exception", failureMessage: message.slice(0, 200), }; } } // --- Simple Integration Helper --- /** * @internal * Low-level dispatch entry point. Use `dispatchLint` from `./integration.js` instead — * that version provides session-persistent baselines and FactStore. * This function creates an ephemeral FactStore per call; facts do not persist across calls. */ export async function dispatchLint(filePath, cwd, pi, facts, registry) { // By default, only run BLOCKING rules for fast feedback on file write const ctx = createDispatchContext(filePath, cwd, pi, facts, true); // Get runners for this file kind if (!ctx.kind) return ""; const runners = registry.getForKind(ctx.kind, ctx.filePath); if (runners.length === 0) { return ""; } // Create groups from registered runners (all in fallback mode) const groups = [ { mode: "fallback", runnerIds: runners.map((r) => r.id), }, ]; const result = await dispatchForFile(ctx, groups, registry); return result.output; }