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 ( + + + 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]) => ( +
+
{filePath}
+
+ {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 ( +
{groupedDiagnostics.map(([filePath, diagnostics]) => (
@@ -185,6 +196,7 @@ export function ReviewEslintCheckPanel() {
)} +
ESLint {eslintVersions.join(', ')} · {summary.files} {fileLabel} with findings
diff --git a/packages/server/review.ts b/packages/server/review.ts index b7513b212..ee8bfba65 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -808,16 +808,20 @@ export async function startReviewServer( 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, @@ -827,12 +831,14 @@ export async function startReviewServer( const getEslintCheckAdvert = (): EslintCheckAdvert => { const input = resolveEslintCheckInput(); + if (!input) { return { available: false, reason: eslintCheckUnavailableReason(), }; } + return getEslintCheckAvailability(input); }; @@ -846,23 +852,28 @@ export async function startReviewServer( 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 @@ -871,6 +882,7 @@ export async function startReviewServer( return null; } } + return { snapshotId: requestedSnapshotId, fingerprintGeneration: baselineGeneration, @@ -889,8 +901,10 @@ export async function startReviewServer( baseline: EslintCheckBaseline; response: EslintCheckResponse; } | null = null; + const getEslintCheck = async (requestedSnapshotId: string | undefined): Promise => { const baseline = await resolveEslintCheckBaseline(requestedSnapshotId); + if (!baseline) { return { status: "error", @@ -898,10 +912,13 @@ export async function startReviewServer( 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", @@ -913,8 +930,10 @@ export async function startReviewServer( : "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", @@ -922,7 +941,9 @@ export async function startReviewServer( 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/shared/eslint-check.test.ts b/packages/shared/eslint-check.test.ts index b2baee8da..0e48aa824 100644 --- a/packages/shared/eslint-check.test.ts +++ b/packages/shared/eslint-check.test.ts @@ -20,6 +20,7 @@ function makeRuntime(options: { 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", diff --git a/packages/shared/eslint-check.ts b/packages/shared/eslint-check.ts index c1495205d..630b5c912 100644 --- a/packages/shared/eslint-check.ts +++ b/packages/shared/eslint-check.ts @@ -57,10 +57,12 @@ export function isEslintCheckCompatibleReviewView(options: { }): 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" @@ -207,6 +209,7 @@ function defaultRunCommand( export function createDefaultEslintCheckRuntime(): EslintCheckRuntime { const runningUnderBun = (process.versions as Record).bun !== undefined; + return { fileExists: existsSync, readTextFile: (path) => readFileSync(path, "utf-8"), @@ -222,6 +225,7 @@ export function buildEslintCheckInput( workspaceRoots?: EslintWorkspaceRoot[], ): EslintCheckInput { const patchFiles = listPatchFiles(rawPatch); + if (!workspaceRoots?.length) { return { rawPatch, @@ -231,12 +235,16 @@ export function buildEslintCheckInput( 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); } @@ -246,17 +254,20 @@ export function buildEslintCheckInput( 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; @@ -267,20 +278,26 @@ function readJson(runtime: EslintCheckRuntime, path: string): Record 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; } @@ -290,8 +307,10 @@ function resolveEslintPackage( 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); @@ -302,6 +321,7 @@ function resolveEslintPackage( ? (bin as Record).eslint : "bin/eslint.js"; const entryPath = resolve(packageRoot, binPath); + if (isWithinRoot(packageRoot, entryPath) && runtime.fileExists(entryPath)) { return { entryPath, @@ -309,11 +329,15 @@ function resolveEslintPackage( }; } } + if (current === root) break; + const parent = dirname(current); if (parent === current) break; + current = parent; } + return null; } @@ -323,24 +347,30 @@ function prepareGroups(input: EslintCheckInput, runtime: EslintCheckRuntime): Pr 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; } @@ -357,10 +387,13 @@ export function getEslintCheckAvailability( (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), @@ -379,17 +412,22 @@ export function extractChangedLines(rawPatch: string): Map> 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; @@ -397,6 +435,7 @@ export function extractChangedLines(rawPatch: string): Map> newLine += 1; } } + result.set(path, changed); } @@ -418,23 +457,31 @@ function parseDiagnostics( } 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, @@ -449,6 +496,7 @@ function parseDiagnostics( }); } } + return diagnostics; } @@ -457,6 +505,7 @@ export async function runEslintCheck( runtime: EslintCheckRuntime = createDefaultEslintCheckRuntime(), ): Promise { const groups = prepareGroups(input, runtime); + if (groups.length === 0) { return { status: "unavailable", @@ -468,16 +517,20 @@ export async function runEslintCheck( 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." }; } @@ -496,7 +549,9 @@ export async function runEslintCheck( ...(command.stderr.trim() && { stderr: command.stderr.trim() }), }; } + const groupDiagnostics = parseDiagnostics(command.stdout, group, changedLines); + if (!groupDiagnostics) { return { status: "error", @@ -504,12 +559,14 @@ export async function runEslintCheck( 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); From ad64fef655f25fc0a6ee73ee773042f017b81a7c Mon Sep 17 00:00:00 2001 From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:25:47 -0300 Subject: [PATCH 3/4] Remove trailing blank line from ESLint types --- packages/shared/eslint-check-types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/eslint-check-types.ts b/packages/shared/eslint-check-types.ts index 9e8b99778..e92af56f4 100644 --- a/packages/shared/eslint-check-types.ts +++ b/packages/shared/eslint-check-types.ts @@ -53,4 +53,3 @@ export type EslintCheckResponse = | EslintCheckOkResponse | EslintCheckUnavailableResponse | EslintCheckErrorResponse; - From e79b354a4b2acb09220639e8fb312f4a24de9105 Mon Sep 17 00:00:00 2001 From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:14:56 -0300 Subject: [PATCH 4/4] Sync portable guide viewer manifest --- packages/core/guide-viewer-manifest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/guide-viewer-manifest.ts b/packages/core/guide-viewer-manifest.ts index 93f88b8e3..e39aeba70 100644 --- a/packages/core/guide-viewer-manifest.ts +++ b/packages/core/guide-viewer-manifest.ts @@ -6,9 +6,9 @@ import type { GuideViewerAssets } from "./guide-format"; export const GUIDE_VIEWER_MANIFEST: Omit = { js: "viewer.CpDlIFcA.js", - css: "viewer.BdruF6Mj.css", + css: "viewer.Dqu_Ysej.css", jsIntegrity: "sha384-AL8vNhcGQZd8DuYJQfpqGrNTVWBusWhMZEyeki9HMOj6ryXhrvqzPj7EuR7DOt9I", - cssIntegrity: "sha384-9i0z0HV8a5Hr0SAQt0+pUfQE96MTbGaCWtZlSzhk+HKIHXsqrGi16HQA4mlEWRvx", + cssIntegrity: "sha384-xQ2TobMyBzCj9wqeQkr0CzqZMVIKsKSp2qYLjGZx+vxrLGylRLBv4+jGOwHghfPr", langs: { "astro": "chunks/astro.BykyiR6i.js", "c": "chunks/c.BIGW1oBm.js",