pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
1,347 lines • 60.8 kB
JavaScript
/**
* Dispatch integration helpers
*
* Provides utilities for integrating the declarative dispatch system
* with the existing index.ts tool_result handler.
*/
import { getDiagnosticLogger } from "../diagnostic-logger.js";
import { detectFileKind } from "../file-kinds.js";
import { getLspCapableKinds, getPrimaryDispatchGroup, } from "../language-policy.js";
import { formatSlopScoreSummary, } from "../session-summary.js";
import { clearCoverageNoticeState, clearLatencyReports, createDispatchContext, dispatchForFile, formatLatencyReport, getLatencyReports, RunnerRegistry, } from "./dispatcher.js";
import { FactStore } from "./fact-store.js";
import { TOOL_PLANS } from "./plan.js";
// Re-export latency tracking types and functions
export { clearLatencyReports, formatLatencyReport, getLatencyReports };
import * as nodeFs from "node:fs";
import * as nodePath from "node:path";
import { fileURLToPath } from "node:url";
import { formatCascadeNeighborDiagnostics } from "../cascade-format.js";
import { logCascade } from "../cascade-logger.js";
import { getDiagnosticTracker } from "../diagnostic-tracker.js";
import { classifyCascadeWaitTier, isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
import { getServersForFileWithConfig } from "../lsp/config.js";
import { getLSPService } from "../lsp/index.js";
import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
import { getProjectIgnoreMatcher } from "../file-utils.js";
import { clearReviewGraphWorkspaceCache, getLastGraphBuildInfo, } from "../review-graph/builder.js";
import { buildReverseDependencyIndexFromGraph, getAffectedFilesFromIndex, writeReverseDependencyIndexToSnapshot, } from "../reverse-deps.js";
import { buildOrUpdateGraph, computeImpactCascade, computeTransitiveImpact, formatImpactCascade, } from "../review-graph/service.js";
import { clearModuleGraphCache } from "../review-graph/workspace-modules.js";
import { RUNTIME_CONFIG } from "../runtime-config.js";
import { findCompiledClassesDir, hasJavaBuildDescriptor, } from "../tool-policy.js";
import { removeWordIndexDocument, updateWordIndexDocument, WORD_INDEX_MAX_BYTES, } from "../word-index.js";
// Register fact providers. All register eagerly here (the dispatch entry) — the
// tree-sitter-backed providers included, since the parsing stack loads
// `web-tree-sitter` lazily inside client.init(), not at module import, so it
// stays out of the eager graph and degrades there rather than crashing at load.
import { registerProvider, runProviders } from "./fact-runner.js";
import { commentFactProvider } from "./facts/comment-facts.js";
import { fileContentProvider } from "./facts/file-content.js";
import { functionFactProvider } from "./facts/function-facts.js";
import { importFactProvider } from "./facts/import-facts.js";
import { tryCatchFactProvider } from "./facts/try-catch-facts.js";
import { resolveRunnerPath, toRunnerDisplayPath } from "./runner-context.js";
import { registerDefaultRunners } from "./runners/index.js";
import { convertLspDiagnostics } from "./utils/lsp-diagnostics.js";
registerProvider(fileContentProvider);
registerProvider(tryCatchFactProvider);
registerProvider(functionFactProvider);
registerProvider(commentFactProvider);
registerProvider(importFactProvider);
// Register fact rules
import { registerRule } from "./fact-rule-runner.js";
import { asyncNoiseRule } from "./rules/async-noise.js";
import { asyncUnnecessaryWrapperRule } from "./rules/async-unnecessary-wrapper.js";
import { corsWildcardRule } from "./rules/cors-wildcard.js";
import { errorObscuringRule } from "./rules/error-obscuring.js";
import { errorSwallowingRule } from "./rules/error-swallowing.js";
import { highComplexityRule } from "./rules/high-complexity.js";
import { highFanOutRule } from "./rules/high-fan-out.js";
import { highImportCouplingRule } from "./rules/high-import-coupling.js";
import { missingErrorPropagationRule } from "./rules/missing-error-propagation.js";
import { commentedCredentialsRule } from "./rules/no-commented-credentials.js";
import { passThroughWrappersRule } from "./rules/pass-through-wrappers.js";
import { placeholderCommentsRule } from "./rules/placeholder-comments.js";
import { unsafeBoundaryRule } from "./rules/unsafe-boundary.js";
import { loadPiLensProjectConfig, } from "../project-lens-config.js";
registerRule(errorObscuringRule);
registerRule(errorSwallowingRule);
registerRule(asyncNoiseRule);
registerRule(passThroughWrappersRule);
registerRule(placeholderCommentsRule);
registerRule(highComplexityRule);
registerRule(unsafeBoundaryRule);
registerRule(asyncUnnecessaryWrapperRule);
registerRule(missingErrorPropagationRule);
registerRule(highFanOutRule);
registerRule(highImportCouplingRule);
registerRule(corsWildcardRule);
registerRule(commentedCredentialsRule);
/**
* Load a project's `.pi-lens.json` config.
*
* Rule thresholds are consumed from `DispatchContext.projectConfig` during rule
* evaluation, not applied to process-global module state. Keeping this helper as
* a thin loader preserves the existing integration seam while avoiding
* cross-workspace threshold bleed when multiple dispatches overlap.
*/
export function applyProjectLensConfig(cwd) {
return loadPiLensProjectConfig(cwd);
}
const sessionFacts = new FactStore();
const cascadeDiagnosticBaselines = new Map();
const sessionRunnerRegistry = new RunnerRegistry();
registerDefaultRunners(sessionRunnerRegistry);
const LSP_CAPABLE_KINDS = new Set(getLspCapableKinds());
const FACT_RULE_IDS = new Set([
"error-obscuring",
"error-swallowing",
"async-noise",
"pass-through-wrappers",
"placeholder-comments",
"high-complexity",
"unsafe-boundary",
"async-unnecessary-wrapper",
"missing-error-propagation",
"high-fan-out",
"commented-out-code",
"duplicate-string-literal",
"function-in-loop",
"cors-wildcard",
"dynamic-regexp",
"max-switch-cases",
"no-commented-credentials",
"no-boolean-params",
"high-import-coupling",
"no-complex-conditionals",
]);
const sessionSlopRuleCounts = new Map();
let sessionSlopDiagnosticCount = 0;
let sessionWrittenLineCount = 0;
// Debounced ast-grep warning scan — fires 2s after the last write to a jsts file.
// Runs warning-tier rules that are too expensive to include in the blocking write path,
// logs all diagnostics for history without surfacing anything to the agent.
const astGrepWarnDebounceTimers = new Map();
const AST_GREP_WARN_DEBOUNCE_MS = 2000;
function scheduleAstGrepWarningScan(filePath, cwd, pi, logContext) {
const existing = astGrepWarnDebounceTimers.get(filePath);
if (existing)
clearTimeout(existing);
const timer = setTimeout(async () => {
astGrepWarnDebounceTimers.delete(filePath);
try {
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, false);
if (ctx.kind !== "jsts")
return;
// Single-runner group: ast-grep only, warning mode (blockingOnly=false)
const group = {
mode: "all",
runnerIds: ["ast-grep"],
filterKinds: ["jsts"],
};
const result = await dispatchForFile(ctx, [group], sessionRunnerRegistry);
if (result.diagnostics.length === 0)
return;
const logger = getDiagnosticLogger();
for (const d of result.diagnostics) {
logger.logCaught(d, logContext, false);
}
}
catch {
// Non-critical background scan — swallow errors silently
}
}, AST_GREP_WARN_DEBOUNCE_MS);
astGrepWarnDebounceTimers.set(filePath, timer);
}
function resetSessionSlopScore() {
sessionSlopRuleCounts.clear();
sessionSlopDiagnosticCount = 0;
sessionWrittenLineCount = 0;
}
function detectFactRuleId(diagnostic) {
if (diagnostic.rule && FACT_RULE_IDS.has(diagnostic.rule)) {
return diagnostic.rule;
}
if (diagnostic.tool && FACT_RULE_IDS.has(diagnostic.tool)) {
return diagnostic.tool;
}
if (diagnostic.id) {
const prefix = diagnostic.id.split(":", 1)[0];
if (FACT_RULE_IDS.has(prefix)) {
return prefix;
}
}
return undefined;
}
function trackSessionSlopStats(ctx, diagnostics) {
const lineCount = ctx.facts.getFileFact(ctx.filePath, "file.lineCount");
if (typeof lineCount === "number" &&
Number.isFinite(lineCount) &&
lineCount > 0) {
sessionWrittenLineCount += lineCount;
}
for (const diagnostic of diagnostics) {
const ruleId = detectFactRuleId(diagnostic);
if (!ruleId)
continue;
sessionSlopDiagnosticCount += 1;
sessionSlopRuleCounts.set(ruleId, (sessionSlopRuleCounts.get(ruleId) ?? 0) + 1);
}
}
export function getDispatchSlopScoreSummary() {
if (sessionSlopDiagnosticCount === 0 || sessionWrittenLineCount <= 0) {
return undefined;
}
const totalKlocWritten = sessionWrittenLineCount / 1000;
const ruleCounts = [...sessionSlopRuleCounts.entries()]
.map(([ruleId, count]) => ({ ruleId, count }))
.sort((a, b) => b.count - a.count || a.ruleId.localeCompare(b.ruleId));
return {
totalRuleDiagnostics: sessionSlopDiagnosticCount,
totalKlocWritten,
scorePerKloc: sessionSlopDiagnosticCount / totalKlocWritten,
ruleCounts,
};
}
export function getDispatchSlopScoreLine() {
const summary = getDispatchSlopScoreSummary();
if (!summary)
return "";
return formatSlopScoreSummary(summary);
}
// SpotBugs analyzes JVM bytecode, so it applies to java + kotlin. Opt-in
// (lens-spotbugs flag) and only when a Java build descriptor + compiled .class
// dir exist — the runner itself mtime-caches so it doesn't re-run per keystroke. #133
const SPOTBUGS_SUPPORTED_KINDS = new Set(["java", "kotlin"]);
function withSpotbugsGroup(kind, groups, ctx) {
if (!SPOTBUGS_SUPPORTED_KINDS.has(kind))
return groups;
if (!ctx.pi.getFlag("lens-spotbugs"))
return groups;
if (!hasJavaBuildDescriptor(ctx.cwd) || !findCompiledClassesDir(ctx.cwd)) {
return groups;
}
if (groups.some((group) => group.runnerIds.includes("spotbugs")))
return groups;
return [
...groups,
{
mode: "all",
runnerIds: ["spotbugs"],
filterKinds: [kind],
semantic: "warning",
},
];
}
function withPrimaryPolicyGroup(kind, groups, pi) {
const lspEnabled = !pi.getFlag("no-lsp");
const normalizedGroups = lspEnabled
? groups
: groups
.map((group) => {
const runnerIds = group.runnerIds.filter((id) => id !== "lsp");
if (runnerIds.length === 0)
return null;
return {
...group,
runnerIds,
};
})
.filter((group) => group !== null);
const primary = getPrimaryDispatchGroup(kind, lspEnabled);
if (!primary)
return normalizedGroups;
const alreadyHasPrimary = normalizedGroups.some((group) => {
if (group.mode !== primary.mode)
return false;
if (group.runnerIds.length !== primary.runnerIds.length)
return false;
return group.runnerIds.every((id, index) => primary.runnerIds[index] === id);
});
if (alreadyHasPrimary)
return normalizedGroups;
return [primary, ...normalizedGroups];
}
export function getDispatchGroupsForKind(kind, pi) {
const plan = TOOL_PLANS[kind];
if (!plan) {
const lspEnabled = !pi.getFlag("no-lsp");
const policyGroup = getPrimaryDispatchGroup(kind, lspEnabled);
if (policyGroup)
return [policyGroup];
if (lspEnabled && LSP_CAPABLE_KINDS.has(kind)) {
return [
{ mode: "all", runnerIds: ["lsp"], filterKinds: [kind] },
];
}
return [];
}
return withPrimaryPolicyGroup(kind, plan.groups, pi);
}
/**
* Reset baselines — call on session_start so a new session
* starts with a clean slate.
*
* Pass `cwd` to also re-apply the project's `.pi-lens.json` rule thresholds
* (a no-op when the file is absent or unchanged, since the loader is
* mtime-cached). Optional for backward compatibility with tests that don't
* care about per-project thresholds.
*/
export function resetDispatchBaselines(cwd) {
if (cwd)
applyProjectLensConfig(cwd);
sessionFacts.clearAll();
resetSessionSlopScore();
clearCoverageNoticeState();
clearReviewGraphWorkspaceCache();
clearReverseDepsIndexCache();
clearModuleGraphCache();
neighborTouchCache.clear();
recentlyCleanNeighborCache.clear();
primaryFilesThisTurn.clear();
cascadeDiagnosticBaselines.clear();
cascadeSessionStats = {
runs: 0,
diagnosticsSurfaced: 0,
coldSnapshotTouches: 0,
};
for (const timer of astGrepWarnDebounceTimers.values())
clearTimeout(timer);
astGrepWarnDebounceTimers.clear();
}
let cascadeSessionStats = {
runs: 0,
diagnosticsSurfaced: 0,
coldSnapshotTouches: 0,
};
export function getCascadeSessionStats() {
return { ...cascadeSessionStats };
}
const neighborTouchCache = new Map();
const recentlyCleanNeighborCache = new Map();
const RECENTLY_CLEAN_TTL_TURNS = 5;
// B10: tracks files that were the *primary* edited file this turn.
// These are excluded from cascade neighbor results — their own pipeline run
// already reported their diagnostics authoritatively.
let cascadeTurnScope = 0;
const primaryFilesThisTurn = new Set();
function ensureCascadeTurnScope(turnSeq) {
if (turnSeq === cascadeTurnScope)
return;
cascadeTurnScope = turnSeq;
primaryFilesThisTurn.clear();
neighborTouchCache.clear();
for (const [key, entry] of recentlyCleanNeighborCache) {
if (turnSeq - entry.turnSeq > RECENTLY_CLEAN_TTL_TURNS) {
recentlyCleanNeighborCache.delete(key);
}
}
}
const CASCADE_TTL_MS = 240_000;
const MAX_PER_FILE = RUNTIME_CONFIG.pipeline.cascadeMaxDiagnosticsPerFile;
const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
const reverseDepsIndexCache = new Map();
function reverseDepsReuseEnabled() {
const raw = process.env.PI_LENS_REVERSE_DEPS_REUSE;
return raw !== "0" && raw !== "false";
}
/** Test-reset hook — mirrors clearReviewGraphWorkspaceCache's scope. */
export function clearReverseDepsIndexCache() {
reverseDepsIndexCache.clear();
}
// Bounded transitive cascade (#162): expand neighbour derivation beyond the
// one-hop importers/callers to depth-2 dependents, so an edit's blast radius
// reaches indirect dependents — capped so the per-edit cost stays bounded. The
// one-hop set is always the floor (it sorts first, before depth-2). Both
// env-tunable; set depth to 1 to restore the old one-hop-only behaviour.
const CASCADE_TRANSITIVE_DEPTH = Math.max(1, Number.parseInt(process.env.PI_LENS_CASCADE_TRANSITIVE_DEPTH ?? "2", 10) || 2);
const CASCADE_NEIGHBOUR_BUDGET = Math.max(MAX_FILES, Number.parseInt(process.env.PI_LENS_CASCADE_NEIGHBOUR_BUDGET ?? "40", 10) ||
40);
// Exported (not just module-local) so the MCP warm-analyze seam
// (clients/mcp/analyze.ts, #536) can gate its own buildOrUpdateGraph call on
// the SAME file-kind eligibility this cascade path uses — one source of truth
// for "does this language get graph nodes at all", never a second hardcoded copy.
export const CASCADE_GRAPH_KINDS = new Set([
"jsts",
"python",
"go",
"rust",
"ruby",
"cxx",
]);
/**
* Unified cascade orchestration — builds graph, discovers neighbors, and
* gathers per-file diagnostics with structured logging to cascade.log.
*
* autoPropagate (jsts): tsserver pushes diagnostics automatically, so we
* read from the passive snapshot instead of touching neighbors actively.
*
* Degraded fallback: when the graph produces no neighbors (ungraphed languages
* like Java/Kotlin/C#), fall back to the passive LSP snapshot from getAllDiagnostics
* to preserve cascade coverage.
*/
/**
* Whether a cascade neighbour is suppressed by the project's ignore config
* (`.pi-lens.json` / `.gitignore` / global `~/.pi-lens/config.json`), via the
* same `getProjectIgnoreMatcher` every other scan surface uses. Cascade surfaces
* collateral diagnostics in OTHER files an edit touched; a file the user ignores
* (e.g. a `*.test.ts` glob in `.pi-lens.json`) must not be re-surfaced here just
* because it imports the edited file — project walk and lens_diagnostics already
* filter it, and cascade was the last surface that didn't (#297). Fail-open: a
* config-probe error never drops a neighbour, matching the walkers' behaviour.
*/
function isIgnoredCascadeNeighbor(filePath, cwd) {
try {
return getProjectIgnoreMatcher(cwd).isIgnored(filePath, false);
}
catch {
return false;
}
}
/**
* #348 phase 2 per-edit seam: update the warm in-memory word index for one
* file, mirroring the review graph's per-edit rebuild at the same call site.
*
* Rules (each a documented, deliberate simplicity choice, not an oversight):
* - `wordIndex` null (no index loaded yet) ⇒ no-op. Cold-session handoff is
* OWNED by phase 1's lifecycle/background build, never invented here — an
* edit arriving before that build finishes just doesn't update anything;
* the eventual full build already reflects every file on disk, incl. this
* edit, so nothing is lost, only delayed.
* - `wordIndex.forward` undefined (pre-phase-2 index shape, e.g. a snapshot
* persisted before this feature or one still using the old serialized
* shape) ⇒ no-op. `updateWordIndexDocument` already refuses to mutate a
* forward-index-less index (see its doc comment) — this is the same rule
* surfaced one layer up so the caller isn't left guessing why nothing
* happened. The NEXT full rebuild (session-start lifecycle) installs a
* forward-index-bearing index that later edits CAN update incrementally.
* - `content` undefined (pipeline couldn't read the file — deleted, or a
* transient race) ⇒ no-op. Deletions aren't plumbed at this seam (this
* call site only ever sees the edited file's post-write content, never a
* delete event) — a removed file ages out at the next full rebuild, same
* scope boundary the review graph accepts for deletes.
* - File over the shared `WORD_INDEX_MAX_BYTES` cap ⇒ removed/absent from
* the index (never partially indexed) — same cap phase 1's build path
* enforces via `collectWordIndexDocs`.
* - On a successful update, `onUpdated` fires so the caller can schedule a
* debounced persist (never a synchronous write per edit — same #260
* discipline as the graph).
*
* Race safety against a build-in-progress: this function body is entirely
* synchronous (no `await` anywhere in it) and is called synchronously at
* `computeCascadeForFile`'s entry, before its own `await buildOrUpdateGraph`.
* Node is single-threaded, so two overlapping cascades (#450's unawaited
* concurrency) can never interleave mid-mutation here — each call runs to
* completion in one turn. The only cross-build hazard is a full session-start
* rebuild REPLACING `runtime.wordIndex` with a new object between the caller
* reading `runtime.wordIndex` (in runtime-tool-result.ts, also synchronous)
* and this function receiving it — in that case this call simply mutates
* whichever index object it was handed (old or new), and the other one is
* abandoned/superseded, never corrupted. No queue, no lock: the simplest rule
* that is still provably correct.
*/
function updateWordIndexForCascade(args) {
const { wordIndex, filePath, content, onUpdated, dbg } = args;
if (!wordIndex || !wordIndex.forward)
return;
if (content === undefined)
return;
const byteLength = Buffer.byteLength(content, "utf-8");
if (byteLength > WORD_INDEX_MAX_BYTES) {
removeWordIndexDocument(wordIndex, filePath);
dbg?.(`word-index per-edit: dropped ${filePath} (over size cap)`);
}
else {
updateWordIndexDocument(wordIndex, { path: filePath, content });
dbg?.(`word-index per-edit: updated ${filePath}`);
}
onUpdated?.(wordIndex);
}
export async function computeCascadeForFile(filePath, cwd, options = {}) {
const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState, fileContent, wordIndex, onWordIndexUpdated, } = options;
ensureCascadeTurnScope(turnSeq);
if (hasBlockers) {
logCascade({
phase: "cascade_skip",
filePath,
reason: "primary_has_blockers",
});
return {
filePath,
result: undefined,
neighborCount: 0,
diagnosticCount: 0,
skipReason: "blockers",
};
}
const fileKind = detectFileKind(filePath);
if (!fileKind) {
logCascade({ phase: "cascade_skip", filePath, reason: "non_code_file" });
return {
filePath,
result: undefined,
neighborCount: 0,
diagnosticCount: 0,
skipReason: "non_code",
};
}
const normalizedFile = resolveRunnerPath(cwd, filePath);
const normalizedFileKey = normalizeMapKey(normalizedFile);
// B10: record this file as a primary edit so later cascade calls in the same
// turn won't show it as a neighbor.
primaryFilesThisTurn.add(normalizedFileKey);
// #348 phase 2: warm per-edit word-index maintenance, review-graph style —
// update at the SAME seam as the graph rebuild below, using content the
// pipeline already read (no extra I/O). See computeCascadeForFile's
// `wordIndex`/`fileContent` doc comments for the cold/no-forward-index
// no-op rules.
//
// Deliberately keyed by `path.resolve(filePath)`, NOT `normalizedFile`
// (which is `normalizeMapKey`'d — realpath-canonicalized + lowercased on
// Windows). The word index's OWN keys come from `collectWordIndexDocs` →
// `collectSourceFilesAsync`'s file walk, which yields plain
// `path.resolve()`-joined paths (native separators, on-disk casing as
// reported by the walk, no realpath call). Keying this update with the
// cascade's normalized key would silently create a SECOND, orphaned entry
// next to the walker's original-cased entry instead of replacing it —
// exactly the kind of divergence the equivalence-property test is meant to
// catch, so the key shape here must match the build path's, not the
// cascade/graph's own (different) normalization scheme.
updateWordIndexForCascade({
wordIndex,
filePath: nodePath.resolve(filePath),
content: fileContent,
onUpdated: onWordIndexUpdated,
dbg,
});
let impact = {
filePath: normalizedFile,
changedSymbols: [],
directImporters: [],
directCallers: [],
neighborFiles: [],
riskFlags: [],
};
let sortedNeighbors = [];
let importerSet = new Set();
let callerSet = new Set();
let referenceCount = 0;
if (CASCADE_GRAPH_KINDS.has(fileKind)) {
const graphStart = Date.now();
const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts, seqState);
const graphMs = Date.now() - graphStart;
// #459: the reuse decision keys on graph.buildGeneration — a stamp that
// travels WITH the graph instance this cascade holds. Deliberately NOT the
// global last-build-info slot: post-#450 cascades overlap, and another
// cascade's cache-hit build can overwrite that slot (graphChanged:false)
// between this build mutating the graph and this read — which would turn a
// changed graph into a spurious reuse of a stale index that steady-state
// cache hits then never heal. Generation equality can't be clobbered into
// a false positive: a graph-mutating build always mints a new generation.
// An unstamped graph (mode "skipped") always rebuilds.
const graphBuildInfo = getLastGraphBuildInfo();
const workspaceKey = normalizeMapKey(cwd);
const cachedReverseDeps = reverseDepsIndexCache.get(workspaceKey);
const canReuse = reverseDepsReuseEnabled() &&
cachedReverseDeps !== undefined &&
graph.buildGeneration !== undefined &&
cachedReverseDeps.generation === graph.buildGeneration;
let reverseDepsIndex;
let reverseDepsSaved;
if (canReuse && cachedReverseDeps) {
reverseDepsIndex = cachedReverseDeps.index;
reverseDepsSaved = cachedReverseDeps.savedToSnapshot;
logCascade({
phase: "reverse_deps_cache",
filePath,
durationMs: Date.now() - graphStart,
metadata: {
action: "reused_unchanged",
savedToSnapshot: reverseDepsSaved,
importsFileCount: Object.keys(reverseDepsIndex.imports).length,
importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
},
});
}
else {
reverseDepsIndex = buildReverseDependencyIndexFromGraph({
cwd,
graph,
});
reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
cwd,
index: reverseDepsIndex,
dbg,
});
reverseDepsIndexCache.set(workspaceKey, {
index: reverseDepsIndex,
savedToSnapshot: reverseDepsSaved,
generation: graph.buildGeneration,
});
logCascade({
phase: "reverse_deps_cache",
filePath,
durationMs: Date.now() - graphStart,
metadata: {
action: "refresh_from_review_graph",
savedToSnapshot: reverseDepsSaved,
importsFileCount: Object.keys(reverseDepsIndex.imports).length,
importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
},
});
}
// Count files represented in the graph (nodes with a filePath).
const graphFileCount = new Set([...graph.nodes.values()].flatMap((n) => n.filePath ? [n.filePath] : [])).size;
logCascade({
phase: "graph_build",
filePath,
graphBuiltMs: graphMs,
graphReused: graphBuildInfo.reused,
graphNodeCount: graph.nodes.size,
graphFileCount,
graphChangedSymbolCount: (graph.changedSymbolsByFile.get(normalizedFileKey) ?? []).length,
metadata: {
graphBuildMode: graphBuildInfo.mode,
skipReason: graphBuildInfo.skipReason,
sourceFileCount: graphBuildInfo.sourceFileCount,
maxFileCount: graphBuildInfo.maxFileCount,
// #451: when the seq fast path fell back (or was skipped), why — so
// cascade.log surfaces the fast-path hit/miss rate.
seqFastpathFallback: graphBuildInfo.seqFastpathFallback,
},
});
impact = computeImpactCascade(graph, normalizedFile, cwd);
const reverseDepNeighbors = getAffectedFilesFromIndex(reverseDepsIndex, normalizedFile, 1, MAX_FILES * 2);
logCascade({
phase: "reverse_deps_cache",
filePath,
metadata: {
action: "merge_neighbors",
depth: 1,
neighborCount: reverseDepNeighbors.length,
neighbors: reverseDepNeighbors.slice(0, 10),
},
});
if (reverseDepNeighbors.length > 0) {
impact.directImporters = [
...new Set([...impact.directImporters, ...reverseDepNeighbors]),
];
impact.neighborFiles = [
...new Set([...impact.neighborFiles, ...reverseDepNeighbors]),
];
logCascade({
phase: "neighbor_snapshot",
filePath,
neighborFile: "[reverse-deps-cache]",
diagnosticCount: reverseDepNeighbors.length,
autoPropagate: false,
metadata: { reverseDepsCache: true },
});
}
// Symbol-level blast radius via LSP references (precision upgrade over
// file-level import edges). Only when changed symbols are detected.
// Keep the budget tight: 750ms per symbol, 1200ms total, max 3 symbols.
if (impact.changedSymbols.length > 0) {
const lspService = getLSPService();
const symbolNodeIds = graph.symbolNodesByFile.get(normalizedFileKey) ?? [];
const refFiles = new Set();
const refsStart = Date.now();
for (const symbolName of impact.changedSymbols.slice(0, 3)) {
const symbolNodeId = symbolNodeIds.find((id) => {
const node = graph.nodes.get(id);
return node?.symbolName === symbolName;
});
if (!symbolNodeId)
continue;
const node = graph.nodes.get(symbolNodeId);
const line = Number(node?.metadata?.line ?? 0);
const column = Number(node?.metadata?.column ?? 0);
if (line <= 0)
continue;
try {
const refs = await Promise.race([
lspService.references(normalizedFile, line - 1, column - 1, false),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 750)),
]);
for (const ref of refs) {
let resolved;
try {
resolved = ref.uri.startsWith("file://")
? fileURLToPath(ref.uri)
: ref.uri;
}
catch {
continue;
}
if (normalizeMapKey(resolved) !== normalizedFileKey &&
nodeFs.existsSync(resolved)) {
refFiles.add(normalizeMapKey(resolved));
}
}
}
catch {
// Timeout or LSP error — fall back to import-graph neighbors
}
if (Date.now() - refsStart > 1200)
break; // Hard ceiling
}
if (refFiles.size > 0) {
impact.neighborFiles = [
...new Set([...impact.neighborFiles, ...refFiles]),
];
logCascade({
phase: "neighbor_snapshot",
filePath,
neighborFile: "[lsp-references]",
diagnosticCount: refFiles.size,
durationMs: Date.now() - refsStart,
autoPropagate: false,
metadata: { lspReferences: true },
});
}
}
// Bounded transitive expansion: add depth>1 dependents (indirect
// importers/callers/referencers) so the blast radius isn't limited to one
// hop. The one-hop sets above remain the floor (they sort first); these
// fill the remaining budget. Graph BFS is in-memory + capped.
if (CASCADE_TRANSITIVE_DEPTH > 1) {
const transitive = computeTransitiveImpact(graph, normalizedFile, {
maxDepth: CASCADE_TRANSITIVE_DEPTH,
maxHits: CASCADE_NEIGHBOUR_BUDGET,
});
const added = [
...new Set(transitive.hits
.map((hit) => hit.file)
.filter((file) => file && normalizeMapKey(file) !== normalizedFileKey)),
].filter((file) => !impact.neighborFiles.includes(file));
if (added.length > 0) {
impact.neighborFiles = [...impact.neighborFiles, ...added];
logCascade({
phase: "neighbor_snapshot",
filePath,
neighborFile: "[transitive-impact]",
diagnosticCount: added.length,
autoPropagate: false,
metadata: {
transitive: true,
maxDepth: CASCADE_TRANSITIVE_DEPTH,
maxDepthReached: transitive.maxDepthReached,
truncated: transitive.truncated,
},
});
}
}
// Sort by relationship strength (B6) then cap to the neighbour budget.
// directImporters are most impactful, then callers, then reference edges.
importerSet = new Set(impact.directImporters);
callerSet = new Set(impact.directCallers);
// neighbors that are neither direct importers nor callers are reference-edge neighbors
const importerOrCallerSet = new Set([
...impact.directImporters,
...impact.directCallers,
]);
referenceCount = impact.neighborFiles.filter((n) => !importerOrCallerSet.has(n)).length;
sortedNeighbors = [...impact.neighborFiles]
.filter((n) => nodeFs.existsSync(n))
.filter((n) => !isExternalOrVendorFile(n, cwd))
// Honour the project's ignore config: a user-ignored neighbour (e.g.
// `**/*.test.ts`) must not surface as collateral cascade noise (#297).
.filter((n) => !isIgnoredCascadeNeighbor(n, cwd))
// B10: exclude files already edited as primary this turn — their own pipeline
// run is the authoritative diagnostic source; showing them as neighbors is noise.
.filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
.sort((a, b) => {
const rank = (p) => importerSet.has(p) ? 0 : callerSet.has(p) ? 1 : 2;
return rank(a) - rank(b);
})
.slice(0, CASCADE_NEIGHBOUR_BUDGET);
}
else {
logCascade({
phase: "cascade_skip",
filePath,
reason: "unsupported_graph_kind",
metadata: { fileKind },
});
return {
filePath,
result: undefined,
neighborCount: 0,
diagnosticCount: 0,
skipReason: "non_code",
};
}
logCascade({
phase: "neighbors_computed",
filePath,
neighborCount: sortedNeighbors.length,
totalNeighborCount: impact.neighborFiles.length,
importerCount: impact.directImporters.length,
callerCount: impact.directCallers.length,
referenceCount: Math.max(0, referenceCount),
riskFlags: impact.riskFlags,
metadata: { neighbors: sortedNeighbors.slice(0, 10) },
});
const lspService = getLSPService();
// Hoist passive snapshot once — used for auto-propagating LSPs and fallback path.
const allDiags = await lspService.getAllDiagnostics();
const neighbors = [];
let producedLspData = false;
let coldSnapshotPaths = [];
if (sortedNeighbors.length > 0) {
const snapshotPaths = sortedNeighbors.filter(shouldReadCascadeFromSnapshot);
const activePaths = sortedNeighbors.filter((n) => !shouldReadCascadeFromSnapshot(n));
// Auto-propagating LSPs (TypeScript/Deno) — read passive snapshot with normalized key.
// When the snapshot is valid, use it immediately (no touch needed — server already has
// fresh data from auto-propagation). When missing or stale, fall through to the active
// touch pool below so we get real diagnostics instead of silently returning zero.
coldSnapshotPaths = [];
for (const neighborPath of snapshotPaths) {
const neighborStart = Date.now();
const entry = allDiags.get(normalizeMapKey(neighborPath));
const snapshotAgeSec = entry
? Math.round((Date.now() - entry.ts) / 1000)
: undefined;
const snapshotValid = entry != null && Date.now() - entry.ts < CASCADE_TTL_MS;
if (!snapshotValid) {
// No usable snapshot — queue for active touch alongside non-jsts neighbors.
logCascade({
phase: "neighbor_snapshot",
filePath,
neighborFile: neighborPath,
diagnosticCount: 0,
durationMs: Date.now() - neighborStart,
autoPropagate: true,
snapshotMissing: entry == null,
snapshotAgeSec,
coldSnapshot: true,
});
coldSnapshotPaths.push(neighborPath);
continue;
}
const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" });
producedLspData = true;
const durationMs = Date.now() - neighborStart;
logCascade({
phase: "neighbor_snapshot",
filePath,
neighborFile: neighborPath,
diagnosticCount: diags.length,
durationMs,
autoPropagate: true,
snapshotMissing: false,
snapshotAgeSec,
});
neighbors.push({
filePath: neighborPath,
reason: neighborReason(importerSet, callerSet, neighborPath),
diagnostics: diags,
lspTouched: false,
durationMs,
});
}
// fan-out active touches in parallel (A3):
// - non-jsts neighbors (always touched)
// - autoPropagate neighbors whose snapshot was missing/stale (coldSnapshotPaths)
// use a tighter 1000ms budget since the server is expected to be warm already.
const touchResults = await Promise.allSettled([...activePaths, ...coldSnapshotPaths].map(async (neighborPath) => {
const isColdSnapshot = coldSnapshotPaths.includes(neighborPath);
const neighborStart = Date.now();
const cacheKey = normalizeMapKey(neighborPath);
const passiveEntry = allDiags.get(cacheKey);
const hasFreshPassiveErrors = passiveEntry != null &&
Date.now() - passiveEntry.ts < CASCADE_TTL_MS &&
passiveEntry.diags.some((d) => d.severity === 1);
const recentlyClean = recentlyCleanNeighborCache.get(cacheKey);
if (recentlyClean &&
turnSeq - recentlyClean.turnSeq <= RECENTLY_CLEAN_TTL_TURNS &&
!hasFreshPassiveErrors) {
producedLspData = true;
const durationMs = Date.now() - neighborStart;
logCascade({
phase: "neighbor_snapshot",
filePath,
neighborFile: neighborPath,
diagnosticCount: 0,
durationMs,
autoPropagate: false,
snapshotMissing: false,
metadata: {
recentlyClean: true,
cleanTurnSeq: recentlyClean.turnSeq,
},
});
return {
filePath: neighborPath,
reason: neighborReason(importerSet, callerSet, neighborPath),
diagnostics: [],
lspTouched: false,
durationMs,
};
}
// A5: skip re-touch if this neighbor was already diagnosed at the current
// write sequence. A new write (higher writeSeq) invalidates the cache entry.
const cached = writeSeq != null ? neighborTouchCache.get(cacheKey) : undefined;
if (cached?.turnSeq === turnSeq && cached?.writeSeq === writeSeq) {
producedLspData = true;
const durationMs = Date.now() - neighborStart;
logCascade({
phase: "neighbor_snapshot",
filePath,
neighborFile: neighborPath,
diagnosticCount: cached.diagnostics.length,
durationMs,
autoPropagate: false,
snapshotMissing: false,
metadata: { cachedWriteSeq: writeSeq },
});
return {
filePath: neighborPath,
reason: neighborReason(importerSet, callerSet, neighborPath),
diagnostics: cached.diagnostics,
lspTouched: false,
durationMs,
};
}
const configuredServerCount = getServersForFileWithConfig(neighborPath).length;
if (configuredServerCount === 0) {
logCascade({
phase: "neighbor_fallback",
filePath,
neighborFile: neighborPath,
fallbackUsed: false,
error: "no_lsp_server_configured",
});
return undefined;
}
// A6: async read to avoid blocking event loop on network-mounted drives
const content = await nodeFs.promises.readFile(neighborPath, "utf8");
// #458: tier-aware cascade-lane wait. A Tier-3 (push-only,
// silent-on-clean — typescript is the lone core-set instance today)
// primary can never give this in-lane wait an affirmative clean
// signal, so the budget is pure cost. Fire the touch (didOpen/
// didChange still happens — the server starts real work) and record
// it as outstanding for the agent_settled quiet window to reconcile
// instead of waiting here. Ambiguous/missing capability data always
// classifies as "waits" (today's behavior) — see cascade-tier.ts.
// The whole attempt is try/caught: any surprise (a service shape
// that doesn't expose getCapabilitySnapshots/getClientForFile, a
// thrown rejection) falls through to the existing full-wait path
// below rather than skip the wait on a failure.
if (isTierAwareCascadeEnabled()) {
try {
const snapshots = (await lspService.getCapabilitySnapshots?.(neighborPath)) ?? [];
const tier = classifyCascadeWaitTier(lspService, neighborPath, snapshots);
if (tier === "tier3-silent") {
const spawnedForTouch = await lspService.getClientForFile(neighborPath);
if (spawnedForTouch) {
// Sampled BEFORE the touchFile notify: a publish landing
// in the notify→record gap must read as post-touch at
// reconcile time, never be misclassified as pre-touch
// (the reconcile compares this against the client's
// PER-FILE publish timestamp — see cascade-tier.ts).
const touchedAt = Date.now();
await lspService.touchFile(neighborPath, content, {
diagnostics: "none",
collectDiagnostics: false,
silent: true,
source: "cascade",
clientScope: "primary",
});
recordOutstandingCascadeTouch({
filePath: neighborPath,
serverId: spawnedForTouch.client.serverId,
touchedAt,
});
const durationMs = Date.now() - neighborStart;
logCascade({
phase: "cascade_tier3_skip",
filePath,
neighborFile: neighborPath,
durationMs,
lspServerCount: configuredServerCount,
coldSnapshot: isColdSnapshot,
metadata: { serverId: spawnedForTouch.client.serverId },
});
// Deliberately NOT cached as clean/diagnosed — the wait was
// skipped, not resolved, so neither neighborTouchCache nor
// recentlyCleanNeighborCache may treat this as a real answer
// (#240 doctrine). Return undefined: the degraded-fallback
// path below still has a chance to surface a passive/stale
// snapshot, same as any other "no fresh data this touch" case.
return undefined;
}
}
}
catch (tierErr) {
dbg?.(`cascade tier-aware skip attempt failed for ${neighborPath}, falling back to full wait: ${tierErr}`);
}
}
// Open with silent=true (suppresses didChangeWatchedFiles rechecks, C2)
// and collect diagnostics from the same touched clients.
// Cold-snapshot neighbors (autoPropagate LSP, server warm) use a tighter
// 1000ms budget — they should respond quickly; we'd rather return zero
// than block cascade for 2s on a slow open.
const rawDiags = await lspService.touchFile(neighborPath, content, {
diagnostics: "document",
collectDiagnostics: true,
maxClientWaitMs: isColdSnapshot ? 1000 : 2000,
silent: true,
source: "cascade",
clientScope: "all",
});
if (!rawDiags)
return undefined;
const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" });
const durationMs = Date.now() - neighborStart;
// Update cache for this neighbor at the current write sequence
if (writeSeq != null) {
neighborTouchCache.set(cacheKey, {
turnSeq,
writeSeq,
diagnostics: diags,
});
}
if (diags.length === 0) {
recentlyCleanNeighborCache.set(cacheKey, {
turnSeq,
checkedAt: Date.now(),
});
}
else {
recentlyCleanNeighborCache.delete(cacheKey);
}
producedLspData = true;
logCascade({
phase: "neighbor_touch",
filePath,
neighborFile: neighborPath,
diagnosticCount: diags.length,
durationMs,
lspTouched: true,
lspServerCount: configuredServerCount,
coldSnapshot: isColdSnapshot,
});
return {
filePath: neighborPath,
reason: neighborReason(importerSet, callerSet, neighborPath),
diagnostics: diags,
lspTouched: true,
durationMs,
};
}));
const allTouchPaths = [...activePaths, ...coldSnapshotPaths];
for (let i = 0; i < touchResults.length; i++) {
const result = touchResults[i];
const neighborPath = allTouchPaths[i];
if (result.status === "fulfilled") {
if (result.value)
neighbors.push(result.value);
}
else {
// A3: one failed LSP doesn't kill the rest — fall back to passive snapshot
dbg?.(`cascade neighbor touch error for ${neighborPath}: ${result.reason}`);
logCascade({
phase: "neighbor_fallback",
filePath,
neighborFile: neighborPath,
fallbackUsed: true,
error: String(result.reason),
});
const entry = allDiags.get(normalizeMapKey(neighborPath));
const diags = entry && Date.now() - entry.ts < CASCADE_TTL_MS
? convertLspDiagnostics(entry.diags
.filter((d) => d.severity === 1)
.slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" })
: [];
neighbors.push({
filePath: neighborPath,
reason: "fallback",
diagnostics: diags,
lspTouched: false,
});
}
}
}
// CR-3/A2: degraded fallback when no neighbor produced trustworthy LSP data —
// not merely when the graph returned zero neighbors.
if (!producedLspData) {
appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd);
if (neighbors.some((n) => n.reason === "fallback")) {
logCascade({
phase: "neighbor_fallback",
filePath,
fallbackUsed: true,
neighborCount: neighbors.length,
});
}
}
const visibleNeighbors = applyCascadeDeltaBaselines(neighbors);
const formatted = formatCascadeResult(cwd, impact, visibleNeighbors, impact.neighborFiles.length);
const filesWithErrors = visibleNeighbors.filter((n) => n.diagnostics.length > 0).length;
logCascade({
phase: "cascade_result",
filePath,
neighborCount: visibleNeighbors.length,
diagnosticCount: visibleNeighbors.reduce((sum, n) => sum + n.diagnostics.length, 0),
metadata: {
filesWithErrors,
hasOutput: formatted.length > 0,
// Log when cascade ran but found nothing — distinguishes "clean" from "no signal"
noNeighbors: visibleNeighbors.length === 0,
noErrors: visibleNeighbors.length > 0 && filesWithErrors === 0,
neighbors: visibleNeighbors.slice(0, 10).map((n) => ({
file: n.filePath.replace(/\\/g, "/").split("/").slice(-2).join("/"),
diagnostics: n.diagnostics.length,
})),
},
});
const diagCount = visibleNeighbors.reduce((sum, n) => sum + n.diagnostics.length, 0);
cascadeSessionStats.runs += 1;
cascadeSessionStats.diagnosticsSurfaced += diagCount;
cascadeSessionStats.coldSnapshotTouches += coldSnapshotPaths.length;
if (!formatted) {
const skipReason = visibleNeighbors.length === 0 ? "no_neighbors" : "clean";
return {
filePath,
result: undefined,
neighborCount: visibleNeighbors.length,
diagnosticCount: diagCount,
skipReason,
};
}
getDiagnosticTracker().trackShown(visibleNeighbors.flatMap((n) => n.diagnostics));
return {
filePath,
result: { filePath, impact, neighbors: visibleNeighbors, formatted },
neighborCount: visibleNeighbors.length,
diagnosticCount: diagCount,
};
}
function diagnosticDeltaKey(diagnostic) {
return [
diagnostic.id,
diagnostic.rule ?? "",
diagnostic.line ?? 0,
diagnostic.column ?? 0,
diagnostic.message,
].join(":");
}
function applyCascadeDeltaBaselines(neighbors) {
return neighbors.map((neighbor) => {
const baselineKey = `session.baseline.cascade.${normalizeMapKey(neighbor.filePath)}`;
const previous = cascadeDiagnosticBaselines.get(baselineKey) ??
sessionFacts.getSessionFact(baselineKey);
cascadeDiagnosticBaselines.set(baselineKey, [...neighbor.diagnostics]);
sessionFacts.setSessionFact(baselineKey, [...neighbor.diagnostics]);
if (!previous)
return neighbor;
const before = new Set(previous.map(diagnosticDeltaKey));
return {
...neighbor,
diagnostics: neighbor.diagnostics.filter((diagnostic) => !before.has(diagnosticDeltaKey(diagnostic))),
};
});
}
function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
const now = Date.now();
const seen = new Set(neighbors.map((n) => normalizeMapKey(n.filePath)));
for (const [diagPath, { diags, ts }] of allDiags) {
const diagKey = normalizeMapKey(diagPath);
if (diagKey === normalizedFileKey || seen.has(diagKey))
continue;
if (primaryFilesThisTurn.has(diagKey))
continue;
if (isExternalOrVendorFile(diagPath, cwd))
continue;
if (isIgnoredCascadeNeighbor(diagPath, cwd))
continue;
if (!nodeFs.existsSync(diagPath))
continue;
if (now - ts > CASCADE_TTL_MS)
continue;
const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath, { source: "cascade" });
if (errors.length === 0)
continue;
neighbors.push({
filePath: diagPath,
reason: "fallback",
diagnostics: errors,
lspTouched: false,
});
seen.add(diagKey);
if (neighbors.length >= MAX_FILES)
break;
}
}
function shouldReadCascadeFromSnapshot(filePath) {
return getServersForFileWithConfig(filePath).some((server) => server.autoPropagateDiagnostics === true);
}
function neighborReason(importerSet, callerSet, neighborPath) {
if (importerSet.has(neighborPath))
return "imports";
if (callerSet.has(neighborPath))
return "calls";
return "references";
}
function formatCascadeResult(cwd, impact, neighbors, totalNeighbors) {
const diagnosticsBlock = formatCascadeNeighborDiagnostics(cwd, neighbors, {
noun: "neighbor",
includeReason: true,
});
if (!diagnosticsBlock)
return "";
const impactHeader = formatImpactCascade(impact, RUNTIME_CONFIG.pipeline.cascadeMaxFiles);
let out = impactHeader
? `${impactHeader}\n${diagnosticsBlock}`
: diagnosticsBlock;
// A10: include truncated filenames so agent knows which files were cut
const truncated = totalNeighbors - neighbors.length;
if (truncated > 0) {
const truncatedNames = impact.neighborFiles
.slice(neighbors.length, neighbors.length + 3)
.map((p) => toRunnerDisplayPath(cwd, p))
.join(", ");
const moreLabel = truncatedNames
? `${truncated} more dependent file(s): ${truncatedNames}`
: `${truncated} more dependent file(s)`;
out += `\n... and ${moreLabel}`;
}
return out;
}
/**
* Run linting for a file using the declarative dispatch system
*
* @param filePath - Path to the file to lint
* @param cwd - Project root directory
* @param pi - Pi agent API (for flags)
* @returns Output string to display to user
*/
export async function dispatchLint(filePath, cwd, pi, modifiedRanges) {
// By default, only run BLOCKING rules for fast feedback on file write
// Uses persistent sessionBaselines so delta mode actually filters
// pre-existing issues after the first write.
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, true, modifiedRanges);
sessionFacts.clearFileFactsFor(ctx.filePath);
const kind = ctx.kind;
if (!kind)
return "";
const groups = withSpotbugsGroup(kind, getDispatchGroupsForKind(kind, pi), ctx);
if (groups.length === 0)
return "";
await runProviders(ctx);
const result = await dispatchForFile(ctx, groups, sessionRunnerRegistry);
trackSessionSlopStats(ctx, result.diagnostics);
return result.output;
}
/**
* Run linting and return full result (including diagnostics)
*/
export async function dispatchLintWithResult(filePath, cwd, pi, modifiedRanges, logContext, options) {
// Default true preserves the per-edit fast path (errors only). Callers that
// want the full picture (warnings + structural smells), e.g. the MCP review
// facade, pass blockingOnly=false to run every runner.
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? true, modifiedRanges);
sessionFacts.clearFileFactsFor(ctx.filePath);
const kind = ctx.kind;
if (!kind) {
return {
diagnostics: [],
blockers: [],
warnings: [],
baselineWarningCount: 0,
fixed: [],
resolvedCount: 0,
output: "",
blockerOutput: "",
hasBlockers: false,
};
}
const groups = withSpotbugsGroup(kind, getDispatchGroupsForKind(kind, pi), ctx);
if (groups.length === 0) {
return {
diagnostics: [],
blockers: [],
warnings: [],
baselineWarningCount: 0,
fixed: [],
resolvedCount: 0,
output: "",
blockerOutput: "",
hasBlockers: false,
};
}
await runProviders(ctx);
const result = await dispatchForFile(ctx, groups, sessionRunnerRegistry);
trackSessionSlopStats(ctx, result.diagnostics);
// Schedule debounced ast-grep warning scan for jsts files.
// Runs 2s after the last write — collapses rapid sequential edits into one scan.
// Results are logged only, never surfaced to the agent.
if (kind === "jsts" && logContext) {
scheduleAstGrepWarningScan(filePath, cwd, pi, logContext);
}
return result;
}
/**
* Same real dispatch path as {@link dispatchLintWithResult} (real context, real
* file-kind→runner selection, real `run()` → spawn → tool), but also returns
* each runner's exact `RunnerResult` (status + `failureKind` + diagnostics) via
* the `onRunnerResult` sink. The live tool-smoke harness (#209) uses this to
* assert each supported tool spawned and exited cleanly without re-implementing
* dispatch's selection/gating. Defaults to `blockingOnly: false` so every
* applicable runner (not just blocking ones) executes.
*/
export async function dispatchLintDetailed(filePath, cwd, pi, options) {
const empty = {
diagnostics: [],
blockers: [],
warnings: [],
baselineWarningCount: 0,
fixed: [],
resolvedCount: 0,
output: "",
blockerOutput: "",
hasBlockers: false,
};
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? false, options?.modifiedRanges);
sessionFacts.clearFileFactsFor(ctx.filePath);
const kind = ctx.kind;
if (!kind)
return { result: empty, runners: [] };
const groups = withSpotbugsGroup(kind, getDispatchGroupsForKind(kind, pi), ctx);
if (groups.length === 0)
return { result: empty, runners: [] };
const runners = [];
const sink = (runnerId, result) => {
runners.push({ runnerId, result });
};
await runProviders(ctx);
const result = await dispatchForFile(ctx, groups, sessionRunnerRegistry, sink);
trackSessionSlopStats(ctx, result.diagnostics);
return { result, runners };
}
/**
* Check if a file should be processed by the dispatcher
* based on the file kind
*/
export function shouldDispatch(filePath) {
const kind = detectFileKind(filePath);
return kind !== undefined;
}
/**
* Get list of available runners for a file
*/
export async function getAvailableRunners(filePath) {
const kind = detectFileKind(filePath);
if (!kind)
return [];
const normalizedPath = filePath.replace(/\\/g, "/");
const pathForFilter = normalizedPath.startsWith("/")
? normalizedPath
: `/${normalizedPath}`;
const runners = sessionRunnerRegistry.getForKind(kind, pathForFilter);
return runners.map((r) => r.id);
}