From bc66eddeea0f62a2ed0c5de6684f086b7e56a16e Mon Sep 17 00:00:00 2001
From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com>
Date: Wed, 29 Jul 2026 02:09:39 -0300
Subject: [PATCH 1/4] Add local ESLint checks to Pi code reviews
---
apps/pi-extension/server.test.ts | 95 ++++
apps/pi-extension/server/serverReview.ts | 148 +++++
apps/pi-extension/vendor.sh | 2 +-
packages/review-editor/App.tsx | 77 ++-
.../review-editor/components/FileTree.tsx | 13 +-
.../review-editor/components/PanelNavRows.tsx | 25 +-
.../components/SectionsPanel.tsx | 11 +-
.../review-editor/dock/ReviewStateContext.tsx | 2 +
.../dock/panels/ReviewEslintCheckPanel.tsx | 193 +++++++
.../dock/reviewPanelComponents.ts | 2 +
.../review-editor/dock/reviewPanelTypes.ts | 2 +
packages/server/review-workspace.test.ts | 90 +++
packages/server/review.ts | 148 +++++
packages/shared/eslint-check-types.ts | 56 ++
packages/shared/eslint-check.test.ts | 312 +++++++++++
packages/shared/eslint-check.ts | 530 ++++++++++++++++++
packages/shared/package.json | 2 +
17 files changed, 1700 insertions(+), 8 deletions(-)
create mode 100644 packages/review-editor/dock/panels/ReviewEslintCheckPanel.tsx
create mode 100644 packages/shared/eslint-check-types.ts
create mode 100644 packages/shared/eslint-check.test.ts
create mode 100644 packages/shared/eslint-check.ts
diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts
index b19dea2e2..948d14307 100644
--- a/apps/pi-extension/server.test.ts
+++ b/apps/pi-extension/server.test.ts
@@ -154,6 +154,32 @@ function makeMockSem(dir: string, options: {
return semPath;
}
+function makeMockEslintProject(dir: string): void {
+ mkdirSync(join(dir, "src"), { recursive: true });
+ mkdirSync(join(dir, "node_modules", "eslint", "bin"), { recursive: true });
+ writeFileSync(join(dir, "src", "app.ts"), "export const value = 1;\n", "utf-8");
+ writeFileSync(join(dir, "eslint.config.js"), "export default [];\n", "utf-8");
+ writeFileSync(join(dir, "node_modules", "eslint", "package.json"), JSON.stringify({
+ version: "9.12.0",
+ bin: { eslint: "bin/eslint.cjs" },
+ }), "utf-8");
+ writeFileSync(join(dir, "node_modules", "eslint", "bin", "eslint.cjs"), [
+ 'const { join } = require("node:path");',
+ "process.stdout.write(JSON.stringify([{",
+ ' filePath: join(process.cwd(), "src", "app.ts"),',
+ " messages: [{",
+ ' ruleId: "react-hooks/exhaustive-deps",',
+ " severity: 1,",
+ ' message: "React Hook has a missing dependency.",',
+ " line: 1,",
+ " column: 1,",
+ " }],",
+ "}]));",
+ "process.exitCode = 1;",
+ "",
+ ].join("\n"), "utf-8");
+}
+
function makeBlockingSem(dir: string): { semPath: string; startedPath: string; releasePath: string } {
const semPath = join(dir, "sem-blocking");
const startedPath = join(dir, "started");
@@ -615,6 +641,75 @@ describe("pi review server", () => {
}
}, 10_000);
+ test("advertises and runs the reviewed project's local ESLint for the current snapshot", async () => {
+ const dir = makeTempDir("plannotator-pi-eslint-server-");
+ makeMockEslintProject(dir);
+ git(dir, ["init"]);
+ git(dir, ["branch", "-M", "main"]);
+ git(dir, ["config", "user.email", "pi-review@example.com"]);
+ git(dir, ["config", "user.name", "Pi Review"]);
+ writeFileSync(join(dir, "README.md"), "# Test\n", "utf-8");
+ git(dir, ["add", "README.md"]);
+ git(dir, ["commit", "-m", "initial"]);
+ const gitContext = await getVcsContext(dir, "git");
+ process.env.PLANNOTATOR_PORT = String(await reservePort());
+ const rawPatch = [
+ "diff --git a/src/app.ts b/src/app.ts",
+ "new file mode 100644",
+ "--- /dev/null",
+ "+++ b/src/app.ts",
+ "@@ -0,0 +1 @@",
+ "+export const value = 1;",
+ "",
+ ].join("\n");
+ const server = await startReviewServer({
+ rawPatch,
+ gitRef: "test",
+ diffType: "uncommitted",
+ gitContext,
+ origin: "pi",
+ htmlContent: "
review",
+ });
+
+ try {
+ const diffPayload = await fetch(`${server.url}/api/diff`).then((response) => response.json()) as {
+ snapshotId: string;
+ eslintCheck?: { available: boolean; fileCount?: number; projectCount?: number };
+ };
+ expect(diffPayload.eslintCheck).toEqual({ available: true, fileCount: 1, projectCount: 1 });
+
+ const response = await fetch(`${server.url}/api/eslint-check`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ snapshotId: diffPayload.snapshotId }),
+ });
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toMatchObject({
+ status: "ok",
+ eslintVersions: ["9.12.0"],
+ summary: { errors: 0, warnings: 1, changedLineWarnings: 1 },
+ diagnostics: [{ filePath: "src/app.ts", line: 1, onChangedLine: true }],
+ });
+
+ writeFileSync(join(dir, "src", "app.ts"), "export const value = 2;\n", "utf-8");
+ const changedResponse = await fetch(`${server.url}/api/eslint-check`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ snapshotId: diffPayload.snapshotId }),
+ });
+ expect(changedResponse.status).toBe(409);
+
+ const staleResponse = await fetch(`${server.url}/api/eslint-check`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ snapshotId: "stale" }),
+ });
+ expect(staleResponse.status).toBe(409);
+ } finally {
+ server.stop();
+ }
+ });
+
test("advertises semantic diff availability and serves parsed sem output", async () => {
const dir = makeTempDir("plannotator-pi-sem-server-");
const dataDir = makeTempDir("plannotator-pi-sem-data-");
diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts
index dfe121567..ec7d9da69 100644
--- a/apps/pi-extension/server/serverReview.ts
+++ b/apps/pi-extension/server/serverReview.ts
@@ -154,6 +154,13 @@ import {
SemanticDiffResponseCache,
} from "../generated/semantic-diff.js";
import type { SemanticDiffAvailability, SemanticDiffResponse } from "../generated/semantic-diff-types.js";
+import {
+ buildEslintCheckInput,
+ getEslintCheckAvailability,
+ isEslintCheckCompatibleReviewView,
+ runEslintCheck,
+} from "../generated/eslint-check.js";
+import type { EslintCheckAdvert, EslintCheckResponse } from "../generated/eslint-check-types.js";
import { discoverCuratedSkills, resolveRequestedReviewProfile, listAllSkills, enableReviewSkill } from "../generated/review-skill-loader.js";
import {
BUILTIN_DEFAULT_PROFILE,
@@ -843,6 +850,136 @@ export async function startReviewServer(options: {
return result;
}
+ function eslintCheckCompatibleView(): boolean {
+ return isEslintCheckCompatibleReviewView({
+ isPRMode,
+ isWorkspaceMode: !!workspace,
+ diffType: currentDiffType as string,
+ });
+ }
+
+ function eslintCheckUnavailableReason(): string {
+ if (isPRMode) return "pr-review-unsupported";
+ return eslintCheckCompatibleView() ? "local-checkout-unavailable" : "snapshot-not-working-tree";
+ }
+
+ function resolveEslintCheckInput() {
+ if (!eslintCheckCompatibleView()) return null;
+ const cwd = workspace?.root
+ ?? (isPRMode
+ ? resolvePRLocalCwd()
+ : resolveAgentCwd());
+ if (!cwd) return null;
+ return buildEslintCheckInput(
+ currentPatch,
+ cwd,
+ workspace?.repos.map((repo) => ({ label: repo.label, cwd: repo.cwd })),
+ );
+ }
+
+ function getEslintCheckAdvert(): EslintCheckAdvert {
+ const input = resolveEslintCheckInput();
+ if (!input) {
+ return {
+ available: false,
+ reason: eslintCheckUnavailableReason(),
+ };
+ }
+ return getEslintCheckAvailability(input);
+ }
+
+ interface EslintCheckBaseline {
+ snapshotId: string;
+ fingerprintGeneration: number;
+ fingerprint: string | null;
+ }
+
+ async function resolveEslintCheckBaseline(
+ requestedSnapshotId: string | undefined,
+ ): Promise {
+ if (requestedSnapshotId !== currentSnapshotId()) return null;
+ const baselineGeneration = fingerprintGeneration;
+ let baseline = currentFingerprint;
+ const pendingCapture = pendingFingerprintCapture;
+ if (baseline == null && pendingCapture) {
+ baseline = await pendingCapture;
+ }
+ if (
+ requestedSnapshotId !== currentSnapshotId()
+ || baselineGeneration !== fingerprintGeneration
+ ) {
+ return null;
+ }
+ if (baseline != null) {
+ const probe = await fileContentFingerprintProbes.run(
+ `${requestedSnapshotId}:${baselineGeneration}`,
+ computeDiffFingerprint,
+ );
+ if (
+ requestedSnapshotId !== currentSnapshotId()
+ || currentFingerprint !== baseline
+ || (probe != null && probe !== baseline)
+ ) {
+ return null;
+ }
+ }
+ return {
+ snapshotId: requestedSnapshotId,
+ fingerprintGeneration: baselineGeneration,
+ fingerprint: baseline,
+ };
+ }
+
+ function sameEslintCheckBaseline(
+ left: EslintCheckBaseline,
+ right: EslintCheckBaseline,
+ ): boolean {
+ return left.snapshotId === right.snapshotId
+ && left.fingerprintGeneration === right.fingerprintGeneration
+ && left.fingerprint === right.fingerprint;
+ }
+
+ let eslintCheckCache: {
+ baseline: EslintCheckBaseline;
+ response: EslintCheckResponse;
+ } | null = null;
+ async function getEslintCheck(requestedSnapshotId: string | undefined): Promise {
+ const baseline = await resolveEslintCheckBaseline(requestedSnapshotId);
+ if (!baseline) {
+ return {
+ status: "error",
+ reason: "stale-snapshot",
+ message: "The reviewed diff changed before ESLint started. Run the check again.",
+ };
+ }
+ if (eslintCheckCache && sameEslintCheckBaseline(eslintCheckCache.baseline, baseline)) {
+ return eslintCheckCache.response;
+ }
+ const input = resolveEslintCheckInput();
+ if (!input) {
+ return {
+ status: "unavailable",
+ reason: eslintCheckUnavailableReason(),
+ message: isPRMode
+ ? "ESLint is currently available only for local code reviews."
+ : eslintCheckCompatibleView()
+ ? "ESLint requires a local checkout of the code under review."
+ : "ESLint is available only when the review's new side is the current working tree.",
+ };
+ }
+ const response = await runEslintCheck(input);
+ const completedBaseline = await resolveEslintCheckBaseline(requestedSnapshotId);
+ if (!completedBaseline || !sameEslintCheckBaseline(baseline, completedBaseline)) {
+ return {
+ status: "error",
+ reason: "stale-snapshot",
+ message: "The reviewed diff changed while ESLint was running. Run the check again.",
+ };
+ }
+ if (response.status === "ok") eslintCheckCache = { baseline, response };
+ return response;
+ }
+
const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => serverUrl,
@@ -1498,6 +1635,7 @@ export async function startReviewServer(options: {
...(baseBehindRemote && { baseBehindRemote: true }),
...(servedError && { error: servedError }),
semanticDiff: await getSemanticDiffAdvert(servedDiffType as DiffType),
+ eslintCheck: getEslintCheckAdvert(),
serverConfig: getServerConfig(gitUser),
});
} else if (url.pathname === "/api/fetch-base" && req.method === "POST") {
@@ -1581,6 +1719,11 @@ export async function startReviewServer(options: {
});
} else if (url.pathname === "/api/semantic-diff" && req.method === "GET") {
json(res, await getSemanticDiff(url));
+ } else if (url.pathname === "/api/eslint-check" && req.method === "POST") {
+ const body = await parseBody(req) as { snapshotId?: unknown };
+ const requestedSnapshotId = typeof body.snapshotId === "string" ? body.snapshotId : undefined;
+ const result = await getEslintCheck(requestedSnapshotId);
+ json(res, result, result.status === "error" && result.reason === "stale-snapshot" ? 409 : 200);
} else if (url.pathname === "/api/commits" && req.method === "GET") {
// Linear commit history for the Commits panel (mirrors Bun review.ts).
// Git-local sessions only — PR/workspace/jj/p4 don't offer the view.
@@ -1662,6 +1805,7 @@ export async function startReviewServer(options: {
hideWhitespace: currentHideWhitespace,
...(currentError ? { error: currentError } : {}),
semanticDiff: await getSemanticDiffAdvert(),
+ eslintCheck: getEslintCheckAdvert(),
});
return;
}
@@ -1776,6 +1920,7 @@ export async function startReviewServer(options: {
...(updatedContext ? { gitContext: updatedContext } : {}),
...(currentError ? { error: currentError } : {}),
semanticDiff: switchSemanticDiff,
+ eslintCheck: getEslintCheckAdvert(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to switch diff";
@@ -1872,6 +2017,7 @@ export async function startReviewServer(options: {
...(layerPatchIncomplete ? { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable } : {}),
...((currentError ?? upgradeError) ? { error: currentError ?? upgradeError } : {}),
semanticDiff: await getSemanticDiffAdvert(),
+ eslintCheck: getEslintCheckAdvert(),
});
return;
}
@@ -1908,6 +2054,7 @@ export async function startReviewServer(options: {
snapshotId: currentSnapshotId(),
prDiffScope: currentPRDiffScope,
semanticDiff: await getSemanticDiffAdvert(),
+ eslintCheck: getEslintCheckAdvert(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to switch PR diff scope";
@@ -2002,6 +2149,7 @@ export async function startReviewServer(options: {
...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }),
...(currentError ? { error: currentError } : {}),
semanticDiff: await getSemanticDiffAdvert(),
+ eslintCheck: getEslintCheckAdvert(),
});
} catch (err) {
return json(res, { error: err instanceof Error ? err.message : "Failed to switch PR" }, 500);
diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh
index 696c5c338..cae12b2fa 100755
--- a/apps/pi-extension/vendor.sh
+++ b/apps/pi-extension/vendor.sh
@@ -29,7 +29,7 @@ for f in config-types storage-types workspace-status-types; do
done
# Everything else in the original flat list stays sourced from packages/shared.
-for f in prompts review-core diff-paths cli-pagination jj-core gitbutler-core vcs-core review-args draft pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff single-flight source-save-node review-profiles guide commit-avatars commit-history port-range; do
+for f in prompts review-core diff-paths cli-pagination jj-core gitbutler-core vcs-core review-args draft pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff eslint-check-types eslint-check single-flight source-save-node review-profiles guide commit-avatars commit-history port-range; do
src="../../packages/shared/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
done
diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx
index b46109b79..cf19aa459 100644
--- a/packages/review-editor/App.tsx
+++ b/packages/review-editor/App.tsx
@@ -15,6 +15,7 @@ import { RepoIcon } from '@plannotator/ui/components/RepoIcon';
import { PullRequestIcon } from '@plannotator/ui/components/PullRequestIcon';
import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from '@plannotator/shared/pr-types';
import type { SemanticDiffAdvert } from '@plannotator/shared/semantic-diff-types';
+import type { EslintCheckAdvert } from '@plannotator/shared/eslint-check-types';
import { configStore, useConfigValue, setReviewPanelView } from '@plannotator/ui/config';
import { loadDiffFont } from '@plannotator/ui/utils/diffFonts';
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
@@ -82,6 +83,7 @@ import {
REVIEW_PR_OVERVIEW_PANEL_ID,
REVIEW_PR_ARTIFACTS_PANEL_ID,
REVIEW_SEMANTIC_DIFF_PANEL_ID,
+ REVIEW_ESLINT_CHECK_PANEL_ID,
REVIEW_ALL_FILES_PANEL_ID,
REVIEW_CODE_NAV_PANEL_ID,
} from './dock/reviewPanelTypes';
@@ -124,6 +126,7 @@ interface DiffData {
prDiffScope?: PRDiffScope;
prDiffScopeOptions?: PRDiffScopeOption[];
semanticDiff?: SemanticDiffAdvert;
+ eslintCheck?: EslintCheckAdvert;
}
function getFileTabTitle(filePath: string): string {
@@ -199,9 +202,13 @@ const ReviewApp: React.FC = () => {
const isAllFilesActiveRef = useRef(isAllFilesActive);
isAllFilesActiveRef.current = isAllFilesActive;
const [isSemanticDiffActive, setIsSemanticDiffActive] = useState(false);
+ const [isEslintCheckActive, setIsEslintCheckActive] = useState(false);
const [isPROverviewActive, setIsPROverviewActive] = useState(false);
const [isPRArtifactsActive, setIsPRArtifactsActive] = useState(false);
const [semanticDiffAvailable, setSemanticDiffAvailable] = useState(false);
+ const [eslintCheckAdvert, setEslintCheckAdvert] = useState({ available: false });
+ const [showEslintConsent, setShowEslintConsent] = useState(false);
+ const eslintConsentGranted = useRef(false);
const [isDiffPanelActive, setIsDiffPanelActive] = useState(false);
const [allFilesVisibleFile, setAllFilesVisibleFile] = useState(null);
const [pendingSelection, setPendingSelection] = useState(null);
@@ -837,6 +844,7 @@ const ReviewApp: React.FC = () => {
if (!panel) {
setIsAllFilesActive(false);
setIsSemanticDiffActive(false);
+ setIsEslintCheckActive(false);
setIsPROverviewActive(false);
setIsPRArtifactsActive(false);
setIsDiffPanelActive(false);
@@ -844,6 +852,7 @@ const ReviewApp: React.FC = () => {
}
setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_PANEL_ID);
setIsSemanticDiffActive(panel.id === REVIEW_SEMANTIC_DIFF_PANEL_ID);
+ setIsEslintCheckActive(panel.id === REVIEW_ESLINT_CHECK_PANEL_ID);
setIsPROverviewActive(panel.id === REVIEW_PR_OVERVIEW_PANEL_ID);
setIsPRArtifactsActive(panel.id === REVIEW_PR_ARTIFACTS_PANEL_ID);
setIsDiffPanelActive(isReviewDiffPanelId(panel.id));
@@ -1146,6 +1155,37 @@ const ReviewApp: React.FC = () => {
}
}, [dockApi, isSemanticDiffActive, openAllFilesPanel]);
+ const openEslintCheckPanel = useCallback(() => {
+ if (!dockApi || !eslintCheckAdvert.available) return;
+ const existing = dockApi.getPanel(REVIEW_ESLINT_CHECK_PANEL_ID);
+ if (existing) {
+ existing.api.setActive();
+ return;
+ }
+ dockApi.addPanel({
+ id: REVIEW_ESLINT_CHECK_PANEL_ID,
+ component: REVIEW_PANEL_TYPES.ESLINT_CHECK,
+ title: 'ESLint',
+ });
+ }, [dockApi, eslintCheckAdvert.available]);
+
+ const requestEslintCheck = useCallback(() => {
+ if (eslintConsentGranted.current) {
+ openEslintCheckPanel();
+ return;
+ }
+ setShowEslintConsent(true);
+ }, [openEslintCheckPanel]);
+
+ const applyEslintCheckAdvert = useCallback((advert?: EslintCheckAdvert) => {
+ if (!advert) return;
+ setEslintCheckAdvert(advert);
+ if (!advert.available) {
+ dockApi?.getPanel(REVIEW_ESLINT_CHECK_PANEL_ID)?.api.close();
+ if (isEslintCheckActive) openAllFilesPanel();
+ }
+ }, [dockApi, isEslintCheckActive, openAllFilesPanel]);
+
// Open the All files overview on first load. Semantic diff stays available via
// the file-tree nav entry, but it's no longer the default landing view.
useEffect(() => {
@@ -1258,6 +1298,7 @@ const ReviewApp: React.FC = () => {
error?: string;
isWSL?: boolean;
semanticDiff?: SemanticDiffAdvert;
+ eslintCheck?: EslintCheckAdvert;
sections?: SinceBaseSections;
commitInfo?: CommitDiffInfo;
baseBehindRemote?: boolean;
@@ -1317,6 +1358,7 @@ const ReviewApp: React.FC = () => {
if (data.error) setDiffError(data.error);
if (data.isWSL) setIsWSL(true);
setSemanticDiffAvailable(data.semanticDiff?.available === true);
+ setEslintCheckAdvert(data.eslintCheck ?? { available: false });
setSections(data.sections ?? null);
setCommitInfo(data.commitInfo ?? null);
setBaseBehindRemote(data.baseBehindRemote === true);
@@ -1649,6 +1691,7 @@ const ReviewApp: React.FC = () => {
repoInfo?: { display: string; branch?: string };
viewedFiles?: string[]; error?: string;
semanticDiff?: SemanticDiffAdvert;
+ eslintCheck?: EslintCheckAdvert;
agentCwd?: string | null;
}) {
const isPRSwitch = !!data.prMetadata;
@@ -1683,6 +1726,7 @@ const ReviewApp: React.FC = () => {
}
setDiffError(data.error || null);
applySemanticDiffAdvert(data.semanticDiff);
+ applyEslintCheckAdvert(data.eslintCheck);
// The PR's local checkout changes on switch (and warms in later). Use the
// server's value when present; otherwise clear it on a switch so the Open-in
// button can't keep pointing at the previous PR's checkout (the 5s freshness
@@ -1736,6 +1780,7 @@ const ReviewApp: React.FC = () => {
diffOptions?: DiffOption[];
error?: string;
semanticDiff?: SemanticDiffAdvert;
+ eslintCheck?: EslintCheckAdvert;
sections?: SinceBaseSections;
commitInfo?: CommitDiffInfo;
baseBehindRemote?: boolean;
@@ -1749,6 +1794,7 @@ const ReviewApp: React.FC = () => {
const nextFiles = orderFilesBySections(parseDiffToFiles(data.rawPatch), data.sections);
applySemanticDiffAdvert(data.semanticDiff);
+ applyEslintCheckAdvert(data.eslintCheck);
setSections(data.sections ?? null);
setCommitInfo(data.commitInfo ?? null);
setBaseBehindRemote(data.baseBehindRemote === true);
@@ -1828,7 +1874,7 @@ const ReviewApp: React.FC = () => {
} finally {
setIsLoadingDiff(false);
}
- }, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile, applySemanticDiffAdvert]);
+ }, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile, applySemanticDiffAdvert, applyEslintCheckAdvert]);
// Switch the base branch the current diff compares against.
// Only triggers a refetch when the active mode actually uses a base.
@@ -2357,6 +2403,8 @@ const ReviewApp: React.FC = () => {
onSemanticDiffUnavailable: handleSemanticDiffUnavailable,
onSemanticDiffLoadError: handleSemanticDiffLoadError,
onSemanticDiffLoadSuccess: handleSemanticDiffLoadSuccess,
+ snapshotId: snapshotId ?? null,
+ eslintCheckAvailable: eslintCheckAdvert.available,
openTourPanel: handleOpenTour,
openGuide: handleOpenGuide,
onCodeNavRequest: canUseLiveWorkspaceActions ? handleCodeNavRequest : undefined,
@@ -2382,7 +2430,7 @@ const ReviewApp: React.FC = () => {
handleAskAI, handleAskAIForFile, handleViewAIResponse, handleClickAIMarker,
aiHistoryForSelection, getAIHistoryForFile, agentJobs.jobs, prMetadata, prContext, prArtifacts,
isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile,
- handleOpenTour, handleOpenGuide, isAllFilesActive, allFilesOrder, allFilesAllCollapsed, onToggleAllFilesCollapsed, registerAllFilesCollapseToggle, commitInfo, isSemanticDiffActive, semanticDiffAvailable,
+ handleOpenTour, handleOpenGuide, isAllFilesActive, allFilesOrder, allFilesAllCollapsed, onToggleAllFilesCollapsed, registerAllFilesCollapseToggle, commitInfo, isSemanticDiffActive, semanticDiffAvailable, snapshotId, eslintCheckAdvert.available,
handleSemanticDiffUnavailable, handleSemanticDiffLoadError, handleSemanticDiffLoadSuccess, handleAddAnnotationForFile,
handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol,
]);
@@ -3205,7 +3253,7 @@ const ReviewApp: React.FC = () => {
files={files}
sections={sections!}
width={fileTreeResize.width}
- activeFileIndex={isAllFilesActive || isSemanticDiffActive || isPROverviewActive ? -1 : activeFileIndex}
+ activeFileIndex={isAllFilesActive || isSemanticDiffActive || isEslintCheckActive || isPROverviewActive ? -1 : activeFileIndex}
scrollHighlightIndex={isAllFilesActive && allFilesVisibleFile ? files.findIndex(f => f.path === allFilesVisibleFile) : undefined}
onSelectFile={handleFilePreview}
onDoubleClickFile={handleFilePinned}
@@ -3233,6 +3281,9 @@ const ReviewApp: React.FC = () => {
onSelectSemanticDiff={() => openSemanticDiffPanel()}
isSemanticDiffActive={isSemanticDiffActive}
semanticDiffAvailable={semanticDiffAvailable}
+ onSelectEslintCheck={eslintCheckAdvert.available ? requestEslintCheck : undefined}
+ isEslintCheckActive={isEslintCheckActive}
+ eslintCheckFileCount={eslintCheckAdvert.fileCount}
onCopyRawDiff={handleCopyDiff}
canCopyRawDiff={!!diffData?.rawPatch}
copyRawDiffStatus={copyRawDiffStatus}
@@ -3288,6 +3339,9 @@ const ReviewApp: React.FC = () => {
onSelectSemanticDiff={() => openSemanticDiffPanel()}
isSemanticDiffActive={isSemanticDiffActive}
semanticDiffAvailable={semanticDiffAvailable}
+ onSelectEslintCheck={eslintCheckAdvert.available ? requestEslintCheck : undefined}
+ isEslintCheckActive={isEslintCheckActive}
+ eslintCheckFileCount={eslintCheckAdvert.fileCount}
onSelectAllFiles={openAllFilesPanel}
isAllFilesActive={isAllFilesActive}
scrollHighlightIndex={isAllFilesActive && allFilesVisibleFile ? files.findIndex(f => f.path === allFilesVisibleFile) : undefined}
@@ -3580,6 +3634,23 @@ const ReviewApp: React.FC = () => {
/>
+ setShowEslintConsent(false)}
+ onConfirm={() => {
+ eslintConsentGranted.current = true;
+ setShowEslintConsent(false);
+ openEslintCheckPanel();
+ }}
+ title="Run Project ESLint?"
+ message="This runs the reviewed project's local ESLint configuration and plugins with your user permissions."
+ subMessage="Plannotator checks only supported files in this review, runs ESLint without --fix, and does not install packages or invoke package scripts. Project configuration and plugins are executable code and may still modify files or perform other actions with your user permissions."
+ confirmText="Run ESLint"
+ cancelText="Cancel"
+ variant="warning"
+ showCancel
+ />
+
{/* Worktree info dialog */}
{(gitContext?.cwd || agentCwd) && prMetadata && (
void;
isSemanticDiffActive?: boolean;
semanticDiffAvailable?: boolean;
+ onSelectEslintCheck?: () => void;
+ isEslintCheckActive?: boolean;
+ eslintCheckFileCount?: number;
onSelectAllFiles?: () => void;
isAllFilesActive?: boolean;
scrollHighlightIndex?: number;
@@ -152,6 +155,9 @@ export const FileTree: React.FC = ({
onSelectSemanticDiff,
isSemanticDiffActive = false,
semanticDiffAvailable = false,
+ onSelectEslintCheck,
+ isEslintCheckActive = false,
+ eslintCheckFileCount,
onSelectAllFiles,
isAllFilesActive = false,
scrollHighlightIndex,
@@ -544,6 +550,9 @@ export const FileTree: React.FC = ({
{semanticDiffAvailable && onSelectSemanticDiff && (
)}
+ {onSelectEslintCheck && (
+
+ )}
{onSelectAllFiles && (
= ({
node={node}
expandedFolders={expandedFolders}
onToggleFolder={handleToggleFolder}
- activeFileIndex={isAllFilesActive || isSemanticDiffActive || isPROverviewActive || isPRArtifactsActive ? -1 : activeFileIndex}
+ activeFileIndex={isAllFilesActive || isSemanticDiffActive || isEslintCheckActive || isPROverviewActive || isPRArtifactsActive ? -1 : activeFileIndex}
scrollHighlightIndex={isAllFilesActive ? scrollHighlightIndex : undefined}
onSelectFile={onSelectFile}
onDoubleClickFile={onDoubleClickFile}
diff --git a/packages/review-editor/components/PanelNavRows.tsx b/packages/review-editor/components/PanelNavRows.tsx
index cb7628086..70f5f6694 100644
--- a/packages/review-editor/components/PanelNavRows.tsx
+++ b/packages/review-editor/components/PanelNavRows.tsx
@@ -42,6 +42,30 @@ export function SemanticDiffRow({ active, onClick }: { active: boolean; onClick:
);
}
+export function EslintCheckRow({
+ active,
+ onClick,
+ fileCount,
+}: {
+ active: boolean;
+ onClick: () => void;
+ fileCount?: number;
+}) {
+ return (
+
+ ES
+ ESLint
+ {fileCount !== undefined && (
+ {fileCount}
+ )}
+
+ );
+}
+
export function AllFilesRow({
active,
onClick,
@@ -71,4 +95,3 @@ export function AllFilesRow({
);
}
-
diff --git a/packages/review-editor/components/SectionsPanel.tsx b/packages/review-editor/components/SectionsPanel.tsx
index 511084dc2..d33e6dfd8 100644
--- a/packages/review-editor/components/SectionsPanel.tsx
+++ b/packages/review-editor/components/SectionsPanel.tsx
@@ -3,7 +3,7 @@ import { CodeAnnotation } from '@plannotator/ui/types';
import type { AvailableBranches, CompareTargetConfig, RecentCommit, SinceBaseSections } from '@plannotator/shared/types';
import { BaseBranchPicker } from './BaseBranchPicker';
import { PanelViewToggle } from './PanelViewToggle';
-import { SemanticDiffRow, AllFilesRow } from './PanelNavRows';
+import { SemanticDiffRow, EslintCheckRow, AllFilesRow } from './PanelNavRows';
import { ViewedControl, ChangeTypeLetter, StageControl, AnnotationBadge, DiffCounts, CommittedDot, TruncatedPath } from './FileRowBits';
import { SearchFileGroup } from './FileTree';
import type { ReviewSearchFileGroup, ReviewSearchMatch } from '../utils/reviewSearch';
@@ -73,6 +73,9 @@ interface SectionsPanelProps {
onSelectSemanticDiff?: () => void;
isSemanticDiffActive?: boolean;
semanticDiffAvailable?: boolean;
+ onSelectEslintCheck?: () => void;
+ isEslintCheckActive?: boolean;
+ eslintCheckFileCount?: number;
/** Footer copy-diffs. */
onCopyRawDiff?: () => void;
canCopyRawDiff?: boolean;
@@ -189,6 +192,9 @@ export const SectionsPanel: React.FC = ({
onSelectSemanticDiff,
isSemanticDiffActive,
semanticDiffAvailable,
+ onSelectEslintCheck,
+ isEslintCheckActive,
+ eslintCheckFileCount,
onCopyRawDiff,
canCopyRawDiff,
copyRawDiffStatus = 'idle',
@@ -570,6 +576,9 @@ export const SectionsPanel: React.FC = ({
{semanticDiffAvailable && onSelectSemanticDiff && (
)}
+ {onSelectEslintCheck && (
+
+ )}
{onSelectAllFiles && (
void;
onSemanticDiffLoadError: () => boolean;
onSemanticDiffLoadSuccess: () => void;
+ snapshotId: string | null;
+ eslintCheckAvailable: boolean;
// Tour
openTourPanel: (jobId: string) => void;
diff --git a/packages/review-editor/dock/panels/ReviewEslintCheckPanel.tsx b/packages/review-editor/dock/panels/ReviewEslintCheckPanel.tsx
new file mode 100644
index 000000000..aa3a26313
--- /dev/null
+++ b/packages/review-editor/dock/panels/ReviewEslintCheckPanel.tsx
@@ -0,0 +1,193 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import type {
+ EslintCheckOkResponse,
+ EslintCheckResponse,
+ EslintDiagnostic,
+} from '@plannotator/shared/eslint-check-types';
+import { useReviewState } from '../ReviewStateContext';
+
+type LoadState =
+ | { status: 'loading' }
+ | { status: 'ready'; data: EslintCheckOkResponse }
+ | { status: 'error'; message: string };
+
+function DiagnosticRow({ diagnostic, onOpen }: { diagnostic: EslintDiagnostic; onOpen: () => void }) {
+ const isError = diagnostic.severity === 2;
+ const severityClass = isError
+ ? 'bg-destructive/15 text-destructive'
+ : 'bg-warning/15 text-warning';
+ const severityLabel = isError ? 'Error' : 'Warning';
+ return (
+
+ );
+}
+
+export function ReviewEslintCheckPanel() {
+ const {
+ snapshotId,
+ eslintCheckAvailable,
+ openDiffFile,
+ onLineSelection,
+ } = useReviewState();
+ const [loadState, setLoadState] = useState({ status: 'loading' });
+ const [retryCount, setRetryCount] = useState(0);
+ const [showAll, setShowAll] = useState(false);
+
+ useEffect(() => {
+ if (!eslintCheckAvailable || !snapshotId) {
+ setLoadState({ status: 'error', message: 'ESLint is unavailable for this review snapshot.' });
+ return;
+ }
+ const controller = new AbortController();
+ setLoadState({ status: 'loading' });
+ fetch('/api/eslint-check', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ snapshotId }),
+ signal: controller.signal,
+ })
+ .then(async (response) => {
+ const data = await response.json() as EslintCheckResponse;
+ if (data.status === 'ok') return data;
+ throw new Error(data.message);
+ })
+ .then((data) => {
+ if (!controller.signal.aborted) setLoadState({ status: 'ready', data });
+ })
+ .catch((error) => {
+ if (controller.signal.aborted) return;
+ setLoadState({ status: 'error', message: error instanceof Error ? error.message : String(error) });
+ });
+ return () => controller.abort();
+ }, [eslintCheckAvailable, retryCount, snapshotId]);
+
+ const visibleDiagnostics = useMemo(() => {
+ if (loadState.status !== 'ready') return [];
+ if (showAll) return loadState.data.diagnostics;
+ return loadState.data.diagnostics.filter((diagnostic) => diagnostic.onChangedLine);
+ }, [loadState, showAll]);
+
+ const groupedDiagnostics = useMemo(() => {
+ const groups = new Map();
+ for (const diagnostic of visibleDiagnostics) {
+ const group = groups.get(diagnostic.filePath) ?? [];
+ group.push(diagnostic);
+ groups.set(diagnostic.filePath, group);
+ }
+ return [...groups.entries()];
+ }, [visibleDiagnostics]);
+
+ const openDiagnostic = useCallback((diagnostic: EslintDiagnostic) => {
+ openDiffFile(diagnostic.filePath);
+ onLineSelection({
+ start: diagnostic.line,
+ end: diagnostic.endLine && diagnostic.endLine >= diagnostic.line ? diagnostic.endLine : diagnostic.line,
+ side: 'additions',
+ });
+ }, [onLineSelection, openDiffFile]);
+
+ if (loadState.status === 'loading') {
+ return (
+
+
+ Running the project's ESLint…
+
+ );
+ }
+
+ if (loadState.status === 'error') {
+ return (
+
+
+
ESLint could not run
+
{loadState.message}
+
+
+
+ );
+ }
+
+ const { summary, eslintVersions } = loadState.data;
+ const changedCount = summary.changedLineErrors + summary.changedLineWarnings;
+ const totalCount = summary.errors + summary.warnings;
+ const noVisibleDiagnostics = visibleDiagnostics.length === 0;
+ const toggleLabel = showAll ? 'Changed lines only' : 'Show all in changed files';
+ const fileLabel = summary.files === 1 ? 'file' : 'files';
+ const errorLabel = summary.changedLineErrors === 1 ? 'error' : 'errors';
+ const warningLabel = summary.changedLineWarnings === 1 ? 'warning' : 'warnings';
+ const hasHiddenDiagnostics = !showAll && totalCount > changedCount;
+ const noFindingsTitle = showAll ? 'No ESLint findings' : 'No ESLint findings on changed lines';
+ return (
+
+
+
+ {summary.changedLineErrors} {errorLabel} · {summary.changedLineWarnings} {warningLabel} on changed lines
+
+ {totalCount} total
+
+
+
+ {groupedDiagnostics.map(([filePath, diagnostics]) => (
+
+
+
+ {diagnostics.map((diagnostic, index) => (
+ openDiagnostic(diagnostic)}
+ />
+ ))}
+
+
+ ))}
+ {noVisibleDiagnostics && (
+
+
+
{noFindingsTitle}
+ {hasHiddenDiagnostics && (
+
+ )}
+
+
+ )}
+
+
+ ESLint {eslintVersions.join(', ')} · {summary.files} {fileLabel} with findings
+
+
+ );
+}
diff --git a/packages/review-editor/dock/reviewPanelComponents.ts b/packages/review-editor/dock/reviewPanelComponents.ts
index 0d9ec533a..68fd452b8 100644
--- a/packages/review-editor/dock/reviewPanelComponents.ts
+++ b/packages/review-editor/dock/reviewPanelComponents.ts
@@ -6,6 +6,7 @@ import { ReviewPRArtifactsPanel } from './panels/ReviewPRArtifactsPanel';
import { ReviewAllFilesDiffPanel } from './panels/ReviewAllFilesDiffPanel';
import { ReviewCodeNavPanel } from './panels/ReviewCodeNavPanel';
import { ReviewSemanticDiffPanel } from './panels/ReviewSemanticDiffPanel';
+import { ReviewEslintCheckPanel } from './panels/ReviewEslintCheckPanel';
/**
* Component registry for dockview — maps panel type strings to React components.
@@ -19,4 +20,5 @@ export const reviewPanelComponents = {
[REVIEW_PANEL_TYPES.ALL_FILES]: ReviewAllFilesDiffPanel,
[REVIEW_PANEL_TYPES.CODE_NAV]: ReviewCodeNavPanel,
[REVIEW_PANEL_TYPES.SEMANTIC_DIFF]: ReviewSemanticDiffPanel,
+ [REVIEW_PANEL_TYPES.ESLINT_CHECK]: ReviewEslintCheckPanel,
} as const;
diff --git a/packages/review-editor/dock/reviewPanelTypes.ts b/packages/review-editor/dock/reviewPanelTypes.ts
index e573efe7d..b4aefdfef 100644
--- a/packages/review-editor/dock/reviewPanelTypes.ts
+++ b/packages/review-editor/dock/reviewPanelTypes.ts
@@ -13,6 +13,7 @@ export const REVIEW_PANEL_TYPES = {
ALL_FILES: 'review-all-files',
CODE_NAV: 'review-code-nav',
SEMANTIC_DIFF: 'review-semantic-diff',
+ ESLINT_CHECK: 'review-eslint-check',
} as const;
export const REVIEW_DIFF_PANEL_ID = 'review-diff';
@@ -29,6 +30,7 @@ export const REVIEW_PR_ARTIFACTS_PANEL_ID = 'review-pr-artifacts';
export const REVIEW_ALL_FILES_PANEL_ID = 'review-all-files';
export const REVIEW_CODE_NAV_PANEL_ID = 'review-code-nav';
export const REVIEW_SEMANTIC_DIFF_PANEL_ID = 'review-semantic-diff';
+export const REVIEW_ESLINT_CHECK_PANEL_ID = 'review-eslint-check';
export function isReviewDiffPanelId(panelId: string): boolean {
return panelId === REVIEW_DIFF_PANEL_ID;
diff --git a/packages/server/review-workspace.test.ts b/packages/server/review-workspace.test.ts
index bf6acef53..d3981d606 100644
--- a/packages/server/review-workspace.test.ts
+++ b/packages/server/review-workspace.test.ts
@@ -108,6 +108,32 @@ function makeMockSem(dir: string, options: {
return semPath;
}
+function makeMockEslintProject(dir: string): void {
+ mkdirSync(join(dir, "src"), { recursive: true });
+ mkdirSync(join(dir, "node_modules", "eslint", "bin"), { recursive: true });
+ writeFileSync(join(dir, "src", "app.ts"), "export const value = 1;\n", "utf-8");
+ writeFileSync(join(dir, "eslint.config.js"), "export default [];\n", "utf-8");
+ writeFileSync(join(dir, "node_modules", "eslint", "package.json"), JSON.stringify({
+ version: "9.12.0",
+ bin: { eslint: "bin/eslint.cjs" },
+ }), "utf-8");
+ writeFileSync(join(dir, "node_modules", "eslint", "bin", "eslint.cjs"), [
+ 'const { join } = require("node:path");',
+ "process.stdout.write(JSON.stringify([{",
+ ' filePath: join(process.cwd(), "src", "app.ts"),',
+ " messages: [{",
+ ' ruleId: "react-hooks/exhaustive-deps",',
+ " severity: 1,",
+ ' message: "React Hook has a missing dependency.",',
+ " line: 1,",
+ " column: 1,",
+ " }],",
+ "}]));",
+ "process.exitCode = 1;",
+ "",
+ ].join("\n"), "utf-8");
+}
+
function makeBlockingSem(dir: string): { semPath: string; startedPath: string; releasePath: string } {
const semPath = join(dir, "sem-blocking");
const startedPath = join(dir, "started");
@@ -154,6 +180,70 @@ afterEach(() => {
});
describe("review-workspace", () => {
+ describe("ESLint check API", () => {
+ it("advertises and runs the reviewed project's local ESLint for the current snapshot", async () => {
+ const dir = makeTempDir("plannotator-eslint-server-");
+ makeMockEslintProject(dir);
+ initRepo(dir);
+ const gitContext = await getVcsContext(dir, "git");
+ const rawPatch = [
+ "diff --git a/src/app.ts b/src/app.ts",
+ "new file mode 100644",
+ "--- /dev/null",
+ "+++ b/src/app.ts",
+ "@@ -0,0 +1 @@",
+ "+export const value = 1;",
+ "",
+ ].join("\n");
+ const server = await startReviewServer({
+ rawPatch,
+ gitRef: "test",
+ diffType: "uncommitted",
+ gitContext,
+ origin: "claude-code",
+ htmlContent: "review",
+ });
+
+ try {
+ const diffPayload = await fetch(`${server.url}/api/diff`).then((response) => response.json()) as {
+ snapshotId: string;
+ eslintCheck?: { available: boolean; fileCount?: number; projectCount?: number };
+ };
+ expect(diffPayload.eslintCheck).toEqual({ available: true, fileCount: 1, projectCount: 1 });
+
+ const response = await fetch(`${server.url}/api/eslint-check`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ snapshotId: diffPayload.snapshotId }),
+ });
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toMatchObject({
+ status: "ok",
+ eslintVersions: ["9.12.0"],
+ summary: { errors: 0, warnings: 1, changedLineWarnings: 1 },
+ diagnostics: [{ filePath: "src/app.ts", line: 1, onChangedLine: true }],
+ });
+
+ writeFileSync(join(dir, "src", "app.ts"), "export const value = 2;\n", "utf-8");
+ const changedResponse = await fetch(`${server.url}/api/eslint-check`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ snapshotId: diffPayload.snapshotId }),
+ });
+ expect(changedResponse.status).toBe(409);
+
+ const staleResponse = await fetch(`${server.url}/api/eslint-check`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ snapshotId: "stale" }),
+ });
+ expect(staleResponse.status).toBe(409);
+ } finally {
+ server.stop();
+ }
+ });
+ });
+
describe("semantic diff API", () => {
const rawPatch = [
"diff --git a/src/app.ts b/src/app.ts",
diff --git a/packages/server/review.ts b/packages/server/review.ts
index d48519d3b..b7513b212 100644
--- a/packages/server/review.ts
+++ b/packages/server/review.ts
@@ -46,6 +46,13 @@ import {
SemanticDiffResponseCache,
} from "@plannotator/shared/semantic-diff";
import type { SemanticDiffAvailability, SemanticDiffResponse } from "@plannotator/shared/semantic-diff-types";
+import {
+ buildEslintCheckInput,
+ getEslintCheckAvailability,
+ isEslintCheckCompatibleReviewView,
+ runEslintCheck,
+} from "@plannotator/shared/eslint-check";
+import type { EslintCheckAdvert, EslintCheckResponse } from "@plannotator/shared/eslint-check-types";
import {
getPRDiffScopeOptions,
getPRFullStackFingerprint,
@@ -791,6 +798,134 @@ export async function startReviewServer(
return result;
};
+ const eslintCheckCompatibleView = (): boolean => {
+ return isEslintCheckCompatibleReviewView({
+ isPRMode,
+ isWorkspaceMode,
+ diffType: currentDiffType as string,
+ });
+ };
+
+ const eslintCheckUnavailableReason = (): string => {
+ if (isPRMode) return "pr-review-unsupported";
+ return eslintCheckCompatibleView() ? "local-checkout-unavailable" : "snapshot-not-working-tree";
+ };
+
+ const resolveEslintCheckInput = () => {
+ if (!eslintCheckCompatibleView()) return null;
+ const cwd = workspace?.root
+ ?? (isPRMode
+ ? resolvePRLocalCwd()
+ : resolveAgentCwd());
+ if (!cwd) return null;
+ return buildEslintCheckInput(
+ currentPatch,
+ cwd,
+ workspace?.repos.map((repo) => ({ label: repo.label, cwd: repo.cwd })),
+ );
+ };
+
+ const getEslintCheckAdvert = (): EslintCheckAdvert => {
+ const input = resolveEslintCheckInput();
+ if (!input) {
+ return {
+ available: false,
+ reason: eslintCheckUnavailableReason(),
+ };
+ }
+ return getEslintCheckAvailability(input);
+ };
+
+ interface EslintCheckBaseline {
+ snapshotId: string;
+ fingerprintGeneration: number;
+ fingerprint: string | null;
+ }
+
+ const resolveEslintCheckBaseline = async (
+ requestedSnapshotId: string | undefined,
+ ): Promise => {
+ if (requestedSnapshotId !== currentSnapshotId()) return null;
+ const baselineGeneration = fingerprintGeneration;
+ let baseline = currentFingerprint;
+ const pendingCapture = pendingFingerprintCapture;
+ if (baseline == null && pendingCapture) {
+ baseline = await pendingCapture;
+ }
+ if (
+ requestedSnapshotId !== currentSnapshotId()
+ || baselineGeneration !== fingerprintGeneration
+ ) {
+ return null;
+ }
+ if (baseline != null) {
+ const probe = await fileContentFingerprintProbes.run(
+ `${requestedSnapshotId}:${baselineGeneration}`,
+ computeDiffFingerprint,
+ );
+ if (
+ requestedSnapshotId !== currentSnapshotId()
+ || currentFingerprint !== baseline
+ || (probe != null && probe !== baseline)
+ ) {
+ return null;
+ }
+ }
+ return {
+ snapshotId: requestedSnapshotId,
+ fingerprintGeneration: baselineGeneration,
+ fingerprint: baseline,
+ };
+ };
+
+ const sameEslintCheckBaseline = (
+ left: EslintCheckBaseline,
+ right: EslintCheckBaseline,
+ ): boolean => left.snapshotId === right.snapshotId
+ && left.fingerprintGeneration === right.fingerprintGeneration
+ && left.fingerprint === right.fingerprint;
+
+ let eslintCheckCache: {
+ baseline: EslintCheckBaseline;
+ response: EslintCheckResponse;
+ } | null = null;
+ const getEslintCheck = async (requestedSnapshotId: string | undefined): Promise => {
+ const baseline = await resolveEslintCheckBaseline(requestedSnapshotId);
+ if (!baseline) {
+ return {
+ status: "error",
+ reason: "stale-snapshot",
+ message: "The reviewed diff changed before ESLint started. Run the check again.",
+ };
+ }
+ if (eslintCheckCache && sameEslintCheckBaseline(eslintCheckCache.baseline, baseline)) {
+ return eslintCheckCache.response;
+ }
+ const input = resolveEslintCheckInput();
+ if (!input) {
+ return {
+ status: "unavailable",
+ reason: eslintCheckUnavailableReason(),
+ message: isPRMode
+ ? "ESLint is currently available only for local code reviews."
+ : eslintCheckCompatibleView()
+ ? "ESLint requires a local checkout of the code under review."
+ : "ESLint is available only when the review's new side is the current working tree.",
+ };
+ }
+ const response = await runEslintCheck(input);
+ const completedBaseline = await resolveEslintCheckBaseline(requestedSnapshotId);
+ if (!completedBaseline || !sameEslintCheckBaseline(baseline, completedBaseline)) {
+ return {
+ status: "error",
+ reason: "stale-snapshot",
+ message: "The reviewed diff changed while ESLint was running. Run the check again.",
+ };
+ }
+ if (response.status === "ok") eslintCheckCache = { baseline, response };
+ return response;
+ };
+
const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => serverUrl,
@@ -1511,6 +1646,7 @@ export async function startReviewServer(
...(baseBehindRemote && { baseBehindRemote: true }),
...(servedError && { error: servedError }),
semanticDiff: await getSemanticDiffAdvert(servedDiffType as DiffType),
+ eslintCheck: getEslintCheckAdvert(),
serverConfig: getServerConfig(gitUser),
});
}
@@ -1622,6 +1758,13 @@ export async function startReviewServer(
return Response.json(await getSemanticDiff(url));
}
+ if (url.pathname === "/api/eslint-check" && req.method === "POST") {
+ const body = await req.json().catch(() => ({})) as { snapshotId?: unknown };
+ const requestedSnapshotId = typeof body.snapshotId === "string" ? body.snapshotId : undefined;
+ const result = await getEslintCheck(requestedSnapshotId);
+ return Response.json(result, { status: result.status === "error" && result.reason === "stale-snapshot" ? 409 : 200 });
+ }
+
// API: Linear commit history for the Commits panel. Git-local
// sessions only — PR/workspace/jj/p4 don't offer the view (same
// gate the client's commitsCapable applies). Computed against the
@@ -1717,6 +1860,7 @@ export async function startReviewServer(
hideWhitespace: currentHideWhitespace,
...(currentError && { error: currentError }),
semanticDiff: await getSemanticDiffAdvert(),
+ eslintCheck: getEslintCheckAdvert(),
});
}
@@ -1848,6 +1992,7 @@ export async function startReviewServer(
...(updatedContext && { gitContext: updatedContext }),
...(currentError && { error: currentError }),
semanticDiff: switchSemanticDiff,
+ eslintCheck: getEslintCheckAdvert(),
});
} catch (err) {
const message =
@@ -1938,6 +2083,7 @@ export async function startReviewServer(
...(layerPatchIncomplete && { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable }),
...((currentError ?? upgradeError) && { error: currentError ?? upgradeError }),
semanticDiff: await getSemanticDiffAdvert(),
+ eslintCheck: getEslintCheckAdvert(),
});
}
@@ -1982,6 +2128,7 @@ export async function startReviewServer(
snapshotId: currentSnapshotId(),
prDiffScope: currentPRDiffScope,
semanticDiff: await getSemanticDiffAdvert(),
+ eslintCheck: getEslintCheckAdvert(),
});
} catch (err) {
const message =
@@ -2120,6 +2267,7 @@ export async function startReviewServer(
...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }),
...(currentError ? { error: currentError } : {}),
semanticDiff: await getSemanticDiffAdvert(),
+ eslintCheck: getEslintCheckAdvert(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to switch PR";
diff --git a/packages/shared/eslint-check-types.ts b/packages/shared/eslint-check-types.ts
new file mode 100644
index 000000000..9e8b99778
--- /dev/null
+++ b/packages/shared/eslint-check-types.ts
@@ -0,0 +1,56 @@
+export type EslintCheckStatus = "ok" | "unavailable" | "error";
+
+export interface EslintCheckAdvert {
+ available: boolean;
+ fileCount?: number;
+ projectCount?: number;
+ reason?: string;
+}
+
+export interface EslintDiagnostic {
+ filePath: string;
+ line: number;
+ column: number;
+ endLine?: number;
+ endColumn?: number;
+ severity: 1 | 2;
+ ruleId: string | null;
+ message: string;
+ fixable: boolean;
+ onChangedLine: boolean;
+}
+
+export interface EslintCheckSummary {
+ files: number;
+ errors: number;
+ warnings: number;
+ changedLineErrors: number;
+ changedLineWarnings: number;
+}
+
+export interface EslintCheckOkResponse {
+ status: "ok";
+ summary: EslintCheckSummary;
+ diagnostics: EslintDiagnostic[];
+ eslintVersions: string[];
+}
+
+export interface EslintCheckUnavailableResponse {
+ status: "unavailable";
+ reason: string;
+ message: string;
+}
+
+export interface EslintCheckErrorResponse {
+ status: "error";
+ reason: string;
+ message: string;
+ exitCode?: number;
+ stderr?: string;
+}
+
+export type EslintCheckResponse =
+ | EslintCheckOkResponse
+ | EslintCheckUnavailableResponse
+ | EslintCheckErrorResponse;
+
diff --git a/packages/shared/eslint-check.test.ts b/packages/shared/eslint-check.test.ts
new file mode 100644
index 000000000..b2baee8da
--- /dev/null
+++ b/packages/shared/eslint-check.test.ts
@@ -0,0 +1,312 @@
+import { describe, expect, test } from "bun:test";
+import {
+ buildEslintCheckInput,
+ extractChangedLines,
+ getEslintCheckAvailability,
+ isEslintCheckCompatibleReviewView,
+ runEslintCheck,
+ type EslintCheckRuntime,
+} from "./eslint-check";
+
+function makeRuntime(options: {
+ files?: string[];
+ contents?: Record;
+ result?: { stdout?: string; stderr?: string; exitCode?: number; timedOut?: boolean; outputLimitExceeded?: boolean };
+ onRunCommand?: (callIndex: number) => void;
+ now?: () => number;
+} = {}): EslintCheckRuntime & {
+ calls: Array<{ command: string; args: string[]; cwd: string; timeoutMs: number }>;
+} {
+ const files = new Set(options.files ?? []);
+ const contents = options.contents ?? {};
+ const calls: Array<{ command: string; args: string[]; cwd: string; timeoutMs: number }> = [];
+ return {
+ calls,
+ nodePath: "/usr/bin/node",
+ now: options.now ?? (() => 0),
+ fileExists: (path) => files.has(path),
+ readTextFile: (path) => contents[path] ?? "",
+ async runCommand(command, args, runOptions) {
+ calls.push({ command, args, cwd: runOptions.cwd, timeoutMs: runOptions.timeoutMs });
+ options.onRunCommand?.(calls.length - 1);
+ return {
+ stdout: options.result?.stdout ?? "[]",
+ stderr: options.result?.stderr ?? "",
+ exitCode: options.result?.exitCode ?? 0,
+ ...(options.result?.timedOut && { timedOut: true }),
+ ...(options.result?.outputLimitExceeded && { outputLimitExceeded: true }),
+ };
+ },
+ };
+}
+
+const patch = [
+ "diff --git a/src/app.tsx b/src/app.tsx",
+ "--- a/src/app.tsx",
+ "+++ b/src/app.tsx",
+ "@@ -8,2 +8,3 @@",
+ " const count = 1;",
+ "+useEffect(() => console.log(count), []);",
+ "+const other = true;",
+ " unchanged();",
+ "",
+].join("\n");
+
+function configuredRuntime(result?: Parameters[0]["result"]) {
+ return makeRuntime({
+ files: [
+ "/repo/src/app.tsx",
+ "/repo/eslint.config.js",
+ "/repo/node_modules/eslint/package.json",
+ "/repo/node_modules/eslint/bin/eslint.js",
+ ],
+ contents: {
+ "/repo/node_modules/eslint/package.json": JSON.stringify({ version: "9.12.0", bin: { eslint: "bin/eslint.js" } }),
+ },
+ result,
+ });
+}
+
+describe("ESLint review check", () => {
+ test("limits ESLint checks to working-tree-backed local review views", () => {
+ expect(isEslintCheckCompatibleReviewView({
+ isPRMode: true,
+ isWorkspaceMode: false,
+ diffType: "uncommitted",
+ })).toBe(false);
+ expect(isEslintCheckCompatibleReviewView({
+ isPRMode: false,
+ isWorkspaceMode: false,
+ diffType: "uncommitted",
+ })).toBe(true);
+ expect(isEslintCheckCompatibleReviewView({
+ isPRMode: false,
+ isWorkspaceMode: false,
+ diffType: "last-commit",
+ })).toBe(false);
+ expect(isEslintCheckCompatibleReviewView({
+ isPRMode: false,
+ isWorkspaceMode: true,
+ diffType: "workspace-current",
+ })).toBe(true);
+ });
+
+ test("extracts added new-side line numbers per file", () => {
+ expect([...extractChangedLines(patch).get("src/app.tsx") ?? []]).toEqual([9, 10]);
+ });
+
+ test("maps workspace-prefixed patch paths back to child repositories", () => {
+ const workspacePatch = patch.replaceAll("src/app.tsx", "web/src/app.tsx");
+ expect(buildEslintCheckInput(workspacePatch, "/workspace", [
+ { label: "web", cwd: "/workspace/apps/web" },
+ { label: "api", cwd: "/workspace/apps/api" },
+ ])).toEqual({
+ rawPatch: workspacePatch,
+ roots: [{
+ cwd: "/workspace/apps/web",
+ files: [{ path: "src/app.tsx", displayPath: "web/src/app.tsx" }],
+ }],
+ });
+ });
+
+ test("advertises only reviewed files with config and project-local ESLint", () => {
+ const runtime = configuredRuntime();
+ expect(getEslintCheckAvailability({
+ roots: [{ cwd: "/repo", files: [{ path: "src/app.tsx" }] }],
+ rawPatch: patch,
+ }, runtime)).toEqual({ available: true, fileCount: 1, projectCount: 1 });
+ });
+
+ test("does not advertise a globally available or unconfigured ESLint", () => {
+ const runtime = makeRuntime({ files: ["/repo/src/app.tsx"] });
+ expect(getEslintCheckAvailability({
+ roots: [{ cwd: "/repo", files: [{ path: "src/app.tsx" }] }],
+ rawPatch: patch,
+ }, runtime)).toMatchObject({ available: false, reason: "eslint-not-configured" });
+ expect(runtime.calls).toEqual([]);
+ });
+
+ test("runs the local ESLint entry with argv and maps structured findings", async () => {
+ const runtime = configuredRuntime({
+ exitCode: 1,
+ stdout: JSON.stringify([{
+ filePath: "/repo/src/app.tsx",
+ messages: [
+ {
+ ruleId: "react-hooks/exhaustive-deps",
+ severity: 1,
+ message: "React Hook useEffect has a missing dependency: 'count'.",
+ line: 9,
+ column: 1,
+ endLine: 9,
+ endColumn: 45,
+ suggestions: [{ desc: "Update dependencies" }],
+ },
+ {
+ ruleId: "no-unused-vars",
+ severity: 2,
+ message: "Unused value.",
+ line: 8,
+ column: 7,
+ },
+ ],
+ }]),
+ });
+
+ const response = await runEslintCheck({
+ roots: [{ cwd: "/repo", files: [{ path: "src/app.tsx" }] }],
+ rawPatch: patch,
+ }, runtime);
+
+ expect(response).toMatchObject({
+ status: "ok",
+ summary: { errors: 1, warnings: 1, changedLineErrors: 0, changedLineWarnings: 1 },
+ eslintVersions: ["9.12.0"],
+ diagnostics: [
+ { filePath: "src/app.tsx", line: 8, severity: 2, onChangedLine: false },
+ { filePath: "src/app.tsx", line: 9, severity: 1, onChangedLine: true, fixable: true },
+ ],
+ });
+ expect(runtime.calls).toEqual([{
+ command: "/usr/bin/node",
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ args: [
+ "/repo/node_modules/eslint/bin/eslint.js",
+ "--format",
+ "json",
+ "--no-color",
+ "--",
+ "src/app.tsx",
+ ],
+ }]);
+ });
+
+ test("uses the nearest package config and a hoisted root ESLint", async () => {
+ const runtime = makeRuntime({
+ files: [
+ "/repo/packages/web/src/app.tsx",
+ "/repo/packages/web/eslint.config.mjs",
+ "/repo/node_modules/eslint/package.json",
+ "/repo/node_modules/eslint/bin/eslint.js",
+ ],
+ contents: {
+ "/repo/node_modules/eslint/package.json": JSON.stringify({ version: "9.0.0", bin: "bin/eslint.js" }),
+ },
+ result: { stdout: "[]" },
+ });
+
+ await runEslintCheck({
+ roots: [{ cwd: "/repo", files: [{ path: "packages/web/src/app.tsx" }] }],
+ rawPatch: "",
+ }, runtime);
+
+ expect(runtime.calls[0]).toMatchObject({
+ cwd: "/repo/packages/web",
+ args: expect.arrayContaining(["src/app.tsx"]),
+ });
+ });
+
+ test("preserves workspace-prefixed display paths", async () => {
+ const runtime = configuredRuntime({
+ stdout: JSON.stringify([{
+ filePath: "/repo/src/app.tsx",
+ messages: [{ ruleId: "rules-of-hooks", severity: 2, message: "Invalid hook call.", line: 9, column: 1 }],
+ }]),
+ exitCode: 1,
+ });
+
+ const response = await runEslintCheck({
+ roots: [{ cwd: "/repo", files: [{ path: "src/app.tsx", displayPath: "web/src/app.tsx" }] }],
+ rawPatch: patch.replaceAll("src/app.tsx", "web/src/app.tsx"),
+ }, runtime);
+
+ expect(response).toMatchObject({
+ status: "ok",
+ diagnostics: [{ filePath: "web/src/app.tsx", onChangedLine: true }],
+ });
+ });
+
+ test("distinguishes ESLint findings from configuration failures", async () => {
+ const findings = await runEslintCheck({
+ roots: [{ cwd: "/repo", files: [{ path: "src/app.tsx" }] }],
+ rawPatch: patch,
+ }, configuredRuntime({ exitCode: 1, stdout: "[]" }));
+ expect(findings.status).toBe("ok");
+
+ const failure = await runEslintCheck({
+ roots: [{ cwd: "/repo", files: [{ path: "src/app.tsx" }] }],
+ rawPatch: patch,
+ }, configuredRuntime({ exitCode: 2, stderr: "Could not find config" }));
+ expect(failure).toMatchObject({ status: "error", reason: "eslint-exit", exitCode: 2 });
+ });
+
+ test("fails safely on timeout, oversized output, and invalid JSON", async () => {
+ await expect(runEslintCheck({
+ roots: [{ cwd: "/repo", files: [{ path: "src/app.tsx" }] }], rawPatch: patch,
+ }, configuredRuntime({ timedOut: true }))).resolves.toMatchObject({ status: "error", reason: "timeout" });
+
+ await expect(runEslintCheck({
+ roots: [{ cwd: "/repo", files: [{ path: "src/app.tsx" }] }], rawPatch: patch,
+ }, configuredRuntime({ outputLimitExceeded: true }))).resolves.toMatchObject({ status: "error", reason: "output-limit" });
+
+ await expect(runEslintCheck({
+ roots: [{ cwd: "/repo", files: [{ path: "src/app.tsx" }] }], rawPatch: patch,
+ }, configuredRuntime({ stdout: "not json" }))).resolves.toMatchObject({ status: "error", reason: "invalid-json" });
+ });
+
+ test("shares one 30-second execution budget across config groups", async () => {
+ let now = 1_000;
+ const runtime = makeRuntime({
+ files: [
+ "/repo/packages/a/src/a.ts",
+ "/repo/packages/a/eslint.config.js",
+ "/repo/packages/b/src/b.ts",
+ "/repo/packages/b/eslint.config.js",
+ "/repo/node_modules/eslint/package.json",
+ "/repo/node_modules/eslint/bin/eslint.js",
+ ],
+ contents: {
+ "/repo/node_modules/eslint/package.json": JSON.stringify({
+ version: "9.12.0",
+ bin: { eslint: "bin/eslint.js" },
+ }),
+ },
+ onRunCommand: () => {
+ now += 5_000;
+ },
+ now: () => now,
+ });
+
+ const response = await runEslintCheck({
+ roots: [{
+ cwd: "/repo",
+ files: [
+ { path: "packages/a/src/a.ts" },
+ { path: "packages/b/src/b.ts" },
+ ],
+ }],
+ rawPatch: "",
+ }, runtime);
+
+ expect(response.status).toBe("ok");
+ expect(runtime.calls.map((call) => call.timeoutMs)).toEqual([30_000, 25_000]);
+ });
+
+ test("rejects paths that escape the reviewed root", () => {
+ const runtime = makeRuntime({
+ files: [
+ "/outside.ts",
+ "/repo/eslint.config.js",
+ "/repo/node_modules/eslint/package.json",
+ "/repo/node_modules/eslint/bin/eslint.js",
+ ],
+ contents: {
+ "/repo/node_modules/eslint/package.json": JSON.stringify({ version: "9.0.0", bin: "bin/eslint.js" }),
+ },
+ });
+ expect(getEslintCheckAvailability({
+ roots: [{ cwd: "/repo", files: [{ path: "../outside.ts" }] }], rawPatch: "",
+ }, runtime)).toMatchObject({ available: false });
+ });
+});
diff --git a/packages/shared/eslint-check.ts b/packages/shared/eslint-check.ts
new file mode 100644
index 000000000..c1495205d
--- /dev/null
+++ b/packages/shared/eslint-check.ts
@@ -0,0 +1,530 @@
+import { spawn } from "node:child_process";
+import { dirname, isAbsolute, join, relative, resolve } from "node:path";
+import { existsSync, readFileSync } from "node:fs";
+import { parseDiffFilePathLines, parseDiffGitHeader } from "./diff-paths";
+import { listPatchFiles } from "./review-core";
+import type {
+ EslintCheckAdvert,
+ EslintCheckResponse,
+ EslintDiagnostic,
+} from "./eslint-check-types";
+
+const ESLINT_TIMEOUT_MS = 30_000;
+const ESLINT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
+const ESLINT_MAX_FILES = 500;
+const LINTABLE_EXTENSIONS = new Set([
+ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte",
+]);
+const CONFIG_FILENAMES = [
+ "eslint.config.js",
+ "eslint.config.mjs",
+ "eslint.config.cjs",
+ "eslint.config.ts",
+ "eslint.config.mts",
+ "eslint.config.cts",
+ ".eslintrc",
+ ".eslintrc.js",
+ ".eslintrc.cjs",
+ ".eslintrc.json",
+ ".eslintrc.yaml",
+ ".eslintrc.yml",
+];
+
+export interface EslintCheckFile {
+ path: string;
+ displayPath?: string;
+}
+
+export interface EslintCheckRoot {
+ cwd: string;
+ files: EslintCheckFile[];
+}
+
+export interface EslintCheckInput {
+ roots: EslintCheckRoot[];
+ rawPatch: string;
+}
+
+export interface EslintWorkspaceRoot {
+ label: string;
+ cwd: string;
+}
+
+export function isEslintCheckCompatibleReviewView(options: {
+ isPRMode: boolean;
+ isWorkspaceMode: boolean;
+ diffType: string;
+}): boolean {
+ if (options.isPRMode) return false;
+ if (options.isWorkspaceMode) return options.diffType === "workspace-current";
+ const worktreeSeparator = options.diffType.lastIndexOf(":");
+ const type = options.diffType.startsWith("worktree:") && worktreeSeparator !== -1
+ ? options.diffType.slice(worktreeSeparator + 1)
+ : options.diffType;
+ return type === "since-base"
+ || type === "uncommitted"
+ || type === "unstaged"
+ || type === "gitbutler:workspace"
+ || type === "jj-current"
+ || type === "p4-default";
+}
+
+export interface EslintCommandResult {
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+ error?: string;
+ timedOut?: boolean;
+ outputLimitExceeded?: boolean;
+}
+
+export interface EslintCheckRuntime {
+ fileExists: (path: string) => boolean;
+ readTextFile: (path: string) => string;
+ runCommand: (
+ command: string,
+ args: string[],
+ options: { cwd: string; timeoutMs: number; maxOutputBytes: number },
+ ) => Promise;
+ nodePath: string;
+ now: () => number;
+}
+
+interface EslintPackage {
+ entryPath: string;
+ version: string;
+}
+
+interface PreparedFile {
+ absolutePath: string;
+ relativePath: string;
+ displayPath: string;
+ configRoot: string;
+}
+
+interface PreparedGroup {
+ cwd: string;
+ eslint: EslintPackage;
+ files: PreparedFile[];
+}
+
+interface RawEslintMessage {
+ ruleId?: unknown;
+ severity?: unknown;
+ message?: unknown;
+ line?: unknown;
+ column?: unknown;
+ endLine?: unknown;
+ endColumn?: unknown;
+ fix?: unknown;
+ suggestions?: unknown;
+}
+
+interface RawEslintResult {
+ filePath?: unknown;
+ messages?: unknown;
+}
+
+function defaultRunCommand(
+ command: string,
+ args: string[],
+ options: { cwd: string; timeoutMs: number; maxOutputBytes: number },
+): Promise {
+ return new Promise((resolveResult) => {
+ let settled = false;
+ let totalBytes = 0;
+ const stdoutChunks: Buffer[] = [];
+ const stderrChunks: Buffer[] = [];
+ let proc: ReturnType;
+
+ const finish = (result: EslintCommandResult) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolveResult(result);
+ };
+
+ try {
+ proc = spawn(command, args, {
+ cwd: options.cwd,
+ stdio: ["ignore", "pipe", "pipe"],
+ shell: false,
+ });
+ } catch (error) {
+ resolveResult({
+ stdout: "",
+ stderr: "",
+ exitCode: 2,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ proc.kill();
+ finish({
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
+ exitCode: 2,
+ timedOut: true,
+ });
+ }, options.timeoutMs);
+
+ const collect = (chunks: Buffer[], chunk: Buffer) => {
+ totalBytes += chunk.length;
+ if (totalBytes > options.maxOutputBytes) {
+ proc.kill();
+ finish({
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
+ exitCode: 2,
+ outputLimitExceeded: true,
+ });
+ return;
+ }
+ chunks.push(chunk);
+ };
+
+ proc.stdout?.on("data", (chunk: Buffer) => collect(stdoutChunks, chunk));
+ proc.stderr?.on("data", (chunk: Buffer) => collect(stderrChunks, chunk));
+ proc.on("error", (error) => {
+ finish({
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
+ exitCode: 2,
+ error: error.message,
+ });
+ });
+ proc.on("close", (code) => {
+ finish({
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
+ exitCode: code ?? 2,
+ });
+ });
+ });
+}
+
+export function createDefaultEslintCheckRuntime(): EslintCheckRuntime {
+ const runningUnderBun = (process.versions as Record).bun !== undefined;
+ return {
+ fileExists: existsSync,
+ readTextFile: (path) => readFileSync(path, "utf-8"),
+ runCommand: defaultRunCommand,
+ nodePath: runningUnderBun ? "node" : process.execPath,
+ now: () => performance.now(),
+ };
+}
+
+export function buildEslintCheckInput(
+ rawPatch: string,
+ cwd: string,
+ workspaceRoots?: EslintWorkspaceRoot[],
+): EslintCheckInput {
+ const patchFiles = listPatchFiles(rawPatch);
+ if (!workspaceRoots?.length) {
+ return {
+ rawPatch,
+ roots: [{ cwd, files: patchFiles.map((file) => ({ path: file.path })) }],
+ };
+ }
+
+ const sortedRoots = [...workspaceRoots].sort((a, b) => b.label.length - a.label.length);
+ const grouped = new Map();
+ for (const file of patchFiles) {
+ const root = sortedRoots.find((candidate) => file.path.startsWith(`${candidate.label}/`));
+ if (!root) continue;
+ const relativePath = file.path.slice(root.label.length + 1);
+ if (!relativePath) continue;
+ const group = grouped.get(root.cwd) ?? { cwd: root.cwd, files: [] };
+ group.files.push({ path: relativePath, displayPath: file.path });
+ grouped.set(root.cwd, group);
+ }
+
+ return { rawPatch, roots: [...grouped.values()] };
+}
+
+function extensionOf(path: string): string {
+ const dot = path.lastIndexOf(".");
+ return dot === -1 ? "" : path.slice(dot).toLowerCase();
+}
+
+function isWithinRoot(root: string, candidate: string): boolean {
+ const rel = relative(root, candidate);
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
+}
+
+function readJson(runtime: EslintCheckRuntime, path: string): Record | null {
+ try {
+ const value = JSON.parse(runtime.readTextFile(path));
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function directoryHasConfig(runtime: EslintCheckRuntime, directory: string): boolean {
+ if (CONFIG_FILENAMES.some((name) => runtime.fileExists(join(directory, name)))) return true;
+ const packageJson = join(directory, "package.json");
+ if (!runtime.fileExists(packageJson)) return false;
+ return readJson(runtime, packageJson)?.eslintConfig !== undefined;
+}
+
+function findConfigRoot(runtime: EslintCheckRuntime, filePath: string, root: string): string | null {
+ let current = dirname(filePath);
+ while (isWithinRoot(root, current)) {
+ if (directoryHasConfig(runtime, current)) return current;
+ if (current === root) break;
+ const parent = dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+ return null;
+}
+
+function resolveEslintPackage(
+ runtime: EslintCheckRuntime,
+ start: string,
+ root: string,
+): EslintPackage | null {
+ let current = start;
+ while (isWithinRoot(root, current)) {
+ const packagePath = join(current, "node_modules", "eslint", "package.json");
+ if (runtime.fileExists(packagePath)) {
+ const packageJson = readJson(runtime, packagePath);
+ const packageRoot = dirname(packagePath);
+ const bin = packageJson?.bin;
+ const binPath = typeof bin === "string"
+ ? bin
+ : bin && typeof bin === "object" && !Array.isArray(bin) && typeof (bin as Record).eslint === "string"
+ ? (bin as Record).eslint
+ : "bin/eslint.js";
+ const entryPath = resolve(packageRoot, binPath);
+ if (isWithinRoot(packageRoot, entryPath) && runtime.fileExists(entryPath)) {
+ return {
+ entryPath,
+ version: typeof packageJson?.version === "string" ? packageJson.version : "unknown",
+ };
+ }
+ }
+ if (current === root) break;
+ const parent = dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+ return null;
+}
+
+function prepareGroups(input: EslintCheckInput, runtime: EslintCheckRuntime): PreparedGroup[] {
+ const groups = new Map();
+ let preparedCount = 0;
+
+ for (const rootInput of input.roots) {
+ const root = resolve(rootInput.cwd);
+ for (const file of rootInput.files) {
+ if (preparedCount >= ESLINT_MAX_FILES) break;
+ if (!LINTABLE_EXTENSIONS.has(extensionOf(file.path))) continue;
+ const absolutePath = resolve(root, file.path);
+ if (!isWithinRoot(root, absolutePath) || !runtime.fileExists(absolutePath)) continue;
+ const configRoot = findConfigRoot(runtime, absolutePath, root);
+ if (!configRoot) continue;
+ const eslint = resolveEslintPackage(runtime, configRoot, root);
+ if (!eslint) continue;
+
+ const key = `${configRoot}\0${eslint.entryPath}`;
+ const group = groups.get(key) ?? { cwd: configRoot, eslint, files: [] };
+ group.files.push({
+ absolutePath,
+ relativePath: relative(configRoot, absolutePath),
+ displayPath: file.displayPath ?? file.path,
+ configRoot,
+ });
+ groups.set(key, group);
+ preparedCount += 1;
+ }
+ }
+
+ return [...groups.values()];
+}
+
+export function getEslintCheckAvailability(
+ input: EslintCheckInput,
+ runtime: EslintCheckRuntime = createDefaultEslintCheckRuntime(),
+): EslintCheckAdvert {
+ const lintableCount = input.roots.reduce(
+ (count, root) => count + root.files.filter((file) => LINTABLE_EXTENSIONS.has(extensionOf(file.path))).length,
+ 0,
+ );
+ if (lintableCount === 0) return { available: false, reason: "no-lintable-files" };
+
+ const groups = prepareGroups(input, runtime);
+ if (groups.length === 0) return { available: false, reason: "eslint-not-configured" };
+ return {
+ available: true,
+ fileCount: groups.reduce((count, group) => count + group.files.length, 0),
+ projectCount: groups.length,
+ };
+}
+
+export function extractChangedLines(rawPatch: string): Map> {
+ const result = new Map>();
+ const chunkStarts = [...rawPatch.matchAll(/^diff --git /gm)];
+
+ for (let index = 0; index < chunkStarts.length; index += 1) {
+ const start = chunkStarts[index].index ?? 0;
+ const end = chunkStarts[index + 1]?.index ?? rawPatch.length;
+ const lines = rawPatch.slice(start, end).split("\n");
+ const fileLines = parseDiffFilePathLines(lines);
+ const header = parseDiffGitHeader(lines[0] ?? "");
+ const path = fileLines.newPath ?? header.newPath;
+ if (!path) continue;
+
+ const changed = result.get(path) ?? new Set();
+ let newLine = 0;
+ for (const line of lines) {
+ const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
+ if (hunk) {
+ newLine = Number.parseInt(hunk[1], 10);
+ continue;
+ }
+ if (newLine === 0) continue;
+ if (line.startsWith("+") && !line.startsWith("+++")) {
+ changed.add(newLine);
+ newLine += 1;
+ } else if (!line.startsWith("-")) {
+ newLine += 1;
+ }
+ }
+ result.set(path, changed);
+ }
+
+ return result;
+}
+
+function numeric(value: unknown, fallback: number): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
+}
+
+function parseDiagnostics(
+ stdout: string,
+ group: PreparedGroup,
+ changedLines: Map>,
+): EslintDiagnostic[] | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(stdout);
+ } catch {
+ return null;
+ }
+ if (!Array.isArray(parsed)) return null;
+
+ const filesByAbsolutePath = new Map(group.files.map((file) => [resolve(file.absolutePath), file]));
+ const diagnostics: EslintDiagnostic[] = [];
+ for (const rawResult of parsed as RawEslintResult[]) {
+ if (!rawResult || typeof rawResult !== "object" || typeof rawResult.filePath !== "string") continue;
+ const rawAbsolutePath = resolve(rawResult.filePath);
+ const normalizedRawPath = rawAbsolutePath.replaceAll("\\", "/");
+ const file = filesByAbsolutePath.get(rawAbsolutePath)
+ ?? group.files.find((candidate) => normalizedRawPath.endsWith(`/${candidate.relativePath.replaceAll("\\", "/")}`));
+ if (!file || !Array.isArray(rawResult.messages)) continue;
+ for (const rawMessage of rawResult.messages as RawEslintMessage[]) {
+ if (!rawMessage || typeof rawMessage !== "object") continue;
+ const severity = rawMessage.severity === 2 ? 2 : rawMessage.severity === 1 ? 1 : null;
+ if (!severity || typeof rawMessage.message !== "string") continue;
+ const line = numeric(rawMessage.line, 1);
+ const fileChangedLines = changedLines.get(file.displayPath);
+ diagnostics.push({
+ filePath: file.displayPath,
+ line,
+ column: numeric(rawMessage.column, 1),
+ ...(typeof rawMessage.endLine === "number" && { endLine: rawMessage.endLine }),
+ ...(typeof rawMessage.endColumn === "number" && { endColumn: rawMessage.endColumn }),
+ severity,
+ ruleId: typeof rawMessage.ruleId === "string" ? rawMessage.ruleId : null,
+ message: rawMessage.message,
+ fixable: rawMessage.fix !== undefined || (Array.isArray(rawMessage.suggestions) && rawMessage.suggestions.length > 0),
+ onChangedLine: fileChangedLines?.has(line) ?? false,
+ });
+ }
+ }
+ return diagnostics;
+}
+
+export async function runEslintCheck(
+ input: EslintCheckInput,
+ runtime: EslintCheckRuntime = createDefaultEslintCheckRuntime(),
+): Promise {
+ const groups = prepareGroups(input, runtime);
+ if (groups.length === 0) {
+ return {
+ status: "unavailable",
+ reason: "eslint-not-configured",
+ message: "No reviewed files have both an ESLint configuration and a project-local ESLint installation.",
+ };
+ }
+
+ const changedLines = extractChangedLines(input.rawPatch);
+ const diagnostics: EslintDiagnostic[] = [];
+ const deadline = runtime.now() + ESLINT_TIMEOUT_MS;
+ for (const group of groups) {
+ const remainingMs = deadline - runtime.now();
+ if (remainingMs <= 0) {
+ return { status: "error", reason: "timeout", message: "ESLint did not finish within 30 seconds." };
+ }
+ const command = await runtime.runCommand(
+ runtime.nodePath,
+ [group.eslint.entryPath, "--format", "json", "--no-color", "--", ...group.files.map((file) => file.relativePath)],
+ { cwd: group.cwd, timeoutMs: remainingMs, maxOutputBytes: ESLINT_MAX_OUTPUT_BYTES },
+ );
+ if (command.timedOut) {
+ return { status: "error", reason: "timeout", message: "ESLint did not finish within 30 seconds." };
+ }
+ if (command.outputLimitExceeded) {
+ return { status: "error", reason: "output-limit", message: "ESLint produced more than 10 MB of output." };
+ }
+ if (command.error) {
+ return { status: "error", reason: "spawn-failed", message: command.error };
+ }
+ if (command.exitCode !== 0 && command.exitCode !== 1) {
+ return {
+ status: "error",
+ reason: "eslint-exit",
+ message: command.stderr.trim() || "ESLint could not run because of a configuration or internal error.",
+ exitCode: command.exitCode,
+ ...(command.stderr.trim() && { stderr: command.stderr.trim() }),
+ };
+ }
+ const groupDiagnostics = parseDiagnostics(command.stdout, group, changedLines);
+ if (!groupDiagnostics) {
+ return {
+ status: "error",
+ reason: "invalid-json",
+ message: command.stderr.trim() || "ESLint returned invalid JSON output.",
+ };
+ }
+ diagnostics.push(...groupDiagnostics);
+ }
+
+ diagnostics.sort((a, b) =>
+ a.filePath.localeCompare(b.filePath) || a.line - b.line || a.column - b.column || b.severity - a.severity,
+ );
+ const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 2).length;
+ const warnings = diagnostics.length - errors;
+ const changedLineDiagnostics = diagnostics.filter((diagnostic) => diagnostic.onChangedLine);
+ const changedLineErrors = changedLineDiagnostics.filter((diagnostic) => diagnostic.severity === 2).length;
+
+ return {
+ status: "ok",
+ summary: {
+ files: new Set(diagnostics.map((diagnostic) => diagnostic.filePath)).size,
+ errors,
+ warnings,
+ changedLineErrors,
+ changedLineWarnings: changedLineDiagnostics.length - changedLineErrors,
+ },
+ diagnostics,
+ eslintVersions: [...new Set(groups.map((group) => group.eslint.version))],
+ };
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 06b2d8d34..565920efc 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -58,6 +58,8 @@
"./goal-setup": "./goal-setup.ts",
"./semantic-diff": "./semantic-diff.ts",
"./semantic-diff-types": "./semantic-diff-types.ts",
+ "./eslint-check": "./eslint-check.ts",
+ "./eslint-check-types": "./eslint-check-types.ts",
"./single-flight": "./single-flight.ts",
"./source-save": "./source-save.ts",
"./source-save-node": "./source-save-node.ts",
From 3044b186ae9833fa9d5f1c9e3c01f9a3cbd5f6ec Mon Sep 17 00:00:00 2001
From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com>
Date: Wed, 29 Jul 2026 02:24:17 -0300
Subject: [PATCH 2/4] Format ESLint review check code for consistency
---
apps/pi-extension/server/serverReview.ts | 21 +++++++
packages/review-editor/App.tsx | 20 ++++++-
.../review-editor/components/FileTree.tsx | 12 +++-
.../components/SectionsPanel.tsx | 12 +++-
.../review-editor/dock/ReviewStateContext.tsx | 1 +
.../dock/panels/ReviewEslintCheckPanel.tsx | 12 ++++
packages/server/review.ts | 21 +++++++
packages/shared/eslint-check.test.ts | 1 +
packages/shared/eslint-check.ts | 57 +++++++++++++++++++
9 files changed, 154 insertions(+), 3 deletions(-)
diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts
index ec7d9da69..19da264cb 100644
--- a/apps/pi-extension/server/serverReview.ts
+++ b/apps/pi-extension/server/serverReview.ts
@@ -860,16 +860,20 @@ export async function startReviewServer(options: {
function eslintCheckUnavailableReason(): string {
if (isPRMode) return "pr-review-unsupported";
+
return eslintCheckCompatibleView() ? "local-checkout-unavailable" : "snapshot-not-working-tree";
}
function resolveEslintCheckInput() {
if (!eslintCheckCompatibleView()) return null;
+
const cwd = workspace?.root
?? (isPRMode
? resolvePRLocalCwd()
: resolveAgentCwd());
+
if (!cwd) return null;
+
return buildEslintCheckInput(
currentPatch,
cwd,
@@ -879,12 +883,14 @@ export async function startReviewServer(options: {
function getEslintCheckAdvert(): EslintCheckAdvert {
const input = resolveEslintCheckInput();
+
if (!input) {
return {
available: false,
reason: eslintCheckUnavailableReason(),
};
}
+
return getEslintCheckAvailability(input);
}
@@ -898,23 +904,28 @@ export async function startReviewServer(options: {
requestedSnapshotId: string | undefined,
): Promise {
if (requestedSnapshotId !== currentSnapshotId()) return null;
+
const baselineGeneration = fingerprintGeneration;
let baseline = currentFingerprint;
const pendingCapture = pendingFingerprintCapture;
+
if (baseline == null && pendingCapture) {
baseline = await pendingCapture;
}
+
if (
requestedSnapshotId !== currentSnapshotId()
|| baselineGeneration !== fingerprintGeneration
) {
return null;
}
+
if (baseline != null) {
const probe = await fileContentFingerprintProbes.run(
`${requestedSnapshotId}:${baselineGeneration}`,
computeDiffFingerprint,
);
+
if (
requestedSnapshotId !== currentSnapshotId()
|| currentFingerprint !== baseline
@@ -923,6 +934,7 @@ export async function startReviewServer(options: {
return null;
}
}
+
return {
snapshotId: requestedSnapshotId,
fingerprintGeneration: baselineGeneration,
@@ -943,8 +955,10 @@ export async function startReviewServer(options: {
baseline: EslintCheckBaseline;
response: EslintCheckResponse;
} | null = null;
+
async function getEslintCheck(requestedSnapshotId: string | undefined): Promise {
const baseline = await resolveEslintCheckBaseline(requestedSnapshotId);
+
if (!baseline) {
return {
status: "error",
@@ -952,10 +966,13 @@ export async function startReviewServer(options: {
message: "The reviewed diff changed before ESLint started. Run the check again.",
};
}
+
if (eslintCheckCache && sameEslintCheckBaseline(eslintCheckCache.baseline, baseline)) {
return eslintCheckCache.response;
}
+
const input = resolveEslintCheckInput();
+
if (!input) {
return {
status: "unavailable",
@@ -967,8 +984,10 @@ export async function startReviewServer(options: {
: "ESLint is available only when the review's new side is the current working tree.",
};
}
+
const response = await runEslintCheck(input);
const completedBaseline = await resolveEslintCheckBaseline(requestedSnapshotId);
+
if (!completedBaseline || !sameEslintCheckBaseline(baseline, completedBaseline)) {
return {
status: "error",
@@ -976,7 +995,9 @@ export async function startReviewServer(options: {
message: "The reviewed diff changed while ESLint was running. Run the check again.",
};
}
+
if (response.status === "ok") eslintCheckCache = { baseline, response };
+
return response;
}
diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx
index cf19aa459..914b8cafc 100644
--- a/packages/review-editor/App.tsx
+++ b/packages/review-editor/App.tsx
@@ -200,15 +200,20 @@ const ReviewApp: React.FC = () => {
// on item version bumps) and early-declared callbacks read the CURRENT value
// at call time instead of a stale closure capture.
const isAllFilesActiveRef = useRef(isAllFilesActive);
+
isAllFilesActiveRef.current = isAllFilesActive;
+
const [isSemanticDiffActive, setIsSemanticDiffActive] = useState(false);
const [isEslintCheckActive, setIsEslintCheckActive] = useState(false);
const [isPROverviewActive, setIsPROverviewActive] = useState(false);
const [isPRArtifactsActive, setIsPRArtifactsActive] = useState(false);
+
const [semanticDiffAvailable, setSemanticDiffAvailable] = useState(false);
+
const [eslintCheckAdvert, setEslintCheckAdvert] = useState({ available: false });
const [showEslintConsent, setShowEslintConsent] = useState(false);
const eslintConsentGranted = useRef(false);
+
const [isDiffPanelActive, setIsDiffPanelActive] = useState(false);
const [allFilesVisibleFile, setAllFilesVisibleFile] = useState(null);
const [pendingSelection, setPendingSelection] = useState(null);
@@ -1157,11 +1162,14 @@ const ReviewApp: React.FC = () => {
const openEslintCheckPanel = useCallback(() => {
if (!dockApi || !eslintCheckAdvert.available) return;
+
const existing = dockApi.getPanel(REVIEW_ESLINT_CHECK_PANEL_ID);
+
if (existing) {
existing.api.setActive();
return;
}
+
dockApi.addPanel({
id: REVIEW_ESLINT_CHECK_PANEL_ID,
component: REVIEW_PANEL_TYPES.ESLINT_CHECK,
@@ -1174,12 +1182,15 @@ const ReviewApp: React.FC = () => {
openEslintCheckPanel();
return;
}
+
setShowEslintConsent(true);
}, [openEslintCheckPanel]);
const applyEslintCheckAdvert = useCallback((advert?: EslintCheckAdvert) => {
if (!advert) return;
+
setEslintCheckAdvert(advert);
+
if (!advert.available) {
dockApi?.getPanel(REVIEW_ESLINT_CHECK_PANEL_ID)?.api.close();
if (isEslintCheckActive) openAllFilesPanel();
@@ -2403,8 +2414,10 @@ const ReviewApp: React.FC = () => {
onSemanticDiffUnavailable: handleSemanticDiffUnavailable,
onSemanticDiffLoadError: handleSemanticDiffLoadError,
onSemanticDiffLoadSuccess: handleSemanticDiffLoadSuccess,
+
snapshotId: snapshotId ?? null,
eslintCheckAvailable: eslintCheckAdvert.available,
+
openTourPanel: handleOpenTour,
openGuide: handleOpenGuide,
onCodeNavRequest: canUseLiveWorkspaceActions ? handleCodeNavRequest : undefined,
@@ -2430,7 +2443,8 @@ const ReviewApp: React.FC = () => {
handleAskAI, handleAskAIForFile, handleViewAIResponse, handleClickAIMarker,
aiHistoryForSelection, getAIHistoryForFile, agentJobs.jobs, prMetadata, prContext, prArtifacts,
isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile,
- handleOpenTour, handleOpenGuide, isAllFilesActive, allFilesOrder, allFilesAllCollapsed, onToggleAllFilesCollapsed, registerAllFilesCollapseToggle, commitInfo, isSemanticDiffActive, semanticDiffAvailable, snapshotId, eslintCheckAdvert.available,
+ handleOpenTour, handleOpenGuide, isAllFilesActive, allFilesOrder, allFilesAllCollapsed, onToggleAllFilesCollapsed, registerAllFilesCollapseToggle, commitInfo, isSemanticDiffActive, semanticDiffAvailable,
+ snapshotId, eslintCheckAdvert.available,
handleSemanticDiffUnavailable, handleSemanticDiffLoadError, handleSemanticDiffLoadSuccess, handleAddAnnotationForFile,
handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol,
]);
@@ -3281,9 +3295,11 @@ const ReviewApp: React.FC = () => {
onSelectSemanticDiff={() => openSemanticDiffPanel()}
isSemanticDiffActive={isSemanticDiffActive}
semanticDiffAvailable={semanticDiffAvailable}
+
onSelectEslintCheck={eslintCheckAdvert.available ? requestEslintCheck : undefined}
isEslintCheckActive={isEslintCheckActive}
eslintCheckFileCount={eslintCheckAdvert.fileCount}
+
onCopyRawDiff={handleCopyDiff}
canCopyRawDiff={!!diffData?.rawPatch}
copyRawDiffStatus={copyRawDiffStatus}
@@ -3339,9 +3355,11 @@ const ReviewApp: React.FC = () => {
onSelectSemanticDiff={() => openSemanticDiffPanel()}
isSemanticDiffActive={isSemanticDiffActive}
semanticDiffAvailable={semanticDiffAvailable}
+
onSelectEslintCheck={eslintCheckAdvert.available ? requestEslintCheck : undefined}
isEslintCheckActive={isEslintCheckActive}
eslintCheckFileCount={eslintCheckAdvert.fileCount}
+
onSelectAllFiles={openAllFilesPanel}
isAllFilesActive={isAllFilesActive}
scrollHighlightIndex={isAllFilesActive && allFilesVisibleFile ? files.findIndex(f => f.path === allFilesVisibleFile) : undefined}
diff --git a/packages/review-editor/components/FileTree.tsx b/packages/review-editor/components/FileTree.tsx
index b3117993b..170bfcf29 100644
--- a/packages/review-editor/components/FileTree.tsx
+++ b/packages/review-editor/components/FileTree.tsx
@@ -81,9 +81,11 @@ interface FileTreeProps {
onSelectSemanticDiff?: () => void;
isSemanticDiffActive?: boolean;
semanticDiffAvailable?: boolean;
+
onSelectEslintCheck?: () => void;
isEslintCheckActive?: boolean;
eslintCheckFileCount?: number;
+
onSelectAllFiles?: () => void;
isAllFilesActive?: boolean;
scrollHighlightIndex?: number;
@@ -155,9 +157,11 @@ export const FileTree: React.FC = ({
onSelectSemanticDiff,
isSemanticDiffActive = false,
semanticDiffAvailable = false,
+
onSelectEslintCheck,
isEslintCheckActive = false,
eslintCheckFileCount,
+
onSelectAllFiles,
isAllFilesActive = false,
scrollHighlightIndex,
@@ -550,9 +554,15 @@ export const FileTree: React.FC = ({
{semanticDiffAvailable && onSelectSemanticDiff && (
)}
+
{onSelectEslintCheck && (
-
+
)}
+
{onSelectAllFiles && (
void;
isSemanticDiffActive?: boolean;
semanticDiffAvailable?: boolean;
+
onSelectEslintCheck?: () => void;
isEslintCheckActive?: boolean;
eslintCheckFileCount?: number;
+
/** Footer copy-diffs. */
onCopyRawDiff?: () => void;
canCopyRawDiff?: boolean;
@@ -192,9 +194,11 @@ export const SectionsPanel: React.FC = ({
onSelectSemanticDiff,
isSemanticDiffActive,
semanticDiffAvailable,
+
onSelectEslintCheck,
isEslintCheckActive,
eslintCheckFileCount,
+
onCopyRawDiff,
canCopyRawDiff,
copyRawDiffStatus = 'idle',
@@ -576,9 +580,15 @@ export const SectionsPanel: React.FC = ({
{semanticDiffAvailable && onSelectSemanticDiff && (
)}
+
{onSelectEslintCheck && (
-
+
)}
+
{onSelectAllFiles && (
void;
onSemanticDiffLoadError: () => boolean;
onSemanticDiffLoadSuccess: () => void;
+
snapshotId: string | null;
eslintCheckAvailable: boolean;
diff --git a/packages/review-editor/dock/panels/ReviewEslintCheckPanel.tsx b/packages/review-editor/dock/panels/ReviewEslintCheckPanel.tsx
index aa3a26313..ba56fe0c5 100644
--- a/packages/review-editor/dock/panels/ReviewEslintCheckPanel.tsx
+++ b/packages/review-editor/dock/panels/ReviewEslintCheckPanel.tsx
@@ -17,6 +17,7 @@ function DiagnosticRow({ diagnostic, onOpen }: { diagnostic: EslintDiagnostic; o
? 'bg-destructive/15 text-destructive'
: 'bg-warning/15 text-warning';
const severityLabel = isError ? 'Error' : 'Warning';
+
return (